Skip to content

feat(turn): consume retry_continuation as a managed step - #4361

Merged
huangruiteng merged 4 commits into
loopx-project:mainfrom
songoow:codex/managed-step-retry-continuation
Sep 15, 2026
Merged

huangruiteng merged 4 commits into
loopx-project:mainfrom
songoow:codex/managed-step-retry-continuation

Conversation

@songoow

@songoow songoow commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

Behavior

Adds loopx turn managed-step, a read-only CLI consumer of the controller's bounded same-Turn retry disposition. It reads the failed Turn journal and a fresh governing envelope, returning typed JSON/markdown without launching a host, writing business state, spending quota, sleeping or starting a scheduler.

Implementation

  • run-once and managed-step share the status/live-decision/envelope owner. The Lark activation projector receives the resolved registry runtime root.
  • Observed attempt/budget values only reconcile against the journal. The two persisted attempt counters must agree.
  • The current journal snapshot is checked by the existing TypeScript journal interpreter. A historical recovery audit neither admits a damaged journal nor permanently blocks a repaired one. The eventual run-once retains fresh host-session and execution-authority checks.
  • Parser, command handler and rendering remain owner-local; the protocol documents the read-only boundary. No second journal consistency state machine or retry ledger is introduced.

Validation

  • 163 related Turn tests passed, including 21 managed-step tests.
  • Real CLI fixture: capacity failure -> wait(1/3) -> same-Turn validated progress -> idempotent replay, exactly one quota slot.
  • The CLI smoke also corrupts a disposable journal phase prefix: canonical inspection and managed-step both reject it, with no retry block emitted and no quota spend. Restoring the journal preserves the successful retry path.
  • Current Goal/receipt/settlement mismatches are refused; stale audit outcomes do not replace current consistency checks.
  • LoopX premerge passed against the actual PR base (with the output differential explicitly bound to that base); Ruff and diff checks passed. The DSH runner is hermetic; this is not a live model/provider qualification.

Affected entry point: CLI only, using existing journal/envelope contracts. No dashboard settings change or autonomous host-wake implementation is part of this slice.

@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.

审查对象:b010b69544e0c24f7200d8550a320dd3946447fc

动机

这组改动解决的是一个真实的 Turn 生命周期断点:run-once 在 retryable host failure 后已经能把 typed failure 和 retry policy 写进 canonical journal,pure Turn Loop Controller 也能产生 retry_continuation,但此前没有公开的生产入口把这两者接起来,外层 scheduler 只能从文本推断是否重试、等待多久以及是否沿用同一 Turn。PR 新增只读 turn managed-step,目标是从 journal 重建 receipt、用 fresh control-plane envelope 重新判定,并返回不带执行权限的 typed disposition。选择复用现有 controller/journal validator,而不是在 scheduler 再写一份 retry 规则,方向正确。

改动思路

入口位于 loopx turn managed-step:CLI 先解析 registry 对应的 runtime root,读取 exact turn_key journal,再通过抽出的 turn_decision.py 走和 run-once 相同的 status → live quota decision → signed envelope 链路。执行语义仍归 decide_loop_disposition;managed-step 只资格化输入并投影结果,不调用 host、不写状态、不花 quota、不 sleep。turn.py 同时把 parser/renderer/decision builder 拆到 owner-local 模块,避免新增命令继续扩大热文件。文档和两层测试覆盖正常 1/3 retry、budget exhausted、lineage/observation mismatch、runtime-root 解析以及完整 CLI self-heal。

正向路径已验证:provider_capacity 第一次失败后返回 wait(30s, 1/3),随后同 Turn run-once 达到 validated_progress,只消耗一个 quota slot。负向输入应在 managed-step typed boundary fail closed;这里仍有一个 journal 内部计数未绑定的问题。

具体改动

  • managed_step.py 新增 failed journal 资格化、caller observation reconciliation、controller 调用和 loopx_turn_managed_step_v0 投影。
  • turn_decision.py 抽取 collect_turn_status_payload、共享 live decision builder、controller advisory primary 和 envelope 签名,并修正 registry-declared runtime root 的 Lark activation lookup。
  • turn_managed_step.py、turn_registration.py、turn_rendering.py 注册/处理/输出新命令;turn.py 改为调用这些 owner。
  • protocol 文档补充 consumer/authority 边界;新增 389 行 typed unit tests 与 418 行 hermetic self-heal smoke。

关键代码讲解

  1. managed_step_receipt_from_journal(managed_step.py:105)先要求 status=failed、exact turn key、result_kind=host_failure,再检查 recovery block 和 retryable failure,最后重建 ValidatedTurnReceipt。问题出在 managed_step.py:134:这里 normalize 了 host_failure,却没有把 host_failure.attempt 与 journal 顶层 host_attempt_count cross-bind。
  2. reconcile_observed_attempt(managed_step.py:147)正确地把外部 --observed-attempt 当 observation,只允许它等于 host_attempt_count;但 controller 的 host_failure_retry_available 实际读取 nested host_failure.attempt。因此两个“权威”字段可以分叉。
  3. decide_managed_step(managed_step.py:232)把 receipt 与 fresh envelope 交给既有 controller,并保留 exact goal/agent/todo/turn 约束;正常路径的复用和无副作用边界是合理的。
  4. build_turn_decision_builder(turn_decision.py:59)让 run-once/managed-step 共享 live decision 语义,并把 resolved runtime root 传给 operator inbox projector;相关 regression pin 已通过。

对主干的风险

[P1] journal 内部 attempt 漂移会绕过 retry ceiling。 我在 exact head 上把同一 failed journal 改成 host_attempt_count=3、host_failure.attempt=1,调用生产 decide_managed_step 后没有报错,而是返回 disposition=wait,顶层 attempt=3、retry_continuation.attempt=1、max_attempts=3。也就是说 presentation 显示已到 3/3,但 controller 仍按 1/3 允许重试;外层 scheduler 若消费该结果会多执行 host。最小修复是在构造 receipt 前要求 normalized host_failure.attempt == journal.host_attempt_count,不一致时 typed fail closed,并增加两个方向的 mismatch regression。

验证方面,四个 focused turn suites 共 159 passed,self-heal smoke 通过,当前 main 的 merge-tree 无冲突;但上述负向 counterexample 失败。此外 GitHub required checks 仍有 test-shard (2)、aggregate pytest 和 merge-gate 为红,直接失败是 local-authority-shadow CLI e2e,可能与本 PR 无关,但在新 exact head 上仍需得到绿色 required checks。

默认关闭/权限语义方面,新行为只通过显式子命令进入,共享 builder 的既有 run-once 路径由 159 个 focused tests 覆盖;输出没有 effects/writeback/spend 字段,命名也没有夸大 actor authority。代码规模约 1594 additions/55 deletions,其中 807 行为 test/smoke,整体虽不小但围绕一个 CLI consumer 和共享 decision seam,比例仍可接受;关键是先补上 counter cross-binding。

我的整体评价

架构方向、owner 复用、只读边界、runtime-root 修复以及正向验证都不错,且 future-facing 抽取与本次 change reason 一致。但 PR 的核心承诺之一是“Turn journal 是 attempt 的唯一 authority、输入漂移 fail closed”,当前 exact head 恰好允许两个 persisted attempt 字段分叉并产生可执行意义上的错误 wait。因此本轮结论是 REQUEST_CHANGES。修复 cross-binding、补负向测试、刷新 exact head 并让 required CI 变绿后,我会重新完整审查。

English verdict: REQUEST_CHANGES for exact head b010b69544e0c24f7200d8550a320dd3946447fc. The managed-step architecture and 159 focused tests are solid, but a journal with host_attempt_count=3 and host_failure.attempt=1 still returns wait with contradictory 3/3 vs 1/3 state, bypassing the retry ceiling. Cross-bind those fields, add mismatch negatives, and obtain green required CI.

@songoow
songoow force-pushed the codex/managed-step-retry-continuation branch from b010b69 to f3e820d Compare September 14, 2026 09:18
@songoow

songoow commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main and fixed the cross-binding at new head f3e820d70.

Your counterexample reproduces exactly. Driving the production decide_managed_step with a journal whose top level says host_attempt_count=3 while the nested record says host_failure.attempt=1 returned disposition=wait with attempt=3, retry_continuation.attempt=1 and max_attempts=3 — the projection reported 3/3 while the controller authorized a retry on 1/3.

The fix is in managed_step_receipt_from_journal, before any reconciliation or controller call: _require_matching_attempts requires host_attempt_count and host_failure.attempt to be equal positive integers and fails closed otherwise, so no branch is taken on a journal whose reported and authoritative attempt counts disagree. The two fields are written from one value by record_host_failure, so a disagreement means the journal was edited or corrupted rather than that either side is a correction.

Regression covers both directions: journal ahead of the nested record and nested record ahead of the journal both raise host_failure attempt disagrees with host_attempt_count, and an agreeing journal still returns wait with attempt=1, so the binding is not a blanket rejection of the retryable path. Removing the binding fails the new test.

Verification at f3e820d70: managed-step suite 18/18; the wider turn suites (test_loopx_turn_managed_step.py, test_loopx_turn_host_failure.py, test_loop_turn_loop_controller.py, test_loopx_turn_executor.py, test_loopx_turn_journal_inspection.py) 167/167; the self-heal smoke still reports provider_capacity -> wait(30s, 1/3) -> same-Turn validated_progress, exactly one quota slot; Ruff clean.

Two notes on the other points in your review. test_loopx_turn_driver.py cannot be collected in this environment at all (ModuleNotFoundError: No module named 'tests.control_plane'), and the same 17 collection errors reproduce on a clean upstream checkout, so I could not run it here; I am not claiming it passes. And the test-shard (2) / pytest / merge-gate reds you saw were from the local-authority-shadow CLI e2e, which is unrelated to this branch; the required checks should be re-read at the new head.

@songoow
songoow force-pushed the codex/managed-step-retry-continuation branch 2 times, most recently from 42999e9 to a56d4d9 Compare September 14, 2026 10:06

@songoow songoow left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Request changes conclusion (author-owned PR; GitHub blocks formal self-review)

审查 head:a56d4d99c86c67d5b2058522730870d453b80230。结论:REQUEST_CHANGES。

动机

把失败 Turn 的 retry_continuation 提供给外部调度器具有实际价值,可以避免从异常文字推断重试策略。新增只读 CLI 是有界交付;它尚不等于自动 scheduler,也不自动执行下一轮。最重要的架构选择是复用当前 journal/recovery 的权威判定,不能用一套较弱的 Python 检查重新定义哪些 journal 可以继续。

改动思路

managed-step 读取 exact Turn journal,经共享 status/live quota decision/envelope 构建链获取当前决策,重建 ValidatedTurnReceipt 后调用既有 loop controller。run-once 仍拥有 host、持久写回与 quota 结算。相对上次评审,host_attempt_count 与 host_failure.attempt 的双向绑定已加入,resolved runtime_root 也正确传给 inbox projector;现有回归覆盖并通过。

不过,当前实现把“旧 audit 曾允许继续”视作资格,未调用已有 TS journal interpreter 重新判定当前 snapshot。与原有 inspection/resume 路径相比,这是更弱的输入边界。

具体改动

完整改动覆盖:turn.py 的 dispatch/decision 抽取;turn_decision.py 的共享 fresh decision;turn_managed_step.py、registration、rendering 的新 CLI;turn_driver export 与 managed_step receipt/observation/controller adapter;协议说明、typed tests 和 self-heal smoke。新增入口与 JSON/markdown 反馈都可达,不需要为这条 CLI-only 读命令改 dashboard 设置。

关键代码讲解

  • build_turn_decision_builder 复用 live quota decision 与 periodic-report hook 投影,resolved root 接线正确;managed-step 不 dispatch Turn-start hooks。
  • managed_step_receipt_from_journal(managed_step.py:152-156)仅检查持久的 recovery_audit.planned.action 是否为 blocked。它没有证明该 audit 对当前文件内容仍有效。
  • _validated_turn_receipt 重建 execution wrapper,让 ValidatedTurnReceipt 验证 receipt 本身;这不会校验 journal 的 completed_phases、settlement binding 等 canonical journal 一致性。
  • 现有 inspect_loopx_turn_journal → assess_existing_turn_recovery → interpret_turn_journal_projection → TS turn_journal.ts 已经拥有这些检查,适合在新入口复用。

对主干的风险

[P2,阻塞] 当前 journal 被 canonical checker 拒绝时,managed-step 仍返回可重试结果。

通过 self-heal fixture 的真实 run-once 先生成正常 provider_capacity failed journal。在 disposable fixture 中仅把 completed_phases 改成不合法的阶段前缀 [quota_spend],保留其余内容及旧 audit:

  • 原有 inspect_loopx_turn_journal(..., retry_failed=True) 返回 journal_consistent=false、completed_phases_not_ordered_prefix、recovery_decision.action=blocked、reason=journal_inconsistent。
  • 同一文件经真实 turn managed-step CLI 却 exit=0、ok=true、disposition=wait,并带 retry_failed_turn=true、attempt 1/3。

这会误导外部 scheduler 重试一个现有恢复 owner 已拒绝的 Turn。没有证明可成功绕过 run-once 或重复扣费;缺陷在新命令的资格与反馈不一致。不要继续逐项补 Python checks:应对读取的同一份当前 snapshot 复用既有 TS journal/recovery 判定,再构造 controller receipt;历史 audit 只作解释材料。回归需要同时比较 canonical inspection 与 managed-step 的结果,并保留正常 failed Turn 的成功用例。

我的整体评价

本轮独立执行 160 个 turn 相关测试(其中 managed-step 18 个)全过;公开 CLI self-heal smoke 通过,失败和重试合计一个 quota slot,幂等 replay 不重复扣费。另对正常 managed-step 前后整个 disposable fixture 做文件内容 hash 对比,变化为空;随后上述损坏 journal 反例稳定复现。

整体 CLI 切片大小与目的可解释,前一轮 attempt 修复有效;但恢复资格出现第二个且更弱的 owner,当前仍应修改。建议以复用 TS interpreter 收敛代码,而非扩大新协议。没有运行真实 DSH 模型服务,也不把 fake runner 当 provider 实机验收。全量 baseline/head 平台矩阵未执行,以上结论限于已验证路径。

English verdict: REQUEST_CHANGES at a56d4d99c86c67d5b2058522730870d453b80230. The attempt cross-binding is fixed, but managed-step trusts a persisted recovery audit instead of qualifying the current journal with the canonical interpreter. A real failed-journal fixture with an invalid phase prefix is blocked by existing inspection yet receives wait/retry_failed_turn=true from the new CLI. All 160 focused Turn tests and the CLI self-heal smoke pass; normal managed-step changes no fixture file contents. Reuse the existing TS recovery boundary and add this negative case; no successful execution bypass or double-spend was demonstrated.

@songoow

songoow commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

修复提交:135d913002eb499a599b1871651adf3851354a95

已删除对历史 recovery audit 的资格判断;同一份当前 journal snapshot 交给既有 TS interpreter 检查。损坏阶段顺序/身份/settlement binding 会被拒绝;旧 blocked audit 不永久阻塞已修复 snapshot。host-session/执行权限仍由后续 run-once 验证。

验证:163 项 Turn 测试、自愈 CLI smoke、Ruff 与按实际 PR 基线运行的 LoopX premerge 通过。损坏 fixture 不返回 retry_continuation,恢复后同 Turn 重试仍只花一个 slot。

已重写 PR 正文以反映当前实现与边界。全部提交带 DCO sign-off;新的 CI 已触发,尚未宣告合并或批准。请按此 head 复审。

Fix follow-up: the reproduced findings are addressed at this exact head with regression coverage and repository-native premerge validation. CI is rerunning; this is a fix report, not an approval.

@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.

动机

纯 Turn Loop Controller 早已能在容量类失败上返回 wait 加类型化 retry_continuation,但没有任何生产 consumer:CLI 侧只有 run-once(执行)与 plan / inspect-journal(只读规划),外层 scheduler 只能从散文里猜这个 Turn 还能不能续跑、预算还剩多少。代价是每个 host 自己实现一套可重试判断,容易重复外部副作用,或者把可修复的 Turn 直接搁死。最近的最小修法是把已有的 journal 投影打印出来,但那样不会用一份新的 governing envelope 重新裁决重试预算,等于把关键判断留在调用方。因此本 PR 增加 loopx turn managed-step:消费 controller 的 retry_continuation,只读地给出同一个失败 Turn 的 disposition,明确不启动 host、不写业务状态、不花 quota、不 sleep、不启动 scheduler。

改动思路

入口是 loopx turn managed-step,经 handle_turn_command 分发;权威输入是 canonical Turn journal(attempt 计数、retry policy、已存 plan/receipt)加一份新采集的控制面决策 envelope,决策 owner 仍是 Turn Loop Controller,journal 一致性仍由既有 TypeScript 解释器判定,所以这是「读取 + 重新裁决」,没有任何状态迁移。作者没有另起一套并发控制或重试账本:run-once 与 managed-step 共用同一段 status/live-decision/envelope 链路(cli_commands/turn_decision.py),抽取时把 Go/No-Go hook 保留在调用方,run-once 因此仍是唯一执行者,managed-step 不发布任何 hook。与既有实现的对照也清楚:inspect-journal 刻意不做决定,直接复用它会留下未计算的重试处置;新模块只是把那一步接到既有 owner 上。所有权代价很小:新增一个 runtime 模块加一次保持行为的抽取,journal 继续是 attempt/budget 的唯一权威,公开协议文档同步写明只读边界与 wait 语义(有界退避后唤醒同一 Turn,而不是立即消费或写入)。

具体改动

关键代码讲解

  1. decide_managed_step(loopx/control_plane/turn_driver/managed_step.py:254):先把调用方观测到的 attempt/上限与 journal 对账,再资格校验(必须是失败 Turn 且是 HOST_FAILURE),然后才问 controller 要 disposition,最后用 journal 里已被证明的值投影 attempt/max_attempts。伪造观测值无法选择分支,失败以 ValueError / RuntimeError 交给 CLI 边界转成 ok=false 与退出码 1。
  2. build_turn_decision_builder(loopx/cli_commands/turn_decision.py:47):从 turn.py 内联闭包里抽出的唯一决策 owner,run-once 改为调用它。periodic-report pending-intent hook、advisory primary 选择、scan roots 与 lookback 上限都原样保留,Lark 激活检查改用已解析的 registry runtime root,因此抽取是行为保持的,managed-step 也复用同一份 envelope 语义。
  3. handle_turn_managed_step(loopx/cli_commands/turn_managed_step.py:47):从 registry runtime root 解析 journal 路径、构建新 envelope、打印类型化 payload;对其它 turn 子命令返回 None,保证注册顺序安全;所有失败都被包成 ok=false,不向用户抛 traceback。
  4. _require_matching_attempts(loopx/control_plane/turn_driver/managed_step.py:92):顶层 host_attempt_count 与嵌套 host_failure.attempt 必须一致,否则拒绝。controller 用嵌套值授权重试而投影用顶层值展示,两者分叉就会误报预算,所以这里选择 fail-closed。

除上述符号,本 PR 还改了注册与渲染(turn_registration.py、turn_rendering.py、turn_driver/__init__.py)、协议文档(docs/reference/protocols/turn-loop-controller-v0.md 的 Managed Step Surface)、441 行 CLI smoke 与 442 行单元测试;总计 10 文件 +1710/-55,其中 7 个生产文件属于新增只读面与一次保持行为的抽取,2 个测试文件与 1 个文档页跟随该契约。

对主干的风险

两个 exact head 阻塞项都不在代码语义里,而在提交与打包资产上:其一,head 合并提交 4c3514e7a 没有 Signed-off-by trailer(分支上四个特性提交都有),Sign-off 检查失败,任何分支提交缺 trailer 都会被 DCO 拒绝;其二,frontstage 打包资产检查在 head 上因五个差异(asset-retention.json、被删的旧 JS、index.html 与两个新增 JS/CSS)报红,我把仓库自带的构建命令在 head 与 merge base 5c271aa6 上各跑一次,得到完全相同的差异,而在当前 origin/main 上干净,说明这是分支落后于主干造成的,不是本 PR 引入。最小修复是在同一次带 -s 的合并里把分支更新到最新 main,然后在新 head 上重跑这两项检查。负向路径已实测:journal 缺失、计数不一致或 phase 前缀损坏时,命令以类型化失败拒绝,不输出重试块、不花 quota、不在 runtime root 写入任何文件;正向路径在真实 CLI 上得到 wait(30s, 1/3),随后同一 Turn 达到 validated progress 且恰好消耗一个 quota 槽位。行为披露与隔离也成立:新增子命令是纯增量、无 feature gate,run-once / plan / inspect-journal 的默认行为不变(261 个 Turn 相关测试通过),公开协议名没有承诺任何比实现更大的 actor 生命周期或权限。剩余残余风险是默认 --host dsh 与 isolated-headless 执行模式可能与该判断所处的 scheduler 上下文不一致,以及每次调用会拉起一次 Node 解释器执行 TS 一致性检查。

我的整体评价

可观测语义对比是明确的:改动前该子命令不存在(argparse 报错),改动后同一份失败 journal 给出类型化 wait 与 journal 证明的 attempt/预算;对损坏输入则给出类型化拒绝、退出码 1,且可验证地零写入。代码体量与其问题相称:一个只读 runtime 模块加一次消除重复决策 owner 的抽取,没有引入第二份状态机、重试账本或新的持久化,scope fit 有真实生产调用点(handle_turn_command)。所以我不对代码语义提阻塞,但本分支当前无法合并:需先补上 head 提交的 DCO trailer、把分支更新到最新 main 以修复打包资产检查,然后在新 head 上让 Sign-off 与 frontstage build 变绿。这两项修好后,基于现有证据我可以给出通过结论;本轮不做合并动作。

English verdict: REQUEST_CHANGES at exact head 4c3514e — the read-only loopx turn managed-step consumer and its behavior-preserving decision extraction are sound (261 turn tests, real-CLI smoke: wait(30s, 1/3) then same-Turn validated progress with exactly one quota slot, and typed refusal with zero writes on damaged journals), but the exact head is unmergeable: the head merge commit carries no DCO Signed-off-by trailer, and the required frontstage build check is red because the branch trails main (identical packaged-asset diff reproduced at the head and at merge base 5c271aa, clean at origin/main 1c7b40e). Repair both in one signed merge onto latest main and re-run Sign-off plus the packaged-asset build check on the new head.

The pure Turn Loop Controller answers `wait` with a typed
`retry_continuation` block when the host failed for a retryable reason, but
nothing in production consumed it: the outer scheduler was left to infer
retryability and backoff from prose. `loopx turn run-once` mints the record and
stops.

Add `loopx turn managed-step`, the first production consumer. It reads one
canonical Turn journal, rebuilds its validated receipt through the existing
`ValidatedTurnReceipt` contract, projects the current control-plane decision as
a fresh `loopx_turn_envelope_v0`, and returns `loopx_turn_managed_step_v0` with
the disposition and, on `wait`, the bounded same-Turn continuation.

The command is read-only by construction: it never invokes a host, writes state,
spends quota, sleeps, or mints a Turn. The Turn journal stays the sole authority
for the attempt count and retry ceiling, so `--observed-attempt` and
`--observed-max-attempts` are reconciled against it and refused on disagreement
rather than adopted. A journal that is not a finished failed Turn, whose typed
host failure is not retryable, or whose recovery plan is `blocked` is refused
before the transition is reached. Carrying `--retry-failed-turn` /
`--resume-turn-key` on the next `run-once` remains the caller's decision.

Because the Turn decision chain is now shared, `run-once` and `managed-step`
build their envelope through one module instead of two copies that could drift.

The new hermetic smoke drives the whole loop through the public CLI with a fake
dsh runner: a first attempt raising `insufficient_capacity` fails with a typed
retryable failure and spends nothing, the managed step answers `wait(30s, 1/3)`
without touching the ledger, and replaying that exact Turn commits
`validated_progress` with exactly one quota slot, where a further replay reports
`replayed` and does not spend again.

Signed-off-by: song <liusongstep@gmail.com>
The shared Turn decision builder wired the operator-inbox activation check to
the raw `--runtime-root` argument instead of the resolved runtime root. When a
registry declares `common_runtime_root` and the command omits the flag, that
raw value is None, so the check read the operator's global
`~/.codex/loopx/extensions/state.json` rather than this registry's extension
state.

Verified on a registry-scoped fixture that installs the extension under its own
runtime root: the unpatched path reported `extension loopx-lark is not
installed` while the resolved path read the registry root. Both owners of this
builder are affected; `run-once` happened to pass the resolved value at its own
call site, so only `managed-step` showed the symptom.

The regression test pins the argument the check receives, and reverting the
wiring fails it.

Signed-off-by: song <liusongstep@gmail.com>
The review found the exact counterexample: a journal with
`host_attempt_count=3` and `host_failure.attempt=1` still returned
`disposition=wait`, with the projection reporting `attempt=3` while
`retry_continuation.attempt` said `1`. The two persisted attempt fields could
therefore diverge and the retry ceiling was bypassed.

The journal records the attempt twice by design: the executor increments
`host_attempt_count`, and `record_host_failure` copies that value into the typed
`host_failure` record the controller reads for the ceiling. They are written
from one value, so a disagreement means the journal was edited or corrupted.

`managed_step_receipt_from_journal` now requires the two to match and fails
closed otherwise, before any reconciliation or controller call, so no branch is
taken on a journal whose presentation and authority disagree.

Verified: the counterexample now raises; an agreeing journal still returns
`wait`; removing the binding fails the new regression; the managed-step suite is
18/18, the wider turn suites are 167/167, the self-heal smoke passes, and Ruff is
clean.

Signed-off-by: song <liusongstep@gmail.com>
…eter

Signed-off-by: song <liusongstep@gmail.com>
@songoow
songoow force-pushed the codex/managed-step-retry-continuation branch from 4c3514e to 9a15412 Compare September 15, 2026 05:52

@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.

队列前言(loopx pr-review --state open,complete=true,policy_revision=3,other-developers-first):47 个 open PR 中 25 个需要 review。本卡覆盖 rank 1 的 #4361——社区贡献者 @songoow 在上一轮 REQUEST_CHANGES 后推送的新 head,tier 0 社区反馈槽位;下一张待办是 rank 2 的 #4360。

结论先行:在这个 exact head 上通过。 上一轮的两个阻塞项都已消失:head 不再是缺少 Signed-off-by 的合并提交(Sign-off = SUCCESS),打包资产检查也随 rebase 变绿;而 PR 自身内容与上一轮逐字节相同(git diff 4c3514e7a 9a154124c -- <10 个 PR 文件> 为空),因此本轮的全部证据都在新 head 上重新取得,未继承上一轮判断。

动机

控制器早就为可重试的宿主失败返回 wait 加类型化 retry_continuation,但在 base 2b4c3cf26 上 git grep -n decide_loop_disposition 2b4c3cf26 -- loopx/ 只能找到定义与包导出,没有任何生产调用方,retry_continuation 也只有 loop_controller.py:698 一个生产者和零个消费者。CLI 侧当时只有 inspect-journal(只报一致性)、plan 和 run-once(执行),所以每个 host 只能从散文里推断这个 Turn 还能不能续跑、预算还剩多少,代价是重复外部副作用或把可修复的 Turn 直接搁死。

这不是「多加一个子命令」的问题:我按最小修法实测过,直接复用既有只读 owner 不够——在同一个 fixture 上跑 loopx turn inspect-journal,返回 journal_consistent=true 以及 decision/replay_legal,但没有 disposition、也没有 retry_continuation,重试处置压根没被计算;而早先那种「把 journal 投影打印出来」的最小补丁还会跳过「用一份新的 governing envelope 重新裁决」。改动前后的对比是可测的:base 上 loopx turn managed-step 直接以 invalid choice: 'managed-step' (choose from inspect-journal, plan, run-once) 退出;head 上同一份失败 journal 给出类型化 wait 与 journal 证明的 attempt 1/3,命令自身不写任何字节。

改动思路

入口是 loopx turn managed-step,经既有 handle_turn_command 分派到 handle_turn_managed_step。权威输入是 canonical Turn journal(turn_journal_path/load_turn_journal)加上由控制面当前决策投影出的新 loopx_turn_envelope_v0;attempt 与重试上限只来自 journal。决策 owner 仍是纯 Turn Loop Controller 的 decide_loop_disposition,journal 一致性仍由既有 TypeScript 解释器(interpret_turn_journal_projection)判定,所以这是「读取 + 重新裁决」,没有任何状态迁移。

所有权代价被压到很小,而且是双向的:新增一个只读 runtime 模块,同时删掉了 turn.py 里内联的那段决策链,抽成 build_turn_decision_builder 供 run-once 与新命令共用,因此两个 owner 不可能各自漂移。抽取是行为保持的,唯一的实质改动是 Lark 激活投影改用已解析的 registry runtime root(此前的 raw 参数在 registry 声明 common_runtime_root 时会静默落回全局默认)。Go/No-Go hook 仍由调用方决定,所以 run-once 继续是唯一执行者,managed-step 不发布任何 hook。

正向路径:失败 Turn 先做前置资格(必须 failed + 类型化 HOST_FAILURE + retryable),对账调用方观测值,交叉绑定两处 attempt,重建并再验证 receipt(含 TS 一致性检查),然后才问 controller 要 disposition,最后用 journal 已被证明的值投影 attempt/预算。负向路径:任何一项不成立都是类型化拒绝 + 退出码 1 + 零写入。隔离性来自「纯增量」:新子命令没有 feature gate、没有新增持久状态,run-once/plan/inspect-journal 的输出与契约不变(378 个 Turn 相关测试在 head 上全通过)。

具体改动

10 个文件 +1710/−55:7 个生产文件(新只读面 + 一次保持行为的抽取)、1 个协议文档章节、2 个测试/示例(441 行 CLI smoke、442 行单测)。改动分类清楚:没有任何 hunk 触碰持久化格式、默认值或既有命令的输出。

关键代码讲解

  1. decide_managed_step(loopx/control_plane/turn_driver/managed_step.py:269):顺序即防线——先拒绝非失败/非宿主失败 Turn,再 reconcile_observed_attempt 对账,再做 receipt 重建与 TS 一致性检查,最后才调用 controller。伪造观测值无法选择分支。
  2. _require_matching_attempts(managed_step.py:55):顶层 host_attempt_count 与嵌套 host_failure.attempt 必须一致。我验证过这不是冗余守门:normalize_host_failure_record 会按 kind+attempt 重建整条记录并要求完全相等,所以嵌套侧无法被伪造;真正的分叉向量就是这两处持久化副本不一致,而本守卫正好是第一条防线。
  3. build_turn_decision_builder(loopx/cli_commands/turn_decision.py:59):从 turn.py 内联闭包逐字抽出,turn.py 因此净减 49 行。hook dispatch 由调用方传入,读路径传空。
  4. handle_turn_managed_step(loopx/cli_commands/turn_managed_step.py:52):对其它 turn 子命令返回 None 保证注册顺序安全;所有异常收敛为 ok=false,不让 traceback 打扰操作者。
  5. render_loopx_turn_managed_step_markdown(loopx/cli_commands/turn_rendering.py:165):成功渲染始终带 execution_authority: none 与 effects: none。

我的验证

  • 21 个 managed-step 单测(3.75s)与 378 个 Turn 套件测试(89.51s)在 head 上通过,ruff check 干净。
  • 仓库自带 smoke 在真实 CLI 上跑通:provider_capacity -> wait(30s, 1/3) -> same-Turn validated_progress,恰好一个 quota 槽位。
  • 我另做了独立反事实(全部在一次性 fixture 上、journal 恢复后再继续):伪造 --observed-attempt 1→2 被拒;把嵌套失败记录重建到 attempt 2 而顶层仍是 1(以及反向)都被拒并报 Turn journal host_failure attempt disagrees with host_attempt_count;伪造 --observed-max-attempts 5 被拒;同一 Turn 提交后再问则报 managed step requires a failed Turn journal。每个用例退出码 1,且整个 runtime 树的哈希逐字节不变——包括 quota ledger。
  • 我实测了 base 与 head 上的 turn --help/子命令分派差异,确认新子命令是纯增量;CI 在 head 上 24 项检查中 21 成功、4 跳过、3 进行中(test-shard 4、stage2c e2e 1、stage2c mutants 0),Sign-off 已是 SUCCESS。

对主干的风险

最强的回归场景不是「代码写错」,而是这里长出第二份重试权威:呈现一个 attempt/预算、却按另一个授权重试,于是调度器唤醒了一个 journal 已经不允许的 Turn,或者把一个 journal 明明允许的 Turn 永久搁死。触发状态是 journal 两处 attempt 副本不一致,或调用方用自己的记账替换 journal 值;防线是上面的检查顺序加「观测值只对账、不采信」,我用真实 CLI 把两个方向都打了一遍,均 fail-closed。

第二类风险是语义越权:外部把 wait 当成执行许可。当前证据是反的——payload/渲染都写着 execution_authority: none,文档明确「有界退避之后唤醒同一个 Turn、是否携带 --retry-failed-turn/--resume-turn-key 由调用方决定」,并且 run-once 仍会重新验证 host-session 与执行权限。

第三类是共享面回归:turn.py 被 run-once 共用,所以隔离不能靠读 diff 推断。我按 disabled-path parity 验证了:base 上 managed-step 以 argparse 拒绝,head 上其余三个子命令行为与测试全绿一致(378 项)。

非阻塞观察(不影响通过,建议后续顺手处理):

  • (P3)重建 receipt 时写入的 mode: "managed_step"(managed_step.py:109)没有任何消费者:ValidatedTurnReceipt.from_execution 不读该字段,而唯一的其它生产者 executor.py:773 写的是 "run_once"。它只是瞬态字典里的修饰值,不落盘、不影响判定,但与「只从已提交字段重建」的注释略有出入;建议删掉或改为透传 journal 自身的值。
  • (P3)441 行 smoke 与 442 行单测在 wait 路径上重叠。二者守的是不同边界(端到端 CLI + quota/只读证据 vs 分支级拒绝),当前可接受,但这是文件再长时应优先压缩的地方。

残余风险:managed-step 的默认 --host dsh 与 isolated-headless 会被接受但未在真实调度上下文里演练;本 PR 不含真实模型/provider 资格验证(fake runner 只替代宿主调用)。这些不影响本轮的只读语义判断。

我的整体评价

可观测语义是明确的新增而非漂移:base 上该子命令不存在(argparse 拒绝),head 上同一份失败 journal 给出类型化 wait 与 journal 证明的 attempt 1/3;同一命令在损坏输入上给出类型化拒绝、退出码 1,且可验证地零写入、零 quota。代码体量与问题相称:一个只读 runtime 模块加一次消除重复决策 owner 的抽取,没有引入第二份状态机、重试账本或新持久化;scope fit 有真实生产调用点(handle_turn_command),并且顺手让 decide_loop_disposition 第一次有了生产消费者。文档与实现的权限边界一致(same Turn、无执行权)。因此本卡给通过结论,两个 P3 观察不阻塞;合并仍走 loopx-pr-merge,本评论不构成合并授权。

English verdict: APPROVE at exact head 9a15412 — the read-only loopx turn managed-step consumer is the controller's first production caller and keeps the journal as the sole attempt/budget authority. Evidence on this head: 21 managed-step tests and 378 Turn-suite tests pass, the shipped CLI smoke reproduces provider_capacity -> wait(30s, 1/3) -> same-Turn validated_progress with exactly one quota slot, and my independent counterfactuals refuse a forged --observed-attempt, a forged ceiling, both directions of a cross-bound attempt journal, and a committed Turn, each with exit 1 and a byte-identical runtime tree. Prior blockers are resolved on the same content: the DCO merge commit is gone (Sign-off SUCCESS) and the packaged-asset check is green after the rebase. Two P3 observations only: the transient mode: "managed_step" value is read by nobody, and the smoke/unit files overlap on the wait path.

@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.

动机

纯 Turn Loop Controller 早已能在容量类宿主失败上给出 wait 与类型化 retry_continuation,但生产环境一直没有 consumer:run-once 会真的执行,inspect-journal 只打印,于是外层 scheduler 只能从散文里猜「同一 Turn 还能不能唤醒、预算是多少」。代价是每个 host 各自实现一套可重试判断,容易出现重复外部副作用或把可修复的 Turn 直接搁死。本 PR 增加 loopx turn managed-step,只读地消费 controller 的处置:读 canonical journal、用一份新的 governing envelope 重新裁决,明确不启动 host、不写业务状态、不花 quota、不 sleep、不启动 scheduler。本轮是对新 head 的复评:上一轮提出的两个阻塞(head 合并提交缺 DCO Signed-off-by、分支落后 main 导致 frontstage 打包资产检查变红)在当前 head 上均已消失——四个提交都带 trailer,Sign-off 与 build 等必需检查全绿。

改动思路

入口是 loopx turn managed-step,经 handle_turn_command 分发;权威输入是 canonical Turn journal(attempt 计数、retry policy、已存 plan/receipt)加一份新采集并签名的 loopx_turn_envelope_v0,决策 owner 仍是纯 controller,journal 一致性仍由既有 TypeScript 解释器判定,因此这是「读取 + 重新裁决」,没有任何状态迁移。所有权方面,作者没有另起第二套一致性状态机或重试账本:run-once 与 managed-step 共用同一段 status/live-decision/envelope 链路(新抽取的 cli_commands/turn_decision.py),抽取时把 Go/No-Go hook 留给调用方,run-once 仍是唯一执行者,managed-step 不发布任何 hook;journal 继续是 attempt/budget 的唯一权威,公开协议文档同步写明只读边界与 wait 语义(有界退避后唤醒同一 Turn,而不是立即消费或写入)。

具体改动

关键代码讲解

  1. managed_step_receipt_from_journal(loopx/control_plane/turn_driver/managed_step.py:126):资格校验按顺序 fail-closed——必须是 failed 且 result_kind 为 HOST_FAILURE、typed host failure 可重试、host_attempt_count 与 host_failure.attempt 必须一致(_require_matching_attempts,第 55/161 行)、lineage 与请求的 goal/agent 相同,最后用现行快照跑 TS 解释器投影,只有 journal_consistent=true 才放行。历史 recovery_audit 不再作为准入依据(上一版用 planned.action=blocked 判断,本轮改为现行快照检查,方向更严谨)。
  2. decide_managed_step(同文件 :269):先做调用方观测值与 journal 的对账(reconcile_observed_attempt),再生成本地 receipt,再调用 decide_loop_disposition,最后用 journal 已证明的值投影 attempt/max_attempts。伪造的观测值无法影响分支选择;predecessor_turn_key 默认取被裁决的 Turn 自身,与 _assert_predecessor_binding 要求「predecessor 必须等于 receipt.turn_key」一致。
  3. build_turn_decision_builder(loopx/cli_commands/turn_decision.py:59):把原先内联在 turn.py 的决策链抽成唯一 owner,run-once 改为调用它;periodic-report pending-intent hook、advisory primary 选择、scan root 与 lookback 上限原样保留。唯一的语义变化是 Lark 激活检查改用已解析的 registry runtime root(此前传原始 --runtime-root,当 registry 声明 common_runtime_root 而命令未传该参数时会是 None),并有单测固定。
  4. handle_turn_managed_step(loopx/cli_commands/turn_managed_step.py:52):解析 journal 路径、构建新 envelope、打印类型化 payload;对其它 turn 子命令返回 None,注册顺序变化不会影响既有命令;所有失败都折叠成 ok=false + 退出码 1,不向用户抛 traceback。

其余改动是注册/渲染(turn_registration.py、turn_rendering.py、turn_driver/__init__.py)、协议文档(Managed Step Surface 一节)、441 行真实 CLI smoke 与 442 行单测;总计 10 文件 +1710/-55,其中 5 个生产文件属于「新增只读面 + 一次保持行为的抽取」,其余是文档与验证资产。负向路径已被验证:journal 缺失、attempt 分叉、phase 前缀损坏均以类型化错误拒绝、退出码 1,不输出重试块、不花 quota、不写 runtime root;正向路径在真实 CLI 上得到 wait(30s, 1/3),随后同一 Turn 自愈并恰好消耗一个 quota 槽位。

对主干的风险

对 run-once 的抽取是本次唯一触及既有路径的改动,我用仓库自带套件做了回归对照:pytest tests -k turn 在 head 上 751 passed / 6 failed,这 6 项在未改动的 origin/main(cd9cfa6d4)上同样失败,属于本机环境继承问题(clone 环境另多出的 3 项,我在同一 clone 里切回 base 提交也复现),不是本 PR 引入。managed-step 本身是纯增量、无 feature gate,默认关闭(只有显式调用才生效),不进入任何调度/唤醒路径。行为披露到位:PR 正文与协议文档都写明了「只读、无执行权限」,以及 Lark 激活检查改用解析后的 runtime root 这一处有意变更。剩余残余风险两条,均不阻塞:其一是 --host/--execution-mode 默认 dsh + isolated-headless,调用方若在别的 surface 上省略这两个参数,会针对不匹配的 scheduler 上下文做裁决(建议在文档示例旁标注或拒绝无上下文调用);其二是每次调用会拉起一次 Node 解释器做 TS 一致性检查,对高频轮询是明确成本。名字与生命周期没有夸大:协议名只承诺「一个 Turn 的一步处置」,没有暗示任何执行或调度权限。

我的整体评价

可观测语义对比清楚:改动前该子命令不存在(argparse 报错),改动后同一份失败 journal 给出类型化 wait 与 journal 证明的 attempt/预算;对损坏输入给出类型化拒绝、退出码 1,零写入零花费。体量与其问题相称:一个只读 runtime 模块加一次消除重复决策 owner 的抽取,没有引入第二份状态机、重试账本或新的持久化,scope fit 有真实生产调用点(handle_turn_command)。两个新修复提交(attempt 交叉绑定、现行快照走 canonical TS 解释器)都在收紧拒绝行为而不是扩张面。因此本轮给出通过结论:exact head 9a15412 上必需检查全绿、DCO 齐全、聚焦测试与真实 CLI smoke 通过,我没有发现阻塞项;本轮不做合并动作。

English verdict: APPROVE at exact head 9a15412 — the read-only loopx turn managed-step consumer, the shared decision-owner extraction, the attempt-field cross-binding and the current-snapshot validation through the canonical TS interpreter are justified and correctly scoped: 21/21 managed-step unit tests, the real-CLI self-heal smoke (wait(30s, 1/3) then same-Turn validated progress with exactly one quota slot, journal bytes unchanged), typed refusals with zero writes on damaged journals, ruff clean on every changed file, and all four commits carry DCO trailers. The prior round's blockers are gone (Sign-off and build green on this head), and the six failures in the wide pytest -k turn selection reproduce identically on untouched origin/main cd9cfa6, so they are environmental rather than introduced here. Two non-blocking notes: the default --host dsh/isolated-headless context may not match a caller's real surface, and each invocation spawns the TS journal interpreter.

@huangruiteng
huangruiteng merged commit 1dc6ad8 into loopx-project:main Sep 15, 2026
28 checks passed
@songoow
songoow deleted the codex/managed-step-retry-continuation branch September 16, 2026 05:54
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.

2 participants