diff --git a/docs/reference/todo-work-counts.md b/docs/reference/todo-work-counts.md index 5525c447b3..ccb071dca1 100644 --- a/docs/reference/todo-work-counts.md +++ b/docs/reference/todo-work-counts.md @@ -85,6 +85,38 @@ lane and closure RPC calls; it does not change persisted Todo or public summary schemas. Python decodes legacy input, validates source ordinals and materializes public fields, with no independent summary count, cap or claimant-allocation rule. +Route-continuation visibility also uses the existing typed quota planning batch. +It shares the normal claim/exclusion rule; eligibility, first-identity deduplication, +sorting and counts precede the display cap. Disabled candidates do not hide later +eligible copies. Historical unclaimed visibility may include excluded records, +but the current-Agent lane excludes them and the execution consumer rechecks +eligibility. A route hint never grants execution or clears a handoff gate. +Python retains legacy field decoding and display formatting. There is no new +RPC, persisted field, provider default or forced migration; supported old planning +requests and legacy/File/SQLite records retain their behavior. + +Handoff visibility uses the same batch and gate snapshot. `excluded_agents` +addresses the review lane for these gates, independently of ordinary execution +claims; exclusion still denies execution. Full-source counts precede display +limits, while projected source order, duplicates and historical display states +are retained. Only `cleared_without_successor` contributes to that named gap +count; no-follow-up, superseded and deferred states are distinct. An explicitly +empty projected gate list suppresses legacy reconstruction. Python retains the +legacy renderer and downstream execution checks, with no separate handoff lane +decision or repeated gate reconstruction for route visibility. + +重规划候选的展示也复用既有 TS quota planning 批处理与 claim/exclusion 规则。 +资格、首个有效身份去重、排序和计数先于展示裁剪;显式关闭的副本不遮挡后续有效 +副本。历史 unclaimed 展示可包含被排除的记录,current-Agent 分组会排除它们, +执行方仍重新校验资格。建议不授予执行权限或清除 handoff 阻塞。Python 保留旧字段 +解码和展示;不新增 RPC、持久字段、provider 默认值或强制迁移。 + +handoff 展示也使用同一批次与 gate 快照。此处 `excluded_agents` 定向其复核分组, +执行仍受排除限制;它不按普通工作 claim 授予资格。完整计数先于裁剪,保留来源顺序、 +重复行和历史展示状态;仅 `cleared_without_successor` 计入对应后继缺口,不能混同 +no-follow-up、superseded 或 deferred。显式空 gate 投影不重建旧来源。Python 保留旧 +格式展示和下游执行检查,删除独立 handoff 分组判断及 route 展示的重复 gate 重建。 + ## 中文说明 `work_counts` 由完整来源计算,随后才裁剪展示。Agent quota 先按原有归属、排除、 diff --git a/loopx/control_plane/todos/handoff_gate.py b/loopx/control_plane/todos/handoff_gate.py index 8377151db0..2c54fd0772 100644 --- a/loopx/control_plane/todos/handoff_gate.py +++ b/loopx/control_plane/todos/handoff_gate.py @@ -142,48 +142,3 @@ def handoff_ready_successor_todo_ids(value: dict[str, Any]) -> set[str]: if normalized: ready.add(normalized) return ready - - -def build_todo_handoff_gate_lanes( - value: dict[str, Any], - *, - agent_identity: dict[str, Any] | None, - item_limit: int, -) -> dict[str, Any]: - handoff_gates = todo_summary_handoff_gates(value) - if not handoff_gates: - return {} - lanes: dict[str, Any] = { - "handoff_gate_count": len(handoff_gates), - "handoff_gates": handoff_gates[:item_limit], - } - agent_id = ( - normalize_todo_claimed_by(agent_identity.get("agent_id")) - if isinstance(agent_identity, dict) - else None - ) - if agent_id: - current_agent_items = [ - item - for item in handoff_gates - if agent_id in normalize_todo_excluded_agents(item.get("excluded_agents")) - ] - cleared_without_successor = [ - item - for item in current_agent_items - if item.get("gate_state") - == HandoffGateState.CLEARED_WITHOUT_SUCCESSOR.value - ] - lanes.update( - { - "current_agent_handoff_gate_count": len(current_agent_items), - "current_agent_handoff_gates": current_agent_items[:item_limit], - "current_agent_cleared_without_successor_handoff_count": len( - cleared_without_successor - ), - "current_agent_cleared_without_successor_handoff_gates": ( - cleared_without_successor[:item_limit] - ), - } - ) - return lanes diff --git a/loopx/control_plane/todos/quota_selection.py b/loopx/control_plane/todos/quota_selection.py index dcb06b8a1d..748337be84 100644 --- a/loopx/control_plane/todos/quota_selection.py +++ b/loopx/control_plane/todos/quota_selection.py @@ -20,6 +20,8 @@ from .resume_planning import build_todo_resume_planning_request from .summary_item import compact_todo_summary_item from .user_gate import is_user_gate_todo_item +from .route_continuation import build_todo_route_continuation_facts +from .handoff_gate import todo_summary_handoff_gates def _closure_source_facts(value: dict[str, Any]) -> dict[str, Any]: @@ -73,6 +75,7 @@ def project_quota_planning( profile = identity.get("agent_profile") profile = profile if isinstance(profile, dict) and profile else None agent = normalize_todo_claimed_by(identity.get("agent_id")) + handoff_gates = todo_summary_handoff_gates(value) def encode(item: dict[str, Any]) -> dict[str, Any]: priority, index = todo_projection_sort_key(item) @@ -104,6 +107,10 @@ def active(key: str) -> list[dict[str, Any]]: try: result = effect_runtime_result("todo.quota_planning.project", { "schema_version": "todo_quota_planning_request_v2", + "route_items": build_todo_route_continuation_facts(value, handoff_gates=handoff_gates), + "handoff_items": [{"display": gate, + "excluded": normalize_todo_excluded_agents(gate.get("excluded_agents"))} + for gate in handoff_gates], "source_contract": _closure_source_facts(value), "resume": build_todo_resume_planning_request(value, agent_id=agent, item_limit=8, available_capabilities=(available_capabilities or []) if resolve_capacity else None), diff --git a/loopx/control_plane/todos/quota_selection.ts b/loopx/control_plane/todos/quota_selection.ts index 9ec6f3d438..fed1a3ce7a 100644 --- a/loopx/control_plane/todos/quota_selection.ts +++ b/loopx/control_plane/todos/quota_selection.ts @@ -7,6 +7,8 @@ import { projectTodoResumePlanning } from "./resume_planning.ts"; import { gateAddressesAgent, actionAddressesAgent, claimAllowsAgent } from "./agent_scope.ts"; import { missingRequiredCapabilities } from "../agents/capability_gate.ts"; import {claimedAdvancementCountFromIndex} from "./frontier_revision.ts"; +import {authorityUnicodeCompare} from "../coordination/authority_store_codec.ts"; +import type {HandoffState} from "./succession.ts"; import {validateTodoClosureSource} from "./succession.ts"; interface Row { @@ -189,15 +191,100 @@ export function projectQuotaSelection(value: unknown): JsonObject { }, claim_visibility: claimVisibility}; } -/** One quota read boundary composes scope/claim, resume and source closure rules. */ +/** Route hints are read visibility, never a grant to execute or clear a gate. + * Reuse the ordinary claim/exclusion owner within the same planning crossing. */ +function routeContinuationLanes(value: unknown, agent: string | null, limit: number): JsonObject { + if (!Array.isArray(value)) throw new EffectRuntimeRequestError("route items must be an array"); + const seen = new Set(); + const selected = value.flatMap(value => { + const row = requireJsonObject(value, "route item"); + const display = requireJsonObject(row.display, "route display"); + if (typeof row.identity !== "string" || (row.replan !== null && typeof row.replan !== "boolean") || + (row.task_class !== null && typeof row.task_class !== "string")) { + throw new EffectRuntimeRequestError("invalid route identity or classification facts"); + } + const gate = requireBoolean(row.gate, "route gate"); + const scope = {claim: optionalNonEmptyString(row.claim, "route claim"), + excluded: requireStringArray(row.excluded, "route excluded")}; + const sort = row.sort; + if (!Array.isArray(sort) || sort.length !== 4 || + !Number.isSafeInteger(sort[0]) || !Number.isSafeInteger(sort[1]) || + typeof sort[2] !== "string" || typeof sort[3] !== "string") { + throw new EffectRuntimeRequestError("invalid route presentation coordinates"); + } + if (row.replan === false || (gate && row.replan !== true) || + (row.task_class !== null && row.task_class !== "advancement_task") || + !row.identity || seen.has(row.identity)) return []; + seen.add(row.identity); + return [{scope, sort: sort as [number, number, string, string], + display: {...display, route_continuation_replan_required: true}}]; + }); + selected.sort((a, b) => a.sort[0] - b.sort[0] || a.sort[1] - b.sort[1] || + authorityUnicodeCompare(a.sort[2], b.sort[2]) || authorityUnicodeCompare(a.sort[3], b.sort[3])); + if (!selected.length) return {}; + const compact = (rows: typeof selected) => rows.slice(0, limit).map(row => row.display); + const result: JsonObject = {route_continuation_replan_count: selected.length, + route_continuation_replan_candidates: compact(selected)}; + if (agent) { + const current = selected.filter(row => claimAllowsAgent(row.scope, agent)); + // Historical unclaimed visibility includes excluded rows; only the current + // lane permits a wake, and its consumer rechecks execution eligibility. + const unclaimed = selected.filter(row => !row.scope.claim); + const other = selected.filter(row => !claimAllowsAgent(row.scope, agent)); + Object.assign(result, { + current_agent_route_continuation_replan_count: current.length, + current_agent_route_continuation_replan_candidates: compact(current), + unclaimed_route_continuation_replan_count: unclaimed.length, + unclaimed_route_continuation_replan_candidates: compact(unclaimed), + other_agent_route_continuation_replan_count: other.length, + other_agent_route_continuation_replan_candidates: compact(other), + route_continuation_replan_selection_policy: "quota may wake the current peer for route continuation replan " + + "candidates claimed by that agent or unclaimed; other-agent route candidates remain diagnostic visibility", + }); + } + return result; +} + +/** Exclusion addresses a handoff review lane; it is not execution eligibility. + * Preserve projected source order, duplicates and unknown historical display states. */ +function handoffGateLanes(value: unknown, agent: string | null, limit: number): JsonObject { + if (!Array.isArray(value)) throw new EffectRuntimeRequestError("handoff items must be an array"); + const rows = value.map(value => { + const row = requireJsonObject(value, "handoff item"); + return {display: requireJsonObject(row.display, "handoff display"), + excluded: requireStringArray(row.excluded, "handoff excluded")}; + }); + if (!rows.length) return {}; + const compact = (items: typeof rows) => items.slice(0, limit).map(row => row.display); + const result: JsonObject = {handoff_gate_count: rows.length, handoff_gates: compact(rows)}; + if (agent) { + const current = rows.filter(row => row.excluded.includes(agent)); + const needsSuccessor: HandoffState = "cleared_without_successor"; + const cleared = current.filter(row => row.display.gate_state === needsSuccessor); + Object.assign(result, {current_agent_handoff_gate_count: current.length, + current_agent_handoff_gates: compact(current), + current_agent_cleared_without_successor_handoff_count: cleared.length, + current_agent_cleared_without_successor_handoff_gates: compact(cleared)}); + } + return result; +} + +/** One quota read boundary composes scope/claim selection, resume, hint + * visibility and the typed source closure rules. */ export function projectTodoQuotaPlanning(value: unknown): JsonObject { const request = requireJsonObject(value, "quota planning"); if (!["todo_quota_planning_request_v0", "todo_quota_planning_request_v1", "todo_quota_planning_request_v2"].includes(String(request.schema_version))) throw new EffectRuntimeRequestError("quota planning schema mismatch"); if (request.schema_version !== "todo_quota_planning_request_v0") { requireStringArray(requireJsonObject(request.selection, "selection").available, "available"); } + const selection = requireJsonObject(request.selection, "selection"); const closure = request.schema_version === "todo_quota_planning_request_v2" ? validateTodoClosureSource(request.source_contract) : {}; return {schema_version: "todo_quota_planning_v0", resume_planning: projectTodoResumePlanning(request.resume), - ...projectQuotaSelection(request.selection), ...closure}; + ...projectQuotaSelection(selection), ...closure, + ...(request.schema_version === "todo_quota_planning_request_v2" ? {handoff_lanes: + handoffGateLanes(request.handoff_items, optionalNonEmptyString(selection.agent_id, "agent_id"), + requireInteger(selection.backlog_limit, "backlog_limit")), route_lanes: + routeContinuationLanes(request.route_items, optionalNonEmptyString(selection.agent_id, "agent_id"), + requireInteger(selection.backlog_limit, "backlog_limit"))} : {})}; } diff --git a/loopx/control_plane/todos/quota_summary.py b/loopx/control_plane/todos/quota_summary.py index 46c8aef265..dda6ab4a0d 100644 --- a/loopx/control_plane/todos/quota_summary.py +++ b/loopx/control_plane/todos/quota_summary.py @@ -11,14 +11,12 @@ ) from .quota_selection import project_quota_planning from .frontier_deadline import todo_summary_frontier_deadline -from .handoff_gate import build_todo_handoff_gate_lanes from .todo_semantics import ( todo_item_task_class, todo_presentation_sort_key, todo_summary_monitor_schedule_gap_items, todo_summary_monitor_writeback_contract, ) -from .route_continuation import build_todo_route_continuation_lanes from .succession_warning import build_todo_succession_warning_lanes from .summary_item import ( compact_todo_summary_item, @@ -315,20 +313,8 @@ def summarize_user_todos_for_quota( summary.update(planning["claim_visibility"]) summary.update(resume_planning["deferred_lanes"]) summary.update(resume_planning["resume_blocked_lanes"]) - summary.update( - build_todo_handoff_gate_lanes( - value, - agent_identity=agent_identity, - item_limit=TODO_BACKLOG_ITEM_LIMIT, - ) - ) - summary.update( - build_todo_route_continuation_lanes( - value, - agent_identity=agent_identity, - item_limit=TODO_BACKLOG_ITEM_LIMIT, - ) - ) + summary.update(planning["handoff_lanes"]) + summary.update(planning["route_lanes"]) summary.update( build_todo_succession_warning_lanes( value, @@ -712,20 +698,8 @@ def summarize_project_asset_todos_for_quota( summary["monitor_writeback"] = monitor_writeback summary.update(planning["claim_visibility"]) summary.update(resume_planning["deferred_lanes"]) - summary.update( - build_todo_handoff_gate_lanes( - value, - agent_identity=agent_identity, - item_limit=TODO_BACKLOG_ITEM_LIMIT, - ) - ) - summary.update( - build_todo_route_continuation_lanes( - value, - agent_identity=agent_identity, - item_limit=TODO_BACKLOG_ITEM_LIMIT, - ) - ) + summary.update(planning["handoff_lanes"]) + summary.update(planning["route_lanes"]) source_claimed_open_count = None if filter_user_gate_blocks_agent else value.get("claimed_open_count") if lanes.claimed_open_items or source_claimed_open_count: summary["claimed_open_count"] = source_claimed_open_count or len(lanes.claimed_open_items) diff --git a/loopx/control_plane/todos/route_continuation.py b/loopx/control_plane/todos/route_continuation.py index 6beba91d6a..09b5e36dc8 100644 --- a/loopx/control_plane/todos/route_continuation.py +++ b/loopx/control_plane/todos/route_continuation.py @@ -1,175 +1,38 @@ +"""Legacy display/fact codec for the existing typed quota planning batch.""" + from __future__ import annotations from typing import Any -from .contract import ( - TODO_TASK_CLASS_ADVANCEMENT, - normalize_todo_claimed_by, - normalize_todo_excluded_agents, -) +from .contract import normalize_todo_claimed_by, normalize_todo_excluded_agents from .compact_projection import compact_todo_projection_item, projection_task_class -from .handoff_gate import todo_summary_handoff_gates from .todo_semantics import todo_presentation_sort_key -TODO_ROUTE_CONTINUATION_SELECTION_POLICY = ( - "quota may wake the current peer for route continuation replan " - "candidates claimed by that agent or unclaimed; other-agent route " - "candidates remain diagnostic visibility" -) - - -def todo_summary_route_continuation_candidates( - value: dict[str, Any], +def build_todo_route_continuation_facts( + value: dict[str, Any], *, handoff_gates: list[dict[str, Any]], ) -> list[dict[str, Any]]: - if not isinstance(value, dict): - return [] - source_items: list[dict[str, Any]] = [] - for key in ( - "route_continuation_replan_candidates", - "route_continuation_candidates", - ): + """Transport all observed rows; TS owns eligibility, deduplication and lanes.""" + groups: list[tuple[dict[str, Any], bool]] = [] + for key in ("route_continuation_replan_candidates", "route_continuation_candidates"): source = value.get(key) - raw_items = source if isinstance(source, list) else [] - source_items.extend(item for item in raw_items if isinstance(item, dict)) - - source_items.extend( - item - for item in todo_summary_handoff_gates(value) - if isinstance(item, dict) - and item.get("route_continuation_replan_required") is True - ) - if not source_items: - return [] - - candidates: list[dict[str, Any]] = [] - seen: set[str] = set() - for item in source_items: - if item.get("route_continuation_replan_required") is False: - continue - task_class = item.get("task_class") - if task_class is not None and projection_task_class(item) != TODO_TASK_CLASS_ADVANCEMENT: - continue - text = str( - item.get("text") - or item.get("title") - or item.get("recommended_action") - or item.get("route_continuation_reason") - or "" - ).strip() - identity = str( - item.get("todo_id") - or item.get("route_id") - or item.get("route_key") - or item.get("index") - or text - ) - if not identity or identity in seen: - continue - seen.add(identity) - compact = compact_todo_projection_item(item, text=text) - compact["route_continuation_replan_required"] = True - if item.get("route_continuation_reason") is not None: - compact["route_continuation_reason"] = item.get("route_continuation_reason") - if item.get("route_id") is not None: - compact["route_id"] = item.get("route_id") - if item.get("route_key") is not None: - compact["route_key"] = item.get("route_key") - candidates.append(compact) - return sorted(candidates, key=todo_presentation_sort_key) - - -def route_continuation_candidate_matches_agent( - item: dict[str, Any], - *, - agent_id: str, -) -> bool: - if agent_id in normalize_todo_excluded_agents(item.get("excluded_agents")): - return False - claimed_by = normalize_todo_claimed_by(item.get("claimed_by")) - return not claimed_by or claimed_by == agent_id - - -def _agent_filtered_route_continuation_items( - items: list[dict[str, Any]], - *, - agent_id: str | None, - claim: str, -) -> list[dict[str, Any]]: - selected: list[dict[str, Any]] = [] - for item in items: - claimed_by = normalize_todo_claimed_by(item.get("claimed_by")) - if claim == "current": - if not agent_id or not route_continuation_candidate_matches_agent( - item, - agent_id=agent_id, - ): - continue - elif claim == "unclaimed": - if claimed_by: - continue - elif claim == "other": - if not agent_id: - continue - if route_continuation_candidate_matches_agent(item, agent_id=agent_id): - continue - selected.append(item) - return selected - - -def build_todo_route_continuation_lanes( - value: dict[str, Any], - *, - agent_identity: dict[str, Any] | None, - item_limit: int, -) -> dict[str, Any]: - candidates = todo_summary_route_continuation_candidates(value) - if not candidates: - return {} - lanes: dict[str, Any] = { - "route_continuation_replan_count": len(candidates), - "route_continuation_replan_candidates": candidates[:item_limit], - } - agent_id = ( - normalize_todo_claimed_by(agent_identity.get("agent_id")) - if isinstance(agent_identity, dict) - else None - ) - if agent_id: - current_agent_candidates = _agent_filtered_route_continuation_items( - candidates, - agent_id=agent_id, - claim="current", - ) - unclaimed_candidates = _agent_filtered_route_continuation_items( - candidates, - agent_id=agent_id, - claim="unclaimed", - ) - other_agent_candidates = _agent_filtered_route_continuation_items( - candidates, - agent_id=agent_id, - claim="other", - ) - lanes.update( - { - "current_agent_route_continuation_replan_candidates": ( - current_agent_candidates[:item_limit] - ), - "unclaimed_route_continuation_replan_candidates": ( - unclaimed_candidates[:item_limit] - ), - "other_agent_route_continuation_replan_candidates": ( - other_agent_candidates[:item_limit] - ), - "current_agent_route_continuation_replan_count": len( - current_agent_candidates - ), - "unclaimed_route_continuation_replan_count": len(unclaimed_candidates), - "other_agent_route_continuation_replan_count": len(other_agent_candidates), - "route_continuation_replan_selection_policy": ( - TODO_ROUTE_CONTINUATION_SELECTION_POLICY - ), - } - ) - return lanes + if isinstance(source, list): + groups.extend((item, False) for item in source if isinstance(item, dict)) + groups.extend((item, True) for item in handoff_gates) + rows: list[dict[str, Any]] = [] + for item, gate in groups: + text = str(item.get("text") or item.get("title") or item.get("recommended_action") + or item.get("route_continuation_reason") or "").strip() + identity = str(item.get("todo_id") or item.get("route_id") or item.get("route_key") + or item.get("index") or text) + display = compact_todo_projection_item(item, text=text) + flag = item.get("route_continuation_replan_required") + rows.append({ + "display": display, "identity": identity, "gate": gate, + "replan": flag if isinstance(flag, bool) else None, + "task_class": projection_task_class(item) if item.get("task_class") is not None else None, + "claim": normalize_todo_claimed_by(item.get("claimed_by")), + "excluded": normalize_todo_excluded_agents(item.get("excluded_agents")), + "sort": list(todo_presentation_sort_key(display)), + }) + return rows diff --git a/tests/control_plane/test_compact_todo_projection.py b/tests/control_plane/test_compact_todo_projection.py index 8986d40f03..0602c40642 100644 --- a/tests/control_plane/test_compact_todo_projection.py +++ b/tests/control_plane/test_compact_todo_projection.py @@ -5,14 +5,17 @@ import pytest from loopx.control_plane.todos.resume_planning import project_todo_resume_planning -from loopx.control_plane.todos.route_continuation import todo_summary_route_continuation_candidates +from loopx.control_plane.todos.quota_selection import project_quota_planning from loopx.control_plane.todos.succession_warning import build_todo_succession_warning_lanes def projected(item: dict) -> list[dict]: return [ project_todo_resume_planning({"deferred_items": [item]})["deferred_items"][0], - todo_summary_route_continuation_candidates({"route_continuation_candidates": [item]})[0], + project_quota_planning({"route_continuation_candidates": [item]}, + all_open_items=[], source_open_count=0, agent_identity=None, + filter_user_gate_blocks_agent=False, available_capabilities=[]) + ["route_lanes"]["route_continuation_replan_candidates"][0], build_todo_succession_warning_lanes( {"completed_without_successor_items": [item]}, item_limit=5, )["completed_without_successor_items"][0], diff --git a/tests/control_plane/test_quota_selection.py b/tests/control_plane/test_quota_selection.py index 97656154a2..b6df6000b1 100644 --- a/tests/control_plane/test_quota_selection.py +++ b/tests/control_plane/test_quota_selection.py @@ -3,12 +3,16 @@ import json from pathlib import Path -from canonical_authority_fixture import initialize_canonical_authority +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.control_plane.todos.machine_section_projection import render_canonical_todo_sections from loopx.control_plane.testing.canary_harness import write_fixture_registry, run_json_cli_result from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from loopx.control_plane.todos.active_state_todo_parser import parse_active_state_todos -from loopx.control_plane.todos.quota_summary import summarize_user_todos_for_quota +from loopx.control_plane.todos.quota_summary import ( + summarize_user_todos_for_quota, summarize_project_asset_todos_for_quota, +) from loopx.control_plane.todos.todo_summary import compact_todo_group @@ -54,6 +58,117 @@ def test_agent_execution_still_respects_claim_and_exclusion(): assert result["claim_scope"]["other_agent_claimed_open_count"] == 1 +@pytest.mark.parametrize("agent", [None, "agent-b"]) +def test_route_replan_visibility_counts_before_budget_without_granting_execution(agent): + own = [item(f"todo_route_{i}", task_class="advancement_task", claimed_by="agent-b", + index=i + 1, status="done", route_continuation_replan_required=True) + for i in range(10)] + free = item("todo_free", task_class="advancement_task", index=12) + peer = item("todo_peer", task_class="advancement_task", claimed_by="agent-a", index=13) + excluded = item("todo_excluded", task_class="advancement_task", excluded_agents=["agent-b"], priority="P2", index=14) + value = summary([]) + value["route_continuation_replan_candidates"] = own + [free, peer, excluded] + # Explicitly-disabled duplicates must not hide an enabled row. + value["route_continuation_candidates"] = [ + {**free, "route_continuation_replan_required": False}, peer, + item("todo_monitor", task_class="continuous_monitor"), + ] + result = summarize_user_todos_for_quota(value, + agent_identity={"agent_id": agent} if agent else None) + assert result["route_continuation_replan_count"] == 13 + assert len(result["route_continuation_replan_candidates"]) == 8 + assert result["open_count"] == 0 and result["first_executable_items"] == [] + if agent: + assert result["current_agent_route_continuation_replan_count"] == 11 + assert result["unclaimed_route_continuation_replan_count"] == 2 + assert result["other_agent_route_continuation_replan_count"] == 2 + assert [row["todo_id"] for row in result["other_agent_route_continuation_replan_candidates"]] == ["todo_peer", "todo_excluded"] + else: + assert "current_agent_route_continuation_replan_count" not in result + + +@pytest.mark.parametrize("agent", [None, "agent-b"]) +@pytest.mark.parametrize("asset", [False, True]) +def test_handoff_visibility_addresses_excluded_review_lane_without_executing_it(agent, asset): + states = ["blocking", "cleared_without_successor", "cleared_with_successor", + "cleared_no_followup", "superseded", "deferred", "historical_unknown"] + gates = [{"todo_id": f"todo_gate_{i}", "gate_state": state, + "excluded_agents": " AGENT-B ", "claimed_by": "agent-a", "index": 20 - i} + for i, state in enumerate(states)] + gates += [dict(gates[1]), dict(gates[1]), + {"todo_id": "todo_other", "gate_state": "cleared_without_successor", + "excluded_agents": ["agent-c"], "claimed_by": "agent-b"}] + value = {**summary([]), "handoff_gates": [None, *gates]} + if asset: + value.pop("items") + value.pop("first_open_items") + project = summarize_project_asset_todos_for_quota if asset else summarize_user_todos_for_quota + result = project(value, + agent_identity={"agent_id": agent} if agent else None) + assert result["handoff_gate_count"] == 10 + assert result["handoff_gates"] == gates[:8] # Source order and duplicates survive. + assert result["open_count"] == 0 and result["first_executable_items"] == [] + if agent: + assert result["current_agent_handoff_gate_count"] == 9 + assert result["current_agent_handoff_gates"] == gates[:8] + assert result["current_agent_cleared_without_successor_handoff_count"] == 3 + assert result["current_agent_cleared_without_successor_handoff_gates"] == [gates[1]] * 3 + else: + assert "current_agent_handoff_gate_count" not in result + + +@pytest.mark.parametrize("projected", [[], [None]]) +def test_empty_projected_handoffs_suppress_raw_legacy_reconstruction(projected): + raw = item("todo_handoff", task_class="advancement_task", action_kind="handoff_review", + excluded_agents=["agent-b"], unblocks_todo_id="todo_target") + result = summarize_user_todos_for_quota({**summary([raw]), "handoff_gates": projected}, + agent_identity={"agent_id": "agent-b"}) + assert "handoff_gate_count" not in result + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +def test_public_quota_route_read_keeps_claim_exclusion_and_provider_unchanged(tmp_path, monkeypatch, provider): + isolate_sqlite_runtime(tmp_path, monkeypatch) + runtime, registry, state = tmp_path / "runtime", tmp_path / "registry.json", tmp_path / "state.md" + records = [{"schema_version": "todo_item_v0", "todo_id": f"todo_route_{name}", + "role": "agent", "source_section": "Agent Todo", "archive_state": "active", + "text": "[P1] Repair stale handoff closeout", "task_class": "advancement_task", + "action_kind": "handoff_review", "status": "open", "done": False, + "priority": "P1", "unblocks_todo_id": "todo_target", + "excluded_agents": ["agent-c"], **scope} + for name, scope in [("own", {"claimed_by": "agent-b"}), ("free", {}), + ("peer", {"claimed_by": "agent-a"}), + ("excluded", {"excluded_agents": ["agent-b"]})]] + state.write_text(render_canonical_todo_sections("# Goal\n\n## Agent Todo\n", records, + provider_revision="fixture-source").markdown) + write_fixture_registry(project=tmp_path, runtime_root=runtime, registry_path=registry, + goal_id="goal-route", domain="quota-route", adapter_kind="generic_project_goal_v0", + state_file=str(state), extra_goal_fields={"coordination": { + "registered_agents": ["agent-a", "agent-b", "agent-c"], "handoff_mode": "soft_claim"}}) + if provider != "legacy": + projection = build_todo_runtime_shadow_projection(goal_id="goal-route", todos=records, handoff_mode="soft_claim") + initialize_canonical_authority(runtime, "goal-route", projection, state_path=state, provider=provider) + state.unlink() + before = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-route") + narrative = state.read_bytes() if state.exists() else None + code, packet = run_json_cli_result("quota", "should-run", "--goal-id", "goal-route", "--agent-id", "agent-b", + "--include-detail", "agent-todos", "--scan-path", str(tmp_path), registry_path=registry, runtime_root=runtime) + assert code == 0, packet + routes = packet["agent_todo_summary"] + assert routes["route_continuation_replan_count"] == 4 + assert routes["current_agent_route_continuation_replan_count"] == 2 + assert routes["unclaimed_route_continuation_replan_count"] == 2 + assert routes["other_agent_route_continuation_replan_count"] == 2 + assert all(row.get("claimed_by") != "agent-a" and "agent-b" not in row.get("excluded_agents", []) + for row in routes["current_agent_route_continuation_replan_candidates"]) + assert routes["handoff_gate_count"] == 4 + assert routes["current_agent_handoff_gate_count"] == 1 + assert [row["todo_id"] for row in routes["current_agent_handoff_gates"]] == ["todo_route_excluded"] + assert routes["current_agent_cleared_without_successor_handoff_count"] == 0 + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-route") == before + assert (state.read_bytes() if state.exists() else None) == narrative + + @pytest.mark.parametrize("reverse", [False, True]) def test_quota_uses_complete_owned_commitment_count_before_display_cap(reverse): own = [item(f"todo_own_{i}", task_class="advancement_task", claimed_by="agent-b", done=False) diff --git a/tests/control_plane_ts/quota_selection.test.ts b/tests/control_plane_ts/quota_selection.test.ts index fc86b3dea3..1a5fc9ed93 100644 --- a/tests/control_plane_ts/quota_selection.test.ts +++ b/tests/control_plane_ts/quota_selection.test.ts @@ -19,6 +19,83 @@ function request(items: JsonObject[], fields: JsonObject = {}): JsonObject { } const ids = (value: unknown) => (value as JsonObject[]).map(item => item.todo_id); +test("route planning shares claim exclusion, preserves legacy visibility and never grants execution", () => { + const fact = (identity: string, fields: JsonObject = {}) => ({identity, + display: {todo_id: identity, text: "Review route"}, gate: false, replan: null, + task_class: "advancement_task", claim: null, excluded: [], sort: [1, 1, "", identity], ...fields}); + const input = {schema_version: "todo_quota_planning_request_v2", selection: request([], {available: [], backlog_limit: 0}), + source_contract: {}, + resume: {schema_version: "todo_resume_planning_request_v0", agent_id: "agent-a", item_limit: 8, + has_deferred_count: false, has_visible_deferred_count: false, deferred_count: null, + available_capabilities: null, sources: Object.fromEntries(["items", "backlog_items", "first_open_items", "deferred_items", + "deferred_resume_candidates", "resume_blocked_items", "monitor_open_items", + "current_agent_claimed_monitor_items", "claimed_monitor_open_items"].map(key => [key, []]))}, + handoff_items: [], + route_items: [fact("free", {replan: false}), fact("free"), fact("own", {claim: "agent-a"}), + fact("peer", {claim: "agent-b"}), fact("excluded", {excluded: ["agent-a"]}), + fact("monitor", {task_class: "continuous_monitor"}), fact("missing", {gate: true}), + fact("gate", {gate: true, replan: true}), fact(""), fact("peer")]}; + const before = structuredClone(input); + const result = projectTodoQuotaPlanning(input), routes = result.route_lanes as JsonObject; + assert.equal(routes.route_continuation_replan_count, 5); + assert.equal(routes.current_agent_route_continuation_replan_count, 3); + assert.equal(routes.unclaimed_route_continuation_replan_count, 3); + assert.equal(routes.other_agent_route_continuation_replan_count, 2); + assert.deepEqual(routes.route_continuation_replan_candidates, []); + assert.deepEqual((result.lanes as JsonObject).executable_items, []); + assert.deepEqual(input, before); + const visible = structuredClone(input); visible.selection.backlog_limit = 8; + assert.deepEqual(ids((projectTodoQuotaPlanning(visible).route_lanes as JsonObject) + .current_agent_route_continuation_replan_candidates), ["free", "gate", "own"]); + // Malformed current carriers fail; older co-deployed requests stay unchanged. + assert.throws(() => projectTodoQuotaPlanning({...input, route_items: null}), /route items/); + assert.throws(() => projectTodoQuotaPlanning({...input, route_items: [fact("bad", {replan: "true"})]}), /classification/); + assert.equal(projectTodoQuotaPlanning({...input, schema_version: "todo_quota_planning_request_v1"}).route_lanes, undefined); + assert.equal(projectTodoQuotaPlanning({...input, schema_version: "todo_quota_planning_request_v1"}).handoff_lanes, undefined); + const legacy = {...input, schema_version: "todo_quota_planning_request_v0"}; + const {available: _available, ...oldSelection} = legacy.selection; + assert.deepEqual(projectTodoQuotaPlanning({...legacy, selection: oldSelection}), + projectTodoQuotaPlanning({...input, schema_version: "todo_quota_planning_request_v1"})); +}); + +test("handoff counts retain addressed review states, source order and duplicates before zero display", () => { + const states = ["blocking", "cleared_without_successor", "cleared_with_successor", + "cleared_no_followup", "superseded", "deferred", "historical_unknown"]; + const handoffs = states.map((gate_state, i) => ({display: {todo_id: `gate-${i}`, gate_state, + claimed_by: "agent-b"}, excluded: ["agent-a"]})); + handoffs.push(handoffs[1], handoffs[1], {display: {todo_id: "other", gate_state: states[1], + claimed_by: "agent-a"}, excluded: ["agent-b"]}); + const input = {schema_version: "todo_quota_planning_request_v2", route_items: [], handoff_items: handoffs, + source_contract: {}, + selection: request([], {available: [], backlog_limit: 0}), + resume: {schema_version: "todo_resume_planning_request_v0", agent_id: "agent-a", item_limit: 8, + has_deferred_count: false, has_visible_deferred_count: false, deferred_count: null, + available_capabilities: null, sources: Object.fromEntries(["items", "backlog_items", "first_open_items", "deferred_items", + "deferred_resume_candidates", "resume_blocked_items", "monitor_open_items", + "current_agent_claimed_monitor_items", "claimed_monitor_open_items"].map(key => [key, []]))}}; + const before = structuredClone(input); + const result = projectTodoQuotaPlanning(input), lanes = result.handoff_lanes as JsonObject; + assert.equal(lanes.handoff_gate_count, 10); + assert.equal(lanes.current_agent_handoff_gate_count, 9); + assert.equal(lanes.current_agent_cleared_without_successor_handoff_count, 3); + assert.deepEqual(lanes.handoff_gates, []); + assert.deepEqual(lanes.current_agent_handoff_gates, []); + assert.deepEqual(lanes.current_agent_cleared_without_successor_handoff_gates, []); + assert.deepEqual((result.lanes as JsonObject).executable_items, []); + assert.deepEqual(input, before); + const visible = {...input, selection: {...input.selection, backlog_limit: 8}}; + assert.deepEqual((projectTodoQuotaPlanning(visible).handoff_lanes as JsonObject).handoff_gates, + handoffs.slice(0, 8).map(row => row.display)); + assert.deepEqual(projectTodoQuotaPlanning({...visible, handoff_items: []}).handoff_lanes, {}); + const unscoped = projectTodoQuotaPlanning({...visible, selection: {...visible.selection, agent_id: null}}) + .handoff_lanes as JsonObject; + assert.equal(unscoped.handoff_gate_count, 10); + assert.equal(unscoped.current_agent_handoff_gate_count, undefined); + for (const handoff_items of [null, [{display: null, excluded: []}], [{display: {}, excluded: "agent-a"}]]) { + assert.throws(() => projectTodoQuotaPlanning({...input, handoff_items}), /handoff/); + } +}); + test("gate applicability overrides execution claims, but not another lane's explicit scope", () => { const rows = [row("global", {gate: true, global: true, claim: "agent-b", excluded: ["agent-a"]}), row("targeted", {gate: true, blocks: "agent-a", claim: "agent-b"}), @@ -143,10 +220,16 @@ test("quota v2 validates closure in the existing batch while retaining v0/v1 wir const v1 = projectTodoQuotaPlanning({schema_version: "todo_quota_planning_request_v1", resume, selection}); assert.deepEqual(v0, v1); assert.equal(v0.source_completeness, undefined); - assert.throws(() => projectTodoQuotaPlanning({schema_version: "todo_quota_planning_request_v2", resume, selection}), /closure source/); - const v2 = projectTodoQuotaPlanning({schema_version: "todo_quota_planning_request_v2", resume, selection, source_contract: {}}); - const {source_completeness, closure_intent, ...unchanged} = v2; + assert.throws(() => projectTodoQuotaPlanning({schema_version: "todo_quota_planning_request_v2", + resume, selection, route_items: [], handoff_items: []}), /closure source/); + const v2 = projectTodoQuotaPlanning({schema_version: "todo_quota_planning_request_v2", resume, selection, + route_items: [], handoff_items: [], source_contract: {}}); + // v2 carries the closure result plus the route/handoff hint lanes; the rest + // of the projection must stay identical to v1. + const {source_completeness, closure_intent, handoff_lanes, route_lanes, ...unchanged} = v2; assert.deepEqual(unchanged, v1); + assert.deepEqual(handoff_lanes, {}); + assert.deepEqual(route_lanes, {}); assert.equal((source_completeness as JsonObject).status, "invalid"); assert.equal(closure_intent, null); });