# 动态工作流（三）：从工具调用到落库

> 动态工作流的产品层：功能开关与入口，模型可用的十个工作流工具，一次“创建并运行”从草稿文件、编译诊断、确认窗到后台 run 的全过程，每个 actor 怎样成为一个子会话，进度怎样回到主会话，日志存进 SQLite 的哪四张表，以及升级问答、保存、修订、恢复与无头模式。

- 作者：David（道雾轩）
- 专栏：ZCode 源码解读（https://daiw.net/manual/zcode.md）
- 最后更新：2026-09-21
- 原文：https://daiw.net/manual/zcode/dwf-tools
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

前两篇讲的是纯函数库与沙箱：[编译器](https://daiw.net/manual/zcode/dwf-compiler)把脚本变成带站点 ID 的 JavaScript，[引擎](https://daiw.net/manual/zcode/dwf-engine)在子进程里调度它。本篇把它们接到产品上：模型通过哪些工具使用动态工作流，run service 怎样启动与观察一个 run，生产 driver 怎样把每个 actor 落成真实的子会话，日志存在哪里。

代码分布在几处：工具在 `apps/zcode-cli/packages/core/src/tool/handlers`（`*-workflow*.ts` 与 `saved-workflows/`），run service 与 driver 在 `apps/zcode-cli/packages/bootstrap/src/app`（`dynamic-workflow-run-*.ts`、`workflow-driver*.ts`），SQLite 日志在 `apps/zcode-cli/packages/adapters/src/storage/session-store`，界面在 `packages/ui`。bootstrap 里的 `script-workflow-*` 是旧脚本工作流的底座，动态工作流沿用了它的子运行时工厂；旧的 `Workflow` 工具在内置工具表里已被注释掉（`apps/zcode-cli/packages/core/src/tool/handlers/index.ts:70`）。`adapters/src/workflow` 与 `contracts/src/workflow` 属于[专家工作流](https://daiw.net/manual/zcode/expert-workflow)和旧脚本工作流，动态工作流的契约在 `contracts/src/tools/*workflow*.ts` 与 `contracts/src/interfaces/dynamic-workflow-run.port.ts`。

## 开关与入口

功能开关定义在 `packages/shared/src/dynamic-workflow-feature.ts`。服务端 `/api/v1/client/configs` 下发 `dynamicWorkflow.mode`，取值 `disabled`、`onDemand`、`alwaysOn`（`packages/shared/src/dynamic-workflow-feature.ts:9`），消费侧折成布尔值，只有 `disabled` 算关（`dynamic-workflow-feature.ts:37`）。缺省、格式非法或请求失败都按 `disabled` 处理，即 fail-closed（`dynamic-workflow-feature.ts:23`）。本地覆盖用环境变量 `ZCODE_DYNAMIC_WORKFLOW_MODE`，优先级是覆盖、远端、缺省（`dynamic-workflow-feature.ts:20`、`dynamic-workflow-feature.ts:63`）。桌面端的 Main 进程在拉起 Host 前改写它：未打包的开发版透传 shell 里的合法值，打包的 preview 版固定写 `alwaysOn`，打包的正式版删掉继承值（`packages/desktop/src/main/desktopRuntimeEnv.ts:441`）。

开关只管桌面与 Web 那一侧。Host 判定后经 `workspace/updateDynamicWorkflowPolicy` 告诉 CLI 的协议服务端，服务端的缺省值是关（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol/server.ts:253`、`apps/zcode-cli/packages/bootstrap/src/zcode-protocol/dynamic-workflow-policy.ts:4`）。运行时配置里这个字段“缺席即开启”：终端 TUI、无头 `-p` 和工作流子会话都不设置它，保留完整的工具面，只有受信 Host 创建的协议会话才会显式写 false（`apps/zcode-cli/packages/core/src/runtime/types.ts:207`）。所以在终端里直接用 Agent CLI，动态工作流默认可用；桌面端正式版则取决于服务端下发。

开关关闭时撤掉三样东西：十个工作流工具（`index.ts:146`）；`/workflow` 命令，面板里剔除、手打也不展开（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol/slash-commands.ts:16`、`apps/zcode-cli/packages/bootstrap/src/app/create-app.ts:804`）；以及教模型写脚本的 `dynamic-workflows` 技能（`create-app.ts:753`）。后两者来自内置插件 zcode-guide（`apps/zcode-cli/packages/bootstrap/src/app/official-plugin-definitions.ts:82`），这个插件的源码不在开源仓库里。

即使工具在场，模型也不该自作主张：工具说明规定只有用户明确要求“用工作流”时才调用，否则用 Agent 工具委托或自己做（`apps/zcode-cli/packages/core/src/tool/handlers/create-workflow-description.ts:40`）。

## 工具一览

| 工具 | 作用 | 确认 |
| --- | --- | --- |
| `CreateWorkflow` | 编译脚本，确认后在后台启动一个新 run；`script`、`saved`、`path` 三种来源恰好给一个 | 任何权限模式都询问（含 yolo、plan），可选“本会话始终允许”（`apps/zcode-cli/packages/core/src/tool/handlers/create-workflow.ts:351`） |
| `AmendWorkflow` | 修订任意状态的 run（包括还在跑的），新 run 取代旧 run，并把已完成的结果作为缓存导入 | 同上；本会话发起、且不是用户亲手停下的 run 免确认 |
| `EvalWorkflowSnippet` | 同步编译并运行一段试验台片段，不落库、不建后台任务 | 片段含 `world.run` 时才询问 |
| `SaveWorkflow` | 把脚本连同元数据存成可复用的定义 | 总是询问，不提供“始终允许”（`apps/zcode-cli/packages/core/src/tool/handlers/save-workflow.ts:286`） |
| `ListSavedWorkflows` | 列出项目与全局保存的定义 | 只读 |
| `ListWorkflowRuns` | 列出本项目的 run，包括其他会话发起的 | 只读 |
| `GetWorkflowRun` | 一个 run 的即时快照：进度、用量、日志尾、结果或失败、产物 | 只读 |
| `ResumeWorkflowRun` | 用同一个 runId 继续一个 `stopped` 的 run | 不询问 |
| `ResolveWorkflowQuestion` | 回答子 Agent 升级上来的阻塞问题 | 不询问 |
| `ListModels` | 列出可填给 `subagent_model` 的模型 | 只读 |

这十个就是开关关闭时一起下架的那一组（`index.ts:146`）。actor 子会话里另有两个控制工具：交结果的 `submit_result` 与提问用的 `escalate`。

`ResumeWorkflowRun` 不弹窗，理由是恢复被 `scriptHash` 钉死在提交时已批准的同一段脚本上（`apps/zcode-cli/packages/core/src/tool/handlers/resume-workflow-run.ts:12`）。注册表处的注释还写着它“alwaysAsk 非 yolo 不可”（`index.ts:122`），与实际的 `needsApproval: false`（`resume-workflow-run.ts:190`）不一致，以后者为准。

## 创建并运行：一次调用的全过程

```mermaid
flowchart TD
  M["主 Agent 调 CreateWorkflow"] --> V["validateInput：三种来源恰好一个"]
  V --> RI["resolveInput：读 saved 或 path 文件，钳 max_concurrency，解析 subagent_model"]
  RI --> PA{"权限 alwaysAsk，prepareApproval：分析通过？"}
  PA -->|"否，不弹窗"| H1["handler 写草稿，按文件行号回诊断"]
  H1 --> E["模型用 Edit 改草稿，免确认"]
  E --> M
  PA -->|"是"| U["确认窗：阶段图、子代理卡、命令集"]
  U -->|"允许"| H2["handler 写草稿，port.submit"]
  H2 --> CO["run service：compileOnce，登记注册表"]
  CO --> L["launch：不等待，交给引擎与沙箱"]
  H2 --> R["返回 backgrounded，backgroundTaskId 即 runId"]
  R --> BT["执行器接管后台追踪，结算后通知主 Agent"]
```

**输入归一**。执行器先跑 `validateInput`，再跑 `resolveInput`（`apps/zcode-cli/packages/core/src/tool/executor/call-runner.ts:187`、`call-runner.ts:207`）。`CreateWorkflow` 在后者里读保存的定义或 `path` 指向的文件，这是全流程唯一一次读盘，此后钩子、权限规则、确认窗与处理函数看到的是同一份字节；并发上界钳进 `[1, 天花板]`，`subagent_model` 解析成规范形式，都在弹窗之前完成，用户批准的就是将要生效的值（`handlers/create-workflow.ts:324`）。

**草稿与诊断回路**。内联脚本无论编译成败都写一份草稿（`handlers/create-workflow.ts:80`），落在 `<cwd>/.zcode/workflow-drafts/`，目录自带一份内容为 `*` 的 `.gitignore`（`apps/zcode-cli/packages/core/src/tool/handlers/workflow-drafts.ts:9`）。文件名取 run 名，没有就取第一个阶段名，扩展名 `.dwf.ts`；每次内联提交都新建文件，重名依次加 `-2`、`-3`，用独占方式创建，绝不在模型背后覆盖（`workflow-drafts.ts:70`、`workflow-drafts.ts:102`）。编不过时，诊断按文件行号写成 `{path}:L{line}:C{column} {message}`，行号能直接粘进一次 `Edit`（`apps/zcode-cli/packages/core/src/tool/handlers/workflow-script-notes.ts:28`），末尾再附一条 NOTE：没有执行，就地编辑这个文件、用 `path` 重新提交，不要把脚本再内联贴一遍（`workflow-script-notes.ts:53`）。工具说明给的理由是：两万 token 的脚本重流一遍既慢，有的 provider 还会卡住（`create-workflow-description.ts:27`）。为了让这个回路顺畅，权限服务对草稿目录里的 `Edit` 与 `Write` 免确认，它排在 plan 模式与项目 deny、ask 规则之后（`apps/zcode-cli/packages/core/src/permission/service.ts:198`、`apps/zcode-cli/packages/core/src/permission/workflow-draft-path.ts:33`）。这类诊断不弹确认窗：让用户批准一段编不过的代码，只会打断模型自己的改错回路（`handlers/create-workflow.ts:288`）。

**确认与提交**。分析通过才弹窗，窗里画的是因果图、控制流图和交接图拼成的阶段图（`handlers/create-workflow.ts:292`、`apps/zcode-cli/packages/core/src/tool/handlers/workflow-analysis-display.ts:5`）。放行后处理函数调用 `DynamicWorkflowRunPort.submit`，带上脚本、工作目录、名字、实参、阶段表、并发上界、子代理模型与脚本文件的绝对路径（`handlers/create-workflow.ts:131`），拿到 runId 就返回 `status: "backgrounded"`，回复里明确劝模型不要用 TaskOutput 干等（`handlers/create-workflow.ts:185`）。run service 这一侧（`apps/zcode-cli/packages/bootstrap/src/app/dynamic-workflow-run-submit.ts:244`）做四件事：`compileOnce` 在一个 `ts.Program` 上产出降级代码、ask 规格、`world.run` 命令集、submit profile 与 sha256 脚本哈希；按天花板钳好并发；在启动之前先把 run 登记进注册表，因为取消随时可能到来，后台追踪器也会立即开始查快照；然后不等待地启动（`dynamic-workflow-run-submit.ts:285`、`dynamic-workflow-run-submit.ts:316`）。runId 形如 `dwfrun-<uuid>`（`dynamic-workflow-run-submit.ts:582`）。

有一个缝隙：处理函数与确认窗用的 `analyzeWorkflowScript` 不做 schema 合成，`ask<T>` 的结果类型不能序列化（诊断 9002）要到 `compileOnce` 才发现，此时它直接抛错，用户已经确认过，这次工具调用以失败告终（`dynamic-workflow-run-submit.ts:540`、`handlers/create-workflow.ts:129`）。

run service 的文件头列了七条不变式（`apps/zcode-cli/packages/bootstrap/src/app/dynamic-workflow-run-service.ts:9`），其中几条决定了可见行为：`dwf_run` 行只由引擎在构造时创建；没有日志存储就不构造本服务，`CreateWorkflow` 退回“只做类型检查”的占位回复（`create-app.ts:581`、`handlers/create-workflow.ts:114`）；每次启动都登记为会话的常驻阻塞工作，免得跑着 run 的会话被当成空闲会话在 10 分钟后回收。

## actor：每个都是一个子会话

引擎第一次向某个 actor 派活时，driver 用 runId 与 actor 引用（如 `actor#1@1`）拼出会话 ID，形如 `sess_dwf-<runId>-<actor>`，非安全字符折成下划线，同一 run、同一 actor 每次都铸出同一个（`apps/zcode-cli/packages/bootstrap/src/app/workflow-driver-helpers.ts:53`、`apps/zcode-cli/packages/contracts/src/interfaces/shared.ts:32`、`apps/zcode-cli/packages/bootstrap/src/app/workflow-driver.ts:172`）。子运行时由 `createScriptWorkflowAgentRuntime` 造出（`create-app.ts:601`），它强制 `mode: "yolo"`、`taskType: "workflow_child"`，并关掉子 Agent（`apps/zcode-cli/packages/bootstrap/src/app/script-workflow-child-runtime.ts:118`）。也就是说，一次 run 获批之后，actor 在里面执行 Bash、编辑文件都不再逐个询问，模式的含义见[权限模式与规则](https://daiw.net/manual/zcode/permission)。会话随后落成一行普通会话，并以角色 `workflow_actor`、路径 `dwf/<runId>/<actor>` 挂到父会话的任务链接上；恢复时直接从存储重新水化（`apps/zcode-cli/packages/bootstrap/src/app/dynamic-workflow-run-launch.ts:428`、`dynamic-workflow-run-launch.ts:379`）。

actor 的工具面是全集减去一张减法表（`apps/zcode-cli/packages/bootstrap/src/app/workflow-actor-tools.ts:33`）：

```ts
const ACTOR_DISALLOWED_TOOLS: readonly string[] = [
  ASK_USER_QUESTION_TOOL_NAME,
  ENTER_PLAN_MODE_TOOL_NAME,
  EXIT_PLAN_MODE_TOOL_NAME,
  "CreateWorkflow",
  // 修订入口与 CreateWorkflow 同一种嵌套编排，同一个根因入列。
  "AmendWorkflow",
  READ_SESSION_CONTEXT_TOOL_NAME,
  // 子代理不许替主代理回答升级问题。
  // 与上面几条的根因不同：这不是悬挂也不是越权读，而是**身份**——升级的整个意义是把判断权
  // 交给创建这条工作流的那一方；让另一个 actor 顺手作答，等于把它悄悄退化成 actor 之间的
  // 互相说服。actor 提问用 `escalate`（恒注册），作答只属于主会话。
  RESOLVE_WORKFLOW_QUESTION_TOOL_NAME,
];
```

前三个在无人应答的子会话里会一直挂着；嵌套编排与越界读父会话是越权；最后一个守的是身份，升级问题只能由创建这条工作流的主会话回答。core 按 `taskType` 再兜一层结构性禁用，另外去掉 `SaveWorkflow` 与 `ResumeWorkflowRun`（`apps/zcode-cli/packages/core/src/runtime/helpers/tool-allowlist.ts:26`）。

系统提示词里，交互式的身份段被换成工作流子 Agent 的身份段（`apps/zcode-cli/packages/core/src/context/sections/workflow-actor.ts:48`）：开场一句说明它是动态工作流 run 里的子 Agent，读它输出的是脚本而不是人，作者写的 persona 叠在其后，安全提示与 Harness 块照旧复用，最后是一段契约（`workflow-actor.ts:33`）：

```ts
function buildWorkflowContract(): string {
  return [
    "# Working inside a workflow",
    `- ${TOOL_SURFACE}`,
    "- Each ask states what to do. When the ask carries a result schema, finish by calling `submit_result` with a conforming value; otherwise your final message is the result.",
    `- ${EVIDENCE_RULE}`,
    "- Report outcomes faithfully. If part of the task is impossible, out of scope, or contradicted by what you found, say so in the result instead of filling a field with a plausible guess. Never fake a passing result to satisfy an instruction.",
    "- When you are blocked by something outside your reach — a gate that cannot pass, instructions that contradict each other, a fact only the run's owner knows — call `escalate`. Questions written in prose reach nobody.",
    // 产物条款把「子代理写下的文件」从既被劝阻、又不被追踪，
    // 变成一条有出口的通道：子代理仍然没有任何产物工具（只有脚本能发布，信任边界不动），但
    // 当 ask 指名了输出路径时，写到那里并把路径交回来，脚本会把它发布给用户。
    "- Do not write report or summary files on your own initiative; findings go in the result. When the ask names an output path, write exactly there and return that path in the result — the script publishes it to the user.",
  ].join("\n");
}
```

每个 ask 在下发时还会追加尾注：一段质量标准（引用读过或跑过的东西、跑过才算通过、做不到就照实说、被堵住就 `escalate`）（`apps/zcode-cli/packages/bootstrap/src/app/workflow-ask-epilogue.ts:20`），typed ask 再加一段 `submit_result` 说明。submit profile 为 `mono` 的 actor，schema 已写进工具声明，尾注只剩一句；`generic` 的则把整份 JSON Schema 附在尾注里（`workflow-driver.ts:311`、`workflow-driver-helpers.ts:135`）。尾注在计算输入哈希之后才追加，不影响缓存身份。

driver 把引擎的三种裁决落到对话上（`workflow-driver.ts:13`）：accept 时 `submit_result` 的处理函数返回成功并停掉本轮；reject 时它抛错，模型在同一轮里拿到一条错误结果去修；nudge 时本轮已经结束，driver 在同一个持久运行时上起一个新轮次，提示词固定以“You ended your turn without submitting a result.”开头（`workflow-driver-helpers.ts:127`）。子会话默认跑在父会话当前的模型上；`subagent_model` 在场时覆盖，恢复时还会按日志里记下的模型“钉住”，免得父会话换了模型，同一个 run 的后半段悄悄换人（`create-app.ts:613`）。

## 进度怎样回到主会话

```mermaid
flowchart TD
  EN["引擎 record 一条 RunEvent"] --> J["写 dwf_event"]
  EN --> DR["driver.emit"]
  DR --> PP["toProgressPayload，附上日志序号"]
  PP --> PS["progress sink：核对父会话身份"]
  PS --> SE["父会话追加 dynamic_workflow_run_progress，不属于任何回合"]
  SE --> V4["v4 投影：workflowRuns 状态键"]
  V4 --> UI["UI：时间线与侧栏"]
  SE --> NT["escalation-raised 与 run-stalled：给主 Agent 发通知"]
  BT["后台任务追踪：getTask 与 waitForTask"] --> FN["结算后：完成通知与交付指引"]
```

引擎每记一条事件，driver 的 `emit` 都把它包成会话事件载荷，附上刚分配的日志序号（`dynamic-workflow-run-launch.ts:168`）。进度汇先核对 run 的父会话就是本 App 的会话，不是就丢弃并记日志，宁可少一份投影也不污染别人的对话（`apps/zcode-cli/packages/bootstrap/src/app/dynamic-workflow-run-progress-sink.ts:23`）。然后在父会话里以根 trace 追加一条 `dynamic_workflow_run_progress` 事件：run 在后台跑，事件不能冒领任何一轮，否则冷恢复后它会出现在一轮已完结对话的末尾（`apps/zcode-cli/packages/core/src/runtime/methods/dynamic-workflow-run-progress.ts:12`、`apps/zcode-cli/packages/contracts/src/events/session.events.ts:143`）。v4 协议把它归约进 `workflowRuns` 状态键（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/product-projection.ts:4305`、`packages/shared/src/zcode-protocol-v4/workflow-runs-reducer.ts:89`），桌面与 Web 的时间线由此点亮；投影里最多保留最近 8 个 run，完整事实仍在日志里（`workflow-runs-reducer.ts:123`、`packages/shared/src/zcode-protocol-v4/workflow-runs.ts:16`）。

模型这一侧，`CreateWorkflow`、`AmendWorkflow`、`ResumeWorkflowRun` 三个工具的输出被执行器当成后台任务追踪，快照、等待与取消都走同一个端口（`apps/zcode-cli/packages/core/src/tool/executor/background-task-registry.ts:39`、`apps/zcode-cli/packages/core/src/tool/executor/background-tasks.ts:752`）。run 结算后，主 Agent 收到一条完成通知，里面带着脚本的顶层返回值、`report` 过的条目和产物，外加一段按终态定制的“交付指引”，比如用户亲手停下的 run 不要主动恢复（`apps/zcode-cli/packages/core/src/runtime-task/notification.ts:20`、`notification.ts:235`）。运行途中只有两类事件会主动通知：子 Agent 的升级提问与 run 停滞（`dynamic-workflow-run-progress.ts:38`）。取消只有一条路径，界面按钮与模型的 TaskStop 都汇到 `DynamicWorkflowRunPort.cancel`，run 结算成 `stopped(user)` 或 `stopped(model)`（`apps/zcode-cli/packages/core/src/runtime/methods/background-stop-dynamic-workflow.ts:5`）。通知与后台任务的通用机制见[后台任务与通知](https://daiw.net/manual/zcode/background-tasks)。

## 日志落在哪几张表

日志与会话共用一个 SQLite 库，默认在 `~/.zcode/cli/db/db.sqlite`（`apps/zcode-cli/packages/adapters/src/storage/session-store/paths.ts:7`）。四张表由迁移 `0019_dwf_journal` 一次建齐，它是 beta 之前把开发期十二条迁移压成的单一基线（`apps/zcode-cli/packages/adapters/src/storage/session-store/migrations.ts:799`）：

| 表 | 一行是什么 | 要点 |
| --- | --- | --- |
| `dwf_run` | 一个 run | 脚本原文与哈希、实参、工具调用 ID、`resumed_from`、并发上界、已花 token、状态、结果与失败 |
| `dwf_actor` | 一个 actor 实例 | `(run_id, site_id, ordinal)` 唯一，persona、会话 ID、实际使用的模型 |
| `dwf_node` | 一次 ask、世界读取、命令、report 或产物 | 输入哈希、有界输入、状态、结果或错误、用量、消息边界、产物 id |
| `dwf_event` | 一条运行事件 | 每个 run 内单调的 `sequence`，载荷是事件 JSON |

`dwf_node` 的定义（`migrations.ts:874`）：

```sql
      create table if not exists dwf_node (
        id integer primary key autoincrement,
        run_id text not null references dwf_run(id) on delete cascade,
        site_id text not null,
        ordinal integer not null,
        kind text not null check(kind in ('ask', 'world-read', 'world-run', 'report', 'artifact')),
        actor_site_id text,
        actor_ordinal integer,
        actor_seq integer,
        input_hash text not null,
        input_json text,
        status text not null check(status in ('running', 'completed', 'failed')),
        result_json text,
        error_json text,
        stats_json text,
        message_boundary integer,
        artifact_id text,
        time_created integer not null,
        time_updated integer not null,
        unique(run_id, site_id, ordinal)
      );
```

几处设计：会话 ID 两列都是纯文本、不加外键；run 表没有节点上限或 token 预算列，token 只是观察面；可空列一律“NULL 即缺席”（`migrations.ts:812`）。产物的字节不进日志，`dwf_node` 里只存指向 tool-artifact store 的 URI（`apps/zcode-cli/packages/dynamic-workflow/src/engine/types.ts:301`）。追加事件时，序号的分配与插入写在同一条 SQL 里，因为 WAL 模式下多个 Agent 进程可能共用这个库，先读 `MAX(sequence)` 再插入会竞争出重复序号（`apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/dwf-journal.ts:367`）。

逻辑状态与物理列不同名：`dwf_run.status` 的 CHECK 集仍是早先的五个值，没有迁移，映射只活在编解码里（`apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/dwf-journal-codecs.ts:29`）：

| 逻辑状态 | 物理 `status` | `failure_json` |
| --- | --- | --- |
| `stopped`，带原因与可选错误 | `cancelled` | 信封 `{"stopReason": …, "error"?: …}` |
| `errored` | `failed` | 错误 JSON 原样 |
| `completed`、`pending`、`running` | 同名 | 不变 |

读回时，没有信封的老 `cancelled` 行一律解成 `stopped(user)`，失败码是 `Interrupted` 的老 `failed` 行解成 `stopped(interrupted)`。旧脚本工作流另有自己的 `workflow_*` 表，两套互不引用（`migrations.ts:801`）。

App 启动构造 run service 时，会把本会话留在日志里、还停在非终态的 run 收敛成 `stopped(interrupted)`，它们只能是死进程的遗物；只收敛本会话，免得误伤兄弟会话正在跑的 run（`apps/zcode-cli/packages/bootstrap/src/app/dynamic-workflow-run-reconcile.ts:43`）。`create-app.ts` 里一处注释还说“收敛成 failed”（`create-app.ts:691`），已经过时。

## 运行中途的提问：升级问答

actor 不能直接问用户（`AskUserQuestion` 在减法表里），被堵住时调用 `escalate`：处理函数阻塞在会话级的升级端口上，driver 铸一个问题 ID `dwfq-<runId 片段>-<序号>`，把问题登进注册表，双轨发出 `escalation-raised`，actor 就停在这次 ask 里等（`workflow-driver.ts:29`、`apps/zcode-cli/packages/bootstrap/src/app/workflow-driver-escalation.ts:194`）。主会话随即收到一条通知（`dynamic-workflow-run-progress.ts:113`）。主 Agent 可以先看 `GetWorkflowRun`，或者用 AskUserQuestion 去问用户，再调用 `ResolveWorkflowQuestion` 作答；答案原样成为 `escalate` 的工具结果，那个 actor 从原处继续，其余 actor 与脚本一直在跑（`apps/zcode-cli/packages/core/src/tool/handlers/resolve-workflow-question.ts:46`）。

规则有三条：每个 ask 最多升级 3 次，用完返回一条普通结果而不是错误，免得模型反复撞墙（`workflow-driver-helpers.ts:84`、`apps/zcode-cli/packages/core/src/tool/handlers/escalate.ts:9`）；没人作答就一直等，不设超时，逃生口是取消，停驻的问题随 ask 一起撤下（`workflow-driver.ts:359`）；升级不写 `dwf_node` 行，等待不算工作量（`apps/zcode-cli/packages/dynamic-workflow/src/engine/types.ts:753`）。`ResolveWorkflowQuestion` 的说明里还建议“用 CreateWorkflow 的 `resume_from` 接着跑修订后的脚本”（`resolve-workflow-question.ts:52`），但这个参数已经从 `CreateWorkflow` 删掉，修订改由 `AmendWorkflow` 负责（`apps/zcode-cli/packages/contracts/src/tools/create-workflow.ts:146`）。

## 保存、修订与恢复

**保存**。`SaveWorkflow` 的 `scope` 必填：项目档写到 `.zcode/workflows/<name>.dwf.ts`，随仓库提交、只在本项目可见；全局档写到 `~/.zcode/workflows/<name>.dwf.ts`，本机所有项目可见（`apps/zcode-cli/packages/core/src/tool/handlers/save-workflow-description.ts:8`）。名字只许 `[A-Za-z0-9_.-]`、最长 64 个字符，这条正则就是防路径穿越的那道线（`apps/zcode-cli/packages/contracts/src/tools/saved-workflow.ts:38`）。文件是合法的 TypeScript，开头一段块注释装 YAML 元数据（`apps/zcode-cli/packages/core/src/tool/handlers/saved-workflows/frontmatter.ts:7`）：

```ts
//     /* zcode-workflow
//     description: ...
//     args:
//       pr: { type: string, required: true }
//     */
//     <plain dwf script>
```

`description` 必填，`whenToUse` 可选，`args` 的类型只有 `string`、`number`、`boolean` 与什么都收的 `json`（`saved-workflow.ts:68`）。脚本逐字节放在注释之后，保存再读回必须拿到同一份，否则 run 的脚本哈希对不上用户看到的文件（`frontmatter.ts:50`）。查找时项目档在前、全局档在后，先到者胜，同名时项目档遮蔽全局档（`apps/zcode-cli/packages/core/src/tool/handlers/saved-workflows/store.ts:50`）。工具说明对保存的要求最严：绝不主动调用，先用一句话建议，等用户同意（`save-workflow-description.ts:20`）。运行时用 `CreateWorkflow` 的 `saved` 来源，实参按声明校验，未知的键、缺少的必填值、类型不符都在运行前拒绝；实际执行的是写到草稿目录的一份拷贝，定义本身不会被一次 run 改动（`apps/zcode-cli/packages/contracts/src/tools/create-workflow.ts:53`）。桌面端的工作流中枢也能直接启动保存的定义，这条路不经工具执行器，用户在中枢里的点击就算同意（`apps/zcode-cli/packages/core/src/runtime/methods/dynamic-workflow-run-start.ts:49`）。

**修订**。`AmendWorkflow` 对任何状态的 run 都可用。run 还在跑时，service 先以“被取代”为由取消它并等它结算，再从已结算的前驱构建导入缓存，然后启动新 run；两个 run 之间只有 `resumed_from` 这一个指针（`dynamic-workflow-run-submit.ts:103`）。缓存按名字匹配具名 actor，每个 actor 的 ask 按顺序比对指令哈希，第一次不一致之后这个 actor 就不再查缓存（`apps/zcode-cli/packages/dynamic-workflow/src/engine/imported-cache.ts:37`）。第一个子 Agent 即将执行改写工作区的工具，或者第一条 `world.run` 真正执行，缓存就关门，此后只有没碰过外部世界的“纯” ask 还能命中（`imported-cache.ts:55`）。省略脚本、并发上界或子代理模型，都沿用前驱的值；并发上界与子代理模型显式传 `null` 才解除（`apps/zcode-cli/packages/core/src/tool/handlers/amend-workflow-description.ts:21`）。确认规则见上表：免确认的依据是回填的前驱事实，即 run 属于本会话且不是用户停下的，重启之后依然成立（`service.ts:373`）。界面上的“配置”按钮是 `port.amend` 的第二个调用方：同一份脚本、新的子代理模型或并发上界，不经模型、不开确认窗，实参沿用前驱（`apps/zcode-cli/packages/core/src/runtime/methods/dynamic-workflow-run-settings.ts:65`）。

**恢复**。`ResumeWorkflowRun` 只接受 `stopped` 的 run，无论原因，`superseded` 除外；`errored` 的 run 重放只会以同样方式失败，要走修订（`resume-workflow-run.ts:60`）。已完成的步骤从日志回放、不花 token，未完成的重新派发。拒绝原因各有错误码，包括存下的脚本已编不过当前门面、哈希与原文不符（日志被外力改过）、老 run 没有存脚本（`resume-workflow-run.ts:50`）。进程亡故留下的 `stopped(interrupted)` 行正是它的主要对象。界面上的恢复按钮走同一个 `port.resume`，再合成一个工具描述子交给执行器，重新接上后台追踪与完成通知（`apps/zcode-cli/packages/core/src/runtime/methods/dynamic-workflow-run-track.ts:5`）。

## 无头模式

无头模式 `-p` 从不构造权限代理，core 退回“一律拒绝”，而 `CreateWorkflow` 的 alwaysAsk 在任何模式下都要过代理，于是工作流在 `-p` 下会立即被拒（`apps/zcode-cli/packages/cli/src/headless-workflow.ts:13`）。CLI 为此包了一层只放行两个工具的代理，在 `-p` 的入口里装上（`apps/zcode-cli/packages/cli/src/prompt-command.ts:219`、`headless-workflow.ts:36`）：

```ts
    requestPermission: async (request, options) => {
      // AmendWorkflow 与 CreateWorkflow 同一道门、同一条例外。
      if (
        request.toolName !== CREATE_WORKFLOW_TOOL_NAME &&
        request.toolName !== AMEND_WORKFLOW_TOOL_NAME
      ) {
        return await denyBroker.requestPermission(request, options);
      }
      return {
        decision: "allow",
        reason: `Headless CLI auto-approves ${request.toolName}: no interactive gate exists in -p mode.`,
        resolvedAt: new Date(),
      };
    },
```

注释强调这只是绕过确认门，不是绕过权限：PermissionRequest 钩子仍先于代理应答（`headless-workflow.ts:27`）。进度输出按格式分工：`text` 模式在 stderr 打简短进度，run 的开始、结算与脚本的 `log` 不节流，节点状态变化至少间隔 400 毫秒（`headless-workflow.ts:163`、`headless-workflow.ts:176`）；`stream-json` 把进度定型成 `type` 为 `workflow.run.progress` 的 NDJSON 行（`headless-workflow.ts:61`）；`json` 什么都不打，只输出最终那一个对象。只要观察到工作流活动，进程就每 100 毫秒轮询一次，等在飞的 run 结算、完成通知驱动的回合跑完再退出，不设超时；按 Ctrl-C 中断时，run 最终落成可恢复的 `stopped(interrupted)`（`headless-workflow.ts:315`、`headless-workflow.ts:317`、`prompt-command.ts:315`）。命令行的其余部分见[命令行入口](https://daiw.net/manual/zcode/cli-surface)。

## 界面

终端 TUI 用与 v4 投影同一份 reducer 维护一份 `workflowRuns` 镜像，另外自己保留每个 run 最近 10 条 log（`apps/zcode-cli/packages/tui/src/app-workflow-mirror.ts:4`、`app-workflow-mirror.ts:26`）；内置命令 `/dwf` 可以列出本会话的 run、取消或恢复（`apps/zcode-cli/packages/cli/src/command-center/handlers/dwf.ts:9`）。桌面与 Web 的界面在 `packages/ui` 里：`src/components/workflow-timeline/` 画 run 的阶段时间线、子代理与产物，`src/settings/saved-workflows/` 管理保存的定义，`src/app-shell/WorkflowRunSidePane.tsx` 是 run 详情侧栏。它们读的都是上面那条 `workflowRuns` 投影与 run 端口的查询面，本书在[Web 与服务端](https://daiw.net/manual/zcode/server-web)一篇概述 UI 的整体结构。工具结果卡的 display 在 core 的执行器里构造（`apps/zcode-cli/packages/core/src/tool/executor/create-workflow-display.ts:1`、`apps/zcode-cli/packages/core/src/tool/executor/workflow-observation-display.ts:1`）；产物在模型面的那一行格式只写一遍，完成通知与 `GetWorkflowRun` 共用（`apps/zcode-cli/packages/core/src/tool/executor/workflow-published-artifacts.ts:1`）。

下一篇：[技能与自定义命令](https://daiw.net/manual/zcode/skills-commands)——技能从哪些目录被发现、怎样进入上下文，自定义命令的格式与 shell 展开，以及内置的斜杠命令。
