/** * Integration tests for async (background) agent execution. * * Tests the async support utilities: jiti availability check, * status file reading/caching. * * Requires pi packages to be importable. Skips gracefully if unavailable. */ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { once } from "node:events"; import { createServer, type Socket } from "node:net"; import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { setChildSessionFactoryModule } from "../../src/runs/shared/child-session.ts"; import { createEventBus, createTempDir, events, makeAgent, makeMinimalCtx, removeTempDir } from "../support/helpers.ts"; import { deliverInterruptRequest, deliverStopRequest, requestAsyncSteer } from "../../src/runs/background/control-channel.ts"; import { SUBAGENT_PROCESS_TERMINAL_EVENT } from "../../src/shared/types.ts"; import { waitForSubagents } from "../../src/runs/background/subagent-wait.ts"; import { activeRunMarkerAgeMs } from "../../src/runs/background/active-run-index.ts"; import { getActiveAsyncCapacitySnapshot } from "../../src/runs/background/active-async-capacity.ts"; import { createRunFanoutBudget, getRunFanoutBudgetSnapshot } from "../../src/runs/shared/run-fanout-budget.ts"; import { reconcileAsyncRun } from "../../src/runs/background/stale-run-reconciler.ts"; import type { AsyncResultPayload, AsyncStatusPayload } from "../support/async-execution-fixture.ts"; import { installAsyncExecutionHooks, writeWatchdogSettings, withIsolatedWatchdogSettings, childWatchdogStatus, available, isAsyncAvailable, executeAsyncSingle, executeAsyncChain, ASYNC_DIR, RESULTS_DIR, TEMP_ROOT_DIR, escapeRegExp, createRepo, waitForAsyncResultFile, waitForAsyncState, tempDir, mockPi, readAsyncPayload, waitForMockPiCall, makeAsyncExecutor, createSubagentExecutor, } from "../support/async-execution-fixture.ts"; describe("async execution utilities", { skip: !available ? "pi packages not available" : undefined }, () => { installAsyncExecutionHooks(); it("coalesces ordinary background status while publishing per-child activity transitions", { timeout: 30_000, skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async (t) => { const id = `async-coalescing-${Date.now().toString(36)}`; const statusPath = path.join(ASYNC_DIR, id, "status.json"); const reportPath = path.join(tempDir, `${id}-report.json`); const factoryPath = path.join(tempDir, `${id}-factory.mjs`); // Exercise the detached production runner through its child-session boundary. // Only external time and successful filesystem publications are instrumented. fs.writeFileSync(factoryPath, ` import fs from "node:fs"; import { syncBuiltinESMExports } from "node:module"; import { mock } from "node:test"; const statusPath = ${JSON.stringify(statusPath)}; const reportPath = ${JSON.stringify(reportPath)}; const children = []; const report = { samples: [], transitions: [], terminal: [] }; process.once("exit", () => { fs.writeFileSync(reportPath + ".tmp", JSON.stringify(report)); fs.renameSync(reportPath + ".tmp", reportPath); }); let writes = 0, bytes = 0, replayed = false, finished = false; const read = () => JSON.parse(fs.readFileSync(statusPath, "utf8")); const rename = fs.renameSync; fs.renameSync = function(source, target) { rename.call(this, source, target); if (target === statusPath) { writes++; bytes += fs.statSync(target).size; if (replayed && !finished && read().state !== "running") { finished = true; queueMicrotask(() => { report.terminal.push(read()); mock.timers.tick(100); report.terminal.push(read()); mock.timers.reset(); }); } } }; syncBuiltinESMExports(); function replay() { mock.timers.enable({ apis: ["Date", "setTimeout"], now: Date.now() }); const emit = (index, event) => children[index].listener(event); const stream = () => emit(1, { type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "x" } }); const transition = (index, event) => { const before = writes; emit(index, event); report.transitions.push({ writes: writes - before, status: read() }); }; const sample = (state) => { const beforeWrites = writes, beforeBytes = bytes; for (let i = 0; i < 1000; i++) { stream(); mock.timers.tick(10); } mock.timers.tick(100); report.samples.push({ state, writes: writes - beforeWrites, bytes: bytes - beforeBytes, now: Date.now(), status: read() }); }; const start = { type: "tool_execution_start", toolName: "contact_supervisor", toolCallId: "decision", args: { reason: "need_decision", message: "Choose" } }; const end = { type: "tool_execution_end", toolName: "contact_supervisor", toolCallId: "decision" }; sample("unset"); transition(1, { type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "working" }], stopReason: "tool_use" } }); sample("active_long_running"); transition(0, start); sample("needs_attention"); transition(1, start); // Aggregate attention is unchanged by either sibling transition. transition(1, end); transition(0, end); const beforePending = writes; stream(); // Leave an ordinary update pending when both children fail. report.pendingWrites = writes - beforePending; replayed = true; for (const child of children) child.reject(new Error("scripted terminal failure")); } export default function() { return { async create() { const child = {}; children.push(child); return { sessionId: "coalescing-" + children.length, sessionFile: undefined, modelId: undefined, messages: [], subscribe(listener) { child.listener = listener; return () => {}; }, prompt() { return new Promise((resolve, reject) => { child.reject = reject; if (children.length === 2 && children.every(c => c.reject)) replay(); }); }, async steer() {}, async followUp() {}, async abort() {}, async dispose() {}, }; }, async dispose() {}, }; } `); const reportReady = new Promise((resolve) => { // libuv compares expanded event paths against the watched directory (Windows 8.3 aliases differ). const watcher = fs.watch(fs.realpathSync.native(tempDir), () => { if (fs.existsSync(reportPath)) resolve(); }); t.after(() => watcher.close()); }); setChildSessionFactoryModule(factoryPath); t.after(() => setChildSessionFactoryModule(fileURLToPath(new URL("../support/runner-child-session-factory.ts", import.meta.url)))); const launched = executeAsyncChain(id, { chain: [{ parallel: [{ agent: "worker", task: "A" }, { agent: "worker", task: "B" }] }], agents: [makeAgent("worker")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, controlConfig: { enabled: true, needsAttentionAfterMs: 999_999, activeNoticeAfterTurns: 1, activeNoticeAfterMs: 999_999, activeNoticeAfterTokens: 999_999, failedToolAttemptsBeforeAttention: 3, notifyOn: ["active_long_running", "needs_attention"], notifyChannels: ["event", "async"] }, }); assert.notEqual(launched.isError, true); await reportReady; const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); t.diagnostic(JSON.stringify(report.samples.map(({ state, writes, bytes }: { state: string; writes: number; bytes: number }) => ({ state, writes, bytes })))); assert.deepEqual(report.samples.map((sample: { writes: number }) => sample.writes), [100, 100, 100]); for (const sample of report.samples) { assert.equal(sample.status.lastActivityAt, sample.now - 110); assert.equal(sample.status.steps[1].lastActivityAt, sample.now - 110); } assert.deepEqual(report.transitions.map((entry: { writes: number }) => entry.writes), [1, 1, 1, 1, 1]); assert.deepEqual(report.transitions.map((entry: { status: AsyncStatusPayload }) => entry.status.steps?.map(step => step.activityState)), [ [undefined, "active_long_running"], ["needs_attention", "active_long_running"], ["needs_attention", "needs_attention"], ["needs_attention", "active_long_running"], [undefined, "active_long_running"], ]); assert.equal(report.samples[2].status.steps[1].turnCount, 1); assert.equal(report.transitions[2].status.activityState, "needs_attention"); assert.equal(report.transitions[3].status.activityState, "needs_attention"); assert.equal(report.pendingWrites, 0); assert.equal(report.terminal[0].state, "failed"); assert.deepEqual(report.terminal[1], report.terminal[0]); }); for (const mode of ["success", "stop", "pause", "deadline", "failure-before-JSON"] as const) { it(`background setup lifecycle: ${mode}`, { skip: !isAsyncAvailable() || process.platform === "win32" ? "requires real POSIX setup executable" : undefined, timeout: 25_000 }, async (t) => { const repo = createRepo("pi-background-setup-"); const baseDir = createTempDir(); const id = `async-setup-${mode}-${Date.now().toString(36)}`; const asyncDir = path.join(ASYNC_DIR, id); const marker = path.join(repo, ".git", "child-launched"); const allocatorFailure = mode === "failure-before-JSON"; const oldPath = process.env.PATH; const server = createServer(); server.listen(0, "127.0.0.1"); await once(server, "listening"); const { port } = server.address() as { port: number }; const hook = path.join(baseDir, allocatorFailure ? "wt" : "setup-hook.cjs"); fs.writeFileSync(hook, `#!${process.execPath} const args = process.argv.slice(2); if (args.includes('--version')) { console.log('wt v0.75.0'); process.exit(0); } if (args.includes('--help')) { console.log('--create --base --no-cd --no-hooks --format'); process.exit(0); } if (${allocatorFailure}) require('node:child_process').execFileSync('git', ['branch', args[args.indexOf('--create') + 1]], { cwd: ${JSON.stringify(repo)} }); // Complete the setup stdin contract before exposing the independent release gate. // Otherwise the hook can exit before the runner writes input and cause EPIPE. let input = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { input += chunk; }); process.stdin.on('end', () => { if (!${allocatorFailure}) { const setup = JSON.parse(input); require('node:assert/strict').equal(setup.runId, ${JSON.stringify(`${id}-s0`)}); require('node:assert/strict').equal(setup.repoRoot, ${JSON.stringify(fs.realpathSync(repo))}); } const socket = require('node:net').connect(${port}, '127.0.0.1', () => socket.write('ready')); socket.on('data', data => { if (data.toString() === 'release') { socket.end(); if (${allocatorFailure}) process.exitCode = 1; else console.log('{}'); } }); }); setTimeout(() => process.exit(90), 15000).unref(); `, { mode: 0o755 }); if (allocatorFailure) process.env.PATH = `${baseDir}${path.delimiter}${oldPath}`; const bus = createEventBus(); let closed = false; const terminal = new Promise((resolve) => bus.on(SUBAGENT_PROCESS_TERMINAL_EVENT, (proof) => { closed = true; resolve(proof); })); let socket: Socket | undefined; let started = false; try { mockPi.onCall({ output: "finite setup completed", writeFiles: [{ path: marker, content: "launched" }] }); const connection = once(server, "connection", { signal: AbortSignal.timeout(20_000) }); const receipt = executeAsyncChain(id, { chain: [{ agent: "worker", task: "Do work", worktree: true }], agents: [makeAgent("worker")], ctx: { pi: { events: bus }, cwd: repo, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, acceptance: false, ...(allocatorFailure ? { worktreeProvider: "worktrunk" } : { worktreeProvider: "native", worktreeBaseDir: path.join(baseDir, "trees"), worktreeSetupHook: hook }), ...(mode === "deadline" ? { timeoutMs: 4_000 } : {}), }); assert.equal(receipt.isError, undefined, receipt.content[0]?.text); started = true; [socket] = await Promise.race([connection, terminal.then((proof) => { throw new Error(`Runner closed before setup ready: ${JSON.stringify(proof)}`); })]) as [Socket]; assert.equal((await once(socket, "data"))[0].toString(), "ready"); const held = await waitForAsyncState(id, (status) => Boolean(status.parallelHandoff)); assert.equal(closed, false); assert.equal(mockPi.callCount(), 0); assert.equal(fs.existsSync(marker), false); if (mode === "stop") deliverStopRequest({ asyncDir, source: "test" }); else if (mode === "pause") deliverInterruptRequest({ asyncDir, source: "test" }); else if (mode !== "deadline") socket.write("release"); const payload = await readAsyncPayload(id); const proof = await terminal; const status = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf8")); const expected = mode === "success" ? "complete" : mode === "stop" ? "stopped" : mode === "pause" ? "paused" : "failed"; if (payload.state !== expected || status.state !== expected || status.steps?.[0]?.status !== expected) { // Failure-only, bounded projections: never print prompts, output, paths or raw stderr. const record = (value: unknown): Record => value !== null && typeof value === "object" ? value as Record : {}; const token = (value: unknown) => typeof value === "string" && /^[a-z_-]{1,64}$/i.test(value) ? value : undefined; const errors = (value: unknown) => { const text = typeof value === "string" ? value.slice(0, 8192) : ""; return { present: Boolean(text), kinds: [ "EPIPE", "ENOENT", "EACCES", "ENOSPC", "ETIMEDOUT", "ABORT_ERR", "ENOBUFS", "setup aborted", "setup deadline", "setup settlement unknown", "process tree settlement", "empty stdout", "invalid JSON", "hook failed", "manual reconciliation required", "not a git repository", "index.lock", "already exists", "Failed to write result", "No model", "model not found", "completion", "acceptance", ].filter((kind) => text.toLowerCase().includes(kind.toLowerCase())), exitCode: text.match(/exit (?:code |status )?(-?\d+)/i)?.[1] }; }; const project = (value: unknown) => { const item = record(value); return { state: token(item.state), status: token(item.status), reason: token(item.reason), exitCode: typeof item.exitCode === "number" ? item.exitCode : undefined, success: typeof item.success === "boolean" ? item.success : undefined, timedOut: item.timedOut === true, stopped: item.stopped === true, error: errors(item.error) }; }; let cleanup: unknown; try { const handoffPath = held.parallelHandoff?.path; if (handoffPath && fs.statSync(handoffPath).size <= 65_536) { const handoff = JSON.parse(fs.readFileSync(handoffPath, "utf8")); cleanup = (handoff.groups ?? []).slice(0, 2).map((group: { cleanup?: { state?: string; pruned?: boolean; tasks?: unknown[]; errors?: string[] } }) => ({ state: token(group.cleanup?.state), pruned: group.cleanup?.pruned, taskCount: group.cleanup?.tasks?.length, errors: group.cleanup?.errors?.slice(0, 4).map(errors), })); } } catch (error) { cleanup = { readError: token(record(error).code) }; } t.diagnostic(JSON.stringify({ mode, expected, mockCalls: mockPi.callCount(), markerPresent: fs.existsSync(marker), result: project(payload), status: project(status), steps: (status.steps ?? []).slice(0, 2).map(project), children: (payload.results ?? []).slice(0, 2).map(project), cleanup, processTerminal: project(proof) })); } assert.equal(payload.state, expected); assert.equal(status.state, expected); assert.equal(status.steps[0].status, expected); assert.equal(payload.success, mode === "success"); assert.equal(mockPi.callCount(), mode === "success" ? 1 : 0); assert.equal(fs.existsSync(marker), mode === "success"); assert.ok(held.parallelHandoff?.path); const handoff = JSON.parse(fs.readFileSync(held.parallelHandoff.path, "utf8")); const group = handoff.groups[0]; if (allocatorFailure) { assert.equal(status.steps[0].worktreePath, undefined); assert.equal(status.steps[0].branch, undefined); assert.deepEqual(group.cleanup.tasks, []); assert.deepEqual(group.children, []); assert.equal(group.cleanup.state, "partial"); assert.equal(group.cleanup.pruned, false); assert.match(group.cleanup.errors.join("\n"), /manual reconciliation required/); const attempt = group.cleanup.errors.find((entry: string) => entry.startsWith("Allocation attempt ")); const evidence = JSON.parse(attempt.slice("Allocation attempt ".length)); assert.equal(evidence.path, null); assert.equal(evidence.validated, false); assert.equal(evidence.command.status, 1); assert.equal(evidence.command.processTree, "unknown"); assert.equal(execFileSync("git", ["branch", "--list", evidence.branch], { cwd: repo, encoding: "utf8" }).trim(), evidence.branch); const persisted = JSON.parse(fs.readFileSync(path.join(asyncDir, "process-terminal.json"), "utf8")); assert.equal(persisted.state, "unknown"); assert.equal(persisted.reason, "process-tree-unverified"); assert.deepEqual(proof, persisted, "actual runner close must not promote setup unknown with zero child writers"); const candidate = JSON.parse(fs.readFileSync(path.join(asyncDir, "process-terminal-candidate.json"), "utf8")); assert.deepEqual(Object.values(candidate.writers).flat(), []); assert.deepEqual(Object.values(candidate.expectedWriters), [0]); } else if (mode === "deadline") { assert.equal(payload.timedOut, true); assert.equal(status.timedOut, true); assert.ok(payload.deadlineAt! <= Date.now()); assert.equal(group.cleanup.pruned, false); assert.equal(group.cleanup.tasks[0].preserved, true); assert.equal(fs.existsSync(group.cleanup.tasks[0].path), true); } else { assert.equal(group.cleanup.state, "complete"); assert.equal(group.cleanup.tasks.length, 1); assert.equal(group.cleanup.tasks[0].worktreeRemoved, true); assert.equal(group.cleanup.tasks[0].branchRemoved, true); assert.equal(fs.existsSync(group.cleanup.tasks[0].path), false); assert.equal(execFileSync("git", ["branch", "--list", group.cleanup.tasks[0].branch], { cwd: repo, encoding: "utf8" }).trim(), ""); } } finally { socket?.end("release"); if (started && !closed) { deliverStopRequest({ asyncDir, source: "test-cleanup" }); await terminal; } socket?.destroy(); await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); if (oldPath === undefined) delete process.env.PATH; else process.env.PATH = oldPath; removeTempDir(baseDir); removeTempDir(repo); } }); } it("does not start child work when initial async status cannot be written", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { const id = `async-status-write-fail-${Date.now().toString(36)}`; fs.mkdirSync(path.join(ASYNC_DIR, id, "status.json"), { recursive: true }); mockPi.onCall({ output: "must not run" }); const result = executeAsyncSingle(id, { agent: "worker", task: "Do not start", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); assert.equal(result.isError, true); assert.match(result.content[0]?.text ?? "", /Failed to persist initial async status/); await new Promise((resolve) => setTimeout(resolve, 300)); assert.equal(mockPi.callCount(), 0); }); it("returns a tool error when an async run uses a missing cwd", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, () => { const id = `async-missing-cwd-${Date.now().toString(36)}`; const missingCwd = path.join(tempDir, "missing-cwd"); const singleResult = executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, cwd: missingCwd, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); assert.equal(singleResult.isError, true); assert.match(singleResult.content[0]?.text ?? "", /Failed to start async run/); assert.match(singleResult.content[0]?.text ?? "", /cwd does not exist/); const chainId = `async-missing-cwd-chain-${Date.now().toString(36)}`; const chainResult = executeAsyncChain(chainId, { chain: [{ agent: "worker", task: "Do work" }], agents: [makeAgent("worker")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, cwd: missingCwd, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); assert.equal(chainResult.isError, true); assert.match(chainResult.content[0]?.text ?? "", /Failed to start async chain/); assert.match(chainResult.content[0]?.text ?? "", /cwd does not exist/); }); it("returns a tool error when the async runner process cannot spawn", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, () => { const originalExecPath = process.execPath; const pathKey = process.platform === "win32" ? "Path" : "PATH"; const originalPath = process.env[pathKey]; process.execPath = path.join(tempDir, process.platform === "win32" ? "pi.exe" : "pi"); process.env[pathKey] = tempDir; try { const id = `async-spawn-fail-${Date.now().toString(36)}`; const result = executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); assert.equal(result.isError, true); assert.match(result.content[0]?.text ?? "", /Failed to start async run/); assert.match(result.content[0]?.text ?? "", /async runner did not produce a pid/); } finally { process.execPath = originalExecPath; if (originalPath === undefined) { delete process.env[pathKey]; } else { process.env[pathKey] = originalPath; } } }); it("returns a tool error when an async chain cannot write its detached runner config", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, () => { const id = `async-chain-write-fail-${Date.now().toString(36)}`; assert.ok(TEMP_ROOT_DIR, "TEMP_ROOT_DIR should be available for async tests"); fs.mkdirSync(TEMP_ROOT_DIR, { recursive: true }); fs.mkdirSync(path.join(TEMP_ROOT_DIR, `async-cfg-${id}.json`), { recursive: true }); const result = executeAsyncChain(id, { chain: [{ agent: "worker", task: "Do work" }], agents: [makeAgent("worker")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); assert.equal(result.isError, true); assert.match(result.content[0]?.text ?? "", /Failed to start async chain/); assert.match(result.content[0]?.text ?? "", /async-cfg-/); }); it("background ignores child watchdog status when child watchdogs are not configured", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { await withIsolatedWatchdogSettings(tempDir, async () => { const id = `async-watchdog-unconfigured-${Date.now().toString(36)}`; const terminalReleasePath = path.join(tempDir, `${id}-release`); mockPi.onCall({ steps: [{ waitForPath: terminalReleasePath, jsonl: [events.assistantMessage("async-done-without-watchdog-config"), childWatchdogStatus(id, "reviewing", 1)] }], keepAliveAfterFinalMessageMs: 10000, }); executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); await waitForMockPiCall(mockPi, 0); // Measure final drain, not detached runner startup; terminal emission is still gated. const start = Date.now(); fs.writeFileSync(terminalReleasePath, "release", "utf-8"); const resultPath = await waitForAsyncResultFile(id, 10_000); const elapsed = Date.now() - start; const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.ok(elapsed < 6000, `unconfigured watchdog status should not delay async final drain, took ${elapsed}ms`); assert.equal(payload.success, true); assert.equal(payload.results[0]?.output, "async-done-without-watchdog-config"); assert.equal((payload.results[0] as { watchdog?: unknown }).watchdog, undefined); }); }); it("background final-drain waits for child watchdog settlement", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { await withIsolatedWatchdogSettings(tempDir, async () => { writeWatchdogSettings(tempDir); const id = `async-watchdog-drain-${Date.now().toString(36)}`; mockPi.onCall({ steps: [ { jsonl: [events.assistantMessage("async-done-before-watchdog"), childWatchdogStatus(id, "reviewing", 1)] }, { delay: 1400, jsonl: [childWatchdogStatus(id, "idle", 2)] }, ], keepAliveAfterFinalMessageMs: 10000, }); const start = Date.now(); executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); const resultPath = await waitForAsyncResultFile(id, 10_000); const elapsed = Date.now() - start; const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.ok(elapsed >= 1200, `watchdog settlement should delay async final drain, took ${elapsed}ms`); assert.ok(elapsed < 9000, `settled watchdog should still allow async cleanup, took ${elapsed}ms`); assert.equal(payload.success, true); assert.equal(payload.results[0]?.output, "async-done-before-watchdog"); assert.equal((payload.results[0] as { watchdog?: { phase?: string } }).watchdog?.phase, "idle"); }); }); it("background publishes a structured-only terminal after watchdog settlement", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { await withIsolatedWatchdogSettings(tempDir, async () => { writeWatchdogSettings(tempDir); const id = `async-watchdog-structured-${Date.now().toString(36)}`; const callsBefore = mockPi.callCount(); mockPi.onCall({ jsonl: [childWatchdogStatus(id, "idle", 1)], structuredOutput: { ok: true } }); executeAsyncSingle(id, { agent: "worker", task: "Return data", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, structuredOutputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, }); const payload = await readAsyncPayload(id); assert.equal(payload.success, true, payload.results[0]?.error); assert.deepEqual(payload.results[0]?.structuredOutput, { ok: true }); assert.equal((payload.results[0] as { watchdog?: { phase?: string } }).watchdog?.phase, "idle"); assert.equal(mockPi.callCount(), callsBefore + 1, "settled structured completion must not continue with another turn"); }); }); for (const terminal of ["error", "aborted"] as const) { it(`background preserves structured evidence when a later ${terminal} terminal keeps the run failed`, { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { const id = `async-structured-${terminal}-${Date.now().toString(36)}`; const terminalMessage = { type: "message_end", message: { role: "assistant", content: [], model: "mock/test-model", stopReason: terminal, errorMessage: terminal === "error" ? "later provider failure" : "This operation was aborted", usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } }, }, }; mockPi.onCall({ structuredOutputCapture: { ok: true }, jsonl: [ { type: "tool_execution_start", toolName: "structured_output", args: { value: { ok: true } } }, { type: "tool_execution_end", toolName: "structured_output" }, { type: "message_end", message: { role: "toolResult", toolName: "structured_output", content: [{ type: "text", text: "Structured output captured." }] } }, terminalMessage, ], }); executeAsyncSingle(id, { agent: "worker", task: "Return data, then fail", agentConfig: makeAgent("worker"), acceptance: false, ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, structuredOutputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, }); const payload = await readAsyncPayload(id); // The runner publishes the result before the terminal status. const status = await waitForAsyncState(id, (candidate) => candidate.state !== "running" && candidate.state !== "queued"); const child = payload.results[0]!; assert.equal(payload.success, false); assert.equal(payload.state, "failed"); assert.equal(child.success, false); assert.deepEqual(child.structuredOutput, { ok: true }); assert.ok(child.structuredOutputPath); assert.deepEqual(JSON.parse(fs.readFileSync(child.structuredOutputPath, "utf-8")), { ok: true }); assert.equal(status.state, "failed"); assert.equal(status.steps?.[0]?.status, "failed"); assert.deepEqual(status.steps?.[0]?.structuredOutput, { ok: true }); assert.equal(status.steps?.[0]?.structuredOutputPath, child.structuredOutputPath); }); } it("background keeps invalid and missing structured captures absent", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { for (const capture of ["invalid", "missing"] as const) { const id = `async-structured-${capture}-${Date.now().toString(36)}`; mockPi.onCall(capture === "invalid" ? { jsonl: [ { type: "tool_execution_start", toolName: "structured_output", args: { value: { ok: "invalid" } } }, { type: "tool_execution_end", toolName: "structured_output", isError: true }, { type: "message_end", message: { role: "toolResult", toolName: "structured_output", content: [{ type: "text", text: "Structured output validation failed." }], isError: true } }, events.assistantMessage("done"), ], } : { output: "done" }); executeAsyncSingle(id, { agent: "worker", task: "Return data", agentConfig: makeAgent("worker"), acceptance: false, ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, structuredOutputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, }); const payload = await readAsyncPayload(id); const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload; assert.equal(payload.success, false); assert.equal(payload.results[0]?.structuredOutput, undefined); assert.equal(status.steps?.[0]?.structuredOutput, undefined); assert.equal(fs.existsSync(path.join(ASYNC_DIR, id, "structured-output", "output.json")), false); } }); it("background child watchdog tail timeout still finalizes successful output", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { await withIsolatedWatchdogSettings(tempDir, async () => { writeWatchdogSettings(tempDir, 150); const id = `async-watchdog-timeout-${Date.now().toString(36)}`; mockPi.onCall({ jsonl: [events.assistantMessage("async-done-before-watchdog-timeout"), childWatchdogStatus(id, "reviewing", 1)], keepAliveAfterFinalMessageMs: 10000, }); const start = Date.now(); executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); const resultPath = await waitForAsyncResultFile(id, 10_000); const elapsed = Date.now() - start; const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.ok(elapsed < 6000, `watchdog tail fallback should not hang async final drain, took ${elapsed}ms`); assert.equal(payload.success, true); assert.equal(payload.results[0]?.output, "async-done-before-watchdog-timeout"); const watchdog = (payload.results[0] as { watchdog?: { phase?: string; timedOut?: boolean } }).watchdog; assert.equal(watchdog?.phase, "stale"); assert.equal(watchdog?.timedOut, true); }); }); it("background runs carry unaddressed child watchdog blockers into the result payload and acceptance", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { await withIsolatedWatchdogSettings(tempDir, async () => { writeWatchdogSettings(tempDir); const id = `async-watchdog-blocker-${Date.now().toString(36)}`; mockPi.onCall({ jsonl: [events.acceptanceReport(), events.watchdogStatusWarning("blocker", "Claims tests passed without running them", { runId: id, agent: "worker", childIndex: 0 })], structuredOutput: { ok: true } }); executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, acceptance: { level: "checked", criteria: ["Ship it"] }, structuredOutputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, }); const resultPath = await waitForAsyncResultFile(id, 10_000); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.equal(payload.success, false); const child = payload.results[0] as AsyncResultPayload["results"][number] & { watchdog?: { warnings?: Array<{ severity: string; addressed: boolean; summary: string }> } }; assert.deepEqual(child.structuredOutput, { ok: true }, "a real blocker remains visible alongside valid structured evidence"); assert.deepEqual(child.watchdog?.warnings?.map((warning) => [warning.severity, warning.addressed]), [["blocker", false]]); const check = child.acceptance?.runtimeChecks?.find((entry) => entry.id === "watchdog-blocker"); assert.equal(check?.status, "failed"); assert.match(check?.message ?? "", /Unresolved watchdog blocker/); }); }); it("background does not abort when a steer arrives after the final stop and turn_start is delayed", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ jsonl: [events.assistantMessage("before steer")], keepAliveAfterFinalMessageMs: 15_000, queuedMessageTurnStartDelayMs: 1400, queuedMessageOutput: "after steer", }); const id = `async-queued-steer-after-final-${Date.now().toString(36)}`; executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); await waitForMockPiCall(mockPi, 0, 10_000); const scriptedFinal = path.join(mockPi.dir, "scripted-final.jsonl"); const deadline = Date.now() + 10_000; while (!fs.existsSync(scriptedFinal)) { if (Date.now() > deadline) assert.fail("Timed out waiting for scripted final message"); await new Promise((resolve) => setTimeout(resolve, 20)); } requestAsyncSteer(path.join(ASYNC_DIR, id), { message: "Continue after the final stop.", id: "after-final", ts: Date.now() }); const payload = await readAsyncPayload(id); assert.equal(payload.success, true, payload.results[0]?.error); assert.equal(payload.results[0]?.error, undefined); assert.equal(payload.results[0]?.output, "after steer"); assert.doesNotMatch(JSON.stringify(payload), /did not settle within \d+ms after its terminal event/); }); for (const type of ["turn_start", "agent_start", "auto_retry_start"]) { it(`background keeps resumed work alive after ${type}`, { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { const id = `async-resumed-${type}-${Date.now().toString(36)}`; mockPi.onCall({ steps: [ { jsonl: [events.assistantMessage("before continuation"), { type }] }, // Queued steering/follow-up work is allowed to outlive the old final-stop grace. { delay: 1400, jsonl: [events.assistantMessage("after continuation")] }, ], }); executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); const payload = await readAsyncPayload(id); assert.equal(payload.success, true, payload.results[0]?.error); assert.equal(payload.results[0]?.output, "after continuation"); }); } it("background ignores stale watchdog completion after resumed work", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { await withIsolatedWatchdogSettings(tempDir, async () => { writeWatchdogSettings(tempDir); const id = `async-resumed-watchdog-${Date.now().toString(36)}`; mockPi.onCall({ steps: [ { jsonl: [events.assistantMessage("before continuation"), { type: "turn_start" }, childWatchdogStatus(id, "idle", 1)] }, { delay: 1400, jsonl: [events.assistantMessage("after continuation")] }, ], }); executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); const payload = await readAsyncPayload(id); assert.equal(payload.success, true, payload.results[0]?.error); assert.equal(payload.results[0]?.output, "after continuation"); }); }); it("background forced drain after final assistant output is cleanup success", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ jsonl: [events.assistantMessage("async-done-before-drain")], stderr: "Done after 1 turn(s). Ready for input.\n", keepAliveAfterFinalMessageMs: 10000, }); const id = `async-final-drain-${Date.now().toString(36)}`; const resultPath = path.join(RESULTS_DIR, `${id}.json`); const sessionRoot = path.join(tempDir, "sessions"); const start = Date.now(); executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot, maxSubagentDepth: 2, }); const deadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > deadline) { assert.fail(`Timed out waiting for async result file: ${resultPath}`); } await new Promise((resolve) => setTimeout(resolve, 100)); } const elapsed = Date.now() - start; const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")); assert.ok(elapsed < 9000, `should clean up async child before the mock's natural keepalive exit, took ${elapsed}ms`); assert.equal(payload.success, true); assert.equal(payload.exitCode, 0); assert.equal(payload.results[0].success, true); assert.equal(payload.results[0].output, "async-done-before-drain"); }); it("background forced drain after empty terminal assistant output is cleanup success", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ jsonl: [events.assistantMessage("")], keepAliveAfterFinalMessageMs: 10000, }); const id = `async-final-drain-empty-${Date.now().toString(36)}`; const resultPath = path.join(RESULTS_DIR, `${id}.json`); const start = Date.now(); executeAsyncSingle(id, { agent: "scout", task: "Inspect something", agentConfig: makeAgent("scout"), acceptance: { level: "attested", criteria: ["Finish cleanly"] }, ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); const deadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > deadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`); await new Promise((resolve) => setTimeout(resolve, 100)); } const elapsed = Date.now() - start; const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")); assert.ok(elapsed < 9000, `should clean up async child before the mock's natural keepalive exit, took ${elapsed}ms`); assert.equal(payload.success, true); assert.equal(payload.exitCode, 0); assert.equal(payload.results[0].success, true); assert.equal(payload.results[0].output, ""); }); it("background final-drain cleanup preserves explicit assistant errors", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ jsonl: [{ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "failed" }], model: "mock/test-model", stopReason: "stop", errorMessage: "provider exploded", usage: { input: 100, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0.001 } }, }, }], keepAliveAfterFinalMessageMs: 10000, }); const id = `async-final-drain-error-${Date.now().toString(36)}`; const resultPath = path.join(RESULTS_DIR, `${id}.json`); executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); const deadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > deadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`); await new Promise((resolve) => setTimeout(resolve, 100)); } const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")); assert.equal(payload.success, false); assert.equal(payload.exitCode, 1); assert.equal(payload.results[0].success, false); assert.equal(payload.results[0].error, "provider exploded"); }); it("reports terminal abort before a missing file-only handoff after mutation", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { const partialOutput = "I’ll inspect the retained candidate before changing it."; const repo = createRepo("pi-subagents-missing-handoff-partial-"); const outputPath = path.join(repo, "missing-challenge-report.md"); mockPi.onCall({ jsonl: [ events.assistantMessage(partialOutput), { type: "message_end", message: { role: "assistant", content: [], model: "mock/test-model", stopReason: "aborted", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } }, }, }, ], writeFiles: [{ path: "input.md", content: "changed by retained child\n" }], }); const task = [ "You are reviving a previous subagent conversation.", "", "Original run: source-run", "Original agent: worker", "Original session file: /tmp/source-session.jsonl", "", "Use the stored session context as background. Answer the orchestrator's follow-up below. Do not assume the original child session is still running.", "", "Follow-up:", "Implementation challenge pass 1 for the accepted candidate. Reconsider it and implement any better current-scope change.", ].join("\n"); const id = `async-missing-handoff-guard-${Date.now().toString(36)}`; try { executeAsyncSingle(id, { agent: "worker", task, agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: repo, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), output: outputPath, outputMode: "file-only", maxSubagentDepth: 2, }); const resultPath = await waitForAsyncResultFile(id); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const child = payload.results[0]; const diagnostic = child?.error ?? ""; assert.equal(payload.success, false); assert.equal(payload.state, "partial"); assert.equal(child?.success, false); assert.match(diagnostic, /^Subagent produced no output after terminal assistant stopReason "aborted"\./); assert.match(diagnostic, new RegExp(`Required file-only output was not produced: ${escapeRegExp(outputPath)}`)); assert.equal(child?.effects?.fileMutation?.status, "observed"); assert.equal(child?.effects?.fileMutation?.attempted, true); assert.deepEqual(child?.effects?.fileMutation?.evidence?.changedFiles, ["input.md"]); assert.equal(child?.effects?.settlementDiagnostic?.requiredOutput?.missing, true); assert.equal(fs.existsSync(outputPath), false); const status = await waitForAsyncState(id, (candidate) => candidate.state === "partial"); assert.equal(status.activityState, "needs_attention"); assert.equal(status.steps?.[0]?.activityState, "needs_attention"); assert.equal(status.steps?.[0]?.error, diagnostic); } finally { removeTempDir(repo); } }); it("preserves terminal empty-output diagnostics after useful child work", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { const partialOutput = "I’ll inspect the retained candidate before changing it."; const outputPath = path.join(tempDir, "missing-aborted-report.md"); mockPi.onCall({ jsonl: [ events.toolStart("read", { path: "src/index.ts" }), events.toolEnd("read"), events.toolResult("read", "file contents"), events.assistantMessage(partialOutput), { type: "message_end", message: { role: "assistant", content: [], model: "mock/test-model", stopReason: "aborted", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } }, }, }, ], exitCode: 0, }); const id = `async-aborted-empty-handoff-${Date.now().toString(36)}`; executeAsyncSingle(id, { agent: "worker", task: "Implement the approved file changes", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), output: outputPath, outputMode: "file-only", maxSubagentDepth: 2, }); const resultPath = await waitForAsyncResultFile(id); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const child = payload.results[0]; const diagnostic = child?.error ?? ""; assert.equal(payload.success, false); assert.equal(child?.success, false); assert.match(diagnostic, /^Subagent produced no output after terminal assistant stopReason "aborted"\./); assert.match(diagnostic, /Required file-only output was not produced/); assert.equal(child?.effects?.settlementDiagnostic?.requiredOutput?.missing, true); assert.equal(child?.effects?.settlementDiagnostic?.finalTextPresent, true); assert.equal(fs.existsSync(outputPath), false); }); it("reports bounded compaction failure context when file-only output is missing", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { const terminalError = `This operation was aborted\n${"x".repeat(12_000)}`; mockPi.onCall({ jsonl: [ events.toolStart("read", { path: "src/index.ts" }), events.toolEnd("read"), events.toolResult("read", "file contents"), { type: "compaction_start" }, { type: "message_end", message: { role: "assistant", content: [], model: "mock/test-model", stopReason: "error", errorMessage: terminalError, usage: { input: 100, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0.001 } }, }, }, { type: "agent_settled" }, ], exitCode: 0, }); const id = `async-compaction-file-only-error-${Date.now().toString(36)}`; const asyncDir = path.join(ASYNC_DIR, id); const outputPath = path.join(tempDir, "missing-oracle-report.md"); executeAsyncSingle(id, { agent: "oracle", task: "Write a report", agentConfig: makeAgent("oracle"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), output: outputPath, outputMode: "file-only", maxSubagentDepth: 2, }); const resultPath = await waitForAsyncResultFile(id); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const child = payload.results[0] as (AsyncResultPayload["results"][number] & Record) | undefined; const diagnostic = child?.error ?? ""; assert.equal(payload.success, false); assert.equal(child?.success, false); assert.match(diagnostic, /^This operation was aborted/); assert.match(diagnostic, /failure followed session compaction and agent settlement/); assert.match(diagnostic, /Required file-only output was not produced/); assert.ok(diagnostic.length <= 8_192); assert.equal(fs.existsSync(outputPath), false); assert.equal(child?.output, ""); assert.equal("savedOutputPath" in (child ?? {}), false); assert.equal("outputReference" in (child ?? {}), false); assert.equal(payload.summary, `oracle:\n${diagnostic}`); const status = await waitForAsyncState(id, (candidate) => candidate.state === "failed"); assert.equal(status.steps?.[0]?.exitCode, 1); assert.equal(status.steps?.[0]?.error, diagnostic); const logPath = path.join(asyncDir, `subagent-log-${id}.md`); const deadline = Date.now() + 10_000; while (!fs.existsSync(logPath)) { if (Date.now() > deadline) assert.fail(`Timed out waiting for async run log: ${logPath}`); await new Promise((resolve) => setTimeout(resolve, 50)); } assert.ok(fs.readFileSync(logPath, "utf-8").includes(`## Summary\noracle:\n${diagnostic}`)); }); it("preserves partial imported async roots with mutation evidence", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { const sourceId = `partial-source-${Date.now().toString(36)}`; const sourceDir = path.join(ASYNC_DIR, sourceId); const message = "Required file-only output was not produced: report.md"; const effects = { fileMutation: { status: "observed", attempted: true, evidence: { source: "tracked-files", trackedOnly: true, cwd: tempDir, changedFiles: ["input.md"], attemptedMutation: true } } }; fs.mkdirSync(sourceDir, { recursive: true }); fs.writeFileSync(path.join(sourceDir, "status.json"), JSON.stringify({ runId: sourceId, mode: "single", state: "partial", activityState: "needs_attention", startedAt: Date.now(), error: message, steps: [{ agent: "worker", status: "failed", activityState: "needs_attention", error: message, effects }], }), "utf-8"); const id = `async-imported-partial-${Date.now().toString(36)}`; executeAsyncChain(id, { chain: [], attachRoot: { runId: sourceId, asyncDir: sourceDir, resultPath: path.join(RESULTS_DIR, `${sourceId}.json`), index: 0, agent: "worker" }, agents: [makeAgent("worker")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); const resultPath = await waitForAsyncResultFile(id); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.equal(payload.success, false); assert.equal(payload.state, "partial"); assert.equal(payload.summary, message); assert.equal(payload.results[0]?.execution?.status, "partial"); assert.deepEqual(payload.results[0]?.effects?.fileMutation?.evidence?.changedFiles, ["input.md"]); const status = await waitForAsyncState(id, (candidate) => candidate.state === "partial"); assert.equal(status.activityState, "needs_attention"); assert.equal(status.steps?.[0]?.activityState, "needs_attention"); }); it("keeps concrete sibling failures above partial mutation evidence", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { const repo = createRepo("pi-subagents-partial-sibling-failure-"); const outputPath = path.join(repo, "missing-report.md"); mockPi.onCall({ matchArgIncludes: "Write required report", jsonl: [ events.assistantMessage("I changed the file but did not hand off the report."), { type: "message_end", message: { role: "assistant", content: [], model: "mock/test-model", stopReason: "aborted", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } }, }, }, ], writeFiles: [{ path: "input.md", content: "changed before missing report\n" }], }); mockPi.onCall({ matchArgIncludes: "Fail normally", stderr: "ordinary sibling failure", exitCode: 1 }); const id = `async-partial-sibling-failure-${Date.now().toString(36)}`; try { executeAsyncChain(id, { chain: [{ parallel: [ { agent: "partial", task: "Write required report" }, { agent: "failure", task: "Fail normally" }, ], concurrency: 2, }], resultMode: "parallel", agents: [makeAgent("partial", { output: outputPath, outputMode: "file-only" }), makeAgent("failure")], ctx: { pi: { events: { emit() {} } }, cwd: repo, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, maxSubagentDepth: 2, }); const payload = await readAsyncPayload(id); assert.equal(payload.success, false); assert.equal(payload.state, "failed"); assert.match(payload.summary, /ordinary sibling failure/); assert.equal(payload.results[0]?.effects?.fileMutation?.status, "observed"); assert.equal(payload.results[0]?.effects?.settlementDiagnostic?.requiredOutput?.missing, true); assert.match(payload.results[1]?.error ?? "", /ordinary sibling failure/); const status = await waitForAsyncState(id, (candidate) => candidate.state === "failed"); assert.equal(status.activityState, undefined); assert.match(status.error ?? "", /ordinary sibling failure/); } finally { removeTempDir(repo); } }); it("notifies once for each distinct long-open background command", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ steps: [ { jsonl: [{ type: "tool_execution_start", toolCallId: "bash-first", toolName: "bash", args: { command: "sleep 2" } }] }, { delay: 2200, jsonl: [{ type: "tool_execution_end", toolCallId: "bash-first", toolName: "bash" }, events.toolResult("bash", "done")] }, { jsonl: [{ type: "tool_execution_start", toolCallId: "bash-second", toolName: "bash", args: { command: "sleep 2" } }] }, { delay: 2200, jsonl: [{ type: "tool_execution_end", toolCallId: "bash-second", toolName: "bash" }, events.toolResult("bash", "done"), events.assistantMessage("Done")] }, ] }); const id = `async-sequential-attention-${Date.now().toString(36)}`; executeAsyncSingle(id, { agent: "worker", task: "Run commands", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, controlConfig: { enabled: true, needsAttentionAfterMs: 999_999, activeNoticeAfterMs: 100, failedToolAttemptsBeforeAttention: 3, notifyOn: ["needs_attention"], notifyChannels: ["event", "async"] }, }); const result = await readAsyncPayload(id); assert.equal(result.success, true); const rows = fs.readFileSync(path.join(ASYNC_DIR, id, "events.jsonl"), "utf8").trim().split("\n").map((line) => JSON.parse(line)); const attention = rows.filter((row) => row.type === "subagent.control" && row.event?.reason === "tool_open_threshold"); assert.deepEqual(attention.map((row) => row.event.toolCallId), ["bash-first", "bash-second"]); // The mock child has no native command controller, so the notice must not offer command controls. for (const row of attention) assert.doesNotMatch(row.noticeText, /"action":"command\.(status|yield|cancel)"/); }); it("background runs emit active-long-running control events from child turns", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ steps: [ { jsonl: [events.assistantMessage("still working")] }, { delay: 2_000, jsonl: [events.assistantMessage("done")] }, ], }); const id = `async-active-long-${Date.now().toString(36)}`; const asyncDir = path.join(ASYNC_DIR, id); const eventsPath = path.join(asyncDir, "events.jsonl"); const resultPath = path.join(RESULTS_DIR, `${id}.json`); executeAsyncSingle(id, { agent: "scout", task: "Investigate behavior", agentConfig: makeAgent("scout"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, controlConfig: { enabled: true, needsAttentionAfterMs: 999_999, activeNoticeAfterTurns: 1, activeNoticeAfterMs: 999_999, activeNoticeAfterTokens: 999_999, failedToolAttemptsBeforeAttention: 3, notifyOn: ["active_long_running", "needs_attention"], notifyChannels: ["event", "async", "intercom"], }, }); const statusPath = path.join(asyncDir, "status.json"); const deadline = Date.now() + 10_000; let eventText = ""; let statusDuringEvent: AsyncStatusPayload | undefined; while (Date.now() < deadline) { if (fs.existsSync(eventsPath)) { eventText = fs.readFileSync(eventsPath, "utf-8"); } if (eventText.includes('"type":"active_long_running"') && fs.existsSync(statusPath)) { const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload; if (status.activityState === "active_long_running" && status.steps?.[0]?.activityState === "active_long_running") { statusDuringEvent = status; break; } } if (eventText.includes('"type":"active_long_running"') && fs.existsSync(resultPath)) { assert.fail("run completed before status.json exposed active_long_running"); } await new Promise((resolve) => setTimeout(resolve, 100)); } assert.match(eventText, /"type":"active_long_running"/); assert.match(eventText, /"reason":"turn_threshold"/); assert.ok(statusDuringEvent, "expected status.json to expose active_long_running while the run is still active"); assert.equal(statusDuringEvent.activityState, "active_long_running"); assert.equal(statusDuringEvent.steps?.[0]?.activityState, "active_long_running"); const doneDeadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > doneDeadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`); await new Promise((resolve) => setTimeout(resolve, 100)); } }); it("does not flag a delayed active tool as idle attention", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ steps: [ { jsonl: [events.toolStart("bash", { command: "sleep 2" })] }, { delay: 2_500, jsonl: [events.toolEnd("bash"), events.toolResult("bash", "done")] }, { jsonl: [events.assistantMessage("Done")] }, ], }); const id = `async-delayed-tool-attention-${Date.now().toString(36)}`; const asyncDir = path.join(ASYNC_DIR, id); const eventsPath = path.join(asyncDir, "events.jsonl"); const resultPath = path.join(RESULTS_DIR, `${id}.json`); executeAsyncSingle(id, { agent: "worker", task: "Run the command", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, controlConfig: { enabled: true, needsAttentionAfterMs: 200, activeNoticeAfterMs: 999_999, failedToolAttemptsBeforeAttention: 3, notifyOn: ["active_long_running", "needs_attention"], notifyChannels: ["event", "async", "intercom"], }, }); const deadline = Date.now() + 10_000; let statusDuringTool: AsyncStatusPayload | undefined; while (Date.now() < deadline && !fs.existsSync(resultPath)) { if (fs.existsSync(asyncDir) && fs.existsSync(path.join(asyncDir, "status.json"))) { const status = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatusPayload; const toolStartedAt = status.steps?.[0]?.currentToolStartedAt; if (status.currentTool === "bash" && status.steps?.[0]?.currentTool === "bash" && toolStartedAt && Date.now() - toolStartedAt >= 1_500) { statusDuringTool = status; break; } } await new Promise((resolve) => setTimeout(resolve, 100)); } assert.ok(statusDuringTool, "expected status.json to expose the active tool"); assert.equal(statusDuringTool?.activityState, undefined); assert.equal(statusDuringTool?.steps?.[0]?.activityState, undefined); const eventText = fs.existsSync(eventsPath) ? fs.readFileSync(eventsPath, "utf-8") : ""; assert.doesNotMatch(eventText, /"type":"needs_attention"/); await waitForAsyncResultFile(id); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.equal(payload.success, true); }); it("background open-tool attention survives an overlapping quick tool", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ steps: [ { jsonl: [{ type: "tool_execution_start", toolCallId: "bash-1", toolName: "bash", args: { command: "sleep 2" } }] }, { delay: 50, jsonl: [ { type: "tool_execution_start", toolCallId: "read-1", toolName: "read", args: { path: "README.md" } }, { type: "tool_execution_end", toolCallId: "read-1", toolName: "read" }, ] }, { delay: 2_000, jsonl: [ { type: "tool_execution_end", toolCallId: "bash-1", toolName: "bash" }, events.toolResult("bash", "done"), events.assistantMessage("Done"), ] }, ], }); const id = `async-overlap-tool-attention-${Date.now().toString(36)}`; const asyncDir = path.join(ASYNC_DIR, id); const eventsPath = path.join(asyncDir, "events.jsonl"); const statusPath = path.join(asyncDir, "status.json"); const resultPath = path.join(RESULTS_DIR, `${id}.json`); executeAsyncSingle(id, { agent: "worker", task: "Run the command", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, controlConfig: { enabled: true, needsAttentionAfterMs: 999_999, activeNoticeAfterMs: 100, failedToolAttemptsBeforeAttention: 3, notifyOn: ["needs_attention"], notifyChannels: ["event", "async", "intercom"], }, }); const deadline = Date.now() + 10_000; let eventText = ""; let statusDuringEvent: AsyncStatusPayload | undefined; while (Date.now() < deadline) { if (fs.existsSync(eventsPath)) eventText = fs.readFileSync(eventsPath, "utf-8"); if (eventText.includes('"reason":"tool_open_threshold"') && fs.existsSync(statusPath)) { const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload; if (status.activityState === "needs_attention" && status.steps?.[0]?.activityState === "needs_attention") { statusDuringEvent = status; break; } } if (eventText.includes('"reason":"tool_open_threshold"') && fs.existsSync(resultPath)) { assert.fail("run completed before status.json exposed overlapping tool attention"); } await new Promise((resolve) => setTimeout(resolve, 100)); } assert.match(eventText, /"type":"needs_attention"/); assert.match(eventText, /"reason":"tool_open_threshold"/); assert.match(eventText, /"currentTool":"bash"/); assert.ok(statusDuringEvent, "expected status.json to expose overlapping tool attention while the run is active"); assert.equal(statusDuringEvent.currentTool, "bash"); assert.equal(statusDuringEvent.steps?.[0]?.currentTool, "bash"); await waitForAsyncResultFile(id); }); it("background open-tool attention survives a supervisor request that ends first", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { const id = `async-supervisor-then-tool-attention-${Date.now().toString(36)}`; const supervisorReleasePath = path.join(tempDir, `${id}.supervisor`); const finalReleasePath = path.join(tempDir, `${id}.final`); mockPi.onCall({ steps: [ { jsonl: [ { type: "tool_execution_start", toolCallId: "bash-1", toolName: "bash", args: { command: "sleep 5" } }, { type: "tool_execution_start", toolCallId: "decision", toolName: "contact_supervisor", args: { reason: "need_decision", message: "Choose" } }, ] }, { waitForPath: supervisorReleasePath, jsonl: [{ type: "tool_execution_end", toolCallId: "decision", toolName: "contact_supervisor" }] }, { waitForPath: finalReleasePath, jsonl: [{ type: "tool_execution_end", toolCallId: "bash-1", toolName: "bash" }, events.toolResult("bash", "done"), events.assistantMessage("Done")] }, ] }); const eventsPath = path.join(ASYNC_DIR, id, "events.jsonl"); const statusPath = path.join(ASYNC_DIR, id, "status.json"); executeAsyncSingle(id, { agent: "worker", task: "Run the command", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, controlConfig: { enabled: true, needsAttentionAfterMs: 999_999, activeNoticeAfterMs: 100, failedToolAttemptsBeforeAttention: 3, notifyOn: ["needs_attention"], notifyChannels: ["event", "async"] }, }); try { const deadline = Date.now() + 10_000; while (Date.now() < deadline && !(fs.existsSync(eventsPath) && fs.readFileSync(eventsPath, "utf-8").includes('"reason":"tool_open_threshold"'))) { await new Promise((resolve) => setTimeout(resolve, 50)); } assert.match(fs.readFileSync(eventsPath, "utf-8"), /"reason":"tool_open_threshold"/); fs.writeFileSync(supervisorReleasePath, ""); let status: AsyncStatusPayload | undefined; while (Date.now() < deadline) { status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload; // The supervisor call's end is in recentTools only after its cleanup ran. if (status.steps?.[0]?.recentTools?.some((tool) => tool.tool === "contact_supervisor")) break; await new Promise((resolve) => setTimeout(resolve, 50)); } assert.ok(status?.steps?.[0]?.recentTools?.some((tool) => tool.tool === "contact_supervisor"), "expected the supervisor call to have ended"); assert.equal(status?.currentTool, "bash"); assert.equal(status?.steps?.[0]?.activityState, "needs_attention"); assert.equal(status?.activityState, "needs_attention"); } finally { fs.writeFileSync(supervisorReleasePath, ""); fs.writeFileSync(finalReleasePath, ""); await waitForAsyncResultFile(id); } }); it("bg_wait wakes when an async child is waiting on contact_supervisor", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { const id = `async-supervisor-attention-${Date.now().toString(36)}`; const replyReleasePath = path.join(tempDir, `${id}.reply`); const finalReleasePath = path.join(tempDir, `${id}.final`); mockPi.onCall({ steps: [ { jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] }, { waitForPath: replyReleasePath, jsonl: [events.toolEnd("contact_supervisor"), events.toolResult("contact_supervisor", "**Reply from supervisor:**\nProceed")] }, { waitForPath: finalReleasePath, jsonl: [events.assistantMessage("Done")] }, ], }); const asyncDir = path.join(ASYNC_DIR, id); const eventsPath = path.join(asyncDir, "events.jsonl"); const resultPath = path.join(RESULTS_DIR, `${id}.json`); const statusPath = path.join(asyncDir, "status.json"); executeAsyncSingle(id, { agent: "worker", task: "Ask the supervisor for a blocking decision", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, controlConfig: { enabled: true, needsAttentionAfterMs: 999_999, activeNoticeAfterMs: 999_999, failedToolAttemptsBeforeAttention: 3, notifyOn: ["active_long_running", "needs_attention"], notifyChannels: ["event", "async", "intercom"], }, }); const releaseMockChild = () => { if (!fs.existsSync(replyReleasePath)) fs.writeFileSync(replyReleasePath, "release", "utf-8"); if (!fs.existsSync(finalReleasePath)) fs.writeFileSync(finalReleasePath, "release", "utf-8"); }; const releaseSupervisorReply = () => { if (!fs.existsSync(replyReleasePath)) fs.writeFileSync(replyReleasePath, "release", "utf-8"); }; try { const attentionDeadline = Date.now() + 10_000; let statusDuringAttention: AsyncStatusPayload | undefined; while (Date.now() < attentionDeadline && !fs.existsSync(resultPath)) { if (fs.existsSync(statusPath)) { const nextStatus = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload; if (nextStatus.currentTool === "contact_supervisor" && nextStatus.activityState === "needs_attention") { statusDuringAttention = nextStatus; break; } } await new Promise((resolve) => setTimeout(resolve, 100)); } assert.ok(statusDuringAttention, "expected status.json to expose the blocking supervisor request"); try { const waitResult = await waitForSubagents({ id, timeoutMs: 3_500 }, undefined, { state: { currentSessionId: "session-1", foregroundRuns: new Map(), asyncJobs: new Map(), cleanupTimers: new Map(), resultFileCoalescer: new Map() }, pollIntervalMs: 100, events: createEventBus(), }); const waitText = waitResult.content[0]?.text ?? ""; assert.equal(waitResult.isError, undefined); assert.match(waitText, /attention required/i); assert.match(waitText, new RegExp(id)); assert.match(waitText, /intercom\(\{ action: "pending" \}\)/); assert.equal(fs.existsSync(resultPath), false, "wait should return before the child completes"); } finally { releaseSupervisorReply(); } const eventText = fs.existsSync(eventsPath) ? fs.readFileSync(eventsPath, "utf-8") : ""; assert.match(eventText, /"type":"needs_attention"/); assert.match(eventText, /"reason":"supervisor_request"/); assert.equal(statusDuringAttention.activityState, "needs_attention"); assert.equal(statusDuringAttention.steps?.[0]?.activityState, "needs_attention"); assert.equal(statusDuringAttention.currentTool, "contact_supervisor"); assert.equal(statusDuringAttention.steps?.[0]?.currentTool, "contact_supervisor"); const clearDeadline = Date.now() + 10_000; let statusAfterReply: AsyncStatusPayload | undefined; while (Date.now() < clearDeadline && !fs.existsSync(resultPath)) { const nextStatus = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload; if (nextStatus.state === "running" && !nextStatus.currentTool && !nextStatus.steps?.[0]?.currentTool) { statusAfterReply = nextStatus; break; } await new Promise((resolve) => setTimeout(resolve, 100)); } assert.ok(statusAfterReply, "expected the child to keep running after the supervisor reply"); assert.equal(statusAfterReply.activityState, undefined); assert.equal(statusAfterReply.steps?.[0]?.activityState, undefined); fs.writeFileSync(finalReleasePath, "release", "utf-8"); await waitForAsyncResultFile(id); } finally { releaseMockChild(); } }); it("background runs escalate repeated mutating tool failures", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ steps: [ { jsonl: [events.toolStart("edit", { path: "src/runs/background/subagent-runner.ts" }), events.toolEnd("edit"), events.toolResult("edit", "No exact match found for subagent-runner.ts", true)] }, { jsonl: [events.toolStart("edit", { path: "src/runs/background/subagent-runner.ts" }), events.toolEnd("edit"), events.toolResult("edit", "No exact match found for subagent-runner.ts", true)] }, { jsonl: [events.toolStart("edit", { path: "src/runs/background/subagent-runner.ts" }), events.toolEnd("edit"), events.toolResult("edit", "No exact match found for subagent-runner.ts", true)] }, { delay: 2_000, jsonl: [events.assistantMessage("I need another attempt.")] }, ], }); const id = `async-tool-failures-${Date.now().toString(36)}`; const asyncDir = path.join(ASYNC_DIR, id); const eventsPath = path.join(asyncDir, "events.jsonl"); const resultPath = path.join(RESULTS_DIR, `${id}.json`); executeAsyncSingle(id, { agent: "worker", task: "Implement the approved fixes", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, controlConfig: { enabled: true, needsAttentionAfterMs: 999_999, activeNoticeAfterTurns: 999_999, activeNoticeAfterMs: 999_999, activeNoticeAfterTokens: 999_999, failedToolAttemptsBeforeAttention: 3, notifyOn: ["active_long_running", "needs_attention"], notifyChannels: ["event", "async", "intercom"], }, }); const statusPath = path.join(asyncDir, "status.json"); const deadline = Date.now() + 10_000; let eventText = ""; let statusDuringEvent: AsyncStatusPayload | undefined; while (Date.now() < deadline) { if (fs.existsSync(eventsPath)) { eventText = fs.readFileSync(eventsPath, "utf-8"); } if (eventText.includes('"reason":"tool_failures"') && fs.existsSync(statusPath)) { const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload; if (status.activityState === "needs_attention" && status.steps?.[0]?.activityState === "needs_attention") { statusDuringEvent = status; break; } } if (eventText.includes('"reason":"tool_failures"') && fs.existsSync(resultPath)) { assert.fail("run completed before status.json exposed needs_attention"); } await new Promise((resolve) => setTimeout(resolve, 100)); } assert.match(eventText, /"type":"needs_attention"/); assert.match(eventText, /"reason":"tool_failures"/); assert.match(eventText, /subagent-runner\.ts/); assert.ok(statusDuringEvent, "expected status.json to expose needs_attention while the run is still active"); assert.equal(statusDuringEvent.activityState, "needs_attention"); assert.equal(statusDuringEvent.steps?.[0]?.activityState, "needs_attention"); const doneDeadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > doneDeadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`); await new Promise((resolve) => setTimeout(resolve, 100)); } }); it("background runs record a parallel batch's mutating failure against the failed call's path", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { // Pi's parallel tool execution: every start and end comes before the result messages. const result = (toolCallId: string, isError: boolean, text: string) => ({ type: "message_end", message: { role: "toolResult", toolCallId, toolName: "edit", isError, content: [{ type: "text", text }] } }); mockPi.onCall({ steps: [ { jsonl: [ { type: "tool_execution_start", toolCallId: "edit-a", toolName: "edit", args: { path: "src/a.ts" } }, { type: "tool_execution_start", toolCallId: "edit-b", toolName: "edit", args: { path: "src/b.ts" } }, { type: "tool_execution_end", toolCallId: "edit-a", toolName: "edit", isError: true }, { type: "tool_execution_end", toolCallId: "edit-b", toolName: "edit", isError: false }, result("edit-a", true, "No exact match found for a.ts"), result("edit-b", false, "Edited src/b.ts"), ] }, { jsonl: [events.assistantMessage("Edit A failed.")] }, ], }); const id = `async-parallel-failure-${Date.now().toString(36)}`; const eventsPath = path.join(ASYNC_DIR, id, "events.jsonl"); const resultPath = path.join(RESULTS_DIR, `${id}.json`); executeAsyncSingle(id, { agent: "worker", task: "Edit both files", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, controlConfig: { enabled: true, needsAttentionAfterMs: 999_999, activeNoticeAfterTurns: 999_999, activeNoticeAfterMs: 999_999, activeNoticeAfterTokens: 999_999, failedToolAttemptsBeforeAttention: 1, notifyOn: ["active_long_running", "needs_attention"], notifyChannels: ["event", "async", "intercom"], }, }); const deadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > deadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`); await new Promise((resolve) => setTimeout(resolve, 100)); } const failureEvent = fs.readFileSync(eventsPath, "utf-8").split("\n").filter(Boolean) .map((line) => (JSON.parse(line) as { event?: { reason?: string; currentPath?: string; recentFailureSummary?: string } }).event) .find((event) => event?.reason === "tool_failures"); assert.equal(failureEvent?.currentPath, "src/a.ts"); assert.equal(failureEvent?.recentFailureSummary, "edit(src/a.ts): No exact match found for a.ts"); }); it("background status shows a tool-budget block from Pi's tool result message while the run is active", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { const blockedMessage = "Tool budget hard limit reached after 2 tool calls (hard 1). The 'bash' tool is blocked so you can finalize from the context you already have."; const blockedResult = { role: "toolResult", toolCallId: "bash-2", toolName: "bash", isError: true, content: [{ type: "text", text: blockedMessage }] }; mockPi.onCall({ steps: [ { jsonl: [events.toolStart("bash", { command: "echo 1" }), events.toolEnd("bash"), events.toolResult("bash", "1")] }, // Pi's sequence for a call blocked by a tool_call handler: start, end with isError, then the toolResult message. { jsonl: [ { type: "tool_execution_start", toolCallId: "bash-2", toolName: "bash", args: { command: "echo 2" } }, { type: "tool_execution_end", toolCallId: "bash-2", toolName: "bash", isError: true }, { type: "message_start", message: blockedResult }, { type: "message_end", message: blockedResult }, ] }, { delay: 2_000, jsonl: [events.assistantMessage("Finalizing from what I have.")] }, ], }); const id = `async-tool-budget-blocked-${Date.now().toString(36)}`; const statusPath = path.join(ASYNC_DIR, id, "status.json"); const resultPath = path.join(RESULTS_DIR, `${id}.json`); executeAsyncSingle(id, { agent: "worker", task: "Run echo three times", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, toolBudget: { hard: 1, block: "*" }, }); const deadline = Date.now() + 10_000; let blockedStep: AsyncStatusPayload["steps"][number] | undefined; while (Date.now() < deadline && !fs.existsSync(resultPath)) { if (fs.existsSync(statusPath)) { const step = (JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload).steps?.[0]; if (step?.status === "running" && step.toolBudgetBlocked) { blockedStep = step; break; } } await new Promise((resolve) => setTimeout(resolve, 50)); } assert.ok(blockedStep, "expected status.json to show the tool-budget block while the run is still active"); assert.equal(blockedStep.toolBudget?.blockedTool, "bash"); const doneDeadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > doneDeadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`); await new Promise((resolve) => setTimeout(resolve, 100)); } }); it("background event logs drop noisy message updates and cap child diagnostics", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { const previousMaxBytes = process.env.SELESAI_SUBAGENT_ASYNC_EVENTS_MAX_BYTES; process.env.SELESAI_SUBAGENT_ASYNC_EVENTS_MAX_BYTES = "1100"; try { mockPi.onCall({ steps: [ { jsonl: [ { type: "message_update", assistantMessageEvent: { type: "thinking_delta", delta: "NOISY_PARTIAL_DELTA", partial: { role: "assistant", content: [{ type: "text", text: "NOISY_PARTIAL_SNAPSHOT".repeat(200) }] }, }, message: { role: "assistant", content: [{ type: "text", text: "NOISY_PARTIAL_MESSAGE".repeat(200) }] }, }, events.toolStart("bash", { command: `echo ${"BIG_COMMAND_PAYLOAD".repeat(200)}` }), events.assistantMessage("Done after noisy stream"), ], }, ], }); const id = `async-noisy-events-${Date.now().toString(36)}`; const asyncDir = path.join(ASYNC_DIR, id); const sessionRoot = path.join(tempDir, "sessions"); executeAsyncSingle(id, { agent: "worker", task: "Stream noisy diagnostics", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot, maxSubagentDepth: 2, }); const resultPath = await waitForAsyncResultFile(id, 10_000); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.equal(payload.success, true); assert.equal(payload.results[0]?.output, "Done after noisy stream"); const eventsText = fs.readFileSync(path.join(asyncDir, "events.jsonl"), "utf-8"); assert.doesNotMatch(eventsText, /"type":"message_update"/); assert.doesNotMatch(eventsText, /NOISY_PARTIAL_/); assert.doesNotMatch(eventsText, /BIG_COMMAND_PAYLOAD/); assert.match(eventsText, /"type":"subagent\.events\.truncated"/); assert.match(eventsText, /"droppedEventType":"tool_execution_start"/); } finally { if (previousMaxBytes === undefined) delete process.env.SELESAI_SUBAGENT_ASYNC_EVENTS_MAX_BYTES; else process.env.SELESAI_SUBAGENT_ASYNC_EVENTS_MAX_BYTES = previousMaxBytes; } }); it("background runs stream child events and live output while active", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ steps: [ { delay: 200, jsonl: [events.toolStart("bash", { command: "ls" })] }, { delay: 600, jsonl: [events.toolEnd("bash"), events.toolResult("bash", "file-a\nfile-b")] }, { delay: 600, jsonl: [events.assistantMessage("Done streaming")], stderr: "warning: mock stderr\n" }, ], }); const id = `async-stream-${Date.now().toString(36)}`; const asyncDir = path.join(ASYNC_DIR, id); const eventsPath = path.join(asyncDir, "events.jsonl"); const outputPath = path.join(asyncDir, "output-0.log"); const resultPath = path.join(RESULTS_DIR, `${id}.json`); const sessionRoot = path.join(tempDir, "sessions"); executeAsyncSingle(id, { agent: "worker", task: "Stream detailed progress", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot, maxSubagentDepth: 2, }); const liveDeadline = Date.now() + 10_000; let sawChildEvent = false; let sawLiveOutput = false; while (Date.now() < liveDeadline && (!sawChildEvent || !sawLiveOutput)) { if (fs.existsSync(eventsPath)) { const content = fs.readFileSync(eventsPath, "utf-8"); sawChildEvent = content.includes('"type":"tool_execution_start"') && content.includes('"subagentSource":"child"'); } if (fs.existsSync(outputPath)) { const content = fs.readFileSync(outputPath, "utf-8"); sawLiveOutput = content.includes("bash: ls") || content.includes("file-a") || content.includes("warning: mock stderr"); } if (sawChildEvent && sawLiveOutput) break; assert.equal(fs.existsSync(resultPath), false, "run finished before live observability was written"); await new Promise((resolve) => setTimeout(resolve, 100)); } assert.equal(sawChildEvent, true, "expected child JSON events to be streamed into events.jsonl"); assert.equal(sawLiveOutput, true, "expected output-0.log to receive live child output"); const doneDeadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > doneDeadline) { assert.fail(`Timed out waiting for async result file: ${resultPath}`); } await new Promise((resolve) => setTimeout(resolve, 100)); } const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")); assert.equal(payload.success, true); assert.equal(payload.results[0].output, "Done streaming"); const status = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")); assert.deepEqual(status.steps[0].recentTools.map((tool: { tool: string; args: string }) => ({ tool: tool.tool, args: tool.args })), [{ tool: "bash", args: "ls" }]); assert.deepEqual(status.steps[0].recentOutput, ["file-a", "file-b", "Done streaming"]); }); const launcherExecutor = () => { const wrapper = path.join(tempDir, "wrap.sh"); const argvLog = path.join(tempDir, "wrap-argv.log"); fs.writeFileSync(wrapper, `#!/bin/sh\nprintf '%s\\n' "$@" > ${JSON.stringify(argvLog)}\nshift\nexec "$@"\n`, { mode: 0o755 }); const runnerLaunchers = { wrap: [wrapper, "tag arg"] }; return { argvLog, runnerLaunchers, executor: makeAsyncExecutor([makeAgent("sandboxed", { launcher: "wrap" })], { runnerLaunchers }) }; }; it("runs a launcher agent in the background through its exec wrapper with steer, supervisor and result intact", { timeout: 30_000, skip: !isAsyncAvailable() || process.platform === "win32" ? "requires jiti and a POSIX wrapper" : undefined }, async () => { const { argvLog, runnerLaunchers, executor } = launcherExecutor(); const replyRelease = path.join(tempDir, "reply-release"); const finalRelease = path.join(tempDir, "final-release"); mockPi.onCall({ steps: [ { jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] }, { waitForPath: replyRelease, jsonl: [events.toolEnd("contact_supervisor"), events.toolResult("contact_supervisor", "**Reply from supervisor:**\nProceed")] }, { waitForPath: finalRelease, jsonl: [events.assistantMessage("wrapped result")] }, ] }); try { // async is omitted and the executor's asyncByDefault is false: the launcher forces background. const launch = await executor.executePublic("launcher-run", { agent: "sandboxed", task: "Work in the sandbox" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir)); assert.equal(launch.isError, undefined, launch.content[0]?.text); const id = launch.details?.asyncId; assert.ok(id); const asyncDir = path.join(ASYNC_DIR, id); const asking = await waitForAsyncState(id, (status) => status.currentTool === "contact_supervisor"); assert.deepEqual((asking as AsyncStatusPayload & { launcher?: unknown }).launcher, { name: "wrap", argv: runnerLaunchers.wrap }); const wrapperArgv = fs.readFileSync(argvLog, "utf-8").trimEnd().split("\n"); assert.equal(wrapperArgv[0], "tag arg"); assert.ok(path.isAbsolute(wrapperArgv[1]!), "the wrapper received the resolved runner executable"); assert.ok(wrapperArgv.some((arg) => /subagent-runner-bootstrap\.(?:ts|js)$/.test(arg))); requestAsyncSteer(asyncDir, { message: "Focus on the tests.", id: "launcher-steer", ts: Date.now() }); await waitForAsyncState(id, (status) => Boolean((status as { steering?: { recent: Array<{ id: string; targets: Array<{ state: string }> }> } }).steering?.recent.some((request) => request.id === "launcher-steer" && request.targets[0]?.state === "queued"))); assert.match(fs.readFileSync(path.join(mockPi.dir, "steers.jsonl"), "utf-8"), /Focus on the tests\./); fs.writeFileSync(replyRelease, "go"); await waitForAsyncState(id, (status) => status.state === "running" && !status.currentTool); fs.writeFileSync(finalRelease, "go"); const payload = await readAsyncPayload(id); assert.equal(payload.success, true, payload.error); assert.equal(payload.results[0]?.output, "wrapped result"); const done = await waitForAsyncState(id, (status) => status.state === "complete"); assert.deepEqual((done as AsyncStatusPayload & { launcher?: unknown }).launcher, { name: "wrap", argv: runnerLaunchers.wrap }); } finally { fs.writeFileSync(replyRelease, "go"); fs.writeFileSync(finalRelease, "go"); } }); it("wraps a workflow-script child that selects a launcher agent", { timeout: 30_000, skip: !isAsyncAvailable() || process.platform === "win32" ? "requires jiti and a POSIX wrapper" : undefined }, async () => { const { argvLog, executor } = launcherExecutor(); mockPi.onCall({ output: "workflow child result" }); const result = await executor.executePublic("launcher-workflow", { async: false, workflowScript: `return await runs.run("sandboxed", { agent: "sandboxed", task: "Work" });` }, new AbortController().signal, undefined, makeMinimalCtx(tempDir)); assert.equal(result.isError, undefined, result.content[0]?.text); const child = result.details?.workflow?.value as { ok?: boolean; output?: string }; assert.equal(child.ok, true); assert.equal(child.output, "workflow child result"); assert.equal(fs.readFileSync(argvLog, "utf-8").split("\n")[0], "tag arg"); }); it("runs a launcher agent whose file defaults to foreground in the background when the call omits async", { timeout: 30_000, skip: !isAsyncAvailable() || process.platform === "win32" ? "requires jiti and a POSIX wrapper" : undefined }, async () => { const { argvLog, runnerLaunchers } = launcherExecutor(); const executor = makeAsyncExecutor([makeAgent("sandboxed", { launcher: "wrap", defaultAsync: false })], { runnerLaunchers }); mockPi.onCall({ output: "defaulted result" }); const launch = await executor.executePublic("launcher-default-foreground", { agent: "sandboxed", task: "Work" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir)); assert.equal(launch.isError, undefined, launch.content[0]?.text); assert.ok(launch.details?.asyncId, "the launch ran in the background"); assert.equal((await readAsyncPayload(launch.details.asyncId)).success, true); assert.equal(fs.readFileSync(argvLog, "utf-8").split("\n")[0], "tag arg"); }); it("stops a launcher-wrapped background run through the file control channel", { timeout: 30_000, skip: !isAsyncAvailable() || process.platform === "win32" ? "requires jiti and a POSIX wrapper" : undefined }, async () => { const { executor } = launcherExecutor(); const neverReleased = path.join(tempDir, "never-released"); mockPi.onCall({ steps: [{ waitForPath: neverReleased, jsonl: [events.assistantMessage("should not finish")] }] }); const launch = await executor.executePublic("launcher-stop", { agent: "sandboxed", task: "Wait" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir)); assert.equal(launch.isError, undefined, launch.content[0]?.text); const id = launch.details!.asyncId!; await waitForMockPiCall(mockPi, 0, 10_000); deliverStopRequest({ asyncDir: path.join(ASYNC_DIR, id), source: "test" }); assert.equal((await readAsyncPayload(id)).state, "stopped"); }); it("refuses an unknown launcher before spending fan-out or creating a session directory", { timeout: 30_000, skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => { const sessionDir = path.join(tempDir, "sessions-root"); const budget = createRunFanoutBudget(`launcher-budget-${Date.now().toString(36)}`, 1); const executor = makeAsyncExecutor([makeAgent("sandboxed", { launcher: "missing" })], { defaultSessionDir: sessionDir }); try { const refused = await executor.execute("launcher-unknown-budget", { agent: "sandboxed", task: "Work", runFanoutBudget: budget }, new AbortController().signal, undefined, makeMinimalCtx(tempDir)); assert.equal(refused.isError, true); assert.match(refused.content[0]?.text ?? "", /uses launcher 'missing', which is not defined in runnerLaunchers/); assert.deepEqual(getRunFanoutBudgetSnapshot(budget), { used: 0, limit: 1, remaining: 1 }); assert.deepEqual(fs.existsSync(sessionDir) ? fs.readdirSync(sessionDir) : [], []); } finally { fs.rmSync(budget.directory, { recursive: true, force: true }); } }); it("refuses launcher launches it cannot wrap before starting any child", { skip: !createSubagentExecutor ? "executor not available" : undefined }, async () => { const marker = path.join(tempDir, "wrapper-ran"); const runnerLaunchers = { wrap: [process.execPath, "-e", `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "ran")`] }; const sandboxed = makeAgent("sandboxed", { launcher: "wrap" }); const plain = makeAsyncExecutor([sandboxed], { runnerLaunchers }); const forced = makeAsyncExecutor([sandboxed], { forceTopLevelAsync: true, runnerLaunchers }); // forceTopLevelAsync must not rescue a foreground request. The public tool rejects clarify on its own, so clarify goes through the internal entry point. for (const [request, run] of [[{ async: false }, plain.executePublic], [{ async: false }, forced.executePublic], [{ clarify: true }, forced.execute]] as const) { const result = await run("launcher-refused", { agent: "sandboxed", task: "Work", ...request }, new AbortController().signal, undefined, makeMinimalCtx(tempDir)); assert.equal(result.isError, true); assert.match(result.content[0]?.text ?? "", /Foreground children run inside the parent process, so a launcher cannot wrap them/); } // A settings override can add machine to an agent whose file sets launcher; the launch must refuse it. const placed = await makeAsyncExecutor([makeAgent("sandboxed", { launcher: "wrap", machine: "workmac" })], { runnerLaunchers }) .executePublic("launcher-placed", { agent: "sandboxed", task: "Work" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir)); assert.equal(placed.isError, true); // Windows refuses saved-machine placement itself before the launcher check is reached. assert.match(placed.content[0]?.text ?? "", process.platform === "win32" ? /not supported from a Windows host/ : /uses launcher 'wrap'.*cannot run on machine 'workmac'/); assert.equal(fs.existsSync(marker), false); assert.equal(mockPi.callCount(), 0); }); // A broker-style launcher: starts the runner in its own process group, stays attached, // does not forward signals, and reports a runner signal death as exit 128+n. const brokerExecutor = () => { const broker = path.join(tempDir, "broker.cjs"); const pidsPath = path.join(tempDir, "broker-pids.json"); fs.writeFileSync(broker, `const { spawn } = require("node:child_process"); const [pidsPath, command, ...args] = process.argv.slice(2); const runner = spawn(command, args, { detached: true, stdio: "inherit" }); require("node:fs").writeFileSync(pidsPath, JSON.stringify({ wrapperPid: process.pid, runnerPid: runner.pid })); runner.on("exit", (code, signal) => process.exit(signal ? 128 + require("node:os").constants.signals[signal] : code ?? 1)); `); const executor = makeAsyncExecutor([makeAgent("sandboxed", { launcher: "broker" })], { runnerLaunchers: { broker: [process.execPath, broker, pidsPath] } }); const pids = () => JSON.parse(fs.readFileSync(pidsPath, "utf-8")) as { wrapperPid: number; runnerPid: number }; const reap = () => { if (!fs.existsSync(pidsPath)) return; for (const pid of Object.values(pids())) try { process.kill(pid, "SIGKILL"); } catch {} }; const launch = async (id: string) => { const result = await executor.executePublic(id, { agent: "sandboxed", task: "Work" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir)); assert.equal(result.isError, undefined, result.content[0]?.text); return result.details!.asyncId!; }; return { pids, reap, launch }; }; const readJson = (filePath: string) => JSON.parse(fs.readFileSync(filePath, "utf-8")); const waitForFile = async (filePath: string) => { for (const deadline = Date.now() + 10_000; !fs.existsSync(filePath); await new Promise((resolve) => setTimeout(resolve, 50))) { if (Date.now() > deadline) throw new Error(`Timed out waiting for ${filePath}`); } }; it("keeps a broker-launched run alive and controllable when only the wrapper dies", { timeout: 30_000, skip: !isAsyncAvailable() || process.platform === "win32" ? "requires jiti and POSIX signals" : undefined }, async () => { const { pids, reap, launch } = brokerExecutor(); mockPi.onCall({ steps: [{ waitForPath: path.join(tempDir, "never-released"), jsonl: [events.assistantMessage("should not finish")] }] }); try { const id = await launch("broker-wrapper-death"); const asyncDir = path.join(ASYNC_DIR, id); await waitForMockPiCall(mockPi, 0, 10_000); const { wrapperPid, runnerPid } = pids(); assert.notEqual(wrapperPid, runnerPid); assert.equal(readJson(path.join(asyncDir, "status.json")).pid, runnerPid); process.kill(wrapperPid, "SIGKILL"); await waitForFile(path.join(asyncDir, "launcher-close.json")); assert.equal(readJson(path.join(asyncDir, "launcher-close.json")).signal, "SIGKILL"); assert.equal(readJson(path.join(asyncDir, "process-terminal.json")).state, "pending"); assert.equal(reconcileAsyncRun(asyncDir).status?.state, "running"); assert.notEqual(activeRunMarkerAgeMs(asyncDir), undefined, "the active-run index still holds the live runner"); requestAsyncSteer(asyncDir, { message: "Still there?", id: "broker-steer", ts: Date.now() }); await waitForAsyncState(id, (status) => Boolean((status as { steering?: { recent: Array<{ id: string; targets: Array<{ state: string }> }> } }).steering?.recent.some((request) => request.id === "broker-steer" && request.targets[0]?.state === "queued"))); deliverStopRequest({ asyncDir, source: "test" }); assert.equal((await readAsyncPayload(id)).state, "stopped"); } finally { reap(); } }); it("fails a broker-launched run whose runner is killed and records the wrapper's exit verbatim", { timeout: 30_000, skip: !isAsyncAvailable() || process.platform === "win32" ? "requires jiti and POSIX signals" : undefined }, async () => { const { pids, reap, launch } = brokerExecutor(); mockPi.onCall({ steps: [{ waitForPath: path.join(tempDir, "never-released"), jsonl: [events.assistantMessage("should not finish")] }] }); try { const id = await launch("broker-runner-death"); const asyncDir = path.join(ASYNC_DIR, id); await waitForMockPiCall(mockPi, 0, 10_000); process.kill(pids().runnerPid, "SIGKILL"); await waitForFile(path.join(asyncDir, "launcher-close.json")); assert.deepEqual({ ...readJson(path.join(asyncDir, "launcher-close.json")), closeObservedAt: 0 }, { version: 1, runId: id, runnerProcessInstanceId: readJson(path.join(asyncDir, "process-terminal.json")).runnerProcessInstanceId, closeObservedAt: 0, exitCode: 137, signal: null, runnerPid: pids().runnerPid }); assert.notEqual(readJson(path.join(asyncDir, "process-terminal.json")).state, "pending"); assert.equal(reconcileAsyncRun(asyncDir).status?.state, "failed"); assert.equal((await readAsyncPayload(id)).state, "failed"); } finally { reap(); } }); it("refuses a launcher run whose wrapper exits before the runner reports its identity", { timeout: 30_000, skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { const executor = makeAsyncExecutor([makeAgent("sandboxed", { launcher: "gone" })], { runnerLaunchers: { gone: [process.execPath, "-e", "process.exit(3)"] } }); mockPi.onCall({ output: "should not run" }); const before = new Set(fs.existsSync(ASYNC_DIR) ? fs.readdirSync(ASYNC_DIR) : []); const result = await executor.executePublic("broker-preproceed", { agent: "sandboxed", task: "Work" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir)); assert.equal(result.isError, true); assert.match(result.content[0]?.text ?? "", /exited before startup state 'identified'/); // The refused launch returns no run id; its run is the directory this launch created. const asyncDir = fs.readdirSync(ASYNC_DIR).filter((entry) => !before.has(entry)).map((entry) => path.join(ASYNC_DIR, entry)).find((dir) => fs.existsSync(path.join(dir, "launcher-close.json"))); assert.ok(asyncDir); assert.equal(readJson(path.join(asyncDir, "launcher-close.json")).exitCode, 3); assert.equal(readJson(path.join(asyncDir, "process-terminal.json")).state, "pending", "a wrapper exit is not runner-terminal proof"); assert.equal(mockPi.callCount(), 0); }); // Resume revives a retained session, so the source run is a fork with a pre-created session file. const forkCtx = (name: string) => { const parentSessionFile = path.join(tempDir, `${name}-parent.jsonl`); const sessionFile = path.join(tempDir, `${name}-child.jsonl`); const header = JSON.stringify({ type: "session", version: 1, id: name, cwd: fs.realpathSync(tempDir) }); fs.writeFileSync(parentSessionFile, `${header}\n`); fs.writeFileSync(sessionFile, `${header}\n`); return { ...makeMinimalCtx(tempDir), sessionManager: { getSessionId: () => `${name}-session`, getSessionFile: () => parentSessionFile, getLeafId: () => "leaf", openSession: () => ({ createBranchedSession: () => sessionFile }) } }; }; const launchAndFinish = async (executor: ReturnType, ctx: ReturnType, agent: string) => { const launch = await executor.execute(`${agent}-launch`, { agent, task: "Do work", async: true, context: "fork", acceptance: false }, new AbortController().signal, undefined, ctx) as { isError?: boolean; content: Array<{ text?: string }>; details: { asyncId?: string } }; assert.equal(launch.isError, undefined, launch.content[0]?.text); assert.equal((await readAsyncPayload(launch.details.asyncId!)).success, true); return launch.details.asyncId!; }; const resume = (executor: ReturnType, ctx: ReturnType, id: string) => executor.execute(`resume-${id}`, { action: "resume", id, message: "Continue", acceptance: false }, new AbortController().signal, undefined, ctx) as Promise<{ isError?: boolean; content: Array<{ text?: string }>; details: { asyncId?: string } }>; it("resumes a launcher run under its recorded launcher with the current argv, refusing while the name is undefined", { timeout: 30_000, skip: !isAsyncAvailable() || process.platform === "win32" ? "requires jiti and a POSIX wrapper" : undefined }, async () => { const { argvLog, runnerLaunchers, executor } = launcherExecutor(); const ctx = forkCtx("launcher-resume"); mockPi.onCall({ output: "first run" }); const sourceId = await launchAndFinish(executor, ctx, "sandboxed"); assert.equal(readJson(path.join(ASYNC_DIR, sourceId, "recovery-descriptor.json")).launcher, "wrap"); fs.rmSync(argvLog); // The agent file no longer names a launcher; the recorded one still applies. const unwrappedAgent = [makeAgent("sandboxed")]; const capacityBefore = getActiveAsyncCapacitySnapshot("launcher-resume-session", 4).used; const runsBefore = fs.readdirSync(ASYNC_DIR).length; const refused = await resume(makeAsyncExecutor(unwrappedAgent, {}), ctx, sourceId); assert.equal(refused.isError, true); assert.match(refused.content[0]?.text ?? "", /launched with launcher 'wrap', which is no longer defined in runnerLaunchers/); assert.equal(mockPi.callCount(), 1); assert.equal(getActiveAsyncCapacitySnapshot("launcher-resume-session", 4).used, capacityBefore); assert.equal(fs.readdirSync(ASYNC_DIR).length, runsBefore, "the refused resume created no run"); assert.equal(fs.existsSync(argvLog), false); const editedArgv = [runnerLaunchers.wrap[0]!, "tag v2"]; mockPi.onCall({ output: "resumed run" }); const resumed = await resume(makeAsyncExecutor(unwrappedAgent, { runnerLaunchers: { wrap: editedArgv } }), ctx, sourceId); assert.equal(resumed.isError, undefined, resumed.content[0]?.text); const payload = await readAsyncPayload(resumed.details.asyncId!); assert.equal(payload.success, true, payload.error); assert.equal(fs.readFileSync(argvLog, "utf-8").split("\n")[0], "tag v2"); assert.deepEqual((await waitForAsyncState(resumed.details.asyncId!, (status) => status.state === "complete") as AsyncStatusPayload & { launcher?: unknown }).launcher, { name: "wrap", argv: editedArgv }); }); it("keeps resuming an unwrapped run unwrapped after its agent file adds a launcher", { timeout: 30_000, skip: !isAsyncAvailable() || process.platform === "win32" ? "requires jiti and a POSIX wrapper" : undefined }, async () => { const { argvLog, runnerLaunchers } = launcherExecutor(); const ctx = forkCtx("unwrapped-resume"); mockPi.onCall({ output: "first run" }); const sourceId = await launchAndFinish(makeAsyncExecutor([makeAgent("plain")]), ctx, "plain"); mockPi.onCall({ output: "resumed run" }); const resumed = await resume(makeAsyncExecutor([makeAgent("plain", { launcher: "wrap" })], { runnerLaunchers }), ctx, sourceId); assert.equal(resumed.isError, undefined, resumed.content[0]?.text); assert.equal((await readAsyncPayload(resumed.details.asyncId!)).success, true); assert.equal(fs.existsSync(argvLog), false); assert.equal((readJson(path.join(ASYNC_DIR, resumed.details.asyncId!, "status.json")) as { launcher?: unknown }).launcher, undefined); }); const runChain = (id: string, chain: unknown[], agents: ReturnType[], runnerLaunchers: Record) => executeAsyncChain(id, { chain, agents, ctx: { pi: { events: createEventBus() }, cwd: tempDir, currentSessionId: "session-1", runnerLaunchers }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, maxSubagentDepth: 2, }); it("runs a chain whose agents share a launcher inside one wrapped runner", { timeout: 30_000, skip: !isAsyncAvailable() || process.platform === "win32" ? "requires jiti and a POSIX wrapper" : undefined }, async () => { const { argvLog, runnerLaunchers } = launcherExecutor(); mockPi.onCall({ output: "first" }); mockPi.onCall({ output: "second" }); const id = `launcher-chain-${Date.now().toString(36)}`; const result = await runChain(id, [{ agent: "sandboxed", task: "A" }, { agent: "helper", task: "B" }], [makeAgent("sandboxed", { launcher: "wrap" }), makeAgent("helper", { launcher: "wrap" })], runnerLaunchers); assert.equal(result.isError, undefined, result.content[0]?.text); const payload = await readAsyncPayload(id); assert.equal(payload.success, true, payload.error); assert.deepEqual(payload.results.map((child) => child.output), ["first", "second"]); assert.equal(fs.readFileSync(argvLog, "utf-8").split("\n").filter((arg) => arg === "tag arg").length, 1, "one runner launch for the whole chain"); assert.deepEqual((await waitForAsyncState(id, (status) => status.state === "complete") as AsyncStatusPayload & { launcher?: unknown }).launcher, { name: "wrap", argv: runnerLaunchers.wrap }); }); it("refuses a chain that would mix launchers in one runner, naming each agent", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { const agents = [makeAgent("plain"), makeAgent("netA", { launcher: "a" }), makeAgent("netB", { launcher: "b" })]; const runnerLaunchers = { a: ["true"], b: ["true"] }; const dynamic = { expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 4 }, parallel: { agent: "netA", task: "Review {target.path}" }, collect: { as: "reviews" } }; const cases: Array<[unknown[], RegExp]> = [ [[{ agent: "plain", task: "A" }, { agent: "netA", task: "B" }], /plain \(none\), netA \(a\)/], [[{ parallel: [{ agent: "netA", task: "A" }, { agent: "netB", task: "B" }] }], /netA \(a\), netB \(b\)/], [[{ agent: "plain", task: "Produce", as: "targets", outputSchema: { type: "object" } }, dynamic], /plain \(none\), netA \(a\)/], ]; for (const [index, [chain, names]] of cases.entries()) { const id = `launcher-mixed-${index}-${Date.now().toString(36)}`; const result = await runChain(id, chain, agents, runnerLaunchers); assert.equal(result.isError, true); assert.match(result.content[0]?.text ?? "", /must use the same launcher or none/); assert.match(result.content[0]?.text ?? "", names); assert.equal(fs.existsSync(path.join(ASYNC_DIR, id)), false); } for (const runner of [{ type: "external-cli", command: process.execPath }, { type: "external-job", provider: "jobs" }] as const) { const external = await runChain(`launcher-chain-${runner.type}-${Date.now().toString(36)}`, [{ agent: "ext", task: "A" }], [makeAgent("ext", { launcher: "a", runner })], runnerLaunchers); assert.equal(external.isError, true); assert.match(external.content[0]?.text ?? "", new RegExp(`Agent 'ext' uses launcher 'a', which wraps the local Pi background runner, so it cannot run with runner\\.type='${runner.type}'`)); } const unknown = await runChain(`launcher-chain-unknown-${Date.now().toString(36)}`, [{ agent: "netA", task: "A" }], agents, {}); assert.equal(unknown.isError, true); assert.match(unknown.content[0]?.text ?? "", /Launcher 'a' used by this chain is not defined in runnerLaunchers/); // The refusal comes before step building, which would overwrite progress.md for a progress-enabled parallel step. fs.writeFileSync(path.join(tempDir, "progress.md"), "existing progress\n"); const unknownParallel = await runChain(`launcher-chain-unknown-par-${Date.now().toString(36)}`, [{ parallel: [{ agent: "netA", task: "A", progress: true }] }], agents, {}); assert.match(unknownParallel.content[0]?.text ?? "", /Launcher 'a' used by this chain is not defined in runnerLaunchers/); assert.equal(fs.readFileSync(path.join(tempDir, "progress.md"), "utf-8"), "existing progress\n"); assert.equal(mockPi.callCount(), 0); }); });