diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 4df4621a23..f4dc248783 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -934,6 +934,7 @@ jobs: tests/test_file_lock_cross_process.py tests/test_host_thread_activity.py tests/control_plane/test_effect_runtime_integration.py + tests/control_plane/test_turn_instance_arg.py tests/control_plane/test_shadow_observation_retirement.py tests/test_python_install_owner_platform.py tests/test_self_update_runtime_activation.py diff --git a/loopx/control_plane/quota/effect_program.py b/loopx/control_plane/quota/effect_program.py index 37644130de..f9247073b5 100644 --- a/loopx/control_plane/quota/effect_program.py +++ b/loopx/control_plane/quota/effect_program.py @@ -54,6 +54,7 @@ "settlement_binding_args", "settlement_result_payload", "settlement_step_command", + "turn_instance_arg", ] @@ -77,6 +78,18 @@ def _quoted_turn_ref(turn_instance_id_ref: str) -> str: return shlex.quote(turn_instance_id_ref) +def turn_instance_arg(turn_instance_id: str | None = None) -> str: + """Render the ``--turn-instance-id`` argument for the shell that runs it. + + A caller holding a concrete Turn passes it through unchanged. Without one, + the argument names the reference the current host shell can expand, so a + native Windows host does not receive an empty Turn from POSIX syntax. + """ + + reference = turn_instance_id or _default_turn_ref() + return f"--turn-instance-id {_quoted_turn_ref(reference)}" + + def _settlement_actor_args(arguments: str, agent_id: str) -> str: """Identity owns the actor; optional host arguments cannot omit or retarget it.""" tokens = shlex.split(arguments) @@ -302,8 +315,4 @@ def settlement_binding_args(plan: Mapping[str, Any] | None) -> str: f" --goal-instance-id " f"{shlex.quote(normalized_goal_ref['goal_instance_id'])}" ) - return ( - binding_arg - + f" --turn-instance-id {_quoted_turn_ref(turn_instance_id)}" - + goal_ref_arg - ) + return binding_arg + " " + turn_instance_arg(turn_instance_id) + goal_ref_arg diff --git a/loopx/control_plane/quota/settlement.py b/loopx/control_plane/quota/settlement.py index 4b438c521a..116d85f176 100644 --- a/loopx/control_plane/quota/settlement.py +++ b/loopx/control_plane/quota/settlement.py @@ -29,6 +29,7 @@ settlement_binding_args, settlement_result_payload, settlement_step_command, + turn_instance_arg, ) QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA = ( @@ -249,6 +250,7 @@ def render_settlement_progress_markdown(payload: dict[str, Any]) -> list[str]: "settlement_binding_args", "settlement_result_payload", "settlement_step_command", + "turn_instance_arg", ] diff --git a/loopx/control_plane/work_items/interaction_contract.py b/loopx/control_plane/work_items/interaction_contract.py index a8e0be0a83..5da4c4c44f 100644 --- a/loopx/control_plane/work_items/interaction_contract.py +++ b/loopx/control_plane/work_items/interaction_contract.py @@ -19,6 +19,7 @@ SettlementStepKind, settlement_binding_args, settlement_step_command, + turn_instance_arg, ) from ..quota.spend_sources import ( build_quota_spend_action, @@ -697,7 +698,7 @@ def _admitted_interaction_next_cli_actions( ) typed_heartbeat_receipt_retry = ( f"on missing/write_failed heartbeat_receipt only: {typed_quota_guard} " - '--turn-instance-id "${LOOPX_TURN:?}"' + f"{turn_instance_arg()}" if scheduler_args else ( "on missing/write_failed heartbeat_receipt only: retry the current " diff --git a/tests/control_plane/test_scheduler_execution_context.py b/tests/control_plane/test_scheduler_execution_context.py index 21063780c0..27e06124a9 100644 --- a/tests/control_plane/test_scheduler_execution_context.py +++ b/tests/control_plane/test_scheduler_execution_context.py @@ -31,6 +31,7 @@ from loopx.control_plane.todos.quota_summary import ( compact_quota_todo_summary_for_payload, ) +from loopx.control_plane.quota import effect_program as quota_effect_program from loopx.control_plane.work_items.interaction_contract import ( finalize_user_gate_notification_cooldown, interaction_next_cli_actions, @@ -1626,7 +1627,19 @@ def test_generic_outer_controller_rerun_actions_are_typed() -> None: assert all(" -H " not in action for action in actions) -def test_codex_app_monitor_quiet_retry_uses_turn_receipt() -> None: +@pytest.mark.parametrize( + ("windows_host", "turn_arg"), + ( + (False, '--turn-instance-id "${LOOPX_TURN:?}"'), + (True, "--turn-instance-id $LOOPX_TURN"), + ), +) +def test_codex_app_monitor_quiet_retry_uses_turn_receipt( + monkeypatch: pytest.MonkeyPatch, + windows_host: bool, + turn_arg: str, +) -> None: + monkeypatch.setattr(quota_effect_program, "_is_windows_host", lambda: windows_host) actions = interaction_next_cli_actions( { "goal_id": "codex-heartbeat-fixture", @@ -1642,7 +1655,7 @@ def test_codex_app_monitor_quiet_retry_uses_turn_receipt() -> None: ( "on missing/write_failed heartbeat_receipt only: loopx --format json " "quota should-run --goal-id codex-heartbeat-fixture --agent-id " - 'codex-fixture --codex-app --turn-instance-id "${LOOPX_TURN:?}"' + f"codex-fixture --codex-app {turn_arg}" ) ] diff --git a/tests/control_plane/test_turn_instance_arg.py b/tests/control_plane/test_turn_instance_arg.py new file mode 100644 index 0000000000..223a71a29d --- /dev/null +++ b/tests/control_plane/test_turn_instance_arg.py @@ -0,0 +1,62 @@ +"""The shared Turn argument must render for the shell that runs it. + +Native Windows has no POSIX ``${NAME:?}`` expansion, so a command built there +has to name the PowerShell variable that carries the Turn. The same helper owns +the settlement binding arguments and the heartbeat-receipt retry hint, so both +inherit one rule instead of one copy each. +""" + +from __future__ import annotations + +import os + +import pytest + +from loopx.control_plane.quota import effect_program as quota_effect_program +from loopx.control_plane.quota.effect_program import turn_instance_arg + +POSIX_REF = "${LOOPX_TURN:?}" +WINDOWS_REF = "$LOOPX_TURN" +POSIX_ARG = f'--turn-instance-id "{POSIX_REF}"' +WINDOWS_ARG = f"--turn-instance-id {WINDOWS_REF}" + + +def _host(monkeypatch: pytest.MonkeyPatch, windows: bool) -> None: + monkeypatch.setattr(quota_effect_program, "_is_windows_host", lambda: windows) + + +@pytest.mark.parametrize( + ("windows", "expected"), + ((False, POSIX_ARG), (True, WINDOWS_ARG)), +) +def test_a_host_default_reference_matches_the_running_shell( + monkeypatch: pytest.MonkeyPatch, windows: bool, expected: str +) -> None: + _host(monkeypatch, windows) + + assert turn_instance_arg() == expected + + +@pytest.mark.parametrize("windows", [False, True]) +def test_an_explicit_turn_is_quoted_unchanged( + monkeypatch: pytest.MonkeyPatch, windows: bool +) -> None: + _host(monkeypatch, windows) + + assert turn_instance_arg("turn-settlement-host-1") == ( + "--turn-instance-id turn-settlement-host-1" + ) + + +def test_a_posix_reference_on_windows_names_the_powershell_variable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _host(monkeypatch, True) + + assert turn_instance_arg(POSIX_REF) == WINDOWS_ARG + + +def test_the_default_matches_the_running_host_shell() -> None: + expected = WINDOWS_ARG if os.name == "nt" else POSIX_ARG + + assert turn_instance_arg() == expected