/** * delegate-spawn.ts — root-side grandchild spawner for governed nesting * (ADR-5 §1/§2/§6, WP-5 step 5). * * The broker's `delegate.request` handler calls this on an admitted request: * a DIRECT `runChildPi` call-site that never touches the global worker * semaphore (the MAJ#3 anti-deadlock precedent generalized — the nested-slot * budget is managed by the broker handler, not here), with `depthOverride` * from the parent task record, namespaced artifacts, and a timeout-driven * soft cancel (`delegate-timeout`). * * Security review round 1 (T3): * - S1#2: the grandchild's ROLE is threaded so role-based tool restrictions * apply (`--tools`/`--exclude-tools` from role-tools config) — a * "read-only" explorer/analyst grandchild must actually be read-only * (ADR-5 §9's serialization exemption depends on it). * R43-S1 (2026-10-19 fix-wave 6): the restrictions are now TRANSITIVE — * the effective grandchild tool policy is the intersection (min) of the * PARENT task role's policy and the requested role's policy * (spawn-policy.ts effectiveGrandchildToolPolicy), applied to the * in-memory agent config below. The delegate grandchildren are spawned * from a MINIMAL AgentConfig (no agents/.md frontmatter is read), * so this is the ONLY argv-level tool cut they receive. * - S1#1: the grandchild authenticates under its OWN subId identity * (grandchild-scoped broker token pre-minted by the handler) — never the * parent's task token. Identity fields (`agentId: subId`) match. * - S2#1: namespaced artifacts via the `artifactsRoot` input field (the env * spread never survived the spawn allowlist). * - S2#3: usage roll-up — message_end usage events are accumulated from the * child's JSON event stream and reported for the parent-task reconciliation. */ import * as fs from "node:fs"; import * as path from "node:path"; import type { AgentConfig } from "../agents/agent-config.ts"; import { type ChildPiRunResult, runChildPi } from "./child-pi/child-pi.ts"; import { effectiveGrandchildToolPolicy } from "./spawn-policy.ts"; export interface GrandchildSpawnInput { cwd: string; runId: string; parentTaskId: string; /** Parent task record role (run-locked read) — R43-S1 transitive tier * containment: the grandchild's tool policy is min(parent, requested). */ parentRole?: string; /** Nested-slot holder id (minted by the broker handler). */ subId: string; prompt: string; role: string; model?: string; maxTurns?: number; /** Mandatory default 900 (spawn-policy normalized). */ timeoutSec: number; /** Parent-task-record depth + 1 (ADR-5 §3). */ depthOverride: number; /** Manifest eventsPath — threads PI_CREW_STATE_ROOT so the grandchild's * ask tool + scratchpad metrics work (stateRoot = dirname(eventsPath)). */ eventsPath?: string; /** Grandchild-scoped broker credentials (S1#1 — NEVER the parent token). */ brokerSpawn?: { socketPath: string; token: string }; onSpawn?: (pid: number | null) => void; /** M3-5 / R43-N3: owner-side cancellation. The broker's delegate abort * registry aborts this on run terminalise / budget abort / broker stop, so * a grandchild can no longer outlive its run by up to its own 24h deadline * (pre-fix: fire-and-forget spawner with no abort path). Aborting produces * `{ ok: false, cancelled: true }` — distinct from the deadline * `timedOut` outcome so the settle path can audit which one happened. */ signal?: AbortSignal; } export interface GrandchildSpawnResult { ok: boolean; /** Fenced-ish result text (caller fences before mailbox delivery). */ resultText: string; /** Usage in tokens for roll-up (input+output+cache summed over * message_end events); undefined = unattributed. * M3-4/R44-N4: AUDIT ONLY — the budget charge is the host-side * reservation, never this self-attested number. */ usageTokens?: number; timedOut?: boolean; /** M3-5/R43-N3: the OWNER aborted the spawn (run terminalise / budget abort * / broker stop) — distinct from the grandchild's own deadline. */ cancelled?: boolean; } /** * Minimal in-memory agent config for a delegate grandchild role. * * R43-S1: applies the EFFECTIVE tool policy — intersection (min) of the * parent task role's policy and the requested grandchild role's policy — * so the pi argv `--tools`/`--exclude-tools` cut reflects the transitive * floor. A read-only parent's grandchild is bash/edit/write-denied even if * the requested role itself is executor-class (defense-in-depth behind the * spawn-policy admission gate). */ export function grandchildAgentForRole(role: string, parentRole?: string): AgentConfig { const policy = effectiveGrandchildToolPolicy(parentRole, role); return { name: role, description: `delegate grandchild (${role})`, source: "builtin", filePath: "", systemPrompt: "", ...(policy.tools !== undefined ? { tools: policy.tools } : {}), ...(policy.excludeTools !== undefined ? { disallowedTools: policy.excludeTools } : {}), }; } /** Sum usage tokens from a message_end JSON event record (S2#3 roll-up). */ export function usageTokensFromEvent(event: unknown): number | undefined { if (!event || typeof event !== "object" || Array.isArray(event)) return undefined; const record = event as { type?: unknown; usage?: unknown; message?: { usage?: unknown } }; if (record.type !== "message_end") return undefined; const usage = (record.usage ?? record.message?.usage) as Record | undefined; if (!usage || typeof usage !== "object") return undefined; let total = 0; let seen = false; for (const key of ["input", "output", "cacheRead", "cacheWrite"]) { const value = usage[key]; if (typeof value === "number" && Number.isFinite(value) && value > 0) { total += value; seen = true; } } return seen ? total : undefined; } /** Namespaced artifacts root: artifacts///nested// */ export function grandchildArtifactsRoot(cwd: string, runId: string, parentTaskId: string, subId: string): string { return path.join(cwd, ".crew", "artifacts", runId, parentTaskId, "nested", subId); } /** RR-023 F3 (2026-09-29 battery finding #3): derive the relayed grandchild * result text from a ChildPiRunResult — pure so it is unit-testable without * spawning. Same precedence as the original inline expression at the capture * site: rawFinalText → stdout → stderr (failure only). * Diagnosability guard: exit 0 with ZERO assistant text (run * team_20260929041427: the model-less gc fell through to the pi global default * and returned empty completions) used to fence an EMPTY mailbox body — * indistinguishable from a relay bug. An explicit marker keeps the relay * honest; the real fix (parent-model inheritance) lives in the broker. */ export function grandchildResultText(result: ChildPiRunResult): string { const ok = result.exitCode === 0; const text = (result.rawFinalText ?? result.stdout ?? "").trim() || (ok ? "" : result.stderr.trim()); return ok && text === "" ? "[grandchild exited 0 with no assistant output]" : text; } export async function spawnDelegateGrandchild(input: GrandchildSpawnInput): Promise { // RR-012 F03: single artifacts formula — grandchildArtifactsRoot(input.cwd, …). // input.cwd is the PARENT TASK cwd threaded by the broker (the admitted // worktree in worktree mode), so artifacts land under the admitted cwd — // never under the leader/broker workspace. const artifactsRoot = grandchildArtifactsRoot(input.cwd, input.runId, input.parentTaskId, input.subId); fs.mkdirSync(artifactsRoot, { recursive: true }); // M3-5/R43-N3: two abort sources — this spawn's own deadline and the // OWNER (broker abort registry). `ownerAborted` distinguishes them for the // settle audit; the AbortController it drives is the single signal handed to // runChildPi, so both paths kill the child the same way. const abort = new AbortController(); let ownerAborted = false; const onOwnerAbort = (): void => { ownerAborted = true; abort.abort(input.signal?.reason); }; if (input.signal?.aborted) onOwnerAbort(); else input.signal?.addEventListener("abort", onOwnerAbort, { once: true }); const timer = setTimeout(() => abort.abort(), input.timeoutSec * 1000); timer.unref?.(); // S2#3: accumulate grandchild usage from message_end events. let usageTokens: number | undefined; const onJsonEvent = (event: unknown): void => { const tokens = usageTokensFromEvent(event); if (tokens !== undefined) usageTokens = (usageTokens ?? 0) + tokens; }; try { const result: ChildPiRunResult = await runChildPi({ cwd: input.cwd, task: input.prompt, // S1#2 + R43-S1: thread the role AND the parent role so the effective // (min-intersected) tool policy applies at the argv level. agent: grandchildAgentForRole(input.role, input.parentRole), role: input.role, ...(input.model !== undefined ? { model: input.model } : {}), ...(input.maxTurns !== undefined ? { maxTurns: input.maxTurns } : {}), depthOverride: input.depthOverride, runId: input.runId, // S1#1: the grandchild carries its OWN identity + credentials — // never the parent's task token. agentId: input.subId, ...(input.brokerSpawn ? { brokerSpawn: input.brokerSpawn } : {}), ...(input.eventsPath ? { eventsPath: input.eventsPath } : {}), // S2#1: namespaced artifacts via the typed field. artifactsRoot, signal: abort.signal, onJsonEvent, onSpawn: input.onSpawn, }); const ok = result.exitCode === 0; return { ok, resultText: grandchildResultText(result), ...(usageTokens !== undefined ? { usageTokens } : {}) }; } catch (error) { const timedOut = abort.signal.aborted && !ownerAborted; const cancelled = ownerAborted; return { ok: false, ...(cancelled ? { cancelled } : { timedOut }), resultText: cancelled ? "[delegate cancelled: run terminated]" : timedOut ? "[delegate timed out]" : `delegate spawn failed: ${(error as Error).message}`, }; } finally { clearTimeout(timer); input.signal?.removeEventListener("abort", onOwnerAbort); } }