From 3915221bd837c0d3d0fe9bbb57773179509f8929 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:53:57 +0800 Subject: [PATCH 1/5] fix(authority): read complete canonical snapshots through bounded pages Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../coordination/canonical_snapshot.py | 240 ++++++++++++++++++ .../coordination/canonical_snapshot_page.ts | 142 +++++++++++ .../coordination_state_contract.generated.ts | 4 + .../coordination_state_contract_generated.py | 4 + .../coordination_state_contract_v0.json | 2 + .../coordination/local_authority.py | 23 +- .../coordination/local_authority_provider.ts | 9 +- .../coordination/local_authority_read.ts | 20 +- .../control_plane/effect_runtime_handlers.ts | 2 + pyproject.toml | 1 + .../generate_coordination_state_contract.py | 2 + .../canonical_authority_fixture.py | 19 ++ .../control_plane/test_canonical_snapshot.py | 230 +++++++++++++++++ .../test_canonical_snapshot_integration.py | 207 +++++++++++++++ .../test_coordination_state_contract.py | 8 +- .../test_local_coordination_authority.py | 46 ++-- .../test_todo_projection_concurrency.py | 2 +- .../authority_store_conformance.ts | 2 + .../canonical_snapshot_conformance.ts | 164 ++++++++++++ .../canonical_snapshot_page.test.ts | 88 +++++++ 20 files changed, 1162 insertions(+), 53 deletions(-) create mode 100644 loopx/control_plane/coordination/canonical_snapshot.py create mode 100644 loopx/control_plane/coordination/canonical_snapshot_page.ts create mode 100644 tests/control_plane/test_canonical_snapshot.py create mode 100644 tests/control_plane/test_canonical_snapshot_integration.py create mode 100644 tests/control_plane_ts/canonical_snapshot_conformance.ts create mode 100644 tests/control_plane_ts/canonical_snapshot_page.test.ts diff --git a/loopx/control_plane/coordination/canonical_snapshot.py b/loopx/control_plane/coordination/canonical_snapshot.py new file mode 100644 index 0000000000..17e82364d9 --- /dev/null +++ b/loopx/control_plane/coordination/canonical_snapshot.py @@ -0,0 +1,240 @@ +"""Assemble one native canonical snapshot without interpreting Todo semantics. + +No partial list escapes this adapter. Continuation, population, identity and +ordering checks protect the transport contract; TS still owns domain validation. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import Any + +from .coordination_state_contract_generated import ( + LOCAL_COORDINATION_TODO_LIST_RESULT_SCHEMA, + LOCAL_COORDINATION_TODO_SNAPSHOT_PAGE_REQUEST_SCHEMA as REQUEST_SCHEMA, + LOCAL_COORDINATION_TODO_SNAPSHOT_PAGE_RESULT_SCHEMA as RESULT_SCHEMA, +) +METHOD = "coordination.local_authority.todo_snapshot_page" + + +def read_canonical_snapshot( + *, + rpc: Callable[..., Any], + runtime_root: str, + goal_id: str, + include_leases: bool, + projection_readback: Mapping[str, Any] | None, + timeout: float, +) -> dict[str, Any]: + """Read all pages at one revision, or return a typed failure without rows.""" + after: dict[str, Any] | None = None + snapshot: dict[str, Any] | None = None + metadata: dict[str, Any] | None = None + source: str | None = None + todos: list[dict[str, Any]] = [] + leases: list[dict[str, Any]] = [] + guards: dict[str, Any] = {} + last_ids: dict[str, str | None] = {"todos": None, "leases": None} + + def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + try: + while True: + page = rpc( + METHOD, + { + "schema_version": REQUEST_SCHEMA, + "runtime_root": runtime_root, + "goal_id": goal_id, + "include_leases": include_leases, + "projection_readback": dict(projection_readback) + if projection_readback is not None + else None, + "after": after, + }, + timeout=timeout, + ) + require(isinstance(page, dict), "snapshot page is not an object") + if page.get("status") != "page": + # Do not return accumulated rows or accept an old unpaged success. + require( + page.get("status") != "loaded", + "snapshot RPC returned an unpaged result", + ) + return { + key: page[key] + for key in ( + "status", + "reason_code", + "reason", + "source_authority", + "decision_read_from_provider", + "legacy_fallback_used", + ) + if key in page + } + require( + page.get("schema_version") == RESULT_SCHEMA + and page.get("decision_read_from_provider") is True + and page.get("legacy_fallback_used") is False, + "invalid snapshot page envelope", + ) + current = page.get("snapshot") + require(isinstance(current, dict), "snapshot identity is missing") + require( + set(current) + == { + "goal_id", + "store_identity", + "provider_revision", + "cursor", + "query_sha256", + "todo_count", + "lease_count", + }, + "invalid snapshot identity fields", + ) + require( + current["goal_id"] == goal_id + and all( + isinstance(current[key], str) and bool(current[key]) + for key in ( + "store_identity", + "provider_revision", + "cursor", + "query_sha256", + ) + ), + "foreign or incomplete snapshot identity", + ) + require( + all( + type(current[key]) is int and current[key] >= 0 + for key in ("todo_count", "lease_count") + ), + "invalid snapshot population", + ) + require( + include_leases or current["lease_count"] == 0, + "unexpected lease population", + ) + current_metadata = page.get("metadata") + require(isinstance(current_metadata, dict), "snapshot metadata is missing") + require( + set(current_metadata) + <= { + "todo_read_model", + "goal_acceptance_contract", + "handoff_mode", + "projection_readback", + } + and "todo_read_model" in current_metadata, + "invalid snapshot metadata fields", + ) + if snapshot is None: + snapshot, metadata, source = ( + current, + current_metadata, + page.get("source_authority"), + ) + require( + current == snapshot + and current_metadata == metadata + and page.get("source_authority") == source, + "snapshot changed between pages", + ) + previous_size = len(todos) + len(leases) + for name, target in (("todos", todos), ("leases", leases)): + if name == "leases" and not include_leases: + require("leases" not in page, "unexpected lease records") + continue + rows = page.get(name) + require(isinstance(rows, list), "snapshot records are missing") + for item in rows: + require(isinstance(item, dict), "snapshot record is not an object") + identity = item.get("todo_id") + require( + isinstance(identity, str) and bool(identity), + "snapshot record identity is missing", + ) + previous = last_ids[name] + require( + previous is None or identity > previous, + "snapshot record is duplicated or out of order", + ) + last_ids[name] = identity + target.append(item) + page_guards = page.get("goal_acceptance_work_guards", {}) + require( + isinstance(page_guards, dict) + and set(page_guards) <= {x["todo_id"] for x in page["todos"]}, + "acceptance guard escaped its page", + ) + require(not (set(page_guards) & set(guards)), "duplicate acceptance guard") + guards.update(page_guards) + require( + len(todos) <= snapshot["todo_count"] + and len(leases) <= snapshot["lease_count"], + "snapshot population overflow", + ) + require( + not leases or len(todos) == snapshot["todo_count"], + "lease page precedes remaining Todos", + ) + require("next" in page, "snapshot continuation is missing") + after = page["next"] + if after is None: + require( + len(todos) == snapshot["todo_count"] + and len(leases) == snapshot["lease_count"], + "snapshot ended before its complete population", + ) + break + require( + isinstance(after, dict) + and set(after) == {"snapshot", "todo_offset", "lease_offset"}, + "invalid snapshot continuation", + ) + require( + after["snapshot"] == snapshot + and type(after["todo_offset"]) is int + and type(after["lease_offset"]) is int + and after["todo_offset"] == len(todos) + and after["lease_offset"] == len(leases), + "snapshot continuation skipped records", + ) + require( + len(todos) + len(leases) > previous_size + and len(todos) + len(leases) + < snapshot["todo_count"] + snapshot["lease_count"], + "snapshot continuation made no progress or passed its end", + ) + assert snapshot is not None and metadata is not None + return { + "schema_version": LOCAL_COORDINATION_TODO_LIST_RESULT_SCHEMA, + **metadata, + "status": "loaded", + "todos": todos, + "todo_ids": [item["todo_id"] for item in todos], + **({"leases": leases} if include_leases else {}), + **( + {"goal_acceptance_work_guards": guards} + if "goal_acceptance_contract" in metadata + else {} + ), + "provider_revision": snapshot["provider_revision"], + "cursor": snapshot["cursor"], + "source_authority": source, + "decision_read_from_provider": True, + "legacy_fallback_used": False, + } + except ValueError as error: + return { + "status": "failed", + "reason_code": "canonical_snapshot_result_invalid", + "reason": str(error), + "decision_read_from_provider": True, + "legacy_fallback_used": False, + } diff --git a/loopx/control_plane/coordination/canonical_snapshot_page.ts b/loopx/control_plane/coordination/canonical_snapshot_page.ts new file mode 100644 index 0000000000..e95c8a2852 --- /dev/null +++ b/loopx/control_plane/coordination/canonical_snapshot_page.ts @@ -0,0 +1,142 @@ +/** Byte-bounded canonical reads. Continuations are positions in one immutable + * provider revision, never permission to combine independently current heads. */ +import type {JsonObject} from "../effect_program.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; +import {projectGoalAcceptanceWorkGuards} from "../goals/acceptance_contract.ts"; +import {decodeProjectionReadback, confirmProjectionReadback} from "../todos/projection_delivery.ts"; +import {authorityStoreSourceAuthority, type AuthorityStore} from "./authority_store.ts"; +import {canonicalAuthoritySha256, hasExactAuthorityKeys, requireAuthorityStoreId} from "./authority_store_codec.ts"; +import {canonicalTodoCollection} from "./local_authority_read.ts"; +import {openRuntimeAuthorityStore, requireLocalAuthorityRuntimeRoot, localAuthorityOpenFailure, + type LocalAuthorityProviderDependencies} from "./local_authority_provider.ts"; + +import {LOCAL_COORDINATION_TODO_SNAPSHOT_PAGE_REQUEST_SCHEMA as CANONICAL_SNAPSHOT_PAGE_REQUEST, + LOCAL_COORDINATION_TODO_SNAPSHOT_PAGE_RESULT_SCHEMA as CANONICAL_SNAPSHOT_PAGE_RESULT} from "./coordination_state_contract.generated.ts"; +export {CANONICAL_SNAPSHOT_PAGE_REQUEST, CANONICAL_SNAPSHOT_PAGE_RESULT}; +// Leave room for the RPC envelope below the unchanged 2 MiB transport limit. +export const CANONICAL_SNAPSHOT_PAGE_BYTES = 1792 * 1024; +export const CANONICAL_SNAPSHOT_PAGE_ITEMS = 4096; +interface Snapshot extends JsonObject { + goal_id: string; store_identity: string; provider_revision: string; cursor: string; + query_sha256: string; todo_count: number; lease_count: number; +} +interface Position {snapshot: Snapshot; todo_offset: number; lease_offset: number} +class SnapshotReadError extends Error { + readonly code: string; + constructor(code: string, message: string) {super(message); this.code = code;} +} +function check(condition: unknown, code: string, message: string): asserts condition { + if (!condition) throw new SnapshotReadError(code, message); +} +function count(value: unknown): number { + check(typeof value === "number" && Number.isSafeInteger(value) && value >= 0, + "canonical_snapshot_request_invalid", "snapshot position must be a non-negative safe integer"); + return value; +} +function decodePosition(value: unknown): Position | null { + if (value === null) return null; + const row = requireJsonObject(value, "snapshot continuation"); + check(hasExactAuthorityKeys(row, ["snapshot", "todo_offset", "lease_offset"]), + "canonical_snapshot_request_invalid", "invalid continuation fields"); + const raw = requireJsonObject(row.snapshot, "snapshot identity"); + check(hasExactAuthorityKeys(raw, ["goal_id", "store_identity", "provider_revision", "cursor", "query_sha256", "todo_count", "lease_count"]), + "canonical_snapshot_request_invalid", "invalid snapshot fields"); + const snapshot: Snapshot = { + goal_id: requireAuthorityStoreId(raw.goal_id, "goal id"), + store_identity: requireAuthorityStoreId(raw.store_identity, "store identity"), + provider_revision: requireAuthorityStoreId(raw.provider_revision, "provider revision"), + cursor: requireAuthorityStoreId(raw.cursor, "cursor"), + query_sha256: requireAuthorityStoreId(raw.query_sha256, "query digest"), + todo_count: count(raw.todo_count), lease_count: count(raw.lease_count), + }; + const todo = count(row.todo_offset), lease = count(row.lease_offset); + check(todo <= snapshot.todo_count && lease <= snapshot.lease_count && todo + lease > 0 && + todo + lease < snapshot.todo_count + snapshot.lease_count && (lease === 0 || todo === snapshot.todo_count), + "canonical_snapshot_request_invalid", "continuation must advance within the ordered snapshot"); + return {snapshot, todo_offset: todo, lease_offset: lease}; +} + +/** Exported for provider conformance, using the same store contract as the + * shipped local entrypoint. No test-only pagination implementation is used. */ +export async function readCanonicalSnapshotFromStore(value: unknown, store: AuthorityStore): Promise { + const input = requireJsonObject(value, "canonical snapshot request"); + check(hasExactAuthorityKeys(input, ["schema_version", "runtime_root", "goal_id", "include_leases", "projection_readback", "after"]) && + input.schema_version === CANONICAL_SNAPSHOT_PAGE_REQUEST && typeof input.include_leases === "boolean", + "canonical_snapshot_request_invalid", "invalid canonical snapshot request"); + const goal = requireAuthorityStoreId(input.goal_id, "goal id"); + const after = decodePosition(input.after); + const readback = input.projection_readback === null ? null : decodeProjectionReadback(input.projection_readback); + const source = authorityStoreSourceAuthority(store); + const base = {schema_version: CANONICAL_SNAPSHOT_PAGE_RESULT, source_authority: source, + decision_read_from_provider: true, legacy_fallback_used: false}; + const identity = await store.storeIdentity(); + if (identity.status !== "available") return {...base, ...identity}; + const head = await store.loadAuthority(); + if (head.status !== "loaded") return {...base, ...head}; + const query = canonicalAuthoritySha256({goal_id: goal, include_leases: input.include_leases, projection_readback: readback}); + // Fail before expensive projection work when a continuation is already stale. + if (after !== null) check(after.snapshot.goal_id === goal && after.snapshot.store_identity === identity.store_identity && + after.snapshot.provider_revision === head.provider_revision && after.snapshot.cursor === head.cursor && + after.snapshot.query_sha256 === query, + "canonical_snapshot_changed", "canonical snapshot changed; restart the complete read, never append a newer page"); + const {projection, todoReadModel: readModel, leaseIndex: leases, acceptance} = + canonicalTodoCollection(head.head, goal, input.include_leases); + const snapshot: Snapshot = {goal_id: goal, store_identity: identity.store_identity, + provider_revision: head.provider_revision, cursor: head.cursor, query_sha256: query, + todo_count: projection.todo_ids.length, lease_count: leases?.lease_todo_ids.length ?? 0}; + if (after !== null) check(after.snapshot.todo_count === snapshot.todo_count && after.snapshot.lease_count === snapshot.lease_count, + "canonical_snapshot_changed", "snapshot population changed"); + const observedIdentity = await store.storeIdentity(); + check(observedIdentity.status === "available" && observedIdentity.store_identity === identity.store_identity, + "canonical_snapshot_changed", "store incarnation changed while loading the snapshot"); + const todoStart = after?.todo_offset ?? 0, leaseStart = after?.lease_offset ?? 0; + const todoIds = projection.todo_ids.slice(todoStart, todoStart + CANONICAL_SNAPSHOT_PAGE_ITEMS); + const leaseIds = leases?.lease_todo_ids.slice(leaseStart, leaseStart + CANONICAL_SNAPSHOT_PAGE_ITEMS - todoIds.length) ?? []; + const guards = acceptance.enabled === true ? projectGoalAcceptanceWorkGuards(head.head, goal, todoIds) : {}; + const metadata = {todo_read_model: readModel, + ...(acceptance.enabled === true ? {goal_acceptance_contract: acceptance} : {}), + ...(leases === null ? {} : {handoff_mode: head.head.handoff_mode ?? "legacy"}), + ...(readback === null ? {} : {projection_readback: confirmProjectionReadback(readback, head.provider_revision)})}; + function page(size: number): JsonObject { + const chosenTodos = todoIds.slice(0, size), chosenLeases = leaseIds.slice(0, Math.max(0, size - todoIds.length)); + const todoEnd = todoStart + chosenTodos.length, leaseEnd = leaseStart + chosenLeases.length; + return {...base, status: "page", snapshot, metadata, + todos: chosenTodos.map(id => projection.todos.get(id)!), + ...(leases === null ? {} : {leases: chosenLeases.map(id => leases.leases.get(id)!)}), + ...(acceptance.enabled !== true ? {} : {goal_acceptance_work_guards: Object.fromEntries( + chosenTodos.filter(id => Object.hasOwn(guards, id)).map(id => [id, guards[id]]))}), + next: todoEnd === snapshot.todo_count && leaseEnd === snapshot.lease_count ? null : + {snapshot, todo_offset: todoEnd, lease_offset: leaseEnd}}; + } + const bytes = (result: JsonObject) => Buffer.byteLength(JSON.stringify(result), "utf8"); + const available = todoIds.length + leaseIds.length; + const complete = page(available); + if (bytes(complete) <= CANONICAL_SNAPSHOT_PAGE_BYTES) return complete; + check(bytes(page(0)) <= CANONICAL_SNAPSHOT_PAGE_BYTES, "canonical_snapshot_metadata_too_large", "snapshot metadata exceeds the page budget"); + let low = 0, high = available; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if (bytes(page(middle)) <= CANONICAL_SNAPSHOT_PAGE_BYTES) low = middle; + else high = middle - 1; + } + check(available === 0 || low > 0, "canonical_snapshot_record_too_large", "one canonical record exceeds the page budget; records are never truncated"); + return page(low); +} + +/** Read-only RPC owner. A failed page never supplies a partial success or a + * legacy fallback; the client must discard any earlier pages of that read. */ +export async function readCanonicalSnapshotPage(value: unknown, + dependencies: LocalAuthorityProviderDependencies = {}): Promise { + try { + const input = requireJsonObject(value, "canonical snapshot request"); + const root = requireLocalAuthorityRuntimeRoot(input.runtime_root); + const goal = requireAuthorityStoreId(input.goal_id, "goal id"); + const store = await openRuntimeAuthorityStore(root, goal, dependencies, {existingOnly: true}); + return await readCanonicalSnapshotFromStore(input, store); + } catch (error) { + return {schema_version: CANONICAL_SNAPSHOT_PAGE_RESULT, status: "failed", + reason_code: error instanceof SnapshotReadError ? error.code : "canonical_snapshot_request_invalid", + reason: error instanceof Error ? error.message : "canonical snapshot read failed", + decision_read_from_provider: true, legacy_fallback_used: false, ...localAuthorityOpenFailure(error)}; + } +} diff --git a/loopx/control_plane/coordination/coordination_state_contract.generated.ts b/loopx/control_plane/coordination/coordination_state_contract.generated.ts index 7b5d1a8406..d7104c651e 100644 --- a/loopx/control_plane/coordination/coordination_state_contract.generated.ts +++ b/loopx/control_plane/coordination/coordination_state_contract.generated.ts @@ -12,6 +12,8 @@ export const LOCAL_COORDINATION_TODO_READ_REQUEST_SCHEMA = "loopx_local_coordina export const LOCAL_COORDINATION_TODO_READ_RESULT_SCHEMA = "loopx_local_coordination_todo_read_result_v0"; export const LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA = "loopx_local_coordination_todo_list_request_v0"; export const LOCAL_COORDINATION_TODO_LIST_RESULT_SCHEMA = "loopx_local_coordination_todo_list_result_v0"; +export const LOCAL_COORDINATION_TODO_SNAPSHOT_PAGE_REQUEST_SCHEMA = "loopx_canonical_snapshot_page_request_v0"; +export const LOCAL_COORDINATION_TODO_SNAPSHOT_PAGE_RESULT_SCHEMA = "loopx_canonical_snapshot_page_result_v0"; export const LOCAL_COORDINATION_PROMOTION_REQUEST_SCHEMA = "loopx_local_coordination_promotion_request_v0"; export const LOCAL_COORDINATION_PROMOTION_RESULT_SCHEMA = "loopx_local_coordination_promotion_result_v0"; export const LOCAL_COORDINATION_PROMOTION_RECEIPT_SCHEMA = "loopx_local_coordination_promotion_receipt_v0"; @@ -237,6 +239,8 @@ export const COORDINATION_STATE_CONTRACT = deepFreeze({ "todo_read_result_schema": LOCAL_COORDINATION_TODO_READ_RESULT_SCHEMA, "todo_list_request_schema": LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA, "todo_list_result_schema": LOCAL_COORDINATION_TODO_LIST_RESULT_SCHEMA, + "todo_snapshot_page_request_schema": LOCAL_COORDINATION_TODO_SNAPSHOT_PAGE_REQUEST_SCHEMA, + "todo_snapshot_page_result_schema": LOCAL_COORDINATION_TODO_SNAPSHOT_PAGE_RESULT_SCHEMA, "promotion_request_schema": LOCAL_COORDINATION_PROMOTION_REQUEST_SCHEMA, "promotion_result_schema": LOCAL_COORDINATION_PROMOTION_RESULT_SCHEMA, "promotion_receipt_schema": LOCAL_COORDINATION_PROMOTION_RECEIPT_SCHEMA, diff --git a/loopx/control_plane/coordination/coordination_state_contract_generated.py b/loopx/control_plane/coordination/coordination_state_contract_generated.py index 5da3f38ca5..e5d9da9c68 100644 --- a/loopx/control_plane/coordination/coordination_state_contract_generated.py +++ b/loopx/control_plane/coordination/coordination_state_contract_generated.py @@ -112,6 +112,8 @@ def _freeze(value: Any) -> Any: 'todo_read_result_schema': 'loopx_local_coordination_todo_read_result_v0', 'todo_list_request_schema': 'loopx_local_coordination_todo_list_request_v0', 'todo_list_result_schema': 'loopx_local_coordination_todo_list_result_v0', + 'todo_snapshot_page_request_schema': 'loopx_canonical_snapshot_page_request_v0', + 'todo_snapshot_page_result_schema': 'loopx_canonical_snapshot_page_result_v0', 'promotion_request_schema': 'loopx_local_coordination_promotion_request_v0', 'promotion_result_schema': 'loopx_local_coordination_promotion_result_v0', 'promotion_receipt_schema': 'loopx_local_coordination_promotion_receipt_v0', @@ -204,6 +206,8 @@ def _freeze(value: Any) -> Any: LOCAL_COORDINATION_TODO_READ_RESULT_SCHEMA: Final[str] = 'loopx_local_coordination_todo_read_result_v0' LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA: Final[str] = 'loopx_local_coordination_todo_list_request_v0' LOCAL_COORDINATION_TODO_LIST_RESULT_SCHEMA: Final[str] = 'loopx_local_coordination_todo_list_result_v0' +LOCAL_COORDINATION_TODO_SNAPSHOT_PAGE_REQUEST_SCHEMA: Final[str] = 'loopx_canonical_snapshot_page_request_v0' +LOCAL_COORDINATION_TODO_SNAPSHOT_PAGE_RESULT_SCHEMA: Final[str] = 'loopx_canonical_snapshot_page_result_v0' LOCAL_COORDINATION_PROMOTION_REQUEST_SCHEMA: Final[str] = 'loopx_local_coordination_promotion_request_v0' LOCAL_COORDINATION_PROMOTION_RESULT_SCHEMA: Final[str] = 'loopx_local_coordination_promotion_result_v0' LOCAL_COORDINATION_PROMOTION_RECEIPT_SCHEMA: Final[str] = 'loopx_local_coordination_promotion_receipt_v0' diff --git a/loopx/control_plane/coordination/coordination_state_contract_v0.json b/loopx/control_plane/coordination/coordination_state_contract_v0.json index bb2bbb419f..4891cd72b7 100644 --- a/loopx/control_plane/coordination/coordination_state_contract_v0.json +++ b/loopx/control_plane/coordination/coordination_state_contract_v0.json @@ -114,6 +114,8 @@ "todo_read_result_schema": "loopx_local_coordination_todo_read_result_v0", "todo_list_request_schema": "loopx_local_coordination_todo_list_request_v0", "todo_list_result_schema": "loopx_local_coordination_todo_list_result_v0", + "todo_snapshot_page_request_schema": "loopx_canonical_snapshot_page_request_v0", + "todo_snapshot_page_result_schema": "loopx_canonical_snapshot_page_result_v0", "promotion_request_schema": "loopx_local_coordination_promotion_request_v0", "promotion_result_schema": "loopx_local_coordination_promotion_result_v0", "promotion_receipt_schema": "loopx_local_coordination_promotion_receipt_v0", diff --git a/loopx/control_plane/coordination/local_authority.py b/loopx/control_plane/coordination/local_authority.py index 6dd85e6fa7..dd54264e89 100644 --- a/loopx/control_plane/coordination/local_authority.py +++ b/loopx/control_plane/coordination/local_authority.py @@ -22,13 +22,10 @@ TODO_DOMAIN_ITEM_SCHEMA_VERSION, TODO_ITEM_SCHEMA_VERSION, ) -from .coordination_state_contract_generated import ( - LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA, -) +from .canonical_snapshot import read_canonical_snapshot from .legacy_writer_fence import legacy_coordination_writer_fence_path -LOCAL_COORDINATION_TODO_LIST_METHOD = "coordination.local_authority.todo_list" LOCAL_COORDINATION_TODO_LIST_TIMEOUT_SECONDS = 15.0 LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA = ( "loopx_local_coordination_todo_claim_request_v1" @@ -197,19 +194,11 @@ def read_canonical_todos_if_promoted( if not local_authority_is_promoted(runtime_root=runtime_root, goal_id=goal_id): return None - result = effect_runtime_result( - LOCAL_COORDINATION_TODO_LIST_METHOD, - { - "schema_version": LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA, - "runtime_root": str(runtime_root.expanduser().resolve(strict=False)), - "goal_id": goal_id, - **({"include_leases": True} if include_leases else {}), - **({"projection_readback": dict(projection_readback)} if projection_readback is not None else {}), - }, - # Promoted goals can carry hundreds of preserved Todos. Keep the - # generic Effect request budget strict, but give this known bounded - # canonical scan the same cold-start allowance as the neighbouring - # shadow/lease authority reads. + 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): diff --git a/loopx/control_plane/coordination/local_authority_provider.ts b/loopx/control_plane/coordination/local_authority_provider.ts index ec27e6ddfb..1364202641 100644 --- a/loopx/control_plane/coordination/local_authority_provider.ts +++ b/loopx/control_plane/coordination/local_authority_provider.ts @@ -195,6 +195,7 @@ export async function openLocalAuthorityStoreHandle( root: string, goalId: string, dependencies: LocalAuthorityProviderDependencies = {}, + options: {existingOnly?: boolean} = {}, ): Promise { const p = paths(root, goalId); let raw: string; @@ -207,7 +208,7 @@ export async function openLocalAuthorityStoreHandle( try { await stat(sqliteAuthorityPath(p.sqlite, goalId)); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return {store: new FileAuthorityStore(p.file, goalId), provider: DEFAULT_LOCAL_AUTHORITY_PROVIDER, + return {store: new FileAuthorityStore(p.file, goalId, options), provider: DEFAULT_LOCAL_AUTHORITY_PROVIDER, sourceAuthority: sourceFor(DEFAULT_LOCAL_AUTHORITY_PROVIDER)}; } throw new LocalAuthorityProviderOpenError(null, "local_authority_selector_unavailable", "Local authority selection could not be resolved"); @@ -238,8 +239,9 @@ export async function openLocalAuthorityStore( root: string, goalId: string, dependencies: LocalAuthorityProviderDependencies = {}, + options: {existingOnly?: boolean} = {}, ): Promise { - return (await openLocalAuthorityStoreHandle(root, goalId, dependencies)).store; + return (await openLocalAuthorityStoreHandle(root, goalId, dependencies, options)).store; } /** Administrative opt-in for an empty, unpromoted goal; no implicit migration. */ @@ -272,11 +274,12 @@ export async function openRuntimeAuthorityStore( root: string, goalId: string, dependencies: LocalAuthorityProviderDependencies, + options: {existingOnly?: boolean} = {}, ): Promise { if (dependencies.createStore !== undefined) { return dependencies.createStore(join(root, "authority", "file-v0"), goalId); } - return await openLocalAuthorityStore(root, goalId, dependencies); + return await openLocalAuthorityStore(root, goalId, dependencies, options); } export function requireLocalAuthorityRuntimeRoot(value: unknown): string { diff --git a/loopx/control_plane/coordination/local_authority_read.ts b/loopx/control_plane/coordination/local_authority_read.ts index 78c9f9e9ae..f85610bb17 100644 --- a/loopx/control_plane/coordination/local_authority_read.ts +++ b/loopx/control_plane/coordination/local_authority_read.ts @@ -12,6 +12,18 @@ import {LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA, LOCAL_COORDINATION_TODO_LIS LOCAL_COORDINATION_TODO_READ_REQUEST_SCHEMA, LOCAL_COORDINATION_TODO_READ_RESULT_SCHEMA} from "./coordination_state_contract.generated.ts"; import {decodeProjectionReadback, confirmProjectionReadback} from "../todos/projection_delivery.ts"; +/** Shared admission for full and paged collection reads. Pagination changes + * transport only; retained records, read-model validation and acceptance keep + * this same owner. */ +export function canonicalTodoCollection(head: JsonObject, goalId: string, includeLeases: boolean) { + return { + projection: indexCoordinationProjectionTodos(head, goalId), + todoReadModel: validateCoordinationTodoReadModel(head, goalId), + leaseIndex: includeLeases ? indexCoordinationProjection(head, goalId) : null, + acceptance: projectGoalAcceptance(head, goalId), + }; +} + /** Provider-first exact Todo read. Missing/unavailable state never falls back. */ export async function readLocalCoordinationTodo( value: unknown, @@ -98,11 +110,9 @@ export async function listLocalCoordinationTodos( legacy_fallback_used: false, }; } - const projection = indexCoordinationProjectionTodos(head.head, goalId); - const todoReadModel = validateCoordinationTodoReadModel(head.head, goalId); - const leaseIndex = input.include_leases === true - ? indexCoordinationProjection(head.head, goalId) : null; - const acceptance = projectGoalAcceptance(head.head, goalId); + const {projection, todoReadModel, leaseIndex, acceptance} = canonicalTodoCollection( + head.head, goalId, input.include_leases === true, + ); return { schema_version: LOCAL_COORDINATION_TODO_LIST_RESULT_SCHEMA, status: "loaded", diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 89369149a1..675a332781 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -1,4 +1,5 @@ import {manageAutomationCadence, projectCadenceSchedule} from "./quota/automation_cadence.ts"; +import {readCanonicalSnapshotPage} from "./coordination/canonical_snapshot_page.ts"; import {manageLocalAuthorityArchive} from "./coordination/local_authority_archive.ts"; import {selectPeriodicReportProgress, selectPeriodicReportApprovalRetry} from "./capabilities/periodic_report_progress.ts"; import {planIssueFixMonitorReconciliation} from "./capabilities/issue_fix_monitor_reconciliation.ts"; @@ -559,6 +560,7 @@ export function createEffectRuntimeHandlers( ["coordination.local_authority.todo_read", readLocalCoordinationTodo], ["coordination.ownership_observation", projectOwnershipObservation], ["coordination.local_authority.ownership_observation", observeLocalCoordinationOwnership], + ["coordination.local_authority.todo_snapshot_page", readCanonicalSnapshotPage], ["coordination.local_authority.todo_list", listLocalCoordinationTodos], [ "coordination.local_authority.legacy_writer_fence.engage", diff --git a/pyproject.toml b/pyproject.toml index c241409912..08ac81d583 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -141,6 +141,7 @@ python_version = "3.11" strict = true files = [ "loopx/control_plane/__init__.py", + "loopx/control_plane/coordination/canonical_snapshot.py", "loopx/control_plane/coordination/executor.py", "loopx/control_plane/coordination/file_provider.py", "loopx/control_plane/coordination/head.py", diff --git a/scripts/generate_coordination_state_contract.py b/scripts/generate_coordination_state_contract.py index 26edc04728..22ca75026d 100644 --- a/scripts/generate_coordination_state_contract.py +++ b/scripts/generate_coordination_state_contract.py @@ -38,6 +38,8 @@ "todo_read_result_schema", "todo_list_request_schema", "todo_list_result_schema", + "todo_snapshot_page_request_schema", + "todo_snapshot_page_result_schema", "promotion_request_schema", "promotion_result_schema", "promotion_receipt_schema", diff --git a/tests/control_plane/canonical_authority_fixture.py b/tests/control_plane/canonical_authority_fixture.py index 38d91e6870..0f008b14ff 100644 --- a/tests/control_plane/canonical_authority_fixture.py +++ b/tests/control_plane/canonical_authority_fixture.py @@ -48,3 +48,22 @@ def isolate_sqlite_runtime(tmp_path, monkeypatch): # Each CLI subprocess resolves its own tempfile root from this environment. for variable in ("TMPDIR", "TEMP", "TMP"): monkeypatch.setenv(variable, str(tmp_path)) + + +def single_snapshot_page(result: dict, goal_id: str = "goal-a") -> dict: + """Encode small provider read fixtures as one complete transport page.""" + return { + "schema_version": "loopx_canonical_snapshot_page_result_v0", "status": "page", + "snapshot": {"goal_id": goal_id, "store_identity": "fixture-store", + "provider_revision": result["provider_revision"], "cursor": result["cursor"], + "query_sha256": "fixture-query", "todo_count": len(result["todos"]), + "lease_count": len(result.get("leases", []))}, + "metadata": {key: result[key] for key in ( + "todo_read_model", "goal_acceptance_contract", "handoff_mode", "projection_readback" + ) if key in result}, + **{key: result[key] for key in ( + "todos", "leases", "goal_acceptance_work_guards", "source_authority", + "decision_read_from_provider", "legacy_fallback_used" + ) if key in result}, + "next": None, + } diff --git a/tests/control_plane/test_canonical_snapshot.py b/tests/control_plane/test_canonical_snapshot.py new file mode 100644 index 0000000000..b5b1a5a065 --- /dev/null +++ b/tests/control_plane/test_canonical_snapshot.py @@ -0,0 +1,230 @@ +"""Cross-language transport admission, independent of Todo domain validation.""" + +from copy import deepcopy + +import pytest + +from loopx.control_plane.coordination.canonical_snapshot import ( + METHOD, + RESULT_SCHEMA, + read_canonical_snapshot, +) + + +def pages(): + snapshot = { + "goal_id": "goal-a", + "store_identity": "store-a", + "provider_revision": "r1", + "cursor": "1", + "query_sha256": "query-a", + "todo_count": 3, + "lease_count": 1, + } + metadata = { + "todo_read_model": {"todo_count": 3}, + "handoff_mode": "native", + "goal_acceptance_contract": {"enabled": True}, + } + base = { + "schema_version": RESULT_SCHEMA, + "status": "page", + "snapshot": snapshot, + "metadata": metadata, + "source_authority": "sqlite_v0", + "decision_read_from_provider": True, + "legacy_fallback_used": False, + } + return deepcopy( + [ + { + **base, + "todos": [ + {"todo_id": "a", "text": "保留🙂"}, + {"todo_id": "b", "archive_state": "archived"}, + ], + "leases": [], + "goal_acceptance_work_guards": {"a": {"allowed": False}}, + "next": {"snapshot": snapshot, "todo_offset": 2, "lease_offset": 0}, + }, + { + **base, + "todos": [{"todo_id": "c", "depends_on": ["b"]}], + "leases": [{"todo_id": "a", "version": 4}], + "goal_acceptance_work_guards": {"c": {"allowed": True}}, + "next": None, + }, + ] + ) + + +def read(responses, *, include_leases=True): + calls = [] + + def rpc(method, request, *, timeout): + assert method == METHOD + assert request["include_leases"] is include_leases + assert timeout == 15 + assert request["after"] == (None if not calls else calls[-1]["next"]) + response = responses[len(calls)] + calls.append(deepcopy(response)) + return response + + result = read_canonical_snapshot( + rpc=rpc, + runtime_root="/disposable", + goal_id="goal-a", + include_leases=include_leases, + projection_readback=None, + timeout=15, + ) + return result, calls + + +def test_complete_snapshot_preserves_archives_guards_and_leases(): + source = pages() + result, calls = read(source) + assert len(calls) == 2 + assert result["status"] == "loaded" + assert result["todos"] == source[0]["todos"] + source[1]["todos"] + assert result["todo_ids"] == ["a", "b", "c"] + assert result["leases"] == source[1]["leases"] + assert result["goal_acceptance_work_guards"] == { + "a": {"allowed": False}, + "c": {"allowed": True}, + } + assert result["provider_revision"] == "r1" + + +@pytest.mark.parametrize("status", ["failed", "unavailable", "missing"]) +def test_later_page_failure_discards_all_earlier_records(status): + source = pages() + source[1] = { + "status": status, + "reason_code": "canonical_snapshot_changed", + "reason": "restart", + "todos": [{"todo_id": "untrusted-partial"}], + } + result, calls = read(source) + assert len(calls) == 2 + assert result == { + "status": status, + "reason_code": "canonical_snapshot_changed", + "reason": "restart", + } + + +@pytest.mark.parametrize( + "mutation", + [ + "revision", + "identity", + "query", + "metadata", + "source", + "duplicate", + "out_of_order", + "premature_end", + "over_count", + "non_integer_count", + "skipped_offset", + "boolean_offset", + "no_progress", + "leases_first", + "foreign_guard", + "missing_next", + "fallback", + "unpaged", + "metadata_injection", + "non_object", + "missing_rows", + "foreign_goal", + "missing_snapshot", + ], +) +def test_malformed_or_mixed_pages_never_escape_as_partial_success(mutation): + source = pages() + # Break aliasing deliberately: changing a later page must not mutate the first snapshot. + source = [deepcopy(page) for page in source] + first, last = source + if mutation in {"revision", "identity", "query"}: + last["snapshot"][ + { + "revision": "provider_revision", + "identity": "store_identity", + "query": "query_sha256", + }[mutation] + ] = "other" + elif mutation == "metadata": + last["metadata"]["handoff_mode"] = "legacy" + elif mutation == "source": + last["source_authority"] = "file_v0" + elif mutation == "duplicate": + last["todos"][0]["todo_id"] = "b" + elif mutation == "out_of_order": + first["todos"].reverse() + elif mutation == "premature_end": + first["next"] = None + elif mutation == "over_count": + last["todos"].append({"todo_id": "d"}) + elif mutation == "non_integer_count": + first["snapshot"]["todo_count"] = True + elif mutation == "skipped_offset": + first["next"]["todo_offset"] = 3 + elif mutation == "boolean_offset": + first["next"]["lease_offset"] = False + elif mutation == "no_progress": + first["todos"] = [] + first["goal_acceptance_work_guards"] = {} + first["next"]["todo_offset"] = 0 + elif mutation == "leases_first": + first["leases"] = [{"todo_id": "a"}] + elif mutation == "foreign_guard": + last["goal_acceptance_work_guards"]["a"] = {"allowed": True} + elif mutation == "missing_next": + del last["next"] + elif mutation == "fallback": + last["legacy_fallback_used"] = True + elif mutation == "unpaged": + last["status"] = "loaded" + elif mutation == "metadata_injection": + last["metadata"]["todos"] = [] + elif mutation == "non_object": + source[1] = [] + elif mutation == "missing_rows": + del last["todos"] + elif mutation == "foreign_goal": + last["snapshot"]["goal_id"] = "goal-b" + elif mutation == "missing_snapshot": + del last["snapshot"] + result, _ = read(source) + assert result["status"] == "failed" + assert result["reason_code"] == "canonical_snapshot_result_invalid" + assert "todos" not in result and "leases" not in result + + +def test_empty_snapshot_is_complete_without_legacy_fallback(): + page = pages()[0] + page["snapshot"].update(todo_count=0, lease_count=0) + page.update(todos=[], leases=[], goal_acceptance_work_guards={}, next=None) + page["metadata"]["todo_read_model"]["todo_count"] = 0 + result, calls = read([page]) + assert len(calls) == 1 + assert result["status"] == "loaded" and result["todos"] == [] + assert result["legacy_fallback_used"] is False + + +def test_todo_only_read_rejects_unrequested_lease_population(): + result, _ = read(pages(), include_leases=False) + assert result["reason_code"] == "canonical_snapshot_result_invalid" + + +def test_unicode_order_is_code_point_order_across_runtimes(): + source = pages() + source[0]["todos"][0]["todo_id"] = "z" + source[0]["todos"][1]["todo_id"] = "\ue000" + source[1]["todos"][0]["todo_id"] = "🙂" + for page in source: + page["goal_acceptance_work_guards"] = {} + result, _ = read(source) + assert result["todo_ids"] == ["z", "\ue000", "🙂"] diff --git a/tests/control_plane/test_canonical_snapshot_integration.py b/tests/control_plane/test_canonical_snapshot_integration.py new file mode 100644 index 0000000000..9e2cf8b790 --- /dev/null +++ b/tests/control_plane/test_canonical_snapshot_integration.py @@ -0,0 +1,207 @@ +"""Production RPC and CLI over real File/SQLite, including failed mixed reads.""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest +from canonical_authority_fixture import ( + initialize_canonical_authority, + isolate_sqlite_runtime, +) + +from loopx.control_plane.coordination import local_authority +from loopx.control_plane.coordination.local_authority import ( + LocalCoordinationAuthorityUnavailable, +) +from loopx.control_plane.coordination.runtime_shadow import ( + build_todo_runtime_shadow_projection, +) +from loopx.control_plane.effect_runtime import MAX_RESPONSE_BYTES, effect_runtime_result +from loopx.control_plane.todos import provider_projection + +REPO = Path(__file__).resolve().parents[2] + + +@pytest.fixture(params=["file", "sqlite"]) +def wide_goal(tmp_path, monkeypatch, request): + isolate_sqlite_runtime(tmp_path, monkeypatch) + runtime, state, registry = ( + tmp_path / "runtime", + tmp_path / "state.md", + tmp_path / "registry.json", + ) + state.write_text( + "# Snapshot recovery\n\nHuman narrative survives.\n\n## Agent Todo\n" + ) + registry.write_text( + json.dumps( + { + "common_runtime_root": str(runtime), + "goals": [ + { + "id": "goal-a", + "repo": str(tmp_path), + "state_file": state.name, + "status": "active", + }, + ], + } + ) + ) + records = [ + { + "schema_version": "todo_item_v0", + "todo_id": f"todo_{index:03}", + "role": "agent", + "status": "open", + "done": False, + "text": f"Retained work {index}", + "note": "完整🙂" * 1600, + "archive_state": "active", + "source_section": "Agent Todo", + "index": index + 1, + "task_class": "advancement_task", + } + for index in range(160) + ] + projection = build_todo_runtime_shadow_projection( + goal_id="goal-a", todos=records, leases=[], handoff_mode="soft_claim" + ) + assert len(json.dumps(projection, ensure_ascii=False).encode()) > MAX_RESPONSE_BYTES + initialize_canonical_authority( + runtime, "goal-a", projection, state_path=state, provider=request.param + ) + return runtime, state, registry, projection + + +def native_read(runtime): + return local_authority.read_canonical_todos_if_promoted( + runtime_root=runtime, goal_id="goal-a", include_leases=True + ) + + +def cli(registry, *args): + process = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "--registry", + str(registry), + "--format", + "json", + *args, + ], + cwd=REPO, + text=True, + capture_output=True, + timeout=60, + check=True, + ) + return json.loads(process.stdout) + + +def test_real_rpc_keeps_budget_and_cli_recovers_complete_display( + wide_goal, monkeypatch +): + runtime, state, registry, projection = wide_goal + # Same stored workload: old one-shot endpoint really crosses the fixed budget. + with pytest.raises(RuntimeError) as oversized: + effect_runtime_result( + "coordination.local_authority.todo_list", + { + "schema_version": "loopx_local_coordination_todo_list_request_v0", + "runtime_root": str(runtime), + "goal_id": "goal-a", + "include_leases": True, + }, + timeout=15, + ) + assert "response is oversized" in str(oversized.value.__cause__) + measured = [] + + def capture(method, payload, **kwargs): + result = effect_runtime_result(method, payload, **kwargs) + measured.append( + len(json.dumps(result, ensure_ascii=False, separators=(",", ":")).encode()) + ) + return result + + with monkeypatch.context() as patch: + patch.setattr(local_authority, "effect_runtime_result", capture) + result = native_read(runtime) + assert len(measured) > 1 and max(measured) <= 1792 * 1024 + assert result["todos"] == projection["todos"] + before_revision = result["provider_revision"] + listed = cli(registry, "todo", "list", "--goal-id", "goal-a", "--role", "agent") + assert len(listed["todos"]) == 160 + assert {row["todo_id"] for row in listed["todos"]} == { + row["todo_id"] for row in projection["todos"] + } + delivered = provider_projection.project_current_canonical_todos( + registry_path=registry, runtime_root=runtime, goal_id="goal-a" + ) + assert delivered["status"] in {"delivered", "current"} + rendered = state.read_text() + assert "Human narrative survives." in rendered + for record in projection["todos"]: + assert f"todo_id={record['todo_id']} " in rendered + assert native_read(runtime)["provider_revision"] == before_revision + # Missing Markdown must not prevent the canonical CLI read. + state.unlink() + assert ( + len( + cli(registry, "todo", "list", "--goal-id", "goal-a", "--role", "agent")[ + "todos" + ] + ) + == 160 + ) + + +def test_real_overlapping_commit_has_no_partial_or_legacy_success( + wide_goal, monkeypatch +): + runtime, state, _, _ = wide_goal + original_display = state.read_bytes() + calls = [] + + def overlap(method, payload, **kwargs): + result = effect_runtime_result(method, payload, **kwargs) + calls.append(result) + if len(calls) == 1: + # A real writer wins between pages, with unchanged Todo data. Revision still matters. + script = """import {openLocalAuthorityStore} from './loopx/control_plane/coordination/local_authority_provider.ts'; +const store=await openLocalAuthorityStore(process.argv[1],'goal-a');const h=await store.loadAuthority(); +const r=await store.commitAuthority({expected_provider_revision:h.provider_revision,operation_id:'between-pages', +next_projection:h.head,events:[],receipts:[]});if(r.status!=='applied')throw new Error(JSON.stringify(r));""" + subprocess.run( + [ + "node", + "--no-warnings", + "--experimental-strip-types", + "--input-type=module", + "-e", + script, + str(runtime), + ], + cwd=REPO, + check=True, + capture_output=True, + timeout=45, + ) + return result + + with monkeypatch.context() as patch: + patch.setattr(local_authority, "effect_runtime_result", overlap) + with pytest.raises(LocalCoordinationAuthorityUnavailable) as error: + native_read(runtime) + assert error.value.code == "canonical_snapshot_changed" + assert "todos" not in error.value.payload + assert len(calls) == 2 + assert state.read_bytes() == original_display + fresh = native_read(runtime) + assert len(fresh["todos"]) == 160 + assert fresh["provider_revision"] != calls[0]["snapshot"]["provider_revision"] diff --git a/tests/control_plane/test_coordination_state_contract.py b/tests/control_plane/test_coordination_state_contract.py index 5e0fda9734..f19fef5031 100644 --- a/tests/control_plane/test_coordination_state_contract.py +++ b/tests/control_plane/test_coordination_state_contract.py @@ -29,7 +29,7 @@ LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA, LOCAL_AUTHORITY_SHADOW_OUTBOX_ENTRY_SCHEMA, LOCAL_AUTHORITY_SHADOW_REQUEST_SCHEMA, - LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA, + LOCAL_COORDINATION_TODO_SNAPSHOT_PAGE_REQUEST_SCHEMA, LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA, ) from loopx.control_plane.turn_driver import delivery_continuity @@ -43,8 +43,8 @@ from loopx.control_plane.coordination.legacy_writer_fence import ( LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA as BRIDGE_WRITE_CHECK_REQUEST_SCHEMA, ) -from loopx.control_plane.coordination.local_authority import ( - LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA as BRIDGE_LIST_REQUEST_SCHEMA, +from loopx.control_plane.coordination.canonical_snapshot import ( + REQUEST_SCHEMA as BRIDGE_LIST_REQUEST_SCHEMA, ) @@ -172,7 +172,7 @@ def test_domain_projection_split_keeps_archival_as_a_task_fact() -> None: def test_python_bridge_uses_generated_local_authority_protocol_schemas() -> None: - assert BRIDGE_LIST_REQUEST_SCHEMA == LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA + assert BRIDGE_LIST_REQUEST_SCHEMA == LOCAL_COORDINATION_TODO_SNAPSHOT_PAGE_REQUEST_SCHEMA def test_python_shadow_bridges_use_generated_protocol_schemas() -> None: diff --git a/tests/control_plane/test_local_coordination_authority.py b/tests/control_plane/test_local_coordination_authority.py index 75c6d38190..32fe2d3e1c 100644 --- a/tests/control_plane/test_local_coordination_authority.py +++ b/tests/control_plane/test_local_coordination_authority.py @@ -8,7 +8,7 @@ from threading import Barrier import pytest -from canonical_authority_fixture import initialize_canonical_authority +from canonical_authority_fixture import initialize_canonical_authority, single_snapshot_page from loopx.control_plane.coordination import local_authority as local_authority_module from loopx.control_plane.coordination.coordination_state_contract import ( @@ -150,7 +150,7 @@ def test_engaged_fence_reads_typescript_provider_result( def _read(method: str, _params: object, *, timeout: float) -> dict[str, object]: calls.append((method, timeout)) - return { + return single_snapshot_page({ "status": "loaded", "todos": [{"todo_id": "todo_a", "role": "agent", "status": "open"}], "todo_read_model": _todo_read_model(1), @@ -159,7 +159,7 @@ def _read(method: str, _params: object, *, timeout: float) -> dict[str, object]: "source_authority": "file_v0", "decision_read_from_provider": True, "legacy_fallback_used": False, - } + }) monkeypatch.setattr( "loopx.control_plane.coordination.local_authority.effect_runtime_result", @@ -171,7 +171,7 @@ def _read(method: str, _params: object, *, timeout: float) -> dict[str, object]: ) assert result is not None assert result["todos"][0]["todo_id"] == "todo_a" - assert calls == [("coordination.local_authority.todo_list", 15.0)] + assert calls == [("coordination.local_authority.todo_snapshot_page", 15.0)] def test_promoted_claim_adapter_invokes_typescript_without_markdown_fallback( @@ -1149,7 +1149,7 @@ def test_todo_list_uses_provider_after_cutover_even_when_markdown_disagrees( state_file.unlink() monkeypatch.setattr( "loopx.control_plane.coordination.local_authority.effect_runtime_result", - lambda method, params, **_kwargs: { + lambda method, params, **_kwargs: single_snapshot_page({ "status": "loaded", "todos": [ { @@ -1165,7 +1165,7 @@ def test_todo_list_uses_provider_after_cutover_even_when_markdown_disagrees( "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") @@ -1510,12 +1510,12 @@ def count_authority_runtime_call( # The extra bounded crossing admits/replays before resolving private argv. assert terminal_phases == ["resolve_validation", "execute_validation", "applied"] assert runtime_calls == [ - "coordination.local_authority.todo_list", + "coordination.local_authority.todo_snapshot_page", "coordination.local_authority.todo_terminal", "coordination.local_authority.todo_terminal", "coordination.local_authority.todo_terminal", - "coordination.local_authority.todo_list", - "coordination.local_authority.todo_list", + "coordination.local_authority.todo_snapshot_page", + "coordination.local_authority.todo_snapshot_page", ] successor_id = completed["generated_successor_todo_ids"][0] @@ -1536,10 +1536,10 @@ def count_authority_runtime_call( assert superseded["superseded"] is True assert superseded["projection_delivery"] == "delivered" assert runtime_calls == [ - "coordination.local_authority.todo_list", + "coordination.local_authority.todo_snapshot_page", "coordination.local_authority.todo_terminal", - "coordination.local_authority.todo_list", - "coordination.local_authority.todo_list", + "coordination.local_authority.todo_snapshot_page", + "coordination.local_authority.todo_snapshot_page", ] canonical = read_canonical_todos_if_promoted( @@ -1567,10 +1567,10 @@ def count_authority_runtime_call( assert archived["moved_count"] == 2 assert archived["projection_delivery"] == "delivered" assert runtime_calls == [ - "coordination.local_authority.todo_list", + "coordination.local_authority.todo_snapshot_page", "coordination.local_authority.todo_archive", - "coordination.local_authority.todo_list", - "coordination.local_authority.todo_list", + "coordination.local_authority.todo_snapshot_page", + "coordination.local_authority.todo_snapshot_page", "coordination.local_authority.todo_archive_ack", ] canonical_after_archive = read_canonical_todos_if_promoted( @@ -1600,10 +1600,10 @@ def count_authority_runtime_call( assert no_change["moved_count"] == 0 assert no_change["provider_revision"] == archive_revision assert runtime_calls == [ - "coordination.local_authority.todo_list", + "coordination.local_authority.todo_snapshot_page", "coordination.local_authority.todo_archive", - "coordination.local_authority.todo_list", - "coordination.local_authority.todo_list", + "coordination.local_authority.todo_snapshot_page", + "coordination.local_authority.todo_snapshot_page", ] unchanged = read_canonical_todos_if_promoted( runtime_root=runtime_root, @@ -2037,7 +2037,7 @@ def _crash_projection(*_args: object, **_kwargs: object) -> dict[str, object]: with pytest.raises(OSError, match="projection delivery crash"): complete_goal_todo(**request) assert runtime_calls == [ - "coordination.local_authority.todo_list", + "coordination.local_authority.todo_snapshot_page", "coordination.local_authority.todo_terminal", ] @@ -2050,12 +2050,12 @@ def _crash_projection(*_args: object, **_kwargs: object) -> dict[str, object]: assert replay["provider_status"] == "replayed" assert replay["idempotent_replay"] is True assert runtime_calls == [ - "coordination.local_authority.todo_list", + "coordination.local_authority.todo_snapshot_page", "coordination.local_authority.todo_terminal", - "coordination.local_authority.todo_list", + "coordination.local_authority.todo_snapshot_page", "coordination.local_authority.todo_terminal", - "coordination.local_authority.todo_list", - "coordination.local_authority.todo_list", + "coordination.local_authority.todo_snapshot_page", + "coordination.local_authority.todo_snapshot_page", ] canonical = read_canonical_todos_if_promoted( runtime_root=runtime_root, goal_id="goal-a" diff --git a/tests/control_plane/test_todo_projection_concurrency.py b/tests/control_plane/test_todo_projection_concurrency.py index 5635469071..f434ef1eca 100644 --- a/tests/control_plane/test_todo_projection_concurrency.py +++ b/tests/control_plane/test_todo_projection_concurrency.py @@ -246,7 +246,7 @@ def test_downlevel_runtime_cannot_acknowledge_delivery( def without_confirmation(method, payload, **kwargs): result = invoke(method, payload, **kwargs) if payload.get("projection_readback") is not None: - result.pop("projection_readback", None) + result.get("metadata", {}).pop("projection_readback", None) return result with monkeypatch.context() as patch: diff --git a/tests/control_plane_ts/authority_store_conformance.ts b/tests/control_plane_ts/authority_store_conformance.ts index 0a6b3b29cc..56db7e0afd 100644 --- a/tests/control_plane_ts/authority_store_conformance.ts +++ b/tests/control_plane_ts/authority_store_conformance.ts @@ -1,3 +1,4 @@ +import {registerCanonicalSnapshotConformance} from "./canonical_snapshot_conformance.ts"; import {registerClaimAcquisitionProofConformance} from "./claim_acquisition_proof_conformance.ts"; import {registerCommandObservationConformance} from "./command_observation_conformance.ts"; import {registerPeriodicReportConformance} from "./periodic_report_conformance.ts"; @@ -272,6 +273,7 @@ export function registerAuthorityStoreConformance( registerAuthoritySourceConformance(providerName, factory); registerHandoffModeConformance(providerName, factory); registerPromotionRecoveryConformance(providerName, factory); + registerCanonicalSnapshotConformance(providerName, factory); for (const native of [false, true]) test(`${providerName} conformance: standing revocation survives canonical ordering and archive (${native ? "native" : "legacy"})`, async (t) => { const {store} = await factory(t); const goal = "goal-standing"; diff --git a/tests/control_plane_ts/canonical_snapshot_conformance.ts b/tests/control_plane_ts/canonical_snapshot_conformance.ts new file mode 100644 index 0000000000..bf178fa30d --- /dev/null +++ b/tests/control_plane_ts/canonical_snapshot_conformance.ts @@ -0,0 +1,164 @@ +/** One snapshot contract on every real provider; no fake paging backend. */ +import assert from "node:assert/strict"; +import test from "node:test"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import type {AuthorityStore} from "../../loopx/control_plane/coordination/authority_store.ts"; +import {coordinationTodoReadModel} from "../../loopx/control_plane/coordination/coordination_projection.ts"; +import {CANONICAL_SNAPSHOT_PAGE_REQUEST, CANONICAL_SNAPSHOT_PAGE_BYTES, + readCanonicalSnapshotFromStore} from "../../loopx/control_plane/coordination/canonical_snapshot_page.ts"; +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import {normalizeGoalAcceptanceDocument} from "../../loopx/control_plane/goals/acceptance_contract.ts"; +import {productionScaleCoordinationFixture} from "./production_scale_coordination_fixture.ts"; +import type {AuthorityStoreConformanceFactory} from "./authority_store_conformance.ts"; + +/** Reuse the mixed production population and widen retained notes to cross the + * actual RPC ceiling. This stresses bytes without inventing more domain rows. */ +export function snapshotFixture(shape: "native" | "legacy" = "native") { + const fixture = productionScaleCoordinationFixture("goal-a", shape); + const rows = fixture.projection.todos as JsonObject[]; + for (const row of rows) row.note = `${row.note ?? ""} ${"Retained full record 文🙂 ".repeat(240)}`; + fixture.projection.todo_read_model = coordinationTodoReadModel(rows, + String((fixture.projection.todo_read_model as JsonObject).schema_version)); + assert.ok(Buffer.byteLength(JSON.stringify(fixture.projection)) > 2 * 1024 * 1024); + return fixture; +} + +export function snapshotRequest(overrides: JsonObject = {}): JsonObject { + return {schema_version: CANONICAL_SNAPSHOT_PAGE_REQUEST, runtime_root: "/disposable", + goal_id: "goal-a", include_leases: true, projection_readback: null, after: null, ...overrides}; +} +export async function seedSnapshot(store: AuthorityStore, projection: JsonObject): Promise { + const result = await store.commitAuthority({expected_provider_revision: null, operation_id: "snapshot-seed", + next_projection: projection, events: [], receipts: []}); + assert.equal(result.status, "applied", JSON.stringify(result)); +} +export async function collectSnapshot(store: AuthorityStore, request = snapshotRequest()) { + const pages: JsonObject[] = [], todos: JsonObject[] = [], leases: JsonObject[] = []; + let after: unknown = null; + do { + const page = await readCanonicalSnapshotFromStore({...request, after}, store); + assert.equal(page.status, "page", JSON.stringify(page)); + assert.ok(Buffer.byteLength(JSON.stringify(page), "utf8") <= CANONICAL_SNAPSHOT_PAGE_BYTES); + if (pages.length) { + assert.deepEqual(page.snapshot, pages[0].snapshot); + assert.deepEqual(page.metadata, pages[0].metadata); + } + pages.push(page); + todos.push(...page.todos as JsonObject[]); + leases.push(...(page.leases ?? []) as JsonObject[]); + after = page.next; + assert.ok(pages.length <= 100, "small conformance fixture must progress"); + } while (after !== null); + return {pages, todos, leases}; +} + +export function registerCanonicalSnapshotConformance(name: string, factory: AuthorityStoreConformanceFactory): void { + for (const shape of ["native", "legacy"] as const) { + test(`${name}: paged canonical ${shape} reads retain the complete complex population`, async t => { + const {store} = await factory(t); + const fixture = snapshotFixture(shape); + await seedSnapshot(store, fixture.projection); + const before = await store.loadAuthority(); + const result = await collectSnapshot(store); + assert.ok(result.pages.length > 1, "fixture must cross a page boundary"); + assert.equal(result.todos.length, fixture.expected_initial_todo_count); + assert.equal(result.leases.length, fixture.expected_current_lease_count); + const byId = (rows: JsonObject[]) => new Map(rows.map(row => [row.todo_id, row])); + assert.deepEqual(byId(result.todos), byId(fixture.projection.todos as JsonObject[])); + assert.deepEqual(byId(result.leases), byId(fixture.projection.leases as JsonObject[])); + assert.deepEqual(await store.loadAuthority(), before, "pagination is read-only"); + const todoOnly = await collectSnapshot(store, snapshotRequest({include_leases: false})); + assert.deepEqual(todoOnly.todos, result.todos); + assert.deepEqual(todoOnly.leases, []); + assert.ok(todoOnly.pages.every(page => !("leases" in page))); + }); + } + + test(`${name}: an overlapping commit rejects continuation even when records did not change`, async t => { + const {store, contender} = await factory(t); + const fixture = snapshotFixture(); + await seedSnapshot(store, fixture.projection); + const first = await readCanonicalSnapshotFromStore(snapshotRequest(), store); + assert.notEqual(first.next, null); + const head = await contender.loadAuthority(); + assert.equal(head.status, "loaded"); + if (head.status !== "loaded") throw new Error("missing seeded state"); + assert.equal((await contender.commitAuthority({expected_provider_revision: head.provider_revision, + operation_id: "concurrent-commit", next_projection: head.head, events: [], receipts: []})).status, "applied"); + await assert.rejects(readCanonicalSnapshotFromStore(snapshotRequest({after: first.next}), store), + {code: "canonical_snapshot_changed"}); + const restarted = await collectSnapshot(store); + assert.notDeepEqual(restarted.pages[0].snapshot, first.snapshot); + }); + + test(`${name}: continuation binds query, Goal, store incarnation and revision`, async t => { + const {store} = await factory(t); + await seedSnapshot(store, snapshotFixture().projection); + const first = await readCanonicalSnapshotFromStore(snapshotRequest(), store); + for (const key of ["goal_id", "store_identity", "provider_revision", "cursor", "query_sha256", "todo_count", "lease_count"]) { + const next = structuredClone(first.next) as JsonObject; + const identity = next.snapshot as JsonObject; + identity[key] = key.endsWith("count") ? Number(identity[key]) + 1 : "foreign"; + await assert.rejects(readCanonicalSnapshotFromStore(snapshotRequest({after: next}), store), + {code: "canonical_snapshot_changed"}, key); + } + for (const changed of [{include_leases: false}, {projection_readback: {provider_revision: "other", changed: true}}]) { + await assert.rejects(readCanonicalSnapshotFromStore(snapshotRequest({...changed, after: first.next}), store), + {code: "canonical_snapshot_changed"}); + } + }); + + test(`${name}: an empty canonical snapshot is a complete answer`, async t => { + const {store} = await factory(t); + const fixture = snapshotFixture().projection; + fixture.todos = []; fixture.leases = []; + fixture.todo_read_model = coordinationTodoReadModel([], "loopx_todo_domain_read_record_v0"); + await seedSnapshot(store, fixture); + const result = await collectSnapshot(store); + assert.equal(result.pages.length, 1); + assert.equal(result.pages[0].next, null); + assert.deepEqual(result.todos, []); + assert.deepEqual(result.leases, []); + }); + + test(`${name}: projection confirmation belongs to the same snapshot as every page`, async t => { + const {store} = await factory(t); + await seedSnapshot(store, snapshotFixture().projection); + const head = await store.loadAuthority(); + if (head.status !== "loaded") throw new Error("missing seeded head"); + for (const [revision, changed, expected] of [[head.provider_revision, false, "current"], + [head.provider_revision, true, "delivered"], ["stale", true, "pending"]] as const) { + const result = await collectSnapshot(store, snapshotRequest({projection_readback: {provider_revision: revision, changed}})); + assert.ok(result.pages.every(page => ((page.metadata as JsonObject).projection_readback as JsonObject).status === expected)); + } + }); + test(`${name}: enabled acceptance guards stay attached to their own page`, async t => { + const {store} = await factory(t); + const projection = snapshotFixture().projection; + const document = normalizeGoalAcceptanceDocument({objective: "Verify all required work", non_goals: [], + criteria: [{id: "criterion-a", description: "Required validation succeeds", validation_argv: ["true"], validation_timeout_seconds: 10}], + bindings: []}); + // Deliberately make every row applicable, so missing a page guard cannot hide + // behind the complex fixture's unrelated maintenance/terminal work classes. + projection.todos = (projection.todos as JsonObject[]).map(row => ({...row, + role: "agent", task_class: "advancement_task", status: "open", done: false, archive_state: "active"})); + projection.todo_read_model = coordinationTodoReadModel(projection.todos as JsonObject[], "loopx_todo_domain_read_record_v0"); + projection.goal_acceptance = {schema_version: "loopx_goal_acceptance_v0", enabled: true, + revision: 1, digest: canonicalAuthoritySha256(document), document, verification: null, bindings: []}; + await seedSnapshot(store, projection); + const result = await collectSnapshot(store); + const seen = new Set(); + for (const page of result.pages) { + const guards = page.goal_acceptance_work_guards as JsonObject; + const ids = (page.todos as JsonObject[]).map(row => String(row.todo_id)); + assert.deepEqual(Object.keys(guards).sort(), [...ids].sort()); + for (const id of ids) { + assert.equal(seen.has(id), false); + seen.add(id); + } + assert.equal(((page.metadata as JsonObject).goal_acceptance_contract as JsonObject).enabled, true); + } + assert.equal(seen.size, result.todos.length); + }); + +} diff --git a/tests/control_plane_ts/canonical_snapshot_page.test.ts b/tests/control_plane_ts/canonical_snapshot_page.test.ts new file mode 100644 index 0000000000..23db65803a --- /dev/null +++ b/tests/control_plane_ts/canonical_snapshot_page.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {mkdtemp, rm, readdir} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; +import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; +import {coordinationTodoReadModel} from "../../loopx/control_plane/coordination/coordination_projection.ts"; +import {readCanonicalSnapshotFromStore, readCanonicalSnapshotPage, CANONICAL_SNAPSHOT_PAGE_BYTES} from "../../loopx/control_plane/coordination/canonical_snapshot_page.ts"; +import {productionScaleCoordinationFixture} from "./production_scale_coordination_fixture.ts"; +import {collectSnapshot, registerCanonicalSnapshotConformance, seedSnapshot, snapshotRequest, snapshotFixture} from "./canonical_snapshot_conformance.ts"; + +async function fixture(t: test.TestContext, kind: "file" | "sqlite" = "file") { + const root = await mkdtemp(join(tmpdir(), "canonical-snapshot-")); + t.after(() => rm(root, {recursive: true, force: true})); + const Store = kind === "file" ? FileAuthorityStore : SqliteAuthorityStore; + return {store: new Store(root, "goal-a"), contender: new Store(root, "goal-a")}; +} +for (const kind of ["file", "sqlite"] as const) { + registerCanonicalSnapshotConformance(kind, t => fixture(t, kind)); + test(`${kind}: a normal complete collection keeps a single RPC response`, async t => { + const {store} = await fixture(t, kind); + const source = productionScaleCoordinationFixture("goal-a", "native"); + await seedSnapshot(store, source.projection); + const result = await collectSnapshot(store); + assert.equal(result.pages.length, 1); + assert.equal(result.todos.length, source.expected_initial_todo_count); + assert.equal(result.leases.length, source.expected_current_lease_count); + }); + test(`${kind}: UTF-8 byte pages retain records beyond the old 2 MiB RPC ceiling`, async t => { + const {store} = await fixture(t, kind); + const projection = snapshotFixture().projection; + const todos = projection.todos as JsonObject[]; + assert.ok(Buffer.byteLength(JSON.stringify(projection)) > 2 * 1024 * 1024); + await seedSnapshot(store, projection); + const result = await collectSnapshot(store); + assert.deepEqual(new Map(result.todos.map(row => [row.todo_id, row])), new Map(todos.map(row => [row.todo_id, row]))); + assert.ok(result.pages.some(page => (page.todos as unknown[]).length < 4096 && page.next !== null)); + }); +} + +test("oversized single records fail explicitly instead of truncating or looping", async t => { + const {store} = await fixture(t); + const projection = snapshotFixture().projection; + const todos = projection.todos as JsonObject[]; + todos[0].note = "x".repeat(CANONICAL_SNAPSHOT_PAGE_BYTES); + projection.todo_read_model = coordinationTodoReadModel(todos, "loopx_todo_domain_read_record_v0"); + await seedSnapshot(store, projection); + const before = await store.loadAuthority(); + await assert.rejects(collectSnapshot(store), {code: "canonical_snapshot_record_too_large"}); + assert.deepEqual(await store.loadAuthority(), before); +}); + +for (const position of [null, -1, true, "128", 0.5]) { + test(`invalid continuation offset ${JSON.stringify(position)} is rejected`, async t => { + const {store} = await fixture(t); + await seedSnapshot(store, snapshotFixture().projection); + const first = await readCanonicalSnapshotFromStore(snapshotRequest(), store); + const after = structuredClone(first.next) as JsonObject; + after.todo_offset = position; + await assert.rejects(readCanonicalSnapshotFromStore(snapshotRequest({after}), store), {code: "canonical_snapshot_request_invalid"}); + }); +} + + +test("a missing runtime provider read does not initialize a replacement authority", async t => { + const root = await mkdtemp(join(tmpdir(), "canonical-missing-")); + t.after(() => rm(root, {recursive: true, force: true})); + const before = await readdir(root); + const result = await readCanonicalSnapshotPage(snapshotRequest({runtime_root: root})); + assert.equal(result.status, "unavailable"); + assert.equal(result.reason_code, "store_identity_unavailable"); + assert.deepEqual(await readdir(root), before); + assert.equal("todos" in result, false); +}); + +test("ordering uses Unicode code points rather than JavaScript default UTF-16 sorting", async t => { + const {store} = await fixture(t); + const projection = snapshotFixture().projection; + projection.todos = ["z", "\ue000", "🙂"].map(todo_id => ({ + schema_version: "todo_domain_record_v0", todo_id, role: "agent", status: "open", done: false, + text: "Preserve stable identity", task_class: "advancement_task", archive_state: "active"})); + projection.leases = []; + projection.todo_read_model = coordinationTodoReadModel(projection.todos as JsonObject[], "loopx_todo_domain_read_record_v0"); + await seedSnapshot(store, projection); + assert.deepEqual((await collectSnapshot(store)).todos.map(row => row.todo_id), ["z", "\ue000", "🙂"]); +}); From 4de0840c0fa5a6cc7a31b642a6fc96f6cd05a6ed Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:54:13 +0800 Subject: [PATCH 2/5] docs(rfc): define remaining local authority default delivery boundaries Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- ...shared-goal-authority-state-provider-v0.md | 84 ++++++++--------- ...-goal-authority-state-provider-v0.zh-CN.md | 40 +++++--- .../typescript-control-plane-migration-v0.md | 16 +++- ...script-control-plane-migration-v0.zh-CN.md | 13 ++- .../canonical-snapshot-pagination.md | 93 +++++++++++++++++++ 5 files changed, 186 insertions(+), 60 deletions(-) create mode 100644 docs/reference/canonical-snapshot-pagination.md diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index 122d050c87..e39b8a196e 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -36,7 +36,7 @@ after promotion. Legacy event-only claims reject rather than disappear at a Markdown boundary; event append locks protect the observation through writeback. Canonical changes reuse durable command receipt recovery. This is an L2/L3 compatibility correction with Python decision deletion, not cohort migration, -SQLite D2 completion or a default flip. The remaining 5–8 packages still depend +SQLite D2 completion or a default flip. The remaining 7–9 packages still depend on executor/consumer closure, qualification, integrated migration and onboarding. [Operation, repair and recovery](../../reference/handoff-mode.md). @@ -45,7 +45,7 @@ source and recovers historical receipts independently of private argv. Agent completion and Monitor stop share current-head display acknowledgement with ordinary edits. [Caller and recovery contract](../../reference/canonical-terminal-review.md). This advances L2/L5 without closing executor-held fences, D1–D3 or default -onboarding; the conditional 5–8-package estimate below remains unchanged. +onboarding; the conditional 7–9-package estimate below remains unchanged. The local registry witness now spans canonical create/claim/update/Monitor poll and terminal mutations through one TS owner. File, SQLite and service-injected @@ -3085,7 +3085,7 @@ explicit runtime-root applies to both intent and Todo IO. Frozen editorial requests retain their original basis. See [operation and boundaries](../../../loopx/capabilities/periodic_report/README.md#todo-authority-and-report-retries). This closes that T3/L5 consumer family, not D1 permanent display freshness, D2 durability, D3 whole-Goal qualification or default-provider selection. The -conditional 5–8 remaining delivery-package estimate is unchanged. +conditional 7–9 remaining delivery-package estimate is unchanged. Summary/work-lane counts now remain independent of display limits and retain incomplete-source knowledge through Agent scoping; canonical list acceptance holds match status. This closes one L5 read consumer, not permanent projection freshness or D1–D3. See [count semantics](../../reference/todo-work-counts.md). @@ -3178,7 +3178,7 @@ provenance across File/SQLite and service-owned PostgreSQL. Verified recovery copies do not select an authority, revive executors or roll back writer fences. This closes the portable recovery-artifact gap only; final source draining, fenced target adoption, later-write accounting and cohort cutover remain L8. -The conditional **5–8 package** estimate below is unchanged until the remaining +The conditional **7–9 package** estimate below is unchanged until the remaining caller, projection, D2, migration and default exits are qualified. Pending reviewed-promotion and command-recovery PRs must be integrated at their accepted heads rather than counted as merged prerequisites. @@ -3243,39 +3243,41 @@ or moving a helper is not by itself a package exit. | C / L8: Whole-Goal rehearsal and cohort migration | Integrate one exact revision/profile after L2–L7; drain capture, fence old writers, verify canonical readback and projection, then rehearse fenced export/rollback. | D3 evidence packet binds lineage, cursor, source digest, command coverage and profile. Existing Goal migration requires explicit cohort approval; no per-command split authority or stale Markdown revival. | | D / L9: New-Goal default and bounded retirement | A dedicated default-change PR makes new-Goal creation/onboarding choose the qualified local profile, including settings/readback, installer and packaged clients. Retire old business writers only as their final callers and migration window close. | L8's integrated product/rollback qualification; distinguish new Goal default from existing Goal migration. Publish compatibility/disable guidance, keep explicit provider choice, permanent rendering and validated import/export. T4 can continue after the default ships. | -**Cadence is evidence-based.** First reconcile the active stack, then deliver A -packages as complete operations while L6/L7 progress independently. B integrates -those contracts into complete user flows; C has one reproducible qualification -checkpoint; D changes the default in its own reviewable PR. After the linked -User completion slice, the 2026-09-20 planning estimate is **5–8 further cohesive -PRs**, conditional on the caller audit finding no additional missing effects: +**Cadence is evidence-based.** The 2026-09-23 decomposition separates bounded +canonical reads from projection/client closure and separates caller admission +from executor-held effects. This refines the older five broad packages into +**seven concrete PR boundaries (up to nine if D2 and migration each split)**, +including the snapshot-pagination PR. It is not a promise that a count of +merges qualifies default-on; unresolved acceptance evidence keeps its hold. -| Remaining work package | Estimated PRs | Exit | +| Ordered PR boundary | Owner / content | Decisive exit | | --- | --- | --- | -| Remaining L2/L3 caller and executor-effect fences | 1–2 | Actual CLI/Turn/Chat command inventory and external-effect boundary closure. | -| L5 / D1 consumer and projection closure | 1 | Full consumer parity, lag/recovery and packaged client readback. | -| L6 / SQLite D2 | 1–2, contributor-owned #4224 | Capacity, crash/restore and separately authorized elapsed-soak evidence on one profile. | -| L7 capture plus L8 integrated migration | 1–2 | Mixed-writer continuity, fenced whole-Goal rehearsal, export/rollback and cohort evidence. | -| L9 default and bounded retirement | 1 | New-Goal onboarding/settings/install choose the qualified profile; remove final obsolete callers. | - -The command-observation/current-proof closure removes a concrete L2/L3 concurrency hold. -The retained-Monitor cycle and grouped executor closure remove concrete L4 holds, not an entire -remaining package: the **5–8 PR planning range remains conditional**, rather than -subtracting one for a lifecycle fix. Actual remaining executor/caller coverage, -L5 consumers, contributor-owned D2, integrated migration and default onboarding -still determine the count. Python host execution/rendering is retained; this -slice removes duplicate TS admission knowledge without adding a Python twin. - -This counts delivery boundaries, not guaranteed merges or all Python deletion. -Scope may split only where a real effect/compatibility boundary warrants it. -Small Python business-rule deletions can ship with each TS owner; rendering, -private command execution and import/export keep their active adapters. - -截至 2026-09-20,关联 User 完成链路补齐后,按以上五类完整交付边界估算还需 **5–8 个 PR**。 -Monitor #4732 已合并,SQLite D2 仍归 #4224 contributor;其余顺序是调用方/执行围栏、 -消费与投影、capture 与整 Goal 演练,最后独立切换默认值。该估算以未发现更多缺失 -effect 为前提,不是合并数承诺,也不要求先删完 Python。TS owner 每收敛一块即可 -删除对应旧规则;仍有真实调用方的渲染、私有命令执行和导入导出适配器继续保留。 +| 1. Remaining canonical callers | L2/L4: inventory actual CLI/Turn/Chat callers, close retained leased metadata and delegated/effect-owned actions through existing typed transactions; remove replaced Python admission. | Each listed command succeeds or rejects coherently on legacy/File/SQLite, including authority and recovery negatives. | +| 2. External-effect execution fence | L3: hold and revalidate current execution proof over the real external effect, including takeover, timeout, process death and uncertain completion. | A stale executor cannot perform/settle a fenced effect; active execution and business receipt recovery retain one owner. | +| 3. Snapshot-bound canonical collection reads | T3/L5, this slice: paginate complete Todos, archives, leases and acceptance guards below the unchanged RPC limit; bind identity, revision, query and progress. | Real File/SQLite RPC/CLI and provider conformance preserve full populations; concurrent commits reject mixed reads. No active-Goal promotion is implied. | +| 4. Projection recovery and client closure | L5/D1: audit Turn/quota/Dashboard/Chat/Lark reads and use the existing outbox for permanent display freshness and repair. | Missing/stale/empty/pending displays and packaged-client interactions are verified; final post-promotion fallback callers retire. | +| 5. SQLite D2 qualification (1–2 PRs) | Contributor-owned #4224/#4328; reconcile the existing capacity PR before adding work. Complete crash/restore/upgrade/platform evidence on one exact profile. | Capacity ledger and separately authorized >=10-day elapsed synthetic soak pass; gaps remain holds. | +| 6. Capture plus whole-Goal migration/rollback (1–2 PRs) | L7/L8: combine mixed-writer/event continuity, drain, old-writer fencing, canonical readback, fenced export/rollback and cohort migration. | One D3 packet binds exact profile, lineage, source digest and command inventory; existing-Goal cohort cutover remains explicit. | +| 7. Default/onboarding and bounded Python retirement | L9/T4: make new-Goal creation, settings, installer and packaged clients choose the qualified local profile; publish migration/disable guidance and delete replaced final business writers. | Integrated L8 qualification, rollback and affected user-entrypoint readback. Preserve active rendering, host execution and import/export adapters. | + +After boundary 3 lands, **six planned PRs remain, potentially eight** under those +two named splits. Caller audit can expose additional missing effects, so this is +an implementation estimate, not a guarantee. Small Python business-rule +retirements accompany their TS owner; deleting all Python is neither the exit +condition nor a prerequisite. PostgreSQL service, credential, tenant, restore +and capacity qualification remains a separate medium-term lane; local default +need not wait for it, and portable conformance does not declare it production-ready. + +The snapshot protocol is documented in [canonical snapshot pagination](../../reference/canonical-snapshot-pagination.md). +It shares existing TS collection validation and acceptance ownership. Python +only assembles/validates the transport and keeps the old public result shape. +Per-page provider reads bound transmission, not database memory or total IO; +concurrent writers may require an explicit full-read restart. D1–D3 holds remain. + +截至 2026-09-23,原五类粗粒度工作细化为七个具体 PR 边界:caller、executor fence、 +一致性分页、投影与客户端、D2、整 Goal 迁移回滚、默认与有界 Python 退役。 +D2 和迁移各可按独立验收拆成两批,因此含本批共 7–9 个,分页合入后计划 6–8 个。 +这不是按合并数量推进资格;未通过的证据仍是 hold。PostgreSQL 运维资格独立推进。 Avoid concurrent edits to the same transaction owner; share fixture/contracts early and rebase after the owner lands. @@ -3311,12 +3313,10 @@ clear ownership to make storage migration appear ready. The saved-plan carrier must retain migration strategy, registered-agent facts and target digest when that extension is integrated. -The remaining default-on program is still approximately **5–8 cohesive PR -packages**, with scope rather than line counts determining the split: caller / -external-effect fencing (1–2), consumer/projection closure (1), contributor-owned -SQLite D2 (#4224, 1–2), integrated capture/whole-Goal acceptance (1–2), then default -onboarding plus bounded Python retirement (1). This slice contributes to the -integrated migration package; it does not count an entire package complete. +The current seven-boundary plan above separates caller admission, executor +fences and snapshot reads; D2 and integrated migration may each split. This +portable-recovery slice contributes to migration qualification, not an entire +completed package. Use that single current plan instead of counting leaf fixes. Actual elapsed soak cannot be compressed into a promised number of PRs. PostgreSQL service admission and operations remain a separate medium-term lane. @@ -3341,7 +3341,7 @@ source resume/succession and post-filter counts survive display limits. This is one L5 consumer closure, not D1 projection freshness or provider promotion. See [read semantics](../../reference/todo-work-counts.md). Remaining caller/executor, consumer recovery, contributor D2, capture/whole-Goal and default onboarding -boundaries retain the conditional **5–8 cohesive PR** estimate. +boundaries retain the conditional **7–9 cohesive PR** estimate. ## Appendix D: Execution ledger diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index 04b513b131..18b17ff6a5 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -31,7 +31,7 @@ 终结 caller 现将审核与验证绑定 canonical 来源,历史回执恢复不再依赖私有 argv。 Agent 完成和 Monitor 停止复用普通编辑的当前 head 显示确认。 [调用与恢复合同](../../reference/canonical-terminal-review.zh-CN.md)。此批推进 L2/L5, -未闭合 executor-held fence、D1–D3 或默认 onboarding,下文有条件的 5–8 批估算不变。 +未闭合 executor-held fence、D1–D3 或默认 onboarding,下文有条件的 7–9 批估算不变。 本地 registry witness 现经同一 TS owner 覆盖 canonical create/claim/update、 Monitor poll 与 terminal mutation;File、SQLite、service-injected PostgreSQL @@ -2532,19 +2532,31 @@ D1 交付确认现于 Markdown 耐久读回后核对 canonical revision。未固 | C/L8:整 Goal 演练与分组迁移 | L2–L7 后汇合一个精确 revision/profile;drain capture、fence 旧 writer、回读 canonical 与投影、演练 fenced export/rollback。 | D3 包绑定 lineage、cursor、source digest、命令覆盖和 profile;已有 Goal 分组迁移需明确批准,不能按命令拆 authority 或复活旧 Markdown。 | | D/L9:新 Goal 默认与有界退役 | 单独 default-change PR 让新建/onboarding 选择合格本地 profile,配齐 settings/readback、installer 和打包客户端;最后 caller 与迁移窗口退出才删除旧业务 writer。 | L8 整体产品/回滚资格;区分新 Goal 默认和已有 Goal 迁移。发布兼容/停用说明,保留显式 provider、永久 renderer 和合法 import/export。T4 可在默认启用后继续收尾。 | -**开发节奏以证据推进。** 先核对在途 stack,再按完整操作交付 A;L6/L7 可独立推进。 -B 汇合为完整用户流程,C 形成一次可复现资格检查点,D 用独立 PR 修改默认。 -按当前已合并边界,剩余 caller/executor 约 1–2 个包,consumer/投影 1 个, -contributor-owned D2 1–2 个,capture/整 Goal 演练 1–2 个,默认与有界删除 1 个; -相邻边界可在证据允许时合并,整体沿用英文 RFC 的 **5–8 个 PR** 条件估计。 -同一 transaction owner 避免并发重写,先共享 fixture/合同,owner 合入后再 rebase。 - -Monitor 周期事务关闭了 L4 的一个具体 hold,不等于关闭整个剩余交付包。 -当前 **5–8 个完整 PR** 的条件估计仍保留,不能按已提交的修复数量递减: -剩余 caller/executor、L5 consumer、contributor-owned D2、整 Goal 演练和默认 -onboarding 决定最终边界。本批收敛两套 TS 准入规则,未新增 Python twin; -仍有调用方的宿主执行、渲染与导入导出适配器继续保留。 +**开发节奏以证据推进。** 2026-09-23 将旧计划的五类粗粒度交付包细化为 +**七个明确 PR 边界;D2 和迁移各拆两批时最多九个**,其中包含本批一致性分页。 +独立列出分页与投影恢复、caller 与 executor effect,是为了让验收和回滚更清楚, +不是以合并数量替代资格证明。 +| 顺序 / PR | 归属与改动 | 退出证据 | +| --- | --- | --- | +| 1. canonical caller 收尾 | L2/L4:盘点 CLI/Turn/Chat,补齐带 lease 的 metadata、委托和 effect-owned 动作,调用既有 TS 事务,删除替代的 Python admission。 | 实际命令在 legacy/File/SQLite 成功与拒绝一致,含权限和恢复反例。 | +| 2. 外部 effect 执行围栏 | L3:执行真实外部 effect 期间持有并重新验证当前 execution proof,覆盖接管、超时、进程退出和不确定完成。 | 旧 executor 不能执行或结算被围栏的 effect;执行与业务 receipt 恢复仍有明确 owner。 | +| 3. canonical 一致性分页(本批) | T3/L5:完整 Todo、归档、lease 和验收 guard 分页,绑定 identity/revision/query/progress,不提高 RPC 上限。 | File/SQLite 真实 RPC/CLI 与跨 provider conformance 无丢失;并发提交拒绝混合版本,不代表活跃 Goal 已晋升。 | +| 4. 投影恢复与客户端闭合 | L5/D1:审计 Turn/quota/Dashboard/Chat/Lark,复用 outbox 完成永久展示新鲜度与恢复。 | 缺失、陈旧、权威空状态、pending 与打包客户端读回;删除最后的晋升后 fallback caller。 | +| 5. SQLite D2(1–2 PR) | #4224/#4328 contributor owner;先对齐已有 capacity PR,再补同一 profile 的 crash/restore/upgrade/platform 证据。 | capacity ledger 和单独授权、真实经过 >=10 天的合成 soak;缺失项仍为 hold。 | +| 6. capture 与整 Goal 迁移回滚(1–2 PR) | L7/L8:混合 writer/event 连续性、drain、旧 writer 围栏、canonical 读回、fenced export/rollback 和 cohort 迁移。 | 一份 D3 packet 绑定 profile/lineage/source digest/command inventory;存量 cohort 切换仍需明确授权。 | +| 7. 默认/onboarding 与有界 Python 退役 | L9/T4:新 Goal、settings、installer 与打包客户端选择合格本地 profile;发布迁移和停用指导,删除已替代的最后业务 writer。 | L8 整体验证、回滚和受影响入口读回;保留仍有调用方的渲染、宿主执行与 import/export。 | + +第 3 项合入后,按此计划还剩 **6 个主 PR,必要时 8 个**。caller 盘点可能揭示其他 +缺失 effect,因此这不是数量保证。Python 业务规则随 TS owner 收敛即可删除, +不把删除全部 Python 当作默认化门槛。PostgreSQL 的 service、credential、tenant、 +restore、capacity 资格是独立中期路线;跨 provider 测试通过不等于生产可切换。 + +本批的[分页合同](../../reference/canonical-snapshot-pagination.md)共用既有 TS +collection validation 与 acceptance owner;Python 只校验并组装传输,保持公开返回 +形状。每页重新读取 provider head,因此限制的是传输大小,不是数据库内存或总 IO; +并发 writer 可导致调用方完整重读。D1–D3 仍保留。同一 transaction owner 避免并发 +重写,先共享 fixture/合同,owner 合入后再 rebase。 L2/L3 命令盘点与 L6 缺失证据未闭合前,不给虚假的日历承诺。>=10 天 soak 是 **被测 profile 就绪之后**的真实时间下限,不是从写计划当天计时;明确授权后可与 @@ -2572,7 +2584,7 @@ User gate/action 及 Agent claim 范围规则;legacy 和 canonical 消费者 列表谓词已删除。完整来源上的 resume/succession 与筛选后的计数不受展示上限影响。 这只闭合 L5 的一个消费者,不代表 D1 永久新鲜度或 provider 晋升。见[读取合同](../../reference/todo-work-counts.md)。 剩余 caller/executor、consumer recovery、contributor D2、capture/整 Goal 演练和默认 -onboarding 仍按 **5–8 个完整 PR** 条件估计,不能按本次修复机械递减。 +onboarding 仍按 **7–9 个完整 PR** 条件估计,不能按本次修复机械递减。 ## 附录 D:执行账本 diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 176ba3ce40..119c96cdc1 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -22,6 +22,18 @@ Retain T0 caller/parity inventory, T1/T2 transaction/effect convergence, T3 comp ## Current implementation checkpoint +Canonical collection transport now uses snapshot-bound, byte-bounded TS pages. +The same `canonicalTodoCollection` owner validates both the retained direct list +and paged reads; Python assembles complete pages and preserves the caller shape. +This replaces the one-shot cross-language read without raising its 2 MiB budget +or duplicating Todo/acceptance semantics in Python. Concurrent revisions fail the +whole read; File read opening cannot create a missing authority. See the +[paging contract](../../reference/canonical-snapshot-pagination.md) for limits +and cost, and the shared-authority implementation sequence for the current +**7–9 PR plan including this slice (6–8 after it lands)**. The more explicit split supersedes the earlier broad-package +estimate; it does not claim D1–D3 closure. + + Canonical command observation now has one typed receipt/head boundary. Team, Todo creation/edit/claim/terminal/archive, Monitor, lease maintenance and Goal acceptance recheck receipts after the head read before interpreting new state. @@ -41,7 +53,7 @@ removed; its retained boundary is source projection/locking and capture IO. The legacy scan includes event-only claims, and canonical mode receipts reuse command recovery with strict historical decisions. Full-source snapshot and real-provider validation guard this T1/T2 replacement. This closes a rule and -caller discrepancy, not a whole default-cutover package; the conditional 5–8 +caller discrepancy, not a whole default-cutover package; the conditional 7–9 package estimate remains. [Changed behavior and recovery](../../reference/handoff-mode.md). Terminal review and validation now converge in the existing TS terminal owner. @@ -845,7 +857,7 @@ explicit runtime-root applies to both intent and Todo IO. Frozen editorial requests retain their original basis. See [operation and boundaries](../../../loopx/capabilities/periodic_report/README.md#todo-authority-and-report-retries). This closes that T3/L5 consumer family, not D1 permanent display freshness, D2 durability, D3 whole-Goal qualification or default-provider selection. The -conditional 5–8 remaining delivery-package estimate is unchanged. +conditional 7–9 remaining delivery-package estimate is unchanged. Todo summary lanes and pre-limit work counts now share `todos/summary_lanes.ts`. Python's lane classification and hidden-work inference loops are removed; quota 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 8b90ea4b42..01631d09c3 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 @@ -13,6 +13,15 @@ --- + +## canonical collection 分页检查点(2026-09-23) + +canonical collection 跨语言传输改为 TS 一致性分页:旧 direct list 和分页共用 +`canonicalTodoCollection` 规则 owner,Python 校验并组装完整分页,保持调用方形状。 +不提高 2 MiB RPC 上限,不在 Python 重建 Todo/acceptance 规则;并发版本变化导致 +整份读取失败,File 只读打开不创建缺失 authority。限制与开销见[分页合同](../../reference/canonical-snapshot-pagination.md)。 +shared-authority 当前计划细化为含本批 **7–9 个 PR,合入后 6–8 个**;此处细化取代旧的粗粒度交付包估算,不代表 D1–D3 完成。 + ## 跨 RFC 的执行优先级(2026-09-16) [统一路线](loopx-overall-roadmap-v0.zh-CN.md) 的 R1–R5 是 T0–T4 的当前产品消费者,不另设一套迁移阶段。团队确认路径现在由 `work_items/team_plan.ts` 负责预览、整批规划及不可变操作身份,复用现有 AuthorityStore 回执/CAS 边界。Python 保留公开安全校验与 legacy Markdown IO adapter,逐 lane 写入循环已移除。R1 检查点区分已交付的分配/重试结果与尚未验收的接收者/执行边界。 @@ -34,7 +43,7 @@ receipt,再执行新准入。这修复同 operation 并发竞争,不扩展 p 删除 Python 的阻塞分类、伪造旧模式和整篇文本重写,保留来源投影、锁与 capture IO。 旧扫描补齐事件独有 claim,canonical 回执复用 command recovery 并严格校验历史决策。 完整快照和真实 provider 验证覆盖这一 T1/T2 替换;它关闭一处规则/调用差异, -不代表关闭整项默认切换交付包,条件性的 5–8 包估算不变。 +不代表关闭整项默认切换交付包,条件性的 7–9 包估算不变。 [行为变化与恢复](../../reference/handoff-mode.md)。 终结审核与验证已收敛到既有 TS terminal owner:Agent 完成、Monitor 停止复用 Chat @@ -637,7 +646,7 @@ Todo 来源;frontier 和报告事实复用同一完整已求值快照。展示 归档拒绝记录仍有效,显式 runtime-root 同时约束 intent 和 Todo IO。已冻结的编辑 请求沿用原始依据,不因重试刷新。见[操作边界](../../../loopx/capabilities/periodic_report/README.md#todo-authority-and-report-retries)。 这闭合一组 T3/L5 消费者,不代表 D1 永久展示新鲜度、D2 耐久性、D3 整 Goal -资格或默认 provider 已完成;条件性的 5–8 个后续完整交付批次估算保持不变。 +资格或默认 provider 已完成;条件性的 7–9 个后续完整交付批次估算保持不变。 Todo 摘要 lane 与裁剪前工作计数现共用 `todos/summary_lanes.ts`,删除 Python 的 lane 分类和隐藏任务推断循环。quota 在作用域筛选后重新计数,不完整来源状态贯穿 diff --git a/docs/reference/canonical-snapshot-pagination.md b/docs/reference/canonical-snapshot-pagination.md new file mode 100644 index 0000000000..9bf9d5daf5 --- /dev/null +++ b/docs/reference/canonical-snapshot-pagination.md @@ -0,0 +1,93 @@ +# Canonical Todo snapshot pagination + +After whole-Goal promotion, collection consumers use +`coordination.local_authority.todo_snapshot_page`. Python assembles its pages +into the existing Todo-list result before returning to CLI, status, Turn, Chat, +team planning or projection delivery. Before promotion, the durable writer +fence is absent and the existing legacy path remains unchanged. + +## Observable contract + +A large, valid canonical collection no longer has to fit in a single RPC +response. Each page contains complete records, up to 4,096 Todo/lease records and +1,792 KiB of UTF-8 JSON. The generic RPC response ceiling remains 2 MiB, leaving 256 KiB for its +envelope. Normal collections can still complete in one page. A record +and its acceptance guard travel together; records are never shortened to meet a +budget. Archived dependency/decision records remain in the full collection. + +Todo IDs use the authority codec's deterministic Unicode code-point ordering. +Todos precede leases, with separate offsets and counts. A canonical empty +collection is a complete result; it does not authorize Markdown fallback. +Pagination is a transport mechanism, not a UI display limit or a partial domain +query. Summary computation still receives the complete population. + +Each request has `schema_version`, `runtime_root`, `goal_id`, `include_leases`, +`projection_readback` and `after`. The first `after` is null; subsequent calls +send the previous page's `next` unchanged. A continuation contains: + +| Field | Meaning | +| --- | --- | +| `snapshot.goal_id` | Goal whose complete collection is being read | +| `snapshot.store_identity` | Provider incarnation; replacing a store invalidates old positions | +| `snapshot.provider_revision`, `snapshot.cursor` | Exact committed authority version | +| `snapshot.query_sha256` | Digest of Goal, lease inclusion and projection-confirmation request | +| `snapshot.todo_count`, `snapshot.lease_count` | Complete populations at that version | +| `todo_offset`, `lease_offset` | Already consumed prefixes within that snapshot | + +A page carries the same snapshot, collection metadata, complete `todos`, optional +`leases`, page-local `goal_acceptance_work_guards`, and `next` (null at the end). +Metadata includes the existing read-model declaration, acceptance projection, +handoff mode and requested projection readback. The old direct TS `todo_list` +endpoint remains compatible; the shipped Python collection adapter now uses +pages, and its public result shape stays unchanged. + +## Consistency and recovery + +The provider is checked again on every page. If a writer commits between pages, +`canonical_snapshot_changed` fails the whole read even when that write happens +to leave Todo data unchanged. Consumers discard every earlier page. The caller +may start a new complete read; the adapter does not silently loop or retry +against a moving head. This also rejects changed queries and store identities. + +The Python transport checks page identity, unchanged metadata, count/offset +agreement, progress, record order, duplicates and guard membership. Invalid +transport returns `canonical_snapshot_result_invalid` without partial rows. +The existing TS domain read-model and acceptance owners continue to validate +semantics; Python does not acquire a second Todo rule engine. + +A missing local File provider is opened with `existingOnly`; attempting a read +cannot initialize a replacement identity or directory. Selected SQLite and +PostgreSQL profiles retain their existing identity checks and provider failures. +No failure path falls back to a display file. + +A single record or metadata envelope that cannot fit is rejected with +`canonical_snapshot_record_too_large` or +`canonical_snapshot_metadata_too_large`. These explicit limits do not qualify +arbitrarily large individual records. Projection confirmation remains tied to +the snapshot read; pending delivery must not trigger a second business mutation. + +## Cost and qualification boundary + +This implementation bounds **transport responses**, not the whole collection's +memory or provider IO. Each page loads and validates the current provider head; +Python retains the assembled collection. A busy writer may force a caller to +restart. Database cursor streaming or retained read transactions would need a +separate provider lifecycle/cleanup contract and are not implied here. + +The budget search measures `Buffer.byteLength(JSON.stringify(page), "utf8")`. +It checks the complete candidate first: removing `next` on a final page can make +the envelope smaller, so byte size is not globally monotone at that last step. +The remaining prefix search retains a continuation and is monotone. Raising the +RPC budget or dropping metadata is not the recovery strategy. + +Qualification covers native/imported complex populations, archives, leases, +acceptance guards, overlapping commits, query/incarnation mismatch, malformed +continuations, Unicode ordering and real File/SQLite RPC-to-CLI readback. +The shared authority-store conformance registers the same snapshot tests for +PostgreSQL and other real providers. The real PostgreSQL suite requires an +isolated server/database; a skipped run is not qualification. + +This closes the bounded collection-read part of T3/L5. It does not establish D1 +permanent projection freshness, SQLite D2 durability/soak, D3 whole-Goal +migration, or default-on qualification. See the +[remaining PR sequence](../architecture/rfcs/shared-goal-authority-state-provider-v0.md). From a5acb895a6d201713b12eb6b84b0d25dd6ded235 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:57:23 +0800 Subject: [PATCH 3/5] fix(authority): preserve missing-store recovery across paged reads Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../coordination/canonical_snapshot.py | 24 +++++++++++-------- .../coordination/canonical_snapshot_page.ts | 12 +++++++++- .../control_plane/test_canonical_snapshot.py | 1 + .../test_legacy_coordination_writer_fence.py | 7 ++++-- .../canonical_snapshot_page.test.ts | 9 ++++--- 5 files changed, 37 insertions(+), 16 deletions(-) diff --git a/loopx/control_plane/coordination/canonical_snapshot.py b/loopx/control_plane/coordination/canonical_snapshot.py index 17e82364d9..1154681cdd 100644 --- a/loopx/control_plane/coordination/canonical_snapshot.py +++ b/loopx/control_plane/coordination/canonical_snapshot.py @@ -64,16 +64,19 @@ def require(condition: bool, message: str) -> None: "snapshot RPC returned an unpaged result", ) return { - key: page[key] - for key in ( - "status", - "reason_code", - "reason", - "source_authority", - "decision_read_from_provider", - "legacy_fallback_used", - ) - if key in page + "schema_version": LOCAL_COORDINATION_TODO_LIST_RESULT_SCHEMA, + **{ + key: page[key] + for key in ( + "status", + "reason_code", + "reason", + "source_authority", + "decision_read_from_provider", + "legacy_fallback_used", + ) + if key in page + }, } require( page.get("schema_version") == RESULT_SCHEMA @@ -232,6 +235,7 @@ def require(condition: bool, message: str) -> None: } except ValueError as error: return { + "schema_version": LOCAL_COORDINATION_TODO_LIST_RESULT_SCHEMA, "status": "failed", "reason_code": "canonical_snapshot_result_invalid", "reason": str(error), diff --git a/loopx/control_plane/coordination/canonical_snapshot_page.ts b/loopx/control_plane/coordination/canonical_snapshot_page.ts index e95c8a2852..336fb3b2f9 100644 --- a/loopx/control_plane/coordination/canonical_snapshot_page.ts +++ b/loopx/control_plane/coordination/canonical_snapshot_page.ts @@ -70,7 +70,17 @@ export async function readCanonicalSnapshotFromStore(value: unknown, store: Auth const base = {schema_version: CANONICAL_SNAPSHOT_PAGE_RESULT, source_authority: source, decision_read_from_provider: true, legacy_fallback_used: false}; const identity = await store.storeIdentity(); - if (identity.status !== "available") return {...base, ...identity}; + if (identity.status !== "available") { + // A promoted but absent store has the same caller contract as the old + // single-read path: recovery must see "missing". Identity is unavailable + // before a store exists, so distinguish absence from an identity failure + // without opening a replacement authority. + if (identity.status === "unavailable") { + const absent = await store.loadAuthority(); + if (absent.status === "missing") return {...base, ...absent}; + } + return {...base, ...identity}; + } const head = await store.loadAuthority(); if (head.status !== "loaded") return {...base, ...head}; const query = canonicalAuthoritySha256({goal_id: goal, include_leases: input.include_leases, projection_readback: readback}); diff --git a/tests/control_plane/test_canonical_snapshot.py b/tests/control_plane/test_canonical_snapshot.py index b5b1a5a065..183a04075c 100644 --- a/tests/control_plane/test_canonical_snapshot.py +++ b/tests/control_plane/test_canonical_snapshot.py @@ -108,6 +108,7 @@ def test_later_page_failure_discards_all_earlier_records(status): result, calls = read(source) assert len(calls) == 2 assert result == { + "schema_version": "loopx_local_coordination_todo_list_result_v0", "status": status, "reason_code": "canonical_snapshot_changed", "reason": "restart", diff --git a/tests/control_plane/test_legacy_coordination_writer_fence.py b/tests/control_plane/test_legacy_coordination_writer_fence.py index 0c49775e53..91f9ed1ff1 100644 --- a/tests/control_plane/test_legacy_coordination_writer_fence.py +++ b/tests/control_plane/test_legacy_coordination_writer_fence.py @@ -357,10 +357,13 @@ def missing_provider( ) assert exc_info.value.code == "local_authority_todo_list_unavailable" - assert captured["method"] == "coordination.local_authority.todo_list" + assert captured["method"] == "coordination.local_authority.todo_snapshot_page" assert captured["params"] == { - "schema_version": "loopx_local_coordination_todo_list_request_v0", + "schema_version": "loopx_canonical_snapshot_page_request_v0", "runtime_root": str(runtime_override.resolve()), "goal_id": SPLIT_ROOT_GOAL_ID, + "include_leases": False, + "projection_readback": None, + "after": None, } assert state.read_text(encoding="utf-8") == state_before diff --git a/tests/control_plane_ts/canonical_snapshot_page.test.ts b/tests/control_plane_ts/canonical_snapshot_page.test.ts index 23db65803a..8a7c38301f 100644 --- a/tests/control_plane_ts/canonical_snapshot_page.test.ts +++ b/tests/control_plane_ts/canonical_snapshot_page.test.ts @@ -6,6 +6,7 @@ import {join} from "node:path"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; +import {sqliteRuntimeIdentity} from "../../loopx/control_plane/coordination/sqlite_runtime.ts"; import {coordinationTodoReadModel} from "../../loopx/control_plane/coordination/coordination_projection.ts"; import {readCanonicalSnapshotFromStore, readCanonicalSnapshotPage, CANONICAL_SNAPSHOT_PAGE_BYTES} from "../../loopx/control_plane/coordination/canonical_snapshot_page.ts"; import {productionScaleCoordinationFixture} from "./production_scale_coordination_fixture.ts"; @@ -17,7 +18,9 @@ async function fixture(t: test.TestContext, kind: "file" | "sqlite" = "file") { const Store = kind === "file" ? FileAuthorityStore : SqliteAuthorityStore; return {store: new Store(root, "goal-a"), contender: new Store(root, "goal-a")}; } -for (const kind of ["file", "sqlite"] as const) { +const qualifiedSqlite = sqliteRuntimeIdentity().sqlite_authority_qualified; +if (!qualifiedSqlite) test.skip("SQLite snapshot conformance requires a qualified SQLite runtime", () => {}); +for (const kind of (qualifiedSqlite ? ["file", "sqlite"] : ["file"]) as ("file" | "sqlite")[]) { registerCanonicalSnapshotConformance(kind, t => fixture(t, kind)); test(`${kind}: a normal complete collection keeps a single RPC response`, async t => { const {store} = await fixture(t, kind); @@ -69,8 +72,8 @@ test("a missing runtime provider read does not initialize a replacement authorit t.after(() => rm(root, {recursive: true, force: true})); const before = await readdir(root); const result = await readCanonicalSnapshotPage(snapshotRequest({runtime_root: root})); - assert.equal(result.status, "unavailable"); - assert.equal(result.reason_code, "store_identity_unavailable"); + assert.equal(result.status, "missing"); + assert.equal(result.reason_code, undefined); assert.deepEqual(await readdir(root), before); assert.equal("todos" in result, false); }); From cde254421d0dd30a70432f71203ff95880cfacc0 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:03:49 +0800 Subject: [PATCH 4/5] docs(rfc): repair bilingual mirror declaration required by governance check Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- docs/architecture/rfcs/automatic-execution-admission-v0.md | 2 +- .../architecture/rfcs/automatic-execution-admission-v0.zh-CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture/rfcs/automatic-execution-admission-v0.md b/docs/architecture/rfcs/automatic-execution-admission-v0.md index b9d1e2bac2..f8e1b3f566 100644 --- a/docs/architecture/rfcs/automatic-execution-admission-v0.md +++ b/docs/architecture/rfcs/automatic-execution-admission-v0.md @@ -5,7 +5,7 @@ - **Owners:** Quota, scheduler and host-runtime maintainers - **Created / last normative revision:** 2026-09-23 - **Implementation baseline:** `23edcb19c` -- **Language mirror:** [中文版](automatic-execution-admission-v0.zh-CN.md) +- **Language mirror:** [中文版](automatic-execution-admission-v0.zh-CN.md) is the semantic mirror; both versions must change together. - **Related contracts:** [roadmap](loopx-overall-roadmap-v0.md), [quota](../../quota-allocation.md), [cadence hint](../../operations/long-task-cadence-policy.md), [session execution modes](agent-session-execution-modes-v0.md) Sections 1–12 define the proposal and acceptance contract. The appendix records diff --git a/docs/architecture/rfcs/automatic-execution-admission-v0.zh-CN.md b/docs/architecture/rfcs/automatic-execution-admission-v0.zh-CN.md index ca8dd60f64..1a1a2b3368 100644 --- a/docs/architecture/rfcs/automatic-execution-admission-v0.zh-CN.md +++ b/docs/architecture/rfcs/automatic-execution-admission-v0.zh-CN.md @@ -5,7 +5,7 @@ - **维护边界:** quota、scheduler、host runtime - **创建 / 规范修订:** 2026-09-23 - **实现基线:** `23edcb19c` -- **语言镜像:** [English](automatic-execution-admission-v0.md) +- **语言镜像:** [English](automatic-execution-admission-v0.md) 与本版互为语义镜像;两版须同步修订。 - **相关契约:** [路线图](loopx-overall-roadmap-v0.zh-CN.md)、[quota](../../quota-allocation.md)、[节奏提示](../../operations/long-task-cadence-policy.md)、[执行模式](agent-session-execution-modes-v0.md) 第 1–12 节定义设计与验收契约;附录记录交付边界。实现某个阶段不等于 RFC 已获批准。 From 373ae0fa4add247abd316f9e4be25de21527156b Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:06:34 +0800 Subject: [PATCH 5/5] docs(authority): clarify missing-store recovery for paged reads Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- docs/reference/canonical-snapshot-pagination.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/reference/canonical-snapshot-pagination.md b/docs/reference/canonical-snapshot-pagination.md index 9bf9d5daf5..6c24aaf97d 100644 --- a/docs/reference/canonical-snapshot-pagination.md +++ b/docs/reference/canonical-snapshot-pagination.md @@ -56,7 +56,10 @@ The existing TS domain read-model and acceptance owners continue to validate semantics; Python does not acquire a second Todo rule engine. A missing local File provider is opened with `existingOnly`; attempting a read -cannot initialize a replacement identity or directory. Selected SQLite and +cannot initialize a replacement identity or directory. It still returns the +existing `missing` result, so callers retain the canonical-authority recovery +path instead of treating absence as a new identity error. An existing head with +an unreadable identity remains an identity failure. Selected SQLite and PostgreSQL profiles retain their existing identity checks and provider failures. No failure path falls back to a display file.