fix(replan): keep checkpoint replan ACKs visible past the run window - #4554
Conversation
The long Todo-chain rule is edge-triggered: an accepted ACK suppresses the obligation while its exact Todo frontier revision matches, and the rule re-arms only after a material revision. The ACK projection stopped scanning after AUTONOMOUS_REPLAN_ACK_MATERIAL_RUN_WINDOW (20) material runs, so a checkpoint-bearing ACK was dropped while its checkpoint still described the current frontier. Frontier consumers that read that projection then saw no ACK and re-issued the long-chain obligation for an unchanged frontier, duplicating the periodic review cadence and reporting a "long chain" trigger that no longer matched the state. A checkpoint-bearing ACK is revision-gated by the typed evaluator, and the obligation-clearing path also requires the exact obligation id, so an old ACK cannot suppress or clear a materially changed frontier. Keep such an ACK visible for the whole scanned history, and keep the material-run window for legacy ACKs without checkpoints, which is also the window the separate periodic review rule owns. Observed on the loopx-meta review lane: 22 material runs after the newest accepted ACK made latest_autonomous_replan_ack_for_projection return None even though the ACK carried a long_todo_chain checkpoint, and the same frontier revision was acknowledged 12 hours apart under two different obligation ids. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
huangruiteng
left a comment
There was a problem hiding this comment.
Approval conclusion (author-owned PR; GitHub blocks formal self-approval)
Reviewed exact head: 329fa5232f270742e719ad59b59a5502beb81979
动机
长 Todo 链规则是边沿触发的:被接受的 ACK 在其记录的 frontier revision 与当前 revision 一致时压制该规则,只有 frontier 发生实质变化才重新武装(#3492 引入,test_long_todo_chain_checkpoint_is_edge_triggered_and_rearms_on_change 就是这条契约)。但 ACK 投影 latest_autonomous_replan_ack_for_projection 在数到 20 条 material run 后无条件 return None,把带 checkpoint 的 ACK 也一起丢掉:于是即使 revision 一字未变,frontier 侧也拿不到 ACK,规则被"运行次数"冒充成实质变化重新触发,并且把独立存在的 periodic review 规则(同样 20 条阈值)盖掉、把根因写成"链过长"。
改动思路
判断留在 typed 侧,投影只负责"给不给候选 ACK":新增 ack_binds_trigger_checkpoints() 区分带 typed trigger checkpoint 的 ACK 与旧格式 ACK;带 checkpoint 的 ACK 在整段被扫描的 history 内保持可见,旧格式 ACK 继续走原来的 material-run 窗口,periodic review 规则继续按自己的窗口工作。这个方向之所以安全,是因为带 checkpoint 的 ACK 并不具备"无条件压制"的能力——classifyAck 只在 revision 精确相等时压制,清除既有 obligation 的路径还额外要求 obligation id 精确相等(而 obligation id 随 revision 轮换)。
具体改动
loopx/control_plane/work_items/autonomous_replan_ack.py(+39/-3):新增ack_binds_trigger_checkpoints(:11);latest_autonomous_replan_ack_for_projection(:298)引入window_expired,窗口到期后只对带 checkpoint 的 ACK 继续放行,其余仍返回None。tests/control_plane/test_autonomous_replan_ack.py(+79):test_checkpoint_ack_stays_visible_beyond_the_material_run_window(:74)与test_legacy_ack_still_expires_with_the_material_run_window(:89),后者防住"把窗口一刀切掉"的过度修复。
关键代码讲解
autonomous_replan_ack.py:11 ack_binds_trigger_checkpoints:只认semantic_delta.trigger_checkpoints里同时具备kind与frontier_revision的条目;畸形或旧格式 ACK 会被判为"无 checkpoint",保持旧窗口(fail-closed 方向)。autonomous_replan_ack.py:298的循环:window_expired一旦置位,后续只在遇到带 checkpoint 的 ACK 时才返回;不含 checkpoint 的 ACK 到了窗口仍然返回None——这正是原行为,也是本次修复的边界。test_...:74:构造"ACK 之前已有 2 倍窗口的 material run",断言仍能拿到带long_todo_chaincheckpoint 的 ACK。把生产改动临时移除后这条会失败(我做了 mutation 验证),说明它精确钉住目标行为。test_...:89:同一位置放一条无 checkpoint 的 ACK,断言窗口到期后返回None,与旧行为一致。
对主干的风险
- 安全边界:让旧 ACK 继续可见,理论上可能压制"其实已经变了"的 frontier。这里由两层挡住:frontier digest 覆盖每条可选 advancement 行的身份与状态字段(
updated_at之类不影响 digest),typed evaluator 要求 revision 精确相等;清除既有 obligation 还需要 obligation id 精确相等。 - 节奏:periodic review 规则不被这次改动触及,仍然是每 20 条 material run 的独立节奏;因此"有间隔地重规划"这条设计没有丢。
一个必须说清楚的边界(与本 PR 的定位相关):这个修复不会自动关掉一条已经打开、且 revision 已经变化的 obligation。本 lane 现在挂着的 replan-ccdd5e1a6f4660a4 记录的 revision 是 cfb03ae5…,而最后一条被接受的 ACK 记录的是 6c0af01c…(中间我改过一条 todo 文本,frontier 确实变了)。所以合并它之后,规则仍会(正确地)要求一次新的语义增量;它修的是"之后不该再因为 20 条运行次数而重发",而不是"当前这条立刻消失"。
我的整体评价
APPROVE。 这是把一个真实的边沿触发契约回收成它本来的样子:判据仍在 typed 侧,Python 只调整候选 ACK 的可见性,旧格式 ACK 与 periodic 节奏都不受影响,正反两个用例 + mutation 验证都到位。证据:pytest 三个模块 52 passed;node --test tests/control_plane_ts/frontier_revision.test.ts 8 passed;examples/autonomous-replan-obligation-smoke.py ok;对真实 run history 的 before/after 探针显示:改动前返回 None,改动后返回那条带 long_todo_chain checkpoint 的 ACK;git merge-tree 对 main 干净。
English verdict: APPROVE — at head 329fa52 this restores the documented edge trigger: a checkpoint-bearing replan ACK stays visible past the material-run window while legacy ACKs keep expiring, so the long-chain rule is no longer re-armed by run count alone and the separate periodic review cadence keeps its own window. Safety rests on the typed evaluator's exact-revision match and the exact-obligation-id requirement for clearing, both unchanged. Evidence: 52 focused pytest cases, 8 TypeScript frontier cases, the autonomous-replan-obligation smoke, a live before/after projection probe (None before, checkpoint ACK after), and a mutation check that fails the new regression without the production change. One boundary worth stating: this does not clear an already-open obligation whose recorded revision has moved — that still needs a fresh semantic delta.
|
Self-merge record (author-owned PR; owner authorized this merge in the current session). Changed surfaces
Checks run
Failures and skips
Manual holds
Why the coverage is enough The change is one predicate plus one flag in an existing projection; its failure modes are "an old ACK suppresses a changed frontier" (blocked by the typed evaluator's exact-revision match and by the exact-obligation-id clearing rule) and "legacy ACKs silently gain extended visibility" (pinned by the counterexample test). The focused suites cover both sides, and neither the writer path nor obligation identity is touched. |
问题
长 Todo 链(
long_todo_chain)规则是边沿触发的:一条被接受的 replan ACK 在其记录的 Todo frontier revision 与当前 revision 一致时压制该规则,只有 frontier 发生实质变化才重新武装(#3492 引入,tests/control_plane/test_goal_frontier_replan_rules.py::test_long_todo_chain_checkpoint_is_edge_triggered_and_rearms_on_change就是这条契约)。但 ACK 投影函数
latest_autonomous_replan_ack_for_projection在数到AUTONOMOUS_REPLAN_ACK_MATERIAL_RUN_WINDOW(20)条 material run 后就直接return None。于是哪怕 ACK 携带的 checkpoint 仍然精确等于当前 frontier revision,它也会因为运行次数到期而被丢掉,frontier 侧拿不到 ACK → 在没有任何 frontier 变化的情况下重新发出long_todo_chainobligation。后果有三层:
periodic_review_due)重复,而且先触发的 long-chain 分支会把后者盖掉;证据
loopx/control_plane/work_items/autonomous_replan_ack.py的窗口返回值,与loopx/control_plane/todos/frontier_revision.ts::classifyAck的 revision 精确匹配契约冲突;仓库里没有任何测试固定"ACK 因运行次数到期而失效"这一行为。latest_autonomous_replan_ack_for_projection返回None;用本 PR 的改动重跑同一份 run 列表,返回的是那条 ACK,且它带着long_todo_chaincheckpoint。1a40ad2c…)在相隔约 12 小时的两条 ACK 里完全相同,但 obligation id 不同(replan-ccfb45db…→replan-b4bc1556…)——说明那次重发不是 frontier 变化引起的。改动
loopx/control_plane/work_items/autonomous_replan_ack.py:新增ack_binds_trigger_checkpoints(),区分"带 typed trigger checkpoint 的 ACK"和旧格式 ACK。带 checkpoint 的 ACK 在整段被扫描的 history 内保持可见;旧格式 ACK 继续使用原有 material-run 窗口;periodic review 规则本身继续按自己的窗口工作。安全性依据:带 checkpoint 的 ACK 并不具备"无条件压制"能力——typed evaluator 只在它写明的 revision 与当前 frontier revision 完全一致时才压制,而清除既有 obligation 的路径还额外要求 obligation id 精确相等(obligation id 随 revision 轮换)。因此让旧 ACK 继续可见不会掩盖任何实质变化,只是不再让运行次数冒充实质变化。
验证
pytest -q tests/control_plane/test_autonomous_replan_ack.py tests/control_plane/test_goal_frontier_replan_rules.py tests/control_plane/test_canonical_frontier_revision.py:52 passedpytest -q tests/control_plane -k replan:259 passed,1 failed(test_todo_replan_cadence.py::test_cli_cadence_roundtrip_syncs_to_an_isolated_runtime,本机 subprocess 取到 Python 3.9 而报 "Python 3.11+ is required",在未应用本改动的origin/main上同样失败,属于本机环境问题)node --experimental-strip-types --test tests/control_plane_ts/frontier_revision.test.ts:8 passpython examples/autonomous-replan-obligation-smoke.py:oktest_checkpoint_ack_stays_visible_beyond_the_material_run_window失败(返回None),旧格式 ACK 的窗口用例仍通过,说明修复精确落在目标行为上。English summary
The long Todo-chain rule is edge-triggered: an accepted replan ACK suppresses the rule while its recorded Todo frontier revision matches, and the rule re-arms only after a material revision.
latest_autonomous_replan_ack_for_projectionnevertheless stopped scanning afterAUTONOMOUS_REPLAN_ACK_MATERIAL_RUN_WINDOW(20) material runs, so a checkpoint-bearing ACK was dropped while its checkpoint still described the current frontier, and frontier consumers re-issued the long-chain obligation for an unchanged state. That duplicated the separate periodic review cadence and mislabeled a run-count re-arm as a long chain.This change keeps a checkpoint-bearing ACK visible for the whole scanned history while legacy ACKs without checkpoints keep the material-run window. It is safe because checkpoint ACKs are revision-gated by the typed evaluator and the obligation-clearing path also requires an exact obligation id, so an old ACK cannot hide a materially changed frontier. Validation: 52 focused pytest cases, 8 TypeScript frontier cases, the autonomous-replan-obligation smoke, and a mutation check that fails the new regression without the production change. Evidence from a live lane: 22 material runs after the newest accepted ACK made the projection return
None; the same revision was acknowledged twice under different obligation ids about 12 hours apart.