@zhushanwen/pi-subagent-workflow 7.4.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 (166) hide show
  1. package/README.md +15 -0
  2. package/package.json +7 -4
  3. package/scripts/rfl.mjs +308 -0
  4. package/skills/workflow-script-format/SKILL.md +1 -1
  5. package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
  6. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
  7. package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
  8. package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
  9. package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
  10. package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
  11. package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
  12. package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
  13. package/src/execution/__tests__/crash-recovery.test.ts +8 -2
  14. package/src/execution/__tests__/delivery-methods.test.ts +385 -0
  15. package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
  16. package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
  17. package/src/execution/__tests__/execute-nesting.test.ts +20 -72
  18. package/src/execution/__tests__/execution-record.test.ts +199 -0
  19. package/src/execution/__tests__/finalize-record.test.ts +197 -15
  20. package/src/execution/__tests__/format.test.ts +131 -7
  21. package/src/execution/__tests__/gc-timer.test.ts +184 -0
  22. package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
  23. package/src/execution/__tests__/helpers/spawn-mock.ts +37 -10
  24. package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
  25. package/src/execution/__tests__/index-session-start.test.ts +257 -5
  26. package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
  27. package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
  28. package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
  29. package/src/execution/__tests__/list-component.test.ts +59 -5
  30. package/src/execution/__tests__/list-fields.test.ts +109 -0
  31. package/src/execution/__tests__/model-resolver.test.ts +38 -1
  32. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
  33. package/src/execution/__tests__/nested-visibility.test.ts +325 -0
  34. package/src/execution/__tests__/notifier-flush.test.ts +209 -7
  35. package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
  36. package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
  37. package/src/execution/__tests__/record-store.test.ts +442 -54
  38. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
  39. package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
  40. package/src/execution/__tests__/resource-policy.test.ts +109 -0
  41. package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
  42. package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
  43. package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
  44. package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
  45. package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
  46. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
  47. package/src/execution/__tests__/session-pending.test.ts +61 -2
  48. package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
  49. package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
  50. package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
  51. package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
  52. package/src/execution/__tests__/spawn-args.test.ts +127 -49
  53. package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
  54. package/src/execution/__tests__/spawned-children.test.ts +92 -0
  55. package/src/execution/__tests__/status-refactor.test.ts +345 -0
  56. package/src/execution/__tests__/stdin-writer.test.ts +97 -0
  57. package/src/execution/__tests__/subagent-service-message-close.test.ts +629 -0
  58. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
  59. package/src/execution/__tests__/subagent-service.test.ts +49 -11
  60. package/src/execution/__tests__/timeout-integration.test.ts +27 -13
  61. package/src/execution/__tests__/tool-action.test.ts +12 -10
  62. package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
  63. package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
  64. package/src/execution/__tests__/worktree-manager.test.ts +300 -90
  65. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +17 -13
  66. package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
  67. package/src/execution/__tests__/worktree-registry.test.ts +72 -34
  68. package/src/execution/agent-result-mapper.ts +4 -1
  69. package/src/execution/argv-mirror.ts +21 -2
  70. package/src/execution/channel-registry-access.ts +3 -1
  71. package/src/execution/execution-record.ts +126 -9
  72. package/src/execution/finalize-record.ts +99 -13
  73. package/src/execution/idle-gc.ts +47 -0
  74. package/src/execution/lifecycle-manager.ts +491 -0
  75. package/src/execution/lifecycle-predicates.ts +65 -0
  76. package/src/execution/manifest-store.ts +61 -16
  77. package/src/execution/model-resolver.ts +26 -5
  78. package/src/execution/notifier.ts +69 -12
  79. package/src/execution/pi-invocation.ts +21 -1
  80. package/src/execution/record-entry.ts +118 -0
  81. package/src/execution/record-store.ts +844 -108
  82. package/src/execution/session-pending.ts +121 -49
  83. package/src/execution/session-reconstructor.ts +224 -7
  84. package/src/execution/session-runner.ts +713 -316
  85. package/src/execution/sessions-index.ts +304 -0
  86. package/src/execution/stdin-writer.ts +93 -7
  87. package/src/execution/stream-sink.ts +20 -3
  88. package/src/execution/subagent-service.ts +917 -138
  89. package/src/execution/temp-prompt.ts +8 -3
  90. package/src/execution/turn-limiter.ts +14 -0
  91. package/src/execution/types.ts +218 -19
  92. package/src/execution/worktree-manager.ts +449 -59
  93. package/src/execution/worktree-registry.ts +97 -29
  94. package/src/index.ts +318 -20
  95. package/src/injectors/subagent-list-injector.ts +26 -8
  96. package/src/injectors/workflow-list-injector.ts +25 -8
  97. package/src/interface/__tests__/subagent-tool-prompt.test.ts +17 -4
  98. package/src/interface/__tests__/tool-render.test.ts +10 -8
  99. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +103 -26
  100. package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +179 -0
  101. package/src/interface/bg-notify-render.ts +32 -8
  102. package/src/interface/command-actions.ts +26 -7
  103. package/src/interface/commands.ts +21 -22
  104. package/src/interface/format.ts +53 -20
  105. package/src/interface/gui-mappers.ts +6 -8
  106. package/src/interface/helpers.ts +170 -10
  107. package/src/interface/list-component.ts +53 -14
  108. package/src/interface/subagent-actions.ts +235 -17
  109. package/src/interface/subagent-tool.ts +81 -16
  110. package/src/interface/subagents.ts +2 -1
  111. package/src/interface/tool-render.ts +21 -27
  112. package/src/interface/tool-workflow-script.ts +29 -33
  113. package/src/interface/tool-workflow.ts +67 -100
  114. package/src/interface/views/WorkflowsView.ts +89 -32
  115. package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
  116. package/src/interface/views/detail-content.ts +1 -1
  117. package/src/interface/views/format.ts +3 -3
  118. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +333 -0
  119. package/src/orchestration/__tests__/args-validator.test.ts +1 -1
  120. package/src/orchestration/__tests__/config-loader.test.ts +38 -0
  121. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
  122. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
  123. package/src/orchestration/__tests__/execute-agent-call.test.ts +144 -1
  124. package/src/orchestration/__tests__/jsonl-run-store-loadall-sources.test.ts +171 -0
  125. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +820 -19
  126. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
  127. package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
  128. package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
  129. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
  130. package/src/orchestration/__tests__/skill-discovery.test.ts +130 -61
  131. package/src/orchestration/__tests__/test-mocks.ts +197 -0
  132. package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
  133. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +110 -0
  134. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
  135. package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
  136. package/src/orchestration/__tests__/workflows-e2e.test.ts +38 -40
  137. package/src/orchestration/agent-opts-resolver.ts +4 -1
  138. package/src/orchestration/args-validator.ts +2 -2
  139. package/src/orchestration/config-loader.ts +30 -1
  140. package/src/orchestration/error-recovery.ts +137 -30
  141. package/src/orchestration/execute-agent-call.ts +44 -10
  142. package/src/orchestration/jsonl-run-store.ts +397 -71
  143. package/src/orchestration/launcher.ts +7 -1
  144. package/src/orchestration/lifecycle.ts +145 -133
  145. package/src/orchestration/models/__tests__/trace.test.ts +408 -0
  146. package/src/orchestration/models/budget.ts +1 -1
  147. package/src/orchestration/models/run-runtime.ts +15 -17
  148. package/src/orchestration/models/run-spec.ts +2 -2
  149. package/src/orchestration/models/run-state.ts +3 -3
  150. package/src/orchestration/models/trace.ts +95 -15
  151. package/src/orchestration/models/types.ts +8 -9
  152. package/src/orchestration/models/workflow-run.ts +50 -71
  153. package/src/orchestration/models/workflow-script.ts +32 -1
  154. package/src/orchestration/skill-discovery.ts +30 -0
  155. package/src/orchestration/worker-handle.ts +1 -1
  156. package/src/orchestration/worker-host.ts +1 -1
  157. package/src/orchestration/worker-script-builder.ts +38 -11
  158. package/src/shared/__tests__/agent-ref.test.ts +34 -0
  159. package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
  160. package/src/shared/__tests__/resource-discovery.test.ts +79 -0
  161. package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
  162. package/src/shared/agent-ref.ts +22 -1
  163. package/src/shared/resource-discovery.ts +162 -59
  164. package/src/shared/schema-jsonify.ts +56 -0
  165. package/workflows/review-fix-loop-utils.cjs +542 -32
  166. package/workflows/review-fix-loop.js +462 -109
@@ -3,7 +3,9 @@
3
3
  // git worktree 生命周期管理:创建、清理、patch 回传、孤儿 reaper。
4
4
  //
5
5
  // 设计约束:
6
- // - gitRun 是唯一 git 命令出口,统一超时/错误包装
6
+ // - gitRunAsync 是唯一 git 命令出口,统一超时/错误包装(旧同步 gitRun 已在 phase 2 删除)
7
+ // - gitRunAsync 输出保真(不 trim):diff stdout 直接落盘为 patch,裁掉尾换行会让
8
+ // `git apply` 报 corrupt patch;需要干净文本的消费点(baseCommit/status 拼接)自行 trim
7
9
  // - recordId 白名单 `^[\w-]+$` 防止路径注入
8
10
  // - clean tree 前置校验防止创建脏 worktree
9
11
  // - checkout 放 os.tmpdir()(脱离 .git/),兼容普通 repo 与 bare+worktree 结构
@@ -14,8 +16,13 @@
14
16
  // [全局注册表重构] scan 不再依赖当前 cwd 是否 git repo,改为遍历
15
17
  // WorktreeRegistry(<agentDir>/subagents/worktrees.json)。判据从终态 marker
16
18
  // 状态机降为 pid 死活一条——进程崩溃无人写终态时也能正确回收。
19
+ //
20
+ // [D5b 对账] scan 末尾追加双向 diff(reconcileWithPhysical):物理面(tmpdir
21
+ // checkout 目录 + git branch --list)与注册表互相对账——注册表条目丢失(锁前
22
+ // last-write-wins 遗留 / 锁降级窗口)或物理资源被外部清掉时收敛,注册表注释
23
+ // 声称的「tmpdir + 分支对账兜底」由此成为代码。
17
24
 
18
- import { execFileSync } from "node:child_process";
25
+ import { execFile } from "node:child_process";
19
26
  import * as fs from "node:fs";
20
27
  import * as os from "node:os";
21
28
  import * as path from "node:path";
@@ -25,7 +32,7 @@ import type { PatchResult,WorktreeHandle } from "./types.ts";
25
32
  import { DirtyWorktreeError } from "./types.ts";
26
33
  import { bestEffort } from "./best-effort.ts";
27
34
  import { getLogger } from "@zhushanwen/pi-extension-logger";
28
- import { isProcessAlive } from "./alive-store.ts";
35
+ import { isProcessAlive, readAliveMarker } from "./alive-store.ts";
29
36
  import { SPAWN_GRACE_MS,type WorktreeEntry,WorktreeRegistry } from "./worktree-registry.ts";
30
37
 
31
38
  const logger = getLogger("subagents");
@@ -36,11 +43,98 @@ const SAFE_ID_RE = /^[\w-]+$/;
36
43
  // 默认 git 命令超时(ms)
37
44
  const GIT_TIMEOUT_MS = 30_000;
38
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
+
70
+ /**
71
+ * gitRunAsync 的包装错误:message 格式与旧同步 gitRun 逐字一致(下游
72
+ * DirtyWorktreeError 判定与测试 toThrow 匹配零改动);exitCode/stderr/timedOut
73
+ * 为新增诊断属性(探针 P-errshape 实测 Node 24:execFile 的 err.stderr 为
74
+ * undefined——stderr 在 callback 第三参;退出码在 err.code(数字))。
75
+ */
76
+ export class GitRunError extends Error {
77
+ readonly exitCode?: number;
78
+ readonly stderr?: string;
79
+ readonly timedOut?: boolean;
80
+
81
+ constructor(
82
+ message: string,
83
+ props: { exitCode?: number; stderr?: string; timedOut?: boolean },
84
+ ) {
85
+ super(message);
86
+ this.name = "GitRunError";
87
+ this.exitCode = props.exitCode;
88
+ this.stderr = props.stderr;
89
+ this.timedOut = props.timedOut;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * 写类 git 命令判定(per-repo mutex 串行对象)。读类(status/rev-parse/diff/branch --list)
95
+ * 无副作用不加锁——并发读 git 自身安全,P-lock 实测冲突仅是写窗口假设。
96
+ */
97
+ function isWriteCommand(args: string[]): boolean {
98
+ if (args[0] === "worktree") return args[1] === "add" || args[1] === "remove" || args[1] === "prune";
99
+ if (args[0] === "branch") return args[1] === "-D";
100
+ return args[0] === "add";
101
+ }
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
+
39
125
  export class WorktreeManager {
40
126
  // 全局注册表:跨 repo 记录所有活 worktree,reaper 遍历此表判孤儿。
41
127
  private readonly registry: WorktreeRegistry;
128
+ // agentDir(<agentDir>/subagents/<enc>/sessions 下扫 .alive 活信号,D5b 对账用)
129
+ private readonly agentDir: string;
130
+ // per-repo 写命令串行队列:value = 队尾(已吞 rejection 的)Promise。
131
+ // 入队形态 prev.catch(()=>{}).then(run)——后继只关心「自己已排队」,
132
+ // 不继承前驱错误(否则 1 个 worktree add 失败会传染同 repo 后续全部写命令,
133
+ // 替代旧同步版单线程天然全局串行的「各命令独立失败」语义)。
134
+ private readonly writeQueues = new Map<string, Promise<void>>();
42
135
 
43
136
  constructor(agentDir: string) {
137
+ this.agentDir = agentDir;
44
138
  this.registry = new WorktreeRegistry(agentDir);
45
139
  }
46
140
 
@@ -51,16 +145,33 @@ export class WorktreeManager {
51
145
  * @param recordId 执行记录 ID(必须匹配 `^[\w-]+$`)
52
146
  * @returns 冻结的 WorktreeHandle
53
147
  */
54
- create(mainCwd: string, recordId: string): WorktreeHandle {
148
+ async create(mainCwd: string, recordId: string): Promise<WorktreeHandle> {
55
149
  if (!SAFE_ID_RE.test(recordId)) {
56
150
  throw new DirtyWorktreeError(
57
151
  `recordId contains unsafe characters: "${recordId}" (must match ^[\\w-]+$)`,
58
152
  );
59
153
  }
60
154
 
61
- this.assertCleanTree(mainCwd);
155
+ // 脏树校验与 base commit 并行(读类无锁可并发)。allSettled 而非 all:
156
+ // status 先 reject 时 all 会短路,rev-parse 的后续 rejection 无人处理 →
157
+ // unhandledRejection。判定顺序固定:status 错误 → 脏树 → rev-parse 错误
158
+ // (脏树语义不变:仍先于 worktree add,rev-parse 结果在脏树时被丢弃)。
159
+ const [statusR, revR] = await Promise.allSettled([
160
+ this.gitRunAsync(["status", "--porcelain"], { cwd: mainCwd }),
161
+ this.gitRunAsync(["rev-parse", "HEAD"], { cwd: mainCwd }),
162
+ ]);
163
+ if (statusR.status === "rejected") throw statusR.reason;
164
+ // 消费点自行 trim:gitRunAsync 返回原始 stdout(保真),status 输出以 \n 结尾
165
+ const statusText = statusR.value.trim();
166
+ if (statusText.length > 0) {
167
+ throw new DirtyWorktreeError(
168
+ `Working tree is dirty in ${mainCwd}:\n${statusText}`,
169
+ );
170
+ }
171
+ if (revR.status === "rejected") throw revR.reason;
172
+ // 消费点自行 trim:rev-parse 输出 "hash\n",不 trim 会把换行带进后续 git args
173
+ const baseCommit = revR.value.trim();
62
174
 
63
- const baseCommit = this.gitRun(["rev-parse", "HEAD"], { cwd: mainCwd });
64
175
  const branch = `pi-sub-${recordId}`;
65
176
  // checkout 放 tmpdir,脱离 .git/ 目录结构。
66
177
  // 这样 git 自行把元数据注册到 <commonDir>/worktrees/<branch>/,
@@ -79,13 +190,13 @@ export class WorktreeManager {
79
190
  }
80
191
  }
81
192
 
82
- this.gitRun(["worktree", "add", "-b", branch, worktreePath, "HEAD"], {
193
+ await this.gitRunAsync(["worktree", "add", "-b", branch, worktreePath, "HEAD"], {
83
194
  cwd: mainCwd,
84
195
  });
85
196
 
86
- // 注册到全局表(pid=0 占位)。runSpawn 在 spawn() 返回后同步补 pid。
197
+ // 注册到全局表(pid=0 占位)。runSpawn 在 spawn() 返回后异步补 pid。
87
198
  // 放在 worktree add 成功后、symlink 前——确保只有真正创建了 worktree 才登记。
88
- this.registry.add({
199
+ await this.registry.add({
89
200
  repo: mainCwd,
90
201
  branch,
91
202
  checkout: worktreePath,
@@ -112,27 +223,32 @@ export class WorktreeManager {
112
223
  } catch (err) {
113
224
  // 回滚已创建的 worktree+分支+注册表条目,best-effort 吞清理异常(原始 err 仍外抛)
114
225
  try {
115
- this.gitRun(["worktree", "remove", "--force", worktreePath], { cwd: mainCwd });
226
+ await this.gitRunAsync(["worktree", "remove", "--force", worktreePath], { cwd: mainCwd });
116
227
  } catch (cleanErr) {
117
228
  bestEffort(cleanErr, "worktree remove (create rollback MF#3)");
118
229
  }
119
230
  try {
120
- this.gitRun(["branch", "-D", branch], { cwd: mainCwd });
231
+ await this.gitRunAsync(["branch", "-D", branch], { cwd: mainCwd });
121
232
  } catch (cleanErr) {
122
233
  bestEffort(cleanErr, "branch delete (create rollback MF#3)");
123
234
  }
124
- this.registry.remove(branch);
235
+ await this.registry.remove(branch);
125
236
  throw err;
126
237
  }
127
238
  }
128
239
 
129
240
  /**
130
- * 注册子进程 pid(runSpawn spawn() 返回后同步调)。
241
+ * 注册子进程 pid(runSpawn spawn() 返回后调)。
131
242
  * create 时 pid 未知写 0 占位,子进程 spawn 返回后(child.pid 同步可得)由此补全。
132
243
  * reaper 据 pid 死活判孤儿,pid=0 条目用 SPAWN_GRACE 宽限。
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 回调上下文)。
133
249
  */
134
- registerPid(branch: string, pid: number): void {
135
- this.registry.updatePid(branch, pid);
250
+ async registerPid(branch: string, pid: number, sessionFile?: string): Promise<void> {
251
+ await this.registry.updatePid(branch, pid, sessionFile);
136
252
  }
137
253
 
138
254
  /**
@@ -142,9 +258,9 @@ export class WorktreeManager {
142
258
  *
143
259
  * @param handle 要清理的 worktree handle(含 mainCwd,不靠路径反推)
144
260
  */
145
- cleanup(handle: WorktreeHandle): void {
261
+ async cleanup(handle: WorktreeHandle): Promise<void> {
146
262
  try {
147
- this.gitRun(["worktree", "remove", "--force", handle.path], {
263
+ await this.gitRunAsync(["worktree", "remove", "--force", handle.path], {
148
264
  cwd: handle.mainCwd,
149
265
  });
150
266
  } catch (err) {
@@ -152,14 +268,14 @@ export class WorktreeManager {
152
268
  }
153
269
 
154
270
  try {
155
- this.gitRun(["branch", "-D", handle.branch], {
271
+ await this.gitRunAsync(["branch", "-D", handle.branch], {
156
272
  cwd: handle.mainCwd,
157
273
  });
158
274
  } catch (err) {
159
275
  bestEffort(err, "branch delete (cleanup)");
160
276
  }
161
277
 
162
- this.registry.remove(handle.branch);
278
+ await this.registry.remove(handle.branch);
163
279
  }
164
280
 
165
281
  /**
@@ -176,15 +292,15 @@ export class WorktreeManager {
176
292
  * written=true 仅当 diff 非空且写盘成功;空 diff 或写失败均 written=false,
177
293
  * 调用方据此回填 record.patchFile,避免悬空路径(`git apply` 不存在的文件)。
178
294
  */
179
- collectPatch(handle: WorktreeHandle, patchFile: string): PatchResult {
295
+ async collectPatch(handle: WorktreeHandle, patchFile: string): Promise<PatchResult> {
180
296
  // git add -A:暂存全部改动(含未跟踪新文件),使后续 --cached diff 能捕获新建文件
181
297
  try {
182
- this.gitRun(["add", "-A"], { cwd: handle.path });
298
+ await this.gitRunAsync(["add", "-A"], { cwd: handle.path });
183
299
  } catch (err) {
184
300
  // add 失败不致命:继续尝试 diff,最差得到部分 diff(仅已跟踪文件的改动)
185
301
  bestEffort(err, "git add -A (collectPatch)");
186
302
  }
187
- const diff = this.gitRun(
303
+ const diff = await this.gitRunAsync(
188
304
  ["diff", "--cached", handle.baseCommit],
189
305
  { cwd: handle.path },
190
306
  );
@@ -203,30 +319,286 @@ export class WorktreeManager {
203
319
  }
204
320
 
205
321
  /**
206
- * 扫描并清理 pi-sub-* 孤儿 worktree
322
+ * 扫描并清理 pi-sub-* 孤儿 worktree + 物理面对账(D5b)。
207
323
  *
208
- * 遍历全局注册表(<agentDir>/subagents/worktrees.json),按 pid 死活判孤儿。
209
- * 不依赖当前 cwd 是否 git repo——注册表里记了 repo 路径,直接 git -C <repo> 跨 repo 清理。
324
+ * 阶段一(既有):遍历全局注册表(<agentDir>/subagents/worktrees.json),
325
+ * pid 死活判孤儿。不依赖当前 cwd 是否 git repo——注册表里记了 repo 路径,
326
+ * 直接 git -C <repo> 跨 repo 清理。
210
327
  *
211
328
  * 判据(唯一不删条件 = 进程还活着):
212
329
  * pid > 0 且 isProcessAlive(pid) → 跳过(活进程,绝不删)
213
330
  * pid > 0 且进程已死 → 孤儿(正常退出未 cleanup / 崩溃残留)
214
331
  * pid == 0 且超 SPAWN_GRACE_MS → 孤儿(create 后崩溃,pid 永未补全)
215
332
  * pid == 0 且未超宽限 → 跳过(可能正在 spawn)
333
+ *
334
+ * 阶段二(D5b):物理面(tmpdir checkout + 分支)与注册表双向 diff 收敛——
335
+ * 兑现 worktree-registry.ts 头注释声称的「tmpdir + 分支对账兜底」。全流程
336
+ * 幂等、失败仅日志(对账失败不阻断 session_start)。
216
337
  */
217
- scan(): void {
338
+ async scan(): Promise<void> {
218
339
  const entries = this.registry.load();
219
340
  const now = Date.now();
220
341
 
342
+ // 逐孤儿串行 await(保持 for 循环串行语义,防止一次 reaper 打出 N 个并发 git)
221
343
  for (const entry of entries) {
222
344
  if (!this.isOrphan(entry, now)) {
223
345
  continue;
224
346
  }
225
- this.cleanupOrphan(entry);
347
+ await this.cleanupOrphan(entry);
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)");
226
596
  }
227
597
  }
228
598
 
229
- /** pid 死活判孤儿。pid=0 走 SPAWN_GRACE 宽限。 */
599
+ /**
600
+ * 判孤儿:pid 死活为主判据。pid=0 走 SPAWN_GRACE 宽限(create→spawn 窗口)。
601
+ */
230
602
  private isOrphan(entry: WorktreeEntry, now: number): boolean {
231
603
  if (entry.pid === 0) {
232
604
  // create→spawn 窗口:超过宽限期仍未补 pid = create 后崩溃
@@ -246,18 +618,18 @@ export class WorktreeManager {
246
618
  }
247
619
 
248
620
  /** 清理单个孤儿条目:worktree remove + branch -D + 注册表移除,三步各自 best-effort。 */
249
- private cleanupOrphan(entry: WorktreeEntry): void {
621
+ private async cleanupOrphan(entry: WorktreeEntry): Promise<void> {
250
622
  try {
251
- this.gitRun(["worktree", "remove", "--force", entry.checkout], { cwd: entry.repo });
623
+ await this.gitRunAsync(["worktree", "remove", "--force", entry.checkout], { cwd: entry.repo });
252
624
  } catch (err) {
253
625
  bestEffort(err, "worktree remove (orphan reaper)");
254
626
  }
255
627
  try {
256
- this.gitRun(["branch", "-D", entry.branch], { cwd: entry.repo });
628
+ await this.gitRunAsync(["branch", "-D", entry.branch], { cwd: entry.repo });
257
629
  } catch (err) {
258
630
  bestEffort(err, "branch delete (orphan reaper)");
259
631
  }
260
- this.registry.remove(entry.branch);
632
+ await this.registry.remove(entry.branch);
261
633
  }
262
634
 
263
635
  // ============================================================
@@ -265,34 +637,52 @@ export class WorktreeManager {
265
637
  // ============================================================
266
638
 
267
639
  /**
268
- * git 命令执行器。统一超时 + 错误包装。
640
+ * git 命令异步执行器。与 gitRun 同一超时/错误包装约定(message 格式逐字一致),
641
+ * 差异仅在错误属性形态(GitRunError 挂 exitCode/stderr/timedOut)。
642
+ * 写类命令经 per-repo mutex 串行(不依赖 git 锁实现细节 + 并发限流 + 行为确定性)。
643
+ *
644
+ * stdout 保真返回(不 trim):collectPatch 把 diff 输出原样落盘为 patch 文件,
645
+ * 裁掉尾换行会产出 `git apply` 拒绝的 corrupt patch(2026-08-16 门 4 实测)。
646
+ * 需要干净文本的消费点(baseCommit / 脏树 status 拼接)自行 trim。
269
647
  */
270
- private gitRun(args: string[], opts: { cwd: string; timeout?: number }): string {
271
- try {
272
- return execFileSync("git", args, {
273
- cwd: opts.cwd,
274
- timeout: opts.timeout ?? GIT_TIMEOUT_MS,
275
- encoding: "utf-8",
276
- stdio: ["pipe", "pipe", "pipe"],
277
- }).trim();
278
- } catch (err: unknown) {
279
- if (err instanceof Error) {
280
- throw new Error(`git ${args[0]} failed: ${err.message}`);
281
- }
282
- throw new Error(`git ${args[0]} failed: unknown error`);
283
- }
284
- }
648
+ private async gitRunAsync(args: string[], opts: { cwd: string; timeout?: number }): Promise<string> {
649
+ const run = (): Promise<string> =>
650
+ new Promise((resolve, reject) => {
651
+ execFile(
652
+ "git",
653
+ args,
654
+ { cwd: opts.cwd, timeout: opts.timeout ?? GIT_TIMEOUT_MS, encoding: "utf-8" },
655
+ (err, stdout, stderr) => {
656
+ if (err) {
657
+ const execErr = err as Error & { code?: unknown; killed?: boolean; signal?: string };
658
+ reject(
659
+ new GitRunError(`git ${args[0]} failed: ${execErr.message}`, {
660
+ // P-errshape 实测:execFile 退出码在 err.code(数字时);超时 killed+SIGTERM
661
+ exitCode: typeof execErr.code === "number" ? execErr.code : undefined,
662
+ stderr: typeof stderr === "string" ? stderr : undefined,
663
+ timedOut: execErr.killed === true && execErr.signal === "SIGTERM",
664
+ }),
665
+ );
666
+ return;
667
+ }
668
+ resolve(stdout);
669
+ },
670
+ );
671
+ });
285
672
 
286
- /**
287
- * 校验工作目录是 clean tree。
288
- */
289
- private assertCleanTree(cwd: string): void {
290
- const status = this.gitRun(["status", "--porcelain"], { cwd });
291
- if (status.length > 0) {
292
- throw new DirtyWorktreeError(
293
- `Working tree is dirty in ${cwd}:\n${status}`,
294
- );
295
- }
673
+ if (!isWriteCommand(args)) return run();
674
+ // per-repo 队列:吞前驱 rejection 后接续本命令;队尾比对自清理防 Map 泄漏
675
+ const repo = opts.cwd;
676
+ const prev = this.writeQueues.get(repo) ?? Promise.resolve();
677
+ const next = prev.catch(() => {}).then(run);
678
+ const tail: Promise<void> = next.then(
679
+ () => undefined,
680
+ () => undefined,
681
+ );
682
+ this.writeQueues.set(repo, tail);
683
+ void tail.finally(() => {
684
+ if (this.writeQueues.get(repo) === tail) this.writeQueues.delete(repo);
685
+ });
686
+ return next;
296
687
  }
297
-
298
688
  }