本文涉及的 pi-rust 项目地址:github.com/bigfish1913...。
如果准备给一个已经能调用模型、执行工具的 Agent 加上 Durable Execution,我们可以先把问题缩小:进程重启后,怎样知道下一步该做什么?
拿修改文件来说,最难判断的并不是"聊天记录还在不在",而是文件可能已经改了,Agent 却没来得及保存结果。重新执行,可能多改一次;直接跳过,又没有依据确认它做完了。恢复程序必须面对这个两难,不能把决定留给一句笼统的"继续运行"。
结果已经保存、只差下一次模型请求,是另一种情况。等待重试时退出,也不能照着普通输出中断来处理。接下来,我们沿着同一个任务,一步步看这些差别怎样变成记录,又怎样帮助新进程作出决定。先理解每条记录解决什么问题,再看对应的字段和函数,不必一开始就记住整条调用链。
具体实现以 pi-rust 的 Rust Agent 运行时 rpi 为例,核查版本为 0.15.0,提交为 fd47e29cb98328b55942cd8d86417a954432c1f1。
本文讲的是这个版本的恢复策略,不是所有 Durable Execution 系统的共同实现,也不承诺任意中断点都能无损接管。
先把职责摆清楚:谁保存,谁决定,谁执行
贯穿全文的任务仍然很小:
给
CHANGELOG.md追加一行"修复登录超时",运行pytest -q,然后汇报结果。
假设模型先提出一个 bash 调用来追加文件。为便于对照,下文把它称为 call-9。任务、调用编号和记录快照都是教学示意,不是真实模型完成该任务的实测轨迹。
从模型提出 call-9 到文件真正改动,会经过几个不同的参与者。进程重启后,也不是由保存记录的组件独自决定是否继续:
- 宿主是使用运行时的应用,例如 CLI。它打开会话、构造执行器,并决定什么时候调用续接入口。
- Harness 与 Agent Loop负责组织模型输入、驱动循环、调用工具、保存结果。Harness 还负责中断后的检查与修复。
- Session 存储提供消息、运行记录和进度的读写能力。记录放在这里,不代表它会自己安排任务。
- 模型 生成回复或提出工具调用;工具才执行命令、修改文件或访问外部系统。
看下面这张图时,可以先沿着宿主找到执行器,再看执行器通向 Session 和工具的两条线。Session 能告诉它过去留下了什么,工具才会真正改变文件。把这两个动作分开,后面"文件可能已改、记录却没保存"的窗口就有了位置。

图:依据固定版本源码整理的逻辑职责,不表示这些组件是独立部署的服务。请求与返回在总览中合并表示,具体顺序见后面的时序图。点击图片可放大。交互版:assets.bigfisharc.cn/diagrams/du...。
这也限定了本文中"恢复"的含义:新执行器读取旧记录,修复上下文,再发起新的调用。旧进程的 Rust 调用栈、HTTP 连接和内存变量不会复活。
记录设计的第一步:把"对话内容"和"执行依据"分开
如果只看模型输入,这个任务是一串消息:
plain
用户:追加记录,然后测试、汇报
assistant:提出 call-9,调用 bash
tool:call-9 已完成
assistant:提出下一次测试调用
......
但把这段消息交给重启后的程序,还不足以决定下一步。比如,历史里的一次"已完成"究竟是不是在回答这次追加?没有结果,是工具还没开始,还是执行后没来得及保存?执行器还需要知道运行是否开始、属于哪个分支、调用是否进入过执行阶段,以及当时是否允许重跑。
回到 call-9,模型提出的调用和工具返回的结果,下一轮还要用到。rpi 把这两条消息各自保存为一个 Entry,也就是会话树上的条目。消息条目沿父子关系连成当前分支,供执行器选取消息、组织下一次模型请求。除了消息,压缩和分支摘要也有对应的 Entry。
至于"运行是否开始""调用走到了哪一步",则要查另外的执行记录。rpi 把它们称为 Record ,例如 operation_started、tool_started、retry_pending。这些记录供执行器判断下一步,不会因为写入 Session 就自动成为发给模型的消息。
这是使用方式的区分,不要求建立两套数据库。rpi 的存储接口可以承载两者。
消息里面究竟装着什么
前面的 assistant:提出 call-9,实际是一条模型回复。模型可以先说"先追加记录",再在同一条回复里给出工具名、参数和调用编号。文字和工具请求都放在 AssistantMessage.content 这个内容块列表中,工具请求对应其中的 ToolCall。它不是独立的一条用户消息;同一条回复也可以包含多个调用。
保存这条回复时,外层的 MessageEntry 负责条目身份,里面的 AgentMessage 区分消息种类。图中把用户消息、模型回复和工具结果展开,可以对照它们各自包含哪些字段;应用自定义的 Custom 分支暂时不展开。

图:内部消息类型与关键字段摘选,按固定版本源码整理,不是完整类型定义。字段右侧混合了含义说明与本例示意值;组合连线表示包含,关联箭头表示分支或调用配对,不是执行时序。点击图片可放大。交互版:assets.bigfisharc.cn/diagrams/du...。
等工具返回,执行器另存一条 ToolResultMessage。它的 toolCallId 等于原调用的 id。这样,即使同一条回复连续调用两次 bash,也能知道每条结果回答的是哪一次。
把本例中的调用和结果各展开一层,就能看到配对关系:
json
[
{
"type": "message",
"id": "entry-a2",
"message": {
"kind": "assistant",
"role": "assistant",
"content": [
{ "type": "text", "text": "先追加记录。" },
{
"type": "toolCall",
"id": "call-9",
"name": "bash",
"arguments": { "command": "......追加文件的命令......" }
}
]
}
},
{
"type": "message",
"id": "entry-t3",
"message": {
"kind": "tool_result",
"role": "toolResult",
"toolCallId": "call-9",
"toolName": "bash",
"content": [{ "type": "text", "text": "命令执行完成。" }],
"isError": false
}
}
]
这是按内部序列化形状摘选的教学片段,省略了父节点、顺序、时间戳、模型身份和用量等字段,也没有展示外层日志封装。命令和输出均为示意,不是可直接导入的完整记录。
这段 JSON 同时出现了 type、kind 和 role,它们描述的对象不同。外层 type: message 说明它是消息条目,kind 区分 AgentMessage 分支,role 属于里面的具体消息。工具结果的 kind 是 tool_result,role 则是 toolResult,不能因为意思相近就混用。发给不同 Provider 时还要转换格式,不能把这段内部结构直接当成模型 API 的请求体。
再看 entry-a2、entry-t3 和 call-9:前两个标识会话树上的条目,后一个标识调用本身。tool_started 不在这些消息的 content 中。这条控制记录用 assistantEntryId 指回 entry-a2,用 resultEntryId 预留 entry-t3。它的 toolCallId 则标明这次记录的是 call-9。
为什么要多留这一层?调用消息能说明模型要求做什么,结果消息能说明保存了什么答复。两者之间还需要执行记录,才能留下调用走到哪一步、当时采用什么重跑策略。下面把它们放进同一份快照,再看它们如何共同支持恢复。
先看一份快照,再拆开字段
下面沿默认的 main 分支查看记录。rpi 用 lane 标识这样的命名执行分支,恢复时也要确定沿哪个分支继续。本文不展开多分支调度。
假设追加结果已经保存,下一次模型请求还没发出,留下的关键数据可以整理成这样:
plain
当前 lane:main
运行记录
operation_started
id: op-17
sourceLeafId: entry-before
intent: run
消息条目
entry-u1:用户任务
entry-a2:assistant 提出 call-9
调用记录
tool_started
runId: op-17
assistantEntryId: entry-a2
toolIndex: 0
toolCallId: call-9
toolName: bash
effectiveArgs: { command: "......追加文件的命令......" }
resultEntryId: entry-t3
replay: never
消息条目
entry-t3:tool result,回答 call-9
没有对应的 operation_finished(op-17)
这是对真实字段的摘选展示,省略了公共元数据,不是完整可导入的 JSONL。
字段先不用逐个记。我们先在快照里连起三个位置:op-17 标识这次运行,entry-a2 留下模型提出的 call-9,entry-t3 保存它的结果。再看末尾:旧运行没有对应的结束记录。接手程序要先看清这些关系,才能讨论下一步,而不是在聊天文本里猜"应该做到哪儿了"。
下面再把这些编号分开,看看每多记一项,究竟少了哪一种猜测:
| 字段或身份 | 回答的问题 | 为什么不能省掉 |
|---|---|---|
| Session ID | 属于哪段长期会话? | 同一会话会接受多次任务,不能拿会话存在代替某次任务仍在执行 |
| lane 与当前叶节点 | 这次执行沿哪个分支继续? | 同一会话可能有分支,不能把所有条目无条件拼成模型历史 |
operation_started.id / runId |
属于哪次具体操作? | 开始与结束要配对,工具记录也要归属到对应运行 |
assistantEntryId + toolIndex |
哪条模型消息中的第几个工具调用? | 一条消息可以提出多个调用,工具名本身不能唯一定位一次执行 |
toolCallId |
哪条结果回答哪次调用? | 历史里出现过"成功"不代表本次调用成功 |
resultEntryId |
预期结果将写成哪个条目? | 结果是否落地可以按身份检查,不依赖文字内容相似 |
公共 seq |
记录提交的先后顺序是什么? | 时间戳不能替代明确的日志顺序,重试判定也需要比较先后 |
字段定义见 session/types.rs,调用记录对应 ToolStartedRecord。
编号记下来之后,还要防止它们彼此矛盾。日志校验器会沿 assistantEntryId 找到消息,核对对应序号的调用 ID 与工具名。它还会检查同一个调用位置是否出现重复的 tool_started,以及已有结果条目是否回答该调用。
具体规则在 session/reducer.rs 的 validate_tool_start。这不表示每条业务路径都会重新跑一遍完整的日志校验。
因此,"有日志"还不够。日志之间必须能互相对上,恢复程序才有可用依据。
正常路径:按什么顺序把执行变成记录
有了这份快照,我们先往回走,看它是怎样一步步写出来的。每到一个提交点,都带着同一个问题:如果现在退出,下次还能知道什么?这样再看恢复路径,就不必重新猜一遍记录的用途。
正常执行从 AgentLane::prompt_text 进入 Harness,随后构建上下文并驱动 run_agent_loop。这里的设计选择是:在关键边界提交,不把全部保存工作推迟到整次任务结束。
任务开始:运行身份和首条输入一起进入追加路径
普通提示路径会为新运行分配身份,构造 operation_started,并把首条用户提示与它交给 append_operation_started_and_entry。
这样设计,是为了让"开始处理哪次任务"和"处理的输入是什么"尽量靠近。否则可能只有一条用户消息,却没有可识别的执行归属;也可能只有开始记录,输入尚未进入会话。
这里需要区分逻辑批次与存储保证。JSONL 后端会把这两个变更编码到一批字节中,再调用追加。但存储 trait,也就是各后端共同实现的接口,默认可以退回两次有序追加。不能仅凭接口名,就断言所有后端都提供同样的事务保证。
追加返回成功,也不等于断电后一定保得住。默认 OS 追加实现会等待 write_all 和 flush 完成,后续读取能看到写入内容。但这条路径没有调用 sync_all 或 sync_data 来要求刷入持久介质。本文后面说记录"提交成功",对这条默认路径指的是追加已完成,不能把它扩大成断电持久性保证。
JSONL 的这段合批逻辑由 append_record_and_entry_owned 实现。但即使会话记录合在一批里,后面的 CHANGELOG.md 修改也没有因此进入同一个事务。一个是保存执行依据,一个是工具修改外部文件,需要分开判断它们是否完成。
模型提出工具调用:先把调用本身留住
模型回复稳定下来、准备执行工具时,SettlingEmitter 会处理对应的消息事件。
对携带工具调用、需要提前提交的 assistant 消息,settle_assistant 会预留条目身份,处理流与条目的绑定,再提交消息。提交成功后,它把消息中的工具调用登记到内存映射,供后续 tool_started 定位引用。
如果不在这里保存,追加文件已经发生、整次任务却还没结束时退出,新进程甚至可能找不到"究竟是哪条模型消息要求修改文件"。
工具开始前:固定调用关系和结果位置
轮到 call-9 时,Agent Loop 先发出 ToolExecutionStart,由 start_tool 尝试保存 tool_started。上面快照里的 resultEntryId: entry-t3 也在这个阶段预留。
此时记录说明调用走到了执行前,不能据此判断文件已经改过。entry-t3 也只是预留的结果位置,还没有结果内容。恢复时既要读取调用记录中的重跑策略,也要另查结果条目是否存在。
还有一个不能略过的细节:该版本 ToolExecutionStart 在参数准备与执行前钩子之前发出。因此,effectiveArgs 在这里保存的是事件携带的参数,不能不加限定地叫作"所有处理完成后的最终实参"。后面讨论安全重跑时会回到这个差别。
工具返回:结果一稳定就保存
工具完成后,settle_tool_result 按 toolCallId 找到预留的 resultEntryId,把真实结果提交到相应条目。
执行器再把更新后的上下文交给下一次模型请求。模型看到原任务、自己提出的 call-9 和对应结果,才有依据决定是否继续运行测试。
整轮结束时,Harness 还会补存尚未提交的新消息、清理进度帧,并尝试写 operation_finished。正常完成、明确中止和失败都有对应的收尾结果;暂停等待的路径则可能刻意保持操作未结束,不能把所有 open operation 都解释成崩溃。
把镜头缩到 call-9 这一次普通本地工具调用,可以沿下面的图检查提交边界:先看上排从左到右,再沿下排从右到左。参数准备通过后,才进入真正可能修改文件的 execute。

图:一次普通顺序本地调用的成功路径,不是完整事务或实测轨迹。假设所示记录提交成功、参数准备通过,省略拒绝、取消、上游录制及整轮最终收尾。外部效果发生后、结果提交前仍有不确定窗口。点击图片可放大。交互版:assets.bigfisharc.cn/diagrams/du...。
顺着图看,tool_started 和 entry-t3 不是同时出现的:前者先留下预期结果的位置,后者要等工具返回才有内容。如果文件已经修改,退出却发生在两者之间,恢复器就只能看到"有调用记录,没有已保存结果"。这也解释了为什么记录调用开始,不能替代保存调用结果。
这些提交处理集中在 settle.rs。到这里,我们已经有了一条可以跟随的顺序,但还不能把它理解成"只要保存失败,工具就一定不会执行"。该版本部分 assistant、工具开始、工具结果写入失败路径只记告警,工具流程不一定因此停止。
假设 call-9 的 assistant 消息已经保存,tool_started 追加却失败了。工具仍可能修改文件,随后进程在结果保存前退出。恢复器能找到调用,却找不到当时保存的重跑许可。即使当前工具声明为 Safe,也缺少历史上的那一份许可,不能自动重跑,只能为这个缺结果的调用留下未知状态。
所以后面分析快照时,都有一个前提:用来作判断的记录确实提交成功了。缺少记录时,不能反过来断定对应动作从未发生。
流式输出怎样保存,又怎样避免恢复出两份回复
前面一直在等"消息稳定下来再保存"。如果模型只输出了半段,进程就退出了,这半段该怎么办?我们先保住已经生成的内容,再看怎样避免多恢复一份。
rpi 把流式输出保存为 assistant_frame。可以把 frame 理解为"这段回复逐步生成时留下的进度片段"。它与正式消息分开,不直接进入会话分支。
但新增一份进度,又带来了新的问题。假设模型回复已保存为 Entry,清理进度记录之前退出;恢复时若再把帧组装成消息,历史里就会出现两份相同回复。
我们沿提交边界看一下,为什么需要绑定,而不能只在末尾写一句"已清理":
plain
帧中已有模型输出
→ commit_stream:把这个流绑定到预留的 entry-a2
→ 提交正式消息 entry-a2
→ clear_stream:标记这个流不再需要恢复
恢复程序不能把 commit_stream 单独理解成"消息已经保存"。它还要检查绑定的 Entry 是否真的存在。
| 中断发生在哪里 | 恢复时如何处理 |
|---|---|
| 绑定写入前,或绑定已写但 Entry 尚不存在 | 已提交帧仍可能是唯一的进度来源,不能因为有绑定就丢掉 |
| Entry 已存在,清理标记还没写入 | 通过绑定找到已有 Entry,不再生成一份重复消息 |
clear_stream 已提交 |
该流已经退出待恢复集合 |
这一层的"回放",是把已保存的输出片段还原成可标识为中断的消息,不是重新请求模型生成同一段输出,也不是执行里面的工具调用。
如果把这几个标记看混了,可以回到一个最直接的判断:绑定只告诉我们"这段进度打算交给哪个条目",查到正式条目才说明消息确实已经提交。恢复时把两者一起看,才能既留下未提交的进度,又不重复已有历史。
对应处理在 frame_progress.rs 的 replay_run_frames,消息的提交与清理由 settle.rs 配合完成。
恢复路径:从一份快照走到下一次模型请求
现在回到最初的快照:call-9 已有结果,旧运行没有结束记录。我们已经知道文件追加的结果留下来了,接下来最关心的是:能不能从这里继续测试,而不是把追加再做一遍?
先只走这个有结果可用的分支。等看清新进程怎样接手,再把中断位置前移,处理结果未知的情况。
第一步:构造 Harness,识别并收尾旧操作
宿主要先打开 Session。以 JSONL 后端为例,JsonlSessionStorage::load 读取日志,按记录顺序应用变更,还原条目、lane 的叶节点和可查询的控制记录。这一步还原的是存储状态,不会重新执行日志里的工具调用。
如果日志末行出现 JSON 语法错误,加载器会把它按未写完的尾部处理,保留有效前缀。这个修复条件不表示可以跳过任意损坏;例如,同一 seq 对应了不同变更,加载仍会报错。先成功打开会话,Harness 才有记录可查。
宿主随后以允许打开已有会话的选项调用 AgentHarness::create。构造路径会重建待处理队列,查找 main 上未结束的操作,并预先判断是否中断在等待重试阶段。
接着,finish_interrupted_operation 会检查未结束操作的数量。发现多条未结束操作时,它不会随便挑一个接着跑,而是报告日志状态有问题。
在该版本中,它先为旧操作写入:
plain
operation_finished
runId: op-17
outcome: aborted
error.code: interrupted
然后才进入旧运行的消息与工具结果修复。
看到 aborted,先不要据此判断任务被用户取消了。这里配合 interrupted 表达的是:旧的执行过程没有正常完成。文件有没有改、任务还要不要继续,仍要往下查,不能从这个结束状态直接推出工具失败。
第二步:修复上下文,而不是从头执行任务
resolve_interrupted_run 处理两类遗留内容:
- 尚未变成正式 Entry 的流式输出,尝试从已提交帧中恢复,并标记为中断内容,同时处理其中缺少结果的调用。
- 分支末尾仍是已提交的 assistant 消息时,检查其中缺少结果的调用,构造对应结果。
构造出的修复消息再由 finish_interrupted_operation 追加到 Session。第二类检查以分支末尾为入口,不是遍历全部历史 assistant 消息。
在本例中,call-9 已有正式结果,无须再次追加文件。调用和结果会进入之后构建的上下文。
如果一次模型回复提出多个调用,已有结果也不能因为同批另一个工具没有结束就被推翻。需要补齐的是缺失的答复,而不是把整批当作"全部失败"后重新执行。构建模型输入时还会检查消息配对,下面会把这层检查与写回 Session 的修复分开。
第三步:判断能否续接,登记待办
旧操作收尾和上下文修复完成后,create 才判断当前分支是否适合继续。
在这个快照里,call-9 的结果已经保存,也没有要求终止,正常循环的下一步应当是请求模型。create 把这项待续接信息登记为 PendingResume,留给宿主调用续接入口时使用。
这个标记只存在当前 Harness 的内存中,还不是存储里的一条调度待办。如果新进程在启动续接前再次退出,不能指望下次打开会话时直接读回它;后面会用快照展开这个窗口。
第四步:宿主明确调用续接入口
到这一步,记录已经修好,但模型还没有因此自动运行。宿主仍然要明确调用续接入口:
rust
lane.resume_pending().await
rpi 的交互宿主在 interactive_tui/run.rs 中调用这个入口。
resume_pending 会取得运行准入、检查续接次数,并排除不合法的分支末尾。随后记录一条可见的续接说明,进入新的运行过程。
没有新的用户提示,模型输入也不会是空的。Harness 从当前 lane 的叶节点沿父链选取分支,再构建上下文。它不会把整个 Session 的条目和控制记录原样发送;发生过压缩时,上下文还会使用摘要和保留的消息尾部。
沿本例展开,保存的消息会变成下一次请求中的对话部分:
plain
Session 当前分支
entry-u1:用户任务
entry-a2:assistant 提出 call-9
entry-t3:call-9 的结果
↓ 选取消息、检查配对、转换格式
下一次请求的对话消息
user:追加记录,然后测试、汇报
assistant:调用 bash,id = call-9
tool result:call-9,执行完成
这是没有发生压缩、结果已经保存时的结构示意,省略续接说明等辅助消息,也不是完整的模型 API 请求体。系统提示和可用工具还会由当前 Harness 配置组装。
消息转换还有一层兜底。messages.rs 中默认的 convert_to_llm 会调用 repair_orphaned_tool_calls,整理调用与结果的配对。如果某条调用仍缺答复,它会为这次请求补一条带错误标志的占位结果;找不到对应调用的孤立结果则不送给模型。这一步既不执行工具,也不把占位结果写回 Session。
因此,模型输入里的配对补齐,不能证明历史文件里也新增了结果。前面的中断修复会尝试保存修复消息,这层转换只处理本次请求的消息。宿主若替换消息转换器,还要自行承担相应处理。
这里空的是"新增提示列表":这次没有新用户输入。原任务、call-9 和已经保存的结果仍可组成上下文,模型可以据此决定是否继续测试。
第五步:新的执行过程,关联旧的任务链
续接会建立新的 operation,而不是把 op-17 的结束记录擦掉,假装旧进程从未退出。
plain
operation_started
id: op-18
intent.resumeData:
resumedFrom: op-17
resumeAttempt: 1
这仍是摘选示意。后续如果再次续接,resumedFrom 保留的是这条续接链的起点,resumeAttempt 则累计次数。
从任务角度看,追加后的测试继续做;从运行历史看,旧操作中断,新操作接手。两种身份都留下来,才有办法解释"为什么没有新问题,Agent 却又开始运行"。
把正常路径和这个恢复分支放在一起,可以看到它们最后汇合在同一个动作:用包含已有结果的上下文请求模型。

图:正常路径与中断位置的概念对照,不是按时间连续发生的一次实测。下半段假设上半段在结果提交后、下一次模型请求之前退出;省略流式帧、队列和失败分支,相关记录均假设提交成功。点击图片可放大。交互版:assets.bigfisharc.cn/diagrams/du...。
这五步的实现集中在 agent_harness.rs。create 负责接手检查,finish_interrupted_operation 收尾旧操作,resolve_interrupted_run 修复消息。宿主调用 resume_pending,才启动后面的 Agent 循环续接。
缺少工具结果时,恢复器按什么顺序做决定
有结果时,我们可以直接接着往下走。现在把中断位置前移一点:文件可能已经追加,但结果 Entry 尚未提交。最初的两难就在这里:重跑可能重复,跳过又不知道完成没有。
先不急着选。恢复器会先找有没有可用的录制结果,再判断工具能不能安全重跑;两条路都走不通,才把未知状态交给后续处理。我们按这个顺序来看。
把上一节的"已有结果"分支也放回来,完整判断顺序如下。读图时先沿左侧向下走,遇到满足条件的分支再向右看,不需要一次记住所有出口。

图:对一个调用作决定的源码逻辑,按固定版本整理。已有结果保持原样;其他分支构造的结果返回中断收尾路径,再由该路径追加。补齐结果之后,仍须另行判断是否续接 Agent 循环。点击图片可放大。交互版:assets.bigfisharc.cn/diagrams/du...。
回到 call-9:如果 entry-t3 还在,第一行就有了出口;若结果缺失,普通 bash 又没有上游录制、默认策略为 Never,就会走到最下面的未知分支。图里红色路径表示不满足重跑条件,不表示已经证实文件修改失败。
先排除已经有录制结果的调用
前面一直假设工具由本地执行器运行。也有些调用在交到本地执行器之前,就已经由上游执行,并带回了结果。这份随调用返回的结果放在 recorded 字段里,本地恢复时可以直接使用,不必再执行一次。
resolve_tool_call 会优先用它构造结果,不再执行本地工具。录制内容无法解析时,也是返回错误结果,不是以"解码失败"为理由重新做一次外部操作。
这是这个实现中特定的结果来源。它与前面的"Session 中已有结果 Entry"都能避免重新执行,但不能混成同一种存储机制。
普通本地工具:历史和现在都允许,才重跑
对没有录制结果的普通本地调用,关键判断是下面这两项同时成立。摘录来自 resolve_tool_call:
rust
let may_replay = frame.is_some_and(|frame| frame.replay == ToolReplay::Safe)
&& tool.is_some_and(|tool| tool.replay == crate::types::ToolReplay::Safe);
这里的 frame 实际是查到的 ToolStartedRecord,不是上一节的流式文本帧。阅读代码时不要被局部变量名带偏。
第一项确认调用当时保存的策略是 Safe;第二项确认当前仍能找到该工具,且注册声明仍为 Safe。
为什么需要两项?如果只看现在,过去明确禁止重跑的调用可能被新配置放行;如果只看过去,已经被修改为不安全的工具也可能继续执行。安全许可要在两边都成立。
默认 CLI 在 session.rs 中把 read、docs 声明为 Safe,把 bash、write、edit 默认设为 Never。回到本例,通过 bash 追加文件的旧调用,结果缺失时就不会自动重跑。
这并没有替我们查明文件是否改过;它只是先避免在证据不足时,再执行一次可能产生副作用的旧命令。
Safe 的含义是"允许重复这个动作",不是"能够返回过去那一刻的值"。重新 read 读到的是现在的文件。如果文件在中断期间变了,新结果也会变化。重跑本身也可能失败;恢复代码会构造带错误标志的结果,并写明重跑失败,而不是把它当成原调用成功。
还有一个实际执行时点:安全工具的重跑发生在 resolve_interrupted_run 内部,这条路径会被 create 调用。构造恢复 Harness 并不是完全没有执行效果的只读检查;它可能已经运行 Safe 工具。 resume_pending 控制的是后面的 Agent 循环续接,不能把它当作所有恢复动作唯一的执行开关。
不允许重跑:补的是"未知",不是"失败"
没有历史记录、当前工具不存在、任意一侧不是 Safe,都会落入无法自动重跑的分支。恢复器为原调用补一条错误结果,说明工具在结果提交前中断,外部效果未知,可能已经发生。
补这条消息有两个目的:让调用有对应答复,让下一次模型请求明确看到不确定性。对应本例,消息表达的是下面这层意思,文字和排版仍是教学示意:
plain
tool result
toolCallId: call-9
isError: true
content:
工具执行在结果保存前中断,外部效果未知。
可能已经追加记录,也可能没有;没有自动重跑。
这个结果仍然回答 call-9,没有另造一次"追加任务"。isError 表示缺少正常可确认的完成结果,不把"未知"偷偷改写成"文件没有修改"。
它没有证明文件没改,也没有保证模型下一轮不会再次提出相同的 bash 命令。到这里,执行器已经把不确定性说明白,后续仍需要应用决定怎么处理。
对这个追加任务,一种可考虑的下一步是先读文件核对,再决定是否补做。换成其他业务,也可能需要幂等键、人工确认,或者限制后续写操作。这些都需要额外的处理规则;补一条未知结果本身,并没有替应用完成确认。
到这里,我们可以把三种"回放"区分开了:
| 动作 | 重新调用外部工具吗? | 得到的是什么? |
|---|---|---|
| 从帧恢复中断消息 | 不因此调用工具 | 已提交输出进度组成的消息 |
| 使用已有结果或上游录制结果 | 不调用 | 先前已经留下的结果 |
| 对缺结果的 Safe 工具重跑 | 会调用 | 这一次重新执行产生的新结果 |
一个容易被字段名掩盖的边界:参数和权限钩子
前面判断了工具能不能重跑,接着要看重新执行时用哪一份参数。它是否与中断前真正交给工具的参数一致?
ToolStartedRecord 有个字段叫 effective_args。单看名字,很容易以为它已经保存了工具最终使用的参数。
但当前版本的普通顺序执行路径是:
plain
发出 ToolExecutionStart,事件携带原调用参数
→ SettlingEmitter 保存 tool_started.effective_args
→ prepare_arguments 与参数校验
→ before_tool_call 可以阻止调用,也可以替换参数
→ execute 使用处理后的参数
普通调用的事件与参数准备顺序在 agent_loop.rs 中。把它与恢复路径对照,差别就出现了:恢复重跑直接调用工具的 execute,传入记录里的 effective_args。它没有在这条路径上重新走普通循环的参数准备、校验和 before_tool_call。
例如,自定义读取工具允许重跑,执行前钩子却把相对路径替换成某个确定的绝对路径。如果记录保存的是替换前的值,重跑时便不能仅凭字段名保证读到同一个目标。这是对该调用顺序的推论,不是本文已经运行过的复现实验。
权限也类似:如果某个工具依赖执行前钩子批准才能调用,就要审视恢复直达 execute 的路径。不能一边让工具声明 Safe,一边假设恢复必然重新经过普通审批入口。
因此,在这个版本中,Safe 的责任比"没有写文件"更具体:工具注册者还要考虑重复执行、参数处理和恢复入口是否匹配。若要设计更强的记录边界,可以考虑在参数和审批确定后保存最终执行描述,并明确恢复时重新校验哪些权限。这是设计建议,不是说当前实现已经这样做了。
哪些状态应该继续,哪些状态应该停下来
走到这里,还剩一个会影响使用体验的问题:打开旧会话,是为了看历史,还是为了继续工作?如果仅凭"有一条未结束操作"就自动运行,原本不该继续的分支也可能被带起来。
rpi 会结合消息末尾、队列语义和重试意图来判断。下面前三种允许续接的情况,都以启动时找到对应的未结束操作为前提:
| 中断或结束位置 | 恢复策略的关键依据 |
|---|---|
| 首条用户提示已保存,模型还没输出 | 有未结束操作,分支仍停在输入,允许接着请求模型 |
| 工具结果已经落地 | 分支处于可继续的位置,且结果未要求终止,登记续接 |
| 已决定稍后重试,等待中退出 | 通过仍有效的 retry_pending 识别,不把失败尝试当作普通中断文本 |
| 模型只留下半截普通回复 | 保存可恢复的中断内容,但不因此一律自动再请求模型 |
| 正常完成或明确中止,结束记录已保存 | 不因为再次打开历史而重跑 |
| 工具结果明确要求终止 | 排除在普通工具结果续接条件之外 |
这些都依赖对应记录和状态实际保存成功。结束记录写失败,与正常保存了结束记录,不应被当作同一种输入。
为什么恢复次数也要持久化
假设某个任务每次执行到相同位置都会崩溃。如果次数只存在内存里,每次重启计数都归零,应用就可能不断请求模型、运行工具,再次崩溃。
rpi 的 MAX_RESUME_ATTEMPTS 是 3,续接次数写入新 operation 的 resumeData。下一次启动可以从记录中读出已经续接了多少次,而不是相信一个刚初始化的计数器。
次数限制是防止无进展重启的一道保护,不是幂等机制。三次错误写入仍然可能是三次副作用;只限制次数不能让它们自动变成一次。
resume_pending 在真正开始前还会取得运行准入,避免同一个 Harness 的活动操作互相冲突。这是本地运行控制,不等于已经实现多台机器之间的租约、选主或过期执行者隔离。
恢复本身中断,仍然存在窗口
前面一直在问"正常执行中断后怎么办"。恢复程序本身也可能再次中断。
沿用已有结果的 call-9 快照。假设 create 已经关闭旧操作、登记 PendingResume,宿主还没调用 resume_pending,进程就再次退出:
plain
已保存的状态
op-17:已有结束记录
aborted / interrupted
main:仍能找到 entry-t3,回答 call-9
op-18:尚无开始记录
退出时丢失的内存状态
PendingResume:原本准备续接 op-17
下一次 create 查找未结束操作时,不会再找到已经收尾的 op-17。这个快照没有其他未结束操作,代码也就不会仅凭分支停在工具结果,重新为它登记 PendingResume。追加结果还在,不等于自动接手的条件也还在。
窗口还可以更靠前:旧 operation 的结束记录已保存,修复消息却还没全部追加。关闭旧操作、写回修复结果和建立新操作是几个分开的提交点,不能把整段恢复看成一次事务。
如果产品需要恢复过程也能反复中断后继续,就要持久化修复进度或待接手意图,并让下一次启动能识别这些中间状态。上面的快照是依据提交顺序作出的推导,不是再次中断的实测结果。
不只保存当前步骤:队列与重试也是待办
假设 Agent 正在追加记录,用户又发来一句"测试之后也检查格式"。应用已经确认接收,但这句话还没进入 Agent 的上下文。如果此时进程退出,只保住当前工具调用,还不足以保住这项新要求。
排队消息:接受与消费是两个边界
rpi 的入队路径先写 queue_enqueued,成功后再发布到内存队列。顺序可以摘成:
rust
self.write_queue_enqueued(kind, &entry_id, &message).await?;
queue.lock().unwrap().pending.push_back(QueuedMessage {
entry_id: entry_id.clone(),
lane: self.lane.clone(),
message: message.clone(),
});
这与前面某些只记告警的保存路径不同:这里写入错误通过 ? 返回,不会继续执行这段内存入队。
再看消费这一端。"已经从队列取出"够不够证明这条要求不会丢?还不够:它可能刚离开内存队列,尚未变成正式消息,进程就退出了。
rpi 在入队记录的 target 中留下预留 Entry ID。队列项取出、注入循环时,不立即把持久化待办抹掉;等对应消息真正以那个 ID 提交为 Entry,才有了"已消费"的依据。这样检查的是要求有没有进入会话,而不只是它有没有离开队列。
重建队列时,程序按顺序读取入队记录,排除已取消的项,也排除目标 Entry 已存在的项。否则,明明上一轮已经交付的指令,会在重启后再来一次。
这是一组身份与提交规则,不是"把所有历史用户消息重新喂给 Agent"。此外,纯上下文更新与请求回复的排队消息语义不同,前者不应仅因为被保存就触发新一轮模型调用。
重试等待:保存意图,不是保存一个活着的计时器
还有一种待办来自执行器自己:模型请求遇到可重试错误,准备等一段时间再试。如果进程在等待中退出,半截输出本身说明不了它是在等待重试,还是在生成回复时中断。
为此,Harness 在进入退避等待前尝试保存 retry_pending,记录所属运行、下一次尝试及预留结果身份。
但历史中存在 retry_pending,也不代表现在还欠一次重试。我们把时间往后推一步:也许等待已经结束,下一次生成甚至已经开始,只是那条旧意图还留在历史里。若只看它存在,就可能把新进度误当成旧失败尝试。
因此,died_awaiting_retry 还会检查最新意图的预留结果 Entry 是否已落地,以及之后是否已有对应的流绑定。结果已落地意味着意图已经兑现;下一次生成已开始,则要处理新生成的进度,不能把它当成旧失败尝试丢掉。
这里恢复的是"还需要继续尝试"的工作含义,不是旧 Tokio 计时器本身。该记录没有保存明确的到期时间,不能据此保证重启后仍然等待原先剩下的那几秒。
另外,这个版本重试意图写入失败也是告警路径。等待能够恢复的判断,仍以记录成功保存为条件。
回头看最初的追加任务,我们需要的其实是一个有依据的决定:有结果,就带着结果继续;允许安全重跑,才重新执行;无法确认文件是否修改,就把未知留下来,而不是猜它成功或失败。运行身份、进度帧、队列和重试记录,都在帮助程序把这些决定做得更具体。这也是理解 Durable Execution 设计时可以一直带着的问题:重启之后,它凭什么知道下一步该做什么?