@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,285 @@
1
+ // src/runtime/worktree-manager.ts
2
+ //
3
+ // git worktree 生命周期管理:创建、清理、patch 回传、孤儿 reaper。
4
+ //
5
+ // 设计约束:
6
+ // - gitRun 是唯一 git 命令出口,统一超时/错误包装
7
+ // - recordId 白名单 `^[\w-]+$` 防止路径注入
8
+ // - clean tree 前置校验防止创建脏 worktree
9
+ // - checkout 放 os.tmpdir()(脱离 .git/),兼容普通 repo 与 bare+worktree 结构
10
+ // - mainCwd 存入 handle,不靠路径反推
11
+ // - scan 遍历全局注册表按 pid 死活判孤儿(绝不删有活进程的 worktree)
12
+ // - Object.freeze 保证 WorktreeHandle 不可变
13
+ //
14
+ // [全局注册表重构] scan 不再依赖当前 cwd 是否 git repo,改为遍历
15
+ // WorktreeRegistry(<agentDir>/subagents/worktrees.json)。判据从终态 marker
16
+ // 状态机降为 pid 死活一条——进程崩溃无人写终态时也能正确回收。
17
+
18
+ import { execFileSync } from "node:child_process";
19
+ import * as fs from "node:fs";
20
+ import * as os from "node:os";
21
+ import * as path from "node:path";
22
+
23
+ import { encodeCwd } from "./path-encoding.ts";
24
+ import type { PatchResult,WorktreeHandle } from "./types.ts";
25
+ import { DirtyWorktreeError } from "./types.ts";
26
+ import { bestEffort } from "./best-effort.ts";
27
+ import { isProcessAlive } from "./alive-store.ts";
28
+ import { SPAWN_GRACE_MS,type WorktreeEntry,WorktreeRegistry } from "./worktree-registry.ts";
29
+
30
+ // recordId 白名单:字母数字下划线短横线
31
+ const SAFE_ID_RE = /^[\w-]+$/;
32
+
33
+ // 默认 git 命令超时(ms)
34
+ const GIT_TIMEOUT_MS = 30_000;
35
+
36
+ export class WorktreeManager {
37
+ // 全局注册表:跨 repo 记录所有活 worktree,reaper 遍历此表判孤儿。
38
+ private readonly registry: WorktreeRegistry;
39
+
40
+ constructor(agentDir: string) {
41
+ this.registry = new WorktreeRegistry(agentDir);
42
+ }
43
+
44
+ /**
45
+ * 为子 agent 创建隔离 worktree。
46
+ *
47
+ * @param mainCwd 主仓库根目录
48
+ * @param recordId 执行记录 ID(必须匹配 `^[\w-]+$`)
49
+ * @returns 冻结的 WorktreeHandle
50
+ */
51
+ create(mainCwd: string, recordId: string): WorktreeHandle {
52
+ if (!SAFE_ID_RE.test(recordId)) {
53
+ throw new DirtyWorktreeError(
54
+ `recordId contains unsafe characters: "${recordId}" (must match ^[\\w-]+$)`,
55
+ );
56
+ }
57
+
58
+ this.assertCleanTree(mainCwd);
59
+
60
+ const baseCommit = this.gitRun(["rev-parse", "HEAD"], { cwd: mainCwd });
61
+ const branch = `pi-sub-${recordId}`;
62
+ // checkout 放 tmpdir,脱离 .git/ 目录结构。
63
+ // 这样 git 自行把元数据注册到 <commonDir>/worktrees/<branch>/,
64
+ // 普通repo(.git/worktrees)与 bare+worktree(.bare/worktrees)都能正确工作。
65
+ // [MF3] 按 encodeCwd(mainCwd) 作用域——消除不同 repo / 不同 session 并发跑 sync
66
+ // fork subagent 时落到同一 /tmp/pi-sub-run-1 的冲突(recordId 是 per-session 自增,无 repo 作用域)。
67
+ const worktreePath = path.join(os.tmpdir(), "pi-subagents", encodeCwd(mainCwd), branch);
68
+
69
+ // 前置清理残留 checkout 目录:上次 create 的 MF#3 回滚可能因目录非空未删干净 tmpdir,
70
+ // 或跨进程竞态。路径在 tmpdir/pi-subagents/<enc>/<branch> 下,按设计只有本扩展创建,清理安全。
71
+ if (fs.existsSync(worktreePath)) {
72
+ try {
73
+ fs.rmSync(worktreePath, { recursive: true, force: true });
74
+ } catch (cleanErr) {
75
+ bestEffort(cleanErr, "pre-create checkout cleanup");
76
+ }
77
+ }
78
+
79
+ this.gitRun(["worktree", "add", "-b", branch, worktreePath, "HEAD"], {
80
+ cwd: mainCwd,
81
+ });
82
+
83
+ // 注册到全局表(pid=0 占位)。session-runner first header 时补 pid。
84
+ // 放在 worktree add 成功后、symlink 前——确保只有真正创建了 worktree 才登记。
85
+ this.registry.add({
86
+ repo: mainCwd,
87
+ branch,
88
+ checkout: worktreePath,
89
+ pid: 0,
90
+ createdAt: Date.now(),
91
+ });
92
+
93
+ // [MF#3] worktree+分支+注册表条目已落盘,后续步骤(symlink)抛错时必须全部回滚,
94
+ // 否则 worktree+分支永久泄漏。create 后所有步骤包 try/catch。
95
+ try {
96
+ // 软链 node_modules(复用主仓库依赖)
97
+ const mainNodeModules = path.join(mainCwd, "node_modules");
98
+ const worktreeNodeModules = path.join(worktreePath, "node_modules");
99
+ if (fs.existsSync(mainNodeModules) && !fs.existsSync(worktreeNodeModules)) {
100
+ fs.symlinkSync(mainNodeModules, worktreeNodeModules);
101
+ }
102
+
103
+ return Object.freeze({
104
+ path: worktreePath,
105
+ branch,
106
+ baseCommit,
107
+ mainCwd,
108
+ });
109
+ } catch (err) {
110
+ // 回滚已创建的 worktree+分支+注册表条目,best-effort 吞清理异常(原始 err 仍外抛)
111
+ try {
112
+ this.gitRun(["worktree", "remove", "--force", worktreePath], { cwd: mainCwd });
113
+ } catch (cleanErr) {
114
+ bestEffort(cleanErr, "worktree remove (create rollback MF#3)");
115
+ }
116
+ try {
117
+ this.gitRun(["branch", "-D", branch], { cwd: mainCwd });
118
+ } catch (cleanErr) {
119
+ bestEffort(cleanErr, "branch delete (create rollback MF#3)");
120
+ }
121
+ this.registry.remove(branch);
122
+ throw err;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * 注册子进程 pid(session-runner first header 时调)。
128
+ * create 时 pid 未知写 0 占位,子进程 spawn 拿到 pid 后由此补全。
129
+ * reaper 据 pid 死活判孤儿,pid=0 条目用 SPAWN_GRACE 宽限。
130
+ */
131
+ registerPid(branch: string, pid: number): void {
132
+ this.registry.updatePid(branch, pid);
133
+ }
134
+
135
+ /**
136
+ * 清理 worktree:git worktree remove --force + git branch -D + 注册表移除。
137
+ * 三步各自独立 try/catch——任一步失败不阻断其余(如 remove 失败仍尝试 branch -D + 注册表移除),
138
+ * 避免单步失败导致后续资源泄漏。
139
+ *
140
+ * @param handle 要清理的 worktree handle(含 mainCwd,不靠路径反推)
141
+ */
142
+ cleanup(handle: WorktreeHandle): void {
143
+ try {
144
+ this.gitRun(["worktree", "remove", "--force", handle.path], {
145
+ cwd: handle.mainCwd,
146
+ });
147
+ } catch (err) {
148
+ bestEffort(err, "worktree remove (cleanup)");
149
+ }
150
+
151
+ try {
152
+ this.gitRun(["branch", "-D", handle.branch], {
153
+ cwd: handle.mainCwd,
154
+ });
155
+ } catch (err) {
156
+ bestEffort(err, "branch delete (cleanup)");
157
+ }
158
+
159
+ this.registry.remove(handle.branch);
160
+ }
161
+
162
+ /**
163
+ * 收集 worktree 的改动为 patch。
164
+ *
165
+ * [MF#3] patchFile 由调用方指定(写在 worktree 之外,避免被 cleanup 删除)。
166
+ * [MF#2] 先 git add -A 暂存全部改动(含未跟踪新文件),再 git diff --cached baseCommit
167
+ * 对比暂存区与 base commit。旧实现 `git diff HEAD baseCommit` 是树 vs 树对比:
168
+ * worktree HEAD 初始即 baseCommit,子 agent 不提交时 HEAD 仍 == baseCommit → diff 恒空 → 改动丢失。
169
+ *
170
+ * @param handle worktree handle
171
+ * @param patchFile patch 输出路径(须在 worktree 之外)
172
+ * @returns patch 结果(patchFile 路径 + failed/written 标记)。
173
+ * written=true 仅当 diff 非空且写盘成功;空 diff 或写失败均 written=false,
174
+ * 调用方据此回填 record.patchFile,避免悬空路径(`git apply` 不存在的文件)。
175
+ */
176
+ collectPatch(handle: WorktreeHandle, patchFile: string): PatchResult {
177
+ // git add -A:暂存全部改动(含未跟踪新文件),使后续 --cached diff 能捕获新建文件
178
+ try {
179
+ this.gitRun(["add", "-A"], { cwd: handle.path });
180
+ } catch (err) {
181
+ // add 失败不致命:继续尝试 diff,最差得到部分 diff(仅已跟踪文件的改动)
182
+ bestEffort(err, "git add -A (collectPatch)");
183
+ }
184
+ const diff = this.gitRun(
185
+ ["diff", "--cached", handle.baseCommit],
186
+ { cwd: handle.path },
187
+ );
188
+
189
+ if (diff.length === 0) {
190
+ // 无改动:不写文件,written=false(与有改动写成功区分)
191
+ return Object.freeze({ patchFile, failed: false, written: false });
192
+ }
193
+
194
+ try {
195
+ fs.writeFileSync(patchFile, diff, "utf-8");
196
+ return Object.freeze({ patchFile, failed: false, written: true });
197
+ } catch {
198
+ return Object.freeze({ patchFile, failed: true, written: false });
199
+ }
200
+ }
201
+
202
+ /**
203
+ * 扫描并清理 pi-sub-* 孤儿 worktree。
204
+ *
205
+ * 遍历全局注册表(<agentDir>/subagents/worktrees.json),按 pid 死活判孤儿。
206
+ * 不依赖当前 cwd 是否 git repo——注册表里记了 repo 路径,直接 git -C <repo> 跨 repo 清理。
207
+ *
208
+ * 判据(唯一不删条件 = 进程还活着):
209
+ * pid > 0 且 isProcessAlive(pid) → 跳过(活进程,绝不删)
210
+ * pid > 0 且进程已死 → 孤儿(正常退出未 cleanup / 崩溃残留)
211
+ * pid == 0 且超 SPAWN_GRACE_MS → 孤儿(create 后崩溃,pid 永未补全)
212
+ * pid == 0 且未超宽限 → 跳过(可能正在 spawn)
213
+ */
214
+ scan(): void {
215
+ const entries = this.registry.load();
216
+ const now = Date.now();
217
+
218
+ for (const entry of entries) {
219
+ if (!this.isOrphan(entry, now)) {
220
+ continue;
221
+ }
222
+ this.cleanupOrphan(entry);
223
+ }
224
+ }
225
+
226
+ /** pid 死活判孤儿。pid=0 走 SPAWN_GRACE 宽限。 */
227
+ private isOrphan(entry: WorktreeEntry, now: number): boolean {
228
+ if (entry.pid === 0) {
229
+ // create→spawn 窗口:超过宽限期仍未补 pid = create 后崩溃
230
+ return now - entry.createdAt > SPAWN_GRACE_MS;
231
+ }
232
+ return !isProcessAlive(entry.pid);
233
+ }
234
+
235
+ /** 清理单个孤儿条目:worktree remove + branch -D + 注册表移除,三步各自 best-effort。 */
236
+ private cleanupOrphan(entry: WorktreeEntry): void {
237
+ try {
238
+ this.gitRun(["worktree", "remove", "--force", entry.checkout], { cwd: entry.repo });
239
+ } catch (err) {
240
+ bestEffort(err, "worktree remove (orphan reaper)");
241
+ }
242
+ try {
243
+ this.gitRun(["branch", "-D", entry.branch], { cwd: entry.repo });
244
+ } catch (err) {
245
+ bestEffort(err, "branch delete (orphan reaper)");
246
+ }
247
+ this.registry.remove(entry.branch);
248
+ }
249
+
250
+ // ============================================================
251
+ // 内部工具
252
+ // ============================================================
253
+
254
+ /**
255
+ * git 命令执行器。统一超时 + 错误包装。
256
+ */
257
+ private gitRun(args: string[], opts: { cwd: string; timeout?: number }): string {
258
+ try {
259
+ return execFileSync("git", args, {
260
+ cwd: opts.cwd,
261
+ timeout: opts.timeout ?? GIT_TIMEOUT_MS,
262
+ encoding: "utf-8",
263
+ stdio: ["pipe", "pipe", "pipe"],
264
+ }).trim();
265
+ } catch (err: unknown) {
266
+ if (err instanceof Error) {
267
+ throw new Error(`git ${args[0]} failed: ${err.message}`);
268
+ }
269
+ throw new Error(`git ${args[0]} failed: unknown error`);
270
+ }
271
+ }
272
+
273
+ /**
274
+ * 校验工作目录是 clean tree。
275
+ */
276
+ private assertCleanTree(cwd: string): void {
277
+ const status = this.gitRun(["status", "--porcelain"], { cwd });
278
+ if (status.length > 0) {
279
+ throw new DirtyWorktreeError(
280
+ `Working tree is dirty in ${cwd}:\n${status}`,
281
+ );
282
+ }
283
+ }
284
+
285
+ }
@@ -0,0 +1,144 @@
1
+ // src/runtime/worktree-registry.ts
2
+ //
3
+ // 全局 worktree 注册表:跨 repo 记录所有活 pi-sub-* worktree。
4
+ //
5
+ // 取代旧的 per-cwd 扫描 + .session mapping sidecar 链。
6
+ // 旧 reaper 的两个根本缺陷由此消除:
7
+ // 1. 触发覆盖:旧 scan 扫「当前 cwd 对应的 repo」,workspace 根 / 非 git 目录启动时
8
+ // rev-parse 报错整个挂掉;且 tmpdir 下的 checkout 永远不会被 pi cwd "看到"。
9
+ // → 新 scan 遍历全局注册表,不依赖 cwd 是否 git repo。
10
+ // 2. 判据脆弱:旧 scan 用 .finalized/.cancelled 终态 marker 作主判据,进程崩溃时
11
+ // 无人写终态 → 孤儿永久泄漏。→ 新判据:pid 死活一条判到底。
12
+ //
13
+ // 并发模型:
14
+ // - 同步 IO(readFileSync/writeFileSync)。Node 单线程保证 sync read-modify-write
15
+ // 在一个 event loop turn 内原子完成,进程内无需 mutex。
16
+ // - 多 WorktreeManager 实例(reaper + service)共享同一文件,sync 操作天然串行。
17
+ // - 跨进程(用户开两个 pi):last-write-wins,丢失条目靠 OS tmpdir + 分支对账兜底。
18
+ // - 原子写:写 .tmp → rename,防写一半崩溃产生损坏 JSON。
19
+
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+
23
+ import { bestEffort } from "./best-effort.ts";
24
+
25
+ /** create→spawn 宽限期(ms):pid=0 条目超过此阈值判 create 后崩溃。 */
26
+ export const SPAWN_GRACE_MS = 60_000;
27
+
28
+ /** JSON 缩进空格数(可读性 + diff 友好)。 */
29
+ const JSON_INDENT = 2;
30
+
31
+ /** 注册表 JSON 顶层结构的运行时类型守卫。 */
32
+ function isRegistryData(value: unknown): value is { entries: WorktreeEntry[] } {
33
+ return (
34
+ typeof value === "object" &&
35
+ value !== null &&
36
+ "entries" in value &&
37
+ Array.isArray(value.entries)
38
+ );
39
+ }
40
+
41
+ /**
42
+ * 注册表条目:一条 = 一个活 worktree。
43
+ * 字段全部来自 WorktreeHandle + session-runner 已捕获的 child.pid,零新数据源。
44
+ */
45
+ export interface WorktreeEntry {
46
+ /** 主仓库根目录(git -C <repo> 操作目标)。 */
47
+ readonly repo: string;
48
+ /** 分支名("pi-sub-<recordId>")。 */
49
+ readonly branch: string;
50
+ /** checkout 目录(tmpdir 下,= WorktreeHandle.path)。 */
51
+ readonly checkout: string;
52
+ /** 子进程 pid(0 = create-spawn 窗口,尚未拿到 pid)。 */
53
+ readonly pid: number;
54
+ /** 创建时间戳(ms,SPAWN_GRACE 判据 + 调试用)。 */
55
+ readonly createdAt: number;
56
+ }
57
+
58
+ /**
59
+ * 全局 worktree 注册表。
60
+ *
61
+ * 文件位置:<agentDir>/subagents/worktrees.json(repo 无关层级,跨 repo 共享)
62
+ * 格式:{ "entries": WorktreeEntry[] }
63
+ */
64
+ export class WorktreeRegistry {
65
+ private readonly filePath: string;
66
+
67
+ constructor(agentDir: string) {
68
+ this.filePath = path.join(agentDir, "subagents", "worktrees.json");
69
+ }
70
+
71
+ /**
72
+ * 新增条目(create 成功后调,pid=0 占位)。
73
+ * 同 branch 已存在则覆盖(防残留覆盖)。
74
+ */
75
+ add(entry: WorktreeEntry): void {
76
+ const entries = this.load();
77
+ const idx = entries.findIndex((e) => e.branch === entry.branch);
78
+ if (idx >= 0) {
79
+ entries[idx] = entry;
80
+ } else {
81
+ entries.push(entry);
82
+ }
83
+ this.save(entries);
84
+ }
85
+
86
+ /**
87
+ * 更新 pid(session-runner first header 时调)。
88
+ * branch 不存在则忽略(create 后崩溃 + reaper 已清的竞态)。
89
+ */
90
+ updatePid(branch: string, pid: number): void {
91
+ const entries = this.load();
92
+ const idx = entries.findIndex((e) => e.branch === branch);
93
+ if (idx >= 0) {
94
+ entries[idx] = { ...entries[idx], pid };
95
+ this.save(entries);
96
+ }
97
+ }
98
+
99
+ /**
100
+ * 移除条目(cleanup/reaper 清理后调)。
101
+ * branch 不存在则忽略(幂等)。
102
+ */
103
+ remove(branch: string): void {
104
+ const entries = this.load();
105
+ const filtered = entries.filter((e) => e.branch !== branch);
106
+ if (filtered.length !== entries.length) {
107
+ this.save(filtered);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * 加载全部条目(reaper 遍历用)。
113
+ * 文件不存在 / 解析失败 / IO 错误 → 返回空数组(视为无活 worktree)。
114
+ */
115
+ load(): WorktreeEntry[] {
116
+ try {
117
+ const raw = fs.readFileSync(this.filePath, "utf-8");
118
+ const parsed = JSON.parse(raw) as unknown;
119
+ if (isRegistryData(parsed)) {
120
+ return parsed.entries;
121
+ }
122
+ return [];
123
+ } catch {
124
+ // 文件不存在(首次运行)/ 解析失败(损坏)/ IO 错误 → 空注册表
125
+ return [];
126
+ }
127
+ }
128
+
129
+ /**
130
+ * 原子写入全部条目。
131
+ * best-effort:写入失败不阻断主流程(create/cleanup 的 git 操作已执行,
132
+ * 注册表与 git 状态的短暂不一致靠下次 reaper 对账收敛)。
133
+ */
134
+ private save(entries: WorktreeEntry[]): void {
135
+ try {
136
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
137
+ const tmp = `${this.filePath}.tmp`;
138
+ fs.writeFileSync(tmp, JSON.stringify({ entries }, null, JSON_INDENT), "utf-8");
139
+ fs.renameSync(tmp, this.filePath);
140
+ } catch (err) {
141
+ bestEffort(err, "worktree registry save");
142
+ }
143
+ }
144
+ }