fix(refresh): require explicit acknowledgement to resume paused external delivery - #4169
Conversation
Signed-off-by: Tartar <xiaoyx67@mail2.sysu.edu.cn>
Signed-off-by: Tartar <xiaoyx67@mail2.sysu.edu.cn>
…l-delivery-resume Signed-off-by: Tartar <xiaoyx67@mail2.sysu.edu.cn>
huangruiteng
left a comment
There was a problem hiding this comment.
动机
本 PR 解决的是一个真实的外部交付恢复缺口:某个 exact Turn 因人工判断而暂停 Explore Graph / Goal Channel 等外部 sink 后,后续调用如果只是省略暂停参数,旧路径会重新进入交付,没有一个可机读、可回放的“必须显式恢复”边界。受影响的是在同一操作上重试 refresh-state 的自动化与维护者;错误恢复会把原本要求人工确认的外部副作用悄悄重新打开。最小修复不能只在 CLI 层记一个布尔值,因为两个 sink 共用同一交付授权且需要跨调用、跨进程重放;把状态转换留在既有 TypeScript 决策边界、由 Python 只做持久化适配,是较小且一致的方案。非目标是新增 provider 权限、替换现有 sink 或把本地 refresh 也强制外部交付。
改动思路
入口仍是 refresh-state。CLI 把当前调用意图和该 operation 的历史事件交给 TypeScript refreshExternalDelivery,由它判定 local、paused、resume_required 或 ready;Python 适配器只在现有 refresh 锁内持久化类型化转换,并把授权结果交给 Explore Graph 与 Goal Channel 两个既有消费者。正向路径是:首次 --suppress-external-sinks 写入 pause 事件;之后省略参数会稳定返回 external_delivery_resume_required;只有携带匹配恢复 key 的 --resume-external-sinks 才进入 ready。负向路径覆盖过期 key、跨 operation key、相关历史损坏及冲突恢复;这些情况均 fail closed,且 receipt-only 修复及无外部交付的调用仍保持本地。该设计复用了既有刷新锁、事件历史和 sink 调用点,没有建立第二套权限存储;provider 可用性也不会自动等价为恢复授权。
具体改动
生产改动集中在 TypeScript 状态机、Python 桥接以及两个现有 sink 的授权接线;测试补齐了正向恢复、重放、错误 key、损坏历史与调用隔离。exact head 为 31e5b8f2ff3bd22824e9270efd37c51276719ac3,共 14 个文件,约 +498/-56。
关键代码讲解
refreshExternalDelivery是唯一的策略 owner:根据历史与本次显式意图产出类型化决策,避免 Python 重新解释状态。- Python refresh 适配器在原有锁和事件边界内提交 pause/resume 转换,不把序列化职责扩大成第二套业务规则。
- Explore Graph 与 Goal Channel 只消费返回的授权结果;省略参数不再绕过已有暂停状态。
- 历史读取对相关损坏 fail closed,但 receipt-only 修复与从未暂停过的旧 operation 保留原有本地/按调用行为。
对主干的风险
主要风险是恢复状态与 operation/Turn 身份绑定错误,导致错误放行、永久卡住,或把本地 refresh 误判成外部交付。针对这些风险,我执行了 23 个 Python 回归、5 个 TypeScript 状态机测试、control-plane typecheck、Ruff 与 git diff --check,均通过;测试覆盖匹配/过期/跨 operation key、损坏历史、重复恢复、两个 sink 以及无交付调用。当前 main 虽在该 head 之后继续前进,但受影响路径没有后续改动,clean merge-tree 也成功。剩余风险主要是不同真实 provider 的网络失败重试组合,现有设计仍由各 sink 保留失败/重试职责;本 PR 没有扩大它们的权限。
我的整体评价
没有发现阻断问题。这个改动把暂停/恢复从容易被“参数缺省”绕过的瞬时调用选择,收敛为可审计的类型化转换,同时保持 TypeScript 决策权和 Python/Provider 的既有职责。范围与故障成本相称,负向与重放覆盖充分,且没有引入推测性的通用框架。未来向 refactor pass 检查了相邻的重复规则:当前少量兼容错误文本有明确适配用途,不值得在本 PR 继续抽象。结论:批准。
English verdict: APPROVE at exact head 31e5b8f2ff3bd22824e9270efd37c51276719ac3; explicit resume is now machine-enforced by the existing typed decision boundary, with 23 Python tests, 5 TypeScript tests, typecheck, Ruff, and diff hygiene passing.
* fix(cli): restore module size and manpage classification budgets Two required public smokes fail on current `main`. `cli-command-module-size-ownership-command-modularization-smoke.py` reports `project_lifecycle.py has 1007 lines, above budget 1000`. The file crossed the budget when #4169 added the mutually exclusive external-sink delivery flags. Extract the three typed inline input codecs into `project_lifecycle_inputs.py`, which returns the owner to 925 lines without changing any public invocation. `cli-help-manpage-smoke.py` reports `unclassified: ['agent-context']`. The command shipped in #4244 without a manpage class. Add it to the existing `MANPAGE_COMMAND_HELP_ONLY` set, which is where comparable read-only lifecycle helpers already live. Extraction keeps the existing ownership contract: the registration and dispatch markers asserted by `cli-project-lifecycle-command-modularization-smoke.py` stay in the module, and `PROJECT_LIFECYCLE_COMMANDS` still covers all four commands. Validation: - `python3 examples/cli-command-module-size-ownership-command-modularization-smoke.py` -> ok - `python3 examples/cli-help-manpage-smoke.py` -> ok - `python3 examples/cli-project-lifecycle-command-modularization-smoke.py` -> ok - `python3 regression/cli-command-module-contract.py` -> ok - `python -m pytest tests/cli_commands/ tests/control_plane/test_cli_output_budget.py` -> 94 passed Signed-off-by: song <liusongstep@gmail.com> * test(smokes): realign stale contracts with shipped behavior Four required public smokes assert contracts that have since moved. Each is reproducible on a clean `main@fa57253`, so this aligns the checks with the shipped behavior rather than changing any product path. - `blocker-push-runtime-smoke.py` asserted the retired per-shell phrasing `` `LOOPX_TURN=<current_time_iso>`; reuse. ``. #4201 moved the bootstrap rule into the shared `HEARTBEAT_TURN_BOOTSTRAP_RULE`, whose current sentence ends with `reuse the value on retries`. Assert that sentence. - `install-local-smoke.py` required the accountable refresh and spend commands inside the `--brief` prompt, but brief mode renders exactly one bounded guard block by design; those commands belong to the full and compact modes. #4201 already realigned the adjacent thin-mode assertions and missed this one. Assert the brief contract, including that the pair stays out. - `github-actions-runtime-smoke.py` rejected the `22.14` SQLite runtime and the Node 26 forward job, and required the pre-#4241 `merge-gate` needs order. Record `SQLITE_NODE_VERSION` with its finalization rationale, extend the `python-tests.yml` expectation, and match the current needs list. - `repository-hygiene-smoke.py` fails because the `v1.0.3` tag exists without a timeline entry. Add the entry, following the existing format. Validation (each command exits 0): - `python3 examples/blocker-push-runtime-smoke.py` - `python3 examples/install-local-smoke.py` - `python3 examples/github-actions-runtime-smoke.py` - `python3 examples/repository-hygiene-smoke.py` - `python3 examples/release/release-readiness-doc-smoke.py` - `python -m pytest tests/control_plane/test_heartbeat_notification_rule.py tests/control_plane/test_heartbeat_prompt_support.py tests/control_plane/test_heartbeat_receipt.py tests/control_plane/test_heartbeat_recommendation_rules.py` -> 48 passed Signed-off-by: song <liusongstep@gmail.com> * test(ci): qualify runtime pins per workflow lane Signed-off-by: song <liusongstep@gmail.com> * test(ci): ignore comments when checking qualified runtime pins Signed-off-by: song <liusongstep@gmail.com> --------- Signed-off-by: song <liusongstep@gmail.com>
A Turn-bound refresh could temporarily suppress external sinks and then accidentally re-enable delivery when a recovery command omitted that flag. Record the pause in the existing rollout journal and require
--resume-external-sinks <resume_key>to acknowledge the current pause before send-capable recovery proceeds.The TypeScript quota owner decides admission; the Python adapter persists transitions under the existing refresh lock, and both Explore and Goal Channel use that decision. Re-pausing invalidates the previous key. Receipt-only repair stays local and dry-run persists no transition. Historical operations without pause evidence and non-Turn refreshes retain their previous behavior. The acknowledgement does not grant provider permissions; rollback to older binaries loses enforcement.
Scope excludes target migration, global-sync permission persistence, general hook changes, lock redesign, and exactly-once transport. A bounded companion cleanup moves the existing early recovery payload into its owning quota adapter to reduce growth in
state_refresh.py.Validation:
pytestandmerge-gatechecks passed.31e5b8f2ff3bd22824e9270efd37c51276719ac3; CI run.