@zhushanwen/pi-subagent-workflow 8.0.0 → 8.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 (55) hide show
  1. package/package.json +7 -4
  2. package/scripts/rfl.mjs +308 -0
  3. package/src/execution/__tests__/finalize-record.test.ts +33 -7
  4. package/src/execution/__tests__/helpers/spawn-mock.ts +12 -3
  5. package/src/execution/__tests__/record-store.test.ts +284 -2
  6. package/src/execution/__tests__/spawn-args.test.ts +5 -5
  7. package/src/execution/__tests__/subagent-service-message-close.test.ts +31 -0
  8. package/src/execution/__tests__/worktree-manager.test.ts +9 -2
  9. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +4 -1
  10. package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
  11. package/src/execution/__tests__/worktree-registry.test.ts +72 -34
  12. package/src/execution/agent-result-mapper.ts +4 -1
  13. package/src/execution/argv-mirror.ts +1 -1
  14. package/src/execution/channel-registry-access.ts +3 -1
  15. package/src/execution/finalize-record.ts +16 -7
  16. package/src/execution/idle-gc.ts +47 -0
  17. package/src/execution/lifecycle-manager.ts +9 -2
  18. package/src/execution/record-entry.ts +118 -0
  19. package/src/execution/record-store.ts +290 -1
  20. package/src/execution/session-pending.ts +5 -4
  21. package/src/execution/session-runner.ts +484 -289
  22. package/src/execution/subagent-service.ts +87 -37
  23. package/src/execution/temp-prompt.ts +8 -3
  24. package/src/execution/types.ts +20 -2
  25. package/src/execution/worktree-manager.ts +325 -14
  26. package/src/execution/worktree-registry.ts +90 -33
  27. package/src/index.ts +42 -2
  28. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +103 -26
  29. package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +179 -0
  30. package/src/interface/format.ts +10 -4
  31. package/src/interface/tool-render.ts +10 -3
  32. package/src/interface/tool-workflow-script.ts +29 -33
  33. package/src/interface/tool-workflow.ts +47 -65
  34. package/src/interface/views/detail-content.ts +1 -1
  35. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +9 -1
  36. package/src/orchestration/__tests__/execute-agent-call.test.ts +49 -1
  37. package/src/orchestration/__tests__/jsonl-run-store-loadall-sources.test.ts +171 -0
  38. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +181 -19
  39. package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
  40. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
  41. package/src/orchestration/__tests__/test-mocks.ts +9 -3
  42. package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
  43. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +12 -0
  44. package/src/orchestration/__tests__/workflows-e2e.test.ts +37 -39
  45. package/src/orchestration/error-recovery.ts +4 -1
  46. package/src/orchestration/execute-agent-call.ts +13 -3
  47. package/src/orchestration/jsonl-run-store.ts +139 -60
  48. package/src/orchestration/lifecycle.ts +10 -1
  49. package/src/orchestration/worker-script-builder.ts +9 -1
  50. package/src/shared/__tests__/resource-discovery.test.ts +24 -0
  51. package/src/shared/agent-ref.ts +6 -1
  52. package/src/shared/resource-discovery.ts +15 -1
  53. package/src/shared/schema-jsonify.ts +4 -1
  54. package/workflows/review-fix-loop-utils.cjs +542 -32
  55. package/workflows/review-fix-loop.js +462 -109
@@ -16,6 +16,11 @@
16
16
  // [全局注册表重构] scan 不再依赖当前 cwd 是否 git repo,改为遍历
17
17
  // WorktreeRegistry(<agentDir>/subagents/worktrees.json)。判据从终态 marker
18
18
  // 状态机降为 pid 死活一条——进程崩溃无人写终态时也能正确回收。
19
+ //
20
+ // [D5b 对账] scan 末尾追加双向 diff(reconcileWithPhysical):物理面(tmpdir
21
+ // checkout 目录 + git branch --list)与注册表互相对账——注册表条目丢失(锁前
22
+ // last-write-wins 遗留 / 锁降级窗口)或物理资源被外部清掉时收敛,注册表注释
23
+ // 声称的「tmpdir + 分支对账兜底」由此成为代码。
19
24
 
20
25
  import { execFile } from "node:child_process";
21
26
  import * as fs from "node:fs";
@@ -27,7 +32,7 @@ import type { PatchResult,WorktreeHandle } from "./types.ts";
27
32
  import { DirtyWorktreeError } from "./types.ts";
28
33
  import { bestEffort } from "./best-effort.ts";
29
34
  import { getLogger } from "@zhushanwen/pi-extension-logger";
30
- import { isProcessAlive } from "./alive-store.ts";
35
+ import { isProcessAlive, readAliveMarker } from "./alive-store.ts";
31
36
  import { SPAWN_GRACE_MS,type WorktreeEntry,WorktreeRegistry } from "./worktree-registry.ts";
32
37
 
33
38
  const logger = getLogger("subagents");
@@ -38,6 +43,30 @@ const SAFE_ID_RE = /^[\w-]+$/;
38
43
  // 默认 git 命令超时(ms)
39
44
  const GIT_TIMEOUT_MS = 30_000;
40
45
 
46
+ /** tmpdir 下的 worktree 根目录(与 create() 的路径拼装保持同源)。 */
47
+ const WORKTREE_TMP_ROOT = "pi-subagents";
48
+
49
+ /** 分支名前缀(create() 生成 `pi-sub-<recordId>`)。 */
50
+ const BRANCH_PREFIX = "pi-sub-";
51
+
52
+ /**
53
+ * 物理面发现的 worktree(tmpdir checkout 目录存在,无论注册表是否登记)。
54
+ * repo 从 checkout/.git 指针文件推导(普通 repo 与 bare+worktree 均覆盖);
55
+ * 推导失败(.git 文件缺失/损坏)时 undefined——checkout 视为无主残留。
56
+ */
57
+ interface PhysicalWorktree {
58
+ /** encodeCwd(mainCwd) 段名(checkout 路径中间层)。 */
59
+ readonly enc: string;
60
+ /** 分支名(checkout 目录名,= pi-sub-<recordId>)。 */
61
+ readonly branch: string;
62
+ /** checkout 绝对路径。 */
63
+ readonly checkout: string;
64
+ /** 推导出的主仓库路径(.git 指针解析失败则 undefined)。 */
65
+ readonly repo?: string;
66
+ /** checkout 目录 mtime(对账 SPAWN_GRACE 判据的 createdAt 近似)。 */
67
+ readonly mtimeMs: number;
68
+ }
69
+
41
70
  /**
42
71
  * gitRunAsync 的包装错误:message 格式与旧同步 gitRun 逐字一致(下游
43
72
  * DirtyWorktreeError 判定与测试 toThrow 匹配零改动);exitCode/stderr/timedOut
@@ -62,18 +91,42 @@ export class GitRunError extends Error {
62
91
  }
63
92
 
64
93
  /**
65
- * 写类 git 命令判定(per-repo mutex 串行对象)。读类(status/rev-parse/diff)
94
+ * 写类 git 命令判定(per-repo mutex 串行对象)。读类(status/rev-parse/diff/branch --list
66
95
  * 无副作用不加锁——并发读 git 自身安全,P-lock 实测冲突仅是写窗口假设。
67
96
  */
68
97
  function isWriteCommand(args: string[]): boolean {
69
- if (args[0] === "worktree") return args[1] === "add" || args[1] === "remove";
98
+ if (args[0] === "worktree") return args[1] === "add" || args[1] === "remove" || args[1] === "prune";
70
99
  if (args[0] === "branch") return args[1] === "-D";
71
100
  return args[0] === "add";
72
101
  }
73
102
 
103
+ /**
104
+ * 从 checkout 目录的 .git 指针文件推导主仓库路径(D5b 对账用)。
105
+ * worktree 的 .git 是文本文件(`gitdir: <repo>/.git/worktrees/<branch>`),
106
+ * 普通 repo(.git)与 bare+worktree(.bare)统一取 worktrees 段上两级。
107
+ * 解析失败(文件缺失/格式异常/路径越界)返回 undefined——调用方按无主残留处置。
108
+ */
109
+ function resolveRepoFromCheckout(checkout: string): string | undefined {
110
+ try {
111
+ const raw = fs.readFileSync(path.join(checkout, ".git"), "utf-8").trim();
112
+ if (!raw.startsWith("gitdir:")) return undefined;
113
+ const gitdir = raw.slice("gitdir:".length).trim();
114
+ // gitdir = <repo>/.git/worktrees/<branch> → 上三级 = repo root
115
+ // (bare 时 <ws>/.bare/worktrees/<br> → <ws>,git -C <bare> 操作合法)
116
+ const worktreesDir = path.dirname(gitdir);
117
+ if (path.basename(worktreesDir) !== "worktrees") return undefined;
118
+ const gitRootDir = path.dirname(worktreesDir);
119
+ return path.dirname(gitRootDir);
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+
74
125
  export class WorktreeManager {
75
126
  // 全局注册表:跨 repo 记录所有活 worktree,reaper 遍历此表判孤儿。
76
127
  private readonly registry: WorktreeRegistry;
128
+ // agentDir(<agentDir>/subagents/<enc>/sessions 下扫 .alive 活信号,D5b 对账用)
129
+ private readonly agentDir: string;
77
130
  // per-repo 写命令串行队列:value = 队尾(已吞 rejection 的)Promise。
78
131
  // 入队形态 prev.catch(()=>{}).then(run)——后继只关心「自己已排队」,
79
132
  // 不继承前驱错误(否则 1 个 worktree add 失败会传染同 repo 后续全部写命令,
@@ -81,6 +134,7 @@ export class WorktreeManager {
81
134
  private readonly writeQueues = new Map<string, Promise<void>>();
82
135
 
83
136
  constructor(agentDir: string) {
137
+ this.agentDir = agentDir;
84
138
  this.registry = new WorktreeRegistry(agentDir);
85
139
  }
86
140
 
@@ -140,9 +194,9 @@ export class WorktreeManager {
140
194
  cwd: mainCwd,
141
195
  });
142
196
 
143
- // 注册到全局表(pid=0 占位)。runSpawn 在 spawn() 返回后同步补 pid。
197
+ // 注册到全局表(pid=0 占位)。runSpawn 在 spawn() 返回后异步补 pid。
144
198
  // 放在 worktree add 成功后、symlink 前——确保只有真正创建了 worktree 才登记。
145
- this.registry.add({
199
+ await this.registry.add({
146
200
  repo: mainCwd,
147
201
  branch,
148
202
  checkout: worktreePath,
@@ -178,19 +232,23 @@ export class WorktreeManager {
178
232
  } catch (cleanErr) {
179
233
  bestEffort(cleanErr, "branch delete (create rollback MF#3)");
180
234
  }
181
- this.registry.remove(branch);
235
+ await this.registry.remove(branch);
182
236
  throw err;
183
237
  }
184
238
  }
185
239
 
186
240
  /**
187
- * 注册子进程 pid(runSpawn spawn() 返回后同步调)。
241
+ * 注册子进程 pid(runSpawn spawn() 返回后调)。
188
242
  * create 时 pid 未知写 0 占位,子进程 spawn 返回后(child.pid 同步可得)由此补全。
189
243
  * reaper 据 pid 死活判孤儿,pid=0 条目用 SPAWN_GRACE 宽限。
190
244
  * sessionFile 可选补全:传入时填入 registry entry(reaper 据 pid 死活判孤儿,不读本字段;保留供诊断)。
245
+ *
246
+ * [D5a] async 化:pid 补全走跨进程锁内 RMW(互斥窗口消除 updatePid 与并发 add/remove
247
+ * 的交错)。永不 reject(锁降级 + best-effort save 均内部兜底),调用方可安全
248
+ * fire-and-forget(session-runner 的 stdout data 回调上下文)。
191
249
  */
192
- registerPid(branch: string, pid: number, sessionFile?: string): void {
193
- this.registry.updatePid(branch, pid, sessionFile);
250
+ async registerPid(branch: string, pid: number, sessionFile?: string): Promise<void> {
251
+ await this.registry.updatePid(branch, pid, sessionFile);
194
252
  }
195
253
 
196
254
  /**
@@ -217,7 +275,7 @@ export class WorktreeManager {
217
275
  bestEffort(err, "branch delete (cleanup)");
218
276
  }
219
277
 
220
- this.registry.remove(handle.branch);
278
+ await this.registry.remove(handle.branch);
221
279
  }
222
280
 
223
281
  /**
@@ -261,16 +319,21 @@ export class WorktreeManager {
261
319
  }
262
320
 
263
321
  /**
264
- * 扫描并清理 pi-sub-* 孤儿 worktree
322
+ * 扫描并清理 pi-sub-* 孤儿 worktree + 物理面对账(D5b)。
265
323
  *
266
- * 遍历全局注册表(<agentDir>/subagents/worktrees.json),按 pid 死活判孤儿。
267
- * 不依赖当前 cwd 是否 git repo——注册表里记了 repo 路径,直接 git -C <repo> 跨 repo 清理。
324
+ * 阶段一(既有):遍历全局注册表(<agentDir>/subagents/worktrees.json),
325
+ * pid 死活判孤儿。不依赖当前 cwd 是否 git repo——注册表里记了 repo 路径,
326
+ * 直接 git -C <repo> 跨 repo 清理。
268
327
  *
269
328
  * 判据(唯一不删条件 = 进程还活着):
270
329
  * pid > 0 且 isProcessAlive(pid) → 跳过(活进程,绝不删)
271
330
  * pid > 0 且进程已死 → 孤儿(正常退出未 cleanup / 崩溃残留)
272
331
  * pid == 0 且超 SPAWN_GRACE_MS → 孤儿(create 后崩溃,pid 永未补全)
273
332
  * pid == 0 且未超宽限 → 跳过(可能正在 spawn)
333
+ *
334
+ * 阶段二(D5b):物理面(tmpdir checkout + 分支)与注册表双向 diff 收敛——
335
+ * 兑现 worktree-registry.ts 头注释声称的「tmpdir + 分支对账兜底」。全流程
336
+ * 幂等、失败仅日志(对账失败不阻断 session_start)。
274
337
  */
275
338
  async scan(): Promise<void> {
276
339
  const entries = this.registry.load();
@@ -283,6 +346,254 @@ export class WorktreeManager {
283
346
  }
284
347
  await this.cleanupOrphan(entry);
285
348
  }
349
+
350
+ await this.reconcileWithPhysical();
351
+ }
352
+
353
+ /**
354
+ * D5b 双向 diff 对账:物理面(tmpdir checkout 目录 + git branch --list)与
355
+ * 注册表互查,收敛三类漂移(锁消灭交错主因后,本对账兜底条目丢失/文件损坏的长尾)。
356
+ * 方向一/方向二的完整判据见 {@link removePhantomRegistryEntries} /
357
+ * {@link reconcileUnregisteredWorktrees}。
358
+ */
359
+ private async reconcileWithPhysical(): Promise<void> {
360
+ // 物理面发现失败(tmpdir 不可读等)→ 放弃本轮对账(失败仅日志,不阻断)
361
+ const physical = await this.discoverPhysicalWorktrees();
362
+ const registered = this.registry.load();
363
+ const registeredBranches = new Set(registered.map((e) => e.branch));
364
+
365
+ // ── 方向一:注册有 → 物理无 ──
366
+ // repo 集合 = 注册表条目 repo ∪ 物理推导 repo,per repo 查物理分支全集。
367
+ const repos = new Set<string>(registered.map((e) => e.repo));
368
+ for (const pt of physical) {
369
+ if (pt.repo) repos.add(pt.repo);
370
+ }
371
+ const branchesByRepo = await this.listPhysicalBranches(repos);
372
+ await this.removePhantomRegistryEntries(registered, branchesByRepo);
373
+
374
+ // ── 方向二:物理有 → 注册无 ──
375
+ const orphans = physical.filter((pt) => !registeredBranches.has(pt.branch));
376
+ await this.reconcileUnregisteredWorktrees(orphans);
377
+ }
378
+
379
+ /** 对账方向一(注册有 → 物理无):条目的分支与 checkout 目录都已不存在 → 条目指向
380
+ * 幻影资源 → 移除条目(纯清账,不删任何仍存在的资源,幂等安全)。 */
381
+ private async removePhantomRegistryEntries(
382
+ registered: WorktreeEntry[],
383
+ branchesByRepo: Map<string, Set<string>>,
384
+ ): Promise<void> {
385
+ for (const entry of registered) {
386
+ const branches = branchesByRepo.get(entry.repo);
387
+ // repo 分支查询失败(get undefined)→ 保守跳过:视为物理存在,不动条目。
388
+ if (branches === undefined) continue;
389
+ const branchGone = !branches.has(entry.branch);
390
+ const checkoutGone = !fs.existsSync(entry.checkout);
391
+ if (branchGone && checkoutGone) {
392
+ logger.warn("[worktree] reconcile: registry entry has no physical worktree/branch, removing entry", {
393
+ branch: entry.branch,
394
+ repo: entry.repo,
395
+ pid: entry.pid,
396
+ });
397
+ await this.registry.remove(entry.branch);
398
+ }
399
+ }
400
+ }
401
+
402
+ /** 对账方向二(物理有 → 注册无):按 enc 段(encodeCwd(mainCwd))聚合,活信号 =
403
+ * <agentDir>/subagents/<enc>/sessions/*.alive 中存活的 pid(session-runner
404
+ * first header 时写入,崩溃残留不删):
405
+ * - 无活 pid:残留判死,checkout mtime 超 SPAWN_GRACE_MS 才清(防误清另一
406
+ * 进程 worktree add 完成到 registry.add 落盘之间的 create 窗口);
407
+ * - 恰好 1 个活 pid 且恰好 1 个残留:补写回注册表(自愈——最常见的双 session
408
+ * 并发覆盖丢条目场景,补写后回归标准 pid 判据路径);
409
+ * - 多活 pid 或多残留无法建立 branch↔pid 对应:跳过 + warn——宁延迟勿误删;
410
+ * 活体自身 cleanup 路径正常(registry.remove 幂等),死体等活 pid 全灭后
411
+ * 下一周期收敛。 */
412
+ private async reconcileUnregisteredWorktrees(orphans: PhysicalWorktree[]): Promise<void> {
413
+ // 按 enc 段聚合处理(活信号以 enc 段为粒度——.alive 在 <enc>/sessions/ 下)
414
+ const orphansByEnc = new Map<string, PhysicalWorktree[]>();
415
+ for (const pt of orphans) {
416
+ const list = orphansByEnc.get(pt.enc) ?? [];
417
+ list.push(pt);
418
+ orphansByEnc.set(pt.enc, list);
419
+ }
420
+ for (const [enc, list] of orphansByEnc) {
421
+ await this.reconcileEncSegment(enc, list);
422
+ }
423
+ }
424
+
425
+ /** 单 enc 段的残留处置三分支:无活 pid 判死清理 / 唯一对应自愈补写 / 多对应保守跳过。 */
426
+ private async reconcileEncSegment(enc: string, list: PhysicalWorktree[]): Promise<void> {
427
+ const alivePids = this.collectAlivePids(enc);
428
+ if (alivePids.length === 0) {
429
+ await this.cleanupDeadSegment(list);
430
+ return;
431
+ }
432
+ if (alivePids.length === 1 && list.length === 1) {
433
+ // 唯一活 pid ↔ 唯一残留:对应关系无歧义,自愈补写回注册表。
434
+ // pid 若最终对应错误(理论上不该发生),后果是延迟清理而非误删(判活跳过)。
435
+ const pt = list[0];
436
+ logger.warn("[worktree] reconcile: unregistered physical worktree with one alive pid, re-registering (self-heal)", {
437
+ branch: pt.branch,
438
+ checkout: pt.checkout,
439
+ repo: pt.repo,
440
+ pid: alivePids[0],
441
+ });
442
+ await this.registry.add({
443
+ repo: pt.repo ?? path.dirname(pt.checkout),
444
+ branch: pt.branch,
445
+ checkout: pt.checkout,
446
+ pid: alivePids[0],
447
+ createdAt: pt.mtimeMs,
448
+ });
449
+ return;
450
+ }
451
+ // 多活 pid / 多残留:无法建立 branch↔pid 对应,保守跳过待下周期。
452
+ logger.warn("[worktree] reconcile: unregistered physical worktrees present but alive-pid mapping ambiguous, skipping this cycle", {
453
+ enc,
454
+ orphans: list.length,
455
+ alivePids: alivePids.length,
456
+ });
457
+ }
458
+
459
+ /** 无活 pid 段:残留判死清理——checkout mtime 超 SPAWN_GRACE_MS 才清(防误清另一
460
+ * 进程 worktree add 完成到 registry.add 落盘之间的 create 窗口)。 */
461
+ private async cleanupDeadSegment(list: PhysicalWorktree[]): Promise<void> {
462
+ for (const pt of list) {
463
+ const age = Date.now() - pt.mtimeMs;
464
+ if (age <= SPAWN_GRACE_MS) continue; // create 窗口(worktree add 后 add 落盘前)
465
+ logger.warn("[worktree] reconcile: unregistered physical worktree with no alive pid, cleaning up", {
466
+ branch: pt.branch,
467
+ checkout: pt.checkout,
468
+ repo: pt.repo,
469
+ ageMs: age,
470
+ });
471
+ await this.cleanupPhysical(pt);
472
+ }
473
+ }
474
+
475
+ /**
476
+ * 物理面发现:扫描 <tmpdir>/pi-subagents/<enc>/<pi-sub-*> checkout 目录。
477
+ * repo 从 checkout/.git 指针文件推导(`gitdir: <repo>/.git/worktrees/<branch>`,
478
+ * 普通 repo 与 bare+worktree(.bare/worktrees/...)统一取 worktrees 段上两级);
479
+ * 推导失败(残缺 checkout)repo=undefined,由调用方按无主残留处置。
480
+ */
481
+ private async discoverPhysicalWorktrees(): Promise<PhysicalWorktree[]> {
482
+ const root = path.join(os.tmpdir(), WORKTREE_TMP_ROOT);
483
+ let encDirs: string[];
484
+ try {
485
+ encDirs = fs.readdirSync(root, { withFileTypes: true })
486
+ .filter((d) => d.isDirectory())
487
+ .map((d) => d.name);
488
+ } catch {
489
+ return []; // tmpdir 根不存在(从未创建过 worktree)→ 空物理面
490
+ }
491
+
492
+ const result: PhysicalWorktree[] = [];
493
+ for (const enc of encDirs) {
494
+ let branchDirs: string[];
495
+ try {
496
+ branchDirs = fs.readdirSync(path.join(root, enc), { withFileTypes: true })
497
+ .filter((d) => d.isDirectory() && d.name.startsWith(BRANCH_PREFIX))
498
+ .map((d) => d.name);
499
+ } catch {
500
+ continue; // 单个 enc 段不可读:跳过该段(对账失败仅影响本段收敛)
501
+ }
502
+ for (const branch of branchDirs) {
503
+ const checkout = path.join(root, enc, branch);
504
+ try {
505
+ const mtimeMs = fs.statSync(checkout).mtimeMs;
506
+ result.push({ enc, branch, checkout, repo: resolveRepoFromCheckout(checkout), mtimeMs });
507
+ } catch (err) {
508
+ bestEffort(err, "physical worktree stat (reconcile)");
509
+ }
510
+ }
511
+ }
512
+ return result;
513
+ }
514
+
515
+ /**
516
+ * per repo 查物理分支全集:`git -C <repo> branch --list 'pi-sub-*' --format=%(refname:short)`。
517
+ * 读类命令不加写锁;单 repo 失败 → map 不含该 repo(get 返回 undefined),
518
+ * 调用方据此保守跳过该 repo 的条目判定(防把「查询失败」误判成「分支不存在」)。
519
+ */
520
+ private async listPhysicalBranches(repos: Set<string>): Promise<Map<string, Set<string>>> {
521
+ const map = new Map<string, Set<string>>();
522
+ for (const repo of repos) {
523
+ try {
524
+ const out = await this.gitRunAsync(
525
+ ["branch", "--list", `${BRANCH_PREFIX}*`, "--format=%(refname:short)"],
526
+ { cwd: repo },
527
+ );
528
+ const branches = new Set(
529
+ out.split("\n").map((l) => l.trim()).filter((l) => l.startsWith(BRANCH_PREFIX)),
530
+ );
531
+ map.set(repo, branches);
532
+ } catch (err) {
533
+ bestEffort(err, `git branch --list (reconcile, repo=${repo})`);
534
+ }
535
+ }
536
+ return map;
537
+ }
538
+
539
+ /**
540
+ * 收集 enc 段的活 pid:<agentDir>/subagents/<enc>/sessions/*.alive 中
541
+ * readAliveMarker 解析成功且 isProcessAlive 的 pid(去重)。
542
+ * 崩溃残留的 .alive(pid 已死)天然过滤掉——这正是「死活判据」的物理面来源。
543
+ */
544
+ private collectAlivePids(enc: string): number[] {
545
+ const sessionsDir = path.join(this.agentDir, "subagents", enc, "sessions");
546
+ let files: string[];
547
+ try {
548
+ files = fs.readdirSync(sessionsDir);
549
+ } catch {
550
+ return []; // enc 段无 sessions 目录(该 repo 从未跑过 subagent)→ 无活信号
551
+ }
552
+ const pids = new Set<number>();
553
+ for (const file of files) {
554
+ if (!file.endsWith(".alive")) continue;
555
+ const marker = readAliveMarker(path.join(sessionsDir, file.slice(0, -".alive".length)));
556
+ if (marker && isProcessAlive(marker.pid)) {
557
+ pids.add(marker.pid);
558
+ }
559
+ }
560
+ return [...pids];
561
+ }
562
+
563
+ /**
564
+ * 清理物理残留(D5b 方向二的死体处置):worktree remove → prune → branch -D
565
+ * → 目录 rm 兜底,四步各自 best-effort(幂等,失败仅日志)。
566
+ * prune 必要性:checkout 目录已不存在的 worktree,remove 会失败且 branch -D
567
+ * 被「used by worktree」拒绝——prune 清掉缺失目录的元数据后分支才可删。
568
+ */
569
+ private async cleanupPhysical(pt: PhysicalWorktree): Promise<void> {
570
+ if (pt.repo) {
571
+ try {
572
+ await this.gitRunAsync(["worktree", "remove", "--force", pt.checkout], { cwd: pt.repo });
573
+ } catch (err) {
574
+ bestEffort(err, "worktree remove (reconcile)");
575
+ }
576
+ try {
577
+ await this.gitRunAsync(["worktree", "prune"], { cwd: pt.repo });
578
+ } catch (err) {
579
+ bestEffort(err, "worktree prune (reconcile)");
580
+ }
581
+ try {
582
+ await this.gitRunAsync(["branch", "-D", pt.branch], { cwd: pt.repo });
583
+ } catch (err) {
584
+ bestEffort(err, "branch delete (reconcile)");
585
+ }
586
+ }
587
+ // 目录兜底:repo 未知(无主残留)或 remove 失败(元数据损坏)时直接删目录。
588
+ // 路径在 tmpdir/pi-subagents/<enc>/pi-sub-* 下,按设计只有本扩展创建,清理安全
589
+ // (与 create() 的前置清理同一安全边界)。
590
+ try {
591
+ if (fs.existsSync(pt.checkout)) {
592
+ fs.rmSync(pt.checkout, { recursive: true, force: true });
593
+ }
594
+ } catch (err) {
595
+ bestEffort(err, "checkout dir rm (reconcile)");
596
+ }
286
597
  }
287
598
 
288
599
  /**
@@ -318,7 +629,7 @@ export class WorktreeManager {
318
629
  } catch (err) {
319
630
  bestEffort(err, "branch delete (orphan reaper)");
320
631
  }
321
- this.registry.remove(entry.branch);
632
+ await this.registry.remove(entry.branch);
322
633
  }
323
634
 
324
635
  // ============================================================
@@ -11,18 +11,23 @@
11
11
  // 无人写终态 → 孤儿永久泄漏。→ 新判据:pid 死活一条判到底。
12
12
  //
13
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 + 分支对账兜底。
14
+ // - 跨进程互斥(D5a):add/updatePid/remove load→mutate→save 全程持
15
+ // proper-lockfile 异步锁(<worktrees.json>.lock,协议登记 data-source-registry.md §6)。
16
+ // 锁不可用(重试耗尽)时降级为无锁 RMW + warn——注册表是 best-effort 数据,
17
+ // 降级不比锁前更差,条目丢失由 reaper 对账兜底(worktree-manager scan
18
+ // 双向 diff,见 reconcileWithPhysical)。
19
+ // - 同步 IO(readFileSync/writeFileSync)持锁执行:锁内临界区毫秒级,
20
+ // async 锁 + sync IO 组合在单线程 event loop 内无 interleaving。
18
21
  // - 原子写:写 .tmp → rename,防写一半崩溃产生损坏 JSON。
19
22
 
20
23
  import * as fs from "node:fs";
21
24
  import * as path from "node:path";
22
25
 
23
- import { bestEffort } from "./best-effort.ts";
26
+ import { withFileLock } from "@zhushanwen/pi-file-lock";
24
27
  import { getLogger } from "@zhushanwen/pi-extension-logger";
25
28
 
29
+ import { bestEffort } from "./best-effort.ts";
30
+
26
31
  const logger = getLogger("subagents");
27
32
 
28
33
  /** create→spawn 宽限期(ms):pid=0 条目超过此阈值判 create 后崩溃。 */
@@ -31,6 +36,11 @@ export const SPAWN_GRACE_MS = 60_000;
31
36
  /** JSON 缩进空格数(可读性 + diff 友好)。 */
32
37
  const JSON_INDENT = 2;
33
38
 
39
+ /** tmp 随机段:36 进制取 8 字符(跳过 "0." 前缀),与 pid 组合保证并发唯一。 */
40
+ const TMP_RANDOM_BASE = 36;
41
+ const TMP_RANDOM_SLICE_START = 2; // 跳过 Math.random 字符串的 "0." 前缀
42
+ const TMP_RANDOM_SLICE_END = 10;
43
+
34
44
  /** 注册表 JSON 顶层结构的运行时类型守卫。 */
35
45
  function isRegistryData(value: unknown): value is { entries: WorktreeEntry[] } {
36
46
  return (
@@ -80,16 +90,17 @@ export class WorktreeRegistry {
80
90
  /**
81
91
  * 新增条目(create 成功后调,pid=0 占位)。
82
92
  * 同 branch 已存在则覆盖(防残留覆盖)。
93
+ * 跨进程锁内 RMW(D5a);锁降级路径见 mutate。
83
94
  */
84
- add(entry: WorktreeEntry): void {
85
- const entries = this.load();
86
- const idx = entries.findIndex((e) => e.branch === entry.branch);
87
- if (idx >= 0) {
88
- entries[idx] = entry;
89
- } else {
90
- entries.push(entry);
91
- }
92
- this.save(entries);
95
+ async add(entry: WorktreeEntry): Promise<void> {
96
+ await this.mutate((entries) => {
97
+ const idx = entries.findIndex((e) => e.branch === entry.branch);
98
+ if (idx >= 0) {
99
+ entries[idx] = entry;
100
+ } else {
101
+ entries.push(entry);
102
+ }
103
+ });
93
104
  }
94
105
 
95
106
  /**
@@ -97,28 +108,73 @@ export class WorktreeRegistry {
97
108
  * branch 不存在则忽略(create 后崩溃 + reaper 已清的竞态)。
98
109
  * sessionFile 可选补全:传入时填入 entry(reaper 据 pid 死活判孤儿,不读本字段)。
99
110
  */
100
- updatePid(branch: string, pid: number, sessionFile?: string): void {
101
- const entries = this.load();
102
- const idx = entries.findIndex((e) => e.branch === branch);
103
- if (idx >= 0) {
104
- entries[idx] = {
105
- ...entries[idx],
106
- pid,
107
- ...(sessionFile !== undefined ? { sessionFile } : {}),
108
- };
109
- this.save(entries, { branch, pid });
110
- }
111
+ async updatePid(branch: string, pid: number, sessionFile?: string): Promise<void> {
112
+ await this.mutate(
113
+ (entries) => {
114
+ const idx = entries.findIndex((e) => e.branch === branch);
115
+ if (idx >= 0) {
116
+ entries[idx] = {
117
+ ...entries[idx],
118
+ pid,
119
+ ...(sessionFile !== undefined ? { sessionFile } : {}),
120
+ };
121
+ }
122
+ },
123
+ { branch, pid },
124
+ );
111
125
  }
112
126
 
113
127
  /**
114
128
  * 移除条目(cleanup/reaper 清理后调)。
115
129
  * branch 不存在则忽略(幂等)。
116
130
  */
117
- remove(branch: string): void {
118
- const entries = this.load();
119
- const filtered = entries.filter((e) => e.branch !== branch);
120
- if (filtered.length !== entries.length) {
121
- this.save(filtered);
131
+ async remove(branch: string): Promise<void> {
132
+ await this.mutate(
133
+ (entries) => {
134
+ const filtered = entries.filter((e) => e.branch !== branch);
135
+ if (filtered.length !== entries.length) {
136
+ entries.length = 0;
137
+ entries.push(...filtered);
138
+ }
139
+ },
140
+ { branch },
141
+ );
142
+ }
143
+
144
+ /**
145
+ * 锁内 RMW 统一入口:withLock(load → mutate → save)。
146
+ *
147
+ * 降级语义(对齐本类既有 best-effort 约定——注册表写失败不阻断 create/cleanup
148
+ * 主流程):锁获取失败(重试耗尽 ELOCKED 等)→ warn + 无锁执行同一段 RMW
149
+ * (= D5a 之前的 last-write-wins 行为,条目丢失由 reaper 对账兜底),
150
+ * 不抛错、永不 reject(调用方含 session-runner 的 fire-and-forget 回调)。
151
+ */
152
+ private async mutate(
153
+ mutate: (entries: WorktreeEntry[]) => void,
154
+ context?: { branch?: string; pid?: number },
155
+ ): Promise<void> {
156
+ const run = (): void => {
157
+ const entries = this.load();
158
+ mutate(entries);
159
+ this.save(entries, context);
160
+ };
161
+ try {
162
+ await withFileLock(this.filePath, () => {
163
+ run();
164
+ return Promise.resolve();
165
+ });
166
+ } catch (lockErr) {
167
+ // 锁不可用(ELOCKED 重试耗尽 / 锁目录损坏等):降级无锁 RMW。
168
+ // 竞争窗口内可能丢条目(旧缺陷形态),由 reaper 对账(scan 双向 diff)收敛。
169
+ logger.warn("[worktree] registry lock unavailable, degraded to lock-free RMW", {
170
+ ...(context ?? {}),
171
+ err: lockErr instanceof Error ? lockErr.message : String(lockErr),
172
+ });
173
+ try {
174
+ run();
175
+ } catch (err) {
176
+ bestEffort(err, "worktree registry degraded RMW");
177
+ }
122
178
  }
123
179
  }
124
180
 
@@ -144,12 +200,13 @@ export class WorktreeRegistry {
144
200
  * 原子写入全部条目。
145
201
  * best-effort:写入失败不阻断主流程(create/cleanup 的 git 操作已执行,
146
202
  * 注册表与 git 状态的短暂不一致靠下次 reaper 对账收敛)。
147
- * 写盘失败时 warn 日志(updatePid 路径带 branch/pid,补全失败可观测闭环)。
203
+ * 写盘失败时 warn 日志(带 branch/pid 上下文,补全失败可观测闭环)。
148
204
  */
149
- private save(entries: WorktreeEntry[], context?: { branch: string; pid: number }): void {
205
+ private save(entries: WorktreeEntry[], context?: { branch?: string; pid?: number }): void {
150
206
  try {
151
207
  fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
152
- const tmp = `${this.filePath}.tmp`;
208
+ // tmp 名带 pid + 随机段:锁降级(无锁并发 RMW)时多进程 tmp 互不覆盖
209
+ const tmp = `${this.filePath}.tmp_${process.pid}_${Math.random().toString(TMP_RANDOM_BASE).slice(TMP_RANDOM_SLICE_START, TMP_RANDOM_SLICE_END)}`;
153
210
  fs.writeFileSync(tmp, JSON.stringify({ entries }, null, JSON_INDENT), "utf-8");
154
211
  fs.renameSync(tmp, this.filePath);
155
212
  } catch (err) {