@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.
Files changed (143) hide show
  1. package/agents/context-builder.md +17 -0
  2. package/agents/general-purpose.md +16 -0
  3. package/agents/oracle.md +17 -0
  4. package/agents/planner.md +17 -0
  5. package/agents/researcher.md +17 -0
  6. package/agents/reviewer.md +17 -0
  7. package/agents/scout.md +17 -0
  8. package/agents/worker.md +16 -0
  9. package/examples/README.md +43 -0
  10. package/examples/chain.example.js +92 -0
  11. package/examples/map-reduce.example.js +99 -0
  12. package/examples/parallel.example.js +82 -0
  13. package/examples/scatter-gather.example.js +106 -0
  14. package/index.ts +1 -0
  15. package/package.json +66 -0
  16. package/skills/workflow-script-format/SKILL.md +328 -0
  17. package/src/execution/__tests__/agent-registry.test.ts +164 -0
  18. package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
  19. package/src/execution/__tests__/alive-store.test.ts +147 -0
  20. package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
  21. package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
  22. package/src/execution/__tests__/config.test.ts +110 -0
  23. package/src/execution/__tests__/crash-recovery.test.ts +311 -0
  24. package/src/execution/__tests__/execute-nesting.test.ts +359 -0
  25. package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
  26. package/src/execution/__tests__/execution-record.test.ts +959 -0
  27. package/src/execution/__tests__/finalized-marker.test.ts +82 -0
  28. package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
  29. package/src/execution/__tests__/format.test.ts +320 -0
  30. package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
  31. package/src/execution/__tests__/list-component.test.ts +347 -0
  32. package/src/execution/__tests__/model-resolver.test.ts +356 -0
  33. package/src/execution/__tests__/output-collector.test.ts +61 -0
  34. package/src/execution/__tests__/path-encoding.test.ts +75 -0
  35. package/src/execution/__tests__/pi-invocation.test.ts +73 -0
  36. package/src/execution/__tests__/record-store.test.ts +545 -0
  37. package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
  38. package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
  39. package/src/execution/__tests__/sdk-contract.test.ts +272 -0
  40. package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
  41. package/src/execution/__tests__/session-file-gc.test.ts +247 -0
  42. package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
  43. package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
  44. package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
  45. package/src/execution/__tests__/spawn-args.test.ts +244 -0
  46. package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
  47. package/src/execution/__tests__/subagent-service.test.ts +678 -0
  48. package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
  49. package/src/execution/__tests__/temp-prompt.test.ts +53 -0
  50. package/src/execution/__tests__/timeout-integration.test.ts +381 -0
  51. package/src/execution/__tests__/tombstone-store.test.ts +73 -0
  52. package/src/execution/__tests__/tool-action.test.ts +330 -0
  53. package/src/execution/__tests__/turn-limiter.test.ts +65 -0
  54. package/src/execution/__tests__/worktree-manager.test.ts +423 -0
  55. package/src/execution/__tests__/worktree-registry.test.ts +161 -0
  56. package/src/execution/agent-registry.ts +252 -0
  57. package/src/execution/agent-result-mapper.ts +84 -0
  58. package/src/execution/alive-store.ts +92 -0
  59. package/src/execution/best-effort.ts +30 -0
  60. package/src/execution/concurrency-pool.ts +84 -0
  61. package/src/execution/config.ts +73 -0
  62. package/src/execution/execute-options-mapper.ts +86 -0
  63. package/src/execution/execution-record.ts +778 -0
  64. package/src/execution/finalized-marker.ts +51 -0
  65. package/src/execution/model-config-service.ts +225 -0
  66. package/src/execution/model-resolver.ts +247 -0
  67. package/src/execution/notifier.ts +168 -0
  68. package/src/execution/output-collector.ts +88 -0
  69. package/src/execution/path-encoding.ts +34 -0
  70. package/src/execution/pi-invocation.ts +70 -0
  71. package/src/execution/record-store.ts +350 -0
  72. package/src/execution/session-context-resolver.ts +64 -0
  73. package/src/execution/session-file-gc.ts +98 -0
  74. package/src/execution/session-reconstructor.ts +450 -0
  75. package/src/execution/session-runner.ts +725 -0
  76. package/src/execution/spawn-event-adapter.ts +150 -0
  77. package/src/execution/subagent-service.ts +973 -0
  78. package/src/execution/subprocess-agent-runner.ts +108 -0
  79. package/src/execution/temp-prompt.ts +57 -0
  80. package/src/execution/tombstone-store.ts +72 -0
  81. package/src/execution/turn-limiter.ts +88 -0
  82. package/src/execution/types.ts +634 -0
  83. package/src/execution/worktree-manager.ts +285 -0
  84. package/src/execution/worktree-registry.ts +144 -0
  85. package/src/index.ts +454 -0
  86. package/src/interface/bg-notify-render.ts +286 -0
  87. package/src/interface/commands.ts +157 -0
  88. package/src/interface/format.ts +501 -0
  89. package/src/interface/gui-adapter.ts +136 -0
  90. package/src/interface/helpers.ts +110 -0
  91. package/src/interface/list-component.ts +643 -0
  92. package/src/interface/list-shared.ts +84 -0
  93. package/src/interface/list-view.ts +373 -0
  94. package/src/interface/reentry-guard.ts +30 -0
  95. package/src/interface/subagent-actions.ts +294 -0
  96. package/src/interface/subagent-tool.ts +294 -0
  97. package/src/interface/subagents.ts +30 -0
  98. package/src/interface/tool-render.ts +333 -0
  99. package/src/interface/tool-workflow-script.ts +351 -0
  100. package/src/interface/tool-workflow.ts +485 -0
  101. package/src/interface/views/WorkflowsView.ts +944 -0
  102. package/src/interface/views/detail-content.ts +298 -0
  103. package/src/interface/views/format.ts +320 -0
  104. package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
  105. package/src/orchestration/__tests__/config-loader.test.ts +381 -0
  106. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
  107. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
  108. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
  109. package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
  110. package/src/orchestration/__tests__/script-lint.test.ts +347 -0
  111. package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
  112. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
  113. package/src/orchestration/agent-opts-resolver.ts +128 -0
  114. package/src/orchestration/concurrency-gate.ts +69 -0
  115. package/src/orchestration/config-loader.ts +313 -0
  116. package/src/orchestration/error-recovery.ts +578 -0
  117. package/src/orchestration/execute-agent-call.ts +174 -0
  118. package/src/orchestration/jsonl-run-store.ts +292 -0
  119. package/src/orchestration/launcher.ts +368 -0
  120. package/src/orchestration/lifecycle.ts +373 -0
  121. package/src/orchestration/models/__tests__/budget.test.ts +367 -0
  122. package/src/orchestration/models/agent-call.ts +76 -0
  123. package/src/orchestration/models/budget.ts +148 -0
  124. package/src/orchestration/models/ports.ts +165 -0
  125. package/src/orchestration/models/run-runtime.ts +91 -0
  126. package/src/orchestration/models/run-spec.ts +54 -0
  127. package/src/orchestration/models/run-state.ts +44 -0
  128. package/src/orchestration/models/trace.ts +102 -0
  129. package/src/orchestration/models/types.ts +242 -0
  130. package/src/orchestration/models/workflow-run.ts +275 -0
  131. package/src/orchestration/models/workflow-script-registry.ts +32 -0
  132. package/src/orchestration/models/workflow-script.ts +90 -0
  133. package/src/orchestration/node-ops.ts +192 -0
  134. package/src/orchestration/script-lint.ts +387 -0
  135. package/src/orchestration/skill-discovery.ts +60 -0
  136. package/src/orchestration/worker-handle.ts +115 -0
  137. package/src/orchestration/worker-host.ts +93 -0
  138. package/src/orchestration/worker-script-builder.ts +281 -0
  139. package/src/orchestration/workflow-files.ts +85 -0
  140. package/src/orchestration/workflow-script-registry-impl.ts +128 -0
  141. package/src/shared/__tests__/resource-discovery.test.ts +226 -0
  142. package/src/shared/agent-event.ts +13 -0
  143. package/src/shared/resource-discovery.ts +535 -0
@@ -0,0 +1,381 @@
1
+ // src/__tests__/timeout-integration.test.ts
2
+ //
3
+ // timeoutMs / signal abort → child.kill 端到端路径测试。
4
+ //
5
+ // 背景:session-runner.ts 无独立 timeoutMs 字段——超时机制是 watchdog(基于
6
+ // computeWatchdogMs(opts.maxTurns) 动态计算的下限 30min timer,兜底 SIGTERM);
7
+ // 外部取消通过 opts.signal (AbortSignal) 传播:onAbort → child.kill("SIGTERM")。
8
+ // 故「timeout 端到端路径」实际是 watchdog timer + signal abort → child.kill 两条链路。
9
+ //
10
+ // 本文件聚焦三条终止语义路径(与 run-spawn-integration.test.ts §12 watchdog 测试互补,
11
+ // 该文件关注 timer 边界值,本文件关注端到端 kill 语义 + 外部 signal 场景):
12
+ // 1. watchdog 到期 → child.kill(maxTurns 驱动的整体超时兜底)
13
+ // 2. 正常完成先于 watchdog 到期 → clearTimeout 生效,不 kill
14
+ // 3. 外部 signal abort(运行中 abort / spawn 前已 aborted)→ child.kill
15
+ //
16
+ // mock 策略(与 run-spawn-integration.test.ts / run-spawn-edges.test.ts 一致):
17
+ // - node:child_process.spawn → 返回 FakeChild(EventEmitter + PassThrough)。
18
+ // - node:child_process.execFileSync → 返回空串(buildEnvBlock git branch 兜底)。
19
+ // - node:fs 同步方法 → mock(避免触碰真实文件系统),promises 保留真实实现。
20
+ // - temp-prompt → mock(返回固定路径,消除 fake-timers flaky)。
21
+ // - alive-store.writeAliveMarker → mock。
22
+
23
+ import type { PassThrough } from "node:stream";
24
+
25
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
26
+
27
+ // ── mock modules ──
28
+ // vitest 把 vi.mock 提升到文件顶部,工厂内部引用模块用 await import。
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
+ class FakeChild extends EventEmitter {
36
+ pid = 12345;
37
+ stdout = new PassThrough();
38
+ stderr = new PassThrough();
39
+ killed = false;
40
+ killSignal: string | undefined;
41
+ kill(sig?: string): boolean {
42
+ this.killed = true;
43
+ this.killSignal = sig;
44
+ return true;
45
+ }
46
+ }
47
+
48
+ return {
49
+ spawn: vi.fn(() => new FakeChild()),
50
+ execFileSync: vi.fn(() => ""),
51
+ };
52
+ });
53
+
54
+ vi.mock("node:fs", async () => {
55
+ const actual = await import("node:fs");
56
+ return {
57
+ default: {
58
+ ...actual,
59
+ mkdirSync: vi.fn(),
60
+ existsSync: vi.fn(() => false),
61
+ appendFileSync: vi.fn(),
62
+ writeFileSync: vi.fn(),
63
+ readdirSync: vi.fn(() => []),
64
+ },
65
+ mkdirSync: vi.fn(),
66
+ existsSync: vi.fn(() => false),
67
+ appendFileSync: vi.fn(),
68
+ writeFileSync: vi.fn(),
69
+ readdirSync: vi.fn(() => []),
70
+ promises: actual.promises,
71
+ };
72
+ });
73
+
74
+ vi.mock("../alive-store.ts", () => ({
75
+ writeAliveMarker: vi.fn(),
76
+ }));
77
+
78
+ vi.mock("../temp-prompt.ts", () => ({
79
+ writePromptToTempFile: vi.fn(async (agent: string) => {
80
+ const safeName = agent.replace(/[^\w.-]+/g, "_");
81
+ return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt-${safeName}.md` };
82
+ }),
83
+ cleanupTempPrompt: vi.fn(async () => {}),
84
+ }));
85
+
86
+ import { execFileSync, spawn } from "node:child_process";
87
+ import * as fs from "node:fs";
88
+
89
+ import { createRecord } from "../execution-record.ts";
90
+ import { type RunOptions, runSpawn, type SessionRunnerContext } from "../session-runner.ts";
91
+
92
+ const mockSpawn = vi.mocked(spawn);
93
+ const mockExec = vi.mocked(execFileSync);
94
+ const mockExistsSync = vi.mocked(fs.existsSync);
95
+
96
+ /**
97
+ * spawn mock 返回的 fake child 类型(结构子集)。
98
+ * FakeChild 定义在 vi.mock 工厂内部(作用域隔离),测试代码通过此类型访问成员。
99
+ */
100
+ interface FakeChild {
101
+ pid: number;
102
+ stdout: PassThrough;
103
+ stderr: PassThrough;
104
+ killed: boolean;
105
+ killSignal: string | undefined;
106
+ kill(sig?: string): boolean;
107
+ emit(event: string, ...args: unknown[]): boolean;
108
+ }
109
+
110
+ /** 从最近一次 spawn 调用取回返回的 FakeChild。 */
111
+ function lastSpawnedChild(): FakeChild {
112
+ const result = mockSpawn.mock.results.at(-1);
113
+ if (!result) throw new Error("spawn was not called yet");
114
+ return result.value as FakeChild;
115
+ }
116
+
117
+ // ============================================================
118
+ // 辅助:向 stdout 写一行(自动补换行)
119
+ // ============================================================
120
+
121
+ function emitStdoutLine(child: FakeChild, obj: Record<string, unknown>): void {
122
+ child.stdout.write(`${JSON.stringify(obj)}\n`);
123
+ }
124
+
125
+ /** 构造 session header 行(stdout 首行)。 */
126
+ function sessionHeader(id = "sess-abc"): Record<string, unknown> {
127
+ return {
128
+ type: "session",
129
+ id,
130
+ timestamp: "2026-07-03T12-00-00-000Z",
131
+ cwd: "/tmp/test",
132
+ };
133
+ }
134
+
135
+ // ============================================================
136
+ // 辅助:构造最小合法的 record / opts / ctx
137
+ // ============================================================
138
+
139
+ function makeRecord() {
140
+ return createRecord("run-1", {
141
+ agent: "general-purpose",
142
+ model: "test-model",
143
+ mode: "sync",
144
+ task: "do something",
145
+ startedAt: 1_000_000,
146
+ rootSessionId: "root-session",
147
+ parentRecordId: undefined,
148
+ depth: 0,
149
+ });
150
+ }
151
+
152
+ function makeOpts(overrides: Partial<RunOptions> = {}): RunOptions {
153
+ return {
154
+ resolved: {
155
+ model: {
156
+ id: "test-model",
157
+ name: "Test Model",
158
+ provider: "test",
159
+ reasoning: false,
160
+ },
161
+ thinkingLevel: undefined,
162
+ },
163
+ agentConfig: undefined,
164
+ appendSystemPrompt: undefined,
165
+ skillPath: undefined,
166
+ schema: undefined,
167
+ maxTurns: undefined,
168
+ graceTurns: undefined,
169
+ signal: undefined,
170
+ onEvent: undefined,
171
+ ...overrides,
172
+ };
173
+ }
174
+
175
+ function makeCtx(overrides: Partial<SessionRunnerContext> = {}): SessionRunnerContext {
176
+ return {
177
+ cwd: "/tmp/test",
178
+ agentDir: "/tmp/test/agents",
179
+ skillDirs: [],
180
+ mainCwd: "/tmp/test",
181
+ mainSessionFile: undefined,
182
+ ...overrides,
183
+ };
184
+ }
185
+
186
+ /**
187
+ * fake timers 下推进时间直到 spawn 被调用。
188
+ *
189
+ * runSpawn 在 mkdirSync + writePromptToTempFile(mock 的 async I/O)之后才调 spawn。
190
+ * 每次推进 10ms 让轮询 setTimeout 触发,advanceTimersByTimeAsync 同时 flush 已 resolve
191
+ * 的 I/O promise,使 runSpawn 继续走到 spawn。
192
+ */
193
+ async function waitForSpawnFake(timeoutSteps = 200): Promise<FakeChild> {
194
+ for (let i = 0; i < timeoutSteps; i++) {
195
+ if (mockSpawn.mock.results.length > 0) break;
196
+ await vi.advanceTimersByTimeAsync(10);
197
+ }
198
+ if (mockSpawn.mock.results.length === 0) {
199
+ throw new Error("spawn was not called (fake timers did not progress to spawn)");
200
+ }
201
+ return lastSpawnedChild();
202
+ }
203
+
204
+ /**
205
+ * 真实 timers 下轮询直到 spawn 被调用(用于 signal abort 测试——不需要推进 watchdog,
206
+ * 用 queueMicrotask 触发 abort,真实 timers 下 mock I/O 正常 resolve)。
207
+ *
208
+ * 与 run-spawn-edges.test.ts 的 waitForSpawn 同模式:setInterval 轮询 mockSpawn.mock.results,
209
+ * 比 vi.waitFor 在该 vitest 版本下更可靠(偶发过早 resolve)。
210
+ */
211
+ async function waitForSpawnReal(timeoutMs = 1000): Promise<FakeChild> {
212
+ const start = Date.now();
213
+ while (mockSpawn.mock.results.length === 0) {
214
+ if (Date.now() - start > timeoutMs) {
215
+ throw new Error(`spawn was not called within ${timeoutMs}ms`);
216
+ }
217
+ await new Promise((r) => setTimeout(r, 5));
218
+ }
219
+ return lastSpawnedChild();
220
+ }
221
+
222
+ // ============================================================
223
+ // 测试
224
+ // ============================================================
225
+
226
+ describe("timeoutMs / signal abort → child.kill 端到端路径", () => {
227
+ beforeEach(() => {
228
+ vi.clearAllMocks();
229
+ mockExec.mockReturnValue("");
230
+ mockExistsSync.mockReturnValue(false);
231
+ });
232
+
233
+ afterEach(() => {
234
+ vi.restoreAllMocks();
235
+ });
236
+
237
+ // ── 1. watchdog 到期 → child.kill ──
238
+ //
239
+ // [R1] watchdog = setTimeout(() => child.kill("SIGTERM"), computeWatchdogMs(maxTurns))。
240
+ // computeWatchdogMs 下限 30min(SPAWN_WATCHDOG_FLOOR_MS),maxTurns=6 → max(30min, 30min)=30min。
241
+ // 子进程卡死(turn_end 永不触发)时 limiter 失效,watchdog 兜底 kill 防资源泄漏。
242
+ describe("watchdog 到期 → signal abort → child.kill", () => {
243
+ beforeEach(() => {
244
+ vi.useFakeTimers();
245
+ });
246
+
247
+ afterEach(() => {
248
+ vi.useRealTimers();
249
+ });
250
+
251
+ it("watchdog 到期(超过 computeWatchdogMs 阈值)→ child.kill(SIGTERM) 被调用", async () => {
252
+ const record = makeRecord();
253
+ // maxTurns=6 → computeWatchdogMs = max(30min, 6*5min) = 30min
254
+ // 不 await:runSpawn 内部 await 子进程 close,watchdog kill 后还需 emit close 才 resolve
255
+ const promise = runSpawn(record, "Task: hang", makeOpts({ maxTurns: 6 }), makeCtx());
256
+
257
+ const child = await waitForSpawnFake();
258
+
259
+ // spawn 后尚未触发 kill
260
+ expect(child.killed).toBe(false);
261
+
262
+ // 推进时间越过 watchdog 阈值(30 * 60 * 1000 + 100ms 余量)
263
+ await vi.advanceTimersByTimeAsync(30 * 60 * 1000 + 100);
264
+
265
+ // watchdog 触发 child.kill("SIGTERM")
266
+ expect(child.killed).toBe(true);
267
+ expect(child.killSignal).toBe("SIGTERM");
268
+
269
+ // 收尾:emit close 让 runSpawn resolve(避免悬挂)
270
+ emitStdoutLine(child, sessionHeader());
271
+ child.stdout.end();
272
+ child.emit("close", 143); // SIGTERM = 128+15
273
+
274
+ const result = await promise;
275
+ // 信号终止(>=128)视为正常完成
276
+ expect(result.success).toBe(true);
277
+ });
278
+ });
279
+
280
+ // ── 2. watchdog 到期前正常完成 → clearTimeout 生效,不 kill ──
281
+ describe("watchdog 到期前正常完成 → 不 kill", () => {
282
+ beforeEach(() => {
283
+ vi.useFakeTimers();
284
+ });
285
+
286
+ afterEach(() => {
287
+ vi.useRealTimers();
288
+ });
289
+
290
+ it("正常 close(0) 先于 watchdog 到期 → clearTimeout 生效,推进时间后 child 未被 kill", async () => {
291
+ const record = makeRecord();
292
+ // maxTurns=6 → watchdog=30min
293
+ const promise = runSpawn(record, "Task: quick", makeOpts({ maxTurns: 6 }), makeCtx());
294
+
295
+ const child = await waitForSpawnFake();
296
+
297
+ // 正常完成:emit header + close(0)(远早于 30min watchdog)
298
+ emitStdoutLine(child, sessionHeader());
299
+ child.stdout.end();
300
+ child.emit("close", 0);
301
+
302
+ const result = await promise;
303
+ expect(result.success).toBe(true);
304
+
305
+ // close 后 runSpawn 已 clearTimeout(watchdog);推进 30+ 分钟验证 watchdog 未触发 kill
306
+ await vi.advanceTimersByTimeAsync(30 * 60 * 1000 + 100);
307
+
308
+ expect(child.killed).toBe(false);
309
+ expect(child.killSignal).toBeUndefined();
310
+ });
311
+ });
312
+
313
+ // ── 3. 外部 signal abort → child.kill ──
314
+ //
315
+ // [d] onAbort = () => child.kill("SIGTERM"),opts.signal.addEventListener("abort", onAbort, {once:true})。
316
+ // 前置检查:if (opts.signal?.aborted) onAbort()——spawn 前已 aborted 时 addEventListener
317
+ // 不会触发,立即 kill 兑现取消语义。
318
+ describe("外部 signal abort → child.kill", () => {
319
+ it("运行中 abort signal → child.kill(SIGTERM) 被调用,success=false(取消语义)", async () => {
320
+ const controller = new AbortController();
321
+ const record = makeRecord();
322
+ const promise = runSpawn(
323
+ record,
324
+ "Task: cancelled",
325
+ makeOpts({ signal: controller.signal }),
326
+ makeCtx(),
327
+ );
328
+
329
+ const child = await waitForSpawnReal();
330
+
331
+ // abort 必须在 spawn 之后(addEventListener 已注册)。
332
+ // queueMicrotask 延迟到当前微任务清空后触发,确保 listener 已挂载。
333
+ queueMicrotask(() => controller.abort());
334
+
335
+ // emit header + close(被 kill 后子进程退出,signal 终止 exitCode>=128)
336
+ emitStdoutLine(child, sessionHeader());
337
+ child.stdout.end();
338
+ child.emit("close", 143); // SIGTERM = 128+15
339
+
340
+ const result = await promise;
341
+
342
+ expect(child.killed).toBe(true);
343
+ expect(child.killSignal).toBe("SIGTERM");
344
+ // signal.aborted 路径:success=false,但 error 为 undefined(取消不算 error)
345
+ expect(result.success).toBe(false);
346
+ expect(result.error).toBeUndefined();
347
+ });
348
+
349
+ it("spawn 前已 aborted 的 signal → 前置检查立即 kill,兑现取消语义", async () => {
350
+ // 覆盖 session-runner.ts L570: if (opts.signal?.aborted) onAbort()
351
+ // 已 aborted 的 signal addEventListener("abort") 不会再触发回调,
352
+ // 故 runSpawn 在注册 listener 后立即前置检查,直接 kill 兑现取消。
353
+ const controller = new AbortController();
354
+ controller.abort(); // spawn 前已 abort
355
+
356
+ const record = makeRecord();
357
+ const promise = runSpawn(
358
+ record,
359
+ "Task: pre-aborted",
360
+ makeOpts({ signal: controller.signal }),
361
+ makeCtx(),
362
+ );
363
+
364
+ const child = await waitForSpawnReal();
365
+
366
+ // 前置检查在 spawn 后同步执行 → child 立即被 kill
367
+ expect(child.killed).toBe(true);
368
+ expect(child.killSignal).toBe("SIGTERM");
369
+
370
+ // 收尾:emit close 让 runSpawn resolve
371
+ emitStdoutLine(child, sessionHeader());
372
+ child.stdout.end();
373
+ child.emit("close", 143);
374
+
375
+ const result = await promise;
376
+ // signal.aborted → success=false,error undefined
377
+ expect(result.success).toBe(false);
378
+ expect(result.error).toBeUndefined();
379
+ });
380
+ });
381
+ });
@@ -0,0 +1,73 @@
1
+ // src/__tests__/tombstone-store.test.ts
2
+ //
3
+ // tombstone-store 专属测试。
4
+ // 覆盖:write→read 往返 / 缺 sidecar → undefined / 损坏 sidecar → undefined / 结构校验。
5
+
6
+ import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+
10
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
11
+
12
+ import { readCancelledTombstone, writeCancelledTombstone } from "../tombstone-store.ts";
13
+
14
+ describe("tombstone-store", () => {
15
+ let tmpDir: string;
16
+ let sessionFile: string;
17
+
18
+ beforeEach(() => {
19
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ts-test-"));
20
+ sessionFile = path.join(tmpDir, "2026-01-01_uuid.jsonl");
21
+ });
22
+ afterEach(() => {
23
+ fs.rmSync(tmpDir, { recursive: true, force: true });
24
+ });
25
+
26
+ describe("write → read 往返", () => {
27
+ it("写入后读回完整数据", () => {
28
+ writeCancelledTombstone(sessionFile, {
29
+ id: "bg-1", status: "cancelled", agent: "worker", startedAt: 1000, endedAt: 2000,
30
+ });
31
+ const tomb = readCancelledTombstone(sessionFile);
32
+ expect(tomb).toEqual({
33
+ id: "bg-1", status: "cancelled", agent: "worker", startedAt: 1000, endedAt: 2000,
34
+ });
35
+ });
36
+
37
+ it("sidecar 路径 = sessionFile + '.cancelled'", () => {
38
+ writeCancelledTombstone(sessionFile, {
39
+ id: "bg-1", status: "cancelled", agent: "w", startedAt: 1, endedAt: 2,
40
+ });
41
+ expect(fs.existsSync(`${sessionFile}.cancelled`)).toBe(true);
42
+ });
43
+ });
44
+
45
+ describe("读降级", () => {
46
+ it("无 sidecar → undefined(正常——非 cancelled record)", () => {
47
+ expect(readCancelledTombstone(sessionFile)).toBeUndefined();
48
+ });
49
+
50
+ it("损坏 JSON → undefined", () => {
51
+ fs.writeFileSync(`${sessionFile}.cancelled`, "NOT JSON\n", "utf-8");
52
+ expect(readCancelledTombstone(sessionFile)).toBeUndefined();
53
+ });
54
+
55
+ it("status 非 'cancelled' → undefined", () => {
56
+ fs.writeFileSync(
57
+ `${sessionFile}.cancelled`,
58
+ `${JSON.stringify({ id: "x", status: "done", agent: "w", startedAt: 1, endedAt: 2 })}\n`,
59
+ "utf-8",
60
+ );
61
+ expect(readCancelledTombstone(sessionFile)).toBeUndefined();
62
+ });
63
+
64
+ it("缺必填字段 → undefined", () => {
65
+ fs.writeFileSync(
66
+ `${sessionFile}.cancelled`,
67
+ `${JSON.stringify({ id: "x", status: "cancelled" })}\n`, // 缺 agent/startedAt/endedAt
68
+ "utf-8",
69
+ );
70
+ expect(readCancelledTombstone(sessionFile)).toBeUndefined();
71
+ });
72
+ });
73
+ });