用户已经说了“执行”,平台也可能收到了 SQL,但页面只看到一次断线。此时重试按钮背后需要回答一个问题:应该再发一次,还是先查刚才那次有没有成功?
沿用前两篇的合成订单例子:用户先问“查 2026 年 1 月各渠道净收入,金额用元”,看到草稿后补充“只看 app,再执行”。查询还在后台运行,用户又说“再按天展开,先给我 SQL”。如果原查询这时返回 16 元,系统需要说明它是之前的 app 月度合计,不能拿它回答新的按天问题。
这些是假设的交互与故障分支,用来讲解机制,不是实际请求日志。本文依据公开提交 6801df7,重点追踪一份查询从草稿到收尾的身份和状态。这里的 Pi 是提供模型与工具循环、会话续接和压缩能力的 Agent SDK;执行授权、查询记录和恢复约束由 Data Agent 的 Rust 服务保存。
一、先把“再执行”落实到一份草稿
“只看 app,再执行”同时包含条件修订与执行要求。若 Pi 只取出后半句,旧的全渠道 SQL 仍然能通过语法检查,但它已经答错了问题。
为便于说明,把净收入任务记作 A。原条件是版本 1,对应草稿 Q1。Pi 应先调用 update_analysis_task,用 expected_version 指明自己读到的条件版本,再提交只修改渠道的补丁。Rust 检查期望版本,合并已有条件,保存版本 2。月份和金额单位继续保留;若期间已有其他修订,旧版本补丁会被拒绝,Pi 需要重新读取条件。
接着,Pi 用 request_query 保存 app 查询 Q2。这个工具会调用平台检查 SQL,再保存草稿和检查结果;它不会执行查询。检查通过后,Q2 等待确认。条件修订会使旧的待确认草稿失效;仅修订 SQL 时,则通过 replaces_query_id 指明被替换的草稿。新 SQL 检查失败也不能恢复旧稿的确认权。条件合并和草稿保存入口分别落实这两步。
最后才是 execute_query。下面是工具参数的教学示例;Q2 和版本号只用于说明绑定关系,不代表真实请求标识。
{
"query_id": "Q2",
"draft_version": "2",
"condition_version": "2",
"instruction_quote": "只看 app,再执行"
}
condition_version 指向分析条件,draft_version 指向 SQL 草稿。条件没变也可能改写 SQL,所以两者要分别核对。具体查询又有独立的 query_id,后续状态查证和结果读取都沿这个标识进行。
二、完整原话证明出处,Pi 仍要理解意图
假设用户说的是“只看 app,先别执行”。把“执行”两个字摘出来作为授权依据,会丢掉前面的否定。聊天执行入口因此要求 instruction_quote 包含当前用户的完整消息。Rust 忽略首尾空白后,核对引用是否与保存的原话一致,并排除宿主生成的查询结果通知。
这项检查只能证明引用来自哪条消息。即使全文一致,Pi 仍可能误解否定或条件关系。Pi 负责判断用户是要求执行、只改 SQL,还是需要先澄清;Rust 没有另一套中文理解器来重新判读“先别执行”。工具说明和消息授权入口划分了这两种责任。

蓝色表示消息理解,绿色表示正式记录,橙色表示执行阶段。图中 A、Q1、Q2 和版本号均为教学标记;只展示首次授权的主要路径。
Rust 还要检查这条消息已登记到哪个任务。Q2 必须属于当前用户、会话及消息关联的任务,当前运行也必须仍有执行权。首次确认时,任务须处于可执行的当前条件版本,知识依据仍然有效;查询须检查通过、等待确认、尚未提交,而且没有取消请求。否则,一条真实的“执行”也可能被错用到另一个问题或旧稿上。
这些检查依据正式记录完成,但检查能力有明确范围。当前目标固定为 synthetic-sqlite,目标版本固定为 1;保存草稿和提交平台都能看到这个边界。记录里有目标版本,并不意味着当前已经实现任意生产平台的动态能力变化检测。
三、授权、消息生效和排队要一起保存
设想 Rust 已经保存“Q2 获准执行”,却在保存消息生效标记前崩溃。恢复后,系统可能把授权消息当成未应用输入,允许撤回或再处理。反过来,先标记消息已应用、却没有保存查询授权,也会让一句执行要求消失。
当前实现将首次授权、confirmation_message_id、消息的 applied_to_task 标记、执行消息的预算归属,以及 execution_state=queued 放在同一事务里。事务提交后,Worker 才能领取查询。授权用例组合这些动作,查询存储模块保存确认和排队状态。这条消息的执行授权生效后,不能再按未应用消息撤回;停止查询需要另行请求取消。绑定预算则确定后续结果解释使用哪条请求的额度,具体调用预算留到第四篇。
网络重传也可能让同一次确认到达两遍。确认函数先核对版本,再执行下面这段实际代码:
if v["confirmation_state"] == "confirmed" {
return Ok(v);
}
if blocked {
return Err(Error::new("message_pending"));
}
已确认的查询直接返回现状,不再次排队,也不把预算转到另一条消息。如果用户确实要重新运行相同 SQL,Pi 应创建新的查询记录,再申请执行。去重针对这次操作,并非永远禁止相同 SQL。
blocked 处理的是另一种竞争:确认旧稿时,又收到一句“先别执行,改成按天”。接收消息和确认查询都会先锁定会话。新消息先提交,且归属尚未判断时,首次确认返回 message_pending;确认先提交,查询就按已经授权的版本继续。后续消息不能追溯改写那次授权。
处理完一条消息也不代表所有阻塞都已解除。实现会检查是否还有未归属的待处理或失败消息;模型超时不能被当成“这条补充与旧 SQL 无关”。阻塞解除只允许重新申请确认,不会自动执行旧稿。这里采用的是消息与确认的事务竞争规则,对应的阻塞查询仍以消息记录为准。
四、一次 Agent 运行结束,查询可以继续
这里需要区分任务和运行。任务 A 保存持续修订的分析问题;一次运行(run)是 Agent 处理某条输入的一次执行尝试。一次运行可以保存草稿后结束,也可以告诉用户查询已提交后结束。任务还在等待,并不要求模型始终保持一次未结束的调用。
同一 Pi 会话串行推进,平台查询独立运行。用户可以在 Q2 执行期间继续提问,随后由另一次运行处理新消息。当前工具还专门限制:本条消息刚确认的查询,即使已经很快完成,get_query 也只交付状态,把结果解释留给后续结果通知。这样,执行轮和结果轮不会各自解释一次同一份结果。相关判断发生在结果读取边界。
Worker 从正式查询记录领取待处理项。领取时增加 lease_epoch,也就是本次领取的代次。若 Worker 停顿后被另一 Worker 接手,旧代次的回执不能覆盖新领取者的状态。这个数字用于判断谁有权更新查询,不改变查询本身的身份。
真正提交前,Worker 先把 queued 改成 submitting,增加提交次数,并提交本地事务;然后才在事务外调用平台。若进程恰好在两步之间退出,数据库无法证明网络请求究竟有没有发出。恢复程序因此将 submitting 视为可能已提交,先查询平台,而不是直接重发。领取、提交意图与代次检查都在查询存储模块中。
平台侧使用稳定的 query_id 查找同一次提交;Pi 工具侧另有稳定的 operation_id 标识一次业务操作。两者分工不同:前者连接提交、状态与结果,后者连接工具登记、业务修改与回执。重启 Worker 或更换 Agent 运行,都不应把旧操作伪装成一个新请求。
五、提交回执未知,先查平台有没有收到
Q2 已经提交,响应却在返回途中断开。这时“客户端没有收到成功”与“平台没有收到 SQL”是两件事。立即换一个查询 ID 重试,可能制造第二次执行;直接告诉用户失败,又可能让一个实际成功的结果无人处理。
Rust 把这类不确定情况保存为 submission_unknown。后续 Worker 沿 Q2 调用 /lookup。查到正在运行,就继续跟踪;查到终态,就保存实际结果。只有平台明确返回未找到该提交,且本地仍处于允许重排的提交状态、没有取消请求时,才有限重排。当前上限是总计三次提交,包含第一次;超过上限记录 submission_retry_exhausted。查证本身超时不能充当“未提交”的证据。

图只讲平台提交的未知状态。橙色分支必须等待外部证据;绿色分支表示已经取得的正式记录。有限重试始终沿原 query_id。
这条分界写在平台调用后的处理和重排条件中。语法不支持、无权执行等明确拒绝可以记为失败;通信异常则保留未知。失败与未知会导致不同的下一步动作,不能为了让页面出现一个红色状态就合并它们。
当前合成平台将提交保存在自己的 SQLite 账本中,按 query_id 和请求载荷指纹去重。同标识、同载荷返回已有提交,异载荷拒绝;测试模式甚至可以在保存提交后故意返回未知,用来覆盖响应丢失。平台实现支持这种查证。因此,本地事务和平台去重能配合工作,但这不构成生产环境的 exactly-once 保证。真实平台若不能稳定查证提交,就需要继续表达未知,不能照搬“未找到便可重试”的假设。
六、晚到的 16 元仍属于原查询
现在 Q2 已经获准执行,任务 A 却因“再按天展开,先给我 SQL”前进到条件版本 3。旧查询继续收尾,新草稿 Q3 对应按天条件。Q2 的月度合计不会因为聊天中出现新要求,就自动变成每日收入。

这是教学时序,纵向表示先后,不表示耗时。Q2 已获授权后,条件 v3 的修订不改写 Q2;Q3 仍需自己的执行授权。
Worker 保存终态时,结果事件携带原 query_id、task_id 和 condition_version。它仅在任务当前版本仍与查询版本一致、且不处于待澄清阶段时,才调整任务阶段。版本已变成 3,Q2 的晚到结果就不能改动当前版本 3 的任务阶段。终态保存代码直接比较这两个版本。
随后,宿主为 Q2 登记一次结果通知,排队交给 Pi。通知要求读取 Q2 的实际 SQL 和结果,按它的原条件解释,同时保留后来的条件。这里可以回答“之前的 app 月度合计是 16 元”;若要回答按天明细,还需查看 Q3,并取得对应执行授权。结果通知没有用户执行意图,聊天执行入口会拒绝拿它授权新 SQL。
时间也属于查询含义。用户若说“上个月”,恢复发生在下个月时,不能按恢复当天重新展开日期。消息协议保存 request_clock,包含接收时的 UTC 参考时刻和业务时区;同消息恢复沿用它。结果通知从绑定预算的来源消息继承时钟,解释仍以查询里已经保存的绝对日期参数为准。通知登记保留了这个来源,也避免借用后来的按天问题的预算。
取消同样要看查询走到哪一步。Q2 尚未提交、仍在排队时,本地事务可以将它终止;进入 submitting 后,即使网络尚未真正发出,也要按可能提交处理。此时先记 cancel_state=requested,再由 Worker 请求平台取消或查证。requested 只说明用户提出了停止要求;只有取得相应平台证据,才能记录 cancelled。如果平台已成功,系统保留成功终态。停止 Pi 当前运行也不等于取消了平台查询。取消实现将取消意图和执行状态分开保存。
七、Pi 检查点还需要工具账本
平台查询恢复之外,还有一种更隐蔽的中断:Rust 已经保存了 Q2 的执行授权,但 Node 进程在收到回执后、把 toolResult 写入 Pi 会话前退出。Pi 只看到“我调用过工具”,却看不到工具结果。如果让模型重新想一遍并换个调用 ID,就可能重新创建任务或查询。
Pi 检查点保存会话树及具体位置,leaf_id 指向这次恢复应接回的节点。工具账本则保存动作事实:SDK 调用 ID 对应哪个 operation_id,来自哪个 origin_run_id,使用什么参数指纹,以及有没有完成回执。两份记录回答不同问题,聊天历史不能替代动作是否完成的证据。
注册调用时,Rust 同时保存调用映射与检查点;应用内业务修改与成功回执也在同一事务内保存。恢复原输入时,新的 run_id 用来验证当前运行权,原恢复链、SDK 调用 ID 和 operation_id 继续保留。同一个调用 ID 携带不同参数,会得到 idempotency_conflict,不能偷偷把旧操作改成另一个操作。工具映射与登记入口保存了这些身份。
恢复时,Node 找到最近一条助手消息中的工具调用,逐个寻找尚未配对的 toolResult,再向 Rust 接回。即使检查点最后已经有另一个工具的结果,也不能漏掉同一轮里尚未完成的调用。成功回执补成成功结果,明确拒绝补成错误结果;随后通过 Pi 的 session.prompt 继续原输入。会话恢复代码处理结果配对,模型与工具循环仍由 SDK 推进。
八、升级后的参数校验,不能改写旧操作的事实
再加一个实际恢复边界:旧版本曾接受只写日期的参数,新版本要求带时区的完整时间。进程升级后,如果先用新契约校验旧调用,再把它报告为“参数错误,本次未执行”,一个已经成功的操作就会被说成从未发生。Pi 可能修正参数,再做一遍。
当前恢复入口先核对已登记调用的原参数指纹和回执。针对可重放回执的业务工具,处理结果如下;只读知识工具仍需重新核对当前读取权限。
| 恢复时掌握的事实 | 应怎样处理 |
|---|---|
| 同一已登记调用、同一参数指纹,有成功或明确拒绝回执 | 接回原回执,不按新参数契约把历史结果重判为未执行 |
| 旧参数已经登记,不符合当前契约,且没有完成回执 | 返回 outcome_unknown,保留原记录,不制造拒绝回执 |
| 参数不符合当前契约,且调用从未登记 | 可以明确告诉 Pi 本次未执行,让它修正参数 |
| 同一调用身份的参数指纹改变 | 拒绝冲突,不能用改后的参数冒充原调用 |

图聚焦升级后不再符合契约的旧参数。合法、已登记但尚未完成的调用仍沿原 operation_id 处理;不能把“已登记”一概解释成成功或失败。
这个区分体现在 Rust 回执恢复入口和 Node 的 invokeRecorded中。新调用仍严格使用当前契约;历史成功只凭当前运行授权、原身份、原指纹和原回执接回。恢复程序有时必须停在未知处,因为错误地宣布“没有发生”会让下一步变得更危险。
恢复回归覆盖旧参数成功、明确拒绝、无回执未知及多工具中断;时钟回归覆盖跨日恢复与结果唤醒。这些合成检查用于验证协议与状态约束,不能证明任意模型都能正确理解用户意图,也不能替代真实平台的幂等、取消与故障验证。
下一篇:把第一版做成可维护的工程。继续看这些约定怎样通过跨语言契约、逐调用预算和有范围的验证落实到日常开发中。