fix(operation): reject stale executor revisions pre-persist and keep typed delivery blockers - #4398
Conversation
…typed delivery blockers prepare-operation --execute persisted a proposal whose requested executor revision did not match the active extension revision; deliver-operation rejected it later, leaving an unreachable gated proposal without a card. prepare now resolves the declared executor capability, protocol, and permission against the active binding in preview and execute modes before any durable proposal or idempotency entry exists. A revision mismatch raises OperationExecutorDriftError carrying the requested and active revisions; an unresolvable binding raises a typed executor_unavailable blocker whose summary never leaks the private resolver text. The Goal Channel CLI collapsed every stage-specific failure into invalid_configuration or provider_api_failed. Delivery stages now raise GoalChannelDeliveryStageError - a ValueError subtype so existing handlers keep working - carrying a stable public-safe blocker, the failure stage, and the provider write state: sender identity verification, dedupe history read, provider send rejection, and binding drift are each distinguishable, and all are known not-performed outcomes. A failure after the provider write (exact readback, receipt recording) is projected as delivery_outcome_unknown: the receipt reports external_write_performed=true with an external_write_outcome=unknown detail instead of claiming a clean not-performed run, and the public packet remains free of private provider detail. Tests cover stale-revision rejection before persistence in both modes, private-text-free resolution failures, typed stage blockers with no provider write at the identity, dedupe, and send stages, the unknown post-send receipt, and the CLI projection of the typed packet. Fixes loopx-project#4366 Signed-off-by: BigDataDZ <76271875+BigDataDZ@users.noreply.github.com>
huangruiteng
left a comment
There was a problem hiding this comment.
评审范围
- PR:
fix(operation): reject stale executor revisions pre-persist and keep typed delivery blockers - 作者:@BigDataDZ
- 评审 head:
2cf82db039d8a6a1bdb318a62dbf038fe04ddfea(未变化,updatedAt2026-09-14T16:48:54Z) - 变更面:3 个生产文件(+214/-32)、2 个测试文件(+360),另有 1 个 1331 行的
uv.lock
动机
main 上有两个彼此相关、但危害不同的缺口。
第一,executor revision 漂移只在投递时才被检查。运维用声明 revision=requested-v1 的请求体 prepare 时,即使当前生效的是 active-v9,prepare 依然会成功,并把提案写进 actions.json 的 proposals 与 idempotency。这条记录此后没有可达的消费路径:提案停在 awaiting_confirmation,人工点确认必定失败;更麻烦的是它占用了 idempotency key,用同一个 key 重试只会拿回这条不可执行的记录。这是一个“先落盘、后失败”的顺序问题。
第二,投递各阶段的失败只能给出很粗的结论。所有失败都抛裸 ValueError,CLI 侧把它们统一映射成 invalid_configuration 或 provider_api_failed,并且 external_write_performed 恒为 False。于是运维看到失败回执时,无法区分“卡片确实没发出去、可以放心重发”和“卡片可能已经发出去了、应该去群里对账”。
本 PR 同时处理这两点,并且明确写下了自己的核心不变量:未知的 provider 结果不得被投影成“干净地没有发生写入”。这个不变量是本次评审的主要检验对象。
改动思路
改动把两处知识各自收敛到了一个 owner,方向是对的。
漂移判定从 deliver_goal_channel_operation_card 的内联比较抽成 confirmed_operation_executor,由 prepare 与 deliver 共用。prepare 侧的新校验发生在构造 preview/apply 之前,也就是在任何持久化写入之前,因此漂移不会留下任何状态;非 executor 类 operation 因为 isinstance(parameters.get("executor"), Mapping) 为假而完全跳过这段逻辑,行为与 main 一致。这正好符合仓库“消除重复权威、不要两处各持一份判定”的约束:此后要改漂移规则,只需要改一个函数。
投递侧新增 GoalChannelDeliveryStageError(ValueError),携带 blocker、failure_stage 和 external_write_performed: bool | None 三态。None 表达“结果未知”,并约定投影时必须按已写入处理。它继承 ValueError,所以即使某个调用点没有专门捕获,也仍然落回既有的 except ValueError 分支,不会退化成未处理异常。CLI 侧的 _error_packet 相应参数化,并在 external_write_performed is None 时输出 True 与 details.external_write_outcome="unknown"。这套三态设计比原来的布尔更准确,也把分类规则放进了类型化异常而不是散落的布尔判断。
关键代码讲解
goal_channel_operation.py::confirmed_operation_executor(:845) —— prepare 与 deliver 的唯一漂移判定点。resolver 自身失败时映射为 executor_unavailable,且只透出通用文案,不透出私有 resolver 文本;revision 不等时抛 OperationExecutorDriftError(blocker="executor_revision_drift", failure_stage="resolve_executor_binding", details={requested,active})。details 里只有两个不透明 revision 字符串,是公开安全的。新增测试断言 preview 与 execute 两条路径都抛该异常,且 actions.json 的 proposals 与 idempotency 保持为空——这条断言直接把“前置”而不是“更早报错”钉住了。
goal_channel_message_delivery.py::GoalChannelDeliveryStageError(:32) —— 把 dedupe 读失败、history 不完整、binding 漂移、provider 拒绝、发送方身份未验证分别标成具名 blocker 与 stage。类文档字符串明确了三态语义与“摘要不得携带私有 provider 细节”的边界,这是本 PR 最有价值的部分。
goal_channel_operation.py 的 readback / record 两段(:487、:527) —— 在 provider 写入之后,把“回执读取失败”与“回执落盘失败”都保留为未知写入(external_write_performed=None),投影为 True。方向正确。
goal_channel.py::_error_packet(:260) —— 参数化 external_write_performed/failure_stage/details,并在未知写入时显式写 details.external_write_outcome="unknown",失败 packet 因此对运维可解释。
对主干的风险
阻塞项(P1):send 阶段把“没有 provider 结论”和“provider 明确拒绝”合并成同一个结论。
GoalChannelMessageDeliverySession.send 在 returncode != 0 or not message_id 时抛 provider_send_rejected,external_write_performed 取默认 False。但这条判定的输入类不止“provider 拒绝”:goal_channel_transport.call 把 OSError 与 subprocess.SubprocessError 一律折叠成 {"returncode": 1, "stdout": "", "stderr": ""},而 default_subprocess_runner 正是以 timeout=30 调用 subprocess.run,TimeoutExpired 属于 SubprocessError。也就是说,lark CLI 超时被 kill、或进程因本地原因退出,都会走同一条分支,得到 provider_send_rejected + external_write_performed=false 的回执。
而此时 provider 完全可能已经接受了这次 POST——卡片已经躺在群里,确认人已经可以点。回执却断言“provider 拒绝且未发生写入”,恰好是本 PR 自己声明要消除的错误投影,而且发生在唯一真正产生外部写入的调用点上。实际的重复卡片风险被 provider 侧 --idempotency-key 与 _existing_message 的历史去重限制住了,所以这不是数据损坏级别的问题;但回执结论本身不真实,运维据此做出的“重发还是对账”判断失去依据。
更需要注意的是,新增测试 test_delivery_projects_typed_stage_blockers 的 send 子用例直接让假 runner 返回 {"returncode": 1, "stderr": "provider rejected"},等于把“provider 已明确拒绝”这个后置条件喂给了被测代码,并断言 external_write_performed is False。这类测试无法反驳“无 provider 结论”的输入类,还会把当前行为固化成期望行为。
最小修复(约 6 行,落在既有 owner 上):在 send 的失败判定里区分两类结果——有 provider 返回体且报文为拒绝时保持 provider_send_rejected;返回体缺失、timed_out 或异常折叠时改抛 GoalChannelDeliveryStageError(blocker="delivery_outcome_unknown", failure_stage="send_operation_card", external_write_performed=None)。同时把该用例补成两个子用例。顺带一提,call 的异常路径丢掉了 runner 已有的 timed_out 字段,保留它可以让这个区分更明确。
次要项(P2):分支内混入 1331 行无关的 uv.lock。
git log --all --oneline -- uv.lock 无输出,说明该文件在仓库全部历史中从未被跟踪;.gitignore 没有忽略它;.github/workflows/ 与 CONTRIBUTING.md 也都不引用 uv.lock/uv sync/uv lock/--frozen(CONTRIBUTING 的安装方式是 python -m pip install -e ".[test]")。文件内容是常规 pypi 元数据,没有私有路径或内部标识,所以不是隐私问题。但在一个 246 行生产+测试的行为修复里混入 1331 行锁文件,违反仓库“不要推送宽泛混合提交”的约定;更实际的影响是一旦合并,这个文件会变成事实上的依赖锁定源,而安装文档仍走 pip,两者容易对不上。建议从本分支移除,或拆成独立的锁文件策略 PR 并同步 CI 与安装文档。
次要项(P2):record_delivery_receipt 复用了 delivery_outcome_unknown,但该阶段写入结果是已证实的。
这层包裹位于 readback 校验通过之后——observed.verified is True 且 message_id/chat_id/sender_app_id 全部匹配,说明卡片确实发出去了。失败摘要仍写 “operation card delivery outcome is unknown after the provider write”,与事实不符。投影本身(None→True)是对的,所以不改变回执真值,但会让“未知写入”这个关键信号被本地落盘故障污染。建议给本地回执落盘失败单独的 stage/摘要。
已核对无问题的面:非 executor 类 operation 因 gate 为假而完全跳过新校验,成功 packet 与 actions.json 布局逐字段不变;failure_stage/details 均为可选字段,不改变 loopx_goal_channel_operation_v0 的必需字段集;新增 blocker 全部是领域中性的英文标识,没有把产品/基准词汇写进通用控制面契约;漂移判定是机器强制的(阻断写入并决定退出码),没有把硬性义务说成“建议”。exact head 上 25 项 CI 检查为 SUCCESS、4 项按条件 SKIPPED、无 FAILURE,Sign-off 与 merge-gate 均通过。
我的整体评价
方向正确、粒度和 owner 归属都合适:把重复的漂移判定收敛成一个共享函数并前置到落盘之前,把含糊的失败字符串换成带三态写入语义的类型化 blocker,这两件事都对主干有正向价值,而且没有引入新存储、新配置或新命令。就“漂移前置拒绝”而言,改动是完整闭环的,新增测试也确实证明了落盘为空。
但类型化结论一旦写进回执就会被运维和自动化按名消费,因此它必须名副实。本 PR 新增的 provider_send_rejected 覆盖了“没有得到 provider 结论”的超时路径,并把 external_write_performed 报成 False,这恰好违反了它自己在 GoalChannelDeliveryStageError 文档里写下的不变量;而新增测试把这一行为固化成了期望。这是本 PR 范围内应当闭合的缺口,最小修复很小且落在既有 owner 上,所以本轮判 REQUEST_CHANGES。
需要的证据是:send 判定区分“有 provider 结论的拒绝”与“无结论的未知结果”,并为后者补一个以 {"returncode": 1, "stdout": "", "stderr": ""}(或 timed_out=True)为输入的投递用例,断言 blocker 为 delivery_outcome_unknown 且 CLI 投影 external_write_performed=true。另建议同时移除 uv.lock,并把 record_delivery_receipt 的 stage 与摘要改准确。完成上述改动后重新评审即可。
English verdict: REQUEST_CHANGES at head 2cf82db039d8a6a1bdb318a62dbf038fe04ddfea. The change is directionally right and proportionate — it moves the executor-revision drift check into a single shared owner (confirmed_operation_executor) that runs before any durable proposal or idempotency write, and replaces bare ValueErrors with typed (blocker, failure_stage, external_write_performed) semantics that treat an unknown provider outcome as a performed write. Validation: 25 CI checks SUCCESS (Sign-off, merge-gate, test-shard 1-4, stage2c, pytest, windows-powershell), 0 FAILURE. Blocking finding: session.send collapses "no provider verdict" into provider_send_rejected with external_write_performed=False, because goal_channel_transport.call folds OSError/SubprocessError (including the 30s subprocess.run timeout) into returncode: 1 with empty output — so a card that may already be live is reported as cleanly not written, the exact misprojection this PR's own contract forbids, and the new send test hard-codes that expectation. Minimum repair is ~6 lines at the send decision point plus one test case. Two non-blocking items: a 1331-line uv.lock that has never been tracked, is not gitignored, and is consumed by no CI or doc; and record_delivery_receipt reusing delivery_outcome_unknown although the readback already proved the write happened. No external writes were performed during this review.
huangruiteng
left a comment
There was a problem hiding this comment.
动机
issue #4366 说的是:prepare-operation --execute 会在不校验 executor revision 的情况下把 proposal 落库。我在 base c979cf11c 上用 base 自己的夹具跑了一遍对照:同一个请求(executor.revision=requested-v1,而扩展绑在别的 revision)在 base 上返回 ok=true status=awaiting_confirmation,并真的把 proposal-88faa3ea7cc445f0a5653a8310bc1534(status=gated)和幂等键一起写进了 actions.json。之后只有 deliver-operation 才会抛 ActionConflictError("operation executor revision is not ready"),而这个异常是 RuntimeError 子类,会被 CLI 兜底的 except Exception 收成 blocker=provider_api_failed,于是 operator 看到的是一个"不可达的 gated proposal + 一条看不出原因的包"。我确认这个复合成本是真的:幂等键也被占住了,同 key 重新 prepare 还会拿回那条陈旧记录。修法方向正确——把 deliver 里本来就在做的 revision 比较提前到"任何持久化之前",并且只保留一份比较逻辑。
改动思路
主干里 deliver 早就有一段内联比较(resolved_executor["revision"] != parameters["executor"]["revision"]),resolver 也早就在这个模块里。本 PR 把它抽成 confirmed_operation_executor(),在 _prepare_goal_channel_operation 的 preview/execute 闭包之前调用一次,deliver 复用同一个函数,所以是去重而不是新增第二处权威:一个规则、两个调用点。类型上也有取舍:OperationExecutorDriftError 继承既有的 ActionConflictError,GoalChannelDeliveryStageError 继承 ValueError——后者意味着 CLI 的 handler 顺序变成承重结构,我确认最终文件里两个新 handler 都排在 except ValueError / except Exception 之前(loopx/cli_commands/goal_channel.py:822-865),否则新分类会被旧 handler 吃掉。诚实的 receipt 也是在这个 seam 上做出来的:写后失败(readback、receipt 落库)不再声称"干净地没写",而是投影成 external_write_performed=true + details.external_write_outcome=unknown,方向保守,和幂等键配合不会造成重复写。
关键代码讲解
loopx/extensions/lark/goal_channel_operation.py:870confirmed_operation_executor:解析绑定、把 resolver 的ValueError收敛成executor_unavailable(摘要里不带 resolver 私有文本),revision 不一致时抛OperationExecutorDriftError,details只带两个不透明 revision。head 的用例断言 preview/execute 两种模式下proposals与idempotency都保持为空。loopx/extensions/lark/goal_channel_message_delivery.py:32GoalChannelDeliveryStageError:一个阶段 = 一个 blocker + failure stage + 写入状态(True/False/None=未知)。它是ValueError子类,所以 CLI 侧的顺序是关键不变式。loopx/extensions/lark/goal_channel_operation.py:520附近的阶段 raise:verify_sender_identity、send_operation_card、以及写后的 readback / receipt 两个external_write_performed=None。前两者在写之前,标 False 是对的;写后标 unknown 也是对的。loopx/cli_commands/goal_channel.py:822的投影:external_write_performed为 None 时投影成true并带external_write_outcome=unknown。这个分支我在用例里看到它被真的驱动了(test_cli_deliver_operation_treats_unknown_write_as_performed)。
对主干的风险
阻塞项 1(诚实 receipt 在 send 阶段有个洞)。 _send_operation_card 只看 returncode 和 message_id:returncode != 0 or not message_id 就抛 provider_send_rejected + external_write_performed=False。但 goal_channel_transport.call() 会把 OSError / subprocess.SubprocessError(含 TimeoutExpired)统一吞成 {"returncode": 1, "stdout": "", "stderr": ""},而真实 runner 就是 subprocess.run(..., timeout=30)。我在 head 上做了端到端探针:让 +messages-send 抛 TimeoutExpired,结果是 blocker=provider_send_rejected, failure_stage=send_operation_card, external_write_performed=False, send_attempted=True——一次可能已经写出去的超时被投影成"确定没写"。这恰好和 PR 自己写的契约("只有结果已知时才给 True/False,None 表示未知")冲突,而这个阶段正是最需要 unknown 的地方。顺带一个更小的错位:CLI 二进制不存在(OSError)也从原来的 invalid_configuration 变成了"provider 拒绝",本来可以用一个 spawn/unavailable 类 blocker 区分。修法很便宜:把超时/未知标记从 call()/runner 传进 send 阶段,抛 delivery_outcome_unknown + None。现有用例之所以是绿的,是因为它用了一个手写的 {"returncode": 1, "stderr": "provider rejected"},没有超时标记——也就是说 mock 把被审查的后置条件直接供给了测试。
阻塞项 2(仓库资产边界)。 和 #4397 一样,这个 PR 在根目录新增 uv.lock(+1331 行,占新增行数的 69%):主干从来没有这个文件、.gitignore 没有覆盖、workflows/docs 里没有任何消费者,而且与 #4396/#4397 里那份逐字节相同(f4214968237e3ee32a285c99d40839523bace0f3)。请从这个 PR 里删掉,或者单独开一个 PR 说明采用 lockfile 的消费方与 CI。
非阻塞项:_prepare_goal_channel_operation 新加的 executor_binding_resolver 参数在生产调用点没有被传(只有测试注入),形态上和既有的 deliver seam 对称,所以我不当阻塞,但建议在 docstring 里标明它是测试 seam,否则看起来像一条受支持的配置入口。
验证(exact head 2cf82db039):
pytest tests/extensions/test_lark_goal_channel.py tests/extensions/test_lark_goal_channel_operation.py -q→ 67 passed。- base 对照探针(
/private/tmp/loopx-base-main):陈旧 revision 的 prepare 在 base 落库 gated proposal + 幂等键;head 同输入抛OperationExecutorDriftError且proposals/idempotency为空。 - 超时探针(head):
blocker=provider_send_rejected, failure_stage=send_operation_card, external_write_performed=False;call()对TimeoutExpired的返回也单独验证过。 - 远端检查:
gh pr checks 4398在2cf82db039上全绿。
我的整体评价
主修是"把已有比较挪到持久化之前",属于正确的窄修,我没有把它判定为过度设计:生产侧净增约 246 行,没有新模块、没有新依赖、没有新增持久状态,也没有动幂等与 M3 已知定位恢复。类型化阶段和"写后未知"的投影放在同一个 delivery 路径里,我认为是相关且可单独测试的改进。所以整体结构我是接受的,change_proportionality 记 proportionate;不能合的原因是上面两条:send 阶段的 unknown 洞会让这份"诚实 receipt"在最关键的阶段失效,以及那个无关的 lockfile。补上 send 阶段的 unknown 分支(顺手把 spawn 失败单独分类)、删掉 uv.lock 之后,这个 PR 就可以重新送审。这一轮我不做任何合并动作。
English verdict: REQUEST_CHANGES at exact head 2cf82db. The primary fix is verified and is real de-duplication rather than a second authority: the revision comparison deliver already performed is now shared and runs before persistence (base probe persists a gated proposal plus idempotency entry for a stale revision; head raises a typed drift error and writes nothing), and the typed CLI handlers are correctly ordered ahead of the ValueError fallback. Two blockers: a timed-out +messages-send is still projected as provider_send_rejected with external_write_performed=false (reproduced end to end, because call() collapses TimeoutExpired into returncode 1), contradicting the PR's own unknown-outcome contract; and the PR carries an unrelated, ungoverned root uv.lock identical to the one in the sibling PRs #4396/#4397.
huangruiteng
left a comment
There was a problem hiding this comment.
说明:这是同一 exact head 的重发版。上一版 review 漏掉了
## 具体改动分节标题,导致评审结论被判为格式不合规;正文内容、证据与结论均未改变。
动机
issue #4366 说的是:prepare-operation --execute 会在不校验 executor revision 的情况下把 proposal 落库。我在 base c979cf11c 上用 base 自己的夹具跑了一遍对照:同一个请求(executor.revision=requested-v1,而扩展绑在别的 revision)在 base 上返回 ok=true status=awaiting_confirmation,并真的把 proposal-88faa3ea7cc445f0a5653a8310bc1534(status=gated)和幂等键一起写进了 actions.json。之后只有 deliver-operation 才会抛 ActionConflictError("operation executor revision is not ready"),而这个异常是 RuntimeError 子类,会被 CLI 兜底的 except Exception 收成 blocker=provider_api_failed,于是 operator 看到的是一个"不可达的 gated proposal + 一条看不出原因的包"。我确认这个复合成本是真的:幂等键也被占住了,同 key 重新 prepare 还会拿回那条陈旧记录。修法方向正确——把 deliver 里本来就在做的 revision 比较提前到"任何持久化之前",并且只保留一份比较逻辑。
改动思路
主干里 deliver 早就有一段内联比较(resolved_executor["revision"] != parameters["executor"]["revision"]),resolver 也早就在这个模块里。本 PR 把它抽成 confirmed_operation_executor(),在 _prepare_goal_channel_operation 的 preview/execute 闭包之前调用一次,deliver 复用同一个函数,所以是去重而不是新增第二处权威:一个规则、两个调用点。类型上也有取舍:OperationExecutorDriftError 继承既有的 ActionConflictError,GoalChannelDeliveryStageError 继承 ValueError——后者意味着 CLI 的 handler 顺序变成承重结构,我确认最终文件里两个新 handler 都排在 except ValueError / except Exception 之前(loopx/cli_commands/goal_channel.py:822-865),否则新分类会被旧 handler 吃掉。诚实的 receipt 也是在这个 seam 上做出来的:写后失败(readback、receipt 落库)不再声称"干净地没写",而是投影成 external_write_performed=true + details.external_write_outcome=unknown,方向保守,和幂等键配合不会造成重复写。
具体改动
关键代码讲解
loopx/extensions/lark/goal_channel_operation.py:870confirmed_operation_executor:解析绑定、把 resolver 的ValueError收敛成executor_unavailable(摘要里不带 resolver 私有文本),revision 不一致时抛OperationExecutorDriftError,details只带两个不透明 revision。head 的用例断言 preview/execute 两种模式下proposals与idempotency都保持为空。loopx/extensions/lark/goal_channel_message_delivery.py:32GoalChannelDeliveryStageError:一个阶段 = 一个 blocker + failure stage + 写入状态(True/False/None=未知)。它是ValueError子类,所以 CLI 侧的顺序是关键不变式。loopx/extensions/lark/goal_channel_operation.py:520附近的阶段 raise:verify_sender_identity、send_operation_card、以及写后的 readback / receipt 两个external_write_performed=None。前两者在写之前,标 False 是对的;写后标 unknown 也是对的。loopx/cli_commands/goal_channel.py:822的投影:external_write_performed为 None 时投影成true并带external_write_outcome=unknown。这个分支我在用例里看到它被真的驱动了(test_cli_deliver_operation_treats_unknown_write_as_performed)。
对主干的风险
阻塞项 1(诚实 receipt 在 send 阶段有个洞)。 _send_operation_card 只看 returncode 和 message_id:returncode != 0 or not message_id 就抛 provider_send_rejected + external_write_performed=False。但 goal_channel_transport.call() 会把 OSError / subprocess.SubprocessError(含 TimeoutExpired)统一吞成 {"returncode": 1, "stdout": "", "stderr": ""},而真实 runner 就是 subprocess.run(..., timeout=30)。我在 head 上做了端到端探针:让 +messages-send 抛 TimeoutExpired,结果是 blocker=provider_send_rejected, failure_stage=send_operation_card, external_write_performed=False, send_attempted=True——一次可能已经写出去的超时被投影成"确定没写"。这恰好和 PR 自己写的契约("只有结果已知时才给 True/False,None 表示未知")冲突,而这个阶段正是最需要 unknown 的地方。顺带一个更小的错位:CLI 二进制不存在(OSError)也从原来的 invalid_configuration 变成了"provider 拒绝",本来可以用一个 spawn/unavailable 类 blocker 区分。修法很便宜:把超时/未知标记从 call()/runner 传进 send 阶段,抛 delivery_outcome_unknown + None。现有用例之所以是绿的,是因为它用了一个手写的 {"returncode": 1, "stderr": "provider rejected"},没有超时标记——也就是说 mock 把被审查的后置条件直接供给了测试。
阻塞项 2(仓库资产边界)。 和 #4397 一样,这个 PR 在根目录新增 uv.lock(+1331 行,占新增行数的 69%):主干从来没有这个文件、.gitignore 没有覆盖、workflows/docs 里没有任何消费者,而且与 #4396/#4397 里那份逐字节相同(f4214968237e3ee32a285c99d40839523bace0f3)。请从这个 PR 里删掉,或者单独开一个 PR 说明采用 lockfile 的消费方与 CI。
非阻塞项:_prepare_goal_channel_operation 新加的 executor_binding_resolver 参数在生产调用点没有被传(只有测试注入),形态上和既有的 deliver seam 对称,所以我不当阻塞,但建议在 docstring 里标明它是测试 seam,否则看起来像一条受支持的配置入口。
验证(exact head 2cf82db039):
pytest tests/extensions/test_lark_goal_channel.py tests/extensions/test_lark_goal_channel_operation.py -q→ 67 passed。- base 对照探针(
/private/tmp/loopx-base-main):陈旧 revision 的 prepare 在 base 落库 gated proposal + 幂等键;head 同输入抛OperationExecutorDriftError且proposals/idempotency为空。 - 超时探针(head):
blocker=provider_send_rejected, failure_stage=send_operation_card, external_write_performed=False;call()对TimeoutExpired的返回也单独验证过。 - 远端检查:
gh pr checks 4398在2cf82db039上全绿。
我的整体评价
主修是"把已有比较挪到持久化之前",属于正确的窄修,我没有把它判定为过度设计:生产侧净增约 246 行,没有新模块、没有新依赖、没有新增持久状态,也没有动幂等与 M3 已知定位恢复。类型化阶段和"写后未知"的投影放在同一个 delivery 路径里,我认为是相关且可单独测试的改进。所以整体结构我是接受的,change_proportionality 记 proportionate;不能合的原因是上面两条:send 阶段的 unknown 洞会让这份"诚实 receipt"在最关键的阶段失效,以及那个无关的 lockfile。补上 send 阶段的 unknown 分支(顺手把 spawn 失败单独分类)、删掉 uv.lock 之后,这个 PR 就可以重新送审。这一轮我不做任何合并动作。
English verdict: REQUEST_CHANGES at exact head 2cf82db. The primary fix is verified and is real de-duplication rather than a second authority: the revision comparison deliver already performed is now shared and runs before persistence (base probe persists a gated proposal plus idempotency entry for a stale revision; head raises a typed drift error and writes nothing), and the typed CLI handlers are correctly ordered ahead of the ValueError fallback. Two blockers: a timed-out +messages-send is still projected as provider_send_rejected with external_write_performed=false (reproduced end to end, because call() collapses TimeoutExpired into returncode 1), contradicting the PR's own unknown-outcome contract; and the PR carries an unrelated, ungoverned root uv.lock identical to the one in the sibling PRs #4396/#4397.
A timed-out or bodyless '+messages-send' was projected as provider_send_rejected with external_write_performed=False, because goal_channel_transport.call folds OSError/SubprocessError into returncode 1 with empty output while the real runner uses a 30s subprocess timeout. A card that may already be live in the chat was therefore reported as cleanly not written, which contradicts this stage's own unknown-outcome contract. call() now carries the fact it already has (timed_out) and marks a command that never started (spawn_failed); the send decision point classifies only a provider response body as a verdict, keeps a zero exit without a readable message id unknown too, and reports an unstartable CLI as provider_unavailable instead of a rejection. The post-write receipt failure keeps external_write_performed=None but gets its own blocker, so the unknown-provider signal is no longer diluted by a local write failure. The executor_binding_resolver seam is documented as test-only. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
Cover the send stage with the inputs the mock previously avoided: an empty provider body and a real TimeoutExpired both assert delivery_outcome_unknown with external_write_performed=None, a FileNotFoundError asserts provider_unavailable, and the CLI projection is parametrized so both the send stage and the receipt stage prove external_write_performed=true with details.external_write_outcome=unknown. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
The branch added a 1331-line uv.lock that no repository history tracks, no workflow or install doc consumes, and that duplicates the copy in the sibling PRs. Dependency installation stays on pip as CONTRIBUTING documents; a lockfile policy needs its own PR with a declared consumer. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
Maintainer repair at
|
Fixes #4366
What
prepare-operation --executepersisted proposals whose requested executor revision no longer matched the active extension revision;deliver-operationrejected them later, leaving an unreachablegatedproposal without a card.OperationExecutorDriftErrorcarrying the requested and active revisions; an unresolvable binding raises a typedexecutor_unavailableblocker whose summary never leaks the private resolver text.invalid_configuration/provider_api_failed. Delivery stages raiseGoalChannelDeliveryStageError(aValueErrorsubtype, so existing handlers keep working) carrying a stable public-safe blocker, the failure stage, and the provider write state: sender identity verification, dedupe history read, provider send rejection, and binding drift are each distinguishable, and all are known not-performed outcomes.delivery_outcome_unknown—external_write_performed=truewith anexternal_write_outcome=unknowndetail instead of a clean not-performed claim — while the public packet stays free of private provider detail.Verification
tests/extensionssuite: 888 passed, including new tests for stale-revision rejection before persistence in both modes, private-text-free resolution failure, typed stage blockers with no provider write at the identity/dedupe/send stages, the unknown post-send receipt, and CLI projection of the typed packet.