@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
@@ -15,13 +15,13 @@
15
15
  // 真实注册表文件,仅 mock ./pi-invocation.ts(把 pi 二进制替换为 node -e 脚本)。
16
16
  //
17
17
  // mock 最小化原则:
18
- // - node:child_process 不 mock(真实 spawn / execFileSync git)
18
+ // - node:child_process 不 mock(真实 spawn / spawnSync git)
19
19
  // - node:fs 不 mock(真实目录/文件:worktree checkout、注册表 JSON)
20
20
  // - alive-store 不 mock(真实 process.kill(pid, 0) 探活)
21
21
  // - 仅 vi.mock("./pi-invocation.ts"):getPiInvocation 返回 node -e 脚本
22
22
  // - fake timers 仅 toFake: ["Date"]:推进注册表宽限判定用,不干扰真实 I/O 事件
23
23
 
24
- import { execFileSync } from "node:child_process";
24
+ import { spawnSync } from "node:child_process";
25
25
  import * as fs from "node:fs";
26
26
  import * as os from "node:os";
27
27
  import * as path from "node:path";
@@ -67,7 +67,8 @@ let handle: WorktreeHandle | undefined;
67
67
  let spawnedPid: number | undefined;
68
68
 
69
69
  function git(args: string[], cwd: string): string {
70
- return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
70
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
71
+ return (r.stdout ?? "").trim();
71
72
  }
72
73
 
73
74
  /** 初始化临时 git repo(至少一个 commit,worktreeManager.create 需要 clean tree + HEAD)。 */
@@ -97,7 +98,7 @@ async function waitForPid(branch: string, timeoutMs = 5000): Promise<number> {
97
98
  }
98
99
 
99
100
  /** 清理:kill 子进程 + worktree cleanup + 删除临时目录。 */
100
- function cleanup(): void {
101
+ async function cleanup(): Promise<void> {
101
102
  if (spawnedPid) {
102
103
  try {
103
104
  process.kill(spawnedPid, "SIGKILL");
@@ -108,7 +109,7 @@ function cleanup(): void {
108
109
  }
109
110
  if (handle) {
110
111
  try {
111
- wtm.cleanup(handle);
112
+ await wtm.cleanup(handle);
112
113
  } catch (err) {
113
114
  // best-effort:git worktree remove 失败不阻断测试清理
114
115
  // eslint-disable-next-line no-console
@@ -131,18 +132,21 @@ beforeEach(() => {
131
132
  registry = new WorktreeRegistry(agentDir);
132
133
  });
133
134
 
134
- afterEach(() => {
135
+ afterEach(async () => {
135
136
  vi.useRealTimers();
136
- cleanup();
137
+ await cleanup();
137
138
  });
138
139
 
139
140
  // ── 用例 ──
140
141
 
141
- describe("worktree pid 注册链路(真实 spawn 集成)", () => {
142
+ // [HISTORICAL] 2026-08-20 PR #185:真实 spawn node 子进程 + 真实 git worktree + 注册表
143
+ // 文件锁集成用例显式超时——整包满并行 + 系统余载下真实子进程链路超 vitest 默认 5s
144
+ // testTimeout(对齐本包 worktree-registry D5a / process-shutdown-hook 口径)。
145
+ describe("worktree pid 注册链路(真实 spawn 集成)", { timeout: 30_000 }, () => {
142
146
  it("正向:spawn 返回后注册表 pid 同步补全,活 worktree 超宽限不被 scan 误清", async () => {
143
147
  // 0. 长驻脚本(子进程 90s 内不退出,模拟长跑子 agent)
144
148
  scriptHolder.script = LONG_RUNNING_SCRIPT; // 1. 真实创建 worktree(pid=0 占位)
145
- handle = wtm.create(repoDir, "rec-1");
149
+ handle = await wtm.create(repoDir, "rec-1");
146
150
  expect(readEntry(handle.branch)).toMatchObject({ pid: 0 });
147
151
 
148
152
  // 2. runSpawn 挂后台(不 await——长驻子进程 close 不触发,await 会挂死),
@@ -170,7 +174,7 @@ describe("worktree pid 注册链路(真实 spawn 集成)", () => {
170
174
  vi.setSystemTime(Date.now() + SPAWN_GRACE_MS + 1000);
171
175
 
172
176
  // 5. scan():活 worktree 必须不被清(修复前:pid=0 超宽限 → 误删 → 红)
173
- wtm.scan();
177
+ await wtm.scan();
174
178
  expect(fs.existsSync(handle.path)).toBe(true);
175
179
 
176
180
  // 6. 收尾:真实时钟恢复 + kill 子进程让 runPromise settle
@@ -183,7 +187,7 @@ describe("worktree pid 注册链路(真实 spawn 集成)", () => {
183
187
  await runPromise;
184
188
 
185
189
  // 7. 反向:进程死后 scan 回收真孤儿
186
- wtm.scan();
190
+ await wtm.scan();
187
191
  expect(fs.existsSync(handle.path)).toBe(false);
188
192
  const entryAfter = readEntry(handle.branch);
189
193
  expect(entryAfter).toBeUndefined();
@@ -193,7 +197,7 @@ describe("worktree pid 注册链路(真实 spawn 集成)", () => {
193
197
  // 0. 短命脚本(子进程立即退出,模拟快速完成的子 agent)
194
198
  scriptHolder.script = SHORT_LIVED_SCRIPT;
195
199
  // 1. 真实创建 worktree
196
- handle = wtm.create(repoDir, "rec-2");
200
+ handle = await wtm.create(repoDir, "rec-2");
197
201
 
198
202
  // 2. 短命脚本子进程:spawn 后同步补 pid(修复前 pid=0,且未超宽限 → 不回收 → 红)
199
203
  const ctx = makeCtx({
@@ -218,7 +222,7 @@ describe("worktree pid 注册链路(真实 spawn 集成)", () => {
218
222
  spawnedPid = entry!.pid;
219
223
 
220
224
  // 4. scan:pid>0 且进程死 → 立即判孤儿回收(无需等宽限)
221
- wtm.scan();
225
+ await wtm.scan();
222
226
  expect(fs.existsSync(handle.path)).toBe(false);
223
227
  expect(readEntry(handle.branch)).toBeUndefined();
224
228
  }, 15000);
@@ -0,0 +1,181 @@
1
+ // src/__tests__/worktree-reconcile.integration.test.ts
2
+ //
3
+ // D5b 对账双向 diff 集成测试:真实 git repo + 真实 tmpdir(不 mock fs/execFile)。
4
+ //
5
+ // 隔离手段:TMPDIR env 重定向到测试私有目录——WorktreeManager 的物理面扫描
6
+ // 根 = os.tmpdir()/pi-subagents(跟随 TMPDIR),避免扫到本机其他进程的真实残留。
7
+ //
8
+ // 覆盖四个收敛方向(判据见 worktree-manager.ts reconcileWithPhysical):
9
+ // 1. 物理有 + 注册无 + 无活 pid + 超 grace → 清分支与目录
10
+ // 2. 物理有 + 注册无 + 恰好 1 活 pid + 1 残留 → 补写回注册表(自愈)
11
+ // 3. 注册有(pid 活)+ 物理无 → 移除幻影条目
12
+ // 4. 多活 pid / 多残留(对应歧义)→ 跳过不动(宁延迟勿误删)
13
+
14
+ import { execFileSync, spawn } from "node:child_process";
15
+ import * as fs from "node:fs";
16
+ import * as os from "node:os";
17
+ import * as path from "node:path";
18
+
19
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
20
+
21
+ import { encodeCwd } from "../path-encoding.ts";
22
+ import { WorktreeRegistry } from "../worktree-registry.ts";
23
+ import { WorktreeManager } from "../worktree-manager.ts";
24
+
25
+ /** 原始 TMPDIR(beforeEach 重定向、afterEach 还原)。 */
26
+ const ORIG_TMPDIR = os.tmpdir();
27
+
28
+ /** git 辅助:repo 内执行(输出 trim)。 */
29
+ function git(repo: string, ...args: string[]): string {
30
+ return execFileSync("git", ["-C", repo, ...args], { encoding: "utf-8" }).trim();
31
+ }
32
+
33
+ // [HISTORICAL] 2026-08-20 PR #185:真实 git 子进程 + 注册表文件锁集成用例显式超时——
34
+ // 每用例多次 git init/worktree add/branch -D 真实子进程调用,整包满并行 + 系统余载下
35
+ // 超 vitest 默认 5s testTimeout(对齐本包 worktree-registry D5a / process-shutdown-hook 口径)。
36
+ describe("WorktreeManager.scan 物理面对账(D5b)", { timeout: 30_000 }, () => {
37
+ let baseDir: string;
38
+ let agentDir: string;
39
+ let repo: string;
40
+ let enc: string;
41
+ let registry: WorktreeRegistry;
42
+ let mgr: WorktreeManager;
43
+ /** 真实系统 tmp 下的测试根(TMPDIR 重定向目标)。 */
44
+ let outerDir: string;
45
+
46
+ beforeEach(() => {
47
+ outerDir = fs.mkdtempSync(path.join(ORIG_TMPDIR, "wt-reconcile-it-"));
48
+ // 物理面扫描根重定向:os.tmpdir() 跟随 TMPDIR env(darwin/linux 均如此)
49
+ process.env.TMPDIR = outerDir;
50
+ baseDir = outerDir;
51
+
52
+ agentDir = path.join(baseDir, "agent");
53
+ repo = path.join(baseDir, "repo");
54
+ fs.mkdirSync(repo, { recursive: true });
55
+ git(repo, "init", "-q");
56
+ git(repo, "config", "user.email", "test@test.local");
57
+ git(repo, "config", "user.name", "test");
58
+ fs.writeFileSync(path.join(repo, "a.txt"), "init\n", "utf-8");
59
+ git(repo, "add", "-A");
60
+ git(repo, "commit", "-q", "-m", "init");
61
+
62
+ enc = encodeCwd(repo);
63
+ registry = new WorktreeRegistry(agentDir);
64
+ mgr = new WorktreeManager(agentDir);
65
+ });
66
+
67
+ afterEach(() => {
68
+ process.env.TMPDIR = ORIG_TMPDIR;
69
+ fs.rmSync(outerDir, { recursive: true, force: true });
70
+ });
71
+
72
+ /** 建一个物理 worktree(按 create() 的真实路径布局,绕过 WorktreeManager.create 的脏树校验)。 */
73
+ function makePhysicalWorktree(branch: string): string {
74
+ const checkout = path.join(baseDir, "pi-subagents", enc, branch);
75
+ git(repo, "worktree", "add", "-q", "-b", branch, checkout, "HEAD");
76
+ return checkout;
77
+ }
78
+
79
+ /** 在 <agentDir>/subagents/<enc>/sessions/ 下写一个 .alive marker。 */
80
+ function writeAliveMarker(sessionId: string, pid: number): void {
81
+ const sessions = path.join(agentDir, "subagents", enc, "sessions");
82
+ fs.mkdirSync(sessions, { recursive: true });
83
+ fs.writeFileSync(
84
+ path.join(sessions, `${sessionId}.jsonl.alive`),
85
+ JSON.stringify({ pid, id: sessionId, startedAt: Date.now() }),
86
+ "utf-8",
87
+ );
88
+ }
89
+
90
+ /** 把目录 mtime 设到 grace 之前(对账判据的 createdAt 近似)。 */
91
+ function ageBeyondGrace(dir: string): void {
92
+ const past = new Date(Date.now() - 120_000);
93
+ fs.utimesSync(dir, past, past);
94
+ }
95
+
96
+ it("物理有 + 注册无 + 无活 pid + 超 grace → 清分支与目录", async () => {
97
+ const checkout = makePhysicalWorktree("pi-sub-t1");
98
+ ageBeyondGrace(checkout);
99
+
100
+ await mgr.scan();
101
+
102
+ expect(fs.existsSync(checkout)).toBe(false);
103
+ expect(git(repo, "branch", "--list", "pi-sub-*")).toBe("");
104
+ expect(registry.load()).toEqual([]);
105
+ });
106
+
107
+ it("物理有 + 注册无 + 未超 grace(create 窗口)→ 跳过不删", async () => {
108
+ const checkout = makePhysicalWorktree("pi-sub-t2");
109
+ // mtime 保持「现在」——另一进程 worktree add 完成到 registry.add 落盘之间的窗口
110
+
111
+ await mgr.scan();
112
+
113
+ expect(fs.existsSync(checkout)).toBe(true);
114
+ expect(git(repo, "branch", "--list", "pi-sub-t2")).not.toBe("");
115
+ });
116
+
117
+ it("物理有 + 注册无 + 恰好 1 活 pid + 1 残留 → 补写回注册表(自愈)", async () => {
118
+ const checkout = makePhysicalWorktree("pi-sub-t3");
119
+ // 活 pid 用本测试进程(isProcessAlive 真实调用必活)
120
+ writeAliveMarker("s-t3", process.pid);
121
+
122
+ await mgr.scan();
123
+
124
+ const entries = registry.load();
125
+ expect(entries).toHaveLength(1);
126
+ expect(entries[0].branch).toBe("pi-sub-t3");
127
+ expect(entries[0].pid).toBe(process.pid);
128
+ expect(entries[0].checkout).toBe(checkout);
129
+ // 物理资源未被删(自愈而非只删)
130
+ expect(fs.existsSync(checkout)).toBe(true);
131
+ });
132
+
133
+ it("注册有(pid 活)+ 物理无 → 移除幻影条目", async () => {
134
+ // pid 活(本进程)使阶段一孤儿清理跳过,隔离方向一的独立语义
135
+ await registry.add({
136
+ repo,
137
+ branch: "pi-sub-ghost",
138
+ checkout: path.join(baseDir, "pi-subagents", enc, "pi-sub-ghost"),
139
+ pid: process.pid,
140
+ createdAt: Date.now(),
141
+ });
142
+
143
+ await mgr.scan();
144
+
145
+ expect(registry.load()).toEqual([]);
146
+ });
147
+
148
+ it("多残留 + 多活 pid(对应歧义)→ 跳过不动", async () => {
149
+ const c1 = makePhysicalWorktree("pi-sub-m1");
150
+ const c2 = makePhysicalWorktree("pi-sub-m2");
151
+ ageBeyondGrace(c1);
152
+ ageBeyondGrace(c2);
153
+ // 第二个「活 pid」用真实 sleep 子进程(与 process.pid 不同值)
154
+ const sleeper = spawn("sleep", ["60"]);
155
+ try {
156
+ expect(sleeper.pid).toBeTruthy();
157
+ writeAliveMarker("s-m1", process.pid);
158
+ writeAliveMarker("s-m2", sleeper.pid as number);
159
+
160
+ await mgr.scan();
161
+
162
+ // 歧义 → 本周期全部跳过(宁延迟勿误删),下周期活 pid 消失后收敛
163
+ expect(fs.existsSync(c1)).toBe(true);
164
+ expect(fs.existsSync(c2)).toBe(true);
165
+ expect(registry.load()).toEqual([]);
166
+ } finally {
167
+ sleeper.kill("SIGKILL");
168
+ }
169
+ });
170
+
171
+ it("幂等:连续两次 scan 结果一致(无重复动作)", async () => {
172
+ const checkout = makePhysicalWorktree("pi-sub-idem");
173
+ ageBeyondGrace(checkout);
174
+
175
+ await mgr.scan();
176
+ await mgr.scan();
177
+
178
+ expect(fs.existsSync(checkout)).toBe(false);
179
+ expect(git(repo, "branch", "--list", "pi-sub-*")).toBe("");
180
+ });
181
+ });
@@ -45,29 +45,29 @@ describe("WorktreeRegistry", () => {
45
45
  });
46
46
 
47
47
  describe("add + load", () => {
48
- it("add 后 load 能读到", () => {
48
+ it("add 后 load 能读到", async () => {
49
49
  const entry = makeEntry();
50
- registry.add(entry);
50
+ await registry.add(entry);
51
51
  const loaded = registry.load();
52
52
  expect(loaded).toHaveLength(1);
53
53
  expect(loaded[0]).toEqual(entry);
54
54
  });
55
55
 
56
- it("多条目跨 repo 共存", () => {
56
+ it("多条目跨 repo 共存", async () => {
57
57
  const entryA = makeEntry({ repo: REPO_A, branch: "pi-sub-bg-1" });
58
58
  const entryB = makeEntry({ repo: REPO_B, branch: "pi-sub-bg-2" });
59
- registry.add(entryA);
60
- registry.add(entryB);
59
+ await registry.add(entryA);
60
+ await registry.add(entryB);
61
61
  const loaded = registry.load();
62
62
  expect(loaded).toHaveLength(2);
63
63
  });
64
64
 
65
- it("同 branch add 覆盖(去重)", () => {
65
+ it("同 branch add 覆盖(去重)", async () => {
66
66
  const entry = makeEntry({ pid: 0 });
67
- registry.add(entry);
67
+ await registry.add(entry);
68
68
  // 同 branch 再次 add(覆盖)
69
69
  const updated = makeEntry({ pid: 999 });
70
- registry.add(updated);
70
+ await registry.add(updated);
71
71
  const loaded = registry.load();
72
72
  expect(loaded).toHaveLength(1);
73
73
  expect(loaded[0].pid).toBe(999);
@@ -75,25 +75,25 @@ describe("WorktreeRegistry", () => {
75
75
  });
76
76
 
77
77
  describe("updatePid", () => {
78
- it("补全 pid(create 占位 → first header 补全)", () => {
79
- registry.add(makeEntry({ branch: "pi-sub-bg-1", pid: 0 }));
80
- registry.updatePid("pi-sub-bg-1", 12345);
78
+ it("补全 pid(create 占位 → first header 补全)", async () => {
79
+ await registry.add(makeEntry({ branch: "pi-sub-bg-1", pid: 0 }));
80
+ await registry.updatePid("pi-sub-bg-1", 12345);
81
81
  const loaded = registry.load();
82
82
  expect(loaded[0].pid).toBe(12345);
83
83
  });
84
84
 
85
- it("branch 不存在时忽略(幂等)", () => {
86
- registry.add(makeEntry({ branch: "pi-sub-bg-1" }));
87
- registry.updatePid("pi-sub-nonexistent", 12345);
85
+ it("branch 不存在时忽略(幂等)", async () => {
86
+ await registry.add(makeEntry({ branch: "pi-sub-bg-1" }));
87
+ await registry.updatePid("pi-sub-nonexistent", 12345);
88
88
  const loaded = registry.load();
89
89
  expect(loaded).toHaveLength(1);
90
90
  expect(loaded[0].pid).toBe(0); // 原条目未变
91
91
  });
92
92
 
93
- it("update 不改变其他字段", () => {
93
+ it("update 不改变其他字段", async () => {
94
94
  const entry = makeEntry({ branch: "pi-sub-bg-1", repo: REPO_A, checkout: "/tmp/x" });
95
- registry.add(entry);
96
- registry.updatePid("pi-sub-bg-1", 999);
95
+ await registry.add(entry);
96
+ await registry.updatePid("pi-sub-bg-1", 999);
97
97
  const loaded = registry.load();
98
98
  expect(loaded[0].repo).toBe(REPO_A);
99
99
  expect(loaded[0].checkout).toBe("/tmp/x");
@@ -102,60 +102,98 @@ describe("WorktreeRegistry", () => {
102
102
  });
103
103
 
104
104
  describe("remove", () => {
105
- it("移除指定 branch", () => {
106
- registry.add(makeEntry({ branch: "pi-sub-bg-1" }));
107
- registry.add(makeEntry({ branch: "pi-sub-bg-2" }));
108
- registry.remove("pi-sub-bg-1");
105
+ it("移除指定 branch", async () => {
106
+ await registry.add(makeEntry({ branch: "pi-sub-bg-1" }));
107
+ await registry.add(makeEntry({ branch: "pi-sub-bg-2" }));
108
+ await registry.remove("pi-sub-bg-1");
109
109
  const loaded = registry.load();
110
110
  expect(loaded).toHaveLength(1);
111
111
  expect(loaded[0].branch).toBe("pi-sub-bg-2");
112
112
  });
113
113
 
114
- it("branch 不存在时忽略(幂等)", () => {
115
- registry.add(makeEntry({ branch: "pi-sub-bg-1" }));
116
- registry.remove("pi-sub-nonexistent");
114
+ it("branch 不存在时忽略(幂等)", async () => {
115
+ await registry.add(makeEntry({ branch: "pi-sub-bg-1" }));
116
+ await registry.remove("pi-sub-nonexistent");
117
117
  expect(registry.load()).toHaveLength(1);
118
118
  });
119
119
 
120
- it("空注册表 remove 不报错", () => {
121
- expect(() => registry.remove("pi-sub-anything")).not.toThrow();
120
+ it("空注册表 remove 不报错", async () => {
121
+ await expect(registry.remove("pi-sub-anything")).resolves.not.toThrow();
122
122
  });
123
123
  });
124
124
 
125
125
  describe("降级与健壮性", () => {
126
- it("文件不存在时 load 返回空数组", () => {
126
+ it("文件不存在时 load 返回空数组", async () => {
127
127
  expect(registry.load()).toEqual([]);
128
128
  });
129
129
 
130
- it("损坏 JSON 时 load 返回空数组", () => {
130
+ it("损坏 JSON 时 load 返回空数组", async () => {
131
131
  fs.mkdirSync(path.dirname(registryFile), { recursive: true });
132
132
  fs.writeFileSync(registryFile, "{ not valid json }}}", "utf-8");
133
133
  expect(registry.load()).toEqual([]);
134
134
  });
135
135
 
136
- it("entries 字段缺失时 load 返回空数组", () => {
136
+ it("entries 字段缺失时 load 返回空数组", async () => {
137
137
  fs.mkdirSync(path.dirname(registryFile), { recursive: true });
138
138
  fs.writeFileSync(registryFile, '{"other": 123}', "utf-8");
139
139
  expect(registry.load()).toEqual([]);
140
140
  });
141
141
 
142
- it("save 创建不存在的父目录", () => {
142
+ it("save 创建不存在的父目录", async () => {
143
143
  // registryFile 在 tmpAgentDir/subagents/ 下,subagents 目录不存在
144
144
  expect(fs.existsSync(path.dirname(registryFile))).toBe(false);
145
- registry.add(makeEntry());
145
+ await registry.add(makeEntry());
146
146
  expect(fs.existsSync(registryFile)).toBe(true);
147
147
  });
148
148
 
149
- it("save 后无残留 .tmp 文件(原子写)", () => {
150
- registry.add(makeEntry());
149
+ it("save 后无残留 .tmp 文件(原子写)", async () => {
150
+ await registry.add(makeEntry());
151
151
  const tmpFile = `${registryFile}.tmp`;
152
152
  expect(fs.existsSync(tmpFile)).toBe(false);
153
153
  });
154
154
  });
155
155
 
156
156
  describe("SPAWN_GRACE_MS 常量", () => {
157
- it("值为 60s", () => {
157
+ it("值为 60s", async () => {
158
158
  expect(SPAWN_GRACE_MS).toBe(60_000);
159
159
  });
160
160
  });
161
+
162
+ describe("D5a 锁互斥(两写方并发不丢条目)", () => {
163
+ // [HISTORICAL] 2026-08-20 PR #185:真实 proper-lockfile 文件锁 RMW 用例显式超时——
164
+ // 单跑 >1s,整包满并行 + 系统余载下 100/31 次持锁 load→mutate→save 超过 vitest
165
+ // 默认 5s testTimeout(隔离跑通过,纯环境饿死),对齐本包 record-store-index 口径。
166
+ it("两个 registry 实例(模拟两 pi 进程写方)并发各 add 50 条,零丢失", { timeout: 30_000 }, async () => {
167
+ // 失败模式 G 的复现形态:无锁时两实例 load→mutate→save 交错,后写者覆盖
168
+ // 先写者 → 条目丢失。锁内 RMW 应保证 100 条全部落盘。
169
+ const r1 = new WorktreeRegistry(tmpAgentDir);
170
+ const r2 = new WorktreeRegistry(tmpAgentDir);
171
+ const tasks: Promise<void>[] = [];
172
+ for (let i = 0; i < 50; i++) {
173
+ tasks.push(r1.add(makeEntry({ branch: `pi-sub-w1-${i}` })));
174
+ tasks.push(r2.add(makeEntry({ branch: `pi-sub-w2-${i}` })));
175
+ }
176
+ await Promise.all(tasks);
177
+ const loaded = r1.load();
178
+ expect(loaded).toHaveLength(100);
179
+ // 两写方的条目都在(非「后写者全量覆盖」形态)
180
+ expect(loaded.filter((e) => e.branch.startsWith("pi-sub-w1-"))).toHaveLength(50);
181
+ expect(loaded.filter((e) => e.branch.startsWith("pi-sub-w2-"))).toHaveLength(50);
182
+ });
183
+
184
+ it("并发 add + remove 交错:remove 只删自己的 branch", { timeout: 30_000 }, async () => {
185
+ const r1 = new WorktreeRegistry(tmpAgentDir);
186
+ const r2 = new WorktreeRegistry(tmpAgentDir);
187
+ await r1.add(makeEntry({ branch: "pi-sub-keep" }));
188
+ const tasks: Promise<void>[] = [];
189
+ for (let i = 0; i < 30; i++) {
190
+ tasks.push(r2.add(makeEntry({ branch: `pi-sub-n-${i}` })));
191
+ }
192
+ tasks.push(r1.remove("pi-sub-keep"));
193
+ await Promise.all(tasks);
194
+ const loaded = r1.load();
195
+ expect(loaded).toHaveLength(30);
196
+ expect(loaded.find((e) => e.branch === "pi-sub-keep")).toBeUndefined();
197
+ });
198
+ });
161
199
  });
@@ -8,6 +8,9 @@
8
8
  import type { AgentResult as WorkflowAgentResult, AgentUsage as WorkflowAgentUsage, ToolCallEntry } from "../orchestration/models/types.ts";
9
9
  import type { AgentResult as SubagentsAgentResult, AgentUsageTotal, ToolCall } from "./types.ts";
10
10
 
11
+ /** safeStringify 的 JSON 截断长度(字符),防大对象拼进 toolCalls 挤爆 trace。 */
12
+ const TOOL_ARGS_JSON_MAX_CHARS = 500;
13
+
11
14
  /**
12
15
  * D-A10: subagents AgentResult → workflow AgentResult 映射。
13
16
  *
@@ -80,7 +83,7 @@ function mapToolCalls(calls: ToolCall[]): ToolCallEntry[] {
80
83
  function safeStringify(value: unknown): string {
81
84
  try {
82
85
  const s = JSON.stringify(value);
83
- return s.length > 500 ? `${s.slice(0, 500)}...` : s;
86
+ return s.length > TOOL_ARGS_JSON_MAX_CHARS ? `${s.slice(0, TOOL_ARGS_JSON_MAX_CHARS)}...` : s;
84
87
  } catch {
85
88
  return String(value);
86
89
  }
@@ -12,6 +12,7 @@ export interface MirrorFlags {
12
12
  noExtensions: boolean;
13
13
  approve: boolean;
14
14
  extensionPaths: string[];
15
+ noContextFiles: boolean;
15
16
  }
16
17
 
17
18
  /**
@@ -31,7 +32,7 @@ const VALUED_FLAGS = new Set<string>([
31
32
  const ARGV_FLAG_START = 2;
32
33
 
33
34
  /**
34
- * 从主进程 argv 解析可镜像的 flag(--no-extensions/--approve/--extension)。
35
+ * 从主进程 argv 解析可镜像的 flag(--no-extensions/--approve/--extension/--no-context-files)。
35
36
  *
36
37
  * 数据源是主 pi 进程的 process.argv:子进程 spawn 的父就是主进程,
37
38
  * 主进程 argv 完整保留启动时收到的全部 flag(已运行时验证)。这让子进程
@@ -39,14 +40,25 @@ const ARGV_FLAG_START = 2;
39
40
  *
40
41
  * 解析规则:
41
42
  * - --no-extensions / -ne、--approve / -a:布尔 flag
43
+ * - --no-context-files / -nc:布尔 flag(镜像理由:--extension 镜像会把
44
+ * @zhushanwen/pi-system-prompt 带进每个 subagent,该扩展靠子进程自己的
45
+ * argv 检测此 flag 决定是否注入全局 AGENTS.md——不镜像则用户 opt-out
46
+ * 在 subagent 路径失效)
42
47
  * - --extension / -e:支持 `--extension <path>`(空格)与 `--extension=<path>`(等号)
43
48
  * - 其他 flag(在 VALUED_FLAGS 中)的值不被误当 extension 路径
44
49
  * - positional 参数被忽略
45
50
  * - argv[0]/argv[1] 是 bun/pi binary 路径,从 argv[2:] 开始扫
46
51
  */
52
+ // [perf] 进程内 process.argv 引用恒定(唯一调用点 session-runner 每次 spawn 传同一引用),
53
+ // 解析结果按 argv 引用相等 memo——每次 spawn 免重复扫描/正则。测试传新数组引用不命中。
54
+ let memoArgv: readonly string[] | undefined;
55
+ let memoResult: MirrorFlags | undefined;
56
+
47
57
  export function mirrorMainProcessFlags(argv: readonly string[]): MirrorFlags {
58
+ if (memoResult !== undefined && memoArgv === argv) return memoResult;
48
59
  let hasNoExtensions = false;
49
60
  let hasApprove = false;
61
+ let hasNoContextFiles = false;
50
62
  const extensionPaths: string[] = [];
51
63
 
52
64
  // argv[0]=runtime(bun), argv[1]=pi binary 路径;flag 从 argv[2] 起
@@ -62,6 +74,10 @@ export function mirrorMainProcessFlags(argv: readonly string[]): MirrorFlags {
62
74
  hasApprove = true;
63
75
  continue;
64
76
  }
77
+ if (tok === "--no-context-files" || tok === "-nc") {
78
+ hasNoContextFiles = true;
79
+ continue;
80
+ }
65
81
  // 等号形式 --extension=path / -e=path
66
82
  const eqMatch = /^(--extension|-e)=(.*)$/.exec(tok);
67
83
  if (eqMatch) {
@@ -86,5 +102,8 @@ export function mirrorMainProcessFlags(argv: readonly string[]): MirrorFlags {
86
102
  // 其他情况(未知 flag、--flag=val 形式、positional)忽略
87
103
  }
88
104
 
89
- return { noExtensions: hasNoExtensions, approve: hasApprove, extensionPaths };
105
+ const result = { noExtensions: hasNoExtensions, approve: hasApprove, extensionPaths, noContextFiles: hasNoContextFiles };
106
+ memoArgv = argv;
107
+ memoResult = result;
108
+ return result;
90
109
  }
@@ -76,7 +76,9 @@ function readHandshakeSlot(): ChannelRegistryHandshake | undefined {
76
76
  );
77
77
  return undefined;
78
78
  }
79
- const version = (slot as { version?: unknown }).version;
79
+ // 断言目标含必填 version 字段(object/null 已守卫;version 值本身在下一行校验),
80
+ // 对齐 format.ts extractAgentName 的必填字段断言先例,避免全可选断言零校验。
81
+ const version = (slot as { version: unknown }).version;
80
82
  if (version !== HANDSHAKE_VERSION) {
81
83
  logger.warn(
82
84
  `[pi-subagent-workflow] channel handshake version mismatch (got ${String(