@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
@@ -0,0 +1,378 @@
1
+ // src/execution/__tests__/keep-alive-no-progress.test.ts
2
+ //
3
+ // [T2-① / P-T2 降级路径 B] keep-alive 裸缺省无进展检测上界。
4
+ //
5
+ // 设计:docs/design/subagent-core-unbounded-wait-audit.md §7.2 T2-① + impl-plan §5
6
+ // P-T2 裁决(probe/p-t2-report.md):历史 89 样本 96.6% keep-alive 窗口 >30min、
7
+ // 长尾 95.5h 合法(parent-shutdown 收敛)——固定 30min 上限被数据否定,按设计降级
8
+ // 路径 B 落地为无进展检测语义:
9
+ // - 裸缺省(无 maxTurns 无 env):keep-alive 期间任何子进程 stdout 活动刷新计时,
10
+ // 连续静默达 KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS(30min)且 fire 时惰性复核无存活
11
+ // 后代(A1-2)才处置;
12
+ // - 显式 maxTurns / env:行为不变(既有固定时长等待,不挂无进展 timer——opt-out
13
+ // 语义保留,A1-1:显式 maxTurns<=0 与 fail-fast 降级也不挂)。
14
+ //
15
+ // mock 布局与 run-spawn-edges.test.ts 一致(FakeChild + mock session-pending)。
16
+
17
+ import { spawn } from "node:child_process";
18
+ import * as fs from "node:fs";
19
+
20
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
21
+
22
+ // [A1-2③] Mock 共享 logger:复核失败的 warn 留痕可被断言(对齐 run-spawn-edges 模式)。
23
+ const { loggerMock } = vi.hoisted(() => ({
24
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
25
+ }));
26
+ vi.mock("../../core/logger.ts", () => ({
27
+ getLogger: () => loggerMock,
28
+ }));
29
+
30
+ vi.mock("node:child_process", async () => {
31
+ const { FakeChild } = await import("./helpers/spawn-mock.ts");
32
+ return {
33
+ spawn: vi.fn(() => new FakeChild()),
34
+ execFile: vi.fn(
35
+ (
36
+ _cmd: string,
37
+ _args: readonly string[],
38
+ _opts: unknown,
39
+ cb: (err: Error | null, stdout?: string, stderr?: string) => void,
40
+ ) => cb(new Error("execFile not configured in this test")),
41
+ ),
42
+ };
43
+ });
44
+
45
+ vi.mock("node:fs", async () => {
46
+ const actual = await import("node:fs");
47
+ return {
48
+ default: {
49
+ ...actual,
50
+ mkdirSync: vi.fn(),
51
+ existsSync: vi.fn(() => false),
52
+ appendFileSync: vi.fn(),
53
+ writeFileSync: vi.fn(),
54
+ readdirSync: vi.fn(() => []),
55
+ },
56
+ mkdirSync: vi.fn(),
57
+ existsSync: vi.fn(() => false),
58
+ appendFileSync: vi.fn(),
59
+ writeFileSync: vi.fn(),
60
+ readdirSync: vi.fn(() => []),
61
+ promises: actual.promises,
62
+ };
63
+ });
64
+
65
+ vi.mock("../alive-store.ts", () => ({
66
+ writeAliveMarker: vi.fn(),
67
+ // [T5②] keep-alive 心跳刷新读取现有 marker id(缺失兜底 record.id)
68
+ readAliveMarker: vi.fn(() => undefined),
69
+ isProcessAlive: vi.fn(() => false),
70
+ }));
71
+
72
+ // keep-alive 判定:本文件统一 count>0(有活跃后代 → keep-alive 分支)。
73
+ vi.mock("../session-pending.ts", () => ({
74
+ readActivePendingFromSessionFile: vi.fn(() => ({ count: 1, recentUnregister: false })),
75
+ prunePendingCursor: vi.fn(),
76
+ listActivePendingFromSessionFile: vi.fn(() => ({ items: [] })),
77
+ }));
78
+
79
+ vi.mock("../temp-prompt.ts", () => ({
80
+ writePromptToTempFile: vi.fn(async (agent: string) => {
81
+ const safeName = agent.replace(/[^\w.-]+/g, "_");
82
+ return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt-${safeName}.md` };
83
+ }),
84
+ cleanupTempPrompt: vi.fn(async () => {}),
85
+ }));
86
+
87
+ import {
88
+ KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS,
89
+ maxTurnsToWatchdogMs,
90
+ runSpawn,
91
+ SPAWN_WATCHDOG_ENV,
92
+ } from "../session-runner.ts";
93
+ import { listActivePendingFromSessionFile, readActivePendingFromSessionFile } from "../session-pending.ts";
94
+ import { isProcessAlive, readAliveMarker, writeAliveMarker } from "../alive-store.ts";
95
+ import {
96
+ emitStdoutLine,
97
+ type FakeChild,
98
+ lastSpawnedChild as lastSpawnedChildOf,
99
+ makeCtx,
100
+ makeOpts,
101
+ makeRecord,
102
+ waitForSpawn as waitForSpawnOf,
103
+ } from "./helpers/spawn-mock.ts";
104
+
105
+ const mockSpawn = vi.mocked(spawn);
106
+ const mockPending = vi.mocked(readActivePendingFromSessionFile);
107
+ const mockListPending = vi.mocked(listActivePendingFromSessionFile);
108
+ const mockReadAliveMarker = vi.mocked(readAliveMarker);
109
+ const mockWriteAliveMarker = vi.mocked(writeAliveMarker);
110
+ const mockIsProcessAlive = vi.mocked(isProcessAlive);
111
+ const mockReaddirSync = vi.mocked(fs.readdirSync);
112
+
113
+ /** A1-2 复核用例的后代 pid(存/死两形态,对齐 descendant-sweep.test.ts 数值风格)。 */
114
+ const LIVE_DESCENDANT_PID = 424_242;
115
+ const DEAD_DESCENDANT_PID = 999_999;
116
+
117
+ const lastSpawnedChild = (): FakeChild => lastSpawnedChildOf(mockSpawn);
118
+ const waitForSpawn = (timeoutMs = 1000): Promise<void> => waitForSpawnOf(mockSpawn, timeoutMs);
119
+
120
+ /** 非 chatMode 层主 + fake timers + agent_end keep-alive 落位的公共前奏。 */
121
+ async function spawnAndReachKeepAlive(opts = makeOpts()): Promise<{
122
+ child: FakeChild;
123
+ finish: (code?: number) => Promise<Awaited<ReturnType<typeof runSpawn>>>;
124
+ }> {
125
+ const record = makeRecord();
126
+ const promise = runSpawn(record, "Task: keep-alive no-progress", opts, makeCtx());
127
+ await waitForSpawn();
128
+ const child = lastSpawnedChild();
129
+ // fake timers 必须在 emit agent_end 之前启用(keep-alive timer 新建于 agent_end
130
+ // 处理器内;不 fake setImmediate——stream flush 靠真实事件循环交付,见 MF-3 先例)。
131
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
132
+ emitStdoutLine(child, sessionHeaderLine());
133
+ emitStdoutLine(child, { type: "agent_end", messages: [], willRetry: false });
134
+ await new Promise((r) => setImmediate(r));
135
+ return {
136
+ child,
137
+ finish: (code = 143) => {
138
+ child.stdout.end();
139
+ child.stderr.end();
140
+ child.emit("close", code);
141
+ return promise as Promise<Awaited<ReturnType<typeof runSpawn>>>;
142
+ },
143
+ };
144
+ }
145
+
146
+ function sessionHeaderLine(): Record<string, unknown> {
147
+ return { type: "session", id: "sess-ka", timestamp: "2026-09-01T00:00:00.000Z", cwd: "/tmp/test" };
148
+ }
149
+
150
+ describe("[T2-①] keep-alive 裸缺省无进展检测上界(P-T2 降级路径 B)", () => {
151
+ beforeEach(() => {
152
+ vi.clearAllMocks();
153
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "");
154
+ mockPending.mockReturnValue({ count: 1, recentUnregister: false });
155
+ });
156
+
157
+ afterEach(() => {
158
+ vi.restoreAllMocks();
159
+ vi.unstubAllEnvs();
160
+ vi.useRealTimers();
161
+ });
162
+
163
+ it("裸缺省:agent_end keep-alive 挂无进展 timer,连续静默 30min 触发 SIGTERM 处置", async () => {
164
+ const { child, finish } = await spawnAndReachKeepAlive();
165
+
166
+ // keep-alive 落位:进程保活
167
+ expect(child.killed).toBe(false);
168
+
169
+ // 静默 30min-1ms:未达阈值,不处置
170
+ await vi.advanceTimersByTimeAsync(KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS - 1);
171
+ expect(child.killed).toBe(false);
172
+
173
+ // 连续静默达 30min:处置(SIGTERM,升级由 killChildWithEscalation 承接)
174
+ await vi.advanceTimersByTimeAsync(1);
175
+ expect(child.killed).toBe(true);
176
+ expect(child.killSignal).toBe("SIGTERM");
177
+
178
+ const result = await finish();
179
+ expect(result.success).toBe(true); // 信号终止视为正常完成(既有语义,T2-③ 形态除外)
180
+ });
181
+
182
+ it("裸缺省:keep-alive 期间 stdout 活动刷新计时——持续输出的层主不被时长上限误杀", async () => {
183
+ const { child, finish } = await spawnAndReachKeepAlive();
184
+ expect(child.killed).toBe(false);
185
+
186
+ // 静默 29min 后子进程产生一次输出(任意行):静默计时被刷新
187
+ await vi.advanceTimersByTimeAsync(KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS - 60_000);
188
+ emitStdoutLine(child, { type: "turn_end" });
189
+ await new Promise((r) => setImmediate(r));
190
+ expect(child.killed).toBe(false);
191
+
192
+ // 再静默 29min(相对上次活动 <30min):若无刷新语义此刻已累计 58min 会被误杀
193
+ await vi.advanceTimersByTimeAsync(KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS - 60_000);
194
+ expect(child.killed).toBe(false);
195
+
196
+ // 刷新后再静默满 30min:连续静默达阈值,处置
197
+ await vi.advanceTimersByTimeAsync(60_000);
198
+ expect(child.killed).toBe(true);
199
+ expect(child.killSignal).toBe("SIGTERM");
200
+
201
+ await finish();
202
+ });
203
+
204
+ it("显式 maxTurns:行为不变——无进展 timer 不挂,30min 静默点不处置,动态超时到期才 kill", async () => {
205
+ const maxTurns = 20; // maxTurnsToWatchdogMs(20) = 100min
206
+ const { child, finish } = await spawnAndReachKeepAlive(makeOpts({ maxTurns }));
207
+
208
+ // 关键反证断言:裸缺省阈值(30min)处连续静默也不 kill——无进展 timer 未挂载
209
+ await vi.advanceTimersByTimeAsync(KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS);
210
+ expect(child.killed).toBe(false);
211
+
212
+ // 既有 MF-4 动态超时行为不变:100min 到期才 kill
213
+ await vi.advanceTimersByTimeAsync(maxTurnsToWatchdogMs(maxTurns) - KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS - 1);
214
+ expect(child.killed).toBe(false);
215
+ await vi.advanceTimersByTimeAsync(1);
216
+ expect(child.killed).toBe(true);
217
+ expect(child.killSignal).toBe("SIGTERM");
218
+
219
+ await finish();
220
+ });
221
+
222
+ it("显式 env 兑底:行为不变——按 env 绝对时限固定等待,不挂无进展 timer", async () => {
223
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "600000"); // 10min(< 30min 静默阈值,反证无进展 timer 未挂)
224
+ const { child, finish } = await spawnAndReachKeepAlive();
225
+
226
+ await vi.advanceTimersByTimeAsync(600_000);
227
+ expect(child.killed).toBe(true);
228
+ expect(child.killSignal).toBe("SIGTERM");
229
+
230
+ await finish();
231
+ });
232
+
233
+ it("keep-alive 结束(后代全部完成 → final kill)即撤无进展 timer,无残留误杀", async () => {
234
+ // 先走 keep-alive(count>0)挂无进展 timer,再被唤醒重评估为 count=0 → final kill
235
+ mockPending
236
+ .mockReturnValueOnce({ count: 1, recentUnregister: false })
237
+ .mockReturnValueOnce({ count: 0, recentUnregister: false });
238
+ const record = makeRecord();
239
+ const promise = runSpawn(record, "Task: keep-then-final", makeOpts(), makeCtx());
240
+ await waitForSpawn();
241
+ const child = lastSpawnedChild();
242
+
243
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
244
+ emitStdoutLine(child, sessionHeaderLine());
245
+ // 第一次 agent_end:keep-alive(挂无进展 timer)
246
+ emitStdoutLine(child, { type: "agent_end", messages: [], willRetry: false });
247
+ await new Promise((r) => setImmediate(r));
248
+ expect(child.killed).toBe(false);
249
+
250
+ // 第二次 agent_end:后代已完成 → final kill(keep-alive 结束,无进展 timer 同撤)
251
+ emitStdoutLine(child, { type: "agent_end", messages: [], willRetry: false });
252
+ await new Promise((r) => setImmediate(r));
253
+ expect(child.killed).toBe(true);
254
+
255
+ // 反证 timer 已撤:advance 30min 不再有任何残留 kill 副作用(进程已 close 收尾)
256
+ child.stdout.end();
257
+ child.stderr.end();
258
+ child.emit("close", 143);
259
+ const result = (await promise) as Awaited<ReturnType<typeof runSpawn>>;
260
+ expect(result.success).toBe(true);
261
+ await vi.advanceTimersByTimeAsync(KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS + 60_000);
262
+ expect(child.killSignal).toBe("SIGTERM"); // 仅 final kill 一次,无二次信号
263
+ });
264
+
265
+ // ── [A1-1] 挂载面收窄:只有裸缺省(maxTurns 未传且 env 未设)挂无进展 timer ──
266
+
267
+ it("A1-1: 显式 maxTurns:0(显式不限时)→ 不挂无进展 timer,静默超阈值不处置", async () => {
268
+ const { child, finish } = await spawnAndReachKeepAlive(makeOpts({ maxTurns: 0 }));
269
+ expect(child.killed).toBe(false);
270
+
271
+ // 无进展阈值(30min)处静默不 kill:timer 未挂(旧实现 keepAliveMs === undefined
272
+ // 即挂 timer,30min 处会误 kill——本断言守护挂载面收窄)
273
+ await vi.advanceTimersByTimeAsync(KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS);
274
+ expect(child.killed).toBe(false);
275
+
276
+ // 显式 opt-out 旧语义 = 等待后代不限时:远超阈值仍无任何 timer 处置
277
+ await vi.advanceTimersByTimeAsync(KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS * 3);
278
+ expect(child.killed).toBe(false);
279
+
280
+ await finish();
281
+ });
282
+
283
+ it("A1-1: env 已设 + maxTurns:0(参数显式压过 env,U5)→ 同样不挂无进展 timer", async () => {
284
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "600000"); // 10min
285
+ const { child, finish } = await spawnAndReachKeepAlive(makeOpts({ maxTurns: 0 }));
286
+
287
+ // 同时越过 env 时限(10min)与无进展阈值(30min)仍不 kill:显式 maxTurns<=0
288
+ // 压过 env(U5),env 固定 watchdog 与无进展 timer 均未挂
289
+ await vi.advanceTimersByTimeAsync(Math.max(KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS, 600_000) + 60_000);
290
+ expect(child.killed).toBe(false);
291
+
292
+ await finish();
293
+ });
294
+
295
+ // ── [A1-2] fire 惰性复核:stdout 静默 ≠ 无进展,存活后代是刷新信号源的另一半 ──
296
+
297
+ it("A1-2①: fire 复核有存活活跃后代 → 视为有进展重挂(30min 固定复核节奏),不处置层主", async () => {
298
+ // 后代 pending 差集非空 + pid 存活(与 descendant sweep 同源判据)
299
+ mockListPending.mockReturnValue({
300
+ items: [{ id: "bg-1", sessionId: "ka-desc-sess", type: "session" }],
301
+ });
302
+ mockReaddirSync.mockReturnValue(["20260901T000000-000_ka-desc-sess.jsonl"]);
303
+ mockReadAliveMarker.mockImplementation((f: unknown) =>
304
+ String(f).endsWith("_ka-desc-sess.jsonl")
305
+ ? { pid: LIVE_DESCENDANT_PID, id: "ka-desc-sess", startedAt: 0 }
306
+ : undefined,
307
+ );
308
+ mockIsProcessAlive.mockImplementation((pid: number) => pid === LIVE_DESCENDANT_PID);
309
+
310
+ const { child, finish } = await spawnAndReachKeepAlive();
311
+ expect(child.killed).toBe(false);
312
+
313
+ // [A1-2 补修] 清掉 agent_end 处置写点的心跳基线:只统计 fire 重挂分支随行的心跳
314
+ mockWriteAliveMarker.mockClear();
315
+
316
+ // 第 1 次连续静默 30min fire:复核发现存活后代 → 重挂不处置
317
+ //(「直接后代跑 >30min、层主静默」的合法形态,P-T2 数据 85/89 parent-shutdown)
318
+ await vi.advanceTimersByTimeAsync(KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS);
319
+ expect(child.killed).toBe(false);
320
+
321
+ // 第 2 个 30min 再 fire(固定复核节奏直到后代死光):后代仍活 → 仍不处置
322
+ await vi.advanceTimersByTimeAsync(KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS);
323
+ expect(child.killed).toBe(false);
324
+
325
+ // [A1-2 补修] 每次复核重挂随行一次心跳:合法化形态「层主静默 + 后代长跑数小时」
326
+ // 期间层主无 agent_end(原心跳写点不再触达),重挂分支是唯一心跳源——缺失则
327
+ // marker 超 1h 软超时判陈旧 → 透明重生放行双写 / 孤儿恢复误终态活 record
328
+ expect(mockWriteAliveMarker).toHaveBeenCalledTimes(2);
329
+ // 心跳写点 = 层主 sessionFile + 子进程 pid(marker 软超时基准刷新语义)
330
+ expect(mockWriteAliveMarker).toHaveBeenCalledWith(
331
+ expect.any(String),
332
+ expect.objectContaining({ pid: child.pid }),
333
+ );
334
+
335
+ await finish();
336
+ });
337
+
338
+ it("A1-2②: fire 复核后代差集非空但 pid 全部已死 → 真静默,执行处置", async () => {
339
+ mockListPending.mockReturnValue({
340
+ items: [{ id: "bg-1", sessionId: "ka-dead-sess", type: "session" }],
341
+ });
342
+ mockReaddirSync.mockReturnValue(["20260901T000000-000_ka-dead-sess.jsonl"]);
343
+ mockReadAliveMarker.mockImplementation((f: unknown) =>
344
+ String(f).endsWith("_ka-dead-sess.jsonl")
345
+ ? { pid: DEAD_DESCENDANT_PID, id: "ka-dead-sess", startedAt: 0 }
346
+ : undefined,
347
+ );
348
+ mockIsProcessAlive.mockReturnValue(false); // 全部已死
349
+
350
+ const { child, finish } = await spawnAndReachKeepAlive();
351
+
352
+ // fire:差集非空但无任何存活 pid → 真静默 → 处置(SIGTERM + sweep 标志承接)
353
+ await vi.advanceTimersByTimeAsync(KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS);
354
+ expect(child.killed).toBe(true);
355
+ expect(child.killSignal).toBe("SIGTERM");
356
+
357
+ await finish();
358
+ });
359
+
360
+ it("A1-2③: fire 复核失败(sessionFile 读不出)→ 按无后代处置 + warn 留痕", async () => {
361
+ mockListPending.mockReturnValue({
362
+ items: [],
363
+ error: "session file unreadable: ENOENT",
364
+ });
365
+
366
+ const { child, finish } = await spawnAndReachKeepAlive();
367
+
368
+ await vi.advanceTimersByTimeAsync(KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS);
369
+ expect(child.killed).toBe(true);
370
+ expect(child.killSignal).toBe("SIGTERM");
371
+ // 复核失败 warn 留痕(与既有保守分支一致的可观测性)
372
+ expect(loggerMock.warn).toHaveBeenCalledWith(
373
+ expect.stringContaining("re-check failed (treating as no live descendants)"),
374
+ );
375
+
376
+ await finish();
377
+ });
378
+ });
@@ -0,0 +1,196 @@
1
+ // src/execution/__tests__/kill-all-escalation.test.ts
2
+ //
3
+ // [T2-⑤ / LC-2] killAllSpawnedChildren 的跳过条件收敛:「killed 且已确认死亡才跳过」。
4
+ //
5
+ // 设计:docs/design/subagent-core-unbounded-wait-audit.md §7.2 T2-⑤。旧实现按
6
+ // child.killed 跳过——killed=true 只表示「发过 kill 请求」≠「已死」,SIGTERM 被无视
7
+ // 的进程(卡死在不可中断 native 调用)会脱离最后一次回收窗口泄漏。新规则:
8
+ // - 已确认死亡(exitCode/signalCode 任一非 null)→ 跳过;
9
+ // - killed 但未确认死亡 → 补 escalation 检查:直接 SIGKILL(dispose 是最后兜底,
10
+ // 没有 30s 升级窗口可等);
11
+ // - 未 killed 且未确认死亡 → 发调用方指定 signal(既有行为)。
12
+
13
+ import { spawn } from "node:child_process";
14
+ import * as fs from "node:fs";
15
+
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
17
+
18
+ vi.mock("node:child_process", async () => {
19
+ const { FakeChild } = await import("./helpers/spawn-mock.ts");
20
+ return {
21
+ spawn: vi.fn(() => new FakeChild()),
22
+ execFile: vi.fn(
23
+ (
24
+ _cmd: string,
25
+ _args: readonly string[],
26
+ _opts: unknown,
27
+ cb: (err: Error | null, stdout?: string, stderr?: string) => void,
28
+ ) => cb(new Error("execFile not configured in this test")),
29
+ ),
30
+ };
31
+ });
32
+
33
+ vi.mock("node:fs", async () => {
34
+ const actual = await import("node:fs");
35
+ return {
36
+ default: {
37
+ ...actual,
38
+ mkdirSync: vi.fn(),
39
+ existsSync: vi.fn(() => false),
40
+ appendFileSync: vi.fn(),
41
+ writeFileSync: vi.fn(),
42
+ readdirSync: vi.fn(() => []),
43
+ },
44
+ mkdirSync: vi.fn(),
45
+ existsSync: vi.fn(() => false),
46
+ appendFileSync: vi.fn(),
47
+ writeFileSync: vi.fn(),
48
+ readdirSync: vi.fn(() => []),
49
+ promises: actual.promises,
50
+ };
51
+ });
52
+
53
+ vi.mock("../alive-store.ts", () => ({ writeAliveMarker: vi.fn() }));
54
+ vi.mock("../session-pending.ts", () => ({
55
+ readActivePendingFromSessionFile: vi.fn(() => ({ count: 0, recentUnregister: false })),
56
+ prunePendingCursor: vi.fn(),
57
+ listActivePendingFromSessionFile: vi.fn(() => ({ items: [] })),
58
+ }));
59
+ vi.mock("../temp-prompt.ts", () => ({
60
+ writePromptToTempFile: vi.fn(async (agent: string) => {
61
+ const safeName = agent.replace(/[^\w.-]+/g, "_");
62
+ return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt-${safeName}.md` };
63
+ }),
64
+ cleanupTempPrompt: vi.fn(async () => {}),
65
+ }));
66
+
67
+ import { killAllSpawnedChildren, runSpawn, spawnedChildren } from "../session-runner.ts";
68
+ import {
69
+ emitStdoutLine,
70
+ type FakeChild,
71
+ lastSpawnedChild as lastSpawnedChildOf,
72
+ makeCtx,
73
+ makeOpts,
74
+ makeRecord,
75
+ waitForSpawn as waitForSpawnOf,
76
+ } from "./helpers/spawn-mock.ts";
77
+
78
+ const mockSpawn = vi.mocked(spawn);
79
+ const lastSpawnedChild = (): FakeChild => lastSpawnedChildOf(mockSpawn);
80
+ const waitForSpawn = (timeoutMs = 1000): Promise<void> => waitForSpawnOf(mockSpawn, timeoutMs);
81
+
82
+ describe("[T2-⑤ / LC-2] killAllSpawnedChildren 跳过条件收敛", () => {
83
+ beforeEach(() => {
84
+ vi.clearAllMocks();
85
+ killAllSpawnedChildren(); // 清可能残留的 Map 条目(前序用例/文件)
86
+ spawnedChildren.clear();
87
+ });
88
+
89
+ afterEach(() => {
90
+ vi.restoreAllMocks();
91
+ });
92
+
93
+ it("killed 但未 close(SIGTERM 发过、进程未确认死亡)→ 不跳过,补 SIGKILL 升级", async () => {
94
+ const record = makeRecord("esc-1");
95
+ const promise = runSpawn(record, "Task: esc", makeOpts(), makeCtx());
96
+ await waitForSpawn();
97
+ const child = lastSpawnedChild();
98
+
99
+ // 模拟 SIGTERM 已发但进程仍在(LC-2 缺陷形态:killed=发过请求≠已死)
100
+ child.killed = true;
101
+ child.killSignal = "SIGTERM";
102
+ expect(child.exitCode).toBeNull();
103
+ expect(child.signalCode).toBeNull();
104
+
105
+ const n = killAllSpawnedChildren();
106
+ expect(n).toBe(1);
107
+ // 补 escalation:dispose 是最后兜底,直接 SIGKILL(不再赌 SIGTERM 生效)
108
+ expect(child.killSignal).toBe("SIGKILL");
109
+
110
+ // 收尾:emit close 让 runSpawn resolve
111
+ emitStdoutLine(child, { type: "session", id: "s", timestamp: "t", cwd: "/tmp" });
112
+ child.stdout.end();
113
+ child.emit("close", 137);
114
+ const result = await promise;
115
+ expect(result.success).toBe(true);
116
+ });
117
+
118
+ it("killed 且已确认死亡(signalCode 非 null)→ 跳过", async () => {
119
+ const record = makeRecord("esc-2");
120
+ const promise = runSpawn(record, "Task: dead-skip", makeOpts(), makeCtx());
121
+ await waitForSpawn();
122
+ const child = lastSpawnedChild();
123
+
124
+ // SIGTERM 已发(经 child.kill 记录信号)且进程已确认死亡(内核回收完成、
125
+ // close 事件尚未回调的窗口)
126
+ child.kill("SIGTERM");
127
+ child.signalCode = "SIGTERM";
128
+
129
+ const n = killAllSpawnedChildren();
130
+ expect(n).toBe(0); // 跳过,不重复发信号
131
+ expect(child.killSignal).toBe("SIGTERM"); // 保持原信号(未被改写为 SIGKILL)
132
+
133
+ child.stdout.end();
134
+ child.emit("close", 143);
135
+ await promise;
136
+ });
137
+
138
+ it("未 killed 且未确认死亡 → 发调用方 signal(既有行为不变)", async () => {
139
+ const record = makeRecord("esc-3");
140
+ const promise = runSpawn(record, "Task: alive", makeOpts(), makeCtx());
141
+ await waitForSpawn();
142
+ const child = lastSpawnedChild();
143
+ expect(child.killed).toBe(false);
144
+
145
+ const n = killAllSpawnedChildren("SIGTERM");
146
+ expect(n).toBe(1);
147
+ expect(child.killed).toBe(true);
148
+ expect(child.killSignal).toBe("SIGTERM");
149
+
150
+ child.stdout.end();
151
+ child.emit("close", 143);
152
+ await promise;
153
+ });
154
+
155
+ it("未 killed 但已确认死亡(自然退出、close 未到)→ 跳过不补信号", async () => {
156
+ const record = makeRecord("esc-4");
157
+ const promise = runSpawn(record, "Task: natural", makeOpts(), makeCtx());
158
+ await waitForSpawn();
159
+ const child = lastSpawnedChild();
160
+ child.exitCode = 0; // 自然退出
161
+
162
+ const n = killAllSpawnedChildren();
163
+ expect(n).toBe(0);
164
+ expect(child.killed).toBe(false);
165
+
166
+ child.stdout.end();
167
+ child.emit("close", 0);
168
+ await promise;
169
+ });
170
+
171
+ it("混合形态:各分支独立判定(killed-not-dead 升级 / alive 首杀 / dead 跳过)", async () => {
172
+ const mk = async (id: string): Promise<{ child: FakeChild; promise: Promise<unknown> }> => {
173
+ const promise = runSpawn(makeRecord(id), `Task: ${id}`, makeOpts(), makeCtx());
174
+ await waitForSpawn();
175
+ return { child: lastSpawnedChild(), promise };
176
+ };
177
+
178
+ const a = await mk("mix-a");
179
+ a.child.killed = true; // killed-not-dead → SIGKILL
180
+ const b = await mk("mix-b"); // alive → SIGTERM
181
+ const c = await mk("mix-c");
182
+ c.child.signalCode = "SIGKILL"; // dead → skip
183
+
184
+ const n = killAllSpawnedChildren();
185
+ expect(n).toBe(2);
186
+ expect(a.child.killSignal).toBe("SIGKILL");
187
+ expect(b.child.killSignal).toBe("SIGTERM");
188
+ expect(c.child.killSignal).toBeUndefined();
189
+
190
+ for (const { child, promise } of [a, b, c]) {
191
+ child.stdout.end();
192
+ child.emit("close", 143);
193
+ await promise;
194
+ }
195
+ });
196
+ });
@@ -0,0 +1,117 @@
1
+ // src/execution/__tests__/lifecycle-manager-idle-timer-identity.test.ts
2
+ //
3
+ // [LC-5/T6①] armIdleTimer 超时回调身份比对(按值守卫,对齐 session-runner
4
+ // removeChildRegistration 先例)。
5
+ //
6
+ // 覆盖(设计 §4.3 LC-5 + §11-2):
7
+ // 1. 「旧 timer 已到期、回调迟到执行」与「同 recordId re-arm」精确交错 → 守卫不误删
8
+ // 新条目(旧代码此处误删 = 新 timer 脱管、disarm 失效 → turn 中途误杀)。
9
+ // 2. 无交错时旧 timer 到点删除自己的条目 + 后续 re-arm 正常(既有语义不回归)。
10
+ // 3. fake timers 下「re-arm 发生在旧 timer 到期回调处置中」的同轮交错端到端:
11
+ // 新 timer 存活、到点正常触发、Map 清理干净。
12
+ //
13
+ // 交错构造说明(§11-2 勘误锚点):sinon fake clock 的同步 tick 模型在「时间推进」的
14
+ // 同一同步流程内立即执行到期回调,不存在「已到期、回调已入队、尚未执行」的中间态
15
+ // 暴露给 clearTimeout——纯 fake timers 无法表达 Node 真实窗口(fire 后回调入
16
+ // macrotask 队列,clearTimeout 不可撤销)。用例 1/2 以手动排程 fake(stub setTimeout
17
+ // 捕获回调、测试显式控制 fire 时机)精确复现该中间态;用例 3 补真实 clock 语义下的
18
+ // 同轮交错端到端回归。
19
+
20
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
21
+
22
+ import {
23
+ _resetLifecycleState,
24
+ armIdleTimer,
25
+ disarmIdleTimer,
26
+ hasIdleTimer,
27
+ } from "../lifecycle-manager.ts";
28
+
29
+ /** 手动排程 fake 捕获的到期回调(fire = 模拟 timer 到点,时机由用例显式控制)。 */
30
+ let scheduled: Array<{ fire: () => void; timer: object }>;
31
+
32
+ beforeEach(() => {
33
+ _resetLifecycleState();
34
+ scheduled = [];
35
+ // 手动排程模型:setTimeout 只登记不排程;clearTimeout no-op(「已入队回调不可撤销」
36
+ // 的真实语义由用例不 fire 被撤销者来表达)。
37
+ vi.stubGlobal(
38
+ "setTimeout",
39
+ (cb: () => void, _delay?: number) => {
40
+ void _delay;
41
+ const timer: object = {};
42
+ scheduled.push({ fire: cb, timer });
43
+ return Object.assign(timer, { unref() {} });
44
+ },
45
+ );
46
+ vi.stubGlobal("clearTimeout", (_t?: unknown) => {
47
+ void _t;
48
+ });
49
+ });
50
+
51
+ afterEach(() => {
52
+ _resetLifecycleState();
53
+ vi.unstubAllGlobals();
54
+ vi.useRealTimers();
55
+ });
56
+
57
+ describe("[LC-5] armIdleTimer 超时回调身份比对", () => {
58
+ it("旧 timer 到点回调迟到执行(re-arm 已覆盖条目)→ 守卫不误删新条目,disarm 仍有效", () => {
59
+ const onTimeoutA = vi.fn();
60
+ const onTimeoutB = vi.fn();
61
+
62
+ armIdleTimer("sa-x", onTimeoutA, 1000); // timerA 入手 排程表,Map: timerA
63
+ // re-arm(agent_settled 刷新):disarm 旧 timer(真实 Node 中 clearTimeout 对已
64
+ // 入队回调无效)→ timerB 入表,Map: timerB。此后 timerA 的回调才迟到执行。
65
+ armIdleTimer("sa-x", onTimeoutB, 1000);
66
+ expect(hasIdleTimer("sa-x")).toBe(true);
67
+
68
+ scheduled[0]?.fire(); // 旧 timerA 回调迟到执行
69
+
70
+ expect(onTimeoutA).toHaveBeenCalledTimes(1); // 旧超时回调本身照常触发
71
+ expect(hasIdleTimer("sa-x")).toBe(true); // [核心] 新条目未被误删(旧代码此处 false = 脱管)
72
+
73
+ disarmIdleTimer("sa-x"); // B 未脱管:disarm 有效
74
+ expect(hasIdleTimer("sa-x")).toBe(false);
75
+ expect(onTimeoutB).not.toHaveBeenCalled();
76
+ });
77
+
78
+ it("无交错:旧 timer 到点删除自己的条目(守卫不改变既有语义),后续 re-arm 正常", () => {
79
+ const onTimeoutA = vi.fn();
80
+ const onTimeoutB = vi.fn();
81
+
82
+ armIdleTimer("sa-y", onTimeoutA, 1000);
83
+ expect(hasIdleTimer("sa-y")).toBe(true);
84
+
85
+ scheduled[0]?.fire(); // 正常到点:Map 中是自己 → delete → 回调
86
+
87
+ expect(onTimeoutA).toHaveBeenCalledTimes(1);
88
+ expect(hasIdleTimer("sa-y")).toBe(false); // 旧条目删除正常(不残留失效 entry)
89
+
90
+ armIdleTimer("sa-y", onTimeoutB, 1000); // 后续 re-arm 正常
91
+ expect(hasIdleTimer("sa-y")).toBe(true);
92
+ scheduled[1]?.fire();
93
+ expect(onTimeoutB).toHaveBeenCalledTimes(1);
94
+ expect(hasIdleTimer("sa-y")).toBe(false);
95
+ });
96
+
97
+ it("fake timers 同轮交错:re-arm 发生在旧 timer 到期回调处置中 → 新 timer 存活到点触发", () => {
98
+ vi.useFakeTimers();
99
+ const onTimeoutB = vi.fn();
100
+ const onTimeoutA = vi.fn(() => {
101
+ // 到期回调处置中同步 re-arm(同轮交错的 fake-timer 可达形态)
102
+ armIdleTimer("sa-z", onTimeoutB, 1000);
103
+ });
104
+
105
+ armIdleTimer("sa-z", onTimeoutA, 1000);
106
+ vi.advanceTimersByTime(1000); // A 到点:删自己 → onTimeoutA → re-arm B
107
+
108
+ expect(onTimeoutA).toHaveBeenCalledTimes(1);
109
+ expect(hasIdleTimer("sa-z")).toBe(true); // B armed,交错不丢 timer
110
+
111
+ vi.advanceTimersByTime(999);
112
+ expect(onTimeoutB).not.toHaveBeenCalled(); // B 从 arm 时刻重新计时
113
+ vi.advanceTimersByTime(1);
114
+ expect(onTimeoutB).toHaveBeenCalledTimes(1); // B 存活到点正常触发
115
+ expect(hasIdleTimer("sa-z")).toBe(false); // 触发后 Map 清理干净
116
+ });
117
+ });