diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 86ad1f13e8..6d87e62abd 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -636,6 +636,18 @@ read or resume evaluation is added. Remaining T3 work includes consumers that reconstruct diagnostics from compact summaries; do not call those migrated. This does not close T1/T2, all T3 consumers, or any durability/promotion hold. +Runtime capability re-entry now uses that same TS owner for verification-target selection, +owner-authority exclusion, advisory versus bound Turn handling, and the recovery contract +without durable grants. Python removes the former target lookup/filter rules and only adapts +host/scheduler facts, calls one typed reducer, and renders shell argv. One interaction packet +reuses the result; a healthy path adds no runtime call. The intentional correction is that an +eligible fallback recommendation cannot hide a blocked task's real capability check before +explicit selection. Success re-enters the same Turn; failure still allows explicit fallback +selection. A committed receipt's Todo remains bound. This is a host-local read plan under +section 3 of the shared-authority RFC, not a capability lease, shared grant, or provider write. +CLI/managed Turn reuse the existing re-entry fields; the generated `/loopx` skill requires +checking missing declarations. No frontend configuration or second UI state is introduced. + Advancement-frontier checkpoint closure: `todos/frontier_revision.ts` now owns agent selection, completeness, material hashing, long-chain thresholds and exact ACK/rearm classification. Python retains the v0 field manifest and legacy JSON/ diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index 52439147c5..877e531952 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -486,6 +486,16 @@ Todo,同一 Todo 的不同展示不重复计算,权威空 backlog 不再复 target capability 是修复产出,不是安装或授权。没有新 provider/inventory/enablement/ promotion;压缩候选来源的上限和其余 T3 consumer 仍需分别闭合。 +运行时能力重入现在复用同一个 TS owner:验证目标选择、owner 权限排除、推荐与已绑定 +Turn 的区分,以及无持久授权的恢复合同由 `agents/capability_gate.ts` 负责。Python 删除 +旧目标查找与过滤规则,仅适配 host/scheduler 参数、调用一次 typed reducer 并渲染 shell +argv;同一 interaction packet 复用结果,健康路径不增加 runtime 调用。修正行为是:显式 +选择前,可执行的低优先级推荐不能隐藏受阻任务的真实能力验证;验证成功在原 Turn 重入, +失败后仍可显式选择其他工作。已提交 receipt 的 Todo 不变。该观察属于 host-local read +plan,遵循 shared-authority RFC 第 3 节边界;不增加 capability lease、共享 grant 或 +provider 写入。CLI/managed Turn 复用已有重入字段,生成的 `/loopx` skill 明确要求核对 +缺失声明;没有新增前端配置或第二份 UI 状态。 + Advancement-frontier checkpoint 闭合:`todos/frontier_revision.ts` 现统一 Agent 选择、完整度、实质内容哈希、长链阈值与精确 ACK/rearm 分类。Python 保留 v0 字段清单 与 legacy JSON/metadata codec,使合法且未变化的 frontier 保持已有指纹;删除旧 Python diff --git a/loopx/control_plane/agents/capability_gate.ts b/loopx/control_plane/agents/capability_gate.ts index e0987187a1..c1db04af8e 100644 --- a/loopx/control_plane/agents/capability_gate.ts +++ b/loopx/control_plane/agents/capability_gate.ts @@ -1,6 +1,6 @@ /** Read policy only: requirements are not enablement, credentials or write authority. */ import type {JsonObject} from "../effect_program.ts"; -import {requireJsonObject, requireStringArray, requireInteger, requireNonEmptyString} from "../runtime_decode.ts"; +import {requireJsonObject, requireStringArray, requireInteger, requireNonEmptyString, requireBoolean, optionalNonEmptyString} from "../runtime_decode.ts"; const DEFAULT_AVAILABLE = ["shell", "filesystem_read", "filesystem_write"]; const OWNER_HELD = new Set(["credentials", "production_access"]); @@ -112,10 +112,76 @@ export function projectCapabilityGate(request: JsonObject): JsonObject | null { reason: "all visible executable todo candidates require unavailable capabilities"}; } +/** Host-local read plan: never writes a grant or changes a committed Turn binding. */ +export function projectRuntimeCapabilityReentry(request: JsonObject): JsonObject | null { + const gate = requireJsonObject(request.gate, "gate"); + const observed = unique(requireStringArray(request.available, "available").filter(c => !OWNER_HELD.has(c))); + const selectionRequired = requireBoolean(request.selection_required, "selection_required"); + const selectedId = optionalNonEmptyString(request.selected_todo_id, "selected_todo_id"); + const receiptId = optionalNonEmptyString(request.receipt_todo_id, "receipt_todo_id"); + // A receipt remains binding even if an inconsistent advisory flag accompanies it. + const boundId = receiptId ?? (selectionRequired ? null : selectedId); + const baseArgs = requireStringArray(request.command_prefix, "command_prefix"); + const schedulerArgs = requireStringArray(request.scheduler_args, "scheduler_args"); + const missing = requireStringArray(gate.repair_missing ?? [], "repair_missing") + .filter(c => !OWNER_HELD.has(c) && !observed.includes(c)); + const resolutions = list(gate.resolution_bindings ?? []).map(value => { + const row = requireJsonObject(value, "resolution binding"); + return { + owner: requireNonEmptyString(row.owner, "owner"), + capability: requireNonEmptyString(row.capability, "capability"), + primary: optionalNonEmptyString(row.primary_blocked_todo_id, "primary_blocked_todo_id"), + ids: requireStringArray(row.blocked_todo_ids ?? [], "blocked_todo_ids"), + }; + }); + const blocked = list(gate.blocked_candidates ?? []).map(value => { + const row = requireJsonObject(value, "blocked candidate"); + return { + id: optionalNonEmptyString(row.todo_id, "todo_id"), + instruction: optionalNonEmptyString(row.text, "text"), + action: optionalNonEmptyString(row.action_kind, "action_kind") ?? "unspecified", + target: optionalNonEmptyString(row.target_key, "target_key"), + required: requireStringArray(row.required_capabilities ?? [], "required_capabilities"), + }; + }); + if (!schedulerArgs.length) return null; + const candidates: JsonObject[] = []; + for (const capability of unique(missing)) { + const bindings = resolutions.filter(row => row.owner === "agent" && row.capability === capability); + const ids = new Set(bindings.flatMap(row => row.ids.length ? row.ids : row.primary ? [row.primary] : [])); + const eligible = blocked.filter(row => row.id && ids.has(row.id) && row.instruction && + row.required.includes(capability) && (!boundId || row.id === boundId)); + // Prefer the gate's highest-priority target, not incidental display order. + const target = eligible.find(row => bindings.some(binding => binding.primary === row.id)) ?? eligible[0]; + if (!target) continue; + candidates.push({ + capability, verification_required: "successful_real_callsite_observation", + verification_target: {todo_id: target.id!, action_kind: target.action, instruction: target.instruction!, + ...(target.target ? {target_ref: target.target} : {})}, + command_argv: [...baseArgs, ...observed.flatMap(c => ["--available-capability", c]), + "--available-capability", capability, ...schedulerArgs], + }); + } + if (!candidates.length) return null; + return { + schema_version: "runtime_capability_reentry_v0", state: "verification_required", + source: "quota_should_run.capability_gate.repair_missing", candidates, + verification_contract: {scope: "real_task_facing_callsite_for_blocked_todo", ordinary_delivery_allowed: false, + advancement_checkpoint: false, settles_turn: false, + on_success: "rerun_quota_in_same_turn_then_continue_if_allowed", + on_failure: "record_exact_blocker_without_capability_flag"}, + inheritance_contract: {source_invocation: "verified quota should-run reentry", + propagates_to: ["interaction_contract.cli_channel.next_cli_actions", "quota spend-slot", "quota monitor-poll"], + session_scoped: true, durable_grant_written: false}, + failure_policy: "Do not add the capability flag when the real callsite check fails; continue the capability repair or record the concrete blocker.", + }; +} + export function evaluateCapabilityGate(value: unknown): JsonObject { const request = requireJsonObject(value, "capability gate request"); if (request.schema_version !== "capability_gate_request_v0") throw new TypeError("capability gate request schema mismatch"); if (request.operation === "project") return {schema_version: "capability_gate_result_v0", result: projectCapabilityGate(request)}; + if (request.operation === "reentry") return {schema_version: "capability_gate_result_v0", result: projectRuntimeCapabilityReentry(request)}; if (request.operation === "missing") { const available = requireStringArray(request.available, "available"); return {schema_version: "capability_gate_result_v0", result: list(request.items).map(item => { diff --git a/loopx/control_plane/work_items/interaction_contract.py b/loopx/control_plane/work_items/interaction_contract.py index 6fcf2fd5e4..05afa0924b 100644 --- a/loopx/control_plane/work_items/interaction_contract.py +++ b/loopx/control_plane/work_items/interaction_contract.py @@ -40,6 +40,7 @@ ) from .accountable_settlement import build_accountable_work_item_settlement_plan from . import action_selection_contract as selection +from . import runtime_capability_reentry as capability_reentry_adapter from .primary_action import ( build_primary_action_projection, protocol_action_label as _protocol_action_label, @@ -48,7 +49,6 @@ protocol_monitor_action as _protocol_monitor_action, ) from .replan_settlement import project_replan_settlement_contract -from .runtime_capability_reentry import build_runtime_capability_reentry_packet from .user_action_frontier import user_action_owns_empty_agent_lane INTERACTION_CONTRACT_SCHEMA_VERSION = "loopx_interaction_contract_v0" @@ -667,6 +667,7 @@ def interaction_next_cli_actions( Mapping[str, Any] | SchedulerExecutionContextResolution | None ) = None, capability_reentry: dict[str, Any] | None = None, + capability_reentry_resolved: bool = False, settlement_plan: Mapping[str, Any] | None = None, turn_instance_id: str | None = None, runtime_root: str | None = None, @@ -763,8 +764,8 @@ def interaction_next_cli_actions( else None ), ) - if capability_reentry is None: - capability_reentry = build_runtime_capability_reentry_packet( + if capability_reentry is None and not capability_reentry_resolved: + capability_reentry = capability_reentry_adapter.build_runtime_capability_reentry_packet( payload, available_capabilities=available_capabilities, scheduler_execution_context=scheduler_execution_context, @@ -1172,22 +1173,11 @@ def _build_interaction_agent_channel( channel["action_portfolio_ref"] = "$.action_portfolio" selection.apply_action_selection_agent_gate(channel, payload) if capability_reentry is not None: - candidate = capability_reentry["candidates"][0] - target = candidate["verification_target"] - channel["next_task_action"] = { - "kind": "capability_verification", - "capability": candidate["capability"], - "todo_id": target["todo_id"], - "action_kind": target["action_kind"], - "operation": target["action_kind"], - "instruction": target["instruction"], - "preflight_allowed": False, - "advancement_checkpoint": False, - "settles_turn": False, - "continuation_cli_action_index": 0, - } - if target.get("target_ref"): - channel["next_task_action"]["target_ref"] = target["target_ref"] + capability_reentry_adapter.apply_agent_channel_projection( + channel, + capability_reentry, + selection_required=selection.action_portfolio_requires_explicit_selection(payload), + ) if _blocked_successor_wait_observation_required(payload): channel["primary_action"] = ( "record one no-spend blocked-successor wait observation, rerun quota, " @@ -1269,14 +1259,6 @@ def _build_interaction_cli_channel( runtime_root: str | None = None, ) -> dict[str, Any]: spend_after_selection = selection.delivery_spend_allowed(payload, spend_after_validation) - if capability_reentry is None: - capability_reentry = build_runtime_capability_reentry_packet( - payload, - available_capabilities=available_capabilities, - scheduler_execution_context=scheduler_execution_context, - turn_instance_id=turn_instance_id, - runtime_root=runtime_root, - ) settlement_plan, replan_settlement_contract = ( _turn_scoped_cli_settlement_context( payload, @@ -1293,6 +1275,7 @@ def _build_interaction_cli_channel( available_capabilities=available_capabilities, scheduler_execution_context=scheduler_execution_context, capability_reentry=capability_reentry, + capability_reentry_resolved=True, settlement_plan=settlement_plan, turn_instance_id=turn_instance_id, runtime_root=runtime_root, @@ -1312,7 +1295,11 @@ def _build_interaction_cli_channel( if settlement_plan is not None and replan_settlement_contract is not None: channel["replan_settlement_contract"] = replan_settlement_contract if capability_reentry is not None: - channel["runtime_capability_reentry"] = capability_reentry + capability_reentry_adapter.apply_cli_channel_projection( + channel, + capability_reentry, + selection_required=selection.action_portfolio_requires_explicit_selection(payload), + ) selected_todo = ( payload.get("selected_todo") if isinstance(payload.get("selected_todo"), Mapping) @@ -1481,7 +1468,7 @@ def build_interaction_contract( and todo_lifecycle_settlement_obligation(payload) is None ) required_reads = _interaction_required_reads(payload) - capability_reentry = build_runtime_capability_reentry_packet( + capability_reentry = capability_reentry_adapter.build_runtime_capability_reentry_packet( payload, available_capabilities=available_capabilities, scheduler_execution_context=scheduler_execution_context, diff --git a/loopx/control_plane/work_items/runtime_capability_reentry.py b/loopx/control_plane/work_items/runtime_capability_reentry.py index 6d8f5b5b0d..0edcbc5989 100644 --- a/loopx/control_plane/work_items/runtime_capability_reentry.py +++ b/loopx/control_plane/work_items/runtime_capability_reentry.py @@ -10,53 +10,12 @@ render_scheduler_execution_args, ) from ..todos.contract import normalize_todo_id +from .action_selection_contract import action_portfolio_requires_explicit_selection RUNTIME_CAPABILITY_REENTRY_SCHEMA_VERSION = "runtime_capability_reentry_v0" -def _verification_target_for_capability( - capability_gate: Mapping[str, Any], - capability: str, -) -> dict[str, Any] | None: - blocked_ids = { - str(todo_id) - for binding in capability_gate.get("resolution_bindings") or [] - if isinstance(binding, Mapping) - and binding.get("owner") == "agent" - and binding.get("capability") == capability - for todo_id in ( - binding.get("blocked_todo_ids") - or [binding.get("primary_blocked_todo_id")] - ) - if str(todo_id or "").strip() - } - for candidate in capability_gate.get("blocked_candidates") or []: - if not isinstance(candidate, Mapping): - continue - todo_id = str(candidate.get("todo_id") or "").strip() - if todo_id not in blocked_ids: - continue - required = runtime_capabilities_for_cli_projection( - candidate.get("required_capabilities") - ) - if capability not in required: - continue - instruction = str(candidate.get("text") or "").strip() - if not instruction: - continue - target = { - "todo_id": todo_id, - "action_kind": str(candidate.get("action_kind") or "unspecified"), - "instruction": instruction, - } - target_ref = str(candidate.get("target_key") or "").strip() - if target_ref: - target["target_ref"] = target_ref - return target - return None - - def build_runtime_capability_reentry_packet( payload: Mapping[str, Any], *, @@ -74,15 +33,8 @@ def build_runtime_capability_reentry_packet( if isinstance(payload.get("capability_gate"), Mapping) else {} ) - observed = runtime_capabilities_for_cli_projection(available_capabilities) - candidates = [ - capability - for capability in runtime_capabilities_for_cli_projection( - capability_gate.get("repair_missing") - ) - if capability not in observed - ] - if not candidates: + # An empty gap has no re-entry work; avoid a runtime hop on the healthy path. + if not capability_gate.get("repair_missing"): return None try: @@ -101,8 +53,6 @@ def build_runtime_capability_reentry_packet( if isinstance(payload.get("selected_todo"), Mapping) else {} ) - selected_todo_id = normalize_todo_id(selected_todo.get("todo_id")) - goal_id = str(payload.get("goal_id") or "") agent_identity = ( payload.get("agent_identity") @@ -128,64 +78,75 @@ def build_runtime_capability_reentry_packet( base_args.extend(["--agent-id", agent_id]) if turn_instance_id: base_args.extend(["--turn-instance-id", turn_instance_id]) - for capability in observed: - base_args.extend(["--available-capability", capability]) - - reentry_candidates = [] - for capability in candidates: - verification_target = _verification_target_for_capability( - capability_gate, - capability, - ) - if verification_target is None: - continue - if ( - selected_todo_id - and normalize_todo_id(verification_target.get("todo_id")) - != selected_todo_id - ): - continue - cli_args = [ - *base_args, - "--available-capability", - capability, - *scheduler_args, - ] - reentry_candidates.append( - { - "capability": capability, - "verification_required": "successful_real_callsite_observation", - "verification_target": verification_target, - "command": shlex.join(cli_args), - } - ) - if not reentry_candidates: + from ..effect_runtime import effect_runtime_result + + receipt = payload.get("heartbeat_receipt") or {} + identity = receipt.get("settlement_identity") or {} + response = effect_runtime_result("agent.capability_gate.evaluate", { + "schema_version": "capability_gate_request_v0", + "operation": "reentry", + "gate": dict(capability_gate), + "available": runtime_capabilities_for_cli_projection(available_capabilities), + "selection_required": action_portfolio_requires_explicit_selection(payload), + "selected_todo_id": normalize_todo_id(selected_todo.get("todo_id")), + "receipt_todo_id": normalize_todo_id(identity.get("todo_id")), + "command_prefix": base_args, + "scheduler_args": scheduler_args, + }) + if not isinstance(response, dict) or response.get("schema_version") != "capability_gate_result_v0": + raise TypeError("invalid typed capability re-entry result") + result = response["result"] + if result is None: return None - return { - "schema_version": RUNTIME_CAPABILITY_REENTRY_SCHEMA_VERSION, - "state": "verification_required", - "source": "quota_should_run.capability_gate.repair_missing", - "candidates": reentry_candidates, - "verification_contract": { - "scope": "real_task_facing_callsite_for_blocked_todo", - "ordinary_delivery_allowed": False, - "advancement_checkpoint": False, - "settles_turn": False, - "on_success": "rerun_quota_in_same_turn_then_continue_if_allowed", - "on_failure": "record_exact_blocker_without_capability_flag", - }, - "inheritance_contract": { - "source_invocation": "verified quota should-run reentry", - "propagates_to": [ - "interaction_contract.cli_channel.next_cli_actions", - "quota spend-slot", - "quota monitor-poll", - ], - "session_scoped": True, - "durable_grant_written": False, - }, - "failure_policy": ( - "Do not add the capability flag when the real callsite check fails; " - "continue the capability repair or record the concrete blocker." - ), + for candidate in result["candidates"]: + candidate["command"] = shlex.join(candidate.pop("command_argv")) + return result + + +def apply_agent_channel_projection( + channel: dict[str, Any], + capability_reentry: Mapping[str, Any], + *, + selection_required: bool, +) -> None: + """Adapt the typed re-entry plan to the existing agent-channel shape.""" + + if selection_required: + channel["primary_action"] = ( + "before choosing a fallback Todo, verify the projected missing " + "runtime capability at its real task-facing callsite; on success " + "run next_cli_actions[0] in this same Turn, then select a Todo; " + "on failure record the concrete blocker and select eligible work " + "with selection_command without adding a capability flag" + ) + candidate = capability_reentry["candidates"][0] + target = candidate["verification_target"] + channel["next_task_action"] = { + "kind": "capability_verification", + "capability": candidate["capability"], + "todo_id": target["todo_id"], + "action_kind": target["action_kind"], + "operation": target["action_kind"], + "instruction": target["instruction"], + "preflight_allowed": False, + "advancement_checkpoint": False, + "settles_turn": False, + "continuation_cli_action_index": 0, } + if target.get("target_ref"): + channel["next_task_action"]["target_ref"] = target["target_ref"] + + +def apply_cli_channel_projection( + channel: dict[str, Any], + capability_reentry: Mapping[str, Any], + *, + selection_required: bool, +) -> None: + """Adapt the typed plan while retaining selection as the failure fallback.""" + + channel["runtime_capability_reentry"] = capability_reentry + if selection_required: + channel["next_cli_actions"] = [ + candidate["command"] for candidate in capability_reentry["candidates"] + ] diff --git a/loopx/slash_command_install.py b/loopx/slash_command_install.py index 6f160e3351..6c2b7d6c9a 100644 --- a/loopx/slash_command_install.py +++ b/loopx/slash_command_install.py @@ -179,6 +179,7 @@ def _command_prompt_specs(*, cli_bin: str, include_legacy_aliases: bool) -> list "If the packet exposes a goal-selection gate, rerun one exact choice before any mutation.", "When authoring task Todos, treat `--action-kind` as the documented extensible public-safe token: choose a short task-relevant value such as `implement`, `test`, or `review`; do not search the LoopX source for an allowlist.", "Consume the turn-start quota JSON packet exactly once: read the complete output directly or save it and query it with `jq`; never pipe it through `head` or `tail`, and never rerun the turn-start call to recover hidden fields. A host whose runtime mints Turn identity uses `--begin-turn`; every other host passes its own `--turn-instance-id`. When selection is required, choose the Todo and use `interaction_contract.cli_channel.selection_command` with the returned Turn identity before mutation.", + "Runtime capability flags describe observed tools, not task requirements or durable permission grants. Before initial quota, include capabilities already established by this host context or successful task-facing use. Read capability_gate.repair_missing even when should_run is true: when runtime_capability_reentry is projected, verify its real callsite and follow the returned same-Turn command before choosing fallback work. Never infer credentials or production access from network availability, and do not claim a missing declaration proves a missing tool.", f"If arguments are empty and the host already identifies an active LoopX goal, follow its exact CLI `interaction_contract` or quota command first; otherwise inspect `{cli_bin} status` and `{cli_bin} bootstrap-command-pack --project .` before changing files.", "If this session cannot mutate the host loop surface, surface the exact pasteable gate instead of claiming autonomous setup.", ], diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index d101b717a8..6012afa9a5 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -1583,15 +1583,16 @@ def test_visible_goal_continuation_begins_turn_and_executes_returned_selection( assert _heartbeat_receipt_count(runtime, turn_instance_id) == 2 +@pytest.mark.parametrize("fallback_available", [False, True]) def test_visible_goal_capability_reentry_preserves_turn_through_selection( - tmp_path: Path, + tmp_path: Path, fallback_available: bool, ) -> None: project, runtime, registry_path = _write_fixture( tmp_path, - required_capability="network", + required_capability=None if fallback_available else "network", ) _configure_runtime_capability_reentry_fixture(project) - _configure_selectable_alternative(project, required_capability="network") + _configure_selectable_alternative(project, required_capability=None if fallback_available else "network") prompt = build_heartbeat_prompt( goal_id=GOAL_ID, agent_id=AGENT_ID, @@ -1610,6 +1611,11 @@ def test_visible_goal_capability_reentry_preserves_turn_through_selection( assert first_rc == 0, first turn_instance_id = first["heartbeat_receipt"]["turn_instance_id"] + if fallback_available: + assert first["interaction_contract"]["cli_channel"]["selection_required"] is True + assert first["selected_todo"]["todo_id"] == TODO_ID + assert first["interaction_contract"]["agent_channel"]["next_task_action"]["kind"] == "capability_verification" + assert first["interaction_contract"]["cli_channel"]["next_cli_actions"][0] == first["runtime_capability_reentry"]["candidates"][0]["command"] reentry_command = first["runtime_capability_reentry"]["candidates"][0][ "command" ] diff --git a/tests/control_plane_ts/capability_gate.test.ts b/tests/control_plane_ts/capability_gate.test.ts index 0a5fa95d4f..91e128d8b8 100644 --- a/tests/control_plane_ts/capability_gate.test.ts +++ b/tests/control_plane_ts/capability_gate.test.ts @@ -6,7 +6,7 @@ import {projectTodoQuotaPlanning, projectQuotaSelection} from "../../loopx/contr import {productionScaleCoordinationFixture} from "./production_scale_coordination_fixture.ts"; const row = (id: string, required: string[] = [], targets: string[] = [], priority = 1): JsonObject => ({ - payload: {todo_id: id, priority: `P${priority}`}, required, targets, rank: [0, 1, priority, 1, 1, 1], + payload: {todo_id: id, text: `Inspect ${id}`, priority: `P${priority}`}, required, targets, rank: [0, 1, priority, 1, 1, 1], }); const project = (candidates: JsonObject[], fields: JsonObject = {}) => projectCapabilityGate({ source: "fixture", candidates, available: [], candidate_order_policy: "claim_then_priority_then_active_next_then_repair", ...fields, @@ -90,3 +90,46 @@ test("runtime decoder rejects malformed facts rather than treating a requirement assert.throws(() => evaluateCapabilityGate({...request, operation: "enable"}), /unknown/); assert.throws(() => project([ {...row("a"), rank: [1]}]), /six/); }); + +const reentry = (fields: JsonObject = {}) => evaluateCapabilityGate({ + schema_version: "capability_gate_request_v0", operation: "reentry", + gate: project([row("p0", ["network"], [], 0), row("fallback", ["shell"], [], 1)]), + available: ["shell"], selection_required: true, selected_todo_id: "fallback", receipt_todo_id: null, + command_prefix: ["loopx", "--format", "json", "quota", "should-run", "--goal-id", "fixture", "--turn-instance-id", "same-turn"], + scheduler_args: ["--codex-app"], ...fields, +}).result as JsonObject | null; + +test("an advisory fallback cannot hide the blocked runtime check; bound work cannot switch", () => { + const plan = reentry()!; + const candidate = (plan.candidates as JsonObject[])[0]!; + assert.equal((candidate.verification_target as JsonObject).todo_id, "p0"); + assert.deepEqual((candidate.command_argv as string[]).slice(-5), ["--available-capability", "shell", "--available-capability", "network", "--codex-app"]); + assert.ok((candidate.command_argv as string[]).includes("same-turn")); + assert.equal(reentry({selection_required: false}), null); + assert.equal(reentry({receipt_todo_id: "fallback"}), null); + assert.equal(reentry({available: ["network"]}), null); + assert.equal(reentry({scheduler_args: []}), null); +}); + +test("verification target honors primary priority and an exact binding even after another target", () => { + const gate = project([row("low", ["network"], [], 2), row("high", ["network"], [], 0)])!; + const target = (plan: JsonObject) => ((plan.candidates as JsonObject[])[0]!.verification_target as JsonObject).todo_id; + assert.equal(target(reentry({gate})!), "high"); + assert.equal(target(reentry({gate, receipt_todo_id: "low"})!), "low"); +}); + +test("verification never converts owner authority into runtime availability", () => { + assert.equal(reentry({gate: project([row("private", ["credentials", "production_access"])])}), null); + const plan = reentry({available: ["shell", "credentials", "production_access"]})!; + const args = (plan.candidates as JsonObject[])[0]!.command_argv as string[]; + assert.ok(!args.includes("credentials") && !args.includes("production_access")); + assert.equal((plan.inheritance_contract as JsonObject).durable_grant_written, false); + assert.equal((plan.verification_contract as JsonObject).ordinary_delivery_allowed, false); +}); + +test("reentry decoder rejects malformed selection and requirement facts", () => { + assert.throws(() => reentry({selection_required: "false"}), /selection_required/); + assert.throws(() => reentry({receipt_todo_id: 3}), /receipt_todo_id/); + assert.throws(() => reentry({gate: {repair_missing: "network"}}), /repair_missing/); + assert.throws(() => reentry({gate: {blocked_candidates: [{required_capabilities: "network"}]}}), /required_capabilities/); +}); diff --git a/tests/test_slash_command_install.py b/tests/test_slash_command_install.py index dc41179cfc..0cff76939e 100644 --- a/tests/test_slash_command_install.py +++ b/tests/test_slash_command_install.py @@ -167,6 +167,8 @@ def test_codex_install_upgrades_managed_loopx_facade(tmp_path: Path) -> None: assert "never pipe a `--begin-turn` call" not in skill_text assert "passes its own `--turn-instance-id`" in skill_text assert "interaction_contract.cli_channel.selection_command" in skill_text + assert "Read capability_gate.repair_missing even when should_run is true" in skill_text + assert "do not claim a missing declaration proves a missing tool" in skill_text assert "do not return merely after setup, planning, or claim" not in skill_text metadata_text = metadata.read_text(encoding="utf-8") assert 'display_name: "LoopX"' in metadata_text