Skip to content

fix(stella-cli,stella-pipeline): seed and announce the candidate task board in isolated runs (#1719) - #1995

Merged
macanderson merged 5 commits into
mainfrom
worktree-fix-1719-candidate-task-board
Aug 7, 2026
Merged

fix(stella-cli,stella-pipeline): seed and announce the candidate task board in isolated runs (#1719)#1995
macanderson merged 5 commits into
mainfrom
worktree-fix-1719-candidate-task-board

Conversation

@macanderson

@macanderson macanderson commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Closes #1719

Stacks on #1971. main is red, and this branch carries #1971's unbreak commits so it can compile and be tested. Once #1971 merges, this PR's diff reduces to the single commit described below — 7 files, +431/−25.

The defect

The PLAN rail shows every step as a hollow grey for the whole turn. The panel's own dot moves (PLAN ● approved 0/7) but no individual step ever reaches started / complete — the rail reports a plan and never reports progress through it.

A step's ring moves only on AgentEvent::TaskUpdate, whose only emitter is the deck's session TaskTap. Pipeline::run_isolated_candidate builds its engine on ws.tools() — the candidate workspace's own stack — and TaskTap is not in that chain. Two independent failures follow, either of which alone freezes the rail:

  1. The board is private and unseeded. The forwarder seeds the session registry's board from the approved proposal, but a candidate ToolRegistry builds its own empty one. task_start "3" answers UnknownTask for a step the gate already numbered 3. A worker routing around it with task_create then builds rows whose subjects overwrite the approved step titles.
  2. Nothing announces it. Even a correct mutation reaches no surface.

Execution 66 of a real session: 1168 events, zero task_update, while the model called task_create ×3 and task_start ×1 exactly as the system prompt asks.

This is the same shape as the already-fixed "isolated runs blind the Files tab" bug — see the attach_read_events comment in candidate_ws.rs. The task board was missed in that sweep.

The fix

SeedingCandidateWorkspace grows a default-no-op seed_task_board(steps, announce). The fan-out stage drives it right after create, before any dispatch, so the worker's very first task_start already resolves the gate's ordinals. It passes the plan's step descriptions verbatim, which is what scope::build_proposal renders the approval card from — so a candidate board and the session board seeded from ScopeReview cannot disagree on a step's id or title.

Announcing — the candidate tool stack gains a TaskTap-shaped decorator (candidate_ws/task_events.rs), outermost over registry + customs + candidate MCP + policy, so it observes every route a task_* call can take.

Why the announce latch, and why boards stay private. The issue notes CandidateWorkspacePort::create carries no fan-out width, so the "how many candidates may report" decision cannot read n. It is taken in create_candidate_workspaces, which is the one seam that knows both n and the plan. TaskUpdate carries a full board snapshot with no candidate tag, so several boards reporting onto one channel would splice into a checklist that is nobody's — the same reasoning that already mutes TextDelta/Reasoning on a shared event lane. Only a lone candidate announces.

Boards are never shared, at any width: TaskBoard::set_status rejects a transition out of a terminal state, so the second sibling to finish a step would receive a tool error for work it really did.

Definition of done, item by item

From the issue Where
An isolated candidate's task_* moves the gate-numbered step and emits TaskUpdate a_seeded_lone_candidate_moves_the_approved_step_and_announces_it
Best-of-N does not regress — candidates must not share one board fanout_candidates_keep_separate_and_silent_boards, best_of_two_adopts_only_the_winner_and_removes_every_workspace
A host rendering no plan (fleet worker, headless) is unchanged Default no-op on the port; events: None skips the tap entirely; the witness author's pristine snapshot is created via port.create() directly (witness_stage.rs:615), never seeded, so its latch stays false
Witness proving the fail→pass flip below

Witness

The new API means the tests cannot compile on main (the feature is genuinely absent). That is the weaker half of the proof, so both halves of the fix were independently neutered on this branch to show each is load-bearing:

Seeding neutered — reproduces the issue's exact symptom:

a_seeded_lone_candidate_moves_the_approved_step_and_announces_it ... FAILED
  the gate-numbered step must resolve on the candidate board:
  Error { message: "no task with id 1 — call task_list to see the board" }
fanout_candidates_keep_separate_and_silent_boards ... FAILED

Announcing neutered:

a_seeded_lone_candidate_moves_the_approved_step_and_announces_it ... FAILED
  a lone candidate's task_start must announce a TaskUpdate

(The fan-out test correctly still passes with the tap dead — it asserts silence, which a dead tap trivially satisfies. Its real job, the separate-boards guarantee, fails in the seeding run above.)

Restored, both pass. The two CLI-side witnesses run against real git worktrees through the production GitCandidateWorkspaces port, not a fake.

Constraints honoured

  • candidate_ws.rs was near the 1500-line ceiling with no baseline entry, so it could not cross it at all: it takes +14 lines (1480 → 1494) and all new logic lands in the new sibling candidate_ws/task_events.rs.
  • pipeline.rs is a god file — +0 net lines (unchanged at its 3462 ceiling).
  • No baseline entry added or raised.

Verification

  • cargo test -p stella-pipeline -p stella-cli2121 passed, 0 failed (20 binaries)
  • cargo clippy -p stella-pipeline -p stella-cli --all-targets -- -D warnings — clean
  • make guards-fast — green, file-size and god-files included

Summary by Sourcery

Ensure isolated candidate workspaces share the approved task plan and surface task progress while tightening flip-halt and management accounting coverage.

New Features:

  • Seed each candidate workspace with the approved plan steps and optionally allow a lone candidate to announce task-board updates on the shared event stream.

Enhancements:

  • Extend the candidate workspace port to accept plan step seeding and an announce latch, and wire this through the fan-out stage and Git-backed candidate workspaces.
  • Clarify management accounting for the Research role and refactor planning budget handling to use a shared Spend wrapper parameter.
  • Add a passing-shell tool executor and supporting helpers to exercise flip-halt behavior when observing real test commands.

Tests:

  • Add pipeline and CLI tests to verify seeded candidate task boards resolve gate-numbered steps, keep per-candidate boards, and only announce from lone candidates.
  • Add a configured-command flip-halt witness test to ensure revisions halt when the tracked test flips from fail to pass.
  • Update management accounting tests to work with the new Spend wrapper and expanded role set.
  • Extend best-of-N isolation tests to assert that seeded task boards remain private and silent in multi-candidate runs.

`cargo clippy -p stella-pipeline --all-targets -- -D warnings` fails on
b5ab7f8. A compile error in the lib masks the test target entirely, so
this reads as one failure and is really four, from two different merges.

From #1953 (the #1778 research stage):

1. `management_prompt/tests.rs` — `ModelCallRole::Research` is a new variant
   and `management_system_block`'s match is exhaustive on purpose (E0004).
   Research rides the sub-agent primitive, so its system prompt travels on
   the `SubAgentSpec`, never through `metered_raw_call`: it joins the
   never-dispatched arm, and `ALL_ROLES` grows to 15.
2. `pipeline.rs` — the new `research` parameter pushed `plan_stage` to 8
   arguments, one over clippy's cap. Bundled `budget`/`total` into the
   `Spend` struct every stage downstream of the fan-out already takes,
   rather than `#[allow]`-ing the lint.

From #1951, which rewrote `tests/verification_hardening.rs` wholesale and
dropped three items #1945 had added to it hours earlier — a same-seam
clobber, in a file #1951's own subject (per-candidate verifier degradation)
never needed to touch:

3. `PassingShell` and `shell_call_result` went with it, leaving the child
   module `flip_halt_arming.rs` referencing two helpers that exist nowhere
   in the tree (E0425 ×2). Restored to their original home, which the child
   reaches through `use super::*`.
4. `a_revision_halts_at_the_step_where_the_tracked_test_flips` went too —
   the configured-command **witness for #1793**. Deleting it did not fail
   any gate, because the crate stopped compiling for reason 3 first: #1793
   has been shipping with half its witness silently gone. Restored verbatim.

Both #1793 witnesses now run and pass. Neither is vacuous: each asserts a
scripted-prompt count, so a `PassingShell` that omitted the `[exit code: 0]`
marker `flip_halt::exit_status` parses would leave the halt unarmed, the
revision would consume the steps scripted beyond the flip, and the count
would be wrong.

`cargo test -p stella-pipeline`: 605 passed, 0 failed.
`cargo clippy -p stella-pipeline --all-targets -- -D warnings`: clean.
Restoring `a_revision_halts_at_the_step_where_the_tracked_test_flips` in
the parent commit took `tests/verification_hardening.rs` to 1557 lines,
which `file-size` rejects outright — the baseline takes no new entries.

Split rather than exempted, and the split is the one the content was
asking for: both #1793 witnesses and the two doubles they share
(`PassingShell`, `shell_call_result`) now live in
`verification_hardening/flip_halt_arming.rs`, the module already named for
the concern. The parent drops to 1434.

That the two witnesses were ever in separate files is what let #1951's
clobber happen quietly: it rewrote the parent wholesale, taking the
configured-command witness and both doubles with it, and nothing failed
that named the missing test — the crate had already stopped compiling for
the missing doubles. With the cluster in one file the same rewrite is a
merge conflict instead of a silent deletion, so the module doc says so.

`cargo test -p stella-pipeline`: 605 passed, 0 failed — both witnesses
among them, at their new path.
… board in isolated runs

An isolated candidate's registry built its own empty task board with no
path to any surface, so the worker's task_start "3" answered UnknownTask
for a step the scope gate had already numbered, and zero TaskUpdate events
reached the deck - the PLAN rail froze at hollow rings for the whole turn
(execution 66: 1168 events, none of them task_update).

Two halves, matching the issue's two failures:

- Seeding: CandidateWorkspace grows a default-no-op seed_task_board; the
  fan-out stage - the one seam that knows both the approved plan and n -
  hands every created workspace the gate's exact rendered step strings, so
  ordinals and titles match the session board seeded from ScopeReview.
- Announcing: the candidate tool stack gains a TaskTap-shaped decorator
  (candidate_ws/task_events.rs, the board half of the attach_read_events
  sweep) that snapshots the board onto the turn's channel after any task_*
  call - but only when the pipeline armed the announce latch, which it does
  solely for a lone candidate: TaskUpdate snapshots carry no candidate tag,
  so several private boards reporting onto one channel would splice into a
  checklist that is nobody's (the TextDelta muting reasoning).

Boards stay per-candidate at every width: a step is completable exactly
once per board, and the second sibling to finish it must not be handed a
terminal-state tool error for work it really did.

Closes #1719

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @macanderson, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
stella-cli-docs Ready Ready Preview Aug 7, 2026 2:39am

@sourcery-ai

sourcery-ai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Extend the candidate pipeline so each isolated candidate workspace’s private task board is seeded from the approved plan and optionally announces task updates, while adding flip-halt and management-accounting tests and small refactors to keep budget tracking and model roles consistent.

Sequence diagram for seeding candidate task boards during fan-out

sequenceDiagram
    participant Pipeline
    participant CandidateWorkspacePort as CandidateWorkspacePort
    participant WS as CandidateWorkspace
    participant Board as CandidateTaskBoard

    Pipeline->>Pipeline: create_candidate_workspaces(port, n, plan)
    Pipeline->>Pipeline: steps = plan.map(step.description)
    loop for each candidate (0..n-1)
        Pipeline->>CandidateWorkspacePort: create()
        CandidateWorkspacePort-->>Pipeline: Ok(WS)
        alt [workspace created]
            Pipeline->>WS: seed_task_board(&steps, n == 1)
            WS->>Board: seed(steps, announce)
            Board->>Board: seed_from_plan(steps)
            Board->>Board: announce.store(announce)
        else [create failed]
            Pipeline-->>Pipeline: record isolation failure
        end
    end
Loading

Sequence diagram for candidate task_* calls announcing TaskUpdate

sequenceDiagram
    actor Candidate
    participant WS as GitCandidateWorkspace
    participant Tools as CandidateTaskTap
    participant Inner as ToolExecutor
    participant Board as TaskBoardHandle
    participant Events as EventSender

    Candidate->>WS: tools()
    WS-->>Candidate: Tools
    Candidate->>Tools: execute("task_start", input)
    Tools->>Inner: execute("task_start", input)
    Inner-->>Tools: ToolOutput
    alt [name starts_with("task_") and announce is true]
        Tools->>Board: lock()
        Tools->>Board: items()
        Board-->>Tools: Vec<TaskItem>
        Tools->>Events: send(AgentEvent::TaskUpdate{tasks})
    else [not a task_* call or announce is false]
        Tools-->>Candidate: ToolOutput
    end
    Tools-->>Candidate: ToolOutput
Loading

File-Level Changes

Change Details Files
Seed candidate workspaces’ task boards from the approved plan and gate announcement by fan-out width.
  • Add seed_task_board hook to CandidateWorkspace with a default no-op implementation and document its semantics.
  • Update Pipeline::create_candidate_workspaces to accept the plan, derive ordered step descriptions, call seed_task_board for each workspace, and only enable announcement when n == 1.
  • Extend FakeWorkspace and its port/test scaffolding to record seed_task_board calls via a shared SeedProbe used in tests.
crates/stella-pipeline/src/ports.rs
crates/stella-pipeline/src/pipeline/fanout_stage.rs
crates/stella-pipeline/src/pipeline/test_doubles.rs
crates/stella-pipeline/src/pipeline/tests/best_of_n.rs
Introduce a task-event tap around candidate tool stacks so isolated runs emit TaskUpdate snapshots without sharing boards across candidates.
  • Add candidate_ws::task_events module that wires a CandidateTaskTap decorator around the full candidate tool stack, maintaining a CandidateTaskBoard handle and announce latch shared with the workspace.
  • Have GitCandidateWorkspaces construct the task board handle, wrap tools in the new tap when an EventSender is present, and store the CandidateTaskBoard on GitCandidateWorkspace.
  • Implement tests that verify a lone candidate both resolves gate-numbered steps and emits TaskUpdate events, and that fan-out candidates keep independent, silent boards.
crates/stella-cli/src/candidate_ws.rs
crates/stella-cli/src/candidate_ws/task_events.rs
Add and tighten tests around flip-halt behavior and best-of-N candidate management, and adjust management accounting to use the new Spend wrapper.
  • Extend flip_halt_arming test module with PassingShell and shell_call_result doubles plus a new witness test ensuring revisions halt at the step where the tracked test flips from fail to pass.
  • Refactor best_of_two_adopts_only_the_winner_and_removes_every_workspace to use seeded probes and assert that each candidate workspace’s board seeding and announce flags are correct under fan-out.
  • Update management_accounting tests and related plan/scope logic to pass a Spend struct through plan_stage instead of separate budget/total references, and ensure Research role is covered in management prompt role accounting.
crates/stella-pipeline/src/pipeline/tests/verification_hardening/flip_halt_arming.rs
crates/stella-pipeline/src/pipeline/tests/best_of_n.rs
crates/stella-pipeline/src/management_prompt/tests.rs
crates/stella-pipeline/src/pipeline/tests/management_accounting.rs
crates/stella-pipeline/src/pipeline.rs
crates/stella-pipeline/src/pipeline/scope_stage.rs

Assessment against linked issues

Issue Objective Addressed Explanation
#1719 Ensure that in isolated single-candidate runs, the candidate’s task_* calls operate on the same gate-numbered steps as the approved plan and emit TaskUpdate events so the PLAN rail reflects step state changes.
#1719 Preserve proper behavior in best-of-N fan-out runs: each candidate must have its own private task board (no shared board, no terminal-state conflicts), and only a lone candidate may announce TaskUpdate events on the shared channel.
#1719 Keep hosts that render no plan (e.g., fleet workers, headless stella run, witness author path) behaviorally unchanged while adding a witness test that demonstrates the previous failure and the new pass condition.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@macanderson

Copy link
Copy Markdown
Owner Author

Follow-ups filed per AGENTS.md § "Nothing left behind":

@macanderson
macanderson merged commit 02e892e into main Aug 7, 2026
11 of 13 checks passed
@macanderson
macanderson deleted the worktree-fix-1719-candidate-task-board branch August 7, 2026 02:38
macanderson added a commit that referenced this pull request Aug 7, 2026
…hree things and the merge kept both

#1985 and #1971/#1995 independently repaired the breaks #1953 left, converged
on the same designs, and landed within minutes of each other. Git merged the
two additively rather than conflicting, so `main` at e0fbbe0 carries each fix
twice and fails `cargo clippy -p stella-pipeline --all-targets -- -D warnings`
three ways:

1. `management_prompt/tests.rs` — `ModelCallRole::Research` appears twice in
   the same or-pattern (`unreachable_patterns`). Kept one.
2. `pipeline/scope_stage.rs` — both PRs bundled `plan_stage`'s budget+total
   into `Spend`, but the call site kept #1985's per-iteration reborrow AND
   the other's hoisted `let mut spend`, now unused (`unused_variables` +
   `unused_mut`). Kept #1985's: the loop replans after a rejected scope card,
   and only a reborrow per attempt survives that.
3. `tests/verification_hardening.rs` — both restored `PassingShell` and
   `shell_call_result` after #1951 deleted them, one into this file and one
   into its `flip_halt_arming` child, leaving the parent's pair dead
   (`dead_code` ×3, counting `SHELL_TOOL`).

For (3) the two copies were not equivalent, so this is not an arbitrary pick:
#1985's are better documented — they name `SHELL_TOOL` as a const distinct
from `WRITING_TOOL` and say why the `[exit code: 0]` marker is load-bearing
(without it the halt never latches and the arming test passes for no reason).
Those are the ones kept. They move to the child, which is where both #1793
witnesses now live, because co-location is what makes the next wholesale
rewrite of the parent a merge conflict instead of the silent deletion that
started this (#1997). The parent's now-stale `mod` doc is corrected in place
rather than left describing a layout that no longer holds.

`cargo clippy -p stella-pipeline --all-targets -- -D warnings`: clean.
macanderson added a commit that referenced this pull request Aug 7, 2026
…e same three things, plus the file-size ratchet blocking every PR (#2008)

> ⚠️ **Overlaps #2000 — merge exactly one of these, never both.** We
built the same unbreak in parallel and reached the *identical*
resolution on all three collisions. This PR additionally fixes a fourth
break (the file-size ratchet) that is currently failing #2000's checks
and every other open PR. If #2000 picks up that one commit, close this;
otherwise close #2000. Merging both is how the collision being fixed
here happened.

## Why main is red

`main` at e0fbbe0 fails `cargo clippy -p stella-pipeline --all-targets
-- -D warnings` **and** `file size ratchet`.

Two unbreak PRs (#1985, and #1971 via #1995) independently repaired the
breaks #1953 left, **converged on the same designs**, and landed minutes
apart. Git merged them additively rather than conflicting, so main now
carries each fix twice:

| # | Break | Where |
|---|---|---|
| 1 | `ModelCallRole::Research` twice in one or-pattern
(`unreachable_patterns`) | `management_prompt/tests.rs` |
| 2 | Both a hoisted `let mut spend` **and** a per-iteration reborrow
(`unused_variables` + `unused_mut`) | `pipeline/scope_stage.rs` |
| 3 | `PassingShell`/`shell_call_result` restored into *both* the parent
and its child (`dead_code` ×3) | `tests/verification_hardening.rs` |
| 4 | Two grandfathered files one line over their ceiling |
`scripts/file-size-baseline.txt` |

## The judgment calls

**(2) — kept #1985's per-iteration reborrow, not the hoisted binding.**
Not arbitrary: `plan_with_review` loops, replanning after a rejected
scope card, and only a `Spend` reborrowed per attempt survives that. The
hoisted version would have been moved on the first iteration.

**(3) — kept #1985's doubles, in the child.** The two copies were *not*
equivalent. #1985's are better documented: they name `SHELL_TOOL` as a
const distinct from `WRITING_TOOL`, and say why the trailing `[exit
code: 0]` marker is load-bearing — without it `FlipHalt::observe` never
latches and the arming test passes for no reason. Those are the ones
kept. They live in `flip_halt_arming` with both #1793 witnesses, because
co-location is what turns the next wholesale rewrite of the parent into
a merge conflict instead of the silent deletion that started this
(#1997). The parent's `mod` doc is corrected in place rather than left
describing a layout that no longer holds.

**(4) — recording growth that already merged, and saying so.** Two
ceilings go **up** by one line each:

```
crates/stella-core/src/driver.rs               2571 → 2572
crates/stella-pipeline/src/pipeline/tests.rs   2536 → 2537
```

Per CLAUDE.md, a raised ceiling to turn a gate green is normally a
defect against the PR that raises it, so this is flagged rather than
slipped through. The difference: **this branch touches neither file.**
Both grew on main via #1979 and #1962, which did not regenerate the
baseline in the same commit. The choice is therefore not "grow or don't"
but "record what already merged, or leave main red for everyone". The
two lines are somebody's to reclaim; neither is mine to judge
irreducible.

The same regeneration **tightens** `pipeline.rs` from 3451 to 3181 — 270
lines of stale headroom now closed off, which is the ratchet working as
intended and more than offsets the two. Regenerated via `make
file-size-update`, never hand-edited.

## Verification

- `cargo clippy -p stella-pipeline --all-targets -- -D warnings` — clean
- `cargo test -p stella-pipeline --lib` — **596 passed, 0 failed**, both
#1793 witnesses among them
- `make guards-fast` — green, `file-size` and `god-files` included

## Related

- #1997 — why a deleted test failed no gate in the first place
- #1985, #1995, #2000 — the colliding unbreaks

## Summary by Sourcery

Unbreaks main by reconciling overlapping clippy and test fixes in
stella-pipeline, consolidating flip-halt arming test doubles, and
updating the file-size baseline so guards and ratchet checks pass again.

Bug Fixes:
- Resolve unreachable pattern warning in management_prompt tests by
removing the duplicate ModelCallRole::Research arm
- Fix clippy unused variable warnings in scope_stage by relying on
per-iteration Spend reborrows
- Restore and colocate shell tooling doubles for flip halt arming tests
so dead-code warnings are cleared while preserving #1793 coverage

Enhancements:
- Clarify documentation and structure of flip halt arming tests by
moving shared shell doubles into the child module and updating the
parent module description

Build:
- Regenerate file-size baseline to reflect recent growth in driver.rs
and pipeline tests while tightening the pipeline.rs ceiling so file-size
ratchet gates pass again
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plan steps never change state during an isolated run — the candidate's task board is private, unseeded, and unannounced

1 participant