@zhushanwen/subagent-core 0.5.1 → 0.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 (68) hide show
  1. package/dist/execution/engine/engines/zcode/reader.d.cts +1 -1
  2. package/dist/execution/engine/engines/zcode/reader.d.ts +1 -1
  3. package/dist/index.cjs +1122 -452
  4. package/dist/index.d.cts +188 -5
  5. package/dist/index.d.ts +188 -5
  6. package/dist/index.js +1133 -463
  7. package/dist/{types-Dv4QhSJ_.d.cts → types-C3fE3R9x.d.cts} +65 -0
  8. package/dist/{types-Dv4QhSJ_.d.ts → types-C3fE3R9x.d.ts} +65 -0
  9. package/dist.bundle/index.cjs +34933 -0
  10. package/package.json +2 -2
  11. package/src/execution/__tests__/agent-registry.test.ts +1 -1
  12. package/src/execution/__tests__/alive-store.test.ts +1 -1
  13. package/src/execution/__tests__/collect-budget.test.ts +291 -0
  14. package/src/execution/__tests__/collect-coordinator-service.test.ts +327 -0
  15. package/src/execution/__tests__/collect-coordinator.test.ts +371 -0
  16. package/src/execution/__tests__/collect-mixed-dispatch.test.ts +262 -0
  17. package/src/execution/__tests__/config-collect-sync.test.ts +120 -0
  18. package/src/execution/__tests__/config.test.ts +1 -1
  19. package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +1 -1
  20. package/src/execution/__tests__/finalized-marker.test.ts +1 -1
  21. package/src/execution/__tests__/gc-timer.test.ts +1 -1
  22. package/src/execution/__tests__/manifest-store-tmp-recovery.test.ts +1 -1
  23. package/src/execution/__tests__/notify-batch.test.ts +500 -0
  24. package/src/execution/__tests__/notify-ledger.test.ts +27 -0
  25. package/src/execution/__tests__/pi-invocation.test.ts +34 -2
  26. package/src/execution/__tests__/record-entry-collect.test.ts +102 -0
  27. package/src/execution/__tests__/relay-env.test.ts +12 -1
  28. package/src/execution/__tests__/session-file-gc.test.ts +1 -1
  29. package/src/execution/__tests__/spawn-event-adapter.test.ts +2 -2
  30. package/src/execution/__tests__/start-collect-guard.test.ts +255 -0
  31. package/src/execution/__tests__/start-sync-model-guard.test.ts +1 -1
  32. package/src/execution/__tests__/subagent-actions-core.test.ts +6 -0
  33. package/src/execution/__tests__/subagent-service-multiproc-guard.test.ts +13 -0
  34. package/src/execution/__tests__/sync-collect-recovery.test.ts +1117 -0
  35. package/src/execution/__tests__/tombstone-store.test.ts +1 -1
  36. package/src/execution/__tests__/worktree-git-ops.test.ts +1 -1
  37. package/src/execution/__tests__/worktree-reconcile-aging.test.ts +2 -2
  38. package/src/execution/__tests__/worktree-reconcile.integration.test.ts +1 -1
  39. package/src/execution/__tests__/worktree-registry.test.ts +1 -1
  40. package/src/execution/collect-coordinator.ts +200 -0
  41. package/src/execution/config.ts +77 -5
  42. package/src/execution/engine/__tests__/common/event-journal.test.ts +1 -1
  43. package/src/execution/engine/__tests__/common/pool-manager.test.ts +2 -2
  44. package/src/execution/engine/__tests__/conformance/contract.read-degradation.test.ts +1 -1
  45. package/src/execution/engine/__tests__/conformance/golden-replay.pi.test.ts +1 -1
  46. package/src/execution/engine/engines/zcode/__tests__/appserver-launcher.test.ts +1 -1
  47. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-degrade.test.ts +1 -1
  48. package/src/execution/execution-record.ts +7 -0
  49. package/src/execution/finalize-record.ts +15 -11
  50. package/src/execution/notifier.ts +234 -25
  51. package/src/execution/notify-host.ts +21 -1
  52. package/src/execution/record-entry.ts +16 -0
  53. package/src/execution/record-store.ts +161 -30
  54. package/src/execution/subagent-actions-core.ts +62 -6
  55. package/src/execution/subagent-service.ts +416 -7
  56. package/src/execution/sync-rebuild.ts +87 -0
  57. package/src/execution/types.ts +63 -0
  58. package/src/index.ts +1 -1
  59. package/src/orchestration/__tests__/config-loader.test.ts +1 -1
  60. package/src/orchestration/__tests__/file-run-store-prune.test.ts +1 -1
  61. package/src/orchestration/__tests__/file-run-store-throttle.test.ts +1 -1
  62. package/src/orchestration/__tests__/review-fix-loop-scriptpath-failfast.test.ts +1 -1
  63. package/src/orchestration/__tests__/script-generate.test.ts +5 -5
  64. package/src/orchestration/__tests__/skill-discovery.test.ts +2 -2
  65. package/src/orchestration/__tests__/workflow-files.test.ts +10 -10
  66. package/src/orchestration/__tests__/workflow-script-registry-impl.test.ts +1 -1
  67. package/src/shared/__tests__/atomic-write.test.ts +1 -1
  68. package/src/shared/__tests__/resource-discovery-host-roots.test.ts +5 -5
@@ -16,6 +16,8 @@ import { MAX_TIMER_DELAY_MS } from "../shared/timer-delay.ts";
16
16
  import { mapToWorkflowAgentResult } from "./agent-result-mapper.ts";
17
17
  import { removeAliveMarker } from "./alive-store.ts";
18
18
  import { bestEffort } from "./best-effort.ts";
19
+ import { CollectCoordinator } from "./collect-coordinator.ts";
20
+ import { DEFAULT_COLLECT_SYNC } from "./config.ts";
19
21
  // [V2 决策 3] lifecycle-manager idle timer:chatMode 统一投递新 turn disarm(防误杀活进程)。
20
22
  // [T4②] DEFAULT_IDLE_TIMEOUT_MS:assertIdleTimeoutMsSafe 错误文案的缺省时长基准。
21
23
  // ([M3] hasIdleTimer / hasLiveProcessHandle 的 piAdapter.hasRunningBackground 过滤随通知簇
@@ -49,11 +51,15 @@ import type { ModelConfigService } from "./model-config-service.ts";
49
51
  import type { AgentConfig, ModelInfo, ResolvedModel } from "./model-resolver.ts";
50
52
  import { type NotifyHost, type PiLike, createNotifyHost } from "./notify-host.ts";
51
53
  // [T4④ / PS-5] flush 被门拦时的未投递 pending 落盘账本(persistUndeliveredNotificationsForReplay 消费)
54
+ import type { BatchBudgetParams, BgNotifyRecord } from "./notifier.ts";
52
55
  import { getBoundNotifyLedger, NOTIFY_LEDGER_CUSTOM_TYPE } from "./notify-ledger.ts";
53
56
  import { getSubagentRecordsDir, getSubagentSessionDir } from "./path-encoding.ts";
54
57
  import { createRoundSettler } from "./round-settlement.ts";
55
58
  import type { StatusFilter } from "./record-store.ts";
56
59
  import { RecordStore } from "./record-store.ts";
60
+ // [E1 恢复批语义修复 / S11] 恢复批补发映射 + getFullRecord miss 成员兜底落标数据源
61
+ //(变化轴拆分,实现与语义注释见 sync-rebuild.ts)
62
+ import { bufferedMemberFallbackRecord, syncRebuildToNotifyMember } from "./sync-rebuild.ts";
57
63
  import { MAX_FORK_DEPTH } from "./session-context-resolver.ts";
58
64
  import {
59
65
  killAllSpawnedChildren,
@@ -242,6 +248,11 @@ const ENV_SELF_RECORD_ID = "PI_SUBAGENT_SELF_RECORD_ID";
242
248
  const ENV_DEPTH = "PI_SUBAGENT_DEPTH";
243
249
  const ENV_ROOT_CWD = "PI_SUBAGENT_ROOT_CWD";
244
250
 
251
+ /** [v2 D4] E1 等待分支 settled 有界重扫上限。无上限重扫 = 泄漏(设计 §3.3 D4 被否
252
+ * 谱系);8 次覆盖重启后主 agent 对 resumable 成员的典型续跑轮次,达限仍有
253
+ * running → disposed,交下次 session_start 收敛。 */
254
+ const SETTLED_RESCAN_LIMIT = 8;
255
+
245
256
  /** resolveIdentity 的产物——一次确定、写入 record 后不再变。 */
246
257
  interface ResolvedIdentity {
247
258
  agent: string;
@@ -314,12 +325,30 @@ export class SubagentService {
314
325
  /** [D4-①] 通知簇 host 面(notifyComplete/notifyClosed/pending 注册注销 + notifier
315
326
  * 实例封装,原私有通知簇四方法与模块函数的搬移落点——notify-host.ts)。
316
327
  * deps 惰性求值(pi/session 级状态运行时可变),行为与原 constructor 内
317
- * createNotifier(this.piAdapter()) 逐字节等价。session_start revive,shutdown dispose。 */
328
+ * createNotifier(this.piAdapter()) 逐字节等价。session_start revive,shutdown dispose。
329
+ * [sync-collect 合并] toNotifyRecord/notify/notifyBatch 随批路由需要由 host 导出
330
+ * (collectCoordinator 闭包与 E9/E1 直发路径消费,见 notify-host.ts 接口注释)。 */
318
331
  private readonly notifyHost: NotifyHost = createNotifyHost({
319
332
  getPi: () => this.pi,
320
333
  listRunning: () => this.store.listRunning(),
321
334
  getIsIdle: () => this.isIdleFn,
322
335
  });
336
+
337
+ /** collectCoordinator(subagent-sync-collect U2):sync 批缓冲 + 闭合检测 + flush 分流。 */
338
+ private readonly collectCoordinator: CollectCoordinator;
339
+ /** [E9] dispose 时已转 async 写账的成员 id(revive 后 flushBatch 防御过滤用)。
340
+ * 背景:dispose 后同进程 revive(/resume /fork /new)时协调器内部缓冲仍持有已转换
341
+ * 成员快照(协调器无 drain API,U5 领地不含 collect-coordinator.ts)——若后续新
342
+ * sync 成员触发闭合,陈旧快照会随批重投(新成员集新 hash,账本跨键不拦)→ 双重
343
+ * 通知。flushBatch 闭包按本集过滤,陈旧成员零重投。id 唯一 per spawn,无误伤面。 */
344
+ private readonly e9ConvertedIds = new Set<string>();
345
+ /** [v2 D4] E1 等待分支的 settled 有界重扫状态(null = 未注册)。disposed 后保持
346
+ * 非 null——同 session 内不再重复注册(补发完成/达限后 settled 边沿已无事可做,
347
+ * 单注册即单重扫);initSession(revive)置 null 允许新 session 重新注册。
348
+ * pi.on 无 off(见 armSettledRescan):旧 handler 闭包捕获旧 state,未 disposed 时
349
+ * 遇 settled 边沿仍会执行,扫描 this.mainSessionFile 当前值(handler 不绑定注册时
350
+ * 的文件域);dispose 的惰化处置见 dispose()。 */
351
+ private settledRescanState: { disposed: boolean; scans: number } | null = null;
323
352
  /** [MF#4][MF#2] fork 深度按 async 调用链传递(AsyncLocalStorage),替代共享可变计数器。
324
353
  * 主 session=0;fork 进入子 session 期间推进为子深度,供嵌套 fork 经 ALS 读到自身深度作为
325
354
  * parentForkDepth。并发 background fork 各自独立调用链,不再互相压低深度值。
@@ -353,7 +382,7 @@ export class SubagentService {
353
382
  /** [D4-②] 轮次结算回调(原 buildSessionRunnerContext 内的 onRoundSettled 业务闭包
354
383
  * 搬移至 round-settlement.ts;deps 回调闭包惰性求值,session-runner agent_settled 时消费)。 */
355
384
  private readonly settleRound = createRoundSettler({
356
- notifyComplete: (record) => this.notifyHost.notifyComplete(record),
385
+ notifyComplete: (record) => this.collectCoordinator.route(record),
357
386
  reportRecordTransition: (record) => this.store.reportRecordTransition(record),
358
387
  closeAfterRoundSettled: (record) => this.closeAfterRoundSettled(record),
359
388
  });
@@ -390,6 +419,12 @@ export class SubagentService {
390
419
 
391
420
  private readonly manifestStore: ManifestStore;
392
421
 
422
+ /**
423
+ * [D6 #7a] records 目录(与 manifestStore 同源同一推导)——屏障失败 warn 带 manifest
424
+ * 文件路径用(ManifestStore.dir 私有,此处不破封装另存同源值;漂移由构造点同语句保证不发生)。
425
+ */
426
+ private readonly recordsDir: string;
427
+
393
428
  /**
394
429
  * [T1/PS-9] subagent sessionDir(getSubagentSessionDir 推导,与 store 同源同一 rootCwd)。
395
430
  * 传给 doFinalizeRecord 的 FinalizeDeps.sessionDir——record.sessionFile 缺失时 finalize
@@ -414,8 +449,83 @@ export class SubagentService {
414
449
  const sessionsDir = getSubagentSessionDir(this.modelService.getAgentDir(), this.rootCwd);
415
450
  const recordsDir = getSubagentRecordsDir(this.modelService.getAgentDir(), this.rootCwd);
416
451
  this.sessionsDir = sessionsDir;
452
+ this.recordsDir = recordsDir;
417
453
  this.manifestStore = new ManifestStore(recordsDir);
418
454
  this.store = new RecordStore(sessionsDir, this.manifestStore, this.pi ?? undefined);
455
+ // collectCoordinator(subagent-sync-collect U2):notifyComplete 唯一路由入口——
456
+ // async 直通(字节不变)/ sync 批缓冲 + 闭合检测。[U3 接线点] 已接线:flush =
457
+ // manifest 屏障(await 全部落盘)→ notifier.notifyBatch 单条批投递(幂等键
458
+ // sync-batch:<hash> + 闭合触发排程合批 flush:同宏任务去抖窗口收纳背靠背
459
+ // route——U8 拆批盲窗修复 → ledger 写账 → attemptDeliver 边沿投递)+
460
+ // batchFinalized 落标(出口①,见下方闭包注释)。屏障先于写账 = 「通知可达 ⇒
461
+ // 索引就位」的构造性保证(时序竞态修复,见 flushBatch 闭包注释)。
462
+ // [U8] 排程与 E9/E1 交互:dispose 经 convertPendingSyncBufferToAsync 先取消挂起
463
+ // 排程(取消而非同步 flush——双通道并发写账防护,见该函数注释);E1 只在
464
+ // session_start 编排处运行(此前 dispose 已取消排程,补发直走 notifyBatch 不经
465
+ // 协调器),异常时序相撞由账本 sync-batch:<hash> 幂等拒绝兜底。
466
+ // [D4-① 合并] notifier 实例封装在 notifyHost 内(createNotifyHost 装配),本类
467
+ // 经 this.notifyHost.notify / notifyBatch / toNotifyRecord 出口消费。
468
+ this.collectCoordinator = new CollectCoordinator({
469
+ notifyAsync: (record) => {
470
+ const notify = this.notifyHost.toNotifyRecord(record);
471
+ if (notify) this.notifyHost.notify(notify);
472
+ },
473
+ toNotifyRecord: (record) => this.notifyHost.toNotifyRecord(record),
474
+ // 闭合判定数据源:listAllActive(原始 ExecutionRecord 内存态,携带 collectMode/
475
+ // batchFinalized 原始值)。[U3 修正] 原接 collectRecords——其经 recordToSubagent
476
+ // 投影丢 collectMode(U2 披露的投影缺口)→ 真链上闭合判定恒立即闭合、跨轮续累
477
+ // 失效(真链 trace 实证)。非终态 sync 成员必在内存(archive 只删终态;磁盘重建
478
+ // 残留属孤儿恢复域),内存视图语义完整。
479
+ listRecords: (limit) => this.store.listAllActive().slice(0, limit),
480
+ flushBatch: async (members) => {
481
+ // [E9 防御过滤] 排除 dispose 时已转 async 写账的成员(同进程 revive 后协调器
482
+ // 缓冲残留的陈旧快照,见 e9ConvertedIds 字段注释);全被排除 → 空批零副作用
483
+ // 返回(成员已单独写账+落标,无需再动)。
484
+ const live = members.filter((m) => !this.e9ConvertedIds.has(m.id));
485
+ if (live.length === 0) return;
486
+ // 成员全量快照:getFullRecord 冷路径重建(成员在 notifyComplete 前已 archive,
487
+ // 内存无;闭合判定 hasRunningSync 的 listRecords 扫描已建 idToFile 索引,此处
488
+ // 命中)。快照在屏障前一次取定,屏障写与落标共用同一份(manifest 与落标 entry
489
+ // 字段同源)。
490
+ // [S11] getFullRecord 不可达(子 session 文件缺失/已 GC 的窗口)的成员改用
491
+ // 缓冲快照兜底落标,不再跳过——该成员仍随 live 进批写账(成员集 hash 含它),
492
+ // 跳过落标的旧行为会在重启后让 E1 重新收集它(无标记候选),以异成员集 hash
493
+ // 重新补发 → 同成员双投递(异 hash 不触发账本 sync-batch 幂等;旧注释「该
494
+ // 窗口下 E1 本也收不到该成员」不成立:E1 走主 session 文件末条 entry 扫描,
495
+ // 与 getFullRecord 的 manifest/子文件通路相互独立,manifest 屏障 best-effort
496
+ // 写失败同样制造此窗口)。
497
+ const fulls: SubagentRecord[] = [];
498
+ const fullMissed: BgNotifyRecord[] = [];
499
+ for (const m of live) {
500
+ const full = this.store.getFullRecord(m.id);
501
+ if (full) fulls.push(full);
502
+ else fullMissed.push(m);
503
+ }
504
+ // [时序屏障] 成员 manifest 写先于写账并 await 全部落盘——「通知可达 ⇒ 索引
505
+ // 就位」的构造性保证(by construction):批通知的指针行消费依赖
506
+ // records/<sa-id>.json 反查索引,fire-and-forget 下「通知送达时已落盘」只是
507
+ // 大概率成立(探针实测 mtime 相对 notify entry ±2/3ms 方向不定)。写失败仍
508
+ // best-effort(debug 不阻断写账投递,语义与 doFinalizeRecord Step 4 一致)。
509
+ await this.writeSyncBatchManifestBarrier(fulls);
510
+ // 单条批投递:accepted=false(同成员集批已在账——E1 重建重发/重复 flush)或
511
+ // 空批/dispose → 零副作用返回,不落标(设计 §3.1.3 出口①绑「写账成功」)。
512
+ const accepted = this.notifyHost.notifyBatch(live, this.getCollectSyncBudget());
513
+ if (!accepted) return;
514
+ // batchFinalized 纯落标(设计 §3.1.3 两出口之一:批闭合 flush 写账成功后)。
515
+ // collectMode/batchFinalized 显式覆写在 appendBatchFinalizedEntry 内(防非
516
+ // entry 源重建丢标记);末条 entry 带标记 → E1 重建扫描(collectMode=sync 且
517
+ // 无标记才收)据此排除,防双重通知。源序「写账先于落标」不变(v1 幂等窗口
518
+ // 语义);manifest 已在屏障提前写,幂等窗口内崩溃时索引更早已就位。
519
+ for (const full of fulls) {
520
+ this.appendBatchFinalizedEntry(full);
521
+ }
522
+ // [S11] miss 成员兜底落标(缓冲快照 → 最小标记 entry,映射见 sync-rebuild.ts),
523
+ // 与 fulls 同出口①语义(写账成功后统一补标)。
524
+ for (const m of fullMissed) {
525
+ this.appendBatchFinalizedEntry(bufferedMemberFallbackRecord(m, this.sessionRootId ?? undefined));
526
+ }
527
+ },
528
+ });
419
529
  // #11:注册进程级 observability 单例——ui-request-queue.handleUiRequest 经
420
530
  // globalThis 桥接(notifyMissingHandlerGlobal)调到同一实例,共享
421
531
  // warnedMissingHandlerSessions 去重集合。未注册时 queue 走 fallback warn(不去重)。
@@ -459,6 +569,13 @@ export class SubagentService {
459
569
  this.initExecContextBaseline(envRoot, init.sessionId);
460
570
  // revive(dispose 的逆操作:/resume /fork /new 后复活)
461
571
  this._disposed = false;
572
+ // [v2 D4] settled 重扫状态随 revive 重置:新 session 的 E1 若再判「仍有 running」
573
+ // 可重新注册。旧 handler 闭包捕获旧 state:正常时序(session_shutdown →
574
+ // session_start)下已随 dispose() 惰化;未经 dispose 的时序残留仍会在 settled
575
+ // 边沿执行——其扫描 this.mainSessionFile 当前值(非注册时的旧文件),行为等价于
576
+ // 新 session 多注册一次扫描,由账本 sync-batch:<hash> 幂等 + batchFinalized 候选
577
+ // 过滤收敛,无跨 session 污染面。
578
+ this.settledRescanState = null;
462
579
  this.store.revive();
463
580
  this.notifyHost.revive();
464
581
  // 孤儿终态恢复(放 initSession 末尾:setPi 已注入(appendEntry 可用)、
@@ -531,11 +648,13 @@ export class SubagentService {
531
648
  /** 孤儿终态恢复委托(RecordStore.recoverOrphanRecords 的唯一调用入口,维持 store
532
649
  * private 封装——与 recoverManifestTmpFiles 同模式;[D4] public 面收窄:唯一调用方
533
650
  * 是 initSession,转 private)。判定语义见 store 侧注释。
651
+ * mainSessionFile 随调用透传(v2 D3 覆写 merge 数据源:主文件末条 entry 的批域
652
+ * 标记与轮终 result/model;initSession 先赋值后恢复,时序就绪)。
534
653
  * 随后跑 entry-born 孤儿恢复(无子文件锚的 register-only record,spawn 窗口期死亡,
535
654
  * E2E 实测缺口)——主 session 文件经 getMainSessionFile 注入(构造期可空)。 */
536
655
  private recoverOrphanRecords(): void {
537
656
  try {
538
- this.store.recoverOrphanRecords(this.sessionRootId ?? undefined);
657
+ this.store.recoverOrphanRecords(this.sessionRootId ?? undefined, this.mainSessionFile);
539
658
  } catch (err) {
540
659
  logger.warn("[subagents] orphan recovery failed", {
541
660
  reason: toErrorMessage(err),
@@ -659,10 +778,271 @@ export class SubagentService {
659
778
  * listener 仍然存活。若 pending-notifications 先于本扩展执行 session_shutdown(后注册
660
779
  * 先执行的语义下会如此),listener 已注销,unregister 事件被静默丢弃。这是可接受的
661
780
  * 退化——进程退出后两侧状态本就不保证一致,下次 session_start 的 crash recovery 会修正。 */
781
+ /** [U4 deviation #8 接线] collectSync 预算热读(flush 时读值,与 getCollectSyncDefault
782
+ * 同款访问链 modelService.getGlobalConfig().collectSync)。节缺失/读失败 → undefined
783
+ * → notifyBatch 落 buildBatchLlmContent 设计默认值(4000/24000,E5 不炸启动)。
784
+ * sanitizeCollectSync 保证节存在时两字段必有合法正整数。 */
785
+ private getCollectSyncBudget(): BatchBudgetParams | undefined {
786
+ const cs = this.collectSyncSection();
787
+ return cs !== undefined ? { perItemChars: cs.perItemChars, totalChars: cs.totalChars } : undefined;
788
+ }
789
+
790
+ /** [E9 专用] batchFinalized 落标 + manifest fire-and-forget 写(设计 §3.1.5 E9)。
791
+ * 批通知路径(flush/E1)已改走「manifest 屏障 → 写账 → 纯落标」序列(「通知可达
792
+ * ⇒ 索引就位」的构造性保证,见 flushBatch 闭包 / runSyncCollectRecoveryScan),
793
+ * 不再经本 helper;仅 E9 转换的成员保持原形态——其走 async 单条通知(全文注入、
794
+ * 无指针行消费),manifest 无时序要求,落标后 fire-and-forget 补写(list 后手动
795
+ * 反查的顺带索引)。
796
+ * 路径 = getFullRecord 冷路径重建 → appendBatchFinalizedEntry 纯落标 → fire
797
+ * manifest。getFullRecord 不可达(子 session 文件缺失/已 GC)→ 跳过该成员
798
+ * (详见 flushBatch 闭包注释)。 */
799
+ private markMembersBatchFinalized(memberIds: readonly string[]): void {
800
+ for (const id of memberIds) {
801
+ const full = this.store.getFullRecord(id);
802
+ if (!full) continue;
803
+ this.appendBatchFinalizedEntry(full);
804
+ // 反查索引缺失只影响指针行反查(session-reader 错误文案已指引绝对路径兜底),
805
+ // 不构成落标失败(与屏障路径的 best-effort 语义同源,仅无时序保证)。
806
+ void this.writeBatchMemberManifest(full).catch((err: unknown) => {
807
+ logger.debug(
808
+ `[subagents] batch-finalized manifest write failed (record=${full.id})`,
809
+ { reason: err instanceof Error ? err.message : String(err) },
810
+ );
811
+ });
812
+ }
813
+ }
814
+
815
+ /** batchFinalized 落标唯一出口(appendEntry 公共末步,纯落标):显式覆写
816
+ * collectMode/batchFinalized → reportSubagentRecord。覆写动机:recordToSubagent
817
+ * 投影已含两字段(U5 修复),但 getFullRecord 冷路径含 sidecar/manifest 重建分支
818
+ * (非 entry 源),显式赋值防非 entry 源重建时丢标记。
819
+ *
820
+ * [v2 D1 断链 1] 落标即「离开批 = 通知已/即将送达 = 指针行即将被消费」——成功
821
+ * 成员走 SP-5 改道 doFinalizeRoundToIdle(不写 manifest),批路径不补写则
822
+ * records/<sa-id>.json 永不产生、session-reader 反查 0 命中。manifest 写点已从
823
+ * 本出口的 fire-and-forget 前移至各调用方:批通知路径(flush/E1)在写账前屏障
824
+ * await 全部落盘(「通知可达 ⇒ 索引就位」的构造性保证);E9 保持落标后
825
+ * fire-and-forget(async 单条通知无指针行消费,无时序要求)。
826
+ * rec 两来源(flush 的 getFullRecord 内存全量 / E1 的 rebuildEntryRecord 重建
827
+ * 快照)必需字段恒齐备(id/agentName←agent/rootSessionId/createdAt←startedAt),
828
+ * task/slug/parentRecordId 等可选 undefined 自然缺省。 */
829
+ private appendBatchFinalizedEntry(rec: SubagentRecord): void {
830
+ this.store.reportSubagentRecord({ ...rec, collectMode: "sync", batchFinalized: true });
831
+ }
832
+
833
+ /** 批成员 manifest(sa- id → sessionFile 反查索引)写的唯一投影点(D2 字段投影 +
834
+ * status 如实投影:成功成员此刻 record 实态 running+resumable → "running",后续
835
+ * message upgrade 走完整 finalize 时 Step 4 原子覆盖为 "closed")。
836
+ * 返回原始 promise 不吞错——失败语义由调用方定:批通知路径经
837
+ * writeSyncBatchManifestBarrier 的 allSettled(debug 不阻断写账);E9 经
838
+ * fire-and-forget catch(debug 不阻断落标)。 */
839
+ private writeBatchMemberManifest(rec: SubagentRecord): Promise<void> {
840
+ return this.manifestStore.writeManifest({
841
+ id: rec.id,
842
+ rootSessionId: rec.rootSessionId ?? "",
843
+ parentRecordId: rec.parentRecordId,
844
+ agentName: rec.agent,
845
+ status: rec.status,
846
+ createdAt: rec.startedAt,
847
+ completedAt: rec.endedAt,
848
+ sessionFile: rec.sessionFile,
849
+ task: rec.task,
850
+ slug: rec.slug,
851
+ model: rec.model,
852
+ });
853
+ }
854
+
855
+ /** [时序屏障] 批通知路径(flush/E1)专用:成员 manifest 并行写 + await 全部完成
856
+ * (allSettled)后才允许写账投递——「通知可达 ⇒ 索引就位」的构造性保证。写失败
857
+ * 不阻断(best-effort 语义与 doFinalizeRecord Step 4 一致:反查索引缺失只影响指针行
858
+ * 反查,session-reader 错误文案已指引绝对路径兜底,不构成写账失败);warn 留痕
859
+ * (D6 #7a / SC-1:屏障失败意味着该成员指针行反查索引缺失,debug 级在排障时不可见)。 */
860
+ private async writeSyncBatchManifestBarrier(recs: readonly SubagentRecord[]): Promise<void> {
861
+ const results = await Promise.allSettled(recs.map((rec) => this.writeBatchMemberManifest(rec)));
862
+ for (let i = 0; i < results.length; i++) {
863
+ const result = results[i]!;
864
+ if (result.status === "rejected") {
865
+ logger.warn(
866
+ `[subagents] batch-finalized manifest write failed (record=${recs[i]!.id}, manifest=${this.recordsDir}/${recs[i]!.id}.json)`,
867
+ { reason: result.reason instanceof Error ? result.reason.message : String(result.reason) },
868
+ );
869
+ }
870
+ }
871
+ }
872
+
873
+ /** [E9] dispose 时批未闭合:缓冲中已终态未通知成员逐条转 async 语义写账(放弃攒批)
874
+ * + 落 batchFinalized 标记(E1 重建扫描据此排除,防双重通知),交由既有 shutdown
875
+ * flush / resume 重放兑底;仍在跑的成员走现有退出路径(disposeAllRecords 关闭,
876
+ * 与 async 一致)。写账用 notifier.notify 现有通路(ledger.record + attemptDeliver)。
877
+ * 源序:写账先于落标——写账后崩溃 → E1 重建收该成员,但 async notifyId 与批 hash
878
+ * 跨键不拦的重发属设计披露的 E9 残余窗(at-least-once 良性,PS-17 同族,v1 接受)。 */
879
+ private convertPendingSyncBufferToAsync(): void {
880
+ // [U8 拆批修复·E9 交互] 挂起的合批排程先取消——dispose 已选「放弃攒批转 async」
881
+ // 语义(最简语义 = 取消而非同步 flush):放任排程触发会让 flushBatch(批 hash
882
+ // 通道)与下方逐条 notify(async id 通道)双通道并发写账 → 同成员双投递,且触发
883
+ // 点可能落在 notifier/store dispose 之后。取消后缓冲原样保留,本函数既有单通路
884
+ // 完整接管(协调器侧幂等:无排程时 no-op)。
885
+ this.collectCoordinator.cancelScheduledFlush();
886
+ const members = this.collectCoordinator.pendingMembers();
887
+ if (members.length === 0) return;
888
+ for (const member of members) {
889
+ this.e9ConvertedIds.add(member.id);
890
+ this.notifyHost.notify(member);
891
+ }
892
+ this.markMembersBatchFinalized(members.map((m) => m.id));
893
+ logger.warn(
894
+ `[subagents] E9 dispose: converted ${members.length} buffered sync member(s) to async notify`,
895
+ { ids: members.map((m) => m.id) },
896
+ );
897
+ }
898
+
899
+ /**
900
+ * [E1] sync 批崩溃恢复钩子(设计 §3.1.5 E1,index.ts session_start 恢复编排处调用,
901
+ * 须晚于 initSession——孤儿终态恢复先行收敛 running 成员,「全员终态」判定才可达):
902
+ *
903
+ * - 扫描主 session 文件每 id 末条 subagent-record entry(store.scanLastRecordEntries,
904
+ * collectLastRecordEntries 同构 + 投影扩展含 collectMode/batchFinalized + 终态五
905
+ * 字段;禁走 collectRecords light 路径——主 session 落标 entry 对它不可见);
906
+ * - 只收 collectMode=sync 且无 batchFinalized 的成员(排除已通过批 flush 或 E9
907
+ * 转换离场的,防双重通知),按 rootSessionId 过滤当前根;
908
+ * - 全员终态且账本无同成员集批记录 → manifest 屏障(await 落盘,「通知可达 ⇒
909
+ * 索引就位」构造性保证,与 flushBatch 同款)→ notifyBatch 补发(内容 = 末条
910
+ * entry 终态快照;账本 record 同 hash 幂等拒绝 = 已投递/已在账,两种结局都算
911
+ * 「已处理」);
912
+ * - 仍有 running → 本次不动,注册 settled 有界重扫(D4,见 armSettledRescan)——
913
+ * 成员延迟终态(主 agent 冷路径 resume → 正常流落 entry)由 settled 边沿驱动
914
+ * 重扫收敛,不再依赖「下次 session_start」作唯一再驱动(v2 §2.4 断链 4);
915
+ * running 口径与协调器同构
916
+ * (resumable 豁免,v2 D3——覆写不可达的防御分支残余不被误判「仍在跑」);
917
+ * - 补发尝试后统一补 batchFinalized 标记(账本拒绝也算已投递;直接用末条重建快照
918
+ * 落标不经 getFullRecord——子文件缺失/已 GC 时标记仍可落盘,窗口自愈不依赖二次
919
+ * 重启;补标自身崩溃重入幂等收敛,末条 entry last-writer-wins)。
920
+ *
921
+ * async 化(时序屏障修复):返回 Promise 但**内部自捕获不外抛**——宿主 index.ts
922
+ * session_start 以同步 try/catch 调用(其 catch 兑现不到 promise 内的异常),
923
+ * 自捕获维持同款 warn 容错语义,浮动调用零适配、不产生 unhandled rejection。
924
+ *
925
+ * [U8 拆批修复] 与协调器合批排程无交集:E1 只在 session_start 编排处运行(此前
926
+ * dispose 已取消挂起排程),补发直走 notifier.notifyBatch 不经协调器;异常时序
927
+ * 相撞由账本 sync-batch:<hash> 幂等拒绝兜底。
928
+ */
929
+ async recoverSyncCollectBatch(): Promise<void> {
930
+ try {
931
+ const { outcome } = await this.runSyncCollectRecoveryScan();
932
+ // [v2 D4] 断链 4:等待分支不再死等——挂 settled 有界重扫(幂等单注册)。
933
+ if (outcome === "waiting") {
934
+ this.armSettledRescan();
935
+ }
936
+ } catch (err) {
937
+ // 与 index.ts 调用点原 try/catch 的 warn 容错同语义(该处 catch 对 async 化后
938
+ // 的 promise 异常兑现不到,容错收敛到本方法内部)。
939
+ logger.warn("[subagents] sync collect batch recovery failed", {
940
+ reason: err instanceof Error ? err.message : String(err),
941
+ });
942
+ }
943
+ }
944
+
945
+ /** [E1/D4] 单次「扫描→判定→可达则补发+落标」,E1 首扫与 settled 重扫共用同一实现
946
+ * (防两处复制粘贴分岔)。三态返回:idle(无 sync 候选——已全部落标/E9 转换/异根,
947
+ * 无事可等)/ waiting(仍有 running 成员,本次不动)/ dispatched(全员终态,已补发
948
+ * +统一落标)。async:补发前有 manifest 屏障 await(见函数头 E1 注释)。 */
949
+ /** 返回 outcome + waitingIds(达限 warn 需滞留成员 id,D6 #7b——仅 waiting 态非空)。 */
950
+ private async runSyncCollectRecoveryScan(): Promise<{
951
+ outcome: "idle" | "waiting" | "dispatched";
952
+ waitingIds: string[];
953
+ }> {
954
+ const lastRecords = this.store.scanLastRecordEntries(this.mainSessionFile);
955
+ if (lastRecords.length === 0) return { outcome: "idle", waitingIds: [] };
956
+ const rootFilter = this.sessionRootId;
957
+ const candidates = lastRecords.filter(
958
+ (r) =>
959
+ r.collectMode === "sync" &&
960
+ r.batchFinalized !== true &&
961
+ (rootFilter === undefined || r.rootSessionId === rootFilter),
962
+ );
963
+ if (candidates.length === 0) return { outcome: "idle", waitingIds: [] };
964
+ // [v2 D3] 与协调器 hasRunningSync 同构口径(collect-coordinator.ts):running+
965
+ // resumable 视为已完成、不阻止补发——成功成员崩溃时的末条 entry 恒为轮终
966
+ // running+resumable(SP-5 有意语义),旧口径只看 status !== "closed" 会把主场景
967
+ // (批内含成功成员)顶死在「等自然终态」永不补发(v2 §2.3 断链 3)。
968
+ const running = candidates.filter((r) => r.resumable !== true && r.status !== "closed");
969
+ if (running.length > 0) {
970
+ logger.debug(
971
+ `[subagents] E1 sync batch recovery: ${running.length} member(s) still running, wait for natural completion`,
972
+ { ids: running.map((r) => r.id) },
973
+ );
974
+ return { outcome: "waiting", waitingIds: running.map((r) => r.id) };
975
+ }
976
+ // [时序屏障] manifest 先于写账 await 全部落盘——E1 补发同样是批通知(指针行消费
977
+ // 依赖反查索引),「通知可达 ⇒ 索引就位」的构造性保证与 flushBatch 同款。
978
+ await this.writeSyncBatchManifestBarrier(candidates);
979
+ // 全员终态:单条批补发(budget 热读与 flushBatch 同源);账本同 hash 幂等拒绝也算
980
+ // 已投递(批已在账/已销账,重放由账本承接)——两种结局统一补标。
981
+ const members = candidates.map((r) => syncRebuildToNotifyMember(r));
982
+ const accepted = this.notifyHost.notifyBatch(members, this.getCollectSyncBudget());
983
+ for (const rec of candidates) {
984
+ this.appendBatchFinalizedEntry(rec);
985
+ }
986
+ logger.warn(
987
+ `[subagents] E1 sync batch recovery: re-notified ${members.length} member(s) (ledger accepted=${accepted})`,
988
+ { ids: members.map((m) => m.id) },
989
+ );
990
+ return { outcome: "dispatched", waitingIds: [] };
991
+ }
992
+
993
+ /** [v2 D4] 注册 agent_settled 有界重扫(幂等:settledRescanState 非 null 不叠加注册
994
+ * ——E1 现仅 session_start 单调用点,守卫是防第二入口引入时的注册叠加断言面)。
995
+ * 每次 settled 边沿重跑同一 E1 扫描(runSyncCollectRecoveryScan):dispatched
996
+ * (补发+落标完成,scan 的 warn 已留痕)或 idle(候选已被其他通路落标)→ disposed;
997
+ * 累计 SETTLED_RESCAN_LIMIT 次仍在等 → disposed + warn 留痕(D6 #7b / SC-2:达限
998
+ * 放弃重扫意味着滞留成员的批通知要等下次 session_start 才收敛,含滞留 id 的 warn
999
+ * 是唯一线索,debug 级排障不可见;后续事件零处理,下次 session_start 再收敛)。
1000
+ * pi.on 无 off(0.84.4 实装)——disposed 标志包装兑现退订(scheduler extension
1001
+ * index.ts subscribeSettled 同款先例)。P-settled 定谳(0.84.4 dist 实装证据):
1002
+ * pi.on 为 per-extension 列表分发——loader.js `on()` 把 handler push 进
1003
+ * extension.handlers.get(event) 数组(非覆盖),runner.js `emit()` 对全部
1004
+ * extension 的全部 handler 逐一 await;故本注册与 ledger host 经
1005
+ * piAdapter.onAgentSettled 注册的 settled 分发互不干扰,无需降级并入 host 链。 */
1006
+ private armSettledRescan(): void {
1007
+ if (this.settledRescanState !== null) return;
1008
+ const state = { disposed: false, scans: 0 };
1009
+ this.settledRescanState = state;
1010
+ this.pi?.on?.("agent_settled", async () => {
1011
+ if (state.disposed) return;
1012
+ state.scans += 1;
1013
+ // await 完整补发序列(manifest 屏障 → 写账 → 落标):pi emit 对 handler 逐一
1014
+ // await(P-settled 定谳,0.84.4 dist runner.js),async 化不改变分发语义。
1015
+ const { outcome, waitingIds } = await this.runSyncCollectRecoveryScan();
1016
+ if (outcome === "waiting" && state.scans < SETTLED_RESCAN_LIMIT) return;
1017
+ state.disposed = true;
1018
+ if (outcome === "waiting") {
1019
+ // D6 #7b / SC-2:warn + 滞留成员 id(debug 级排障不可见——达限即批通知挂起至下次 session_start)
1020
+ logger.warn(
1021
+ `[subagents] E1 settled rescan: reached limit (${SETTLED_RESCAN_LIMIT}) with member(s) still running, disposed until next session_start`,
1022
+ { ids: waitingIds },
1023
+ );
1024
+ }
1025
+ });
1026
+ }
1027
+
1028
+ // [E1 语义对齐 toNotifyRecord] 补发成员映射 syncRebuildToNotifyMember 拆至
1029
+ // sync-rebuild.ts(变化轴:恢复批通知语义):one-shot 成功成员末条恒
1030
+ // running+resumable(SP-5),直通 status 会让恢复批批头「0 finished」且丢
1031
+ // patchFile 的 git-apply 指针——对齐后补发记录为 closed + outcome 物化 +
1032
+ // patchFile 透传。调用点:runSyncCollectRecoveryScan。
1033
+
662
1034
  dispose(): void {
663
1035
  if (this._disposed) return;
664
1036
  this._disposed = true;
665
1037
  this.stopGcTimer();
1038
+ // [v2 D4] settled 重扫 handler 惰化:dispose 后 trailing settled 边沿若仍触发,
1039
+ // 旧 handler 不得再跑扫描——notifier 随后将 dispose,notifyBatch 短路返回 false
1040
+ // 且不写账,而 E1 dispatched 段不判 accepted 仍统一落标 → 批被标 batchFinalized
1041
+ // 而通知从未写账(永久丢失,不可逆)。惰化后通知由下次重启的 E1 首扫兑现
1042
+ // (成员无标记,候选可达)。与 initSession revive 重置不冲突:dispose 是终态置
1043
+ // disposed,revive 置 null 是新 session 的重新注册,旧 state 对象随旧 handler
1044
+ // 闭包保持 disposed 永久惰化。
1045
+ if (this.settledRescanState !== null) this.settledRescanState.disposed = true;
666
1046
  // [dispose stub] 第一时间换 stub,防 trailing ui_request 调到 stale handler 闭包
667
1047
  // (仍持有 disposed session 的 ctx)产生误导性 console.error。stub 干净降级为 cancelled。
668
1048
  // 必须在 emit/abort 之前——这些步骤可能同步触发 trailing pump。
@@ -674,6 +1054,14 @@ export class SubagentService {
674
1054
  // abortRunningControllers 需要在 disposeAllRecords archive 之前执行(archive 后 store 找不到 record)。
675
1055
  this.store.abortRunningControllers();
676
1056
  killAllSpawnedChildren();
1057
+ // [E9] 批未闭合时缓冲终态成员逐条转 async 写账 + 落 batchFinalized(设计 §3.1.5 E9)。
1058
+ // 必须在 disposeAllRecords 之前——它会把活跃 record(含 SP-5 成功回退的
1059
+ // running+resumable 缓冲成员)全部 archive 清内存,之后再 getFullRecord 落标只剩
1060
+ // 冷 idToFile(无目录扫描则 miss → 跳过落标 → E1 重建误收已转换成员);先转换取
1061
+ // 内存命中,与 flushBatch 出口①同款通路。同样在 flushPendingNotifications 之前
1062
+ //(转换条目加入本次 flush)与 notifier/store dispose 之前(写账与 appendEntry
1063
+ // 通道仍可用)。仍在跑成员不在此处理——后续 disposeAllRecords 按现有退出路径关闭。
1064
+ this.convertPendingSyncBufferToAsync();
677
1065
  // SP-4: 级联关闭所有活跃 record(parent-shutdown reason)
678
1066
  // 在 abort/kill 之后执行:先终止子进程,再清理 record 状态。
679
1067
  this.disposeAllRecords("parent-shutdown");
@@ -1081,7 +1469,7 @@ export class SubagentService {
1081
1469
  // [D4-①] 通知簇搬移 notify-host 后的遗留调用点修正:this.notifyComplete 方法已
1082
1470
  // 不存在(其余三处调用点均 this.notifyHost.notifyComplete),旧引用 throw
1083
1471
  // TypeError 被本 .catch 吞掉 → settled watchdog 失败通知静默丢失
1084
- .then(() => this.notifyHost.notifyComplete(record))
1472
+ .then(() => this.collectCoordinator.route(record))
1085
1473
  .catch((err: unknown) => bestEffort(err, "settled watchdog hot-path finalize", "error"));
1086
1474
  }
1087
1475
 
@@ -1427,6 +1815,23 @@ export class SubagentService {
1427
1815
  return this.store.collectRecords(limit, statusFilter, this.sessionRootId ?? this.sessionId ?? undefined);
1428
1816
  }
1429
1817
 
1818
+ /**
1819
+ * collectSync.default 当前生效值(subagent-sync-collect U2,偏差#3 接线:
1820
+ * startHandler 缺省 collect 解析用)。
1821
+ * config 未配/读失败 → DEFAULT_COLLECT_SYNC.default 兜底(E5 不炸启动)。
1822
+ * 新 session 生效语义与 engine 配置一致(globalConfig 由 ModelConfigService
1823
+ * reloadGlobalConfig 刷新)。
1824
+ */
1825
+ /** collectSync 节单读取点(S9,code-simplify):「读节」一处,「投影成 default 或
1826
+ * budget」各自 accessor 负责(getCollectSyncDefault / getCollectSyncBudget)。 */
1827
+ private collectSyncSection() {
1828
+ return this.modelService.getGlobalConfig().collectSync;
1829
+ }
1830
+
1831
+ getCollectSyncDefault(): "async" | "sync" {
1832
+ return this.collectSyncSection()?.default ?? DEFAULT_COLLECT_SYNC.default;
1833
+ }
1834
+
1430
1835
  /** [perf] 单 record 详情懒加载(全量:eventLog/displayItems/result/turns/tokens)。
1431
1836
  * 内存 running record 直接投影;磁盘 record 全量重建(per-file 缓存,stat 戳校验)。
1432
1837
  * 返回 undefined:id 不存在于内存与磁盘。 */
@@ -1563,6 +1968,10 @@ export class SubagentService {
1563
1968
  // 从 RunContext 回填;缺省 = pi 投影,存量调用方零感知)
1564
1969
  engine: opts.engine,
1565
1970
  engineFallback: opts.engineFallback,
1971
+ // subagent-sync-collect U2(偏差#4 接线):sync record 落 collectMode——
1972
+ // 协调器路由判据 + startHandler pendingSyncCount 枚举含本条的数据源。
1973
+ // undefined = async(缺省语义,旧记录零迁移)。
1974
+ collectMode: opts.collect === "sync" ? "sync" : undefined,
1566
1975
  controller,
1567
1976
  });
1568
1977
 
@@ -1714,7 +2123,7 @@ export class SubagentService {
1714
2123
  await this.runEngineTask(record, opts, engine, signal);
1715
2124
  // cancel 抢先(closedReason='cancelled')时 cancelBackground 自己 notify,跳过
1716
2125
  if (record.closedReason !== "cancelled") {
1717
- this.notifyHost.notifyComplete(record);
2126
+ this.collectCoordinator.route(record);
1718
2127
  }
1719
2128
  } finally {
1720
2129
  this.pool.release();
@@ -2125,7 +2534,7 @@ export class SubagentService {
2125
2534
  // 是 disposeAllRecords 的编排性关闭(record 已关、告知由 list 的 closedReason
2126
2535
  // 表达)——迟到的完成回注不注入(可能已切换的)新 session。
2127
2536
  if (notifyGateAllowsDelivery(record.closedReason)) {
2128
- this.notifyHost.notifyComplete(record);
2537
+ this.collectCoordinator.route(record);
2129
2538
  }
2130
2539
  })
2131
2540
  .catch((err: unknown) => {
@@ -2258,7 +2667,7 @@ export class SubagentService {
2258
2667
  // pending-notifications:cancel 注销(只记 registry 状态)
2259
2668
  this.notifyHost.emitPendingUnregister(record.id, "closed");
2260
2669
  // cancel 完成通知(与轮次收尾 .then 对称——cancel 抢先时 .then 跳过 notify)
2261
- this.notifyHost.notifyComplete(record);
2670
+ this.collectCoordinator.route(record);
2262
2671
  return true;
2263
2672
  }
2264
2673
 
@@ -0,0 +1,87 @@
1
+ // src/execution/sync-rebuild.ts
2
+ //
3
+ // [E1 恢复批语义修复] sync 批崩溃恢复(E1)与批闭合 flush(S11 兜底)的两条
4
+ // BgNotifyRecord/SubagentRecord 映射,从 subagent-service 拆出(D4 按变化轴拆分:
5
+ // 改恢复批的通知语义 / 落标数据源形态,只改本文件)。两函数均纯映射——零 service/
6
+ // store 依赖,rootSessionId 由调用方注入。
7
+
8
+ import { deriveOutcome } from "./execution-record.ts";
9
+ import type { BgNotifyRecord } from "./notifier.ts";
10
+ import type { SubagentRecord } from "./types.ts";
11
+
12
+ /**
13
+ * E1 末条 entry 终态快照 → BgNotifyRecord(补发成员;sync 仅 one-shot,round/
14
+ * sessionFile 不透传——与 route() 缓冲快照的 one-shot 形态对齐)。
15
+ *
16
+ * [E1 恢复批语义修复] status/outcome/patchFile 三点对齐 notify-host toNotifyRecord
17
+ * 的映射(正常 flush 路径对同一成员的补发形态),不再直通 rec.status:
18
+ * - status:one-shot 成功成员崩溃时末条 entry 恒为 running+resumable(SP-5 轮终
19
+ * 形态,resumable 豁免正据此放行其入批)——直通会让补发记录仍是 running 形态:
20
+ * buildBatchLlmContent 批头只统计 closed 成员(全员成功的恢复批显示
21
+ * 「0 finished, 0 failed, 0 cancelled」),条目文案落「finished a round」
22
+ * (对话轮次语义)。同 toNotifyRecord:closed 或非 chatMode → "closed"(仅
23
+ * chatMode 轮次形态保持 running——sync 成员恒 one-shot,此分支为防御完整性)。
24
+ * - outcome:closed 状态物化(单一权威 deriveOutcome)——resumable 成员末条未
25
+ * 终态化,closedReason 缺省与正常 flush 路径同形,无 error 即 completed、有
26
+ * error 即 failed;notifier.notifyBatch 投影边界的 `??` 兜底对此幂等。
27
+ * - patchFile:透传(依赖 rebuildEntryRecord 的同名投影)——worktree one-shot
28
+ * 成员的 git-apply 回收指针只在 closed+completed 分支文案携带(running 分支
29
+ * 不含),正常 flush 路径经 toNotifyRecord 特意携带,恢复批不得缺失。
30
+ */
31
+ export function syncRebuildToNotifyMember(rec: SubagentRecord): BgNotifyRecord {
32
+ const status: BgNotifyRecord["status"] =
33
+ rec.status === "closed" || !rec.chatMode ? "closed" : "running";
34
+ return {
35
+ id: rec.id,
36
+ status,
37
+ closedReason: rec.closedReason,
38
+ outcome: status === "closed" ? deriveOutcome(rec.closedReason, rec.error) : undefined,
39
+ agent: rec.agent,
40
+ model: rec.model,
41
+ result: rec.result,
42
+ error: rec.error,
43
+ startedAt: rec.startedAt,
44
+ endedAt: rec.endedAt,
45
+ patchFile: rec.patchFile,
46
+ };
47
+ }
48
+
49
+ /**
50
+ * [S11] getFullRecord miss 成员的缓冲快照兜底 → 最小 SubagentRecord(batchFinalized
51
+ * 落标数据源)。getFullRecord 不可达(子 session 文件缺失/已 GC)时,缓冲的
52
+ * BgNotifyRecord 是该成员唯一残存数据。最小形态只保标记链路必需字段:
53
+ * id/agent/task/startedAt 是 rebuildEntryRecord 的解析门槛(缺 task 的 entry 会被
54
+ * E1 扫描判损坏跳过——标记失效方向,task 占位空串保 entry 可解析);终态字段
55
+ * (closedReason/result/error/endedAt/model/patchFile)自缓冲快照如实投影;
56
+ * rootSessionId 取当前 session 根(E1 候选过滤的归属口径)。
57
+ */
58
+ export function bufferedMemberFallbackRecord(
59
+ m: BgNotifyRecord,
60
+ rootSessionId: string | undefined,
61
+ ): SubagentRecord {
62
+ return {
63
+ id: m.id,
64
+ agent: m.agent,
65
+ task: "",
66
+ slug: "",
67
+ status: "closed",
68
+ closedReason: m.closedReason,
69
+ mode: "background",
70
+ startedAt: m.startedAt,
71
+ rootSessionId,
72
+ parentRecordId: undefined,
73
+ depth: 0,
74
+ endedAt: m.endedAt,
75
+ turns: 0,
76
+ totalTokens: 0,
77
+ model: m.model ?? "",
78
+ thinkingLevel: undefined,
79
+ eventLog: [],
80
+ displayItems: [],
81
+ result: m.result,
82
+ error: m.error,
83
+ sessionFile: undefined,
84
+ chatMode: false,
85
+ patchFile: m.patchFile,
86
+ };
87
+ }