检查点、回退与分叉
Write 与 Edit 每次成功都留一份文件快照;回退对话只移动会话上的分支游标,不删消息;回退文件分“按检查点直接恢复”和“先预览、按哈希校验再应用”两条路;分叉把前缀复制进子会话,稳定分叉在回合结束时就钉好边界;旁支会话则是一个只继承上下文的隐藏分叉。
会话库只追加不删除,这一篇讲建在它上面的三件事:检查点(改文件前留一份快照)、回退(把对话或文件退回某个点)和分叉(从某个点复制出一个新会话)。协议与载荷定义在 apps/zcode-cli/packages/contracts/src/rewind/index.ts,实现集中在 apps/zcode-cli/packages/core/src/runtime/methods/ 下的 rewind.ts、rewind-message.ts、file-rewind.ts、workspace-checkpoints.ts、workspace-checkpoint-persistence.ts、workspace-fork.ts、session-fork.ts、stable-fork-boundary.ts 八个文件,共约 4400 行;落库部分见上一篇SQLite 会话库。
先说清一件容易误会的事:Agent 的检查点不是 Git 快照,而是工具层记下的“改动前后全文”。回退作用于三种范围:conversation、workspace、both(apps/zcode-cli/packages/contracts/src/rewind/index.ts:21)。
怎么用
TUI 里的 /rewind 与 /fork 不带参数时都先弹出最近 50 个检查点的选择列表(apps/zcode-cli/packages/cli/src/command-center/create.ts:229、create.ts:242);带参数的 /rewind 当作提示词交给运行时,在回合入口解析(apps/zcode-cli/packages/core/src/runtime/methods/turn.ts:106)。解析器认识这些写法(apps/zcode-cli/packages/core/src/runtime/helpers/commands.ts:17):
| 写法 | 效果 |
|---|---|
/rewind status(或 help、-h) | 显示最近一个检查点;TUI 里不带参数时改为弹列表 |
/rewind latest、/rewind <checkpointId> | 把该检查点记下的文件恢复到改动前 |
/rewind conversation <messageId>(或 message) | 回退对话到这条用户消息之前 |
/rewind code <messageId>(或 workspace) | 恢复与这条消息相关的最近一个检查点 |
/rewind both <messageId> | 先恢复文件,成功后再回退对话 |
/rewind cascade <范围> <messageId> | 从这条消息往后的所有检查点倒序恢复;范围含对话时再回退对话 |
/fork、/fork latest、/fork <checkpointId> | 从检查点分叉出新会话;TUI 会随即切到新会话(apps/zcode-cli/packages/cli/src/tui-prompt-handler.ts:239) |
桌面与 Web 端不走斜杠命令,而是 ZCode Protocol V4 的命令(见ZCode Protocol V4):消息上的“编辑”按钮发 editUserQuery,重试发 retryTurn,“分叉”按钮发 forkAssistant(packages/ui/src/i18n/locales/zh-CN.ts:3977、zh-CN.ts:3986),本轮文件改动摘要上的“撤销”发 applyFileRewind(zh-CN.ts:1540);选中回复文字后点“在辅助对话中提问”,或输入 /side <问题>、/btw <问题>,创建旁支会话(zh-CN.ts:645、packages/ui/src/v4/slashCommands.ts:110、packages/ui/src/v4/MarkdownSelectionTooltip.tsx:104)。配置里有个 features.rewind,默认 true(apps/zcode-cli/packages/contracts/src/config/index.ts:310),但全仓库没有任何代码读取它,从代码看设成 false 也关不掉这些功能。
检查点:每次成功的 Write 或 Edit 记一份
检查点不是按回合或定时拍的。每个工具结果闭合后,回合循环都会调一次 emitFileMutationCheckpoint,把本回合用户消息的 ID 当作锚点传进去(apps/zcode-cli/packages/core/src/runtime/methods/turn-tools.ts:370);只有输出里同时带 filePath、structuredPatch 和 originalFile 的成功结果才算数(apps/zcode-cli/packages/core/src/runtime/helpers/rewind.ts:38),目前产出这种结构的只有 Write 与 Edit 两个工具,新建文件的 originalFile 为 null(apps/zcode-cli/packages/core/src/tool/handlers/write.ts:177)。落盘的部分(apps/zcode-cli/packages/core/src/runtime/methods/tools.ts:187):
const artifact = await this.artifactStore.writeToolResultArtifact(
{
sessionId: this.sessionId,
turnId: options.traceContext.turnId,
toolCallId: options.result.toolCallId,
toolName: options.result.toolName,
content: stringifyWorkspaceCheckpointArtifact(candidate, options.result),
contentType: WORKSPACE_CHECKPOINT_CONTENT_TYPE,
retention: "session",
trace: options.traceContext,
},
{ signal: options.abortSignal },
);
throwIfTurnAborted(options.abortSignal);
const event = this.createEvent(
SessionEventType.CheckpointCreated,
{
checkpointId: `checkpoint_${crypto.randomUUID()}`,
messageId: options.messageId,
targetMessageId: options.messageId,
toolMessageId: options.toolMessageId,
scope: RewindScope.Workspace,
snapshotRef: artifact.uri,
diffRef: artifact.uri,
fileCount: 1,
},一个检查点只管一个文件。快照是一份 workspace_file_before_change JSON,存改动前全文、改动后全文、是否原本存在和结构化补丁(helpers/rewind.ts:58,schema 见 rewind/index.ts:100),写在 ~/.zcode/cli/artifacts/<会话 ID>/ 下,以 zcode-artifact:// 地址引用(apps/zcode-cli/packages/bootstrap/src/app/create-app.ts:354、apps/zcode-cli/packages/adapters/src/storage/index.ts:71)。写快照失败只记警告,不影响工具结果(tools.ts:240)。
事件流在内存里,进程一退,CheckpointCreated 就没了。所以事件管线把它另存一份到 session_entry,类型是 runtime/workspace_checkpoint(apps/zcode-cli/packages/core/src/runtime/methods/events.ts:270),注释说只留 artifact 不留关联载荷的话,冷恢复后预览与应用都找不到快照(apps/zcode-cli/packages/core/src/runtime/methods/workspace-checkpoint-persistence.ts:42)。恢复会话时这些条目按原序号回放进内存事件流(workspace-checkpoint-persistence.ts:101,调用在 apps/zcode-cli/packages/core/src/runtime/methods/resume.ts:191)。同一次写入还会记进本回合的文件变更表,回合最后一次模型调用把它汇总成增删行数挂在 ModelComplete 事件上,桌面端“本轮改动”和撤销入口就来自这里(apps/zcode-cli/packages/core/src/runtime/helpers/turn-file-changes.ts:7、apps/zcode-cli/packages/core/src/runtime/methods/turn-model-step.ts:593)。
这套机制有几个边界:
- Bash 改的文件没有检查点。Bash 的输出里没有那三个字段,不会产生快照。
- 快照不会被清理。写入时传了
retention: "session",但 Node 实现NodeToolArtifactStore从头到尾没读这个字段,也没有找到清理cli/artifacts目录的代码;桌面资源管理器还把它归为不可清理的工具输出(packages/services/src/storage/domain/storageCatalog.ts:29、storageCatalog.ts:80)。 - 桌面端另有一套 Git 检查点。
packages/services里有个IGitCheckpointService,用临时GIT_INDEX_FILE把工作区状态做成隐藏提交,挂在refs/zcode/checkpoints/...下,不碰用户的 index 与分支(packages/services/src/git/repo/gitCheckpointRepo.ts:240、packages/services/src/git/repo/gitCheckpointHelpers.ts:28)。它作为 RPC 服务注册了(packages/services/src/git/gitCheckpoint.ts:20),但本仓库的界面代码里没有找到调用它创建检查点的地方,和上面的 Agent 检查点也不相通。
NOTICE 把话说在前面(NOTICE.md:15):
任务快照、Git 检查点和会话恢复不能替代对全部本地文件、数据库及外部服务的备份。
回退对话:只移游标,不删消息
消息与 part 表只追加。回退对话不删任何一行,只在会话行的 revert 列写一个游标(apps/zcode-cli/packages/core/src/runtime/methods/rewind-message.ts:566):
await this.sessionStore!.setRevert({
sessionID: this.sessionId,
revert: {
// 只保存 target/created 无法在连续编辑或重启后还原旧 rewind 前缀。
// keptMessageIDs 保存的是本次 rewind 前 active branch 的保留前缀,避免旧分支重新浮出。
keptMessageIDs: keptMessages.map((message) => message.info.id as MessageId),
branchCutAfterMessageID: branchCutAfterMessageId,
branchGeneration,
messageID: keptMessages.at(-1)?.info.id ?? options.targetMessageId,
kind: "conversation_rewind",
scope: RewindScope.Conversation,
targetMessageID: options.targetMessageId,
},
});keptMessageIDs 是目标之前要保留的前缀,branchCutAfterMessageID 取回退这一刻库里最后一条消息(rewind-message.ts:528)。之后读历史时,活跃分支等于“保留的前缀”加上“游标之后新追加的消息”,中间那段旧分支还在库里,只是不再被选中(rewind/index.ts:193)。恢复、冷启动投影和分叉复制共用这个裁剪函数(rewind/index.ts:175)。
branchGeneration 每回退一次加一(rewind-message.ts:529),同步给后台任务注册表,旧分支上后台任务的迟到结果据此作废(rewind-message.ts:581,见后台任务与通知)。提交游标之前,还要先停掉被砍掉的那几个回合里仍在运行的后台任务,停不掉就整体失败,“不提交 branch cut,也不碰 workspace”(rewind-message.ts:646)。游标写好后,内存里的模型历史、读文件状态、上下文前缀、缓存统计、回合计数、本回合文件变更表全部按新分支重建(rewind-message.ts:675)。
目标必须是一条用户消息(摘要消息不算),也就是把这次提问连同之后的回复一起撤掉。重试传来的是助手消息的 ID,纯对话范围(包括 TUI 的 /rewind conversation)会往前找到所属回合的用户消息(rewind-message.ts:476、rewind-message.ts:418);both 与 cascade 不做这种映射,目标不是用户消息就直接拒绝。对话回退失败时不发 RewindTriggered 事件,免得界面先裁掉而库里仍是旧分支(rewind-message.ts:663);文件回退失败则照发一条策略为 unavailable 的事件(apps/zcode-cli/packages/core/src/runtime/methods/rewind.ts:359)。
能不能回退由 evaluateRewindTarget 裁定(rewind/index.ts:229)。消息被压缩覆盖时,对话回退照样可行:先剪分支,再在新分支上重建压缩范围(rewind/index.ts:316):
| 策略 | 何时 | 可用范围 |
|---|---|---|
active_chain | 目标在活跃链上,或被压缩覆盖但回退的是对话 | 有检查点时三种都行,否则只有 conversation |
file_only | 目标被压缩覆盖、只回退文件、且有检查点 | 只恢复文件,对话停在压缩后的上下文 |
unavailable | 找不到目标,或要回退文件却没有检查点 | 无 |
契约里还有第四种 fork_required(rewind/index.ts:32),evaluateRewindTarget 从不返回它,只作为 SessionForked 事件上的策略标记出现。
回退文件:两条路
按检查点直接恢复。/rewind <checkpointId>、/rewind code <messageId> 走 rewindWorkspaceToCheckpoint(apps/zcode-cli/packages/core/src/runtime/methods/rewind.ts:202):读出快照,把文件写回改动前的内容,原本不存在的文件直接删掉(apps/zcode-cli/packages/core/src/runtime/methods/workspace-fork.ts:47),再写一条合成的用户提示并作为 rewind_notice 附件告诉模型(methods/rewind.ts:308)。这条路不检查文件在快照之后有没有被改过。另外要注意“一个检查点一个文件”:/rewind <id> 只恢复那一个文件,之后别的文件上的改动原样保留。要撤掉某条消息以来的全部改动得用 cascade,它先把所有快照都读出来,任何一份缺失就整体放弃,再按创建顺序倒着写,避免停在半回退状态(rewind-message.ts:310)。
先预览、再应用。桌面端的撤销用 previewWorkspaceFileRewind 与 applyWorkspaceFileRewind(apps/zcode-cli/packages/core/src/runtime/methods/file-rewind.ts:79、file-rewind.ts:94),按消息 ID 或回合找出相关检查点,先在内存里从新到旧“模拟”一遍(file-rewind.ts:406):
冲突的判定就是一次哈希比较(file-rewind.ts:417):
const currentState =
simulatedByPath.get(operation.path) ??
(await readCurrentFileState.call(this, operation.path, traceContext, options.abortSignal));
if ("reason" in currentState) {
markUnsafe(unsafeByPath, {
action: operation.action,
message: currentState.message,
path: operation.path,
reason: currentState.reason,
toolName: operation.toolName,
});
continue;
}
const expectedHash = hashContent(operation.afterContent);
if (currentState.hash !== expectedHash) {
markUnsafe(unsafeByPath, {
action: operation.action,
currentHash: currentState.hash ?? "missing",
expectedHash,
path: operation.path,
reason: "external_modified",
toolName: operation.toolName,
});
continue;
}同一个文件被改过多次时,后一次模拟的“当前内容”取前一次模拟写回的结果,所以多次改动能层层剥回去。快照里没存改后全文的旧数据,用结构化补丁以零模糊度重放出来(file-rewind.ts:543)。只要有一个文件不安全,canApply 就是假,整批不动(file-rewind.ts:468);不安全的原因有 checkpoint_unreadable、external_modified、file_read_failed、unsupported_checkpoint,类型里还有一个 checkpoint_missing 但 core 从不产生(apps/zcode-cli/packages/core/src/runtime/types.ts:566)。应用阶段每写一个文件前先读下当前内容记进 journal,第 N 个写失败就把前面的倒序恢复,补偿也失败才升级为不可恢复的错误(file-rewind.ts:184)。成功后发一条原因为 file_summary_rewind 的 RewindTriggered,它同样另存为 runtime/workspace_file_rewind 条目,重启后撤销按钮不会复活(workspace-checkpoint-persistence.ts:80)。
编辑重发与重试
两条回退可以组合。桌面端编辑一条用户消息时,editUserQuery 的 workspaceMode 缺省是 preserve,只回退对话;选 rewind 就先预览该回合的文件撤销,有不安全文件、有 Bash 一类改动或者压根没有可撤销文件,都以 guard.workspaceRewind* 拒绝并把预览交回界面;通过后应用文件撤销,在 commitAfterApply 这道提交闸里才写对话游标,最后用新文本重新起一个回合(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/commands/handlers/fork-edit-retry.ts:150)。只有最后一轮的用户提问能编辑、最后一轮的回复能重试(fork-edit-retry.ts:68、fork-edit-retry.ts:78)。重试等于回退对话加重发原输入,原输入要在截断之前解析好,截断后就拿不到了(fork-edit-retry.ts:241)。注释还记录了一个踩过的坑:组合回退曾在文件事务回调里再提交一条 /rewind 提示词,嵌套回退等着当前命令释放队列,界面永远停在编辑态,所以现在直接调用 core 的回退原语(fork-edit-retry.ts:88)。
分叉
分叉从父会话的某个点复制出一个子会话。子会话的 parent_id 指向父会话,trace_id 沿用父会话的根 trace,task_type 为 fork,标题是 Fork of 加父标题,工作目录与父会话相同(apps/zcode-cli/packages/core/src/runtime/methods/session-fork.ts:147)。按入口分三条路:
| 入口 | 实现 | 动不动文件 | 提交方式 |
|---|---|---|---|
TUI /fork、协议旧命令 session/fork(packages/shared/src/zcode-protocol/index.ts:3585) | forkWorkspaceFromCheckpoint(apps/zcode-cli/packages/core/src/runtime/methods/workspace-checkpoints.ts:133) | 恢复检查点,改的是共享工作目录 | 先建会话再逐条复制消息 |
桌面端回合末尾的分叉 forkAssistant | forkStableConversationAtMessage(session-fork.ts:1049) | 不动 | 一个 commitForkBundle 事务 |
旁支会话 createSelectionSideSession | createSelectionSideConversation(session-fork.ts:804) | 不动 | 同上 |
第一条路需要格外小心:子会话与父会话共用一个工作目录,恢复文件就是改父会话的文件。代码为按消息分叉专门改过语义(workspace-checkpoints.ts:145):
// message 目标的 fork 语义是"历史包含目标回合,工作区停在 fork 点时刻"。
// 恢复目标回合自身 checkpoint 的 beforeContent 会把该回合刚产出的文件回退/删除——
// fork 与父会话共用工作区目录,父会话的产物也随之丢失(在最新回复上分叉时最明显)。
// message fork 只撤销 fork 点之后的 checkpoint;显式 checkpoint 目标(/fork latest、
// targetCheckpointId)仍保留"回到该 checkpoint 修改前"的 rewind 式语义。按消息分叉(协议旧命令给的是消息目标时)只撤销分叉点之后的检查点,同一文件取分叉点后第一次改动前的内容;分叉点之后没有改动就退化成纯对话分叉(workspace-fork.ts:149、workspace-fork.ts:188)。显式指定检查点(包括 TUI 的 /fork 与 /fork latest)则仍是“回到这个检查点改动之前”,父会话里那个文件也会被写回。反过来,TUI 的 /fork 只认检查点,会话里还没改过任何文件时直接报 No workspace checkpoint is available yet.(workspace-checkpoints.ts:162)。协议旧命令要求父会话当时没有运行中的回合(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/server-operations.ts:2231)。
稳定分叉边界。桌面端的分叉按钮只挂在一轮最后一段完成的助手回复上(fork-edit-retry.ts:46)。为了分叉时不必再猜“这一轮到哪条消息为止”,回合结束、TurnComplete 对订阅者可见之前,运行时先把边界写进最终那条助手消息的锚点(apps/zcode-cli/packages/core/src/runtime/methods/stable-fork-boundary.ts:83):
const anchor: MessageProjectionAnchor = {
...boundary.info.anchor,
...(input.traceContext.turnId ? { turnId: input.traceContext.turnId } : {}),
historyRoundCount: input.historyRoundCount,
orderedMessageIds,
boundaryMessageId: input.boundaryMessageId,
goalBoundary,
};
await store.saveMessage({ ...boundary.info, anchor });orderedMessageIds 是这一轮从起点到边界的连续消息,goalBoundary 是此刻目标的快照(清掉只属于父会话的活跃运行字段)加上边界之前的完成验证记录 ID(stable-fork-boundary.ts:94、stable-fork-boundary.ts:106)。回合循环先结算目标用量再固定边界,两者都落库后 TurnComplete 才开放分叉(turn.ts:623)。分叉时要求这段 ID 在活跃分支里严格连续,终点必须是一条没出错的助手消息(session-fork.ts:1132)。父会话此时可以正在跑下一轮,分叉不会打断它(fork-edit-retry.ts:275)。
一个事务提交。稳定分叉与旁支会话把要写的东西都在内存里算好:所有消息、part、回合、目标与验证记录都换成子会话本地的新 ID,再整包交给 commitForkBundle(session-fork.ts:642、session-fork.ts:737)。存储层在一个 begin immediate 事务里写完,写之前逐项核对包里每个引用都指向子会话自己(apps/zcode-cli/packages/adapters/src/storage/session-store/sqlite-session-store.ts:115、sqlite-session-store.ts:384);父会话里的命令事实行保证重放同一条命令只会得到同一个子会话。复制与不复制的内容:
| 复制进子会话 | 不复制 |
|---|---|
边界之前的活跃分支消息与 part(ID 重映射,元数据记 forkOrigin) | 被回退掉的旧分支 |
| 模型选择与执行状态两条条目;执行状态取复制范围内最后一条助手消息上记录的,而不是父会话当前的 | Todo 列表、排队中的输入 |
| 目标快照与边界前的验证记录(旁支会话除外) | 目标的活跃运行字段 |
一对分叉提示:一条只给模型的隐藏用户消息,一条界面上的分隔时间线(session-fork.ts:377) | 检查点记录,文件也不动;父会话的内存事件 |
执行状态的取法见 session-fork.ts:599,目标运行字段清空见 stable-fork-boundary.ts:94。另一条“压缩覆盖的编辑改为分叉”路径 forkConversationBeforeMessage 仍在 core 里(session-fork.ts:1080),但宿主接口上它已标注“新 editUserQuery 永不调用”(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/commands/types.ts:261)。
把回退和分叉放在同一张图里:
旁支会话
选中一段回复问个题外话,又不想打扰正在干活的主会话,就开一个旁支会话(selection side chat,界面上叫“辅助对话”,zh-CN.ts:610)。它是一次特殊的分叉:task_type 为 selection_side_chat,标题固定为 Selection side chat(session-fork.ts:165)。复制父会话的活跃分支,如果父会话正在跑,只取到本轮真实用户输入为止,不带正在生成的回复与工具调用(session-fork.ts:833);复制来的消息全部标成只给模型看、界面隐藏,副屏从空白开始(session-fork.ts:677);不复制目标与验证记录,连消息锚点上的目标边界也剥掉(session-fork.ts:607)。末尾追加一条隐藏的系统提醒(session-fork.ts:499):
const SELECTION_SIDE_CHAT_BOUNDARY = [
"The preceding conversation was inherited from the parent task for reference only.",
"Do not continue the parent's active work automatically; answer only new questions sent in this side chat.",
"Modify the workspace only when the user explicitly asks you to do so in this side chat.",
].join(" ");注意它和父会话同样共用工作目录,这段提醒只是请模型别主动改文件,并不是隔离。TUI 没有这个入口,只有桌面与 Web 端的界面调用它。
还有一个名字容易混淆的文件:embedded-search-branch.ts 跟对话分支无关,它决定 Bash 可用时是否隐藏 Glob 与 Grep、改由 Bash 里的搜索前置脚本承担检索(apps/zcode-cli/packages/core/src/runtime/methods/embedded-search-branch.ts:21),属于读、写、改、搜的范畴。
下一篇:遥测、调试与提示词轨迹——traceId 怎样一层层往下传,OpenTelemetry 默认为什么是关的,模型请求又在本地留下了哪些记录。