Files
PolyLoop/research-wiki/design/0016-action-executor-failure.md
iomgaa e701563a0b docs(design): 落定第一个下游提的五个缺口的三份方案
0014 契约套件怎么发给下游、0015 参数快照的内容契约、0016 动作执行接缝抛异常时的契约。
三份都过了 CLAUDE.md §3 的硕士生冷读,冷读抓到的八处「在讲文档自己」的句子、五处缺前置
知识、三处只写结论没写理由、三处参数两地取值不同,全部采纳。

0016 否掉了提 issue 那一方倾向的方案(库捕获执行器异常转 ENV_ERROR)。三层理由:日志
「不自洽」这个前提本身不成立——异常抛出时副作用状态未知,日志停在「动作意图有、结果无」
正是照实记录;库替它写一条步记录反而是在编造,而那条记录会让恢复把未知状态抹掉;最后
它会把执行器里一个 AttributeError 变成一批环境故障,run() 照常返回正常结果,调用方拿不到
任何异常。
2026-08-27 03:58:08 -04:00

13 KiB
Raw Permalink Blame History

Design 0016 · 动作执行接缝抛异常时的契约

日期 2026-08-26 · 状态 已接受(2026-08-26 项目负责人确认)

回答 实验室 Gitea 上 PolyLoop 仓库的 issue #2,标题是「ActionExecutor 的契约没说抛异常时 会怎样,而库这一侧不捕获」,提出者是下游项目 dissect2。

补充 0007-seam-behaviour.md 决策一。那一条定的是三个动作状态各自在什么条件下被赋上, 说的全是协议之内的事;本文往下定协议之外那条路——实现方不返回结果、直接抛出时会怎样。 0007 的其余部分不受影响。

触及 ../../src/polyloop/ports/__init__.pyActionExecutor 的 docstring、 ../../tests/contract/ 里动作执行接缝那一份,以及 ../../tests/unit/test_session.py。要回写的 是决策一那三条正面表述:docstring 补上「环境故障走返回值、抛出的异常库不接管」,契约那份补 一条不带断言的说明,指向库这一侧真正断言它的地方,而那条断言本身加在 tests/unit/test_session.py 里(见文末)。不回写这三处,本文就是死的——写适配器的人读的是 ActionExecutor 的 docstring,而它现在通篇不提异常。

背景

issue #2 指出的事实逐条核过都成立。

ActionExecutor 的 docstring 只写了一件事:动作本身报错算「已执行」,不算环境故障。那句话 管的是返回值里状态那一列该填什么。实现方如果干脆不返回、直接抛出,那句话一个字都没覆盖。 session 里调用执行器的那一行外面也确实没有 try_Driver._execute 写完动作意图就 await 执行器,拿到 ActionOutcome 往下走,异常原样穿出去。

对照之下,DecisionParser 的 docstring 明写了「不许抛异常」,并且写清了真抛了库也不接管、 以及为什么不接管。同一个模块里的两个接缝,一个把这件事写死了,另一个从没提过。所以这不是 「写得不够细」,是一处真实的契约空白。

issue 给了两条路,提出者倾向第二条:

  • 在契约里明写「不许抛异常」,环境故障一律走返回值;
  • 库捕获执行器抛出的异常,把它转成 ActionStatus.ENV_ERROR,这样那一步的步记录和这次运行 的结束记录一定会落地。

倾向第二条的理由是:它对「日志一定自洽」这件事的保证更硬。

契约按第一条定,并写得比 issue 那句话更精确。第二条被否,理由在决策二。

决策一:环境故障走返回值,异常穿出不被库接管

契约的正面表述有三条。

环境自己坏了——连不上、协议不对、开好的会话没了——执行器返回 ActionStatus.ENV_ERROR 不要以异常表达。

实现方真的抛出了异常,库不捕获,异常原样穿出 run()resume()。调用方拿到的是那个异常 本身,不是一个正常返回的运行结果。

asyncio.CancelledError 必须原样穿过,不许捕获吞没。这一条来自 ../../CLAUDE.md §1.6,对 每一个执行器实现都成立,契约套件里已经有它的断言。这里重复一遍,是因为上一条读快了容易读成 「异常一概不用管」,而取消恰恰是那个必须管的异常——管的方式是让它穿过去,并且在 finally 里把 in-flight 资源放掉。

这条契约划出的责任分界是:把可预期的环境异常翻译成 ENV_ERROR 是适配器的正常工作,不是 catch-all。一个 HTTP 客户端的连接超时、一个容器会话的「会话已关闭」,适配器知道这些异常长 什么样,也知道它们意味着环境不能接着服务了,捕获它们并返回 ENV_ERROR 是在履行契约。剩下 的那些——实现方自己都没预料到的异常——是 bug,让它穿出去。

这条契约拦不住存心的实现方,代价认下来。 一个执行器完全可以在自己的 execute 外面套一层 except Exception: return ENV_ERROR,产生的效果和被否掉的方案二一模一样,只是发生在下游而不是 库里。库这一侧分不出这两者——它收到的都是一个填好了状态的 ActionOutcome,看不出那个状态是 判出来的还是兜出来的,也没有任何机器手段能拦。

这样仍然比方案二好,差的不是能不能拦住,是谁知道自己做了这个选择。 下游那么写,是它在 自己的仓库里、对自己的数据做的一次决定;它知道自己这么做了,出问题时它查得到那一行。库那么 写,是替所有下游做了同一个决定,而且没有任何一个下游有机会知道——它们只会看到一批 ENV_ERROR,然后去查环境,查一个根本没坏的环境。所以契约保证的不是「不可能被绕过」,是 默认行为是对的:一个照着契约写、没有多套一层的实现,它的 bug 不会被伪装成环境故障。

决策二:为什么不采纳「库捕获并转成 ENV_ERROR

第一层:「日志不自洽」这个前提本身不成立

执行器抛异常时,日志里的状态是明确的:这一步的模型调用意图有、模型调用结果有、动作意图有、 步记录没有。

_recovery 判一次执行处在哪一态时只看两个维度。「一次执行」指一次模型调用,或者一次动作 执行;两个维度是「它的意图写进日志了没有」与「它的结果写进日志了没有」。四种组合各有一个 含义(0002-step-level-resume.md 决策二):

意图 结果 含义 恢复做什么
还没开始 重跑这一步
执行完了 跳过
状态未知 按那条意图上记着的重放策略决定
结构上说不通 判为日志损坏,拒绝续跑

执行器抛异常落在第三行。plan_resume 按那条动作意图上记着的重放策略分岔:声明为「可安全 重放」的给出 ResumeAction.REPLAY_LAST_ACTION,续跑时重新解释那条已经存下来的模型回复,再 执行一次动作;否则给出 ResumeAction.STOP_UNKNOWNresume() 拿着它走收尾,以 StopReason.RESUME_STATE_UNKNOWN 结束这次运行。

那条重放策略的值来自工具规格——给库注册一个工具时连同名字、描述、参数 schema 一起声明的 那份说明,其中一项就是「这个工具重复执行一次是否无害」。有一类动作问不出规格:模型输出的是 一整段代码而不是一次工具调用,没有工具名可查。这一类一律取「绝不重放」。

这不是误判。执行器抛异常之后,副作用到底发生没发生本来就是未知的——异常可能来自环境返回的 错误,也可能来自适配器在拿到结果之后的一行代码,从库这一侧看不出区别。这和进程崩在动作执行 中途是同一种状态,也正是四态表那一档要描述的东西。日志现在记的就是事实。

第二层:库替它写一条步记录反而是在编造

StepCompleted 有一条构造期不变量:result_id 为空当且仅当 action_outcome 也为空。要给 出异常的这一步写一条步记录,就必须同时编一个 ActionOutcome 出来——状态、观察、完成信号、 截断字符数,四个字段全都是库现造的,没有一个来自执行器。

而一条带着动作结果的完整步记录,恢复读到的是「上一步走完了」,于是接着往下跑,那个未知状态 就被抹掉了。这比不写更糟:不写只是少一条记录,日志停在「意图有、结果无」,恢复照实判成未知; 写了是把「不知道」改写成「知道,而且是这个值」,而后面每一步都建立在这个值上。

第三层:它把实现方的 bug 伪装成环境故障,然后送进下游的统计

执行器里一个 AttributeError 会被转成 ActionStatus.ENV_ERROR,这次运行以 StopReason.ENV_ERROR 正常收尾,run() 返回一个正常的运行结果,调用方拿不到任何异常。一个 包装类的 bug 就这样变成了「这批实验里若干次运行环境故障」。

这正是 DecisionParser 那段「不许抛」论证反对的事:库接住一个不属于协议的异常,就得给它编 一个停止原因,而任何一个编出来的原因都会把实现的 bug 伪装成「这次运行以某某原因结束」。提 issue 的下游自己也说了不想要「环境真的故障」和「我们的包装类有 bug」在数据里分不开,而方案二 正好造成这个后果。

决策三:模型调用接缝捕获、动作执行接缝不捕获,这个不对称从哪来

_call_model 里捕获了 Exception,写一条失败的模型调用结果记录,然后以 StopReason.LLM_ERROR 收尾;_execute 里什么都不捕获。同一份代码里两个接缝待遇相反,这不是 疏忽,有两条理由。

两个接缝的契约对「失败怎么表达」的规定正好相反。 ModelClient 的契约规定失败必须以 异常表达,不许返回一个内容为空的正常回复——后者会让库没有任何办法把基础设施故障和「模型真的 回了空字符串」分开,而这两者在分析里属于完全不同的类别。所以库捕获 ModelClient 抛出的东西, 处理的是协议内的正常路径:那个异常就是契约规定的失败表达方式。ActionExecutor 的契约 规定环境故障必须ENV_ERROR 返回值表达,于是抛异常落在协议之外,库不接管。

库能不能确定地知道这一步该结算成什么。 模型调用抛出异常意味着没拿到回复,这一点是确定的: 本库要为这一步结算的问题是「有没有一条模型回复可用」,而这个问题有确定答案。所以库写下一条 失败的模型调用结果,记的是事实,恢复读到它也不会再去猜。钱花没花是 PolyGateway 那一层的账, 不是本库要结算的东西,而且「花了钱却没有留痕」这件事不会发生——那条 ModelCallResult 一定会 落地,failure 字段说明这次失败的形态,对账要用的东西都在那儿。

动作执行抛出异常意味着环境状态未知。副作用发生了没有、发生了多少,库无从知道,日志里也没有 任何一处记着,所以库没有资格替它结算成任何一个具体的值。不写恰好是照实:日志停在「动作意图 有、步记录无」,恢复照四态表把它读成未知。

决策四:提 issue 的下游实际要做的事

他们的硬需求有两条:环境故障那一步也必须记,因为那一步的模型调用已经成功、钱已经花了、账已经 在网关那边记了;以及丢掉那一步会连带丢掉模型在出故障那一步说了什么。

在返回 ENV_ERROR 那条路上,这个需求已经满足了。_loop 拿到执行结果之后无条件写一条 步记录,然后才做完成判定并以 StopReason.ENV_ERROR 收尾——写步在前、判停在后,环境故障那一步 和别的步一样有完整记录。

在抛异常那条路上,这一步的步记录确实没有,但账目没有丢:_call_model 是在拿到回复之后立刻 写模型调用结果记录的,写完才返回,而执行器要等解释完决策之后才被调用。所以异常抛出时,那条 模型调用结果早已经在日志里,「模型说了什么」从 RunLog.model_results 里读得到。丢的只是那一 步的步记录,而步记录本来就是「这一步走完了」的标记。

他们担心的另一件事——「要在自己的包装类里写 catch-all,而我们的协作约定禁止这种形状」——也 不成立。捕获具体的环境异常(连接失败、超时、会话已关闭)并返回 ENV_ERROR,捕获的是有名有姓 的几个异常类型,这不是 catch-all,../../CLAUDE.md §1.7 禁的是吞掉错误,而这里错误没有被吞: 它变成了一个明确的状态值,还带着给模型看的观察文本。

留给后续的

ModelClient 抛出的实现方 bug 会被记成 StopReason.LLM_ERROR 适配器里的一个 AttributeError 和模型网关真的连不上,在日志里长得一模一样。这是既定设计的已知代价,暂不 动:那条路的正确性建立在「库能确定地知道这一步该结算成什么」上,不是建立在「能分辨 bug 和真 故障」上。要改的话得先想清楚库凭什么区分这两者,而不是在 _call_model 里多加几个 except 分支。

契约套件验不了「实现方在环境故障时返回 ENV_ERROR 而不是抛异常」。 这套件面对的是一个 任意实现,没有办法逼它进入环境故障——真去把它的网络掐掉既不可移植,也会把那些根本没有网络的 合法实现判成不合格。这和三个状态的触发条件验不到那一层是同一个原因,那条已经作为一段不带 断言的说明留在动作执行接缝的契约文件里,本文这一条按同样的口径写。

能验的是库这一侧,也必须验:执行器抛出一个普通异常时,那个异常穿出 run(),且日志停在 「动作意图有、步记录无」。这条断言落在 ../../tests/unit/test_session.py——那里现在还没有它, 是本文落地时要加的。