Skip to content

feat: the fleet board contract — clients can finally see the conductor (P5.0) - #294

Merged
saucam merged 1 commit into
mainfrom
feat/conductor-p5-fleet-contract
Aug 14, 2026
Merged

feat: the fleet board contract — clients can finally see the conductor (P5.0)#294
saucam merged 1 commit into
mainfrom
feat/conductor-p5-fleet-contract

Conversation

@saucam

@saucam saucam commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

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.md said it plainly — "designed, not built — the only front door today is codeoid attach conductor in 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.

fleet.subscribe    -> fleet.snapshot.result, then streamed fleet.update
fleet.unsubscribe

The scope question

Gated on a new fleet:read. Deliberately not session:read/session:dispatch — those are ZeroID scopes delegated to the conductor's own agent identity to gate its fleet_* MCP tools; this gates a human's client reading the board over the wire. 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 didn't settle

  • fleet.unsubscribe added. Without it a client that navigates away could only stop the stream by dropping its socket.
  • One board-change hook, not fourteen. DispatcherHost.onBoardChange fires 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.
  • Deltas are exactly-once via a compound watermark. updated_at is 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 carries taskUpdatedAt plus 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 prompt and worker workdir. 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. dependsOn is 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.role field 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

  • 2310 daemon tests pass (13 new in src/tests/fleet-board.test.ts), 409 web, 7 Rust suites
  • lint / typecheck / check:versions clean both sides; cargo fmt + clippy clean
  • New tests cover tenancy isolation on both snapshot and delta, scope enforcement, prompt confidentiality, watermark advance, unsubscribe, disconnect reaping, and the no-subscriber path (the dispatcher signals on every tick, including on a daemon nobody is watching)

Two pre-existing exhaustiveness guards caught the additions before I did — the DaemonMessage switch test and the client-message fixture map. Both updated.

No client draws the board yet — that's P5.1–P5.4.

…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>
@saucam
saucam merged commit 300501d into main Aug 14, 2026
4 checks passed
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>
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.

3 participants