@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.
- package/README.md +6 -5
- package/package.json +14 -9
- package/relay/relay.mjs +45 -2
- package/skills/workflow-script-format/SKILL.md +2 -56
- package/src/host/__tests__/inflight-reporter.test.ts +26 -1
- package/src/host/__tests__/pi-host.test.ts +418 -4
- package/src/host/inflight-reporter.ts +13 -10
- package/src/host/pi-host.ts +205 -17
- package/src/index.ts +21 -72
- package/src/injectors/__tests__/engine-awareness.test.ts +236 -33
- package/src/injectors/__tests__/helpers/injector-test-mocks.ts +15 -7
- package/src/injectors/__tests__/workflow-list-injector.test.ts +447 -40
- package/src/injectors/resource-list-injector.ts +103 -32
- package/src/injectors/subagent-list-injector.ts +4 -28
- package/src/injectors/workflow-list-injector.ts +8 -4
- package/src/interface/__tests__/capture-tool.ts +56 -0
- package/src/interface/__tests__/commands-resume.test.ts +103 -0
- package/src/interface/__tests__/tool-prompt-contract.test.ts +361 -0
- package/src/interface/__tests__/tool-render.test.ts +105 -2
- package/src/interface/__tests__/tool-subagents.test.ts +24 -26
- package/src/interface/__tests__/tool-workflow-resume.test.ts +227 -0
- package/src/interface/__tests__/tool-workflow-script.test.ts +557 -0
- package/src/interface/__tests__/tool-workflow.test.ts +884 -0
- package/src/interface/bg-notify-render.ts +18 -9
- package/src/interface/command-actions.ts +22 -17
- package/src/interface/commands.ts +58 -12
- package/src/interface/format.ts +50 -16
- package/src/interface/id-preview.ts +4 -4
- package/src/interface/subagent-actions.ts +18 -50
- package/src/interface/subagent-tool.ts +26 -18
- package/src/interface/subagents.ts +5 -11
- package/src/interface/tool-render.ts +14 -30
- package/src/interface/tool-shared.ts +55 -12
- package/src/interface/tool-subagents.ts +21 -11
- package/src/interface/tool-workflow-script.ts +52 -72
- package/src/interface/tool-workflow.ts +345 -139
- package/src/interface/views/WorkflowsView.ts +10 -7
- package/src/interface/views/__tests__/{WorkflowsView-signature.test.ts → WorkflowsView.test.ts} +155 -11
- package/src/interface/views/detail-content.ts +3 -5
- package/src/jsonl-run-store.ts +656 -544
- package/src/model-events.ts +35 -0
- package/src/session-lifecycle.ts +349 -86
- package/src/subagents-events.ts +56 -0
- package/src/workflow-events.ts +285 -200
- package/src/workflow-notify.ts +456 -0
- package/src/injectors/__tests__/engine-section-stability.test.ts +0 -353
- package/src/injectors/__tests__/model-list-injector.test.ts +0 -136
- package/src/injectors/__tests__/subagent-list-injector.test.ts +0 -376
- package/src/interface/__tests__/detectors.test.ts +0 -161
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +0 -183
- package/src/interface/__tests__/tool-workflow-run-builtin-name.test.ts +0 -219
- package/src/interface/__tests__/tool-workflow-script-generate.test.ts +0 -251
- package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +0 -215
- package/src/interface/__tests__/workflow-state-file-exposure.test.ts +0 -38
- package/src/interface/__tests__/workflow-tool-prompt.test.ts +0 -129
- package/src/interface/helpers.ts +0 -272
- package/src/interface/views/__tests__/detail-content-session-file.test.ts +0 -73
- package/src/interface/views/__tests__/record-progress.test.ts +0 -235
package/src/jsonl-run-store.ts
CHANGED
|
@@ -1,328 +1,544 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Workflow Extension —
|
|
2
|
+
* Workflow Extension — Run Record Store
|
|
3
3
|
*
|
|
4
4
|
* RunStore port 的 Infra 实现。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
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
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* - **
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
// ──
|
|
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
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
89
|
+
* v2 条目 → 注册定界 / 终态条目抑制。载荷按 kind 分流;载荷形状损坏(缺 runId
|
|
90
|
+
* 等半写形态)warn 留证后跳过([SO-DATA-2] per-entry 隔离口径:残缺 entry 不得
|
|
91
|
+
* 让 loadAll 返回空)。
|
|
99
92
|
*/
|
|
100
|
-
|
|
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
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
*
|
|
142
|
-
* 原实现靠「deserializeRun 抛 TypeError → catch → warn」;codec 收敛后形状损坏
|
|
143
|
-
* 走 undefined 返回(warn 分支保持同等留证),try/catch 保留兜底 codec 唯一抛点
|
|
144
|
-
* (done 快照缺 reason 的 WorkflowRun I2 不变式)。
|
|
191
|
+
* 文件不存在(ENOENT)原样上抛交调用方分流(新形态实体早期崩溃 vs 历史实体)。
|
|
145
192
|
*/
|
|
146
|
-
function
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
238
|
+
return events;
|
|
181
239
|
}
|
|
182
240
|
|
|
183
|
-
/**
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
249
|
+
return undefined;
|
|
199
250
|
}
|
|
200
251
|
|
|
201
|
-
/**
|
|
202
|
-
*
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
// ──
|
|
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
|
-
*
|
|
231
|
-
*
|
|
232
|
-
* (未 flush 的 running 尾部丢失,等价崩溃链由 kill-9 恢复收编)明显放大。
|
|
233
|
-
* 模块私有:无外部消费方(构造参数 saveDebounceMs 可调窗口,测试经其注入)。
|
|
269
|
+
* 单个 call 的重建中间形态(fold 循环产出;AgentCall 构造延迟到 Trace 建立后
|
|
270
|
+
* ——traceNode 需与 Trace 共享同一节点副本,D-10 引用共享)。
|
|
234
271
|
*/
|
|
235
|
-
|
|
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
|
-
*
|
|
239
|
-
* 文件上限。
|
|
285
|
+
* record 事件流 → WorkflowRun 聚合重建(纯函数,零 IO)。
|
|
240
286
|
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* -
|
|
246
|
-
*
|
|
247
|
-
*
|
|
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
|
-
*
|
|
250
|
-
*
|
|
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
|
-
*
|
|
266
|
-
*
|
|
305
|
+
* argsSummary → args 尽力恢复(旧格式帧回落通道,core resume-run.parseArgsSummary
|
|
306
|
+
* 同款语义):现行写入面 run-created 帧携带 args 全文(上方优先消费);本函数只
|
|
307
|
+
* 服务旧格式帧——未截断摘要可完整恢复,截断/不可解析回落 {}(旧格式流的 $ARGS
|
|
308
|
+
* 语义限制,回落处置对齐 core 侧「尽力恢复」——core 同款分支 warn 留证,非静默)。
|
|
267
309
|
*/
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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 (
|
|
512
|
+
/** Session directory root (record streams live under <sessionDir>/workflow-state/). */
|
|
281
513
|
sessionDir: string;
|
|
282
|
-
/** Pi ExtensionAPI for
|
|
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
|
-
|
|
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
|
-
*
|
|
300
|
-
*
|
|
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,
|
|
553
|
+
return path.join(this.sessionDir, STATE_DIR_NAME);
|
|
343
554
|
}
|
|
344
555
|
|
|
345
|
-
/**
|
|
346
|
-
private
|
|
347
|
-
return path.join(this.stateDir, `${runId}
|
|
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
|
-
/**
|
|
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.
|
|
567
|
+
return this.recordPathFor(runId);
|
|
353
568
|
}
|
|
354
569
|
|
|
355
570
|
/**
|
|
356
|
-
*
|
|
357
|
-
*
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
583
|
+
* 终局记录查询(record run-settled 帧投影):生产消费方 = 通知载荷链
|
|
584
|
+
* (runSettledEffects——载荷源帧直取)。miss = 无终局帧(未终局,或流读失败
|
|
585
|
+
* 降级——调用方按「不可判定」处置:通知跳过 + error 留痕,不回退兜底)。
|
|
586
|
+
* 流损坏(RecordStreamCorruptionError)与 IO 错误同走 miss + warn(通知链
|
|
587
|
+
* 不是恢复面,保守 miss 可诊断;恢复面的拒绝语义在 loadAll)。
|
|
418
588
|
*/
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
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
|
-
*
|
|
447
|
-
*
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
*
|
|
469
|
-
*
|
|
657
|
+
* 逐注册条目重建 run:record 流全量读(严格解析)→ fold 重建 → 终局判定
|
|
658
|
+
* (fold 唯一判法)。条目面的唯一参与 = 终态条目幂等补写抑制
|
|
659
|
+
* (settledEntries)——条目不再作为终局证据(旧「journal 无帧而条目在 → 按条
|
|
660
|
+
* 目重建」的双面证据分支随 D1 判读单一裁决删除:条目是投影锚,不是第二判据)。
|
|
470
661
|
*/
|
|
471
|
-
private
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
679
|
+
events = readRecordStream(recordPath);
|
|
488
680
|
} catch (err) {
|
|
489
681
|
if (isEnoentError(err)) {
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
727
|
+
* 终态条目幂等补写(v2 收编面):载荷经 core barrel 的
|
|
728
|
+
* buildWorkflowRecordSettledEntryData 构造器单源复用(防字段集手抄漂移——壳写
|
|
729
|
+
* 点与 core finalizeRun 写点同一构造;字段源 = record run-settled 帧 + ask
|
|
730
|
+
* 计数投影)。best-effort:appendEntry 失败留痕不阻断 loadAll(条目是投影锚,
|
|
731
|
+
* 下次 loadAll 幂等重试)。
|
|
559
732
|
*/
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
*
|
|
572
|
-
*
|
|
766
|
+
* 收尾:幂等(缓存自身 Promise——首次未完成时并发交叠进入的后续调用返回同一
|
|
767
|
+
* Promise)。[D1] 无物化面可冲刷(无批/timer/watcher),置位 disposed 后返回。
|
|
573
768
|
*
|
|
574
|
-
*
|
|
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
|
-
|
|
592
|
-
|
|
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
|
-
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
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
|
}
|