@zhushanwen/pi-subagent-workflow 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -0
- package/agents/{scout.md → explorer.md} +1 -1
- package/agents/orchestrator.md +48 -0
- package/package.json +1 -1
- package/src/execution/__tests__/agent-registry.test.ts +3 -3
- package/src/execution/__tests__/ask-user-transit-e2e.test.ts +484 -0
- package/src/execution/__tests__/channel-registry-handshake.test.ts +233 -0
- package/src/execution/__tests__/crash-recovery.test.ts +5 -1
- package/src/execution/__tests__/dialog-queue.test.ts +299 -0
- package/src/execution/__tests__/execute-nesting.test.ts +1 -1
- package/src/execution/__tests__/execute-options-mapper.test.ts +22 -3
- package/src/execution/__tests__/finalize-record.test.ts +173 -0
- package/src/execution/__tests__/gui-mode-dispatch.test.ts +2 -3
- package/src/execution/__tests__/helpers/spawn-mock.ts +209 -0
- package/src/execution/__tests__/host-mode.test.ts +87 -0
- package/src/execution/__tests__/index-session-start.test.ts +342 -0
- package/src/execution/__tests__/list-component.test.ts +1 -1
- package/src/execution/__tests__/notifier-flush.test.ts +78 -0
- package/src/execution/__tests__/path-encoding.test.ts +30 -1
- package/src/execution/__tests__/record-store.test.ts +86 -2
- package/src/execution/__tests__/records-cwd-isolation.test.ts +91 -0
- package/src/execution/__tests__/rpc-mode.test.ts +89 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +157 -153
- package/src/execution/__tests__/run-spawn-integration.test.ts +85 -151
- package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +193 -0
- package/src/execution/__tests__/session-file-gc.test.ts +46 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +7 -1
- package/src/execution/__tests__/spawn-args.test.ts +14 -19
- package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +189 -0
- package/src/execution/__tests__/stdin-writer.test.ts +353 -0
- package/src/execution/__tests__/subagent-service.test.ts +73 -3
- package/src/execution/__tests__/tool-action.test.ts +4 -4
- package/src/execution/__tests__/ui-channels.test.ts +187 -0
- package/src/execution/__tests__/ui-interaction-model.test.ts +67 -0
- package/src/execution/__tests__/ui-request-handler-factory.test.ts +166 -0
- package/src/execution/__tests__/ui-request-handler.test.ts +204 -0
- package/src/execution/__tests__/ui-request-observability.test.ts +101 -0
- package/src/execution/__tests__/ui-request-queue.test.ts +133 -0
- package/src/execution/__tests__/worktree-manager.test.ts +1 -1
- package/src/execution/agent-registry.ts +1 -1
- package/src/execution/channel-registry-access.ts +138 -0
- package/src/execution/dialog-queue.ts +329 -0
- package/src/execution/execute-options-mapper.ts +5 -3
- package/src/execution/finalize-record.ts +160 -0
- package/src/execution/get-state-handshake.ts +104 -0
- package/src/execution/host-mode.ts +52 -0
- package/src/execution/manifest-store.ts +206 -0
- package/src/execution/notifier.ts +5 -1
- package/src/execution/path-encoding.ts +18 -0
- package/src/execution/pi-invocation.ts +1 -1
- package/src/execution/record-store.ts +108 -2
- package/src/execution/session-file-gc.ts +25 -3
- package/src/execution/session-runner.ts +216 -32
- package/src/execution/spawn-event-adapter.ts +219 -6
- package/src/execution/stdin-writer.ts +106 -0
- package/src/execution/subagent-service.ts +167 -197
- package/src/execution/types.ts +6 -6
- package/src/execution/ui-channels.ts +216 -0
- package/src/execution/ui-interaction-model.ts +48 -0
- package/src/execution/ui-request-handler-factory.ts +175 -0
- package/src/execution/ui-request-observability.ts +77 -0
- package/src/execution/ui-request-queue.ts +168 -0
- package/src/index.ts +90 -6
- package/src/interface/__tests__/detectors.test.ts +76 -0
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +27 -4
- package/src/interface/__tests__/workflow-tool-prompt.test.ts +24 -2
- package/src/interface/format.ts +2 -0
- package/src/interface/subagent-actions.ts +21 -11
- package/src/interface/subagent-tool.ts +43 -10
- package/src/interface/tool-workflow.ts +58 -16
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// src/__tests__/rpc-mode.test.ts
|
|
2
|
+
//
|
|
3
|
+
// RPC 模式相关功能
|
|
4
|
+
// 1. buildSpawnArgs 返回 --mode rpc(而非 json)
|
|
5
|
+
// 2. parseSpawnLine 正确解析 Pi 原生 RpcResponse(type:response + command + success + data|error)
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import { buildSpawnArgs } from "../session-runner.ts";
|
|
10
|
+
import { parseSpawnLine } from "../spawn-event-adapter.ts";
|
|
11
|
+
|
|
12
|
+
describe("buildSpawnArgs - RPC mode", () => {
|
|
13
|
+
const baseParams = {
|
|
14
|
+
model: "gpt-4",
|
|
15
|
+
thinkingLevel: undefined,
|
|
16
|
+
agentTools: undefined,
|
|
17
|
+
appendSystemPromptPath: undefined,
|
|
18
|
+
sessionDir: "/tmp/sessions",
|
|
19
|
+
forkSource: undefined,
|
|
20
|
+
skillPaths: undefined,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
it("生成 --mode rpc 参数(非 json)", () => {
|
|
24
|
+
const args = buildSpawnArgs(baseParams);
|
|
25
|
+
// 应包含 --mode rpc
|
|
26
|
+
expect(args).toContain("--mode");
|
|
27
|
+
const modeIdx = args.indexOf("--mode");
|
|
28
|
+
expect(args[modeIdx + 1]).toBe("rpc");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("不包含 --mode json", () => {
|
|
32
|
+
const args = buildSpawnArgs(baseParams);
|
|
33
|
+
const modeIdx = args.indexOf("--mode");
|
|
34
|
+
expect(args[modeIdx + 1]).not.toBe("json");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("parseSpawnLine - RpcResponse", () => {
|
|
39
|
+
// Pi 原生 RpcResponse 格式(rpc-types.ts):{type:"response", id?, command, success, data?, error?}
|
|
40
|
+
// W1 修复后 isRpcResponse 判 type:response + command:string + success:boolean(SR-1 根因 1b)
|
|
41
|
+
|
|
42
|
+
it("解析 Pi 原生 success response(kind=response)", () => {
|
|
43
|
+
const rpcResponse = JSON.stringify({
|
|
44
|
+
type: "response",
|
|
45
|
+
id: "req-123",
|
|
46
|
+
command: "run",
|
|
47
|
+
success: true,
|
|
48
|
+
data: { answers: [{ question: "Q1", answer: "A1" }] },
|
|
49
|
+
});
|
|
50
|
+
const result = parseSpawnLine(rpcResponse);
|
|
51
|
+
expect(result?.kind).toBe("response");
|
|
52
|
+
if (result?.kind === "response") {
|
|
53
|
+
expect(result.id).toBe("req-123");
|
|
54
|
+
expect(result.command).toBe("run");
|
|
55
|
+
expect(result.success).toBe(true);
|
|
56
|
+
expect(result.data).toEqual({ answers: [{ question: "Q1", answer: "A1" }] });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("解析 Pi 原生 error response(kind=response)", () => {
|
|
61
|
+
const rpcError = JSON.stringify({
|
|
62
|
+
type: "response",
|
|
63
|
+
id: "req-456",
|
|
64
|
+
command: "run",
|
|
65
|
+
success: false,
|
|
66
|
+
error: "task failed: timeout",
|
|
67
|
+
});
|
|
68
|
+
const result = parseSpawnLine(rpcError);
|
|
69
|
+
expect(result?.kind).toBe("response");
|
|
70
|
+
if (result?.kind === "response") {
|
|
71
|
+
expect(result.id).toBe("req-456");
|
|
72
|
+
expect(result.success).toBe(false);
|
|
73
|
+
expect(result.error).toBe("task failed: timeout");
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("旧 JSON-RPC 2.0 格式不再归类为 response(W1 废弃)", () => {
|
|
78
|
+
// SR-1: 旧 {jsonrpc:"2.0", id, result} 无 type:response 字段,应落入 invalid
|
|
79
|
+
const oldRpc = JSON.stringify({ jsonrpc: "2.0", id: "req-old", result: { x: 1 } });
|
|
80
|
+
const result = parseSpawnLine(oldRpc);
|
|
81
|
+
expect(result?.kind).not.toBe("response");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("非 response 类型的 JSON 不归类为 response", () => {
|
|
85
|
+
const normalEvent = JSON.stringify({ type: "tool_execution_start", toolName: "bash" });
|
|
86
|
+
const result = parseSpawnLine(normalEvent);
|
|
87
|
+
expect(result?.kind).not.toBe("response");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -6,53 +6,24 @@
|
|
|
6
6
|
// - [C1] orphan 进程兜底:killAllSpawnedChildren 对未退出 child 发 SIGTERM。
|
|
7
7
|
// - [M8] stdout 边界:损坏行(非法 JSON / 缺 type)静默忽略 + 残留尾行(close 前无 \n)
|
|
8
8
|
// 由 close handler 再 parse。
|
|
9
|
+
// - [agent_end] rpc 长驻进程自然完成:agent_end(willRetry=false)→ kill SIGTERM 触发 close。
|
|
9
10
|
//
|
|
10
|
-
// mock
|
|
11
|
-
//
|
|
12
|
-
// 测试用控制器 emit data/close/error 控制时序。
|
|
13
|
-
// - node:child_process.execFileSync → 返回空串(buildEnvBlock 的 git branch 调用避免副作用)。
|
|
14
|
-
// - node:fs 同步方法 → mock(mkdirSync/existsSync/appendFileSync/writeFileSync 等),
|
|
15
|
-
// 避免 sessionDir/sessionFile 触碰真实文件系统。
|
|
16
|
-
// - fs.promises.* → 保留真实实现(temp-prompt 整体被 mock,不触发真实 I/O)。
|
|
17
|
-
// - temp-prompt → mock(writePromptToTempFile 返回固定路径,消除 fake-timers flaky)。
|
|
18
|
-
// - alive-store.writeAliveMarker → mock(避免写 .alive sidecar)。
|
|
19
|
-
|
|
20
|
-
import type { PassThrough } from "node:stream";
|
|
11
|
+
// mock 工厂 + FakeChild + 工具函数共享自 helpers/spawn-mock.ts(详见该文件头注释)。
|
|
12
|
+
// vi.mock 必须各文件独立声明(文件作用域),工厂内用 `await import` 取回 FakeChild。
|
|
21
13
|
|
|
22
|
-
import {
|
|
14
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
15
|
+
import * as fs from "node:fs";
|
|
23
16
|
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
// vitest 会把 vi.mock 提升到文件顶部(早于其他 import / 声明)。mock 工厂若要引用
|
|
27
|
-
// FakeChild,需在工厂内部 import(async 工厂可用 await import),而非引用顶部
|
|
28
|
-
// 顶层 import(它们在 vi.mock 执行时尚未绑定)。
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
29
18
|
|
|
30
19
|
vi.mock("node:child_process", async () => {
|
|
31
|
-
const {
|
|
32
|
-
const { PassThrough } = await import("node:stream");
|
|
33
|
-
|
|
34
|
-
// FakeChild:模拟 ChildProcess(EventEmitter + PassThrough streams)。
|
|
35
|
-
// 测试通过 mockSpawn.mock.results.at(-1).value 取回实例,控制 emit data/close/error 时序。
|
|
36
|
-
class FakeChild extends EventEmitter {
|
|
37
|
-
pid = 12345;
|
|
38
|
-
stdout = new PassThrough();
|
|
39
|
-
stderr = new PassThrough();
|
|
40
|
-
killed = false;
|
|
41
|
-
killSignal: string | undefined;
|
|
42
|
-
kill(sig?: string): boolean {
|
|
43
|
-
this.killed = true;
|
|
44
|
-
this.killSignal = sig;
|
|
45
|
-
return true;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
20
|
+
const { FakeChild } = await import("./helpers/spawn-mock.ts");
|
|
49
21
|
return {
|
|
50
22
|
spawn: vi.fn(() => new FakeChild()),
|
|
51
|
-
execFileSync: vi.fn(() => ""),
|
|
23
|
+
execFileSync: vi.fn(() => ""),
|
|
52
24
|
};
|
|
53
25
|
});
|
|
54
26
|
|
|
55
|
-
// node:fs:同步方法 mock(runSpawn 用到的全部),promises 保留真实实现(temp-prompt 用)。
|
|
56
27
|
vi.mock("node:fs", async () => {
|
|
57
28
|
const actual = await import("node:fs");
|
|
58
29
|
return {
|
|
@@ -64,13 +35,11 @@ vi.mock("node:fs", async () => {
|
|
|
64
35
|
writeFileSync: vi.fn(),
|
|
65
36
|
readdirSync: vi.fn(() => []),
|
|
66
37
|
},
|
|
67
|
-
// 具名导出与 default 保持一致
|
|
68
38
|
mkdirSync: vi.fn(),
|
|
69
39
|
existsSync: vi.fn(() => false),
|
|
70
40
|
appendFileSync: vi.fn(),
|
|
71
41
|
writeFileSync: vi.fn(),
|
|
72
42
|
readdirSync: vi.fn(() => []),
|
|
73
|
-
// promises 保留真实实现——temp-prompt 已被 mock(见下方 vi.mock),不再触发真实 I/O
|
|
74
43
|
promises: actual.promises,
|
|
75
44
|
};
|
|
76
45
|
});
|
|
@@ -79,8 +48,6 @@ vi.mock("../alive-store.ts", () => ({
|
|
|
79
48
|
writeAliveMarker: vi.fn(),
|
|
80
49
|
}));
|
|
81
50
|
|
|
82
|
-
// temp-prompt:mock 掉真实 fs.promises I/O,消除 fake-timers 下的 flaky 竞态
|
|
83
|
-
// (详见 run-spawn-integration.test.ts 同名 mock 的注释)。
|
|
84
51
|
vi.mock("../temp-prompt.ts", () => ({
|
|
85
52
|
writePromptToTempFile: vi.fn(async (agent: string) => {
|
|
86
53
|
const safeName = agent.replace(/[^\w.-]+/g, "_");
|
|
@@ -89,123 +56,25 @@ vi.mock("../temp-prompt.ts", () => ({
|
|
|
89
56
|
cleanupTempPrompt: vi.fn(async () => {}),
|
|
90
57
|
}));
|
|
91
58
|
|
|
92
|
-
import {
|
|
93
|
-
import
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
59
|
+
import { killAllSpawnedChildren, runSpawn, spawnedChildren } from "../session-runner.ts";
|
|
60
|
+
import {
|
|
61
|
+
emitStdoutLine,
|
|
62
|
+
type FakeChild,
|
|
63
|
+
lastSpawnedChild as lastSpawnedChildOf,
|
|
64
|
+
makeCtx,
|
|
65
|
+
makeOpts,
|
|
66
|
+
makeRecord,
|
|
67
|
+
sessionHeader,
|
|
68
|
+
waitForSpawn as waitForSpawnOf,
|
|
69
|
+
} from "./helpers/spawn-mock.ts";
|
|
97
70
|
|
|
98
71
|
const mockSpawn = vi.mocked(spawn);
|
|
99
72
|
const mockExec = vi.mocked(execFileSync);
|
|
100
73
|
const mockExistsSync = vi.mocked(fs.existsSync);
|
|
101
74
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
* 测试代码通过此类型访问 stdout/stderr/kill 等成员。
|
|
106
|
-
*/
|
|
107
|
-
interface FakeChild {
|
|
108
|
-
pid: number;
|
|
109
|
-
stdout: PassThrough;
|
|
110
|
-
stderr: PassThrough;
|
|
111
|
-
killed: boolean;
|
|
112
|
-
killSignal: string | undefined;
|
|
113
|
-
kill(sig?: string): boolean;
|
|
114
|
-
emit(event: string, ...args: unknown[]): boolean;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** 从最近一次 spawn 调用取回返回的 FakeChild(测试控制器)。 */
|
|
118
|
-
function lastSpawnedChild(): FakeChild {
|
|
119
|
-
const result = mockSpawn.mock.results.at(-1);
|
|
120
|
-
if (!result) throw new Error("spawn was not called yet");
|
|
121
|
-
return result.value as FakeChild;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* 等待 runSpawn 内部调到 spawn(拿到 child 控制器)。
|
|
126
|
-
*
|
|
127
|
-
* runSpawn 是 async,spawn 在 mkdirSync + writePromptToTempFile 之后才调(均有微任务/
|
|
128
|
-
* I/O 延迟)。用 setInterval 轮询 mockSpawn.mock.results,比 vi.waitFor 在该 vitest 版本
|
|
129
|
-
* 下更可靠(vi.waitFor 偶发过早 resolve 导致后续读取竞态)。
|
|
130
|
-
*/
|
|
131
|
-
async function waitForSpawn(timeoutMs = 1000): Promise<void> {
|
|
132
|
-
const start = Date.now();
|
|
133
|
-
while (mockSpawn.mock.results.length === 0) {
|
|
134
|
-
if (Date.now() - start > timeoutMs) {
|
|
135
|
-
throw new Error(`spawn was not called within ${timeoutMs}ms`);
|
|
136
|
-
}
|
|
137
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// ============================================================
|
|
142
|
-
// 辅助:向 stdout 写一行(自动补换行,runSpawn 按 \n split 行)
|
|
143
|
-
// ============================================================
|
|
144
|
-
|
|
145
|
-
function emitStdoutLine(child: FakeChild, obj: Record<string, unknown>): void {
|
|
146
|
-
child.stdout.write(`${JSON.stringify(obj)}\n`);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** 构造 session header 行(stdout 首行)。 */
|
|
150
|
-
function sessionHeader(id = "sess-abc"): Record<string, unknown> {
|
|
151
|
-
return {
|
|
152
|
-
type: "session",
|
|
153
|
-
id,
|
|
154
|
-
timestamp: "2026-07-03T12-00-00-000Z",
|
|
155
|
-
cwd: "/tmp/test",
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// ============================================================
|
|
160
|
-
// 辅助:构造最小合法的 record / opts / ctx
|
|
161
|
-
// ============================================================
|
|
162
|
-
|
|
163
|
-
function makeRecord() {
|
|
164
|
-
return createRecord("run-1", {
|
|
165
|
-
agent: "general-purpose",
|
|
166
|
-
model: "test-model",
|
|
167
|
-
mode: "sync",
|
|
168
|
-
task: "do something",
|
|
169
|
-
startedAt: 1_000_000,
|
|
170
|
-
rootSessionId: "root-session",
|
|
171
|
-
parentRecordId: undefined,
|
|
172
|
-
depth: 0,
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function makeOpts(overrides: Partial<RunOptions> = {}): RunOptions {
|
|
177
|
-
return {
|
|
178
|
-
resolved: {
|
|
179
|
-
model: {
|
|
180
|
-
id: "test-model",
|
|
181
|
-
name: "Test Model",
|
|
182
|
-
provider: "test",
|
|
183
|
-
reasoning: false,
|
|
184
|
-
},
|
|
185
|
-
thinkingLevel: undefined,
|
|
186
|
-
},
|
|
187
|
-
agentConfig: undefined,
|
|
188
|
-
appendSystemPrompt: undefined,
|
|
189
|
-
skillPath: undefined,
|
|
190
|
-
schema: undefined,
|
|
191
|
-
maxTurns: undefined,
|
|
192
|
-
graceTurns: undefined,
|
|
193
|
-
signal: undefined,
|
|
194
|
-
onEvent: undefined,
|
|
195
|
-
...overrides,
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function makeCtx(overrides: Partial<SessionRunnerContext> = {}): SessionRunnerContext {
|
|
200
|
-
return {
|
|
201
|
-
cwd: "/tmp/test",
|
|
202
|
-
agentDir: "/tmp/test/agents",
|
|
203
|
-
skillDirs: [],
|
|
204
|
-
mainCwd: "/tmp/test",
|
|
205
|
-
mainSessionFile: undefined,
|
|
206
|
-
...overrides,
|
|
207
|
-
};
|
|
208
|
-
}
|
|
75
|
+
// 绑定到本文件 mockSpawn 的 lastSpawnedChild/waitForSpawn(需读 mockSpawn.mock.results)
|
|
76
|
+
const lastSpawnedChild = (): FakeChild => lastSpawnedChildOf(mockSpawn);
|
|
77
|
+
const waitForSpawn = (timeoutMs = 1000): Promise<void> => waitForSpawnOf(mockSpawn, timeoutMs);
|
|
209
78
|
|
|
210
79
|
// ============================================================
|
|
211
80
|
// 测试
|
|
@@ -326,6 +195,58 @@ describe("runSpawn", () => {
|
|
|
326
195
|
expect(r.success).toBe(true);
|
|
327
196
|
}
|
|
328
197
|
});
|
|
198
|
+
|
|
199
|
+
// [dispose-cleanup Minor 优化2] killAllSpawnedChildren 末尾 clear spawnedChildren Set。
|
|
200
|
+
// 防主进程崩溃/close 事件漏触发时 Set 无限增长。正常路径 close 事件会 delete(保留 per-child
|
|
201
|
+
// 精细清理语义);killAllSpawnedChildren 是 dispose 全量兼底。
|
|
202
|
+
it("killAllSpawnedChildren 后 spawnedChildren Set 被 clear(size===0)", async () => {
|
|
203
|
+
// 前置清理:即他测试可能残留的 child(close 未触发场景)
|
|
204
|
+
killAllSpawnedChildren();
|
|
205
|
+
expect(spawnedChildren.size).toBe(0);
|
|
206
|
+
|
|
207
|
+
// spawn 两个未 close 的 child(模拟 close 事件漏触发的极端累积场景)
|
|
208
|
+
const rec1 = makeRecord();
|
|
209
|
+
const p1 = runSpawn(rec1, "Task: clear-1", makeOpts(), makeCtx());
|
|
210
|
+
await waitForSpawn();
|
|
211
|
+
const c1 = lastSpawnedChild();
|
|
212
|
+
|
|
213
|
+
const beforeCount = mockSpawn.mock.results.length;
|
|
214
|
+
const rec2 = makeRecord();
|
|
215
|
+
const p2 = runSpawn(rec2, "Task: clear-2", makeOpts(), makeCtx());
|
|
216
|
+
const start = Date.now();
|
|
217
|
+
while (mockSpawn.mock.results.length < beforeCount + 1) {
|
|
218
|
+
if (Date.now() - start > 1000) throw new Error("second spawn not called");
|
|
219
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
220
|
+
}
|
|
221
|
+
const c2 = lastSpawnedChild();
|
|
222
|
+
|
|
223
|
+
// 两个 child 都在 Set 中
|
|
224
|
+
expect(spawnedChildren.size).toBe(2);
|
|
225
|
+
|
|
226
|
+
// dispose 兼底:kill + clear
|
|
227
|
+
const n = killAllSpawnedChildren();
|
|
228
|
+
expect(n).toBe(2);
|
|
229
|
+
expect(c1.killed).toBe(true);
|
|
230
|
+
expect(c2.killed).toBe(true);
|
|
231
|
+
// Set 被 clear(兑底防泄漏)
|
|
232
|
+
expect(spawnedChildren.size).toBe(0);
|
|
233
|
+
|
|
234
|
+
// 再次调用:Set 已空,返回 0(不会重复 kill 已 kill 的 child)
|
|
235
|
+
const n2 = killAllSpawnedChildren();
|
|
236
|
+
expect(n2).toBe(0);
|
|
237
|
+
|
|
238
|
+
// 收尾
|
|
239
|
+
for (const { child, promise } of [
|
|
240
|
+
{ child: c1, promise: p1 },
|
|
241
|
+
{ child: c2, promise: p2 },
|
|
242
|
+
]) {
|
|
243
|
+
emitStdoutLine(child, sessionHeader());
|
|
244
|
+
child.stdout.end();
|
|
245
|
+
child.emit("close", 143);
|
|
246
|
+
const r = await promise;
|
|
247
|
+
expect(r.success).toBe(true);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
329
250
|
});
|
|
330
251
|
|
|
331
252
|
// ── 2. stdout 边界:损坏行 + 残留尾行 (M8) ──
|
|
@@ -436,4 +357,87 @@ describe("runSpawn", () => {
|
|
|
436
357
|
expect(record.turnCount).toBe(1); // 3 片拼接后解析为 1 次 turn_end
|
|
437
358
|
});
|
|
438
359
|
});
|
|
360
|
+
|
|
361
|
+
// ── 3. agent_end 自然完成(rpc 长驻进程不自动退出,需主动 kill)──
|
|
362
|
+
//
|
|
363
|
+
// [rpc agent_end] pi --mode rpc 是长驻进程(runRpcMode 末尾 return new Promise(() => {})),
|
|
364
|
+
// 处理完 prompt 后不退出。runSpawn 只靠 child.on("close") 判完成——如果不处理 agent_end,
|
|
365
|
+
// 子进程会卡到 watchdog 30 分钟兜底 kill。修复:收到 agent_end(willRetry=false)后
|
|
366
|
+
// 主动 child.kill("SIGTERM") 让子进程退出,触发 close → runSpawn resolve。
|
|
367
|
+
// willRetry=true 时 agent 会重试,不能 kill。
|
|
368
|
+
describe("agent_end 自然完成", () => {
|
|
369
|
+
it("agent_end(willRetry=false)→ child.kill(SIGTERM) 被调用,close 后 success=true", async () => {
|
|
370
|
+
const record = makeRecord();
|
|
371
|
+
const promise = runSpawn(record, "Task: done", makeOpts(), makeCtx());
|
|
372
|
+
|
|
373
|
+
await waitForSpawn();
|
|
374
|
+
const child = lastSpawnedChild();
|
|
375
|
+
|
|
376
|
+
emitStdoutLine(child, sessionHeader());
|
|
377
|
+
// agent 自然完成(willRetry=false)
|
|
378
|
+
emitStdoutLine(child, { type: "agent_end", messages: [], willRetry: false });
|
|
379
|
+
child.stdout.end();
|
|
380
|
+
child.stderr.end();
|
|
381
|
+
// agent_end 触发 kill(SIGTERM) → 子进程退出(exitCode 143 = 128+15)
|
|
382
|
+
child.emit("close", 143);
|
|
383
|
+
|
|
384
|
+
const result = await promise;
|
|
385
|
+
|
|
386
|
+
expect(child.killed).toBe(true);
|
|
387
|
+
expect(child.killSignal).toBe("SIGTERM");
|
|
388
|
+
// 信号终止(>=128)视为正常完成
|
|
389
|
+
expect(result.success).toBe(true);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("agent_end(willRetry=true)→ child.kill 不被调用(agent 会重试,等下一个 agent_end)", async () => {
|
|
393
|
+
const record = makeRecord();
|
|
394
|
+
const promise = runSpawn(record, "Task: retry", makeOpts(), makeCtx());
|
|
395
|
+
|
|
396
|
+
await waitForSpawn();
|
|
397
|
+
const child = lastSpawnedChild();
|
|
398
|
+
|
|
399
|
+
emitStdoutLine(child, sessionHeader());
|
|
400
|
+
// willRetry=true:agent 会重试,不应 kill
|
|
401
|
+
emitStdoutLine(child, { type: "agent_end", messages: [], willRetry: true });
|
|
402
|
+
// 此时 child.killed 应仍为 false。给一点时间让 event pump 处理完。
|
|
403
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
404
|
+
expect(child.killed).toBe(false);
|
|
405
|
+
|
|
406
|
+
// 收尾:模拟重试后的最终完成(willRetry=false)
|
|
407
|
+
emitStdoutLine(child, { type: "agent_end", messages: [], willRetry: false });
|
|
408
|
+
child.stdout.end();
|
|
409
|
+
child.stderr.end();
|
|
410
|
+
child.emit("close", 143);
|
|
411
|
+
|
|
412
|
+
const result = await promise;
|
|
413
|
+
|
|
414
|
+
// 最终的 agent_end(willRetry=false)触发 kill
|
|
415
|
+
expect(child.killed).toBe(true);
|
|
416
|
+
expect(child.killSignal).toBe("SIGTERM");
|
|
417
|
+
expect(result.success).toBe(true);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("agent_end 后的后续 event 仍被 handleSdkEvent 处理(kill 不阻塞 event pump)", async () => {
|
|
421
|
+
const record = makeRecord();
|
|
422
|
+
const promise = runSpawn(record, "Task: flush", makeOpts(), makeCtx());
|
|
423
|
+
|
|
424
|
+
await waitForSpawn();
|
|
425
|
+
const child = lastSpawnedChild();
|
|
426
|
+
|
|
427
|
+
emitStdoutLine(child, sessionHeader());
|
|
428
|
+
// agent_end kill 是 fire-and-forget(SIGTERM 异步),后续 stdout 行仍被 event pump 处理
|
|
429
|
+
emitStdoutLine(child, { type: "agent_end", messages: [], willRetry: false });
|
|
430
|
+
emitStdoutLine(child, { type: "turn_end" }); // kill 后的 turn_end 仍应被处理
|
|
431
|
+
child.stdout.end();
|
|
432
|
+
child.stderr.end();
|
|
433
|
+
child.emit("close", 143);
|
|
434
|
+
|
|
435
|
+
const result = await promise;
|
|
436
|
+
|
|
437
|
+
expect(child.killed).toBe(true);
|
|
438
|
+
expect(result.success).toBe(true);
|
|
439
|
+
// turn_end 被处理 → turnCount=1
|
|
440
|
+
expect(record.turnCount).toBe(1);
|
|
441
|
+
});
|
|
442
|
+
});
|
|
439
443
|
});
|