ActionExecutor 的契约没说抛异常时会怎样,而库这一侧不捕获 #2
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
我们是谁
dissect2 —— dissect 的第二版,正在重建,要接 PolyLoop 当执行内核,会是第一个真实使用者。我们只提需求、不改这个仓库。行号都核过了。
问题
ActionExecutor的契约没说清楚实现方抛异常时会怎样,而库这一侧也不捕获。对照一下隔壁那个接缝。
src/polyloop/ports/__init__.py:188-199,DecisionParser明明白白写了:这段论证很好,而且它同样适用于动作执行。但
src/polyloop/ports/__init__.py:205-224的ActionExecutor契约通篇没提异常——只讲了「动作本身报错算已执行,不算环境故障」,那讲的是返回值该怎么填,不是抛出时会怎样。库这边也没有兜。
src/polyloop/session/__init__.py:679:这一行外面没有
try。后果
执行器抛异常时,异常直接穿出
run()。这一步的StepRecord不写,RunFinished也不写。于是日志里留下一份没有结束标记的运行。下次
resume读到它,会判成「这次运行还没跑完,可以接着跑」——而实际上它是崩在一个实现 bug 上的。对我们的具体影响:我们的硬需求是「环境故障那一步也必须记」,因为模型调用已经成功、钱已经花了、账已经记了。丢掉那一步会让账目与轨迹对不上,而且会丢掉模型在出故障那一步说了什么——排查「是环境坏了还是模型写了危险代码」最需要的就是这段原文。
正常路径你们是满足这条的(执行器返回
status=ENV_ERROR时先无条件写步记录,再以ENV_ERROR收尾)。缺的只是抛出这条路径。绕过去的代价
我们在自己的
Episode包装类里把整个execute包成 catch-all。我们不想这么做,因为我们自己的协作约定明确禁止这种形状(
except Exception兜底把问题盖过去),而且这样一来「环境真的故障」和「我们的包装类有 bug」在数据里就分不开了。一个可能的做法(不强加)
两条路,选哪条你们定:
DecisionParser对齐,理由可以直接复用那一段。这样责任在实现方,我们照做。ENV_ERROR,保证步记录和RunFinished一定落地。这样即使实现方有 bug,日志也是自洽的。我们倾向 2,理由是它对「日志一定自洽」这件事的保证更硬——但这是你们的设计权衡,1 也完全说得通。
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里。