Skip to content

fix(operation): reject stale executor revisions pre-persist and keep typed delivery blockers - #4398

Merged
huangruiteng merged 5 commits into
loopx-project:mainfrom
BigDataDZ:codex/reject-stale-executor-revision
Sep 15, 2026
Merged

huangruiteng merged 5 commits into
loopx-project:mainfrom
BigDataDZ:codex/reject-stale-executor-revision

Conversation

@BigDataDZ

Copy link
Copy Markdown
Contributor

Fixes #4366

What

prepare-operation --execute persisted proposals whose requested executor revision no longer matched the active extension revision; deliver-operation rejected them later, leaving an unreachable gated proposal without a card.

  • Pre-persist revision guard: prepare now resolves the declared executor capability/protocol/permission against the active binding in preview and execute modes before any durable proposal or idempotency entry exists. A 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.
  • Typed delivery stages: the CLI no longer collapses every stage failure into invalid_configuration / provider_api_failed. Delivery stages 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.
  • Honest receipts: a failure after the provider write (exact readback, receipt recording) is projected as delivery_outcome_unknownexternal_write_performed=true with an external_write_outcome=unknown detail instead of a clean not-performed claim — while the public packet stays free of private provider detail.
  • Idempotency semantics and the M3 known-locator recovery slice are untouched, per the scope boundary.

Verification

  • Full tests/extensions suite: 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.

…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 huangruiteng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

评审范围

  • PR:fix(operation): reject stale executor revisions pre-persist and keep typed delivery blockers
  • 作者:@BigDataDZ
  • 评审 head:2cf82db039d8a6a1bdb318a62dbf038fe04ddfea(未变化,updatedAt 2026-09-14T16:48:54Z
  • 变更面:3 个生产文件(+214/-32)、2 个测试文件(+360),另有 1 个 1331 行的 uv.lock

动机

main 上有两个彼此相关、但危害不同的缺口。

第一,executor revision 漂移只在投递时才被检查。运维用声明 revision=requested-v1 的请求体 prepare 时,即使当前生效的是 active-v9,prepare 依然会成功,并把提案写进 actions.jsonproposalsidempotency。这条记录此后没有可达的消费路径:提案停在 awaiting_confirmation,人工点确认必定失败;更麻烦的是它占用了 idempotency key,用同一个 key 重试只会拿回这条不可执行的记录。这是一个“先落盘、后失败”的顺序问题。

第二,投递各阶段的失败只能给出很粗的结论。所有失败都抛裸 ValueError,CLI 侧把它们统一映射成 invalid_configurationprovider_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),携带 blockerfailure_stageexternal_write_performed: bool | None 三态。None 表达“结果未知”,并约定投影时必须按已写入处理。它继承 ValueError,所以即使某个调用点没有专门捕获,也仍然落回既有的 except ValueError 分支,不会退化成未处理异常。CLI 侧的 _error_packet 相应参数化,并在 external_write_performed is None 时输出 Truedetails.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.jsonproposalsidempotency 保持为空——这条断言直接把“前置”而不是“更早报错”钉住了。

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.sendreturncode != 0 or not message_id 时抛 provider_send_rejectedexternal_write_performed 取默认 False。但这条判定的输入类不止“provider 拒绝”:goal_channel_transport.callOSErrorsubprocess.SubprocessError 一律折叠成 {"returncode": 1, "stdout": "", "stderr": ""},而 default_subprocess_runner 正是以 timeout=30 调用 subprocess.runTimeoutExpired 属于 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”,与事实不符。投影本身(NoneTrue)是对的,所以不改变回执真值,但会让“未知写入”这个关键信号被本地落盘故障污染。建议给本地回执落盘失败单独的 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 huangruiteng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

动机

issue #4366 说的是:prepare-operation --execute 会在不校验 executor revision 的情况下把 proposal 落库。我在 base c979cf11c 上用 base 自己的夹具跑了一遍对照:同一个请求(executor.revision=requested-v1,而扩展绑在别的 revision)在 base 上返回 ok=true status=awaiting_confirmation,并真的把 proposal-88faa3ea7cc445f0a5653a8310bc1534status=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 继承既有的 ActionConflictErrorGoalChannelDeliveryStageError 继承 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,方向保守,和幂等键配合不会造成重复写。

关键代码讲解

  1. loopx/extensions/lark/goal_channel_operation.py:870 confirmed_operation_executor:解析绑定、把 resolver 的 ValueError 收敛成 executor_unavailable(摘要里不带 resolver 私有文本),revision 不一致时抛 OperationExecutorDriftErrordetails 只带两个不透明 revision。head 的用例断言 preview/execute 两种模式下 proposalsidempotency 都保持为空。
  2. loopx/extensions/lark/goal_channel_message_delivery.py:32 GoalChannelDeliveryStageError:一个阶段 = 一个 blocker + failure stage + 写入状态(True/False/None=未知)。它是 ValueError 子类,所以 CLI 侧的顺序是关键不变式。
  3. loopx/extensions/lark/goal_channel_operation.py:520 附近的阶段 raise:verify_sender_identitysend_operation_card、以及写后的 readback / receipt 两个 external_write_performed=None。前两者在写之前,标 False 是对的;写后标 unknown 也是对的。
  4. 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 只看 returncodemessage_idreturncode != 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-sendTimeoutExpired,结果是 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 同输入抛 OperationExecutorDriftErrorproposals/idempotency 为空。
  • 超时探针(head):blocker=provider_send_rejected, failure_stage=send_operation_card, external_write_performed=Falsecall()TimeoutExpired 的返回也单独验证过。
  • 远端检查:gh pr checks 43982cf82db039 上全绿。

我的整体评价

主修是"把已有比较挪到持久化之前",属于正确的窄修,我没有把它判定为过度设计:生产侧净增约 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 huangruiteng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

说明:这是同一 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-88faa3ea7cc445f0a5653a8310bc1534status=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 继承既有的 ActionConflictErrorGoalChannelDeliveryStageError 继承 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,方向保守,和幂等键配合不会造成重复写。

具体改动

关键代码讲解

  1. loopx/extensions/lark/goal_channel_operation.py:870 confirmed_operation_executor:解析绑定、把 resolver 的 ValueError 收敛成 executor_unavailable(摘要里不带 resolver 私有文本),revision 不一致时抛 OperationExecutorDriftErrordetails 只带两个不透明 revision。head 的用例断言 preview/execute 两种模式下 proposalsidempotency 都保持为空。
  2. loopx/extensions/lark/goal_channel_message_delivery.py:32 GoalChannelDeliveryStageError:一个阶段 = 一个 blocker + failure stage + 写入状态(True/False/None=未知)。它是 ValueError 子类,所以 CLI 侧的顺序是关键不变式。
  3. loopx/extensions/lark/goal_channel_operation.py:520 附近的阶段 raise:verify_sender_identitysend_operation_card、以及写后的 readback / receipt 两个 external_write_performed=None。前两者在写之前,标 False 是对的;写后标 unknown 也是对的。
  4. 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 只看 returncodemessage_idreturncode != 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-sendTimeoutExpired,结果是 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 同输入抛 OperationExecutorDriftErrorproposals/idempotency 为空。
  • 超时探针(head):blocker=provider_send_rejected, failure_stage=send_operation_card, external_write_performed=Falsecall()TimeoutExpired 的返回也单独验证过。
  • 远端检查:gh pr checks 43982cf82db039 上全绿。

我的整体评价

主修是"把已有比较挪到持久化之前",属于正确的窄修,我没有把它判定为过度设计:生产侧净增约 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>
@huangruiteng

Copy link
Copy Markdown
Collaborator

Maintainer repair at 69e5ff7f6 (on the author's branch)

Both blocking findings from the exact-head review are addressed, and the two
non-blocking suggestions are folded in. The author's commit is preserved; three
signed commits sit on top.

1. A missing provider verdict is no longer projected as a rejection

  • goal_channel_transport.call now carries the fact it already had: a
    TimeoutExpired returns timed_out: true, and a command that never started
    returns spawn_failed: true, instead of both collapsing into
    returncode: 1 with empty output.
  • The send decision point keeps its own classifier
    (delivery_send_failure). Only a provider response body is a verdict, so only
    a non-zero exit with a body stays provider_send_rejected; a timeout, a
    bodyless result, or a zero exit without a readable message id report
    delivery_outcome_unknown with external_write_performed = None; an
    unstartable CLI reports provider_unavailable (nothing ran, so nothing was
    written).
  • record_delivery_receipt keeps external_write_performed = None but now has
    its own delivery_receipt_write_failed blocker, so a local disk failure no
    longer dilutes the unknown-provider signal after the readback proved the card
    is live.
  • executor_binding_resolver is documented as a test seam rather than a
    supported configuration entry.

2. uv.lock removed

The 1331-line root uv.lock is gone. No repository history tracks it, no
workflow or install document consumes it, and CONTRIBUTING installs with pip. A
lockfile policy needs its own PR with a declared consumer.

Evidence

  • pytest tests/extensions -q -> 864 passed
  • pytest tests/extensions/test_lark_goal_channel.py tests/extensions/test_lark_goal_channel_operation.py -q -> 68 passed
  • ruff check on the five changed Python files -> clean
  • loopx canary premerge --from-git-diff -> 12 selected, 0 failures
  • Send-stage coverage added for the inputs the previous mock avoided: empty
    provider body and a real TimeoutExpired assert delivery_outcome_unknown
    with external_write_performed is None; FileNotFoundError asserts
    provider_unavailable; the CLI projection test is parametrized so both the
    send stage and the receipt stage prove external_write_performed = true with
    details.external_write_outcome = "unknown".

The exact-head review and merge-readiness decision for 69e5ff7f6 follow in the
next review pass.

@huangruiteng
huangruiteng merged commit 0741865 into loopx-project:main Sep 15, 2026
19 of 20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(operation): reject stale executor revisions before durable proposal and preserve delivery blockers

2 participants