Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions crates/stella-pipeline/src/management_prompt/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const SHARED_MANAGEMENT_PREAMBLE: &str = "";
/// family. Completeness is not compiler-checked here — that job belongs to
/// the exhaustive match in [`management_system_block`], which forces a new
/// variant to declare its prefix posture before this array matters.
const ALL_ROLES: [ModelCallRole; 14] = [
const ALL_ROLES: [ModelCallRole; 15] = [
ModelCallRole::Unknown,
ModelCallRole::Triage,
ModelCallRole::Plan,
Expand All @@ -51,6 +51,7 @@ const ALL_ROLES: [ModelCallRole; 14] = [
ModelCallRole::DomainInference,
ModelCallRole::Reflection,
ModelCallRole::Summarization,
ModelCallRole::Research,
];

/// The system block a role dispatches through the management chokepoint
Expand Down Expand Up @@ -82,15 +83,18 @@ fn management_system_block(role: ModelCallRole) -> Option<String> {
// adopt the split these arms move to `Some(...)` and the roles join
// the parity witness automatically.
ModelCallRole::Plan | ModelCallRole::PlanRepair => None,
// Never dispatched through the management chokepoint.
// Never dispatched through the management chokepoint. `Research`
// (#1778) rides the sub-agent primitive — its system prompt travels
// on the `SubAgentSpec`, not through `metered_raw_call`.
ModelCallRole::Unknown
| ModelCallRole::WitnessAuthor
| ModelCallRole::WitnessRepair
| ModelCallRole::AgentAuthor
| ModelCallRole::SkillAuthor
| ModelCallRole::DomainInference
| ModelCallRole::Reflection
| ModelCallRole::Summarization => None,
| ModelCallRole::Summarization
| ModelCallRole::Research => None,
}
}

Expand Down
14 changes: 7 additions & 7 deletions crates/stella-pipeline/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1454,16 +1454,16 @@ impl<'a> Pipeline<'a> {
// Stage: plan

/// `revision` is the reviewer's note from a rejected scope card, or `None`
/// for a turn's first plan.
/// for a turn's first plan. `spend` bundles budget + total as downstream
/// does: #1778's `research` param took the pair one over clippy's cap.
async fn plan_stage(
&self,
goal: &str,
recall: &[RecalledFrame],
research: &[ResearchFinding],
repo_structure: &str,
revision: Option<&str>,
budget: &mut BudgetGuard,
total: &mut f64,
spend: &mut Spend<'_>,
) -> Result<Vec<PlanStep>, PipelineBudgetAbort> {
self.emit(AgentEvent::Stage {
name: StageKind::Plan,
Expand Down Expand Up @@ -1491,8 +1491,8 @@ impl<'a> Pipeline<'a> {
overrides: &worker_overrides,
timeout: self.config.engine.model_timeout,
},
budget,
total,
spend.budget,
spend.total,
)
.await
{
Expand All @@ -1516,8 +1516,8 @@ impl<'a> Pipeline<'a> {
overrides: &worker_overrides,
timeout: self.config.engine.model_timeout,
},
budget,
total,
spend.budget,
spend.total,
)
.await
{
Expand Down
4 changes: 2 additions & 2 deletions crates/stella-pipeline/src/pipeline/scope_stage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ impl Pipeline<'_> {
let repo_structure = self.repo.structure_summary().await;
let mut revision: Option<String> = None;
let mut spent_revisions = 0usize;
let mut spend = Spend { budget, total };

loop {
let plan = match self
Expand All @@ -40,8 +41,7 @@ impl Pipeline<'_> {
research,
&repo_structure,
revision.as_deref(),
budget,
total,
&mut spend,
)
.await
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -453,8 +453,10 @@ async fn a_late_plan_is_abandoned_and_falls_back_to_the_single_step_plan() {
&[],
"",
None,
&mut budget,
&mut total,
&mut Spend {
budget: &mut budget,
total: &mut total,
},
)
.await
.expect("a wedged planner is never a run-ending failure");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
//! FlipHalt arming on the authored-witness path (#1793).
//! FlipHalt arming (#1793) — **both** witnesses and the doubles they share.
//!
//! The mid-turn early stop used to be armed only from a configured
//! `--test-command` baseline, so on the authored-witness path — the default
//! for every run without a configured command — a revision kept running to
//! its step and loop caps after the witness had already flipped. This pins
//! the repair: `witness_on_demand` arms the latch the moment the witness's
//! failing baseline is credited into the oracle, and the revision receives
//! it (unfired) through the same `run_engine_turn` seam the execute turn
//! uses.
//! its step and loop caps after the witness had already flipped. The repair:
//! `witness_on_demand` arms the latch the moment the witness's failing
//! baseline is credited into the oracle, and the revision receives it
//! (unfired) through the same `run_engine_turn` seam the execute turn uses.
//!
//! The two paths are pinned by two witnesses that differ only in where the
//! tracked command comes from, so they live together with `PassingShell` and
//! `shell_call_result` — the doubles both need — rather than reaching for
//! them across a module boundary. They were split apart once already, and the
//! parent's next wholesale rewrite deleted the configured-command witness and
//! both doubles without failing a gate: the crate had stopped compiling for
//! the missing doubles first, so nothing was left to notice the missing test.
//! Keeping the cluster in one file is what makes that clobber a merge
//! conflict instead of a silent deletion.

use super::*;

Expand Down Expand Up @@ -105,3 +114,125 @@ async fn an_authored_witness_arms_the_revision_flip_halt() {
flipped, not spend the scripted steps beyond it"
);
}
/// A shell double whose every command "passes": the output carries the
/// trailing exit-0 marker [`crate::flip_halt::exit_status`] parses. What
/// [`EmptyTools`] can never express — a worker *observing* the tracked test
/// succeed through a tool result.
struct PassingShell;
#[async_trait]
impl ToolExecutor for PassingShell {
fn schemas(&self) -> Vec<ToolSchema> {
vec![ToolSchema {
name: "bash".into(),
description: "run a shell command".into(),
input_schema: serde_json::json!({ "type": "object" }),
read_only: false,
speculation_safe: false,
}]
}
async fn execute(&self, _name: &str, _input: &Value) -> ToolOutput {
ToolOutput::Ok {
content: "1 passed\n[exit code: 0]".into(),
}
}
}

/// A completion that runs `command` through the shell — the observation the
/// flip halt correlates by `call_id` and scores against the tracked test.
fn shell_call_result(command: &str) -> CompletionResult {
CompletionResult {
tool_calls: vec![ToolCall {
call_id: format!("call-shell-{command}"),
name: "bash".into(),
input: serde_json::json!({ "command": command }),
}],
..text_result("")
}
}

/// #1793 witness (configured-command side): a revision that observes the
/// tracked test go fail→pass halts at that step boundary instead of running
/// on. The provider is scripted with steps BEYOND the flip; consuming them
/// is exactly the waste `flip_halt` exists to stop, so the call count is the
/// assertion.
#[tokio::test]
async fn a_revision_halts_at_the_step_where_the_tracked_test_flips() {
let provider = ScriptedProvider::new(vec![
text_result("single"),
// Execute turn: acts, but the suite still fails afterwards.
text_result("done"),
// Revision, step 1: re-run the tracked test — it now passes.
shell_call_result("cargo test -p x"),
// Steps the revision would burn WITHOUT the halt. They must never be
// consumed: the goal was met at the step above.
shell_call_result("cargo test -p x"),
text_result("revision done"),
]);
let resolver = OneProvider(&provider);
// Baseline fails (arms the halt), post-execute fails (forces the
// revision), post-revise passes (the flip), confirmation passes (#859).
let runner = ScriptedRunner::scripted(
vec![
TestScript::Fail,
TestScript::Fail,
TestScript::Pass,
TestScript::Pass,
],
"@@ -1 +1 @@\n-old\n+new",
);
let tools = PassingShell;
let recall = NoContextRecall;
let repo = NoRepoStructure;
let repo_status = NoRepoStatus;
let approvals = AutoApproveGate;
let sleeper = NoopSleeper;
let router = router();
let (tx, _rx) = mpsc::unbounded_channel();

let config = PipelineConfig {
test_command: Some("cargo test -p x".into()),
diff_diagnostic: Some(DiagnosticInvocation::GitDiff),
..PipelineConfig::default()
};
let pipeline = Pipeline::new(
PipelinePorts {
router: &router,
providers: &resolver,
tools: &tools,
recall: &recall,
repo: &repo,
repo_status: &repo_status,
touches: &NoFileTouches,
diagnostics: &runner,
tests: &runner,
lint: None,
mutation: None,
coverage: None,
approvals: &approvals,
sleeper: &sleeper,
hooks: None,
candidate_workspaces: None,
mcp_prefetch: None,
steering: None,
},
tx,
config,
);

let mut messages = vec![CompletionMessage::system("sys")];
let mut budget = BudgetGuard::new(BudgetMode::Off, None, None);
let outcome = pipeline
.run("Fix the failing test", &mut messages, &mut budget)
.await
.expect("run succeeds");

let verdict = outcome.verdict.expect("a verdict was produced");
assert!(verdict.passed, "the flip was confirmed: {verdict:?}");
assert_eq!(
provider.prompts().len(),
3,
"triage, execute, one revision step — the revision must halt at the \
boundary where the tracked test flipped, not spend the scripted \
steps beyond it"
);
}
Loading