@zhushanwen/pi-subagent-workflow 0.1.0
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/agents/context-builder.md +17 -0
- package/agents/general-purpose.md +16 -0
- package/agents/oracle.md +17 -0
- package/agents/planner.md +17 -0
- package/agents/researcher.md +17 -0
- package/agents/reviewer.md +17 -0
- package/agents/scout.md +17 -0
- package/agents/worker.md +16 -0
- package/examples/README.md +43 -0
- package/examples/chain.example.js +92 -0
- package/examples/map-reduce.example.js +99 -0
- package/examples/parallel.example.js +82 -0
- package/examples/scatter-gather.example.js +106 -0
- package/index.ts +1 -0
- package/package.json +66 -0
- package/skills/workflow-script-format/SKILL.md +328 -0
- package/src/execution/__tests__/agent-registry.test.ts +164 -0
- package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
- package/src/execution/__tests__/alive-store.test.ts +147 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
- package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
- package/src/execution/__tests__/config.test.ts +110 -0
- package/src/execution/__tests__/crash-recovery.test.ts +311 -0
- package/src/execution/__tests__/execute-nesting.test.ts +359 -0
- package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
- package/src/execution/__tests__/execution-record.test.ts +959 -0
- package/src/execution/__tests__/finalized-marker.test.ts +82 -0
- package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
- package/src/execution/__tests__/format.test.ts +320 -0
- package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
- package/src/execution/__tests__/list-component.test.ts +347 -0
- package/src/execution/__tests__/model-resolver.test.ts +356 -0
- package/src/execution/__tests__/output-collector.test.ts +61 -0
- package/src/execution/__tests__/path-encoding.test.ts +75 -0
- package/src/execution/__tests__/pi-invocation.test.ts +73 -0
- package/src/execution/__tests__/record-store.test.ts +545 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
- package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
- package/src/execution/__tests__/sdk-contract.test.ts +272 -0
- package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
- package/src/execution/__tests__/session-file-gc.test.ts +247 -0
- package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
- package/src/execution/__tests__/spawn-args.test.ts +244 -0
- package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
- package/src/execution/__tests__/subagent-service.test.ts +678 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
- package/src/execution/__tests__/temp-prompt.test.ts +53 -0
- package/src/execution/__tests__/timeout-integration.test.ts +381 -0
- package/src/execution/__tests__/tombstone-store.test.ts +73 -0
- package/src/execution/__tests__/tool-action.test.ts +330 -0
- package/src/execution/__tests__/turn-limiter.test.ts +65 -0
- package/src/execution/__tests__/worktree-manager.test.ts +423 -0
- package/src/execution/__tests__/worktree-registry.test.ts +161 -0
- package/src/execution/agent-registry.ts +252 -0
- package/src/execution/agent-result-mapper.ts +84 -0
- package/src/execution/alive-store.ts +92 -0
- package/src/execution/best-effort.ts +30 -0
- package/src/execution/concurrency-pool.ts +84 -0
- package/src/execution/config.ts +73 -0
- package/src/execution/execute-options-mapper.ts +86 -0
- package/src/execution/execution-record.ts +778 -0
- package/src/execution/finalized-marker.ts +51 -0
- package/src/execution/model-config-service.ts +225 -0
- package/src/execution/model-resolver.ts +247 -0
- package/src/execution/notifier.ts +168 -0
- package/src/execution/output-collector.ts +88 -0
- package/src/execution/path-encoding.ts +34 -0
- package/src/execution/pi-invocation.ts +70 -0
- package/src/execution/record-store.ts +350 -0
- package/src/execution/session-context-resolver.ts +64 -0
- package/src/execution/session-file-gc.ts +98 -0
- package/src/execution/session-reconstructor.ts +450 -0
- package/src/execution/session-runner.ts +725 -0
- package/src/execution/spawn-event-adapter.ts +150 -0
- package/src/execution/subagent-service.ts +973 -0
- package/src/execution/subprocess-agent-runner.ts +108 -0
- package/src/execution/temp-prompt.ts +57 -0
- package/src/execution/tombstone-store.ts +72 -0
- package/src/execution/turn-limiter.ts +88 -0
- package/src/execution/types.ts +634 -0
- package/src/execution/worktree-manager.ts +285 -0
- package/src/execution/worktree-registry.ts +144 -0
- package/src/index.ts +454 -0
- package/src/interface/bg-notify-render.ts +286 -0
- package/src/interface/commands.ts +157 -0
- package/src/interface/format.ts +501 -0
- package/src/interface/gui-adapter.ts +136 -0
- package/src/interface/helpers.ts +110 -0
- package/src/interface/list-component.ts +643 -0
- package/src/interface/list-shared.ts +84 -0
- package/src/interface/list-view.ts +373 -0
- package/src/interface/reentry-guard.ts +30 -0
- package/src/interface/subagent-actions.ts +294 -0
- package/src/interface/subagent-tool.ts +294 -0
- package/src/interface/subagents.ts +30 -0
- package/src/interface/tool-render.ts +333 -0
- package/src/interface/tool-workflow-script.ts +351 -0
- package/src/interface/tool-workflow.ts +485 -0
- package/src/interface/views/WorkflowsView.ts +944 -0
- package/src/interface/views/detail-content.ts +298 -0
- package/src/interface/views/format.ts +320 -0
- package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
- package/src/orchestration/__tests__/config-loader.test.ts +381 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
- package/src/orchestration/__tests__/script-lint.test.ts +347 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
- package/src/orchestration/agent-opts-resolver.ts +128 -0
- package/src/orchestration/concurrency-gate.ts +69 -0
- package/src/orchestration/config-loader.ts +313 -0
- package/src/orchestration/error-recovery.ts +578 -0
- package/src/orchestration/execute-agent-call.ts +174 -0
- package/src/orchestration/jsonl-run-store.ts +292 -0
- package/src/orchestration/launcher.ts +368 -0
- package/src/orchestration/lifecycle.ts +373 -0
- package/src/orchestration/models/__tests__/budget.test.ts +367 -0
- package/src/orchestration/models/agent-call.ts +76 -0
- package/src/orchestration/models/budget.ts +148 -0
- package/src/orchestration/models/ports.ts +165 -0
- package/src/orchestration/models/run-runtime.ts +91 -0
- package/src/orchestration/models/run-spec.ts +54 -0
- package/src/orchestration/models/run-state.ts +44 -0
- package/src/orchestration/models/trace.ts +102 -0
- package/src/orchestration/models/types.ts +242 -0
- package/src/orchestration/models/workflow-run.ts +275 -0
- package/src/orchestration/models/workflow-script-registry.ts +32 -0
- package/src/orchestration/models/workflow-script.ts +90 -0
- package/src/orchestration/node-ops.ts +192 -0
- package/src/orchestration/script-lint.ts +387 -0
- package/src/orchestration/skill-discovery.ts +60 -0
- package/src/orchestration/worker-handle.ts +115 -0
- package/src/orchestration/worker-host.ts +93 -0
- package/src/orchestration/worker-script-builder.ts +281 -0
- package/src/orchestration/workflow-files.ts +85 -0
- package/src/orchestration/workflow-script-registry-impl.ts +128 -0
- package/src/shared/__tests__/resource-discovery.test.ts +226 -0
- package/src/shared/agent-event.ts +13 -0
- package/src/shared/resource-discovery.ts +535 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
// src/__tests__/run-spawn-edges.test.ts
|
|
2
|
+
//
|
|
3
|
+
// runSpawn 的 stdout 边界与 orphan 进程兜底集成测试(从 run-spawn-integration.test.ts 拆出)。
|
|
4
|
+
//
|
|
5
|
+
// 本文件覆盖:
|
|
6
|
+
// - [C1] orphan 进程兜底:killAllSpawnedChildren 对未退出 child 发 SIGTERM。
|
|
7
|
+
// - [M8] stdout 边界:损坏行(非法 JSON / 缺 type)静默忽略 + 残留尾行(close 前无 \n)
|
|
8
|
+
// 由 close handler 再 parse。
|
|
9
|
+
//
|
|
10
|
+
// mock 策略(与 run-spawn-integration.test.ts 一致,vi.mock 是文件作用域,每文件需独立声明):
|
|
11
|
+
// - node:child_process.spawn → 返回 FakeChild(EventEmitter + PassThrough),
|
|
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";
|
|
21
|
+
|
|
22
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
23
|
+
|
|
24
|
+
// ── mock modules ──
|
|
25
|
+
//
|
|
26
|
+
// vitest 会把 vi.mock 提升到文件顶部(早于其他 import / 声明)。mock 工厂若要引用
|
|
27
|
+
// FakeChild,需在工厂内部 import(async 工厂可用 await import),而非引用顶部
|
|
28
|
+
// 顶层 import(它们在 vi.mock 执行时尚未绑定)。
|
|
29
|
+
|
|
30
|
+
vi.mock("node:child_process", async () => {
|
|
31
|
+
const { EventEmitter } = await import("node:events");
|
|
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
|
+
|
|
49
|
+
return {
|
|
50
|
+
spawn: vi.fn(() => new FakeChild()),
|
|
51
|
+
execFileSync: vi.fn(() => ""), // buildEnvBlock 的 git branch 调用,返回空避免副作用
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// node:fs:同步方法 mock(runSpawn 用到的全部),promises 保留真实实现(temp-prompt 用)。
|
|
56
|
+
vi.mock("node:fs", async () => {
|
|
57
|
+
const actual = await import("node:fs");
|
|
58
|
+
return {
|
|
59
|
+
default: {
|
|
60
|
+
...actual,
|
|
61
|
+
mkdirSync: vi.fn(),
|
|
62
|
+
existsSync: vi.fn(() => false),
|
|
63
|
+
appendFileSync: vi.fn(),
|
|
64
|
+
writeFileSync: vi.fn(),
|
|
65
|
+
readdirSync: vi.fn(() => []),
|
|
66
|
+
},
|
|
67
|
+
// 具名导出与 default 保持一致
|
|
68
|
+
mkdirSync: vi.fn(),
|
|
69
|
+
existsSync: vi.fn(() => false),
|
|
70
|
+
appendFileSync: vi.fn(),
|
|
71
|
+
writeFileSync: vi.fn(),
|
|
72
|
+
readdirSync: vi.fn(() => []),
|
|
73
|
+
// promises 保留真实实现——temp-prompt 已被 mock(见下方 vi.mock),不再触发真实 I/O
|
|
74
|
+
promises: actual.promises,
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
vi.mock("../alive-store.ts", () => ({
|
|
79
|
+
writeAliveMarker: vi.fn(),
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
// temp-prompt:mock 掉真实 fs.promises I/O,消除 fake-timers 下的 flaky 竞态
|
|
83
|
+
// (详见 run-spawn-integration.test.ts 同名 mock 的注释)。
|
|
84
|
+
vi.mock("../temp-prompt.ts", () => ({
|
|
85
|
+
writePromptToTempFile: vi.fn(async (agent: string) => {
|
|
86
|
+
const safeName = agent.replace(/[^\w.-]+/g, "_");
|
|
87
|
+
return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt-${safeName}.md` };
|
|
88
|
+
}),
|
|
89
|
+
cleanupTempPrompt: vi.fn(async () => {}),
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
93
|
+
import * as fs from "node:fs";
|
|
94
|
+
|
|
95
|
+
import { createRecord } from "../execution-record.ts";
|
|
96
|
+
import { killAllSpawnedChildren, type RunOptions, runSpawn, type SessionRunnerContext } from "../session-runner.ts";
|
|
97
|
+
|
|
98
|
+
const mockSpawn = vi.mocked(spawn);
|
|
99
|
+
const mockExec = vi.mocked(execFileSync);
|
|
100
|
+
const mockExistsSync = vi.mocked(fs.existsSync);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* spawn mock 返回的 fake child 类型。
|
|
104
|
+
* 由于 FakeChild 定义在 vi.mock 工厂内部(作用域隔离),此处用结构子集类型描述,
|
|
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
|
+
}
|
|
209
|
+
|
|
210
|
+
// ============================================================
|
|
211
|
+
// 测试
|
|
212
|
+
// ============================================================
|
|
213
|
+
|
|
214
|
+
describe("runSpawn", () => {
|
|
215
|
+
beforeEach(() => {
|
|
216
|
+
vi.clearAllMocks();
|
|
217
|
+
// execFileSync 默认返回空串(git branch 兜底)
|
|
218
|
+
mockExec.mockReturnValue("");
|
|
219
|
+
// existsSync 默认 false(sessionFile 不存在兜底路径)
|
|
220
|
+
mockExistsSync.mockReturnValue(false);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
afterEach(() => {
|
|
224
|
+
vi.restoreAllMocks();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── 1. orphan 进程兜底(C1)──
|
|
228
|
+
//
|
|
229
|
+
// [C1] runSpawn 把每个 spawned child(sync + background)注册到模块级 spawnedChildren Set。
|
|
230
|
+
// SubagentService.dispose 调 killAllSpawnedChildren 遍历该 Set 对仍存活的子进程发 SIGTERM,
|
|
231
|
+
// 覆盖 sync 子进程(controller=undefined,abortRunningControllers 跳过它)。
|
|
232
|
+
//
|
|
233
|
+
// 关键验证:
|
|
234
|
+
// 1. child 退出(close/error)后从 Set 移除 → killAllSpawnedChildren 不重复 kill。
|
|
235
|
+
// 2. child 未退出时 killAllSpawnedChildren → child.kill("SIGTERM") 被调。
|
|
236
|
+
// 3. 已 kill 的 child 二次调用无害(killAllSpawnedChildren 跳过 killed=true 的)。
|
|
237
|
+
describe("orphan 进程兜底 (C1)", () => {
|
|
238
|
+
it("未退出的 child → killAllSpawnedChildren 对它发 SIGTERM", async () => {
|
|
239
|
+
const record = makeRecord();
|
|
240
|
+
// 不 await——runSpawn 内部 await 子进程 close,killAllSpawnedChildren 测试在 close 前
|
|
241
|
+
const promise = runSpawn(record, "Task: orphan", makeOpts(), makeCtx());
|
|
242
|
+
|
|
243
|
+
await waitForSpawn();
|
|
244
|
+
const child = lastSpawnedChild();
|
|
245
|
+
|
|
246
|
+
// spawn 后 child.killed 应为 false(尚未被 kill)
|
|
247
|
+
expect(child.killed).toBe(false);
|
|
248
|
+
|
|
249
|
+
// dispose 兜底:killAllSpawnedChildren 应 kill 未退出的 child
|
|
250
|
+
const n = killAllSpawnedChildren();
|
|
251
|
+
expect(n).toBeGreaterThanOrEqual(1);
|
|
252
|
+
expect(child.killed).toBe(true);
|
|
253
|
+
expect(child.killSignal).toBe("SIGTERM");
|
|
254
|
+
|
|
255
|
+
// 收尾:emit close 让 runSpawn resolve(避免悬挂)
|
|
256
|
+
emitStdoutLine(child, sessionHeader());
|
|
257
|
+
child.stdout.end();
|
|
258
|
+
child.emit("close", 143);
|
|
259
|
+
|
|
260
|
+
const result = await promise;
|
|
261
|
+
expect(result.success).toBe(true); // 信号终止视为正常完成
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("已 close 的 child → 从 Set 移除,killAllSpawnedChildren 不重复 kill", async () => {
|
|
265
|
+
const record = makeRecord();
|
|
266
|
+
const promise = runSpawn(record, "Task: closed", makeOpts(), makeCtx());
|
|
267
|
+
|
|
268
|
+
await waitForSpawn();
|
|
269
|
+
const child = lastSpawnedChild();
|
|
270
|
+
|
|
271
|
+
// 子进程正常退出
|
|
272
|
+
emitStdoutLine(child, sessionHeader());
|
|
273
|
+
child.stdout.end();
|
|
274
|
+
child.emit("close", 0);
|
|
275
|
+
|
|
276
|
+
await promise;
|
|
277
|
+
expect(child.killed).toBe(false); // 正常退出未触发 kill
|
|
278
|
+
|
|
279
|
+
// close 后 child 已从 Set 移除;再调 killAllSpawnedChildren 不会 kill 它
|
|
280
|
+
const n = killAllSpawnedChildren();
|
|
281
|
+
expect(n).toBe(0);
|
|
282
|
+
expect(child.killed).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("多个未退出 child(sync + bg)→ killAllSpawnedChildren 全部 kill", async () => {
|
|
286
|
+
// spawn 两个 child(模拟 sync + background 并发),都未退出。
|
|
287
|
+
// 注意:waitForSpawn 等待 results.length===0→非0 转换,只适用于首次 spawn。
|
|
288
|
+
// 第二次 spawn 需等待 results.length 递增到 2,否则 lastSpawnedChild 取回的是 c1。
|
|
289
|
+
const beforeCount = mockSpawn.mock.results.length;
|
|
290
|
+
|
|
291
|
+
const rec1 = makeRecord();
|
|
292
|
+
const p1 = runSpawn(rec1, "Task: c1", makeOpts(), makeCtx());
|
|
293
|
+
await waitForSpawn();
|
|
294
|
+
const c1 = lastSpawnedChild();
|
|
295
|
+
|
|
296
|
+
const rec2 = makeRecord();
|
|
297
|
+
const p2 = runSpawn(rec2, "Task: c2", makeOpts(), makeCtx());
|
|
298
|
+
// 等待第二次 spawn:results.length 从 beforeCount+1 涨到 beforeCount+2
|
|
299
|
+
const start = Date.now();
|
|
300
|
+
while (mockSpawn.mock.results.length < beforeCount + 2) {
|
|
301
|
+
if (Date.now() - start > 1000) throw new Error("second spawn not called");
|
|
302
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
303
|
+
}
|
|
304
|
+
const c2 = lastSpawnedChild();
|
|
305
|
+
|
|
306
|
+
// c1 和 c2 是不同实例
|
|
307
|
+
expect(c1).not.toBe(c2);
|
|
308
|
+
expect(c1.killed).toBe(false);
|
|
309
|
+
expect(c2.killed).toBe(false);
|
|
310
|
+
|
|
311
|
+
// dispose 兜底 kill 两个
|
|
312
|
+
const n = killAllSpawnedChildren();
|
|
313
|
+
expect(n).toBeGreaterThanOrEqual(2);
|
|
314
|
+
expect(c1.killed).toBe(true);
|
|
315
|
+
expect(c2.killed).toBe(true);
|
|
316
|
+
|
|
317
|
+
// 收尾
|
|
318
|
+
for (const { child, promise } of [
|
|
319
|
+
{ child: c1, promise: p1 },
|
|
320
|
+
{ child: c2, promise: p2 },
|
|
321
|
+
]) {
|
|
322
|
+
emitStdoutLine(child, sessionHeader());
|
|
323
|
+
child.stdout.end();
|
|
324
|
+
child.emit("close", 143);
|
|
325
|
+
const r = await promise;
|
|
326
|
+
expect(r.success).toBe(true);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ── 2. stdout 边界:损坏行 + 残留尾行 (M8) ──
|
|
332
|
+
//
|
|
333
|
+
// [M8] runSpawn 的 stdout 解析容错:
|
|
334
|
+
// - parseSpawnLine 对「非法 JSON」「合法 JSON 但缺 type 字段」归为 kind:"invalid"。
|
|
335
|
+
// - runSpawn 的 data 处理器只认 header/event 两类,invalid 行静默忽略(L559 注释
|
|
336
|
+
// "invalid 行忽略")——单行损坏不应中断整个事件流。
|
|
337
|
+
// - close 前 stdoutBuffer 若残留未以 \n 结尾的合法 event 行,close handler 会再 parse
|
|
338
|
+
// 一次(L574-579)——覆盖子进程末行漏 \n 的场景。
|
|
339
|
+
describe("stdout 边界:损坏行 + 残留尾行 (M8)", () => {
|
|
340
|
+
it("stdout 夹杂非法 JSON 行 → 该行被忽略,合法 turn_end 正常计数(不抛错)", async () => {
|
|
341
|
+
const record = makeRecord();
|
|
342
|
+
const promise = runSpawn(record, "Task: garbage", makeOpts(), makeCtx());
|
|
343
|
+
|
|
344
|
+
// 等待 spawn 被调用拿到 child
|
|
345
|
+
await waitForSpawn();
|
|
346
|
+
const child = lastSpawnedChild();
|
|
347
|
+
|
|
348
|
+
emitStdoutLine(child, sessionHeader());
|
|
349
|
+
// 非法 JSON 行(如 pi 的调试输出 / 进度条残片)—— parseSpawnLine 归为 invalid
|
|
350
|
+
child.stdout.write("this is not json\n");
|
|
351
|
+
// 合法 turn_end 事件
|
|
352
|
+
emitStdoutLine(child, { type: "turn_end" });
|
|
353
|
+
child.stdout.end();
|
|
354
|
+
child.emit("close", 0);
|
|
355
|
+
|
|
356
|
+
const result = await promise;
|
|
357
|
+
|
|
358
|
+
expect(result.success).toBe(true);
|
|
359
|
+
expect(record.turnCount).toBe(1); // 非法行被忽略,仅 turn_end 计数
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("stdout 夹杂合法 JSON 但缺 type 字段 → 该行被忽略,不抛错", async () => {
|
|
363
|
+
const record = makeRecord();
|
|
364
|
+
const promise = runSpawn(record, "Task: notype", makeOpts(), makeCtx());
|
|
365
|
+
|
|
366
|
+
// 等待 spawn 被调用拿到 child
|
|
367
|
+
await waitForSpawn();
|
|
368
|
+
const child = lastSpawnedChild();
|
|
369
|
+
|
|
370
|
+
emitStdoutLine(child, sessionHeader());
|
|
371
|
+
// 合法 JSON 但无 type 字段 —— parseSpawnLine 归为 invalid("missing string 'type'")
|
|
372
|
+
child.stdout.write('{"foo":"bar"}\n');
|
|
373
|
+
emitStdoutLine(child, { type: "turn_end" });
|
|
374
|
+
child.stdout.end();
|
|
375
|
+
child.emit("close", 0);
|
|
376
|
+
|
|
377
|
+
const result = await promise;
|
|
378
|
+
|
|
379
|
+
expect(result.success).toBe(true);
|
|
380
|
+
expect(record.turnCount).toBe(1); // 无 type 行被忽略
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("残留尾行(close 前未以 \\n 结尾的合法 event)→ close handler 再 parse 处理", async () => {
|
|
384
|
+
// 覆盖 session-runner.ts L574-579:close 前 stdoutBuffer 残留的合法 event 行。
|
|
385
|
+
//
|
|
386
|
+
// 关键:不能用 emitStdoutLine(它会补 \n,残留行在 data 处理器就被 split 消费了,
|
|
387
|
+
// 走不到 close handler 的残留 parse 分支)。需同步 emit data(无 \n)确保该行
|
|
388
|
+
// 残留在 stdoutBuffer 直到 close handler 处理。
|
|
389
|
+
//
|
|
390
|
+
// 同步 emit 的必要性:PassThrough 的 .write() 会把 data flush 排到后续微任务,
|
|
391
|
+
// 若先 .write() 再 emit("close"),close listener 同步执行时 stdoutBuffer 仍为空
|
|
392
|
+
// → 残留逻辑被跳过 → turnCount=0(测出真实 bug 风险)。直接 emit("data", ...) 同步
|
|
393
|
+
// 触发 data 处理器,使行残留在 buffer(split("\n") 无换行 → pop 回 buffer),close
|
|
394
|
+
// handler 才能捕到它。
|
|
395
|
+
const record = makeRecord();
|
|
396
|
+
const promise = runSpawn(record, "Task: tail", makeOpts(), makeCtx());
|
|
397
|
+
|
|
398
|
+
// 等待 spawn 被调用拿到 child
|
|
399
|
+
await waitForSpawn();
|
|
400
|
+
const child = lastSpawnedChild();
|
|
401
|
+
|
|
402
|
+
emitStdoutLine(child, sessionHeader());
|
|
403
|
+
// 合法 turn_end 但不带末尾 \n:同步 emit(绕过 write 的异步 flush)。
|
|
404
|
+
// data 处理器把它整体留在 stdoutBuffer(无 \n → split 后 pop 回 buffer),
|
|
405
|
+
// 由 close handler 的残留 parse 逻辑(L574-579)处理。
|
|
406
|
+
child.stdout.emit("data", JSON.stringify({ type: "turn_end" }));
|
|
407
|
+
child.emit("close", 0);
|
|
408
|
+
|
|
409
|
+
const result = await promise;
|
|
410
|
+
|
|
411
|
+
expect(result.success).toBe(true);
|
|
412
|
+
expect(record.turnCount).toBe(1); // 残留尾行被 close handler 正确解析
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("同一 JSON 行跨 3 次 data 事件分片 → stdoutBuffer 字符串拼接后正确解析", async () => {
|
|
416
|
+
// 覆盖 stdoutBuffer += data 的字符串拼接(setEncoding("utf8") 后 data 收到 string,
|
|
417
|
+
// 非 Buffer)。拆成 3 片写入(跨 type 字段名边界 + 跨 turn_end 值边界),验证拼接无误。
|
|
418
|
+
// .write() 的异步 flush 在 await promise(resolve 排在 data 微任务之后)前完成。
|
|
419
|
+
const record = makeRecord();
|
|
420
|
+
const promise = runSpawn(record, "Task: split3", makeOpts(), makeCtx());
|
|
421
|
+
|
|
422
|
+
// 等待 spawn 被调用拿到 child
|
|
423
|
+
await waitForSpawn();
|
|
424
|
+
const child = lastSpawnedChild();
|
|
425
|
+
|
|
426
|
+
emitStdoutLine(child, sessionHeader());
|
|
427
|
+
child.stdout.write('{"typ');
|
|
428
|
+
child.stdout.write('e":"turn_en');
|
|
429
|
+
child.stdout.write('d"}\n');
|
|
430
|
+
child.stdout.end();
|
|
431
|
+
child.emit("close", 0);
|
|
432
|
+
|
|
433
|
+
const result = await promise;
|
|
434
|
+
|
|
435
|
+
expect(result.success).toBe(true);
|
|
436
|
+
expect(record.turnCount).toBe(1); // 3 片拼接后解析为 1 次 turn_end
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
});
|