feat(turn): select the managed Turn host explicitly - #4443
Conversation
The Turn host must be selected, never inferred. The previous default rule read the environment: a configured `DEEPSEEK_API_KEY` silently re-pointed `loopx turn plan` and `loopx turn run-once` from the individual Codex CLI host to the managed dsh host. Discovering a credential is not a decision to change where a Turn runs, and it left the operator unable to tell a chosen configuration from an incidental one. Selection is now explicit and the credential only authenticates it: - `MANAGED_DEFAULT_TURN_HOST` (`dsh`) is the shipped product default, chosen once and independent of the environment; - `LOOPX_TURN_HOST` re-points that default without repeating `--host`; - an explicit `--host` still wins over both; - `managed_executor_binding` reports the executor, its kind, the credential env var *name*, the endpoint env var name, whether it is operator-credential-bound, and whether it can launch here. The managed host is billed to the operator endpoint, so it is unavailable with a typed reason when it cannot be authenticated or launched: `operator_credential_unconfigured` or `dsh_runtime_unavailable`. `run-once --execute` fails closed on that verdict with no host invocation, no Journal write, and no quota spend; `plan` reports it without refusing. An explicitly selected individual host makes no launchability claim (`available: null`). Default behavior change on the `codex-cli`/`dsh`/`generic-cli` Turn lanes: `run-once` moved from `generic-cli` to `dsh`, and `plan` from `codex-cli` to `dsh`, with the planned execution mode following the selected host. `--host generic-cli` and `--host codex-cli` remain the explicit compatibility and rollback paths, and `LOOPX_TURN_HOST` re-points the default once. Validation: - 313 tests across the turn driver/executor, default-host, managed-executor, dsh goal mode, scan-root, and CLI output budget/differential suites; - `examples/loopx-turn-managed-executor-binding-smoke.py` (explicit selection, both typed refusals, fail-closed execution, explicit individual host); - `examples/loopx-turn-managed-default-flow-smoke.py` (the real dsh runtime on a local mock endpoint: credential-bound default commits one validated Turn, no-credential default refuses, hidden runtime refuses); - `loopx canary premerge --from-git-diff`, including the one-time bounded CLI output growth allowance for the Turn surfaces. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
The connector doc still described the host as resolved from the environment. It now documents the explicit selection surfaces (shipped default, LOOPX_TURN_HOST, --host), the managed_executor readback block and what each field claims, and the fail-closed verdict run-once applies before it invokes a host. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
…n gates The RFC still described the managed host as resolved from the operator credential. It now records what the repository ships and what the managed stack will ship: the Turn host is selected, never inferred (shipped default `dsh`, `LOOPX_TURN_HOST` re-points it, an explicit `--host` wins), the steward channel selects its own executor (shipped default `codex`, because it is the only interactive Chat transport today), and a configured credential only authenticates the selection instead of changing it. - The role table gains the steward channel executor row and states each selection source, including the `individual` versus `managed` executor kind. - The steward-channel section replaces the credential-resolved binding rule and records the promotion gate for the managed host (an interactive Chat transport), so the credential is explicitly not the promotion signal. - The milestone table records the steward channel's M1 contract as selected -- endpoint, model, source and executor kind -- and states the session-identity gap that still blocks a channel answer from proving which session served it. - Evidence and dsh-pin rows are re-pointed at the replacement PRs (#4443, #4446) and the dsh pin PR #4420. Docs only: no runtime is promoted, no default behavior changes here, and the English and Chinese mirrors are updated together. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
There was a problem hiding this comment.
Approval conclusion (author-owned PR; GitHub blocks formal self-approval)
Approval conclusion (author-owned PR; GitHub blocks formal self-approval)
详细中文评审
审查对象:464262e6abc552b28e202bc90eeeaf0dd5c28f0e(feat(turn): select the managed Turn host explicitly,base main,19 文件 +2099/-200)。执行契约 policy_revision=3;结果 JSON 已通过 pr-review --check-result。
动机
改之前,托管 Turn 的默认宿主由环境碰巧决定:loopx turn plan 默认 codex-cli、run-once 默认 generic-cli,两个命令的口径都不一样,而且没有单一 owner。前一轮堆叠版本更进一步按凭据解析默认值——装了 DEEPSEEK_API_KEY 的机器悄悄换成 dsh,没装的机器留在 generic-cli。结果是同一份计划在不同机器上语义不同,运营方无法区分「我选的」和「环境给的」,而计费边界恰恰挂在这个默认值上。本 PR 把选择收敛为一处显式解析,凭据退回认证事实。
改动思路
把三件事拆成三个 owner:选择归 loopx/control_plane/turn_driver/host_binding.py::selected_turn_host(显式 --host > LOOPX_TURN_HOST > 产品默认 MANAGED_DEFAULT_TURN_HOST),可用性归同模块的 managed_executor_binding(只在能证明不可启动时给 available=false,非托管宿主保持 None,不做无证据断言),执行前拒绝归 turn_driver/executor.run_loopx_turn_once。凭据事实抽到共享叶子模块 loopx/control_plane/operator_credential.py,只报变量名与是否配置,不读取值、不参与选择。CLI 侧只做接线,并复用仓库既有的 CLI 输出差分/预算机制来表达一次性增长,而不是放宽断言。
具体改动
selected_turn_host(host_binding.py:66):唯一的宿主选择点,返回(host, source),来源用product_default/explicit_config区分。managed_executor_binding(host_binding.py:105):投影executor、executor_kind、credential_env、endpoint_env、operator_credential_bound、available、unavailable_reason;typed 原因只有dsh_runtime_unavailable与operator_credential_unconfigured,remediation 刻意留在文档而不是 payload。run_loopx_turn_once(executor.py:81):不可用时以 statusunavailable终止,且发生在宿主调用、Journal 写入、配额消耗之前。operator_credential.py:configured_operator_credential/operator_credential_configured,被 host_binding 与 chat_manager 共同引用,消除读取漂移。- CLI 输出预算:
cli_output_differential.py新增按 surface 限定的_TURN_HOST_AND_MANAGED_EXECUTOR_BINDING_V0_GROWTH_ALLOWANCE(chars 832 / lines 14),绑定 none→v0 过渡且一次性。 - 测试与 smoke:
tests/test_turn_default_host_binding.py、tests/test_turn_managed_executor_binding.py、tests/test_turn_executor.py,以及examples/loopx-turn-managed-executor-binding-smoke.py(365 行)与examples/loopx-turn-managed-default-flow-smoke.py(642 行)。 - 文档:
docs/reference/protocols/loopx-turn-v0.md新增 Host Selection;docs/integrations/deepseek-harness-connector.md补宿主选择与读取章节。
对主干的风险
- 默认路径行为收窄:未安装
loopx[deepseek-harness]的机器上,默认run-once --execute从「能跑」变成「拒绝」。这是有意的 fail-closed,但对既有用户是一次真实变化,需要发布说明显眼披露并给出三种补救(装运行时 / 注入 runner / 显式--host)。回滚成本低(一个环境变量或一个参数),无持久状态迁移。 - 调度器产生的 unattended 计划仍映射到
isolated_headless_turn的generic-cli,与新默认不一致;PR 在文档里把它记为待决缺口而不是声称已统一,这个处理是诚实的,但缺口本身仍在。 operator_credential.py与同系列 #4446 互相新增,后合并方会有一次 add/add 冲突,需要一次 rebase(内容相同,取任一即可)。- 未验证维度:managed default flow smoke 用真实 dsh runtime 与真实 executor,但 LLM 端点是本地 mock SSE,因此真实 provider 往返不在本 head 的验证范围;Pi 与 L1 事件源仍是 opt-in,未被本 PR 晋级。
我的整体评价
APPROVE。这是对默认路径语义错误的正确修复:change_proportionality=proportionate、repository_reuse=separation_justified、authority_semantics=aligned、default_off_isolation=isolated、observable_semantics=intentional_change_validated。相对被它替代的旧堆叠链(#4409+#4416+#4427)范围是缩小的:合并为单一 main-based 切片,删掉了凭据解析分支。非阻塞建议两条:把 unattended 计划映射对齐或显式标注为回滚路径(P2);managed default flow smoke 偏厚,若 dsh 层已有等价覆盖,可只保留默认选择那一段。
验证:test_turn_default_host_binding.py + test_turn_managed_executor_binding.py + test_turn_executor.py 通过;313 条 turn/executor/CLI 输出预算测试通过;loopx-turn-managed-default-flow-smoke.py(真实 dsh runtime + 本地 mock SSE)与既有 codex-cli/dsh e2e smokes 通过;docs-governance-smoke.py、cli-output-budget-regression-smoke.py 通过;loopx canary premerge --from-git-diff gate passed;远端 CI 12 pass / 4 skipping / 7 pending,无失败。
English verdict: APPROVE at 464262e6abc552b28e202bc90eeeaf0dd5c28f0e. The managed Turn host is now selected explicitly instead of being resolved from the environment: --host > LOOPX_TURN_HOST > product default (dsh), with host_binding.selected_turn_host as the single decision owner; an operator credential is reported as a fact only and no longer re-points the executor. An unavailable managed host now fails closed before host invocation, Journal write, or quota consumption, with typed reasons dsh_runtime_unavailable / operator_credential_unconfigured. Key non-blocking notes: the unattended scheduler plan still emits generic-cli without being labeled the rollback path (P2), and loopx/control_plane/operator_credential.py is added by both this PR and #4446, so the second one to merge needs an add/add rebase. Validation: 313 turn/executor/CLI-output tests, the managed-executor binding smoke, the managed default flow smoke (real dsh runtime, local mock SSE endpoint), the docs-governance and CLI-output-budget smokes, and loopx canary premerge --from-git-diff all pass; remote CI has no failures.
2e96a57 (#4443) removed both halves of the `loopx turn managed-step` surface while rewiring host selection: the `managed-step` subparser in turn_registration.py and the `handle_turn_managed_step` dispatch in turn.py. loopx/cli_commands/turn_managed_step.py and its unit test survived, so the module still passed its own tests while the subcommand no longer existed. Evidence: - `turn managed-step` now exits 2 with `invalid choice: 'managed-step'`. - examples/loopx-turn-managed-step-self-heal-smoke.py fails on main and passes at 503991d, the commit before that change. Restore the subparser with the same flags and choices as before, and dispatch it beside the journal-inspection owner. The managed step selects from the shipped run-once hosts and always plans isolated-headless, but its default host follows resolve_default_turn_host() so an explicit LOOPX_TURN_HOST selection is honored instead of being pinned back to one host. To keep loopx/cli_commands/turn.py inside its frozen 1114-line baseline while adding the dispatch back, move the resume-binding resolution into turn_selection.py. The helper returns `(requested, binding)` because those are two different facts: a run-once Codex CLI Turn derives a binding from its own envelope, and that derived binding must not be read back as an explicit resume request or `--resume-turn-key` would refuse a legitimate resume. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Co-authored-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
`run-once` rebuilt the fresh Turn decision inline after #4443, so the status read, scheduler context, route source, capability hooks and decision arguments were maintained twice and the later fix only recovered part of them. Both commands now build through `build_fresh_turn_decision_owner`, which also carries the inputs a later spend or scheduler re-check must settle against, and the decision builder is private. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Co-authored-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
`run-once` and `managed-step` must resolve the same governing decision. #4443 (2e96a57) 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 a1213ed (#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 292b85e): - `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>
Summary
The Turn host is now selected, never inferred, and the operator credential
only authenticates the host that was already selected. This replaces the
credential-resolved default rule, which let a configured
DEEPSEEK_API_KEYsilently re-point
loopx turn planandloopx turn run-oncefrom theindividual Codex CLI host to the managed dsh host.
Discovering a credential is not a decision to change where a Turn runs. Under
the old rule an operator could not tell a chosen configuration from an
incidental one, and the affected surfaces reported a billing boundary (
managedvs
individual) that the environment had picked for them.What changed
dsh(managed executor)LOOPX_TURN_HOST--host codex-cli|claude-code|dsh|generic-cli(plan),codex-cli|dsh|generic-cli(run-once)DEEPSEEK_API_KEY, optional endpointDEEPSEEK_BASE_URLresolve_default_turn_host/selected_turn_hostinloopx/control_plane/turn_driver/host_binding.pyare now pure functions ofconfiguration: the product default applies,
LOOPX_TURN_HOSTre-points it,and no credential value or presence participates in selection.
loopx/control_plane/operator_credential.pyreports credential facts onlyand documents that selection is owned by each surface, not by this module.
plans
isolated-headlessrather than an execution mode the outer controllerrejects.
The managed host authenticates or it refuses
managed_executor_bindingprojects the executor, its kind (managed,individual,generic), the credential env var name (never its value), theendpoint env var name, whether the executor is operator-credential-bound, and
whether it can launch here. When it cannot,
availableisfalseandunavailable_reasonnames the missing fact:unavailable_reasondsh_runtime_unavailable--host codex-clioperator_credential_unconfiguredDEEPSEEK_API_KEY, or select--host codex-cliexplicitlyrun-once --executefails closed on that verdict: statusunavailable, no hostinvocation, no Journal write, and no quota slot spend.
planreports the sameverdict without refusing. An explicitly selected individual host
(
--host codex-cli,--host claude-code) is billed to that individual login andmakes no launchability claim (
available: null).Default behavior change
Affected lanes:
codex-cli,claude-code,dsh,generic-cli.loopx turn run-once:generic-cli→dshloopx turn plan:codex-cli→dshDisclosed in
docs/reference/protocols/loopx-turn-v0.mdunder a new HostSelection section, with the typed refusal reasons and their remediations.
Tests and smokes that name a specific host now pass
--hostexplicitly so theykeep testing the host they named rather than the ambient default.
--host generic-cliremains the compatibility and rollback path, andLOOPX_TURN_HOSTre-points the default once for a machine that wants the formerbehavior.
CLI output budget
The Turn plan readback grows once, from two reviewed causes: the new
managed_executorblock, and the host-selection consequences on the plan(host projection, scheduler execution context, and controller-owned
next_cli_actionsrendering). The allowance incli_output_differential.pyissurface-scoped to the Turn surfaces, bound to the none-to-v0 transition, and
one-time: once v0 is the baseline the same growth is a regression.
Validation
examples/loopx-turn-managed-executor-binding-smoke.pyexamples/loopx-turn-managed-default-flow-smoke.pyexamples/dsh-turn-host-adapter-smoke.py,loopx-turn-dsh-e2e-smoke.py,loopx-turn-dsh-builtin-host-e2e-smoke.py,loopx-turn-dsh-real-e2e-smoke.py --host dsh|generic-cliexamples/loopx-turn-codex-cli-e2e-smoke.py,loopx-turn-codex-cli-quickstart-smoke.py,loopx-turn-fake-host-walkthrough-smoke.py,loopx-turn-path-delta-acceptance-smoke.pyexamples/control_plane/cli-output-budget-regression-smoke.py,control-plane-maintainability-ratchet-smoke.py,docs-governance-smoke.pyloopx canary premerge --from-git-diffThe default-flow smoke runs the real dsh runtime against a local mock
OpenAI-compatible SSE endpoint, so it consumes no operator key and no individual
CLI subscription. Every credential in the tests and smokes is a fixture string
whose only role is to authenticate the already-selected host.
Boundaries
Supersedes
This single main-based change replaces the stacked
#4409(credential-resolved default host),#4416(managed executor readbackand fail-closed start), and
#4427(credential-resolved default flowqualification). Those PRs are closed in favor of this one so the delivery chain
does not need layer-by-layer refreshes and can be merged one slice at a time.