# 动态工作流（二）：引擎、沙箱与日志回放

> 引擎怎样在不做 I/O、不读时钟的前提下调度多个 actor：序号、日志回放与 hold 规则、repair 与 nudge、用量记账；沙箱怎样用子进程加 vm 上下文隔离脚本、禁用 Date.now 与 Math.random；NDJSON 线协议，以及各类故障落到哪种终态。

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

[上一篇](https://daiw.org/manual/zcode/dwf-compiler)的终点是一段降级后的 JavaScript：唯一的自由标识符是 `__host`，门面调用都改写成了 `__host.*`，有站点的还带上了站点 ID。这一篇讲它怎样被执行，以及中断之后怎样从原处接着跑。

执行分三层。同一个纯函数包里的引擎核心（`apps/zcode-cli/packages/dynamic-workflow/src/engine`）负责调度和日志决策；兄弟包 `@zcode/dynamic-workflow-runtime`（`apps/zcode-cli/packages/dynamic-workflow-runtime`）把脚本放进子进程里的 vm 上下文，用 NDJSON 把 `__host.*` 调用桥回引擎；bootstrap 里的生产 driver 把每个 actor 落成一个真实的子会话，把日志写进 SQLite。前两层在本篇，第三层在[下一篇](https://daiw.org/manual/zcode/dwf-tools)。

## 三层与三条边界

引擎的类型文件开头列出了它的契约面（`apps/zcode-cli/packages/dynamic-workflow/src/engine/types.ts:1`）：Boundary A 是沙箱脚本调用的宿主 API `WorkflowHostApi`（`types.ts:360`）；Boundary B 是引擎向下驱动的 `WorkflowDriver`（`types.ts:416`），外加 driver 向上回报进度的 `WorkflowReportSink`（`types.ts:505`）；Boundary C 是一组运行事件 `RunEvent`（`types.ts:603`）；另有一个日志端口 `JournalStorePort`（`types.ts:970`）。`WorkflowEngine` 同时实现 A 与回报面（`apps/zcode-cli/packages/dynamic-workflow/src/engine/engine.ts:136`）。

| 层 | 位置 | 负责 | 边界 |
| --- | --- | --- | --- |
| 引擎核心 | `dynamic-workflow/src/engine` | 序号、日志查询与写入决策、actor 队列、并发上限、submit 裁决、用量累计、终态 | 不做 I/O、不读时钟、不用随机数 |
| 沙箱 harness | `dynamic-workflow-runtime/src` | 写入口文件、起子进程、NDJSON 桥接、超时与取消、把宿主故障归一成引擎终态 | 只依赖纯包与 node 内建 |
| 生产 driver | `bootstrap/src/app/workflow-driver.ts` 等 | actor 子会话、模型调用、世界读取、产物落盘、SQLite 日志 | 见[下一篇](https://daiw.org/manual/zcode/dwf-tools) |
| 试验台 driver | `bootstrap/src/app/dynamic-workflow-snippet-service.ts` | `EvalWorkflowSnippet`：内存日志，没有 ask | 同一套沙箱与世界读取 |

纯度是确定性回放的前提。引擎文件头写得很直白（`engine.ts:4`）：

> 核心不做任何 I/O、不读时钟、不用随机数：随时间/调度变化的决策要么被 journal 记录、要么被"每站点序号 + 每 actor FIFO 的 actorSeq"这套确定性规则固定，从而首次执行与 replay 逐字一致。

runtime 包的 README 把依赖边界写成“整条 sandbox↔engine 管线 app-free 可跑”的证明：它绝不 import core、contracts、bootstrap、adapters（`apps/zcode-cli/packages/dynamic-workflow-runtime/README.md:6`）。

## 身份：站点乘序号

一次 ask 的身份是“站点 ID × 该站点第几次执行”，第 n 次执行 `ask#3` 就是实例 `ask#3@n`（`types.ts:24`）；actor 同形，是“创建站点 × 序号”。序号只有一个铸造点 `nextOrdinal`，createActor、ask、世界读取、report、产物全都经它，它也顺手记下实例出生时所在的阶段（`engine.ts:614`）。阶段名另用一张计数表，一个恰好叫 `report#1` 的阶段不能挪动那个站点的序号（`engine.ts:147`）。

同一 actor 上的 ask 还有一个准入序号 `actorSeq`，按记录类型的定义，ask 行在“run、actor 站点、actor 序号、actorSeq”四列上唯一（`types.ts:881`）；SQLite 表上没有这条约束，由引擎自己守（`apps/zcode-cli/packages/adapters/src/storage/session-store/migrations.ts:818`）。每次宿主调用的输入另算一个防御性哈希：对规范化 JSON 做 FNV-1a 32 位，不用 `node:crypto`，因为它只做回放时的一致性校验，确定性和可移植才是重点（`apps/zcode-cli/packages/dynamic-workflow/src/engine/hash.ts:1`）。ask 哈希的是指令文本（`apps/zcode-cli/packages/dynamic-workflow/src/engine/scheduler.ts:107`），世界读取哈希 `{op, args}`（`apps/zcode-cli/packages/dynamic-workflow/src/engine/engine-world.ts:37`），report 哈希条目本身（`apps/zcode-cli/packages/dynamic-workflow/src/engine/engine-report.ts:49`）。

## AskScheduler：一次 ask 的一生

ask 的生命周期全在 `AskScheduler` 里（`scheduler.ts:38`）。引擎的 `ask` 先挡两种接线错误：句柄不认识是 `UnknownActor`，站点没有规格是 `MissingAskSpec`，两者都让整个 run 失败；后者不肯兜底成无类型 ask，因为那会让 typed ask 悄悄失去 schema 校验（`engine.ts:417`）。之后交给调度器：

```mermaid
flowchart TD
  A["admitAsk：铸出 ask 站点的第 n 个实例"] --> J{"日志里已有这一行？"}
  J -->|"有，但 inputHash 不符"| F["InputHashMismatch，整个 run 失败"]
  J -->|"有，已完结"| C["按记录的 actorSeq 挂起，轮到时直接按记录结算"]
  J -->|"有，仍是 running"| R["按记录的 actorSeq 挂起，轮到时重新派发"]
  J -->|"没有"| N["排在该 actor 所有记录行之后，分配新的 actorSeq"]
  N --> I{"修订导入缓存命中？"}
  I -->|"命中"| C2["写一行 completed，按缓存结算"]
  I -->|"未命中"| Q["准入即落 running，进 actor 队列"]
  R --> Q
  Q --> P["pumpActor：actor 空闲，且在飞 ask 少于 maxConcurrency"]
  P --> D["ensureSession 惰性建会话，node-dispatched，driver.startAsk"]
```

日志命中的那一段（`scheduler.ts:110`）：

```ts
    const recorded = this.journal.getNode(this.host.runId, siteId, ordinal);
    if (recorded !== undefined) {
      // 命中即防御性校验 inputHash——不一致说明纯度契约被破坏，run 大声失败。
      if (recorded.inputHash !== hash) {
        const err = hashMismatch(instance, recorded.inputHash, hash);
        this.host.failRun(err);
        return Promise.reject(err);
      }
      const seq = recorded.actorSeq ?? 0;
      if (recorded.status === "running") {
        // 崩溃于执行中：按记录的 actorSeq 位置重新 live 派发（hold 规则保证其准入次序）。
        actor.pendingRecorded.set(seq, () => {
          actor.imported?.reconcileRecorded(seq, recorded.inputHash, this.host.wasLiveBeforeResume(instance));
          this.admitLive(instance, actor, seq, instructions, hash, spec, deferred);
        });
      } else {
        // completed / failed：短路结算，无 driver 调用。
        actor.pendingRecorded.set(seq, () => {
          actor.imported?.reconcileRecorded(seq, recorded.inputHash, this.host.wasLiveBeforeResume(instance));
          this.releaseCachedAsk(instance, recorded, deferred);
        });
      }
      this.drainAdmission(actor);
      return deferred.promise;
    }
```

几条要点：

- **hold 规则**。记录行不是一到就放行，而是按 `actorSeq` 挂在 `pendingRecorded` 里，严格按序释放；新来的 ask 要等这个 actor 在日志里的所有记录行都准入完，才能排进去（`scheduler.ts:294`）。记录行的条数在 actor 注册时从日志里数出来（`scheduler.ts:75`）。从代码看，这是为了让新 ask 分到的 `actorSeq` 排在所有记录之后：一个 actor 是一段累积上下文的对话，回放时它收到任务的顺序必须和第一次一样。
- **失败也要回放**。已记录的失败按原样再拒绝一次，因为脚本可能 `try/catch` 过它并据此分支（`scheduler.ts:320`）；同理，结算为失败时必须落日志、覆盖准入时的 running（`scheduler.ts:418`）。一次 ask 落两次库：准入写 running，结算写 completed 或 failed；report 是唯一的例外，一次写成 completed（`types.ts:870`）。
- **派发**。每个 actor 同时只跑一个 ask（`actor.current`），全 run 在飞的 ask 数不超过 `caps.maxConcurrency`（`scheduler.ts:330`）。会话在第一次派发时才建，每个 actor 一次（`scheduler.ts:374`）。按模型请求计的进程级闸门不在这里，在 driver 之下（`scheduler.ts:361`），见本篇最后一节。

笔者把两个包复制出来编译，用内存日志和一个假 driver 跑了下面这段脚本两次，第一次在 `ask#3` 刚派发时取消，第二次用同一个 runId、同一份日志再跑：

```ts
interface Verdict { ok: boolean; reason: string }
const a = agent("甲");
const b = agent("乙");
const [x, y] = await Promise.all([a.ask<Verdict>("审一"), b.ask<Verdict>("审二")]);
const z = await a.ask("总结 " + x.reason + y.reason);
return { x, y, z };
```

假 driver 让“乙”第一次交一个坏结果。两次的事件序列（整理过格式）：

```text
第一次
run-started
node-queued ask#1@1 / node-queued ask#2@1
node-dispatched ask#1@1 / node-dispatched ask#2@1
node-settled ask#1@1 ok
  reject [{"expected":"present","got":"missing","path":"$.reason"},{"expected":"boolean","got":"string \"yes\"","path":"$.ok"}]
node-repairing ask#2@1
node-settled ask#2@1 ok
node-queued ask#3@1 / node-dispatched ask#3@1
node-settled ask#3@1 cancelled
run-settled stopped user

第二次
run-started
node-settled ask#1@1 cached ok
node-settled ask#2@1 cached ok
node-queued ask#3@1 / node-dispatched ask#3@1
node-settled ask#3@1 ok
run-settled completed
```

两个 actor 的 ask 并行派发；`ask#3` 要等脚本里的 `Promise.all` 兑现才发出。第二次运行里，已完结的两步直接按记录结算，没有 queued、dispatched 事件，也不经过 driver；被取消的 `ask#3` 在日志里仍是 running（中止在飞 ask 时只发事件、不改日志行），于是按原位置重新派发。

### repair 与 nudge

typed ask 的结果经 `submit_result` 工具交回。driver 上报 `askSubmitAttempted`，调度器用注入的校验器检查：通过就 accept 并结算；不通过且还有预算就回 reject 与违规列表，发一条 `node-repairing`；预算用完则取消这次 ask，以 `ValidationFailed` 结算（`apps/zcode-cli/packages/dynamic-workflow/src/engine/scheduler-submit.ts:23`）。一轮对话结束而没有提交时，无类型 ask 直接拿最终文本结算；typed ask 先 nudge 一次，再没有提交就以 `ResultNotSubmitted` 结算，错误里带着最终文本（`scheduler-submit.ts:94`）。两个预算是常量：repair 3 次，nudge 1 次（`types.ts:1039`、`types.ts:1042`）。

校验前还有一步宽松归一：原值不过、而原值是字符串时，先 `JSON.parse` 一次再校验。注释记着来由：GLM-5.3 经 Anthropic 兼容端点时，常把 `result` 参数序列化成 JSON 字符串，校验器如实报“expected object, got string”，repair 三次后 run 失败（`scheduler-submit.ts:57`）。解析后仍不过时，报的是解析后对象上的违规，模型才有得改（`scheduler-submit.ts:82`）。reject 在 driver 那边怎样变成同一轮里的错误结果、nudge 怎样起一个新轮次，在下一篇。

### 用量记账

driver 每完成一次 ask 上报 `AskStats`：token 数、工具调用数、轮数，以及其中碰过外部世界的调用数（`apps/zcode-cli/packages/dynamic-workflow/src/engine/ask-observation-types.ts:10`）。引擎把 token 累加进 `spentTokens`，先写库再发 `usage-updated`，事件载荷与列值因此永远相等；run 结算之后迟到的统计只记账、不再发事件，因为 `run-settled` 必须是事件流的最后一条（`engine.ts:542`）。用量只是观察面，只记账、只广播，永远不会让 run 失败（`engine.ts:200`）。修订出来的 run 从前驱的累计值起账，脏值在写库前归一（`engine.ts:270`）。

## 日志：四类记录与回放

日志端口是同步的仓储式接口，正好贴合 `node:sqlite` 的 `DatabaseSync`，也让核心保持确定性（`types.ts:9`）。四类记录对应四张表：`RunRecord`（`types.ts:799`）、`ActorRecord`（`types.ts:846`）、`NodeRecord`（`types.ts:886`）、按单调序号追加的 `StoredEvent`（`types.ts:927`）。节点种类有 ask、world-read、world-run、report、artifact 五种（`types.ts:560`）；`world.run` 在站点层面是一次世界读取，落库时单列 `world-run`，理由是“效应不该伪装成读”（`types.ts:554`、`engine-world.ts:67`）。世界读取的结果进日志，所以回放对 run 与 resume 之间的磁盘变化免疫（`engine-world.ts:49`）；落库的实参序列化后超过 4096 字节就逐项截成预览（`types.ts:575`）。

引擎每记一条事件，都是先写日志、再把同一个对象交给 driver 扇出（`engine.ts:640`）。`log` 与 `phase-entered` 只有事件、没有节点行，没法去重，resume 重跑会把它们再发一遍，消费者按单调归约免疫（`types.ts:707`）。

resume 就是用既有的 runId 再构造一次引擎：日志里已有这一行，构造函数走恢复分支，report 计数、产物版本、已花 token 都从日志行恢复（`engine.ts:315`）。前提是脚本逐字节相同：两侧都有哈希且不同，就在构造时同步抛出 `ScriptHashMismatch`，拒绝的是这次 resume，而不是把一个还能用正确脚本恢复的 run 标成失败（`engine.ts:298`）。内存版日志 `InMemoryJournalStore` 进出都做 `structuredClone`，模拟存储边界，试验台就用它（`apps/zcode-cli/packages/dynamic-workflow/src/engine/journal-memory.ts:1`）。SQLite 版在[下一篇](https://daiw.org/manual/zcode/dwf-tools)。

## 沙箱：子进程加一个 vm 上下文

harness 的入口是 `runWorkflowScript`（`apps/zcode-cli/packages/dynamic-workflow-runtime/src/harness.ts:172`）：先建一个转发用的回报面、用它造 driver，再以这个 driver 构造引擎（`harness.ts:176`）；然后渲染一份自包含的 ESM 入口文件，写到 `<cwd>/.zcode/workflow-runs/<runId>.mjs`。payload 不走命令行，因为 Windows 的命令行上限是 32,767 字符，整份脚本放上去一过约 18 KB 就 `spawn ENAMETOOLONG`（`harness.ts:227`）。入口文件留作每次执行体的存档，目录里写一份内容为 `*` 的 `.gitignore`；项目目录写不进去就退到系统临时目录并报一条警告（`apps/zcode-cli/packages/dynamic-workflow-runtime/src/child-entry-file.ts:8`）。

子进程默认以 `process.execPath --max-old-space-size=256 <entry>` 启动（`harness.ts:162`、`harness.ts:238`），环境里显式带上 `ELECTRON_RUN_AS_NODE=1`：桌面端的 Agent 跑在 Electron Helper 里，不带它子进程会按完整的 Chromium 应用启动，卡在 GPU 初始化（`harness.ts:246`）。CLI 打成 SEA 单文件时不认 Node 的命令行旗标，于是改为自己 re-exec 自己，带上隐藏子命令 `__zcode-dwf-child`（`apps/zcode-cli/packages/contracts/src/plugins/index.ts:20`、`apps/zcode-cli/packages/bootstrap/src/app/dynamic-workflow-run-launch.ts:352`）。`run.ts` 在严格的参数解析之前就把它分派出去（`apps/zcode-cli/packages/cli/src/run.ts:300`），子命令 `import()` 入口文件并调用它导出的 `start`，注入 CLI 进程的 vm、readline 与 stdio（`apps/zcode-cli/packages/cli/src/dwf-child-command.ts:44`）；堆上限在这条路上只能由入口文件 best-effort 地 `v8.setFlagsFromString`（`apps/zcode-cli/packages/dynamic-workflow-runtime/src/child-source.ts:353`）。

子进程里，脚本跑在 `vm.createContext` 建的独立 realm 里，只有 ES 内建对象加一个注入的 `__host`，天然没有 `process`、`require`、`Buffer`、`fetch`（`child-source.ts:15`）。两个 realm 之间只有两样东西跨界：一个外层的 `__send(string)` 函数和入站的行字符串，所以脚本看到的 `Array.isArray`、`instanceof`、原型链全是 context 自己的（`child-source.ts:19`）；运行期的 `import()` 会因为没提供回调而抛错（`child-source.ts:24`）。`args` 在 context 里用 `JSON.parse` 构造再浅冻结，注释承认这不是安全边界，只防手滑重新赋值（`child-source.ts:156`）。最后是三条运行期禁令（`child-source.ts:227`）：

```js
// —— 运行期禁令（belt；编译诊断是 suspenders）：Date.now / argless new Date() / Math.random ——
var __NativeDate = Date;
class __WorkflowDate extends __NativeDate {
  constructor() {
    if (arguments.length === 0) throw new Error("argless new Date() is disabled in workflows");
    super(...arguments);
  }
  static now() {
    throw new Error("Date.now() is disabled in workflows");
  }
  static parse(value) {
    return __NativeDate.parse(value);
  }
  static UTC() {
    return __NativeDate.UTC.apply(__NativeDate, arguments);
  }
}
globalThis.Date = __WorkflowDate;
Math.random = function () {
  throw new Error("Math.random() is disabled in workflows");
};
```

理由是回放。日志只记宿主调用，脚本自己的逻辑在 resume 时会从头再算一遍；如果逻辑依赖当前时间或随机数，第二遍就可能拼出不同的指令，撞上 `InputHashMismatch`，那条错误信息说的正是“the script is not deterministic, so the journal cannot be replayed”（`scheduler.ts:459`）。`new Date(毫秒数)`、`Date.parse`、`Date.UTC` 这些确定性的用法照常可用。从门面的设计看，脚本真要当前时间，可以用 `world.run` 去取：它的结果进日志，resume 时读记录而不重新执行（`apps/zcode-cli/packages/dynamic-workflow/src/facade/dts.ts:384`）。

注释里说的编译期那一层（“suspenders”）目前并不存在：分析器里没有检查 `Date.now` 或 `Math.random` 的诊断，笔者试过，含这两个调用的脚本零诊断通过编译，只会在运行时抛错。

## NDJSON 线协议

线协议的唯一真源是 `protocol.ts`，子进程那一侧是手写的镜像，因为它以内嵌字符串的形式运行，没法 import（`apps/zcode-cli/packages/dynamic-workflow-runtime/src/protocol.ts:4`）。

| 方向 | `kind` | 内容 | 说明 |
| --- | --- | --- | --- |
| 子到父 | `create-actor` | `localId`、`siteId`、`name`、`persona` | 即发即忘（`protocol.ts:45`） |
| 子到父 | `request` | `id` 与 `type`：`ask`、`world-read`、`publish-artifact` | 需要应答（`protocol.ts:60`） |
| 子到父 | `event` | `type`：`log`、`report`、`declare-artifact`、`phase-entered` | 不需要应答，但到达顺序承重（`protocol.ts:95`） |
| 子到父 | `complete` | `ok`，加 `value` 或 `error` | 脚本返回或抛错（`protocol.ts:150`） |
| 父到子 | `response` | `id`、`ok`，加 `value` 或 `error` | 应答一次 `request`（`protocol.ts:165`） |

`create-actor` 不走请求应答，是因为 Boundary A 要求 `createActor` 同步返回句柄：子进程没法为一次同步返回等一个来回，于是自己造一个本地句柄 `local#N` 发出去；父进程处理 create-actor 是纯同步的，stdio 又是先进先出，所以任何引用这个句柄的 ask 到达之前，映射一定已经建好（`protocol.ts:14`）。事件通道的顺序同样承重：一条带标签的 report 必须晚于它的看板声明到达，否则引擎以 `ArtifactUndeclared` 让整个 run 失败（`protocol.ts:90`）。错误以 `WireError` 过界，带着 `code`、`violations`、`finalText`，脚本里的 `try/catch` 能按结构处理；模型侧的错误不再过界，要么在运行时里重试，要么让整个 run 停下（`protocol.ts:25`）。

runtime 的 README 只列了 `request` 里的 ask、world-read 与 `event` 里的 log（`apps/zcode-cli/packages/dynamic-workflow-runtime/README.md:43`），落后于 `protocol.ts`。笔者把上一篇的编译器和这个包复制出来，喂给子进程一段小脚本，在父进程一侧手工应答，抓到的原始行如下：

```text
child->parent {"kind":"event","type":"phase-entered","name":"规划"}
child->parent {"kind":"create-actor","localId":"local#1","siteId":"actor#1","name":"规划员"}
child->parent {"kind":"request","id":"r1","type":"ask","siteId":"ask#1","actor":"local#1","instructions":"列出步骤"}
parent->child {"kind":"response","id":"r1","ok":true,"value":{"steps":["a","b"]}}
child->parent {"kind":"event","type":"log","message":"steps=2"}
child->parent {"kind":"request","id":"r2","type":"world-read","siteId":"world-read#1","op":"glob","args":["*.ts"]}
parent->child {"kind":"response","id":"r2","ok":true,"value":["x.ts","y.ts"]}
child->parent {"kind":"event","type":"report","siteId":"report#1","item":{"count":2}}
child->parent {"kind":"complete","ok":true,"value":{"plan":{"steps":["a","b"]},"banned":"Error: Date.now() is disabled in workflows"}}
```

脚本最后在 `try` 里调了一次 `Date.now()`，返回值里的 `banned` 就是那条运行期禁令的错误。把 harness 与引擎连起来，一次正常的 run 是这样的：

```mermaid
sequenceDiagram
  participant H as harness（父进程）
  participant E as WorkflowEngine
  participant D as driver
  participant C as 子进程 vm
  H->>C: spawn node 入口文件
  C->>H: create-actor（本地句柄）
  H->>E: createActor，建立句柄映射
  C->>H: request ask
  H->>E: engine.ask
  E->>D: createActorSession，startAsk
  D-->>E: askSubmitAttempted
  E->>D: respondToSubmit accept
  E-->>H: ask 的 promise 兑现
  H->>C: response ok
  C->>H: event report 或 log
  C->>H: complete ok
  H->>E: engine.complete
  E->>D: dispose
  H->>C: kill
```

## 失败裁决

run 的裁决归引擎所有，harness 自己的 finalize 只做清理：清定时器、摘掉取消监听、关掉读取子进程输出的 readline，子进程还活着就 kill（`harness.ts:314`）。故障按“是谁的错”分成两路（`harness.ts:324`）：

```ts
    // 终结失败（脚本抛错 / 子进程崩溃 / 超时 / 协议损坏）都是 run 失败：交给引擎的公有 fail()，
    // 由它 first-wins 结算、driver 侧取消在飞 ask、journal 记 failed + failure_json——run 的裁决
    // 归引擎所有，journal 与调用方看到的结果不分叉。子进程清理由 finalize（经 engine.settled）负责。
    const failRun = (error: WorkflowError): void => {
      if (finalized) return;
      engine.fail(error);
    };
    // 宿主侧故障（沙箱崩溃 / 超时 / 协议损坏）：stopped(interrupted)，可 resume。
    const interruptRun = (message: string, cause?: unknown): void => {
      if (finalized) return;
      engine.stop(
        "interrupted",
        new WorkflowError("Interrupted", message, cause === undefined ? undefined : { cause }),
      );
    };
```

第一段注释是旧说法，下面的 `interruptRun` 才是现状：

| 情形 | 路径 | 终态 | 能否 resume |
| --- | --- | --- | --- |
| 脚本 `return` | `complete ok` → `engine.complete` | `completed` | 不需要 |
| 脚本抛错，包括降级后的函数体编译失败 | `complete ok:false` → `engine.fail(DriverError)`（`harness.ts:474`） | `errored` | 否，只能修订 |
| 引擎契约违反：`InputHashMismatch`、`UnknownActor`、`MissingAskSpec`、`DuplicateActorName`、`ReportCapExceeded` 等 | 引擎内部 `failRun` | `errored` | 否 |
| 子进程起不来、崩溃或提前退出、墙钟超时、吐出非法 NDJSON 行 | `interruptRun`（`harness.ts:254`、`harness.ts:379`、`harness.ts:395`、`harness.ts:411`） | `stopped(interrupted)` | 能 |
| 取消信号 | `onAbort`：`"model"`、`"interrupted"`、`{ superseded }` 各归其类，其余算 `user`（`harness.ts:340`） | `stopped(...)` | `superseded` 以外都能 |
| 确定性的模型侧错误 | driver 的 `stopRun` → `engine.stop("provider")`（`engine.ts:565`） | `stopped(provider)` | 能，先解决原因 |

几个细节：

- 所有结算路径的第一行都是“已结算就忽略”，先到者胜（`apps/zcode-cli/packages/dynamic-workflow/src/engine/engine-settlement.ts:7`）。脚本返回时仍可能有在飞的 ask，比如 `Promise.race` 的输家或没被 `await` 的 ask，它们和取消一样被中止并补发 `node-settled(cancelled)`，否则会一直烧 token（`engine-settlement.ts:15`）。
- 崩溃时以子进程 stderr 归因，stderr 保留末尾 64KB（`harness.ts:163`、`harness.ts:415`）。
- 生产路径调用 `runWorkflowScript` 时不传 `timeoutMs`，正式的 run 没有墙钟超时（`dynamic-workflow-run-launch.ts:295`）；试验台传入调用方给的超时，默认 60 秒、最多 600 秒（`apps/zcode-cli/packages/bootstrap/src/app/dynamic-workflow-snippet-service.ts:147`、`apps/zcode-cli/packages/contracts/src/tools/eval-workflow-snippet.ts:14`）。
- 与文档不一致：runtime 的 README 说四类终结失败都调 `engine.fail`，取消调 `engine.cancel()`，结算状态是 `failed` 与 `cancelled`（`apps/zcode-cli/packages/dynamic-workflow-runtime/README.md:54`、`:57`、`:26`）。代码里宿主侧故障走 `stop("interrupted")`，引擎上没有 `cancel` 方法，逻辑状态是 `completed`、`errored`、`stopped` 三个终态（`types.ts:589`、`types.ts:597`）。harness 文件头的注释（`harness.ts:23`）与入口文件模块“回落也失败就归一成 failed”的说法（`child-entry-file.ts:14`）同样过时，函数自己的文档注释才对（`harness.ts:166`）。

## 并发上限

run 级的容量上限只有一个 `caps.maxConcurrency`，提交时定下并存进日志（`types.ts:110`）。它限制的是在飞的 ask 数，世界读取不受 actor 队列与并发上限约束（`engine-world.ts:92`）。生产环境里上限的天花板是 `max(1, min(16, availableParallelism() − 2))`，给主 Agent 与宿主进程留两个核（`apps/zcode-cli/packages/bootstrap/src/app/workflow-concurrency-ceiling.ts:4`）；请求的值被钳进 `[1, 天花板]` 并向下取整，过大不报错（`apps/zcode-cli/packages/bootstrap/src/app/dynamic-workflow-run-service.ts:135`）。

模型请求另有一道进程级闸门：纯包里的 `ConcurrencyController` 是一台按 provider key 分桶的 AIMD 状态机，不读时钟，`now` 由调用方传入（`apps/zcode-cli/packages/dynamic-workflow/src/engine/concurrency.ts:1`）。限流时 cap 乘以 0.75，连续 4 次成功加 1，地板是 1，一个 key 空闲 300000 毫秒后回到天花板（`concurrency.ts:25`、`concurrency.ts:33`、`concurrency.ts:35`、`concurrency.ts:37`）。bootstrap 的治理器让一个 CLI 进程里的所有 run 和主 Agent 共用这套桶，闸门按每一次模型请求准入，退避期间不占槽（`apps/zcode-cli/packages/bootstrap/src/app/workflow-concurrency-governor.ts:4`）；主 Agent 的请求立即放行但计入在飞数，它的回合永远不会被工作流流量阻塞（`workflow-concurrency-governor.ts:54`）。所以工具说明告诉模型：并发是运行时的事，只有用户要求时才设 `max_concurrency`，遇到限流更不要去调它（`apps/zcode-cli/packages/core/src/tool/handlers/create-workflow-description.ts:71`）。

下一篇：[动态工作流（三）：从工具调用到落库](https://daiw.org/manual/zcode/dwf-tools)——模型用哪些工具创建、修订、试跑、保存与恢复工作流，每个 actor 怎样成为一个子会话，日志又写进了 SQLite 的哪几张表。
