From 9a0219dfcd7f978764c3b165f52a1ed43a43f4c6 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 01:34:43 +0800 Subject: [PATCH 1/2] refactor(quota): centralize capability resolution and retire Python decisions Signed-off-by: huangruiteng --- loopx/control_plane/agents/capability_gate.py | 411 ++---------------- loopx/control_plane/agents/capability_gate.ts | 127 ++++++ .../control_plane/effect_runtime_handlers.ts | 2 + loopx/control_plane/todos/quota_selection.py | 8 +- loopx/control_plane/todos/quota_selection.ts | 19 +- tests/control_plane/test_capability_gate.py | 22 + .../control_plane/test_capability_gate_cli.py | 52 +++ .../control_plane_ts/capability_gate.test.ts | 92 ++++ 8 files changed, 353 insertions(+), 380 deletions(-) create mode 100644 loopx/control_plane/agents/capability_gate.ts create mode 100644 tests/control_plane/test_capability_gate_cli.py create mode 100644 tests/control_plane_ts/capability_gate.test.ts diff --git a/loopx/control_plane/agents/capability_gate.py b/loopx/control_plane/agents/capability_gate.py index eaac3df43e..150935d68d 100644 --- a/loopx/control_plane/agents/capability_gate.py +++ b/loopx/control_plane/agents/capability_gate.py @@ -1,5 +1,6 @@ from __future__ import annotations +from functools import lru_cache from typing import Any from ..todos.contract import ( @@ -19,19 +20,6 @@ from .profile import agent_profile_candidate_rank -CAPABILITY_GATE_SCHEMA_VERSION = "capability_gate_v0" -DEFAULT_AVAILABLE_CAPABILITIES = ( - "shell", - "filesystem_read", - "filesystem_write", -) -CAPABILITY_REPAIR_BRIDGE_HINTS = { - "benchmark_runner", - "network", - "external_evidence_poll", - "worker_bridge", - "cli_bridge", -} CAPABILITY_OWNER_GATE_HINTS = { "credentials", "production_access", @@ -48,190 +36,41 @@ def runtime_capabilities_for_cli_projection(value: Any) -> list[str]: ] -def _capability_missing_action(missing: list[str]) -> str: - missing_set = set(missing) - if not missing_set: - return "run" - if missing_set & CAPABILITY_OWNER_GATE_HINTS: - return "ask_owner" - return "repair_bridge" - - -def _capability_resolution(missing: list[str]) -> dict[str, Any]: - owner_missing = [ - capability - for capability in missing - if capability in CAPABILITY_OWNER_GATE_HINTS - ] - repair_missing = [ - capability - for capability in missing - if capability not in CAPABILITY_OWNER_GATE_HINTS - ] - action = _capability_missing_action(missing) - decision_owner = ( - "user" - if action == "ask_owner" - else "agent" - if action == "repair_bridge" - else "capability_gate" - ) - resolution_steps: list[dict[str, Any]] = [] - if owner_missing: - resolution_steps.append( - { - "owner": "user", - "action": "provide_or_authorize", - "capabilities": owner_missing, - } - ) - if repair_missing: - resolution_steps.append( - { - "owner": "agent", - "action": "repair_bridge", - "capabilities": repair_missing, - } - ) - return { - "action": action, - "decision_owner": decision_owner, - "owner_missing": owner_missing, - "repair_missing": repair_missing, - "unsupported_missing": [], - "resolution_steps": resolution_steps, - } - - -def _capability_priority(value: Any) -> str: - priority = str(value or "").strip().upper() - for prefix in ("P0", "P1", "P2"): - if priority.startswith(prefix): - return prefix - return "P1" - +def _evaluate(operation: str, **facts: Any) -> Any: + from ..effect_runtime import effect_runtime_result -def _blocked_capability_resolution_bindings( - blocked: list[dict[str, Any]], -) -> list[dict[str, Any]]: - bindings: dict[tuple[str, str], dict[str, Any]] = {} - for item in blocked: - todo_id = str(item.get("todo_id") or "").strip() - for capability in normalize_required_capabilities( - item.get("missing_capabilities") - ): - action = _capability_missing_action([capability]) - if action == "ask_owner": - owner = "user" - resolution_action = "provide_or_authorize" - elif action == "repair_bridge": - owner = "agent" - resolution_action = "repair_bridge" - else: - owner = "capability_gate" - resolution_action = "unsupported" - key = (owner, capability) - binding = bindings.get(key) - if binding is None: - binding = { - "owner": owner, - "action": resolution_action, - "capability": capability, - "priority": _capability_priority(item.get("priority")), - "primary_blocked_todo_id": todo_id or None, - "blocked_todo_ids": [], - } - bindings[key] = binding - if todo_id and todo_id not in binding["blocked_todo_ids"]: - binding["blocked_todo_ids"].append(todo_id) - return list(bindings.values()) + response = effect_runtime_result("agent.capability_gate.evaluate", { + "schema_version": "capability_gate_request_v0", "operation": operation, **facts, + }) + if not isinstance(response, dict) or response.get("schema_version") != "capability_gate_result_v0": + raise TypeError("invalid typed capability gate result") + return response["result"] -def _binding_capabilities( - bindings: list[dict[str, Any]], - *, - owner: str, -) -> list[str]: - result: list[str] = [] - for binding in bindings: - if binding.get("owner") != owner: - continue - capability = str(binding.get("capability") or "").strip() - if capability and capability not in result: - result.append(capability) - return result - - -def _owner_capability_action(bindings: list[dict[str, Any]]) -> str | None: - actions: list[str] = [] - for binding in bindings: - if binding.get("owner") != "user": - continue - capability = str(binding.get("capability") or "").strip() - todo_id = str(binding.get("primary_blocked_todo_id") or "").strip() - if capability: - actions.append(f"{capability} for {todo_id}" if todo_id else capability) - if not actions: - return None - return "provide or authorize the missing owner-held capability: " + ", ".join( - actions - ) - +@lru_cache(maxsize=512) +def _missing(required: tuple[str, ...], targets: tuple[str, ...], + available: tuple[str, ...]) -> tuple[str, ...]: + # Cache pure normalized requirement values, never Todo identity/state or authority. + result = _evaluate("missing", items=[{"required": list(required), "targets": list(targets)}], + available=list(available)) + return tuple(result[0]) -def available_capabilities_with_defaults(value: Any) -> list[str]: - capabilities = list(DEFAULT_AVAILABLE_CAPABILITIES) - for capability in normalize_required_capabilities(value): - if capability not in capabilities: - capabilities.append(capability) - return capabilities - -def missing_required_capabilities( - item: dict[str, Any], - *, - available_capabilities: Any, -) -> list[str]: - available = set(available_capabilities_with_defaults(available_capabilities)) - required = normalize_required_capabilities(item.get("required_capabilities")) - targets = set(normalize_target_capabilities(item.get("target_capabilities"))) - return [ - capability - for capability in required - if capability not in targets and capability not in available - ] +def missing_required_capabilities(item: dict[str, Any], *, available_capabilities: Any) -> list[str]: + required = tuple(normalize_required_capabilities(item.get("required_capabilities"))) + if not required: + return [] + return list(_missing(required, tuple(normalize_target_capabilities(item.get("target_capabilities"))), + tuple(normalize_required_capabilities(available_capabilities)))) def _capability_item_identity(item: dict[str, Any]) -> tuple[str, str]: return ( str(item.get("todo_id") or ""), - str(item.get("text") or "").strip(), + "" if item.get("todo_id") else str(item.get("text") or "").strip(), ) -def _capability_candidate_item( - item: dict[str, Any], - *, - missing: list[str], - missing_target_capabilities: list[str] | None = None, -) -> dict[str, Any]: - text = str(item.get("text") or "").strip() - payload = compact_todo_summary_item(item, text=text) - required = normalize_required_capabilities(item.get("required_capabilities")) - targets = normalize_target_capabilities(item.get("target_capabilities")) - payload["required_capabilities"] = required - if targets: - payload["target_capabilities"] = targets - payload["missing_capabilities"] = missing - payload["capability_action"] = _capability_missing_action(missing) - missing_targets = normalize_target_capabilities(missing_target_capabilities) - if missing_targets: - payload["missing_target_capabilities"] = missing_targets - if missing_targets and set(missing_targets) & CAPABILITY_REPAIR_BRIDGE_HINTS: - payload["capability_repair_mode"] = True - payload["capability_action"] = "repair_bridge" - return payload - - def _agent_lane_candidate_sort_key( raw_item: dict[str, Any], *, @@ -257,39 +96,6 @@ def _agent_lane_candidate_sort_key( ) -def _sort_capability_runnable_candidates( - runnable: list[dict[str, Any]], - *, - agent_identity: dict[str, Any] | None, -) -> tuple[list[dict[str, Any]], str | None]: - if not isinstance(agent_identity, dict): - return runnable, None - agent_id = normalize_todo_claimed_by(agent_identity.get("agent_id")) - if not agent_id: - return runnable, None - agent_profile = ( - agent_identity.get("agent_profile") - if isinstance(agent_identity.get("agent_profile"), dict) - else None - ) - policy = ( - "claim_then_profile_then_priority_then_active_next_then_repair" - if agent_profile - else "claim_then_priority_then_active_next_then_repair" - ) - return ( - sorted( - runnable, - key=lambda item: _agent_lane_candidate_sort_key( - item, - agent_id=agent_id, - agent_profile=agent_profile, - ), - ), - policy, - ) - - def _select_advancement_candidate_source( agent_todo_summary: dict[str, Any], ) -> tuple[list[Any], str]: @@ -304,7 +110,7 @@ def _select_advancement_candidate_source( ], "agent_todo_summary.active_next_action_executable_items", ) - if isinstance(backlog_items, list) and backlog_items: + if isinstance(backlog_items, list): return backlog_items, "agent_todo_summary.executable_backlog_items" if isinstance(first_executable_items, list) and first_executable_items: return first_executable_items, "agent_todo_summary.first_executable_items" @@ -369,163 +175,26 @@ def _collect_capability_gate_candidates( ) -def _match_capability_candidates( - candidates: list[dict[str, Any]], - *, - available_capabilities: list[str], -) -> tuple[list[dict[str, Any]], list[dict[str, Any]], bool]: - blocked: list[dict[str, Any]] = [] - runnable: list[dict[str, Any]] = [] - saw_requirement = False - for item in candidates: - required = normalize_required_capabilities(item.get("required_capabilities")) - targets = normalize_target_capabilities(item.get("target_capabilities")) - saw_requirement = saw_requirement or bool(required or targets) - missing = missing_required_capabilities( - item, - available_capabilities=available_capabilities, - ) - if missing: - blocked.append(_capability_candidate_item(item, missing=missing)) - continue - missing_targets = [ - capability - for capability in targets - if capability not in available_capabilities - ] - runnable.append( - _capability_candidate_item( - item, - missing=[], - missing_target_capabilities=missing_targets, - ) - ) - return blocked, runnable, saw_requirement - - -def _unique_candidate_values( - candidates: list[dict[str, Any]], - field: str, -) -> list[str]: - values: list[str] = [] - for item in candidates: - for value in item.get(field) or []: - normalized = str(value) - if normalized not in values: - values.append(normalized) - return values - - -def _build_runnable_capability_gate( - *, - source: str, - available: list[str], - blocked: list[dict[str, Any]], - runnable: list[dict[str, Any]], - agent_identity: dict[str, Any] | None, -) -> dict[str, Any]: - runnable, candidate_order_policy = _sort_capability_runnable_candidates( - runnable, - agent_identity=agent_identity, - ) - resolution_bindings = _blocked_capability_resolution_bindings(blocked) - repair_missing = _unique_candidate_values( - [item for item in runnable if item.get("capability_repair_mode") is True], - "missing_target_capabilities", - ) - for capability in _binding_capabilities(resolution_bindings, owner="agent"): - if capability not in repair_missing: - repair_missing.append(capability) - return { - "schema_version": CAPABILITY_GATE_SCHEMA_VERSION, - "source": source, - "required": _unique_candidate_values(runnable, "required_capabilities"), - "available": available, - "missing": [], - "action": "run", - "decision_owner": "agent", - "selection_policy": "agent_steering_audit_over_runnable_candidates", - "candidate_order_policy": candidate_order_policy or "projection_order", - "runnable_count": len(runnable), - "runnable_candidates": runnable, - "blocked_candidates": blocked, - "blocked_missing": _unique_candidate_values(blocked, "missing_capabilities"), - "owner_missing": _binding_capabilities(resolution_bindings, owner="user"), - "repair_missing": repair_missing, - "unsupported_missing": _binding_capabilities( - resolution_bindings, - owner="capability_gate", - ), - "resolution_bindings": resolution_bindings, - "repair_candidate_count": sum( - 1 for item in runnable if item.get("capability_repair_mode") is True - ), - "reason": "capability gate projected runnable candidate set; agent chooses the actual todo", - "owner_action": _owner_capability_action(resolution_bindings), - } - - -def _build_blocked_capability_gate( - *, - source: str, - available: list[str], - blocked: list[dict[str, Any]], -) -> dict[str, Any]: - missing = _unique_candidate_values(blocked, "missing_capabilities") - resolution = _capability_resolution(missing) - resolution_bindings = _blocked_capability_resolution_bindings(blocked) - return { - "schema_version": CAPABILITY_GATE_SCHEMA_VERSION, - "source": source, - "required": _unique_candidate_values(blocked, "required_capabilities"), - "available": available, - "missing": missing, - "action": str(resolution["action"]), - "decision_owner": resolution["decision_owner"], - "owner_missing": list(resolution["owner_missing"]), - "repair_missing": list(resolution["repair_missing"]), - "unsupported_missing": resolution["unsupported_missing"], - "resolution_steps": resolution["resolution_steps"], - "resolution_bindings": resolution_bindings, - "selection_policy": "no_runnable_candidate", - "runnable_count": 0, - "runnable_candidates": [], - "blocked_candidates": blocked, - "blocks_delivery": True, - "reason": "all visible executable todo candidates require unavailable capabilities", - "owner_action": _owner_capability_action(resolution_bindings), - } - - def build_capability_gate( - agent_todo_summary: dict[str, Any] | None, - *, - available_capabilities: list[str], - agent_identity: dict[str, Any] | None = None, + agent_todo_summary: dict[str, Any] | None, *, + available_capabilities: list[str], agent_identity: dict[str, Any] | None = None, ) -> dict[str, Any] | None: if not isinstance(agent_todo_summary, dict): return None candidates, source = _collect_capability_gate_candidates(agent_todo_summary) if not candidates: return None - - available = available_capabilities_with_defaults(available_capabilities) - blocked, runnable, saw_requirement = _match_capability_candidates( - candidates, - available_capabilities=available, - ) - if not saw_requirement and not blocked: - return None - if runnable: - return _build_runnable_capability_gate( - source=source, - available=available, - blocked=blocked, - runnable=runnable, - agent_identity=agent_identity, - ) - return _build_blocked_capability_gate( - source=source, - available=available, - blocked=blocked, - ) + identity = agent_identity if isinstance(agent_identity, dict) else {} + agent = normalize_todo_claimed_by(identity.get("agent_id")) + profile = identity.get("agent_profile") + profile = profile if isinstance(profile, dict) else None + policy = ("claim_then_profile_then_priority_then_active_next_then_repair" if profile else + "claim_then_priority_then_active_next_then_repair") if agent else None + return _evaluate("project", source=source, + available=normalize_required_capabilities(available_capabilities), + candidate_order_policy=policy, candidates=[{ + "payload": compact_todo_summary_item(item, text=str(item.get("text") or "").strip()), + "required": normalize_required_capabilities(item.get("required_capabilities")), + "targets": normalize_target_capabilities(item.get("target_capabilities")), + "rank": list(_agent_lane_candidate_sort_key(item, agent_id=agent, agent_profile=profile)), + } for item in candidates]) diff --git a/loopx/control_plane/agents/capability_gate.ts b/loopx/control_plane/agents/capability_gate.ts new file mode 100644 index 0000000000..e0987187a1 --- /dev/null +++ b/loopx/control_plane/agents/capability_gate.ts @@ -0,0 +1,127 @@ +/** 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"; + +const DEFAULT_AVAILABLE = ["shell", "filesystem_read", "filesystem_write"]; +const OWNER_HELD = new Set(["credentials", "production_access"]); +const REPAIR_OUTPUT = new Set(["benchmark_runner", "network", "external_evidence_poll", "worker_bridge", "cli_bridge"]); +type CapabilityAction = "run" | "ask_owner" | "repair_bridge"; +type ResolutionOwner = "user" | "agent"; +interface Requirement {required: string[]; targets: string[]} +interface Candidate extends Requirement {payload: JsonObject; rank: number[]; ordinal: number} +interface Binding { + owner: ResolutionOwner; action: "provide_or_authorize" | "repair_bridge"; capability: string; + priority: string; primary_blocked_todo_id: string | null; blocked_todo_ids: string[]; +} +const unique = (values: readonly string[]) => [...new Set(values)]; +export const availableCapabilities = (values: readonly string[]): string[] => unique([...DEFAULT_AVAILABLE, ...values]); + +/** A repair output is not its own prerequisite; this does not grant that output. */ +export function missingRequiredCapabilities(required: readonly string[], targets: readonly string[], available: readonly string[]): string[] { + const present = new Set([...availableCapabilities(available), ...targets]); + return required.filter(capability => !present.has(capability)); +} +const action = (missing: readonly string[]): CapabilityAction => !missing.length ? "run" : + missing.some(capability => OWNER_HELD.has(capability)) ? "ask_owner" : "repair_bridge"; +function requirement(value: unknown): Requirement { + const row = requireJsonObject(value, "capability requirement"); + return {required: requireStringArray(row.required, "required"), targets: requireStringArray(row.targets, "targets")}; +} +function list(value: unknown): unknown[] { + if (!Array.isArray(value)) throw new TypeError("capability rows must be an array"); + return value; +} +const values = (rows: readonly JsonObject[], key: string) => unique(rows.flatMap(row => row[key] as string[] ?? [])); +const priority = (row: JsonObject): string => /^(P[0-2])/.exec(String(row.priority ?? "").trim().toUpperCase())?.[1] ?? "P1"; + +function bindings(blocked: readonly JsonObject[]): Binding[] { + const result = new Map(); + for (const row of blocked) for (const capability of row.missing_capabilities as string[]) { + const owner: ResolutionOwner = OWNER_HELD.has(capability) ? "user" : "agent"; + const id = String(row.todo_id ?? "").trim() || null; + const key = `${owner}:${capability}`; + let binding = result.get(key); + if (!binding) { + binding = {owner, action: owner === "user" ? "provide_or_authorize" : "repair_bridge", capability, + priority: priority(row), primary_blocked_todo_id: id, blocked_todo_ids: []}; + result.set(key, binding); + } else if (priority(row) < binding.priority) { + // Display/source order is not the urgency of a shared repair obligation. + binding.priority = priority(row); binding.primary_blocked_todo_id = id; + } + if (id && !binding.blocked_todo_ids.includes(id)) binding.blocked_todo_ids.push(id); + } + return [...result.values()]; +} + +export function projectCapabilityGate(request: JsonObject): JsonObject | null { + const available = availableCapabilities(requireStringArray(request.available, "available")); + const source = requireNonEmptyString(request.source, "source"); + const candidates: Candidate[] = list(request.candidates).map((value, ordinal) => { + const row = requireJsonObject(value, "capability candidate"); + const rank = list(row.rank).map(value => requireInteger(value, "rank")); + if (rank.length !== 6) throw new TypeError("capability candidate rank must have six dimensions"); + return {...requirement(row), payload: requireJsonObject(row.payload, "payload"), rank, ordinal}; + }); + const blocked: JsonObject[] = [], runnable: {payload: JsonObject; rank: number[]; ordinal: number}[] = []; + let sawRequirement = false; + for (const candidate of candidates) { + const {required, targets} = candidate; + sawRequirement ||= !!(required.length || targets.length); + const missing = missingRequiredCapabilities(required, targets, available); + const missingTargets = missing.length ? [] : targets.filter(capability => !available.includes(capability)); + const repair = missingTargets.some(capability => REPAIR_OUTPUT.has(capability)); + const payload = {...candidate.payload, required_capabilities: required, + ...(targets.length ? {target_capabilities: targets} : {}), missing_capabilities: missing, + capability_action: repair ? "repair_bridge" : action(missing), + ...(missingTargets.length ? {missing_target_capabilities: missingTargets} : {}), + ...(repair ? {capability_repair_mode: true} : {})}; + if (missing.length) blocked.push(payload); + else { + const rank = [...candidate.rank]; rank[4] = repair ? 0 : 1; + runnable.push({payload, rank, ordinal: candidate.ordinal}); + } + } + if (!sawRequirement && !blocked.length) return null; + const policy = request.candidate_order_policy == null ? null : requireNonEmptyString(request.candidate_order_policy, "candidate_order_policy"); + if (policy) runnable.sort((a, b) => { + for (let i = 0; i < a.rank.length; i++) if (a.rank[i] !== b.rank[i]) return a.rank[i]! - b.rank[i]!; + return a.ordinal - b.ordinal; + }); + const rows = runnable.map(row => row.payload), resolution = bindings(blocked); + const forOwner = (owner: ResolutionOwner) => resolution.filter(binding => binding.owner === owner).map(binding => binding.capability); + const ownerAction = resolution.filter(binding => binding.owner === "user").map(binding => + binding.primary_blocked_todo_id ? `${binding.capability} for ${binding.primary_blocked_todo_id}` : binding.capability); + const common = {schema_version: "capability_gate_v0", source, available, + runnable_count: rows.length, runnable_candidates: rows, blocked_candidates: blocked, + owner_missing: forOwner("user"), unsupported_missing: [], resolution_bindings: resolution, + owner_action: ownerAction.length ? `provide or authorize the missing owner-held capability: ${ownerAction.join(", ")}` : null}; + if (rows.length) return {...common, required: values(rows, "required_capabilities"), missing: [], action: "run", + decision_owner: "agent", selection_policy: "agent_steering_audit_over_runnable_candidates", + candidate_order_policy: policy ?? "projection_order", blocked_missing: values(blocked, "missing_capabilities"), + repair_missing: unique([...values(rows.filter(row => row.capability_repair_mode === true), "missing_target_capabilities"), ...forOwner("agent")]), + repair_candidate_count: rows.filter(row => row.capability_repair_mode === true).length, + reason: "capability gate projected runnable candidate set; agent chooses the actual todo"}; + const missing = values(blocked, "missing_capabilities"), ownerMissing = forOwner("user"), repairMissing = forOwner("agent"); + return {...common, required: values(blocked, "required_capabilities"), missing, action: action(missing), + decision_owner: ownerMissing.length ? "user" : "agent", repair_missing: repairMissing, + resolution_steps: [ + ...(ownerMissing.length ? [{owner: "user", action: "provide_or_authorize", capabilities: ownerMissing}] : []), + ...(repairMissing.length ? [{owner: "agent", action: "repair_bridge", capabilities: repairMissing}] : []), + ], selection_policy: "no_runnable_candidate", blocks_delivery: true, + reason: "all visible executable todo candidates require unavailable capabilities"}; +} + +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 === "missing") { + const available = requireStringArray(request.available, "available"); + return {schema_version: "capability_gate_result_v0", result: list(request.items).map(item => { + const {required, targets} = requirement(item); + return missingRequiredCapabilities(required, targets, available); + })}; + } + throw new TypeError("unknown capability gate operation"); +} diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 96995316af..bcb2633879 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -146,6 +146,7 @@ import { import { evaluateCoordinationTodoArchiveSelection } from "./coordination/todo_archive_selection.ts"; import {evaluateStandingDecisionProjection} from "./todos/standing_decision.ts"; import {evaluateDecisionScope} from "./todos/decision_scope.ts"; +import {evaluateCapabilityGate} from "./agents/capability_gate.ts"; import {captureArchivedTodoDependencies} from "./todos/archive_capture.ts"; import { evaluateCoordinationTodoSuccessorDerivation } from "./coordination/todo_successor_derivation.ts"; import { @@ -381,6 +382,7 @@ export function createEffectRuntimeHandlers( ["todo.public_update.plan", planPublicTodoUpdate], ["todo.standing_decision.project", evaluateStandingDecisionProjection], ["todo.decision_scope.evaluate", evaluateDecisionScope], + ["agent.capability_gate.evaluate", evaluateCapabilityGate], ["todo.archive.capture_dependencies", captureArchivedTodoDependencies], ["todo.monitor_metadata.plan", planMonitorMetadata], ["todo.authoring_scope.plan", planTodoAuthoringScope], diff --git a/loopx/control_plane/todos/quota_selection.py b/loopx/control_plane/todos/quota_selection.py index d4324969a1..95224798d7 100644 --- a/loopx/control_plane/todos/quota_selection.py +++ b/loopx/control_plane/todos/quota_selection.py @@ -4,12 +4,12 @@ from typing import Any -from ..agents.capability_gate import missing_required_capabilities from ..agents.profile import agent_profile_candidate_rank from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result from .contract import ( normalize_todo_claimed_by, normalize_todo_bound_agent, normalize_todo_blocks_agent, normalize_todo_excluded_agents, normalize_todo_global_gate, + normalize_required_capabilities, normalize_target_capabilities, ) from .projection import ( todo_item_has_removed_continuation_policy, todo_item_is_actionable_open, @@ -49,7 +49,8 @@ def encode(item: dict[str, Any]) -> dict[str, Any]: "task_class": todo_item_task_class(item), "priority": priority, "index": index, "profile_rank": agent_profile_candidate_rank(item, agent_profile=profile), - "missing": missing_required_capabilities(item, available_capabilities=available_capabilities), + "required": normalize_required_capabilities(item.get("required_capabilities")), + "targets": normalize_target_capabilities(item.get("target_capabilities")), "raw_claimed": bool(item.get("claimed_by")), } @@ -59,10 +60,11 @@ def active(key: str) -> list[dict[str, Any]]: try: result = effect_runtime_result("todo.quota_planning.project", { - "schema_version": "todo_quota_planning_request_v0", + "schema_version": "todo_quota_planning_request_v1", "resume": build_todo_resume_planning_request(value, agent_id=agent, item_limit=8, available_capabilities=(available_capabilities or []) if resolve_capacity else None), "selection": { + "available": normalize_required_capabilities(available_capabilities), "items": [encode(item) for item in all_open_items], "active_items": active("active_next_action_items"), "active_executable_items": active("active_next_action_executable_items"), diff --git a/loopx/control_plane/todos/quota_selection.ts b/loopx/control_plane/todos/quota_selection.ts index a3698fc3fa..c71e4a16bb 100644 --- a/loopx/control_plane/todos/quota_selection.ts +++ b/loopx/control_plane/todos/quota_selection.ts @@ -4,6 +4,7 @@ import { requireJsonObject, requireBoolean, requireInteger, requireStringArray, optionalNonEmptyString } from "../runtime_decode.ts"; import { projectTodoResumePlanning } from "./resume_planning.ts"; import { gateAddressesAgent } from "./gate_scope.ts"; +import { missingRequiredCapabilities } from "../agents/capability_gate.ts"; interface Row { payload: JsonObject; display: JsonObject; claim: string | null; @@ -13,7 +14,7 @@ interface Row { profileRank: number; missing: readonly string[]; rawClaimed: boolean; } -function decodeRow(value: unknown): Row { +function decodeRow(value: unknown, available?: readonly string[]): Row { const raw = requireJsonObject(value, "quota selection row"); const payload = requireJsonObject(raw.payload, "payload"); const boolean = (key: string) => requireBoolean(raw[key], key); @@ -25,12 +26,14 @@ function decodeRow(value: unknown): Row { gate: boolean("gate"), removed: boolean("removed"), actionable: boolean("actionable"), due: boolean("due"), taskClass: optional("task_class") ?? "advancement_task", priority: integer("priority"), index: integer("index"), profileRank: integer("profile_rank"), - missing: requireStringArray(raw.missing, "missing"), rawClaimed: boolean("raw_claimed")}; + missing: available === undefined ? requireStringArray(raw.missing, "missing") : + missingRequiredCapabilities(requireStringArray(raw.required, "required"), requireStringArray(raw.targets, "targets"), available), + rawClaimed: boolean("raw_claimed")}; } -function rows(value: unknown): Row[] { +function rows(value: unknown, available?: readonly string[]): Row[] { if (!Array.isArray(value)) throw new EffectRuntimeRequestError("quota rows must be an array"); - return value.map(decodeRow); + return value.map(row => decodeRow(row, available)); } const payloads = (items: readonly Row[]) => items.map(row => row.payload); const compact = (items: readonly Row[], limit: number) => items.slice(0, limit).map(row => row.display); @@ -119,7 +122,8 @@ function claimScope(source: readonly Row[], selected: readonly Row[], agent: str export function projectQuotaSelection(value: unknown): JsonObject { const request = requireJsonObject(value, "quota selection"); - const source = rows(request.items), active = rows(request.active_items), activeExecutable = rows(request.active_executable_items); + const available = request.available === undefined ? undefined : requireStringArray(request.available, "available"); + const source = rows(request.items, available), active = rows(request.active_items, available), activeExecutable = rows(request.active_executable_items, available); const agent = optionalNonEmptyString(request.agent_id, "agent_id"); const userMode = requireBoolean(request.user_gate_scope, "user_gate_scope"); const supported = requireBoolean(request.monitor_supported, "monitor_supported"); @@ -173,7 +177,10 @@ export function projectQuotaSelection(value: unknown): JsonObject { /** One quota read boundary composes scope/claim selection and existing resume rules. */ export function projectTodoQuotaPlanning(value: unknown): JsonObject { const request = requireJsonObject(value, "quota planning"); - if (request.schema_version !== "todo_quota_planning_request_v0") throw new EffectRuntimeRequestError("quota planning schema mismatch"); + if (!["todo_quota_planning_request_v0", "todo_quota_planning_request_v1"].includes(String(request.schema_version))) throw new EffectRuntimeRequestError("quota planning schema mismatch"); + if (request.schema_version === "todo_quota_planning_request_v1") { + requireStringArray(requireJsonObject(request.selection, "selection").available, "available"); + } return {schema_version: "todo_quota_planning_v0", resume_planning: projectTodoResumePlanning(request.resume), ...projectQuotaSelection(request.selection)}; } diff --git a/tests/control_plane/test_capability_gate.py b/tests/control_plane/test_capability_gate.py index 8cc6fc52e3..40bb052dbf 100644 --- a/tests/control_plane/test_capability_gate.py +++ b/tests/control_plane/test_capability_gate.py @@ -183,3 +183,25 @@ def test_target_capability_is_repair_output_instead_of_prerequisite() -> None: def test_requirement_free_candidate_does_not_invent_capability_gate() -> None: assert _gate({"executable_backlog_items": [_todo("todo_plain", index=1)]}) is None + + +def test_resolution_binding_uses_highest_priority_not_first_display_row() -> None: + low = {**_todo("todo_low", index=1, required=["credentials"]), "priority": "P2"} + high = {**_todo("todo_high", index=2, required=["credentials"]), "priority": "P0"} + gate = _gate({"executable_backlog_items": [low, high]}) + binding = gate["resolution_bindings"][0] + assert binding["primary_blocked_todo_id"] == "todo_high" + assert binding["priority"] == "P0" + assert set(binding["blocked_todo_ids"]) == {"todo_low", "todo_high"} + + +def test_one_todo_identity_is_not_duplicated_by_display_text() -> None: + todo = _todo("todo_one", index=1, required=["network"]) + gate = _gate({"active_next_action_executable_items": [todo], + "executable_backlog_items": [{**todo, "text": "A longer display rendering"}]}) + assert len(gate["blocked_candidates"]) == 1 + + +def test_authoritative_empty_backlog_does_not_revive_stale_first_item() -> None: + assert _gate({"executable_backlog_items": [], "first_executable_items": [ + _todo("todo_stale", index=1, required=["network"])]}) is None diff --git a/tests/control_plane/test_capability_gate_cli.py b/tests/control_plane/test_capability_gate_cli.py new file mode 100644 index 0000000000..7274121454 --- /dev/null +++ b/tests/control_plane/test_capability_gate_cli.py @@ -0,0 +1,52 @@ +"""Real quota reads preserve requirement/target semantics across authority sources.""" +import json + +import pytest +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from loopx.control_plane.coordination.runtime_shadow import build_runtime_shadow_source_snapshot +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.control_plane.testing.canary_harness import write_fixture_registry, run_json_cli_result + + +@pytest.mark.parametrize("provider", ["markdown", "file", "sqlite"]) +@pytest.mark.parametrize("network_available", [False, True]) +def test_real_quota_capability_gate_does_not_enable_repair_outputs(tmp_path, monkeypatch, provider, network_available): + if provider == "sqlite": + isolate_sqlite_runtime(tmp_path, monkeypatch) + state, runtime, registry = tmp_path / "STATE.md", tmp_path / "runtime", tmp_path / "registry.json" + state.write_text( + "# Goal\n\n## Agent Todo\n\n" + "- [ ] [P0] Inspect external evidence\n" + " \n" + "- [ ] [P1] Repair the bridge\n" + " \n" + "- [ ] [P2] Inspect protected evidence\n" + " \n" + "\n## User Todo / Owner Review Reading Queue\n\n" + ) + write_fixture_registry(project=tmp_path, runtime_root=runtime, registry_path=registry, goal_id="goal-a", + domain="software", adapter_kind="generic_project_goal_v0", state_file=str(state), + registered_agents=["agent-a"], quota_allowed_slots=None) + if provider != "markdown": + goal = json.loads(registry.read_text())["goals"][0] + projection, _ = build_runtime_shadow_source_snapshot(goal=goal, runtime_root=runtime, + state_path=state, registry_path=registry) + initialize_canonical_authority(runtime, "goal-a", projection, state_path=state, provider=provider) + state.unlink() + before = state.read_bytes() if state.exists() else None + authority = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a") + args = ["quota", "should-run", "--goal-id", "goal-a", "--agent-id", "agent-a", + "--include-detail", "agent-todos", "--scan-path", str(tmp_path)] + if network_available: + args += ["--available-capability", "network"] + code, packet = run_json_cli_result(*args, registry_path=registry, runtime_root=runtime) + assert code == 0, packet + gate = packet["capability_gate"] + assert gate["action"] == "run" + assert gate["owner_missing"] == ["credentials"] + assert ("network" in gate["available"]) is network_available + ids = [row["todo_id"] for row in gate["runnable_candidates"]] + assert ids == (["todo_external", "todo_repair"] if network_available else ["todo_repair"]) + assert gate["repair_candidate_count"] == (0 if network_available else 1) + assert (state.read_bytes() if state.exists() else None) == before + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a") == authority diff --git a/tests/control_plane_ts/capability_gate.test.ts b/tests/control_plane_ts/capability_gate.test.ts new file mode 100644 index 0000000000..0a5fa95d4f --- /dev/null +++ b/tests/control_plane_ts/capability_gate.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {evaluateCapabilityGate, missingRequiredCapabilities, projectCapabilityGate} from "../../loopx/control_plane/agents/capability_gate.ts"; +import {projectTodoQuotaPlanning, projectQuotaSelection} from "../../loopx/control_plane/todos/quota_selection.ts"; +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], +}); +const project = (candidates: JsonObject[], fields: JsonObject = {}) => projectCapabilityGate({ + source: "fixture", candidates, available: [], candidate_order_policy: "claim_then_priority_then_active_next_then_repair", ...fields, +}); + +test("requirements, repair outputs and runtime availability are different facts", () => { + assert.deepEqual(missingRequiredCapabilities(["shell", "network", "credentials"], [], []), ["network", "credentials"]); + assert.deepEqual(missingRequiredCapabilities(["network", "credentials"], ["network"], []), ["credentials"]); + assert.deepEqual(missingRequiredCapabilities(["network"], [], ["network"]), []); + const input = [row("repair", ["network"], ["network"]), row("blocked", ["network"])]; + const before = structuredClone(input); + const gate = project(input)!; + assert.equal(gate.action, "run"); + assert.deepEqual(gate.available, ["shell", "filesystem_read", "filesystem_write"]); + assert.equal(gate.repair_candidate_count, 1); + assert.deepEqual((gate.blocked_candidates as JsonObject[]).map(item => item.todo_id), ["blocked"]); + assert.deepEqual(input, before); +}); + +test("owner and bridge resolution remain scoped to affected work, including mixed blockers", () => { + const gate = project([row("a", ["credentials", "network"]), row("b", ["production_access", "gpu_runner"])])!; + assert.equal(gate.action, "ask_owner"); + assert.deepEqual(gate.owner_missing, ["credentials", "production_access"]); + assert.deepEqual(gate.repair_missing, ["network", "gpu_runner"]); + assert.deepEqual(gate.resolution_steps, [ + {owner: "user", action: "provide_or_authorize", capabilities: ["credentials", "production_access"]}, + {owner: "agent", action: "repair_bridge", capabilities: ["network", "gpu_runner"]}, + ]); + const runnable = project([row("blocked", ["credentials"]), row("local", ["shell"])])!; + assert.equal(runnable.action, "run"); + assert.equal(runnable.blocks_delivery, undefined); + assert.deepEqual(runnable.owner_missing, ["credentials"]); +}); + +test("binding priority is independent of display order without dropping impacted identities", () => { + for (const candidates of [[row("low", ["network"], [], 2), row("high", ["network"], [], 0)], + [row("high", ["network"], [], 0), row("low", ["network"], [], 2)]]) { + const binding = (project(candidates)!.resolution_bindings as JsonObject[])[0]!; + assert.equal(binding.priority, "P0"); assert.equal(binding.primary_blocked_todo_id, "high"); + assert.deepEqual(new Set(binding.blocked_todo_ids as string[]), new Set(["low", "high"])); + } +}); + +test("repair order stays inside existing claim/profile/priority buckets", () => { + const normal = row("normal", ["shell"], [], 0), repair = row("repair", ["network"], ["network"], 1); + const ids = (value: JsonObject) => (value.runnable_candidates as JsonObject[]).map(row => row.todo_id); + assert.deepEqual(ids(project([repair, normal])!), ["normal", "repair"]); + assert.deepEqual(ids(project([repair, normal], {candidate_order_policy: null})!), ["repair", "normal"]); + assert.equal(project([row("plain")]), null); + assert.equal(project([]), null); +}); + +test("production-scale requirements use the same rule for every retained record without mutation", () => { + const fixture = productionScaleCoordinationFixture("goal-capability"); + const before = structuredClone(fixture); + const records = fixture.projection.todos as JsonObject[]; + const candidates = records.map((record, index) => row(String(record.todo_id), + index % 3 === 0 ? ["network"] : index % 3 === 1 ? ["credentials"] : ["shell"])); + const gate = project(candidates)!; + assert.equal(Number(gate.runnable_count) + (gate.blocked_candidates as JsonObject[]).length, records.length); + assert.deepEqual(fixture, before); +}); + +test("quota v1 recomputes missing capabilities, never trusts stale Python results", () => { + const candidate = {payload: {todo_id: "monitor"}, claim: null, bound: null, blocks: null, excluded: [], + global: false, gate: false, removed: false, actionable: true, due: true, task_class: "continuous_monitor", + priority: 1, index: 1, profile_rank: 1, missing: [], raw_claimed: false, required: ["network"], targets: []}; + const request = {items: [candidate], active_items: [], active_executable_items: [], agent_id: "agent-a", + user_gate_scope: false, monitor_supported: true, diagnostic_limit: 3, backlog_limit: 8, + visibility_limit: 16, profile: null, source_open_count: 1, available: []}; + assert.equal(((projectQuotaSelection(request).lanes as JsonObject).monitor_due_items as unknown[]).length, 0); + assert.equal(((projectQuotaSelection({...request, available: ["network"]}).lanes as JsonObject).monitor_due_items as unknown[]).length, 1); + assert.throws(() => projectTodoQuotaPlanning({schema_version: "todo_quota_planning_request_v1", selection: {}}), /available/); +}); + +test("runtime decoder rejects malformed facts rather than treating a requirement as absent", () => { + const request = {schema_version: "capability_gate_request_v0", operation: "missing", available: [], + items: [{required: ["network"], targets: []}]}; + assert.deepEqual(evaluateCapabilityGate(request).result, [["network"]]); + assert.throws(() => evaluateCapabilityGate({...request, items: [{required: "network", targets: []}]}), /required/); + assert.throws(() => evaluateCapabilityGate({...request, operation: "enable"}), /unknown/); + assert.throws(() => project([ {...row("a"), rank: [1]}]), /six/); +}); From 057d8c09ad333f905a555f03dfa46d2ef22479e8 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 01:34:43 +0800 Subject: [PATCH 2/2] docs(rfc): record capability consumer closure and retained boundaries Signed-off-by: huangruiteng --- ...shared-goal-authority-state-provider-v0.md | 8 ++++++ ...-goal-authority-state-provider-v0.zh-CN.md | 6 +++++ .../typescript-control-plane-migration-v0.md | 14 ++++++++++ ...script-control-plane-migration-v0.zh-CN.md | 9 +++++++ .../agent-management-projection-v0.md | 26 +++++++++++++++++++ 5 files changed, 63 insertions(+) diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index 41a7d52ab6..9ce7e05802 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -2664,6 +2664,14 @@ The original direction remains; execution cards expand these stages rather than #### Durability execution cards +Capability-gap consumers now share the TS requirement/resolution owner across +legacy and canonical inputs, including quota's Monitor capability partition. +The old Python missing-set and owner/repair decision builders are removed; +source adaptation and read-only candidate ordering remain. This is T3 read-policy +consolidation with disclosed resolution-priority/empty-source/identity corrections, +not capability enablement, a durable permission receipt or D1–D3 qualification. +The existing permanent Markdown projection and cutover holds remain unchanged. + The T3 decision-dependency read policy now shares one TS owner for scope coverage, exact links and consistency diagnostics. Explicit gate recipients are independent of claim attribution; conflicting exact targets request repair rather than grant diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index fdb8d31dbb..197fcb6a5b 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -2139,6 +2139,12 @@ T3 decision-dependency 读取策略现由同一 TS owner 解释 scope coverage **D1 — 资格化永久投影交付,可与 T1/T2 重叠推进。** +能力缺口 consumer 在 legacy/canonical 输入上共用 TS requirement/resolution owner, +包括 quota 的 Monitor 能力分流。删除 Python missing-set 与 owner/repair 决策 builder, +保留来源适配和只读候选排序。这是披露修复优先级、空来源和 identity 修正的 T3 读取 +规则收拢,不是能力启用、持久权限回执或 D1–D3 资格化。永久 Markdown 投影与 cutover +门禁不变。 + T2 的无 lease 原生 Monitor 观察与独立后继现由同一 canonical CAS/receipt 提交; route planner 本身仍不授予权限。CLI 将已提交回执交给既有 journal/outbox renderer, 展示失败标为 pending,不回滚提交、不重新生成后继;quota 继续消费同一 v0 业务回执 diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 239c0b3634..48d12a57bc 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -487,6 +487,20 @@ delivery. This does not finish all T2 commands or authorize whole-Goal promotion **T3 — close remaining structured consumers, then remove their old reads.** +Capability resolution now shares `agents/capability_gate.ts`: missing prerequisites, +repair outputs, owner/agent resolution and blocked-Todo bindings have one typed +owner. Quota planning v1 passes normalized requirements, not Python-computed +missing lists; Monitor partitioning invokes the same rule in-process. The public +gate uses one batch; exact-target recovery callers retain a bounded value-only +cached bridge to that rule, not a second implementation. Python keeps legacy +codecs, candidate source/eligibility and the shared profile/rank adapter. +Disclosed corrections: a shared resolution binding names the highest-priority +blocked Todo, display variants deduplicate by Todo identity, and an authoritative +empty backlog never revives stale first-item diagnostics. Target capabilities +remain repair outputs, not permission or installed capabilities. No new provider, +source inventory, enablement or promotion is introduced; compact candidate-source +limits and the remaining T3 consumers still require their own closure. + Quota's scope/claim consumer now composes selection, bounded visibility and the existing resume planner in one `todo.quota_planning.project` call per source. `quota_selection.ts` replaces the Python claim-visibility module and the separate 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 11bb8f5b18..a33d6740ef 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 @@ -419,6 +419,15 @@ summary 对候选关系批量调用,避免每对 Todo 一次 RPC;legacy comp 状态快照 parity。T3 仍需处理旧 action-token fallback 路由及从压缩 summary 重建诊断的 消费者,不把它们列为已迁移;不宣称 T1/T2、全部 T3 或持久化/promotion 完成。 +能力缺口与修复路由现由 `agents/capability_gate.ts` 统一解释执行前提、修复产出、 +owner/Agent 责任和受阻 Todo 绑定。Quota planning v1 传归一化的要求,而不是 Python +算好的 missing;Monitor 分流在 TS 进程内复用同一规则。公共 gate 一次批处理,精确目标 +恢复调用保留有界、只缓存归一化值的桥接,不保留第二套判断。Python 继续负责 legacy +codec、候选来源/资格和共享 profile/rank 适配。明确修正:共享缺口绑定最高优先级受阻 +Todo,同一 Todo 的不同展示不重复计算,权威空 backlog 不再复活陈旧 first-item。 +target capability 是修复产出,不是安装或授权。没有新 provider/inventory/enablement/ +promotion;压缩候选来源的上限和其余 T3 consumer 仍需分别闭合。 + 列表过滤现改用 `compact_evaluated_todo_group`,不再用仅活动项重算 resume。 初始解析/canonical 读取仍通过 TS owner 在完整来源上求值;过滤要求匹配的已求值 条件,不能把归档中的已完成依赖变成丢失。共享合成 fixture 增补“有 scope 无 outcome” diff --git a/docs/reference/protocols/agent-management-projection-v0.md b/docs/reference/protocols/agent-management-projection-v0.md index 8eddbfe5e1..fcf3fcebcc 100644 --- a/docs/reference/protocols/agent-management-projection-v0.md +++ b/docs/reference/protocols/agent-management-projection-v0.md @@ -157,6 +157,32 @@ The row may be rendered as a "task" card for operator familiarity, but API and state names should keep `todo` terminology to avoid implying a second runtime model. +### Capability prerequisites and repair outputs + +The read-only capability gate distinguishes `required_capabilities` (execution +prerequisites) from `target_capabilities` (what a repair task aims to provide). +A target is not its own prerequisite, but declaring it neither enables it nor +grants credentials, production access, write scope, a claim or a lease. A task +repairing `network` can therefore be runnable while another task requiring +`network` stays blocked until availability is observed. + +`agents/capability_gate.ts` owns missing-set evaluation, resolution responsibility +and impacted-Todo bindings. Unknown missing capabilities retain Agent repair +routing; `credentials` and `production_access` retain owner routing. Other runnable +work remains available when one candidate is blocked. A shared capability binding +uses the highest-priority affected Todo rather than whichever display row appears +first, while preserving all affected Todo IDs. Display variants of one ID do not +become separate tasks. An explicitly empty evaluated backlog is authoritative; +the older first-item source is used only when the backlog field is absent. + +Quota's v1 planning request carries normalized requirement facts, so the same TS +rule partitions due Monitors without per-Todo RPCs or trusting stale precomputed +missing lists. Candidate source/eligibility and profile ranking remain adapters; +this does not claim complete-inventory selection or change Monitor due/writeback +and deferred-replan rules. Use `quota should-run --include-detail agent-todos` to +inspect the existing full capability-gate detail; the default CLI keeps its +compact representation. No configuration, UI control or storage migration is added. + ## Handoff Notes Inter-agent handoff should appear as a typed note attached to existing todo,