@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,316 @@
1
+ // src/execution/__tests__/subagent-service-recovery-bounds.test.ts
2
+ //
3
+ // [u-svc / T2] 服务侧回收上界与 kill 收敛:
4
+ // - T2④/LC-2:closeChatIdle / closeAfterRoundSettled / cancelBackground 三条裸 SIGTERM
5
+ // 收敛到 killRecordChildWithEscalation(spy 断言调用点与参数);
6
+ // - T2⑥/PS-1:disposeAllRecords 补三回收面(controller.abort + kill + disarm idle timer
7
+ // + disarm settled watchdog);
8
+ // - T2③/LC-1:deliverMessage 热路径 armSettledWatchdog(挂载 + 到期 onTimeout 处置:
9
+ // kill + 失败终态化 + error 含 'settled watchdog' 标记与恢复指引);
10
+ // - T2⑧/PS-3:deliverMessage 非 EPIPE 失败 re-arm idle timer(进程不再裸奔)。
11
+ //
12
+ // mock 形态沿用 subagent-service-message-close.test.ts(真实 SubagentService + ServiceInternals
13
+ // cast 暴露 store + mock session-runner/stdin-writer/logger)。
14
+
15
+ import * as fs from "node:fs";
16
+ import * as os from "node:os";
17
+ import * as path from "node:path";
18
+
19
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
20
+
21
+ const { loggerMock } = vi.hoisted(() => ({
22
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
23
+ }));
24
+ vi.mock("../../core/logger.ts", () => ({ getLogger: () => loggerMock }));
25
+
26
+ // killRecordChildWithEscalation 换 spy(收敛调用点断言);spawnedChildren 用真 Map
27
+ //(deliverMessage 热路径判定 / EPIPE 兜底按值删除的消费者);getChildByRecord 从同
28
+ // 一 Map 查(mock 工厂与测试共享同一实例)。
29
+ const { killChildSpy, spawnedMap, getChildByRecordMock } = vi.hoisted(() => {
30
+ const map = new Map<string, unknown>();
31
+ return {
32
+ killChildSpy: vi.fn(),
33
+ spawnedMap: map,
34
+ getChildByRecordMock: vi.fn((recordId: string) => map.get(recordId)),
35
+ };
36
+ });
37
+ vi.mock("../session-runner.ts", () => ({
38
+ runSpawn: vi.fn(),
39
+ killAllSpawnedChildren: vi.fn(),
40
+ getChildByRecord: getChildByRecordMock,
41
+ registerSpawnedChildForRecord: vi.fn(),
42
+ spawnedChildren: spawnedMap,
43
+ killRecordChildWithEscalation: killChildSpy,
44
+ }));
45
+
46
+ // sendPromptCommand 换 spy(控制热路径写成功 / 抛非 EPIPE 错误)。
47
+ const { sendPromptCommandMock } = vi.hoisted(() => ({ sendPromptCommandMock: vi.fn() }));
48
+ vi.mock("../stdin-writer.ts", () => ({
49
+ sendPromptCommand: sendPromptCommandMock,
50
+ clearEpipeFailure: vi.fn(),
51
+ recordEpipeFailure: vi.fn(() => 1),
52
+ resetAllEpipeFailures: vi.fn(),
53
+ EPIPE_FAILURE_THRESHOLD: 3,
54
+ }));
55
+
56
+ import { FakeChild } from "./helpers/spawn-mock.ts";
57
+ import { createRecord } from "../execution-record.ts";
58
+ import { ModelConfigService } from "../model-config-service.ts";
59
+ import type { RecordStore } from "../record-store.ts";
60
+ import { SubagentService } from "../subagent-service.ts";
61
+ import type { PiLike } from "../subagent-service.ts";
62
+ import { armSettledWatchdog, hasSettledWatchdog, SETTLED_WATCHDOG_TIMEOUT_MS, _resetSettledWatchdogsForTest } from "../settled-watchdog.ts";
63
+ import { armIdleTimer, hasIdleTimer, _resetLifecycleState } from "../lifecycle-manager.ts";
64
+ import type { ExecutionRecord } from "../types.ts";
65
+
66
+ function makeTmpAgentDir(): string {
67
+ return fs.mkdtempSync(path.join(os.tmpdir(), "svc-recovery-bounds-"));
68
+ }
69
+
70
+ function makePi(): PiLike & {
71
+ appendEntry: ReturnType<typeof vi.fn>;
72
+ events: { emit: ReturnType<typeof vi.fn> };
73
+ sendMessage: ReturnType<typeof vi.fn>;
74
+ } {
75
+ return {
76
+ appendEntry: vi.fn(),
77
+ events: { emit: vi.fn() },
78
+ sendMessage: vi.fn(),
79
+ } as unknown as PiLike & {
80
+ appendEntry: ReturnType<typeof vi.fn>;
81
+ events: { emit: ReturnType<typeof vi.fn> };
82
+ sendMessage: ReturnType<typeof vi.fn>;
83
+ };
84
+ }
85
+
86
+ function makeRecord(overrides: Partial<ExecutionRecord> & { id?: string } = {}): ExecutionRecord {
87
+ const { id = "sa-test", ...rest } = overrides;
88
+ const r = createRecord(id, {
89
+ agent: "general-purpose",
90
+ model: "test/model",
91
+ mode: "background",
92
+ task: "test",
93
+ slug: "test",
94
+ startedAt: 1000,
95
+ rootSessionId: "root-session",
96
+ chatMode: true,
97
+ controller: new AbortController(),
98
+ });
99
+ Object.assign(r, rest);
100
+ return r;
101
+ }
102
+
103
+ interface ServiceInternals {
104
+ store: RecordStore;
105
+ }
106
+
107
+ type MockPi = ReturnType<typeof makePi>;
108
+
109
+ function setup(): { agentDir: string; service: SubagentService; store: RecordStore; pi: MockPi } {
110
+ const agentDir = makeTmpAgentDir();
111
+ const modelService = new ModelConfigService({ agentDir });
112
+ const service = new SubagentService({ cwd: agentDir, modelService });
113
+ const pi = makePi();
114
+ service.initSession({ pi, sessionId: "root-session" });
115
+ const store = (service as unknown as ServiceInternals).store;
116
+ return { agentDir, service, store, pi };
117
+ }
118
+
119
+ /** bracket 调 private 方法(ServiceInternals cast 先例的扩展形态)。 */
120
+ function privateFn<K extends string>(service: SubagentService, key: K): (...args: never[]) => unknown {
121
+ return (service as unknown as Record<string, (...args: never[]) => unknown>)[key];
122
+ }
123
+
124
+ describe("T2④ service-side kill convergence", () => {
125
+ let agentDir: string;
126
+ let service: SubagentService;
127
+ let store: RecordStore;
128
+ let pi: MockPi;
129
+
130
+ beforeEach(() => {
131
+ vi.restoreAllMocks();
132
+ killChildSpy.mockClear();
133
+ sendPromptCommandMock.mockReset();
134
+ ({ agentDir, service, store, pi } = setup());
135
+ });
136
+
137
+ afterEach(() => {
138
+ _resetLifecycleState();
139
+ _resetSettledWatchdogsForTest();
140
+ spawnedMap.clear();
141
+ fs.rmSync(agentDir, { recursive: true, force: true });
142
+ });
143
+
144
+ it("cancelBackground (via cancel) routes through killRecordChildWithEscalation", async () => {
145
+ const record = makeRecord({ id: "sa-cancel" });
146
+ store.register(record);
147
+ spawnedMap.set(record.id, new FakeChild());
148
+ const ok = service.cancel(record.id);
149
+ expect(ok).toBe(true);
150
+ expect(killChildSpy).toHaveBeenCalledWith(record.id, "cancelBackground");
151
+ });
152
+
153
+ it("closeChatIdle (via closeSubagent force:false on idle) routes through killRecordChildWithEscalation and disarms timers", async () => {
154
+ const record = makeRecord({ id: "sa-close-idle" });
155
+ store.register(record);
156
+ spawnedMap.set(record.id, new FakeChild());
157
+ armIdleTimer(record.id, () => {}); // Path A:idle timer armed(进程保活)
158
+ armSettledWatchdog(record.id, () => {});
159
+ await service.closeSubagent(record, false);
160
+ expect(killChildSpy).toHaveBeenCalledWith(record.id, "closeChatIdle");
161
+ expect(hasIdleTimer(record.id)).toBe(false);
162
+ expect(hasSettledWatchdog(record.id)).toBe(false);
163
+ });
164
+
165
+ it("closeAfterRoundSettled routes through killRecordChildWithEscalation", async () => {
166
+ const record = makeRecord({ id: "sa-close-round" });
167
+ await (
168
+ privateFn(service, "closeAfterRoundSettled") as (this: SubagentService, r: ExecutionRecord) => Promise<void>
169
+ ).call(service, record);
170
+ expect(killChildSpy).toHaveBeenCalledWith(record.id, "closeAfterRoundSettled");
171
+ });
172
+
173
+ it("disposeAllRecords applies all recovery surfaces: abort + escalation kill + disarm idle/settled timers", () => {
174
+ const running = makeRecord({ id: "sa-dispose-run" });
175
+ const idle = makeRecord({ id: "sa-dispose-idle" });
176
+ store.register(running);
177
+ store.register(idle);
178
+ spawnedMap.set(running.id, new FakeChild());
179
+ armIdleTimer(idle.id, () => {});
180
+ armSettledWatchdog(idle.id, () => {});
181
+
182
+ const count = service.disposeAllRecords("parent-new");
183
+
184
+ expect(count).toBe(2);
185
+ // 回收面 i:controller.abort
186
+ expect(running.controller?.signal.aborted).toBe(true);
187
+ expect(idle.controller?.signal.aborted).toBe(true);
188
+ // 回收面 ii:kill 收敛到 escalation 入口
189
+ expect(killChildSpy).toHaveBeenCalledWith(running.id, "disposeAllRecords (parent-new)");
190
+ expect(killChildSpy).toHaveBeenCalledWith(idle.id, "disposeAllRecords (parent-new)");
191
+ // 回收面 iii:idle timer + settled watchdog 双 disarm
192
+ expect(hasIdleTimer(idle.id)).toBe(false);
193
+ expect(hasSettledWatchdog(idle.id)).toBe(false);
194
+ // record 终态化
195
+ expect(running.status).toBe("closed");
196
+ expect(idle.status).toBe("closed");
197
+ expect(running.closedReason).toBe("parent-new");
198
+ });
199
+ });
200
+
201
+ describe("T2③ hot-path settled watchdog", () => {
202
+ let agentDir: string;
203
+ let service: SubagentService;
204
+ let store: RecordStore;
205
+ let pi: MockPi;
206
+
207
+ beforeEach(() => {
208
+ vi.restoreAllMocks();
209
+ killChildSpy.mockClear();
210
+ // 模拟真 kill 的语义面(置 killed 标记):isResumable 判定(无活进程)与失败通知
211
+ // 放行依赖它——真实 kill 后 close 事件把句柄移出 spawnedChildren,语义相同。
212
+ killChildSpy.mockImplementation((recordId: string) => {
213
+ const c = spawnedMap.get(recordId) as FakeChild | undefined;
214
+ if (c) c.killed = true;
215
+ });
216
+ sendPromptCommandMock.mockReset();
217
+ sendPromptCommandMock.mockImplementation(() => {});
218
+ ({ agentDir, service, store, pi } = setup());
219
+ });
220
+
221
+ afterEach(() => {
222
+ vi.useRealTimers();
223
+ _resetLifecycleState();
224
+ _resetSettledWatchdogsForTest();
225
+ spawnedMap.clear();
226
+ fs.rmSync(agentDir, { recursive: true, force: true });
227
+ });
228
+
229
+ it("arms the settled watchdog after a successful hot-path prompt", async () => {
230
+ const record = makeRecord({ id: "sa-hot-arm" });
231
+ store.register(record);
232
+ spawnedMap.set(record.id, new FakeChild());
233
+ await service.deliverMessage(record, "hello", false);
234
+ expect(sendPromptCommandMock).toHaveBeenCalledTimes(1);
235
+ expect(hasSettledWatchdog(record.id)).toBe(true);
236
+ });
237
+
238
+ it("onTimeout: kills the child, fails the round, error carries 'settled watchdog' marker and recovery hint", async () => {
239
+ // fake timers 必须先于 arm 生效(useFakeTimers 不接管已存在的真实 timer)
240
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
241
+ const record = makeRecord({ id: "sa-hot-timeout" });
242
+ store.register(record);
243
+ spawnedMap.set(record.id, new FakeChild());
244
+ await service.deliverMessage(record, "hello", false);
245
+ expect(hasSettledWatchdog(record.id)).toBe(true);
246
+
247
+ await vi.advanceTimersByTimeAsync(SETTLED_WATCHDOG_TIMEOUT_MS + 1);
248
+
249
+ // kill 收敛入口被触发(onTimeout 第一步)
250
+ expect(killChildSpy).toHaveBeenCalledWith(record.id, "settled watchdog (hot path)");
251
+ // watchdog 到期自清(armedTimers 先删条目再执行回调)
252
+ expect(hasSettledWatchdog(record.id)).toBe(false);
253
+ // 该轮失败终态化(chatMode → MF-6 回退 resumable)+ 失败通知
254
+ expect(record.resumable).toBe(true);
255
+ expect(record.status).toBe("running");
256
+ expect(record.result).toContain("settled watchdog");
257
+ expect(record.result).toContain("Recovery");
258
+ expect(record.result).toContain("action:'list'");
259
+ // 失败通知送达(内核直发路径)
260
+ expect(pi.sendMessage).toHaveBeenCalled();
261
+ const notifyContent = (pi.sendMessage.mock.calls[0]?.[0] as { content?: string } | undefined)?.content ?? "";
262
+ expect(notifyContent).toContain("settled watchdog");
263
+ });
264
+ });
265
+
266
+ describe("T2⑧ non-EPIPE hot-path failure re-arms idle timer", () => {
267
+ let agentDir: string;
268
+ let service: SubagentService;
269
+ let store: RecordStore;
270
+
271
+ beforeEach(() => {
272
+ vi.restoreAllMocks();
273
+ killChildSpy.mockClear();
274
+ ({ agentDir, service, store } = setup());
275
+ });
276
+
277
+ afterEach(() => {
278
+ _resetLifecycleState();
279
+ _resetSettledWatchdogsForTest();
280
+ spawnedMap.clear();
281
+ fs.rmSync(agentDir, { recursive: true, force: true });
282
+ });
283
+
284
+ it("re-arms the idle timer (default duration) and rethrows on non-EPIPE write failure", async () => {
285
+ const record = makeRecord({ id: "sa-nonpipe" });
286
+ store.register(record);
287
+ spawnedMap.set(record.id, new FakeChild());
288
+ sendPromptCommandMock.mockImplementation(() => {
289
+ throw new Error("write after end: ERR_STREAM_DESTROYED");
290
+ });
291
+
292
+ await expect(service.deliverMessage(record, "hello", false)).rejects.toThrow(/ERR_STREAM_DESTROYED/);
293
+
294
+ // 防泄漏前提恢复:入口 disarm 过的 idle timer 被 re-arm(进程不再裸奔)
295
+ expect(hasIdleTimer(record.id)).toBe(true);
296
+ expect(loggerMock.warn).toHaveBeenCalledWith(
297
+ expect.stringContaining("idle timer re-armed"),
298
+ expect.objectContaining({ detail: expect.stringContaining("ERR_STREAM_DESTROYED") }),
299
+ );
300
+ });
301
+
302
+ it("falls back to DEFAULT idle duration (visible) when record.idleTimeoutMs is invalid", async () => {
303
+ const record = makeRecord({ id: "sa-nonpipe-bad" });
304
+ // 直接注入非法值模拟「入口校验前已存在的 record」/ 回归形态:re-arm 首选值非法 →
305
+ // 降级挂 DEFAULT(防御性兜底可见,不静默不挂)
306
+ record.idleTimeoutMs = 3_000_000_000;
307
+ store.register(record);
308
+ spawnedMap.set(record.id, new FakeChild());
309
+ sendPromptCommandMock.mockImplementation(() => {
310
+ throw new Error("ERR_STREAM_DESTROYED");
311
+ });
312
+
313
+ await expect(service.deliverMessage(record, "hello", false)).rejects.toThrow(/ERR_STREAM_DESTROYED/);
314
+ expect(hasIdleTimer(record.id)).toBe(true);
315
+ });
316
+ });
@@ -3,7 +3,7 @@
3
3
  // timeoutMs / signal abort → child.kill 端到端路径测试。
4
4
  //
5
5
  // 背景:session-runner.ts 无独立 timeoutMs 字段——超时机制是 watchdog(基于
6
- // computeWatchdogMs(opts.maxTurns) 动态计算的下限 30min timer,兜底 SIGTERM);
6
+ // maxTurnsToWatchdogMs(opts.maxTurns) 动态计算的下限 30min timer,兜底 SIGTERM);
7
7
  // 外部取消通过 opts.signal (AbortSignal) 传播:onAbort → child.kill("SIGTERM")。
8
8
  // 故「timeout 端到端路径」实际是 watchdog timer + signal abort → child.kill 两条链路。
9
9
  //
@@ -102,7 +102,7 @@ import * as fs from "node:fs";
102
102
 
103
103
  import { createRecord } from "../execution-record.ts";
104
104
  import {
105
- computeWatchdogMs,
105
+ maxTurnsToWatchdogMs,
106
106
  type RunOptions,
107
107
  resolveSpawnWatchdogMs,
108
108
  runSpawn,
@@ -270,8 +270,8 @@ describe("timeoutMs / signal abort → child.kill 端到端路径", () => {
270
270
 
271
271
  // ── 1. watchdog 到期 → child.kill ──
272
272
  //
273
- // [R1] watchdog = setTimeout(() => child.kill("SIGTERM"), computeWatchdogMs(maxTurns))。
274
- // computeWatchdogMs 下限 30min(SPAWN_WATCHDOG_FLOOR_MS),maxTurns=6 → max(30min, 30min)=30min。
273
+ // [R1] watchdog = setTimeout(() => child.kill("SIGTERM"), maxTurnsToWatchdogMs(maxTurns))。
274
+ // maxTurnsToWatchdogMs 下限 30min(SPAWN_WATCHDOG_FLOOR_MS),maxTurns=6 → max(30min, 30min)=30min。
275
275
  // 子进程卡死(turn_end 永不触发)时 limiter 失效,watchdog 兜底 kill 防资源泄漏。
276
276
  describe("watchdog 到期 → signal abort → child.kill", () => {
277
277
  beforeEach(() => {
@@ -282,9 +282,9 @@ describe("timeoutMs / signal abort → child.kill 端到端路径", () => {
282
282
  vi.useRealTimers();
283
283
  });
284
284
 
285
- it("watchdog 到期(超过 computeWatchdogMs 阈值)→ child.kill(SIGTERM) 被调用", async () => {
285
+ it("watchdog 到期(超过 maxTurnsToWatchdogMs 阈值)→ child.kill(SIGTERM) 被调用", async () => {
286
286
  const record = makeRecord();
287
- // maxTurns=6 → computeWatchdogMs = max(30min, 6*5min) = 30min
287
+ // maxTurns=6 → maxTurnsToWatchdogMs = max(30min, 6*5min) = 30min
288
288
  // 不 await:runSpawn 内部 await 子进程 close,watchdog kill 后还需 emit close 才 resolve
289
289
  const promise = runSpawn(record, "Task: hang", makeOpts({ maxTurns: 6 }), makeCtx());
290
290
 
@@ -396,7 +396,7 @@ describe("timeoutMs / signal abort → child.kill 端到端路径", () => {
396
396
  expect(resolveSpawnWatchdogMs(0)).toBeUndefined();
397
397
  expect(resolveSpawnWatchdogMs(-5)).toBeUndefined();
398
398
  // maxTurns 有效(>0)→ turns 估算优先于 env 兑底
399
- expect(resolveSpawnWatchdogMs(6)).toBe(computeWatchdogMs(6));
399
+ expect(resolveSpawnWatchdogMs(6)).toBe(maxTurnsToWatchdogMs(6));
400
400
  vi.stubEnv(SPAWN_WATCHDOG_ENV, "abc");
401
401
  expect(resolveSpawnWatchdogMs(undefined)).toBeUndefined();
402
402
  vi.stubEnv(SPAWN_WATCHDOG_ENV, "-1");
@@ -23,7 +23,7 @@ vi.mock("../../core/logger.ts", () => ({
23
23
  getLogger: () => loggerMock,
24
24
  }));
25
25
 
26
- import { DialogGlobalQueue, type UiRequest } from "../dialog-queue.ts";
26
+ import { DEFAULT_DIALOG_TIMEOUT_MS, DialogGlobalQueue, type UiRequest } from "../dialog-queue.ts";
27
27
  import { type ChannelHandler,createUiChannelRegistry } from "../ui-channels.ts";
28
28
  import { createUiRequestHandlerForMode } from "../ui-request-handler-factory.ts";
29
29
 
@@ -367,3 +367,97 @@ describe("createUiRequestHandlerForMode — TUI 零回归", () => {
367
367
  }
368
368
  });
369
369
  });
370
+
371
+ // ── LC-3/T2⑦:dialog timeout 协同(SDK 层超时先到 / 队列级上界兜底,settle 恰一次)──
372
+
373
+ describe("createUiRequestHandlerForMode — dialog timeout 协同(LC-3/T2⑦)", () => {
374
+ it("请求方传 timeout → select/confirm/input 透传 SDK 第三参 {timeout};未传不加第三参(参数个数不变)", async () => {
375
+ const ctx = makeCtxWithUi();
376
+ const handler = createUiRequestHandlerForMode(ctx, createUiChannelRegistry(), new DialogGlobalQueue())!;
377
+
378
+ await handler({ method: "select", id: "t1", title: "Choose", options: ["a"], timeout: 1234 });
379
+ expect(ctx.ui.select).toHaveBeenCalledWith("Choose", ["a"], { timeout: 1234 });
380
+
381
+ await handler({ method: "confirm", id: "t2", title: "Sure?", message: "Go?", timeout: 456 });
382
+ expect(ctx.ui.confirm).toHaveBeenCalledWith("Sure?", "Go?", { timeout: 456 });
383
+
384
+ await handler({ method: "input", id: "t3", title: "In", placeholder: "p", timeout: 78 });
385
+ expect(ctx.ui.input).toHaveBeenCalledWith("In", "p", { timeout: 78 });
386
+
387
+ // 未传 timeout:不加第三参(调用面严格 additive,与既有 (e) 零回归断言同形态)
388
+ await handler({ method: "input", id: "t4", title: "In2", placeholder: "p2" });
389
+ expect(ctx.ui.input).toHaveBeenCalledWith("In2", "p2");
390
+ });
391
+
392
+ it("全链路:req.timeout 经 L2 队列按传值超时 settle(host ctx.ui promise 挂死形态)", async () => {
393
+ const ctx = makeCtxWithUi();
394
+ // host UI promise 挂死:SDK 自身永不返回,唯一回收通道是队列级上界
395
+ ctx.ui.select.mockReturnValue(new Promise(() => {}));
396
+ const handler = createUiRequestHandlerForMode(ctx, createUiChannelRegistry(), new DialogGlobalQueue())!;
397
+
398
+ const p = handler({ method: "select", id: "t1", title: "q", options: ["a"], timeout: 1234 });
399
+
400
+ await vi.advanceTimersByTimeAsync(1233);
401
+ let settled = false;
402
+ void p.then(() => {
403
+ settled = true;
404
+ });
405
+ await vi.advanceTimersByTimeAsync(0);
406
+ expect(settled).toBe(false);
407
+
408
+ await vi.advanceTimersByTimeAsync(1);
409
+ await expect(p).resolves.toEqual({ cancelled: true });
410
+ });
411
+
412
+ it("未传 timeout → 队列级默认上界(30min)兜底 settle,SDK opts 不出现", async () => {
413
+ const ctx = makeCtxWithUi();
414
+ ctx.ui.select.mockReturnValue(new Promise(() => {}));
415
+ const handler = createUiRequestHandlerForMode(ctx, createUiChannelRegistry(), new DialogGlobalQueue())!;
416
+
417
+ const p = handler({ method: "select", id: "t1", title: "q", options: ["a"] });
418
+ expect(ctx.ui.select).toHaveBeenCalledWith("q", ["a"]);
419
+
420
+ await vi.advanceTimersByTimeAsync(DEFAULT_DIALOG_TIMEOUT_MS);
421
+ await expect(p).resolves.toEqual({ cancelled: true });
422
+ });
423
+
424
+ it("竞态恰一次:SDK 层超时先到(auto-dismiss)→ handler 先 settle,队列级 timer 兜底不二 settle", async () => {
425
+ const ctx = makeCtxWithUi();
426
+ // SDK timeout 生效形态:500ms 后 auto-dismiss(resolve undefined = 取消)
427
+ ctx.ui.select.mockImplementation(
428
+ () => new Promise<string | undefined>((resolve) => setTimeout(() => resolve(undefined), 500)),
429
+ );
430
+ const handler = createUiRequestHandlerForMode(ctx, createUiChannelRegistry(), new DialogGlobalQueue())!;
431
+
432
+ const p = handler({ method: "select", id: "t1", title: "q", options: ["a"], timeout: 1000 });
433
+
434
+ // SDK 先到(500ms):handler settle,此时队列级 timer(1000ms)尚未触发
435
+ await vi.advanceTimersByTimeAsync(500);
436
+ await expect(p).resolves.toEqual({ cancelled: true });
437
+
438
+ // 越过队列级超时点:timer 已随 handler 完成清除,无第二次 settle、无队列超时日志
439
+ await vi.advanceTimersByTimeAsync(2000);
440
+ await expect(p).resolves.toEqual({ cancelled: true });
441
+ expect(loggerMock.warn).not.toHaveBeenCalled();
442
+ });
443
+
444
+ // [A2-3] 非法 timeout 与队列层判定对齐:不透传 SDK 第三参(语义反转的 opts
445
+ // (<=0 可能立即 auto-dismiss)不进 SDK),由队列层默认上界兜底 settle。
446
+ it.each([-1, 0, Number.NaN])(
447
+ "timeout 非法(%p)→ SDK 不收第三参(参数个数 2),队列层按默认上界 settle(A2-3)",
448
+ async (badTimeout) => {
449
+ const ctx = makeCtxWithUi();
450
+ ctx.ui.select.mockReturnValue(new Promise(() => {})); // host UI 挂死,暴露队列层兜底
451
+ const handler = createUiRequestHandlerForMode(ctx, createUiChannelRegistry(), new DialogGlobalQueue())!;
452
+
453
+ const p = handler({ method: "select", id: "t1", title: "q", options: ["a"], timeout: badTimeout });
454
+ // 第三参不出现:toHaveBeenCalledWith 逐参精确匹配,两参断言即锁死参数个数为 2
455
+ expect(ctx.ui.select).toHaveBeenCalledWith("q", ["a"]);
456
+ expect(ctx.ui.select).toHaveBeenCalledTimes(1);
457
+
458
+ // 队列层把非法值回落默认上界(30min):到点 settle cancelled
459
+ await vi.advanceTimersByTimeAsync(DEFAULT_DIALOG_TIMEOUT_MS);
460
+ await expect(p).resolves.toEqual({ cancelled: true });
461
+ },
462
+ );
463
+ });
@@ -131,3 +131,28 @@ describe("UI 请求队列", () => {
131
131
  expect(callOrder).toEqual(["Q1", "Q2"]);
132
132
  });
133
133
  });
134
+
135
+ // ── LC-3/T2⑦ 验收⑤:timeout 字段透传链路(L1 队列 → handler 段)──
136
+ // 下游段(handler → dialogQueue 消费)由 dialog-queue.test.ts / ui-request-handler-factory.test.ts 锁死。
137
+
138
+ describe("UI 请求队列 — timeout 字段透传(LC-3/T2⑦)", () => {
139
+ it("ExtensionUiRequest.timeout 经 handleUiRequest 到达 uiRequestHandler 收到的 UiRequest", async () => {
140
+ const received: UiRequest[] = [];
141
+ const handler: UiRequestHandler = (req: UiRequest) => {
142
+ received.push(req);
143
+ return Promise.resolve<UiResponse>({ value: "ok" });
144
+ };
145
+
146
+ const child = makeFakeChild();
147
+ const ctx = { uiRequestHandler: handler } as Parameters<
148
+ typeof createUiRequestQueue
149
+ >[1];
150
+ const enqueue = createUiRequestQueue(child, ctx);
151
+
152
+ enqueue("r1", { ...makeSelectReq("Q1"), timeout: 5000 });
153
+ await vi.advanceTimersByTimeAsync(0);
154
+
155
+ expect(received).toHaveLength(1);
156
+ expect(received[0].timeout).toBe(5000);
157
+ });
158
+ });