Skip to content

A best-of-N fan-out's PLAN rail is still frozen: TaskUpdate carries no candidate tag, so only a lone candidate may report #1996

Description

@macanderson

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):

  1. 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).
  2. Regenerate the committed wire artifacts in the same PR: make wire-schema-update (docs/wire/), or the wire-schema gate fails.
  3. 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.
  4. 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.
  5. 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.rscreate_candidate_workspaces, where announce is decided
  • crates/stella-pipeline/src/ports.rsCandidateWorkspace::seed_task_board contract
  • crates/stella-cli/src/candidate_ws/task_events.rs — the tap and its announce latch
  • crates/stella-protocol/src/event.rsTaskUpdate
  • crates/stella-tui/src/plan.rsPlan::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

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Polish — worth doing, not urgentarea:clistella-cli — commands, flags, wiringtriageUntyped request — convert by adding bug / feature / epic

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions