检查点、回退与分叉

Write 与 Edit 每次成功都留一份文件快照;回退对话只移动会话上的分支游标,不删消息;回退文件分“按检查点直接恢复”和“先预览、按哈希校验再应用”两条路;分叉把前缀复制进子会话,稳定分叉在回合结束时就钉好边界;旁支会话则是一个只继承上下文的隐藏分叉。

作者 David更新于 39 篇(共 47 篇)

会话库只追加不删除,这一篇讲建在它上面的三件事:检查点(改文件前留一份快照)、回退(把对话或文件退回某个点)和分叉(从某个点复制出一个新会话)。协议与载荷定义在 apps/zcode-cli/packages/contracts/src/rewind/index.ts,实现集中在 apps/zcode-cli/packages/core/src/runtime/methods/ 下的 rewind.tsrewind-message.tsfile-rewind.tsworkspace-checkpoints.tsworkspace-checkpoint-persistence.tsworkspace-fork.tssession-fork.tsstable-fork-boundary.ts 八个文件,共约 4400 行;落库部分见上一篇SQLite 会话库

先说清一件容易误会的事:Agent 的检查点不是 Git 快照,而是工具层记下的“改动前后全文”。回退作用于三种范围:conversationworkspacebothapps/zcode-cli/packages/contracts/src/rewind/index.ts:21)。

怎么用

TUI 里的 /rewind/fork 不带参数时都先弹出最近 50 个检查点的选择列表(apps/zcode-cli/packages/cli/src/command-center/create.ts:229create.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,“分叉”按钮发 forkAssistantpackages/ui/src/i18n/locales/zh-CN.ts:3977zh-CN.ts:3986),本轮文件改动摘要上的“撤销”发 applyFileRewindzh-CN.ts:1540);选中回复文字后点“在辅助对话中提问”,或输入 /side <问题>/btw <问题>,创建旁支会话(zh-CN.ts:645packages/ui/src/v4/slashCommands.ts:110packages/ui/src/v4/MarkdownSelectionTooltip.tsx:104)。配置里有个 features.rewind,默认 trueapps/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);只有输出里同时带 filePathstructuredPatchoriginalFile 的成功结果才算数(apps/zcode-cli/packages/core/src/runtime/helpers/rewind.ts:38),目前产出这种结构的只有 Write 与 Edit 两个工具,新建文件的 originalFilenullapps/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:354apps/zcode-cli/packages/adapters/src/storage/index.ts:71)。写快照失败只记警告,不影响工具结果(tools.ts:240)。

事件流在内存里,进程一退,CheckpointCreated 就没了。所以事件管线把它另存一份到 session_entry,类型是 runtime/workspace_checkpointapps/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:7apps/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:29storageCatalog.ts:80)。
  • 桌面端另有一套 Git 检查点packages/services 里有个 IGitCheckpointService,用临时 GIT_INDEX_FILE 把工作区状态做成隐藏提交,挂在 refs/zcode/checkpoints/... 下,不碰用户的 index 与分支(packages/services/src/git/repo/gitCheckpointRepo.ts:240packages/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:476rewind-message.ts:418);bothcascade 不做这种映射,目标不是用户消息就直接拒绝。对话回退失败时不发 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_requiredrewind/index.ts:32),evaluateRewindTarget 从不返回它,只作为 SessionForked 事件上的策略标记出现。

回退文件:两条路

按检查点直接恢复/rewind <checkpointId>/rewind code <messageId>rewindWorkspaceToCheckpointapps/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)。

先预览、再应用。桌面端的撤销用 previewWorkspaceFileRewindapplyWorkspaceFileRewindapps/zcode-cli/packages/core/src/runtime/methods/file-rewind.ts:79file-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_unreadableexternal_modifiedfile_read_failedunsupported_checkpoint,类型里还有一个 checkpoint_missing 但 core 从不产生(apps/zcode-cli/packages/core/src/runtime/types.ts:566)。应用阶段每写一个文件前先读下当前内容记进 journal,第 N 个写失败就把前面的倒序恢复,补偿也失败才升级为不可恢复的错误(file-rewind.ts:184)。成功后发一条原因为 file_summary_rewindRewindTriggered,它同样另存为 runtime/workspace_file_rewind 条目,重启后撤销按钮不会复活(workspace-checkpoint-persistence.ts:80)。

编辑重发与重试

两条回退可以组合。桌面端编辑一条用户消息时,editUserQueryworkspaceMode 缺省是 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:68fork-edit-retry.ts:78)。重试等于回退对话加重发原输入,原输入要在截断之前解析好,截断后就拿不到了(fork-edit-retry.ts:241)。注释还记录了一个踩过的坑:组合回退曾在文件事务回调里再提交一条 /rewind 提示词,嵌套回退等着当前命令释放队列,界面永远停在编辑态,所以现在直接调用 core 的回退原语(fork-edit-retry.ts:88)。

分叉

分叉从父会话的某个点复制出一个子会话。子会话的 parent_id 指向父会话,trace_id 沿用父会话的根 trace,task_typefork,标题是 Fork of 加父标题,工作目录与父会话相同(apps/zcode-cli/packages/core/src/runtime/methods/session-fork.ts:147)。按入口分三条路:

入口实现动不动文件提交方式
TUI /fork、协议旧命令 session/forkpackages/shared/src/zcode-protocol/index.ts:3585forkWorkspaceFromCheckpointapps/zcode-cli/packages/core/src/runtime/methods/workspace-checkpoints.ts:133恢复检查点,改的是共享工作目录先建会话再逐条复制消息
桌面端回合末尾的分叉 forkAssistantforkStableConversationAtMessagesession-fork.ts:1049不动一个 commitForkBundle 事务
旁支会话 createSelectionSideSessioncreateSelectionSideConversationsession-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:149workspace-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:94stable-fork-boundary.ts:106)。回合循环先结算目标用量再固定边界,两者都落库后 TurnComplete 才开放分叉(turn.ts:623)。分叉时要求这段 ID 在活跃分支里严格连续,终点必须是一条没出错的助手消息(session-fork.ts:1132)。父会话此时可以正在跑下一轮,分叉不会打断它(fork-edit-retry.ts:275)。

一个事务提交。稳定分叉与旁支会话把要写的东西都在内存里算好:所有消息、part、回合、目标与验证记录都换成子会话本地的新 ID,再整包交给 commitForkBundlesession-fork.ts:642session-fork.ts:737)。存储层在一个 begin immediate 事务里写完,写之前逐项核对包里每个引用都指向子会话自己(apps/zcode-cli/packages/adapters/src/storage/session-store/sqlite-session-store.ts:115sqlite-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_typeselection_side_chat,标题固定为 Selection side chatsession-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 默认为什么是关的,模型请求又在本地留下了哪些记录。

本页目录