@zhushanwen/pi-subagent-workflow 8.7.0 → 8.8.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 (56) hide show
  1. package/README.md +5 -11
  2. package/package.json +8 -11
  3. package/skills/workflow-script-format/SKILL.md +32 -13
  4. package/src/host/__tests__/pi-host.test.ts +23 -2
  5. package/src/host/pi-host.ts +61 -6
  6. package/src/index.ts +155 -528
  7. package/src/injectors/__tests__/engine-awareness.test.ts +5 -3
  8. package/src/injectors/__tests__/engine-section-stability.test.ts +48 -33
  9. package/src/injectors/__tests__/model-list-injector.test.ts +18 -21
  10. package/src/injectors/__tests__/subagent-list-injector.test.ts +54 -14
  11. package/src/injectors/__tests__/workflow-list-injector.test.ts +26 -12
  12. package/src/injectors/engine-awareness.ts +80 -8
  13. package/src/injectors/model-list-injector.ts +15 -58
  14. package/src/injectors/resource-list-injector.ts +209 -0
  15. package/src/injectors/subagent-list-injector.ts +53 -224
  16. package/src/injectors/workflow-list-injector.ts +36 -196
  17. package/src/interface/__tests__/detectors.test.ts +46 -35
  18. package/src/interface/__tests__/subagent-tool-path-guard.test.ts +42 -26
  19. package/src/interface/__tests__/subagent-tool-prompt.test.ts +14 -3
  20. package/src/interface/__tests__/tool-workflow-run-builtin-name.test.ts +216 -0
  21. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +17 -6
  22. package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +38 -2
  23. package/src/interface/bg-notify-render.ts +7 -17
  24. package/src/interface/command-actions.ts +5 -15
  25. package/src/interface/commands.ts +116 -76
  26. package/src/interface/format.ts +354 -245
  27. package/src/interface/gui-mappers.ts +18 -22
  28. package/src/interface/helpers.ts +9 -130
  29. package/src/interface/list-component.ts +164 -117
  30. package/src/interface/list-shared.ts +3 -3
  31. package/src/interface/list-view.ts +130 -73
  32. package/src/interface/subagent-actions.ts +50 -675
  33. package/src/interface/subagent-tool-schema.ts +7 -9
  34. package/src/interface/subagent-tool.ts +5 -4
  35. package/src/interface/subagents.ts +9 -8
  36. package/src/interface/tool-render.ts +5 -15
  37. package/src/interface/tool-workflow-script.ts +34 -75
  38. package/src/interface/tool-workflow.ts +62 -96
  39. package/src/interface/tui-kit.ts +305 -0
  40. package/src/interface/views/WorkflowsView.ts +270 -207
  41. package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +3 -3
  42. package/src/interface/views/__tests__/detail-content-session-file.test.ts +7 -4
  43. package/src/interface/views/detail-content.ts +16 -14
  44. package/src/interface/views/view-constants.ts +19 -0
  45. package/src/jsonl-run-store.ts +144 -228
  46. package/agents/analyst.md +0 -61
  47. package/agents/coder.md +0 -70
  48. package/agents/debugger.md +0 -67
  49. package/agents/doc-reviewer.md +0 -50
  50. package/agents/explorer.md +0 -64
  51. package/agents/general-purpose.md +0 -32
  52. package/agents/orchestrator.md +0 -63
  53. package/agents/planner.md +0 -54
  54. package/agents/researcher.md +0 -65
  55. package/agents/reviewer.md +0 -74
  56. package/src/interface/views/format.ts +0 -320
@@ -8,14 +8,17 @@
8
8
  import { describe, expect, it } from "vitest";
9
9
 
10
10
  import { buildDetailContent } from "../detail-content.ts";
11
- import type { ThemeLike } from "../format.ts";
12
- import type { ExecutionTraceNode } from "@zhushanwen/subagent-core/orchestration/models/types.ts";
13
- import type { WorkflowRun } from "@zhushanwen/subagent-core/orchestration/models/workflow-run.ts";
11
+ import type { ThemeLike } from "../../format.ts";
12
+ import type { ExecutionTraceNode } from "@zhushanwen/subagent-core";
13
+ import type { WorkflowRun } from "@zhushanwen/subagent-core";
14
14
 
15
- /** plain theme: fg/bold 直接返回原文,无 ANSI 色码——测试断言纯文本。 */
15
+ /** plain theme: 着色方法直接返回原文,无 ANSI 色码——测试断言纯文本。
16
+ * bg/underline 为宽 ThemeLike 接口的 no-op 实现(workflow 渲染面只用 fg/bold)。 */
16
17
  const plainTheme: ThemeLike = {
18
+ bg: (_color: string, text: string) => text,
17
19
  fg: (_tag: string, text: string) => text,
18
20
  bold: (text: string) => text,
21
+ underline: (text: string) => text,
19
22
  };
20
23
 
21
24
  function makeNode(overrides: Partial<ExecutionTraceNode> = {}): ExecutionTraceNode {
@@ -12,29 +12,31 @@
12
12
 
13
13
  import { Key, matchesKey } from "@earendil-works/pi-tui";
14
14
 
15
- import { getAllToolCalls, projectLiveProgress } from "@zhushanwen/subagent-core/execution/execution-record.ts";
16
- import type { AgentEventLogEntry } from "@zhushanwen/subagent-core/execution/types.ts";
17
- import type { ExecutionTraceNode } from "@zhushanwen/subagent-core/orchestration/models/types.ts";
18
- import type { WorkflowRun } from "@zhushanwen/subagent-core/orchestration/models/workflow-run.ts";
15
+ import { getAllToolCalls, projectLiveProgress } from "@zhushanwen/subagent-core";
16
+ import type { AgentEventLogEntry } from "@zhushanwen/subagent-core";
17
+ import type { ExecutionTraceNode } from "@zhushanwen/subagent-core";
18
+ import type { WorkflowRun } from "@zhushanwen/subagent-core";
19
19
  import {
20
- BOX_BORDER_CHARS,
21
- BUDGET_TOKENS_DIVISOR,
22
20
  ELLIPSIS,
23
21
  formatActivityLine,
24
22
  formatElapsed,
25
23
  formatElapsedSeconds,
26
- formatEventLine,
27
24
  formatTokenStat,
25
+ formatTraceEventLine,
26
+ statusDotStr,
27
+ type ThemeLike,
28
+ } from "../format.ts";
29
+ import { PAGE_SCROLL_DEFAULT } from "../tui-kit.ts";
30
+ import {
31
+ BOX_BORDER_CHARS,
32
+ BUDGET_TOKENS_DIVISOR,
28
33
  MAX_TOOL_CALLS_DISPLAY,
29
34
  OUTPUT_TRUNCATE_BYTES,
30
- PAGE_SCROLL_DEFAULT,
31
35
  PROMPT_FOLD_LINES,
32
- statusDotStr,
33
- type ThemeLike,
34
- } from "./format.ts";
36
+ } from "./view-constants.ts";
35
37
 
36
- // ── 共享常量(BOX_BORDER_CHARS / BUDGET_TOKENS_DIVISOR / MAX_TOOL_CALLS_DISPLAY
37
- // 已上移到 format.ts,供 WorkflowsView + detail-content 共用,避免重复定义)──
38
+ // ── 共享常量(BOX_BORDER_CHARS / BUDGET_TOKENS_DIVISOR / MAX_TOOL_CALLS_DISPLAY 等)
39
+ // 落位 ./view-constants.ts(post-convergence C4:views 专属常量自 format.ts 沉回 views/)──
38
40
 
39
41
  /** 探测宽度:足够大避免截断折行影响行数统计(对齐 subagents DETAIL_LEN_PROBE_WIDTH)。 */
40
42
  const DETAIL_LEN_PROBE_WIDTH = 9999;
@@ -232,7 +234,7 @@ function renderActivitySection(
232
234
  const start = eventLog.length - showCount;
233
235
  for (let i = start; i < eventLog.length; i++) {
234
236
  const entry = eventLog[i] as AgentEventLogEntry;
235
- rightLines.push(theme.fg("dim", ` ${formatEventLine(entry, theme)}`.slice(0, mainWidth - BOX_BORDER_CHARS)));
237
+ rightLines.push(theme.fg("dim", ` ${formatTraceEventLine(entry, theme)}`.slice(0, mainWidth - BOX_BORDER_CHARS)));
236
238
  }
237
239
  if (totalCount === 0 && !live.currentActivity) {
238
240
  rightLines.push(theme.fg("dim", " (starting...)"));
@@ -0,0 +1,19 @@
1
+ // src/interface/views/view-constants.ts
2
+ //
3
+ // workflow 全屏视图族专属布局常量(WorkflowsView + detail-content 消费)。
4
+ // 自 interface/format.ts 沉回 views/(post-convergence C4:format.ts 只保留
5
+ // 跨视图族共享的格式化函数,views 专属常量就近落位消费方目录)。
6
+ // ELLIPSIS 留在 format.ts(formatActivityLine/formatPhaseLine 亦消费,非 views 专属)。
7
+
8
+ /** 左侧 sidebar 宽度(WorkflowsView 布局)。 */
9
+ export const SIDEBAR_WIDTH = 24;
10
+ /** prompt 区折叠时显示的行数(detail-content L2)。 */
11
+ export const PROMPT_FOLD_LINES = 3;
12
+ /** result 正文渲染的截断预算(字节,detail-content L2)。 */
13
+ export const OUTPUT_TRUNCATE_BYTES = 100_000;
14
+ /** box 左右边框字符宽度(│ x 2),用于内容行截断预算。 */
15
+ export const BOX_BORDER_CHARS = 2;
16
+ /** token 数 → k 单位的除数。 */
17
+ export const BUDGET_TOKENS_DIVISOR = 1000;
18
+ /** Activity 区最多显示的 tool call 条数。 */
19
+ export const MAX_TOOL_CALLS_DISPLAY = 3;
@@ -17,6 +17,9 @@
17
17
  * 快照 entry(pi.appendEntry)——pi 文件(session JSONL)是 workflow 数据持久化
18
18
  * 权威,state 文件降级为纯性能缓存(读序 = entry > state 文件 > 空,写路径保留)。
19
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²) 磁盘占用。
20
23
  *
21
24
  * save 去抖语义(cw swf-perf wave2):
22
25
  * - **热路径**(running 中间态,本实例已写过):per-runId pending 批合并——窗口内
@@ -27,18 +30,25 @@
27
30
  * 同步挂链 flush 绕过 timer——首写立即可见(跨 session 重启后 loadAll 从 entry
28
31
  * 发现 run)、done 立即落盘(终态优先持久化:transition("done") 后的 save
29
32
  * 不进去抖批,去抖窗口内的崩溃不吞终态)。
30
- * - workflow-record entry 每次成功 flush 都 append(含热路径中间态 flush)——
31
- * entry 流 = 落盘历史(最后一条 = 最后一次成功 flush,崩溃丢失边界语义与
32
- * state 文件路径一致);去抖已把 flush 频率控制在与 agent-call 周期同量级。
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 恢复收编)。
33
40
  * - per-runId 串行 flush 链:同 runId 的 flush 排队顺序执行(不跳过、永不并发
34
41
  * writeFile),链尾吞错防断链——错误只经各 save() Promise 的 settlers 传播。
35
42
  * - dispose():幂等(缓存自身 Promise);刷全部 pending 批 + await 全部 in-flight
36
43
  * 链后返回。dispose 后 save 静默 no-op + debug 日志(session_shutdown 编排收尾)。
37
44
  *
38
- * 序列化策略:
39
- * - WorkflowRun 是带方法的 class 聚合根——序列化只取公共字段快照。
40
- * - Budget/Trace/AgentCall 都有公共构造器或 fromArray 工厂,反序列化时重建实例。
41
- * - Snapshot 形态用 SnapshotVersion 守护(D-5:格式识别)。
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 裁决②),故本侧零改动即保持。
42
52
  *
43
53
  * [S3 查证结论] pi 0.84.1 实装(node_modules/@earendil-works/pi-coding-agent/dist,
44
54
  * core/session-manager.js,PS-19)的 session 生命周期管理不含自动 GC:
@@ -49,9 +59,10 @@
49
59
  * 里用户手动删除选中的单个顶层 session 文件(trash CLI → unlink fallback,
50
60
  * dist/modes/interactive/components/session-selector.js:539-550),非自动、
51
61
  * 不递归子目录。**推论:workflow-state state 文件无限累积,
52
- * 保留策略由本包自担**——磁盘侧保留现为 opt-in(B1):设 {@link STATE_MAX_RUNS_ENV}
53
- * 后每次新 run state 文件首写成功即按 mtime 裁剪到上限(默认关,见
54
- * pruneStateFilesBeyondCap);内存侧由 evictDoneRunsBeyondCap 淘汰。W17 后 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 文件
55
66
  * 已降级为纯性能缓存(权威数据在 session JSONL 的 workflow-record entry),随 session
56
67
  * 文件被用户删除时一并消失。
57
68
  *
@@ -62,76 +73,23 @@ import * as fs from "node:fs";
62
73
  import * as path from "node:path";
63
74
 
64
75
  import type { CustomEntry, ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
65
- import { getLogger } from "@zhushanwen/subagent-core/core/logger.ts";
66
-
67
- import { AgentCall } from "@zhushanwen/subagent-core/orchestration/models/agent-call.ts";
68
- import { Budget } from "@zhushanwen/subagent-core/orchestration/models/budget.ts";
69
- import type { RunSpec } from "@zhushanwen/subagent-core/orchestration/models/run-spec.ts";
70
- import type { RunState } from "@zhushanwen/subagent-core/orchestration/models/run-state.ts";
71
- import { Trace } from "@zhushanwen/subagent-core/orchestration/models/trace.ts";
72
- import type { DoneReason, RunStatus, WorkerLogEntry } from "@zhushanwen/subagent-core/orchestration/models/types.ts";
73
- import type { AgentCallOpts, AgentResult, ExecutionTraceNode } from "@zhushanwen/subagent-core/orchestration/models/types.ts";
74
- import type { WorkflowRunMeta } from "@zhushanwen/subagent-core/orchestration/models/workflow-run.ts";
75
- import { WorkflowRun } from "@zhushanwen/subagent-core/orchestration/models/workflow-run.ts";
76
-
77
- // ── Snapshot format (D-5 version guard) ──────────────────────
78
-
79
- /**
80
- * 快照格式版本。D-5:旧 session(无此字段或值不匹配)被 loadAll 忽略。
81
- *
82
- * 版本历史:
83
- * - wf-run-v1:status 三态(含 paused)、meta 含 pausedAt。
84
- * - wf-run-v2(当前):status 两态(running/done)、meta 无 pausedAt(随一次性
85
- * 生命周期收窄,F6/F8)。v1 文件 loadAll 静默跳过——含 v1 running 残留跳过 =
86
- * 静默消失不显示,接受(父文档 D-5 边界声明:旧 run 历史价值低,不做兼容迁移)。
87
- *
88
- * 升级格式时 bump 此常量并在 deserializeRun 中适配——旧文件返回 null(被 loadAll 跳过)。
89
- */
90
- export const SNAPSHOT_VERSION = "wf-run-v2" as const;
91
-
92
- /**
93
- * 持久化快照形态——WorkflowRun 公共字段的 JSON 可序列化投影。
94
- *
95
- * calls 序列化为数组(Map 不能直接 JSON.stringify);反序列化时重建 Map。
96
- * budget/trace 在 deserialize 时重建实例(带方法的 class)。
97
- */
98
- interface RunSnapshot {
99
- v: typeof SNAPSHOT_VERSION;
100
- runId: string;
101
- spec: RunSpec;
102
- state: {
103
- status: RunStatus;
104
- reason?: DoneReason;
105
- budget: {
106
- maxTokens?: number;
107
- maxCost?: number;
108
- maxTimeMs?: number;
109
- usedTokens: number;
110
- usedCost: number;
111
- totalCallCount: number;
112
- };
113
- calls: Array<{
114
- id: number;
115
- opts: AgentCallOpts;
116
- status: "pending" | "running" | "done";
117
- attempts: number;
118
- result?: AgentResult;
119
- sessionId?: string;
120
- sessionFile?: string;
121
- traceNode: ExecutionTraceNode;
122
- }>;
123
- trace: ExecutionTraceNode[];
124
- errorLogs: WorkerLogEntry[];
125
- error?: string;
126
- scriptResult?: unknown;
127
- };
128
- meta: {
129
- startedAt: string;
130
- completedAt?: string;
131
- workerErrorCount?: number;
132
- scriptErrorCount?: number;
133
- };
134
- }
76
+ // DEFAULT_SAVE_MIN_INTERVAL_MS / DEFAULT_STATE_MAX_RUNS 保留深路径:core barrel
77
+ // 刻意不导出(exports 面即 semver 契约,见 subagent-core src/index.ts 头注),壳侧
78
+ // barrel 消费(DEFAULT_* 已进 core barrel——u-2c 删 ./* 通配后深路径 tsc 不可解析)
79
+ import {
80
+ DEFAULT_SAVE_MIN_INTERVAL_MS,
81
+ DEFAULT_STATE_MAX_RUNS,
82
+ } from "@zhushanwen/subagent-core";
83
+ import { getLogger } from "@zhushanwen/subagent-core";
84
+
85
+ import { WorkflowRun } from "@zhushanwen/subagent-core";
86
+ import {
87
+ SNAPSHOT_VERSION,
88
+ fromRunSnapshot,
89
+ toRunSnapshot,
90
+ type RunSnapshot,
91
+ } from "@zhushanwen/subagent-core";
92
+ import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
135
93
 
136
94
  // ── Workflow-record self-describing entry (W17, D4) ─────────
137
95
 
@@ -147,7 +105,7 @@ export const WORKFLOW_RECORD_CUSTOM_TYPE = "workflow-record";
147
105
  *
148
106
  * = 完整 RunSnapshot 快照(runId/status/calls/trace 等全部重建需要的字段)+ 版本号。
149
107
  * 读取方无需逆向解析 state 文件或指针(D4 自描述原则);snapshot 内部自带 D-5
150
- * snapshotVersion guard(deserializeRun 检查),entry 层 v 与 snapshot 层 v 是两级
108
+ * snapshotVersion guard(fromRunSnapshot 检查),entry 层 v 与 snapshot 层 v 是两级
151
109
  * 独立版本(entry schema 演化 vs 快照格式演化)。
152
110
  */
153
111
  // 模块内类型(不导出:无外部消费方,fallow unused_types/private_type_leaks 双轨判定;
@@ -166,117 +124,25 @@ function toWorkflowRecordEntryData(snapshot: RunSnapshot): WorkflowRecordEntryDa
166
124
  return { v: 1, snapshot, updatedAt: new Date().toISOString() };
167
125
  }
168
126
 
169
- // ── Serialization ────────────────────────────────────────────
170
-
171
- function serializeRun(run: WorkflowRun): RunSnapshot {
172
- return {
173
- v: SNAPSHOT_VERSION,
174
- runId: run.runId,
175
- spec: run.spec,
176
- state: {
177
- status: run.state.status,
178
- reason: run.state.reason,
179
- budget: {
180
- maxTokens: run.state.budget.maxTokens,
181
- maxCost: run.state.budget.maxCost,
182
- maxTimeMs: run.state.budget.maxTimeMs,
183
- usedTokens: run.state.budget.usedTokens,
184
- usedCost: run.state.budget.usedCost,
185
- totalCallCount: run.state.budget.totalCallCount,
186
- },
187
- calls: Array.from(run.state.calls.values()).map((c) => {
188
- // strip live(同 trace 序列化,不持久化运行期对象)
189
- const { live: _live, ...traceNodeRest } = c.traceNode;
190
- return {
191
- id: c.id,
192
- opts: c.opts,
193
- status: c.status,
194
- attempts: c.attempts,
195
- result: c.result,
196
- sessionId: c.sessionId,
197
- sessionFile: c.sessionFile,
198
- traceNode: traceNodeRest,
199
- };
200
- }),
201
- // trace 节点浅拷贝时 strip live 字段——ExecutionRecord 含可变 turns[]/controller,
202
- // 不适合序列化;live 是运行期对象(done 时由 dispatchAgentCall 清除,重跑时重建)。
203
- trace: run.state.trace.toArray().map(({ live: _live, ...rest }) => rest),
204
- errorLogs: run.state.errorLogs,
205
- error: run.state.error,
206
- scriptResult: run.state.scriptResult,
207
- },
208
- meta: run.meta,
209
- };
210
- }
211
-
212
- /**
213
- * 反序列化快照为 WorkflowRun。D-5:版本不匹配返回 null(旧 session)。
214
- */
215
- function deserializeRun(snapshot: RunSnapshot): WorkflowRun | null {
216
- // D-5 version guard
217
- if (snapshot.v !== SNAPSHOT_VERSION) return null;
218
-
219
- const budget = new Budget({
220
- maxTokens: snapshot.state.budget.maxTokens,
221
- maxCost: snapshot.state.budget.maxCost,
222
- maxTimeMs: snapshot.state.budget.maxTimeMs,
223
- usedTokens: snapshot.state.budget.usedTokens,
224
- usedCost: snapshot.state.budget.usedCost,
225
- });
226
- budget.totalCallCount = snapshot.state.budget.totalCallCount;
227
-
228
- const calls = new Map<number, AgentCall>();
229
- for (const c of snapshot.state.calls) {
230
- const call = new AgentCall(c.id, c.opts, c.traceNode);
231
- call.status = c.status;
232
- call.attempts = c.attempts;
233
- // Restore result directly — bypasses markRunning/markDone state-machine guards
234
- // because we're reconstructing a known-good persisted state, not transitioning.
235
- if (c.result !== undefined) {
236
- call.result = c.result;
237
- }
238
- if (c.sessionId !== undefined) {
239
- call.setSessionId(c.sessionId);
240
- }
241
- if (c.sessionFile !== undefined) {
242
- call.setSessionFile(c.sessionFile);
243
- }
244
- calls.set(c.id, call);
245
- }
246
-
247
- const trace = Trace.fromArray(snapshot.state.trace);
248
-
249
- const state: RunState = {
250
- status: snapshot.state.status,
251
- reason: snapshot.state.reason,
252
- budget,
253
- calls,
254
- trace,
255
- errorLogs: snapshot.state.errorLogs,
256
- error: snapshot.state.error,
257
- scriptResult: snapshot.state.scriptResult,
258
- };
259
-
260
- const meta: WorkflowRunMeta = {
261
- startedAt: snapshot.meta.startedAt,
262
- completedAt: snapshot.meta.completedAt,
263
- workerErrorCount: snapshot.meta.workerErrorCount,
264
- scriptErrorCount: snapshot.meta.scriptErrorCount,
265
- };
266
-
267
- // WorkflowRun.reconstruct 跳过 I1 校验——持久化的 running 状态没有 worker
268
- // (进程被杀后 worker 不可能还活着),违反 I1。D-4 kill-9 恢复在 session_start
269
- // 时把残留 running 转 done,failed,恢复 I1(见 index.ts session_start handler)。
270
- return WorkflowRun.reconstruct(snapshot.runId, snapshot.spec, state, meta);
271
- }
127
+ // ── Serialization → core codec(下沉收口 D4)──────────────────
128
+ //
129
+ // serializeRun/deserializeRun 本地投影已退役:快照投影/重水合/版本 guard 单源消费
130
+ // core run-snapshot codec(toRunSnapshot/fromRunSnapshot)。键序与 strip live 语义
131
+ // 与原本地实现逐字节一致(⛔5 快照锚定:__tests__/jsonl-run-store-snapshot-codec.test.ts);
132
+ // 唯一投影差异 = spec.budgetRef 剔除(codec 单源裁决,嵌套 run 落盘少一脏字段,
133
+ // 性质同 strip live——同偏差登记)。
272
134
 
273
135
  /** workflow-record entry → 重建 run 写入 recordRuns(v1 entry guard + D-5 版本不匹配
274
136
  * 跳过;同 runId 后写覆盖 = 最后一条 entry 胜出)。返回 entry 是否命中该类型。
275
137
  *
276
- * [SO-DATA-2] per-entry 隔离:deserializeRun 在 v guard 之后直接读 snapshot.state.budget
277
- * 等嵌套字段,残缺 entry(截断/手改/半写)抛 TypeError 会沿 collectEntrySources 穿透
278
- * loadAll 的 catch → 返回空——单条损坏让全部 run 不可见。现单条 try/catch:损坏
279
- * entry 跳过 + warn 留证(含 entry 索引与原因),其余 entry 正常重建。
138
+ * 版本可见性分层(D4 裁决③宿主侧落地):v 不匹配(v1 存量/未来版本)→ 静默跳过
139
+ * (既有语义);v 匹配但形状损坏(codec 返回 undefined——codec 形状校验不抛)→
140
+ * warn 留证。
141
+ *
142
+ * [SO-DATA-2] per-entry 隔离:残缺 entry(截断/手改/半写)不得让 loadAll 返回空。
143
+ * 原实现靠「deserializeRun 抛 TypeError → catch → warn」;codec 收敛后形状损坏
144
+ * 走 undefined 返回(warn 分支保持同等留证),try/catch 保留兜底 codec 唯一抛点
145
+ * (done 快照缺 reason 的 WorkflowRun I2 不变式)。
280
146
  */
281
147
  function collectRecordRun(entry: CustomEntry, entryIndex: number, recordRuns: Map<string, WorkflowRun>): boolean {
282
148
  if (entry.customType !== WORKFLOW_RECORD_CUSTOM_TYPE) return false;
@@ -284,11 +150,19 @@ function collectRecordRun(entry: CustomEntry, entryIndex: number, recordRuns: Ma
284
150
  const data = entry.data as WorkflowRecordEntryData | undefined;
285
151
  if (data?.v !== 1 || !data.snapshot) return true;
286
152
  try {
287
- const run = deserializeRun(data.snapshot);
288
- // D-5: null = old snapshot format / version mismatch — skip silently
289
- if (run) recordRuns.set(run.runId, run); // 后写覆盖 = 最后一条 entry 胜出
153
+ if (data.snapshot.v === SNAPSHOT_VERSION) {
154
+ const run = fromRunSnapshot(data.snapshot);
155
+ if (run) {
156
+ recordRuns.set(run.runId, run); // 后写覆盖 = 最后一条 entry 胜出
157
+ } else {
158
+ logger.warn(
159
+ `[subagent-workflow] workflow-record entry #${entryIndex} corrupted, skipped run rebuild: snapshot shape invalid`,
160
+ );
161
+ }
162
+ }
163
+ // D-5: 版本不匹配 = old snapshot format / future version — skip silently
290
164
  } catch (err) {
291
- const reason = err instanceof Error ? err.message : String(err);
165
+ const reason = toErrorMessage(err);
292
166
  logger.warn(
293
167
  `[subagent-workflow] workflow-record entry #${entryIndex} corrupted, skipped run rebuild: ${reason}`,
294
168
  );
@@ -325,24 +199,24 @@ function collectEntrySources(entries: SessionEntry[]): {
325
199
  return { recordRuns, pointers };
326
200
  }
327
201
 
328
- /** 旧 link 指针指向的 state 文件读取:末行 JSON 解析重建。损坏/不可读返回 null
329
- * (单文件失败不阻断其余 run 重建)。 */
202
+ /** 旧 link 指针指向的 state 文件读取:末行 JSON 解析重建。损坏/不可读/版本不匹配
203
+ * 返回 null(单文件失败不阻断其余 run 重建;D-5 静默跳过语义保持)。 */
330
204
  async function loadRunFromStateFile(filePath: string): Promise<WorkflowRun | null> {
331
205
  try {
332
206
  const content = await fs.promises.readFile(filePath, "utf8");
333
207
  const lines = content.split("\n").filter((l) => l.trim());
334
208
  const lastLine = lines[lines.length - 1];
335
209
  if (!lastLine) return null;
336
- const parsed = JSON.parse(lastLine) as RunSnapshot;
337
- // D-5: null = old format / version mismatch — skip silently
338
- return deserializeRun(parsed);
210
+ const parsed: unknown = JSON.parse(lastLine);
211
+ // D-5: undefined = old format / version mismatch / corrupt shape — skip silently
212
+ return fromRunSnapshot(parsed) ?? null;
339
213
  } catch {
340
214
  // Corrupt/unreadable state file — skip (don't crash loadAll).
341
215
  return null;
342
216
  }
343
217
  }
344
218
 
345
- // ── State file retention (B1, opt-in) ────────────────────────
219
+ // ── State file retention (OR-5 ⑥b, default-on) ───────────────
346
220
 
347
221
  /** run state 文件名 glob:runId 形如 `wf-<ts>-<rand>`(lifecycle.ts 生成),只删命中者。
348
222
  * 同目录可能存在的非 state 文件(及 session JSONL——在父目录,本就不在扫描范围)永不碰。 */
@@ -362,7 +236,7 @@ async function pruneStateFilesBeyondCap(stateDir: string, maxRuns: number): Prom
362
236
  names = await fs.promises.readdir(stateDir);
363
237
  } catch (err) {
364
238
  if (!isEnoentError(err)) {
365
- const reason = err instanceof Error ? err.message : String(err);
239
+ const reason = toErrorMessage(err);
366
240
  logger.warn(`[subagent-workflow] state retention: readdir ${stateDir} failed: ${reason}`);
367
241
  }
368
242
  return;
@@ -388,7 +262,7 @@ async function pruneStateFilesBeyondCap(stateDir: string, maxRuns: number): Prom
388
262
  logger.debug(`[subagent-workflow] state retention: pruned ${victim.full}`);
389
263
  } catch (err) {
390
264
  if (isEnoentError(err)) continue; // 并发删除已达成目标
391
- const reason = err instanceof Error ? err.message : String(err);
265
+ const reason = toErrorMessage(err);
392
266
  logger.warn(`[subagent-workflow] state retention: failed to delete ${victim.full}: ${reason}`);
393
267
  }
394
268
  }
@@ -412,16 +286,21 @@ const logger = getLogger("subagents");
412
286
  * save 去抖窗口默认值(ms)。区间 100-250 内取值——agent-call 间隔秒级,
413
287
  * 200ms 足以合并同一 call 周期内的多次状态 mutation,又不至于让崩溃窗口
414
288
  * (未 flush 的 running 尾部丢失,等价崩溃链由 kill-9 恢复收编)明显放大。
415
- * export 供测试边界构造(对齐 TRACE_RESULT_MAX_CHARS export 先例)。
289
+ * 模块私有:无外部消费方(构造参数 saveDebounceMs 可调窗口,测试经其注入)。
416
290
  */
417
- export const DEFAULT_SAVE_DEBOUNCE_MS = 200;
291
+ const DEFAULT_SAVE_DEBOUNCE_MS = 200;
418
292
 
419
293
  /**
420
- * 磁盘保留清理的 opt-in 开关 env(B1):workflow-state 目录内 run state 文件上限。
294
+ * 磁盘保留清理的上限 env(OR-5 ⑥b 默认开):workflow-state 目录内 run state
295
+ * 文件上限。
421
296
  *
422
- * 默认关——未设/空/非有限数/≤0 都不清理(「limits 默认关」裁决;解析对齐
423
- * session-runner 的 SPAWN_WATCHDOG_ENV watchdog 风格:Number() + Number.isFinite
424
- * 过滤,非法值回落 undefined = 不启用,而非抛错或取默认上限)。
297
+ * 解析语义与 core FileRunStore envName 通道一致(两实现面单源 {@link
298
+ * DEFAULT_STATE_MAX_RUNS}):
299
+ * - 未设/空 → 按默认上限 {@link DEFAULT_STATE_MAX_RUNS} 裁剪(**默认开**——
300
+ * OR-5 修复前的 opt-in「默认关」正是跨 run 无界累积缺陷本身);
301
+ * - 有限正数 → 上限 = env 值(显式覆盖默认值);
302
+ * - 非法值(非有限数/≤0)→ 不清理(显式 opt-out 通道:用户意图不明时不动
303
+ * 磁盘,对齐 prune 内部「任何失败都不抛」的保守哲学)。
425
304
  *
426
305
  * 用 XYZ_ 前缀而非 PI_:本 env 是 pi 进程内读的配置 env,xyz-agent 桌面 spawn 链按
427
306
  * ENV_WHITELIST_PREFIXES(只有 XYZ_ 等)过滤,PI_ 前缀在桌面场景被静默丢弃——
@@ -429,10 +308,10 @@ export const DEFAULT_SAVE_DEBOUNCE_MS = 200;
429
308
  */
430
309
  export const STATE_MAX_RUNS_ENV = "XYZ_SUBAGENT_STATE_MAX_RUNS";
431
310
 
432
- /** 解析保留上限;env 未设/非法/≤0 返回 undefined(调用方不清理)。 */
311
+ /** 解析保留上限;env 未设/空 → 默认上限,显式非法/≤0 → undefined(不清理)。 */
433
312
  function getEnvStateMaxRuns(): number | undefined {
434
313
  const raw = process.env[STATE_MAX_RUNS_ENV];
435
- if (!raw) return undefined;
314
+ if (raw === undefined || raw === "") return DEFAULT_STATE_MAX_RUNS;
436
315
  const parsed = Number(raw);
437
316
  if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
438
317
  return parsed;
@@ -453,7 +332,7 @@ interface PendingSaveBatch {
453
332
  settlers: Array<{ resolve: () => void; reject: (e: unknown) => void }>;
454
333
  }
455
334
 
456
- export interface JsonlRunStoreOptions {
335
+ interface JsonlRunStoreOptions {
457
336
  /** Session directory root (state files live under <sessionDir>/workflow-state/). */
458
337
  sessionDir: string;
459
338
  /** Pi ExtensionAPI for workflow-record appendEntry writes (optional for testing). */
@@ -462,6 +341,12 @@ export interface JsonlRunStoreOptions {
462
341
  ctx?: ExtensionContext;
463
342
  /** save 去抖窗口(ms),默认 {@link DEFAULT_SAVE_DEBOUNCE_MS}。 */
464
343
  saveDebounceMs?: number;
344
+ /**
345
+ * workflow-record entry append 节流最小间隔(ms);0 = 禁用节流。缺省
346
+ * {@link DEFAULT_SAVE_MIN_INTERVAL_MS}(单源复用 core 常量)。测试经此注入小窗口
347
+ * (fake timers 推进)。
348
+ */
349
+ entryAppendMinIntervalMs?: number;
465
350
  }
466
351
 
467
352
  export class JsonlRunStore {
@@ -469,10 +354,19 @@ export class JsonlRunStore {
469
354
  private readonly pi?: ExtensionAPI;
470
355
  private readonly ctx?: ExtensionContext;
471
356
  private readonly saveDebounceMs: number;
357
+ /** workflow-record entry append 节流最小间隔(ms),0 = 禁用。 */
358
+ private readonly entryAppendMinIntervalMs: number;
472
359
  /** per-runId 去抖批(热路径)。 */
473
360
  private readonly pending = new Map<string, PendingSaveBatch>();
474
361
  /** 本实例已至少成功发起过一次 flush 的 runId(冷/热路径判据)。 */
475
362
  private readonly writtenOnce = new Set<string>();
363
+ /**
364
+ * per-runId 上次 workflow-record entry append 时刻(节流判据,时间源 Date.now()——
365
+ * fake timers 下可推进)。终态 append 后删(终态后 runId 不再 save);残留条目
366
+ * 只出现在 running 中 run 消失场景,单条可忽略(对齐 core FileRunStore.lastSavedAt
367
+ * 的取舍先例)。
368
+ */
369
+ private readonly lastEntryAppendAt = new Map<string, number>();
476
370
  /**
477
371
  * per-runId 串行 flush 链。同 runId 的 flush 排队顺序执行(排队不跳过——
478
372
  * 跳过会丢最新状态且打破后写覆盖前写的单调性),不同 runId 互不阻塞。
@@ -488,6 +382,10 @@ export class JsonlRunStore {
488
382
  this.pi = opts.pi;
489
383
  this.ctx = opts.ctx;
490
384
  this.saveDebounceMs = opts.saveDebounceMs ?? DEFAULT_SAVE_DEBOUNCE_MS;
385
+ this.entryAppendMinIntervalMs = Math.max(
386
+ 0,
387
+ opts.entryAppendMinIntervalMs ?? DEFAULT_SAVE_MIN_INTERVAL_MS,
388
+ );
491
389
  }
492
390
 
493
391
  /** State directory: <sessionDir>/workflow-state/ */
@@ -646,16 +544,36 @@ export class JsonlRunStore {
646
544
  throw err;
647
545
  }
648
546
  // serialize-at-flush:写 flush 时刻的最新聚合状态(latestRun 语义)
649
- const snapshot = serializeRun(run);
547
+ const snapshot = toRunSnapshot(run);
650
548
  await fs.promises.writeFile(filePath, JSON.stringify(snapshot) + "\n", "utf8");
651
- // W17 [D4]:每次成功 flush 同步 append 自描述 workflow-record entry(同一份
652
- // snapshot,entry 与 state 文件内容一致)。pi 文件是 workflow 数据持久化权威
653
- //(loadAll 优先从 entry 重建),state 文件降级纯性能缓存。pi 未注入(测试)时跳过。
654
- this.pi?.appendEntry(
655
- WORKFLOW_RECORD_CUSTOM_TYPE,
656
- toWorkflowRecordEntryData(snapshot),
657
- );
658
- // B1 磁盘保留清理(opt-in):新 run state 文件首写成功后触发(rollbackFirstWrite
549
+ // W17 [D4]:成功 flush 同步 append 自描述 workflow-record entry(同一份 snapshot,
550
+ // entry 与 state 文件内容一致)。pi 文件是 workflow 数据持久化权威(loadAll 优先
551
+ // 从 entry 重建),state 文件降级纯性能缓存。pi 未注入(测试)时跳过。
552
+ // [B-1] entry append 节流(语义对齐 core FileRunStore.save OR-5 ⑥a):running
553
+ // 中间态距上次 append 不足间隔 → 跳过(pi session JSONL append-only,节流前
554
+ // 每次 flush 全量 append 累积单 run O(n²) 磁盘);终态永不节流(最终状态必进
555
+ // pi 权威文件);间隔 0 禁用。判据在 append 成功后更新——本调用点位于 writeFile
556
+ // 成功之后,writeFile 抛错时判据不更新,不吞下一次重试机会。
557
+ const isTerminal = run.state.status !== "running";
558
+ const now = Date.now();
559
+ const lastAppendAt = this.lastEntryAppendAt.get(runId);
560
+ if (
561
+ this.entryAppendMinIntervalMs <= 0 ||
562
+ isTerminal ||
563
+ lastAppendAt === undefined ||
564
+ now - lastAppendAt >= this.entryAppendMinIntervalMs
565
+ ) {
566
+ this.pi?.appendEntry(
567
+ WORKFLOW_RECORD_CUSTOM_TYPE,
568
+ toWorkflowRecordEntryData(snapshot),
569
+ );
570
+ if (isTerminal) {
571
+ this.lastEntryAppendAt.delete(runId);
572
+ } else {
573
+ this.lastEntryAppendAt.set(runId, now);
574
+ }
575
+ }
576
+ // OR-5 ⑥b 磁盘保留清理(默认开):新 run state 文件首写成功后触发(rollbackFirstWrite
659
577
  // 即 save() 冷路径传入的 isFirstWrite——「本实例首次写该 runId」≈ 新文件落盘时刻,
660
578
  // 每个 run 只清一次,热路径 flush 不重复扫描目录)。prune 内部吞错不抛,
661
579
  // 在串行链上 await:save 返回即清理已定,测试可同步断言目录终态。
@@ -711,15 +629,13 @@ export class JsonlRunStore {
711
629
 
712
630
  private async doDispose(): Promise<void> {
713
631
  // 同步置位:阻断新 save 进入去抖/冷路径(R5 no-op 分支接住 shutdown 后
714
- // in-flight 链的迟到 save)。doDispose 被调用后同步执行到第一个 await 前。
632
+ // in-flight 链的迟到 save)。置位必须先于 flushPendingSaves——async 函数体
633
+ // 在调用时同步执行到第一个 await,批收集发生在置位后的同一同步段,时序与
634
+ // 折叠前的内联收集逐分支等值。
715
635
  this.disposed = true;
716
- const flushes: Promise<void>[] = [];
717
- for (const [runId, batch] of Array.from(this.pending.entries())) {
718
- clearTimeout(batch.timer);
719
- this.pending.delete(runId);
720
- flushes.push(this.enqueueFlush(runId, batch.latestRun, batch.settlers, false));
721
- }
722
- await Promise.allSettled(flushes);
636
+ // 复用 flushPendingSaves(批收集循环与 await allSettled 与折叠前内联实现逐行等价;
637
+ // flushPendingSaves 自身不动 disposed——dispose 语义仍由本方法的置位与缓存 Promise 承担)
638
+ await this.flushPendingSaves();
723
639
  // await 全部 in-flight 链(ES4:flush 全部落定后才返回)
724
640
  await Promise.allSettled(Array.from(this.chains.values()));
725
641
  }
@@ -729,8 +645,8 @@ export class JsonlRunStore {
729
645
  *
730
646
  * 1. 优先扫描自描述 `workflow-record` entry(重建源——同一 runId 多条时最后一条
731
647
  * 胜出,等价「最后一次成功 flush」)。entry 层 v1 guard:不认识的版本跳过而非
732
- * 猜测;snapshot 层 D-5 snapshotVersion guard 保持(版本不匹配 → deserializeRun
733
- * 返回 null → 跳过,不做兼容迁移)。
648
+ * 猜测;snapshot 层 D-5 snapshotVersion guard 保持(版本不匹配 → fromRunSnapshot
649
+ * 返回 undefined → 跳过,不做兼容迁移)。
734
650
  * 2. 旧 `workflow-state-link` 指针 entry 兼容读取(优先级低——存量 run 不静默
735
651
  * 丢失,父文档 #9 踩坑):entry 未覆盖的 runId 经指针读 state 文件最后行。
736
652
  *