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

182 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`——那里现在还没有它,
是本文落地时要加的。