feat: the fleet board contract — clients can finally see the conductor (P5.0) - #294
Merged
Conversation
…r (P5.0) The conductor has been shipped and invisible. P0-P4 gave it cross-workspace session resolution, a durable crash-safe dispatch queue, delegated worker identities and compressed digests — and by deliberate design it added zero client<->daemon wire types, rendering as an ordinary session whose fleet_* calls are tool cards. That is enough to CHAT with it. It is not enough to SEE the fleet: the task board, worker lifecycle, blocked tasks and audit trail live only in daemon SQLite, so the only front door has been `codeoid attach conductor` in a terminal. This is the one additive read+subscribe surface that closes the gap (docs/conductor-frontends-design.md §11). It adds no dispatch semantics — every field it exposes is already held by the daemon. fleet.subscribe -> fleet.snapshot.result, then streamed fleet.update fleet.unsubscribe Gated on a new `fleet:read` scope. That is deliberately NOT session:read / session:dispatch: those are ZeroID scopes delegated to the conductor's own AGENT identity to gate its fleet_* MCP tools, whereas this gates a human's client reading the board over the wire. It is also separate from session:list, because the board exposes orchestration internals — what was dispatched, what failed, what it cost — beyond the session enumeration a watcher already gets. Three decisions the spec sketch did not settle: - fleet.unsubscribe was added. Without it a client that navigates away from the conductor surface could only stop the stream by dropping its socket. - The board-change signal is ONE hook, not fourteen. DispatcherHost.onBoardChange fires once per entry path (enqueue, group enqueue, end of tick) rather than at each individual store mutation. Every mutation happens inside one of those paths, so coverage is complete by construction and a mutation added inside the tick later cannot be missed. - Deltas are exactly-once via a compound watermark. updated_at is millisecond-granular and one tick routinely settles several tasks inside the same millisecond, which leaves a single cursor only bad options: `>` DROPS every task sharing the boundary millisecond, `>=` RE-SENDS them forever. The watermark carries taskUpdatedAt plus the ids already delivered at exactly that millisecond — inclusive query, no drops; id set, no repeats. The dispatch prompt and worker workdir are deliberately absent from the wire projection: the board draws lifecycle, and the prompt is the one task field that would carry arbitrary user text to every subscribed client. A test asserts it never appears. `dependsOn` is on the wire type but never populated, as §11 specifies, so typed fan-in edges are a later non-breaking add. Mirrored in the Rust codeoid-protocol crate, which also gains the SessionInfo.role field it never modelled — the prerequisite §10 flagged, and the reason the TUI could not so much as badge a conductor. Every new Rust enum carries #[serde(other)] so a newer daemon degrades to an unrendered node instead of failing the whole board's deserialization. The crate's wire tests parse JSON CAPTURED from the real TS daemon rather than hand-written fixtures, because the failure mode for a hand-maintained mirror is silent field drop. Daemon advertises the `fleet.board` capability so clients feature-detect instead of showing an empty board against an older daemon. No client draws the board yet — that is P5.1-P5.4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
akashjavelin
approved these changes
Aug 14, 2026
saucam
added a commit
that referenced
this pull request
Aug 19, 2026
…#296) `ORCHESTRATOR_FLEET_TOOLS > the BUILT server registers exactly those tools` read `server.instance._registeredTools` — a private field on the MINIFIED copy of the MCP SDK that @anthropic-ai/claude-agent-sdk bundles. That is not a stable surface, and it moved: the field now reads back undefined, so the test dies with TypeError: undefined is not an object (evaluating 'Object.keys(server.instance._registeredTools)') No code changed to cause this. The workflow pins `bun-version: latest`; #294 went green on bun 1.3.13 at 00:35 and the same job re-run at 14:49 on bun 1.3.14 fails on main's own tree. Dependencies are identical either side (--frozen-lockfile, claude-agent-sdk@0.3.220) — only the runtime moved. Every open PR is red until this lands. buildFleetMcpServer now materialises the picked tool array once and returns its names as `registeredToolNames`, derived from the exact array handed to `createSdkMcpServer`. That keeps what the test was actually protecting — that `pick()` is applied on the way in, rather than a constant quietly diverging from the real surface — while depending on nothing minified. A second test cross-checks `registeredToolNames` against the live server so the accessor cannot drift from reality. It reads the private field only as a corroborating signal and skips when absent, so an SDK move degrades it to a no-op instead of re-breaking the build. Pinning bun would also go green today and re-break on the next bump. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The conductor has been shipped and invisible. P0–P4 gave it cross-workspace session resolution (21.6% → 73.0% P@1), a durable crash-safe dispatch queue, delegated worker identities and compressed digests — and by deliberate design it added zero client↔daemon wire types, rendering as an ordinary session whose
fleet_*calls are tool cards.That is enough to chat with it. It is not enough to see the fleet: the task board, worker lifecycle, blocked tasks and audit trail live only in daemon SQLite.
conductor-design.mdsaid it plainly — "designed, not built — the only front door today iscodeoid attach conductorin a terminal."This is the one additive read+subscribe surface that closes the gap (
conductor-frontends-design.md§11). It adds no dispatch semantics — every field it exposes is already held by the daemon.The scope question
Gated on a new
fleet:read. Deliberately notsession:read/session:dispatch— those are ZeroID scopes delegated to the conductor's own agent identity to gate itsfleet_*MCP tools; this gates a human's client reading the board over the wire. Also separate fromsession:list, because the board exposes orchestration internals (what was dispatched, what failed, what it cost) beyond the session enumeration a watcher already gets.Three decisions the spec sketch didn't settle
fleet.unsubscribeadded. Without it a client that navigates away could only stop the stream by dropping its socket.DispatcherHost.onBoardChangefires once per entry path (enqueue, group enqueue, end of tick) rather than at each of the ~14 store mutations. Every mutation happens inside one of those paths, so coverage is complete by construction — a mutation added inside the tick later cannot be missed.updated_atis millisecond-granular and one tick routinely settles several tasks in the same millisecond, leaving a single cursor only bad options:>drops every task sharing the boundary millisecond,>=repeats them forever. The watermark carriestaskUpdatedAtplus the ids already delivered at exactly that millisecond — inclusive query (no drops), id set (no repeats). I found this because a test I wrote asserted the stronger property and failed.Not on the wire, on purpose
The dispatch
promptand workerworkdir. The board draws lifecycle; the prompt is the one task field that would carry arbitrary user text to every subscribed client. A test asserts it never appears.dependsOnis on the wire type but never populated, exactly as §11 specifies, so typed fan-in edges stay a non-breaking add.Companion PR
highflame-ai/codeoid-ui mirrors this and adds the
SessionInfo.rolefield that crate never modelled — the prerequisite §10 flagged, and the reason the TUI couldn't badge a conductor. Every new Rust enum carries#[serde(other)]so a newer daemon degrades to an unrendered node rather than failing the whole board's deserialization. Its wire tests parse JSON captured from this running daemon, not hand-written fixtures — for a hand-maintained mirror, a fixture written from the same assumption as the code proves nothing.Verification
src/tests/fleet-board.test.ts), 409 web, 7 Rust suitescheck:versionsclean both sides;cargo fmt+ clippy cleanTwo pre-existing exhaustiveness guards caught the additions before I did — the
DaemonMessageswitch test and the client-message fixture map. Both updated.No client draws the board yet — that's P5.1–P5.4.