Skip to content

feat(web): live fleet board state over fleet.subscribe - #324

Merged
saucam merged 1 commit into
mainfrom
feat/p5-2-fleet-state
Sep 5, 2026
Merged

feat(web): live fleet board state over fleet.subscribe#324
saucam merged 1 commit into
mainfrom
feat/p5-2-fleet-state

Conversation

@saucam

@saucam saucam commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Groundwork for P5.2 (conductor-frontends-design.md §12). Independent of #323 — no shared files.

Why

The board contract shipped in P5.0 (snapshot, then streamed deltas) and no client has consumed it since. This adds the state slice the docked conductor surface renders, and routes fleet.update into it.

I verified the contract works end-to-end while testing #319 — a real dispatch produced queued → running → done deltas plus a task_done event — so this is built against observed behaviour, not just the type.

Shape

Reducers are exported as pure functions holding the whole ordering and bounding contract, so the part worth testing needs no reactive root — the same split lib/fleet.ts uses. The signal layer is a thin shell.

Four rules that earn their place

Replace in place, insert by key. A task's createdAt never changes, so a running → done transition must not make the row jump while you're reading it. New rows are positioned by createdAt rather than unshifted, because "deltas arrive newest-last" is an assumption about the network, not a guarantee — a reconnect replays, a burst interleaves.

Events order by autoincrement id, not timestamp. One dispatcher tick routinely settles several events in the same millisecond; ordering those by createdAt shuffles them arbitrarily between renders.

The board is capped client-side at the daemon's own page sizes (FLEET_TASK_LIMIT 100 / FLEET_EVENT_LIMIT 50). The snapshot is bounded but the delta stream is not, so a board left open all day would grow without limit. Capping to the window the daemon would have sent also means a reconnect can't silently change how much history is on screen. The snapshot is re-sliced too, so a future daemon raising its limit doesn't raise this client's memory ceiling.

agg is replaced wholesale, never derived. It counts tasks that may have aged off this capped board, so a locally recomputed count would drift low exactly on the long-lived sessions where it matters most.

One deliberate non-obvious choice

unsubscribeFleet keeps the board. Leaving the conductor pane shouldn't blank what you just read, and the next subscribe re-snapshots anyway — clearing would only add a flash of empty state on every visit. A generation counter drops replies from a subscription already left (the same guard blackboard.ts uses for its index fetch).

Verification

15 tests: in-place replace, out-of-order insertion, both caps, idempotent redelivery, a missing conductor treated as a valid state rather than an error, error-clearing on recovery, and the task→session join for both spawn workers and send targets.

Typecheck, lint, full suite (178 tests) and production build clean.

Next

The docked surface itself — CenterPane branching on role === "conductor" to render the chat plus a state-grouped rail over this slice.

🤖 Generated with Claude Code

Groundwork for P5.2 (docs/conductor-frontends-design.md §12). The board
contract shipped in P5.0 — snapshot then streamed deltas — and no client
has consumed it since. This adds the state slice the docked conductor
surface will render, and routes `fleet.update` into it.

Reducers are exported as PURE functions holding the whole ordering and
bounding contract, so the part worth testing needs no reactive root — the
same split lib/fleet.ts uses for grouping. The signal layer is a thin
shell over them.

Four rules earn their place:

**Replace in place, insert by key.** A task's createdAt never changes, so
a running→done transition must not make the row jump while you read it.
New rows are positioned by createdAt rather than unshifted, because
"deltas arrive newest-last" is an assumption about the network, not a
guarantee — a reconnect replays and a burst interleaves.

**Events order by autoincrement id, not timestamp.** One dispatcher tick
routinely settles several events in the same millisecond; ordering those
by createdAt shuffles them arbitrarily between renders.

**The board is capped client-side** at the daemon's own page sizes. The
snapshot is bounded but the delta stream is not, so a board left open all
day would grow without limit. Capping to the window the daemon would have
sent also means a reconnect cannot silently change how much history is on
screen. The snapshot is re-sliced too, so a future daemon raising its
limit does not raise this client's memory ceiling.

**`agg` is replaced wholesale, never derived.** It counts tasks that may
have aged off this capped board, so a locally recomputed count would
drift low exactly on the long-lived sessions where it matters.

Unsubscribe deliberately KEEPS the board: leaving the pane should not
blank what you just read, and the next subscribe re-snapshots anyway, so
clearing would only add a flash of empty state on every visit. A
generation counter drops replies from a subscription already left, the
same guard blackboard.ts uses.

15 tests covering ordering, capping, idempotent redelivery, a missing
conductor as a valid state, and the task→session join. Typecheck, lint
and build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@saucam
saucam merged commit 9ce7657 into main Sep 5, 2026
4 checks passed
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.

2 participants