@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
@@ -9,6 +9,15 @@
9
9
 
10
10
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
11
 
12
+ // [LC-7/T7①] Mock 共享 logger:env 非法值回落默认的 warn 留痕可被断言
13
+ //(对齐 channel-registry-handshake.test.ts 模式;vi.mock 自动 hoist 到 import 前)。
14
+ const { loggerMock } = vi.hoisted(() => ({
15
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
16
+ }));
17
+ vi.mock("../../core/logger.ts", () => ({
18
+ getLogger: () => loggerMock,
19
+ }));
20
+
12
21
  import {
13
22
  DEFAULT_IDLE_TIMEOUT_MS,
14
23
  DEFAULT_MAX_ALIVE_PROCESSES,
@@ -30,6 +39,9 @@ describe("lifecycle-manager — V2 §5.2 模块 1", () => {
30
39
  beforeEach(() => {
31
40
  vi.useFakeTimers();
32
41
  _resetLifecycleState();
42
+ loggerMock.debug.mockClear();
43
+ loggerMock.warn.mockClear();
44
+ loggerMock.error.mockClear();
33
45
  // [F-4 同源修复] env 隔离:「默认超时」用例依赖 XYZ_SUBAGENT_IDLE_TIMEOUT_MS
34
46
  // 未设基线(getEnvIdleTimeoutMs 会覆盖 DEFAULT),宿主 export 即假红。空串 = 未设。
35
47
  vi.stubEnv("XYZ_SUBAGENT_IDLE_TIMEOUT_MS", "");
@@ -138,6 +150,33 @@ describe("lifecycle-manager — V2 §5.2 模块 1", () => {
138
150
  expect(() => armIdleTimer("sa-env-overflow", onTimeout)).toThrowError(/2147483647/);
139
151
  expect(hasIdleTimer("sa-env-overflow")).toBe(false);
140
152
  });
153
+
154
+ // [LC-7/T7①] env 非法值回落默认不再静默:warn 留痕(env 名 + 实际值 + 生效行为)。
155
+ // 「以为设了极长保活、实际回落 5min」的语义漂移必须可诊断。
156
+ it("[LC-7] env 非法值('30m' 非纯数字)→ 回落 DEFAULT_IDLE_TIMEOUT_MS 且 warn 留痕", () => {
157
+ vi.stubEnv("XYZ_SUBAGENT_IDLE_TIMEOUT_MS", "30m");
158
+ const onTimeout = vi.fn();
159
+ armIdleTimer("sa-lc7-invalid", onTimeout); // 不传 timeoutMs → env(非法)→ DEFAULT
160
+
161
+ // 生效行为 = 默认 5min(非法值没有按字面 '30m' 也不按禁用处理)
162
+ vi.advanceTimersByTime(DEFAULT_IDLE_TIMEOUT_MS - 1);
163
+ expect(onTimeout).not.toHaveBeenCalled();
164
+ vi.advanceTimersByTime(1);
165
+ expect(onTimeout).toHaveBeenCalledTimes(1);
166
+
167
+ expect(loggerMock.warn).toHaveBeenCalledTimes(1);
168
+ const msg = String(loggerMock.warn.mock.calls[0]?.[0] ?? "");
169
+ expect(msg).toContain("XYZ_SUBAGENT_IDLE_TIMEOUT_MS"); // env 变量名
170
+ expect(msg).toContain("30m"); // 实际值
171
+ expect(msg).toContain("DEFAULT_IDLE_TIMEOUT_MS"); // 生效行为:回落默认
172
+ });
173
+
174
+ it("[LC-7] env 未设(空串基线)→ 静默走 DEFAULT,零 warn(未配置不是异常)", () => {
175
+ const onTimeout = vi.fn();
176
+ armIdleTimer("sa-lc7-clean", onTimeout);
177
+ expect(hasIdleTimer("sa-lc7-clean")).toBe(true);
178
+ expect(loggerMock.warn).not.toHaveBeenCalled();
179
+ });
141
180
  });
142
181
 
143
182
  // ============================================================
@@ -0,0 +1,99 @@
1
+ // src/execution/__tests__/manifest-store-tmp-recovery.test.ts
2
+ //
3
+ // [u-svc / T5④ / PS-13] recoverTmpFiles 循环内 per-file 容错:
4
+ // 单个 tmp 文件操作失败(ENOENT——并发回收/外部清理抢先)只 warn + 跳过,
5
+ // 不再中断整轮——剩余 tmp 继续处理;返回值形态不变(跳过者不计数)。
6
+
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
+
9
+ const { loggerMock } = vi.hoisted(() => ({
10
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
11
+ }));
12
+ vi.mock("../../core/logger.ts", () => ({ getLogger: () => loggerMock }));
13
+
14
+ // fs partial mock:unlinkSync 可按路径注错,其余转发真实实现。
15
+ const { unlinkSyncMock } = vi.hoisted(() => ({ unlinkSyncMock: vi.fn() }));
16
+ vi.mock("node:fs", async (importOriginal) => {
17
+ const actual = await importOriginal<typeof import("node:fs")>();
18
+ return {
19
+ ...actual,
20
+ unlinkSync: unlinkSyncMock,
21
+ default: { ...actual, unlinkSync: unlinkSyncMock },
22
+ };
23
+ });
24
+
25
+ import * as fs from "node:fs";
26
+ import * as os from "node:os";
27
+ import * as path from "node:path";
28
+
29
+ import { ManifestStore } from "../manifest-store.ts";
30
+
31
+ const VALID_MANIFEST = {
32
+ id: "sa-good",
33
+ rootSessionId: "root-1",
34
+ agentName: "general-purpose",
35
+ createdAt: 1,
36
+ status: "running",
37
+ };
38
+
39
+ describe("T5④ recoverTmpFiles per-file tolerance", () => {
40
+ let dir: string;
41
+ let store: ManifestStore;
42
+
43
+ beforeEach(async () => {
44
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "manifest-tmp-recovery-"));
45
+ store = new ManifestStore(dir);
46
+ // 真实 unlinkSync(mock 模块对象上的 unlinkSync 已被替换,不能自引用转发)
47
+ const actualFs = await vi.importActual<typeof import("node:fs")>("node:fs");
48
+ unlinkSyncMock.mockReset();
49
+ unlinkSyncMock.mockImplementation((p: fs.PathLike) => actualFs.unlinkSync(p));
50
+ loggerMock.warn.mockClear();
51
+ loggerMock.error.mockClear();
52
+ loggerMock.debug.mockClear();
53
+ });
54
+
55
+ afterEach(() => {
56
+ vi.restoreAllMocks();
57
+ fs.rmSync(dir, { recursive: true, force: true });
58
+ });
59
+
60
+ it("processes remaining tmp files when one unlink fails mid-loop", async () => {
61
+ const actualFs = await vi.importActual<typeof import("node:fs")>("node:fs");
62
+ // 三个 tmp:good(合法 → promote)、stale(manifest sa-stale.json 已存在 → 删)、
63
+ // doomed(unlink ENOENT)
64
+ fs.writeFileSync(path.join(dir, "sa-good.json.tmp.111"), JSON.stringify(VALID_MANIFEST));
65
+ fs.writeFileSync(path.join(dir, "sa-stale.json"), JSON.stringify(VALID_MANIFEST));
66
+ fs.writeFileSync(path.join(dir, "sa-stale.json.tmp.222"), "{}");
67
+ fs.writeFileSync(path.join(dir, "sa-doomed.json.tmp.333"), "not json");
68
+ const doomedPath = path.join(dir, "sa-doomed.json.tmp.333");
69
+ unlinkSyncMock.mockImplementation((p: fs.PathLike) => {
70
+ if (String(p) === doomedPath) {
71
+ const err = new Error("ENOENT: file vanished") as NodeJS.ErrnoException;
72
+ err.code = "ENOENT";
73
+ throw err;
74
+ }
75
+ return actualFs.unlinkSync(p);
76
+ });
77
+
78
+ const result = await store.recoverTmpFiles();
79
+
80
+ // 整轮不中断:doomed 之外的 tmp 全部处理完
81
+ expect(result.recovered).toBe(1); // good promote
82
+ expect(result.deleted).toBe(1); // stale 删
83
+ // good 被提升为正式 manifest
84
+ expect(fs.existsSync(path.join(dir, "sa-good.json"))).toBe(true);
85
+ // 失败留痕(warn 级,含文件名)
86
+ expect(loggerMock.warn).toHaveBeenCalledWith(
87
+ expect.stringContaining("sa-doomed.json.tmp.333"),
88
+ expect.objectContaining({ detail: expect.stringContaining("ENOENT") }),
89
+ );
90
+ expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining("1 of 3 tmp file(s)"));
91
+ });
92
+
93
+ it("returns counts unchanged and no warnings when all tmp files succeed", async () => {
94
+ fs.writeFileSync(path.join(dir, "sa-ok.json.tmp.444"), JSON.stringify(VALID_MANIFEST));
95
+ const result = await store.recoverTmpFiles();
96
+ expect(result).toEqual({ deleted: 0, recovered: 1 });
97
+ expect(loggerMock.warn).not.toHaveBeenCalled();
98
+ });
99
+ });
@@ -0,0 +1,113 @@
1
+ // src/execution/__tests__/max-turns-to-watchdog-ms.test.ts
2
+ //
3
+ // U3(subagent-core-sink-design):computeWatchdogMs 语义化更名 maxTurnsToWatchdogMs
4
+ // + floor 语义文档化。本文件锚定换算纯函数语义:
5
+ // - floor 断言(设计 §4 S2):maxTurnsToWatchdogMs(2) >= 1_800_000——zsw 曾自实现
6
+ // 无 floor 版本致 maxTurns=2 被 10min 误杀(设计 §2.1 例 1),floor 是两宿主
7
+ // 预算一致性的锚点(G1)。
8
+ // - 边界:0.5 轮 / 缺省(不挂 watchdog)/ 极大值。
9
+ //
10
+ // 纯函数换算,无 timer 参与,无需 fake timers。
11
+ import { describe, expect, it, vi } from "vitest";
12
+
13
+ // [LC-7/T7①] Mock 共享 logger:非法 env 值的 warn 留痕可被断言
14
+ //(对齐 channel-registry-handshake.test.ts 模式;vi.mock 自动 hoist 到 import 前)。
15
+ const { loggerMock } = vi.hoisted(() => ({
16
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
17
+ }));
18
+ vi.mock("../../core/logger.ts", () => ({
19
+ getLogger: () => loggerMock,
20
+ }));
21
+
22
+ import {
23
+ maxTurnsToWatchdogMs,
24
+ resolveSpawnWatchdogMs,
25
+ SPAWN_WATCHDOG_ENV,
26
+ } from "../session-runner.ts";
27
+
28
+ const MIN_MS = 60_000;
29
+ /** watchdog floor(session-runner WATCHDOG_FLOOR_MINUTES=30 的毫秒值)。 */
30
+ const FLOOR_MS = 30 * MIN_MS;
31
+ /** Node setTimeout delay 安全域上界(>2^31-1 被置 1ms 立即触发,assertSafeTimerDelay 拦截域)。 */
32
+ const TIMER_SAFE_MAX = 2 ** 31 - 1;
33
+
34
+ describe("maxTurnsToWatchdogMs(U3 watchdog 换算,floor 语义)", () => {
35
+ it("floor 断言(S2):maxTurns=2 → >= 1_800_000(floor 生效,非 2×5=10min)", () => {
36
+ expect(maxTurnsToWatchdogMs(2)).toBeGreaterThanOrEqual(1_800_000);
37
+ // 精确值锚定:floor 是 max 不是「至少再加余量」
38
+ expect(maxTurnsToWatchdogMs(2)).toBe(FLOOR_MS);
39
+ });
40
+
41
+ it("0.5 轮(小数)→ floor 30min(不出现 2.5min 的短 watchdog)", () => {
42
+ expect(maxTurnsToWatchdogMs(0.5)).toBe(FLOOR_MS);
43
+ });
44
+
45
+ it("maxTurns=6 → 恰为 floor 临界(max(30min, 30min))", () => {
46
+ expect(maxTurnsToWatchdogMs(6)).toBe(FLOOR_MS);
47
+ });
48
+
49
+ it("线性段:maxTurns=20 → 100min;maxTurns=100 → 500min(M-1 长任务不被 30min 误杀)", () => {
50
+ expect(maxTurnsToWatchdogMs(20)).toBe(100 * MIN_MS);
51
+ expect(maxTurnsToWatchdogMs(100)).toBe(500 * MIN_MS);
52
+ });
53
+
54
+ it("极大值 → 保持纯算术结果(> 2^31-1 溢出域;挂载前由 assertSafeTimerDelay fail-fast 拦截)", () => {
55
+ expect(maxTurnsToWatchdogMs(Number.MAX_SAFE_INTEGER)).toBeGreaterThan(TIMER_SAFE_MAX);
56
+ expect(maxTurnsToWatchdogMs(Number.POSITIVE_INFINITY)).toBe(Number.POSITIVE_INFINITY);
57
+ });
58
+
59
+ it("上层防线:换算链路入口对极大值 fail-fast(resolveSpawnWatchdogMs(Infinity) 抛出)", () => {
60
+ expect(() => resolveSpawnWatchdogMs(Number.POSITIVE_INFINITY)).toThrow();
61
+ });
62
+
63
+ it("缺省(maxTurns 未传/null 且 env 未设)→ resolveSpawnWatchdogMs 返回 undefined(不挂 watchdog)", () => {
64
+ // 空串与未设同义(getEnvSpawnWatchdogMs 对 falsy raw 返回 undefined);
65
+ // 显式 stub 防御宿主 shell export 污染(vitest.setup.ts 已在模块加载前净化)。
66
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "");
67
+ try {
68
+ expect(resolveSpawnWatchdogMs(undefined)).toBeUndefined();
69
+ expect(resolveSpawnWatchdogMs(null)).toBeUndefined();
70
+ } finally {
71
+ vi.unstubAllEnvs();
72
+ }
73
+ });
74
+
75
+ // [LC-7/T7①] env 非法值不再静默失效:warn 留痕(env 名 + 实际值 + 生效行为)。
76
+ it("[LC-7] env 设非法值('30m' 非纯数字)→ undefined(不挂 watchdog)且 warn 留痕", () => {
77
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "30m");
78
+ try {
79
+ expect(resolveSpawnWatchdogMs(undefined)).toBeUndefined();
80
+ expect(loggerMock.warn).toHaveBeenCalledTimes(1);
81
+ const msg = String(loggerMock.warn.mock.calls[0]?.[0] ?? "");
82
+ expect(msg).toContain("XYZ_SUBAGENT_SPAWN_WATCHDOG_MS"); // env 变量名
83
+ expect(msg).toContain("30m"); // 实际值
84
+ expect(msg).toContain("NOT armed"); // 生效行为:等价关闭,运维可诊断「以为有兜底、实际裸奔」
85
+ } finally {
86
+ loggerMock.warn.mockClear();
87
+ vi.unstubAllEnvs();
88
+ }
89
+ });
90
+
91
+ it("[LC-7] env 设 <=0 → 同样 warn 留痕(不挂 watchdog,语义与非法值一致可见)", () => {
92
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "-5");
93
+ try {
94
+ expect(resolveSpawnWatchdogMs(undefined)).toBeUndefined();
95
+ expect(loggerMock.warn).toHaveBeenCalledTimes(1);
96
+ expect(String(loggerMock.warn.mock.calls[0]?.[0] ?? "")).toContain("-5");
97
+ } finally {
98
+ loggerMock.warn.mockClear();
99
+ vi.unstubAllEnvs();
100
+ }
101
+ });
102
+
103
+ it("[LC-7] env 未设(空串)→ 静默 undefined,零 warn(未配置不是异常,不制造噪音)", () => {
104
+ vi.stubEnv(SPAWN_WATCHDOG_ENV, "");
105
+ try {
106
+ expect(resolveSpawnWatchdogMs(undefined)).toBeUndefined();
107
+ expect(loggerMock.warn).not.toHaveBeenCalled();
108
+ } finally {
109
+ loggerMock.warn.mockClear();
110
+ vi.unstubAllEnvs();
111
+ }
112
+ });
113
+ });
@@ -0,0 +1,294 @@
1
+ // src/execution/__tests__/notify-ledger.test.ts
2
+ //
3
+ // T4③ 重投止损(PS-6)单测族:回执确认不可达时 attempts 上限 + 放弃终态
4
+ // (设计 docs/design/subagent-core-unbounded-wait-audit.md §7.2 T4③ / §8.2 S-E)。
5
+ //
6
+ // 覆盖验收面:
7
+ // - ①回执不可匹配 → 重投至 NOTIFY_REDELIVERY_MAX_ATTEMPTS → 放弃:
8
+ // abandoned 终态 entry 落盘 + warn 含 subagent 标识与 subagents action:"list"
9
+ // 恢复指引,此后零重投(同一条完成通知不无限唤醒 LLM)
10
+ // - ②放弃条目跨重启不复活:recoverFromSession 跳过 abandoned 差集,
11
+ // 同 notifyId record 幂等拒绝,后续 watchdog/settled 零投递
12
+ // - ③未达上限时正常重投行为不变:照常超时重投,回执到达后照常销账
13
+ // - 放弃终态持久化的 compaction 补写:abandoned entry 被清除后按内存态补写
14
+ //
15
+ // 全族 fake timers(看门狗 120s 周期);warn 断言经 configureCore 日志 sink 捕获
16
+ // (core logger facade 动态解析宿主实现,见 agents-assembly.test.ts 同款)。
17
+
18
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19
+
20
+ import {
21
+ createNotifyLedger,
22
+ NOTIFY_ABANDONED_CUSTOM_TYPE,
23
+ NOTIFY_ACK_CUSTOM_TYPE,
24
+ NOTIFY_LEDGER_CUSTOM_TYPE,
25
+ NOTIFY_REDELIVERY_MAX_ATTEMPTS,
26
+ NOTIFY_WATCHDOG_MS,
27
+ _resetNotifyLedgerForTest,
28
+ type NotifyLedgerHost,
29
+ } from "../notify-ledger.ts";
30
+ import { configureCore, resetCoreForTests } from "../../core/host-services.ts";
31
+
32
+ // ─── mock host ─────────────────────────────────────────────
33
+
34
+ interface LedgerHostMock {
35
+ host: NotifyLedgerHost;
36
+ /** 追加的 plain custom entry(ledger/ack/abandoned 三列,含 customType)。与
37
+ * sessionEntries 共享同一数组——送达 custom_message entry 也会落入(无 data 字段)。 */
38
+ entries: { type: string; customType: string; data?: Record<string, unknown> }[];
39
+ /** session entries 视图(回执扫描 + 恢复扫描的输入;与 entries 共享同一数组)。 */
40
+ sessionEntries: unknown[];
41
+ /** 送达消息(sendDelivery 调用)。 */
42
+ sentMessages: { customType: string; content: string; display: boolean; details?: unknown }[];
43
+ /** settled 边沿回调(测试手动触发模拟 pi agent_settled 事件)。 */
44
+ settledHandlers: Array<() => void>;
45
+ setIdle(idle: boolean): void;
46
+ /** 送达是否同步落盘为 custom_message entry。false = 模拟回执不可匹配(PS-6 触发态)。 */
47
+ deliverPersists: { value: boolean };
48
+ }
49
+
50
+ function makeLedgerHost(): LedgerHostMock {
51
+ const entries: LedgerHostMock["entries"] = [];
52
+ const sessionEntries: unknown[] = entries;
53
+ const sentMessages: LedgerHostMock["sentMessages"] = [];
54
+ const settledHandlers: Array<() => void> = [];
55
+ const idle = { value: true };
56
+ const deliverPersists = { value: true };
57
+ const host: NotifyLedgerHost = {
58
+ appendLedgerEntry: (customType, data) => {
59
+ entries.push({ type: "custom", customType, data: data as Record<string, unknown> });
60
+ },
61
+ readSessionEntries: () => sessionEntries,
62
+ isIdle: () => idle.value,
63
+ onAgentSettled: (handler) => {
64
+ settledHandlers.push(handler);
65
+ },
66
+ sendDelivery: (message) => {
67
+ sentMessages.push(message);
68
+ if (deliverPersists.value) {
69
+ sessionEntries.push({
70
+ type: "custom_message",
71
+ customType: message.customType,
72
+ content: message.content,
73
+ display: message.display,
74
+ details: message.details,
75
+ });
76
+ }
77
+ },
78
+ };
79
+ return {
80
+ host,
81
+ entries,
82
+ sessionEntries,
83
+ sentMessages,
84
+ settledHandlers,
85
+ setIdle: (v: boolean) => {
86
+ idle.value = v;
87
+ },
88
+ deliverPersists,
89
+ };
90
+ }
91
+
92
+ /** 触发一次 settled 边沿(模拟 pi agent_settled 事件)。 */
93
+ function fireSettled(mock: LedgerHostMock): void {
94
+ for (const handler of mock.settledHandlers) handler();
95
+ }
96
+
97
+ /** 未终态号差集:ledger entry 的 notifyId −(ack ∪ abandoned)的 notifyId。 */
98
+ function unsettledDiff(mock: LedgerHostMock): Set<string> {
99
+ const ledgerIds = new Set<string>();
100
+ const settled = new Set<string>();
101
+ for (const e of mock.entries) {
102
+ if (e.customType !== NOTIFY_LEDGER_CUSTOM_TYPE && e.customType !== NOTIFY_ACK_CUSTOM_TYPE && e.customType !== NOTIFY_ABANDONED_CUSTOM_TYPE) {
103
+ continue;
104
+ }
105
+ const notifyId = e.data?.["notifyId"];
106
+ if (typeof notifyId !== "string") continue;
107
+ if (e.customType === NOTIFY_LEDGER_CUSTOM_TYPE) ledgerIds.add(notifyId);
108
+ else settled.add(notifyId);
109
+ }
110
+ for (const id of settled) ledgerIds.delete(id);
111
+ return ledgerIds;
112
+ }
113
+
114
+ function abandonedEntries(mock: LedgerHostMock): { data?: Record<string, unknown> }[] {
115
+ return mock.entries.filter((e) => e.customType === NOTIFY_ABANDONED_CUSTOM_TYPE);
116
+ }
117
+
118
+ /** 从日志 sink 中取放弃 warn(区别于分桶 warn「notify delivery bucket [...]」)。 */
119
+ function abandonedWarns(logs: Array<{ level: string; message: string; data?: unknown }>): Array<{
120
+ level: string;
121
+ message: string;
122
+ data?: unknown;
123
+ }> {
124
+ return logs.filter((l) => l.level === "warn" && l.message.includes("abandoned"));
125
+ }
126
+
127
+ // ─── T4③ 重投止损(PS-6) ─────────────────────────────────
128
+
129
+ describe("NotifyLedger — T4③ 重投止损(PS-6)", () => {
130
+ let logCalls: Array<{ level: string; component: string; message: string; data?: unknown }>;
131
+
132
+ beforeEach(() => {
133
+ vi.useFakeTimers();
134
+ logCalls = [];
135
+ configureCore({
136
+ dataRoot: () => "/fake-notify-ledger-data-root",
137
+ log: (level, component, message, data) => {
138
+ logCalls.push({ level, component, message, data });
139
+ },
140
+ });
141
+ _resetNotifyLedgerForTest();
142
+ });
143
+ afterEach(() => {
144
+ _resetNotifyLedgerForTest();
145
+ resetCoreForTests();
146
+ vi.useRealTimers();
147
+ });
148
+
149
+ it("① 回执不可匹配 → 重投至 attempts 上限后放弃:abandoned 终态 entry + warn 含恢复指引,此后零重投", () => {
150
+ const mock = makeLedgerHost();
151
+ // 回执永不可匹配(模拟 compaction 清除送达 entry —— PS-6 触发态)
152
+ mock.deliverPersists.value = false;
153
+ const ledger = createNotifyLedger(mock.host);
154
+
155
+ ledger.record("sa-cap", "content", { notifyId: "sa-cap", id: "sa-cap", agent: "worker" });
156
+ ledger.attemptDeliver(); // 第 1 次投递(attempts=1)
157
+ expect(mock.sentMessages).toHaveLength(1);
158
+
159
+ // 未达上限:每周期照常超时重投(共 4 次重投,attempts 累至上限)
160
+ for (let i = 2; i <= NOTIFY_REDELIVERY_MAX_ATTEMPTS; i += 1) {
161
+ vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS);
162
+ expect(mock.sentMessages).toHaveLength(i);
163
+ }
164
+ expect(abandonedEntries(mock)).toHaveLength(0);
165
+ expect(ledger.waitingReceiptCount()).toBe(1);
166
+
167
+ // 第 5 个周期:attempts 已达上限仍无回执 → 放弃,不再重投
168
+ vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS);
169
+ expect(mock.sentMessages).toHaveLength(NOTIFY_REDELIVERY_MAX_ATTEMPTS); // 零新增
170
+ // 终态 entry 落盘(与 ack 同形:{ v:1, notifyId })
171
+ const abandoned = abandonedEntries(mock);
172
+ expect(abandoned).toHaveLength(1);
173
+ expect(abandoned[0]?.data).toEqual({ v: 1, notifyId: "sa-cap" });
174
+ // 账面摘除:pending / waiting 归零,差集为空(ledger − ack − abandoned)
175
+ expect(ledger.pendingCount()).toBe(0);
176
+ expect(ledger.waitingReceiptCount()).toBe(0);
177
+ expect(unsettledDiff(mock)).toEqual(new Set());
178
+ // 放弃不计入 watchdogReplays 桶(该桶口径 = 实际重投条数 = 上限 − 1)
179
+ expect(ledger.deliveryMetrics().watchdogReplays).toBe(NOTIFY_REDELIVERY_MAX_ATTEMPTS - 1);
180
+
181
+ // warn:含 subagent 标识(agent 名)与 subagents action:"list" 恢复指引;
182
+ // notifyId / attempts 动态值在 data(D4 约定,msg 近固定 key)
183
+ const warns = abandonedWarns(logCalls);
184
+ expect(warns).toHaveLength(1);
185
+ expect(warns[0]?.component).toBe("subagents");
186
+ expect(warns[0]?.message).toContain("worker");
187
+ expect(warns[0]?.message).toContain('subagents action:"list"');
188
+ expect(warns[0]?.data).toEqual({ notifyId: "sa-cap", attempts: NOTIFY_REDELIVERY_MAX_ATTEMPTS });
189
+
190
+ // 止损后看门狗空转:同一条通知不无限重复注入
191
+ vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS * 3);
192
+ expect(mock.sentMessages).toHaveLength(NOTIFY_REDELIVERY_MAX_ATTEMPTS);
193
+
194
+ // 放弃是终态:同 notifyId 再 record 被幂等拒绝(绝不重发)
195
+ expect(ledger.record("sa-cap", "content", { notifyId: "sa-cap" })).toBe(false);
196
+
197
+ ledger.dispose();
198
+ });
199
+
200
+ it("② 放弃条目重启恢复不复活:recoverFromSession 跳过 abandoned 差集,watchdog/settled 零投递", () => {
201
+ const mock = makeLedgerHost();
202
+ // 模拟重启前落盘:s-gone 已放弃(ledger + abandoned 两列),s-live 未销账未放弃
203
+ mock.sessionEntries.push(
204
+ { type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE, data: { v: 1, notifyId: "s-gone", content: "cg", record: { notifyId: "s-gone", agent: "gone-agent" } } },
205
+ { type: "custom", customType: NOTIFY_ABANDONED_CUSTOM_TYPE, data: { v: 1, notifyId: "s-gone" } },
206
+ { type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE, data: { v: 1, notifyId: "s-live", content: "cl", record: { notifyId: "s-live" } } },
207
+ );
208
+ mock.setIdle(false); // busy:恢复路径的 attemptDeliver 挂 pending,隔离投递时机
209
+
210
+ const ledger = createNotifyLedger(mock.host);
211
+ // 仅 s-live 重放;s-gone 被放弃终态挡住
212
+ expect(ledger.recoverFromSession()).toBe(1);
213
+ expect(mock.sentMessages).toHaveLength(0);
214
+ expect(ledger.pendingCount()).toBe(1);
215
+
216
+ // 放弃号幂等拒绝(重启后 record 也不复活)
217
+ expect(ledger.record("s-gone", "cg", { notifyId: "s-gone" })).toBe(false);
218
+
219
+ // 看门狗多周期 + settled 边沿:s-gone 零投递
220
+ vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS * 3);
221
+ expect(mock.sentMessages).toHaveLength(0);
222
+ fireSettled(mock);
223
+ expect(mock.sentMessages).toHaveLength(0);
224
+
225
+ // 对照:idle 后 s-live 正常补投(证明差集重放本身未被破坏)
226
+ mock.setIdle(true);
227
+ fireSettled(mock);
228
+ expect(mock.sentMessages).toHaveLength(1);
229
+ expect(mock.sentMessages[0]?.details).toMatchObject({ notifyId: "s-live" });
230
+
231
+ ledger.dispose();
232
+ });
233
+
234
+ it("③ 未达上限时正常重投行为不变:照常超时重投,回执到达后照常销账", () => {
235
+ const mock = makeLedgerHost();
236
+ // 前三轮回执不可匹配(重投路径)
237
+ mock.deliverPersists.value = false;
238
+ const ledger = createNotifyLedger(mock.host);
239
+
240
+ ledger.record("sa-ok", "content", { notifyId: "sa-ok", id: "sa-ok", agent: "worker" });
241
+ ledger.attemptDeliver(); // 第 1 次投递
242
+ expect(mock.sentMessages).toHaveLength(1);
243
+
244
+ // 两个周期无回执 → 两次照常重投(attempts=3 < 上限),无任何放弃痕迹
245
+ vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS * 2);
246
+ expect(mock.sentMessages).toHaveLength(3);
247
+ expect(abandonedEntries(mock)).toHaveLength(0);
248
+ expect(abandonedWarns(logCalls)).toHaveLength(0);
249
+ expect(ledger.deliveryMetrics().watchdogReplays).toBe(2);
250
+
251
+ // 回执恢复可达:本周期重投落盘 → settled 边沿销账(既有 at-least-once 闭环不变)
252
+ mock.deliverPersists.value = true;
253
+ vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS); // 第 3 次重投(本次落盘)
254
+ expect(mock.sentMessages).toHaveLength(4);
255
+ fireSettled(mock);
256
+ expect(unsettledDiff(mock)).toEqual(new Set());
257
+ expect(ledger.waitingReceiptCount()).toBe(0);
258
+ expect(abandonedEntries(mock)).toHaveLength(0);
259
+
260
+ ledger.dispose();
261
+ });
262
+
263
+ it("放弃终态持久化:abandoned entry 被 compaction 清除后按内存态补写,重启仍不复活", () => {
264
+ const mock = makeLedgerHost();
265
+ mock.deliverPersists.value = false;
266
+ const ledger = createNotifyLedger(mock.host);
267
+
268
+ ledger.record("sa-cp", "content", { notifyId: "sa-cp", id: "sa-cp", agent: "worker" });
269
+ ledger.attemptDeliver();
270
+ vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS * NOTIFY_REDELIVERY_MAX_ATTEMPTS); // 达上限放弃
271
+ expect(abandonedEntries(mock)).toHaveLength(1);
272
+
273
+ // 模拟 compaction 只清掉 abandoned entry(ledger entry 保留)
274
+ const idx = mock.entries.findIndex((e) => e.customType === NOTIFY_ABANDONED_CUSTOM_TYPE);
275
+ expect(idx).toBeGreaterThanOrEqual(0);
276
+ mock.entries.splice(idx, 1);
277
+
278
+ // compaction 降级补写放弃终态(内存是权威)
279
+ expect(ledger.compactionCheck()).toBe(1);
280
+ expect(abandonedEntries(mock)).toHaveLength(1);
281
+ ledger.dispose();
282
+
283
+ // 重启:abandoned 在场 → 不复活不重投
284
+ const mock2 = makeLedgerHost();
285
+ mock2.sessionEntries.push(...mock.entries);
286
+ mock2.setIdle(false);
287
+ const ledger2 = createNotifyLedger(mock2.host);
288
+ expect(ledger2.recoverFromSession()).toBe(0);
289
+ mock2.setIdle(true);
290
+ fireSettled(mock2);
291
+ expect(mock2.sentMessages).toHaveLength(0);
292
+ ledger2.dispose();
293
+ });
294
+ });