Context
#1719 (PR #1995) unfroze the PLAN rail for isolated runs by giving each candidate workspace a seeded, private task board plus a tap that emits AgentEvent::TaskUpdate. But it deliberately arms the announce latch only when n == 1:
// crates/stella-pipeline/src/pipeline/fanout_stage.rs, create_candidate_workspaces
ws.seed_task_board(&steps, n == 1);
So a best-of-N fan-out (--candidates 2+) still shows a frozen PLAN rail — every step a hollow ○ for the whole turn, exactly the #1719 symptom, on the one path #1719 did not fix.
Why it was left this way
AgentEvent::TaskUpdate { tasks } carries a full board snapshot and no candidate tag (crates/stella-protocol/src/event.rs). N candidates each hold their own board — they must, because TaskBoard::set_status rejects a transition out of a terminal state, so a shared board would hand the second sibling to finish a step a tool error for work it really did (#1719's own definition of done requires this).
Two untagged full-board snapshots interleaved on one channel therefore render a checklist that is nobody's: the consumer folds each snapshot as the board, so the rail flickers between candidates' disagreeing states. Muting is strictly better than lying — the same call the fan-out already makes for TextDelta/Reasoning on a shared event lane (fanout_stage.rs module doc).
Announcing was gated rather than solved because the honest fix changes the wire contract, which is out of scope for a P1 rail fix.
What "done" looks like
A best-of-N run's PLAN rail shows progress, attributed to the candidate that made it, with no candidate able to overwrite another's rows.
Sketch (the design decision is genuinely open — please review before building):
- Add an optional candidate discriminator to
AgentEvent::TaskUpdate — e.g. #[serde(default, skip_serializing_if = "Option::is_none")] candidate: Option<u32>. Additive and None for every existing emitter, so older readers are unaffected (see crates/stella-protocol/README.md on the one-directional wire rule).
- Regenerate the committed wire artifacts in the same PR:
make wire-schema-update (docs/wire/), or the wire-schema gate fails.
- Thread the candidate index into
seed_task_board (it is already available as the loop index in create_candidate_workspaces) and stamp it on the emitted event.
- Decide the renderer policy in
crates/stella-tui/src/plan.rs (Plan::apply_board), which today folds every TaskUpdate into one board. Options: show only the leading candidate; show the winner retroactively; render N rails. This is the part that needs a human's judgment, not a mechanical change.
- Golden frames:
crates/stella-tui/tests/deck_render_snapshots.rs, regenerated with BLESS=1 and read, not blessed blind.
Files
crates/stella-pipeline/src/pipeline/fanout_stage.rs — create_candidate_workspaces, where announce is decided
crates/stella-pipeline/src/ports.rs — CandidateWorkspace::seed_task_board contract
crates/stella-cli/src/candidate_ws/task_events.rs — the tap and its announce latch
crates/stella-protocol/src/event.rs — TaskUpdate
crates/stella-tui/src/plan.rs — Plan::apply_board
Verify
Witness: a fan-out of 2 where both candidates complete step 1 must produce two attributable TaskUpdates, and neither candidate's task_complete may error. The existing fanout_candidates_keep_separate_and_silent_boards (crates/stella-cli/src/candidate_ws/task_events.rs) asserts today's silence and must be updated in the same PR — it is the test that will otherwise contradict the new behaviour.
Constraints
Context
#1719 (PR #1995) unfroze the PLAN rail for isolated runs by giving each candidate workspace a seeded, private task board plus a tap that emits
AgentEvent::TaskUpdate. But it deliberately arms the announce latch only whenn == 1:So a best-of-N fan-out (
--candidates 2+) still shows a frozen PLAN rail — every step a hollow○for the whole turn, exactly the #1719 symptom, on the one path #1719 did not fix.Why it was left this way
AgentEvent::TaskUpdate { tasks }carries a full board snapshot and no candidate tag (crates/stella-protocol/src/event.rs). N candidates each hold their own board — they must, becauseTaskBoard::set_statusrejects a transition out of a terminal state, so a shared board would hand the second sibling to finish a step a tool error for work it really did (#1719's own definition of done requires this).Two untagged full-board snapshots interleaved on one channel therefore render a checklist that is nobody's: the consumer folds each snapshot as the board, so the rail flickers between candidates' disagreeing states. Muting is strictly better than lying — the same call the fan-out already makes for
TextDelta/Reasoningon a shared event lane (fanout_stage.rsmodule doc).Announcing was gated rather than solved because the honest fix changes the wire contract, which is out of scope for a P1 rail fix.
What "done" looks like
A best-of-N run's PLAN rail shows progress, attributed to the candidate that made it, with no candidate able to overwrite another's rows.
Sketch (the design decision is genuinely open — please review before building):
AgentEvent::TaskUpdate— e.g.#[serde(default, skip_serializing_if = "Option::is_none")] candidate: Option<u32>. Additive andNonefor every existing emitter, so older readers are unaffected (seecrates/stella-protocol/README.mdon the one-directional wire rule).make wire-schema-update(docs/wire/), or thewire-schemagate fails.seed_task_board(it is already available as the loop index increate_candidate_workspaces) and stamp it on the emitted event.crates/stella-tui/src/plan.rs(Plan::apply_board), which today folds everyTaskUpdateinto one board. Options: show only the leading candidate; show the winner retroactively; render N rails. This is the part that needs a human's judgment, not a mechanical change.crates/stella-tui/tests/deck_render_snapshots.rs, regenerated withBLESS=1and read, not blessed blind.Files
crates/stella-pipeline/src/pipeline/fanout_stage.rs—create_candidate_workspaces, whereannounceis decidedcrates/stella-pipeline/src/ports.rs—CandidateWorkspace::seed_task_boardcontractcrates/stella-cli/src/candidate_ws/task_events.rs— the tap and its announce latchcrates/stella-protocol/src/event.rs—TaskUpdatecrates/stella-tui/src/plan.rs—Plan::apply_boardVerify
Witness: a fan-out of 2 where both candidates complete step 1 must produce two attributable
TaskUpdates, and neither candidate'stask_completemay error. The existingfanout_candidates_keep_separate_and_silent_boards(crates/stella-cli/src/candidate_ws/task_events.rs) asserts today's silence and must be updated in the same PR — it is the test that will otherwise contradict the new behaviour.Constraints
crates/stella-protocol/src/event.rshas no baseline entry any more (fix(stella-store,repo): unbreak main — rustdoc private-link + obsolete event.rs ratchet entry #1965 retired it): it is an ordinary file at ~1454 lines and cannot cross 1500. Budget the variant's lines or extract supporting types.AgentEventfield means the serde round-trip test obligation (AGENTS.md invariant sync(stella): Gemini/Vertex/Bedrock providers, custom tools, skills from monorepo #4) and thewire-schemagate.