diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 44368ff77e..4ab3bba3d5 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -84,6 +84,44 @@ Each page reuses the existing executable-revision request scope; operation records remain live reads, and the next page resolves the current code again. +### Todo read transaction source checkpoint + +`todo.read` now composes canonical provider reads, full-source resume and +succession evidence, standing decisions, both role summaries, selection and the +public result through `todos/read_projection.ts` and `todos/read_transaction.ts`. +Ordinary canonical queries use one request. Large result continuations retain +one observation and result digest; changed results refuse rather than combining +pages. Dependency and closure decisions still use the complete source. +Python retains registry, document/material IO, independent Vision-wait consumers +and compatibility exports. Its replaced summary, filter and result coordination +is retired. Persisted completion spellings use shared token data and compatibility codecs +without scalar RPCs; +`todo.summary.project`, `todo.completion_state.normalize` and +`todo.completion_state.require_metadata` are retired after their production +callers exit. Existing semantic owners execute in process inside the transaction. + +CLI query results carry a query-scoped `projection_envelope`. A read grants no +execution, acceptance, lease or mutation authority. The shared wire and private +snapshot budgets are unchanged. Oversized Python facts use an owned temporary +source, not another durable store; complete results use digest-bound pieces. +Envelope coverage counts the complete matching source before display limits. +Hot, limited and thin views report their final displayed count separately; +display truncation alone does not create a source omission or alert. Exact +detail reads count their returned Todo rather than the removed list view. +Remaining historical/source adapters still name their actual IO and crossings. +Python source codecs and transport now share the existing +`todos/goal_todo_projection.py` module. The separate Python `read_projection.py` +is removed; the independent py/ts twin budget stays at 43. Current host oracle +observations remain with the existing acceptance owner. +The unchanged 18-row Todo output matrix adds 783–854 JSON characters for source +freshness and omitted-work coverage; Markdown is unchanged. Only the first +envelope schema transition receives a 896-character allowance on the three +measured Todo query views. Existing absolute ceilings and later growth limits +remain in force. These are controlled-transport output measurements. +Source/controlled-IPC evidence does not qualify normal installed CLI/Host use, +full-command performance, R3 adoption or D2/D3/default delivery. + + ## Current delivery frontier (post-merge, 2026-10-16) 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 3dee7c57d9..b1bc9e2699 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 @@ -71,6 +71,36 @@ Python 保留有界地址扫描、请求者注册检查和实时 `Delegations.re 每页复用既有代码版本请求作用域;操作记录仍实时读取,下一页重新解析当前代码。 +### Todo 读取事务源码检查点 + +`todo.read` 现由 `todos/read_projection.ts` 与 `todos/read_transaction.ts` +组合 canonical provider 读取、完整源上的恢复与后继证据、standing decisions、 +两个角色的摘要、筛选与公共结果。普通 canonical 查询使用一次请求。 +大结果的连续页保留同一次观察和结果摘要;结果变化即拒绝,不拼接新旧页。 +依赖与闭合判断仍使用完整源。 +Python 保留 registry、文档/材料 IO、独立的 Vision-wait 消费者及兼容导出。 +被替代的摘要、筛选与结果协调已退役。持久化 completion 拼写使用共享 token 数据与兼容 codec, +不再逐标量调用 RPC;实际生产调用者退出后,退役 `todo.summary.project`、 +`todo.completion_state.normalize` 与 `todo.completion_state.require_metadata`。 +事务在进程内执行既有语义 owner。 + +CLI 查询结果携带查询范围内的 `projection_envelope`。读取不授予执行、验收、 +租约或修改权限。共享 wire 与私有快照预算不变。超大 Python 事实使用私有临时 +源,不增加持久化存储;完整结果通过摘要绑定的片段传输。 +信封覆盖计数使用显示限制前的完整匹配源。hot、限量及 thin 视图分别报告最终 +显示数量;显示截断本身不产生来源遗漏或告警。精确详情按返回的 Todo 计数, +不按已移除的列表视图计数。 +仍保留的历史/源适配明确记录实际 IO 与跨运行时调用。 +Python 源 codec 与传输现在共用既有 `todos/goal_todo_projection.py` 模块, +删除独立的 Python `read_projection.py`,独立维护的 py/ts 配对预算仍为 43。 +当前宿主 oracle 观测仍归既有 acceptance owner。 +同一 18 行 Todo 输出矩阵为来源新鲜度和遗漏工作覆盖增加 783–854 个 JSON 字符, +Markdown 不变。仅三个已测 Todo 查询视图的首次信封 schema 迁移获得 896 字符 +余量;既有绝对上限与后续增长限制继续生效。这些是受控传输下的输出测量。 +源码/受控 IPC 证据不代表正常安装的 CLI/Host +使用、完整命令性能、R3 采用或 D2/D3/默认交付资格。 + + ## 当前交付边界(合入后,2026-10-16) diff --git a/docs/reference/todo-work-counts.md b/docs/reference/todo-work-counts.md index 31dfe11363..fbc673c118 100644 --- a/docs/reference/todo-work-counts.md +++ b/docs/reference/todo-work-counts.md @@ -18,6 +18,11 @@ new provider setting: legacy inputs and promoted File/SQLite inputs share the same typed summary owner. PostgreSQL uses the same provider-neutral records; service deployment and whole-Goal promotion remain separately qualified. +The list's `projection_envelope` also separates source coverage from display. +Its `included_count` covers all query matches; `shown_count` counts the final +ordinary, hot, limited, thin or exact view. Display truncation alone does not +raise `incomplete_coverage`; see the [envelope contract](contracts/projection-envelope-contract.md). + | Field | Meaning | | --- | --- | | `open` | Nonterminal source rows, including blocked work; it is not executable work | @@ -51,8 +56,9 @@ are lower bounds while `complete=false`. Current capability re-evaluation replaces prior readiness for observed rows and cannot certify unseen rows. The TypeScript `todos/summary_lanes.ts` owner returns indexes into the one input -array instead of repeating full Todo bodies for each lane. The Python adapter -normalizes legacy fields/timestamps and validates the returned ordinal bounds. +array instead of repeating full Todo bodies for each lane. The native read owner +normalizes canonical fields/timestamps and materializes the selected records. +Python retains the legacy source codecs. One observation time governs Monitor due and expiry classification within the batch. Source order, completed/deferred conventions and claimant visibility remain compatible; `done_count` still includes deferred rows as required by its @@ -92,11 +98,14 @@ read and continue to show the whole Goal; no new configuration editor is needed. Resume and succession are evaluated on the complete source before selection. The typed batch filters rows without renumbering their original source indexes, then builds lanes/counts, and only then applies display limits. Status/identity -filters do not recompute dependencies from their smaller view. One internal `todo.summary.project` batch now composes selection, counts, -visibility allocation and closure. Its transient request replaces the separate -lane and closure RPC calls; it does not change persisted Todo or public summary -schemas. Python decodes legacy input, validates source ordinals and materializes -public fields, with no independent summary count, cap or claimant-allocation rule. +filters do not recompute dependencies from their smaller view. The `todo.read` transaction composes source evaluation, selection, counts, +frontier identity, handoff context and result construction through the existing +TypeScript owners. Canonical provider IO runs in that transaction; Markdown +source codecs and material IO remain explicit Python adapters. Filtering reuses +validated full-source evidence, never dependencies inferred from a small view. +The old `todo.summary.project` transport and Python ordinal materializer are +retired. Large source/result transport preserves complete decisions and refuses +changed continuations; display limits still disclose shown and available counts. Route-continuation visibility also uses the existing typed quota planning batch. It shares the normal claim/exclusion rule; eligibility, first-identity deduplication, diff --git a/loopx/capabilities/periodic_report/post_writeback_hook.py b/loopx/capabilities/periodic_report/post_writeback_hook.py index 6b03f7a1c9..a10e6158e6 100644 --- a/loopx/capabilities/periodic_report/post_writeback_hook.py +++ b/loopx/capabilities/periodic_report/post_writeback_hook.py @@ -16,10 +16,6 @@ build_goal_frontier_projection_from_summaries, ) from .todo_source import read_report_source_history, read_report_todo_source -from ...control_plane.effect_runtime import ( - CANONICAL_AUTHORITY_READ_TIMEOUT_SECONDS, - effect_runtime_result, -) from ...control_plane.todos.quota_summary import summarize_user_todos_for_quota from ...control_plane.todos.todo_index import MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL from ...history import collect_history, load_index_snapshot, load_registry @@ -202,18 +198,18 @@ def build_periodic_report_post_writeback_projection( if available_capabilities is None and isinstance(payload.get("turn"), Mapping): available_capabilities = payload["turn"].get("available_capabilities") if source_todos is None and "todo_source" in payload: - source_todos = effect_runtime_result( - "coordination.local_authority.todo_source", - { - "schema_version": "loopx_local_coordination_todo_source_request_v0", - "runtime_root": str(runtime_root), "goal_id": goal_id, - "source": payload["todo_source"], - }, - timeout=CANONICAL_AUTHORITY_READ_TIMEOUT_SECONDS, - large_local_snapshot=True, - ) + from ...control_plane.todos.goal_todo_projection import project_todo_read, restore_todo_field_evaluations + from ...control_plane.runtime.time import now_utc + + now = now_utc() + readback = project_todo_read({"kind": "historical_fields", + "source": {"runtime_root": str(runtime_root), "goal_id": goal_id, "original_source": payload["todo_source"]}, + "rollout_events": [], "available_capabilities": available_capabilities, + "observed_at": now.timestamp(), "evaluated_at": now.isoformat()}) + source_todos = readback["canonical"] if source_todos.get("status") != "loaded": raise ValueError("original canonical Todo source is unavailable") + source_todos["_todo_fields"] = restore_todo_field_evaluations(readback) source_runs = read_report_source_history( runtime_root=runtime_root, goal_id=goal_id, run=payload, ) @@ -228,7 +224,7 @@ def build_periodic_report_post_writeback_projection( state.get("path") if isinstance(state, Mapping) else None ) or payload.get("state_file") if source_todos is not None: - fields = canonical_todo_summary_fields( + fields = source_todos.get("_todo_fields") or canonical_todo_summary_fields( source_todos["todos"], rollout_events=events, available_capabilities=available_capabilities, diff --git a/loopx/capabilities/periodic_report/todo_source.py b/loopx/capabilities/periodic_report/todo_source.py index b925285b29..b6ef0d6350 100644 --- a/loopx/capabilities/periodic_report/todo_source.py +++ b/loopx/capabilities/periodic_report/todo_source.py @@ -11,7 +11,6 @@ from typing import Any from ...control_plane.coordination.local_authority import ( - canonical_todo_summary_fields, read_canonical_todos_if_promoted, ) from ...control_plane.todos.active_state_todo_parser import parse_active_state_todos @@ -62,16 +61,11 @@ def read_report_todo_source( registry, None, registry_path=registry_path ) canonical = read_canonical_todos_if_promoted( - runtime_root=runtime_root, goal_id=goal_id + runtime_root=runtime_root, goal_id=goal_id, include_summary=True, + rollout_events=rollout_events, available_capabilities=available_capabilities ) if canonical is not None: - fields = canonical_todo_summary_fields( - canonical["todos"], - rollout_events=rollout_events, - available_capabilities=available_capabilities, - goal_acceptance_contract=canonical.get("goal_acceptance_contract"), - goal_acceptance_work_guards=canonical.get("goal_acceptance_work_guards"), - ) + fields = canonical["_todo_fields"] return fields, [row for row in canonical["todos"] if row.get("role") == "user"] state_path = state_path or resolve_state_file( Path(str(goal.get("repo") or "")).expanduser(), diff --git a/loopx/cli_commands/todo_list.py b/loopx/cli_commands/todo_list.py index 335f80feb2..3f4a26b16d 100644 --- a/loopx/cli_commands/todo_list.py +++ b/loopx/cli_commands/todo_list.py @@ -42,6 +42,7 @@ def handle_todo_list_command( project=Path(args.project).expanduser() if args.project else None, state_file=Path(args.state_file).expanduser() if args.state_file else None, runtime_root_arg=runtime_root_arg, + json_references=(format_name or str(getattr(args, "format", None) or "markdown")) == "json", ) except Exception as exc: from ..usage_ping import capture_failure @@ -56,6 +57,7 @@ def handle_todo_list_command( ) output_format = format_name or str(getattr(args, "format", None) or "markdown") if (payload.get("ok") and not args.todo_id and not args.todo_thin + and "todo_list_record_references" not in payload and output_format == "json"): try: payload = effect_runtime_result( diff --git a/loopx/control_plane/collaboration/conversation_binding.ts b/loopx/control_plane/collaboration/conversation_binding.ts index 466fb6c52d..cf3068d553 100644 --- a/loopx/control_plane/collaboration/conversation_binding.ts +++ b/loopx/control_plane/collaboration/conversation_binding.ts @@ -496,7 +496,7 @@ function planContextRecovery(params: JsonObject, row: JsonObject, current: JsonO || !["context_window_exceeded", "host_gate", "upstream_transport_failure", "server_overloaded"].includes(String(failed.error_code)) || typeof failed.upstream_turn_id !== "string" || !failed.upstream_turn_id) return no; const previous = row.context_recovery == null ? null : requireJsonObject(row.context_recovery, "reserved context recovery"); - if (recovery.admitted_turn != null && previous?.failed_turn_id === failed.turn_id + if (recovery.admitted_turn != null && previous !== null && previous.failed_turn_id === failed.turn_id && Number.isSafeInteger(previous.routing_attempt) && Number(previous.routing_attempt) >= 1 && Number(previous.routing_attempt) <= 3 && previous.version === version) { // A crash after queue admission must adopt that exact canonical Turn, @@ -560,7 +560,7 @@ function planRoutingRecovery(recovery: JsonObject, row: JsonObject, current: Jso failed: JsonObject, previous: JsonObject | null, version: number): JsonObject | null { if (!["host_gate", "upstream_transport_failure"].includes(String(failed.error_code))) return null; // An unfinished older reservation must finish under its original identity. - if (previous?.failed_turn_id === failed.turn_id && previous.routing_attempt === undefined) return null; + if (previous !== null && previous.failed_turn_id === failed.turn_id && previous.routing_attempt === undefined) return null; const observed = recovery.native_observation == null ? null : requireJsonObject(recovery.native_observation, "native failure readback"); if (observed && observed.pre_execution_routing_timeout !== true) return null; diff --git a/loopx/control_plane/coordination/local_authority.py b/loopx/control_plane/coordination/local_authority.py index f6d3c6c6bd..98f45aa9e5 100644 --- a/loopx/control_plane/coordination/local_authority.py +++ b/loopx/control_plane/coordination/local_authority.py @@ -7,8 +7,6 @@ from __future__ import annotations -from ..goals.acceptance_readback import configured_checks - from collections.abc import Mapping from copy import deepcopy from pathlib import Path @@ -247,6 +245,7 @@ def claim_canonical_todo_if_promoted( def read_canonical_todos_if_promoted( *, runtime_root: Path, goal_id: str, include_leases: bool = False, projection_readback: Mapping[str, Any] | None = None, + include_summary: bool = False, rollout_events=None, available_capabilities=None, ) -> dict[str, Any] | None: """Return canonical Todos after cutover, or ``None`` before cutover. @@ -258,13 +257,28 @@ def read_canonical_todos_if_promoted( if not local_authority_is_promoted(runtime_root=runtime_root, goal_id=goal_id): return None - result = read_canonical_snapshot( - rpc=effect_runtime_result, - runtime_root=str(runtime_root.expanduser().resolve(strict=False)), - goal_id=goal_id, include_leases=include_leases, - projection_readback=projection_readback, - timeout=LOCAL_COORDINATION_TODO_LIST_TIMEOUT_SECONDS, - ) + projected = None + if include_summary: + from ..todos.goal_todo_projection import project_todo_read + from ..runtime.time import now_utc + + now = now_utc() + projected = project_todo_read({"kind": "canonical_fields", + "source": {"runtime_root": str(runtime_root.expanduser().resolve(strict=False)), "goal_id": goal_id, + "include_leases": include_leases, "projection_readback": dict(projection_readback) if projection_readback is not None else None}, + "rollout_events": list(rollout_events or []), + "rollout_event_source": getattr(rollout_events, "source", None), + "available_capabilities": sorted({str(value).strip().lower() for value in available_capabilities if str(value).strip()}) if available_capabilities is not None else None, + "observed_at": now.timestamp(), "evaluated_at": now.isoformat()}) + result = projected["canonical"] + else: + result = read_canonical_snapshot( + rpc=effect_runtime_result, + runtime_root=str(runtime_root.expanduser().resolve(strict=False)), + goal_id=goal_id, include_leases=include_leases, + projection_readback=projection_readback, + timeout=LOCAL_COORDINATION_TODO_LIST_TIMEOUT_SECONDS, + ) if not isinstance(result, Mapping): raise LocalCoordinationAuthorityUnavailable( "local coordination authority returned an invalid Todo list", @@ -318,6 +332,9 @@ def read_canonical_todos_if_promoted( "canonical projection confirmation is missing or invalid", code="local_authority_projection_confirmation_invalid", payload=payload, ) + if projected is not None and "fields" in projected: + from ..todos.goal_todo_projection import restore_todo_field_evaluations + payload["_todo_fields"] = restore_todo_field_evaluations(projected) return payload @@ -340,8 +357,10 @@ def read(self, *, runtime_root: Path, goal_id: str) -> dict[str, Any] | None: key = (runtime_root.expanduser().resolve(strict=False), goal_id) if key not in self._reads: try: + from ..todos.resume_condition import TodoResumeRolloutEvents self._reads[key] = read_canonical_todos_if_promoted( - runtime_root=key[0], goal_id=goal_id, + runtime_root=key[0], goal_id=goal_id, include_summary=True, + rollout_events=TodoResumeRolloutEvents([], runtime_root=key[0], goal_id=goal_id), ) except LocalCoordinationAuthorityUnavailable as error: # A failed first read cannot become a successful attention view @@ -369,13 +388,10 @@ def read_canonical_todo_fields_if_promoted( read neither repairs Markdown nor grants mutation/promotion authority. Callers pass the same fields to all decisions in one planning operation. """ - canonical = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=goal_id) - return ( - canonical_todo_summary_fields(canonical["todos"], rollout_events=rollout_events, - goal_acceptance_contract=canonical.get("goal_acceptance_contract"), - goal_acceptance_work_guards=canonical.get("goal_acceptance_work_guards")) - if canonical is not None else None - ) + canonical = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=goal_id, + include_summary=True, rollout_events=rollout_events) + return canonical["_todo_fields"] if canonical is not None else None + def canonical_todo_summary_fields( @@ -386,69 +402,12 @@ def canonical_todo_summary_fields( goal_acceptance_contract: dict[str, Any] | None = None, goal_acceptance_work_guards: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Adapt canonical records into the existing Todo summary read model.""" + from ..todos.goal_todo_projection import canonical_todo_fields - from ..todos.active_state_editing import TODO_SECTION_HEADINGS - from ..todos.standing_decision import build_standing_decision_authority - from ..todos.todo_summary import compact_todo_group, count_advancement_todos - - # Read standing decisions before assigning presentation-only indexes, and - # include retained history so archiving a revocation cannot revive approval. - standing_authority = build_standing_decision_authority( - [item for item in todos if item.get("role") == "user"], canonical_records=True, - ) - archived_ids = { - item["todo_id"] - for item in todos - if item.get("archive_state") == "archive" - } - todos = canonical_todo_items(todos) - # These are native authority decisions, not persisted Todo fields. Keep the - # records visible while every summary/selection uses the same work guard. - if goal_acceptance_contract and configured_checks(goal_acceptance_contract) is not None: - guards = goal_acceptance_work_guards or {} - todos = [{**item, "goal_acceptance_guard": guards[item["todo_id"]]} - if item.get("todo_id") in guards else item for item in todos] - fields: dict[str, Any] = {} - for role in ("user", "agent"): - items = [ - item - for item in todos - if ("user" if item.get("role") == "user" else "agent") == role - and item.get("todo_id") not in archived_ids - ] - summary = compact_todo_group( - items, - source_section=TODO_SECTION_HEADINGS[role], - role=role, - include_empty_source=True, - resume_source_items=todos, - rollout_events=rollout_events, - available_capabilities=available_capabilities, - item_limit=None, - ) - if summary: - if role == "agent": - if goal_acceptance_contract and (configured_checks(goal_acceptance_contract) is not None or goal_acceptance_contract.get("outcome_requirements")): - summary["goal_acceptance_contract"] = goal_acceptance_contract - archived_done = count_advancement_todos( - [ - item - for item in todos - if item.get("todo_id") in archived_ids - and item.get("done") is True - ] - ) - if archived_done: - summary["archived_advancement_done_count"] = archived_done - summary["advancement_done_count"] = ( - int(summary.get("advancement_done_count") or 0) + archived_done - ) - fields[f"{role}_todos"] = summary - if role == "user": - if standing_authority: - fields["standing_decision_authority"] = standing_authority - return fields + return canonical_todo_fields(todos, rollout_events=rollout_events, + available_capabilities=available_capabilities, + goal_acceptance_contract=goal_acceptance_contract, + goal_acceptance_work_guards=goal_acceptance_work_guards) def canonical_todo_items(todos: list[dict[str, Any]]) -> list[dict[str, Any]]: diff --git a/loopx/control_plane/effect_runtime.py b/loopx/control_plane/effect_runtime.py index 8f4e59e89b..41f865d165 100644 --- a/loopx/control_plane/effect_runtime.py +++ b/loopx/control_plane/effect_runtime.py @@ -42,6 +42,7 @@ MAX_REQUEST_BYTES = 2 * 1024 * 1024 MAX_LOCAL_SNAPSHOT_BYTES = 64 * 1024 * 1024 LOCAL_SNAPSHOT_METHODS = frozenset({ + "todo.read", "collaboration.inbox.read", "collaboration.delegation.inventory_page", "benchmark.trajectory.codex-usage", diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 7f39a1b37a..b7476c528c 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -268,13 +268,11 @@ export function createEffectRuntimeHandlers( ["turn_journal.find_settlement", lazyHandler(() => import("./turn_driver/turn_journal_query.ts"), ({findTurnJournalBySettlement}) => findTurnJournalBySettlement)], ["turn_journal.observed_capabilities", lazyHandler(() => import("./turn_driver/turn_journal_query.ts"), ({readTurnJournalCapabilities}) => readTurnJournalCapabilities)], ["todo.completion_fence.evaluate", lazyHandler(() => import("./todos/completion_fence.ts"), ({evaluateTodoCompletionFence}) => evaluateTodoCompletionFence)], - ["todo.completion_state.normalize", lazyHandler(() => import("./todos/completion_state.ts"), ({normalizeTodoCompletionValue}) => normalizeTodoCompletionValue)], - ["todo.completion_state.require_metadata", lazyHandler(() => import("./todos/completion_state.ts"), ({requireTodoCompletionMetadataValue}) => requireTodoCompletionMetadataValue)], ["todo.field_update.plan", lazyHandler(() => import("./todos/field_update.ts"), ({planTodoFieldUpdate}) => planTodoFieldUpdate)], ["todo.priority.plan", lazyHandler(() => import("./todos/priority.ts"), ({evaluateTodoPriority}) => evaluateTodoPriority)], ["todo.public_update.plan", lazyHandler(() => import("./todos/public_update.ts"), ({planPublicTodoUpdate}) => planPublicTodoUpdate)], ["todo.standing_decision.project", lazyHandler(() => import("./todos/standing_decision.ts"), ({evaluateStandingDecisionProjection}) => evaluateStandingDecisionProjection)], - ["todo.summary.project", lazyHandler(() => import("./todos/summary_projection.ts"), ({projectTodoSummary}) => projectTodoSummary)], + ["todo.read", lazyHandler(() => import("./todos/read_transport.ts"), ({readTodoRuntime}) => readTodoRuntime)], ["capabilities.periodic_report.progress.select", lazyHandler(() => import("./capabilities/periodic_report_progress.ts"), ({selectPeriodicReportProgress}) => selectPeriodicReportProgress)], ["capabilities.periodic_report.approval_retry.select", lazyHandler(() => import("./capabilities/periodic_report_progress.ts"), ({selectPeriodicReportApprovalRetry}) => selectPeriodicReportApprovalRetry)], ["todo.succession.project", lazyHandler(() => import("./todos/succession.ts"), ({projectTodoSuccession}) => projectTodoSuccession)], diff --git a/loopx/control_plane/effect_runtime_server.ts b/loopx/control_plane/effect_runtime_server.ts index 6a06e308c7..54f2e767fa 100644 --- a/loopx/control_plane/effect_runtime_server.ts +++ b/loopx/control_plane/effect_runtime_server.ts @@ -28,6 +28,7 @@ const MAX_REQUEST_BYTES = 2 * 1024 * 1024; const MAX_INLINE_RESPONSE_BYTES = 2 * 1024 * 1024; // Explicit opt-in: ordinary effects retain the 2 MiB request/response wire. const LOCAL_SNAPSHOT_METHODS = new Set([ + "todo.read", "collaboration.inbox.read", "collaboration.delegation.inventory_page", "benchmark.trajectory.codex-usage", diff --git a/loopx/control_plane/effect_runtime_snapshot.ts b/loopx/control_plane/effect_runtime_snapshot.ts index 94f493657b..b3bd40438a 100644 --- a/loopx/control_plane/effect_runtime_snapshot.ts +++ b/loopx/control_plane/effect_runtime_snapshot.ts @@ -60,6 +60,12 @@ export async function readPrivateJsonSnapshot(value: unknown): Promise } } +/** A domain-owned source can exceed a wire frame; it still uses the same + * private-file admission. The domain verifies its full source identity. */ +export async function openPrivateSnapshotSource(path: unknown): Promise { + return privateFile(requireNonEmptyString(path, "source snapshot path"), constants.O_RDONLY); +} + export async function openPrivateResponseSink(path: unknown): Promise { try { return await privateFile(requireNonEmptyString(path, "response sink"), constants.O_WRONLY); diff --git a/loopx/control_plane/testing/cli_output_budget.py b/loopx/control_plane/testing/cli_output_budget.py index 122c3f9f0e..8cb7837323 100644 --- a/loopx/control_plane/testing/cli_output_budget.py +++ b/loopx/control_plane/testing/cli_output_budget.py @@ -327,7 +327,7 @@ class CliOutputCommandClassification: "role/status filters, full per-role sections without --limit, and " "direct todo-id lifecycle commands" ), - semantic_json_keys=("todos", "agent_todos", "filter_semantics"), + semantic_json_keys=("todos", "agent_todos", "filter_semantics", "projection_envelope"), markdown_anchor="# LoopX Todo List", max_chars={ "small": {"json": 7_500, "markdown": 1_000}, @@ -573,6 +573,7 @@ class CliOutputCommandClassification: "agent_todos", "explicit_limit", "todo_list_projection", + "projection_envelope", ), markdown_anchor="# LoopX Todo List", max_chars={"json": 40_000, "markdown": 1_200}, @@ -591,6 +592,7 @@ class CliOutputCommandClassification: "returned_todo_count", "omitted_todo_count", "todo_list_field_projection", + "projection_envelope", ), markdown_anchor="# LoopX Todo List", max_chars={"json": 20_000, "markdown": 6_000}, diff --git a/loopx/control_plane/testing/cli_output_differential.py b/loopx/control_plane/testing/cli_output_differential.py index 80e51e1648..742af46b1e 100644 --- a/loopx/control_plane/testing/cli_output_differential.py +++ b/loopx/control_plane/testing/cli_output_differential.py @@ -685,6 +685,11 @@ def _schema_migration_growth_allowance( json={"chars": 3_000, "utf8_bytes": 3_000, "lines": 110, "compact_payload_chars": 2_800}, markdown={"chars": 192, "utf8_bytes": 224, "lines": 3, "compact_payload_chars": 0}, ) +# The unchanged 18-row Todo matrix adds 783–854 JSON chars and no lines. +# Source freshness and omitted-work coverage are useful; Markdown is unchanged. +_TODO_PROJECTION_ENVELOPE_V0_MIGRATION_ALLOWANCE: dict[Metric, int] = { + "chars": 896, "utf8_bytes": 896, "lines": 0, "compact_payload_chars": 896, +} def _projection_envelope_migration( @@ -696,6 +701,13 @@ def _projection_envelope_migration( if before == after: return {}, [], [] surface_id = str(base.get("surface_id") or "") + row_surface = str(base.get("row_id") or "").partition("/")[2].partition("/")[0] + if (surface_id == "todo_list" and row_surface in { + "todo_list", "todo_list_limited", "todo_list_thin"} + and output_format == "json" and before == () + and after == ("loopx_projection_envelope_v0",)): + return _TODO_PROJECTION_ENVELOPE_V0_MIGRATION_ALLOWANCE, [], [ + "Todo projection envelope schema migrated: none -> loopx_projection_envelope_v0"] if (surface_id in {"status", "quota_should_run"} and before == () and after == ("loopx_projection_envelope_v0",)): migration_allowance = ( diff --git a/loopx/control_plane/todos/active_state_todo_parser.py b/loopx/control_plane/todos/active_state_todo_parser.py index ab43f928b0..8847859565 100644 --- a/loopx/control_plane/todos/active_state_todo_parser.py +++ b/loopx/control_plane/todos/active_state_todo_parser.py @@ -9,14 +9,10 @@ normalize_todo_id, TODO_TASK_PATTERN, ) -from .standing_decision import build_standing_decision_authority from .machine_region import find_todo_source_regions, visible_markdown_lines from .todo_block_codec import decode_todo_blocks from .todo_summary import ( MAX_STATUS_TODOS_PER_ROLE, - compact_todo_group, - count_advancement_todos, - normalize_todo_text, ) from ..runtime.time import now_utc_iso @@ -77,61 +73,20 @@ def parse_active_state_todos( and int(orchestration.get("max_children") or 0) > 0 ) items, archive_items, source_sections = parse_todo_source(state_text, goal=goal, state_path=state_path) - result: dict[str, Any] = {} - archived_resume_source_items = [ - item for item in archive_items if normalize_todo_id(item.get("todo_id")) - ] - resume_source_items = [*items["user"], *items["agent"], *archived_resume_source_items] - user = compact_todo_group( - items["user"], - source_section=source_sections["user"], - role="user", - include_empty_source=source_sections["user"] is not None, - preferred_todo_ids=preferred_todo_ids, - resume_source_items=resume_source_items, - rollout_events=rollout_events, - available_capabilities=available_capabilities, - item_limit=item_limit, - include_task_orchestration_authority=include_task_orchestration_authority, - evaluated_at=resume_evaluated_at, - ) - agent = compact_todo_group( - items["agent"], - source_section=source_sections["agent"], - role="agent", - include_empty_source=source_sections["agent"] is not None, - preferred_todo_ids=preferred_todo_ids, - resume_source_items=resume_source_items, - rollout_events=rollout_events, - available_capabilities=available_capabilities, - item_limit=item_limit, - include_task_orchestration_authority=include_task_orchestration_authority, - vision_runs=(goal or {}).get("latest_runs"), - evaluated_at=resume_evaluated_at, - ) - archived_advancement_done_count = count_advancement_todos( - # Lossless source text must not widen legacy attention classification. - [ - {**item, "text": normalize_todo_text(str(item.get("text") or ""))} - for item in archive_items if item.get("done") is True - ] - ) - if agent and archived_advancement_done_count: - agent["archived_advancement_done_count"] = archived_advancement_done_count - agent["advancement_done_count"] = ( - int(agent.get("advancement_done_count") or 0) - + archived_advancement_done_count - ) - if user: - result["user_todos"] = user - if agent: - result["agent_todos"] = agent + from .goal_todo_projection import source_todo_fields + from .standing_decision import standing_decision_source_items + + lineage = [*items["user"], *items["agent"], + *[item for item in archive_items if normalize_todo_id(item.get("todo_id"))]] archived_decisions = [item for item in archive_items if item.get("role") == "user"] - standing_authority = build_standing_decision_authority( - [*items["user"], *archived_decisions], - # Separate sections are not a single chronological append log. + result = source_todo_fields([ + {"role": role, "source_section": source_sections[role], "items": items[role], + "include_empty_source": source_sections[role] is not None} for role in ("user", "agent")], + lineage=lineage, rollout_events=rollout_events, available_capabilities=available_capabilities, + preferred_todo_ids=preferred_todo_ids, item_limit=item_limit, evaluated_at=resume_evaluated_at, + archived_items=archive_items, + standing_items=standing_decision_source_items([*items["user"], *archived_decisions]), legacy_source_order=not archived_decisions, - ) - if standing_authority: - result["standing_decision_authority"] = standing_authority + include_task_orchestration_authority=include_task_orchestration_authority, + vision_runs=(goal or {}).get("latest_runs")) return result diff --git a/loopx/control_plane/todos/active_state_todos.py b/loopx/control_plane/todos/active_state_todos.py index 5f92a1990e..f561cf44a2 100644 --- a/loopx/control_plane/todos/active_state_todos.py +++ b/loopx/control_plane/todos/active_state_todos.py @@ -6,7 +6,6 @@ from ..coordination.local_authority import ( CanonicalTodoSnapshot, - canonical_todo_summary_fields, read_canonical_todos_if_promoted, ) from ..goals.active_state_metadata import parse_state_frontmatter @@ -91,11 +90,11 @@ def active_state_todo_fields( goal = {**goal, **source_goal} # Inspect authority before the display file. A missing/stale projection is # not an empty Todo collection, and an unavailable provider must fail closed. - canonical_reader = todo_snapshot.read if todo_snapshot is not None else read_canonical_todos_if_promoted - canonical = ( - canonical_reader(runtime_root=runtime_root, goal_id=goal_id) - if runtime_root is not None and goal_id else None - ) + canonical = (None if runtime_root is None or not goal_id else + todo_snapshot.read(runtime_root=runtime_root, goal_id=goal_id) if todo_snapshot is not None else + read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=goal_id, + include_summary=True, rollout_events=TodoResumeRolloutEvents( + rollout_events or [], runtime_root=runtime_root, goal_id=goal_id))) state_path = resolve_goal_local_path(goal.get("state_file"), goal, fallback_base=Path.cwd()) if canonical is None and state_path is not None: from ..goals.legacy_event_source import require_no_legacy_todo_events @@ -128,17 +127,9 @@ def active_state_todo_fields( if runtime_root is not None and goal_id: events = TodoResumeRolloutEvents(events, runtime_root=runtime_root, goal_id=goal_id) if canonical is not None: - from ..goals.acceptance import observe_canonical_goal_acceptance - acceptance = observe_canonical_goal_acceptance(canonical, + from .goal_todo_projection import observe_todo_acceptance + fields = observe_todo_acceptance(canonical["_todo_fields"], canonical, registry_path=source_registry, goal_id=goal_id) - fields = canonical_todo_summary_fields( - canonical["todos"], - rollout_events=events, - goal_acceptance_contract=acceptance, - goal_acceptance_work_guards=canonical.get( - "goal_acceptance_work_guards" - ), - ) # Canonical observation/successor transactions now support current # lease proof. Scheduling exposes due work; mutation admission still # validates the caller's proof and never falls back to the old writer. diff --git a/loopx/control_plane/todos/completion_state.py b/loopx/control_plane/todos/completion_state.py index 9337087b4d..57b65d918a 100644 --- a/loopx/control_plane/todos/completion_state.py +++ b/loopx/control_plane/todos/completion_state.py @@ -4,13 +4,15 @@ from enum import Enum from functools import lru_cache -from typing import Any, Mapping +import json +from pathlib import Path +from typing import Any -from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result TODO_COMPLETION_STATE_REQUEST_SCHEMA = "loopx_todo_completion_state_request_v0" TODO_COMPLETION_STATE_RESULT_SCHEMA = "loopx_todo_completion_state_result_v0" +_VALUE_CODEC = json.loads(Path(__file__).with_name("completion_value_codec.json").read_text(encoding="utf-8")) class TodoCompletionContinuation(str, Enum): @@ -36,37 +38,22 @@ def _no_followup_value(value: Any) -> bool | str: return value if isinstance(value, bool) else _string_value(value) -def _result(method: str, params: dict[str, Any]) -> Mapping[str, Any]: - try: - result = effect_runtime_result(method, params) - except EffectRuntimeRejected as exc: - raise ValueError(str(exc)) from None - if not isinstance(result, Mapping): - raise RuntimeError("TypeScript Todo completion state result must be an object") - if result.get("schema_version") != TODO_COMPLETION_STATE_RESULT_SCHEMA: - raise RuntimeError("TypeScript Todo completion state result shape mismatch") - return result @lru_cache(maxsize=64) def _normalize_cached(kind: str, value: bool | str) -> bool | str | None: - result = _result( - "todo.completion_state.normalize", - { - "schema_version": TODO_COMPLETION_STATE_REQUEST_SCHEMA, - "kind": kind, - "value": value, - }, - ) - normalized = result.get("value") if kind == "no_followup": - if normalized is not None and not isinstance(normalized, bool): - raise RuntimeError("TypeScript no_followup normalization shape mismatch") - elif normalized is not None and not isinstance(normalized, str): - raise RuntimeError( - "TypeScript completion metadata normalization shape mismatch" - ) - return normalized + if isinstance(value, bool): + return value + candidate = value.strip(_VALUE_CODEC["ecmascript_trim"]).lower() + if candidate in _VALUE_CODEC["true_tokens"]: + return True + if candidate in _VALUE_CODEC["false_tokens"]: + return False + return None + candidate = str(value).strip(_VALUE_CODEC["ecmascript_trim"]).lower() + vocabulary = TodoCompletionContinuation if kind == "continuation" else TodoCompletionRecovery + return candidate if candidate in {item.value for item in vocabulary} else None def normalize_todo_no_followup(value: Any) -> bool | None: @@ -85,15 +72,13 @@ def normalize_todo_completion_recovery(value: Any) -> str | None: def require_todo_completion_metadata(key: str, value: Any) -> str | None: - result = _result( - "todo.completion_state.require_metadata", - { - "schema_version": TODO_COMPLETION_STATE_REQUEST_SCHEMA, - "key": key, - "value": _string_value(value), - }, - ) - normalized = result.get("value") - if normalized is not None and not isinstance(normalized, str): - raise RuntimeError("TypeScript completion metadata result shape mismatch") + raw = _string_value(value) + if key == "completion_continuation": + normalized = _normalize_cached("continuation", raw) + if normalized is None: + raise ValueError("completion_continuation must be one of: active_goal, successor, no_followup") + else: + normalized = _normalize_cached("recovery", raw) + if normalized is None and raw != "": + raise ValueError("completion_recovery must be same_turn_terminal_closeout or lifecycle_reentry_terminal_closeout") return normalized diff --git a/loopx/control_plane/todos/completion_state.ts b/loopx/control_plane/todos/completion_state.ts index e2271ddc15..663902c0f1 100644 --- a/loopx/control_plane/todos/completion_state.ts +++ b/loopx/control_plane/todos/completion_state.ts @@ -1,4 +1,5 @@ import type { JsonObject } from "../effect_program.ts"; +import valueCodec from "./completion_value_codec.json" with {type: "json"}; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { requireBoolean as requiredBoolean, @@ -64,10 +65,10 @@ export function normalizeTodoNoFollowup(value: unknown): boolean | null { throw new EffectRuntimeRequestError("no_followup must be a boolean, string, or null"); } const candidate = value.trim().toLowerCase(); - if (["1", "true", "yes", "y", "no_followup", "no-followup"].includes(candidate)) { + if (valueCodec.true_tokens.includes(candidate)) { return true; } - if (["0", "false", "no", "n"].includes(candidate)) return false; + if (valueCodec.false_tokens.includes(candidate)) return false; return null; } @@ -136,35 +137,6 @@ export function completionContinuationForWrite( return "active_goal"; } -export function normalizeTodoCompletionValue(value: unknown): JsonObject { - const request = requestObject(value, "normalize"); - const kind = request.kind; - if (kind !== "no_followup" && kind !== "continuation" && kind !== "recovery") { - throw new EffectRuntimeRequestError("completion normalization kind is unsupported"); - } - const raw = request.value; - const normalized = kind === "no_followup" - ? normalizeTodoNoFollowup(raw) - : kind === "continuation" - ? normalizeTodoCompletionContinuation(raw) - : normalizeTodoCompletionRecovery(raw); - return { - schema_version: TODO_COMPLETION_STATE_RESULT_SCHEMA, - kind, - value: normalized, - }; -} - -export function requireTodoCompletionMetadataValue(value: unknown): JsonObject { - const request = requestObject(value, "require_metadata"); - const key = optionalString(request.key, "key"); - if (key === null) throw new EffectRuntimeRequestError("key must be a string"); - return { - schema_version: TODO_COMPLETION_STATE_RESULT_SCHEMA, - value: requireTodoCompletionMetadata(key, request.value), - }; -} - export function selectTodoCompletionState(value: unknown): TodoCompletionStateResult { const request = requestObject(value, "evaluate"); const todo = requiredObject(request.todo, "todo"); diff --git a/loopx/control_plane/todos/completion_value_codec.json b/loopx/control_plane/todos/completion_value_codec.json new file mode 100644 index 0000000000..efdf9eb246 --- /dev/null +++ b/loopx/control_plane/todos/completion_value_codec.json @@ -0,0 +1,17 @@ +{ + "true_tokens": [ + "1", + "true", + "yes", + "y", + "no_followup", + "no-followup" + ], + "false_tokens": [ + "0", + "false", + "no", + "n" + ], + "ecmascript_trim": "\t\n\u000b\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff" +} diff --git a/loopx/control_plane/todos/context_projection.ts b/loopx/control_plane/todos/context_projection.ts index fa9f97cdff..22beacb5e0 100644 --- a/loopx/control_plane/todos/context_projection.ts +++ b/loopx/control_plane/todos/context_projection.ts @@ -3,6 +3,7 @@ import type { JsonObject } from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { requireBoolean, requireInteger, requireJsonObject } from "../runtime_decode.ts"; import { canonicalAuthorityJson } from "../coordination/authority_store_codec.ts"; +import {compactThinTodoRead, compactTodoReadSummary, compactThinTodoReadSummary} from "./read_presentation.ts"; // A read lens over canonical records, shared by global and Goal conversations. // These are existing Todo facts, not dependency evaluation or execution grants. @@ -15,6 +16,21 @@ const CONTEXT_FIELDS = [ /** Select before redaction; never convert nested scopes or booleans to prose. */ export function projectTodoContextPage(input: JsonObject): JsonObject { + if (input.thin_summary_payload !== undefined) { + const details = requireJsonObject(input.details, "thin summary details"); + return compactThinTodoReadSummary(requireJsonObject(input.thin_summary_payload, "thin Todo summary"), { + role: details.role, items_matched: requireInteger(details.items_matched, "items matched"), + items_returned: requireInteger(details.items_returned, "items returned"), + item_limit_per_role: requireInteger(details.item_limit_per_role, "item limit per role")}); + } + if (input.thin_payload !== undefined) { + if (Object.keys(input).some(key => key !== "thin_payload")) throw new EffectRuntimeRequestError("thin Todo presentation cannot be combined with other views"); + return compactThinTodoRead(requireJsonObject(input.thin_payload, "thin Todo payload")); + } + if (input.summary_payload !== undefined) { + if (Object.keys(input).some(key => !["summary_payload", "role", "limit"].includes(key))) throw new EffectRuntimeRequestError("Todo summary presentation cannot be combined with other views"); + return compactTodoReadSummary(requireJsonObject(input.summary_payload, "Todo summary payload"), String(input.role), input.limit == null ? null : Number(input.limit)); + } if (input.handoff_sources !== undefined) { if (Object.keys(input).some(key => key !== "handoff_sources" && key !== "handoff_followups")) { throw new EffectRuntimeRequestError("handoff context cannot be combined with list/detail/page inputs"); diff --git a/loopx/control_plane/todos/contract.py b/loopx/control_plane/todos/contract.py index 24e9fd269a..f51e7c6dbc 100644 --- a/loopx/control_plane/todos/contract.py +++ b/loopx/control_plane/todos/contract.py @@ -1,6 +1,8 @@ from __future__ import annotations import hashlib +import json +from pathlib import Path import re from dataclasses import dataclass from enum import Enum @@ -28,6 +30,8 @@ ) +_READ_LEGACY_CODEC = json.loads(Path(__file__).with_name("read_legacy_codec.json").read_text(encoding="utf-8")) + TODO_TASK_PATTERN = re.compile(r"^\s*[-*]\s+\[([ xX-])\]\s+(.+?)\s*$") TODO_METADATA_PATTERN = re.compile(r"^\s*\s*$") TODO_METADATA_TOKEN_PATTERN = re.compile( @@ -138,32 +142,8 @@ def resolve_next_user_task_class( TODO_TERMINAL_STATUS_VALUES = {TODO_STATUS_DONE, TODO_STATUS_DEFERRED} TODO_LEGACY_TERMINAL_STATUS_VALUES = {"completed", "closed", "archived"} -TODO_ACTION_KIND_ADVANCEMENT_VALUES = { - "advance", - "analyze", - "benchmark_run", - "codex_run", - "compact_blocker_writeback", - "compare", - "execute", - "fix", - "implement", - "rebuild", - "rebuild_score", - "repair", - "run", - "run_eval", - "test", - "validate", - "writeback", -} -TODO_ACTION_KIND_MONITOR_VALUES = { - "external_evidence", - "monitor", - "observe", - "poll", - "watch", -} +TODO_ACTION_KIND_ADVANCEMENT_VALUES = set(_READ_LEGACY_CODEC["advancement_actions"]) +TODO_ACTION_KIND_MONITOR_VALUES = set(_READ_LEGACY_CODEC["monitor_actions"]) class TodoContinuationPolicy(str, Enum): @@ -180,44 +160,12 @@ class TodoContinuationPolicy(str, Enum): } -TODO_HARD_MONITOR_PATTERNS = ( - re.compile(r"(?i)\bdo not\b.*\b(?:launch|run|execute|start)\b.*\buntil\b"), - re.compile(r"(?i)\b(?:only|just)\b.*\b(?:after|when|once)\b.*\b(?:owner|user|credential|approval|prerequisite|evidence)\b"), - re.compile(r"(?i)\b(?:credential|gcp|gcs|gcp_project|gcp_sa_key|gs://)\b.*\b(?:missing|required|provide|proof|prerequisite|gate|gated)\b"), - re.compile(r"(?i)\b(?:readiness|proof)\b.*\bbefore any formal\b.*\brun\b"), - re.compile(r"(?i)\bremaining formal\b.*\bpath\b"), - re.compile(r"(?i)\b(?:route|input)\b.*\babsent\b"), - re.compile(r"(?i)\b0\b.*\b(?:candidate|candidates)\b"), -) +TODO_HARD_MONITOR_PATTERNS = tuple(re.compile(pattern) for pattern in _READ_LEGACY_CODEC["TODO_HARD_MONITOR_PATTERNS"]) -TODO_ADVANCEMENT_OVERRIDE_PATTERNS = ( - re.compile( - r"(?i)(?:^|[::]\s*)(?:implement|add|make|fix|build|wire|define|compare|run|" - r"execute|test|validate|rebuild|repair|archive|publish|merge|write|attribute|" - r"collect|aggregate|generate|produce|materialize|rerun|repeat|eval|evaluate|score)\b" - ), - re.compile( - r"(?i)\b(?:implementation slice|validation-backed patch|smoke fixture|" - r"regression suite|readiness scan|source preflight|setup-readiness scan)\b" - ), -) -TODO_BLOCKED_MONITOR_PATTERNS = ( - *TODO_HARD_MONITOR_PATTERNS, - re.compile(r"(?i)\b(?:blocked|gated|waiting)\b.*\b(?:owner|user|credential|substrate|proof|prerequisite|evidence)\b"), -) -TODO_MONITOR_PATTERNS = ( - re.compile(r"(?i)\bdependency monitor\b"), - re.compile(r"(?i)\bobservation lane\b"), - re.compile(r"(?i)(?:^|[::]\s*)observe\b"), - re.compile(r"(?i)(?:^|[::]\s*)poll\b"), - re.compile(r"(?i)(?:^|[::]\s*)watch\b"), - re.compile(r"(?i)\bmonitor-only\b"), - *TODO_BLOCKED_MONITOR_PATTERNS, -) -TODO_ADVANCEMENT_PATTERNS = ( - *TODO_ADVANCEMENT_OVERRIDE_PATTERNS, - re.compile(r"(?i)\b(?:task|validation hypothesis|validation step|bounded step|learning run)\b"), -) +TODO_ADVANCEMENT_OVERRIDE_PATTERNS = tuple(re.compile(pattern) for pattern in _READ_LEGACY_CODEC["TODO_ADVANCEMENT_OVERRIDE_PATTERNS"]) +TODO_BLOCKED_MONITOR_PATTERNS = tuple(re.compile(pattern) for pattern in _READ_LEGACY_CODEC["TODO_BLOCKED_MONITOR_PATTERNS"]) +TODO_MONITOR_PATTERNS = tuple(re.compile(pattern) for pattern in _READ_LEGACY_CODEC["TODO_MONITOR_PATTERNS"]) +TODO_ADVANCEMENT_PATTERNS = tuple(re.compile(pattern) for pattern in _READ_LEGACY_CODEC["TODO_ADVANCEMENT_PATTERNS"]) NEXT_ACTION_HARD_MONITOR_PATTERNS = ( re.compile(r"(?i)\bdo not\b.*\b(?:launch|run|execute|start)\b.*\buntil\b"), diff --git a/loopx/control_plane/todos/frontier_revision.py b/loopx/control_plane/todos/frontier_revision.py index ac3b8f061f..2b29d51485 100644 --- a/loopx/control_plane/todos/frontier_revision.py +++ b/loopx/control_plane/todos/frontier_revision.py @@ -5,6 +5,7 @@ import base64 import json import zlib +from pathlib import Path from typing import Any # Refs #4447: the todo contract owns this vocabulary; import it instead of @@ -20,39 +21,7 @@ TODO_FRONTIER_REVISION_SCHEMA_VERSION = "todo_frontier_revision_v0" TODO_FRONTIER_REVISION_INDEX_SCHEMA_VERSION = "todo_frontier_revision_index_v0" -FRONTIER_REVISION_FIELDS = ( - "todo_id", - "status", - "done", - "title", - "text", - "task_class", - "claimed_by", - "bound_agent", - "blocks_agent", - "excluded_agents", - "priority", - "action_kind", - "task_domain", - "task_repository", - "capability_binding_ref", - "required_capabilities", - "target_capabilities", - "target_key", - "continuation_policy", - "removed_continuation_policy", - "decision_scope", - "required_decision_scopes", - "decision_outcome", - "replan_obligation_id", - "unblocks_todo_id", - "depends_on_todo_id", - "depends_on_todo_ids", - "resume_when", - "no_followup", - "successor_todo_ids", - "completion_continuation", -) +FRONTIER_REVISION_FIELDS = tuple(json.loads(Path(__file__).with_name("read_legacy_codec.json").read_text(encoding="utf-8"))["frontier_fields"]) def frontier_source_facts( diff --git a/loopx/control_plane/todos/goal_todo_projection.py b/loopx/control_plane/todos/goal_todo_projection.py index 531564b21f..5939cd8c7a 100644 --- a/loopx/control_plane/todos/goal_todo_projection.py +++ b/loopx/control_plane/todos/goal_todo_projection.py @@ -14,15 +14,12 @@ from ..goals.legacy_event_source import require_no_legacy_todo_events from .active_state_editing import TODO_SECTION_HEADINGS -from .active_state_todo_parser import parse_active_state_todos, parse_todo_source -from .list_projection import compact_explicit_limit_todo_summary -from .succession_warning import public_todo_summary from .contract import ( normalize_todo_claimed_by, normalize_todo_id, normalize_todo_status, ) -from .todo_summary import compact_evaluated_todo_group, compact_todo_group +from .todo_summary import compact_evaluated_todo_group def empty_todo_summary(*, role: str) -> dict[str, Any]: @@ -69,6 +66,17 @@ def summary_items(fields: dict[str, Any], role: str) -> list[dict[str, Any]]: return [item for item in summary.get("items") or [] if isinstance(item, dict)] +def observe_todo_acceptance(fields, canonical, *, registry_path, goal_id): + from ..goals.acceptance import observe_canonical_goal_acceptance + + acceptance = observe_canonical_goal_acceptance( + canonical, registry_path=registry_path, goal_id=goal_id) + summary = fields.get("agent_todos") + if isinstance(summary, dict) and "goal_acceptance_contract" in summary: + return {**fields, "agent_todos": {**summary, "goal_acceptance_contract": acceptance}} + return fields + + class GoalTodoSummaries: """Role summaries and todo items projected from one active-state text.""" @@ -108,37 +116,19 @@ def goal_todo_summaries( limit: int | None, include_retained: bool = False, ) -> GoalTodoSummaries: - """Project todo summaries from active-state text plus its event projection. - - The text is a parameter rather than a file read so a writer that still - holds the state-file lock can project the exact bytes it is about to - commit; ``list_goal_todos`` passes the on-disk text. - """ + from ..runtime.time import now_utc require_no_legacy_todo_events(goal or {}, state_path=state_path) - if include_retained: - active, archived, _sections = parse_todo_source(state_text, goal=goal, state_path=state_path) - markdown_fields = retained_todo_summary_fields( - [*active["user"], *active["agent"], *archived], rollout_events=rollout_events, - ) - else: - markdown_fields = parse_active_state_todos( - state_text, - goal=goal, - state_path=state_path, - item_limit=None, - rollout_events=rollout_events, - ) - return todo_summaries_from_fields( - fields=markdown_fields, - source="markdown_active_state", - rollout_events=rollout_events, - roles=roles, - status=status, - todo_id=todo_id, - agent_id=agent_id, - limit=limit, - ) + facts = prepare_todo_source_text(goal, state_text, state_path, todo_id=todo_id, retained=include_retained) + now = now_utc() + projected = project_todo_read({"kind": "source_fields", **facts, + "rollout_events": list(rollout_events), "rollout_event_source": getattr(rollout_events, "source", None), + "observed_at": now.timestamp(), "evaluated_at": now.isoformat(), + "projection_query": {"roles": roles, "status": normalize_todo_status(status), + "todo_id": normalize_todo_id(todo_id) if todo_id else None, + "agent_id": normalize_todo_claimed_by(agent_id) if agent_id else None, + "limit": limit, "source_name": "markdown_active_state"}})["projection"] + return GoalTodoSummaries(**projected) def todo_summaries_from_fields( @@ -152,47 +142,18 @@ def todo_summaries_from_fields( agent_id: str | None, limit: int | None, ) -> GoalTodoSummaries: - """Apply the shared Todo consumer semantics to an authority read model.""" - - summaries: dict[str, dict[str, Any]] = {} - todos: list[dict[str, Any]] = [] - unfiltered_count = 0 - uncapped_todo_count = 0 - for item_role in roles: - key = f"{item_role}_todos" - raw_summary = fields.get(key) if isinstance(fields, dict) else None - unfiltered_count += len((raw_summary or {}).get("items") or []) - summary = filtered_todo_summary( - raw_summary, - role=item_role, - status=status, - todo_id=todo_id, - agent_id=agent_id, - item_limit=limit, - ) - # Rebuilding a display group must not discard its canonical acceptance - # source. Receipt-bound quota reentry consumes this provider-first list; - # work guards without their owner contract cannot reconstruct a hold. - acceptance = (raw_summary or {}).get("goal_acceptance_contract") - if isinstance(acceptance, dict): - summary["goal_acceptance_contract"] = acceptance - if limit is not None: - summary = compact_explicit_limit_todo_summary( - summary, - role=item_role, - item_limit=limit, - ) - summary = public_todo_summary(summary) - summaries[key] = summary - todos.extend(summary.get("items") or []) - uncapped_todo_count += int(summary.get("total_count") or 0) - return GoalTodoSummaries( - source=source, - summaries=summaries, - todos=todos, - unfiltered_count=unfiltered_count, - uncapped_todo_count=uncapped_todo_count, - ) + from ..runtime.time import now_utc + + now = now_utc() + result = project_todo_read({"kind": "select", "fields": fields, + "field_evaluations": {f"{item_role}_todos": [getattr(item, "succession_evaluation", None) + for item in (fields.get(f"{item_role}_todos") or {}).get("items") or []] for item_role in roles}, + "roles": roles, "status": normalize_todo_status(status), + "todo_id": normalize_todo_id(todo_id) if todo_id else None, + "agent_id": normalize_todo_claimed_by(agent_id) if agent_id else None, + "limit": limit, "source_name": source, + "observed_at": now.timestamp(), "evaluated_at": now.isoformat()})["projection"] + return GoalTodoSummaries(**result) def retained_todo_summary_fields( @@ -200,25 +161,12 @@ def retained_todo_summary_fields( *, rollout_events: list[dict[str, Any]], ) -> dict[str, Any]: - """Feed retained source rows through the existing typed summary owner. - History is an explicit cold read; active scheduler/attention summaries - continue to exclude archives. Preserve full text, evidence and source ids. - """ - return { - f"{role}_todos": compact_todo_group( - [item for item in items if item.get("role") == role], - source_section=TODO_SECTION_HEADINGS[role], - role=role, - include_empty_source=True, - resume_source_items=items, - rollout_events=rollout_events, - item_limit=None, - text_limit=None, - include_content_revision=False, - ) - for role in ("user", "agent") - } + return source_todo_fields([ + {"role": role, "source_section": TODO_SECTION_HEADINGS[role], + "items": [item for item in items if item.get("role") == role], + "include_empty_source": True, "text_limit": None, "include_content_revision": False} + for role in ("user", "agent")], lineage=items, rollout_events=rollout_events) def exact_archived_todo_summaries( @@ -232,43 +180,20 @@ def exact_archived_todo_summaries( agent_id: str | None, limit: int | None, ) -> GoalTodoSummaries | None: - """Project one exact retained Todo without widening normal active lists.""" - - item = next( - ( - dict(candidate) - for candidate in archived_items - if normalize_todo_id(candidate.get("todo_id")) == todo_id - and candidate.get("archive_state") == "archive" - ), - None, - ) - if item is None: - return None - item_role = item.get("role") - if item_role not in {"user", "agent"} or item_role not in roles: - return None - summary = compact_todo_group( - [item], - source_section=str(item.get("source_section") or "Completed Work Archive"), - role=item_role, - include_empty_source=True, - resume_source_items=archived_items, - rollout_events=rollout_events, - item_limit=None, - ) - if summary is None: - return None - return todo_summaries_from_fields( - fields={f"{item_role}_todos": summary}, - source=source, - rollout_events=rollout_events, - roles=roles, - status=status, - todo_id=todo_id, - agent_id=agent_id, - limit=limit, - ) + from ..runtime.time import now_utc + + lineage, facts = legacy_lineage_facts(archived_items) + groups = [{"role": role, "source_section": "Completed Work Archive", "include_empty_source": True, + "items": [row for row in archived_items if row.get("role") == role]} for role in ("user", "agent")] + now = now_utc() + projected = project_todo_read({"kind": "source_fields", "groups": prepare_todo_source_groups(groups, lineage=archived_items), + "lineage": lineage, "lineage_facts": facts, "rollout_events": list(rollout_events), + "rollout_event_source": getattr(rollout_events, "source", None), + "observed_at": now.timestamp(), "evaluated_at": now.isoformat(), + "projection_query": {"exact_archive": True, "roles": roles, "status": normalize_todo_status(status), + "todo_id": normalize_todo_id(todo_id), "agent_id": normalize_todo_claimed_by(agent_id) if agent_id else None, + "limit": limit, "source_name": source}})["projection"] + return GoalTodoSummaries(**projected) if projected is not None else None def project_goal_todo_items( goal: dict[str, Any] | None, @@ -306,3 +231,234 @@ def project_goal_todo_items( "summary_items", "todo_summaries_from_fields", ] + + + +def project_todo_read(params: dict[str, Any]) -> dict[str, Any]: + from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result + + import base64 + import hashlib + import json + import os + import tempfile + from contextlib import ExitStack + from pathlib import Path + from ..effect_runtime import MAX_LOCAL_SNAPSHOT_BYTES + + request = {"schema_version": "todo_read_request_v0", **params} + encoded = json.dumps(request, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8") + chunks = [] + identity = None + try: + with ExitStack() as stack: + if len(encoded) > MAX_LOCAL_SNAPSHOT_BYTES - 4096: + directory = Path(stack.enter_context(tempfile.TemporaryDirectory(prefix="loopx-effect-todo-"))) + source = directory / "facts.json" + fd = os.open(source, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "wb") as file: + file.write(encoded) + request = {"schema_version": "todo_read_request_v0", "facts_ref": { + "path": str(source), "byte_count": len(encoded), "sha256": hashlib.sha256(encoded).hexdigest()}} + consumed = 0 + while True: + result = effect_runtime_result("todo.read", request, large_local_snapshot=True, + timeout=15.0 if params.get("kind") in {"query", "canonical_fields", "historical_fields"} else 10.0) + if not isinstance(result, dict) or result.get("schema_version") != "todo_read_result_v0": + raise ValueError("invalid typed Todo read result") + piece = result.get("result_piece") + if piece is None: + if chunks and not (result.get("canonical") or result.get("rejection")): + raise ValueError("incomplete Todo read result") + break + if not isinstance(piece, dict) or piece.get("offset") != consumed: + raise ValueError("invalid Todo read result page") + current = (piece.get("sha256"), piece.get("byte_count")) + if identity is None: + identity = current + if identity != current or type(current[1]) is not int or current[1] <= consumed: + raise ValueError("Todo read result identity changed") + block = base64.b64decode(piece["data"], validate=True) + if not block: + raise ValueError("Todo read result page did not advance") + chunks.append(block) + consumed += len(block) + if consumed > current[1]: + raise ValueError("Todo read result exceeds its declared population") + after = piece.get("next") + if after is None: + complete = b"".join(chunks) + if consumed != current[1] or hashlib.sha256(complete).hexdigest() != current[0]: + raise ValueError("Todo read result digest mismatch") + result = json.loads(complete) + break + if not isinstance(after, dict) or after.get("offset") != consumed or after.get("sha256") != current[0]: + raise ValueError("invalid Todo read result continuation") + request = {**request, "result_page": after} + except EffectRuntimeRejected as exc: + raise ValueError(str(exc)) from exc + if not isinstance(result, dict) or result.get("schema_version") != "todo_read_result_v0": + raise ValueError("invalid typed Todo read result") + if result.get("rejection"): + raise EffectRuntimeRejected(result["rejection"]["message"]) + if params.get("kind") == "group": + summary = result.get("summary") + if not isinstance(summary, dict) or summary.get("schema_version") != "todo_summary_v0": + raise ValueError("invalid typed Todo summary fields") + if params.get("role") == "agent": + frontier = summary.get("advancement_frontier_revision_index") + if not isinstance(frontier, dict) or frontier.get("schema_version") != "todo_frontier_revision_index_v0": + raise ValueError("invalid typed Todo summary frontier index") + return result + + +def restore_todo_field_evaluations(result): + from .succession_warning import _EvaluatedTodo + + fields = result["fields"] + for role, decisions in result["field_evaluations"].items(): + fields[role]["items"] = [_EvaluatedTodo(item, decision) + for item, decision in zip(fields[role]["items"], decisions, strict=True)] + return fields + + +def canonical_todo_fields(todos, *, rollout_events=None, **facts): + from ..runtime.time import now_utc + + now = now_utc() + if facts.get("available_capabilities") is not None: + facts["available_capabilities"] = sorted({str(value).strip().lower() + for value in facts["available_capabilities"] if str(value).strip()}) + return restore_todo_field_evaluations(project_todo_read({ + "kind": "fields", "todos": todos, **facts, + "rollout_events": list(rollout_events or []), + "rollout_event_source": getattr(rollout_events, "source", None), + "observed_at": now.timestamp(), "evaluated_at": now.isoformat(), + })) + + +def source_todo_fields(groups, *, lineage, rollout_events=None, available_capabilities=None, + preferred_todo_ids=None, item_limit=None, evaluated_at=None, + archived_items=None, standing_items=None, legacy_source_order=False, + vision_runs=None, include_task_orchestration_authority=False): + from ..runtime.time import now_utc + + now = now_utc() + prepared = prepare_todo_source_groups(groups, lineage=lineage) + lineage_rows, lineage_facts = legacy_lineage_facts(lineage) + result = project_todo_read({"kind": "source_fields", "groups": prepared, + "lineage": lineage_rows, "lineage_facts": lineage_facts, "rollout_events": list(rollout_events or []), + "rollout_event_source": getattr(rollout_events, "source", None), + "available_capabilities": available_capabilities, + "preferred_todo_ids": sorted(preferred_todo_ids or []), "item_limit": item_limit, + "archived_items": archived_items, "standing_items": standing_items, + "legacy_source_order": legacy_source_order, + "include_full_source": bool(vision_runs), + "include_task_orchestration_authority": include_task_orchestration_authority, + "observed_at": now.timestamp(), "evaluated_at": evaluated_at or now.isoformat()}) + fields = restore_todo_field_evaluations(result) + for role in ("user", "agent"): + summary = fields.get(f"{role}_todos") + if not summary: + continue + if include_task_orchestration_authority: + from .todo_summary import _task_orchestration_authority + summary["task_orchestration_authority"] = _task_orchestration_authority( + result["source_orchestration"][role], role=role) + if vision_runs: + from .succession_warning import _EvaluatedTodo + from ..goals.goal_vision_wait_projection import attach_active_vision_waits + source = [_EvaluatedTodo(item, decision) for item, decision in zip( + result["source_items"][role], result["source_evaluations"][role], strict=True)] + attach_active_vision_waits(summary, vision_runs, role=role, items=source, + lineage_items=lineage) + return fields + + +def project_todo_read_group(items, *, source_section, role, preferred_todo_ids=None, + lineage=None, rollout_events=None, available_capabilities=None, + item_limit=None, evaluated_at=None, evaluated=False, + full_selection=True, selection=None, resume_lineage=None, + include_full_source=False, include_task_orchestration_authority=False): + from .succession_warning import _EvaluatedTodo + from ..runtime.time import now_utc + + now = now_utc() + lineage_rows, lineage_facts = legacy_lineage_facts(lineage or []) + result = project_todo_read({ + "kind": "group", "items": items, "lineage": lineage_rows, "lineage_facts": lineage_facts, + "resume_lineage": resume_lineage, + "evaluated": evaluated, + "evaluations": [item.succession_evaluation if isinstance(item, _EvaluatedTodo) else None + for item in items] if evaluated else None, + "source_section": source_section, "role": role, + "preferred_todo_ids": sorted(preferred_todo_ids or []), + "rollout_events": list(rollout_events or []), + "rollout_event_source": getattr(rollout_events, "source", None), + "available_capabilities": sorted(available_capabilities) if available_capabilities is not None else None, + "item_limit": item_limit, "evaluated_at": evaluated_at or now.isoformat(), + "observed_at": now.timestamp(), "full_selection": full_selection, + "selection": selection, + "include_full_source": include_full_source, + "include_task_orchestration_authority": include_task_orchestration_authority, + }) + # The native owner validates this ephemeral evidence again on filtered reads. + if "items" in result: + result["items"] = [_EvaluatedTodo(item, decision) + for item, decision in zip(result["items"], result["evaluations"], strict=True)] + result["summary"]["items"] = [ + _EvaluatedTodo(item, decision) for item, decision in zip( + result["summary"].get("items", []), result["visible_evaluations"], strict=True) + ] + return result + + +def prepare_todo_source_groups(groups, *, lineage): + from .todo_summary import _structured_todo_group_items, _structured_resume_source_items + from .resume_condition import build_todo_resume_evaluation_request + + prepared = [] + for group in groups: + items = _structured_todo_group_items(group["items"], source_section=group["source_section"], + role=group["role"], text_limit=group.get("text_limit", 500), + include_content_revision=group.get("include_content_revision", True)) + prepared.append({**group, "items": items, + "resume_lineage": build_todo_resume_evaluation_request([], source_items=_structured_resume_source_items( + lineage, source_section=group["source_section"]))["source_items"] + if any(item.get("resume_when") for item in items) else None}) + return prepared + + +def legacy_lineage_facts(items): + from .succession_warning import succession_facts + + return ([{"todo_id": item.get("todo_id"), "role": item.get("role"), "source_section": item.get("source_section"), + "archive_state": item.get("archive_state") or "active"} for item in items], + [succession_facts(item) for item in items]) + + +def prepare_todo_source_text(goal, state_text, state_path, *, todo_id=None, retained=False): + from .active_state_todo_parser import parse_todo_source + from .todo_summary import _structured_resume_source_items + from .standing_decision import standing_decision_source_items + from .active_state_editing import TODO_SECTION_HEADINGS + from .contract import normalize_todo_id + + active, archived, sections = parse_todo_source(state_text, goal=goal, state_path=state_path) + records = [*active["user"], *active["agent"], *archived] + groups = [{"role": role, "source_section": TODO_SECTION_HEADINGS[role] if retained else sections[role], + "items": [item for item in records if item.get("role") == role] if retained else active[role], + "include_empty_source": retained or sections[role] is not None, + "text_limit": None if retained else 500, "include_content_revision": not retained} + for role in ("user", "agent")] + archived_decisions = [item for item in archived if item.get("role") == "user"] + archived_query = _structured_resume_source_items(archived, source_section="Completed Work Archive") if todo_id else [] + lineage = records if retained else [*active["user"], *active["agent"], *[item for item in archived if normalize_todo_id(item.get("todo_id"))]] + lineage_rows, lineage_facts = legacy_lineage_facts(lineage) + return {"groups": prepare_todo_source_groups(groups, lineage=records), "lineage": lineage_rows, "lineage_facts": lineage_facts, + "archived_items": None if retained else archived, + "standing_items": None if retained else standing_decision_source_items([*active["user"], *archived_decisions]), + "legacy_source_order": not archived_decisions, + "archived_items_for_query": archived_query, + "full_bodies": [item for item in records if item.get("todo_id") == todo_id] if todo_id else [], + "source_count": len(records)} diff --git a/loopx/control_plane/todos/list_projection.py b/loopx/control_plane/todos/list_projection.py index 57df51e17e..fa6c9fe40b 100644 --- a/loopx/control_plane/todos/list_projection.py +++ b/loopx/control_plane/todos/list_projection.py @@ -1,493 +1,59 @@ +"""Compatibility exports for complete native Todo presentation lenses.""" from __future__ import annotations +import json +from pathlib import Path from typing import Any, Literal +from ..effect_runtime import effect_runtime_result -from .todo_semantics import todo_blocker_reason +_READ_CODEC = json.loads(Path(__file__).with_name("read_legacy_codec.json").read_text(encoding="utf-8")) +_READ_LIST_LAYOUT = _READ_CODEC["list_constants"] +AGENT_LANE_TODO_LIST_PROJECTION_SCHEMA_VERSION = _READ_LIST_LAYOUT["AGENT_LANE_TODO_LIST_PROJECTION_SCHEMA_VERSION"] +AGENT_LANE_TODO_LIST_SUMMARY_SCHEMA_VERSION = _READ_LIST_LAYOUT["AGENT_LANE_TODO_LIST_SUMMARY_SCHEMA_VERSION"] +EXPLICIT_LIMIT_TODO_LIST_SUMMARY_SCHEMA_VERSION = _READ_LIST_LAYOUT["EXPLICIT_LIMIT_TODO_LIST_SUMMARY_SCHEMA_VERSION"] +THIN_TODO_LIST_PROJECTION_SCHEMA_VERSION = _READ_LIST_LAYOUT["THIN_TODO_LIST_PROJECTION_SCHEMA_VERSION"] +THIN_TODO_LIST_SUMMARY_SCHEMA_VERSION = _READ_LIST_LAYOUT["THIN_TODO_LIST_SUMMARY_SCHEMA_VERSION"] +AGENT_LANE_TODO_LIST_ITEM_LIMIT = _READ_LIST_LAYOUT["AGENT_LANE_TODO_LIST_ITEM_LIMIT"] +AGENT_LANE_TODO_LIST_TEXT_LIMIT = _READ_LIST_LAYOUT["AGENT_LANE_TODO_LIST_TEXT_LIMIT"] +THIN_TODO_LIST_ITEM_LIMIT_PER_ROLE = _READ_LIST_LAYOUT["THIN_TODO_LIST_ITEM_LIMIT_PER_ROLE"] +THIN_TODO_LIST_NESTED_ITEM_LIMIT = _READ_LIST_LAYOUT["THIN_TODO_LIST_NESTED_ITEM_LIMIT"] +THIN_TODO_LIST_NESTED_DICT_FIELD_LIMIT = _READ_LIST_LAYOUT["THIN_TODO_LIST_NESTED_DICT_FIELD_LIMIT"] +THIN_TODO_LIST_ITEM_FIELDS = tuple(_READ_LIST_LAYOUT["THIN_TODO_LIST_ITEM_FIELDS"]) +AGENT_LANE_HOT_PATH_VIEW = "agent_lane_hot_path" +EXPLICIT_LIMIT_COLD_PATH_VIEW = "explicit_limit_cold_path" +THIN_EXPLICIT_VIEW = "thin_explicit_view" +TodoListProjectionView = Literal["agent_lane_hot_path", "explicit_limit_cold_path"] -AGENT_LANE_TODO_LIST_PROJECTION_SCHEMA_VERSION = "agent_lane_todo_list_projection_v0" -AGENT_LANE_TODO_LIST_SUMMARY_SCHEMA_VERSION = ( - "agent_lane_todo_list_summary_compaction_v0" -) -EXPLICIT_LIMIT_TODO_LIST_SUMMARY_SCHEMA_VERSION = ( - "explicit_limit_todo_list_summary_compaction_v0" -) -THIN_TODO_LIST_PROJECTION_SCHEMA_VERSION = "todo_list_thin_projection_v0" -THIN_TODO_LIST_SUMMARY_SCHEMA_VERSION = "todo_list_thin_summary_compaction_v0" -AGENT_LANE_TODO_LIST_ITEM_LIMIT = 12 -AGENT_LANE_TODO_LIST_TEXT_LIMIT = 180 -# Two items per role keeps the maximally populated retained field shape inside -# the registered 20k/600-line JSON budget; the regression owns that invariant. -THIN_TODO_LIST_ITEM_LIMIT_PER_ROLE = 2 -THIN_TODO_LIST_NESTED_ITEM_LIMIT = 3 -THIN_TODO_LIST_NESTED_DICT_FIELD_LIMIT = 8 -AGENT_LANE_HOT_PATH_VIEW: Literal["agent_lane_hot_path"] = "agent_lane_hot_path" -EXPLICIT_LIMIT_COLD_PATH_VIEW: Literal["explicit_limit_cold_path"] = ( - "explicit_limit_cold_path" -) -THIN_EXPLICIT_VIEW: Literal["thin_explicit_view"] = "thin_explicit_view" -TodoListProjectionView = Literal[ - "agent_lane_hot_path", - "explicit_limit_cold_path", -] -_RETAINED_LANE_LIMITS = { - "items": AGENT_LANE_TODO_LIST_ITEM_LIMIT, - "first_open_items": 3, - "first_executable_items": 3, - "deferred_items": 3, - "monitor_due_items": 2, - "monitor_schedule_gap_items": 2, - "blocker_items": 2, - "resume_blocked_items": 2, - "active_next_action_items": 3, - "active_next_action_executable_items": 3, -} -_RETAINED_DICTS = { - "work_counts", - "deferred_work_counts", - "monitor_writeback", - "source_proof", - "terminal_closure_proof", -} -_ITEM_FIELDS = ( - "schema_version", - "gate_state", - "successor_count", - "excluded_agents", - "index", - "todo_id", - "role", - "status", - "priority", - "text", - "title", - "note", - "task_class", - "action_kind", - "task_domain", - "capability_binding_ref", - "task_repository", - "continuation_policy", - "required_capabilities", - "target_capabilities", - "claimed_by", - "bound_agent", - "goal_bound", - "blocks_agent", - "global_gate", - "unblocks_todo_id", - "successor_todo_ids", - "completion_continuation", - "completion_recovery", - "resume_when", - "resume_monitor_generation", - "resume_ready", - "target_key", - "cadence", - "next_due_at", - "expires_at", - "watch_only", -) +def compact_thin_todo_list_payload(payload): + return effect_runtime_result("todo.context.page", {"thin_payload": payload}, large_local_snapshot=True) -THIN_TODO_LIST_ITEM_FIELDS = ( - "todo_id", - "role", - "status", - "priority", - "text", - "task_class", - "action_kind", - "claimed_by", - "bound_agent", - "goal_bound", - "blocks_agent", - "global_gate", - "unblocks_todo_id", - "decision_scope", - "required_decision_scopes", - "resume_when", - "resume_ready", - "target_key", - "cadence", - "next_due_at", - "expires_at", - "watch_only", -) -_COMPACT_ITEM_TEXT_FIELDS = frozenset({"text", "title", "note"}) -_THIN_ITEM_TEXT_FIELDS = frozenset({"text"}) +def compact_agent_lane_todo_summary(summary, *, role): + return effect_runtime_result("todo.context.page", {"summary_payload": summary, "role": role, "limit": None}, large_local_snapshot=True) -def _compact_text(value: Any) -> Any: - if not isinstance(value, str): - return value - text = value.strip() - if len(text) <= AGENT_LANE_TODO_LIST_TEXT_LIMIT: - return text - return text[: AGENT_LANE_TODO_LIST_TEXT_LIMIT - 3].rstrip() + "..." +def compact_explicit_limit_todo_summary(summary, *, role, item_limit): + return effect_runtime_result("todo.context.page", {"summary_payload": summary, "role": role, "limit": item_limit}, large_local_snapshot=True) -def _project_item_fields( - value: Any, - *, - fields: tuple[str, ...], - text_fields: frozenset[str], -) -> Any: - if not isinstance(value, dict): - return value - compact: dict[str, Any] = {} - for key in fields: - child = value.get(key) - if child is None: - continue - compact[key] = ( - _compact_text(child) if key in text_fields else child - ) - return compact - - -def _compact_item(value: Any) -> Any: - compact = _project_item_fields( - value, - fields=_ITEM_FIELDS, - text_fields=_COMPACT_ITEM_TEXT_FIELDS, - ) - if isinstance(value, dict) and isinstance(compact, dict): - reason = todo_blocker_reason(value) - if reason: - compact["reason"] = reason - return compact - - -def _compact_thin_item(value: Any) -> Any: - projected = _project_item_fields( - value, - fields=THIN_TODO_LIST_ITEM_FIELDS, - text_fields=_THIN_ITEM_TEXT_FIELDS, - ) - if not isinstance(projected, dict): - return projected - return {key: _compact_thin_value(child) for key, child in projected.items()} - - -def _compact_thin_value(value: Any) -> Any: - """Bound every retained thin field, including nested scope metadata.""" - - if isinstance(value, str): - return _compact_text(value) - if isinstance(value, list): - return [ - _compact_thin_value(child) - for child in value[:THIN_TODO_LIST_NESTED_ITEM_LIMIT] - ] - if isinstance(value, dict): - return { - key: _compact_thin_value(child) - for key, child in list(value.items())[ - :THIN_TODO_LIST_NESTED_DICT_FIELD_LIMIT - ] - } - return value - - -def _summary_source_view(value: Any) -> str: - if not isinstance(value, dict): - return "full_detail" - view = value.get("view") - if isinstance(view, str) and view: - return view - if value.get("schema_version") == AGENT_LANE_TODO_LIST_SUMMARY_SCHEMA_VERSION: - return AGENT_LANE_HOT_PATH_VIEW - return "full_detail" - - -def compact_thin_todo_summary( - summary: dict[str, Any], - *, - role: str, - items_matched: int, - items_returned: int, - item_limit_per_role: int, -) -> dict[str, Any]: - """Project one role summary to a bounded field-only Todo list view.""" - - compact: dict[str, Any] = {} - omitted_nonempty_lane_count = 0 - omitted_nonempty_dict_count = 0 - source_view = "full_detail" - for key, value in summary.items(): - if isinstance(value, list): - if key != "items": - omitted_nonempty_lane_count += bool(value) - continue - if isinstance(value, dict): - if key in {"work_counts", "deferred_work_counts"}: - compact[key] = value - elif key == "payload_compaction": - source_view = _summary_source_view(value) - else: - omitted_nonempty_dict_count += bool(value) - continue - compact[key] = value - items_omitted = max(0, items_matched - items_returned) - compacted_lanes = {} - if items_omitted: - compacted_lanes["items"] = { - "shown": items_returned, - "total": items_matched, - } - compact["payload_compaction"] = { - "schema_version": THIN_TODO_LIST_SUMMARY_SCHEMA_VERSION, - "view": THIN_EXPLICIT_VIEW, - "source_view": source_view, - "role": role, - "items_projected_to": "todos", - "item_limit_per_role": item_limit_per_role, - "item_fields": list(THIN_TODO_LIST_ITEM_FIELDS), - "item_text_limit": AGENT_LANE_TODO_LIST_TEXT_LIMIT, - "nested_item_limit": THIN_TODO_LIST_NESTED_ITEM_LIMIT, - "items_matched": items_matched, - "items_returned": items_returned, - "items_omitted": items_omitted, - "compacted_lanes": compacted_lanes, - "omitted_nonempty_lane_count": omitted_nonempty_lane_count, - "omitted_nonempty_dict_count": omitted_nonempty_dict_count, - "full_detail_cold_path": "todo list without --thin or active state", - } - return compact - - -def thin_todo_list_field_projection_contract( - *, - matched_todo_count: int, - returned_todo_count: int, - item_limit_per_role: int, - source_view: str, -) -> dict[str, Any]: - return { - "schema_version": THIN_TODO_LIST_PROJECTION_SCHEMA_VERSION, - "view": THIN_EXPLICIT_VIEW, - "source_view": source_view, - "item_container": "todos", - "matched_todo_count": matched_todo_count, - "returned_todo_count": returned_todo_count, - "omitted_todo_count": max( - 0, - matched_todo_count - returned_todo_count, - ), - "item_limit_per_role": item_limit_per_role, - "counts_cover_full_match": True, - "item_fields": list(THIN_TODO_LIST_ITEM_FIELDS), - "item_text_limit": AGENT_LANE_TODO_LIST_TEXT_LIMIT, - "nested_item_limit": THIN_TODO_LIST_NESTED_ITEM_LIMIT, - "nested_dict_field_limit": THIN_TODO_LIST_NESTED_DICT_FIELD_LIMIT, - "full_detail_cold_paths": [ - "todo list without --thin", - "todo list --todo-id without --thin", - "active state", - ], - } - - -def compact_thin_todo_list_payload(payload: dict[str, Any]) -> dict[str, Any]: - """Return the opt-in Todo list field and cardinality projection.""" - - compact = dict(payload) - compact.pop("state_file", None) - compact.pop("project", None) - compact.pop("filter_semantics", None) - - explicit_limit = compact.get("explicit_limit") - item_limit_per_role = THIN_TODO_LIST_ITEM_LIMIT_PER_ROLE - if isinstance(explicit_limit, int): - item_limit_per_role = min(item_limit_per_role, explicit_limit) - - source_projection = compact.get("todo_list_projection") - source_projection = source_projection if isinstance(source_projection, dict) else {} - source_view = str(source_projection.get("view") or "full_detail") - matched_todo_count = int( - source_projection.get("matched_todo_count") or compact.get("todo_count") or 0 - ) - - projected_items: list[dict[str, Any]] = [] - for key, role in (("user_todos", "user"), ("agent_todos", "agent")): - summary = compact.get(key) - if not isinstance(summary, dict): - continue - source_items = [ - item for item in summary.get("items") or [] if isinstance(item, dict) - ] - retained_items = source_items[:item_limit_per_role] - projected_role_items = [_compact_thin_item(item) for item in retained_items] - projected_items.extend(projected_role_items) - items_matched = int(summary.get("total_count") or len(source_items)) - projected = compact_thin_todo_summary( - summary, - role=role, - items_matched=items_matched, - items_returned=len(projected_role_items), - item_limit_per_role=item_limit_per_role, - ) - compact[key] = projected - compact["todos"] = projected_items - returned_todo_count = len(projected_items) - omitted_todo_count = max(0, matched_todo_count - returned_todo_count) - compact["matched_todo_count"] = matched_todo_count - compact["returned_todo_count"] = returned_todo_count - compact["omitted_todo_count"] = omitted_todo_count - todo_id_filter = compact.get("todo_id_filter") - if isinstance(todo_id_filter, str): - matched = next( - ( - item - for item in projected_items - if item.get("todo_id") == todo_id_filter - ), - None, - ) - compact["todo"] = matched - compact["relations"] = todo_item_relations(matched) if matched else {} - - compact["thin"] = True - compact["todo_list_field_projection"] = thin_todo_list_field_projection_contract( - matched_todo_count=matched_todo_count, - returned_todo_count=returned_todo_count, - item_limit_per_role=item_limit_per_role, - source_view=source_view, - ) - return compact +def compact_thin_todo_summary(summary, *, role, items_matched, items_returned, item_limit_per_role): + return effect_runtime_result("todo.context.page", {"thin_summary_payload": summary, "details": { + "role": role, "items_matched": items_matched, "items_returned": items_returned, + "item_limit_per_role": item_limit_per_role}}, large_local_snapshot=True) def todo_item_relations(item: dict[str, Any]) -> dict[str, Any]: """Project the stable relationship fields for one todo list item.""" relations: dict[str, Any] = {} - for key in ( - "claimed_by", - "bound_agent", - "goal_bound", - "blocks_agent", - "excluded_agents", - "global_gate", - "unblocks_todo_id", - "successor_todo_ids", - "completion_continuation", - "completion_recovery", - "superseded_by", - "resume_when", - "resume_monitor_generation", - "resume_condition", - "resume_ready", - "decision_scope", - "required_decision_scopes", - "required_write_scopes", - "required_capabilities", - "target_capabilities", - "task_class", - "action_kind", - "continuation_policy", - "target_key", - "cadence", - "next_due_at", - "expires_at", - ): + for key in _READ_CODEC["relation_fields"]: value = item.get(key) if value is not None and value != []: relations[key] = value return relations -def compact_agent_lane_todo_summary( - summary: dict[str, Any], - *, - role: str, -) -> dict[str, Any]: - """Bound one agent-lane list summary while retaining actionable identities.""" - compact: dict[str, Any] = {} - compacted_lanes: dict[str, dict[str, int]] = {} - omitted_nonempty_lane_count = 0 - omitted_nonempty_dict_count = 0 - for key, value in summary.items(): - if isinstance(value, list): - limit = _RETAINED_LANE_LIMITS.get(key) - if limit is None: - omitted_nonempty_lane_count += bool(value) - continue - if key == "items": - retained = [ - item - for item in value - if not isinstance(item, dict) or item.get("status") != "done" - ] - else: - retained = value - compact[key] = [_compact_item(item) for item in retained[:limit]] - if len(retained) > limit: - compacted_lanes[key] = {"shown": limit, "total": len(retained)} - continue - if isinstance(value, dict): - if key in _RETAINED_DICTS: - compact[key] = value - else: - omitted_nonempty_dict_count += bool(value) - continue - compact[key] = value - compact["payload_compaction"] = { - "schema_version": AGENT_LANE_TODO_LIST_SUMMARY_SCHEMA_VERSION, - "role": role, - "item_limit": AGENT_LANE_TODO_LIST_ITEM_LIMIT, - "item_text_limit": AGENT_LANE_TODO_LIST_TEXT_LIMIT, - "terminal_items_in_hot_path": False, - "compacted_lanes": compacted_lanes, - "omitted_nonempty_lane_count": omitted_nonempty_lane_count, - "omitted_nonempty_dict_count": omitted_nonempty_dict_count, - "full_detail_cold_path": ( - "todo list with --role, --status, or --todo-id; " - "todo list without --agent-id; or active state" - ), - } - return compact - - -def compact_explicit_limit_todo_summary( - summary: dict[str, Any], - *, - role: str, - item_limit: int, -) -> dict[str, Any]: - """Bound every item-bearing lane for the explicit-limit cold path.""" - compact: dict[str, Any] = {} - compacted_lanes: dict[str, dict[str, int]] = {} - omitted_nonempty_dict_count = 0 - for key, value in summary.items(): - if isinstance(value, list): - compact[key] = [_compact_item(item) for item in value[:item_limit]] - if len(value) > item_limit: - compacted_lanes[key] = { - "shown": item_limit, - "total": len(value), - } - continue - if isinstance(value, dict): - if key in _RETAINED_DICTS: - compact[key] = value - else: - omitted_nonempty_dict_count += bool(value) - continue - compact[key] = value - compact["payload_compaction"] = { - "schema_version": EXPLICIT_LIMIT_TODO_LIST_SUMMARY_SCHEMA_VERSION, - "view": EXPLICIT_LIMIT_COLD_PATH_VIEW, - "role": role, - "item_limit": item_limit, - "item_text_limit": AGENT_LANE_TODO_LIST_TEXT_LIMIT, - "compacted_lanes": compacted_lanes, - "omitted_nonempty_dict_count": omitted_nonempty_dict_count, - "full_detail_cold_path": "todo list without --limit or active state", - } - return compact - - def todo_list_projection_contract( *, matched_todo_count: int, diff --git a/loopx/control_plane/todos/list_readback.py b/loopx/control_plane/todos/list_readback.py index 332588ea6d..b9403a37d7 100644 --- a/loopx/control_plane/todos/list_readback.py +++ b/loopx/control_plane/todos/list_readback.py @@ -11,43 +11,12 @@ from ...history import load_registry from ...paths import resolve_runtime_root from ...rollout_event_log import load_rollout_events, rollout_event_log_path -from ..effect_runtime import effect_runtime_result from ..goals.state_resolution import resolve_goal_state -from ..coordination.local_authority import ( - canonical_todo_items, - canonical_todo_summary_fields, - read_canonical_todos_if_promoted, -) -from .active_state_todo_parser import parse_todo_source from .contract import normalize_todo_claimed_by, normalize_todo_id, normalize_todo_status -from .goal_todo_projection import ( - exact_archived_todo_summaries, - retained_todo_summary_fields, - goal_todo_summaries, - todo_summaries_from_fields, -) -from .list_projection import ( - compact_agent_lane_todo_summary, - compact_thin_todo_list_payload, - todo_item_relations, - todo_list_projection_contract, -) from .todo_index import MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL from .resume_condition import TodoResumeRolloutEvents -def _restore_full_source_body( - detail: dict[str, Any], todo_id: str, source_items: list[dict[str, Any]], -) -> None: - """Restore source text only after the shared reader has scoped the record.""" - matches = [item for item in source_items - if item.get("todo_id") == todo_id - and item.get("role") == detail.get("role") - and item.get("archive_state", "active") == detail.get("archive_state", "active")] - if len(matches) == 1: - detail["text"] = str(matches[0].get("text") or "") - else: - raise ValueError("Todo detail cannot recover an unambiguous full source body") def list_goal_todos( @@ -64,6 +33,7 @@ def list_goal_todos( limit: int | None = None, thin: bool = False, read_scope: Literal["active", "completed_history"] = "active", + json_references: bool = False, ) -> dict[str, Any]: if read_scope not in {"active", "completed_history"}: raise ValueError("Todo read_scope must be active or completed_history") @@ -101,173 +71,47 @@ def list_goal_todos( rollout_events = TodoResumeRolloutEvents(rollout_events, runtime_root=runtime_root, goal_id=goal_id) - roles = [role] if role else ["user", "agent"] - canonical_read = read_canonical_todos_if_promoted( - runtime_root=runtime_root, - goal_id=goal_id, - ) - if canonical_read is not None: - projected = todo_summaries_from_fields( - fields=(retained_todo_summary_fields( - canonical_todo_items(canonical_read["todos"]), rollout_events=rollout_events, - ) if read_scope == "completed_history" else canonical_todo_summary_fields( - canonical_read["todos"], - rollout_events=rollout_events, - goal_acceptance_contract=canonical_read.get("goal_acceptance_contract"), - goal_acceptance_work_guards=canonical_read.get("goal_acceptance_work_guards"), - )), - source="file_authority", - rollout_events=rollout_events, - roles=roles, - status=status, - todo_id=normalized_todo_id, - agent_id=normalized_agent_id, - limit=limit, - ) - else: - if not resolved_state_file.exists(): - raise ValueError(f"active state file does not exist: {resolved_state_file}") - state_text = resolved_state_file.read_text(encoding="utf-8") - projected = goal_todo_summaries( - goal, - state_text=state_text, - state_path=resolved_state_file, - rollout_events=rollout_events, - roles=roles, - status=status, - todo_id=normalized_todo_id, - agent_id=normalized_agent_id, - limit=limit, - include_retained=read_scope == "completed_history", - ) - if read_scope == "active" and normalized_todo_id and not projected.todos: - if canonical_read is not None: - archived_items = [ - item - for item in canonical_todo_items(canonical_read["todos"]) - if item.get("archive_state") == "archive" - ] - else: - _active_items, archived_items, _source_sections = parse_todo_source( - state_text, - goal=goal, - state_path=resolved_state_file, - ) - archived_projection = exact_archived_todo_summaries( - archived_items=archived_items, - source=projected.source, - rollout_events=rollout_events, - roles=roles, - status=status, - todo_id=normalized_todo_id, - agent_id=normalized_agent_id, - limit=limit, - ) - if archived_projection is not None: - projected = archived_projection - source = projected.source - summaries = projected.summaries - todos = projected.todos - # Exact cold reads restore source bytes after the shared summary owner has - # evaluated identity/status/guards. List and thin projections stay bounded; - # a hot summary is never treated as the original request. - if normalized_todo_id and not thin and len(todos) == 1: - if canonical_read is not None: - source_items = canonical_read["todos"] - else: - active, archived, _sections = parse_todo_source( - state_text, goal=goal, state_path=resolved_state_file, - ) - source_items = [*active["user"], *active["agent"], *archived] - _restore_full_source_body(todos[0], normalized_todo_id, source_items) - unfiltered_count = projected.unfiltered_count - uncapped_todo_count = projected.uncapped_todo_count + from ..coordination.local_authority import local_authority_is_promoted, LocalCoordinationAuthorityUnavailable + from .goal_todo_projection import project_todo_read + from ..runtime.time import now_utc - matched_todo_count = len(todos) - agent_lane_hot_path = bool( - read_scope == "active" and normalized_agent_id and limit is None - and role is None - and status is None - and normalized_todo_id is None - ) - if agent_lane_hot_path: - summaries = { - key: compact_agent_lane_todo_summary( - summary, - role=key.removesuffix("_todos"), - ) - for key, summary in summaries.items() - } - todos = [ - item - for key in ("user_todos", "agent_todos") - for item in summaries.get(key, {}).get("items") or [] - if isinstance(item, dict) - ] + now = now_utc() + params = {"kind": "query", + "source": {"runtime_root": str(runtime_root.expanduser().resolve(strict=False)), "goal_id": goal_id}, + "rollout_events": list(rollout_events), "rollout_event_source": rollout_events.source, + "observed_at": now.timestamp(), "evaluated_at": now.isoformat(), + "query": {"goal_id": goal_id, "role": role, "status": normalize_todo_status(status), + "todo_id": normalized_todo_id, "agent_id": normalized_agent_id, "limit": limit, + "read_scope": read_scope, "json_references": json_references, "thin": thin, + "state_file": str(resolved_state_file), "project": str(resolved_project) if resolved_project else None}} + if not local_authority_is_promoted(runtime_root=runtime_root, goal_id=goal_id): + params["legacy_source"] = _legacy_todo_read_source(goal, resolved_state_file, + todo_id=normalized_todo_id, retained=read_scope == "completed_history") + result = project_todo_read(params) + if result.get("source_request") == "legacy_todo_source": + params["legacy_source"] = _legacy_todo_read_source(goal, resolved_state_file, + todo_id=normalized_todo_id, retained=read_scope == "completed_history") + result = project_todo_read(params) + if "payload" not in result: + failure = result["canonical"] + code = str(failure.get("reason_code") or "local_authority_todo_list_unavailable") + payload = {"source_authority": "unknown_fail_closed"} if code == "local_authority_mode_read_failed" else failure + raise LocalCoordinationAuthorityUnavailable(str(failure.get("reason") or "canonical Todo authority is unavailable"), + code=code, payload=payload) + payload = result["payload"] + if "canonical_acceptance" in result: + from .goal_todo_projection import observe_todo_acceptance + payload = observe_todo_acceptance(payload, result["canonical_acceptance"], + registry_path=registry_path, goal_id=goal_id) + return payload + + +def _legacy_todo_read_source(goal, state_file, *, todo_id, retained): + from ..goals.legacy_event_source import require_no_legacy_todo_events - matched_todo = todos[0] if len(todos) == 1 else None - payload: dict[str, Any] = { - "ok": True, - "dry_run": True, - "read_only": True, - "command": "list", - "goal_id": goal_id, - "role": role or "all", - "status_filter": normalize_todo_status(status) if status else None, - "source": source, - "todo_count": matched_todo_count, - "todos": todos, - "state_file": str(resolved_state_file), - "project": str(resolved_project) if resolved_project else None, - } - if canonical_read is not None: - payload["authority_read"] = { - "source_authority": canonical_read["source_authority"], - "provider_revision": canonical_read.get("provider_revision"), - "cursor": canonical_read.get("cursor"), - "todo_read_model": canonical_read.get("todo_read_model"), - "decision_read_from_provider": True, - "legacy_fallback_used": False, - } - if normalized_agent_id: - payload["agent_id_filter"] = normalized_agent_id - payload["unfiltered_todo_count"] = unfiltered_count - payload["filter_semantics"] = ( - "agent todos include unclaimed items plus claimed_by=; " - "User gates use global_gate, then blocks_agent, then legacy claimed_by scope; " - "User actions use bound_agent, then legacy claimed_by scope; unscoped items remain visible" - ) - if agent_lane_hot_path: - payload["returned_todo_count"] = len(todos) - payload["todo_list_projection"] = todo_list_projection_contract( - matched_todo_count=matched_todo_count, - returned_todo_count=len(todos), - ) - if limit is not None: - payload["explicit_limit"] = limit - payload["unfiltered_todo_count"] = unfiltered_count - payload["returned_todo_count"] = len(todos) - payload["todo_list_projection"] = todo_list_projection_contract( - matched_todo_count=uncapped_todo_count, - returned_todo_count=len(todos), - view="explicit_limit_cold_path", - item_limit_per_role=limit, - full_detail_cold_paths=( - "todo list without --limit", - "active state", - ), - ) - if normalized_todo_id: - payload["todo_id_filter"] = normalized_todo_id - payload["matched"] = bool(todos) - payload["relations"] = todo_item_relations(matched_todo) if matched_todo else {} - if len(todos) > 1: - payload["ambiguous"] = True - if not todos: - payload["not_found"] = True - if normalized_todo_id: - return effect_runtime_result( - "todo.context.page", {"detail_payload": payload}, large_local_snapshot=True, - ) - payload.update(summaries) - return compact_thin_todo_list_payload(payload) if thin else payload + if not state_file.exists(): + raise ValueError(f"active state file does not exist: {state_file}") + state_text = state_file.read_text(encoding="utf-8") + require_no_legacy_todo_events(goal, state_path=state_file) + from .goal_todo_projection import prepare_todo_source_text + return prepare_todo_source_text(goal, state_text, state_file, todo_id=todo_id, retained=retained) diff --git a/loopx/control_plane/todos/read_codec.ts b/loopx/control_plane/todos/read_codec.ts new file mode 100644 index 0000000000..11da7fbe83 --- /dev/null +++ b/loopx/control_plane/todos/read_codec.ts @@ -0,0 +1,254 @@ +/** Compatibility codecs for canonical and historical Todo read records. */ +import {createHash} from "node:crypto"; +import type {JsonObject} from "../effect_program.ts"; +import {canonicalAuthorityJson, canonicalAuthoritySha256, authorityUnicodeCompare, AuthorityStoreProtocolError} from "../coordination/authority_store_codec.ts"; +import {COORDINATION_STATE_CONTRACT} from "../coordination/coordination_state_contract.generated.ts"; +import {compactPythonWhitespace, stripPythonWhitespace, normalizeTodoAgent} from "../coordination/todo_agents.ts"; +import {legacyTodoPriority} from "./priority.ts"; +import {normalizeTodoNoFollowup} from "./completion_state.ts"; +import {normalizeTodoResumeWhen, TODO_RESUME_NORMALIZE_REQUEST_SCHEMA_VERSION} from "./resume_condition.ts"; +import {normalizeTodoDecisionScope} from "./decision_metadata.ts"; +import {normalizeTodoCapabilities, normalizeTodoRepository} from "./work_requirements.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import codec from "./read_legacy_codec.json" with {type: "json"}; + +export const recordFields = COORDINATION_STATE_CONTRACT.todo_read_record.fields; +const classes = ["advancement_task", "continuous_monitor", "user_gate", "user_action", "blocker"]; +export const id = (value: unknown): string | null => { + const candidate = typeof value === "string" ? stripPythonWhitespace(value).toLowerCase() : ""; + return /^todo_[a-z0-9_-]{3,64}$/.test(candidate) ? candidate : null; +}; +export const object = (value: unknown): JsonObject => value !== null && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : {}; +export const text = (value: unknown): string => value == null ? "" : String(value); +export function ids(value: unknown): string[] { + const values = Array.isArray(value) ? value : [value]; + const found = values.flatMap(raw => (typeof raw === "object" && raw !== null ? JSON.stringify(raw) : text(raw || "")) + .match(new RegExp(boundary + "todo_[A-Za-z0-9_-]+" + boundary, "gu")) ?? []); + return [...new Set(found.map(id).filter((v): v is string => v !== null))]; +} +const hash = (value: string): string => createHash("sha256").update(value).digest("hex"); +export const withoutNull = (value: JsonObject): JsonObject => Object.fromEntries(Object.entries(value).filter(([, v]) => v != null)); + +export function compactTodoReadText(value: string, limit: number | null = 500): string { + const full = compactPythonWhitespace(value), points = Array.from(full); + return limit === null || points.length <= limit ? full : stripPythonWhitespace(points.slice(0, limit - 1).join("")) + "…"; +} +export function pythonJson(value: unknown): string { + return JSON.stringify(canonicalAuthorityJson(value)).replace(/[\u007f-\uffff]/g, + ch => "\\u" + ch.charCodeAt(0).toString(16).padStart(4, "0")); +} +const boundary = "(?:(?<=[\\p{L}\\p{N}_])(?![\\p{L}\\p{N}_])|(? values.map(pattern => new RegExp(pattern.replace("(?i)", "").replaceAll("\\b", boundary).replaceAll("\\s", " "), "iu")); +const classification = [ + [patterns(codec.TODO_HARD_MONITOR_PATTERNS), "continuous_monitor"], + [patterns(codec.TODO_ADVANCEMENT_OVERRIDE_PATTERNS), "advancement_task"], + [patterns(codec.TODO_BLOCKED_MONITOR_PATTERNS), "continuous_monitor"], + [patterns(codec.TODO_MONITOR_PATTERNS), "continuous_monitor"], +] as const; + +export function taskClass(item: JsonObject): string { + const explicit = text(item.task_class).trim().toLowerCase(); + if (classes.includes(explicit)) return explicit; + const action = text(item.action_kind).trim().toLowerCase(); + if (codec.advancement_actions.includes(action)) return "advancement_task"; + if (codec.monitor_actions.includes(action)) return "continuous_monitor"; + const label = compactPythonWhitespace(text(item.text)).replace(/[\u0130\u0131]/gu, "i"); + for (const [rules, kind] of classification) if (rules.some(rule => rule.test(label))) return kind; + return "advancement_task"; +} + +export function readAgent(value: unknown): string | null { + try {return normalizeTodoAgent(typeof value === "boolean" ? value ? "True" : "" : value, "agent");} + catch (error) {if (error instanceof AuthorityStoreProtocolError) return null; throw error;} +} +export function readExcludedAgents(value: unknown): string[] { + const values = typeof value === "string" ? value.split(",") : Array.isArray(value) ? value : [value]; + return [...new Set(values.map(readAgent).filter((v): v is string => v !== null))].sort(authorityUnicodeCompare); +} +export const readNoFollowup = (value: unknown): boolean | null => normalizeTodoNoFollowup(typeof value === "boolean" ? value : text(value || "")); +export function readResume(value: unknown): string | null { + const candidate = compactPythonWhitespace(text(value || "")); + const known = normalizeTodoResumeWhen({schema_version: TODO_RESUME_NORMALIZE_REQUEST_SCHEMA_VERSION, resume_when: candidate}); + if (known) return known; + const lowered = candidate.toLowerCase(); + return !lowered.startsWith("resume_at:") && /^[a-z][a-z0-9_-]{0,31}(?::[a-z0-9_.:@-]{1,96})?$/.test(lowered) ? lowered : null; +} + +export function readScope(value: unknown): JsonObject | null { + const raw = typeof value === "string" ? value : object(value); + const selected = typeof raw === "string" ? raw : {kind: raw.kind, granularity: raw.granularity, scope_key: raw.scope_key, + ...(id(raw.decision_id) ? {decision_id: id(raw.decision_id)} : {})}; + try {return normalizeTodoDecisionScope(selected);} + catch (error) {if (error instanceof EffectRuntimeRequestError) return null; throw error;} +} +function readScopes(value: unknown): JsonObject[] { + const values = Array.isArray(value) ? value : text(value || "").split(/[,;|]/u), result: JsonObject[] = []; + for (const raw of values) { + const scope = readScope(raw); + if (scope && !result.some(row => row.kind === scope.kind && row.granularity === scope.granularity && row.scope_key === scope.scope_key)) result.push(scope); + } + return result; +} + +function listValues(value: unknown): unknown[] { + return Array.isArray(value) ? value : text(value || "").split(/[,;|]/u); +} +function readCapabilities(value: unknown): string[] { + const result: string[] = []; + for (const raw of listValues(value)) { + try { + for (const token of normalizeTodoCapabilities([scalarText(raw)], "capability")) if (!result.includes(token)) result.push(token); + } catch (error) {if (!(error instanceof EffectRuntimeRequestError)) throw error;} + } + return result; +} +function writeScopes(value: unknown): string[] { + const scopes = listValues(value).map(raw => compactPythonWhitespace(scalarText(raw))).filter(scope => scope && + Array.from(scope).length <= 160 && !scope.startsWith("/") && !scope.startsWith("~") && !scope.split("/").includes("..") && !/[ <>]/u.test(scope)); + return [...new Set(scopes)]; +} + +const truthy = (v: unknown): boolean => Array.isArray(v) ? v.length > 0 : v !== null && typeof v === "object" ? Object.keys(v).length > 0 : Boolean(v); +function repr(value: unknown): string { + if (value == null) return "None"; + if (typeof value === "boolean") return value ? "True" : "False"; + if (Array.isArray(value)) return "[" + value.map(repr).join(", ") + "]"; + if (typeof value === "object") return "{" + Object.entries(value).map(([k, v]) => `${repr(k)}: ${repr(v)}`).join(", ") + "}"; + if (typeof value !== "string") return String(value); + const quote = value.includes("'") && !value.includes('"') ? '"' : "'"; + return quote + Array.from(value).map(point => { + if (point === quote || point === "\\") return "\\" + point; + if (point === "\n") return "\\n"; + if (point === "\r") return "\\r"; + if (point === "\t") return "\\t"; + if (point !== " " && /[\p{C}\p{Z}]/u.test(point)) { + const code = point.codePointAt(0)!; + return code <= 255 ? "\\x" + code.toString(16).padStart(2, "0") : code <= 65535 ? + "\\u" + code.toString(16).padStart(4, "0") : "\\U" + code.toString(16).padStart(8, "0"); + } + return point; + }).join("") + quote; +} +const scalarText = (value: unknown): string => !truthy(value) ? "" : typeof value === "string" ? value : repr(value); + +export function handoffSource(item: JsonObject): JsonObject { + const refs = (value: unknown): JsonObject => ({...(truthy(value) ? {truthy: true} : {}), + ...(Array.isArray(value) && value.length ? {values: value.map(scalarText)} : {})}); + const nestedMetadata: JsonObject = {}, nested: JsonObject = {}; + for (const key of ["handoff", "handoff_note"]) { + const value = item[key], row = object(value); + if (truthy(value)) nested[key] = {truthy: true, fields: value !== null && typeof value === "object" && !Array.isArray(value) + ? Object.fromEntries(Object.entries(row).map(([k, v]) => [k, scalarText(v)])) : null}; + nestedMetadata[key] = {from_agent: readAgent(truthy(row.from_agent) ? row.from_agent : item.agent_id), + to_agent: readAgent(row.to_agent), evidence_refs: refs(row.evidence_refs)}; + } + const fields = ["continuation_hint", "suggested_next_action", "note", "reason", "summary", "intent", "blocked_on", "evidence", "title", "text", "task_class", "goal_id", "source", "latest_event_kind"]; + const action = compactPythonWhitespace(scalarText(item.action_kind)).toLowerCase(); + const legacy = "(" + [item.goal_id, item.role, item.index, truthy(item.text) ? item.text : item.title].map(repr).join(", ") + ")"; + return {...nested, nested_metadata: nestedMetadata, + texts: Object.fromEntries(fields.filter(key => truthy(item[key])).map(key => [key, scalarText(item[key])])), + metadata: {todo_id: id(item.todo_id), claimed_by: readAgent(item.claimed_by), excluded_agents: readExcludedAgents(item.excluded_agents), + successor_todo_ids: ids(item.successor_todo_ids), unblocks_todo_id: id(item.unblocks_todo_id), superseded_by: id(item.superseded_by), + action_kind: /^[a-z][a-z0-9_-]{0,63}$/.test(action) ? action : null, resume_when: readResume(item.resume_when), + required_decision_scopes: readScopes(item.required_decision_scopes), decision_scope: readScope(item.decision_scope)}, + evidence_refs: refs(item.evidence_refs), has_evidence: truthy(item.evidence), has_note: truthy(item.note), + goal_id: "", source: "", legacy_id: id(item.todo_id) ? null : "handoff_" + createHash("sha1").update(legacy).digest("hex").slice(0, 12)}; +} + +/** Provider records are already decoded. This changes presentation, never authority. */ +export function canonicalTodoReadItems(values: JsonObject[]): JsonObject[] { + return values.map((value, offset) => { + const archived = value.archive_state === "archive", native = value.schema_version === "todo_domain_record_v0"; + const row = {...value, ...(archived ? {source_section: "Completed Work Archive", index: offset + 1} : {}), + ...(native ? {schema_version: "todo_item_v0", source_section: archived ? "Completed Work Archive" : + value.role === "agent" ? "Agent Todo" : "User Todo / Owner Review Reading Queue", index: offset + 1} : {})}; + return row; + }); +} + +export function structureCanonicalTodo(item: JsonObject, role: unknown, section: unknown, + limit: number | null = 500, revision = true): JsonObject { + const full = compactPythonWhitespace(text(item.text)), body = compactTodoReadText(full, limit); + const normalizedStatus = stripPythonWhitespace(text(item.status)).toLowerCase(); + const status = ["open", "blocked", "done", "deferred"].includes(normalizedStatus) ? normalizedStatus : item.done ? "done" : "open"; + const parsed = legacyTodoPriority(body); + return {...readMetadata(item), schema_version: "todo_item_v0", role, source_section: section, + archive_state: item.archive_state ?? "active", status, done: status === "done" || status === "deferred", + text: body, task_class: taskClass({...item, text: body}), + ...(revision && full ? {content_revision: "sha256:" + hash(full)} : {}), + ...(parsed.priority ? {priority: parsed.priority, title: compactTodoReadText(parsed.title, limit)} : {})}; +} + +function readMetadata(item: JsonObject): JsonObject { + const result = {...item}; + for (const key of ["created_by", "claimed_by", "bound_agent", "blocks_agent"]) { + const normalized = readAgent(item[key]); + if (normalized !== null) result[key] = normalized; + } + for (const key of ["global_gate", "goal_bound"]) { + const value = item[key]; + if (typeof value === "boolean") continue; + const token = compactPythonWhitespace(text(value || "")).toLowerCase(); + if (["1", "true", "yes", "y", "global_gate", "global-gate"].includes(token)) result[key] = true; + else if (["0", "false", "no", "n"].includes(token)) result[key] = false; + } + const excluded = readExcludedAgents(item.excluded_agents); + if (excluded.length) result.excluded_agents = excluded; + const resume = readResume(item.resume_when); + if (resume !== null) result.resume_when = resume; + for (const key of ["required_capabilities", "target_capabilities"]) { + const values = readCapabilities(item[key]); + if (values.length) result[key] = values; + } + const scopes = writeScopes(item.required_write_scopes); + if (scopes.length) result.required_write_scopes = scopes; + const decision = readScope(item.decision_scope), required = readScopes(item.required_decision_scopes); + if (decision) result.decision_scope = decision; + if (required.length) result.required_decision_scopes = required; + const outcome = compactPythonWhitespace(scalarText(item.decision_outcome)).toLowerCase(); + if (["approve", "reject", "cancel"].includes(outcome)) result.decision_outcome = outcome; + const binding = stripPythonWhitespace(scalarText(item.capability_binding_ref)).toLowerCase(); + if (/^[a-z][a-z0-9_.-]{0,31}:[a-z][a-z0-9_.:-]{2,95}$/u.test(binding)) result.capability_binding_ref = binding; + if (item.task_repository) { + try {const repository = normalizeTodoRepository(item.task_repository); if (repository) result.task_repository = repository;} + catch (error) {if (!(error instanceof EffectRuntimeRequestError)) throw error;} + } + for (const [key, values] of [["continuation_policy", ["independent_handoff", "same_agent_non_delivery"]], + ["removed_continuation_policy", ["primary_review", "review_handoff"]]] as const) { + const value = stripPythonWhitespace(scalarText(item[key])).toLowerCase(); + if ((values as readonly string[]).includes(value)) result[key] = value; + } + for (const key of ["resume_monitor_generation", "material_change_generation"]) { + const value = item[key]; + if (value != null && value !== "" && (typeof value === "number" || typeof value === "boolean" || typeof value === "string" && /^[+-]?\d+$/.test(value.trim()))) { + const number = Math.trunc(Number(value)); + if (Number.isSafeInteger(number) && number >= 0) result[key] = number; + } + } + for (const key of ["action_kind", "task_domain"]) { + const value = compactPythonWhitespace(text(item[key])).toLowerCase(); + if (new RegExp(key === "action_kind" ? "^[a-z][a-z0-9_-]{0,63}$" : "^[a-z][a-z0-9_.-]{0,63}$").test(value)) result[key] = value; + } + const noFollowup = readNoFollowup(item.no_followup); + if (noFollowup !== null) result.no_followup = noFollowup; + const declarationKeys = ["validation_command", "validation_command_argv", "validation_label", "validation_timeout_seconds"]; + if (declarationKeys.some(key => item[key] != null && item[key] !== "")) { + const command = typeof item.validation_command === "string" ? stripPythonWhitespace(item.validation_command) : item.validation_command; + let argv = item.validation_command_argv ?? null; + if (typeof argv === "string") { + argv = stripPythonWhitespace(argv); + if (!argv) argv = null; + else {try {argv = JSON.parse(argv as string);} catch (error) {if (!(error instanceof SyntaxError)) throw error;}} + } + const timeout = typeof item.validation_timeout_seconds === "string" && /^\d+$/.test(item.validation_timeout_seconds.trim()) + ? Number(item.validation_timeout_seconds.trim()) : item.validation_timeout_seconds === "" ? null : item.validation_timeout_seconds ?? null; + result.completion_validation_required = true; + result.completion_validation_sha256 = canonicalAuthoritySha256({validation_command: command === "" ? null : command ?? null, + validation_command_argv: argv, validation_label: item.validation_label === "" ? null : item.validation_label ?? null, + validation_timeout_seconds: timeout}); + if (!Object.hasOwn(result, "completion_validation_revision")) result.completion_validation_revision = 0; + if (!Object.hasOwn(result, "completion_validation_revision_history")) result.completion_validation_revision_history = []; + } + for (const key of declarationKeys) delete result[key]; + return result; +} diff --git a/loopx/control_plane/todos/read_legacy_codec.json b/loopx/control_plane/todos/read_legacy_codec.json new file mode 100644 index 0000000000..3bea730d27 --- /dev/null +++ b/loopx/control_plane/todos/read_legacy_codec.json @@ -0,0 +1,236 @@ +{ + "TODO_HARD_MONITOR_PATTERNS": [ + "(?i)\\bdo not\\b.*\\b(?:launch|run|execute|start)\\b.*\\buntil\\b", + "(?i)\\b(?:only|just)\\b.*\\b(?:after|when|once)\\b.*\\b(?:owner|user|credential|approval|prerequisite|evidence)\\b", + "(?i)\\b(?:credential|gcp|gcs|gcp_project|gcp_sa_key|gs://)\\b.*\\b(?:missing|required|provide|proof|prerequisite|gate|gated)\\b", + "(?i)\\b(?:readiness|proof)\\b.*\\bbefore any formal\\b.*\\brun\\b", + "(?i)\\bremaining formal\\b.*\\bpath\\b", + "(?i)\\b(?:route|input)\\b.*\\babsent\\b", + "(?i)\\b0\\b.*\\b(?:candidate|candidates)\\b" + ], + "TODO_ADVANCEMENT_OVERRIDE_PATTERNS": [ + "(?i)(?:^|[::]\\s*)(?:implement|add|make|fix|build|wire|define|compare|run|execute|test|validate|rebuild|repair|archive|publish|merge|write|attribute|collect|aggregate|generate|produce|materialize|rerun|repeat|eval|evaluate|score)\\b", + "(?i)\\b(?:implementation slice|validation-backed patch|smoke fixture|regression suite|readiness scan|source preflight|setup-readiness scan)\\b" + ], + "TODO_BLOCKED_MONITOR_PATTERNS": [ + "(?i)\\bdo not\\b.*\\b(?:launch|run|execute|start)\\b.*\\buntil\\b", + "(?i)\\b(?:only|just)\\b.*\\b(?:after|when|once)\\b.*\\b(?:owner|user|credential|approval|prerequisite|evidence)\\b", + "(?i)\\b(?:credential|gcp|gcs|gcp_project|gcp_sa_key|gs://)\\b.*\\b(?:missing|required|provide|proof|prerequisite|gate|gated)\\b", + "(?i)\\b(?:readiness|proof)\\b.*\\bbefore any formal\\b.*\\brun\\b", + "(?i)\\bremaining formal\\b.*\\bpath\\b", + "(?i)\\b(?:route|input)\\b.*\\babsent\\b", + "(?i)\\b0\\b.*\\b(?:candidate|candidates)\\b", + "(?i)\\b(?:blocked|gated|waiting)\\b.*\\b(?:owner|user|credential|substrate|proof|prerequisite|evidence)\\b" + ], + "TODO_MONITOR_PATTERNS": [ + "(?i)\\bdependency monitor\\b", + "(?i)\\bobservation lane\\b", + "(?i)(?:^|[::]\\s*)observe\\b", + "(?i)(?:^|[::]\\s*)poll\\b", + "(?i)(?:^|[::]\\s*)watch\\b", + "(?i)\\bmonitor-only\\b", + "(?i)\\bdo not\\b.*\\b(?:launch|run|execute|start)\\b.*\\buntil\\b", + "(?i)\\b(?:only|just)\\b.*\\b(?:after|when|once)\\b.*\\b(?:owner|user|credential|approval|prerequisite|evidence)\\b", + "(?i)\\b(?:credential|gcp|gcs|gcp_project|gcp_sa_key|gs://)\\b.*\\b(?:missing|required|provide|proof|prerequisite|gate|gated)\\b", + "(?i)\\b(?:readiness|proof)\\b.*\\bbefore any formal\\b.*\\brun\\b", + "(?i)\\bremaining formal\\b.*\\bpath\\b", + "(?i)\\b(?:route|input)\\b.*\\babsent\\b", + "(?i)\\b0\\b.*\\b(?:candidate|candidates)\\b", + "(?i)\\b(?:blocked|gated|waiting)\\b.*\\b(?:owner|user|credential|substrate|proof|prerequisite|evidence)\\b" + ], + "TODO_ADVANCEMENT_PATTERNS": [ + "(?i)(?:^|[::]\\s*)(?:implement|add|make|fix|build|wire|define|compare|run|execute|test|validate|rebuild|repair|archive|publish|merge|write|attribute|collect|aggregate|generate|produce|materialize|rerun|repeat|eval|evaluate|score)\\b", + "(?i)\\b(?:implementation slice|validation-backed patch|smoke fixture|regression suite|readiness scan|source preflight|setup-readiness scan)\\b", + "(?i)\\b(?:task|validation hypothesis|validation step|bounded step|learning run)\\b" + ], + "advancement_actions": [ + "advance", + "analyze", + "benchmark_run", + "codex_run", + "compact_blocker_writeback", + "compare", + "execute", + "fix", + "implement", + "rebuild", + "rebuild_score", + "repair", + "run", + "run_eval", + "test", + "validate", + "writeback" + ], + "monitor_actions": [ + "external_evidence", + "monitor", + "observe", + "poll", + "watch" + ], + "frontier_fields": [ + "todo_id", + "status", + "done", + "title", + "text", + "task_class", + "claimed_by", + "bound_agent", + "blocks_agent", + "excluded_agents", + "priority", + "action_kind", + "task_domain", + "task_repository", + "capability_binding_ref", + "required_capabilities", + "target_capabilities", + "target_key", + "continuation_policy", + "removed_continuation_policy", + "decision_scope", + "required_decision_scopes", + "decision_outcome", + "replan_obligation_id", + "unblocks_todo_id", + "depends_on_todo_id", + "depends_on_todo_ids", + "resume_when", + "no_followup", + "successor_todo_ids", + "completion_continuation" + ], + "list_constants": { + "_ITEM_FIELDS": [ + "schema_version", + "gate_state", + "successor_count", + "excluded_agents", + "index", + "todo_id", + "role", + "status", + "priority", + "text", + "title", + "note", + "task_class", + "action_kind", + "task_domain", + "capability_binding_ref", + "task_repository", + "continuation_policy", + "required_capabilities", + "target_capabilities", + "claimed_by", + "bound_agent", + "goal_bound", + "blocks_agent", + "global_gate", + "unblocks_todo_id", + "successor_todo_ids", + "completion_continuation", + "completion_recovery", + "resume_when", + "resume_monitor_generation", + "resume_ready", + "target_key", + "cadence", + "next_due_at", + "expires_at", + "watch_only" + ], + "_RETAINED_LANE_LIMITS": { + "items": 12, + "first_open_items": 3, + "first_executable_items": 3, + "deferred_items": 3, + "monitor_due_items": 2, + "monitor_schedule_gap_items": 2, + "blocker_items": 2, + "resume_blocked_items": 2, + "active_next_action_items": 3, + "active_next_action_executable_items": 3 + }, + "_RETAINED_DICTS": [ + "deferred_work_counts", + "monitor_writeback", + "source_proof", + "terminal_closure_proof", + "work_counts" + ], + "THIN_TODO_LIST_ITEM_FIELDS": [ + "todo_id", + "role", + "status", + "priority", + "text", + "task_class", + "action_kind", + "claimed_by", + "bound_agent", + "goal_bound", + "blocks_agent", + "global_gate", + "unblocks_todo_id", + "decision_scope", + "required_decision_scopes", + "resume_when", + "resume_ready", + "target_key", + "cadence", + "next_due_at", + "expires_at", + "watch_only" + ], + "_COMPACT_ITEM_TEXT_FIELDS": [ + "note", + "text", + "title" + ], + "_THIN_ITEM_TEXT_FIELDS": [ + "text" + ], + "AGENT_LANE_TODO_LIST_ITEM_LIMIT": 12, + "AGENT_LANE_TODO_LIST_TEXT_LIMIT": 180, + "THIN_TODO_LIST_ITEM_LIMIT_PER_ROLE": 2, + "THIN_TODO_LIST_NESTED_ITEM_LIMIT": 3, + "THIN_TODO_LIST_NESTED_DICT_FIELD_LIMIT": 8, + "AGENT_LANE_TODO_LIST_PROJECTION_SCHEMA_VERSION": "agent_lane_todo_list_projection_v0", + "AGENT_LANE_TODO_LIST_SUMMARY_SCHEMA_VERSION": "agent_lane_todo_list_summary_compaction_v0", + "EXPLICIT_LIMIT_TODO_LIST_SUMMARY_SCHEMA_VERSION": "explicit_limit_todo_list_summary_compaction_v0", + "THIN_TODO_LIST_PROJECTION_SCHEMA_VERSION": "todo_list_thin_projection_v0", + "THIN_TODO_LIST_SUMMARY_SCHEMA_VERSION": "todo_list_thin_summary_compaction_v0" + }, + "relation_fields": [ + "claimed_by", + "bound_agent", + "goal_bound", + "blocks_agent", + "excluded_agents", + "global_gate", + "unblocks_todo_id", + "successor_todo_ids", + "completion_continuation", + "completion_recovery", + "superseded_by", + "resume_when", + "resume_monitor_generation", + "resume_condition", + "resume_ready", + "decision_scope", + "required_decision_scopes", + "required_write_scopes", + "required_capabilities", + "target_capabilities", + "task_class", + "action_kind", + "continuation_policy", + "target_key", + "cadence", + "next_due_at", + "expires_at" + ] +} diff --git a/loopx/control_plane/todos/read_presentation.ts b/loopx/control_plane/todos/read_presentation.ts new file mode 100644 index 0000000000..489e72756d --- /dev/null +++ b/loopx/control_plane/todos/read_presentation.ts @@ -0,0 +1,116 @@ +/** Existing Todo list response codecs, after full-source decisions. */ +import type {JsonObject} from "../effect_program.ts"; +import {stripPythonWhitespace} from "../coordination/todo_agents.ts"; +import {INTERNAL_STATE_SHAPE_PATTERNS} from "../goals/vision_checkpoint.ts"; +import {compactTodoReadText} from "./read_codec.ts"; +import codec from "./read_legacy_codec.json" with {type: "json"}; + +const layout = codec.list_constants; +const dict = (v: unknown): v is JsonObject => v !== null && typeof v === "object" && !Array.isArray(v); +const nonempty = (v: unknown): boolean => Array.isArray(v) ? v.length > 0 : dict(v) ? Object.keys(v).length > 0 : Boolean(v); +function compactText(v: unknown): unknown { + if (typeof v !== "string") return v; + const full = stripPythonWhitespace(v), points = Array.from(full); + return points.length <= 180 ? full : stripPythonWhitespace(points.slice(0, 177).join("")) + "..."; +} +function compactItem(value: unknown, thin = false): unknown { + if (!dict(value)) return value; + const fields = thin ? layout.THIN_TODO_LIST_ITEM_FIELDS : layout._ITEM_FIELDS; + const result: JsonObject = {}; + for (const key of fields) if (value[key] != null) result[key] = ["text", ...(thin ? [] : ["title", "note"])].includes(key) ? compactText(value[key]) : value[key]; + if (!thin && (value.task_class === "blocker" || value.status === "blocked")) { + const reason = compactTodoReadText(String(value.reason || ""), 220); + if (reason && !INTERNAL_STATE_SHAPE_PATTERNS.some(rule => rule.test(reason))) result.reason = reason; + } + return thin ? Object.fromEntries(Object.entries(result).map(([key, v]) => [key, compactThinValue(v)])) : result; +} +function compactThinValue(value: unknown): unknown { + if (typeof value === "string") return compactText(value); + if (Array.isArray(value)) return value.slice(0, 3).map(compactThinValue); + if (dict(value)) return Object.fromEntries(Object.entries(value).slice(0, 8).map(([k, v]) => [k, compactThinValue(v)])); + return value; +} + +export function compactTodoReadSummary(summary: JsonObject, role: string, limit: number | null): JsonObject { + const result: JsonObject = {}, lanes: JsonObject = {}; + let omittedLanes = 0, omittedDicts = 0; + for (const [key, value] of Object.entries(summary)) { + if (Array.isArray(value)) { + const cap = limit ?? (layout._RETAINED_LANE_LIMITS as Record)[key]; + if (cap === undefined) {omittedLanes += Number(nonempty(value)); continue;} + const retained = limit === null && key === "items" ? value.filter(row => !dict(row) || row.status !== "done") : value; + result[key] = retained.slice(0, cap).map(row => compactItem(row)); + if (retained.length > cap) lanes[key] = {shown: cap, total: retained.length}; + } else if (dict(value)) { + if (layout._RETAINED_DICTS.includes(key)) result[key] = value; + else omittedDicts += Number(nonempty(value)); + } else result[key] = value; + } + result.payload_compaction = limit === null ? { + schema_version: layout.AGENT_LANE_TODO_LIST_SUMMARY_SCHEMA_VERSION, role, item_limit: 12, item_text_limit: 180, + terminal_items_in_hot_path: false, compacted_lanes: lanes, omitted_nonempty_lane_count: omittedLanes, + omitted_nonempty_dict_count: omittedDicts, + full_detail_cold_path: "todo list with --role, --status, or --todo-id; todo list without --agent-id; or active state", + } : { + schema_version: layout.EXPLICIT_LIMIT_TODO_LIST_SUMMARY_SCHEMA_VERSION, view: "explicit_limit_cold_path", role, + item_limit: limit, item_text_limit: 180, compacted_lanes: lanes, omitted_nonempty_dict_count: omittedDicts, + full_detail_cold_path: "todo list without --limit or active state", + }; + return result; +} + +export function todoReadProjectionContract(matched: number, returned: number, limit: number | null): JsonObject { + return {schema_version: layout.AGENT_LANE_TODO_LIST_PROJECTION_SCHEMA_VERSION, + view: limit === null ? "agent_lane_hot_path" : "explicit_limit_cold_path", matched_todo_count: matched, + returned_todo_count: returned, item_limit_per_role: limit ?? 12, counts_cover_full_match: true, + full_detail_cold_paths: limit === null ? ["todo list with --role, --status, or --todo-id", "todo list without --agent-id", "active state"] : + ["todo list without --limit", "active state"]}; +} + +export function compactThinTodoRead(payload: JsonObject): JsonObject { + const result = {...payload}; + for (const key of ["state_file", "project", "filter_semantics"]) delete result[key]; + const limit = Math.min(2, typeof result.explicit_limit === "number" ? result.explicit_limit : 2); + const projection = dict(result.todo_list_projection) ? result.todo_list_projection : {}; + const sourceView = String(projection.view || "full_detail"), matched = Number(projection.matched_todo_count || result.todo_count || 0); + const todos: unknown[] = []; + for (const role of ["user", "agent"]) { + const key = `${role}_todos`, summary = result[key]; + if (!dict(summary)) continue; + const items = Array.isArray(summary.items) ? summary.items.filter(dict) : []; + const chosen = items.slice(0, limit).map(row => compactItem(row, true)); + todos.push(...chosen); + result[key] = compactThinTodoReadSummary(summary, {role, items_matched: Number(summary.total_count || items.length), + items_returned: chosen.length, item_limit_per_role: limit}); + } + return {...result, todos, matched_todo_count: matched, returned_todo_count: todos.length, + omitted_todo_count: Math.max(0, matched - todos.length), thin: true, + todo_list_field_projection: {schema_version: layout.THIN_TODO_LIST_PROJECTION_SCHEMA_VERSION, view: "thin_explicit_view", + source_view: sourceView, item_container: "todos", matched_todo_count: matched, returned_todo_count: todos.length, + omitted_todo_count: Math.max(0, matched - todos.length), item_limit_per_role: limit, counts_cover_full_match: true, + item_fields: layout.THIN_TODO_LIST_ITEM_FIELDS, item_text_limit: 180, nested_item_limit: 3, nested_dict_field_limit: 8, + full_detail_cold_paths: ["todo list without --thin", "todo list --todo-id without --thin", "active state"]}}; +} + +export function compactThinTodoReadSummary(summary: JsonObject, details: { + role: unknown; items_matched: number; items_returned: number; item_limit_per_role: number; +}): JsonObject { + const fields: JsonObject = {}; + let omittedLanes = 0, omittedDicts = 0, view = "full_detail"; + for (const [name, value] of Object.entries(summary)) { + if (Array.isArray(value)) {if (name !== "items") omittedLanes += Number(nonempty(value));} + else if (dict(value)) { + if (["work_counts", "deferred_work_counts"].includes(name)) fields[name] = value; + else if (name === "payload_compaction") view = typeof value.view === "string" && value.view ? value.view : + value.schema_version === layout.AGENT_LANE_TODO_LIST_SUMMARY_SCHEMA_VERSION ? "agent_lane_hot_path" : "full_detail"; + else omittedDicts += Number(nonempty(value)); + } else fields[name] = value; + } + const total = details.items_matched, omitted = Math.max(0, total - details.items_returned); + fields.payload_compaction = {schema_version: layout.THIN_TODO_LIST_SUMMARY_SCHEMA_VERSION, view: "thin_explicit_view", source_view: view, + role: details.role, items_projected_to: "todos", item_limit_per_role: details.item_limit_per_role, item_fields: layout.THIN_TODO_LIST_ITEM_FIELDS, item_text_limit: 180, + nested_item_limit: 3, items_matched: total, items_returned: details.items_returned, items_omitted: omitted, + compacted_lanes: omitted ? {items: {shown: details.items_returned, total}} : {}, omitted_nonempty_lane_count: omittedLanes, + omitted_nonempty_dict_count: omittedDicts, full_detail_cold_path: "todo list without --thin or active state"}; + return fields; +} diff --git a/loopx/control_plane/todos/read_projection.ts b/loopx/control_plane/todos/read_projection.ts new file mode 100644 index 0000000000..f72cc23311 --- /dev/null +++ b/loopx/control_plane/todos/read_projection.ts @@ -0,0 +1,332 @@ +/** One full-source Todo read reduction; the existing owners decide every lane. */ +import {authorityUnicodeCompare} from "../coordination/authority_store_codec.ts"; +import {stripPythonWhitespace} from "../coordination/todo_agents.ts"; +import {compactTodoReadSummary} from "./read_presentation.ts"; +import type {JsonObject} from "../effect_program.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {requireJsonObject, requireBoolean} from "../runtime_decode.ts"; +import {schedulerTimestampMilliseconds} from "../scheduler/state_store.ts"; +import {evaluateTodoResumeConditionsFromSource} from "./resume_condition.ts"; +import {normalizeTodoNoFollowup} from "./completion_state.ts"; +import {readTodoPriority, todoPriorityRank} from "./priority.ts"; +import {evaluateTodoSuccession, validateTodoSuccession, SUCCESSION_FACT_COLUMNS, + SUCCESSION_EVALUATION_COLUMNS} from "./succession.ts"; +import {projectTodoSummary, TODO_SUMMARY_PROJECTION_COLUMNS} from "./summary_projection.ts"; +import {projectTodoHandoffContext} from "./handoff_projection.ts"; +import {projectStandingDecisions} from "./standing_decision.ts"; +import {configuredGoalChecks} from "../goals/acceptance_contract.ts"; +import codec from "./read_legacy_codec.json" with {type: "json"}; + +export const TODO_READ_REQUEST = "todo_read_request_v0"; +export const TODO_READ_RESULT = "todo_read_result_v0"; +import {recordFields, id, object, text, ids, withoutNull, compactTodoReadText, pythonJson, + taskClass, canonicalTodoReadItems, structureCanonicalTodo, readAgent, readExcludedAgents, readNoFollowup, readResume, handoffSource} from "./read_codec.ts"; + +function successionFacts(item: JsonObject): JsonObject { + const resume = readResume(item.resume_when) || "", handoff = readExcludedAgents(item.excluded_agents).length > 0 && id(item.unblocks_todo_id) !== null; + const status = stripPythonWhitespace(text(item.status)).toLowerCase(); + return {todo_id: id(item.todo_id), status: ["open", "done", "blocked", "deferred"].includes(status) ? status : item.done ? "done" : "open", + active: (item.archive_state || "active") === "active", advancement: taskClass(item) === "advancement_task", + no_followup: readNoFollowup(item.no_followup) === true, successors: ids(item.successor_todo_ids), + superseded_by: id(item.superseded_by), unblocks: id(item.unblocks_todo_id), + resumes: resume.startsWith("todo_done:") ? id(resume.slice(10)) : null, handoff, done: item.done === true, + route_flag: typeof item.route_continuation_replan_required === "boolean" ? item.route_continuation_replan_required : null, + legacy_route_label: handoff && typeof item.route_continuation_replan_required !== "boolean" + ? [item.action_kind, item.title, item.text].filter(v => text(v).trim()).map(text).join(" ") : "", + context_fields: Object.keys(withoutNull(item)).filter(key => key !== "succession_evaluation").sort(authorityUnicodeCompare)}; +} +function compactRecord(item: JsonObject): JsonObject { + const result: JsonObject = {index: item.index ?? null, done: Boolean(item.done), text: item.text ?? null}; + for (const key of recordFields) if (!Object.hasOwn(result, key) && item[key] != null) result[key] = item[key]!; + if (item.content_revision) result.content_revision = item.content_revision; + if (item.goal_acceptance_guard && typeof item.goal_acceptance_guard === "object") result.goal_acceptance_guard = item.goal_acceptance_guard; + return result; +} + +function summaryRow(item: JsonObject, evaluation: JsonObject, preferred: Set): JsonObject { + const resume = readResume(item.resume_when) || "", status = item.status || (item.done ? "done" : "open"); + const index = typeof item.index === "number" && Number.isInteger(item.index) && item.index >= 0 ? item.index : 999999; + return {status, done: Boolean(item.done), task_class: taskClass(item), has_resume: Boolean(resume), resume_ready: item.resume_ready ?? null, + resume_evaluated: resumeEvaluated(item), + acceptance_blocked: object(item.goal_acceptance_guard).allowed === false, claimed: Boolean(item.claimed_by), + preferred: preferred.has(item.todo_id), watch_only: taskClass(item) === "continuous_monitor" && (item.watch_only === true || stripPythonWhitespace(text(item.watch_only || "")).toLowerCase() === "true"), + due_at: readScheduleTime(item.next_due_at), expires_at: readScheduleTime(item.expires_at), + sort: [todoPriorityRank(readTodoPriority(item)), index, + index === 999999 ? text(item.completed_at || item.updated_at) : "", index === 999999 ? text(item.todo_id) : ""], + completed_at: item.completed_at || null, updated_at: item.updated_at || null, completion_index: Number(item.index || 0), + linked_user_action: id(item.unblocks_todo_id) !== null, no_followup: readNoFollowup(item.no_followup) === true, + successor_gap: evaluation.successor_gap!, handoff_state: evaluation.handoff_state!, replan: evaluation.route_continuation_replan_required!, + todo_id: id(item.todo_id), claim: readAgent(item.claimed_by), bound: readAgent(item.bound_agent), blocks: readAgent(item.blocks_agent), + global: Boolean(item.global_gate), excluded: readExcludedAgents(item.excluded_agents)}; +} + +function resumeEvaluated(item: JsonObject): boolean { + const resume = readResume(item.resume_when), condition = object(item.resume_condition); + return !resume || condition.schema_version === "todo_resume_condition_v0" && condition.resume_when === resume && + typeof condition.satisfied === "boolean" && item.resume_ready === condition.satisfied; +} + +function readScheduleTime(value: unknown): number | null { + if (typeof value === "string") { + const date = /^([0-9]{4})-([0-9]{2})-([0-9]{2})/.exec(value.trim()); + if (date) { + const year = Number(date[1]), month = Number(date[2]), day = Number(date[3]); + const check = new Date(0); check.setUTCFullYear(year, month - 1, day); + if (check.getUTCFullYear() !== year || check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day) return null; + } + } + const milliseconds = schedulerTimestampMilliseconds(value); + return milliseconds === null ? null : milliseconds / 1000; +} + +function frontierFacts(items: JsonObject[]): JsonObject[] { + return items.map(item => ({id: stripPythonWhitespace(text(item.todo_id)), claim: readAgent(item.claimed_by), + excluded: readExcludedAgents(item.excluded_agents), updated: stripPythonWhitespace(text(item.updated_at || item.completed_at)), + advancement: taskClass(item) === "advancement_task", actionable: item.done !== true && item.status === "open" && (!readResume(item.resume_when) || item.resume_ready === true) && object(item.goal_acceptance_guard).allowed !== false, + serialized: pythonJson(Object.fromEntries(codec.frontier_fields.filter(key => item[key] != null).map(key => [key, item[key]!])))})); +} + +function handoffGates(items: JsonObject[], evaluations: JsonObject[]): JsonObject[] { + const fields = ["todo_id", "role", "task_class", "action_kind", "task_domain", "task_repository", "continuation_policy", "claimed_by", "unblocks_todo_id", "resume_when", "no_followup", "superseded_by", "route_continuation_replan_required", "route_continuation_reason", "route_id", "route_key", "next_due_at"]; + return items.flatMap((item, i) => { + const decision = evaluations[i]!; + if (decision.handoff_state == null) return []; + const successors = decision.successor_todo_ids as string[]; + return [withoutNull({schema_version: "todo_handoff_gate_v1", gate_state: decision.handoff_state, + index: item.index ?? null, done: item.done === true || item.status === "done" || item.status === "deferred", status: item.status!, + text: stripPythonWhitespace(text(item.text)), excluded_agents: item.excluded_agents ?? [], successor_count: successors.length, + ...Object.fromEntries(fields.filter(key => item[key] != null).map(key => [key, item[key]!])), + ...(decision.route_continuation_replan_required ? {route_continuation_replan_required: true, + route_continuation_reason: item.route_continuation_reason ?? "stale handoff closeout requires a bounded route continuation replan"} : {}), + ...(successors.length ? {successor_todo_ids: successors} : {})})]; + }).sort((a, b) => Number(a.index || 999999) - Number(b.index || 999999) || authorityUnicodeCompare(text(a.todo_id), text(b.todo_id))); +} + +export async function projectTodoReadGroup(input: JsonObject): Promise { + if (!Array.isArray(input.items) || !Array.isArray(input.lineage)) throw new EffectRuntimeRequestError("Todo read requires complete source arrays"); + const items = input.items.map(value => ({...requireJsonObject(value, "Todo read item")})); + const lineage = input.lineage.map(value => requireJsonObject(value, "Todo read lineage")); + const lineageFacts = input.lineage_facts == null ? lineage.map(successionFacts) : input.lineage_facts; + if (!Array.isArray(lineageFacts) || lineageFacts.length !== lineage.length) throw new EffectRuntimeRequestError("Todo lineage fact cardinality mismatch"); + for (const [index, value] of lineageFacts.entries()) { + const fact = requireJsonObject(value, "Todo lineage facts"); + if (fact.todo_id !== id(lineage[index]!.todo_id) || fact.active !== ((lineage[index]!.archive_state || "active") === "active")) { + throw new EffectRuntimeRequestError("Todo lineage facts disagree with source identity"); + } + } + const evaluated = requireBoolean(input.evaluated, "evaluated"); + if (evaluated && items.some(item => !resumeEvaluated(item))) { + throw new EffectRuntimeRequestError("Todo display requires a matching full-source resume evaluation"); + } + if (!evaluated) { + const waiting = items.filter(item => readResume(item.resume_when)); + if (waiting.length) { + const result = await evaluateTodoResumeConditionsFromSource({schema_version: "todo_resume_evaluation_request_v0", items: waiting, + source_items: [...(Array.isArray(input.resume_lineage) ? input.resume_lineage : lineage), ...items], rollout_events: input.rollout_events ?? [], evaluated_at: input.evaluated_at!, + ...(input.rollout_event_source == null ? {} : {rollout_event_source: input.rollout_event_source}), + available_capabilities: input.available_capabilities ?? null}); + const conditions = new Map((result.conditions as JsonObject[]).map(row => [row.todo_id, row.condition])); + for (const item of waiting) { + item.resume_condition = conditions.get(item.todo_id) ?? {schema_version: "todo_resume_condition_v0", resume_when: item.resume_when!, satisfied: false, unsupported: true}; + item.resume_ready = object(item.resume_condition).satisfied === true; + } + } + } + const facts = items.map(successionFacts); + let decisions: JsonObject[]; + if (items.length === 0) { + decisions = []; + } else if (evaluated) { + if (!Array.isArray(input.evaluations) || input.evaluations.length !== items.length || input.evaluations.some(value => value == null)) throw new EffectRuntimeRequestError("Todo display requires a matching full-source succession evaluation"); + const evaluations = input.evaluations; + decisions = facts.map((row, i) => validateTodoSuccession(row, evaluations[i])); + } else { + const selected = new Map(); + const key = (row: JsonObject) => JSON.stringify([id(row.todo_id), row.role ?? null, row.archive_state || "active"]); + for (const item of items) selected.set(key(item), (selected.get(key(item)) ?? 0) + 1); + const source: JsonObject[] = []; + lineage.forEach((row, index) => { + const count = selected.get(key(row)) ?? 0; + if (!count) source.push(requireJsonObject(lineageFacts[index], "Todo lineage facts")); + else selected.set(key(row), count - 1); + }); + try { + decisions = evaluateTodoSuccession([...source, ...facts]).slice(source.length); + } catch (error) { + if (!(error instanceof EffectRuntimeRequestError)) throw error; + return {schema_version: TODO_READ_RESULT, rejection: {phase: "succession", message: error.message}}; + } + } + const preferred = new Set(Array.isArray(input.preferred_todo_ids) ? input.preferred_todo_ids : []); + const rows = items.map((item, i) => summaryRow(item, decisions[i]!, preferred)); + const contexts = facts.map(row => row.context_fields!); + const compacts = items.map(compactRecord); + const projected = projectTodoSummary({schema_version: "todo_summary_projection_request_v3", columns: [...TODO_SUMMARY_PROJECTION_COLUMNS], + rows: rows.map(row => TODO_SUMMARY_PROJECTION_COLUMNS.map(name => row[name]!)), + succession: {schema_version: "todo_succession_request_v1", row_columns: [...SUCCESSION_FACT_COLUMNS], + rows: facts.map((row, i) => SUCCESSION_FACT_COLUMNS.map(name => name === "context_fields" ? i : row[name]!)), + context_field_sets: contexts, evaluation_columns: [...SUCCESSION_EVALUATION_COLUMNS], + evaluations: decisions.map(row => SUCCESSION_EVALUATION_COLUMNS.map(name => row[name]!))}, + frontier_rows: input.role === "agent" ? frontierFacts(items) : null, + observed_at: input.observed_at!, selection: input.selection ?? null, role: input.role ?? null, + source_section: input.source_section ?? null, item_limit: input.item_limit ?? null, full_selection: input.full_selection ?? true}); + const visible = [...new Set([...Object.values(projected.lanes).flatMap(lane => lane.format === "raw" ? [] : lane.indices), + ...Object.values(projected.orchestration).flat()])].sort((a, b) => a - b); + const handoffs = projectTodoHandoffContext(visible.map(index => handoffSource(compacts[index]!))); + visible.forEach((index, i) => {if (handoffs[i]!.note) compacts[index]!.handoff_note = handoffs[i]!.note;}); + const summary = projected.fields; + for (const [name, lane] of Object.entries(projected.lanes)) { + summary[name] = lane.indices.map(index => { + if (lane.format === "raw") return items[index]!; + const row = {...compacts[index]!}; + const removed = lane.format === "active" ? ["note", "evidence", "reason", "completed_at", "updated_at", "superseded_by"] : + lane.format === "recent" || lane.format === "gap" ? ["note", "evidence", "reason"] : []; + for (const key of removed) delete row[key]; + if (lane.format === "gap") Object.assign(row, {succession_tracked: true, recommended_action: object(summary.todo_succession_warning).recommended_action}); + return row; + }); + } + if (summary.todo_succession_warning) object(summary.todo_succession_warning).items = summary.completed_without_successor_items!; + const chosen = projected.source_indices.map(index => items[index]!); + const chosenDecisions = projected.source_indices.map(index => decisions[index]!); + const gates = handoffGates(chosen, chosenDecisions); + if (gates.length) summary.handoff_gates = gates; + return {schema_version: TODO_READ_RESULT, summary, items: chosen, evaluations: chosenDecisions, + visible_evaluations: projected.lanes.items!.indices.map(index => decisions[index]!), + orchestration: Object.fromEntries(Object.entries(projected.orchestration).map(([name, ordinals]) => [name, ordinals.map(i => compacts[i]!)]))}; +} + +export async function projectCanonicalTodoFields(input: JsonObject): Promise { + if (!Array.isArray(input.todos)) throw new EffectRuntimeRequestError("canonical Todo read source must be an array"); + const original = input.todos.map(value => requireJsonObject(value, "canonical Todo source")); + const decisions = original.filter(row => row.role === "user"); + const standing = decisions.length ? projectStandingDecisions(decisions, false) : null; + const all = input.presentation_ready === true ? original : canonicalTodoReadItems(original); + const archived = new Set(all.filter(row => row.archive_state === "archive").map(row => row.todo_id)); + const acceptance = object(input.goal_acceptance_contract), guards = object(input.goal_acceptance_work_guards); + const checksConfigured = configuredGoalChecks(acceptance) !== null; + const fields: JsonObject = {}, evaluations: JsonObject = {}; + for (const role of ["user", "agent"]) { + const section = role === "agent" ? "Agent Todo" : "User Todo / Owner Review Reading Queue"; + const items = all.filter(row => (row.role === "user" ? "user" : "agent") === role && (input.retained === true || !archived.has(row.todo_id))) + .map(row => structureCanonicalTodo({...row, + ...(input.retained !== true && checksConfigured && guards[text(row.todo_id)] ? {goal_acceptance_guard: guards[text(row.todo_id)]} : {})}, role, section, + input.retained === true ? null : 500, input.retained !== true)); + const lineage = Array.isArray(input.lineage) ? input.lineage.map(value => requireJsonObject(value, "Todo lineage")) : all; + const grouped = await projectTodoReadGroup({...input, items, lineage, evaluated: false, role, source_section: section, + item_limit: null, full_selection: true, resume_lineage: all.map(row => structureCanonicalTodo(row, row.role ?? null, + row.source_section ?? section))}); + if (grouped.rejection) return grouped; + const summary = requireJsonObject(grouped.summary, "canonical Todo summary"); + if (role === "agent" && input.retained !== true) { + if (checksConfigured || Object.keys(object(acceptance.outcome_requirements)).length) summary.goal_acceptance_contract = acceptance; + const done = all.filter(row => archived.has(row.todo_id) && row.done === true && taskClass(row) === "advancement_task").length; + if (done) {summary.archived_advancement_done_count = done; summary.advancement_done_count = Number(summary.advancement_done_count || 0) + done;} + } + fields[`${role}_todos`] = summary; + evaluations[`${role}_todos`] = grouped.visible_evaluations; + } + if (standing && input.retained !== true) fields.standing_decision_authority = standing; + return {schema_version: TODO_READ_RESULT, fields, field_evaluations: evaluations}; +} + +function emptySummary(role: string): JsonObject { + return {schema_version: "todo_summary_v0", role, source_section: role === "agent" ? "Agent Todo" : "User Todo / Owner Review Reading Queue", + total_count: 0, open_count: 0, done_count: 0, items: [], first_open_items: []}; +} + +export async function selectTodoReadFields(input: JsonObject): Promise { + const fields = requireJsonObject(input.fields, "Todo source fields"), evaluations = requireJsonObject(input.field_evaluations, "Todo source evaluations"); + if (!Array.isArray(input.roles) || input.roles.some(role => role !== "user" && role !== "agent")) throw new EffectRuntimeRequestError("Todo read roles must be user or agent"); + const summaries: JsonObject = {}, todos: unknown[] = []; + let unfiltered = 0, uncapped = 0; + for (const role of input.roles as string[]) { + const key = `${role}_todos`, raw = object(fields[key]), items = Array.isArray(raw.items) ? raw.items : []; + unfiltered += items.length; + let summary = emptySummary(role); + if (items.length) { + const projected = await projectTodoReadGroup({...input, items, lineage: [], lineage_facts: [], role, + source_section: raw.source_section || summary.source_section, evaluated: true, + evaluations: evaluations[key], item_limit: input.limit ?? null, full_selection: true, + selection: {role, status: input.status ?? null, todo_id: input.todo_id ?? null, agent_id: input.agent_id ?? null}}); + if (projected.rejection) return projected; + if ((projected.items as unknown[]).length) summary = requireJsonObject(projected.summary, "selected Todo summary"); + } + if (raw.goal_acceptance_contract && typeof raw.goal_acceptance_contract === "object") { + summary.goal_acceptance_contract = raw.goal_acceptance_contract; + } + if (input.limit != null) summary = compactTodoReadSummary(summary, role, Number(input.limit)); + summaries[key] = summary; + todos.push(...summary.items as unknown[]); + uncapped += Number(summary.total_count || 0); + } + return {schema_version: TODO_READ_RESULT, projection: {source: input.source_name!, summaries, todos, + unfiltered_count: unfiltered, uncapped_todo_count: uncapped}}; +} + +export async function projectSourceTodoFields(input: JsonObject): Promise { + if (!Array.isArray(input.groups)) throw new EffectRuntimeRequestError("Todo source requires role groups"); + let groups = input.groups; + const query = input.projection_query == null ? null : requireJsonObject(input.projection_query, "Todo projection query"); + if (query?.exact_archive === true) { + const target = (input.lineage as JsonObject[]).find(row => id(row.todo_id) === query.todo_id && row.archive_state === "archive"); + if (!target || !(query.roles as unknown[]).includes(target.role)) return {schema_version: TODO_READ_RESULT, projection: null}; + const group = groups.map(value => requireJsonObject(value, "Todo archive group")).find(row => row.role === target.role)!; + const item = (group.items as JsonObject[]).find(row => id(row.todo_id) === query.todo_id)!; + groups = [{...group, items: [{...item, source_section: String(target.source_section || "Completed Work Archive")}], + source_section: String(target.source_section || "Completed Work Archive"), include_empty_source: true}]; + } + const fields: JsonObject = {}, evaluations: JsonObject = {}, sources: JsonObject = {}, sourceEvaluations: JsonObject = {}, orchestration: JsonObject = {}; + for (const value of groups) { + const group = requireJsonObject(value, "Todo source role group"); + const projected = await projectTodoReadGroup({...input, ...group, evaluated: false}); + if (projected.rejection) return projected; + const items = projected.items as unknown[]; + if (!items.length && group.include_empty_source !== true) continue; + fields[`${String(group.role)}_todos`] = projected.summary; + evaluations[`${String(group.role)}_todos`] = projected.visible_evaluations; + if (input.include_full_source === true) { + sources[String(group.role)] = projected.items; + sourceEvaluations[String(group.role)] = projected.evaluations; + } + if (input.include_task_orchestration_authority === true) orchestration[String(group.role)] = projected.orchestration; + } + const agent = object(fields.agent_todos); + if (Object.keys(agent).length && Array.isArray(input.archived_items)) { + const count = input.archived_items.filter(value => { + const row = requireJsonObject(value, "archived Todo source"); + return row.done === true && taskClass({...row, text: compactTodoReadText(text(row.text))}) === "advancement_task"; + }).length; + if (count) {agent.archived_advancement_done_count = count; agent.advancement_done_count = Number(agent.advancement_done_count || 0) + count;} + } + if (Array.isArray(input.standing_items) && input.standing_items.length) { + const standing = projectStandingDecisions(input.standing_items.map(value => requireJsonObject(value, "standing decision source")), input.legacy_source_order === true); + if (standing) fields.standing_decision_authority = standing; + } + const result = {schema_version: TODO_READ_RESULT, fields, field_evaluations: evaluations, + ...(input.include_full_source === true ? {source_items: sources, source_evaluations: sourceEvaluations} : {}), + ...(input.include_task_orchestration_authority === true ? {source_orchestration: orchestration} : {})}; + return query === null ? result : selectTodoReadFields({...input, ...query, fields, field_evaluations: evaluations}); +} + +export async function readTodo(value: unknown, observedAt = new Date().toISOString()): Promise { + const input = requireJsonObject(value, "Todo read request"); + if (input.schema_version !== TODO_READ_REQUEST || !["group", "fields", "canonical_fields", "select", "source_fields", "query", "historical_fields"].includes(text(input.kind))) { + throw new EffectRuntimeRequestError("Todo read request schema mismatch"); + } + if (input.kind === "canonical_fields") { + const {readCanonicalTodoProjection} = await import("./read_transaction.ts"); + return readCanonicalTodoProjection({...input, read_observed_at: observedAt}); + } + if (input.kind === "query") { + const {readTodoQuery} = await import("./read_transaction.ts"); + return readTodoQuery({...input, read_observed_at: observedAt}); + } + if (input.kind === "historical_fields") { + const {readHistoricalTodoProjection} = await import("./read_transaction.ts"); + return readHistoricalTodoProjection(input); + } + if (input.kind === "select") return selectTodoReadFields(input); + if (input.kind === "source_fields") return projectSourceTodoFields(input); + return input.kind === "fields" ? projectCanonicalTodoFields(input) : projectTodoReadGroup(input); +} diff --git a/loopx/control_plane/todos/read_transaction.ts b/loopx/control_plane/todos/read_transaction.ts new file mode 100644 index 0000000000..e94ee8999f --- /dev/null +++ b/loopx/control_plane/todos/read_transaction.ts @@ -0,0 +1,171 @@ +/** Canonical provider IO and full-source summaries share one read transaction. */ +import type {JsonObject} from "../effect_program.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; +import {stat} from "node:fs/promises"; +import {legacyCoordinationWriterFencePath} from "../coordination/legacy_writer_fence.ts"; +import {requireLocalAuthorityRuntimeRoot} from "../coordination/local_authority_provider.ts"; +import {requireAuthorityStoreId} from "../coordination/authority_store_codec.ts"; +import {readCanonicalSnapshot} from "../coordination/canonical_snapshot_page.ts"; +import {readLocalCoordinationTodoSource} from "../coordination/local_authority_read.ts"; +import {LOCAL_COORDINATION_TODO_SNAPSHOT_PAGE_REQUEST_SCHEMA, + LOCAL_COORDINATION_TODO_LIST_RESULT_SCHEMA} from "../coordination/coordination_state_contract.generated.ts"; +import {projectCanonicalTodoFields, selectTodoReadFields, projectSourceTodoFields, projectTodoReadGroup, TODO_READ_RESULT} from "./read_projection.ts"; +import {canonicalTodoReadItems, structureCanonicalTodo} from "./read_codec.ts"; +import {compactTodoReadSummary, compactThinTodoRead, todoReadProjectionContract} from "./read_presentation.ts"; +import {projectTodoContextPage} from "./context_projection.ts"; +import {sealProjectionEnvelope, PROJECTION_ENVELOPE_SEAL_REQUEST} from "../projection_envelope.ts"; + +export async function readCanonicalTodoProjection(input: JsonObject): Promise { + const source = requireJsonObject(input.source, "canonical Todo read source"); + const todos: unknown[] = [], leases: unknown[] = [], guards: JsonObject = {}; + let after: unknown = null, first: JsonObject | null = null; + do { + const page = await readCanonicalSnapshot({schema_version: LOCAL_COORDINATION_TODO_SNAPSHOT_PAGE_REQUEST_SCHEMA, + runtime_root: source.runtime_root, goal_id: source.goal_id, include_leases: source.include_leases === true, + projection_readback: source.projection_readback ?? null, after}); + if (page.status !== "page") return {schema_version: TODO_READ_RESULT, canonical: { + ...page, schema_version: LOCAL_COORDINATION_TODO_LIST_RESULT_SCHEMA}}; + if (first === null) first = page; + todos.push(...page.todos as unknown[]); + if (Array.isArray(page.leases)) leases.push(...page.leases); + Object.assign(guards, page.goal_acceptance_work_guards ?? {}); + after = page.next; + } while (after !== null); + const snapshot = requireJsonObject(first!.snapshot, "canonical snapshot identity"); + const canonical: JsonObject = {schema_version: LOCAL_COORDINATION_TODO_LIST_RESULT_SCHEMA, + status: "loaded", ...requireJsonObject(first!.metadata, "canonical Todo metadata"), + todos, todo_ids: todos.map(value => requireJsonObject(value, "canonical Todo").todo_id), + store_identity: snapshot.store_identity, + ...(source.include_leases === true ? {leases} : {}), + source_authority: first!.source_authority, provider_revision: snapshot.provider_revision, + cursor: snapshot.cursor, decision_read_from_provider: true, legacy_fallback_used: false, + ...(Object.hasOwn(requireJsonObject(first!.metadata, "canonical metadata"), "goal_acceptance_contract") ? {goal_acceptance_work_guards: guards} : {})}; + const projected = await projectCanonicalTodoFields({...input, todos, retained: requireJsonObject(input.query ?? {}, "Todo query").read_scope === "completed_history", + goal_acceptance_contract: canonical.goal_acceptance_contract ?? null, + goal_acceptance_work_guards: guards}); + if (input.query == null || projected.rejection) return {...projected, canonical}; + return todoListPayload(input, originalRecords(canonical.todos), projected, canonical); +} + +const originalRecords = (value: unknown): JsonObject[] => Array.isArray(value) ? value.map(row => requireJsonObject(row, "Todo source row")) : []; + +export async function readHistoricalTodoProjection(input: JsonObject): Promise { + const source = requireJsonObject(input.source, "historical Todo read source"); + const canonical = await readLocalCoordinationTodoSource({schema_version: "loopx_local_coordination_todo_source_request_v0", + runtime_root: source.runtime_root, goal_id: source.goal_id, source: source.original_source}); + if (canonical.status !== "loaded") return {schema_version: TODO_READ_RESULT, canonical}; + return {...await projectCanonicalTodoFields({...input, todos: canonical.todos, + goal_acceptance_contract: canonical.goal_acceptance_contract ?? null, + goal_acceptance_work_guards: canonical.goal_acceptance_work_guards ?? null}), canonical}; +} + +export async function readTodoQuery(input: JsonObject): Promise { + const source = requireJsonObject(input.source, "Todo read source"); + const root = requireLocalAuthorityRuntimeRoot(source.runtime_root), goal = requireAuthorityStoreId(source.goal_id, "goal id"); + let promoted = false; + try { + await stat(legacyCoordinationWriterFencePath(root, goal)); + promoted = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") return {schema_version: TODO_READ_RESULT, canonical: { + status: "failed", reason_code: "local_authority_mode_read_failed", reason: "local coordination authority mode cannot be inspected", + source_authority: "unknown_fail_closed"}}; + } + if (promoted) return readCanonicalTodoProjection(input); + if (input.legacy_source == null) return {schema_version: TODO_READ_RESULT, source_request: "legacy_todo_source"}; + const legacy = requireJsonObject(input.legacy_source, "legacy Todo source"); + const projected = await projectSourceTodoFields({...input, ...legacy}); + if (projected.rejection) return projected; + return todoListPayload(input, originalRecords(legacy.full_bodies), projected, null); +} + +async function todoListPayload(input: JsonObject, original: JsonObject[], fields: JsonObject, canonical: JsonObject | null): Promise { + const query = requireJsonObject(input.query, "Todo list query"); + const sourceName = canonical === null ? "markdown_active_state" : "file_authority"; + const roles: unknown[] = query.role == null ? ["user", "agent"] : [query.role]; + const select = async (source: JsonObject) => selectTodoReadFields({...input, fields: source.fields, + field_evaluations: source.field_evaluations, roles, status: query.status ?? null, + todo_id: query.todo_id ?? null, agent_id: query.agent_id ?? null, limit: query.limit ?? null, source_name: sourceName}); + let selected = await select(fields); + if (selected.rejection) return selected; + let projection = requireJsonObject(selected.projection, "Todo read projection"); + if (query.read_scope !== "completed_history" && query.todo_id && !(projection.todos as unknown[]).length) { + const legacy = requireJsonObject(input.legacy_source ?? {}, "legacy Todo source"); + const archived = canonical === null ? originalRecords(legacy.archived_items_for_query) : canonicalTodoReadItems(original).filter(row => row.archive_state === "archive"); + const target = archived.find(row => row.todo_id === query.todo_id); + if (target && roles.includes(target.role)) { + const section = target.source_section || "Completed Work Archive"; + const item = canonical === null ? target : structureCanonicalTodo(target, target.role, section); + const grouped = await projectTodoReadGroup({...input, items: [item], role: item.role, source_section: section, + lineage: archived, resume_lineage: archived.map(row => canonical === null ? row : structureCanonicalTodo(row, row.role ?? null, row.source_section ?? section)), + evaluated: false, item_limit: null, full_selection: true, + ...(canonical === null && Array.isArray(legacy.archived_handoffs) ? {handoff_sources: [legacy.archived_handoffs[archived.indexOf(target)]]} : {})}); + if (grouped.rejection) return grouped; + const key = `${String(target.role)}_todos`, exact = {fields: {[key]: grouped.summary}, field_evaluations: {[key]: grouped.visible_evaluations}}; + selected = await select(exact); + if (selected.rejection) return selected; + projection = requireJsonObject(selected.projection, "archived Todo projection"); + } + } + let todos = projection.todos as JsonObject[]; + if (query.todo_id && todos.length === 1) { + const item = todos[0]!; + const matching = original.filter(row => row.todo_id === query.todo_id && row.role === item.role && + (row.archive_state ?? "active") === (item.archive_state ?? "active")); + if (matching.length !== 1) throw new Error("Todo detail cannot recover an unambiguous full source body"); + item.text = String(matching[0]!.text ?? ""); + } + const matchedCount = todos.length; + const hot = query.read_scope !== "completed_history" && query.agent_id && query.limit == null && + query.role == null && query.status == null && query.todo_id == null; + if (hot) { + const summaries = requireJsonObject(projection.summaries, "Todo summaries"); + for (const [key, summary] of Object.entries(summaries)) summaries[key] = compactTodoReadSummary( + requireJsonObject(summary, "Todo summary"), key.replace(/_todos$/, ""), null); + todos = Object.values(summaries).flatMap(value => requireJsonObject(value, "Todo summary").items as JsonObject[]); + } + const payload: JsonObject = {ok: true, dry_run: true, read_only: true, command: "list", goal_id: query.goal_id, + role: query.role ?? "all", status_filter: query.status ?? null, source: sourceName, todo_count: matchedCount, + todos, state_file: query.state_file, project: query.project ?? null, + ...(canonical === null ? {} : {authority_read: {source_authority: canonical.source_authority, provider_revision: canonical.provider_revision, + cursor: canonical.cursor, todo_read_model: canonical.todo_read_model, + decision_read_from_provider: true, legacy_fallback_used: false}})}; + if (query.agent_id) Object.assign(payload, {agent_id_filter: query.agent_id, unfiltered_todo_count: projection.unfiltered_count, + filter_semantics: "agent todos include unclaimed items plus claimed_by=; " + + "User gates use global_gate, then blocks_agent, then legacy claimed_by scope; " + + "User actions use bound_agent, then legacy claimed_by scope; unscoped items remain visible"}); + if (hot) Object.assign(payload, {returned_todo_count: todos.length, + todo_list_projection: todoReadProjectionContract(matchedCount, todos.length, null)}); + if (query.limit != null) Object.assign(payload, {explicit_limit: query.limit, + unfiltered_todo_count: projection.unfiltered_count, returned_todo_count: todos.length, + todo_list_projection: todoReadProjectionContract(Number(projection.uncapped_todo_count), todos.length, Number(query.limit))}); + if (query.todo_id) { + const item = todos[0], relations: JsonObject = {}; + const relationFields = ["claimed_by", "bound_agent", "goal_bound", "blocks_agent", "excluded_agents", "global_gate", "unblocks_todo_id", + "successor_todo_ids", "completion_continuation", "completion_recovery", "superseded_by", "resume_when", "resume_monitor_generation", + "resume_condition", "resume_ready", "decision_scope", "required_decision_scopes", "required_write_scopes", "required_capabilities", + "target_capabilities", "task_class", "action_kind", "continuation_policy", "target_key", "cadence", "next_due_at", "expires_at"]; + if (item) for (const key of relationFields) if (item[key] != null && (!Array.isArray(item[key]) || (item[key] as unknown[]).length)) relations[key] = item[key]; + Object.assign(payload, {todo_id_filter: query.todo_id, matched: todos.length > 0, relations, + ...(todos.length > 1 ? {ambiguous: true} : {}), ...(todos.length === 0 ? {not_found: true} : {})}); + } + Object.assign(payload, projection.summaries); + // The existing formatter is composed here, so a public read never calls back + // across runtimes for fields of the result it just received. + const result = query.todo_id ? projectTodoContextPage({detail_payload: payload}) : query.thin ? compactThinTodoRead(payload) : + query.json_references ? projectTodoContextPage({list_payload: payload}) : payload; + const shownCount = query.todo_id ? Number(result.todo !== null) : (result.todos as JsonObject[]).length; + const observed = String(input.read_observed_at); + result.projection_envelope = sealProjectionEnvelope({schema_version: PROJECTION_ENVELOPE_SEAL_REQUEST, + projection: "goal_todo_read", observed_at: observed, + sources: [{source_id: sourceName, read_status: "read", last_read_at: observed, + item_count: canonical === null ? Number(requireJsonObject(input.legacy_source, "legacy source").source_count) : original.length}], + coverage: {scope: "goal_todo_query", expected_count: projection.uncapped_todo_count, + included_count: projection.uncapped_todo_count, shown_count: shownCount, + available_count: projection.uncapped_todo_count, omitted: []}}); + return {schema_version: TODO_READ_RESULT, payload: result, + ...(canonical?.goal_acceptance_oracle_observation_basis ? {canonical_acceptance: { + goal_acceptance_contract: canonical.goal_acceptance_contract, + goal_acceptance_oracle_observation_basis: canonical.goal_acceptance_oracle_observation_basis, + }} : {})}; +} diff --git a/loopx/control_plane/todos/read_transport.ts b/loopx/control_plane/todos/read_transport.ts new file mode 100644 index 0000000000..1c8eff700c --- /dev/null +++ b/loopx/control_plane/todos/read_transport.ts @@ -0,0 +1,65 @@ +/** Python transport only; native callers consume readTodo in process. */ +import {createHash} from "node:crypto"; +import {readTodo, TODO_READ_REQUEST, TODO_READ_RESULT} from "./read_projection.ts"; +import type {JsonObject} from "../effect_program.ts"; +import {MAX_LOCAL_SNAPSHOT_BYTES, openPrivateSnapshotSource} from "../effect_runtime_snapshot.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; +import {BARE_SHA256_PATTERN} from "../content_digest.ts"; + +const completeBytes = MAX_LOCAL_SNAPSHOT_BYTES - 2 * 1024 * 1024; +const pieceBytes = Math.floor(completeBytes / 4) * 3; + +async function sourceFacts(value: unknown): Promise { + const ref = requireJsonObject(value, "Todo fact source reference"); + const digest = requireNonEmptyString(ref.sha256, "Todo fact source digest"), size = ref.byte_count; + if (!BARE_SHA256_PATTERN.test(digest) || typeof size !== "number" || !Number.isSafeInteger(size) || size <= 0) { + throw new EffectRuntimeRequestError("invalid complete Todo fact source"); + } + const file = await openPrivateSnapshotSource(ref.path); + try { + const before = await file.stat(); + if (before.size !== size) throw new Error("Todo source size changed"); + const bytes = await file.readFile(), after = await file.stat(); + if (bytes.length !== size || after.size !== size || before.ino !== after.ino || before.dev !== after.dev || + createHash("sha256").update(bytes).digest("hex") !== digest) throw new Error("Todo source identity changed"); + return requireJsonObject(JSON.parse(new TextDecoder("utf-8", {fatal: true, ignoreBOM: true}).decode(bytes)), "Todo source facts"); + } finally {await file.close();} +} + +export async function readTodoRuntime(value: unknown): Promise { + const request = requireJsonObject(value, "Todo read request"); + if (request.schema_version !== TODO_READ_REQUEST) throw new EffectRuntimeRequestError("Todo read request schema mismatch"); + let input = request; + if (request.facts_ref !== undefined) { + try {input = await sourceFacts(request.facts_ref);} + catch {throw new EffectRuntimeRequestError("complete Todo fact source unavailable or invalid; repeat the original read");} + if (input.facts_ref !== undefined || input.schema_version !== TODO_READ_REQUEST) throw new EffectRuntimeRequestError("invalid Todo fact source envelope"); + } + const page = request.result_page == null ? null : requireJsonObject(request.result_page, "Todo result page"); + const observed = page === null ? new Date().toISOString() : requireNonEmptyString(page.observed_at, "original read observation"); + const clock = Date.parse(observed); + if (!Number.isFinite(clock) || clock > Date.now()) throw new EffectRuntimeRequestError("invalid original Todo read observation"); + let result = await readTodo(input, observed); + if (result.rejection || result.source_request || (result.canonical && !result.fields && !result.payload)) return result; + if (input.kind === "group") { + const {items, evaluations, orchestration, ...projection} = result; + result = {...projection, selected_count: (items as unknown[]).length, + ...(input.include_full_source === true ? {items, evaluations} : {}), + ...(input.include_task_orchestration_authority === true ? {orchestration} : {})}; + } + const bytes = Buffer.from(JSON.stringify(result), "utf8"); + if (page === null && bytes.length <= completeBytes) return result; + const digest = createHash("sha256").update(bytes).digest("hex"); + const offset = page === null ? 0 : page.offset; + if (typeof offset !== "number" || !Number.isSafeInteger(offset) || offset < 0 || offset >= bytes.length || offset % pieceBytes !== 0 || + (page !== null && page.sha256 !== digest)) { + return {schema_version: TODO_READ_RESULT, canonical: {status: "failed", reason_code: "canonical_snapshot_changed", + reason: "Todo read result changed; restart the complete read, never append a newer page", + source_authority: "unknown_fail_closed", decision_read_from_provider: false, legacy_fallback_used: false}}; + } + const end = Math.min(bytes.length, offset + pieceBytes); + return {schema_version: TODO_READ_RESULT, result_piece: {offset, byte_count: bytes.length, sha256: digest, + data: bytes.subarray(offset, end).toString("base64"), + next: end === bytes.length ? null : {offset: end, sha256: digest, observed_at: observed}}}; +} diff --git a/loopx/control_plane/todos/standing_decision.py b/loopx/control_plane/todos/standing_decision.py index efae270f74..4d163fb1c0 100644 --- a/loopx/control_plane/todos/standing_decision.py +++ b/loopx/control_plane/todos/standing_decision.py @@ -26,6 +26,26 @@ def build_standing_decision_authority( Canonical IDs obey their provider contract, not the Markdown token codec. Both sources use the same typed eligibility and chronology owner. """ + items = standing_decision_source_items(user_items, canonical_records=canonical_records) + if not items: + return None + result = effect_runtime_result( + "todo.standing_decision.project", + { + "schema_version": "standing_decision_projection_request_v0", + "items": items, + "legacy_source_order": legacy_source_order and not canonical_records, + }, + ) + if result is not None and ( + not isinstance(result, dict) + or result.get("schema_version") != "standing_decision_authority_v0" + ): + raise ValueError("invalid typed standing decision projection") + return result + + +def standing_decision_source_items(user_items, *, canonical_records=False): items = [] for item in user_items or []: if not isinstance(item, dict): @@ -85,19 +105,4 @@ def build_standing_decision_authority( "unblocks_todo_id": item.get("unblocks_todo_id"), } ) - if not items: - return None - result = effect_runtime_result( - "todo.standing_decision.project", - { - "schema_version": "standing_decision_projection_request_v0", - "items": items, - "legacy_source_order": legacy_source_order and not canonical_records, - }, - ) - if result is not None and ( - not isinstance(result, dict) - or result.get("schema_version") != "standing_decision_authority_v0" - ): - raise ValueError("invalid typed standing decision projection") - return result + return items diff --git a/loopx/control_plane/todos/summary_projection.ts b/loopx/control_plane/todos/summary_projection.ts index c853825f06..033cf0f034 100644 --- a/loopx/control_plane/todos/summary_projection.ts +++ b/loopx/control_plane/todos/summary_projection.ts @@ -1,5 +1,5 @@ import {projectTodoHandoffContext} from "./handoff_projection.ts"; -/** Whole-source summary decisions. Python materializes these ordinals as public +/** Whole-source summary decisions. The native read owner materializes public * display records; counts and closure never depend on a presentation budget. */ import type {JsonObject} from "../effect_program.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; diff --git a/loopx/control_plane/todos/todo_summary.py b/loopx/control_plane/todos/todo_summary.py index fe8acdd63b..648064a2e7 100644 --- a/loopx/control_plane/todos/todo_summary.py +++ b/loopx/control_plane/todos/todo_summary.py @@ -2,7 +2,7 @@ from datetime import datetime import re -from typing import Any, Callable, Optional, TypeGuard +from typing import Any, Callable, Optional from ..goals.goal_vision_wait_projection import attach_active_vision_waits from .contract import ( @@ -39,14 +39,11 @@ ) from .summary_item import todo_text_content_revision from .completion_validation_projection import project_completion_validation_authority -from .frontier_revision import frontier_source_facts, TODO_FRONTIER_REVISION_INDEX_SCHEMA_VERSION -from .handoff_gate import build_todo_handoff_gate_states -from .handoff_note import attach_todo_handoff_notes, handoff_context_source, validate_handoff_context +from .handoff_note import attach_todo_handoff_notes from .todo_semantics import ( todo_item_is_actionable_open as projection_todo_item_is_actionable_open, todo_item_is_deferred as projection_todo_item_is_deferred, todo_item_is_due_monitor as projection_todo_item_is_due_monitor, - todo_item_is_watch_only_monitor as projection_todo_item_is_watch_only_monitor, todo_item_missing_monitor_schedule as projection_todo_item_missing_monitor_schedule, todo_item_task_class as projection_todo_item_task_class, todo_item_next_due_at as projection_todo_item_next_due_at, @@ -56,8 +53,6 @@ todo_presentation_sort_key as projection_todo_presentation_sort_key, todo_projection_sort_key as projection_todo_projection_sort_key, ) -from .resume_condition import evaluate_todo_resume_conditions -from ..runtime.time import now_utc, now_utc_iso from ..work_items.project_asset import build_project_asset_todo_summary from .user_gate import open_user_gate_todo_items from ..coordination.coordination_state_contract import ( @@ -81,18 +76,6 @@ TASK_ORCHESTRATION_AUTHORITY_SCHEMA_VERSION = "task_orchestration_authority_v0" TODO_ARCHIVE_STATE_ACTIVE = "active" -# One internal batch carries the whole source, so the adapter sends columnar -# facts: repeating every key name per Todo pushed a long-history request past the -# effect-runtime request budget. The typed owner decodes the declared columns -# back into row objects before validating them, so no cell changes meaning. -SUMMARY_PROJECTION_REQUEST_SCHEMA_VERSION = "todo_summary_projection_request_v3" -SUMMARY_PROJECTION_COLUMNS = ( - "status", "done", "task_class", "has_resume", "resume_ready", "resume_evaluated", - "acceptance_blocked", "claimed", "preferred", "watch_only", "due_at", "expires_at", - "sort", "completed_at", "updated_at", "completion_index", "linked_user_action", - "no_followup", "successor_gap", "handoff_state", "replan", "todo_id", "claim", - "bound", "blocks", "global", "excluded", -) AttentionItemBuilder = Callable[..., dict[str, Any]] GoalLifecycleFields = Callable[[dict[str, Any], Optional[dict[str, Any]]], dict[str, Any]] PublicSafeText = Callable[..., Optional[str]] @@ -736,52 +719,6 @@ def attach_dependency_blockers( item["dependency_blockers"] = blockers -def _apply_resume_conditions( - items: list[dict[str, Any]], - *, - source_section: str | None, - resume_source_items: list[dict[str, Any]] | None = None, - rollout_events: list[dict[str, Any]] | None = None, - available_capabilities: Any = None, - evaluated_at: str | None = None, -) -> None: - resume_items = [ - item - for item in items - if normalize_todo_resume_when(item.get("resume_when")) - ] - if not resume_items: - return - # Only prepare history when at least one item needs the typed evaluator. - # Succession still receives the complete original lineage independently. - source_items = [ - *_structured_resume_source_items( - resume_source_items, source_section=source_section, - ), - *items, - ] - conditions = evaluate_todo_resume_conditions( - resume_items, - source_items=source_items, - rollout_events=rollout_events, - available_capabilities=available_capabilities, - evaluated_at=evaluated_at or now_utc_iso(), - ) - for item in items: - resume_when = normalize_todo_resume_when(item.get("resume_when")) - if not resume_when: - continue - todo_id = normalize_todo_id(item.get("todo_id")) - condition = conditions.get(todo_id or "") - if condition is None: - condition = { - "schema_version": "todo_resume_condition_v0", - "resume_when": resume_when, - "satisfied": False, - "unsupported": True, - } - item["resume_condition"] = condition - item["resume_ready"] = bool(condition.get("satisfied")) def active_next_action_todo_ids(value: Any) -> set[str]: @@ -851,177 +788,10 @@ def _structured_resume_source_items( ] -def _resume_condition_evaluated(item: dict[str, Any], resume: str | None) -> bool: - """The source's own full-source resume evaluation for this condition.""" - condition = item.get("resume_condition") - return (isinstance(condition, dict) - and condition.get("schema_version") == "todo_resume_condition_v0" - and condition.get("resume_when") == resume - and isinstance(condition.get("satisfied"), bool) - and item.get("resume_ready") is condition.get("satisfied")) - -def _materialize_summary_lanes(items: list[dict[str, Any]], compacts: list[dict[str, Any]], - result: dict[str, Any], summary: dict[str, Any], lanes: dict[str, Any], - orchestration: dict[str, list[int]], *, fused_context: bool, -) -> None: - """Materialize validated ordinals with the existing display field allowlist.""" - if fused_context: - contexts = validate_handoff_context(result.get("handoff_context"), len(items)) - for compact, context in zip(compacts, contexts, strict=True): - if context["note"]: - compact["handoff_note"] = context["note"] - else: - # Counts/closure still use every source row in their existing batch. - # The independent display lens only needs the ordinals that are used. - visible = sorted({index for lane in lanes.values() if lane["format"] != "raw" for index in lane["indices"]} - | {index for indices in orchestration.values() for index in indices}) - attach_todo_handoff_notes([compacts[index] for index in visible]) - warning = summary.get("todo_succession_warning") - warning_action = warning.get("recommended_action") if isinstance(warning, dict) else None - if summary.get("completed_without_successor_count") and not isinstance(warning_action, str): - raise ValueError("invalid typed Todo succession warning") - for name, lane in lanes.items(): - mode = lane.get("format") - if mode not in {"raw", "active", "compact", "recent", "gap"}: - raise ValueError("invalid Todo summary display format") - formatted = [] - for index in lane["indices"]: - item = items[index] - if mode == "raw": - compact = item - elif mode == "active": - compact = dict(compacts[index]) - for key in ("note", "evidence", "reason", "completed_at", "updated_at", "superseded_by"): - compact.pop(key, None) - elif mode in {"compact", "recent", "gap"}: - compact = dict(compacts[index]) - if mode in {"recent", "gap"}: - for key in ("note", "evidence", "reason"): - compact.pop(key, None) - if mode == "gap": - compact.update(succession_tracked=True, - recommended_action=warning_action) - else: - raise ValueError("invalid Todo summary display format") - formatted.append(compact) - summary[name] = formatted - if isinstance(warning, dict): - warning["items"] = summary["completed_without_successor_items"] - - -def _project_summary(items: list[dict[str, Any]], preferred_todo_ids: set[str] | None, - *, selection: dict[str, Any] | None, role: str | None, source_section: str | None, - item_limit: int | None, full_selection: bool, -) -> dict[str, Any]: - """Adapt evaluated facts and materialize one typed summary decision.""" - import json - from ..effect_runtime import MAX_REQUEST_BYTES, EffectRuntimeRejected, effect_runtime_result - from .succession_warning import succession_evaluations, succession_request - # Display may never invent the source's resume decision. Assert the - # full-source precondition before any RPC that reuses the evaluation, so an - # unevaluated source fails with its own diagnostic instead of a downstream - # "succession evaluation must be an object" from a later owner. - for item in items: - resume = normalize_todo_resume_when(item.get("resume_when")) - if resume and not _resume_condition_evaluated(item, resume): - raise ValueError("Todo display requires a matching full-source resume evaluation") - # Evidence is merely carried here. The shared TS summary owner validates - # the same full-source hashes/semantics before using any summary fact, - # avoiding a separate RPC for every role/filter (including empty roles). - succession = succession_evaluations(items) - rows = [] - for item, evaluation in zip(items, succession, strict=True): - resume = normalize_todo_resume_when(item.get("resume_when")) - evaluated = _resume_condition_evaluated(item, resume) - due = projection_todo_item_next_due_at(item) - expires = projection_todo_item_expires_at(item) - guard = item.get("goal_acceptance_guard") - rows.append({"status": item.get("status") or ("done" if item.get("done") else "open"), - "done": bool(item.get("done")), "task_class": projection_todo_item_task_class(item), - "has_resume": bool(resume), "resume_ready": item.get("resume_ready"), - "resume_evaluated": evaluated, "acceptance_blocked": isinstance(guard, dict) and guard.get("allowed") is False, - "claimed": bool(item.get("claimed_by")), "preferred": item.get("todo_id") in (preferred_todo_ids or set()), - "watch_only": projection_todo_item_is_watch_only_monitor(item), - "due_at": due.timestamp() if due else None, "expires_at": expires.timestamp() if expires else None, - "sort": list(projection_todo_presentation_sort_key(item)), - "completed_at": str(item.get("completed_at") or "") or None, - "updated_at": str(item.get("updated_at") or "") or None, - "completion_index": int(item.get("index") or 0), - "linked_user_action": bool(normalize_todo_id(item.get("unblocks_todo_id"))), - "no_followup": normalize_todo_no_followup(item.get("no_followup")) is True, - "successor_gap": evaluation["successor_gap"], "handoff_state": evaluation["handoff_state"], - "replan": evaluation["route_continuation_replan_required"], - **{"todo_id": normalize_todo_id(item.get("todo_id")), - "claim": normalize_todo_claimed_by(item.get("claimed_by")), - "bound": normalize_todo_bound_agent(item.get("bound_agent")), - "blocks": normalize_todo_blocks_agent(item.get("blocks_agent")), - "global": bool(item.get("global_gate")), - "excluded": normalize_todo_excluded_agents(item.get("excluded_agents"))}}) - compacts = [_compact_todo_item_record(item) for item in items] - contexts = [handoff_context_source(item) for item in compacts] - for context in contexts: - for key in ("todo_id", "claimed_by", "excluded_agents"): - context.get("metadata", {}).pop(key, None) - context.get("texts", {}).pop("task_class", None) - if not context.get("metadata"): - context.pop("metadata", None) - try: - request = { - "schema_version": SUMMARY_PROJECTION_REQUEST_SCHEMA_VERSION, - "handoff_sources": contexts, - "columns": list(SUMMARY_PROJECTION_COLUMNS), - "rows": [[row[name] for name in SUMMARY_PROJECTION_COLUMNS] for row in rows], - "succession": succession_request(items, reuse=True), - "frontier_rows": frontier_source_facts(items) if role == "agent" else None, - "observed_at": now_utc().timestamp(), - "selection": selection, "role": role, "source_section": source_section, - "item_limit": item_limit, "full_selection": full_selection, - } - fused_context = len(json.dumps(request, separators=(",", ":")).encode()) <= MAX_REQUEST_BYTES - 4096 - if not fused_context: - request.pop("handoff_sources") - result = effect_runtime_result("todo.summary.project", request) - except EffectRuntimeRejected as error: - raise ValueError(str(error)) from error - if not isinstance(result, dict) or result.get("schema_version") != "todo_summary_projection_v0": - raise ValueError("invalid typed Todo summary projection") - - def valid_ordinals(value: Any) -> TypeGuard[list[int]]: - return (isinstance(value, list) - and all(type(index) is int and 0 <= index < len(items) for index in value) - and len(set(value)) == len(value)) - - selected = result.get("source_indices") - if not valid_ordinals(selected): - raise ValueError("invalid typed Todo selection ordinals") - if type(result.get("full_selection")) is not bool: - raise ValueError("invalid typed Todo selection ordinals") - selected_set = set(selected) - lanes, orchestration = result.get("lanes"), result.get("orchestration") - if not isinstance(lanes, dict) or not isinstance(orchestration, dict): - raise ValueError("invalid typed Todo summary lanes") - for indices in [*(lane.get("indices") if isinstance(lane, dict) else None for lane in lanes.values()), - *orchestration.values()]: - if not valid_ordinals(indices): - raise ValueError("invalid Todo summary source ordinal") - if not set(indices) <= selected_set: - raise ValueError("Todo summary lane escaped the selected source") - summary = result.get("fields") - if not isinstance(summary, dict) or summary.get("schema_version") != "todo_summary_v0": - raise ValueError("invalid typed Todo summary fields") - if role == "agent": - index = summary.get("advancement_frontier_revision_index") - if not isinstance(index, dict) or index.get("schema_version") != TODO_FRONTIER_REVISION_INDEX_SCHEMA_VERSION: - raise ValueError("invalid typed Todo summary frontier index") - _materialize_summary_lanes(items, compacts, result, summary, lanes, orchestration, - fused_context=fused_context) - return {"summary": summary, "items": [items[index] for index in selected], - "succession": [succession[index] for index in selected], - "orchestration": {name: [compacts[index] for index in indices] for name, indices in orchestration.items()}} def compact_todo_group( @@ -1041,32 +811,30 @@ def compact_todo_group( vision_runs: list[dict[str, Any]] | None = None, evaluated_at: str | None = None, ) -> dict[str, Any] | None: + from .goal_todo_projection import project_todo_read_group + if not items and not include_empty_source: return None - items = _structured_todo_group_items( - items, - source_section=source_section, - role=role, - text_limit=text_limit, - include_content_revision=include_content_revision, - ) - _apply_resume_conditions( - items, - source_section=source_section, - resume_source_items=resume_source_items, - rollout_events=rollout_events, - available_capabilities=available_capabilities, - evaluated_at=evaluated_at, - ) - from .succession_warning import evaluate_succession - - evaluate_succession(items, resume_source_items) - return compact_evaluated_todo_group( - items, source_section=source_section, role=role, - include_empty_source=include_empty_source, preferred_todo_ids=preferred_todo_ids, - item_limit=item_limit, include_task_orchestration_authority=include_task_orchestration_authority, - vision_runs=vision_runs, lineage_items=resume_source_items, - ) + items = _structured_todo_group_items(items, source_section=source_section, role=role, + text_limit=text_limit, include_content_revision=include_content_revision) + resume_lineage = (_structured_resume_source_items(resume_source_items, source_section=source_section) + if any(normalize_todo_resume_when(item.get("resume_when")) for item in items) else None) + projected = project_todo_read_group(items, source_section=source_section, role=role, + preferred_todo_ids=preferred_todo_ids, lineage=resume_source_items, + resume_lineage=resume_lineage, item_limit=item_limit, + rollout_events=rollout_events, available_capabilities=available_capabilities, + evaluated_at=evaluated_at, include_full_source=bool(vision_runs), + include_task_orchestration_authority=include_task_orchestration_authority) + items = projected.get("items", projected["summary"]["items"]) + if not projected["selected_count"] and not include_empty_source: + return None + summary = projected["summary"] + attach_active_vision_waits(summary, vision_runs, role=role, items=items, + lineage_items=resume_source_items) + if include_task_orchestration_authority: + summary["task_orchestration_authority"] = _task_orchestration_authority( + projected["orchestration"], role=role) + return summary @@ -1084,27 +852,22 @@ def compact_evaluated_todo_group( full_selection: bool = True, selection: dict[str, Any] | None = None, ) -> dict[str, Any] | None: - """Filter/display an already evaluated snapshot, never re-evaluate topology. + from .goal_todo_projection import project_todo_read_group - Callers must come from the full-source parser or canonical summary, not - persisted derived fields. Missing or mismatched evaluations fail closed. - """ if not items and not include_empty_source: return None - projected = _project_summary(items, preferred_todo_ids, selection=selection, - role=role, source_section=source_section, item_limit=item_limit, full_selection=full_selection) - items = projected["items"] - if not items and not include_empty_source: + lineage = lineage_items + projected = project_todo_read_group(items, source_section=source_section, role=role, + preferred_todo_ids=preferred_todo_ids, lineage=lineage, item_limit=item_limit, + evaluated=True, selection=selection, full_selection=full_selection, + include_full_source=bool(vision_runs), include_task_orchestration_authority=include_task_orchestration_authority) + items = projected.get("items", projected["summary"]["items"]) + if not projected["selected_count"] and not include_empty_source: return None - summary: dict[str, Any] = projected["summary"] - handoff_gates = build_todo_handoff_gate_states(items, evaluations=projected["succession"]) - attach_active_vision_waits( - summary, vision_runs, role=role, items=items, - lineage_items=lineage_items, - ) + summary = projected["summary"] + attach_active_vision_waits(summary, vision_runs, role=role, items=items, + lineage_items=lineage_items) if include_task_orchestration_authority: summary["task_orchestration_authority"] = _task_orchestration_authority( projected["orchestration"], role=role) - if handoff_gates: - summary["handoff_gates"] = handoff_gates return summary diff --git a/loopx/control_plane/work_items/refresh_recommendation.py b/loopx/control_plane/work_items/refresh_recommendation.py index df8be91e66..df98b10e23 100644 --- a/loopx/control_plane/work_items/refresh_recommendation.py +++ b/loopx/control_plane/work_items/refresh_recommendation.py @@ -6,7 +6,7 @@ from typing import Any from ..agents.agent_lane_recommendation import build_agent_lane_next_action -from ..coordination.local_authority import read_canonical_todos_if_promoted, canonical_todo_summary_fields +from ..coordination.local_authority import read_canonical_todos_if_promoted from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result from ..todos.active_state_todo_parser import parse_active_state_todos from ..todos.contract import normalize_todo_id @@ -49,22 +49,20 @@ def load_refresh_planning_source( that task snapshot. Provider failures propagate rather than using prose. """ events = load_rollout_events(rollout_event_log_path(runtime_root, goal_id)) - canonical = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=goal_id) + canonical = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=goal_id, + include_summary=True, rollout_events=events) if canonical is not None: - from ..goals.acceptance import observe_canonical_goal_acceptance + from ..todos.goal_todo_projection import observe_todo_acceptance oracle_registry = registry_path if canonical.get("goal_acceptance_oracle_observation_basis") is not None and oracle_registry is not None: from .recommendation_source_io import load_recommendation_source_goal # A shared roster's checkout is display data, including on refresh # paths without --next-action. Observe the current source workspace. oracle_registry, _ = load_recommendation_source_goal(oracle_registry, goal_id) - acceptance = observe_canonical_goal_acceptance(canonical, + fields = observe_todo_acceptance(canonical["_todo_fields"], canonical, registry_path=oracle_registry, goal_id=goal_id) - fields = canonical_todo_summary_fields( - canonical["todos"], rollout_events=events, - goal_acceptance_contract=acceptance, - goal_acceptance_work_guards=canonical.get("goal_acceptance_work_guards"), - ) if canonical is not None else None + else: + fields = None try: text = state_path.read_text(encoding="utf-8") except FileNotFoundError: diff --git a/tests/control_plane/canonical_authority_fixture.py b/tests/control_plane/canonical_authority_fixture.py index f2deeb13d8..9b3d3e396e 100644 --- a/tests/control_plane/canonical_authority_fixture.py +++ b/tests/control_plane/canonical_authority_fixture.py @@ -12,6 +12,15 @@ import tempfile +def assert_todo_read_unchanged(current, previous): + """Compare durable/read content while checking the separate observation scope.""" + assert current["projection_envelope"]["schema_version"] == "loopx_projection_envelope_v0" + if "projection_envelope" in previous: + assert current["projection_envelope"]["coverage"] == previous["projection_envelope"]["coverage"] + assert {key: value for key, value in current.items() if key != "projection_envelope"} == { + key: value for key, value in previous.items() if key != "projection_envelope"} + + def initialize_canonical_authority(runtime_root: Path, goal_id: str, projection: dict, *, state_path: Path, provider: str = "file") -> dict: repository = Path(__file__).resolve().parents[2] module = repository / "loopx/control_plane/coordination/file_authority_store.ts" diff --git a/tests/control_plane/test_cli_output_differential.py b/tests/control_plane/test_cli_output_differential.py index d97bd723ff..605290e96c 100644 --- a/tests/control_plane/test_cli_output_differential.py +++ b/tests/control_plane/test_cli_output_differential.py @@ -1391,6 +1391,23 @@ def test_projection_envelope_migration_is_bounded_and_one_time( assert not compare_cli_output_receipts(_receipt(outside_base), _receipt(outside))["ok"] +@pytest.mark.parametrize("row_prefix", [ + "surface/todo_list", "variant/todo_list_limited", "variant/todo_list_thin", +]) +def test_todo_envelope_migration_retains_a_bounded_one_time_allowance(row_prefix): + base = _row(row_id=f"{row_prefix}/small/json", surface_id="todo_list") + candidate = {**base, + "projection_envelope_schema_versions": ["loopx_projection_envelope_v0"], + **{metric: base[metric] + 896 for metric in ("chars", "utf8_bytes", "compact_payload_chars")}} + assert compare_cli_output_receipts(_receipt(base), _receipt(candidate))["ok"] + excessive = {**candidate, "chars": candidate["chars"] + 1} + assert not compare_cli_output_receipts(_receipt(base), _receipt(excessive))["ok"] + repeated = {**candidate, "chars": candidate["chars"] + 896} + assert not compare_cli_output_receipts(_receipt(candidate), _receipt(repeated))["ok"] + unsupported = {**candidate, "projection_envelope_schema_versions": ["loopx_projection_envelope_v1"]} + assert not compare_cli_output_receipts(_receipt(candidate), _receipt(unsupported))["ok"] + + def test_both_command_routes_are_counted_in_either_order() -> None: text = ( "loopx --registry '/tmp/registry path' --runtime-root /tmp/root refresh-state\n" diff --git a/tests/control_plane/test_explore_result_writeback.py b/tests/control_plane/test_explore_result_writeback.py index 7aa6ea04a7..9d125ef3b9 100644 --- a/tests/control_plane/test_explore_result_writeback.py +++ b/tests/control_plane/test_explore_result_writeback.py @@ -1,3 +1,5 @@ + +from canonical_authority_fixture import assert_todo_read_unchanged from copy import deepcopy import json @@ -994,7 +996,7 @@ def test_corrupt_graph_cannot_report_success_on_replay_and_recovers_after_repair assert not result["ok"], "A partial log must not become a successful delivery receipt" assert result["primary_committed"] and result["retryable"] assert log.read_bytes() == corrupt - assert list_goal_todos(registry_path=args["registry_path"], goal_id=args["goal_id"]) == before + assert_todo_read_unchanged(list_goal_todos(registry_path=args["registry_path"], goal_id=args["goal_id"]), before) log.write_bytes(valid) assert deliver_result_attachment(payload=payload(), **args)["idempotent_replay"] assert log.read_bytes() == valid diff --git a/tests/control_plane/test_frontier_revision_scope.py b/tests/control_plane/test_frontier_revision_scope.py index e2edf99b90..1531fdec9f 100644 --- a/tests/control_plane/test_frontier_revision_scope.py +++ b/tests/control_plane/test_frontier_revision_scope.py @@ -27,8 +27,8 @@ def test_summary_adapter_rejects_non_object_batched_frontier(monkeypatch, index) def invalid_frontier(method, request, **kwargs): result = original(method, request, **kwargs) - if method == "todo.summary.project": - result["fields"]["advancement_frontier_revision_index"] = index + if method == "todo.read": + result["summary"]["advancement_frontier_revision_index"] = index return result monkeypatch.setattr(effect_runtime, "effect_runtime_result", invalid_frontier) diff --git a/tests/control_plane/test_handoff_context_projection.py b/tests/control_plane/test_handoff_context_projection.py index 214073822a..eab7c2892c 100644 --- a/tests/control_plane/test_handoff_context_projection.py +++ b/tests/control_plane/test_handoff_context_projection.py @@ -227,7 +227,7 @@ def track(method, request, **kwargs): assert result["done_count"] == 1000 and result["total_count"] == 1000 assert len(result["items"]) == 12 assert result["recent_completed_advancement_items"][0]["handoff_note"]["summary"] == "a" * 279 + "..." - assert calls == ["todo.summary.project", "todo.context.page"] + assert calls == ["todo.read"] def test_byte_bounded_context_batches_retain_every_record(monkeypatch): diff --git a/tests/control_plane/test_local_coordination_authority.py b/tests/control_plane/test_local_coordination_authority.py index e73e677587..3756bbee32 100644 --- a/tests/control_plane/test_local_coordination_authority.py +++ b/tests/control_plane/test_local_coordination_authority.py @@ -61,6 +61,12 @@ ) +def _assert_same_todo_content(current, expected): + assert current["projection_envelope"]["coverage"] == expected["projection_envelope"]["coverage"] + assert {key: value for key, value in current.items() if key != "projection_envelope"} == { + key: value for key, value in expected.items() if key != "projection_envelope"} + + def _engage_fence(runtime_root: Path, goal_id: str = "goal-a") -> None: path = legacy_coordination_writer_fence_path( runtime_root=runtime_root, @@ -1116,29 +1122,13 @@ def test_todo_list_uses_provider_after_cutover_even_when_markdown_disagrees( ), encoding="utf-8", ) - _engage_fence(runtime_root) + projection = build_todo_runtime_shadow_projection(goal_id="goal-a", todos=[{ + "schema_version": "todo_item_v0", "todo_id": "todo_provider", "index": 1, + "role": "agent", "status": "open", "done": False, "text": "provider Todo", + "task_class": "advancement_task", "archive_state": "active", "source_section": "Agent Todo", + }]) + initialize_canonical_authority(runtime_root, "goal-a", projection, state_path=state_file) state_file.unlink() - monkeypatch.setattr( - "loopx.control_plane.coordination.local_authority.effect_runtime_result", - lambda method, params, **_kwargs: single_snapshot_page({ - "status": "loaded", - "todos": [ - { - "todo_id": "todo_provider", - "role": "agent", - "status": "open", - "text": "provider Todo", - } - ], - "todo_read_model": _todo_read_model(1), - "provider_revision": "file:2", - "cursor": "2", - "source_authority": "file_v0", - "decision_read_from_provider": True, - "legacy_fallback_used": False, - }), - ) - result = list_goal_todos(registry_path=registry_path, goal_id="goal-a") assert result["source"] == "file_authority" @@ -1255,7 +1245,7 @@ def test_canonical_hard_lease_claim_cli_atomically_acquires_ownership( assert preview["status"] == "planned" assert preview["todo_changed"] is True assert preview["lease_changed"] is True - assert list_goal_todos(registry_path=registry_path, goal_id="goal-a") == before + _assert_same_todo_content(list_goal_todos(registry_path=registry_path, goal_id="goal-a"), before) applied = json.loads( subprocess.run( @@ -1298,7 +1288,7 @@ def invoke_edit(argv): preview_edit = invoke_edit([*edit, "--dry-run"]) assert preview_edit.returncode == 0, preview_edit.stdout + preview_edit.stderr assert json.loads(preview_edit.stdout)["status"] == "planned" - assert list_goal_todos(registry_path=registry_path, goal_id="goal-a") == after + _assert_same_todo_content(list_goal_todos(registry_path=registry_path, goal_id="goal-a"), after) first_edit = invoke_edit(edit) assert first_edit.returncode == 0, first_edit.stdout + first_edit.stderr assert json.loads(first_edit.stdout)["status"] == "applied" diff --git a/tests/control_plane/test_monitor_followthrough_contract.py b/tests/control_plane/test_monitor_followthrough_contract.py index 80ea23edd7..9f02e0ee47 100644 --- a/tests/control_plane/test_monitor_followthrough_contract.py +++ b/tests/control_plane/test_monitor_followthrough_contract.py @@ -1,5 +1,7 @@ from __future__ import annotations +from canonical_authority_fixture import assert_todo_read_unchanged + import json from pathlib import Path @@ -1103,13 +1105,10 @@ def test_material_poll_user_followup_requires_explicit_class_without_writeback( ) assert "todo_writeback" not in result assert state.read_text(encoding="utf-8") == before_state - assert ( - list_goal_todos( + assert_todo_read_unchanged(list_goal_todos( registry_path=registry, goal_id=GOAL_ID, - ) - == before_todos - ) + ), before_todos) def test_material_poll_explicit_user_gate_blocks_bound_agent( diff --git a/tests/control_plane/test_native_todo_planning_update.py b/tests/control_plane/test_native_todo_planning_update.py index 26f4c4ceb4..6e897c1f31 100644 --- a/tests/control_plane/test_native_todo_planning_update.py +++ b/tests/control_plane/test_native_todo_planning_update.py @@ -1,6 +1,8 @@ """Real CLI and canonical FileAuthorityStore; no production registry or promotion.""" from __future__ import annotations +from canonical_authority_fixture import assert_todo_read_unchanged + import json import shlex import subprocess @@ -797,7 +799,7 @@ def test_public_cli_external_wait_uses_current_successor_source( display = state.read_bytes() if state.exists() else None rejected = update(registry, *args, ok=False) assert "external-wait successor must be runnable, not resume-gated" in json.dumps(rejected) - assert list_goal_todos(registry_path=registry, goal_id="goal-a") == source + assert_todo_read_unchanged(list_goal_todos(registry_path=registry, goal_id="goal-a"), source) assert (state.read_bytes() if state.exists() else None) == display return update(registry, *args, "--dry-run") diff --git a/tests/control_plane/test_quota_authority_settlement_journey.py b/tests/control_plane/test_quota_authority_settlement_journey.py index 9e720c0161..e87a908641 100644 --- a/tests/control_plane/test_quota_authority_settlement_journey.py +++ b/tests/control_plane/test_quota_authority_settlement_journey.py @@ -1,6 +1,8 @@ """Select current work and execute its returned settlement, using real stores/CLI.""" from __future__ import annotations +from canonical_authority_fixture import assert_todo_read_unchanged + import json import shlex from datetime import datetime, timedelta, timezone @@ -98,8 +100,8 @@ def test_canonical_todos_cannot_replace_missing_goal_intent(tmp_path, provider): path.unlink() # A read-only canonical Todo lookup still works; it cannot authorize a # delivery whose Goal objective and constraints are unavailable. - assert list_goal_todos(registry_path=registry, goal_id=cli.GOAL_ID, - runtime_root_arg=str(runtime), todo_id=cli.TODO_ID) == before + assert_todo_read_unchanged(list_goal_todos(registry_path=registry, goal_id=cli.GOAL_ID, + runtime_root_arg=str(runtime), todo_id=cli.TODO_ID), before) code, guard = _guard(project, runtime, registry) assert code == 1 assert guard["should_run"] is False @@ -107,8 +109,8 @@ def test_canonical_todos_cannot_replace_missing_goal_intent(tmp_path, provider): assert cli._heartbeat_receipt_count(runtime, cli.TURN_ID) == 0 assert cli._spend_run_count(runtime) == 0 assert not path.exists() - assert list_goal_todos(registry_path=registry, goal_id=cli.GOAL_ID, - runtime_root_arg=str(runtime), todo_id=cli.TODO_ID) == before + assert_todo_read_unchanged(list_goal_todos(registry_path=registry, goal_id=cli.GOAL_ID, + runtime_root_arg=str(runtime), todo_id=cli.TODO_ID), before) @pytest.mark.parametrize("provider", ["file", "sqlite"]) diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index f4ad1af48c..ace3fae70e 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -1983,15 +1983,15 @@ def test_blocked_wait_transports_complete_lifecycle_facts_without_prose( cadence="5m", expires_at="2026-01-02T00:00:00Z", watch_only=True) source = {"agent_todos": {"items": [waiting, target]}, "user_todos": {"items": []}} before = json.dumps(source, sort_keys=True) - invoke = effect_runtime._request_with_info + invoke = effect_runtime.effect_runtime_request requests = [] - def observe_request(info, **kwargs): - if kwargs["method"] == "quota.settlement.read": - requests.append(kwargs["params"]) - return invoke(info, **kwargs) + def observe_request(method, params, **kwargs): + if method == "quota.settlement.read": + requests.append(params) + return invoke(method, params, **kwargs) - monkeypatch.setattr(effect_runtime, "_request_with_info", observe_request) + monkeypatch.setattr(effect_runtime, "effect_runtime_request", observe_request) result = require_blocked_retry_wait(source, todo_id=TODO_ID, observed_at=observed, allow_turn_settlement_retry=kind == "implicit") assert len(requests) == 1 diff --git a/tests/control_plane/test_retained_team_plan_receipt.py b/tests/control_plane/test_retained_team_plan_receipt.py index fa41ed520b..acc0789856 100644 --- a/tests/control_plane/test_retained_team_plan_receipt.py +++ b/tests/control_plane/test_retained_team_plan_receipt.py @@ -1,4 +1,7 @@ + """Recover a fixed 1.x receipt through the current public adapter.""" + +from canonical_authority_fixture import assert_todo_read_unchanged import json from pathlib import Path @@ -41,5 +44,5 @@ def replay(**overrides): apply_team_plan(registry_path=registry, goal_id="goal-a", agent_id=None, proposal={**history["plan"], "objective": "Changed original intent"}, expected_state_fingerprint=history["basis"]) - assert list_goal_todos(registry_path=registry, goal_id="goal-a") == before + assert_todo_read_unchanged(list_goal_todos(registry_path=registry, goal_id="goal-a"), before) assert state.read_bytes() == display diff --git a/tests/control_plane/test_settled_monitor_user_gate.py b/tests/control_plane/test_settled_monitor_user_gate.py index f301ffedf9..64482db94f 100644 --- a/tests/control_plane/test_settled_monitor_user_gate.py +++ b/tests/control_plane/test_settled_monitor_user_gate.py @@ -1,6 +1,8 @@ """Exact monitor closeout wins over an unrelated scoped user gate.""" from __future__ import annotations +from canonical_authority_fixture import assert_todo_read_unchanged + import json import pytest @@ -162,7 +164,7 @@ def call(command): before = list_goal_todos(registry_path=source, goal_id=GOAL_ID) rc, denied = call("monitor-poll") assert rc == 1 and denied["error_code"] == "monitor_poll_admission_rejected", denied - assert list_goal_todos(registry_path=source, goal_id=GOAL_ID) == before + assert_todo_read_unchanged(list_goal_todos(registry_path=source, goal_id=GOAL_ID), before) index = runtime / "goals" / GOAL_ID / "runs" / "index.jsonl" assert not index.exists() if gate_kind != "linked": @@ -176,7 +178,7 @@ def call(command): committed = list_goal_todos(registry_path=source, goal_id=GOAL_ID) rc, replay = call("monitor-poll") assert rc == 0 and replay["replayed"] is True and replay["appended"] is False - assert list_goal_todos(registry_path=source, goal_id=GOAL_ID) == committed + assert_todo_read_unchanged(list_goal_todos(registry_path=source, goal_id=GOAL_ID), committed) rows = [json.loads(line) for line in index.read_text().splitlines()] assert [row["classification"] for row in rows] == ["quota_monitor_poll"] diff --git a/tests/control_plane/test_todo_completion_state_runtime.py b/tests/control_plane/test_todo_completion_state_runtime.py index 58fc60d0c9..a86d75510d 100644 --- a/tests/control_plane/test_todo_completion_state_runtime.py +++ b/tests/control_plane/test_todo_completion_state_runtime.py @@ -1,3 +1,4 @@ +"""Persisted completion spellings decode without a field-level runtime call.""" from __future__ import annotations import pytest @@ -6,37 +7,20 @@ from loopx.control_plane.todos import completion_state -def _result(**values: object) -> dict[str, object]: - return { - "schema_version": completion_state.TODO_COMPLETION_STATE_RESULT_SCHEMA, - **values, - } +@pytest.mark.parametrize(("raw", "expected"), [ + ("YES", True), ("no-followup", True), ("FALSE", False), (False, False), + (None, None), ("unrecognized", None), ("\ufeffYES\ufeff", True), ("\x1cYES\x1c", None), +]) +def test_metadata_spellings_do_not_start_the_runtime(monkeypatch, raw, expected): + def unexpected(*args, **kwargs): + raise AssertionError("metadata codecs must not call the runtime") + monkeypatch.setattr(effect_runtime, "effect_runtime_request", unexpected) + assert completion_state.normalize_todo_no_followup(raw) is expected -def test_scalar_normalization_uses_bounded_process_cache(monkeypatch) -> None: - calls = 0 - def call(_method: str, params: dict[str, object]) -> dict[str, object]: - nonlocal calls - calls += 1 - return _result(kind=params["kind"], value=True) - - completion_state._normalize_cached.cache_clear() - monkeypatch.setattr(completion_state, "effect_runtime_result", call) - - assert completion_state.normalize_todo_no_followup("YES") is True - assert completion_state.normalize_todo_no_followup("YES") is True - assert calls == 1 - completion_state._normalize_cached.cache_clear() - - -def test_python_facade_preserves_typed_rejection_as_value_error(monkeypatch) -> None: - def reject(_method: str, _params: dict[str, object]) -> object: - raise effect_runtime.EffectRuntimeRejected("typed state rejected") - - monkeypatch.setattr(completion_state, "effect_runtime_result", reject) - - with pytest.raises(ValueError, match="typed state rejected"): - completion_state.require_todo_completion_metadata( - "completion_continuation", "implicit", - ) +def test_completion_metadata_rejects_unknown_continuation_without_inventing_recovery(): + assert completion_state.require_todo_completion_metadata("completion_continuation", " successor ") == "successor" + assert completion_state.require_todo_completion_metadata("completion_recovery", "") is None + with pytest.raises(ValueError, match="completion_continuation must be one of"): + completion_state.require_todo_completion_metadata("completion_continuation", "implicit") diff --git a/tests/control_plane/test_todo_consumer_scope.py b/tests/control_plane/test_todo_consumer_scope.py index 5f3566af72..3730978a2f 100644 --- a/tests/control_plane/test_todo_consumer_scope.py +++ b/tests/control_plane/test_todo_consumer_scope.py @@ -89,51 +89,28 @@ def read(*args): assert (state.read_bytes() if state.exists() else None) == before -def test_filter_composes_with_existing_lane_call_and_rejects_downgraded_response(monkeypatch): +def test_filter_uses_one_read_transaction_and_rejects_a_downgraded_response(monkeypatch): from loopx.control_plane import effect_runtime source = compact_todo_group([{"todo_id": "todo_one", "text": "Review result", "role": "user", "status": "open", "task_class": "user_action", "bound_agent": "agent-a"}], role="user", source_section="User Todo", item_limit=None) original = effect_runtime.effect_runtime_result requests = [] + def track(method, request, **kwargs): - if method == "todo.summary.project": - requests.append(request) + requests.append((method, request)) return original(method, request, **kwargs) + monkeypatch.setattr(effect_runtime, "effect_runtime_result", track) assert filtered_todo_summary(source, role="user", agent_id="agent-a")["total_count"] == 1 - assert len(requests) == 1 - assert requests[0]["schema_version"] == "todo_summary_projection_request_v3" - # One whole-source batch stays columnar, so adding fields cannot silently - # push a long-history request past the runtime request budget. - assert requests[0]["columns"][:3] == ["status", "done", "task_class"] - assert all(len(cells) == len(requests[0]["columns"]) for cells in requests[0]["rows"]) + assert len(requests) == 1 and requests[0][0] == "todo.read" + assert requests[0][1]["schema_version"] == "todo_read_request_v0" + def downgrade(method, request, **kwargs): result = original(method, request, **kwargs) - if method == "todo.summary.project": - result.pop("source_indices", None) + result.pop("schema_version", None) return result - monkeypatch.setattr(effect_runtime, "effect_runtime_result", downgrade) - with pytest.raises(ValueError, match="selection ordinals"): - filtered_todo_summary(source, role="user", agent_id="agent-a") - -@pytest.mark.parametrize("corruption", ["duplicate", "boolean", "outside_selection"]) -def test_typed_lane_response_cannot_alias_or_escape_selected_source(monkeypatch, corruption): - from loopx.control_plane import effect_runtime - source = compact_todo_group([ - {"todo_id": "todo_owned", "text": "Review result", "role": "user", "status": "open", - "task_class": "user_action", "bound_agent": "agent-a"}, - {"todo_id": "todo_other", "text": "Review peer result", "role": "user", "status": "open", - "task_class": "user_action", "bound_agent": "agent-b"}], - role="user", source_section="User Todo", item_limit=None) - outside = next(index for index, item in enumerate(source["items"]) if item["todo_id"] == "todo_other") - original = effect_runtime.effect_runtime_result - def corrupt(method, request, **kwargs): - result = original(method, request, **kwargs) - if method == "todo.summary.project": - result["lanes"]["first_open_items"]["indices"] = {"duplicate": [0, 0], "boolean": [False], "outside_selection": [outside]}[corruption] - return result - monkeypatch.setattr(effect_runtime, "effect_runtime_result", corrupt) - with pytest.raises(ValueError, match="source ordinal|escaped the selected source"): + monkeypatch.setattr(effect_runtime, "effect_runtime_result", downgrade) + with pytest.raises(ValueError, match="invalid typed Todo read result"): filtered_todo_summary(source, role="user", agent_id="agent-a") diff --git a/tests/control_plane/test_todo_conversation_context.py b/tests/control_plane/test_todo_conversation_context.py index cbbc506dd5..ed6382f7cd 100644 --- a/tests/control_plane/test_todo_conversation_context.py +++ b/tests/control_plane/test_todo_conversation_context.py @@ -1,5 +1,8 @@ + """Conversation evidence reads use real canonical providers without granting work.""" +from canonical_authority_fixture import assert_todo_read_unchanged + import json import subprocess import sys @@ -54,7 +57,7 @@ def test_common_context_recovers_constraints_from_canonical_authority(tmp_path, allowed[0] = False assert not tool.read(CONTEXT_TOOL_NAME, {"view": "todos", "goal_id": "goal-a"})["ok"] assert len(observed) == 4 - assert list_goal_todos(registry_path=registry, goal_id="goal-a") == canonical + assert_todo_read_unchanged(list_goal_todos(registry_path=registry, goal_id="goal-a"), canonical) assert not state.exists() # The shipped CLI uses the same exact selector but an external audience. diff --git a/tests/control_plane/test_todo_list_explicit_limit.py b/tests/control_plane/test_todo_list_explicit_limit.py index 9989027c4f..f10a7d33f8 100644 --- a/tests/control_plane/test_todo_list_explicit_limit.py +++ b/tests/control_plane/test_todo_list_explicit_limit.py @@ -201,8 +201,7 @@ def test_default_payload_keeps_the_pre_limit_shape(tmp_path: Path) -> None: payload = list_goal_todos(registry_path=registry_path, goal_id=GOAL_ID) - # The no-limit cold path is byte-identical to the pre---limit shape: - # no explicit-limit disclosure fields appear. + # The cold view retains its fields and adds the required read provenance. assert set(payload) == { "ok", "dry_run", @@ -218,7 +217,9 @@ def test_default_payload_keeps_the_pre_limit_shape(tmp_path: Path) -> None: "project", "user_todos", "agent_todos", + "projection_envelope", } + assert payload["projection_envelope"]["coverage"]["expected_count"] == 16 assert payload["todo_count"] == 16 assert len(payload["todos"]) == 16 for absent_key in ( diff --git a/tests/control_plane/test_todo_list_read_transport.py b/tests/control_plane/test_todo_list_read_transport.py index a3033c5d95..f44d48f37d 100644 --- a/tests/control_plane/test_todo_list_read_transport.py +++ b/tests/control_plane/test_todo_list_read_transport.py @@ -3,15 +3,19 @@ import json import os -from pathlib import Path import subprocess import sys +from pathlib import Path import pytest +from canonical_authority_fixture import ( + initialize_canonical_authority, + isolate_sqlite_runtime, +) -from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime -from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection - +from loopx.control_plane.coordination.runtime_shadow import ( + build_todo_runtime_shadow_projection, +) REPO = Path(__file__).resolve().parents[2] GOAL = "read-transport" @@ -68,8 +72,8 @@ def run(*tail, format_name="json", expected_exit=0, isolated=False): yield request.param, run, prefix, state subprocess.run( - [sys.executable, "-c", "from loopx.control_plane.effect_runtime import effect_runtime_result;" - "effect_runtime_result('runtime.shutdown', {}, retry_safe=False)"], + [sys.executable, "-c", ("from loopx.control_plane.effect_runtime import effect_runtime_result;" + "effect_runtime_result('runtime.shutdown', {}, retry_safe=False)")], cwd=REPO, capture_output=True, text=True, timeout=30, check=True, ) @@ -107,7 +111,7 @@ def test_read_filters_errors_and_fresh_provider(read_cli): added = subprocess.run( [sys.executable, "-m", "loopx.cli", *prefix, "--format", "json", "todo", "add", "--goal-id", GOAL, "--role", "agent", "--text", "Fresh canonical requirement", - "--claimed-by", "agent-a"], cwd=REPO, capture_output=True, text=True, timeout=45, + "--claimed-by", "agent-a"], cwd=REPO, capture_output=True, text=True, timeout=45, check=False, ) assert added.returncode == 0, added.stdout + added.stderr fresh = run() @@ -123,6 +127,21 @@ def test_read_filters_errors_and_fresh_provider(read_cli): assert fresh["authority_read"]["legacy_fallback_used"] is False +def test_display_limits_report_complete_sources_and_actual_shown_items(read_cli): + _, run, _, _ = read_cli + for options, available in [((), 3), (("--agent-id", "agent-a"), 2), + (("--limit", "1"), 3), (("--thin",), 3)]: + payload = run(*options) + envelope = payload["projection_envelope"] + coverage = envelope["coverage"] + assert envelope["alert"] is False + assert coverage["complete"] is True and coverage["omitted"] == [] + assert coverage["expected_count"] == coverage["included_count"] == available + assert coverage["available_count"] == available + assert coverage["shown_count"] == len(payload["todos"]) + assert coverage["truncated"] == (len(payload["todos"]) < available) + + def test_read_entrypoint_does_not_load_mutation_owners(read_cli): _, run, _, _ = read_cli assert run(isolated=True)["todo_count"] == 3 @@ -170,5 +189,5 @@ def test_todo_help_does_not_load_mutation_owners(): "\nassert 'loopx.state_refresh' not in sys.modules" ) result = subprocess.run([sys.executable, "-c", script], cwd=REPO, - capture_output=True, text=True, timeout=45) + capture_output=True, text=True, timeout=45, check=False) assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/control_plane/test_todo_list_record_references.py b/tests/control_plane/test_todo_list_record_references.py index 9a067cc0d3..7ad53db447 100644 --- a/tests/control_plane/test_todo_list_record_references.py +++ b/tests/control_plane/test_todo_list_record_references.py @@ -47,17 +47,17 @@ def cli(registry, runtime, flags, output_format="json"): def test_projection_failure_returns_the_standard_cli_error_without_source_changes(tmp_path, monkeypatch, capsys): from loopx.cli import main - from loopx.cli_commands import todo_list as todo_command + from loopx.control_plane import effect_runtime isolate_sqlite_runtime(tmp_path, monkeypatch) registry, state = _write_fixture(tmp_path) before = state.read_bytes() def unavailable(operation, payload, **kwargs): - assert operation == "todo.context.page" and payload["list_payload"]["ok"] + assert operation == "todo.read" and payload["kind"] == "query" raise RuntimeError("Todo projection unavailable") - monkeypatch.setattr(todo_command, "effect_runtime_result", unavailable) + monkeypatch.setattr(effect_runtime, "effect_runtime_result", unavailable) result = main(["--registry", str(registry), "todo", "list", "--format", "json", "--goal-id", GOAL_ID]) error = json.loads(capsys.readouterr().out) @@ -91,6 +91,10 @@ def test_real_cli_references_reconstruct_all_scoped_lanes_and_counts(tmp_path, m ]: native = list_goal_todos(registry_path=registry, goal_id=GOAL_ID, **options) wire = cli(registry, runtime, flags) + native_envelope = native.pop("projection_envelope") + wire_envelope = wire.pop("projection_envelope") + assert native_envelope["schema_version"] == wire_envelope["schema_version"] == "loopx_projection_envelope_v0" + assert native_envelope["coverage"] == wire_envelope["coverage"] disclosure = wire.pop("todo_list_record_references") assert disclosure["replaced_view_records"] > 0 assert expand_references(wire, wire) == native @@ -101,7 +105,9 @@ def test_real_cli_references_reconstruct_all_scoped_lanes_and_counts(tmp_path, m assert cli(registry, runtime, flags, "markdown") == render_todo_markdown(native) + "\n" thin = cli(registry, runtime, ["--thin"]) assert "todo_list_record_references" not in thin - assert thin == list_goal_todos(registry_path=registry, goal_id=GOAL_ID, thin=True) + direct_thin = list_goal_todos(registry_path=registry, goal_id=GOAL_ID, thin=True) + assert thin.pop("projection_envelope")["coverage"] == direct_thin.pop("projection_envelope")["coverage"] + assert thin == direct_thin exact = cli(registry, runtime, ["--todo-id", "todo_agent_current"]) assert "todo_list_record_references" not in exact assert exact["todo"]["text"] == "Execute the current agent advancement." diff --git a/tests/control_plane/test_todo_list_thin.py b/tests/control_plane/test_todo_list_thin.py index 17c7cdf236..45c12038ed 100644 --- a/tests/control_plane/test_todo_list_thin.py +++ b/tests/control_plane/test_todo_list_thin.py @@ -12,9 +12,7 @@ measure_cli_output, ) from loopx.control_plane.todos.contract import encode_metadata_value -from loopx.control_plane.todos.list_projection import ( - compact_thin_todo_list_payload, -) +from loopx.control_plane.todos.goal_todo_projection import project_todo_read from loopx.control_plane.todos.markdown import render_todo_markdown from loopx.todos import list_goal_todos @@ -496,15 +494,28 @@ def test_thin_intrinsically_bounds_high_cardinality_and_reports_overflow( ) -def test_thin_widest_retained_shape_stays_inside_fixed_output_budget() -> None: - payload = compact_thin_todo_list_payload( - _widest_retained_thin_payload(items_per_role=5) - ) +def test_thin_widest_retained_shape_stays_inside_fixed_output_budget(tmp_path: Path) -> None: + source = _widest_retained_thin_payload(items_per_role=5) + groups = [{"role": role, "source_section": f"{role.title()} Todo", + "items": source[f"{role}_todos"]["items"], "include_empty_source": True} + for role in ("user", "agent")] + payload = project_todo_read({ + "kind": "query", "source": {"runtime_root": str(tmp_path), "goal_id": GOAL_ID}, + "observed_at": 1767225600, "evaluated_at": "2026-01-01T00:00:00Z", + "legacy_source": {"groups": groups, "lineage": [item for group in groups for item in group["items"]], + "source_count": 10}, + "query": {"goal_id": GOAL_ID, "read_scope": "active", "thin": True, + "state_file": str(tmp_path / "state.md")}, + })["payload"] assert payload["returned_todo_count"] == 4 assert payload["omitted_todo_count"] == 6 assert payload["todo_list_field_projection"]["item_limit_per_role"] == 2 assert all("title" not in item for item in payload["todos"]) + coverage = payload["projection_envelope"]["coverage"] + assert coverage["complete"] is True + assert coverage["included_count"] == coverage["available_count"] == 10 + assert coverage["shown_count"] == 4 and coverage["truncated"] is True spec = CLI_OUTPUT_MODE_VARIANT_BY_ID["todo_list_thin"] rendered_by_format = { diff --git a/tests/control_plane/test_todo_summary_projection.py b/tests/control_plane/test_todo_summary_projection.py index bd62cf4394..d1bbe59500 100644 --- a/tests/control_plane/test_todo_summary_projection.py +++ b/tests/control_plane/test_todo_summary_projection.py @@ -84,12 +84,12 @@ def test_a_selection_cannot_restore_a_lost_full_source_proof(): def test_long_history_stays_inside_the_runtime_request_budget(monkeypatch): """A whole-source batch must not outgrow the co-deployed runtime's request.""" from loopx.control_plane import effect_runtime - from loopx.control_plane.effect_runtime import MAX_REQUEST_BYTES + from loopx.control_plane.effect_runtime import MAX_LOCAL_SNAPSHOT_BYTES requests = [] original = effect_runtime.effect_runtime_result def track(method, request, **kwargs): - if method == "todo.summary.project": + if method == "todo.read": requests.append(request) return original(method, request, **kwargs) @@ -100,7 +100,7 @@ def track(method, request, **kwargs): assert result["done_count"] == 4096 request = requests[0] encoded = json.dumps(request, separators=(",", ":")).encode() - assert len(encoded) < MAX_REQUEST_BYTES + assert len(encoded) < MAX_LOCAL_SNAPSHOT_BYTES def test_no_wait_skips_resume_preparation_but_keeps_archived_successor(monkeypatch): @@ -194,7 +194,7 @@ def track(method, request, **kwargs): monkeypatch.setattr(succession_warning, "effect_runtime_result", track) selected = filtered_todo_summary(source, role="agent", todo_id="todo_summary_1") assert selected["done_count"] == 1 and not selected.get("completed_without_successor_count") - assert calls.count("todo.summary.project") == 1 + assert calls.count("todo.read") == 1 assert "todo.succession.project" not in calls next(item for item in source["items"] if item["todo_id"] == "todo_summary_1")["no_followup"] = True import pytest @@ -237,7 +237,7 @@ def track(method, request, **kwargs): monkeypatch.setattr(effect_runtime, "effect_runtime_result", track) selected = filtered_todo_summary(source, role="agent", agent_id="agent-a", item_limit=1) assert selected["advancement_frontier_revision_index"]["claimed_advancement_counts"] == {"agent-a": 24} - assert calls == ["todo.summary.project"] + assert calls == ["todo.read"] assert not hasattr(frontier_revision, "attach_advancement_frontier_revision_index") assert not hasattr(frontier_revision, "build_advancement_frontier_revision_index") @@ -251,9 +251,9 @@ def test_missing_batched_frontier_never_falls_back_to_another_owner_call(monkeyp original = effect_runtime.effect_runtime_result def drop_index(method, request, **kwargs): - assert method == "todo.summary.project" + assert method == "todo.read" result = original(method, request, **kwargs) - result["fields"].pop("advancement_frontier_revision_index") + result["summary"].pop("advancement_frontier_revision_index") return result monkeypatch.setattr(effect_runtime, "effect_runtime_result", drop_index) diff --git a/tests/control_plane_ts/todo_completion_state.test.ts b/tests/control_plane_ts/todo_completion_state.test.ts index 03c7668f7e..0f722fb7ce 100644 --- a/tests/control_plane_ts/todo_completion_state.test.ts +++ b/tests/control_plane_ts/todo_completion_state.test.ts @@ -4,8 +4,10 @@ import test from "node:test"; import { buildTodoCompletionMetadataUpdates, - normalizeTodoCompletionValue, - requireTodoCompletionMetadataValue, + normalizeTodoNoFollowup, + normalizeTodoCompletionContinuation, + normalizeTodoCompletionRecovery, + requireTodoCompletionMetadata, selectTodoCompletionState, TODO_COMPLETION_STATE_REQUEST_SCHEMA, } from "../../loopx/control_plane/todos/completion_state.ts"; @@ -35,14 +37,22 @@ const fixture = JSON.parse( }>; }; +function normalizeValue(request: Record) { + const value = request.kind === "no_followup" ? normalizeTodoNoFollowup(request.value) : + request.kind === "continuation" ? normalizeTodoCompletionContinuation(request.value) : normalizeTodoCompletionRecovery(request.value); + return {schema_version: "loopx_todo_completion_state_result_v0", kind: request.kind, value}; +} +function requireValue(request: Record) { + return {schema_version: "loopx_todo_completion_state_result_v0", value: requireTodoCompletionMetadata(String(request.key), request.value)}; +} const methods = { - normalize: normalizeTodoCompletionValue, - require_metadata: requireTodoCompletionMetadataValue, + normalize: normalizeValue, + require_metadata: requireValue, evaluate: selectTodoCompletionState, metadata_updates: buildTodoCompletionMetadataUpdates, } as const; -test("pinned Python completion-state characterization remains exact", () => { +test("persisted completion spellings and lifecycle compatibility remain exact", () => { assert.equal( fixture.schema_version, "loopx_todo_completion_state_characterization_v0", @@ -86,22 +96,12 @@ test("whole completion owner selects continuation and rejects contradictory inte }), /cannot record both no_followup and a successor/); }); -test("retired continuation-only RPC fails closed; live metadata decoding remains", async () => { - const handlers = createEffectRuntimeHandlers({ - fingerprint: "completion-retirement-test", requestShutdown: () => undefined, - }); - await assert.rejects(dispatchEffectRuntimeMethod( - handlers, "todo.completion_state.continuation_for_write", { - schema_version: TODO_COMPLETION_STATE_REQUEST_SCHEMA, - no_followup: false, has_successor: false, - }, - ), /unsupported Effect runtime method/); - assert.deepEqual(await dispatchEffectRuntimeMethod( - handlers, "todo.completion_state.require_metadata", { - schema_version: TODO_COMPLETION_STATE_REQUEST_SCHEMA, - key: "completion_continuation", value: "SUCCESSOR", - }, - ), { schema_version: "loopx_todo_completion_state_result_v0", value: "successor" }); +test("retired scalar and summary RPCs fail closed", async () => { + const handlers = createEffectRuntimeHandlers({fingerprint: "completion-retirement-test", requestShutdown: () => undefined}); + for (const method of ["todo.completion_state.continuation_for_write", "todo.completion_state.normalize", + "todo.completion_state.require_metadata", "todo.summary.project"]) { + await assert.rejects(dispatchEffectRuntimeMethod(handlers, method, {}), /unsupported Effect runtime method/); + } }); test("runtime boundary rejects malformed authority input", () => { diff --git a/tests/control_plane_ts/todo_read.test.ts b/tests/control_plane_ts/todo_read.test.ts new file mode 100644 index 0000000000..6aea33361e --- /dev/null +++ b/tests/control_plane_ts/todo_read.test.ts @@ -0,0 +1,172 @@ +import assert from "node:assert/strict"; +import {mkdtemp, writeFile, rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import test from "node:test"; +import {readTodo, TODO_READ_REQUEST} from "../../loopx/control_plane/todos/read_projection.ts"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {openLocalAuthorityStore, selectLocalSqliteAuthority} from "../../loopx/control_plane/coordination/local_authority_provider.ts"; +import {engageLegacyCoordinationWriterFence} from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import {coordinationTodoReadModel} from "../../loopx/control_plane/coordination/coordination_projection.ts"; +import {TODO_CANONICAL_READ_RECORD_SCHEMA} from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; + +const now = "2026-10-19T07:00:00Z"; +const row = (todo_id: string, extra: JsonObject = {}): JsonObject => ({schema_version: "todo_item_v0", todo_id, + index: 1, role: "agent", status: "open", done: false, text: "Review accepted evidence", task_class: "advancement_task", + archive_state: "active", source_section: "Agent Todo", ...extra}); +const group = (items: JsonObject[], extra: JsonObject = {}) => readTodo({schema_version: TODO_READ_REQUEST, + kind: "group", items, lineage: [], evaluated: false, role: "agent", source_section: "Agent Todo", + item_limit: 1, observed_at: Date.parse(now) / 1000, evaluated_at: now, ...extra}); + +test("current acceptance checks survive full-source reads and selection without an enable flag", async () => { + const acceptance = {criteria: [], revision: 1, digest: "sha256:" + "a".repeat(64)}; + const fields = await readTodo({schema_version: TODO_READ_REQUEST, kind: "fields", + todos: [row("todo_guarded")], goal_acceptance_contract: acceptance, + goal_acceptance_work_guards: {todo_guarded: {allowed: false}}, + observed_at: Date.parse(now) / 1000, evaluated_at: now}); + const summary = (fields.fields as JsonObject).agent_todos as JsonObject; + assert.deepEqual(summary.goal_acceptance_contract, acceptance); + assert.equal(((summary.items as JsonObject[])[0].goal_acceptance_guard as JsonObject).allowed, false); + const selected = await readTodo({schema_version: TODO_READ_REQUEST, kind: "select", + fields: fields.fields, field_evaluations: fields.field_evaluations, roles: ["agent"], + source_name: "file_authority", observed_at: Date.parse(now) / 1000, evaluated_at: now}); + const summaries = (selected.projection as JsonObject).summaries as JsonObject; + assert.deepEqual((summaries.agent_todos as JsonObject).goal_acceptance_contract, acceptance); +}); + +for (const provider of ["file", "sqlite"] as const) { + test(`${provider} full read keeps archived dependencies and fresh provider results without Markdown`, async t => { + const root = await mkdtemp(join(tmpdir(), "loopx-todo-read-")); + t.after(() => rm(root, {recursive: true, force: true})); + const state = join(root, "state.md"), goal = "goal-a"; + await writeFile(state, "# Synthetic Goal\n"); + if (provider === "sqlite") await selectLocalSqliteAuthority(root, goal, true); + const store = await openLocalAuthorityStore(root, goal); + const body = "Verify the original acceptance requirement 😀. ".repeat(40); + const todos = [row("todo_waiting", {status: "deferred", done: true, text: body, + resume_when: "todo_done:todo_archived"}), row("todo_archived", {role: "user", status: "done", done: true, + task_class: "user_action", archive_state: "archive", source_section: "Completed Work Archive"})] + .sort((a, b) => String(a.todo_id).localeCompare(String(b.todo_id))); + const projection = {goal_id: goal, handoff_mode: "soft_claim", todos, leases: [], + todo_read_model: coordinationTodoReadModel(todos, TODO_CANONICAL_READ_RECORD_SCHEMA)}; + const committed = await store.commitAuthority({expected_provider_revision: null, operation_id: "seed", + events: [], next_projection: projection, receipts: []}); + assert.equal(committed.status, "applied"); + if (committed.status !== "applied") return; + const fence = await engageLegacyCoordinationWriterFence({schema_version: "loopx_legacy_coordination_writer_fence_engage_request_v0", + runtime_root: root, goal_id: goal, state_path: state, fence: {schema_version: "loopx_legacy_coordination_writer_fence_v0", + state: "engaged", goal_id: goal, fence_id: "fixture", source_version: "fixture", + source_projection_sha256: canonicalAuthoritySha256(projection), expected_shadow_provider_revision: committed.provider_revision}}); + assert.equal(fence.status, "applied"); + await writeFile(state, new Uint8Array([255])); + const request = {schema_version: TODO_READ_REQUEST, kind: "query", source: {runtime_root: root, goal_id: goal}, + observed_at: Date.parse(now) / 1000, evaluated_at: now, + query: {goal_id: goal, role: null, status: null, todo_id: "todo_waiting", agent_id: null, limit: null, + read_scope: "active", json_references: true, thin: false, state_file: state, project: root}}; + const result = await readTodo(request), payload = result.payload as JsonObject; + assert.ok(payload, JSON.stringify(result)); + assert.equal(payload.read_only, true); + assert.equal((payload.todo as JsonObject).text, body); + assert.equal((payload.todo as JsonObject).resume_ready, true); + assert.equal((payload.authority_read as JsonObject).provider_revision, committed.provider_revision); + assert.equal((await store.loadAuthority()).status, "loaded"); + const laterTodos = [row("todo_waiting", {status: "blocked", text: "Changed current requirement"}), todos.find(item => item.todo_id === "todo_archived")!] + .sort((a, b) => String(a.todo_id).localeCompare(String(b.todo_id))); + const changed = {...projection, todos: laterTodos, + todo_read_model: coordinationTodoReadModel(laterTodos, TODO_CANONICAL_READ_RECORD_SCHEMA)}; + const later = await store.commitAuthority({expected_provider_revision: committed.provider_revision, + operation_id: "later", events: [], next_projection: changed, receipts: []}); + assert.equal(later.status, "applied"); + const reread = (await readTodo(request)).payload as JsonObject; + assert.equal((reread.todo as JsonObject).status, "blocked"); + assert.equal((reread.todo as JsonObject).text, "Changed current requirement"); + assert.notEqual((reread.authority_read as JsonObject).provider_revision, committed.provider_revision); + }); +} + +test("display limits retain full-source closure and reject altered evaluation evidence", async () => { + const items = Array.from({length: 4096}, (_, i) => row(`todo_completed_${i}`, {index: i + 1, + status: "done", done: true, no_followup: true})); + const result = await group(items), summary = result.summary as JsonObject; + assert.equal(summary.total_count, 4096); + assert.equal((summary.items as unknown[]).length, 1); + assert.equal((summary.terminal_closure_proof as JsonObject).item_count, 4096); + const first = await group([row("todo_owned", {status: "done", done: true, claimed_by: "agent-a"})]); + const changed = {...(first.items as JsonObject[])[0], no_followup: true}; + await assert.rejects(() => group([changed], {evaluated: true, evaluations: first.evaluations}), /matching full-source/); +}); + +test("controller validation commands remain private while their declaration identity survives", async () => { + const result = await readTodo({schema_version: TODO_READ_REQUEST, kind: "fields", + todos: [row("todo_validation", {validation_command: " printf synthetic ", validation_label: "check"})], + observed_at: Date.parse(now) / 1000, evaluated_at: now}); + const summary = (result.fields as JsonObject).agent_todos as JsonObject; + const item = (summary.items as JsonObject[])[0]!; + assert.equal(item.completion_validation_required, true); + // Pinned to the independent Python declaration codec, including null fields. + assert.equal(item.completion_validation_sha256, "31a837db75ed8cf0143228f0d3e94dae3886f0e79c496f7f4c335644f4c04020"); + assert.equal(JSON.stringify(result).includes("printf synthetic"), false); +}); + +test("a receiver view cannot include a peer's addressed work in any selected lane", async () => { + const first = await group([row("todo_owned", {role: "user", task_class: "user_action", bound_agent: "agent-a"}), + row("todo_peer", {role: "user", task_class: "user_action", bound_agent: "agent-b", index: 2})], + {role: "user", source_section: "User Todo", item_limit: null}); + const selected = await group(first.items as JsonObject[], {evaluated: true, evaluations: first.evaluations, + role: "user", source_section: "User Todo", item_limit: null, + selection: {role: "user", status: null, todo_id: null, agent_id: "agent-a"}}); + assert.deepEqual((selected.items as JsonObject[]).map(item => item.todo_id), ["todo_owned"]); + for (const value of Object.values(selected.summary as JsonObject)) { + if (Array.isArray(value)) for (const item of value as JsonObject[]) { + if (item.todo_id) assert.equal(item.todo_id, "todo_owned"); + } + } +}); + +test("source capture selects an evaluated view without reusing unrelated lineage carriers", async () => { + const item = row("todo_source_capture"); + const result = await readTodo({schema_version: TODO_READ_REQUEST, kind: "source_fields", + groups: [{role: "agent", source_section: "Agent Todo", items: [item], include_empty_source: true}], + lineage: [item], evaluated_at: now, observed_at: Date.parse(now) / 1000, + projection_query: {roles: ["agent"], status: null, todo_id: null, agent_id: null, limit: null, source_name: "markdown_active_state"}}); + const projected = result.projection as JsonObject; + assert.equal(projected.uncapped_todo_count, 1); + assert.equal((projected.todos as JsonObject[])[0]!.todo_id, item.todo_id); +}); + +test("query display limits preserve complete source coverage and report the final view", async t => { + const root = await mkdtemp(join(tmpdir(), "loopx-todo-coverage-")); + t.after(() => rm(root, {recursive: true, force: true})); + const items = Array.from({length: 30}, (_, i) => row(`todo_coverage_${i}`, { + index: i + 1, role: i < 15 ? "user" : "agent", task_class: i < 15 ? "user_action" : "advancement_task", + })); + const legacy = {groups: ["user", "agent"].map(role => ({role, source_section: `${role} Todo`, + items: items.filter(item => item.role === role), include_empty_source: true})), + lineage: items, full_bodies: items, source_count: items.length}; + const cases = [ + {name: "ordinary", query: {}, available: 30, shown: 30}, + {name: "hot", query: {agent_id: "agent-a"}, available: 30, shown: 24}, + {name: "limited", query: {limit: 1}, available: 30, shown: 2}, + {name: "thin", query: {thin: true}, available: 30, shown: 4}, + {name: "references", query: {json_references: true, limit: 1}, available: 30, shown: 2}, + {name: "exact", query: {todo_id: items[0].todo_id}, available: 1, shown: 1}, + {name: "missing", query: {todo_id: "todo_missing"}, available: 0, shown: 0}, + ]; + for (const scenario of cases) await t.test(scenario.name, async () => { + const read = await readTodo({schema_version: TODO_READ_REQUEST, kind: "query", + source: {runtime_root: root, goal_id: "coverage"}, legacy_source: legacy, + observed_at: Date.parse(now) / 1000, evaluated_at: now, + query: {goal_id: "coverage", read_scope: "active", state_file: join(root, "state.md"), ...scenario.query}}, now); + const payload = read.payload as JsonObject, envelope = payload.projection_envelope as JsonObject; + const coverage = envelope.coverage as JsonObject; + assert.equal(envelope.alert, false); + assert.equal(coverage.complete, true); + assert.equal(coverage.expected_count, scenario.available); + assert.equal(coverage.included_count, scenario.available); + assert.deepEqual(coverage.omitted, []); + assert.equal(coverage.shown_count, scenario.shown); + assert.equal(coverage.available_count, scenario.available); + assert.equal(coverage.truncated, scenario.shown < scenario.available); + }); +});