@zhushanwen/pi-subagent-workflow 8.14.7 → 9.0.1

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 (58) hide show
  1. package/README.md +6 -5
  2. package/package.json +14 -9
  3. package/relay/relay.mjs +45 -2
  4. package/skills/workflow-script-format/SKILL.md +2 -56
  5. package/src/host/__tests__/inflight-reporter.test.ts +26 -1
  6. package/src/host/__tests__/pi-host.test.ts +418 -4
  7. package/src/host/inflight-reporter.ts +13 -10
  8. package/src/host/pi-host.ts +205 -17
  9. package/src/index.ts +21 -72
  10. package/src/injectors/__tests__/engine-awareness.test.ts +236 -33
  11. package/src/injectors/__tests__/helpers/injector-test-mocks.ts +15 -7
  12. package/src/injectors/__tests__/workflow-list-injector.test.ts +447 -40
  13. package/src/injectors/resource-list-injector.ts +103 -32
  14. package/src/injectors/subagent-list-injector.ts +4 -28
  15. package/src/injectors/workflow-list-injector.ts +8 -4
  16. package/src/interface/__tests__/capture-tool.ts +56 -0
  17. package/src/interface/__tests__/commands-resume.test.ts +103 -0
  18. package/src/interface/__tests__/tool-prompt-contract.test.ts +361 -0
  19. package/src/interface/__tests__/tool-render.test.ts +105 -2
  20. package/src/interface/__tests__/tool-subagents.test.ts +24 -26
  21. package/src/interface/__tests__/tool-workflow-resume.test.ts +227 -0
  22. package/src/interface/__tests__/tool-workflow-script.test.ts +557 -0
  23. package/src/interface/__tests__/tool-workflow.test.ts +884 -0
  24. package/src/interface/bg-notify-render.ts +18 -9
  25. package/src/interface/command-actions.ts +22 -17
  26. package/src/interface/commands.ts +58 -12
  27. package/src/interface/format.ts +50 -16
  28. package/src/interface/id-preview.ts +4 -4
  29. package/src/interface/subagent-actions.ts +18 -50
  30. package/src/interface/subagent-tool.ts +26 -18
  31. package/src/interface/subagents.ts +5 -11
  32. package/src/interface/tool-render.ts +14 -30
  33. package/src/interface/tool-shared.ts +55 -12
  34. package/src/interface/tool-subagents.ts +21 -11
  35. package/src/interface/tool-workflow-script.ts +52 -72
  36. package/src/interface/tool-workflow.ts +345 -139
  37. package/src/interface/views/WorkflowsView.ts +10 -7
  38. package/src/interface/views/__tests__/{WorkflowsView-signature.test.ts → WorkflowsView.test.ts} +155 -11
  39. package/src/interface/views/detail-content.ts +3 -5
  40. package/src/jsonl-run-store.ts +656 -544
  41. package/src/model-events.ts +35 -0
  42. package/src/session-lifecycle.ts +349 -86
  43. package/src/subagents-events.ts +56 -0
  44. package/src/workflow-events.ts +285 -200
  45. package/src/workflow-notify.ts +456 -0
  46. package/src/injectors/__tests__/engine-section-stability.test.ts +0 -353
  47. package/src/injectors/__tests__/model-list-injector.test.ts +0 -136
  48. package/src/injectors/__tests__/subagent-list-injector.test.ts +0 -376
  49. package/src/interface/__tests__/detectors.test.ts +0 -161
  50. package/src/interface/__tests__/subagent-tool-prompt.test.ts +0 -183
  51. package/src/interface/__tests__/tool-workflow-run-builtin-name.test.ts +0 -219
  52. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +0 -251
  53. package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +0 -215
  54. package/src/interface/__tests__/workflow-state-file-exposure.test.ts +0 -38
  55. package/src/interface/__tests__/workflow-tool-prompt.test.ts +0 -129
  56. package/src/interface/helpers.ts +0 -272
  57. package/src/interface/views/__tests__/detail-content-session-file.test.ts +0 -73
  58. package/src/interface/views/__tests__/record-progress.test.ts +0 -235
@@ -1,328 +1,544 @@
1
1
  /**
2
- * Workflow Extension — JSONL Run Store
2
+ * Workflow Extension — Run Record Store
3
3
  *
4
4
  * RunStore port 的 Infra 实现。
5
5
  *
6
- * 职责:持久化 WorkflowRun 聚合根到 JSONL 文件 + 跨 session 重水合。
6
+ * 职责:WorkflowRun 聚合根的 record 流权威重建(跨 session 重水合)。
7
7
  *
8
8
  * 层归属:Infra(D-12)。implements Engine 层的 RunStore port。
9
9
  * 依赖 @earendil-works/pi-coding-agent 的 ExtensionAPI/ExtensionContext(Infra 允许 Pi SDK)。
10
10
  *
11
- * 设计:
12
- * - JsonlRunStore implements RunStore(而非散落的 persist/reconstruct 自由函数)。
13
- * - **D-5: 不向后兼容**——reconstruct 时检查 snapshotVersion,无版本号或版本不匹配
14
- * 的 session 返回空数组(spec 决策:旧 run 历史价值低,不尝试兼容迁移)。
15
- * - rewrite mode(writeFile 覆盖,文件始终是最新单行快照)。
16
- * - **W17 [D4] workflow-record 自描述 entry**:每次成功 flush 同步 append 一条完整
17
- * 快照 entry(pi.appendEntry)——pi 文件(session JSONL)是 workflow 数据持久化
18
- * 权威,state 文件降级为纯性能缓存(读序 = entry > state 文件 > 空,写路径保留)。
19
- * 旧 `workflow-state-link` 指针 entry 退役(loadAll 保留兼容读,存量 run 不丢)。
20
- * [B-1/OR-5 同源] entry append 按 runId 节流(见 save 注释「entry append 节流」),
21
- * pi session JSONL 是 append-only 文件,节流前每次 flush 全量 append 会随 run
22
- * 时长累积出单 run O(n²) 磁盘占用。
11
+ * 介质形态([D1] record 单源存储收敛,设计 workflow-run-resume-revision §3.3 D1):
12
+ * - **唯一事实源 = record 事件流**(`<sessionDir>/workflow-state/<runId>.record.jsonl`,
13
+ * append-only,core worker-message-pump 单写者 + 收编入口追加):`run-created`
14
+ * 携带 scriptSource 全文、`agent-settled` 携带 result 全文;lifecycle/终局/calls
15
+ * 全部是 fold 内存投影,重启即从 record 重建。
16
+ * - **state 快照文件已删**:旧形态的 `<runId>.jsonl` 整文件覆盖写投影不再存在——
17
+ * 判终局 = record fold 终态唯一判法,无第二份可分叉的投影文件(设计目标 2)。
18
+ * 本类名沿用历史文件名(jsonl-run-store.ts / JsonlRunStore——消费面与测试脚本
19
+ * 的改名波及大于语义收益),语义已收敛为 record store 单模式。
20
+ * - **旧格式两件套(旧 journal `.events.jsonl` + state 快照 `<runId>.jsonl`)不读、
21
+ * 不写、不主动删**(用户裁决 2026-09-28:不做存量迁移):全部读取路径只认
22
+ * record 后缀;注册条目的 journalPath 锚点后缀天然区分新旧实体——旧后缀锚点
23
+ * = 历史 run,跳过不重建(从壳侧读取面消失即 D1 历史数据处置的预期行为)。
24
+ * - **loadAll = record 流折叠重建**:v2 注册条目定界(本会话有哪些实体)→
25
+ * record 流全量读 → fold 重建(终局 ⟸ run-settled 帧);「有注册、无终局帧」
26
+ * 的实体保持 running 交恢复链收编;终局实体幂等补写 v2 终态条目。
27
+ * **坏行停摆语义**:解析失败(半截行/非法 JSON/缺信封)与载荷完整性缺失
28
+ * (settled 帧缺 result 全文)即抛错拒绝,不静默跳过——record 流是唯一事实
29
+ * 源,读不出 = 拒绝(场景 18),静默跳过会把数据损坏伪装成「无 run 历史」。
30
+ * - **主 session 每 run 两条 v2 小条目**(投影锚,非第二事实源):注册条目
31
+ * (core lifecycle.runWorkflow 写,身份 + record 流路径锚点)与终态条目(core
32
+ * finalizeRun 终局 coda 写;本 store 的 loadAll 在「record 已终局而终态条目
33
+ * 缺失」时幂等补写)。条目可随时从 record 完整重建,判读只信 record fold。
34
+ * - **save = 显式 no-op**:run 域状态变化的持久化 = 事件追加(core 单写者链),
35
+ * 壳侧无投影可写。方法保留 = RunStore port 契约(core 调用点不改)。
23
36
  *
24
- * save 去抖语义(cw swf-perf wave2):
25
- * - **热路径**(running 中间态,本实例已写过):per-runId pending 批合并——窗口内
26
- * N 次 save 只落盘 1 次(serialize-at-flush:写 flush 时刻最新聚合状态)。
27
- * 固定窗口不重置 timer(批创建时定时一次),保证 flush 延迟有界 ≤saveDebounceMs;
28
- * agent-call 间隔秒级下 trailing 重置无合并增益反可无限推迟。
29
- * - **冷路径**(本实例对该 runId 首写,或 status !== "running" 即 done):
30
- * 同步挂链 flush 绕过 timer——首写立即可见(跨 session 重启后 loadAll 从 entry
31
- * 发现 run)、done 立即落盘(终态优先持久化:transition("done") 后的 save
32
- * 不进去抖批,去抖窗口内的崩溃不吞终态)。
33
- * - workflow-record entry append 节流([B-1],语义对齐 core FileRunStore.save
34
- * 的 OR-5 ⑥a 节流,间隔常量单源复用 core DEFAULT_SAVE_MIN_INTERVAL_MS):
35
- * running 中间态的 entry append 有最小间隔(缺省 60s;0 = 禁用),终态 flush
36
- * 的 entry 永不节流(最终状态必进 pi 权威文件,loadAll/恢复不丢终态);
37
- * state 文件 writeFile 不节流(rewrite mode 覆盖写,无累积)。节流窗口内的
38
- * entry 跳过 = pi 文件最后一条 entry 最多落后真实状态一个窗口,崩溃语义与
39
- * 未落盘 running 尾部丢失同源(kill-9 恢复收编)。
40
- * - per-runId 串行 flush 链:同 runId 的 flush 排队顺序执行(不跳过、永不并发
41
- * writeFile),链尾吞错防断链——错误只经各 save() Promise 的 settlers 传播。
42
- * - dispose():幂等(缓存自身 Promise);刷全部 pending 批 + await 全部 in-flight
43
- * 链后返回。dispose 后 save 静默 no-op + debug 日志(session_shutdown 编排收尾)。
44
- *
45
- * 序列化策略(下沉收口 D4 后):
46
- * - 快照投影/重水合/版本 guard 全部消费 core run-snapshot codec(toRunSnapshot/
47
- * fromRunSnapshot)——字段演进单点(G2);本 store 只保留 IO 策略(rewrite/
48
- * 去抖/append,D4 裁决:IO 差异归属宿主 store 层)。
49
- * - 版本值沿用 core SNAPSHOT_VERSION "wf-run-v2"(D4 裁决①:pi 存量逐字节可读)。
50
- * - pi 侧版本不匹配静默跳过语义保持(D-5);「缺 v 宽容」是 core FileRunStore
51
- * 侧的存量预处理职责,不内聚进 codec(D4 裁决②),故本侧零改动即保持。
52
- *
53
- * [S3 查证结论] pi 0.84.4 实装(node_modules/@earendil-works/pi-coding-agent/dist,
54
- * core/session-manager.js,PS-19)的 session 生命周期管理不含自动 GC:
55
- * listSessionsFromDir 只做只读扫描(readdir + `.jsonl` 过滤 + header 解析,:548-571,
56
- * 非递归——`<sessionDir>/workflow-state/` 子目录完全不在 pi 的任何扫描/清理范围内),
57
- * SessionManager.list / listAll 只是它之上的 cwd 过滤/排序封装(:1281-1287 / :1289),
58
- * 无按 age/数量的 retention/prune/expire 删除逻辑;唯一删除路径是 TUI SessionSelector
59
- * 里用户手动删除选中的单个顶层 session 文件(trash CLI → unlink fallback,
60
- * dist/modes/interactive/components/session-selector.js:539-550),非自动、
61
- * 不递归子目录。**推论:workflow-state state 文件无限累积,
62
- * 保留策略由本包自担**——磁盘侧保留默认开(OR-5 ⑥b):每次新 run state 文件首写
63
- * 成功即按 mtime 裁剪到上限(未设 {@link STATE_MAX_RUNS_ENV} 时取
64
- * {@link DEFAULT_STATE_MAX_RUNS} 默认值,见 pruneStateFilesBeyondCap;显式非法值
65
- * 是 opt-out 通道);内存侧由 evictDoneRunsBeyondCap 淘汰。W17 后 state 文件
66
- * 已降级为纯性能缓存(权威数据在 session JSONL 的 workflow-record entry),随 session
67
- * 文件被用户删除时一并消失。
37
+ * 磁盘足迹([S3 查证结论沿用] pi 不扫描/清理 `<sessionDir>/workflow-state/`,
38
+ * 保留策略由本包自担):保留维护轮的触发点与判据单源在 core
39
+ * (runRetentionMaintenanceRound,session_start 兜底触发点见 session-lifecycle.ts)。
40
+ * 本 store 不再触发维护轮——旧触发点(state 快照首写)随快照删除而消失;
41
+ * abandon 终局化接线(abandonElapsedInterruptedRuns)随 D9 拆除,interrupted 的
42
+ * 进入方收敛为崩溃收编与 terminate(core 侧)。
68
43
  */
69
44
 
70
45
  import * as fs from "node:fs";
71
46
  import * as path from "node:path";
72
47
 
73
48
  import type { CustomEntry, ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
74
- // DEFAULT_SAVE_MIN_INTERVAL_MS / DEFAULT_STATE_MAX_RUNS 保留深路径:core barrel
75
- // 刻意不导出(exports 面即 semver 契约,见 subagent-core src/index.ts 头注),壳侧
76
- // barrel 消费(DEFAULT_* 已进 core barrel——u-2c 删 ./* 通配后深路径 tsc 不可解析)
49
+ // 全部经 core barrel 消费(生产消费纪律:extensions 源码不深路径 import core)。
77
50
  import {
78
- DEFAULT_SAVE_MIN_INTERVAL_MS,
79
- DEFAULT_STATE_MAX_RUNS,
51
+ AgentCall,
52
+ ALL_RUN_OUTCOMES,
53
+ Budget,
54
+ RUN_EVENT_TYPES,
55
+ RUN_EVENT_JOURNAL_SUFFIX,
56
+ STATE_DIR_NAME,
57
+ Trace,
58
+ WORKFLOW_RECORD_CUSTOM_TYPE,
59
+ buildWorkflowRecordSettledEntryData,
60
+ classifyWorkflowRecordEntryData,
61
+ getLogger,
62
+ runSettledOutcomeToDoneReason,
63
+ type AgentResult,
64
+ type ExecutionTraceNode,
65
+ type RunOutcome,
66
+ type RunStore,
67
+ type WorkflowRecordRegisteredEntryData,
68
+ type WorkflowRecordSettledEntryData,
69
+ type WorkflowRunEvent,
80
70
  } from "@zhushanwen/subagent-core";
81
- import { getLogger } from "@zhushanwen/subagent-core";
82
-
83
71
  import { WorkflowRun } from "@zhushanwen/subagent-core";
84
- import {
85
- SNAPSHOT_VERSION,
86
- fromRunSnapshot,
87
- toRunSnapshot,
88
- pruneStateFilesBeyondCap,
89
- type RunSnapshot,
90
- } from "@zhushanwen/subagent-core";
91
72
  import { guardStaleCtx, isEnoentError, toErrorMessage } from "@zhushanwen/pi-ext-guards";
92
73
 
93
- // ── Workflow-record self-describing entry (W17, D4) ─────────
74
+ // ── [W1 / D1] v2 条目读面(注册定界 + 终态条目抑制)────────────────
75
+
76
+ /** loadAll 的 entry 扫描产物。 */
77
+ interface EntrySources {
78
+ /** v2 注册条目(runId → 注册载荷;record 重建的定界源 + journalPath 锚点)。 */
79
+ registered: Map<string, WorkflowRecordRegisteredEntryData>;
80
+ /**
81
+ * 已有 v2 终态条目的 run(runId → 终态载荷,后写覆盖 = last-wins)。唯一用途 =
82
+ * 终态条目幂等补写的抑制判定(条目已在则不重写)。**不参与终局判读**——
83
+ * 判终局 = record fold 唯一判法(D1:条目是投影锚,可随时从 record 重建)。
84
+ */
85
+ settledEntries: Map<string, WorkflowRecordSettledEntryData>;
86
+ }
94
87
 
95
88
  /**
96
- * 自描述 workflow record entry 的 customType(W17 [D4])。命名对齐 W16 的
97
- * `subagent-record`(连字符风格)。写点字面量与本常量的等值由
98
- * __tests__/jsonl-run-store-session-file.test.ts 断言钉住(消费方引用本常量,勿用裸字符串)。
89
+ * v2 条目 → 注册定界 / 终态条目抑制。载荷按 kind 分流;载荷形状损坏(缺 runId
90
+ * 等半写形态)warn 留证后跳过([SO-DATA-2] per-entry 隔离口径:残缺 entry 不得
91
+ * 让 loadAll 返回空)。
99
92
  */
100
- export const WORKFLOW_RECORD_CUSTOM_TYPE = "workflow-record";
93
+ function collectV2RecordEntry(entry: CustomEntry, entryIndex: number, sources: EntrySources): void {
94
+ if (entry.customType !== WORKFLOW_RECORD_CUSTOM_TYPE) return;
95
+ const classification = classifyWorkflowRecordEntryData(entry.data);
96
+ if (classification.ok || classification.reason !== "v2") return;
97
+ const v2 = classification.entry;
98
+ if (v2.kind === "registered") {
99
+ if (typeof v2.runId !== "string" || v2.runId === "" || typeof v2.journalPath !== "string") {
100
+ logger.warn(
101
+ `[subagent-workflow] workflow-record v2 registered entry #${entryIndex} malformed (runId/journalPath missing), skipped`,
102
+ );
103
+ return;
104
+ }
105
+ sources.registered.set(v2.runId, v2);
106
+ return;
107
+ }
108
+ // settled
109
+ if (typeof v2.runId !== "string" || v2.runId === "") {
110
+ logger.warn(
111
+ `[subagent-workflow] workflow-record v2 settled entry #${entryIndex} malformed (runId missing), skipped`,
112
+ );
113
+ return;
114
+ }
115
+ sources.settledEntries.set(v2.runId, v2);
116
+ }
117
+
118
+ /** loadAll 的 entry 扫描:主 session entries → v2 注册定界 + 终态条目抑制(唯一发现
119
+ * 通道)。历史形态 entry(v1 全量快照 / 旧 workflow-state-link 指针)静默忽略——
120
+ * 历史数据仍在盘上,不再重建;未知 entry 的容忍面不受影响。 */
121
+ function collectEntrySources(entries: SessionEntry[]): EntrySources {
122
+ const sources: EntrySources = {
123
+ registered: new Map<string, WorkflowRecordRegisteredEntryData>(),
124
+ settledEntries: new Map<string, WorkflowRecordSettledEntryData>(),
125
+ };
126
+ // 索引循环:损坏留证的 warn 需要 entry 索引(SO-DATA-2)
127
+ for (let i = 0; i < entries.length; i++) {
128
+ const entry = entries[i]!;
129
+ if (entry.type !== "custom") continue;
130
+ collectV2RecordEntry(entry, i, sources);
131
+ }
132
+ return sources;
133
+ }
134
+
135
+ // ── run-settled 帧判读(DoneReason 映射 + 终局记录投影)────────────
136
+
137
+ // DoneReason 联合判别单点 = core terminal-actions.runSettledOutcomeToDoneReason
138
+ // (值表与语义注释见 core 侧定义)——壳内消费方(foldRecordStreamToRun /
139
+ // appendSettledEntryFallback)经顶部 import 取用;re-export 维持
140
+ // workflow-notify 等壳内文件的既有本地导入路径。
141
+ /** run-settled 帧形状(从 record 事件词表提取——errorCode 类型同源,免第二定义点)。 */
142
+ type RunSettledFrame = Extract<WorkflowRunEvent, { type: "run-settled" }>;
143
+
144
+ export { runSettledOutcomeToDoneReason } from "@zhushanwen/subagent-core";
101
145
 
102
146
  /**
103
- * `workflow-record` entry 的 data schema(v1)。
104
- *
105
- * = 完整 RunSnapshot 快照(runId/status/calls/trace 等全部重建需要的字段)+ 版本号。
106
- * 读取方无需逆向解析 state 文件或指针(D4 自描述原则);snapshot 内部自带 D-5
107
- * snapshotVersion guard(fromRunSnapshot 检查),entry 层 v 与 snapshot 层 v 是两级
108
- * 独立版本(entry schema 演化 vs 快照格式演化)。
147
+ * 终局记录(record run-settled 帧载荷的进程内投影——与 core 侧
148
+ * RunSettlementRecord 同构)。reason/errorCode 直取帧载荷;DoneReason 由
149
+ * runSettledOutcomeToDoneReason 联合派生(不在本形状内预计算)。
109
150
  */
110
- // 模块内类型(不导出:无外部消费方,fallow unused_types/private_type_leaks 双轨判定;
111
- // 运行侧 workflow-extractor 的同形结构独立定义,见其注释)。
112
- interface WorkflowRecordEntryData {
113
- /** schema 版本(W17 起 v1)。消费方按 v 判别解析,不认识的版本跳过而非猜测。 */
114
- v: 1;
115
- /** 完整 RunSnapshot(与同次 flush 写入 state 文件的内容是同一份,不二次序列化)。 */
116
- snapshot: RunSnapshot;
117
- /** append 时刻 ISO 时间(诊断用;重建不依赖)。 */
118
- updatedAt: string;
151
+ export interface RunSettlementRecord {
152
+ outcome: RunOutcome;
153
+ errorCode?: RunSettledFrame["errorCode"];
154
+ reason?: string;
155
+ settledAt: number;
119
156
  }
120
157
 
121
- /** 已序列化快照 → 自描述 entry data(doFlush 消费同一 snapshot,保证 entry 与 state 文件一致)。 */
122
- function toWorkflowRecordEntryData(snapshot: RunSnapshot): WorkflowRecordEntryData {
123
- return { v: 1, snapshot, updatedAt: new Date().toISOString() };
158
+ // ── record 流读原语(严格解析 + 完整性拒绝)────────────────────
159
+
160
+ /**
161
+ * record 流损坏(读失败拒绝,场景 18)。
162
+ *
163
+ * 语义:record 流是 run 域唯一事实源(D1),解析失败或载荷完整性缺失 = 流被
164
+ * 截断/篡改/写入器缺陷——拒绝静默跳过(跳过会把损坏伪装成「无 run 历史」,
165
+ * 恢复链收编会在残缺事实上追加收编帧)。错误消息含恢复指引(错误 → 权威源 →
166
+ * 处置闭环)。
167
+ */
168
+ export class RecordStreamCorruptionError extends Error {
169
+ constructor(message: string) {
170
+ super(message);
171
+ this.name = "RecordStreamCorruptionError";
172
+ }
124
173
  }
125
174
 
126
- // ── Serialization → core codec(下沉收口 D4)──────────────────
127
- //
128
- // serializeRun/deserializeRun 本地投影已退役:快照投影/重水合/版本 guard 单源消费
129
- // core run-snapshot codec(toRunSnapshot/fromRunSnapshot)。键序与 strip live 语义
130
- // 与原本地实现逐字节一致(⛔5 快照锚定:__tests__/jsonl-run-store-snapshot-codec.test.ts);
131
- // 唯一投影差异 = spec.budgetRef 剔除(codec 单源裁决,嵌套 run 落盘少一脏字段,
132
- // 性质同 strip live——同偏差登记)。
175
+ /** record 事件词表集合([D2] 守卫判定的本地只读投影;词表 SSOT = run-events RUN_EVENT_TYPES)。 */
176
+ const RUN_EVENT_TYPE_SET: ReadonlySet<string> = new Set<string>(RUN_EVENT_TYPES);
133
177
 
134
- /** workflow-record entry → 重建 run 写入 recordRuns(v1 entry guard + D-5 版本不匹配
135
- * 跳过;同 runId 后写覆盖 = 最后一条 entry 胜出)。返回 entry 是否命中该类型。
136
- *
137
- * 版本可见性分层(D4 裁决③宿主侧落地):v 不匹配(v1 存量/未来版本)→ 静默跳过
138
- * (既有语义);v 匹配但形状损坏(codec 返回 undefined——codec 形状校验不抛)→
139
- * warn 留证。
178
+ /** 事件信封守卫(taste/no-unsafe-cast:结构断言改类型守卫——type/ts 可用性在守卫内收窄)。 */
179
+ function hasEventEnvelope(v: object): v is { type: string; ts: number } {
180
+ const rec = v as Record<string, unknown>;
181
+ return typeof rec["type"] === "string" && rec["type"] !== "" && typeof rec["ts"] === "number" && Number.isFinite(rec["ts"]);
182
+ }
183
+
184
+ /**
185
+ * record 流全量读(严格解析):合法事件行按写入序返回;坏行(JSON 解析失败 /
186
+ * 非对象 / 缺 type/ts 信封)与载荷完整性缺失(agent-settled 帧缺 result 全文)
187
+ * 抛 {@link RecordStreamCorruptionError}——不静默跳过(场景 18 坏行停摆语义,
188
+ * 与 core journal scan 活体投影的宽容跳过语义刻意分层:活体 fold 不能因单帧
189
+ * 全停,恢复读面不能对损坏装瞎)。
140
190
  *
141
- * [SO-DATA-2] per-entry 隔离:残缺 entry(截断/手改/半写)不得让 loadAll 返回空。
142
- * 原实现靠「deserializeRun 抛 TypeError → catch → warn」;codec 收敛后形状损坏
143
- * 走 undefined 返回(warn 分支保持同等留证),try/catch 保留兜底 codec 唯一抛点
144
- * (done 快照缺 reason 的 WorkflowRun I2 不变式)。
191
+ * 文件不存在(ENOENT)原样上抛交调用方分流(新形态实体早期崩溃 vs 历史实体)。
145
192
  */
146
- function collectRecordRun(entry: CustomEntry, entryIndex: number, recordRuns: Map<string, WorkflowRun>): boolean {
147
- if (entry.customType !== WORKFLOW_RECORD_CUSTOM_TYPE) return false;
148
- // v1 entry guard:schema 版本不认识 → 跳过(不猜测解析)
149
- const data = entry.data as WorkflowRecordEntryData | undefined;
150
- if (data?.v !== 1 || !data.snapshot) return true;
151
- try {
152
- if (data.snapshot.v === SNAPSHOT_VERSION) {
153
- const run = fromRunSnapshot(data.snapshot);
154
- if (run) {
155
- recordRuns.set(run.runId, run); // 后写覆盖 = 最后一条 entry 胜出
156
- } else {
157
- logger.warn(
158
- `[subagent-workflow] workflow-record entry #${entryIndex} corrupted, skipped run rebuild: snapshot shape invalid`,
159
- );
193
+ function readRecordStream(recordPath: string): WorkflowRunEvent[] {
194
+ const content = fs.readFileSync(recordPath, "utf8");
195
+ const events: WorkflowRunEvent[] = [];
196
+ const lines = content.split("\n");
197
+ for (let i = 0; i < lines.length; i++) {
198
+ const line = lines[i]!;
199
+ if (line.trim().length === 0) continue;
200
+ const corruption = (reason: string): RecordStreamCorruptionError =>
201
+ new RecordStreamCorruptionError(
202
+ `record 流损坏,拒绝静默跳过(文件=${recordPath} 行=${i + 1}):${reason}。` +
203
+ "record 流是 run 域唯一事实源(D1),坏行意味着截断/篡改/写入器缺陷。恢复:检查该文件是否被外部编辑" +
204
+ "或写入器版本与载荷契约不符(dispatchRunCreated/dispatchAskSettled);无法修复时接受该 run 不可续跑,勿手工删行。",
205
+ );
206
+ let parsed: unknown;
207
+ try {
208
+ parsed = JSON.parse(line);
209
+ } catch {
210
+ throw corruption("非法 JSON(半截行/截断写入)");
211
+ }
212
+ if (typeof parsed !== "object" || parsed === null || !hasEventEnvelope(parsed)) {
213
+ throw corruption("缺事件信封(type/ts)或非对象行");
214
+ }
215
+ const event = parsed as WorkflowRunEvent;
216
+ // [D2] 词表/词值守卫(对齐 core isWorkflowRunEventLine 判定面):type 落词表 +
217
+ // outcome(携带时)落词表——历史形态帧(旧词表成员/旧 outcome 值)按损坏拒绝
218
+ // ([D1] 历史数据处置:旧词表行不进解析路径;本 strict 读面不静默跳过坏行,
219
+ // 拒绝语义同半截行——调用方 storeHealthy=false fail-fast)。
220
+ if (!RUN_EVENT_TYPE_SET.has(event.type)) {
221
+ throw corruption(`词表外事件 type=${JSON.stringify(event.type)}(旧词表历史行——[D1] 不读旧两件)`);
222
+ }
223
+ if (event.type === "run-settled" || event.type === "agent-settled") {
224
+ // event 已窄化为 RunSettledEvent | AgentSettledEvent(两成员 outcome 均必填);
225
+ // !== undefined 防御保留——旧格式历史行可能缺字段(读取面放行,词表判据兜底)。
226
+ const outcome = event.outcome;
227
+ if (outcome !== undefined && !(ALL_RUN_OUTCOMES as readonly string[]).includes(outcome as string)) {
228
+ throw corruption(`词表外 outcome=${JSON.stringify(outcome)}([D2] 词表重构后的历史形态——interrupted 已入 lifecycle)`);
160
229
  }
161
230
  }
162
- // D-5: 版本不匹配 = old snapshot format / future version — skip silently
163
- } catch (err) {
164
- const reason = toErrorMessage(err);
165
- logger.warn(
166
- `[subagent-workflow] workflow-record entry #${entryIndex} corrupted, skipped run rebuild: ${reason}`,
167
- );
168
- }
169
- return true;
170
- }
171
-
172
- /** 旧 workflow-state-link 指针 entry → 写入 pointers(仅 state 文件发现通道,W17 前形态)。
173
- * 返回 entry 是否命中该类型。 */
174
- function collectStateLinkPointer(entry: CustomEntry, pointers: Map<string, { path: string }>): boolean {
175
- if (entry.customType !== "workflow-state-link") return false;
176
- const data = entry.data as { runId?: string; path?: string } | undefined;
177
- if (data?.runId && data?.path) {
178
- pointers.set(data.runId, { path: data.path });
231
+ if (event.type === "agent-settled" && event.result === undefined) {
232
+ throw corruption(
233
+ "agent-settled 帧缺 result 全文(record 单源后为非法形态——流被篡改或写入器未携带全文,场景 18)",
234
+ );
235
+ }
236
+ events.push(event);
179
237
  }
180
- return true;
238
+ return events;
181
239
  }
182
240
 
183
- /** loadAll 的 entry 扫描:主 session entries → 自描述 record 快照(每 runId 末条胜出)
184
- * + 旧 workflow-state-link 指针(仅 state 文件发现通道)。 */
185
- function collectEntrySources(entries: SessionEntry[]): {
186
- recordRuns: Map<string, WorkflowRun>;
187
- pointers: Map<string, { path: string }>;
188
- } {
189
- const recordRuns = new Map<string, WorkflowRun>();
190
- const pointers = new Map<string, { path: string }>();
191
- // 索引循环:collectRecordRun 的 warn 留证需要 entry 索引(SO-DATA-2)
192
- for (let i = 0; i < entries.length; i++) {
193
- const entry = entries[i]!;
194
- if (entry.type !== "custom") continue;
195
- if (collectRecordRun(entry, i, recordRuns)) continue;
196
- collectStateLinkPointer(entry, pointers);
241
+ /** 事件流尾向扫描取最后一帧 run-settled(单终局不变量下的防御性读取)。 */
242
+ function lastRunSettledEvent(
243
+ events: readonly WorkflowRunEvent[],
244
+ ): RunSettledFrame | undefined {
245
+ for (let i = events.length - 1; i >= 0; i--) {
246
+ const ev = events[i];
247
+ if (ev?.type === "run-settled") return ev;
197
248
  }
198
- return { recordRuns, pointers };
249
+ return undefined;
199
250
  }
200
251
 
201
- /** 旧 link 指针指向的 state 文件读取:末行 JSON 解析重建。损坏/不可读/版本不匹配
202
- * 返回 null(单文件失败不阻断其余 run 重建;D-5 静默跳过语义保持)。 */
203
- async function loadRunFromStateFile(filePath: string): Promise<WorkflowRun | null> {
204
- try {
205
- const content = await fs.promises.readFile(filePath, "utf8");
206
- const lines = content.split("\n").filter((l) => l.trim());
207
- const lastLine = lines[lines.length - 1];
208
- if (!lastLine) return null;
209
- const parsed: unknown = JSON.parse(lastLine);
210
- // D-5: undefined = old format / version mismatch / corrupt shape — skip silently
211
- return fromRunSnapshot(parsed) ?? null;
212
- } catch {
213
- // Corrupt/unreadable state file — skip (don't crash loadAll).
214
- return null;
252
+ /**
253
+ * 尾向扫描「末次 run-interrupted 后无 run-resumed」的中断时刻(ISO;无中断或
254
+ * 已复活 → undefined)。[D2] interrupted 暂停态的 fold 投影依据——loadAll 重建
255
+ * 的中断 run 由 meta.interruptedAt 承载(聚合 status 保持两态)。
256
+ */
257
+ function lastInterruptedAt(events: readonly WorkflowRunEvent[]): string | undefined {
258
+ for (let i = events.length - 1; i >= 0; i--) {
259
+ const ev = events[i];
260
+ if (ev?.type === "run-resumed") return undefined; // 已复活——中断标记清除
261
+ if (ev?.type === "run-interrupted") return new Date(ev.ts).toISOString();
215
262
  }
263
+ return undefined;
216
264
  }
217
265
 
218
- // ── State file retention (OR-5 ⑥b, default-on) ───────────────
219
- //
220
- // 裁剪主体单源消费 core pruneStateFilesBeyondCap(S4-A7 收口:本地曾有一份逐段
221
- // 同构的私有实现,retention 语义双份各自演化是漂移隐患);日志注入本模块
222
- // logger + [subagent-workflow] tag,错误字符串化用 ext-guards toErrorMessage——
223
- // 行为与收口前一致(差异仅经 core 单源演化时两宿主同步)。
224
-
225
- // ── JsonlRunStore ────────────────────────────────────────────
226
-
227
- const logger = getLogger("subagents");
266
+ // ── record 流 fold 重建(事件流 → WorkflowRun)──────────────────
228
267
 
229
268
  /**
230
- * save 去抖窗口默认值(ms)。区间 100-250 内取值——agent-call 间隔秒级,
231
- * 200ms 足以合并同一 call 周期内的多次状态 mutation,又不至于让崩溃窗口
232
- * (未 flush 的 running 尾部丢失,等价崩溃链由 kill-9 恢复收编)明显放大。
233
- * 模块私有:无外部消费方(构造参数 saveDebounceMs 可调窗口,测试经其注入)。
269
+ * 单个 call 的重建中间形态(fold 循环产出;AgentCall 构造延迟到 Trace 建立后
270
+ * ——traceNode 需与 Trace 共享同一节点副本,D-10 引用共享)。
234
271
  */
235
- const DEFAULT_SAVE_DEBOUNCE_MS = 200;
272
+ interface CallDraft {
273
+ taskIndex: number;
274
+ agentName: string;
275
+ phase?: string;
276
+ startedAtIso: string;
277
+ attempts: number;
278
+ /** 有 settled 帧 = done;无 = 在途(running)。 */
279
+ result?: AgentResult;
280
+ settledOutcome?: RunOutcome;
281
+ settledTs?: number;
282
+ }
236
283
 
237
284
  /**
238
- * 磁盘保留清理的上限 env(OR-5 ⑥b 默认开):workflow-state 目录内 run state
239
- * 文件上限。
285
+ * record 事件流 → WorkflowRun 聚合重建(纯函数,零 IO)。
240
286
  *
241
- * 解析语义与 core FileRunStore envName 通道一致(两实现面单源 {@link
242
- * DEFAULT_STATE_MAX_RUNS}):
243
- * - 未设/空 → 按默认上限 {@link DEFAULT_STATE_MAX_RUNS} 裁剪(**默认开**——
244
- * OR-5 修复前的 opt-in「默认关」正是跨 run 无界累积缺陷本身);
245
- * - 有限正数 → 上限 = env 值(显式覆盖默认值);
246
- * - 非法值(非有限数/≤0)→ 不清理(显式 opt-out 通道:用户意图不明时不动
247
- * 磁盘,对齐 prune 内部「任何失败都不抛」的保守哲学)。
287
+ * fold 语义(词表 = run-events.ts [D4] 对齐后 9 事件):
288
+ * - `run-created`:spec.scriptSource(全文,[D1])+ args(全文,设计 §3.1 载荷表;
289
+ * 旧格式帧回落 argsSummary 尽力恢复——parseLegacyArgsSummary)+ startedAt 锚点
290
+ * (帧 ts 优先,回落注册条目 startedAt);
291
+ * - `agent-started`:按 taskIndex 建 call(agentName/phase/startedAt);
292
+ * 重复 started 不重建(同 record fold 语义);
293
+ * - `agent-retrying`:无实体字段可回填(attempts 终值由 settled 帧承载)——跳过;
294
+ * - `agent-settled`:result 全文回填 + done 终态(settled 先于 started 的残
295
+ * 形态建占位行——同 journal fold 兜底);sessionFile/sessionId 从 result 透传
296
+ * (对齐 finalizeCall 的 call 字段填充纪律);
297
+ * - `run-settled`:终局(status=done + reason 映射 + completedAt=帧 ts);无帧 =
298
+ * running(交恢复链收编)。
248
299
  *
249
- * 用 TAIJI_ 前缀而非 PI_:本 env 是 pi 进程内读的配置 env,taiji 桌面 spawn 链按
250
- * ENV_WHITELIST_PREFIXES(只有 TAIJI_ 等)过滤,PI_ 前缀在桌面场景被静默丢弃——
251
- * 同 TAIJI_SUBAGENT_IDLE_TIMEOUT_MS 的改名教训(lifecycle-manager.ts)。
300
+ * record 流不承载的字段(budget 计数/errorLogs/trace 完整面)按恢复语义最小形态
301
+ * 缺省——步骤级详情的恢复读面 = record 流直读(session-reader 家族链),不经本聚合。
252
302
  */
253
- export const STATE_MAX_RUNS_ENV = "TAIJI_SUBAGENT_STATE_MAX_RUNS";
254
-
255
- /** 解析保留上限;env 未设/空 → 默认上限,显式非法/≤0 → undefined(不清理)。 */
256
- function getEnvStateMaxRuns(): number | undefined {
257
- const raw = process.env[STATE_MAX_RUNS_ENV];
258
- if (raw === undefined || raw === "") return DEFAULT_STATE_MAX_RUNS;
259
- const parsed = Number(raw);
260
- if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
261
- return parsed;
262
- }
263
303
 
264
304
  /**
265
- * per-runId 去抖批。窗口内 N 次 save 合并:latestRun 保留最新聚合引用
266
- * (serialize-at-flush),settlers 收集批内全部 save() 调用方的 settle 回调。
305
+ * argsSummary → args 尽力恢复(旧格式帧回落通道,core resume-run.parseArgsSummary
306
+ * 同款语义):现行写入面 run-created 帧携带 args 全文(上方优先消费);本函数只
307
+ * 服务旧格式帧——未截断摘要可完整恢复,截断/不可解析回落 {}(旧格式流的 $ARGS
308
+ * 语义限制,回落处置对齐 core 侧「尽力恢复」——core 同款分支 warn 留证,非静默)。
267
309
  */
268
- interface PendingSaveBatch {
269
- latestRun: WorkflowRun;
270
- /**
271
- * 批的去抖 timer(构造时即确定——经 {@link JsonlRunStore.armPendingBatch} 工厂
272
- * 内联组装,timer 与批对象在同一同步段成型,类型上不存在「先构造后赋值」的
273
- * 可选窗口)。
274
- */
275
- timer: NodeJS.Timeout;
276
- settlers: Array<{ resolve: () => void; reject: (e: unknown) => void }>;
310
+ function parseLegacyArgsSummary(argsSummary: string | undefined): Record<string, unknown> {
311
+ if (argsSummary === undefined || argsSummary === "" || argsSummary.endsWith("…")) return {};
312
+ try {
313
+ const parsed: unknown = JSON.parse(argsSummary);
314
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
315
+ return parsed as Record<string, unknown>;
316
+ }
317
+ } catch (err) {
318
+ // 回落 {} 不变($ARGS 语义限制),warn 留证旧格式数据异常(对齐 core
319
+ // parseArgsSummary 同分支的日志级别)
320
+ logger.warn(
321
+ `[subagent-workflow] legacy run-created argsSummary is not parseable JSON — $ARGS restored as {} (${toErrorMessage(err)})`,
322
+ );
323
+ }
324
+ return {};
277
325
  }
278
326
 
327
+ /** [foldRecordStreamToRun 拆分] 事件流 → call 重建中间形态(per taskIndex 聚合
328
+ * started/settled 两帧;settled 先于 started 的残形态占位行兜底——同 journal fold,
329
+ * 保投影不丢终局)。 */
330
+ function collectRunCallDrafts(events: readonly WorkflowRunEvent[]): Map<number, CallDraft> {
331
+ const drafts = new Map<number, CallDraft>();
332
+ for (const event of events) {
333
+ if (event.type === "agent-started") {
334
+ if (drafts.has(event.taskIndex)) continue;
335
+ drafts.set(event.taskIndex, {
336
+ taskIndex: event.taskIndex,
337
+ agentName: event.agentName,
338
+ ...(event.phase !== undefined ? { phase: event.phase } : {}),
339
+ startedAtIso: new Date(event.ts).toISOString(),
340
+ attempts: event.attempt,
341
+ });
342
+ } else if (event.type === "agent-settled") {
343
+ applySettledToRunDraft(drafts, event);
344
+ }
345
+ }
346
+ return drafts;
347
+ }
348
+
349
+ /** [collectRunCallDrafts 拆分] agent-settled 归并(无 started 行时占位兜底成行)。 */
350
+ function applySettledToRunDraft(
351
+ drafts: Map<number, CallDraft>,
352
+ event: Extract<WorkflowRunEvent, { type: "agent-settled" }>,
353
+ ): void {
354
+ const existing = drafts.get(event.taskIndex);
355
+ if (existing === undefined) {
356
+ // settled 先于 dispatched 的残形态:占位行兜底(同 journal fold——
357
+ // 保投影不丢终局)
358
+ drafts.set(event.taskIndex, {
359
+ taskIndex: event.taskIndex,
360
+ agentName: "(unknown)",
361
+ startedAtIso: new Date(event.ts).toISOString(),
362
+ attempts: event.attempt,
363
+ result: event.result,
364
+ settledOutcome: event.outcome,
365
+ settledTs: event.ts,
366
+ });
367
+ return;
368
+ }
369
+ existing.attempts = event.attempt;
370
+ existing.result = event.result;
371
+ existing.settledOutcome = event.outcome;
372
+ existing.settledTs = event.ts;
373
+ }
374
+
375
+ /** [foldRecordStreamToRun 拆分] 中间形态 → trace 节点(Trace 先重建——call 的
376
+ * traceNode 回链到 Trace 副本,D-10 引用共享;匹配不到退化为独立浅拷贝,仅保构造不炸)。 */
377
+ function draftsToExecutionTraceNodes(drafts: Map<number, CallDraft>): ExecutionTraceNode[] {
378
+ return [...drafts.values()].map((d) => ({
379
+ stepIndex: d.taskIndex,
380
+ agent: d.agentName,
381
+ task: "",
382
+ model: "",
383
+ status: d.settledOutcome === undefined ? "running" : d.settledOutcome === "done" ? "completed" : "failed",
384
+ ...(d.phase !== undefined ? { phase: d.phase } : {}),
385
+ startedAt: d.startedAtIso,
386
+ // settled 终局保真:result 全文随节点(D-10 节点持 result 引用的原语义)+
387
+ // completedAt(节点时间锚)+ error 诊断面
388
+ ...(d.result !== undefined ? { result: d.result } : {}),
389
+ ...(d.result?.error !== undefined ? { error: d.result.error } : {}),
390
+ ...(d.settledTs !== undefined ? { completedAt: new Date(d.settledTs).toISOString() } : {}),
391
+ }));
392
+ }
393
+
394
+ /** [foldRecordStreamToRun 拆分] 中间形态 → AgentCall(直接构造 done/running 终态,
395
+ * bypass markRunning/markDone 状态机守卫——重建已知良好持久态的既定先例;opts 最小
396
+ * 形态:record 流现行载荷不携带入参全文,重建聚合的 opts 仅满足 AgentCallOpts
397
+ * 契约形状,prompt 占位空串)。 */
398
+ function draftsToAgentCalls(
399
+ drafts: Map<number, CallDraft>,
400
+ sharedNodes: Map<number, ExecutionTraceNode>,
401
+ nodes: ExecutionTraceNode[],
402
+ ): Map<number, AgentCall> {
403
+ const calls = new Map<number, AgentCall>();
404
+ for (const d of drafts.values()) {
405
+ const linked = sharedNodes.get(d.taskIndex) ?? nodes.find((n) => n.stepIndex === d.taskIndex)!;
406
+ const call = new AgentCall(d.taskIndex, { prompt: "" }, linked);
407
+ call.attempts = d.attempts;
408
+ call.status = d.settledOutcome === undefined ? "running" : "done";
409
+ if (d.result !== undefined) {
410
+ call.result = d.result;
411
+ // sessionFile/sessionId 对齐 finalizeCall 的 call 字段填充纪律
412
+ if (d.result.sessionFile !== undefined) call.sessionFile = d.result.sessionFile;
413
+ if (d.result.sessionId !== undefined) call.sessionId = d.result.sessionId;
414
+ }
415
+ calls.set(d.taskIndex, call);
416
+ }
417
+ return calls;
418
+ }
419
+
420
+
421
+ /** [foldRecordStreamToRun 拆分] run spec 重建(run-created 帧优先,注册条目兜底):
422
+ * args 全文优先(设计 §3.1 载荷表 run-created 行「args」);旧格式帧回落
423
+ * argsSummary 尽力恢复(未截断可完整恢复,截断回落 {}——core parseArgsSummary
424
+ * 同款语义;两侧行为等价由 record-mode 测试锁定);scriptPath 锚定恢复(core
425
+ * rebuildRunFromRecord 同款):worker 沙箱 eval 模式无 __dirname,模板脚本靠
426
+ * scriptPath 定位 _shared 族共享件;旧格式帧缺失回落空串。 */
427
+ function rebuildRunSpecFromEntries(
428
+ created: Extract<WorkflowRunEvent, { type: "run-created" }> | undefined,
429
+ reg: WorkflowRecordRegisteredEntryData,
430
+ ) {
431
+ return {
432
+ scriptSource: created?.scriptSource ?? "",
433
+ args: created?.args ?? parseLegacyArgsSummary(created?.argsSummary),
434
+ scriptName: reg.scriptName,
435
+ scriptPath: created?.scriptPath ?? "",
436
+ ...(reg.slug !== undefined ? { slug: reg.slug } : {}),
437
+ };
438
+ }
439
+
440
+ function foldRecordStreamToRun(
441
+ runId: string,
442
+ reg: WorkflowRecordRegisteredEntryData,
443
+ events: readonly WorkflowRunEvent[],
444
+ ): WorkflowRun {
445
+ const created = events.find(
446
+ (e): e is Extract<WorkflowRunEvent, { type: "run-created" }> => e.type === "run-created",
447
+ );
448
+ const startedAtMs =
449
+ created?.ts ?? (Number.isFinite(reg.startedAt) ? reg.startedAt : Date.now());
450
+ const startedAtIso = new Date(startedAtMs).toISOString();
451
+ const spec = rebuildRunSpecFromEntries(created, reg);
452
+
453
+ const drafts = collectRunCallDrafts(events);
454
+
455
+ // Trace 先重建:call 的 traceNode 回链到 Trace 副本(D-10 引用共享;匹配不到
456
+ // 退化为独立浅拷贝,仅保构造不炸)。
457
+ const nodes = draftsToExecutionTraceNodes(drafts);
458
+ const trace = Trace.fromArray(nodes);
459
+ const sharedNodes = new Map(trace.toArray().map((n) => [n.stepIndex, n]));
460
+
461
+ const calls = draftsToAgentCalls(drafts, sharedNodes, nodes);
462
+
463
+ const settledEvent = lastRunSettledEvent(events);
464
+ if (settledEvent === undefined) {
465
+ const interruptedAt = lastInterruptedAt(events);
466
+ return WorkflowRun.reconstruct(
467
+ runId,
468
+ spec,
469
+ {
470
+ status: "running",
471
+ budget: new Budget(),
472
+ calls,
473
+ trace,
474
+ errorLogs: [],
475
+ },
476
+ {
477
+ startedAt: startedAtIso,
478
+ // 中断标记([D2] 聚合 status 两态、中断态经 meta 投影表达):末次
479
+ // run-interrupted 后无 run-resumed 复活 → meta.interruptedAt 置位,
480
+ // runSummary 投影 'interrupted'(CLI/TUI 不显示僵尸「运行中」;resume
481
+ // 资格判据在 core fold lifecycle,不受本投影影响)。
482
+ ...(interruptedAt !== undefined ? { interruptedAt } : {}),
483
+ },
484
+ );
485
+ }
486
+ const reason = runSettledOutcomeToDoneReason(settledEvent.outcome, settledEvent.errorCode);
487
+ return WorkflowRun.reconstruct(
488
+ runId,
489
+ spec,
490
+ {
491
+ status: "done",
492
+ reason,
493
+ budget: new Budget(),
494
+ calls,
495
+ trace,
496
+ errorLogs: [],
497
+ // 成功终局无 error;失败终局带帧 reason 文本(不落 generic 恢复文案)
498
+ ...(reason !== "completed" && settledEvent.reason !== undefined ? { error: settledEvent.reason } : {}),
499
+ },
500
+ {
501
+ startedAt: startedAtIso,
502
+ completedAt: new Date(settledEvent.ts).toISOString(),
503
+ },
504
+ );
505
+ }
506
+
507
+ // ── JsonlRunStore ────────────────────────────────────────────
508
+
509
+ const logger = getLogger("subagents");
510
+
279
511
  interface JsonlRunStoreOptions {
280
- /** Session directory root (state files live under <sessionDir>/workflow-state/). */
512
+ /** Session directory root (record streams live under <sessionDir>/workflow-state/). */
281
513
  sessionDir: string;
282
- /** Pi ExtensionAPI for workflow-record appendEntry writes (optional for testing). */
514
+ /** Pi ExtensionAPI for v2 终态条目补写(loadAll 收编面;optional for testing)。 */
283
515
  pi?: ExtensionAPI;
284
516
  /** Pi ExtensionContext for sessionManager.getEntries (optional for testing). */
285
517
  ctx?: ExtensionContext;
286
- /** save 去抖窗口(ms),默认 {@link DEFAULT_SAVE_DEBOUNCE_MS}。 */
287
- saveDebounceMs?: number;
288
- /**
289
- * workflow-record entry append 节流最小间隔(ms);0 = 禁用节流。缺省
290
- * {@link DEFAULT_SAVE_MIN_INTERVAL_MS}(单源复用 core 常量)。测试经此注入小窗口
291
- * (fake timers 推进)。
292
- */
293
- entryAppendMinIntervalMs?: number;
294
518
  }
295
519
 
296
- export class JsonlRunStore {
520
+ /**
521
+ * RunStore port 的 pi 宿主 Inf 实现(session 锚定:唯一事实源 = record 事件流,
522
+ * [D1] 单模式)。port 契约类型经 core barrel(RunStore 三方法 save / loadAll /
523
+ * stateFilePath,models/ports.ts)。
524
+ *
525
+ * 本类的四个 interface 面(方法分组索引):
526
+ * 1. **port 面**(RunStore 契约):save(no-op)/ loadAll(record 折叠重建)/
527
+ * stateFilePath(record 流路径)——组合根装配 LifecycleDeps.store 的注入面;
528
+ * 2. **adoption 面**(skill-reload 接管):rebind——post-reload session_start
529
+ * 就地重绑 appendEntry 源(v2 终态条目补写面的消费源);
530
+ * 3. **终局记录查询面**:settledRecordOf——通知载荷链的帧直取源;
531
+ * 4. **生命周期面**:dispose + flushPendingSaves——幂等收尾(无投影物化面后
532
+ * 为纯契约保留)。
533
+ */
534
+ export class JsonlRunStore implements RunStore {
297
535
  private readonly sessionDir: string;
298
536
  /**
299
- * workflow-record entry 的 appendEntry 源。store 对 pi 的唯一消费面是 doFlush 的
300
- * appendEntry(W17 权威 entry 写入),类型收窄为该面——[skill-reload D3] rebind
301
- * 时换入 stale-guarded 包装(见 {@link rebind}),构造时为裸 pi 原引用。
537
+ * v2 终态条目补写的 appendEntry 源(loadAll 收编面)。类型收窄为该面——
538
+ * [skill-reload D3] rebind 时换入 stale-guarded 包装,构造时为裸 pi 原引用。
302
539
  */
303
540
  private pi?: Pick<ExtensionAPI, "appendEntry">;
304
541
  private ctx?: ExtensionContext;
305
- private readonly saveDebounceMs: number;
306
- /** workflow-record entry append 节流最小间隔(ms),0 = 禁用。 */
307
- private readonly entryAppendMinIntervalMs: number;
308
- /** per-runId 去抖批(热路径)。 */
309
- private readonly pending = new Map<string, PendingSaveBatch>();
310
- /** 本实例已至少成功发起过一次 flush 的 runId(冷/热路径判据)。 */
311
- private readonly writtenOnce = new Set<string>();
312
- /**
313
- * per-runId 上次 workflow-record entry append 时刻(节流判据,时间源 Date.now()——
314
- * fake timers 下可推进)。终态 append 后删(终态后 runId 不再 save);残留条目
315
- * 只出现在 running 中 run 消失场景,单条可忽略(对齐 core FileRunStore.lastSavedAt
316
- * 的取舍先例)。
317
- */
318
- private readonly lastEntryAppendAt = new Map<string, number>();
319
- /**
320
- * per-runId 串行 flush 链。同 runId 的 flush 排队顺序执行(排队不跳过——
321
- * 跳过会丢最新状态且打破后写覆盖前写的单调性),不同 runId 互不阻塞。
322
- * 链条目 settle 后不清理:runId 数量有界、生命周期短于 store,惰性清理
323
- * 与排队写入存在竞态——取舍为每 runId 残留一个 settled Promise 引用,可忽略。
324
- */
325
- private readonly chains = new Map<string, Promise<void>>();
326
542
  private disposed = false;
327
543
  private disposePromise: Promise<void> | undefined;
328
544
 
@@ -330,285 +546,241 @@ export class JsonlRunStore {
330
546
  this.sessionDir = opts.sessionDir;
331
547
  this.pi = opts.pi;
332
548
  this.ctx = opts.ctx;
333
- this.saveDebounceMs = opts.saveDebounceMs ?? DEFAULT_SAVE_DEBOUNCE_MS;
334
- this.entryAppendMinIntervalMs = Math.max(
335
- 0,
336
- opts.entryAppendMinIntervalMs ?? DEFAULT_SAVE_MIN_INTERVAL_MS,
337
- );
338
549
  }
339
550
 
340
- /** State directory: <sessionDir>/workflow-state/ */
551
+ /** State directory: <sessionDir>/workflow-state/(目录分量经 core barrel STATE_DIR_NAME 单源) */
341
552
  private get stateDir(): string {
342
- return path.join(this.sessionDir, "workflow-state");
553
+ return path.join(this.sessionDir, STATE_DIR_NAME);
343
554
  }
344
555
 
345
- /** State file path for a given runId. */
346
- private filePathFor(runId: string): string {
347
- return path.join(this.stateDir, `${runId}.jsonl`);
556
+ /** record 流路径 for a given runId(唯一持久件)。 */
557
+ private recordPathFor(runId: string): string {
558
+ return path.join(this.stateDir, `${runId}${RUN_EVENT_JOURNAL_SUFFIX}`);
348
559
  }
349
560
 
350
- /** Public accessor: run 状态快照文件绝对路径(RunStore port 实现)。 */
561
+ /**
562
+ * Public accessor: run 唯一持久件(record 事件流)的绝对路径(RunStore port
563
+ * 实现)。[D1] 快照文件删除后,该指针语义从「state 快照」收敛为 record 流
564
+ * 路径(消费方 = 壳侧 interface 的 stateFile 展示字段)。
565
+ */
351
566
  stateFilePath(runId: string): string {
352
- return this.filePathFor(runId);
567
+ return this.recordPathFor(runId);
353
568
  }
354
569
 
355
570
  /**
356
- * Persist a single run: rewrite mode (overwrite) — file always contains the
357
- * latest complete snapshot on a single line.
358
- *
359
- * 去抖路由:
360
- * - 冷路径(本实例首写,或 status !== "running")→ 立即挂链 flush(绕过 timer),
361
- * 回滚资格 = 首写(flush 失败时 doFlush 回滚 writtenOnce,下次 save 重走冷路径);
362
- * - 热路径(running 且已写过)→ 并入 per-runId 去抖批(固定窗口不重置 timer)。
363
- *
364
- * Promise 语义:本批实际落盘后 resolve(同批多次调用共享 settle);IO 错误
365
- * (非 ENOENT)reject 本批全部调用方;ENOENT 静默 resolve(工作目录已被清理,
366
- * 持久化无意义也无法完成——见 doFlush)。
571
+ * [D1] 显式 no-op:record 单源后 run 域状态变化的持久化 = 事件追加(core
572
+ * worker-message-pump 单写者链落 record 流),壳侧无投影可写。方法保留 =
573
+ * RunStore port 契约(core runWorkflow/recoverCrashedRuns 调用点不改);恒
574
+ * resolve,零 IO。disposed 前后行为一致(no-op 本身就是收尾安全形态)。
367
575
  */
368
576
  async save(run: WorkflowRun): Promise<void> {
369
- // R5 处置:dispose 后(session_shutdown 收尾后 in-flight 链的迟到 save)静默
370
- // no-op + debug 留痕。不复活同步 flush——单向闸门状态机简单;此时 run 是
371
- // running 落盘无增益(kill-9 恢复同样转 done,failed),终态 reason 保真损失极窄。
372
- if (this.disposed) {
373
- logger.debug(
374
- `[subagent-workflow] jsonl-run-store save after dispose: silently dropped (runId=${run.runId})`,
375
- );
376
- return;
377
- }
378
-
379
- const runId = run.runId;
380
- const isFirstWrite = !this.writtenOnce.has(runId);
381
- const isCold = isFirstWrite || run.state.status !== "running";
382
- if (isCold) {
383
- // 判定即记录:原子防并发双冷(两次并发首写都判 true 会各 flush 一次 entry)。
384
- // ENOENT 边界:首写 flush 遇 ENOENT 时 entry 未写但 writtenOnce 已记——
385
- // sessionDir 已删场景持久化无意义,接受(非 ENOENT 失败由 doFlush 回滚,重走冷路径)。
386
- this.writtenOnce.add(runId);
387
- // 原子取走 pending 批(终态与最后一个 agent-call 的 debounced save 交错时,
388
- // pending 批 settlers 并入本次同步 flush 的批合并 settle,timer 取消防二次写)。
389
- const batch = this.pending.get(runId);
390
- if (batch) {
391
- clearTimeout(batch.timer);
392
- this.pending.delete(runId);
393
- }
394
- return this.enqueueFlush(runId, run, batch ? batch.settlers : [], isFirstWrite);
395
- }
396
-
397
- // 热路径:running 中间态,并入去抖批
398
- const existing = this.pending.get(runId);
399
- if (existing) {
400
- // latestRun 更新(固定窗口不重置 timer——flush 延迟有界 ≤saveDebounceMs)
401
- existing.latestRun = run;
402
- return new Promise<void>((resolve, reject) => {
403
- existing.settlers.push({ resolve, reject });
404
- });
405
- }
406
- const settlers: PendingSaveBatch["settlers"] = [];
407
- const promise = new Promise<void>((resolve, reject) => {
408
- settlers.push({ resolve, reject });
409
- });
410
- this.pending.set(runId, this.armPendingBatch(runId, run, settlers));
411
- return promise;
577
+ logger.debug(
578
+ `[subagent-workflow] record store save: no-op (record stream is the sole persistence, runId=${run.runId})`,
579
+ );
412
580
  }
413
581
 
414
582
  /**
415
- * 构造去抖批([review 修复] 工厂内联组装:timer 与批对象在同一同步段成型,
416
- * PendingSaveBatch.timer 保持非可选——消除「批先构造、timer 后赋值」靠注释维持
417
- * 的可选窗口)。timer 回调闭包经局部 batch 变量持批引用做身份守卫(ES3)。
583
+ * 终局记录查询(record run-settled 帧投影):生产消费方 = 通知载荷链
584
+ * (runSettledEffects——载荷源帧直取)。miss = 无终局帧(未终局,或流读失败
585
+ * 降级——调用方按「不可判定」处置:通知跳过 + error 留痕,不回退兜底)。
586
+ * 流损坏(RecordStreamCorruptionError)与 IO 错误同走 miss + warn(通知链
587
+ * 不是恢复面,保守 miss 可诊断;恢复面的拒绝语义在 loadAll)。
418
588
  */
419
- private armPendingBatch(
420
- runId: string,
421
- run: WorkflowRun,
422
- settlers: PendingSaveBatch["settlers"],
423
- ): PendingSaveBatch {
424
- // timer 回调闭包经下方 const batch 持批引用做身份守卫——前向引用在运行时安全:
425
- // 回调最早 saveDebounceMs 后才执行,届时 batch 已在本同步段尾部初始化完毕。
426
- const timer = setTimeout(() => {
427
- // ES3 幂等守卫(批身份比较):回调闭包持自身批引用,与 pending Map 现值做
428
- // 身份比较而非仅按键存在性判断。除「批已被冷路径/flushPendingSaves/dispose
429
- // 原子取走(clearTimeout 与回调触发在 fake timers 下可能交错)」的交接语义外,
430
- // 还防「旧 timer 撞新批」交错:本批被取走后同 runId 的新批已入 Map 时,若只看
431
- // 键存在性,旧 timer 会误取走新批提前 flush(缩短新批去抖窗口)。身份不匹配
432
- // 直接 return,批由取走方负责 flush。
433
- if (this.pending.get(runId) !== batch) return;
434
- this.pending.delete(runId);
435
- // 孤儿 Promise(无调用方持有):错误只经 settlers 传播给 save() 调用方,
436
- // 此处 catch 防止 unhandled rejection。
437
- this.enqueueFlush(runId, batch.latestRun, batch.settlers, false).catch(() => {});
438
- }, this.saveDebounceMs);
439
- // DS5:timer 必须 unref——不 unref 会钉住空转的 extension 进程不退出。
440
- timer.unref();
441
- const batch: PendingSaveBatch = { latestRun: run, timer, settlers };
442
- return batch;
589
+ settledRecordOf(runId: string): RunSettlementRecord | undefined {
590
+ let events: readonly WorkflowRunEvent[];
591
+ try {
592
+ events = readRecordStream(this.recordPathFor(runId));
593
+ } catch (err) {
594
+ if (!isEnoentError(err)) {
595
+ logger.warn(
596
+ `[subagent-workflow] record stream read failed, settlement record unavailable (runId=${runId}): ${toErrorMessage(err)}`,
597
+ );
598
+ }
599
+ return undefined;
600
+ }
601
+ const settled = lastRunSettledEvent(events);
602
+ if (settled === undefined) return undefined;
603
+ return {
604
+ outcome: settled.outcome,
605
+ ...(settled.errorCode !== undefined ? { errorCode: settled.errorCode } : {}),
606
+ ...(settled.reason !== undefined ? { reason: settled.reason } : {}),
607
+ settledAt: settled.ts,
608
+ };
443
609
  }
444
610
 
445
611
  /**
446
- * 把一次 flush 排到 runId 的串行链尾。调用方 Promise(本函数返回值)与传入
447
- * settlers 由同一次 doFlush 独占 settle 一次。
612
+ * Reconstruct all runs([D1] record 流权威重建)。
613
+ *
614
+ * **v2 注册条目定界**:注册条目给出本会话的实体集与 record 流路径锚点 →
615
+ * 逐实体 record 全量读(严格解析)→ fold 重建(终局 ⟸ run-settled 帧;非终局
616
+ * 保持 running 交恢复链收编);已终局且终态条目缺失 → 幂等补写(条目投影锚
617
+ * 的收编半边;经 rebind 后的 appendEntry 面,stale guard 内置)。
618
+ *
619
+ * **历史实体分流(D1 历史数据处置)**:注册条目 journalPath 锚点后缀非
620
+ * record 后缀 = 旧形态实体(旧 journal 锚点)→ 跳过不重建(历史 run 从壳侧
621
+ * 读取面消失 = 预期行为,不读旧两件套)。
622
+ *
623
+ * **拒绝语义**:record 流存在但读失败(损坏/IO 非 ENOENT)→ 抛错穿透(宿主
624
+ * storeHealthy=false fail-fast,workflow 域停初始化)——静默跳过会把数据损坏
625
+ * 伪装成「无 run 历史」。流缺失(ENOENT)的新形态实体 = 注册后首帧前崩溃 →
626
+ * degraded running 聚合交恢复链收编(静默丢弃会让该实体对恢复链不可见)。
627
+ *
628
+ * [已知限制·登记](沿用)恢复域 = 本 session 的 entry 集及其锚点:崩溃后用户
629
+ * 不再 resume 原 session 时,原 session 的遗留 run 对新 session 的 loadAll/
630
+ * 恢复链不可见——跨 session 收编需要跨 session 写入语义,与 W17 session 锚定
631
+ * 设计相悖,登记不改;跨进程维度的收编归 runtime 启动扫描(startupSweep)。
632
+ *
633
+ * 需要 ctx(构造时注入)——无 ctx 时返回空(测试或非 Pi 环境下)。
448
634
  */
449
- private enqueueFlush(
450
- runId: string,
451
- run: WorkflowRun,
452
- settlers: PendingSaveBatch["settlers"],
453
- rollbackFirstWrite: boolean,
454
- ): Promise<void> {
455
- const promise = new Promise<void>((resolve, reject) => {
456
- settlers.push({ resolve, reject });
457
- });
458
- // 排队不跳过:前一 flush in-flight 时本次挂链尾顺序执行,同 runId 永不并发
459
- // writeFile(整文件覆盖写并发会互相截断)。链尾吞错防断链——错误只经 settlers 传播。
460
- const next = (this.chains.get(runId) ?? Promise.resolve())
461
- .then(() => this.doFlush(runId, run, settlers, rollbackFirstWrite))
462
- .catch(() => {});
463
- this.chains.set(runId, next);
464
- return promise;
635
+ async loadAll(): Promise<WorkflowRun[]> {
636
+ if (!this.ctx) return [];
637
+ let entries: SessionEntry[];
638
+ try {
639
+ entries = this.ctx.sessionManager.getEntries();
640
+ } catch (err) {
641
+ // getEntries failed — 返回空集(降级语义保持),但必须 error 留痕:静默
642
+ // 空集会把 session 文件读故障伪装成「无 run 历史」,恢复无从下手。
643
+ logger.error(
644
+ `[subagent-workflow] loadAll: getEntries failed, returning empty run set (degraded). Recovery: check session file readability: ${toErrorMessage(err)}`,
645
+ );
646
+ return [];
647
+ }
648
+ // rebuild 的损坏错误(RecordStreamCorruptionError / IO)直接上抛——恢复面
649
+ // 拒绝语义(宿主 storeHealthy=false),不被 getEntries 的降级 catch 吞掉。
650
+ const { registered, settledEntries } = collectEntrySources(entries);
651
+ return this.rebuildRunsFromRecordStreams(registered, settledEntries);
465
652
  }
466
653
 
654
+ // ── [D1] record 流权威重建 ────────────────────────────────────
655
+
467
656
  /**
468
- * 实际落盘(在 runId 串行链上执行)。settlers 由本函数独占 settle 一次:
469
- * 成功或 ENOENT 全 resolve,其他错误全 reject。
657
+ * 逐注册条目重建 run:record 流全量读(严格解析)→ fold 重建 → 终局判定
658
+ * (fold 唯一判法)。条目面的唯一参与 = 终态条目幂等补写抑制
659
+ * (settledEntries)——条目不再作为终局证据(旧「journal 无帧而条目在 → 按条
660
+ * 目重建」的双面证据分支随 D1 判读单一裁决删除:条目是投影锚,不是第二判据)。
470
661
  */
471
- private async doFlush(
472
- runId: string,
473
- run: WorkflowRun,
474
- settlers: PendingSaveBatch["settlers"],
475
- rollbackFirstWrite: boolean,
476
- ): Promise<void> {
477
- const filePath = this.filePathFor(runId);
478
- try {
479
- // 兜底容错:run 工作目录(sessionDir)已被清理时,mkdir 抛 ENOENT,放弃持久化。
480
- // 竞态场景(review-fix-loop-e2e 等 runAndWait 测试):handleReturn 内
481
- // run.transition("done") 同步改 status 后,runAndWait 轮询发现 done 并 resolve,
482
- // 测试 afterEach 随即 rmSync 删除 sessionDir;此时 in-flight 的 mkdir
483
- // 遇到目录链已删除 → ENOENT({recursive:true} 在并发 rmSync 下仍可抛 ENOENT)。
484
- // run 既已终态(状态不再变化),持久化无意义也无法完成 → settle resolve。
485
- // 仅容错 ENOENT,其他错误(EACCES/ENOSPC 等真实磁盘问题)reject 不掩盖。
662
+ private rebuildRunsFromRecordStreams(
663
+ registered: Map<string, WorkflowRecordRegisteredEntryData>,
664
+ settledEntries: Map<string, WorkflowRecordSettledEntryData>,
665
+ ): WorkflowRun[] {
666
+ const runs: WorkflowRun[] = [];
667
+ for (const [runId, reg] of registered) {
668
+ const recordPath = reg.journalPath;
669
+ if (!recordPath.endsWith(RUN_EVENT_JOURNAL_SUFFIX)) {
670
+ // 旧形态锚点(旧 journal .events.jsonl)= 历史 run:不读旧两件套
671
+ //(D1 历史数据处置——历史 run 从壳侧读取面消失,resume 一律拒绝)。
672
+ logger.debug(
673
+ `[subagent-workflow] record store: legacy journal anchor skipped, run not rebuilt (runId=${runId}, anchor=${recordPath})`,
674
+ );
675
+ continue;
676
+ }
677
+ let events: readonly WorkflowRunEvent[];
486
678
  try {
487
- await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
679
+ events = readRecordStream(recordPath);
488
680
  } catch (err) {
489
681
  if (isEnoentError(err)) {
490
- for (const s of settlers) s.resolve();
491
- return;
682
+ if (settledEntries.has(runId)) {
683
+ // 流缺失而终态条目在:实体的呈现面归条目读者(runtime 投影 /
684
+ // session-reader),壳侧不重建——不交恢复链(对不在场的流伪造收编
685
+ // 事实)。判读单一不受影响:壳侧不产生任何 done 聚合(条目不是判据,
686
+ // 只是「流已被清理/缺失、无需收编」的存续分流信号)。
687
+ logger.warn(
688
+ `[subagent-workflow] record store: record stream missing but settled entry present, run skipped (runId=${runId}, path=${recordPath})`,
689
+ );
690
+ continue;
691
+ }
692
+ // 新形态实体的 record 流缺失(无终态条目)= 注册后首帧前崩溃:degraded
693
+ // running 基线交恢复链按中断收编(静默跳过会让该实体对恢复链/扫描全部
694
+ // 不可见)。
695
+ logger.warn(
696
+ `[subagent-workflow] record store: record stream missing, degraded rebuild for interruption adoption (runId=${runId}, path=${recordPath})`,
697
+ );
698
+ runs.push(foldRecordStreamToRun(runId, reg, []));
699
+ continue;
492
700
  }
701
+ // 损坏 / 真实 IO 错误:拒绝(穿透 → 宿主 fail-fast),不静默降级。
493
702
  throw err;
494
703
  }
495
- // serialize-at-flush:写 flush 时刻的最新聚合状态(latestRun 语义)
496
- const snapshot = toRunSnapshot(run);
497
- await fs.promises.writeFile(filePath, JSON.stringify(snapshot) + "\n", "utf8");
498
- // W17 [D4]:成功 flush 同步 append 自描述 workflow-record entry(同一份 snapshot,
499
- // entry 与 state 文件内容一致)。pi 文件是 workflow 数据持久化权威(loadAll 优先
500
- // 从 entry 重建),state 文件降级纯性能缓存。pi 未注入(测试)时跳过。
501
- // [B-1] entry append 节流(语义对齐 core FileRunStore.save OR-5 ⑥a):running
502
- // 中间态距上次 append 不足间隔 → 跳过(pi session JSONL append-only,节流前
503
- // 每次 flush 全量 append 累积单 run O(n²) 磁盘);终态永不节流(最终状态必进
504
- // pi 权威文件);间隔 0 禁用。判据在 append 成功后更新——本调用点位于 writeFile
505
- // 成功之后,writeFile 抛错时判据不更新,不吞下一次重试机会。
506
- const isTerminal = run.state.status !== "running";
507
- const now = Date.now();
508
- const lastAppendAt = this.lastEntryAppendAt.get(runId);
509
- if (
510
- this.entryAppendMinIntervalMs <= 0 ||
511
- isTerminal ||
512
- lastAppendAt === undefined ||
513
- now - lastAppendAt >= this.entryAppendMinIntervalMs
514
- ) {
515
- this.pi?.appendEntry(
516
- WORKFLOW_RECORD_CUSTOM_TYPE,
517
- toWorkflowRecordEntryData(snapshot),
704
+ const settledEvent = lastRunSettledEvent(events);
705
+ if (settledEvent === undefined && settledEntries.has(runId)) {
706
+ // 条目已判终局而 record 流无终局帧(空流/残流):与流缺失同款存续分流——
707
+ // 实体呈现面归条目读者,壳侧不重建不交恢复链。warn 留证条目与流的分叉
708
+ //(流被外部清空/篡改的信号面;恢复面的拒绝语义只在坏行,空流不拒)。
709
+ logger.warn(
710
+ `[subagent-workflow] record store: settled entry present but record stream has no run-settled frame, run skipped (runId=${runId})`,
518
711
  );
519
- if (isTerminal) {
520
- this.lastEntryAppendAt.delete(runId);
521
- } else {
522
- this.lastEntryAppendAt.set(runId, now);
523
- }
524
- }
525
- // OR-5 ⑥b 磁盘保留清理(默认开):新 run state 文件首写成功后触发(rollbackFirstWrite
526
- // 即 save() 冷路径传入的 isFirstWrite——「本实例首次写该 runId」≈ 新文件落盘时刻,
527
- // 每个 run 只清一次,热路径 flush 不重复扫描目录)。prune 内部吞错不抛,
528
- // 在串行链上 await:save 返回即清理已定,测试可同步断言目录终态。
529
- if (rollbackFirstWrite) {
530
- const maxRuns = getEnvStateMaxRuns();
531
- if (maxRuns !== undefined) {
532
- // 单源裁剪(core pruneStateFilesBeyondCap),注入本扩展 logger tag 与
533
- // toErrorMessage(行为与本地实现收口前一致)
534
- await pruneStateFilesBeyondCap(this.stateDir, maxRuns, {
535
- warn: (msg) => logger.warn(`[subagent-workflow] ${msg}`),
536
- debug: (msg) => logger.debug(`[subagent-workflow] ${msg}`),
537
- toMsg: toErrorMessage,
538
- });
539
- }
712
+ continue;
540
713
  }
541
- for (const s of settlers) s.resolve();
542
- } catch (err) {
543
- // ES9 失败回滚(热路径 flush 也会写 entry,回滚的意义收敛为「下次 save 重走
544
- // 冷路径立即重试」——不再有旧指针形态下「热路径永不写 entry → run 对重启
545
- // 不可见」的窗口)。堵住首写失败后还得等去抖窗的恢复延迟。
546
- // 残余窗口(已知接受):回滚后若再无任何 save(随即崩溃/退出),entry 与
547
- // state 文件双双缺失——等价崩溃丢失,由 kill-9 恢复兜底。
548
- if (rollbackFirstWrite) {
549
- this.writtenOnce.delete(runId);
714
+ const run = foldRecordStreamToRun(runId, reg, events);
715
+ runs.push(run);
716
+ // 终态条目幂等补写:record 已终局而主 session 终态条目缺失(终局 coda 的
717
+ // 条目半边写失败 / 旧版本写点形态)→ 补写;条目已在 → 跳过(双重启不重复
718
+ // 追加的构造性保证)。无 pi(测试/非 Pi 环境)跳过。
719
+ if (settledEvent !== undefined && !settledEntries.has(runId)) {
720
+ this.appendSettledEntryFallback(runId, settledEvent, events);
550
721
  }
551
- for (const s of settlers) s.reject(err);
552
722
  }
723
+ return runs;
553
724
  }
554
725
 
555
726
  /**
556
- * 立即刷全部 pending 去抖批(测试与排查的备用手段)。自身恒 resolve——IO 错误
557
- * 已由各 save() Promise 的 settlers 传播给调用方。store 保持可用:不动 disposed
558
- * 标志,后续 save 正常进入新去抖批。
727
+ * 终态条目幂等补写(v2 收编面):载荷经 core barrel 的
728
+ * buildWorkflowRecordSettledEntryData 构造器单源复用(防字段集手抄漂移——壳写
729
+ * 点与 core finalizeRun 写点同一构造;字段源 = record run-settled 帧 + ask
730
+ * 计数投影)。best-effort:appendEntry 失败留痕不阻断 loadAll(条目是投影锚,
731
+ * 下次 loadAll 幂等重试)。
559
732
  */
560
- async flushPendingSaves(): Promise<void> {
561
- const flushes: Promise<void>[] = [];
562
- for (const [runId, batch] of Array.from(this.pending.entries())) {
563
- clearTimeout(batch.timer);
564
- this.pending.delete(runId);
565
- flushes.push(this.enqueueFlush(runId, batch.latestRun, batch.settlers, false));
733
+ private appendSettledEntryFallback(
734
+ runId: string,
735
+ settledEvent: RunSettledFrame,
736
+ events: readonly WorkflowRunEvent[],
737
+ ): void {
738
+ if (!this.pi) return;
739
+ const data = buildWorkflowRecordSettledEntryData({
740
+ runId,
741
+ reason: runSettledOutcomeToDoneReason(settledEvent.outcome, settledEvent.errorCode),
742
+ outcome: settledEvent.outcome,
743
+ ...(settledEvent.errorCode !== undefined ? { errorCode: settledEvent.errorCode } : {}),
744
+ settledAt: settledEvent.ts,
745
+ callCount: events.filter((e) => e.type === "agent-settled").length,
746
+ usedTokens: 0,
747
+ });
748
+ try {
749
+ this.pi.appendEntry(WORKFLOW_RECORD_CUSTOM_TYPE, data);
750
+ } catch (err) {
751
+ logger.warn(
752
+ `[subagent-workflow] v2 settled entry backfill failed (runId=${runId}): ${toErrorMessage(err)}`,
753
+ );
566
754
  }
567
- await Promise.allSettled(flushes);
568
755
  }
569
756
 
757
+ // ── 生命周期面(无投影物化面后的契约保留)────────────────────
758
+
759
+ /**
760
+ * [D1] 无 pending 批(无投影物化面)——立即返回。方法保留 = 生命周期面契约
761
+ *(dispose / 排查调用点不改)。
762
+ */
763
+ async flushPendingSaves(): Promise<void> {}
764
+
570
765
  /**
571
- * 收尾:刷全部 pending 批 + 停 timer + await 全部 in-flight 链(in-flight flush
572
- * 完成后才返回),此后 save 静默 no-op。
766
+ * 收尾:幂等(缓存自身 Promise——首次未完成时并发交叠进入的后续调用返回同一
767
+ * Promise)。[D1] 无物化面可冲刷(无批/timer/watcher),置位 disposed 后返回。
573
768
  *
574
- * 幂等:dispose 缓存自身 Promise——首次未完成时并发交叠进入的后续调用返回
575
- * 同一 Promise(「dispose 返回 = 全部 flush 已落盘」对每个调用方都成立,
576
- * 无第二次拿到立即 resolve 的空 Promise 瑕疵)。故本方法不能是 async 函数
577
- * (async 总是创建新 Promise 破坏同一引用保证)。
769
+ * 本方法不能是 async 函数(async 总是创建新 Promise 破坏同一引用保证)。
578
770
  */
579
771
  dispose(): Promise<void> {
580
772
  if (this.disposePromise) return this.disposePromise;
581
- this.disposePromise = this.doDispose();
582
- return this.disposePromise;
583
- }
584
-
585
- private async doDispose(): Promise<void> {
586
- // 同步置位:阻断新 save 进入去抖/冷路径(R5 no-op 分支接住 shutdown 后
587
- // in-flight 链的迟到 save)。置位必须先于 flushPendingSaves——async 函数体
588
- // 在调用时同步执行到第一个 await,批收集发生在置位后的同一同步段,时序与
589
- // 折叠前的内联收集逐分支等值。
590
773
  this.disposed = true;
591
- // 复用 flushPendingSaves(批收集循环与 await allSettled 与折叠前内联实现逐行等价;
592
- // flushPendingSaves 自身不动 disposed——dispose 语义仍由本方法的置位与缓存 Promise 承担)
593
- await this.flushPendingSaves();
594
- // await 全部 in-flight 链(ES4:flush 全部落定后才返回)
595
- await Promise.allSettled(Array.from(this.chains.values()));
774
+ this.disposePromise = Promise.resolve();
775
+ return this.disposePromise;
596
776
  }
597
777
 
598
778
  /**
599
- * [skill-reload D3] adoption 就地重绑:post-reload session_start 接管(adoption)时
600
- * 原地改写 entry 写入源与 ctx——store 实例跨 reload 存活(D2 槽),在飞去抖批与
601
- * per-runId 串行 flush 链持有 this,原地改写对后续 flush 天然可见(不遍历对象图
602
- * 重绑:闭包引用不可枚举,漏一处 = 恢复后随机 assertActive 抛——设计被否项)。
603
- * writtenOnce / lastEntryAppendAt / pending / chains 全部保留(接管而非重建)。
604
- *
605
- * [skill-reload D5] 换入的 appendEntry 源包 guardStaleCtx:下一次 reload 窗口
606
- * (invalidate → adoption rebind 完成之间,通常 <1s)in-flight flush 触碰已 stale
607
- * 的本 pi 时统一 debug 丢弃(不分中间态/终态——appendEntry 是同步 void,stale
608
- * 表现为同步 assertActive 抛错,不包会把窗口内 flush 打成 IO 错误路径:settlers
609
- * reject + writtenOnce 回滚)。终态保全不依赖窗口内写入,由 adoption 快照重发
610
- * ({@link resendSnapshots})承担:窗口内终态的 run 在 adoption 时刻内存对象已
611
- * 是终态,快照重发追加的就是终态 entry。
779
+ * [skill-reload D3] adoption 就地重绑:post-reload session_start 接管(adoption)
780
+ * 时原地改写 appendEntry 源与 ctx——store 实例跨 reload 存活(D2 槽)。
781
+ * [W1] 换入的 appendEntry 源的消费方 = loadAll 的 v2 终态条目幂等补写(收编/
782
+ * 调和面);stale guard 语义不变(窗口内 appendEntry 统一 debug 丢弃——条目是
783
+ * 投影锚,缺失由下次 loadAll 幂等补写自愈)。
612
784
  */
613
785
  rebind(pi: ExtensionAPI, ctx: ExtensionContext): void {
614
786
  this.pi = {
@@ -626,64 +798,4 @@ export class JsonlRunStore {
626
798
  };
627
799
  this.ctx = ctx;
628
800
  }
629
-
630
- /**
631
- * [skill-reload D4] adoption 快照重发:把 runs 内全部 run 的当前快照经 per-runId
632
- * 串行 flush 链(enqueueFlush → doFlush)重发一条权威 workflow-record entry。
633
- *
634
- * 设计红线:必须经本链而非裸 pi.appendEntry——doFlush 的 await writeFile 与
635
- * appendEntry 之间存在事件循环间隙(W17 补充事实),绕链直接 append 会与
636
- * in-flight 中间态 flush 物理乱序(终态在前中间态在后,last-ways 读回 running →
637
- * 崩溃恢复误判);走串行链后同 runId 的 entry 顺序由链内闭合保证。
638
- *
639
- * 节流语义自然继承 doFlush:终态 flush 永不节流(最终状态必进 pi 权威文件);
640
- * running 中间态受既有 entryAppendMinIntervalMs(缺省 60s)节流约束可跳过——
641
- * 与常规 flush 同源(pi 文件最后一条 entry 最多落后真实状态一个窗口)。测试
642
- * 断言因此按终态/首写路径构造,不依赖中间态重发必落。
643
- *
644
- * rollbackFirstWrite=false:重发不是新文件首写(不触发磁盘保留裁剪);失败不
645
- * 回滚 writtenOnce——重发失败向上抛,由 adoption 失败处置整体兜底(G3 可见)。
646
- */
647
- async resendSnapshots(runs: Map<string, WorkflowRun>): Promise<void> {
648
- for (const run of runs.values()) {
649
- // 串行 await:adoption 是一次性路径,跨 runId 顺序无语义,但全部重发完成
650
- //(或首个失败上抛)后才返回,调用方据此判定接管完成。
651
- await this.enqueueFlush(run.runId, run, [], false);
652
- }
653
- }
654
-
655
- /**
656
- * Reconstruct all runs(W17 [D4] 读序 = workflow-record entry > state 文件 > 空)。
657
- *
658
- * 1. 优先扫描自描述 `workflow-record` entry(重建源——同一 runId 多条时最后一条
659
- * 胜出,等价「最后一次成功 flush」)。entry 层 v1 guard:不认识的版本跳过而非
660
- * 猜测;snapshot 层 D-5 snapshotVersion guard 保持(版本不匹配 → fromRunSnapshot
661
- * 返回 undefined → 跳过,不做兼容迁移)。
662
- * 2. 旧 `workflow-state-link` 指针 entry 兼容读取(优先级低——存量 run 不静默
663
- * 丢失,父文档 #9 踩坑):entry 未覆盖的 runId 经指针读 state 文件最后行。
664
- *
665
- * 需要 ctx(构造时注入)——无 ctx 时返回空(测试或非 Pi 环境下)。
666
- */
667
- async loadAll(): Promise<WorkflowRun[]> {
668
- if (!this.ctx) return [];
669
- const runs: WorkflowRun[] = [];
670
- try {
671
- const entries = this.ctx.sessionManager.getEntries();
672
- const { recordRuns, pointers } = collectEntrySources(entries);
673
-
674
- // 1) 自描述 entry 重建(优先——pi 文件是持久化权威)
675
- runs.push(...recordRuns.values());
676
-
677
- // 2) 旧 link 指针 → state 文件兼容(entry 已覆盖的 runId 跳过——link 优先级低)
678
- for (const [runId, pointer] of pointers) {
679
- if (recordRuns.has(runId)) continue;
680
- const run = await loadRunFromStateFile(pointer.path);
681
- if (run) runs.push(run);
682
- }
683
- } catch (err) {
684
- // getEntries failed — return what we have (empty).
685
- void err;
686
- }
687
- return runs;
688
- }
689
801
  }