feat: render collaboration fleets and their goal blackboard in the web UI - #262
Conversation
`collaborationRole` has been on the wire since #256 and the UI ignored it entirely, so an N-role collaboration rendered as N+1 unrelated sessions — the exact opposite of the mental model a collaborative session exists to create. Group them. `lib/fleet.ts` is a pure grouping/filtering module (no Solid, so the interesting logic is testable without a reactive root); the pane consumes it. What the rows now say: - Children nest under their orchestrator behind a continuous left rail, keyed by role (`review#2`) rather than by their daemon-generated `<parent>:<role>-N` name, whose parent prefix is pure repetition once the row is already nested. Full name stays in the tooltip. - The orchestrator shows its goal, and folds its fleet shut from a toggle that is a SIBLING of the row button, not a child of it — nesting a button inside a button is invalid HTML. - Read-only roles are badged `ro`. That badge is §6's independence property made visible: a scout's leaf identity carries no `tools:write` at all. - Children always show their backend chip, INCLUDING "claude". Standalone rows suppress the default-backend chip, but which model sits behind which role is the entire point of a mixed fleet and must not be inferable only from a chip's absence. Two cases that are easy to get wrong, so both are pinned by tests: - An orphan child — `parentSessionId` naming a session not in the list, because the parent was destroyed while children drain or hasn't reached this client yet — is promoted to top level rather than dropped. A session that silently disappears from the sidebar is a far worse failure than one rendered without its group. It keeps its role badges but shows its full name, since no parent row is there to supply context. - Grouping happens BEFORE filtering. A query matching only a child keeps the orchestrator as (dimmed) context; a bare `reasoning` row with no indication of its goal is less useful than no filter at all. Web suite 319 → 341.
…ard.read The blackboard is only reachable through the role-scoped MCP tools, which means the person who created the collaboration and is paying for it cannot see what their fleet produced. Add two read verbs for the human. The exemption, stated plainly because it looks like a hole in §6: role scoping keeps the AGENTS independent of each other — a reviewer that can read its peers is an echo, not a panel. The goal's owner is not a participant. They can already read every child's transcript, so withholding the artifacts those transcripts produced protects nothing and only makes the fleet unobservable. That reasoning is load-bearing, so it is encoded in the type system rather than in a comment alone. `Blackboard#forOwner` returns a distinct `OwnerBlackboard` class, not a `RoleBlackboard` with a flag: a flag makes "unscoped" reachable by passing a boolean, whereas an explicit `forOwner()` is greppable and cannot be handed where a role handle is expected. It exposes no `write` — an owner write would land unattributed and unscoped on a board whose entire contract is attributable handoffs — and tenant scoping still applies in full. Ownership is checked before any of that, and `sessionId` may name either the orchestrator or one of its role-children. Clients focus children as often as parents, and a client that walked `parentSessionId` wrongly would get an EMPTY board rather than an error — the least debuggable outcome available. The parent is re-fetched through `#getOwnedSession` rather than trusted from the child's field, so the ownership check binds to the session whose artifacts are about to be read. Scopes, no new one minted: `index` takes `session:list` (metadata about a session the holder can already enumerate, no bodies) and `read` takes `session:watch` (a body is session content, the same class as streamed output). Both already sit in WATCHER_SCOPES and OPERATOR_SCOPES, so every token minted before this keeps working — a new scope would have 403'd them all. Also: an unwritten artifact returns `null`, not an error. A collaboration in flight legitimately has empty lanes, and a client can't render that as "pending" if the daemon calls it a failure. An orphaned child whose orchestrator was destroyed DOES error, because teardown drops the artifacts and "empty" would otherwise be indistinguishable from "gone". Daemon suite 2044 → 2063. The three security-relevant tests were mutation-checked — dropping the tenant check, swapping the two scope tiers, and letting a plain session through each fail exactly one test.
…duced The session list now shows the fleet; this shows its OUTPUT. §4 has the orchestrator hold an index and never the bodies — and so, until now, did the UI: every handoff between role-children happened entirely off-screen. Two panes. The index (kind · slot · version · author · size) on the left, one artifact body on the right, fetched only when picked. That split is the design's, not a layout preference: a `diff` can be 256 KB, and the index exists precisely so you can see what's on the board without paying to load it. The test asserts no `<pre>` renders until you click. Details that carry meaning rather than decoration: - Rows sort in SDLC flow order (spec → research → adr → task-list → diff → findings), not alphabetically, so the board reads as the pipeline it is. Alphabetical buries `spec` under `diff`. - Every writer slot is shown. Two `findings` rows reading `review` and `review#2` is the visible form of MULTI_WRITER_KINDS; one row would mean a panel had silently collapsed into a single voice. - A `null` artifact renders as "not written yet", never as an error. The daemon returns null for exactly this reason — an empty lane is a normal state of a collaboration in flight. - Polls every 4s while open and tears the timer down on close. There's no push channel for the board, and a stale panel makes a working fleet look stalled; a timer surviving the close would hit the daemon for the life of the tab, so a test pins that too. The trigger is a `board` chip in SessionControls, shown for an orchestrator OR any role-child (the daemon resolves the hop, so both land on the same board) and gated on the daemon advertising the `blackboard` capability — against an older daemon the affordance doesn't appear rather than appearing and erroring on click. The state slice takes `goalSessionId` from the RESULT, not from the id it asked about, so focusing a child and focusing its orchestrator converge. Both async paths drop replies for a board or artifact the user has since navigated away from, and a refresh that no longer lists the selected artifact clears the body pane instead of stranding it. Web suite 341 → 370.
The artifact-ref key separator was written as a raw control byte instead of an escape. Nothing that normally catches a mistake said a word: it typechecked, it linted, all 370 web tests passed. The only symptom was `git diff --stat` reporting `Bin 0 -> 6081 bytes` — git classifies a file containing a NUL as binary, so the file stopped producing diffs and would have reached the PR unreviewable. The separator itself was the right idea and stays, now as an explicit `\u0000` escape in a named constant. `kind` is an open namespace (`extra/<key>`) and `slot` is daemon-generated, so any printable delimiter is one future naming choice away from collapsing two distinct artifacts onto one key — which would render the wrong body under the right row. Adds `src/tests/source-hygiene.test.ts` to make the class of mistake loud: it walks every source file in the repo and fails on a raw NUL. The walker asserts its own file count first, so a broken walk can't pass by finding nothing, and the guard is mutation-checked: planting a NUL in lib/fleet.ts fails it, removing it passes. Daemon suite 2063 to 2064.
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
pr-shepherd: green on iteration 1 — no fixes required. CI attachment verified first (this repo's
Branch hygiene: 4 commits ahead of CI counts match a local run of the same suites exactly, so no environmental drift (lockfile/bun version/cache) to chase. No commits were pushed and no files were changed by the shepherd.
|
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
…e back missing (#263) `collaborationRole` has been persisted since P1b (#256) and `resumeSessions` read `meta.collaboration` while silently ignoring it. The visible symptom was cosmetic: role-children came back detached from their orchestrator, so the fleet grouping shipped in #262 held only until the daemon bounced. The invisible symptoms were the problem. A resumed child came back with: - **no `workerShape`**, so its next turn registered a full session agent instead of a scope-capped `scout` leaf (`#ensureAgentIdentity`); - **no capability role**, so `roleDeniesTool` had nothing to deny with and a read-only reviewer's `Write`/`Edit` degraded from denied to merely asked; - **no blackboard mount**, so it could not publish a handoff; and - **no autonomous budget** — it resumed `guarded`, and since nobody ever attaches to a child, its first non-safe tool call parks at `waiting_approval` with zero clients. The fleet rendered as a live collaboration and was dead. Two of those are a privilege regression across a restart, not just a lost feature. §6's "a reviewer that provably cannot write" held on the create path and quietly stopped holding on the resume path. ## Derive, don't re-invent No new persisted field was needed: a child's identity (`collaborationRole`) plus its goal's config reproduces the plan it spawned under. `plannedChildFor` recovers the `PlannedChild`, and it is implemented BY calling `planChildren` rather than re-deriving shape/write/reads/writes a second time — a resumed child that computed its own shape would be one edit from disagreeing with the one it spawned under, and the direction that drift fails is a read-only reviewer coming back able to write. Sharing the derivation makes that unrepresentable. `roleChildPosture` is now the single definition of a child's restrictions (worker shape + capability role + brief + collaborationRole), called by both `#spawnCollaborationChildren` and resume. `#blackboardMountFor` takes a `GoalScope` instead of a live parent `Session`, because resume is capped and time-boxed and a parent can legitimately miss the window its own children made — while the board itself is keyed on (tenant, goal id) in SQLite and needs no resident orchestrator. `#attachOrchestratorBlackboard` is likewise shared, since an orchestrator's mount is scoped to its own id either way. Attribution already anticipated this: `authorSub` is keyed to the ROLE within the goal, never the session id, so a resumed child writes under the same subject its pre-restart versions carry. ## The torn case Child transcript present, orchestrator's gone (teardown normally removes both). It restores the FENCE from what the child itself carries — `write` is on `collaborationRole` — and nothing else: no mount for artifacts that died with the goal, and deliberately no autonomous budget, because an agent that cannot coordinate should not burn turns unattended. It gets an honest brief saying so, and resume logs it at warn level, since a silent degrade is indistinguishable from a healthy fleet in the session list. ## Verification Daemon suite 2064 to 2081. Adds `Session.hasBlackboardMount` and `SessionManager._sessionForTest`. All seven restart tests were mutation-checked against the pre-fix state (dropping the posture spread) and all seven fail. Three further mutations each fail exactly the tests that should catch them: minting a mount but never attaching it, not tracking resumed tokens for revocation, and minting a mount on the orphan path. Mutation testing also caught two flaws in the tests themselves, both fixed: `activeTokens` counts tokens ever MINTED, so it could not tell a mount a session holds from one dropped on the floor (now asserted per-session via `hasBlackboardMount`); and the per-child assertions sat inside a loop over a list the pre-fix state empties, so that test passed vacuously until a length check was added. Verified live across a real process restart, not just a second SessionManager in-process: boot, create a 3-role collaboration, publish an artifact, SIGTERM, boot again on the same config dir. 15/15 — children reattach with roles and ordinals, write authority stays per-role, the capability role is active, they return autonomous with a fresh 50-turn budget, the board and its attribution survive, a resumed child still resolves to its goal and reads a body back, and cascade teardown still collapses the whole resumed fleet.
Closes the largest gap between "collaborative sessions work" and "collaborative sessions are legible": the fleet and its output are both visible now.
collaborationRolehas been on the wire since #256 and the web UI referenced it zero times — an N-role collaboration rendered as N+1 unrelated sessions, the exact opposite of the mental model the feature exists to create. And the goal blackboard from #260 was reachable only through the role-scoped MCP tools, so the person who created the collaboration could not see what their fleet produced.Design reference:
docs/collaborative-session-design.md§4 (artifacts), §6 (independence).1. Fleet grouping (
15f503c)web/src/lib/fleet.tsis a pure grouping/filtering module — no Solid, so the interesting logic is testable without a reactive root. The pane consumes it.review#2) rather than by their daemon-generated<parent>:<role>-Nname, whose parent prefix is pure repetition once the row is already nested. Full name moves to the tooltip.ro. That badge is §6's independence property made visible: a scout's leaf identity carries notools:writeat all.claude. Standalone rows suppress the default-backend chip, but which model sits behind which role is the entire point of a mixed fleet and must not be inferable only from a chip's absence.Two cases that are easy to get wrong, both pinned by tests:
parentSessionIdnaming a session not in the list is normal — the parent was destroyed while children drain, or hasn't reached this client yet. A session that silently disappears from the sidebar is a far worse failure than one rendered without its group. It keeps its role badges but shows its full name, since no parent row is there to supply context.reasoningrow with no indication of its goal is less useful than no filter at all.2. Owner-facing blackboard verbs (
bc74d32)blackboard.index(metadata, no bodies) andblackboard.read(one body).The exemption from §6, stated plainly because it looks like a hole: role scoping keeps the agents independent of each other — a reviewer that can read its peers is an echo, not a panel. The goal's owner is not a participant. They can already read every child's transcript, so withholding the artifacts those transcripts produced protects nothing and only makes the fleet unobservable.
That reasoning is load-bearing, so it is encoded in the type system rather than in a comment alone.
Blackboard#forOwnerreturns a distinctOwnerBlackboard, not aRoleBlackboardwith a flag — a flag makes "unscoped" reachable by passing a boolean, whereas an explicitforOwner()is greppable and cannot be handed where a role handle is expected. It exposes nowrite(an owner write would land unattributed and unscoped on a board whose entire contract is attributable handoffs), and tenant scoping still applies in full.sessionIdmay name either the orchestrator or one of its role-children. Clients focus children as often as parents, and a client that walkedparentSessionIdwrongly would get an empty board rather than an error — the least debuggable outcome available. The parent is re-fetched through#getOwnedSessionrather than trusted from the child's field, so the check binds to the session whose artifacts are about to be read.indextakessession:list(metadata about a session the holder can already enumerate);readtakessession:watch(a body is session content, the same class as streamed output). Both already sit inWATCHER_SCOPESandOPERATOR_SCOPES, so every token minted before this keeps working — a new scope would have 403'd them all.null, not an error: a collaboration in flight legitimately has empty lanes, and a client can't render that as "pending" if the daemon calls it a failure. An orphaned child whose orchestrator was destroyed does error, because teardown drops the artifacts and "empty" would otherwise be indistinguishable from "gone".3. The drawer (
86b6058)Index left, one artifact body right, fetched only when picked. That split is the design's, not a layout preference: a
diffcan be 256 KB, and the index exists precisely so you can see what's on the board without paying to load it. A test asserts no<pre>renders until you click.findingsrows readingreviewandreview#2is the visible form ofMULTI_WRITER_KINDS; one row would mean a panel had silently collapsed into a single voice.The trigger is a
boardchip inSessionControls, shown for an orchestrator or any role-child, gated on the daemon advertising the newblackboardcapability — against an older daemon the affordance doesn't appear rather than appearing and erroring on click.4. A bug I introduced and caught (
a553383)refKey's separator was written as a literal NUL byte instead of an escape. Nothing that normally catches a mistake said a word: it typechecked, it linted, all 370 web tests passed. The only symptom wasgit diff --statreportingBin 0 -> 6081 bytes— git classifies a file containing a NUL as binary, soweb/src/state/blackboard.tsstopped producing diffs and would have arrived here unreviewable.The separator itself was the right idea and stays, now as an explicit
\u0000in a named constant:kindis an open namespace (extra/<key>) andslotis daemon-generated, so any printable delimiter is one future naming choice away from collapsing two distinct artifacts onto one key — which would render the wrong body under the right row.src/tests/source-hygiene.test.tsnow walks every source file in the repo and fails on a raw NUL. The walker asserts its own file count first, so a broken walk can't pass by finding nothing.Wire compatibility
Additive only. Two new
ClientMessagevariants, two newDaemonMessagevariants, one new capability id. Older clients (including the Rustcodeoid-ui) ignore what they don't know and simply don't grow the panel — no lockstep change is required in codeoid-ui for this PR.Verification
biome + eslint clean; eslint warning count is byte-identical to
origin/main(30, all pre-existing, none in new files). Both bundles build.Mutation-checked — the three security-relevant handler tests were verified against deliberately broken implementations, each failing exactly one test:
another tenant gets not_found, never someone else's boardindexonsession:watchinstead ofsession:listindex needs session:list and read needs session:watcha plain session says it has no blackboard...The NUL guard was mutation-checked too (planting a NUL in
lib/fleet.tsfails it).Live run against a real daemon, because green CI is not a working feature — three separate times on this feature it wasn't. Isolated
XDG_CONFIG_HOME,--local, port 7411. This closes a gap the unit tests structurally cannot: they callmanager.handle()directly, so a wrong Zod schema would still pass while the real socket rejected everything.18/18 checks passed, including:
blackboardcapabilityparseClientMessagecollaborationRolearrives on the wire for every child (review#1 write=false,review#2 write=false)null; a malformedkindis rejected at the transportNot in this PR
Panel + barrier + v1 guards (the N-way join, live-worker cap, cost roll-up), mid-goal restart resume, and the stale
MODEL_CATALOGinsrc/daemon/models.ts. Non-Claude orchestrators remain #245 — the blackboard is deliberately HTTP-mountable so it does not inherit that limit.🤖 Generated with Claude Code