Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions docs/architecture/rfcs/typescript-control-plane-migration-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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` 统一解释执行前提、修复产出、
Expand Down
45 changes: 43 additions & 2 deletions docs/reference/protocols/decision-scope-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down
115 changes: 16 additions & 99 deletions loopx/control_plane/agents/agent_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ def _build_scoped_gate_fixture(root: Path) -> _SelectedTodoToolFixture:
" <!-- loopx:todo "
f"todo_id={SCOPED_GATE_USER_TODO_ID} status=open "
"task_class=user_gate action_kind=approve_product_first_screen "
"decision_scope=write_scope:action:first-screen "
f"blocks_agent={SELECTED_TODO_TOOL_FIXTURE_AGENT_ID} priority=P2 -->\n\n"
"## Agent Todo\n\n"
f"- [-] [P1] {SCOPED_GATE_SUCCESSOR_ACTION_TEXT}\n"
Expand Down
28 changes: 28 additions & 0 deletions loopx/control_plane/todos/decision_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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,
)
Loading