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
26 changes: 26 additions & 0 deletions docs/reference/local-delegation.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,32 @@ 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, 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.

中文:预检以 binding 固定的真实 worker 工作树作为安全扫描根。quota 因控制面
修复延后该精确 Todo 时,返回 `state: turn_blocked`、原选路状态、
`turn_blocker.reason_code` 和契约错误数;规范验收可能仍已就绪。
`executor: null` 表示未检查执行器,不表示模型故障。使用相同 registry 和
`loopx check --scan-root /absolute/reviewer-worktree` 定位,再修复原来源或配置
并重做预检。不能豁免测试目录、改扫安装包、换 Todo 或创建新操作绕过拒绝。
其他无结构 CLI 故障仍报错;拒绝投影字段畸形(含 `state` 不是已解码字符串
字面量)时按失败关闭报错,不返回 `turn_blocked`;该观察不启动 host、Turn
journal 或扣额。
普通 quota 选路仍可安排其他独立且合格的工作;本预检不会替换 Todo。

`request.json` contains the same `collaboration_brief_v0` used by MCP:

```json
Expand Down
20 changes: 19 additions & 1 deletion loopx/cli_commands/turn_decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,27 @@
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

#: The route source every Turn owner attributes its decision to.
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,
*,
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion loopx/collaboration_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
23 changes: 23 additions & 0 deletions loopx/control_plane/collaboration/delegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, turnSelectionRejectionState} from "../turn_driver/selection_rejection.ts";

function requireThat(ok: unknown, message: string): asserts ok {
if (!ok) throw new EffectRuntimeRequestError(message);
Expand Down Expand Up @@ -170,6 +171,28 @@ 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);
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
&& 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 {
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");
Expand Down
2 changes: 2 additions & 0 deletions loopx/control_plane/effect_runtime_handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,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";
Expand Down Expand Up @@ -714,6 +715,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],
Expand Down
60 changes: 60 additions & 0 deletions loopx/control_plane/turn_driver/selection_rejection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/** Observe a refused selection from quota's decision; never decide admission. */
import type {JsonObject} from "../effect_program.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 || 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
|| 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, key === "state" ? state : 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 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;
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,
},
};
}
24 changes: 24 additions & 0 deletions tests/control_plane_ts/delegation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]};
Expand All @@ -15,6 +16,29 @@ 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<string,unknown>),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<string,unknown>),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<string,unknown>),state:raw},
error_code:`turn_todo_selection_${raw[0]}`}}),/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");
Expand Down
57 changes: 57 additions & 0 deletions tests/control_plane_ts/turn_selection_rejection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import test from "node:test";
import assert from "node:assert/strict";
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",
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"}, {...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<string,unknown>).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",
delivery_preemptions:["control_repair","/operator/path"],recovery_action:"raw recovery"}}});
const refusal = result.selection_rejection as Record<string,unknown>;
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);
});
Loading
Loading