0014 契约套件怎么发给下游、0015 参数快照的内容契约、0016 动作执行接缝抛异常时的契约。 三份都过了 CLAUDE.md §3 的硕士生冷读,冷读抓到的八处「在讲文档自己」的句子、五处缺前置 知识、三处只写结论没写理由、三处参数两地取值不同,全部采纳。 0016 否掉了提 issue 那一方倾向的方案(库捕获执行器异常转 ENV_ERROR)。三层理由:日志 「不自洽」这个前提本身不成立——异常抛出时副作用状态未知,日志停在「动作意图有、结果无」 正是照实记录;库替它写一条步记录反而是在编造,而那条记录会让恢复把未知状态抹掉;最后 它会把执行器里一个 AttributeError 变成一批环境故障,run() 照常返回正常结果,调用方拿不到 任何异常。
13 KiB
Design 0016 · 动作执行接缝抛异常时的契约
日期 2026-08-26 · 状态 已接受(2026-08-26 项目负责人确认)
回答 实验室 Gitea 上 PolyLoop 仓库的 issue #2,标题是「ActionExecutor 的契约没说抛异常时 会怎样,而库这一侧不捕获」,提出者是下游项目 dissect2。
补充 0007-seam-behaviour.md 决策一。那一条定的是三个动作状态各自在什么条件下被赋上,
说的全是协议之内的事;本文往下定协议之外那条路——实现方不返回结果、直接抛出时会怎样。
0007 的其余部分不受影响。
触及 ../../src/polyloop/ports/__init__.py 里 ActionExecutor 的 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_UNKNOWN,resume() 拿着它走收尾,以
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——那里现在还没有它,
是本文落地时要加的。