From ade8083e814126c82455a43c73ce5e36a9a07c42 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:50:10 +0800 Subject: [PATCH 1/4] fix(authority): recover canonical Todo display during refresh Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../coordination/local_authority.py | 4 +- .../todos/projection_delivery.ts | 46 ++- .../todos/provider_projection.py | 59 +++- .../work_items/refresh_recommendation.py | 24 +- loopx/state_refresh.py | 40 ++- .../test_canonical_planning_consumers.py | 27 +- .../test_refresh_todo_projection.py | 268 ++++++++++++++++++ .../test_todo_machine_section_projection.py | 1 + .../test_todo_projection_recovery.py | 16 ++ .../test_todo_provider_projection.py | 1 + .../projection_confirmation_conformance.ts | 21 +- .../projection_delivery.test.ts | 31 +- 12 files changed, 481 insertions(+), 57 deletions(-) create mode 100644 tests/control_plane/test_refresh_todo_projection.py diff --git a/loopx/control_plane/coordination/local_authority.py b/loopx/control_plane/coordination/local_authority.py index 6dd85e6fa7..87442f910a 100644 --- a/loopx/control_plane/coordination/local_authority.py +++ b/loopx/control_plane/coordination/local_authority.py @@ -258,7 +258,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 d882241434..30fc401854 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 45140be462..39fe2c2ed1 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 f3ec0810cb..f7a97bd242 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/state_refresh.py b/loopx/state_refresh.py index 9cfd0cbd23..c5ae786f3f 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 1f1ab8e7ba..16c3aff171 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 0000000000..578ba33af9 --- /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 c6a1e43895..35e59d530e 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 fc7a81c7ec..f9898383fe 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 1d8bf2c0bd..bd1f3da954 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/projection_confirmation_conformance.ts b/tests/control_plane_ts/projection_confirmation_conformance.ts index fdb867a1b9..40d9b5421c 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 734ce7f3a9..8d27473834 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})); +}); From 386c7e8b6a0a23e6dd1a0a7b21785893d0ff2a96 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:50:21 +0800 Subject: [PATCH 2/4] docs(rfc): reconcile local authority rollout and refresh recovery Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- ...shared-goal-authority-state-provider-v0.md | 36 +++++++++----- ...-goal-authority-state-provider-v0.zh-CN.md | 16 +++++-- .../typescript-control-plane-migration-v0.md | 7 ++- ...script-control-plane-migration-v0.zh-CN.md | 9 ++++ .../active-state-structured-projection-v0.md | 48 +++++++++++++++++-- 5 files changed, 96 insertions(+), 20 deletions(-) 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 dd1247ca0a..6a7abae0bd 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -3168,12 +3168,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.** @@ -3266,6 +3269,17 @@ 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 estimated **5–8 complete +packages**, not a count of small fixes: (1) remaining caller/executor fences, +(2) full consumer/projection qualification, (3) SQLite D2, (4) capture and whole-Goal +migration, (5) new-Goal defaults and bounded writer retirement. This refresh +slice advances package 2 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 is also in review and must be qualified at its accepted head. None of +these PR statuses grants cutover or changes the selected profile. + **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 @@ -3327,12 +3341,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 remaining default-on program is still approximately **5–8 cohesive PR packages**, with scope rather than line counts determining the split: caller / 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..804f623802 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 @@ -2464,10 +2464,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 部署。** @@ -2532,6 +2534,12 @@ 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 已合并,后续应验收组合 head,不继续沿用旧的 PR hold。快照分页 #4922、 +SQLite 读取证明优化 #4931 仍在评审。#4910 已加入更大测量轴;#4224 实测 1 MiB +receipt/scan 超预算,恢复和自然时间资格仍有缺项,不能将优化 PR 当成 D2 通过。 +本次刷新恢复推进下述 consumer/投影包,但没有把其他调用方或默认切换标记完成。 + **开发节奏以证据推进。** 先核对在途 stack,再按完整操作交付 A;L6/L7 可独立推进。 B 汇合为完整用户流程,C 形成一次可复现资格检查点,D 用独立 PR 修改默认。 按当前已合并边界,剩余 caller/executor 约 1–2 个包,consumer/投影 1 个, diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 5ec9d57164..6cd773d8b5 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -1869,8 +1869,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 fa8ef46bd9..d6dbdcf4de 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 @@ -1436,6 +1436,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/protocols/active-state-structured-projection-v0.md b/docs/reference/protocols/active-state-structured-projection-v0.md index e79c0fe760..5747965dd4 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 From fa245069c60cb10e95a615d0e7a9543e0e5faece Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:41:26 +0800 Subject: [PATCH 3/4] chore(semantics): refresh registry IO census after projection refactor Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/semantics/project_registry_io_manifest_v1.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 4a51d08c9b..54622aa94d 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -1311,7 +1311,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", @@ -1535,7 +1535,7 @@ }, { "site": "loopx/history.py::.collect_history::codec_read:load_registry#1", - "line": 301, + "line": 327, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -1543,7 +1543,7 @@ }, { "site": "loopx/history.py::.inspect_index_duplicates::codec_read:load_registry#1", - "line": 542, + "line": 571, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -1551,7 +1551,7 @@ }, { "site": "loopx/history.py::.rebuild_index_artifact_collisions::codec_read:load_registry#1", - "line": 756, + "line": 785, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -1559,7 +1559,7 @@ }, { "site": "loopx/history.py::.repair_index_duplicates::codec_read:load_registry#1", - "line": 646, + "line": 675, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -1695,7 +1695,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", From bb940c3fcd8991c5ffb273fc81817333cbf74a6f Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:00:04 +0800 Subject: [PATCH 4/4] fix(control-plane): pass the typed projection readback to the snapshot page The refreshed main's canonical snapshot conformance still built the request readback as `{provider_revision, changed}`, while the adjusted contract decodes `provider_revision`, `changed`, `attempt` and `target`, so the two cases that exercise the projection confirmation failed on a decode error instead of the expected `canonical_snapshot_changed` rejection. The shipped producer and both TypeScript entrypoints already send the typed shape, so the conformance request is updated to it and the pagination reference names the typed fields. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- docs/reference/canonical-snapshot-pagination.md | 5 +++++ tests/control_plane_ts/canonical_snapshot_conformance.ts | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/reference/canonical-snapshot-pagination.md b/docs/reference/canonical-snapshot-pagination.md index 6c24aaf97d..cf2f64a50b 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/tests/control_plane_ts/canonical_snapshot_conformance.ts b/tests/control_plane_ts/canonical_snapshot_conformance.ts index bf178fa30d..4ce9f35e2d 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)); } });