// src/__tests__/run-spawn-integration.test.ts // // runSpawn 集成测试(spawn 改造核心函数)。 // // runSpawn 负责 spawn pi 子进程、pump stdout JSON 事件流、signal/maxTurns 终止、 // identity 补写、exitCode 判定。此前完全无集成测试——本文件覆盖审查发现的关键路径。 // // mock 策略(参考 worktree-manager.test.ts 的 mock 模式): // - node:child_process.spawn → 返回 FakeChild(EventEmitter + PassThrough), // 测试用控制器 emit data/close/error 控制时序。 // - node:child_process.execFile → err-first callback 默认兜底(buildEnvBlock 的 git // branch 调用失败 → catch → branch="")。 // - node:fs 同步方法 → mock(mkdirSync/existsSync/appendFileSync/writeFileSync 等), // 避免 sessionDir/sessionFile 触碰真实文件系统。 // - fs.promises.* → 保留真实实现(temp-prompt 整体被 mock,不触发真实 I/O)。 // - temp-prompt → mock(writePromptToTempFile 返回固定路径,消除 fake-timers flaky)。 // - alive-store.writeAliveMarker → mock(避免写 .alive sidecar)。 // // mock 工厂 + FakeChild class + 工具函数(lastSpawnedChild/waitForSpawn/emitStdoutLine/ // sessionHeader/makeRecord/makeOpts/makeCtx)抽到 helpers/spawn-mock.ts,与 // run-spawn-edges.test.ts / run-spawn-rpc-mode.test.ts 三文件共享。vi.mock 工厂内用 // `await import("./helpers/spawn-mock.ts")` 取回 FakeChild(绕开 vitest 的 hoisting 限制—— // 工厂函数体不能引用顶层 import 变量,但 async 工厂内的 await import 是运行时求值)。 import { spawn } from "node:child_process"; import * as fs from "node:fs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // ── mock modules(工厂体共享自 helpers/spawn-mock.ts;vi.mock 必须各文件独立声明)── vi.mock("node:child_process", async () => { const { FakeChild } = await import("./helpers/spawn-mock.ts"); return { spawn: vi.fn(() => new FakeChild()), // buildEnvBlock 的 git branch 调用(execFile 异步):默认 err-first 兜底 → catch → branch="" execFile: vi.fn( ( _cmd: string, _args: readonly string[], _opts: unknown, cb: (err: Error | null, stdout?: string, stderr?: string) => void, ) => cb(new Error("execFile not configured in this test")), ), }; }); // node:fs:同步方法 mock(runSpawn 用到的全部),promises 保留真实实现(temp-prompt 用)。 vi.mock("node:fs", async () => { const actual = await import("node:fs"); return { default: { ...actual, mkdirSync: vi.fn(), existsSync: vi.fn(() => false), appendFileSync: vi.fn(), writeFileSync: vi.fn(), readdirSync: vi.fn(() => []), }, mkdirSync: vi.fn(), existsSync: vi.fn(() => false), appendFileSync: vi.fn(), writeFileSync: vi.fn(), readdirSync: vi.fn(() => []), promises: actual.promises, }; }); vi.mock("../alive-store.ts", () => ({ writeAliveMarker: vi.fn(), })); // temp-prompt:mock 掉真实 fs.promises I/O(mkdtemp/writeFile/rm)。 // 原先保留真实实现导致 fake-timers 测试偶发 flaky——writePromptToTempFile 的真实异步 // I/O 在 CI 慢机器上无法在 advanceTimersByTimeAsync 的有限步数内 resolve,spawn 永不触发。 vi.mock("../temp-prompt.ts", () => ({ writePromptToTempFile: vi.fn(async (agent: string) => { const safeName = agent.replace(/[^\w.-]+/g, "_"); return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt-${safeName}.md` }; }), cleanupTempPrompt: vi.fn(async () => {}), })); import { runSpawn } from "../session-runner.ts"; import { emitStdoutLine, type FakeChild, lastSpawnedChild as lastSpawnedChildOf, makeCtx, makeOpts, makeRecord, mockSessionFileExists as mockSessionFileExistsOf, sessionHeader, waitForSpawn as waitForSpawnOf, } from "./helpers/spawn-mock.ts"; const mockSpawn = vi.mocked(spawn); const mockExistsSync = vi.mocked(fs.existsSync); const mockAppendFileSync = vi.mocked(fs.appendFileSync); const mockMkdirSync = vi.mocked(fs.mkdirSync); // 绑定到本文件 mockSpawn 的 lastSpawnedChild/waitForSpawn(需读 mockSpawn.mock.results) const lastSpawnedChild = (): FakeChild => lastSpawnedChildOf(mockSpawn); const waitForSpawn = (timeoutMs = 1000): Promise => waitForSpawnOf(mockSpawn, timeoutMs); const mockSessionFileExists = (p: string): void => mockSessionFileExistsOf(mockExistsSync, p); // ============================================================ // 测试 // ============================================================ describe("runSpawn", () => { beforeEach(() => { vi.clearAllMocks(); // existsSync 默认 false(sessionFile 不存在兜底路径) mockExistsSync.mockReturnValue(false); }); afterEach(() => { vi.restoreAllMocks(); }); // ── 1. 正常路径(happy path)── describe("正常路径", () => { it("header + turn_end 事件 + close(0) → success=true,record.sessionFile 被设置,turnCount 正确", async () => { const record = makeRecord(); const promise = runSpawn(record, "Task: hello", makeOpts(), makeCtx()); // 等待 spawn 被调用拿到 child // 等待 spawn 被调用拿到 child await waitForSpawn(); const child = lastSpawnedChild(); const expectedSessionFile = "/tmp/test/agents/subagents/--tmp-test--/sessions/2026-07-03T12-00-00-000Z_sess-abc.jsonl"; // 进程退出后 existsSync(record.sessionFile) 校验通过 → 补写 identity mockSessionFileExists(expectedSessionFile); // emit stdout:header + 2 个 turn_end emitStdoutLine(child, sessionHeader("sess-abc")); emitStdoutLine(child, { type: "turn_end" }); emitStdoutLine(child, { type: "turn_end" }); child.stdout.end(); child.emit("close", 0); const result = await promise; expect(result.success).toBe(true); expect(result.turns).toBe(2); // 2 个 turn_end expect(record.turnCount).toBe(2); // record.sessionFile 由 header 推导路径回填 expect(record.sessionFile).toBe(expectedSessionFile); expect(result.sessionFile).toBe(expectedSessionFile); // sessionId 来自 header.id expect(result.sessionId).toBe("sess-abc"); }); it("collectResult 文本/toolCalls 从 record 派生(text_delta + tool 调用累积进 turns)", async () => { const record = makeRecord(); const promise = runSpawn(record, "Task: write", makeOpts(), makeCtx()); // 等待 spawn 被调用拿到 child await waitForSpawn(); const child = lastSpawnedChild(); mockSessionFileExists( "/tmp/test/agents/subagents/--tmp-test--/sessions/2026-07-03T12-00-00-000Z_sess-x.jsonl", ); emitStdoutLine(child, sessionHeader("sess-x")); emitStdoutLine(child, { type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "Hello " } }); emitStdoutLine(child, { type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "world" } }); emitStdoutLine(child, { type: "tool_execution_start", toolCallId: "t1", toolName: "bash", args: { command: "ls" } }); emitStdoutLine(child, { type: "tool_execution_end", toolCallId: "t1", toolName: "bash", result: { details: "file.txt" } }); emitStdoutLine(child, { type: "turn_end" }); child.stdout.end(); child.emit("close", 0); const result = await promise; expect(result.success).toBe(true); expect(result.text).toBe("Hello world"); expect(result.toolCalls).toHaveLength(1); expect(result.toolCalls[0]!.toolName).toBe("bash"); expect(result.toolCalls[0]!.result?.details).toBe("file.txt"); }); }); // ── 2. signal abort → child.kill(cancel 路径)── describe("signal abort", () => { it("abort signal 触发后 child.kill(SIGTERM) 被调用,success=false", async () => { const controller = new AbortController(); const record = makeRecord(); const promise = runSpawn( record, "Task: long", makeOpts({ signal: controller.signal }), makeCtx(), ); // 等待 spawn 被调用拿到 child await waitForSpawn(); const child = lastSpawnedChild(); // abort 必须在 spawn 之后(addEventListener 已注册)。 // 用 queueMicrotask 延迟到当前微任务清空后触发,确保 listener 已挂载。 queueMicrotask(() => controller.abort()); // emit header + close(被 kill 后子进程退出,signal 终止 exitCode>=128 或 null) emitStdoutLine(child, sessionHeader()); child.stdout.end(); child.emit("close", 143); // SIGTERM = 128+15 const result = await promise; expect(child.killed).toBe(true); expect(child.killSignal).toBe("SIGTERM"); // signal.aborted 路径:success=false,但 error 为 undefined(取消不算 error) expect(result.success).toBe(false); expect(result.error).toBeUndefined(); }); }); // ── 3. maxTurns → limiter.abort → proc.kill ── describe("maxTurns 限制", () => { it("maxTurns=1, graceTurns=0 → 超出 turn 后 child.kill 被调用,success=true(达限视为正常完成)", async () => { const record = makeRecord(); const promise = runSpawn( record, "Task: bounded", makeOpts({ maxTurns: 1, graceTurns: 0 }), makeCtx(), ); // 等待 spawn 被调用拿到 child await waitForSpawn(); const child = lastSpawnedChild(); emitStdoutLine(child, sessionHeader()); // 第 1 个 turn_end:turn=1 >= maxTurns=1 → steer(noop) emitStdoutLine(child, { type: "turn_end" }); // 第 2 个 turn_end:turn=2 >= maxTurns(1)+graceTurns(0)=1 → abort → proc.kill emitStdoutLine(child, { type: "turn_end" }); child.stdout.end(); // 被 kill 后信号终止(exitCode>=128),视为正常完成 child.emit("close", 143); const result = await promise; expect(child.killed).toBe(true); expect(child.killSignal).toBe("SIGTERM"); // maxTurns kill 视为正常完成(exitCode>=128 走信号终止分支 → success=true) expect(result.success).toBe(true); }); it("maxTurns=2, graceTurns=2 → steer 后需 graceTurns 到达才 kill(第 4 turn 才 abort)", async () => { const record = makeRecord(); const promise = runSpawn( record, "Task: grace", makeOpts({ maxTurns: 2, graceTurns: 2 }), makeCtx(), ); // 等待 spawn 被调用拿到 child await waitForSpawn(); const child = lastSpawnedChild(); // emit header + 4 个 turn_end。limiter 逻辑(maxTurns=2, graceTurns=2): // turn=1: 1<2,无动作 // turn=2: 2>=limit → steer(仅一次) // turn=3: 3=4 → abort → proc.kill(SIGTERM) // 注:PassThrough 流加 data 监听器后,缓冲块逐个在后续微任务中 flush,close 前 await // 较长时间窗(20ms)确保全部 turn_end 已被 data 处理器消费(含 limiter.abort → kill)。 emitStdoutLine(child, sessionHeader()); emitStdoutLine(child, { type: "turn_end" }); // turn=1 emitStdoutLine(child, { type: "turn_end" }); // turn=2: steer emitStdoutLine(child, { type: "turn_end" }); // turn=3: 宽限内 emitStdoutLine(child, { type: "turn_end" }); // turn=4: abort → kill child.stdout.end(); await new Promise((r) => setTimeout(r, 20)); child.emit("close", 143); const result = await promise; // 第 4 个 turn_end 触发 abort → child.kill(SIGTERM) expect(child.killed).toBe(true); expect(child.killSignal).toBe("SIGTERM"); expect(record.turnCount).toBe(4); // exitCode>=128(信号终止)→ success=true(达限视为正常完成) expect(result.success).toBe(true); }); }); // ── 4. 子进程非零退出(exitCode < 128)── describe("非零退出码", () => { it("close(1) + stderr 内容 → success=false,error 含 stderr", async () => { const record = makeRecord(); const promise = runSpawn(record, "Task: fail", makeOpts(), makeCtx()); // 等待 spawn 被调用拿到 child await waitForSpawn(); const child = lastSpawnedChild(); emitStdoutLine(child, sessionHeader()); child.stderr.write("Error: something went wrong\n"); child.stderr.end(); child.stdout.end(); child.emit("close", 1); const result = await promise; expect(result.success).toBe(false); expect(result.error).toContain("something went wrong"); }); it("close(1) 无 stderr → error 含 exit code 信息", async () => { const record = makeRecord(); const promise = runSpawn(record, "Task: fail", makeOpts(), makeCtx()); // 等待 spawn 被调用拿到 child await waitForSpawn(); const child = lastSpawnedChild(); child.stdout.end(); child.stderr.end(); child.emit("close", 1); const result = await promise; expect(result.success).toBe(false); expect(result.error).toContain("exited with code 1"); }); }); // ── 5. 子进程被信号终止(exitCode = null 或 >= 128)── describe("信号终止", () => { it("close(null) → success=true(信号终止视为正常,非 aborted)", async () => { const record = makeRecord(); const promise = runSpawn(record, "Task: sig", makeOpts(), makeCtx()); // 等待 spawn 被调用拿到 child await waitForSpawn(); const child = lastSpawnedChild(); emitStdoutLine(child, sessionHeader()); child.stdout.end(); child.stderr.end(); child.emit("close", null); const result = await promise; expect(result.success).toBe(true); }); it("close(143)(SIGTERM,>=128)无 signal → success=true", async () => { const record = makeRecord(); const promise = runSpawn(record, "Task: sig143", makeOpts(), makeCtx()); // 等待 spawn 被调用拿到 child await waitForSpawn(); const child = lastSpawnedChild(); emitStdoutLine(child, sessionHeader()); child.stdout.end(); child.stderr.end(); child.emit("close", 143); const result = await promise; expect(result.success).toBe(true); }); }); // ── 6. spawn 本身失败(error 事件,如 ENOENT)── describe("spawn error 事件", () => { it("child.emit('error') → success=false,error 含错误信息,record.lastError 被设置", async () => { const record = makeRecord(); const promise = runSpawn(record, "Task: enoent", makeOpts(), makeCtx()); // 等待 spawn 被调用拿到 child await waitForSpawn(); const child = lastSpawnedChild(); // spawn error 事件(command not found 等)——runSpawn 的 error handler resolve(128) child.emit("error", new Error("spawn ENOENT")); const result = await promise; expect(result.success).toBe(false); expect(result.error).toContain("spawn ENOENT"); expect(record.lastError).toContain("spawn ENOENT"); }); it("[worktree-reaper-fix] ENOENT error 消息拼 spawnCwd(避免误诊 node 被卸载)", async () => { const record = makeRecord(); const promise = runSpawn(record, "Task: enoent-cwd", makeOpts(), makeCtx()); await waitForSpawn(); const child = lastSpawnedChild(); // ENOENT 且带 code(Node spawn 失败的真实形态)——error handler 必须拼 spawnCwd const err = Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }); child.emit("error", err); const result = await promise; expect(result.success).toBe(false); // makeCtx().cwd = "/tmp/test",无 worktree 时 spawnCwd = ctx.cwd expect(record.lastError).toContain("/tmp/test"); }); }); // ── 7. identity 写入职责(M4 / V2 决策 5:迁移到子进程 session_start)── describe("identity 写入职责", () => { it("正常 close 后 session-runner 不再 fs 补写 identity(M4 迁移到子进程 session_start)", async () => { const record = makeRecord(); const promise = runSpawn(record, "Task: identity", makeOpts(), makeCtx()); // 等待 spawn 被调用拿到 child await waitForSpawn(); const child = lastSpawnedChild(); const sessionFile = "/tmp/test/agents/subagents/--tmp-test--/sessions/2026-07-03T12-00-00-000Z_sess-id.jsonl"; mockSessionFileExists(sessionFile); emitStdoutLine(child, sessionHeader("sess-id")); emitStdoutLine(child, { type: "turn_end" }); child.stdout.end(); child.emit("close", 0); await promise; // [M4 / V2 决策 5] identity 不再由 session-runner fs.appendFileSync 补写—— // 改由子进程 session_start hook 用 pi.appendEntry 写(pi 自动生成 id/parentId, // 修复旧 fs 补写缺 id/parentId 污染 _buildIndex 的 bug)。identity 写入由 // index-session-start-identity.test.ts 覆盖;此处守护 session-runner 不再 fs 写。 expect(mockAppendFileSync).not.toHaveBeenCalled(); }); it("sessionFile 不存在(existsSync=false)→ 不补写 identity(不调 appendFileSync)", async () => { const record = makeRecord(); const promise = runSpawn(record, "Task: nofile", makeOpts(), makeCtx()); // 等待 spawn 被调用拿到 child await waitForSpawn(); const child = lastSpawnedChild(); // existsSync 全返回 false(默认)→ record.sessionFile 经兜底查找仍不存在 emitStdoutLine(child, sessionHeader("sess-none")); child.stdout.end(); child.emit("close", 0); await promise; expect(mockAppendFileSync).not.toHaveBeenCalled(); }); }); // ── 8. fork depth 环境变量传递 ── describe("fork depth 环境变量", () => { it("fork=true, parentForkDepth=2 → spawn env 含 PI_SUBAGENT_FORK_DEPTH=3", async () => { const record = makeRecord(); const promise = runSpawn( record, "Task: fork", makeOpts({ fork: true, parentForkDepth: 2 }), makeCtx({ mainSessionFile: "/tmp/main.jsonl" }), ); // 等待 spawn 被调用拿到 child await waitForSpawn(); const child = lastSpawnedChild(); emitStdoutLine(child, sessionHeader()); child.stdout.end(); child.emit("close", 0); await promise; // spawn 第 3 参数是 options(含 env) const spawnOpts = mockSpawn.mock.calls[0]?.[2] as { env: Record }; expect(spawnOpts.env.PI_SUBAGENT_FORK_DEPTH).toBe("3"); }); it("fork=false → spawn env 不含 PI_SUBAGENT_FORK_DEPTH", async () => { const record = makeRecord(); const promise = runSpawn(record, "Task: nofork", makeOpts(), makeCtx()); // 等待 spawn 被调用拿到 child await waitForSpawn(); const child = lastSpawnedChild(); emitStdoutLine(child, sessionHeader()); child.stdout.end(); child.emit("close", 0); await promise; const spawnOpts = mockSpawn.mock.calls[0]?.[2] as { env: Record }; expect(spawnOpts.env.PI_SUBAGENT_FORK_DEPTH).toBeUndefined(); }); it("spawn 参数含 --session-dir 且 cwd 来自 ctx.cwd", async () => { const record = makeRecord(); const promise = runSpawn(record, "Task: args", makeOpts(), makeCtx()); // 等待 spawn 被调用拿到 child await waitForSpawn(); const child = lastSpawnedChild(); emitStdoutLine(child, sessionHeader()); child.stdout.end(); child.emit("close", 0); await promise; // spawn(command, args, opts):args 是数组,应含 --session-dir const args = mockSpawn.mock.calls[0]?.[1] as string[]; const sessionDirIdx = args.indexOf("--session-dir"); expect(sessionDirIdx).toBeGreaterThanOrEqual(0); expect(args[sessionDirIdx + 1]).toContain("--tmp-test--"); // spawn opts.cwd 来自 ctx.cwd(无 worktree 时) const spawnOpts = mockSpawn.mock.calls[0]?.[2] as { cwd: string }; expect(spawnOpts.cwd).toBe("/tmp/test"); }); }); // ── 9. onEvent 回调 ── describe("onEvent 回调", () => { it("stdout 事件经 handleSdkEvent 翻译后回调 opts.onEvent", async () => { const onEvent = vi.fn(); const record = makeRecord(); const promise = runSpawn(record, "Task: events", makeOpts({ onEvent }), makeCtx()); // 等待 spawn 被调用拿到 child await waitForSpawn(); const child = lastSpawnedChild(); emitStdoutLine(child, sessionHeader()); emitStdoutLine(child, { type: "turn_end" }); child.stdout.end(); child.emit("close", 0); await promise; // onEvent 至少被 turn_end 调过一次(onEvent 签名是单参数 event) const turnEndCalls = onEvent.mock.calls.filter(([ev]) => (ev as { type: string }).type === "turn_end"); expect(turnEndCalls.length).toBeGreaterThanOrEqual(1); }); }); // ── 10. sessionDir 创建 ── describe("sessionDir 创建", () => { it("mkdirSync 以 recursive 创建 sessionDir", async () => { const record = makeRecord(); const promise = runSpawn(record, "Task: mkdir", makeOpts(), makeCtx()); // 等待 spawn 被调用拿到 child await waitForSpawn(); const child = lastSpawnedChild(); emitStdoutLine(child, sessionHeader()); child.stdout.end(); child.emit("close", 0); await promise; expect(mockMkdirSync).toHaveBeenCalledWith( "/tmp/test/agents/subagents/--tmp-test--/sessions", { recursive: true }, ); }); }); // ── 11. 临时 prompt 文件清理 ── describe("临时 prompt 文件清理", () => { it("有 appendSystemPrompt → 创建临时文件并在 finally 清理(spawn 退出后目录消失)", async () => { const record = makeRecord(); const promise = runSpawn( record, "Task: cleanup", makeOpts({ appendSystemPrompt: ["extra instructions"] }), makeCtx(), ); // 等待 spawn 被调用拿到 child await waitForSpawn(); const child = lastSpawnedChild(); emitStdoutLine(child, sessionHeader()); child.stdout.end(); child.emit("close", 0); await promise; // temp-prompt 已 mock(见文件顶部 vi.mock),返回固定文件名; // 验证 spawn args 含 --append-system-prompt(说明临时文件路径被传给子进程) const args = mockSpawn.mock.calls[0]?.[1] as string[]; const appendIdx = args.indexOf("--append-system-prompt"); expect(appendIdx).toBeGreaterThanOrEqual(0); expect(args[appendIdx + 1]).toMatch(/prompt-general-purpose\.md$/); }); }); // ── 12. watchdog 超时兜底(R1)── // // [R0/R1] SPAWN_WATCHDOG_MS = 30 * 60 * 1000(session-runner.ts 顶部)。 // 子进程卡死在单 tool 内(turn_end 永不触发)时 limiter 失效,watchdog timer 兜底 SIGTERM, // 防止 background 槽位/资源泄漏。timer 加了 .unref()(不阻止 Node 退出)。 // // fake timers 方案 A:describe 块内 beforeEach useFakeTimers、afterEach useRealTimers。 // 难点:waitForSpawn 内部用 `await new Promise(r => setTimeout(r, 5))` 轮询,fake timers 下 // 该 setTimeout 不会自动触发——改用 vi.advanceTimersByTimeAsync 推进时间,它会同步 flush // 已到期的 timer + 让挂起的真实 I/O(writePromptToTempFile 的 fs.promises)resolve。 // 关键验证:unref 的 timer 在 fake timers 下仍能被 advanceTimersByTime 触发(vitest fake // timers 忽略 unref)。 describe("watchdog 超时兜底 (R1)", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); /** * fake timers 下推进时间直到 spawn 被调用(替代 waitForSpawn,返回 child 控制器)。 * * [快照语义] 同 helpers/spawn-mock.ts 的 waitForSpawn:记调用时 baseline,等待其后的 * **新** spawn——支持同一测试/文件内多次 runSpawn。 * * runSpawn 在 mkdirSync + writePromptToTempFile(真实 fs.promises I/O)之后才调 spawn。 * 每次推进 10ms 让轮询 setTimeout 触发;同时 advanceTimersByTimeAsync flush 已 resolve * 的 I/O promise,使 runSpawn 继续走到 spawn。 */ async function waitForSpawnFake(timeoutSteps = 200): Promise { const baseline = mockSpawn.mock.results.length; for (let i = 0; i < timeoutSteps; i++) { if (mockSpawn.mock.results.length > baseline) break; await vi.advanceTimersByTimeAsync(10); } if (mockSpawn.mock.results.length <= baseline) { throw new Error("spawn was not called (fake timers did not progress to spawn)"); } return lastSpawnedChild(); } it("watchdog 超时(>30min)→ child.kill(SIGTERM) 被调用", async () => { const record = makeRecord(); // 不 await——runSpawn 内部 await 子进程 close,watchdog 触发 kill 后还需 emit close 才 resolve // [M-1] maxTurns=6 使 computeWatchdogMs 返回下限 30min(max(30min, 6*5min) = 30min) const promise = runSpawn(record, "Task: hang", makeOpts({ maxTurns: 6 }), makeCtx()); const child = await waitForSpawnFake(); // spawn 后 child.killed 应为 false(watchdog 尚未触发) expect(child.killed).toBe(false); // 推进时间越过 watchdog 阈值(30 * 60 * 1000 + 100ms 余量) await vi.advanceTimersByTimeAsync(30 * 60 * 1000 + 100); // watchdog 触发 child.kill("SIGTERM") expect(child.killed).toBe(true); expect(child.killSignal).toBe("SIGTERM"); // 收尾:emit close 让 runSpawn 的 await promise resolve(避免悬挂) emitStdoutLine(child, sessionHeader()); child.stdout.end(); child.emit("close", 143); // SIGTERM = 128+15 const result = await promise; // 信号终止(>=128)视为正常完成 expect(result.success).toBe(true); }); it("正常 close(0) → clearTimeout 生效,watchdog 不触发 kill", async () => { const record = makeRecord(); // [M-1] maxTurns=6 使 watchdog=30min(与上面 watchdog 触发测试同基准) const promise = runSpawn(record, "Task: quick", makeOpts({ maxTurns: 6 }), makeCtx()); const child = await waitForSpawnFake(); // 正常完成:emit header + close(0) emitStdoutLine(child, sessionHeader()); child.stdout.end(); child.emit("close", 0); const result = await promise; expect(result.success).toBe(true); // close 后 runSpawn 已 clearTimeout(watchdog);推进 30+ 分钟验证 watchdog 未触发 kill await vi.advanceTimersByTimeAsync(30 * 60 * 1000 + 100); expect(child.killed).toBe(false); expect(child.killSignal).toBeUndefined(); }); }); // ── 13. stderr 截断防 OOM(R2)── // // [R2] stderrBuffer = (stderrBuffer + data).slice(-STDERR_MAX_CHARS)(STDERR_MAX_CHARS=64*1024)。 // 失控子进程打满 stderr 会耗尽父进程内存;保留尾部 64KB 用于 error 诊断。 describe("stderr 截断防 OOM (R2)", () => { it("stderr > 64KB → error 中的 stderr 内容是尾部 64KB(尾部标记保留,头部标记被截断)", async () => { const record = makeRecord(); const promise = runSpawn(record, "Task: noisy stderr", makeOpts(), makeCtx()); await waitForSpawn(); const child = lastSpawnedChild(); // 头部标记(应被截断):足够长的前缀让 64KB 窗口推过它 const HEAD_MARKER = "HEAD_MARKER_HEAD_MARKER"; child.stderr.write(HEAD_MARKER); // 填充 > 64KB 的噪声(用 "x" 重复),把头部标记推出 64KB 尾部窗口 child.stderr.write("x".repeat(70 * 1024)); // 尾部标记(应在截断后仍保留) child.stderr.write("TAIL_MARKER_TAIL_MARKER"); emitStdoutLine(child, sessionHeader()); child.stderr.end(); child.stdout.end(); child.emit("close", 1); // 非零退出 → error 含 stderr const result = await promise; // 非零退出码 → success=false expect(result.success).toBe(false); // error 含尾部标记(证明保留了尾部 64KB) expect(result.error).toContain("TAIL_MARKER_TAIL_MARKER"); // error 不含头部标记(证明头部被截断丢弃) expect(result.error).not.toContain(HEAD_MARKER); }); }); // ── 14. UTF-8 编码跨 chunk 安全(R3)── // // [R3] child.stdout.setEncoding("utf8") 让流按字符边界切分,避免多字节 UTF-8 //(CJK/emoji)跨 chunk 时 toString() 产生 U+FFFD 替换符导致 JSON.parse 失败。 // // 测法选择:用流状态(readableEncoding)验证 setEncoding("utf8") 被 runSpawn 调用过。 // 为什么不用 spy:runSpawn 在 spawn() 返回后同步调用 setEncoding,而 waitForSpawn 拿到 // child 时调用已完成,无法事后挂 spy 捕获。readableEncoding 是 PassThrough(Readable) 暴露的 // 只读属性,setEncoding("utf8") 后值变为 "utf8",是"编码确实被设置"的可靠证据。 // 端到端分片写多字节字符无法真正模拟"字节边界切断"(PassThrough + setEncoding 已在更高层 // 合并),故补一个"分片写入仍能解析"的用例佐证行缓冲健壮性。 describe("UTF-8 编码跨 chunk 安全 (R3)", () => { it("runSpawn 对 child.stdout 和 child.stderr 调用 setEncoding('utf8')(readableEncoding 验证)", async () => { const record = makeRecord(); const promise = runSpawn(record, "Task: utf8", makeOpts(), makeCtx()); await waitForSpawn(); const child = lastSpawnedChild(); // spawn 后 runSpawn 已同步调用 setEncoding("utf8"):readableEncoding 反映该调用结果 expect(child.stdout.readableEncoding).toBe("utf8"); expect(child.stderr.readableEncoding).toBe("utf8"); emitStdoutLine(child, sessionHeader()); child.stdout.end(); child.stderr.end(); child.emit("close", 0); const result = await promise; expect(result.success).toBe(true); }); it("stdout 多次分片写入同一 JSON 行仍能正确解析 turn_end(行缓冲合并)", async () => { // 端到端验证:数据分两次 write,runSpawn 的行缓冲(stdoutBuffer += data)合并后解析。 // 此用例验证"分片写入仍能解析",setEncoding("utf8") 已由上一用例覆盖。 const record = makeRecord(); const promise = runSpawn(record, "Task: split", makeOpts(), makeCtx()); await waitForSpawn(); const child = lastSpawnedChild(); // header 完整一行 emitStdoutLine(child, sessionHeader()); // turn_end 拆成两半写入(跨 chunk 模拟) child.stdout.write('{"type":"turn_e'); child.stdout.write('nd"}\n'); child.stdout.end(); child.emit("close", 0); const result = await promise; expect(result.success).toBe(true); expect(record.turnCount).toBe(1); // 拆开的 turn_end 仍被解析为 1 次 }); }); // ── 15. stdin prompt 注入 ── // // [RPC prompt 修复] pi runRpcMode 只通过 stdin RpcCommand 驱动——positional task arg // / -p flag 在 rpc mode 下被 resolveAppMode 无视。runSpawn 必须在 spawn 后主动写 // {type:"prompt",message:} 到 child.stdin,否则子进程阻塞、totalTokens 恒 0。 // sendPromptCommand 在 spawn + setEncoding 后同步执行,waitForSpawn 拿到 child 时 // 命令已在 stdin 缓冲。PassThrough.write 无 reader 时缓冲全部数据,可事后读出断言。 describe("stdin prompt 注入", () => { it("spawn 后向 stdin 写一行 {type:prompt} 且 message 含 task 文本", async () => { const record = makeRecord(); const taskText = "Task: hello-prompt-injection"; const promise = runSpawn(record, taskText, makeOpts(), makeCtx()); await waitForSpawn(); const child = lastSpawnedChild(); // sendPromptCommand 已同步执行——PassThrough 缓冲了写入的命令,读出来断言。 // pause() 让 PassThrough 切到暂停模式(默认 flow 模式下数据缓冲在内部), // 然后 read() 取出全部已缓冲内容。 child.stdin.pause(); const buffered = child.stdin.read()?.toString() ?? ""; // 收尾:让 runSpawn resolve(避免悬挂) emitStdoutLine(child, sessionHeader()); child.stdout.end(); child.stderr.end(); child.emit("close", 0); await promise; // 断言:缓冲含合法 JSON,type=prompt,message 含 task 文本 const lines = buffered.trim().split("\n"); expect(lines.length).toBeGreaterThanOrEqual(1); const cmd = JSON.parse(lines[0]!) as { type: string; message: string; id?: string }; expect(cmd.type).toBe("prompt"); expect(cmd.message).toBe(taskText); expect(typeof cmd.id).toBe("string"); }); it("child.stdin.destroyed → sendPromptCommand 不抛错(guard 生效)", async () => { const record = makeRecord(); const promise = runSpawn(record, "Task: destroyed-stdin", makeOpts(), makeCtx()); await waitForSpawn(); const child = lastSpawnedChild(); // destroy stdin 模拟子进程已关闭输入通道;sendPromptCommand 在 spawn 时已执行过一次 //(stdin 当时未 destroyed),这里仅验证后续不抛。此用例主要保护 guard 逻辑—— // 收尾正常 close 即证明无异常抛出中断 runSpawn。 child.stdin.destroy(); emitStdoutLine(child, sessionHeader()); child.stdout.end(); child.stderr.end(); child.emit("close", 0); const result = await promise; expect(result.success).toBe(true); }); }); // 注:C1(orphan 进程兜底)与 M8(stdout 边界)describe 块已移至 run-spawn-edges.test.ts, // 拆分以保持本文件 < 1000 行(pre-commit hook 限制)。两文件各自独立声明文件级 mock。 // ============================================================ // E2E:runSpawn 从主进程 process.argv 镜像 extension/approve flag 到子进程 // 验证完整链路:runSpawn 真实执行 → 读 process.argv → buildSpawnArgs 拼参 → // spawn 收到的 args 含镜像 flag。spawn 被 mock(FakeChild),但 runSpawn 体内逻辑 // 全真实跑,process.argv 用真实进程变量(测试中临时覆写 + 还原)。 // ============================================================ describe("E2E: 镜像主进程 argv flag 到子进程 spawn args", () => { const originalArgv = process.argv; afterEach(() => { process.argv = originalArgv; }); it("主进程 argv 含 --extension/--no-extensions/--approve → 子进程 spawn args 全部镜像", async () => { // 模拟主 pi 进程启动参数(xyz-agent runtime 启动 pi 的真实形态) process.argv = [ "bun", "/path/to/pi", "--mode", "rpc", "--no-extensions", "--approve", "--extension", "/exts/goal", "--extension", "/exts/todo", "--session-dir", "/sessions", ]; const record = makeRecord(); const promise = runSpawn(record, "Task", makeOpts(), makeCtx()); await waitForSpawn(); const child = lastSpawnedChild(); mockSessionFileExists( "/tmp/test/agents/subagents/--tmp-test--/sessions/2026-07-03T12-00-00-000Z_sess-mirror.jsonl", ); emitStdoutLine(child, sessionHeader("sess-mirror")); child.stdout.end(); child.stderr.end(); child.emit("close", 0); await promise; // 断言 spawn 收到的调用参数含全部镜像 flag const spawnCall = mockSpawn.mock.calls[0]; // spawn(command, args, options) → args 是第二个参数 const spawnArgs = spawnCall[1] as string[]; expect(spawnArgs).toContain("--no-extensions"); expect(spawnArgs).toContain("--approve"); // 两个 extension 路径都镜像,顺序保留 const extIdxs = spawnArgs .map((a, i) => (a === "--extension" ? i : -1)) .filter((i) => i >= 0); expect(extIdxs).toHaveLength(2); expect(spawnArgs[extIdxs[0] + 1]).toBe("/exts/goal"); expect(spawnArgs[extIdxs[1] + 1]).toBe("/exts/todo"); }); it("主进程 argv 无目标 flag → 子进程 spawn args 不含镜像 flag(向后兼容)", async () => { // 模拟纯 pi CLI 直接跑(无 extension/approve 配置) process.argv = ["bun", "/path/to/pi", "--mode", "rpc"]; const record = makeRecord(); const promise = runSpawn(record, "Task", makeOpts(), makeCtx()); await waitForSpawn(); const child = lastSpawnedChild(); mockSessionFileExists( "/tmp/test/agents/subagents/--tmp-test--/sessions/2026-07-03T12-00-00-000Zsess-noflag.jsonl", ); emitStdoutLine(child, sessionHeader("sess-noflag")); child.stdout.end(); child.stderr.end(); child.emit("close", 0); await promise; const spawnCall = mockSpawn.mock.calls[0]; const spawnArgs = spawnCall[1] as string[]; expect(spawnArgs).not.toContain("--no-extensions"); expect(spawnArgs).not.toContain("--approve"); expect(spawnArgs).not.toContain("--extension"); }); }); });