# Workflow 运行时

> 把多智能体编排写成跑在 QuickJS 里的脚本：宿主注入的原语、子智能体派发、并发与重试上限、日志与续跑、事件和界面。

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

让主智能体一轮轮地派子智能体，编排逻辑就散在对话里：没法复现，进程一崩也没法从中间接着跑。MiMo 的 Workflow 把编排写成一段 JavaScript：分几个阶段、哪里并行、失败重试几次、什么时候停，由脚本决定；每一处真正“干活”的调用交给子智能体。脚本跑在 QuickJS 沙箱里，只能用宿主注入的少数几个函数。

这套运行时整个在 `packages/opencode/src/workflow/` 下，上游 OpenCode 没有对应物。沙箱本身（QuickJS 的装配、期限与内存）放在 [exec 与 QuickJS 沙箱](https://daiw.net/manual/mimo-code/exec-sandbox)，四个内置脚本放在下一篇。

## 怎么用

- **默认关闭**：设 `MIMOCODE_EXPERIMENTAL_WORKFLOW_TOOL=true`（或总开关 `MIMOCODE_EXPERIMENTAL`）后，才会注册 `workflow` 工具、TUI 的 `/workflows` 命令和 `/deep-research` 命令（`packages/opencode/src/flag/flag.ts:361`、`packages/opencode/src/tool/registry.ts:312`、`packages/opencode/src/cli/cmd/tui/app.tsx:597`、`packages/opencode/src/command/index.ts:193`）。
- **工具参数**：`operation` 有五种。`run` 要么给 `name` 跑内置 workflow，要么给 `script` 跑内联脚本，不能同时给；可选 `args`、`workspace`、`async`。另外四种是 `status`、`wait`、`cancel`、`resume`（`packages/opencode/src/tool/workflow.ts:36`）。工具说明末尾会附上内置 workflow 目录（`registry.ts:490`）。
- **配置**：`workflow` 段有 `maxConcurrentAgents`、`maxDepth`、`maxLifecycleAgents`、`scriptDeadlineMs` 四项（`packages/opencode/src/config/config.ts:466`），默认值见后文的表。
- **界面**：运行中的 workflow 在对话里是一个面板，实时刷新阶段、日志和计数；`/workflows` 列出当前会话的所有运行，选中后进入全屏页面。
- **自定义脚本**：放在 `.mimocode/workflows/<name>.js` 或 `.claude/workflows/<name>.js`，但只能被另一个脚本里的 `workflow("<name>")` 调用，见“子 workflow”一节。

整体数据流：

```mermaid
flowchart TD
  T["workflow 工具 run"] --> S["WorkflowRuntime.start"]
  S --> L["launch：写库、存脚本、读日志"]
  L --> Q["evalScript：QuickJS 执行脚本"]
  Q -->|"agent()"| A["Actor.spawn 子智能体"]
  Q -->|"workflow()"| L
  Q -->|"phase() / log()"| E["transcript、日志文件、Bus 事件"]
  A -->|"成功结果"| J["同步追加 jsonl 日志"]
```

## 脚本的形状

- 开头必须是 `export const meta = { ... }`，`name` 与 `description` 必填，另有可选的 `whenToUse`、`phases`、`model`、`permissions`（`packages/opencode/src/workflow/meta.ts:20`）。其中 `model` 在代码里找不到读取它的地方。
- meta 不执行：`parseMeta` 用一个递归下降读取器把字面量当数据解析，只认对象、数组、字符串、数字、布尔与 `null`，函数调用、运算符、模板串一律报错（`meta.ts:41`）。文件头注释解释了为什么不用 `new Function`：那会在宿主进程里、沙箱之外执行 meta，一个藏着 `child_process` 调用的 meta 就能跑任意命令。
- 解析完把 meta 语句替换成等长的空白，正文行号不变，报错堆栈仍然对得上（`meta.ts:81`）。
- 正文被包进 `(async () => { ... })()` 执行（`packages/opencode/src/workflow/sandbox.ts:205`），所以顶层可以 `await`，也可以 `return`，返回值就是这次运行的结果。

## 原语

宿主注入的函数在 `launch` 里组装（`packages/opencode/src/workflow/runtime.ts:1373`）：

```ts
const hooks: Record<string, HostFn> = {
  agent,
  phase,
  log: logHook,
  workflow: workflowHook,
  readFile: fileHooks.readFile,
  writeFile: fileHooks.writeFile,
  glob: fileHooks.glob,
  exists: fileHooks.exists,
}
```

`parallel` 和 `pipeline` 不是宿主函数，而是沙箱启动时注入的一段纯 guest 代码（`sandbox.ts:70`）：

```js
globalThis.parallel = (thunks) =>
  Promise.all(thunks.map((t) => Promise.resolve().then(t)));
globalThis.pipeline = (items, ...stages) =>
  Promise.all(items.map((item, index) =>
    stages.reduce((acc, stage) => acc.then((prev) => stage(prev, item, index)), Promise.resolve(item))));
```

| 原语 | 行为 |
| --- | --- |
| `agent(prompt, opts?)` | 派一个子智能体。给了 `schema` 返回校验过的对象，否则返回最终文本；失败不抛异常，返回空值 |
| `parallel(thunks)` | 就是 `Promise.all`，不限流；任一 thunk 抛错，整批失败 |
| `pipeline(items, ...stages)` | 每个 item 依次过所有 stage，不同 item 之间互不等待 |
| `workflow(nameOrScript, args?, opts?)` | 把另一个 workflow 当独立子运行执行并等待结果 |
| `phase(title)`、`log(message)` | 记录进度：写 transcript、写日志文件、发事件 |
| `readFile`、`writeFile`、`glob`、`exists` | 文件读写，限定在 workspace 根目录内 |
| `args` | 启动参数，按值注入的全局变量 |

几点要对照着看：

- 官方博客只提到 `agent()`、`parallel()`、`pipeline()`，代码里还有上表其余几个。
- 工具说明写着 parallel 里抛错的 thunk“resolves to null”（`packages/opencode/src/tool/workflow.txt:11`），预置代码并不捕获；源码注释说明这是有意的，脚本逻辑错误应当大声失败（`sandbox.ts:63`）。以代码为准。
- 宿主返回的 `null` 进沙箱会变成 `undefined`（`sandbox.ts:334`），所以脚本里判断 agent 失败要写 `!r` 或 `r == null`，写 `r === null` 会漏判。
- 沙箱删掉了 `Date`、`WeakRef`、`FinalizationRegistry`，`Math.random` 换成以 runID 的 SHA-1 为种子的伪随机数，保证续跑时重放出同样的序列（`sandbox.ts:170`、`runtime.ts:1400`）。需要日期就由调用方经 `args` 传入。
- 文件原语用 `resolveInWorkspace` 拒绝 `..` 与越界的绝对路径，`glob` 的结果排序后再滤掉根目录之外的匹配。检查按路径字面进行、不解析符号链接，注释自己说明它是作用域限制而非安全边界（`packages/opencode/src/workflow/workspace.ts:11`）。

## agent()：派子智能体

`agent` 的选项有 `agentType`（默认 general）、`tools`、`model`、`schema`、`isolation`、`label`、`phase`、`timeoutMs`、`retry`（`runtime.ts:224`）。`model` 可以是 `provider/model`，也可以是 `lite` 这样的模型组名，解析失败回落到运行默认值并只告警一次（`runtime.ts:333`）。共享工作区的派发调用的是 Actor 服务（`runtime.ts:816`）：

```ts
const spawned = yield* actor.spawn({
  mode: "subagent",
  sessionID: input.sessionID,
  agentType: o.agentType ?? "general",
  description: spawnDescription(o),
  task: prompt,
  context: "none",
  tools: o.tools ? [...o.tools] : "INHERIT",
  background: true,
  parentActorID: input.parentActorID,
  model: resolvedModel,
  // Register the child in the reclaim set the instant the actor
  // exists — synchronously inside the spawn Effect, BEFORE its work
  // fiber detaches. A cancel racing this spawn would otherwise miss
  // it (the child runs detached in the actor scope, so interrupting
  // the workflow fiber can't stop it) and leak an orphan. MR104 #2.
  onActorID: (id) => {
    entry.childActorIDs.add(id)
    onActorID?.(id)
  },
  ...(o.schema ? { format: { type: "json_schema" as const, schema: o.schema, retryCount: 2 } } : {}),
})
```

子智能体在 workflow 所在的会话里以后台 subagent 身份运行，`context: "none"` 表示看不到父对话，工具默认继承。给了 `schema` 就走结构化输出，校验失败最多再修两次；返回值在要求 schema 时只取结构化结果，否则优先结构化结果、再取最终文本（`runtime.ts:854`）。Actor 的生成与回收见 [Actor](https://daiw.net/manual/mimo-code/actors)。

失败被归成五类：`over-cap`、`spawn-reject`、`timeout`、`actor-error`、`no-deliverable`（`runtime.ts:680`）。脚本只看到空值，原因通过 `workflow.agent_failed` 事件发出。

`isolation: "worktree"` 另走一条路：先建一个新 worktree，在 `Instance.provide` 绑定的该目录里派发，子智能体的读写与 Shell 都落在 worktree 里。结束后只有“成功且有改动”才保留 worktree，并在返回值上附加 `_worktree`（分支、目录、`changed`），其余情况一律删除（`runtime.ts:1029`）。隔离调用不进日志，续跑时总会重新执行。

**权限**：后台子智能体没法回答授权询问。询问路由对普通后台子智能体采取“继承”：父会话已经允许的就放行，否则立即以 `DeniedError` 失败、绝不挂起（`packages/opencode/src/agent/config.ts:57`）。meta 的 `permissions` 清单就是为这个准备的：`launch` 在启动时把每项权限问一次，用户选“总是允许”后规则进入会话，子智能体随后继承。只有顶层、前台发起的运行才会真的弹窗，嵌套运行和后台发起的运行一律非交互（`runtime.ts:737`）。

**可见性**：`session/visibility.ts` 决定哪些会话禁止渲染，拦的是“为运行时派生的智能体而建”的宿主会话。workflow 的子智能体不在其列，因为它们根本不建子会话：该文件的注释专门澄清，`agent()` 只在 workflow 自己的会话里登记 actor，不是第四类子会话（`packages/opencode/src/session/visibility.ts:31`）；真正被拦的只有检查点写手、dream、distill 三类智能体的宿主会话（`packages/opencode/src/agent/config.ts:7`）。所以 TUI 可以从结构树直接打开每个子智能体的对话。

## 上限与期限

| 项 | 默认 | 出处 |
| --- | --- | --- |
| 全局并发 | `min(16, 2 × CPU 核数)`，配置 `maxConcurrentAgents` 覆盖 | `runtime.ts:387` |
| 单次运行并发 | 等于全局上限，只能往小调 | `runtime.ts:615` |
| 单次运行累计派发 | 1000（`maxLifecycleAgents`），超出后 `agent()` 直接返回空值 | `runtime.ts:620` |
| 嵌套深度 | 8（`maxDepth`） | `runtime.ts:611` |
| 整个脚本 | 12 小时（`scriptDeadlineMs`） | `runtime.ts:30` |
| 单个 agent 超时 | 默认不限，`timeoutMs` 开启 | `runtime.ts:649` |
| 重试 | 默认 1 次；`retry.attempts` 是总次数，退避 `baseMs` 400、`maxMs` 4000，全抖动 | `runtime.ts:1102` |

全局信号量由所有运行（含子运行）共享，首次启动时按配置建好，之后改配置不会重建（`runtime.ts:394`）。只有 `spawn-reject`、`timeout`、`actor-error` 会重试，额度耗尽和“跑完了却没产出”不重试（`runtime.ts:684`）。超时的子智能体会被优雅取消，结果按空值处理。

## 子 workflow

`workflow()` 的第一个参数含有 `export const meta =` 就当内联脚本，否则当名字（`packages/opencode/src/workflow/resolve.ts:7`）。名字只允许字母、数字和 `._-`，从 workspace 根向上走到 worktree 根，每层依次找 `.mimocode/workflows/<name>.js`、`.claude/workflows/<name>.js`，就近优先（`resolve.ts:20`）。

这里和 README 有出入。README 说可以“用同名文件覆盖内置 Workflow（如 `.mimocode/workflows/compose.js`）”，但 `workflow` 工具的 `run` 按 `name` 只查内置注册表（`tool/workflow.ts:206`），而脚本里的 `workflow()` 只查上述文件、不查内置注册表。两条查找路径互不相交：工具以 `name: "compose"` 调用的永远是内置版本，放在 `.mimocode/workflows/` 下的文件只对脚本内的 `workflow("compose")` 生效。想从顶层跑自定义脚本，要么把文件内容作为 `script` 传入，要么传一段内联脚本，在里面调用 `workflow("<name>")`。

子运行的 runID 由父 runID 与调用键哈希而来（`runtime.ts:1259`），续跑时找得回来；它有自己的派发额度与超时默认值，workspace 只能缩小不能扩大（`runtime.ts:1314`）。成环、超深、名字找不到属于接线错误，带 `WorkflowStructuralError` 标记一路传到根运行、让整棵树失败（`runtime.ts:1281`、`runtime.ts:1339`）；子运行自身失败则和 `agent()` 一样返回空值，另发 `workflow.child_failed` 事件。

## 持久化与续跑

运行状态存三处：

- SQLite 表 `workflow_run`：会话 ID（级联删除）、名字、状态、运行中与成败计数、当前阶段、父 actor、参数、脚本 SHA-256、单 agent 超时、错误（`packages/opencode/src/workflow/workflow.sql.ts:6`）。计数写库做了防抖，每次运行每 250 毫秒最多一次（`runtime.ts:418`）。
- 数据目录下 `workflow/<runID>.js`：启动时保存的脚本全文（`packages/opencode/src/workflow/persistence.ts:100`）。
- 同目录的 `<runID>.jsonl`：结果日志。

每次成功的非隔离 `agent()` 调用，结果都会**同步**追加进日志（`runtime.ts:1140`）。键是 prompt、`agentType`、`model`、`schema`、`phase` 的 SHA-256 再加出现序号（`persistence.ts:25`），`label`、`tools` 不参与，改它们不会让缓存失效。命中时直接返回（`runtime.ts:1074`）：

```ts
if (o.isolation !== "worktree") {
  const base = journalKeyBase(promptStr, {
    agentType: o.agentType,
    model: o.model,
    schema: o.schema,
    phase: o.phase,
  })
  const n = occ.get(base) ?? 0
  occ.set(base, n + 1)
  const key = base + ":" + n
  if (journal.results.has(key)) {
    // Cache hit: no spawn, no agentCount increment (would hit the 1000
    // cap on replays alone). Outcome counter DOES climb so the live view
    // reflects reality as replay proceeds.
    entry.succeeded++
    markAgentNode("succeeded", journal.results.get(key))
    scheduleFlush(entry)
    return Promise.resolve(journal.results.get(key))
  }
```

`resume` 用原 runID 重新执行整份脚本：同一 runID 的续跑用进程内锁串行化，仍在本进程里运行的拒绝续跑（`runtime.ts:1537`、`runtime.ts:1545`）；脚本的 SHA-256 与库里记录的不同，说明脚本改过，就清空日志从头跑（`runtime.ts:1562`）。于是已经成功的调用秒回，失败的调用重跑，脚本越“收敛”，第二次做的事越少。模型、并发、期限不入库，续跑时用默认值（`runtime.ts:1514`）；单 agent 超时入库，续跑时沿用（`runtime.ts:1577`）。

取消时先中断脚本 fiber，最多等 5 秒（`runtime.ts:499`）。取消和失败都会回收：优雅取消在途子智能体（每个最多等 5 秒）、删掉仍归本运行所有的 worktree（每个最多 10 秒），并递归取消仍在跑的子运行（`runtime.ts:445`）。成功路径不回收，保留下来的 worktree 就是交付物。

## 事件、结果与界面

Bus 事件共六个：`workflow.started`、`workflow.phase`、`workflow.log`、`workflow.finished`、`workflow.agent_failed`、`workflow.child_failed`（`packages/opencode/src/workflow/events.ts:5`）。

`run` 默认同步：工具一直阻塞到运行结束，其间每 250 毫秒把 transcript 与计数推进工具部件的 metadata，TUI 据此刷新面板（`tool/workflow.ts:287`）。transcript 只保留头 40 行、尾 160 行（`tool/workflow.ts:109`），最终输出里的结果 JSON 截到 4000 字符（`tool/workflow.ts:331`）。`async: true` 则立刻返回 runID，结束时给父智能体的收件箱投一条通知，内容同样截到 4000 字符（`runtime.ts:1418`，收件箱见[收件箱、团队与进度检查](https://daiw.net/manual/mimo-code/inbox-team)）。

脚本调用时还会记一棵只用于观察的结构树：阶段、agent、子 workflow 三种节点，agent 节点带 prompt、模型、工具、是否隔离、耗时和最多 600 字符的结果摘要（`runtime.ts:66`、`runtime.ts:110`）。它不参与日志键和续跑。

服务端在 `/workflows` 下挂了四个路由（`packages/opencode/src/server/routes/instance/workflows.ts:19`）：

| 路由 | 作用 |
| --- | --- |
| `GET /workflows?sessionID=` | 列出会话的运行，`sessionID` 必填 |
| `POST /workflows/:runID/resume` | 续跑，只接受顶层运行的 ID |
| `GET /workflows/:runID/transcript` | 完整 transcript，不截断 |
| `GET /workflows/:runID/structure` | 结构树 |

取消没有 HTTP 路由，只能通过工具的 `cancel`。TUI 的 `/workflows` 对话框每秒轮询列表（`packages/opencode/src/cli/cmd/tui/component/dialog-workflows.tsx:20`），全屏页面用 `WorkflowTree` 画卡片：agent 卡片显示标签、模型、工具数、schema、隔离与耗时，点“open ↗”打开该子智能体的对话；子 workflow 卡片点进去是子运行的页面（`packages/opencode/src/cli/cmd/tui/component/workflow-tree.tsx:34`）。状态为运行中、失败或已取消时页面上有“↻ resume”，确认框提醒会重新执行并可能产生费用（`packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:2929`）。

下一篇：[四个内置 Workflow](https://daiw.net/manual/mimo-code/builtin-workflows)——逐个拆读 compose、deep-research、fact-check、research-experiment 的阶段、并行点与失败处理。
