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