From 875a44fa11a618d58f12cfcc5d13021c86d552e2 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Mon, 27 Jul 2026 22:54:44 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20a=20collaboration=20did=20not=20survive?= =?UTF-8?q?=20a=20daemon=20restart=20=E2=80=94=20the=20fence=20came=20back?= =?UTF-8?q?=20missing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/daemon/collaboration.ts | 117 ++++++++++++ src/daemon/session-manager.ts | 280 +++++++++++++++++++++------- src/daemon/session.ts | 15 ++ src/tests/collaboration.test.ts | 316 ++++++++++++++++++++++++++++++++ 4 files changed, 664 insertions(+), 64 deletions(-) diff --git a/src/daemon/collaboration.ts b/src/daemon/collaboration.ts index d2e925d..45ff52b 100644 --- a/src/daemon/collaboration.ts +++ b/src/daemon/collaboration.ts @@ -290,6 +290,123 @@ export function childSessionName(parentName: string, child: PlannedChild): strin return `${parentName}:${child.roleName}${suffix}`; } +/** + * Recover the `PlannedChild` for one live role instance, from the goal config + * plus the identity a child already carries (`collaborationRole`). + * + * The resume counterpart of `planChildren`, and deliberately implemented BY + * calling it rather than re-deriving `shape`/`write`/`reads`/`writes` from the + * role entry a second time. A resumed child that computed its own shape would + * be one edit away from disagreeing with the one it spawned under — and the + * direction that disagreement fails is a read-only reviewer coming back able to + * write. Sharing the derivation makes that class of drift unrepresentable. + * + * `undefined` when the role or ordinal is no longer in the config, which is the + * fail-closed direction: no plan, no restored authority. + */ +export function plannedChildFor( + config: CollaborationConfig, + roleName: string, + ordinal: number, +): PlannedChild | undefined { + const planned = planChildren(config); + if (!planned.ok) return undefined; + return planned.children.find( + (c) => c.roleName === roleName && c.ordinal === ordinal, + ); +} + +/** + * Everything about a role-child's Session that ENCODES ITS RESTRICTIONS — + * worker shape, capability role, and the brief that states the contract. + * + * One function, two callers: `#spawnCollaborationChildren` on create and the + * resume path on restart. They were separate before, and the omission was + * silent in exactly the worst way: a resumed reviewer came back with no + * `workerShape` (so its next turn registered a full session agent instead of a + * scope-capped `scout` leaf) and no capability role (so `roleDeniesTool` had + * nothing to deny with). Nothing failed; the fence was just gone. + * + * `constitution` is a parameter rather than computed here so the degraded + * resume path — child on disk, goal config unrecoverable — can substitute an + * honest "your goal was lost" brief while still getting the real restrictions. + */ +export function roleChildPosture( + child: PlannedChild, + parentSessionId: string, + constitution: string, +): { + role: "worker"; + workerShape: "ship" | "scout"; + pack: { + id: string; + constitution: string; + role: { name: string; write: boolean; network: "read-only"; envelope: "all" }; + roleName: string; + subagents: never[]; + }; + collaborationRole: { + parentSessionId: string; + roleName: string; + ordinal: number; + write: boolean; + }; +} { + return { + role: "worker", + // The enforcement behind §6: a read-only role becomes a "scout", whose + // LEAF identity profile carries no tools:write at all, so it cannot mint + // write authority even via a sub-agent. + workerShape: child.shape, + // ...and the same restriction at the canUseTool fence, where + // roleDeniesTool turns `write: false` into a hard tool deny (Claude-hard; + // advisory + logged on backends whose tools don't all route through the + // gate — see roleEnforcement). + pack: { + id: "collaboration", + constitution, + role: { + name: child.roleName, + write: child.write, + // Not `false`: §3 gives the search role web access, and roleDeniesTool + // only denies network tools on an explicit false. Per-role network + // gating is a later phase. + network: "read-only", + envelope: "all", + }, + roleName: child.roleName, + subagents: [], + }, + collaborationRole: { + parentSessionId, + roleName: child.roleName, + ordinal: child.ordinal, + write: child.write, + }, + }; +} + +/** + * The brief for a child whose goal config could not be recovered on resume. + * + * Reachable only from a torn state (the child's transcript survived while its + * orchestrator's did not — teardown normally removes both). It says so plainly + * instead of leaving the agent to infer a goal from its own transcript, and it + * tells it to stop rather than resume work it can no longer coordinate. + */ +export function orphanedChildBrief(roleName: string, write: boolean): string { + return [ + ``, + `You are the "${roleName}" role of a collaborative session whose goal configuration did not survive a daemon restart.`, + write + ? "Your write authority is unchanged." + : "You are READ-ONLY: your identity holds no write scope, so file edits will be denied.", + "The goal blackboard is NOT mounted — the goal it belonged to is gone, and its artifacts were dropped with it.", + "Do not start new work and do not guess the goal. Report your state if asked, and stop.", + "", + ].join("\n"); +} + /** * The goal brief handed to a role-child on spawn. * diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index b1233f7..b0062ce 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -23,7 +23,7 @@ import { createPushTransport, PushService } from "./push/index.js"; import { hasScope, SCOPES } from "../protocol/scopes.js"; import { applyPatches, getManifest, getSnapshot } from "./settings/store.js"; import { RateLimiter } from "./rate-limit.js"; -import type { TranscriptStore } from "./transcript.js"; +import type { TranscriptMeta, TranscriptStore } from "./transcript.js"; import { FsAccessError, handleFsBrowseDir, @@ -39,14 +39,17 @@ import { resolveModelIdForProvider, } from "./models.js"; import { Blackboard, type OwnerBlackboard } from "./blackboard/service.js"; -import { BlackboardStore } from "./blackboard/store.js"; +import { BlackboardStore, type GoalScope } from "./blackboard/store.js"; import { BlackboardMcpHttp } from "./blackboard/mcp-http.js"; import { childBrief, childSessionName, compileGoalPack, orchestratorRole, + orphanedChildBrief, planChildren, + plannedChildFor, + roleChildPosture, validateCollaboration, type PlannedChild, } from "./collaboration.js"; @@ -103,6 +106,7 @@ import type { PipelinePhaseWire, PipelineWire, SessionInfo, + SessionMode, SessionWorktree, } from "../protocol/types.js"; import type { Scope } from "../protocol/scopes.js"; @@ -459,9 +463,20 @@ export class SessionManager { (a, b) => resumeSortKey(b) - resumeSortKey(a), ); const capped = sorted.slice(0, RESUME_MAX_SESSIONS); + // Goal config by orchestrator session id, built from EVERY meta on disk + // rather than from `capped`. A child inside this boot's resume window whose + // orchestrator fell outside it still needs its restrictions and its brief, + // and the blackboard is keyed on (tenant, goal id) in SQLite — so the + // child's mount works whether or not the orchestrator object is resident. + const goalConfigs = new Map(); + for (const m of allMetas) { + if (m.collaboration) goalConfigs.set(m.sessionId, m.collaboration); + } const deadline = Date.now() + RESUME_DEADLINE_MS; let resumed = 0; let skippedDeadline = 0; + let resumedChildren = 0; + let orphanedChildren = 0; for (let i = 0; i < capped.length; i++) { // Time-box: a few huge transcripts shouldn't wedge startup. Stop and @@ -472,6 +487,14 @@ export class SessionManager { } const meta = capped[i]!; try { + // Role-children need their restrictions rebuilt BEFORE construction — + // worker shape and capability role are constructor inputs, not things + // that can be attached afterwards. + const child = this.#resumeRoleChild(meta, goalConfigs); + if (child) { + if (child.orphaned) orphanedChildren++; + else resumedChildren++; + } const session = new Session({ name: meta.sessionName, // Heal a workdir persisted with a literal `~` or one that has since @@ -508,6 +531,10 @@ mcpHub: this.#mcpHub, // its role→backend bindings must come back after a restart or the // orchestrator resumes with no idea what it was coordinating. collaboration: meta.collaboration, + // ...and the same is true of a CHILD's restrictions. Spread after + // `role` so the worker role from the posture wins over `meta.role` + // (they agree — both are "worker" — but the posture is the authority). + ...(child?.options ?? {}), defaultModel: meta.role === "conductor" ? this.#config?.conductor?.model : undefined, fleet: @@ -536,6 +563,17 @@ mcpHub: this.#mcpHub, }); this.#sessions.set(session.id, session); + // Track a resumed child's mount so teardown revokes it. Without this a + // restart leaks one still-valid blackboard credential per child on + // every boot, and destroying the goal would not revoke them. + if (child?.options.blackboardMcp) { + this.#blackboardTokens.set(session.id, child.options.blackboardMcp.token); + } + // An orchestrator's own mount is scoped to its own id, so it can only + // be attached now — exactly as on the create path. + if (meta.collaboration) { + this.#attachOrchestratorBlackboard(session, meta.collaboration); + } // Resume is NOT a creation — don't burn a slot in the // per-user concurrency cap. Otherwise restarting with N // persisted sessions saturates the limit on the spot and the @@ -554,10 +592,120 @@ mcpHub: this.#mcpHub, `[codeoid] resume: restored ${resumed} of ${sorted.length} session(s); ${droppedCap} left over the ${RESUME_MAX_SESSIONS}-session cap, ${skippedDeadline} skipped past the ${RESUME_DEADLINE_MS}ms deadline (still on disk; loadable on a future restart).`, ); } + if (resumedChildren > 0) { + console.log( + `[codeoid] resume: ${resumedChildren} collaboration role-child(ren) restored with their role scoping + goal blackboard`, + ); + } + // Loud, because it is the one path where a role-child comes back WITHOUT + // its goal: its restrictions hold, but it cannot coordinate, and a silent + // degrade would look identical to a healthy fleet in the session list. + if (orphanedChildren > 0) { + console.warn( + `[codeoid] resume: ${orphanedChildren} role-child(ren) had no recoverable goal config — restored read-only-as-configured, with no blackboard mount and no autonomous budget`, + ); + } return resumed; } + /** + * Rebuild a role-child's restrictions from disk, or `undefined` when this + * meta isn't a role-child at all. + * + * Why this exists: `collaborationRole` was persisted from the first day of + * P1b, and resume read `meta.collaboration` while silently ignoring it. The + * visible symptom was cosmetic (children detached from their parent in the + * session list). The real one was not — 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 tools degraded from denied to merely asked; + * - no blackboard mount, so it could not publish a handoff; and + * - no autonomous budget, so it came back `guarded` — which, with nobody + * ever attached to a child, parks it at `waiting_approval` forever on its + * first non-safe tool call. The fleet looked alive and was dead. + * + * Nothing about a child needs a new persisted field: its identity + * (`collaborationRole`) plus its goal's config reproduces the plan it spawned + * under, via `plannedChildFor`. + */ + #resumeRoleChild( + meta: TranscriptMeta, + goalConfigs: ReadonlyMap, + ): + | { + orphaned: boolean; + options: { + role: "worker"; + workerShape: "ship" | "scout"; + pack: ReturnType["pack"]; + collaborationRole: ReturnType["collaborationRole"]; + initialMode?: { mode: SessionMode; maxTurns?: number }; + blackboardMcp?: { url: string; token: string }; + }; + } + | undefined { + const role = meta.collaborationRole; + if (!role) return undefined; + + const collaboration = goalConfigs.get(role.parentSessionId); + const planned = collaboration + ? plannedChildFor(collaboration, role.roleName, role.ordinal) + : undefined; + + if (!collaboration || !planned) { + // Torn state: the child's transcript survived while its orchestrator's + // did not (teardown removes both). Restore the FENCE from what the child + // itself carries — `write` is on `collaborationRole` — and nothing else. + // Deliberately no autonomous budget: an agent that cannot coordinate + // should not be able to burn turns unattended. + return { + orphaned: true, + options: roleChildPosture( + { + roleName: role.roleName, + ordinal: role.ordinal, + providerId: meta.providerId ?? "claude", + shape: role.write ? "ship" : "scout", + write: role.write, + }, + role.parentSessionId, + orphanedChildBrief(role.roleName, role.write), + ), + }; + } + + return { + orphaned: false, + options: { + ...roleChildPosture(planned, role.parentSessionId, childBrief(collaboration, planned)), + // Re-armed per boot, not persisted: the budget is a per-stretch-of-work + // allowance, and carrying a spent one across a restart would resume a + // child with zero turns left. + initialMode: { + mode: "autonomous", + maxTurns: this.#dispatcher.config.workerToolBudget, + }, + // Scoped to the child's OWN tenant plus its goal id. Same authorSub the + // pre-restart versions carry, so its history stays one contributor. + ...(() => { + const mount = this.#blackboardMountFor( + { + accountId: meta.accountId, + projectId: meta.projectId, + goalSessionId: role.parentSessionId, + }, + planned, + collaboration.roles.find((r) => r.name === role.roleName), + ); + return mount ? { blackboardMcp: mount } : {}; + })(), + }, + }; + } + /** * Resolve a session by id, gated on tenancy. Returns null when: * @@ -1481,6 +1629,15 @@ mcpHub: this.#mcpHub, return this.#blackboardMcp; } + /** + * Unscoped session lookup — tests only, and named so a production call site + * is obvious in review. Everything user-facing must go through + * `#getOwnedSession`, which gates on tenancy. + */ + _sessionForTest(id: string): Session | undefined { + return this.#sessions.get(id); + } + /** * The URL a role-child mounts the blackboard from. Loopback regardless of the * daemon's bind address — the agent subprocess runs on this host, and the @@ -1737,30 +1894,7 @@ mcpHub: this.#mcpHub, this.#sessions.set(session.id, session); this.#rateLimiter.recordCreation(auth.sub); - // The orchestrator needs the blackboard too, and needs it MOST: §4 has it - // holding the index of artifact states, and §7 has it reading every - // reviewer's findings to synthesize. Without a mount it cannot see a - // single thing its children publish, and the coordination loop never - // closes. Attached here rather than passed to the constructor because the - // goal id it is scoped to IS this session's id. - if (collaboration) { - const orchestrator = orchestratorRole(collaboration); - const mount = this.#blackboardMountFor( - session, - { - roleName: ORCHESTRATOR_ROLE, - ordinal: 1, - providerId: session.providerId, - shape: "scout", - write: false, - }, - orchestrator, - ); - if (mount) { - session.attachBlackboard(mount); - this.#blackboardTokens.set(session.id, mount.token); - } - } + if (collaboration) this.#attachOrchestratorBlackboard(session, collaboration); if (collaboration && planned.length > 0) { const spawned = await this.#spawnCollaborationChildren(session, collaboration, planned, auth); @@ -1845,32 +1979,72 @@ mcpHub: this.#mcpHub, * * The minted token carries the role's scope, so the child's mount is its * permission — there is no wider handle reachable from it. + * + * Takes the goal SCOPE rather than a live parent `Session` so the resume path + * can mint a mount without one. The blackboard is keyed on + * (tenant, goalSessionId) in SQLite, so a child's access to its goal does not + * depend on the orchestrator object being resident — which matters because + * resume is capped and time-boxed, and a parent can legitimately miss the + * window its own children made. */ #blackboardMountFor( - parent: Session, + scope: GoalScope, child: PlannedChild, role: CollaborationRole | undefined, ): { url: string; token: string } | undefined { if (!this.#blackboardUrl) return undefined; const handle = this.#goalBlackboard().forRole( - { - accountId: parent.accountId, - projectId: parent.projectId, - goalSessionId: parent.id, - }, + scope, { roleName: child.roleName, ordinal: child.ordinal, // Attribution keyed to the ROLE within the goal, not the child's // session id: a role-child replaced after a restart is still the same // contributor, and its earlier artifacts should keep reading that way. - authorSub: `agent:${parent.id}:${child.roleName}#${child.ordinal}`, + // That property is what makes resume work at all — a resumed child + // writes under the authorSub its pre-restart versions carry. + authorSub: `agent:${scope.goalSessionId}:${child.roleName}#${child.ordinal}`, }, role ? { reads: role.reads, writes: role.writes } : undefined, ); return { url: this.#blackboardUrl, token: this.#blackboardMcp.mint(handle) }; } + /** + * Give an orchestrator its own blackboard mount. + * + * The orchestrator needs the blackboard MOST: §4 has it holding the index of + * artifact states, and §7 has it reading every reviewer's findings to + * synthesize. Without a mount it cannot see a single thing its children + * publish and the coordination loop never closes. + * + * Called post-construction in both `#create` and resume, because the goal id + * it is scoped to IS this session's own id. + */ + #attachOrchestratorBlackboard( + session: Session, + collaboration: CollaborationConfig, + ): void { + const mount = this.#blackboardMountFor( + { + accountId: session.accountId, + projectId: session.projectId, + goalSessionId: session.id, + }, + { + roleName: ORCHESTRATOR_ROLE, + ordinal: 1, + providerId: session.providerId, + shape: "scout", + write: false, + }, + orchestratorRole(collaboration), + ); + if (!mount) return; + session.attachBlackboard(mount); + this.#blackboardTokens.set(session.id, mount.token); + } + async #spawnCollaborationChildren( parent: Session, collaboration: CollaborationConfig, @@ -1882,7 +2056,11 @@ mcpHub: this.#mcpHub, // Minted before construction so the child's provider can mount it from // the start — the token carries this role's read/write scope. const blackboard = this.#blackboardMountFor( - parent, + { + accountId: parent.accountId, + projectId: parent.projectId, + goalSessionId: parent.id, + }, child, collaboration.roles.find((r) => r.name === child.roleName), ); @@ -1896,36 +2074,10 @@ mcpHub: this.#mcpHub, hooks: this.#hooks, providerId: child.providerId, defaultModel: child.model, - role: "worker", - // The enforcement behind §6: a read-only role becomes a "scout", - // whose LEAF identity profile carries no tools:write at all, so it - // cannot mint write authority even via a sub-agent. - workerShape: child.shape, - // ...and the same restriction at the canUseTool fence, where - // roleDeniesTool turns `write: false` into a hard tool deny - // (Claude-hard; advisory + logged on backends whose tools don't all - // route through the gate — see roleEnforcement). - pack: { - id: "collaboration", - constitution: childBrief(collaboration, child), - role: { - name: child.roleName, - write: child.write, - // Not `false`: §3 gives the search role web access, and - // roleDeniesTool only denies network tools on an explicit false. - // Per-role network gating is a later phase. - network: "read-only", - envelope: "all", - }, - roleName: child.roleName, - subagents: [], - }, - collaborationRole: { - parentSessionId: parent.id, - roleName: child.roleName, - ordinal: child.ordinal, - write: child.write, - }, + // Worker shape, capability role, brief, and collaborationRole — the + // whole restriction set, from the one function the resume path also + // calls so the two can't drift. + ...roleChildPosture(child, parent.id, childBrief(collaboration, child)), // Autonomous with a bounded budget — the same posture dispatch gives // its workers, and for the same reason: NOBODY ATTACHES TO A CHILD. // The owner's approval happens once at dispatch time (the R3 gate on diff --git a/src/daemon/session.ts b/src/daemon/session.ts index cb80f1f..a60d3b8 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -2259,6 +2259,21 @@ export class Session { this.#blackboardMcp = mount; } + /** + * Whether a goal-blackboard mount is attached to THIS session. + * + * Deliberately not inferable from `BlackboardMcpHttp.activeTokens`: that + * counts tokens ever minted, so a mount that was minted and then dropped on + * the floor is indistinguishable there from one a session actually holds. + * The distinction is the whole difference between a resumed role-child that + * can publish a handoff and one that silently cannot. + * + * The token itself is never exposed — only whether one is present. + */ + get hasBlackboardMount(): boolean { + return this.#blackboardMcp !== undefined; + } + toInfo(): SessionInfo { return { id: this.id, diff --git a/src/tests/collaboration.test.ts b/src/tests/collaboration.test.ts index b6b8445..e587f5b 100644 --- a/src/tests/collaboration.test.ts +++ b/src/tests/collaboration.test.ts @@ -22,9 +22,13 @@ import { join } from "node:path"; import type { CodeoidConfig } from "../config.js"; import { orchestratorRole, + orphanedChildBrief, parseRoleSpec, planChildren, + plannedChildFor, + roleChildPosture, validateCollaboration, + type PlannedChild, type ProviderLookup, } from "../daemon/collaboration.js"; import { MockSessionProvider, mockResult } from "../daemon/providers/mock/session-provider.js"; @@ -1102,3 +1106,315 @@ describe("blackboard.index / blackboard.read", () => { expect(resp.code).toBe("not_found"); }); }); + +// ── Resume: the child's restrictions are DERIVED, never re-invented ────────── + +// The security property lives in this pair of pure functions, so it is pinned +// here rather than only through a manager. `plannedChildFor` is implemented by +// calling `planChildren` precisely so a resumed child cannot compute a +// different shape than the one it spawned under — and the direction that drift +// fails is a read-only reviewer coming back able to write. +describe("plannedChildFor", () => { + const CONFIG: CollaborationConfig = { + goal: "g", + roles: [ + { name: "orchestrator", providerId: "claude" }, + { name: "review", providerId: "gemini", count: 3, write: false }, + { name: "reasoning", providerId: "openai", write: true, model: "gpt-5-codex" }, + ], + }; + + test("reproduces exactly what planChildren produced, member for member", () => { + const planned = planChildren(CONFIG); + expect(planned.ok).toBe(true); + if (!planned.ok) return; + for (const child of planned.children) { + expect(plannedChildFor(CONFIG, child.roleName, child.ordinal)).toEqual(child); + } + }); + + test("carries the write authority and the shape that follows from it", () => { + expect(plannedChildFor(CONFIG, "review", 2)).toMatchObject({ + write: false, + shape: "scout", + providerId: "gemini", + ordinal: 2, + }); + expect(plannedChildFor(CONFIG, "reasoning", 1)).toMatchObject({ + write: true, + shape: "ship", + model: "gpt-5-codex", + }); + }); + + test("fails closed on a role or ordinal that is no longer in the config", () => { + // No plan means no restored authority — the safe direction. + expect(plannedChildFor(CONFIG, "review", 4)).toBeUndefined(); + expect(plannedChildFor(CONFIG, "gone", 1)).toBeUndefined(); + // The orchestrator is never a child (planChildren excludes it). + expect(plannedChildFor(CONFIG, "orchestrator", 1)).toBeUndefined(); + }); +}); + +describe("roleChildPosture", () => { + const scout: PlannedChild = { + roleName: "review", + ordinal: 2, + providerId: "gemini", + shape: "scout", + write: false, + }; + + test("a read-only role gets BOTH fences, not one of them", () => { + const p = roleChildPosture(scout, "goal-1", "BRIEF"); + // The leaf identity profile: scout holds no tools:write. + expect(p.workerShape).toBe("scout"); + // ...and the canUseTool gate, independently. + expect(p.pack.role.write).toBe(false); + expect(roleDeniesTool(p.pack.role, "Write")).toMatch(/read-only/); + expect(roleDeniesTool(p.pack.role, "Edit")).toMatch(/read-only/); + // Reads stay allowed — a reviewer that cannot read is useless. + expect(roleDeniesTool(p.pack.role, "Read")).toBeNull(); + }); + + test("network stays read-only, not false — the search role needs the web", () => { + const p = roleChildPosture(scout, "goal-1", "BRIEF"); + expect(p.pack.role.network).toBe("read-only"); + expect(roleDeniesTool(p.pack.role, "WebFetch")).toBeNull(); + }); + + test("a writing role gets ship and an unblocked write path", () => { + const p = roleChildPosture( + { roleName: "reasoning", ordinal: 1, providerId: "openai", shape: "ship", write: true }, + "goal-1", + "BRIEF", + ); + expect(p.workerShape).toBe("ship"); + expect(p.pack.role.write).toBe(true); + expect(roleDeniesTool(p.pack.role, "Write")).toBeNull(); + }); + + test("stamps the collaborationRole a client groups the fleet by", () => { + const p = roleChildPosture(scout, "goal-1", "BRIEF"); + expect(p.collaborationRole).toEqual({ + parentSessionId: "goal-1", + roleName: "review", + ordinal: 2, + write: false, + }); + expect(p.role).toBe("worker"); + }); +}); + +describe("orphanedChildBrief", () => { + test("says the goal is gone and tells the agent to stop", () => { + const b = orphanedChildBrief("review", false); + expect(b).toMatch(/did not survive a daemon restart/); + expect(b).toMatch(/blackboard is NOT mounted/); + expect(b).toMatch(/Do not start new work/); + expect(b).toMatch(/READ-ONLY/); + }); + + test("does not claim read-only for a role that writes", () => { + expect(orphanedChildBrief("reasoning", true)).not.toMatch(/READ-ONLY/); + }); +}); + +// ── Resume: a real restart, driven through resumeSessions() ───────────────── + +// A second SessionManager over the SAME sqlite file and transcript dir is what +// a daemon restart actually is. Asserting through `session.list` rather than +// through internals keeps these honest about what a client can observe. +// +// Every one of these failed before this change, and the cosmetic one was the +// least of it: children came back with no worker shape, no capability role, no +// blackboard mount, and in `guarded` mode with nobody attached — so the fleet +// rendered as unrelated sessions AND deadlocked on its first handoff. +describe("collaboration survives a daemon restart", () => { + const CONFIG: CollaborationConfig = { + goal: "Survive the restart", + roles: [ + { name: "orchestrator", providerId: "claude" }, + { name: "review", providerId: "gemini", count: 2 }, + { name: "reasoning", providerId: "claude", write: true }, + ], + }; + + const BLACKBOARD_URL = "http://127.0.0.1:7400/mcp/blackboard"; + + /** Restart: a fresh manager over the same on-disk state. */ + async function restart(): Promise { + await manager.drain(3_000); + await Bun.sleep(150); // let in-flight meta writes land + const next = new SessionManager( + new Store(join(tmp, "codeoid.db")), + new TranscriptStore(join(tmp, "transcripts")), + undefined, + undefined, + undefined, + { config: mkConfig(), providers: makeRegistry() }, + ); + next.setBlackboardUrl(BLACKBOARD_URL); + await next.resumeSessions(); + manager = next; // so afterEach drains the live one + return next; + } + + const listFrom = async (m: SessionManager): Promise => { + const resp = await m.handle( + { type: "session.list", id: `ls-${Math.random()}` }, + AUTH, + CLIENT, + ); + return (resp as { sessions: SessionInfo[] }).sessions; + }; + + const createGoal = async (id: string): Promise => { + manager.setBlackboardUrl(BLACKBOARD_URL); + const resp = await run({ type: "session.create", id, name: id, workdir, collaboration: CONFIG }); + if (resp.type !== "response.ok") throw new Error(`create failed: ${JSON.stringify(resp)}`); + return resp.data as SessionInfo; + }; + + test("children come back attached to their parent, with role + ordinal", async () => { + const parent = await createGoal("rs1"); + const before = childrenOf(await allSessions(), parent.id); + expect(before).toHaveLength(3); + + const kids = childrenOf(await listFrom(await restart()), parent.id); + // Without collaborationRole these are three unrelated sessions whose only + // hint of belonging together is a name prefix. + expect(kids).toHaveLength(3); + expect( + kids.map((k) => `${k.collaborationRole!.roleName}#${k.collaborationRole!.ordinal}`).sort(), + ).toEqual(["reasoning#1", "review#1", "review#2"]); + }); + + test("write authority is restored per role, not uniformly", async () => { + const parent = await createGoal("rs2"); + const kids = childrenOf(await listFrom(await restart()), parent.id); + const byRole = new Map(kids.map((k) => [`${k.collaborationRole!.roleName}#${k.collaborationRole!.ordinal}`, k])); + expect(byRole.get("review#1")!.collaborationRole!.write).toBe(false); + expect(byRole.get("review#2")!.collaborationRole!.write).toBe(false); + expect(byRole.get("reasoning#1")!.collaborationRole!.write).toBe(true); + }); + + test("the capability role comes back, so roleDeniesTool has something to deny with", async () => { + // `profile` is "collaboration ()" only when the pack AND its + // capability role are active. Before this change a resumed child had + // neither, so a read-only reviewer's Write went from denied to merely asked. + const parent = await createGoal("rs3"); + const kids = childrenOf(await listFrom(await restart()), parent.id); + expect(kids.map((k) => k.profile).sort()).toEqual([ + "collaboration (reasoning)", + "collaboration (review)", + "collaboration (review)", + ]); + expect(kids.every((k) => k.role === "worker")).toBe(true); + }); + + test("children come back autonomous with a fresh budget, not guarded", async () => { + // NOBODY ATTACHES TO A CHILD. Guarded means the first non-safe tool call + // parks at waiting_approval with zero clients and the goal deadlocks. + const parent = await createGoal("rs4"); + const kids = childrenOf(await listFrom(await restart()), parent.id); + expect(kids.map((k) => k.mode)).toEqual(["autonomous", "autonomous", "autonomous"]); + expect(kids.every((k) => (k.turnsRemaining ?? 0) > 0)).toBe(true); + }); + + test("every member holds an ATTACHED mount, not just a minted token", async () => { + // Asserted per session rather than via `activeTokens`, which counts tokens + // ever minted — a mount minted and then dropped on the floor looks + // identical there to one a session actually holds, and that difference is + // exactly whether a resumed child can publish a handoff. + const parent = await createGoal("rs5"); + const next = await restart(); + + const resumedParent = (await listFrom(next)).find((s) => s.id === parent.id)!; + expect(resumedParent.collaboration?.goal).toBe(CONFIG.goal); + expect(resumedParent.collaboration?.roles).toHaveLength(3); + + const held = (id: string) => next._sessionForTest(id)?.hasBlackboardMount; + expect(held(parent.id)).toBe(true); // §4 index + §7 synthesis + + // Guard the guard: the per-child assertions below live inside a loop, so + // without this a regression that returns NO children passes vacuously — + // which is exactly what happened on the first draft of this test. + const kids = childrenOf(await listFrom(next), parent.id); + expect(kids).toHaveLength(3); + for (const kid of kids) { + expect(held(kid.id)).toBe(true); + } + expect(next.blackboardMcp.activeTokens).toBe(4); + }); + + test("a resumed child can still read the artifacts it wrote before the restart", async () => { + // The end-to-end point of the whole change: attribution is keyed to the + // ROLE within the goal, so a resumed child's mount addresses the same + // artifacts its pre-restart self published. + const parent = await createGoal("rs6"); + const bb = new Blackboard(new BlackboardStore(store.database)); + const scope = { + accountId: AUTH.accountId, + projectId: AUTH.projectId, + goalSessionId: parent.id, + }; + bb.forRole(scope, { + roleName: "reasoning", + ordinal: 1, + authorSub: `agent:${parent.id}:reasoning#1`, + }).write("diff", "PRE-RESTART DIFF"); + + const next = await restart(); + const idx = await next.handle( + { type: "blackboard.index", id: "i1", sessionId: parent.id }, + AUTH, + CLIENT, + ); + expect(idx.type).toBe("blackboard.index.result"); + if (idx.type !== "blackboard.index.result") return; + expect(idx.entries.map((e) => e.kind)).toEqual(["diff"]); + expect(idx.entries[0]!.authorSub).toBe(`agent:${parent.id}:reasoning#1`); + }); + + test("destroying the goal after a restart revokes every resumed token", async () => { + // The tokens minted during resume must be tracked, or each boot leaks one + // still-valid credential per child and teardown revokes none of them. + const parent = await createGoal("rs7"); + const next = await restart(); + expect(next.blackboardMcp.activeTokens).toBe(4); + const destroyed = await next.handle( + { type: "session.destroy", id: "d1", sessionId: parent.id }, + AUTH, + CLIENT, + ); + expect(destroyed.type).toBe("response.ok"); + expect(next.blackboardMcp.activeTokens).toBe(0); + }); + + test("an orphaned child keeps its fence but gets no mount and no budget", async () => { + // Torn state: the child's transcript survives, its orchestrator's doesn't. + const parent = await createGoal("rs8"); + await manager.drain(3_000); + await Bun.sleep(150); + rmSync(join(tmp, "transcripts", `${parent.id}.meta.json`), { force: true }); + rmSync(join(tmp, "transcripts", parent.id), { recursive: true, force: true }); + + const next = await restart(); + const kids = childrenOf(await listFrom(next), parent.id); + expect(kids).toHaveLength(3); + // The fence holds — that is the part that must never fail open. + const review = kids.find((k) => k.collaborationRole!.roleName === "review")!; + expect(review.collaborationRole!.write).toBe(false); + expect(review.profile).toBe("collaboration (review)"); + // ...but it cannot coordinate, so it is not handed turns to burn. + expect(kids.every((k) => k.mode !== "autonomous")).toBe(true); + // No goal, no board: minting a mount for artifacts that were dropped with + // the goal would hand out a credential to nothing. + for (const kid of kids) { + expect(next._sessionForTest(kid.id)?.hasBlackboardMount).toBe(false); + } + expect(next.blackboardMcp.activeTokens).toBe(0); + }); +}); +