@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,571 @@
1
+ // src/execution/__tests__/worktree-git-ops.test.ts
2
+ //
3
+ // worktree-git-ops 测试(sink 设计 U5 / ⛔3):真实 git 临时仓集成(不 mock
4
+ // execFile/fs,先例 worktree-reconcile.integration.test.ts)。
5
+ //
6
+ // 覆盖:
7
+ // - ⛔3 降级路径①锚点缺失/损坏(三形态:文件缺失 / 内容空白 / 内容不被 git 认)
8
+ // → 断言 warn 发出 + 裸 diff + patchIncomplete:true
9
+ // - ⛔3 降级路径②add 失败(真实 index.lock 冲突构造)→ 同上断言
10
+ // - 新文件 + 已提交改动场景:完整机制 patch 含两者(`diff --cached <base>`)
11
+ // - 大 diff maxBuffer:缺省 1MB 超限形态如实登记(GitRunError 上抛 + ⛔3① git
12
+ // 层降级 warn)/ 32MB 放宽后 gitRun 与 collectWorktreePatch 完整产出
13
+ // - cleanupWorktree 三步容错(remove 失败不阻断 / onRemoved 抛错不 reject)
14
+ // - listWorktreePorcelain 原始输出保真(与 execFileSync 原始输出逐字节全等)
15
+ // - 保真读 / GitRunError / SafeId / dirty 谓词
16
+ //
17
+ // warn 断言经 configureCore 注入 log spy(facade 每次调用动态解析,配置即生效)。
18
+
19
+ import { execFileSync } from "node:child_process";
20
+ import * as fs from "node:fs";
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
+ import { configureCore, resetCoreForTests } from "../../core/host-services.ts";
27
+ import { DirtyWorktreeError } from "../types.ts";
28
+ import {
29
+ assertSafeId,
30
+ cleanupWorktree,
31
+ collectWorktreePatch,
32
+ GitRunError,
33
+ gitRun,
34
+ isSafeId,
35
+ isTreeDirty,
36
+ listWorktreePorcelain,
37
+ } from "../worktree-git-ops.ts";
38
+
39
+ /** git 辅助:repo/worktree 内执行(原始 stdout,不 trim——保真对照用)。 */
40
+ function git(dir: string, ...args: string[]): string {
41
+ return execFileSync("git", ["-C", dir, ...args], { encoding: "utf-8" });
42
+ }
43
+
44
+ // [HISTORICAL] 真实 git 子进程集成用例显式超时(对齐 worktree-reconcile.integration
45
+ // 满并行防超出口径)。
46
+ describe("worktree-git-ops", { timeout: 30_000 }, () => {
47
+ let outerDir: string;
48
+ let repo: string;
49
+ let worktree: string;
50
+ let branch: string;
51
+ let baseCommit: string;
52
+ let logCalls: Array<{ level: string; component: string; message: string; data?: unknown }>;
53
+
54
+ /** warn 级日志调用(⛔3 断言用)。 */
55
+ function warnCalls(): Array<{ message: string; data?: unknown }> {
56
+ return logCalls.filter((c) => c.level === "warn");
57
+ }
58
+
59
+ beforeEach(() => {
60
+ outerDir = fs.mkdtempSync(path.join(os.tmpdir(), "wt-gitops-"));
61
+ repo = path.join(outerDir, "repo");
62
+ fs.mkdirSync(repo, { recursive: true });
63
+ git(repo, "init", "-q");
64
+ git(repo, "config", "user.email", "test@test.local");
65
+ git(repo, "config", "user.name", "test");
66
+ git(repo, "config", "commit.gpgsign", "false");
67
+ fs.writeFileSync(path.join(repo, "base.txt"), "base\n", "utf-8");
68
+ git(repo, "add", "-A");
69
+ git(repo, "commit", "-q", "-m", "base");
70
+ baseCommit = git(repo, "rev-parse", "HEAD").trim();
71
+
72
+ branch = "pi-sub-t1";
73
+ worktree = path.join(outerDir, "wt");
74
+ git(repo, "worktree", "add", "-q", "-b", branch, worktree, baseCommit);
75
+
76
+ logCalls = [];
77
+ configureCore({
78
+ dataRoot: () => outerDir,
79
+ log: (level, component, message, data) => {
80
+ logCalls.push({ level, component, message, data });
81
+ },
82
+ });
83
+ });
84
+
85
+ afterEach(() => {
86
+ resetCoreForTests();
87
+ fs.rmSync(outerDir, { recursive: true, force: true });
88
+ });
89
+
90
+ /**
91
+ * 标准三件套场景(⛔3 验收②的原材料):
92
+ * ① 已提交改动(worktree HEAD 前进一个 commit,新增 committed.txt)
93
+ * ② 未提交改动(base.txt 追加一行)
94
+ * ③ 新文件(new.txt,未跟踪)
95
+ */
96
+ function stageCommittedAndUncommittedAndNewFile(): void {
97
+ fs.writeFileSync(path.join(worktree, "committed.txt"), "committed-change\n", "utf-8");
98
+ git(worktree, "add", "-A");
99
+ git(worktree, "commit", "-q", "-m", "wip");
100
+ fs.appendFileSync(path.join(worktree, "base.txt"), "uncommitted\n", "utf-8");
101
+ fs.writeFileSync(path.join(worktree, "new.txt"), "brand-new\n", "utf-8");
102
+ }
103
+
104
+ function patchFile(): string {
105
+ return path.join(outerDir, "out.patch");
106
+ }
107
+
108
+ function commitAnchor(): { kind: "commit"; baseCommit: string } {
109
+ return { kind: "commit", baseCommit };
110
+ }
111
+
112
+ function writeAnchorFile(content: string): string {
113
+ const p = path.join(outerDir, "baseline.anchor");
114
+ fs.writeFileSync(p, content, "utf-8");
115
+ return p;
116
+ }
117
+
118
+ // ============================================================
119
+ // collectWorktreePatch:完整机制(anchor 双形态)
120
+ // ============================================================
121
+
122
+ describe("collectWorktreePatch 完整机制", () => {
123
+ it("commit 锚点:新文件 + 已提交改动场景 patch 含两者(⛔3 验收②,真实 git)", async () => {
124
+ stageCommittedAndUncommittedAndNewFile();
125
+
126
+ const result = await collectWorktreePatch({
127
+ worktreePath: worktree,
128
+ patchFile: patchFile(),
129
+ anchor: commitAnchor(),
130
+ });
131
+
132
+ expect(result.written).toBe(true);
133
+ expect(result.patchIncomplete).toBeUndefined();
134
+ const patch = fs.readFileSync(result.patchFile, "utf-8");
135
+ // 已提交改动(committed.txt 在 worktree HEAD 前进的 commit 里引入)
136
+ expect(patch).toContain("diff --git a/committed.txt b/committed.txt");
137
+ // 新文件(add -A 暂存后 --cached diff 捕获)
138
+ expect(patch).toContain("diff --git a/new.txt b/new.txt");
139
+ // 未提交改动也在(同一基线机制覆盖全量)
140
+ expect(patch).toContain("diff --git a/base.txt b/base.txt");
141
+ // 无降级留痕时 warn 零发出
142
+ expect(warnCalls()).toHaveLength(0);
143
+ });
144
+
145
+ it("anchor-file 锚点:宿主持久锚点文件(sidecar 语义)与 commit 锚点等价", async () => {
146
+ stageCommittedAndUncommittedAndNewFile();
147
+ const anchorFile = writeAnchorFile(`${baseCommit}\n`);
148
+
149
+ const result = await collectWorktreePatch({
150
+ worktreePath: worktree,
151
+ patchFile: patchFile(),
152
+ anchor: { kind: "anchor-file", path: anchorFile },
153
+ });
154
+
155
+ expect(result.written).toBe(true);
156
+ expect(result.patchIncomplete).toBeUndefined();
157
+ const patch = fs.readFileSync(result.patchFile, "utf-8");
158
+ expect(patch).toContain("diff --git a/committed.txt b/committed.txt");
159
+ expect(patch).toContain("diff --git a/new.txt b/new.txt");
160
+ });
161
+
162
+ it("保真读:patch 文件保留 git 原始尾换行(trim 会产出 corrupt patch)", async () => {
163
+ fs.appendFileSync(path.join(worktree, "base.txt"), "uncommitted\n", "utf-8");
164
+
165
+ const result = await collectWorktreePatch({
166
+ worktreePath: worktree,
167
+ patchFile: patchFile(),
168
+ anchor: commitAnchor(),
169
+ });
170
+
171
+ const patch = fs.readFileSync(result.patchFile, "utf-8");
172
+ expect(result.written).toBe(true);
173
+ expect(patch.endsWith("\n")).toBe(true);
174
+ expect(patch).toContain("@@ -1");
175
+ });
176
+
177
+ it("空 diff:written=false 且不写文件(避免悬空路径)", async () => {
178
+ const out = patchFile();
179
+
180
+ const result = await collectWorktreePatch({
181
+ worktreePath: worktree,
182
+ patchFile: out,
183
+ anchor: commitAnchor(),
184
+ });
185
+
186
+ expect(result.written).toBe(false);
187
+ expect(result.patchIncomplete).toBeUndefined();
188
+ expect(fs.existsSync(out)).toBe(false);
189
+ expect(warnCalls()).toHaveLength(0);
190
+ });
191
+ });
192
+
193
+ // ============================================================
194
+ // 大 diff maxBuffer:Node execFile 缺省 1MB 超限 / 宿主 32MB 放宽
195
+ // ============================================================
196
+
197
+ describe("大 diff maxBuffer(缺省 1MB 超限 / 宿主 32MB)", () => {
198
+ /** ~1.5MB 多行文本(30000 行 × ~51B),贴近批量重构的大 diff 真实场景。 */
199
+ const BIG_CONTENT = Array.from(
200
+ { length: 30_000 },
201
+ (_, i) => `line-${i} ${"x".repeat(40)}`,
202
+ ).join("\n");
203
+
204
+ /** 在 worktree 给已跟踪 base.txt 追加 ~1.5MB(30000 行 × ~51B)。
205
+ * 用已跟踪文件修改而非 untracked 新文件:纯 `git diff`(工作区 vs index)
206
+ * 不含 untracked,直接 diff 用例才有大输出(贴近批量重构改既有文件场景)。 */
207
+ function stageBigDiff(): void {
208
+ fs.appendFileSync(path.join(worktree, "base.txt"), `\n${BIG_CONTENT}\n`, "utf-8");
209
+ }
210
+
211
+ it("gitRun 不传 maxBuffer:stdout 超 1MB → GitRunError(Node execFile 缺省行为不变)", async () => {
212
+ stageBigDiff();
213
+
214
+ await expect(gitRun(["diff"], { cwd: worktree })).rejects.toThrow(GitRunError);
215
+ });
216
+
217
+ it("gitRun 传 maxBuffer 32MB:超 1MB stdout 完整返回", async () => {
218
+ stageBigDiff();
219
+
220
+ const out = await gitRun(["diff"], { cwd: worktree, maxBuffer: 32 * 1024 * 1024 });
221
+
222
+ expect(out.length).toBeGreaterThan(1_500_000);
223
+ expect(out).toContain("diff --git a/base.txt b/base.txt");
224
+ });
225
+
226
+ it(
227
+ "collectWorktreePatch 缺省:大 diff → GitRunError 原样上抛" +
228
+ "(如实登记:diff --cached 超限先被 ⛔3① git 层按「锚点被拒」降级 warn," +
229
+ "降级裸 diff HEAD 再超限、无 catch 上抛)",
230
+ async () => {
231
+ stageBigDiff();
232
+
233
+ const err = await collectWorktreePatch({
234
+ worktreePath: worktree,
235
+ patchFile: patchFile(),
236
+ anchor: commitAnchor(),
237
+ }).catch((e: unknown) => e);
238
+
239
+ // 终态:降级裸 diff HEAD 同样超限(该路径无 catch),GitRunError 原样上抛,
240
+ // patch 文件不产出
241
+ expect(err).toBeInstanceOf(GitRunError);
242
+ expect((err as GitRunError).message).toContain("git diff failed");
243
+ expect(fs.existsSync(patchFile())).toBe(false);
244
+ // 中间留痕:diff --cached 超限 ≠ 锚点损坏,但 ⛔3① git 层按既有 catch
245
+ // 语义降级(最小修复不改降级判定),warn 如实发出
246
+ const warns = warnCalls();
247
+ expect(warns).toHaveLength(1);
248
+ expect(warns[0].message).toContain("rejected by git");
249
+ },
250
+ );
251
+
252
+ it("collectWorktreePatch 传 maxBuffer 32MB:大 diff 完整成 patch(无降级留痕)", async () => {
253
+ stageBigDiff();
254
+
255
+ const result = await collectWorktreePatch({
256
+ worktreePath: worktree,
257
+ patchFile: patchFile(),
258
+ anchor: commitAnchor(),
259
+ maxBuffer: 32 * 1024 * 1024,
260
+ });
261
+
262
+ expect(result.written).toBe(true);
263
+ expect(result.patchIncomplete).toBeUndefined();
264
+ const patch = fs.readFileSync(result.patchFile, "utf-8");
265
+ expect(patch).toContain("diff --git a/base.txt b/base.txt");
266
+ // patch 完整:全文行进 diff(内容 + diff 前缀 > 1.5MB,远超 1MB 缺省)
267
+ expect(patch.length).toBeGreaterThan(1_500_000);
268
+ expect(patch.endsWith("\n")).toBe(true);
269
+ expect(warnCalls()).toHaveLength(0);
270
+ });
271
+ });
272
+
273
+ // ============================================================
274
+ // collectWorktreePatch:⛔3 降级路径①锚点缺失/损坏
275
+ // ============================================================
276
+
277
+ describe("⛔3① 锚点缺失/损坏 → warn + 裸 diff + patchIncomplete:true", () => {
278
+ it("锚点文件缺失:warn 发出 + 裸 diff + patchIncomplete:true + 丢已提交改动", async () => {
279
+ stageCommittedAndUncommittedAndNewFile();
280
+ const missing = path.join(outerDir, "never-written.anchor");
281
+
282
+ const result = await collectWorktreePatch({
283
+ worktreePath: worktree,
284
+ patchFile: patchFile(),
285
+ anchor: { kind: "anchor-file", path: missing },
286
+ });
287
+
288
+ // 显著 warn(不做纯静默)
289
+ const warns = warnCalls();
290
+ expect(warns).toHaveLength(1);
291
+ expect(warns[0].message).toContain("anchor file missing or unreadable");
292
+ expect(warns[0].data).toMatchObject({ anchorFile: missing });
293
+ // patchIncomplete 留痕
294
+ expect(result.written).toBe(true);
295
+ expect(result.patchIncomplete).toBe(true);
296
+ // 裸 diff:仅未提交改动 + 已暂存新文件(add -A 成功),丢已提交改动
297
+ const patch = fs.readFileSync(result.patchFile, "utf-8");
298
+ expect(patch).toContain("diff --git a/base.txt b/base.txt");
299
+ expect(patch).toContain("diff --git a/new.txt b/new.txt");
300
+ expect(patch).not.toContain("committed.txt");
301
+ });
302
+
303
+ it("锚点文件内容空白(损坏形态一):warn 发出 + 裸 diff + patchIncomplete:true", async () => {
304
+ stageCommittedAndUncommittedAndNewFile();
305
+ const anchorFile = writeAnchorFile(" \n\t\n");
306
+
307
+ const result = await collectWorktreePatch({
308
+ worktreePath: worktree,
309
+ patchFile: patchFile(),
310
+ anchor: { kind: "anchor-file", path: anchorFile },
311
+ });
312
+
313
+ const warns = warnCalls();
314
+ expect(warns).toHaveLength(1);
315
+ expect(warns[0].message).toContain("empty or blank");
316
+ expect(result.patchIncomplete).toBe(true);
317
+ const patch = fs.readFileSync(result.patchFile, "utf-8");
318
+ expect(patch).toContain("diff --git a/base.txt b/base.txt");
319
+ expect(patch).not.toContain("committed.txt");
320
+ });
321
+
322
+ it("锚点内容不被 git 认(损坏形态二):warn 发出 + 裸 diff + patchIncomplete:true", async () => {
323
+ stageCommittedAndUncommittedAndNewFile();
324
+ const anchorFile = writeAnchorFile("deadbeef-not-a-real-commit\n");
325
+
326
+ const result = await collectWorktreePatch({
327
+ worktreePath: worktree,
328
+ patchFile: patchFile(),
329
+ anchor: { kind: "anchor-file", path: anchorFile },
330
+ });
331
+
332
+ const warns = warnCalls();
333
+ expect(warns).toHaveLength(1);
334
+ expect(warns[0].message).toContain("rejected by git");
335
+ expect(result.written).toBe(true);
336
+ expect(result.patchIncomplete).toBe(true);
337
+ const patch = fs.readFileSync(result.patchFile, "utf-8");
338
+ expect(patch).toContain("diff --git a/base.txt b/base.txt");
339
+ expect(patch).not.toContain("committed.txt");
340
+ });
341
+
342
+ it("commit 锚点为空串:warn 发出 + 裸 diff + patchIncomplete:true", async () => {
343
+ stageCommittedAndUncommittedAndNewFile();
344
+
345
+ const result = await collectWorktreePatch({
346
+ worktreePath: worktree,
347
+ patchFile: patchFile(),
348
+ anchor: { kind: "commit", baseCommit: " " },
349
+ });
350
+
351
+ const warns = warnCalls();
352
+ expect(warns).toHaveLength(1);
353
+ expect(warns[0].message).toContain("anchor commit is empty");
354
+ expect(result.patchIncomplete).toBe(true);
355
+ expect(fs.readFileSync(result.patchFile, "utf-8")).toContain(
356
+ "diff --git a/base.txt b/base.txt",
357
+ );
358
+ });
359
+
360
+ it("锚点缺失且树干净:空 diff 不写文件,patchIncomplete 留痕不因 written=false 丢失", async () => {
361
+ const result = await collectWorktreePatch({
362
+ worktreePath: worktree,
363
+ patchFile: patchFile(),
364
+ anchor: { kind: "anchor-file", path: path.join(outerDir, "absent.anchor") },
365
+ });
366
+
367
+ expect(result.written).toBe(false);
368
+ expect(result.patchIncomplete).toBe(true);
369
+ expect(fs.existsSync(result.patchFile)).toBe(false);
370
+ expect(warnCalls()).toHaveLength(1);
371
+ });
372
+ });
373
+
374
+ // ============================================================
375
+ // collectWorktreePatch:⛔3 降级路径②add 失败
376
+ // ============================================================
377
+
378
+ describe("⛔3② add 失败 → warn + 裸 diff + patchIncomplete:true(真实 index.lock 冲突)", () => {
379
+ /** 在 worktree 的 git 元数据目录放 index.lock(真实 git add 必然失败)。 */
380
+ function blockIndex(): void {
381
+ const gitDir = git(worktree, "rev-parse", "--git-dir").trim();
382
+ fs.writeFileSync(path.join(gitDir, "index.lock"), "", "utf-8");
383
+ }
384
+
385
+ it("add 失败:warn 发出 + 裸 diff + patchIncomplete:true + 新文件不进 patch", async () => {
386
+ stageCommittedAndUncommittedAndNewFile();
387
+ blockIndex();
388
+
389
+ const result = await collectWorktreePatch({
390
+ worktreePath: worktree,
391
+ patchFile: patchFile(),
392
+ anchor: commitAnchor(),
393
+ });
394
+
395
+ // 非致命:不 reject,warn 显著(不做 fail-fast 也不静默)
396
+ const warns = warnCalls();
397
+ expect(warns).toHaveLength(1);
398
+ expect(warns[0].message).toContain("git add -A failed");
399
+ expect(warns[0].data).toMatchObject({ worktreePath: worktree });
400
+ expect(result.written).toBe(true);
401
+ expect(result.patchIncomplete).toBe(true);
402
+ // 裸 diff HEAD:已跟踪未提交改动在;已提交改动与 untracked 新文件丢
403
+ //(D5 裁决:降级形态较 zsw `diff <base>` 多丢已提交改动,留痕可判断)
404
+ const patch = fs.readFileSync(result.patchFile, "utf-8");
405
+ expect(patch).toContain("diff --git a/base.txt b/base.txt");
406
+ expect(patch).not.toContain("committed.txt");
407
+ expect(patch).not.toContain("new.txt");
408
+ });
409
+
410
+ it("add 失败且树干净:written=false,patchIncomplete 留痕仍在", async () => {
411
+ blockIndex();
412
+
413
+ const result = await collectWorktreePatch({
414
+ worktreePath: worktree,
415
+ patchFile: patchFile(),
416
+ anchor: commitAnchor(),
417
+ });
418
+
419
+ expect(result.written).toBe(false);
420
+ expect(result.patchIncomplete).toBe(true);
421
+ expect(warnCalls()).toHaveLength(1);
422
+ });
423
+ });
424
+
425
+ // ============================================================
426
+ // cleanupWorktree:三步容错
427
+ // ============================================================
428
+
429
+ describe("cleanupWorktree 三步容错", () => {
430
+ it("正常路径:remove + branch -D + onRemoved 全部执行,资源真实消失", async () => {
431
+ const onRemoved = vi.fn();
432
+
433
+ await cleanupWorktree({ repo, worktreePath: worktree, branch, onRemoved });
434
+
435
+ expect(onRemoved).toHaveBeenCalledTimes(1);
436
+ expect(git(repo, "branch", "--list", branch).trim()).toBe("");
437
+ expect(fs.existsSync(worktree)).toBe(false);
438
+ expect(warnCalls()).toHaveLength(0);
439
+ });
440
+
441
+ it("remove 失败(locked worktree)不阻断:onRemoved 仍被调,不 reject", async () => {
442
+ const onRemoved = vi.fn();
443
+ // 确定性 remove 失败构造:locked working tree 连 --force 也拒绝移除
444
+ git(repo, "worktree", "lock", worktree);
445
+
446
+ await cleanupWorktree({ repo, worktreePath: worktree, branch, onRemoved });
447
+
448
+ expect(onRemoved).toHaveBeenCalledTimes(1);
449
+ // branch -D 因 worktree 元数据未清(used by worktree)也失败被容错吞掉——
450
+ // 分支留给宿主 reaper/对账兜底收敛,本函数只保证不阻断、不丢步。
451
+ expect(git(repo, "branch", "--list", branch).trim()).not.toBe("");
452
+ });
453
+
454
+ it("onRemoved 抛错不 reject:前两步已执行,失败仅 warn", async () => {
455
+ const onRemoved = vi.fn(() => {
456
+ throw new Error("registry remove failed");
457
+ });
458
+
459
+ await expect(
460
+ cleanupWorktree({ repo, worktreePath: worktree, branch, onRemoved }),
461
+ ).resolves.toBeUndefined();
462
+
463
+ expect(onRemoved).toHaveBeenCalledTimes(1);
464
+ expect(git(repo, "branch", "--list", branch).trim()).toBe("");
465
+ const warns = warnCalls();
466
+ expect(warns).toHaveLength(1);
467
+ expect(warns[0].message).toContain("onRemoved host hook failed");
468
+ });
469
+
470
+ it("onRemoved 缺席(可选钩子)不抛", async () => {
471
+ await expect(
472
+ cleanupWorktree({ repo, worktreePath: worktree, branch }),
473
+ ).resolves.toBeUndefined();
474
+ expect(git(repo, "branch", "--list", branch).trim()).toBe("");
475
+ });
476
+ });
477
+
478
+ // ============================================================
479
+ // listWorktreePorcelain:原始输出保真
480
+ // ============================================================
481
+
482
+ describe("listWorktreePorcelain 原始输出保真", () => {
483
+ it("与 git 原始 stdout 逐字节全等(不 trim / 不加工)", async () => {
484
+ const result = await listWorktreePorcelain({ repo });
485
+ const raw = execFileSync("git", ["-C", repo, "worktree", "list", "--porcelain"], {
486
+ encoding: "utf-8",
487
+ });
488
+
489
+ expect(result).toBe(raw);
490
+ expect(result.endsWith("\n")).toBe(true);
491
+ });
492
+
493
+ it("内容含 worktree checkout 与 branch 行(git 原始行文即 realpath 形态)", async () => {
494
+ const result = await listWorktreePorcelain({ repo });
495
+
496
+ // git 输出的是 realpath 形态(macOS /var→/private/var)——这正是宿主
497
+ // realpath 对账依赖原始输出、禁止加工的原因(设计 D5)。
498
+ expect(result).toContain(`worktree ${fs.realpathSync(repo)}\n`);
499
+ expect(result).toContain(`worktree ${fs.realpathSync(worktree)}\n`);
500
+ expect(result).toContain(`branch refs/heads/${branch}\n`);
501
+ });
502
+
503
+ it("非 git 目录 → GitRunError", async () => {
504
+ const notARepo = path.join(outerDir, "not-a-repo");
505
+ fs.mkdirSync(notARepo, { recursive: true });
506
+
507
+ await expect(listWorktreePorcelain({ repo: notARepo })).rejects.toBeInstanceOf(GitRunError);
508
+ });
509
+ });
510
+
511
+ // ============================================================
512
+ // gitRun / GitRunError / SafeId / dirty 谓词
513
+ // ============================================================
514
+
515
+ describe("gitRun / GitRunError", () => {
516
+ it("失败命令 reject GitRunError(name/exitCode/stderr 诊断属性)", async () => {
517
+ const err = await gitRun(["rev-parse", "definitely-not-a-ref"], { cwd: repo }).catch(
518
+ (e: unknown) => e,
519
+ );
520
+
521
+ expect(err).toBeInstanceOf(GitRunError);
522
+ const gitErr = err as GitRunError;
523
+ expect(gitErr.name).toBe("GitRunError");
524
+ expect(gitErr.message).toContain("git rev-parse failed");
525
+ expect(typeof gitErr.exitCode).toBe("number");
526
+ expect(gitErr.exitCode).not.toBe(0);
527
+ expect(typeof gitErr.stderr).toBe("string");
528
+ });
529
+
530
+ it("成功命令 stdout 保真返回(不 trim,尾换行保留)", async () => {
531
+ const out = await gitRun(["rev-parse", "HEAD"], { cwd: repo });
532
+
533
+ expect(out.trim()).toBe(baseCommit);
534
+ expect(out.endsWith("\n")).toBe(true);
535
+ });
536
+ });
537
+
538
+ describe("SafeId", () => {
539
+ it("isSafeId:白名单 ^[\\w-]+$ 放行 / 路径注入拒绝", () => {
540
+ expect(isSafeId("bg-42_abc")).toBe(true);
541
+ expect(isSafeId("a".repeat(64))).toBe(true);
542
+ expect(isSafeId("../evil")).toBe(false);
543
+ expect(isSafeId("a b")).toBe(false);
544
+ expect(isSafeId("")).toBe(false);
545
+ expect(isSafeId("a/b")).toBe(false);
546
+ });
547
+
548
+ it("assertSafeId:不合法抛 DirtyWorktreeError(消息含白名单说明)", () => {
549
+ expect(() => assertSafeId("../evil")).toThrow(DirtyWorktreeError);
550
+ expect(() => assertSafeId("../evil")).toThrow("must match ^[\\w-]+$");
551
+ expect(() => assertSafeId("../evil", "sessionId")).toThrow(/sessionId/);
552
+ expect(() => assertSafeId("ok-id_1")).not.toThrow();
553
+ });
554
+ });
555
+
556
+ describe("isTreeDirty(dirty 谓词)", () => {
557
+ it("空输出 / 纯空白 → false;porcelain 条目 → true", () => {
558
+ expect(isTreeDirty("")).toBe(false);
559
+ expect(isTreeDirty("\n")).toBe(false);
560
+ expect(isTreeDirty(" \n")).toBe(false);
561
+ expect(isTreeDirty(" M base.txt\n")).toBe(true);
562
+ expect(isTreeDirty("?? new.txt\n")).toBe(true);
563
+ });
564
+
565
+ it("与真实 git status --porcelain 联动:干净树 false,改动后 true", async () => {
566
+ expect(isTreeDirty(git(worktree, "status", "--porcelain"))).toBe(false);
567
+ fs.appendFileSync(path.join(worktree, "base.txt"), "x\n", "utf-8");
568
+ expect(isTreeDirty(git(worktree, "status", "--porcelain"))).toBe(true);
569
+ });
570
+ });
571
+ });