@zhushanwen/pi-subagent-workflow 8.4.0 → 8.6.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 (171) hide show
  1. package/package.json +22 -7
  2. package/relay/relay.mjs +390 -0
  3. package/skills/subagent-ext-config/SKILL.md +80 -0
  4. package/src/execution/__tests__/agent-registry.test.ts +110 -0
  5. package/src/execution/__tests__/bg-notify-render.test.ts +73 -0
  6. package/src/execution/__tests__/chat-engine-routing.test.ts +601 -0
  7. package/src/execution/__tests__/delivery-methods.test.ts +38 -1
  8. package/src/execution/__tests__/execute-options-mapper.test.ts +11 -0
  9. package/src/execution/__tests__/execution-record.test.ts +237 -1
  10. package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +171 -0
  11. package/src/execution/__tests__/format-schema-instruction.test.ts +63 -32
  12. package/src/execution/__tests__/helpers/spawn-mock.ts +4 -0
  13. package/src/execution/__tests__/index-session-start.test.ts +86 -7
  14. package/src/execution/__tests__/lifecycle-manager.test.ts +46 -0
  15. package/src/execution/__tests__/list-fields.test.ts +45 -14
  16. package/src/execution/__tests__/model-resolver.test.ts +57 -5
  17. package/src/execution/__tests__/notifier-flush.test.ts +64 -26
  18. package/src/execution/__tests__/notify-ledger.test.ts +826 -0
  19. package/src/execution/__tests__/output-collector.test.ts +299 -2
  20. package/src/execution/__tests__/pi-invocation.test.ts +62 -1
  21. package/src/execution/__tests__/relay-agent.test.ts +448 -0
  22. package/src/execution/__tests__/relay-env.test.ts +42 -0
  23. package/src/execution/__tests__/rpc-mode.test.ts +1 -1
  24. package/src/execution/__tests__/run-spawn-edges.test.ts +44 -1
  25. package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +199 -0
  26. package/src/execution/__tests__/session-runner-schema-env.test.ts +39 -0
  27. package/src/execution/__tests__/spawn-args.test.ts +37 -26
  28. package/src/execution/__tests__/start-sync-model-guard.test.ts +150 -0
  29. package/src/execution/__tests__/startup-config-declaration.test.ts +35 -0
  30. package/src/execution/__tests__/stream-sink-retirement.test.ts +261 -0
  31. package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +310 -0
  32. package/src/execution/__tests__/subprocess-agent-runner.test.ts +147 -6
  33. package/src/execution/__tests__/timeout-integration.test.ts +220 -2
  34. package/src/execution/__tests__/tool-action.test.ts +92 -1
  35. package/src/execution/agent-registry.ts +16 -0
  36. package/src/execution/argv-mirror.ts +5 -1
  37. package/src/execution/concurrency-pool.ts +1 -1
  38. package/src/execution/config.ts +25 -2
  39. package/src/execution/engine/__tests__/common/data-dir.test.ts +53 -0
  40. package/src/execution/engine/__tests__/common/errors.test.ts +132 -0
  41. package/src/execution/engine/__tests__/common/event-journal.test.ts +177 -0
  42. package/src/execution/engine/__tests__/common/kill-chain.test.ts +192 -0
  43. package/src/execution/engine/__tests__/common/nesting-guard.test.ts +81 -0
  44. package/src/execution/engine/__tests__/common/persona-router.test.ts +123 -0
  45. package/src/execution/engine/__tests__/common/pool-manager.test.ts +154 -0
  46. package/src/execution/engine/__tests__/common/schema-emulation.test.ts +128 -0
  47. package/src/execution/engine/__tests__/conformance/__fixtures__/pi-golden-events.json +28 -0
  48. package/src/execution/engine/__tests__/conformance/agent-event-invariants.ts +141 -0
  49. package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +109 -0
  50. package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +101 -0
  51. package/src/execution/engine/__tests__/conformance/contract.probe.test.ts +77 -0
  52. package/src/execution/engine/__tests__/conformance/contract.read-degradation.test.ts +104 -0
  53. package/src/execution/engine/__tests__/conformance/contract.relay.test.ts +342 -0
  54. package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +201 -0
  55. package/src/execution/engine/__tests__/conformance/golden-replay.pi.test.ts +76 -0
  56. package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +79 -0
  57. package/src/execution/engine/__tests__/engine-discovery.test.ts +87 -0
  58. package/src/execution/engine/__tests__/engines-declaration.test.ts +36 -0
  59. package/src/execution/engine/__tests__/model-prompt.test.ts +85 -0
  60. package/src/execution/engine/__tests__/paths.test.ts +39 -0
  61. package/src/execution/engine/__tests__/registry.test.ts +120 -0
  62. package/src/execution/engine/__tests__/routing.test.ts +231 -0
  63. package/src/execution/engine/common/data-dir.ts +62 -0
  64. package/src/execution/engine/common/errors.ts +183 -0
  65. package/src/execution/engine/common/event-journal.ts +254 -0
  66. package/src/execution/engine/common/journal-replay.ts +62 -0
  67. package/src/execution/engine/common/kill-chain.ts +221 -0
  68. package/src/execution/engine/common/nesting-guard.ts +50 -0
  69. package/src/execution/engine/common/persona-router.ts +108 -0
  70. package/src/execution/engine/common/pool-manager.ts +226 -0
  71. package/src/execution/engine/common/schema-emulation.ts +189 -0
  72. package/src/execution/engine/common/session-view-projection.ts +51 -0
  73. package/src/execution/engine/engine-discovery.ts +65 -0
  74. package/src/execution/engine/engines/pi/__tests__/pi-engine.test.ts +469 -0
  75. package/src/execution/engine/engines/pi/__tests__/reader.test.ts +155 -0
  76. package/src/execution/engine/engines/pi/__tests__/task-spec-mapper.test.ts +164 -0
  77. package/src/execution/engine/engines/pi/pi-engine.ts +415 -0
  78. package/src/execution/engine/engines/pi/reader.ts +48 -0
  79. package/src/execution/engine/engines/pi/registration.ts +35 -0
  80. package/src/execution/engine/engines/pi/task-spec-mapper.ts +100 -0
  81. package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-spawn.json +39 -0
  82. package/src/execution/engine/engines/zcode/__tests__/launcher.test.ts +150 -0
  83. package/src/execution/engine/engines/zcode/__tests__/parser.test.ts +246 -0
  84. package/src/execution/engine/engines/zcode/__tests__/preparer.test.ts +228 -0
  85. package/src/execution/engine/engines/zcode/__tests__/reader.test.ts +210 -0
  86. package/src/execution/engine/engines/zcode/__tests__/registration.test.ts +64 -0
  87. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +127 -0
  88. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +580 -0
  89. package/src/execution/engine/engines/zcode/constants.ts +43 -0
  90. package/src/execution/engine/engines/zcode/golden-sample.ts +39 -0
  91. package/src/execution/engine/engines/zcode/launcher.ts +161 -0
  92. package/src/execution/engine/engines/zcode/parser.ts +436 -0
  93. package/src/execution/engine/engines/zcode/preparer.ts +363 -0
  94. package/src/execution/engine/engines/zcode/reader.ts +381 -0
  95. package/src/execution/engine/engines/zcode/registration.ts +37 -0
  96. package/src/execution/engine/engines/zcode/zcode-engine.ts +658 -0
  97. package/src/execution/engine/host-task-spec.ts +47 -0
  98. package/src/execution/engine/model-prompt.ts +59 -0
  99. package/src/execution/engine/paths.ts +42 -0
  100. package/src/execution/engine/port.ts +153 -0
  101. package/src/execution/engine/registry.ts +123 -0
  102. package/src/execution/engine/routing.ts +218 -0
  103. package/src/execution/engine/types.ts +309 -0
  104. package/src/execution/execute-options-mapper.ts +13 -8
  105. package/src/execution/execution-record.ts +66 -1
  106. package/src/execution/lifecycle-manager.ts +23 -1
  107. package/src/execution/model-config-service.ts +16 -1
  108. package/src/execution/model-resolver.ts +37 -59
  109. package/src/execution/notifier.ts +105 -35
  110. package/src/execution/notify-ledger.ts +580 -0
  111. package/src/execution/output-collector.ts +143 -3
  112. package/src/execution/pi-invocation.ts +32 -2
  113. package/src/execution/record-entry.ts +14 -0
  114. package/src/execution/record-store.ts +34 -0
  115. package/src/execution/relay-env.ts +37 -0
  116. package/src/execution/session-runner.ts +328 -71
  117. package/src/execution/stream-sink.ts +26 -0
  118. package/src/execution/subagent-service.ts +273 -13
  119. package/src/execution/subprocess-agent-runner.ts +210 -14
  120. package/src/execution/types.ts +124 -5
  121. package/src/execution/ui-request-queue.ts +14 -4
  122. package/src/index.ts +99 -1
  123. package/src/interface/__tests__/subagent-tool-path-guard.test.ts +157 -0
  124. package/src/interface/__tests__/subagent-tool-prompt.test.ts +12 -0
  125. package/src/interface/bg-notify-render.ts +33 -12
  126. package/src/interface/helpers.ts +2 -2
  127. package/src/interface/subagent-actions.ts +29 -9
  128. package/src/interface/subagent-tool-schema.ts +156 -0
  129. package/src/interface/subagent-tool.ts +56 -119
  130. package/src/interface/subagents.ts +2 -2
  131. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +19 -3
  132. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +0 -6
  133. package/src/orchestration/__tests__/agent-call-stream.test.ts +0 -5
  134. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +89 -4
  135. package/src/orchestration/__tests__/execute-agent-call.test.ts +137 -0
  136. package/src/orchestration/__tests__/jsonl-run-store-corrupt-entry.test.ts +150 -0
  137. package/src/orchestration/__tests__/jsonl-run-store-retention.test.ts +202 -0
  138. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +326 -3
  139. package/src/orchestration/__tests__/lifecycle.test.ts +41 -7
  140. package/src/orchestration/__tests__/non-cloneable-return-e2e.test.ts +95 -0
  141. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +57 -3
  142. package/src/orchestration/__tests__/skill-discovery.test.ts +44 -0
  143. package/src/orchestration/__tests__/worker-exit-without-result.test.ts +368 -0
  144. package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +43 -0
  145. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +22 -3
  146. package/src/orchestration/agent-opts-resolver.ts +104 -23
  147. package/src/orchestration/error-recovery.ts +189 -33
  148. package/src/orchestration/execute-agent-call.ts +39 -0
  149. package/src/orchestration/jsonl-run-store.ts +121 -7
  150. package/src/orchestration/launcher.ts +60 -15
  151. package/src/orchestration/lifecycle.ts +10 -7
  152. package/src/orchestration/models/__tests__/budget.test.ts +1 -61
  153. package/src/orchestration/models/budget.ts +5 -35
  154. package/src/orchestration/models/run-runtime.ts +24 -9
  155. package/src/orchestration/models/types.ts +16 -0
  156. package/src/orchestration/script-lint.ts +1 -1
  157. package/src/orchestration/skill-discovery.ts +31 -8
  158. package/src/orchestration/worker-script-builder.ts +19 -3
  159. package/src/shared/__tests__/model-ref.test.ts +306 -0
  160. package/src/shared/__tests__/schema-jsonify.test.ts +1 -1
  161. package/src/shared/__tests__/timer-delay.test.ts +61 -0
  162. package/src/shared/meta-parser.ts +5 -1
  163. package/src/shared/model-ref.ts +286 -0
  164. package/src/shared/resource-meta.ts +5 -0
  165. package/src/shared/schema-env.ts +44 -0
  166. package/src/shared/schema-jsonify.ts +6 -4
  167. package/src/shared/timer-delay.ts +54 -0
  168. package/workflows/review-fix-loop-utils.cjs +9 -7
  169. package/workflows/review-fix-loop.js +20 -12
  170. package/src/orchestration/__tests__/concurrency-gate.test.ts +0 -125
  171. package/src/orchestration/concurrency-gate.ts +0 -69
@@ -12,15 +12,16 @@
12
12
  * 重试矩阵(domain-models.md §失败处理矩阵):
13
13
  * - worker error/exit(非零)→ 3 次重试 + 指数退避 1s/2s/4s;超限 failed
14
14
  * - script error → 3 次重试 + 指数退避;超限 failed
15
- * - 重试前 rebuildRuntime(G3-001:整个 RunRuntime 重建:worker+gate+controller)
15
+ * - 重试前 rebuildRuntime(G3-001:整个 RunRuntime 重建:worker+controller)
16
16
  *
17
17
  * 关键不变式:
18
- * - 重试前必须 rebuildRuntime(worker+gate+controller 整体重建,避免孤儿资源)。
18
+ * - 重试前必须 rebuildRuntime(worker+controller 整体重建,避免孤儿资源)。
19
19
  * - 重试计数载体是 run.meta.workerErrorCount/scriptErrorCount(跨 runtime 存活,
20
20
  * retry replaceRuntime 后计数不丢)。
21
21
  * - handleWorkerExit 检查 handle.isCurrent(G-025:stale exit 事件丢弃)。
22
22
  *
23
- * 层归属:Engine。依赖 ports + ConcurrencyGate + WorkflowRun + executeAgentCall。
23
+ * 层归属:Engine。依赖 ports + WorkflowRun + executeAgentCall。
24
+ * (旧并发门闩 gate 抽象已删——no-op,实际并发由 SubagentService ConcurrencyPool 管理。)
24
25
  *
25
26
  * 参考:domain-models.md §失败处理矩阵。
26
27
  */
@@ -32,7 +33,6 @@ import { createRecord, updateFromEvent } from "../execution/execution-record.ts"
32
33
  import { SubagentStream } from "../execution/stream-sink.ts";
33
34
  import type { AgentEvent } from "../shared/agent-event.ts";
34
35
  import { resolveAgentOpts } from "./agent-opts-resolver.ts";
35
- import { ConcurrencyGate, DEFAULT_CONCURRENCY } from "./concurrency-gate.ts";
36
36
  import { executeAgentCall } from "./execute-agent-call.ts";
37
37
  import { AgentCall } from "./models/agent-call.ts";
38
38
  import type { LifecycleDeps, WorkerHandlers } from "./models/ports.ts";
@@ -66,6 +66,16 @@ const MAX_ERROR_LOGS = 500;
66
66
  /** malformed agent-call 日志中 opts JSON 的预览截断长度(字符)。 */
67
67
  const MALFORMED_MSG_LOG_PREVIEW_CHARS = 200;
68
68
 
69
+ /**
70
+ * [F1] worker 交付前退出(无终态消息)的归因文案。
71
+ *
72
+ * 最常见根因:execute() 返回值含 function/Symbol/循环引用等不可克隆成员 → worker 侧
73
+ * _safePost 吞掉 DataCloneError → return 消息从未发出 → worker exit(0)。旧实现
74
+ * handleWorkerExit 对 code===0 no-op → run 永久 running、runAndWait 悬挂。
75
+ */
76
+ const WORKER_EXITED_WITHOUT_RESULT_MSG =
77
+ "worker exited before delivering a result (return value may not be structured-cloneable)";
78
+
69
79
  // ── Worker 消息类型(与 infra/worker-script-builder.ts WorkerInMsg 对齐) ──
70
80
 
71
81
  interface AgentCallMsg {
@@ -136,6 +146,31 @@ function backoffDelay(retryIndex: number): number {
136
146
  return RETRY_BACKOFF_BASE_MS * Math.pow(EXPONENTIAL_BACKOFF_BASE, retryIndex - 1);
137
147
  }
138
148
 
149
+ /**
150
+ * [SW-DATA-3] store.save 尽力持久化:save 抛错(如 ENOSPC 磁盘满)不阻断状态机推进。
151
+ *
152
+ * save 失败若向上抛,handle* 的调用方(worker-host 绑定处 `void handlers.onXxx(...)`)
153
+ * 无人接 → unhandledRejection + 后续 pending:unregister / onRunDone 不执行 → pending
154
+ * 通知幽灵注销(列表残留永不清理的 running 条目)。catch 后记 error 日志,调用方继续
155
+ * emit/onRunDone(内存态已终态;落盘失败仅丢本次持久化快照,kill-9 恢复时残留 running
156
+ * 由 session_start 兜底转 failed)。
157
+ */
158
+ async function saveRunBestEffort(
159
+ run: WorkflowRun,
160
+ deps: LifecycleDeps,
161
+ context: string,
162
+ ): Promise<void> {
163
+ try {
164
+ await deps.store.save(run);
165
+ } catch (err) {
166
+ const m = err instanceof Error ? err.message : String(err);
167
+ logger.error(
168
+ `[workflow] store.save failed (${context}, runId=${run.runId}): ${m}. ` +
169
+ "Continuing state-machine finalization (in-memory state already terminal).",
170
+ );
171
+ }
172
+ }
173
+
139
174
  function delay(ms: number): Promise<void> {
140
175
  return new Promise((resolve) => {
141
176
  const timer = setTimeout(resolve, ms);
@@ -169,7 +204,56 @@ function discardInFlightCalls(run: WorkflowRun): number[] {
169
204
  }
170
205
 
171
206
  /**
172
- * 重建整个 RunRuntime:新 controller + 新 gate + 新 worker
207
+ * 计算 run 的剩余时间预算(ms)[race-F3]
208
+ *
209
+ * 未配置预算(budgetTimeMs 未设或 <=0,默认不限)返回 undefined;已配置时返回
210
+ * max(0, budgetTimeMs - 已耗墙钟),已耗墙钟从 run.meta.startedAt(ISO)推算——
211
+ * 含退避等待在内的全部 wall clock,重试不重置预算。startedAt 解析失败(损坏快照)
212
+ * 防御性按 0 已耗处理(给满额预算,不因元数据损坏提前杀 run)。
213
+ *
214
+ * 背景:rebuildRuntime 重排计时器原样用满额 budgetTimeMs——每吃一次 worker/script
215
+ * 错误重试就重置一次预算,最坏 6 次重试放大 ~6× 墙钟,时间预算对重试路径失效。
216
+ */
217
+ function remainingTimeBudgetMs(run: WorkflowRun): number | undefined {
218
+ const budget = run.spec.budgetTimeMs;
219
+ if (!budget || budget <= 0) return undefined;
220
+ const startedMs = Date.parse(run.meta.startedAt);
221
+ const elapsed = Number.isFinite(startedMs) ? Math.max(0, Date.now() - startedMs) : 0;
222
+ return Math.max(0, budget - elapsed);
223
+ }
224
+
225
+ /**
226
+ * 重试前发现时间预算已耗尽的收尾:不 rebuild,直接 done,time_limited 终态。
227
+ *
228
+ * 副作用与 handleWorkerError 超限路径对齐:transition + 持久化 + 注销
229
+ * pending-notification + onRunDone。transition 单独 try(M12)——并发 abort 导致
230
+ * illegal-transition 是预期的,可忽略。
231
+ */
232
+ async function finalizeTimeBudgetExhausted(run: WorkflowRun, deps: LifecycleDeps): Promise<void> {
233
+ deps.log?.("debug", "workflow:error-recovery", "time budget exhausted on rebuild, transition done", {
234
+ runId: run.runId,
235
+ budgetTimeMs: run.spec.budgetTimeMs,
236
+ });
237
+ run.state.error = run.state.error ?? `Time budget exhausted (${run.spec.budgetTimeMs} ms wall clock) before retry rebuild`;
238
+ let transitioned = false;
239
+ try {
240
+ run.transition("done", "time_limited");
241
+ transitioned = true;
242
+ } catch (te: unknown) {
243
+ // run 可能在检查后、transition 前被并发 abort——预期,不记错
244
+ void te;
245
+ }
246
+ if (!transitioned) return;
247
+ await deps.store.save(run).catch((e: unknown) => {
248
+ const m = e instanceof Error ? e.message : String(e);
249
+ logger.error(`[workflow] store.save failed (time budget exhausted): ${m}`);
250
+ });
251
+ deps.eventBus?.emit("pending:unregister", { id: run.runId, reason: run.state.reason ?? "time_limited" });
252
+ deps.onRunDone?.(run);
253
+ }
254
+
255
+ /**
256
+ * 重建整个 RunRuntime:新 controller + 新 worker。
173
257
  *
174
258
  * 调 run.replaceRuntime(newRt)(G5-001):原子释放旧 runtime(worker.terminate +
175
259
  * abort)+ 绑定新 runtime,全程 status==="running" 不变(不变式 I1 不违反)。
@@ -180,6 +264,10 @@ function discardInFlightCalls(run: WorkflowRun): number[] {
180
264
  *
181
265
  * 前置:run.state.status === "running"(replaceRuntime 要求,G6-001)。
182
266
  *
267
+ * [race-F3] 时间预算重排按剩余墙钟折算(remainingTimeBudgetMs),不再用满额——
268
+ * 否则每次错误重试都重置预算,最坏 6 次重试放大 ~6×。耗尽时的终态转移不在本函数
269
+ * (唯一生产调用方 scheduleRebuild 已前置拦截,见其注释)。
270
+ *
183
271
  * @throws status !== "running"(由 replaceRuntime 抛)
184
272
  */
185
273
  export function rebuildRuntime(
@@ -194,7 +282,6 @@ export function rebuildRuntime(
194
282
  budgetTimeMs: run.spec.budgetTimeMs,
195
283
  });
196
284
  const controller = new AbortController();
197
- const gate = new ConcurrencyGate({ maxConcurrency: DEFAULT_CONCURRENCY });
198
285
  const worker = deps.workerHost.start(run.spec, run.spec.args, handlers);
199
286
  // D-12 regression fix (round-2 #2):重新调度 run 级墙钟预算计时器。
200
287
  // replaceRuntime 释放旧 runtime 时 clearTimeout 了旧计时器(run-runtime.release),
@@ -204,15 +291,20 @@ export function rebuildRuntime(
204
291
  // 不影响无时间预算的 run)。
205
292
  // 重排分支改为 if——语义与原三元一致(同一条件调 scheduleTimeBudget),仅为在
206
293
  // 分支内记 L2 日志,控制流/异常语义零变化。
294
+ // [race-F3] 重排值改为剩余墙钟(remainingTimeBudgetMs)而非满额——重试不重置预算;
295
+ // L2 日志 payload 同步报实际重排值(排障时与 setTimeout 对得上)。remaining > 0
296
+ // 由调用方 scheduleRebuild 保证(耗尽在那里转 time_limited,不进本函数);本处
297
+ // remaining <= 0 时不挂 timer(防御直调,宁可不挂也不能挂出 0ms 立即触发)。
207
298
  let timeBudgetTimer: ReturnType<typeof setTimeout> | undefined;
208
- if (run.spec.budgetTimeMs && run.spec.budgetTimeMs > 0 && deps.scheduleTimeBudget) {
209
- timeBudgetTimer = deps.scheduleTimeBudget(run.runId, run.spec.budgetTimeMs);
299
+ const remainingBudgetMs = remainingTimeBudgetMs(run);
300
+ if (remainingBudgetMs !== undefined && remainingBudgetMs > 0 && deps.scheduleTimeBudget) {
301
+ timeBudgetTimer = deps.scheduleTimeBudget(run.runId, remainingBudgetMs);
210
302
  deps.log?.("debug", "workflow:error-recovery", "time budget rescheduled", {
211
303
  runId: run.runId,
212
- budgetTimeMs: run.spec.budgetTimeMs,
304
+ budgetTimeMs: remainingBudgetMs,
213
305
  });
214
306
  }
215
- run.replaceRuntime(new RunRuntime(worker, gate, controller, timeBudgetTimer));
307
+ run.replaceRuntime(new RunRuntime(worker, controller, timeBudgetTimer));
216
308
  // 清除被旧 runtime abort 的在飞 call——必须在 replaceRuntime 之后同步执行(无
217
309
  // await 间隔):replaceRuntime 同步 abort 旧 controller + terminate 旧 worker,
218
310
  // 在飞 executeAgentCall 的 finalize 发生在 `await runner.run` resolve 后的
@@ -270,10 +362,16 @@ export async function handleWorkerMessage(
270
362
  dispatchWorkflowCall(run, msg, deps);
271
363
  return;
272
364
  case "return":
365
+ // [F1] 标记本 runtime 代际已收到终态消息:WorkerHandle.isCurrent 守卫保证消息必
366
+ // 来自当前代际 worker。handleWorkerExit 的 exit(0) 无终态判定据此区分——
367
+ // 「已交付但 run 仍 running」(script-error 重试退避窗口)不得误判 failed。
368
+ if (run.runtime) run.runtime.receivedTerminalMessage = true;
273
369
  await handleReturn(run, msg, deps);
274
370
  return;
275
371
  case "error":
276
372
  // M1: 传 handlers(rebuildRuntime 需要)
373
+ // [F1] 同 return——error 也是终态消息,标记本代际已交付(同上防误判)。
374
+ if (run.runtime) run.runtime.receivedTerminalMessage = true;
277
375
  await handleScriptError(
278
376
  run,
279
377
  msg.error,
@@ -291,8 +389,9 @@ export async function handleWorkerMessage(
291
389
  * 异步触发(不 await)——立即返回,让 worker 能继续发后续 agent-call(parallel 场景)。
292
390
  * executeAgentCall 内部完成 markDone + trace.update。
293
391
  *
294
- * **C-3 修复**:executeAgentCall 通过 `run.runtime.gate.withSlot` 包装——gate 管并发
295
- * 上限(maxConcurrency=4)+ FIFO 排队,runner spawn。两层职责分离。
392
+ * **C-3 修复**:executeAgentCall dispatchCall 异步触发——原 gate.withSlot 包装已随
393
+ * 并发门闩 gate 抽象删除(no-op),并发调度归 SubagentService ConcurrencyPool,
394
+ * runner 管 spawn。
296
395
  *
297
396
  * **C-2 修复**:call 完成后检查 `budget.isExceeded` → abortRun(budget_limited),
298
397
  * 终止整个 run(避免烧光预算后继续 spawn 新 call)。
@@ -394,8 +493,10 @@ function dispatchAgentCall(
394
493
  const call = new AgentCall(msg.callId, resolved.opts, node);
395
494
  run.state.calls.set(msg.callId, call);
396
495
 
397
- // C-3:经 ConcurrencyGate.withSlot 获取并发槽位后执行。
398
- // gate maxConcurrency=4 + FIFO;executeAgentCall 管 retry/budget/stale-context;
496
+ // C-3:agent call 执行入口。
497
+ // (原经 gate.withSlot 包装,并发门闩 gate 已删——no-op 抽象,实际并发由
498
+ // SubagentService ConcurrencyPool 管理;仅保留其 pre-abort 检查语义,见下方
499
+ // dispatchCall 内 signal.aborted 分支。)executeAgentCall 管 retry/budget/stale-context;
399
500
  // runner(runner.run)管 spawn pi 子进程。
400
501
  // assignRuntime/replaceRuntime 保证 status==="running" ⟺ runtime defined,
401
502
  // 故 run.runtime 在此必存在(dispatchAgentCall 仅从 handleWorkerMessage 调用,
@@ -415,20 +516,24 @@ function dispatchAgentCall(
415
516
  const stream = deps.streamSink
416
517
  ? new SubagentStream(`${run.runId}-${msg.callId}`, deps.streamSink)
417
518
  : undefined;
418
- void runtime.gate
419
- .withSlot(
420
- async () => {
421
- try {
519
+ // 原 gate.withSlot(fn, signal) 语义内联:pre-aborted 时 reject AbortError(
520
+ // 下方 .catch 依赖此约定不记错),否则直接执行——并发调度归 ConcurrencyPool。
521
+ const dispatchCall = async (): Promise<void> => {
522
+ if (signal.aborted) {
523
+ const abortErr = new Error("Operation aborted before start");
524
+ abortErr.name = "AbortError";
525
+ throw abortErr;
526
+ }
527
+ try {
422
528
  // OB2(S7 残留):isOrphaned 谓词注入——旧代际 finalize 在 trace.update 前被
423
529
  // 拦截(判定语义与下方 .then/.catch 守卫同一 isOrphanedCall,详见
424
530
  // execute-agent-call.ts finalizeCall 文档注释)。
425
- await executeAgentCall(call, deps.runner, run.state.budget, signal, run.state.trace, onEvent, stream, () => isOrphanedCall(run, msg.callId, call));
426
- } finally {
427
- stream?.dispose();
428
- }
429
- },
430
- signal,
431
- )
531
+ await executeAgentCall(call, deps.runner, run.state.budget, signal, run.state.trace, onEvent, stream, () => isOrphanedCall(run, msg.callId, call));
532
+ } finally {
533
+ stream?.dispose();
534
+ }
535
+ };
536
+ void dispatchCall()
432
537
  .then(() => {
433
538
  // 清除 live record:终态已由 executeAgentCall → finalizeCall 写入 node.result,
434
539
  // live 不再需要(且含可变状态,不保留)。无论 stale 与否都清,避免内存泄漏。
@@ -491,12 +596,12 @@ function dispatchAgentCall(
491
596
  }
492
597
  })
493
598
  .catch((err: unknown) => {
494
- // withSlot queued + signal-aborted reject AbortError——预期,不记错。
599
+ // pre-abort 检查(原 gate.withSlot 语义)在 dispatchCall 入口 reject AbortError——预期,不记错。
495
600
  if (err instanceof Error && err.name === "AbortError") return;
496
601
  const message = err instanceof Error ? err.message : String(err);
497
602
  logger.error(`[workflow] agent call ${msg.callId} failed: ${message}`);
498
603
  // 兜底回发:executeAgentCall 抛非 Abort 异常时(如 runner undefined 的 TypeError、
499
- // gate.withSlot 内部 bug)原 catch 仅 console.error,worker 内对 callId 的 pending
604
+ // dispatchCall 内部 bug)原 catch 仅 console.error,worker 内对 callId 的 pending
500
605
  // Promise 永不 resolve → agent() 永久 await → worker 脚本挂死。构造 failed AgentResult
501
606
  //(与 resolveAgentOpts 失败路径 L262-275 一致的模式)postAgentResult 回 worker,
502
607
  // 让 pending Promise resolve(结果为 error),脚本可继续或失败退出。
@@ -700,7 +805,8 @@ async function handleReturn(
700
805
  }
701
806
  run.state.scriptResult = msg.result;
702
807
  run.transition("done", "completed");
703
- await deps.store.save(run);
808
+ // [SW-DATA-3] save 失败不阻断终态推进(原 await 裸抛 → unhandledRejection + 幽灵注销)
809
+ await saveRunBestEffort(run, deps, "handleReturn (done,completed)");
704
810
  deps.log?.("debug", "workflow:error-recovery", "run saved after return", { runId: run.runId, reason: run.state.reason });
705
811
  // C-4: run 到达 done 终态 → 注销 pending-notification + 通知 Interface 层
706
812
  deps.log?.("debug", "workflow:error-recovery", "emit pending:unregister", { runId: run.runId, reason: run.state.reason });
@@ -718,6 +824,9 @@ async function handleReturn(
718
824
  * - run.meta.workerErrorCount(C.5,跨 runtime 存活)< MAX → 退避 + rebuildRuntime
719
825
  * - >= MAX → transition done,failed
720
826
  *
827
+ * [R4-F1] 同代际幂等:复用 receivedTerminalMessage 代际标志(见函数体注释)——
828
+ * worker 崩溃时 error + exit(1) 双事件只处理一次(第二个事件直接跳过)。
829
+ *
721
830
  * @throws 不抛错——所有失败路径转 transition 或日志
722
831
  */
723
832
  export async function handleWorkerError(
@@ -730,6 +839,17 @@ export async function handleWorkerError(
730
839
  // 否则终态后到达的 worker error 仍会 workerErrorCount++(污染跨 runtime 计数)。
731
840
  if (isTerminal(run)) return;
732
841
 
842
+ // [R4-F1] 同代际幂等守卫:worker 崩溃时 error + exit(1) 双事件各派发一次
843
+ // handleWorkerError(onError 先到,exit 非 0 经 handleWorkerExit 委托二次到达)——
844
+ // 旧实现单次崩溃 workerErrorCount +2、两个 scheduleRebuild 并行交错(双 rebuild
845
+ // 各自 new Worker,旧 handle 的 terminate/exit 事件与新 handle 的生命周期互相踩踏)。
846
+ // 复用 R4 的 receivedTerminalMessage 代际标志(RunRuntime 字段,rebuild 自然重置):
847
+ // 进入处理前置 true 标记「本代际已有 error/terminal 处理」,第二个事件(无论
848
+ // onError 直达还是 exit(1) 委托)命中标志直接跳过。新代际的 handleWorkerError
849
+ // 不受影响(新 RunRuntime 的标志为 false)。
850
+ if (run.runtime?.receivedTerminalMessage) return;
851
+ if (run.runtime) run.runtime.receivedTerminalMessage = true;
852
+
733
853
  const count = (run.meta.workerErrorCount ?? 0) + 1;
734
854
  run.meta.workerErrorCount = count;
735
855
 
@@ -742,7 +862,8 @@ export async function handleWorkerError(
742
862
  run.state.error = err.message;
743
863
  deps.log?.("debug", "workflow:error-recovery", "handleWorkerError retries exceeded, transition done", { runId: run.runId, count });
744
864
  run.transition("done", "failed");
745
- await deps.store.save(run);
865
+ // [SW-DATA-3] save 失败不阻断终态推进
866
+ await saveRunBestEffort(run, deps, "handleWorkerError (done,failed)");
746
867
  deps.log?.("debug", "workflow:error-recovery", "run saved after worker error", { runId: run.runId, reason: run.state.reason });
747
868
  // C-4: run 到达 done 终态 → 注销 pending-notification + 通知 Interface 层
748
869
  deps.log?.("debug", "workflow:error-recovery", "emit pending:unregister", { runId: run.runId, reason: run.state.reason });
@@ -756,8 +877,14 @@ export async function handleWorkerError(
756
877
  /**
757
878
  * 处理 worker 线程 exit。
758
879
  *
759
- * code === 0 → 正常退出(脚本主动 return 或自然结束),no-op
760
- * code !== 0 委托 handleWorkerError(非零 exit 视为崩溃)
880
+ * code === 0
881
+ * - 本代际已收到终态消息(return/error)→ no-op(正常收尾退出,或 script-error 重试
882
+ * 退避窗口——rebuild 即将发生,不得干扰)
883
+ * - 本代际未收到任何终态消息 → [F1] 转 done,failed(WORKER_EXITED_WITHOUT_RESULT_MSG)。
884
+ * 旧实现对 code===0 一律 no-op:不可克隆 return 被 worker 侧 _safePost 吞掉后
885
+ * DataCloneError 静默丢失,worker exit(0) 而 run 永久 running、runAndWait 悬挂。
886
+ * code !== 0 → 委托 handleWorkerError(非零 exit 视为崩溃,既有重试矩阵;重试耗尽仍会
887
+ * 转 done,failed,无悬挂面)
761
888
  *
762
889
  * **G-025 竞态防护**:检查 handle.isCurrent——stale exit 事件(已 terminate 的旧
763
890
  * worker 的 exit)直接丢弃,不影响当前 runtime 的新 worker。
@@ -773,7 +900,25 @@ export async function handleWorkerExit(
773
900
  if (!handle.isCurrent) return;
774
901
  if (isTerminal(run)) return;
775
902
 
776
- if (code === 0) return; // 正常退出,no-op
903
+ if (code === 0) {
904
+ // 本代际已交付终态消息 → 正常收尾 / 重试退避窗口,no-op(rebuild 负责后续)
905
+ if (run.runtime?.receivedTerminalMessage) return;
906
+
907
+ // [F1] 无终态消息的 exit(0) = worker 静默退出(不可克隆 return 被吞 / 脚本直调
908
+ // process.exit(0) 等)。置 failed 保证 runAndWait 必有终态。不重试:rebuild 重跑
909
+ // 脚本对确定性根因(不可克隆 return)无意义,且 belt 路径优先给用户明确归因。
910
+ deps.log?.("debug", "workflow:error-recovery", "worker exited without terminal message, transition done", { runId: run.runId });
911
+ run.state.error = WORKER_EXITED_WITHOUT_RESULT_MSG;
912
+ run.transition("done", "failed");
913
+ await saveRunBestEffort(run, deps, "handleWorkerExit (done,failed, no terminal message)");
914
+ deps.log?.("debug", "workflow:error-recovery", "run saved after exit without result", { runId: run.runId, reason: run.state.reason });
915
+ // C-4: run 到达 done 终态 → 注销 pending-notification + 通知 Interface 层
916
+ deps.log?.("debug", "workflow:error-recovery", "emit pending:unregister", { runId: run.runId, reason: run.state.reason });
917
+ deps.eventBus?.emit("pending:unregister", { id: run.runId, reason: run.state.reason ?? "completed" });
918
+ deps.log?.("debug", "workflow:error-recovery", "emit pending:unregister done", { runId: run.runId });
919
+ deps.onRunDone?.(run);
920
+ return;
921
+ }
777
922
 
778
923
  // 非零 exit → 委托 handleWorkerError(C.3: onExit 传 handle 用于竞态防护)
779
924
  await handleWorkerError(
@@ -826,7 +971,8 @@ export async function handleScriptError(
826
971
  run.state.error = `Workflow failed after ${MAX_WORKER_RETRIES} retries: ${errorMsg}`;
827
972
  deps.log?.("debug", "workflow:error-recovery", "handleScriptError retries exceeded, transition done", { runId: run.runId, count });
828
973
  run.transition("done", "failed");
829
- await deps.store.save(run);
974
+ // [SW-DATA-3] save 失败不阻断终态推进
975
+ await saveRunBestEffort(run, deps, "handleScriptError (done,failed)");
830
976
  deps.log?.("debug", "workflow:error-recovery", "run saved after script error", { runId: run.runId, reason: run.state.reason });
831
977
  // C-4: run 到达 done 终态 → 注销 pending-notification + 通知 Interface 层
832
978
  deps.log?.("debug", "workflow:error-recovery", "emit pending:unregister", { runId: run.runId, reason: run.state.reason });
@@ -858,5 +1004,15 @@ async function scheduleRebuild(
858
1004
  // 退避期间状态可能变化——重检
859
1005
  if (isTerminal(run)) return;
860
1006
 
1007
+ // [race-F3] 时间预算折算后已耗尽 → 不再 rebuild 重试,直接 time_limited 终态。
1008
+ // 必须在退避 delay 之后、rebuildRuntime 之前检查:检查前移会在「退避期间耗尽」的
1009
+ // 窗口漏判(rebuild 挂不出 timer,run 预算静默失效);检查点与 rebuildRuntime 的
1010
+ // 计时器挂载之间无 await,remaining > 0 判定不会失效。
1011
+ const remainingMs = remainingTimeBudgetMs(run);
1012
+ if (remainingMs !== undefined && remainingMs <= 0) {
1013
+ await finalizeTimeBudgetExhausted(run, deps);
1014
+ return;
1015
+ }
1016
+
861
1017
  rebuildRuntime(run, deps, handlers);
862
1018
  }
@@ -9,6 +9,7 @@
9
9
  * - 重试:3 次 + 指数退避(BACKOFF_MS = [1000, 2000, 4000])
10
10
  * - 预算:超限不重试(直接 markDone failed)
11
11
  * - stale-context:不重试(直接 markDone failed)
12
+ * - [MF-1] 确定性 schema 失败:不重试(直接 markDone failed)
12
13
  * - 成功:consume usage + incrementCallCount + markDone + trace.update(completed)
13
14
  *
14
15
  * 关键设计:
@@ -73,6 +74,36 @@ export function isStaleContextErrorMsg(msg: string | undefined): boolean {
73
74
  return STALE_CONTEXT_PATTERNS.some((p) => lower.includes(p));
74
75
  }
75
76
 
77
+ /**
78
+ * [MF-1] 确定性 schema 失败标记(error 文本前缀,产出方 = output-collector 的
79
+ * describeMissingParsedOutput)。
80
+ *
81
+ * 标记 SSOT 放本模块(与 STALE_CONTEXT_PATTERNS 同布局:orchestration 持表、
82
+ * execution 值引用;反向引用会形成 execute-agent-call → output-collector →
83
+ * execute-agent-call 运行时循环)。
84
+ *
85
+ * 标记词逐字核对不命中 STALE_CONTEXT_PATTERNS 任一 pattern 与
86
+ * isStaleContextErrorMsg 的子串匹配(否则归因 error 被误诊 stale-context,
87
+ * 虽然同样不重试但归因语义被污染;output-collector.test 有交叉锁定)。
88
+ *
89
+ * 三态可重试性矩阵(F-1 归因):
90
+ * | 归因态 | 带本标记 | 可重试性 | 理由 |
91
+ * |----------------------------|---------|---------|------|
92
+ * | ① 从未调用 SO tool | 是 | 不可重试 | 缺 extension 是环境确定性(C1 安装盲区),同环境重试必同结果 |
93
+ * | ② SO 调用 isError(gate 终止/不可满足 schema) | 是 | 不可重试 | 同 schema 重试必同结果(第五轮实测:3 attempts/4 子进程/235s 纯烧钱) |
94
+ * | ③ 调用过但无 details | 否 | 可重试 | 可能瞬态(details 提取/序列化异常),保留既有重试语义 |
95
+ */
96
+ export const DETERMINISTIC_SCHEMA_FAILURE_PREFIX = "Structured output failed deterministically:";
97
+
98
+ /**
99
+ * [MF-1] 判断错误信息是否为确定性 schema 失败(命中标记前缀)。
100
+ * 命中时不重试——同 schema 重试必同结果(矩阵见 DETERMINISTIC_SCHEMA_FAILURE_PREFIX)。
101
+ */
102
+ export function isDeterministicSchemaFailureMsg(msg: string | undefined): boolean {
103
+ if (!msg) return false;
104
+ return msg.includes(DETERMINISTIC_SCHEMA_FAILURE_PREFIX);
105
+ }
106
+
76
107
  // ── 内部 helper ──────────────────────────────────────────────
77
108
 
78
109
  /**
@@ -184,6 +215,14 @@ export async function executeAgentCall(
184
215
  return;
185
216
  }
186
217
 
218
+ // [MF-1] 确定性 schema 失败:不重试(gate 终止/不可满足 schema 同 schema 重试必同
219
+ // 结果——重试纯烧钱;三态可重试性矩阵见 DETERMINISTIC_SCHEMA_FAILURE_PREFIX)
220
+ if (result.error !== undefined && isDeterministicSchemaFailureMsg(result.error)) {
221
+ finalizeCall(call, result, trace, isOrphaned);
222
+ budget.incrementCallCount();
223
+ return;
224
+ }
225
+
187
226
  // signal 已 abort:调用方终止,不重试(避免无意义的递归)
188
227
  if (signal.aborted) {
189
228
  finalizeCall(call, result, trace, isOrphaned);
@@ -40,6 +40,21 @@
40
40
  * - Budget/Trace/AgentCall 都有公共构造器或 fromArray 工厂,反序列化时重建实例。
41
41
  * - Snapshot 形态用 SnapshotVersion 守护(D-5:格式识别)。
42
42
  *
43
+ * [S3 查证结论] pi 0.84.1 实装(node_modules/@earendil-works/pi-coding-agent/dist,
44
+ * core/session-manager.js,PS-19)的 session 生命周期管理不含自动 GC:
45
+ * listSessionsFromDir 只做只读扫描(readdir + `.jsonl` 过滤 + header 解析,:548-571,
46
+ * 非递归——`<sessionDir>/workflow-state/` 子目录完全不在 pi 的任何扫描/清理范围内),
47
+ * SessionManager.list / listAll 只是它之上的 cwd 过滤/排序封装(:1281-1287 / :1289),
48
+ * 无按 age/数量的 retention/prune/expire 删除逻辑;唯一删除路径是 TUI SessionSelector
49
+ * 里用户手动删除选中的单个顶层 session 文件(trash CLI → unlink fallback,
50
+ * dist/modes/interactive/components/session-selector.js:539-550),非自动、
51
+ * 不递归子目录。**推论:workflow-state state 文件无限累积,
52
+ * 保留策略由本包自担**——磁盘侧保留现为 opt-in(B1):设 {@link STATE_MAX_RUNS_ENV}
53
+ * 后每次新 run state 文件首写成功即按 mtime 裁剪到上限(默认关,见
54
+ * pruneStateFilesBeyondCap);内存侧由 evictDoneRunsBeyondCap 淘汰。W17 后 state 文件
55
+ * 已降级为纯性能缓存(权威数据在 session JSONL 的 workflow-record entry),随 session
56
+ * 文件被用户删除时一并消失。
57
+ *
43
58
  * 参考:domain-models.md §Ports(RunStore 定义)、clarification.md D-5。
44
59
  */
45
60
 
@@ -256,15 +271,28 @@ function deserializeRun(snapshot: RunSnapshot): WorkflowRun | null {
256
271
  }
257
272
 
258
273
  /** workflow-record entry → 重建 run 写入 recordRuns(v1 entry guard + D-5 版本不匹配
259
- * 跳过;同 runId 后写覆盖 = 最后一条 entry 胜出)。返回 entry 是否命中该类型。 */
260
- function collectRecordRun(entry: CustomEntry, recordRuns: Map<string, WorkflowRun>): boolean {
274
+ * 跳过;同 runId 后写覆盖 = 最后一条 entry 胜出)。返回 entry 是否命中该类型。
275
+ *
276
+ * [SO-DATA-2] per-entry 隔离:deserializeRun 在 v guard 之后直接读 snapshot.state.budget
277
+ * 等嵌套字段,残缺 entry(截断/手改/半写)抛 TypeError 会沿 collectEntrySources 穿透
278
+ * loadAll 的 catch → 返回空——单条损坏让全部 run 不可见。现单条 try/catch:损坏
279
+ * entry 跳过 + warn 留证(含 entry 索引与原因),其余 entry 正常重建。
280
+ */
281
+ function collectRecordRun(entry: CustomEntry, entryIndex: number, recordRuns: Map<string, WorkflowRun>): boolean {
261
282
  if (entry.customType !== WORKFLOW_RECORD_CUSTOM_TYPE) return false;
262
283
  // v1 entry guard:schema 版本不认识 → 跳过(不猜测解析)
263
284
  const data = entry.data as WorkflowRecordEntryData | undefined;
264
285
  if (data?.v !== 1 || !data.snapshot) return true;
265
- const run = deserializeRun(data.snapshot);
266
- // D-5: null = old snapshot format / version mismatch — skip silently
267
- if (run) recordRuns.set(run.runId, run); // 后写覆盖 = 最后一条 entry 胜出
286
+ try {
287
+ const run = deserializeRun(data.snapshot);
288
+ // D-5: null = old snapshot format / version mismatch — skip silently
289
+ if (run) recordRuns.set(run.runId, run); // 后写覆盖 = 最后一条 entry 胜出
290
+ } catch (err) {
291
+ const reason = err instanceof Error ? err.message : String(err);
292
+ logger.warn(
293
+ `[subagent-workflow] workflow-record entry #${entryIndex} corrupted, skipped run rebuild: ${reason}`,
294
+ );
295
+ }
268
296
  return true;
269
297
  }
270
298
 
@@ -287,9 +315,11 @@ function collectEntrySources(entries: SessionEntry[]): {
287
315
  } {
288
316
  const recordRuns = new Map<string, WorkflowRun>();
289
317
  const pointers = new Map<string, { path: string }>();
290
- for (const entry of entries) {
318
+ // 索引循环:collectRecordRun warn 留证需要 entry 索引(SO-DATA-2)
319
+ for (let i = 0; i < entries.length; i++) {
320
+ const entry = entries[i]!;
291
321
  if (entry.type !== "custom") continue;
292
- if (collectRecordRun(entry, recordRuns)) continue;
322
+ if (collectRecordRun(entry, i, recordRuns)) continue;
293
323
  collectStateLinkPointer(entry, pointers);
294
324
  }
295
325
  return { recordRuns, pointers };
@@ -312,6 +342,58 @@ async function loadRunFromStateFile(filePath: string): Promise<WorkflowRun | nul
312
342
  }
313
343
  }
314
344
 
345
+ // ── State file retention (B1, opt-in) ────────────────────────
346
+
347
+ /** run state 文件名 glob:runId 形如 `wf-<ts>-<rand>`(lifecycle.ts 生成),只删命中者。
348
+ * 同目录可能存在的非 state 文件(及 session JSONL——在父目录,本就不在扫描范围)永不碰。 */
349
+ const STATE_FILE_GLOB = /^wf-.*\.jsonl$/;
350
+
351
+ /**
352
+ * 把 workflow-state 目录裁剪到 maxRuns 个最新 state 文件(mtime 升序,删最旧)。
353
+ *
354
+ * 只删本目录内命中 {@link STATE_FILE_GLOB} 的文件;任何失败都不抛(清理是旁路
355
+ * 维护,不能拖垮持久化主链路):readdir/stat 失败静默放弃本轮,单个 unlink 失败
356
+ * (非 ENOENT)logger.warn 留证后继续删其余——ENOENT 视为并发删除竞态下的已达成
357
+ * 目标,不告警。
358
+ */
359
+ async function pruneStateFilesBeyondCap(stateDir: string, maxRuns: number): Promise<void> {
360
+ let names: string[];
361
+ try {
362
+ names = await fs.promises.readdir(stateDir);
363
+ } catch (err) {
364
+ if (!isEnoentError(err)) {
365
+ const reason = err instanceof Error ? err.message : String(err);
366
+ logger.warn(`[subagent-workflow] state retention: readdir ${stateDir} failed: ${reason}`);
367
+ }
368
+ return;
369
+ }
370
+ const stateFiles = names.filter((n) => STATE_FILE_GLOB.test(n)).sort();
371
+ if (stateFiles.length <= maxRuns) return;
372
+
373
+ // stat 全集取 mtime;allSettled 部分降级——单文件 stat 失败(并发删除 ENOENT 等)
374
+ // 静默跳过该文件,不阻断本轮裁剪
375
+ const settled = await Promise.allSettled(
376
+ stateFiles.map(async (name) => {
377
+ const full = path.join(stateDir, name);
378
+ return { full, mtimeMs: (await fs.promises.stat(full)).mtimeMs };
379
+ }),
380
+ );
381
+ const byMtimeAsc = settled
382
+ .flatMap((r) => (r.status === "fulfilled" ? [r.value] : []))
383
+ .sort((a, b) => a.mtimeMs - b.mtimeMs);
384
+ const victims = byMtimeAsc.slice(0, byMtimeAsc.length - maxRuns);
385
+ for (const victim of victims) {
386
+ try {
387
+ await fs.promises.unlink(victim.full);
388
+ logger.debug(`[subagent-workflow] state retention: pruned ${victim.full}`);
389
+ } catch (err) {
390
+ if (isEnoentError(err)) continue; // 并发删除已达成目标
391
+ const reason = err instanceof Error ? err.message : String(err);
392
+ logger.warn(`[subagent-workflow] state retention: failed to delete ${victim.full}: ${reason}`);
393
+ }
394
+ }
395
+ }
396
+
315
397
  // ── JsonlRunStore ────────────────────────────────────────────
316
398
 
317
399
  /** Node fs 错误 code 判定(ENOENT = 路径不存在,并发删除场景)。 */
@@ -334,6 +416,28 @@ const logger = getLogger("subagents");
334
416
  */
335
417
  export const DEFAULT_SAVE_DEBOUNCE_MS = 200;
336
418
 
419
+ /**
420
+ * 磁盘保留清理的 opt-in 开关 env(B1):workflow-state 目录内 run state 文件上限。
421
+ *
422
+ * 默认关——未设/空/非有限数/≤0 都不清理(「limits 默认关」裁决;解析对齐
423
+ * session-runner 的 SPAWN_WATCHDOG_ENV watchdog 风格:Number() + Number.isFinite
424
+ * 过滤,非法值回落 undefined = 不启用,而非抛错或取默认上限)。
425
+ *
426
+ * 用 XYZ_ 前缀而非 PI_:本 env 是 pi 进程内读的配置 env,xyz-agent 桌面 spawn 链按
427
+ * ENV_WHITELIST_PREFIXES(只有 XYZ_ 等)过滤,PI_ 前缀在桌面场景被静默丢弃——
428
+ * 同 XYZ_SUBAGENT_IDLE_TIMEOUT_MS 的改名教训(lifecycle-manager.ts)。
429
+ */
430
+ export const STATE_MAX_RUNS_ENV = "XYZ_SUBAGENT_STATE_MAX_RUNS";
431
+
432
+ /** 解析保留上限;env 未设/非法/≤0 返回 undefined(调用方不清理)。 */
433
+ function getEnvStateMaxRuns(): number | undefined {
434
+ const raw = process.env[STATE_MAX_RUNS_ENV];
435
+ if (!raw) return undefined;
436
+ const parsed = Number(raw);
437
+ if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
438
+ return parsed;
439
+ }
440
+
337
441
  /**
338
442
  * per-runId 去抖批。窗口内 N 次 save 合并:latestRun 保留最新聚合引用
339
443
  * (serialize-at-flush),settlers 收集批内全部 save() 调用方的 settle 回调。
@@ -551,6 +655,16 @@ export class JsonlRunStore {
551
655
  WORKFLOW_RECORD_CUSTOM_TYPE,
552
656
  toWorkflowRecordEntryData(snapshot),
553
657
  );
658
+ // B1 磁盘保留清理(opt-in):新 run state 文件首写成功后触发(rollbackFirstWrite
659
+ // 即 save() 冷路径传入的 isFirstWrite——「本实例首次写该 runId」≈ 新文件落盘时刻,
660
+ // 每个 run 只清一次,热路径 flush 不重复扫描目录)。prune 内部吞错不抛,
661
+ // 在串行链上 await:save 返回即清理已定,测试可同步断言目录终态。
662
+ if (rollbackFirstWrite) {
663
+ const maxRuns = getEnvStateMaxRuns();
664
+ if (maxRuns !== undefined) {
665
+ await pruneStateFilesBeyondCap(this.stateDir, maxRuns);
666
+ }
667
+ }
554
668
  for (const s of settlers) s.resolve();
555
669
  } catch (err) {
556
670
  // ES9 失败回滚(热路径 flush 也会写 entry,回滚的意义收敛为「下次 save 重走