@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
@@ -25,7 +25,7 @@ vi.mock("@zhushanwen/pi-extension-logger", () => ({
25
25
  getLogger: () => loggerMock,
26
26
  }));
27
27
 
28
- import { doFinalizeRecord } from "../finalize-record.ts";
28
+ import { doFinalizeRecord, doFinalizeRoundToIdle } from "../finalize-record.ts";
29
29
  import { ManifestStore } from "../manifest-store.ts";
30
30
  import type { AgentResult, ExecutionRecord } from "../types.ts";
31
31
 
@@ -86,38 +86,37 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
86
86
  return {
87
87
  manifestStore,
88
88
  worktreeManager: {} as never,
89
- store: { archive: vi.fn() } as never,
89
+ store: { archive: vi.fn(), reportRecordTransition: vi.fn() } as never,
90
90
  modelService: {} as never,
91
91
  pi: { appendEntry: vi.fn() },
92
- clearThrottle: vi.fn(),
93
92
  emitUnregister: vi.fn(),
94
93
  };
95
94
  }
96
95
 
97
- it("status=cancelled → manifest 写 cancelled(不再归并 failed)", async () => {
96
+ it("status=closed + cancelled reason → manifest 写 closed(v4 B-1:cancelled 折入 closed)", async () => {
98
97
  const record = makeMinimalRecord({ id: "rec-cancelled" });
99
- await doFinalizeRecord(makeDeps(), record, makeMinimalResult(), "cancelled");
98
+ await doFinalizeRecord(makeDeps(), record, makeMinimalResult(), "closed", "cancelled");
100
99
 
101
100
  const manifest = await manifestStore.readManifest("rec-cancelled");
102
101
  expect(manifest).not.toBeNull();
103
- // 关键断言:cancelled 直接透传,不是 "failed"
104
- expect(manifest?.status).toBe("cancelled");
102
+ // 关键断言:v4 B-1 manifest status 恒写 closed(cancelled 区分靠 tombstone sidecar)
103
+ expect(manifest?.status).toBe("closed");
105
104
  });
106
105
 
107
- it("status=done → manifest 写 completed", async () => {
106
+ it("status=closed + user-close → manifest 写 closed", async () => {
108
107
  const record = makeMinimalRecord({ id: "rec-done" });
109
- await doFinalizeRecord(makeDeps(), record, makeMinimalResult(), "done");
108
+ await doFinalizeRecord(makeDeps(), record, makeMinimalResult(), "closed", "user-close");
110
109
 
111
110
  const manifest = await manifestStore.readManifest("rec-done");
112
- expect(manifest?.status).toBe("completed");
111
+ expect(manifest?.status).toBe("closed");
113
112
  });
114
113
 
115
- it("status=failed → manifest 写 failed", async () => {
114
+ it("status=closed + gc → manifest 写 closed", async () => {
116
115
  const record = makeMinimalRecord({ id: "rec-failed" });
117
- await doFinalizeRecord(makeDeps(), record, makeMinimalResult(), "failed");
116
+ await doFinalizeRecord(makeDeps(), record, makeMinimalResult(), "closed", "gc");
118
117
 
119
118
  const manifest = await manifestStore.readManifest("rec-failed");
120
- expect(manifest?.status).toBe("failed");
119
+ expect(manifest?.status).toBe("closed");
121
120
  });
122
121
 
123
122
  it("manifest write 抛错时 cleanup-first 顺序仍执行(Step 3 before Step 4 throw)", async () => {
@@ -148,7 +147,7 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
148
147
 
149
148
  // ── 核心 claim 1:不抛错(cleanup-first → manifest write 失败不 throw)──
150
149
  await expect(
151
- doFinalizeRecord(deps, record, makeMinimalResult(), "done"),
150
+ doFinalizeRecord(deps, record, makeMinimalResult(), "closed"),
152
151
  ).resolves.toBeUndefined();
153
152
 
154
153
  // ── 核心 claim 2:Step 3 cleanup 先执行 —— finalized marker 真实写入 ──
@@ -158,7 +157,7 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
158
157
  expect(fs.existsSync(`${sessionFile}.alive`)).toBe(false);
159
158
 
160
159
  // ── 核心 claim 4:pending-notifications 注销仍触发(emitUnregister)──
161
- expect(deps.emitUnregister).toHaveBeenCalledWith("rec-cleanup-first", "done");
160
+ expect(deps.emitUnregister).toHaveBeenCalledWith("rec-cleanup-first", "closed");
162
161
 
163
162
  // ── 核心 claim 5:manifest 写失败被 logger.error 记录(含 record id + error)──
164
163
  expect(loggerMock.error).toHaveBeenCalledWith(expect.stringContaining("manifest 写入失败"));
@@ -184,3 +183,186 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
184
183
  loggerMock.error.mockClear();
185
184
  });
186
185
  });
186
+
187
+ // ============================================================
188
+ // doFinalizeRoundToIdle — chatMode 轮次完成进 idle(M2-A)
189
+ // ============================================================
190
+
191
+ describe("doFinalizeRoundToIdle — chatMode 轮次完成进 idle (M2-A)", () => {
192
+ let tmpDir: string;
193
+ let manifestStore: ManifestStore;
194
+
195
+ beforeEach(() => {
196
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "finalize-idle-test-"));
197
+ manifestStore = new ManifestStore(tmpDir);
198
+ });
199
+
200
+ afterEach(() => {
201
+ fs.rmSync(tmpDir, { recursive: true, force: true });
202
+ });
203
+
204
+ /** 构造 FinalizeDeps:worktreeManager.cleanup / store.archive 为 vi.fn 以断言「不调」。 */
205
+ function makeDeps() {
206
+ return {
207
+ manifestStore,
208
+ worktreeManager: { cleanup: vi.fn(), collectPatch: vi.fn() } as never,
209
+ store: { archive: vi.fn(), reportRecordTransition: vi.fn() } as never,
210
+ modelService: {} as never,
211
+ pi: { appendEntry: vi.fn() } as never,
212
+ emitUnregister: vi.fn(),
213
+ };
214
+ }
215
+
216
+ it("record 带 sessionFile → 删 .alive + record.status=running + round 0→1", async () => {
217
+ const sessionFile = path.join(tmpDir, "session.jsonl");
218
+ // 预写 .alive marker(验证 doFinalizeRoundToIdle 删除它——进程已回收)
219
+ fs.writeFileSync(
220
+ `${sessionFile}.alive`,
221
+ `${JSON.stringify({ pid: 99999, id: "rec-idle", startedAt: 1000 })}\n`,
222
+ "utf-8",
223
+ );
224
+ const record = makeMinimalRecord({ id: "rec-idle", sessionFile, chatMode: true, round: 0 });
225
+ // tryTransition 已把 status 设为 done,模拟 runAndFinalize 调用前的状态
226
+ record.status = "closed";
227
+
228
+ await doFinalizeRoundToIdle(makeDeps(), record, makeMinimalResult());
229
+
230
+ // 状态机(v4 B-1:旧 idle 折入 running,finalizeRoundToIdle 设 running)
231
+ expect(record.status).toBe("running");
232
+ expect(record.round).toBe(1);
233
+ // .alive marker 被删(进程已 SIGTERM 回收)
234
+ expect(fs.existsSync(`${sessionFile}.alive`)).toBe(false);
235
+ });
236
+
237
+ it("W16: 轮终上报 reportRecordTransition(record-store 类外恢复写点迁移落 entry)", async () => {
238
+ const deps = makeDeps();
239
+ const record = makeMinimalRecord({ id: "rec-report", chatMode: true, round: 2 });
240
+ record.status = "closed";
241
+ await doFinalizeRoundToIdle(deps, record, makeMinimalResult());
242
+ expect(deps.store.reportRecordTransition).toHaveBeenCalledTimes(1);
243
+ // 上报发生在 round 推进之后(entry 携带新轮计数,重建源不滞后)
244
+ expect(record.round).toBe(3);
245
+ expect(record.status).toBe("running");
246
+ });
247
+
248
+ it("record.round 已为 N → round 变 N+1", async () => {
249
+ const record = makeMinimalRecord({ id: "rec-round", round: 3 });
250
+ record.status = "closed";
251
+ await doFinalizeRoundToIdle(makeDeps(), record, makeMinimalResult());
252
+ expect(record.round).toBe(4);
253
+ expect(record.status).toBe("running");
254
+ });
255
+
256
+ it("不调 store.archive(record 留内存,getMutable 仍可查)", async () => {
257
+ const deps = makeDeps();
258
+ const record = makeMinimalRecord({ id: "rec-noarchive" });
259
+ record.status = "closed";
260
+ await doFinalizeRoundToIdle(deps, record, makeMinimalResult());
261
+ expect(deps.store.archive).not.toHaveBeenCalled();
262
+ });
263
+
264
+ it("不 cleanup worktree(即使 record 带 worktreeHandle——保留对话模式工作目录)", async () => {
265
+ const deps = makeDeps();
266
+ const record = makeMinimalRecord({
267
+ id: "rec-noworktree",
268
+ worktreeHandle: { path: "/tmp/x", branch: "b", baseCommit: "c", mainCwd: "/tmp" } as never,
269
+ });
270
+ record.status = "closed";
271
+ await doFinalizeRoundToIdle(deps, record, makeMinimalResult());
272
+ expect(deps.worktreeManager.cleanup).not.toHaveBeenCalled();
273
+ });
274
+
275
+ it("emitUnregister 被调(status=running,进程已死从 pending 活跃差集移除)", async () => {
276
+ const deps = makeDeps();
277
+ const record = makeMinimalRecord({ id: "rec-emit" });
278
+ record.status = "closed";
279
+ await doFinalizeRoundToIdle(deps, record, makeMinimalResult());
280
+ expect(deps.emitUnregister).toHaveBeenCalledWith("rec-emit", "running");
281
+ });
282
+
283
+ it("不调 completeRecord:record 不冻结(endedAt / agentResult 仍 undefined)", async () => {
284
+ const record = makeMinimalRecord({ id: "rec-nofreeze" });
285
+ record.status = "closed";
286
+ await doFinalizeRoundToIdle(makeDeps(), record, makeMinimalResult());
287
+ expect(record.endedAt).toBeUndefined();
288
+ expect(record.agentResult).toBeUndefined();
289
+ });
290
+
291
+ it("不写 manifest(idle 非终态,manifest 是终态诊断辅助)", async () => {
292
+ const record = makeMinimalRecord({ id: "rec-nomanifest" });
293
+ record.status = "closed";
294
+ await doFinalizeRoundToIdle(makeDeps(), record, makeMinimalResult());
295
+ const manifest = await manifestStore.readManifest("rec-nomanifest");
296
+ expect(manifest).toBeNull();
297
+ });
298
+
299
+ it("MF-2: record.result 设为 result.text(否则 notifier idle 回复恒为 (empty),G1/G2 不成立)", async () => {
300
+ const record = makeMinimalRecord({ id: "rec-result" });
301
+ record.status = "closed";
302
+ const result = makeMinimalResult();
303
+ result.text = "review done, found 3 issues";
304
+ await doFinalizeRoundToIdle(makeDeps(), record, result);
305
+ // MF-2:record.result 被 result.text 填充,notifier idle 分支读取后携带回复正文
306
+ expect(record.result).toBe("review done, found 3 issues");
307
+ });
308
+
309
+ it("MF-2 兑底:失败轮次 result.text 为空时用 result.error 填 record.result(MF-6 回退 idle 路径)", async () => {
310
+ const record = makeMinimalRecord({ id: "rec-result-err" });
311
+ record.status = "closed";
312
+ const result = makeMinimalResult();
313
+ result.text = "";
314
+ result.success = false;
315
+ result.error = "spawn timeout";
316
+ await doFinalizeRoundToIdle(makeDeps(), record, result);
317
+ // 失败轮次的 notify 需可读:result.text 空 → 兑底用 error
318
+ expect(record.result).toBe("round did not complete: spawn timeout");
319
+ });
320
+
321
+ it("C1TC10: chatMode 空增量轮占位——record.result 固定 (no output this round),不含上一轮文本(D5)", async () => {
322
+ const record = makeMinimalRecord({ id: "rec-increment-empty", chatMode: true });
323
+ record.status = "closed";
324
+ // 预置上一轮通知文本(模拟增量语义前的 record.result 残留)
325
+ record.result = "PREV-ROUND-TEXT";
326
+ const result = makeMinimalResult();
327
+ result.text = "";
328
+ result.success = true;
329
+ result.error = undefined;
330
+ await doFinalizeRoundToIdle(makeDeps(), record, result);
331
+ // 空增量轮通知不含上一轮文本:沿用旧值会让父 agent 误读为原样重复回复(D5 判定)
332
+ expect(record.result).toBe("(no output this round)");
333
+ expect(record.result).not.toContain("PREV-ROUND-TEXT");
334
+ });
335
+
336
+ it("C1TC11 [R2-1]: one-shot 首轮空文本成功完成 → record.result 补占位「(empty)」(非 undefined)", async () => {
337
+ const record = makeMinimalRecord({ id: "rec-oneshot-empty", chatMode: false });
338
+ record.status = "closed";
339
+ // one-shot 成功空文本完成路径(collectResult getFullText 返回 ""、success=true):
340
+ // result 前值 undefined
341
+ record.result = undefined;
342
+ const result = makeMinimalResult();
343
+ result.text = "";
344
+ result.success = true;
345
+ result.error = undefined;
346
+ await doFinalizeRoundToIdle(makeDeps(), record, result);
347
+ // [R2-1] 轮终信号优先:占位非 undefined 是 renderer hasRunning 排除轮终 running 的
348
+ // 判据——保持 undefined 会让完成注入后末位 turn 永久「工作中」
349
+ expect(record.result).toBe("(empty)");
350
+ // notifier buildLlmContent 的 record.result ?? "(empty)" 兜底同款措辞:
351
+ // 通知文案逐字节产出 "completed. Result:\n(empty)"(G4 保持)
352
+ expect(record.result ?? "(empty)").toBe("(empty)");
353
+ });
354
+
355
+ it("C1TC11b [R2-1]: one-shot 续轮空文本 → 沿用前值(one-shot 无增量语义,不覆盖为占位)", async () => {
356
+ const record = makeMinimalRecord({ id: "rec-oneshot-cont", chatMode: false });
357
+ record.status = "closed";
358
+ // 上一轮真实产出(续轮 record.result 前值)
359
+ record.result = "first round output";
360
+ const result = makeMinimalResult();
361
+ result.text = "";
362
+ result.success = true;
363
+ result.error = undefined;
364
+ await doFinalizeRoundToIdle(makeDeps(), record, result);
365
+ // one-shot record.result = 该 subagent 最终输出:空文本续轮沿用前值,不被 "(empty)" 覆盖
366
+ expect(record.result).toBe("first round output");
367
+ });
368
+ });
@@ -70,15 +70,11 @@ describe("statusGlyph", () => {
70
70
  });
71
71
 
72
72
  it("done → checkmark, success", () => {
73
- expect(statusGlyph("done")).toEqual({ icon: "✓", color: "success" });
73
+ expect(statusGlyph("closed")).toEqual({ icon: "✓", color: "success" });
74
74
  });
75
75
 
76
- it("failedcross, error", () => {
77
- expect(statusGlyph("failed")).toEqual({ icon: "", color: "error" });
78
- });
79
-
80
- it("cancelled → square, muted", () => {
81
- expect(statusGlyph("cancelled")).toEqual({ icon: "■", color: "muted" });
76
+ it("closedcheckmark, success", () => {
77
+ expect(statusGlyph("closed")).toEqual({ icon: "", color: "success" });
82
78
  });
83
79
  });
84
80
 
@@ -260,6 +256,134 @@ describe("truncLine", () => {
260
256
  });
261
257
  });
262
258
 
259
+ // ============================================================
260
+ // truncLine — legacy 对拍(indexOf 化重构的逐字节等价锚定)
261
+ // ============================================================
262
+
263
+ /**
264
+ * 旧实现参照:改造前的 per-char 版本,逐字取自 git 历史(093e28fe3~1 的
265
+ * format.ts truncLine)。现行实现(indexOf("\\x1b") 段跳过)必须与其逐字节
266
+ * 等价——本函数即对拍 oracle,与 helpers-bounded-serialize.test.ts 的
267
+ * legacySerialize 同形态(参照物随测试落盘,防「验证只存在于改造当时」)。
268
+ */
269
+ const legacySegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
270
+
271
+ function legacyTruncLine(text: string, maxWidth: number): string {
272
+ if (maxWidth <= 0) return "";
273
+ const flat = text.replace(/[\r\n]+/g, " ");
274
+ if (visibleWidth(flat) <= maxWidth) return flat;
275
+
276
+ const targetWidth = Math.max(0, maxWidth - 1);
277
+ let result = "";
278
+ let currentWidth = 0;
279
+ let activeStyles: string[] = [];
280
+ let i = 0;
281
+
282
+ while (i < flat.length) {
283
+ // 捕获 ANSI SGR 序列
284
+ const ansiMatch = flat.slice(i).match(/^\x1b\[[0-9;]*m/);
285
+ if (ansiMatch) {
286
+ const code = ansiMatch[0];
287
+ result += code;
288
+
289
+ if (code === "\x1b[0m" || code === "\x1b[m") {
290
+ activeStyles = []; // reset → 清空栈
291
+ } else {
292
+ activeStyles.push(code);
293
+ }
294
+ i += code.length;
295
+ continue;
296
+ }
297
+
298
+ // 找到下一段纯文本(非 ANSI)的边界
299
+ let end = i;
300
+ while (end < flat.length && !flat.slice(end).match(/^\x1b\[[0-9;]*m/)) {
301
+ end++;
302
+ }
303
+
304
+ // 按 grapheme 迭代这段文本,累加到 targetWidth
305
+ const textPortion = flat.slice(i, end);
306
+ for (const seg of legacySegmenter.segment(textPortion)) {
307
+ const grapheme = seg.segment;
308
+ const graphemeWidth = visibleWidth(grapheme);
309
+
310
+ if (currentWidth + graphemeWidth > targetWidth) {
311
+ return result + activeStyles.join("") + "…" + (activeStyles.length ? "\x1b[0m" : "");
312
+ }
313
+
314
+ result += grapheme;
315
+ currentWidth += graphemeWidth;
316
+ }
317
+ i = end;
318
+ }
319
+
320
+ // 理论上 visibleWidth 检查已提前返回,此行兜底
321
+ return result + activeStyles.join("") + "…" + (activeStyles.length ? "\x1b[0m" : "");
322
+ }
323
+
324
+ /** 确定性 PRNG(mulberry32):fuzz 用例固定 seed 生成,失败可按 case 编号 + seed 复现。 */
325
+ function mulberry32(seed: number): () => number {
326
+ let s = seed | 0;
327
+ return () => {
328
+ s = (s + 0x6d2b79f5) | 0;
329
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
330
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
331
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
332
+ };
333
+ }
334
+
335
+ /** 定向混排片段池:ASCII / CJK / emoji(含肤色修饰与 ZWJ 组合)/ SGR / 非 SGR ESC(OSC、CSI-K、裸 ESC)/ 换行。 */
336
+ const FUZZ_PIECES = [
337
+ "plain", " ", "a", "0", "MiXeD-Case_123",
338
+ "你好世界", "汉字宽度测试", "日本語テキスト",
339
+ "😀", "😁😂🤣", "👍🏽", "👨‍👩‍👧‍👦", "é", "é",
340
+ "\x1b[31m", "\x1b[0m", "\x1b[m", "\x1b[1;32m", "\x1b[38;5;196m",
341
+ "\x1b[K", "\x1b]0;title\x07", "\x1bZ", "\x1b",
342
+ "\n", "\r\n",
343
+ ];
344
+
345
+ function genFuzzText(rand: () => number): string {
346
+ const pieceCount = Math.floor(rand() * 40);
347
+ let out = "";
348
+ for (let p = 0; p < pieceCount; p++) {
349
+ out += FUZZ_PIECES[Math.floor(rand() * FUZZ_PIECES.length)];
350
+ }
351
+ return out;
352
+ }
353
+
354
+ describe("truncLine — legacy 对拍(per-char 参照逐字节等价)", () => {
355
+ it("定向 fuzz:300 随机 ESC/SGR/CJK/emoji 混排用例(固定 seed 可复现)与旧实现逐字节一致", () => {
356
+ const rand = mulberry32(20260815);
357
+ let truncated = 0;
358
+ for (let i = 0; i < 300; i++) {
359
+ const text = genFuzzText(rand);
360
+ const maxWidth = Math.floor(rand() * 61); // 0..60,含 0/1 边界
361
+ const actual = truncLine(text, maxWidth);
362
+ expect(
363
+ actual,
364
+ `case #${i} text=${JSON.stringify(text)} maxWidth=${maxWidth}`,
365
+ ).toBe(legacyTruncLine(text, maxWidth));
366
+ if (actual.endsWith("…")) truncated++;
367
+ }
368
+ // 前置有效性:fuzz 确实命中了截断路径(否则对拍只测了「宽度内原样返回」早退分支)
369
+ expect(truncated).toBeGreaterThan(100);
370
+ });
371
+
372
+ it("手工对抗用例:SGR 重叠 / OSC 内截断 / CSI-K 混排 / 串尾裸 ESC", () => {
373
+ const cases: Array<[string, number]> = [
374
+ ["\x1b[31m红\x1b[32m绿\x1b[1;4m加粗下划线尾巴超宽截断点", 9],
375
+ ["AB\x1b]0;title\x07CDEFGHIJKLMNOP", 10],
376
+ ["\x1b[31mred\x1b[K\x1b[32mgreen tail past width", 10],
377
+ ["text almost at width limit\x1b", 22],
378
+ ["before \x1bZ after bare esc tail", 12],
379
+ ["\x1b[m你\x1b[0m好\x1b[38;5;196m世👨‍👩‍👧‍👦界".repeat(3), 14],
380
+ ];
381
+ for (const [text, maxWidth] of cases) {
382
+ expect(truncLine(text, maxWidth)).toBe(legacyTruncLine(text, maxWidth));
383
+ }
384
+ });
385
+ });
386
+
263
387
  // ============================================================
264
388
  // wrapText
265
389
  // ============================================================
@@ -0,0 +1,184 @@
1
+ // src/execution/__tests__/gc-timer.test.ts
2
+ //
3
+ // [M8] idle record GC 定时器测试(subagent-service.ts startGcTimer,L485-507)。
4
+ //
5
+ // 背景:GC 定时器(1h interval 扫描 + isResumable && idleSince 30 天 TTL + store.archive)
6
+ // 此前零测试覆盖——所有相关测试把 startGcTimer mock 成 no-op(index-session-start /
7
+ // crash-recovery / stream-sink-guard / session-start-reaper / index-session-start-identity)。
8
+ // 误判 isResumable/idleSince 会把可续聊 record 从内存错误归档(用户数据路径),
9
+ // 或永不触发(内存驻留失效)——两个方向都无回归拦截。
10
+ //
11
+ // 本文件用真实 SubagentService + 真实 startGcTimer(不 mock GC 逻辑本身),仅 mock
12
+ // session-runner(import 链依赖 + isResumable 的 hasLiveProcessHandle 查询点)与 logger。
13
+ // timer 全部 vi.useFakeTimers,不真实等待。
14
+
15
+ import type { ChildProcess } from "node:child_process";
16
+ import * as fs from "node:fs";
17
+ import * as os from "node:os";
18
+ import * as path from "node:path";
19
+
20
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
21
+
22
+ const { loggerMock } = vi.hoisted(() => ({
23
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() },
24
+ }));
25
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({ getLogger: () => loggerMock }));
26
+
27
+ // mock session-runner:runSpawn/killAllSpawnedChildren 占位(import 链需要),
28
+ // getChildByRecord 默认返回 undefined(无活进程 → isResumable=true),用例内按需覆盖。
29
+ vi.mock("../session-runner.ts", () => ({
30
+ runSpawn: vi.fn(),
31
+ killAllSpawnedChildren: vi.fn(),
32
+ getChildByRecord: vi.fn(() => undefined),
33
+ spawnedChildren: new Map(),
34
+ }));
35
+
36
+ import { getChildByRecord } from "../session-runner.ts";
37
+ import { createRecord } from "../execution-record.ts";
38
+ import { ModelConfigService } from "../model-config-service.ts";
39
+ import { RecordStore } from "../record-store.ts";
40
+ import { SubagentService } from "../subagent-service.ts";
41
+ import type { ExecutionRecord } from "../types.ts";
42
+
43
+ const mockGetChildByRecord = vi.mocked(getChildByRecord);
44
+
45
+ /** 与 startGcTimer 内部常量一致(1h 扫描 / 30 天 TTL)。 */
46
+ const GC_INTERVAL_MS = 60 * 60 * 1000;
47
+ const IDLE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
48
+
49
+ /** initSession 注入的最小 pi duck-type(同 subagent-service PiLike 形状,结构匹配即可)。 */
50
+ interface PiStub {
51
+ appendEntry(customType: string, data?: unknown): void;
52
+ events: { emit(channel: string, data: unknown): void };
53
+ sendMessage(
54
+ message: { customType: string; content: string; display: boolean; details?: unknown },
55
+ options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
56
+ ): void;
57
+ }
58
+
59
+ function makePi(): PiStub {
60
+ return {
61
+ appendEntry: vi.fn(),
62
+ events: { emit: vi.fn() },
63
+ sendMessage: vi.fn(),
64
+ };
65
+ }
66
+
67
+ /** 构造 running + 无活进程 + 指定 idleSince 的 record(GC 扫描的目标态)。
68
+ * listAllActive 只返回 status==="running"(createRecord 初始即 running)。 */
69
+ function makeIdleRecord(id: string, idleSince: number): ExecutionRecord {
70
+ const record = createRecord(id, {
71
+ agent: "general-purpose",
72
+ model: "test/model",
73
+ mode: "background",
74
+ task: "test",
75
+ slug: "test",
76
+ startedAt: idleSince,
77
+ rootSessionId: "root-session",
78
+ chatMode: true,
79
+ controller: new AbortController(),
80
+ });
81
+ record.idleSince = idleSince;
82
+ return record;
83
+ }
84
+
85
+ /** 暴露私有字段供测试读取 store。 */
86
+ interface ServiceInternals {
87
+ store: RecordStore;
88
+ }
89
+
90
+ describe("[M8] idle record GC 定时器(startGcTimer)", () => {
91
+ let agentDir: string;
92
+ let service: SubagentService;
93
+ let store: RecordStore;
94
+
95
+ beforeEach(() => {
96
+ vi.useFakeTimers();
97
+ agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "gc-timer-"));
98
+ const modelService = new ModelConfigService({ agentDir, cwd: agentDir });
99
+ service = new SubagentService({ cwd: agentDir, modelService });
100
+ service.initSession({ pi: makePi(), sessionId: "root-session" });
101
+ store = (service as unknown as ServiceInternals).store;
102
+ mockGetChildByRecord.mockReset();
103
+ // 默认无活进程(isResumable=true)——Path B:进程已回收、可冷路径 resume 的等待续聊态
104
+ mockGetChildByRecord.mockImplementation(() => undefined);
105
+ loggerMock.warn.mockClear();
106
+ });
107
+
108
+ afterEach(() => {
109
+ service.dispose();
110
+ fs.rmSync(agentDir, { recursive: true, force: true });
111
+ vi.useRealTimers();
112
+ });
113
+
114
+ it("TTL 边界:超 1ms 归档、差 1ms 不归档(同一轮扫描)", () => {
115
+ // idleSince 相对 interval 首次触发时刻(fake clock + 1h)构造:
116
+ // over = fireAt - TTL - 1 → 扫描时 age = TTL + 1 > TTL → archive
117
+ // under = fireAt - TTL + 1 → 扫描时 age = TTL - 1 ≤ TTL → 留内存
118
+ const fireAt = Date.now() + GC_INTERVAL_MS;
119
+ store.register(makeIdleRecord("sa-over", fireAt - IDLE_TTL_MS - 1));
120
+ store.register(makeIdleRecord("sa-under", fireAt - IDLE_TTL_MS + 1));
121
+
122
+ service.startGcTimer();
123
+ vi.advanceTimersByTime(GC_INTERVAL_MS);
124
+
125
+ expect(store.getMutable("sa-over")).toBeUndefined(); // archive 从内存移除
126
+ expect(store.getMutable("sa-under")).toBeDefined(); // 未超 TTL 留内存
127
+ expect(loggerMock.warn).toHaveBeenCalledTimes(1);
128
+ expect(loggerMock.warn).toHaveBeenCalledWith(
129
+ expect.stringContaining("GC: archiving idle record sa-over"),
130
+ );
131
+ });
132
+
133
+ it("有活进程的 record 不归档(isResumable=false,即使 idleSince 超 TTL)", () => {
134
+ const fireAt = Date.now() + GC_INTERVAL_MS;
135
+ // Path A:进程保活等待续聊(idle timer armed、child 未 kill)——归档会让活进程失管
136
+ store.register(makeIdleRecord("sa-live", fireAt - IDLE_TTL_MS - 1));
137
+ mockGetChildByRecord.mockImplementation(
138
+ (id: string): ChildProcess | undefined =>
139
+ id === "sa-live" ? ({ killed: false } as unknown as ChildProcess) : undefined,
140
+ );
141
+
142
+ service.startGcTimer();
143
+ vi.advanceTimersByTime(GC_INTERVAL_MS);
144
+
145
+ expect(store.getMutable("sa-live")).toBeDefined(); // 有活进程 → 不归档
146
+ expect(loggerMock.warn).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it("每小时重复扫描:上一轮差 1ms 的 record 下一轮超龄被归档", () => {
150
+ const fireAt = Date.now() + GC_INTERVAL_MS;
151
+ store.register(makeIdleRecord("sa-late", fireAt - IDLE_TTL_MS + 1));
152
+
153
+ service.startGcTimer();
154
+ vi.advanceTimersByTime(GC_INTERVAL_MS);
155
+ expect(store.getMutable("sa-late")).toBeDefined(); // 第一轮:age = TTL - 1,未到
156
+
157
+ vi.advanceTimersByTime(GC_INTERVAL_MS); // 第二轮:age = TTL + 1h - 1 > TTL
158
+ expect(store.getMutable("sa-late")).toBeUndefined();
159
+ });
160
+
161
+ it("startGcTimer 幂等:重复调用不产生第二个 interval", () => {
162
+ const fireAt = Date.now() + GC_INTERVAL_MS;
163
+ store.register(makeIdleRecord("sa-over", fireAt - IDLE_TTL_MS - 1));
164
+
165
+ service.startGcTimer();
166
+ service.startGcTimer(); // 已有 timer 直接 return(防重复 interval)
167
+ vi.advanceTimersByTime(GC_INTERVAL_MS);
168
+
169
+ // 同一 record 一轮只扫一次(重复 interval 会对同一 record 产生第二条 GC warn)
170
+ expect(loggerMock.warn).toHaveBeenCalledTimes(1);
171
+ });
172
+
173
+ it("dispose 后 interval 停止:超 TTL record 不再被归档", () => {
174
+ service.startGcTimer();
175
+ vi.advanceTimersByTime(GC_INTERVAL_MS); // 第一轮正常触发
176
+ service.dispose(); // stopGcTimer + disposeAllRecords
177
+
178
+ // dispose 后塞入超 TTL record,interval 已停 → 无论推进多久都不归档
179
+ store.register(makeIdleRecord("sa-after-dispose", Date.now() - IDLE_TTL_MS - 1));
180
+ vi.advanceTimersByTime(GC_INTERVAL_MS * 10);
181
+
182
+ expect(store.getMutable("sa-after-dispose")).toBeDefined();
183
+ });
184
+ });