任务落盘了,响应却丢了:如何让 Agent 重试只创建一张任务

20 阅读12分钟

2026-08-31-response-loss-idempotency-juejin-cover.png

任务落盘了,响应却丢了:如何让 Agent 重试只创建一张任务

任务文件已经写入磁盘,调用方却只收到异常。恢复后再提交一次,到底是在找回原结果,还是在派发第二张任务?我们在 CodeFlowMu 的受控实验里得到了明确对照:报告保持一份,任务却变成两张。

CodeFlowMu 是我们开发的本地优先多 Agent 运行体,负责维护持久任务、运行会话、工具调用和审计证据,让不同职责的 Agent 在受控工作空间里协作。

本文依据 2026-08-31 修订的中文原文整理。原始实验、公开附件和正式发布说明均保留对应版本边界。

1. 先复现:同一个故障窗口,报告一份、任务两张

最初实验固定在 CodeFlowMu V2.0.4 提交 2ba1ad9b。实验不是生产事故统计,而是主动制造“动作已经完成、调用方却失去成功结果”的响应丢失窗口。

受测层次历史实验观察工程含义
上层内存去重第一次动作已经发生,但成功结果没有进入可复用缓存;恢复后相同调用再次进入执行路径进程内缓存不能解决跨恢复的结果未知
write_report相同任务、内容和提交标识重试,deduplicated=true,报告文件数保持为 1受测报告路径已经具备持久结果复用
write_task相同业务创建意图再次进入真实创建路径,分配新任务身份,TASK 数变为 2任务创建合同没有把稳定提交身份绑定到第一次结果

历史响应丢失实验:报告一份,任务两张

图 1:V2.0.4 的历史故障对照,不代表 V2.1.2 当前行为。图中计数只覆盖受测路径,不表示生产环境发生频率。来源:RBE-20260828-01 证据页。

这组结果很重要,因为它否定了一个过于宽泛的结论:并不是“CodeFlowMu 所有写工具都没有幂等”。

恰恰相反,write_report 是一个重要反例。它说明上层允许重试,并不必然制造第二份副作用,只要底层接口已经拥有可恢复的持久身份和结果复用机制。

真正的缺口集中在任务创建。当时 write_report 已暴露 client_submission_id,而 write_task 还没有相应的正式持久幂等合同。调用方主观上认为“这还是刚才那一次提交”,并不等于 Runtime 已经持久保存并认可了这个业务身份。

2. 为什么不能靠异常或同标题查重

外部研究起点:异常并不能证明副作用没有发生

这条研究链的外部起点是 LlamaIndex 的 PR #22841:fix(core): avoid retrying failed function tools。

它处理的具体问题是:调用层先用一种参数形式真实执行 FunctionTool,遇到异常后又换另一种形式再执行。危险之处在于,第一次异常并不必然发生在副作用之前;如果第一次其实已经完成写入、发送或创建,那么第二次“尝试另一种调用方式”就可能重复执行真实动作。

LlamaIndex 的修复思路,是把参数形式的选择移动到真实调用之前,通过函数签名判断调用方式,避免用一次可能带副作用的执行去试探接口。

这个外部案例给 CodeFlowMu 的价值,不是提供一份可以照抄的补丁,而是提出了一个值得进入自身 Runtime 的故障模型:

当调用方看见异常时,系统是否能够证明副作用尚未发生?如果不能,重试依据是什么?

外部研究只负责提出问题。CodeFlowMu 是否存在同类工程缺口,必须由自己的实现和第一方实验回答。

工程判断:可靠重试不是“查重”,而是恢复第一次结果

发现两张 TASK 之后,最容易想到的修法是:创建前查一下有没有同标题任务,有就不创建。

这不是可靠幂等。

标题、正文摘要、发送者甚至时间窗口都可能相同,却代表两次真正独立的业务意图。反过来,同一次业务提交在恢复时也可能带着不同的 trace、连接、进程或重试次数。模糊内容匹配既可能漏掉重复,也可能误伤合法新任务。

因此 CodeFlowMu V2.1.2 没有把问题定义成“怎么更聪明地查重”,而是定义成:

如何给一次创建意图一个稳定、可持久化、可恢复、可冲突检测的提交身份。

这也是这次工程化最关键的变化:重试从调用方策略,变成创建接口合同的一部分。

3. 让提交身份和请求摘要一起持久化

V2.1.2 在任务创建路径中建立了这条关系:

client_submission_id → request_digest → task_id / task_path → creation_result

client_submission_id 描述的是一次业务创建意图,而不是某一次 HTTP、MCP 或进程调用。需要跨响应丢失、跨进程恢复的调用方,必须在重试时复用同一个稳定 ID。

如果重试时重新生成一个 ID,Runtime 会把它理解为新的业务意图。这不是幂等失败,而是调用方给出了一个新的身份。

只有提交 ID 仍然不够。同一个 ID 如果第一次要求 DEV 修复登录,第二次却变成 OPS 发布环境,Runtime 不能因为 ID 相同就错误复用第一次结果。因此 V2.1.2 同时保存规范化的 request_digest。

实现中的摘要方案为 write-task-v1:从 sender、recipient、subject、body、priority、thread_key、parent、references、depends_on、risk_level 等语义字段构造确定性 JSON,再计算 SHA-256。对象键排序,字符串统一 Unicode NFC 与换行,数组保留顺序;调用时间、重试次数、trace ID 等本次执行信息不进入业务摘要。

Runtime 同时保存 digest_schema_version。同一提交 ID 如果出现不同摘要或不同摘要版本,不会被静默解释成同一任务,而是返回 conflict,且不产生新的 TASK 副作用。

这是一份任务创建接口合同,不是把整个 Runtime 宣称成通用事务数据库。既有 write_report 去重语义保持不变,FCoP 的 TASK / REPORT / REVIEW / EVAL 协议也没有因为这次补丁而改版。

4. 三阶段回执如何封住崩溃窗口

为什么还需要三阶段创建回执

如果只在 TASK 创建完成后再写一条“这个 submission 已经成功”的索引,仍然存在一个更窄但真实的崩溃窗口:

  1. TASK 已经成功落盘;
  2. 回执索引还没来得及写;
  3. 进程退出;
  4. 调用方带同一提交 ID 重试。

如果 Runtime 只看“最终回执不存在”,仍然可能错误地再创建一次。

因此 V2.1.2 把创建结果持久化为三个阶段:

reserved → task_created → committed

状态已有持久事实恢复规则
reserved提交 ID、摘要版本、摘要、预分配任务身份与路径TASK 尚不存在时沿原身份继续;已存在且内容匹配时接管原任务,不换号
task_createdTASK 已创建及文件摘要已经确认匹配时继续提交回执;文件反而缺失时返回类型化冲突,不能假装“从未创建”
committed完整机器可读创建结果同 ID、同摘要直接复用第一次结果,不再次执行创建

提交级互斥与任务序号保护解决并发占位;原子持久化和中间态恢复规则解决崩溃恢复。目标路径如果已经存在,但身份或内容不匹配,也不能通过覆盖文件或另建第二张任务绕开不确定性。

对于长期停留在中间态的 reservation,Runtime 提供只读诊断:可以观察当前状态、目标 TASK 是否存在、摘要是否一致以及 reservation 是否长期未推进。诊断只报告事实,不因为“超时”自动删除记录,也不自行替业务层重新创建或裁决任务。

created、reused、conflict:把重试结果变成机器语义

dispositionaction_taken含义
createdtrue本次调用完成首次创建
reusedfalse对应创建结果已经存在,返回第一次的权威任务身份
conflictfalse同一提交身份与已有摘要或恢复事实冲突,没有新增 TASK

action_taken=false 并不必然意味着请求失败。在 reused 场景里,它恰好说明 Runtime 没有再执行第二次创建,却仍然找回了第一次的成功结果。

这里的“成功”只表示任务对象创建结果被恢复,不代表任务已经执行完成、QA 已通过或业务已经交付。技术幂等不能替代上层治理状态。

兼容路径也没有被包装成更强保证。旧调用方不提供 client_submission_id 时,仍然可以使用原有 legacy 创建方式;但这种调用不会被宣称拥有跨响应丢失、跨进程的强幂等语义。

5. 独立 QA:丢响应和八路并发后只剩一张 TASK

实现完成后,未参与实现的 QA 独立执行响应丢失和并发场景。验收关注的是实际 TASK 数量、task_id 是否一致,以及创建回执是什么。

场景独立 QA 实际观察
A2:创建成功后模拟响应未收到,再用同一提交身份重试TASK 数为 1;前后 task_id 相同;第二次返回 reused / action_taken=false
A4:8 路并发使用同一提交 ID 和同一摘要created=1 / reused=7;唯一 task_id;TASK 数为 1

进程重启后的结果复用、同 ID 异摘要冲突、legacy 返回兼容、中间态文件缺失与 stale reservation 诊断另有定向测试。

这里必须保留证据边界:A2/A4 的独立 QA 证明的是受测任务创建合同在两个关键故障模型中收敛,不等于“所有工具、所有 Host、所有网络环境都已经被独立验证”。

但对本次工程问题而言,前后差异已经足够明确:

历史受测版本中,同一创建意图在响应丢失后可以产生两张 TASK;V2.1.2 中,提供稳定提交 ID 和一致摘要的受测重试、并发与重启路径会收敛到同一个权威任务身份。

6. 已经交付的版本,以及仍未证明的范围

V2.1.2 于 2026-08-30 完成私有母版 Runtime/Shell 发布。公开文章不直接链接私有 CodeFlowMu 仓库;对外可核查的版本事实、脱敏 QA 结果与范围说明统一保留在本文的公开证据页。

最终发布验证记录为:

  • Runtime:1842 pass / 0 fail / 1 skip;
  • Shell:1037 pass / 0 fail / 0 skip;
  • V2.1.1 与 V2.1.2 同协议关键矩阵分别连续 10 轮通过:Runtime 1630/1630、Shell 550/550;
  • typecheck、Shell build、安装器契约、规则和版本一致性通过。

这些数字是发布测试集合的实际结果,不应该被相加成所谓“幂等可靠率”。

本次发布对象是 CodeFlowMu 私有母版 Runtime/Shell,不是 Open Dev Team Edition,也不代表真实 LAN/Gateway、浏览器 profile 或用户生产项目已经被同一套夹具覆盖。Windows 符号链接权限导致的 1 项 skip、既有依赖审计告警以及 Python 定向夹具 warning 继续保留在证据说明中。

7. 把实验迁移到自己的写接口

可以迁移到其他 Agent Runtime 的检查方法

对每一个会产生副作用的工具,至少检查:

  1. 副作用已经落盘、响应丢失后,进程重启能否找回第一次结果?
  2. 相同稳定 ID、相同请求是结果复用,还是再次执行?
  3. 相同 ID、不同请求是否明确冲突,并保证零新增副作用?
  4. 并发请求的“检查 + 占位 + 创建”是否真的受到一致并发保护?
  5. 中间状态能否沿原身份恢复,而不是靠另建对象逃避不确定性?
  6. 旧调用是否保持兼容,同时没有被错误宣传为拥有同等级别的幂等保证?
  7. 已经正确的其他工具路径是否保持原有保护,没有在统一改造中退化?

如果一个 Runtime 只能回答“失败了就再试一次”,它还没有真正定义副作用边界。

更可靠的答案应该是:每一次可重试的业务意图,都有一个能够跨响应丢失、并发和进程恢复继续存在的持久身份。

证据范围与主要来源

  • 历史实验、V2.1.2 工程更新与公开版本说明:公开 JSON fixture、Reader 与 check 用于验证冻结历史材料的一致性;公开页同时给出脱敏实现、独立 QA、发布门禁和残余风险说明。
  • LlamaIndex PR #22841:提供外部故障模型与调用形式修复背景,不作为 CodeFlowMu 的实现或验收证据。
  • CodeFlowMu V2.1.2 的实现、独立 QA 与发布原始日志位于受限私有母版中;本文不向公共读者提供不可访问的私有仓库链接。
  • 真实生产发生频率、全部操作系统、真实 LAN/Gateway 与用户生产项目不在本文证据覆盖范围内。

版本说明:中文原文 · English version。

研究主页:数字员工工场。

你的写接口在“副作用已落盘、回执未返回”处被终止后,重启重试能找回原对象,还是会再创建一次?