From 057810c0c348734e1980356345d9fdbb9d581f8f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 01:11:47 +0000 Subject: [PATCH 1/5] fix(stella-pipeline,stella-cli): three defects costing Terminal-Bench solve rate, wall clock and tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent fixes, each on a path every benchmark task takes. **#1787 — a verifier that thinks before it answers parsed as no answer.** `parse_verifier_response` consulted only the first non-empty line, so a reply opening with "Here is my assessment:" carried no verdict token there and returned `None`. `None` is the signal for `heuristic_fallback`, which passes only on an observed flip or green touched tests and otherwise FAILS — so this did not degrade one verdict, it converted *every* verdict from a preambling model into a heuristic that mostly refuses. Reasoning models preamble by construction, which makes it a whole model family the verifier could not grade with. Two positions are now authoritative, tried in order: the reply's first non-empty line, then its last. Deliberately two and not a scan of the body — intermediate prose is where a verifier *discusses* failing tests, and reading it would reintroduce the misread the token set was narrowed to prevent. The head still wins outright, so every reply that leads with its verdict parses exactly as before. **#1793 — the measured early stop never applied to a revision.** `FlipHalt` (built from a Terminal-Bench regression: a correct solution, then the rest of the wall clock spent re-litigating whether the grader could see it) armed only from a configured `--test-command` baseline. An authored witness reaches the same precondition — an observed FAILING baseline — but in `witness_on_demand`, which never armed it. And `revise_turn` passed `None` unconditionally regardless. Both fixed, and the second is narrower than it first looks. A revision inherits the halt only while the oracle reads `FlipState::Failing`. A verifier can reject a candidate whose witness flipped — the test went green but the review found the change wanting — and arming there would end the revision the first time the model re-ran that passing test, before it addressed anything the verifier objected to. `Flipped` and `Unstable` are both excluded because a pass has been seen and a halt cannot tell a reproducible one from a flake; the sticky latch is refused for the same reason. **#1595 — the default `stella run` estimated worst.** Five of stella-cli's seven assembly sites seeded a `CalibrationMap` and handed it to the engine. The two that did not were `run_pipeline_one_shot` — which *is* `stella run` — and fleet workers, because `Pipeline` had no way to accept one. The cost is larger than a lost seed: with no map at all the correction is inert for the whole run, so the drift those turns measured was never applied to them either. `Pipeline` gains `with_calibration`, borrowed rather than owned because `CalibrationMap` is deliberately not `Clone` (an owned field would also cost `PipelineConfig` its `Clone`). It attaches to all four engines the pipeline builds — the worker's turns, the best-of-N children, and the witness author/repair — via one `attach` helper, because seeding three of four would reproduce this issue's exact shape one level down. Refs #1787 (item 1 narrowed: the token protocol is now preamble-tolerant; the forced structured-output posture remains, and item 3 is untouched) Closes #1793 Closes #1595 ## Witnesses - `a_verifier_that_preambles_is_read_at_its_conclusion` and `intermediate_prose_never_decides_a_verdict` — both fail on main (verified by reverting verify.rs alone and re-running). - `a_revision_inherits_the_halt_while_the_tracked_command_is_still_red` and `a_revision_on_a_green_or_flaky_command_is_never_halted`. - `the_pipeline_path_sizes_its_budget_with_the_callers_calibration` and `a_pipeline_lent_no_calibration_reports_the_identity_factor`, reading the factor off each engine step's `StepManifest` (`call_seq == 0`; management roles ride the same step and have no compaction budget). ## File-size baseline `agent.rs` +3 and `fleet_cmd.rs` +3 are the call-site wiring — the seed and the lend, which need the store, the config and the pipeline all in scope and so cannot live elsewhere. `pipeline/tests.rs` +1 is a `mod` declaration. Against that, `pipeline.rs` ratchets DOWN 11 lines: `with_turn_gate` moved to the new `pipeline/attachments.rs` beside `with_calibration`, and the repeated gate-attachment blocks collapsed into `attach`. ## Left behind Filed rather than fixed: #1948 (the verdict reasoning is bounded twice, in different units — a multi-byte reply is truncated to a third of the cap), #1949 (no end-to-end witness that the authored-witness path arms the halt; the `turn.halt_on_goal_met` parity row stays `ShippedUnwitnessed`), #1950 (no CLI-side witness that the two call sites do the lending; `calibration.drift` stays `ShippedUnwitnessed`). #1787's forced structured-output posture — a provider-parity declaration per invariant 8 — is deliberately NOT in this change and is the larger remaining half of that issue. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R6sipVC9LGErHS5Sw93Ufb --- crates/stella-cli/src/agent.rs | 5 +- crates/stella-cli/src/fleet_cmd.rs | 5 +- crates/stella-parity/src/lib.rs | 15 +- crates/stella-pipeline/src/flip_halt.rs | 88 ++++++++++- crates/stella-pipeline/src/pipeline.rs | 45 +++--- .../src/pipeline/attachments.rs | 74 +++++++++ .../src/pipeline/fanout_stage.rs | 4 +- crates/stella-pipeline/src/pipeline/tests.rs | 1 + .../src/pipeline/tests/calibration.rs | 143 ++++++++++++++++++ .../src/pipeline/witness_stage.rs | 16 +- crates/stella-pipeline/src/verify.rs | 91 ++++++----- .../stella-pipeline/src/verify/tests/parse.rs | 63 +++++++- scripts/file-size-baseline.txt | 12 +- 13 files changed, 471 insertions(+), 91 deletions(-) create mode 100644 crates/stella-pipeline/src/pipeline/attachments.rs create mode 100644 crates/stella-pipeline/src/pipeline/tests/calibration.rs diff --git a/crates/stella-cli/src/agent.rs b/crates/stella-cli/src/agent.rs index 1868ec4d3..35043850a 100644 --- a/crates/stella-cli/src/agent.rs +++ b/crates/stella-cli/src/agent.rs @@ -272,6 +272,8 @@ async fn run_pipeline_one_shot( }; let custom_tools = discover_custom_tools(cfg, format == OutputFormat::Text).await; let store = open_store(&cfg.workspace_root); + // Owned here so it outlives every engine the pipeline builds below (#1595). + let calibration = seed_calibration(&store, cfg); if format == OutputFormat::Text { tui::section_header("Stella (pipeline)"); @@ -453,7 +455,8 @@ async fn run_pipeline_one_shot( }; let events = pipeline_event_sender(&tx, format); - let pipeline = resume_frame::pipeline(&cfg.durability, ports, events, pipeline_config); + let pipeline = resume_frame::pipeline(&cfg.durability, ports, events, pipeline_config) + .with_calibration(&calibration); pipeline.run(prompt, &mut messages, &mut budget).await }; diff --git a/crates/stella-cli/src/fleet_cmd.rs b/crates/stella-cli/src/fleet_cmd.rs index 31a424e8a..597361245 100644 --- a/crates/stella-cli/src/fleet_cmd.rs +++ b/crates/stella-cli/src/fleet_cmd.rs @@ -707,6 +707,8 @@ async fn run_task( // a one-shot or deck turn. The store is rooted in the task worktree so // parallel workers never contend on a single SQLite writer. let store = agent::open_store(root); + // Owned above the pipeline so it outlives every engine it builds (#1595). + let calibration = agent::seed_calibration(&store, &cfg); let execution = agent::begin_execution(&store, "fleet", &task.prompt, &cfg, None); // From here on this attempt's spend is durable in the store even if this // thread never lives to report it — publish the handle that makes it @@ -879,7 +881,8 @@ async fn run_task( ); let pipeline = crate::resume_frame::pipeline(&cfg.durability, ports, tx.clone(), config) - .with_turn_gate(gate.as_ref()); + .with_turn_gate(gate.as_ref()) + .with_calibration(&calibration); // The system prompt + task prompt are already in `messages`; the // pipeline appends its own volatile recall+goal message, so pass the // raw task prompt as the goal (the pipeline never re-reads `messages` diff --git a/crates/stella-parity/src/lib.rs b/crates/stella-parity/src/lib.rs index 665d30356..b3759591a 100644 --- a/crates/stella-parity/src/lib.rs +++ b/crates/stella-parity/src/lib.rs @@ -422,14 +422,17 @@ pub static CAPABILITIES: &[Capability] = &[ engine_home: "stella-core estimator CalibrationMap: per-model token-drift correction feeding compaction", engine_entries: &["with_calibration"], cli: SurfacePosture::ShippedUnwitnessed { - mechanism: "seed_calibration from the store plus with_calibration on the interactive, \ - raw one-shot, goal, deck and sub-session paths — NOT on the default \ - `stella run` staged-pipeline path or on fleet workers (#1595)", + mechanism: "seed_calibration from the store plus with_calibration on all seven \ + assembly sites: the interactive, raw one-shot, goal, deck and \ + sub-session paths hand it to the engine directly, and the default \ + `stella run` staged-pipeline path and fleet workers lend it to the \ + Pipeline, which attaches it to every engine it builds (#1595)", missing: "a CLI-side test pinning that persisted drift samples reach the engine's \ calibration on session start (stella-core and stella-store each test their \ - half; the CLI seam between them has no witness) — and the same test would \ - have caught the two paths above, which is why this row is unwitnessed and \ - wrong at the same time", + half; the CLI seam between them has no witness). The pipeline half of the \ + #1595 gap now has one — `the_pipeline_path_sizes_its_budget_with_the_\ + callers_calibration` proves a lent map reaches the engines — but nothing \ + yet proves `run_pipeline_one_shot` and the fleet worker do the lending", }, api: SurfacePosture::Shipped { mechanism: "a process-lifetime CalibrationMap per provider_id, fed by every \ diff --git a/crates/stella-pipeline/src/flip_halt.rs b/crates/stella-pipeline/src/flip_halt.rs index 630abef96..4bc7c0301 100644 --- a/crates/stella-pipeline/src/flip_halt.rs +++ b/crates/stella-pipeline/src/flip_halt.rs @@ -43,11 +43,12 @@ //! the work is credited is a separate question with its own, stricter, //! machinery. +use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use stella_core::driver::TurnHalt; -use crate::verify::normalize_command; +use crate::verify::{FlipState, normalize_command}; /// The marker the bash tool appends to a command's output. /// @@ -146,6 +147,41 @@ impl FlipHalt { } } +/// The halt to hand a *revision* turn, given the one this candidate armed and +/// what its oracle currently believes about the tracked command. +/// +/// A revision halts on a flip for the same reason an execute turn does: the +/// tracked command going green is the goal being met, and what follows is the +/// re-litigation this module's header measured. Revise turns used to pass +/// `None` unconditionally, so the measured fix never applied to them (#1793). +/// +/// Handed over only while `tracked` is [`FlipState::Failing`], which is the +/// same precondition `run_candidate` arms from and is doing real work here +/// rather than restating it. A revision is entered when the *verifier* +/// rejected the candidate, and that happens on a flipped command too — the +/// witness went green but the review found the change wanting. Arming then +/// would end the revision at its first step boundary the moment the model +/// re-ran an already-passing test, before it addressed a single thing the +/// verifier objected to. Only `Failing` means the flip this halt watches for +/// is still ahead of the turn rather than behind it. +/// +/// [`FlipState::Flipped`] and [`FlipState::Unstable`] are both excluded, for +/// one reason: a pass has been seen. `Unstable` is the weaker case — the pass +/// did not reproduce — but a halt cannot tell a reproducible pass from a flaky +/// one, and stopping a revision on a flake is the failure the confirmation +/// re-run (#859) exists to prevent downstream. +/// +/// The sticky latch is refused too. It survives the turn that fired it, so an +/// already-flipped halt would stop the next revision instantly even if the +/// oracle disagreed about the command's state. +#[must_use] +pub fn for_revision(armed: &Option>, tracked: FlipState) -> Option> { + if tracked != FlipState::Failing { + return None; + } + armed.as_ref().filter(|halt| !halt.is_flipped()).cloned() +} + impl TurnHalt for FlipHalt { fn halt_reason(&self) -> Option { self.is_flipped().then(|| { @@ -231,4 +267,54 @@ mod tests { let unrelated = serde_json::json!({"path": "src/lib.rs"}); assert_eq!(command_of(&unrelated), None); } + + /// **Witness (#1793).** A revise turn carries the halt while the tracked + /// command is still red. + /// + /// Revise turns passed `None` unconditionally, so the measured early stop + /// never applied to a revision — free wall clock and tokens after the work + /// was done. + #[test] + fn a_revision_inherits_the_halt_while_the_tracked_command_is_still_red() { + assert!( + for_revision(&None, FlipState::Failing).is_none(), + "nothing to watch stays nothing to watch" + ); + + let armed = Some(Arc::new(FlipHalt::new("pytest -q"))); + let inherited = for_revision(&armed, FlipState::Failing) + .expect("a red command's halt carries into the revision"); + assert_eq!(inherited.tracked(), "pytest -q"); + assert!(Arc::ptr_eq(&inherited, armed.as_ref().unwrap())); + } + + /// The half that would silently break revision if it were wrong: a + /// revision entered on an ALREADY-GREEN command gets no halt. + /// + /// A verifier can reject a candidate whose witness flipped — the test went + /// green but the review found the change wanting. Arming there would end + /// the revision the first time the model re-ran that passing test, before + /// it addressed anything the verifier objected to. `Unstable` is excluded + /// for the same reason with less margin: a pass was seen, and a halt + /// cannot tell a reproducible one from a flake. + #[test] + fn a_revision_on_a_green_or_flaky_command_is_never_halted() { + let armed = Some(Arc::new(FlipHalt::new("pytest -q"))); + for state in [FlipState::Flipped, FlipState::Unstable, FlipState::None] { + assert!( + for_revision(&armed, state).is_none(), + "{state:?} means the flip is behind the turn, not ahead of it" + ); + } + + // The sticky latch is refused even when the oracle still reads red: it + // survives the turn that fired it. + assert!( + armed + .as_ref() + .unwrap() + .observe("pytest -q", "ok\n[exit code: 0]") + ); + assert!(for_revision(&armed, FlipState::Failing).is_none()); + } } diff --git a/crates/stella-pipeline/src/pipeline.rs b/crates/stella-pipeline/src/pipeline.rs index 75af7d4ab..b9a595c69 100644 --- a/crates/stella-pipeline/src/pipeline.rs +++ b/crates/stella-pipeline/src/pipeline.rs @@ -53,7 +53,9 @@ use stella_core::hooks::{HookRunner, Hooks}; use stella_core::receipts::RECEIPT_SEQ_ALLOCATED_BASE; use stella_core::retry::{RetryPolicy, Sleeper}; use stella_core::router::FallbackInfo; -use stella_core::{AbortKind, BudgetGuard, Engine, EngineConfig, EventSender, Router, TurnOutcome}; +use stella_core::{ + AbortKind, BudgetGuard, CalibrationMap, Engine, EngineConfig, EventSender, Router, TurnOutcome, +}; use stella_protocol::{ AgentEvent, CompletionMessage, LadderRung, LadderSnapshot, MessageRole, ModelCallRole, ModelRef, OracleObservation, ProofStep, ProofTree, Provider, Role, StageKind, VerdictEvidence, @@ -106,6 +108,7 @@ use crate::witness::{ validate_witness_identity, validate_witness_invocation, witness_identity_matches, witness_prompt, witness_repair_prompt, }; +mod attachments; mod authored; mod candidate_result; mod disclosure; @@ -692,13 +695,13 @@ struct CandidateState { /// warrant (the #1701 recurrence a projection method used to guard /// against by hand). signals: ChangeSignals, - /// Ends the execute turn as soon as the tracked test goes fail→pass. - /// - /// `None` whenever there is nothing to watch: no configured test command, - /// or a baseline that was already passing (which can never flip, so a - /// halt on it would end turns that had work left). See - /// [`crate::flip_halt`] for why stopping is a separate question from - /// crediting. + /// Ends a turn as soon as the tracked test goes fail→pass, armed from + /// whichever failing baseline this candidate observed: a configured + /// command's, below, or an authored witness's (#1793). `None` while there + /// is nothing to watch — no command, or a baseline already passing, which + /// can never flip. See [`crate::flip_halt`] for why stopping is a separate + /// question from crediting, and [`crate::flip_halt::for_revision`] for the + /// narrower rule a revise turn follows. flip_halt: Option>, oracle: FlipOracle, /// The oracle's observations in the order they were made (#864) — @@ -864,6 +867,9 @@ pub struct Pipeline<'a> { /// engine this pipeline builds and consulted before every management /// call, so a paused pipeline-driven worker parks instead of spending. turn_gate: Option<&'a dyn stella_core::ports::TurnGate>, + /// Caller-owned token-drift model ([`Pipeline::with_calibration`]), lent to + /// every engine this pipeline builds. `None` leaves estimation uncorrected. + calibration: Option<&'a CalibrationMap>, events: EventSender, config: PipelineConfig, configured_test: Result, crate::witness::TestInvocationError>, @@ -928,6 +934,7 @@ impl<'a> Pipeline<'a> { mcp_prefetch: ports.mcp_prefetch, steering: ports.steering, turn_gate: None, + calibration: None, events: events.into(), config, configured_test, @@ -939,22 +946,6 @@ impl<'a> Pipeline<'a> { } } - /// Attach a boundary pause gate. Every engine the pipeline builds — the - /// worker's execute/revise turns and the witness author's — parks at its - /// step boundaries while the gate holds, and every management call - /// (triage, verifier, guidance) parks before dispatch: the same safe - /// boundary as budget aborts, never mid-tool. - /// - /// This is the seam that lets a supervisor's pause reach a - /// pipeline-driven worker at all. Without it only the raw step-loop path - /// held a gate, so `Fleet::pause_task` on a pipeline worker silently did - /// nothing — the named follow-up in `fleet_cmd`. - #[must_use] - pub fn with_turn_gate(mut self, gate: &'a dyn stella_core::ports::TurnGate) -> Self { - self.turn_gate = Some(gate); - self - } - /// Drive one prompt through the full staged flow. `messages` is the /// caller-owned history: seed it with the stable system prefix (the cached /// prompt prefix, L-E8); the pipeline appends the volatile recall+goal @@ -1708,9 +1699,7 @@ impl<'a> Pipeline<'a> { if let Some((hooks, runner)) = self.hooks { engine = engine.with_hooks(hooks, runner); } - if let Some(gate) = self.turn_gate { - engine = engine.with_gate(gate); - } + engine = self.attach(engine); let view = fan.as_ref().map(|fan| fan.candidate()); if let Some(view) = view.as_ref() { engine = engine.with_steering(view); @@ -2960,7 +2949,7 @@ impl<'a> Pipeline<'a> { &mut state.messages, spend.budget, &mut state.signals, - None, + crate::flip_halt::for_revision(&state.flip_halt, state.oracle.state()), ) .await { diff --git a/crates/stella-pipeline/src/pipeline/attachments.rs b/crates/stella-pipeline/src/pipeline/attachments.rs new file mode 100644 index 000000000..1877866b7 --- /dev/null +++ b/crates/stella-pipeline/src/pipeline/attachments.rs @@ -0,0 +1,74 @@ +//! The optional things a caller bolts onto a [`Pipeline`] after construction, +//! and the rule that every engine the pipeline builds inherits them. +//! +//! Separate from the ports in [`PipelinePorts`] because these are not +//! capabilities the pipeline needs in order to run — it runs without any of +//! them. They are host-owned state that outlives a turn (a supervisor's pause +//! flag, the session's accumulated token-drift model), which is why each is a +//! borrow with the pipeline's own lifetime rather than an owned field: the +//! caller keeps it across turns and lends it in, mirroring how `BudgetGuard` +//! and `CalibrationMap` are owned above the engine rather than by it. +//! +//! Split out of `pipeline.rs` for the reason everything is: it is a +//! grandfathered god file closed to growth, and this is a coherent seam to +//! take with it. + +use super::*; + +impl<'a> Pipeline<'a> { + /// Attach a boundary pause gate. Every engine the pipeline builds — the + /// worker's execute/revise turns and the witness author's — parks at its + /// step boundaries while the gate holds, and every management call + /// (triage, verifier, guidance) parks before dispatch: the same safe + /// boundary as budget aborts, never mid-tool. + /// + /// This is the seam that lets a supervisor's pause reach a + /// pipeline-driven worker at all. Without it only the raw step-loop path + /// held a gate, so `Fleet::pause_task` on a pipeline worker silently did + /// nothing — the named follow-up in `fleet_cmd`. + #[must_use] + pub fn with_turn_gate(mut self, gate: &'a dyn stella_core::ports::TurnGate) -> Self { + self.turn_gate = Some(gate); + self + } + + /// Attach the caller's token-drift calibration, so the engines this + /// pipeline builds size their compaction budget against what this model's + /// tokenizer actually charged rather than against the raw estimate + /// (#1595). + /// + /// Five of `stella-cli`'s seven assembly sites seeded a `CalibrationMap` + /// and handed it to the engine; the two that did not were the **default + /// `stella run`** path and fleet workers, because a pipeline had no way to + /// accept one. So the most-used path in the product was the one estimating + /// worst — and not only by losing the seed: with no map at all the + /// correction is inert for the whole run, so the drift those turns + /// measured was never applied to them either. + /// + /// Borrowed rather than owned because `CalibrationMap` is deliberately not + /// `Clone` — the caller owns it across turns and the engine reads it + /// through `&self` (see its type doc). An owned field here would also cost + /// `PipelineConfig` its `Clone`, which many call sites rely on. + #[must_use] + pub fn with_calibration(mut self, calibration: &'a CalibrationMap) -> Self { + self.calibration = Some(calibration); + self + } + + /// Bolt this pipeline's attachments onto one freshly-built engine. + /// + /// Every engine the pipeline constructs goes through here, which is the + /// point: the worker's turns, the best-of-N children and the witness + /// author are all engines, and attaching to some but not others is how + /// #1595 happened one level up. A new attachment is added once, here, + /// rather than at each construction site. + pub(super) fn attach<'e>(&'e self, mut engine: Engine<'e>) -> Engine<'e> { + if let Some(gate) = self.turn_gate { + engine = engine.with_gate(gate); + } + if let Some(calibration) = self.calibration { + engine = engine.with_calibration(calibration); + } + engine + } +} diff --git a/crates/stella-pipeline/src/pipeline/fanout_stage.rs b/crates/stella-pipeline/src/pipeline/fanout_stage.rs index 307d0b7c0..522bb94fe 100644 --- a/crates/stella-pipeline/src/pipeline/fanout_stage.rs +++ b/crates/stella-pipeline/src/pipeline/fanout_stage.rs @@ -279,9 +279,7 @@ impl<'a> Pipeline<'a> { if let Some((hooks, runner)) = self.hooks { engine = engine.with_hooks(hooks, surface.hook_runner.unwrap_or(runner)); } - if let Some(gate) = self.turn_gate { - engine = engine.with_gate(gate); - } + engine = self.attach(engine); let view = fan.map(SteeringFanOut::candidate); if let Some(view) = view.as_ref() { engine = engine.with_steering(view); diff --git a/crates/stella-pipeline/src/pipeline/tests.rs b/crates/stella-pipeline/src/pipeline/tests.rs index be2612741..acb7a85e6 100644 --- a/crates/stella-pipeline/src/pipeline/tests.rs +++ b/crates/stella-pipeline/src/pipeline/tests.rs @@ -3,6 +3,7 @@ //! private surface (`CandidateSurface`, `Pipeline::gather_diff`, ...) //! stays reachable via `super::*`. +mod calibration; mod conversational_window; mod management_accounting; mod telemetry; diff --git a/crates/stella-pipeline/src/pipeline/tests/calibration.rs b/crates/stella-pipeline/src/pipeline/tests/calibration.rs new file mode 100644 index 000000000..e4b6be2d6 --- /dev/null +++ b/crates/stella-pipeline/src/pipeline/tests/calibration.rs @@ -0,0 +1,143 @@ +//! Token-drift calibration reaching the engines the pipeline builds (#1595). +//! +//! Its own module because the subject is a *wiring* fact, not a decision: the +//! arithmetic is `stella-core`'s and is tested there. What is tested here is +//! that the default `stella run` path hands the engine a calibration at all — +//! five of `stella-cli`'s seven assembly sites did and the two behind the +//! pipeline could not, because a pipeline had no way to accept one. + +use super::*; + +use stella_core::CalibrationMap; + +/// Samples with a constant additive overhead, spanning enough sizes for the +/// fit to separate the overhead from the proportional error. Deliberately the +/// shape `stella-core`'s own witness uses: an ~8k per-request schema block on +/// top of an otherwise accurate estimate. +fn warmed() -> CalibrationMap { + let calibration = CalibrationMap::new(); + calibration.seed( + "worker", + &[(4_000, 12_000), (40_000, 48_000), (100_000, 108_000)], + ); + calibration +} + +/// The factor each ENGINE step reports having sized its budget with. +/// +/// `call_seq == 0` is the engine's own worker call; the pipeline's management +/// roles ride the same step at 1, 2, … and are excluded deliberately. A raw +/// management call has no transcript to compact and so no compaction budget — +/// its manifest carries the identity factor whatever the engines were lent, +/// and counting it would make this assert something untrue of the seam. +fn factors(events: &[AgentEvent]) -> Vec { + events + .iter() + .filter_map(|e| match e { + AgentEvent::StepManifest { + call_seq: 0, + calibration_factor, + .. + } => Some(*calibration_factor), + _ => None, + }) + .collect() +} + +/// Drive one scripted single-task run, optionally lending it a calibration, +/// and return the events it emitted. +async fn run_with(calibration: Option<&CalibrationMap>) -> Vec { + let provider = ScriptedProvider::new(vec![text_result("single"), text_result("done")]); + let resolver = OneProvider(&provider); + let runner = ScriptedRunner::new(vec![false, true], "@@ -1 +1 @@\n-old\n+new"); + let tools = EmptyTools; + let recall = NoContextRecall; + let repo = NoRepoStructure; + let repo_status = NoRepoStatus; + let approvals = AutoApproveGate; + let sleeper = NoopSleeper; + let router = router(); + let (tx, mut rx) = mpsc::unbounded_channel(); + + let config = PipelineConfig { + test_command: Some("cargo test -p x".into()), + diff_diagnostic: Some(DiagnosticInvocation::GitDiff), + ..PipelineConfig::default() + }; + let mut 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, + ); + if let Some(calibration) = calibration { + pipeline = pipeline.with_calibration(calibration); + } + + let mut messages = vec![CompletionMessage::system("sys")]; + let mut budget = BudgetGuard::new(BudgetMode::Off, None, None); + pipeline + .run("Fix the failing test", &mut messages, &mut budget) + .await + .expect("run succeeds"); + drain(&mut rx) +} + +/// **Witness (#1595).** The engines behind the default `stella run` size their +/// compaction budget against measured drift. +/// +/// `stella-cli` assembles a session at seven call sites. Five seeded a +/// `CalibrationMap` from the store and handed it to the engine; the two behind +/// the pipeline — `run_pipeline_one_shot`, which *is* `stella run`, and fleet +/// workers — could not, because `Pipeline` had no way to accept one. So the +/// most-used path in the product was the only one estimating uncorrected. +/// +/// Note what the gap cost beyond the seed: with no map at all the correction +/// is inert for the whole run, so the drift those turns *measured* was never +/// applied to them either. This asserts the correction is live, not that any +/// particular number came out of it — the arithmetic is `stella-core`'s and is +/// witnessed there. +#[tokio::test] +async fn the_pipeline_path_sizes_its_budget_with_the_callers_calibration() { + let calibration = warmed(); + let corrected = factors(&run_with(Some(&calibration)).await); + assert!( + !corrected.is_empty(), + "the run must report at least one step manifest to read a factor from" + ); + assert!( + corrected.iter().all(|f| (f - 1.0).abs() > f64::EPSILON), + "every step must size its budget with the lent calibration, got {corrected:?}" + ); +} + +/// The other half, and the reason the assertion above is meaningful: a +/// pipeline lent nothing corrects nothing. `1.0` is the identity factor — +/// what every pipeline-driven turn reported before this wiring existed. +#[tokio::test] +async fn a_pipeline_lent_no_calibration_reports_the_identity_factor() { + let uncorrected = factors(&run_with(None).await); + assert!(!uncorrected.is_empty()); + assert!( + uncorrected.iter().all(|f| (f - 1.0).abs() < f64::EPSILON), + "an unlent pipeline must not invent a correction, got {uncorrected:?}" + ); +} diff --git a/crates/stella-pipeline/src/pipeline/witness_stage.rs b/crates/stella-pipeline/src/pipeline/witness_stage.rs index c388b2b1f..757e0301f 100644 --- a/crates/stella-pipeline/src/pipeline/witness_stage.rs +++ b/crates/stella-pipeline/src/pipeline/witness_stage.rs @@ -296,9 +296,7 @@ impl<'a> Pipeline<'a> { self.sleeper, ) .with_call_role(stella_protocol::ModelCallRole::WitnessAuthor); - if let Some(gate) = self.turn_gate { - engine = engine.with_gate(gate); - } + engine = self.attach(engine); let mut messages = vec![ CompletionMessage::system(WITNESS_SYSTEM_PROMPT), @@ -412,9 +410,7 @@ impl<'a> Pipeline<'a> { self.sleeper, ) .with_call_role(stella_protocol::ModelCallRole::WitnessRepair); - if let Some(gate) = self.turn_gate { - repair_engine = repair_engine.with_gate(gate); - } + repair_engine = self.attach(repair_engine); let repaired = match self .run_engine_turn( &repair_engine, @@ -661,6 +657,14 @@ impl<'a> Pipeline<'a> { state .oracle .observe_run(&witness.command, false, &witness.baseline_output); + // Same observed fact, second consumer (#1793). `run_candidate` arms the + // mid-turn halt from a configured command whose baseline FAILED; an + // authored witness reaches that same precondition here instead, because + // it does not exist until after execution. Arming was missing on this + // path entirely, so every later revise turn ran to its step/loop caps + // even once the witness had flipped — the wall-clock burn + // [`crate::flip_halt`]'s module doc measured on Terminal-Bench. + state.flip_halt = Some(Arc::new(FlipHalt::new(&witness.command))); // The graft added an untracked file after the pre-execution snapshot // was taken. Enrolling it as pre-existing keeps every later diff // gather (each revision re-gathers) from reading the scaffolding as diff --git a/crates/stella-pipeline/src/verify.rs b/crates/stella-pipeline/src/verify.rs index 3e51d0b54..4970395fe 100644 --- a/crates/stella-pipeline/src/verify.rs +++ b/crates/stella-pipeline/src/verify.rs @@ -870,10 +870,24 @@ fn bounded_reasoning(text: &str) -> String { /// token appears — the signal the caller uses to invoke the /// [`heuristic_fallback`] verdict rather than trusting an unparseable /// verifier response. +/// +/// Two lines are authoritative, tried in order: the reply's **first** non-empty +/// line, then its **last** (#1787). The head alone was the whole protocol, and +/// a verifier that opens with "Here is my assessment:" therefore parsed to +/// nothing — silently converting every verdict from that model into +/// [`heuristic_fallback`], which fails anything without green touched tests. A +/// reasoning model does not decline to answer; it answers *after* thinking, so +/// the concluding line is where its verdict actually is. +/// +/// Deliberately two positions and not a scan of the whole body: the head and +/// the tail are the places the protocol could plausibly put a verdict, while +/// intermediate prose is where a verifier *discusses* failing tests. Reading +/// that would reintroduce the misread the token set was narrowed to prevent — +/// and the head still wins, so a reply that leads with its verdict is parsed +/// exactly as before no matter what its closing line says. pub fn parse_verifier_response(text: &str) -> Option { - // Only the FIRST non-empty line decides the verdict — the verifier prompt asks - // for PASS/FAIL there. And the ambiguous "yes"/"no" synonyms are excluded: - // scanning the whole body for them misread a genuine PASS line like "no + // Within a line, the ambiguous "yes"/"no" synonyms are excluded: + // scanning for them misread a genuine PASS line like "no // obvious issues. PASS" as a FAIL because "no" was hit first. // A negated verdict token is not that verdict: "the tests do not pass" is // a FAIL, and crediting its "pass" token as a PASS inverted real verdicts. @@ -901,45 +915,46 @@ pub fn parse_verifier_response(text: &str) -> Option { // ("it does not, in this case, pass"), so counting them would drop real // negations to buy back cases the token window already handles. const CLAUSE_BREAKS: &[char] = &['.', '!', '?', ';', ':']; - let first_line = text.lines().map(str::trim).find(|l| !l.is_empty())?; - let lower = first_line.to_ascii_lowercase(); - for clause in lower.split(CLAUSE_BREAKS) { - let mut since_negation: Option = None; - for raw in clause.split(|c: char| !c.is_ascii_alphanumeric()) { - if raw.is_empty() { - continue; - } - // `distance` is 0 for the token immediately after the negator, so the - // bound of 1 is the documented two-token window: "not pass" and - // "not currently pass" negate; "not a problem PASS" does not. - let negated = matches!(since_negation, Some(distance) if distance <= 1); - match raw { - "pass" | "passed" | "approve" | "approved" => { - return Some(Verdict { - passed: !negated, - reasoning: bounded_reasoning(text), - heuristic: false, - verifier_independent: None, - }); + // `Some(passed)` if this one line states a verdict. Pulled out of the + // caller so the head and the tail are scanned by identical rules — a + // second copy of this is how the two positions would drift apart. + let verdict_of = |line: &str| -> Option { + let lower = line.to_ascii_lowercase(); + for clause in lower.split(CLAUSE_BREAKS) { + let mut since_negation: Option = None; + for raw in clause.split(|c: char| !c.is_ascii_alphanumeric()) { + if raw.is_empty() { + continue; } - "fail" | "failed" | "reject" | "rejected" if !negated => { - return Some(Verdict { - passed: false, - reasoning: bounded_reasoning(text), - heuristic: false, - verifier_independent: None, - }); + // `distance` is 0 for the token immediately after the negator, so the + // bound of 1 is the documented two-token window: "not pass" and + // "not currently pass" negate; "not a problem PASS" does not. + let negated = matches!(since_negation, Some(distance) if distance <= 1); + match raw { + "pass" | "passed" | "approve" | "approved" => return Some(!negated), + "fail" | "failed" | "reject" | "rejected" if !negated => return Some(false), + _ => {} } - _ => {} + since_negation = if NEGATORS.contains(&raw) { + Some(0) + } else { + since_negation.map(|distance| distance + 1) + }; } - since_negation = if NEGATORS.contains(&raw) { - Some(0) - } else { - since_negation.map(|distance| distance + 1) - }; } - } - None + None + }; + let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()); + let first = lines.next()?; + // The head wins outright; the tail is consulted only when the head + // declined to state a verdict at all. + let passed = verdict_of(first).or_else(|| verdict_of(lines.next_back()?))?; + Some(Verdict { + passed, + reasoning: bounded_reasoning(text), + heuristic: false, + verifier_independent: None, + }) } /// Ceiling on verifier prose forwarded into a worker's revision prompt. diff --git a/crates/stella-pipeline/src/verify/tests/parse.rs b/crates/stella-pipeline/src/verify/tests/parse.rs index 0c01e6c37..9fa5cfc5d 100644 --- a/crates/stella-pipeline/src/verify/tests/parse.rs +++ b/crates/stella-pipeline/src/verify/tests/parse.rs @@ -29,13 +29,74 @@ fn parses_pass_and_fail_verdicts() { parse_verifier_response("PASS — no obvious issues").map(|v| v.passed), Some(true) ); - // Only the first non-empty line is authoritative. + // The head wins outright: a reply that leads with its verdict is read + // there, whatever the prose below it says. assert_eq!( parse_verifier_response("FAIL\nthe change looks fine otherwise").map(|v| v.passed), Some(false) ); } +/// **Witness (#1787).** A verifier that thinks before it answers is still +/// answering. +/// +/// The protocol consulted only the first non-empty line, so a reply opening +/// with "Here is my assessment:" carried no verdict token there and parsed to +/// `None`. `None` is the signal for [`crate::verify::heuristic_fallback`], +/// which passes only on an observed flip or green touched tests and otherwise +/// FAILS — so this did not degrade one verdict, it silently converted *every* +/// verdict from a preambling model into a heuristic that mostly refuses. +/// Reasoning models preamble by construction, which makes this a whole model +/// family the verifier could not grade with. +#[test] +fn a_verifier_that_preambles_is_read_at_its_conclusion() { + let pass = "Here is my assessment:\n\n\ + The diff adds the missing endpoint and the witness covers it.\n\n\ + PASS"; + assert_eq!( + parse_verifier_response(pass).map(|v| v.passed), + Some(true), + "a preamble before a PASS must not read as an unparseable reply" + ); + + let fail = "Let me work through this.\n\n\ + The handler is added but nothing asserts on it.\n\n\ + FAIL — the test is vacuous"; + assert_eq!(parse_verifier_response(fail).map(|v| v.passed), Some(false)); + + // The reasoning is still the whole reply, not just the line the verdict + // was found on — a revision acts on the thinking, not the token. + let verdict = parse_verifier_response(pass).expect("a PASS token is present"); + assert!(verdict.reasoning.contains("missing endpoint")); + assert!( + !verdict.heuristic, + "a parsed verdict is never the heuristic" + ); +} + +/// The tail is consulted only as a second resort, and only ever the *last* +/// line — never the body in between. +/// +/// This is the property that keeps the widened protocol safe. A verifier's +/// middle paragraphs are where it *discusses* failing tests, and reading them +/// would reintroduce exactly the misread the token set was narrowed to +/// prevent: here the body says "the tests fail" while the verifier's actual +/// verdict, stated last, is a PASS. +#[test] +fn intermediate_prose_never_decides_a_verdict() { + let reply = "Assessment follows.\n\ + Before the change the tests fail, which is what the witness needs.\n\ + PASS"; + assert_eq!(parse_verifier_response(reply).map(|v| v.passed), Some(true)); + + // And a reply that never states a verdict at either position is still + // unparseable — the widening must not invent one out of prose. + assert_eq!( + parse_verifier_response("Here is my assessment:\n\nI need more information."), + None + ); +} + /// A negated verdict token states the OPPOSITE verdict, and the parser used /// to credit it as written: "The tests do not pass" parsed as a PASS — the /// most damaging possible misread, converting a stated refusal into the diff --git a/scripts/file-size-baseline.txt b/scripts/file-size-baseline.txt index 11fc725c8..ba05d0f03 100644 --- a/scripts/file-size-baseline.txt +++ b/scripts/file-size-baseline.txt @@ -15,22 +15,22 @@ 1911 bench/terminal_bench_analysis/tb21_evidence_contract.py 4334 bench/terminal_bench_analysis/tests/test_tb21_analysis.py 1659 bench/terminal_bench_analysis/tests/test_tb21_evidence_contract.py -2266 crates/stella-cli/src/agent.rs +2269 crates/stella-cli/src/agent.rs 1751 crates/stella-cli/src/agent/tests.rs 4621 crates/stella-cli/src/command_deck.rs -1504 crates/stella-cli/src/fleet_cmd.rs -2129 crates/stella-core/src/bus.rs +1507 crates/stella-cli/src/fleet_cmd.rs +2126 crates/stella-core/src/bus.rs 2568 crates/stella-core/src/driver.rs 3681 crates/stella-core/src/driver/tests.rs 1781 crates/stella-model/src/anthropic/tests.rs 2093 crates/stella-model/src/openai.rs 1565 crates/stella-model/src/zai.rs 1895 crates/stella-model/src/zai/tests.rs -3573 crates/stella-pipeline/src/pipeline.rs -2573 crates/stella-pipeline/src/pipeline/tests.rs +3562 crates/stella-pipeline/src/pipeline.rs +2574 crates/stella-pipeline/src/pipeline/tests.rs 2965 crates/stella-protocol/src/event.rs 1995 crates/stella-store/src/lib.rs -2268 crates/stella-store/src/tests.rs +2262 crates/stella-store/src/tests.rs 1916 crates/stella-store/src/usage.rs 2181 crates/stella-tools/src/registry.rs 1839 crates/stella-tools/src/scripts.rs From ffa1ac3e041a3cbcd6853bb02bbac29c1830334b Mon Sep 17 00:00:00 2001 From: Mac Anderson Date: Thu, 6 Aug 2026 18:37:42 -0700 Subject: [PATCH 2/5] chore:file size ratchet --- scripts/file-size-baseline.txt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/file-size-baseline.txt b/scripts/file-size-baseline.txt index 8adaa3011..e1a7d69d4 100644 --- a/scripts/file-size-baseline.txt +++ b/scripts/file-size-baseline.txt @@ -26,9 +26,8 @@ 2093 crates/stella-model/src/openai.rs 1565 crates/stella-model/src/zai.rs 1895 crates/stella-model/src/zai/tests.rs -3562 crates/stella-pipeline/src/pipeline.rs -2574 crates/stella-pipeline/src/pipeline/tests.rs -2965 crates/stella-protocol/src/event.rs +3451 crates/stella-pipeline/src/pipeline.rs +2534 crates/stella-pipeline/src/pipeline/tests.rs 1996 crates/stella-store/src/lib.rs 2266 crates/stella-store/src/tests.rs 1916 crates/stella-store/src/usage.rs From 89235737f41de854e5cd83a7b8dc3e9e3cce3bfb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 01:48:20 +0000 Subject: [PATCH 3/5] chore: retighten the file-size ratchet after the merge Co-Authored-By: Claude Fable 5 --- scripts/file-size-baseline.txt | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/file-size-baseline.txt b/scripts/file-size-baseline.txt index e9d93100b..e1a7d69d4 100644 --- a/scripts/file-size-baseline.txt +++ b/scripts/file-size-baseline.txt @@ -27,8 +27,6 @@ 1565 crates/stella-model/src/zai.rs 1895 crates/stella-model/src/zai/tests.rs 3451 crates/stella-pipeline/src/pipeline.rs -3451 crates/stella-pipeline/src/pipeline.rs -3451 crates/stella-pipeline/src/pipeline.rs 2534 crates/stella-pipeline/src/pipeline/tests.rs 1996 crates/stella-store/src/lib.rs 2266 crates/stella-store/src/tests.rs From 146e1ec6fcc02f526a289571fcbfcbcb30efd7b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 02:04:29 +0000 Subject: [PATCH 4/5] =?UTF-8?q?fix(stella-pipeline,stella-cli):=20unbreak?= =?UTF-8?q?=20the=20base=20=E2=80=94=20clippy=20arity=20and=20a=20private?= =?UTF-8?q?=20intra-doc=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reproduce on origin/main at 43402ae and are unrelated to this branch's own changes; see #1972 for the full account. * `plan_stage` reached 8 arguments when #1953 added `research`, one past clippy's limit, so `make lint` fails workspace-wide. Fixed by taking the `Spend` the pipeline already uses for exactly this pair rather than `budget` and `total` loose — the established idiom here, and it removes a line at each of the three sites instead of adding an #[allow]. * `daemon/boot.rs`'s module doc intra-doc-links `SkipReason::NoResumePoint`, which is `pub(super)` and therefore absent from a non-private rustdoc build, failing `cargo doc -D warnings`. Demoted to a code span, matching how #1965 fixed the sibling case in stella-store. Refs #1972 Co-Authored-By: Claude Fable 5 --- crates/stella-cli/src/daemon/boot.rs | 2 +- crates/stella-pipeline/src/pipeline.rs | 14 ++++++++------ crates/stella-pipeline/src/pipeline/scope_stage.rs | 3 +-- .../src/pipeline/tests/management_accounting.rs | 6 ++++-- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/crates/stella-cli/src/daemon/boot.rs b/crates/stella-cli/src/daemon/boot.rs index 53bb8443c..f4cec045f 100644 --- a/crates/stella-cli/src/daemon/boot.rs +++ b/crates/stella-cli/src/daemon/boot.rs @@ -56,7 +56,7 @@ //! the checkpoint on every terminal path, abort included, so a policy stop //! retracts its resume point on the way out. A row written by a build that //! predates #1653 — where a policy stop really did store `Error` — is -//! therefore filtered by [`SkipReason::NoResumePoint`] anyway, without this +//! therefore filtered by `SkipReason::NoResumePoint` anyway, without this //! module having to trust its status. //! - **The attempt bound still applies.** An `Error` that resumes into //! another `Error` is counted like any other continuation and retired diff --git a/crates/stella-pipeline/src/pipeline.rs b/crates/stella-pipeline/src/pipeline.rs index d8c32b891..84e8421e0 100644 --- a/crates/stella-pipeline/src/pipeline.rs +++ b/crates/stella-pipeline/src/pipeline.rs @@ -1453,8 +1453,10 @@ impl<'a> Pipeline<'a> { research: &[ResearchFinding], repo_structure: &str, revision: Option<&str>, - budget: &mut BudgetGuard, - total: &mut f64, + // Bundled rather than two parameters: `Spend` exists for exactly this + // pair, and #1778's `research` argument pushed the loose form past + // clippy's arity limit. + spend: &mut Spend<'_>, ) -> Result, PipelineBudgetAbort> { self.emit(AgentEvent::Stage { name: StageKind::Plan, @@ -1482,8 +1484,8 @@ impl<'a> Pipeline<'a> { overrides: &worker_overrides, timeout: self.config.engine.model_timeout, }, - budget, - total, + spend.budget, + spend.total, ) .await { @@ -1507,8 +1509,8 @@ impl<'a> Pipeline<'a> { overrides: &worker_overrides, timeout: self.config.engine.model_timeout, }, - budget, - total, + spend.budget, + spend.total, ) .await { diff --git a/crates/stella-pipeline/src/pipeline/scope_stage.rs b/crates/stella-pipeline/src/pipeline/scope_stage.rs index 77d4e273e..15565d165 100644 --- a/crates/stella-pipeline/src/pipeline/scope_stage.rs +++ b/crates/stella-pipeline/src/pipeline/scope_stage.rs @@ -40,8 +40,7 @@ impl Pipeline<'_> { research, &repo_structure, revision.as_deref(), - budget, - total, + &mut Spend { budget, total }, ) .await { diff --git a/crates/stella-pipeline/src/pipeline/tests/management_accounting.rs b/crates/stella-pipeline/src/pipeline/tests/management_accounting.rs index 7e03e3fd8..629902302 100644 --- a/crates/stella-pipeline/src/pipeline/tests/management_accounting.rs +++ b/crates/stella-pipeline/src/pipeline/tests/management_accounting.rs @@ -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"); From f9171f30f8cb209e20eef2d9335622b397058e2d Mon Sep 17 00:00:00 2001 From: Mac Anderson Date: Thu, 6 Aug 2026 19:14:27 -0700 Subject: [PATCH 5/5] fix(stella-pipeline): keep the plan_stage arity fix out of a closed god file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `Spend` bundle that fixed `plan_stage`'s clippy arity carried a three-line rationale comment into `pipeline.rs`, taking it to 3453 against a recorded ceiling of 3451 — the god file rule is that a grandfathered file is closed to growth, so the ratchet failed while clippy, rustdoc and the workspace suite all passed. The rationale moves to `Spend`'s own doc in `stage_budget.rs`, which is where a reader asking "why does this signature take a bundle?" already looks, and which is not a god file. `pipeline.rs` lands at 3450 — one under its ceiling and with the baseline untouched, rather than regenerated to make the gate agree. --- crates/stella-pipeline/src/pipeline.rs | 3 --- crates/stella-pipeline/src/pipeline/stage_budget.rs | 5 +++++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/stella-pipeline/src/pipeline.rs b/crates/stella-pipeline/src/pipeline.rs index 84e8421e0..d258fa5b3 100644 --- a/crates/stella-pipeline/src/pipeline.rs +++ b/crates/stella-pipeline/src/pipeline.rs @@ -1453,9 +1453,6 @@ impl<'a> Pipeline<'a> { research: &[ResearchFinding], repo_structure: &str, revision: Option<&str>, - // Bundled rather than two parameters: `Spend` exists for exactly this - // pair, and #1778's `research` argument pushed the loose form past - // clippy's arity limit. spend: &mut Spend<'_>, ) -> Result, PipelineBudgetAbort> { self.emit(AgentEvent::Stage { diff --git a/crates/stella-pipeline/src/pipeline/stage_budget.rs b/crates/stella-pipeline/src/pipeline/stage_budget.rs index e9fc89004..29c037531 100644 --- a/crates/stella-pipeline/src/pipeline/stage_budget.rs +++ b/crates/stella-pipeline/src/pipeline/stage_budget.rs @@ -16,6 +16,11 @@ use crate::triage::TaskClass; /// mutation must land there — an owned copy would need a write-back on each /// of `run`'s early returns, and one missed return is a silently vanished /// spend. +/// +/// A signature still threading the loose pair adopts this the next time it +/// gains an input: `plan_stage` did so when #1778's `research` argument pushed +/// the loose form past clippy's arity limit. The bundle is the fix rather than +/// an `#[allow]` precisely because the pair was never two things. pub(super) struct Spend<'a> { /// Gates each paid call; consulted between model calls only (invariant #6). pub(super) budget: &'a mut BudgetGuard,