diff --git a/docs/api/mcp.md b/docs/api/mcp.md index b7d05041..3042fab7 100644 --- a/docs/api/mcp.md +++ b/docs/api/mcp.md @@ -4073,6 +4073,14 @@ Epoch ms the ledger recorded this settlement — the resolution a progress-based > `readonly` **perWorker**: [`Budget`](index.md#budget-4) +##### deliverable? + +> `readonly` `optional` **deliverable?**: [`DeliverableSpec`](runtime.md#deliverablespec)\<`unknown`\> + +The same independent completion check used for workers. When present, the driver receives a +`submit_result` tool and may finish work itself instead of being forced to delegate it. The +first passing submission is retained; a false or throwing check fails closed. + ##### analysts? > `readonly` `optional` **analysts?**: [`AnalystRegistry`](index.md#analystregistry) @@ -4215,6 +4223,16 @@ choice, steerable counterpart to the one-shot own-sandbox delegation MCP. `string` \| `undefined` +##### submittedResult() + +> **submittedResult**(): \{ `result`: `unknown`; \} \| `undefined` + +The first result whose injected independent check passed, if the driver submitted one. + +###### Returns + +\{ `result`: `unknown`; \} \| `undefined` + ##### settled() > **settled**(): readonly [`SettledWorker`](#settledworker)[] diff --git a/docs/api/runtime.md b/docs/api/runtime.md index 7bbea49f..6f9b4281 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -9205,6 +9205,13 @@ Resolve a spawned `profile` to a worker LEAF or a driver child (the recursion se Per-child budget reserved from the conserved pool on each spawn. +##### deliverable? + +> `readonly` `optional` **deliverable?**: [`DeliverableSpec`](#deliverablespec)\<`unknown`\> + +Independent completion check for work the driver performs itself. When present, the driver + receives `submit_result`; the first passing submission ends the loop and becomes the output. + ##### maxLiveWorkers? > `readonly` `optional` **maxLiveWorkers?**: `number` @@ -9461,6 +9468,18 @@ The URL an in-box harness mounts as `mcp.mcpServers.coordination.url`. > `readonly` **port**: `number` +##### submittedResult + +> **submittedResult**: () => \{ `result`: `unknown`; \} \| `undefined` + +The first driver-authored result whose injected independent check passed. + +The first result whose injected independent check passed, if the driver submitted one. + +###### Returns + +\{ `result`: `unknown`; \} \| `undefined` + ##### drainResolved > **drainResolved**: () => `Promise`\<`number`\> @@ -11707,9 +11726,9 @@ WHERE workers run — derives the worker seam. Provide this OR an explicit `make > `readonly` `optional` **deliverable?**: [`DeliverableSpec`](#deliverablespec)\<`unknown`\> -The completion oracle for backend-derived workers (settled ⟺ delivered). Strongly recommended: - without it the supervisor trusts a worker's self-report — exactly the "ran but didn't deliver" - failure mode of a static orchestrator. +The independent completion check for backend-derived workers and direct supervisor + submissions. Strongly recommended: without it the supervisor cannot submit its own work and + backend-derived workers fall back to their own validity signal. ##### makeWorkerAgent? @@ -11985,6 +12004,12 @@ Resolve a spawned worker `profile` to a leaf agent — the recursion seam (same Per-child budget reserved from the conserved pool on each spawn. +##### deliverable? + +> `readonly` `optional` **deliverable?**: [`DeliverableSpec`](#deliverablespec)\<`unknown`\> + +Independent completion check for direct driver work (`submit_result`). + ##### maxLiveWorkers? > `readonly` `optional` **maxLiveWorkers?**: `number` @@ -19969,6 +19994,12 @@ Stand up the coordination MCP over a live scope. The HOST address is `127.0.0.1` [`Budget`](index.md#budget-4) +###### deliverable? + +[`DeliverableSpec`](#deliverablespec)\<`unknown`\> + +Independent completion check exposed to the driver as `submit_result`. + ###### maxLiveWorkers? `number` diff --git a/src/mcp/tools/coordination.ts b/src/mcp/tools/coordination.ts index 040fffea..e59600dc 100644 --- a/src/mcp/tools/coordination.ts +++ b/src/mcp/tools/coordination.ts @@ -15,6 +15,7 @@ import type { Settled, Agent as SuperviseAgent, } from '../../runtime' +import type { DeliverableSpec } from '../../runtime/supervise/completion-gate' import { type WatchTraceOptions, watchTrace } from '../../runtime/supervise/detector-monitor' import { freeSlots } from '../../runtime/supervise/dispatch' import { type BusRecord, type BusStats, createEventBus } from '../../runtime/supervise/event-bus' @@ -104,6 +105,12 @@ export interface CoordinationToolsOptions { readonly blobs: ResultBlobStore readonly makeWorkerAgent: MakeWorkerAgent readonly perWorker: Budget + /** + * The same independent completion check used for workers. When present, the driver receives a + * `submit_result` tool and may finish work itself instead of being forced to delegate it. The + * first passing submission is retained; a false or throwing check fails closed. + */ + readonly deliverable?: DeliverableSpec readonly analysts?: AnalystRegistry readonly onEvent?: (event: CoordinationEvent) => void | Promise readonly questionPolicy?: QuestionPolicy @@ -181,6 +188,8 @@ export interface CoordinationTools { readonly tools: McpToolDescriptor[] isStopped(): boolean stopReason(): string | undefined + /** The first result whose injected independent check passed, if the driver submitted one. */ + submittedResult(): { readonly result: unknown } | undefined settled(): ReadonlyArray questions(): ReadonlyArray /** The full ordered log of every bus event — UP (settled / question / finding) and DOWN @@ -216,6 +225,7 @@ export const coordinationVerbNames = [ 'list_questions', 'answer_question', 'ask_parent', + 'submit_result', 'stop', 'list_analysts', 'run_analyst', @@ -225,8 +235,10 @@ const idArg = { type: 'string', description: 'The workerId returned by spawn_age /** Build the driver's MCP tools over a live scope. */ export function createCoordinationTools(opts: CoordinationToolsOptions): CoordinationTools { + const deliverable = opts.deliverable let stopped = false let reason: string | undefined + let submitted: { readonly result: unknown } | undefined let questionSeq = 0 const ledger: SettledWorker[] = [] const questions: QuestionRecord[] = [...(opts.priorQuestions ?? [])] @@ -984,6 +996,66 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin return { question: q } }, }, + ...(deliverable + ? [ + { + name: 'submit_result', + description: [ + 'Submit the complete result to the injected independent check.', + 'The first passing result is retained; stop work when accepted.', + ...(deliverable.describe ? [`Expected result: ${deliverable.describe}`] : []), + ].join(' '), + inputSchema: { + type: 'object', + properties: { + result: { + description: 'The complete result in the form requested by the task.', + }, + }, + required: ['result'], + additionalProperties: false, + }, + handler: async (raw: unknown) => { + if (submitted) { + return { + accepted: true, + retained: 'earlier-passing-result', + stop: true, + } + } + const a = obj(raw) + if (!Object.hasOwn(a, 'result')) { + throw new Error('submit_result: "result" is required') + } + + // Copy once at intake so the value checked below is the exact value retained after + // acceptance, even when this handler is called directly rather than through JSON-RPC. + const result = structuredClone(a.result) + let accepted = false + try { + accepted = (await deliverable.check(result)) === true + } catch { + accepted = false + } + if (!accepted) return { accepted: false, stop: false } + // Two remote callers may submit concurrently. Whichever passing check completes + // first owns the retained result; a later completion must never overwrite it. + if (submitted) { + return { + accepted: true, + retained: 'earlier-passing-result', + stop: true, + } + } + + submitted = Object.freeze({ result }) + stopped = true + reason = 'result-accepted' + return { accepted: true, retained: 'this-result', stop: true } + }, + } satisfies McpToolDescriptor, + ] + : []), { name: 'stop', description: 'Declare the run complete.', @@ -1046,6 +1118,7 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin stats: () => bus.stats(), isStopped: () => stopped, stopReason: () => reason, + submittedResult: () => submitted, settled: () => ledger, questions: () => questions, drainResolved, diff --git a/src/runtime/supervise/coordination-driver.ts b/src/runtime/supervise/coordination-driver.ts index ef391869..98fcbaff 100644 --- a/src/runtime/supervise/coordination-driver.ts +++ b/src/runtime/supervise/coordination-driver.ts @@ -44,6 +44,7 @@ import { type ToolLoopCompaction, type ToolLoopCompactionOptions, } from '../tool-loop' +import type { DeliverableSpec } from './completion-gate' import type { PriorCoordination } from './coordination-log' import { bestDelivered, @@ -81,6 +82,9 @@ export interface DriverAgentOptions { readonly makeWorkerAgent: MakeWorkerAgent /** Per-child budget reserved from the conserved pool on each spawn. */ readonly perWorker: Budget + /** Independent completion check for work the driver performs itself. When present, the driver + * receives `submit_result`; the first passing submission ends the loop and becomes the output. */ + readonly deliverable?: DeliverableSpec /** Hard cap on simultaneously-LIVE workers — `spawn_agent` fails closed once this many are in * flight (a concurrency fence on top of the conserved-pool fence). Omit/`<= 0` = no cap. */ readonly maxLiveWorkers?: number @@ -307,6 +311,7 @@ export function driverAgent(opts: DriverAgentOptions): Agent { blobs: opts.blobs, makeWorkerAgent: opts.makeWorkerAgent, perWorker: opts.perWorker, + ...(opts.deliverable ? { deliverable: opts.deliverable } : {}), ...(opts.maxLiveWorkers !== undefined ? { maxLiveWorkers: opts.maxLiveWorkers } : {}), ...(opts.analysts ? { analysts: opts.analysts } : {}), ...(opts.analyzeOnSettle ? { analyzeOnSettle: opts.analyzeOnSettle } : {}), @@ -484,9 +489,11 @@ export function driverAgent(opts: DriverAgentOptions): Agent { // never be lost to the driver's pull discipline (e.g. a brain that spawned and stopped // without awaiting). Non-blocking: live children are the supervisor's to tear down. await coord.drainResolved() - // The driver's deliverable comes from the finalizer seam over DELIVERED children only - // (the completion-oracle), never its own prose — a driver cannot self-declare done - // (Foreman 0/18). Default keep-best; nothing delivered → undefined. + // Direct work is eligible only through `submit_result`, after the same injected independent + // check workers face. Raw driver prose remains ineligible. The first passing submission wins; + // otherwise finalize over delivered children as before. + const submitted = coord.submittedResult() + if (submitted) return submitted.result return runFinalizer(opts.finalizer ?? bestDelivered, { settled: coord.settled(), blobs: opts.blobs, diff --git a/src/runtime/supervise/coordination-mcp.ts b/src/runtime/supervise/coordination-mcp.ts index 12b4a8b0..b1df02d1 100644 --- a/src/runtime/supervise/coordination-mcp.ts +++ b/src/runtime/supervise/coordination-mcp.ts @@ -33,6 +33,7 @@ import { type SettledWorker, type WorkerWatchOptions, } from '../../mcp/tools/coordination' +import type { DeliverableSpec } from './completion-gate' import type { Budget, ResultBlobStore, Scope } from './types' export interface CoordinationMcpHandle { @@ -41,6 +42,8 @@ export interface CoordinationMcpHandle { readonly port: number /** The coordination tools' settled-worker ledger (for the driver's finalize). */ settled(): ReadonlyArray + /** The first driver-authored result whose injected independent check passed. */ + submittedResult: CoordinationTools['submittedResult'] /** Post-loop drain of already-settled, unpulled children into the ledger — call before reading * `settled()` for a finalize, so a delivered child the harness never awaited is not lost. */ drainResolved: CoordinationTools['drainResolved'] @@ -61,6 +64,8 @@ export async function serveCoordinationMcp(opts: { blobs: ResultBlobStore makeWorkerAgent: MakeWorkerAgent perWorker: Budget + /** Independent completion check exposed to the driver as `submit_result`. */ + deliverable?: DeliverableSpec /** Hard cap on simultaneously-LIVE workers — `spawn_agent` fails closed once this many are in * flight (a concurrency fence on top of the conserved-pool fence). Omit/`<= 0` = no cap. */ maxLiveWorkers?: number @@ -89,6 +94,7 @@ export async function serveCoordinationMcp(opts: { blobs: opts.blobs, makeWorkerAgent: opts.makeWorkerAgent, perWorker: opts.perWorker, + ...(opts.deliverable ? { deliverable: opts.deliverable } : {}), ...(opts.maxLiveWorkers !== undefined ? { maxLiveWorkers: opts.maxLiveWorkers } : {}), awaitTimeoutMs: opts.awaitTimeoutMs ?? DEFAULT_AWAIT_EVENT_TIMEOUT_MS, ...(opts.analysts ? { analysts: opts.analysts } : {}), @@ -150,6 +156,7 @@ export async function serveCoordinationMcp(opts: { url: `http://${host}:${port}/mcp`, port, settled: () => coord.settled(), + submittedResult: () => coord.submittedResult(), drainResolved: () => coord.drainResolved(), isStopped: () => coord.isStopped(), history: () => coord.history(), diff --git a/src/runtime/supervise/supervise.ts b/src/runtime/supervise/supervise.ts index 84ce4deb..34d76407 100644 --- a/src/runtime/supervise/supervise.ts +++ b/src/runtime/supervise/supervise.ts @@ -62,9 +62,9 @@ export interface SuperviseOptions { readonly budget: Budget /** WHERE workers run — derives the worker seam. Provide this OR an explicit `makeWorkerAgent`. */ readonly backend?: ExecutorConfig - /** The completion oracle for backend-derived workers (settled ⟺ delivered). Strongly recommended: - * without it the supervisor trusts a worker's self-report — exactly the "ran but didn't deliver" - * failure mode of a static orchestrator. */ + /** The independent completion check for backend-derived workers and direct supervisor + * submissions. Strongly recommended: without it the supervisor cannot submit its own work and + * backend-derived workers fall back to their own validity signal. */ readonly deliverable?: DeliverableSpec /** Override the worker seam directly (tests / advanced) instead of deriving it from `backend`. */ readonly makeWorkerAgent?: MakeWorkerAgent @@ -235,6 +235,7 @@ export function supervise(profile: SupervisorProfile, task: unknown, opts: Super blobs, makeWorkerAgent: workerFactory, perWorker, + ...(opts.deliverable ? { deliverable: opts.deliverable } : {}), ...(log ? { onEvent: (ev) => log.append(runId, ev, new Date(now()).toISOString()) } : {}), ...(priorCoordination && (priorCoordination.questions.length > 0 || priorCoordination.findings.length > 0) diff --git a/src/runtime/supervise/supervisor-agent.ts b/src/runtime/supervise/supervisor-agent.ts index ba2e5418..b413cc74 100644 --- a/src/runtime/supervise/supervisor-agent.ts +++ b/src/runtime/supervise/supervisor-agent.ts @@ -10,9 +10,8 @@ * coordination verbs: `serveCoordinationMcp` exposes spawn/await/steer/stop over the live scope, * and the caller's `driveHarness` runs the harness with that MCP mounted. The harness IS the brain. * - * Both arms spawn children through the SAME `makeWorkerAgent` seam and finalize through the SAME - * seam (`runFinalizer` over DELIVERED children only — default keep-best, never the driver's own - * prose). + * Both arms spawn children through the SAME `makeWorkerAgent` seam and apply the SAME independent + * deliverable check to direct submissions. Raw driver prose is never eligible. */ import { ValidationError } from '../../errors' import type { @@ -23,6 +22,7 @@ import type { } from '../../mcp/tools/coordination' import { type RouterConfig, routerBrain } from '../router-client' import type { ToolLoopChat, ToolLoopCompactionOptions } from '../tool-loop' +import type { DeliverableSpec } from './completion-gate' import { driverAgent } from './coordination-driver' import type { PriorCoordination } from './coordination-log' import { serveCoordinationMcp } from './coordination-mcp' @@ -81,6 +81,8 @@ export interface SupervisorAgentDeps { readonly makeWorkerAgent: MakeWorkerAgent /** Per-child budget reserved from the conserved pool on each spawn. */ readonly perWorker: Budget + /** Independent completion check for direct driver work (`submit_result`). */ + readonly deliverable?: DeliverableSpec /** Hard cap on simultaneously-LIVE workers across both arms — `spawn_agent` fails closed once * this many are in flight (a concurrency fence on top of the conserved-pool fence; bounds live * boxes/sandboxes, not total work). Omit/`<= 0` = no cap. */ @@ -162,6 +164,7 @@ export function supervisorAgent( makeWorkerAgent: deps.makeWorkerAgent, perWorker: deps.perWorker, systemPrompt, + ...(deps.deliverable ? { deliverable: deps.deliverable } : {}), ...(deps.maxLiveWorkers !== undefined ? { maxLiveWorkers: deps.maxLiveWorkers } : {}), ...(deps.extraTools ? { extraTools: deps.extraTools } : {}), ...(deps.executeExtraTool ? { executeExtraTool: deps.executeExtraTool } : {}), @@ -194,6 +197,7 @@ export function supervisorAgent( blobs: deps.blobs, makeWorkerAgent: deps.makeWorkerAgent, perWorker: deps.perWorker, + ...(deps.deliverable ? { deliverable: deps.deliverable } : {}), ...(deps.maxLiveWorkers !== undefined ? { maxLiveWorkers: deps.maxLiveWorkers } : {}), ...(deps.analysts ? { analysts: deps.analysts } : {}), ...(deps.analyzeOnSettle ? { analyzeOnSettle: deps.analyzeOnSettle } : {}), @@ -205,12 +209,20 @@ export function supervisorAgent( : {}), }) try { - await driveHarness({ profile, task, scope, coordinationMcpUrl: mcp.url }) + try { + await driveHarness({ profile, task, scope, coordinationMcpUrl: mcp.url }) + } catch (error) { + // Once the injected check has accepted a result, a later backend shutdown/timeout cannot + // erase that completed work. Without an accepted submission, preserve the backend error. + if (!mcp.submittedResult()) throw error + } // Drain settled-but-unpulled children first — a gate-verified delivery the harness never // awaited must still reach the finalize ledger. await mcp.drainResolved() - // The deliverable comes from the finalizer seam over DELIVERED children only — never the - // harness's own output (Foreman 0/18). Default keep-best. + // Direct work is eligible only through `submit_result`, after the injected independent + // check passes. Raw harness prose remains ineligible. + const submitted = mcp.submittedResult() + if (submitted) return submitted.result return await runFinalizer(deps.finalizer ?? bestDelivered, { settled: mcp.settled(), blobs: deps.blobs, diff --git a/tests/kernel/coordination-driver.test.ts b/tests/kernel/coordination-driver.test.ts index 2ef776e1..5e7e8c43 100644 --- a/tests/kernel/coordination-driver.test.ts +++ b/tests/kernel/coordination-driver.test.ts @@ -590,6 +590,42 @@ describe('driverAgent — the driver can ACT (call work tools itself), not only expect(childSpawns).toEqual([]) }) + it('returns the first directly submitted result that passes the independent check', async () => { + SHARED_BLOBS = new InMemoryResultBlobStore() + const journal = new InMemorySpawnJournal() + const seen: SeenMessages = [] + const chat = scriptedBrain( + [ + { toolCalls: [{ name: 'submit_result', arguments: { result: { answer: 0 } } }] }, + { toolCalls: [{ name: 'submit_result', arguments: { result: { answer: 42 } } }] }, + { content: 'must not need another turn' }, + ], + seen, + ) + const root = driverAgent({ + ...driverOpts('root', chat, dummyWorker), + deliverable: { + describe: 'an object whose answer is 42', + check: (result) => (result as { answer?: unknown }).answer === 42, + }, + }) + const result = await createSupervisor().run(root, 'solve it directly', { + budget: { maxIterations: 100, maxTokens: 100_000 }, + runId: 'direct-submit', + journal, + blobs: SHARED_BLOBS, + executors: createExecutorRegistry(), + maxDepth: 2, + now: () => 0, + }) + + expect(result.kind).toBe('winner') + if (result.kind === 'winner') expect(result.out).toEqual({ answer: 42 }) + expect(seen).toHaveLength(2) + const tree = (await journal.loadTree('direct-submit')) as SpawnEvent[] + expect(tree.filter((e) => e.kind === 'spawned' && e.id !== 'direct-submit')).toEqual([]) + }) + it('the work tool is tried FIRST; a null return falls through to the coordination dispatch', async () => { SHARED_BLOBS = new InMemoryResultBlobStore() const journal = new InMemorySpawnJournal() @@ -642,6 +678,15 @@ describe('driverAgent — the driver can ACT (call work tools itself), not only // The collision guard fires eagerly — NOT buried in a swallowed act() throw. expect(() => driverAgent(opts)).toThrow(/collides with a coordination verb/) }) + + it('reserves submit_result even when no independent check is configured', () => { + const opts: DriverAgentOptions = { + ...driverOpts('root', scriptedBrain([{ content: 'x' }], []), dummyWorker), + extraTools: [{ ...echoTool, name: 'submit_result' }], + executeExtraTool: async () => 'nope', + } + expect(() => driverAgent(opts)).toThrow(/collides with a coordination verb/) + }) }) describe('driverAgent — the analyst up-leg (analysts + analyzeOnSettle pass-through)', () => { diff --git a/tests/kernel/coordination.test.ts b/tests/kernel/coordination.test.ts index 2c3f2c39..a801b467 100644 --- a/tests/kernel/coordination.test.ts +++ b/tests/kernel/coordination.test.ts @@ -65,6 +65,65 @@ const tool = (tb: ReturnType, name: string) => { } describe('coordination tools', () => { + it('exposes direct submission only with an injected check and retains the first passing result', async () => { + const { scope } = mockScope() + const withoutCheck = createCoordinationTools({ + scope, + blobs, + makeWorkerAgent, + perWorker: { maxIterations: 1, maxTokens: 10 }, + }) + expect(withoutCheck.tools.map((t) => t.name)).not.toContain('submit_result') + + const checked: unknown[] = [] + const withCheck = createCoordinationTools({ + scope, + blobs, + makeWorkerAgent, + perWorker: { maxIterations: 1, maxTokens: 10 }, + deliverable: { + describe: 'an object whose answer is 42', + check(result) { + checked.push(result) + const answer = (result as { answer?: unknown }).answer + if (answer === 'throw') throw new Error('check broke') + return answer === 42 + }, + }, + }) + const submit = tool(withCheck, 'submit_result') + + expect(await submit.handler({ result: { answer: 0 } })).toEqual({ + accepted: false, + stop: false, + }) + expect(await submit.handler({ result: { answer: 'throw' } })).toEqual({ + accepted: false, + stop: false, + }) + expect(withCheck.isStopped()).toBe(false) + expect(withCheck.submittedResult()).toBeUndefined() + + const passing = { answer: 42 } + expect(await submit.handler({ result: passing })).toEqual({ + accepted: true, + retained: 'this-result', + stop: true, + }) + passing.answer = 0 + expect(withCheck.submittedResult()).toEqual({ result: { answer: 42 } }) + expect(withCheck.isStopped()).toBe(true) + expect(withCheck.stopReason()).toBe('result-accepted') + + expect(await submit.handler({ result: { answer: 43 } })).toEqual({ + accepted: true, + retained: 'earlier-passing-result', + stop: true, + }) + expect(checked).toHaveLength(3) + expect(withCheck.submittedResult()).toEqual({ result: { answer: 42 } }) + }) + it('spawn_agent returns workerId and fails closed when admission fails', async () => { const { scope, setAdmit } = mockScope() const tb = createCoordinationTools({ diff --git a/tests/kernel/supervise-convenience.test.ts b/tests/kernel/supervise-convenience.test.ts index cacedbfb..e95d6f48 100644 --- a/tests/kernel/supervise-convenience.test.ts +++ b/tests/kernel/supervise-convenience.test.ts @@ -59,6 +59,29 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ expect(result.kind).toBe('winner') }) + it('threads the independent check to direct supervisor submissions', async () => { + const brain = scriptedBrain([ + { toolCalls: [{ name: 'submit_result', arguments: { result: { answer: 42 } } }] }, + { content: 'must not need another turn' }, + ]) + const result = await supervise( + { name: 'root', harness: null, systemPrompt: 'solve or delegate' }, + 'solve it directly', + { + budget, + makeWorkerAgent: () => deliveringLeaf('unused', {}), + brain, + deliverable: { + describe: 'an object whose answer is 42', + check: (value) => (value as { answer?: unknown }).answer === 42, + }, + }, + ) + + expect(result.kind).toBe('winner') + if (result.kind === 'winner') expect(result.out).toEqual({ answer: 42 }) + }) + it('runDir makes the run durable and resumable; unset stays in-memory', async () => { const dir = await mkdtemp(join(tmpdir(), 'supervise-rundir-')) try { diff --git a/tests/kernel/supervisor-agent.test.ts b/tests/kernel/supervisor-agent.test.ts index bc08be56..ce6ab53c 100644 --- a/tests/kernel/supervisor-agent.test.ts +++ b/tests/kernel/supervisor-agent.test.ts @@ -109,6 +109,35 @@ describe('supervisorAgent — the brain is resolved from profile.harness (backen expect(result.kind).toBe('winner') }) + it('SANDBOX arm retains a checked direct result even when the backend exits with an error afterward', async () => { + const blobs = new InMemoryResultBlobStore() + const journal = new InMemorySpawnJournal() + const driveHarness: DriveHarness = async ({ coordinationMcpUrl }) => { + await jsonRpc(coordinationMcpUrl, 'tools/call', { + name: 'submit_result', + arguments: { result: { answer: 42 } }, + }) + throw new Error('backend exited after submission') + } + const root = supervisorAgent( + { name: 'sup', harness: 'pi', systemPrompt: 'solve or delegate' }, + { + blobs, + makeWorkerAgent: () => deliveringLeaf('unused', {}), + perWorker, + driveHarness, + deliverable: { + describe: 'an object whose answer is 42', + check: (result) => (result as { answer?: unknown }).answer === 42, + }, + }, + ) + + const result = await runSupervisor(root, blobs, journal) + expect(result.kind).toBe('winner') + if (result.kind === 'winner') expect(result.out).toEqual({ answer: 42 }) + }) + it('fails loud when a sandboxed-harness supervisor has no driveHarness substrate', () => { const blobs = new InMemoryResultBlobStore() expect(() =>