From 4287d98ae9ae1a1d3d5d9410c14e310742766955 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Wed, 16 Sep 2026 06:31:36 +0800 Subject: [PATCH] feat(turn): name the operator-reachable exits in a managed executor refusal Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- docs/reference/protocols/loopx-turn-v0.md | 18 ++++- ...opx-turn-managed-executor-binding-smoke.py | 14 ++++ loopx/control_plane/turn_driver/executor.py | 2 + .../control_plane/turn_driver/host_binding.py | 70 +++++++++++++++++++ loopx/semantics/inventory_v0.json | 2 +- tests/test_turn_managed_executor_binding.py | 61 ++++++++++++++++ 6 files changed, 165 insertions(+), 2 deletions(-) diff --git a/docs/reference/protocols/loopx-turn-v0.md b/docs/reference/protocols/loopx-turn-v0.md index 838b62eaf6..81648650fa 100644 --- a/docs/reference/protocols/loopx-turn-v0.md +++ b/docs/reference/protocols/loopx-turn-v0.md @@ -152,7 +152,7 @@ ambient environment. (`managed_executor_binding_v0`): the executor and its kind (`managed`, `individual`, `generic`), the credential env var *name* (never its value), the endpoint env var name, whether the executor is operator-credential-bound, and -whether it can launch here. When it cannot, `available` is `false` and +whether it can launch here. When it cannot, `available` is `false`, `unavailable_reason` names the missing fact: | `unavailable_reason` | meaning | remediation | @@ -161,6 +161,22 @@ whether it can launch here. When it cannot, `available` is `false` and | `operator_credential_unconfigured` | the managed host is selected but no operator credential or runner hook would authenticate it | set `DEEPSEEK_API_KEY`, or select `--host codex-cli` explicitly | | `invalid_reasoning_effort` | the resolved execution profile names a reasoning effort the host adapter does not support | pass a supported `--dsh-reasoning-effort`, or clear the overriding environment variable | +The same readback also carries `unavailable_remediation`, which names those +exits as typed codes so a caller does not have to parse the reason string: + +| `unavailable_remediation` | exit it names | +| --- | --- | +| `configure_operator_credential` | set the credential env var this readback reports as `credential_env` | +| `configure_dsh_runtime` | install the released runtime, or pass its runner hook | +| `correct_execution_profile` | pass a supported `--dsh-reasoning-effort`, or clear the overriding environment variable | +| `select_individual_host` | select the individual host instead of the managed one | + +The list is empty for every launchable or non-managed executor, and naming an +exit selects nothing: acting on it is still an explicit credential, profile, or +`--host` change. The refusal `run-once --execute` returns on that verdict +repeats the exits as `remediation` and adds the concrete `remediation_host` and +`remediation_env_vars`. + `run-once --execute` fails closed on that verdict: status `unavailable`, no host invocation, no Journal write, and no quota slot spend. An explicitly selected individual host (`--host codex-cli`, `--host claude-code`) is billed to that diff --git a/examples/loopx-turn-managed-executor-binding-smoke.py b/examples/loopx-turn-managed-executor-binding-smoke.py index 56e71afbbc..791c425446 100644 --- a/examples/loopx-turn-managed-executor-binding-smoke.py +++ b/examples/loopx-turn-managed-executor-binding-smoke.py @@ -31,6 +31,9 @@ EXECUTOR_KIND_INDIVIDUAL, EXECUTOR_KIND_MANAGED, OPERATOR_CREDENTIAL_UNCONFIGURED, + REMEDY_CONFIGURE_DSH_RUNTIME, + REMEDY_CONFIGURE_OPERATOR_CREDENTIAL, + REMEDY_SELECT_INDIVIDUAL_HOST, ) @@ -314,6 +317,13 @@ def main() -> int: assert refusal["ok"] is False, refusal assert refusal["status"] == "unavailable", refusal assert refusal["reason"] == OPERATOR_CREDENTIAL_UNCONFIGURED, refusal + # The refusal is actionable: the exits travel with the typed reason. + assert refusal["remediation"] == [ + REMEDY_CONFIGURE_OPERATOR_CREDENTIAL, + REMEDY_SELECT_INDIVIDUAL_HOST, + ], refusal + assert refusal["remediation_host"] == "codex-cli", refusal + assert refusal["remediation_env_vars"] == [CREDENTIAL_ENV], refusal _expect_no_effects(refusal) _expect_no_journal(refusal, runtime) @@ -335,6 +345,10 @@ def main() -> int: assert refusal["ok"] is False, refusal assert refusal["status"] == "unavailable", refusal assert refusal["reason"] == DSH_RUNTIME_UNAVAILABLE, refusal + assert refusal["remediation"] == [ + REMEDY_CONFIGURE_DSH_RUNTIME, + REMEDY_SELECT_INDIVIDUAL_HOST, + ], refusal _expect_no_effects(refusal) _expect_no_journal(refusal, runtime) diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index fdd3e477b2..0c70dd403e 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -30,6 +30,7 @@ from .driver import selected_turn_todo from .host_binding import ( managed_executor_payload_entry, + managed_executor_remediation_projection, managed_executor_unavailable_payload, ) from .host_failure import BuiltInHostError, project_host_failure, record_host_failure @@ -802,6 +803,7 @@ def _execution_payload( ), **({"todo_completion": todo_completion} if todo_completion else {}), **({"reason": journal.get("reason")} if journal.get("reason") else {}), + **managed_executor_remediation_projection(journal), **project_host_failure(journal), **({"recovery": dict(recovery)} if isinstance(recovery, Mapping) else {}), } diff --git a/loopx/control_plane/turn_driver/host_binding.py b/loopx/control_plane/turn_driver/host_binding.py index 517da0ba89..88ccc05222 100644 --- a/loopx/control_plane/turn_driver/host_binding.py +++ b/loopx/control_plane/turn_driver/host_binding.py @@ -28,6 +28,7 @@ from typing import Any, Callable from ..operator_credential import ( + OPERATOR_CREDENTIAL_ENV_VARS, OPERATOR_ENDPOINT_ENV_VAR, configured_operator_credential, env_text, @@ -68,6 +69,14 @@ # endpoint, so it refuses instead of letting the managed default consume # whatever personal login happens to exist on the machine. OPERATOR_CREDENTIAL_UNCONFIGURED = "operator_credential_unconfigured" +# Operator-reachable exits from a fail-closed managed executor. A refusal that +# names only the missing fact leaves the operator to guess the way out, so the +# same typed readback names the exits as codes. These describe what the operator +# can change here; they never select a host, endpoint, or model themselves. +REMEDY_CONFIGURE_OPERATOR_CREDENTIAL = "configure_operator_credential" +REMEDY_CONFIGURE_DSH_RUNTIME = "configure_dsh_runtime" +REMEDY_CORRECT_EXECUTION_PROFILE = "correct_execution_profile" +REMEDY_SELECT_INDIVIDUAL_HOST = "select_individual_host" def selected_turn_host( @@ -109,6 +118,27 @@ def dsh_runtime_importable( return False +def _managed_unavailable_remediation(reason: str | None) -> list[str]: + """Name the operator-reachable exits from one managed refusal. + + The list is empty whenever the managed executor can launch, so a caller + reads the same field in both states instead of branching on its presence. + Selecting an individual host is always one exit, because it is the + documented alternative to a managed host nothing here can authenticate. + """ + + if reason is None: + return [] + remedy_by_reason = { + DSH_RUNTIME_UNAVAILABLE: REMEDY_CONFIGURE_DSH_RUNTIME, + OPERATOR_CREDENTIAL_UNCONFIGURED: REMEDY_CONFIGURE_OPERATOR_CREDENTIAL, + } + return [ + remedy_by_reason.get(reason, REMEDY_CORRECT_EXECUTION_PROFILE), + REMEDY_SELECT_INDIVIDUAL_HOST, + ] + + def managed_executor_binding( host: str, *, @@ -164,6 +194,9 @@ def managed_executor_binding( "operator_credential_bound": operator_credential_bound, "available": unavailable_reason is None, "unavailable_reason": unavailable_reason, + "unavailable_remediation": _managed_unavailable_remediation( + unavailable_reason + ), } return { "schema_version": MANAGED_EXECUTOR_BINDING_SCHEMA_VERSION, @@ -179,6 +212,7 @@ def managed_executor_binding( "operator_credential_bound": False, "available": None, "unavailable_reason": None, + "unavailable_remediation": [], } @@ -213,8 +247,44 @@ def managed_executor_unavailable_payload( binding = plan.get("managed_executor") if not isinstance(binding, Mapping) or binding.get("available") is not False: return None + remediation = binding.get("unavailable_remediation") return { "status": "unavailable", "host": dict(host_projection), "reason": str(binding.get("unavailable_reason") or ""), + # The operator-facing refusal carries the exits as data, so a caller + # does not have to re-derive them from the reason string. + "remediation": [ + str(code) + for code in (remediation if isinstance(remediation, list) else []) + if str(code) + ], + "remediation_host": INDIVIDUAL_TURN_HOST, + "remediation_env_vars": list(OPERATOR_CREDENTIAL_ENV_VARS), + } + + +def managed_executor_remediation_projection( + journal: Mapping[str, Any], +) -> dict[str, Any]: + """Return the refusal's remediation entry for a public payload, else ``{}``. + + Only a refusal that named at least one exit projects anything, so a payload + that can launch stays byte-identical to the one before this field existed. + """ + + remediation = journal.get("remediation") + if not isinstance(remediation, list) or not remediation: + return {} + projection: dict[str, Any] = { + "remediation": [str(code) for code in remediation if str(code)] } + host = journal.get("remediation_host") + if isinstance(host, str) and host: + projection["remediation_host"] = host + env_vars = journal.get("remediation_env_vars") + if isinstance(env_vars, list): + projection["remediation_env_vars"] = [ + str(name) for name in env_vars if str(name) + ] + return projection diff --git a/loopx/semantics/inventory_v0.json b/loopx/semantics/inventory_v0.json index 0b59480e33..eaaadbbb12 100644 --- a/loopx/semantics/inventory_v0.json +++ b/loopx/semantics/inventory_v0.json @@ -904,7 +904,7 @@ "python_closed_sets": 492, "python_literal_aliases": 8, "typescript_const_arrays": 40, - "named_string_constants": 2045, + "named_string_constants": 2049, "schema_version_names": 756, "schema_version_same_runtime_forks": 7, "cross_runtime_twins": 166, diff --git a/tests/test_turn_managed_executor_binding.py b/tests/test_turn_managed_executor_binding.py index 48cc9c33ec..0ba80ac797 100644 --- a/tests/test_turn_managed_executor_binding.py +++ b/tests/test_turn_managed_executor_binding.py @@ -12,6 +12,11 @@ MANAGED_EXECUTOR_BINDING_SCHEMA_VERSION, MANAGED_TURN_HOST, OPERATOR_CREDENTIAL_UNCONFIGURED, + REMEDY_CONFIGURE_DSH_RUNTIME, + REMEDY_CONFIGURE_OPERATOR_CREDENTIAL, + REMEDY_CORRECT_EXECUTION_PROFILE, + REMEDY_SELECT_INDIVIDUAL_HOST, + managed_executor_unavailable_payload, managed_executor_binding, resolve_default_turn_host, ) @@ -52,6 +57,7 @@ def test_managed_executor_reports_the_operator_credential_and_endpoint(): "operator_credential_bound": True, "available": True, "unavailable_reason": None, + "unavailable_remediation": [], } @@ -64,6 +70,61 @@ def test_managed_executor_fails_closed_when_the_runtime_is_missing(): assert binding["available"] is False assert binding["unavailable_reason"] == DSH_RUNTIME_UNAVAILABLE + assert binding["unavailable_remediation"] == [ + REMEDY_CONFIGURE_DSH_RUNTIME, + REMEDY_SELECT_INDIVIDUAL_HOST, + ] + + +def test_the_refusal_names_the_operator_reachable_exits(): + """A typed reason alone leaves the operator to guess the way out.""" + + binding = managed_executor_binding("dsh", environ={}, module_probe=_RUNTIME) + refusal = managed_executor_unavailable_payload( + {"managed_executor": binding}, + execute=True, + host_projection={"host": "dsh"}, + ) + + assert binding["unavailable_remediation"] == [ + REMEDY_CONFIGURE_OPERATOR_CREDENTIAL, + REMEDY_SELECT_INDIVIDUAL_HOST, + ] + assert refusal is not None + assert refusal["reason"] == OPERATOR_CREDENTIAL_UNCONFIGURED + # The exits travel as data: the credential variable to set and the host to + # select instead, so no caller has to re-derive them from the reason. + assert refusal["remediation"] == binding["unavailable_remediation"] + assert refusal["remediation_host"] == "codex-cli" + assert refusal["remediation_env_vars"] == ["DEEPSEEK_API_KEY"] + + +def test_a_refused_execution_profile_names_its_own_remedy(): + binding = managed_executor_binding( + "dsh", + environ={ + "DEEPSEEK_API_KEY": "sk-operator", + "LOOPX_TURN_REASONING_EFFORT": "turbo", + }, + module_probe=_RUNTIME, + ) + + assert binding["unavailable_reason"] == INVALID_REASONING_EFFORT + assert binding["unavailable_remediation"] == [ + REMEDY_CORRECT_EXECUTION_PROFILE, + REMEDY_SELECT_INDIVIDUAL_HOST, + ] + + +def test_a_launchable_executor_offers_no_remedy(): + binding = managed_executor_binding( + "dsh", + environ={"DEEPSEEK_API_KEY": "sk-operator"}, + module_probe=_RUNTIME, + ) + + assert binding["available"] is True + assert binding["unavailable_remediation"] == [] def test_configured_runner_hook_makes_the_managed_host_launchable():