From 11b97ac60af02723d09c6fa043dcefc33d42b775 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:30:27 +0800 Subject: [PATCH 1/2] fix(turn): restore the shared current-Turn decision owner `run-once` and `managed-step` must resolve the same governing decision. #4443 (2e96a570d) replaced the shared call in `loopx/cli_commands/turn.py` with a private copy of the whole chain - live status, scheduler context, capability hook projection, decision parameters, advisory-primary rebinding and the signed envelope - and a1213edce (#4451) restored only the advisory-primary half. The copy stayed, so a decision input added to the shared owner would have moved one Turn subcommand and not the other. Route `run-once` through the shared owner. `build_fresh_turn_decision` owns the whole chain and returns the decision together with the envelope signed from that same decision, so an owner that also needs the raw decision (reward recall) cannot re-derive a second copy. `build_fresh_envelope_for_managed_step` stays the read-only projection: it exposes no hook parameter, so the managed step cannot start publishing Go/No-Go hooks by accident. Evidence (`/private/tmp/loopx-turn-dedup`, origin/main 292b85e90): - `turn plan` on the same fixture emits a byte-identical `turn_envelope` before and after (`diff` of the JSON dumps: no difference). - `examples/loopx-turn-managed-step-self-heal-smoke.py` prints identical output before and after. - `tests/test_loopx_turn_{managed_step,driver,executor,codex_cli}.py`, `tests/test_turn_{envelope,managed_executor_binding,default_host_binding, loop_disposition}.py`, `tests/test_loop_{turn_loop_controller}.py`, `tests/test_loopx_turn_{settlement_parity,host_failure,journal_inspection, transaction}.py`: 386 passed. - `tests/architecture` plus the control-plane portfolio/CLI-budget/capability memory/shadow-e2e suites: 140 passed. The test seam for the adaptive orchestration contract moves with the code: the envelope is signed by the shared owner, so `tests/test_loopx_turn_driver.py` injects `build_turn_envelope` where that owner resolves it, and `tests/test_loopx_turn_journal_inspection.py` guards the live reads of the shared owner instead of the removed `turn.py` re-import. A new plan-mode test fails if `turn.py` ever reads its live status outside the shared owner again. The post-settlement `loopx_turn_run_once` scheduler re-evaluation still builds its own decision: it deliberately re-reads status after the commit, has no advisory primary and carries a different route source, so folding it into the plan owner would change behavior. Boundary considered, left as is. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- tests/test_loopx_turn_driver.py | 66 ++++++++++++++++++++- tests/test_loopx_turn_journal_inspection.py | 12 +++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/tests/test_loopx_turn_driver.py b/tests/test_loopx_turn_driver.py index bd98280dc2..fdf7d3b376 100644 --- a/tests/test_loopx_turn_driver.py +++ b/tests/test_loopx_turn_driver.py @@ -1515,6 +1515,64 @@ def test_turn_cli_consumes_live_state_without_writes( assert before == after +def test_turn_cli_resolves_its_decision_through_the_shared_owner( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``turn`` must not re-inline the chain it shares with the managed step. + + #4443 replaced the shared call with a private copy of status -> scheduler + context -> decision -> envelope inside ``loopx/cli_commands/turn.py``. A + duplicate copy is not a formatting problem: adding one decision input to + the shared owner would then move only one subcommand, and ``run-once`` and + ``managed-step`` would disagree about what the current Turn should do. + Reading the live status through the shared owner is the fact that + identifies a private copy. + """ + + from loopx.cli_commands import turn_decision + + project, runtime, registry = _write_live_fixture(tmp_path) + status_reads: list[dict[str, Any]] = [] + real_collect_status = turn_decision.collect_status + + def recording_collect_status(*args: Any, **kwargs: Any) -> dict[str, Any]: + status_reads.append(dict(kwargs)) + return real_collect_status(*args, **kwargs) + + monkeypatch.setattr(turn_decision, "collect_status", recording_collect_status) + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = cli_main( + [ + "--registry", + str(registry), + "--runtime-root", + str(runtime), + "--format", + "json", + "turn", + "plan", + "--goal-id", + "loopx-turn-fixture", + "--agent-id", + "codex-fixture", + "--scan-root", + str(project), + ] + ) + + payload = json.loads(output.getvalue()) + assert exit_code == 0, payload + assert payload["turn_envelope"]["schema_version"] == "loopx_turn_envelope_v0" + assert len(status_reads) == 1, ( + "`turn` must read its live status through the shared Turn decision " + "owner so run-once and managed-step cannot drift; shared-owner status " + f"reads: {len(status_reads)}" + ) + + def test_turn_cli_projects_explicit_fresh_iteration_context( tmp_path: Path, ) -> None: @@ -2863,10 +2921,12 @@ def test_turn_run_once_cli_uses_built_in_codex_host_and_typed_writeback( result_kind: str, ) -> None: from loopx.cli_commands.turn import ( - build_turn_envelope as real_build_turn_envelope, refresh_state_run as real_refresh_state_run, spend_quota_slot as real_spend_quota_slot, ) + from loopx.cli_commands.turn_decision import ( + build_turn_envelope as real_build_turn_envelope, + ) from loopx.cli_commands.turn_todo_writeback import ( update_goal_todo as real_update_goal_todo, @@ -2953,8 +3013,10 @@ def fake_codex_host(request: dict[str, object], **_kwargs: object) -> dict[str, } monkeypatch.setattr("loopx.cli_commands.turn.run_codex_cli_host", fake_codex_host) + # The envelope is signed by the shared decision owner, so the adaptive + # orchestration contract is injected where that owner resolves the builder. monkeypatch.setattr( - "loopx.cli_commands.turn.build_turn_envelope", + "loopx.cli_commands.turn_decision.build_turn_envelope", adaptive_turn_envelope, ) monkeypatch.setattr( diff --git a/tests/test_loopx_turn_journal_inspection.py b/tests/test_loopx_turn_journal_inspection.py index 61b4ab10c8..167e2d9230 100644 --- a/tests/test_loopx_turn_journal_inspection.py +++ b/tests/test_loopx_turn_journal_inspection.py @@ -10,6 +10,7 @@ from loopx.cli import main as cli_main from loopx.cli_commands import turn as turn_command +from loopx.cli_commands import turn_decision from loopx.cli_commands import turn_rendering, turn_todo_writeback from loopx.control_plane.turn_driver import executor from loopx.control_plane.turn_driver import turn_journal_runtime @@ -295,7 +296,6 @@ def unexpected_call(*args: object, **kwargs: object) -> None: for name in ( "build_lark_operator_inbox_urgency_projector", "collect_status", - "scheduler_execution_context_for_turn", "build_live_quota_should_run_decision", "build_loopx_turn_plan", "run_codex_cli_host", @@ -304,6 +304,16 @@ def unexpected_call(*args: object, **kwargs: object) -> None: "refresh_state_run", ): monkeypatch.setattr(turn_command, name, unexpected_call) + # These live reads now belong to the shared Turn decision owner, so + # ``inspect-journal`` must never reach them there either. + for name in ( + "build_lark_operator_inbox_urgency_projector", + "collect_status", + "scheduler_execution_context_for_turn", + "build_live_quota_should_run_decision", + "build_turn_envelope", + ): + monkeypatch.setattr(turn_decision, name, unexpected_call) for name in ("complete_goal_todo", "update_goal_todo"): monkeypatch.setattr(turn_todo_writeback, name, unexpected_call) monkeypatch.setattr(executor, "execute_turn_driver_settlement", unexpected_call) From 263d08e264462abc4131f23d6e49648fe1be2090 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:45:29 +0800 Subject: [PATCH 2/2] test(turn): keep the current-Turn owner guards on the owner The shared Turn decision owner now performs the live reads a Turn used to resolve for itself: live status, scheduler execution context and the operator-inbox projector live in `cli_commands/turn_decision.py`, and the envelope is signed from the owner's decision. The inspect-journal guard still patched those names on the command module, so it raised `AttributeError` instead of proving the inspection branch stays read-free, and `main` has been red on it since the owner landed. Patch the three reads where they live, keep the envelope builder the command still signs with, and add a regression test that fails when a subcommand re-inlines the shared status read. Both guards are mutation-checked: a duplicate status read fails the new test, and a live read on the inspection path fails the existing one. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- tests/test_loopx_turn_driver.py | 20 +++++++------------- tests/test_loopx_turn_journal_inspection.py | 12 ++++++------ 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/tests/test_loopx_turn_driver.py b/tests/test_loopx_turn_driver.py index fdf7d3b376..e3dbc42583 100644 --- a/tests/test_loopx_turn_driver.py +++ b/tests/test_loopx_turn_driver.py @@ -1521,13 +1521,11 @@ def test_turn_cli_resolves_its_decision_through_the_shared_owner( ) -> None: """``turn`` must not re-inline the chain it shares with the managed step. - #4443 replaced the shared call with a private copy of status -> scheduler - context -> decision -> envelope inside ``loopx/cli_commands/turn.py``. A - duplicate copy is not a formatting problem: adding one decision input to - the shared owner would then move only one subcommand, and ``run-once`` and - ``managed-step`` would disagree about what the current Turn should do. - Reading the live status through the shared owner is the fact that - identifies a private copy. + A private copy inside ``loopx/cli_commands/turn.py`` is not a formatting + problem: adding one decision input to the shared owner would then move only + one subcommand, and ``run-once`` and ``managed-step`` would disagree about + what the current Turn should do. Reading the live status through the shared + owner is the fact that identifies a private copy. """ from loopx.cli_commands import turn_decision @@ -2921,12 +2919,10 @@ def test_turn_run_once_cli_uses_built_in_codex_host_and_typed_writeback( result_kind: str, ) -> None: from loopx.cli_commands.turn import ( + build_turn_envelope as real_build_turn_envelope, refresh_state_run as real_refresh_state_run, spend_quota_slot as real_spend_quota_slot, ) - from loopx.cli_commands.turn_decision import ( - build_turn_envelope as real_build_turn_envelope, - ) from loopx.cli_commands.turn_todo_writeback import ( update_goal_todo as real_update_goal_todo, @@ -3013,10 +3009,8 @@ def fake_codex_host(request: dict[str, object], **_kwargs: object) -> dict[str, } monkeypatch.setattr("loopx.cli_commands.turn.run_codex_cli_host", fake_codex_host) - # The envelope is signed by the shared decision owner, so the adaptive - # orchestration contract is injected where that owner resolves the builder. monkeypatch.setattr( - "loopx.cli_commands.turn_decision.build_turn_envelope", + "loopx.cli_commands.turn.build_turn_envelope", adaptive_turn_envelope, ) monkeypatch.setattr( diff --git a/tests/test_loopx_turn_journal_inspection.py b/tests/test_loopx_turn_journal_inspection.py index 167e2d9230..9aed5afa78 100644 --- a/tests/test_loopx_turn_journal_inspection.py +++ b/tests/test_loopx_turn_journal_inspection.py @@ -294,24 +294,24 @@ def unexpected_call(*args: object, **kwargs: object) -> None: raise AssertionError("inspect-journal reached a live or write path") for name in ( - "build_lark_operator_inbox_urgency_projector", - "collect_status", "build_live_quota_should_run_decision", "build_loopx_turn_plan", + "build_turn_envelope", "run_codex_cli_host", "run_loopx_turn_once", "spend_quota_slot", "refresh_state_run", ): monkeypatch.setattr(turn_command, name, unexpected_call) - # These live reads now belong to the shared Turn decision owner, so - # ``inspect-journal`` must never reach them there either. + # The shared decision owner now performs the live reads this command used to + # resolve itself, so the guard has to patch them where they live. Patching + # the old ``turn_command`` names would fail loudly here instead of proving + # anything: `turn` no longer resolves its own status, scheduler context or + # operator-inbox projector. for name in ( "build_lark_operator_inbox_urgency_projector", "collect_status", "scheduler_execution_context_for_turn", - "build_live_quota_should_run_decision", - "build_turn_envelope", ): monkeypatch.setattr(turn_decision, name, unexpected_call) for name in ("complete_goal_todo", "update_goal_todo"):