From a4926b5df48a0f6a03ae38878f35e25597e9fbb1 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:25:21 +0800 Subject: [PATCH 1/2] fix(turn): preserve quota refusal in delegation preflight Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- docs/reference/local-delegation.md | 22 ++++++++ loopx/cli_commands/turn_decision.py | 20 ++++++- loopx/collaboration_mcp.py | 2 +- .../control_plane/collaboration/delegation.ts | 22 ++++++++ .../control_plane/effect_runtime_handlers.ts | 2 + .../turn_driver/selection_rejection.ts | 50 +++++++++++++++++ tests/control_plane_ts/delegation.test.ts | 20 +++++++ .../turn_selection_rejection.test.ts | 38 +++++++++++++ tests/test_delegation_preflight.py | 56 +++++++++++++++++++ tests/test_loopx_turn_error_readback.py | 34 +++++++++++ 10 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 loopx/control_plane/turn_driver/selection_rejection.ts create mode 100644 tests/control_plane_ts/turn_selection_rejection.test.ts diff --git a/docs/reference/local-delegation.md b/docs/reference/local-delegation.md index c4c51567b8..b1399ce9f4 100644 --- a/docs/reference/local-delegation.md +++ b/docs/reference/local-delegation.md @@ -159,6 +159,28 @@ delegate read --operation-id review-round-1 delegate wait --operation-id review-round-1 ``` +Inspection uses the bound worker workspace as its actual safety scan root. If +quota defers the exact Todo for control repair, inspection returns +`state: turn_blocked` with `turn_blocker.reason_code`, the original selection +state and a contract-error count. Canonical acceptance can still be ready; +`executor: null` means it was not inspected, not that the model runtime failed. +Use `loopx check --scan-root /absolute/reviewer-worktree` with the same registry +to diagnose the scan. Repair the source or configuration, then inspect again. +Do not exempt tests, scan the installed package instead, retarget the Todo or +start another operation to bypass the refusal. Other unstructured CLI failures +remain errors. The observation starts no host, Turn journal or quota spend. +Normal quota selection may still admit unrelated eligible work; this preflight +never substitutes another Todo. + +中文:预检以 binding 固定的真实 worker 工作树作为安全扫描根。quota 因控制面 +修复延后该精确 Todo 时,返回 `state: turn_blocked`、原选路状态、 +`turn_blocker.reason_code` 和契约错误数;规范验收可能仍已就绪。 +`executor: null` 表示未检查执行器,不表示模型故障。使用相同 registry 和 +`loopx check --scan-root /absolute/reviewer-worktree` 定位,再修复原来源或配置 +并重做预检。不能豁免测试目录、改扫安装包、换 Todo 或创建新操作绕过拒绝。 +其他无结构 CLI 故障仍报错;该观察不启动 host、Turn journal 或扣额。 +普通 quota 选路仍可安排其他独立且合格的工作;本预检不会替换 Todo。 + `request.json` contains the same `collaboration_brief_v0` used by MCP: ```json diff --git a/loopx/cli_commands/turn_decision.py b/loopx/cli_commands/turn_decision.py index f93667b0d2..8db3b1f10b 100644 --- a/loopx/cli_commands/turn_decision.py +++ b/loopx/cli_commands/turn_decision.py @@ -35,6 +35,7 @@ scheduler_execution_context_for_turn, ) from ..status import AUTONOMOUS_REPLAN_PERIODIC_LOOKBACK, collect_status +from ..control_plane.effect_runtime import effect_runtime_result from .lark_inbox import build_lark_operator_inbox_urgency_projector from .turn_selection import turn_controller_advisory_primary @@ -42,6 +43,19 @@ TURN_DECISION_ROUTE_SOURCE = "loopx_turn_plan" +class TurnTodoSelectionError(ValueError): + """Transport quota's existing refusal, without a new admission rule.""" + + def __init__(self, projection: dict[str, Any]) -> None: + self.code = projection["error_code"] + self.payload = {"selection_rejection": projection["selection_rejection"]} + reason = projection["selection_rejection"].get("reason_code") + super().__init__( + "Requested Turn Todo is not currently eligible; no alternate task was selected" + + (f"; reason={reason}" if reason else "") + ) + + def collect_turn_status_payload( args: argparse.Namespace, *, @@ -164,7 +178,11 @@ def resolve(self) -> dict[str, Any]: decision = self.build_turn_decision(requested_action_todo_id=self.requested_todo_id) selected = decision.get("selected_todo") if not isinstance(selected, dict) or selected.get("todo_id") != self.requested_todo_id: - raise ValueError("Requested Turn Todo is not currently eligible; no alternate task was selected") + summary = self.status_payload.get("contract_summary") + raise TurnTodoSelectionError(effect_runtime_result("turn.selection.rejection", { + "requested_todo_id": self.requested_todo_id, "decision": decision, + "contract_error_count": summary.get("errors") if isinstance(summary, Mapping) else None, + })) selected["selected_by"] = "turn_explicit_todo" return decision diff --git a/loopx/collaboration_mcp.py b/loopx/collaboration_mcp.py index 4c2ef7346e..c38ab1486e 100644 --- a/loopx/collaboration_mcp.py +++ b/loopx/collaboration_mcp.py @@ -356,7 +356,7 @@ def inspect(self, binding_id: str) -> dict: or selected_scan_root.resolve() != workspace): raise ValueError("delegation inspection cannot execute or retarget bound work") preview = self._cli(binding, *arguments) - if preview.get("status") != "preview": + if preview.get("status") != "preview" and "selection_rejection" not in preview: raise ValueError(f"delegation Turn preflight unavailable: {preview.get('error') or preview.get('status')}") current = delegation_validation.capture(self, binding) if acceptance != current or self.binding(binding_id, require_active=True) != binding: diff --git a/loopx/control_plane/collaboration/delegation.ts b/loopx/control_plane/collaboration/delegation.ts index be8ffe982b..6a0055d860 100644 --- a/loopx/control_plane/collaboration/delegation.ts +++ b/loopx/control_plane/collaboration/delegation.ts @@ -6,6 +6,7 @@ import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; import {acceptanceValidationEffects, type AcceptanceCompletionRequirements} from "../goals/acceptance_contract.ts"; import {normalizeTodoCompletionValidationDeclaration} from "../todos/completion_validation_declaration.ts"; +import {readTurnSelectionRejection} from "../turn_driver/selection_rejection.ts"; function requireThat(ok: unknown, message: string): asserts ok { if (!ok) throw new EffectRuntimeRequestError(message); @@ -170,6 +171,27 @@ export function delegationPreflight(params: JsonObject): JsonObject { } const preview = requireJsonObject(params.preview, "Turn preview"); const effects = requireJsonObject(preview.effects, "preview effects"); + if (preview.ok === false && preview.selection_rejection !== undefined) { + const refusal = readTurnSelectionRejection(preview.selection_rejection, binding.todo_id); + requireThat(preview.effects_scope === "current_invocation" + && ["host_invoked", "state_written", "quota_spent", "scheduler_acknowledged"].every(k => effects[k] === false) + && refusal.schema_version === "loopx_turn_selection_rejection_v0" + && refusal.source === "quota.should-run" && refusal.requested_todo_id === binding.todo_id + && ["deferred", "rejected", "unavailable"].includes(String(refusal.state)) + && preview.error_code === `turn_todo_selection_${refusal.state}`, + "delegation inspection requires a matching effect-free selection refusal"); + const acceptance = params.acceptance === null ? null : requireJsonObject(params.acceptance, "task acceptance"); + return { + schema_version: "loopx_delegation_preflight_v0", binding, state: "turn_blocked", + turn_eligible: false, turn_route: null, turn_blocker: refusal, + acceptance_ready: acceptance?.todo_id === binding.todo_id && acceptance?.state === "ready" + && params.validation_files_current === true, + authority_ready: true, authority_reason: null, authority_state: "promoted", + authority_next_action: "none", promotion_from_surface_allowed: false, + executor: null, effects, + note: "Quota refused this exact Todo before host or executor inspection. Read status/check with the bound workspace scan root; do not retarget this inspection or bypass repair.", + }; + } requireThat(preview.dry_run === true && preview.status === "preview" && ["host_invoked", "state_written", "quota_spent", "scheduler_acknowledged"].every(k => effects[k] === false), "delegation inspection requires a read-only Turn preview"); diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 8150dd2c85..f790bfd629 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -88,6 +88,7 @@ import { type TurnJournalInspectionRequest, } from "./turn_driver/turn_journal.ts"; import { commitTurnJournal } from "./turn_driver/turn_journal_effects.ts"; +import { projectTurnSelectionRejection } from "./turn_driver/selection_rejection.ts"; import { evaluateTodoCompletionFence, } from "./todos/completion_fence.ts"; @@ -713,6 +714,7 @@ export function createEffectRuntimeHandlers( evaluatePostWritebackHookTransaction, ], ["collaboration.delegation.binding", selectDelegationBinding], + ["turn.selection.rejection", projectTurnSelectionRejection], ["collaboration.delegation.preflight", delegationPreflight], ["collaboration.delegation.validation_plan", delegationValidationPlan], ["collaboration.delegation.turn_plan", delegationTurnPlanDecision], diff --git a/loopx/control_plane/turn_driver/selection_rejection.ts b/loopx/control_plane/turn_driver/selection_rejection.ts new file mode 100644 index 0000000000..5302485c64 --- /dev/null +++ b/loopx/control_plane/turn_driver/selection_rejection.ts @@ -0,0 +1,50 @@ +/** Observe a refused selection from quota's decision; never decide admission. */ +import type {JsonObject} from "../effect_program.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; + +function code(value: unknown): string | null { + return typeof value === "string" && /^[a-z][a-z0-9_]{0,159}$/.test(value) ? value : null; +} + +export function readTurnSelectionRejection(value: unknown, requested: unknown): JsonObject { + const row = requireJsonObject(value, "Turn selection refusal"); + if (row.schema_version !== "loopx_turn_selection_rejection_v0" || row.source !== "quota.should-run" + || row.requested_todo_id !== requested || !["deferred","rejected","unavailable"].includes(String(row.state)) + || (row.reason_code !== null && code(row.reason_code) === null) + || (row.recovery_action !== null && code(row.recovery_action) === null) + || !Array.isArray(row.delivery_preemptions) || row.delivery_preemptions.length > 8 + || row.delivery_preemptions.some(value => code(value) === null) + || ![true,false,null].includes(row.status_health_ok as boolean | null) + || (row.contract_error_count !== null && (!Number.isSafeInteger(row.contract_error_count) || Number(row.contract_error_count) < 0))) + throw new EffectRuntimeRequestError("matching bounded Turn selection refusal required"); + return Object.fromEntries(["schema_version","source","requested_todo_id","state","reason_code", + "delivery_preemptions","recovery_action","status_health_ok","contract_error_count"].map(key => [key,row[key]])); +} + +export function projectTurnSelectionRejection(params: JsonObject): JsonObject { + const requested = params.requested_todo_id; + if (typeof requested !== "string" || requested.length < 1 || requested.length > 256) + throw new EffectRuntimeRequestError("bounded requested Todo identity required"); + const decision = requireJsonObject(params.decision, "current quota decision"); + const raw = decision.action_selection_qualification; + const qualification = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as JsonObject : {}; + const state = qualification.requested_todo_id === requested + && ["deferred", "rejected"].includes(String(qualification.state)) + ? qualification.state as string : "unavailable"; + const reasons = Array.isArray(qualification.delivery_preemptions) + ? qualification.delivery_preemptions.filter(value => code(value) !== null).slice(0, 8) : []; + const count = params.contract_error_count; + return { + error_code: `turn_todo_selection_${state}`, + selection_rejection: { + schema_version: "loopx_turn_selection_rejection_v0", source: "quota.should-run", + requested_todo_id: requested, state, + reason_code: state === "unavailable" ? null : code(qualification.reason), + delivery_preemptions: state === "unavailable" ? [] : reasons, + recovery_action: state === "unavailable" ? null : code(qualification.recovery_action), + status_health_ok: typeof decision.status_health_ok === "boolean" ? decision.status_health_ok : null, + contract_error_count: Number.isSafeInteger(count) && Number(count) >= 0 ? count : null, + }, + }; +} diff --git a/tests/control_plane_ts/delegation.test.ts b/tests/control_plane_ts/delegation.test.ts index eba606ec9c..aaded2c55c 100644 --- a/tests/control_plane_ts/delegation.test.ts +++ b/tests/control_plane_ts/delegation.test.ts @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import {recordDelegationAdoption, delegationInventoryItem, delegationInventoryQuery, delegationPreflight, delegationTurnPlanDecision, delegationValidationPlan, recoverValidatedDelegationSettlement, selectDelegationBinding, transitionDelegationObservation} from "../../loopx/control_plane/collaboration/delegation.ts"; import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import {projectTurnSelectionRejection} from "../../loopx/control_plane/turn_driver/selection_rejection.ts"; const binding = {id: "review", agent_id: "reviewer", todo_id: "todo_review", workspace: "/fixture", requesters: ["coordinator", "analyst"], host_args: ["--host", "dsh"], timeout_seconds: 60, output_refs: ["output.json"]}; @@ -15,6 +16,25 @@ const validationTodo = {todo_id: binding.todo_id, done: false, status: "open", const validationBasis = {status: "loaded", provider_revision: "fixture:1", todo: validationTodo, completion_requirements: null}; +test("preflight preserves a quota refusal, but rejects effectful or retargeted errors", () => { + const projected = projectTurnSelectionRejection({requested_todo_id:binding.todo_id,contract_error_count:3, + decision:{status_health_ok:false,action_selection_qualification:{state:"deferred", + requested_todo_id:binding.todo_id,reason:"control_repair",recovery_action:"reenter_guard_without_selection"}}}); + const preview = {ok:false,...projected,effects_scope:"current_invocation", + effects:{host_invoked:false,state_written:false,quota_spent:false,scheduler_acknowledged:false}}; + const input = {binding,preview,acceptance:{todo_id:binding.todo_id,state:"ready"},validation_files_current:true}; + const observed = delegationPreflight(input); + assert.equal(observed.state,"turn_blocked"); assert.equal(observed.turn_eligible,false); + assert.equal(observed.acceptance_ready,true); assert.equal(observed.executor,null); + assert.deepEqual(observed.turn_blocker,projected.selection_rejection); + assert.deepEqual(delegationPreflight({...input,preview:{...preview,selection_rejection:{ + ...(projected.selection_rejection as Record),private_context:"not exported"}}}).turn_blocker,projected.selection_rejection); + for (const effects of [{...preview.effects,host_invoked:true}, {...preview.effects,state_written:null}]) + assert.throws(() => delegationPreflight({...input,preview:{...preview,effects}}),/effect-free/); + assert.throws(() => delegationPreflight({...input,preview:{...preview, + selection_rejection:{...(projected.selection_rejection as Record),requested_todo_id:"other"}}}),/matching/); +}); + test("independent delegation requires the current canonical declaration, not a Goal-wide contract", () => { const plan = delegationValidationPlan({binding, basis: validationBasis, declaration}); assert.equal(plan.state, "ready"); diff --git a/tests/control_plane_ts/turn_selection_rejection.test.ts b/tests/control_plane_ts/turn_selection_rejection.test.ts new file mode 100644 index 0000000000..929953c6e1 --- /dev/null +++ b/tests/control_plane_ts/turn_selection_rejection.test.ts @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import {projectTurnSelectionRejection} from "../../loopx/control_plane/turn_driver/selection_rejection.ts"; + +const qualification = {schema_version:"action_selection_qualification_v0", state:"deferred", + requested_todo_id:"todo_worker", reason:"control_repair", recovery_action:"reenter_guard_without_selection", + delivery_preemptions:["control_repair","delivery_not_allowed"]}; +const params = {requested_todo_id:"todo_worker", contract_error_count:3, + decision:{status_health_ok:false, action_selection_qualification:qualification}}; + +test("rejection reflects the quota reason and health count without another eligibility decision", () => { + const before = structuredClone(params); + const result = projectTurnSelectionRejection(params); + assert.equal(result.error_code,"turn_todo_selection_deferred"); + assert.deepEqual(result.selection_rejection, {schema_version:"loopx_turn_selection_rejection_v0", + source:"quota.should-run",requested_todo_id:"todo_worker",state:"deferred",reason_code:"control_repair", + delivery_preemptions:["control_repair","delivery_not_allowed"],recovery_action:"reenter_guard_without_selection", + status_health_ok:false,contract_error_count:3}); + assert.deepEqual(params,before); +}); + +test("absent or different selection stays unavailable, never borrowed or launchable", () => { + for (const raw of [undefined,{}, {...qualification,requested_todo_id:"other"}, {...qualification,state:"qualified"}]) { + const result = projectTurnSelectionRejection({...params,decision:{action_selection_qualification:raw}}); + assert.equal(result.error_code,"turn_todo_selection_unavailable"); + assert.equal((result.selection_rejection as Record).reason_code,null); + } +}); + +test("raw messages, paths and unsupported counts are not disclosed by the bounded projection", () => { + const result = projectTurnSelectionRejection({...params,contract_error_count:-1,decision:{...params.decision, + private_context:"not exported",action_selection_qualification:{...qualification,reason:"raw message", + delivery_preemptions:["control_repair","/operator/path"],recovery_action:"raw recovery"}}}); + const refusal = result.selection_rejection as Record; + assert.equal(refusal.reason_code,null); assert.equal(refusal.recovery_action,null); + assert.equal(refusal.contract_error_count,null); assert.deepEqual(refusal.delivery_preemptions,["control_repair"]); + assert.equal(JSON.stringify(result).includes("not exported"),false); +}); diff --git a/tests/test_delegation_preflight.py b/tests/test_delegation_preflight.py index 389f2a1645..5d52a96e91 100644 --- a/tests/test_delegation_preflight.py +++ b/tests/test_delegation_preflight.py @@ -1,12 +1,15 @@ """A binding inspection must use the actual Turn without launching or spending.""" import json +import asyncio import os import subprocess import sys from pathlib import Path import pytest +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client from loopx.control_plane.turn_driver import build_loopx_turn_plan from loopx.control_plane.turn_driver.executor import ( @@ -19,6 +22,59 @@ service = delegation_service +@pytest.mark.parametrize("health_repair", [False, True]) +def test_actual_workspace_scan_refusal_is_typed_and_effect_free(service, health_repair): + root, runner = service + if health_repair: + registry = json.loads(runner.registry.read_text()) + registry["goals"][0]["control_plane"] = {"self_repair": { + "enabled": True, "allow_health_blocker_repair": True, + }} + runner.registry.write_text(json.dumps(registry)) + workspace = Path(json.loads(runner.config.read_text())["bindings"][0]["workspace"]) + # A synthetic literal exercises the real scanner; never exempt test files. + (workspace / "unsafe-fixture.py").write_text("fixture = " + repr("tok" + "en=" + "abcdefghijklmnop1234")) + before = runner.registry.read_bytes() + status, result = cli(runner, "inspect", "--binding-id", "analysis") + assert status == 0, result + assert result["state"] == "turn_blocked" + assert result["authority_ready"] and result["acceptance_ready"] + assert result["turn_eligible"] is False and result["executor"] is None + refusal = result["turn_blocker"] + assert refusal["requested_todo_id"] == "todo_analyst-initial" + assert refusal["state"] == "deferred" + assert refusal["reason_code"] == ("control_repair" if health_repair else "delivery_not_allowed") + assert refusal["status_health_ok"] is False and refusal["contract_error_count"] >= 1 + assert not any(result["effects"].values()) + if health_repair: + async def inspect_mcp(): + params = StdioServerParameters(command=sys.executable, args=[ + "-m", "loopx.collaboration_mcp", "--registry", str(runner.registry), + "--runtime-root", str(runner.root), "--goal-id", runner.goal_id, + "--agent-id", runner.agent_id, "--workspace", str(root / "lead"), + "--execution-config", str(runner.config), + ]) + async with stdio_client(params) as (reader, writer): + async with ClientSession(reader, writer) as session: + await session.initialize() + inspected = await session.call_tool("inspect_execution_binding", {"binding_id": "analysis"}) + assert not inspected.isError + return json.loads(inspected.content[0].text) + mcp_result = asyncio.run(inspect_mcp()) + assert mcp_result["turn_blocker"] == refusal + assert mcp_result["state"] == "turn_blocked" + assert not any(mcp_result["effects"].values()) + assert runner.registry.read_bytes() == before + assert not (root / "host-started").exists() + assert not list((root / "runtime" / "goals").glob("*/turns/*.json")) + # Removing this exact synthetic input restores ordinary inspection, rather + # than leaving a persisted repair route or choosing a different Todo. + (workspace / "unsafe-fixture.py").unlink() + status, restored = cli(runner, "inspect", "--binding-id", "analysis") + assert status == 0 and restored["turn_eligible"], restored + assert not any(restored["effects"].values()) + + def test_real_cli_preflight_preserves_unknown_runtime_and_state(service): root, runner = service before = runner.registry.read_bytes() diff --git a/tests/test_loopx_turn_error_readback.py b/tests/test_loopx_turn_error_readback.py index c5ebb3592a..db01c19f65 100644 --- a/tests/test_loopx_turn_error_readback.py +++ b/tests/test_loopx_turn_error_readback.py @@ -1,12 +1,46 @@ """Error readback preserves uncertainty without inventing execution facts.""" from __future__ import annotations +import pytest + +from loopx.cli_commands.turn_decision import FreshTurnDecisionOwner, TurnTodoSelectionError from loopx.cli_commands.turn_rendering import ( build_turn_error_payload, render_loopx_turn_execution_markdown, + render_loopx_turn_plan_markdown, ) +@pytest.mark.parametrize("command", ["plan", "run-once"]) +def test_selection_refusal_keeps_quota_reason_in_json_and_markdown(command): + calls = [] + + def current_decision(**kwargs): + calls.append(kwargs) + return {"status_health_ok": False, "action_selection_qualification": { + "requested_todo_id": "todo_fixture", "state": "deferred", + "reason": "control_repair", "recovery_action": "reenter_guard_without_selection", + }} + + owner = FreshTurnDecisionOwner( + status_payload={"contract_summary": {"errors": 3}}, + scheduler_execution_context={}, operator_inbox_urgency_projector=lambda **_: {}, + build_turn_decision=current_decision, requested_todo_id="todo_fixture", + ) + with pytest.raises(TurnTodoSelectionError) as caught: + owner.resolve() + assert calls == [{"requested_action_todo_id": "todo_fixture"}] + result = build_turn_error_payload({}, caught.value, turn_command=command) + assert result["error_code"] == "turn_todo_selection_deferred" + assert result["selection_rejection"]["requested_todo_id"] == "todo_fixture" + assert result["selection_rejection"]["reason_code"] == "control_repair" + assert result["selection_rejection"]["contract_error_count"] == 3 + assert not any(result["effects"].values()) + assert "journal_observation" not in result + renderer = render_loopx_turn_plan_markdown if command == "plan" else render_loopx_turn_execution_markdown + assert "reason=control_repair" in renderer(result) + + def test_pre_execution_error_preserves_hook_effects_without_claiming_a_host() -> None: result = build_turn_error_payload( {"effects": {"state_written": True}}, ValueError("invalid adapter"), From cc5955e4a8a250f8f0dd65887850b19cc367782e Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:36:40 +0800 Subject: [PATCH 2/2] fix(turn): decode the refusal state instead of coercing it `String(row.state)` accepted JSON arrays such as ["deferred"], and the producer coerced the same way, so a malformed qualification could reach consumers as a non-string enum. Decode the literal in one place (`turnSelectionRejectionState`), use the decoded value in the producer, reader and delegation preflight, and fail closed when it is not a decoded string. Cover the array counterexamples in the TypeScript suites and through the real managed Effect runtime, and state the fail-closed rule in the delegation reference. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- docs/reference/local-delegation.md | 8 +++- .../control_plane/collaboration/delegation.ts | 7 ++-- .../turn_driver/selection_rejection.ts | 22 +++++++--- tests/control_plane_ts/delegation.test.ts | 4 ++ .../turn_selection_rejection.test.ts | 23 ++++++++++- tests/test_delegation_preflight.py | 40 +++++++++++++++++++ 6 files changed, 91 insertions(+), 13 deletions(-) diff --git a/docs/reference/local-delegation.md b/docs/reference/local-delegation.md index b1399ce9f4..22676fd28b 100644 --- a/docs/reference/local-delegation.md +++ b/docs/reference/local-delegation.md @@ -168,7 +168,9 @@ Use `loopx check --scan-root /absolute/reviewer-worktree` with the same registry to diagnose the scan. Repair the source or configuration, then inspect again. Do not exempt tests, scan the installed package instead, retarget the Todo or start another operation to bypass the refusal. Other unstructured CLI failures -remain errors. The observation starts no host, Turn journal or quota spend. +remain errors, and a refusal whose bounded fields are malformed — including a +`state` that is not one of the decoded string literals — fails closed instead of +reporting `turn_blocked`. The observation starts no host, Turn journal or quota spend. Normal quota selection may still admit unrelated eligible work; this preflight never substitutes another Todo. @@ -178,7 +180,9 @@ never substitutes another Todo. `executor: null` 表示未检查执行器,不表示模型故障。使用相同 registry 和 `loopx check --scan-root /absolute/reviewer-worktree` 定位,再修复原来源或配置 并重做预检。不能豁免测试目录、改扫安装包、换 Todo 或创建新操作绕过拒绝。 -其他无结构 CLI 故障仍报错;该观察不启动 host、Turn journal 或扣额。 +其他无结构 CLI 故障仍报错;拒绝投影字段畸形(含 `state` 不是已解码字符串 +字面量)时按失败关闭报错,不返回 `turn_blocked`;该观察不启动 host、Turn +journal 或扣额。 普通 quota 选路仍可安排其他独立且合格的工作;本预检不会替换 Todo。 `request.json` contains the same `collaboration_brief_v0` used by MCP: diff --git a/loopx/control_plane/collaboration/delegation.ts b/loopx/control_plane/collaboration/delegation.ts index 6a0055d860..e8d610a740 100644 --- a/loopx/control_plane/collaboration/delegation.ts +++ b/loopx/control_plane/collaboration/delegation.ts @@ -6,7 +6,7 @@ import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; import {acceptanceValidationEffects, type AcceptanceCompletionRequirements} from "../goals/acceptance_contract.ts"; import {normalizeTodoCompletionValidationDeclaration} from "../todos/completion_validation_declaration.ts"; -import {readTurnSelectionRejection} from "../turn_driver/selection_rejection.ts"; +import {readTurnSelectionRejection, turnSelectionRejectionState} from "../turn_driver/selection_rejection.ts"; function requireThat(ok: unknown, message: string): asserts ok { if (!ok) throw new EffectRuntimeRequestError(message); @@ -173,12 +173,13 @@ export function delegationPreflight(params: JsonObject): JsonObject { const effects = requireJsonObject(preview.effects, "preview effects"); if (preview.ok === false && preview.selection_rejection !== undefined) { const refusal = readTurnSelectionRejection(preview.selection_rejection, binding.todo_id); + const refusalState = turnSelectionRejectionState(refusal.state); requireThat(preview.effects_scope === "current_invocation" && ["host_invoked", "state_written", "quota_spent", "scheduler_acknowledged"].every(k => effects[k] === false) && refusal.schema_version === "loopx_turn_selection_rejection_v0" && refusal.source === "quota.should-run" && refusal.requested_todo_id === binding.todo_id - && ["deferred", "rejected", "unavailable"].includes(String(refusal.state)) - && preview.error_code === `turn_todo_selection_${refusal.state}`, + && refusalState !== null + && preview.error_code === `turn_todo_selection_${refusalState}`, "delegation inspection requires a matching effect-free selection refusal"); const acceptance = params.acceptance === null ? null : requireJsonObject(params.acceptance, "task acceptance"); return { diff --git a/loopx/control_plane/turn_driver/selection_rejection.ts b/loopx/control_plane/turn_driver/selection_rejection.ts index 5302485c64..f796e3fdce 100644 --- a/loopx/control_plane/turn_driver/selection_rejection.ts +++ b/loopx/control_plane/turn_driver/selection_rejection.ts @@ -1,16 +1,25 @@ /** Observe a refused selection from quota's decision; never decide admission. */ import type {JsonObject} from "../effect_program.ts"; -import {requireJsonObject} from "../runtime_decode.ts"; +import {isStringLiteral, requireJsonObject} from "../runtime_decode.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; function code(value: unknown): string | null { return typeof value === "string" && /^[a-z][a-z0-9_]{0,159}$/.test(value) ? value : null; } +const REJECTION_STATES = ["deferred", "rejected", "unavailable"] as const; +type RejectionState = (typeof REJECTION_STATES)[number]; + +/** Decode the state literal itself; a non-string (e.g. a JSON array) is not a refusal state. */ +export function turnSelectionRejectionState(value: unknown): RejectionState | null { + return typeof value === "string" && isStringLiteral(value, REJECTION_STATES) ? value : null; +} + export function readTurnSelectionRejection(value: unknown, requested: unknown): JsonObject { const row = requireJsonObject(value, "Turn selection refusal"); + const state = turnSelectionRejectionState(row.state); if (row.schema_version !== "loopx_turn_selection_rejection_v0" || row.source !== "quota.should-run" - || row.requested_todo_id !== requested || !["deferred","rejected","unavailable"].includes(String(row.state)) + || row.requested_todo_id !== requested || state === null || (row.reason_code !== null && code(row.reason_code) === null) || (row.recovery_action !== null && code(row.recovery_action) === null) || !Array.isArray(row.delivery_preemptions) || row.delivery_preemptions.length > 8 @@ -19,7 +28,8 @@ export function readTurnSelectionRejection(value: unknown, requested: unknown): || (row.contract_error_count !== null && (!Number.isSafeInteger(row.contract_error_count) || Number(row.contract_error_count) < 0))) throw new EffectRuntimeRequestError("matching bounded Turn selection refusal required"); return Object.fromEntries(["schema_version","source","requested_todo_id","state","reason_code", - "delivery_preemptions","recovery_action","status_health_ok","contract_error_count"].map(key => [key,row[key]])); + "delivery_preemptions","recovery_action","status_health_ok","contract_error_count"] + .map(key => [key, key === "state" ? state : row[key]])); } export function projectTurnSelectionRejection(params: JsonObject): JsonObject { @@ -29,9 +39,9 @@ export function projectTurnSelectionRejection(params: JsonObject): JsonObject { const decision = requireJsonObject(params.decision, "current quota decision"); const raw = decision.action_selection_qualification; const qualification = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as JsonObject : {}; - const state = qualification.requested_todo_id === requested - && ["deferred", "rejected"].includes(String(qualification.state)) - ? qualification.state as string : "unavailable"; + const refused = qualification.requested_todo_id === requested + ? turnSelectionRejectionState(qualification.state) : null; + const state = refused === "deferred" || refused === "rejected" ? refused : "unavailable"; const reasons = Array.isArray(qualification.delivery_preemptions) ? qualification.delivery_preemptions.filter(value => code(value) !== null).slice(0, 8) : []; const count = params.contract_error_count; diff --git a/tests/control_plane_ts/delegation.test.ts b/tests/control_plane_ts/delegation.test.ts index aaded2c55c..cac137ae6d 100644 --- a/tests/control_plane_ts/delegation.test.ts +++ b/tests/control_plane_ts/delegation.test.ts @@ -33,6 +33,10 @@ test("preflight preserves a quota refusal, but rejects effectful or retargeted e assert.throws(() => delegationPreflight({...input,preview:{...preview,effects}}),/effect-free/); assert.throws(() => delegationPreflight({...input,preview:{...preview, selection_rejection:{...(projected.selection_rejection as Record),requested_todo_id:"other"}}}),/matching/); + for (const raw of [["deferred"],["rejected"],["unavailable"]]) + assert.throws(() => delegationPreflight({...input,preview:{...preview, + selection_rejection:{...(projected.selection_rejection as Record),state:raw}, + error_code:`turn_todo_selection_${raw[0]}`}}),/matching/); }); test("independent delegation requires the current canonical declaration, not a Goal-wide contract", () => { diff --git a/tests/control_plane_ts/turn_selection_rejection.test.ts b/tests/control_plane_ts/turn_selection_rejection.test.ts index 929953c6e1..c33eae57d2 100644 --- a/tests/control_plane_ts/turn_selection_rejection.test.ts +++ b/tests/control_plane_ts/turn_selection_rejection.test.ts @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import {projectTurnSelectionRejection} from "../../loopx/control_plane/turn_driver/selection_rejection.ts"; +import {projectTurnSelectionRejection, readTurnSelectionRejection, + turnSelectionRejectionState} from "../../loopx/control_plane/turn_driver/selection_rejection.ts"; const qualification = {schema_version:"action_selection_qualification_v0", state:"deferred", requested_todo_id:"todo_worker", reason:"control_repair", recovery_action:"reenter_guard_without_selection", @@ -20,13 +21,31 @@ test("rejection reflects the quota reason and health count without another eligi }); test("absent or different selection stays unavailable, never borrowed or launchable", () => { - for (const raw of [undefined,{}, {...qualification,requested_todo_id:"other"}, {...qualification,state:"qualified"}]) { + for (const raw of [undefined,{}, {...qualification,requested_todo_id:"other"}, + {...qualification,state:"qualified"}, {...qualification,state:["deferred"]}, + {...qualification,state:["rejected"]}, {...qualification,state:["unavailable"]}, + {...qualification,state:["control_repair"]}]) { const result = projectTurnSelectionRejection({...params,decision:{action_selection_qualification:raw}}); assert.equal(result.error_code,"turn_todo_selection_unavailable"); assert.equal((result.selection_rejection as Record).reason_code,null); } }); +test("only a decoded string state is a refusal; array states fail closed at the reader", () => { + for (const raw of ["deferred","rejected","unavailable"]) assert.equal(turnSelectionRejectionState(raw),raw); + for (const raw of [["deferred"],["rejected"],["unavailable"],[],["control_repair"],null,7,{state:"deferred"}]) + assert.equal(turnSelectionRejectionState(raw),null); + const refusal = (state: unknown) => ({schema_version:"loopx_turn_selection_rejection_v0",source:"quota.should-run", + requested_todo_id:"todo_worker",state,reason_code:"control_repair",delivery_preemptions:["control_repair"], + recovery_action:"reenter_guard_without_selection",status_health_ok:false,contract_error_count:3}); + for (const raw of ["deferred","rejected","unavailable"]) { + const read = readTurnSelectionRejection(refusal(raw),"todo_worker"); + assert.equal(read.state,raw); assert.equal(Array.isArray(read.state),false); + } + for (const raw of [["deferred"],["rejected"],["unavailable"],[],null,7]) + assert.throws(() => readTurnSelectionRejection(refusal(raw),"todo_worker"),/matching bounded/); +}); + test("raw messages, paths and unsupported counts are not disclosed by the bounded projection", () => { const result = projectTurnSelectionRejection({...params,contract_error_count:-1,decision:{...params.decision, private_context:"not exported",action_selection_qualification:{...qualification,reason:"raw message", diff --git a/tests/test_delegation_preflight.py b/tests/test_delegation_preflight.py index 5d52a96e91..433f284392 100644 --- a/tests/test_delegation_preflight.py +++ b/tests/test_delegation_preflight.py @@ -172,6 +172,46 @@ def test_preflight_surfaces_actual_turn_rejection_without_launch(service): assert not (root / "host-started").exists() +def test_real_runtime_decodes_only_string_selection_states(): + """The live managed runtime rejects a JSON-array refusal enum instead of echoing it.""" + from loopx.control_plane.effect_runtime import ( + EffectRuntimeRemoteError, + effect_runtime_result, + ) + + binding = {"id": "review", "agent_id": "reviewer", "todo_id": "todo_review"} + effects = {"host_invoked": False, "state_written": False, + "quota_spent": False, "scheduler_acknowledged": False} + + def params(state): + return { + "binding": binding, + "authority": {"ready": True, "reason": None}, + "preview": { + "ok": False, "effects_scope": "current_invocation", "effects": effects, + "error_code": "turn_todo_selection_deferred", + "selection_rejection": { + "schema_version": "loopx_turn_selection_rejection_v0", + "source": "quota.should-run", "requested_todo_id": binding["todo_id"], + "state": state, "reason_code": "control_repair", + "delivery_preemptions": ["control_repair"], + "recovery_action": "reenter_guard_without_selection", + "status_health_ok": False, "contract_error_count": 2, + }, + }, + "acceptance": None, "validation_files_current": False, + } + + accepted = effect_runtime_result("collaboration.delegation.preflight", params("deferred")) + assert accepted["state"] == "turn_blocked" + assert accepted["turn_blocker"]["state"] == "deferred" + assert accepted["turn_eligible"] is False and accepted["executor"] is None + assert not any(accepted["effects"].values()) + for raw in (["deferred"], ["rejected"], ["unavailable"]): + with pytest.raises(EffectRuntimeRemoteError): + effect_runtime_result("collaboration.delegation.preflight", params(raw)) + + def test_preflight_projects_unavailable_authority_without_turn_or_provider( service, monkeypatch ):