@zhushanwen/pi-subagent-workflow 7.3.4 → 8.0.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 (166) hide show
  1. package/README.md +39 -12
  2. package/agents/analyst.md +61 -0
  3. package/agents/coder.md +70 -0
  4. package/agents/debugger.md +67 -0
  5. package/agents/doc-reviewer.md +3 -3
  6. package/agents/explorer.md +50 -18
  7. package/agents/general-purpose.md +19 -8
  8. package/agents/orchestrator.md +37 -32
  9. package/agents/planner.md +45 -11
  10. package/agents/researcher.md +53 -11
  11. package/agents/reviewer.md +74 -0
  12. package/package.json +1 -1
  13. package/skills/workflow-script-format/SKILL.md +1 -1
  14. package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
  15. package/src/execution/__tests__/agent-registry.test.ts +13 -11
  16. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
  17. package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
  18. package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
  19. package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
  20. package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
  21. package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
  22. package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
  23. package/src/execution/__tests__/crash-recovery.test.ts +8 -2
  24. package/src/execution/__tests__/delivery-methods.test.ts +385 -0
  25. package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
  26. package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
  27. package/src/execution/__tests__/execute-nesting.test.ts +20 -72
  28. package/src/execution/__tests__/execution-record.test.ts +199 -0
  29. package/src/execution/__tests__/finalize-record.test.ts +170 -14
  30. package/src/execution/__tests__/format.test.ts +131 -7
  31. package/src/execution/__tests__/gc-timer.test.ts +184 -0
  32. package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
  33. package/src/execution/__tests__/helpers/spawn-mock.ts +25 -7
  34. package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
  35. package/src/execution/__tests__/index-session-start.test.ts +257 -5
  36. package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
  37. package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
  38. package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
  39. package/src/execution/__tests__/list-component.test.ts +59 -5
  40. package/src/execution/__tests__/list-fields.test.ts +109 -0
  41. package/src/execution/__tests__/model-resolver.test.ts +38 -1
  42. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
  43. package/src/execution/__tests__/nested-visibility.test.ts +325 -0
  44. package/src/execution/__tests__/notifier-flush.test.ts +209 -7
  45. package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
  46. package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
  47. package/src/execution/__tests__/record-store.test.ts +158 -52
  48. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
  49. package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
  50. package/src/execution/__tests__/resource-policy.test.ts +109 -0
  51. package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
  52. package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
  53. package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
  54. package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
  55. package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
  56. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
  57. package/src/execution/__tests__/session-pending.test.ts +61 -2
  58. package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
  59. package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
  60. package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
  61. package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
  62. package/src/execution/__tests__/spawn-args.test.ts +127 -49
  63. package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
  64. package/src/execution/__tests__/spawned-children.test.ts +92 -0
  65. package/src/execution/__tests__/status-refactor.test.ts +345 -0
  66. package/src/execution/__tests__/stdin-writer.test.ts +97 -0
  67. package/src/execution/__tests__/subagent-service-message-close.test.ts +598 -0
  68. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
  69. package/src/execution/__tests__/subagent-service.test.ts +49 -11
  70. package/src/execution/__tests__/timeout-integration.test.ts +27 -13
  71. package/src/execution/__tests__/tool-action.test.ts +12 -10
  72. package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
  73. package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
  74. package/src/execution/__tests__/worktree-manager.test.ts +292 -89
  75. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +13 -12
  76. package/src/execution/argv-mirror.ts +21 -2
  77. package/src/execution/execution-record.ts +126 -9
  78. package/src/execution/finalize-record.ts +90 -13
  79. package/src/execution/host-mode.ts +1 -1
  80. package/src/execution/lifecycle-manager.ts +484 -0
  81. package/src/execution/lifecycle-predicates.ts +65 -0
  82. package/src/execution/manifest-store.ts +61 -16
  83. package/src/execution/model-resolver.ts +26 -5
  84. package/src/execution/notifier.ts +69 -12
  85. package/src/execution/pi-invocation.ts +21 -1
  86. package/src/execution/record-store.ts +554 -107
  87. package/src/execution/session-pending.ts +116 -45
  88. package/src/execution/session-reconstructor.ts +224 -7
  89. package/src/execution/session-runner.ts +290 -75
  90. package/src/execution/sessions-index.ts +304 -0
  91. package/src/execution/stdin-writer.ts +93 -7
  92. package/src/execution/stream-sink.ts +20 -3
  93. package/src/execution/subagent-service.ts +867 -138
  94. package/src/execution/turn-limiter.ts +14 -0
  95. package/src/execution/types.ts +204 -22
  96. package/src/execution/worktree-manager.ts +128 -49
  97. package/src/execution/worktree-registry.ts +13 -2
  98. package/src/index.ts +277 -19
  99. package/src/injectors/subagent-list-injector.ts +26 -8
  100. package/src/injectors/workflow-list-injector.ts +25 -8
  101. package/src/interface/__tests__/subagent-tool-prompt.test.ts +18 -5
  102. package/src/interface/__tests__/tool-render.test.ts +15 -13
  103. package/src/interface/bg-notify-render.ts +32 -8
  104. package/src/interface/command-actions.ts +26 -7
  105. package/src/interface/commands.ts +21 -22
  106. package/src/interface/format.ts +44 -17
  107. package/src/interface/gui-mappers.ts +6 -8
  108. package/src/interface/helpers.ts +170 -10
  109. package/src/interface/list-component.ts +53 -14
  110. package/src/interface/subagent-actions.ts +235 -17
  111. package/src/interface/subagent-tool.ts +82 -17
  112. package/src/interface/subagents.ts +2 -1
  113. package/src/interface/tool-render.ts +11 -24
  114. package/src/interface/tool-workflow.ts +20 -35
  115. package/src/interface/views/WorkflowsView.ts +89 -32
  116. package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
  117. package/src/interface/views/__tests__/detail-content-session-file.test.ts +1 -1
  118. package/src/interface/views/format.ts +3 -3
  119. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +325 -0
  120. package/src/orchestration/__tests__/args-validator.test.ts +1 -1
  121. package/src/orchestration/__tests__/config-loader.test.ts +38 -0
  122. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
  123. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
  124. package/src/orchestration/__tests__/execute-agent-call.test.ts +95 -0
  125. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +657 -18
  126. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
  127. package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
  128. package/src/orchestration/__tests__/skill-discovery.test.ts +157 -0
  129. package/src/orchestration/__tests__/test-mocks.ts +191 -0
  130. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +98 -0
  131. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
  132. package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
  133. package/src/orchestration/__tests__/workflows-e2e.test.ts +1 -1
  134. package/src/orchestration/agent-opts-resolver.ts +4 -1
  135. package/src/orchestration/args-validator.ts +2 -2
  136. package/src/orchestration/config-loader.ts +30 -1
  137. package/src/orchestration/error-recovery.ts +133 -29
  138. package/src/orchestration/execute-agent-call.ts +31 -7
  139. package/src/orchestration/jsonl-run-store.ts +287 -40
  140. package/src/orchestration/launcher.ts +7 -1
  141. package/src/orchestration/lifecycle.ts +135 -132
  142. package/src/orchestration/models/__tests__/trace.test.ts +408 -0
  143. package/src/orchestration/models/budget.ts +1 -1
  144. package/src/orchestration/models/run-runtime.ts +15 -17
  145. package/src/orchestration/models/run-spec.ts +2 -2
  146. package/src/orchestration/models/run-state.ts +3 -3
  147. package/src/orchestration/models/trace.ts +95 -15
  148. package/src/orchestration/models/types.ts +8 -9
  149. package/src/orchestration/models/workflow-run.ts +50 -71
  150. package/src/orchestration/models/workflow-script.ts +32 -1
  151. package/src/orchestration/skill-discovery.ts +30 -0
  152. package/src/orchestration/worker-handle.ts +1 -1
  153. package/src/orchestration/worker-host.ts +1 -1
  154. package/src/orchestration/worker-script-builder.ts +29 -10
  155. package/src/shared/__tests__/agent-ref.test.ts +34 -0
  156. package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
  157. package/src/shared/__tests__/resource-discovery.test.ts +55 -0
  158. package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
  159. package/src/shared/agent-ref.ts +16 -0
  160. package/src/shared/resource-discovery.ts +147 -58
  161. package/src/shared/schema-jsonify.ts +53 -0
  162. package/workflows/README.md +4 -4
  163. package/agents/code-reviewer.md +0 -47
  164. package/agents/context-builder.md +0 -21
  165. package/agents/oracle.md +0 -34
  166. package/agents/worker.md +0 -20
@@ -1,7 +1,7 @@
1
1
  // src/__tests__/worktree-manager.test.ts
2
2
  //
3
3
  // WorktreeManager 单元测试。
4
- // mock execFileSync(git 命令)+ WorktreeRegistry(注册表)+ alive-store(进程探活)。
4
+ // mock execFile(git 命令)+ WorktreeRegistry(注册表)+ alive-store(进程探活)。
5
5
  //
6
6
  // [全局注册表重构] scan 不再读 sidecar / 不再依赖 cwd 是否 git repo。
7
7
  // 判据从「终态 marker 状态机」改为「pid 死活」。
@@ -14,7 +14,7 @@ import { DirtyWorktreeError } from "../types.ts";
14
14
  // ── mock modules ──
15
15
 
16
16
  vi.mock("node:child_process", () => ({
17
- execFileSync: vi.fn(),
17
+ execFile: vi.fn(),
18
18
  }));
19
19
 
20
20
  vi.mock("node:fs", () => ({
@@ -36,7 +36,7 @@ vi.mock("../alive-store.ts", () => ({
36
36
 
37
37
  // WorktreeRegistry mock:内存数组模拟,add/updatePid/remove/load 全部可追踪
38
38
  const { mockLoad, mockAdd, mockUpdatePid, mockRemove, registryEntries } = vi.hoisted(() => {
39
- type Entry = { repo: string; branch: string; checkout: string; pid: number; createdAt: number };
39
+ type Entry = { repo: string; branch: string; checkout: string; pid: number; createdAt: number; sessionFile?: string };
40
40
  const entries: Entry[] = [];
41
41
  return {
42
42
  registryEntries: entries,
@@ -46,9 +46,12 @@ const { mockLoad, mockAdd, mockUpdatePid, mockRemove, registryEntries } = vi.hoi
46
46
  if (idx >= 0) entries[idx] = e;
47
47
  else entries.push(e);
48
48
  }),
49
- mockUpdatePid: vi.fn((branch: string, pid: number): void => {
49
+ mockUpdatePid: vi.fn((branch: string, pid: number, sessionFile?: string): void => {
50
50
  const e = entries.find((x) => x.branch === branch);
51
- if (e) e.pid = pid;
51
+ if (e) {
52
+ e.pid = pid;
53
+ if (sessionFile !== undefined) e.sessionFile = sessionFile;
54
+ }
52
55
  }),
53
56
  mockRemove: vi.fn((branch: string): void => {
54
57
  const idx = entries.findIndex((x) => x.branch === branch);
@@ -67,7 +70,8 @@ vi.mock("../worktree-registry.ts", () => ({
67
70
  SPAWN_GRACE_MS: 60_000,
68
71
  }));
69
72
 
70
- import { execFileSync } from "node:child_process";
73
+
74
+ import { execFile } from "node:child_process";
71
75
  import * as fs from "node:fs";
72
76
  import * as os from "node:os";
73
77
  import * as path from "node:path";
@@ -76,10 +80,29 @@ import { isProcessAlive } from "../alive-store.ts";
76
80
  import { encodeCwd } from "../path-encoding.ts";
77
81
  import { WorktreeManager } from "../worktree-manager.ts";
78
82
 
79
- const mockExec = vi.mocked(execFileSync);
83
+ const mockExecFile = vi.mocked(execFile);
80
84
  const mockExistsSync = vi.mocked(fs.existsSync);
81
85
  const mockIsProcessAlive = vi.mocked(isProcessAlive);
82
86
 
87
+ /**
88
+ * gitRunAsync(execFile callback 风格)mock 装配。
89
+ * impl 返回 {err?, stdout?, stderr?};err 模拟失败路径(exitCode=killed/signal 挂在 err 上)。
90
+ */
91
+ type ExecFileResult = {
92
+ err?: (Error & { code?: unknown; killed?: boolean; signal?: string }) | null;
93
+ stdout?: string;
94
+ stderr?: string;
95
+ };
96
+ function setupExecFile(impl?: (args: readonly string[]) => ExecFileResult): void {
97
+ mockExecFile.mockImplementation(
98
+ (_cmd: string, args: readonly string[], _opts: unknown, cb: (err: Error | null, stdout?: string, stderr?: string) => void) => {
99
+ const r = impl ? impl(args) : { stdout: "" };
100
+ if (r.err) cb(r.err, r.stdout ?? "", r.stderr ?? "");
101
+ else cb(null, r.stdout ?? "", r.stderr ?? "");
102
+ },
103
+ );
104
+ }
105
+
83
106
  const MAIN_CWD = "/home/user/project";
84
107
  const AGENT_DIR = "/home/user/.pi/agent";
85
108
  const RECORD_ID = "bg-42-abc";
@@ -101,12 +124,10 @@ function makeHandle(checkoutPath: string = expectedCreatePath(RECORD_ID)) {
101
124
  }
102
125
 
103
126
  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 "";
127
+ setupExecFile((_args: readonly string[]) => {
128
+ // rev-parse 真实输出形态:hash + \n(gitRunAsync 不 trim,消费点 trim 后才得干净 hash)
129
+ if (_args?.[0] === "rev-parse" && _args?.[1] === "HEAD") return { stdout: `${BASE_COMMIT}\n` };
130
+ return { stdout: "" };
110
131
  });
111
132
  // worktreePath 不存在(无需前置清理);node_modules 存在
112
133
  mockExistsSync.mockImplementation((p: unknown) => {
@@ -118,13 +139,14 @@ function setupCleanTree(): void {
118
139
  }
119
140
 
120
141
  /** 向注册表注入一条活条目(模拟 create 后的状态)。 */
121
- function injectEntry(overrides: Partial<{ branch: string; pid: number; checkout: string; repo: string; createdAt: number }> = {}): void {
142
+ function injectEntry(overrides: Partial<{ branch: string; pid: number; checkout: string; repo: string; createdAt: number; sessionFile: string }> = {}): void {
122
143
  registryEntries.push({
123
144
  repo: overrides.repo ?? MAIN_CWD,
124
145
  branch: overrides.branch ?? "pi-sub-orphan1",
125
146
  checkout: overrides.checkout ?? path.join(os.tmpdir(), "pi-sub-orphan1"),
126
147
  pid: overrides.pid ?? 0,
127
148
  createdAt: overrides.createdAt ?? Date.now(),
149
+ sessionFile: overrides.sessionFile,
128
150
  });
129
151
  }
130
152
 
@@ -138,10 +160,10 @@ describe("WorktreeManager", () => {
138
160
  });
139
161
 
140
162
  describe("create", () => {
141
- it("正常流程返回冻结 handle(path 在 tmpdir,含 mainCwd)", () => {
163
+ it("正常流程返回冻结 handle(path 在 tmpdir,含 mainCwd)", async () => {
142
164
  setupCleanTree();
143
165
 
144
- const handle = mgr.create(MAIN_CWD, RECORD_ID);
166
+ const handle = await mgr.create(MAIN_CWD, RECORD_ID);
145
167
 
146
168
  expect(handle.path).toBe(expectedCreatePath(RECORD_ID));
147
169
  expect(handle.branch).toBe(`pi-sub-${RECORD_ID}`);
@@ -150,10 +172,10 @@ describe("WorktreeManager", () => {
150
172
  expect(Object.isFrozen(handle)).toBe(true);
151
173
  });
152
174
 
153
- it("成功后写入注册表(pid=0 占位)", () => {
175
+ it("成功后写入注册表(pid=0 占位)", async () => {
154
176
  setupCleanTree();
155
177
 
156
- mgr.create(MAIN_CWD, RECORD_ID);
178
+ await mgr.create(MAIN_CWD, RECORD_ID);
157
179
 
158
180
  expect(mockAdd).toHaveBeenCalledTimes(1);
159
181
  const entry = mockAdd.mock.calls[0][0] as { repo: string; branch: string; pid: number };
@@ -162,30 +184,35 @@ describe("WorktreeManager", () => {
162
184
  expect(entry.pid).toBe(0);
163
185
  });
164
186
 
165
- it("脏树抛 DirtyWorktreeError 且不写注册表", () => {
166
- mockExec.mockImplementation((_cmd: string, args?: readonly string[]) => {
167
- if (args?.[0] === "status") return "M src/index.ts\n";
168
- return "";
187
+ it("脏树抛 DirtyWorktreeError 且不写注册表(status 与 rev-parse 并行,均发起)", async () => {
188
+ setupExecFile((args: readonly string[]) => {
189
+ if (args[0] === "status") return { stdout: "M src/index.ts\n" };
190
+ return { stdout: "" };
169
191
  });
170
192
 
171
- expect(() => mgr.create(MAIN_CWD, RECORD_ID)).toThrow(DirtyWorktreeError);
193
+ // async create:同步 throw 变 rejected promise
194
+ await expect(mgr.create(MAIN_CWD, RECORD_ID)).rejects.toThrow(DirtyWorktreeError);
172
195
  expect(mockAdd).not.toHaveBeenCalled();
196
+ // allSettled 并行:两条读命令都发起(rev-parse 结果在脏树时被丢弃)
197
+ const cmds = mockExecFile.mock.calls.map((c) => `${c[1]?.[0]} ${c[1]?.[1] ?? ""}`);
198
+ expect(cmds).toContain("status --porcelain");
199
+ expect(cmds).toContain("rev-parse HEAD");
173
200
  });
174
201
 
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);
202
+ it("recordId 含特殊字符抛 DirtyWorktreeError", async () => {
203
+ await expect(mgr.create(MAIN_CWD, "../evil-path")).rejects.toThrow(DirtyWorktreeError);
204
+ await expect(mgr.create(MAIN_CWD, "hello world")).rejects.toThrow(DirtyWorktreeError);
205
+ await expect(mgr.create(MAIN_CWD, "")).rejects.toThrow(DirtyWorktreeError);
206
+ await expect(mgr.create(MAIN_CWD, "a;b")).rejects.toThrow(DirtyWorktreeError);
180
207
  });
181
208
 
182
- it("recordId 单字符 / 连续短横线合法", () => {
209
+ it("recordId 单字符 / 连续短横线合法", async () => {
183
210
  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--");
211
+ expect((await mgr.create(MAIN_CWD, "a")).branch).toBe("pi-sub-a");
212
+ expect((await mgr.create(MAIN_CWD, "--test--")).branch).toBe("pi-sub---test--");
186
213
  });
187
214
 
188
- it("残留 checkout 目录存在时前置清理(fs.rmSync)", () => {
215
+ it("残留 checkout 目录存在时前置清理(fs.rmSync)", async () => {
189
216
  setupCleanTree();
190
217
  // worktreePath 已存在 → 触发前置 rmSync
191
218
  mockExistsSync.mockImplementation((p: unknown) => {
@@ -195,7 +222,7 @@ describe("WorktreeManager", () => {
195
222
  return false;
196
223
  });
197
224
 
198
- mgr.create(MAIN_CWD, RECORD_ID);
225
+ await mgr.create(MAIN_CWD, RECORD_ID);
199
226
 
200
227
  expect(fs.rmSync).toHaveBeenCalledWith(
201
228
  expectedCreatePath(RECORD_ID),
@@ -203,75 +230,120 @@ describe("WorktreeManager", () => {
203
230
  );
204
231
  });
205
232
 
206
- it("symlink 失败时回滚 worktree + 分支 + 注册表", () => {
233
+ it("symlink 失败时回滚 worktree + 分支 + 注册表(回滚经 gitRunAsync)", async () => {
207
234
  setupCleanTree();
208
235
  // symlink 抛错触发 MF#3 回滚
209
236
  vi.mocked(fs.symlinkSync).mockImplementation(() => {
210
237
  throw new Error("symlink permission denied");
211
238
  });
212
239
 
213
- expect(() => mgr.create(MAIN_CWD, RECORD_ID)).toThrow("symlink permission denied");
240
+ await expect(mgr.create(MAIN_CWD, RECORD_ID)).rejects.toThrow("symlink permission denied");
214
241
 
215
- // 回滚:worktree remove + branch delete + registry remove
216
- expect(mockExec).toHaveBeenCalledWith(
242
+ // 回滚:worktree remove + branch delete(异步路径)+ registry remove
243
+ expect(mockExecFile).toHaveBeenCalledWith(
217
244
  "git",
218
245
  expect.arrayContaining(["worktree", "remove", "--force"]),
219
246
  expect.anything(),
247
+ expect.anything(),
220
248
  );
221
- expect(mockExec).toHaveBeenCalledWith(
249
+ expect(mockExecFile).toHaveBeenCalledWith(
222
250
  "git",
223
251
  expect.arrayContaining(["branch", "-D"]),
224
252
  expect.anything(),
253
+ expect.anything(),
225
254
  );
226
255
  expect(mockRemove).toHaveBeenCalledWith(`pi-sub-${RECORD_ID}`);
227
256
  });
257
+ it("per-repo mutex:同 repo 并发 create 的 worktree add 串行(maxActive=1)", async () => {
258
+ // clearAllMocks 不清 mockImplementation——重置前例注入的 symlinkSync throw,本用例走完整 create
259
+ vi.mocked(fs.symlinkSync).mockImplementation(() => {});
260
+ mockExistsSync.mockImplementation((p: unknown) => {
261
+ const s = String(p);
262
+ if (s.includes("node_modules")) return true;
263
+ return false;
264
+ });
265
+ let writeActive = 0;
266
+ let maxWriteActive = 0;
267
+ let addCount = 0;
268
+ mockExecFile.mockImplementation(
269
+ (_cmd: string, args: readonly string[], _opts: unknown, cb: (err: Error | null, stdout?: string, stderr?: string) => void) => {
270
+ const isAdd = args[0] === "worktree" && args[1] === "add";
271
+ if (isAdd) {
272
+ // 只对写命令(worktree add)计并发——读命令(status/rev-parse)无锁可并行是设计预期
273
+ writeActive++;
274
+ maxWriteActive = Math.max(maxWriteActive, writeActive);
275
+ addCount++;
276
+ }
277
+ setTimeout(() => {
278
+ if (isAdd) writeActive--;
279
+ // rev-parse 真实输出带尾换行;baseCommit 由消费点 trim
280
+ cb(null, args[0] === "rev-parse" ? `${BASE_COMMIT}\n` : "", "");
281
+ }, 5);
282
+ },
283
+ );
284
+
285
+ const handles = await Promise.all([
286
+ mgr.create(MAIN_CWD, "con-a"),
287
+ mgr.create(MAIN_CWD, "con-b"),
288
+ ]);
289
+
290
+ // 两个 create 全部成功、branch 不同
291
+ expect(handles[0].branch).toBe("pi-sub-con-a");
292
+ expect(handles[1].branch).toBe("pi-sub-con-b");
293
+ // 同 repo 的 2 个 worktree add 全部执行且互斥(per-repo mutex)
294
+ expect(addCount).toBe(2);
295
+ expect(maxWriteActive).toBe(1);
296
+ });
228
297
  });
229
298
 
230
299
  describe("registerPid", () => {
231
300
  it("委托 registry.updatePid", () => {
232
301
  mgr.registerPid("pi-sub-bg-1", 12345);
233
- expect(mockUpdatePid).toHaveBeenCalledWith("pi-sub-bg-1", 12345);
302
+ expect(mockUpdatePid).toHaveBeenCalledWith("pi-sub-bg-1", 12345, undefined);
234
303
  });
235
304
  });
236
305
 
237
306
  describe("cleanup", () => {
238
- it("worktree remove + branch -D + 注册表移除(cwd 用 handle.mainCwd)", () => {
239
- mockExec.mockReturnValue("");
307
+ it("worktree remove + branch -D + 注册表移除(cwd 用 handle.mainCwd)", async () => {
308
+ setupExecFile();
240
309
  const handle = makeHandle();
241
310
 
242
- mgr.cleanup(handle);
311
+ await mgr.cleanup(handle);
243
312
 
244
- expect(mockExec).toHaveBeenCalledWith(
313
+ expect(mockExecFile).toHaveBeenCalledWith(
245
314
  "git",
246
315
  ["worktree", "remove", "--force", handle.path],
247
316
  expect.objectContaining({ cwd: MAIN_CWD }),
317
+ expect.anything(),
248
318
  );
249
- expect(mockExec).toHaveBeenCalledWith(
319
+ expect(mockExecFile).toHaveBeenCalledWith(
250
320
  "git",
251
321
  ["branch", "-D", handle.branch],
252
322
  expect.objectContaining({ cwd: MAIN_CWD }),
323
+ expect.anything(),
253
324
  );
254
325
  expect(mockRemove).toHaveBeenCalledWith(handle.branch);
255
326
  });
256
327
 
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 "";
328
+ it("worktree remove 失败时 branch -D + 注册表移除仍执行(best-effort 分离)", async () => {
329
+ // 第一条 git(worktree remove)失败,第二条(branch -D)应仍执行
330
+ setupExecFile((args) => {
331
+ if (args[1] === "remove") {
332
+ return { err: Object.assign(new Error("Command failed: git worktree remove"), { code: 128 }), stderr: "fatal: worktree locked" };
333
+ }
334
+ return { stdout: "" };
264
335
  });
265
336
  const handle = makeHandle();
266
337
 
267
338
  // 不应抛错
268
- expect(() => mgr.cleanup(handle)).not.toThrow();
339
+ await expect(mgr.cleanup(handle)).resolves.toBeUndefined();
269
340
 
270
341
  // branch -D 仍被调用
271
- expect(mockExec).toHaveBeenCalledWith(
342
+ expect(mockExecFile).toHaveBeenCalledWith(
272
343
  "git",
273
344
  ["branch", "-D", handle.branch],
274
345
  expect.anything(),
346
+ expect.anything(),
275
347
  );
276
348
  // 注册表仍被移除
277
349
  expect(mockRemove).toHaveBeenCalledWith(handle.branch);
@@ -279,141 +351,272 @@ describe("WorktreeManager", () => {
279
351
  });
280
352
 
281
353
  describe("collectPatch", () => {
282
- it("有改动返回 patch 文件", () => {
283
- mockExec.mockReturnValue("diff --git a/src\n+// new");
354
+ it("有改动返回 patch 文件", async () => {
355
+ setupExecFile((args) => (args[0] === "diff" ? { stdout: "diff --git a/src\n+// new" } : { stdout: "" }));
284
356
 
285
357
  const handle = makeHandle();
286
358
  const patchFile = path.join(os.tmpdir(), `outside-${RECORD_ID}.patch`);
287
359
 
288
- const result = mgr.collectPatch(handle, patchFile);
360
+ const result = await mgr.collectPatch(handle, patchFile);
289
361
 
290
362
  expect(result.failed).toBe(false);
291
363
  expect(result.written).toBe(true);
292
- expect(mockExec).toHaveBeenCalledWith("git", ["add", "-A"], expect.anything());
293
- expect(mockExec).toHaveBeenCalledWith(
364
+ expect(mockExecFile).toHaveBeenCalledWith("git", ["add", "-A"], expect.anything(), expect.anything());
365
+ expect(mockExecFile).toHaveBeenCalledWith(
294
366
  "git",
295
367
  ["diff", "--cached", BASE_COMMIT],
296
368
  expect.anything(),
369
+ expect.anything(),
297
370
  );
298
371
  });
299
372
 
300
- it("无改动返回 failed=false, written=false(不写文件)", () => {
301
- mockExec.mockReturnValue("");
302
- const result = mgr.collectPatch(makeHandle(), "/tmp/x.patch");
373
+ it("无改动返回 failed=false, written=false(不写文件)", async () => {
374
+ setupExecFile();
375
+ const result = await mgr.collectPatch(makeHandle(), "/tmp/x.patch");
303
376
  expect(result.failed).toBe(false);
304
377
  expect(result.written).toBe(false);
305
378
  });
379
+
380
+ it("patch 内容 = git diff stdout 原文(含尾部换行不被裁剪),git apply --check 兼容", async () => {
381
+ // 真实 git diff 输出以 \n 结尾;旧实现 gitRunAsync resolve(stdout.trim())
382
+ // 裁掉尾换行 → 落盘 patch 在干净副本上 `git apply --check` 报
383
+ // "corrupt patch at line N"(2026-08-16 门 4 实测,exit 128)
384
+ const rawDiff =
385
+ "diff --git a/data.txt b/data.txt\n" +
386
+ "index c690e0d..f858a88 100644\n" +
387
+ "--- a/data.txt\n" +
388
+ "+++ b/data.txt\n" +
389
+ "@@ -1 +1,2 @@\n" +
390
+ " base content\n" +
391
+ "+appended\n";
392
+ setupExecFile((args) => (args[0] === "diff" ? { stdout: rawDiff } : { stdout: "" }));
393
+
394
+ await mgr.collectPatch(makeHandle(), "/tmp/x.patch");
395
+
396
+ // 写盘字节与 diff stdout 逐字节一致(保真 = 不裁剪两端空白)
397
+ const written = vi.mocked(fs.writeFileSync).mock.calls[0][1] as string;
398
+ expect(written).toBe(rawDiff);
399
+ expect(written.endsWith("\n")).toBe(true);
400
+ });
401
+ });
402
+
403
+ // ============================================================
404
+ // gitRunAsync 包装与 per-repo mutex(Phase 1 异步化新增)
405
+ // ============================================================
406
+ describe("gitRunAsync(经 collectPatch/cleanup 公共面触达)", () => {
407
+ it("非零退出:message 同构 + exitCode/stderr 属性(P-errshape:stderr 在 callback 第三参)", async () => {
408
+ setupExecFile((args) => {
409
+ if (args[0] === "diff") {
410
+ return {
411
+ err: Object.assign(new Error("Command failed: git diff --cached abc123def456\nfatal: bad object"), { code: 128 }),
412
+ stderr: "fatal: bad object",
413
+ };
414
+ }
415
+ return { stdout: "" };
416
+ });
417
+
418
+ // diff 无 try/catch,直接向上抛(与旧同步 gitRun 行为一致)
419
+ const err = await mgr.collectPatch(makeHandle(), "/tmp/x.patch").catch((e: unknown) => e);
420
+ expect(err).toBeInstanceOf(Error);
421
+ expect((err as Error).message).toContain("git diff failed: Command failed: git diff --cached");
422
+ expect((err as { exitCode?: number }).exitCode).toBe(128);
423
+ expect((err as { stderr?: string }).stderr).toBe("fatal: bad object");
424
+ });
425
+
426
+ it("超时路径:killed+SIGTERM 标记 timedOut,不挂误导性 exitCode", async () => {
427
+ setupExecFile((args) => {
428
+ if (args[0] === "diff") {
429
+ return { err: Object.assign(new Error("Command failed: git diff --cached"), { killed: true, signal: "SIGTERM", code: null }) };
430
+ }
431
+ return { stdout: "" };
432
+ });
433
+
434
+ const err = await mgr.collectPatch(makeHandle(), "/tmp/x.patch").catch((e: unknown) => e);
435
+ expect((err as Error).message).toContain("git diff failed:");
436
+ expect((err as { timedOut?: boolean }).timedOut).toBe(true);
437
+ expect((err as { exitCode?: number }).exitCode).toBeUndefined();
438
+ });
439
+
440
+ it("per-repo mutex:同 repo 写类命令串行(最大并发 1),全部完成无饥饿", async () => {
441
+ let active = 0;
442
+ let maxActive = 0;
443
+ // 异步完成 cb:若队列失效,后继命令的 impl 入口会先于前驱 cb 到达 → active=2
444
+ mockExecFile.mockImplementation(
445
+ (_cmd: string, args: readonly string[], _opts: unknown, cb: (err: Error | null, stdout?: string, stderr?: string) => void) => {
446
+ active++;
447
+ maxActive = Math.max(maxActive, active);
448
+ setTimeout(() => {
449
+ active--;
450
+ cb(null, "", "");
451
+ }, 5);
452
+ void args;
453
+ },
454
+ );
455
+
456
+ const distinct = [1, 2, 3].map((i) =>
457
+ Object.freeze({ ...makeHandle(path.join(os.tmpdir(), `pi-sub-mut-${i}`)), branch: `pi-sub-mut-${i}` }),
458
+ );
459
+ await Promise.all(distinct.map((h) => mgr.cleanup(h)));
460
+
461
+ // 同 repo 6 条写命令(3×(remove+branch -D))全部执行(无饥饿)
462
+ const writeCalls = mockExecFile.mock.calls.filter(
463
+ (c) => (c[1]?.[0] === "worktree" && c[1]?.[1] === "remove") || (c[1]?.[0] === "branch" && c[1]?.[1] === "-D"),
464
+ );
465
+ expect(writeCalls).toHaveLength(6);
466
+ // 队列生效:任何时刻至多 1 条写命令在途
467
+ expect(maxActive).toBe(1);
468
+ });
469
+
470
+ it("mutex 前驱失败不传染后继:第一个 remove 失败,后续同 repo 写命令正常执行", async () => {
471
+ let removeCount = 0;
472
+ setupExecFile((args) => {
473
+ if (args[0] === "worktree" && args[1] === "remove") {
474
+ removeCount++;
475
+ if (removeCount === 1) {
476
+ return { err: Object.assign(new Error("Command failed: git worktree remove"), { code: 128 }) };
477
+ }
478
+ }
479
+ return { stdout: "" };
480
+ });
481
+
482
+ const h1 = Object.freeze({ ...makeHandle(), branch: "pi-sub-fail-1" });
483
+ const h2 = Object.freeze({ ...makeHandle(), branch: "pi-sub-fail-2" });
484
+ await Promise.all([mgr.cleanup(h1), mgr.cleanup(h2)]);
485
+
486
+ // h2 的 remove 正常执行(第 2 次),h1/h2 的 branch -D 各自执行
487
+ expect(removeCount).toBe(2);
488
+ const branchCalls = mockExecFile.mock.calls.filter((c) => c[1]?.[0] === "branch" && c[1]?.[1] === "-D");
489
+ expect(branchCalls).toHaveLength(2);
490
+ // 两个注册表条目都移除(各自 cleanup 尾部执行)
491
+ expect(mockRemove).toHaveBeenCalledWith("pi-sub-fail-1");
492
+ expect(mockRemove).toHaveBeenCalledWith("pi-sub-fail-2");
493
+ });
306
494
  });
307
495
 
308
496
  // ============================================================
309
497
  // scan:全局注册表 + pid 死活判据(核心重构)
310
498
  // ============================================================
311
499
  describe("scan", () => {
312
- it("pid 已死的条目被清理(正常退出未 cleanup / 崩溃残留)", () => {
500
+ it("pid 已死的条目被清理(正常退出未 cleanup / 崩溃残留)", async () => {
501
+ setupExecFile();
313
502
  injectEntry({ branch: "pi-sub-dead", pid: 11111 });
314
503
  mockIsProcessAlive.mockReturnValue(false); // pid 死
315
504
 
316
- mgr.scan();
505
+ await mgr.scan();
317
506
 
318
- expect(mockExec).toHaveBeenCalledWith(
507
+ expect(mockExecFile).toHaveBeenCalledWith(
319
508
  "git",
320
509
  ["worktree", "remove", "--force", expect.any(String)],
321
510
  expect.objectContaining({ cwd: MAIN_CWD }),
511
+ expect.anything(),
322
512
  );
323
- expect(mockExec).toHaveBeenCalledWith(
513
+ expect(mockExecFile).toHaveBeenCalledWith(
324
514
  "git",
325
515
  ["branch", "-D", "pi-sub-dead"],
326
516
  expect.objectContaining({ cwd: MAIN_CWD }),
517
+ expect.anything(),
327
518
  );
328
519
  expect(mockRemove).toHaveBeenCalledWith("pi-sub-dead");
329
520
  });
330
521
 
331
- it("pid 活的条目不删(绝不删活进程)", () => {
522
+ it("pid 活的条目不删(绝不删活进程)", async () => {
523
+ setupExecFile();
332
524
  injectEntry({ branch: "pi-sub-alive", pid: 22222 });
333
525
  mockIsProcessAlive.mockReturnValue(true); // pid 活
334
526
 
335
- mgr.scan();
527
+ await mgr.scan();
336
528
 
337
- const removeCalls = mockExec.mock.calls.filter(
529
+ const removeCalls = mockExecFile.mock.calls.filter(
338
530
  (c) => c[1]?.[0] === "worktree" && c[1]?.[1] === "remove",
339
531
  );
340
532
  expect(removeCalls).toHaveLength(0);
341
533
  expect(mockRemove).not.toHaveBeenCalled();
342
534
  });
343
535
 
344
- it("崩溃残留:无终态 + pid 死 → 清理(原 P0 缺陷场景)", () => {
536
+ it("崩溃残留:无终态 + pid 死 → 清理(原 P0 缺陷场景)", async () => {
345
537
  // 这是原 reaper 永远泄漏的场景:进程崩溃无人写终态 marker
538
+ setupExecFile();
346
539
  injectEntry({ branch: "pi-sub-crash", pid: 33333 });
347
540
  mockIsProcessAlive.mockReturnValue(false); // 崩溃后进程已死
348
541
 
349
- mgr.scan();
542
+ await mgr.scan();
350
543
 
351
- expect(mockExec).toHaveBeenCalledWith(
544
+ expect(mockExecFile).toHaveBeenCalledWith(
352
545
  "git",
353
546
  ["branch", "-D", "pi-sub-crash"],
354
547
  expect.anything(),
548
+ expect.anything(),
355
549
  );
356
550
  expect(mockRemove).toHaveBeenCalledWith("pi-sub-crash");
357
551
  });
358
552
 
359
- it("pid=0 + 未超 SPAWN_GRACE → 跳过(可能正在 spawn)", () => {
553
+ it("pid=0 + 未超 SPAWN_GRACE → 跳过(可能正在 spawn)", async () => {
554
+ setupExecFile();
360
555
  injectEntry({ branch: "pi-sub-spawning", pid: 0, createdAt: Date.now() });
361
556
 
362
- mgr.scan();
557
+ await mgr.scan();
363
558
 
364
- const removeCalls = mockExec.mock.calls.filter(
559
+ const removeCalls = mockExecFile.mock.calls.filter(
365
560
  (c) => c[1]?.[0] === "worktree" && c[1]?.[1] === "remove",
366
561
  );
367
562
  expect(removeCalls).toHaveLength(0);
368
563
  });
369
564
 
370
- it("pid=0 + 超 SPAWN_GRACE → 清理(create 后崩溃)", () => {
565
+ it("pid=0 + 超 SPAWN_GRACE → 清理(create 后崩溃)", async () => {
566
+ setupExecFile();
371
567
  const sixtyOneMinAgo = Date.now() - 61 * 60 * 1000;
372
568
  injectEntry({ branch: "pi-sub-spawn-crash", pid: 0, createdAt: sixtyOneMinAgo });
373
569
 
374
- mgr.scan();
570
+ await mgr.scan();
375
571
 
376
- expect(mockExec).toHaveBeenCalledWith(
572
+ expect(mockExecFile).toHaveBeenCalledWith(
377
573
  "git",
378
574
  ["branch", "-D", "pi-sub-spawn-crash"],
379
575
  expect.anything(),
576
+ expect.anything(),
380
577
  );
381
578
  expect(mockRemove).toHaveBeenCalledWith("pi-sub-spawn-crash");
382
579
  });
383
580
 
384
- it("跨 repo 清理:条目 repo 字段作为 git -C 目标", () => {
581
+ it("跨 repo 清理:条目 repo 字段作为 git -C 目标", async () => {
582
+ setupExecFile();
385
583
  const OTHER_REPO = "/home/user/other-repo";
386
584
  injectEntry({ branch: "pi-sub-cross", pid: 44444, repo: OTHER_REPO });
387
585
  mockIsProcessAlive.mockReturnValue(false);
388
586
 
389
- mgr.scan();
587
+ await mgr.scan();
390
588
 
391
589
  // git 命令的 cwd 应为 OTHER_REPO,非 MAIN_CWD
392
- expect(mockExec).toHaveBeenCalledWith(
590
+ expect(mockExecFile).toHaveBeenCalledWith(
393
591
  "git",
394
592
  expect.arrayContaining(["branch", "-D", "pi-sub-cross"]),
395
593
  expect.objectContaining({ cwd: OTHER_REPO }),
594
+ expect.anything(),
396
595
  );
397
596
  });
398
597
 
399
- it("空注册表时无操作", () => {
598
+ it("空注册表时无操作", async () => {
599
+ setupExecFile();
400
600
  // registryEntries 已在 beforeEach 清空
401
- mgr.scan();
402
- expect(mockExec).not.toHaveBeenCalled();
601
+ await mgr.scan();
602
+ expect(mockExecFile).not.toHaveBeenCalled();
403
603
  expect(mockRemove).not.toHaveBeenCalled();
404
604
  });
405
605
 
406
- it("worktree remove 失败时 branch -D + 注册表移除仍执行(best-effort)", () => {
606
+ it("worktree remove 失败时 branch -D + 注册表移除仍执行(best-effort)", async () => {
607
+ setupExecFile((args) => {
608
+ if (args[0] === "worktree") {
609
+ return { err: Object.assign(new Error("Command failed: git worktree remove"), { code: 128 }) };
610
+ }
611
+ return { stdout: "" };
612
+ });
407
613
  injectEntry({ branch: "pi-sub-stubborn", pid: 55555 });
408
614
  mockIsProcessAlive.mockReturnValue(false);
409
- mockExec.mockImplementation(() => {
410
- throw new Error("worktree locked");
411
- });
412
615
 
413
- mgr.scan();
616
+ await mgr.scan();
414
617
 
415
618
  // branch -D 仍被调用(两次 git 调用各自独立)
416
- const branchDeleteCalls = mockExec.mock.calls.filter(
619
+ const branchDeleteCalls = mockExecFile.mock.calls.filter(
417
620
  (c) => c[1]?.[0] === "branch" && c[1]?.[1] === "-D",
418
621
  );
419
622
  expect(branchDeleteCalls).toHaveLength(1);