Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion docs/reference/protocols/loopx-turn-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions examples/loopx-turn-managed-executor-binding-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions loopx/control_plane/turn_driver/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {}),
}
Expand Down
70 changes: 70 additions & 0 deletions loopx/control_plane/turn_driver/host_binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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,
Expand All @@ -179,6 +212,7 @@ def managed_executor_binding(
"operator_credential_bound": False,
"available": None,
"unavailable_reason": None,
"unavailable_remediation": [],
}


Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion loopx/semantics/inventory_v0.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
61 changes: 61 additions & 0 deletions tests/test_turn_managed_executor_binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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": [],
}


Expand All @@ -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():
Expand Down
Loading