@zhushanwen/pi-subagent-workflow 7.3.4 → 8.0.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 +39 -12
  2. package/agents/analyst.md +61 -0
  3. package/agents/coder.md +70 -0
  4. package/agents/debugger.md +67 -0
  5. package/agents/doc-reviewer.md +3 -3
  6. package/agents/explorer.md +50 -18
  7. package/agents/general-purpose.md +19 -8
  8. package/agents/orchestrator.md +37 -32
  9. package/agents/planner.md +45 -11
  10. package/agents/researcher.md +53 -11
  11. package/agents/reviewer.md +74 -0
  12. package/package.json +1 -1
  13. package/skills/workflow-script-format/SKILL.md +1 -1
  14. package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
  15. package/src/execution/__tests__/agent-registry.test.ts +13 -11
  16. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
  17. package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
  18. package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
  19. package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
  20. package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
  21. package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
  22. package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
  23. package/src/execution/__tests__/crash-recovery.test.ts +8 -2
  24. package/src/execution/__tests__/delivery-methods.test.ts +385 -0
  25. package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
  26. package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
  27. package/src/execution/__tests__/execute-nesting.test.ts +20 -72
  28. package/src/execution/__tests__/execution-record.test.ts +199 -0
  29. package/src/execution/__tests__/finalize-record.test.ts +170 -14
  30. package/src/execution/__tests__/format.test.ts +131 -7
  31. package/src/execution/__tests__/gc-timer.test.ts +184 -0
  32. package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
  33. package/src/execution/__tests__/helpers/spawn-mock.ts +25 -7
  34. package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
  35. package/src/execution/__tests__/index-session-start.test.ts +257 -5
  36. package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
  37. package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
  38. package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
  39. package/src/execution/__tests__/list-component.test.ts +59 -5
  40. package/src/execution/__tests__/list-fields.test.ts +109 -0
  41. package/src/execution/__tests__/model-resolver.test.ts +38 -1
  42. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
  43. package/src/execution/__tests__/nested-visibility.test.ts +325 -0
  44. package/src/execution/__tests__/notifier-flush.test.ts +209 -7
  45. package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
  46. package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
  47. package/src/execution/__tests__/record-store.test.ts +158 -52
  48. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
  49. package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
  50. package/src/execution/__tests__/resource-policy.test.ts +109 -0
  51. package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
  52. package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
  53. package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
  54. package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
  55. package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
  56. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
  57. package/src/execution/__tests__/session-pending.test.ts +61 -2
  58. package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
  59. package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
  60. package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
  61. package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
  62. package/src/execution/__tests__/spawn-args.test.ts +127 -49
  63. package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
  64. package/src/execution/__tests__/spawned-children.test.ts +92 -0
  65. package/src/execution/__tests__/status-refactor.test.ts +345 -0
  66. package/src/execution/__tests__/stdin-writer.test.ts +97 -0
  67. package/src/execution/__tests__/subagent-service-message-close.test.ts +598 -0
  68. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
  69. package/src/execution/__tests__/subagent-service.test.ts +49 -11
  70. package/src/execution/__tests__/timeout-integration.test.ts +27 -13
  71. package/src/execution/__tests__/tool-action.test.ts +12 -10
  72. package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
  73. package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
  74. package/src/execution/__tests__/worktree-manager.test.ts +292 -89
  75. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +13 -12
  76. package/src/execution/argv-mirror.ts +21 -2
  77. package/src/execution/execution-record.ts +126 -9
  78. package/src/execution/finalize-record.ts +90 -13
  79. package/src/execution/host-mode.ts +1 -1
  80. package/src/execution/lifecycle-manager.ts +484 -0
  81. package/src/execution/lifecycle-predicates.ts +65 -0
  82. package/src/execution/manifest-store.ts +61 -16
  83. package/src/execution/model-resolver.ts +26 -5
  84. package/src/execution/notifier.ts +69 -12
  85. package/src/execution/pi-invocation.ts +21 -1
  86. package/src/execution/record-store.ts +554 -107
  87. package/src/execution/session-pending.ts +116 -45
  88. package/src/execution/session-reconstructor.ts +224 -7
  89. package/src/execution/session-runner.ts +290 -75
  90. package/src/execution/sessions-index.ts +304 -0
  91. package/src/execution/stdin-writer.ts +93 -7
  92. package/src/execution/stream-sink.ts +20 -3
  93. package/src/execution/subagent-service.ts +867 -138
  94. package/src/execution/turn-limiter.ts +14 -0
  95. package/src/execution/types.ts +204 -22
  96. package/src/execution/worktree-manager.ts +128 -49
  97. package/src/execution/worktree-registry.ts +13 -2
  98. package/src/index.ts +277 -19
  99. package/src/injectors/subagent-list-injector.ts +26 -8
  100. package/src/injectors/workflow-list-injector.ts +25 -8
  101. package/src/interface/__tests__/subagent-tool-prompt.test.ts +18 -5
  102. package/src/interface/__tests__/tool-render.test.ts +15 -13
  103. package/src/interface/bg-notify-render.ts +32 -8
  104. package/src/interface/command-actions.ts +26 -7
  105. package/src/interface/commands.ts +21 -22
  106. package/src/interface/format.ts +44 -17
  107. package/src/interface/gui-mappers.ts +6 -8
  108. package/src/interface/helpers.ts +170 -10
  109. package/src/interface/list-component.ts +53 -14
  110. package/src/interface/subagent-actions.ts +235 -17
  111. package/src/interface/subagent-tool.ts +82 -17
  112. package/src/interface/subagents.ts +2 -1
  113. package/src/interface/tool-render.ts +11 -24
  114. package/src/interface/tool-workflow.ts +20 -35
  115. package/src/interface/views/WorkflowsView.ts +89 -32
  116. package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
  117. package/src/interface/views/__tests__/detail-content-session-file.test.ts +1 -1
  118. package/src/interface/views/format.ts +3 -3
  119. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +325 -0
  120. package/src/orchestration/__tests__/args-validator.test.ts +1 -1
  121. package/src/orchestration/__tests__/config-loader.test.ts +38 -0
  122. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
  123. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
  124. package/src/orchestration/__tests__/execute-agent-call.test.ts +95 -0
  125. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +657 -18
  126. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
  127. package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
  128. package/src/orchestration/__tests__/skill-discovery.test.ts +157 -0
  129. package/src/orchestration/__tests__/test-mocks.ts +191 -0
  130. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +98 -0
  131. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
  132. package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
  133. package/src/orchestration/__tests__/workflows-e2e.test.ts +1 -1
  134. package/src/orchestration/agent-opts-resolver.ts +4 -1
  135. package/src/orchestration/args-validator.ts +2 -2
  136. package/src/orchestration/config-loader.ts +30 -1
  137. package/src/orchestration/error-recovery.ts +133 -29
  138. package/src/orchestration/execute-agent-call.ts +31 -7
  139. package/src/orchestration/jsonl-run-store.ts +287 -40
  140. package/src/orchestration/launcher.ts +7 -1
  141. package/src/orchestration/lifecycle.ts +135 -132
  142. package/src/orchestration/models/__tests__/trace.test.ts +408 -0
  143. package/src/orchestration/models/budget.ts +1 -1
  144. package/src/orchestration/models/run-runtime.ts +15 -17
  145. package/src/orchestration/models/run-spec.ts +2 -2
  146. package/src/orchestration/models/run-state.ts +3 -3
  147. package/src/orchestration/models/trace.ts +95 -15
  148. package/src/orchestration/models/types.ts +8 -9
  149. package/src/orchestration/models/workflow-run.ts +50 -71
  150. package/src/orchestration/models/workflow-script.ts +32 -1
  151. package/src/orchestration/skill-discovery.ts +30 -0
  152. package/src/orchestration/worker-handle.ts +1 -1
  153. package/src/orchestration/worker-host.ts +1 -1
  154. package/src/orchestration/worker-script-builder.ts +29 -10
  155. package/src/shared/__tests__/agent-ref.test.ts +34 -0
  156. package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
  157. package/src/shared/__tests__/resource-discovery.test.ts +55 -0
  158. package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
  159. package/src/shared/agent-ref.ts +16 -0
  160. package/src/shared/resource-discovery.ts +147 -58
  161. package/src/shared/schema-jsonify.ts +53 -0
  162. package/workflows/README.md +4 -4
  163. package/agents/code-reviewer.md +0 -47
  164. package/agents/context-builder.md +0 -21
  165. package/agents/oracle.md +0 -34
  166. package/agents/worker.md +0 -20
@@ -89,13 +89,19 @@ function writeSessionJsonl(
89
89
  }
90
90
 
91
91
  describe("RecordStore", () => {
92
+ let rootDir: string;
92
93
  let tmpDir: string;
93
94
 
94
95
  beforeEach(() => {
95
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rs-test-"));
96
+ // [DS3] 两层布局:tmpDir 深一层(对齐生产 <enc> 段结构),sessions-index.json
97
+ // dirname(tmpDir)=rootDir 内随 afterEach 清理(含 MF-3/S-20 的嵌套 <enc> 段索引)。
98
+ rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "rs-test-"));
99
+ tmpDir = path.join(rootDir, "sessions");
100
+ fs.mkdirSync(tmpDir);
96
101
  });
97
102
  afterEach(() => {
98
- fs.rmSync(tmpDir, { recursive: true, force: true });
103
+ // maxRetries:fire-and-forget sessions-index 写可能与删除并发(ENOTEMPTY 竞态)
104
+ fs.rmSync(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
99
105
  });
100
106
 
101
107
  // ============================================================
@@ -104,7 +110,7 @@ describe("RecordStore", () => {
104
110
  describe("archive 立即移除", () => {
105
111
  it("archive 后 record 立即从内存移除(不再 linger)", () => {
106
112
  const store = new RecordStore(tmpDir);
107
- const r = makeRecord({ id: "sync-1", mode: "sync", status: "done" });
113
+ const r = makeRecord({ id: "sync-1", mode: "sync", status: "closed" });
108
114
  store.register(r);
109
115
  expect(store.getMutable("sync-1")).toBeDefined();
110
116
  store.archive(r);
@@ -113,7 +119,7 @@ describe("RecordStore", () => {
113
119
 
114
120
  it("background record 同样立即移除(不再 FIFO)", () => {
115
121
  const store = new RecordStore(tmpDir);
116
- const r = makeRecord({ id: "bg-1", mode: "background", status: "done" });
122
+ const r = makeRecord({ id: "bg-1", mode: "background", status: "closed" });
117
123
  store.register(r);
118
124
  store.archive(r);
119
125
  expect(store.getMutable("bg-1")).toBeUndefined();
@@ -131,20 +137,25 @@ describe("RecordStore", () => {
131
137
  expect(ids).toContain("run-1");
132
138
  });
133
139
 
134
- it("磁盘 session.jsonl 重建的终态 record 出现在结果中(无 sidecar → crashed)", () => {
140
+ it("磁盘 session.jsonl 重建的 record 出现在结果中(无 sidecar → running, SP-2 跨重启可恢复)", () => {
135
141
  const sessionFile = path.join(tmpDir, "2026-01-01-uuid-a.jsonl");
136
142
  writeSessionJsonl(sessionFile, {
137
143
  id: "bg-1", agent: "worker", mode: "background", task: "do it", startedAt: 5000,
138
144
  });
139
- // 无 sidecar → 四分支兜底 crashed
145
+ // 无 sidecar → 四分支兜底 running(v4 B-1:旧 idle 折入 running,SP-2 跨重启可恢复)
140
146
  const store = new RecordStore(tmpDir);
141
147
  const found = store.collectRecords(100).find((r) => r.id === "bg-1");
142
148
  expect(found).toBeDefined();
143
- expect(found?.status).toBe("crashed");
149
+ expect(found?.status).toBe("running");
144
150
  expect(found?.agent).toBe("worker");
145
- expect(found?.turns).toBe(1);
146
- expect(found?.totalTokens).toBe(30);
147
- expect(found?.result).toBe("result text");
151
+ // [perf] 列表是 light(头部 identity,详情字段缺省)——turns/tokens/result 走 getFullRecord 懒加载
152
+ expect(found?.turns).toBe(0);
153
+ expect(found?.result).toBeUndefined();
154
+ const full = store.getFullRecord("bg-1");
155
+ expect(full?.turns).toBe(1);
156
+ expect(full?.totalTokens).toBe(30);
157
+ expect(full?.result).toBe("result text");
158
+ expect(full?.status).toBe("running"); // 状态矩阵在全量路径同样套用
148
159
  });
149
160
 
150
161
  it("磁盘 session.jsonl + .finalized sidecar → done", () => {
@@ -156,10 +167,10 @@ describe("RecordStore", () => {
156
167
  const store = new RecordStore(tmpDir);
157
168
  const found = store.collectRecords(100).find((r) => r.id === "bg-2");
158
169
  expect(found).toBeDefined();
159
- expect(found?.status).toBe("done");
170
+ expect(found?.status).toBe("closed");
160
171
  });
161
172
 
162
- it("statusFilter='running' 只返回 running(磁盘终态被滤掉)", () => {
173
+ it("statusFilter='running' 含内存 + 磁盘跨重启 running(v4 B-1:旧 idle 折入 running)", () => {
163
174
  const sessionFile = path.join(tmpDir, "2026-01-01-uuid-a.jsonl");
164
175
  writeSessionJsonl(sessionFile, {
165
176
  id: "bg-1", agent: "worker", mode: "background", task: "do it", startedAt: 5000,
@@ -168,7 +179,8 @@ describe("RecordStore", () => {
168
179
  store.register(makeRecord({ id: "run-1", mode: "background", startedAt: 1000 }));
169
180
  const filter: StatusFilter = "running";
170
181
  const ids = store.collectRecords(100, filter).map((r) => r.id);
171
- expect(ids).toEqual(["run-1"]); // 只有内存 running,磁盘 crashed 被滤
182
+ // 内存 run-1 + 磁盘跨重启 bg-1 都是 running(旧 idle 折入);startedAt desc → bg-1(5000) 先
183
+ expect(ids).toEqual(["bg-1", "run-1"]);
172
184
  });
173
185
 
174
186
  it("statusFilter='all'(默认)返回内存 + 磁盘", () => {
@@ -191,7 +203,7 @@ describe("RecordStore", () => {
191
203
  const store = new RecordStore(tmpDir);
192
204
  store.register(makeRecord({ id: "dup-1", mode: "background", status: "running", startedAt: 5000 }));
193
205
  const found = store.collectRecords(100).find((r) => r.id === "dup-1");
194
- expect(found?.status).toBe("running"); // 内存 running 覆盖磁盘 crashed
206
+ expect(found?.status).toBe("running"); // 内存 running 覆盖磁盘 idle(SP-2)
195
207
  });
196
208
 
197
209
  it("[MF-3/S-20] 跨进程组合:子进程(worktree)写入 enc(ROOT) 的深层 record 被 ROOT store 重建,同 rootSessionId 全树可见、他树被排除", () => {
@@ -235,7 +247,7 @@ describe("RecordStore", () => {
235
247
  // cancelled tombstone override
236
248
  // ============================================================
237
249
  describe("cancelled tombstone", () => {
238
- it("有 .cancelled sidecar → status override 为 cancelled", () => {
250
+ it("有 .cancelled sidecar → status override 为 closed + closedReason=cancelled", () => {
239
251
  const sessionFile = path.join(tmpDir, "2026-01-01-uuid-a.jsonl");
240
252
  writeSessionJsonl(sessionFile, {
241
253
  id: "bg-1", agent: "worker", mode: "background", task: "do it", startedAt: 5000,
@@ -245,7 +257,9 @@ describe("RecordStore", () => {
245
257
  });
246
258
  const store = new RecordStore(tmpDir);
247
259
  const found = store.collectRecords(100).find((r) => r.id === "bg-1");
248
- expect(found?.status).toBe("cancelled");
260
+ // v4 B-1:cancelled 折入 closed,closedReason='cancelled' 保留用户取消语义
261
+ expect(found?.status).toBe("closed");
262
+ expect(found?.closedReason).toBe("cancelled");
249
263
  expect(found?.error).toBe("cancelled by user");
250
264
  });
251
265
  });
@@ -254,17 +268,19 @@ describe("RecordStore", () => {
254
268
  // compareRecords 排序稳定性(内存 running record)
255
269
  // ============================================================
256
270
  describe("compareRecords 排序", () => {
257
- it("status priority(running < crashed)", () => {
271
+ it("status priority(running < closed)", () => {
258
272
  const store = new RecordStore(tmpDir);
259
- // 内存 running record vs 磁盘无 sidecar → crashed
273
+ // 内存 running record
260
274
  const running = makeRecord({ id: "run-1", mode: "background", startedAt: 3000, status: "running" });
261
275
  store.register(running);
262
- // 磁盘 crashed record(无 sidecar → 四分支兜底)
263
- writeSessionJsonl(path.join(tmpDir, "a.jsonl"), {
276
+ // 磁盘 closed record(.finalized sidecar → closed,v4 B-1 统一终态)
277
+ const doneFile = path.join(tmpDir, "a.jsonl");
278
+ writeSessionJsonl(doneFile, {
264
279
  id: "done-1", agent: "w", mode: "background", task: "t", startedAt: 5000,
265
280
  });
281
+ writeFinalized(doneFile);
266
282
  const ids = store.collectRecords(100).map((r) => r.id);
267
- expect(ids[0]).toBe("run-1"); // running 排前
283
+ expect(ids[0]).toBe("run-1"); // running(0) 排在 closed(3) 前
268
284
  });
269
285
 
270
286
  it("同 status 时 startedAt desc(新→旧)", () => {
@@ -275,30 +291,55 @@ describe("RecordStore", () => {
275
291
  writeSessionJsonl(path.join(tmpDir, "new.jsonl"), {
276
292
  id: "new", agent: "w", mode: "background", task: "t", startedAt: 9000,
277
293
  });
278
- // 两个都是 crashed(磁盘重建,无 sidecar),按 startedAt desc
294
+ // 两个都是 running(磁盘重建,无 sidecar → SP-2 兜底 running,旧 idle 折入),按 startedAt desc
279
295
  const ids = store.collectRecords(100).map((r) => r.id);
280
296
  expect(ids).toEqual(["new", "old"]);
281
297
  });
282
298
  });
283
299
 
300
+ // ============================================================
301
+ // running 态 record(M2-A chatMode 续聊;v4 B-1:旧 idle 折入 running)
302
+ // ============================================================
303
+ describe("running 态 record(旧 idle 折入,等待续聊)", () => {
304
+ it("getMutable 能查到 running record(不筛 status,running record 留内存不 archive)", () => {
305
+ const store = new RecordStore(tmpDir);
306
+ const r = makeRecord({ id: "idle-1", status: "running" });
307
+ store.register(r);
308
+ // running record 不 archive → getMutable 仍可查(续聊定位依赖此)
309
+ expect(store.getMutable("idle-1")).toBe(r);
310
+ });
311
+
312
+ it("running record 经 collectRecords(statusFilter=all) 可见", () => {
313
+ const store = new RecordStore(tmpDir);
314
+ store.register(makeRecord({ id: "idle-2", status: "running" }));
315
+ const ids = store.collectRecords(100, "all").map((r) => r.id);
316
+ expect(ids).toContain("idle-2");
317
+ });
318
+
319
+ it("running record 经 collectRecords(statusFilter=running) 可见(v4 B-1:旧 idle 折入 running)", () => {
320
+ const store = new RecordStore(tmpDir);
321
+ store.register(makeRecord({ id: "idle-3", status: "running" }));
322
+ const ids = store.collectRecords(100, "running").map((r) => r.id);
323
+ expect(ids).toContain("idle-3");
324
+ });
325
+ });
326
+
327
+
284
328
  // ============================================================
285
329
  // 重建缓存
286
330
  // ============================================================
287
331
  describe("重建缓存", () => {
288
- it("notifyChange 后缓存失效(新 session.jsonl 可见)", () => {
332
+ it(" session.jsonl 无需内存事件即立即可见([perf] stat 校验取代 notifyChange 整体失效)", () => {
289
333
  const store = new RecordStore(tmpDir);
290
334
  // 首次 collect:空目录
291
335
  expect(store.collectRecords(100)).toHaveLength(0);
292
- // 写新 session.jsonl
336
+ // 写新 session.jsonl(模拟外部进程/子进程写入——不经本进程任何内存事件)
293
337
  writeSessionJsonl(path.join(tmpDir, "new.jsonl"), {
294
338
  id: "bg-1", agent: "w", mode: "background", task: "t", startedAt: 1000,
295
339
  });
296
- // 缓存仍命中旧结果(notifyChange 未触发)
297
- expect(store.collectRecords(100)).toHaveLength(0);
298
- // register 触发 notifyChange 缓存失效
299
- store.register(makeRecord({ id: "trigger", mode: "sync", startedAt: 2000 }));
300
- store.archive(makeRecord({ id: "trigger", mode: "sync", startedAt: 2000 }));
301
- // 现在 bg-1 可见
340
+ // [perf] 旧实现:reconCache 建立后只能靠 register/archive 触发 notifyChange 失效,
341
+ // 否则永远 stale(外部进程写入的文件对本进程不可见)。新实现:每次 collectRecords
342
+ // readdir + stat 校验,新文件立即可见(同时修复跨进程可见性)。
302
343
  const ids = store.collectRecords(100).map((r) => r.id);
303
344
  expect(ids).toContain("bg-1");
304
345
  });
@@ -336,14 +377,16 @@ describe("RecordStore", () => {
336
377
  }
337
378
 
338
379
  // ── 分支 1: .cancelled ──
339
- it(".cancelled sidecar → cancelled", () => {
380
+ it(".cancelled sidecar → closed + closedReason=cancelled", () => {
340
381
  const sessionFile = writeBaseSession();
341
382
  writeCancelledTombstone(sessionFile, {
342
383
  id: SESSION_ID, status: "cancelled", agent: "worker", startedAt: STARTED_AT, endedAt: 6000,
343
384
  });
344
385
  const store = new RecordStore(tmpDir);
345
386
  const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
346
- expect(found?.status).toBe("cancelled");
387
+ // v4 B-1:cancelled 折入 closed(closedReason='cancelled' 区分用户取消)
388
+ expect(found?.status).toBe("closed");
389
+ expect(found?.closedReason).toBe("cancelled");
347
390
  expect(found?.error).toBe("cancelled by user");
348
391
  expect(found?.endedAt).toBe(6000);
349
392
  });
@@ -354,7 +397,7 @@ describe("RecordStore", () => {
354
397
  writeFinalized(sessionFile);
355
398
  const store = new RecordStore(tmpDir);
356
399
  const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
357
- expect(found?.status).toBe("done");
400
+ expect(found?.status).toBe("closed");
358
401
  });
359
402
 
360
403
  // ── 分支 2: .finalized failed ──
@@ -386,7 +429,7 @@ describe("RecordStore", () => {
386
429
  writeFinalized(sessionFile);
387
430
  const store = new RecordStore(tmpDir);
388
431
  const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
389
- expect(found?.status).toBe("failed");
432
+ expect(found?.status).toBe("closed");
390
433
  });
391
434
 
392
435
  // ── 分支 3: .alive + 活 pid → running + externalInstance ──
@@ -401,27 +444,27 @@ describe("RecordStore", () => {
401
444
  expect(found?.externalInstance).toEqual(marker);
402
445
  });
403
446
 
404
- // ── 分支 3→4: .alive + 死 pid → crashed ──
405
- it(".alive + 死 pid → crashed", () => {
447
+ // ── 分支 3→4: .alive + 死 pid → running(SP-2:跨重启可恢复)──
448
+ it(".alive + 死 pid → running(SP-2 跨重启可恢复)", () => {
406
449
  const sessionFile = writeBaseSession();
407
450
  const marker: AliveMarker = { pid: 9999999, id: SESSION_ID, startedAt: STARTED_AT };
408
451
  writeAliveMarker(sessionFile, marker);
409
452
  const store = new RecordStore(tmpDir);
410
453
  const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
411
- expect(found?.status).toBe("crashed");
454
+ expect(found?.status).toBe("running");
412
455
  expect(found?.externalInstance).toBeUndefined();
413
456
  });
414
457
 
415
- // ── 分支 4: 都无 sidecar → crashed ──
416
- it("无任何 sidecar → crashed", () => {
458
+ // ── 分支 4: 都无 sidecar → running(SP-2:跨重启可恢复)──
459
+ it("无任何 sidecar → running(SP-2 跨重启可恢复)", () => {
417
460
  writeBaseSession();
418
461
  const store = new RecordStore(tmpDir);
419
462
  const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
420
- expect(found?.status).toBe("crashed");
463
+ expect(found?.status).toBe("running");
421
464
  });
422
465
 
423
- // ── 分支 4: >24h 软超时 → crashed(无视探活)──
424
- it(">24h 软超时 → crashed(即使 pid 存活)", () => {
466
+ // ── 分支 4: >24h 软超时 → running(SP-2:跨重启可恢复,无视探活)──
467
+ it(">24h 软超时 → running(SP-2,即使 pid 存活)", () => {
425
468
  const sessionFile = writeBaseSession();
426
469
  // startedAt 设为 25 小时前
427
470
  const oldStartedAt = Date.now() - 25 * 60 * 60 * 1000;
@@ -436,7 +479,7 @@ describe("RecordStore", () => {
436
479
 
437
480
  const store = new RecordStore(tmpDir);
438
481
  const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
439
- expect(found?.status).toBe("crashed");
482
+ expect(found?.status).toBe("running");
440
483
  expect(found?.externalInstance).toBeUndefined();
441
484
  });
442
485
 
@@ -449,18 +492,21 @@ describe("RecordStore", () => {
449
492
  });
450
493
  const store = new RecordStore(tmpDir);
451
494
  const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
452
- expect(found?.status).toBe("cancelled");
495
+ // v4 B-1:.cancelled 优先级不变,但 status 折入 closed(closedReason='cancelled')
496
+ expect(found?.status).toBe("closed");
497
+ expect(found?.closedReason).toBe("cancelled");
453
498
  });
454
499
 
455
500
  // ── 回归:旧 .cancelled 单分支行为不变 ──
456
- it("旧 .cancelled 单分支行为不变(回归)", () => {
501
+ it("旧 .cancelled 单分支行为不变(回归,status 折入 closed)", () => {
457
502
  const sessionFile = writeBaseSession();
458
503
  writeCancelledTombstone(sessionFile, {
459
504
  id: SESSION_ID, status: "cancelled", agent: "worker", startedAt: STARTED_AT, endedAt: 7000,
460
505
  });
461
506
  const store = new RecordStore(tmpDir);
462
507
  const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
463
- expect(found?.status).toBe("cancelled");
508
+ expect(found?.status).toBe("closed");
509
+ expect(found?.closedReason).toBe("cancelled");
464
510
  expect(found?.error).toBe("cancelled by user");
465
511
  expect(found?.endedAt).toBe(7000);
466
512
  });
@@ -546,7 +592,7 @@ describe("RecordStore", () => {
546
592
  // endedAt 重建(问题 2 修复:终态耗时不再随墙钟增长)
547
593
  // ============================================================
548
594
  describe("endedAt 重建(耗时不再无限增长)", () => {
549
- it(".finalized → endedAt 为最后 entry 时间戳(非 now)", () => {
595
+ it(".finalized → light endedAt 用 mtime 近似,全量 endedAt 为最后 entry 时间戳", () => {
550
596
  const sessionFile = path.join(tmpDir, "fin.jsonl");
551
597
  writeSessionJsonl(sessionFile, {
552
598
  id: "bg-1", agent: "w", mode: "background", task: "t",
@@ -555,10 +601,15 @@ describe("RecordStore", () => {
555
601
  writeFinalized(sessionFile);
556
602
  const store = new RecordStore(tmpDir);
557
603
  const found = store.collectRecords(100, "all", "sess-A").find((r) => r.id === "bg-1");
558
- expect(found?.endedAt).toBe(9000); // 不是 Date.now()
604
+ // [perf] light 分支 2:endedAt jsonl mtime 近似(finalize 后文件不再变化,
605
+ // 与最后 entry ts 差 <1s)——不是 entry ts 9000,也不是随墙钟无限增长的 now 基准。
606
+ expect(found?.endedAt).toBeDefined();
607
+ expect(found?.endedAt).toBeGreaterThan(5000);
608
+ // 全量(getFullRecord):精确用最后 entry 时间戳(非 now)。
609
+ expect(store.getFullRecord("bg-1")?.endedAt).toBe(9000);
559
610
  });
560
611
 
561
- it("无 sidecar(crashed)→ endedAt 为最后 entry 时间戳(非 now)", () => {
612
+ it("无 sidecar(running, SP-2)→ endedAt 保持 undefined(非终态)", () => {
562
613
  const sessionFile = path.join(tmpDir, "crash.jsonl");
563
614
  writeSessionJsonl(sessionFile, {
564
615
  id: "bg-1", agent: "w", mode: "background", task: "t",
@@ -566,8 +617,9 @@ describe("RecordStore", () => {
566
617
  });
567
618
  const store = new RecordStore(tmpDir);
568
619
  const found = store.collectRecords(100, "all", "sess-A").find((r) => r.id === "bg-1");
569
- expect(found?.status).toBe("crashed");
570
- expect(found?.endedAt).toBe(9000);
620
+ expect(found?.status).toBe("running");
621
+ // SP-2: running 是非终态(旧 idle 折入),endedAt 保持 undefined(待续聊)
622
+ expect(found?.endedAt).toBeUndefined();
571
623
  });
572
624
 
573
625
  it(".alive running → endedAt 保持 undefined(running 耗时继续增长是正确的)", () => {
@@ -602,7 +654,7 @@ describe("RecordStore", () => {
602
654
  id: "broken-1",
603
655
  rootSessionId: "sess-current",
604
656
  agentName: "worker",
605
- status: "crashed", // 越界值(union 不含 "crashed")
657
+ status: "crashed", // 越界值(union 不含 "crashed",SP-1 后 valid statuses = running/closed/cancelled
606
658
  createdAt: 1000,
607
659
  } as unknown as ManifestRecord;
608
660
  }
@@ -666,4 +718,58 @@ describe("RecordStore", () => {
666
718
  );
667
719
  });
668
720
  });
721
+
722
+ // ============================================================
723
+ // SP-2: reconstructAll 兜底分支输出 running(非 crashed;v4 两态后旧 idle 折入 running)
724
+ // ============================================================
725
+ describe("SP-2: reconstructAll 兜底 running(跨重启恢复)", () => {
726
+ // TC-1: 兜底分支输出 running(非 crashed)
727
+ it("TC-1: 无 sidecar + 死 pid → reconstructAll 输出 running(可冷路径 resume)", () => {
728
+ const sessionFile = path.join(tmpDir, "sp2-tc1.jsonl");
729
+ writeSessionJsonl(sessionFile, {
730
+ id: "sa-sp2-1", agent: "worker", mode: "background", task: "cross-restart task",
731
+ startedAt: 1000, rootSessionId: "sess-sp2",
732
+ });
733
+ // 无任何 sidecar marker → 分支 4 兜底
734
+ const store = new RecordStore(tmpDir);
735
+ const found = store.collectRecords(100, "all", "sess-sp2").find((r) => r.id === "sa-sp2-1");
736
+ expect(found).toBeDefined();
737
+ expect(found?.status).toBe("running");
738
+ // SP-2: running 是非终态(旧 idle 折入),endedAt 保持 undefined
739
+ expect(found?.endedAt).toBeUndefined();
740
+ });
741
+
742
+ // TC-2: 有 .finalized marker 的 record 仍归档为 done(不回归)
743
+ it("TC-2: .finalized marker → done(分支 1/2 不回归)", () => {
744
+ const sessionFile = path.join(tmpDir, "sp2-tc2.jsonl");
745
+ writeSessionJsonl(sessionFile, {
746
+ id: "sa-sp2-2", agent: "worker", mode: "background", task: "finalized task",
747
+ startedAt: 2000, rootSessionId: "sess-sp2",
748
+ });
749
+ writeFinalized(sessionFile);
750
+ const store = new RecordStore(tmpDir);
751
+ const found = store.collectRecords(100, "all", "sess-sp2").find((r) => r.id === "sa-sp2-2");
752
+ expect(found).toBeDefined();
753
+ // 分支 2 仍归档为 done,不受 SP-2 影响
754
+ expect(found?.status).toBe("closed");
755
+ expect(found?.endedAt).toBeDefined();
756
+ });
757
+
758
+ // TC-2b: .cancelled marker → closed+cancelled(分支 1 不回归)
759
+ it("TC-2b: .cancelled marker → closed+cancelled reason(分支 1 不回归)", () => {
760
+ const sessionFile = path.join(tmpDir, "sp2-tc2b.jsonl");
761
+ writeSessionJsonl(sessionFile, {
762
+ id: "sa-sp2-2b", agent: "worker", mode: "background", task: "cancelled task",
763
+ startedAt: 3000, rootSessionId: "sess-sp2",
764
+ });
765
+ writeCancelledTombstone(sessionFile, {
766
+ id: "sa-sp2-2b", status: "cancelled", agent: "worker", startedAt: 3000, endedAt: 4000,
767
+ });
768
+ const store = new RecordStore(tmpDir);
769
+ const found = store.collectRecords(100, "all", "sess-sp2").find((r) => r.id === "sa-sp2-2b");
770
+ expect(found).toBeDefined();
771
+ expect(found?.status).toBe("closed");
772
+ expect(found?.closedReason).toBe("cancelled");
773
+ });
774
+ });
669
775
  });
@@ -43,7 +43,15 @@ vi.mock("node:child_process", async () => {
43
43
 
44
44
  return {
45
45
  spawn: vi.fn(() => new FakeChild()),
46
- execFileSync: vi.fn(() => ""),
46
+ // buildEnvBlock execFile 异步取 git branch:默认 err-first 兜底(catch → branch=""
47
+ execFile: vi.fn(
48
+ (
49
+ _cmd: string,
50
+ _args: readonly string[],
51
+ _opts: unknown,
52
+ cb: (err: Error | null, stdout?: string, stderr?: string) => void,
53
+ ) => cb(new Error("execFile not configured in this test")),
54
+ ),
47
55
  };
48
56
  });
49
57
 
@@ -103,6 +111,7 @@ vi.mock("../temp-prompt.ts", () => ({
103
111
 
104
112
  import { spawn } from "node:child_process";
105
113
 
114
+ import { waitForSpawn } from "./helpers/spawn-mock.ts";
106
115
  import { ModelConfigService } from "../model-config-service.ts";
107
116
  import type { ModelInfo, ModelRegistryLike } from "../model-resolver.ts";
108
117
  import { ManifestStore } from "../manifest-store.ts";
@@ -139,16 +148,6 @@ function getLastSpawnEnv(): Record<string, string | undefined> {
139
148
  return (mockSpawn.mock.calls.at(-1)?.[2]?.env as Record<string, string | undefined>) ?? {};
140
149
  }
141
150
 
142
- async function waitForSpawn(timeoutMs = 1000): Promise<void> {
143
- const start = Date.now();
144
- while (mockSpawn.mock.results.length === 0) {
145
- if (Date.now() - start > timeoutMs) {
146
- throw new Error(`spawn was not called within ${timeoutMs}ms`);
147
- }
148
- await new Promise((r) => setTimeout(r, 2));
149
- }
150
- }
151
-
152
151
  function sessionHeader(id = "baseline-session"): Record<string, unknown> {
153
152
  return { type: "session", id, timestamp: "2026-08-11T00-00-00-000Z", cwd: "/tmp/test" };
154
153
  }
@@ -283,7 +282,7 @@ describe("进程级基线兜底(ALS 断裂修复,pi 事件回调模型)",
283
282
  const { service } = setup({});
284
283
 
285
284
  const execPromise = service.execute({ task: "fork child", ctxModel, fork: true });
286
- await waitForSpawn();
285
+ await waitForSpawn(mockSpawn);
287
286
  const childEnv = getLastSpawnEnv();
288
287
 
289
288
  // 742 行 parentDepth = forkDepthAls.getStore() ?? forkDepthBaseline —— ALS 断裂时基线=1,+1 → 2
@@ -23,6 +23,7 @@ vi.mock("node:child_process", async () => {
23
23
  class FakeChild extends EventEmitter {
24
24
  pid = 12345;
25
25
  stdout = new PassThrough();
26
+ stdin = new PassThrough(); // runSpawn 注册 stdin EPIPE handler(session-runner),FakeChild 必须提供
26
27
  stderr = new PassThrough();
27
28
  killed = false;
28
29
  killSignal: string | undefined;
@@ -35,7 +36,16 @@ vi.mock("node:child_process", async () => {
35
36
 
36
37
  return {
37
38
  spawn: vi.fn(() => new FakeChild()),
38
- execFileSync: vi.fn(() => ""),
39
+ // buildEnvBlock execFile 异步取 git branch:默认 err-first 兜底(catch → branch=""),
40
+ // 形态同 worktree-manager.test.ts 的 setupExecFile
41
+ execFile: vi.fn(
42
+ (
43
+ _cmd: string,
44
+ _args: readonly string[],
45
+ _opts: unknown,
46
+ cb: (err: Error | null, stdout?: string, stderr?: string) => void,
47
+ ) => cb(new Error("execFile not configured in this test")),
48
+ ),
39
49
  };
40
50
  });
41
51
 
@@ -71,14 +81,14 @@ vi.mock("../temp-prompt.ts", () => ({
71
81
  cleanupTempPrompt: vi.fn(async () => {}),
72
82
  }));
73
83
 
74
- import { execFileSync, spawn } from "node:child_process";
84
+ import { spawn } from "node:child_process";
75
85
  import * as fs from "node:fs";
76
86
 
87
+ import { waitForSpawn } from "./helpers/spawn-mock.ts";
77
88
  import { createRecord } from "../execution-record.ts";
78
89
  import { type RunOptions, runSpawn, type SessionRunnerContext } from "../session-runner.ts";
79
90
 
80
91
  const mockSpawn = vi.mocked(spawn);
81
- const mockExec = vi.mocked(execFileSync);
82
92
  const mockExistsSync = vi.mocked(fs.existsSync);
83
93
 
84
94
  interface FakeChild {
@@ -101,17 +111,6 @@ function getLastSpawnEnv(): Record<string, string | undefined> {
101
111
  return (mockSpawn.mock.calls.at(-1)?.[2]?.env as Record<string, string | undefined>) ?? {};
102
112
  }
103
113
 
104
- /** 等待 runSpawn 内部调到 spawn(async,spawn 在 writePromptToTempFile await 之后才调)。 */
105
- async function waitForSpawn(timeoutMs = 1000): Promise<void> {
106
- const start = Date.now();
107
- while (mockSpawn.mock.results.length === 0) {
108
- if (Date.now() - start > timeoutMs) {
109
- throw new Error(`spawn was not called within ${timeoutMs}ms`);
110
- }
111
- await new Promise((r) => setTimeout(r, 5));
112
- }
113
- }
114
-
115
114
  // ── fixture ──
116
115
 
117
116
  function makeRecord(overrides: { id?: string; depth?: number } = {}) {
@@ -160,7 +159,6 @@ describe("runSpawn 跨进程身份 env 注入(递归可见性场景 1b)", ()
160
159
  beforeEach(() => {
161
160
  vi.clearAllMocks();
162
161
  mockExistsSync.mockReturnValue(false);
163
- mockExec.mockReturnValue("");
164
162
  });
165
163
 
166
164
  afterEach(() => {
@@ -173,7 +171,7 @@ describe("runSpawn 跨进程身份 env 注入(递归可见性场景 1b)", ()
173
171
  const opts = makeRunOpts({ fork: false });
174
172
 
175
173
  const resultPromise = runSpawn(record, "test task", opts, ctx);
176
- await waitForSpawn();
174
+ await waitForSpawn(mockSpawn);
177
175
  const childEnv = getLastSpawnEnv();
178
176
 
179
177
  expect(childEnv.PI_SUBAGENT_ROOT_SESSION_ID).toBe("root-main");
@@ -195,7 +193,7 @@ describe("runSpawn 跨进程身份 env 注入(递归可见性场景 1b)", ()
195
193
  const opts = makeRunOpts({ fork: true, parentForkDepth: 1 });
196
194
 
197
195
  const resultPromise = runSpawn(record, "test task", opts, ctx);
198
- await waitForSpawn();
196
+ await waitForSpawn(mockSpawn);
199
197
  const childEnv = getLastSpawnEnv();
200
198
 
201
199
  // 身份 env 无条件存在(决策 2)
@@ -216,7 +214,7 @@ describe("runSpawn 跨进程身份 env 注入(递归可见性场景 1b)", ()
216
214
  const ctx = makeCtx({ sessionRootId: "root-topmost" });
217
215
 
218
216
  const resultPromise = runSpawn(record, "test task", makeRunOpts(), ctx);
219
- await waitForSpawn();
217
+ await waitForSpawn(mockSpawn);
220
218
  const childEnv = getLastSpawnEnv();
221
219
 
222
220
  expect(childEnv.PI_SUBAGENT_DEPTH).toBe("3");
@@ -236,7 +234,7 @@ describe("runSpawn 跨进程身份 env 注入(递归可见性场景 1b)", ()
236
234
  const ctx = makeCtx({ sessionRootId: "real-root-session" });
237
235
 
238
236
  const resultPromise = runSpawn(record, "test task", makeRunOpts(), ctx);
239
- await waitForSpawn();
237
+ await waitForSpawn(mockSpawn);
240
238
  const childEnv = getLastSpawnEnv();
241
239
 
242
240
  // env 用 ctx.sessionRootId,不是 record.rootSessionId
@@ -258,7 +256,7 @@ describe("runSpawn 跨进程身份 env 注入(递归可见性场景 1b)", ()
258
256
  const ctx = makeCtx({ cwd: checkoutPath, mainCwd: checkoutPath, rootCwd, agentDir });
259
257
 
260
258
  const resultPromise = runSpawn(record, "test task", makeRunOpts(), ctx);
261
- await waitForSpawn();
259
+ await waitForSpawn(mockSpawn);
262
260
  const childEnv = getLastSpawnEnv();
263
261
  const spawnArgs = mockSpawn.mock.calls.at(-1)?.[1] as string[];
264
262