fix: a collaboration did not survive a daemon restart — the fence came back missing - #263
Conversation
…e back missing `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.
|
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 changes pushed. All four checks concluded
Verified CI actually attached rather than trusting a clean-looking merge state: this PR is based on
Nothing to auto-fix: commit subject already matches the required prefix regex, branch was 1 ahead / 0 behind Remaining |
Closes the
docs/collaborative-session-design.md§11 P2 exit criterion that was never actually met — "a restart resumes mid-goal" — and repairs a privilege regression found while checking it.collaborationRolehas been persisted since P1b (#256).resumeSessionsreadmeta.collaborationand silently ignored it.What that actually cost
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 ones were the problem. A resumed child came back with:
workerShapescoutleaf (#ensureAgentIdentity)roleDeniesToolhad nothing to deny with — a read-only reviewer'sWrite/Editwent from denied to merely askedguarded; since nobody ever attaches to a child, its first non-safe tool call parks atwaiting_approvalwith zero clientsThe first two are a privilege regression across a restart, not a lost feature. §6's "a reviewer that provably cannot write" held on the create path and quietly stopped holding on the resume path. The last one means the fleet rendered as a live collaboration and was dead.
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.plannedChildForrecovers thePlannedChild— implemented by callingplanChildrenrather than re-derivingshape/write/reads/writesa 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 class of bug unrepresentable rather than merely tested-against.roleChildPostureis now the single definition of a child's restrictions (worker shape + capability role + brief +collaborationRole), called by both#spawnCollaborationChildrenand resume. They were separate before, which is how the omission stayed silent.#blackboardMountFortakes aGoalScopeinstead of a live parentSession. Resume is capped (RESUME_MAX_SESSIONS) and time-boxed, so a parent can legitimately miss the window its own children made — while the board is keyed on(tenant, goalSessionId)in SQLite and needs no resident orchestrator.#attachOrchestratorBlackboardis shared for the same reason: an orchestrator's mount is scoped to its own session id on both paths.Attribution already anticipated all of this —
authorSubis 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, and its history stays one contributor.The torn case
Child transcript present, orchestrator's gone (teardown normally removes both). It restores the fence from what the child itself carries —
writeis oncollaborationRole— and nothing else:Verification
Daemon suite 2064 → 2081. Adds
Session.hasBlackboardMountandSessionManager._sessionForTest(named so a production call site is obvious in review).Mutation-checked. All seven restart tests fail against the pre-fix state (dropping the posture spread). Three further mutations each fail exactly the tests that should catch them:
every member holds an ATTACHED mount…destroying the goal after a restart revokes every resumed tokenan orphaned child keeps its fence but gets no mount and no budgetworkerShape/writehardcoded open in the derivationa read-only role gets BOTH fences, not one of themMutation testing also caught two flaws in my own tests, both fixed — worth stating because a green test that asserts nothing is the failure mode this whole change exists to prevent:
activeTokenscounts tokens ever minted, so it could not distinguish a mount a session holds from one minted and dropped on the floor. The mount test passed under a mutation that broke exactly what it claimed to check. Now asserted per-session viahasBlackboardMount.source-hygiene.test.ts.Verified live across a real process restart, not just a second
SessionManagerin-process: boot a real daemon, create a 3-role collaboration, publish an artifact,SIGTERM, boot again on the same config dir, reconnect. 15/15:reasoning#1,review#1,review#2)collaboration (review)/collaboration (reasoning)autonomouswith a fresh 50-turn budget, androle=workerauthorSubis byte-identical across the restartNot in this PR
The rest of §11 P3 (dispatch barrier,
panelphase kind, synthesis, live-worker cap, per-collaboration cost roll-up). Non-Claude orchestrators remain #245.🤖 Generated with Claude Code