@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
@@ -15,6 +15,22 @@
15
15
  * - rewrite mode(writeFile 覆盖,文件始终是最新单行快照)。
16
16
  * - workflow-state-link 指针条目机制保留(pi.appendEntry)。
17
17
  *
18
+ * save 去抖语义(cw swf-perf wave2):
19
+ * - **热路径**(running 中间态,本实例已写过):per-runId pending 批合并——窗口内
20
+ * N 次 save 只落盘 1 次(serialize-at-flush:写 flush 时刻最新聚合状态)。
21
+ * 固定窗口不重置 timer(批创建时定时一次),保证 flush 延迟有界 ≤saveDebounceMs;
22
+ * agent-call 间隔秒级下 trailing 重置无合并增益反可无限推迟。
23
+ * - **冷路径**(本实例对该 runId 首写,或 status !== "running" 即 done):
24
+ * 同步挂链 flush 绕过 timer——首写立即可见(跨 session 重启后 loadAll 依赖指针
25
+ * 发现文件)、done 立即落盘(终态优先持久化:transition("done") 后的 save
26
+ * 不进去抖批,去抖窗口内的崩溃不吞终态)。
27
+ * - 指针(workflow-state-link)只在两处写:创建(本实例首写,即使 status 是
28
+ * running)与终态(status==="done")。中间态 flush 永不写指针——每实例每 run ≤2 条。
29
+ * - per-runId 串行 flush 链:同 runId 的 flush 排队顺序执行(不跳过、永不并发
30
+ * writeFile),链尾吞错防断链——错误只经各 save() Promise 的 settlers 传播。
31
+ * - dispose():幂等(缓存自身 Promise);刷全部 pending 批 + await 全部 in-flight
32
+ * 链后返回。dispose 后 save 静默 no-op + debug 日志(session_shutdown 编排收尾)。
33
+ *
18
34
  * 序列化策略:
19
35
  * - WorkflowRun 是带方法的 class 聚合根——序列化只取公共字段快照。
20
36
  * - Budget/Trace/AgentCall 都有公共构造器或 fromArray 工厂,反序列化时重建实例。
@@ -27,6 +43,7 @@ import * as fs from "node:fs";
27
43
  import * as path from "node:path";
28
44
 
29
45
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
46
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
30
47
 
31
48
  import { AgentCall } from "./models/agent-call.ts";
32
49
  import { Budget } from "./models/budget.ts";
@@ -43,9 +60,15 @@ import { WorkflowRun } from "./models/workflow-run.ts";
43
60
  /**
44
61
  * 快照格式版本。D-5:旧 session(无此字段或值不匹配)被 loadAll 忽略。
45
62
  *
63
+ * 版本历史:
64
+ * - wf-run-v1:status 三态(含 paused)、meta 含 pausedAt。
65
+ * - wf-run-v2(当前):status 两态(running/done)、meta 无 pausedAt(随一次性
66
+ * 生命周期收窄,F6/F8)。v1 文件 loadAll 静默跳过——含 v1 running 残留跳过 =
67
+ * 静默消失不显示,接受(父文档 D-5 边界声明:旧 run 历史价值低,不做兼容迁移)。
68
+ *
46
69
  * 升级格式时 bump 此常量并在 deserializeRun 中适配——旧文件返回 null(被 loadAll 跳过)。
47
70
  */
48
- export const SNAPSHOT_VERSION = "wf-run-v1" as const;
71
+ export const SNAPSHOT_VERSION = "wf-run-v2" as const;
49
72
 
50
73
  /**
51
74
  * 持久化快照形态——WorkflowRun 公共字段的 JSON 可序列化投影。
@@ -86,7 +109,6 @@ interface RunSnapshot {
86
109
  meta: {
87
110
  startedAt: string;
88
111
  completedAt?: string;
89
- pausedAt?: string;
90
112
  workerErrorCount?: number;
91
113
  scriptErrorCount?: number;
92
114
  };
@@ -125,7 +147,7 @@ function serializeRun(run: WorkflowRun): RunSnapshot {
125
147
  };
126
148
  }),
127
149
  // trace 节点浅拷贝时 strip live 字段——ExecutionRecord 含可变 turns[]/controller,
128
- // 不适合序列化;pause/resume 后 live undefined(重跑时由 dispatchAgentCall 重建)。
150
+ // 不适合序列化;live 是运行期对象(done 时由 dispatchAgentCall 清除,重跑时重建)。
129
151
  trace: run.state.trace.toArray().map(({ live: _live, ...rest }) => rest),
130
152
  errorLogs: run.state.errorLogs,
131
153
  error: run.state.error,
@@ -186,7 +208,6 @@ function deserializeRun(snapshot: RunSnapshot): WorkflowRun | null {
186
208
  const meta: WorkflowRunMeta = {
187
209
  startedAt: snapshot.meta.startedAt,
188
210
  completedAt: snapshot.meta.completedAt,
189
- pausedAt: snapshot.meta.pausedAt,
190
211
  workerErrorCount: snapshot.meta.workerErrorCount,
191
212
  scriptErrorCount: snapshot.meta.scriptErrorCount,
192
213
  };
@@ -199,77 +220,303 @@ function deserializeRun(snapshot: RunSnapshot): WorkflowRun | null {
199
220
 
200
221
  // ── JsonlRunStore ────────────────────────────────────────────
201
222
 
223
+ /** Node fs 错误 code 判定(ENOENT = 路径不存在,并发删除场景)。 */
224
+ function isEnoentError(err: unknown): boolean {
225
+ return (
226
+ typeof err === "object" &&
227
+ err !== null &&
228
+ "code" in err &&
229
+ (err as { code: unknown }).code === "ENOENT"
230
+ );
231
+ }
232
+
233
+ const logger = getLogger("subagents");
234
+
235
+ /**
236
+ * save 去抖窗口默认值(ms)。区间 100-250 内取值——agent-call 间隔秒级,
237
+ * 200ms 足以合并同一 call 周期内的多次状态 mutation,又不至于让崩溃窗口
238
+ * (未 flush 的 running 尾部丢失,等价崩溃链由 kill-9 恢复收编)明显放大。
239
+ * export 供测试边界构造(对齐 TRACE_RESULT_MAX_CHARS export 先例)。
240
+ */
241
+ export const DEFAULT_SAVE_DEBOUNCE_MS = 200;
242
+
243
+ /**
244
+ * per-runId 去抖批。窗口内 N 次 save 合并:latestRun 保留最新聚合引用
245
+ * (serialize-at-flush),settlers 收集批内全部 save() 调用方的 settle 回调。
246
+ */
247
+ interface PendingSaveBatch {
248
+ latestRun: WorkflowRun;
249
+ /**
250
+ * 批的去抖 timer(构造时即确定——经 {@link JsonlRunStore.armPendingBatch} 工厂
251
+ * 内联组装,timer 与批对象在同一同步段成型,类型上不存在「先构造后赋值」的
252
+ * 可选窗口)。
253
+ */
254
+ timer: NodeJS.Timeout;
255
+ settlers: Array<{ resolve: () => void; reject: (e: unknown) => void }>;
256
+ }
257
+
202
258
  export interface JsonlRunStoreOptions {
203
- /** Session directory root (state files live under <sessionDir>/workflow-state/). */
259
+ /** Session directory root (state files live under <sessionDir>/workflow-state/). */
204
260
  sessionDir: string;
205
- /** Pi ExtensionAPI for appendEntry pointer writes (optional for testing). */
261
+ /** Pi ExtensionAPI for appendEntry pointer writes (optional for testing). */
206
262
  pi?: ExtensionAPI;
207
- /** Pi ExtensionContext for sessionManager.getEntries (optional for testing). */
263
+ /** Pi ExtensionContext for sessionManager.getEntries (optional for testing). */
208
264
  ctx?: ExtensionContext;
265
+ /** save 去抖窗口(ms),默认 {@link DEFAULT_SAVE_DEBOUNCE_MS}。 */
266
+ saveDebounceMs?: number;
209
267
  }
210
268
 
211
269
  export class JsonlRunStore {
212
270
  private readonly sessionDir: string;
213
271
  private readonly pi?: ExtensionAPI;
214
272
  private readonly ctx?: ExtensionContext;
273
+ private readonly saveDebounceMs: number;
274
+ /** per-runId 去抖批(热路径)。 */
275
+ private readonly pending = new Map<string, PendingSaveBatch>();
276
+ /** 本实例已至少成功发起过一次 flush 的 runId(冷/热路径判据)。 */
277
+ private readonly writtenOnce = new Set<string>();
278
+ /**
279
+ * per-runId 串行 flush 链。同 runId 的 flush 排队顺序执行(排队不跳过——
280
+ * 跳过会丢最新状态且打破后写覆盖前写的单调性),不同 runId 互不阻塞。
281
+ * 链条目 settle 后不清理:runId 数量有界、生命周期短于 store,惰性清理
282
+ * 与排队写入存在竞态——取舍为每 runId 残留一个 settled Promise 引用,可忽略。
283
+ */
284
+ private readonly chains = new Map<string, Promise<void>>();
285
+ private disposed = false;
286
+ private disposePromise: Promise<void> | undefined;
215
287
 
216
288
  constructor(opts: JsonlRunStoreOptions) {
217
289
  this.sessionDir = opts.sessionDir;
218
290
  this.pi = opts.pi;
219
291
  this.ctx = opts.ctx;
292
+ this.saveDebounceMs = opts.saveDebounceMs ?? DEFAULT_SAVE_DEBOUNCE_MS;
220
293
  }
221
294
 
222
- /** State directory: <sessionDir>/workflow-state/ */
295
+ /** State directory: <sessionDir>/workflow-state/ */
223
296
  private get stateDir(): string {
224
297
  return path.join(this.sessionDir, "workflow-state");
225
298
  }
226
299
 
227
- /** State file path for a given runId. */
300
+ /** State file path for a given runId. */
228
301
  private filePathFor(runId: string): string {
229
302
  return path.join(this.stateDir, `${runId}.jsonl`);
230
303
  }
231
304
 
232
- /** Public accessor: run 状态快照文件绝对路径(RunStore port 实现)。 */
305
+ /** Public accessor: run 状态快照文件绝对路径(RunStore port 实现)。 */
233
306
  stateFilePath(runId: string): string {
234
307
  return this.filePathFor(runId);
235
308
  }
236
309
 
237
- /**
238
- * Persist a single run: rewrite mode (overwrite) — file always contains the
239
- * latest complete snapshot on a single line. Appends a workflow-state-link
240
- * pointer entry via pi.appendEntry so loadAll can locate files.
241
- */
310
+ /**
311
+ * Persist a single run: rewrite mode (overwrite) — file always contains the
312
+ * latest complete snapshot on a single line.
313
+ *
314
+ * 去抖路由:
315
+ * - 冷路径(本实例首写,或 status !== "running")→ 立即挂链 flush(绕过 timer),
316
+ * writePointer = 首写 || status==="done";
317
+ * - 热路径(running 且已写过)→ 并入 per-runId 去抖批(固定窗口不重置 timer)。
318
+ *
319
+ * Promise 语义:本批实际落盘后 resolve(同批多次调用共享 settle);IO 错误
320
+ * (非 ENOENT)reject 本批全部调用方;ENOENT 静默 resolve(工作目录已被清理,
321
+ * 持久化无意义也无法完成——见 doFlush)。
322
+ */
242
323
  async save(run: WorkflowRun): Promise<void> {
243
- const filePath = this.filePathFor(run.runId);
244
- // 兜底容错:run 工作目录(sessionDir)已被清理时,mkdir ENOENT,save 放弃。
245
- // 竞态场景(review-fix-loop-e2e runAndWait 测试):handleReturn
246
- // run.transition("done") 同步改 status 后,runAndWait 轮询发现 done 并 resolve,
247
- // 测试 afterEach 随即 rmSync 删除 sessionDir;此时本方法 in-flight 的 mkdir
248
- // 遇到目录链已删除 ENOENT({recursive:true} 在并发 rmSync 下仍可抛 ENOENT)。
249
- // run 既已终态(状态不再变化),持久化无意义也无法完成 → silent return。
250
- // 仅容错 ENOENT,重新抛出其他错误(EACCES/ENOSPC 等真实磁盘问题不掩盖)。
324
+ // R5 处置:dispose 后(session_shutdown 收尾后 in-flight 链的迟到 save)静默
325
+ // no-op + debug 留痕。不复活同步 flush——单向闸门状态机简单;此时 run 是
326
+ // running 落盘无增益(kill-9 恢复同样转 done,failed),终态 reason 保真损失极窄。
327
+ if (this.disposed) {
328
+ logger.debug(
329
+ `[subagent-workflow] jsonl-run-store save after dispose: silently dropped (runId=${run.runId})`,
330
+ );
331
+ return;
332
+ }
333
+
334
+ const runId = run.runId;
335
+ const isFirstWrite = !this.writtenOnce.has(runId);
336
+ const isCold = isFirstWrite || run.state.status !== "running";
337
+ if (isCold) {
338
+ // 判定即记录:原子防并发双冷(两次并发首写都判 true 会写两条创建指针)。
339
+ // ENOENT 边界:首写 flush 遇 ENOENT 时指针未写但 writtenOnce 已记——
340
+ // sessionDir 已删场景指针无意义,接受(非 ENOENT 失败由 doFlush 回滚,重走冷路径)。
341
+ this.writtenOnce.add(runId);
342
+ // 原子取走 pending 批(终态与最后一个 agent-call 的 debounced save 交错时,
343
+ // pending 批 settlers 并入本次同步 flush 的批合并 settle,timer 取消防二次写)。
344
+ const batch = this.pending.get(runId);
345
+ if (batch) {
346
+ clearTimeout(batch.timer);
347
+ this.pending.delete(runId);
348
+ }
349
+ const writePointer = isFirstWrite || run.state.status === "done";
350
+ return this.enqueueFlush(runId, run, batch ? batch.settlers : [], writePointer);
351
+ }
352
+
353
+ // 热路径:running 中间态,并入去抖批
354
+ const existing = this.pending.get(runId);
355
+ if (existing) {
356
+ // latestRun 更新(固定窗口不重置 timer——flush 延迟有界 ≤saveDebounceMs)
357
+ existing.latestRun = run;
358
+ return new Promise<void>((resolve, reject) => {
359
+ existing.settlers.push({ resolve, reject });
360
+ });
361
+ }
362
+ const settlers: PendingSaveBatch["settlers"] = [];
363
+ const promise = new Promise<void>((resolve, reject) => {
364
+ settlers.push({ resolve, reject });
365
+ });
366
+ this.pending.set(runId, this.armPendingBatch(runId, run, settlers));
367
+ return promise;
368
+ }
369
+
370
+ /**
371
+ * 构造去抖批([review 修复] 工厂内联组装:timer 与批对象在同一同步段成型,
372
+ * PendingSaveBatch.timer 保持非可选——消除「批先构造、timer 后赋值」靠注释维持
373
+ * 的可选窗口)。timer 回调闭包经局部 batch 变量持批引用做身份守卫(ES3)。
374
+ */
375
+ private armPendingBatch(
376
+ runId: string,
377
+ run: WorkflowRun,
378
+ settlers: PendingSaveBatch["settlers"],
379
+ ): PendingSaveBatch {
380
+ // timer 回调闭包经下方 const batch 持批引用做身份守卫——前向引用在运行时安全:
381
+ // 回调最早 saveDebounceMs 后才执行,届时 batch 已在本同步段尾部初始化完毕。
382
+ const timer = setTimeout(() => {
383
+ // ES3 幂等守卫(批身份比较):回调闭包持自身批引用,与 pending Map 现值做
384
+ // 身份比较而非仅按键存在性判断。除「批已被冷路径/flushPendingSaves/dispose
385
+ // 原子取走(clearTimeout 与回调触发在 fake timers 下可能交错)」的交接语义外,
386
+ // 还防「旧 timer 撞新批」交错:本批被取走后同 runId 的新批已入 Map 时,若只看
387
+ // 键存在性,旧 timer 会误取走新批提前 flush(缩短新批去抖窗口)。身份不匹配
388
+ // 直接 return,批由取走方负责 flush。
389
+ if (this.pending.get(runId) !== batch) return;
390
+ this.pending.delete(runId);
391
+ // 孤儿 Promise(无调用方持有):错误只经 settlers 传播给 save() 调用方,
392
+ // 此处 catch 防止 unhandled rejection。
393
+ this.enqueueFlush(runId, batch.latestRun, batch.settlers, false).catch(() => {});
394
+ }, this.saveDebounceMs);
395
+ // DS5:timer 必须 unref——不 unref 会钉住空转的 extension 进程不退出。
396
+ timer.unref();
397
+ const batch: PendingSaveBatch = { latestRun: run, timer, settlers };
398
+ return batch;
399
+ }
400
+
401
+ /**
402
+ * 把一次 flush 排到 runId 的串行链尾。调用方 Promise(本函数返回值)与传入
403
+ * settlers 由同一次 doFlush 独占 settle 一次。
404
+ */
405
+ private enqueueFlush(
406
+ runId: string,
407
+ run: WorkflowRun,
408
+ settlers: PendingSaveBatch["settlers"],
409
+ writePointer: boolean,
410
+ ): Promise<void> {
411
+ const promise = new Promise<void>((resolve, reject) => {
412
+ settlers.push({ resolve, reject });
413
+ });
414
+ // 排队不跳过:前一 flush in-flight 时本次挂链尾顺序执行,同 runId 永不并发
415
+ // writeFile(整文件覆盖写并发会互相截断)。链尾吞错防断链——错误只经 settlers 传播。
416
+ const next = (this.chains.get(runId) ?? Promise.resolve())
417
+ .then(() => this.doFlush(runId, run, settlers, writePointer))
418
+ .catch(() => {});
419
+ this.chains.set(runId, next);
420
+ return promise;
421
+ }
422
+
423
+ /**
424
+ * 实际落盘(在 runId 串行链上执行)。settlers 由本函数独占 settle 一次:
425
+ * 成功或 ENOENT 全 resolve,其他错误全 reject。
426
+ */
427
+ private async doFlush(
428
+ runId: string,
429
+ run: WorkflowRun,
430
+ settlers: PendingSaveBatch["settlers"],
431
+ writePointer: boolean,
432
+ ): Promise<void> {
433
+ const filePath = this.filePathFor(runId);
251
434
  try {
252
- await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
435
+ // 兜底容错:run 工作目录(sessionDir)已被清理时,mkdir ENOENT,放弃持久化。
436
+ // 竞态场景(review-fix-loop-e2e 等 runAndWait 测试):handleReturn 内
437
+ // run.transition("done") 同步改 status 后,runAndWait 轮询发现 done 并 resolve,
438
+ // 测试 afterEach 随即 rmSync 删除 sessionDir;此时 in-flight 的 mkdir
439
+ // 遇到目录链已删除 → ENOENT({recursive:true} 在并发 rmSync 下仍可抛 ENOENT)。
440
+ // run 既已终态(状态不再变化),持久化无意义也无法完成 → settle resolve。
441
+ // 仅容错 ENOENT,其他错误(EACCES/ENOSPC 等真实磁盘问题)reject 不掩盖。
442
+ try {
443
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
444
+ } catch (err) {
445
+ if (isEnoentError(err)) {
446
+ for (const s of settlers) s.resolve();
447
+ return;
448
+ }
449
+ throw err;
450
+ }
451
+ // serialize-at-flush:写 flush 时刻的最新聚合状态(latestRun 语义)
452
+ const snapshot = serializeRun(run);
453
+ await fs.promises.writeFile(filePath, JSON.stringify(snapshot) + "\n", "utf8");
454
+ if (writePointer && this.pi) {
455
+ this.pi.appendEntry("workflow-state-link", {
456
+ runId,
457
+ path: filePath,
458
+ updatedAt: new Date().toISOString(),
459
+ });
460
+ }
461
+ for (const s of settlers) s.resolve();
253
462
  } catch (err) {
254
- if (
255
- typeof err === "object" &&
256
- err !== null &&
257
- "code" in err &&
258
- (err as { code: unknown }).code === "ENOENT"
259
- ) {
260
- return;
463
+ // ES9 失败回滚:应写指针的 flush 未写成时回滚首写资格——下次 save 判
464
+ // !writtenOnce.has(runId) 重走冷路径、writePointer 再判 true 重试指针。堵住
465
+ // 「首写失败后热路径 writePointer 恒 false → 指针永失 → run 对重启后
466
+ // loadAll 不可见」窗口(loadAll 仅经指针发现文件)。
467
+ // 残余窗口(已知接受):回滚后若仅剩 writePointer=false 的热批 flush 成功且
468
+ // 再无任何 save(随即崩溃/退出),指针仍可能缺失——窗口远窄于 ES9 所堵场景,
469
+ // 由崩溃等价论证覆盖(kill-9 恢复兜底)。
470
+ if (writePointer) {
471
+ this.writtenOnce.delete(runId);
261
472
  }
262
- throw err;
473
+ for (const s of settlers) s.reject(err);
263
474
  }
264
- const snapshot = serializeRun(run);
265
- await fs.promises.writeFile(filePath, JSON.stringify(snapshot) + "\n", "utf8");
266
- if (this.pi) {
267
- this.pi.appendEntry("workflow-state-link", {
268
- runId: run.runId,
269
- path: filePath,
270
- updatedAt: new Date().toISOString(),
271
- });
475
+ }
476
+
477
+ /**
478
+ * 立即刷全部 pending 去抖批(测试与排查的备用手段)。自身恒 resolve——IO 错误
479
+ * 已由各 save() Promise 的 settlers 传播给调用方。store 保持可用:不动 disposed
480
+ * 标志,后续 save 正常进入新去抖批。
481
+ */
482
+ async flushPendingSaves(): Promise<void> {
483
+ const flushes: Promise<void>[] = [];
484
+ for (const [runId, batch] of Array.from(this.pending.entries())) {
485
+ clearTimeout(batch.timer);
486
+ this.pending.delete(runId);
487
+ flushes.push(this.enqueueFlush(runId, batch.latestRun, batch.settlers, false));
488
+ }
489
+ await Promise.allSettled(flushes);
490
+ }
491
+
492
+ /**
493
+ * 收尾:刷全部 pending 批 + 停 timer + await 全部 in-flight 链(in-flight flush
494
+ * 完成后才返回),此后 save 静默 no-op。
495
+ *
496
+ * 幂等:dispose 缓存自身 Promise——首次未完成时并发交叠进入的后续调用返回
497
+ * 同一 Promise(「dispose 返回 = 全部 flush 已落盘」对每个调用方都成立,
498
+ * 无第二次拿到立即 resolve 的空 Promise 瑕疵)。故本方法不能是 async 函数
499
+ * (async 总是创建新 Promise 破坏同一引用保证)。
500
+ */
501
+ dispose(): Promise<void> {
502
+ if (this.disposePromise) return this.disposePromise;
503
+ this.disposePromise = this.doDispose();
504
+ return this.disposePromise;
505
+ }
506
+
507
+ private async doDispose(): Promise<void> {
508
+ // 同步置位:阻断新 save 进入去抖/冷路径(R5 no-op 分支接住 shutdown 后
509
+ // in-flight 链的迟到 save)。doDispose 被调用后同步执行到第一个 await 前。
510
+ this.disposed = true;
511
+ const flushes: Promise<void>[] = [];
512
+ for (const [runId, batch] of Array.from(this.pending.entries())) {
513
+ clearTimeout(batch.timer);
514
+ this.pending.delete(runId);
515
+ flushes.push(this.enqueueFlush(runId, batch.latestRun, batch.settlers, false));
272
516
  }
517
+ await Promise.allSettled(flushes);
518
+ // await 全部 in-flight 链(ES4:flush 全部落定后才返回)
519
+ await Promise.allSettled(Array.from(this.chains.values()));
273
520
  }
274
521
 
275
522
  /**
@@ -77,7 +77,13 @@ export interface LauncherDeps extends LifecycleDeps {
77
77
 
78
78
  /** 轮询间隔 Promise。 */
79
79
  function pollInterval(): Promise<void> {
80
- return new Promise((resolve) => setTimeout(resolve, STATUS_POLL_INTERVAL_MS));
80
+ // IF10(#16):unref 使轮询等待 tick 不钉住事件循环(对齐 subagent-service
81
+ // gcTimer.unref?.() 先例的防御 duck-type 写法)。resolve 语义不变——unref
82
+ // 只影响进程退出判定,已注册 timer 仍按 500ms 触发。
83
+ return new Promise((resolve) => {
84
+ const timer = setTimeout(resolve, STATUS_POLL_INTERVAL_MS);
85
+ timer.unref?.();
86
+ });
81
87
  }
82
88
 
83
89
  /**