From 13f7a6ef5a94fdf8115c78764f9fdd17aea9485e Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 14:56:35 +0800 Subject: [PATCH 01/12] refactor(todo): centralize semantic read policy Signed-off-by: huangruiteng --- .../explore/composition_frontier.py | 2 +- .../capabilities/explore/todo_branch_plan.py | 2 +- .../explore/worker_branch_plan.py | 2 +- .../issue_fix/pr_gate_reconcile.py | 2 +- loopx/capabilities/issue_fix/pr_review_ack.py | 2 +- .../project_progress_snapshot.py | 2 +- .../agents/agent_lane_recommendation.py | 2 +- loopx/control_plane/agents/agent_scope.py | 2 +- loopx/control_plane/agents/capability_gate.py | 2 +- .../goals/goal_frontier/__init__.py | 2 +- .../goals/goal_frontier/ack_policy.py | 2 +- .../goal_frontier/fallback_disposition.py | 2 +- loopx/control_plane/goals/goal_vision_wait.py | 2 +- .../goals/goal_vision_wait_projection.py | 2 +- .../goals/start_goal_todo_delta.py | 2 +- loopx/control_plane/quota/monitor_poll.py | 2 +- .../control_plane/quota/projection_repair.py | 2 +- .../control_plane/quota/should_run_packet.py | 2 +- .../control_plane/quota/should_run_prepare.py | 10 +- .../quota/task_orchestration_admission.py | 2 +- .../external_evidence_observation.py | 2 +- loopx/control_plane/todos/projection.py | 817 +----------------- loopx/control_plane/todos/todo_semantics.py | 813 +++++++++++++++++ loopx/control_plane/todos/todo_summary.py | 2 +- .../turn_driver/delivery_continuity.py | 2 +- .../work_items/capability_monitor_fallback.py | 2 +- .../work_items/delivery_history.py | 2 +- .../work_items/interaction_contract.py | 2 +- .../work_items/planning_inventory.py | 2 +- .../work_items/primary_action.py | 2 +- .../control_plane/work_items/repair_delta.py | 2 +- .../work_items/user_action_frontier.py | 2 +- loopx/control_plane/work_items/work_lane.py | 2 +- .../work_items/work_lane_context.py | 2 +- loopx/extensions/lark/presentation/kanban.py | 2 +- loopx/quota.py | 2 +- loopx/status.py | 2 +- .../test_todo_semantic_kernel.py | 40 + 38 files changed, 894 insertions(+), 854 deletions(-) create mode 100644 loopx/control_plane/todos/todo_semantics.py create mode 100644 tests/control_plane/test_todo_semantic_kernel.py diff --git a/loopx/capabilities/explore/composition_frontier.py b/loopx/capabilities/explore/composition_frontier.py index 5752e1d1a4..cdfe6c3917 100644 --- a/loopx/capabilities/explore/composition_frontier.py +++ b/loopx/capabilities/explore/composition_frontier.py @@ -12,7 +12,7 @@ normalize_explore_result_node_refs, normalize_todo_claimed_by, ) -from ...control_plane.todos.projection import ( +from ...control_plane.todos.todo_semantics import ( todo_item_is_actionable_open, todo_item_task_class, ) diff --git a/loopx/capabilities/explore/todo_branch_plan.py b/loopx/capabilities/explore/todo_branch_plan.py index 881f765f71..b1269d9616 100644 --- a/loopx/capabilities/explore/todo_branch_plan.py +++ b/loopx/capabilities/explore/todo_branch_plan.py @@ -13,7 +13,7 @@ normalize_todo_id, normalize_todo_status, ) -from ...control_plane.todos.projection import ( +from ...control_plane.todos.todo_semantics import ( todo_item_is_actionable_open, todo_item_task_class, todo_priority_rank, diff --git a/loopx/capabilities/explore/worker_branch_plan.py b/loopx/capabilities/explore/worker_branch_plan.py index 52ced39055..244947c19a 100644 --- a/loopx/capabilities/explore/worker_branch_plan.py +++ b/loopx/capabilities/explore/worker_branch_plan.py @@ -46,7 +46,7 @@ _shared_dependency_capabilities, _scopes_overlap, ) -from ...control_plane.todos.projection import todo_item_task_class, todo_projection_sort_key +from ...control_plane.todos.todo_semantics import todo_item_task_class, todo_projection_sort_key WORKER_BRANCH_PLAN_SCHEMA_VERSION = "loopx_explore_worker_branch_plan_v0" diff --git a/loopx/capabilities/issue_fix/pr_gate_reconcile.py b/loopx/capabilities/issue_fix/pr_gate_reconcile.py index 20757a6aa1..9e4942f959 100644 --- a/loopx/capabilities/issue_fix/pr_gate_reconcile.py +++ b/loopx/capabilities/issue_fix/pr_gate_reconcile.py @@ -8,7 +8,7 @@ from ...control_plane.todos.contract import ( normalize_todo_decision_scope, ) -from ...control_plane.todos.projection import todo_item_task_class +from ...control_plane.todos.todo_semantics import todo_item_task_class from ...todos import complete_goal_todo, list_goal_todos from .pr_lifecycle import build_issue_fix_pr_lifecycle_monitor_packet from .pr_lifecycle_rollout import append_pr_merge_rollout_event diff --git a/loopx/capabilities/issue_fix/pr_review_ack.py b/loopx/capabilities/issue_fix/pr_review_ack.py index b1352e01a5..549891ae0a 100644 --- a/loopx/capabilities/issue_fix/pr_review_ack.py +++ b/loopx/capabilities/issue_fix/pr_review_ack.py @@ -10,7 +10,7 @@ TODO_TASK_CLASS_USER_ACTION, normalize_todo_bound_agent, ) -from ...control_plane.todos.projection import todo_item_task_class +from ...control_plane.todos.todo_semantics import todo_item_task_class from ...history import load_registry from ...paths import resolve_runtime_root from ...rollout_event_log import ( diff --git a/loopx/capabilities/periodic_report/project_progress_snapshot.py b/loopx/capabilities/periodic_report/project_progress_snapshot.py index f6b41a0df0..7c0bfc3d49 100644 --- a/loopx/capabilities/periodic_report/project_progress_snapshot.py +++ b/loopx/capabilities/periodic_report/project_progress_snapshot.py @@ -6,7 +6,7 @@ from typing import Any from ...control_plane.todos.active_state_todo_parser import parse_active_state_todos -from ...control_plane.todos.projection import todo_item_is_actionable_open +from ...control_plane.todos.todo_semantics import todo_item_is_actionable_open from ...registry import find_registry_goal, read_json, resolve_state_file from .incremental import select_incremental_project_progress diff --git a/loopx/control_plane/agents/agent_lane_recommendation.py b/loopx/control_plane/agents/agent_lane_recommendation.py index 4e5c85ccfe..7f83a349f6 100644 --- a/loopx/control_plane/agents/agent_lane_recommendation.py +++ b/loopx/control_plane/agents/agent_lane_recommendation.py @@ -11,7 +11,7 @@ normalize_todo_claimed_by, normalize_todo_id, ) -from ..todos.projection import todo_item_is_due_monitor +from ..todos.todo_semantics import todo_item_is_due_monitor from ..todos.summary_item import compact_todo_summary_item from ..work_items.primary_action import protocol_action_text from ..work_items.work_lane import ( diff --git a/loopx/control_plane/agents/agent_scope.py b/loopx/control_plane/agents/agent_scope.py index dd3a48f265..af8931bf2b 100644 --- a/loopx/control_plane/agents/agent_scope.py +++ b/loopx/control_plane/agents/agent_scope.py @@ -27,7 +27,7 @@ ) from ..todos.handoff_gate import HandoffGateState from ..todos.resume_planning import project_todo_resume_planning -from ..todos.projection import ( +from ..todos.todo_semantics import ( todo_item_claimed_by_agent_or_unclaimed, todo_item_excludes_agent, todo_item_is_actionable_open, diff --git a/loopx/control_plane/agents/capability_gate.py b/loopx/control_plane/agents/capability_gate.py index 150935d68d..383639e316 100644 --- a/loopx/control_plane/agents/capability_gate.py +++ b/loopx/control_plane/agents/capability_gate.py @@ -9,7 +9,7 @@ normalize_target_capabilities, normalize_todo_claimed_by, ) -from ..todos.projection import ( +from ..todos.todo_semantics import ( todo_index_rank, todo_item_is_actionable_open, todo_item_task_class, diff --git a/loopx/control_plane/goals/goal_frontier/__init__.py b/loopx/control_plane/goals/goal_frontier/__init__.py index 88bdbe311c..83637cf3de 100644 --- a/loopx/control_plane/goals/goal_frontier/__init__.py +++ b/loopx/control_plane/goals/goal_frontier/__init__.py @@ -12,7 +12,7 @@ from ...agents.runtime_model import peer_work_key, select_peer_for_work from ...runtime.time import parse_timestamp from ...todos.contract import normalize_todo_replan_obligation_id -from ...todos.projection import ( +from ...todos.todo_semantics import ( todo_advancement_frontier_counts, todo_item_is_watch_only_monitor, ) diff --git a/loopx/control_plane/goals/goal_frontier/ack_policy.py b/loopx/control_plane/goals/goal_frontier/ack_policy.py index c50f95805b..3447ebb995 100644 --- a/loopx/control_plane/goals/goal_frontier/ack_policy.py +++ b/loopx/control_plane/goals/goal_frontier/ack_policy.py @@ -10,7 +10,7 @@ normalize_todo_replan_obligation_id, replan_successor_semantic_binding, ) -from ...todos.projection import todo_item_is_actionable_open, todo_item_task_class +from ...todos.todo_semantics import todo_item_is_actionable_open, todo_item_task_class from ...work_items.progress_observation import ( replan_obligation_trigger_checkpoints, replan_obligation_trigger_kinds, diff --git a/loopx/control_plane/goals/goal_frontier/fallback_disposition.py b/loopx/control_plane/goals/goal_frontier/fallback_disposition.py index 630a8bd278..a99b9ba531 100644 --- a/loopx/control_plane/goals/goal_frontier/fallback_disposition.py +++ b/loopx/control_plane/goals/goal_frontier/fallback_disposition.py @@ -7,7 +7,7 @@ normalize_todo_id, ) from ...todos.resume_planning import project_todo_resume_planning -from ...todos.projection import ( +from ...todos.todo_semantics import ( agent_scoped_selectable_advancement_todo_ids, ) from ..goal_vision_read_model import ( diff --git a/loopx/control_plane/goals/goal_vision_wait.py b/loopx/control_plane/goals/goal_vision_wait.py index 317d903423..f453c5a6ec 100644 --- a/loopx/control_plane/goals/goal_vision_wait.py +++ b/loopx/control_plane/goals/goal_vision_wait.py @@ -5,7 +5,7 @@ from typing import Any from ..effect_runtime import effect_runtime_result -from ..todos.projection import todo_item_excludes_agent +from ..todos.todo_semantics import todo_item_excludes_agent from ..todos.contract import ( TODO_TASK_CLASS_BLOCKER, normalize_todo_claimed_by, diff --git a/loopx/control_plane/goals/goal_vision_wait_projection.py b/loopx/control_plane/goals/goal_vision_wait_projection.py index 7a3b62dd2c..383365869e 100644 --- a/loopx/control_plane/goals/goal_vision_wait_projection.py +++ b/loopx/control_plane/goals/goal_vision_wait_projection.py @@ -2,7 +2,7 @@ from typing import Any -from ..todos.projection import agent_scoped_selectable_advancement_todo_ids +from ..todos.todo_semantics import agent_scoped_selectable_advancement_todo_ids from .goal_vision_read_model import ( acceptance_gaps_from_agent_vision, latest_agent_vision_from_runs, diff --git a/loopx/control_plane/goals/start_goal_todo_delta.py b/loopx/control_plane/goals/start_goal_todo_delta.py index 2bbb0d5206..8152323c03 100644 --- a/loopx/control_plane/goals/start_goal_todo_delta.py +++ b/loopx/control_plane/goals/start_goal_todo_delta.py @@ -21,7 +21,7 @@ from ...control_plane.todos.contract import ( TODO_TASK_CLASS_ADVANCEMENT, ) -from ...control_plane.todos.projection import todo_item_is_actionable_open +from ...control_plane.todos.todo_semantics import todo_item_is_actionable_open from ...project_prompt import render_cli_command_prefix, shell_arg from ...registry import registry_goals, resolve_state_file diff --git a/loopx/control_plane/quota/monitor_poll.py b/loopx/control_plane/quota/monitor_poll.py index 4bf5d4b5d4..6532a329d1 100644 --- a/loopx/control_plane/quota/monitor_poll.py +++ b/loopx/control_plane/quota/monitor_poll.py @@ -25,7 +25,7 @@ from ..todos.external_wait_contract import ( build_monitor_advancement_authoring_contract, ) -from ..todos.projection import todo_item_task_class +from ..todos.todo_semantics import todo_item_task_class from .decision_summary import compact_quota_decision, quota_decision_agent_id from .spend_sources import DEFAULT_SLOT_SPEND_SOURCE diff --git a/loopx/control_plane/quota/projection_repair.py b/loopx/control_plane/quota/projection_repair.py index 7f8b225858..df427715aa 100644 --- a/loopx/control_plane/quota/projection_repair.py +++ b/loopx/control_plane/quota/projection_repair.py @@ -8,7 +8,7 @@ TODO_TASK_CLASS_ADVANCEMENT, normalize_required_write_scopes, ) -from ..todos.projection import ( +from ..todos.todo_semantics import ( todo_item_is_actionable_open, todo_item_task_class, ) diff --git a/loopx/control_plane/quota/should_run_packet.py b/loopx/control_plane/quota/should_run_packet.py index 1e6c9ea217..5b460b427d 100644 --- a/loopx/control_plane/quota/should_run_packet.py +++ b/loopx/control_plane/quota/should_run_packet.py @@ -95,7 +95,7 @@ from ..todos.contract import ( normalize_todo_claimed_by, ) -from ..todos.projection import ( +from ..todos.todo_semantics import ( todo_item_is_actionable_open as projection_todo_item_is_actionable_open, ) from ..todos.quota_summary import ( diff --git a/loopx/control_plane/quota/should_run_prepare.py b/loopx/control_plane/quota/should_run_prepare.py index 632d7053cf..2a66abe948 100644 --- a/loopx/control_plane/quota/should_run_prepare.py +++ b/loopx/control_plane/quota/should_run_prepare.py @@ -79,19 +79,11 @@ normalize_todo_resume_when, normalize_todo_status, ) -from ..todos.projection import ( +from ..todos.todo_semantics import ( todo_item_is_actionable_open as projection_todo_item_is_actionable_open, -) -from ..todos.projection import ( todo_item_is_due_monitor as projection_todo_item_is_due_monitor, -) -from ..todos.projection import ( todo_item_is_expired_monitor as projection_todo_item_is_expired_monitor, -) -from ..todos.projection import ( todo_item_next_due_at as projection_todo_item_next_due_at, -) -from ..todos.projection import ( todo_item_task_class as projection_todo_item_task_class, ) from ..todos.quota_summary import ( diff --git a/loopx/control_plane/quota/task_orchestration_admission.py b/loopx/control_plane/quota/task_orchestration_admission.py index 5809372c2e..dd2c40cb26 100644 --- a/loopx/control_plane/quota/task_orchestration_admission.py +++ b/loopx/control_plane/quota/task_orchestration_admission.py @@ -14,7 +14,7 @@ normalize_todo_task_domain, normalize_todo_task_repository, ) -from ..todos.projection import todo_item_is_actionable_open +from ..todos.todo_semantics import todo_item_is_actionable_open from ..work_items.primary_action import protocol_action_text from .projection_repair import write_scope_allowed diff --git a/loopx/control_plane/scheduler/external_evidence_observation.py b/loopx/control_plane/scheduler/external_evidence_observation.py index 05a25c0fb9..e8be659e77 100644 --- a/loopx/control_plane/scheduler/external_evidence_observation.py +++ b/loopx/control_plane/scheduler/external_evidence_observation.py @@ -4,7 +4,7 @@ from typing import Any from ..todos.contract import normalize_todo_claimed_by, normalize_todo_id -from ..todos.projection import ( +from ..todos.todo_semantics import ( todo_summary_claim_scope_agent_id, todo_summary_has_only_future_scoped_monitor_work, todo_summary_monitor_due_count, diff --git a/loopx/control_plane/todos/projection.py b/loopx/control_plane/todos/projection.py index 048c0fa04e..982623f84b 100644 --- a/loopx/control_plane/todos/projection.py +++ b/loopx/control_plane/todos/projection.py @@ -1,813 +1,8 @@ -from __future__ import annotations +"""Compatibility exports for the canonical Todo semantic kernel. -from datetime import datetime -import re -from typing import Any +New production code should import :mod:`todo_semantics` directly. This module +remains a stable import path for extensions and older integrations while the +Python/TypeScript control-plane migration removes duplicate decision rules. +""" -from ..scheduler.monitor_todo import ( - monitor_todo_expires_at, - monitor_todo_has_schedule, - monitor_todo_is_actionable_open, - monitor_todo_is_due, - monitor_todo_is_expired, - monitor_todo_missing_schedule, - monitor_todo_next_due_at, - monitor_todo_task_class, -) -from .contract import ( - TODO_STATUS_DEFERRED, - TODO_TASK_CLASS_ADVANCEMENT, - TODO_TASK_CLASS_MONITOR, - normalize_todo_claimed_by, - normalize_todo_excluded_agents, - normalize_removed_todo_continuation_policy, - normalize_todo_id, - normalize_todo_status, - normalize_todo_watch_only, -) - - -TODO_MISSING_PRIORITY_RANK = 50 -TODO_MISSING_INDEX = 999999 -TODO_PRIORITY_PREFIX_PATTERN = re.compile( - r"^\s*\[(P[0-4][^\]]*)\]\s*(.+)$", - re.IGNORECASE, -) -TODO_PRIORITY_LABEL_PATTERN = re.compile(r"\bP([0-4])\b", re.IGNORECASE) - - -def todo_item_is_watch_only_monitor(item: dict[str, Any]) -> bool: - return bool( - todo_item_task_class(item) == TODO_TASK_CLASS_MONITOR - and normalize_todo_watch_only(item.get("watch_only")) is True - ) - - -def todo_priority_parts(text: str) -> tuple[str | None, str]: - match = TODO_PRIORITY_PREFIX_PATTERN.match(text) - if not match: - return None, text - return match.group(1).strip().upper(), match.group(2).strip() - - -def todo_priority_label( - item: dict[str, Any], - *, - text_mode: str = "label", -) -> str | None: - priority = item.get("priority") - if isinstance(priority, str) and priority.strip(): - return priority.strip().upper() - text = " ".join( - str(value or "") - for value in (item.get("title"), item.get("text")) - if str(value or "").strip() - ) - if text_mode == "prefix": - priority, _ = todo_priority_parts(text) - return priority - match = TODO_PRIORITY_LABEL_PATTERN.search(text.upper()) - if not match: - return None - return f"P{match.group(1)}" - - -def todo_priority_rank(value: Any, *, text_mode: str = "label") -> int: - if isinstance(value, dict): - priority = todo_priority_label(value, text_mode=text_mode) - elif isinstance(value, str): - priority = value.strip().upper() - else: - priority = None - if not priority: - return TODO_MISSING_PRIORITY_RANK - match = re.match(r"P([0-4])", priority) - if not match: - return TODO_MISSING_PRIORITY_RANK - return int(match.group(1)) - - -def todo_index_rank(item: dict[str, Any]) -> int: - raw_index = item.get("index") - try: - return int(raw_index) if raw_index is not None else TODO_MISSING_INDEX - except (TypeError, ValueError): - return TODO_MISSING_INDEX - - -def todo_projection_sort_key( - item: dict[str, Any], - *, - text_mode: str = "label", -) -> tuple[int, int]: - return (todo_priority_rank(item, text_mode=text_mode), todo_index_rank(item)) - - -def todo_claimed_visibility_items( - items: list[dict[str, Any]], - *, - limit: int, -) -> list[dict[str, Any]]: - if limit <= 0 or len(items) <= limit: - return items[:limit] - claim_order: list[str] = [] - buckets: dict[str, list[dict[str, Any]]] = {} - for item in items: - claimed_by = normalize_todo_claimed_by(item.get("claimed_by")) - if not claimed_by: - continue - if claimed_by not in buckets: - buckets[claimed_by] = [] - claim_order.append(claimed_by) - buckets[claimed_by].append(item) - if not buckets: - return items[:limit] - - original_index = {id(item): index for index, item in enumerate(items)} - per_claimant_cap = max(1, limit // len(buckets)) - selected: list[dict[str, Any]] = [] - selected_ids: set[int] = set() - for claimed_by in claim_order: - taken = 0 - for item in buckets[claimed_by]: - if taken >= per_claimant_cap: - break - if len(selected) >= limit: - break - selected.append(item) - selected_ids.add(id(item)) - taken += 1 - if len(selected) >= limit: - break - - if len(selected) < limit: - for item in items: - if id(item) in selected_ids: - continue - selected.append(item) - selected_ids.add(id(item)) - if len(selected) >= limit: - break - - return sorted( - selected, key=lambda item: original_index.get(id(item), TODO_MISSING_INDEX) - )[:limit] - - -def todo_item_task_text( - item: dict[str, Any], - *, - keys: tuple[str, ...] = ("title", "text"), -) -> str: - return " ".join( - str(item.get(key) or "") for key in keys if str(item.get(key) or "").strip() - ) - - -def todo_item_task_class( - item: dict[str, Any], - *, - task_text_keys: tuple[str, ...] = ("title", "text"), -) -> str: - return monitor_todo_task_class( - item, - task_text=todo_item_task_text(item, keys=task_text_keys), - ) - - -def todo_item_is_actionable_open(item: dict[str, Any]) -> bool: - return monitor_todo_is_actionable_open(item) - - -def todo_item_is_deferred(item: dict[str, Any]) -> bool: - return (normalize_todo_status(item.get("status")) or "") == TODO_STATUS_DEFERRED - - -def todo_item_next_due_at(item: dict[str, Any]) -> datetime | None: - return monitor_todo_next_due_at(item) - - -def todo_item_has_monitor_schedule(item: dict[str, Any]) -> bool: - return monitor_todo_has_schedule(item) - - -def todo_item_expires_at(item: dict[str, Any]) -> datetime | None: - return monitor_todo_expires_at(item) - - -def todo_item_is_expired_monitor( - item: dict[str, Any], *, now: datetime | None = None -) -> bool: - return monitor_todo_is_expired(item, now=now) - - -def todo_item_is_due_monitor( - item: dict[str, Any], - *, - now: datetime | None = None, - task_text_keys: tuple[str, ...] = ("title", "text"), -) -> bool: - return monitor_todo_is_due( - item, - now=now, - task_text=todo_item_task_text(item, keys=task_text_keys), - ) - - -def todo_item_missing_monitor_schedule( - item: dict[str, Any], - *, - now: datetime | None = None, - task_text_keys: tuple[str, ...] = ("title", "text"), -) -> bool: - return monitor_todo_missing_schedule( - item, - now=now, - task_text=todo_item_task_text(item, keys=task_text_keys), - ) - - -def todo_item_claimed_by_agent_or_unclaimed( - item: dict[str, Any], - *, - agent_id: str | None, -) -> bool: - if todo_item_has_removed_continuation_policy(item): - return False - normalized_agent_id = normalize_todo_claimed_by(agent_id) - if not normalized_agent_id: - return True - if normalized_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 == normalized_agent_id - - -def todo_advancement_frontier_items( - summary: dict[str, Any] | None, - *, - agent_id: str | None, -) -> dict[str, list[dict[str, Any]]]: - """Return the authoritative advancement frontier items grouped by claim ownership. - - Preserves the slot precedence of executable backlog first, falling back to - unclaimed priority and claimed advancement open items when the executable backlog - is omitted. Peer-claimed items are tracked separately and excluded from the current - agent's selectable advancement frontier. - """ - - empty: dict[str, list[dict[str, Any]]] = { - "current_agent_claimed_items": [], - "unclaimed_items": [], - "other_agent_claimed_items": [], - } - if not isinstance(summary, dict): - return empty - - normalized_agent_id = normalize_todo_claimed_by(agent_id) - executable_items = summary.get("executable_backlog_items") - if isinstance(executable_items, list): - current_items: list[dict[str, Any]] = [] - unclaimed_items: list[dict[str, Any]] = [] - other_items: list[dict[str, Any]] = [] - for value in executable_items: - if not isinstance(value, dict): - continue - if not todo_item_is_actionable_open(value): - continue - if todo_item_task_class(value) != TODO_TASK_CLASS_ADVANCEMENT: - continue - claimed_by = normalize_todo_claimed_by(value.get("claimed_by")) - if claimed_by: - if normalized_agent_id and claimed_by == normalized_agent_id: - if not todo_item_excludes_agent( - value, agent_id=normalized_agent_id - ): - current_items.append(value) - elif normalized_agent_id: - other_items.append(value) - else: - current_items.append(value) - continue - if not todo_item_excludes_agent(value, agent_id=normalized_agent_id): - unclaimed_items.append(value) - return { - "current_agent_claimed_items": current_items, - "unclaimed_items": unclaimed_items, - "other_agent_claimed_items": other_items, - } - - unclaimed_items = [ - value - for value in summary.get("unclaimed_priority_open_items") or [] - if isinstance(value, dict) - and todo_item_is_actionable_open(value) - and todo_item_task_class(value) == TODO_TASK_CLASS_ADVANCEMENT - and not todo_item_excludes_agent(value, agent_id=normalized_agent_id) - ] - current_items = [ - value - for value in summary.get("claimed_advancement_open_items") or [] - if isinstance(value, dict) - and todo_item_is_actionable_open(value) - and todo_item_task_class(value) == TODO_TASK_CLASS_ADVANCEMENT - and ( - not normalized_agent_id - or normalize_todo_claimed_by(value.get("claimed_by")) == normalized_agent_id - ) - and not todo_item_excludes_agent(value, agent_id=normalized_agent_id) - ] - other_items = [ - value - for value in summary.get("claimed_advancement_open_items") or [] - if isinstance(value, dict) - and todo_item_is_actionable_open(value) - and todo_item_task_class(value) == TODO_TASK_CLASS_ADVANCEMENT - and normalized_agent_id - and normalize_todo_claimed_by(value.get("claimed_by")) - and normalize_todo_claimed_by(value.get("claimed_by")) != normalized_agent_id - ] - return { - "current_agent_claimed_items": current_items, - "unclaimed_items": unclaimed_items, - "other_agent_claimed_items": other_items, - } - - -def agent_scoped_selectable_advancement_todo_ids( - agent_todo_summary: dict[str, Any] | None, - *, - agent_id: str | None, -) -> set[str]: - """Return the ids the agent-scoped selectable advancement frontier holds. - - Derived directly from the authoritative ``todo_advancement_frontier_items`` - helper so that slot precedence and claim ownership predicates never diverge - from the frontier counter. - """ - - frontier_items = todo_advancement_frontier_items( - agent_todo_summary, - agent_id=agent_id, - ) - selectable: set[str] = set() - for item in ( - frontier_items["current_agent_claimed_items"] - + frontier_items["unclaimed_items"] - ): - if todo_id := normalize_todo_id(item.get("todo_id")): - selectable.add(todo_id) - return selectable - - -def todo_advancement_frontier_counts( - summary: dict[str, Any] | None, - *, - agent_id: str | None, -) -> dict[str, int]: - """Classify the durable advancement frontier by exact claim ownership.""" - - if not isinstance(summary, dict): - return { - "current_agent_claimed_advancement_count": 0, - "unclaimed_advancement_count": 0, - "other_agent_claimed_advancement_count": 0, - } - frontier_items = todo_advancement_frontier_items(summary, agent_id=agent_id) - claim_scope = summary.get("claim_scope") - other_items = ( - claim_scope.get("other_agent_claimed_items") - if isinstance(claim_scope, dict) - else [] - ) - diagnostic_other_count = sum( - 1 - for value in other_items or [] - if isinstance(value, dict) - and todo_item_is_actionable_open(value) - and todo_item_task_class(value) == TODO_TASK_CLASS_ADVANCEMENT - ) - return { - "current_agent_claimed_advancement_count": max( - len(frontier_items["current_agent_claimed_items"]), - _positive_int(summary.get("current_agent_claimed_advancement_count")), - ), - "unclaimed_advancement_count": len(frontier_items["unclaimed_items"]), - "other_agent_claimed_advancement_count": max( - len(frontier_items["other_agent_claimed_items"]), - diagnostic_other_count, - ), - } - - -def todo_item_has_removed_continuation_policy(item: dict[str, Any]) -> bool: - return bool( - normalize_removed_todo_continuation_policy( - item.get("removed_continuation_policy") - ) - ) - - -def todo_item_excludes_agent( - item: dict[str, Any], - *, - agent_id: str | None, -) -> bool: - normalized_agent_id = normalize_todo_claimed_by(agent_id) - return bool( - normalized_agent_id - and normalized_agent_id - in normalize_todo_excluded_agents(item.get("excluded_agents")) - ) - - -def todo_summary_claim_scope_agent_id(summary: dict[str, Any] | None) -> str | None: - if not isinstance(summary, dict): - return None - claim_scope = summary.get("claim_scope") - if not isinstance(claim_scope, dict): - return None - return normalize_todo_claimed_by(claim_scope.get("agent_id")) - - -def todo_summary_monitor_writeback_contract( - summary: dict[str, Any] | None, -) -> dict[str, Any] | None: - if not isinstance(summary, dict): - return None - contract = summary.get("monitor_writeback") - if not isinstance(contract, dict): - return None - if contract.get("supported") is not False: - return None - compact: dict[str, Any] = {"supported": False} - source = str(contract.get("source") or "").strip() - if source: - compact["source"] = source - return compact - - -def todo_summary_monitor_writeback_supported(summary: dict[str, Any] | None) -> bool: - contract = todo_summary_monitor_writeback_contract(summary) - if not contract: - return True - return contract.get("supported") is not False - - -def todo_summary_monitor_items(summary: dict[str, Any] | None) -> list[dict[str, Any]]: - if not isinstance(summary, dict): - return [] - items: list[dict[str, Any]] = [] - seen: set[tuple[str, int]] = set() - for key in ( - "monitor_due_items", - "current_agent_claimed_monitor_items", - "monitor_open_items", - "claimed_monitor_open_items", - "first_open_items", - ): - values = summary.get(key) - if not isinstance(values, list): - continue - for value in values: - if not isinstance(value, dict): - continue - if not todo_item_is_actionable_open(value): - continue - if todo_item_task_class(value) != TODO_TASK_CLASS_MONITOR: - continue - identity = (normalize_todo_id(value.get("todo_id")) or "", id(value)) - if identity in seen: - continue - seen.add(identity) - items.append(value) - return items - - -def _summary_monitor_items( - summary: dict[str, Any] | None, - *, - projected_key: str, - predicate: Any, - task_text_keys: tuple[str, ...], - text_mode: str, -) -> list[dict[str, Any]]: - if not isinstance(summary, dict): - return [] - if not todo_summary_monitor_writeback_supported(summary): - return [] - projected_items = summary.get(projected_key) - if isinstance(projected_items, list): - items = [ - item - for item in projected_items - if isinstance(item, dict) - if todo_item_is_actionable_open(item) - if todo_item_task_class(item, task_text_keys=task_text_keys) - == TODO_TASK_CLASS_MONITOR - if predicate(item) - ] - else: - raw_items = summary.get("monitor_open_items") - items = [ - item - for item in (raw_items if isinstance(raw_items, list) else []) - if isinstance(item, dict) - if predicate(item) - ] - agent_id = todo_summary_claim_scope_agent_id(summary) - if agent_id: - items = [ - item - for item in items - if todo_item_claimed_by_agent_or_unclaimed(item, agent_id=agent_id) - ] - return sorted( - items, - key=lambda item: todo_projection_sort_key(item, text_mode=text_mode), - ) - - -def todo_summary_monitor_due_items( - summary: dict[str, Any] | None, - *, - task_text_keys: tuple[str, ...] = ("title", "text"), - text_mode: str = "label", -) -> list[dict[str, Any]]: - return _summary_monitor_items( - summary, - projected_key="monitor_due_items", - predicate=lambda item: todo_item_is_due_monitor( - item, - task_text_keys=task_text_keys, - ), - task_text_keys=task_text_keys, - text_mode=text_mode, - ) - - -def todo_summary_monitor_due_count( - summary: dict[str, Any] | None, - *, - due_items: list[dict[str, Any]] | None = None, - task_text_keys: tuple[str, ...] = ("title", "text"), - text_mode: str = "label", -) -> int: - if not isinstance(summary, dict): - return 0 - if not todo_summary_monitor_writeback_supported(summary): - return 0 - projected_count = summary.get("monitor_due_count") - if isinstance(projected_count, int): - return max(0, projected_count) - agent_id = todo_summary_claim_scope_agent_id(summary) - if agent_id: - raw_items = summary.get("monitor_open_items") - if isinstance(raw_items, list): - return len( - [ - item - for item in raw_items - if isinstance(item, dict) - if todo_item_is_due_monitor(item, task_text_keys=task_text_keys) - if todo_item_claimed_by_agent_or_unclaimed(item, agent_id=agent_id) - ] - ) - return len( - due_items - if due_items is not None - else todo_summary_monitor_due_items( - summary, - task_text_keys=task_text_keys, - text_mode=text_mode, - ) - ) - return len( - due_items - if due_items is not None - else todo_summary_monitor_due_items( - summary, - task_text_keys=task_text_keys, - text_mode=text_mode, - ) - ) - - -def todo_summary_monitor_schedule_gap_items( - summary: dict[str, Any] | None, - *, - task_text_keys: tuple[str, ...] = ("title", "text"), - text_mode: str = "label", -) -> list[dict[str, Any]]: - return _summary_monitor_items( - summary, - projected_key="monitor_schedule_gap_items", - predicate=lambda item: todo_item_missing_monitor_schedule( - item, - task_text_keys=task_text_keys, - ), - task_text_keys=task_text_keys, - text_mode=text_mode, - ) - - -def todo_summary_monitor_schedule_gap_count( - summary: dict[str, Any] | None, - *, - gap_items: list[dict[str, Any]] | None = None, - task_text_keys: tuple[str, ...] = ("title", "text"), - text_mode: str = "label", -) -> int: - if not isinstance(summary, dict): - return 0 - if not todo_summary_monitor_writeback_supported(summary): - return 0 - agent_id = todo_summary_claim_scope_agent_id(summary) - if agent_id: - raw_items = summary.get("monitor_open_items") - if isinstance(raw_items, list): - return len( - [ - item - for item in raw_items - if isinstance(item, dict) - if todo_item_missing_monitor_schedule( - item, - task_text_keys=task_text_keys, - ) - if todo_item_claimed_by_agent_or_unclaimed(item, agent_id=agent_id) - ] - ) - return len( - gap_items - if gap_items is not None - else todo_summary_monitor_schedule_gap_items( - summary, - task_text_keys=task_text_keys, - text_mode=text_mode, - ) - ) - projected_count = summary.get("monitor_schedule_gap_count") - if isinstance(projected_count, int): - return max(0, projected_count) - return len( - gap_items - if gap_items is not None - else todo_summary_monitor_schedule_gap_items( - summary, - task_text_keys=task_text_keys, - text_mode=text_mode, - ) - ) - - -def todo_summary_open_count(summary: dict[str, Any] | None) -> int: - if not isinstance(summary, dict): - return 0 - try: - return max(0, int(summary.get("open_count") or 0)) - except (TypeError, ValueError): - return 0 - - -def todo_summary_open_task_counts(summary: dict[str, Any] | None) -> dict[str, int]: - open_count = todo_summary_open_count(summary) - classified_items: list[dict[str, Any]] = [] - seen: set[tuple[Any, str]] = set() - executable_backlog_items: list[dict[str, Any]] | None = None - monitor_open_items: list[dict[str, Any]] | None = None - if isinstance(summary, dict): - raw_executable_backlog = summary.get("executable_backlog_items") - if isinstance(raw_executable_backlog, list): - executable_backlog_items = [ - item - for item in raw_executable_backlog - if isinstance(item, dict) - if todo_item_is_actionable_open(item) - if todo_item_task_class(item) == TODO_TASK_CLASS_ADVANCEMENT - ] - raw_monitor_open = summary.get("monitor_open_items") - if isinstance(raw_monitor_open, list): - monitor_open_items = [ - item - for item in raw_monitor_open - if isinstance(item, dict) - if todo_item_is_actionable_open(item) - if todo_item_task_class(item) == TODO_TASK_CLASS_MONITOR - ] - for key in ( - "first_executable_items", - "first_open_items", - "monitor_open_items", - ): - source_items = summary.get(key) - if not isinstance(source_items, list): - continue - for item in source_items: - if not isinstance(item, dict): - continue - text = str(item.get("text") or "").strip() - if not text: - continue - identity = (item.get("index"), text) - if identity in seen: - continue - seen.add(identity) - classified_items.append(item) - if executable_backlog_items is not None: - advancement_count = len(executable_backlog_items) - else: - visible_open = min(open_count, len(classified_items)) - advancement_visible_count = sum( - 1 - for item in classified_items[:visible_open] - if todo_item_is_actionable_open(item) - and todo_item_task_class(item) == TODO_TASK_CLASS_ADVANCEMENT - ) - hidden_count = max(0, open_count - visible_open) - advancement_count = advancement_visible_count + hidden_count - if monitor_open_items is not None: - monitor_visible_count = len(monitor_open_items) - else: - visible_open = min(open_count, len(classified_items)) - monitor_visible_count = sum( - 1 - for item in classified_items[:visible_open] - if todo_item_is_actionable_open(item) - and todo_item_task_class(item) == TODO_TASK_CLASS_MONITOR - ) - hidden_count = max(0, open_count - len(classified_items)) - return { - "open": open_count, - "advancement": advancement_count, - "monitor": monitor_visible_count, - "monitor_due": todo_summary_monitor_due_count(summary), - "monitor_schedule_gap": todo_summary_monitor_schedule_gap_count(summary), - "hidden": hidden_count, - } - - -def todo_summary_has_only_future_scoped_monitor_work( - summary: dict[str, Any] | None, -) -> bool: - """Return true when the scoped agent has only non-due monitor work left.""" - - agent_id = todo_summary_claim_scope_agent_id(summary) - if not agent_id or not isinstance(summary, dict): - return False - if not todo_summary_monitor_items(summary): - return False - if todo_summary_monitor_due_count(summary) > 0: - return False - if todo_summary_monitor_schedule_gap_count(summary) > 0: - return False - if _positive_int(summary.get("current_agent_claimed_advancement_count")) > 0: - return False - - for key in ( - "current_agent_claimed_advancement_items", - "unclaimed_priority_open_items", - "first_executable_items", - "executable_backlog_items", - ): - values = summary.get(key) - if not isinstance(values, list): - continue - for item in values: - if not isinstance(item, dict): - continue - if not todo_item_is_actionable_open(item): - continue - if todo_item_task_class(item) != TODO_TASK_CLASS_ADVANCEMENT: - continue - if todo_item_claimed_by_agent_or_unclaimed(item, agent_id=agent_id): - return False - return True - - -def _positive_int(value: Any) -> int: - try: - parsed = int(value) - except (TypeError, ValueError): - return 0 - return max(0, parsed) - - -def todo_summary_first_executable_item( - summary: dict[str, Any] | None, -) -> dict[str, Any] | None: - if not isinstance(summary, dict): - return None - raw_items = summary.get("first_executable_items") - items = raw_items if isinstance(raw_items, list) else [] - for item in items: - if not isinstance(item, dict): - continue - if not todo_item_is_actionable_open(item): - continue - if todo_item_task_class(item) != TODO_TASK_CLASS_ADVANCEMENT: - continue - return item - return None +from .todo_semantics import * # noqa: F401,F403 diff --git a/loopx/control_plane/todos/todo_semantics.py b/loopx/control_plane/todos/todo_semantics.py new file mode 100644 index 0000000000..048c0fa04e --- /dev/null +++ b/loopx/control_plane/todos/todo_semantics.py @@ -0,0 +1,813 @@ +from __future__ import annotations + +from datetime import datetime +import re +from typing import Any + +from ..scheduler.monitor_todo import ( + monitor_todo_expires_at, + monitor_todo_has_schedule, + monitor_todo_is_actionable_open, + monitor_todo_is_due, + monitor_todo_is_expired, + monitor_todo_missing_schedule, + monitor_todo_next_due_at, + monitor_todo_task_class, +) +from .contract import ( + TODO_STATUS_DEFERRED, + TODO_TASK_CLASS_ADVANCEMENT, + TODO_TASK_CLASS_MONITOR, + normalize_todo_claimed_by, + normalize_todo_excluded_agents, + normalize_removed_todo_continuation_policy, + normalize_todo_id, + normalize_todo_status, + normalize_todo_watch_only, +) + + +TODO_MISSING_PRIORITY_RANK = 50 +TODO_MISSING_INDEX = 999999 +TODO_PRIORITY_PREFIX_PATTERN = re.compile( + r"^\s*\[(P[0-4][^\]]*)\]\s*(.+)$", + re.IGNORECASE, +) +TODO_PRIORITY_LABEL_PATTERN = re.compile(r"\bP([0-4])\b", re.IGNORECASE) + + +def todo_item_is_watch_only_monitor(item: dict[str, Any]) -> bool: + return bool( + todo_item_task_class(item) == TODO_TASK_CLASS_MONITOR + and normalize_todo_watch_only(item.get("watch_only")) is True + ) + + +def todo_priority_parts(text: str) -> tuple[str | None, str]: + match = TODO_PRIORITY_PREFIX_PATTERN.match(text) + if not match: + return None, text + return match.group(1).strip().upper(), match.group(2).strip() + + +def todo_priority_label( + item: dict[str, Any], + *, + text_mode: str = "label", +) -> str | None: + priority = item.get("priority") + if isinstance(priority, str) and priority.strip(): + return priority.strip().upper() + text = " ".join( + str(value or "") + for value in (item.get("title"), item.get("text")) + if str(value or "").strip() + ) + if text_mode == "prefix": + priority, _ = todo_priority_parts(text) + return priority + match = TODO_PRIORITY_LABEL_PATTERN.search(text.upper()) + if not match: + return None + return f"P{match.group(1)}" + + +def todo_priority_rank(value: Any, *, text_mode: str = "label") -> int: + if isinstance(value, dict): + priority = todo_priority_label(value, text_mode=text_mode) + elif isinstance(value, str): + priority = value.strip().upper() + else: + priority = None + if not priority: + return TODO_MISSING_PRIORITY_RANK + match = re.match(r"P([0-4])", priority) + if not match: + return TODO_MISSING_PRIORITY_RANK + return int(match.group(1)) + + +def todo_index_rank(item: dict[str, Any]) -> int: + raw_index = item.get("index") + try: + return int(raw_index) if raw_index is not None else TODO_MISSING_INDEX + except (TypeError, ValueError): + return TODO_MISSING_INDEX + + +def todo_projection_sort_key( + item: dict[str, Any], + *, + text_mode: str = "label", +) -> tuple[int, int]: + return (todo_priority_rank(item, text_mode=text_mode), todo_index_rank(item)) + + +def todo_claimed_visibility_items( + items: list[dict[str, Any]], + *, + limit: int, +) -> list[dict[str, Any]]: + if limit <= 0 or len(items) <= limit: + return items[:limit] + claim_order: list[str] = [] + buckets: dict[str, list[dict[str, Any]]] = {} + for item in items: + claimed_by = normalize_todo_claimed_by(item.get("claimed_by")) + if not claimed_by: + continue + if claimed_by not in buckets: + buckets[claimed_by] = [] + claim_order.append(claimed_by) + buckets[claimed_by].append(item) + if not buckets: + return items[:limit] + + original_index = {id(item): index for index, item in enumerate(items)} + per_claimant_cap = max(1, limit // len(buckets)) + selected: list[dict[str, Any]] = [] + selected_ids: set[int] = set() + for claimed_by in claim_order: + taken = 0 + for item in buckets[claimed_by]: + if taken >= per_claimant_cap: + break + if len(selected) >= limit: + break + selected.append(item) + selected_ids.add(id(item)) + taken += 1 + if len(selected) >= limit: + break + + if len(selected) < limit: + for item in items: + if id(item) in selected_ids: + continue + selected.append(item) + selected_ids.add(id(item)) + if len(selected) >= limit: + break + + return sorted( + selected, key=lambda item: original_index.get(id(item), TODO_MISSING_INDEX) + )[:limit] + + +def todo_item_task_text( + item: dict[str, Any], + *, + keys: tuple[str, ...] = ("title", "text"), +) -> str: + return " ".join( + str(item.get(key) or "") for key in keys if str(item.get(key) or "").strip() + ) + + +def todo_item_task_class( + item: dict[str, Any], + *, + task_text_keys: tuple[str, ...] = ("title", "text"), +) -> str: + return monitor_todo_task_class( + item, + task_text=todo_item_task_text(item, keys=task_text_keys), + ) + + +def todo_item_is_actionable_open(item: dict[str, Any]) -> bool: + return monitor_todo_is_actionable_open(item) + + +def todo_item_is_deferred(item: dict[str, Any]) -> bool: + return (normalize_todo_status(item.get("status")) or "") == TODO_STATUS_DEFERRED + + +def todo_item_next_due_at(item: dict[str, Any]) -> datetime | None: + return monitor_todo_next_due_at(item) + + +def todo_item_has_monitor_schedule(item: dict[str, Any]) -> bool: + return monitor_todo_has_schedule(item) + + +def todo_item_expires_at(item: dict[str, Any]) -> datetime | None: + return monitor_todo_expires_at(item) + + +def todo_item_is_expired_monitor( + item: dict[str, Any], *, now: datetime | None = None +) -> bool: + return monitor_todo_is_expired(item, now=now) + + +def todo_item_is_due_monitor( + item: dict[str, Any], + *, + now: datetime | None = None, + task_text_keys: tuple[str, ...] = ("title", "text"), +) -> bool: + return monitor_todo_is_due( + item, + now=now, + task_text=todo_item_task_text(item, keys=task_text_keys), + ) + + +def todo_item_missing_monitor_schedule( + item: dict[str, Any], + *, + now: datetime | None = None, + task_text_keys: tuple[str, ...] = ("title", "text"), +) -> bool: + return monitor_todo_missing_schedule( + item, + now=now, + task_text=todo_item_task_text(item, keys=task_text_keys), + ) + + +def todo_item_claimed_by_agent_or_unclaimed( + item: dict[str, Any], + *, + agent_id: str | None, +) -> bool: + if todo_item_has_removed_continuation_policy(item): + return False + normalized_agent_id = normalize_todo_claimed_by(agent_id) + if not normalized_agent_id: + return True + if normalized_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 == normalized_agent_id + + +def todo_advancement_frontier_items( + summary: dict[str, Any] | None, + *, + agent_id: str | None, +) -> dict[str, list[dict[str, Any]]]: + """Return the authoritative advancement frontier items grouped by claim ownership. + + Preserves the slot precedence of executable backlog first, falling back to + unclaimed priority and claimed advancement open items when the executable backlog + is omitted. Peer-claimed items are tracked separately and excluded from the current + agent's selectable advancement frontier. + """ + + empty: dict[str, list[dict[str, Any]]] = { + "current_agent_claimed_items": [], + "unclaimed_items": [], + "other_agent_claimed_items": [], + } + if not isinstance(summary, dict): + return empty + + normalized_agent_id = normalize_todo_claimed_by(agent_id) + executable_items = summary.get("executable_backlog_items") + if isinstance(executable_items, list): + current_items: list[dict[str, Any]] = [] + unclaimed_items: list[dict[str, Any]] = [] + other_items: list[dict[str, Any]] = [] + for value in executable_items: + if not isinstance(value, dict): + continue + if not todo_item_is_actionable_open(value): + continue + if todo_item_task_class(value) != TODO_TASK_CLASS_ADVANCEMENT: + continue + claimed_by = normalize_todo_claimed_by(value.get("claimed_by")) + if claimed_by: + if normalized_agent_id and claimed_by == normalized_agent_id: + if not todo_item_excludes_agent( + value, agent_id=normalized_agent_id + ): + current_items.append(value) + elif normalized_agent_id: + other_items.append(value) + else: + current_items.append(value) + continue + if not todo_item_excludes_agent(value, agent_id=normalized_agent_id): + unclaimed_items.append(value) + return { + "current_agent_claimed_items": current_items, + "unclaimed_items": unclaimed_items, + "other_agent_claimed_items": other_items, + } + + unclaimed_items = [ + value + for value in summary.get("unclaimed_priority_open_items") or [] + if isinstance(value, dict) + and todo_item_is_actionable_open(value) + and todo_item_task_class(value) == TODO_TASK_CLASS_ADVANCEMENT + and not todo_item_excludes_agent(value, agent_id=normalized_agent_id) + ] + current_items = [ + value + for value in summary.get("claimed_advancement_open_items") or [] + if isinstance(value, dict) + and todo_item_is_actionable_open(value) + and todo_item_task_class(value) == TODO_TASK_CLASS_ADVANCEMENT + and ( + not normalized_agent_id + or normalize_todo_claimed_by(value.get("claimed_by")) == normalized_agent_id + ) + and not todo_item_excludes_agent(value, agent_id=normalized_agent_id) + ] + other_items = [ + value + for value in summary.get("claimed_advancement_open_items") or [] + if isinstance(value, dict) + and todo_item_is_actionable_open(value) + and todo_item_task_class(value) == TODO_TASK_CLASS_ADVANCEMENT + and normalized_agent_id + and normalize_todo_claimed_by(value.get("claimed_by")) + and normalize_todo_claimed_by(value.get("claimed_by")) != normalized_agent_id + ] + return { + "current_agent_claimed_items": current_items, + "unclaimed_items": unclaimed_items, + "other_agent_claimed_items": other_items, + } + + +def agent_scoped_selectable_advancement_todo_ids( + agent_todo_summary: dict[str, Any] | None, + *, + agent_id: str | None, +) -> set[str]: + """Return the ids the agent-scoped selectable advancement frontier holds. + + Derived directly from the authoritative ``todo_advancement_frontier_items`` + helper so that slot precedence and claim ownership predicates never diverge + from the frontier counter. + """ + + frontier_items = todo_advancement_frontier_items( + agent_todo_summary, + agent_id=agent_id, + ) + selectable: set[str] = set() + for item in ( + frontier_items["current_agent_claimed_items"] + + frontier_items["unclaimed_items"] + ): + if todo_id := normalize_todo_id(item.get("todo_id")): + selectable.add(todo_id) + return selectable + + +def todo_advancement_frontier_counts( + summary: dict[str, Any] | None, + *, + agent_id: str | None, +) -> dict[str, int]: + """Classify the durable advancement frontier by exact claim ownership.""" + + if not isinstance(summary, dict): + return { + "current_agent_claimed_advancement_count": 0, + "unclaimed_advancement_count": 0, + "other_agent_claimed_advancement_count": 0, + } + frontier_items = todo_advancement_frontier_items(summary, agent_id=agent_id) + claim_scope = summary.get("claim_scope") + other_items = ( + claim_scope.get("other_agent_claimed_items") + if isinstance(claim_scope, dict) + else [] + ) + diagnostic_other_count = sum( + 1 + for value in other_items or [] + if isinstance(value, dict) + and todo_item_is_actionable_open(value) + and todo_item_task_class(value) == TODO_TASK_CLASS_ADVANCEMENT + ) + return { + "current_agent_claimed_advancement_count": max( + len(frontier_items["current_agent_claimed_items"]), + _positive_int(summary.get("current_agent_claimed_advancement_count")), + ), + "unclaimed_advancement_count": len(frontier_items["unclaimed_items"]), + "other_agent_claimed_advancement_count": max( + len(frontier_items["other_agent_claimed_items"]), + diagnostic_other_count, + ), + } + + +def todo_item_has_removed_continuation_policy(item: dict[str, Any]) -> bool: + return bool( + normalize_removed_todo_continuation_policy( + item.get("removed_continuation_policy") + ) + ) + + +def todo_item_excludes_agent( + item: dict[str, Any], + *, + agent_id: str | None, +) -> bool: + normalized_agent_id = normalize_todo_claimed_by(agent_id) + return bool( + normalized_agent_id + and normalized_agent_id + in normalize_todo_excluded_agents(item.get("excluded_agents")) + ) + + +def todo_summary_claim_scope_agent_id(summary: dict[str, Any] | None) -> str | None: + if not isinstance(summary, dict): + return None + claim_scope = summary.get("claim_scope") + if not isinstance(claim_scope, dict): + return None + return normalize_todo_claimed_by(claim_scope.get("agent_id")) + + +def todo_summary_monitor_writeback_contract( + summary: dict[str, Any] | None, +) -> dict[str, Any] | None: + if not isinstance(summary, dict): + return None + contract = summary.get("monitor_writeback") + if not isinstance(contract, dict): + return None + if contract.get("supported") is not False: + return None + compact: dict[str, Any] = {"supported": False} + source = str(contract.get("source") or "").strip() + if source: + compact["source"] = source + return compact + + +def todo_summary_monitor_writeback_supported(summary: dict[str, Any] | None) -> bool: + contract = todo_summary_monitor_writeback_contract(summary) + if not contract: + return True + return contract.get("supported") is not False + + +def todo_summary_monitor_items(summary: dict[str, Any] | None) -> list[dict[str, Any]]: + if not isinstance(summary, dict): + return [] + items: list[dict[str, Any]] = [] + seen: set[tuple[str, int]] = set() + for key in ( + "monitor_due_items", + "current_agent_claimed_monitor_items", + "monitor_open_items", + "claimed_monitor_open_items", + "first_open_items", + ): + values = summary.get(key) + if not isinstance(values, list): + continue + for value in values: + if not isinstance(value, dict): + continue + if not todo_item_is_actionable_open(value): + continue + if todo_item_task_class(value) != TODO_TASK_CLASS_MONITOR: + continue + identity = (normalize_todo_id(value.get("todo_id")) or "", id(value)) + if identity in seen: + continue + seen.add(identity) + items.append(value) + return items + + +def _summary_monitor_items( + summary: dict[str, Any] | None, + *, + projected_key: str, + predicate: Any, + task_text_keys: tuple[str, ...], + text_mode: str, +) -> list[dict[str, Any]]: + if not isinstance(summary, dict): + return [] + if not todo_summary_monitor_writeback_supported(summary): + return [] + projected_items = summary.get(projected_key) + if isinstance(projected_items, list): + items = [ + item + for item in projected_items + if isinstance(item, dict) + if todo_item_is_actionable_open(item) + if todo_item_task_class(item, task_text_keys=task_text_keys) + == TODO_TASK_CLASS_MONITOR + if predicate(item) + ] + else: + raw_items = summary.get("monitor_open_items") + items = [ + item + for item in (raw_items if isinstance(raw_items, list) else []) + if isinstance(item, dict) + if predicate(item) + ] + agent_id = todo_summary_claim_scope_agent_id(summary) + if agent_id: + items = [ + item + for item in items + if todo_item_claimed_by_agent_or_unclaimed(item, agent_id=agent_id) + ] + return sorted( + items, + key=lambda item: todo_projection_sort_key(item, text_mode=text_mode), + ) + + +def todo_summary_monitor_due_items( + summary: dict[str, Any] | None, + *, + task_text_keys: tuple[str, ...] = ("title", "text"), + text_mode: str = "label", +) -> list[dict[str, Any]]: + return _summary_monitor_items( + summary, + projected_key="monitor_due_items", + predicate=lambda item: todo_item_is_due_monitor( + item, + task_text_keys=task_text_keys, + ), + task_text_keys=task_text_keys, + text_mode=text_mode, + ) + + +def todo_summary_monitor_due_count( + summary: dict[str, Any] | None, + *, + due_items: list[dict[str, Any]] | None = None, + task_text_keys: tuple[str, ...] = ("title", "text"), + text_mode: str = "label", +) -> int: + if not isinstance(summary, dict): + return 0 + if not todo_summary_monitor_writeback_supported(summary): + return 0 + projected_count = summary.get("monitor_due_count") + if isinstance(projected_count, int): + return max(0, projected_count) + agent_id = todo_summary_claim_scope_agent_id(summary) + if agent_id: + raw_items = summary.get("monitor_open_items") + if isinstance(raw_items, list): + return len( + [ + item + for item in raw_items + if isinstance(item, dict) + if todo_item_is_due_monitor(item, task_text_keys=task_text_keys) + if todo_item_claimed_by_agent_or_unclaimed(item, agent_id=agent_id) + ] + ) + return len( + due_items + if due_items is not None + else todo_summary_monitor_due_items( + summary, + task_text_keys=task_text_keys, + text_mode=text_mode, + ) + ) + return len( + due_items + if due_items is not None + else todo_summary_monitor_due_items( + summary, + task_text_keys=task_text_keys, + text_mode=text_mode, + ) + ) + + +def todo_summary_monitor_schedule_gap_items( + summary: dict[str, Any] | None, + *, + task_text_keys: tuple[str, ...] = ("title", "text"), + text_mode: str = "label", +) -> list[dict[str, Any]]: + return _summary_monitor_items( + summary, + projected_key="monitor_schedule_gap_items", + predicate=lambda item: todo_item_missing_monitor_schedule( + item, + task_text_keys=task_text_keys, + ), + task_text_keys=task_text_keys, + text_mode=text_mode, + ) + + +def todo_summary_monitor_schedule_gap_count( + summary: dict[str, Any] | None, + *, + gap_items: list[dict[str, Any]] | None = None, + task_text_keys: tuple[str, ...] = ("title", "text"), + text_mode: str = "label", +) -> int: + if not isinstance(summary, dict): + return 0 + if not todo_summary_monitor_writeback_supported(summary): + return 0 + agent_id = todo_summary_claim_scope_agent_id(summary) + if agent_id: + raw_items = summary.get("monitor_open_items") + if isinstance(raw_items, list): + return len( + [ + item + for item in raw_items + if isinstance(item, dict) + if todo_item_missing_monitor_schedule( + item, + task_text_keys=task_text_keys, + ) + if todo_item_claimed_by_agent_or_unclaimed(item, agent_id=agent_id) + ] + ) + return len( + gap_items + if gap_items is not None + else todo_summary_monitor_schedule_gap_items( + summary, + task_text_keys=task_text_keys, + text_mode=text_mode, + ) + ) + projected_count = summary.get("monitor_schedule_gap_count") + if isinstance(projected_count, int): + return max(0, projected_count) + return len( + gap_items + if gap_items is not None + else todo_summary_monitor_schedule_gap_items( + summary, + task_text_keys=task_text_keys, + text_mode=text_mode, + ) + ) + + +def todo_summary_open_count(summary: dict[str, Any] | None) -> int: + if not isinstance(summary, dict): + return 0 + try: + return max(0, int(summary.get("open_count") or 0)) + except (TypeError, ValueError): + return 0 + + +def todo_summary_open_task_counts(summary: dict[str, Any] | None) -> dict[str, int]: + open_count = todo_summary_open_count(summary) + classified_items: list[dict[str, Any]] = [] + seen: set[tuple[Any, str]] = set() + executable_backlog_items: list[dict[str, Any]] | None = None + monitor_open_items: list[dict[str, Any]] | None = None + if isinstance(summary, dict): + raw_executable_backlog = summary.get("executable_backlog_items") + if isinstance(raw_executable_backlog, list): + executable_backlog_items = [ + item + for item in raw_executable_backlog + if isinstance(item, dict) + if todo_item_is_actionable_open(item) + if todo_item_task_class(item) == TODO_TASK_CLASS_ADVANCEMENT + ] + raw_monitor_open = summary.get("monitor_open_items") + if isinstance(raw_monitor_open, list): + monitor_open_items = [ + item + for item in raw_monitor_open + if isinstance(item, dict) + if todo_item_is_actionable_open(item) + if todo_item_task_class(item) == TODO_TASK_CLASS_MONITOR + ] + for key in ( + "first_executable_items", + "first_open_items", + "monitor_open_items", + ): + source_items = summary.get(key) + if not isinstance(source_items, list): + continue + for item in source_items: + if not isinstance(item, dict): + continue + text = str(item.get("text") or "").strip() + if not text: + continue + identity = (item.get("index"), text) + if identity in seen: + continue + seen.add(identity) + classified_items.append(item) + if executable_backlog_items is not None: + advancement_count = len(executable_backlog_items) + else: + visible_open = min(open_count, len(classified_items)) + advancement_visible_count = sum( + 1 + for item in classified_items[:visible_open] + if todo_item_is_actionable_open(item) + and todo_item_task_class(item) == TODO_TASK_CLASS_ADVANCEMENT + ) + hidden_count = max(0, open_count - visible_open) + advancement_count = advancement_visible_count + hidden_count + if monitor_open_items is not None: + monitor_visible_count = len(monitor_open_items) + else: + visible_open = min(open_count, len(classified_items)) + monitor_visible_count = sum( + 1 + for item in classified_items[:visible_open] + if todo_item_is_actionable_open(item) + and todo_item_task_class(item) == TODO_TASK_CLASS_MONITOR + ) + hidden_count = max(0, open_count - len(classified_items)) + return { + "open": open_count, + "advancement": advancement_count, + "monitor": monitor_visible_count, + "monitor_due": todo_summary_monitor_due_count(summary), + "monitor_schedule_gap": todo_summary_monitor_schedule_gap_count(summary), + "hidden": hidden_count, + } + + +def todo_summary_has_only_future_scoped_monitor_work( + summary: dict[str, Any] | None, +) -> bool: + """Return true when the scoped agent has only non-due monitor work left.""" + + agent_id = todo_summary_claim_scope_agent_id(summary) + if not agent_id or not isinstance(summary, dict): + return False + if not todo_summary_monitor_items(summary): + return False + if todo_summary_monitor_due_count(summary) > 0: + return False + if todo_summary_monitor_schedule_gap_count(summary) > 0: + return False + if _positive_int(summary.get("current_agent_claimed_advancement_count")) > 0: + return False + + for key in ( + "current_agent_claimed_advancement_items", + "unclaimed_priority_open_items", + "first_executable_items", + "executable_backlog_items", + ): + values = summary.get(key) + if not isinstance(values, list): + continue + for item in values: + if not isinstance(item, dict): + continue + if not todo_item_is_actionable_open(item): + continue + if todo_item_task_class(item) != TODO_TASK_CLASS_ADVANCEMENT: + continue + if todo_item_claimed_by_agent_or_unclaimed(item, agent_id=agent_id): + return False + return True + + +def _positive_int(value: Any) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return 0 + return max(0, parsed) + + +def todo_summary_first_executable_item( + summary: dict[str, Any] | None, +) -> dict[str, Any] | None: + if not isinstance(summary, dict): + return None + raw_items = summary.get("first_executable_items") + items = raw_items if isinstance(raw_items, list) else [] + for item in items: + if not isinstance(item, dict): + continue + if not todo_item_is_actionable_open(item): + continue + if todo_item_task_class(item) != TODO_TASK_CLASS_ADVANCEMENT: + continue + return item + return None diff --git a/loopx/control_plane/todos/todo_summary.py b/loopx/control_plane/todos/todo_summary.py index 8558b0e867..6b5524785d 100644 --- a/loopx/control_plane/todos/todo_summary.py +++ b/loopx/control_plane/todos/todo_summary.py @@ -547,7 +547,7 @@ def compact_active_next_action_todo_item(item: dict[str, Any]) -> dict[str, Any] def todo_item_task_class(item: dict[str, Any]) -> str: - return projection_todo_item_task_class(item, task_text_keys=("text",)) + return projection_todo_item_task_class(item) def count_advancement_todos(items: list[dict[str, Any]]) -> int: diff --git a/loopx/control_plane/turn_driver/delivery_continuity.py b/loopx/control_plane/turn_driver/delivery_continuity.py index 4a6643dffc..52dc693874 100644 --- a/loopx/control_plane/turn_driver/delivery_continuity.py +++ b/loopx/control_plane/turn_driver/delivery_continuity.py @@ -16,7 +16,7 @@ normalize_todo_id, normalize_todo_status, ) -from ..todos.projection import todo_item_task_class +from ..todos.todo_semantics import todo_item_task_class DELIVERY_BOUNDARY_IN_FLIGHT = "in_flight_continuation" DELIVERY_BOUNDARY_SEMANTIC_CLOSEOUT = "semantic_closeout" diff --git a/loopx/control_plane/work_items/capability_monitor_fallback.py b/loopx/control_plane/work_items/capability_monitor_fallback.py index c830ef450c..801fb620c0 100644 --- a/loopx/control_plane/work_items/capability_monitor_fallback.py +++ b/loopx/control_plane/work_items/capability_monitor_fallback.py @@ -4,7 +4,7 @@ from ..agents.capability_gate import build_capability_gate from ..todos.contract import TODO_TASK_CLASS_ADVANCEMENT, TODO_TASK_CLASS_MONITOR -from ..todos.projection import todo_item_task_class +from ..todos.todo_semantics import todo_item_task_class from ..todos.summary_item import compact_todo_summary_item diff --git a/loopx/control_plane/work_items/delivery_history.py b/loopx/control_plane/work_items/delivery_history.py index 5de7cf52fb..e0cc34b590 100644 --- a/loopx/control_plane/work_items/delivery_history.py +++ b/loopx/control_plane/work_items/delivery_history.py @@ -88,7 +88,7 @@ def project_delivery_response( ) -> dict[str, Any]: """Select a canonical source row; TS alone decides its supervision meaning.""" from ..todos.summary_item import todo_planning_source_items - from ..todos.projection import todo_summary_claim_scope_agent_id + from ..todos.todo_semantics import todo_summary_claim_scope_agent_id source = next((item for item in todo_planning_source_items(summary, include_terminal=True) if item.get("todo_id") == run.get("todo_id")), None) if summary else None diff --git a/loopx/control_plane/work_items/interaction_contract.py b/loopx/control_plane/work_items/interaction_contract.py index 79d38377ff..6fcf2fd5e4 100644 --- a/loopx/control_plane/work_items/interaction_contract.py +++ b/loopx/control_plane/work_items/interaction_contract.py @@ -30,7 +30,7 @@ normalize_todo_id, normalize_todo_replan_obligation_id, ) -from ..todos.projection import todo_item_task_class +from ..todos.todo_semantics import todo_item_task_class from ..todos.user_gate import open_todo_count from ..todos.write_hint import build_capability_resolution_writeback_actions from .autonomous_replan_obligation import ( diff --git a/loopx/control_plane/work_items/planning_inventory.py b/loopx/control_plane/work_items/planning_inventory.py index 5d115d5e41..12283fd730 100644 --- a/loopx/control_plane/work_items/planning_inventory.py +++ b/loopx/control_plane/work_items/planning_inventory.py @@ -9,7 +9,7 @@ normalize_todo_claimed_by, normalize_todo_id, ) -from ..todos.projection import ( +from ..todos.todo_semantics import ( todo_item_is_actionable_open, todo_item_task_class, ) diff --git a/loopx/control_plane/work_items/primary_action.py b/loopx/control_plane/work_items/primary_action.py index d49a54ea74..a4c53af5f7 100644 --- a/loopx/control_plane/work_items/primary_action.py +++ b/loopx/control_plane/work_items/primary_action.py @@ -7,7 +7,7 @@ agent_scope_frontier_action as _agent_scope_frontier_action, ) from ..todos.contract import TODO_TASK_CLASS_ADVANCEMENT -from ..todos.projection import todo_item_is_actionable_open, todo_item_task_class +from ..todos.todo_semantics import todo_item_is_actionable_open, todo_item_task_class from .autonomous_replan_obligation import todo_lifecycle_settlement_obligation diff --git a/loopx/control_plane/work_items/repair_delta.py b/loopx/control_plane/work_items/repair_delta.py index 5a64b26c51..008e6a7037 100644 --- a/loopx/control_plane/work_items/repair_delta.py +++ b/loopx/control_plane/work_items/repair_delta.py @@ -19,7 +19,7 @@ normalize_todo_resume_when, normalize_todo_status, ) -from ..todos.projection import ( +from ..todos.todo_semantics import ( todo_item_claimed_by_agent_or_unclaimed, todo_item_expires_at, todo_item_is_actionable_open, diff --git a/loopx/control_plane/work_items/user_action_frontier.py b/loopx/control_plane/work_items/user_action_frontier.py index 3c02e1eefd..07cdaa0612 100644 --- a/loopx/control_plane/work_items/user_action_frontier.py +++ b/loopx/control_plane/work_items/user_action_frontier.py @@ -3,7 +3,7 @@ from typing import Any from ..todos.contract import TODO_TASK_CLASS_USER_ACTION -from ..todos.projection import todo_item_task_class +from ..todos.todo_semantics import todo_item_task_class def user_action_owns_empty_agent_lane(payload: dict[str, Any]) -> bool: diff --git a/loopx/control_plane/work_items/work_lane.py b/loopx/control_plane/work_items/work_lane.py index 4525333377..ea8ba4d2f4 100644 --- a/loopx/control_plane/work_items/work_lane.py +++ b/loopx/control_plane/work_items/work_lane.py @@ -4,7 +4,7 @@ from ..effect_program import ReceiptBoundMonitorPhase from ..todos.contract import TODO_TASK_CLASS_MONITOR, normalize_todo_id -from ..todos.projection import todo_priority_label, todo_priority_rank +from ..todos.todo_semantics import todo_priority_label, todo_priority_rank WORK_LANE_CONTRACT_SCHEMA_VERSION = "work_lane_contract_v1" WORK_LANE_RECEIPT_BOUND_MONITOR_SETTLEMENT_OBLIGATION = ( diff --git a/loopx/control_plane/work_items/work_lane_context.py b/loopx/control_plane/work_items/work_lane_context.py index 94aabf3bd0..248eb2c017 100644 --- a/loopx/control_plane/work_items/work_lane_context.py +++ b/loopx/control_plane/work_items/work_lane_context.py @@ -5,7 +5,7 @@ from ..agents.agent_scope import _agent_scope_monitor_blocked_resume_candidates from ..scheduler.external_evidence_observation import build_external_evidence_poll_signal from ..todos.contract import next_action_requires_advancement_text -from ..todos.projection import ( +from ..todos.todo_semantics import ( todo_summary_claim_scope_agent_id, todo_summary_first_executable_item, todo_summary_monitor_due_count, diff --git a/loopx/extensions/lark/presentation/kanban.py b/loopx/extensions/lark/presentation/kanban.py index 62296ffcfb..166f22c6f7 100644 --- a/loopx/extensions/lark/presentation/kanban.py +++ b/loopx/extensions/lark/presentation/kanban.py @@ -2196,7 +2196,7 @@ def sync_loopx_todos_to_lark_kanban( from ....capabilities.issue_fix.outcome_projection import ( build_issue_fix_outcome_collection_from_domain_state, ) - from ....control_plane.todos.projection import todo_priority_label + from ....control_plane.todos.todo_semantics import todo_priority_label from ....todos import resolve_todo_state_path, section_bounds, todo_blocks resolved_project, resolved_state_file = resolve_todo_state_path( diff --git a/loopx/quota.py b/loopx/quota.py index e84daed8d8..410cec4a87 100644 --- a/loopx/quota.py +++ b/loopx/quota.py @@ -98,7 +98,7 @@ normalize_todo_claimed_by, normalize_todo_id, ) -from .control_plane.todos.projection import ( +from .control_plane.todos.todo_semantics import ( todo_index_rank as projection_todo_index_rank, todo_item_expires_at as projection_todo_item_expires_at, todo_item_is_due_monitor as projection_todo_item_is_due_monitor, diff --git a/loopx/status.py b/loopx/status.py index 5f605fcecf..09fdf909e4 100644 --- a/loopx/status.py +++ b/loopx/status.py @@ -196,7 +196,7 @@ normalize_todo_task_class as normalize_todo_task_class, todo_done_for_status, ) -from .control_plane.todos.projection import ( +from .control_plane.todos.todo_semantics import ( todo_item_is_expired_monitor as todo_item_is_expired_monitor, ) diff --git a/tests/control_plane/test_todo_semantic_kernel.py b/tests/control_plane/test_todo_semantic_kernel.py new file mode 100644 index 0000000000..45235b12ce --- /dev/null +++ b/tests/control_plane/test_todo_semantic_kernel.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from loopx.control_plane.todos.todo_semantics import ( + TODO_TASK_CLASS_ADVANCEMENT, + TODO_TASK_CLASS_MONITOR, + todo_item_claimed_by_agent_or_unclaimed, + todo_item_is_due_monitor, + todo_item_task_class, +) +from loopx.control_plane.todos.todo_summary import todo_item_task_class as summary_task_class + + +FIXTURE = Path(__file__).parents[1] / "fixtures/control_plane/coordination_production_scale_v0.json" + + +def test_summary_and_projection_share_title_aware_task_classification() -> None: + item = {"title": "Observe dependency health", "text": "", "status": "open"} + assert todo_item_task_class(item) == TODO_TASK_CLASS_MONITOR + assert summary_task_class(item) == TODO_TASK_CLASS_MONITOR + assert todo_item_is_due_monitor( + {**item, "next_due_at": "2025-01-01T00:00:00Z"}, + now=__import__("datetime").datetime.fromisoformat("2025-01-01T00:00:00+00:00"), + ) + + +def test_exclusion_is_part_of_selectability_not_claim_ownership() -> None: + item = {"task_class": TODO_TASK_CLASS_ADVANCEMENT, "status": "open", "excluded_agents": ["agent-a"]} + assert not todo_item_claimed_by_agent_or_unclaimed(item, agent_id="agent-a") + assert todo_item_claimed_by_agent_or_unclaimed(item, agent_id="agent-b") + + +def test_complex_fixture_declares_cross_rfc_semantic_edges() -> None: + cases = json.loads(FIXTURE.read_text())["semantic_cases"] + assert cases["global_gate_without_goal_binding"]["global_gate"] is True + assert cases["global_gate_without_goal_binding"]["goal_bound"] is False + assert cases["expired_lease"]["lease_epoch"] == 7 + assert cases["excluded_unclaimed_advancement"]["claimed_by"] is None From 53acd864ae5805ed4abb5c4dd18513f3ea33876f Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 14:56:40 +0800 Subject: [PATCH 02/12] docs(todo): specify semantic kernel convergence Signed-off-by: huangruiteng --- docs/architecture/rfcs/README.md | 15 +- .../rfcs/canonical-todo-semantic-kernel-v0.md | 191 ++++++++++++++++++ ...canonical-todo-semantic-kernel-v0.zh-CN.md | 152 ++++++++++++++ .../production_scale_coordination_fixture.ts | 3 + .../todo_semantic_fixture.test.ts | 16 ++ .../coordination_production_scale_v0.json | 44 +++- 6 files changed, 413 insertions(+), 8 deletions(-) create mode 100644 docs/architecture/rfcs/canonical-todo-semantic-kernel-v0.md create mode 100644 docs/architecture/rfcs/canonical-todo-semantic-kernel-v0.zh-CN.md create mode 100644 tests/control_plane_ts/todo_semantic_fixture.test.ts diff --git a/docs/architecture/rfcs/README.md b/docs/architecture/rfcs/README.md index 5ce1f8bf2a..a1f8fe1cea 100644 --- a/docs/architecture/rfcs/README.md +++ b/docs/architecture/rfcs/README.md @@ -48,13 +48,14 @@ changes. ## Control-Plane Kernel, State, And Migration -- [Human-confirmed domain operations v0](human-confirmed-domain-operations-v0.md) - ([中文版](human-confirmed-domain-operations-v0.zh-CN.md)) - - **RFC status:** Draft. - - **Delivery on `main`:** Proposal only. - - **Current boundary:** Separates generic authenticated interaction, optional - financial execution and venue adapters. Defines shared frontend/Lark - confirmation and automatic outcomes; no runtime or trading permission added. +- [Canonical Todo Semantic Kernel and Read-Model Convergence v0](canonical-todo-semantic-kernel-v0.md) + ([中文版](canonical-todo-semantic-kernel-v0.zh-CN.md)) + - **RFC status:** Proposed implementation RFC. + - **Delivery on `main`:** Not yet shipped; this branch contains the first + compatibility-preserving extraction. + - **Current boundary:** Python Todo read semantics have one kernel and an + import-only projection facade; the TypeScript semantic packet and facade + retirement remain follow-up work. - [Agent Loop Effect Interpreter v0](agent-loop-effect-interpreter-v0.md) ([中文版](agent-loop-effect-interpreter-v0.zh-CN.md)) - **RFC status:** Accepted. diff --git a/docs/architecture/rfcs/canonical-todo-semantic-kernel-v0.md b/docs/architecture/rfcs/canonical-todo-semantic-kernel-v0.md new file mode 100644 index 0000000000..3260f5a9c2 --- /dev/null +++ b/docs/architecture/rfcs/canonical-todo-semantic-kernel-v0.md @@ -0,0 +1,191 @@ +# RFC: Canonical Todo Semantic Kernel and Read-Model Convergence (v0) + +- Status: Proposed implementation RFC +- Proposed by: LoopX maintainers +- Date: 2026-09-12 +- Scope: the Todo semantic rules shared by the TypeScript authority path and + Python projections, building on the [TypeScript Control-Plane Migration + v0](typescript-control-plane-migration-v0.md) and the [Shared Goal Authority + and Pluggable State Providers v0](shared-goal-authority-state-provider-v0.md) +- Delivery shape: one compatibility-preserving Python kernel first, followed + by a language-neutral conformance packet for the TypeScript transaction path +- Language note: the [Chinese version](canonical-todo-semantic-kernel-v0.zh-CN.md) + is a semantic mirror; a difference is a defect. + +## 1. Problem + +The two parent RFCs correctly moved authority and transaction ownership toward +TypeScript, but their consumers still carried several copies of the same Todo +rules. `todos/projection.py` owned task classification, priority, monitor +eligibility, claim visibility, and frontier selection. `todos/todo_summary.py` +wrapped many of those functions again and intentionally called classification +with `text` only. Agent scope, quota preparation, Goal Frontier, work-lane +selection, and capability fallbacks each imported or rewrapped a subset. + +That split created two kinds of drift: + +1. the same record could be classified differently depending on whether the + caller read a projection or a summary; and +2. a rule fix had to be repeated across compatibility facades, even though the + canonical coordination record and the provider transaction already had one + identity contract. + +A title-only monitor is a concrete failure. A Todo with `title="Observe build +health"` and an empty `text` was monitor work in the projection path but could +be treated as advancement in the summary path. Claim exclusion had the same +risk when one caller looked at a projected frontier and another rebuilt it from +summary buckets. + +## 2. Decision + +Create `loopx.control_plane.todos.todo_semantics` as the single Python owner for +provider-neutral Todo read semantics. It owns the pure predicates and ordering +used by all callers: + +- task text and task-class resolution; +- open/actionable/deferred status; +- monitor due, expiry, and missing-schedule checks; +- priority extraction, ranking, and stable ordering; +- claim, exclusion, and removed-continuation eligibility; +- claimed visibility and advancement-frontier partitioning. + +`todos/projection.py` remains a compatibility export for external integrations +and older extensions. It contains no independent rule implementation. New +production code imports `todo_semantics` directly. `todo_summary.py` continues +to own summary assembly, compaction, and display-specific limits, but it calls +the kernel for every semantic predicate. + +The kernel resolves task text from `title` and `text` in that order, retaining +both values when present. A persisted explicit `task_class` still wins over +text inference. This fixes title-only records while preserving existing +explicit classifications and action-kind inference. + +The coordination provider remains a storage and CAS boundary. The kernel does +not become a writer, receipt authority, scheduler, or provider adapter. The +TypeScript authority remains the transaction owner described by the parent +RFCs; this RFC removes duplicated Python read policy so the next TypeScript +cutover can compare one semantic packet instead of several ad hoc projections. + +## 3. Semantic changes and repairs + +### 3.1 Title-aware classification + +All Python read paths now use the same `(title, text)` task text. Explicit +`task_class` continues to take precedence. A title-only monitor is therefore a +monitor in status, quota, Goal Frontier, and summary paths. This is a behavior +fix, not a cosmetic rename. + +### 3.2 Exclusion is eligibility, not ownership + +`excluded_agents` is evaluated after claim ownership. An unclaimed Todo can be +visible to one Agent and ineligible for another; exclusion never creates a +claim, lease, or authority grant. The kernel exposes this as one predicate so +frontier counters and selected IDs cannot disagree. + +### 3.3 Stable ordering is one policy + +Priority and index ordering are resolved in one place. Missing or malformed +priority remains the bounded rank used by existing projections. The compatibility +facade preserves the old import path and call signature while production callers +move to the kernel. + +### 3.4 Display limits remain non-semantic + +The kernel never treats a truncated list as the complete Todo set. Summary +assembly may cap display rows, but counts, frontier classification, and +coordination digests continue to use the complete source or an explicitly +qualified canonical projection. + +## 4. Ownership and non-goals + +This RFC owns read semantics only. It does not: + +- change the default provider, promote SQLite/NoKV/PostgreSQL, or migrate an + existing Goal; +- add a new lease, receipt, scheduler, or network authority; +- make Markdown a generated source of truth; +- infer user approval from `goal_bound`, actor identity, or title text; +- remove the compatibility import until downstream extension inventory and the + TypeScript conformance packet are complete. + +The capability owner is the existing Todo/control-plane contract. No new +capability or provider package is introduced. + +## 5. Migration plan + +1. **Kernel extraction (this slice).** Move the existing projection rule set to + `todo_semantics.py`, make `projection.py` a compatibility export, and migrate + production imports. Keep external import paths working. +2. **Summary convergence (this slice).** Remove the text-only classification + exception in `todo_summary.py`; summary limits and compaction remain local. +3. **Fixture conformance (this slice).** Extend the deterministic production- + scale fixture with title-only monitor, excluded unclaimed advancement, + explicit global gate without goal binding, and an expired lease edge. These + are synthetic declarations, not copied Goal state. +4. **TypeScript packet (next slice).** Emit the same semantic cases into the + language-neutral contract and compare TypeScript selection/classification + results with the Python kernel before deleting more Python adapters. +5. **Compatibility retirement (later).** After extension imports and the + TypeScript packet are audited, deprecate the `projection.py` facade in one + disclosed release and remove it only when no supported caller remains. + +Each step is reversible: the facade can be restored as an import-only shim, and +no provider selector or persisted revision changes. + +## 6. Validation contract + +The focused Python tests must prove title-aware classification, monitor due +behavior, exclusion/ownership separation, and fixture edge declarations. The +existing projection, canonical-governance, frontier, and long-history suites +must remain green. TypeScript typecheck and coordination/monitor/quota tests +must remain green because the fixture is consumed by both runtime families. + +The real local readback is deliberately read-only: + +```bash +loopx --format json status --goal-id loopx-meta +loopx --format json todo list --goal-id loopx-meta --role agent --status open +``` + +The first command verifies the live registry/runtime contract; the second +checks that the live Goal can still be read through the public Todo surface. +Neither command writes the Goal, changes a lease, or promotes a provider. A +non-zero result is a delivery hold rather than a reason to weaken the fixture. + +## 7. Acceptance criteria + +- `projection.py` has no independent semantic implementation. +- All production imports use `todo_semantics` or the summary assembler. +- The title-only monitor test passes through both direct and summary paths. +- The complex fixture remains deterministic, public-safe, and consumed by the + existing TypeScript conformance tests. +- Python focused tests, TypeScript typecheck/tests, `git diff --check`, and the + read-only `loopx-meta` commands pass. +- No private Goal state, credential, raw run log, local absolute path, or + generated artifact is committed. + +## 8. Duplication inventory + +Before this RFC, duplication was structural rather than byte-for-byte only: + +| Area | Repeated knowledge | Consequence | +| --- | --- | --- | +| `projection.py` and `todo_summary.py` | task classification, actionable status, priority ordering, monitor predicates | title/text disagreement and repeated fixes | +| agent scope and quota preparation | local wrappers around actionable/classification predicates | callers could silently choose different helper defaults | +| Goal Frontier and work-lane consumers | claim/exclusion and frontier partitioning | counters and selected IDs could diverge | +| Python projection and TypeScript transaction consumers | canonical Todo fields and provider read-model shape | migration required several comparison points | + +The first three rows are consolidated here. The fourth remains an explicit +cross-language conformance task for the next RFC slice; this PR does not claim +to delete the TypeScript authority or alter provider semantics. + +## 9. Open questions + +- Which supported extension release first stops importing `projection.py`? +- Should the TypeScript packet be generated from the coordination state contract + or remain a separately versioned semantic fixture? +- What evidence is sufficient to retire the compatibility facade without + surprising local plugins? + +Those questions do not block this read-policy consolidation because the facade +preserves the current public import surface. diff --git a/docs/architecture/rfcs/canonical-todo-semantic-kernel-v0.zh-CN.md b/docs/architecture/rfcs/canonical-todo-semantic-kernel-v0.zh-CN.md new file mode 100644 index 0000000000..f5f0341117 --- /dev/null +++ b/docs/architecture/rfcs/canonical-todo-semantic-kernel-v0.zh-CN.md @@ -0,0 +1,152 @@ +# RFC:Canonical Todo 语义内核与读模型收敛(v0) + +- 状态:实现提案 +- 提议者:LoopX maintainers +- 日期:2026-09-12 +- 范围:收敛 TypeScript authority 路径与 Python projection 共用的 Todo + 语义规则,建立在 [TypeScript Control-Plane Migration v0](typescript-control-plane-migration-v0.md) + 与 [Shared Goal Authority and Pluggable State Providers v0](shared-goal-authority-state-provider-v0.md) + 之上 +- 交付形态:先提供一个保持兼容的 Python 语义内核,再为 TypeScript 事务路径 + 增加语言中立的 conformance packet +- 语言说明:[英文版本](canonical-todo-semantic-kernel-v0.md) 与本文必须语义镜像; + 存在差异即为缺陷。 + +## 1. 问题 + +两份父 RFC 已经把 authority 与事务所有权逐步收敛到 TypeScript,但 Todo +消费者仍然保留了多份相同规则。`todos/projection.py` 负责任务分类、优先级、 +monitor eligibility、claim visibility 和 frontier 选择; +`todos/todo_summary.py` 又重新包装了其中多数函数,并且刻意只用 `text` 做分类。 +agent scope、quota preparation、Goal Frontier、work-lane selection 和 capability +fallback 也各自导入或包装了其中一部分。 + +因此出现两类漂移:同一 Todo 在 projection 和 summary 路径上可能得到不同分类; +修复一条规则时,必须重复修改多个 facade。最直观的失败是只有 +`title="Observe build health"` 而 `text` 为空的 monitor:projection 能识别,summary +却可能把它当成 advancement。`excluded_agents` 也有同样风险:一个调用方读 +frontier,另一个调用方从 summary bucket 重建,结果可能不一致。 + +## 2. 决策 + +新增 `loopx.control_plane.todos.todo_semantics`,作为 provider-neutral Todo 读语义的 +唯一 Python owner。它负责: + +- task text 与 task-class 解析; +- open/actionable/deferred 状态; +- monitor 到期、过期、缺少 schedule; +- priority 提取、排序与稳定顺序; +- claim、排除与已移除 continuation 的 eligibility; +- claimed visibility 与 advancement frontier 分区。 + +`todos/projection.py` 保留为兼容导出,供旧扩展继续使用,但不再包含独立规则实现。 +新的生产代码直接导入 `todo_semantics`。`todo_summary.py` 继续负责 summary 组装、 +压缩和展示限制,但所有语义 predicate 都调用内核。 + +内核按 `title`、`text` 的顺序组合任务文本;显式持久化的 `task_class` 仍然优先于 +文本推断。这样修复只有 title 的 Todo,同时保留现有显式分类和 action-kind 推断。 + +coordination provider 仍然只是 storage/CAS 边界。内核不成为 writer、receipt authority、 +scheduler 或 provider adapter。TypeScript authority 仍是父 RFC 定义的事务 owner;本 RFC +只删除重复的 Python read policy,让下一步 TypeScript cutover 可以比较一份语义 packet。 + +## 3. 语义变化与修复 + +### 3.1 分类现在识别 title + +所有 Python 读路径统一使用 `(title, text)`。显式 `task_class` 仍优先。只有 title 的 +monitor 会在 status、quota、Goal Frontier 和 summary 中保持 monitor 分类。这是行为修复, +不是重命名。 + +### 3.2 排除属于 eligibility,不属于 ownership + +`excluded_agents` 在 claim ownership 之后判断。一个未 claim 的 Todo 可以对某个 Agent 可见, +同时对另一个 Agent 不可执行;排除不会制造 claim、lease 或 authority 授权。内核提供单一 +predicate,确保 frontier count 与 selected id 不再分叉。 + +### 3.3 稳定排序只有一套规则 + +priority 和 index 在同一个 owner 中解析。缺失或非法 priority 继续使用现有 projection 的 +有界 rank。兼容 facade 保留旧 import 路径和调用签名,生产调用方迁移到内核。 + +### 3.4 展示限制不改变语义 + +内核不会把截断列表当成完整 Todo 集合。summary 可以限制展示行数,但 count、frontier +分类和 coordination digest 继续使用完整 source 或明确 qualification 的 canonical projection。 + +## 4. 所有权与非目标 + +本 RFC 只负责读语义,不会: + +- 修改默认 provider、提升 SQLite/NoKV/PostgreSQL,或迁移现有 Goal; +- 新增 lease、receipt、scheduler 或网络 authority; +- 把 Markdown 变成 generated source of truth; +- 从 `goal_bound`、actor identity 或 title 文本推断用户批准; +- 在 extension inventory 与 TypeScript conformance packet 完成前删除兼容 import。 + +能力 owner 仍是现有 Todo/control-plane contract;不创建新 capability 或 provider package。 + +## 5. 迁移计划 + +1. **内核抽取(本阶段)。** 将 projection 规则移动到 `todo_semantics.py`,把 + `projection.py` 改成兼容导出,迁移生产 import,保留外部路径。 +2. **Summary 收敛(本阶段)。** 删除 `todo_summary.py` 只使用 text 的特例;summary 的 + 限制与压缩仍留在自身。 +3. **Fixture conformance(本阶段)。** 扩展 deterministic production-scale fixture,加入 + title-only monitor、未 claim 但排除某 Agent 的 advancement、显式 global gate(不依赖 + goal binding)和过期 lease。这些都是合成声明,不复制真实 Goal 状态。 +4. **TypeScript packet(下一阶段)。** 把相同 semantic cases 放入语言中立 contract, + 在删除更多 Python adapter 前比较 TypeScript 与 Python 的分类和选择结果。 +5. **兼容层退休(后续)。** 完成 extension import audit 后,在一次有披露的 release 中 + 弃用 `projection.py`,确认没有受支持调用方后再删除。 + +每一步都可回滚:可以恢复 import-only facade,不触碰 provider selector 或持久化 revision。 + +## 6. 验证契约 + +聚焦 Python 测试必须覆盖 title-aware classification、monitor due、exclusion/ownership 分离 +和 fixture edge 声明。既有 projection、canonical-governance、frontier、long-history 测试 +必须继续通过。TypeScript typecheck 以及 coordination/monitor/quota 测试也必须通过,因为 +fixture 同时被两套 runtime 使用。 + +真实本机读回保持只读: + +```bash +loopx --format json status --goal-id loopx-meta +loopx --format json todo list --goal-id loopx-meta --role agent --status open +``` + +第一条命令检查 live registry/runtime contract;第二条通过公共 Todo surface 检查 live Goal +仍可读。两条命令都不会写 Goal、改变 lease 或提升 provider。失败时应阻止交付,不能削弱 +fixture 或 gate。 + +## 7. 验收标准 + +- `projection.py` 不再含独立语义实现; +- 所有生产 import 使用 `todo_semantics` 或 summary assembler; +- title-only monitor 同时通过 direct 与 summary 路径; +- complex fixture 确定、public-safe,并被现有 TypeScript conformance 测试消费; +- Python 聚焦测试、TypeScript typecheck/tests、`git diff --check` 与只读 `loopx-meta` 命令通过; +- 不提交私有 Goal 状态、凭据、原始 run log、本机绝对路径或生成物。 + +## 8. 重复清单 + +本 RFC 前的重复主要是结构性重复,不只是相同代码文本: + +| 区域 | 重复知识 | 后果 | +| --- | --- | --- | +| `projection.py` 与 `todo_summary.py` | task classification、actionable status、priority、monitor predicate | title/text 分歧,修复要重复做 | +| agent scope 与 quota preparation | actionable/classification 的本地 wrapper | 调用方可能使用不同 helper 默认值 | +| Goal Frontier 与 work-lane consumer | claim/exclusion 与 frontier 分区 | count 与 selected id 可能不一致 | +| Python projection 与 TypeScript transaction consumer | canonical Todo 字段与 provider read-model shape | migration 需要多处比较点 | + +前 3 行由本 RFC 收敛;第 4 行仍是下一阶段的跨语言 conformance 任务。本 PR 不声称删除 +TypeScript authority,也不改变 provider 语义。 + +## 9. 未决问题 + +- 哪一个受支持 extension release 可以停止 import `projection.py`? +- TypeScript packet 应从 coordination state contract 生成,还是保持独立版本的 semantic fixture? +- 在不惊动本地 plugin 的前提下,退休兼容 facade 需要哪些证据? + +这些问题不阻塞本次 read-policy 收敛,因为 facade 保留了现有 public import surface。 diff --git a/tests/control_plane_ts/production_scale_coordination_fixture.ts b/tests/control_plane_ts/production_scale_coordination_fixture.ts index 27a3f6c9ab..05cab6a47a 100644 --- a/tests/control_plane_ts/production_scale_coordination_fixture.ts +++ b/tests/control_plane_ts/production_scale_coordination_fixture.ts @@ -23,6 +23,7 @@ const envelope = JSON.parse(readFileSync(new URL( linked_decision_count: number; completion_target_index: number; supersede_target_index: number; + semantic_cases: Record>; }; export const PRODUCTION_SCALE_FIXTURE_SCHEMA = @@ -48,6 +49,7 @@ export interface ProductionScaleCoordinationFixture { readonly expected_agent_archive_count_after_terminals: number; readonly expected_user_archive_count: number; readonly expected_standing_user_decision_count: number; + readonly semantic_cases: Readonly>>; } function statusSeries(counts: Record): string[] { @@ -209,6 +211,7 @@ export function productionScaleCoordinationFixture( expected_agent_archive_count_after_terminals: initialAgentDone + 2 - 5, expected_user_archive_count: (envelope.user_status_counts.done ?? 0) - 5, expected_standing_user_decision_count: envelope.standing_user_decision_count, + semantic_cases: envelope.semantic_cases, }; } diff --git a/tests/control_plane_ts/todo_semantic_fixture.test.ts b/tests/control_plane_ts/todo_semantic_fixture.test.ts new file mode 100644 index 0000000000..fffaef5fc1 --- /dev/null +++ b/tests/control_plane_ts/todo_semantic_fixture.test.ts @@ -0,0 +1,16 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import {productionScaleCoordinationFixture} from "./production_scale_coordination_fixture.ts"; + +test("production-scale fixture carries cross-RFC semantic edge cases", () => { + const fixture = productionScaleCoordinationFixture("fixture-goal"); + const cases = fixture.semantic_cases; + assert.equal(cases.title_only_monitor.title, "Observe dependency health"); + assert.equal(cases.title_only_monitor.text, ""); + assert.equal(cases.excluded_unclaimed_advancement.claimed_by, null); + assert.deepEqual(cases.excluded_unclaimed_advancement.excluded_agents, ["agent-a"]); + assert.equal(cases.global_gate_without_goal_binding.global_gate, true); + assert.equal(cases.global_gate_without_goal_binding.goal_bound, false); + assert.equal(cases.expired_lease.lease_epoch, 7); +}); diff --git a/tests/fixtures/control_plane/coordination_production_scale_v0.json b/tests/fixtures/control_plane/coordination_production_scale_v0.json index 813203601c..5f0489bc45 100644 --- a/tests/fixtures/control_plane/coordination_production_scale_v0.json +++ b/tests/fixtures/control_plane/coordination_production_scale_v0.json @@ -17,5 +17,47 @@ "scoped_without_outcome_count": 12, "linked_decision_count": 12, "completion_target_index": 160, - "supersede_target_index": 161 + "supersede_target_index": 161, + "semantic_cases": { + "title_only_monitor": { + "todo_id": "todo_fixture_title_monitor", + "role": "agent", + "status": "open", + "done": false, + "title": "Observe dependency health", + "text": "", + "task_class": null, + "next_due_at": "2025-01-01T00:00:00Z", + "watch_only": false + }, + "excluded_unclaimed_advancement": { + "todo_id": "todo_fixture_excluded_advancement", + "role": "agent", + "status": "open", + "done": false, + "text": "Implement the isolated fixture path", + "task_class": "advancement_task", + "excluded_agents": [ + "agent-a" + ], + "claimed_by": null + }, + "global_gate_without_goal_binding": { + "todo_id": "todo_fixture_global_gate", + "role": "user", + "status": "open", + "done": false, + "text": "Approve the shared direction", + "task_class": "user_gate", + "global_gate": true, + "goal_bound": false + }, + "expired_lease": { + "todo_id": "todo_fixture_expired_lease", + "owner": "agent-a", + "status": "active", + "expires_at": "2024-01-01T00:00:00Z", + "lease_epoch": 7 + } + } } From 1abd52517ca5d5bbb5d3fdb2fb26df13789805ad Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 13:38:46 +0800 Subject: [PATCH 03/12] refactor(todos): type projection delivery outcomes Signed-off-by: huangruiteng --- .../control_plane/coordination/todo_compatibility_edit.ts | 3 ++- loopx/control_plane/coordination/todo_update.ts | 3 ++- loopx/control_plane/todos/projection_delivery.ts | 7 +++++++ 3 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 loopx/control_plane/todos/projection_delivery.ts diff --git a/loopx/control_plane/coordination/todo_compatibility_edit.ts b/loopx/control_plane/coordination/todo_compatibility_edit.ts index e47006b26e..94cde3e4c2 100644 --- a/loopx/control_plane/coordination/todo_compatibility_edit.ts +++ b/loopx/control_plane/coordination/todo_compatibility_edit.ts @@ -2,6 +2,7 @@ import type { JsonObject } from "../effect_program.ts"; import type { AuthorityStore } from "./authority_store.ts"; import { canonicalAuthorityBytes, canonicalAuthorityObject, canonicalAuthoritySha256, requireAuthorityStoreId } from "./authority_store_codec.ts"; import { prepareCoordinationProjectionCommit, indexCoordinationProjection, validateCoordinationTodoReadModel } from "./coordination_projection.ts"; +import { projectionDelivery } from "../todos/projection_delivery.ts"; export const TODO_COMPATIBILITY_EDIT_SCHEMA = "loopx_todo_compatibility_edit_request_v0"; export const TODO_COMPATIBILITY_EDIT_RESULT_SCHEMA = "loopx_todo_compatibility_edit_result_v0"; @@ -75,7 +76,7 @@ export async function editCoordinationTodo( status: status === "applied" && !original.changed ? "no_change" : status, changed: status !== "replayed" && original.changed, provider_revision: receipt.provider_revision, cursor: receipt.cursor, - projection_delivery: original.changed ? "pending" : "not_required", + projection_delivery: projectionDelivery(original.changed), projection_source: "committed_authority_journal", }; }; diff --git a/loopx/control_plane/coordination/todo_update.ts b/loopx/control_plane/coordination/todo_update.ts index 11756d7202..68d6a4c50e 100644 --- a/loopx/control_plane/coordination/todo_update.ts +++ b/loopx/control_plane/coordination/todo_update.ts @@ -27,6 +27,7 @@ import { evaluateCoordinationTerminalFence, COORDINATION_TERMINAL_FENCE_REQUEST_ import { leaseEpoch } from "../work_items/task_lease_acquire.ts"; import { parseIsoTimestamp } from "../runtime_timestamp.ts"; import { normalizeNativePlanningIntent, planNativeTodoUpdate } from "../todos/native_update_plan.ts"; +import { projectionDelivery } from "../todos/projection_delivery.ts"; export const COORDINATION_TODO_UPDATE_REQUEST_SCHEMA = "loopx_local_coordination_todo_update_request_v0"; @@ -139,7 +140,7 @@ function replayUpdate( changed: status !== "replayed" && original.changed, todo_id: input.todo_id, provider_revision: receipt.provider_revision, cursor: receipt.cursor, original_receipt: original, - projection_delivery: original.changed ? "pending" : "not_required", + projection_delivery: projectionDelivery(original.changed), projection_source: "committed_authority_journal"}; } diff --git a/loopx/control_plane/todos/projection_delivery.ts b/loopx/control_plane/todos/projection_delivery.ts new file mode 100644 index 0000000000..e12c107e5f --- /dev/null +++ b/loopx/control_plane/todos/projection_delivery.ts @@ -0,0 +1,7 @@ +/** Canonical projection-delivery state returned by Todo mutations. */ +export type TodoProjectionDelivery = "pending" | "not_required"; + +/** Keep mutation results consistent and make the no-op meaning explicit. */ +export function projectionDelivery(changed: boolean): TodoProjectionDelivery { + return changed ? "pending" : "not_required"; +} From 3f647824d9506bdba8f2957c84453834aa60a83f Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 13:42:40 +0800 Subject: [PATCH 04/12] test(todos): cover projection delivery contract Signed-off-by: huangruiteng --- loopx/control_plane/todos/projection_delivery.ts | 10 +++++++++- .../control_plane_ts/projection_delivery.test.ts | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 tests/control_plane_ts/projection_delivery.test.ts diff --git a/loopx/control_plane/todos/projection_delivery.ts b/loopx/control_plane/todos/projection_delivery.ts index e12c107e5f..0349676a5b 100644 --- a/loopx/control_plane/todos/projection_delivery.ts +++ b/loopx/control_plane/todos/projection_delivery.ts @@ -1,7 +1,15 @@ /** Canonical projection-delivery state returned by Todo mutations. */ -export type TodoProjectionDelivery = "pending" | "not_required"; +export type TodoProjectionDelivery = "pending" | "delivered" | "current" | "not_required"; /** Keep mutation results consistent and make the no-op meaning explicit. */ export function projectionDelivery(changed: boolean): TodoProjectionDelivery { return changed ? "pending" : "not_required"; } + +/** Decode provider readback without letting ad-hoc strings cross the boundary. */ +export function parseProjectionDelivery(value: unknown): TodoProjectionDelivery { + if (value === "pending" || value === "delivered" || value === "current" || value === "not_required") { + return value; + } + throw new Error("projection_delivery is unsupported"); +} diff --git a/tests/control_plane_ts/projection_delivery.test.ts b/tests/control_plane_ts/projection_delivery.test.ts new file mode 100644 index 0000000000..98e2e68e15 --- /dev/null +++ b/tests/control_plane_ts/projection_delivery.test.ts @@ -0,0 +1,15 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseProjectionDelivery, projectionDelivery } from "../../loopx/control_plane/todos/projection_delivery.ts"; + +test("projection delivery maps mutation and no-op outcomes", () => { + assert.equal(projectionDelivery(true), "pending"); + assert.equal(projectionDelivery(false), "not_required"); +}); + +test("projection delivery parser accepts provider readback states", () => { + for (const value of ["pending", "delivered", "current", "not_required"]) { + assert.equal(parseProjectionDelivery(value), value); + } + assert.throws(() => parseProjectionDelivery("unknown")); +}); From efe30c9cced1973f4fcd162abebba889ae6608c1 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 13:51:29 +0800 Subject: [PATCH 05/12] refactor(todos): close projection delivery protocol across callers Signed-off-by: huangruiteng --- loopx/control_plane/coordination/todo_claim.ts | 3 ++- loopx/control_plane/coordination/todo_create.ts | 3 ++- .../coordination/todo_monitor_poll.ts | 3 ++- .../coordination/todo_terminal_lifecycle.ts | 5 +++-- loopx/control_plane/todos/projection_delivery.ts | 11 +++++++++-- tests/control_plane_ts/projection_delivery.test.ts | 14 +++++++++++++- .../projection_delivery_composition_v0.json | 9 +++++++++ 7 files changed, 40 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/control_plane/projection_delivery_composition_v0.json diff --git a/loopx/control_plane/coordination/todo_claim.ts b/loopx/control_plane/coordination/todo_claim.ts index ed348cf03e..48e2ec7a9f 100644 --- a/loopx/control_plane/coordination/todo_claim.ts +++ b/loopx/control_plane/coordination/todo_claim.ts @@ -8,6 +8,7 @@ import { requireAuthorityStoreId, } from "./authority_store_codec.ts"; import {validateContinuationNote, computeContinuationTodoFacts} from "./continuation_note.ts"; +import {projectionDelivery} from "../todos/projection_delivery.ts"; import {normalizeRegisteredTodoAgents, normalizeTodoAgent} from "./todo_agents.ts"; import { prepareCoordinationProjectionCommit, @@ -450,7 +451,7 @@ export async function executeCoordinationTodoClaim( provider_revision: receipt.provider_revision, cursor: receipt.cursor, original_receipt: original, - projection_delivery: result.changed === false ? "not_required" : "pending", + projection_delivery: projectionDelivery(result.changed !== false), projection_source: "committed_authority_journal", }; }; diff --git a/loopx/control_plane/coordination/todo_create.ts b/loopx/control_plane/coordination/todo_create.ts index eafb21d76e..93fcd38891 100644 --- a/loopx/control_plane/coordination/todo_create.ts +++ b/loopx/control_plane/coordination/todo_create.ts @@ -1,4 +1,5 @@ import type { JsonObject } from "../effect_program.ts"; +import {projectionDelivery} from "../todos/projection_delivery.ts"; import type { AuthorityStore, AuthorityStoreReceiptResult } from "./authority_store.ts"; import { AuthorityStoreProtocolError, @@ -77,7 +78,7 @@ function replayCreate( provider_revision: receipt.provider_revision, cursor: receipt.cursor, original_receipt: original, - projection_delivery: "pending", + projection_delivery: projectionDelivery(true), projection_source: "committed_authority_journal", }; } diff --git a/loopx/control_plane/coordination/todo_monitor_poll.ts b/loopx/control_plane/coordination/todo_monitor_poll.ts index 264ee05390..b5129d7c3a 100644 --- a/loopx/control_plane/coordination/todo_monitor_poll.ts +++ b/loopx/control_plane/coordination/todo_monitor_poll.ts @@ -11,6 +11,7 @@ import {planMonitorMetadata, TODO_MONITOR_METADATA_REQUEST_SCHEMA} from "../todo import {planMonitorSuccessor, selectMonitorTodo, MONITOR_SUCCESSOR_REQUEST_SCHEMA} from "../scheduler/monitor_successor.ts"; import {optionalNonEmptyString, requireBoolean} from "../runtime_decode.ts"; import {planTodoAuthoringScope, TODO_AUTHORING_SCOPE_REQUEST_SCHEMA} from "../todos/authoring_scope.ts"; +import {projectionDelivery} from "../todos/projection_delivery.ts"; export const COORDINATION_MONITOR_POLL_REQUEST_SCHEMA = "loopx_coordination_monitor_poll_request_v0"; export const COORDINATION_MONITOR_POLL_RESULT_SCHEMA = "loopx_coordination_monitor_poll_result_v0"; @@ -42,7 +43,7 @@ function replay(receipt: AuthorityStoreReceiptResult, input: CoordinationMonitor return {schema_version: COORDINATION_MONITOR_POLL_RESULT_SCHEMA, status, changed: status !== "replayed", provider_revision: receipt.provider_revision, cursor: receipt.cursor, writeback: {...canonicalAuthorityObject(original.writeback, "Monitor writeback"), provider_replayed: status === "replayed"}, - projection_delivery: "pending", projection_source: "committed_authority_journal"}; + projection_delivery: projectionDelivery(true), projection_source: "committed_authority_journal"}; } function normalize(raw: CoordinationMonitorPollInput): CoordinationMonitorPollInput { diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index 2e2bc2e691..c2ed4cf84e 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import {projectionDelivery} from "../todos/projection_delivery.ts"; import type { JsonObject } from "../effect_program.ts"; import type { @@ -451,7 +452,7 @@ function replayTerminal( provider_revision: receipt.provider_revision, cursor: receipt.cursor, original_receipt: original, - projection_delivery: result.changed === true ? "pending" : "not_required", + projection_delivery: projectionDelivery(result.changed === true), projection_source: "committed_authority_journal", }; } @@ -1134,7 +1135,7 @@ function replayArchive( provider_revision: receipt.provider_revision, cursor: receipt.cursor, original_receipt: original, - projection_delivery: result.changed === true ? "pending" : "not_required", + projection_delivery: projectionDelivery(result.changed === true), projection_source: "committed_authority_journal", }; } diff --git a/loopx/control_plane/todos/projection_delivery.ts b/loopx/control_plane/todos/projection_delivery.ts index 0349676a5b..3c75397cf3 100644 --- a/loopx/control_plane/todos/projection_delivery.ts +++ b/loopx/control_plane/todos/projection_delivery.ts @@ -1,5 +1,8 @@ /** Canonical projection-delivery state returned by Todo mutations. */ export type TodoProjectionDelivery = "pending" | "delivered" | "current" | "not_required"; +const PROJECTION_DELIVERY_VALUES = new Set([ + "pending", "delivered", "current", "not_required", +]); /** Keep mutation results consistent and make the no-op meaning explicit. */ export function projectionDelivery(changed: boolean): TodoProjectionDelivery { @@ -8,8 +11,12 @@ export function projectionDelivery(changed: boolean): TodoProjectionDelivery { /** Decode provider readback without letting ad-hoc strings cross the boundary. */ export function parseProjectionDelivery(value: unknown): TodoProjectionDelivery { - if (value === "pending" || value === "delivered" || value === "current" || value === "not_required") { + if (typeof value === "string" && PROJECTION_DELIVERY_VALUES.has(value as TodoProjectionDelivery)) { return value; } - throw new Error("projection_delivery is unsupported"); + throw new Error(`projection_delivery is unsupported: ${String(value)}`); +} + +export function isProjectionDelivery(value: unknown): value is TodoProjectionDelivery { + return typeof value === "string" && PROJECTION_DELIVERY_VALUES.has(value as TodoProjectionDelivery); } diff --git a/tests/control_plane_ts/projection_delivery.test.ts b/tests/control_plane_ts/projection_delivery.test.ts index 98e2e68e15..9d45fc1a36 100644 --- a/tests/control_plane_ts/projection_delivery.test.ts +++ b/tests/control_plane_ts/projection_delivery.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { parseProjectionDelivery, projectionDelivery } from "../../loopx/control_plane/todos/projection_delivery.ts"; +import { readFile } from "node:fs/promises"; +import { isProjectionDelivery, parseProjectionDelivery, projectionDelivery } from "../../loopx/control_plane/todos/projection_delivery.ts"; test("projection delivery maps mutation and no-op outcomes", () => { assert.equal(projectionDelivery(true), "pending"); @@ -12,4 +13,15 @@ test("projection delivery parser accepts provider readback states", () => { assert.equal(parseProjectionDelivery(value), value); } assert.throws(() => parseProjectionDelivery("unknown")); + assert.equal(isProjectionDelivery("delivered"), true); + assert.equal(isProjectionDelivery("DELIVERED"), false); + assert.equal(isProjectionDelivery(null), false); +}); + +test("composition fixture keeps mutation and provider states distinct", async () => { + const fixture = JSON.parse(await readFile("tests/fixtures/control_plane/projection_delivery_composition_v0.json", "utf8")); + for (const item of fixture.cases) { + const actual = item.changed === undefined ? parseProjectionDelivery(item.readback) : projectionDelivery(item.changed); + assert.equal(actual, item.expected, item.name); + } }); diff --git a/tests/fixtures/control_plane/projection_delivery_composition_v0.json b/tests/fixtures/control_plane/projection_delivery_composition_v0.json new file mode 100644 index 0000000000..7feaee880b --- /dev/null +++ b/tests/fixtures/control_plane/projection_delivery_composition_v0.json @@ -0,0 +1,9 @@ +{ + "schema_version": "todo_projection_delivery_composition_v0", + "cases": [ + {"name": "mutation_changed", "changed": true, "expected": "pending"}, + {"name": "mutation_noop", "changed": false, "expected": "not_required"}, + {"name": "provider_ack", "readback": "delivered", "expected": "delivered"}, + {"name": "provider_already_current", "readback": "current", "expected": "current"} + ] +} From a2118b24e08d2c954c5b0e4aaf53fb1df30aa145 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 14:03:33 +0800 Subject: [PATCH 06/12] refactor(todos): unify projection delivery status semantics Signed-off-by: huangruiteng --- .../todos/provider_projection.py | 32 +++++++++++++++++-- .../todos/provider_terminal_lifecycle.py | 4 +-- .../test_todo_provider_projection.py | 8 +++++ 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/loopx/control_plane/todos/provider_projection.py b/loopx/control_plane/todos/provider_projection.py index 987fc67773..68260244f6 100644 --- a/loopx/control_plane/todos/provider_projection.py +++ b/loopx/control_plane/todos/provider_projection.py @@ -14,6 +14,7 @@ import json import stat import tempfile +from enum import StrEnum from collections.abc import Mapping from pathlib import Path from typing import Any @@ -35,6 +36,28 @@ TODO_PROJECTION_DELIVERY_SCHEMA = "loopx_todo_projection_delivery_v0" +class ProjectionDeliveryStatus(StrEnum): + """Stable cross-language states for canonical Todo display delivery.""" + PENDING = "pending" + DELIVERED = "delivered" + CURRENT = "current" + NOT_REQUIRED = "not_required" + + +def parse_projection_delivery(value: object) -> ProjectionDeliveryStatus: + try: + return ProjectionDeliveryStatus(value) # type: ignore[arg-type] + except (TypeError, ValueError) as error: + raise ValueError(f"unsupported projection_delivery: {value!r}") from error + + +def projection_delivery_requires_ack(value: object) -> bool: + return parse_projection_delivery(value) in { + ProjectionDeliveryStatus.DELIVERED, + ProjectionDeliveryStatus.CURRENT, + } + + def _read_text_exact(path: Path) -> str: with path.open("r", encoding="utf-8", newline="") as handle: return handle.read() @@ -202,10 +225,10 @@ def settle_canonical_todo_projection( """Drain the committed provider head, preserving a successful mutation.""" if payload.get("dry_run") is True or payload.get("status") == "planned": - payload["projection_delivery"] = "not_required" + payload["projection_delivery"] = ProjectionDeliveryStatus.NOT_REQUIRED.value payload["projection_outbox"] = { "schema_version": TODO_PROJECTION_DELIVERY_SCHEMA, - "status": "not_required", + "status": ProjectionDeliveryStatus.NOT_REQUIRED.value, "source": "committed_authority_journal", } return payload @@ -246,13 +269,16 @@ def settle_canonical_todo_projection( delivery["trigger_provider_revision"] = trigger_revision if isinstance(trigger_cursor, str) and trigger_cursor: delivery["trigger_cursor"] = trigger_cursor - payload["projection_delivery"] = delivery["status"] + payload["projection_delivery"] = parse_projection_delivery(delivery["status"]).value payload["projection_outbox"] = delivery return payload __all__ = [ "TODO_PROJECTION_DELIVERY_SCHEMA", + "ProjectionDeliveryStatus", + "parse_projection_delivery", + "projection_delivery_requires_ack", "project_current_canonical_todos", "settle_canonical_todo_projection", ] diff --git a/loopx/control_plane/todos/provider_terminal_lifecycle.py b/loopx/control_plane/todos/provider_terminal_lifecycle.py index c911eabf44..f6b9556cb8 100644 --- a/loopx/control_plane/todos/provider_terminal_lifecycle.py +++ b/loopx/control_plane/todos/provider_terminal_lifecycle.py @@ -36,7 +36,7 @@ from .contract import resolve_next_user_task_class from .mutation_authority import normalize_todo_lifecycle_authority from .path_resolution import resolve_todo_state_path -from .provider_projection import settle_canonical_todo_projection +from .provider_projection import projection_delivery_requires_ack, settle_canonical_todo_projection from .successor_derivation import build_successor_intents _TERMINAL_REQUEST_SCHEMA = "loopx_local_coordination_todo_terminal_lifecycle_request_v0" @@ -564,7 +564,7 @@ def archive_canonical_todos_if_promoted( if ( not dry_run and response.get("moved_count", 0) > 0 - and response.get("projection_delivery") in {"delivered", "current"} + and projection_delivery_requires_ack(response.get("projection_delivery")) ): # The native owner retains the attempt until its external projection # provider succeeds. An ACK failure must preserve the committed result diff --git a/tests/control_plane/test_todo_provider_projection.py b/tests/control_plane/test_todo_provider_projection.py index c3aaa05d27..e825ea88ab 100644 --- a/tests/control_plane/test_todo_provider_projection.py +++ b/tests/control_plane/test_todo_provider_projection.py @@ -181,3 +181,11 @@ def test_explicit_projection_fences_requested_revision( ) assert state_file.read_text(encoding="utf-8") == SOURCE + + +def test_projection_delivery_status_contract_is_strict(): + assert provider_projection.parse_projection_delivery("delivered") is provider_projection.ProjectionDeliveryStatus.DELIVERED + assert provider_projection.projection_delivery_requires_ack("current") is True + assert provider_projection.projection_delivery_requires_ack("pending") is False + with pytest.raises(ValueError, match="unsupported projection_delivery"): + provider_projection.parse_projection_delivery("completed") From f81e40e2321956d9b223a54d08e0188ef66c8838 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 14:11:21 +0800 Subject: [PATCH 07/12] refactor(control-plane): complete RFC projection delivery stage Signed-off-by: huangruiteng --- AGENTS.md | 6 +++ docs/book/chapters/source-protocol-map.md | 4 ++ docs/book/en/chapters/source-protocol-map.md | 6 +++ .../quota/monitor_poll_commit.ts | 11 +++-- .../todos/provider_projection.py | 12 +++++- .../test_todo_provider_projection.py | 13 ++++++ .../projection_delivery.test.ts | 1 + .../projection_delivery_composition_v0.json | 40 +++++++++++++++++-- tsconfig.control-plane.json | 1 + 9 files changed, 86 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fe4d80c686..48507de87a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -358,6 +358,12 @@ section, or abstraction, pass a scope-fit review: leaves behavior split across follow-up PRs. Separate characterization/parity fixtures, mechanical moves, behavior changes, and cleanup when that makes review and rollback clearer. +- "Right-sized" is a semantic constraint, not a small-diff target. When the + user explicitly asks to advance a complete RFC stage, a larger PR may include + every causally coupled production boundary, fixture, migration note, and + real-path validation needed to make that stage complete. Do not split such a + request merely because it crosses several modules; split only independent + change reasons or unsafe review surfaces. - Keep public PRs concise and current-purpose focused. Future extension points are allowed when they reduce near-term churn, preserve compatibility, or define a real contract that is documented and tested. Do not bundle diff --git a/docs/book/chapters/source-protocol-map.md b/docs/book/chapters/source-protocol-map.md index b3cbc82e39..4c7afd78ee 100644 --- a/docs/book/chapters/source-protocol-map.md +++ b/docs/book/chapters/source-protocol-map.md @@ -427,6 +427,10 @@ disposition,并指出 owner、下一产物与复核条件。需要跨方向同 缩小切片不是只减少行数,而是保持一条完整因果链: +当用户明确要求推进一个完整 RFC 阶段时,这条因果链可以跨越多个模块、provider、fixture +和验证路径。只要这些部分共同构成完整阶段且能一起审阅,较大的 PR 是合适的。应按独立的 +变更原因或不安全的审阅边界拆分,不应仅为了减少文件数而拆开一个连贯阶段。 + ```text source -> invariant diff --git a/docs/book/en/chapters/source-protocol-map.md b/docs/book/en/chapters/source-protocol-map.md index 4549bca799..8bcb332992 100644 --- a/docs/book/en/chapters/source-protocol-map.md +++ b/docs/book/en/chapters/source-protocol-map.md @@ -453,6 +453,12 @@ This is usually too broad: Right-sizing is not only reducing line count. Preserve one complete causal chain: +When the request explicitly targets a complete RFC stage, that causal chain may +span several modules, providers, fixtures, and validation paths. A larger PR is +appropriate when those pieces are required to make the stage complete and are +reviewed together. Split independent change reasons or unsafe review surfaces, +not a coherent stage merely to reduce the file count. + ```text source -> invariant diff --git a/loopx/control_plane/quota/monitor_poll_commit.ts b/loopx/control_plane/quota/monitor_poll_commit.ts index c120f4baa3..625d140170 100644 --- a/loopx/control_plane/quota/monitor_poll_commit.ts +++ b/loopx/control_plane/quota/monitor_poll_commit.ts @@ -3,6 +3,7 @@ import { access, readFile, rm } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; import { monitorSuccessorIntent, monitorSuccessorRoute } from "../scheduler/monitor_successor.ts"; import { normalizeTodoCapabilities } from "../todos/work_requirements.ts"; +import { parseProjectionDelivery } from "../todos/projection_delivery.ts"; import type { JsonObject } from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; @@ -642,9 +643,13 @@ function compactProviderWriteback(receipt: JsonObject): JsonObject { * Omitted on the legacy path to retain its exact v0 response shape. */ function monitorProjectionDelivery(receipt: JsonObject): JsonObject { if (receipt.projection_delivery == null) return {}; - const status = optionalString(receipt.projection_delivery, "projection_delivery"); - if (!["delivered", "pending", "not_required"].includes(String(status))) { - throw new EffectRuntimeRequestError("invalid Monitor projection delivery status"); + let status: ReturnType; + try { + status = parseProjectionDelivery(receipt.projection_delivery); + } catch (error) { + throw new EffectRuntimeRequestError( + error instanceof Error ? error.message : "invalid Monitor projection delivery status", + ); } const outbox = requiredObject(receipt.projection_outbox, "projection_outbox"); const diagnostic: JsonObject = {}; diff --git a/loopx/control_plane/todos/provider_projection.py b/loopx/control_plane/todos/provider_projection.py index 68260244f6..bdf88ace6d 100644 --- a/loopx/control_plane/todos/provider_projection.py +++ b/loopx/control_plane/todos/provider_projection.py @@ -58,6 +58,15 @@ def projection_delivery_requires_ack(value: object) -> bool: } +def projection_delivery_for_mutation(changed: bool) -> ProjectionDeliveryStatus: + """Map a committed mutation to its display outbox state.""" + return ( + ProjectionDeliveryStatus.PENDING + if changed + else ProjectionDeliveryStatus.NOT_REQUIRED + ) + + def _read_text_exact(path: Path) -> str: with path.open("r", encoding="utf-8", newline="") as handle: return handle.read() @@ -225,7 +234,7 @@ def settle_canonical_todo_projection( """Drain the committed provider head, preserving a successful mutation.""" if payload.get("dry_run") is True or payload.get("status") == "planned": - payload["projection_delivery"] = ProjectionDeliveryStatus.NOT_REQUIRED.value + payload["projection_delivery"] = projection_delivery_for_mutation(False).value payload["projection_outbox"] = { "schema_version": TODO_PROJECTION_DELIVERY_SCHEMA, "status": ProjectionDeliveryStatus.NOT_REQUIRED.value, @@ -279,6 +288,7 @@ def settle_canonical_todo_projection( "ProjectionDeliveryStatus", "parse_projection_delivery", "projection_delivery_requires_ack", + "projection_delivery_for_mutation", "project_current_canonical_todos", "settle_canonical_todo_projection", ] diff --git a/tests/control_plane/test_todo_provider_projection.py b/tests/control_plane/test_todo_provider_projection.py index e825ea88ab..e7b842b82f 100644 --- a/tests/control_plane/test_todo_provider_projection.py +++ b/tests/control_plane/test_todo_provider_projection.py @@ -189,3 +189,16 @@ def test_projection_delivery_status_contract_is_strict(): assert provider_projection.projection_delivery_requires_ack("pending") is False with pytest.raises(ValueError, match="unsupported projection_delivery"): provider_projection.parse_projection_delivery("completed") + + +def test_projection_delivery_composition_fixture_matches_provider_semantics(): + fixture_path = Path(__file__).parents[1] / "fixtures" / "control_plane" / "projection_delivery_composition_v0.json" + fixture = json.loads(fixture_path.read_text(encoding="utf-8")) + for case in fixture["cases"]: + status = ( + provider_projection.parse_projection_delivery(case["readback"]).value + if "readback" in case + else provider_projection.projection_delivery_for_mutation(case.get("changed", False)).value + ) + assert status == case["expected"], case["name"] + assert provider_projection.projection_delivery_requires_ack(status) is case["requires_ack"], case["name"] diff --git a/tests/control_plane_ts/projection_delivery.test.ts b/tests/control_plane_ts/projection_delivery.test.ts index 9d45fc1a36..69ac140ae4 100644 --- a/tests/control_plane_ts/projection_delivery.test.ts +++ b/tests/control_plane_ts/projection_delivery.test.ts @@ -23,5 +23,6 @@ test("composition fixture keeps mutation and provider states distinct", async () for (const item of fixture.cases) { const actual = item.changed === undefined ? parseProjectionDelivery(item.readback) : projectionDelivery(item.changed); assert.equal(actual, item.expected, item.name); + assert.equal(item.requires_ack, actual === "delivered" || actual === "current", item.name); } }); diff --git a/tests/fixtures/control_plane/projection_delivery_composition_v0.json b/tests/fixtures/control_plane/projection_delivery_composition_v0.json index 7feaee880b..0bc5898123 100644 --- a/tests/fixtures/control_plane/projection_delivery_composition_v0.json +++ b/tests/fixtures/control_plane/projection_delivery_composition_v0.json @@ -1,9 +1,41 @@ { "schema_version": "todo_projection_delivery_composition_v0", "cases": [ - {"name": "mutation_changed", "changed": true, "expected": "pending"}, - {"name": "mutation_noop", "changed": false, "expected": "not_required"}, - {"name": "provider_ack", "readback": "delivered", "expected": "delivered"}, - {"name": "provider_already_current", "readback": "current", "expected": "current"} + { + "name": "mutation_changed", + "changed": true, + "expected": "pending", + "requires_ack": false + }, + { + "name": "mutation_noop", + "changed": false, + "expected": "not_required", + "requires_ack": false + }, + { + "name": "provider_ack", + "readback": "delivered", + "expected": "delivered", + "requires_ack": true + }, + { + "name": "provider_already_current", + "readback": "current", + "expected": "current", + "requires_ack": true + }, + { + "name": "provider_pending", + "readback": "pending", + "expected": "pending", + "requires_ack": false + }, + { + "name": "mutation_noop_does_not_ack", + "changed": false, + "expected": "not_required", + "requires_ack": false + } ] } diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index 44551b9cf4..6577ad2148 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -54,6 +54,7 @@ "loopx/control_plane/scheduler/heartbeat_followup_cli.ts", "loopx/control_plane/scheduler/state_transition_rules.ts", "loopx/control_plane/todos/completion_fence.ts", + "loopx/control_plane/todos/projection_delivery.ts", "loopx/control_plane/todos/completion_state.ts", "loopx/control_plane/todos/completion_validation_plan.ts", "loopx/control_plane/todos/next_action.ts", From 1c1cf237bb8900566233bc80edaf7cc93c4e7243 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 14:18:05 +0800 Subject: [PATCH 08/12] test(rfc): add projection delivery causal-chain fixture Signed-off-by: huangruiteng --- .../rfcs/typescript-control-plane-migration-v0.md | 8 ++++++++ ...typescript-control-plane-migration-v0.zh-CN.md | 6 ++++++ .../test_todo_provider_projection.py | 15 +++++++++++++++ .../control_plane_ts/projection_delivery.test.ts | 7 +++++++ .../control_plane/projection_delivery_e2e_v1.json | 11 +++++++++++ 5 files changed, 47 insertions(+) create mode 100644 tests/fixtures/control_plane/projection_delivery_e2e_v1.json diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index b01f4d79af..dde8a869c5 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -16,6 +16,14 @@ ## Current implementation checkpoint +The projection-delivery stage now closes the cross-language boundary: typed +TypeScript mutation results and the Python compatibility provider share the +same four-state contract (`pending`, `delivered`, `current`, `not_required`). +Provider readback is validated before acknowledgement decisions, and the +end-to-end causal chain is covered by a shared composition fixture. This is a +completed delivery stage, not a promotion of Markdown or a claim that the +remaining lifecycle writers have migrated. + Native update now composes `todos/public_update.ts` for a bounded nonterminal planning intent (status, evidence/reason, resume/clear and successor links), against the same complete canonical head used for authority checks and CAS. 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 9d536804c1..890fec5ad0 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 @@ -15,6 +15,12 @@ ## 当前实现检查点 +投影交付阶段现已闭合跨语言边界:typed TypeScript mutation 结果与 Python +兼容 provider 共用四态契约(`pending`、`delivered`、`current`、`not_required`)。 +Provider readback 在 acknowledgement 决策前进行校验,端到端因果链由共享组合 +fixture 覆盖。这是一个已完成的交付阶段,不代表 Markdown 晋升,也不声称其余 +lifecycle writer 已全部迁移。 + Native update 现通过 `todos/public_update.ts` 组合有界的非终态 planning intent (status、evidence/reason、resume/clear、successor links),使用权限检查与 CAS 同一份完整 canonical head。独立 intent 命名空间不扩大原 text/note patch allowlist, diff --git a/tests/control_plane/test_todo_provider_projection.py b/tests/control_plane/test_todo_provider_projection.py index e7b842b82f..8548646b64 100644 --- a/tests/control_plane/test_todo_provider_projection.py +++ b/tests/control_plane/test_todo_provider_projection.py @@ -202,3 +202,18 @@ def test_projection_delivery_composition_fixture_matches_provider_semantics(): ) assert status == case["expected"], case["name"] assert provider_projection.projection_delivery_requires_ack(status) is case["requires_ack"], case["name"] + + +def test_projection_delivery_e2e_fixture_preserves_causal_states(): + fixture_path = Path(__file__).parents[1] / "fixtures" / "control_plane" / "projection_delivery_e2e_v1.json" + fixture = json.loads(fixture_path.read_text(encoding="utf-8")) + observed = [] + for transition in fixture["transitions"]: + if "readback" in transition: + status = provider_projection.parse_projection_delivery(transition["readback"]).value + else: + status = provider_projection.projection_delivery_for_mutation(transition["changed"]).value + observed.append(status) + assert status == transition["delivery"], transition["step"] + assert provider_projection.projection_delivery_requires_ack(status) is transition["ack"], transition["step"] + assert observed == ["pending", "delivered", "current", "not_required", "pending"] diff --git a/tests/control_plane_ts/projection_delivery.test.ts b/tests/control_plane_ts/projection_delivery.test.ts index 69ac140ae4..bc5c1a0a1c 100644 --- a/tests/control_plane_ts/projection_delivery.test.ts +++ b/tests/control_plane_ts/projection_delivery.test.ts @@ -26,3 +26,10 @@ test("composition fixture keeps mutation and provider states distinct", async () assert.equal(item.requires_ack, actual === "delivered" || actual === "current", item.name); } }); + +test("end-to-end fixture preserves delivery causal chain", async () => { + const fixture = JSON.parse(await readFile("tests/fixtures/control_plane/projection_delivery_e2e_v1.json", "utf8")); + const observed = fixture.transitions.map((item: { changed?: boolean; readback?: unknown }) => + item.readback === undefined ? projectionDelivery(item.changed === true) : parseProjectionDelivery(item.readback)); + assert.deepEqual(observed, ["pending", "delivered", "current", "not_required", "pending"]); +}); diff --git a/tests/fixtures/control_plane/projection_delivery_e2e_v1.json b/tests/fixtures/control_plane/projection_delivery_e2e_v1.json new file mode 100644 index 0000000000..33b750794d --- /dev/null +++ b/tests/fixtures/control_plane/projection_delivery_e2e_v1.json @@ -0,0 +1,11 @@ +{ + "schema_version": "todo_projection_delivery_e2e_v1", + "goal_id": "fixture-goal", + "transitions": [ + {"step": "create", "changed": true, "delivery": "pending", "ack": false}, + {"step": "display-retry", "readback": "delivered", "delivery": "delivered", "ack": true}, + {"step": "idempotent-replay", "readback": "current", "delivery": "current", "ack": true}, + {"step": "terminal-no-op", "changed": false, "delivery": "not_required", "ack": false}, + {"step": "failed-display", "readback": "pending", "delivery": "pending", "ack": false} + ] +} From 044afcc9200f7927c0f4a517a7dd0320e03c1f87 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 15:58:12 +0800 Subject: [PATCH 09/12] docs(rfc): fold todo semantics into migration contract Signed-off-by: huangruiteng --- AGENTS.md | 6 - docs/architecture/rfcs/README.md | 15 +- .../rfcs/canonical-todo-semantic-kernel-v0.md | 191 ------------------ ...canonical-todo-semantic-kernel-v0.zh-CN.md | 152 -------------- .../typescript-control-plane-migration-v0.md | 8 + ...script-control-plane-migration-v0.zh-CN.md | 6 + docs/book/chapters/source-protocol-map.md | 4 - docs/book/en/chapters/source-protocol-map.md | 6 - 8 files changed, 21 insertions(+), 367 deletions(-) delete mode 100644 docs/architecture/rfcs/canonical-todo-semantic-kernel-v0.md delete mode 100644 docs/architecture/rfcs/canonical-todo-semantic-kernel-v0.zh-CN.md diff --git a/AGENTS.md b/AGENTS.md index 48507de87a..fe4d80c686 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -358,12 +358,6 @@ section, or abstraction, pass a scope-fit review: leaves behavior split across follow-up PRs. Separate characterization/parity fixtures, mechanical moves, behavior changes, and cleanup when that makes review and rollback clearer. -- "Right-sized" is a semantic constraint, not a small-diff target. When the - user explicitly asks to advance a complete RFC stage, a larger PR may include - every causally coupled production boundary, fixture, migration note, and - real-path validation needed to make that stage complete. Do not split such a - request merely because it crosses several modules; split only independent - change reasons or unsafe review surfaces. - Keep public PRs concise and current-purpose focused. Future extension points are allowed when they reduce near-term churn, preserve compatibility, or define a real contract that is documented and tested. Do not bundle diff --git a/docs/architecture/rfcs/README.md b/docs/architecture/rfcs/README.md index a1f8fe1cea..5ce1f8bf2a 100644 --- a/docs/architecture/rfcs/README.md +++ b/docs/architecture/rfcs/README.md @@ -48,14 +48,13 @@ changes. ## Control-Plane Kernel, State, And Migration -- [Canonical Todo Semantic Kernel and Read-Model Convergence v0](canonical-todo-semantic-kernel-v0.md) - ([中文版](canonical-todo-semantic-kernel-v0.zh-CN.md)) - - **RFC status:** Proposed implementation RFC. - - **Delivery on `main`:** Not yet shipped; this branch contains the first - compatibility-preserving extraction. - - **Current boundary:** Python Todo read semantics have one kernel and an - import-only projection facade; the TypeScript semantic packet and facade - retirement remain follow-up work. +- [Human-confirmed domain operations v0](human-confirmed-domain-operations-v0.md) + ([中文版](human-confirmed-domain-operations-v0.zh-CN.md)) + - **RFC status:** Draft. + - **Delivery on `main`:** Proposal only. + - **Current boundary:** Separates generic authenticated interaction, optional + financial execution and venue adapters. Defines shared frontend/Lark + confirmation and automatic outcomes; no runtime or trading permission added. - [Agent Loop Effect Interpreter v0](agent-loop-effect-interpreter-v0.md) ([中文版](agent-loop-effect-interpreter-v0.zh-CN.md)) - **RFC status:** Accepted. diff --git a/docs/architecture/rfcs/canonical-todo-semantic-kernel-v0.md b/docs/architecture/rfcs/canonical-todo-semantic-kernel-v0.md deleted file mode 100644 index 3260f5a9c2..0000000000 --- a/docs/architecture/rfcs/canonical-todo-semantic-kernel-v0.md +++ /dev/null @@ -1,191 +0,0 @@ -# RFC: Canonical Todo Semantic Kernel and Read-Model Convergence (v0) - -- Status: Proposed implementation RFC -- Proposed by: LoopX maintainers -- Date: 2026-09-12 -- Scope: the Todo semantic rules shared by the TypeScript authority path and - Python projections, building on the [TypeScript Control-Plane Migration - v0](typescript-control-plane-migration-v0.md) and the [Shared Goal Authority - and Pluggable State Providers v0](shared-goal-authority-state-provider-v0.md) -- Delivery shape: one compatibility-preserving Python kernel first, followed - by a language-neutral conformance packet for the TypeScript transaction path -- Language note: the [Chinese version](canonical-todo-semantic-kernel-v0.zh-CN.md) - is a semantic mirror; a difference is a defect. - -## 1. Problem - -The two parent RFCs correctly moved authority and transaction ownership toward -TypeScript, but their consumers still carried several copies of the same Todo -rules. `todos/projection.py` owned task classification, priority, monitor -eligibility, claim visibility, and frontier selection. `todos/todo_summary.py` -wrapped many of those functions again and intentionally called classification -with `text` only. Agent scope, quota preparation, Goal Frontier, work-lane -selection, and capability fallbacks each imported or rewrapped a subset. - -That split created two kinds of drift: - -1. the same record could be classified differently depending on whether the - caller read a projection or a summary; and -2. a rule fix had to be repeated across compatibility facades, even though the - canonical coordination record and the provider transaction already had one - identity contract. - -A title-only monitor is a concrete failure. A Todo with `title="Observe build -health"` and an empty `text` was monitor work in the projection path but could -be treated as advancement in the summary path. Claim exclusion had the same -risk when one caller looked at a projected frontier and another rebuilt it from -summary buckets. - -## 2. Decision - -Create `loopx.control_plane.todos.todo_semantics` as the single Python owner for -provider-neutral Todo read semantics. It owns the pure predicates and ordering -used by all callers: - -- task text and task-class resolution; -- open/actionable/deferred status; -- monitor due, expiry, and missing-schedule checks; -- priority extraction, ranking, and stable ordering; -- claim, exclusion, and removed-continuation eligibility; -- claimed visibility and advancement-frontier partitioning. - -`todos/projection.py` remains a compatibility export for external integrations -and older extensions. It contains no independent rule implementation. New -production code imports `todo_semantics` directly. `todo_summary.py` continues -to own summary assembly, compaction, and display-specific limits, but it calls -the kernel for every semantic predicate. - -The kernel resolves task text from `title` and `text` in that order, retaining -both values when present. A persisted explicit `task_class` still wins over -text inference. This fixes title-only records while preserving existing -explicit classifications and action-kind inference. - -The coordination provider remains a storage and CAS boundary. The kernel does -not become a writer, receipt authority, scheduler, or provider adapter. The -TypeScript authority remains the transaction owner described by the parent -RFCs; this RFC removes duplicated Python read policy so the next TypeScript -cutover can compare one semantic packet instead of several ad hoc projections. - -## 3. Semantic changes and repairs - -### 3.1 Title-aware classification - -All Python read paths now use the same `(title, text)` task text. Explicit -`task_class` continues to take precedence. A title-only monitor is therefore a -monitor in status, quota, Goal Frontier, and summary paths. This is a behavior -fix, not a cosmetic rename. - -### 3.2 Exclusion is eligibility, not ownership - -`excluded_agents` is evaluated after claim ownership. An unclaimed Todo can be -visible to one Agent and ineligible for another; exclusion never creates a -claim, lease, or authority grant. The kernel exposes this as one predicate so -frontier counters and selected IDs cannot disagree. - -### 3.3 Stable ordering is one policy - -Priority and index ordering are resolved in one place. Missing or malformed -priority remains the bounded rank used by existing projections. The compatibility -facade preserves the old import path and call signature while production callers -move to the kernel. - -### 3.4 Display limits remain non-semantic - -The kernel never treats a truncated list as the complete Todo set. Summary -assembly may cap display rows, but counts, frontier classification, and -coordination digests continue to use the complete source or an explicitly -qualified canonical projection. - -## 4. Ownership and non-goals - -This RFC owns read semantics only. It does not: - -- change the default provider, promote SQLite/NoKV/PostgreSQL, or migrate an - existing Goal; -- add a new lease, receipt, scheduler, or network authority; -- make Markdown a generated source of truth; -- infer user approval from `goal_bound`, actor identity, or title text; -- remove the compatibility import until downstream extension inventory and the - TypeScript conformance packet are complete. - -The capability owner is the existing Todo/control-plane contract. No new -capability or provider package is introduced. - -## 5. Migration plan - -1. **Kernel extraction (this slice).** Move the existing projection rule set to - `todo_semantics.py`, make `projection.py` a compatibility export, and migrate - production imports. Keep external import paths working. -2. **Summary convergence (this slice).** Remove the text-only classification - exception in `todo_summary.py`; summary limits and compaction remain local. -3. **Fixture conformance (this slice).** Extend the deterministic production- - scale fixture with title-only monitor, excluded unclaimed advancement, - explicit global gate without goal binding, and an expired lease edge. These - are synthetic declarations, not copied Goal state. -4. **TypeScript packet (next slice).** Emit the same semantic cases into the - language-neutral contract and compare TypeScript selection/classification - results with the Python kernel before deleting more Python adapters. -5. **Compatibility retirement (later).** After extension imports and the - TypeScript packet are audited, deprecate the `projection.py` facade in one - disclosed release and remove it only when no supported caller remains. - -Each step is reversible: the facade can be restored as an import-only shim, and -no provider selector or persisted revision changes. - -## 6. Validation contract - -The focused Python tests must prove title-aware classification, monitor due -behavior, exclusion/ownership separation, and fixture edge declarations. The -existing projection, canonical-governance, frontier, and long-history suites -must remain green. TypeScript typecheck and coordination/monitor/quota tests -must remain green because the fixture is consumed by both runtime families. - -The real local readback is deliberately read-only: - -```bash -loopx --format json status --goal-id loopx-meta -loopx --format json todo list --goal-id loopx-meta --role agent --status open -``` - -The first command verifies the live registry/runtime contract; the second -checks that the live Goal can still be read through the public Todo surface. -Neither command writes the Goal, changes a lease, or promotes a provider. A -non-zero result is a delivery hold rather than a reason to weaken the fixture. - -## 7. Acceptance criteria - -- `projection.py` has no independent semantic implementation. -- All production imports use `todo_semantics` or the summary assembler. -- The title-only monitor test passes through both direct and summary paths. -- The complex fixture remains deterministic, public-safe, and consumed by the - existing TypeScript conformance tests. -- Python focused tests, TypeScript typecheck/tests, `git diff --check`, and the - read-only `loopx-meta` commands pass. -- No private Goal state, credential, raw run log, local absolute path, or - generated artifact is committed. - -## 8. Duplication inventory - -Before this RFC, duplication was structural rather than byte-for-byte only: - -| Area | Repeated knowledge | Consequence | -| --- | --- | --- | -| `projection.py` and `todo_summary.py` | task classification, actionable status, priority ordering, monitor predicates | title/text disagreement and repeated fixes | -| agent scope and quota preparation | local wrappers around actionable/classification predicates | callers could silently choose different helper defaults | -| Goal Frontier and work-lane consumers | claim/exclusion and frontier partitioning | counters and selected IDs could diverge | -| Python projection and TypeScript transaction consumers | canonical Todo fields and provider read-model shape | migration required several comparison points | - -The first three rows are consolidated here. The fourth remains an explicit -cross-language conformance task for the next RFC slice; this PR does not claim -to delete the TypeScript authority or alter provider semantics. - -## 9. Open questions - -- Which supported extension release first stops importing `projection.py`? -- Should the TypeScript packet be generated from the coordination state contract - or remain a separately versioned semantic fixture? -- What evidence is sufficient to retire the compatibility facade without - surprising local plugins? - -Those questions do not block this read-policy consolidation because the facade -preserves the current public import surface. diff --git a/docs/architecture/rfcs/canonical-todo-semantic-kernel-v0.zh-CN.md b/docs/architecture/rfcs/canonical-todo-semantic-kernel-v0.zh-CN.md deleted file mode 100644 index f5f0341117..0000000000 --- a/docs/architecture/rfcs/canonical-todo-semantic-kernel-v0.zh-CN.md +++ /dev/null @@ -1,152 +0,0 @@ -# RFC:Canonical Todo 语义内核与读模型收敛(v0) - -- 状态:实现提案 -- 提议者:LoopX maintainers -- 日期:2026-09-12 -- 范围:收敛 TypeScript authority 路径与 Python projection 共用的 Todo - 语义规则,建立在 [TypeScript Control-Plane Migration v0](typescript-control-plane-migration-v0.md) - 与 [Shared Goal Authority and Pluggable State Providers v0](shared-goal-authority-state-provider-v0.md) - 之上 -- 交付形态:先提供一个保持兼容的 Python 语义内核,再为 TypeScript 事务路径 - 增加语言中立的 conformance packet -- 语言说明:[英文版本](canonical-todo-semantic-kernel-v0.md) 与本文必须语义镜像; - 存在差异即为缺陷。 - -## 1. 问题 - -两份父 RFC 已经把 authority 与事务所有权逐步收敛到 TypeScript,但 Todo -消费者仍然保留了多份相同规则。`todos/projection.py` 负责任务分类、优先级、 -monitor eligibility、claim visibility 和 frontier 选择; -`todos/todo_summary.py` 又重新包装了其中多数函数,并且刻意只用 `text` 做分类。 -agent scope、quota preparation、Goal Frontier、work-lane selection 和 capability -fallback 也各自导入或包装了其中一部分。 - -因此出现两类漂移:同一 Todo 在 projection 和 summary 路径上可能得到不同分类; -修复一条规则时,必须重复修改多个 facade。最直观的失败是只有 -`title="Observe build health"` 而 `text` 为空的 monitor:projection 能识别,summary -却可能把它当成 advancement。`excluded_agents` 也有同样风险:一个调用方读 -frontier,另一个调用方从 summary bucket 重建,结果可能不一致。 - -## 2. 决策 - -新增 `loopx.control_plane.todos.todo_semantics`,作为 provider-neutral Todo 读语义的 -唯一 Python owner。它负责: - -- task text 与 task-class 解析; -- open/actionable/deferred 状态; -- monitor 到期、过期、缺少 schedule; -- priority 提取、排序与稳定顺序; -- claim、排除与已移除 continuation 的 eligibility; -- claimed visibility 与 advancement frontier 分区。 - -`todos/projection.py` 保留为兼容导出,供旧扩展继续使用,但不再包含独立规则实现。 -新的生产代码直接导入 `todo_semantics`。`todo_summary.py` 继续负责 summary 组装、 -压缩和展示限制,但所有语义 predicate 都调用内核。 - -内核按 `title`、`text` 的顺序组合任务文本;显式持久化的 `task_class` 仍然优先于 -文本推断。这样修复只有 title 的 Todo,同时保留现有显式分类和 action-kind 推断。 - -coordination provider 仍然只是 storage/CAS 边界。内核不成为 writer、receipt authority、 -scheduler 或 provider adapter。TypeScript authority 仍是父 RFC 定义的事务 owner;本 RFC -只删除重复的 Python read policy,让下一步 TypeScript cutover 可以比较一份语义 packet。 - -## 3. 语义变化与修复 - -### 3.1 分类现在识别 title - -所有 Python 读路径统一使用 `(title, text)`。显式 `task_class` 仍优先。只有 title 的 -monitor 会在 status、quota、Goal Frontier 和 summary 中保持 monitor 分类。这是行为修复, -不是重命名。 - -### 3.2 排除属于 eligibility,不属于 ownership - -`excluded_agents` 在 claim ownership 之后判断。一个未 claim 的 Todo 可以对某个 Agent 可见, -同时对另一个 Agent 不可执行;排除不会制造 claim、lease 或 authority 授权。内核提供单一 -predicate,确保 frontier count 与 selected id 不再分叉。 - -### 3.3 稳定排序只有一套规则 - -priority 和 index 在同一个 owner 中解析。缺失或非法 priority 继续使用现有 projection 的 -有界 rank。兼容 facade 保留旧 import 路径和调用签名,生产调用方迁移到内核。 - -### 3.4 展示限制不改变语义 - -内核不会把截断列表当成完整 Todo 集合。summary 可以限制展示行数,但 count、frontier -分类和 coordination digest 继续使用完整 source 或明确 qualification 的 canonical projection。 - -## 4. 所有权与非目标 - -本 RFC 只负责读语义,不会: - -- 修改默认 provider、提升 SQLite/NoKV/PostgreSQL,或迁移现有 Goal; -- 新增 lease、receipt、scheduler 或网络 authority; -- 把 Markdown 变成 generated source of truth; -- 从 `goal_bound`、actor identity 或 title 文本推断用户批准; -- 在 extension inventory 与 TypeScript conformance packet 完成前删除兼容 import。 - -能力 owner 仍是现有 Todo/control-plane contract;不创建新 capability 或 provider package。 - -## 5. 迁移计划 - -1. **内核抽取(本阶段)。** 将 projection 规则移动到 `todo_semantics.py`,把 - `projection.py` 改成兼容导出,迁移生产 import,保留外部路径。 -2. **Summary 收敛(本阶段)。** 删除 `todo_summary.py` 只使用 text 的特例;summary 的 - 限制与压缩仍留在自身。 -3. **Fixture conformance(本阶段)。** 扩展 deterministic production-scale fixture,加入 - title-only monitor、未 claim 但排除某 Agent 的 advancement、显式 global gate(不依赖 - goal binding)和过期 lease。这些都是合成声明,不复制真实 Goal 状态。 -4. **TypeScript packet(下一阶段)。** 把相同 semantic cases 放入语言中立 contract, - 在删除更多 Python adapter 前比较 TypeScript 与 Python 的分类和选择结果。 -5. **兼容层退休(后续)。** 完成 extension import audit 后,在一次有披露的 release 中 - 弃用 `projection.py`,确认没有受支持调用方后再删除。 - -每一步都可回滚:可以恢复 import-only facade,不触碰 provider selector 或持久化 revision。 - -## 6. 验证契约 - -聚焦 Python 测试必须覆盖 title-aware classification、monitor due、exclusion/ownership 分离 -和 fixture edge 声明。既有 projection、canonical-governance、frontier、long-history 测试 -必须继续通过。TypeScript typecheck 以及 coordination/monitor/quota 测试也必须通过,因为 -fixture 同时被两套 runtime 使用。 - -真实本机读回保持只读: - -```bash -loopx --format json status --goal-id loopx-meta -loopx --format json todo list --goal-id loopx-meta --role agent --status open -``` - -第一条命令检查 live registry/runtime contract;第二条通过公共 Todo surface 检查 live Goal -仍可读。两条命令都不会写 Goal、改变 lease 或提升 provider。失败时应阻止交付,不能削弱 -fixture 或 gate。 - -## 7. 验收标准 - -- `projection.py` 不再含独立语义实现; -- 所有生产 import 使用 `todo_semantics` 或 summary assembler; -- title-only monitor 同时通过 direct 与 summary 路径; -- complex fixture 确定、public-safe,并被现有 TypeScript conformance 测试消费; -- Python 聚焦测试、TypeScript typecheck/tests、`git diff --check` 与只读 `loopx-meta` 命令通过; -- 不提交私有 Goal 状态、凭据、原始 run log、本机绝对路径或生成物。 - -## 8. 重复清单 - -本 RFC 前的重复主要是结构性重复,不只是相同代码文本: - -| 区域 | 重复知识 | 后果 | -| --- | --- | --- | -| `projection.py` 与 `todo_summary.py` | task classification、actionable status、priority、monitor predicate | title/text 分歧,修复要重复做 | -| agent scope 与 quota preparation | actionable/classification 的本地 wrapper | 调用方可能使用不同 helper 默认值 | -| Goal Frontier 与 work-lane consumer | claim/exclusion 与 frontier 分区 | count 与 selected id 可能不一致 | -| Python projection 与 TypeScript transaction consumer | canonical Todo 字段与 provider read-model shape | migration 需要多处比较点 | - -前 3 行由本 RFC 收敛;第 4 行仍是下一阶段的跨语言 conformance 任务。本 PR 不声称删除 -TypeScript authority,也不改变 provider 语义。 - -## 9. 未决问题 - -- 哪一个受支持 extension release 可以停止 import `projection.py`? -- TypeScript packet 应从 coordination state contract 生成,还是保持独立版本的 semantic fixture? -- 在不惊动本地 plugin 的前提下,退休兼容 facade 需要哪些证据? - -这些问题不阻塞本次 read-policy 收敛,因为 facade 保留了现有 public import surface。 diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index dde8a869c5..86ad1f13e8 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -24,6 +24,14 @@ end-to-end causal chain is covered by a shared composition fixture. This is a completed delivery stage, not a promotion of Markdown or a claim that the remaining lifecycle writers have migrated. +The same stage also removes duplicated Python read policy around that boundary. +Task-class resolution, title-aware actionability, dependency readiness, agent +eligibility, priority ordering, and canonical Todo read records now have one +Python semantic owner while TypeScript remains the transaction owner. The old +projection module is an import-only compatibility facade. This keeps the +replacement-first rule intact: compatibility remains available, but it cannot +silently become a second semantic implementation. + Native update now composes `todos/public_update.ts` for a bounded nonterminal planning intent (status, evidence/reason, resume/clear and successor links), against the same complete canonical head used for authority checks and CAS. 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 890fec5ad0..52439147c5 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 @@ -21,6 +21,12 @@ Provider readback 在 acknowledgement 决策前进行校验,端到端因果链 fixture 覆盖。这是一个已完成的交付阶段,不代表 Markdown 晋升,也不声称其余 lifecycle writer 已全部迁移。 +同一阶段也删除了该边界周围重复的 Python read policy。task-class 解析、识别 title +的 actionable 判断、依赖就绪、Agent eligibility、priority 排序和 canonical Todo +read record 现在只有一个 Python 语义 owner,而 TypeScript 仍是事务 owner。旧 +projection 模块只保留 import-only 兼容 facade。这样继续遵守 replacement-first: +兼容路径仍可用,但不能静默形成第二份语义实现。 + Native update 现通过 `todos/public_update.ts` 组合有界的非终态 planning intent (status、evidence/reason、resume/clear、successor links),使用权限检查与 CAS 同一份完整 canonical head。独立 intent 命名空间不扩大原 text/note patch allowlist, diff --git a/docs/book/chapters/source-protocol-map.md b/docs/book/chapters/source-protocol-map.md index 4c7afd78ee..b3cbc82e39 100644 --- a/docs/book/chapters/source-protocol-map.md +++ b/docs/book/chapters/source-protocol-map.md @@ -427,10 +427,6 @@ disposition,并指出 owner、下一产物与复核条件。需要跨方向同 缩小切片不是只减少行数,而是保持一条完整因果链: -当用户明确要求推进一个完整 RFC 阶段时,这条因果链可以跨越多个模块、provider、fixture -和验证路径。只要这些部分共同构成完整阶段且能一起审阅,较大的 PR 是合适的。应按独立的 -变更原因或不安全的审阅边界拆分,不应仅为了减少文件数而拆开一个连贯阶段。 - ```text source -> invariant diff --git a/docs/book/en/chapters/source-protocol-map.md b/docs/book/en/chapters/source-protocol-map.md index 8bcb332992..4549bca799 100644 --- a/docs/book/en/chapters/source-protocol-map.md +++ b/docs/book/en/chapters/source-protocol-map.md @@ -453,12 +453,6 @@ This is usually too broad: Right-sizing is not only reducing line count. Preserve one complete causal chain: -When the request explicitly targets a complete RFC stage, that causal chain may -span several modules, providers, fixtures, and validation paths. A larger PR is -appropriate when those pieces are required to make the stage complete and are -reviewed together. Split independent change reasons or unsafe review surfaces, -not a coherent stage merely to reduce the file count. - ```text source -> invariant From 844840c43b18806e0da041587fe7f985d4c7707e Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 15:59:04 +0800 Subject: [PATCH 10/12] fix(todo): preserve typed projection delivery parse Signed-off-by: huangruiteng --- loopx/control_plane/todos/projection_delivery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loopx/control_plane/todos/projection_delivery.ts b/loopx/control_plane/todos/projection_delivery.ts index 3c75397cf3..eea6577409 100644 --- a/loopx/control_plane/todos/projection_delivery.ts +++ b/loopx/control_plane/todos/projection_delivery.ts @@ -12,7 +12,7 @@ export function projectionDelivery(changed: boolean): TodoProjectionDelivery { /** Decode provider readback without letting ad-hoc strings cross the boundary. */ export function parseProjectionDelivery(value: unknown): TodoProjectionDelivery { if (typeof value === "string" && PROJECTION_DELIVERY_VALUES.has(value as TodoProjectionDelivery)) { - return value; + return value as TodoProjectionDelivery; } throw new Error(`projection_delivery is unsupported: ${String(value)}`); } From 17f602d70ee1c43b964c6dc124c4b6bd1dc477ee Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 19:37:27 +0800 Subject: [PATCH 11/12] fix(status): align todo semantic compatibility owner Signed-off-by: huangruiteng --- loopx/status.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loopx/status.py b/loopx/status.py index 09fdf909e4..fb021fdcd8 100644 --- a/loopx/status.py +++ b/loopx/status.py @@ -216,7 +216,7 @@ "todo_item_next_due_at": "loopx.control_plane.todos.todo_summary", "todo_projection_sort_key": "loopx.control_plane.todos.todo_summary", "normalize_todo_task_class": "loopx.control_plane.todos.contract", - "todo_item_is_expired_monitor": "loopx.control_plane.todos.projection", + "todo_item_is_expired_monitor": "loopx.control_plane.todos.todo_semantics", } From 3916d923c5aef509252e8a64cb2e804ebdc14143 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 20:07:00 +0800 Subject: [PATCH 12/12] test(control-plane): refresh dashboard payload budget Signed-off-by: huangruiteng --- docs/reference/contracts/interface-budget-contract.md | 2 +- examples/control_plane/hot-path-interface-budget-smoke.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/contracts/interface-budget-contract.md b/docs/reference/contracts/interface-budget-contract.md index ee307582b9..bcb3c83f5f 100644 --- a/docs/reference/contracts/interface-budget-contract.md +++ b/docs/reference/contracts/interface-budget-contract.md @@ -11,7 +11,7 @@ and size/count budgets. | `heartbeat_prompt_json` | heartbeat automation | wake and route one bounded turn | `quota should-run`, `status`, or `review-packet --handoff-only` | `json_chars <= 4800` plus `interface_budget.within_budget=true` | `nested_keys <= 40` | `top_level_keys <= 30` | | `review_packet_handoff_only_json` | project-agent handoff | forward the smallest sufficient task packet | full `review-packet` or run-history artifact | `json_chars <= 3000` plus `handoff_interface_budget.within_budget=true` | `nested_keys <= 40` | `top_level_keys <= 18` | | `quota_should_run_json` | quota guard | decide whether the selected goal may spend compute | `status`, `history`, or active state | `json_chars <= 13000` | `nested_keys <= 330` | `top_level_keys <= 52` | -| `dashboard_status_json` | operator dashboard | render first-screen operator state | `history`, run artifacts, or project-local adapter output | `json_chars <= 18500` | `nested_keys <= 260` | `top_level_keys <= 25` | +| `dashboard_status_json` | operator dashboard | render first-screen operator state | `history`, run artifacts, or project-local adapter output | `json_chars <= 19500` | `nested_keys <= 260` | `top_level_keys <= 25` | These four budgets measure compact machine payloads. For `heartbeat_prompt_json`, the measured payload is the actual diff --git a/examples/control_plane/hot-path-interface-budget-smoke.py b/examples/control_plane/hot-path-interface-budget-smoke.py index 05f39b87a4..5eb564a7fb 100644 --- a/examples/control_plane/hot-path-interface-budget-smoke.py +++ b/examples/control_plane/hot-path-interface-budget-smoke.py @@ -76,7 +76,7 @@ "owner": "operator dashboard", "consumer": "render first-screen operator state", "cold_path": "history, run artifacts, or project-local adapter output", - "max_json_chars": 18_500, + "max_json_chars": 19_500, "max_nested_keys": 260, "max_top_level_keys": 25, },