@zhushanwen/subagent-core 0.2.0 → 0.4.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 (195) hide show
  1. package/README.md +45 -2
  2. package/agents/analyst.md +60 -0
  3. package/agents/coder.md +69 -0
  4. package/agents/debugger.md +66 -0
  5. package/agents/doc-reviewer.md +49 -0
  6. package/agents/explorer.md +63 -0
  7. package/agents/general-purpose.md +32 -0
  8. package/agents/orchestrator.md +61 -0
  9. package/agents/planner.md +53 -0
  10. package/agents/researcher.md +67 -0
  11. package/agents/reviewer.md +73 -0
  12. package/dist/chunk-4KN4TTG7.js +240 -0
  13. package/dist/chunk-APZY4IME.js +27 -0
  14. package/dist/chunk-X7SZ5HLQ.js +66 -0
  15. package/dist/execution/engine/engines/zcode/constants.cjs +65 -0
  16. package/dist/execution/engine/engines/zcode/constants.d.cts +93 -1
  17. package/dist/execution/engine/engines/zcode/constants.d.ts +93 -1
  18. package/dist/execution/engine/engines/zcode/constants.js +43 -1
  19. package/dist/execution/engine/engines/zcode/reader.d.cts +1 -1
  20. package/dist/execution/engine/engines/zcode/reader.d.ts +1 -1
  21. package/dist/execution/engine/engines/zcode/reader.js +4 -234
  22. package/dist/execution/engine/paths.js +7 -19
  23. package/dist/index.cjs +14709 -100
  24. package/dist/index.d.cts +4053 -200
  25. package/dist/index.d.ts +4053 -200
  26. package/dist/index.js +14358 -102
  27. package/dist/{types-BxyAidGf.d.cts → types-DpUO16pj.d.cts} +464 -1
  28. package/dist/{types-BxyAidGf.d.ts → types-DpUO16pj.d.ts} +464 -1
  29. package/package.json +6 -3
  30. package/src/__tests__/review-fix-loop-script.test.ts +90 -2
  31. package/src/__tests__/review-fix-loop-utils.test.ts +21 -0
  32. package/src/__tests__/smoke.test.ts +9 -2
  33. package/src/execution/__tests__/agent-profile.test.ts +232 -0
  34. package/src/execution/__tests__/agents-assembly.test.ts +219 -0
  35. package/src/execution/__tests__/chat-engine-routing.test.ts +71 -0
  36. package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +1 -0
  37. package/src/execution/__tests__/create-concurrency-pool.test.ts +194 -0
  38. package/src/execution/__tests__/delivery-methods.test.ts +2 -0
  39. package/src/execution/__tests__/descendant-sweep.test.ts +269 -0
  40. package/src/execution/__tests__/dialog-queue.test.ts +199 -1
  41. package/src/execution/__tests__/epipe-fallback.test.ts +2 -0
  42. package/src/execution/__tests__/execution-runtime-face.test.ts +272 -0
  43. package/src/execution/__tests__/finalize-record.test.ts +106 -0
  44. package/src/execution/__tests__/gc-timer.test.ts +2 -0
  45. package/src/execution/__tests__/get-record-for-action-restart.test.ts +2 -0
  46. package/src/execution/__tests__/get-state-handshake.test.ts +127 -0
  47. package/src/execution/__tests__/keep-alive-no-progress.test.ts +378 -0
  48. package/src/execution/__tests__/kill-all-escalation.test.ts +196 -0
  49. package/src/execution/__tests__/lifecycle-manager-idle-timer-identity.test.ts +117 -0
  50. package/src/execution/__tests__/lifecycle-manager.test.ts +39 -0
  51. package/src/execution/__tests__/manifest-store-tmp-recovery.test.ts +99 -0
  52. package/src/execution/__tests__/max-turns-to-watchdog-ms.test.ts +113 -0
  53. package/src/execution/__tests__/notify-ledger.test.ts +294 -0
  54. package/src/execution/__tests__/record-store-orphan-revive.test.ts +159 -0
  55. package/src/execution/__tests__/record-store.test.ts +83 -0
  56. package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +4 -0
  57. package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +1 -0
  58. package/src/execution/__tests__/run-spawn-edges.test.ts +407 -17
  59. package/src/execution/__tests__/run-spawn-integration.test.ts +1 -1
  60. package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +12 -4
  61. package/src/execution/__tests__/service-kill-escalation.test.ts +89 -0
  62. package/src/execution/__tests__/session-pending.test.ts +228 -0
  63. package/src/execution/__tests__/session-runner-branch-cache-lru.test.ts +110 -0
  64. package/src/execution/__tests__/session-runner-close-prune.test.ts +181 -0
  65. package/src/execution/__tests__/session-runner-epipe.test.ts +1 -0
  66. package/src/execution/__tests__/session-runner-heartbeat-idle-fallback.test.ts +226 -0
  67. package/src/execution/__tests__/settled-watchdog.test.ts +289 -0
  68. package/src/execution/__tests__/spawned-children.test.ts +153 -10
  69. package/src/execution/__tests__/subagent-actions-core.test.ts +997 -0
  70. package/src/execution/__tests__/subagent-service-message-close.test.ts +71 -10
  71. package/src/execution/__tests__/subagent-service-multiproc-guard.test.ts +174 -0
  72. package/src/execution/__tests__/subagent-service-notify-gate.test.ts +266 -0
  73. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +3 -0
  74. package/src/execution/__tests__/subagent-service-recovery-bounds.test.ts +316 -0
  75. package/src/execution/__tests__/timeout-integration.test.ts +7 -7
  76. package/src/execution/__tests__/ui-request-handler-factory.test.ts +95 -1
  77. package/src/execution/__tests__/ui-request-queue.test.ts +25 -0
  78. package/src/execution/__tests__/worktree-git-ops.test.ts +571 -0
  79. package/src/execution/__tests__/worktree-reconcile-aging.test.ts +204 -0
  80. package/src/execution/agent-registry.ts +225 -1
  81. package/src/execution/agents-assembly.ts +88 -0
  82. package/src/execution/concurrency-pool.ts +71 -9
  83. package/src/execution/dialog-queue.ts +101 -3
  84. package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +49 -2
  85. package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +87 -1
  86. package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +26 -0
  87. package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +83 -1
  88. package/src/execution/engine/__tests__/conformance/zcode-appserver-harness.ts +131 -0
  89. package/src/execution/engine/__tests__/registry.test.ts +90 -1
  90. package/src/execution/engine/engine-discovery.ts +12 -16
  91. package/src/execution/engine/engines/pi/task-spec-mapper.ts +3 -3
  92. package/src/execution/engine/engines/zcode/__tests__/__fixtures__/fake-appserver.mjs +274 -0
  93. package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-appserver.json +36 -0
  94. package/src/execution/engine/engines/zcode/__tests__/connection.test.ts +472 -0
  95. package/src/execution/engine/engines/zcode/__tests__/preparer-appserver.test.ts +387 -0
  96. package/src/execution/engine/engines/zcode/__tests__/session-channel.test.ts +780 -0
  97. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-appserver.test.ts +815 -0
  98. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-degrade.test.ts +462 -0
  99. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +119 -1
  100. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +136 -6
  101. package/src/execution/engine/engines/zcode/appserver-home.ts +442 -0
  102. package/src/execution/engine/engines/zcode/appserver-probe.ts +141 -0
  103. package/src/execution/engine/engines/zcode/connection.ts +585 -0
  104. package/src/execution/engine/engines/zcode/constants.ts +129 -0
  105. package/src/execution/engine/engines/zcode/golden-sample.ts +54 -8
  106. package/src/execution/engine/engines/zcode/preparer.ts +25 -22
  107. package/src/execution/engine/engines/zcode/session-channel.ts +656 -0
  108. package/src/execution/engine/engines/zcode/zcode-engine.ts +909 -41
  109. package/src/execution/engine/host-task-spec.ts +3 -3
  110. package/src/execution/engine/port.ts +41 -1
  111. package/src/execution/engine/registry.ts +53 -0
  112. package/src/execution/finalize-record.ts +63 -5
  113. package/src/execution/get-state-handshake.ts +85 -10
  114. package/src/execution/lifecycle-manager.ts +27 -3
  115. package/src/execution/manifest-store.ts +53 -62
  116. package/src/execution/notifier.ts +10 -10
  117. package/src/execution/notify-ledger.ts +117 -16
  118. package/src/execution/record-entry.ts +8 -2
  119. package/src/execution/record-store.ts +29 -15
  120. package/src/execution/session-pending.ts +213 -62
  121. package/src/execution/session-runner.ts +974 -128
  122. package/src/execution/sessions-index.ts +10 -55
  123. package/src/execution/settled-watchdog.ts +99 -0
  124. package/src/execution/subagent-actions-core.ts +686 -0
  125. package/src/execution/subagent-service.ts +396 -49
  126. package/src/execution/ui-request-handler-factory.ts +27 -4
  127. package/src/execution/worktree-git-ops.ts +397 -0
  128. package/src/execution/worktree-manager.ts +72 -10
  129. package/src/execution/worktree-registry.ts +6 -12
  130. package/src/index.ts +413 -6
  131. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +42 -3
  132. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +2 -4
  133. package/src/orchestration/__tests__/args-meta.test.ts +358 -0
  134. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +1 -8
  135. package/src/orchestration/__tests__/error-recovery-rebuild-failure.test.ts +312 -0
  136. package/src/orchestration/__tests__/error-recovery-terminal-hardening.test.ts +382 -0
  137. package/src/orchestration/__tests__/file-run-store-prune.test.ts +168 -0
  138. package/src/orchestration/__tests__/file-run-store-throttle.test.ts +168 -0
  139. package/src/orchestration/__tests__/file-run-store.test.ts +389 -0
  140. package/src/orchestration/__tests__/helpers/flush-microtasks.ts +13 -0
  141. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +34 -0
  142. package/src/orchestration/__tests__/lifecycle-abort-broadcast-signal.test.ts +357 -0
  143. package/src/orchestration/__tests__/lifecycle-recover-crashed.test.ts +289 -0
  144. package/src/orchestration/__tests__/lifecycle.test.ts +77 -0
  145. package/src/orchestration/__tests__/run-snapshot.test.ts +353 -0
  146. package/src/orchestration/__tests__/script-generate.test.ts +314 -0
  147. package/src/orchestration/__tests__/worker-pending-timeout-abort.test.ts +275 -0
  148. package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +78 -1
  149. package/src/orchestration/__tests__/workflow-files.test.ts +186 -0
  150. package/src/orchestration/__tests__/workflow-run-summary.test.ts +119 -0
  151. package/src/orchestration/__tests__/workflow-script-registry-impl.test.ts +124 -0
  152. package/src/orchestration/agent-opts-resolver.ts +7 -7
  153. package/src/orchestration/args-meta.ts +198 -0
  154. package/src/orchestration/error-recovery.ts +416 -146
  155. package/src/orchestration/execute-agent-call.ts +9 -9
  156. package/src/orchestration/file-run-store.ts +327 -0
  157. package/src/orchestration/launcher.ts +35 -26
  158. package/src/orchestration/lifecycle.ts +355 -79
  159. package/src/orchestration/models/agent-call.ts +7 -7
  160. package/src/orchestration/models/budget.ts +5 -5
  161. package/src/orchestration/models/run-runtime.ts +13 -13
  162. package/src/orchestration/models/trace.ts +8 -8
  163. package/src/orchestration/models/workflow-run.ts +27 -27
  164. package/src/orchestration/models/workflow-script.ts +4 -4
  165. package/src/orchestration/run-snapshot.ts +266 -0
  166. package/src/orchestration/script-generate.ts +154 -0
  167. package/src/orchestration/script-lint.ts +33 -33
  168. package/src/orchestration/worker-handle.ts +10 -10
  169. package/src/orchestration/worker-host.ts +10 -10
  170. package/src/orchestration/worker-script-builder.ts +401 -346
  171. package/src/orchestration/workflow-files.ts +37 -11
  172. package/src/orchestration/workflow-run-summary.ts +69 -0
  173. package/src/orchestration/workflow-script-registry-impl.ts +31 -9
  174. package/src/shared/__tests__/agent-ref.test.ts +209 -2
  175. package/src/shared/__tests__/atomic-write.test.ts +267 -0
  176. package/src/shared/__tests__/bounded-serialize.test.ts +236 -0
  177. package/src/shared/__tests__/injection-render.test.ts +518 -0
  178. package/src/shared/__tests__/resource-discovery-host-roots.test.ts +474 -0
  179. package/src/shared/__tests__/resource-discovery.test.ts +3 -2
  180. package/src/shared/agent-ref.ts +143 -2
  181. package/src/shared/atomic-write.ts +320 -0
  182. package/src/shared/bounded-serialize.ts +154 -0
  183. package/src/shared/injection-render.ts +279 -0
  184. package/src/shared/meta-parser.ts +41 -1
  185. package/src/shared/resource-discovery.ts +104 -37
  186. package/src/shared/resource-meta.ts +14 -0
  187. package/src/shared/xml-injection.ts +9 -9
  188. package/workflows/README.md +9 -9
  189. package/workflows/chain.js +4 -2
  190. package/workflows/map-reduce.js +5 -3
  191. package/workflows/parallel.js +5 -3
  192. package/workflows/review-fix-loop-utils.cjs +14 -3
  193. package/workflows/review-fix-loop.js +18 -7
  194. package/workflows/scatter-gather.js +4 -2
  195. package/dist/chunk-3VOERJPJ.js +0 -22
@@ -13,9 +13,19 @@
13
13
 
14
14
  import { spawn } from "node:child_process";
15
15
  import * as fs from "node:fs";
16
+ import * as path from "node:path";
16
17
 
17
18
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
19
 
20
+ // [LC-9/T7②] Mock 共享 logger:stdout invalid 行的 debug 留痕可被断言
21
+ //(对齐 channel-registry-handshake.test.ts 模式;vi.mock 自动 hoist 到 import 前)。
22
+ const { loggerMock } = vi.hoisted(() => ({
23
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
24
+ }));
25
+ vi.mock("../../core/logger.ts", () => ({
26
+ getLogger: () => loggerMock,
27
+ }));
28
+
19
29
  vi.mock("node:child_process", async () => {
20
30
  const { FakeChild } = await import("./helpers/spawn-mock.ts");
21
31
  return {
@@ -54,11 +64,18 @@ vi.mock("node:fs", async () => {
54
64
 
55
65
  vi.mock("../alive-store.ts", () => ({
56
66
  writeAliveMarker: vi.fn(),
67
+ // [T5②] keep-alive 心跳刷新读取现有 marker id(缺失兜底 record.id)
68
+ readAliveMarker: vi.fn(() => undefined),
69
+ isProcessAlive: vi.fn(() => false),
57
70
  }));
58
71
 
59
72
  // [recursive-orchestration] agent_end 后代判定独立 mock(判定函数本身在 session-pending.test.ts 单独测)。
73
+ // listActivePendingFromSessionFile:[A1-2] keep-alive 无进展 fire 时的存活后代惰性复核
74
+ // 消费(差集清单 + pid 探活);默认空清单 = 无存活后代 → fire 照旧处置(既有用例语义不变)。
60
75
  vi.mock("../session-pending.ts", () => ({
61
76
  readActivePendingFromSessionFile: vi.fn(() => ({ count: 0 })),
77
+ listActivePendingFromSessionFile: vi.fn(() => ({ items: [] })),
78
+ prunePendingCursor: vi.fn(),
62
79
  }));
63
80
 
64
81
  vi.mock("../temp-prompt.ts", () => ({
@@ -69,7 +86,9 @@ vi.mock("../temp-prompt.ts", () => ({
69
86
  cleanupTempPrompt: vi.fn(async () => {}),
70
87
  }));
71
88
 
72
- import { killAllSpawnedChildren, runSpawn, spawnedChildren, WAKEUP_GRACE_MS, computeWatchdogMs, SPAWN_WATCHDOG_ENV } from "../session-runner.ts";
89
+ import { KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS, killAllSpawnedChildren, runSpawn, spawnedChildren, WAKEUP_GRACE_MS, maxTurnsToWatchdogMs, SPAWN_WATCHDOG_ENV } from "../session-runner.ts";
90
+ import { writeAliveMarker } from "../alive-store.ts";
91
+ import { getSubagentSessionDir } from "../path-encoding.ts";
73
92
  import { readActivePendingFromSessionFile } from "../session-pending.ts";
74
93
  import {
75
94
  emitStdoutLine,
@@ -258,10 +277,11 @@ describe("runSpawn", () => {
258
277
  //
259
278
  // [M8] runSpawn 的 stdout 解析容错:
260
279
  // - parseSpawnLine 对「非法 JSON」「合法 JSON 但缺 type 字段」归为 kind:"invalid"。
261
- // - runSpawn 的 data 处理器只认 header/event 两类,invalid 行静默忽略(L559 注释
262
- // "invalid 行忽略")——单行损坏不应中断整个事件流。
280
+ // - runSpawn 的 data 处理器只认 header/event 两类,invalid 行不中断事件流。
281
+ // [LC-9/T7②] 起不再静默:per-child 计数 + debug 级前 3 条样本留痕,close 时
282
+ // 聚合输出总数与样本(「事件行损坏被静默丢弃」曾使 LC-1 形态 (c) 无法排查)。
263
283
  // - close 前 stdoutBuffer 若残留未以 \n 结尾的合法 event 行,close handler 会再 parse
264
- // 一次(L574-579)——覆盖子进程末行漏 \n 的场景。
284
+ // 一次(L574-579)——覆盖子进程末行漏 \n 的场景;残留 invalid 行同计统计。
265
285
  describe("stdout 边界:损坏行 + 残留尾行 (M8)", () => {
266
286
  it("stdout 夹杂非法 JSON 行 → 该行被忽略,合法 turn_end 正常计数(不抛错)", async () => {
267
287
  const record = makeRecord();
@@ -285,6 +305,89 @@ describe("runSpawn", () => {
285
305
  expect(record.turnCount).toBe(1); // 非法行被忽略,仅 turn_end 计数
286
306
  });
287
307
 
308
+ // [LC-9/T7②] invalid 行可观测性:计数 + debug 样本 + close 聚合,不再零可见。
309
+ it("[LC-9] invalid 行计数留痕:前 3 条逐条 debug 样本 + close 聚合总数(不刷屏)", async () => {
310
+ const record = makeRecord();
311
+ const promise = runSpawn(record, "Task: invalid-obs", makeOpts(), makeCtx());
312
+
313
+ await waitForSpawn();
314
+ const child = lastSpawnedChild();
315
+
316
+ emitStdoutLine(child, sessionHeader());
317
+ child.stdout.write("garbage-line-1\n");
318
+ child.stdout.write('{"foo":"bar"}\n'); // 缺 type 字段的合法 JSON,同归 invalid
319
+ child.stdout.write("garbage-line-3\n");
320
+ emitStdoutLine(child, { type: "turn_end" });
321
+ child.stdout.end();
322
+ child.emit("close", 0);
323
+
324
+ const result = await promise;
325
+ expect(result.success).toBe(true);
326
+ expect(record.turnCount).toBe(1); // 容错行为不变:invalid 不中断事件流
327
+
328
+ const debugMsgs = loggerMock.debug.mock.calls.map((c) => String(c[0]));
329
+ // 前 3 条逐条样本(含原始行内容,可定位是哪行损坏)
330
+ const sampleMsgs = debugMsgs.filter((m) => m.includes("stdout invalid line #"));
331
+ expect(sampleMsgs).toHaveLength(3);
332
+ expect(sampleMsgs[0]).toContain("#1");
333
+ expect(sampleMsgs[0]).toContain("garbage-line-1");
334
+ expect(sampleMsgs[1]).toContain('{"foo":"bar"}');
335
+ expect(sampleMsgs[2]).toContain("garbage-line-3");
336
+ // close 聚合:总数一次暴露(processTrailingLine 由 close handler 必经)
337
+ const aggMsgs = debugMsgs.filter((m) => m.includes("invalid line(s) dropped in total"));
338
+ expect(aggMsgs).toHaveLength(1);
339
+ expect(aggMsgs[0]).toContain("3 invalid line(s)");
340
+ });
341
+
342
+ it("[LC-9] invalid 行超过样本上限(5 条)→ 样本 debug 恰 3 条(防刷屏)+ 聚合仍报全量 5", async () => {
343
+ const record = makeRecord();
344
+ const promise = runSpawn(record, "Task: invalid-flood", makeOpts(), makeCtx());
345
+
346
+ await waitForSpawn();
347
+ const child = lastSpawnedChild();
348
+
349
+ emitStdoutLine(child, sessionHeader());
350
+ for (let i = 1; i <= 5; i++) {
351
+ child.stdout.write(`flood-${i}\n`);
352
+ }
353
+ emitStdoutLine(child, { type: "turn_end" });
354
+ child.stdout.end();
355
+ child.emit("close", 0);
356
+
357
+ const result = await promise;
358
+ expect(result.success).toBe(true);
359
+
360
+ const debugMsgs = loggerMock.debug.mock.calls.map((c) => String(c[0]));
361
+ const sampleMsgs = debugMsgs.filter((m) => m.includes("stdout invalid line #"));
362
+ expect(sampleMsgs).toHaveLength(3); // MAX_INVALID_LINE_SAMPLES=3,第 4/5 条仅累计
363
+ const aggMsgs = debugMsgs.filter((m) => m.includes("invalid line(s) dropped in total"));
364
+ expect(aggMsgs).toHaveLength(1);
365
+ expect(aggMsgs[0]).toContain("5 invalid line(s)"); // 聚合不丢全量计数
366
+ });
367
+
368
+ it("[LC-9] 残留尾行为 invalid(无 \\n 结尾的损坏行)→ close handler 同计统计", async () => {
369
+ const record = makeRecord();
370
+ const promise = runSpawn(record, "Task: tail-invalid", makeOpts(), makeCtx());
371
+
372
+ await waitForSpawn();
373
+ const child = lastSpawnedChild();
374
+
375
+ emitStdoutLine(child, sessionHeader());
376
+ // 无 \n 的损坏行:同步 emit(同上方残留用例的时序原理)残留在 stdoutBuffer,
377
+ // 由 close handler 的 processTrailingLine 再 parse。
378
+ child.stdout.emit("data", "not-json-tail");
379
+ child.emit("close", 0);
380
+
381
+ const result = await promise;
382
+ expect(result.success).toBe(true);
383
+
384
+ const debugMsgs = loggerMock.debug.mock.calls.map((c) => String(c[0]));
385
+ const aggMsgs = debugMsgs.filter((m) => m.includes("invalid line(s) dropped in total"));
386
+ expect(aggMsgs).toHaveLength(1);
387
+ expect(aggMsgs[0]).toContain("1 invalid line(s)");
388
+ expect(aggMsgs[0]).toContain("not-json-tail"); // 聚合携带样本
389
+ });
390
+
288
391
  it("stdout 夹杂合法 JSON 但缺 type 字段 → 该行被忽略,不抛错", async () => {
289
392
  const record = makeRecord();
290
393
  const promise = runSpawn(record, "Task: notype", makeOpts(), makeCtx());
@@ -457,12 +560,12 @@ describe("runSpawn", () => {
457
560
  }
458
561
  });
459
562
 
460
- // [MF-4] count>0 分支的动态超时:等待超时 = computeWatchdogMs(maxTurns),非固定 2h。
563
+ // [MF-4] count>0 分支的动态超时:等待超时 = maxTurnsToWatchdogMs(maxTurns),非固定 2h。
461
564
  // maxTurns=20 → 20×5min = 100min 到期 kill。若回归到固定 2h 常量,100min 处不会 kill,
462
- // 本用例 advance(computeWatchdogMs − 1) 后仍不 kill、再 +1 才 kill 的断言会失败。
463
- it("MF-4: agent_end(count>0)→ 等待超时 = computeWatchdogMs(maxTurns)(动态,非固定 2h)", async () => {
565
+ // 本用例 advance(maxTurnsToWatchdogMs − 1) 后仍不 kill、再 +1 才 kill 的断言会失败。
566
+ it("MF-4: agent_end(count>0)→ 等待超时 = maxTurnsToWatchdogMs(maxTurns)(动态,非固定 2h)", async () => {
464
567
  const maxTurns = 20;
465
- const expected = computeWatchdogMs(maxTurns);
568
+ const expected = maxTurnsToWatchdogMs(maxTurns);
466
569
  mockPending.mockReturnValue({ count: 2 });
467
570
  const record = makeRecord();
468
571
  const promise = runSpawn(record, "Task: slow-desc", makeOpts({ maxTurns }), makeCtx());
@@ -498,10 +601,13 @@ describe("runSpawn", () => {
498
601
  }
499
602
  });
500
603
 
501
- // [MF-4b] 预算语义对齐:maxTurns 未传且无兑底 env → count>0 分支不 re-arm watchdog,
502
- // 不限时等待后代。若误回旧 50min 估算默认,51min 处会 kill,本用例失败。
503
- it("MF-4b: agent_end(count>0)+ maxTurns 未传 → 不 re-arm watchdog(不限时等待)", async () => {
504
- // hermetic:确保兑底 env 未设(若外层 shell 误设会让「不限时」断言失效)
604
+ // [MF-4b] 预算语义对齐 + [T2-① / P-T2 降级 B]:maxTurns 未传且无兑底 env → count>0
605
+ // 分支不 re-arm 固定时长 watchdog,改挂**无进展检测上界**——连续静默达
606
+ // KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS 才处置,期间任何 stdout 活动刷新计时。
607
+ // 原守护意图保留:若误回旧 50min 估算默认(固定时长、不刷新),活动刷新后 51min
608
+ // 处早已 kill,本用例失败。
609
+ it("MF-4b: agent_end(count>0)+ maxTurns 未传 → 无进展检测上界(活动刷新不限时,纯静默有界)", async () => {
610
+ // hermetic:确保兑底 env 未设(若外层 shell 误设会让「裸缺省」断言失效)
505
611
  const prevWatchdogEnv = process.env[SPAWN_WATCHDOG_ENV];
506
612
  delete process.env[SPAWN_WATCHDOG_ENV];
507
613
  mockPending.mockReturnValue({ count: 2 });
@@ -519,10 +625,32 @@ describe("runSpawn", () => {
519
625
  // keep alive:不 kill
520
626
  expect(child.killed).toBe(false);
521
627
 
522
- // 超过旧 50min 估算默认仍不 kill = watchdog 未 re-arm
523
- await vi.advanceTimersByTimeAsync(51 * 60 * 1000);
628
+ // 静默 29min:未达无进展阈值,不 kill
629
+ await vi.advanceTimersByTimeAsync(KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS - 60_000);
524
630
  expect(child.killed).toBe(false);
525
631
 
632
+ // 子进程活动(输出一行):静默计时刷新。若误回旧 50min 估算默认(固定时长
633
+ // 不刷新),此点之后不久会 kill——51min 断言守护该回归
634
+ emitStdoutLine(child, { type: "message_end", message: {} });
635
+ await new Promise((r) => setImmediate(r));
636
+ await vi.advanceTimersByTimeAsync(KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS - 60_000);
637
+ expect(child.killed).toBe(false);
638
+
639
+ // 再次活动刷新(此刻距上次活动 29min < 30min 阈值),随后静默 21min——总时钟
640
+ // 已 >51min 仍不 kill = 无固定时长上限(原守护点:误回旧 50min 估算默认则此处
641
+ // 已 kill);距最近一次活动静默 21min < 30min 阈值。
642
+ emitStdoutLine(child, { type: "message_end", message: {} });
643
+ await new Promise((r) => setImmediate(r));
644
+ await vi.advanceTimersByTimeAsync(21 * 60 * 1000);
645
+ expect(child.killed).toBe(false);
646
+
647
+ // 连续静默达阈值(最后一次活动后 30min 无任何输出):有界回收(新契约)。
648
+ // 本段 advance 跨过 SIGTERM 后的 30s 升级点(escalation 语义:FakeChild 未退出),
649
+ // killSignal 已升级为 SIGKILL。
650
+ await vi.advanceTimersByTimeAsync(KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS);
651
+ expect(child.killed).toBe(true);
652
+ expect(child.killSignal).toBe("SIGKILL");
653
+
526
654
  child.stdout.end();
527
655
  child.stderr.end();
528
656
  child.emit("close", 143);
@@ -537,11 +665,11 @@ describe("runSpawn", () => {
537
665
 
538
666
  // [S-9] pending.error 分支(sessionFile 不可读 → 保守 keep-alive + re-arm dynamic watchdog)
539
667
  // 集成行为 guard:session-pending 单测覆盖 error 返回值,但 session-runner 的 no-kill +
540
- // re-arm 到 computeWatchdogMs(maxTurns) 行为无集成 guard。若 re-arm 误删/误用固定超时,
668
+ // re-arm 到 maxTurnsToWatchdogMs(maxTurns) 行为无集成 guard。若 re-arm 误删/误用固定超时,
541
669
  // 保守 keep-alive 会退化成永久挂起或被固定超时误杀。
542
670
  it("S-9: agent_end(count=0 + error)→ 保守不 kill + watchdog re-arm 到动态超时", async () => {
543
671
  const maxTurns = 20;
544
- const expected = computeWatchdogMs(maxTurns);
672
+ const expected = maxTurnsToWatchdogMs(maxTurns);
545
673
  mockPending.mockReturnValue({ count: 0, error: "session file unreadable: EACCES" });
546
674
  const record = makeRecord();
547
675
  const promise = runSpawn(record, "Task: unreadable", makeOpts({ maxTurns }), makeCtx());
@@ -557,7 +685,7 @@ describe("runSpawn", () => {
557
685
  // 保守策略:sessionFile 不可读时不 kill(宁可空等也不误杀有后代的进程)
558
686
  expect(child.killed).toBe(false);
559
687
 
560
- // watchdog re-arm 到动态超时(computeWatchdogMs(maxTurns)),未到期不 kill
688
+ // watchdog re-arm 到动态超时(maxTurnsToWatchdogMs(maxTurns)),未到期不 kill
561
689
  await vi.advanceTimersByTimeAsync(expected - 1);
562
690
  expect(child.killed).toBe(false);
563
691
 
@@ -684,4 +812,266 @@ describe("runSpawn", () => {
684
812
  expect(record.turnCount).toBe(1);
685
813
  });
686
814
  });
815
+
816
+ // ── 4. [T1/RC-1+RC-2] agent_end 惰性回补 + [T1/LC-4] 收尾反查 ──
817
+ //
818
+ // RC-1:RPC mode 的 get_state 握手一次性(7s 预算耗尽永不再试),sessionFile 成为
819
+ // 永久缺失。T1 双管:
820
+ // ① agent_end 决策点发现 sessionFile 缺失 → 现场单次 get_state(idle 子进程毫秒级
821
+ // 应答,探针 P-T1)回填后走正常三分支;
822
+ // ② 回补失败不重试 → 既有保守分支(行为不劣化);
823
+ // ③ close 收尾 findSessionFileByHeaderId 兜底移出 if (record.sessionFile) 守卫——
824
+ // 「sessionId 有、sessionFile 无」形态(两字段独立采集)也做 sessionDir 反查。
825
+ //
826
+ // 测试形态:不 emit header(RPC mode)→ record.sessionFile 只能靠 get_state 回填。
827
+ // stdin 上第 1 个 get_state 是 spawn 握手(测试不回应 = 握手失败),第 2 个是 agent_end
828
+ // 惰性回补(按用例决定回应与否)。
829
+ describe("[T1] agent_end 惰性回补 + LC-4 收尾反查", () => {
830
+ const mockPending = vi.mocked(readActivePendingFromSessionFile);
831
+ const mockWriteAliveMarker = vi.mocked(writeAliveMarker);
832
+ const mockReaddirSync = vi.mocked(fs.readdirSync);
833
+
834
+ /** 统计 stdin 收到的 get_state 请求数(握手 1 次 + agent_end 惰性回补 1 次)。 */
835
+ function countGetStateRequests(child: FakeChild): { seen: number } {
836
+ const counter = { seen: 0 };
837
+ child.stdin.on("data", (data: Buffer | string) => {
838
+ const text = typeof data === "string" ? data : data.toString();
839
+ for (const line of text.split("\n")) {
840
+ if (!line.trim()) continue;
841
+ try {
842
+ if ((JSON.parse(line) as { type?: string }).type === "get_state") counter.seen++;
843
+ } catch {
844
+ // prompt 命令等非 JSON 行忽略
845
+ }
846
+ }
847
+ });
848
+ return counter;
849
+ }
850
+
851
+ /** 只对第 N 次(1-based)get_state 回应 response(其余不回应 = 握手失败)。 */
852
+ function respondToNthGetState(
853
+ child: FakeChild,
854
+ nth: number,
855
+ data: Record<string, unknown>,
856
+ ): void {
857
+ let seen = 0;
858
+ child.stdin.on("data", (chunk: Buffer | string) => {
859
+ const text = typeof chunk === "string" ? chunk : chunk.toString();
860
+ for (const line of text.split("\n")) {
861
+ if (!line.trim()) continue;
862
+ let cmd: { type?: string; id?: string };
863
+ try {
864
+ cmd = JSON.parse(line) as { type?: string; id?: string };
865
+ } catch {
866
+ continue;
867
+ }
868
+ if (cmd.type !== "get_state" || !cmd.id) continue;
869
+ seen++;
870
+ if (seen === nth) {
871
+ emitStdoutLine(child, {
872
+ type: "response",
873
+ command: "get_state",
874
+ success: true,
875
+ id: cmd.id,
876
+ data,
877
+ });
878
+ }
879
+ }
880
+ });
881
+ }
882
+
883
+ /** 轮询等待条件(真实 timers;vi.waitFor 在本 vitest 版本下偶发过早,见 spawn-mock 注释)。 */
884
+ async function waitFor(cond: () => boolean, timeoutMs = 2000): Promise<void> {
885
+ const deadline = Date.now() + timeoutMs;
886
+ while (!cond()) {
887
+ if (Date.now() > deadline) throw new Error("condition not met within timeout");
888
+ await new Promise((r) => setTimeout(r, 10));
889
+ }
890
+ }
891
+
892
+ // ① 握手失败(sessionFile 空)→ agent_end 惰性 get_state 回填 → 正常三分支(final kill)
893
+ it("① 握手失败 sessionFile 缺失 → agent_end 惰性 get_state 回填 → 无后代走 final kill(非保守不杀)", async () => {
894
+ // 回补成功后判定:无活跃后代 → final kill 分支(若仍走保守分支则不会 kill,断言失败)
895
+ mockPending.mockReturnValue({ count: 0 });
896
+ const record = makeRecord();
897
+ const promise = runSpawn(record, "Task: lazy-backfill", makeOpts(), makeCtx());
898
+
899
+ await waitForSpawn();
900
+ const child = lastSpawnedChild();
901
+ const counter = countGetStateRequests(child);
902
+ // 第 1 个 get_state(spawn 握手)不回应;第 2 个(agent_end 惰性回补)回应完整数据
903
+ const expectedSessionFile =
904
+ "/tmp/test/agents/subagents/--tmp-test--/sessions/lazy-backfill.jsonl";
905
+ respondToNthGetState(child, 2, {
906
+ sessionFile: expectedSessionFile,
907
+ sessionId: "lazy-sess",
908
+ });
909
+
910
+ // 不 emit header(RPC mode 形态):agent_end 时 record.sessionFile 缺失
911
+ emitStdoutLine(child, { type: "agent_end", messages: [], willRetry: false });
912
+
913
+ // 决策点异步:惰性 get_state → 回填 → readActivePending(count=0) → final kill
914
+ await waitFor(() => child.killed);
915
+ expect(child.killSignal).toBe("SIGTERM");
916
+
917
+ // 回填落地:record.sessionFile + alive marker(对齐 finishHandshake 回填面)
918
+ expect(record.sessionFile).toBe(expectedSessionFile);
919
+ expect(mockWriteAliveMarker).toHaveBeenCalledWith(
920
+ expectedSessionFile,
921
+ expect.objectContaining({ pid: child.pid, id: "lazy-sess" }),
922
+ );
923
+ // 惰性请求确实发出:握手 1 次 + agent_end 惰性 1 次
924
+ expect(counter.seen).toBeGreaterThanOrEqual(2);
925
+
926
+ // 收尾:close 让 runSpawn resolve
927
+ child.stdout.end();
928
+ child.stderr.end();
929
+ child.emit("close", 143);
930
+ const result = await promise;
931
+ expect(result.success).toBe(true);
932
+ expect(result.sessionFile).toBe(expectedSessionFile);
933
+ });
934
+
935
+ // ② 回补超时/失败 → 仍走保守分支(行为不劣化)
936
+ it("② 回补无响应超时 → record.sessionFile 仍缺失 → 保守不杀(不劣化为 final kill)", async () => {
937
+ // mock 判定:sessionFile 缺失形态返回 error(真实现语义,见 session-pending.ts:85)
938
+ mockPending.mockReturnValue({ count: 0, error: "no sessionFile (handshake not settled)" });
939
+ const record = makeRecord();
940
+ const promise = runSpawn(record, "Task: lazy-timeout", makeOpts(), makeCtx());
941
+
942
+ await waitForSpawn();
943
+ const child = lastSpawnedChild();
944
+ const counter = countGetStateRequests(child);
945
+
946
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
947
+ try {
948
+ emitStdoutLine(child, { type: "agent_end", messages: [], willRetry: false });
949
+ // stdin flush 依赖真实事件循环(setImmediate 不 fake):决策点已发出惰性 get_state
950
+ await new Promise((r) => setImmediate(r));
951
+ // 惰性请求已发出(握手 1 + 惰性 1),但无人回应
952
+ expect(counter.seen).toBe(2);
953
+ expect(child.killed).toBe(false);
954
+
955
+ // 1s 回补预算到期:空结果 → 不重试 → readActivePending error → 保守分支
956
+ await vi.advanceTimersByTimeAsync(1000);
957
+ expect(record.sessionFile).toBeUndefined();
958
+ expect(child.killed).toBe(false); // 保守不杀
959
+
960
+ // maxTurns 未传(opts 默认)+ env 未设 → 保守分支挂无进展检测上界
961
+ // [T2-① / P-T2 降级 B] 不再「不限时」:连续静默达阈值才处置;此处静默
962
+ // 29min(< 30min 阈值)不 kill——保守 keep-alive 仍有界但不被时长误杀
963
+ await vi.advanceTimersByTimeAsync(KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS - 60_000);
964
+ expect(child.killed).toBe(false);
965
+
966
+ child.stdout.end();
967
+ child.stderr.end();
968
+ child.emit("close", 143);
969
+ const result = await promise;
970
+ // 进程最终由 close 收尾(保守分支不吞掉 runSpawn 的完成)
971
+ expect(result.success).toBe(true);
972
+ expect(record.sessionFile).toBeUndefined();
973
+ } finally {
974
+ vi.useRealTimers();
975
+ }
976
+ });
977
+
978
+ // ③ [T1/LC-4] sessionId 有 + sessionFile 无 → close 收尾 findSessionFileByHeaderId 可达
979
+ // 形态说明:握手只回 sessionId 时握手自身 7s 预算耗尽才 settle(其 timer 创建于
980
+ // useFakeTimers 之前,fake advance 驱不动)——本用例经 agent_end 惰性回补只回
981
+ // sessionId,同产「handshakeResult.sessionId 有、record.sessionFile 无」形态,
982
+ // 且顺带覆盖惰性回补的 sessionId 补入 handshakeResult 分支(close 收尾 lookupId 源)。
983
+ it("③ LC-4: 惰性回补只回 sessionId(sessionFile 仍无)→ close 收尾 sessionDir 反查修正 record.sessionFile", async () => {
984
+ // sessionFile 缺失 → 决策点惰性回补;回补判定保守(error → 不杀)
985
+ mockPending.mockReturnValue({ count: 0, error: "no sessionFile (handshake not settled)" });
986
+ const record = makeRecord();
987
+ const promise = runSpawn(record, "Task: lc4-lookup", makeOpts(), makeCtx());
988
+
989
+ await waitForSpawn();
990
+ const child = lastSpawnedChild();
991
+ // 第 2 个 get_state(惰性回补)只回 sessionId(两字段独立采集的 RC-1 残余形态)
992
+ respondToNthGetState(child, 2, { sessionId: "sess-lc4" });
993
+
994
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
995
+ try {
996
+ emitStdoutLine(child, { type: "agent_end", messages: [], willRetry: false });
997
+ // flush:惰性 get_state 写出 + response 回流 → 回补拿到 sessionId(无 sessionFile)
998
+ await new Promise((r) => setImmediate(r));
999
+ await new Promise((r) => setImmediate(r));
1000
+ await vi.advanceTimersByTimeAsync(1000); // 回补预算到期兜底(response 已先行到达)
1001
+
1002
+ // 保守分支:sessionFile 仍缺失 → 不杀(LC-4 修复前的死路:无任何反查可达)
1003
+ expect(record.sessionFile).toBeUndefined();
1004
+ expect(child.killed).toBe(false);
1005
+
1006
+ // 兜底查找的目录命中项:文件名格式 <timestamp>_<sessionId>.jsonl
1007
+ const ctx = makeCtx();
1008
+ const sessionDir = getSubagentSessionDir(ctx.agentDir, ctx.rootCwd);
1009
+ const matchedName = "20260901T12000000_sess-lc4.jsonl";
1010
+ mockReaddirSync.mockReturnValue([matchedName, "unrelated_other.jsonl"]);
1011
+
1012
+ // close 收尾:lookupId = handshakeResult.sessionId(惰性回补补入)→ 反查命中
1013
+ child.stdout.end();
1014
+ child.stderr.end();
1015
+ child.emit("close", 0);
1016
+ await promise;
1017
+
1018
+ const expected = path.join(sessionDir, matchedName);
1019
+ expect(record.sessionFile).toBe(expected);
1020
+ // findSessionFileByHeaderId 真实执行过(readdirSync 收到 sessionDir)
1021
+ expect(mockReaddirSync).toHaveBeenCalledWith(sessionDir);
1022
+ } finally {
1023
+ vi.useRealTimers();
1024
+ }
1025
+ });
1026
+
1027
+ // ④ [A1-3] 回补 await 的异步窗口内 close 竞态:continuation 在三分支前复核进程生死,
1028
+ // 已死直接 return——否则 keep-alive 分支 re-arm 泄漏 timer + touch marker 向死 pid
1029
+ // 写心跳。本用例让回补窗口(1s 预算)内进程死亡,回补超时 continuation 恢复时
1030
+ // 进程已 close 收尾:断言无 marker 写入、无任何新 kill 信号。
1031
+ it("④ A1-3: 回补等待窗口内 kill → close → continuation 不触达三分支(不 touch marker / 不挂 timer)", async () => {
1032
+ // 若守卫缺失(回归),continuation 会以 count=1 走 keep-alive 分支:
1033
+ // touchAliveMarkerForHeartbeat 写 marker + 裸缺省 arm 无进展 timer(30min 后 kill)
1034
+ mockPending.mockReturnValue({ count: 1 });
1035
+ const record = makeRecord();
1036
+ const promise = runSpawn(record, "Task: lazy-race-close", makeOpts(), makeCtx());
1037
+
1038
+ await waitForSpawn();
1039
+ const child = lastSpawnedChild();
1040
+ const killSpy = vi.spyOn(child, "kill");
1041
+
1042
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
1043
+ try {
1044
+ // 不 emit header(RPC mode 形态)→ record.sessionFile 缺失 → agent_end 触发惰性回补
1045
+ emitStdoutLine(child, { type: "agent_end", messages: [], willRetry: false });
1046
+ // stdin flush 依赖真实事件循环:惰性 get_state 已写出,continuation 阻塞在 await
1047
+ await new Promise((r) => setImmediate(r));
1048
+ expect(child.killed).toBe(false);
1049
+
1050
+ // await 窗口内进程死亡:kill + close(真 ChildProcess close 后 exitCode 非 null,
1051
+ // FakeChild 需手动赋值模拟该语义——A1-3 守卫读取的就是它)
1052
+ child.kill("SIGTERM");
1053
+ child.exitCode = 143;
1054
+ child.stdout.end();
1055
+ child.stderr.end();
1056
+ child.emit("close", 143);
1057
+ await promise; // close 收尾完成(timer 清理 / 句柄移除)
1058
+
1059
+ // 给 record 补 sessionFile:让回归路径(守卫缺失时 continuation 触达 keep-alive
1060
+ // 分支)的 touchAliveMarkerForHeartbeat 可观测(无 sessionFile 它会提前 no-op)
1061
+ record.sessionFile = "/tmp/fake-race-session.jsonl";
1062
+
1063
+ // 回补预算到期 → continuation 恢复:应命中 A1-3 守卫直接 return
1064
+ await vi.advanceTimersByTimeAsync(1000);
1065
+
1066
+ // 无 marker 写入(守卫缺失时 touchAliveMarkerForHeartbeat 会写)
1067
+ expect(mockWriteAliveMarker).not.toHaveBeenCalled();
1068
+ // 无任何 timer 触发的 kill(守卫缺失时无进展 timer 30min 后会二次 kill)
1069
+ await vi.advanceTimersByTimeAsync(KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS + WAKEUP_GRACE_MS);
1070
+ expect(killSpy).toHaveBeenCalledTimes(1); // 仅手动 kill 那一次
1071
+ expect(child.killSignal).toBe("SIGTERM");
1072
+ } finally {
1073
+ vi.useRealTimers();
1074
+ }
1075
+ });
1076
+ });
687
1077
  });
@@ -649,7 +649,7 @@ describe("runSpawn", () => {
649
649
  it("watchdog 超时(>30min)→ child.kill(SIGTERM) 被调用", async () => {
650
650
  const record = makeRecord();
651
651
  // 不 await——runSpawn 内部 await 子进程 close,watchdog 触发 kill 后还需 emit close 才 resolve
652
- // [M-1] maxTurns=6 使 computeWatchdogMs 返回下限 30min(max(30min, 6*5min) = 30min)
652
+ // [M-1] maxTurns=6 使 maxTurnsToWatchdogMs 返回下限 30min(max(30min, 6*5min) = 30min)
653
653
  const promise = runSpawn(record, "Task: hang", makeOpts({ maxTurns: 6 }), makeCtx());
654
654
 
655
655
  const child = await waitForSpawnFake();
@@ -61,11 +61,15 @@ vi.mock("node:fs", async () => {
61
61
 
62
62
  vi.mock("../alive-store.ts", () => ({
63
63
  writeAliveMarker: vi.fn(),
64
+ // [T5②] keep-alive 心跳刷新读取现有 marker id(缺失兜底 record.id)
65
+ readAliveMarker: vi.fn(() => undefined),
66
+ isProcessAlive: vi.fn(() => false),
64
67
  }));
65
68
 
66
69
  // 场景 ① 依赖 keep-alive 分支命中:count=1(有活跃后代 → 不 kill → 重挂 watchdog)
67
70
  vi.mock("../session-pending.ts", () => ({
68
71
  readActivePendingFromSessionFile: vi.fn(() => ({ count: 1 })),
72
+ prunePendingCursor: vi.fn(),
69
73
  }));
70
74
 
71
75
  vi.mock("../temp-prompt.ts", () => ({
@@ -163,7 +167,7 @@ describe("[F-R2] stdout 回调链内 fail-fast throw 不逃逸", () => {
163
167
  }
164
168
  });
165
169
 
166
- it("② chatMode agent_settled armIdleTimer throw → 降级不挂 idle timer,onRoundSettled 照常、提前 resolve", async () => {
170
+ it("② chatMode agent_settled armIdleTimer throw → 降级挂 DEFAULT idle timer + warn,onRoundSettled 照常、提前 resolve", async () => {
167
171
  const record = makeChatModeRecord("sa-f2-idle");
168
172
  const onRoundSettled = vi.fn();
169
173
  const ctx: Partial<SessionRunnerContext> = { onRoundSettled };
@@ -176,18 +180,22 @@ describe("[F-R2] stdout 回调链内 fail-fast throw 不逃逸", () => {
176
180
  emitStdoutLine(child, { type: "agent_settled" });
177
181
  await new Promise((r) => setTimeout(r, 20));
178
182
 
179
- // 降级语义:idle timer 未挂(throw 发生在 arm 入口校验,先于 setTimeout)
180
- expect(hasIdleTimer(record.id)).toBe(false);
183
+ // [T4② / PS-4] 降级语义修正:旧「不挂」(丢通知放行门 + 进程回收)改为
184
+ // 「挂 DEFAULT_IDLE_TIMEOUT_MS + warn」——防御性兜底保住两个不变量且可见
185
+ expect(hasIdleTimer(record.id)).toBe(true);
181
186
  expect(child.killed).toBe(false);
182
187
 
183
188
  // catch 后续语句照常执行:本轮完成通知不因 GC timer 故障丢失
184
189
  expect(onRoundSettled).toHaveBeenCalledTimes(1);
185
190
 
186
- // 错误可见
191
+ // 错误可见(原失败留痕)+ 降级动作可见(T4②)
187
192
  expect(loggerMock.error).toHaveBeenCalledWith(
188
193
  expect.stringContaining("armIdleTimer"),
189
194
  expect.objectContaining({ detail: expect.stringContaining("exceeds the Node setTimeout limit") }),
190
195
  );
196
+ expect(loggerMock.warn).toHaveBeenCalledWith(
197
+ expect.stringContaining("fell back to DEFAULT_IDLE_TIMEOUT_MS"),
198
+ );
191
199
 
192
200
  // 收尾:chatMode 首轮 agent_settled 已 resolveRun(0),close 后 runSpawn 正常返回
193
201
  child.stdout.end();
@@ -0,0 +1,89 @@
1
+ // src/execution/__tests__/service-kill-escalation.test.ts
2
+ //
3
+ // [u-svc / T2④ / LC-2] killRecordChildWithEscalation 真实现行为(服务侧 kill 收敛入口,
4
+ // 无 SpawnRunState 调用方):
5
+ // - child 不在 spawnedChildren / 已 killed → no-op(与旧 `child && !child.killed` 守卫
6
+ // 语义逐字对齐);
7
+ // - SIGTERM 后 30s 未见 exit → SIGKILL 升级(SIGTERM 被无视的幽灵进程防线);
8
+ // - exit 到达 → 升级 timer 自动清除;
9
+ // - 同 record 重复调用先清旧升级 timer(防叠加)。
10
+
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+
13
+ import { FakeChild } from "./helpers/spawn-mock.ts";
14
+ import {
15
+ _resetServiceKillStateForTest,
16
+ killRecordChildWithEscalation,
17
+ spawnedChildren,
18
+ } from "../session-runner.ts";
19
+
20
+ const SIGKILL_ESCALATION_MS = 30_000;
21
+
22
+ describe("T2④ killRecordChildWithEscalation", () => {
23
+ beforeEach(() => {
24
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
25
+ _resetServiceKillStateForTest();
26
+ spawnedChildren.clear();
27
+ });
28
+
29
+ afterEach(() => {
30
+ vi.useRealTimers();
31
+ vi.restoreAllMocks();
32
+ _resetServiceKillStateForTest();
33
+ spawnedChildren.clear();
34
+ });
35
+
36
+ it("is a no-op when the record has no registered child", () => {
37
+ expect(() => killRecordChildWithEscalation("sa-missing", "test")).not.toThrow();
38
+ });
39
+
40
+ it("is a no-op when the child already received a kill request (escalation owned by prior path)", () => {
41
+ const child = new FakeChild();
42
+ child.killed = true;
43
+ const killSpy = vi.spyOn(child, "kill");
44
+ spawnedChildren.set("sa-killed", child);
45
+ killRecordChildWithEscalation("sa-killed", "test");
46
+ expect(killSpy).not.toHaveBeenCalled();
47
+ });
48
+
49
+ it("sends SIGTERM and escalates to SIGKILL after 30s without exit", async () => {
50
+ const child = new FakeChild();
51
+ const killSpy = vi.spyOn(child, "kill");
52
+ spawnedChildren.set("sa-escalate", child);
53
+
54
+ killRecordChildWithEscalation("sa-escalate", "closeChatIdle");
55
+ expect(killSpy).toHaveBeenNthCalledWith(1, "SIGTERM");
56
+
57
+ await vi.advanceTimersByTimeAsync(SIGKILL_ESCALATION_MS - 1);
58
+ expect(killSpy).toHaveBeenCalledTimes(1); // 未到期不升级
59
+
60
+ await vi.advanceTimersByTimeAsync(1);
61
+ expect(killSpy).toHaveBeenNthCalledWith(2, "SIGKILL");
62
+ });
63
+
64
+ it("clears the escalation timer when the child exits in time", async () => {
65
+ const child = new FakeChild();
66
+ const killSpy = vi.spyOn(child, "kill");
67
+ spawnedChildren.set("sa-exit", child);
68
+
69
+ killRecordChildWithEscalation("sa-exit", "cancelBackground");
70
+ child.emit("exit", 0, null);
71
+ await vi.advanceTimersByTimeAsync(SIGKILL_ESCALATION_MS + 1_000);
72
+ expect(killSpy).toHaveBeenCalledTimes(1); // 只有 SIGTERM,无 SIGKILL
73
+ });
74
+
75
+ it("repeated calls clear the previous escalation timer (no stacking)", async () => {
76
+ const child = new FakeChild();
77
+ const killSpy = vi.spyOn(child, "kill");
78
+ spawnedChildren.set("sa-repeat", child);
79
+
80
+ killRecordChildWithEscalation("sa-repeat", "first");
81
+ await vi.advanceTimersByTimeAsync(10_000);
82
+ // 第二次调用前 child.killed 已为 true(首次 SIGTERM 置位)→ no-op 路径
83
+ killRecordChildWithEscalation("sa-repeat", "second");
84
+ expect(killSpy).toHaveBeenCalledTimes(1);
85
+ await vi.advanceTimersByTimeAsync(SIGKILL_ESCALATION_MS);
86
+ expect(killSpy).toHaveBeenCalledTimes(2); // 首次 timer 正常触发(未被重复调用清掉)
87
+ expect(killSpy).toHaveBeenNthCalledWith(2, "SIGKILL");
88
+ });
89
+ });