# 专家工作流与图调度器

> core/src/workflow 里的一条固定八阶段长任务流水线：每个阶段交给一次性子会话，执行阶段由依赖图调度器按并发上限推进、可让 planner 扩图，终审可以重开节点，状态写成可恢复的文件快照。它在当前版本只有 CLI 与 TUI 的 /expert 一个入口，与动态工作流是两条独立的线。

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

`apps/zcode-cli/packages/core/src/workflow` 里有一个名叫 Expert Workflow 的内置工作流，定义里的描述只有一句（`apps/zcode-cli/packages/core/src/workflow/definition.ts:36`）：

> Durable long-task workflow for large `NL->Code` work. Child agent sessions perform each phase.

也就是把一个大的“自然语言到代码”任务拆成八个固定阶段，每个阶段交给一次性的子会话去跑；其中执行阶段不是一个会话，而是一张依赖图，由 `WorkflowGraphScheduler` 按依赖与并发上限推进；每走一步都把快照写盘，进程死了可以接着跑。它与第 31 到 33 篇的动态工作流名字相近，但代码、存储与入口都不相通，最后一节专门对照。

| 位置 | 职责 |
| --- | --- |
| `core/src/workflow/definition.ts` | 内置定义与默认策略 |
| `core/src/workflow/expert/` | `ExpertWorkflowRuntime`：阶段循环、阶段执行、终审循环、图种子、失败分类、重试 |
| `core/src/workflow/scheduler.ts`、`scheduler/` | `WorkflowGraphScheduler`：依赖图调度、planner 扩图、集合状态 |
| `core/src/workflow/lifecycle.ts` | 快照的纯函数变换：恢复修复、取消、重开、播种、提示词更新 |
| `apps/zcode-cli/packages/contracts/src/workflow/index.ts` | 定义、快照、图、事件与存储端口的 zod schema |
| `apps/zcode-cli/packages/adapters/src/workflow/index.ts` | 文件存储实现 |
| `apps/zcode-cli/packages/bootstrap/src/app/workflow-facade.ts`、`workflow-methods.ts` | 装配：子会话 runner、定义解析、对外方法 |

## 它接进产品了吗

接进了，但只有一条路。在 bootstrap、cli、services、ui 里搜 `ExpertWorkflowRuntime`、`WorkflowGraphScheduler`、`createExpertWorkflowDefinition`，调用方只有下面这条链：

1. **bootstrap**：`createApp` 构造 `createWorkflowFacade`（`apps/zcode-cli/packages/bootstrap/src/app/create-app.ts:826`），把它的方法摊进 App 对象（`create-app.ts:1122`）。门面在这里 `new ExpertWorkflowRuntime`，并按定义缓存实例（`apps/zcode-cli/packages/bootstrap/src/app/workflow-facade.ts:160`）。
2. **cli 命令中心**：`/expert` 被解析成已知命令（`apps/zcode-cli/packages/cli/src/command-center/slash-commands.ts:36`），分派到 `handleExpertCommand`（`apps/zcode-cli/packages/cli/src/command-center/create.ts:217`）。TUI 的提交处理与无头模式都经过这个命令中心（`apps/zcode-cli/packages/cli/src/tui-prompt-handler.ts:238`、`apps/zcode-cli/packages/cli/src/prompt-command.ts:498`），无头模式还把 `/expert` 明确留在命令中心，不交给普通 prompt 路径（`prompt-command.ts:453`）。
3. **帮助文案**：`/expert [status|resume|stop|<task>]` 出现在共享的命令帮助表与 `zcode --help` 里（`packages/shared/src/zcode-slash-command-help.ts:58`、`apps/zcode-cli/packages/i18n/src/locales/zh-CN.ts:59`）。

没有接上的部分：

- **桌面端与 Web**：App 输入框展示的内置命令只有 `goal`、`compact`、`init` 与 App 专属的 `plan`（`apps/zcode-cli/packages/bootstrap/src/slash-command-surface.ts:3`），v4 命令表里也没有专家工作流的命令（`packages/shared/src/zcode-protocol-v4/command.ts:43`），桌面端没有入口。
- **门面上的其余方法**：`runExpertWorkflowBackground`、`retryWorkflow`、`listExpertWorkflows`、`readExpertWorkflowEvents`，以及不带 Expert 前缀的 `runWorkflow`、`resumeWorkflow` 等同族方法（`apps/zcode-cli/packages/bootstrap/src/app/workflow-methods.ts:5`），在门面之外都没有调用方。App 选项里的 `onWorkflowEvent` 也没有任何宿主传入（`apps/zcode-cli/packages/bootstrap/src/app/types.ts:181`）。
- **自定义定义**：门面支持从 `workflows/definitions/<id>.json` 读别的定义（`workflow-facade.ts:148`），但 `/expert` 从不传 `definitionId` 或 `workflowKind`（`apps/zcode-cli/packages/cli/src/command-center/handlers/expert.ts:90`），能跑到的只有内置定义。
- **后台任务体系**：run 不登记进运行时任务表，也不产生 `<task-notification>`（对照[后台任务与通知](https://daiw.net/manual/zcode/background-tasks)）。连 `startBackground` 也只是 `void continueRun(...)` 后立即返回（`apps/zcode-cli/packages/core/src/workflow/expert/runtime.ts:66`）。
- **测试**：开源仓库里没有针对它的测试，全仓只有 4 个测试文件（在 `packages/ui/test` 与 `packages/services/test` 下），都与此无关。

## 怎么用

| 输入 | 行为 | 出处 |
| --- | --- | --- |
| `/expert` | 显示当前工作目录下最近更新的那次 run | `expert.ts:108`、`apps/zcode-cli/packages/core/src/workflow/expert/runtime-context.ts:295` |
| `/expert <task>` | 切到 yolo，新建 run 并在前台一直跑到完成或暂停 | `expert.ts:86` |
| `/expert status [runId]` | 显示状态 | `expert.ts:22` |
| `/expert resume [runId]` | 切到 yolo，从第一个未完成的阶段接着跑 | `expert.ts:58` |
| `/expert stop [runId]`（`cancel` 同义） | 取消 | `expert.ts:123` |

几个容易踩的地方：

- 任务文本如果以 `status`、`resume`、`stop`、`cancel` 开头，会被当成子命令，后面的文字变成 runId（`expert.ts:117`）。
- “切到 yolo”切的是主会话本身：TUI 先调一次 `setMode("yolo")`，门面再改一次运行时配置（`workflow-facade.ts:232`），跑完不会切回去。帮助文案也写明了是 yolo 模式（`zcode-slash-command-help.ts:55`）。
- 命令是前台的：处理函数一直等到 `continueRun` 返回（`workflow-methods.ts:247`、`runtime.ts:41`），期间各阶段子会话的事件经 `onEvent` 转给 TUI 显示（`workflow-facade.ts:111`）。
- 中途中止这条命令会触发中止信号，run 被记为 `cancelled`（`apps/zcode-cli/packages/core/src/workflow/expert/run-loop.ts:84`）；`cancelled` 是终态，之后 `resume` 只会显示状态（`apps/zcode-cli/packages/core/src/workflow/expert/ids.ts:27`、`runtime.ts:101`）。能续跑的是进程被杀留下的 `running` 快照，以及因错误停下的 `paused` run。

run 的全部状态写在 `<cliStorageRoot>/workflows` 下，默认即 `~/.zcode/cli/workflows`（`workflow-facade.ts:75`、`apps/zcode-cli/packages/adapters/src/workflow/index.ts:35`），runId 形如 `wf_expert_<uuid>`（`runtime-context.ts:40`）：

| 路径 | 内容 | 写法 |
| --- | --- | --- |
| `index.json` | 所有 run 的列表项 | 每写一次快照就整体重写（`adapters/src/workflow/index.ts:128`） |
| `runs/<runId>/run.json` | 完整快照 | 每次状态变化整体覆盖（`adapters/src/workflow/index.ts:121`） |
| `runs/<runId>/events.jsonl` | 工作流事件 | 追加（`adapters/src/workflow/index.ts:45`） |
| `runs/<runId>/graph.jsonl` | 图记录：meta、node、edge、collection、op | 追加；存储端口没有读取它的方法（`apps/zcode-cli/packages/contracts/src/workflow/index.ts:792`），只写不读 |
| `runs/<runId>/artifacts/`、`report.md` | 阶段、节点、planner 的产物与最终报告 | `adapters/src/workflow/index.ts:100`、`113` |
| `definitions/<id>.json` | 自定义定义 | 只读（`adapters/src/workflow/index.ts:188`） |

## 八个阶段

阶段表写在定义里（`definition.ts:49`），`behavior` 只有四种取值（`contracts/src/workflow/index.ts:71`）：

| 阶段 | behavior | 产物 | 做什么 |
| --- | --- | --- | --- |
| `clarify` | agent | `artifacts/01-clarify.md` | 细化目标、假设、验收标准与未决问题，只在阻塞时提问 |
| `task_analysis` | agent | `artifacts/02-task-analysis.md` | 把任务对到仓库上下文：约束、风险、可能涉及的文件、验证方式与失败路径 |
| `arch_decompose` | agent | `artifacts/03-architecture-decompose.md` | 拆成实现与验证节点的依赖图；输出里的 JSON 会被拿去给 `exec` 播种（`definition.ts:72`） |
| `env_setup` | agent | `artifacts/04-env-setup.md` | 检查是否需要环境、依赖、凭据或本地服务 |
| `meta_prompt` | agent | `artifacts/05-meta-prompt.md` | 生成下游需要的执行指令、约束与节点提示词；输出里的 JSON 用来改写 `exec` 节点的提示词（`definition.ts:91`） |
| `exec` | scheduled_graph | `artifacts/06-exec.md`（调度摘要）与 `artifacts/exec/<节点>.md` | 按依赖图执行 |
| `final_critic` | critic | `artifacts/07-final-critic.md` | 对照验收标准审查结果，可以要求重开节点 |
| `complete` | complete | `report.md` | 写最终报告，把 run 标为完成 |

主循环按 `phaseOrder` 顺序走，已完成的阶段直接跳过，这也是恢复时能从断点接着跑的原因（`run-loop.ts:32`、`40`）。初始图里八个阶段各是一个 `phase:<阶段>` 节点，首尾相连（`apps/zcode-cli/packages/core/src/workflow/expert/prompts.ts:157`）。

```mermaid
flowchart TD
  T["/expert 任务"] --> P1["clarify"] --> P2["task_analysis"] --> P3["arch_decompose"]
  P3 -->|"JSON 图种子"| N["exec 的任务节点"]
  P3 --> P4["env_setup"] --> P5["meta_prompt"]
  P5 -->|"节点提示词更新"| N
  P5 --> E["exec：WorkflowGraphScheduler"]
  N -.-> E
  E --> F["final_critic"]
  F -->|"fail 且有可重开的节点"| R["重开节点，exec 与 final_critic 置回 pending"]
  R --> E
  F -->|"pass、没有重开建议或跑满 3 轮"| D["complete：写 report.md"]
```

## 默认策略

默认策略随定义一起给出（`definition.ts:11`）：

```ts
export const DEFAULT_EXPERT_WORKFLOW_STRATEGY: WorkflowStrategy = {
  clarify: {
    confidenceThreshold: 0.8,
    maxRounds: 3,
    minRounds: 1,
  },
  executor: {
    drainingChangeHours: 1,
    frontierTarget: 3,
    maxConcurrentLoops: 2,
    maxConsecutiveErrors: 3,
    maxPlannerRuns: 10,
  },
  finalCritic: {
    maxIterations: 3,
  },
  reactLoop: {
    maxRounds: 30,
  },
};
```

建 run 时策略被抄进快照，恢复时用快照里那份（`runtime-context.ts:75`）。十个参数里只有五个真正被代码读取：

| 参数 | 默认 | 实际作用 |
| --- | --- | --- |
| `clarify.confidenceThreshold`、`maxRounds`、`minRounds` | 0.8、3、1 | 只作为一行文字写进阶段提示词（`expert/prompts.ts:38`）；`clarify` 阶段实际只跑一次子会话 |
| `executor.drainingChangeHours` | 1 | 没有任何代码读取 |
| `executor.frontierTarget` | 3 | 可探索集合里 pending 加 active 的节点数达到它、又没有新完成的节点时，不再叫 planner 扩图（`apps/zcode-cli/packages/core/src/workflow/scheduler/collection-planner.ts:90`） |
| `executor.maxConcurrentLoops` | 2 | 同时在跑的节点上限（`apps/zcode-cli/packages/core/src/workflow/scheduler.ts:65`） |
| `executor.maxConsecutiveErrors` | 3 | 一值三用：调度器连续失败多少次就暂停（`scheduler.ts:110`）、单个节点最多尝试几次（作为 `maxAttempts` 传给节点执行，`scheduler.ts:140`）、planner 累计失败多少次就放弃这个集合（`collection-planner.ts:109`） |
| `executor.maxPlannerRuns` | 10 | 单个集合最多扩图几次（`collection-planner.ts:102`） |
| `finalCritic.maxIterations` | 3 | 终审最多几轮（`apps/zcode-cli/packages/core/src/workflow/expert/critic-loop.ts:28`） |
| `reactLoop.maxRounds` | 30 | 只写进提示词（`expert/prompts.ts:40`、`79`）；子会话按普通回合跑，不受它约束 |

另有一个写死的数字：终审对同一个节点最多重开 2 次（`critic-loop.ts:145`）。

## 阶段交给一次性子会话

每个 agent 阶段、每个图节点、每次 planner 调用都是一个“活动”（activity），由门面里的 `WorkflowAgentRunner` 执行（`workflow-facade.ts:83`）：为这个活动新建一个 `AgentRuntime`，会话 ID 为 `sess_workflow_act_<uuid>`（`workflow-facade.ts:87`），只跑一个回合（`workflow-facade.ts:116`），回合的最终回答就是这个活动的产物。子运行时的配置（`workflow-facade.ts:286`）：

- `agentName` 为 `zcode-expert`，模式固定 `yolo`，模型沿用主会话当前的选择，事件存储用内存实现，工作目录是项目目录；
- `taskType` 为 `workflow_child`。这类子会话的交互事件不会镜像到父界面，所以 `CreateWorkflow`、`AmendWorkflow`、`SaveWorkflow`、`ResumeWorkflowRun`、`ResolveWorkflowQuestion` 被结构性地拿掉，免得它们在子会话里发出父界面看不到的确认请求（`apps/zcode-cli/packages/core/src/runtime/helpers/tool-allowlist.ts:18`）。

阶段提示词由 `buildPhasePrompt` 拼成（`expert/prompts.ts:13`）：阶段名、runId、工作目录、用户任务、上面那几行策略、阶段目标、前序产物清单，结尾要求“输出一份简洁的 Markdown 产物”（`expert/prompts.ts:50`）。只有 `arch_decompose` 额外附上一份 JSON 图的格式约定（`expert/prompts.ts:20`）。从代码看，前序产物的内容并不进提示词，清单里给的是相对 run 目录的路径（如 `artifacts/01-clarify.md`，`apps/zcode-cli/packages/core/src/workflow/expert/phase-runner.ts:139`），而提示词没有给出 run 目录本身在哪；子会话的工作目录是项目目录，未必找得到这些文件。

阶段跑完，产物写进 run 目录，快照、图记录与事件各写一笔（`phase-runner.ts:103`）。随后两步图变换：

- **播种**：从 `arch_decompose` 的回答里抠 JSON（`apps/zcode-cli/packages/core/src/workflow/expert/graph-artifacts.ts:12`）。解析很宽容：整段是 JSON、在 json 代码块里、或者取第一个左花括号到最后一个右花括号都行（`apps/zcode-cli/packages/core/src/workflow/expert/parsers/json.ts:1`），字段名也认多种写法（`apps/zcode-cli/packages/core/src/workflow/expert/parsers/graph-seed.ts:106`）。没有依赖的根节点会被补一条依赖 `phase:meta_prompt` 的边，保证任务节点在元提示阶段之后才就绪（`graph-seed.ts:32`）。节点或集合重名、边指向不存在的节点、成环都会抛错（`apps/zcode-cli/packages/core/src/workflow/lifecycle.ts:293`、`311`）。解析不出 JSON 就不播种，`exec` 退化为只有 `phase:exec` 一个节点，用阶段提示词跑一个子会话（`ids.ts:11`、`expert/prompts.ts:59`）。
- **改写节点提示词**：从 `meta_prompt` 的回答里抠 JSON，按节点 ID 更新 `exec` 节点的标题、描述或提示词（`graph-artifacts.ts:57`、`lifecycle.ts:375`）。`meta_prompt` 的提示词里没有这份 JSON 的格式约定，只有子会话碰巧输出了可解析的 JSON 才会生效。

## 图调度器

`runScheduledPhase` 为 `exec` 阶段构造一个 `WorkflowGraphScheduler`，节点执行器与 planner 执行器都指向上面那个子会话 runner（`apps/zcode-cli/packages/core/src/workflow/expert/scheduled-phase.ts:27`）。进入时先把残留的 `active` 节点复位为 `pending`（`scheduler.ts:55`），然后循环：检查集合 planner、判断是否完成、判断是否该暂停、派发就绪节点。派发与等待这段（`scheduler.ts:124`）：

```ts
      const readyNodes = orderedReadyExecutableNodes(snapshot.graph, executableNodeIds).filter(
        (node) => !active.has(node.id),
      );
      let dispatched = 0;
      for (const node of readyNodes) {
        if (active.size >= maxConcurrent) break;
        // ...
        active.set(node.id, promise);
        snapshot = (await promise.started).snapshot;
        dispatched++;
      }

      if (dispatched > 0) continue;

      if (active.size > 0) {
        const outcome = await Promise.race(active.values());
        active.delete(outcome.nodeId);
        consecutiveErrors = outcome.ok ? 0 : consecutiveErrors + 1;
        continue;
      }
```

- **就绪**：节点为 `pending`，且所有依赖都处于终态。终态包括 `failed`（`contracts/src/workflow/index.ts:590`、`642`），所以依赖失败的下游照样会被派发。
- **顺序**：先派普通节点，再在各个可探索集合之间轮流各取一个（`apps/zcode-cli/packages/core/src/workflow/scheduler/graph.ts:25`）。
- **派发**：每派一个都先等它把 `active` 状态写进快照，再派下一个（`scheduler.ts:144`）；同时在跑的不超过 2 个。没得派时等最先结束的那个，成功就把连续失败计数清零。
- **完成**：所有可执行节点都是 `completed`、`cancelled` 或 `skipped`，并且有节点的可探索集合都已枯竭（`graph.ts:73`）。注意这里不含 `failed`：留有失败节点的阶段不会完成。
- **暂停**：连续 3 个失败结果，等在跑的节点收尾后以 `error_threshold` 暂停（`scheduler.ts:110`）；没有可派的、没有在跑的、又没全部完成，以 `deadlock` 暂停（`scheduler.ts:157`）。暂停会让 `runScheduledPhase` 抛错，run 随之暂停（`scheduled-phase.ts:97`）。

节点产物写到 `artifacts/exec/<节点 ID>.md`（`apps/zcode-cli/packages/core/src/workflow/scheduler/node-runner.ts:140`），就绪集合一变化就发一条 `frontier_changed` 事件（`scheduler.ts:189`）。

### planner 扩图与集合

集合（collection）是一组节点加上目标与度量。标了 `explorable` 的集合在执行中可以长出新节点：每轮循环开头，`checkCollectionPlanners` 逐个检查这些集合要不要叫 planner（`collection-planner.ts:24`）。刚播种、一个节点都还没完成的集合先不扩（`collection-planner.ts:75`）；之后依次判断：前沿（集合里 pending 与 active 的节点数）已达目标且没有新完成的节点，就不扩（`collection-planner.ts:90`）；已经扩满 `maxPlannerRuns` 次，或 planner 累计失败达到 `maxConsecutiveErrors` 次，就把集合判为枯竭（`collection-planner.ts:102`、`109`）。

这几道关都没拦住（前沿不足 3 个，或者有 planner 还没看过的新完成节点）时，就同步跑一次 planner 子会话；在跑的节点不受影响，但这一轮的派发要等 planner 回来。planner 的提示词要求“只返回 JSON”，并给出节点、边、`collectionNodeIds`、`exhausted` 的格式（`apps/zcode-cli/packages/core/src/workflow/scheduler/prompts.ts:67`），回答存成 `artifacts/exec/planners/<集合>-<次数>.md`（`collection-planner.ts:249`）。新增部分要过校验：节点重名、自环、端点不存在、边重复、成环都判为失败（`apps/zcode-cli/packages/core/src/workflow/scheduler/planner-expansion.ts:35`、`124`）。

集合状态在 `active`、`draining`、`exhausted` 之间走：除了上面两道上限，planner 自己声明 `exhausted`，或者连着两次没改图、前沿为零、也没有新完成的节点，也判枯竭（`planner-expansion.ts:52`）。planner 失败的那一次如果让累计失败数到了上限，当场就判枯竭（`collection-planner.ts:327`）。

## 终审循环

`final_critic` 最多跑 3 轮，每轮先用普通的阶段执行跑一次子会话，再把回答当 JSON 解析（`critic-loop.ts:36`）：

```ts
    const phaseRun = await runPhase(ctx, current, definition, options);
    current = phaseRun.snapshot;
    const critic = parseCriticResult(phaseRun.response);

    if (critic.verdict === "pass") {
      // ...
      return current;
    }

    const reopenProposals = dedupeReopenProposals(critic.reopenProposals);
    // ...
    if (reopenProposals.length === 0) {
      return current;
    }
    // ...
    const retryReason = `Final critic reopened node(s): ${reopenedNodeIds.join(", ")}`;
    current = resetPhaseForRetry(ctx, current, execDefinition.phase, retryReason);
    current = resetPhaseForRetry(ctx, current, definition.phase, retryReason);
    await ctx.store.writeSnapshot(current, { signal: options.abortSignal });
    await ctx.appendGraphStatus(current, execDefinition.phase, "pending", options.abortSignal);
    await ctx.appendGraphStatus(current, definition.phase, "pending", options.abortSignal);
    current = await runScheduledPhase(ctx, current, execDefinition, options);
```

- 判决字段认 `verdict`，也认旧写法 `passed` 与 `overallVerdict`；重开建议认 `reopenProposals`、`reopen_proposals`、`reopenNodes`（`apps/zcode-cli/packages/core/src/workflow/expert/parsers/critic.ts:38`、`57`）。
- 重开只接受 `completed`、`failed`、`skipped` 的节点，每个节点最多 2 次，超限的建议被拒并记一条事件（`lifecycle.ts:86`、`245`，`critic-loop.ts:135`）。被重开的节点回到 `pending`，它下游已完成的节点不会跟着重跑。
- 判 `fail` 但没有重开建议、或建议全被拒时，直接返回。由于阶段执行已经把 `final_critic` 记为完成，run 会接着走到 `complete`，状态照样是 `completed`。
- 跑满 3 轮仍在重开：`final_critic` 记为 `failed`，发 `critic_iteration_limit_reached`（`critic-loop.ts:119`），但主循环照常进入 `complete`，run 同样是 `completed`，只能在报告的阶段列表里看到终审失败。

<Callout type="warn">
  从代码看，终审阶段用的是通用阶段提示词，只要求输出 Markdown（`expert/prompts.ts:50`），没有写明判决 JSON 的格式；而解析器找不到 JSON 或解析不出判决就抛错（`parsers/critic.ts:13`、`parsers/json.ts:15`）。子会话如果只写了一份 Markdown 审查报告，这个错误会一路抛到主循环，把 run 变成 `paused`（`run-loop.ts:114`）。
</Callout>

## 失败、暂停与重试

| 层 | 规则 | 出处 |
| --- | --- | --- |
| 节点 | 失败后尝试次数加一，不满 3 次回 `pending`，满 3 次记 `failed` | `node-runner.ts:206` |
| 调度器 | 连续 3 个失败结果，或无路可走，暂停 | `scheduler.ts:110`、`157` |
| planner | 失败 3 次或扩满 10 次，集合判枯竭，不影响阶段继续 | `collection-planner.ts:327`、`102` |
| agent 阶段 | 子会话抛错：阶段记 `failed`，错误继续上抛 | `phase-runner.ts:155` |
| run | 抛到主循环的错误：中止信号已触发则记 `cancelled`，否则记 `paused` 并附失败信息 | `run-loop.ts:77` |

`paused` 时快照里会带上一条失败记录与三个恢复动作 `retry`、`retry_with_current_model`、`cancel`（`apps/zcode-cli/packages/core/src/workflow/expert/failures.ts:46`）。失败按错误链归成网络、限流、超时、认证、上下文超限、权限、工具等十一类（`failures.ts:72`、`contracts/src/workflow/index.ts:198`），认证、取消、配置三类标为不可重试（`failures.ts:128`）。run 级状态只会走到 `pending`、`running`、`paused`、`completed`、`cancelled`；schema 里的 `failed` 没有代码会写（`contracts/src/workflow/index.ts:11`）。

恢复动作目前只是数据：`/expert` 没有 retry 子命令，`retryWorkflow` 也没有调用方。真正的 `retry` 会把失败与在跑的节点复位为 `pending`、清掉失败记录（`apps/zcode-cli/packages/core/src/workflow/expert/retry-state.ts:7`），而用户能用的 `resume` 只修复 `active` 的残留（`lifecycle.ts:99`）。所以一个因节点用满 3 次尝试而暂停的 run，`/expert resume` 之后那个节点仍是 `failed`；它的下游会继续跑，但阶段永远凑不齐“全部完成”，从代码看调度器最终会以 `deadlock` 再次暂停。

## 快照与恢复

```mermaid
stateDiagram-v2
  direction LR
  [*] --> pending: 写初始快照与初始图
  pending --> running: continueRun
  running --> completed: complete 阶段写完报告
  running --> paused: 阶段或调度器抛错
  running --> cancelled: 中止信号或 stop 子命令
  paused --> running: resume
  paused --> cancelled: stop 子命令
  running --> running: 进程退出后 resume，修复残留 active
  completed --> [*]
  cancelled --> [*]
```

每次状态变化都覆盖一次 `run.json`，另往 `graph.jsonl` 追加一条图记录、往 `events.jsonl` 追加一条事件（`runtime-context.ts:138`、`169`）。恢复只读 `run.json`（`adapters/src/workflow/index.ts:91`）：`reconcileWorkflowSnapshotForResume` 把 `active` 的节点改回 `pending` 并写明原因，`active` 的阶段改回 `pending`，`active` 的活动记为 `cancelled`（`lifecycle.ts:88`），然后主循环跳过已完成阶段接着跑。取消则把所有 `pending` 与 `active` 的节点、阶段、活动一并记为 `cancelled`（`lifecycle.ts:163`），再中止本进程里这个 run 的控制器（`runtime.ts:247`）。控制器表只在内存里（`runtime-context.ts:22`），所以从另一个进程执行 `stop` 只会改写快照，停不掉正在别处运行的子会话。

## 与动态工作流的关系

仓库里其实有三条都叫 workflow 的线：

| | 专家工作流 | 脚本工作流 | 动态工作流 |
| --- | --- | --- | --- |
| 谁来编排 | 固定八阶段加图调度器 | 模型提交的脚本，旧 `Workflow` 工具 | 模型写的 TypeScript 脚本，编译后在沙箱里跑 |
| 契约 | `contracts/src/workflow/index.ts` | `apps/zcode-cli/packages/contracts/src/workflow/script.ts`、`apps/zcode-cli/packages/contracts/src/tools/workflow.ts` | `apps/zcode-cli/packages/contracts/src/interfaces/dynamic-workflow-run.port.ts` 等 |
| 存储 | `~/.zcode/cli/workflows` 下的 JSON 与 JSONL | SQLite 的 `workflow_*` 表 | SQLite 的 `dwf_*` 表 |
| 入口 | `/expert` | `Workflow` 工具，当前不注册 | `CreateWorkflow` 等工具、`/workflow`、桌面端 |
| 后台任务类型 | 不登记 | `local_workflow` | `local_dynamic_workflow` |

从代码留下的痕迹看，动态工作流是从脚本工作流长出来的，与专家工作流没有继承关系：

- 动态工作流的 README 说生产驱动放在 bootstrap，“evolving the existing `script-workflow-*` substrate”（`apps/zcode-cli/packages/dynamic-workflow/README.md:15`）；它的 run 快照类型建在旧 `Workflow` 工具端口的 `WorkflowTaskSnapshot` 上（`apps/zcode-cli/packages/contracts/src/interfaces/dynamic-workflow-run.port.ts:200`）。
- `workflow_*` 表来自 0.13.0 的迁移 `0007_workflow_script_runtime`（`apps/zcode-cli/packages/adapters/src/storage/session-store/migrations.ts:227`），`dwf_*` 表的迁移注释写明“legacy 的 workflow_* 表只是模板不是家”（`migrations.ts:801`）。专家工作流两套表都不用。
- `/workflow` 是随 CLI 打包的 zcode-guide 插件提供的自定义命令，对应的是动态工作流（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol/slash-commands.ts:62`）。

共用的只有外围：三者的子会话都用 `workflow_child` 任务类型，共享同一份工具禁用名单，注释里点名了 `/workflow`、`/expert` 与脚本工作流（`tool-allowlist.ts:18`）；App 接口里脚本工作流的方法沿用了 `ExpertWorkflowCommandResult` 作返回类型（`app/types.ts:549`）。另有一处注释与代码不符：内置工具表把旧 `Workflow` 工具称作“`/expert` 脚本通道”（`apps/zcode-cli/packages/core/src/tool/handlers/index.ts:144`），但 `/expert` 实际走的是本篇的 `ExpertWorkflowRuntime`，与 `Workflow` 工具无关。

两者思路上的相近之处——分阶段、每一步一个子会话、持久化以便续跑、事后审查——都是在各自的代码里重新实现的。动态工作流怎样让模型自己写编排脚本，从下一篇开始讲。

下一篇：[动态工作流（一）：门面与编译器](https://daiw.net/manual/zcode/dwf-compiler)——模型写的 TypeScript 工作流脚本长什么样，编译器怎样从脚本里恢复类型、依赖与站点身份。
