@zhushanwen/pi-subagent-workflow 8.14.7 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +6 -5
  2. package/package.json +11 -7
  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 +376 -85
  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,50 +1,55 @@
1
1
  /**
2
- * Workflow Extension — workflow tool(3 actions,FR-5 tool 收口)。
2
+ * Workflow Extension — workflow tool(4 actions,FR-5 tool 收口)。
3
3
  *
4
4
  * 合并原 tool-workflow.ts + tool-workflow-run.ts 为单 tool。
5
5
  *
6
6
  * Actions:
7
- * - run: registry.get → runWorkflow(直接启动,无需用户确认)
7
+ * - run: registry.getPath → runWorkflow(直接启动,无需用户确认)
8
8
  * - status: 列出 runs(deps.runs)
9
9
  * - abort: 调 abortRun
10
+ * - resume: 调 resumeRun(interrupted 态 run 断点续跑,D14 args 校验在入口)
10
11
  *
11
- * **restart 不包含**(D-9 废弃);**pause/resume 不包含**(一次性生命周期——run
12
- * 不可挂起,提前停止用 abort,要新结果开新 run)。
12
+ * **restart 不包含**(D-9 废弃);**pause 不包含**(一次性生命周期——run 不可
13
+ * 挂起,提前停止用 abort,要新结果开新 run)。**resume 是中断恢复不是挂起恢复**:
14
+ * 仅 interrupted 态 run 可续(崩溃收编 / terminate 被动失联后的断点续跑,
15
+ * workflow-run-resume-revision U3/D14),不是把活跃 run 暂停再继续。
13
16
  *
14
17
  * 层归属:Interface。依赖 Pi SDK + Engine lifecycle/launcher + helpers。
15
18
  */
16
19
 
20
+ import { readFileSync } from "node:fs";
21
+ import os from "node:os";
22
+
17
23
  import { StringEnum } from "@earendil-works/pi-ai";
18
24
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
19
25
  import { getLogger } from "@zhushanwen/pi-extension-logger";
20
26
 
21
27
  const logger = getLogger("tool-workflow");
22
28
  import { Text } from "@earendil-works/pi-tui";
23
- import {
24
- guiComponent,
25
- type GuiContext,
26
- type GuiRenderResult,
27
- guiResult,
28
- isGuiCapable,
29
- } from "@zhushanwen/extension-protocol";
30
29
  import { type Static, Type } from "typebox";
31
30
 
32
31
  import { SLUG_MAX_LENGTH } from "@zhushanwen/subagent-core";
33
32
  import { THINKING_ORDER } from "@zhushanwen/subagent-core";
34
33
  import type { LauncherDeps } from "@zhushanwen/subagent-core";
35
- import { abortRun, runWorkflow } from "@zhushanwen/subagent-core";
34
+ import { abortRun, resumeRun, runWorkflow } from "@zhushanwen/subagent-core";
35
+ import type { ResumeRunOptions } from "@zhushanwen/subagent-core";
36
36
  import type { RunStore } from "@zhushanwen/subagent-core";
37
37
  import type { WorkflowRun } from "@zhushanwen/subagent-core";
38
+ // [D8 创建期拒单] 模型目录分类裁决 + 宿主注入的模型清单投影访问器(既有投影面:
39
+ // session_start 把 ctx.modelRegistry 注入 ModelConfigService 单例——extension 零
40
+ // runtime import,H2/P5 宿主注入同款先例)。
41
+ import { assertModelInCatalog } from "@zhushanwen/subagent-core";
42
+ import { getModelConfigService } from "@zhushanwen/subagent-core";
38
43
  // D9 closure:core args-meta 消费(reservedKeys 注入见下方 ARGS_META_OPTIONS)
39
44
  // MAX_TIMER_DELAY_MS:OR-1 消费(barrel 导出,深路径经 u-2c 删通配后 tsc 不可解析)
40
45
  import {
41
46
  argKeysFromMeta,
42
47
  findFlattenedArgKeys,
43
48
  MAX_TIMER_DELAY_MS,
49
+ workflowNotFoundMessage,
44
50
  } from "@zhushanwen/subagent-core";
45
- import { assertEntryTimeBudget, assertSlugWithinLimit } from "@zhushanwen/subagent-core";
51
+ import { assertEntryTimeBudget, assertEntryTokenBudget, assertSlugWithinLimit } from "@zhushanwen/subagent-core";
46
52
  import { runSummary } from "@zhushanwen/subagent-core";
47
- import { mapRunIcon, mapRunStatus, toGuiCtx } from "./gui-mappers.ts";
48
53
  import { ID_PREVIEW_LENGTH } from "./id-preview.ts";
49
54
  import type { RunStartDetails, WorkflowToolResult } from "./tool-result.ts";
50
55
  import {
@@ -57,11 +62,10 @@ import { formatRunStatusElapsed } from "./format.ts";
57
62
  import {
58
63
  assertNotAborted,
59
64
  buildRunSpecFromScript,
60
- formatAvailableWorkflowList,
61
65
  optionSlugSuffix,
62
66
  renderTextResult,
67
+ throwPrefixed,
63
68
  } from "./tool-shared.ts";
64
- import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
65
69
 
66
70
  // ── Parameter schema ─────────────────────────────────────────
67
71
 
@@ -69,18 +73,20 @@ import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
69
73
  export type WorkflowAction =
70
74
  | "run"
71
75
  | "status"
72
- | "abort";
76
+ | "abort"
77
+ | "resume";
73
78
 
74
79
  const WORKFLOW_ACTIONS: readonly WorkflowAction[] = [
75
80
  "run",
76
81
  "status",
77
82
  "abort",
83
+ "resume",
78
84
  ];
79
85
 
80
86
  const WorkflowParams = Type.Object({
81
87
  action: StringEnum(WORKFLOW_ACTIONS, { description: "Workflow action to execute" }),
82
88
  name: Type.Optional(
83
- Type.String({ description: "Workflow ref: builtin/saved workflow name from <available_workflows> (C5: run accepts names), or absolute path to the .js script (use <location>; run action)" }),
89
+ Type.String({ description: "Workflow ref: absolute path to the .js script — use the <location> value from <available_workflows> (bare names are rejected; run action)" }),
84
90
  ),
85
91
  slug: Type.Optional(
86
92
  Type.String({
@@ -91,15 +97,18 @@ const WorkflowParams = Type.Object({
91
97
  }),
92
98
  ),
93
99
  runId: Type.Optional(
94
- Type.String({ description: "Workflow run ID (abort action)" }),
100
+ Type.String({ description: "Workflow run ID (abort / resume action; find it via action:status)" }),
95
101
  ),
96
102
  args: Type.Optional(
97
103
  Type.Record(Type.String(), Type.Unknown(), {
98
- description: "Arguments passed to workflow as key-value pairs (run action)",
104
+ description:
105
+ "Arguments passed to workflow as key-value pairs (run action). For resume: optional — when passed, " +
106
+ "must deep-equal the run's original args (except the internal _runId key); mismatch is rejected with " +
107
+ "the differing fields listed. Omit to reuse the original args.",
99
108
  }),
100
109
  ),
101
110
  tokens: Type.Optional(Type.Number({ description: "Max token budget — ONLY set when user explicitly requests a limit; omit = unlimited (default)" })),
102
- time: Type.Optional(Type.Number({ description: `Max time budget in ms — ONLY set when user explicitly requests a limit; omit = unlimited (default; hard ceiling ${MAX_TIMER_DELAY_MS} ms — larger values fail fast at entry)` })),
111
+ time: Type.Optional(Type.Number({ description: `Max time budget in ms — ONLY set when user explicitly requests a limit; omit = unlimited (default; hard ceiling ${MAX_TIMER_DELAY_MS} ms — larger values fail fast at entry). For resume: the budget applied to the resumed execution (active time already spent by the run is counted against it; suspended time is not)` })),
103
112
  error: Type.Optional(
104
113
  Type.String({ description: "Error/reason message (optional, used with abort)" }),
105
114
  ),
@@ -160,7 +169,7 @@ interface RunSummary {
160
169
  startedAt?: string;
161
170
  completedAt?: string;
162
171
  error?: string;
163
- /** Run 状态快照文件绝对路径(<sessionDir>/workflow-state/<runId>.jsonl)。 */
172
+ /** run 唯一持久件(record 事件流 <sessionDir>/workflow-state/<runId>.record.jsonl)的绝对路径。 */
164
173
  stateFile?: string;
165
174
  }
166
175
 
@@ -170,78 +179,23 @@ interface RunSummary {
170
179
  * Discriminated union of `workflow` tool `details` payloads.
171
180
  *
172
181
  * Discriminant: `action`. Each action's details shape is explicitly typed so
173
- * downstream consumers (GUI list-tree renderer, structured-output) can narrow
174
- * without unsafe casts.
182
+ * downstream consumers (structured-output) can narrow without unsafe casts.
175
183
  */
176
184
  export type WorkflowToolDetails =
177
- | ({ action: "run"; name: string; __gui__?: GuiRenderResult } & RunStartDetails)
178
- | { action: "status"; runs: RunSummary[]; __gui__?: GuiRenderResult }
179
- | { action: "abort"; runId: string; status: string; reason?: string; __gui__?: GuiRenderResult };
185
+ | ({ action: "run"; name: string } & RunStartDetails)
186
+ | { action: "status"; runs: RunSummary[] }
187
+ | { action: "abort"; runId: string; status: string; reason?: string }
188
+ | ({ action: "resume"; runId: string } & RunStartDetails);
180
189
 
181
190
  /** Result returned by the `workflow` tool's execute(公共骨架见 tool-result.ts)。 */
182
191
  type WorkflowExecuteResult = WorkflowToolResult<WorkflowToolDetails | undefined>;
183
192
 
184
- // ── GUI 协议 helpers ───────────────────────────────────────
185
-
186
- /** 为 details 附加 __gui__(RPC 模式下)。union 各成员已声明 __gui__?,无需强转。 */
187
- function withGui(
188
- details: WorkflowToolDetails | undefined,
189
- ctx?: GuiContext,
190
- ): WorkflowToolDetails | undefined {
191
- if (!details) return undefined;
192
- if (ctx && isGuiCapable(ctx)) {
193
- return { ...details, __gui__: guiResult(buildWorkflowGui(details)) };
194
- }
195
- return details;
196
- }
197
-
198
- /** 按 WorkflowToolDetails 构造对应的 GuiComponent。 */
199
- export function buildWorkflowGui(details: WorkflowToolDetails) {
200
- if (details.action === "run") {
201
- // not_found 曾是「isError:true + not_found details」的错误形态(W4 前返回值 isError 被
202
- // pi 丢弃);W4 后该错误改为 throw(details 不再产出此形态),本分支保留消费历史
203
- // session entry / 防御性渲染,不能走通用 mapper 的 done/check 成功映射。
204
- if (details.status === "not_found") {
205
- return guiComponent("stats-line", {
206
- items: [{ label: "run", value: "not found", severity: "danger" as const }],
207
- });
208
- }
209
- const statusStr = details.status;
210
- return guiComponent("list-tree", {
211
- items: [{
212
- label: [details.name, details.slug, details.runId.slice(0, ID_PREVIEW_LENGTH)].filter(Boolean).join(" "),
213
- status: mapRunStatus(statusStr),
214
- icon: mapRunIcon(statusStr),
215
- }],
216
- });
217
- }
218
- if (details.action === "status") {
219
- return guiComponent("list-tree", {
220
- items: details.runs.map((r) => {
221
- const statusStr = r.reason ? `${r.status} (${r.reason})` : r.status;
222
- return {
223
- label: [r.name, r.slug, r.runId.slice(0, ID_PREVIEW_LENGTH)].filter(Boolean).join(" "),
224
- status: mapRunStatus(statusStr),
225
- icon: mapRunIcon(statusStr),
226
- };
227
- }),
228
- });
229
- }
230
- // abort(唯一 lifecycle action):破坏性终止非成功完成,用 warn 与成功区分
231
- return guiComponent("stats-line", {
232
- items: [{
233
- label: details.action,
234
- value: details.runId.slice(0, ID_PREVIEW_LENGTH),
235
- severity: "warn" as const,
236
- }],
237
- });
238
- }
239
-
240
193
  // ── Tool registration ────────────────────────────────────────
241
194
 
242
195
  /**
243
- * 注册 workflow tool(3 actions: run / status / abort;pause/resume 已随一次性
244
- * 生命周期移除——enum 拒绝由 pi 核心校验拦截,见 F3)。
196
+ * 注册 workflow tool(4 actions: run / status / abort / resume;pause 已随一次性
197
+ * 生命周期移除——enum 拒绝由 pi 核心校验拦截,见 F3。resume = interrupted 态断点
198
+ * 续跑,不是挂起恢复)。
245
199
  *
246
200
  * @param pi ExtensionAPI
247
201
  * @param deps LauncherDeps(LifecycleDeps + registry)
@@ -261,28 +215,34 @@ export function registerWorkflowTool(
261
215
  name: "workflow",
262
216
  label: "Workflow",
263
217
  description:
264
- "Execute and control workflows: run (start), status, abort.\n" +
218
+ "Execute and control workflows: run (start), status, abort, resume (continue an interrupted run).\n" +
265
219
  "Replaces workflow + workflow-run tools.",
266
- promptSnippet: "Run, abort, or check workflow status",
220
+ promptSnippet: "Run, abort, resume, or check workflow status",
267
221
  promptGuidelines: [
268
222
  "PRIORITY: When user says 'workflow', 'run workflow', try run action FIRST.",
269
223
  "All listed workflows run DIRECTLY with action:run — refs/descriptions come from " +
270
224
  "<available_workflows> (injected each turn). For parameter details, read the <location> " +
271
225
  "script file (script header has @pi-meta parameters + usage + phases). Do NOT use " +
272
226
  "workflow-script generate for patterns already covered by available workflows.",
273
- "run: pass the workflow ref as name — the listed <name> (builtin/saved workflow) or its <location> absolute .js path from <available_workflows> — then start in background (no user confirmation needed).",
227
+ "run: pass the workflow ref as name — ALWAYS the <location> absolute .js path from <available_workflows> (bare names are rejected with a not-found error listing locations).",
274
228
  "DO NOT bash sleep or poll status after starting — results appear automatically via notifyDone.",
275
- "Runs are one-shot: there is no pause/resume — to stop a run early use abort; for a fresh result start a new run.",
229
+ "Runs are one-shot: there is no pause — to stop a run early use abort; for a fresh result start a new run. " +
230
+ "resume is ONLY for runs whose status is interrupted (crash or session-switch during execution): it replays " +
231
+ "completed calls from the record at zero token cost and continues where the run stopped. " +
232
+ "Do NOT resume a settled (done/failed/cancelled) run — start a new run instead.",
233
+ "resume: pass the SAME args as the original run (they are verified field-by-field; a mismatch is rejected " +
234
+ "with the differing fields listed). Omit args to reuse the original ones. Changed args = different intent = new run.",
276
235
  "Call shapes (JSON): " +
277
236
  "- run: {\"action\":\"run\",\"name\":\"<script>\",\"args\":{...},\"tokens\":N,\"time\":N,\"model\":\"<provider/modelId>\",\"thinkingLevel\":\"<level>\"}. " +
278
237
  "- status: {\"action\":\"status\"}. " +
279
- "- abort: {\"action\":\"abort\",\"runId\":\"<id>\"} (optional: {\"error\":\"<reason>\"}).",
238
+ "- abort: {\"action\":\"abort\",\"runId\":\"<id>\"} (optional: {\"error\":\"<reason>\"}). " +
239
+ "- resume: {\"action\":\"resume\",\"runId\":\"<id>\",\"args\":{...},\"time\":N} — args/time optional.",
280
240
  "Budget: Do NOT set tokens/time unless the user explicitly requests a limit. Built-in workflows run unlimited by default.",
281
241
  "Model/thinkingLevel: omit by default (inherit main agent's model). Only set model/thinkingLevel when the user explicitly requests a specific model or thinking depth for this run.",
282
242
  "Anti-patterns: Flattening args sub-fields (task/items/...) to the top level — they belong inside args. Calling {\"action\":\"run\"} without name.",
283
- "CRITICAL: For orchestration patterns, ALWAYS use action:run with an existing built-in " +
284
- "name — NEVER use workflow-script action:generate to recreate patterns already covered " +
285
- "by available workflows. workflow-script generate is ONLY for novel patterns.",
243
+ "CRITICAL: For orchestration patterns, ALWAYS use action:run with the <location> absolute " +
244
+ "path of a listed workflow — NEVER use workflow-script action:generate to recreate patterns " +
245
+ "already covered by available workflows. workflow-script generate is ONLY for novel patterns.",
286
246
  ],
287
247
  parameters: WorkflowParams,
288
248
 
@@ -315,7 +275,10 @@ export function registerWorkflowTool(
315
275
  result = actionStatus(deps);
316
276
  break;
317
277
  case "abort":
318
- result = await actionLifecycle("abort", params, deps);
278
+ result = await actionAbort(params, deps);
279
+ break;
280
+ case "resume":
281
+ result = await actionResume(params, deps);
319
282
  break;
320
283
  default: {
321
284
  // Exhaustiveness check — 新增 WorkflowAction 成员时未补 case,tsc 在此报错。
@@ -323,11 +286,9 @@ export function registerWorkflowTool(
323
286
  throw new Error(`Unknown action: ${String(_exhaustive)}`);
324
287
  }
325
288
  }
326
- // GUI 协议:RPC 模式下附加 __gui__ 到 details
327
- return {
328
- ...result,
329
- details: withGui(result.details, toGuiCtx(_ctx)),
330
- };
289
+ // workflow 块分支(WORKFLOW_TOOL_NAMES 集合分流)恒折叠单行不消费 __gui__
290
+ // (D8 裁决),details 不构造 GUI 描述符。
291
+ return result;
331
292
  } finally {
332
293
  releaseReentryGuard(reentryRef);
333
294
  }
@@ -350,9 +311,7 @@ export function registerWorkflowTool(
350
311
  );
351
312
  },
352
313
 
353
- renderResult(result: { content?: Array<{ type: string; text?: string }> }, _options: unknown, _theme: Theme, _context?: unknown) {
354
- return renderTextResult(result);
355
- },
314
+ renderResult: renderTextResult,
356
315
  });
357
316
  }
358
317
 
@@ -365,40 +324,34 @@ export async function actionRun(
365
324
  ): Promise<WorkflowExecuteResult> {
366
325
  const name = params.name;
367
326
  if (!name) {
368
- throw new Error("run requires 'name' parameter (absolute .js path from <available_workflows> <location>). Correct: {\"action\":\"run\",\"name\":\"<ref>\",\"args\":{...}}");
327
+ throw new Error(
328
+ "run requires 'name' parameter (absolute .js path from <available_workflows> <location>). Correct: {\"action\":\"run\",\"name\":\"<ref>\"}",
329
+ );
369
330
  }
370
331
  // 弱模型常见误用(P0 静默失败):把 task/items 等 args 子字段平铺到 workflow params
371
332
  // 顶层(缺 args 嵌套)。args ?? {} 会静默 args={},启动缺参 run 不报错——比 subagent
372
333
  // 平铺事故更严重。m6:先 registry.getPath(动态参数集来源——schema 即 SSOT),
373
334
  // not_found 优先返回;平铺检测报错带 Correct 正例纠正。
374
335
  //
375
- // C5③(convergence D-4 pi 半边):name 解析先查内置/已保存 workflow 名
376
- // (registry.get——内置 chain/parallel/map-reduce/scatter-gather/review-fix-loop 或
377
- // 用户 project/user 级脚本名,按 tmp>project>user>npm 优先级合并)——内置名命中
378
- // 直接用内置脚本;未命中再走 getPath(绝对路径,~/ 展开——normalizeRef 既有)。
379
- // 严格超集:现有路径用法零变化(路径串不会撞 workflow 名——名字是简单标识符),
380
- // 两者都未命中仍走原 not_found 报错(建议清单不变)。
381
- let script = await deps.registry.get(name);
382
- if (!script) {
383
- script = await deps.registry.getPath(name);
384
- }
336
+ // [D4-1 按名解析退役] name 解析 = 路径单通道(getPath:绝对路径 + ~/ 展开,
337
+ // normalizeRef 既有——相对路径/裸名一律 null → not_found 拒单,既有文案列全部
338
+ // 可用条目并附 location,失败一次即可按绝对路径自救)。原 registry.get(内置名 +
339
+ // 用户保存名)按名解析整体退役:派发终态形态 = 全路径(用户裁决 2026-09-21),
340
+ // P5 修复发现面后注册表会命中内置名让裸名「复活」——反向违反终态裁决,故机制
341
+ // 删除而非禁用。行为变更四要素:量级 = 裸名派发调用;旧行为 = registry.get 命中
342
+ // 即启动;新行为 = not_found 拒单(零 token 沉没);恢复 = 按清单 location 重试。
343
+ const script = await deps.registry.getPath(name);
385
344
  // W4c:config-loader 的 toCachedMeta 对不可读/不存在文件返回 available:false 的
386
345
  // stub(非 undefined),仅判 !script 会绕过 not_found → 空 sourceCode 假启动
387
- //(W4b verifier 探针实测复现)。与下方 suggestions 分支的 wf.available 过滤口径对齐。
346
+ //(W4b verifier 探针实测复现)。
388
347
  if (!script || !script.available) {
389
- // 模糊匹配建议。throw(W4):pi 只对 execute throw 置 isError:true,
390
- // 返回值里的 isError 被 agent-loop 丢弃(agent-loop.js:453-483)——文案原样进 toolResult。
391
- const all = await deps.registry.loadAll();
392
- const suggestions = formatAvailableWorkflowList(all);
393
- // [按名解析自救指引] 摘要逐条附绝对路径 location:run 的 name 形参最贴近的
348
+ // throw(W4):pi 只对 execute throw 置 isError:true,返回值里的 isError 被
349
+ // agent-loop 丢弃(agent-loop.js:453-483)——文案原样进 toolResult。
350
+ // [全路径自救指引] 清单逐条附绝对路径 location:run 的 name 形参最贴近的
394
351
  // 读取面就是本清单(<available_workflows> 注入面在 start 时已过时/可能不在
395
- // 上下文)——模型按清单里的名字重试(8.6.0 实装 getPath-only 时代的实测失败
396
- // 形态,2026-09-14 嵌套 subagent 现场)还是注定 throw;带上 location 后失败
397
- // 一次即可按绝对路径自救。main 的 C5③ 按名解析(registry.get 内置/已保存名
398
- // 优先)是主修复,本行是清单侧恢复指引闭环。
399
- throw new Error(
400
- `Workflow '${name}' not found. Available (name — use the absolute location path as 'name' when the bare name is rejected):\n${suggestions || " (none)"}`,
401
- );
352
+ // 上下文)——带 location 后失败一次即可按绝对路径自救。按名解析已退役
353
+ // (D4-1),location 是唯一活路;文案单源 = core launcher.workflowNotFoundMessage。
354
+ throw new Error(await workflowNotFoundMessage(name, deps));
402
355
  }
403
356
 
404
357
  // m6:动态参数集(schema 即 SSOT)→ 平铺检测;无 parameters → 单次 warn + 跳过
@@ -432,7 +385,31 @@ export async function actionRun(
432
385
  // Type.Number 直通(无上界)——超 setTimeout 安全域的值会穿透到 lifecycle 内层
433
386
  // 防线(assertSafeTimerDelay),而入口拦截让它永不进入副作用链(判定与文案单点在
434
387
  // core shared/entry-guards;LLM 可据消息自纠:clamp 或省略走 unlimited 语义)。
388
+ // 负值同在入口拒绝:tokens 负值会被 Budget 的 maxTokens>0 守卫、time 负值会被
389
+ // lifecycle 的 budgetTimeMs>0 判定静默升格 unlimited(显式预算被忽略),入口拦截
390
+ // 替代静默升格。
435
391
  assertEntryTimeBudget(time);
392
+ assertEntryTokenBudget(tokens);
393
+
394
+ // [D8 创建期拒单] 工具参数 model 是创建期唯一静态声明源(agent 资产 frontmatter
395
+ // model 随脚本 JS 动态求值不可静态解析——由派发期 isPiRoute 对称校验覆盖,
396
+ // 见 workflow-dispatch.resolveWorkflowIdentity)。查无 = run 创建失败(同步 throw,
397
+ // 零 spawn 零 token),错误列可用清单 + 分类修复指引(查无 vs provider 配置漂移)。
398
+ // 目录经宿主注入投影访问:session_start 已把 ctx.modelRegistry 注入
399
+ // ModelConfigService 单例;单例缺席(生产不可达——tool execute 前必有 session_start)
400
+ // 降级跳过 + warn 留痕,派发期 identity 解析仍是权威裁决。
401
+ if (params.model !== undefined) {
402
+ const modelService = getModelConfigService();
403
+ if (modelService === null) {
404
+ logger.warn(
405
+ "[tool-workflow] model catalog unavailable (model service not initialized) — creation-time model check skipped; dispatch-time identity resolution remains authoritative",
406
+ );
407
+ } else {
408
+ assertModelInCatalog(params.model, modelService.getModelRegistry(), {
409
+ source: "run-level model override",
410
+ });
411
+ }
412
+ }
436
413
 
437
414
  // 构建 RunSpec + 启动(m3:parameters 从 script.meta 拷贝——chokepoint 校验用;
438
415
  // 校验失败 → ArgsValidationError 直接 throw 给 pi(W4:err.message 含 §5.3 指引,
@@ -488,18 +465,37 @@ function actionStatus(deps: LauncherDeps): WorkflowExecuteResult {
488
465
  };
489
466
  }
490
467
 
491
- // ── abort lifecycle action ────────────────────────────────────
468
+ // ── abort action ─────────────────────────────────────────────
492
469
 
493
- // 一次性生命周期:abort 是唯一的提前停止方式(pause/resume 已随 D-2 移除),
494
- // action 参数保留字面量类型与 WorkflowAction 单成员分发对齐。
495
- async function actionLifecycle(
496
- action: "abort",
470
+ /**
471
+ * [W2/V1 D1 分流表第 6 行] 单一判源函数(CLI 面显示/排序的混合判源收拢):输出
472
+ * 投影三态 status(running|interrupted|done——重水合中断 run 经 meta.interruptedAt
473
+ * 投影中断态,[D2] 与 shared WorkflowRunStatus 三态同词;活体终局经进程内终局
474
+ * 记录注册表判定、恢复路径写点 run 经聚合 status 读)。判源 = core runSummary
475
+ * 投影,散落的等价分支形态不采用(D1 否决记录:散落分支在 grep 层不可区分,
476
+ * 活体判据误用漏网)。
477
+ *
478
+ * 排序键契约:STATUS_ORDER 权重表只消费本函数的三态输出;outcome 细分由
479
+ * runSummary.reason 并列承载(列表行 reasonSuffix)。
480
+ *
481
+ * 落点说明(实施期登记):函数体消费 core barrel 既有导出 runSummary,落本文件
482
+ * 使 commands/WorkflowsView 可单向 import(commands → view 既有边使 commands.ts
483
+ * 落点成环,本文件与两者均无既有边)。
484
+ */
485
+ export function displayStatusOf(run: WorkflowRun): "running" | "interrupted" | "done" {
486
+ return runSummary(run).status;
487
+ }
488
+
489
+ // 一次性生命周期:abort 是唯一的提前停止方式(pause/resume 已随 D-2 移除)。
490
+ async function actionAbort(
497
491
  params: WorkflowToolParams,
498
492
  deps: LauncherDeps,
499
493
  ): Promise<WorkflowExecuteResult> {
500
494
  const runId = params.runId;
501
495
  if (!runId) {
502
- throw new Error(`'runId' is required for ${action}. Correct: {"action":"${action}","runId":"<id>"} (use action:"status" to find runId)`);
496
+ throw new Error(
497
+ "abort requires 'runId' parameter. Correct: {\"action\":\"abort\",\"runId\":\"<id>\"} (use action:\"status\" to find runId)",
498
+ );
503
499
  }
504
500
  const run = deps.runs.get(runId);
505
501
  if (!run) {
@@ -508,10 +504,14 @@ async function actionLifecycle(
508
504
  );
509
505
  }
510
506
  try {
511
- const oldStatus = run.state.status;
507
+ // [W2/V1 D1 分流表第 7 行] abort 读回换源:oldStatus/newStatus/reason 全部
508
+ // 取 runSummary 投影(活体终局经终局记录派生 DoneReason——原两态机字段读随
509
+ // 活体写点删除停更,abort 后 state.reason 恒 undefined)。
510
+ const oldStatus = displayStatusOf(run);
512
511
  await abortRun(runId, deps, params.error);
513
- const newStatus = run.state.status;
514
- const reasonSuffix = run.state.reason ? ` (${run.state.reason})` : "";
512
+ const summary = runSummary(run);
513
+ const newStatus = summary.status;
514
+ const reasonSuffix = summary.reason ? ` (${summary.reason})` : "";
515
515
  return {
516
516
  content: [
517
517
  {
@@ -519,13 +519,219 @@ async function actionLifecycle(
519
519
  text: `Workflow '${run.spec.scriptName}' (${runId}): ${oldStatus} → ${newStatus}${reasonSuffix}`,
520
520
  },
521
521
  ],
522
- details: { action, runId, status: newStatus, reason: run.state.reason },
522
+ details: { action: "abort", runId, status: newStatus, reason: summary.reason },
523
523
  };
524
524
  } catch (err) {
525
- // throw(W4b):abortRun 失败改 throw(原 return isError 被 pi 丢弃),"Error: " 前缀保持
526
- const msg = toErrorMessage(err);
527
- throw new Error(`Error: ${msg}`);
525
+ // "Error: " 前缀是 abortRun 失败的既有 LLM 可见形态,保持不变
526
+ throwPrefixed("Error", err);
527
+ }
528
+ }
529
+
530
+ // ── resume action(D14 args 校验 + resumeRun 接线)──────────
531
+
532
+ /**
533
+ * D14 比对排除键:rfl 仪表向 spec.args 原地注入的稳定 `_runId`(lifecycle
534
+ * runWorkflow 注入面)——run 派发的机器字段,不属用户意图,比对前双侧剔除。
535
+ */
536
+ const RESUME_ARGS_EXCLUDED_KEY = "_runId";
537
+
538
+ /** record 流内 run-created 帧的 args 读取结果(D14 比对的数据源形态)。 */
539
+ type HistoricalArgs =
540
+ | { kind: "args"; args: Record<string, unknown> }
541
+ | { kind: "absent" }
542
+ | { kind: "truncated" };
543
+
544
+ /**
545
+ * record 行 → run-created 帧窄化(type 字面量判别 + args 全文 / argsSummary 透传;
546
+ * 非该帧 / 非 object 返回 undefined——结构判别走运行时守卫,不做断言)。
547
+ */
548
+ function asRunCreatedFrame(parsed: unknown): { args?: unknown; argsSummary: unknown } | undefined {
549
+ if (typeof parsed !== "object" || parsed === null) return undefined;
550
+ if (!("type" in parsed) || parsed.type !== "run-created") return undefined;
551
+ return {
552
+ args: "args" in parsed ? parsed.args : undefined,
553
+ argsSummary: "argsSummary" in parsed ? parsed.argsSummary : undefined,
554
+ };
555
+ }
556
+
557
+ /**
558
+ * 读 run-created 帧的历史 args(D14 比对数据源;record 流是唯一事实源)。
559
+ *
560
+ * - args 全文优先(设计 §3.1 载荷表 run-created 行「args」——现行写入面
561
+ * dispatchRunCreated 随帧落全文,任意体积的 args 都可逐字段深度比对);
562
+ * - 流不存在 / 无 run-created 帧 → absent:不在 D14 层拒绝——资格判据(无 record
563
+ * 流 / 首帧缺失)归 resumeRun 权威文案,此处不重复实现(分层:D14 只管 args 一致性);
564
+ * - 旧格式帧回落 argsSummary(无 args 字段):截断(>256 字符,写侧
565
+ * summarizeRunArgs 截断标记)→ truncated——截断摘要无法逐字段比对,保守拒绝
566
+ * (静默放行 = 静默忽略传入 args,D14 不采用形态);
567
+ * - 解析失败 → throw:args/argsSummary 是 JSON.stringify 产物,未截断必可解析——
568
+ * 不可解析 = 流被篡改或写入器 bug(对齐 D12 拒绝精神)。
569
+ */
570
+ function readHistoricalArgs(recordPath: string, runId: string): HistoricalArgs {
571
+ let content: string;
572
+ try {
573
+ content = readFileSync(recordPath, "utf8");
574
+ } catch {
575
+ return { kind: "absent" };
528
576
  }
577
+ for (const line of content.split("\n")) {
578
+ if (line.trim().length === 0) continue;
579
+ let parsed: unknown;
580
+ try {
581
+ parsed = JSON.parse(line);
582
+ } catch {
583
+ continue;
584
+ }
585
+ const frame = asRunCreatedFrame(parsed);
586
+ if (frame === undefined) continue;
587
+ // args 全文优先:大 args run 的 D14 逐字段比对由此成立(truncated 拒绝只对
588
+ // 旧格式帧的截断摘要形态出现)
589
+ if (isPlainObject(frame.args)) return { kind: "args", args: frame.args };
590
+ const summary = frame.argsSummary;
591
+ if (typeof summary !== "string" || summary.length === 0) return { kind: "absent" };
592
+ if (summary.endsWith("…")) return { kind: "truncated" };
593
+ let args: unknown;
594
+ try {
595
+ args = JSON.parse(summary);
596
+ } catch {
597
+ // 未截断摘要必可解析(JSON.stringify 产物)——不可解析 = 字段被篡改/流损坏,
598
+ // 与上方 malformed 分支同构拒绝(裸 SyntaxError 无恢复指引,不透出)。
599
+ throw new Error(
600
+ `Resume rejected: original args of run ${runId} are malformed in its record stream — ` +
601
+ "the record stream is the sole source of truth. Recovery: inspect the record file for external edits; " +
602
+ "if unrepairable, start a new run.",
603
+ );
604
+ }
605
+ if (!isPlainObject(args)) {
606
+ throw new Error(
607
+ `Resume rejected: original args of run ${runId} are malformed in its record stream — ` +
608
+ "the record stream is the sole source of truth. Recovery: inspect the record file for external edits; " +
609
+ "if unrepairable, start a new run.",
610
+ );
611
+ }
612
+ return { kind: "args", args };
613
+ }
614
+ return { kind: "absent" };
615
+ }
616
+
617
+ /** plain object 判定(数组/null 排除——数组按值比、不递归键差)。 */
618
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
619
+ return typeof v === "object" && v !== null && !Array.isArray(v);
620
+ }
621
+
622
+ /**
623
+ * 逐字段深度比对(D14)——返回差异字段描述列表(空 = 一致)。
624
+ *
625
+ * 差异三形态:仅 resume 侧有(unexpected)/ 仅原 run 侧有(missing)/ 值不等
626
+ * (mismatch)。嵌套 plain object 递归比对(路径 `a.b` 形态);数组与原始值按
627
+ * JSON 值比。值展示用 JSON.stringify(函数/undefined 等不可序列化形态按原样)。
628
+ */
629
+ export function diffResumeArgs(
630
+ incoming: Record<string, unknown>,
631
+ historical: Record<string, unknown>,
632
+ ): string[] {
633
+ return diffPlainObject(incoming, historical).map((d) => `args.${d}`);
634
+ }
635
+
636
+ /** diffResumeArgs 的递归体(裸键路径,`a.b` 形态——前缀由公开包装统一添加)。 */
637
+ function diffPlainObject(
638
+ incoming: Record<string, unknown>,
639
+ historical: Record<string, unknown>,
640
+ ): string[] {
641
+ const diffs: string[] = [];
642
+ const keys = new Set([...Object.keys(incoming), ...Object.keys(historical)]);
643
+ keys.delete(RESUME_ARGS_EXCLUDED_KEY);
644
+ for (const key of [...keys].sort()) {
645
+ const hasIn = Object.hasOwn(incoming, key);
646
+ const hasHist = Object.hasOwn(historical, key);
647
+ if (!hasIn || !hasHist) {
648
+ const holder = hasIn ? "resume args only (not in original run)" : "original run only (missing from resume args)";
649
+ diffs.push(`${key}: ${holder}`);
650
+ continue;
651
+ }
652
+ const a = incoming[key];
653
+ const b = historical[key];
654
+ if (isPlainObject(a) && isPlainObject(b)) {
655
+ diffs.push(...diffPlainObject(a, b).map((d) => `${key}.${d}`));
656
+ continue;
657
+ }
658
+ const sa = JSON.stringify(a);
659
+ const sb = JSON.stringify(b);
660
+ if (sa !== sb) diffs.push(`${key}: resume ${sa ?? String(a)} vs original ${sb ?? String(b)}`);
661
+ }
662
+ return diffs;
663
+ }
664
+
665
+ /**
666
+ * resume action:interrupted 态 run 断点续跑。
667
+ *
668
+ * 入口语义(D14):
669
+ * - runId 必需;
670
+ * - args 可选——传入时与 run-created 帧的历史 args 逐字段深度比对(排除 `_runId`),
671
+ * 不一致明确拒绝并列出差异字段(换 args 重放 = 换意图,属新 run);不传默认沿用历史;
672
+ * - time 可选——resume 执行段的时间预算(活跃段算式在 core:已耗活跃时间计入、
673
+ * 搁置不计);入口护栏(负值/超安全域)与 run action 同源(assertEntryTimeBudget)。
674
+ *
675
+ * 资格拒绝(非 interrupted / record 损坏 / 锁被占 / D13 嵌套 / 预算耗尽)由 core
676
+ * resumeRun 权威裁决——ResumeRejectionError 文案含恢复指引,原样透出(throw,
677
+ * pi 置 isError:true)。
678
+ */
679
+ export async function actionResume(
680
+ params: WorkflowToolParams,
681
+ deps: LauncherDeps,
682
+ ): Promise<WorkflowExecuteResult> {
683
+ const runId = params.runId;
684
+ if (!runId) {
685
+ throw new Error(
686
+ "resume requires 'runId' parameter. Correct: {\"action\":\"resume\",\"runId\":\"<id>\"} (use action:\"status\" to find runId; args optional — must equal the original run's args)",
687
+ );
688
+ }
689
+ assertEntryTimeBudget(params.time);
690
+
691
+ // D14 args 校验(fail-fast——先于任何副作用;不一致即拒绝,不触碰 run 状态)
692
+ if (params.args !== undefined) {
693
+ const historical = readHistoricalArgs(deps.store.stateFilePath(runId), runId);
694
+ if (historical.kind === "truncated") {
695
+ // 仅旧格式帧(args 全文载荷落地前落盘、截断摘要形态)到达此分支——现行
696
+ // 写入面随帧落 args 全文,任意体积可逐字段比对
697
+ throw new Error(
698
+ `Resume rejected: original args of run ${runId} exceed the record's args summary limit ` +
699
+ "(legacy record stream without the full-args payload) — they cannot be verified field-by-field. " +
700
+ "Recovery: start a new run for these arguments, or resume WITHOUT args knowing $ARGS will be empty " +
701
+ "(a truncated summary cannot be reconstructed).",
702
+ );
703
+ }
704
+ if (historical.kind === "args") {
705
+ const diffs = diffResumeArgs(params.args, historical.args);
706
+ if (diffs.length > 0) {
707
+ throw new Error(
708
+ `Resume rejected: args for run ${runId} differ from the original run — resume replays the same intent; ` +
709
+ `changed arguments belong to a new run. Differing fields:\n - ${diffs.join("\n - ")}\n` +
710
+ "Recovery: pass the original args exactly, omit args to reuse them, or start a new run.",
711
+ );
712
+ }
713
+ }
714
+ // absent:D14 层不拒绝——无 record 流 / 无 run-created 帧的资格判据归 resumeRun 权威文案
715
+ }
716
+
717
+ const options: ResumeRunOptions = {
718
+ ...(params.time !== undefined ? { budgetTimeMs: params.time } : {}),
719
+ host: os.hostname(),
720
+ };
721
+ await resumeRun(runId, deps, options);
722
+
723
+ return {
724
+ content: [
725
+ {
726
+ type: "text",
727
+ text:
728
+ `Resuming workflow run ${runId} — completed calls are replayed from the record at zero token cost, ` +
729
+ "unfinished calls are re-dispatched. Running in background — DO NOT bash sleep or poll status; " +
730
+ "results are auto-delivered via notifyDone.",
731
+ },
732
+ ],
733
+ details: { action: "resume", runId, status: "running", stateFile: deps.store.stateFilePath(runId) },
734
+ };
529
735
  }
530
736
 
531
737
  // ── helpers ──────────────────────────────────────────────────