@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,228 @@
1
+ // src/execution/__tests__/session-pending.test.ts
2
+ //
3
+ // [LC-6/T6②] session-pending 增量游标有界化:register−unregister 差集计数 + 剪枝。
4
+ //
5
+ // 覆盖:
6
+ // - 差集等值(语义不变):register/unregister 差集、同 id 重 register 覆盖、
7
+ // unregister 后同 id 再 register 恢复活跃、count 端口收到的 = 活跃差集、
8
+ // latestUnregister 60s 唤醒窗口判据不受差集化影响。
9
+ // - 剪枝:文件删除后 cursor 自动剪枝(重建同名文件从头全量读,不残留旧 offset);
10
+ // prunePendingCursor 原语(进程 close 侧回收);prune 不存在条目 no-op。
11
+ //
12
+ // 真实 fs(临时目录)——增量游标的 offset/差集语义被真实文件形态执行
13
+ //(对齐 descendant-sweep.test.ts 模式;该文件的差集清单/交错用例继续回归)。
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
+ import { configureNotifyDomain, resetNotifyDomainForTests } from "../../core/notify-ports.ts";
22
+ import {
23
+ clearPendingCursors,
24
+ listActivePendingFromSessionFile,
25
+ prunePendingCursor,
26
+ readActivePendingFromSessionFile,
27
+ } from "../session-pending.ts";
28
+
29
+ let sessionDir = "";
30
+
31
+ beforeEach(() => {
32
+ clearPendingCursors();
33
+ sessionDir = fs.mkdtempSync(path.join(os.tmpdir(), "session-pending-"));
34
+ });
35
+
36
+ afterEach(() => {
37
+ clearPendingCursors();
38
+ resetNotifyDomainForTests();
39
+ fs.rmSync(sessionDir, { recursive: true, force: true });
40
+ });
41
+
42
+ function entryLine(customType: string, data: Record<string, unknown>): string {
43
+ return `${JSON.stringify({
44
+ type: "custom",
45
+ customType,
46
+ data,
47
+ timestamp: "2026-09-01T00:00:00.000Z",
48
+ id: `e-${Math.random().toString(36).slice(2)}`,
49
+ })}\n`;
50
+ }
51
+
52
+ function registerData(id: string, sessionId: string): Record<string, unknown> {
53
+ return { id, type: "session", name: `desc-${id}`, status: "active", registeredAt: 1, expiresAt: undefined, sessionId };
54
+ }
55
+
56
+ /** 注入直数端口:返回收到的 entry 数(观察 count 口径的消费面)。 */
57
+ function stubCountingPort(): (entries: unknown[]) => number {
58
+ const counter = vi.fn((entries: unknown[]) => entries.length);
59
+ configureNotifyDomain({ countActiveFromEntries: counter });
60
+ return counter;
61
+ }
62
+
63
+ describe("[LC-6] 差集计数语义等值", () => {
64
+ it("register−unregister 差集:unregister 后 count/list 归零", () => {
65
+ const counter = stubCountingPort();
66
+ const f = path.join(sessionDir, "a.jsonl");
67
+ fs.writeFileSync(
68
+ f,
69
+ entryLine("pending:register", registerData("bg-1", "sess-1")) +
70
+ entryLine("pending:register", registerData("bg-2", "sess-2")) +
71
+ entryLine("pending:unregister", { id: "bg-1" }),
72
+ );
73
+
74
+ const r = readActivePendingFromSessionFile(f);
75
+ expect(r.error).toBeUndefined();
76
+ expect(r.count).toBe(1); // 2 register − 1 unregister
77
+ expect(counter.mock.calls[0]?.[0]).toHaveLength(1); // 端口收到差集(非全量 3 行)
78
+ expect((counter.mock.calls[0]?.[0] as unknown[])[0]).toMatchObject({ customType: "pending:register" });
79
+
80
+ const list = listActivePendingFromSessionFile(f);
81
+ expect(list.items.map((i) => i.id)).toEqual(["bg-2"]);
82
+ });
83
+
84
+ it("同 id 重 register(未 unregister)覆盖 → 仍 1 个活跃", () => {
85
+ stubCountingPort();
86
+ const f = path.join(sessionDir, "b.jsonl");
87
+ fs.writeFileSync(f, entryLine("pending:register", registerData("bg-1", "sess-1")));
88
+ fs.appendFileSync(f, entryLine("pending:register", registerData("bg-1", "sess-1-moved")));
89
+
90
+ const list = listActivePendingFromSessionFile(f);
91
+ expect(list.items).toHaveLength(1);
92
+ expect(list.items[0]?.sessionId).toBe("sess-1-moved");
93
+ });
94
+
95
+ it("unregister 后同 id 再 register → 恢复活跃", () => {
96
+ stubCountingPort();
97
+ const f = path.join(sessionDir, "c.jsonl");
98
+ fs.writeFileSync(
99
+ f,
100
+ entryLine("pending:register", registerData("bg-1", "sess-1")) +
101
+ entryLine("pending:unregister", { id: "bg-1" }) +
102
+ entryLine("pending:register", registerData("bg-1", "sess-2")),
103
+ );
104
+
105
+ const list = listActivePendingFromSessionFile(f);
106
+ expect(list.items.map((i) => i.id)).toEqual(["bg-1"]);
107
+ expect(list.items[0]?.sessionId).toBe("sess-2");
108
+ expect(readActivePendingFromSessionFile(f).count).toBe(1);
109
+ });
110
+
111
+ it("latestUnregister 60s 唤醒窗口判据不受差集化影响", () => {
112
+ stubCountingPort();
113
+ const f = path.join(sessionDir, "d.jsonl");
114
+ const nowIso = new Date().toISOString();
115
+ fs.writeFileSync(
116
+ f,
117
+ entryLine("pending:register", registerData("bg-1", "sess-1")) +
118
+ JSON.stringify({
119
+ type: "custom",
120
+ customType: "pending:unregister",
121
+ data: { id: "bg-1" },
122
+ timestamp: nowIso,
123
+ id: "e-unreg",
124
+ }) +
125
+ "\n",
126
+ );
127
+
128
+ const r = readActivePendingFromSessionFile(f);
129
+ expect(r.count).toBe(0); // 差集归零
130
+ expect(r.recentUnregister).toBe(true); // unregister 时刻仍被游标记录(唤醒窗口语义保持)
131
+ });
132
+
133
+ it("缺 data.id 的畸形 pending 行丢弃,不影响其余差集", () => {
134
+ stubCountingPort();
135
+ const f = path.join(sessionDir, "e.jsonl");
136
+ fs.writeFileSync(
137
+ f,
138
+ entryLine("pending:register", {}) + // 畸形:无 id
139
+ entryLine("pending:register", registerData("bg-2", "sess-2")),
140
+ );
141
+
142
+ const list = listActivePendingFromSessionFile(f);
143
+ expect(list.items.map((i) => i.id)).toEqual(["bg-2"]);
144
+ });
145
+ });
146
+
147
+ describe("[LC-6] 游标剪枝", () => {
148
+ it("文件删除后 cursor 自动剪枝:重建同名文件从头全量读(不残留旧 offset)", () => {
149
+ stubCountingPort();
150
+ const f = path.join(sessionDir, "reborn.jsonl");
151
+ // 第一代:15 字节/行 × 10 行 filler → cursor offset = 150
152
+ const gen1 = Array.from({ length: 10 }, (_, i) => `filler-line-${String(i).padStart(2, "0")}\n`).join("");
153
+ fs.writeFileSync(f, gen1);
154
+ expect(readActivePendingFromSessionFile(f).count).toBe(0);
155
+
156
+ // 删除后的一次判定触发剪枝(stat ENOENT → cursors.delete,惰性剪枝语义)。
157
+ fs.unlinkSync(f);
158
+ const duringAbsent = readActivePendingFromSessionFile(f);
159
+ expect(duringAbsent.error).toBeDefined();
160
+
161
+ // 重建同名文件:新内容 size > 旧 offset(150),bg-x 的 register 行落在旧 offset
162
+ // 之内——cursor 未剪枝时会从 byte 150 续读漏掉它。
163
+ const gen2 =
164
+ entryLine("pending:register", registerData("bg-x", "sess-x")) +
165
+ "filler".padEnd(200, ".") +
166
+ "\n";
167
+ expect(Buffer.byteLength(gen2, "utf-8")).toBeGreaterThan(150);
168
+ fs.writeFileSync(f, gen2);
169
+
170
+ const list = listActivePendingFromSessionFile(f);
171
+ expect(list.items.map((i) => i.id)).toEqual(["bg-x"]); // 剪枝生效:从头全量读
172
+ });
173
+
174
+ it("prunePendingCursor:回收后重新判定等值(全量重读差集不变)", () => {
175
+ stubCountingPort();
176
+ const f = path.join(sessionDir, "pruned.jsonl");
177
+ fs.writeFileSync(
178
+ f,
179
+ entryLine("pending:register", registerData("bg-1", "sess-1")) +
180
+ entryLine("pending:register", registerData("bg-2", "sess-2")),
181
+ );
182
+ expect(readActivePendingFromSessionFile(f).count).toBe(2);
183
+
184
+ prunePendingCursor(f); // 进程 close 侧回收
185
+
186
+ fs.appendFileSync(f, entryLine("pending:unregister", { id: "bg-1" }));
187
+ const r = readActivePendingFromSessionFile(f);
188
+ expect(r.count).toBe(1); // 重建后差集正确(无重复无丢失)
189
+ expect(listActivePendingFromSessionFile(f).items.map((i) => i.id)).toEqual(["bg-2"]);
190
+ });
191
+
192
+ it("prunePendingCursor:prune 后覆写文件(size ≥ 旧 offset)→ 新内容完整可见", () => {
193
+ stubCountingPort();
194
+ const f = path.join(sessionDir, "overwritten.jsonl");
195
+ const gen1 = Array.from({ length: 10 }, (_, i) => `filler-line-${String(i).padStart(2, "0")}\n`).join("");
196
+ fs.writeFileSync(f, gen1);
197
+ readActivePendingFromSessionFile(f); // offset = 160
198
+
199
+ const gen2 =
200
+ entryLine("pending:register", registerData("bg-y", "sess-y")) +
201
+ "filler".padEnd(200, ".") +
202
+ "\n";
203
+ fs.writeFileSync(f, gen2); // 覆写(模拟文件替换),size > 旧 offset
204
+ prunePendingCursor(f);
205
+
206
+ expect(listActivePendingFromSessionFile(f).items.map((i) => i.id)).toEqual(["bg-y"]);
207
+ });
208
+
209
+ it("prunePendingCursor 不存在的条目为 no-op(不抛错)", () => {
210
+ expect(() => prunePendingCursor(path.join(sessionDir, "never-seen.jsonl"))).not.toThrow();
211
+ });
212
+
213
+ it("读失败(文件不存在)→ error 面返回且 cursor 已剪(后续可全量重建)", () => {
214
+ stubCountingPort();
215
+ const f = path.join(sessionDir, "vanish.jsonl");
216
+ fs.writeFileSync(f, entryLine("pending:register", registerData("bg-1", "sess-1")));
217
+ expect(readActivePendingFromSessionFile(f).count).toBe(1);
218
+
219
+ fs.unlinkSync(f);
220
+ const r = readActivePendingFromSessionFile(f); // 剪枝触发点(stat ENOENT)
221
+ expect(r.error).toBeDefined();
222
+ expect(r.count).toBe(0);
223
+
224
+ // 重建后从文件头判定(无 offset 残留),条目完整恢复
225
+ fs.writeFileSync(f, entryLine("pending:register", registerData("bg-1", "sess-1")));
226
+ expect(readActivePendingFromSessionFile(f).count).toBe(1);
227
+ });
228
+ });
@@ -0,0 +1,110 @@
1
+ // src/execution/__tests__/session-runner-branch-cache-lru.test.ts
2
+ //
3
+ // [LC-8/T6③] branchCache LRU 上界。
4
+ //
5
+ // 背景(设计 §4.3 LC-8):缓存 key=cwd,worktree 路径每次唯一 → 模块级 Map 永不淘汰,
6
+ // 条目按 path 永久累积(长寿命 orchestrator 内存无界,实锤·轻微)。T6③ 加 LRU 上限
7
+ // BRANCH_CACHE_MAX_ENTRIES=64(常量导出)。
8
+ //
9
+ // 覆盖(验收:65 个路径插入后规模 64,最旧被淘汰):
10
+ // - 65 次 miss 插入 → size 封顶 64,execFile 恰 65 次;
11
+ // - 最旧条目已被淘汰(再查重新 spawn git),次旧条目仍在(LRU 只淘汰最旧);
12
+ // - get 命中刷新 LRU 序(touch 后不再是淘汰候选);
13
+ // - 常量导出值 = 64。
14
+
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
+
17
+ // buildEnvBlock 用 execFile 异步取 git branch:按 opts.cwd 回填可区分的 branch 名。
18
+ const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }));
19
+ vi.mock("node:child_process", async () => {
20
+ const actual = await import("node:child_process");
21
+ return { ...actual, execFile: execFileMock };
22
+ });
23
+
24
+ // Mock 共享 logger(buildEnvBlock 的 git 失败 debug 留痕不落真实日志盘)。
25
+ const { loggerMock } = vi.hoisted(() => ({
26
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
27
+ }));
28
+ vi.mock("../../core/logger.ts", () => ({ getLogger: () => loggerMock }));
29
+
30
+ import {
31
+ BRANCH_CACHE_MAX_ENTRIES,
32
+ _getBranchCacheSizeForTest,
33
+ _resetBranchCacheForTest,
34
+ buildEnvBlock,
35
+ } from "../session-runner.ts";
36
+
37
+ /** execFile mock 回填 br-<cwd>(branch 与 cwd 一一对应,淘汰行为可断言)。 */
38
+ function stubGitBranchSuccess(): void {
39
+ execFileMock.mockImplementation(
40
+ (
41
+ _cmd: string,
42
+ _args: readonly string[],
43
+ opts: { cwd?: string },
44
+ cb: (err: Error | null, stdout?: string, stderr?: string) => void,
45
+ ) => cb(null, `br-${opts?.cwd ?? "unknown"}`, ""),
46
+ );
47
+ }
48
+
49
+ beforeEach(() => {
50
+ _resetBranchCacheForTest();
51
+ stubGitBranchSuccess();
52
+ });
53
+
54
+ afterEach(() => {
55
+ _resetBranchCacheForTest();
56
+ execFileMock.mockReset();
57
+ loggerMock.debug.mockClear();
58
+ });
59
+
60
+ describe("[LC-8] branchCache LRU 上界", () => {
61
+ it("常量导出 BRANCH_CACHE_MAX_ENTRIES = 64", () => {
62
+ expect(BRANCH_CACHE_MAX_ENTRIES).toBe(64);
63
+ });
64
+
65
+ it("65 个路径插入后规模封顶 64,最旧被淘汰(重新 spawn git)", async () => {
66
+ const cwds = Array.from({ length: BRANCH_CACHE_MAX_ENTRIES + 1 }, (_, i) => `/tmp/lru-cwd-${i}`);
67
+ for (const cwd of cwds) {
68
+ const block = await buildEnvBlock(cwd);
69
+ expect(block).toContain(`Git branch: br-${cwd}`);
70
+ }
71
+ // 65 次 miss 插入,第 65 次插入时淘汰最旧(cwd-0)→ execFile 恰 65 次、规模 64
72
+ expect(execFileMock).toHaveBeenCalledTimes(BRANCH_CACHE_MAX_ENTRIES + 1);
73
+ expect(_getBranchCacheSizeForTest()).toBe(BRANCH_CACHE_MAX_ENTRIES);
74
+
75
+ // 最旧(cwd-0)已被 LRU 淘汰 → 再次查询重新 spawn git(66 次);重插后淘汰次旧
76
+ //(cwd-1),更近插入的 cwd-2 不受影响
77
+ const block0 = await buildEnvBlock("/tmp/lru-cwd-0");
78
+ expect(block0).toContain("Git branch: br-/tmp/lru-cwd-0");
79
+ expect(execFileMock).toHaveBeenCalledTimes(BRANCH_CACHE_MAX_ENTRIES + 2);
80
+
81
+ // cwd-2(仍近插入序)仍在缓存 → 查询不再 spawn(仍 66 次)
82
+ await buildEnvBlock("/tmp/lru-cwd-2");
83
+ expect(execFileMock).toHaveBeenCalledTimes(BRANCH_CACHE_MAX_ENTRIES + 2);
84
+ });
85
+
86
+ it("get 命中刷新 LRU 序:被 touch 的条目不再是最旧淘汰候选", async () => {
87
+ const cwdA = "/tmp/lru-touch-a";
88
+ const cwdB = "/tmp/lru-touch-b";
89
+ await buildEnvBlock(cwdA); // A 先插
90
+ await buildEnvBlock(cwdB); // B 后插
91
+ expect(execFileMock).toHaveBeenCalledTimes(2);
92
+
93
+ await buildEnvBlock(cwdA); // 命中并 touch → A 变最新,B 变最旧;零 spawn
94
+ expect(execFileMock).toHaveBeenCalledTimes(2);
95
+
96
+ // 再插 63 个新条目挤爆缓存(2 + 63 = 65 → 淘汰恰 1 个最旧):B(最旧)被淘汰、
97
+ // A(被 touch 过,次新)存活
98
+ for (let i = 0; i < BRANCH_CACHE_MAX_ENTRIES - 1; i++) {
99
+ await buildEnvBlock(`/tmp/lru-flood-${i}`);
100
+ }
101
+ expect(_getBranchCacheSizeForTest()).toBe(BRANCH_CACHE_MAX_ENTRIES);
102
+ const spawnsAfterFlood = execFileMock.mock.calls.length;
103
+
104
+ await buildEnvBlock(cwdA); // A 被 touch 过 → 仍在缓存
105
+ expect(execFileMock).toHaveBeenCalledTimes(spawnsAfterFlood);
106
+
107
+ await buildEnvBlock(cwdB); // B 最早未 touch → 已被淘汰,重新 spawn
108
+ expect(execFileMock).toHaveBeenCalledTimes(spawnsAfterFlood + 1);
109
+ });
110
+ });
@@ -0,0 +1,181 @@
1
+ // src/execution/__tests__/session-runner-close-prune.test.ts
2
+ //
3
+ // [LC-6/T6②] 进程 close 接线:waitForChildExit close handler 调 prunePendingCursor
4
+ // 剪枝该进程 sessionFile 的 pending 增量游标(session-runner 领地内的单点接线,主
5
+ // agent 裁决授权)。
6
+ //
7
+ // 覆盖:
8
+ // 1. close(resume 锁定 sessionFile)后 cursor 被剪枝——行为观察面:覆写同名文件
9
+ // (新 register 行落在旧 offset 之内、size ≥ 旧 offset),判定应从头全量读出
10
+ // 新条目;cursor 未剪时会从旧 offset 续读丢掉它。
11
+ // 2. sessionFile 未回填(无 resume、无 header 回填)时 close 跳过剪枝不抛错
12
+ // (此时也无 cursor:判定从未发生)。
13
+ //
14
+ // mock 拓扑对齐 run-spawn-resume.test.ts(FakeChild / fs 部分 mock / temp-prompt /
15
+ // alive-store);session-pending 刻意**不 mock**——接线行为的观察面就是真实游标
16
+ // 剪枝语义(statSync/readFileSync 不在 fs mock 名单,走真实 fs)。
17
+
18
+ import { spawn } from "node:child_process";
19
+ import * as fs from "node:fs";
20
+ import { writeFile } from "node:fs/promises";
21
+ import * as os from "node:os";
22
+ import * as path from "node:path";
23
+
24
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
25
+
26
+ const { loggerMock } = vi.hoisted(() => ({
27
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
28
+ }));
29
+ vi.mock("../../core/logger.ts", () => ({ getLogger: () => loggerMock }));
30
+
31
+ vi.mock("node:child_process", async () => {
32
+ const { FakeChild } = await import("./helpers/spawn-mock.ts");
33
+ return {
34
+ spawn: vi.fn(() => new FakeChild()),
35
+ execFile: vi.fn(
36
+ (
37
+ _cmd: string,
38
+ _args: readonly string[],
39
+ _opts: unknown,
40
+ cb: (err: Error | null, stdout?: string, stderr?: string) => void,
41
+ ) => cb(new Error("execFile not configured in this test")),
42
+ ),
43
+ };
44
+ });
45
+
46
+ // [注意] 工厂内必须 importOriginal 拿真 actual——不带它 `await import("node:fs")` 自引用
47
+ // 返回 mock 面。命名面先 ...actual 展开(session-pending 的 statSync/readFileSync、
48
+ // 测试 fixture 的 mkdtempSync/rmSync 走真实 fs;fixture 写盘走真实透传的
49
+ // node:fs/promises——writeFileSync 本身被 mock 隔离 runSpawn 的生产写入),再覆盖
50
+ // runSpawn 生产写入面的 5 个方法(隔离 ~/.xyz-agent 生产路径写)。
51
+ vi.mock("node:fs", async (importOriginal) => {
52
+ const actual = await importOriginal<typeof import("node:fs")>();
53
+ return {
54
+ default: {
55
+ ...actual,
56
+ mkdirSync: vi.fn(),
57
+ existsSync: vi.fn(() => false),
58
+ appendFileSync: vi.fn(),
59
+ writeFileSync: vi.fn(),
60
+ readdirSync: vi.fn(() => []),
61
+ },
62
+ ...actual,
63
+ mkdirSync: vi.fn(),
64
+ existsSync: vi.fn(() => false),
65
+ appendFileSync: vi.fn(),
66
+ writeFileSync: vi.fn(),
67
+ readdirSync: vi.fn(() => []),
68
+ promises: actual.promises,
69
+ };
70
+ });
71
+
72
+ vi.mock("../alive-store.ts", () => ({
73
+ writeAliveMarker: vi.fn(),
74
+ }));
75
+
76
+ vi.mock("../temp-prompt.ts", () => ({
77
+ writePromptToTempFile: vi.fn(async (agent: string) => {
78
+ const safeName = agent.replace(/[^\w.-]+/g, "_");
79
+ return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt-${safeName}.md` };
80
+ }),
81
+ cleanupTempPrompt: vi.fn(async () => {}),
82
+ }));
83
+
84
+ import { runSpawn } from "../session-runner.ts";
85
+ import { clearPendingCursors, listActivePendingFromSessionFile } from "../session-pending.ts";
86
+ import {
87
+ type FakeChild,
88
+ lastSpawnedChild as lastSpawnedChildOf,
89
+ makeCtx,
90
+ makeOpts,
91
+ makeRecord,
92
+ waitForSpawn as waitForSpawnOf,
93
+ } from "./helpers/spawn-mock.ts";
94
+
95
+ const mockSpawn = vi.mocked(spawn);
96
+ const mockExistsSync = vi.mocked(fs.existsSync);
97
+
98
+ const lastSpawnedChild = (): FakeChild => lastSpawnedChildOf(mockSpawn);
99
+ const waitForSpawn = (timeoutMs = 1000): Promise<void> => waitForSpawnOf(mockSpawn, timeoutMs);
100
+
101
+ let sessionDir = "";
102
+
103
+ function entryLine(customType: string, data: Record<string, unknown>): string {
104
+ return `${JSON.stringify({
105
+ type: "custom",
106
+ customType,
107
+ data,
108
+ timestamp: "2026-09-01T00:00:00.000Z",
109
+ id: `e-${Math.random().toString(36).slice(2)}`,
110
+ })}\n`;
111
+ }
112
+
113
+ function registerData(id: string, sessionId: string): Record<string, unknown> {
114
+ return { id, type: "session", name: `desc-${id}`, status: "active", registeredAt: 1, expiresAt: undefined, sessionId };
115
+ }
116
+
117
+ /** resume + close 的最短驱动:spawn 后直接 end stdout + close(handshake 经 abandon 放弃)。 */
118
+ async function runToClose(sessionFile: string): Promise<void> {
119
+ const record = makeRecord();
120
+ const promise = runSpawn(record, "Task: close prune", makeOpts(), makeCtx(), {
121
+ sessionFile,
122
+ });
123
+ await waitForSpawn();
124
+ const child = lastSpawnedChild();
125
+ // resume.sessionFile 真实存在 → existsSync 对它 true(identity 补写路径走通)
126
+ mockExistsSync.mockImplementation((p: unknown) => String(p) === sessionFile);
127
+ child.stdin.on("data", () => {}); // 消费 stdin 防背压
128
+ child.stdout.end();
129
+ child.emit("close", 0);
130
+ await promise;
131
+ }
132
+
133
+ beforeEach(() => {
134
+ vi.clearAllMocks();
135
+ clearPendingCursors();
136
+ mockExistsSync.mockReturnValue(false);
137
+ sessionDir = fs.mkdtempSync(path.join(os.tmpdir(), "close-prune-"));
138
+ });
139
+
140
+ afterEach(() => {
141
+ clearPendingCursors();
142
+ vi.restoreAllMocks();
143
+ fs.rmSync(sessionDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
144
+ });
145
+
146
+ describe("[LC-6] 进程 close 剪枝 pending 游标", () => {
147
+ it("close 后 cursor 被剪枝:覆写同名文件后判定从头全量读(旧 offset 不残留)", async () => {
148
+ const f = path.join(sessionDir, "closed-proc.jsonl");
149
+ // gen1:15 字节/行 × 10 行 filler → 预建 cursor offset = 150
150
+ const gen1 = Array.from({ length: 10 }, (_, i) => `filler-line-${String(i).padStart(2, "0")}\n`).join("");
151
+ await writeFile(f, gen1); // 真实写盘(同步 writeFileSync 被 mock 隔离生产写入)
152
+ expect(listActivePendingFromSessionFile(f).items).toHaveLength(0); // cursor 建立
153
+
154
+ // 覆写(模拟磁盘状态变化):register bg-y 落在旧 offset(150)之内、size > 150——
155
+ // cursor 未剪时会从 byte 150 续读丢掉它。
156
+ const gen2 =
157
+ entryLine("pending:register", registerData("bg-y", "sess-y")) +
158
+ "filler".padEnd(150, ".") +
159
+ "\n";
160
+ expect(Buffer.byteLength(gen2, "utf-8")).toBeGreaterThan(150);
161
+ await writeFile(f, gen2);
162
+
163
+ await runToClose(f); // resume 锁定 sessionFile → close handler 剪枝
164
+
165
+ const items = listActivePendingFromSessionFile(f).items;
166
+ expect(items.map((i) => i.id)).toEqual(["bg-y"]); // 剪枝生效:从头全量读
167
+ });
168
+
169
+ it("sessionFile 未回填(无 resume/无 header)时 close 跳过剪枝,流程正常完成", async () => {
170
+ const record = makeRecord();
171
+ const promise = runSpawn(record, "Task: no sessionFile", makeOpts(), makeCtx());
172
+ await waitForSpawn();
173
+ const child = lastSpawnedChild();
174
+ child.stdin.on("data", () => {});
175
+ child.stdout.end();
176
+ child.emit("close", 0);
177
+ // sessionFile undefined → guard 跳过(不抛错);record.sessionFile 保持未回填
178
+ await expect(promise).resolves.toBeDefined();
179
+ expect(record.sessionFile).toBeUndefined();
180
+ });
181
+ });
@@ -54,6 +54,7 @@ vi.mock("../alive-store.ts", () => ({ writeAliveMarker: vi.fn() }));
54
54
 
55
55
  vi.mock("../session-pending.ts", () => ({
56
56
  readActivePendingFromSessionFile: vi.fn(() => ({ count: 0 })),
57
+ prunePendingCursor: vi.fn(),
57
58
  }));
58
59
 
59
60
  vi.mock("../temp-prompt.ts", () => ({