# 检查点写手

> 隐藏子智能体 checkpoint-writer 怎样被拉起、继承父请求前缀、按 11 节模板改写 checkpoint.md，以及校验钩子与写入沙箱。

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

档位一越过，`fireCheckpoints` 就调用 `tryStartCheckpointWriter`（见[周期](https://daiw.org/manual/mimo-code/cycles)）。真正动笔的是隐藏子智能体 `checkpoint-writer`：它和主智能体用同一个模型，带着文件工具在后台把 `checkpoint.md` 等文件原地改一遍，主智能体不必停下来等。它不是“把对话丢给模型、拿回一段摘要”的单次调用，而是一个会读文件、调 `task` 工具、分几轮编辑的完整 agent 回合，所以要回答的问题也多：它看到什么，能写哪里，写坏了怎么办，没写完怎么算。

## 怎么用

写手不能手动调用。它在 `packages/opencode/src/agent/agent.ts:346` 登记为 `hidden: true` 的原生 subagent，没有自己的 `prompt` 字段，由运行时在三种时机拉起：检查点档位触发；越过触发线或提供方报溢出时还没有检查点；`/rebuild` 时还没有检查点。可调的只有几处：

- `checkpoint.fork`（默认 `true`）：写手继承父请求的系统提示词、工具表与消息前缀，以复用提示词缓存；设为 `false` 则用写手自己的身份，只喂上次水位线以来的增量（`packages/opencode/src/session/checkpoint.ts:806`）。
- `memory.disable_write: true` 或环境变量 `MIMOCODE_DISABLE_CHECKPOINT`：根本不拉起写手（`checkpoint.ts:643`、`checkpoint.ts:664`）。
- 观察：写手跑在标题以 `checkpoint-writer:` 开头的子会话里；主循环等它时，状态栏显示 “Writing checkpoint…” 或 “Preparing conversation context…”；每个写手结束发一条 `writer.cache_perf` 事件，带缓存读写量与命中率。

## 拉起：tryStartCheckpointWriter

`checkpoint.ts:638` 按顺序做这些事：

1. **闸门**：检查点或记忆写入被关，返回 `skipped`。本会话已有写手在跑，就把请求放进单槽等待队列并返回 `queued`，当前写手结束时再用它启动新写手。
2. **取历史**：用和主循环完全相同的方式取 main 切片（`checkpoint.ts:698`），保证分叉出去的前缀与父请求逐字节一致。
3. **定水位线**：`computeBoundary` 在历史末尾留出一段通常 1 万到 2 万 token 的尾巴，尾巴之前那条消息就是这次要覆盖到的位置，再经 `adjustBoundaryForApiInvariants` 修正（`checkpoint.ts:717`），算法见[上下文重建](https://daiw.org/manual/mimo-code/context-rebuild)。
4. **备好文件**：建目录，把旧的小写 `memory.md` 改名为 `MEMORY.md`，`checkpoint.md`、`MEMORY.md`、`notes.md` 不存在时写入模板（`checkpoint.ts:753`）。
5. **拼提示词、捕获前缀**：见后面两节。
6. **建子会话并 spawn**：子会话的 `parentID` 指向父会话。这样父会话的消息表一行不增，子智能体列表里也看不到写手，而所有文件路径都按父会话算好再传进去（`checkpoint.ts:955`）。

spawn 的参数（`checkpoint.ts:972`）：

```ts
const result = yield* actor.spawn({
  mode: "subagent",
  sessionID: writerChildSession.id,
  // Axis A: writer runs under child session, but its checkpoint.md /
  // memory.md / progress paths AND CheckpointContext entries are keyed
  // on the PARENT. The splitover plugin reads these via actor.preStop
  // and must see parentSessionID to re-derive the right paths — without
  // it, checkpointPath(child) returns an empty file and the plugin
  // emits a false topic-missing reflection that loops the writer up to
  // MAX_PRE_REACT.
  parentSessionID: input.sessionID,
  agentType: "checkpoint-writer",
  description: `checkpoint writer for session ${input.sessionID} covering ${rangeDesc}`,
  task: promptText,
  context: "full",
  tools: ["read", "write", "edit", "apply_patch", "glob", "grep", "task"],
  model: {
    providerID: input.model.providerID as ProviderID,
    modelID: input.model.modelID as ModelID,
  },
  background: true,
  forkContext: forkCtx,
})
```

`model` 就是触发这次写入的主循环所用的模型。`tools` 是运行时白名单：分叉模式下模型看到的工具表与父请求相同（为了缓存），调用名单之外的工具会拿到 “Tool not permitted” 结果（`packages/opencode/src/session/prompt.ts:1546`）。Actor 怎样生成、登记与回收见 [Actor](https://daiw.org/manual/mimo-code/actors)。

spawn 之后，`tryStartCheckpointWriter` 把父会话 `checkpoint.md` 里已有的 Discovered 标题存进 `CheckpointContext`，供校验钩子去重（`checkpoint.ts:1003`），然后在服务作用域里 fork 一个结算 fiber。写手成功，它把父会话的 `last_checkpoint_message_id` 推到本次水位线；失败或被取消，水位线不动，日志里区分两类失败（`checkpoint.ts:1054`）：确定性失败指上下文溢出、鉴权、请求格式错这类每一档都会重演的问题；瞬时失败的请求已经走过重试阶梯，下一档自然是再试一次。最后，若等待队列里有请求，结算 fiber 立即为它启动新写手。

## 分叉：让写手复用父请求的缓存

`checkpoint.fork` 为真时，写手的第一个请求与父会话在水位线时刻发出的请求共享前缀：同样的系统提示词、同样的工具定义、同样的消息历史，只在末尾多一条写手的任务。前缀由 `prefixCaptureRef` 指向的闭包构建（`checkpoint.ts:864`），装进 `ForkContext`（`packages/opencode/src/actor/spawn.ts:70`）：

| 字段 | 内容 |
| --- | --- |
| `system` | 父 agent 的系统提示词；会话有冻结的前缀快照时直接取快照 |
| `tools` | 父 agent 的工具定义，只作记录；实际请求用写手解析出的可执行工具，两边 schema 一致 |
| `inheritedMessages` | 截到水位线（含水位线）的历史，转成模型消息，不折叠重建尾部 |
| `parentPermission` | 父 agent 的权限规则，用来过滤写手的工具可见性，使工具表与父请求一致 |
| `watermarkMsgID` | 水位线消息 ID |

主循环遇到 `contextMode` 为 full 的 subagent 或 peer 时走分叉分支（`prompt.ts:4223`）：系统提示词取 `forkCtx.system`，消息是 `inheritedMessages` 加上写手自己新增的消息。所以写手的任务开头要先声明 “Ignore the general coding-assistant framing in the system prompt above”，因为它看到的系统提示词本来是写给主智能体的。

两边有一处有意的差异：主循环发请求时把重建尾部折叠成活动清单，写手的前缀不折叠，好让它从完整的历史写检查点。所以会话里已有重建边界时，两边的消息部分并不逐字节相同（`packages/opencode/src/session/llm-request-prefix.ts:25`）。前缀快照本身见[压缩、修剪与请求前缀](https://daiw.org/manual/mimo-code/compaction-and-prune)。

`fork: false` 时，写手用自己的系统提示词与工具，只收到上次水位线之后的增量。增量起点经 `alignToNonToolResultUser` 往前对齐（`packages/opencode/src/session/checkpoint-align.ts:20`）；这个函数本意是跳过只含工具结果的 user 消息，但 OpenCode 的工具结果挂在 assistant 消息的 `tool` 部件上，实际效果是退到最近的一条 user 消息。增量为空就直接跳过，免得空跑一回还把水位线推过去。

## 写手的提示词

`composeWriterPrompt`（`checkpoint.ts:361`）把一段绝对路径声明、子代理进展差异块和 `packages/opencode/src/agent/prompt/checkpoint-writer.txt` 包进同一个 `<system-reminder>`，作为写手的任务消息。开头这段专门防止模型凭“记忆”编造路径（`checkpoint.ts:369`）：

```ts
return [
  "<system-reminder>",
  "You are now operating in checkpoint-writer mode. Ignore the general coding-assistant framing in the system prompt above. The read, write, edit, glob, grep, and task tools are available; do not invoke others.",
  "",
  "========================================================================",
  "ABSOLUTE PATHS — USE THESE VERBATIM. NEVER COMPUTE, INFER, OR MODIFY.",
  "========================================================================",
  "",
  `CHECKPOINT_PATH = ${input.checkpointFile}`,
  `MEMORY_PATH     = ${input.memoryFile}`,
  `TASK_MEM_DIR    = ${input.taskMemDir}`,
  `NOTES_PATH      = ${input.notesFile}`,
```

后面几行接着叮嘱：Write 的第一个参数必须逐字用这几个路径，发现自己在敲 `/data/checkpoints/` 就停下，那是旧版 v2 的目录布局。之后是 167 行的 `checkpoint-writer.txt`，要求写手按 Turn 1、Turn 2a、Turn 2 三步完成：

- **Turn 1**：并行读 `CHECKPOINT_PATH`、`MEMORY_PATH`、`NOTES_PATH`，以及主文件索引行指向的溢出文件。
- **Turn 2a**：把对话尾部与 notes 条目逐条分流。工作方式偏好进 §3 或 `MEMORY.md` 的 Rules，跨任务事实进 §7 或 Discovered durable knowledge，错误与修复进 §8，讨论得出的决定进 §10，引语与悬而未决的问题进 §11。连接串、端口、完整命令行这类精确值必须逐字照抄。§4 任务树只能来自 `task` 工具 `list` 的结果，不许出现工具没返回的任务 ID。
- **Turn 2**：在一条消息里并行发出全部编辑。每节只改斜体说明行下面的正文，不动标题与说明行；必要时往 `MEMORY.md` 追加条目；最后用 Write 把 `notes.md` 整个覆盖回模板。

关键约束里有两句值得看原文（`checkpoint-writer.txt:121`、`checkpoint-writer.txt:134`）：

> This is the anchor. Without verbatim, the next-cycle agent will lose the user's actual words and may drift.

> When unsure, default to KEEP. A stale §1 is recoverable; a wrong §1 erases user intent.

也就是说，§1 至少要有一段块引用的用户原话；用户最近一句是“实现、修复、运行”这类承诺型请求才更新 §1，“查找、解释、列出”这类查询型请求就保持原样，拿不准时保持。

子代理进展的对账由 `packages/opencode/src/session/checkpoint-progress-reconcile.ts` 预处理：扫描 `tasks/*/progress.md` 头部 frontmatter 里的 `written-at`，与主检查点 §4 行尾的 `last-reconciled-written-at` 标记比较，把新增（NEW）或有变化（CHANGED）的任务列成一个以 “SUBAGENT PROGRESS to integrate” 开头的块塞进提示词（`checkpoint-progress-reconcile.ts:83`）。写手要读这些文件，把其中的逐字命令并入主检查点 §5，结论与发现并入 §5 或 §7，再把 §4 那一行的标记更新为新的 `written-at`。没有这个块时，提示词要求写手不要自己去读任何 `progress.md`。子智能体结束前怎样被要求写这份文件，见[收件箱、团队与进度检查](https://daiw.org/manual/mimo-code/inbox-team)。

## 11 节模板与预算

模板定义在 `packages/opencode/src/session/checkpoint-templates.ts:1`，博客说的 11 个字段就是这 11 节。分节预算（`checkpoint-templates.ts:88`）会替换进提示词里的 `{{SECTION_BUDGETS}}`：

| 节 | 内容 | 预算（token） |
| --- | --- | --- |
| §1 Active intent | 用户最近的明确请求，块引用原话 | 500 |
| §2 Next concrete action | 下一步具体动作 | 1000 |
| §3 Directives (this session) | 只属于本会话的工作方式 | 800 |
| §4 Task tree | 任务树，来自 `task` 工具 | 1000 |
| §5 Current work | 检查点前正在做的事，写明路径与位置 | 2000 |
| §6 Files and code sections | 正在读改的文件，各附一句用途 | 1500 |
| §7 Discovered knowledge (cross-task) | 跨任务的发现，可晋升进项目记忆 | 2000 |
| §8 Errors and fixes | 错误与解决办法，新的在前 | 1500 |
| §9 Live resources | 分支、未提交文件、进程等运行时状态 | 1000 |
| §10 Design decisions and discussion outcomes | 讨论得出、没有立即落成代码的决定 | 3000 |
| §11 Open notes | 放不进前十节的零碎内容 | 800 |

各节预算加起来是 15100，文件里的注释却写着 “Total ~11K”（`checkpoint-templates.ts:86`）；而校验时整份文件的上限、重建时注入的上限都是 11000。某节快超预算时，提示词要求把一整个话题抽到同目录的 `checkpoint-<topic>.md`，原处留一行 `- See checkpoint-<topic>.md (N items) - <one-line summary>` 形式的索引。`MEMORY.md` 模板只有 Project context、Rules、Architecture decisions、Discovered durable knowledge 四节，预算合计 10000。

## 校验与反思

写手每次准备结束，都会触发 `actor.preStop` 钩子。内置插件 `CheckpointSplitoverPlugin` 只匹配 checkpoint-writer（`packages/opencode/src/plugin/checkpoint-splitover.ts:20`），读父会话的文件跑一遍校验，再决定是否让写手重来（`checkpoint-splitover.ts:35`）：

```ts
if (violations.length === 0) return

const extractRequired = violations.filter((v) => v.severity === "extract-required")
if (extractRequired.length > 0) {
  output.continue = true
  output.reason = buildExtractionReflection(extractRequired)
  return
}

const errors = violations.filter((v) => v.severity === "error")
if (errors.length > 0) {
  output.continue = true
  output.reason = buildReflectionMessage(errors, {
    checkpoint: checkpointPath(sessionID),
    memory: memoryPath(projectID),
  })
  return
}
```

违规分三级（校验入口在 `packages/opencode/src/session/checkpoint-retry.ts:44`）：

- **extract-required**：整份 `checkpoint.md` 超过 11000 token、`MEMORY.md` 超过 10000，或某一节超出分节预算。反思消息以 “EXTRACTION REQUIRED” 开头，要求把次要话题抽进溢出文件，原处留索引行。
- **error**：结构问题，如缺 `Topic:` 行、缺必备小节、Discovered 标题与已有检查点逐字重复。反思消息按文件列出问题，再附上两条绝对路径。
- **warn**：如 `Topic:` 超过 80 字符、Discovered 条目缺 `Why:` 或 `How to apply:`，只记录不拦截。

`continue: true` 会把理由作为新的一轮任务交给写手，重入上限 `MAX_PRE_REACT` 为 3（`spawn.ts:39`）。

这里新旧格式没有对齐。校验器检查的仍是旧结构（`packages/opencode/src/session/checkpoint-validator.ts:25`）：

```ts
export const TOPIC_MAX_CHARS = 80

export const SNAPSHOT_REQUIRED_SECTIONS = [
  "### Execution context",
  "### Live resources",
  "### Session metadata",
] as const

export const LEARNING_REQUIRED_SECTIONS = [
  "### Discovered",
  "### Dead ends",
] as const
```

它要求文件里有一行 `Topic:`，并包含上面这几个三级标题，配套测试里的“干净检查点”也是这种格式。现行模板与提示词却是 §1 到 §11，既没有 `Topic:` 行，也没有这些小节。按代码推断，照模板写出的文件会先被判出 `topic-missing` 与 `subsection-missing` 两类 error，钩子随即要求写手补上这些行，最多重入 3 轮；`renderIndex` 读不到 `Topic:` 时把主题显示为 `(unknown)`（`checkpoint.ts:1236`）。这一点没有实际运行验证。另外，`checkpoint-retry.ts` 里的 `quarantineCheckpoint` 与 `runTaskProgressValidators` 在源码中找不到调用方；`CheckpointContext` 的 `expectedRevisions` 登记时恒为空数组（`checkpoint.ts:1004`），针对 `MEMORY.md` 的指令修订检查因此不会触发。

## 安全边界

`docs/compose/spec/checkpoint-writer-safety.md` 记录了一次事故：一个已经写完的写手被通用的“空输出”提醒重新唤起，顺着父会话的开发任务改起了项目源码。规格里的修复在代码里都能对上：

1. **写入沙箱**。write、edit、apply_patch、notebook_edit 都经过统一闸门 `assertWriteAllowed`（`packages/opencode/src/tool/external-directory.ts:109`），其中 `assertAgentWriteSandbox` 对写手只放行记忆树（`packages/opencode/src/tool/memory-path-guard.ts:37`）：

```ts
if (input.agentName === "checkpoint-writer") {
  if (pathContains(memoryRoot, target)) return
  throw new Error(
    `Agent '${input.agentName}' may only write under the memory tree.\n` +
      `  memory: ${memoryRoot}\n` +
      `You attempted: ${input.target}.`,
  )
}
```

目标路径先经 `path.resolve` 折叠 `..`，借 `.mimocode/../src` 这类写法逃不出去。

2. **记忆树内的白名单**（`memory-path-guard.ts:79`）。写手只能写 `projects/<pid>/` 下的 `memory.md`（大小写不限）或 `memory-*.md`，`sessions/<sid>/` 下的 `checkpoint.md`、`notes.md` 与 `checkpoint-*.md`，以及 `sessions/<sid>/tasks/<TID>/*.md`（`TID` 形如 `T1`、`T1.2`）。白名单只看路径形状，不核对 `<sid>` 是不是父会话，写手在子会话里改父会话的文件也因此能通过。记忆树内的写入跳过 `edit` 权限询问（`external-directory.ts:162`），免得写手继承父会话的 `edit: ask` 后卡住。

3. **工具白名单与非交互**。spawn 时的 `tools` 挡住其他工具，写手拿不到 shell。系统派生的智能体遇到权限询问一律按非交互处理（`packages/opencode/src/agent/config.ts:69`），自动拒绝而不是挂起等人。

4. **专用的空输出提醒**。写手的回合以空输出或只有思考结束时，收到的不再是“给用户一个答复”，而是（`prompt.ts:3377`）：

```ts
const reminder =
  policy === "checkpoint"
    ? [
        "Your checkpoint writer turn ended without a completion signal.",
        "Do not answer or continue the parent session's task. Work only on the authorized checkpoint and memory paths already provided to you.",
        "If any authorized edits remain, finish them now. If they are complete, call no more tools and reply exactly CHECKPOINT_COMPLETE.",
      ]
```

策略由 `resolveInvalidOutputPolicy` 集中决定（`agent/config.ts:34`），这类续跑最多 `MIMOCODE_INVALID_OUTPUT_CONTINUATION_LIMIT` 次，默认 2（`packages/opencode/src/flag/flag.ts:178`）。

5. **失败就算失败**。回合以 assistant 错误收场时，Actor 把结果记为失败，而不是“成功但没有文本”（`spawn.ts:357`），结算 fiber 因此不会推进水位线。

## 等待与收尾

- `waitForWriterSettlement` 最多等 5 分钟（`checkpoint.ts:1137`），到点返回 `timeout` 而不是 `failure`：写手可能还在跑，仍可能成功并推进水位线。最后一档的恢复闸只在确认是可重试失败时才设。
- 重建时若写手在跑，`renderRebuildContext` 最多等 30 秒拿更新的检查点，超时就用磁盘上现有的（`checkpoint.ts:201`）。
- `drainWriters` 在进程退出前统一等所有写手，默认 120 秒（`checkpoint.ts:1170`）。

`writer.cache_perf` 的统计按写手的 actorID 在父会话的消息里累加（`checkpoint.ts:1078`），而写手的消息落在子会话里；按代码推断，这组数字可能一直是 0。

## 带 checkpoint 字样的工具说明

`packages/opencode/src/tool/actor.checkpoint.txt` 和 `memory.checkpoint.txt` 并不是写手专用的工具说明。`withCheckpointDescription` 把它们追加到 `actor`、`memory` 两个工具的说明后面，检查点被关闭时不追加（`packages/opencode/src/tool/checkpoint-description.ts:3`）。前者告诉模型 `context="state"` 会给子智能体注入检查点摘要，子智能体写进 `tasks/<TID>/progress.md` 的内容会被下一次检查点吸收；后者说明会话检查点也进了记忆检索的索引。写手在分叉模式下看到的就是父请求的完整工具表。

下一篇：[上下文重建与预算化注入](https://daiw.org/manual/mimo-code/context-rebuild)——检查点写好之后，越过触发线时怎样把它和记忆一起拼回请求。
