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 877b15a7e9..ba09e6543f 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -2680,6 +2680,14 @@ It does not qualify D1/D2, alter the default provider, or relax D3 promotion hol Markdown remains the permanent one-way display; the remaining execution cards below are unchanged. +Scoped fallback now consumes the same typed decision owner for selection and +gate relations, retiring the Python token-overlap matcher and selection loop. +Exact dependency authority and explicit global gates remain; equal legacy action +keys retain blocking compatibility, while different/missing keys cannot prove independence. This +is a T3 consumer closure with disclosed semantics, not a new provider or a D1–D3 +qualification. Source adaptation, permanent projection and all promotion holds +remain unchanged; see the TS card and decision-scope contract for the exact rules. + Use the [TS execution cards](typescript-control-plane-migration-v0.md#execution-cards-after-the-current-stack) for command inventory, update/monitor transactions and consumer deletion. Do not repeat that plan in a second implementation or treat a merged read-policy PR 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 b918713a4c..a05b7d9dc2 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 @@ -2142,6 +2142,12 @@ T3 decision-dependency 读取策略现由同一 TS owner 解释 scope coverage 不改变默认 provider 或放宽 D3 promotion hold。Markdown 继续作为永久单向展示, 后续执行卡与退役条件保留。 +Scoped fallback 的选择与门禁关系也已复用同一 TS decision owner,删除 Python +词语重合匹配和选择循环。显式依赖及 global gate 保留,旧完整 action key 相同仅 +保留阻塞兼容;键不同或缺少事实不能证明独立性。这是披露语义变化的 T3 consumer 闭合, +不是新 provider,也不代表 D1–D3 已资格化。来源适配、永久投影和 promotion hold +不变;具体规则见 TS 执行卡及 decision-scope 协议。 + **D1 — 资格化永久投影交付,可与 T1/T2 重叠推进。** 能力缺口 consumer 在 legacy/canonical 输入上共用 TS requirement/resolution owner, diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 6fb4f9665c..8c6f53b36a 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -576,8 +576,16 @@ Agent fallback, global Todo and summary consumers batch their candidate relation to avoid one RPC per pair. Legacy completion still uses the shared coverage rule. Validation covers the production-scale fixture, complete provider reads beyond display limits, stale/missing display, and isolated real-state snapshot parity. -Remaining T3 work includes legacy action-token fallback routing and consumers -that reconstruct diagnostics from compact summaries; do not call those migrated. +Scoped fallback selection now shares that TS owner for eligibility, priority, +deduplication and gate relations. The Python action-token gate matcher and +selection loop are retired. Explicit dependencies/global gates take precedence; +equal legacy action labels retain blocking compatibility, not word-overlap +authority. Different or missing labels cannot certify safe independence. This +deliberately removes inferred overlap dependencies and unjustified safe bypass; +see the [fallback contract](../../reference/protocols/decision-scope-v0.md#scoped-fallback-selection). +Python retains lane source adaptation and compact presentation; no new provider +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. The list-filter consumer now uses `compact_evaluated_todo_group` instead of 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 a38c3b3d87..6aefd2a769 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 @@ -431,7 +431,13 @@ scope coverage 和 open-gate routing。 Python 保留 legacy 解码和修复展示,删除第二套规则。Agent fallback、global Todo、 summary 对候选关系批量调用,避免每对 Todo 一次 RPC;legacy completion 也复用覆盖规则。 验证覆盖复杂容量 fixture、展示上限之外的完整 provider 来源、陈旧/缺失展示和隔离真实 -状态快照 parity。T3 仍需处理旧 action-token fallback 路由及从压缩 summary 重建诊断的 +状态快照 parity。Scoped fallback 的资格、优先级、去重和门禁关系现已收拢到同一 TS +owner,删除 Python action-token 门禁匹配和选择循环。显式依赖及 global gate 优先; +旧 action_kind 相同仅保留阻塞兼容,不再以词语重合推断依赖。键不同或缺少依赖事实时, +不能证明候选是安全 fallback。这有意移除词语推断和无证据的安全绕行,详见 +[fallback 协议](../../reference/protocols/decision-scope-v0.md#scoped-fallback-selection)。 +Python 保留 lane 来源适配和展示压缩,不增加 provider 读取或 resume 重算。 +T3 仍需处理从压缩 summary 重建诊断的 消费者,不把它们列为已迁移;不宣称 T1/T2、全部 T3 或持久化/promotion 完成。 能力缺口与修复路由现由 `agents/capability_gate.ts` 统一解释执行前提、修复产出、 diff --git a/docs/reference/protocols/decision-scope-v0.md b/docs/reference/protocols/decision-scope-v0.md index 00008e147d..2f90258e25 100644 --- a/docs/reference/protocols/decision-scope-v0.md +++ b/docs/reference/protocols/decision-scope-v0.md @@ -214,8 +214,8 @@ unscoped multi-agent decisions never grant standing authority. later authoring field. 3. **Projection:** surface the fields in status, quota, review packets, and frontstage local ops mode. -4. **Hot path:** make status/quota prefer structured scope relation over text - inference. +4. **Hot path:** status/quota fallback uses structured scope relations, then + exact legacy action labels; title/body word overlap is not dependency authority. 5. **Lint fallback:** keep regex and optional LLM proposals as projection-gap repair helpers, not runtime authority. @@ -241,6 +241,47 @@ Legacy metadata codecs and operator repair copy remain in Python. Candidate-pair consumers use batched relations. No provider commit, lease, source promotion or Markdown writeback authority is added by this read-only contract. +### Scoped fallback selection + +The same typed owner now selects fallback candidates, rather than letting Python +reinterpret a relation matrix with token-overlap heuristics. It applies these +rules in order: + +1. Explicit `global_gate=true` still blocks fallback for every addressed lane. +2. Exact Todo links and decision scopes retain their existing precedence, + including contradictory-target diagnostics. Explicit independence is not + overridden by matching action labels. +3. Only when neither relation exists, two nonempty legacy `action_kind` values + are compared as complete trimmed, case-normalized keys. Equal keys retain + blocking compatibility; distinct keys do **not** prove independence (for + example, `approve_release` versus `release`). This is not a new permission + scope or an approval receipt. Use explicit dependencies when authoring gates. +4. Distinct or missing keys do not prove independence: that candidate cannot be advertised + as safe fallback. This does not rewrite the gate or promote it to global scope. + +Intentional correction: word overlap no longer claims a known dependency, +and disjoint words no longer claim safe independence. English and Chinese task +prose cannot alter the relation. Without structural scope, both remain unproven +for safe fallback; adding an explicit exact link or decision scope makes the +intended boundary readable without teaching the Agent a naming convention. +This may withhold previously offered legacy fallback until its scope is +clarified. It never removes, retargets or automatically broadens a User gate. + +Selection rejects completed, archived, blocked, removed-continuation and +actor-ineligible candidate rows. A ready deferred row retains its separate replan +meaning, not permission to execute deferred work. Priority and persisted index +retain precedence; Monitor debt only prefers advancement at equal priority, and +source position breaks remaining ties. Deduplication retains the first source +identity. An authoritative empty capability result is never refilled from backlog; +due Monitor and evaluated deferred lanes keep their separate existing contracts. +All supplied candidates are considered before the three-item diagnostic limit. +The adapter returns existing compact display rows, without another provider read, +wait evaluation, Todo mutation, permission grant or notification-policy change. + +The inputs are evaluated quota lanes, not a claim that every compact-summary +consumer now reads the complete inventory. Missing/stale display is handled by +the existing canonical source adapter; permanent Markdown remains a projection. + - Missing structured fields on legacy state: fall back to compatibility lint and emit a projection-gap repair hint. - Conflicting structured fields: fail closed with a concrete blocker. diff --git a/loopx/control_plane/agents/agent_scope.py b/loopx/control_plane/agents/agent_scope.py index 3f7d88aef9..dd3a48f265 100644 --- a/loopx/control_plane/agents/agent_scope.py +++ b/loopx/control_plane/agents/agent_scope.py @@ -11,7 +11,7 @@ agent_scope_frontier_action as _agent_scope_frontier_action, build_agent_scope_frontier_payload, ) -from ..todos.decision_scope import todo_gate_relations, todo_gate_relation_blocks_agent +from ..todos.decision_scope import select_scoped_gate_fallback from ..work_items.work_lane import ( work_lane_contract_is_due_monitor_attempt, work_lane_contract_requires_current_agent_attempt, @@ -23,7 +23,6 @@ normalize_todo_bound_agent, normalize_todo_claimed_by, normalize_todo_excluded_agents, - normalize_todo_global_gate, normalize_todo_id, ) from ..todos.handoff_gate import HandoffGateState @@ -108,12 +107,6 @@ def _todo_projection_sort_key(item: dict[str, Any]) -> tuple[int, int]: return todo_projection_sort_key(item) -def _monitor_debt_projection_sort_key(item: dict[str, Any]) -> tuple[Any, ...]: - priority, *remainder = _todo_projection_sort_key(item) - advancement_first = 0 if _todo_task_class(item) == TODO_TASK_CLASS_ADVANCEMENT else 1 - return (priority, advancement_first, *remainder) - - def _todo_item_is_actionable_open(item: dict[str, Any]) -> bool: return todo_item_is_actionable_open(item) @@ -212,42 +205,6 @@ def _action_scope_tokens_from_text(text: str) -> set[str]: } -def _todo_action_kind_tokens(item: dict[str, Any]) -> set[str]: - return _action_scope_tokens_from_text(str(item.get("action_kind") or "")) - - -def _todo_action_scope_tokens(item: dict[str, Any]) -> set[str]: - text = " ".join( - str(value or "") - for value in (item.get("action_kind"), item.get("title"), item.get("text")) - if str(value or "").strip() - ) - return _action_scope_tokens_from_text(text) - - -def _user_gate_blocks_agent_item(gate: dict[str, Any], agent_item: dict[str, Any], - relation: dict[str, Any] | None) -> bool: - if normalize_todo_global_gate(gate.get("global_gate")): - return True - if relation: - return todo_gate_relation_blocks_agent(relation) - - gate_action_tokens = _todo_action_kind_tokens(gate) - agent_action_tokens = _todo_action_kind_tokens(agent_item) - if gate_action_tokens and agent_action_tokens: - return bool(gate_action_tokens & agent_action_tokens) - if agent_action_tokens: - return False - - gate_tokens = _todo_action_scope_tokens(gate) - agent_tokens = _todo_action_scope_tokens(agent_item) - if not gate_tokens or not agent_tokens: - return False - if gate_action_tokens: - return len(gate_action_tokens & agent_tokens) >= 2 - return len(gate_tokens & agent_tokens) >= 3 - - def _todo_item_claimed_by_agent_or_unclaimed(item: dict[str, Any], *, agent_id: str) -> bool: return agent_scope_item_claimed_by_agent_or_unclaimed(item, agent_id=agent_id) @@ -300,71 +257,31 @@ def _scoped_user_gate_fallback( *ready_deferred_candidates, ] executable_items = [item for item in executable_items if isinstance(item, dict)] - deduped_executable_items: list[dict[str, Any]] = [] - seen_todo_ids: set[str] = set() - for item in executable_items: - todo_id = normalize_todo_id(item.get("todo_id")) - if todo_id and todo_id in seen_todo_ids: - continue - if todo_id: - seen_todo_ids.add(todo_id) - deduped_executable_items.append(item) - executable_items = sorted( - deduped_executable_items, - key=( - _monitor_debt_projection_sort_key - if monitor_debt_backoff_active - else _todo_projection_sort_key - ), - ) claim_scope = ( agent_todo_summary.get("claim_scope") if isinstance(agent_todo_summary.get("claim_scope"), dict) else None ) - if claim_scope: - agent_id = normalize_todo_claimed_by(claim_scope.get("agent_id")) - executable_items = [ - item - for item in executable_items - if agent_scope_item_claimed_by_agent_or_unclaimed(item, agent_id=agent_id) - ] - blocked_items: list[dict[str, Any]] = [] - relations = todo_gate_relations(gates, executable_items) - selected: dict[str, Any] | None = None - blocking_gate: dict[str, Any] | None = None - for item_index, item in enumerate(executable_items): - matching_gate = next( - (gate for gate_index, gate in enumerate(gates) - if _user_gate_blocks_agent_item(gate, item, relations[gate_index][item_index])), - None, - ) - if matching_gate: - blocking_gate = blocking_gate or matching_gate - text = str(item.get("text") or "").strip() - blocked_item = compact_todo_summary_item(item, text=text) - relation = relations[gates.index(matching_gate)][item_index] - if relation: - blocked_item["todo_gate_relation"] = relation - blocked_items.append(blocked_item) - continue - if selected is None: - selected = item - - if selected is None: - return None - if not blocking_gate and not allow_unrelated_gate: + selection = select_scoped_gate_fallback( + gates, executable_items, agent_id=claim_scope.get("agent_id") if claim_scope else None, + allow_unrelated_gate=allow_unrelated_gate, monitor_debt_backoff_active=monitor_debt_backoff_active, + ) + if selection is None: return None - + selected = executable_items[selection["selected_index"]] + gate_to_surface = gates[selection["gate_index"]] + blocking_gate = selection["has_blocking_gate"] + blocked_items = [] + for blocked in selection["blocked"][:3]: + item = executable_items[blocked["candidate_index"]] + blocked_items.append({**compact_todo_summary_item(item, text=str(item.get("text") or "").strip()), + "todo_gate_relation": blocked["relation"]}) selected_text = str(selected.get("text") or "").strip() - gate_to_surface = blocking_gate or gates[0] selected_item = compact_todo_summary_item(selected, text=selected_text) - selected_is_deferred_replan = ( - todo_item_is_deferred(selected) and selected.get("resume_ready") is True - ) + selected_is_deferred_replan = selection["deferred_replan"] if selected_is_deferred_replan: selected_item["fallback_kind"] = "deferred_successor_replan" - selected_relation = relations[gates.index(gate_to_surface)][executable_items.index(selected)] + selected_relation = selection["selected_relation"] if selected_relation: selected_item["todo_gate_relation"] = selected_relation gate_text = str(gate_to_surface.get("text") or "").strip() diff --git a/loopx/control_plane/testing/control_plane_composition_scenarios.py b/loopx/control_plane/testing/control_plane_composition_scenarios.py index 07cac79281..149763b877 100644 --- a/loopx/control_plane/testing/control_plane_composition_scenarios.py +++ b/loopx/control_plane/testing/control_plane_composition_scenarios.py @@ -99,6 +99,7 @@ def _scoped_gate_successor_replan_source( status="open", task_class="user_gate", action_kind="approve_product_first_screen", + decision_scope={"kind": "write_scope", "granularity": "action", "scope_key": "first-screen"}, blocks_agent=agent_id, text="[P2] Review the unrelated product first screen.", ) diff --git a/loopx/control_plane/testing/scoped_gate_successor_tool_behavior.py b/loopx/control_plane/testing/scoped_gate_successor_tool_behavior.py index b68b667c27..cddac6ba00 100644 --- a/loopx/control_plane/testing/scoped_gate_successor_tool_behavior.py +++ b/loopx/control_plane/testing/scoped_gate_successor_tool_behavior.py @@ -119,6 +119,7 @@ def _build_scoped_gate_fixture(root: Path) -> _SelectedTodoToolFixture: " \n\n" "## Agent Todo\n\n" f"- [-] [P1] {SCOPED_GATE_SUCCESSOR_ACTION_TEXT}\n" diff --git a/loopx/control_plane/todos/decision_scope.py b/loopx/control_plane/todos/decision_scope.py index 62d5beee24..675b3c752f 100644 --- a/loopx/control_plane/todos/decision_scope.py +++ b/loopx/control_plane/todos/decision_scope.py @@ -14,6 +14,7 @@ normalize_todo_required_decision_scopes, ) from .user_gate import is_user_gate_todo_item +from .projection import todo_projection_sort_key, todo_item_task_class, todo_item_has_removed_continuation_policy TODO_GATE_BLOCKING_STATES = frozenset( {"gate_targets_todo", "gate_covers_action", "projection_repair_required"} @@ -347,3 +348,30 @@ def todo_gate_relations(gates: list[dict[str, Any]], items: list[dict[str, Any]] def todo_gate_relation_blocks_agent(relation: dict[str, Any] | None) -> bool: return bool(relation and relation.get("state") in TODO_GATE_BLOCKING_STATES) + + +def select_scoped_gate_fallback(gates: list[dict[str, Any]], items: list[dict[str, Any]], *, + agent_id: str | None, allow_unrelated_gate: bool, + monitor_debt_backoff_active: bool) -> dict[str, Any] | None: + """Decode legacy facts; the typed owner returns positions, never display rows.""" + if not gates or not items: + return None + from .contract import normalize_todo_bound_agent, normalize_todo_excluded_agents + + def facts(item: dict[str, Any]) -> dict[str, Any]: + priority, index = todo_projection_sort_key(item) + return {**_facts(item), "action_kind": item.get("action_kind"), + "archive_state": item.get("archive_state"), "resume_ready": item.get("resume_ready") is True, + "bound_agent": normalize_todo_bound_agent(item.get("bound_agent")), + "excluded_agents": normalize_todo_excluded_agents(item.get("excluded_agents")), + "removed": todo_item_has_removed_continuation_policy(item), + "task_class": todo_item_task_class(item), "priority_rank": priority, "persisted_index": index} + + return _projection( + "fallback", + _evaluate("fallback", gates=[facts(gate) for gate in gates], candidates=[facts(item) for item in items], + agent_id=normalize_todo_claimed_by(agent_id), allow_unrelated_gate=allow_unrelated_gate, + monitor_debt_backoff_active=monitor_debt_backoff_active), + schema_versions=frozenset({"scoped_gate_fallback_selection_v0"}), + nullable=True, + ) diff --git a/loopx/control_plane/todos/decision_scope.ts b/loopx/control_plane/todos/decision_scope.ts index 8504a994c8..697766947d 100644 --- a/loopx/control_plane/todos/decision_scope.ts +++ b/loopx/control_plane/todos/decision_scope.ts @@ -1,7 +1,7 @@ /** Read-only decision dependency rules over one complete source snapshot. * A consistent dependency is not approval, a lease, or a mutation receipt. */ import type {JsonObject} from "../effect_program.ts"; -import {requireJsonObject, optionalNonEmptyString} from "../runtime_decode.ts"; +import {requireJsonObject, optionalNonEmptyString, requireBoolean, requireInteger} from "../runtime_decode.ts"; import {gateAddressesAgent} from "./gate_scope.ts"; export const DECISION_SCOPE_REQUEST_SCHEMA = "todo_decision_scope_request_v0"; @@ -70,6 +70,69 @@ export function todoGateRelation(gate: JsonObject, item: JsonObject): JsonObject return exact ? {...exact, ...(scope ? {decision_scope_relation: scope} : {})} : scope; } +/** Legacy action labels are exact keys, not natural-language permission rules. + * Missing scope is insufficient evidence for safe bypass, never a global gate. */ +export function fallbackGateRelation(gate: JsonObject, item: JsonObject): JsonObject { + if (gate.global_gate === true) return {source: "global_gate", state: "gate_covers_action"}; + const explicit = todoGateRelation(gate, item); + if (explicit) return explicit; + const action = (row: JsonObject) => typeof row.action_kind === "string" ? row.action_kind.trim().toLowerCase() : ""; + const a = action(gate), b = action(item); + return {schema_version: "todo_gate_relation_v0", gate_todo_id: gate.todo_id ?? null, + agent_todo_id: item.todo_id ?? null, + source: a && a === b ? "legacy_exact_action_kind" : "missing_dependency_scope", + state: a && a === b ? "gate_covers_action" : "projection_repair_required", + ...(!a || a !== b ? {reason: "safe_fallback_requires_explicit_dependency_scope"} : {})}; +} + +/** Select only from already evaluated lanes. Never re-evaluate waits against a + * compact list or refill an authoritative empty capability result from backlog. + * Return source positions, leaving display compaction and wording to adapters. */ +export function selectScopedGateFallback(request: JsonObject): JsonObject | null { + const agent = optionalNonEmptyString(request.agent_id, "agent_id"); + const debt = requireBoolean(request.monitor_debt_backoff_active, "monitor_debt_backoff_active"); + const allowUnrelated = requireBoolean(request.allow_unrelated_gate, "allow_unrelated_gate"); + const gates = rows(request.gates).map((gate, index) => ({gate, index})).filter(({gate}) => + gate.done !== true && ["open", "blocked"].includes(String(gate.status || "open")) && + gate.archive_state !== "archive" && (!agent || addressed(gate, agent))); + if (!gates.length) return null; + const source = rows(request.candidates).map((item, index) => ({item, index, + priority: requireInteger(item.priority_rank, "priority_rank"), + persisted: requireInteger(item.persisted_index, "persisted_index")})); + const seen = new Set(); + const candidates = source.filter(({item}) => { + const id = text(item.todo_id); + if (id && seen.has(id)) return false; + if (id) seen.add(id); + const deferred = item.status === "deferred" && item.resume_ready === true; + return item.archive_state !== "archive" && ((item.status === "open" && item.done !== true) || deferred) && + item.removed !== true && (!agent || ((!item.claimed_by || item.claimed_by === agent) && + (!item.bound_agent || item.bound_agent === agent) && + !(Array.isArray(item.excluded_agents) && item.excluded_agents.includes(agent)))); + }); + candidates.sort((a, b) => a.priority - b.priority || + (debt ? Number(a.item.task_class !== "advancement_task") - Number(b.item.task_class !== "advancement_task") : 0) || + a.persisted - b.persisted || a.index - b.index); + const blocked: JsonObject[] = []; + let selected: typeof candidates[number] | undefined; + let blockingGate: typeof gates[number] | undefined; + for (const candidate of candidates) { + const match = gates.map(g => ({...g, relation: fallbackGateRelation(g.gate, candidate.item)})) + .find(g => g.relation.state !== "independent"); + if (match) { + blockingGate ??= match; + blocked.push({candidate_index: candidate.index, gate_index: match.index, relation: match.relation}); + } else selected ??= candidate; + } + if (!selected || (!blockingGate && !allowUnrelated)) return null; + const surface = blockingGate ?? gates[0]!; + return {schema_version: "scoped_gate_fallback_selection_v0", + selected_index: selected.index, gate_index: surface.index, blocked: blocked.slice(0, 3), blocked_count: blocked.length, + has_blocking_gate: blockingGate !== undefined, + selected_relation: fallbackGateRelation(surface.gate, selected.item), + deferred_replan: selected.item.status === "deferred" && selected.item.resume_ready === true}; +} + function open(items: JsonObject[]): JsonObject[] { return items.filter(item => item.done !== true && ["open", "blocked"].includes(String(item.status || "open"))); } @@ -143,6 +206,7 @@ export function evaluateDecisionScope(value: unknown): JsonObject { if (request.schema_version !== DECISION_SCOPE_REQUEST_SCHEMA) throw new TypeError("decision scope request schema mismatch"); let result: JsonObject | boolean | null | (JsonObject | null)[][]; switch (request.operation) { + case "fallback": result = selectScopedGateFallback(request); break; case "consistency": result = decisionScopeConsistency(request); break; case "standing": result = scopeStandingAuthority(request.authority, optionalNonEmptyString(request.agent_id, "agent_id")); break; case "covers": result = decisionScopeCovers(request.gate_scope, request.required_scope); break; diff --git a/tests/control_plane/test_scoped_gate_fallback_cli.py b/tests/control_plane/test_scoped_gate_fallback_cli.py new file mode 100644 index 0000000000..f7fb26edd8 --- /dev/null +++ b/tests/control_plane/test_scoped_gate_fallback_cli.py @@ -0,0 +1,55 @@ +"""Real source -> quota CLI -> fallback, with permanent display non-authoritative.""" +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("explicit_target", [False, True, None]) +def test_quota_fallback_uses_real_source_and_explicit_relations(tmp_path, monkeypatch, provider, explicit_target): + 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" + "- [ ] [P1] Inspect the report\n" + " \n" + "- [ ] [P0] Publish the report\n" + " \n" + "\n## User Todo / Owner Review Reading Queue\n\n" + "- [ ] Review publication\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_before = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a") + code, packet = run_json_cli_result("quota", "should-run", "--goal-id", "goal-a", "--agent-id", "agent-a", + "--scan-path", str(tmp_path), registry_path=registry, runtime_root=runtime) + assert code == 0, packet + fallback = packet.get("scoped_user_gate_fallback") + # The explicit target wins even when the two action labels disagree. + if explicit_target is None: + # Different labels cannot prove independence from an unscoped approval. + assert fallback is None + assert packet["should_run"] is False + else: + expected = "todo_publish" if explicit_target else "todo_inspect" + assert fallback["selected_executable"]["todo_id"] == expected + assert fallback["blocked_agent_items"][0]["todo_id"] != expected + assert packet["requires_user_action"] is True + assert (state.read_bytes() if state.exists() else None) == before + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a") == authority_before diff --git a/tests/control_plane/test_scoped_gate_fallback_selection.py b/tests/control_plane/test_scoped_gate_fallback_selection.py new file mode 100644 index 0000000000..ea88ae822f --- /dev/null +++ b/tests/control_plane/test_scoped_gate_fallback_selection.py @@ -0,0 +1,51 @@ +"""Public quota fallback invariants, independent of wording or display order.""" +from loopx.control_plane.agents.agent_scope import _scoped_user_gate_fallback + + +def select(gate=None, items=None, **options): + gate = {"todo_id": "todo_gate", "task_class": "user_gate", "status": "open", + "blocks_agent": "agent-a", "action_kind": "publish_report", "unblocks_todo_id": "todo_publish", **(gate or {})} + items = items if items is not None else [ + {"todo_id": "todo_work", "task_class": "advancement_task", "status": "open", + "action_kind": "inspect_report", "text": "Inspect report", "index": 1}] + return _scoped_user_gate_fallback( + {"gate_open_items": [gate]}, + {"executable_backlog_items": items, "claim_scope": {"agent_id": "agent-a"}}, + allow_unrelated_gate=True, **options, + ) + + +def test_distinct_action_labels_cannot_certify_safe_independence(): + assert select({"unblocks_todo_id": None}, items=[{"todo_id": "todo_work", "status": "open", + "action_kind": "compile_library"}]) is None + + +def test_missing_structural_scope_cannot_certify_safe_independence(): + assert select({"action_kind": None, "text": "Review something", "unblocks_todo_id": None}) is None + + +def test_exact_dependency_and_global_gate_win_over_distinct_action_labels(): + assert select({"unblocks_todo_id": "todo_work"}) is None + assert select({"global_gate": True}) is None + assert select({"action_kind": "inspect_report", "unblocks_todo_id": None}) is None + + +def test_explicit_independence_wins_over_identical_words(): + result = select({"unblocks_todo_id": "todo_other", "action_kind": "inspect_report"}) + assert result is not None + + +def test_excluded_and_bound_to_another_agent_are_not_fallback_candidates(): + for restriction in ({"excluded_agents": ["agent-a"]}, {"bound_agent": "agent-b"}, + {"claimed_by": "agent-b"}): + assert select(items=[{"todo_id": "todo_work", "status": "open", + "action_kind": "compile_library", **restriction}]) is None + + +def test_empty_capability_result_does_not_revive_backlog(): + assert select(capability_gate={"runnable_candidates": []}) is None + + +def test_finished_rows_in_stale_candidate_lanes_are_not_executable(): + for state in ({"status": "done"}, {"archive_state": "archive"}, {"status": "blocked"}): + assert select(items=[{"todo_id": "todo_work", "action_kind": "compile_library", **state}]) is None diff --git a/tests/control_plane/test_todo_decision_scope_runtime_boundary.py b/tests/control_plane/test_todo_decision_scope_runtime_boundary.py index a5b687e4a2..f3af89d842 100644 --- a/tests/control_plane/test_todo_decision_scope_runtime_boundary.py +++ b/tests/control_plane/test_todo_decision_scope_runtime_boundary.py @@ -138,3 +138,17 @@ def test_nullable_operations_still_accept_explicit_null( assert decision_scope.decision_scope_gate_relation(GATE, AGENT_ITEM) is None assert decision_scope.exact_todo_gate_relation(GATE, AGENT_ITEM) is None assert decision_scope.todo_gate_relation(GATE, AGENT_ITEM) is None + assert decision_scope.select_scoped_gate_fallback( + [GATE], [AGENT_ITEM], agent_id="agent-a", allow_unrelated_gate=True, + monitor_debt_backoff_active=False, + ) is None + + +@pytest.mark.parametrize("value", [True, [], {}, {"schema_version": "todo_gate_relation_v0"}]) +def test_fallback_runtime_result_fails_closed(monkeypatch, value): + monkeypatch.setattr(decision_scope, "effect_runtime_result", lambda *_args, **_kwargs: _response(value)) + with pytest.raises(TypeError, match="invalid typed decision scope fallback"): + decision_scope.select_scoped_gate_fallback( + [GATE], [AGENT_ITEM], agent_id="agent-a", allow_unrelated_gate=True, + monitor_debt_backoff_active=False, + ) diff --git a/tests/control_plane/test_user_gate_lane_progress.py b/tests/control_plane/test_user_gate_lane_progress.py index edb34a7e5d..d6bb8af290 100644 --- a/tests/control_plane/test_user_gate_lane_progress.py +++ b/tests/control_plane/test_user_gate_lane_progress.py @@ -32,7 +32,7 @@ ) -def _status_payload(*, gate_action_kind: str) -> dict: +def _status_payload(*, gate_action_kind: str, blocks_deferred: bool = False) -> dict: completed = quota_todo_item( todo_id="todo_prerequisite", status="done", @@ -61,6 +61,7 @@ def _status_payload(*, gate_action_kind: str) -> dict: text="[P2-user] Review the product first screen.", action_kind=gate_action_kind, blocks_agent=AGENT_ID, + unblocks_todo_id="todo_ready_deferred" if blocks_deferred else "todo_first_screen", ) return quota_status_payload( goal_id=GOAL_ID, @@ -203,7 +204,7 @@ def decide(gate_status: str) -> dict: def test_blocking_user_gate_backs_off_instead_of_polling_as_active_work() -> None: payload = build_quota_should_run( - _status_payload(gate_action_kind="refine_benchmark_treatment"), + _status_payload(gate_action_kind="refine_benchmark_treatment", blocks_deferred=True), goal_id=GOAL_ID, agent_id=AGENT_ID, scheduler_execution_context=APP_CONTEXT, @@ -403,7 +404,7 @@ def test_acked_human_gate_advances_despite_unrelated_historical_host_failure( now = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc) monkeypatch.setattr(scheduler_hint_module, "now_utc", lambda: now) payload = build_quota_should_run( - _status_payload(gate_action_kind="refine_benchmark_treatment"), + _status_payload(gate_action_kind="refine_benchmark_treatment", blocks_deferred=True), goal_id=GOAL_ID, agent_id=AGENT_ID, scheduler_execution_context=APP_CONTEXT, diff --git a/tests/control_plane_ts/scoped_gate_fallback.test.ts b/tests/control_plane_ts/scoped_gate_fallback.test.ts new file mode 100644 index 0000000000..769509ca06 --- /dev/null +++ b/tests/control_plane_ts/scoped_gate_fallback.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {fallbackGateRelation, selectScopedGateFallback} from "../../loopx/control_plane/todos/decision_scope.ts"; +import {productionScaleCoordinationFixture} from "./production_scale_coordination_fixture.ts"; + +const gate = (extra: JsonObject = {}): JsonObject => ({todo_id: "todo_gate", status: "open", done: false, + blocks_agent: "agent-a", action_kind: "publish_report", ...extra}); +const work = (extra: JsonObject = {}): JsonObject => ({todo_id: "todo_work", status: "open", done: false, + task_class: "advancement_task", action_kind: "inspect_report", priority_rank: 1, persisted_index: 1, ...extra}); +const select = (extra: JsonObject = {}) => selectScopedGateFallback({gates: [gate({unblocks_todo_id: "todo_publish"})], candidates: [work()], + agent_id: "agent-a", monitor_debt_backoff_active: false, allow_unrelated_gate: true, ...extra}); + +test("legacy action comparison is exact and independent of language and prose", () => { + for (const text of ["publish report inspect approve", "请检查报告,不要发布", ""]) { + assert.equal(fallbackGateRelation(gate({text}), work({text})).state, "projection_repair_required"); + assert.equal(fallbackGateRelation(gate({action_kind: "检查报告"}), work({action_kind: "检查报告", text})).state, "gate_covers_action"); + assert.equal(fallbackGateRelation(gate({action_kind: " INSPECT_REPORT "}), work({text})).state, "gate_covers_action"); + } + assert.equal(fallbackGateRelation(gate({action_kind: null}), work()).source, "missing_dependency_scope"); + assert.equal(select({gates: [gate({action_kind: null})]}), null); + assert.equal(select({gates: [gate({action_kind: "approve_release"})], + candidates: [work({action_kind: "release"})]}), null); +}); + +test("typed exact/scope/global relations precede compatibility labels", () => { + const scope = {kind: "write_scope", granularity: "action", scope_key: "release"}; + const dependent = work({required_decision_scopes: [scope]}); + assert.equal(select({gates: [gate({global_gate: true})]}), null); + assert.equal(select({gates: [gate({unblocks_todo_id: "todo_work"})]}), null); + assert.equal(select({gates: [gate({decision_scope: scope})], candidates: [dependent]}), null); + const contradiction = gate({decision_scope: scope, unblocks_todo_id: "todo_other"}); + assert.equal(fallbackGateRelation(contradiction, dependent).state, "projection_repair_required"); + assert.equal(select({gates: [contradiction], candidates: [dependent]}), null); + assert.ok(select({gates: [gate({unblocks_todo_id: "todo_other", action_kind: "inspect_report"})]})); +}); + +test("eligibility independently enforces retained lifecycle and actor facts", () => { + for (const restriction of [{status: "done"}, {status: "blocked"}, {done: true}, {archive_state: "archive"}, + {claimed_by: "agent-b"}, {bound_agent: "agent-b"}, {excluded_agents: ["agent-a"]}, {removed: true}, + {status: "deferred", resume_ready: false}]) { + assert.equal(select({candidates: [work(restriction)]}), null, JSON.stringify(restriction)); + } + assert.equal(select({candidates: [work({status: "deferred", done: true, resume_ready: true})]})?.deferred_replan, true); + assert.equal(select({gates: [gate({blocks_agent: "agent-b"})]}), null); + assert.equal(select({gates: [gate({status: "done"})]}), null); + // An unrelated claim cannot cancel an explicit gate addressed to this lane. + assert.equal(select({gates: [gate({claimed_by: "agent-b", global_gate: true})]}), null); + assert.ok(select({agent_id: null})); +}); + +test("production-scale mixed lifecycle facts are unchanged by fallback selection", () => { + const fixture = productionScaleCoordinationFixture("goal-a"); + const records = fixture.projection.todos as JsonObject[]; + const candidates = records.filter(row => row.role === "agent").map((row, index) => work({ + ...row, action_kind: "publish_report", required_decision_scopes: [{kind: "direction", granularity: "action", scope_key: "delivery"}], priority_rank: 1, persisted_index: index, + })); + candidates.push(work({todo_id: "todo_independent_tail", persisted_index: candidates.length + 1})); + const before = JSON.stringify({fixture, candidates}); + const result = select({candidates, allow_unrelated_gate: false, + gates: [gate({decision_scope: {kind: "direction", granularity: "action", scope_key: "delivery"}})]}); + assert.equal(result?.selected_index, candidates.length - 1); + assert.ok(Number(result?.blocked_count) > 3); + assert.equal((result?.blocked as JsonObject[]).length, 3); + assert.equal(JSON.stringify({fixture, candidates}), before); +}); + +test("selection is stable, debt only breaks same-priority ties, and source identities survive deduplication", () => { + const candidates = [work({todo_id: "todo_monitor", task_class: "continuous_monitor", persisted_index: 1}), + work({todo_id: "todo_delivery", persisted_index: 2}), work({todo_id: "todo_delivery", persisted_index: 0})]; + assert.equal(select({candidates})?.selected_index, 0); + assert.equal(select({candidates, monitor_debt_backoff_active: true})?.selected_index, 1); + assert.equal(select({candidates: [work({priority_rank: 0}), ...candidates], monitor_debt_backoff_active: true})?.selected_index, 0); + assert.equal(select({candidates: [work(), work({todo_id: "todo_second"})]})?.selected_index, 0); + assert.equal(select({candidates: []}), null); +}); + +test("all candidates are evaluated before display limits and independent work remains available", () => { + const scope = {kind: "direction", granularity: "action", scope_key: "delivery"}; + const candidates = Array.from({length: 500}, (_, i) => work({todo_id: `todo_blocked_${i}`, required_decision_scopes: [scope], persisted_index: i})); + candidates.push(work({todo_id: "todo_last", persisted_index: 501})); + const result = select({candidates, allow_unrelated_gate: false, gates: [gate({decision_scope: scope})]}); + assert.equal(result?.selected_index, 500); + assert.equal((result?.blocked as JsonObject[]).length, 3); + assert.equal(result?.blocked_count, 500); + assert.equal(result?.has_blocking_gate, true); + assert.equal(select({allow_unrelated_gate: false}), null); +});