ActionExecutor 的契约没说抛异常时会怎样,而库这一侧不捕获 #2

Closed
opened 2026-08-26 19:57:00 +08:00 by iomgaa · 1 comment
Owner

我们是谁

dissect2 —— dissect 的第二版,正在重建,要接 PolyLoop 当执行内核,会是第一个真实使用者。我们只提需求、不改这个仓库。行号都核过了。

问题

ActionExecutor 的契约没说清楚实现方抛异常时会怎样,而库这一侧也不捕获。

对照一下隔壁那个接缝。src/polyloop/ports/__init__.py:188-199DecisionParser 明明白白写了:

不许抛异常:对任何输入都要返回一个 ParsedReply,解释不出来就走 InvalidDecision 那一支……真抛了库也不接管:接住就得给它编一个停止原因,而编出来的原因会把「解释器有 bug」伪装成「这次运行以某某原因结束」,然后进下游的统计。

这段论证很好,而且它同样适用于动作执行。但 src/polyloop/ports/__init__.py:205-224ActionExecutor 契约通篇没提异常——只讲了「动作本身报错算已执行,不算环境故障」,那讲的是返回值该怎么填,不是抛出时会怎样。

库这边也没有兜。src/polyloop/session/__init__.py:679

outcome = await self._request.action_executor.execute(action)

这一行外面没有 try

后果

执行器抛异常时,异常直接穿出 run()。这一步的 StepRecord 不写,RunFinished 也不写。

于是日志里留下一份没有结束标记的运行。下次 resume 读到它,会判成「这次运行还没跑完,可以接着跑」——而实际上它是崩在一个实现 bug 上的。

对我们的具体影响:我们的硬需求是「环境故障那一步也必须记」,因为模型调用已经成功、钱已经花了、账已经记了。丢掉那一步会让账目与轨迹对不上,而且会丢掉模型在出故障那一步说了什么——排查「是环境坏了还是模型写了危险代码」最需要的就是这段原文。

正常路径你们是满足这条的(执行器返回 status=ENV_ERROR 时先无条件写步记录,再以 ENV_ERROR 收尾)。缺的只是抛出这条路径。

绕过去的代价

我们在自己的 Episode 包装类里把整个 execute 包成 catch-all。

我们不想这么做,因为我们自己的协作约定明确禁止这种形状(except Exception 兜底把问题盖过去),而且这样一来「环境真的故障」和「我们的包装类有 bug」在数据里就分不开了。

一个可能的做法(不强加)

两条路,选哪条你们定:

  1. 在契约里明写「不许抛异常」,和 DecisionParser 对齐,理由可以直接复用那一段。这样责任在实现方,我们照做。
  2. 库捕获并转成 ENV_ERROR,保证步记录和 RunFinished 一定落地。这样即使实现方有 bug,日志也是自洽的。

我们倾向 2,理由是它对「日志一定自洽」这件事的保证更硬——但这是你们的设计权衡,1 也完全说得通。

## 我们是谁 dissect2 —— dissect 的第二版,正在重建,要接 PolyLoop 当执行内核,会是第一个真实使用者。我们只提需求、不改这个仓库。行号都核过了。 ## 问题 `ActionExecutor` 的契约没说清楚实现方抛异常时会怎样,而库这一侧也不捕获。 对照一下隔壁那个接缝。`src/polyloop/ports/__init__.py:188-199`,`DecisionParser` 明明白白写了: > **不许抛异常**:对任何输入都要返回一个 `ParsedReply`,解释不出来就走 `InvalidDecision` 那一支……真抛了库也不接管:接住就得给它编一个停止原因,而编出来的原因会把「解释器有 bug」伪装成「这次运行以某某原因结束」,然后进下游的统计。 这段论证很好,而且它同样适用于动作执行。但 `src/polyloop/ports/__init__.py:205-224` 的 `ActionExecutor` 契约通篇没提异常——只讲了「动作本身报错算已执行,不算环境故障」,那讲的是**返回值**该怎么填,不是**抛出**时会怎样。 库这边也没有兜。`src/polyloop/session/__init__.py:679`: ```python outcome = await self._request.action_executor.execute(action) ``` 这一行外面没有 `try`。 ## 后果 执行器抛异常时,异常直接穿出 `run()`。这一步的 `StepRecord` 不写,`RunFinished` 也不写。 于是日志里留下一份**没有结束标记的运行**。下次 `resume` 读到它,会判成「这次运行还没跑完,可以接着跑」——而实际上它是崩在一个实现 bug 上的。 对我们的具体影响:我们的硬需求是「环境故障那一步也必须记」,因为模型调用已经成功、钱已经花了、账已经记了。丢掉那一步会让账目与轨迹对不上,而且会丢掉模型在出故障那一步说了什么——排查「是环境坏了还是模型写了危险代码」最需要的就是这段原文。 正常路径你们是满足这条的(执行器返回 `status=ENV_ERROR` 时先无条件写步记录,再以 `ENV_ERROR` 收尾)。缺的只是抛出这条路径。 ## 绕过去的代价 我们在自己的 `Episode` 包装类里把整个 `execute` 包成 catch-all。 我们不想这么做,因为我们自己的协作约定明确禁止这种形状(`except Exception` 兜底把问题盖过去),而且这样一来「环境真的故障」和「我们的包装类有 bug」在数据里就分不开了。 ## 一个可能的做法(不强加) 两条路,选哪条你们定: 1. **在契约里明写「不许抛异常」**,和 `DecisionParser` 对齐,理由可以直接复用那一段。这样责任在实现方,我们照做。 2. **库捕获并转成 `ENV_ERROR`**,保证步记录和 `RunFinished` 一定落地。这样即使实现方有 bug,日志也是自洽的。 我们倾向 2,理由是它对「日志一定自洽」这件事的保证更硬——但这是你们的设计权衡,1 也完全说得通。
Author
Owner

1.0.2 已发布,契约写进 ActionExecutor 的 docstring 了。

但我们采纳的是你们提的方案一,不是你们倾向的方案二。 这是五条里唯一一处我们和你们判断不一致的,所以理由写详细些。

契约定成什么

三条:环境自己坏了(连不上、协议不对、会话没了)返回 ActionStatus.ENV_ERROR,不要以异常表达;实现方真抛出来的异常库不捕获,原样穿出 run()resume()asyncio.CancelledError 必须原样穿过。

为什么不采纳「库捕获转 ENV_ERROR」

第一层:「日志不自洽」这个前提本身不成立。 执行器抛异常时,日志停在「动作意图已写、动作结果没写」。恢复判定读到这个状态判成状态未知,按重放策略处置。这不是误判——执行器抛异常之后,副作用到底发生没发生本来就是未知的,和进程崩在动作执行中途是同一种状态。日志记的就是事实。

第二层:库替它写一条步记录反而是在编造。 StepCompleted 有一条构造期不变量(result_id 为空当且仅当 action_outcome 为空),要给这一步写记录就得编一个 ActionOutcome 出来。而一条带着动作结果的完整步记录,恢复会读成「上一步走完了」然后接着往下跑——那个未知状态就被抹掉了。不写只是少一条记录,写了是把「不知道」改写成「知道,而且是这个值」。

第三层,也是对你们影响最直接的:它把实现方的 bug 伪装成环境故障,送进你们的统计。 你们包装类里一个 AttributeError 会被转成 ENV_ERROR,运行以 StopReason.ENV_ERROR 正常收尾,run() 返回一个正常的 RunResult调用方拿不到任何异常。一个包装类的 bug 就这样变成「这批实验里若干次运行环境故障」。你们自己在 issue 里说不想要「环境真的故障」和「我们的包装类有 bug」在数据里分不开——方案二正好造成这个后果,而且是替所有下游做的,没有任何一个下游有机会知道。

你们的硬需求已经满足了

「环境故障那一步也必须记,因为模型调用已经成功、钱已经花了」——模型调用结果那条记录在异常抛出之前就已经写进日志了。所以「模型在出故障那一步说了什么」在 RunLog.model_results 里读得到,账没有丢。这一点我们核过写入顺序才敢这么说。

你们实际要做的,比方案二还轻

在 AppWorld 包装类里捕获具体的环境异常(连接失败、超时、会话已关闭),转成 ENV_ERROR 返回。那不是 catch-all,那是适配器的正常工作——错误没有被吞,它变成了一个明确的状态值。剩下你们自己没预料到的异常让它穿出去,那正是你们想要的:bug 不被伪装。

一处我们认下来的代价

这条契约拦不住存心的实现方——你们完全可以在 execute 外面套一层 except Exception: return ENV_ERROR,效果和方案二一样,而库这一侧分不出来。差的不是能不能拦住,是谁知道自己做了这个选择:你们那么写,是在自己的仓库里对自己的数据做的决定,出问题时查得到那一行;库那么写,是替所有人做了这个决定。

完整论证在 research-wiki/design/0016-action-executor-failure.md。库这一侧的三条断言(异常穿出 run()、日志停在「意图有结果无」、resume 判成 RESUME_STATE_UNKNOWN)在 tests/unit/test_session.py 里。

**1.0.2 已发布**,契约写进 `ActionExecutor` 的 docstring 了。 **但我们采纳的是你们提的方案一,不是你们倾向的方案二。** 这是五条里唯一一处我们和你们判断不一致的,所以理由写详细些。 ## 契约定成什么 三条:环境自己坏了(连不上、协议不对、会话没了)返回 `ActionStatus.ENV_ERROR`,不要以异常表达;实现方真抛出来的异常**库不捕获**,原样穿出 `run()` 与 `resume()`;`asyncio.CancelledError` 必须原样穿过。 ## 为什么不采纳「库捕获转 ENV_ERROR」 **第一层:「日志不自洽」这个前提本身不成立。** 执行器抛异常时,日志停在「动作意图已写、动作结果没写」。恢复判定读到这个状态判成**状态未知**,按重放策略处置。这不是误判——执行器抛异常之后,副作用到底发生没发生本来就是未知的,和进程崩在动作执行中途是同一种状态。日志记的就是事实。 **第二层:库替它写一条步记录反而是在编造。** `StepCompleted` 有一条构造期不变量(`result_id` 为空当且仅当 `action_outcome` 为空),要给这一步写记录就得编一个 `ActionOutcome` 出来。而一条带着动作结果的完整步记录,恢复会读成「上一步走完了」然后接着往下跑——**那个未知状态就被抹掉了**。不写只是少一条记录,写了是把「不知道」改写成「知道,而且是这个值」。 **第三层,也是对你们影响最直接的:它把实现方的 bug 伪装成环境故障,送进你们的统计。** 你们包装类里一个 `AttributeError` 会被转成 `ENV_ERROR`,运行以 `StopReason.ENV_ERROR` 正常收尾,`run()` 返回一个正常的 `RunResult`,**调用方拿不到任何异常**。一个包装类的 bug 就这样变成「这批实验里若干次运行环境故障」。你们自己在 issue 里说不想要「环境真的故障」和「我们的包装类有 bug」在数据里分不开——方案二正好造成这个后果,而且是替所有下游做的,没有任何一个下游有机会知道。 ## 你们的硬需求已经满足了 「环境故障那一步也必须记,因为模型调用已经成功、钱已经花了」——**模型调用结果那条记录在异常抛出之前就已经写进日志了**。所以「模型在出故障那一步说了什么」在 `RunLog.model_results` 里读得到,账没有丢。这一点我们核过写入顺序才敢这么说。 ## 你们实际要做的,比方案二还轻 在 AppWorld 包装类里捕获**具体的**环境异常(连接失败、超时、会话已关闭),转成 `ENV_ERROR` 返回。**那不是 catch-all,那是适配器的正常工作**——错误没有被吞,它变成了一个明确的状态值。剩下你们自己没预料到的异常让它穿出去,那正是你们想要的:bug 不被伪装。 ## 一处我们认下来的代价 这条契约拦不住存心的实现方——你们完全可以在 `execute` 外面套一层 `except Exception: return ENV_ERROR`,效果和方案二一样,而库这一侧分不出来。差的不是能不能拦住,是**谁知道自己做了这个选择**:你们那么写,是在自己的仓库里对自己的数据做的决定,出问题时查得到那一行;库那么写,是替所有人做了这个决定。 完整论证在 `research-wiki/design/0016-action-executor-failure.md`。库这一侧的三条断言(异常穿出 `run()`、日志停在「意图有结果无」、`resume` 判成 `RESUME_STATE_UNKNOWN`)在 `tests/unit/test_session.py` 里。
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: iomgaa/PolyLoop#2