diff --git a/docs/reference/protocols/host-mode-plan-v0.md b/docs/reference/protocols/host-mode-plan-v0.md index 1df78a7009..6651139075 100644 --- a/docs/reference/protocols/host-mode-plan-v0.md +++ b/docs/reference/protocols/host-mode-plan-v0.md @@ -121,9 +121,11 @@ as an executor. "selected_connector_id": "loopx_turn", "selected_turn_mapping": { "host": "generic-cli", + "host_selection": "resolved_default", "execution_mode": "isolated-headless", "scheduler_owner": "outer_controller", - "plan_command": "loopx turn plan --goal-id loopx-meta --agent-id codex-main-control --host generic-cli --execution-mode isolated-headless --scheduler-owner outer_controller" + "plan_command": "loopx turn plan --goal-id loopx-meta --agent-id codex-main-control --execution-mode isolated-headless --scheduler-owner outer_controller", + "plan_command_rollback": "loopx turn plan --goal-id loopx-meta --agent-id codex-main-control --host generic-cli --execution-mode isolated-headless --scheduler-owner outer_controller" }, "next_preview_command": "loopx turn plan ...", "mode_options": [], @@ -161,8 +163,11 @@ A fixture or implementation is acceptable when: 1. `schema_version=host_mode_plan_v0` and `mode=dry_run_host_mode_selector`; 2. the five canonical modes are present and intent selects the expected mode; -3. `isolated_headless_turn` maps to `loopx turn plan --host generic-cli - --execution-mode isolated-headless --scheduler-owner outer_controller`; +3. `isolated_headless_turn` maps to the shipped host resolution: the preview + command is `loopx turn plan --execution-mode isolated-headless + --scheduler-owner outer_controller` with no pinned `--host`, and the pinned + compatibility variant is reported as `plan_command_rollback` + (`--host generic-cli`); 4. scoped identity flows into Turn and quota preview commands as `--agent-id`; 5. the no-spend policy covers selector previews, Turn plan previews, quiet monitors, cadence-only changes, and final/readiness checks; diff --git a/examples/host-mode-plan-smoke.py b/examples/host-mode-plan-smoke.py index 3131ea438e..a31a9276d8 100755 --- a/examples/host-mode-plan-smoke.py +++ b/examples/host-mode-plan-smoke.py @@ -108,15 +108,20 @@ def test_headless_maps_to_loopx_turn_plan_not_parallel_runner() -> None: plan = build_full_plan("continue_without_ui") selected = plan["selected_turn_mapping"] assert selected["host"] == "generic-cli", selected + assert selected["host_selection"] == "resolved_default", selected assert selected["execution_mode"] == "isolated-headless", selected assert selected["scheduler_owner"] == "outer_controller", selected command = selected["plan_command"] assert "loopx turn plan" in command, command - assert "--host generic-cli" in command, command + # The preview keeps the shipped host resolution, so a lane without an + # operator credential does not land on the compatibility adapter path. + assert "--host" not in command, command assert "--execution-mode isolated-headless" in command, command assert "--scheduler-owner outer_controller" in command, command assert "--agent-id codex-main-control" in command, command assert "--available-capability shell" in command, command + rollback = selected["plan_command_rollback"] + assert "--host generic-cli" in rollback, rollback assert plan["turn_contract"]["schema_version"] == "loopx_turn_v0", plan assert plan["turn_contract"]["independent_validation_required"] is True, plan assert plan["turn_contract"]["writeback_before_quota_spend"] is True, plan diff --git a/examples/project/host-mode-plan-cli-smoke.py b/examples/project/host-mode-plan-cli-smoke.py index e621e1991f..1bd7afaeb1 100755 --- a/examples/project/host-mode-plan-cli-smoke.py +++ b/examples/project/host-mode-plan-cli-smoke.py @@ -66,11 +66,16 @@ def test_cli_selects_headless_turn_and_scopes_agent_id() -> None: assert payload["selected_capability_ready"] is True, payload command = payload["next_preview_command"] assert "loopx turn plan" in command, command - assert "--host generic-cli" in command, command + # The preview keeps the shipped host resolution instead of pinning the + # compatibility adapter host; the pinned variant stays available as the + # mode's rollback command. + assert "--host" not in command, command assert "--execution-mode isolated-headless" in command, command assert "--scheduler-owner outer_controller" in command, command assert "--agent-id codex-main-control" in command, command assert "--available-capability shell" in command, command + rollback = payload["selected_turn_mapping"]["plan_command_rollback"] + assert "--host generic-cli" in rollback, rollback assert payload["selected_missing_host_capabilities"] == [], payload assert payload["selected_blocking_reasons"] == [], payload assert payload["operator_next_steps"][0]["kind"] == "state_preview", payload diff --git a/loopx/host_mode_planner.py b/loopx/host_mode_planner.py index fb6314fc0d..ae2f1720f6 100644 --- a/loopx/host_mode_planner.py +++ b/loopx/host_mode_planner.py @@ -120,6 +120,18 @@ ] _INTENT_PRIMARY_MODE = {meta["intent"]: mode for mode, meta in _MODE_METADATA.items()} +# The headless Turn modes preview the *shipped* host resolution instead of +# pinning one host. `loopx turn plan`/`run-once` resolve their default from the +# operator credential, so a preview that pinned `generic-cli` would quietly ask +# every operator for the compatibility adapter path. The declared host stays in +# the mapping and in the rollback command, because the mode's scheduler context +# and capability requirements are still stated for it. +RESOLVED_DEFAULT_TURN_HOST_MODES = frozenset( + {MODE_ISOLATED_HEADLESS_TURN, MODE_SHELL_SERVICE} +) +TURN_HOST_SELECTION_PINNED = "pinned" +TURN_HOST_SELECTION_RESOLVED_DEFAULT = "resolved_default" + # Typed host identity -> runtime connector catalog id. Only identities with a # registered catalog connector may emit a host-specific visible mapping; any # other identity fails closed instead of fabricating a connector id. @@ -260,6 +272,7 @@ def _turn_plan_command( cli_bin: str, available_capabilities: list[str] | None, host_identity: str | None, + pin_host: bool = True, ) -> str | None: meta = _MODE_METADATA[mode] turn_host = meta.get("turn_host") @@ -303,9 +316,10 @@ def _turn_plan_command( ) scheduler_owner = meta.get("scheduler_owner") scheduler_arg = f" --scheduler-owner {shell_arg(scheduler_owner)}" if scheduler_owner else "" + host_arg = f" --host {shell_arg(turn_host)}" if pin_host else "" return ( - f"{shell_arg(cli_bin)} turn plan --goal-id {shell_arg(goal_id)}{agent_arg} " - f"--host {shell_arg(turn_host)} --execution-mode {shell_arg(execution_mode)}" + f"{shell_arg(cli_bin)} turn plan --goal-id {shell_arg(goal_id)}" + f"{agent_arg}{host_arg} --execution-mode {shell_arg(execution_mode)}" f"{scheduler_arg}{capability_args}" ) @@ -526,6 +540,7 @@ def _build_mode_option( visible_unresolved = ( mode == MODE_VISIBLE_TUI and host_identity not in VISIBLE_HOST_CONNECTOR_IDS ) + resolves_default_host = mode in RESOLVED_DEFAULT_TURN_HOST_MODES turn_plan_command = ( None if visible_unresolved @@ -536,8 +551,25 @@ def _build_mode_option( cli_bin=cli_bin, available_capabilities=available_capabilities, host_identity=host_identity, + pin_host=not resolves_default_host, ) ) + # A mode that previews the shipped resolution still names the pinned host it + # would use instead, so an operator who wants the compatibility adapter path + # reads one command rather than re-deriving the flags. + turn_plan_command_rollback = ( + _turn_plan_command( + goal_id=goal_id, + agent_id=agent_id, + mode=mode, + cli_bin=cli_bin, + available_capabilities=available_capabilities, + host_identity=host_identity, + pin_host=True, + ) + if resolves_default_host + else None + ) quota_guard_command = _mode_quota_guard( mode=mode, goal_id=goal_id, @@ -614,9 +646,15 @@ def _build_mode_option( "recommended_next_steps": recommended_next_steps, "turn_mapping": { "host": effective_turn_host, + "host_selection": ( + TURN_HOST_SELECTION_RESOLVED_DEFAULT + if resolves_default_host + else TURN_HOST_SELECTION_PINNED + ), "execution_mode": meta.get("turn_execution_mode"), "scheduler_owner": meta.get("scheduler_owner"), "plan_command": turn_plan_command, + "plan_command_rollback": turn_plan_command_rollback, }, "scheduler_execution_context": _scheduler_context(mode, host_identity), "quota_guard_command": quota_guard_command, @@ -641,6 +679,7 @@ def target_turn_command(to_mode: str) -> str | None: cli_bin=cli_bin, available_capabilities=available_capabilities, host_identity=host_identity, + pin_host=to_mode not in RESOLVED_DEFAULT_TURN_HOST_MODES, ) except HostModePlanError: # A visible target without a catalog-registered identity has no diff --git a/loopx/semantics/inventory_v0.json b/loopx/semantics/inventory_v0.json index d5620542d4..d8d2b3704e 100644 --- a/loopx/semantics/inventory_v0.json +++ b/loopx/semantics/inventory_v0.json @@ -907,7 +907,7 @@ "python_closed_sets": 495, "python_literal_aliases": 8, "typescript_const_arrays": 40, - "named_string_constants": 2031, + "named_string_constants": 2033, "schema_version_names": 756, "schema_version_same_runtime_forks": 7, "cross_runtime_twins": 166,