@zhushanwen/pi-subagent-workflow 0.1.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 (143) hide show
  1. package/agents/context-builder.md +17 -0
  2. package/agents/general-purpose.md +16 -0
  3. package/agents/oracle.md +17 -0
  4. package/agents/planner.md +17 -0
  5. package/agents/researcher.md +17 -0
  6. package/agents/reviewer.md +17 -0
  7. package/agents/scout.md +17 -0
  8. package/agents/worker.md +16 -0
  9. package/examples/README.md +43 -0
  10. package/examples/chain.example.js +92 -0
  11. package/examples/map-reduce.example.js +99 -0
  12. package/examples/parallel.example.js +82 -0
  13. package/examples/scatter-gather.example.js +106 -0
  14. package/index.ts +1 -0
  15. package/package.json +66 -0
  16. package/skills/workflow-script-format/SKILL.md +328 -0
  17. package/src/execution/__tests__/agent-registry.test.ts +164 -0
  18. package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
  19. package/src/execution/__tests__/alive-store.test.ts +147 -0
  20. package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
  21. package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
  22. package/src/execution/__tests__/config.test.ts +110 -0
  23. package/src/execution/__tests__/crash-recovery.test.ts +311 -0
  24. package/src/execution/__tests__/execute-nesting.test.ts +359 -0
  25. package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
  26. package/src/execution/__tests__/execution-record.test.ts +959 -0
  27. package/src/execution/__tests__/finalized-marker.test.ts +82 -0
  28. package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
  29. package/src/execution/__tests__/format.test.ts +320 -0
  30. package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
  31. package/src/execution/__tests__/list-component.test.ts +347 -0
  32. package/src/execution/__tests__/model-resolver.test.ts +356 -0
  33. package/src/execution/__tests__/output-collector.test.ts +61 -0
  34. package/src/execution/__tests__/path-encoding.test.ts +75 -0
  35. package/src/execution/__tests__/pi-invocation.test.ts +73 -0
  36. package/src/execution/__tests__/record-store.test.ts +545 -0
  37. package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
  38. package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
  39. package/src/execution/__tests__/sdk-contract.test.ts +272 -0
  40. package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
  41. package/src/execution/__tests__/session-file-gc.test.ts +247 -0
  42. package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
  43. package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
  44. package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
  45. package/src/execution/__tests__/spawn-args.test.ts +244 -0
  46. package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
  47. package/src/execution/__tests__/subagent-service.test.ts +678 -0
  48. package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
  49. package/src/execution/__tests__/temp-prompt.test.ts +53 -0
  50. package/src/execution/__tests__/timeout-integration.test.ts +381 -0
  51. package/src/execution/__tests__/tombstone-store.test.ts +73 -0
  52. package/src/execution/__tests__/tool-action.test.ts +330 -0
  53. package/src/execution/__tests__/turn-limiter.test.ts +65 -0
  54. package/src/execution/__tests__/worktree-manager.test.ts +423 -0
  55. package/src/execution/__tests__/worktree-registry.test.ts +161 -0
  56. package/src/execution/agent-registry.ts +252 -0
  57. package/src/execution/agent-result-mapper.ts +84 -0
  58. package/src/execution/alive-store.ts +92 -0
  59. package/src/execution/best-effort.ts +30 -0
  60. package/src/execution/concurrency-pool.ts +84 -0
  61. package/src/execution/config.ts +73 -0
  62. package/src/execution/execute-options-mapper.ts +86 -0
  63. package/src/execution/execution-record.ts +778 -0
  64. package/src/execution/finalized-marker.ts +51 -0
  65. package/src/execution/model-config-service.ts +225 -0
  66. package/src/execution/model-resolver.ts +247 -0
  67. package/src/execution/notifier.ts +168 -0
  68. package/src/execution/output-collector.ts +88 -0
  69. package/src/execution/path-encoding.ts +34 -0
  70. package/src/execution/pi-invocation.ts +70 -0
  71. package/src/execution/record-store.ts +350 -0
  72. package/src/execution/session-context-resolver.ts +64 -0
  73. package/src/execution/session-file-gc.ts +98 -0
  74. package/src/execution/session-reconstructor.ts +450 -0
  75. package/src/execution/session-runner.ts +725 -0
  76. package/src/execution/spawn-event-adapter.ts +150 -0
  77. package/src/execution/subagent-service.ts +973 -0
  78. package/src/execution/subprocess-agent-runner.ts +108 -0
  79. package/src/execution/temp-prompt.ts +57 -0
  80. package/src/execution/tombstone-store.ts +72 -0
  81. package/src/execution/turn-limiter.ts +88 -0
  82. package/src/execution/types.ts +634 -0
  83. package/src/execution/worktree-manager.ts +285 -0
  84. package/src/execution/worktree-registry.ts +144 -0
  85. package/src/index.ts +454 -0
  86. package/src/interface/bg-notify-render.ts +286 -0
  87. package/src/interface/commands.ts +157 -0
  88. package/src/interface/format.ts +501 -0
  89. package/src/interface/gui-adapter.ts +136 -0
  90. package/src/interface/helpers.ts +110 -0
  91. package/src/interface/list-component.ts +643 -0
  92. package/src/interface/list-shared.ts +84 -0
  93. package/src/interface/list-view.ts +373 -0
  94. package/src/interface/reentry-guard.ts +30 -0
  95. package/src/interface/subagent-actions.ts +294 -0
  96. package/src/interface/subagent-tool.ts +294 -0
  97. package/src/interface/subagents.ts +30 -0
  98. package/src/interface/tool-render.ts +333 -0
  99. package/src/interface/tool-workflow-script.ts +351 -0
  100. package/src/interface/tool-workflow.ts +485 -0
  101. package/src/interface/views/WorkflowsView.ts +944 -0
  102. package/src/interface/views/detail-content.ts +298 -0
  103. package/src/interface/views/format.ts +320 -0
  104. package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
  105. package/src/orchestration/__tests__/config-loader.test.ts +381 -0
  106. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
  107. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
  108. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
  109. package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
  110. package/src/orchestration/__tests__/script-lint.test.ts +347 -0
  111. package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
  112. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
  113. package/src/orchestration/agent-opts-resolver.ts +128 -0
  114. package/src/orchestration/concurrency-gate.ts +69 -0
  115. package/src/orchestration/config-loader.ts +313 -0
  116. package/src/orchestration/error-recovery.ts +578 -0
  117. package/src/orchestration/execute-agent-call.ts +174 -0
  118. package/src/orchestration/jsonl-run-store.ts +292 -0
  119. package/src/orchestration/launcher.ts +368 -0
  120. package/src/orchestration/lifecycle.ts +373 -0
  121. package/src/orchestration/models/__tests__/budget.test.ts +367 -0
  122. package/src/orchestration/models/agent-call.ts +76 -0
  123. package/src/orchestration/models/budget.ts +148 -0
  124. package/src/orchestration/models/ports.ts +165 -0
  125. package/src/orchestration/models/run-runtime.ts +91 -0
  126. package/src/orchestration/models/run-spec.ts +54 -0
  127. package/src/orchestration/models/run-state.ts +44 -0
  128. package/src/orchestration/models/trace.ts +102 -0
  129. package/src/orchestration/models/types.ts +242 -0
  130. package/src/orchestration/models/workflow-run.ts +275 -0
  131. package/src/orchestration/models/workflow-script-registry.ts +32 -0
  132. package/src/orchestration/models/workflow-script.ts +90 -0
  133. package/src/orchestration/node-ops.ts +192 -0
  134. package/src/orchestration/script-lint.ts +387 -0
  135. package/src/orchestration/skill-discovery.ts +60 -0
  136. package/src/orchestration/worker-handle.ts +115 -0
  137. package/src/orchestration/worker-host.ts +93 -0
  138. package/src/orchestration/worker-script-builder.ts +281 -0
  139. package/src/orchestration/workflow-files.ts +85 -0
  140. package/src/orchestration/workflow-script-registry-impl.ts +128 -0
  141. package/src/shared/__tests__/resource-discovery.test.ts +226 -0
  142. package/src/shared/agent-event.ts +13 -0
  143. package/src/shared/resource-discovery.ts +535 -0
@@ -0,0 +1,423 @@
1
+ // src/__tests__/worktree-manager.test.ts
2
+ //
3
+ // WorktreeManager 单元测试。
4
+ // mock execFileSync(git 命令)+ WorktreeRegistry(注册表)+ alive-store(进程探活)。
5
+ //
6
+ // [全局注册表重构] scan 不再读 sidecar / 不再依赖 cwd 是否 git repo。
7
+ // 判据从「终态 marker 状态机」改为「pid 死活」。
8
+ // 测试重点覆盖原方案缺失的崩溃残留场景(P0)。
9
+
10
+ import { beforeEach, describe, expect, it, vi } from "vitest";
11
+
12
+ import { DirtyWorktreeError } from "../types.ts";
13
+
14
+ // ── mock modules ──
15
+
16
+ vi.mock("node:child_process", () => ({
17
+ execFileSync: vi.fn(),
18
+ }));
19
+
20
+ vi.mock("node:fs", () => ({
21
+ default: {
22
+ existsSync: vi.fn(),
23
+ rmSync: vi.fn(),
24
+ symlinkSync: vi.fn(),
25
+ writeFileSync: vi.fn(),
26
+ },
27
+ existsSync: vi.fn(),
28
+ rmSync: vi.fn(),
29
+ symlinkSync: vi.fn(),
30
+ writeFileSync: vi.fn(),
31
+ }));
32
+
33
+ vi.mock("../alive-store.ts", () => ({
34
+ isProcessAlive: vi.fn(),
35
+ }));
36
+
37
+ // WorktreeRegistry mock:内存数组模拟,add/updatePid/remove/load 全部可追踪
38
+ const { mockLoad, mockAdd, mockUpdatePid, mockRemove, registryEntries } = vi.hoisted(() => {
39
+ type Entry = { repo: string; branch: string; checkout: string; pid: number; createdAt: number };
40
+ const entries: Entry[] = [];
41
+ return {
42
+ registryEntries: entries,
43
+ mockLoad: vi.fn((): Entry[] => entries.slice()),
44
+ mockAdd: vi.fn((e: Entry): void => {
45
+ const idx = entries.findIndex((x) => x.branch === e.branch);
46
+ if (idx >= 0) entries[idx] = e;
47
+ else entries.push(e);
48
+ }),
49
+ mockUpdatePid: vi.fn((branch: string, pid: number): void => {
50
+ const e = entries.find((x) => x.branch === branch);
51
+ if (e) e.pid = pid;
52
+ }),
53
+ mockRemove: vi.fn((branch: string): void => {
54
+ const idx = entries.findIndex((x) => x.branch === branch);
55
+ if (idx >= 0) entries.splice(idx, 1);
56
+ }),
57
+ };
58
+ });
59
+
60
+ vi.mock("../worktree-registry.ts", () => ({
61
+ WorktreeRegistry: class {
62
+ add = mockAdd;
63
+ updatePid = mockUpdatePid;
64
+ remove = mockRemove;
65
+ load = mockLoad;
66
+ },
67
+ SPAWN_GRACE_MS: 60_000,
68
+ }));
69
+
70
+ import { execFileSync } from "node:child_process";
71
+ import * as fs from "node:fs";
72
+ import * as os from "node:os";
73
+ import * as path from "node:path";
74
+
75
+ import { encodeCwd } from "../path-encoding.ts";
76
+ import { isProcessAlive } from "../alive-store.ts";
77
+ import { WorktreeManager } from "../worktree-manager.ts";
78
+
79
+ const mockExec = vi.mocked(execFileSync);
80
+ const mockExistsSync = vi.mocked(fs.existsSync);
81
+ const mockIsProcessAlive = vi.mocked(isProcessAlive);
82
+
83
+ const MAIN_CWD = "/home/user/project";
84
+ const AGENT_DIR = "/home/user/.pi/agent";
85
+ const RECORD_ID = "bg-42-abc";
86
+ const BASE_COMMIT = "abc123def456";
87
+
88
+ /** create 路径期望(tmpdir/pi-subagents/<enc(mainCwd)> 下) */
89
+ function expectedCreatePath(recordId: string): string {
90
+ return path.join(os.tmpdir(), "pi-subagents", encodeCwd(MAIN_CWD), `pi-sub-${recordId}`);
91
+ }
92
+
93
+ /** 构造完整 handle(含 mainCwd,供 cleanup/collectPatch 测试用) */
94
+ function makeHandle(checkoutPath: string = expectedCreatePath(RECORD_ID)) {
95
+ return Object.freeze({
96
+ path: checkoutPath,
97
+ branch: `pi-sub-${RECORD_ID}`,
98
+ baseCommit: BASE_COMMIT,
99
+ mainCwd: MAIN_CWD,
100
+ });
101
+ }
102
+
103
+ function setupCleanTree(): void {
104
+ mockExec.mockImplementation((_cmd: string, args?: readonly string[]) => {
105
+ if (args?.[0] === "status") return "";
106
+ if (args?.[0] === "rev-parse" && args?.[1] === "HEAD") return BASE_COMMIT;
107
+ if (args?.[0] === "worktree") return "";
108
+ if (args?.[0] === "branch") return "";
109
+ return "";
110
+ });
111
+ // worktreePath 不存在(无需前置清理);node_modules 存在
112
+ mockExistsSync.mockImplementation((p: unknown) => {
113
+ const s = String(p);
114
+ if (s.includes("pi-sub-")) return false; // checkout 目录不存在
115
+ if (s.includes("node_modules")) return true;
116
+ return false;
117
+ });
118
+ }
119
+
120
+ /** 向注册表注入一条活条目(模拟 create 后的状态)。 */
121
+ function injectEntry(overrides: Partial<{ branch: string; pid: number; checkout: string; repo: string; createdAt: number }> = {}): void {
122
+ registryEntries.push({
123
+ repo: overrides.repo ?? MAIN_CWD,
124
+ branch: overrides.branch ?? "pi-sub-orphan1",
125
+ checkout: overrides.checkout ?? path.join(os.tmpdir(), "pi-sub-orphan1"),
126
+ pid: overrides.pid ?? 0,
127
+ createdAt: overrides.createdAt ?? Date.now(),
128
+ });
129
+ }
130
+
131
+ describe("WorktreeManager", () => {
132
+ let mgr: WorktreeManager;
133
+
134
+ beforeEach(() => {
135
+ vi.clearAllMocks();
136
+ registryEntries.length = 0;
137
+ mgr = new WorktreeManager(AGENT_DIR);
138
+ });
139
+
140
+ describe("create", () => {
141
+ it("正常流程返回冻结 handle(path 在 tmpdir,含 mainCwd)", () => {
142
+ setupCleanTree();
143
+
144
+ const handle = mgr.create(MAIN_CWD, RECORD_ID);
145
+
146
+ expect(handle.path).toBe(expectedCreatePath(RECORD_ID));
147
+ expect(handle.branch).toBe(`pi-sub-${RECORD_ID}`);
148
+ expect(handle.baseCommit).toBe(BASE_COMMIT);
149
+ expect(handle.mainCwd).toBe(MAIN_CWD);
150
+ expect(Object.isFrozen(handle)).toBe(true);
151
+ });
152
+
153
+ it("成功后写入注册表(pid=0 占位)", () => {
154
+ setupCleanTree();
155
+
156
+ mgr.create(MAIN_CWD, RECORD_ID);
157
+
158
+ expect(mockAdd).toHaveBeenCalledTimes(1);
159
+ const entry = mockAdd.mock.calls[0][0] as { repo: string; branch: string; pid: number };
160
+ expect(entry.repo).toBe(MAIN_CWD);
161
+ expect(entry.branch).toBe(`pi-sub-${RECORD_ID}`);
162
+ expect(entry.pid).toBe(0);
163
+ });
164
+
165
+ it("脏树抛 DirtyWorktreeError 且不写注册表", () => {
166
+ mockExec.mockImplementation((_cmd: string, args?: readonly string[]) => {
167
+ if (args?.[0] === "status") return "M src/index.ts\n";
168
+ return "";
169
+ });
170
+
171
+ expect(() => mgr.create(MAIN_CWD, RECORD_ID)).toThrow(DirtyWorktreeError);
172
+ expect(mockAdd).not.toHaveBeenCalled();
173
+ });
174
+
175
+ it("recordId 含特殊字符抛 DirtyWorktreeError", () => {
176
+ expect(() => mgr.create(MAIN_CWD, "../evil-path")).toThrow(DirtyWorktreeError);
177
+ expect(() => mgr.create(MAIN_CWD, "hello world")).toThrow(DirtyWorktreeError);
178
+ expect(() => mgr.create(MAIN_CWD, "")).toThrow(DirtyWorktreeError);
179
+ expect(() => mgr.create(MAIN_CWD, "a;b")).toThrow(DirtyWorktreeError);
180
+ });
181
+
182
+ it("recordId 单字符 / 连续短横线合法", () => {
183
+ setupCleanTree();
184
+ expect(mgr.create(MAIN_CWD, "a").branch).toBe("pi-sub-a");
185
+ expect(mgr.create(MAIN_CWD, "--test--").branch).toBe("pi-sub---test--");
186
+ });
187
+
188
+ it("残留 checkout 目录存在时前置清理(fs.rmSync)", () => {
189
+ setupCleanTree();
190
+ // worktreePath 已存在 → 触发前置 rmSync
191
+ mockExistsSync.mockImplementation((p: unknown) => {
192
+ const s = String(p);
193
+ if (s.includes("pi-sub-bg-42-abc")) return true; // checkout 残留
194
+ if (s.includes("node_modules")) return true;
195
+ return false;
196
+ });
197
+
198
+ mgr.create(MAIN_CWD, RECORD_ID);
199
+
200
+ expect(fs.rmSync).toHaveBeenCalledWith(
201
+ expectedCreatePath(RECORD_ID),
202
+ { recursive: true, force: true },
203
+ );
204
+ });
205
+
206
+ it("symlink 失败时回滚 worktree + 分支 + 注册表", () => {
207
+ setupCleanTree();
208
+ // symlink 抛错触发 MF#3 回滚
209
+ vi.mocked(fs.symlinkSync).mockImplementation(() => {
210
+ throw new Error("symlink permission denied");
211
+ });
212
+
213
+ expect(() => mgr.create(MAIN_CWD, RECORD_ID)).toThrow("symlink permission denied");
214
+
215
+ // 回滚:worktree remove + branch delete + registry remove
216
+ expect(mockExec).toHaveBeenCalledWith(
217
+ "git",
218
+ expect.arrayContaining(["worktree", "remove", "--force"]),
219
+ expect.anything(),
220
+ );
221
+ expect(mockExec).toHaveBeenCalledWith(
222
+ "git",
223
+ expect.arrayContaining(["branch", "-D"]),
224
+ expect.anything(),
225
+ );
226
+ expect(mockRemove).toHaveBeenCalledWith(`pi-sub-${RECORD_ID}`);
227
+ });
228
+ });
229
+
230
+ describe("registerPid", () => {
231
+ it("委托 registry.updatePid", () => {
232
+ mgr.registerPid("pi-sub-bg-1", 12345);
233
+ expect(mockUpdatePid).toHaveBeenCalledWith("pi-sub-bg-1", 12345);
234
+ });
235
+ });
236
+
237
+ describe("cleanup", () => {
238
+ it("worktree remove + branch -D + 注册表移除(cwd 用 handle.mainCwd)", () => {
239
+ mockExec.mockReturnValue("");
240
+ const handle = makeHandle();
241
+
242
+ mgr.cleanup(handle);
243
+
244
+ expect(mockExec).toHaveBeenCalledWith(
245
+ "git",
246
+ ["worktree", "remove", "--force", handle.path],
247
+ expect.objectContaining({ cwd: MAIN_CWD }),
248
+ );
249
+ expect(mockExec).toHaveBeenCalledWith(
250
+ "git",
251
+ ["branch", "-D", handle.branch],
252
+ expect.objectContaining({ cwd: MAIN_CWD }),
253
+ );
254
+ expect(mockRemove).toHaveBeenCalledWith(handle.branch);
255
+ });
256
+
257
+ it("worktree remove 失败时 branch -D + 注册表移除仍执行(best-effort 分离)", () => {
258
+ // 第一条 git(worktree remove)抛错,第二条(branch -D)应仍执行
259
+ let callCount = 0;
260
+ mockExec.mockImplementation(() => {
261
+ callCount++;
262
+ if (callCount === 1) throw new Error("worktree locked");
263
+ return "";
264
+ });
265
+ const handle = makeHandle();
266
+
267
+ // 不应抛错
268
+ expect(() => mgr.cleanup(handle)).not.toThrow();
269
+
270
+ // branch -D 仍被调用
271
+ expect(mockExec).toHaveBeenCalledWith(
272
+ "git",
273
+ ["branch", "-D", handle.branch],
274
+ expect.anything(),
275
+ );
276
+ // 注册表仍被移除
277
+ expect(mockRemove).toHaveBeenCalledWith(handle.branch);
278
+ });
279
+ });
280
+
281
+ describe("collectPatch", () => {
282
+ it("有改动返回 patch 文件", () => {
283
+ mockExec.mockReturnValue("diff --git a/src\n+// new");
284
+
285
+ const handle = makeHandle();
286
+ const patchFile = path.join(os.tmpdir(), `outside-${RECORD_ID}.patch`);
287
+
288
+ const result = mgr.collectPatch(handle, patchFile);
289
+
290
+ expect(result.failed).toBe(false);
291
+ expect(result.written).toBe(true);
292
+ expect(mockExec).toHaveBeenCalledWith("git", ["add", "-A"], expect.anything());
293
+ expect(mockExec).toHaveBeenCalledWith(
294
+ "git",
295
+ ["diff", "--cached", BASE_COMMIT],
296
+ expect.anything(),
297
+ );
298
+ });
299
+
300
+ it("无改动返回 failed=false, written=false(不写文件)", () => {
301
+ mockExec.mockReturnValue("");
302
+ const result = mgr.collectPatch(makeHandle(), "/tmp/x.patch");
303
+ expect(result.failed).toBe(false);
304
+ expect(result.written).toBe(false);
305
+ });
306
+ });
307
+
308
+ // ============================================================
309
+ // scan:全局注册表 + pid 死活判据(核心重构)
310
+ // ============================================================
311
+ describe("scan", () => {
312
+ it("pid 已死的条目被清理(正常退出未 cleanup / 崩溃残留)", () => {
313
+ injectEntry({ branch: "pi-sub-dead", pid: 11111 });
314
+ mockIsProcessAlive.mockReturnValue(false); // pid 死
315
+
316
+ mgr.scan();
317
+
318
+ expect(mockExec).toHaveBeenCalledWith(
319
+ "git",
320
+ ["worktree", "remove", "--force", expect.any(String)],
321
+ expect.objectContaining({ cwd: MAIN_CWD }),
322
+ );
323
+ expect(mockExec).toHaveBeenCalledWith(
324
+ "git",
325
+ ["branch", "-D", "pi-sub-dead"],
326
+ expect.objectContaining({ cwd: MAIN_CWD }),
327
+ );
328
+ expect(mockRemove).toHaveBeenCalledWith("pi-sub-dead");
329
+ });
330
+
331
+ it("pid 活的条目不删(绝不删活进程)", () => {
332
+ injectEntry({ branch: "pi-sub-alive", pid: 22222 });
333
+ mockIsProcessAlive.mockReturnValue(true); // pid 活
334
+
335
+ mgr.scan();
336
+
337
+ const removeCalls = mockExec.mock.calls.filter(
338
+ (c) => c[1]?.[0] === "worktree" && c[1]?.[1] === "remove",
339
+ );
340
+ expect(removeCalls).toHaveLength(0);
341
+ expect(mockRemove).not.toHaveBeenCalled();
342
+ });
343
+
344
+ it("崩溃残留:无终态 + pid 死 → 清理(原 P0 缺陷场景)", () => {
345
+ // 这是原 reaper 永远泄漏的场景:进程崩溃无人写终态 marker
346
+ injectEntry({ branch: "pi-sub-crash", pid: 33333 });
347
+ mockIsProcessAlive.mockReturnValue(false); // 崩溃后进程已死
348
+
349
+ mgr.scan();
350
+
351
+ expect(mockExec).toHaveBeenCalledWith(
352
+ "git",
353
+ ["branch", "-D", "pi-sub-crash"],
354
+ expect.anything(),
355
+ );
356
+ expect(mockRemove).toHaveBeenCalledWith("pi-sub-crash");
357
+ });
358
+
359
+ it("pid=0 + 未超 SPAWN_GRACE → 跳过(可能正在 spawn)", () => {
360
+ injectEntry({ branch: "pi-sub-spawning", pid: 0, createdAt: Date.now() });
361
+
362
+ mgr.scan();
363
+
364
+ const removeCalls = mockExec.mock.calls.filter(
365
+ (c) => c[1]?.[0] === "worktree" && c[1]?.[1] === "remove",
366
+ );
367
+ expect(removeCalls).toHaveLength(0);
368
+ });
369
+
370
+ it("pid=0 + 超 SPAWN_GRACE → 清理(create 后崩溃)", () => {
371
+ const sixtyOneMinAgo = Date.now() - 61 * 60 * 1000;
372
+ injectEntry({ branch: "pi-sub-spawn-crash", pid: 0, createdAt: sixtyOneMinAgo });
373
+
374
+ mgr.scan();
375
+
376
+ expect(mockExec).toHaveBeenCalledWith(
377
+ "git",
378
+ ["branch", "-D", "pi-sub-spawn-crash"],
379
+ expect.anything(),
380
+ );
381
+ expect(mockRemove).toHaveBeenCalledWith("pi-sub-spawn-crash");
382
+ });
383
+
384
+ it("跨 repo 清理:条目 repo 字段作为 git -C 目标", () => {
385
+ const OTHER_REPO = "/home/user/other-repo";
386
+ injectEntry({ branch: "pi-sub-cross", pid: 44444, repo: OTHER_REPO });
387
+ mockIsProcessAlive.mockReturnValue(false);
388
+
389
+ mgr.scan();
390
+
391
+ // git 命令的 cwd 应为 OTHER_REPO,非 MAIN_CWD
392
+ expect(mockExec).toHaveBeenCalledWith(
393
+ "git",
394
+ expect.arrayContaining(["branch", "-D", "pi-sub-cross"]),
395
+ expect.objectContaining({ cwd: OTHER_REPO }),
396
+ );
397
+ });
398
+
399
+ it("空注册表时无操作", () => {
400
+ // registryEntries 已在 beforeEach 清空
401
+ mgr.scan();
402
+ expect(mockExec).not.toHaveBeenCalled();
403
+ expect(mockRemove).not.toHaveBeenCalled();
404
+ });
405
+
406
+ it("worktree remove 失败时 branch -D + 注册表移除仍执行(best-effort)", () => {
407
+ injectEntry({ branch: "pi-sub-stubborn", pid: 55555 });
408
+ mockIsProcessAlive.mockReturnValue(false);
409
+ mockExec.mockImplementation(() => {
410
+ throw new Error("worktree locked");
411
+ });
412
+
413
+ mgr.scan();
414
+
415
+ // branch -D 仍被调用(两次 git 调用各自独立)
416
+ const branchDeleteCalls = mockExec.mock.calls.filter(
417
+ (c) => c[1]?.[0] === "branch" && c[1]?.[1] === "-D",
418
+ );
419
+ expect(branchDeleteCalls).toHaveLength(1);
420
+ expect(mockRemove).toHaveBeenCalledWith("pi-sub-stubborn");
421
+ });
422
+ });
423
+ });
@@ -0,0 +1,161 @@
1
+ // src/__tests__/worktree-registry.test.ts
2
+ //
3
+ // WorktreeRegistry 单元测试。
4
+ // 用真实 tmpdir 做文件 IO(不 mock fs),验证:
5
+ // - add/updatePid/remove/load 语义
6
+ // - 同 branch 覆盖(去重)
7
+ // - 文件不存在 / 损坏 / IO 错误的降级
8
+ // - 原子写(.tmp → rename)
9
+
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+
14
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
15
+
16
+ import { SPAWN_GRACE_MS, type WorktreeEntry,WorktreeRegistry } from "../worktree-registry.ts";
17
+
18
+ const REPO_A = "/home/user/repo-a";
19
+ const REPO_B = "/home/user/repo-b";
20
+
21
+ function makeEntry(overrides: Partial<WorktreeEntry> = {}): WorktreeEntry {
22
+ return {
23
+ repo: REPO_A,
24
+ branch: "pi-sub-bg-1",
25
+ checkout: path.join(os.tmpdir(), "pi-sub-bg-1"),
26
+ pid: 0,
27
+ createdAt: Date.now(),
28
+ ...overrides,
29
+ };
30
+ }
31
+
32
+ describe("WorktreeRegistry", () => {
33
+ let tmpAgentDir: string;
34
+ let registry: WorktreeRegistry;
35
+ let registryFile: string;
36
+
37
+ beforeEach(() => {
38
+ tmpAgentDir = fs.mkdtempSync(path.join(os.tmpdir(), "wt-registry-test-"));
39
+ registry = new WorktreeRegistry(tmpAgentDir);
40
+ registryFile = path.join(tmpAgentDir, "subagents", "worktrees.json");
41
+ });
42
+
43
+ afterEach(() => {
44
+ fs.rmSync(tmpAgentDir, { recursive: true, force: true });
45
+ });
46
+
47
+ describe("add + load", () => {
48
+ it("add 后 load 能读到", () => {
49
+ const entry = makeEntry();
50
+ registry.add(entry);
51
+ const loaded = registry.load();
52
+ expect(loaded).toHaveLength(1);
53
+ expect(loaded[0]).toEqual(entry);
54
+ });
55
+
56
+ it("多条目跨 repo 共存", () => {
57
+ const entryA = makeEntry({ repo: REPO_A, branch: "pi-sub-bg-1" });
58
+ const entryB = makeEntry({ repo: REPO_B, branch: "pi-sub-bg-2" });
59
+ registry.add(entryA);
60
+ registry.add(entryB);
61
+ const loaded = registry.load();
62
+ expect(loaded).toHaveLength(2);
63
+ });
64
+
65
+ it("同 branch add 覆盖(去重)", () => {
66
+ const entry = makeEntry({ pid: 0 });
67
+ registry.add(entry);
68
+ // 同 branch 再次 add(覆盖)
69
+ const updated = makeEntry({ pid: 999 });
70
+ registry.add(updated);
71
+ const loaded = registry.load();
72
+ expect(loaded).toHaveLength(1);
73
+ expect(loaded[0].pid).toBe(999);
74
+ });
75
+ });
76
+
77
+ describe("updatePid", () => {
78
+ it("补全 pid(create 占位 → first header 补全)", () => {
79
+ registry.add(makeEntry({ branch: "pi-sub-bg-1", pid: 0 }));
80
+ registry.updatePid("pi-sub-bg-1", 12345);
81
+ const loaded = registry.load();
82
+ expect(loaded[0].pid).toBe(12345);
83
+ });
84
+
85
+ it("branch 不存在时忽略(幂等)", () => {
86
+ registry.add(makeEntry({ branch: "pi-sub-bg-1" }));
87
+ registry.updatePid("pi-sub-nonexistent", 12345);
88
+ const loaded = registry.load();
89
+ expect(loaded).toHaveLength(1);
90
+ expect(loaded[0].pid).toBe(0); // 原条目未变
91
+ });
92
+
93
+ it("update 不改变其他字段", () => {
94
+ const entry = makeEntry({ branch: "pi-sub-bg-1", repo: REPO_A, checkout: "/tmp/x" });
95
+ registry.add(entry);
96
+ registry.updatePid("pi-sub-bg-1", 999);
97
+ const loaded = registry.load();
98
+ expect(loaded[0].repo).toBe(REPO_A);
99
+ expect(loaded[0].checkout).toBe("/tmp/x");
100
+ expect(loaded[0].branch).toBe("pi-sub-bg-1");
101
+ });
102
+ });
103
+
104
+ describe("remove", () => {
105
+ it("移除指定 branch", () => {
106
+ registry.add(makeEntry({ branch: "pi-sub-bg-1" }));
107
+ registry.add(makeEntry({ branch: "pi-sub-bg-2" }));
108
+ registry.remove("pi-sub-bg-1");
109
+ const loaded = registry.load();
110
+ expect(loaded).toHaveLength(1);
111
+ expect(loaded[0].branch).toBe("pi-sub-bg-2");
112
+ });
113
+
114
+ it("branch 不存在时忽略(幂等)", () => {
115
+ registry.add(makeEntry({ branch: "pi-sub-bg-1" }));
116
+ registry.remove("pi-sub-nonexistent");
117
+ expect(registry.load()).toHaveLength(1);
118
+ });
119
+
120
+ it("空注册表 remove 不报错", () => {
121
+ expect(() => registry.remove("pi-sub-anything")).not.toThrow();
122
+ });
123
+ });
124
+
125
+ describe("降级与健壮性", () => {
126
+ it("文件不存在时 load 返回空数组", () => {
127
+ expect(registry.load()).toEqual([]);
128
+ });
129
+
130
+ it("损坏 JSON 时 load 返回空数组", () => {
131
+ fs.mkdirSync(path.dirname(registryFile), { recursive: true });
132
+ fs.writeFileSync(registryFile, "{ not valid json }}}", "utf-8");
133
+ expect(registry.load()).toEqual([]);
134
+ });
135
+
136
+ it("entries 字段缺失时 load 返回空数组", () => {
137
+ fs.mkdirSync(path.dirname(registryFile), { recursive: true });
138
+ fs.writeFileSync(registryFile, '{"other": 123}', "utf-8");
139
+ expect(registry.load()).toEqual([]);
140
+ });
141
+
142
+ it("save 创建不存在的父目录", () => {
143
+ // registryFile 在 tmpAgentDir/subagents/ 下,subagents 目录不存在
144
+ expect(fs.existsSync(path.dirname(registryFile))).toBe(false);
145
+ registry.add(makeEntry());
146
+ expect(fs.existsSync(registryFile)).toBe(true);
147
+ });
148
+
149
+ it("save 后无残留 .tmp 文件(原子写)", () => {
150
+ registry.add(makeEntry());
151
+ const tmpFile = `${registryFile}.tmp`;
152
+ expect(fs.existsSync(tmpFile)).toBe(false);
153
+ });
154
+ });
155
+
156
+ describe("SPAWN_GRACE_MS 常量", () => {
157
+ it("值为 60s", () => {
158
+ expect(SPAWN_GRACE_MS).toBe(60_000);
159
+ });
160
+ });
161
+ });