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 5efb00438..2280fd58a 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -3169,12 +3169,15 @@ provider conformance cover the consumer family. See [operation and semantic changes](../../reference/todo-continuation-readback.md). This closes a bounded L5/L7 gap; permanent projection delivery/recovery, D2 and D3 are still open. -D1 delivery confirmation now follows durable Markdown readback with a typed -canonical revision check. Unpinned settlement retries up to three times using -the returned complete snapshot; pinned projection never silently retargets. -Overlap, churn and confirmation outage remain pending without repeating business -commits. This qualifies the bounded delivery/retry boundary, not permanent -freshness, a background drainer, all L5 consumers or D2/D3. See the +D1 delivery confirmation follows durable Markdown readback. The TS owner now +owns latest/pinned intent and the three-attempt retry decision; Python retains +file locks, durable publication and rendering. Committed `refresh-state` and +same-Turn replay drain the existing projection path without repeating Todo or +quota mutations. Planning, missing-work diagnosis and initial delivery share a +complete canonical snapshot; the final confirmation still reads the provider. +Authoritative emptiness never falls back to stale Markdown. Legacy and preview +refresh remain unchanged. This closes the refresh recovery/diagnostic caller, +not all L5 consumers, a background worker, D2 or D3; see the [projection contract](../../reference/protocols/active-state-structured-projection-v0.md). **D2 — qualify exactly one local profile; independent of PostgreSQL deployment.** @@ -3267,6 +3270,15 @@ 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. | +**2026-09-24 reconciliation.** The count remains an estimate of complete +packages, not a count of small fixes, and this refresh slice advances boundary 4 +(projection recovery and client closure) without claiming its other consumers +are qualified. SQLite #4910 added the larger measurement axes; #4224 records +failed 1 MiB receipt/scan budgets and still-missing recovery/soak evidence. +#4931 is the in-review read-proof optimization, not proof that D2 passed. +Snapshot pagination #4922 has merged and still must be qualified at its accepted +head. None of these PR statuses grants cutover or changes the selected profile. + **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 @@ -3330,12 +3342,12 @@ The TS owner shares durable qualification and exact receipt proof between both paths. See [operation and acceptance](../../reference/reviewed-coordination-promotion.md). This stage does not authorize an active Goal migration or flip a default. -For an existing claimed Goal, integrate the claim-preserving migration in #4870 -with this slice, qualify the exact combined head and resolve its existing CI and -review holds. Preserve the registered owners, existing claims and leases; do not +Claim-preserving migration #4870 and reviewed cutover #4888 are merged; +shadow drain planning #4920 is also merged. Qualify their combined current head +for an existing claimed Goal rather than treating an old PR hold as current. Preserve the registered owners, existing claims and leases; do not 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. +must retain migration strategy, registered-agent facts and target digest during +combined qualification. The current seven-boundary plan above separates caller admission, executor fences and snapshot reads; D2 and integrated migration may each split. This 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 f6fd03b9b..58999b49d 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 @@ -2465,10 +2465,12 @@ route planner 本身仍不授予权限。CLI 将已提交回执交给既有 jour - caller 迁走后才删除旧 projection repair/receipt 路径。退出条件是可复核的 freshness/readback 和可操作修复路径,不能只证明成功渲染过一次。 -D1 交付确认现于 Markdown 耐久读回后核对 canonical revision。未固定版本的结算 -最多追赶三次,复用返回的完整快照;固定版本不擅自换目标。并发、持续变化及确认故障 -保留 pending,不重做业务提交。这闭合有界交付/重试,不代表永久新鲜度、后台 drain、 -全部 L5 或 D2/D3;见[投影合同](../../reference/protocols/active-state-structured-projection-v0.md)。 +D1 的最新/固定版本意图和三次追赶决定收口到 TS;Python 保留文件锁、耐久落盘与 +渲染。已提交的 `refresh-state` 及同 Turn 重试通过现有投影路径恢复显示,不重做 Todo +或 quota 变更。规划、缺失工作诊断及首次渲染复用完整 canonical 快照,耐久写后仍读 +provider 确认;权威空集合不回退到陈旧 Markdown。Legacy 与预览行为保持不变。 +这关闭刷新恢复/诊断调用方,不代表全部 L5、后台 drain 或 D2/D3 完成; +见[投影合同](../../reference/protocols/active-state-structured-projection-v0.md)。 **D2 — 资格化一个本地 profile,不等待 PostgreSQL 部署。** @@ -2533,6 +2535,13 @@ 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 可在默认启用后继续收尾。 | +**2026-09-24 基线核对。** 保留 claim 的 #4870、reviewed cutover #4888、shadow drain +规划 #4920 已合并,快照分页 #4922 也已合并,后续应验收组合 head,而不是继续沿用 +旧的 PR hold;SQLite 读取证明优化 #4931 仍在评审。#4910 已加入更大测量轴;#4224 +实测 1 MiB receipt/scan 超预算,恢复和自然时间资格仍有缺项,不能将优化 PR 当成 D2 +通过。本次刷新恢复推进下表中第 4 项投影恢复边界,但没有把其他调用方或默认切换 +标记完成。 + **开发节奏以证据推进。** 2026-09-23 将旧计划的五类粗粒度交付包细化为 **七个明确 PR 边界;D2 和迁移各拆两批时最多九个**,其中包含本批一致性分页。 独立列出分页与投影恢复、caller 与 executor effect,是为了让验收和回滚更清楚, diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 41e9171c2..32d0d6b2f 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -1883,8 +1883,11 @@ Canonical single-Todo and full-source reads now have one read-only TypeScript module, separate from mutation orchestration and sharing the provider opening boundary. Projection delivery composes a revision confirmation with the existing full-source read; ordinary callers retain their response shape. Python owns -physical Markdown durability/retry, not the current-head comparison. Three-attempt -recovery and pinned-intent preservation use the existing journal-backed path; +physical Markdown durability and rendering. TS owns current-head comparison, +latest/pinned intent and bounded retry. Committed refresh and same-Turn recovery +reuse that path, with one complete planning snapshot also owning missing-work +diagnostics. This removes Python retry/admission policy and the promoted record's +second Markdown-based Todo diagnosis; no new RPC method, durable ACK or provider default. The stronger confirmation costs one additional read on a stable delivery. Full L5/D1 qualification, D2 and cutover remain open; see the [projection contract](../../reference/protocols/active-state-structured-projection-v0.md). 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 f24431a3d..9e26e9f6f 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 @@ -1446,6 +1446,15 @@ transaction 只能靠削弱既有行为才能通过 invariant/recovery/performan 实测交付记录存于[逐条 ledger](ledger/typescript-control-plane-migration-v0/)。 每条记录说明已交付边界及剩余验收缺口;上方 T1–T4 检查点仍是当前迁移计划。 +### Canonical 显示确认与刷新恢复 + +TS 拥有 canonical revision 比较、最新/固定版本意图及三次重试上限;Python 继续执行 +文件锁、耐久落盘和 Markdown 渲染。已提交刷新与同 Turn 重试复用这一恢复路径,规划 +快照同时供应缺失工作诊断和初始显示,删除 Python 的重试决策及晋升后从旧 Markdown +再判断 Todo 数量的路径。没有增加 RPC 方法或持久 ACK,正常恢复增加一次确认读取。 +这是 T3/D1 的刷新调用方闭合;其他 consumer、D2 与整 Goal 切换仍需独立资格。 +见[投影合同](../../reference/protocols/active-state-structured-projection-v0.md)。 + ### Reviewed coordination cutover ownership Saved-plan execution and fenced recovery now share the TypeScript promotion diff --git a/docs/reference/canonical-snapshot-pagination.md b/docs/reference/canonical-snapshot-pagination.md index 6c24aaf97..cf2f64a50 100644 --- a/docs/reference/canonical-snapshot-pagination.md +++ b/docs/reference/canonical-snapshot-pagination.md @@ -25,6 +25,11 @@ 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: +`projection_readback` is the typed confirmation request (`provider_revision`, +`changed`, `attempt`, `target`); it carries the caller's pinned-or-latest intent +and attempt budget, so the page returns one confirmation for the same snapshot +as every page instead of letting the display lock hold a provider commit. + | Field | Meaning | | --- | --- | | `snapshot.goal_id` | Goal whose complete collection is being read | diff --git a/docs/reference/protocols/active-state-structured-projection-v0.md b/docs/reference/protocols/active-state-structured-projection-v0.md index e79c0fe76..5747965dd 100644 --- a/docs/reference/protocols/active-state-structured-projection-v0.md +++ b/docs/reference/protocols/active-state-structured-projection-v0.md @@ -257,6 +257,47 @@ from facts; old wire `effective` hints are accepted but cannot override them. 有效租约,未过期但持有人失去资格时返回原因,不自动续租、转移或清理。 读取不提供写授权;release 的 key/version 门禁与幂等、CAS 规则保持不变。 +### Refresh recovery and authoritative diagnostics + +After promotion, a successful non-preview `refresh-state` now attempts Todo +projection delivery, including recovery of a previously committed same-Turn +writeback. CLI and Turn use the same path. Legacy refresh and preview remain +non-repairing; rejected admission does not acquire a display-write opportunity. +A provider or display failure after the refresh commit is `projection_delivery=pending`, +not a failed or repeated business write. JSON and Markdown responses disclose +that distinction. Retry the original Turn, or use `todo project-markdown` with a +fresh provider revision; do not repeat Todo completion or quota spend. + +The planner retains its complete canonical snapshot for delivery rather than +immediately reading it again. The renderer still confirms authority after +durable file readback. TS returns a typed `next_action=retry|finish`; only +latest-head intent may retry an overlap, and no fourth attempt is admitted. +Pinned explicit projection preserves its requested revision. These are internal, +co-deployed request fields, not persisted request bytes or new receipt versions. +Existing receipts and provider formats are unchanged. + +The refresh record's missing-work diagnosis also uses the same canonical Todo +summary. Stale Markdown cannot fabricate a missing-task warning or hide a truly +empty canonical group. Markdown remains the source of independent narrative. +Delivery can catch up to a newer provider revision without rewriting the earlier +refresh record or pretending its original planning snapshot was newer. + +Recovery adds real rendering, file durability and confirmation work to committed +promoted refreshes. It is not a free read or a claim of lower latency. The normal +path shares the planning read and adds one confirmation read; same-Turn replay +loads the current head before repairing. Missing display recovers only Todo +sections, with the existing private-validation digest and source-ownership +checks. It cannot reconstruct independent Goal narrative or bypass an +unavailable private validation declaration. No timer, new outbox, persistent ACK, +provider default or active-Goal migration is introduced. + +中文:已晋升 Goal 的非预览 `refresh-state` 和同 Turn 重试现在会恢复 Todo 显示。 +业务成功、显示 pending 分别报告;只重试显示,不重新完成 Todo 或扣费。规划、缺失工作 +诊断与投影起点复用完整 canonical 快照,权威空集合不回退到旧 Markdown;耐久写入后 +仍读取 provider 确认,由 TS 统一决定是否追赶以及三次上限。Legacy、预览与拒绝请求 +不获得新的显示写入。这个默认行为变化只影响已晋升 Goal,增加了渲染和耐久确认成本; +不改变默认 provider。缺失文件仅恢复 Todo 区域,不能恢复独立 Goal 叙述。 + ## Migration Path The projector accepts complete legacy records and native `TodoDomainRecord` @@ -273,8 +314,8 @@ one provider transaction. After that commit, the Python compatibility adapter renders the latest head under the Markdown lock and durably reads it back. A renderer/write failure leaves typed `pending` delivery evidence without reversing or hiding the canonical commit. A later successful -mutation or `todo project-markdown --execute` replays the current head -idempotently. This is projection recovery, not a second authority path. +mutation, committed `refresh-state` (including same-Turn replay), or +`todo project-markdown --execute` replays the current head idempotently. This is projection recovery, not a second authority path. The ordinary state writer and projection writer share durable atomic publication. Missing-display recovery uses create-only publication and cannot overwrite a concurrently restored document. When bytes already match, execution still syncs @@ -303,7 +344,8 @@ only the internal projection readback request opts into confirmation metadata. The TypeScript read owner validates complete canonical data and compares the host's durable readback revision with the same loaded head. Python retains -Markdown ownership, durability, bounded IO retry and rendering. A missing +Markdown ownership, physical durability and rendering. The TypeScript confirmation +owns latest-head versus pinned intent and the three-attempt retry decision. A missing confirmation from a downlevel runtime cannot be treated as delivery success. The normal successful execution adds one provider read; each caught-up attempt reuses the already returned full snapshot. This is a freshness cost, not a diff --git a/loopx/control_plane/coordination/local_authority.py b/loopx/control_plane/coordination/local_authority.py index dd54264e8..6eb4197e7 100644 --- a/loopx/control_plane/coordination/local_authority.py +++ b/loopx/control_plane/coordination/local_authority.py @@ -247,7 +247,9 @@ def read_canonical_todos_if_promoted( if (not isinstance(confirmation, Mapping) or confirmation.get("provider_revision") != projection_readback["provider_revision"] or confirmation.get("observed_provider_revision") != payload.get("provider_revision") - or confirmation.get("status") not in {"pending", "delivered", "current"}): + or confirmation.get("status") not in {"pending", "delivered", "current"} + or confirmation.get("next_action") not in {"retry", "finish"} + or (confirmation.get("next_action") == "retry" and confirmation.get("status") != "pending")): raise LocalCoordinationAuthorityUnavailable( "canonical projection confirmation is missing or invalid", code="local_authority_projection_confirmation_invalid", payload=payload, diff --git a/loopx/control_plane/todos/projection_delivery.ts b/loopx/control_plane/todos/projection_delivery.ts index d88224143..30fc40185 100644 --- a/loopx/control_plane/todos/projection_delivery.ts +++ b/loopx/control_plane/todos/projection_delivery.ts @@ -26,6 +26,8 @@ export function isProjectionDelivery(value: unknown): value is TodoProjectionDel export interface ProjectionReadback { provider_revision: string; changed: boolean; + attempt: number; + target: "pinned" | "latest"; } export function decodeProjectionReadback(value: unknown): ProjectionReadback { @@ -33,17 +35,41 @@ export function decodeProjectionReadback(value: unknown): ProjectionReadback { throw new TypeError("projection_readback must be an object"); } const row = value as Record; - if (Object.keys(row).length !== 2 || typeof row.provider_revision !== "string" || + if (Object.keys(row).length !== 4 || typeof row.provider_revision !== "string" || !row.provider_revision.trim() || row.provider_revision !== row.provider_revision.trim() || - typeof row.changed !== "boolean") throw new TypeError("invalid projection_readback"); - return {provider_revision: row.provider_revision, changed: row.changed}; + typeof row.changed !== "boolean" || + typeof row.attempt !== "number" || !Number.isSafeInteger(row.attempt) || row.attempt < 1 || + row.attempt > 3 || (row.target !== "pinned" && row.target !== "latest")) throw new TypeError("invalid projection_readback"); + return {provider_revision: row.provider_revision, changed: row.changed, + attempt: row.attempt, target: row.target}; } -export function confirmProjectionReadback(readback: ProjectionReadback, observedRevision: string) { - return { - provider_revision: readback.provider_revision, - observed_provider_revision: observedRevision, - status: readback.provider_revision === observedRevision - ? (readback.changed ? "delivered" : "current") : "pending", - } satisfies {provider_revision: string; observed_provider_revision: string; status: TodoProjectionDelivery}; +/** One policy for mutation delivery, refresh recovery and explicit projection. + * The display lock does not lock provider commits. Only a latest-head request + * may follow an overlap; exact-revision requests must remain pinned. + */ +export type ProjectionConfirmation = { + provider_revision: string; + observed_provider_revision: string; +} & ( + | {status: "delivered" | "current"; next_action: "finish"} + | {status: "pending"; next_action: "retry" | "finish"; + reason_code: "todo_projection_revision_advanced"; retryable: true; + retry_business_mutation: false; recommended_action: string} +); + +export function confirmProjectionReadback( + readback: ProjectionReadback, observedRevision: string, +): ProjectionConfirmation { + const basis = {provider_revision: readback.provider_revision, + observed_provider_revision: observedRevision}; + if (readback.provider_revision === observedRevision) { + return {...basis, status: readback.changed ? "delivered" : "current", next_action: "finish"}; + } + return {...basis, status: "pending", + next_action: readback.target === "latest" && readback.attempt < 3 ? "retry" : "finish", + reason_code: "todo_projection_revision_advanced", retryable: true, + retry_business_mutation: false, + recommended_action: "Read the current provider revision with todo list, then retry todo project-markdown for that revision.", + }; } diff --git a/loopx/control_plane/todos/provider_projection.py b/loopx/control_plane/todos/provider_projection.py index 45140be46..39fe2c2ed 100644 --- a/loopx/control_plane/todos/provider_projection.py +++ b/loopx/control_plane/todos/provider_projection.py @@ -22,6 +22,7 @@ from ..coordination.local_authority import ( LocalCoordinationAuthorityUnavailable, read_canonical_todos_if_promoted, + local_authority_is_promoted, ) from .machine_section_projection import ( TodoSectionProjectionError, @@ -80,6 +81,7 @@ def project_current_canonical_todos( state_file: Path | None = None, execute: bool = True, registry_data: Mapping[str, Any] | None = None, + canonical_snapshot: dict[str, Any] | None = None, ) -> dict[str, Any]: """Render one exact canonical head into machine-owned Markdown regions.""" @@ -95,7 +97,10 @@ def project_current_canonical_todos( with exclusive_cross_runtime_file_lock( state_path, operation="project_canonical_todo_sections" ): - authority_read = read_canonical_todos_if_promoted( + # The refresh planner can supply its complete authoritative snapshot. + # It may age while refresh commits: durable confirmation below must still + # observe the provider and can never be replaced by this earlier read. + authority_read = canonical_snapshot if canonical_snapshot is not None else read_canonical_todos_if_promoted( runtime_root=runtime_root, goal_id=goal_id, ) @@ -106,7 +111,9 @@ def project_current_canonical_todos( recovered_missing = False changed = False confirmation: dict[str, Any] | None = None - for attempt in range(1, 4): + attempt = 0 + while True: + attempt += 1 provider_revision = authority_read.get("provider_revision") if not isinstance(provider_revision, str) or not provider_revision: raise ValueError("canonical Todo authority omitted provider revision") @@ -169,29 +176,23 @@ def project_current_canonical_todos( break confirmed = read_canonical_todos_if_promoted( runtime_root=runtime_root, goal_id=goal_id, - projection_readback={"provider_revision": provider_revision, "changed": changed}, + projection_readback={"provider_revision": provider_revision, "changed": changed, + "attempt": attempt, + "target": "pinned" if expected_provider_revision is not None else "latest"}, ) if not isinstance(confirmed, dict) or not isinstance(confirmed.get("projection_readback"), dict): raise ValueError("canonical projection confirmation is missing") confirmation = confirmed["projection_readback"] - if parse_projection_delivery(confirmation["status"]) != ProjectionDeliveryStatus.PENDING: - break - # A pinned command must not silently render a different revision. - # Unpinned recovery reuses this complete read for its next attempt. - if expected_provider_revision is not None or attempt == 3: + if confirmation["next_action"] == "finish": break + # TS owns the retry bound and exact-revision intent. Reuse the + # complete confirmation snapshot instead of issuing another read. authority_read = confirmed return { "schema_version": TODO_PROJECTION_DELIVERY_SCHEMA, - "status": confirmation["status"] if confirmation is not None else "planned", - **({"observed_provider_revision": confirmation["observed_provider_revision"]} - if confirmation is not None else {}), + **(confirmation if confirmation is not None else {"status": "planned"}), "delivery_attempts": attempt, - **({"reason_code": "todo_projection_revision_advanced", "retryable": True, - "retry_business_mutation": False, - "recommended_action": "Read the current provider revision with todo list, then retry todo project-markdown for that revision."} - if confirmation is not None and confirmation["status"] == "pending" else {}), "source": "committed_authority_journal", "goal_id": goal_id, "state_file": str(state_path), @@ -220,6 +221,8 @@ def settle_canonical_todo_projection( goal_id: str, project: Path | None = None, state_file: Path | None = None, + only_if_promoted: bool = False, + canonical_snapshot: dict[str, Any] | None = None, ) -> dict[str, Any]: """Drain the committed provider head, preserving a successful mutation.""" @@ -234,6 +237,8 @@ def settle_canonical_todo_projection( trigger_revision = payload.get("provider_revision") trigger_cursor = payload.get("cursor") try: + if only_if_promoted and not local_authority_is_promoted(runtime_root=runtime_root, goal_id=goal_id): + return payload delivery = project_current_canonical_todos( registry_path=registry_path, runtime_root=runtime_root, @@ -241,6 +246,7 @@ def settle_canonical_todo_projection( project=project, state_file=state_file, execute=True, + canonical_snapshot=canonical_snapshot, ) except Exception as error: # noqa: BLE001 - canonical commit already landed if isinstance(error, LocalCoordinationAuthorityUnavailable): @@ -273,6 +279,28 @@ def settle_canonical_todo_projection( return payload +def recover_refresh_todo_projection( + payload: dict[str, Any], *, registry_path: Path, runtime_root: Path, + goal_id: str, project: Path | None = None, state_file: Path | None = None, + canonical_snapshot: dict[str, Any] | None = None, +) -> dict[str, Any]: + """A committed refresh/replay is a recovery opportunity, never a new Todo write. + + Keep legacy, rejected and preview calls unchanged. The journal already owns + durable projection intent; there is no additional queue, receipt or quota + event here. A display failure stays pending alongside the committed refresh. + """ + if payload.get("dry_run") is True or not ( + payload.get("ok") is True or payload.get("appended") is True + ): + return payload + return settle_canonical_todo_projection( + payload, registry_path=registry_path, runtime_root=runtime_root, + goal_id=goal_id, project=project, state_file=state_file, only_if_promoted=True, + canonical_snapshot=canonical_snapshot, + ) + + __all__ = [ "TODO_PROJECTION_DELIVERY_SCHEMA", "ProjectionDeliveryStatus", @@ -281,4 +309,5 @@ def settle_canonical_todo_projection( "projection_delivery_for_mutation", "project_current_canonical_todos", "settle_canonical_todo_projection", + "recover_refresh_todo_projection", ] diff --git a/loopx/control_plane/work_items/refresh_recommendation.py b/loopx/control_plane/work_items/refresh_recommendation.py index f3ec0810c..f7a97bd24 100644 --- a/loopx/control_plane/work_items/refresh_recommendation.py +++ b/loopx/control_plane/work_items/refresh_recommendation.py @@ -1,11 +1,12 @@ from __future__ import annotations from collections.abc import Mapping +from dataclasses import dataclass from pathlib import Path from typing import Any from ..agents.agent_lane_recommendation import build_agent_lane_next_action -from ..coordination.local_authority import read_canonical_todo_fields_if_promoted +from ..coordination.local_authority import read_canonical_todos_if_promoted, canonical_todo_summary_fields from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result from ..todos.active_state_todo_parser import parse_active_state_todos from ..todos.contract import normalize_todo_id @@ -26,29 +27,40 @@ RECOMMENDED_ACTION_SOURCE_DEFAULT = "default_refresh_action" +@dataclass(frozen=True) +class RefreshPlanningSource: + state_text: str + events: list[dict[str, Any]] + todo_fields: dict[str, Any] | None + canonical_snapshot: dict[str, Any] | None + + def load_refresh_planning_source( runtime_root: Path, goal_id: str, state_path: Path, *, require_display: bool, -) -> tuple[str, list[dict[str, Any]], dict[str, Any] | None]: +) -> RefreshPlanningSource: """Read one shared planning snapshot without repairing its display. Canonical Todo availability permits observation without Markdown, not an edit of missing Next Action narrative. Provider failures propagate. """ events = load_rollout_events(rollout_event_log_path(runtime_root, goal_id)) - fields = read_canonical_todo_fields_if_promoted( - runtime_root=runtime_root, goal_id=goal_id, rollout_events=events, - ) + canonical = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=goal_id) + fields = canonical_todo_summary_fields( + canonical["todos"], rollout_events=events, + goal_acceptance_contract=canonical.get("goal_acceptance_contract"), + goal_acceptance_work_guards=canonical.get("goal_acceptance_work_guards"), + ) if canonical is not None else None try: text = state_path.read_text(encoding="utf-8") except FileNotFoundError: if fields is None or require_display: raise FileNotFoundError(f"state file does not exist: {state_path}") from None text = "" - return text, events, fields + return RefreshPlanningSource(text, events, fields, canonical) def _first_valid_action(values: list[str]) -> str | None: diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 7a3543a73..b51972a68 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -1367,7 +1367,7 @@ }, { "site": "loopx/control_plane/todos/provider_projection.py::.project_current_canonical_todos::codec_read:load_registry#1", - "line": 86, + "line": 88, "column": 70, "kind": "codec_read", "api": "load_registry", @@ -1751,7 +1751,7 @@ }, { "site": "loopx/state_refresh.py::.refresh_state_run::codec_read:load_registry#1", - "line": 883, + "line": 899, "column": 16, "kind": "codec_read", "api": "load_registry", diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index 9cfd0cbd2..c5ae786f3 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -381,6 +381,7 @@ def build_state_refresh_record( progress_observation: dict[str, Any] | None = None, delivery_workspace: dict[str, Any] | None = None, settlement_identity: SettlementIdentity | None = None, + todo_fields: dict[str, Any] | None = None, ) -> dict[str, Any]: frontmatter = parse_frontmatter(state_text) next_action = active_state_next_action_entries( @@ -422,7 +423,12 @@ def build_state_refresh_record( } if recommended_action_resolution: record["recommended_action_resolution"] = recommended_action_resolution - projection_gap = state_projection_gap_warning(state_text) + projection_gap = state_projection_gap_warning( + state_text, + # An authoritative empty group must not fall back to stale Markdown. + user_todos=(todo_fields.get("user_todos") or {}) if todo_fields is not None else None, + agent_todos=(todo_fields.get("agent_todos") or {}) if todo_fields is not None else None, + ) if projection_gap: record["state_projection_gap"] = projection_gap if delivery_batch_scale: @@ -578,9 +584,16 @@ def _build_state_refresh_output_projections( def render_state_refresh_markdown(payload: dict[str, Any]) -> str: + delivery = payload.get("projection_outbox") + delivery_lines = [] + if isinstance(delivery, dict): + delivery_lines.append(f"- Todo display: `{delivery['status']}`") + if delivery.get("status") == "pending": + delivery_lines.append("- Canonical Todo state is committed; repair the display without repeating business work or quota spend.") + delivery_lines.append(str(delivery.get("recommended_action") or "")) recovery_markdown = render_refresh_recovery_markdown(payload) if recovery_markdown is not None: - return recovery_markdown + return "\n".join([recovery_markdown, *delivery_lines]) state = payload.get("state") if isinstance(payload.get("state"), dict) else {} frontmatter = state.get("frontmatter") if isinstance(state.get("frontmatter"), dict) else {} lines = [ @@ -603,6 +616,7 @@ def render_state_refresh_markdown(payload: dict[str, Any]) -> str: f"- state_updated_at: `{frontmatter.get('updated_at')}`", f"- health_check: `{payload.get('health_check')}`", ] + lines.extend(delivery_lines) lines.extend(render_settlement_progress_markdown(payload)) if "external_sink_delivery_authorized" in payload: lines.append( @@ -811,6 +825,8 @@ def refresh_state_run( sync_global: bool = True, external_delivery: dict[str, Any] | None = None, ) -> dict[str, Any]: + from .control_plane.todos.provider_projection import recover_refresh_todo_projection + safe_goal_id = validate_goal_id_path_segment(goal_id) if checkpoint_read_context_id and not turn_instance_id: raise ValueError("--checkpoint-read-context requires the original Turn identity") @@ -954,7 +970,10 @@ def refresh_state_run( goal_id=safe_goal_id, dry_run=dry_run, ) if recovery_payload is not None: - return recovery_payload + return recover_refresh_todo_projection( + recovery_payload, registry_path=registry_path, runtime_root=runtime_root, + goal_id=safe_goal_id, project=project, state_file=state_file, + ) if checkpoint_read_context_id and not checkpoint_supplement: raise ValueError("--checkpoint-read-context applies only to a missing-checkpoint supplement") settlement_workspace_requirement = resolve_settlement_workspace_requirement( @@ -984,9 +1003,12 @@ def refresh_state_run( project_override=project, state_file_override=state_file, ) - state_text, planning_events, todo_fields = load_refresh_planning_source( + planning_source = load_refresh_planning_source( runtime_root, safe_goal_id, resolved_state_file, require_display=bool(next_action) ) + state_text, planning_events, todo_fields = ( + planning_source.state_text, planning_source.events, planning_source.todo_fields, + ) expected_write_state_text = state_text normalized_next_action = normalize_next_action_text(next_action) if next_action else None registered_agents = registered_agents_for_goal(registry_goal) @@ -1288,6 +1310,7 @@ def refresh_state_run( progress_observation=normalized_progress_observation, delivery_workspace=delivery_workspace, settlement_identity=settlement_identity, + todo_fields=todo_fields, ) if delivery_workspace_causality: record["delivery_workspace_causality"] = delivery_workspace_causality @@ -1547,6 +1570,11 @@ def refresh_state_run( if committed_readback is None: raise RuntimeError("committed refresh settlement readback missing") attach_settlement_progress(payload, committed_readback, registry_path=registry_path, runtime_root=runtime_root) - return finish_external_delivery_refresh( - payload, settlement_readback, runtime_root, dry_run=dry_run, + return recover_refresh_todo_projection( + finish_external_delivery_refresh( + payload, settlement_readback, runtime_root, dry_run=dry_run, + ), + registry_path=registry_path, runtime_root=runtime_root, goal_id=safe_goal_id, + project=resolved_project, state_file=resolved_state_file, + canonical_snapshot=planning_source.canonical_snapshot, ) diff --git a/tests/control_plane/test_canonical_planning_consumers.py b/tests/control_plane/test_canonical_planning_consumers.py index 1f1ab8e7b..16c3aff17 100644 --- a/tests/control_plane/test_canonical_planning_consumers.py +++ b/tests/control_plane/test_canonical_planning_consumers.py @@ -437,16 +437,16 @@ def test_todo_replan_guard_closes_later_utc_ack_across_offsets( @pytest.mark.parametrize("promoted", [False, True]) -def test_public_refresh_retains_legacy_parity_and_uses_one_provider_read( +def test_preview_refresh_retains_legacy_parity_and_uses_one_provider_read( tmp_path: Path, monkeypatch, promoted: bool ) -> None: - from loopx.control_plane.coordination import local_authority + from loopx.control_plane.work_items import refresh_recommendation registry, path, goal = _fixture(tmp_path) if promoted: _promote(registry, path, goal) path.write_text(_state(done=True, text="Stale work")) - original = local_authority.read_canonical_todos_if_promoted + original = refresh_recommendation.read_canonical_todos_if_promoted reads = [] def read(**kwargs): @@ -454,7 +454,7 @@ def read(**kwargs): reads.append(result) return result - monkeypatch.setattr(local_authority, "read_canonical_todos_if_promoted", read) + monkeypatch.setattr(refresh_recommendation, "read_canonical_todos_if_promoted", read) result = _refresh(registry) assert "Canonical work" in json.dumps(result) assert len(reads) == 1 @@ -479,11 +479,15 @@ def test_refresh_todo_text_is_record_content_not_an_artifact_path( record = json.loads(Path(result["json_path"]).read_text()) assert text in json.dumps(record) assert not list(tmp_path.rglob("escape.json")) - assert path.read_bytes() == before + if promoted: + assert result["projection_delivery"] == "delivered" + assert text in path.read_text() + else: + assert path.read_bytes() == before @pytest.mark.parametrize("promoted", [False, True]) -def test_public_refresh_missing_projection_is_readable_not_implicitly_rebuilt( +def test_preview_refresh_missing_projection_is_readable_not_implicitly_rebuilt( tmp_path: Path, promoted: bool, ) -> None: registry, path, goal = _fixture(tmp_path) @@ -502,18 +506,21 @@ def test_public_refresh_missing_projection_is_readable_not_implicitly_rebuilt( assert not path.exists() -def test_committed_refresh_records_canonical_recommendation_without_rewriting_display( +def test_committed_refresh_records_canonical_recommendation_and_repairs_display( tmp_path: Path, ) -> None: registry, path, goal = _fixture(tmp_path) _promote(registry, path, goal) path.write_text(_state(done=True, text="Stale display")) before = read_canonical_todos_if_promoted(runtime_root=tmp_path / "runtime", goal_id="goal-a") - display = path.read_bytes() - _refresh(registry, dry_run=False) + from loopx.control_plane.todos.projection_document import TodoProjectionDocument + narrative = TodoProjectionDocument.parse(path.read_text()).narrative + result = _refresh(registry, dry_run=False) runs = (tmp_path / "runtime/goals/goal-a/runs/index.jsonl").read_text() assert "Canonical work" in runs - assert path.read_bytes() == display + assert result["projection_delivery"] == "delivered" + assert "Canonical work" in path.read_text() and "Stale display" not in path.read_text() + assert TodoProjectionDocument.parse(path.read_text()).narrative == narrative assert ( read_canonical_todos_if_promoted(runtime_root=tmp_path / "runtime", goal_id="goal-a") == before diff --git a/tests/control_plane/test_refresh_todo_projection.py b/tests/control_plane/test_refresh_todo_projection.py new file mode 100644 index 000000000..578ba33af --- /dev/null +++ b/tests/control_plane/test_refresh_todo_projection.py @@ -0,0 +1,268 @@ +"""State refresh repairs canonical display without another business mutation.""" + +import json + +import pytest +from canonical_authority_fixture import ( + initialize_canonical_authority, + isolate_sqlite_runtime, +) +from tests.control_plane import test_refresh_external_delivery as refresh_fixtures +from loopx.control_plane.coordination.local_authority import ( + read_canonical_todos_if_promoted, +) +from loopx.state_refresh import refresh_state_run +from tests.control_plane import test_todo_projection_concurrency as projection_fixtures + +canonical_projection = projection_fixtures.canonical_projection +session = refresh_fixtures.session + + +def test_refresh_delivers_committed_todos_without_a_new_mutation(canonical_projection): + args, state, first, _ = canonical_projection + before = read_canonical_todos_if_promoted( + runtime_root=args["runtime_root"], goal_id=args["goal_id"] + ) + result = refresh_state_run( + registry_path=args["registry_path"], + runtime_root_override=str(args["runtime_root"]), + goal_id=args["goal_id"], + project=None, + state_file=None, + classification="validated_change", + recommended_action="Inspect the next bounded task.", + dry_run=False, + sync_global=False, + ) + assert result["ok"] and result["appended"] + assert result["projection_delivery"] == "delivered" + assert "Canonical work" in state.read_text() + assert "Human narrative." in state.read_text() + after = read_canonical_todos_if_promoted( + runtime_root=args["runtime_root"], goal_id=args["goal_id"] + ) + assert (before["provider_revision"], before["cursor"], before["todos"]) == ( + after["provider_revision"], + after["cursor"], + after["todos"], + ) + assert after["provider_revision"] == first["provider_revision"] + + +def _refresh(args, **overrides): + return refresh_state_run( + registry_path=args["registry_path"], + runtime_root_override=str(args["runtime_root"]), + goal_id=args["goal_id"], + project=None, + state_file=None, + classification="validated_change", + recommended_action="Inspect the next bounded task.", + dry_run=overrides.pop("dry_run", False), + sync_global=False, + **overrides, + ) + + +def test_refresh_gap_uses_canonical_work_even_if_display_delivery_fails( + canonical_projection, monkeypatch +): + from loopx.control_plane.todos import provider_projection + from loopx.state_refresh import render_state_refresh_markdown + + args, state, _, _ = canonical_projection + state.write_text("## Next Action\n- Implement the next task.\n\n## Agent Todo\n") + + def fail(*a, **kw): + raise OSError("display unavailable") + + monkeypatch.setattr(provider_projection, "atomic_write_state_text", fail) + result = _refresh(args) + assert result["ok"] and result["appended"] + assert "state_projection_gap" not in result + assert result["projection_delivery"] == "pending" + assert result["projection_outbox"]["retry_business_mutation"] is False + assert "pending" in render_state_refresh_markdown(result) + assert "without repeating" in render_state_refresh_markdown(result) + + +def test_refresh_empty_authority_cannot_be_hidden_by_stale_display(): + from pathlib import Path + from loopx.state_refresh import build_state_refresh_record + + source = "## Next Action\n- Implement the next task.\n\n## Agent Todo\n- [ ] Obsolete work\n" + args = dict( + goal_id="g", + state_file=Path("state.md"), + state_text=source, + classification="validated_change", + recommended_action="Inspect.", + recommended_action_source="explicit_arg", + generated_at="2026-01-01T00:00:00Z", + registry_goal={}, + ) + assert "state_projection_gap" not in build_state_refresh_record(**args) + authoritative_empty = build_state_refresh_record(**args, todo_fields={}) + assert authoritative_empty["state_projection_gap"]["agent_open_count"] == 0 + assert ( + authoritative_empty["state_projection_gap"]["requires_todo_expansion"] is True + ) + + +def test_refresh_dry_run_does_not_deliver_display_or_append_history( + canonical_projection, +): + args, state, _, _ = canonical_projection + before = state.read_bytes() + result = _refresh(args, dry_run=True) + assert result["dry_run"] and not result["appended"] + assert "projection_delivery" not in result + assert state.read_bytes() == before + assert not ( + args["runtime_root"] / "goals" / args["goal_id"] / "runs/index.jsonl" + ).exists() + + +def test_refresh_missing_display_is_recovered_only_as_todo_sections( + canonical_projection, +): + args, state, _, _ = canonical_projection + state.unlink() + result = _refresh(args) + assert result["ok"] and result["projection_delivery"] == "delivered" + assert result["projection_outbox"]["recovery_scope"] == "todo_sections_only" + assert result["projection_outbox"]["narrative_preserved"] is False + assert "Canonical work" in state.read_text() + + +def test_legacy_and_rejected_refresh_do_not_open_projection( + canonical_projection, monkeypatch +): + from loopx.control_plane.todos import provider_projection + + args, state, _, _ = canonical_projection + before = state.read_bytes() + + def unexpected(**kw): + raise AssertionError("must not repair a rejected or legacy refresh") + + monkeypatch.setattr( + provider_projection, "project_current_canonical_todos", unexpected + ) + rejected = {"ok": False, "appended": False} + assert ( + provider_projection.recover_refresh_todo_projection(rejected, **args) + is rejected + ) + monkeypatch.setattr( + provider_projection, "local_authority_is_promoted", lambda **kw: False + ) + legacy = {"ok": True, "appended": True} + assert provider_projection.recover_refresh_todo_projection(legacy, **args) is legacy + assert state.read_bytes() == before + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_cli_same_turn_replay_recovers_projection_without_duplicate_writeback( + session, monkeypatch, tmp_path, provider +): + from loopx.control_plane.coordination.runtime_shadow import ( + build_runtime_shadow_source_snapshot, + ) + from loopx.control_plane.todos import provider_projection + from tests.control_plane.test_quota_settlement_cli import GOAL_ID + + project, runtime, registry, args, run, journal, index = session + isolate_sqlite_runtime(tmp_path, monkeypatch) + initial = run(args) + assert initial["appended"] + data = json.loads(registry.read_text()) + goal = data["goals"][0] + state = project / goal["state_file"] + projection, _ = build_runtime_shadow_source_snapshot( + goal=goal, runtime_root=runtime, state_path=state, registry_path=registry + ) + seeded = initialize_canonical_authority( + runtime, GOAL_ID, projection, state_path=state, provider=provider + ) + before = index.read_bytes(), journal.read_bytes() + original = state.read_text() + state.write_text("# Existing narrative\n\n## Agent Todo\n") + + def fail(*a, **kw): + raise OSError("display unavailable") + + with monkeypatch.context() as patch: + patch.setattr(provider_projection, "atomic_write_state_text", fail) + failed = run(args) + assert failed["ok"] and failed["idempotent_replay"] + assert failed["projection_delivery"] == "pending" + assert (index.read_bytes(), journal.read_bytes()) == before + recovered = run(args) + assert recovered["idempotent_replay"] and not recovered["appended"] + assert recovered["projection_delivery"] == "delivered" + assert "Existing narrative" in state.read_text() + assert projection["todos"][0]["todo_id"] in state.read_text() + stable = run(args) + assert stable["projection_delivery"] == "current" + assert (index.read_bytes(), journal.read_bytes()) == before + assert ( + read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID)[ + "provider_revision" + ] + == seeded["provider_revision"] + ) + # The preserved narrative is the current document, not an old receipt's copy. + assert original != state.read_text() + + +def test_recovery_fence_failure_cannot_hide_a_committed_refresh( + canonical_projection, monkeypatch +): + from loopx.control_plane.todos import provider_projection + + args, state, _, _ = canonical_projection + before = state.read_bytes() + + def fail(**kw): + raise PermissionError("fence stat unavailable") + + monkeypatch.setattr(provider_projection, "local_authority_is_promoted", fail) + result = _refresh(args) + assert result["ok"] and result["appended"] + assert result["projection_delivery"] == "pending" + assert result["projection_outbox"]["retry_business_mutation"] is False + assert state.read_bytes() == before + + +def test_refresh_reuses_planning_snapshot_but_rechecks_a_concurrent_commit( + canonical_projection, monkeypatch +): + from loopx.control_plane.todos import provider_projection + + args, _, _, advance = canonical_projection + read = provider_projection.read_canonical_todos_if_promoted + write = provider_projection.atomic_write_state_text + confirmations, commits = [], [] + + def observe(**kwargs): + confirmations.append(kwargs.get("projection_readback")) + return read(**kwargs) + + def overlap(*a, **kw): + write(*a, **kw) + if not commits: + commits.append(advance()) + + monkeypatch.setattr( + provider_projection, "read_canonical_todos_if_promoted", observe + ) + monkeypatch.setattr(provider_projection, "atomic_write_state_text", overlap) + result = _refresh(args) + assert result["projection_delivery"] == "delivered" + assert ( + result["projection_outbox"]["provider_revision"] + == commits[0]["provider_revision"] + ) + assert result["projection_outbox"]["delivery_attempts"] == 2 + assert len(confirmations) == 2 and all(confirmations) # No repeated initial read. diff --git a/tests/control_plane/test_todo_machine_section_projection.py b/tests/control_plane/test_todo_machine_section_projection.py index c6a1e4389..35e59d530 100644 --- a/tests/control_plane/test_todo_machine_section_projection.py +++ b/tests/control_plane/test_todo_machine_section_projection.py @@ -103,6 +103,7 @@ def _confirmed_payload(payload, request): "provider_revision": witness["provider_revision"], "observed_provider_revision": payload["provider_revision"], "status": "delivered" if witness["changed"] else "current", + "next_action": "finish", }} diff --git a/tests/control_plane/test_todo_projection_recovery.py b/tests/control_plane/test_todo_projection_recovery.py index fc7a81c7e..f9898383f 100644 --- a/tests/control_plane/test_todo_projection_recovery.py +++ b/tests/control_plane/test_todo_projection_recovery.py @@ -299,6 +299,22 @@ def test_production_scale_rebuild_retains_order_and_requires_private_declaration assert len(parsed_ids) == 464 assert _read(runtime) == before + # A routine refresh must also drain the full source, including records well + # beyond presentation limits, without re-running a canonical mutation. + from loopx.state_refresh import refresh_state_run + state.unlink() + refreshed = refresh_state_run( + registry_path=registry, runtime_root_override=str(runtime), goal_id="goal-a", + project=None, state_file=None, classification="validated_change", + recommended_action="Inspect recovered Todo display.", dry_run=False, sync_global=False, + ) + assert refreshed["ok"] and refreshed["projection_delivery"] == "delivered" + assert refreshed["projection_outbox"]["todo_count"] == 464 + active, archived, _ = parse_todo_source(state.read_text()) + recovered_ids = {row["todo_id"] for rows in (*active.values(), archived) for row in rows} + assert recovered_ids == set(parsed_ids) + assert _read(runtime) == before + def test_equal_display_does_not_acknowledge_an_unfinished_durability_barrier(canonical_display, monkeypatch): registry, runtime, state = canonical_display diff --git a/tests/control_plane/test_todo_provider_projection.py b/tests/control_plane/test_todo_provider_projection.py index 1d8bf2c0b..bd1f3da95 100644 --- a/tests/control_plane/test_todo_provider_projection.py +++ b/tests/control_plane/test_todo_provider_projection.py @@ -77,6 +77,7 @@ def _authority_read(**kwargs) -> dict[str, object]: "provider_revision": witness["provider_revision"], "observed_provider_revision": result["provider_revision"], "status": "delivered" if witness["changed"] else "current", + "next_action": "finish", } return result diff --git a/tests/control_plane_ts/canonical_snapshot_conformance.ts b/tests/control_plane_ts/canonical_snapshot_conformance.ts index bf178fa30..4ce9f35e2 100644 --- a/tests/control_plane_ts/canonical_snapshot_conformance.ts +++ b/tests/control_plane_ts/canonical_snapshot_conformance.ts @@ -102,7 +102,8 @@ export function registerCanonicalSnapshotConformance(name: string, factory: Auth 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}}]) { + for (const changed of [{include_leases: false}, + {projection_readback: {provider_revision: "other", changed: true, attempt: 1, target: "latest" as const}}]) { await assert.rejects(readCanonicalSnapshotFromStore(snapshotRequest({...changed, after: first.next}), store), {code: "canonical_snapshot_changed"}); } @@ -128,7 +129,8 @@ export function registerCanonicalSnapshotConformance(name: string, factory: Auth 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}})); + const result = await collectSnapshot(store, snapshotRequest({projection_readback: + {provider_revision: revision, changed, attempt: 1, target: "latest" as const}})); assert.ok(result.pages.every(page => ((page.metadata as JsonObject).projection_readback as JsonObject).status === expected)); } }); diff --git a/tests/control_plane_ts/projection_confirmation_conformance.ts b/tests/control_plane_ts/projection_confirmation_conformance.ts index fdb867a1b..40d9b5421 100644 --- a/tests/control_plane_ts/projection_confirmation_conformance.ts +++ b/tests/control_plane_ts/projection_confirmation_conformance.ts @@ -26,11 +26,11 @@ export function registerProjectionConfirmationConformance(name: string, factory: assert.equal(Object.hasOwn(plain, "projection_readback"), false); for (const changed of [false, true]) { const confirmed = await listLocalCoordinationTodos({...request, - projection_readback: {provider_revision: seeded.provider_revision, changed}}, dependencies); + projection_readback: {provider_revision: seeded.provider_revision, changed, attempt: 1, target: "latest"}}, dependencies); const {projection_readback, ...unchanged} = confirmed; assert.deepEqual(unchanged, plain, "confirmation cannot change full-source semantics"); assert.deepEqual(projection_readback, {status: changed ? "delivered" : "current", - provider_revision: seeded.provider_revision, observed_provider_revision: seeded.provider_revision}); + provider_revision: seeded.provider_revision, observed_provider_revision: seeded.provider_revision, next_action: "finish"}); } const before = await contender.loadAuthority(); assert.equal(before.status, "loaded"); if (before.status !== "loaded") return; @@ -39,9 +39,20 @@ export function registerProjectionConfirmationConformance(name: string, factory: assert.equal(committed.status, "applied"); const after = await store.loadAuthority(); const stale = await listLocalCoordinationTodos({...request, include_leases: true, - projection_readback: {provider_revision: seeded.provider_revision, changed: true}}, dependencies); - assert.deepEqual(stale.projection_readback, {status: "pending", provider_revision: seeded.provider_revision, - observed_provider_revision: committed.provider_revision}); + projection_readback: {provider_revision: seeded.provider_revision, changed: true, attempt: 1, target: "latest"}}, dependencies); + const confirmation = stale.projection_readback as Record; + assert.equal(confirmation.status, "pending"); + assert.equal(confirmation.provider_revision, seeded.provider_revision); + assert.equal(confirmation.observed_provider_revision, committed.provider_revision); + assert.equal(confirmation.next_action, "retry"); + assert.equal(confirmation.retry_business_mutation, false); + for (const target of ["latest", "pinned"]) for (const attempt of [1, 3]) { + const result = await listLocalCoordinationTodos({...request, + projection_readback: {provider_revision: seeded.provider_revision, changed: true, attempt, target}}, dependencies); + const decision = result.projection_readback as Record; + assert.equal(decision.next_action, target === "latest" && attempt === 1 ? "retry" : "finish"); + assert.equal(decision.status, "pending"); + } assert.equal((stale.todos as unknown[]).length, fixture.expected_initial_todo_count); assert.equal((stale.leases as unknown[]).length, fixture.expected_current_lease_count); assert.equal(stale.provider_revision, committed.provider_revision); diff --git a/tests/control_plane_ts/projection_delivery.test.ts b/tests/control_plane_ts/projection_delivery.test.ts index 734ce7f3a..8d2747383 100644 --- a/tests/control_plane_ts/projection_delivery.test.ts +++ b/tests/control_plane_ts/projection_delivery.test.ts @@ -37,14 +37,37 @@ test("end-to-end fixture preserves delivery causal chain", async () => { test("projection confirmation binds durable host readback to one observed revision", async () => { const {decodeProjectionReadback, confirmProjectionReadback} = await import("../../loopx/control_plane/todos/projection_delivery.ts"); for (const changed of [true, false]) { - const readback = decodeProjectionReadback({provider_revision: "revision-a", changed}); + const readback = decodeProjectionReadback({provider_revision: "revision-a", changed, attempt: 1, target: "latest"}); assert.equal(confirmProjectionReadback(readback, "revision-a").status, changed ? "delivered" : "current"); - assert.deepEqual(confirmProjectionReadback(readback, "revision-b"), { - status: "pending", provider_revision: "revision-a", observed_provider_revision: "revision-b", - }); + const overlap = confirmProjectionReadback(readback, "revision-b"); + assert.equal(overlap.status, "pending"); + assert.equal(overlap.next_action, "retry"); + assert.equal(overlap.observed_provider_revision, "revision-b"); } for (const bad of [null, [], {}, {provider_revision: "", changed: false}, {provider_revision: "a", changed: "true"}, {provider_revision: "a", changed: false, verified: true}]) { assert.throws(() => decodeProjectionReadback(bad)); } }); + + +test("delivery retry is bounded and pinned requests cannot chase a new head", async () => { + const {decodeProjectionReadback, confirmProjectionReadback} = await import("../../loopx/control_plane/todos/projection_delivery.ts"); + for (const target of ["pinned", "latest"]) for (const attempt of [1, 2, 3]) { + const witness = decodeProjectionReadback({provider_revision: "old", changed: true, attempt, target}); + const pending = confirmProjectionReadback(witness, "new"); + assert.equal(pending.status, "pending"); + assert.equal(pending.next_action, target === "latest" && attempt < 3 ? "retry" : "finish"); + const current = confirmProjectionReadback(witness, "old"); + assert.equal(current.status, "delivered"); + assert.equal(current.next_action, "finish"); + } + const valid = {provider_revision: "old", changed: true, attempt: 1, target: "latest"}; + for (const attempt of [0, -1, 1.5, 4, "1", null, Number.NaN]) { + assert.throws(() => decodeProjectionReadback({...valid, attempt})); + } + for (const target of [null, "", "maybe", false]) { + assert.throws(() => decodeProjectionReadback({...valid, target})); + } + assert.throws(() => decodeProjectionReadback({...valid, extra: true})); +});