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); + }); +}); +