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
32 changes: 32 additions & 0 deletions docs/reference/todo-work-counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 先按原有归属、排除、
Expand Down
45 changes: 0 additions & 45 deletions loopx/control_plane/todos/handoff_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions loopx/control_plane/todos/quota_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down
91 changes: 89 additions & 2 deletions loopx/control_plane/todos/quota_selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string>();
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"))} : {})};
}
34 changes: 4 additions & 30 deletions loopx/control_plane/todos/quota_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading