From 9644f33190c58638b791b8c7909cde63f18427dc Mon Sep 17 00:00:00 2001 From: wchwawa Date: Sun, 6 Sep 2026 14:54:00 +1000 Subject: [PATCH 01/27] fix(coordination): bind shadow capture and recovery to durable lineages Signed-off-by: wchwawa --- loopx/bootstrap.py | 68 +- loopx/cli_commands/bootstrap_connect.py | 1 + loopx/cli_commands/coordination_shadow.py | 100 +- loopx/cli_commands/handoff_mode.py | 5 +- loopx/cli_commands/project.py | 6 +- loopx/cli_commands/project_lifecycle.py | 5 + .../cli_commands/registry_admin_lifecycle.py | 5 + loopx/cli_commands/task_lease.py | 95 +- loopx/cli_commands/todo.py | 148 +- loopx/cli_commands/todo_event.py | 6 +- loopx/cli_commands/turn.py | 1 + loopx/configure_goal.py | 2 +- .../coordination_state_contract.generated.ts | 19 +- .../coordination_state_contract_generated.py | 27 +- .../coordination_state_contract_v0.json | 15 +- .../coordination/file_authority_store.ts | 38 +- .../coordination/legacy_writer_fence.py | 74 +- .../coordination/legacy_writer_fence.ts | 121 +- .../coordination/legacy_writer_lock_paths.ts | 19 + .../coordination/local_authority_runtime.ts | 310 ++-- .../coordination/local_authority_shadow.ts | 421 ++++- .../local_authority_shadow_adapter.py | 1284 ++++++--------- .../local_authority_shadow_identity.ts | 16 + .../local_authority_shadow_observation.py | 479 ++++++ .../local_authority_shadow_outbox.py | 605 ++++--- .../local_authority_shadow_outbox.ts | 144 +- .../coordination/runtime_shadow.py | 184 ++- .../coordination/runtime_shadow.ts | 1467 ++++------------- .../runtime_shadow_writer_adapter.py | 84 +- .../coordination/shadow_management.py | 194 +++ .../coordination/shadow_management.ts | 541 ++++++ loopx/control_plane/projects/registry.py | 24 +- .../todos/active_state_editing.py | 30 + loopx/control_plane/todos/event_writeback.py | 480 +++--- loopx/control_plane/todos/handoff_mode.py | 32 +- .../work_items/task_lease_acquire.ts | 42 +- .../work_items/task_lease_acquire_adapter.py | 4 +- .../work_items/task_lease_lifecycle.ts | 35 +- loopx/feedback.py | 48 +- loopx/file_lock.py | 33 +- loopx/state_migration.py | 192 ++- loopx/state_refresh.py | 17 +- loopx/status_server.py | 1 + loopx/todo_followups.py | 38 +- loopx/todos.py | 19 +- .../generate_coordination_state_contract.py | 5 + 46 files changed, 4236 insertions(+), 3248 deletions(-) create mode 100644 loopx/control_plane/coordination/legacy_writer_lock_paths.ts create mode 100644 loopx/control_plane/coordination/local_authority_shadow_identity.ts create mode 100644 loopx/control_plane/coordination/local_authority_shadow_observation.py create mode 100644 loopx/control_plane/coordination/shadow_management.py create mode 100644 loopx/control_plane/coordination/shadow_management.ts diff --git a/loopx/bootstrap.py b/loopx/bootstrap.py index c6419898fe..4138655e83 100644 --- a/loopx/bootstrap.py +++ b/loopx/bootstrap.py @@ -4,12 +4,18 @@ import re import shlex from pathlib import Path + +from .file_lock import exclusive_cross_runtime_file_lock +from .registry import atomic_write_json, find_registry_goal +from .control_plane.coordination.legacy_writer_fence import legacy_todo_write_transaction, require_legacy_state_replacement_allowed +from .control_plane.coordination.runtime_shadow_writer_adapter import require_runtime_shadow_capture_prepared, begin_todo_runtime_shadow_capture, settle_todo_runtime_shadow_capture from typing import Any from .control_plane.runtime.time import now_local_iso from .control_plane.runtime.public_safety import public_safe_compact_text from .control_plane.todos.active_state_editing import ( TODO_SECTION_HEADINGS, + atomic_write_state_text, insertion_anchor, section_bounds, ) @@ -33,7 +39,7 @@ DEFAULT_ORCHESTRATION_MODE, MULTI_SUBAGENT_ORCHESTRATION_MODE, ) -from .paths import DEFAULT_RUNTIME_ROOT, rel_or_abs +from .paths import rel_or_abs, resolve_runtime_root from .registry_writability import probe_registry_write_path from .todos import add_todo_to_lines @@ -751,7 +757,7 @@ def bootstrap_project( if not state_file.is_absolute(): state_file = project / state_file goal_doc = resolve_project_path(project, goal_doc) - runtime_root = runtime_root.expanduser() if runtime_root else DEFAULT_RUNTIME_ROOT + runtime_root = resolve_runtime_root(read_json_if_exists(registry_path), str(runtime_root) if runtime_root else None, registry_path=registry_path) updated_at = now_iso() execution_profile = build_execution_profile( minimum_scale=execution_minimum_scale, @@ -959,12 +965,30 @@ def bootstrap_project( "private_boundary_note": "Add .loopx/ and .codex/goals/ to the project .gitignore if the goal state contains private evidence.", "error": str(global_writability.get("error") or "global registry is not writable"), } + shadow_capture = None + shadow_evidence: dict[str, Any] = {} if not dry_run: - write_json(registry_path, registry) - if state_action in {"created", "replaced"}: - state_file.parent.mkdir(parents=True, exist_ok=True) - state_file.write_text( - render_state_markdown( + with exclusive_cross_runtime_file_lock(registry_path, operation="bootstrap_registry"), legacy_todo_write_transaction( + registry_path, goal_id, state_file, None, "bootstrap_state", False, + runtime_root=runtime_root, + ): + current_registry = read_json_if_exists(registry_path) + current_goal = find_registry_goal(current_registry, goal_id) + previous_root = resolve_runtime_root(current_registry, None, registry_path=registry_path) + if previous_root != runtime_root: + for previous_goal in current_registry.get("goals", []): + if isinstance(previous_goal, dict) and previous_goal.get("id"): + require_legacy_state_replacement_allowed(runtime_root=previous_root, + goal_id=str(previous_goal["id"]), goal=previous_goal) + original = state_file.read_text(encoding="utf-8") if state_file.exists() else "" + if force or not state_file.exists(): + require_legacy_state_replacement_allowed(runtime_root=runtime_root, + goal_id=goal_id, goal=current_goal) + state_action = ("kept-existing-preserve-todos" if force and preserve_todos else "kept-existing") if state_file.exists() and (not force or preserve_todos) else "replaced" if state_file.exists() else "created" + planned = original + if state_action in {"created", "replaced"}: + declared_handoff_mode = goal_handoff_mode(original) if original and force else HANDOFF_MODE_LEGACY + planned = render_state_markdown( project=project, goal_id=goal_id, adapter_kind=adapter_kind, @@ -978,13 +1002,28 @@ def bootstrap_project( codex_app_heartbeat=codex_app_heartbeat, include_connection_validation=include_connection_validation, handoff_mode=declared_handoff_mode, - ), - encoding="utf-8", - ) - elif repaired_state_text is not None and repaired_todo_source_roles: - state_file.write_text(repaired_state_text, encoding="utf-8") - if todo_source_migration is not None: - todo_source_migration["applied"] = True + ) + else: + planned, repaired_todo_source_roles = repair_missing_todo_source_sections(original) + if planned != original: + shadow_capture = begin_todo_runtime_shadow_capture(registry_path=registry_path, + runtime_root=runtime_root, goal_id=goal_id, state_path=state_file, + write_class="bootstrap_state", original_text=original) + shadow_capture.prepare(planned) + require_runtime_shadow_capture_prepared(shadow_capture, runtime_root=runtime_root, goal_id=goal_id) + atomic_write_state_text(state_file, planned) + shadow_capture.committed() + if todo_source_migration is not None: + todo_source_migration["applied"] = True + current_registry.setdefault("schema_version", "0.1") + current_registry["updated_at"] = updated_at.split("T")[0] + current_registry["common_runtime_root"] = str(runtime_root) + registry, registry_goal_action = merge_goal(current_registry, goal_entry, force=force) + atomic_write_json(registry_path, registry) + if shadow_capture is not None: + shadow_evidence = settle_todo_runtime_shadow_capture({}, registry_path=registry_path, + runtime_root=runtime_root, goal_id=goal_id, write_class="bootstrap_state", + capture=shadow_capture, observe_legacy=False, emit_disabled=False) if sync_global: global_sync = sync_project_registry_to_global( registry_path=registry_path, @@ -995,6 +1034,7 @@ def bootstrap_project( ) return { + **shadow_evidence, "ok": True, "dry_run": dry_run, "project": str(project), diff --git a/loopx/cli_commands/bootstrap_connect.py b/loopx/cli_commands/bootstrap_connect.py index bf455903ac..de9d7207d9 100644 --- a/loopx/cli_commands/bootstrap_connect.py +++ b/loopx/cli_commands/bootstrap_connect.py @@ -257,6 +257,7 @@ def handle_bootstrap_connect_command( "ok": False, "registry": str(registry_path), "error": str(exc), + **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), } print_payload(payload, args.format, render_bootstrap_markdown) return 0 if payload.get("ok") else 1 diff --git a/loopx/cli_commands/coordination_shadow.py b/loopx/cli_commands/coordination_shadow.py index 10c5829149..e258c818c9 100644 --- a/loopx/cli_commands/coordination_shadow.py +++ b/loopx/cli_commands/coordination_shadow.py @@ -8,6 +8,7 @@ from ..control_plane.coordination.runtime_shadow import ( bootstrap_coordination_runtime_shadow, + build_runtime_shadow_source_snapshot, build_todo_runtime_shadow_projection, inspect_coordination_runtime_shadow, load_task_lease_runtime_shadow_records, @@ -19,7 +20,7 @@ from ..history import load_registry from ..paths import resolve_runtime_root from ..registry import find_registry_goal -from ..todos import list_goal_todos +from ..state_refresh import resolve_goal_state PrintPayload = Callable[ @@ -44,11 +45,11 @@ def register_coordination_shadow_command( ("inspect", "Compare the current legacy projection with the file shadow."), ( "qualify", - "Qualify sustained parity coverage across the file shadow lineage.", + "Validate bounded parity and transaction coverage for the active outbox lineage.", ), ( "read-candidate", - "Read one parity-matched file Todo as pre-promotion evidence.", + "Read one Todo from a freshly qualified bounded file shadow.", ), ( "bootstrap", @@ -67,23 +68,24 @@ def register_coordination_shadow_command( help="Execute the administrative effect; otherwise preview only.", ) if name == "rollback": - action.add_argument( + selector = action.add_mutually_exclusive_group(required=True) + selector.add_argument( "--provider-revision", - required=True, help="Exact file shadow revision observed by inspect.", ) + selector.add_argument("--bootstrap-operation-id", help="Exact pending bootstrap operation to abort and archive.") if name == "qualify": action.add_argument( "--minimum-operations", type=int, default=3, - help="Minimum distinct mirrored operations required (default: 3).", + help="Minimum verified primary mutations in this bounded lineage (default: 3).", ) action.add_argument( "--require-event-kind", action="append", default=[], - help="Required mirrored mutation kind; repeat for multiple kinds.", + help="Required verified outbox write class; repeat for multiple classes.", ) if name == "read-candidate": action.add_argument( @@ -140,6 +142,15 @@ def _render(payload: dict[str, object]) -> str: f"- read_candidate_qualified: `{read_candidate.get('read_candidate_qualified')}`", ] ) + bounded = qualification if isinstance(qualification, dict) else read_candidate + if isinstance(bounded, dict) and bounded.get("scope") == "bounded": + lines.extend([ + "- qualification_scope: `bounded`", + f"- sustained_parity_verdict: `{bounded.get('sustained_parity_verdict')}`", + ]) + policy = bounded.get("policy") + if isinstance(policy, dict): + lines.append(f"- minimum_primary_mutations: `{policy.get('minimum_operations')}`") error = payload.get("error") if error: lines.append(f"- error: `{error}`") @@ -185,29 +196,22 @@ def handle_coordination_shadow_command( runtime_root_arg, registry_path=registry_path, ) - todo_projection = list_goal_todos( - registry_path=registry_path, - goal_id=args.goal_id, - project=args.project, - state_file=args.state_file, - runtime_root_arg=runtime_root_arg, - ) - projection = build_todo_runtime_shadow_projection( - goal_id=args.goal_id, - todos=todo_projection.get("todos"), - leases=load_task_lease_runtime_shadow_records( - runtime_root=runtime_root, - goal_id=args.goal_id, - ), - ) + _, _, state_path = resolve_goal_state(registry=registry, goal_id=args.goal_id, + project_override=args.project, state_file_override=args.state_file) + if args.coordination_shadow_command == "rollback": + projection, source_snapshot = {}, {"state_path": str(state_path)} + else: + projection, source_snapshot = build_runtime_shadow_source_snapshot(goal=goal, + runtime_root=runtime_root, state_path=state_path, registry_path=registry_path) projection_version = _projection_version(projection) projected_todos = projection.get("todos") projected_leases = projection.get("leases") - inspection = inspect_coordination_runtime_shadow( + inspection = {"status": "not_evaluated"} if args.coordination_shadow_command == "rollback" else inspect_coordination_runtime_shadow( goal=goal, runtime_root=runtime_root, goal_id=args.goal_id, projection=projection, + source_snapshot=source_snapshot, ) payload: dict[str, object] = { "ok": inspection.get("status") != "failed", @@ -233,13 +237,23 @@ def handle_coordination_shadow_command( "decision_read_from_shadow": False, } if args.coordination_shadow_command == "bootstrap" and args.execute: + from ..control_plane.coordination.shadow_management import read_shadow_management_state + management = read_shadow_management_state(runtime_root, args.goal_id) + if management is not None and management["status"] in {"bootstrapping", "active"}: + operation_id = str(management["operation"]["operation_id"]) + else: + predecessor = management["operation"]["operation_id"] if management and management["status"] == "inactive" else "initial" + operation_digest = _projection_version({"predecessor": predecessor, "projection": projection, + "source_snapshot": source_snapshot, "runtime_root": str(runtime_root)}) + operation_id = f"shadow-bootstrap:{args.goal_id}:{operation_digest}" bootstrap = bootstrap_coordination_runtime_shadow( goal=goal, runtime_root=runtime_root, goal_id=args.goal_id, - operation_id=f"shadow-bootstrap:{args.goal_id}:{projection_version}", + operation_id=operation_id, source_version=str(payload["source_version"]), projection=projection, + source_snapshot=source_snapshot, ) payload["executed"] = True payload["bootstrap"] = bootstrap @@ -249,6 +263,7 @@ def handle_coordination_shadow_command( runtime_root=runtime_root, goal_id=args.goal_id, projection=projection, + source_snapshot=source_snapshot, ) final_inspection = payload["inspection"] payload["ok"] = bool( @@ -262,6 +277,7 @@ def handle_coordination_shadow_command( runtime_root=runtime_root, goal_id=args.goal_id, projection=projection, + source_snapshot=source_snapshot, minimum_operations=args.minimum_operations, required_event_kinds=args.require_event_kind, ) @@ -274,6 +290,7 @@ def handle_coordination_shadow_command( goal_id=args.goal_id, todo_id=args.todo_id, projection=projection, + source_snapshot=source_snapshot, ) payload["read_candidate"] = read_candidate payload["ok"] = bool( @@ -282,38 +299,21 @@ def handle_coordination_shadow_command( and read_candidate.get("decision_read_from_shadow") is False ) if args.coordination_shadow_command == "rollback": - provider_revision = str(args.provider_revision).strip() + provider_revision = getattr(args, "provider_revision", None) + pending_bootstrap = getattr(args, "bootstrap_operation_id", None) payload["expected_provider_revision"] = provider_revision - observed_revision = inspection.get("provider_revision") - if observed_revision is not None and observed_revision != provider_revision: - payload["ok"] = False - payload["error"] = "provider revision does not match active shadow" - payload["error_code"] = "shadow_provider_revision_mismatch" - elif args.execute: + payload["expected_bootstrap_operation_id"] = pending_bootstrap + if args.execute: rollback = rollback_coordination_runtime_shadow( - goal=goal, - runtime_root=runtime_root, - goal_id=args.goal_id, - operation_id=( - f"shadow-rollback:{args.goal_id}:{provider_revision}" - ), + goal=goal, runtime_root=runtime_root, goal_id=args.goal_id, + operation_id=f"shadow-rollback:{args.goal_id}:{provider_revision or pending_bootstrap}", expected_provider_revision=provider_revision, + expected_bootstrap_operation_id=pending_bootstrap, + projection=projection, source_snapshot=source_snapshot, ) payload["executed"] = True payload["rollback"] = rollback - if rollback.get("status") in {"applied", "replayed"}: - payload["inspection"] = inspect_coordination_runtime_shadow( - goal=goal, - runtime_root=runtime_root, - goal_id=args.goal_id, - projection=projection, - ) - final_inspection = payload["inspection"] - payload["ok"] = bool( - rollback.get("status") in {"applied", "replayed"} - and isinstance(final_inspection, dict) - and final_inspection.get("status") == "missing" - ) + payload["ok"] = rollback.get("status") in {"applied", "replayed", "recovered"} except Exception as exc: payload = { "ok": False, diff --git a/loopx/cli_commands/handoff_mode.py b/loopx/cli_commands/handoff_mode.py index d6becd214a..dca0050022 100644 --- a/loopx/cli_commands/handoff_mode.py +++ b/loopx/cli_commands/handoff_mode.py @@ -1,5 +1,8 @@ from __future__ import annotations +from ..control_plane.coordination.legacy_writer_fence import LegacyCoordinationWriterFenced +from ..control_plane.coordination.shadow_management import ShadowManagementError + import argparse from collections.abc import Callable from pathlib import Path @@ -128,7 +131,7 @@ def handle_handoff_mode_command( runtime_root_arg=runtime_root_arg, **path_args, ) - except HandoffModeError as exc: + except (HandoffModeError, LegacyCoordinationWriterFenced, ShadowManagementError) as exc: payload = { "ok": False, "schema_version": "goal_handoff_mode_v0", diff --git a/loopx/cli_commands/project.py b/loopx/cli_commands/project.py index 89afd89bac..42b618b1e9 100644 --- a/loopx/cli_commands/project.py +++ b/loopx/cli_commands/project.py @@ -1,5 +1,8 @@ from __future__ import annotations +from ..control_plane.coordination.legacy_writer_fence import LegacyCoordinationWriterFenced +from ..control_plane.coordination.shadow_management import ShadowManagementError + import argparse from collections.abc import Callable from pathlib import Path @@ -138,12 +141,13 @@ def handle_project_command( repository=args.repository, external_locator=args.external_locator, ) - except (OSError, TypeError, ValueError) as exc: + except (OSError, TypeError, ValueError, LegacyCoordinationWriterFenced, ShadowManagementError) as exc: payload = { "ok": False, "changed": False, "registry": str(registry_path), "error": str(exc), + **({"error_code": exc.code, **exc.payload} if isinstance(exc, (LegacyCoordinationWriterFenced, ShadowManagementError)) else {}), } payload.setdefault("registry", str(registry_path)) print_payload(payload, output_format(args), render_project_command_markdown) diff --git a/loopx/cli_commands/project_lifecycle.py b/loopx/cli_commands/project_lifecycle.py index 909905ba3e..0cf6bbb5d7 100644 --- a/loopx/cli_commands/project_lifecycle.py +++ b/loopx/cli_commands/project_lifecycle.py @@ -627,6 +627,7 @@ def handle_project_lifecycle_command( "appended": False, "dry_run": bool(args.dry_run), "error": str(exc), + **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), } print_payload(payload, fmt, render_state_refresh_markdown) return 1 @@ -683,6 +684,7 @@ def handle_project_lifecycle_command( "appended": False, "dry_run": bool(args.dry_run), "error": str(exc), + **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), } if isinstance(exc, ReplanWritebackRejected): transition = project_replan_writeback_rejection( @@ -921,6 +923,7 @@ def handle_project_lifecycle_command( "appended": False, "dry_run": bool(args.dry_run), "error": str(exc), + **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), } print_payload(payload, fmt, render_read_only_project_map_markdown) return 0 if payload.get("ok") else 1 @@ -961,6 +964,7 @@ def handle_project_lifecycle_command( "appended": False, "dry_run": bool(args.dry_run), "error": str(exc), + **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), } print_payload(payload, fmt, render_reward_markdown) return 0 if payload.get("ok") else 1 @@ -990,6 +994,7 @@ def handle_project_lifecycle_command( "appended": False, "dry_run": bool(args.dry_run), "error": str(exc), + **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), } print_payload(payload, fmt, render_operator_gate_markdown) return 0 if payload.get("ok") else 1 diff --git a/loopx/cli_commands/registry_admin_lifecycle.py b/loopx/cli_commands/registry_admin_lifecycle.py index 3d24069d68..5acda02188 100644 --- a/loopx/cli_commands/registry_admin_lifecycle.py +++ b/loopx/cli_commands/registry_admin_lifecycle.py @@ -211,6 +211,7 @@ def handle_registry_lifecycle_command( "dry_run": not bool(args.execute), "archived": False, "error": str(exc), + **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), } print_payload(payload, args.format, render_archive_runtime_markdown) return 0 if payload.get("ok") else 1 @@ -233,6 +234,7 @@ def handle_registry_lifecycle_command( "wrote": False, "backup_written": False, "error": str(exc), + **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), } print_payload(payload, args.format, render_global_goal_retirement_markdown) return 0 if payload.get("ok") else 1 @@ -258,6 +260,7 @@ def handle_registry_lifecycle_command( "wrote_local_registry": False, "wrote_global_registry": False, "error": str(exc), + **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), } print_payload(payload, args.format, render_project_uninstall_markdown) return 0 if payload.get("ok") else 1 @@ -281,6 +284,7 @@ def handle_registry_lifecycle_command( "global_registry": str(global_registry_path(runtime_root)), "dry_run": bool(args.dry_run), "error": str(exc), + **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), } print_payload(payload, args.format, render_global_sync_markdown) return 0 if payload.get("ok") else 1 @@ -343,6 +347,7 @@ def handle_registry_lifecycle_command( "target_runtime_root": args.target_runtime_root or args.runtime_root or str(DEFAULT_RUNTIME_ROOT), "selected_goal_ids": args.goal_id or ([] if not getattr(args, "all_goals", False) else [""]), "error": str(exc), + **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), } print_payload(payload, args.format, render_state_migration_markdown) return 0 if payload.get("ok") else 1 diff --git a/loopx/cli_commands/task_lease.py b/loopx/cli_commands/task_lease.py index fff383277e..4b39adb8a1 100644 --- a/loopx/cli_commands/task_lease.py +++ b/loopx/cli_commands/task_lease.py @@ -4,12 +4,6 @@ from collections.abc import Callable from pathlib import Path -from ..control_plane.coordination.runtime_shadow import ( - build_todo_runtime_shadow_projection, - dispatch_coordination_runtime_shadow, - load_task_lease_runtime_shadow_records, - resolve_coordination_runtime_shadow_config, -) from ..control_plane.work_items.task_lease import ( TaskLeaseError, inspect_task_lease, @@ -22,10 +16,7 @@ execute_native_task_lease_acquire, ) from ..file_lock import LockAcquireTimeoutError -from ..history import load_registry from ..presentation.markdown import append_operator_action_markdown -from ..registry import find_registry_goal -from ..todos import list_goal_todos PrintPayload = Callable[ @@ -34,77 +25,6 @@ ] -def _mirror_committed_task_lease_runtime_shadow( - payload: dict[str, object], - *, - args: argparse.Namespace, - registry_path: Path, - runtime_root_arg: str | None, - runtime_root: Path, -) -> dict[str, object] | None: - if not payload.get("ok") or args.task_lease_command == "inspect": - return None - try: - registry = load_registry(registry_path) - goal = find_registry_goal(registry, args.goal_id) - shadow_enabled = resolve_coordination_runtime_shadow_config(goal).enabled - except Exception: - return None - if not shadow_enabled: - return None - - lease = payload.get("lease") - source_version = ( - str(lease.get("updated_at") or "").strip() - if isinstance(lease, dict) - else "" - ) - idempotency_key = str(args.idempotency_key or "").strip() - if not source_version or not idempotency_key: - return { - "schema_version": "loopx_coordination_runtime_shadow_dispatch_v0", - "status": "failed", - "reason_code": "canonical_mutation_identity_missing", - "primary_writeback_preserved": True, - "decision_read_from_shadow": False, - } - try: - todo_projection = list_goal_todos( - registry_path=registry_path, - goal_id=args.goal_id, - runtime_root_arg=runtime_root_arg, - ) - projection = build_todo_runtime_shadow_projection( - goal_id=args.goal_id, - todos=todo_projection.get("todos"), - leases=load_task_lease_runtime_shadow_records( - runtime_root=runtime_root, - goal_id=args.goal_id, - ), - ) - except Exception as exc: - return { - "schema_version": "loopx_coordination_runtime_shadow_dispatch_v0", - "status": "failed", - "reason_code": "shadow_projection_unavailable", - "reason": str(exc), - "primary_writeback_preserved": True, - "decision_read_from_shadow": False, - } - return dispatch_coordination_runtime_shadow( - goal=goal, - runtime_root=runtime_root, - goal_id=args.goal_id, - operation_id=( - f"task-lease-shadow:{args.task_lease_command}:{args.goal_id}:" - f"{args.todo_id}:{idempotency_key}" - ), - event_kind=f"task_lease_{args.task_lease_command}", - source_version=source_version, - projection=projection, - ) - - def render_task_lease_markdown(payload: dict[str, object]) -> str: lines = [ "# LoopX Task Lease", @@ -292,6 +212,7 @@ def handle_task_lease_command( "schema_version": "task_lease_v0", "action": getattr(args, "task_lease_command", None), "error": str(exc), + **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), "error_code": exc.code, **exc.payload, } @@ -301,6 +222,7 @@ def handle_task_lease_command( "schema_version": "task_lease_v0", "action": getattr(args, "task_lease_command", None), "error": str(exc), + **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), **exc.to_payload(), } except Exception as exc: @@ -309,17 +231,8 @@ def handle_task_lease_command( "schema_version": "task_lease_v0", "action": getattr(args, "task_lease_command", None), "error": str(exc), - "error_code": exc.__class__.__name__, + **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), + "error_code": getattr(exc, "code", exc.__class__.__name__), } - if payload.get("ok") and args.task_lease_command != "inspect": - runtime_shadow = _mirror_committed_task_lease_runtime_shadow( - payload, - args=args, - registry_path=registry_path, - runtime_root_arg=runtime_root_arg, - runtime_root=runtime_root, - ) - if runtime_shadow is not None: - payload["coordination_runtime_shadow"] = runtime_shadow print_payload(payload, output_format(args), render_task_lease_markdown) return 0 if payload.get("ok") else 1 diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index 859358f678..017795602a 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -1,12 +1,10 @@ from __future__ import annotations import argparse -import os -import stat -import tempfile from collections.abc import Callable, Sequence from pathlib import Path +from ..control_plane.todos.active_state_editing import atomic_write_state_text as _atomic_write_text from ..control_plane.todos.contract import ( TODO_TASK_CLASS_ADVANCEMENT, normalize_todo_continuation_policy, @@ -14,12 +12,6 @@ replan_successor_semantic_binding, ) from ..control_plane.capability_hooks import PostWritebackHookRegistration -from ..control_plane.coordination.runtime_shadow import ( - build_todo_runtime_shadow_projection, - dispatch_coordination_runtime_shadow, - load_task_lease_runtime_shadow_records, - resolve_coordination_runtime_shadow_config, -) from ..control_plane.coordination.local_authority import ( read_canonical_todos_if_promoted, ) @@ -32,10 +24,12 @@ from ..control_plane.todos.machine_section_projection import ( render_canonical_todo_sections, ) -from ..file_lock import exclusive_file_lock +from ..file_lock import exclusive_cross_runtime_file_lock as exclusive_file_lock +from ..control_plane.coordination.shadow_management import require_shadow_primary_write_allowed +from ..control_plane.coordination.legacy_writer_fence import require_registry_source_write_allowed from ..history import load_index, load_registry from ..paths import resolve_runtime_root -from ..registry import find_registry_goal, registry_goals +from ..registry import registry_goals from ..control_plane.work_items.semantic_replan_writeback import ( qualify_replan_writeback, ) @@ -70,7 +64,6 @@ ) from .todo_event import ( RolloutEventAppender, - TODO_EVENT_KINDS, append_todo_rollout_event, todo_error_payload, ) @@ -86,36 +79,6 @@ ) -def _fsync_parent_directory(path: Path) -> None: - if os.name != "posix": # pragma: no cover - Windows has no directory fsync - return - descriptor = os.open(path.parent, os.O_RDONLY) - try: - os.fsync(descriptor) - finally: - os.close(descriptor) - - -def _atomic_write_text(path: Path, text: str) -> None: - """Durably replace a projection without changing the state-file mode.""" - - original_mode = stat.S_IMODE(path.stat().st_mode) - descriptor, temporary = tempfile.mkstemp( - prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent) - ) - temporary_path = Path(temporary) - try: - with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as handle: - os.chmod(temporary_path, original_mode) - handle.write(text) - handle.flush() - os.fsync(handle.fileno()) - os.replace(temporary_path, path) - _fsync_parent_directory(path) - finally: - temporary_path.unlink(missing_ok=True) - - def _read_text_exact(path: Path) -> str: """Decode UTF-8 while preserving every source newline sequence.""" @@ -129,87 +92,6 @@ def _read_text_exact(path: Path) -> str: ] -def _mirror_committed_todo_runtime_shadow( - payload: dict[str, object], - *, - args: argparse.Namespace, - registry_path: Path, - runtime_root_arg: str | None, -) -> dict[str, object] | None: - """Mirror an actual Todo write only after the legacy write has committed.""" - - if not payload.get("ok") or payload.get("dry_run"): - return None - changed = bool(payload.get("changed")) or any( - bool(payload.get(field)) - for field in ("added", "metadata_updated", "status_changed") - ) - if not changed: - return None - - try: - registry = load_registry(registry_path) - goal = find_registry_goal(registry, args.goal_id) - shadow_enabled = resolve_coordination_runtime_shadow_config(goal).enabled - except Exception: - # The optional observer cannot turn a committed canonical mutation into - # a failed command while the feature remains absent or unreadable. - return None - if not shadow_enabled: - return None - - rollout_event = payload.get("rollout_event") - event_id = ( - str(rollout_event.get("event_id") or "").strip() - if isinstance(rollout_event, dict) - else "" - ) - source_version = str(payload.get("updated_at") or "").strip() - if not event_id or not source_version: - return { - "schema_version": "loopx_coordination_runtime_shadow_dispatch_v0", - "status": "failed", - "reason_code": "canonical_mutation_identity_missing", - "primary_writeback_preserved": True, - "decision_read_from_shadow": False, - } - - runtime_root = resolve_runtime_root(registry, runtime_root_arg) - try: - todo_projection = list_goal_todos( - registry_path=registry_path, - goal_id=args.goal_id, - **_todo_path_args(args), - runtime_root_arg=runtime_root_arg, - ) - projection = build_todo_runtime_shadow_projection( - goal_id=args.goal_id, - todos=todo_projection.get("todos"), - leases=load_task_lease_runtime_shadow_records( - runtime_root=runtime_root, - goal_id=args.goal_id, - ), - ) - except Exception as exc: - return { - "schema_version": "loopx_coordination_runtime_shadow_dispatch_v0", - "status": "failed", - "reason_code": "shadow_projection_unavailable", - "reason": str(exc), - "primary_writeback_preserved": True, - "decision_read_from_shadow": False, - } - return dispatch_coordination_runtime_shadow( - goal=goal, - runtime_root=runtime_root, - goal_id=args.goal_id, - operation_id=f"todo-shadow:{event_id}", - event_kind=TODO_EVENT_KINDS.get(args.todo_command, "todo_update"), - source_version=source_version, - projection=projection, - ) - - def _completion_settlement_requirement( todo: dict[str, object], *, @@ -380,7 +262,7 @@ def handle_todo_command( validate_todo_project_markdown_options(args) registry = load_registry(registry_path) authority_read = read_canonical_todos_if_promoted( - runtime_root=resolve_runtime_root(registry, runtime_root_arg), + runtime_root=resolve_runtime_root(registry, runtime_root_arg, registry_path=registry_path), goal_id=args.goal_id, ) if not isinstance(authority_read, dict): @@ -409,6 +291,15 @@ def handle_todo_command( provider_revision=args.provider_revision, ) if args.execute and projection.changed: + require_registry_source_write_allowed( + registry_path=registry_path, + runtime_root=resolve_runtime_root(registry, runtime_root_arg, registry_path=registry_path), + goal_id=args.goal_id, + state_file=state_path, + ) + require_shadow_primary_write_allowed( + resolve_runtime_root(registry, runtime_root_arg, registry_path=registry_path), args.goal_id, + ) _atomic_write_text(state_path, projection.markdown) if _read_text_exact(state_path) != projection.markdown: raise RuntimeError("Todo Markdown projection readback mismatch") @@ -775,14 +666,7 @@ def handle_todo_command( runtime_root_arg=runtime_root_arg, append_cli_rollout_event=append_cli_rollout_event, ) - runtime_shadow = _mirror_committed_todo_runtime_shadow( - payload, - args=args, - registry_path=registry_path, - runtime_root_arg=runtime_root_arg, - ) - if runtime_shadow is not None: - payload["coordination_runtime_shadow"] = runtime_shadow + if ( args.todo_command == "complete" and getattr(args, "turn_instance_id", None) diff --git a/loopx/cli_commands/todo_event.py b/loopx/cli_commands/todo_event.py index 102975bc3b..8c41d8135b 100644 --- a/loopx/cli_commands/todo_event.py +++ b/loopx/cli_commands/todo_event.py @@ -9,6 +9,10 @@ from ..control_plane.todos.contract import decision_scope_metadata_value from ..control_plane.work_items.task_lease import TaskLeaseError from ..file_lock import lock_timeout_error_fields +from ..control_plane.coordination.legacy_writer_fence import LegacyCoordinationWriterFenced +from ..control_plane.coordination.shadow_management import ShadowManagementError +from ..control_plane.coordination.runtime_shadow_writer_adapter import ActiveStateAuthorityMutationError +from ..control_plane.coordination.local_authority import LocalCoordinationAuthorityUnavailable RolloutEventAppender = Callable[..., dict[str, object]] @@ -40,7 +44,7 @@ def todo_error_payload(args: argparse.Namespace, exc: Exception) -> dict[str, ob "error": str(exc), **lock_timeout_error_fields(exc), } - if isinstance(exc, (TaskLeaseError, HandoffModeError)): + if isinstance(exc, (TaskLeaseError, HandoffModeError, LegacyCoordinationWriterFenced, ShadowManagementError, ActiveStateAuthorityMutationError, LocalCoordinationAuthorityUnavailable)): payload["error_code"] = exc.code payload.update(exc.payload) elif isinstance(exc, TodoExternalWaitAuthoringError): diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py index 9358a49654..f91caa105b 100644 --- a/loopx/cli_commands/turn.py +++ b/loopx/cli_commands/turn.py @@ -972,6 +972,7 @@ def resolve_built_in_session_binding( ), "mode": "run_once" if args.turn_command == "run-once" else "plan", "error": str(exc), + **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), "effects": { "host_invoked": False, "state_written": False, diff --git a/loopx/configure_goal.py b/loopx/configure_goal.py index 796d8a399b..b2594ad708 100644 --- a/loopx/configure_goal.py +++ b/loopx/configure_goal.py @@ -35,7 +35,7 @@ ) from .control_plane.agents.supervisor import normalize_peer_supervisor from .control_plane.agents.work_mode import normalize_agent_work_modes -from .control_plane.coordination import local_authority_shadow_adapter as shadow +from .control_plane.coordination import local_authority_shadow_observation as shadow from .control_plane.coordination.configuration import normalize_goal_write_scope from .control_plane.operator_inbox_binding import local_private_config_digest from .control_plane.reward_memory import ( diff --git a/loopx/control_plane/coordination/coordination_state_contract.generated.ts b/loopx/control_plane/coordination/coordination_state_contract.generated.ts index 92e69a8641..173f761261 100644 --- a/loopx/control_plane/coordination/coordination_state_contract.generated.ts +++ b/loopx/control_plane/coordination/coordination_state_contract.generated.ts @@ -38,18 +38,22 @@ export const LOCAL_AUTHORITY_SHADOW_REQUEST_SCHEMA = "loopx_local_authority_shad export const LOCAL_AUTHORITY_SHADOW_PROJECTION_SCHEMA = "loopx_local_authority_shadow_projection_v0"; export const LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA = "loopx_local_authority_shadow_evidence_v0"; export const LOCAL_AUTHORITY_SHADOW_OBSERVATION_RECEIPT_SCHEMA = "loopx_local_authority_shadow_observation_receipt_v0"; -export const LOCAL_AUTHORITY_SHADOW_OUTBOX_ENTRY_SCHEMA = "loopx_local_authority_shadow_outbox_entry_v0"; -export const LOCAL_AUTHORITY_SHADOW_OUTBOX_COMMIT_SCHEMA = "loopx_local_authority_shadow_outbox_commit_v0"; +export const LOCAL_AUTHORITY_SHADOW_OUTBOX_ENTRY_SCHEMA = "loopx_local_authority_shadow_outbox_entry_v1"; +export const LOCAL_AUTHORITY_SHADOW_OUTBOX_COMMIT_SCHEMA = "loopx_local_authority_shadow_outbox_commit_v1"; export const LOCAL_AUTHORITY_SHADOW_DRAIN_CURSOR_SCHEMA = "loopx_local_authority_shadow_drain_cursor_v0"; export const LOCAL_AUTHORITY_SHADOW_TRANSACTION_PROJECTION_SCHEMA = "loopx_coordination_runtime_shadow_projection_v0"; -export const LOCAL_AUTHORITY_SHADOW_COMMIT_ENTRY_REQUEST_SCHEMA = "loopx_coordination_runtime_shadow_commit_entry_request_v0"; +export const LOCAL_AUTHORITY_SHADOW_COMMIT_ENTRY_REQUEST_SCHEMA = "loopx_coordination_runtime_shadow_commit_entry_request_v1"; export const LOCAL_AUTHORITY_SHADOW_COMMIT_ENTRY_RESULT_SCHEMA = "loopx_coordination_runtime_shadow_commit_entry_result_v0"; export const LOCAL_AUTHORITY_SHADOW_READ_REQUEST_SCHEMA = "loopx_coordination_runtime_shadow_outbox_read_v0"; export const LOCAL_AUTHORITY_SHADOW_READ_RESULT_SCHEMA = "loopx_coordination_runtime_shadow_outbox_read_result_v0"; -export const LOCAL_AUTHORITY_SHADOW_EVENT_SCHEMA = "loopx_coordination_runtime_shadow_outbox_event_v0"; -export const LOCAL_AUTHORITY_SHADOW_TRANSACTION_RECEIPT_SCHEMA = "loopx_coordination_runtime_shadow_outbox_receipt_v0"; +export const LOCAL_AUTHORITY_SHADOW_EVENT_SCHEMA = "loopx_coordination_runtime_shadow_outbox_event_v1"; +export const LOCAL_AUTHORITY_SHADOW_TRANSACTION_RECEIPT_SCHEMA = "loopx_coordination_runtime_shadow_outbox_receipt_v1"; export const LOCAL_AUTHORITY_SHADOW_TRANSACTION_EVIDENCE_SCHEMA = "loopx_local_authority_shadow_evidence_v1"; +export const SHADOW_MANAGEMENT_STATE_SCHEMA = "loopx_shadow_management_state_v1"; +export const SHADOW_MANAGEMENT_MANIFEST_SCHEMA = "loopx_shadow_management_manifest_v1"; +export const SHADOW_OUTBOX_MANIFEST_SCHEMA = "loopx_shadow_outbox_manifest_v1"; + export const LEGACY_COORDINATION_WRITER_FENCE_SCHEMA = "loopx_legacy_coordination_writer_fence_v0"; export const LEGACY_COORDINATION_WRITER_FENCE_ENGAGE_REQUEST_SCHEMA = "loopx_legacy_coordination_writer_fence_engage_request_v0"; export const LEGACY_COORDINATION_WRITER_FENCE_RESULT_SCHEMA = "loopx_legacy_coordination_writer_fence_result_v0"; @@ -254,6 +258,11 @@ export const COORDINATION_STATE_CONTRACT = deepFreeze({ "transaction_receipt_schema": LOCAL_AUTHORITY_SHADOW_TRANSACTION_RECEIPT_SCHEMA, "transaction_evidence_schema": LOCAL_AUTHORITY_SHADOW_TRANSACTION_EVIDENCE_SCHEMA }, + "shadow_management_protocol": { + "state_schema": SHADOW_MANAGEMENT_STATE_SCHEMA, + "manifest_schema": SHADOW_MANAGEMENT_MANIFEST_SCHEMA, + "outbox_manifest_schema": SHADOW_OUTBOX_MANIFEST_SCHEMA + }, "legacy_writer_fence_protocol": { "fence_schema": LEGACY_COORDINATION_WRITER_FENCE_SCHEMA, "engage_request_schema": LEGACY_COORDINATION_WRITER_FENCE_ENGAGE_REQUEST_SCHEMA, diff --git a/loopx/control_plane/coordination/coordination_state_contract_generated.py b/loopx/control_plane/coordination/coordination_state_contract_generated.py index d8924760e6..99a23788f2 100644 --- a/loopx/control_plane/coordination/coordination_state_contract_generated.py +++ b/loopx/control_plane/coordination/coordination_state_contract_generated.py @@ -129,17 +129,20 @@ def _freeze(value: Any) -> Any: 'projection_schema': 'loopx_local_authority_shadow_projection_v0', 'evidence_schema': 'loopx_local_authority_shadow_evidence_v0', 'observation_receipt_schema': 'loopx_local_authority_shadow_observation_receipt_v0', - 'outbox_entry_schema': 'loopx_local_authority_shadow_outbox_entry_v0', - 'outbox_commit_schema': 'loopx_local_authority_shadow_outbox_commit_v0', + 'outbox_entry_schema': 'loopx_local_authority_shadow_outbox_entry_v1', + 'outbox_commit_schema': 'loopx_local_authority_shadow_outbox_commit_v1', 'drain_cursor_schema': 'loopx_local_authority_shadow_drain_cursor_v0', 'transaction_projection_schema': 'loopx_coordination_runtime_shadow_projection_v0', - 'commit_entry_request_schema': 'loopx_coordination_runtime_shadow_commit_entry_request_v0', + 'commit_entry_request_schema': 'loopx_coordination_runtime_shadow_commit_entry_request_v1', 'commit_entry_result_schema': 'loopx_coordination_runtime_shadow_commit_entry_result_v0', 'read_request_schema': 'loopx_coordination_runtime_shadow_outbox_read_v0', 'read_result_schema': 'loopx_coordination_runtime_shadow_outbox_read_result_v0', - 'event_schema': 'loopx_coordination_runtime_shadow_outbox_event_v0', - 'transaction_receipt_schema': 'loopx_coordination_runtime_shadow_outbox_receipt_v0', + 'event_schema': 'loopx_coordination_runtime_shadow_outbox_event_v1', + 'transaction_receipt_schema': 'loopx_coordination_runtime_shadow_outbox_receipt_v1', 'transaction_evidence_schema': 'loopx_local_authority_shadow_evidence_v1'}, + 'shadow_management_protocol': {'state_schema': 'loopx_shadow_management_state_v1', + 'manifest_schema': 'loopx_shadow_management_manifest_v1', + 'outbox_manifest_schema': 'loopx_shadow_outbox_manifest_v1'}, 'legacy_writer_fence_protocol': {'fence_schema': 'loopx_legacy_coordination_writer_fence_v0', 'engage_request_schema': 'loopx_legacy_coordination_writer_fence_engage_request_v0', 'result_schema': 'loopx_legacy_coordination_writer_fence_result_v0', @@ -216,18 +219,22 @@ def _freeze(value: Any) -> Any: LOCAL_AUTHORITY_SHADOW_PROJECTION_SCHEMA: Final[str] = 'loopx_local_authority_shadow_projection_v0' LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA: Final[str] = 'loopx_local_authority_shadow_evidence_v0' LOCAL_AUTHORITY_SHADOW_OBSERVATION_RECEIPT_SCHEMA: Final[str] = 'loopx_local_authority_shadow_observation_receipt_v0' -LOCAL_AUTHORITY_SHADOW_OUTBOX_ENTRY_SCHEMA: Final[str] = 'loopx_local_authority_shadow_outbox_entry_v0' -LOCAL_AUTHORITY_SHADOW_OUTBOX_COMMIT_SCHEMA: Final[str] = 'loopx_local_authority_shadow_outbox_commit_v0' +LOCAL_AUTHORITY_SHADOW_OUTBOX_ENTRY_SCHEMA: Final[str] = 'loopx_local_authority_shadow_outbox_entry_v1' +LOCAL_AUTHORITY_SHADOW_OUTBOX_COMMIT_SCHEMA: Final[str] = 'loopx_local_authority_shadow_outbox_commit_v1' LOCAL_AUTHORITY_SHADOW_DRAIN_CURSOR_SCHEMA: Final[str] = 'loopx_local_authority_shadow_drain_cursor_v0' LOCAL_AUTHORITY_SHADOW_TRANSACTION_PROJECTION_SCHEMA: Final[str] = 'loopx_coordination_runtime_shadow_projection_v0' -LOCAL_AUTHORITY_SHADOW_COMMIT_ENTRY_REQUEST_SCHEMA: Final[str] = 'loopx_coordination_runtime_shadow_commit_entry_request_v0' +LOCAL_AUTHORITY_SHADOW_COMMIT_ENTRY_REQUEST_SCHEMA: Final[str] = 'loopx_coordination_runtime_shadow_commit_entry_request_v1' LOCAL_AUTHORITY_SHADOW_COMMIT_ENTRY_RESULT_SCHEMA: Final[str] = 'loopx_coordination_runtime_shadow_commit_entry_result_v0' LOCAL_AUTHORITY_SHADOW_READ_REQUEST_SCHEMA: Final[str] = 'loopx_coordination_runtime_shadow_outbox_read_v0' LOCAL_AUTHORITY_SHADOW_READ_RESULT_SCHEMA: Final[str] = 'loopx_coordination_runtime_shadow_outbox_read_result_v0' -LOCAL_AUTHORITY_SHADOW_EVENT_SCHEMA: Final[str] = 'loopx_coordination_runtime_shadow_outbox_event_v0' -LOCAL_AUTHORITY_SHADOW_TRANSACTION_RECEIPT_SCHEMA: Final[str] = 'loopx_coordination_runtime_shadow_outbox_receipt_v0' +LOCAL_AUTHORITY_SHADOW_EVENT_SCHEMA: Final[str] = 'loopx_coordination_runtime_shadow_outbox_event_v1' +LOCAL_AUTHORITY_SHADOW_TRANSACTION_RECEIPT_SCHEMA: Final[str] = 'loopx_coordination_runtime_shadow_outbox_receipt_v1' LOCAL_AUTHORITY_SHADOW_TRANSACTION_EVIDENCE_SCHEMA: Final[str] = 'loopx_local_authority_shadow_evidence_v1' +SHADOW_MANAGEMENT_STATE_SCHEMA: Final[str] = 'loopx_shadow_management_state_v1' +SHADOW_MANAGEMENT_MANIFEST_SCHEMA: Final[str] = 'loopx_shadow_management_manifest_v1' +SHADOW_OUTBOX_MANIFEST_SCHEMA: Final[str] = 'loopx_shadow_outbox_manifest_v1' + LEGACY_COORDINATION_WRITER_FENCE_SCHEMA: Final[str] = 'loopx_legacy_coordination_writer_fence_v0' LEGACY_COORDINATION_WRITER_FENCE_ENGAGE_REQUEST_SCHEMA: Final[str] = 'loopx_legacy_coordination_writer_fence_engage_request_v0' LEGACY_COORDINATION_WRITER_FENCE_RESULT_SCHEMA: Final[str] = 'loopx_legacy_coordination_writer_fence_result_v0' diff --git a/loopx/control_plane/coordination/coordination_state_contract_v0.json b/loopx/control_plane/coordination/coordination_state_contract_v0.json index 4660527e7c..4d53a8fa72 100644 --- a/loopx/control_plane/coordination/coordination_state_contract_v0.json +++ b/loopx/control_plane/coordination/coordination_state_contract_v0.json @@ -127,18 +127,23 @@ "projection_schema": "loopx_local_authority_shadow_projection_v0", "evidence_schema": "loopx_local_authority_shadow_evidence_v0", "observation_receipt_schema": "loopx_local_authority_shadow_observation_receipt_v0", - "outbox_entry_schema": "loopx_local_authority_shadow_outbox_entry_v0", - "outbox_commit_schema": "loopx_local_authority_shadow_outbox_commit_v0", + "outbox_entry_schema": "loopx_local_authority_shadow_outbox_entry_v1", + "outbox_commit_schema": "loopx_local_authority_shadow_outbox_commit_v1", "drain_cursor_schema": "loopx_local_authority_shadow_drain_cursor_v0", "transaction_projection_schema": "loopx_coordination_runtime_shadow_projection_v0", - "commit_entry_request_schema": "loopx_coordination_runtime_shadow_commit_entry_request_v0", + "commit_entry_request_schema": "loopx_coordination_runtime_shadow_commit_entry_request_v1", "commit_entry_result_schema": "loopx_coordination_runtime_shadow_commit_entry_result_v0", "read_request_schema": "loopx_coordination_runtime_shadow_outbox_read_v0", "read_result_schema": "loopx_coordination_runtime_shadow_outbox_read_result_v0", - "event_schema": "loopx_coordination_runtime_shadow_outbox_event_v0", - "transaction_receipt_schema": "loopx_coordination_runtime_shadow_outbox_receipt_v0", + "event_schema": "loopx_coordination_runtime_shadow_outbox_event_v1", + "transaction_receipt_schema": "loopx_coordination_runtime_shadow_outbox_receipt_v1", "transaction_evidence_schema": "loopx_local_authority_shadow_evidence_v1" }, + "shadow_management_protocol": { + "state_schema": "loopx_shadow_management_state_v1", + "manifest_schema": "loopx_shadow_management_manifest_v1", + "outbox_manifest_schema": "loopx_shadow_outbox_manifest_v1" + }, "legacy_writer_fence_protocol": { "fence_schema": "loopx_legacy_coordination_writer_fence_v0", "engage_request_schema": "loopx_legacy_coordination_writer_fence_engage_request_v0", diff --git a/loopx/control_plane/coordination/file_authority_store.ts b/loopx/control_plane/coordination/file_authority_store.ts index 265ac79dff..99ee9cb7ea 100644 --- a/loopx/control_plane/coordination/file_authority_store.ts +++ b/loopx/control_plane/coordination/file_authority_store.ts @@ -236,8 +236,9 @@ export class FileAuthorityStore implements AuthorityStore { readonly directory: string; readonly path: string; readonly identityPath: string; + private readonly existingOnly: boolean; - constructor(directory: string, goalId: string) { + constructor(directory: string, goalId: string, options: { existingOnly?: boolean } = {}) { this.goalId = requireAuthorityStoreId(goalId, "goal id"); if (typeof directory !== "string" || directory.length === 0) { throw new AuthorityStoreProtocolError("store directory is required"); @@ -246,6 +247,7 @@ export class FileAuthorityStore implements AuthorityStore { const digest = createHash("sha256").update(goalId, "utf8").digest("hex").slice(0, 16); this.path = join(this.directory, `authority-store-${digest}.json`); this.identityPath = join(this.directory, "store-identity"); + this.existingOnly = options.existingOnly === true; } /** Narrow effect seam for crash-window qualification; not a semantic hook. */ @@ -253,17 +255,21 @@ export class FileAuthorityStore implements AuthorityStore { await durableReplace(path, payload); } - private async readStoreIdentity(): Promise { + /** Filesystem-only crash seam; the archive owner must still fsync both parents. */ + protected async archiveRenamed(): Promise {} + + private async readStoreIdentity(createIfMissing = !this.existingOnly): Promise { try { const identity = await readFile(this.identityPath, "utf8"); if (!STORE_IDENTITY_PATTERN.test(identity)) { throw new AuthorityStoreProtocolError("store identity does not match file:<32 lowercase hex>"); } - await syncDirectory(this.directory); + if (createIfMissing) await syncDirectory(this.directory); return identity; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + if (!createIfMissing) throw new FileStoreUnavailableError("existing store identity is missing"); return await withFileMutationLock(this.identityPath, async () => { try { const identity = await readFile(this.identityPath, "utf8"); @@ -365,6 +371,9 @@ export class FileAuthorityStore implements AuthorityStore { reason: error instanceof Error ? error.message : "provider read unavailable", }; } + if (this.existingOnly && current === null) { + return { status: "failed", reason_code: "existing_authority_missing", reason: "existing-only store cannot bootstrap a missing authority" }; + } if ((current?.provider_revision ?? null) !== normalized.expected_provider_revision) { return { status: "conflict", @@ -525,18 +534,13 @@ export class FileAuthorityStore implements AuthorityStore { reason: error instanceof Error ? error.message : "invalid archive request", }; } - const archiveId = createHash("sha256") - .update(this.goalId, "utf8") - .update("\0", "utf8") - .update(normalizedOperationId, "utf8") - .digest("hex") - .slice(0, 24); + const archiveId = this.authorityArchiveId(normalizedOperationId); const archiveDirectory = join(this.directory, "rollback"); - const archivePath = join(archiveDirectory, `authority-store-${archiveId}.json`); + const archivePath = this.authorityArchivePath(normalizedOperationId); let renameStarted = false; try { return await withFileMutationLock(this.path, async () => { - const identity = await this.readStoreIdentity(); + const identity = await this.readStoreIdentity(false); let archived: FileAuthorityStoreDocument | null = null; try { archived = decodeDocument( @@ -584,6 +588,7 @@ export class FileAuthorityStore implements AuthorityStore { await mkdir(archiveDirectory, { recursive: true, mode: 0o700 }); renameStarted = true; await rename(this.path, archivePath); + await this.archiveRenamed(); await syncDirectory(this.directory); await syncDirectory(archiveDirectory); return { @@ -605,4 +610,15 @@ export class FileAuthorityStore implements AuthorityStore { }; } } + + /** Stable provider-owned destination; callers cannot supply an archive path. */ + authorityArchiveId(operationId: string): string { + requireAuthorityStoreId(operationId, "operation id"); + return createHash("sha256").update(this.goalId, "utf8").update("\0", "utf8") + .update(operationId, "utf8").digest("hex").slice(0, 24); + } + + authorityArchivePath(operationId: string): string { + return join(this.directory, "rollback", `authority-store-${this.authorityArchiveId(operationId)}.json`); + } } diff --git a/loopx/control_plane/coordination/legacy_writer_fence.py b/loopx/control_plane/coordination/legacy_writer_fence.py index ae5c203f1d..89263deb96 100644 --- a/loopx/control_plane/coordination/legacy_writer_fence.py +++ b/loopx/control_plane/coordination/legacy_writer_fence.py @@ -14,9 +14,12 @@ from typing import Any, Iterator from ..effect_runtime import effect_runtime_result -from ...file_lock import exclusive_file_lock +from ...file_lock import exclusive_cross_runtime_file_lock from ...history import load_registry from ...paths import resolve_runtime_root +from .shadow_management import ( + ShadowManagementError, read_shadow_bootstrap_source_path, read_shadow_management_state, require_shadow_primary_write_allowed, +) from .coordination_state_contract_generated import ( LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA, ) @@ -58,6 +61,66 @@ def legacy_coordination_todo_lock_path(*, runtime_root: Path, goal_id: str) -> P ) +def require_registry_source_write_allowed( + *, registry_path: Path, runtime_root: Path, goal_id: str, state_file: Path, + canonical_mutation: bool = True, +) -> None: + """Check the registered source authority while its shared state lock is held. + + A runtime override changes runtime storage, not the identity of an existing + state file. Re-read the registry and the atomic management record after S + acquisition, without acquiring another root's M or T in reverse order. + """ + + binding = require_shadow_primary_write_allowed(runtime_root, goal_id) + if canonical_mutation and binding is not None: + bound_source = read_shadow_bootstrap_source_path(runtime_root, goal_id, binding) + if bound_source.resolve(strict=False) != state_file.resolve(strict=False): + raise ShadowManagementError( + "shadow_source_state_path_mismatch", + "the state file is not the source established by the active capture binding", + ) + registry = load_registry(registry_path) + if not any(isinstance(goal, dict) and goal.get("id") == goal_id for goal in registry.get("goals", [])): + return + registered_root = resolve_runtime_root(registry, None, registry_path=registry_path) + if registered_root.expanduser().resolve(strict=False) == runtime_root.expanduser().resolve(strict=False): + return + binding = require_shadow_primary_write_allowed(registered_root, goal_id) + if canonical_mutation: + if binding is not None: + raise ShadowManagementError( + "shadow_source_runtime_root_mismatch", + "the registered state source has an active capture binding; write through its runtime root", + ) + require_legacy_coordination_write_allowed(runtime_root=registered_root, goal_id=goal_id) + + +def require_legacy_state_replacement_allowed( + *, runtime_root: Path, goal_id: str, goal: dict[str, Any] | None, +) -> None: + """A generic rebuild cannot retire or rebind an existing shadow lineage.""" + + require_shadow_primary_write_allowed(runtime_root, goal_id) + require_legacy_coordination_write_allowed(runtime_root=runtime_root, goal_id=goal_id) + coordination = goal.get("coordination") if isinstance(goal, dict) else None + config = coordination.get("runtime_shadow") if isinstance(coordination, dict) else None + configured = config is not None and ( + not isinstance(config, dict) or config.get("enabled") is not False + ) + digest = hashlib.sha256(goal_id.encode("utf-8")).hexdigest()[:16] + candidates = ( + runtime_root / "authority-shadow" / "outbox" / goal_id, + runtime_root / "authority-shadow" / "file-v0" / f"authority-store-{digest}.json", + ) + state = read_shadow_management_state(runtime_root, goal_id) + if configured or (state is not None and state["status"] == "active") or any(path.exists() for path in candidates): + raise ShadowManagementError( + "shadow_source_replacement_requires_rebootstrap", + "active shadow source cannot be replaced or rebound by a generic state rebuild", + ) + + def require_legacy_coordination_write_allowed( *, runtime_root: Path, goal_id: str ) -> None: @@ -137,19 +200,24 @@ def legacy_todo_write_transaction( None, registry_path=registry_path, ) - with exclusive_file_lock( + with exclusive_cross_runtime_file_lock( legacy_coordination_todo_lock_path( runtime_root=resolved_runtime_root, goal_id=goal_id, ), agent_id=agent_id, operation="legacy_coordination_todo_write", - ), exclusive_file_lock( + ), exclusive_cross_runtime_file_lock( state_file, agent_id=agent_id, operation=operation, ): if not dry_run: + require_registry_source_write_allowed( + registry_path=registry_path, runtime_root=resolved_runtime_root, goal_id=goal_id, + state_file=state_file, + ) + require_shadow_primary_write_allowed(resolved_runtime_root, goal_id) require_legacy_coordination_write_allowed( runtime_root=resolved_runtime_root, goal_id=goal_id, diff --git a/loopx/control_plane/coordination/legacy_writer_fence.ts b/loopx/control_plane/coordination/legacy_writer_fence.ts index 9712c26e1f..fa0cdb0f9a 100644 --- a/loopx/control_plane/coordination/legacy_writer_fence.ts +++ b/loopx/control_plane/coordination/legacy_writer_fence.ts @@ -1,10 +1,15 @@ import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; +import { readFile, realpath, stat } from "node:fs/promises"; import { isAbsolute, join } from "node:path"; import type { JsonObject } from "../effect_program.ts"; import { atomicWriteJson, withFileMutationLock } from "../effect_runtime_io.ts"; import { requireJsonObject } from "../runtime_decode.ts"; +import { readShadowBootstrapSourcePath, requireShadowPrimaryWriteAllowed, ShadowManagementError, shadowMaintenanceLockPath } from "./shadow_management.ts"; +import { legacyCoordinationTodoLockPath, legacyCoordinationLeaseLockPath, taskLeaseLockPath } from "./legacy_writer_lock_paths.ts"; +export { legacyCoordinationTodoLockPath, legacyCoordinationLeaseLockPath, + LEGACY_COORDINATION_TODO_LOCK_KEY, LEGACY_COORDINATION_LEASE_LOCK_KEY } from "./legacy_writer_lock_paths.ts"; + import { canonicalAuthorityBytes, canonicalAuthorityObject, @@ -25,8 +30,28 @@ export { LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA, LEGACY_COORDINATION_WRITE_CHECK_RESULT_SCHEMA, }; -export const LEGACY_COORDINATION_TODO_LOCK_KEY = "legacy-todo-writer"; -export const LEGACY_COORDINATION_LEASE_LOCK_KEY = "legacy-task-lease-writer"; + +export class LegacyCoordinationWriteError extends Error { + code: string; + payload: JsonObject; + constructor(code: string, payload: JsonObject) { + super(String(payload.reason ?? "legacy coordination writer is fenced")); + this.code = code; + this.payload = payload; + } +} + +/** Call under the existing primary lock, before receipts, capture or bytes. */ +export async function requireLegacyCoordinationPrimaryWriteAllowed(root: string, goalId: string): Promise { + await requireShadowPrimaryWriteAllowed(root, goalId); + const guard = await checkLegacyCoordinationWriteAllowed({ + schema_version: LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA, + runtime_root: root, goal_id: goalId, + }); + if (guard.status !== "allowed") { + throw new LegacyCoordinationWriteError(String(guard.reason_code ?? "legacy_writer_fence_check_failed"), guard); + } +} function runtimeRoot(value: unknown): string { if (typeof value !== "string" || value.trim() !== value || !isAbsolute(value)) { @@ -40,16 +65,6 @@ export function legacyCoordinationWriterFencePath(root: string, goalId: string): return join(root, "authority-transition", "file-v0", `legacy-writer-fence-${digest}.json`); } -export function legacyCoordinationTodoLockPath(root: string, goalId: string): string { - const digest = createHash("sha256").update(goalId, "utf8").digest("hex").slice(0, 16); - return join(root, "authority-transition", "file-v0", `legacy-todo-writer-${digest}`); -} - -export function legacyCoordinationLeaseLockPath(root: string, goalId: string): string { - const digest = createHash("sha256").update(goalId, "utf8").digest("hex").slice(0, 16); - return join(root, "authority-transition", "file-v0", `legacy-task-lease-writer-${digest}`); -} - export function decodeLegacyCoordinationWriterFence(value: unknown): JsonObject { const fence = canonicalAuthorityObject(value, "legacy coordination writer fence"); if ( @@ -108,45 +123,65 @@ export async function engageLegacyCoordinationWriterFence(value: unknown): Promi } const root = runtimeRoot(input.runtime_root); const goalId = requireAuthorityStoreId(input.goal_id, "goal id"); + if (typeof input.state_path !== "string" || input.state_path.trim() !== input.state_path + || !isAbsolute(input.state_path) || input.state_path.includes("\0")) { + throw new Error("state_path must be the absolute source state file path"); + } + const statePath = await realpath(input.state_path); + if (!(await stat(statePath)).isFile()) throw new Error("state_path must identify an existing source state file"); const fence = decodeLegacyCoordinationWriterFence(input.fence); if (fence.goal_id !== goalId) throw new Error("legacy writer fence goal mismatch"); const path = legacyCoordinationWriterFencePath(root, goalId); - return await withFileMutationLock(path, async () => { - const existing = await loadLegacyCoordinationWriterFence(root, goalId); - if (existing.status === "loaded") { - const matched = canonicalAuthorityBytes(existing.fence).equals( - canonicalAuthorityBytes(fence), - ); - return { - schema_version: LEGACY_COORDINATION_WRITER_FENCE_RESULT_SCHEMA, - status: matched ? "replayed" : "conflict", - ...(matched ? { fence } : { - reason_code: "legacy_writer_fence_identity_mismatch", - reason: "a different legacy writer fence is already engaged", - }), - }; + return await withFileMutationLock(shadowMaintenanceLockPath(root, goalId), async () => { + const binding = await requireShadowPrimaryWriteAllowed(root, goalId); + if (binding !== null && await realpath(await readShadowBootstrapSourcePath(root, goalId, binding)) !== statePath) { + throw new ShadowManagementError("shadow_source_state_path_mismatch"); } - if (existing.status === "failed") return { - schema_version: LEGACY_COORDINATION_WRITER_FENCE_RESULT_SCHEMA, - ...existing, - }; - await atomicWriteJson(path, fence); - const readback = await loadLegacyCoordinationWriterFence(root, goalId); - if ( - readback.status !== "loaded" || - !canonicalAuthorityBytes(readback.fence).equals(canonicalAuthorityBytes(fence)) - ) throw new Error("legacy writer fence readback mismatch"); - return { - schema_version: LEGACY_COORDINATION_WRITER_FENCE_RESULT_SCHEMA, - status: "applied", - fence, - }; + return withFileMutationLock(legacyCoordinationTodoLockPath(root, goalId), () => + withFileMutationLock(statePath, () => + withFileMutationLock(legacyCoordinationLeaseLockPath(root, goalId), () => + withFileMutationLock(taskLeaseLockPath({runtime_root: root, goal_id: goalId}), () => + withFileMutationLock(path, async () => { + const existing = await loadLegacyCoordinationWriterFence(root, goalId); + if (existing.status === "loaded") { + const matched = canonicalAuthorityBytes(existing.fence).equals( + canonicalAuthorityBytes(fence), + ); + return { + schema_version: LEGACY_COORDINATION_WRITER_FENCE_RESULT_SCHEMA, + status: matched ? "replayed" : "conflict", + ...(matched ? { fence } : { + reason_code: "legacy_writer_fence_identity_mismatch", + reason: "a different legacy writer fence is already engaged", + }), + }; + } + if (existing.status === "failed") return { + schema_version: LEGACY_COORDINATION_WRITER_FENCE_RESULT_SCHEMA, + ...existing, + }; + await atomicWriteJson(path, fence); + const readback = await loadLegacyCoordinationWriterFence(root, goalId); + if ( + readback.status !== "loaded" || + !canonicalAuthorityBytes(readback.fence).equals(canonicalAuthorityBytes(fence)) + ) throw new Error("legacy writer fence readback mismatch"); + return { + schema_version: LEGACY_COORDINATION_WRITER_FENCE_RESULT_SCHEMA, + status: "applied", + fence, + }; + }), + ), + ), + ), + ); }); } catch (error) { return { schema_version: LEGACY_COORDINATION_WRITER_FENCE_RESULT_SCHEMA, status: "failed", - reason_code: "invalid_legacy_writer_fence_request", + reason_code: error instanceof ShadowManagementError ? error.reason_code : "invalid_legacy_writer_fence_request", reason: error instanceof Error ? error.message : "invalid writer fence request", }; } diff --git a/loopx/control_plane/coordination/legacy_writer_lock_paths.ts b/loopx/control_plane/coordination/legacy_writer_lock_paths.ts new file mode 100644 index 0000000000..4e678d9c40 --- /dev/null +++ b/loopx/control_plane/coordination/legacy_writer_lock_paths.ts @@ -0,0 +1,19 @@ +import { createHash } from "node:crypto"; +import { join } from "node:path"; + +export const LEGACY_COORDINATION_TODO_LOCK_KEY = "legacy-todo-writer"; +export const LEGACY_COORDINATION_LEASE_LOCK_KEY = "legacy-task-lease-writer"; + +export function legacyCoordinationTodoLockPath(root: string, goalId: string): string { + const digest = createHash("sha256").update(goalId, "utf8").digest("hex").slice(0, 16); + return join(root, "authority-transition", "file-v0", `${LEGACY_COORDINATION_TODO_LOCK_KEY}-${digest}`); +} + +export function legacyCoordinationLeaseLockPath(root: string, goalId: string): string { + const digest = createHash("sha256").update(goalId, "utf8").digest("hex").slice(0, 16); + return join(root, "authority-transition", "file-v0", `${LEGACY_COORDINATION_LEASE_LOCK_KEY}-${digest}`); +} + +export function taskLeaseLockPath(request: { runtime_root: string; goal_id: string }): string { + return join(request.runtime_root, "goals", request.goal_id, "task-leases", ".task-leases"); +} diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index 9bb2359606..aeac12c68c 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -1,3 +1,5 @@ +import { withFileMutationLock } from "../effect_runtime_io.ts"; +import { ShadowManagementError, requireShadowPrimaryWriteAllowed, shadowMaintenanceLockPath } from "./shadow_management.ts"; import { isAbsolute, join } from "node:path"; import type { JsonObject } from "../effect_program.ts"; @@ -73,6 +75,14 @@ export { } from "./coordination_state_contract.generated.ts"; export { LEGACY_COORDINATION_WRITER_FENCE_SCHEMA } from "./legacy_writer_fence.ts"; +async function withCanonicalWriter(root: string, goalId: string, dryRun: boolean, write: () => Promise): Promise { + if (dryRun) return await write(); + return await withFileMutationLock(shadowMaintenanceLockPath(root, goalId), async () => { + await requireShadowPrimaryWriteAllowed(root, goalId); + return await write(); + }); +} + interface LocalAuthorityRuntimeDependencies { createStore?: (directory: string, goalId: string) => AuthorityStore; createShadowStore?: (directory: string, goalId: string) => AuthorityStore; @@ -394,65 +404,67 @@ export async function promoteLocalCoordinationAuthority( legacy_fallback_used: false, }; - const finalShadowHead = await shadow.loadAuthority(); - if ( - finalShadowHead.status !== "loaded" || - finalShadowHead.provider_revision !== request.expected_shadow_provider_revision || - canonicalAuthoritySha256(finalShadowHead.head) !== request.expected_shadow_projection_sha256 - ) return { - schema_version: LOCAL_COORDINATION_PROMOTION_RESULT_SCHEMA, - status: "failed", - reason_code: "local_authority_shadow_changed_during_qualification", - reason: "shadow head changed while promotion evidence was being verified", - legacy_writer_fenced: true, - legacy_fallback_used: false, - }; + return await withCanonicalWriter(request.runtime_root, request.goal_id, false, async () => { + const finalShadowHead = await shadow.loadAuthority(); + if ( + finalShadowHead.status !== "loaded" || + finalShadowHead.provider_revision !== request.expected_shadow_provider_revision || + canonicalAuthoritySha256(finalShadowHead.head) !== request.expected_shadow_projection_sha256 + ) return { + schema_version: LOCAL_COORDINATION_PROMOTION_RESULT_SCHEMA, + status: "failed", + reason_code: "local_authority_shadow_changed_during_qualification", + reason: "shadow head changed while promotion evidence was being verified", + legacy_writer_fenced: true, + legacy_fallback_used: false, + }; - const identity = promotionIdentity(request); - const committed = await canonical.commitAuthority({ - expected_provider_revision: null, - operation_id: request.operation_id, - events: [{ - ...identity, - schema_version: "loopx_local_coordination_promotion_event_v0", - mode_transition: "legacy_canonical_to_file_v0", - }], - next_projection: finalShadowHead.head, - receipts: [identity], - }); - if (committed.status === "applied") { + const identity = promotionIdentity(request); + const committed = await canonical.commitAuthority({ + expected_provider_revision: null, + operation_id: request.operation_id, + events: [{ + ...identity, + schema_version: "loopx_local_coordination_promotion_event_v0", + mode_transition: "legacy_canonical_to_file_v0", + }], + next_projection: finalShadowHead.head, + receipts: [identity], + }); + if (committed.status === "applied") { + const readback = await promotionReadback(canonical, request); + return readback.matched + ? promotionResult(request, "applied", readback) + : { + schema_version: LOCAL_COORDINATION_PROMOTION_RESULT_SCHEMA, + status: "failed", + reason_code: readback.reason_code ?? "local_authority_promotion_readback_mismatch", + reason: "promotion commit lacks an exact durable readback", + legacy_writer_fenced: true, + legacy_fallback_used: false, + }; + } const readback = await promotionReadback(canonical, request); - return readback.matched - ? promotionResult(request, "applied", readback) - : { - schema_version: LOCAL_COORDINATION_PROMOTION_RESULT_SCHEMA, - status: "failed", - reason_code: readback.reason_code ?? "local_authority_promotion_readback_mismatch", - reason: "promotion commit lacks an exact durable readback", - legacy_writer_fenced: true, - legacy_fallback_used: false, - }; - } - const readback = await promotionReadback(canonical, request); - if (readback.matched) { - return promotionResult( - request, - committed.status === "ambiguous" ? "recovered" : "replayed", - readback, - ); - } - return { - schema_version: LOCAL_COORDINATION_PROMOTION_RESULT_SCHEMA, - ...committed, - ...(committed.status === "ambiguous" ? { reconciliation_required: true } : {}), - legacy_writer_fenced: true, - legacy_fallback_used: false, - }; + if (readback.matched) { + return promotionResult( + request, + committed.status === "ambiguous" ? "recovered" : "replayed", + readback, + ); + } + return { + schema_version: LOCAL_COORDINATION_PROMOTION_RESULT_SCHEMA, + ...committed, + ...(committed.status === "ambiguous" ? { reconciliation_required: true } : {}), + legacy_writer_fenced: true, + legacy_fallback_used: false, + }; + }); } catch (error) { return { schema_version: LOCAL_COORDINATION_PROMOTION_RESULT_SCHEMA, status: "failed", - reason_code: "local_authority_promotion_unavailable", + reason_code: error instanceof ShadowManagementError ? error.reason_code : "local_authority_promotion_unavailable", reason: error instanceof Error ? error.message : "promotion unavailable", legacy_writer_fenced: true, legacy_fallback_used: false, @@ -510,29 +522,31 @@ export async function mutateLocalCoordinationAuthority( } const root = runtimeRoot(input.runtime_root); const goalId = requireAuthorityStoreId(input.goal_id, "goal id"); - const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? - new FileAuthorityStore(authorityDirectory(root), goalId); - const result = await commitCoordinationProjectionMutation(store, { - goal_id: goalId, - operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), - expected_provider_revision: requireAuthorityStoreId( - input.expected_provider_revision, - "expected provider revision", - ), - mutations: decodeMutations(input.mutations), + return await withCanonicalWriter(root, goalId, false, async () => { + const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? + new FileAuthorityStore(authorityDirectory(root), goalId); + const result = await commitCoordinationProjectionMutation(store, { + goal_id: goalId, + operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), + expected_provider_revision: requireAuthorityStoreId( + input.expected_provider_revision, + "expected provider revision", + ), + mutations: decodeMutations(input.mutations), + }); + return { + schema_version: LOCAL_COORDINATION_MUTATION_RESULT_SCHEMA, + ...result, + source_authority: "file_v0", + decision_read_from_provider: true, + legacy_fallback_used: false, + }; }); - return { - schema_version: LOCAL_COORDINATION_MUTATION_RESULT_SCHEMA, - ...result, - source_authority: "file_v0", - decision_read_from_provider: true, - legacy_fallback_used: false, - }; } catch (error) { return { schema_version: LOCAL_COORDINATION_MUTATION_RESULT_SCHEMA, status: "failed", - reason_code: "invalid_local_coordination_mutation_request", + reason_code: error instanceof ShadowManagementError ? error.reason_code : "invalid_local_coordination_mutation_request", reason: error instanceof Error ? error.message : "invalid mutation request", source_authority: "file_v0", decision_read_from_provider: true, @@ -556,59 +570,61 @@ export async function claimLocalCoordinationTodo( } const root = runtimeRoot(input.runtime_root); const goalId = requireAuthorityStoreId(input.goal_id, "goal id"); - const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? - new FileAuthorityStore(authorityDirectory(root), goalId); - if (!Array.isArray(input.registered_agents)) { - throw new Error("registered_agents must be a JSON array"); - } - const registeredAgents = input.registered_agents.map( - (value) => claimAgentValue(value, "registered agent"), - ); - const leaseRequestValue = input.lease_request; - const leaseRequest = leaseRequestValue === null || leaseRequestValue === undefined - ? null - : (() => { - const request = requireJsonObject(leaseRequestValue, "lease_request"); - const expectedVersion = request.expected_version; - if (expectedVersion !== null && expectedVersion !== undefined && - (!Number.isSafeInteger(expectedVersion) || Number(expectedVersion) < 0)) { - throw new Error( - "lease_request.expected_version must be a non-negative safe integer or null", - ); - } - return { - idempotency_key: normalizeIdempotencyKey(request.idempotency_key), - expected_version: expectedVersion === undefined ? null : expectedVersion as number | null, - ttl_seconds: normalizeTtl(request.ttl_seconds), - }; - })(); - const result = await executeCoordinationTodoClaim(store, { - goal_id: goalId, - todo_id: requireAuthorityStoreId(input.todo_id, "todo id"), - claimed_by: claimAgentValue(input.claimed_by, "claimed_by"), - actor_agent_id: input.actor_agent_id === null || input.actor_agent_id === undefined - ? null - : claimAgentValue(input.actor_agent_id, "actor_agent_id"), - expected_role: input.role === null || input.role === undefined + return await withCanonicalWriter(root, goalId, input.dry_run === true, async () => { + const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? + new FileAuthorityStore(authorityDirectory(root), goalId); + if (!Array.isArray(input.registered_agents)) { + throw new Error("registered_agents must be a JSON array"); + } + const registeredAgents = input.registered_agents.map( + (value) => claimAgentValue(value, "registered agent"), + ); + const leaseRequestValue = input.lease_request; + const leaseRequest = leaseRequestValue === null || leaseRequestValue === undefined ? null - : requireAuthorityStoreId(input.role, "role"), - registered_agents: registeredAgents, - operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), - lease_request: leaseRequest, - dry_run: input.dry_run, - now: claimObservedAt(input.observed_at), + : (() => { + const request = requireJsonObject(leaseRequestValue, "lease_request"); + const expectedVersion = request.expected_version; + if (expectedVersion !== null && expectedVersion !== undefined && + (!Number.isSafeInteger(expectedVersion) || Number(expectedVersion) < 0)) { + throw new Error( + "lease_request.expected_version must be a non-negative safe integer or null", + ); + } + return { + idempotency_key: normalizeIdempotencyKey(request.idempotency_key), + expected_version: expectedVersion === undefined ? null : expectedVersion as number | null, + ttl_seconds: normalizeTtl(request.ttl_seconds), + }; + })(); + const result = await executeCoordinationTodoClaim(store, { + goal_id: goalId, + todo_id: requireAuthorityStoreId(input.todo_id, "todo id"), + claimed_by: claimAgentValue(input.claimed_by, "claimed_by"), + actor_agent_id: input.actor_agent_id === null || input.actor_agent_id === undefined + ? null + : claimAgentValue(input.actor_agent_id, "actor_agent_id"), + expected_role: input.role === null || input.role === undefined + ? null + : requireAuthorityStoreId(input.role, "role"), + registered_agents: registeredAgents, + operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), + lease_request: leaseRequest, + dry_run: input.dry_run === true, + now: claimObservedAt(input.observed_at), + }); + return { + ...result, + source_authority: "file_v0", + decision_read_from_provider: true, + legacy_fallback_used: false, + }; }); - return { - ...result, - source_authority: "file_v0", - decision_read_from_provider: true, - legacy_fallback_used: false, - }; } catch (error) { return { schema_version: COORDINATION_TODO_CLAIM_RESULT_SCHEMA, status: "failed", - reason_code: "invalid_local_coordination_todo_claim_request", + reason_code: error instanceof ShadowManagementError ? error.reason_code : "invalid_local_coordination_todo_claim_request", reason: error instanceof Error ? error.message : "invalid Todo claim request", source_authority: "file_v0", decision_read_from_provider: true, @@ -637,33 +653,35 @@ export async function createLocalCoordinationTodo( } const root = runtimeRoot(input.runtime_root); const goalId = requireAuthorityStoreId(input.goal_id, "goal id"); - const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? - new FileAuthorityStore(authorityDirectory(root), goalId); - if (!Array.isArray(input.registered_agents)) { - throw new TypeError("registered_agents must be a JSON array"); - } - const result = await executeCoordinationTodoCreate(store, { - goal_id: goalId, - todo: requireJsonObject(input.todo, "todo"), - actor_agent_id: input.actor_agent_id === null || input.actor_agent_id === undefined - ? null - : claimAgentValue(input.actor_agent_id, "actor_agent_id"), - registered_agents: input.registered_agents.map( - (agent) => claimAgentValue(agent, "registered agent"), - ), - operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), - dry_run: input.dry_run, - now: claimObservedAt(input.observed_at), + return await withCanonicalWriter(root, goalId, input.dry_run === true, async () => { + const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? + new FileAuthorityStore(authorityDirectory(root), goalId); + if (!Array.isArray(input.registered_agents)) { + throw new TypeError("registered_agents must be a JSON array"); + } + const result = await executeCoordinationTodoCreate(store, { + goal_id: goalId, + todo: requireJsonObject(input.todo, "todo"), + actor_agent_id: input.actor_agent_id === null || input.actor_agent_id === undefined + ? null + : claimAgentValue(input.actor_agent_id, "actor_agent_id"), + registered_agents: input.registered_agents.map( + (agent) => claimAgentValue(agent, "registered agent"), + ), + operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), + dry_run: input.dry_run === true, + now: claimObservedAt(input.observed_at), + }); + return { + ...result, + ...providerEvidence, + }; }); - return { - ...result, - ...providerEvidence, - }; } catch (error) { return { schema_version: COORDINATION_TODO_CREATE_RESULT_SCHEMA, status: "failed", - reason_code: "invalid_local_coordination_todo_create_request", + reason_code: error instanceof ShadowManagementError ? error.reason_code : "invalid_local_coordination_todo_create_request", reason: error instanceof Error ? error.message : "invalid Todo create request", ...providerEvidence, }; @@ -721,13 +739,15 @@ export async function editLocalCoordinationTodo( const {runtime_root, ...request} = input; const root = runtimeRoot(runtime_root); const goalId = requireAuthorityStoreId(input.goal_id, "goal id"); - const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? - new FileAuthorityStore(authorityDirectory(root), goalId); - return {...await editCoordinationTodo(store, request), - source_authority: "file_v0", decision_read_from_provider: true, legacy_fallback_used: false}; + return await withCanonicalWriter(root, goalId, input.dry_run === true, async () => { + const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? + new FileAuthorityStore(authorityDirectory(root), goalId); + return {...await editCoordinationTodo(store, request), + source_authority: "file_v0", decision_read_from_provider: true, legacy_fallback_used: false}; + }); } catch (error) { return {schema_version: TODO_COMPATIBILITY_EDIT_RESULT_SCHEMA, status: "failed", - reason_code: "invalid_local_compatibility_edit", changed: false, + reason_code: error instanceof ShadowManagementError ? error.reason_code : "invalid_local_compatibility_edit", changed: false, reason: error instanceof Error ? error.message : "invalid local compatibility edit"}; } } diff --git a/loopx/control_plane/coordination/local_authority_shadow.ts b/loopx/control_plane/coordination/local_authority_shadow.ts index 9d64a1ff33..608ca461c0 100644 --- a/loopx/control_plane/coordination/local_authority_shadow.ts +++ b/loopx/control_plane/coordination/local_authority_shadow.ts @@ -1,8 +1,10 @@ import { createHash } from "node:crypto"; -import { join } from "node:path"; +import { readFile, readdir } from "node:fs/promises"; +import { join, resolve } from "node:path"; import type { JsonObject } from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; +import { withFileMutationLock } from "../effect_runtime_io.ts"; import { requireInteger, requireJsonObject, @@ -16,11 +18,15 @@ import type { AuthorityStoreLoadResult, AuthorityStoreReceiptResult, } from "./authority_store.ts"; -import { authorityUnicodeCompare, canonicalAuthorityBytes } from "./authority_store_codec.ts"; +import { authorityUnicodeCompare, canonicalAuthorityBytes, canonicalAuthoritySha256 } from "./authority_store_codec.ts"; import { TODO_CANONICAL_READ_RECORD_FIELDS, + validateCoordinationTodoReadModel, } from "./coordination_projection.ts"; import { FileAuthorityStore } from "./file_authority_store.ts"; +import { requireShadowCaptureBinding, withShadowMaintenanceLock, readShadowBootstrapSourcePath } from "./shadow_management.ts"; +import { outboxEntryIdentity, OUTBOX_ENTRY_FILE_PATTERN } from "./local_authority_shadow_identity.ts"; +import { legacyCoordinationTodoLockPath, taskLeaseLockPath } from "./legacy_writer_lock_paths.ts"; import { LOCAL_AUTHORITY_SHADOW_COMMIT_ENTRY_REQUEST_SCHEMA, LOCAL_AUTHORITY_SHADOW_COMMIT_ENTRY_RESULT_SCHEMA, @@ -33,6 +39,8 @@ import { LOCAL_AUTHORITY_SHADOW_REQUEST_SCHEMA, LOCAL_AUTHORITY_SHADOW_TRANSACTION_PROJECTION_SCHEMA, LOCAL_AUTHORITY_SHADOW_TRANSACTION_RECEIPT_SCHEMA, + LOCAL_AUTHORITY_SHADOW_OUTBOX_ENTRY_SCHEMA, + LOCAL_AUTHORITY_SHADOW_OUTBOX_COMMIT_SCHEMA, } from "./coordination_state_contract.generated.ts"; export { @@ -385,6 +393,8 @@ const COMMIT_ENTRY_REQUEST_FIELDS = new Set([ "partition_digest", ]); const ENTRY_FIELDS = new Set([ + "prepared_sha256", "committed_sha256", + "capture_lineage_id", "entry_id", "partition", "seq", @@ -396,6 +406,7 @@ const ENTRY_FIELDS = new Set([ "resolution", ]); const READ_REQUEST_FIELDS = new Set([ + "receipt_operation_id", "schema_version", "runtime_root", "goal_id", @@ -405,7 +416,7 @@ const READ_REQUEST_FIELDS = new Set([ ]); const ENTRY_ID_PATTERN = /^local-shadow-tx-[0-9a-f]{64}$/u; const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u; -const MAX_SCAN_LIMIT = 1000; +const MAX_SCAN_LIMIT = 10000; const REVISION_RETRY_ATTEMPTS = 3; export type ShadowPartition = (typeof SHADOW_PARTITIONS)[number]; @@ -427,6 +438,7 @@ interface ShadowEntryWriter { } interface ShadowEntrySource { + previous_partition_digest: string; kind: (typeof SOURCE_KINDS)[number]; previous_bytes_digest: string | null; bytes_digest: string | null; @@ -435,6 +447,9 @@ interface ShadowEntrySource { } interface ShadowEntry { + prepared_sha256: string; + committed_sha256: string | null; + capture_lineage_id: string; entry_id: string; partition: ShadowPartition; seq: number; @@ -470,6 +485,7 @@ export interface LocalAuthorityShadowCommitEntryResult extends JsonObject { } interface ReadRequest { + receipt_operation_id: string | null; runtime_root: string; goal_id: string; store_kind: "runtime_shadow" | "legacy_observation"; @@ -527,10 +543,15 @@ function decodeEntry(value: unknown): ShadowEntry { } const writer = requireJsonObject(raw.writer, "entry.writer"); const source = requireJsonObject(raw.source, "entry.source"); + rejectUnexpectedFields(writer, new Set(["runtime", "write_class", "operation_id"]), "entry.writer"); + rejectUnexpectedFields(source, new Set(["kind", "previous_bytes_digest", "previous_partition_digest", "bytes_digest", "lease", "event_id"]), "entry.source"); const lease = source.lease === null || source.lease === undefined ? null : requireJsonObject(source.lease, "entry.source.lease"); return { + prepared_sha256: optionalDigest(raw.prepared_sha256, "entry.prepared_sha256") ?? (() => { throw new Error("prepared_sha256 is required"); })(), + committed_sha256: optionalDigest(raw.committed_sha256, "entry.committed_sha256"), + capture_lineage_id: requireNonEmptyString(raw.capture_lineage_id, "entry.capture_lineage_id"), entry_id: entryId, partition: requireStringLiteral(raw.partition, SHADOW_PARTITIONS, "entry.partition"), seq, @@ -540,6 +561,8 @@ function decodeEntry(value: unknown): ShadowEntry { operation_id: optionalString(writer.operation_id, "entry.writer.operation_id"), }, source: { + previous_partition_digest: optionalDigest(source.previous_partition_digest, "entry.source.previous_partition_digest") ?? + (() => { throw new Error("previous_partition_digest is required"); })(), kind: requireStringLiteral(source.kind, SOURCE_KINDS, "entry.source.kind"), previous_bytes_digest: optionalDigest( source.previous_bytes_digest, @@ -624,6 +647,7 @@ function decodeReadRequest(value: unknown): ReadRequest { throw new EffectRuntimeRequestError(`scan_limit must be between 0 and ${MAX_SCAN_LIMIT}`); } return { + receipt_operation_id: optionalString(request.receipt_operation_id, "receipt_operation_id"), runtime_root: requireNonEmptyString(request.runtime_root, "runtime_root"), goal_id: requireGoalId(request.goal_id), store_kind: request.store_kind === undefined || request.store_kind === "runtime_shadow" @@ -707,6 +731,11 @@ export function composeLocalAuthorityShadowHead( leases, todo_read_model: todoReadModel(todos), partitions, + ...(base.capture_profile === undefined ? {} : { + capture_profile: base.capture_profile, + capture_lineage_id: base.capture_lineage_id, + source_root_digest: base.source_root_digest, + }), }; return next; } @@ -715,6 +744,9 @@ function transactionReceipt(request: CommitEntryRequest, noOp: boolean): JsonObj const { entry } = request; return { schema_version: LOCAL_AUTHORITY_SHADOW_TRANSACTION_RECEIPT_SCHEMA, + prepared_sha256: entry.prepared_sha256, + committed_sha256: entry.committed_sha256, + capture_lineage_id: entry.capture_lineage_id, entry_id: entry.entry_id, partition: entry.partition, seq: entry.seq, @@ -724,6 +756,7 @@ function transactionReceipt(request: CommitEntryRequest, noOp: boolean): JsonObj source_kind: entry.source.kind, source_bytes_digest: entry.source.bytes_digest, source_previous_bytes_digest: entry.source.previous_bytes_digest, + source_previous_partition_digest: entry.source.previous_partition_digest, source_event_id: entry.source.event_id, source_lease: entry.source.lease, source_root_digest: entry.source_root_digest, @@ -750,12 +783,16 @@ function transactionEvent(request: CommitEntryRequest, noOp: boolean): JsonObjec else if (entry.resolution === "unproved") kind = "source_transaction_unproved"; return { schema_version: LOCAL_AUTHORITY_SHADOW_EVENT_SCHEMA_V1, + prepared_sha256: entry.prepared_sha256, + committed_sha256: entry.committed_sha256, + capture_lineage_id: entry.capture_lineage_id, kind, partition: entry.partition, seq: entry.seq, entry_id: entry.entry_id, write_class: entry.writer.write_class, partition_digest: request.partition_digest, + previous_partition_digest: entry.source.previous_partition_digest, no_op: noOp, }; } @@ -777,6 +814,7 @@ function commitEntryResult( reason_code: options.reasonCode ?? null, goal_id: request.goal_id, entry_id: request.entry.entry_id, + capture_lineage_id: request.entry.capture_lineage_id, partition: request.entry.partition, seq: request.entry.seq, no_op: NO_OP_RESOLUTIONS.has(request.entry.resolution), @@ -791,16 +829,13 @@ function transactionReceiptMatches( request: CommitEntryRequest, result: Extract, ): boolean { - return result.receipts.some((raw) => { - const receipt = raw as Record; - return receipt.schema_version === LOCAL_AUTHORITY_SHADOW_TRANSACTION_RECEIPT_SCHEMA && - receipt.entry_id === request.entry.entry_id && - receipt.partition === request.entry.partition && - receipt.seq === request.entry.seq && - (receipt.partition_digest ?? null) === request.partition_digest && - receipt.primary_authority === "legacy_local" && - receipt.provider_to_local_writes === false; - }); + if (result.receipts.length !== 1) return false; + const actual = { ...result.receipts[0] }; + const expected = transactionReceipt(request, NO_OP_RESOLUTIONS.has(request.entry.resolution)); + if (typeof actual.drained_at !== "string") return false; + delete actual.drained_at; + delete expected.drained_at; + return canonicalAuthorityBytes(actual).equals(canonicalAuthorityBytes(expected)); } async function reconcileTransactionReceipt( @@ -844,7 +879,7 @@ function openShadowStore( const providerDirectory = storeKind === "legacy_observation" ? join(runtimeRoot, "authority-shadow", "file", goalId) : join(runtimeRoot, "authority-shadow", "file-v0"); - return (dependencies.openStore ?? ((directory, id) => new FileAuthorityStore(directory, id)))( + return (dependencies.openStore ?? ((directory, id) => new FileAuthorityStore(directory, id, { existingOnly: true })))( providerDirectory, goalId, ); @@ -854,6 +889,279 @@ type CommitAttempt = | { kind: "final"; result: LocalAuthorityShadowCommitEntryResult } | { kind: "retry"; result: LocalAuthorityShadowCommitEntryResult }; +export interface ShadowLineageBinding { + capture_profile: string; + capture_lineage_id: string; + source_root_digest: string; + store_identity: string; + bootstrap_operation_id: string; + bootstrap_provider_revision: string; +} + +export class ShadowLineageError extends Error { + readonly reason_code: string; + constructor(reasonCode: string) { super(reasonCode); this.reason_code = reasonCode; } +} + +function requireLineage(condition: unknown, reason: string): asserts condition { + if (!condition) throw new ShadowLineageError(reason); +} + +function sourceReference(entry: ShadowEntry, digest: string | null): string { + if (entry.source.bytes_digest !== null) return entry.source.bytes_digest; + if (entry.source.event_id !== null) return `event:${entry.source.event_id}`; + if (entry.resolution === "seed" && digest !== null) return `seed:${digest}`; + throw new ShadowLineageError("entry_source_identity_missing"); +} + +function validateEntryIdentity(request: CommitEntryRequest, binding: ShadowLineageBinding): void { + const { entry } = request; + requireLineage(entry.capture_lineage_id === binding.capture_lineage_id, "stale_generation"); + const rootDigest = `sha256:${createHash("sha256").update(resolve(request.runtime_root)).digest("hex")}`; + requireLineage(entry.source_root_digest === binding.source_root_digest && rootDigest === binding.source_root_digest, + "source_root_mismatch"); + requireLineage(entry.entry_id === outboxEntryIdentity(request.goal_id, entry.partition, entry.seq, + sourceReference(entry, request.partition_digest), entry.capture_lineage_id, entry.source_root_digest), + "entry_identity_mismatch"); + if (request.partition_projection !== null) { + requireLineage(request.partition_digest === `sha256:${canonicalAuthoritySha256(request.partition_projection)}`, + "partition_digest_mismatch"); + } + requireLineage(entry.source.kind !== "state_event_log", "event_log_writer_not_bound"); + requireLineage(entry.source.kind === (entry.partition === "todos" ? "markdown_active_state" : "task_lease_record"), + "entry_source_partition_mismatch"); + requireLineage(entry.resolution !== "unproved" && entry.resolution !== "seed", "source_transaction_unproved"); +} + +function partitionProjection(head: JsonObject, partition: ShadowPartition): JsonObject { + return partition === "todos" ? { handoff_mode: head.handoff_mode, todos: head.todos } : { leases: head.leases }; +} + +function validateSourceContinuity(request: CommitEntryRequest, previous: JsonObject): void { + const digest = `sha256:${canonicalAuthoritySha256(partitionProjection(previous, request.entry.partition))}`; + requireLineage(request.entry.source.previous_partition_digest === digest, "source_partition_continuity_unproved"); + if (!NO_OP_RESOLUTIONS.has(request.entry.resolution)) { + requireLineage(request.partition_digest !== digest, "partition_unchanged"); + } +} + +async function verifyPendingEntryFiles(request: CommitEntryRequest): Promise { + const entry = request.entry; + const directory = join(request.runtime_root, "authority-shadow", "outbox", request.goal_id, entry.partition); + const stem = `${String(entry.seq).padStart(10, "0")}-${entry.entry_id}`; + const bytes = await readFile(join(directory, `${stem}.prepared.json`)); + requireLineage(`sha256:${createHash("sha256").update(bytes).digest("hex")}` === entry.prepared_sha256, + "outbox_prepared_bytes_mismatch"); + const prepared = requireJsonObject(JSON.parse(bytes.toString("utf8")), "prepared entry"); + requireLineage(prepared.schema_version === LOCAL_AUTHORITY_SHADOW_OUTBOX_ENTRY_SCHEMA && + prepared.goal_id === request.goal_id && prepared.entry_id === entry.entry_id && prepared.seq === entry.seq && + prepared.partition === entry.partition && prepared.capture_lineage_id === entry.capture_lineage_id && + prepared.source_root_digest === entry.source_root_digest && prepared.prepared_at === entry.prepared_at && + canonicalAuthorityBytes(prepared.writer).equals(canonicalAuthorityBytes(entry.writer)), "outbox_prepared_identity_mismatch"); + const source = { ...requireJsonObject(prepared.source, "prepared source") }; + delete source.previous_lease; + requireLineage(canonicalAuthorityBytes(source).equals(canonicalAuthorityBytes(entry.source)), "outbox_prepared_source_mismatch"); + if (request.partition_projection !== null) { + let projection = requireJsonObject(prepared.projection, "prepared projection"); + if (entry.partition === "leases") { + requireLineage(Array.isArray(projection.leases), "outbox_prepared_projection_mismatch"); + const leases = (projection.leases as JsonObject[]).map((value) => { + const record = requireJsonObject(value.record, "prepared lease record"); + requireLineage(record.goal_id === request.goal_id && record.todo_id === value.file_stem, "source_lease_identity_mismatch"); + return record; + }); + projection = { leases }; + } + requireLineage(canonicalAuthorityBytes(projection).equals(canonicalAuthorityBytes(request.partition_projection)), + "outbox_prepared_projection_mismatch"); + } + let markerBytes: Buffer | null = null; + try { markerBytes = await readFile(join(directory, `${stem}.committed.json`)); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + requireLineage((markerBytes === null ? null : `sha256:${createHash("sha256").update(markerBytes).digest("hex")}`) === entry.committed_sha256, + "outbox_committed_bytes_mismatch"); + if (markerBytes !== null) { + requireLineage(entry.resolution === "committed", "outbox_resolution_marker_mismatch"); + const marker = requireJsonObject(JSON.parse(markerBytes.toString("utf8")), "committed marker"); + rejectUnexpectedFields(marker, new Set(["schema_version", "entry_id", "capture_lineage_id", "committed_at"]), "committed marker"); + requireLineage(marker.schema_version === LOCAL_AUTHORITY_SHADOW_OUTBOX_COMMIT_SCHEMA && marker.entry_id === entry.entry_id && + marker.capture_lineage_id === entry.capture_lineage_id && marker.committed_at === entry.committed_at, "outbox_committed_identity_mismatch"); + } else { + requireLineage(entry.committed_at === null && entry.resolution !== "committed", "outbox_committed_marker_missing"); + } +} + +/** Resolve markerless evidence again under the actual primary lock, and keep + * that lock through the candidate commit. A caller's earlier observation can + * have become stale while it crossed the Python/TypeScript process boundary. + */ +async function withMarkerlessSourceProof( + request: CommitEntryRequest, + binding: Awaited>, + operation: () => Promise, +): Promise { + if (request.entry.committed_sha256 !== null) return await operation(); + const entry = request.entry; + const proveAndCommit = async (sourcePath: string): Promise => { + await verifyPendingEntryFiles(request); + const directory = join(request.runtime_root, "authority-shadow", "outbox", request.goal_id, entry.partition); + for (const item of await readdir(directory, { withFileTypes: true })) { + requireLineage(item.isFile() && !item.isSymbolicLink(), "source_transaction_unproved"); + if (item.name === "drain-cursor.json") continue; + const match = OUTBOX_ENTRY_FILE_PATTERN.exec(item.name); + requireLineage(match !== null && Number(match[1]) <= entry.seq && + (Number(match[1]) !== entry.seq || match[2] === entry.entry_id), "source_transaction_unproved"); + } + let source: Buffer | null = null; + try { source = await readFile(sourcePath); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const digest = source === null ? null : `sha256:${createHash("sha256").update(source).digest("hex")}`; + const expected = entry.resolution === "abandoned" ? entry.source.previous_bytes_digest : entry.source.bytes_digest; + requireLineage((entry.resolution === "abandoned" || entry.resolution === "committed_proven_by_readback") && + digest === expected, "source_transaction_unproved"); + return await operation(); + }; + if (entry.partition === "todos") { + const statePath = await readShadowBootstrapSourcePath(request.runtime_root, request.goal_id, binding); + return await withFileMutationLock(legacyCoordinationTodoLockPath(request.runtime_root, request.goal_id), () => + withFileMutationLock(statePath, () => proveAndCommit(statePath))); + } + const todoId = entry.source.lease?.todo_id; + requireLineage(typeof todoId === "string" && /^[A-Za-z0-9_.-]+$/.test(todoId) && todoId !== "." && todoId !== "..", + "source_transaction_unproved"); + const leasePath = join(request.runtime_root, "goals", request.goal_id, "task-leases", `${todoId}.json`); + return await withFileMutationLock(taskLeaseLockPath(request), () => proveAndCommit(leasePath)); +} + +export interface ValidatedShadowLineage { + head: Extract; + transactions: AuthorityStoreCommittedTransaction[]; + last_sequences: Record; + last_applied_sequences: Record; + write_classes: string[]; +} + +/** The caller holds its primary partition lock. This is existing-only and + * never takes M or writes a cursor: management cannot complete a transition + * while that primary lock is held, and a changed binding still fails closed. + */ +export async function readProvenShadowSequence( + runtimeRoot: string, goalId: string, partition: ShadowPartition, expectedLineageId: string, +): Promise { + const binding = await requireShadowCaptureBinding(runtimeRoot, goalId); + requireLineage(binding.capture_lineage_id === expectedLineageId, "stale_generation"); + const store = new FileAuthorityStore(join(runtimeRoot, "authority-shadow", "file-v0"), goalId, { existingOnly: true }); + const lineage = await loadValidatedShadowLineage(store, runtimeRoot, goalId, binding); + const current = await requireShadowCaptureBinding(runtimeRoot, goalId); + requireLineage(canonicalAuthorityBytes(binding).equals(canonicalAuthorityBytes(current)), "stale_generation"); + return lineage.last_sequences[partition]; +} + +/** Validate the exact bootstrap, every transaction, and the final readback. + * The caller owns maintenance exclusion; this function never takes M. + */ +export async function loadValidatedShadowLineage( + store: AuthorityStore, + runtimeRoot: string, + goalId: string, + binding: ShadowLineageBinding, +): Promise { + const identity = await store.storeIdentity(); + requireLineage(identity.status === "available" && identity.store_identity === binding.store_identity, + "shadow_store_identity_mismatch"); + const head = await store.loadAuthority(); + requireLineage(head.status === "loaded", "bootstrap_required"); + const transactions: AuthorityStoreCommittedTransaction[] = []; + let after: string | null = null; + for (;;) { + const page = await store.scanCommitted(after, 256); + requireLineage(page.status === "page", "shadow_history_unavailable"); + transactions.push(...page.transactions); + requireLineage(transactions.length <= 10000, "shadow_qualification_history_too_large"); + if (!page.has_more) break; + requireLineage(page.next_cursor !== null && page.next_cursor !== after && page.transactions.length > 0, + "shadow_qualification_cursor_stalled"); + after = page.next_cursor; + } + const first = transactions[0]; + requireLineage(first !== undefined && first.cursor === "1" && first.operation_id === binding.bootstrap_operation_id && + first.provider_revision === binding.bootstrap_provider_revision && first.receipts.length === 0 && first.events.length === 1, + "shadow_qualification_bootstrap_identity_invalid"); + const baseline = first.projection; + requireLineage(binding.capture_profile === "file_outbox_v1" && baseline.capture_profile === binding.capture_profile && + baseline.capture_lineage_id === binding.capture_lineage_id && baseline.source_root_digest === binding.source_root_digest && + baseline.goal_id === goalId && baseline.schema_version === LOCAL_AUTHORITY_SHADOW_PROJECTION_SCHEMA_V1 && + typeof baseline.handoff_mode === "string" && Array.isArray(baseline.leases), "legacy_lineage_ineligible"); + validateCoordinationTodoReadModel(baseline, goalId); + requireLineage(canonicalAuthorityBytes(baseline.partitions).equals(canonicalAuthorityBytes({ todos: null, leases: null })), + "shadow_bootstrap_partitions_invalid"); + const bootstrapEvent = first.events[0]!; + requireLineage(canonicalAuthorityBytes(bootstrapEvent).equals(canonicalAuthorityBytes({ + schema_version: "loopx_coordination_runtime_shadow_bootstrap_event_v0", + operation_id: binding.bootstrap_operation_id, + source_version: bootstrapEvent.source_version, + source_projection_sha256: canonicalAuthoritySha256(baseline), + mode_declaration: "legacy_canonical_shadow", + })) && typeof bootstrapEvent.source_version === "string", "shadow_qualification_bootstrap_shape_invalid"); + let previous = baseline; + const settled: Record = { todos: 0, leases: 0 }; + const applied: Record = { todos: 0, leases: 0 }; + const writeClasses = new Set(); + const operationIds = new Set([first.operation_id]); + for (const [index, transaction] of transactions.slice(1).entries()) { + requireLineage(transaction.cursor === String(index + 2) && transaction.receipts.length === 1 && transaction.events.length === 1 && + !operationIds.has(transaction.operation_id), "shadow_qualification_transaction_shape_invalid"); + operationIds.add(transaction.operation_id); + const receipt = transaction.receipts[0]!; + const partition = requireStringLiteral(receipt.partition, SHADOW_PARTITIONS, "receipt.partition"); + const noOp = receipt.no_op === true; + const projection: JsonObject | null = noOp ? null : partition === "todos" + ? { handoff_mode: transaction.projection.handoff_mode, todos: transaction.projection.todos } + : { leases: transaction.projection.leases }; + const request: CommitEntryRequest = { + runtime_root: runtimeRoot, goal_id: goalId, + entry: decodeEntry({ + capture_lineage_id: receipt.capture_lineage_id, + prepared_sha256: receipt.prepared_sha256, committed_sha256: receipt.committed_sha256, + entry_id: receipt.entry_id, partition, seq: receipt.seq, + writer: { runtime: receipt.writer_runtime, write_class: receipt.write_class, operation_id: receipt.writer_operation_id }, + source: { kind: receipt.source_kind, bytes_digest: receipt.source_bytes_digest, + previous_partition_digest: receipt.source_previous_partition_digest, + previous_bytes_digest: receipt.source_previous_bytes_digest, event_id: receipt.source_event_id, lease: receipt.source_lease }, + source_root_digest: receipt.source_root_digest, prepared_at: receipt.prepared_at, + committed_at: receipt.committed_at, resolution: receipt.resolution, + }), + partition_projection: projection, + partition_digest: optionalDigest(receipt.partition_digest, "receipt.partition_digest"), + }; + validateEntryIdentity(request, binding); + validateSourceContinuity(request, previous); + requireLineage(transaction.operation_id === request.entry.entry_id && request.entry.seq === settled[partition] + 1 && + noOp === NO_OP_RESOLUTIONS.has(request.entry.resolution) && + transactionReceiptMatches(request, { status: "found", receipts: transaction.receipts, + cursor: transaction.cursor, provider_revision: transaction.provider_revision }), "shadow_qualification_transaction_identity_invalid"); + requireLineage(canonicalAuthorityBytes(transaction.events).equals(canonicalAuthorityBytes([transactionEvent(request, noOp)])), + "shadow_qualification_event_identity_invalid"); + const expected = composeLocalAuthorityShadowHead(previous, goalId, request.entry, projection, request.partition_digest); + requireLineage(canonicalAuthorityBytes(expected).equals(canonicalAuthorityBytes(transaction.projection)), + "shadow_qualification_projection_history_invalid"); + validateCoordinationTodoReadModel(transaction.projection, goalId); + settled[partition] = request.entry.seq; + if (!noOp) { applied[partition] = request.entry.seq; writeClasses.add(request.entry.writer.write_class); } + previous = transaction.projection; + } + const last = transactions.at(-1)!; + const reread = await store.loadAuthority(); + requireLineage(reread.status === "loaded" && reread.provider_revision === head.provider_revision && + last.provider_revision === head.provider_revision && last.cursor === head.cursor && + canonicalAuthorityBytes(previous).equals(canonicalAuthorityBytes(head.head)) && + canonicalAuthorityBytes(reread.head).equals(canonicalAuthorityBytes(head.head)), "shadow_snapshot_changed_retry"); + return { head, transactions, last_sequences: settled, last_applied_sequences: applied, + write_classes: [...writeClasses].sort(authorityUnicodeCompare) }; +} + async function settleCommitOutcome( store: AuthorityStore, request: CommitEntryRequest, @@ -921,6 +1229,9 @@ async function attemptCommitEntry( }), }; } + if (loaded.status === "missing") { + return { kind: "final", result: commitEntryResult(request, "failed", { reasonCode: "bootstrap_required" }) }; + } const nextHead = composeLocalAuthorityShadowHead( loaded.status === "loaded" ? loaded.head : null, request.goal_id, @@ -928,6 +1239,7 @@ async function attemptCommitEntry( request.partition_projection, request.partition_digest, ); + validateCoordinationTodoReadModel(nextHead, request.goal_id); const committed = await store.commitAuthority({ expected_provider_revision: loaded.status === "loaded" ? loaded.provider_revision : null, operation_id: request.entry.entry_id, @@ -949,8 +1261,8 @@ async function attemptCommitEntry( * * `operation_id` is the entry id, so a retry after a lost response replays * onto the same transaction instead of recording the source write twice. - * No-op resolutions (abandoned / unproved) keep the sequence chain auditable - * without changing the compared head fields. + * Proven abandoned entries settle their sequence without changing the compared + * head. Unproved entries remain pending and require explicit recovery. */ export async function commitLocalAuthorityShadowEntry( value: unknown, @@ -958,32 +1270,37 @@ export async function commitLocalAuthorityShadowEntry( ): Promise { const request = decodeCommitEntryRequest(value); const noOp = NO_OP_RESOLUTIONS.has(request.entry.resolution); - let store: AuthorityStore; try { - store = openShadowStore( - request.runtime_root, - request.goal_id, - "runtime_shadow", - dependencies, - ); - } catch { - return commitEntryResult(request, "unavailable", { - reasonCode: "provider_construction_failed", + return await withShadowMaintenanceLock(request.runtime_root, request.goal_id, async () => { + const binding = await requireShadowCaptureBinding(request.runtime_root, request.goal_id); + validateEntryIdentity(request, binding); + const store = openShadowStore(request.runtime_root, request.goal_id, "runtime_shadow", dependencies); + for (let index = 0; index < REVISION_RETRY_ATTEMPTS; index += 1) { + const active = await requireShadowCaptureBinding(request.runtime_root, request.goal_id); + requireLineage(active.capture_lineage_id === binding.capture_lineage_id, "stale_generation"); + const lineage = await loadValidatedShadowLineage(store, request.runtime_root, request.goal_id, active); + const existing = await store.readReceipt(request.entry.entry_id); + if (existing.status === "found") { + return await reconcileTransactionReceipt(store, request, binding.store_identity, "replayed"); + } + requireLineage(existing.status === "missing", "shadow_receipt_unavailable"); + requireLineage(lineage.transactions.length < 10000, "shadow_qualification_history_too_large"); + const attempt = await withMarkerlessSourceProof(request, active, async () => { + await verifyPendingEntryFiles(request); + requireLineage(request.entry.seq === lineage.last_sequences[request.entry.partition] + 1, + "partition_sequence_mismatch"); + validateSourceContinuity(request, lineage.head.head); + return await attemptCommitEntry(store, request, binding.store_identity, noOp); + }); + if (attempt.kind === "final") return attempt.result; + } + return commitEntryResult(request, "conflict_retry_required", { reasonCode: "provider_revision_mismatch" }); + }); + } catch (error) { + const raw = error as { reason_code?: string; code?: string }; + return commitEntryResult(request, "failed", { + reasonCode: raw.reason_code ?? raw.code ?? "provider_call_failed", }); - } - try { - const identity = await store.storeIdentity(); - if (identity.status !== "available") { - return commitEntryResult(request, identity.status, { reasonCode: identity.reason_code }); - } - let attempt: CommitAttempt | null = null; - for (let index = 0; index < REVISION_RETRY_ATTEMPTS; index += 1) { - attempt = await attemptCommitEntry(store, request, identity.store_identity, noOp); - if (attempt.kind === "final") return attempt.result; - } - return (attempt as CommitAttempt).result; - } catch { - return commitEntryResult(request, "unavailable", { reasonCode: "provider_call_failed" }); } } @@ -1088,8 +1405,28 @@ export async function readLocalAuthorityShadow( }; } const result = loadedReadResult(base, identity.store_identity, loaded); + if (request.store_kind === "runtime_shadow" && loaded.status === "loaded" && loaded.head.capture_profile !== "file_outbox_v1") { + result.eligible = false; + result.reason_code = "legacy_lineage_ineligible"; + } else if (request.store_kind === "runtime_shadow" && loaded.status === "loaded") { + const binding = await requireShadowCaptureBinding(request.runtime_root, request.goal_id); + const lineage = await loadValidatedShadowLineage(store, request.runtime_root, request.goal_id, binding); + const receipt = request.receipt_operation_id === null ? null : + lineage.transactions.find((transaction) => transaction.operation_id === request.receipt_operation_id) ?? null; + result.proof = { + capture_lineage_id: binding.capture_lineage_id, + bootstrap_provider_revision: binding.bootstrap_provider_revision, + last_sequences: lineage.last_sequences, + last_applied_sequences: lineage.last_applied_sequences, + transactions: structuredClone(lineage.transactions.filter((transaction) => + request.scan_after_cursor === null || Number(transaction.cursor) > Number(request.scan_after_cursor) + ).slice(0, request.scan_limit)) as unknown as JsonObject[], + receipt: receipt === null ? null : structuredClone(receipt) as unknown as JsonObject, + }; + } return request.scan_limit > 0 ? await appendScanPage(store, request, result) : result; - } catch { - return { ...base, reason_code: "provider_call_failed" }; + } catch (error) { + const raw = error as { reason_code?: string; code?: string }; + return { ...base, status: "failed", reason_code: raw.reason_code ?? raw.code ?? "provider_call_failed" }; } } diff --git a/loopx/control_plane/coordination/local_authority_shadow_adapter.py b/loopx/control_plane/coordination/local_authority_shadow_adapter.py index eb60c6e889..0fb18a673c 100644 --- a/loopx/control_plane/coordination/local_authority_shadow_adapter.py +++ b/loopx/control_plane/coordination/local_authority_shadow_adapter.py @@ -1,15 +1,11 @@ -"""Post-commit bridge from legacy local authority into a file shadow. +"""Transaction capture evidence, receipt-proven drain, and operator readback. -The adapter deliberately owns no lifecycle decision. It is entered only after -the existing Markdown or task-lease writer has succeeded, projects public-safe -facts, and asks the TypeScript authority-store boundary to retain an -observation. Missing configuration is a zero-effect fast path. +The independent compatibility observation path lives in +local_authority_shadow_observation; this owner only delivers durable entries. """ from __future__ import annotations -import hashlib -import json import time from collections.abc import Iterator, Mapping from contextlib import contextmanager @@ -20,7 +16,6 @@ from ...file_lock import ( LockAcquireTimeoutError, exclusive_cross_runtime_file_lock, - exclusive_file_lock, try_exclusive_file_lock, ) from ...history import load_registry @@ -31,12 +26,8 @@ from .coordination_state_contract_generated import ( LOCAL_AUTHORITY_SHADOW_COMMIT_ENTRY_REQUEST_SCHEMA, LOCAL_AUTHORITY_SHADOW_COMMIT_ENTRY_RESULT_SCHEMA, - LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA, - LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA, - LOCAL_AUTHORITY_SHADOW_PROJECTION_SCHEMA, LOCAL_AUTHORITY_SHADOW_READ_REQUEST_SCHEMA, LOCAL_AUTHORITY_SHADOW_READ_RESULT_SCHEMA, - LOCAL_AUTHORITY_SHADOW_REQUEST_SCHEMA, LOCAL_AUTHORITY_SHADOW_TRANSACTION_EVIDENCE_SCHEMA, ) from .local_authority_shadow_projection import ( @@ -46,119 +37,13 @@ canonical_value, head_digest, partition_digest, - text_digest, todo_partition_projection, ) from .runtime_shadow import resolve_coordination_runtime_shadow_config +from .shadow_management import read_shadow_capture_binding -_CONFIG_FIELDS = {"schema_version", "mode"} -_PROJECTION_ATTEMPTS = 3 -_CONFLICT_RETRY_ATTEMPTS = 3 -_EVIDENCE_OUTCOMES = { - "captured", - "replayed", - "ambiguous_reconciled", - "ambiguous_unproved", - "unavailable", - "failed", - "protocol_mismatch", - "conflict_retry_required", -} -_TODO_FIELDS = ( - "todo_id", - "role", - "status", - "claimed_by", - "bound_agent", - "goal_bound", - "blocks_agent", - "excluded_agents", - "global_gate", - "task_class", - "action_kind", - "required_write_scopes", - "required_capabilities", - "continuation_policy", - "successor_todo_ids", - "no_followup", - "completion_continuation", -) -_LEASE_FIELDS = ( - "todo_id", - "owner", - "idempotency_key", - "write_scopes", - "version", - "lease_epoch", - "acquired_at", - "updated_at", - "expires_at", - "released_at", - "status", -) - - -def local_authority_shadow_summary(goal: Mapping[str, Any]) -> dict[str, Any]: - """Project the closed local-shadow configuration for operator readback.""" - - coordination = ( - goal.get("coordination") - if isinstance(goal.get("coordination"), Mapping) - else {} - ) - raw = coordination.get("authority_shadow") - if raw is None: - return {"enabled": False, "mode": None, "status": "disabled"} - valid = bool( - isinstance(raw, Mapping) - and set(raw) == _CONFIG_FIELDS - and raw.get("schema_version") == LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA - and raw.get("mode") == "file_one_way" - ) - return { - "enabled": valid, - "mode": raw.get("mode") if isinstance(raw, Mapping) else None, - "status": "enabled" if valid else "invalid", - } - - -def validate_local_authority_shadow_change( - enable_file: bool, - clear: bool, -) -> None: - """Reject contradictory CLI intent before reading or mutating the registry.""" - - if enable_file and clear: - raise ValueError( - "--local-authority-shadow-file cannot be combined with " - "--clear-local-authority-shadow" - ) - - -def apply_local_authority_shadow_change( - goal: dict[str, Any], - enable_file: bool, - clear: bool, -) -> None: - """Apply a validated default-off local-shadow configuration change.""" - - if not enable_file and not clear: - return - coordination = ( - goal.get("coordination") if isinstance(goal.get("coordination"), dict) else {} - ) - if clear: - coordination.pop("authority_shadow", None) - else: - coordination["authority_shadow"] = { - "schema_version": LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA, - "mode": "file_one_way", - } - if coordination: - goal["coordination"] = coordination - else: - goal.pop("coordination", None) +from .local_authority_shadow_observation import local_authority_shadow_summary def effective_runtime_root( @@ -179,345 +64,6 @@ def effective_runtime_root( return resolve_runtime_root(registry, override, registry_path=registry_path) -def _base_evidence( - *, - goal_id: str, - outcome: str, - reason_code: str, -) -> dict[str, Any]: - return { - "schema_version": LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA, - "outcome": outcome, - "reason_code": reason_code, - "goal_id": goal_id, - "observation_id": None, - "source_digest": None, - "capture_kind": "post_commit_snapshot", - "source_transaction_correlated": False, - "durable_source_outbox": False, - "source_candidate_compared": False, - "parity_verdict": "not_evaluated", - "primary_authority": "legacy_local", - "candidate_provider": "file", - "candidate_read_for_decision": False, - "provider_to_local_writes": False, - "primary_writeback_preserved": True, - "store_identity": None, - "provider_revision": None, - "cursor": None, - } - - -def _shadow_config(registry: dict[str, Any], goal_id: str) -> dict[str, str] | None: - goal = find_registry_goal(registry, goal_id) - coordination = goal.get("coordination") if isinstance(goal, dict) else None - if not isinstance(coordination, dict) or "authority_shadow" not in coordination: - return None - raw = coordination.get("authority_shadow") - if ( - not isinstance(raw, dict) - or set(raw) != _CONFIG_FIELDS - or raw.get("schema_version") != LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA - or raw.get("mode") != "file_one_way" - ): - raise ValueError("authority_shadow must be a closed file_one_way config") - return {"mode": "file_one_way"} - - -def _canonical(value: object) -> bytes: - return json.dumps( - value, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - allow_nan=False, - ).encode("utf-8") - - -def _compact_todo(raw: object) -> dict[str, Any] | None: - if not isinstance(raw, dict): - return None - todo_id = str(raw.get("todo_id") or "").strip() - if not todo_id: - return None - compact = {field: raw[field] for field in _TODO_FIELDS if field in raw} - compact["todo_id"] = todo_id - if "status" not in compact and isinstance(raw.get("done"), bool): - compact["status"] = "done" if raw["done"] else "open" - return json.loads(_canonical(compact)) - - -def _compact_lease(path: Path, *, goal_id: str) -> dict[str, Any]: - raw = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(raw, dict): - raise ValueError("task lease must contain an object") - if raw.get("goal_id") != goal_id or raw.get("todo_id") != path.stem: - raise ValueError("task lease identity does not match its shadow source") - return json.loads( - _canonical({field: raw[field] for field in _LEASE_FIELDS if field in raw}) - ) - - -def _source_projection( - *, - registry_path: Path, - runtime_root: Path, - goal_id: str, -) -> dict[str, Any]: - from ...control_plane.todos.handoff_mode import goal_handoff_mode_for_goal - from ...todos import list_goal_todos - - todo_payload = list_goal_todos( - registry_path=registry_path, - goal_id=goal_id, - runtime_root_arg=str(runtime_root), - ) - todos = [ - compact - for raw in todo_payload.get("todos") or [] - if (compact := _compact_todo(raw)) is not None - ] - todos.sort(key=lambda item: str(item["todo_id"])) - lease_dir = runtime_root / "goals" / goal_id / "task-leases" - leases = ( - [ - _compact_lease(path, goal_id=goal_id) - for path in sorted(lease_dir.glob("*.json")) - ] - if lease_dir.exists() - else [] - ) - projection = { - "schema_version": LOCAL_AUTHORITY_SHADOW_PROJECTION_SCHEMA, - "goal_id": goal_id, - "handoff_mode": goal_handoff_mode_for_goal( - registry_path=registry_path, - goal_id=goal_id, - ), - "todos": todos, - "leases": leases, - } - return json.loads(_canonical(projection)) - - -def _stable_projection( - *, - registry_path: Path, - runtime_root: Path, - goal_id: str, -) -> dict[str, Any]: - previous = _source_projection( - registry_path=registry_path, - runtime_root=runtime_root, - goal_id=goal_id, - ) - for _attempt in range(_PROJECTION_ATTEMPTS): - current = _source_projection( - registry_path=registry_path, - runtime_root=runtime_root, - goal_id=goal_id, - ) - if current == previous: - return current - previous = current - raise RuntimeError("local authority sources did not stabilize for shadowing") - - -def _valid_evidence( - result: object, - *, - goal_id: str, - observation_id: str, - source_digest: str, -) -> bool: - if not isinstance(result, dict): - return False - return ( - result.get("schema_version") == LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA - and result.get("outcome") in _EVIDENCE_OUTCOMES - and result.get("goal_id") == goal_id - and result.get("observation_id") == observation_id - and result.get("source_digest") == source_digest - and result.get("capture_kind") == "post_commit_snapshot" - and result.get("source_transaction_correlated") is False - and result.get("durable_source_outbox") is False - and result.get("source_candidate_compared") is False - and result.get("parity_verdict") == "not_evaluated" - and result.get("primary_authority") == "legacy_local" - and result.get("candidate_provider") == "file" - and result.get("candidate_read_for_decision") is False - and result.get("provider_to_local_writes") is False - and result.get("primary_writeback_preserved") is True - and ( - result.get("reason_code") is None - or isinstance(result.get("reason_code"), str) - ) - ) - - -def observe_local_authority_commit( - *, - registry_path: Path, - runtime_root: Path | None, - goal_id: str, - observation_trigger: str, -) -> dict[str, Any] | None: - """Capture a best-effort post-commit snapshot without changing its verdict. - - ``observation_trigger`` is diagnostic context, not a primary transaction - identity. The snapshot may include commits that landed after that trigger. - """ - - if not goal_id or goal_id in {".", ".."} or "/" in goal_id or "\\" in goal_id: - return _base_evidence( - goal_id=goal_id, - outcome="failed", - reason_code="invalid_shadow_goal_id", - ) - try: - registry = load_registry(registry_path) - config = _shadow_config(registry, goal_id) - except Exception: - return _base_evidence( - goal_id=goal_id, - outcome="failed", - reason_code="invalid_shadow_config", - ) - if config is None: - return None - - try: - if runtime_root is None: - runtime_root = resolve_runtime_root( - registry, - None, - registry_path=registry_path, - ) - # Candidate-provider bytes live outside the legacy per-goal runtime - # tree. State migration may copy that tree, but it must never copy a - # store identity or revision and accidentally create a second lineage. - shadow_root = runtime_root / "authority-shadow" / "file" / goal_id - with exclusive_file_lock( - shadow_root / "observation", - timeout_seconds=1.0, - operation="local_authority_shadow_observe", - ): - result: dict[str, Any] | None = None - for _attempt in range(_CONFLICT_RETRY_ATTEMPTS): - projection = _stable_projection( - registry_path=registry_path, - runtime_root=runtime_root, - goal_id=goal_id, - ) - source_digest = ( - "sha256:" + hashlib.sha256(_canonical(projection)).hexdigest() - ) - observation_id = ( - "local-shadow:" - + hashlib.sha256( - _canonical( - { - "goal_id": goal_id, - "observation_trigger": observation_trigger, - "source_digest": source_digest, - } - ) - ).hexdigest() - ) - raw_result = effect_runtime_result( - "coordination.local_authority_shadow.record", - { - "schema_version": LOCAL_AUTHORITY_SHADOW_REQUEST_SCHEMA, - "mode": config["mode"], - "runtime_root": str(runtime_root), - "goal_id": goal_id, - "observation_id": observation_id, - "observation_trigger": observation_trigger, - "source_digest": source_digest, - "source_projection": projection, - }, - timeout=15.0, - ) - if not _valid_evidence( - raw_result, - goal_id=goal_id, - observation_id=observation_id, - source_digest=source_digest, - ): - return _base_evidence( - goal_id=goal_id, - outcome="failed", - reason_code="shadow_observation_result_invalid", - ) - result = dict(raw_result) - if result["outcome"] != "conflict_retry_required": - return result - if result is not None: - return result - except LockAcquireTimeoutError: - return _base_evidence( - goal_id=goal_id, - outcome="unavailable", - reason_code="shadow_observation_lock_timeout", - ) - except Exception: - return _base_evidence( - goal_id=goal_id, - outcome="failed", - reason_code="shadow_observation_failed", - ) - return _base_evidence( - goal_id=goal_id, - outcome="failed", - reason_code="shadow_observation_failed", - ) - - -def observe_todo_local_authority_commit( - payload: dict[str, Any], - registry_path: Path, - goal_id: str, - write_class: str, - *, - runtime_root: Path | None = None, -) -> dict[str, Any]: - """Attach post-commit shadow evidence without changing the Todo verdict. - - ``runtime_root`` is the effective root the writer resolved for this call; - ``None`` falls back to the registry root exactly as the other hooks do. - """ - - changed = any( - payload.get(field) - for field in ("changed", "added", "metadata_updated", "completed", "superseded") - ) - if payload.get("dry_run") or not changed: - return payload - todo_id = str(payload.get("todo_id") or "none") - updated_at = str(payload.get("updated_at") or "unknown") - evidence = observe_local_authority_commit( - registry_path=registry_path, - runtime_root=runtime_root, - goal_id=goal_id, - observation_trigger=f"{write_class}:{todo_id}:{updated_at}", - ) - if evidence is not None: - payload["authority_shadow"] = evidence - return payload - - -__all__ = [ - "LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA", - "LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA", - "apply_local_authority_shadow_change", - "effective_runtime_root", - "local_authority_shadow_summary", - "observe_local_authority_commit", - "observe_todo_local_authority_commit", - "validate_local_authority_shadow_change", -] - - # --------------------------------------------------------------------------- # Transaction-bound outbox drain (Stage 2C second half plumbing). # @@ -528,7 +74,9 @@ def observe_todo_local_authority_commit( # behind instead of guessing. # --------------------------------------------------------------------------- -LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA_V1 = LOCAL_AUTHORITY_SHADOW_TRANSACTION_EVIDENCE_SCHEMA +LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA_V1 = ( + LOCAL_AUTHORITY_SHADOW_TRANSACTION_EVIDENCE_SCHEMA +) INLINE_DRAIN_MAX_ENTRIES = 16 INLINE_DRAIN_BUDGET_SECONDS = 2.0 INLINE_DRAIN_LOCK_TIMEOUT_SECONDS = 0.25 @@ -546,7 +94,14 @@ def observe_todo_local_authority_commit( } _SETTLED_OUTCOMES = {"delivered", "replayed", "ambiguous_reconciled"} _SEED_WRITE_CLASSES = {"seed", "reseed_after_crash_gap"} -_ENTRY_SOURCE_FIELDS = ("kind", "previous_bytes_digest", "bytes_digest", "lease", "event_id") +_ENTRY_SOURCE_FIELDS = ( + "kind", + "previous_bytes_digest", + "previous_partition_digest", + "bytes_digest", + "lease", + "event_id", +) _EVIDENCE_V1_OUTCOMES = { "delivered", "replayed", @@ -593,7 +148,9 @@ class DrainResult: @property def ok(self) -> bool: - return self.outcome in {"drained", "nothing_pending"} and self.stopped_at is None + return ( + self.outcome in {"drained", "nothing_pending"} and self.stopped_at is None + ) @property def drained_count(self) -> int: @@ -646,7 +203,9 @@ def primary_lock_is_free(target: Path) -> bool: """Probe a partition's Python primary lock once without waiting.""" try: - with try_exclusive_file_lock(target, operation="local_authority_shadow_drain_probe") as held: + with try_exclusive_file_lock( + target, operation="local_authority_shadow_drain_probe" + ) as held: return held is not None except OSError: return False @@ -704,43 +263,13 @@ def _event_present(sources: _GoalSources, event_id: str) -> bool: return False -def _lease_record(sources: _GoalSources, todo_id: str) -> dict[str, Any] | None: - if not todo_id: - return None - path = sources.lease_dir / f"{todo_id}.json" - if not path.exists(): +def _lease_bytes(sources: _GoalSources, todo_id: str) -> bytes | None: + if not todo_id or "/" in todo_id or "\\" in todo_id or todo_id in {".", ".."}: + raise outbox.OutboxError("outbox_file_invalid", "invalid lease source identity") + try: + return (sources.lease_dir / f"{todo_id}.json").read_bytes() + except FileNotFoundError: return None - raw = json.loads(path.read_text(encoding="utf-8")) - return raw if isinstance(raw, dict) else None - - -def _todo_partition_seed( - *, - registry_path: Path, - runtime_root: Path, - goal_id: str, - sources: _GoalSources, -) -> outbox.SeedSource: - """Full todos-partition snapshot; caller holds the state-file lock.""" - - from ...control_plane.todos.handoff_mode import goal_handoff_mode - from ...todos import list_goal_todos - - state_text = _read_state_text(sources.state_path) - payload = list_goal_todos( - registry_path=registry_path, - goal_id=goal_id, - runtime_root_arg=str(runtime_root), - ) - projection = todo_partition_projection( - handoff_mode=goal_handoff_mode(state_text), - todos=payload.get("todos") or [], - ) - return outbox.SeedSource( - partition=TODO_PARTITION, - projection=projection, - source_bytes_digest=text_digest(state_text), - ) @contextmanager @@ -754,11 +283,26 @@ def _primary_lock_if_free( """Hold the partition's primary lock only if it is free right now.""" if partition == TODO_PARTITION: - with try_exclusive_file_lock( - sources.state_path, - operation="local_authority_shadow_drain_resolve", - ) as held: - yield held is not None + from .legacy_writer_fence import legacy_coordination_todo_lock_path + + try: + with ( + exclusive_cross_runtime_file_lock( + legacy_coordination_todo_lock_path( + runtime_root=runtime_root, goal_id=goal_id + ), + timeout_seconds=0.0, + operation="local_authority_shadow_drain_resolve", + ), + exclusive_cross_runtime_file_lock( + sources.state_path, + timeout_seconds=0.0, + operation="local_authority_shadow_drain_resolve", + ), + ): + yield True + except LockAcquireTimeoutError: + yield False return from ..work_items.task_lease import task_lease_lock_path @@ -807,8 +351,16 @@ def _commit_entry_request( projection: dict[str, Any] | None, digest: str | None, ) -> dict[str, Any]: - raw_source = entry.prepared.get("source") if isinstance(entry.prepared.get("source"), dict) else {} - writer = entry.prepared.get("writer") if isinstance(entry.prepared.get("writer"), dict) else {} + raw_source = ( + entry.prepared.get("source") + if isinstance(entry.prepared.get("source"), dict) + else {} + ) + writer = ( + entry.prepared.get("writer") + if isinstance(entry.prepared.get("writer"), dict) + else {} + ) committed_at = entry.committed.get("committed_at") if entry.committed else None return { "schema_version": LOCAL_AUTHORITY_SHADOW_COMMIT_ENTRY_REQUEST_SCHEMA, @@ -825,6 +377,15 @@ def _commit_entry_request( }, "source": {key: raw_source.get(key) for key in _ENTRY_SOURCE_FIELDS}, "source_root_digest": entry.prepared.get("source_root_digest"), + "capture_lineage_id": entry.prepared.get("capture_lineage_id"), + "prepared_sha256": outbox.raw_bytes_digest( + entry.prepared_path.read_bytes() + ), + "committed_sha256": outbox.raw_bytes_digest( + entry.committed_path.read_bytes() + ) + if entry.committed_path + else None, "prepared_at": entry.prepared.get("prepared_at"), "committed_at": committed_at, "resolution": resolution, @@ -838,7 +399,8 @@ def _valid_commit_entry_result(result: object, entry: outbox.OutboxEntry) -> boo if not isinstance(result, dict): return False return ( - result.get("schema_version") == LOCAL_AUTHORITY_SHADOW_COMMIT_ENTRY_RESULT_SCHEMA + result.get("schema_version") + == LOCAL_AUTHORITY_SHADOW_COMMIT_ENTRY_RESULT_SCHEMA and result.get("outcome") in _COMMIT_ENTRY_OUTCOMES and result.get("entry_id") == entry.entry_id and result.get("partition") == entry.partition @@ -854,6 +416,7 @@ def read_local_authority_shadow( store_kind: str = "runtime_shadow", scan_after_cursor: str | None = None, scan_limit: int = 0, + receipt_operation_id: str | None = None, ) -> dict[str, Any]: """Read-only candidate view through the TypeScript store boundary.""" @@ -866,6 +429,7 @@ def read_local_authority_shadow( "store_kind": store_kind, "scan_after_cursor": scan_after_cursor, "scan_limit": scan_limit, + "receipt_operation_id": receipt_operation_id, }, timeout=15.0, ) @@ -887,9 +451,19 @@ def __init__(self, *, max_entries: int, budget_seconds: float) -> None: def exhausted(self) -> bool: return self.consumed >= self._max_entries or time.monotonic() >= self._deadline + @property + def remaining_entries(self) -> int: + return max(0, self._max_entries - self.consumed) + + def can_reclaim(self, count: int) -> bool: + return ( + self.consumed + count <= self._max_entries + and time.monotonic() < self._deadline + ) + class _PartitionDrainer: - """Drain one partition in sequence order; all state lives on ``result``.""" + """Prove under M, release for the TS transaction, then reacquire before cleanup.""" def __init__( self, @@ -901,48 +475,269 @@ def __init__( sources: _GoalSources, result: DrainResult, budget: _DrainBudget, + lock_timeout_seconds: float, + capture_lineage_id: str, ) -> None: - self._registry_path = registry_path self._runtime_root = runtime_root self._goal_id = goal_id self._partition = partition self._sources = sources self._result = result self._budget = budget + self._lock_timeout = lock_timeout_seconds self._directory = outbox.partition_directory(runtime_root, goal_id, partition) + self._lineage: str | None = capture_lineage_id self.last_delivered_digest: str | None = None - def run(self) -> None: - # Files the cursor already covers are settled; reclaim them first so a - # crash between the cursor write and the unlinks can never wedge the - # partition. - self._result.reclaimed_residue += outbox.reclaim_retired_residue(self._directory) - while not self._budget.exhausted(): - entries = outbox.list_entries(self._directory) - if not entries: - return - entry = entries[0] - if entry.is_committed: - settled = self._deliver_committed(entry) - else: - settled = self._resolve_prepared_only(entry) - if not settled: - return - if outbox.list_entries(self._directory): - self._result.budget_exhausted = True + def _lock(self) -> Any: + return exclusive_cross_runtime_file_lock( + outbox.drain_lock_target(self._runtime_root, self._goal_id), + timeout_seconds=self._lock_timeout, + operation="local_authority_shadow_drain", + ) - def _deliver_committed(self, entry: outbox.OutboxEntry) -> bool: - writer = entry.prepared.get("writer") if isinstance(entry.prepared.get("writer"), dict) else {} - resolution = "seed" if writer.get("write_class") in _SEED_WRITE_CLASSES else "committed" - projection, digest = _entry_projection(entry, goal_id=self._goal_id) - if projection is None: + def _binding(self) -> dict[str, Any]: + view = read_shadow_capture_binding(self._runtime_root, self._goal_id) + if view["status"] != "active": + raise outbox.OutboxError( + str(view.get("reason_code") or "bootstrap_required"), + "shadow capture has no active binding", + ) + binding = dict(view["binding"]) + lineage = str(binding["capture_lineage_id"]) + if self._lineage is not None and self._lineage != lineage: raise outbox.OutboxError( - "outbox_file_invalid", - f"committed entry {entry.entry_id} has no partition projection", + "stale_generation", "drain belongs to an earlier lineage" ) - return self._commit(entry, resolution=resolution, projection=projection, digest=digest) + self._lineage = lineage + return binding - def _resolve_prepared_only(self, entry: outbox.OutboxEntry) -> bool: + def _proof(self) -> tuple[dict[str, Any], list[dict[str, Any]]]: + binding = self._binding() + # Parse before consulting the candidate: malformed cursor bytes are evidence. + outbox.read_cursor(self._directory) + view = read_local_authority_shadow( + runtime_root=self._runtime_root, + goal_id=self._goal_id, + scan_limit=10_000, + ) + proof = view.get("proof") + if view.get("status") != "loaded" or not isinstance(proof, dict): + raise outbox.OutboxError( + str(view.get("reason_code") or "outbox_receipt_unproved"), + "candidate history is not proved", + ) + transactions = proof.get("transactions") + if ( + proof.get("capture_lineage_id") != self._lineage + or view.get("store_identity") != binding["store_identity"] + or not isinstance(transactions, list) + or not transactions + or not all(isinstance(tx, dict) for tx in transactions) + or transactions[-1].get("cursor") != view.get("cursor") + or transactions[-1].get("provider_revision") + != view.get("provider_revision") + ): + raise outbox.OutboxError( + "outbox_receipt_unproved", "incomplete or foreign history proof" + ) + return view, transactions + + @staticmethod + def _receipt(transaction: dict[str, Any]) -> dict[str, Any] | None: + receipts = transaction.get("receipts") + if ( + isinstance(receipts, list) + and len(receipts) == 1 + and isinstance(receipts[0], dict) + ): + return receipts[0] + return None + + def _partition_history( + self, transactions: list[dict[str, Any]] + ) -> dict[int, dict[str, Any]]: + history: dict[int, dict[str, Any]] = {} + for transaction in transactions: + receipt = self._receipt(transaction) + if receipt is None or receipt.get("partition") != self._partition: + continue + seq = receipt.get("seq") + if ( + type(seq) is not int + or seq != len(history) + 1 + or receipt.get("capture_lineage_id") != self._lineage + or receipt.get("source_root_digest") + != outbox.runtime_root_digest(self._runtime_root) + or receipt.get("entry_id") != transaction.get("operation_id") + ): + raise outbox.OutboxError( + "outbox_receipt_unproved", "partition history is not continuous" + ) + history[seq] = transaction + return history + + def _check_file( + self, entry: outbox.OutboxEntry, transaction: dict[str, Any] + ) -> list[tuple[Path, str]]: + receipt = self._receipt(transaction) + if ( + receipt is None + or receipt.get("entry_id") != entry.entry_id + or receipt.get("seq") != entry.seq + or receipt.get("partition") != entry.partition + or receipt.get("capture_lineage_id") != self._lineage + ): + raise outbox.OutboxError( + "outbox_receipt_mismatch", "entry does not match its receipt" + ) + files: list[tuple[Path, str]] = [] + for path, key in ( + (entry.prepared_path, "prepared_sha256"), + (entry.committed_path, "committed_sha256"), + ): + if path is None or not path.exists(): + continue + expected = receipt.get(key) + if ( + not isinstance(expected, str) + or outbox.raw_bytes_digest(path.read_bytes()) != expected + ): + raise outbox.OutboxError( + "outbox_receipt_mismatch", "outbox bytes differ from the receipt" + ) + files.append((path, expected)) + return files + + def _reconcile( + self, + transactions: list[dict[str, Any]], + *, + delivered_entry_id: str | None = None, + ) -> list[outbox.OutboxEntry] | None: + history = self._partition_history(transactions) + cursor = outbox.read_cursor(self._directory) + if cursor is not None: + anchor = history.get(cursor["last_seq"]) + if ( + anchor is None + or anchor.get("operation_id") != cursor["last_entry_id"] + or anchor.get("cursor") != cursor["last_cursor"] + or anchor.get("provider_revision") != cursor["last_provider_revision"] + or partition_digest(self._projection(anchor)) + != cursor["last_partition_digest"] + ): + raise outbox.OutboxError( + "outbox_cursor_unproved", "cursor has no exact history anchor" + ) + entries = outbox.list_entries(self._directory, allow_committed_only=True) + verified: dict[str, list[tuple[Path, str]]] = {} + for entry in entries: + transaction = history.get(entry.seq) + if transaction is not None: + verified[entry.entry_id] = self._check_file(entry, transaction) + elif not entry.prepared: + raise outbox.OutboxError( + "outbox_file_invalid", "unproved committed-only residue" + ) + elif entry.prepared.get("capture_lineage_id") != self._lineage: + raise outbox.OutboxError( + "stale_generation", "outbox entry belongs to another lineage" + ) + recovered = [ + entry + for entry in entries + if entry.seq in history and entry.entry_id != delivered_entry_id + ] + # Prove every residue first, then reclaim a bounded prefix. Repeated + # small-budget recovery must make progress without concealing a bad tail. + if not self._budget.can_reclaim(0): + self._result.budget_exhausted = True + return None + if len(recovered) > self._budget.remaining_entries: + self._result.budget_exhausted = True + recovered = recovered[: self._budget.remaining_entries] + selected_ids = {entry.entry_id for entry in recovered} + if delivered_entry_id is not None: + selected_ids.add(delivered_entry_id) + files = [ + item + for entry_id, batch in verified.items() + if entry_id in selected_ids + for item in batch + ] + # No deletion or cursor rewrite until the complete batch has been checked. + if history: + last = history[len(history)] + with _primary_lock_if_free( + self._partition, + runtime_root=self._runtime_root, + goal_id=self._goal_id, + sources=self._sources, + ) as held: + if not held: + raise outbox.OutboxError( + "primary_writer_busy", "primary writer is in flight" + ) + self._binding() + if ( + outbox.read_cursor(self._directory) != cursor + or outbox.list_entries(self._directory, allow_committed_only=True) + != entries + ): + raise outbox.OutboxError( + "outbox_file_changed", "outbox changed during proof" + ) + digest = partition_digest(self._projection(last)) + if cursor is None or cursor["last_seq"] != len(history): + outbox.write_cursor( + self._directory, + partition=self._partition, + last_seq=len(history), + last_entry_id=str(last["operation_id"]), + last_partition_digest=digest, + last_cursor=str(last["cursor"]), + last_provider_revision=str(last["provider_revision"]), + ) + self._result.reclaimed_residue += outbox.reclaim_verified_files(files) + for entry in recovered: + transaction = history[entry.seq] + receipt = self._receipt(transaction) + assert receipt is not None + self._result.entries.append( + { + "entry_id": entry.entry_id, + "partition": entry.partition, + "seq": entry.seq, + "resolution": receipt["resolution"], + "outcome": "replayed", + "reason_code": "verified_receipt_recovery", + "cursor": transaction["cursor"], + "provider_revision": transaction["provider_revision"], + "partition_digest": receipt["partition_digest"], + } + ) + self._result.replayed += 1 + self._result.no_op += int(receipt["no_op"]) + self._budget.consumed += 1 + return [entry for entry in entries if entry.seq not in history] + + def _projection(self, transaction: dict[str, Any]) -> dict[str, Any]: + head = transaction["projection"] + if self._partition == TODO_PARTITION: + return {"handoff_mode": head["handoff_mode"], "todos": head["todos"]} + return {"leases": head["leases"]} + + def _resolve( + self, entry: outbox.OutboxEntry, pending: list[outbox.OutboxEntry] + ) -> tuple[str, dict[str, Any] | None, str | None]: + if entry.is_committed: + projection, digest = _entry_projection(entry, goal_id=self._goal_id) + if projection is None: + raise outbox.OutboxError( + "outbox_file_invalid", "committed entry has no projection" + ) + return "committed", projection, digest with _primary_lock_if_free( self._partition, runtime_root=self._runtime_root, @@ -950,178 +745,133 @@ def _resolve_prepared_only(self, entry: outbox.OutboxEntry) -> bool: sources=self._sources, ) as held: if not held: - if self._partition not in self._result.in_flight_partitions: - self._result.in_flight_partitions.append(self._partition) - return False + raise outbox.OutboxError( + "primary_writer_busy", "prepared writer is in flight" + ) + # Current A cannot prove an earlier A->B was abandoned after B->A. + if any(other.seq > entry.seq for other in pending): + raise outbox.OutboxError( + "outbox_source_unproved", + "later writes make source recovery ambiguous", + ) resolution = outbox.resolve_prepared_only_entry( entry, markdown_text_reader=lambda: _read_state_text(self._sources.state_path), - lease_record_reader=lambda todo_id: _lease_record(self._sources, todo_id), - event_presence_reader=lambda event_id: _event_present(self._sources, event_id), + lease_bytes_reader=lambda todo_id: _lease_bytes(self._sources, todo_id), + event_presence_reader=lambda event_id: _event_present( + self._sources, event_id + ), ) - projection: dict[str, Any] | None = None - digest: str | None = None - if resolution == "committed": - projection, digest = _entry_projection(entry, goal_id=self._goal_id) - if projection is None: - resolution = "unproved" - else: - resolution = "committed_proven_by_readback" - if resolution == "unproved": - # The source moved in a way no recorded entry explains; a full - # partition snapshot under the same lock closes the gap. - seed = ( - _todo_partition_seed( - registry_path=self._registry_path, - runtime_root=self._runtime_root, - goal_id=self._goal_id, - sources=self._sources, - ) - if self._partition == TODO_PARTITION - else outbox.lease_seed_source(self._runtime_root, self._goal_id) + if resolution == "abandoned": + return resolution, None, None + if resolution != "committed": + raise outbox.OutboxError( + "outbox_source_unproved", "prepared source cannot be proved" ) - outbox.write_seed_entry( + projection, digest = _entry_projection(entry, goal_id=self._goal_id) + if projection is None: + raise outbox.OutboxError( + "outbox_source_unproved", "prepared source has no projection" + ) + return "committed_proven_by_readback", projection, digest + + def _record_view(self, view: dict[str, Any]) -> None: + self._result.candidate_readback_verified = True + self._result.store_identity = view.get("store_identity") + self._result.provider_revision = view.get("provider_revision") + self._result.last_cursor = view.get("cursor") + self._result.cursor_after = view.get("cursor") + self._result.head_digest = view.get("head_digest") + + def run(self) -> None: + while not self._budget.exhausted(): + with self._lock(): + view, transactions = self._proof() + if self._result.cursor_before is None: + self._result.cursor_before = view.get("cursor") + pending = self._reconcile(transactions) + self._record_view(view) + if not pending: + return + if self._budget.exhausted(): + self._result.budget_exhausted = True + return + entry = pending[0] + resolution, projection, digest = self._resolve(entry, pending) + if entry.seq != len(self._partition_history(transactions)) + 1: + raise outbox.OutboxError( + "outbox_sequence_gap", "pending sequence is not continuous" + ) + request = _commit_entry_request( runtime_root=self._runtime_root, goal_id=self._goal_id, - seed=seed, - write_class="reseed_after_crash_gap", + entry=entry, + resolution=resolution, + projection=projection, + digest=digest, ) - self._result.reseeded += 1 - return self._commit(entry, resolution=resolution, projection=projection, digest=digest) - - def _commit( - self, - entry: outbox.OutboxEntry, - *, - resolution: str, - projection: dict[str, Any] | None, - digest: str | None, - ) -> bool: - expected_root = outbox.runtime_root_digest(self._runtime_root) - if entry.prepared.get("source_root_digest") != expected_root: - # The entry was written for a different runtime root; delivering it - # here would stitch another lineage's transaction into this one. - raise outbox.OutboxError( - "source_root_mismatch", - f"entry {entry.entry_id} was recorded for a different runtime root", + # TS owns M for every public commit, including retries. Never re-enter M across RPC. + raw = effect_runtime_result( + "coordination.runtime_shadow.commit_entry", request, timeout=15.0 ) - request = _commit_entry_request( - runtime_root=self._runtime_root, - goal_id=self._goal_id, - entry=entry, - resolution=resolution, - projection=projection, - digest=digest, - ) - raw = effect_runtime_result( - "coordination.runtime_shadow.commit_entry", - request, - timeout=15.0, - ) - self._budget.consumed += 1 - if not _valid_commit_entry_result(raw, entry): - self._result.stopped_at = { - "partition": entry.partition, - "seq": entry.seq, + self._budget.consumed += 1 + if not _valid_commit_entry_result(raw, entry): + raise outbox.OutboxError( + "shadow_commit_entry_result_invalid", "invalid commit result" + ) + if raw["outcome"] not in _SETTLED_OUTCOMES: + self._result.stopped_at = { + "partition": entry.partition, + "seq": entry.seq, + "entry_id": entry.entry_id, + "outcome": raw["outcome"], + "reason_code": raw.get("reason_code"), + } + return + with self._lock(): + view, transactions = self._proof() + history = self._partition_history(transactions) + transaction = history.get(entry.seq) + if ( + transaction is None + or transaction.get("operation_id") != entry.entry_id + or transaction.get("cursor") != raw.get("cursor") + or transaction.get("provider_revision") + != raw.get("provider_revision") + or view.get("store_identity") != raw.get("store_identity") + or self._receipt(transaction).get("no_op") != raw.get("no_op") + or self._receipt(transaction).get("partition_digest") != digest + ): + raise outbox.OutboxError( + "shadow_commit_entry_result_invalid", + "ACK differs from exact receipt", + ) + self._reconcile(transactions, delivered_entry_id=entry.entry_id) + self._record_view(view) + summary = { "entry_id": entry.entry_id, - "outcome": "failed", - "reason_code": "shadow_commit_entry_result_invalid", - } - return False - result = dict(raw) - summary = { - "entry_id": entry.entry_id, - "partition": entry.partition, - "seq": entry.seq, - "resolution": resolution, - "outcome": result["outcome"], - "reason_code": result.get("reason_code"), - "cursor": result.get("cursor"), - "provider_revision": result.get("provider_revision"), - "partition_digest": digest, - } - self._result.entries.append(summary) - if result.get("store_identity"): - self._result.store_identity = str(result["store_identity"]) - if result["outcome"] not in _SETTLED_OUTCOMES: - self._result.stopped_at = { "partition": entry.partition, "seq": entry.seq, - "entry_id": entry.entry_id, - "outcome": result["outcome"], - "reason_code": result.get("reason_code"), + "resolution": resolution, + "outcome": raw["outcome"], + "reason_code": raw.get("reason_code"), + "cursor": raw.get("cursor"), + "provider_revision": raw.get("provider_revision"), + "partition_digest": digest, } - return False - previous = outbox.read_cursor(self._directory) - cursor_digest = digest - if cursor_digest is None and previous is not None: - cursor_digest = previous.get("last_partition_digest") - outbox.write_cursor( - self._directory, - partition=entry.partition, - last_seq=entry.seq, - last_entry_id=entry.entry_id, - last_partition_digest=cursor_digest, - last_cursor=result.get("cursor"), - last_provider_revision=result.get("provider_revision"), - ) - outbox.remove_entry_files(entry) - if result["outcome"] == "delivered": - self._result.delivered += 1 - elif result["outcome"] == "replayed": - self._result.replayed += 1 - else: - self._result.reconciled += 1 - if result["no_op"]: - self._result.no_op += 1 - elif digest is not None: - self.last_delivered_digest = digest - if result.get("cursor"): - self._result.last_cursor = str(result["cursor"]) - if result.get("provider_revision"): - self._result.provider_revision = str(result["provider_revision"]) - return True - - -def _candidate_cursor(runtime_root: Path, goal_id: str) -> str | None: - """Current candidate cursor, or None when the store has no document yet.""" - - directory = runtime_root / "authority-shadow" / "file-v0" - if not directory.is_dir() or not any(directory.glob("authority-store-*.json")): - return None - try: - view = read_local_authority_shadow(runtime_root=runtime_root, goal_id=goal_id) - except Exception: - return None - cursor = view.get("cursor") - return str(cursor) if isinstance(cursor, str) else None - - -def _verify_readback( - result: DrainResult, - *, - runtime_root: Path, - goal_id: str, - delivered_digests: dict[str, str], -) -> None: - try: - view = read_local_authority_shadow(runtime_root=runtime_root, goal_id=goal_id) - except Exception: - result.candidate_readback_verified = False - return - head = view.get("head") - if view.get("status") != "loaded" or not isinstance(head, dict): - result.candidate_readback_verified = False - return - result.store_identity = view.get("store_identity") or result.store_identity - result.head_digest = view.get("head_digest") - result.cursor_after = view.get("cursor") - verified = head_digest(head) == view.get("head_digest") - partitions = head.get("partitions") if isinstance(head.get("partitions"), dict) else {} - for partition, digest in delivered_digests.items(): - marker = partitions.get(partition) if isinstance(partitions, dict) else None - verified = verified and isinstance(marker, dict) and marker.get("partition_digest") == digest - result.candidate_readback_verified = verified + self._result.entries.append(summary) + if raw["outcome"] == "delivered": + self._result.delivered += 1 + elif raw["outcome"] == "replayed": + self._result.replayed += 1 + else: + self._result.reconciled += 1 + if raw["no_op"]: + self._result.no_op += 1 + elif digest is not None: + self.last_delivered_digest = digest + if outbox.list_entries(self._directory, allow_committed_only=True): + self._result.budget_exhausted = True def _drain_prelude( @@ -1143,7 +893,7 @@ def _drain_prelude( resolve_coordination_runtime_shadow_config( find_registry_goal(registry, goal_id) ).enabled - or _shadow_config(registry, goal_id) is not None + or local_authority_shadow_summary(find_registry_goal(registry, goal_id) or {})["enabled"] is True ) resolved = ( runtime_root @@ -1157,16 +907,6 @@ def _drain_prelude( return registry, resolved -def _outbox_is_idle(summary: Mapping[str, Mapping[str, Any]]) -> bool: - return all( - item["committed_pending"] == 0 - and item["prepared_only"] == 0 - and item["retired_residue"] == 0 - and item["invalid"] is None - for item in summary.values() - ) - - def _drain_partitions( result: DrainResult, *, @@ -1176,13 +916,19 @@ def _drain_partitions( goal_id: str, max_entries: int, budget_seconds: float, + lock_timeout_seconds: float, ) -> None: - """Drain every partition in order under the held drain lock, then read back.""" + """Drain partitions through the shared management lock and TS commit owner.""" sources = _goal_sources(registry, runtime_root=runtime_root, goal_id=goal_id) - result.cursor_before = _candidate_cursor(runtime_root, goal_id) + binding_view = read_shadow_capture_binding(runtime_root, goal_id) + if binding_view["status"] != "active": + raise outbox.OutboxError( + str(binding_view.get("reason_code") or "bootstrap_required"), + "drain requires an active capture lineage", + ) + capture_lineage_id = str(binding_view["binding"]["capture_lineage_id"]) budget = _DrainBudget(max_entries=max_entries, budget_seconds=budget_seconds) - delivered_digests: dict[str, str] = {} for partition in PARTITIONS: if result.stopped_at is not None: break @@ -1194,31 +940,34 @@ def _drain_partitions( sources=sources, result=result, budget=budget, + lock_timeout_seconds=lock_timeout_seconds, + capture_lineage_id=capture_lineage_id, ) drainer.run() - if drainer.last_delivered_digest is not None: - delivered_digests[partition] = drainer.last_delivered_digest - if delivered_digests or result.drained_count: - _verify_readback( - result, - runtime_root=runtime_root, - goal_id=goal_id, - delivered_digests=delivered_digests, - ) def _settle_drain_outcome(result: DrainResult) -> None: if result.stopped_at is not None: result.outcome = "stopped" - result.reason_code = str(result.stopped_at.get("reason_code") or result.stopped_at["outcome"]) + result.reason_code = str( + result.stopped_at.get("reason_code") or result.stopped_at["outcome"] + ) else: - result.outcome = "drained" + result.outcome = ( + "drained" + if result.drained_count or result.budget_exhausted + else "nothing_pending" + ) def _count_backlog(result: DrainResult, runtime_root: Path, goal_id: str) -> None: summary_after = outbox.outbox_summary(runtime_root, goal_id) - result.pending_after = sum(int(item["committed_pending"]) for item in summary_after.values()) - result.prepared_only_after = sum(int(item["prepared_only"]) for item in summary_after.values()) + result.pending_after = sum( + int(item["committed_pending"]) for item in summary_after.values() + ) + result.prepared_only_after = sum( + int(item["prepared_only"]) for item in summary_after.values() + ) def drain_local_authority_shadow_outbox( @@ -1243,24 +992,29 @@ def drain_local_authority_shadow_outbox( if prelude is None: return result registry, resolved_root = prelude - if _outbox_is_idle(outbox.outbox_summary(resolved_root, goal_id)): - result.outcome = "nothing_pending" + binding = read_shadow_capture_binding(resolved_root, goal_id) + if binding["status"] != "active": + runtime_enabled = resolve_coordination_runtime_shadow_config(find_registry_goal(registry, goal_id)).enabled + requires_bootstrap = (runtime_enabled or binding["status"] in {"inactive", "hold"} + or outbox.outbox_root(resolved_root, goal_id).exists()) + result.outcome = "stopped" if requires_bootstrap else "nothing_pending" + result.reason_code = ( + str(binding.get("reason_code") or "bootstrap_required") + if result.outcome == "stopped" + else None + ) return result try: - with exclusive_file_lock( - outbox.drain_lock_target(resolved_root, goal_id), - timeout_seconds=lock_timeout_seconds, - operation="local_authority_shadow_drain", - ): - _drain_partitions( - result, - registry=registry, - registry_path=registry_path, - runtime_root=resolved_root, - goal_id=goal_id, - max_entries=max_entries, - budget_seconds=budget_seconds, - ) + _drain_partitions( + result, + registry=registry, + registry_path=registry_path, + runtime_root=resolved_root, + goal_id=goal_id, + max_entries=max_entries, + budget_seconds=budget_seconds, + lock_timeout_seconds=lock_timeout_seconds, + ) except LockAcquireTimeoutError: result.outcome = "drain_deferred" result.reason_code = "drain_lock_busy" @@ -1272,7 +1026,11 @@ def drain_local_authority_shadow_outbox( result.reason_code = "shadow_drain_failed" else: _settle_drain_outcome(result) - _count_backlog(result, resolved_root, goal_id) + try: + _count_backlog(result, resolved_root, goal_id) + except Exception: + result.outcome = "stopped" + result.reason_code = result.reason_code or "outbox_status_unavailable" return result @@ -1306,7 +1064,13 @@ def local_authority_shadow_status( if runtime_root is None: runtime_root = resolve_runtime_root(registry, None, registry_path=registry_path) config = local_authority_shadow_summary(goal) - legacy_observation = config["enabled"] is True + runtime_config = resolve_coordination_runtime_shadow_config(goal) + management = read_shadow_capture_binding(runtime_root, goal_id) + legacy_observation = ( + config["enabled"] is True + and not runtime_config.enabled + and management["status"] == "missing" + ) backlog = outbox.outbox_summary(runtime_root, goal_id) candidate: dict[str, Any] try: @@ -1322,7 +1086,9 @@ def local_authority_shadow_status( view = read_local_authority_shadow( runtime_root=runtime_root, goal_id=goal_id, - store_kind=("legacy_observation" if legacy_observation else "runtime_shadow"), + store_kind=( + "legacy_observation" if legacy_observation else "runtime_shadow" + ), ) head = view.get("head") if isinstance(view.get("head"), dict) else None candidate = { @@ -1334,7 +1100,9 @@ def local_authority_shadow_status( "head_digest": view.get("head_digest"), "head_schema_version": head.get("schema_version") if head else None, "partitions": view.get("partitions"), - "codec_agreement": (head_digest(head) == view.get("head_digest")) if head else None, + "codec_agreement": (head_digest(head) == view.get("head_digest")) + if head + else None, } except _CandidateMissing: candidate = { @@ -1360,21 +1128,31 @@ def local_authority_shadow_status( "partitions": None, "codec_agreement": None, } - store_bytes = _store_bytes( - runtime_root, - goal_id, - legacy_observation=legacy_observation, - ) + try: + store_bytes = _store_bytes( + runtime_root, goal_id, legacy_observation=legacy_observation + ) + storage_error = None + except OSError: + store_bytes = None + storage_error = "shadow_store_unavailable" return { - "ok": all(item["invalid"] is None for item in backlog.values()), + "ok": all(item["invalid"] is None for item in backlog.values()) + and storage_error is None + and management["status"] != "hold", "action": "status", "goal_id": goal_id, "config": config, + "runtime_config": asdict(runtime_config), + "management": management, + "storage_error": storage_error, "runtime_root_digest": outbox.runtime_root_digest(runtime_root), "outbox": backlog, "candidate": candidate, "store_bytes": store_bytes, - "retention_pressure": store_bytes > RETENTION_PRESSURE_BYTES, + "retention_pressure": store_bytes > RETENTION_PRESSURE_BYTES + if store_bytes is not None + else None, } @@ -1471,11 +1249,15 @@ def valid_evidence_v1(result: object, *, goal_id: str) -> bool: and result.get("candidate_read_for_decision") is False and result.get("provider_to_local_writes") is False and result.get("primary_writeback_preserved") is True - and (result.get("reason_code") is None or isinstance(result.get("reason_code"), str)) + and ( + result.get("reason_code") is None + or isinstance(result.get("reason_code"), str) + ) ) -__all__ += [ +__all__ = [ + "effective_runtime_root", "CLI_DRAIN_LOCK_TIMEOUT_SECONDS", "INLINE_DRAIN_BUDGET_SECONDS", "INLINE_DRAIN_LOCK_TIMEOUT_SECONDS", diff --git a/loopx/control_plane/coordination/local_authority_shadow_identity.ts b/loopx/control_plane/coordination/local_authority_shadow_identity.ts new file mode 100644 index 0000000000..85ab02514b --- /dev/null +++ b/loopx/control_plane/coordination/local_authority_shadow_identity.ts @@ -0,0 +1,16 @@ +import { createHash } from "node:crypto"; +import { canonicalAuthorityBytes } from "./authority_store_codec.ts"; + +export const OUTBOX_ENTRY_FILE_PATTERN = /^(\d{10})-(local-shadow-tx-[0-9a-f]{64})\.(prepared|committed)\.json$/u; + +/** Shared entry identity; independent of capture and history readers. */ +export function outboxEntryIdentity( + goalId: string, partition: string, seq: number, sourceRef: string, + captureLineageId: string, sourceRootDigest: string, +): string { + const digest = createHash("sha256").update(canonicalAuthorityBytes({ + goal_id: goalId, partition, seq, source_ref: sourceRef, + capture_lineage_id: captureLineageId, source_root_digest: sourceRootDigest, + })).digest("hex"); + return `local-shadow-tx-${digest}`; +} diff --git a/loopx/control_plane/coordination/local_authority_shadow_observation.py b/loopx/control_plane/coordination/local_authority_shadow_observation.py new file mode 100644 index 0000000000..bae192ad0b --- /dev/null +++ b/loopx/control_plane/coordination/local_authority_shadow_observation.py @@ -0,0 +1,479 @@ +"""Post-commit bridge from legacy local authority into a file shadow. + +The adapter deliberately owns no lifecycle decision. It is entered only after +the existing Markdown or task-lease writer has succeeded, projects public-safe +facts, and asks the TypeScript authority-store boundary to retain an +observation. Missing configuration is a zero-effect fast path. +""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from ...file_lock import ( + LockAcquireTimeoutError, + exclusive_file_lock, +) +from ...history import load_registry +from ...paths import resolve_runtime_root +from ...registry import find_registry_goal +from ..effect_runtime import effect_runtime_result +from .coordination_state_contract_generated import ( + LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA, + LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA, + LOCAL_AUTHORITY_SHADOW_PROJECTION_SCHEMA, + LOCAL_AUTHORITY_SHADOW_REQUEST_SCHEMA, +) + + +_CONFIG_FIELDS = {"schema_version", "mode"} +_PROJECTION_ATTEMPTS = 3 +_CONFLICT_RETRY_ATTEMPTS = 3 +_EVIDENCE_OUTCOMES = { + "captured", + "replayed", + "ambiguous_reconciled", + "ambiguous_unproved", + "unavailable", + "failed", + "protocol_mismatch", + "conflict_retry_required", +} +_TODO_FIELDS = ( + "todo_id", + "role", + "status", + "claimed_by", + "bound_agent", + "goal_bound", + "blocks_agent", + "excluded_agents", + "global_gate", + "task_class", + "action_kind", + "required_write_scopes", + "required_capabilities", + "continuation_policy", + "successor_todo_ids", + "no_followup", + "completion_continuation", +) +_LEASE_FIELDS = ( + "todo_id", + "owner", + "idempotency_key", + "write_scopes", + "version", + "lease_epoch", + "acquired_at", + "updated_at", + "expires_at", + "released_at", + "status", +) + + +def local_authority_shadow_summary(goal: Mapping[str, Any]) -> dict[str, Any]: + """Project the closed local-shadow configuration for operator readback.""" + + coordination = ( + goal.get("coordination") + if isinstance(goal.get("coordination"), Mapping) + else {} + ) + raw = coordination.get("authority_shadow") + if raw is None: + return {"enabled": False, "mode": None, "status": "disabled"} + valid = bool( + isinstance(raw, Mapping) + and set(raw) == _CONFIG_FIELDS + and raw.get("schema_version") == LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA + and raw.get("mode") == "file_one_way" + ) + return { + "enabled": valid, + "mode": raw.get("mode") if isinstance(raw, Mapping) else None, + "status": "enabled" if valid else "invalid", + } + + +def validate_local_authority_shadow_change( + enable_file: bool, + clear: bool, +) -> None: + """Reject contradictory CLI intent before reading or mutating the registry.""" + + if enable_file and clear: + raise ValueError( + "--local-authority-shadow-file cannot be combined with " + "--clear-local-authority-shadow" + ) + + +def apply_local_authority_shadow_change( + goal: dict[str, Any], + enable_file: bool, + clear: bool, +) -> None: + """Apply a validated default-off local-shadow configuration change.""" + + if not enable_file and not clear: + return + coordination = ( + goal.get("coordination") if isinstance(goal.get("coordination"), dict) else {} + ) + if clear: + coordination.pop("authority_shadow", None) + else: + coordination["authority_shadow"] = { + "schema_version": LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA, + "mode": "file_one_way", + } + if coordination: + goal["coordination"] = coordination + else: + goal.pop("coordination", None) + + + + +def _base_evidence( + *, + goal_id: str, + outcome: str, + reason_code: str, +) -> dict[str, Any]: + return { + "schema_version": LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA, + "outcome": outcome, + "reason_code": reason_code, + "goal_id": goal_id, + "observation_id": None, + "source_digest": None, + "capture_kind": "post_commit_snapshot", + "source_transaction_correlated": False, + "durable_source_outbox": False, + "source_candidate_compared": False, + "parity_verdict": "not_evaluated", + "primary_authority": "legacy_local", + "candidate_provider": "file", + "candidate_read_for_decision": False, + "provider_to_local_writes": False, + "primary_writeback_preserved": True, + "store_identity": None, + "provider_revision": None, + "cursor": None, + } + + +def _shadow_config(registry: dict[str, Any], goal_id: str) -> dict[str, str] | None: + goal = find_registry_goal(registry, goal_id) + coordination = goal.get("coordination") if isinstance(goal, dict) else None + if not isinstance(coordination, dict) or "authority_shadow" not in coordination: + return None + raw = coordination.get("authority_shadow") + if ( + not isinstance(raw, dict) + or set(raw) != _CONFIG_FIELDS + or raw.get("schema_version") != LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA + or raw.get("mode") != "file_one_way" + ): + raise ValueError("authority_shadow must be a closed file_one_way config") + return {"mode": "file_one_way"} + + +def _canonical(value: object) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + + +def _compact_todo(raw: object) -> dict[str, Any] | None: + if not isinstance(raw, dict): + return None + todo_id = str(raw.get("todo_id") or "").strip() + if not todo_id: + return None + compact = {field: raw[field] for field in _TODO_FIELDS if field in raw} + compact["todo_id"] = todo_id + if "status" not in compact and isinstance(raw.get("done"), bool): + compact["status"] = "done" if raw["done"] else "open" + return json.loads(_canonical(compact)) + + +def _compact_lease(path: Path, *, goal_id: str) -> dict[str, Any]: + raw = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError("task lease must contain an object") + if raw.get("goal_id") != goal_id or raw.get("todo_id") != path.stem: + raise ValueError("task lease identity does not match its shadow source") + return json.loads( + _canonical({field: raw[field] for field in _LEASE_FIELDS if field in raw}) + ) + + +def _source_projection( + *, + registry_path: Path, + runtime_root: Path, + goal_id: str, +) -> dict[str, Any]: + from ...control_plane.todos.handoff_mode import goal_handoff_mode_for_goal + from ...todos import list_goal_todos + + todo_payload = list_goal_todos( + registry_path=registry_path, + goal_id=goal_id, + runtime_root_arg=str(runtime_root), + ) + todos = [ + compact + for raw in todo_payload.get("todos") or [] + if (compact := _compact_todo(raw)) is not None + ] + todos.sort(key=lambda item: str(item["todo_id"])) + lease_dir = runtime_root / "goals" / goal_id / "task-leases" + leases = ( + [ + _compact_lease(path, goal_id=goal_id) + for path in sorted(lease_dir.glob("*.json")) + ] + if lease_dir.exists() + else [] + ) + projection = { + "schema_version": LOCAL_AUTHORITY_SHADOW_PROJECTION_SCHEMA, + "goal_id": goal_id, + "handoff_mode": goal_handoff_mode_for_goal( + registry_path=registry_path, + goal_id=goal_id, + ), + "todos": todos, + "leases": leases, + } + return json.loads(_canonical(projection)) + + +def _stable_projection( + *, + registry_path: Path, + runtime_root: Path, + goal_id: str, +) -> dict[str, Any]: + previous = _source_projection( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + ) + for _attempt in range(_PROJECTION_ATTEMPTS): + current = _source_projection( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + ) + if current == previous: + return current + previous = current + raise RuntimeError("local authority sources did not stabilize for shadowing") + + +def _valid_evidence( + result: object, + *, + goal_id: str, + observation_id: str, + source_digest: str, +) -> bool: + if not isinstance(result, dict): + return False + return ( + result.get("schema_version") == LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA + and result.get("outcome") in _EVIDENCE_OUTCOMES + and result.get("goal_id") == goal_id + and result.get("observation_id") == observation_id + and result.get("source_digest") == source_digest + and result.get("capture_kind") == "post_commit_snapshot" + and result.get("source_transaction_correlated") is False + and result.get("durable_source_outbox") is False + and result.get("source_candidate_compared") is False + and result.get("parity_verdict") == "not_evaluated" + and result.get("primary_authority") == "legacy_local" + and result.get("candidate_provider") == "file" + and result.get("candidate_read_for_decision") is False + and result.get("provider_to_local_writes") is False + and result.get("primary_writeback_preserved") is True + and ( + result.get("reason_code") is None + or isinstance(result.get("reason_code"), str) + ) + ) + + +def observe_local_authority_commit( + *, + registry_path: Path, + runtime_root: Path | None, + goal_id: str, + observation_trigger: str, +) -> dict[str, Any] | None: + """Capture a best-effort post-commit snapshot without changing its verdict. + + ``observation_trigger`` is diagnostic context, not a primary transaction + identity. The snapshot may include commits that landed after that trigger. + """ + + if not goal_id or goal_id in {".", ".."} or "/" in goal_id or "\\" in goal_id: + return _base_evidence( + goal_id=goal_id, + outcome="failed", + reason_code="invalid_shadow_goal_id", + ) + try: + registry = load_registry(registry_path) + config = _shadow_config(registry, goal_id) + except Exception: + return _base_evidence( + goal_id=goal_id, + outcome="failed", + reason_code="invalid_shadow_config", + ) + if config is None: + return None + + try: + if runtime_root is None: + runtime_root = resolve_runtime_root( + registry, + None, + registry_path=registry_path, + ) + # Candidate-provider bytes live outside the legacy per-goal runtime + # tree. State migration may copy that tree, but it must never copy a + # store identity or revision and accidentally create a second lineage. + shadow_root = runtime_root / "authority-shadow" / "file" / goal_id + with exclusive_file_lock( + shadow_root / "observation", + timeout_seconds=1.0, + operation="local_authority_shadow_observe", + ): + result: dict[str, Any] | None = None + for _attempt in range(_CONFLICT_RETRY_ATTEMPTS): + projection = _stable_projection( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + ) + source_digest = ( + "sha256:" + hashlib.sha256(_canonical(projection)).hexdigest() + ) + observation_id = ( + "local-shadow:" + + hashlib.sha256( + _canonical( + { + "goal_id": goal_id, + "observation_trigger": observation_trigger, + "source_digest": source_digest, + } + ) + ).hexdigest() + ) + raw_result = effect_runtime_result( + "coordination.local_authority_shadow.record", + { + "schema_version": LOCAL_AUTHORITY_SHADOW_REQUEST_SCHEMA, + "mode": config["mode"], + "runtime_root": str(runtime_root), + "goal_id": goal_id, + "observation_id": observation_id, + "observation_trigger": observation_trigger, + "source_digest": source_digest, + "source_projection": projection, + }, + timeout=15.0, + ) + if not _valid_evidence( + raw_result, + goal_id=goal_id, + observation_id=observation_id, + source_digest=source_digest, + ): + return _base_evidence( + goal_id=goal_id, + outcome="failed", + reason_code="shadow_observation_result_invalid", + ) + result = dict(raw_result) + if result["outcome"] != "conflict_retry_required": + return result + if result is not None: + return result + except LockAcquireTimeoutError: + return _base_evidence( + goal_id=goal_id, + outcome="unavailable", + reason_code="shadow_observation_lock_timeout", + ) + except Exception: + return _base_evidence( + goal_id=goal_id, + outcome="failed", + reason_code="shadow_observation_failed", + ) + return _base_evidence( + goal_id=goal_id, + outcome="failed", + reason_code="shadow_observation_failed", + ) + + +def observe_todo_local_authority_commit( + payload: dict[str, Any], + registry_path: Path, + goal_id: str, + write_class: str, + *, + runtime_root: Path | None = None, +) -> dict[str, Any]: + """Attach post-commit shadow evidence without changing the Todo verdict. + + ``runtime_root`` is the effective root the writer resolved for this call; + ``None`` falls back to the registry root exactly as the other hooks do. + """ + + changed = any( + payload.get(field) + for field in ("changed", "added", "metadata_updated", "completed", "superseded") + ) + if payload.get("dry_run") or not changed: + return payload + todo_id = str(payload.get("todo_id") or "none") + updated_at = str(payload.get("updated_at") or "unknown") + evidence = observe_local_authority_commit( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + observation_trigger=f"{write_class}:{todo_id}:{updated_at}", + ) + if evidence is not None: + payload["authority_shadow"] = evidence + return payload + + +__all__ = [ + "LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA", + "LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA", + "apply_local_authority_shadow_change", + "local_authority_shadow_summary", + "observe_local_authority_commit", + "observe_todo_local_authority_commit", + "validate_local_authority_shadow_change", +] diff --git a/loopx/control_plane/coordination/local_authority_shadow_outbox.py b/loopx/control_plane/coordination/local_authority_shadow_outbox.py index a67aca878a..620d43ae7d 100644 --- a/loopx/control_plane/coordination/local_authority_shadow_outbox.py +++ b/loopx/control_plane/coordination/local_authority_shadow_outbox.py @@ -8,24 +8,26 @@ lock, or an operator command) turns each committed entry into exactly one candidate-store transaction whose ``operation_id`` is the entry id. -The outbox never changes the primary verdict: every failure here is swallowed -into typed capture evidence and the primary write proceeds unchanged. +Prepare failures return typed evidence for the transaction owner to reject an +active-lineage write before changing primary bytes. Marker failures preserve +prepared evidence; candidate delivery happens after releasing primary locks. """ from __future__ import annotations +import hashlib import json +import math import os import re import uuid from collections.abc import Callable, Iterable, Mapping -from dataclasses import dataclass, field -from datetime import datetime, timezone +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any from .local_authority_shadow_projection import ( - LEASE_PARTITION, PARTITIONS, TODO_PARTITION, ProjectionValueError, @@ -39,6 +41,12 @@ LOCAL_AUTHORITY_SHADOW_DRAIN_CURSOR_SCHEMA, LOCAL_AUTHORITY_SHADOW_OUTBOX_COMMIT_SCHEMA, LOCAL_AUTHORITY_SHADOW_OUTBOX_ENTRY_SCHEMA, + LOCAL_AUTHORITY_SHADOW_READ_REQUEST_SCHEMA, + LOCAL_AUTHORITY_SHADOW_READ_RESULT_SCHEMA, +) +from .shadow_management import ( + read_shadow_capture_binding, + shadow_maintenance_lock_target, ) @@ -79,23 +87,40 @@ def outbox_root(runtime_root: Path, goal_id: str) -> Path: def partition_directory(runtime_root: Path, goal_id: str, partition: str) -> Path: if partition not in PARTITIONS: - raise OutboxError("invalid_partition", f"unknown outbox partition {partition!r}") + raise OutboxError( + "invalid_partition", f"unknown outbox partition {partition!r}" + ) return outbox_root(runtime_root, goal_id) / partition def drain_lock_target(runtime_root: Path, goal_id: str) -> Path: - return outbox_root(runtime_root, goal_id) / "drain" + return shadow_maintenance_lock_target(runtime_root, goal_id) def lease_directory(runtime_root: Path, goal_id: str) -> Path: return runtime_root / "goals" / goal_id / "task-leases" -def entry_identity(*, goal_id: str, partition: str, seq: int, source_ref: str) -> str: +def entry_identity( + *, + goal_id: str, + partition: str, + seq: int, + source_ref: str, + capture_lineage_id: str, + source_root_digest: str, +) -> str: """Bind the entry id to the exact primary bytes (or event) it records.""" return ENTRY_ID_PREFIX + sha256_digest( - {"goal_id": goal_id, "partition": partition, "seq": seq, "source_ref": source_ref} + { + "goal_id": goal_id, + "partition": partition, + "seq": seq, + "source_ref": source_ref, + "capture_lineage_id": capture_lineage_id, + "source_root_digest": source_root_digest, + } ).removeprefix("sha256:") @@ -118,7 +143,9 @@ def durable_write_json(path: Path, payload: Mapping[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) temporary = path.with_name(f"{path.name}.tmp-{os.getpid()}-{uuid.uuid4().hex}") - data = json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=1).encode("utf-8") + data = json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=1).encode( + "utf-8" + ) descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) try: view = memoryview(data) @@ -143,7 +170,18 @@ def _as_object(value: object) -> dict[str, Any]: def _load_json(path: Path) -> dict[str, Any]: - raw = json.loads(path.read_text(encoding="utf-8")) + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (ValueError, UnicodeError) as error: + raise OutboxError( + "outbox_file_invalid", f"{path.name} is not valid JSON" + ) from error + except FileNotFoundError: + raise + except OSError as error: + raise OutboxError( + "outbox_file_unavailable", f"{path.name} cannot be read" + ) from error if not isinstance(raw, dict): raise OutboxError("outbox_file_invalid", f"{path.name} is not a JSON object") return raw @@ -179,7 +217,9 @@ def projection(self) -> dict[str, Any] | None: def recorded_partition_digest(self) -> str | None: for record in (self.committed, self.prepared): - if isinstance(record, dict) and isinstance(record.get("partition_digest"), str): + if isinstance(record, dict) and isinstance( + record.get("partition_digest"), str + ): return str(record["partition_digest"]) return None @@ -187,15 +227,33 @@ def recorded_partition_digest(self) -> str | None: _EntryKey = tuple[int, str] -def _index_entry_files(directory: Path) -> tuple[dict[_EntryKey, Path], dict[_EntryKey, Path]]: +def _index_entry_files( + directory: Path, +) -> tuple[dict[_EntryKey, Path], dict[_EntryKey, Path]]: """Map ``(seq, entry_id)`` to the prepared and committed files present.""" prepared: dict[_EntryKey, Path] = {} committed: dict[_EntryKey, Path] = {} - for path in directory.iterdir(): - match = _ENTRY_FILE.match(path.name) - if match is None: + try: + paths = list(directory.iterdir()) + except FileNotFoundError: + return prepared, committed + except OSError as error: + raise OutboxError( + "outbox_file_unavailable", "outbox inventory cannot be read" + ) from error + for path in paths: + if path.name == "drain-cursor.json" and not path.is_symlink(): continue + match = _ENTRY_FILE.fullmatch(path.name) + if match is None or path.is_symlink() or not path.is_file(): + raise OutboxError( + "outbox_file_invalid", "outbox contains unclassified evidence" + ) + if int(match.group("seq")) < 1: + raise OutboxError( + "outbox_file_invalid", "outbox sequence is outside its range" + ) key = (int(match.group("seq")), match.group("entry_id")) target = prepared if match.group("phase") == "prepared" else committed target[key] = path @@ -229,10 +287,13 @@ def _load_prepared_record( expected_partition = directory.name source_ref = record_source_ref(record) root_digest = record.get("source_root_digest") + lineage_id = record.get("capture_lineage_id") bound = ( record.get("schema_version") == OUTBOX_ENTRY_SCHEMA and record.get("entry_id") == entry_id and record.get("seq") == seq + and type(record.get("seq")) is int + and 1 <= seq <= MAX_OUTBOX_SEQUENCE and record.get("goal_id") == expected_goal and record.get("partition") == expected_partition and writer.get("runtime") in _WRITER_RUNTIMES @@ -241,12 +302,16 @@ def _load_prepared_record( and source.get("kind") in _SOURCE_KINDS and isinstance(root_digest, str) and _DIGEST_PATTERN.match(root_digest) is not None + and isinstance(lineage_id, str) + and bool(lineage_id) and source_ref is not None and entry_identity( goal_id=expected_goal, partition=expected_partition, seq=seq, source_ref=source_ref, + capture_lineage_id=lineage_id, + source_root_digest=root_digest, ) == entry_id ) @@ -258,12 +323,25 @@ def _load_prepared_record( return record -def _load_committed_record(path: Path | None, *, entry_id: str) -> dict[str, Any] | None: +def _load_committed_record( + path: Path | None, *, entry_id: str, capture_lineage_id: str | None = None +) -> dict[str, Any] | None: if path is None: return None record = _load_json(path) - if record.get("schema_version") != OUTBOX_COMMIT_SCHEMA or record.get("entry_id") != entry_id: - raise OutboxError("outbox_file_invalid", f"{path.name} does not match its entry") + if ( + record.get("schema_version") != OUTBOX_COMMIT_SCHEMA + or record.get("entry_id") != entry_id + or not isinstance(record.get("capture_lineage_id"), str) + or not record["capture_lineage_id"] + or ( + capture_lineage_id is not None + and record["capture_lineage_id"] != capture_lineage_id + ) + ): + raise OutboxError( + "outbox_file_invalid", f"{path.name} does not match its entry" + ) return record @@ -278,8 +356,8 @@ def retired_residue(directory: Path) -> list[Path]: The cursor is written before an entry's files are unlinked, so anything it covers is already settled in the candidate store. A crash between the cursor write and the unlinks, or between the two unlinks, leaves these - files behind; they are residue to reclaim, never entries to deliver or - markers to reject. + files behind. This is a diagnostic count only: every file still requires + an exact receipt proof before it can be reclaimed. """ if not directory.is_dir(): @@ -294,37 +372,60 @@ def retired_residue(directory: Path) -> list[Path]: return sorted(residue) -def reclaim_retired_residue(directory: Path) -> int: - """Unlink retired residue; the caller must hold the goal's drain lock.""" +def raw_bytes_digest(value: bytes) -> str: + return "sha256:" + hashlib.sha256(value).hexdigest() - residue = retired_residue(directory) - for path in residue: - path.unlink(missing_ok=True) - return len(residue) +def reclaim_verified_files(files: Iterable[tuple[Path, str]]) -> int: + """Remove only exact bytes proved against receipts under maintenance/primary locks. -def list_entries(directory: Path) -> list[OutboxEntry]: - """All live entries of one partition directory, oldest first. - - Files the durable cursor already covers are retired residue and are not - listed; a committed marker without a prepared entry above the cursor is - real corruption and fails closed. + Validate the complete batch before the first unlink. A watermark alone is + deliberately not accepted by this interface. """ + batch = list(files) + for path, expected_digest in batch: + if raw_bytes_digest(path.read_bytes()) != expected_digest: + raise OutboxError("outbox_file_changed", "verified outbox bytes changed") + for path, _digest in batch: + path.unlink() + _fsync_directory(path.parent) + return len(batch) + + +def list_entries( + directory: Path, *, allow_committed_only: bool = False +) -> list[OutboxEntry]: + """All entries, including unverified residue, independent of cursor hints.""" - if not directory.is_dir(): - return [] - watermark = _retired_watermark(directory) prepared, committed = _index_entry_files(directory) - prepared = {key: path for key, path in prepared.items() if key[0] > watermark} - committed = {key: path for key, path in committed.items() if key[0] > watermark} + identities: dict[int, str] = {} + for seq, entry_id in [*prepared, *committed]: + if seq in identities and identities[seq] != entry_id: + raise OutboxError( + "outbox_file_invalid", "sequence has multiple entry identities" + ) + identities[seq] = entry_id orphan_markers = sorted(set(committed) - set(prepared)) - if orphan_markers: + if orphan_markers and not allow_committed_only: seq, entry_id = orphan_markers[0] raise OutboxError( "outbox_file_invalid", f"committed marker without prepared entry: {entry_file_name(seq, entry_id, 'committed')}", ) entries: list[OutboxEntry] = [] + for seq, entry_id in orphan_markers: + marker_path = committed[(seq, entry_id)] + entries.append( + OutboxEntry( + partition=directory.name, + seq=seq, + entry_id=entry_id, + prepared_path=directory / entry_file_name(seq, entry_id, "prepared"), + committed_path=marker_path, + prepared={}, + committed=_load_committed_record(marker_path, entry_id=entry_id), + ) + ) for seq, entry_id in sorted(prepared): key = (seq, entry_id) prepared_record = _load_prepared_record( @@ -338,10 +439,14 @@ def list_entries(directory: Path) -> list[OutboxEntry]: prepared_path=prepared[key], committed_path=committed.get(key), prepared=prepared_record, - committed=_load_committed_record(committed.get(key), entry_id=entry_id), + committed=_load_committed_record( + committed.get(key), + entry_id=entry_id, + capture_lineage_id=prepared_record["capture_lineage_id"], + ), ) ) - return entries + return sorted(entries, key=lambda entry: entry.seq) def cursor_path(directory: Path) -> Path: @@ -350,12 +455,81 @@ def cursor_path(directory: Path) -> Path: def read_cursor(directory: Path) -> dict[str, Any] | None: path = cursor_path(directory) - if not path.exists(): + if path.is_symlink(): + raise OutboxError( + "outbox_file_invalid", "cursor must belong to its own partition" + ) + try: + record = _load_json(path) + except FileNotFoundError: return None - record = _load_json(path) - if record.get("schema_version") != DRAIN_CURSOR_SCHEMA: - raise OutboxError("outbox_file_invalid", "drain cursor schema is unsupported") - return record + return decode_cursor(record, partition=directory.name) + + +_CURSOR_FIELDS = frozenset( + { + "schema_version", + "partition", + "last_seq", + "last_entry_id", + "last_partition_digest", + "last_cursor", + "last_provider_revision", + "updated_at", + } +) +MAX_OUTBOX_SEQUENCE = 9_999_999_999 + + +def decode_cursor(value: object, *, partition: str) -> dict[str, Any]: + """Decode the shared wire cursor. It is a hint, never a delivery receipt.""" + + def invalid() -> OutboxError: + return OutboxError("outbox_file_invalid", "drain cursor binding is invalid") + + if not isinstance(value, dict) or set(value) != _CURSOR_FIELDS: + raise invalid() + seq = value.get("last_seq") + if isinstance(seq, bool) or not isinstance(seq, (int, float)): + raise invalid() + if not 1 <= seq <= MAX_OUTBOX_SEQUENCE or not math.isfinite(seq) or seq != int(seq): + raise invalid() + entry_id = value.get("last_entry_id") + digest = value.get("last_partition_digest") + if ( + value.get("schema_version") != DRAIN_CURSOR_SCHEMA + or partition not in PARTITIONS + or value.get("partition") != partition + or not isinstance(entry_id, str) + or re.fullmatch(r"local-shadow-tx-[0-9a-f]{64}", entry_id) is None + or ( + digest is not None + and ( + not isinstance(digest, str) or _DIGEST_PATTERN.fullmatch(digest) is None + ) + ) + or any( + not isinstance(value.get(key), str) or not value[key].strip() + for key in ("last_cursor", "last_provider_revision") + ) + ): + raise invalid() + timestamp = value.get("updated_at") + if ( + not isinstance(timestamp, str) + or re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|\+00:00)", timestamp + ) + is None + ): + raise invalid() + try: + parsed = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + except ValueError as error: + raise invalid() from error + if parsed.utcoffset() != timedelta(0): + raise invalid() + return {**value, "last_seq": int(seq)} def write_cursor( @@ -370,21 +544,72 @@ def write_cursor( ) -> None: durable_write_json( cursor_path(directory), + decode_cursor( + { + "schema_version": DRAIN_CURSOR_SCHEMA, + "partition": partition, + "last_seq": last_seq, + "last_entry_id": last_entry_id, + "last_partition_digest": last_partition_digest, + "last_cursor": last_cursor, + "last_provider_revision": last_provider_revision, + "updated_at": utc_now_text(), + }, + partition=partition, + ), + ) + + +def _proved_sequence(runtime_root: Path, goal_id: str, partition: str) -> int: + """Read settled progress while the primary lock prevents management changes. + + This exceptional missing-cursor path owns neither M nor cursor writes. The + native boundary validates the complete bounded history without initializing + a missing store. Primary exclusion keeps its lineage stable during the read. + """ + from ..effect_runtime import effect_runtime_result + + binding = read_shadow_capture_binding(runtime_root, goal_id) + if binding["status"] != "active": + raise OutboxError( + "bootstrap_required", "sequence recovery needs an active lineage" + ) + view = effect_runtime_result( + "coordination.runtime_shadow.outbox_read", { - "schema_version": DRAIN_CURSOR_SCHEMA, - "partition": partition, - "last_seq": last_seq, - "last_entry_id": last_entry_id, - "last_partition_digest": last_partition_digest, - "last_cursor": last_cursor, - "last_provider_revision": last_provider_revision, - "updated_at": utc_now_text(), + "schema_version": LOCAL_AUTHORITY_SHADOW_READ_REQUEST_SCHEMA, + "runtime_root": str(runtime_root), + "goal_id": goal_id, + "scan_limit": 10_000, }, + timeout=15.0, ) + proof = view.get("proof") if isinstance(view, dict) else None + if ( + not isinstance(view, dict) + or view.get("schema_version") != LOCAL_AUTHORITY_SHADOW_READ_RESULT_SCHEMA + or view.get("status") != "loaded" + or not isinstance(proof, dict) + or proof.get("capture_lineage_id") != binding["binding"]["capture_lineage_id"] + or view.get("store_identity") != binding["binding"]["store_identity"] + or read_shadow_capture_binding(runtime_root, goal_id) != binding + ): + raise OutboxError( + "outbox_sequence_unproved", "missing cursor has no stable history proof" + ) + sequences = proof.get("last_sequences") + seq = sequences.get(partition) if isinstance(sequences, dict) else None + if type(seq) is not int or not 0 <= seq <= MAX_OUTBOX_SEQUENCE: + raise OutboxError( + "outbox_sequence_unproved", "history has no verified partition progress" + ) + return seq -def next_seq(directory: Path) -> int: - """Gap-free sequence: past the newest file and the drained watermark.""" +def next_seq( + directory: Path, *, runtime_root: Path | None = None, goal_id: str | None = None +) -> int: + """Allocate after visible files and the cursor hint; drain proves continuity.""" highest = 0 if directory.is_dir(): @@ -395,23 +620,13 @@ def next_seq(directory: Path) -> int: cursor = read_cursor(directory) if cursor is not None: highest = max(highest, int(cursor.get("last_seq") or 0)) + elif runtime_root is not None and goal_id is not None: + highest = max(highest, _proved_sequence(runtime_root, goal_id, directory.name)) + if highest >= MAX_OUTBOX_SEQUENCE: + raise OutboxError("outbox_sequence_exhausted", "outbox sequence is exhausted") return highest + 1 -def latest_partition_digest(directory: Path) -> str | None: - """Digest of the newest known partition state (pending entry, else cursor).""" - - entries = list_entries(directory) - for entry in reversed(entries): - digest = entry.recorded_partition_digest() - if digest is not None: - return digest - cursor = read_cursor(directory) - if cursor is not None and isinstance(cursor.get("last_partition_digest"), str): - return str(cursor["last_partition_digest"]) - return None - - def runtime_root_digest(runtime_root: Path) -> str: """Digest of the absolute, dot-normalized root; must match the TypeScript writer. @@ -446,7 +661,11 @@ def read_lease_records(directory: Path) -> list[tuple[str, dict[str, Any]]]: return [] records: list[tuple[str, dict[str, Any]]] = [] for path in sorted(directory.iterdir()): - if not path.is_file() or not _LEASE_FILE.match(path.name) or path.name.startswith("."): + if ( + not path.is_file() + or not _LEASE_FILE.match(path.name) + or path.name.startswith(".") + ): continue raw = json.loads(path.read_text(encoding="utf-8")) if isinstance(raw, dict): @@ -461,14 +680,20 @@ def compact_lease_projection( raw_leases = raw_projection.get("leases") if not isinstance(raw_leases, list): - raise OutboxError("outbox_file_invalid", "lease partition projection must list leases") + raise OutboxError( + "outbox_file_invalid", "lease partition projection must list leases" + ) records: list[tuple[str, object]] = [] for item in raw_leases: if not isinstance(item, dict): - raise OutboxError("outbox_file_invalid", "lease projection item must be an object") + raise OutboxError( + "outbox_file_invalid", "lease projection item must be an object" + ) stem = item.get("file_stem") if not isinstance(stem, str) or not stem: - raise OutboxError("outbox_file_invalid", "lease projection item needs a file_stem") + raise OutboxError( + "outbox_file_invalid", "lease projection item needs a file_stem" + ) records.append((stem, item.get("record"))) try: return lease_partition_projection(records, goal_id=goal_id) @@ -476,8 +701,14 @@ def compact_lease_projection( raise OutboxError("outbox_file_invalid", str(error)) from error -def _writer(write_class: str, *, runtime: str, operation_id: str | None) -> dict[str, Any]: - return {"runtime": runtime, "write_class": write_class, "operation_id": operation_id} +def _writer( + write_class: str, *, runtime: str, operation_id: str | None +) -> dict[str, Any]: + return { + "runtime": runtime, + "write_class": write_class, + "operation_id": operation_id, + } def _entry_record( @@ -489,6 +720,7 @@ def _entry_record( writer: Mapping[str, Any], source: Mapping[str, Any], source_root_digest: str, + capture_lineage_id: str, projection: Mapping[str, Any] | None, digest: str | None, ) -> dict[str, Any]: @@ -501,7 +733,10 @@ def _entry_record( "writer": dict(writer), "source": dict(source), "source_root_digest": source_root_digest, - "projection": canonical_value(dict(projection)) if projection is not None else None, + "capture_lineage_id": capture_lineage_id, + "projection": canonical_value(dict(projection)) + if projection is not None + else None, "partition_digest": digest, "prepared_at": utc_now_text(), } @@ -548,6 +783,7 @@ def __init__( self._state_path = state_path self._write_class = write_class self._original_digest = text_digest(original_text) + self._original_text = original_text self._projector = projector self._directory = ( partition_directory(runtime_root, goal_id, TODO_PARTITION) @@ -557,6 +793,7 @@ def __init__( self._seq: int | None = None self._entry_id: str | None = None self._event_id: str | None = None + self._lineage_id: str | None = None self.outcome = CaptureOutcome(partition=TODO_PARTITION if enabled else None) @classmethod @@ -599,10 +836,14 @@ def skip(self, reason: str) -> None: def _project(self, state_text: str) -> dict[str, Any]: if self._projector is None: - raise OutboxError("outbox_prepare_failed", "a todo partition projector is required") + raise OutboxError( + "outbox_prepare_failed", "a todo partition projector is required" + ) projection = self._projector(state_text) if set(projection) != {"handoff_mode", "todos"}: - raise OutboxError("outbox_prepare_failed", "projector must return {handoff_mode, todos}") + raise OutboxError( + "outbox_prepare_failed", "projector must return {handoff_mode, todos}" + ) return projection def _fail(self, reason_code: str, error: BaseException) -> None: @@ -614,36 +855,43 @@ def _fail(self, reason_code: str, error: BaseException) -> None: def prepare(self, new_text: str, *, event_id: str | None = None) -> None: """Record the prepared entry for the bytes about to be written. - For the state-event-log branch pass ``new_text=original`` plus the - event id; the projection is then recorded by ``committed`` after the - append, still inside the same lock. + Event-only writers have no source-owned outbox transaction and return + an explicit hold without creating an entry. """ if not self.enabled or self._directory is None or self._runtime_root is None: self.outcome.skipped_reason = "shadow_disabled" return + binding_view = read_shadow_capture_binding(self._runtime_root, self._goal_id) + if binding_view["status"] != "active": + self.outcome.skipped_reason = str( + binding_view.get("reason_code") or "bootstrap_required" + ) + return + self._lineage_id = str(binding_view["binding"]["capture_lineage_id"]) + if event_id is not None: + self.outcome.skipped_reason = "event_log_writer_not_bound" + return try: - if event_id is None: - projection = self._project(new_text) - digest = partition_digest(projection) - if digest == latest_partition_digest(self._directory): - self.outcome.skipped_reason = "partition_unchanged" - return - source_ref = text_digest(new_text) - bytes_digest: str | None = source_ref - source_kind = SOURCE_MARKDOWN - else: - projection = None - digest = None - bytes_digest = None - source_kind = SOURCE_STATE_EVENT_LOG - source_ref = f"event:{event_id}" - seq = next_seq(self._directory) + projection = self._project(new_text) + digest = partition_digest(projection) + previous_digest = partition_digest(self._project(self._original_text)) + if digest == previous_digest: + self.outcome.skipped_reason = "partition_unchanged" + return + source_ref = text_digest(new_text) + bytes_digest = source_ref + source_kind = SOURCE_MARKDOWN + seq = next_seq( + self._directory, runtime_root=self._runtime_root, goal_id=self._goal_id + ) entry_id = entry_identity( goal_id=self._goal_id, partition=TODO_PARTITION, seq=seq, source_ref=source_ref, + capture_lineage_id=self._lineage_id, + source_root_digest=runtime_root_digest(self._runtime_root), ) record = _entry_record( goal_id=self._goal_id, @@ -658,11 +906,13 @@ def prepare(self, new_text: str, *, event_id: str | None = None) -> None: source={ "kind": source_kind, "previous_bytes_digest": self._original_digest, + "previous_partition_digest": previous_digest, "bytes_digest": bytes_digest, "lease": None, "event_id": event_id, }, source_root_digest=runtime_root_digest(self._runtime_root), + capture_lineage_id=self._lineage_id, projection=projection, digest=digest, ) @@ -670,7 +920,7 @@ def prepare(self, new_text: str, *, event_id: str | None = None) -> None: self._directory / entry_file_name(seq, entry_id, "prepared"), record, ) - except Exception as error: # noqa: BLE001 - the primary write must proceed + except Exception as error: # noqa: BLE001 - the transaction owner enforces active preparation self._fail("outbox_prepare_failed", error) return self._seq = seq @@ -681,7 +931,7 @@ def prepare(self, new_text: str, *, event_id: str | None = None) -> None: self.outcome.partition_digest = digest self.outcome.source_bytes_digest = bytes_digest - def committed(self, *, projection_from_disk: bool = False) -> None: + def committed(self) -> None: """Mark the prepared entry committed after the primary write returned.""" if self._seq is None or self._entry_id is None or self._directory is None: @@ -691,29 +941,13 @@ def committed(self, *, projection_from_disk: bool = False) -> None: marker: dict[str, Any] = { "schema_version": OUTBOX_COMMIT_SCHEMA, "entry_id": self._entry_id, + "capture_lineage_id": self._lineage_id, "committed_at": utc_now_text(), } try: - if projection_from_disk: - if self._state_path is None: - raise OutboxError("outbox_commit_marker_failed", "state path is required") - projection = self._project(self._state_path.read_text(encoding="utf-8")) - digest = partition_digest(projection) - if digest == latest_partition_digest(self._directory): - # The event changed nothing the shadow compares; retire the - # prepared entry so no crash-window resolution is needed. - (self._directory / entry_file_name(self._seq, self._entry_id, "prepared")).unlink( - missing_ok=True - ) - self.outcome.entry_id = None - self.outcome.seq = None - self.outcome.skipped_reason = "partition_unchanged" - return - marker["projection"] = canonical_value(projection) - marker["partition_digest"] = digest - self.outcome.partition_digest = digest durable_write_json( - self._directory / entry_file_name(self._seq, self._entry_id, "committed"), + self._directory + / entry_file_name(self._seq, self._entry_id, "committed"), marker, ) except Exception as error: # noqa: BLE001 - the primary write already landed @@ -724,10 +958,9 @@ def committed(self, *, projection_from_disk: bool = False) -> None: """Return ``committed``, ``abandoned`` or ``unproved`` for a prepared-only entry.""" -_LEASE_FENCE_KEYS = ("version", "lease_epoch", "status", "updated_at") - - -def _resolve_markdown_source(source: Mapping[str, Any], reader: Callable[[], str]) -> str: +def _resolve_markdown_source( + source: Mapping[str, Any], reader: Callable[[], str] +) -> str: current_digest = text_digest(reader()) if current_digest == source.get("bytes_digest"): return "committed" @@ -736,29 +969,25 @@ def _resolve_markdown_source(source: Mapping[str, Any], reader: Callable[[], str return "unproved" -def _lease_matches(current: Mapping[str, Any] | None, expected: Mapping[str, Any]) -> bool: - if current is None or not expected: - return False - return all(current.get(key) == expected.get(key) for key in _LEASE_FENCE_KEYS) - - def _resolve_lease_source( source: Mapping[str, Any], - reader: Callable[[str], dict[str, Any] | None], + reader: Callable[[str], bytes | None], ) -> str: planned = _as_object(source.get("lease")) if not planned: return "unproved" current = reader(str(planned.get("todo_id") or "")) - if _lease_matches(current, planned): + digest = raw_bytes_digest(current) if current is not None else None + if digest is not None and digest == source.get("bytes_digest"): return "committed" - previous = _as_object(source.get("previous_lease")) - if (not previous and current is None) or _lease_matches(current, previous): + if digest == source.get("previous_bytes_digest"): return "abandoned" return "unproved" -def _resolve_event_source(source: Mapping[str, Any], reader: Callable[[str], bool]) -> str: +def _resolve_event_source( + source: Mapping[str, Any], reader: Callable[[str], bool] +) -> str: event_id = source.get("event_id") if isinstance(event_id, str) and event_id and reader(event_id): # The append landed but the projection was never recorded; only a @@ -771,7 +1000,7 @@ def resolve_prepared_only_entry( entry: OutboxEntry, *, markdown_text_reader: Callable[[], str] | None, - lease_record_reader: Callable[[str], dict[str, Any] | None] | None, + lease_bytes_reader: Callable[[str], bytes | None] | None, event_presence_reader: Callable[[str], bool] | None, ) -> str: """Decide what a prepared entry without a committed marker means. @@ -784,14 +1013,16 @@ def resolve_prepared_only_entry( kind = source.get("kind") if kind == SOURCE_MARKDOWN and markdown_text_reader is not None: return _resolve_markdown_source(source, markdown_text_reader) - if kind == SOURCE_TASK_LEASE and lease_record_reader is not None: - return _resolve_lease_source(source, lease_record_reader) + if kind == SOURCE_TASK_LEASE and lease_bytes_reader is not None: + return _resolve_lease_source(source, lease_bytes_reader) if kind == SOURCE_STATE_EVENT_LOG and event_presence_reader is not None: return _resolve_event_source(source, event_presence_reader) return "unproved" -def entries_by_partition(runtime_root: Path, goal_id: str) -> dict[str, list[OutboxEntry]]: +def entries_by_partition( + runtime_root: Path, goal_id: str +) -> dict[str, list[OutboxEntry]]: return { partition: list_entries(partition_directory(runtime_root, goal_id, partition)) for partition in PARTITIONS @@ -807,14 +1038,21 @@ def outbox_summary(runtime_root: Path, goal_id: str) -> dict[str, Any]: try: entries = list_entries(directory) cursor = read_cursor(directory) + residue_count = len(retired_residue(directory)) invalid: str | None = None except OutboxError as error: entries, cursor, invalid = [], None, error.reason_code + residue_count = 0 + except OSError: + entries, cursor, invalid = [], None, "outbox_file_unavailable" + residue_count = 0 summary[partition] = { "committed_pending": sum(1 for entry in entries if entry.is_committed), "prepared_only": sum(1 for entry in entries if not entry.is_committed), - "retired_residue": len(retired_residue(directory)), - "next_seq": (max((entry.seq for entry in entries), default=0) if entries else 0), + "retired_residue": residue_count, + "next_seq": ( + max((entry.seq for entry in entries), default=0) if entries else 0 + ), "cursor_last_seq": int(cursor.get("last_seq") or 0) if cursor else None, "cursor_last_entry_id": cursor.get("last_entry_id") if cursor else None, "invalid": invalid, @@ -822,85 +1060,6 @@ def outbox_summary(runtime_root: Path, goal_id: str) -> dict[str, Any]: return summary -def remove_entry_files(entry: OutboxEntry) -> None: - entry.prepared_path.unlink(missing_ok=True) - if entry.committed_path is not None: - entry.committed_path.unlink(missing_ok=True) - - -@dataclass(frozen=True, slots=True) -class SeedSource: - """A full-partition snapshot taken under the partition's primary lock.""" - - partition: str - projection: dict[str, Any] - source_bytes_digest: str | None = None - extra_source: dict[str, Any] = field(default_factory=dict) - - -def write_seed_entry( - *, - runtime_root: Path, - goal_id: str, - seed: SeedSource, - write_class: str = "seed", -) -> OutboxEntry: - """Write a committed full-partition entry (seed or reseed); caller holds the lock.""" - - directory = partition_directory(runtime_root, goal_id, seed.partition) - digest = partition_digest(seed.projection) - seq = next_seq(directory) - source_ref: str = seed.source_bytes_digest if seed.source_bytes_digest else f"seed:{digest}" - entry_id = entry_identity(goal_id=goal_id, partition=seed.partition, seq=seq, source_ref=source_ref) - record = _entry_record( - goal_id=goal_id, - partition=seed.partition, - seq=seq, - entry_id=entry_id, - writer=_writer(write_class, runtime=WRITER_RUNTIME_PYTHON, operation_id=None), - source={ - "kind": SOURCE_MARKDOWN if seed.partition == TODO_PARTITION else SOURCE_TASK_LEASE, - "previous_bytes_digest": None, - "bytes_digest": seed.source_bytes_digest, - "lease": None, - "event_id": None, - **dict(seed.extra_source), - }, - source_root_digest=runtime_root_digest(runtime_root), - projection=seed.projection, - digest=digest, - ) - prepared_path = directory / entry_file_name(seq, entry_id, "prepared") - committed_path = directory / entry_file_name(seq, entry_id, "committed") - durable_write_json(prepared_path, record) - marker = { - "schema_version": OUTBOX_COMMIT_SCHEMA, - "entry_id": entry_id, - "committed_at": utc_now_text(), - } - durable_write_json(committed_path, marker) - return OutboxEntry( - partition=seed.partition, - seq=seq, - entry_id=entry_id, - prepared_path=prepared_path, - committed_path=committed_path, - prepared=record, - committed=marker, - ) - - -def lease_seed_source(runtime_root: Path, goal_id: str) -> SeedSource: - """Snapshot the lease partition from disk; caller holds the lease lock.""" - - records = read_lease_records(lease_directory(runtime_root, goal_id)) - try: - projection = lease_partition_projection(records, goal_id=goal_id) - except ProjectionValueError as error: - raise OutboxError("outbox_prepare_failed", str(error)) from error - return SeedSource(partition=LEASE_PARTITION, projection=projection) - - def iter_committed(entries: Iterable[OutboxEntry]) -> list[OutboxEntry]: return [entry for entry in entries if entry.is_committed] @@ -915,7 +1074,6 @@ def iter_committed(entries: Iterable[OutboxEntry]) -> list[OutboxEntry]: "CaptureOutcome", "OutboxEntry", "OutboxError", - "SeedSource", "TodoPartitionCapture", "TodoPartitionProjector", "compact_lease_projection", @@ -925,9 +1083,7 @@ def iter_committed(entries: Iterable[OutboxEntry]) -> list[OutboxEntry]: "entry_file_name", "entry_identity", "iter_committed", - "latest_partition_digest", "lease_directory", - "lease_seed_source", "list_entries", "next_seq", "outbox_root", @@ -935,13 +1091,12 @@ def iter_committed(entries: Iterable[OutboxEntry]) -> list[OutboxEntry]: "partition_directory", "read_cursor", "read_lease_records", - "reclaim_retired_residue", + "reclaim_verified_files", + "raw_bytes_digest", "record_source_ref", - "remove_entry_files", "resolve_prepared_only_entry", "retired_residue", "runtime_root_digest", "utc_now_text", "write_cursor", - "write_seed_entry", ] diff --git a/loopx/control_plane/coordination/local_authority_shadow_outbox.ts b/loopx/control_plane/coordination/local_authority_shadow_outbox.ts index 3b51e749a1..998a56943a 100644 --- a/loopx/control_plane/coordination/local_authority_shadow_outbox.ts +++ b/loopx/control_plane/coordination/local_authority_shadow_outbox.ts @@ -5,6 +5,9 @@ import { join, resolve } from "node:path"; import type { JsonObject } from "../effect_program.ts"; import { durableWriteJson } from "../effect_runtime_io.ts"; import { authorityUnicodeCompare, canonicalAuthorityBytes } from "./authority_store_codec.ts"; +import { requireShadowCaptureBinding, ShadowManagementError } from "./shadow_management.ts"; +import { outboxEntryIdentity, OUTBOX_ENTRY_FILE_PATTERN } from "./local_authority_shadow_identity.ts"; +import { readProvenShadowSequence } from "./local_authority_shadow.ts"; import { LOCAL_AUTHORITY_SHADOW_BINDING_SCHEMA, LOCAL_AUTHORITY_SHADOW_DRAIN_CURSOR_SCHEMA, @@ -17,19 +20,19 @@ import { * * The task-lease writers already hold the goal's lease lock when they persist * a record; this module lets them append a two-phase outbox entry for exactly - * that partition inside the same lock. It never touches the candidate store, - * never blocks, and never throws into the lease write: every failure is - * returned on the capture object so the writer can attach typed evidence. + * that partition inside the same lock. Missing cursor recovery reads validated + * candidate history without taking M. Preparation failures are returned to the + * primary owner, which enforces the active capture obligation before writing. */ export { + outboxEntryIdentity, LOCAL_AUTHORITY_SHADOW_BINDING_SCHEMA, LOCAL_AUTHORITY_SHADOW_DRAIN_CURSOR_SCHEMA, LOCAL_AUTHORITY_SHADOW_OUTBOX_COMMIT_SCHEMA, LOCAL_AUTHORITY_SHADOW_OUTBOX_ENTRY_SCHEMA, }; export const LEASE_PARTITION = "leases"; -const ENTRY_FILE = /^(\d{10})-(local-shadow-tx-[0-9a-f]{64})\.(prepared|committed)\.json$/u; const LEASE_FILE = /^[A-Za-z0-9_.-]+\.json$/u; const LEASE_SOURCE_FIELDS = ["todo_id", "version", "lease_epoch", "status", "updated_at"] as const; @@ -66,24 +69,6 @@ export function leaseRecordDigest(record: JsonObject): string { return sha256Digest(`${JSON.stringify(record, null, 2)}\n`); } -/** Must stay byte-compatible with the Python `entry_identity` derivation. */ -export function outboxEntryIdentity( - goalId: string, - partition: string, - seq: number, - sourceRef: string, -): string { - const digest = createHash("sha256") - .update(canonicalAuthorityBytes({ - goal_id: goalId, - partition, - seq, - source_ref: sourceRef, - })) - .digest("hex"); - return `local-shadow-tx-${digest}`; -} - export function outboxPartitionDirectory( runtimeRoot: string, goalId: string, @@ -100,34 +85,85 @@ function isMissing(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === "ENOENT"; } -async function nextSeq(directory: string): Promise { - let highest = 0; +const CURSOR_FIELDS = [ + "schema_version", "partition", "last_seq", "last_entry_id", "last_partition_digest", + "last_cursor", "last_provider_revision", "updated_at", +] as const; +export const MAX_OUTBOX_SEQUENCE = 9_999_999_999; + +export class OutboxCursorError extends Error { + readonly code: "outbox_file_invalid" | "outbox_file_unavailable"; + constructor(code: "outbox_file_invalid" | "outbox_file_unavailable") { + super(code === "outbox_file_invalid" ? "drain cursor binding is invalid" : "drain cursor cannot be read"); + this.code = code; + } +} + +export async function readOutboxCursor(directory: string, partition: string): Promise { + let bytes: Uint8Array; try { - for (const name of await readdir(directory)) { - const match = ENTRY_FILE.exec(name); - if (match !== null) highest = Math.max(highest, Number(match[1])); - } + bytes = await readFile(join(directory, "drain-cursor.json")); } catch (error) { - if (!isMissing(error)) throw error; + if (isMissing(error)) return null; + throw new OutboxCursorError("outbox_file_unavailable"); } try { - const raw: unknown = JSON.parse(await readFile(join(directory, "drain-cursor.json"), "utf8")); - if (raw !== null && typeof raw === "object") { - const lastSeq = (raw as Record).last_seq; - if (typeof lastSeq === "number" && Number.isSafeInteger(lastSeq)) { - highest = Math.max(highest, lastSeq); - } + return decodeOutboxCursor(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)), partition); + } catch { + throw new OutboxCursorError("outbox_file_invalid"); + } +} + +/** Shared wire validation; even a valid cursor still needs an exact receipt. */ +export function decodeOutboxCursor(value: unknown, partition: string): JsonObject { + const invalid = (): Error => new OutboxCursorError("outbox_file_invalid"); + if (value === null || typeof value !== "object" || Array.isArray(value)) throw invalid(); + const record = value as JsonObject; + if (Object.keys(record).length !== CURSOR_FIELDS.length || + CURSOR_FIELDS.some((key) => !Object.hasOwn(record, key))) throw invalid(); + if (record.schema_version !== LOCAL_AUTHORITY_SHADOW_DRAIN_CURSOR_SCHEMA || + !["todos", "leases"].includes(partition) || record.partition !== partition || + typeof record.last_seq !== "number" || !Number.isInteger(record.last_seq) || + record.last_seq < 1 || record.last_seq > MAX_OUTBOX_SEQUENCE || + typeof record.last_entry_id !== "string" || + !/^local-shadow-tx-[0-9a-f]{64}$/u.test(record.last_entry_id) || + (record.last_partition_digest !== null && + (typeof record.last_partition_digest !== "string" || !/^sha256:[0-9a-f]{64}$/u.test(record.last_partition_digest))) || + [record.last_cursor, record.last_provider_revision].some((part) => typeof part !== "string" || part.trim().length === 0)) { + throw invalid(); + } + const timestamp = record.updated_at; + if (typeof timestamp !== "string" || + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|\+00:00)$/u.test(timestamp)) throw invalid(); + const parsed = new Date(timestamp); + if (!Number.isFinite(parsed.getTime()) || parsed.getUTCFullYear() < 1 || + parsed.toISOString().slice(0, 19) !== timestamp.slice(0, 19)) throw invalid(); + return record; +} + +async function nextSeq(directory: string, runtimeRoot: string, goalId: string, lineageId: string): Promise { + let highest = 0; + try { + for (const name of await readdir(directory)) { + const match = OUTBOX_ENTRY_FILE_PATTERN.exec(name); + if (match !== null) highest = Math.max(highest, Number(match[1])); } } catch (error) { if (!isMissing(error)) throw error; } + const cursor = await readOutboxCursor(directory, LEASE_PARTITION); + const proved = cursor === null + ? await readProvenShadowSequence(runtimeRoot, goalId, LEASE_PARTITION, lineageId) + : cursor.last_seq as number; + highest = Math.max(highest, proved); + if (highest >= MAX_OUTBOX_SEQUENCE) throw new Error("outbox sequence exhausted"); return highest + 1; } async function readLeasePartition( leaseDirectory: string, plannedStem: string, - plannedLease: JsonObject, + plannedLease: JsonObject | null, ): Promise { const records = new Map(); let names: string[] = []; @@ -145,7 +181,7 @@ async function readLeasePartition( records.set(stem, raw as JsonObject); } } - records.set(plannedStem, plannedLease); + if (plannedLease !== null) records.set(plannedStem, plannedLease); const stems = [...records.keys()]; stems.sort(authorityUnicodeCompare); return stems.map((stem) => ({ file_stem: stem, record: records.get(stem) as JsonObject })); @@ -179,6 +215,7 @@ export interface LeaseOutboxCapture { seq: number | null; source_bytes_digest: string | null; failure: { reason_code: string; error_class: string } | null; + skipped_reason?: string; /** Write the committed marker after the lease record landed. Never throws. */ commit(): Promise; } @@ -194,8 +231,8 @@ function failureOf(reasonCode: string, error: unknown): { reason_code: string; e * Record a prepared lease-partition entry for the record about to be written. * * Call inside the lease lock, right before `atomicWriteJson(leasePath, planned)`; - * call `commit()` right after it returns. A returned failure never blocks the - * primary write. + * call `commit()` right after it returns. The primary owner must reject an + * active mutation when preparation returns a failure. */ export async function beginLeaseOutboxEntry( input: LeaseOutboxCaptureInput, @@ -215,16 +252,34 @@ export async function beginLeaseOutboxEntry( } const directory = outboxPartitionDirectory(input.runtime_root, input.goal_id, LEASE_PARTITION); try { + const binding = await requireShadowCaptureBinding(input.runtime_root, input.goal_id); + if (input.previous_lease !== null && + canonicalAuthorityBytes(input.previous_lease).equals(canonicalAuthorityBytes(input.planned_lease))) { + return { ...inert, skipped_reason: "partition_unchanged" }; + } const projection = { leases: await readLeasePartition(input.lease_directory, plannedStem, input.planned_lease) }; + const previousRecords = await readLeasePartition(input.lease_directory, plannedStem, input.previous_lease); + for (const item of [...previousRecords, ...projection.leases]) { + const record = item.record as JsonObject; + if (record.goal_id !== input.goal_id || record.todo_id !== item.file_stem) { + throw new Error("lease source identity does not match its partition"); + } + } + const previousPartitionDigest = sha256Digest(canonicalAuthorityBytes({ + leases: previousRecords.map((item) => item.record), + })); const bytesDigest = leaseRecordDigest(input.planned_lease); - const seq = await nextSeq(directory); - const entryId = outboxEntryIdentity(input.goal_id, LEASE_PARTITION, seq, bytesDigest); + const seq = await nextSeq(directory, input.runtime_root, input.goal_id, binding.capture_lineage_id); + const sourceRootDigest = sha256Digest(resolve(input.runtime_root)); + const entryId = outboxEntryIdentity(input.goal_id, LEASE_PARTITION, seq, bytesDigest, + binding.capture_lineage_id, sourceRootDigest); const entry: JsonObject = { schema_version: LOCAL_AUTHORITY_SHADOW_OUTBOX_ENTRY_SCHEMA, goal_id: input.goal_id, partition: LEASE_PARTITION, seq, entry_id: entryId, + capture_lineage_id: binding.capture_lineage_id, writer: { runtime: "typescript", write_class: input.write_class, @@ -232,6 +287,7 @@ export async function beginLeaseOutboxEntry( }, source: { kind: "task_lease_record", + previous_partition_digest: previousPartitionDigest, previous_bytes_digest: input.previous_lease === null ? null : leaseRecordDigest(input.previous_lease), @@ -240,7 +296,7 @@ export async function beginLeaseOutboxEntry( previous_lease: leaseSourceFacts(input.previous_lease), event_id: null, }, - source_root_digest: sha256Digest(resolve(input.runtime_root)), + source_root_digest: sourceRootDigest, projection, partition_digest: null, prepared_at: new Date().toISOString(), @@ -261,6 +317,7 @@ export async function beginLeaseOutboxEntry( { schema_version: LOCAL_AUTHORITY_SHADOW_OUTBOX_COMMIT_SCHEMA, entry_id: entryId, + capture_lineage_id: binding.capture_lineage_id, committed_at: new Date().toISOString(), }, ); @@ -271,6 +328,9 @@ export async function beginLeaseOutboxEntry( }; return capture; } catch (error) { + if (error instanceof ShadowManagementError && error.code === "bootstrap_required") { + return { ...inert, skipped_reason: "bootstrap_required" }; + } return { ...inert, failure: failureOf("outbox_prepare_failed", error) }; } } diff --git a/loopx/control_plane/coordination/runtime_shadow.py b/loopx/control_plane/coordination/runtime_shadow.py index b96a98073b..4b13c201d8 100644 --- a/loopx/control_plane/coordination/runtime_shadow.py +++ b/loopx/control_plane/coordination/runtime_shadow.py @@ -1,14 +1,14 @@ -"""Default-off Python adapter for the Stage 2C coordination runtime shadow. +"""Source snapshots and management adapters for bounded file-shadow evidence. -The legacy Todo and task-lease writers remain canonical. Callers may invoke -this adapter only after their primary mutation commits; every shadow outcome is -returned as evidence and must not change the primary command result. +The legacy Todo and lease stores remain canonical. Bootstrap binds one complete +source snapshot; subsequent candidate mutations belong to the durable outbox. """ from __future__ import annotations import json import hashlib +import re from collections.abc import Callable, Mapping from dataclasses import dataclass from pathlib import Path @@ -74,45 +74,28 @@ def resolve_coordination_runtime_shadow_config( RuntimeInvoker = Callable[..., object] -_LEASE_PROJECTION_FIELDS = ( - "todo_id", - "owner", - "write_scopes", - "version", - "lease_epoch", - "acquired_at", - "updated_at", - "expires_at", - "released_at", - "status", -) - - def load_task_lease_runtime_shadow_records( *, runtime_root: Path, goal_id: str, ) -> list[dict[str, object]]: - """Read compact legacy lease records for a post-commit shadow snapshot.""" + """Read complete legacy lease records for a source snapshot.""" lease_directory = runtime_root / "goals" / goal_id / "task-leases" if not lease_directory.exists(): return [] records: list[dict[str, object]] = [] - for path in sorted(lease_directory.glob("todo_*.json")): + for path in sorted(lease_directory.glob("*.json")): + if re.fullmatch(r"[A-Za-z0-9_.-]+\.json", path.name) is None: + continue value = json.loads(path.read_text(encoding="utf-8")) if not isinstance(value, Mapping): raise ValueError(f"task lease is not an object: {path.name}") todo_id = value.get("todo_id") if not isinstance(todo_id, str) or not todo_id: raise ValueError(f"task lease omits todo_id: {path.name}") - records.append( - { - field: value[field] - for field in _LEASE_PROJECTION_FIELDS - if field in value and value[field] is not None - } - ) + from .local_authority_shadow_projection import compact_lease + records.append(compact_lease(value, goal_id=goal_id, file_stem=path.stem)) records.sort(key=lambda item: str(item["todo_id"])) return records @@ -122,26 +105,17 @@ def build_todo_runtime_shadow_projection( goal_id: str, todos: object, leases: object = None, + handoff_mode: str = "hard_lease", ) -> dict[str, object]: - """Persist the complete canonical Todo consumer record for provider cutover.""" + """Build the complete source projection using the capture partition rules.""" - from ..todos.todo_summary import canonical_todo_read_record + from .local_authority_shadow_projection import canonical_bytes, canonical_value, todo_partition_projection from .coordination_state_contract import ( TODO_CANONICAL_READ_RECORD_FIELDS, TODO_CANONICAL_READ_RECORD_SCHEMA_VERSION, ) - compact: list[dict[str, object]] = [] - if isinstance(todos, list): - for item in todos: - if not isinstance(item, Mapping): - continue - todo_id = item.get("todo_id") - if not isinstance(todo_id, str) or not todo_id: - continue - projected = canonical_todo_read_record(dict(item), reject_unknown=True) - compact.append(projected) - compact.sort(key=lambda item: str(item["todo_id"])) + compact = todo_partition_projection(handoff_mode=handoff_mode, todos=todos if isinstance(todos, list) else [])["todos"] compact_leases: list[dict[str, object]] = [] if isinstance(leases, list): for item in leases: @@ -150,26 +124,14 @@ def build_todo_runtime_shadow_projection( todo_id = item.get("todo_id") if not isinstance(todo_id, str) or not todo_id: continue - compact_leases.append( - { - field: item[field] - for field in _LEASE_PROJECTION_FIELDS - if field in item and item[field] is not None - } - ) + compact_leases.append(canonical_value(dict(item))) compact_leases.sort(key=lambda item: str(item["todo_id"])) - todo_records_sha256 = hashlib.sha256( - json.dumps( - compact, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - ).encode("utf-8") - ).hexdigest() + todo_records_sha256 = hashlib.sha256(canonical_bytes(compact)).hexdigest() return { "schema_version": LOCAL_AUTHORITY_SHADOW_TRANSACTION_PROJECTION_SCHEMA, "goal_id": goal_id, "source_authority": "legacy_markdown_and_task_lease", + "handoff_mode": handoff_mode, "todos": compact, "leases": compact_leases, "todo_read_model": { @@ -178,9 +140,93 @@ def build_todo_runtime_shadow_projection( "records_sha256": todo_records_sha256, "contract_fields": list(TODO_CANONICAL_READ_RECORD_FIELDS), }, + "partitions": {"todos": None, "leases": None}, } +def build_runtime_shadow_source_snapshot( + *, goal: Mapping[str, Any], runtime_root: Path, state_path: Path, + registry_path: Path, +) -> tuple[dict[str, object], dict[str, object]]: + """Project exactly the bytes carried by one ephemeral source precondition. + + TS takes the shared source locks and verifies every byte/inventory before + publishing a baseline or a bounded qualification result. + """ + from ...event_sourced_state import build_state_projection, normalize_state_event, render_active_state_sections + from ...rollout_event_log import ROLLOUT_EVENT_SCHEMA_VERSION, rollout_event_log_path + from ...history import load_registry + from ...paths import resolve_runtime_root + from ...state_refresh import resolve_goal_state + from ..status.active_state_projection import state_event_log_candidates + from ..todos.active_state_todo_parser import parse_active_state_todos + from ..todos.goal_todo_projection import todo_summaries_from_fields + from ..todos.handoff_mode import goal_handoff_mode + from .local_authority_shadow_projection import canonical_bytes, compact_lease + from .shadow_management import ShadowManagementError + + goal_id = str(goal["id"]) + state_path = state_path.expanduser().resolve() + state_bytes = state_path.read_bytes() + state_text = state_bytes.decode("utf-8") + evidence: list[dict[str, object]] = [] + + def read_evidence(path: Path) -> bytes | None: + path = path.expanduser().resolve() + try: + data = path.read_bytes() + except FileNotFoundError: + data = None + evidence.append({"path": str(path), "bytes_sha256": None if data is None else "sha256:" + hashlib.sha256(data).hexdigest()}) + return data + + rollout_bytes = read_evidence(rollout_event_log_path(runtime_root, goal_id)) + rollout_events: list[dict[str, Any]] = [] + for line in (rollout_bytes or b"").decode("utf-8").splitlines(): + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(value, dict) and value.get("schema_version") == ROLLOUT_EVENT_SCHEMA_VERSION: + rollout_events.append(value) + + # Use the production candidate selection and projection semantics. A log + # with no Todo projection is harmless; an unbound Todo overlay is a hold. + for path in state_event_log_candidates(dict(goal), state_path=state_path): + data = read_evidence(path) + if not data: + continue + events = [normalize_state_event(json.loads(line)) for line in data.decode("utf-8").splitlines() if line.strip()] + rendered = render_active_state_sections(build_state_projection(events, goal_id=goal_id)) + fields = parse_active_state_todos(rendered, goal=dict(goal), state_path=state_path, item_limit=None, rollout_events=rollout_events) + if any(fields.get(f"{role}_todos") for role in ("user", "agent")): + raise ShadowManagementError("event_log_writer_not_bound") + + fields = parse_active_state_todos(state_text, goal=dict(goal), state_path=state_path, item_limit=None, rollout_events=rollout_events) + todos = todo_summaries_from_fields(fields=fields, source="markdown_active_state", projection_fields={}, + projection_overlay=None, rollout_events=rollout_events, roles=["user", "agent"], status=None, + todo_id=None, agent_id=None, limit=None).todos + leases: list[dict[str, Any]] = [] + inventory: list[dict[str, object]] = [] + for path in sorted((runtime_root / "goals" / goal_id / "task-leases").glob("*.json")): + if re.fullmatch(r"[A-Za-z0-9_.-]+\.json", path.name) is None: + continue + data = path.read_bytes() + leases.append(compact_lease(json.loads(data), goal_id=goal_id, file_stem=path.stem)) + inventory.append({"name": path.name, "bytes_sha256": "sha256:" + hashlib.sha256(data).hexdigest()}) + projection = build_todo_runtime_shadow_projection(goal_id=goal_id, todos=todos, leases=leases, + handoff_mode=goal_handoff_mode(state_text)) + registry = load_registry(registry_path) + registered_root = resolve_runtime_root(registry, None, registry_path=registry_path) + _, _, registered_state = resolve_goal_state(registry=registry, goal_id=goal_id, + project_override=None, state_file_override=None) + return projection, {"state_path": str(state_path), "registered_runtime_root": str(registered_root.expanduser().absolute()), + "registered_state_path": str(registered_state.expanduser().resolve()), + "state_bytes_sha256": "sha256:" + hashlib.sha256(state_bytes).hexdigest(), + "lease_inventory": inventory, "projection_sha256": hashlib.sha256(canonical_bytes(projection)).hexdigest(), + "evidence_files": evidence} + + def dispatch_coordination_runtime_shadow( *, goal: Mapping[str, Any] | None, @@ -206,7 +252,7 @@ def dispatch_coordination_runtime_shadow( request = { "schema_version": RUNTIME_SHADOW_REQUEST_SCHEMA_VERSION, - "runtime_root": str(runtime_root.expanduser().resolve()), + "runtime_root": str(runtime_root.expanduser().absolute()), "goal_id": goal_id, "operation_id": operation_id, "event_kind": event_kind, @@ -243,6 +289,7 @@ def bootstrap_coordination_runtime_shadow( operation_id: str, source_version: str, projection: Mapping[str, Any], + source_snapshot: Mapping[str, Any] | None = None, runtime_invoker: RuntimeInvoker = effect_runtime_result, ) -> dict[str, object]: """Import one legacy baseline into an empty shadow without promoting it.""" @@ -258,8 +305,9 @@ def bootstrap_coordination_runtime_shadow( } request = { "schema_version": RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA_VERSION, - "runtime_root": str(runtime_root.expanduser().resolve()), + "runtime_root": str(runtime_root.expanduser().absolute()), "goal_id": goal_id, + "source_snapshot": dict(source_snapshot or {}), "operation_id": operation_id, "source_version": source_version, "projection": dict(projection), @@ -292,7 +340,10 @@ def rollback_coordination_runtime_shadow( runtime_root: Path, goal_id: str, operation_id: str, - expected_provider_revision: str, + expected_provider_revision: str | None = None, + expected_bootstrap_operation_id: str | None = None, + projection: Mapping[str, Any] | None = None, + source_snapshot: Mapping[str, Any] | None = None, runtime_invoker: RuntimeInvoker = effect_runtime_result, ) -> dict[str, object]: """Quarantine one revision-fenced pre-promotion file shadow lineage.""" @@ -308,10 +359,13 @@ def rollback_coordination_runtime_shadow( } request = { "schema_version": RUNTIME_SHADOW_ROLLBACK_REQUEST_SCHEMA_VERSION, - "runtime_root": str(runtime_root.expanduser().resolve()), + "runtime_root": str(runtime_root.expanduser().absolute()), "goal_id": goal_id, + "source_snapshot": dict(source_snapshot or {}), "operation_id": operation_id, "expected_provider_revision": expected_provider_revision, + "expected_bootstrap_operation_id": expected_bootstrap_operation_id, + "projection": dict(projection or {}), } try: result = runtime_invoker(RUNTIME_SHADOW_ROLLBACK_METHOD, request) @@ -341,6 +395,7 @@ def inspect_coordination_runtime_shadow( runtime_root: Path, goal_id: str, projection: Mapping[str, Any], + source_snapshot: Mapping[str, Any] | None = None, runtime_invoker: RuntimeInvoker = effect_runtime_result, ) -> dict[str, object]: """Read parity evidence without allowing the shadow to drive decisions.""" @@ -357,8 +412,9 @@ def inspect_coordination_runtime_shadow( } request = { "schema_version": RUNTIME_SHADOW_INSPECT_REQUEST_SCHEMA_VERSION, - "runtime_root": str(runtime_root.expanduser().resolve()), + "runtime_root": str(runtime_root.expanduser().absolute()), "goal_id": goal_id, + "source_snapshot": dict(source_snapshot or {}), "projection": dict(projection), } try: @@ -393,6 +449,7 @@ def qualify_coordination_runtime_shadow( projection: Mapping[str, Any], minimum_operations: int, required_event_kinds: list[str], + source_snapshot: Mapping[str, Any] | None = None, runtime_invoker: RuntimeInvoker = effect_runtime_result, ) -> dict[str, object]: """Qualify coverage across a shadow lineage without serving from it.""" @@ -409,8 +466,9 @@ def qualify_coordination_runtime_shadow( } request = { "schema_version": RUNTIME_SHADOW_QUALIFY_REQUEST_SCHEMA_VERSION, - "runtime_root": str(runtime_root.expanduser().resolve()), + "runtime_root": str(runtime_root.expanduser().absolute()), "goal_id": goal_id, + "source_snapshot": dict(source_snapshot or {}), "projection": dict(projection), "minimum_operations": minimum_operations, "required_event_kinds": list(required_event_kinds), @@ -446,6 +504,7 @@ def read_coordination_runtime_shadow_todo_candidate( goal_id: str, todo_id: str, projection: Mapping[str, Any], + source_snapshot: Mapping[str, Any] | None = None, runtime_invoker: RuntimeInvoker = effect_runtime_result, ) -> dict[str, object]: """Read one parity-matched file Todo as pre-promotion evidence only.""" @@ -461,8 +520,9 @@ def read_coordination_runtime_shadow_todo_candidate( } request = { "schema_version": RUNTIME_SHADOW_TODO_READ_REQUEST_SCHEMA_VERSION, - "runtime_root": str(runtime_root.expanduser().resolve()), + "runtime_root": str(runtime_root.expanduser().absolute()), "goal_id": goal_id, + "source_snapshot": dict(source_snapshot or {}), "todo_id": todo_id, "projection": dict(projection), } diff --git a/loopx/control_plane/coordination/runtime_shadow.ts b/loopx/control_plane/coordination/runtime_shadow.ts index 728b3888c6..187eaf1708 100644 --- a/loopx/control_plane/coordination/runtime_shadow.ts +++ b/loopx/control_plane/coordination/runtime_shadow.ts @@ -1,1223 +1,320 @@ -import { isAbsolute, join } from "node:path"; +import { createHash } from "node:crypto"; +import { readFile, readdir, lstat } from "node:fs/promises"; +import { isAbsolute, join, resolve } from "node:path"; import type { JsonObject } from "../effect_program.ts"; +import { withFileMutationLock } from "../effect_runtime_io.ts"; import { requireJsonObject } from "../runtime_decode.ts"; -import type { - AuthorityStore, - AuthorityStoreCommittedTransaction, - AuthorityStoreReceiptResult, -} from "./authority_store.ts"; -import { - canonicalAuthorityBytes, - canonicalAuthorityObject, - canonicalAuthoritySha256, - requireAuthorityStoreId, -} from "./authority_store_codec.ts"; -import { - indexCoordinationProjectionTodos, - validateCoordinationTodoReadModel, -} from "./coordination_projection.ts"; +import type { AuthorityStore } from "./authority_store.ts"; +import { canonicalAuthorityBytes, canonicalAuthorityObject, canonicalAuthoritySha256, requireAuthorityStoreId } from "./authority_store_codec.ts"; +import { indexCoordinationProjectionTodos, validateCoordinationTodoReadModel } from "./coordination_projection.ts"; import { FileAuthorityStore } from "./file_authority_store.ts"; +import { legacyCoordinationTodoLockPath, legacyCoordinationLeaseLockPath, loadLegacyCoordinationWriterFence } from "./legacy_writer_fence.ts"; +import { loadValidatedShadowLineage, localAuthorityShadowHeadDigest, ShadowLineageError } from "./local_authority_shadow.ts"; +import { readOutboxCursor } from "./local_authority_shadow_outbox.ts"; import { - COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA, - COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_RESULT_SCHEMA, - COORDINATION_RUNTIME_SHADOW_COMMIT_REQUEST_SCHEMA, - COORDINATION_RUNTIME_SHADOW_COMMIT_RESULT_SCHEMA, - COORDINATION_RUNTIME_SHADOW_INSPECT_REQUEST_SCHEMA, - COORDINATION_RUNTIME_SHADOW_INSPECT_RESULT_SCHEMA, - COORDINATION_RUNTIME_SHADOW_QUALIFY_REQUEST_SCHEMA, - COORDINATION_RUNTIME_SHADOW_QUALIFY_RESULT_SCHEMA, - COORDINATION_RUNTIME_SHADOW_RECEIPT_SCHEMA, - COORDINATION_RUNTIME_SHADOW_ROLLBACK_REQUEST_SCHEMA, - COORDINATION_RUNTIME_SHADOW_ROLLBACK_RESULT_SCHEMA, - COORDINATION_RUNTIME_SHADOW_TODO_READ_REQUEST_SCHEMA, - COORDINATION_RUNTIME_SHADOW_TODO_READ_RESULT_SCHEMA, -} from "./coordination_state_contract.generated.ts"; + bootstrapManagedShadow, rollbackManagedShadow, requireShadowCaptureBinding, + withShadowMaintenanceLock, ShadowManagementError, requireShadowPrimaryWriteAllowed, +} from "./shadow_management.ts"; +import * as schemas from "./coordination_state_contract.generated.ts"; -export const COORDINATION_RUNTIME_SHADOW_REQUEST_SCHEMA = - COORDINATION_RUNTIME_SHADOW_COMMIT_REQUEST_SCHEMA; -export const COORDINATION_RUNTIME_SHADOW_RESULT_SCHEMA = - COORDINATION_RUNTIME_SHADOW_COMMIT_RESULT_SCHEMA; +export const COORDINATION_RUNTIME_SHADOW_REQUEST_SCHEMA = schemas.COORDINATION_RUNTIME_SHADOW_COMMIT_REQUEST_SCHEMA; +export const COORDINATION_RUNTIME_SHADOW_RESULT_SCHEMA = schemas.COORDINATION_RUNTIME_SHADOW_COMMIT_RESULT_SCHEMA; export { - COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA, - COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_RESULT_SCHEMA, - COORDINATION_RUNTIME_SHADOW_INSPECT_REQUEST_SCHEMA, - COORDINATION_RUNTIME_SHADOW_INSPECT_RESULT_SCHEMA, - COORDINATION_RUNTIME_SHADOW_QUALIFY_REQUEST_SCHEMA, - COORDINATION_RUNTIME_SHADOW_QUALIFY_RESULT_SCHEMA, - COORDINATION_RUNTIME_SHADOW_RECEIPT_SCHEMA, - COORDINATION_RUNTIME_SHADOW_ROLLBACK_REQUEST_SCHEMA, - COORDINATION_RUNTIME_SHADOW_ROLLBACK_RESULT_SCHEMA, - COORDINATION_RUNTIME_SHADOW_TODO_READ_REQUEST_SCHEMA, + COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA, COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_RESULT_SCHEMA, + COORDINATION_RUNTIME_SHADOW_INSPECT_REQUEST_SCHEMA, COORDINATION_RUNTIME_SHADOW_INSPECT_RESULT_SCHEMA, + COORDINATION_RUNTIME_SHADOW_QUALIFY_REQUEST_SCHEMA, COORDINATION_RUNTIME_SHADOW_QUALIFY_RESULT_SCHEMA, + COORDINATION_RUNTIME_SHADOW_RECEIPT_SCHEMA, COORDINATION_RUNTIME_SHADOW_ROLLBACK_REQUEST_SCHEMA, + COORDINATION_RUNTIME_SHADOW_ROLLBACK_RESULT_SCHEMA, COORDINATION_RUNTIME_SHADOW_TODO_READ_REQUEST_SCHEMA, COORDINATION_RUNTIME_SHADOW_TODO_READ_RESULT_SCHEMA, } from "./coordination_state_contract.generated.ts"; -interface RuntimeShadowRequest { - runtime_root: string; - goal_id: string; - operation_id: string; - event_kind: string; - source_version: string; - projection: JsonObject; -} - interface RuntimeShadowDependencies { createStore?: (directory: string, goalId: string) => AuthorityStore; createFileStore?: (directory: string, goalId: string) => FileAuthorityStore; } -interface RuntimeShadowInspectionRequest { - runtime_root: string; - goal_id: string; - projection: JsonObject; -} - -interface RuntimeShadowBootstrapRequest { +interface ShadowRequest extends JsonObject { runtime_root: string; goal_id: string; - operation_id: string; - source_version: string; projection: JsonObject; + source_snapshot: JsonObject; } -interface RuntimeShadowRollbackRequest { - runtime_root: string; - goal_id: string; - operation_id: string; - expected_provider_revision: string; -} - -interface RuntimeShadowQualificationRequest { - runtime_root: string; - goal_id: string; - projection: JsonObject; - minimum_operations: number; - required_event_kinds: string[]; -} - -interface RuntimeShadowTodoReadRequest { - runtime_root: string; - goal_id: string; - todo_id: string; - projection: JsonObject; -} - -function requiredString(value: unknown, label: string): string { - if (typeof value !== "string" || value.trim() !== value || value.length === 0) { - throw new Error(`${label} must be a non-empty trimmed string`); - } +function text(value: unknown, label: string): string { + if (typeof value !== "string" || !value || value.trim() !== value) throw new Error(`${label} must be a non-empty trimmed string`); return value; } - -function requiredPositiveSafeInteger(value: unknown, label: string): number { - if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > 10_000) { - throw new Error(`${label} must be a positive safe integer no greater than 10000`); - } - return Number(value); -} - -function requiredUniqueStrings(value: unknown, label: string): string[] { - if (!Array.isArray(value)) throw new Error(`${label} must be an array`); - if (value.length > 32) throw new Error(`${label} must contain at most 32 entries`); - const normalized = value.map((entry, index) => - requiredString(entry, `${label}[${index}]`) - ); - if (new Set(normalized).size !== normalized.length) { - throw new Error(`${label} must not contain duplicates`); - } - return normalized; -} - -function decodeRequest(value: unknown): RuntimeShadowRequest { - const input = requireJsonObject(value, "coordination runtime shadow request"); - if (input.schema_version !== COORDINATION_RUNTIME_SHADOW_REQUEST_SCHEMA) { - throw new Error("coordination runtime shadow request schema mismatch"); - } - const runtimeRoot = requiredString(input.runtime_root, "runtime_root"); - if (!isAbsolute(runtimeRoot)) { - throw new Error("runtime_root must be absolute"); - } - return { - runtime_root: runtimeRoot, - goal_id: requireAuthorityStoreId(input.goal_id, "goal id"), - operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), - event_kind: requiredString(input.event_kind, "event_kind"), - source_version: requiredString(input.source_version, "source_version"), +function exact(value: JsonObject, keys: string[], label: string): void { + if (Object.keys(value).some((key) => !keys.includes(key)) || keys.some((key) => !(key in value))) { + throw new Error(`${label} has unsupported or missing fields`); + } +} +function failure(schema: string, error: unknown): JsonObject { + const typed = error as { reason_code?: string; code?: string }; + return { schema_version: schema, status: "failed", reason_code: typed.reason_code ?? typed.code ?? "invalid_shadow_request", + reason: error instanceof Error ? error.message : "shadow operation failed", qualified: false, + read_candidate_qualified: false, parity_matches: false, scope: "bounded", sustained_parity_verified: false, + sustained_parity_verdict: "not_evaluated", + primary_writeback_preserved: true, decision_read_from_shadow: false }; +} +function decode(value: unknown, schema: string, extra: string[] = []): ShadowRequest { + const input = requireJsonObject(value, "coordination shadow request"); + const allowed = ["schema_version", "runtime_root", "goal_id", "projection", "source_snapshot", ...extra]; + if (Object.keys(input).some((key) => !allowed.includes(key)) || input.schema_version !== schema) { + throw new Error("coordination shadow request schema or fields mismatch"); + } + const root = text(input.runtime_root, "runtime_root"); + if (!isAbsolute(root)) throw new Error("runtime_root must be absolute"); + const goal = requireAuthorityStoreId(input.goal_id, "goal id"); + if (goal.includes("/") || goal.includes("\\") || goal === "." || goal === "..") throw new Error("goal id must be one path segment"); + return { ...input, runtime_root: resolve(root), goal_id: goal, projection: canonicalAuthorityObject(input.projection, "projection"), - }; -} - -function decodeInspectionRequest(value: unknown): RuntimeShadowInspectionRequest { - const input = requireJsonObject(value, "coordination runtime shadow inspection request"); - if (input.schema_version !== COORDINATION_RUNTIME_SHADOW_INSPECT_REQUEST_SCHEMA) { - throw new Error("coordination runtime shadow inspection request schema mismatch"); - } - const runtimeRoot = requiredString(input.runtime_root, "runtime_root"); - if (!isAbsolute(runtimeRoot)) { - throw new Error("runtime_root must be absolute"); - } - return { - runtime_root: runtimeRoot, - goal_id: requireAuthorityStoreId(input.goal_id, "goal id"), - projection: canonicalAuthorityObject(input.projection, "projection"), - }; -} - -function decodeBootstrapRequest(value: unknown): RuntimeShadowBootstrapRequest { - const input = requireJsonObject(value, "coordination runtime shadow bootstrap request"); - if (input.schema_version !== COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA) { - throw new Error("coordination runtime shadow bootstrap request schema mismatch"); - } - const runtimeRoot = requiredString(input.runtime_root, "runtime_root"); - if (!isAbsolute(runtimeRoot)) { - throw new Error("runtime_root must be absolute"); - } - return { - runtime_root: runtimeRoot, - goal_id: requireAuthorityStoreId(input.goal_id, "goal id"), - operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), - source_version: requiredString(input.source_version, "source_version"), - projection: canonicalAuthorityObject(input.projection, "projection"), - }; -} - -function decodeRollbackRequest(value: unknown): RuntimeShadowRollbackRequest { - const input = requireJsonObject(value, "coordination runtime shadow rollback request"); - if (input.schema_version !== COORDINATION_RUNTIME_SHADOW_ROLLBACK_REQUEST_SCHEMA) { - throw new Error("coordination runtime shadow rollback request schema mismatch"); - } - const runtimeRoot = requiredString(input.runtime_root, "runtime_root"); - if (!isAbsolute(runtimeRoot)) { - throw new Error("runtime_root must be absolute"); - } - return { - runtime_root: runtimeRoot, - goal_id: requireAuthorityStoreId(input.goal_id, "goal id"), - operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), - expected_provider_revision: requireAuthorityStoreId( - input.expected_provider_revision, - "expected provider revision", - ), - }; -} - -function decodeQualificationRequest(value: unknown): RuntimeShadowQualificationRequest { - const input = requireJsonObject(value, "coordination runtime shadow qualification request"); - if (input.schema_version !== COORDINATION_RUNTIME_SHADOW_QUALIFY_REQUEST_SCHEMA) { - throw new Error("coordination runtime shadow qualification request schema mismatch"); - } - const runtimeRoot = requiredString(input.runtime_root, "runtime_root"); - if (!isAbsolute(runtimeRoot)) throw new Error("runtime_root must be absolute"); - return { - runtime_root: runtimeRoot, - goal_id: requireAuthorityStoreId(input.goal_id, "goal id"), - projection: canonicalAuthorityObject(input.projection, "projection"), - minimum_operations: requiredPositiveSafeInteger( - input.minimum_operations, - "minimum_operations", - ), - required_event_kinds: requiredUniqueStrings( - input.required_event_kinds, - "required_event_kinds", - ), - }; -} - -function decodeTodoReadRequest(value: unknown): RuntimeShadowTodoReadRequest { - const input = requireJsonObject(value, "coordination runtime shadow Todo read request"); - if (input.schema_version !== COORDINATION_RUNTIME_SHADOW_TODO_READ_REQUEST_SCHEMA) { - throw new Error("coordination runtime shadow Todo read request schema mismatch"); - } - const runtimeRoot = requiredString(input.runtime_root, "runtime_root"); - if (!isAbsolute(runtimeRoot)) throw new Error("runtime_root must be absolute"); - return { - runtime_root: runtimeRoot, - goal_id: requireAuthorityStoreId(input.goal_id, "goal id"), - todo_id: requireAuthorityStoreId(input.todo_id, "todo id"), - projection: canonicalAuthorityObject(input.projection, "projection"), - }; -} - -function bootstrapEvent(request: RuntimeShadowBootstrapRequest): JsonObject { - return { - schema_version: "loopx_coordination_runtime_shadow_bootstrap_event_v0", - operation_id: request.operation_id, - source_version: request.source_version, - source_projection_sha256: canonicalAuthoritySha256(request.projection), - mode_declaration: "legacy_canonical_shadow", - }; -} - -async function bootstrapReadback( - store: AuthorityStore, - request: RuntimeShadowBootstrapRequest, -): Promise<{ - matched: boolean; - cursor?: string; - provider_revision?: string; - reason_code?: string; -}> { - const head = await store.loadAuthority(); - if (head.status !== "loaded") { - return { - matched: false, - reason_code: head.status === "missing" ? "shadow_bootstrap_missing" : head.reason_code, - }; - } - const history = await store.scanCommitted(null, 1); - if (history.status !== "page" || history.transactions.length !== 1) { - return { - matched: false, - reason_code: history.status === "page" - ? "shadow_bootstrap_history_missing" - : history.reason_code, - }; - } - const first = history.transactions[0]!; - const matches = first.cursor === "1" && - first.operation_id === request.operation_id && - first.receipts.length === 0 && - canonicalAuthorityBytes(first.events).equals( - canonicalAuthorityBytes([bootstrapEvent(request)]), - ) && - canonicalAuthorityBytes(first.projection).equals( - canonicalAuthorityBytes(request.projection), - ); - return { - matched: matches, - cursor: head.cursor, - provider_revision: head.provider_revision, - ...(matches ? {} : { reason_code: "shadow_bootstrap_identity_mismatch" }), - }; -} - -function bootstrapResult( - request: RuntimeShadowBootstrapRequest, - status: "applied" | "replayed" | "recovered", - readback: Awaited>, -): JsonObject { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_RESULT_SCHEMA, - status, - operation_id: request.operation_id, - source_version: request.source_version, - source_projection_sha256: canonicalAuthoritySha256(request.projection), - mode_declaration: "legacy_canonical_shadow", - cursor: readback.cursor, - provider_revision: readback.provider_revision, - bootstrap_receipts_empty: true, - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; -} - -/** - * Install the existing legacy coordination projection as the first file-shadow - * head. This is an administrative import seam, not an agent mutation: it only - * succeeds against an uninitialized store and intentionally creates no - * operation receipt. The source digest and mode declaration live in the first - * committed event so restart can distinguish migration from missing state. - */ -export async function bootstrapCoordinationRuntimeShadow( - value: unknown, - dependencies: RuntimeShadowDependencies = {}, -): Promise { - let request: RuntimeShadowBootstrapRequest; - try { - request = decodeBootstrapRequest(value); - } catch (error) { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_RESULT_SCHEMA, - status: "failed", - reason_code: "invalid_shadow_bootstrap_request", - reason: error instanceof Error ? error.message : "invalid bootstrap request", - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; - } - - const directory = join(request.runtime_root, "authority-shadow", "file-v0"); - const store = dependencies.createStore?.(directory, request.goal_id) ?? - new FileAuthorityStore(directory, request.goal_id); + source_snapshot: canonicalAuthorityObject(input.source_snapshot, "source_snapshot") }; +} + +/** Source preconditions are ephemeral. They never become an alternative state ledger. */ +function sourceSnapshot(request: ShadowRequest): JsonObject { + const snapshot = request.source_snapshot; + exact(snapshot, ["state_path", "registered_runtime_root", "registered_state_path", "state_bytes_sha256", "lease_inventory", "projection_sha256", "evidence_files"], "source_snapshot"); + if (!isAbsolute(text(snapshot.state_path, "state_path")) || + !isAbsolute(text(snapshot.registered_runtime_root, "registered_runtime_root")) || + !isAbsolute(text(snapshot.registered_state_path, "registered_state_path")) || + !/^sha256:[0-9a-f]{64}$/.test(text(snapshot.state_bytes_sha256, "state_bytes_sha256")) || + !Array.isArray(snapshot.lease_inventory) || !Array.isArray(snapshot.evidence_files) || + snapshot.projection_sha256 !== canonicalAuthoritySha256(request.projection)) { + throw new ShadowManagementError("source_snapshot_invalid"); + } + return snapshot; +} +export async function withShadowSourceLocks(request: ShadowRequest, operation: () => Promise): Promise { + const snapshot = request.source_snapshot; + if (!isAbsolute(text(snapshot.state_path, "state_path"))) throw new ShadowManagementError("source_snapshot_invalid"); + const root = request.runtime_root; + const goal = request.goal_id; + return await withFileMutationLock(legacyCoordinationTodoLockPath(root, goal), () => + withFileMutationLock(String(snapshot.state_path), () => + withFileMutationLock(legacyCoordinationLeaseLockPath(root, goal), () => + withFileMutationLock(join(root, "goals", goal, "task-leases", ".task-leases"), operation)))); +} +async function withPrePromotionSourceLocks(request: ShadowRequest, operation: () => Promise): Promise { + return await withShadowSourceLocks(request, async () => { + const fence = await loadLegacyCoordinationWriterFence(request.runtime_root, request.goal_id); + if (fence.status !== "missing") throw new ShadowManagementError(fence.status === "loaded" ? "legacy_authority_already_promoted" : fence.reason_code); + return await operation(); + }); +} +function bytesDigest(value: Uint8Array): string { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} +async function optionalBytes(path: string): Promise { + try { return await readFile(path); } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} +export async function verifyShadowSourceSnapshot(request: ShadowRequest): Promise { + const snapshot = sourceSnapshot(request); + if (resolve(String(snapshot.state_path)) !== resolve(String(snapshot.registered_state_path))) { + throw new ShadowManagementError("shadow_source_state_path_mismatch"); + } + const registeredRoot = resolve(String(snapshot.registered_runtime_root)); + if (registeredRoot !== request.runtime_root) { + await requireShadowPrimaryWriteAllowed(registeredRoot, request.goal_id); + const fence = await loadLegacyCoordinationWriterFence(registeredRoot, request.goal_id); + if (fence.status !== "missing") throw new ShadowManagementError(fence.status === "loaded" ? "legacy_authority_already_promoted" : fence.reason_code); + throw new ShadowManagementError("shadow_source_runtime_root_mismatch"); + } + exact(request.projection, ["schema_version", "goal_id", "source_authority", "handoff_mode", "todos", "leases", "todo_read_model", "partitions"], "source projection"); + if (request.projection.schema_version !== schemas.LOCAL_AUTHORITY_SHADOW_TRANSACTION_PROJECTION_SCHEMA || + request.projection.goal_id !== request.goal_id || request.projection.source_authority !== "legacy_markdown_and_task_lease" || + typeof request.projection.handoff_mode !== "string" || + !canonicalAuthorityBytes(request.projection.partitions).equals(canonicalAuthorityBytes({ todos: null, leases: null }))) { + throw new ShadowManagementError("source_projection_invalid"); + } + const bytes = await optionalBytes(String(snapshot.state_path)); + if (bytes === null || bytesDigest(bytes) !== snapshot.state_bytes_sha256) throw new ShadowManagementError("source_changed_retry"); + const directory = join(request.runtime_root, "goals", request.goal_id, "task-leases"); + let names: string[]; + try { names = await readdir(directory); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + names = []; + } + const inventory: JsonObject[] = []; + const leases: JsonObject[] = []; + for (const name of names.filter((name) => /^[A-Za-z0-9_.-]+\.json$/.test(name)).sort()) { + const data = await readFile(join(directory, name)); + const lease = canonicalAuthorityObject(JSON.parse(data.toString("utf8")), "lease"); + if (lease.goal_id !== request.goal_id || lease.todo_id !== name.slice(0, -5)) throw new ShadowManagementError("source_lease_identity_mismatch"); + inventory.push({ name, bytes_sha256: bytesDigest(data) }); + leases.push(lease); + } + if (!canonicalAuthorityBytes(inventory).equals(canonicalAuthorityBytes(snapshot.lease_inventory)) || + !canonicalAuthorityBytes(leases).equals(canonicalAuthorityBytes(request.projection.leases))) { + throw new ShadowManagementError("source_changed_retry"); + } + for (const raw of snapshot.evidence_files as JsonObject[]) { + const evidence = requireJsonObject(raw, "evidence source"); + exact(evidence, ["path", "bytes_sha256"], "evidence source"); + const path = text(evidence.path, "evidence path"); + if (!isAbsolute(path)) throw new ShadowManagementError("source_snapshot_invalid"); + const data = await optionalBytes(path); + if ((data === null ? null : bytesDigest(data)) !== evidence.bytes_sha256) throw new ShadowManagementError("source_changed_retry"); + } + validateCoordinationTodoReadModel(request.projection, request.goal_id); +} + +export async function bootstrapCoordinationRuntimeShadow(value: unknown, _dependencies: RuntimeShadowDependencies = {}): Promise { + const schema = schemas.COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_RESULT_SCHEMA; try { - const existing = await store.loadAuthority(); - if (existing.status === "loaded") { - const readback = await bootstrapReadback(store, request); - return readback.matched - ? bootstrapResult(request, "replayed", readback) - : { - schema_version: COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_RESULT_SCHEMA, - status: "failed", - reason_code: readback.reason_code ?? "shadow_already_initialized", - reason: "shadow store is already initialized by different content", - current_provider_revision: existing.provider_revision, - current_cursor: existing.cursor, - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; - } - if (existing.status !== "missing") { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_RESULT_SCHEMA, - status: "failed", - reason_code: existing.reason_code, - reason: existing.reason, - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; - } - - const result = await store.commitAuthority({ - expected_provider_revision: null, - operation_id: request.operation_id, - events: [bootstrapEvent(request)], - next_projection: request.projection, - receipts: [], + const request = decode(value, schemas.COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA, ["operation_id", "source_version"]); + text(request.operation_id, "operation_id"); text(request.source_version, "source_version"); + const result = await bootstrapManagedShadow(request, { + withPrimaryLocks: (operation) => withPrePromotionSourceLocks(request, operation), + verifySourceSnapshot: () => verifyShadowSourceSnapshot(request), }); - if (result.status === "applied") { - const readback = await bootstrapReadback(store, request); - if (!readback.matched) { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_RESULT_SCHEMA, - status: "failed", - reason_code: readback.reason_code ?? "shadow_bootstrap_readback_mismatch", - reason: "bootstrap commit did not produce the expected initial lineage", - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; - } - return bootstrapResult(request, "applied", readback); - } - if (result.status === "ambiguous") { - const readback = await bootstrapReadback(store, request); - if (readback.matched) return bootstrapResult(request, "recovered", readback); - return { - schema_version: COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_RESULT_SCHEMA, - status: "ambiguous", - operation_id: request.operation_id, - reason_code: result.reason_code, - reason: result.reason, - reconciliation_required: true, - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; - } - if (result.status === "conflict") { - const readback = await bootstrapReadback(store, request); - if (readback.matched) return bootstrapResult(request, "replayed", readback); - return { - schema_version: COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_RESULT_SCHEMA, - status: "failed", - reason_code: "shadow_already_initialized", - reason: result.conflict_kind, - current_provider_revision: result.current_provider_revision, - current_cursor: result.current_cursor, - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; - } - return { - schema_version: COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_RESULT_SCHEMA, - status: "failed", - reason_code: result.reason_code, - reason: result.reason, - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; - } catch (error) { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_RESULT_SCHEMA, - status: "failed", - reason_code: "shadow_bootstrap_unavailable", - reason: error instanceof Error ? error.message : "bootstrap unavailable", - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; - } + return { schema_version: schema, ...result, primary_writeback_preserved: true, decision_read_from_shadow: false }; + } catch (error) { return failure(schema, error); } } - -/** - * Remove one exact file-shadow lineage from the active path while retaining a - * durable quarantine copy. Legacy Todo/task-lease state remains canonical, so - * this pre-promotion rollback never changes a runtime decision and may be - * followed by a fresh bootstrap from that legacy source. - */ -export async function rollbackCoordinationRuntimeShadow( - value: unknown, - dependencies: RuntimeShadowDependencies = {}, -): Promise { - let request: RuntimeShadowRollbackRequest; +export async function rollbackCoordinationRuntimeShadow(value: unknown, _dependencies: RuntimeShadowDependencies = {}): Promise { + const schema = schemas.COORDINATION_RUNTIME_SHADOW_ROLLBACK_RESULT_SCHEMA; try { - request = decodeRollbackRequest(value); - } catch (error) { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_ROLLBACK_RESULT_SCHEMA, - status: "failed", - reason_code: "invalid_shadow_rollback_request", - reason: error instanceof Error ? error.message : "invalid rollback request", - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; - } - - const directory = join(request.runtime_root, "authority-shadow", "file-v0"); - const store = dependencies.createFileStore?.(directory, request.goal_id) ?? - new FileAuthorityStore(directory, request.goal_id); - const result = await store.archiveAuthorityDocument( - request.expected_provider_revision, - request.operation_id, - ); - if (result.status === "applied" || result.status === "replayed") { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_ROLLBACK_RESULT_SCHEMA, - ...result, - operation_id: request.operation_id, - expected_provider_revision: request.expected_provider_revision, - active_shadow_removed: true, - archive_retained: true, - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; - } - if (result.status === "ambiguous") { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_ROLLBACK_RESULT_SCHEMA, - ...result, - operation_id: request.operation_id, - expected_provider_revision: request.expected_provider_revision, - reconciliation_required: true, - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; - } - let reasonCode: string; - let reason: string; - if (result.status === "missing") { - reasonCode = "shadow_rollback_source_missing"; - reason = "active shadow lineage is missing"; - } else if (result.status === "conflict") { - reasonCode = result.conflict_kind; - reason = result.conflict_kind; - } else { - reasonCode = result.reason_code; - reason = result.reason; - } - return { - schema_version: COORDINATION_RUNTIME_SHADOW_ROLLBACK_RESULT_SCHEMA, - ...result, - status: "failed", - reason_code: reasonCode, - reason, - operation_id: request.operation_id, - expected_provider_revision: request.expected_provider_revision, - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; -} - -function expectedReceipt(request: RuntimeShadowRequest): JsonObject { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_RECEIPT_SCHEMA, - operation_id: request.operation_id, - event_kind: request.event_kind, - source_version: request.source_version, - projection_sha256: canonicalAuthoritySha256(request.projection), - }; -} - -function failed( - reasonCode: string, - reason: string, - extra: JsonObject = {}, -): JsonObject { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_RESULT_SCHEMA, - status: "failed", - reason_code: reasonCode, - reason, - primary_writeback_preserved: true, - decision_read_from_shadow: false, - ...extra, - }; -} - -function receiptMatches( - readback: AuthorityStoreReceiptResult, - receipt: JsonObject, -): boolean { - if (readback.status !== "found") return false; - const expected = canonicalAuthorityBytes(receipt); - return readback.receipts.some((candidate) => - canonicalAuthorityBytes(candidate).equals(expected) - ); -} - -function receiptResult( - request: RuntimeShadowRequest, - readback: AuthorityStoreReceiptResult, - receipt: JsonObject, - status: "replayed" | "recovered", -): JsonObject { - if (readback.status !== "found") { - return failed( - "shadow_receipt_missing", - "shadow operation has no durable receipt", - { operation_id: request.operation_id }, - ); - } - if (!receiptMatches(readback, receipt)) { - return failed( - "shadow_operation_identity_mismatch", - "shadow operation id is already bound to different committed content", - { - operation_id: request.operation_id, - cursor: readback.cursor, - provider_revision: readback.provider_revision, - }, - ); - } - return { - schema_version: COORDINATION_RUNTIME_SHADOW_RESULT_SCHEMA, - status, - operation_id: request.operation_id, - cursor: readback.cursor, - provider_revision: readback.provider_revision, - parity: { - schema_version: "loopx_coordination_runtime_shadow_parity_v0", - receipt_matches: true, - projection_sha256: receipt.projection_sha256, - }, - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; -} - -async function verifyAppliedProjection( - store: AuthorityStore, - request: RuntimeShadowRequest, - providerRevision: string, -): Promise { - const head = await store.loadAuthority(); - if (head.status !== "loaded") { - return { - verified: false, - status: head.status, - projection_matches: false, - }; - } - if (head.provider_revision !== providerRevision) { - return { - verified: false, - status: "superseded_before_readback", - projection_matches: null, - current_provider_revision: head.provider_revision, - }; - } - const projectionMatches = canonicalAuthoritySha256(head.head) === - canonicalAuthoritySha256(request.projection); - return { - verified: projectionMatches, - status: projectionMatches ? "matched_current_head" : "projection_mismatch", - projection_matches: projectionMatches, - provider_revision: head.provider_revision, - }; -} - -/** - * Compare the current legacy coordination projection with the file shadow. - * This is an evidence-only read for Stage 2C migration/parity qualification; - * callers must never use it to make a runtime coordination decision. - */ -export async function inspectCoordinationRuntimeShadow( - value: unknown, - dependencies: RuntimeShadowDependencies = {}, -): Promise { - let request: RuntimeShadowInspectionRequest; - try { - request = decodeInspectionRequest(value); - } catch (error) { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_INSPECT_RESULT_SCHEMA, - status: "failed", - reason_code: "invalid_shadow_inspection_request", - reason: error instanceof Error ? error.message : "invalid shadow inspection request", - parity_matches: false, - bootstrap_required: false, - decision_read_from_shadow: false, - }; - } - - const directory = join(request.runtime_root, "authority-shadow", "file-v0"); - const store = dependencies.createStore?.(directory, request.goal_id) ?? - new FileAuthorityStore(directory, request.goal_id); - const expectedProjectionSha256 = canonicalAuthoritySha256(request.projection); - try { - const head = await store.loadAuthority(); - if (head.status === "missing") { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_INSPECT_RESULT_SCHEMA, - status: "missing", - expected_projection_sha256: expectedProjectionSha256, - parity_matches: false, - bootstrap_required: true, - decision_read_from_shadow: false, - }; - } - if (head.status !== "loaded") { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_INSPECT_RESULT_SCHEMA, - status: "failed", - reason_code: head.reason_code, - reason: head.reason, - expected_projection_sha256: expectedProjectionSha256, - parity_matches: false, - bootstrap_required: false, - decision_read_from_shadow: false, - }; - } - const observedProjectionSha256 = canonicalAuthoritySha256(head.head); - const parityMatches = observedProjectionSha256 === expectedProjectionSha256; - return { - schema_version: COORDINATION_RUNTIME_SHADOW_INSPECT_RESULT_SCHEMA, - status: parityMatches ? "matched" : "drifted", - expected_projection_sha256: expectedProjectionSha256, - observed_projection_sha256: observedProjectionSha256, - provider_revision: head.provider_revision, - cursor: head.cursor, - parity_matches: parityMatches, - bootstrap_required: false, - decision_read_from_shadow: false, - }; - } catch (error) { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_INSPECT_RESULT_SCHEMA, - status: "failed", - reason_code: "shadow_read_unavailable", - reason: error instanceof Error ? error.message : "shadow read unavailable", - expected_projection_sha256: expectedProjectionSha256, - parity_matches: false, - bootstrap_required: false, - decision_read_from_shadow: false, - }; - } -} - -/** - * Exercise the first provider-read seam without promoting it to decision - * authority. The file head is eligible as a read candidate only when it - * matches the current legacy projection byte-for-byte; missing, drifted, or - * malformed provider state fails closed and never falls back silently. - */ -export async function readCoordinationRuntimeShadowTodoCandidate( - value: unknown, - dependencies: RuntimeShadowDependencies = {}, -): Promise { - let request: RuntimeShadowTodoReadRequest; - try { - request = decodeTodoReadRequest(value); - } catch (error) { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_TODO_READ_RESULT_SCHEMA, - status: "failed", - reason_code: "invalid_shadow_todo_read_request", - reason: error instanceof Error ? error.message : "invalid Todo read request", - read_candidate_qualified: false, - decision_read_from_shadow: false, - }; - } - - const directory = join(request.runtime_root, "authority-shadow", "file-v0"); - const store = dependencies.createStore?.(directory, request.goal_id) ?? - new FileAuthorityStore(directory, request.goal_id); - const expectedProjectionSha256 = canonicalAuthoritySha256(request.projection); - try { - const head = await store.loadAuthority(); - if (head.status === "missing") { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_TODO_READ_RESULT_SCHEMA, - status: "missing", - reason_code: "shadow_todo_read_store_missing", - expected_projection_sha256: expectedProjectionSha256, - read_candidate_qualified: false, - bootstrap_required: true, - decision_read_from_shadow: false, - }; - } - if (head.status !== "loaded") { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_TODO_READ_RESULT_SCHEMA, - status: "failed", - reason_code: head.reason_code, - reason: head.reason, - expected_projection_sha256: expectedProjectionSha256, - read_candidate_qualified: false, - decision_read_from_shadow: false, - }; - } - const observedProjectionSha256 = canonicalAuthoritySha256(head.head); - if (observedProjectionSha256 !== expectedProjectionSha256) { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_TODO_READ_RESULT_SCHEMA, - status: "drifted", - reason_code: "shadow_todo_read_projection_drift", - expected_projection_sha256: expectedProjectionSha256, - observed_projection_sha256: observedProjectionSha256, - provider_revision: head.provider_revision, - cursor: head.cursor, - parity_matches: false, - read_candidate_qualified: false, - decision_read_from_shadow: false, - }; + const request = decode(value, schemas.COORDINATION_RUNTIME_SHADOW_ROLLBACK_REQUEST_SCHEMA, + ["operation_id", "expected_provider_revision", "expected_bootstrap_operation_id"]); + text(request.operation_id, "operation_id"); + const revision = request.expected_provider_revision; + const bootstrap = request.expected_bootstrap_operation_id; + if ((typeof revision === "string") === (typeof bootstrap === "string")) throw new Error("rollback requires exactly one revision or bootstrap operation selector"); + const result = await rollbackManagedShadow(request, { withPrimaryLocks: (operation) => withPrePromotionSourceLocks(request, operation) }); + return { schema_version: schema, ...result, primary_writeback_preserved: true, decision_read_from_shadow: false }; + } catch (error) { return failure(schema, error); } +} + +async function pendingOutbox(root: string, goal: string, + binding: Awaited>, + transactions: Awaited>["transactions"]): Promise { + const outbox = join(root, "authority-shadow", "outbox", goal); + const outboxStat = await lstat(outbox); + if (!outboxStat.isDirectory() || outboxStat.isSymbolicLink()) throw new ShadowLineageError("outbox_file_invalid"); + const inventory = await readdir(outbox, { withFileTypes: true }); + if (inventory.some((item) => !["manifest.json", "todos", "leases"].includes(item.name) || item.isSymbolicLink() || + (item.name === "manifest.json" ? !item.isFile() : !item.isDirectory()))) throw new ShadowLineageError("outbox_unproved_residue"); + const manifestBytes = await optionalBytes(join(outbox, "manifest.json")); + if (manifestBytes === null) throw new ShadowLineageError("outbox_manifest_unproved"); + let manifest: unknown; + try { manifest = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(manifestBytes)); } + catch { throw new ShadowLineageError("outbox_manifest_unproved"); } + if (!canonicalAuthorityBytes(manifest).equals(canonicalAuthorityBytes({ + schema_version: schemas.SHADOW_OUTBOX_MANIFEST_SCHEMA, goal_id: goal, ...binding, + }))) throw new ShadowLineageError("outbox_manifest_unproved"); + for (const partition of ["todos", "leases"]) { + const directory = join(root, "authority-shadow", "outbox", goal, partition); + let names: string[]; + try { names = await readdir(directory); } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; } - const projection = indexCoordinationProjectionTodos(head.head, request.goal_id); - const todo = projection.todos.get(request.todo_id); - if (todo === undefined) { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_TODO_READ_RESULT_SCHEMA, - status: "todo_missing", - reason_code: "shadow_todo_read_todo_missing", - todo_id: request.todo_id, - todo_ids: projection.todo_ids, - expected_projection_sha256: expectedProjectionSha256, - observed_projection_sha256: observedProjectionSha256, - provider_revision: head.provider_revision, - cursor: head.cursor, - parity_matches: true, - read_candidate_qualified: false, - decision_read_from_shadow: false, - }; + if (names.some((name) => name !== "drain-cursor.json")) return true; + if (names.includes("drain-cursor.json")) { + const stat = await lstat(join(directory, "drain-cursor.json")); + if (!stat.isFile() || stat.isSymbolicLink()) throw new ShadowLineageError("outbox_file_invalid"); } + const cursor = await readOutboxCursor(directory, partition); + const settled = transactions.slice(1).filter((transaction) => transaction.receipts[0]?.partition === partition); + if (cursor === null) { if (settled.length) return true; continue; } + const anchor = settled.at(-1); + if (anchor === undefined || anchor.operation_id !== cursor.last_entry_id || + anchor.receipts[0]?.seq !== cursor.last_seq || anchor.cursor !== cursor.last_cursor || + anchor.provider_revision !== cursor.last_provider_revision) throw new ShadowLineageError("outbox_cursor_unproved"); + const applied = settled.filter((transaction) => transaction.receipts[0]?.no_op === false).at(-1); + if ((applied?.receipts[0]?.partition_digest ?? null) !== cursor.last_partition_digest) throw new ShadowLineageError("outbox_cursor_unproved"); + } + return false; +} + +async function qualifySnapshot(request: ShadowRequest, dependencies: RuntimeShadowDependencies, minimum: number, required: string[]): Promise { + return await withShadowMaintenanceLock(request.runtime_root, request.goal_id, () => withShadowSourceLocks(request, async () => { + await verifyShadowSourceSnapshot(request); + const store = dependencies.createStore?.(join(request.runtime_root, "authority-shadow", "file-v0"), request.goal_id) ?? + new FileAuthorityStore(join(request.runtime_root, "authority-shadow", "file-v0"), request.goal_id, { existingOnly: true }); + const initial = await store.loadAuthority(); + if (initial.status === "loaded" && initial.head.capture_profile !== "file_outbox_v1") throw new ShadowLineageError("legacy_lineage_ineligible"); + const binding = await requireShadowCaptureBinding(request.runtime_root, request.goal_id); + const lineage = await loadValidatedShadowLineage(store, request.runtime_root, request.goal_id, binding); + const pending = await pendingOutbox(request.runtime_root, request.goal_id, binding, lineage.transactions); + await verifyShadowSourceSnapshot(request); + const matched = localAuthorityShadowHeadDigest(request.projection) === localAuthorityShadowHeadDigest(lineage.head.head); + const missing = required.filter((kind) => !lineage.write_classes.includes(kind)); + const operations = lineage.transactions.slice(1).filter((transaction) => transaction.receipts[0]?.no_op === false).length; + const qualified = matched && !pending && operations >= minimum && missing.length === 0; return { - schema_version: COORDINATION_RUNTIME_SHADOW_TODO_READ_RESULT_SCHEMA, - status: "matched", - todo_id: request.todo_id, - todo, - todo_ids: projection.todo_ids, - expected_projection_sha256: expectedProjectionSha256, - observed_projection_sha256: observedProjectionSha256, - provider_revision: head.provider_revision, - cursor: head.cursor, - parity_matches: true, - read_candidate_qualified: true, - source: "file_v0", - decision_read_from_shadow: false, + status: !matched ? "drifted" : pending ? "not_ready" : qualified ? "qualified" : "insufficient_evidence", + qualified, parity_matches: matched, reason_code: pending ? "outbox_pending" : !matched ? "shadow_projection_drift" : null, + scope: "bounded", sustained_parity_verified: false, sustained_parity_verdict: "not_evaluated", capture_profile: binding.capture_profile, + capture_lineage_id: binding.capture_lineage_id, bootstrap_provider_revision: binding.bootstrap_provider_revision, + provider_revision: lineage.head.provider_revision, cursor: lineage.head.cursor, + expected_projection_sha256: localAuthorityShadowHeadDigest(request.projection), + observed_projection_sha256: localAuthorityShadowHeadDigest(lineage.head.head), + policy: { minimum_operations: minimum, required_event_kinds: required }, + evidence: { bootstrap_verified: true, transaction_lineage_verified: true, operation_count: operations, + observed_event_kinds: lineage.write_classes, missing_required_event_kinds: missing, + enough_operations: operations >= minimum, coverage_complete: missing.length === 0, + todo_consumer_semantics_verified: true, last_sequences: lineage.last_sequences, + last_applied_sequences: lineage.last_applied_sequences, pending_outbox: pending }, + primary_writeback_preserved: true, decision_read_from_shadow: false, + head: lineage.head.head, }; - } catch (error) { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_TODO_READ_RESULT_SCHEMA, - status: "failed", - reason_code: "shadow_todo_read_unavailable", - reason: error instanceof Error ? error.message : "Todo read unavailable", - expected_projection_sha256: expectedProjectionSha256, - read_candidate_qualified: false, - decision_read_from_shadow: false, - }; - } -} - -function validateBootstrapTransaction( - transaction: AuthorityStoreCommittedTransaction, -): string | null { - if (transaction.cursor !== "1" || transaction.events.length !== 1) { - return "shadow_qualification_bootstrap_shape_invalid"; - } - const event = transaction.events[0]!; - if ( - event.schema_version !== "loopx_coordination_runtime_shadow_bootstrap_event_v0" || - event.operation_id !== transaction.operation_id || - event.mode_declaration !== "legacy_canonical_shadow" || - event.source_projection_sha256 !== canonicalAuthoritySha256(transaction.projection) || - transaction.receipts.length !== 0 - ) { - return "shadow_qualification_bootstrap_identity_invalid"; - } - return null; -} - -function validateMirroredTransaction( - transaction: AuthorityStoreCommittedTransaction, -): { reason_code: string | null; event_kind: string | null } { - if (transaction.events.length !== 1 || transaction.receipts.length !== 1) { - return { - reason_code: "shadow_qualification_transaction_shape_invalid", - event_kind: null, - }; - } - const event = transaction.events[0]!; - const receipt = transaction.receipts[0]!; - const eventKind = typeof event.event_kind === "string" ? event.event_kind : null; - if ( - event.schema_version !== "loopx_coordination_runtime_shadow_event_v0" || - receipt.schema_version !== COORDINATION_RUNTIME_SHADOW_RECEIPT_SCHEMA || - event.operation_id !== transaction.operation_id || - receipt.operation_id !== transaction.operation_id || - eventKind === null || - receipt.event_kind !== eventKind || - typeof event.source_version !== "string" || - receipt.source_version !== event.source_version || - typeof event.projection_sha256 !== "string" || - receipt.projection_sha256 !== event.projection_sha256 || - event.projection_sha256 !== canonicalAuthoritySha256(transaction.projection) - ) { - return { - reason_code: "shadow_qualification_transaction_identity_invalid", - event_kind: eventKind, - }; - } - return { reason_code: null, event_kind: eventKind }; -} - -async function scanShadowLineage( - store: AuthorityStore, -): Promise< - | { status: "loaded"; transactions: AuthorityStoreCommittedTransaction[] } - | { status: "failed"; reason_code: string; reason: string } -> { - const transactions: AuthorityStoreCommittedTransaction[] = []; - let cursor: string | null = null; - for (;;) { - const page = await store.scanCommitted(cursor, 256); - if (page.status !== "page") { - return { - status: "failed", - reason_code: page.reason_code, - reason: page.reason, - }; - } - transactions.push(...page.transactions.map((entry) => structuredClone(entry))); - if (transactions.length > 10_000) { - return { - status: "failed", - reason_code: "shadow_qualification_history_too_large", - reason: "shadow qualification history exceeds the bounded 10000 transaction limit", - }; - } - if (!page.has_more) return { status: "loaded", transactions }; - if (page.next_cursor === null || page.next_cursor === cursor) { - return { - status: "failed", - reason_code: "shadow_qualification_cursor_stalled", - reason: "shadow qualification scan did not advance its cursor", - }; - } - cursor = page.next_cursor; - } -} - -/** - * Produce promotion evidence across a lineage of distinct mirrored operations. - * The policy is coverage-based rather than time-based: the caller selects a - * minimum operation count and the mutation kinds that must have been observed. - * This remains an evidence-only read and cannot promote or serve the shadow. - */ -export async function qualifyCoordinationRuntimeShadow( - value: unknown, - dependencies: RuntimeShadowDependencies = {}, -): Promise { - let request: RuntimeShadowQualificationRequest; + })); +} +function policy(request: ShadowRequest): { minimum: number; required: string[] } { + const minimum = request.minimum_operations ?? 3; + const required = request.required_event_kinds ?? []; + if (!Number.isSafeInteger(minimum) || Number(minimum) < 1 || Number(minimum) > 10000 || + !Array.isArray(required) || required.length > 32 || required.some((kind) => typeof kind !== "string" || !kind.trim()) || + new Set(required).size !== required.length) throw new Error("invalid bounded qualification policy"); + return { minimum: Number(minimum), required: required as string[] }; +} +export async function qualifyCoordinationRuntimeShadow(value: unknown, dependencies: RuntimeShadowDependencies = {}): Promise { + const schema = schemas.COORDINATION_RUNTIME_SHADOW_QUALIFY_RESULT_SCHEMA; try { - request = decodeQualificationRequest(value); - } catch (error) { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_QUALIFY_RESULT_SCHEMA, - status: "failed", - reason_code: "invalid_shadow_qualification_request", - reason: error instanceof Error ? error.message : "invalid qualification request", - qualified: false, - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; - } - - const directory = join(request.runtime_root, "authority-shadow", "file-v0"); - const store = dependencies.createStore?.(directory, request.goal_id) ?? - new FileAuthorityStore(directory, request.goal_id); - const policy = { - schema_version: "loopx_coordination_runtime_shadow_parity_policy_v0", - minimum_operations: request.minimum_operations, - required_event_kinds: request.required_event_kinds, - }; + const request = decode(value, schemas.COORDINATION_RUNTIME_SHADOW_QUALIFY_REQUEST_SCHEMA, ["minimum_operations", "required_event_kinds"]); + const selected = policy(request); + const result = await qualifySnapshot(request, dependencies, selected.minimum, selected.required); + delete result.head; + return { schema_version: schema, ...result }; + } catch (error) { return failure(schema, error); } +} +export async function inspectCoordinationRuntimeShadow(value: unknown, dependencies: RuntimeShadowDependencies = {}): Promise { + const schema = schemas.COORDINATION_RUNTIME_SHADOW_INSPECT_RESULT_SCHEMA; try { - const head = await store.loadAuthority(); - if (head.status === "missing") { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_QUALIFY_RESULT_SCHEMA, - status: "missing", - policy, - qualified: false, - bootstrap_required: true, - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; - } - if (head.status !== "loaded") { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_QUALIFY_RESULT_SCHEMA, - status: "failed", - reason_code: head.reason_code, - reason: head.reason, - policy, - qualified: false, - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; - } - const expectedProjectionSha256 = canonicalAuthoritySha256(request.projection); - const observedProjectionSha256 = canonicalAuthoritySha256(head.head); - let todoReadModel: JsonObject; - try { - validateCoordinationTodoReadModel(request.projection, request.goal_id); - todoReadModel = validateCoordinationTodoReadModel(head.head, request.goal_id); - } catch (error) { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_QUALIFY_RESULT_SCHEMA, - status: "drifted", - reason_code: "shadow_todo_consumer_semantics_invalid", - reason: error instanceof Error ? error.message : "Todo read-model validation failed", - policy, - qualified: false, - parity_matches: false, - expected_projection_sha256: expectedProjectionSha256, - observed_projection_sha256: observedProjectionSha256, - provider_revision: head.provider_revision, - cursor: head.cursor, - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; - } - const lineage = await scanShadowLineage(store); - if (lineage.status === "failed") { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_QUALIFY_RESULT_SCHEMA, - status: "failed", - reason_code: lineage.reason_code, - reason: lineage.reason, - policy, - qualified: false, - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; - } - const bootstrap = lineage.transactions[0]; - const bootstrapFailure = bootstrap === undefined - ? "shadow_qualification_bootstrap_missing" - : validateBootstrapTransaction(bootstrap); - const eventKinds = new Set(); - let transactionFailure: string | null = bootstrapFailure; - for (const transaction of lineage.transactions.slice(1)) { - const validation = validateMirroredTransaction(transaction); - if (validation.event_kind !== null) eventKinds.add(validation.event_kind); - transactionFailure ??= validation.reason_code; - } - const observedEventKinds = [...eventKinds].sort(); - const missingRequiredEventKinds = request.required_event_kinds.filter( - (eventKind) => !eventKinds.has(eventKind), - ); - const operationCount = Math.max(0, lineage.transactions.length - 1); - const currentHeadMatches = expectedProjectionSha256 === observedProjectionSha256; - const enoughOperations = operationCount >= request.minimum_operations; - const coverageComplete = missingRequiredEventKinds.length === 0; - const qualified = currentHeadMatches && transactionFailure === null && - enoughOperations && coverageComplete; - let status: "qualified" | "insufficient_evidence" | "drifted"; - if (!currentHeadMatches || transactionFailure !== null) status = "drifted"; - else status = qualified ? "qualified" : "insufficient_evidence"; - return { - schema_version: COORDINATION_RUNTIME_SHADOW_QUALIFY_RESULT_SCHEMA, - status, - policy, - qualified, - parity_matches: currentHeadMatches, - expected_projection_sha256: expectedProjectionSha256, - observed_projection_sha256: observedProjectionSha256, - provider_revision: head.provider_revision, - cursor: head.cursor, - evidence: { - schema_version: "loopx_coordination_runtime_shadow_parity_evidence_v0", - bootstrap_verified: bootstrapFailure === null, - transaction_lineage_verified: transactionFailure === null, - operation_count: operationCount, - observed_event_kinds: observedEventKinds, - missing_required_event_kinds: missingRequiredEventKinds, - enough_operations: enoughOperations, - coverage_complete: coverageComplete, - todo_consumer_semantics_verified: true, - todo_read_model: todoReadModel, - ...(transactionFailure === null ? {} : { reason_code: transactionFailure }), - }, - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; + const request = decode(value, schemas.COORDINATION_RUNTIME_SHADOW_INSPECT_REQUEST_SCHEMA); + const result = await qualifySnapshot(request, dependencies, 0, []); + delete result.head; + return { schema_version: schema, ...result, status: result.qualified ? "matched" : result.status, + bootstrap_required: false }; } catch (error) { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_QUALIFY_RESULT_SCHEMA, - status: "failed", - reason_code: "shadow_qualification_unavailable", - reason: error instanceof Error ? error.message : "qualification unavailable", - policy, - qualified: false, - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; + const result = failure(schema, error); + if (result.reason_code === "bootstrap_required") return { ...result, status: "missing", bootstrap_required: true }; + return result; } } - -/** - * Mirror one already-committed legacy coordination mutation into the Stage 2C - * file shadow. The result is evidence only: it never authorizes, rejects, or - * rolls back the primary mutation and no runtime decision reads this store. - */ -export async function commitCoordinationRuntimeShadow( - value: unknown, - dependencies: RuntimeShadowDependencies = {}, -): Promise { - let request: RuntimeShadowRequest; - try { - request = decodeRequest(value); - } catch (error) { - return failed( - "invalid_shadow_request", - error instanceof Error ? error.message : "invalid shadow request", - ); - } - - const directory = join( - request.runtime_root, - "authority-shadow", - "file-v0", - ); - const store = dependencies.createStore?.(directory, request.goal_id) ?? - new FileAuthorityStore(directory, request.goal_id); - const receipt = expectedReceipt(request); - +export async function readCoordinationRuntimeShadowTodoCandidate(value: unknown, dependencies: RuntimeShadowDependencies = {}): Promise { + const schema = schemas.COORDINATION_RUNTIME_SHADOW_TODO_READ_RESULT_SCHEMA; try { - const existing = await store.readReceipt(request.operation_id); - if (existing.status === "found") { - return receiptResult(request, existing, receipt, "replayed"); - } - if (existing.status !== "missing") { - return failed(existing.reason_code, existing.reason, { - operation_id: request.operation_id, - }); - } - - for (let attempt = 0; attempt < 2; attempt += 1) { - const head = await store.loadAuthority(); - if (head.status !== "loaded" && head.status !== "missing") { - return failed(head.reason_code, head.reason, { - operation_id: request.operation_id, - }); - } - const result = await store.commitAuthority({ - expected_provider_revision: head.status === "loaded" - ? head.provider_revision - : null, - operation_id: request.operation_id, - events: [{ - schema_version: "loopx_coordination_runtime_shadow_event_v0", - operation_id: request.operation_id, - event_kind: request.event_kind, - source_version: request.source_version, - projection_sha256: receipt.projection_sha256, - }], - next_projection: request.projection, - receipts: [receipt], - }); - if (result.status === "applied") { - const readback = await store.readReceipt(request.operation_id); - if (!receiptMatches(readback, receipt) || readback.status !== "found") { - return failed( - "shadow_commit_readback_mismatch", - "shadow commit did not produce its exact durable receipt", - { operation_id: request.operation_id }, - ); - } - const projectionReadback = await verifyAppliedProjection( - store, - request, - readback.provider_revision, - ); - if (projectionReadback.status === "projection_mismatch") { - return failed( - "shadow_commit_projection_mismatch", - "shadow commit receipt exists but current projection differs", - { - operation_id: request.operation_id, - provider_revision: readback.provider_revision, - }, - ); - } - return { - schema_version: COORDINATION_RUNTIME_SHADOW_RESULT_SCHEMA, - status: "applied", - operation_id: request.operation_id, - cursor: readback.cursor, - provider_revision: readback.provider_revision, - parity: { - schema_version: "loopx_coordination_runtime_shadow_parity_v0", - receipt_matches: true, - projection_sha256: receipt.projection_sha256, - projection_readback: projectionReadback, - }, - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; - } - if (result.status === "ambiguous") { - const readback = await store.readReceipt(request.operation_id); - if (readback.status === "found") { - return receiptResult(request, readback, receipt, "recovered"); - } - return { - schema_version: COORDINATION_RUNTIME_SHADOW_RESULT_SCHEMA, - status: "ambiguous", - operation_id: request.operation_id, - reason_code: result.reason_code, - reason: result.reason, - reconciliation_required: true, - primary_writeback_preserved: true, - decision_read_from_shadow: false, - }; - } - if ( - result.status === "conflict" && - result.conflict_kind === "operation_id_exists" - ) { - return receiptResult( - request, - await store.readReceipt(request.operation_id), - receipt, - "replayed", - ); - } - if ( - result.status === "conflict" && - result.conflict_kind === "provider_revision_mismatch" && - attempt === 0 - ) { - continue; - } - if (result.status === "conflict") { - return failed("shadow_provider_conflict", result.conflict_kind, { - operation_id: request.operation_id, - current_provider_revision: result.current_provider_revision, - current_cursor: result.current_cursor, - }); - } - return failed(result.reason_code, result.reason, { - operation_id: request.operation_id, - }); - } - return failed( - "shadow_provider_conflict", - "shadow provider revision changed during bounded retry", - { operation_id: request.operation_id }, - ); - } catch (error) { - return failed( - "shadow_write_unavailable", - error instanceof Error ? error.message : "shadow write unavailable", - { operation_id: request.operation_id }, - ); - } + const request = decode(value, schemas.COORDINATION_RUNTIME_SHADOW_TODO_READ_REQUEST_SCHEMA, ["todo_id"]); + const todoId = text(request.todo_id, "todo_id"); + const result = await qualifySnapshot(request, dependencies, 3, []); + const head = result.head as JsonObject; + delete result.head; + if (!result.qualified) return { schema_version: schema, ...result, read_candidate_qualified: false }; + const index = indexCoordinationProjectionTodos(head, request.goal_id); + const todo = index.todos.get(todoId); + return { schema_version: schema, ...result, status: todo === undefined ? "todo_missing" : "matched", + todo_id: todoId, todo: todo ?? null, todo_ids: index.todo_ids, read_candidate_qualified: todo !== undefined }; + } catch (error) { return failure(schema, error); } +} +/** Retired observation writes must never mix into a transaction-bound lineage. */ +export async function commitCoordinationRuntimeShadow(_value: unknown, _dependencies: RuntimeShadowDependencies = {}): Promise { + return { schema_version: COORDINATION_RUNTIME_SHADOW_RESULT_SCHEMA, status: "failed", + reason_code: "legacy_lineage_read_only", primary_writeback_preserved: true, decision_read_from_shadow: false }; } diff --git a/loopx/control_plane/coordination/runtime_shadow_writer_adapter.py b/loopx/control_plane/coordination/runtime_shadow_writer_adapter.py index 023310d6a5..3b52cf557d 100644 --- a/loopx/control_plane/coordination/runtime_shadow_writer_adapter.py +++ b/loopx/control_plane/coordination/runtime_shadow_writer_adapter.py @@ -11,6 +11,51 @@ from . import local_authority_shadow_outbox as outbox from .local_authority_shadow_projection import LEASE_PARTITION from .runtime_shadow import resolve_coordination_runtime_shadow_config +from .shadow_management import ShadowManagementError, read_shadow_capture_binding, require_shadow_primary_write_allowed + + +class ActiveStateAuthorityMutationError(ValueError): + """A prose-only writer attempted to change canonical coordination state.""" + + code = "active_state_authority_mutation_forbidden" + payload = {"primary_writeback_preserved": True} + + +def require_prose_state_write_allowed( + *, registry_path: Path, runtime_root: Path, goal_id: str, state_path: Path, + original_text: str, planned_text: str, +) -> None: + """Under S, enforce source maintenance and the owned prose-only invariant.""" + + from .legacy_writer_fence import require_registry_source_write_allowed + require_registry_source_write_allowed( + registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_id, + state_file=state_path, canonical_mutation=False, + ) + + from ...rollout_event_log import load_rollout_events, rollout_event_log_path + from ..todos.todo_index import MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL + from .local_authority_shadow_adapter import todo_partition_projector + + try: + goal = find_registry_goal(load_registry(registry_path), goal_id) + events = load_rollout_events( + rollout_event_log_path(runtime_root, goal_id), + limit=MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL, + ) + projector = todo_partition_projector( + goal, state_path=state_path, rollout_events=events, + ) + if projector(original_text) != projector(planned_text): + raise ActiveStateAuthorityMutationError( + "prose update would change canonical Todo or handoff state" + ) + except ActiveStateAuthorityMutationError: + raise + except Exception as error: + raise ActiveStateAuthorityMutationError( + "prose update cannot prove that canonical coordination state is unchanged" + ) from error def begin_todo_runtime_shadow_capture( @@ -24,10 +69,11 @@ def begin_todo_runtime_shadow_capture( ) -> outbox.TodoPartitionCapture: """Create the default-off transaction capture while the Todo lock is held.""" + active_binding = read_shadow_capture_binding(runtime_root, goal_id)["status"] == "active" try: registry = load_registry(registry_path) goal = find_registry_goal(registry, goal_id) - enabled = resolve_coordination_runtime_shadow_config(goal).enabled + enabled = active_binding or resolve_coordination_runtime_shadow_config(goal).enabled from ...rollout_event_log import load_rollout_events, rollout_event_log_path from ..todos.todo_index import MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL from .local_authority_shadow_adapter import todo_partition_projector @@ -42,7 +88,7 @@ def begin_todo_runtime_shadow_capture( rollout_events=events, ) except Exception: - enabled = False + enabled = active_binding projector = None return outbox.TodoPartitionCapture.begin( enabled=enabled, @@ -55,6 +101,19 @@ def begin_todo_runtime_shadow_capture( ) + +def require_runtime_shadow_capture_prepared( + capture: outbox.TodoPartitionCapture, *, runtime_root: Path, goal_id: str, +) -> None: + """Active lineage cannot admit a primary transition without durable preparation.""" + + if capture.outcome.failure is not None and require_shadow_primary_write_allowed(runtime_root, goal_id) is not None: + raise ShadowManagementError( + "shadow_capture_prepare_failed", + "durable shadow preparation failed; the primary state was not changed", + ) + + def settle_todo_runtime_shadow_capture( payload: dict[str, Any], *, @@ -63,14 +122,13 @@ def settle_todo_runtime_shadow_capture( goal_id: str, write_class: str, capture: outbox.TodoPartitionCapture, + observe_legacy: bool = True, + emit_disabled: bool = True, ) -> dict[str, Any]: """Boundedly drain one transaction capture after releasing the Todo lock.""" - from .local_authority_shadow_adapter import ( - capture_evidence, - drain_local_authority_shadow_outbox, - observe_todo_local_authority_commit, - ) + from .local_authority_shadow_observation import observe_todo_local_authority_commit + from .local_authority_shadow_adapter import capture_evidence, drain_local_authority_shadow_outbox drain = ( drain_local_authority_shadow_outbox( @@ -81,11 +139,12 @@ def settle_todo_runtime_shadow_capture( if capture.outcome.entry_id is not None else None ) - payload["coordination_runtime_shadow"] = capture_evidence( - goal_id=goal_id, - capture=capture.outcome, - drain=drain, - ) + if emit_disabled or capture.enabled: + payload["coordination_runtime_shadow"] = capture_evidence( + goal_id=goal_id, capture=capture.outcome, drain=drain, + ) + if not observe_legacy: + return payload return observe_todo_local_authority_commit( payload, registry_path, @@ -117,6 +176,7 @@ def settle_lease_runtime_shadow_capture( else None ), failure=dict(raw["failure"]) if isinstance(raw.get("failure"), Mapping) else None, + skipped_reason=str(raw["skipped_reason"]) if raw.get("skipped_reason") else None, ) from .local_authority_shadow_adapter import ( capture_evidence, diff --git a/loopx/control_plane/coordination/shadow_management.py b/loopx/control_plane/coordination/shadow_management.py new file mode 100644 index 0000000000..738907142e --- /dev/null +++ b/loopx/control_plane/coordination/shadow_management.py @@ -0,0 +1,194 @@ +"""Read-only primary guard for the TypeScript-owned shadow management journal. + +Call under the writer's primary lock. This module never creates a binding, +repairs a journal, or consults the candidate provider during a primary write. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import re +from typing import Any + +from .coordination_state_contract_generated import ( + SHADOW_MANAGEMENT_MANIFEST_SCHEMA, SHADOW_MANAGEMENT_STATE_SCHEMA, +) +from .local_authority_shadow_projection import sha256_digest + +SHADOW_CAPTURE_PROFILE = "file_outbox_v1" +_DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z") +_STATE_KEYS = { + "schema_version", "goal_id", "source_root_digest", "status", "binding", + "operation", "previous_operation_id", "result", +} +_BINDING_KEYS = { + "capture_profile", "capture_lineage_id", "source_root_digest", "store_identity", + "bootstrap_operation_id", "bootstrap_provider_revision", +} +_OPERATION_KEYS = {"kind", "operation_id", "request_digest", "manifest_digest", "phase"} + + +class ShadowManagementError(RuntimeError): + """A typed management hold; callers must not swallow it as capture failure.""" + + def __init__(self, code: str, message: str | None = None) -> None: + super().__init__(message or code) + self.code = code + self.reason_code = code + self.payload: dict[str, Any] = {"status": "blocked", "reason_code": code} + + +def shadow_management_directory(runtime_root: Path, goal_id: str) -> Path: + digest = hashlib.sha256(goal_id.encode("utf-8")).hexdigest()[:16] + return runtime_root / "authority-transition" / "file-v0" / f"shadow-management-{digest}" + + +def shadow_maintenance_lock_target(runtime_root: Path, goal_id: str) -> Path: + return shadow_management_directory(runtime_root, goal_id) / "maintenance" + + +def shadow_management_state_path(runtime_root: Path, goal_id: str) -> Path: + return shadow_management_directory(runtime_root, goal_id) / "state.json" + + +def _text(value: object) -> bool: + return isinstance(value, str) and bool(value) and value.strip() == value + + +def _binding(value: object, root_digest: str) -> bool: + return ( + isinstance(value, dict) and set(value) == _BINDING_KEYS + and all(_text(item) for item in value.values()) + and value["capture_profile"] == SHADOW_CAPTURE_PROFILE + and value["source_root_digest"] == root_digest + and re.fullmatch(r"file:[0-9a-f]{32}", value["store_identity"]) is not None + and re.fullmatch(r"file:[1-9][0-9]*:[0-9a-f]{24}", value["bootstrap_provider_revision"]) is not None + ) + + +def read_shadow_management_state(runtime_root: Path, goal_id: str) -> dict[str, Any] | None: + """Validate the closed local journal shape without performing any writes.""" + try: + raw = shadow_management_state_path(runtime_root, goal_id).read_text(encoding="utf-8") + except FileNotFoundError: + return None + except UnicodeError as exc: + raise ShadowManagementError("shadow_management_state_invalid") from exc + except OSError as exc: + raise ShadowManagementError("shadow_management_state_unavailable") from exc + root_digest = "sha256:" + hashlib.sha256(os.path.abspath(str(runtime_root)).encode()).hexdigest() + try: + state = json.loads(raw) + if not isinstance(state, dict) or set(state) != _STATE_KEYS: + raise ValueError("journal fields differ") + if (state["schema_version"] != SHADOW_MANAGEMENT_STATE_SCHEMA + or state["goal_id"] != goal_id or state["source_root_digest"] != root_digest): + raise ValueError("journal scope differs") + status = state["status"] + if status not in {"bootstrapping", "active", "rolling_back", "inactive"}: + raise ValueError("journal status is invalid") + operation = state["operation"] + if not isinstance(operation, dict) or set(operation) != _OPERATION_KEYS: + raise ValueError("journal operation is invalid") + if (not _text(operation["operation_id"]) + or not isinstance(operation["request_digest"], str) + or not _DIGEST.fullmatch(operation["request_digest"]) + or not isinstance(operation["manifest_digest"], str) + or not _DIGEST.fullmatch(operation["manifest_digest"])): + raise ValueError("journal operation identity is invalid") + kind = "bootstrap" if status in {"bootstrapping", "active"} else "rollback" + phases = {"prepared", "candidate_committed", "outbox_ready"} if kind == "bootstrap" else { + "prepared", "candidate_archived", "outbox_archived", + } + terminal = status in {"active", "inactive"} + if operation["kind"] != kind or operation["phase"] not in ({"complete"} if terminal else phases): + raise ValueError("journal phase is invalid") + if state["previous_operation_id"] is not None and not _text(state["previous_operation_id"]): + raise ValueError("journal predecessor is invalid") + if terminal != isinstance(state["result"], dict): + raise ValueError("journal result is invalid") + if not terminal and state["result"] is not None: + raise ValueError("journal result is premature") + if state["binding"] is not None and not _binding(state["binding"], root_digest): + raise ValueError("journal binding is invalid") + if status == "active" and state["binding"] is None: + raise ValueError("active journal has no binding") + if status == "inactive" and state["binding"] is not None: + raise ValueError("inactive journal has a binding") + return state + except (ValueError, TypeError, KeyError) as exc: + raise ShadowManagementError("shadow_management_state_invalid") from exc + + +def require_shadow_primary_write_allowed(runtime_root: Path, goal_id: str) -> dict[str, Any] | None: + state = read_shadow_management_state(runtime_root, goal_id) + if state is None or state["status"] == "inactive": + return None + if state["status"] != "active": + raise ShadowManagementError("shadow_management_in_progress") + return dict(state["binding"]) + + +def read_shadow_bootstrap_source_path( + runtime_root: Path, goal_id: str, binding: dict[str, Any], +) -> Path: + """Read this lineage's immutable source path under the caller's source lock. + + This does not acquire maintenance, consult the provider, or repair files. + Source contents can evolve; the bootstrap path remains the writer boundary. + """ + + state = read_shadow_management_state(runtime_root, goal_id) + if state is None or state["status"] == "inactive": + raise ShadowManagementError("bootstrap_required") + if state["status"] != "active": + raise ShadowManagementError("shadow_management_in_progress") + if (state["binding"] != binding + or state["operation"]["operation_id"] != binding["bootstrap_operation_id"]): + raise ShadowManagementError("stale_generation") + operation_id = binding["bootstrap_operation_id"] + directory = hashlib.sha256(operation_id.encode("utf-8")).hexdigest() + path = shadow_management_directory(runtime_root, goal_id) / "operations" / directory / "manifest.json" + try: + manifest = json.loads(path.read_text(encoding="utf-8")) + if (not isinstance(manifest, dict) + or sha256_digest(manifest) != state["operation"]["manifest_digest"] + or manifest.get("schema_version") != SHADOW_MANAGEMENT_MANIFEST_SCHEMA + or manifest.get("kind") != "bootstrap" or manifest.get("goal_id") != goal_id + or manifest.get("operation_id") != operation_id + or manifest.get("capture_lineage_id") != binding["capture_lineage_id"] + or manifest.get("source_root_digest") != binding["source_root_digest"] + or manifest.get("request_digest") != state["operation"]["request_digest"]): + raise ValueError("bootstrap manifest binding differs") + request = manifest.get("request") + if (not isinstance(request, dict) or request.get("runtime_root") != str(runtime_root) + or request.get("goal_id") != goal_id or request.get("operation_id") != operation_id + or sha256_digest(request) != manifest["request_digest"]): + raise ValueError("bootstrap request binding differs") + snapshot = request.get("source_snapshot") + source = snapshot.get("state_path") if isinstance(snapshot, dict) else None + if not isinstance(source, str) or not _text(source) or "\0" in source or not Path(source).is_absolute(): + raise ValueError("bootstrap source path is invalid") + except (OSError, UnicodeError, ValueError, TypeError, KeyError) as exc: + raise ShadowManagementError("shadow_management_manifest_invalid") from exc + if read_shadow_management_state(runtime_root, goal_id) != state: + raise ShadowManagementError("stale_generation") + return Path(source) + + +def read_shadow_capture_binding(runtime_root: Path, goal_id: str) -> dict[str, Any]: + """Observational capture status; primary guards use the throwing API above.""" + try: + state = read_shadow_management_state(runtime_root, goal_id) + except ShadowManagementError as error: + return {"status": "hold", "reason_code": error.code} + if state is None: + return {"status": "missing", "reason_code": "bootstrap_required"} + if state["status"] == "inactive": + return {"status": "inactive", "reason_code": "bootstrap_required"} + if state["status"] != "active": + return {"status": "hold", "reason_code": "shadow_management_in_progress"} + return {"status": "active", "binding": dict(state["binding"])} diff --git a/loopx/control_plane/coordination/shadow_management.ts b/loopx/control_plane/coordination/shadow_management.ts new file mode 100644 index 0000000000..0e32a5c10f --- /dev/null +++ b/loopx/control_plane/coordination/shadow_management.ts @@ -0,0 +1,541 @@ +/** Durable, per-goal shadow lifecycle. The journal never grants decision authority. */ +import { createHash, randomUUID } from "node:crypto"; +import { lstat, mkdir, open, readFile, readdir, rename } from "node:fs/promises"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import type { JsonObject } from "../effect_program.ts"; +import { atomicWriteJson, withFileMutationLock } from "../effect_runtime_io.ts"; +import { + canonicalAuthorityBytes, canonicalAuthorityObject, canonicalAuthoritySha256, + hasExactAuthorityKeys, isAuthorityJsonObject, requireAuthorityStoreId, +} from "./authority_store_codec.ts"; +import { FileAuthorityStore } from "./file_authority_store.ts"; +import { + SHADOW_MANAGEMENT_STATE_SCHEMA, SHADOW_MANAGEMENT_MANIFEST_SCHEMA, SHADOW_OUTBOX_MANIFEST_SCHEMA, +} from "./coordination_state_contract.generated.ts"; + +export { SHADOW_MANAGEMENT_STATE_SCHEMA, SHADOW_MANAGEMENT_MANIFEST_SCHEMA, SHADOW_OUTBOX_MANIFEST_SCHEMA }; +export const SHADOW_CAPTURE_PROFILE = "file_outbox_v1"; +const DIGEST = /^sha256:[0-9a-f]{64}$/; + +export interface ShadowCaptureBinding extends JsonObject { + capture_profile: string; + capture_lineage_id: string; + source_root_digest: string; + store_identity: string; + bootstrap_operation_id: string; + bootstrap_provider_revision: string; +} +export interface ShadowManagementState extends JsonObject { + schema_version: string; + goal_id: string; + source_root_digest: string; + status: "bootstrapping" | "active" | "rolling_back" | "inactive"; + binding: ShadowCaptureBinding | null; + operation: JsonObject; + previous_operation_id: string | null; + result: JsonObject | null; +} +export interface ShadowManagementDependencies { + withPrimaryLocks: (operation: () => Promise) => Promise; + verifySourceSnapshot?: () => Promise; + /** A filesystem effect boundary used by real-process crash qualification. */ + afterEffect?: (phase: string) => Promise; +} +export class ShadowManagementError extends Error { + readonly code: string; + readonly reason_code: string; + readonly payload: JsonObject; + constructor(code: string, message = code) { + super(message); + this.code = code; + this.reason_code = code; + this.payload = { status: "blocked", reason_code: code }; + } +} +export function shadowSourceRootDigest(root: string): string { + return `sha256:${createHash("sha256").update(resolve(root), "utf8").digest("hex")}`; +} +export function shadowManagementDirectory(root: string, goal: string): string { + const digest = createHash("sha256").update(goal, "utf8").digest("hex").slice(0, 16); + return join(root, "authority-transition", "file-v0", `shadow-management-${digest}`); +} +export function shadowMaintenanceLockPath(root: string, goal: string): string { + return join(shadowManagementDirectory(root, goal), "maintenance"); +} +export function shadowManagementStatePath(root: string, goal: string): string { + return join(shadowManagementDirectory(root, goal), "state.json"); +} +export async function withShadowMaintenanceLock(root: string, goal: string, operation: () => Promise): Promise { + await makeDirectoryDurable(shadowManagementDirectory(root, goal)); + return await withFileMutationLock(shadowMaintenanceLockPath(root, goal), operation); +} +function exact(value: unknown, fields: string[]): value is JsonObject { + return isAuthorityJsonObject(value) && hasExactAuthorityKeys(value, fields); +} +function text(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.trim() === value; +} +function validBinding(value: unknown, digest: string): value is ShadowCaptureBinding { + return exact(value, ["capture_profile", "capture_lineage_id", "source_root_digest", "store_identity", "bootstrap_operation_id", "bootstrap_provider_revision"]) + && Object.values(value).every(text) && value.capture_profile === SHADOW_CAPTURE_PROFILE + && value.source_root_digest === digest && /^file:[0-9a-f]{32}$/.test(String(value.store_identity)) + && /^file:[1-9][0-9]*:[0-9a-f]{24}$/.test(String(value.bootstrap_provider_revision)); +} +function decodeState(value: unknown, root: string, goal: string): ShadowManagementState { + const fail = () => { throw new ShadowManagementError("shadow_management_state_invalid"); }; + if (!exact(value, ["schema_version", "goal_id", "source_root_digest", "status", "binding", "operation", "previous_operation_id", "result"])) return fail(); + const digest = shadowSourceRootDigest(root); + if (value.schema_version !== SHADOW_MANAGEMENT_STATE_SCHEMA || value.goal_id !== goal || value.source_root_digest !== digest) return fail(); + if (!["bootstrapping", "active", "rolling_back", "inactive"].includes(String(value.status))) return fail(); + const operation = value.operation; + if (!exact(operation, ["kind", "operation_id", "request_digest", "manifest_digest", "phase"]) || !text(operation.operation_id) + || !DIGEST.test(String(operation.request_digest)) || !DIGEST.test(String(operation.manifest_digest))) return fail(); + const kind = ["bootstrapping", "active"].includes(String(value.status)) ? "bootstrap" : "rollback"; + const terminal = ["active", "inactive"].includes(String(value.status)); + const phases = terminal ? ["complete"] : kind === "bootstrap" ? ["prepared", "candidate_committed", "outbox_ready"] : ["prepared", "candidate_archived", "outbox_archived"]; + if (operation.kind !== kind || !phases.includes(String(operation.phase))) return fail(); + if (value.previous_operation_id !== null && !text(value.previous_operation_id)) return fail(); + if (terminal ? !isAuthorityJsonObject(value.result) : value.result !== null) return fail(); + if (value.binding !== null && !validBinding(value.binding, digest)) return fail(); + if ((value.status === "active" && value.binding === null) || (value.status === "inactive" && value.binding !== null)) return fail(); + return value as ShadowManagementState; +} +async function readJson(path: string): Promise { + let raw: string; + try { raw = await readFile(path, "utf8"); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } + return canonicalAuthorityObject(JSON.parse(raw), "management document"); +} +export async function readShadowManagementState(root: string, goal: string): Promise { + try { + const value = await readJson(shadowManagementStatePath(root, goal)); + return value === null ? null : decodeState(value, root, goal); + } catch (error) { + if (error instanceof ShadowManagementError) throw error; + throw new ShadowManagementError("shadow_management_state_invalid"); + } +} +export async function requireShadowPrimaryWriteAllowed(root: string, goal: string): Promise { + const state = await readShadowManagementState(root, goal); + if (!state || state.status === "inactive") return null; + if (state.status !== "active") throw new ShadowManagementError("shadow_management_in_progress"); + return state.binding; +} +export async function requireShadowCaptureBinding(root: string, goal: string): Promise { + const binding = await requireShadowPrimaryWriteAllowed(root, goal); + if (!binding) throw new ShadowManagementError("bootstrap_required"); + return binding; +} + +/** Read the source path established by this active bootstrap. The caller owns + * exclusion; this helper takes no locks and never creates or repairs files. + */ +export async function readShadowBootstrapSourcePath(root: string, goal: string, binding: ShadowCaptureBinding): Promise { + const state = await readShadowManagementState(root, goal); + if (!state || state.status === "inactive") throw new ShadowManagementError("bootstrap_required"); + if (state.status !== "active") throw new ShadowManagementError("shadow_management_in_progress"); + if (!same(state.binding, binding) || state.operation.operation_id !== binding.bootstrap_operation_id) { + throw new ShadowManagementError("stale_generation"); + } + const locator: ManagementRequest = { runtime_root: root, goal_id: goal, operation_id: binding.bootstrap_operation_id }; + const invalid = () => { throw new ShadowManagementError("shadow_management_manifest_invalid"); }; + let manifest: JsonObject | null; + try { manifest = await readJson(manifestPath(locator)); } catch { return invalid(); } + if (!manifest || managementDigest(manifest) !== state.operation.manifest_digest + || manifest.schema_version !== SHADOW_MANAGEMENT_MANIFEST_SCHEMA || manifest.kind !== "bootstrap" + || manifest.goal_id !== goal || manifest.operation_id !== binding.bootstrap_operation_id + || manifest.capture_lineage_id !== binding.capture_lineage_id + || manifest.source_root_digest !== binding.source_root_digest + || manifest.request_digest !== state.operation.request_digest + || !isAuthorityJsonObject(manifest.request)) return invalid(); + const request = manifest.request; + if (request.runtime_root !== root || request.goal_id !== goal || request.operation_id !== binding.bootstrap_operation_id + || requestDigest(request as ManagementRequest) !== manifest.request_digest + || !isAuthorityJsonObject(request.source_snapshot)) return invalid(); + const path = request.source_snapshot.state_path; + if (!text(path) || !isAbsolute(path) || path.includes("\0")) return invalid(); + const current = await readShadowManagementState(root, goal); + if (!same(current, state)) throw new ShadowManagementError("stale_generation"); + return path; +} + +interface ManagementRequest extends JsonObject { runtime_root: string; goal_id: string; operation_id: string } +function requestOf(value: unknown): ManagementRequest { + const request = canonicalAuthorityObject(value, "shadow management request"); + for (const name of ["runtime_root", "goal_id", "operation_id"]) requireAuthorityStoreId(request[name], name); + if (resolve(String(request.runtime_root)) !== request.runtime_root) throw new ShadowManagementError("invalid_shadow_management_request"); + if ([".", ".."].includes(String(request.goal_id)) || /[/\\\0]/.test(String(request.goal_id))) throw new ShadowManagementError("invalid_shadow_management_request"); + return request as ManagementRequest; +} +function operationDirectory(request: ManagementRequest): string { + const digest = createHash("sha256").update(request.operation_id).digest("hex"); + return join(shadowManagementDirectory(request.runtime_root, request.goal_id), "operations", digest); +} +function manifestPath(request: ManagementRequest): string { return join(operationDirectory(request), "manifest.json"); } +function resultPath(request: ManagementRequest): string { return join(operationDirectory(request), "result.json"); } +function outboxPath(request: ManagementRequest): string { return join(request.runtime_root, "authority-shadow", "outbox", request.goal_id); } +function archiveOutboxPath(request: ManagementRequest): string { return join(operationDirectory(request), "outbox"); } +function provider(request: ManagementRequest, existingOnly = true, dependencies?: ShadowManagementDependencies): FileAuthorityStore { + class ManagedFileStore extends FileAuthorityStore { + protected override async archiveRenamed(): Promise { + await dependencies?.afterEffect?.("rollback_candidate_renamed"); + } + } + return new ManagedFileStore(join(request.runtime_root, "authority-shadow", "file-v0"), request.goal_id, { existingOnly }); +} +function managementDigest(value: unknown): string { + return `sha256:${canonicalAuthoritySha256(value)}`; +} +function requestDigest(request: ManagementRequest): string { + // Rollback selects an immutable target. Fresh primary readback is evidence, + // not part of the operation identity after that target has been archived. + if ("expected_provider_revision" in request) { + return managementDigest({ + kind: "rollback", schema_version: request.schema_version ?? null, + runtime_root: request.runtime_root, goal_id: request.goal_id, operation_id: request.operation_id, + expected_provider_revision: request.expected_provider_revision, + expected_bootstrap_operation_id: request.expected_bootstrap_operation_id ?? null, + }); + } + return managementDigest(request); +} +function same(left: unknown, right: unknown): boolean { + return canonicalAuthorityBytes(left).equals(canonicalAuthorityBytes(right)); +} +async function syncDirectory(path: string): Promise { + if (process.platform === "win32") return; + const handle = await open(path, "r"); + try { await handle.sync(); } finally { await handle.close(); } +} +async function makeDirectoryDurable(path: string): Promise { + const missing: string[] = []; + let cursor = path; + for (;;) { + try { await lstat(cursor); break; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + missing.push(cursor); + const parent = dirname(cursor); + if (parent === cursor) throw new ShadowManagementError("shadow_management_directory_unavailable"); + cursor = parent; + } + for (const directory of missing.reverse()) { + await mkdir(directory, { recursive: true, mode: 0o700 }); + await syncDirectory(dirname(directory)); + } +} +async function writeImmutable(path: string, value: JsonObject): Promise { + const existing = await readJson(path); + if (existing) { + if (!same(existing, value)) throw new ShadowManagementError("management_operation_identity_mismatch"); + return; + } + await makeDirectoryDurable(dirname(path)); + await atomicWriteJson(path, value); +} +async function persist(request: ManagementRequest, state: ShadowManagementState): Promise { + decodeState(state, request.runtime_root, request.goal_id); + await atomicWriteJson(shadowManagementStatePath(request.runtime_root, request.goal_id), state); +} +async function effect(dependencies: ShadowManagementDependencies, phase: string): Promise { + await dependencies.afterEffect?.(phase); +} +async function retainTerminal(request: ManagementRequest, state: ShadowManagementState | null): Promise { + if (!state || !state.result) return; + const original = { ...request, operation_id: String(state.operation.operation_id) }; + await writeImmutable(resultPath(original), { + request_digest: state.operation.request_digest, + manifest_digest: state.operation.manifest_digest, + result: state.result, + }); +} +async function replay(request: ManagementRequest, state: ShadowManagementState | null): Promise { + const digest = requestDigest(request); + if (state?.operation.operation_id === request.operation_id) { + if (state.operation.request_digest !== digest) throw new ShadowManagementError("management_operation_identity_mismatch"); + return state.result ? { ...state.result, status: "replayed", current_management_status: state.status, current_capture_lineage_id: state.binding?.capture_lineage_id ?? null } : null; + } + const prior = await readJson(resultPath(request)); + if (!prior) return null; + if (prior.request_digest !== digest) throw new ShadowManagementError("management_operation_identity_mismatch"); + if (!isAuthorityJsonObject(prior.result)) throw new ShadowManagementError("shadow_management_state_invalid"); + return { ...prior.result, status: "replayed", current_management_status: state?.status ?? "missing", current_capture_lineage_id: state?.binding?.capture_lineage_id ?? null }; +} +async function loadManifest(request: ManagementRequest, state: ShadowManagementState): Promise { + const manifest = await readJson(manifestPath(request)); + if (!manifest || managementDigest(manifest) !== state.operation.manifest_digest + || manifest.schema_version !== SHADOW_MANAGEMENT_MANIFEST_SCHEMA + || manifest.goal_id !== request.goal_id || manifest.operation_id !== request.operation_id + || manifest.source_root_digest !== shadowSourceRootDigest(request.runtime_root) + || manifest.request_digest !== requestDigest(request)) { + throw new ShadowManagementError("shadow_management_manifest_invalid"); + } + return manifest; +} +function initialState(request: ManagementRequest, kind: "bootstrap" | "rollback", manifest: JsonObject, prior: ShadowManagementState | null): ShadowManagementState { + return { + schema_version: SHADOW_MANAGEMENT_STATE_SCHEMA, goal_id: request.goal_id, + source_root_digest: shadowSourceRootDigest(request.runtime_root), + status: kind === "bootstrap" ? "bootstrapping" : "rolling_back", + binding: kind === "rollback" ? prior?.binding ?? null : null, + operation: { kind, operation_id: request.operation_id, request_digest: requestDigest(request), manifest_digest: managementDigest(manifest), phase: "prepared" }, + previous_operation_id: prior ? String(prior.operation.operation_id) : null, result: null, + }; +} +async function advance(request: ManagementRequest, state: ShadowManagementState, phase: string): Promise { + state.operation.phase = phase; + await persist(request, state); +} +function failure(error: unknown): JsonObject { + const code = error instanceof ShadowManagementError ? error.code + : text((error as { code?: unknown })?.code) ? String((error as { code: string }).code) : "shadow_management_unavailable"; + return { status: "failed", reason_code: code, reconciliation_required: true, primary_writeback_preserved: true, decision_read_from_shadow: false }; +} + +/** Inventory raw bytes, including malformed cursors, without interpreting delivery. */ +async function inventory(path: string): Promise { + try { const stat = await lstat(path); if (!stat.isDirectory()) throw new ShadowManagementError("shadow_outbox_layout_invalid"); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } + const entries: JsonObject[] = []; + async function visit(directory: string, prefix: string): Promise { + for (const name of (await readdir(directory)).sort()) { + const relative = prefix ? `${prefix}/${name}` : name; + const full = join(directory, name); + const stat = await lstat(full); + if (stat.isDirectory()) { + entries.push({ path: relative, kind: "directory" }); + await visit(full, relative); + } else if (stat.isFile()) { + const bytes = await readFile(full); + entries.push({ path: relative, kind: "file", size: bytes.length, sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}` }); + } else throw new ShadowManagementError("shadow_outbox_layout_invalid"); + } + } + await visit(path, ""); + return { entries, digest: managementDigest(entries) }; +} +async function fileDigest(path: string): Promise { + try { return `sha256:${createHash("sha256").update(await readFile(path)).digest("hex")}`; } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } +} +async function candidateSnapshot(store: FileAuthorityStore): Promise { + const loaded = await store.loadAuthority(); + if (loaded.status === "missing") return null; + if (loaded.status !== "loaded") throw new ShadowManagementError(loaded.reason_code); + const identity = await store.storeIdentity(); + if (identity.status !== "available") throw new ShadowManagementError(identity.reason_code); + const first = await store.scanCommitted(null, 1); + if (first.status !== "page" || first.transactions.length !== 1) throw new ShadowManagementError("candidate_history_unavailable"); + return { + provider_revision: loaded.provider_revision, cursor: loaded.cursor, sha256: await fileDigest(store.path), + store_identity: identity.store_identity, capture_lineage_id: loaded.head.capture_lineage_id ?? null, + source_root_digest: loaded.head.source_root_digest ?? null, capture_profile: loaded.head.capture_profile ?? null, + bootstrap_operation_id: first.transactions[0].operation_id, + bootstrap_provider_revision: first.transactions[0].provider_revision, + }; +} + +export async function bootstrapManagedShadow(value: unknown, dependencies: ShadowManagementDependencies): Promise { + try { + const request = requestOf(value); + requireAuthorityStoreId(request.source_version, "source_version"); + canonicalAuthorityObject(request.projection, "projection"); + if (!isAuthorityJsonObject(request.source_snapshot) || !dependencies.verifySourceSnapshot) throw new ShadowManagementError("source_snapshot_required"); + return await withShadowMaintenanceLock(request.runtime_root, request.goal_id, async () => { + let state = await readShadowManagementState(request.runtime_root, request.goal_id); + const priorResult = await replay(request, state); + if (priorResult) return priorResult; + const recovering = state?.operation.operation_id === request.operation_id; + if (state && state.status !== "inactive" && !(recovering && state.status === "bootstrapping")) throw new ShadowManagementError("shadow_management_in_progress"); + return await dependencies.withPrimaryLocks(async () => { + await dependencies.verifySourceSnapshot!(); + let manifest: JsonObject; + if (recovering) { + manifest = await loadManifest(request, state!); + } else { + if (await candidateSnapshot(provider(request)) !== null) throw new ShadowManagementError("legacy_shadow_read_only"); + const oldOutbox = await inventory(outboxPath(request)); + if (oldOutbox && (oldOutbox.entries as JsonObject[]).length !== 0) throw new ShadowManagementError("legacy_shadow_read_only"); + await retainTerminal(request, state); + // An orphan manifest before intent publication is reusable only for this exact request. + const orphan = await readJson(manifestPath(request)); + if (orphan && orphan.request_digest !== requestDigest(request)) throw new ShadowManagementError("management_operation_identity_mismatch"); + const lineage = orphan?.capture_lineage_id ?? randomUUID(); + manifest = { + schema_version: SHADOW_MANAGEMENT_MANIFEST_SCHEMA, kind: "bootstrap", goal_id: request.goal_id, + operation_id: request.operation_id, source_root_digest: shadowSourceRootDigest(request.runtime_root), + request_digest: requestDigest(request), request, + predecessor_operation_id: state?.operation.operation_id ?? null, capture_lineage_id: lineage, + }; + await writeImmutable(manifestPath(request), manifest); + state = initialState(request, "bootstrap", manifest, state); + await persist(request, state); + await effect(dependencies, "bootstrap_prepared"); + } + const lineage = String(manifest.capture_lineage_id); + const projection: JsonObject = { ...canonicalAuthorityObject(request.projection, "projection"), capture_profile: SHADOW_CAPTURE_PROFILE, capture_lineage_id: lineage, source_root_digest: shadowSourceRootDigest(request.runtime_root) }; + const event: JsonObject = { + schema_version: "loopx_coordination_runtime_shadow_bootstrap_event_v0", operation_id: request.operation_id, + source_version: request.source_version, source_projection_sha256: canonicalAuthoritySha256(projection), + mode_declaration: "legacy_canonical_shadow", + }; + const store = provider(request, false); + await makeDirectoryDurable(store.directory); + let loaded = await store.loadAuthority(); + if (loaded.status === "missing") { + const committed = await store.commitAuthority({ expected_provider_revision: null, operation_id: request.operation_id, events: [event], next_projection: projection, receipts: [] }); + if (committed.status !== "applied" && committed.status !== "ambiguous" && committed.status !== "conflict") throw new ShadowManagementError(committed.reason_code); + loaded = await store.loadAuthority(); + } + if (loaded.status !== "loaded" || loaded.cursor !== "1" || !same(loaded.head, projection)) throw new ShadowManagementError("bootstrap_readback_mismatch"); + const scan = await store.scanCommitted(null, 2); + if (scan.status !== "page" || scan.transactions.length !== 1 || scan.transactions[0].operation_id !== request.operation_id + || !same(scan.transactions[0].events, [event]) || scan.transactions[0].receipts.length !== 0) throw new ShadowManagementError("bootstrap_readback_mismatch"); + const identity = await store.storeIdentity(); + if (identity.status !== "available") throw new ShadowManagementError(identity.reason_code); + const binding: ShadowCaptureBinding = { + capture_profile: SHADOW_CAPTURE_PROFILE, capture_lineage_id: lineage, + source_root_digest: shadowSourceRootDigest(request.runtime_root), store_identity: identity.store_identity, + bootstrap_operation_id: request.operation_id, bootstrap_provider_revision: loaded.provider_revision, + }; + await effect(dependencies, "bootstrap_candidate_committed"); + await advance(request, state!, "candidate_committed"); + await writeImmutable(join(outboxPath(request), "manifest.json"), { schema_version: SHADOW_OUTBOX_MANIFEST_SCHEMA, goal_id: request.goal_id, ...binding }); + await effect(dependencies, "bootstrap_outbox_ready"); + await advance(request, state!, "outbox_ready"); + await dependencies.verifySourceSnapshot!(); + const result: JsonObject = { + status: recovering ? "recovered" : "applied", operation_id: request.operation_id, + ...binding, provider_revision: loaded.provider_revision, cursor: loaded.cursor, + bootstrap_receipts_empty: true, mode_declaration: "legacy_canonical_shadow", + primary_writeback_preserved: true, decision_read_from_shadow: false, + }; + state!.status = "active"; state!.binding = binding; state!.result = result; state!.operation.phase = "complete"; + await persist(request, state!); + await effect(dependencies, "bootstrap_complete"); + return result; + }); + }); + } catch (error) { return failure(error); } +} + +export async function rollbackManagedShadow(value: unknown, dependencies: ShadowManagementDependencies): Promise { + try { + const request = requestOf(value); + if (request.expected_provider_revision !== null && !text(request.expected_provider_revision)) throw new ShadowManagementError("invalid_shadow_rollback_request"); + if (request.expected_bootstrap_operation_id !== undefined && request.expected_bootstrap_operation_id !== null && !text(request.expected_bootstrap_operation_id)) throw new ShadowManagementError("invalid_shadow_rollback_request"); + if (text(request.expected_provider_revision) === text(request.expected_bootstrap_operation_id)) throw new ShadowManagementError("invalid_shadow_rollback_request"); + return await withShadowMaintenanceLock(request.runtime_root, request.goal_id, async () => { + let state = await readShadowManagementState(request.runtime_root, request.goal_id); + const priorResult = await replay(request, state); + if (priorResult) return priorResult; + const recovering = state?.status === "rolling_back" && state.operation.operation_id === request.operation_id; + const aborting = state?.status === "bootstrapping" && request.expected_bootstrap_operation_id === state.operation.operation_id; + if (text(request.expected_bootstrap_operation_id) && !recovering && !aborting) throw new ShadowManagementError("bootstrap_operation_not_pending"); + if (state?.status === "rolling_back" && !recovering) throw new ShadowManagementError("shadow_management_in_progress"); + if (state?.status === "bootstrapping" && !aborting) throw new ShadowManagementError("bootstrap_operation_identity_required"); + return await dependencies.withPrimaryLocks(async () => { + const store = provider(request, true, dependencies); + let manifest: JsonObject; + if (recovering) manifest = await loadManifest(request, state!); + else { + if (!state || state.status === "inactive") throw new ShadowManagementError("shadow_rollback_source_missing"); + const candidate = await candidateSnapshot(store); + if (!aborting && (candidate?.provider_revision ?? null) !== request.expected_provider_revision) throw new ShadowManagementError("provider_revision_mismatch"); + if (candidate === null && !aborting) throw new ShadowManagementError("shadow_rollback_source_missing"); + let bootstrapManifest: JsonObject | null = null; + if (aborting) { + const original = { ...request, operation_id: String(state.operation.operation_id) }; + bootstrapManifest = await readJson(manifestPath(original)); + if (!bootstrapManifest || managementDigest(bootstrapManifest) !== state.operation.manifest_digest) throw new ShadowManagementError("shadow_management_manifest_invalid"); + } + if (candidate) { + const expectedLineage = state.binding?.capture_lineage_id ?? bootstrapManifest?.capture_lineage_id; + const expectedBootstrap = state.binding?.bootstrap_operation_id ?? bootstrapManifest?.operation_id; + if (candidate.capture_profile !== SHADOW_CAPTURE_PROFILE || candidate.capture_lineage_id !== expectedLineage + || candidate.source_root_digest !== shadowSourceRootDigest(request.runtime_root) + || candidate.bootstrap_operation_id !== expectedBootstrap + || (state.binding && (candidate.store_identity !== state.binding.store_identity + || candidate.bootstrap_provider_revision !== state.binding.bootstrap_provider_revision))) { + throw new ShadowManagementError("rollback_candidate_identity_mismatch"); + } + } + const pending = await inventory(outboxPath(request)); + await retainTerminal(request, state); + manifest = { + schema_version: SHADOW_MANAGEMENT_MANIFEST_SCHEMA, kind: "rollback", goal_id: request.goal_id, + operation_id: request.operation_id, source_root_digest: shadowSourceRootDigest(request.runtime_root), + request_digest: requestDigest(request), request, + predecessor_operation_id: state.operation.operation_id, prior_binding: state.binding, + candidate, outbox: pending, aborted_bootstrap: bootstrapManifest, + capture_lineage_id: state.binding?.capture_lineage_id ?? bootstrapManifest?.capture_lineage_id ?? null, + }; + await writeImmutable(manifestPath(request), manifest); + state = initialState(request, "rollback", manifest, state); + await persist(request, state); + await effect(dependencies, "rollback_prepared"); + } + const expected = manifest.candidate as JsonObject | null; + const sourceDigest = await fileDigest(store.path); + const archiveDigest = await fileDigest(store.authorityArchivePath(request.operation_id)); + if (expected === null) { + if (sourceDigest !== null || archiveDigest !== null) throw new ShadowManagementError("rollback_candidate_identity_mismatch"); + } else if (sourceDigest === expected.sha256 && archiveDigest === null) { + const archived = await store.archiveAuthorityDocument(String(expected.provider_revision), request.operation_id); + if (archived.status !== "applied" && archived.status !== "replayed") throw new ShadowManagementError("rollback_candidate_archive_unavailable"); + } else if (sourceDigest !== null || archiveDigest !== expected.sha256) { + throw new ShadowManagementError("rollback_candidate_identity_mismatch"); + } + if (expected !== null) { + await syncDirectory(store.directory); + await syncDirectory(dirname(store.authorityArchivePath(request.operation_id))); + } + await effect(dependencies, "rollback_candidate_archived"); + await advance(request, state!, "candidate_archived"); + const expectedOutbox = manifest.outbox as JsonObject | null; + const activeOutbox = await inventory(outboxPath(request)); + const archivedOutbox = await inventory(archiveOutboxPath(request)); + if (expectedOutbox === null) { + if (activeOutbox !== null || archivedOutbox !== null) throw new ShadowManagementError("rollback_outbox_identity_mismatch"); + } else if (same(activeOutbox, expectedOutbox) && archivedOutbox === null) { + await rename(outboxPath(request), archiveOutboxPath(request)); + await effect(dependencies, "rollback_outbox_renamed"); + await syncDirectory(dirname(outboxPath(request))); + await syncDirectory(operationDirectory(request)); + } else if (activeOutbox !== null || !same(archivedOutbox, expectedOutbox)) { + throw new ShadowManagementError("rollback_outbox_identity_mismatch"); + } + if (expectedOutbox !== null) { + await syncDirectory(dirname(outboxPath(request))); + await syncDirectory(operationDirectory(request)); + } + await effect(dependencies, "rollback_outbox_archived"); + await advance(request, state!, "outbox_archived"); + if ((expected !== null && await fileDigest(store.authorityArchivePath(request.operation_id)) !== expected.sha256) + || !same(await inventory(archiveOutboxPath(request)), expectedOutbox)) throw new ShadowManagementError("rollback_archive_readback_mismatch"); + const result: JsonObject = { + status: recovering ? "recovered" : "applied", operation_id: request.operation_id, + archive_id: store.authorityArchiveId(request.operation_id), + archived_provider_revision: expected?.provider_revision ?? null, archived_cursor: expected?.cursor ?? null, + capture_lineage_id: manifest.capture_lineage_id, + candidate_archive_path: expected ? store.authorityArchivePath(request.operation_id) : null, + outbox_archive_path: expectedOutbox ? archiveOutboxPath(request) : null, + active_shadow_removed: true, archive_retained: true, capture_status: "bootstrap_required", + primary_writeback_preserved: true, decision_read_from_shadow: false, + }; + if (isAuthorityJsonObject(manifest.aborted_bootstrap)) { + const aborted = manifest.aborted_bootstrap; + await writeImmutable(resultPath({ ...request, operation_id: String(aborted.operation_id) }), { + request_digest: aborted.request_digest, manifest_digest: managementDigest(aborted), + result: { status: "aborted", reason_code: "bootstrap_aborted", operation_id: aborted.operation_id, + rollback_operation_id: request.operation_id, capture_lineage_id: aborted.capture_lineage_id, + primary_writeback_preserved: true, decision_read_from_shadow: false }, + }); + } + state!.status = "inactive"; state!.binding = null; state!.result = result; state!.operation.phase = "complete"; + await persist(request, state!); + await effect(dependencies, "rollback_complete"); + return result; + }); + }); + } catch (error) { return failure(error); } +} diff --git a/loopx/control_plane/projects/registry.py b/loopx/control_plane/projects/registry.py index c6161ee18d..b0dd31bcab 100644 --- a/loopx/control_plane/projects/registry.py +++ b/loopx/control_plane/projects/registry.py @@ -1,14 +1,16 @@ from __future__ import annotations import json -import os import re from pathlib import Path from typing import Any from ...bootstrap import build_goal_entry from ...control_plane.runtime.time import now_local_iso -from ...file_lock import exclusive_file_lock +from ...file_lock import exclusive_cross_runtime_file_lock as exclusive_file_lock +from ...paths import resolve_runtime_root +from ..todos.active_state_editing import atomic_write_state_text as _atomic_write_text +from ..coordination.legacy_writer_fence import legacy_todo_write_transaction, require_legacy_state_replacement_allowed from ...paths import DEFAULT_RUNTIME_ROOT from ...registry import atomic_write_json from ...repository_identity import normalize_repository_identity @@ -29,19 +31,6 @@ def _unique(values: list[str]) -> list[str]: return list(dict.fromkeys(str(value).strip() for value in values if str(value).strip())) -def _atomic_write_text(path: Path, text: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - temporary = path.with_name(f".{path.name}.project-register.tmp") - try: - with temporary.open("w", encoding="utf-8") as handle: - handle.write(text) - handle.flush() - os.fsync(handle.fileno()) - os.replace(temporary, path) - finally: - temporary.unlink(missing_ok=True) - - def _registry_records( registry: dict[str, Any], *, @@ -311,7 +300,10 @@ def register_project_goal( raise ValueError( f"project_id already has its first registered goal: {project_id}" ) - with exclusive_file_lock(state_file, operation="project_register_state"): + effective_root = resolve_runtime_root(registry, str(runtime_root) if runtime_root else None, registry_path=registry_path) + with legacy_todo_write_transaction(registry_path, goal_id, state_file, None, "project_register_state", False, runtime_root=effective_root): + if not state_file.exists(): + require_legacy_state_replacement_allowed(runtime_root=effective_root, goal_id=goal_id, goal=existing_goal) if state_file.exists(): existing_state = state_file.read_text(encoding="utf-8") existing_updated_at = re.search( diff --git a/loopx/control_plane/todos/active_state_editing.py b/loopx/control_plane/todos/active_state_editing.py index a4eb752a8b..4b6c76c5ff 100644 --- a/loopx/control_plane/todos/active_state_editing.py +++ b/loopx/control_plane/todos/active_state_editing.py @@ -1,6 +1,10 @@ from __future__ import annotations +import os import re +import stat +import tempfile +from pathlib import Path from typing import Any from ..goals.active_state_metadata import todo_role_for_heading @@ -27,6 +31,32 @@ COMPLETED_WORK_ARCHIVE_HEADING = "Completed Work Archive" +def atomic_write_state_text(path: Path, text: str) -> None: + """Persist complete UTF-8 state while the caller holds its sibling lock.""" + + path.parent.mkdir(parents=True, exist_ok=True) + mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o600 + descriptor, temporary = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent) + ) + temporary_path = Path(temporary) + try: + with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as handle: + os.chmod(temporary_path, mode) + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_path, path) + if os.name == "posix": + parent = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(parent) + finally: + os.close(parent) + finally: + temporary_path.unlink(missing_ok=True) + + def section_bounds(lines: list[str], role: str) -> tuple[int, int, str] | None: if any(TODO_REGION_PREFIX in line for line in lines): region = next((r for r in find_todo_regions(lines) if r.role == role), None) diff --git a/loopx/control_plane/todos/event_writeback.py b/loopx/control_plane/todos/event_writeback.py index d81d95bfaf..875132823a 100644 --- a/loopx/control_plane/todos/event_writeback.py +++ b/loopx/control_plane/todos/event_writeback.py @@ -1,5 +1,10 @@ from __future__ import annotations +from contextlib import nullcontext +from ..coordination.legacy_writer_fence import legacy_todo_write_transaction, require_legacy_coordination_write_allowed +from ..coordination.shadow_management import require_shadow_primary_write_allowed +from ..coordination.local_authority_shadow_adapter import effective_runtime_root + import hashlib import re from pathlib import Path @@ -249,6 +254,8 @@ def event_projection_todo_context( "goal": goal, "fields": fields, "event_log_path": event_log_path, + "registry_path": registry_path, + "state_path": state_path, "role": matched_role, "item": matched_item, "raw_item": raw_item or matched_item, @@ -460,253 +467,266 @@ def complete_event_projected_goal_todo( completion_fence: dict[str, Any] | None = None, completion_state: Mapping[str, Any] | None = None, completion_validation_source_authority: dict[str, Any] | None = None, + runtime_root: Path | None = None, + primary_lock_held: bool = False, ) -> dict[str, Any]: - item = dict(context["item"]) - role = str(context["role"]) - todo_id = normalize_todo_id(item.get("todo_id")) - if not todo_id: - raise ValueError("event-projected todo has no stable todo_id") - if not event_projection_source_matches( - context, - completion_validation_source_authority, - ): - return _completion_validation_source_drift_failure( - goal_id=goal_id, - todo_id=todo_id, - dry_run=dry_run, + registry_path = Path(context["registry_path"]) + state_path = Path(context["state_path"]) + root = runtime_root or effective_runtime_root(registry_path, None) + transaction = nullcontext() if primary_lock_held else legacy_todo_write_transaction( + registry_path, goal_id, state_path, actor_agent_id, "todo_event_complete", + dry_run, runtime_root=root, + ) + with transaction: + if not dry_run: + require_shadow_primary_write_allowed(root, goal_id) + require_legacy_coordination_write_allowed(runtime_root=root, goal_id=goal_id) + item = dict(context["item"]) + role = str(context["role"]) + todo_id = normalize_todo_id(item.get("todo_id")) + if not todo_id: + raise ValueError("event-projected todo has no stable todo_id") + if not event_projection_source_matches( + context, + completion_validation_source_authority, + ): + return _completion_validation_source_drift_failure( + goal_id=goal_id, + todo_id=todo_id, + dry_run=dry_run, + ) + if clear_claim and item.get("claimed_by"): + item.pop("claimed_by", None) + effective_claimed_by = claimed_by or normalize_todo_claimed_by(item.get("claimed_by")) + store = AppendOnlyStateEventStore(Path(context["event_log_path"])) + if completion_fence is None or completion_state is None: + transaction = reduce_todo_completion_transaction( + todo=item, + projection_source="event_log", + completion_turn_key=completion_turn_key, + no_followup=no_followup, + goal_id=goal_id, + todo_id=todo_id, + completion_identity_source=completion_identity_source, + requested_has_successor=bool( + normalize_todo_id_list(successor_todo_ids) + or normalize_todo_id_list(item.get("successor_todo_ids")) + or next_agent_todo + or next_user_todo + ), + dry_run=dry_run, + ) + if transaction["decision"] in {"execute_validation", "reject"}: + raise RuntimeError( + "event-projected Todo completion validation must run through " + "the completion gate" + ) + completion_fence = dict(transaction["fence"]) + candidate_state = transaction.get("completion_state") + completion_state = ( + dict(candidate_state) + if isinstance(candidate_state, Mapping) + else None + ) + already_done = bool(completion_fence["terminal_before_request"]) + terminal_upgrade = completion_fence["reason"] in { + "same_turn_terminal_upgrade", + "lifecycle_reentry_terminal_upgrade", + } + untyped_completion_repair = ( + completion_fence["reason"] == "untyped_completion_repair" ) - if clear_claim and item.get("claimed_by"): - item.pop("claimed_by", None) - effective_claimed_by = claimed_by or normalize_todo_claimed_by(item.get("claimed_by")) - store = AppendOnlyStateEventStore(Path(context["event_log_path"])) - if completion_fence is None or completion_state is None: - transaction = reduce_todo_completion_transaction( - todo=item, - projection_source="event_log", - completion_turn_key=completion_turn_key, - no_followup=no_followup, - goal_id=goal_id, - todo_id=todo_id, - completion_identity_source=completion_identity_source, - requested_has_successor=bool( - normalize_todo_id_list(successor_todo_ids) - or normalize_todo_id_list(item.get("successor_todo_ids")) - or next_agent_todo - or next_user_todo - ), - dry_run=dry_run, + unscoped_identity_repair = ( + completion_fence["reason"] == "unscoped_completion_identity_repair" + ) + if completion_fence["outcome"] == "replay": + return { + "ok": True, + "dry_run": dry_run, + "completed": True, + "idempotent_replay": True, + "changed": False, + "goal_id": goal_id, + "role": role, + "section": TODO_SECTION_HEADINGS[role], + "todo": item.get("text") or item.get("title"), + "todo_id": todo_id, + "status": TODO_STATUS_DONE, + "completion_continuation": completion_fence.get( + "completion_continuation" + ), + "completion_recovery": item.get("completion_recovery"), + "status_changed": False, + "next_todos": [], + "state_file": str(context.get("state_file") or ""), + "project": str(context.get("project") or "") or None, + "updated_at": item.get("updated_at"), + "source": "event_log", + } + next_unblocks_todo_id = todo_id if next_agent_todo else None + next_user_bound_agent = effective_claimed_by + if next_user_todo and len(registered_agents) > 1: + if not next_user_bound_agent: + raise ValueError( + "multi-agent --next-user-todo requires a completing --claimed-by " + "agent so the user todo can be bound" + ) + + next_results: list[dict[str, Any]] = [] + if next_agent_todo: + next_results.append( + _append_event_projected_successor( + store=store, + goal_id=goal_id, + role="agent", + text=inherit_todo_priority(next_agent_todo, str(item.get("text") or "")), + updated_at=updated_at, + fields=context["fields"], + task_class=next_task_class or "advancement_task", + action_kind=next_action_kind, + capability_binding_ref=item.get("capability_binding_ref"), + task_repository=next_task_repository, + required_capabilities=next_required_capabilities, + continuation_policy=next_continuation_policy, + claimed_by=next_claimed_by, + excluded_agents=next_excluded_agents, + unblocks_todo_id=next_unblocks_todo_id, + dry_run=dry_run, + actor_agent_id=actor_agent_id, + ) + ) + if next_user_todo: + next_results.append( + _append_event_projected_successor( + store=store, + goal_id=goal_id, + role="user", + text=inherit_todo_priority(next_user_todo, str(item.get("text") or "")), + updated_at=updated_at, + fields=context["fields"], + task_class=next_user_task_class, + action_kind=( + "gate" if next_user_task_class == TODO_TASK_CLASS_USER_GATE else None + ), + capability_binding_ref=None, + task_repository=None, + required_capabilities=None, + continuation_policy=None, + claimed_by=None, + bound_agent=next_user_bound_agent, + blocks_agent=( + next_user_bound_agent + if next_user_task_class == TODO_TASK_CLASS_USER_GATE + else None + ), + unblocks_todo_id=None, + dry_run=dry_run, + actor_agent_id=actor_agent_id, + ) + ) + + normalized_successor_todo_ids = merge_todo_id_lists( + successor_todo_ids, + [item.get("todo_id") for item in next_results], + normalize_todo_id_list(item.get("successor_todo_ids")), ) - if transaction["decision"] in {"execute_validation", "reject"}: + if not isinstance(completion_state, Mapping): raise RuntimeError( - "event-projected Todo completion validation must run through " - "the completion gate" + "event-projected Todo completion requires the TypeScript " + "transaction state" ) - completion_fence = dict(transaction["fence"]) - candidate_state = transaction.get("completion_state") - completion_state = ( - dict(candidate_state) - if isinstance(candidate_state, Mapping) - else None + completion_continuation = completion_state.get("continuation") + completion_recovery = completion_state.get("recovery") + if completion_continuation not in { + "active_goal", + "successor", + "no_followup", + } or completion_recovery not in { + None, + "same_turn_terminal_closeout", + "lifecycle_reentry_terminal_closeout", + }: + raise RuntimeError( + "TypeScript Todo completion transaction state shape mismatch" + ) + completion_payload: dict[str, Any] = {"updated_at": updated_at} + completion_payload["completion_continuation"] = completion_continuation + if completion_recovery: + completion_payload["completion_recovery"] = completion_recovery + if not already_done: + completion_payload["completed_at"] = updated_at + if evidence: + completion_payload["evidence"] = evidence + if completion_turn_key: + completion_payload["completion_turn_key"] = completion_turn_key + if note: + completion_payload["note"] = note + if no_followup: + completion_payload["no_followup"] = "true" + if normalized_successor_todo_ids: + completion_payload["successor_todo_ids"] = normalized_successor_todo_ids + completion_event = make_state_event( + event_id=_todo_write_event_id( + goal_id=goal_id, + todo_id=todo_id, + action="complete", + updated_at=updated_at, + text=evidence or note, + ), + goal_id=goal_id, + event_type=TODO_COMPLETED, + refs={"todo_id": todo_id}, + payload=completion_payload, + recorded_at=updated_at, + producer="loopx.todo.complete", + actor_agent_id=actor_agent_id, ) - already_done = bool(completion_fence["terminal_before_request"]) - terminal_upgrade = completion_fence["reason"] in { - "same_turn_terminal_upgrade", - "lifecycle_reentry_terminal_upgrade", - } - untyped_completion_repair = ( - completion_fence["reason"] == "untyped_completion_repair" - ) - unscoped_identity_repair = ( - completion_fence["reason"] == "unscoped_completion_identity_repair" - ) - if completion_fence["outcome"] == "replay": - return { + if ( + not already_done + or terminal_upgrade + or untyped_completion_repair + or unscoped_identity_repair + ) and not dry_run: + store.append(completion_event) + + result = { "ok": True, "dry_run": dry_run, "completed": True, - "idempotent_replay": True, - "changed": False, "goal_id": goal_id, "role": role, "section": TODO_SECTION_HEADINGS[role], "todo": item.get("text") or item.get("title"), "todo_id": todo_id, "status": TODO_STATUS_DONE, - "completion_continuation": completion_fence.get( - "completion_continuation" + "status_changed": not already_done, + "text_changed": False, + "metadata_updated": ( + (not already_done) + or terminal_upgrade + or untyped_completion_repair + or unscoped_identity_repair + ), + "changed": ( + (not already_done) + or terminal_upgrade + or untyped_completion_repair + or unscoped_identity_repair + or bool(next_results) ), - "completion_recovery": item.get("completion_recovery"), - "status_changed": False, - "next_todos": [], + "claimed_by": normalize_todo_claimed_by(effective_claimed_by), + "task_class": item.get("task_class"), + "action_kind": item.get("action_kind"), + "capability_binding_ref": item.get("capability_binding_ref"), + "continuation_policy": item.get("continuation_policy"), + "successor_todo_ids": normalized_successor_todo_ids, + "completion_continuation": completion_continuation, + "completion_recovery": completion_recovery, + "next_todos": next_results, "state_file": str(context.get("state_file") or ""), "project": str(context.get("project") or "") or None, - "updated_at": item.get("updated_at"), + "updated_at": updated_at, "source": "event_log", } - next_unblocks_todo_id = todo_id if next_agent_todo else None - next_user_bound_agent = effective_claimed_by - if next_user_todo and len(registered_agents) > 1: - if not next_user_bound_agent: - raise ValueError( - "multi-agent --next-user-todo requires a completing --claimed-by " - "agent so the user todo can be bound" - ) - - next_results: list[dict[str, Any]] = [] - if next_agent_todo: - next_results.append( - _append_event_projected_successor( - store=store, - goal_id=goal_id, - role="agent", - text=inherit_todo_priority(next_agent_todo, str(item.get("text") or "")), - updated_at=updated_at, - fields=context["fields"], - task_class=next_task_class or "advancement_task", - action_kind=next_action_kind, - capability_binding_ref=item.get("capability_binding_ref"), - task_repository=next_task_repository, - required_capabilities=next_required_capabilities, - continuation_policy=next_continuation_policy, - claimed_by=next_claimed_by, - excluded_agents=next_excluded_agents, - unblocks_todo_id=next_unblocks_todo_id, - dry_run=dry_run, - actor_agent_id=actor_agent_id, - ) - ) - if next_user_todo: - next_results.append( - _append_event_projected_successor( - store=store, - goal_id=goal_id, - role="user", - text=inherit_todo_priority(next_user_todo, str(item.get("text") or "")), - updated_at=updated_at, - fields=context["fields"], - task_class=next_user_task_class, - action_kind=( - "gate" if next_user_task_class == TODO_TASK_CLASS_USER_GATE else None - ), - capability_binding_ref=None, - task_repository=None, - required_capabilities=None, - continuation_policy=None, - claimed_by=None, - bound_agent=next_user_bound_agent, - blocks_agent=( - next_user_bound_agent - if next_user_task_class == TODO_TASK_CLASS_USER_GATE - else None - ), - unblocks_todo_id=None, - dry_run=dry_run, - actor_agent_id=actor_agent_id, - ) - ) - - normalized_successor_todo_ids = merge_todo_id_lists( - successor_todo_ids, - [item.get("todo_id") for item in next_results], - normalize_todo_id_list(item.get("successor_todo_ids")), - ) - if not isinstance(completion_state, Mapping): - raise RuntimeError( - "event-projected Todo completion requires the TypeScript " - "transaction state" - ) - completion_continuation = completion_state.get("continuation") - completion_recovery = completion_state.get("recovery") - if completion_continuation not in { - "active_goal", - "successor", - "no_followup", - } or completion_recovery not in { - None, - "same_turn_terminal_closeout", - "lifecycle_reentry_terminal_closeout", - }: - raise RuntimeError( - "TypeScript Todo completion transaction state shape mismatch" - ) - completion_payload: dict[str, Any] = {"updated_at": updated_at} - completion_payload["completion_continuation"] = completion_continuation - if completion_recovery: - completion_payload["completion_recovery"] = completion_recovery - if not already_done: - completion_payload["completed_at"] = updated_at - if evidence: - completion_payload["evidence"] = evidence - if completion_turn_key: - completion_payload["completion_turn_key"] = completion_turn_key - if note: - completion_payload["note"] = note - if no_followup: - completion_payload["no_followup"] = "true" - if normalized_successor_todo_ids: - completion_payload["successor_todo_ids"] = normalized_successor_todo_ids - completion_event = make_state_event( - event_id=_todo_write_event_id( - goal_id=goal_id, - todo_id=todo_id, - action="complete", - updated_at=updated_at, - text=evidence or note, - ), - goal_id=goal_id, - event_type=TODO_COMPLETED, - refs={"todo_id": todo_id}, - payload=completion_payload, - recorded_at=updated_at, - producer="loopx.todo.complete", - actor_agent_id=actor_agent_id, - ) - if ( - not already_done - or terminal_upgrade - or untyped_completion_repair - or unscoped_identity_repair - ) and not dry_run: - store.append(completion_event) - - result = { - "ok": True, - "dry_run": dry_run, - "completed": True, - "goal_id": goal_id, - "role": role, - "section": TODO_SECTION_HEADINGS[role], - "todo": item.get("text") or item.get("title"), - "todo_id": todo_id, - "status": TODO_STATUS_DONE, - "status_changed": not already_done, - "text_changed": False, - "metadata_updated": ( - (not already_done) - or terminal_upgrade - or untyped_completion_repair - or unscoped_identity_repair - ), - "changed": ( - (not already_done) - or terminal_upgrade - or untyped_completion_repair - or unscoped_identity_repair - or bool(next_results) - ), - "claimed_by": normalize_todo_claimed_by(effective_claimed_by), - "task_class": item.get("task_class"), - "action_kind": item.get("action_kind"), - "capability_binding_ref": item.get("capability_binding_ref"), - "continuation_policy": item.get("continuation_policy"), - "successor_todo_ids": normalized_successor_todo_ids, - "completion_continuation": completion_continuation, - "completion_recovery": completion_recovery, - "next_todos": next_results, - "state_file": str(context.get("state_file") or ""), - "project": str(context.get("project") or "") or None, - "updated_at": updated_at, - "source": "event_log", - } - result["self_merged"] = self_merged - return result + result["self_merged"] = self_merged + return result diff --git a/loopx/control_plane/todos/handoff_mode.py b/loopx/control_plane/todos/handoff_mode.py index 80e0af5d46..30a7c70718 100644 --- a/loopx/control_plane/todos/handoff_mode.py +++ b/loopx/control_plane/todos/handoff_mode.py @@ -421,6 +421,13 @@ def set_goal_handoff_mode( runtime_root_from_registry, task_lease_lock_path, ) + from ..coordination.legacy_writer_fence import legacy_todo_write_transaction + from ..coordination.runtime_shadow_writer_adapter import ( + require_runtime_shadow_capture_prepared, + begin_todo_runtime_shadow_capture, + settle_todo_runtime_shadow_capture, + ) + from .active_state_editing import atomic_write_state_text requested = normalize_handoff_mode(mode) if not str(mode or "").strip(): @@ -437,7 +444,10 @@ def set_goal_handoff_mode( # One effective runtime root for the lease lock, the quiescence scan, and # the post-commit observation of this call. runtime_root = runtime_root_from_registry(registry_path, runtime_root_arg) - with exclusive_file_lock(resolved_state_file, operation="handoff_mode_set"): + with legacy_todo_write_transaction( + registry_path, goal_id, resolved_state_file, None, "handoff_mode_set", + False, runtime_root=runtime_root, + ): original = resolved_state_file.read_text(encoding="utf-8") previous, previous_mode_fields = _previous_handoff_mode_fields( parse_state_frontmatter(original).get(HANDOFF_MODE_FRONTMATTER_KEY) @@ -454,6 +464,11 @@ def set_goal_handoff_mode( if previous == requested: payload["changed"] = False return payload + capture = begin_todo_runtime_shadow_capture( + registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_id, + state_path=resolved_state_file, write_class="handoff_mode_set", + original_text=original, + ) lease_lock = task_lease_lock_path(runtime_root=runtime_root, goal_id=goal_id) with exclusive_file_lock(lease_lock, operation="handoff_mode_set"): claimed, leases = _quiescence_offenders( @@ -518,11 +533,12 @@ def set_goal_handoff_mode( lines = original.splitlines() _write_handoff_mode_frontmatter(lines, requested) new_text = "\n".join(lines) + ("\n" if original.endswith("\n") else "") - resolved_state_file.write_text(new_text, encoding="utf-8") + capture.prepare(new_text) + require_runtime_shadow_capture_prepared(capture, runtime_root=runtime_root, goal_id=goal_id) + atomic_write_state_text(resolved_state_file, new_text) + capture.committed() payload["changed"] = True - from ..coordination.local_authority_shadow_adapter import ( - observe_local_authority_commit, - ) + from ..coordination.local_authority_shadow_observation import observe_local_authority_commit evidence = observe_local_authority_commit( registry_path=registry_path, @@ -532,4 +548,8 @@ def set_goal_handoff_mode( ) if evidence is not None: payload["authority_shadow"] = evidence - return payload + return settle_todo_runtime_shadow_capture( + payload, registry_path=registry_path, runtime_root=runtime_root, + goal_id=goal_id, write_class="handoff_mode_set", capture=capture, + observe_legacy=False, emit_disabled=False, + ) diff --git a/loopx/control_plane/work_items/task_lease_acquire.ts b/loopx/control_plane/work_items/task_lease_acquire.ts index 033e3a2d94..bd0c786b47 100644 --- a/loopx/control_plane/work_items/task_lease_acquire.ts +++ b/loopx/control_plane/work_items/task_lease_acquire.ts @@ -1,3 +1,5 @@ +import { ShadowManagementError, requireShadowPrimaryWriteAllowed } from "../coordination/shadow_management.ts"; +import { LegacyCoordinationWriteError, requireLegacyCoordinationPrimaryWriteAllowed } from "../coordination/legacy_writer_fence.ts"; import { createHash } from "node:crypto"; import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; @@ -8,10 +10,9 @@ import { } from "../effect_runtime_errors.ts"; import { atomicWriteJson, withFileMutationLock } from "../effect_runtime_io.ts"; import { - checkLegacyCoordinationWriteAllowed, legacyCoordinationLeaseLockPath, - LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA, -} from "../coordination/legacy_writer_fence.ts"; + taskLeaseLockPath, +} from "../coordination/legacy_writer_lock_paths.ts"; import { settlementIdentity, type JsonObject, @@ -460,9 +461,7 @@ export function taskLeasePath(request: { runtime_root: string; goal_id: string; return join(taskLeaseDirectory(request), `${request.todo_id}.json`); } -export function taskLeaseLockPath(request: { runtime_root: string; goal_id: string }): string { - return join(taskLeaseDirectory(request), ".task-leases"); -} +export { taskLeaseLockPath } from "../coordination/legacy_writer_lock_paths.ts"; function executionContext(value: unknown): ExecutionContext { const context: ExecutionContext = { effectId: null, leasePath: null }; @@ -1185,7 +1184,9 @@ function failureEnvelope( failure: TaskLeaseFailure, context: ExecutionContext, ): TaskLeaseAcquireEnvelope { - const step = VALIDATION_FAILURE_CODES.has(failure.code) + const step = (VALIDATION_FAILURE_CODES.has(failure.code) + || failure.code.startsWith("shadow_management_") + || failure.code.startsWith("legacy_")) ? "validation" : "durable_writeback"; const kind = failureKind(failure.code); @@ -1344,7 +1345,9 @@ async function commitAcquire( }; await dependencies.beforeWrite?.(lease); await revalidateAuthoritySources(request.authority.source_receipts); - const shadowCapture = request.runtime_shadow === null + const captureRequired = request.runtime_shadow !== null || + await requireShadowPrimaryWriteAllowed(request.runtime_root, request.goal_id) !== null; + const shadowCapture = !captureRequired ? null : await beginLeaseOutboxEntry({ runtime_root: request.runtime_root, @@ -1355,6 +1358,9 @@ async function commitAcquire( previous_lease: existing, planned_lease: lease, }); + if (shadowCapture?.failure && await requireShadowPrimaryWriteAllowed(request.runtime_root, request.goal_id) !== null) { + throw new ShadowManagementError("shadow_capture_prepare_failed", "durable shadow preparation failed; the primary lease was not changed"); + } await atomicWriteJson(leasePath, lease); await shadowCapture?.commit(); const response = successEnvelope(request, lease, leasePath, acquireEffectId(request), false); @@ -1364,6 +1370,7 @@ async function commitAcquire( seq: shadowCapture.seq, source_bytes_digest: shadowCapture.source_bytes_digest, failure: shadowCapture.failure, + skipped_reason: shadowCapture.skipped_reason, }; } return response; @@ -1378,7 +1385,7 @@ export async function executeTaskLeaseAcquire( try { request = decodeRequest(value); } catch (error) { - if (error instanceof TaskLeaseAcquireError) { + if (error instanceof TaskLeaseAcquireError || error instanceof ShadowManagementError || error instanceof LegacyCoordinationWriteError) { return failureEnvelope( { code: error.code, message: error.message, payload: error.payload }, context, @@ -1391,25 +1398,12 @@ export async function executeTaskLeaseAcquire( return await withFileMutationLock( legacyCoordinationLeaseLockPath(request.runtime_root, request.goal_id), () => withFileMutationLock(taskLeaseLockPath(request), async () => { - const writerGuard = await checkLegacyCoordinationWriteAllowed({ - schema_version: LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA, - runtime_root: request.runtime_root, - goal_id: request.goal_id, - }); - if (writerGuard.status !== "allowed") { - throw new TaskLeaseAcquireError( - writerGuard.status === "blocked" - ? "legacy task-lease writer is fenced; use the canonical file authority" - : String(writerGuard.reason ?? "legacy writer fence check failed"), - String(writerGuard.reason_code ?? "legacy_writer_fence_check_failed"), - writerGuard, - ); - } + await requireLegacyCoordinationPrimaryWriteAllowed(request.runtime_root, request.goal_id); return await commitAcquire(request, dependencies); }), ); } catch (error) { - if (error instanceof TaskLeaseAcquireError) { + if (error instanceof TaskLeaseAcquireError || error instanceof ShadowManagementError || error instanceof LegacyCoordinationWriteError) { return failureEnvelope( { code: error.code, message: error.message, payload: error.payload }, context, diff --git a/loopx/control_plane/work_items/task_lease_acquire_adapter.py b/loopx/control_plane/work_items/task_lease_acquire_adapter.py index f7037833ee..c84e64f458 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_adapter.py +++ b/loopx/control_plane/work_items/task_lease_acquire_adapter.py @@ -54,9 +54,7 @@ def _attach_local_authority_shadow( str(lease.get("updated_at") or lease.get("released_at") or "unknown"), ) ) - from ..coordination.local_authority_shadow_adapter import ( - observe_local_authority_commit, - ) + from ..coordination.local_authority_shadow_observation import observe_local_authority_commit evidence = observe_local_authority_commit( registry_path=registry_path, diff --git a/loopx/control_plane/work_items/task_lease_lifecycle.ts b/loopx/control_plane/work_items/task_lease_lifecycle.ts index 2f8990a137..519aca09cb 100644 --- a/loopx/control_plane/work_items/task_lease_lifecycle.ts +++ b/loopx/control_plane/work_items/task_lease_lifecycle.ts @@ -1,3 +1,5 @@ +import { ShadowManagementError, requireShadowPrimaryWriteAllowed } from "../coordination/shadow_management.ts"; +import { LegacyCoordinationWriteError, requireLegacyCoordinationPrimaryWriteAllowed } from "../coordination/legacy_writer_fence.ts"; import { createHash, randomUUID } from "node:crypto"; import { readFile, readdir } from "node:fs/promises"; import { join } from "node:path"; @@ -20,9 +22,7 @@ import { type FileMutationLockClaim, } from "../effect_runtime_io.ts"; import { - checkLegacyCoordinationWriteAllowed, legacyCoordinationLeaseLockPath, - LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA, } from "../coordination/legacy_writer_fence.ts"; import { settlementIdentity, @@ -489,8 +489,9 @@ async function captureLeaseWrite( next: LeaseRecord, writeClass: string, ): Promise> | null> { - if (request.runtime_shadow === null) return null; - return await beginLeaseOutboxEntry({ + if (request.runtime_shadow === null && + await requireShadowPrimaryWriteAllowed(request.runtime_root, request.goal_id) === null) return null; + const capture = await beginLeaseOutboxEntry({ runtime_root: request.runtime_root, goal_id: request.goal_id, lease_directory: taskLeaseDirectory(request), @@ -499,6 +500,10 @@ async function captureLeaseWrite( previous_lease: previous, planned_lease: next, }); + if (capture.failure && await requireShadowPrimaryWriteAllowed(request.runtime_root, request.goal_id) !== null) { + throw new ShadowManagementError("shadow_capture_prepare_failed", "durable shadow preparation failed; the primary lease was not changed"); + } + return capture; } function attachRuntimeShadowCapture( @@ -513,6 +518,7 @@ function attachRuntimeShadowCapture( seq: capture.seq, source_bytes_digest: capture.source_bytes_digest, failure: capture.failure, + skipped_reason: capture.skipped_reason, }, }; } @@ -1293,6 +1299,9 @@ function failureRequestContext(value: unknown): Partial | null } function errorInfo(error: unknown, fallbackStage: LifecycleStage = "durable_writeback"): LifecycleErrorInfo { + if (error instanceof ShadowManagementError || error instanceof LegacyCoordinationWriteError) { + return {code: error.code, message: error.message, payload: error.payload, stage: "validation"}; + } if (error instanceof TaskLeaseLifecycleError) { return { code: error.code, @@ -2069,6 +2078,7 @@ async function fenceVerify( } else { receipt = lockedReceipt; } + await requireShadowPrimaryWriteAllowed(request.runtime_root, request.goal_id); const initialTodo = authorityTodo(request); // Publish the token before semantic validation. If the response is lost // after acquisition, a retry can adopt this exact lock and finish the @@ -2240,6 +2250,7 @@ async function fenceVerify( const explicitFence = request.idempotency_key !== null || request.expected_version !== null; const autoAcquire = allowsUserGateAutoAcquire(request, todo); if (autoAcquire && !effective && !active) { + await requireLegacyCoordinationPrimaryWriteAllowed(request.runtime_root, request.goal_id); if (!request.owner) { throw new TaskLeaseLifecycleError("hard_lease handoff mode auto-acquire requires an attributed actor; provide --agent-id", "handoff_mode_requires_lease", { goal_id: request.goal_id, todo_id: request.todo_id, reason: "missing_actor", lease_path: leasePath }); } @@ -2615,6 +2626,7 @@ async function fenceClose( } let shadowCapture: LeaseOutboxCapture | null = null; if (request.committed && request.release_lease) { + await requireLegacyCoordinationPrimaryWriteAllowed(request.runtime_root, request.goal_id); const at = lifecycleNow(request, dependencies); const leasePath = leasePathFor(request); const rawLease = await readLease(leasePath); @@ -2758,20 +2770,7 @@ export async function executeTaskLeaseLifecycle( return await withFileMutationLock( legacyCoordinationLeaseLockPath(request.runtime_root, request.goal_id), () => withFileMutationLock(lockPathFor(request!), async () => { - const writerGuard = await checkLegacyCoordinationWriteAllowed({ - schema_version: LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA, - runtime_root: request!.runtime_root, - goal_id: request!.goal_id, - }); - if (writerGuard.status !== "allowed") { - throw new TaskLeaseLifecycleError( - writerGuard.status === "blocked" - ? "legacy task-lease writer is fenced; use the canonical file authority" - : String(writerGuard.reason ?? "legacy writer fence check failed"), - String(writerGuard.reason_code ?? "legacy_writer_fence_check_failed"), - writerGuard, - ); - } + await requireLegacyCoordinationPrimaryWriteAllowed(request!.runtime_root, request!.goal_id); return await ordinaryOperation(request!, dependencies); }), ); diff --git a/loopx/feedback.py b/loopx/feedback.py index d118c4f3cb..f0f4d2919a 100644 --- a/loopx/feedback.py +++ b/loopx/feedback.py @@ -1,5 +1,11 @@ from __future__ import annotations +from contextlib import nullcontext + +from .file_lock import exclusive_cross_runtime_file_lock +from .control_plane.coordination.runtime_shadow_writer_adapter import require_prose_state_write_allowed +from .control_plane.todos.active_state_editing import atomic_write_state_text + import json import re from datetime import datetime, timezone @@ -390,6 +396,13 @@ def plan_active_state_update( return update, state_file, updated_text if changed and not dry_run else None +class HumanRewardSummaryWriteError(RuntimeError): + """The reward overlay committed, but its optional summary did not settle.""" + + code = "active_state_summary_write_failed" + payload = {"appended": True, "active_state_summary_written": False} + + def append_human_reward( *, registry_path: Path, @@ -403,7 +416,7 @@ def append_human_reward( ) -> dict[str, Any]: validate_goal_id(goal_id) registry = load_registry(registry_path) - runtime_root = resolve_runtime_root(registry, runtime_root_override) + runtime_root = resolve_runtime_root(registry, runtime_root_override, registry_path=registry_path) index_path = runtime_root / "goals" / goal_id / "runs" / "index.jsonl" runs, raw_count = load_index(index_path) selected = select_run(runs, run_generated_at) @@ -458,13 +471,32 @@ def append_human_reward( ) if not dry_run: - index_path.parent.mkdir(parents=True, exist_ok=True) - with index_path.open("a", encoding="utf-8") as f: - f.write(json.dumps(index_record, ensure_ascii=False) + "\n") - if state_file_to_write and state_text_to_write is not None: - state_file_to_write.write_text(state_text_to_write, encoding="utf-8") - active_state_update["written"] = True - active_state_update["would_write"] = False + state_lock = ( + exclusive_cross_runtime_file_lock(state_file_to_write, operation="reward_summary") + if state_file_to_write is not None else nullcontext() + ) + with state_lock: + if state_file_to_write is not None: + original = state_file_to_write.read_text(encoding="utf-8") + planned, changed = insert_progress_ledger_entry( + original, str(active_state_update["entry"]), + updated_at=str(reward.get("recorded_at") or now_local()), + ) + require_prose_state_write_allowed( + registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_id, + state_path=state_file_to_write, original_text=original, planned_text=planned, + ) + state_text_to_write = planned if changed else None + index_path.parent.mkdir(parents=True, exist_ok=True) + with index_path.open("a", encoding="utf-8") as f: + f.write(json.dumps(index_record, ensure_ascii=False) + "\n") + if state_file_to_write and state_text_to_write is not None: + try: + atomic_write_state_text(state_file_to_write, state_text_to_write) + except OSError as error: + raise HumanRewardSummaryWriteError(str(error)) from error + active_state_update["written"] = True + active_state_update["would_write"] = False return { "ok": True, diff --git a/loopx/file_lock.py b/loopx/file_lock.py index c5fc773426..d7d066ced9 100644 --- a/loopx/file_lock.py +++ b/loopx/file_lock.py @@ -752,20 +752,20 @@ def _release_effect_mutation_claim( _remove_created_effect_file(path, identity) -def _reclaim_stale_effect_mutation_lock(path: Path) -> None: +def _reclaim_stale_effect_mutation_lock(path: Path) -> bool: identity = _effect_file_identity(path) if identity is None: - return + return False owner = _read_effect_mutation_owner(path) if owner is not None and _effect_mutation_process_is_alive(owner.get("pid")): - return + return False if owner is None: try: age_seconds = time.time() - path.stat().st_mtime except OSError: - return + return False if age_seconds < EFFECT_MUTATION_INVALID_STALE_SECONDS: - return + return False claim_token = ( str(owner["token"]) if owner is not None @@ -773,37 +773,38 @@ def _reclaim_stale_effect_mutation_lock(path: Path) -> None: ) claim = _claim_effect_mutation_lock(path, claim_token) if claim is None: - return + return False stale_path = path.with_name(f"{path.name}.stale.{uuid4()}") try: current = _read_effect_mutation_owner(path) if owner is not None and ( current is None or current.get("token") != owner.get("token") ): - return + return False if current is not None and _effect_mutation_process_is_alive( current.get("pid") ): - return + return False if current is None: try: if ( time.time() - path.stat().st_mtime < EFFECT_MUTATION_INVALID_STALE_SECONDS ): - return + return False except OSError: - return + return False if not _same_effect_file_identity(identity, _effect_file_identity(path)): - return + return False path.replace(stale_path) except FileNotFoundError: - return + return False finally: if claim is not None: _release_effect_mutation_claim(claim) stale_path.unlink(missing_ok=True) + return True def _release_effect_mutation_lock( path: Path, @@ -911,6 +912,7 @@ def exclusive_cross_runtime_file_lock( started = time.monotonic() started_at = _utc_now_iso() deadline = started + timeout + retried_reclaimed_lock = False while True: try: descriptor = os.open( @@ -919,7 +921,12 @@ def exclusive_cross_runtime_file_lock( 0o600, ) except FileExistsError: - _reclaim_stale_effect_mutation_lock(effect_lock_path) + reclaimed = _reclaim_stale_effect_mutation_lock(effect_lock_path) + # A zero-wait probe gets one immediate acquisition attempt after + # proving and removing a dead owner. It never waits for a live one. + if reclaimed and not retried_reclaimed_lock: + retried_reclaimed_lock = True + continue now = time.monotonic() if now >= deadline: raise _timeout_error( diff --git a/loopx/state_migration.py b/loopx/state_migration.py index 40a8719ac0..1858a46bd6 100644 --- a/loopx/state_migration.py +++ b/loopx/state_migration.py @@ -1,13 +1,20 @@ from __future__ import annotations import copy +from contextlib import ExitStack import json +import hashlib import shutil from datetime import datetime, timezone from pathlib import Path from typing import Any -from .global_registry import write_json +from .registry import atomic_write_json as write_json, find_registry_goal +from .file_lock import exclusive_cross_runtime_file_lock +from .paths import resolve_runtime_root +from .control_plane.todos.active_state_editing import atomic_write_state_text +from .control_plane.coordination.legacy_writer_fence import legacy_coordination_todo_lock_path, require_legacy_state_replacement_allowed +from .control_plane.work_items.task_lease import task_lease_lock_path from .registry import registry_goals from .control_plane.coordination.coordination_state_contract_generated import ( LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA as AUTHORITY_SHADOW_CONFIG_SCHEMA, @@ -129,7 +136,7 @@ def copy_rewritten_text_file(source: Path, target: Path, *, goal_id_map: dict[st except UnicodeDecodeError: shutil.copy2(source, target) return - target.write_text(rewrite_text(text, goal_id_map=goal_id_map, path_map=path_map), encoding="utf-8") + atomic_write_state_text(target, rewrite_text(text, goal_id_map=goal_id_map, path_map=path_map)) def copy_active_state_files( @@ -293,9 +300,7 @@ def seed_migrated_authority_shadows( ) continue try: - from .control_plane.coordination.local_authority_shadow_adapter import ( - observe_local_authority_commit, - ) + from .control_plane.coordination.local_authority_shadow_observation import observe_local_authority_commit result = observe_local_authority_commit( registry_path=target_registry_path, @@ -339,85 +344,114 @@ def migrate_legacy_state( if not legacy_registry_path.exists(): raise FileNotFoundError(f"legacy registry does not exist: {legacy_registry_path}") - source_registry = read_json_object(legacy_registry_path) - source_by_id = {str(goal.get("id")): goal for goal in registry_goals(source_registry)} - missing = [goal_id for goal_id in goal_ids if goal_id not in source_by_id] - if missing: - raise ValueError(f"goal id not found in legacy registry: {', '.join(missing)}") - - selected_pairs: list[tuple[dict[str, Any], dict[str, Any]]] = [] - for old_goal_id in goal_ids: - source_goal = source_by_id[old_goal_id] - migrated = rewrite_value(source_goal, goal_id_map=goal_id_map, path_map=path_map) - if not isinstance(migrated, dict): - raise ValueError(f"migrated goal is not an object: {old_goal_id}") - migrated["id"] = goal_id_map.get(old_goal_id, str(migrated.get("id") or old_goal_id)) - selected_pairs.append((source_goal, project_local_goal(migrated))) - - existing_registry = read_json_object(target_registry_path) if target_registry_path.exists() else {} - existing_goals = existing_registry.get("goals") - if not isinstance(existing_goals, list): - existing_goals = [] - incoming_goals = [target for _, target in selected_pairs] - target_payload = dict(existing_registry) - target_payload["schema_version"] = str(target_payload.get("schema_version") or source_registry.get("schema_version") or "0.1") - target_payload["updated_at"] = now_local() - target_payload["common_runtime_root"] = str(target_runtime_root) - target_payload.pop("registry_role", None) - target_payload["goals"] = merge_goals(existing_goals, incoming_goals) - - active_state_results = ( - copy_active_state_files( - selected_pairs, - goal_id_map=goal_id_map, - path_map=path_map, - execute=execute, + with ExitStack() as locks: + if execute: + for path in sorted({legacy_registry_path.resolve(), target_registry_path.resolve()}, key=str): + locks.enter_context(exclusive_cross_runtime_file_lock(path, operation="state_migration_registry")) + source_registry = read_json_object(legacy_registry_path) + source_by_id = {str(goal.get("id")): goal for goal in registry_goals(source_registry)} + missing = [goal_id for goal_id in goal_ids if goal_id not in source_by_id] + if missing: + raise ValueError(f"goal id not found in legacy registry: {', '.join(missing)}") + + selected_pairs: list[tuple[dict[str, Any], dict[str, Any]]] = [] + for old_goal_id in goal_ids: + source_goal = source_by_id[old_goal_id] + migrated = rewrite_value(source_goal, goal_id_map=goal_id_map, path_map=path_map) + if not isinstance(migrated, dict): + raise ValueError(f"migrated goal is not an object: {old_goal_id}") + migrated["id"] = goal_id_map.get(old_goal_id, str(migrated.get("id") or old_goal_id)) + selected_pairs.append((source_goal, project_local_goal(migrated))) + + existing_registry = read_json_object(target_registry_path) if target_registry_path.exists() else {} + existing_goals = existing_registry.get("goals") + if not isinstance(existing_goals, list): + existing_goals = [] + incoming_goals = [target for _, target in selected_pairs] + target_payload = dict(existing_registry) + target_payload["schema_version"] = str(target_payload.get("schema_version") or source_registry.get("schema_version") or "0.1") + target_payload["updated_at"] = now_local() + target_payload["common_runtime_root"] = str(target_runtime_root) + target_payload.pop("registry_role", None) + target_payload["goals"] = merge_goals(existing_goals, incoming_goals) + + if execute: + endpoints: dict[tuple[str, str], tuple[Path, str, dict[str, Any] | None, Path | None]] = {} + for source_goal, target_goal in selected_pairs: + old_id, new_id = str(source_goal["id"]), str(target_goal["id"]) + endpoints[(str(legacy_runtime_root.resolve()), old_id)] = (legacy_runtime_root, old_id, source_goal, resolve_goal_state(source_goal.get("repo"), source_goal.get("state_file"))) + endpoints[(str(target_runtime_root.resolve()), new_id)] = (target_runtime_root, new_id, find_registry_goal(existing_registry, new_id), resolve_goal_state(target_goal.get("repo"), target_goal.get("state_file"))) + ordered = [endpoints[key] for key in sorted(endpoints)] + for root, identity, _goal, _state in ordered: + locks.enter_context(exclusive_cross_runtime_file_lock(legacy_coordination_todo_lock_path(runtime_root=root, goal_id=identity), operation="state_migration_todo")) + for state_path in sorted({entry[3].resolve() for entry in ordered if entry[3] is not None}, key=str): + locks.enter_context(exclusive_cross_runtime_file_lock(state_path, operation="state_migration_state")) + if copy_runtime: + for root, identity, _goal, _state in ordered: + digest = hashlib.sha256(identity.encode()).hexdigest()[:16] + target = root / "authority-transition" / "file-v0" / f"legacy-task-lease-writer-{digest}" + locks.enter_context(exclusive_cross_runtime_file_lock(target, operation="state_migration_leases")) + for root, identity, _goal, _state in ordered: + locks.enter_context(exclusive_cross_runtime_file_lock(task_lease_lock_path(runtime_root=root, goal_id=identity), operation="state_migration_leases")) + for root, identity, goal, _state in ordered: + require_legacy_state_replacement_allowed(runtime_root=root, goal_id=identity, goal=goal) + previous_root = resolve_runtime_root(existing_registry, None, registry_path=target_registry_path) + if previous_root != target_runtime_root: + for previous_goal in registry_goals(existing_registry): + require_legacy_state_replacement_allowed(runtime_root=previous_root, goal_id=str(previous_goal["id"]), goal=previous_goal) + + active_state_results = ( + copy_active_state_files( + selected_pairs, + goal_id_map=goal_id_map, + path_map=path_map, + execute=execute, + ) + if copy_active_state + else [] ) - if copy_active_state - else [] - ) - runtime_results = ( - copy_runtime_goal_dirs( - legacy_runtime_root=legacy_runtime_root, - target_runtime_root=target_runtime_root, - goal_id_map=goal_id_map, - selected_old_goal_ids=goal_ids, - path_map=path_map, - execute=execute, + runtime_results = ( + copy_runtime_goal_dirs( + legacy_runtime_root=legacy_runtime_root, + target_runtime_root=target_runtime_root, + goal_id_map=goal_id_map, + selected_old_goal_ids=goal_ids, + path_map=path_map, + execute=execute, + ) + if copy_runtime + else [] ) - if copy_runtime - else [] - ) - if execute: - write_json(target_registry_path, target_payload) + if execute: + write_json(target_registry_path, target_payload) - authority_shadow_seeds = seed_migrated_authority_shadows( - goals=incoming_goals, - target_registry_path=target_registry_path, - target_runtime_root=target_runtime_root, - execute=execute, - ) + authority_shadow_seeds = seed_migrated_authority_shadows( + goals=incoming_goals, + target_registry_path=target_registry_path, + target_runtime_root=target_runtime_root, + execute=execute, + ) - return { - "ok": True, - "schema_version": "loopx_state_migration_v0", - "dry_run": not execute, - "execute": execute, - "legacy_registry": str(legacy_registry_path), - "target_registry": str(target_registry_path), - "legacy_runtime_root": str(legacy_runtime_root), - "target_runtime_root": str(target_runtime_root), - "selected_goal_ids": goal_ids, - "migrated_goal_ids": [goal.get("id") for goal in incoming_goals], - "goal_id_map": goal_id_map, - "path_map": path_map, - "wrote_project_registry": execute, - "project_registry_goal_count": len(target_payload.get("goals", [])), - "active_state": active_state_results, - "runtime_goals": runtime_results, - "authority_shadow_seeds": authority_shadow_seeds, - } + return { + "ok": True, + "schema_version": "loopx_state_migration_v0", + "dry_run": not execute, + "execute": execute, + "legacy_registry": str(legacy_registry_path), + "target_registry": str(target_registry_path), + "legacy_runtime_root": str(legacy_runtime_root), + "target_runtime_root": str(target_runtime_root), + "selected_goal_ids": goal_ids, + "migrated_goal_ids": [goal.get("id") for goal in incoming_goals], + "goal_id_map": goal_id_map, + "path_map": path_map, + "wrote_project_registry": execute, + "project_registry_goal_count": len(target_payload.get("goals", [])), + "active_state": active_state_results, + "runtime_goals": runtime_results, + "authority_shadow_seeds": authority_shadow_seeds, + } def render_state_migration_markdown(payload: dict[str, Any]) -> str: diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index 4630d393cf..5e93832f7c 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -67,7 +67,9 @@ resolve_runtime_projection_route, ) from .feedback import validate_local_control_text, validate_public_safe_text -from .file_lock import exclusive_file_lock +from .file_lock import exclusive_file_lock, exclusive_cross_runtime_file_lock +from .control_plane.coordination.runtime_shadow_writer_adapter import require_prose_state_write_allowed +from .control_plane.todos.active_state_editing import atomic_write_state_text from .global_registry import sync_project_registry_to_global from .history import ( load_index, @@ -862,7 +864,7 @@ def refresh_state_run( "blocked outcome_gap settlement" ) registry = load_registry(registry_path) - runtime_root = resolve_runtime_root(registry, runtime_root_override) + runtime_root = resolve_runtime_root(registry, runtime_root_override, registry_path=registry_path) settlement_identity = None settlement_result = None delivery_workspace_causality = None @@ -1053,7 +1055,7 @@ def refresh_state_run( generated_at = now_local() active_state_next_action_update: dict[str, Any] | None = None if normalized_next_action: - with exclusive_file_lock(resolved_state_file): + with exclusive_cross_runtime_file_lock(resolved_state_file): locked_state_text = resolved_state_file.read_text(encoding="utf-8") expected_write_state_text = locked_state_text updated_state_text, state_updated = replace_next_action_section( @@ -1218,14 +1220,19 @@ def refresh_state_run( and active_state_next_action_update.get("would_update") and not dry_run ): - with exclusive_file_lock(resolved_state_file): + with exclusive_cross_runtime_file_lock(resolved_state_file): current_state_text = resolved_state_file.read_text(encoding="utf-8") if current_state_text != expected_write_state_text: raise ValueError( "active goal state changed while refresh-state was qualifying " "its semantic writeback; retry from the current state" ) - resolved_state_file.write_text(state_text, encoding="utf-8") + require_prose_state_write_allowed( + registry_path=registry_path, runtime_root=runtime_root, + goal_id=safe_goal_id, state_path=resolved_state_file, + original_text=current_state_text, planned_text=state_text, + ) + atomic_write_state_text(resolved_state_file, state_text) record = build_state_refresh_record( goal_id=safe_goal_id, state_file=resolved_state_file, diff --git a/loopx/status_server.py b/loopx/status_server.py index 0a0ba00454..130536262e 100644 --- a/loopx/status_server.py +++ b/loopx/status_server.py @@ -384,6 +384,7 @@ def _handle_reward_append(self) -> None: "dry_run": False, "appended": False, "error": str(exc), + **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), }, status=400, ) diff --git a/loopx/todo_followups.py b/loopx/todo_followups.py index a3dd7910c1..b5e3d4f605 100644 --- a/loopx/todo_followups.py +++ b/loopx/todo_followups.py @@ -4,7 +4,14 @@ from pathlib import Path from typing import Any -from .file_lock import exclusive_file_lock +from .control_plane.coordination.legacy_writer_fence import legacy_todo_write_transaction +from .control_plane.coordination.local_authority_shadow_adapter import effective_runtime_root +from .control_plane.coordination.runtime_shadow_writer_adapter import ( + require_runtime_shadow_capture_prepared, + begin_todo_runtime_shadow_capture, + settle_todo_runtime_shadow_capture, +) +from .control_plane.todos.active_state_editing import atomic_write_state_text from .state_refresh import now_local from .control_plane.todos.contract import TODO_TASK_CLASS_ADVANCEMENT from .control_plane.todos.todo_summary import normalize_todo_text @@ -85,8 +92,17 @@ def capture_followup_todos( ) items: list[dict[str, Any]] = [] - with exclusive_file_lock(resolved_state_file): + runtime_root = effective_runtime_root(registry_path, runtime_root_arg) + with legacy_todo_write_transaction( + registry_path, goal_id, resolved_state_file, None, "todo_capture_followups", + dry_run, runtime_root=runtime_root, + ): original = resolved_state_file.read_text(encoding="utf-8") + capture = begin_todo_runtime_shadow_capture( + registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_id, + state_path=resolved_state_file, write_class="todo_capture_followups", + original_text=original, + ) lines = original.splitlines() existing_texts = _existing_agent_todo_texts(lines) seen_texts: set[str] = set() @@ -148,7 +164,10 @@ def capture_followup_todos( new_text = "\n".join(lines) + ("\n" if original.endswith("\n") else "") new_text = replace_updated_at(new_text, updated_at) if not dry_run: - resolved_state_file.write_text(new_text, encoding="utf-8") + capture.prepare(new_text) + require_runtime_shadow_capture_prepared(capture, runtime_root=runtime_root, goal_id=goal_id) + atomic_write_state_text(resolved_state_file, new_text) + capture.committed() result = { "ok": True, @@ -168,14 +187,11 @@ def capture_followup_todos( "updated_at": updated_at if changed else None, } if changed and not dry_run: - from .control_plane.coordination.local_authority_shadow_adapter import ( - effective_runtime_root, - observe_local_authority_commit, - ) + from .control_plane.coordination.local_authority_shadow_observation import observe_local_authority_commit shadow = observe_local_authority_commit( registry_path=registry_path, - runtime_root=effective_runtime_root(registry_path, runtime_root_arg), + runtime_root=runtime_root, goal_id=goal_id, observation_trigger=( f"todo_capture_followups:{recorded_count}:{updated_at}" @@ -183,4 +199,8 @@ def capture_followup_todos( ) if shadow is not None: result["authority_shadow"] = shadow - return result + return settle_todo_runtime_shadow_capture( + result, registry_path=registry_path, runtime_root=runtime_root, + goal_id=goal_id, write_class="todo_capture_followups", capture=capture, + observe_legacy=False, emit_disabled=False, + ) diff --git a/loopx/todos.py b/loopx/todos.py index e2151ce30b..030bb0db70 100644 --- a/loopx/todos.py +++ b/loopx/todos.py @@ -119,6 +119,7 @@ resolve_user_gate_global_gate_update, ) from .control_plane.coordination.legacy_writer_fence import legacy_todo_write_transaction +from .control_plane.todos.active_state_editing import atomic_write_state_text from .control_plane.coordination.local_authority import ( canonical_todo_summary_fields, claim_canonical_todo_if_promoted, @@ -134,6 +135,7 @@ ) from .control_plane.coordination.local_authority_shadow_adapter import effective_runtime_root from .control_plane.coordination.runtime_shadow_writer_adapter import ( + require_runtime_shadow_capture_prepared, begin_todo_runtime_shadow_capture, settle_todo_runtime_shadow_capture, ) @@ -969,7 +971,8 @@ def add_goal_todo( new_text = replace_updated_at(new_text, updated_at) if changed and not dry_run: shadow_capture.prepare(new_text) - resolved_state_file.write_text(new_text, encoding="utf-8") + require_runtime_shadow_capture_prepared(shadow_capture, runtime_root=shadow_runtime_root, goal_id=goal_id) + atomic_write_state_text(resolved_state_file, new_text) shadow_capture.committed() payload = { @@ -1538,7 +1541,8 @@ def update_goal_todo( new_text = replace_updated_at(new_text, updated_at) if changed and not dry_run: shadow_capture.prepare(new_text) - resolved_state_file.write_text(new_text, encoding="utf-8") + require_runtime_shadow_capture_prepared(shadow_capture, runtime_root=shadow_runtime_root, goal_id=goal_id) + atomic_write_state_text(resolved_state_file, new_text) shadow_capture.committed() write_class = "todo_claim" if claim_only else "todo_update" payload = { @@ -1778,6 +1782,8 @@ def complete_goal_todo( event_result = complete_event_projected_goal_todo( goal_id=goal_id, context=event_context, + runtime_root=shadow_runtime_root, + primary_lock_held=True, evidence=evidence, completion_turn_key=completion_turn_key, completion_identity_source=completion_identity_source, @@ -1953,7 +1959,8 @@ def complete_goal_todo( new_text = replace_updated_at(new_text, updated_at) if changed and not dry_run: shadow_capture.prepare(new_text) - resolved_state_file.write_text(new_text, encoding="utf-8") + require_runtime_shadow_capture_prepared(shadow_capture, runtime_root=shadow_runtime_root, goal_id=goal_id) + atomic_write_state_text(resolved_state_file, new_text) shadow_capture.committed() release_verified_task_lease_fence( task_lease_fence, @@ -2188,7 +2195,8 @@ def supersede_goal_todo( new_text = replace_updated_at(new_text, updated_at) if changed and not dry_run: shadow_capture.prepare(new_text) - resolved_state_file.write_text(new_text, encoding="utf-8") + require_runtime_shadow_capture_prepared(shadow_capture, runtime_root=shadow_runtime_root, goal_id=goal_id) + atomic_write_state_text(resolved_state_file, new_text) shadow_capture.committed() release_verified_task_lease_fence(task_lease_fence, committed=changed and not dry_run) result = { @@ -2259,7 +2267,8 @@ def archive_completed_todos( new_text = replace_updated_at(new_text, updated_at) if changed and not dry_run: shadow_capture.prepare(new_text) - resolved_state_file.write_text(new_text, encoding="utf-8") + require_runtime_shadow_capture_prepared(shadow_capture, runtime_root=shadow_runtime_root, goal_id=goal_id) + atomic_write_state_text(resolved_state_file, new_text) shadow_capture.committed() result = { diff --git a/scripts/generate_coordination_state_contract.py b/scripts/generate_coordination_state_contract.py index 0be49b7db0..dc4e92196c 100644 --- a/scripts/generate_coordination_state_contract.py +++ b/scripts/generate_coordination_state_contract.py @@ -153,6 +153,11 @@ "local_authority_protocol": {key: f"LOCAL_COORDINATION_{key.upper()}" for key in LOCAL_AUTHORITY_PROTOCOL_KEYS}, "runtime_shadow_protocol": {key: f"COORDINATION_RUNTIME_SHADOW_{key.upper()}" for key in RUNTIME_SHADOW_PROTOCOL_KEYS}, "local_authority_shadow_protocol": {key: f"LOCAL_AUTHORITY_SHADOW_{key.upper()}" for key in LOCAL_AUTHORITY_SHADOW_PROTOCOL_KEYS}, + "shadow_management_protocol": { + "state_schema": "SHADOW_MANAGEMENT_STATE_SCHEMA", + "manifest_schema": "SHADOW_MANAGEMENT_MANIFEST_SCHEMA", + "outbox_manifest_schema": "SHADOW_OUTBOX_MANIFEST_SCHEMA", + }, "legacy_writer_fence_protocol": LEGACY_WRITER_FENCE_CONSTANT_NAMES, "delivery_continuity_protocol": {key: f"DELIVERY_{key.upper()}" for key in DELIVERY_CONTINUITY_PROTOCOL_KEYS}, "delivery_workspace_protocol": {key: f"DELIVERY_WORKSPACE_{key.upper()}" for key in DELIVERY_WORKSPACE_PROTOCOL_KEYS}, From e7ce3fdf51d13c51741ef02a59b0c4cf538d4277 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Sun, 6 Sep 2026 14:54:00 +1000 Subject: [PATCH 02/27] test(coordination): qualify shadow crashes, writers, and installed packages Signed-off-by: wchwawa --- .github/workflows/python-tests.yml | 41 +- .../shared-goal-authority-e2e/installed.py | 237 ++++++ examples/shared-goal-authority-e2e/mutants.py | 254 +++++++ pyproject.toml | 1 + tests/cli_commands/test_project_registry.py | 2 +- .../canonical_authority_fixture.py | 38 + tests/control_plane/shadow_e2e_fixture.py | 181 +++++ ...est_coordination_runtime_shadow_adapter.py | 451 +---------- .../test_coordination_shadow_command.py | 28 +- .../test_coordination_state_contract.py | 5 +- .../test_effect_runtime_integration.py | 38 +- .../test_local_authority_shadow_drain.py | 256 +++---- .../test_local_authority_shadow_outbox.py | 322 ++++---- .../test_local_authority_shadow_runtime.py | 13 +- .../test_local_coordination_authority.py | 105 +-- .../test_runtime_shadow_bounded_e2e.py | 320 ++++++++ .../test_runtime_shadow_writer_capture.py | 9 +- .../test_shadow_cursor_safety.py | 197 +++++ .../test_shadow_drain_adversarial.py | 204 +++++ tests/control_plane/test_shadow_drain_e2e.py | 268 +++++++ tests/control_plane/test_shadow_management.py | 118 +++ .../test_shadow_management_e2e.py | 271 +++++++ .../test_shadow_writer_boundaries.py | 717 ++++++++++++++++++ .../test_split_root_todo_writeback_fence.py | 38 +- .../test_state_migration_authority_shadow.py | 4 +- .../test_todo_machine_section_projection.py | 63 +- .../test_todo_mutation_authority.py | 5 + .../coordination_runtime_shadow.test.ts | 663 ++++------------ .../file_outbox_qualification.test.ts | 37 + .../shadow_management_crash_worker.ts | 37 + .../local_authority_runtime.test.ts | 112 ++- .../local_authority_shadow_outbox.test.ts | 502 ++++-------- .../shadow_cursor_safety.test.ts | 61 ++ tests/control_plane_ts/shadow_file_fixture.ts | 105 +++ .../shadow_management.test.ts | 319 ++++++++ .../shadow_native_writer_boundary.test.ts | 132 ++++ .../task_lease_lifecycle.test.ts | 28 +- 37 files changed, 4319 insertions(+), 1863 deletions(-) create mode 100644 examples/shared-goal-authority-e2e/installed.py create mode 100644 examples/shared-goal-authority-e2e/mutants.py create mode 100644 tests/control_plane/canonical_authority_fixture.py create mode 100644 tests/control_plane/shadow_e2e_fixture.py create mode 100644 tests/control_plane/test_runtime_shadow_bounded_e2e.py create mode 100644 tests/control_plane/test_shadow_cursor_safety.py create mode 100644 tests/control_plane/test_shadow_drain_adversarial.py create mode 100644 tests/control_plane/test_shadow_drain_e2e.py create mode 100644 tests/control_plane/test_shadow_management.py create mode 100644 tests/control_plane/test_shadow_management_e2e.py create mode 100644 tests/control_plane/test_shadow_writer_boundaries.py create mode 100644 tests/control_plane_ts/file_outbox_qualification.test.ts create mode 100644 tests/control_plane_ts/fixtures/shadow_management_crash_worker.ts create mode 100644 tests/control_plane_ts/shadow_cursor_safety.test.ts create mode 100644 tests/control_plane_ts/shadow_file_fixture.ts create mode 100644 tests/control_plane_ts/shadow_management.test.ts create mode 100644 tests/control_plane_ts/shadow_native_writer_boundary.test.ts diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index fe63ae1092..4f1f813f9b 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -117,7 +117,7 @@ jobs: # Without timing history least_duration alternates equal-weight tests. # Each runner retains the measured two-worker pool. run: >- - python -m pytest -q -n 2 + python -m pytest -q -n 2 -m "not stage2c_e2e" --splits 2 --group ${{ matrix.shard }} --splitting-algorithm least_duration --durations=25 --durations-min=1 @@ -176,6 +176,45 @@ jobs: secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + stage2c-correctness-e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + cache: pip + - uses: actions/setup-node@v6 + with: + node-version: "22.6" + cache: npm + - name: Install test and package build tools + run: | + python -m pip install --disable-pip-version-check -e ".[test]" "build>=1,<2" + npm ci --ignore-scripts + - name: Qualify real CLI, mixed writers, process death, and recovery + run: python -m pytest -q -m stage2c_e2e --junitxml=stage2c-e2e.xml + - name: Reject deliberate correctness regressions + run: python examples/shared-goal-authority-e2e/mutants.py --output .local/stage2c-mutants + - name: Build independently installed distributions + run: python -m build + - name: Qualify wheel outside the repository + run: python examples/shared-goal-authority-e2e/installed.py --artifact dist/*.whl --report-json installed-wheel.json + - name: Qualify sdist outside the repository + run: python examples/shared-goal-authority-e2e/installed.py --artifact dist/*.tar.gz --report-json installed-sdist.json + - name: Retain bounded acceptance evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: stage2c-correctness-evidence + include-hidden-files: true + path: | + stage2c-e2e.xml + installed-wheel.json + installed-sdist.json + .local/stage2c-mutants/ + windows-powershell: runs-on: windows-latest timeout-minutes: 20 diff --git a/examples/shared-goal-authority-e2e/installed.py b/examples/shared-goal-authority-e2e/installed.py new file mode 100644 index 0000000000..bfa64ada34 --- /dev/null +++ b/examples/shared-goal-authority-e2e/installed.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Qualify one built wheel or sdist through an isolated installed-package E2E. + +This runner never imports the checkout. It installs the supplied artifact into +an empty temporary venv, executes the installed console command from a separate +working directory, and retains all process results in the requested JSON report. +Run it once per distribution format; every stage is required. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import time +from typing import Any + + +GOAL = "installed-authority-e2e" +REQUIRED_STAGES = { + "installed_python_ts_json_resources", "real_baseline_bootstrap", + "three_mutations_one_receipt_each", "default_qualification_and_exact_candidate_read", + "rollback_inactive_write_new_lineage", +} + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +class InstalledQualification: + def __init__(self, artifact: Path, workspace: Path, report: dict[str, Any]) -> None: + self.artifact = artifact + workspace = workspace.resolve() + self.workspace = workspace + self.report = report + self.venv = workspace / "venv" + self.cwd = workspace / "outside-checkout" + self.cwd.mkdir() + self.runtime = self.cwd / "runtime" + self.registry = self.cwd / "registry.json" + self.project = self.cwd / "project" + self.project.mkdir() + self.python = self.venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + self.console = self.python.parent / ("loopx.exe" if os.name == "nt" else "loopx") + node = shutil.which("node") + require(node is not None, "Node.js is required; this qualification cannot skip native execution") + self.node = Path(str(node)).absolute() + removed = [key for key in os.environ if key.startswith(("PYTHON", "LOOPX", "NODE", "PIP_")) or key in {"VIRTUAL_ENV", "CONDA_PREFIX"}] + self.environment = {key: value for key, value in os.environ.items() if key not in removed} + self.environment["PATH"] = os.pathsep.join([str(self.python.parent), str(self.node.parent), os.defpath]) + self.environment["PYTHONNOUSERSITE"] = "1" + self.report["isolation"] = {"cwd": str(self.cwd), "venv": str(self.venv), + "removed_environment_keys": sorted(removed), "node": str(self.node), + "checkout_added_to_import_path": False} + + def process(self, stage: str, argv: list[str], *, timeout: int = 60, parse_json: bool = True) -> Any: + started = time.monotonic() + result = subprocess.run(argv, cwd=self.cwd, env=self.environment, + text=True, capture_output=True, timeout=timeout, check=False) + self.report["processes"].append({"stage": stage, "argv": argv, "returncode": result.returncode, + "elapsed_seconds": round(time.monotonic() - started, 3), + "stdout": result.stdout, "stderr": result.stderr}) + require(result.returncode == 0, f"{stage} exited {result.returncode}: {result.stdout}\n{result.stderr}") + return json.loads(result.stdout) if parse_json else result.stdout + + def checked(self, stage: str, **evidence: Any) -> None: + self.report["checks"].append({"stage": stage, "status": "passed", **evidence}) + + def cli(self, stage: str, *args: str) -> dict[str, Any]: + result = self.process(stage, [str(self.console), "--registry", str(self.registry), + "--runtime-root", str(self.runtime), "--format", "json", *args]) + require(isinstance(result, dict) and result.get("ok") is True, f"{stage} did not return ok: {result}") + return result + + def sdk_add(self, stage: str, text: str) -> dict[str, Any]: + code = """ +import json, sys +from pathlib import Path +from loopx.todos import add_goal_todo +value = add_goal_todo(registry_path=Path(sys.argv[1]), runtime_root_arg=sys.argv[2], + goal_id=sys.argv[3], role='agent', text=sys.argv[4], task_class='advancement_task', action_kind='analyze') +print(json.dumps(value)) +""" + value = self.process(stage, [str(self.python), "-I", "-c", code, + str(self.registry), str(self.runtime), GOAL, text]) + require(value.get("ok") is True and value.get("added") is True, f"{stage} did not add one Todo") + return value + + def read_store(self, stage: str, package: Path) -> dict[str, Any]: + module = package / "control_plane/coordination/file_authority_store.ts" + code = """ +import {pathToFileURL} from 'node:url'; +import {join} from 'node:path'; +const [modulePath, root, goal] = process.argv.slice(1); +const {FileAuthorityStore} = await import(pathToFileURL(modulePath).href); +const store = new FileAuthorityStore(join(root, 'authority-shadow', 'file-v0'), goal, {existingOnly: true}); +const head = await store.loadAuthority(); +const history = await store.scanCommitted(null, 100); +console.log(JSON.stringify({module_path: modulePath, head, history})); +""" + return self.process(stage, [str(self.node), "--no-warnings", "--experimental-strip-types", + "--input-type=module", "-e", code, str(module), str(self.runtime), GOAL]) + + def run(self) -> None: + self.process("create_empty_venv", [sys.executable, "-I", "-m", "venv", str(self.venv)], + timeout=120, parse_json=False) + self.process("install_artifact", [str(self.python), "-I", "-m", "pip", "--disable-pip-version-check", + "install", "--no-input", "--no-cache-dir", "--no-deps", str(self.artifact)], + timeout=240, parse_json=False) + provenance_code = """ +import hashlib, importlib.metadata, importlib.resources, json, pathlib, sys +import loopx, loopx.todos, loopx.control_plane.coordination.shadow_management +package = pathlib.Path(loopx.__file__).resolve().parent +resources = {} +for relative in ['control_plane/coordination/runtime_shadow.ts', 'control_plane/coordination/shadow_management.ts', + 'control_plane/coordination/file_authority_store.ts', 'control_plane/coordination/local_authority_shadow_identity.ts', + 'control_plane/coordination/legacy_writer_lock_paths.ts', + 'control_plane/work_items/task_lease_acquire.ts', + 'control_plane/coordination/coordination_state_contract_v0.json', + 'control_plane/coordination/coordination_state_contract.generated.ts']: + resource = importlib.resources.files('loopx').joinpath(relative) + raw = resource.read_bytes() + if relative.endswith('.json'): json.loads(raw) + resources[relative] = {'path': str(resource), 'sha256': hashlib.sha256(raw).hexdigest()} +print(json.dumps({'executable': sys.executable, 'package': str(package), + 'version': importlib.metadata.version('loopx'), 'resources': resources, + 'python_modules': [loopx.__file__, loopx.todos.__file__, loopx.control_plane.coordination.shadow_management.__file__]})) +""" + provenance = self.process("installed_resource_provenance", [str(self.python), "-I", "-c", provenance_code]) + package = Path(provenance["package"]) + require(package.is_relative_to(self.venv), "Python imported outside the isolated venv") + for path in provenance["python_modules"] + [item["path"] for item in provenance["resources"].values()]: + require(Path(path).resolve().is_relative_to(package), f"resource escaped installed package: {path}") + self.report["provenance"] = provenance + self.checked("installed_python_ts_json_resources", resource_count=len(provenance["resources"])) + + initialized = self.cli("console_project_bootstrap", "bootstrap", "--project", str(self.project), + "--goal-id", GOAL, "--objective", "Qualify installed authority transactions.", + "--no-onboarding-scan", "--onboarding-connection-validation", "provider-prevalidated", "--no-global-sync") + # Set configuration only, before shadow bootstrap creates the real binding. + registry = json.loads(self.registry.read_text()) + goal = next(item for item in registry["goals"] if item["id"] == GOAL) + goal["coordination"].update({"agent_model": "peer_v1", "registered_agents": ["agent-a", "agent-b"], + "runtime_shadow": {"schema_version": "loopx_coordination_runtime_shadow_config_v0", + "enabled": True, "provider": "file_v0"}}) + self.registry.write_text(json.dumps(registry)) + state = Path(initialized["state_file"]) + state.write_text(state.read_text().replace("---\n", "---\nhandoff_mode: hard_lease\n", 1)) + boot = self.cli("console_shadow_bootstrap", "coordination-shadow", "bootstrap", "--goal-id", GOAL, "--execute")["bootstrap"] + require(boot.get("status") == "applied" and bool(boot.get("capture_lineage_id")), f"bootstrap not applied: {boot}") + self.checked("real_baseline_bootstrap", capture_lineage_id=boot["capture_lineage_id"]) + + first = self.sdk_add("installed_python_todo_add", "First installed package mutation.") + require(first["coordination_runtime_shadow"]["outcome"] == "delivered", f"first Todo capture failed: {first}") + lease = self.cli("console_native_lease_acquire", "task-lease", "acquire", "--goal-id", GOAL, + "--todo-id", first["todo_id"], "--owner", "agent-a", "--idempotency-key", "installed-lease-a", "--ttl-seconds", "600") + require(lease["coordination_runtime_shadow"]["outcome"] == "delivered", f"native lease capture failed: {lease}") + second = self.sdk_add("installed_python_second_todo_add", "Third installed package mutation.") + require(second["coordination_runtime_shadow"]["outcome"] == "delivered", f"second Todo capture failed: {second}") + drained = self.cli("console_drain", "authority-shadow", "drain", "--goal-id", GOAL) + require(drained.get("pending_after") == 0 and drained.get("prepared_only_after") == 0, + f"drain left unverified work: {drained}") + qualified = self.cli("console_qualify_default_policy", "coordination-shadow", "qualify", "--goal-id", GOAL)["qualification"] + require(qualified.get("status") == "qualified" and qualified.get("qualified") is True, + f"default policy was not qualified: {qualified}") + require(qualified["policy"]["minimum_operations"] == 3 and qualified["evidence"]["operation_count"] == 3, + "qualification did not prove exactly three real primary mutations under the default threshold") + candidate = self.cli("console_read_candidate", "coordination-shadow", "read-candidate", "--goal-id", GOAL, + "--todo-id", first["todo_id"])["read_candidate"] + require(candidate.get("status") == "matched" and candidate.get("read_candidate_qualified") is True, + f"candidate read was not qualified: {candidate}") + independent = self.read_store("independent_installed_native_readback", package) + require(independent["head"]["status"] == "loaded" and independent["history"]["status"] == "page", "independent provider read failed") + transactions = independent["history"]["transactions"] + require(len(transactions) == 4 and independent["head"]["cursor"] == "4", "primary writes did not map one-to-one to receipts") + require(all(len(tx["receipts"]) == 1 for tx in transactions[1:]), "a primary mutation has missing or duplicate receipts") + old_head = independent["head"]["head"] + require({first["todo_id"], second["todo_id"]}.issubset({item["todo_id"] for item in old_head["todos"]}), "native readback lost a Todo") + require(any(item["todo_id"] == first["todo_id"] and item["owner"] == "agent-a" for item in old_head["leases"]), "native readback lost the lease") + self.checked("three_mutations_one_receipt_each", mutation_count=3, cursor="4") + self.checked("default_qualification_and_exact_candidate_read", minimum_operations=3, todo_id=first["todo_id"]) + + rolled = self.cli("console_rollback", "coordination-shadow", "rollback", "--goal-id", GOAL, + "--provider-revision", independent["head"]["provider_revision"], "--execute")["rollback"] + require(rolled.get("status") == "applied", f"rollback not applied: {rolled}") + absent = self.read_store("independent_read_after_rollback", package) + require(absent["head"]["status"] == "missing", "rollback left an active candidate") + inactive = self.sdk_add("installed_python_inactive_primary_write", "Primary remains writable after rollback.") + require(inactive["coordination_runtime_shadow"]["reason_code"] == "bootstrap_required", "inactive write fabricated capture qualification") + require(not (self.runtime / "authority-shadow" / "outbox" / GOAL).exists(), "inactive write created outbox without a lineage") + again = self.cli("console_new_bootstrap", "coordination-shadow", "bootstrap", "--goal-id", GOAL, "--execute")["bootstrap"] + require(again.get("status") == "applied" and again.get("capture_lineage_id") != boot["capture_lineage_id"], "new baseline reused the retired lineage") + final = self.read_store("independent_new_baseline_readback", package) + require(final["head"]["status"] == "loaded" and final["head"]["cursor"] == "1", "new baseline has an invalid initial cursor") + require(inactive["todo_id"] in {item["todo_id"] for item in final["head"]["head"]["todos"]}, "new baseline omitted the inactive primary write") + require(len(final["history"]["transactions"]) == 1, "new baseline pretended to retain old mutation coverage") + self.checked("rollback_inactive_write_new_lineage", previous_lineage=boot["capture_lineage_id"], new_lineage=again["capture_lineage_id"]) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifact", required=True, type=Path, help="Exact built wheel or sdist to install") + parser.add_argument("--report-json", required=True, type=Path, help="Complete qualification report and raw process results") + args = parser.parse_args(argv) + artifact = args.artifact.expanduser().resolve() + report: dict[str, Any] = {"schema_version": "loopx_installed_authority_e2e_v1", "status": "failed", + "artifact": str(artifact), "artifact_sha256": None, "checks": [], "processes": [], + "fail": 0, "pending": 0, "unverified": 0} + try: + require(artifact.is_file() and (artifact.name.endswith(".whl") or artifact.name.endswith(".tar.gz")), "artifact must be an existing wheel or sdist") + report["artifact_sha256"] = hashlib.sha256(artifact.read_bytes()).hexdigest() + with tempfile.TemporaryDirectory(prefix="loopx-installed-authority-") as directory: + InstalledQualification(artifact, Path(directory), report).run() + require({row["stage"] for row in report["checks"]} == REQUIRED_STAGES, "a required qualification stage did not execute") + report["status"] = "passed" + except Exception as error: + report["fail"] = 1 + report["unverified"] = len(REQUIRED_STAGES - {row["stage"] for row in report["checks"]}) + report["error"] = {"type": type(error).__name__, "message": str(error)} + finally: + destination = args.report_json.expanduser().resolve() + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + print(json.dumps({"status": report["status"], "report_json": str(destination), + "checks_passed": len(report["checks"]), "fail": report["fail"], "pending": report["pending"], "unverified": report["unverified"]})) + return 0 if report["status"] == "passed" and report["fail"] == report["pending"] == report["unverified"] == 0 else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/shared-goal-authority-e2e/mutants.py b/examples/shared-goal-authority-e2e/mutants.py new file mode 100644 index 0000000000..fd855d191b --- /dev/null +++ b/examples/shared-goal-authority-e2e/mutants.py @@ -0,0 +1,254 @@ +"""Deliberate regressions for bounded shadow qualification, in isolated copies only. + +Each case first requires its unchanged oracle to pass. A mutation counts as +killed only when that same test reports an assertion failure, never an import, +syntax, process timeout, or setup failure. No live goal or checkout is edited. +""" +from __future__ import annotations + +import argparse +import ast +from dataclasses import dataclass +import difflib +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +from collections.abc import Callable + +COORDINATION = "loopx/control_plane/coordination/" + + +def replacement(before: str, after: str) -> Callable[[str], str]: + def apply(source: str) -> str: + if source.count(before) != 1: + raise ValueError("mutation locator drift; review source and oracle") + return source.replace(before, after) + return apply + + +@dataclass(frozen=True) +class Case: + name: str + edits: tuple[tuple[str, Callable[[str], str]], ...] + test: str + pattern: str | None = None + + def command(self) -> list[str]: + if self.pattern is None: + return [sys.executable, "-m", "pytest", "-q", "--tb=short", self.test] + return ["node", "--no-warnings", "--experimental-strip-types", "--test", + "--test-name-pattern=" + self.pattern, self.test] + + +CASES = [ + Case('lineage', ((COORDINATION + 'local_authority_shadow.ts', replacement(' requireLineage(entry.capture_lineage_id === binding.capture_lineage_id, "stale_generation");', ' // DELIBERATE MUTANT: omit active lineage validation.')),), + 'tests/control_plane_ts/local_authority_shadow_outbox.test.ts', 'self-consistent foreign'), + Case('previous_partition', ((COORDINATION + 'local_authority_shadow.ts', replacement(' requireLineage(request.entry.source.previous_partition_digest === digest, "source_partition_continuity_unproved");', ' // DELIBERATE MUTANT: omit previous partition proof.')),), + 'tests/control_plane_ts/local_authority_shadow_outbox.test.ts', 'missing primary mutation'), + Case('qualification_history', ((COORDINATION + 'runtime_shadow.ts', replacement(' const lineage = await loadValidatedShadowLineage(store, request.runtime_root, request.goal_id, binding);', ' const page = await store.scanCommitted(null, 10000);\n const lineage = { head: await store.loadAuthority(), transactions: page.transactions,\n last_sequences: {}, last_applied_sequences: {}, write_classes: ["todo_add"] };')),), + 'tests/control_plane_ts/coordination_runtime_shadow.test.ts', 'observation transaction mixed'), + Case('management_request_digest', ((COORDINATION + "shadow_management.ts", replacement('if (state.operation.request_digest !== digest) throw new ShadowManagementError("management_operation_identity_mismatch");', 'if (false) throw new ShadowManagementError("management_operation_identity_mismatch");')),), + "tests/control_plane_ts/shadow_management.test.ts", 'management request digest'), + Case('management_manifest_hash', ((COORDINATION + "shadow_management.ts", replacement('managementDigest(manifest) !== state.operation.manifest_digest\n || manifest.schema_version !== SHADOW_MANAGEMENT_MANIFEST_SCHEMA\n || manifest.goal_id', 'false\n || manifest.schema_version !== SHADOW_MANAGEMENT_MANIFEST_SCHEMA\n || manifest.goal_id')),), + "tests/control_plane_ts/shadow_management.test.ts", 'management manifest hash'), + Case('management_phase', ((COORDINATION + "shadow_management.ts", replacement('operation.kind !== kind || !phases.includes(String(operation.phase))', 'operation.kind !== kind')),), + "tests/control_plane_ts/shadow_management.test.ts", 'management phase validation'), + Case('management_goal_binding', ((COORDINATION + "shadow_management.ts", replacement('value.goal_id !== goal || ', '')),), + "tests/control_plane_ts/shadow_management.test.ts", 'management goal binding'), + Case('management_candidate_lineage', ((COORDINATION + "shadow_management.ts", replacement('candidate.capture_lineage_id !== expectedLineage', 'false')),), + "tests/control_plane_ts/shadow_management.test.ts", 'rollback refuses a different valid candidate lineage'), +] + +PREPARED_WRITE = ''' durable_write_json( + self._directory / entry_file_name(seq, entry_id, "prepared"), + record, + )''' +CASES.extend([ + Case("receipt_bytes", ((COORDINATION + "local_authority_shadow_adapter.py", replacement( + " expected = receipt.get(key)", + " expected = outbox.raw_bytes_digest(path.read_bytes())")),), + "tests/control_plane/test_shadow_drain_adversarial.py::test_raw_residue_mismatch_preserves_every_file_before_any_cleanup"), + Case("cursor_regression", ((COORDINATION + "local_authority_shadow_adapter.py", replacement( + "last_seq=len(history),", "last_seq=1,")),), + "tests/control_plane/test_shadow_drain_e2e.py::test_public_primary_maps_one_to_one_to_receipts_and_replays_idempotently"), + Case("early_committed", ((COORDINATION + "local_authority_shadow_outbox.py", replacement( + PREPARED_WRITE, PREPARED_WRITE + ''' + durable_write_json( + self._directory / entry_file_name(seq, entry_id, "committed"), + {"schema_version": OUTBOX_COMMIT_SCHEMA, "entry_id": entry_id, + "capture_lineage_id": self._lineage_id, "committed_at": utc_now_text()}, + )''')),), + "tests/control_plane/test_shadow_drain_e2e.py::test_primary_sigkill_preserves_complete_bytes_and_proves_before_marker[before_replace]"), +]) + + +def remove_fence(source: str) -> str: + function = next(node for node in ast.parse(source).body + if isinstance(node, ast.FunctionDef) + and node.name == "require_legacy_coordination_write_allowed") + lines = source.splitlines(keepends=True) + return "".join(lines[:function.body[0].lineno - 1]) + " return\n" + "".join(lines[function.end_lineno:]) + + +def move_guard_outside_lock(name: str) -> Callable[[str], str]: + def apply(source: str) -> str: + function = next(node for node in ast.parse(source).body + if isinstance(node, ast.FunctionDef) + and node.name == "legacy_todo_write_transaction") + calls = [node for node in ast.walk(function) if isinstance(node, ast.Expr) + and isinstance(node.value, ast.Call) and isinstance(node.value.func, ast.Name) + and node.value.func.id == name] + if len(calls) != 1: + raise ValueError("guard locator drift; review lock protocol") + call = calls[0] + lines = source.splitlines(keepends=True) + segment = "".join(lines[call.lineno - 1:call.end_lineno]) + text = "".join(lines[:call.lineno - 1] + lines[call.end_lineno:]) + start = text.index(" with exclusive_cross_runtime_file_lock(\n", + text.index("def legacy_todo_write_transaction(")) + # Preserve the exact arguments while moving only the actual guard. + unindented = "".join(line[4:] for line in segment.splitlines(keepends=True)) + return text[:start] + " if not dry_run:\n" + unindented + text[start:] + return apply + + +WRITER_TEST = "tests/control_plane/test_shadow_writer_boundaries.py::" +FENCE_TEST = WRITER_TEST + "test_cli_waiting_for_todo_mutex_rechecks_fence_after_engagement" +CASES.extend([ + Case("remove_fence", ((COORDINATION + "legacy_writer_fence.py", remove_fence),), FENCE_TEST), + Case("fence_outside_lock", ((COORDINATION + "legacy_writer_fence.py", + move_guard_outside_lock("require_legacy_coordination_write_allowed")),), FENCE_TEST), + Case("source_binding_outside_lock", ((COORDINATION + "legacy_writer_fence.py", + move_guard_outside_lock("require_registry_source_write_allowed")),), + WRITER_TEST + "test_waiting_override_writer_rechecks_registry_binding_inside_shared_state_lock"), + Case("remove_refresh_cas", (("loopx/state_refresh.py", replacement( + ''' if current_state_text != expected_write_state_text: + raise ValueError( + "active goal state changed while refresh-state was qualifying " + "its semantic writeback; retry from the current state" + )''', "")),), + WRITER_TEST + "test_concurrent_public_refresh_preserves_the_newer_owned_paragraph"), + Case("fence_unshared_state_lock", ((COORDINATION + "legacy_writer_fence.ts", replacement( + "withFileMutationLock(statePath, () =>", + 'withFileMutationLock(statePath + ".mutant-unshared", () =>')),), + WRITER_TEST + "test_real_writer_commits_before_a_later_fence_is_published[True]"), + Case("remove_bound_state_path", ((COORDINATION + "legacy_writer_fence.py", replacement( + "if bound_source.resolve(strict=False) != state_file.resolve(strict=False):", + "if False:")),), + "tests/control_plane/test_shadow_drain_adversarial.py::test_state_file_override_cannot_attribute_an_unbound_source_to_active_lineage"), + Case("bootstrap_unregistered_root", ((COORDINATION + "runtime_shadow.ts", replacement( + "if (registeredRoot !== request.runtime_root)", + "if (false && registeredRoot !== request.runtime_root)")),), + "tests/control_plane/test_runtime_shadow_bounded_e2e.py::test_controller_cannot_bind_the_registered_source_to_an_override_runtime_root"), + Case("bootstrap_unregistered_state", ((COORDINATION + "runtime_shadow.ts", replacement( + "if (resolve(String(snapshot.state_path)) !== resolve(String(snapshot.registered_state_path)))", + "if (false && resolve(String(snapshot.state_path)) !== resolve(String(snapshot.registered_state_path)))")),), + "tests/control_plane/test_runtime_shadow_bounded_e2e.py::test_controller_cannot_bind_an_alternate_state_file_before_or_after_bootstrap"), +]) + +# Restore both halves of the obsolete mirror: the public CLI hook and an actual +# second FileAuthorityStore commit. A fabricated RPC result would prove nothing. +MIRROR_HOOK = ''' if payload.get("ok") and payload.get("added") and not payload.get("dry_run"): + from ..control_plane.effect_runtime import effect_runtime_result as restored_mirror + restored_mirror("coordination.runtime_shadow.commit", { + "runtime_root": str(resolve_runtime_root(load_registry(registry_path), runtime_root_arg)), + "goal_id": args.goal_id, "operation_id": "restored-snapshot:" + payload["todo_id"], + }) +''' +MIRROR_COMMIT = ''' const request = requireJsonObject(_value, "restored snapshot request"); + const root = String(request.runtime_root); const goal = String(request.goal_id); + return await withShadowMaintenanceLock(root, goal, async () => { + const store = new FileAuthorityStore(join(root, "authority-shadow", "file-v0"), goal, { existingOnly: true }); + const head = await store.loadAuthority(); + if (head.status !== "loaded") throw new Error("restored mirror needs a real existing baseline"); + const result = await store.commitAuthority({expected_provider_revision: head.provider_revision, + operation_id: String(request.operation_id), next_projection: head.head, + events: [{schema_version: "loopx_coordination_runtime_shadow_event_v0", event_kind: "todo_add"}], + receipts: [{schema_version: "loopx_coordination_runtime_shadow_receipt_v0", operation_id: request.operation_id}]}); + return {...result}; + });''' +CASES.append(Case("duplicate_mirror", ( + ("loopx/cli_commands/todo.py", replacement(" print_payload(\n payload,\n", + MIRROR_HOOK + " print_payload(\n payload,\n")), + (COORDINATION + "runtime_shadow.ts", replacement( + ''' return { schema_version: COORDINATION_RUNTIME_SHADOW_RESULT_SCHEMA, status: "failed", + reason_code: "legacy_lineage_read_only", primary_writeback_preserved: true, decision_read_from_shadow: false };''', + MIRROR_COMMIT)), +), "tests/control_plane/test_shadow_drain_e2e.py::test_public_mutation_has_no_second_snapshot_mirror")) + + +def run(case: Case, directory: Path, log: Path) -> subprocess.CompletedProcess[str]: + environment = {key: value for key, value in os.environ.items() + if not key.startswith(("PYTHON", "LOOPX", "NODE", "COVERAGE"))} + environment.update(PYTHONPATH=str(directory), PYTHONNOUSERSITE="1") + result = subprocess.run(case.command(), cwd=directory, env=environment, + capture_output=True, text=True, timeout=120) + log.write_text(result.stdout + result.stderr, encoding="utf-8") + return result + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, default=Path(__file__).resolve().parents[2]) + parser.add_argument("--output", type=Path, required=True, help="Private log directory outside tracked source") + parser.add_argument("--case", action="append", choices=[case.name for case in CASES]) + args = parser.parse_args() + source, output = args.source.resolve(), args.output.resolve() + output.mkdir(parents=True, exist_ok=True) + cases = [case for case in CASES if args.case is None or case.name in args.case] + results: list[dict] = [] + with tempfile.TemporaryDirectory(prefix="loopx-stage2c-mutants-") as temporary: + frozen = Path(temporary) / "source" + for folder in ("loopx", "tests"): + shutil.copytree(source / folder, frozen / folder, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".pytest_cache")) + for name in ("package.json", "pyproject.toml"): + shutil.copy2(source / name, frozen / name) + if (source / "node_modules").is_dir(): + (frozen / "node_modules").symlink_to(source / "node_modules", target_is_directory=True) + manifest = {str(path.relative_to(frozen)): hashlib.sha256(path.read_bytes()).hexdigest() + for folder in ("loopx", "tests") for path in (frozen / folder).rglob("*") if path.is_file()} + (output / "source-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + for case in cases: + originals = {path: (frozen / path).read_text() for path, _ in case.edits} + replacements = {path: edit(originals[path]) for path, edit in case.edits} + control = run(case, frozen, output / (case.name + "-GREEN.log")) + if control.returncode != 0 or not any(token in control.stdout for token in ("1 passed", "pass 1")): + raise AssertionError(f"{case.name}: unchanged oracle did not pass") + patch = "".join("".join(difflib.unified_diff( + originals[path].splitlines(keepends=True), text.splitlines(keepends=True), + fromfile=path, tofile=path)) for path, text in replacements.items()) + (output / (case.name + ".diff")).write_text(patch) + try: + for path, text in replacements.items(): + (frozen / path).write_text(text) + # Timestamp/size-based Python bytecode caches must not mask an edit. + for cache in (frozen / "loopx").rglob("__pycache__"): + shutil.rmtree(cache) + mutant = run(case, frozen, output / (case.name + "-RED.log")) + log = mutant.stdout + mutant.stderr + killed = (mutant.returncode == 1 and "AssertionError" in log + and any(token in log for token in ("1 failed", "fail 1")) + and not any(token in log for token in ("SyntaxError", "ImportError", "ModuleNotFoundError"))) + finally: + for path, text in originals.items(): + (frozen / path).write_text(text) + for cache in (frozen / "loopx").rglob("__pycache__"): + shutil.rmtree(cache) + results.append({"name": case.name, "test": case.test, "pattern": case.pattern, + "control_exit": control.returncode, "mutant_exit": mutant.returncode, + "killed_by_assertion": killed}) + report = {"selected": len(cases), "executed": len(results), + "killed": sum(row["killed_by_assertion"] for row in results), "results": results} + (output / "report.json").write_text(json.dumps(report, indent=2) + "\n") + print(json.dumps(results[-1]), flush=True) + return 0 if all(row["killed_by_assertion"] for row in results) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml index ebf892f176..6b11b31711 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,6 +117,7 @@ include = ["loopx*"] ] [tool.pytest.ini_options] +markers = ["stage2c_e2e: real process Stage 2C correctness and recovery acceptance"] norecursedirs = ["deprecate"] [tool.coverage.run] diff --git a/tests/cli_commands/test_project_registry.py b/tests/cli_commands/test_project_registry.py index 4616edcd26..6ec004add0 100644 --- a/tests/cli_commands/test_project_registry.py +++ b/tests/cli_commands/test_project_registry.py @@ -1927,7 +1927,7 @@ def test_sync_global_rejects_malformed_project_collections( payload = source else: target_path = global_path - target_path.parent.mkdir(parents=True) + target_path.parent.mkdir(parents=True, exist_ok=True) payload = {"schema_version": "0.1", "goals": []} if malformation == "non-list": payload["projects"] = {} diff --git a/tests/control_plane/canonical_authority_fixture.py b/tests/control_plane/canonical_authority_fixture.py new file mode 100644 index 0000000000..4f37066875 --- /dev/null +++ b/tests/control_plane/canonical_authority_fixture.py @@ -0,0 +1,38 @@ +"""Initialize an already canonical provider for its independent consumer tests. + +This fixture runs the real FileAuthorityStore in a Node process. It deliberately +does not claim that a shadow qualification can promote canonical authority. +""" +from __future__ import annotations + +import json +from pathlib import Path +import subprocess + + +def initialize_canonical_authority(runtime_root: Path, goal_id: str, projection: dict, *, state_path: Path) -> dict: + repository = Path(__file__).resolve().parents[2] + module = repository / "loopx/control_plane/coordination/file_authority_store.ts" + fence_module = repository / "loopx/control_plane/coordination/legacy_writer_fence.ts" + codec_module = repository / "loopx/control_plane/coordination/authority_store_codec.ts" + script = ( + f"import {{FileAuthorityStore}} from {json.dumps(module.as_uri())};" + f"import {{engageLegacyCoordinationWriterFence}} from {json.dumps(fence_module.as_uri())};" + f"import {{canonicalAuthoritySha256}} from {json.dumps(codec_module.as_uri())};" + "import {join} from 'node:path';let input='';for await(const chunk of process.stdin)input+=chunk;" + "const request=JSON.parse(input);const store=new FileAuthorityStore(join(request.root,'authority','file-v0'),request.goal);" + "const result=await store.commitAuthority({expected_provider_revision:null,operation_id:'canonical-fixture'," + "events:[],next_projection:request.projection,receipts:[]});" + "const fence=await engageLegacyCoordinationWriterFence({schema_version:'loopx_legacy_coordination_writer_fence_engage_request_v0'," + "runtime_root:request.root,goal_id:request.goal,state_path:request.state_path,fence:{schema_version:'loopx_legacy_coordination_writer_fence_v0'," + "state:'engaged',goal_id:request.goal,fence_id:'canonical-fixture',source_version:'canonical-fixture'," + "source_projection_sha256:canonicalAuthoritySha256(request.projection),expected_shadow_provider_revision:result.provider_revision}});" + "if(fence.status!=='applied')throw new Error(JSON.stringify(fence));process.stdout.write(JSON.stringify(result));" + ) + process = subprocess.run(["node", "--no-warnings", "--experimental-strip-types", "--input-type=module", "-e", script], + input=json.dumps({"root": str(runtime_root), "goal": goal_id, "projection": projection, "state_path": str(state_path)}), + capture_output=True, text=True, check=False, timeout=45) + assert process.returncode == 0, process.stderr + result = json.loads(process.stdout) + assert result["status"] == "applied", result + return result diff --git a/tests/control_plane/shadow_e2e_fixture.py b/tests/control_plane/shadow_e2e_fixture.py new file mode 100644 index 0000000000..85471c8f83 --- /dev/null +++ b/tests/control_plane/shadow_e2e_fixture.py @@ -0,0 +1,181 @@ +"""Disposable public CLI fixtures and scheduling-only process crash seams.""" + +from __future__ import annotations + +import json +from pathlib import Path +import select +import subprocess +import sys +from dataclasses import dataclass + +REPO = Path(__file__).resolve().parents[2] + + +@dataclass +class ShadowWorkspace: + registry: Path + runtime: Path + state: Path + goal: str = "goal-e2e" + + def arguments(self, *args: str) -> list[str]: + return [ + "--registry", + str(self.registry), + "--runtime-root", + str(self.runtime), + "--format", + "json", + *args, + "--goal-id", + self.goal, + ] + + def cli(self, *args: str, success: bool = True) -> dict: + result = subprocess.run( + [sys.executable, "-m", "loopx.cli", *self.arguments(*args)], + cwd=REPO, + capture_output=True, + text=True, + timeout=45, + ) + if success: + assert result.returncode == 0, f"{result.stdout}\n{result.stderr}" + assert "Traceback" not in result.stderr, result.stderr + return json.loads(result.stdout) + + def add(self, text: str) -> dict: + return self.cli("todo", "add", "--role", "agent", "--text", text) + + def drain(self, **limits: str) -> dict: + args = [ + item + for key, value in limits.items() + for item in ("--" + key.replace("_", "-"), value) + ] + return self.cli("authority-shadow", "drain", *args, success=False) + + def crash(self, window: str, *args: str) -> dict: + child = subprocess.Popen( + [ + sys.executable, + "-c", + CRASH_WORKER, + window, + str(self.state), + *self.arguments(*args), + ], + cwd=REPO, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + assert child.stdout is not None + try: + readable, _, _ = select.select([child.stdout], [], [], 30) + assert readable, "public CLI did not reach the requested persistence window" + line = child.stdout.readline() + if not line.startswith("BARRIER "): + child.kill() + stdout, stderr = child.communicate(timeout=10) + raise AssertionError(f"No process barrier: {line}{stdout}\n{stderr}") + payload = json.loads(line.removeprefix("BARRIER ")) + child.kill() + child.communicate(timeout=10) + assert child.returncode == -9 + return payload + finally: + if child.poll() is None: + child.kill() + child.communicate(timeout=10) + + +def workspace(path: Path, *, bootstrap: bool = True) -> ShadowWorkspace: + path.mkdir(parents=True, exist_ok=True) + state = path / "ACTIVE_GOAL_STATE.md" + state.write_text( + "---\ngoal_id: goal-e2e\nhandoff_mode: hard_lease\n" + "updated_at: 2026-09-01T00:00:00+00:00\n---\n\n## Agent Todo\n\n", + encoding="utf-8", + ) + runtime, registry = path / "runtime", path / "registry.json" + registry.write_text( + json.dumps( + { + "common_runtime_root": str(runtime), + "goals": [ + { + "id": "goal-e2e", + "status": "active", + "repo": str(path), + "state_file": state.name, + "coordination": { + "agent_model": "peer_v1", + "registered_agents": ["agent-a", "agent-b"], + "runtime_shadow": { + "schema_version": "loopx_coordination_runtime_shadow_config_v0", + "enabled": True, + "provider": "file_v0", + }, + }, + } + ], + } + ), + encoding="utf-8", + ) + result = ShadowWorkspace(registry, runtime, state) + if bootstrap: + boot = result.cli("coordination-shadow", "bootstrap", "--execute")["bootstrap"] + assert boot["status"] == "applied", boot + return result + + +CRASH_WORKER = r""" +import json, pathlib, sys, time +from loopx.cli import main +from loopx.control_plane.coordination import local_authority_shadow_adapter as adapter +from loopx.control_plane.coordination import local_authority_shadow_outbox as outbox +from loopx.control_plane.todos import active_state_editing +window, state = sys.argv[1], pathlib.Path(sys.argv[2]) +def pause(payload=None): + print('BARRIER ' + json.dumps(payload or {}), flush=True) + time.sleep(40) + raise RuntimeError('parent failed to terminate at persistence barrier') +actual_rpc = adapter.effect_runtime_result +def rpc(method, request, **kwargs): + if method == 'coordination.runtime_shadow.commit_entry' and window == 'before_commit': + pause({'request': request}) + result = actual_rpc(method, request, **kwargs) + if method == 'coordination.runtime_shadow.commit_entry' and window == 'after_commit': + pause({'request': request, 'result': result}) + return result +adapter.effect_runtime_result = rpc +actual_cursor = outbox.write_cursor +def cursor(*args, **kwargs): + result = actual_cursor(*args, **kwargs) + if window == 'after_cursor': pause() + return result +outbox.write_cursor = cursor +actual_json = outbox.durable_write_json +def write_json(path, value): + if window == 'before_marker' and path.name.endswith('.committed.json'): pause() + return actual_json(path, value) +outbox.durable_write_json = write_json +actual_replace = active_state_editing.os.replace +def replace(source, target): + is_primary = pathlib.Path(target) == state + if is_primary and window == 'before_replace': pause() + result = actual_replace(source, target) + if is_primary and window == 'after_replace': pause() + return result +active_state_editing.os.replace = replace +actual_unlink = pathlib.Path.unlink +def unlink(path, *args, **kwargs): + result = actual_unlink(path, *args, **kwargs) + if window == 'between_unlinks' and path.name.endswith('.prepared.json'): pause() + return result +pathlib.Path.unlink = unlink +raise SystemExit(main(sys.argv[3:])) +""" diff --git a/tests/control_plane/test_coordination_runtime_shadow_adapter.py b/tests/control_plane/test_coordination_runtime_shadow_adapter.py index a31a3bfb58..76aa7d1ff5 100644 --- a/tests/control_plane/test_coordination_runtime_shadow_adapter.py +++ b/tests/control_plane/test_coordination_runtime_shadow_adapter.py @@ -1,13 +1,11 @@ from __future__ import annotations -from argparse import Namespace from pathlib import Path import pytest from loopx.cli_commands import todo as todo_command from loopx.cli_commands import task_lease as task_lease_command -from loopx.control_plane import effect_runtime from loopx.control_plane.coordination.runtime_shadow import ( RUNTIME_SHADOW_BOOTSTRAP_METHOD, RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA_VERSION, @@ -506,319 +504,24 @@ def test_todo_projection_rejects_unversioned_machine_owned_fields() -> None: ) -def test_committed_todo_hook_has_no_default_output_or_runtime_call( - monkeypatch, - tmp_path: Path, -) -> None: - monkeypatch.setattr( - todo_command, - "load_registry", - lambda _path: {"goals": [{"id": "goal-a"}]}, - ) - - def unexpected(*_args, **_kwargs): - raise AssertionError("default-off hook must not build or dispatch") - - monkeypatch.setattr(todo_command, "list_goal_todos", unexpected) - monkeypatch.setattr( - todo_command, "dispatch_coordination_runtime_shadow", unexpected - ) - result = todo_command._mirror_committed_todo_runtime_shadow( - { - "ok": True, - "dry_run": False, - "changed": True, - "updated_at": "2026-09-03T07:00:00+08:00", - "rollout_event": {"event_id": "event-a"}, - }, - args=Namespace( - goal_id="goal-a", - todo_command="update", - project=None, - state_file=None, - ), - registry_path=tmp_path / "registry.json", - runtime_root_arg=None, - ) - - assert result is None - - -def test_committed_todo_hook_dispatches_after_explicit_opt_in( - monkeypatch, - tmp_path: Path, -) -> None: - goal = { - "id": "goal-a", - "coordination": { - "runtime_shadow": { - "enabled": True, - "schema_version": RUNTIME_SHADOW_CONFIG_SCHEMA_VERSION, - "provider": "file_v0", - } - }, - } - monkeypatch.setattr( - todo_command, - "load_registry", - lambda _path: {"goals": [goal]}, - ) - monkeypatch.setattr(todo_command, "resolve_runtime_root", lambda *_args: tmp_path) - monkeypatch.setattr( - todo_command, - "list_goal_todos", - lambda **_kwargs: {"todos": [_canonical_todo(status="done")]}, - ) - captured: dict[str, object] = {} - - def dispatch(**kwargs): - captured.update(kwargs) - return {"status": "applied"} - - monkeypatch.setattr(todo_command, "dispatch_coordination_runtime_shadow", dispatch) - result = todo_command._mirror_committed_todo_runtime_shadow( - { - "ok": True, - "dry_run": False, - "changed": True, - "updated_at": "2026-09-03T07:00:00+08:00", - "rollout_event": {"event_id": "event-a"}, - }, - args=Namespace( - goal_id="goal-a", - todo_command="complete", - project=None, - state_file=None, - ), - registry_path=tmp_path / "registry.json", - runtime_root_arg=None, - ) - - assert result == {"status": "applied"} - assert captured["operation_id"] == "todo-shadow:event-a" - assert captured["event_kind"] == "todo_complete" - assert captured["projection"]["todos"] == [_canonical_todo(status="done")] - - -def test_committed_todo_hook_reaches_the_file_shadow_through_typescript( - monkeypatch, - tmp_path: Path, -) -> None: - goal = { - "id": "goal-a", - "coordination": { - "runtime_shadow": { - "enabled": True, - "schema_version": RUNTIME_SHADOW_CONFIG_SCHEMA_VERSION, - "provider": "file_v0", - } - }, - } - monkeypatch.setattr( - todo_command, - "load_registry", - lambda _path: {"goals": [goal]}, - ) - monkeypatch.setattr( - todo_command, - "resolve_runtime_root", - lambda *_args: tmp_path / "state", - ) - monkeypatch.setattr( - todo_command, - "list_goal_todos", - lambda **_kwargs: { - "todos": [_canonical_todo(claimed_by="agent-a")] - }, - ) - monkeypatch.setattr( - effect_runtime, - "_runtime_dir", - lambda: tmp_path / "effect-runtime", - ) - try: - result = todo_command._mirror_committed_todo_runtime_shadow( - { - "ok": True, - "dry_run": False, - "changed": True, - "updated_at": "2026-09-03T07:30:00+08:00", - "rollout_event": {"event_id": "event-real-runtime"}, - }, - args=Namespace( - goal_id="goal-a", - todo_command="claim", - project=None, - state_file=None, - ), - registry_path=tmp_path / "registry.json", - runtime_root_arg=None, - ) - finally: - effect_runtime.effect_runtime_result("runtime.shutdown", {}, retry_safe=False) - assert result is not None - assert result["status"] == "applied" - assert result["decision_read_from_shadow"] is False - assert result["parity"]["receipt_matches"] is True - assert result["parity"]["projection_readback"]["verified"] is True - - -def test_runtime_shadow_inspection_reaches_file_store_through_typescript( - monkeypatch, - tmp_path: Path, -) -> None: - goal = { - "id": "goal-a", - "coordination": { - "runtime_shadow": { - "enabled": True, - "schema_version": RUNTIME_SHADOW_CONFIG_SCHEMA_VERSION, - "provider": "file_v0", - } - }, - } - projection = build_todo_runtime_shadow_projection( - goal_id="goal-a", - todos=[ - { - "schema_version": "todo_item_v0", - "todo_id": "todo_one", - "role": "agent", - "status": "open", - "done": False, - "text": "qualify", - "archive_state": "active", - "source_section": "Agent Todo", - } - ], - ) - runtime_root = tmp_path / "state" - monkeypatch.setattr( - effect_runtime, - "_runtime_dir", - lambda: tmp_path / "effect-runtime", - ) - try: - before = inspect_coordination_runtime_shadow( - goal=goal, - runtime_root=runtime_root, - goal_id="goal-a", - projection=projection, - ) - applied = dispatch_coordination_runtime_shadow( - goal=goal, - runtime_root=runtime_root, - goal_id="goal-a", - operation_id="todo-shadow:event-inspect", - event_kind="todo_update", - source_version="state:1", - projection=projection, - ) - after = inspect_coordination_runtime_shadow( - goal=goal, - runtime_root=runtime_root, - goal_id="goal-a", - projection=projection, - ) - finally: - effect_runtime.effect_runtime_result("runtime.shutdown", {}, retry_safe=False) - assert before["status"] == "missing" - assert before["bootstrap_required"] is True - assert applied["status"] == "applied" - assert after["status"] == "matched" - assert after["parity_matches"] is True - assert after["decision_read_from_shadow"] is False -def test_runtime_shadow_qualification_reaches_file_store_through_typescript( - monkeypatch, - tmp_path: Path, -) -> None: - goal = { - "id": "goal-a", - "coordination": { - "runtime_shadow": { - "enabled": True, - "schema_version": RUNTIME_SHADOW_CONFIG_SCHEMA_VERSION, - "provider": "file_v0", - } - }, - } - projection = build_todo_runtime_shadow_projection( - goal_id="goal-a", - todos=[ - { - "schema_version": "todo_item_v0", - "todo_id": "todo_one", - "role": "agent", - "status": "open", - "done": False, - "text": "qualify", - "archive_state": "active", - "source_section": "Agent Todo", - } - ], - ) - runtime_root = tmp_path / "state" - monkeypatch.setattr( - effect_runtime, - "_runtime_dir", - lambda: tmp_path / "effect-runtime", - ) - try: - bootstrap = bootstrap_coordination_runtime_shadow( - goal=goal, - runtime_root=runtime_root, - goal_id="goal-a", - operation_id="bootstrap:goal-a:state-0", - source_version="state:0", - projection=projection, - ) - for index, event_kind in enumerate( - ("todo_claim", "task_lease_acquire", "todo_complete"), - start=1, - ): - result = dispatch_coordination_runtime_shadow( - goal=goal, - runtime_root=runtime_root, - goal_id="goal-a", - operation_id=f"shadow:goal-a:{index}", - event_kind=event_kind, - source_version=f"state:{index}", - projection=projection, - ) - assert result["status"] == "applied" - qualified = qualify_coordination_runtime_shadow( - goal=goal, - runtime_root=runtime_root, - goal_id="goal-a", - projection=projection, - minimum_operations=3, - required_event_kinds=["todo_claim", "task_lease_acquire"], - ) - finally: - effect_runtime.effect_runtime_result("runtime.shutdown", {}, retry_safe=False) - assert bootstrap["status"] == "applied" - assert qualified["status"] == "qualified" - assert qualified["qualified"] is True - assert qualified["evidence"]["operation_count"] == 3 - assert qualified["decision_read_from_shadow"] is False -def test_lease_projection_reads_compact_terminal_records(tmp_path: Path) -> None: +def test_lease_projection_preserves_complete_terminal_record(tmp_path: Path) -> None: lease_dir = tmp_path / "goals" / "goal-a" / "task-leases" lease_dir.mkdir(parents=True) (lease_dir / "todo_b.json").write_text( '{"schema_version":"task_lease_v0","goal_id":"goal-a",' '"todo_id":"todo_b","owner":"agent-a","version":2,' '"lease_epoch":1,"status":"released","released_at":"later",' - '"idempotency_key":"must-not-enter-projection"}', + '"idempotency_key":"retained-identity"}', encoding="utf-8", ) @@ -829,6 +532,9 @@ def test_lease_projection_reads_compact_terminal_records(tmp_path: Path) -> None assert records == [ { + "schema_version": "task_lease_v0", + "goal_id": "goal-a", + "idempotency_key": "retained-identity", "todo_id": "todo_b", "owner": "agent-a", "version": 2, @@ -839,151 +545,10 @@ def test_lease_projection_reads_compact_terminal_records(tmp_path: Path) -> None ] -def test_committed_task_lease_hook_dispatches_full_coordination_snapshot( - monkeypatch, - tmp_path: Path, -) -> None: - goal = { - "id": "goal-a", - "coordination": { - "runtime_shadow": { - "enabled": True, - "schema_version": RUNTIME_SHADOW_CONFIG_SCHEMA_VERSION, - "provider": "file_v0", - } - }, - } - monkeypatch.setattr( - task_lease_command, - "load_registry", - lambda _path: {"goals": [goal]}, - ) - monkeypatch.setattr( - task_lease_command, - "list_goal_todos", - lambda **_kwargs: {"todos": [_canonical_todo()]}, - ) - monkeypatch.setattr( - task_lease_command, - "load_task_lease_runtime_shadow_records", - lambda **_kwargs: [ - { - "todo_id": "todo_one", - "owner": "agent-a", - "version": 1, - "lease_epoch": 1, - "status": "active", - } - ], - ) - captured: dict[str, object] = {} - - def dispatch(**kwargs): - captured.update(kwargs) - return {"status": "applied"} - - monkeypatch.setattr( - task_lease_command, - "dispatch_coordination_runtime_shadow", - dispatch, - ) - result = task_lease_command._mirror_committed_task_lease_runtime_shadow( - { - "ok": True, - "lease": { - "todo_id": "todo_one", - "updated_at": "2026-09-03T07:05:00Z", - }, - }, - args=Namespace( - goal_id="goal-a", - todo_id="todo_one", - task_lease_command="acquire", - idempotency_key="acquire-1", - ), - registry_path=tmp_path / "registry.json", - runtime_root_arg=None, - runtime_root=tmp_path, - ) - - assert result == {"status": "applied"} - assert captured["operation_id"] == ( - "task-lease-shadow:acquire:goal-a:todo_one:acquire-1" - ) - assert captured["event_kind"] == "task_lease_acquire" - assert captured["projection"]["leases"] == [ - { - "todo_id": "todo_one", - "owner": "agent-a", - "version": 1, - "lease_epoch": 1, - "status": "active", - } - ] -def test_committed_task_lease_hook_reaches_file_shadow_through_typescript( - monkeypatch, - tmp_path: Path, -) -> None: - goal = { - "id": "goal-a", - "coordination": { - "runtime_shadow": { - "enabled": True, - "schema_version": RUNTIME_SHADOW_CONFIG_SCHEMA_VERSION, - "provider": "file_v0", - } - }, - } - monkeypatch.setattr( - task_lease_command, - "load_registry", - lambda _path: {"goals": [goal]}, - ) - monkeypatch.setattr( - task_lease_command, - "list_goal_todos", - lambda **_kwargs: {"todos": [_canonical_todo()]}, - ) - lease_dir = tmp_path / "state" / "goals" / "goal-a" / "task-leases" - lease_dir.mkdir(parents=True) - (lease_dir / "todo_one.json").write_text( - '{"todo_id":"todo_one","owner":"agent-a","version":1,' - '"lease_epoch":1,"updated_at":"2026-09-03T07:35:00Z",' - '"status":"active"}', - encoding="utf-8", - ) - monkeypatch.setattr( - effect_runtime, - "_runtime_dir", - lambda: tmp_path / "effect-runtime", - ) - try: - result = task_lease_command._mirror_committed_task_lease_runtime_shadow( - { - "ok": True, - "lease": { - "todo_id": "todo_one", - "updated_at": "2026-09-03T07:35:00Z", - }, - }, - args=Namespace( - goal_id="goal-a", - todo_id="todo_one", - task_lease_command="acquire", - idempotency_key="lease-real-runtime", - ), - registry_path=tmp_path / "registry.json", - runtime_root_arg=None, - runtime_root=tmp_path / "state", - ) - finally: - effect_runtime.effect_runtime_result("runtime.shutdown", {}, retry_safe=False) - assert result is not None - assert result["status"] == "applied" - assert result["decision_read_from_shadow"] is False - assert result["parity"]["receipt_matches"] is True - assert result["parity"]["projection_readback"]["verified"] is True +def test_retired_cli_observers_cannot_overwrite_transaction_evidence() -> None: + assert not hasattr(todo_command, "_mirror_committed_todo_runtime_shadow") + assert not hasattr(task_lease_command, "_mirror_committed_task_lease_runtime_shadow") diff --git a/tests/control_plane/test_coordination_shadow_command.py b/tests/control_plane/test_coordination_shadow_command.py index d29d86ec1b..3e601b6d31 100644 --- a/tests/control_plane/test_coordination_shadow_command.py +++ b/tests/control_plane/test_coordination_shadow_command.py @@ -48,21 +48,13 @@ def _run( monkeypatch.setattr( command, "resolve_runtime_root", lambda *_args, **_kwargs: tmp_path ) - monkeypatch.setattr( - command, - "list_goal_todos", - lambda **_kwargs: { - "todos": [ - _canonical_todo("todo_b", status="open"), - _canonical_todo("todo_a", status="done"), - ] - }, - ) - monkeypatch.setattr( - command, - "load_task_lease_runtime_shadow_records", - lambda **_kwargs: [{"todo_id": "todo_b", "owner": "agent-a"}], - ) + monkeypatch.setattr(command, "resolve_goal_state", lambda **kwargs: (_goal(), tmp_path, tmp_path / "ACTIVE_GOAL_STATE.md")) + monkeypatch.setattr(command, "build_runtime_shadow_source_snapshot", lambda **kwargs: ( + command.build_todo_runtime_shadow_projection(goal_id="goal-a", todos=[ + _canonical_todo("todo_b", status="open"), _canonical_todo("todo_a", status="done")], + leases=[{"todo_id": "todo_b", "owner": "agent-a"}]), + {"state_path": str(tmp_path / "ACTIVE_GOAL_STATE.md")}, + )) captured: dict[str, object] = {} def print_payload(payload, *_args) -> None: @@ -323,7 +315,7 @@ def qualify(**kwargs) -> dict[str, object]: ] -def test_coordination_shadow_rollback_is_revision_fenced_and_reads_back_missing( +def test_coordination_shadow_rollback_passes_exact_selector_to_management_owner( monkeypatch, tmp_path: Path, ) -> None: @@ -369,7 +361,7 @@ def rollback(**kwargs) -> dict[str, object]: assert result == 0 assert payload["ok"] is True assert payload["executed"] is True - assert payload["inspection"]["status"] == "missing" + assert payload["inspection"]["status"] == "not_evaluated" assert rollback_request["expected_provider_revision"] == "file:revision-1" assert rollback_request["operation_id"] == ( "shadow-rollback:goal-a:file:revision-1" @@ -387,7 +379,7 @@ def test_coordination_shadow_rejects_goal_without_exact_opt_in( ) monkeypatch.setattr( command, - "list_goal_todos", + "build_runtime_shadow_source_snapshot", lambda **_kwargs: (_ for _ in ()).throw( AssertionError("must not read legacy state without opt-in") ), diff --git a/tests/control_plane/test_coordination_state_contract.py b/tests/control_plane/test_coordination_state_contract.py index fe3d4e43d7..0d58e3e2f0 100644 --- a/tests/control_plane/test_coordination_state_contract.py +++ b/tests/control_plane/test_coordination_state_contract.py @@ -33,10 +33,7 @@ LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA, ) from loopx.control_plane.turn_driver import delivery_continuity -from loopx.control_plane.coordination.local_authority_shadow_adapter import ( - LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA as BRIDGE_SHADOW_EVIDENCE_SCHEMA, - LOCAL_AUTHORITY_SHADOW_REQUEST_SCHEMA as BRIDGE_SHADOW_REQUEST_SCHEMA, -) +from loopx.control_plane.coordination.local_authority_shadow_observation import LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA as BRIDGE_SHADOW_EVIDENCE_SCHEMA, LOCAL_AUTHORITY_SHADOW_REQUEST_SCHEMA as BRIDGE_SHADOW_REQUEST_SCHEMA from loopx.control_plane.coordination.local_authority_shadow_outbox import ( OUTBOX_ENTRY_SCHEMA, ) diff --git a/tests/control_plane/test_effect_runtime_integration.py b/tests/control_plane/test_effect_runtime_integration.py index af55b87bcb..47fbc5230f 100644 --- a/tests/control_plane/test_effect_runtime_integration.py +++ b/tests/control_plane/test_effect_runtime_integration.py @@ -16,6 +16,7 @@ from loopx.control_plane.coordination.runtime_shadow import ( RUNTIME_SHADOW_CONFIG_SCHEMA_VERSION, bootstrap_coordination_runtime_shadow, + build_runtime_shadow_source_snapshot, dispatch_coordination_runtime_shadow, inspect_coordination_runtime_shadow, read_coordination_runtime_shadow_todo_candidate, @@ -155,7 +156,7 @@ def test_managed_runtime_is_reused_and_restart_safe_for_typed_write( ) -def test_coordination_runtime_shadow_crosses_python_typescript_boundary( +def test_retired_coordination_snapshot_mirror_is_rejected_across_runtime_boundary( tmp_path: Path, monkeypatch, ) -> None: @@ -201,15 +202,13 @@ def test_coordination_runtime_shadow_crosses_python_typescript_boundary( projection=request["projection"], ) - assert applied["status"] == "applied" - assert applied["parity"]["receipt_matches"] is True - assert applied["parity"]["projection_readback"]["verified"] is True - assert replayed["status"] == "replayed" - assert replayed["cursor"] == applied["cursor"] == "1" - assert read_candidate["status"] == "matched" - assert read_candidate["todo"]["claimed_by"] == "agent-a" - assert read_candidate["read_candidate_qualified"] is True + assert applied["status"] == "failed" + assert applied["reason_code"] == "legacy_lineage_read_only" + assert replayed["status"] == "failed" + assert replayed["reason_code"] == "legacy_lineage_read_only" + assert read_candidate["read_candidate_qualified"] is False assert read_candidate["decision_read_from_shadow"] is False + assert not (tmp_path / "state/authority-shadow/file-v0").exists() effect_runtime.effect_runtime_result("runtime.shutdown", {}, retry_safe=False) @@ -221,6 +220,8 @@ def test_coordination_runtime_shadow_bootstrap_crosses_python_typescript_boundar monkeypatch.setattr(effect_runtime, "_runtime_dir", lambda: tmp_path / "runtime") goal = { "id": "shadow-bootstrap-goal", + "repo": str(tmp_path), + "state_file": "ACTIVE_GOAL_STATE.md", "coordination": { "runtime_shadow": { "enabled": True, @@ -229,13 +230,14 @@ def test_coordination_runtime_shadow_bootstrap_crosses_python_typescript_boundar } }, } - projection = { - "schema_version": "loopx_coordination_runtime_shadow_projection_v0", - "goal_id": "shadow-bootstrap-goal", - "source_authority": "legacy_markdown_and_task_lease", - "todos": [{"todo_id": "todo_existing", "status": "open"}], - "leases": [], - } + state_path = tmp_path / "ACTIVE_GOAL_STATE.md" + state_path.write_text("---\ngoal_id: shadow-bootstrap-goal\nhandoff_mode: hard_lease\n---\n\n## Agent Todo\n\n" + "- [ ] Preserve the existing Todo.\n" + " \n", encoding="utf-8") + registry_path = tmp_path / "registry.json" + registry_path.write_text(json.dumps({"common_runtime_root": str(tmp_path / "state"), "goals": [goal]}), encoding="utf-8") + projection, source_snapshot = build_runtime_shadow_source_snapshot(goal=goal, runtime_root=tmp_path / "state", + state_path=state_path, registry_path=registry_path) request = { "goal": goal, "runtime_root": tmp_path / "state", @@ -243,6 +245,7 @@ def test_coordination_runtime_shadow_bootstrap_crosses_python_typescript_boundar "operation_id": "bootstrap:shadow-bootstrap-goal:state-1", "source_version": "state:1", "projection": projection, + "source_snapshot": source_snapshot, } applied = bootstrap_coordination_runtime_shadow(**request) @@ -252,6 +255,7 @@ def test_coordination_runtime_shadow_bootstrap_crosses_python_typescript_boundar runtime_root=tmp_path / "state", goal_id="shadow-bootstrap-goal", projection=projection, + source_snapshot=source_snapshot, ) assert applied["status"] == "applied" @@ -269,6 +273,7 @@ def test_coordination_runtime_shadow_bootstrap_crosses_python_typescript_boundar f"rollback:shadow-bootstrap-goal:{applied['provider_revision']}" ), "expected_provider_revision": str(applied["provider_revision"]), + "source_snapshot": source_snapshot, } rolled_back = rollback_coordination_runtime_shadow(**rollback_request) rollback_replay = rollback_coordination_runtime_shadow(**rollback_request) @@ -277,6 +282,7 @@ def test_coordination_runtime_shadow_bootstrap_crosses_python_typescript_boundar runtime_root=tmp_path / "state", goal_id="shadow-bootstrap-goal", projection=projection, + source_snapshot=source_snapshot, ) assert rolled_back["status"] == "applied" diff --git a/tests/control_plane/test_local_authority_shadow_drain.py b/tests/control_plane/test_local_authority_shadow_drain.py index 73b760d4e6..0ecabf4a23 100644 --- a/tests/control_plane/test_local_authority_shadow_drain.py +++ b/tests/control_plane/test_local_authority_shadow_drain.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import uuid from pathlib import Path import pytest @@ -12,53 +13,20 @@ partition_digest, text_digest, ) -from loopx.file_lock import exclusive_file_lock +from loopx.file_lock import exclusive_file_lock, exclusive_cross_runtime_file_lock +from loopx.control_plane.coordination.shadow_management import require_shadow_primary_write_allowed +from shadow_e2e_fixture import workspace from loopx.history import load_registry from loopx.registry import find_registry_goal -from loopx.todos import add_goal_todo, list_goal_todos +from loopx.todos import list_goal_todos -GOAL_ID = "goal-drain" +GOAL_ID = "goal-e2e" def _fixture(tmp_path: Path) -> tuple[Path, Path, Path]: - repo = tmp_path / "repo" - repo.mkdir() - state = repo / "ACTIVE_GOAL_STATE.md" - state.write_text( - "---\n" - f"goal_id: {GOAL_ID}\n" - "handoff_mode: hard_lease\n" - "updated_at: 2026-09-03T00:00:00+00:00\n" - "---\n\n" - "## Agent Todo\n\n", - encoding="utf-8", - ) - runtime_root = tmp_path / "runtime" - registry = tmp_path / "registry.json" - registry.write_text( - json.dumps( - { - "common_runtime_root": str(runtime_root), - "goals": [ - { - "id": GOAL_ID, - "domain": "harness_self_improvement", - "status": "active", - "repo": str(repo), - "state_file": state.name, - "adapter": {"kind": "harness_self_improvement"}, - "coordination": { - "agent_model": "peer_v1", - "registered_agents": ["agent-a"], - }, - } - ], - } - ), - encoding="utf-8", - ) - return registry, state, runtime_root + real = workspace(tmp_path / "repo") + return real.registry, real.state, real.runtime def _record_todo_write( @@ -70,7 +38,7 @@ def _record_todo_write( mark_committed: bool = True, write_file: bool = True, ) -> outbox.TodoPartitionCapture: - """Simulate a hooked writer: capture around one add_goal_todo write.""" + """Prepare a source transaction without invoking a second capture-enabled writer.""" original = state.read_text(encoding="utf-8") goal = find_registry_goal(load_registry(registry), GOAL_ID) @@ -83,42 +51,26 @@ def _record_todo_write( original_text=original, projector=adapter.todo_partition_projector(goal, state_path=state), ) - if write_file: - result = add_goal_todo( - registry_path=registry, - goal_id=GOAL_ID, - role="agent", - text=text, - task_class="advancement_task", - ) - assert result["ok"] is True - new_text = state.read_text(encoding="utf-8") - else: - # The bytes the writer would have produced, without producing them. - result = add_goal_todo( - registry_path=registry, - goal_id=GOAL_ID, - role="agent", - text=text, - task_class="advancement_task", - ) - assert result["ok"] is True - new_text = state.read_text(encoding="utf-8") - state.write_text(original, encoding="utf-8") + todo_id = f"todo_{uuid.uuid4().hex[:12]}" + new_text = original + ( + f"- [ ] {text}\n" + f" \n" + ) capture.prepare(new_text) assert capture.outcome.failure is None assert capture.outcome.entry_id is not None + if write_file: + state.write_text(new_text, encoding="utf-8") if mark_committed: capture.committed() return capture def _drain(registry: Path, runtime_root: Path, **overrides: object) -> adapter.DrainResult: + limits = {"max_entries": 20, "budget_seconds": 10, **overrides} return adapter.drain_local_authority_shadow_outbox( - registry_path=registry, - runtime_root=runtime_root, - goal_id=GOAL_ID, - **overrides, # type: ignore[arg-type] + registry_path=registry, runtime_root=runtime_root, goal_id=GOAL_ID, + **limits, # type: ignore[arg-type] ) @@ -137,20 +89,20 @@ def test_drain_delivers_each_committed_entry_once_in_order_and_verifies_readback assert result.ok is True assert result.outcome == "drained" - assert result.config_enabled is False + assert result.config_enabled is True assert (result.delivered, result.replayed, result.no_op) == (3, 0, 0) assert result.pending_after == 0 assert result.prepared_only_after == 0 assert result.budget_exhausted is False assert result.candidate_readback_verified is True - assert result.last_cursor == "3" - assert (result.cursor_before, result.cursor_after, result.drained_count) == (None, "3", 3) + assert result.last_cursor == "4" + assert (result.cursor_before, result.cursor_after, result.drained_count) == ("1", "4", 3) payload = result.to_payload() assert payload["ok"] is True assert payload["drained_count"] == 3 - assert payload["cursor_after"] == "3" + assert payload["cursor_after"] == "4" assert [item["outcome"] for item in result.entries] == ["delivered"] * 3 - assert [item["cursor"] for item in result.entries] == ["1", "2", "3"] + assert [item["cursor"] for item in result.entries] == ["2", "3", "4"] assert [item["entry_id"] for item in result.entries] == [ capture.outcome.entry_id for capture in captures ] @@ -177,10 +129,10 @@ def test_drain_delivers_each_committed_entry_once_in_order_and_verifies_readback todo["todo_id"] for todo in listed["todos"] ) assert view["head_digest"] == head_digest(head) == result.head_digest - assert [tx["operation_id"] for tx in view["scan"]["transactions"]] == [ + assert [tx["operation_id"] for tx in view["scan"]["transactions"][1:]] == [ capture.outcome.entry_id for capture in captures ] - receipts = [tx["receipts"][0] for tx in view["scan"]["transactions"]] + receipts = [tx["receipts"][0] for tx in view["scan"]["transactions"][1:]] assert all(receipt["source_transaction_correlated"] is True for receipt in receipts) assert all(receipt["durable_source_outbox"] is True for receipt in receipts) assert all(receipt["parity_verdict"] == "not_evaluated" for receipt in receipts) @@ -210,21 +162,23 @@ def crash(*_args: object, **_kwargs: object) -> None: assert first.pending_after == 1 monkeypatch.undo() + calls = _commit_entry_calls(monkeypatch) second = _drain(registry, runtime_root) assert second.ok is True + assert "coordination.runtime_shadow.commit_entry" not in calls assert (second.delivered, second.replayed) == (0, 1) assert second.entries[0]["entry_id"] == capture.outcome.entry_id - assert second.entries[0]["cursor"] == "1" + assert second.entries[0]["cursor"] == "2" assert second.pending_after == 0 assert second.candidate_readback_verified is True view = adapter.read_local_authority_shadow(runtime_root=runtime_root, goal_id=GOAL_ID, scan_limit=10) - assert len(view["scan"]["transactions"]) == 1 + assert len(view["scan"]["transactions"]) == 2 def test_drain_defers_when_another_drainer_holds_the_lock(tmp_path: Path) -> None: registry, state, runtime_root = _fixture(tmp_path) _record_todo_write(registry, state, runtime_root, "Pending behind a drainer") - with exclusive_file_lock( + with exclusive_cross_runtime_file_lock( outbox.drain_lock_target(runtime_root, GOAL_ID), timeout_seconds=1.0, operation="test_hold" ): result = _drain(registry, runtime_root, lock_timeout_seconds=0.05) @@ -251,13 +205,13 @@ def test_drain_batch_is_bounded_and_reports_what_it_left(tmp_path: Path) -> None second = _drain(registry, runtime_root) assert second.delivered == 1 assert second.pending_after == 0 - assert (second.cursor_before, second.cursor_after) == ("2", "3") + assert (second.cursor_before, second.cursor_after) == ("3", "4") view = adapter.read_local_authority_shadow(runtime_root=runtime_root, goal_id=GOAL_ID) - assert view["cursor"] == "3" + assert view["cursor"] == "4" assert view["head"]["partitions"]["todos"]["seq"] == 3 -def test_drain_stops_in_order_when_the_store_boundary_misbehaves( +def test_drain_stops_in_order_on_real_candidate_corruption( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: registry, state, runtime_root = _fixture(tmp_path) @@ -265,24 +219,29 @@ def test_drain_stops_in_order_when_the_store_boundary_misbehaves( _record_todo_write(registry, state, runtime_root, f"Ordered {index}") real = adapter.effect_runtime_result calls: list[str] = [] - - def flaky(method: str, params: object, **kwargs: object) -> object: - calls.append(method) - if method == "coordination.runtime_shadow.commit_entry" and len(calls) == 2: - return {"schema_version": "garbage"} + saved: list[bytes] = [] + candidate = next((runtime_root / "authority-shadow" / "file-v0").glob("authority-store-*.json")) + + def corrupt_before_second_commit(method: str, params: object, **kwargs: object) -> object: + if method == "coordination.runtime_shadow.commit_entry": + calls.append(method) + if len(calls) == 2: + saved.append(candidate.read_bytes()) + candidate.write_text("{malformed candidate history") + # The real TypeScript handler and real FileAuthorityStore decide every result. return real(method, params, **kwargs) - monkeypatch.setattr(adapter, "effect_runtime_result", flaky) + monkeypatch.setattr(adapter, "effect_runtime_result", corrupt_before_second_commit) result = _drain(registry, runtime_root) assert result.outcome == "stopped" assert result.delivered == 1 - assert result.stopped_at is not None - assert result.stopped_at["seq"] == 2 - assert result.stopped_at["reason_code"] == "shadow_commit_entry_result_invalid" + assert result.stopped_at is not None and result.stopped_at["seq"] == 2 + assert result.stopped_at["outcome"] in {"failed", "unavailable"} assert result.pending_after == 2 assert [entry.seq for entry in outbox.list_entries(_todo_dir(runtime_root))] == [2, 3] + assert candidate.read_text() == "{malformed candidate history" monkeypatch.undo() - + candidate.write_bytes(saved[0]) recovered = _drain(registry, runtime_root) assert recovered.delivered == 2 assert recovered.pending_after == 0 @@ -293,8 +252,8 @@ def test_prepared_only_entries_resolve_only_under_a_free_primary_lock(tmp_path: proven = _record_todo_write(registry, state, runtime_root, "Marker lost after write", mark_committed=False) with exclusive_file_lock(state, timeout_seconds=1.0, operation="writer_in_flight"): busy = _drain(registry, runtime_root) - assert busy.outcome == "drained" - assert busy.in_flight_partitions == ["todos"] + assert busy.outcome == "stopped" + assert busy.reason_code == "primary_writer_busy" assert busy.prepared_only_after == 1 assert busy.delivered == 0 @@ -306,7 +265,7 @@ def test_prepared_only_entries_resolve_only_under_a_free_primary_lock(tmp_path: assert result.entries[0]["entry_id"] == proven.outcome.entry_id view = adapter.read_local_authority_shadow(runtime_root=runtime_root, goal_id=GOAL_ID, scan_limit=5) assert view["head"]["partitions"]["todos"]["partition_digest"] == proven.outcome.partition_digest - receipt = view["scan"]["transactions"][0]["receipts"][0] + receipt = view["scan"]["transactions"][1]["receipts"][0] assert receipt["resolution"] == "committed_proven_by_readback" abandoned = _record_todo_write( @@ -318,41 +277,27 @@ def test_prepared_only_entries_resolve_only_under_a_free_primary_lock(tmp_path: assert result.entries[0]["resolution"] == "abandoned" assert result.entries[0]["entry_id"] == abandoned.outcome.entry_id view = adapter.read_local_authority_shadow(runtime_root=runtime_root, goal_id=GOAL_ID, scan_limit=5) - assert view["cursor"] == "2" + assert view["cursor"] == "3" assert view["head"]["partitions"]["todos"]["seq"] == 1 - assert view["scan"]["transactions"][1]["events"][0]["kind"] == "source_transaction_abandoned" + assert view["scan"]["transactions"][2]["events"][0]["kind"] == "source_transaction_abandoned" -def test_unexplained_prepared_only_entry_triggers_reseed_under_the_primary_lock(tmp_path: Path) -> None: +def test_unexplained_prepared_entry_holds_without_reseed_or_deletion(tmp_path: Path) -> None: registry, state, runtime_root = _fixture(tmp_path) _record_todo_write(registry, state, runtime_root, "Baseline") assert _drain(registry, runtime_root).delivered == 1 stale = _record_todo_write(registry, state, runtime_root, "Half-recorded", mark_committed=False) - # An unhooked writer edits the file after the crash: neither digest matches. - state.write_text( - state.read_text(encoding="utf-8") + "\n- [ ] (agent) foreign edit \n", - encoding="utf-8", - ) - + state.write_text(state.read_text() + "\nExternal source change after the writer stopped.\n") + before = {str(path.relative_to(runtime_root)): path.read_bytes() + for path in (runtime_root / "authority-shadow").rglob("*") if path.is_file()} result = _drain(registry, runtime_root) - - assert result.ok is True - assert result.reseeded == 1 - assert result.no_op == 1 - assert [item["resolution"] for item in result.entries] == ["unproved", "seed"] - assert result.entries[0]["entry_id"] == stale.outcome.entry_id - view = adapter.read_local_authority_shadow(runtime_root=runtime_root, goal_id=GOAL_ID, scan_limit=10) - kinds = [tx["events"][0]["kind"] for tx in view["scan"]["transactions"]] - assert kinds == ["source_transaction_delivered", "source_transaction_unproved", "partition_seeded"] - listed = list_goal_todos(registry_path=registry, goal_id=GOAL_ID, runtime_root_arg=str(runtime_root)) - assert [todo["todo_id"] for todo in view["head"]["todos"]] == sorted( - todo["todo_id"] for todo in listed["todos"] - ) - assert len(view["head"]["todos"]) == 3 - assert view["head"]["partitions"]["todos"]["seq"] == 3 - seed_receipt = view["scan"]["transactions"][2]["receipts"][0] - assert seed_receipt["write_class"] == "reseed_after_crash_gap" - assert seed_receipt["source_bytes_digest"] == text_digest(state.read_text(encoding="utf-8")) + assert result.outcome == "stopped" + assert result.reason_code == "outbox_source_unproved" + assert result.delivered == result.no_op == 0 + assert result.entries == [] + assert [entry.entry_id for entry in outbox.list_entries(_todo_dir(runtime_root))] == [stale.outcome.entry_id] + assert {str(path.relative_to(runtime_root)): path.read_bytes() + for path in (runtime_root / "authority-shadow").rglob("*") if path.is_file()} == before def test_lease_partition_entries_retain_complete_records_at_drain(tmp_path: Path) -> None: @@ -375,12 +320,16 @@ def test_lease_partition_entries_retain_complete_records_at_drain(tmp_path: Path "acquire_ttl_seconds": 1800, } bytes_digest = text_digest(json.dumps(record, indent=2) + "\n") - entry_id = outbox.entry_identity(goal_id=GOAL_ID, partition="leases", seq=1, source_ref=bytes_digest) + binding = require_shadow_primary_write_allowed(runtime_root, GOAL_ID) + assert binding is not None + entry_id = outbox.entry_identity(goal_id=GOAL_ID, partition="leases", seq=1, source_ref=bytes_digest, + capture_lineage_id=binding["capture_lineage_id"], source_root_digest=binding["source_root_digest"]) outbox.durable_write_json( directory / outbox.entry_file_name(1, entry_id, "prepared"), { "schema_version": outbox.OUTBOX_ENTRY_SCHEMA, "goal_id": GOAL_ID, + "capture_lineage_id": binding["capture_lineage_id"], "partition": "leases", "seq": 1, "entry_id": entry_id, @@ -388,6 +337,7 @@ def test_lease_partition_entries_retain_complete_records_at_drain(tmp_path: Path "source": { "kind": "task_lease_record", "previous_bytes_digest": None, + "previous_partition_digest": partition_digest({"leases": []}), "bytes_digest": bytes_digest, "lease": {"todo_id": record["todo_id"], "version": 2, "lease_epoch": 1, "status": "active", "updated_at": record["updated_at"]}, "previous_lease": None, @@ -401,9 +351,12 @@ def test_lease_partition_entries_retain_complete_records_at_drain(tmp_path: Path ) outbox.durable_write_json( directory / outbox.entry_file_name(1, entry_id, "committed"), - {"schema_version": outbox.OUTBOX_COMMIT_SCHEMA, "entry_id": entry_id, "committed_at": "2026-09-03T00:01:00.100Z"}, + {"schema_version": outbox.OUTBOX_COMMIT_SCHEMA, "entry_id": entry_id, "capture_lineage_id": binding["capture_lineage_id"], "committed_at": "2026-09-03T00:01:00.100Z"}, ) + primary_lease = outbox.lease_directory(runtime_root, GOAL_ID) / (record["todo_id"] + ".json") + primary_lease.parent.mkdir(parents=True, exist_ok=True) + primary_lease.write_text(json.dumps(record, indent=2) + "\n") result = _drain(registry, runtime_root) assert result.ok is True @@ -411,7 +364,7 @@ def test_lease_partition_entries_retain_complete_records_at_drain(tmp_path: Path view = adapter.read_local_authority_shadow(runtime_root=runtime_root, goal_id=GOAL_ID, scan_limit=5) head = view["head"] assert head["todos"] == [] - assert head["handoff_mode"] is None + assert head["handoff_mode"] == "hard_lease" assert head["leases"] == [record] expected_digest = partition_digest({"leases": head["leases"]}) assert head["partitions"]["leases"] == {"seq": 1, "partition_digest": expected_digest} @@ -424,8 +377,9 @@ def test_status_reports_backlog_candidate_and_growth_facts(tmp_path: Path) -> No empty = adapter.local_authority_shadow_status(registry_path=registry, runtime_root=runtime_root, goal_id=GOAL_ID) assert empty["ok"] is True assert empty["config"]["status"] == "disabled" - assert empty["candidate"]["status"] == "missing" - assert empty["store_bytes"] == 0 + assert empty["candidate"]["status"] == "loaded" + assert empty["candidate"]["cursor"] == "1" + assert empty["store_bytes"] > 0 assert empty["retention_pressure"] is False assert str(runtime_root) not in json.dumps(empty) @@ -445,7 +399,7 @@ def test_status_reports_backlog_candidate_and_growth_facts(tmp_path: Path) -> No "invalid": None, } assert drained["candidate"]["status"] == "loaded" - assert drained["candidate"]["cursor"] == "1" + assert drained["candidate"]["cursor"] == "2" assert drained["candidate"]["codec_agreement"] is True assert drained["candidate"]["head_schema_version"] == "loopx_coordination_runtime_shadow_projection_v0" assert drained["store_bytes"] > 0 @@ -505,17 +459,18 @@ def test_crash_between_the_two_unlinks_leaves_residue_the_next_drain_reclaims( ) -> None: registry, state, runtime_root = _fixture(tmp_path) capture = _record_todo_write(registry, state, runtime_root, "Retired but half-removed") - real_remove = outbox.remove_entry_files + real_remove = outbox.reclaim_verified_files - def crash_between_unlinks(entry: outbox.OutboxEntry) -> None: - entry.prepared_path.unlink(missing_ok=True) + def crash_between_unlinks(files: object) -> None: + batch = list(files) + batch[0][0].unlink() raise OSError("simulated crash between the prepared and committed unlinks") - monkeypatch.setattr(outbox, "remove_entry_files", crash_between_unlinks) + monkeypatch.setattr(outbox, "reclaim_verified_files", crash_between_unlinks) first = _drain(registry, runtime_root) assert first.outcome == "stopped" assert first.reason_code == "shadow_drain_failed" - monkeypatch.setattr(outbox, "remove_entry_files", real_remove) + monkeypatch.setattr(outbox, "reclaim_verified_files", real_remove) # On disk: the cursor covers seq 1 and only the committed marker survives. marker_name = outbox.entry_file_name(1, str(capture.outcome.entry_id), "committed") @@ -523,33 +478,34 @@ def crash_between_unlinks(entry: outbox.OutboxEntry) -> None: assert names == sorted([marker_name, "drain-cursor.json"]) assert outbox.read_cursor(_todo_dir(runtime_root))["last_seq"] == 1 # The marker is retired residue, not corruption: listing stays valid. - assert outbox.list_entries(_todo_dir(runtime_root)) == [] + assert len(outbox.list_entries(_todo_dir(runtime_root), allow_committed_only=True)) == 1 assert [path.name for path in outbox.retired_residue(_todo_dir(runtime_root))] == [marker_name] summary = outbox.outbox_summary(runtime_root, GOAL_ID)["todos"] - assert summary["invalid"] is None - assert summary["retired_residue"] == 1 + assert summary["invalid"] == "outbox_file_invalid" + assert len(outbox.retired_residue(_todo_dir(runtime_root))) == 1 assert summary["committed_pending"] == 0 status = adapter.local_authority_shadow_status(registry_path=registry, runtime_root=runtime_root, goal_id=GOAL_ID) - assert status["ok"] is True - assert status["outbox"]["todos"]["retired_residue"] == 1 + assert status["ok"] is False + assert status["outbox"]["todos"]["invalid"] == "outbox_file_invalid" calls = _commit_entry_calls(monkeypatch) second = _drain(registry, runtime_root) assert second.ok is True assert second.outcome == "drained" assert second.reclaimed_residue == 1 - assert (second.delivered, second.replayed) == (0, 0) + assert (second.delivered, second.replayed) == (0, 1) assert "coordination.runtime_shadow.commit_entry" not in calls assert list(_todo_dir(runtime_root).iterdir()) == [_todo_dir(runtime_root) / "drain-cursor.json"] view = adapter.read_local_authority_shadow(runtime_root=runtime_root, goal_id=GOAL_ID, scan_limit=5) - assert view["cursor"] == "1" + assert view["cursor"] == "2" # A later write mints seq 2 from the cursor, never reusing the retired seq. later = _record_todo_write(registry, state, runtime_root, "After the reclaim") assert later.outcome.seq == 2 third = _drain(registry, runtime_root) assert third.delivered == 1 - assert third.reclaimed_residue == 0 + # The newly delivered transaction also removes its two verified files. + assert third.reclaimed_residue == 2 def test_crash_after_the_cursor_but_before_any_unlink_is_reclaimed_without_a_store_call( @@ -557,14 +513,14 @@ def test_crash_after_the_cursor_but_before_any_unlink_is_reclaimed_without_a_sto ) -> None: registry, state, runtime_root = _fixture(tmp_path) capture = _record_todo_write(registry, state, runtime_root, "Cursor written, files untouched") - real_remove = outbox.remove_entry_files + real_remove = outbox.reclaim_verified_files - def crash_before_unlinks(entry: outbox.OutboxEntry) -> None: + def crash_before_unlinks(files: object) -> None: raise OSError("simulated crash after the cursor write") - monkeypatch.setattr(outbox, "remove_entry_files", crash_before_unlinks) + monkeypatch.setattr(outbox, "reclaim_verified_files", crash_before_unlinks) assert _drain(registry, runtime_root).outcome == "stopped" - monkeypatch.setattr(outbox, "remove_entry_files", real_remove) + monkeypatch.setattr(outbox, "reclaim_verified_files", real_remove) names = sorted(path.name for path in _todo_dir(runtime_root).iterdir()) entry_id = str(capture.outcome.entry_id) assert names == [ @@ -572,7 +528,7 @@ def crash_before_unlinks(entry: outbox.OutboxEntry) -> None: outbox.entry_file_name(1, entry_id, "prepared"), "drain-cursor.json", ] - assert outbox.list_entries(_todo_dir(runtime_root)) == [] + assert len(outbox.list_entries(_todo_dir(runtime_root), allow_committed_only=True)) == 1 calls = _commit_entry_calls(monkeypatch) result = _drain(registry, runtime_root) @@ -602,14 +558,14 @@ def test_drain_fails_closed_on_an_entry_recorded_for_another_runtime_root(tmp_pa capture = _record_todo_write(registry, state, runtime_root, "Written under a foreign root") entry_id = str(capture.outcome.entry_id) prepared_path = _todo_dir(runtime_root) / outbox.entry_file_name(1, entry_id, "prepared") - record = json.loads(prepared_path.read_text(encoding="utf-8")) + record = json.loads(prepared_path.read_text()) record["source_root_digest"] = outbox.runtime_root_digest(tmp_path / "elsewhere") outbox.durable_write_json(prepared_path, record) - + before = prepared_path.read_bytes() result = _drain(registry, runtime_root) - assert result.outcome == "stopped" - assert result.reason_code == "source_root_mismatch" + assert result.reason_code == "outbox_file_invalid" assert result.delivered == 0 - assert result.pending_after == 1 - assert [entry.seq for entry in outbox.list_entries(_todo_dir(runtime_root))] == [1] + assert prepared_path.read_bytes() == before + with pytest.raises(outbox.OutboxError): + outbox.list_entries(_todo_dir(runtime_root)) diff --git a/tests/control_plane/test_local_authority_shadow_outbox.py b/tests/control_plane/test_local_authority_shadow_outbox.py index 907dc8b140..2e3a594916 100644 --- a/tests/control_plane/test_local_authority_shadow_outbox.py +++ b/tests/control_plane/test_local_authority_shadow_outbox.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import uuid from pathlib import Path import pytest @@ -16,16 +17,20 @@ text_digest, todo_partition_projection, ) +from loopx.control_plane.coordination.runtime_shadow import ( + bootstrap_coordination_runtime_shadow, + build_runtime_shadow_source_snapshot, +) +from loopx.control_plane.coordination.shadow_management import require_shadow_primary_write_allowed from loopx.file_lock import exclusive_file_lock from loopx.history import load_registry from loopx.registry import find_registry_goal -from loopx.todos import add_goal_todo GOAL_ID = "goal-outbox" -def _fixture(tmp_path: Path) -> tuple[Path, Path, Path]: +def _fixture(tmp_path: Path, *, bootstrap: bool = True) -> tuple[Path, Path, Path]: repo = tmp_path / "repo" repo.mkdir() state = repo / "ACTIVE_GOAL_STATE.md" @@ -62,6 +67,21 @@ def _fixture(tmp_path: Path) -> tuple[Path, Path, Path]: ), encoding="utf-8", ) + if bootstrap: + goal = find_registry_goal(load_registry(registry), GOAL_ID) + projection, snapshot = build_runtime_shadow_source_snapshot( + goal=goal, runtime_root=runtime_root, state_path=state, registry_path=registry, + ) + enabled_goal = {**goal, "coordination": {**goal["coordination"], "runtime_shadow": { + "schema_version": "loopx_coordination_runtime_shadow_config_v0", + "enabled": True, "provider": "file_v0", + }}} + result = bootstrap_coordination_runtime_shadow( + goal=enabled_goal, runtime_root=runtime_root, goal_id=GOAL_ID, + operation_id="bootstrap:outbox-test", source_version="source:initial", + projection=projection, source_snapshot=snapshot, + ) + assert result["status"] == "applied", result return registry, state, runtime_root @@ -86,27 +106,55 @@ def _capture( ) -def _add_todo(registry: Path, text: str) -> str: - result = add_goal_todo( - registry_path=registry, - goal_id=GOAL_ID, - role="agent", - text=text, - task_class="advancement_task", +def _planned_todo(original: str, text: str) -> tuple[str, str]: + """Build source bytes for a low-level capture test; do not run another writer.""" + todo_id = f"todo_{uuid.uuid4().hex[:12]}" + return todo_id, original + ( + f"- [ ] {text}\n" + f" \n" ) - assert result["ok"] is True - return str(result["todo_id"]) def _todo_dir(runtime_root: Path) -> Path: return outbox.partition_directory(runtime_root, GOAL_ID, "todos") +def _drain(registry: Path, runtime_root: Path) -> adapter.DrainResult: + original = registry.read_bytes() + data = json.loads(original) + data["goals"][0]["coordination"]["runtime_shadow"] = { + "schema_version": "loopx_coordination_runtime_shadow_config_v0", + "enabled": True, "provider": "file_v0", + } + registry.write_text(json.dumps(data)) + try: + return adapter.drain_local_authority_shadow_outbox( + registry_path=registry, runtime_root=runtime_root, goal_id=GOAL_ID, + max_entries=10, budget_seconds=10, lock_timeout_seconds=2, + ) + finally: + registry.write_bytes(original) + + +def _record_change(registry: Path, state: Path, runtime_root: Path, text: str) -> outbox.TodoPartitionCapture: + original = state.read_text() + _, proposed = _planned_todo(original, text) + capture = _capture(registry, state, runtime_root, original_text=original) + capture.prepare(proposed) + state.write_text(proposed) + capture.committed() + assert capture.outcome.failure is None, capture.outcome.failure + return capture + + +def _files(directory: Path) -> dict[str, bytes]: + return {str(path.relative_to(directory)): path.read_bytes() for path in directory.rglob("*") if path.is_file()} + + def test_capture_records_prepared_then_committed_and_skips_prose_only_writes(tmp_path: Path) -> None: registry, state, runtime_root = _fixture(tmp_path) original = state.read_text(encoding="utf-8") - todo_id = _add_todo(registry, "Bind the shadow to the primary transaction.") - new_text = state.read_text(encoding="utf-8") + todo_id, new_text = _planned_todo(original, "Bind the shadow to the primary transaction.") capture = _capture(registry, state, runtime_root, original_text=original) capture.prepare(new_text) @@ -117,9 +165,12 @@ def test_capture_records_prepared_then_committed_and_skips_prose_only_writes(tmp assert capture.outcome.seq == 1 assert capture.outcome.source_bytes_digest == text_digest(new_text) assert capture.outcome.entry_id == outbox.entry_identity( - goal_id=GOAL_ID, partition="todos", seq=1, source_ref=text_digest(new_text) + goal_id=GOAL_ID, partition="todos", seq=1, source_ref=text_digest(new_text), + capture_lineage_id=require_shadow_primary_write_allowed(runtime_root, GOAL_ID)["capture_lineage_id"], + source_root_digest=outbox.runtime_root_digest(runtime_root), ) + state.write_text(new_text) capture.committed() entries = outbox.list_entries(_todo_dir(runtime_root)) assert [entry.is_committed for entry in entries] == [True] @@ -130,6 +181,7 @@ def test_capture_records_prepared_then_committed_and_skips_prose_only_writes(tmp assert entry.prepared["source"] == { "kind": "markdown_active_state", "previous_bytes_digest": text_digest(original), + "previous_partition_digest": partition_digest({"handoff_mode": "hard_lease", "todos": []}), "bytes_digest": text_digest(new_text), "lease": None, "event_id": None, @@ -154,16 +206,17 @@ def test_capture_records_prepared_then_committed_and_skips_prose_only_writes(tmp assert prose.outcome.skipped_reason == "partition_unchanged" assert len(outbox.list_entries(_todo_dir(runtime_root))) == 1 - _add_todo(registry, "Second coordination fact.") + _, third_text = _planned_todo(new_text, "Second coordination fact.") third = _capture(registry, state, runtime_root, original_text=new_text) - third.prepare(state.read_text(encoding="utf-8")) + third.prepare(third_text) + state.write_text(third_text) third.committed() assert third.outcome.seq == 2 assert [entry.seq for entry in outbox.list_entries(_todo_dir(runtime_root))] == [1, 2] def test_disabled_capture_creates_nothing(tmp_path: Path) -> None: - registry, state, runtime_root = _fixture(tmp_path) + registry, state, runtime_root = _fixture(tmp_path, bootstrap=False) capture = _capture(registry, state, runtime_root, original_text="", enabled=False) capture.prepare("# anything") capture.committed() @@ -172,51 +225,26 @@ def test_disabled_capture_creates_nothing(tmp_path: Path) -> None: assert not (runtime_root / "authority-shadow").exists() -def test_event_branch_records_projection_in_committed_marker(tmp_path: Path) -> None: +def test_event_only_capture_holds_without_inventing_projection_or_retiring_entries(tmp_path: Path) -> None: registry, state, runtime_root = _fixture(tmp_path) - empty = state.read_text(encoding="utf-8") - baseline_id = _add_todo(registry, "Baseline coordination fact.") - original = state.read_text(encoding="utf-8") - baseline = _capture(registry, state, runtime_root, original_text=empty) - baseline.prepare(original) - baseline.committed() - assert baseline.outcome.seq == 1 - - # An appended event that changes no compared field retires its own entry. - unchanged = _capture(registry, state, runtime_root, original_text=original, write_class="todo_complete_event_projection") - unchanged.prepare(original, event_id="evt-noop") - assert unchanged.outcome.entry_id is not None - unchanged.committed(projection_from_disk=True) - assert unchanged.outcome.entry_id is None - assert unchanged.outcome.skipped_reason == "partition_unchanged" + _record_change(registry, state, runtime_root, "Baseline coordination fact.") + original = state.read_text() + before = _files(_todo_dir(runtime_root)) + for event_id, proposed in (("evt-noop", original), ("evt-change", original + "\n## Operator Notes\nEvent evidence.\n")): + capture = _capture(registry, state, runtime_root, original_text=original, + write_class="todo_complete_event_projection") + capture.prepare(proposed, event_id=event_id) + capture.committed() + assert capture.outcome.entry_id is None + assert capture.outcome.skipped_reason == "event_log_writer_not_bound" + assert _files(_todo_dir(runtime_root)) == before assert [entry.seq for entry in outbox.list_entries(_todo_dir(runtime_root))] == [1] - capture = _capture(registry, state, runtime_root, original_text=original, write_class="todo_complete_event_projection") - capture.prepare(original, event_id="evt-1") - [_baseline, entry] = outbox.list_entries(_todo_dir(runtime_root)) - assert entry.prepared["source"]["kind"] == "state_event_log" - assert entry.prepared["source"]["event_id"] == "evt-1" - assert entry.prepared["projection"] is None - assert entry.prepared["writer"]["operation_id"] == "evt-1" - assert entry.source_ref == "event:evt-1" - assert not entry.is_committed - - todo_id = _add_todo(registry, "Landed by the event append.") - capture.committed(projection_from_disk=True) - [_baseline, entry] = outbox.list_entries(_todo_dir(runtime_root)) - assert entry.is_committed - projection = entry.projection() - assert projection is not None - assert sorted(item["todo_id"] for item in projection["todos"]) == sorted([baseline_id, todo_id]) - assert entry.recorded_partition_digest() == partition_digest(projection) - assert capture.outcome.partition_digest == partition_digest(projection) - def test_prepared_only_entries_resolve_from_source_probes(tmp_path: Path) -> None: registry, state, runtime_root = _fixture(tmp_path) original = state.read_text(encoding="utf-8") - _add_todo(registry, "Crash between write and marker.") - new_text = state.read_text(encoding="utf-8") + _, new_text = _planned_todo(original, "Crash between write and marker.") capture = _capture(registry, state, runtime_root, original_text=original) capture.prepare(new_text) [entry] = outbox.list_entries(_todo_dir(runtime_root)) @@ -226,7 +254,7 @@ def resolve(text: str) -> str: return outbox.resolve_prepared_only_entry( entry, markdown_text_reader=lambda: text, - lease_record_reader=None, + lease_bytes_reader=None, event_presence_reader=None, ) @@ -244,75 +272,66 @@ def resolve(text: str) -> str: committed=None, ) assert outbox.resolve_prepared_only_entry( - event_entry, markdown_text_reader=None, lease_record_reader=None, + event_entry, markdown_text_reader=None, lease_bytes_reader=None, event_presence_reader=lambda event_id: event_id == "evt-9", ) == "unproved" assert outbox.resolve_prepared_only_entry( - event_entry, markdown_text_reader=None, lease_record_reader=None, + event_entry, markdown_text_reader=None, lease_bytes_reader=None, event_presence_reader=lambda _event_id: False, ) == "abandoned" - planned = {"todo_id": "todo-a", "version": 2, "lease_epoch": 1, "status": "active", "updated_at": "t2"} - previous = {"todo_id": "todo-a", "version": 1, "lease_epoch": 1, "status": "active", "updated_at": "t1"} + planned = {"todo_id": "todo-a", "version": 2, "lease_epoch": 1, "status": "active", "updated_at": "t2", "owner": "agent-a"} + previous = {**planned, "version": 1, "updated_at": "t1"} + planned_bytes = canonical_bytes(planned) + previous_bytes = canonical_bytes(previous) lease_entry = outbox.OutboxEntry( - partition="leases", - seq=1, - entry_id="local-shadow-tx-" + "1" * 64, - prepared_path=tmp_path / "unused.prepared.json", - committed_path=None, - prepared={"source": {"kind": "task_lease_record", "lease": planned, "previous_lease": previous}}, + partition="leases", seq=1, entry_id="local-shadow-tx-" + "1" * 64, + prepared_path=tmp_path / "unused.prepared.json", committed_path=None, + prepared={"source": {"kind": "task_lease_record", "lease": planned, + "bytes_digest": outbox.raw_bytes_digest(planned_bytes), + "previous_bytes_digest": outbox.raw_bytes_digest(previous_bytes)}}, committed=None, ) - def lease_resolve(current: dict[str, object] | None) -> str: + def lease_resolve(current: bytes | None) -> str: return outbox.resolve_prepared_only_entry( - lease_entry, - markdown_text_reader=None, - lease_record_reader=lambda _todo_id: current, + lease_entry, markdown_text_reader=None, lease_bytes_reader=lambda _todo_id: current, event_presence_reader=None, ) - assert lease_resolve({**planned, "owner": "agent-a"}) == "committed" - assert lease_resolve({**previous, "owner": "agent-a"}) == "abandoned" - assert lease_resolve({**planned, "version": 9}) == "unproved" + assert lease_resolve(planned_bytes) == "committed" + assert lease_resolve(previous_bytes) == "abandoned" + # Equal versions/epochs/statuses never prove different owner or payload bytes. + assert lease_resolve(canonical_bytes({**planned, "owner": "agent-b"})) == "unproved" + assert lease_resolve(canonical_bytes({**planned, "extra": "unrecorded"})) == "unproved" + assert lease_resolve(canonical_bytes({**planned, "version": 9})) == "unproved" assert lease_resolve(None) == "unproved" -def test_sequence_advances_past_the_drain_cursor_and_lists_oldest_first(tmp_path: Path) -> None: - _registry, _state, runtime_root = _fixture(tmp_path) +def test_cursor_allocation_hint_does_not_authorize_a_gap_or_candidate_write(tmp_path: Path) -> None: + registry, state, runtime_root = _fixture(tmp_path) directory = _todo_dir(runtime_root) directory.mkdir(parents=True) - outbox.write_cursor( - directory, - partition="todos", - last_seq=5, - last_entry_id="local-shadow-tx-" + "a" * 64, - last_partition_digest=None, - last_cursor="5", - last_provider_revision="rev-5", - ) + outbox.write_cursor(directory, partition="todos", last_seq=5, + last_entry_id="local-shadow-tx-" + "a" * 64, + last_partition_digest=None, last_cursor="5", last_provider_revision="rev-5") assert outbox.next_seq(directory) == 6 - seed = outbox.SeedSource(partition="todos", projection={"handoff_mode": "hard_lease", "todos": []}) - first = outbox.write_seed_entry(runtime_root=runtime_root, goal_id=GOAL_ID, seed=seed) - second = outbox.write_seed_entry(runtime_root=runtime_root, goal_id=GOAL_ID, seed=seed) - assert (first.seq, second.seq) == (6, 7) + first = _record_change(registry, state, runtime_root, "Recorded after the cursor hint.") + second = _record_change(registry, state, runtime_root, "Another source transaction.") + assert (first.outcome.seq, second.outcome.seq) == (6, 7) assert [entry.seq for entry in outbox.list_entries(directory)] == [6, 7] - assert all(entry.is_committed for entry in outbox.list_entries(directory)) - assert outbox.latest_partition_digest(directory) == partition_digest(seed.projection) summary = outbox.outbox_summary(runtime_root, GOAL_ID) assert summary["todos"]["committed_pending"] == 2 assert summary["todos"]["cursor_last_seq"] == 5 assert summary["leases"] == { - "committed_pending": 0, - "prepared_only": 0, - "retired_residue": 0, - "next_seq": 0, - "cursor_last_seq": None, - "cursor_last_entry_id": None, - "invalid": None, + "committed_pending": 0, "prepared_only": 0, "retired_residue": 0, "next_seq": 0, + "cursor_last_seq": None, "cursor_last_entry_id": None, "invalid": None, } - outbox.remove_entry_files(first) - assert [entry.seq for entry in outbox.list_entries(directory)] == [7] + before = _files(runtime_root / "authority-shadow") + result = _drain(registry, runtime_root) + assert result.reason_code == "outbox_cursor_unproved" + assert result.delivered == 0 + assert _files(runtime_root / "authority-shadow") == before def test_canonical_projection_rejects_floats_and_bad_lease_identity() -> None: @@ -333,17 +352,20 @@ def test_canonical_projection_rejects_floats_and_bad_lease_identity() -> None: assert projection["leases"][1]["extra"] == "retained" -def test_capture_failure_is_typed_and_never_raises(tmp_path: Path) -> None: +def test_capture_failure_is_typed_and_preserves_the_primary_result(tmp_path: Path) -> None: registry, state, runtime_root = _fixture(tmp_path) - blocker = runtime_root / "authority-shadow" - blocker.parent.mkdir(parents=True) - blocker.write_text("not a directory", encoding="utf-8") - capture = _capture(registry, state, runtime_root, original_text="") - capture.prepare("---\ngoal_id: goal-outbox\n---\n\n## Agent Todo\n\n- [ ] (agent) x \n") + original = state.read_text() + _, new_text = _planned_todo(original, "Primary survives an unavailable outbox directory.") + blocker = _todo_dir(runtime_root) + blocker.write_text("not a directory") + capture = _capture(registry, state, runtime_root, original_text=original) + capture.prepare(new_text) + state.write_text(new_text) capture.committed() assert capture.outcome.entry_id is None assert capture.outcome.failure is not None assert capture.outcome.failure["reason_code"] == "outbox_prepare_failed" + assert state.read_text() == new_text def test_primary_lock_probe_reports_held_locks(tmp_path: Path) -> None: @@ -358,9 +380,10 @@ def test_primary_lock_probe_reports_held_locks(tmp_path: Path) -> None: def test_prepared_records_must_bind_their_directory_identity_and_source(tmp_path: Path) -> None: registry, state, runtime_root = _fixture(tmp_path) original = state.read_text(encoding="utf-8") - _add_todo(registry, "Bound to this goal and partition.") + _, new_text = _planned_todo(original, "Bound to this goal and partition.") capture = _capture(registry, state, runtime_root, original_text=original) - capture.prepare(state.read_text(encoding="utf-8")) + capture.prepare(new_text) + state.write_text(new_text) capture.committed() directory = _todo_dir(runtime_root) [entry] = outbox.list_entries(directory) @@ -390,38 +413,67 @@ def tampered(**changes: object) -> None: outbox.durable_write_json(entry.prepared_path, entry.prepared) assert len(outbox.list_entries(directory)) == 1 - # Seed entries bind their identity through the partition digest instead. - lease_seed = outbox.write_seed_entry( - runtime_root=runtime_root, - goal_id=GOAL_ID, - seed=outbox.lease_seed_source(runtime_root, GOAL_ID), - ) - assert outbox.record_source_ref(lease_seed.prepared) == f"seed:{lease_seed.recorded_partition_digest()}" - lease_directory = outbox.partition_directory(runtime_root, GOAL_ID, "leases") - assert [item.entry_id for item in outbox.list_entries(lease_directory)] == [lease_seed.entry_id] + # A valid-looking root/lineage from another generation cannot reuse this ID. + tampered(capture_lineage_id="another-lineage") + tampered(source_root_digest="sha256:" + "1" * 64) + tampered(schema_version="loopx_local_authority_shadow_outbox_entry_v0") + outbox.durable_write_json(entry.prepared_path, entry.prepared) + assert entry.committed_path is not None and entry.committed is not None + outbox.durable_write_json(entry.committed_path, {**entry.committed, "capture_lineage_id": "another-lineage"}) + with pytest.raises(outbox.OutboxError) as invalid_marker: + outbox.list_entries(directory) + assert invalid_marker.value.reason_code == "outbox_file_invalid" + outbox.durable_write_json(entry.committed_path, entry.committed) + assert len(outbox.list_entries(directory)) == 1 -def test_retired_residue_is_defined_by_the_cursor_watermark(tmp_path: Path) -> None: - _registry, _state, runtime_root = _fixture(tmp_path) +def test_cursor_residue_is_diagnostic_and_requires_an_exact_receipt(tmp_path: Path) -> None: + registry, state, runtime_root = _fixture(tmp_path) + _record_change(registry, state, runtime_root, "First source transaction.") + _record_change(registry, state, runtime_root, "Second source transaction.") directory = _todo_dir(runtime_root) - seed = outbox.SeedSource(partition="todos", projection={"handoff_mode": "hard_lease", "todos": []}) - first = outbox.write_seed_entry(runtime_root=runtime_root, goal_id=GOAL_ID, seed=seed) - second = outbox.write_seed_entry(runtime_root=runtime_root, goal_id=GOAL_ID, seed=seed) + first, second = outbox.list_entries(directory) assert outbox.retired_residue(directory) == [] - outbox.write_cursor( - directory, - partition="todos", - last_seq=first.seq, - last_entry_id=first.entry_id, - last_partition_digest=first.recorded_partition_digest(), - last_cursor="1", - last_provider_revision="rev-1", - ) + outbox.write_cursor(directory, partition="todos", last_seq=first.seq, + last_entry_id=first.entry_id, last_partition_digest=first.recorded_partition_digest(), + last_cursor="2", last_provider_revision="file:2:" + "1" * 24) assert [path.name for path in outbox.retired_residue(directory)] == sorted( - [first.committed_path.name, first.prepared_path.name] # type: ignore[union-attr] + [first.committed_path.name, first.prepared_path.name] ) - assert [entry.seq for entry in outbox.list_entries(directory)] == [second.seq] + # Hiding entries below an unproved watermark would lose the only delivery evidence. + assert [entry.seq for entry in outbox.list_entries(directory)] == [first.seq, second.seq] assert outbox.next_seq(directory) == 3 - assert outbox.reclaim_retired_residue(directory) == 2 - assert outbox.retired_residue(directory) == [] - assert [entry.seq for entry in outbox.list_entries(directory)] == [second.seq] + before = _files(runtime_root / "authority-shadow") + result = _drain(registry, runtime_root) + assert result.reason_code == "outbox_cursor_unproved" + assert _files(runtime_root / "authority-shadow") == before + + +def test_exact_receipts_allow_cursor_then_residue_cleanup(tmp_path: Path) -> None: + registry, state, runtime_root = _fixture(tmp_path) + _record_change(registry, state, runtime_root, "An exact transaction to deliver.") + directory = _todo_dir(runtime_root) + original = _files(directory) + delivered = _drain(registry, runtime_root) + assert delivered.delivered == 1, delivered.reason_code + cursor = outbox.read_cursor(directory) + assert cursor is not None and cursor["last_seq"] == 1 + assert outbox.list_entries(directory) == [] + for name, raw in original.items(): + (directory / name).write_bytes(raw) + assert len(outbox.retired_residue(directory)) == 2 + recovered = _drain(registry, runtime_root) + assert recovered.reason_code is None, recovered.reason_code + assert outbox.list_entries(directory) == [] + assert outbox.read_cursor(directory) == cursor + + +def test_reclaim_validates_the_complete_batch_before_any_unlink(tmp_path: Path) -> None: + first, second = tmp_path / "first.json", tmp_path / "second.json" + first.write_bytes(b"first exact receipt bytes") + second.write_bytes(b"second changed after proof") + proof = [(first, outbox.raw_bytes_digest(first.read_bytes())), (second, outbox.raw_bytes_digest(b"different bytes"))] + with pytest.raises(outbox.OutboxError) as raised: + outbox.reclaim_verified_files(proof) + assert raised.value.reason_code == "outbox_file_changed" + assert first.exists() and second.exists() diff --git a/tests/control_plane/test_local_authority_shadow_runtime.py b/tests/control_plane/test_local_authority_shadow_runtime.py index 614045a959..dccae6e59c 100644 --- a/tests/control_plane/test_local_authority_shadow_runtime.py +++ b/tests/control_plane/test_local_authority_shadow_runtime.py @@ -5,10 +5,7 @@ import pytest -from loopx.control_plane.coordination.local_authority_shadow_adapter import ( - LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA, - observe_local_authority_commit, -) +from loopx.control_plane.coordination.local_authority_shadow_observation import LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA, observe_local_authority_commit from loopx.control_plane.todos.handoff_mode import set_goal_handoff_mode from loopx.control_plane.work_items.task_lease import ( acquire_task_lease, @@ -117,7 +114,7 @@ def forbidden(*args: object, **kwargs: object) -> object: raise AssertionError("default-off path constructed the shadow runtime") monkeypatch.setattr( - "loopx.control_plane.coordination.local_authority_shadow_adapter.effect_runtime_result", + "loopx.control_plane.coordination.local_authority_shadow_observation.effect_runtime_result", forbidden, ) @@ -396,7 +393,7 @@ def unavailable( } monkeypatch.setattr( - "loopx.control_plane.coordination.local_authority_shadow_adapter.effect_runtime_result", + "loopx.control_plane.coordination.local_authority_shadow_observation.effect_runtime_result", unavailable, ) @@ -467,7 +464,7 @@ def test_provider_revision_conflict_resamples_source_under_same_observation_lock requests: list[dict[str, object]] = [] monkeypatch.setattr( - "loopx.control_plane.coordination.local_authority_shadow_adapter._stable_projection", + "loopx.control_plane.coordination.local_authority_shadow_observation._stable_projection", lambda **_kwargs: next(projections), ) @@ -503,7 +500,7 @@ def conflict_then_advance( } monkeypatch.setattr( - "loopx.control_plane.coordination.local_authority_shadow_adapter.effect_runtime_result", + "loopx.control_plane.coordination.local_authority_shadow_observation.effect_runtime_result", conflict_then_advance, ) diff --git a/tests/control_plane/test_local_coordination_authority.py b/tests/control_plane/test_local_coordination_authority.py index a36907ee67..baae6afae7 100644 --- a/tests/control_plane/test_local_coordination_authority.py +++ b/tests/control_plane/test_local_coordination_authority.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import hashlib import subprocess import sys from concurrent.futures import ThreadPoolExecutor @@ -17,12 +16,12 @@ from loopx.control_plane.coordination.runtime_shadow import ( build_todo_runtime_shadow_projection, ) -from loopx.control_plane.effect_runtime import effect_runtime_result from loopx.control_plane.todos.active_state_editing import TODO_SECTION_HEADINGS from loopx.control_plane.coordination.legacy_writer_fence import ( legacy_coordination_writer_fence_path, ) -from loopx.todos import add_goal_todo, list_goal_todos +from loopx.todos import add_goal_todo, list_goal_todos, update_goal_todo +from canonical_authority_fixture import initialize_canonical_authority def _engage_fence(runtime_root: Path, goal_id: str = "goal-a") -> None: @@ -41,85 +40,6 @@ def _todo_read_model(todo_count: int) -> dict[str, object]: } -def _promote_local_projection( - *, - runtime_root: Path, - goal_id: str, - projection: dict[str, object], - operation_suffix: str, -) -> str: - canonical_bytes = json.dumps( - projection, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - ).encode("utf-8") - projection_sha256 = hashlib.sha256(canonical_bytes).hexdigest() - source_version = f"state:{operation_suffix}:1" - - bootstrap = effect_runtime_result( - "coordination.runtime_shadow.bootstrap", - { - "schema_version": "loopx_coordination_runtime_shadow_bootstrap_v0", - "runtime_root": str(runtime_root), - "goal_id": goal_id, - "operation_id": f"bootstrap:{goal_id}:{operation_suffix}", - "source_version": f"state:{operation_suffix}:0", - "projection": projection, - }, - ) - assert bootstrap["status"] == "applied" - mirrored = effect_runtime_result( - "coordination.runtime_shadow.commit", - { - "schema_version": "loopx_coordination_runtime_shadow_commit_v0", - "runtime_root": str(runtime_root), - "goal_id": goal_id, - "operation_id": f"todo:{goal_id}:{operation_suffix}:qualify", - "event_kind": "todo_update", - "source_version": source_version, - "projection": projection, - }, - ) - assert mirrored["status"] == "applied" - provider_revision = str(mirrored["provider_revision"]) - fence = { - "schema_version": "loopx_legacy_coordination_writer_fence_v0", - "state": "engaged", - "goal_id": goal_id, - "fence_id": f"legacy-writer-fence:{goal_id}:{operation_suffix}", - "source_version": source_version, - "source_projection_sha256": projection_sha256, - "expected_shadow_provider_revision": provider_revision, - } - engaged = effect_runtime_result( - "coordination.local_authority.legacy_writer_fence.engage", - { - "schema_version": "loopx_legacy_coordination_writer_fence_engage_request_v0", - "runtime_root": str(runtime_root), - "goal_id": goal_id, - "fence": fence, - }, - ) - assert engaged["status"] == "applied" - promoted = effect_runtime_result( - "coordination.local_authority.promote", - { - "schema_version": "loopx_local_coordination_promotion_request_v0", - "runtime_root": str(runtime_root), - "goal_id": goal_id, - "operation_id": f"promote:{goal_id}:{operation_suffix}", - "expected_shadow_provider_revision": provider_revision, - "expected_shadow_projection_sha256": projection_sha256, - "minimum_operations": 1, - "required_event_kinds": ["todo_update"], - "writer_fence": fence, - }, - ) - assert promoted["status"] == "applied" - return provider_revision - - def test_absent_fence_preserves_legacy_path_without_starting_typescript( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -399,7 +319,7 @@ def test_todo_list_uses_provider_after_cutover_even_when_markdown_disagrees( assert result["authority_read"]["legacy_fallback_used"] is False -def test_promoted_hard_lease_claim_cli_atomically_acquires_ownership( +def test_canonical_hard_lease_claim_cli_atomically_acquires_ownership( tmp_path: Path, ) -> None: runtime_root = tmp_path / "runtime" @@ -445,12 +365,7 @@ def test_promoted_hard_lease_claim_cli_atomically_acquires_ownership( todos=[todo], ) projection["handoff_mode"] = "hard_lease" - _promote_local_projection( - runtime_root=runtime_root, - goal_id="goal-a", - projection=projection, - operation_suffix="atomic-claim", - ) + initialize_canonical_authority(runtime_root, "goal-a", projection, state_path=state_file) state_file.unlink() command = [ @@ -524,10 +439,10 @@ def test_promoted_hard_lease_claim_cli_atomically_acquires_ownership( assert not state_file.exists() -def test_real_shadow_projection_promotes_complete_complex_todo_semantics( +def test_real_canonical_provider_preserves_complete_complex_todo_semantics( tmp_path: Path, ) -> None: - """Exercise builder -> shadow -> promotion -> production Todo list.""" + """Preserve the full record through a real already canonical provider.""" runtime_root = tmp_path / "runtime" project = tmp_path / "project" @@ -622,13 +537,9 @@ def test_real_shadow_projection_promotes_complete_complex_todo_semantics( projection = build_todo_runtime_shadow_projection( goal_id="goal-a", todos=[complex_todo, successor, claimable], + handoff_mode="soft_claim", ) - _promote_local_projection( - runtime_root=runtime_root, - goal_id="goal-a", - projection=projection, - operation_suffix="complex", - ) + initialize_canonical_authority(runtime_root, "goal-a", projection, state_path=state_file) state_file.unlink() result = list_goal_todos(registry_path=registry_path, goal_id="goal-a") diff --git a/tests/control_plane/test_runtime_shadow_bounded_e2e.py b/tests/control_plane/test_runtime_shadow_bounded_e2e.py new file mode 100644 index 0000000000..276d9e4dbf --- /dev/null +++ b/tests/control_plane/test_runtime_shadow_bounded_e2e.py @@ -0,0 +1,320 @@ +"""Real public CLI and independent native TypeScript file-profile qualification. + +The production writers, file provider and parsers run unchanged. Tests control +only process scheduling and deliberately edited source bytes. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest +import subprocess +import sys + +from loopx.control_plane.coordination.runtime_shadow import build_runtime_shadow_source_snapshot +from loopx.control_plane.coordination.coordination_state_contract_generated import TASK_LEASE_ACQUIRE_REQUEST_SCHEMA +from loopx.control_plane.work_items.task_lease_acquire_adapter import task_lease_acquire_authority_facts + +REPO = Path(__file__).resolve().parents[2] + + +pytestmark = pytest.mark.stage2c_e2e + + +def workspace(tmp_path: Path) -> tuple[Path, Path, Path]: + state = tmp_path / "ACTIVE_GOAL_STATE.md" + state.write_text("---\ngoal_id: goal-a\nhandoff_mode: hard_lease\n---\n\n## Agent Todo\n\n", encoding="utf-8") + runtime = tmp_path / "runtime" + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{ + "id": "goal-a", "status": "active", "repo": str(tmp_path), "state_file": state.name, + "coordination": {"agent_model": "peer_v1", "registered_agents": ["agent-a", "agent-b"], + "runtime_shadow": {"schema_version": "loopx_coordination_runtime_shadow_config_v0", "enabled": False, "provider": "file_v0"}}, + }]}), encoding="utf-8") + return registry, runtime, state + + +def cli(registry: Path, runtime: Path, *arguments: str, success: bool = True) -> dict: + completed = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(registry), + "--runtime-root", str(runtime), "--format", "json", *arguments], cwd=REPO, + env={**os.environ, "PYTHONPATH": str(REPO)}, capture_output=True, text=True, timeout=45) + assert completed.stdout.strip(), completed.stderr + payload = json.loads(completed.stdout) + if success: + assert completed.returncode == 0, (completed.stderr, payload) + assert payload.get("ok") is True, payload + return payload + + +def enable(registry: Path) -> dict: + value = json.loads(registry.read_text()) + value["goals"][0]["coordination"]["runtime_shadow"]["enabled"] = True + registry.write_text(json.dumps(value), encoding="utf-8") + return value["goals"][0] + + +def native(tmp_path: Path, module: str, function: str, request: dict) -> dict: + path = tmp_path / "native-request.json" + path.write_text(json.dumps(request), encoding="utf-8") + script = (f"import {{ {function} }} from {json.dumps((REPO / module).as_uri())};" + "import {readFile} from 'node:fs/promises';" + f"process.stdout.write(JSON.stringify(await {function}(JSON.parse(await readFile(process.argv[1],'utf8')))));" ) + process = subprocess.run(["node", "--no-warnings", "--experimental-strip-types", "--input-type=module", "-e", script, str(path)], + cwd=tmp_path, capture_output=True, text=True, timeout=45) + assert process.returncode == 0, process.stderr + return json.loads(process.stdout) + + +def history(tmp_path: Path, runtime: Path) -> list[dict]: + result = native(tmp_path, "loopx/control_plane/coordination/local_authority_shadow.ts", "readLocalAuthorityShadow", { + "schema_version": "loopx_coordination_runtime_shadow_outbox_read_v0", "runtime_root": str(runtime), + "goal_id": "goal-a", "scan_limit": 10000, + }) + assert result["status"] == "loaded", result + return result["proof"]["transactions"] + + +def acquire_native(tmp_path: Path, registry: Path, runtime: Path, todo_id: str) -> dict: + return native(tmp_path, "loopx/control_plane/work_items/task_lease_acquire.ts", "executeTaskLeaseAcquire", { + "schema_version": TASK_LEASE_ACQUIRE_REQUEST_SCHEMA, "runtime_root": str(runtime), "goal_id": "goal-a", + "todo_id": todo_id, "owner": "agent-b", "idempotency_key": "native-" + todo_id, "ttl_seconds": 120, + "write_scopes": [], "expected_version": None, + "authority": task_lease_acquire_authority_facts(registry_path=registry, goal_id="goal-a", todo_id=todo_id), + "runtime_shadow": {"schema_version": "loopx_coordination_runtime_shadow_binding_v0", "provider": "file_v0"}, + }) + + +def test_public_cli_and_independent_native_writer_qualify_one_complete_lineage(tmp_path: Path) -> None: + registry, runtime, state = workspace(tmp_path) + cli(registry, runtime, "handoff-mode", "set", "--goal-id", "goal-a", "--mode", "soft_claim") + archived = cli(registry, runtime, "todo", "add", "--goal-id", "goal-a", "--role", "agent", "--text", "Previously completed task", "--claimed-by", "agent-a") + cli(registry, runtime, "todo", "complete", "--goal-id", "goal-a", "--todo-id", archived["todo_id"], + "--agent-id", "agent-a", "--evidence", "validation://completed", "--no-follow-up") + cli(registry, runtime, "todo", "archive-completed", "--goal-id", "goal-a", "--max-active-done", "0", "--execute") + cli(registry, runtime, "handoff-mode", "set", "--goal-id", "goal-a", "--mode", "hard_lease") + first = cli(registry, runtime, "todo", "add", "--goal-id", "goal-a", "--role", "agent", "--text", "Existing first task") + second = cli(registry, runtime, "todo", "add", "--goal-id", "goal-a", "--role", "agent", "--text", "Existing second task") + baseline_lease = cli(registry, runtime, "task-lease", "acquire", "--goal-id", "goal-a", "--todo-id", first["todo_id"], + "--owner", "agent-a", "--idempotency-key", "baseline-lease", "--ttl-seconds", "120") + assert not (runtime / "authority-shadow" / "file-v0").exists() + enable(registry) + boot = cli(registry, runtime, "coordination-shadow", "bootstrap", "--goal-id", "goal-a", "--execute") + assert boot["projection_summary"] == {"todo_count": 2, "lease_count": 1} + assert boot["bootstrap"]["cursor"] == "1" + native_request = { + "schema_version": TASK_LEASE_ACQUIRE_REQUEST_SCHEMA, "runtime_root": str(runtime), "goal_id": "goal-a", + "todo_id": second["todo_id"], "owner": "agent-b", "idempotency_key": "native-second", "ttl_seconds": 120, + "write_scopes": [], "expected_version": None, + "authority": task_lease_acquire_authority_facts(registry_path=registry, goal_id="goal-a", todo_id=second["todo_id"]), + "runtime_shadow": {"schema_version": "loopx_coordination_runtime_shadow_binding_v0", "provider": "file_v0"}, + } + acquired = native(tmp_path, "loopx/control_plane/work_items/task_lease_acquire.ts", "executeTaskLeaseAcquire", native_request) + assert acquired["acquired"] is True, acquired + additions = [cli(registry, runtime, "todo", "add", "--goal-id", "goal-a", "--role", "agent", "--text", f"New captured task {index}") for index in range(2)] + for addition in additions: + assert addition["coordination_runtime_shadow"]["source_transaction_correlated"] is True + inspect = cli(registry, runtime, "coordination-shadow", "inspect", "--goal-id", "goal-a") + assert inspect["inspection"]["status"] == "matched", inspect + qualified = cli(registry, runtime, "coordination-shadow", "qualify", "--goal-id", "goal-a", "--minimum-operations", "3", + "--require-event-kind", "todo_add", "--require-event-kind", "task_lease_acquire") + assert qualified["qualification"]["scope"] == "bounded" + assert qualified["qualification"]["sustained_parity_verified"] is False + assert qualified["qualification"]["sustained_parity_verdict"] == "not_evaluated" + assert qualified["qualification"]["evidence"]["operation_count"] == 3 + assert qualified["qualification"]["cursor"] == "4" + read = cli(registry, runtime, "coordination-shadow", "read-candidate", "--goal-id", "goal-a", "--todo-id", first["todo_id"]) + assert read["read_candidate"]["read_candidate_qualified"] is True + assert read["read_candidate"]["decision_read_from_shadow"] is False + proof = native(tmp_path, "loopx/control_plane/coordination/local_authority_shadow.ts", "readLocalAuthorityShadow", { + "schema_version": "loopx_coordination_runtime_shadow_outbox_read_v0", "runtime_root": str(runtime), + "goal_id": "goal-a", "scan_limit": 10000, + }) + transactions = proof["proof"]["transactions"] + assert len(transactions) == 4 + assert transactions[0]["receipts"] == [] + assert transactions[0]["projection"]["leases"] == [baseline_lease["lease"]] + baseline_ids = [todo["todo_id"] for todo in transactions[0]["projection"]["todos"]] + assert baseline_ids == sorted([first["todo_id"], second["todo_id"]]) + assert archived["todo_id"] not in baseline_ids + assert "Previously completed task" in state.read_text() + receipts = [transaction["receipts"][0] for transaction in transactions[1:]] + assert {receipt["writer_runtime"] for receipt in receipts} == {"python", "typescript"} + assert len({receipt["entry_id"] for receipt in receipts}) == 3 + assert all(receipt["capture_lineage_id"] == boot["bootstrap"]["capture_lineage_id"] for receipt in receipts) + + +def test_unrecorded_canonical_change_cannot_become_qualified_after_a_later_public_write(tmp_path: Path) -> None: + registry, runtime, state = workspace(tmp_path) + enable(registry) + cli(registry, runtime, "coordination-shadow", "bootstrap", "--goal-id", "goal-a", "--execute") + original = state.read_text() + state.write_text(original.replace("handoff_mode: hard_lease", "handoff_mode: soft_claim")) + added = cli(registry, runtime, "todo", "add", "--goal-id", "goal-a", "--role", "agent", "--text", "After unrecorded mutation") + assert added["added"] is True + qualified = cli(registry, runtime, "coordination-shadow", "qualify", "--goal-id", "goal-a", success=False) + assert qualified["qualification"]["qualified"] is False + assert list((runtime / "authority-shadow" / "outbox" / "goal-a" / "todos").glob("*.prepared.json")) + state.write_text(original) + again = cli(registry, runtime, "coordination-shadow", "qualify", "--goal-id", "goal-a", success=False) + assert again["qualification"]["qualified"] is False + + +def test_snapshot_changed_between_python_builder_and_native_inspection_is_rejected(tmp_path: Path) -> None: + registry, runtime, state = workspace(tmp_path) + goal = enable(registry) + cli(registry, runtime, "coordination-shadow", "bootstrap", "--goal-id", "goal-a", "--execute") + projection, snapshot = build_runtime_shadow_source_snapshot(goal=goal, runtime_root=runtime, state_path=state, registry_path=registry) + state.write_text(state.read_text() + "\n## Notes\nProse changed after snapshot.\n") + result = native(tmp_path, "loopx/control_plane/coordination/runtime_shadow.ts", "inspectCoordinationRuntimeShadow", { + "schema_version": "loopx_coordination_runtime_shadow_inspect_v0", "runtime_root": str(runtime), "goal_id": "goal-a", + "projection": projection, "source_snapshot": snapshot, + }) + assert result["status"] == "failed" + assert result["reason_code"] == "source_changed_retry" + + +def test_public_handoff_followups_and_monitor_successor_capture_each_primary_mutation(tmp_path: Path) -> None: + registry, runtime, _state = workspace(tmp_path) + enable(registry) + cli(registry, runtime, "coordination-shadow", "bootstrap", "--goal-id", "goal-a", "--execute") + cli(registry, runtime, "handoff-mode", "set", "--goal-id", "goal-a", "--mode", "soft_claim") + assert len(history(tmp_path, runtime)) == 2 + cli(registry, runtime, "todo", "capture-followups", "--goal-id", "goal-a", + "--follow-up", "First retained followup", "--follow-up", "Second retained followup", "--evidence", "validation://followups") + assert len(history(tmp_path, runtime)) == 3 + monitor = cli(registry, runtime, "todo", "add", "--goal-id", "goal-a", "--role", "agent", + "--text", "Observe the public release", "--task-class", "continuous_monitor", "--action-kind", "monitor", + "--claimed-by", "agent-a", "--target-key", "release:bounded", "--cadence", "30m", + "--next-due-at", "2000-01-01T00:00:00+00:00", "--watch-only") + assert len(history(tmp_path, runtime)) == 4 + result = cli(registry, runtime, "quota", "monitor-poll", "--goal-id", "goal-a", "--agent-id", "agent-a", + "--runtime-profile", "generic_cli", "--available-capability", "network", "--todo-id", monitor["todo_id"], + "--target-key", "release:bounded", "--result-hash", "release-v1", "--material-change", + "--next-agent-todo", "Validate the released head", "--next-action-kind", "validate_release_head", + "--next-task-repository", "git:github.com/huangruiteng/loopx", "--next-required-capability", "network", + "--next-continuation-policy", "same_agent_non_delivery", "--next-claimed-by", "agent-a", "--execute") + assert len(result["successor_todo_ids"]) == 1 + transactions = history(tmp_path, runtime) + assert len(transactions) == 6 # Baseline, handoff, followup batch, monitor add, observation update, successor add. + receipts = [transaction["receipts"][0] for transaction in transactions[1:]] + assert len({receipt["entry_id"] for receipt in receipts}) == 5 + assert [receipt["seq"] for receipt in receipts] == [1, 2, 3, 4, 5] + assert {receipt["write_class"] for receipt in receipts} >= {"handoff_mode_set", "todo_capture_followups", "todo_add", "todo_update"} + qualified = cli(registry, runtime, "coordination-shadow", "qualify", "--goal-id", "goal-a", "--minimum-operations", "5") + assert qualified["qualification"]["qualified"] is True + assert qualified["qualification"]["evidence"]["operation_count"] == 5 + + +def test_disabling_configuration_cannot_cancel_an_active_capture_obligation(tmp_path: Path) -> None: + registry, runtime, state = workspace(tmp_path) + enable(registry) + cli(registry, runtime, "coordination-shadow", "bootstrap", "--goal-id", "goal-a", "--execute") + initial = state.read_bytes() + config = json.loads(registry.read_text()) + config["goals"][0]["coordination"]["runtime_shadow"]["enabled"] = False + registry.write_text(json.dumps(config)) + cli(registry, runtime, "handoff-mode", "set", "--goal-id", "goal-a", "--mode", "soft_claim") + cli(registry, runtime, "handoff-mode", "set", "--goal-id", "goal-a", "--mode", "hard_lease") + assert state.read_bytes() == initial + enable(registry) + # The identical final source must not erase the two intervening mutations. + transactions = history(tmp_path, runtime) + assert len(transactions) == 3 + assert [row["receipts"][0]["seq"] for row in transactions[1:]] == [1, 2] + qualified = cli(registry, runtime, "coordination-shadow", "qualify", "--goal-id", "goal-a", "--minimum-operations", "2") + assert qualified["qualification"]["qualified"] is True + assert qualified["qualification"]["evidence"]["operation_count"] == 2 + + +def test_native_lease_writer_cannot_reuse_a_sequence_when_its_cursor_is_missing(tmp_path: Path) -> None: + registry, runtime, _state = workspace(tmp_path) + first = cli(registry, runtime, "todo", "add", "--goal-id", "goal-a", "--role", "agent", "--text", "First lease task") + second = cli(registry, runtime, "todo", "add", "--goal-id", "goal-a", "--role", "agent", "--text", "Second lease task") + enable(registry) + cli(registry, runtime, "coordination-shadow", "bootstrap", "--goal-id", "goal-a", "--execute") + cli(registry, runtime, "task-lease", "acquire", "--goal-id", "goal-a", "--todo-id", first["todo_id"], + "--owner", "agent-a", "--idempotency-key", "first-lease", "--ttl-seconds", "120") + assert len(history(tmp_path, runtime)) == 2 + cursor = runtime / "authority-shadow/outbox/goal-a/leases/drain-cursor.json" + cursor.unlink() + acquired = acquire_native(tmp_path, registry, runtime, second["todo_id"]) + assert acquired["acquired"] is True, acquired + pending = list(cursor.parent.glob("*.prepared.json")) + assert len(pending) == 1 + assert json.loads(pending[0].read_text())["seq"] == 2 + assert not cursor.exists() # The writer recovers a sequence, never a cursor. + cli(registry, runtime, "todo", "add", "--goal-id", "goal-a", "--role", "agent", "--text", "Trigger bounded drain") + transactions = history(tmp_path, runtime) + lease_receipts = [row["receipts"][0] for row in transactions[1:] if row["receipts"][0]["partition"] == "leases"] + assert [receipt["seq"] for receipt in lease_receipts] == [1, 2] + assert len({receipt["entry_id"] for receipt in lease_receipts}) == 2 + assert cli(registry, runtime, "coordination-shadow", "qualify", "--goal-id", "goal-a")["qualification"]["qualified"] is True + + +def test_public_committed_primary_cannot_be_relabelled_abandoned_by_native_request(tmp_path: Path) -> None: + from shadow_e2e_fixture import workspace as crash_workspace + from loopx.control_plane.coordination import local_authority_shadow_adapter as adapter + from loopx.control_plane.coordination import local_authority_shadow_outbox as outbox + + w = crash_workspace(tmp_path) + w.crash("before_commit", "todo", "add", "--role", "agent", "--text", "A committed primary is never abandoned") + directory = outbox.partition_directory(w.runtime, w.goal, "todos") + [entry] = outbox.list_entries(directory) + request = adapter._commit_entry_request(runtime_root=w.runtime, goal_id=w.goal, entry=entry, + resolution="abandoned", projection=None, digest=None) + assert request["entry"]["committed_sha256"] is not None + before = {path.name: path.read_bytes() for path in directory.iterdir()} + primary = w.state.read_bytes() + result = adapter.effect_runtime_result("coordination.runtime_shadow.commit_entry", request, timeout=15) + assert result["outcome"] == "failed" + assert result["reason_code"] == "outbox_resolution_marker_mismatch" + assert {path.name: path.read_bytes() for path in directory.iterdir()} == before + assert w.state.read_bytes() == primary + assert w.drain()["ok"] is True + view = adapter.read_local_authority_shadow(runtime_root=w.runtime, goal_id=w.goal, scan_limit=20) + [transaction] = view["proof"]["transactions"][1:] + assert transaction["receipts"][0]["resolution"] == "committed" + assert transaction["receipts"][0]["no_op"] is False + + +def test_controller_cannot_bind_the_registered_source_to_an_override_runtime_root(tmp_path: Path) -> None: + registry, runtime, state = workspace(tmp_path) + enable(registry) + original = state.read_bytes() + override = tmp_path / "override-runtime" + for registered_is_active in (False, True): + if registered_is_active: + cli(registry, runtime, "coordination-shadow", "bootstrap", "--goal-id", "goal-a", "--execute") + rejected = cli(registry, override, "coordination-shadow", "bootstrap", "--goal-id", "goal-a", "--execute", success=False) + assert rejected["bootstrap"]["status"] == "failed" + assert rejected["bootstrap"]["reason_code"] == "shadow_source_runtime_root_mismatch", rejected + assert not (override / "authority-shadow/file-v0").exists() + assert state.read_bytes() == original + qualified = cli(registry, override, "coordination-shadow", "qualify", "--goal-id", "goal-a", success=False) + assert qualified["qualification"]["qualified"] is False + assert qualified["qualification"]["reason_code"] == "shadow_source_runtime_root_mismatch" + + +def test_controller_cannot_bind_an_alternate_state_file_before_or_after_bootstrap(tmp_path: Path) -> None: + registry, runtime, state = workspace(tmp_path) + enable(registry) + alternate = tmp_path / "alternate-state.md" + alternate.write_bytes(state.read_bytes()) + original = state.read_bytes() + for active in (False, True): + if active: + cli(registry, runtime, "coordination-shadow", "bootstrap", "--goal-id", "goal-a", "--execute") + rejected = cli(registry, runtime, "coordination-shadow", "bootstrap", "--goal-id", "goal-a", + "--state-file", str(alternate), "--execute", success=False) + assert rejected["bootstrap"]["status"] == "failed" + expected = "management_operation_identity_mismatch" if active else "shadow_source_state_path_mismatch" + assert rejected["bootstrap"]["reason_code"] == expected, rejected + assert state.read_bytes() == alternate.read_bytes() == original + qualified = cli(registry, runtime, "coordination-shadow", "qualify", "--goal-id", "goal-a", + "--state-file", str(alternate), success=False) + assert qualified["qualification"]["qualified"] is False + assert qualified["qualification"]["reason_code"] == "shadow_source_state_path_mismatch" + if not active: + assert not (runtime / "authority-shadow/file-v0").exists() diff --git a/tests/control_plane/test_runtime_shadow_writer_capture.py b/tests/control_plane/test_runtime_shadow_writer_capture.py index a37a4919af..10f1035dc2 100644 --- a/tests/control_plane/test_runtime_shadow_writer_capture.py +++ b/tests/control_plane/test_runtime_shadow_writer_capture.py @@ -1,6 +1,8 @@ from __future__ import annotations import json +import subprocess +import sys from pathlib import Path from loopx.control_plane.coordination import local_authority_shadow_adapter as adapter @@ -59,6 +61,11 @@ def _fixture(tmp_path: Path, *, enabled: bool) -> tuple[Path, Path, Path]: ), encoding="utf-8", ) + if enabled: + boot = subprocess.run([sys.executable, "-m", "loopx.entrypoint", "--registry", str(registry), + "--format", "json", "coordination-shadow", "bootstrap", "--goal-id", GOAL_ID, "--execute"], + cwd=Path(__file__).resolve().parents[2], text=True, capture_output=True, timeout=30) + assert boot.returncode == 0, boot.stdout + boot.stderr return registry, state, runtime_root @@ -96,7 +103,7 @@ def test_runtime_shadow_todo_writer_captures_full_records_and_reuses_one_store( assert head["todos"][0]["text"] == "Retain complete canonical Todo fields." assert head["todos"][0]["note"] == "transaction-bound" assert head["todo_read_model"]["todo_count"] == 1 - assert view["cursor"] == "2" + assert view["cursor"] == "3" assert not (runtime_root / "authority-shadow" / "file" / GOAL_ID).exists() diff --git a/tests/control_plane/test_shadow_cursor_safety.py b/tests/control_plane/test_shadow_cursor_safety.py new file mode 100644 index 0000000000..26c6425c4a --- /dev/null +++ b/tests/control_plane/test_shadow_cursor_safety.py @@ -0,0 +1,197 @@ +"""Safety properties of the outbox cursor, independently of delivery.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from loopx.control_plane.coordination import local_authority_shadow_outbox as outbox + + +def valid_cursor() -> dict[str, object]: + return { + "schema_version": outbox.DRAIN_CURSOR_SCHEMA, + "partition": "todos", + "last_seq": 1, + "last_entry_id": "local-shadow-tx-" + "a" * 64, + "last_partition_digest": "sha256:" + "b" * 64, + "last_cursor": "opaque-cursor", + "last_provider_revision": "opaque-revision", + "updated_at": "2026-09-05T01:02:03.123456+00:00", + } + + +def write_cursor_fixture(tmp_path: Path, record: object) -> Path: + directory = tmp_path / "outbox" / "goal" / "todos" + directory.mkdir(parents=True) + (directory / "drain-cursor.json").write_text(json.dumps(record), encoding="utf-8") + return directory + + +@pytest.mark.parametrize("value", [True, False, "1", None, -1, 0, 1.5, 10_000_000_000]) +def test_cursor_sequence_rejects_non_integer_or_out_of_filename_range( + tmp_path: Path, value: object +) -> None: + cursor = valid_cursor() + cursor["last_seq"] = value + directory = write_cursor_fixture(tmp_path, cursor) + before = outbox.cursor_path(directory).read_bytes() + with pytest.raises(outbox.OutboxError, match="cursor") as failure: + outbox.read_cursor(directory) + assert failure.value.reason_code == "outbox_file_invalid" + assert outbox.cursor_path(directory).read_bytes() == before + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("partition", "leases"), + ("last_entry_id", "local-shadow-tx-short"), + ("last_partition_digest", "sha256:short"), + ("last_cursor", None), + ("last_provider_revision", ""), + ("updated_at", "yesterday"), + ("unrecognized", True), + ], +) +def test_cursor_rejects_incomplete_or_foreign_binding( + tmp_path: Path, field: str, value: object +) -> None: + cursor = valid_cursor() + cursor[field] = value + directory = write_cursor_fixture(tmp_path, cursor) + with pytest.raises(outbox.OutboxError) as failure: + outbox.read_cursor(directory) + assert failure.value.reason_code == "outbox_file_invalid" + + +@pytest.mark.parametrize("raw", [b"{", b"\xff", b"[]", b"null"]) +def test_cursor_parse_errors_are_typed_and_preserve_bytes( + tmp_path: Path, raw: bytes +) -> None: + directory = tmp_path / "todos" + directory.mkdir() + path = directory / "drain-cursor.json" + path.write_bytes(raw) + with pytest.raises(outbox.OutboxError) as failure: + outbox.read_cursor(directory) + assert failure.value.reason_code == "outbox_file_invalid" + assert path.read_bytes() == raw + + +def test_cursor_accepts_opaque_revisions_and_equivalent_json_integer( + tmp_path: Path, +) -> None: + cursor = valid_cursor() + cursor["last_seq"] = 1.0 + directory = write_cursor_fixture(tmp_path, cursor) + decoded = outbox.read_cursor(directory) + assert decoded is not None + assert decoded["last_seq"] == 1 + assert decoded["last_cursor"] == "opaque-cursor" + assert decoded["last_provider_revision"] == "opaque-revision" + + +def test_cursor_cannot_hide_unvalidated_prepared_bytes(tmp_path: Path) -> None: + directory = write_cursor_fixture(tmp_path, valid_cursor()) + path = directory / ("0000000001-local-shadow-tx-" + "a" * 64 + ".prepared.json") + path.write_bytes(b"{not a prepared transaction}") + with pytest.raises(outbox.OutboxError): + outbox.list_entries(directory) + assert path.read_bytes() == b"{not a prepared transaction}" + + +def test_status_contains_cursor_error_without_raising(tmp_path: Path) -> None: + root = tmp_path / "runtime" + directory = outbox.partition_directory(root, "goal", "todos") + directory.mkdir(parents=True) + (directory / "drain-cursor.json").write_bytes(b"{") + summary = outbox.outbox_summary(root, "goal") + assert summary["todos"]["invalid"] is not None + + +def test_receipt_reclamation_binds_raw_bytes(tmp_path: Path) -> None: + import hashlib + + path = tmp_path / "prepared.json" + original = b'{ "source": "canonical primary" }\n' + path.write_bytes(original) + digest = "sha256:" + hashlib.sha256(original).hexdigest() + assert outbox.reclaim_verified_files([(path, digest)]) == 1 + assert not path.exists() + + +def test_receipt_reclamation_checks_all_files_before_unlink(tmp_path: Path) -> None: + import hashlib + + first, second = tmp_path / "first.json", tmp_path / "second.json" + first.write_bytes(b"first") + second.write_bytes(b"changed") + expected = "sha256:" + hashlib.sha256(b"first").hexdigest() + with pytest.raises(outbox.OutboxError, match="changed"): + outbox.reclaim_verified_files([(first, expected), (second, expected)]) + assert first.read_bytes() == b"first" + assert second.read_bytes() == b"changed" + + +def test_zero_wait_cross_runtime_lock_reclaims_dead_holder_and_acquires( + tmp_path: Path, +) -> None: + import subprocess + import sys + from loopx.file_lock import exclusive_cross_runtime_file_lock + + target = tmp_path / "primary.md" + child = subprocess.run( + [sys.executable, "-c", "import os; print(os.getpid())"], + capture_output=True, + text=True, + check=True, + ) + (tmp_path / "primary.md.ts-effect.lock").write_text( + json.dumps( + { + "pid": int(child.stdout), + "token": "terminated-process", + } + ) + ) + with exclusive_cross_runtime_file_lock(target, timeout_seconds=0): + assert ( + json.loads((tmp_path / "primary.md.ts-effect.lock").read_text())["token"] + != "terminated-process" + ) + + +@pytest.mark.parametrize( + "kind", ["unexpected_json", "temporary_bytes", "partition_is_file", "symlink"] +) +def test_outbox_inventory_cannot_hide_unclassified_or_unreadable_evidence( + tmp_path: Path, kind: str +) -> None: + directory = tmp_path / "goal" / "todos" + directory.parent.mkdir() + if kind == "partition_is_file": + directory.write_bytes(b"not a directory") + else: + directory.mkdir() + if kind == "symlink": + target = tmp_path / "outside.json" + target.write_bytes(b"{}") + ( + directory + / ("0000000001-local-shadow-tx-" + "a" * 64 + ".prepared.json") + ).symlink_to(target) + else: + ( + directory + / ( + "unknown.json" + if kind == "unexpected_json" + else "entry.prepared.json.tmp-dead" + ) + ).write_bytes(b"partial") + with pytest.raises(outbox.OutboxError): + outbox.list_entries(directory) diff --git a/tests/control_plane/test_shadow_drain_adversarial.py b/tests/control_plane/test_shadow_drain_adversarial.py new file mode 100644 index 0000000000..83f3a4cc84 --- /dev/null +++ b/tests/control_plane/test_shadow_drain_adversarial.py @@ -0,0 +1,204 @@ +"""Adversarial recovery ordering through the public CLI and real file provider.""" +from __future__ import annotations + +from pathlib import Path + +import pytest + + +from shadow_e2e_fixture import workspace +from loopx.control_plane.coordination import local_authority_shadow_adapter as adapter +from loopx.control_plane.coordination import local_authority_shadow_outbox as outbox + + +pytestmark = pytest.mark.stage2c_e2e + + +def test_missing_cursor_cannot_reuse_a_sequence_when_the_next_writer_arrives_first(tmp_path: Path) -> None: + w = workspace(tmp_path) + w.add("First complete transaction") + w.add("Second complete transaction") + directory = outbox.partition_directory(w.runtime, w.goal, "todos") + (directory / "drain-cursor.json").unlink() + # No operator drain runs between evidence loss and the next public writer. + result = w.add("Writer arrives before cursor recovery") + assert result["ok"] is True + recovered = w.drain() + assert recovered["ok"] is True, recovered + view = adapter.read_local_authority_shadow(runtime_root=w.runtime, goal_id=w.goal, scan_limit=20) + receipts = [tx["receipts"][0] for tx in view["proof"]["transactions"][1:]] + assert [receipt["seq"] for receipt in receipts] == [1, 2, 3] + assert outbox.read_cursor(directory)["last_seq"] == 3 + + +def test_small_recovery_budget_makes_progress_through_verified_residue(tmp_path: Path) -> None: + w = workspace(tmp_path) + directory = outbox.partition_directory(w.runtime, w.goal, "todos") + residue: dict[str, bytes] = {} + for index in range(3): + w.crash("after_commit", "todo", "add", "--role", "agent", "--text", f"Acknowledged transaction {index}") + residue.update({path.name: path.read_bytes() for path in directory.glob("*.json") if path.name != "drain-cursor.json"}) + assert w.drain()["ok"] is True + for name, data in residue.items(): + (directory / name).write_bytes(data) + before = adapter.read_local_authority_shadow(runtime_root=w.runtime, goal_id=w.goal, scan_limit=20) + for left in (2, 1, 0): + result = w.drain(max_entries="1") + assert result["ok"] is True, result + assert result["replayed"] == 1, result + assert len(outbox.list_entries(directory)) == left + after = adapter.read_local_authority_shadow(runtime_root=w.runtime, goal_id=w.goal, scan_limit=20) + assert after["proof"]["transactions"] == before["proof"]["transactions"] + + +def test_raw_residue_mismatch_preserves_every_file_before_any_cleanup(tmp_path: Path) -> None: + w = workspace(tmp_path) + directory = outbox.partition_directory(w.runtime, w.goal, "todos") + residue: dict[str, bytes] = {} + for index in range(2): + w.crash("after_commit", "todo", "add", "--role", "agent", "--text", f"Exact receipt {index}") + residue.update({path.name: path.read_bytes() for path in directory.glob("*.json") if path.name != "drain-cursor.json"}) + assert w.drain()["ok"] is True + for name, data in residue.items(): + (directory / name).write_bytes(data) + last = sorted(directory.glob("*.prepared.json"))[-1] + # Valid identical JSON semantics still do not match the receipt's exact bytes. + last.write_bytes(last.read_bytes() + b"\n") + before = {path.name: path.read_bytes() for path in directory.iterdir()} + result = w.drain() + assert result["ok"] is False and result["reason_code"] == "outbox_receipt_mismatch", result + assert result["reclaimed_residue"] == 0 + assert {path.name: path.read_bytes() for path in directory.iterdir()} == before + + +def test_real_prepare_io_failure_holds_public_primary_before_replace(tmp_path: Path) -> None: + w = workspace(tmp_path) + directory = outbox.partition_directory(w.runtime, w.goal, "todos") + directory.write_text("A real filesystem obstruction, not a directory") + before = w.state.read_bytes() + rejected = w.cli("handoff-mode", "set", "--mode", "soft_claim", success=False) + assert rejected["ok"] is False, rejected + assert w.state.read_bytes() == before + assert directory.read_text() == "A real filesystem obstruction, not a directory" + directory.unlink() + w.cli("handoff-mode", "set", "--mode", "soft_claim") + assert w.drain()["ok"] is True + view = adapter.read_local_authority_shadow(runtime_root=w.runtime, goal_id=w.goal, scan_limit=20) + assert view["head"]["handoff_mode"] == "soft_claim" + assert len(view["proof"]["transactions"]) == 2 + assert view["proof"]["transactions"][1]["receipts"][0]["seq"] == 1 + + +@pytest.mark.parametrize( + ("window", "claimed_resolution"), + [("before_replace", "committed_proven_by_readback"), ("before_marker", "abandoned")], +) +def test_native_markerless_resolution_requires_source_evidence( + tmp_path: Path, window: str, claimed_resolution: str, +) -> None: + w = workspace(tmp_path) + w.crash(window, "todo", "add", "--role", "agent", "--text", "Source proof is not a caller flag") + directory = outbox.partition_directory(w.runtime, w.goal, "todos") + [entry] = outbox.list_entries(directory) + projection, digest = adapter._entry_projection(entry, goal_id=w.goal) + if claimed_resolution == "abandoned": + projection, digest = None, None + request = adapter._commit_entry_request( + runtime_root=w.runtime, goal_id=w.goal, entry=entry, + resolution=claimed_resolution, projection=projection, digest=digest, + ) + before = {path.name: path.read_bytes() for path in directory.iterdir()} + result = adapter.effect_runtime_result("coordination.runtime_shadow.commit_entry", request, timeout=15) + assert result["outcome"] == "failed", result + assert {path.name: path.read_bytes() for path in directory.iterdir()} == before + view = adapter.read_local_authority_shadow(runtime_root=w.runtime, goal_id=w.goal, scan_limit=20) + assert len(view["proof"]["transactions"]) == 1 + + +def test_registry_runtime_override_cannot_bypass_an_active_source_binding(tmp_path: Path) -> None: + import json + import subprocess + import sys + from shadow_e2e_fixture import REPO + + w = workspace(tmp_path) + for index in range(3): + w.add(f"Qualification evidence {index}") + before = w.state.read_bytes() + other_root = tmp_path / "other-runtime" + results = [] + for mode in ("soft_claim", "hard_lease"): + argv = w.arguments("handoff-mode", "set", "--mode", mode) + argv[argv.index("--runtime-root") + 1] = str(other_root) + command = subprocess.run([sys.executable, "-m", "loopx.cli", *argv], cwd=REPO, + capture_output=True, text=True, timeout=45) + assert "Traceback" not in command.stderr, command.stderr + results.append(json.loads(command.stdout)) + after_qualification = w.cli("coordination-shadow", "qualify", success=False) + assert not any(result.get("ok") and result.get("changed") for result in results), { + "override_results": results, "qualification_at_original_root": after_qualification, + } + assert w.state.read_bytes() == before + + +@pytest.mark.parametrize("overlay", [False, True], ids=["event_only", "event_overlay"]) +def test_public_qualification_and_candidate_reads_hold_unbound_event_todos( + tmp_path: Path, overlay: bool, +) -> None: + from loopx.event_sourced_state import AppendOnlyStateEventStore, TODO_ADDED, make_state_event + + w = workspace(tmp_path) + ids = [w.add(f"Markdown evidence {index}")["todo_id"] for index in range(3)] + assert w.cli("coordination-shadow", "qualify")["qualification"]["qualified"] is True + event_id = ids[0] if overlay else "todo_unbound_event" + log = w.state.with_name("events.jsonl") + store = AppendOnlyStateEventStore(log) + store.append(make_state_event( + event_id="evt-unbound-todo", goal_id=w.goal, event_type=TODO_ADDED, + refs={"todo_id": event_id}, payload={"role": "agent", "title": "Event source remains independently writable", "task_class": "advancement_task"}, + recorded_at="2026-09-06T00:00:00+00:00", + )) + assert len(store.load()) == 1 + evidence = log.read_bytes() + for command in (("qualify",), ("read-candidate", "--todo-id", ids[0])): + result = w.cli("coordination-shadow", *command, success=False) + assert result["ok"] is False, result + assert result["error"] == "event_log_writer_not_bound", result + assert result["decision_read_from_shadow"] is False + assert log.read_bytes() == evidence + + +@pytest.mark.parametrize("missing", ["identity", "candidate"]) +def test_missing_cursor_source_allocation_never_recreates_missing_store_files( + tmp_path: Path, missing: str, +) -> None: + w = workspace(tmp_path) + w.add("Existing generation") + directory = outbox.partition_directory(w.runtime, w.goal, "todos") + (directory / "drain-cursor.json").unlink() + store = w.runtime / "authority-shadow" / "file-v0" + path = store / "store-identity" if missing == "identity" else next(store.glob("authority-store-*.json")) + path.unlink() + shadow = w.runtime / "authority-shadow" + before = {str(item.relative_to(shadow)): item.read_bytes() for item in shadow.rglob("*") if item.is_file()} + primary = w.state.read_bytes() + result = w.cli("todo", "add", "--role", "agent", "--text", "Read-only recovery cannot bootstrap", success=False) + assert result["ok"] is False, result + assert w.state.read_bytes() == primary + assert not path.exists() + assert {str(item.relative_to(shadow)): item.read_bytes() for item in shadow.rglob("*") if item.is_file()} == before + + +def test_state_file_override_cannot_attribute_an_unbound_source_to_active_lineage(tmp_path: Path) -> None: + w = workspace(tmp_path) + other = tmp_path / "OTHER_ACTIVE_STATE.md" + other.write_bytes(w.state.read_bytes()) + original = w.state.read_bytes() + outcomes = [] + for mode in ("soft_claim", "hard_lease", "soft_claim", "hard_lease"): + outcomes.append(w.cli("handoff-mode", "set", "--mode", mode, "--state-file", str(other), success=False)) + qualification = w.cli("coordination-shadow", "qualify", success=False) + assert not any(result.get("ok") and result.get("changed") for result in outcomes), { + "override_results": outcomes, "qualification_for_bound_source": qualification, + } + assert w.state.read_bytes() == original diff --git a/tests/control_plane/test_shadow_drain_e2e.py b/tests/control_plane/test_shadow_drain_e2e.py new file mode 100644 index 0000000000..760069d95e --- /dev/null +++ b/tests/control_plane/test_shadow_drain_e2e.py @@ -0,0 +1,268 @@ +"""Receipt, cursor and crash invariants through real CLI and independent TS processes.""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys + +import pytest + +from shadow_e2e_fixture import REPO, ShadowWorkspace, workspace +from loopx.control_plane.coordination import local_authority_shadow_adapter as adapter +from loopx.control_plane.coordination import local_authority_shadow_outbox as outbox + + +pytestmark = pytest.mark.stage2c_e2e + + +def directory(w: ShadowWorkspace) -> Path: + return outbox.partition_directory(w.runtime, w.goal, "todos") + + +def history(w: ShadowWorkspace) -> dict: + return adapter.read_local_authority_shadow( + runtime_root=w.runtime, goal_id=w.goal, scan_limit=10_000 + ) + + +def pending(w: ShadowWorkspace, text: str = "One primary mutation") -> dict: + return w.crash("before_commit", "todo", "add", "--role", "agent", "--text", text) + + +def snapshot(path: Path) -> dict[str, bytes]: + return { + str(item.relative_to(path)): item.read_bytes() + for item in path.rglob("*") + if item.is_file() + } + + +def test_public_primary_maps_one_to_one_to_receipts_and_replays_idempotently( + tmp_path: Path, +) -> None: + w = workspace(tmp_path) + ids = [w.add(f"Public fact {index}")["todo_id"] for index in range(3)] + w.drain() + view = history(w) + transactions = view["proof"]["transactions"] + assert len(transactions) == 4 # One complete baseline, three actual mutations. + assert [item["todo_id"] for item in view["head"]["todos"]] == sorted(ids) + assert view["head"]["handoff_mode"] == "hard_lease" + assert view["head"]["leases"] == [] + assert [tx["receipts"][0]["seq"] for tx in transactions[1:]] == [1, 2, 3] + assert all(tx["receipts"][0]["no_op"] is False for tx in transactions[1:]) + assert w.drain()["outcome"] == "nothing_pending" + assert history(w)["proof"]["transactions"] == transactions + assert [p.name for p in directory(w).iterdir()] == ["drain-cursor.json"] + for args in (("qualify",), ("read-candidate", "--todo-id", ids[0])): + arguments = w.arguments("coordination-shadow", *args) + arguments[arguments.index("--format") + 1] = "markdown" + result = subprocess.run([sys.executable, "-m", "loopx.cli", *arguments], + cwd=REPO, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr + assert "qualification_scope: `bounded`" in result.stdout + assert "sustained_parity_verdict: `not_evaluated`" in result.stdout + assert "minimum_primary_mutations: `3`" in result.stdout + + +def test_public_mutation_has_no_second_snapshot_mirror(tmp_path: Path) -> None: + w = workspace(tmp_path) + assert w.add("Exactly one durable primary mutation")["added"] is True + module = REPO / "loopx/control_plane/coordination/file_authority_store.ts" + script = ( + f"import {{FileAuthorityStore}} from {json.dumps(module.as_uri())};" + "const store=new FileAuthorityStore(process.argv[1],process.argv[2],{existingOnly:true});" + "process.stdout.write(JSON.stringify(await store.scanCommitted(null,10000)));" + ) + readback = subprocess.run( + ["node", "--no-warnings", "--experimental-strip-types", "--input-type=module", "-e", script, + str(w.runtime / "authority-shadow/file-v0"), w.goal], + capture_output=True, text=True, check=True, timeout=30, + ) + page = json.loads(readback.stdout) + assert page["status"] == "page", page + assert len(page["transactions"]) == 2, page # Full baseline plus one mutation. + assert page["transactions"][0]["receipts"] == [] + assert len(page["transactions"][1]["receipts"]) == 1 + assert page["transactions"][1]["receipts"][0]["write_class"] == "todo_add" + + +@pytest.mark.parametrize( + "attack", + [ + "high_water", + "fake_digest", + "string_seq", + "boolean_seq", + "missing_anchor", + "wrong_root", + "wrong_lineage", + ], +) +def test_cursor_cannot_authorize_deletion_or_hide_mutations( + tmp_path: Path, attack: str +) -> None: + w = workspace(tmp_path) + w.add("First captured mutation") + pending(w, "Second real mutation") + d = directory(w) + cursor_path = d / "drain-cursor.json" + value = json.loads(cursor_path.read_bytes()) + if attack == "high_water": + value["last_seq"] = 9_999_999_999 + elif attack == "fake_digest": + value["last_partition_digest"] = "sha256:" + "f" * 64 + elif attack == "string_seq": + value["last_seq"] = "1" + elif attack == "boolean_seq": + value["last_seq"] = True + elif attack == "missing_anchor": + value["last_cursor"] = "uncommitted-cursor" + else: + entry = next(d.glob("*.prepared.json")) + record = json.loads(entry.read_bytes()) + record[ + "source_root_digest" if attack == "wrong_root" else "capture_lineage_id" + ] = "sha256:" + "a" * 64 if attack == "wrong_root" else "other-lineage" + entry.write_text(json.dumps(record)) + cursor_path.write_text(json.dumps(value)) + before = snapshot(d) + result = w.drain() + assert result["ok"] is False, result + assert result["reason_code"] in { + "outbox_cursor_invalid", + "outbox_cursor_unproved", + "outbox_file_invalid", + "stale_generation", + } + assert snapshot(d) == before + assert len(history(w)["proof"]["transactions"]) == 2 + status = w.cli("authority-shadow", "status", success=False) + assert "outbox" in status + + +@pytest.mark.parametrize("window", ["after_commit", "after_cursor", "between_unlinks"]) +def test_real_sigkill_drain_windows_recover_from_exact_receipts( + tmp_path: Path, window: str +) -> None: + w = workspace(tmp_path) + w.crash( + window, + "todo", + "add", + "--role", + "agent", + "--text", + "Committed before process death", + ) + before = history(w)["proof"]["transactions"] + assert len(before) == 2 + result = w.drain() + assert result["ok"] is True, result + assert result["replayed"] == 1 + assert result["delivered"] == 0 + assert history(w)["proof"]["transactions"] == before + assert outbox.read_cursor(directory(w))["last_seq"] == 1 + assert [p.name for p in directory(w).iterdir()] == ["drain-cursor.json"] + + +def test_missing_cursor_recovers_only_from_complete_verified_history( + tmp_path: Path, +) -> None: + w = workspace(tmp_path) + w.add("First") + w.add("Second") + before = history(w)["proof"]["transactions"] + (directory(w) / "drain-cursor.json").unlink() + assert w.drain()["ok"] is True + assert outbox.read_cursor(directory(w))["last_seq"] == 2 + w.add("Third") + assert history(w)["proof"]["transactions"][:3] == before + assert outbox.read_cursor(directory(w))["last_seq"] == 3 + + +@pytest.mark.parametrize("window", ["before_replace", "after_replace", "before_marker"]) +def test_primary_sigkill_preserves_complete_bytes_and_proves_before_marker( + tmp_path: Path, window: str +) -> None: + w = workspace(tmp_path) + old = w.state.read_bytes() + w.crash( + window, "todo", "add", "--role", "agent", "--text", "Atomic primary sentence" + ) + current = w.state.read_bytes() + listed = w.cli("todo", "list")["todos"] + assert (current == old) is (window == "before_replace") + assert len(listed) == (0 if window == "before_replace" else 1) + assert not list(directory(w).glob("*.committed.json")) + result = w.drain() + assert result["ok"] is True, result + receipt = history(w)["proof"]["transactions"][-1]["receipts"][0] + assert receipt["resolution"] == ( + "abandoned" if window == "before_replace" else "committed_proven_by_readback" + ) + assert receipt["no_op"] is (window == "before_replace") + + +def test_prepared_a_b_a_never_infers_first_write_was_abandoned(tmp_path: Path) -> None: + w = workspace(tmp_path) + w.crash("before_marker", "handoff-mode", "set", "--mode", "soft_claim") + # A later real writer returns the canonical primary to its initial A. + w.cli("handoff-mode", "set", "--mode", "hard_lease") + assert w.cli("handoff-mode", "show")["handoff_mode"] == "hard_lease" + before = snapshot(directory(w)) + result = w.drain() + assert ( + result["ok"] is False and result["reason_code"] == "outbox_source_unproved" + ), result + assert snapshot(directory(w)) == before + assert len(history(w)["proof"]["transactions"]) == 1 + qualify = w.cli("coordination-shadow", "qualify", success=False) + assert qualify["ok"] is False + + +def test_concurrent_real_drainers_commit_once_without_cursor_regression( + tmp_path: Path, +) -> None: + w = workspace(tmp_path) + for index in range(3): + pending(w, f"Concurrent {index}") + argv = [ + sys.executable, + "-m", + "loopx.cli", + *w.arguments("authority-shadow", "drain", "--lock-timeout-seconds", "2"), + ] + children = [ + subprocess.Popen( + argv, cwd=REPO, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + for _ in range(2) + ] + for child in children: + stdout, stderr = child.communicate(timeout=45) + assert "Traceback" not in stderr + result = json.loads(stdout) + assert result["outcome"] in {"drained", "nothing_pending", "drain_deferred"}, ( + result + ) + assert w.drain()["ok"] is True + txs = history(w)["proof"]["transactions"] + assert len(txs) == 4 + assert len({tx["operation_id"] for tx in txs}) == 4 + assert outbox.read_cursor(directory(w))["last_seq"] == 3 + + +def test_bounded_drain_preserves_unprocessed_entries(tmp_path: Path) -> None: + w = workspace(tmp_path) + for index in range(3): + pending(w, f"Bounded {index}") + result = w.drain(max_entries="1") + assert result["ok"] and result["delivered"] == 1 and result["budget_exhausted"], ( + result + ) + assert [entry.seq for entry in outbox.list_entries(directory(w))] == [2, 3] + assert w.drain()["ok"] + assert len(history(w)["proof"]["transactions"]) == 4 diff --git a/tests/control_plane/test_shadow_management.py b/tests/control_plane/test_shadow_management.py new file mode 100644 index 0000000000..cc247e05b1 --- /dev/null +++ b/tests/control_plane/test_shadow_management.py @@ -0,0 +1,118 @@ +"""The local primary guard never creates state or bypasses a durable hold.""" + +import json +import hashlib +from pathlib import Path +import subprocess + +import pytest + +from loopx.control_plane.coordination.shadow_management import ( + ShadowManagementError, + read_shadow_management_state, + require_shadow_primary_write_allowed, + shadow_management_state_path, + shadow_maintenance_lock_target, +) + + +def test_absent_management_preserves_default_without_creating_files(tmp_path: Path) -> None: + root = tmp_path / "missing" + assert require_shadow_primary_write_allowed(root, "goal-a") is None + assert read_shadow_management_state(root, "goal-a") is None + assert not root.exists() + assert "authority-transition" in shadow_maintenance_lock_target(root, "goal-a").parts + + +@pytest.mark.parametrize("raw", ["{", "null", "[]", '{"status":"active"}']) +def test_corrupt_management_holds_before_any_primary_write(tmp_path: Path, raw: str) -> None: + path = shadow_management_state_path(tmp_path, "goal-a") + path.parent.mkdir(parents=True) + path.write_text(raw) + before = path.read_bytes() + with pytest.raises(ShadowManagementError) as failure: + require_shadow_primary_write_allowed(tmp_path, "goal-a") + assert failure.value.code == "shadow_management_state_invalid" + assert path.read_bytes() == before + + +def test_python_reads_typescript_binding_and_rejects_cross_root_replay(tmp_path: Path) -> None: + root = tmp_path / "runtime" + script = """ +import {bootstrapManagedShadow} from './loopx/control_plane/coordination/shadow_management.ts'; +const root = process.argv[1]; +const request = {runtime_root:root,goal_id:'goal-a',operation_id:'bootstrap:guard',source_version:'v1',source_snapshot:{},projection:{goal_id:'goal-a',todos:[],leases:[]}}; +const result = await bootstrapManagedShadow(request,{withPrimaryLocks:async fn=>await fn(),verifySourceSnapshot:async()=>{}}); +process.stdout.write(JSON.stringify(result)); +""" + result = subprocess.run( + ["node", "--no-warnings", "--experimental-strip-types", "--input-type=module", "-e", script, str(root)], + check=True, capture_output=True, text=True, + ) + applied = json.loads(result.stdout) + assert applied["status"] == "applied" + binding = require_shadow_primary_write_allowed(root, "goal-a") + assert binding is not None + assert binding["capture_lineage_id"] == applied["capture_lineage_id"] + other = tmp_path / "other-root" + destination = shadow_management_state_path(other, "goal-a") + destination.parent.mkdir(parents=True) + destination.write_bytes(shadow_management_state_path(root, "goal-a").read_bytes()) + with pytest.raises(ShadowManagementError, match="shadow_management_state_invalid"): + require_shadow_primary_write_allowed(other, "goal-a") + + +@pytest.mark.parametrize("status", ["bootstrapping", "rolling_back"]) +def test_pending_journal_is_a_primary_hold(tmp_path: Path, status: str) -> None: + root_digest = "sha256:" + hashlib.sha256(str(tmp_path).encode()).hexdigest() + state = { + "schema_version": "loopx_shadow_management_state_v1", "goal_id": "goal-a", + "source_root_digest": root_digest, "status": status, "binding": None, + "operation": {"kind": "bootstrap" if status == "bootstrapping" else "rollback", + "operation_id": "operation:pending", "request_digest": "sha256:" + "1" * 64, + "manifest_digest": "sha256:" + "2" * 64, "phase": "prepared"}, + "previous_operation_id": None, "result": None, + } + path = shadow_management_state_path(tmp_path, "goal-a") + path.parent.mkdir(parents=True) + path.write_text(json.dumps(state)) + with pytest.raises(ShadowManagementError) as failure: + require_shadow_primary_write_allowed(tmp_path, "goal-a") + assert failure.value.code == "shadow_management_in_progress" + + +@pytest.mark.stage2c_e2e +def test_bound_source_path_comes_from_the_verified_typescript_bootstrap(tmp_path: Path) -> None: + from loopx.control_plane.coordination import shadow_management as management + from shadow_e2e_fixture import workspace + + w = workspace(tmp_path) + binding = require_shadow_primary_write_allowed(w.runtime, w.goal) + assert binding is not None + before = {str(path.relative_to(w.runtime)): path.read_bytes() for path in w.runtime.rglob("*") if path.is_file()} + assert management.read_shadow_bootstrap_source_path(w.runtime, w.goal, binding) == w.state + with pytest.raises(ShadowManagementError, match="stale_generation"): + management.read_shadow_bootstrap_source_path(w.runtime, w.goal, {**binding, "capture_lineage_id": "another-lineage"}) + assert {str(path.relative_to(w.runtime)): path.read_bytes() for path in w.runtime.rglob("*") if path.is_file()} == before + + +@pytest.mark.stage2c_e2e +@pytest.mark.parametrize("damage", ["altered", "missing"]) +def test_bound_source_path_never_accepts_or_repairs_a_damaged_manifest(tmp_path: Path, damage: str) -> None: + from loopx.control_plane.coordination import shadow_management as management + from shadow_e2e_fixture import workspace + + w = workspace(tmp_path) + binding = require_shadow_primary_write_allowed(w.runtime, w.goal) + assert binding is not None + [manifest] = management.shadow_management_directory(w.runtime, w.goal).glob("operations/*/manifest.json") + if damage == "altered": + value = json.loads(manifest.read_bytes()) + value["request"]["source_snapshot"]["state_path"] = str(tmp_path / "foreign-state.md") + manifest.write_text(json.dumps(value)) + else: + manifest.unlink() + before = {str(path.relative_to(w.runtime)): path.read_bytes() for path in w.runtime.rglob("*") if path.is_file()} + with pytest.raises(ShadowManagementError, match="shadow_management_manifest_invalid"): + management.read_shadow_bootstrap_source_path(w.runtime, w.goal, binding) + assert {str(path.relative_to(w.runtime)): path.read_bytes() for path in w.runtime.rglob("*") if path.is_file()} == before diff --git a/tests/control_plane/test_shadow_management_e2e.py b/tests/control_plane/test_shadow_management_e2e.py new file mode 100644 index 0000000000..ffd2fb9766 --- /dev/null +++ b/tests/control_plane/test_shadow_management_e2e.py @@ -0,0 +1,271 @@ +"""Public CLI and real Python -> TypeScript management interleavings. + +All goals and providers are disposable. The scheduling seam delays a real RPC; +it never supplies a substitute provider result or edits an active user goal. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import select +import subprocess +import sys +import time + +import pytest + +from loopx.control_plane.coordination.shadow_management import read_shadow_management_state +from loopx.control_plane.coordination.runtime_shadow import build_runtime_shadow_source_snapshot +from loopx.control_plane.coordination.coordination_state_contract_generated import ( + COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA, +) +from loopx.control_plane.effect_runtime import effect_runtime_result + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +pytestmark = pytest.mark.stage2c_e2e + + +def _workspace(tmp_path: Path) -> tuple[Path, Path]: + runtime = tmp_path / "runtime" + goals = [] + for goal_id in ("goal-a", "goal-b"): + repo = tmp_path / goal_id + repo.mkdir() + (repo / "ACTIVE_GOAL_STATE.md").write_text( + f"---\ngoal_id: {goal_id}\nhandoff_mode: hard_lease\n" + "updated_at: 2026-01-01T00:00:00+00:00\n---\n\n## Agent Todo\n\n" + ) + goals.append({ + "id": goal_id, "status": "active", "repo": str(repo), + "state_file": "ACTIVE_GOAL_STATE.md", + "coordination": { + "agent_model": "peer_v1", "registered_agents": ["agent-a", "agent-b"], + "runtime_shadow": { + "schema_version": "loopx_coordination_runtime_shadow_config_v0", + "enabled": True, "provider": "file_v0", + }, + }, + }) + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": goals})) + return registry, runtime + + +def _arguments(registry: Path, runtime: Path, *args: str) -> list[str]: + return ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", *args] + + +def _cli(registry: Path, runtime: Path, *args: str, success: bool = True) -> dict: + result = subprocess.run( + [sys.executable, "-m", "loopx.cli", *_arguments(registry, runtime, *args)], + cwd=REPO_ROOT, capture_output=True, text=True, timeout=30, + ) + if success: + assert result.returncode == 0, f"{result.stdout}\n{result.stderr}" + return json.loads(result.stdout) + + +def _candidate(runtime: Path, goal: str) -> Path: + digest = hashlib.sha256(goal.encode()).hexdigest()[:16] + return runtime / "authority-shadow" / "file-v0" / f"authority-store-{digest}.json" + + +def _bootstrap(registry: Path, runtime: Path, goal: str = "goal-a") -> dict: + return _cli(registry, runtime, "coordination-shadow", "bootstrap", "--goal-id", goal, "--execute")["bootstrap"] + + +def test_public_rollback_preserves_other_goal_and_replays_after_primary_changes(tmp_path: Path) -> None: + registry, runtime = _workspace(tmp_path) + first = _bootstrap(registry, runtime) + _bootstrap(registry, runtime, "goal-b") + other = _candidate(runtime, "goal-b").read_bytes() + identity = (runtime / "authority-shadow" / "file-v0" / "store-identity").read_bytes() + result = _cli(registry, runtime, "coordination-shadow", "rollback", "--goal-id", "goal-a", + "--provider-revision", first["provider_revision"], "--execute")["rollback"] + assert result["status"] == "applied" + _cli(registry, runtime, "todo", "add", "--goal-id", "goal-a", "--role", "agent", + "--text", "Write primary while awaiting bootstrap", "--claimed-by", "agent-a") + assert not _candidate(runtime, "goal-a").exists() + second = _bootstrap(registry, runtime) + assert first["capture_lineage_id"] != second["capture_lineage_id"] + current = _candidate(runtime, "goal-a").read_bytes() + historical = _cli(registry, runtime, "coordination-shadow", "rollback", "--goal-id", "goal-a", + "--provider-revision", first["provider_revision"], "--execute")["rollback"] + assert historical["status"] == "replayed" + assert historical["current_capture_lineage_id"] == second["capture_lineage_id"] + assert _candidate(runtime, "goal-a").read_bytes() == current + assert _candidate(runtime, "goal-b").read_bytes() == other + assert (runtime / "authority-shadow" / "file-v0" / "store-identity").read_bytes() == identity + + +_DELAYED_WRITER = """ +import json, pathlib, sys, time +from loopx.control_plane.coordination import local_authority_shadow_adapter as adapter +from loopx.cli import main +barrier, release, timing = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), sys.argv[3] +actual = adapter.effect_runtime_result +def delayed(method, request, **kwargs): + if method != 'coordination.runtime_shadow.commit_entry': + return actual(method, request, **kwargs) + result = actual(method, request, **kwargs) if timing == 'after' else None + barrier.write_text(json.dumps({'request':request, 'result':result})) + deadline = time.monotonic() + 60 + while not release.exists(): + if time.monotonic() > deadline: + raise RuntimeError('test scheduling barrier timed out') + time.sleep(.01) + return result if timing == 'after' else actual(method, request, **kwargs) +adapter.effect_runtime_result = delayed +raise SystemExit(main(sys.argv[4:])) +""" + + +def _paused_writer(tmp_path: Path, registry: Path, runtime: Path, timing: str) -> tuple[subprocess.Popen, Path, dict]: + barrier = tmp_path / "commit-barrier.json" + release = tmp_path / "commit-release" + args = _arguments(registry, runtime, "todo", "add", "--goal-id", "goal-a", "--role", "agent", + "--text", "Transaction across a management boundary", "--claimed-by", "agent-a") + child = subprocess.Popen( + [sys.executable, "-c", _DELAYED_WRITER, str(barrier), str(release), timing, *args], + cwd=REPO_ROOT, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + deadline = time.monotonic() + 20 + while not barrier.exists(): + if child.poll() is not None: + stdout, stderr = child.communicate() + pytest.fail(f"public writer exited before real commit RPC: {stdout}\n{stderr}") + if time.monotonic() > deadline: + child.kill() + stdout, stderr = child.communicate() + pytest.fail(f"public writer did not reach real commit RPC: {stdout}\n{stderr}") + time.sleep(.01) + # Atomic JSON is unnecessary for authority here; retry the test notification only. + for _ in range(100): + try: + return child, release, json.loads(barrier.read_text()) + except json.JSONDecodeError: + time.sleep(.01) + child.kill() + raise AssertionError("incomplete test barrier") + + +def _release(child: subprocess.Popen, path: Path) -> dict: + path.write_text("continue") + try: + stdout, stderr = child.communicate(timeout=30) + except subprocess.TimeoutExpired: + child.kill() + child.communicate() + raise + assert child.returncode == 0, f"{stdout}\n{stderr}" + return json.loads(stdout) + + +@pytest.mark.parametrize("timing", ["before", "after"]) +def test_late_real_commit_cannot_cross_rollback_and_rebootstrap(tmp_path: Path, timing: str) -> None: + registry, runtime = _workspace(tmp_path) + first = _bootstrap(registry, runtime) + _bootstrap(registry, runtime, "goal-b") + other = _candidate(runtime, "goal-b").read_bytes() + child, release, barrier = _paused_writer(tmp_path, registry, runtime, timing) + try: + request = barrier["request"] + assert request["entry"]["capture_lineage_id"] == first["capture_lineage_id"] + revision = first["provider_revision"] if timing == "before" else barrier["result"]["provider_revision"] + rollback = _cli(registry, runtime, "coordination-shadow", "rollback", "--goal-id", "goal-a", + "--provider-revision", revision, "--execute")["rollback"] + archived = Path(rollback["outbox_archive_path"]) + retained = {str(path.relative_to(archived)): path.read_bytes() for path in archived.rglob("*") if path.is_file()} + assert any(name.endswith(".prepared.json") for name in retained) + second = _bootstrap(registry, runtime) + assert second["capture_lineage_id"] != first["capture_lineage_id"] + candidate = _candidate(runtime, "goal-a").read_bytes() + _release(child, release) + assert _candidate(runtime, "goal-a").read_bytes() == candidate + assert _candidate(runtime, "goal-b").read_bytes() == other + assert {str(path.relative_to(archived)): path.read_bytes() for path in archived.rglob("*") if path.is_file()} == retained + active_outbox = runtime / "authority-shadow" / "outbox" / "goal-a" + assert not list(active_outbox.rglob("drain-cursor.json")) + assert not list(active_outbox.rglob("*.prepared.json")) + late = effect_runtime_result("coordination.runtime_shadow.commit_entry", request) + assert late["outcome"] not in {"delivered", "replayed", "reconciled"} + assert _candidate(runtime, "goal-a").read_bytes() == candidate + finally: + if child.poll() is None: + child.kill() + child.communicate() + + +def test_corrupt_history_after_real_commit_cannot_authorize_cursor_cleanup(tmp_path: Path) -> None: + registry, runtime = _workspace(tmp_path) + _bootstrap(registry, runtime) + child, release, barrier = _paused_writer(tmp_path, registry, runtime, "after") + try: + assert barrier["result"]["outcome"] in {"delivered", "replayed", "reconciled"} + directory = runtime / "authority-shadow" / "outbox" / "goal-a" / "todos" + entries = {path.name: path.read_bytes() for path in directory.glob("*.json")} + assert any(name.endswith(".prepared.json") for name in entries) + candidate = _candidate(runtime, "goal-a") + record = json.loads(candidate.read_text()) + record["committed"][0]["provider_revision"] = "file:1:" + "0" * 24 + candidate.write_text(json.dumps(record)) + corrupt = candidate.read_bytes() + _release(child, release) + assert {path.name: path.read_bytes() for path in directory.glob("*.json")} == entries + assert candidate.read_bytes() == corrupt + assert not (directory / "drain-cursor.json").exists() + finally: + if child.poll() is None: + child.kill() + child.communicate() + + +@pytest.mark.parametrize("phase", ["bootstrap_prepared", "bootstrap_candidate_committed", "bootstrap_outbox_ready"]) +def test_public_bootstrap_operation_selector_aborts_real_crash(tmp_path: Path, phase: str) -> None: + registry, runtime = _workspace(tmp_path) + _bootstrap(registry, runtime, "goal-b") + other = _candidate(runtime, "goal-b").read_bytes() + goal = json.loads(registry.read_text())["goals"][0] + state_path = Path(goal["repo"]) / goal["state_file"] + original = state_path.read_bytes() + projection, snapshot = build_runtime_shadow_source_snapshot( + goal=goal, runtime_root=runtime, state_path=state_path, registry_path=registry, + ) + operation = "bootstrap:public-crash" + request = {"schema_version": COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA, + "runtime_root": str(runtime), "goal_id": "goal-a", "operation_id": operation, + "source_version": "source:before-crash", "projection": projection, "source_snapshot": snapshot} + worker = REPO_ROOT / "tests" / "control_plane_ts" / "fixtures" / "shadow_management_crash_worker.ts" + child = subprocess.Popen( + ["node", "--no-warnings", "--experimental-strip-types", str(worker), "bootstrap-public", json.dumps(request), phase], + cwd=REPO_ROOT, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + try: + ready, _, _ = select.select([child.stdout], [], [], 10) + assert ready, "bootstrap did not reach the real filesystem barrier" + line = child.stdout.readline() + assert line.strip() == f"ready:{phase}", line + child.kill() + child.communicate(timeout=10) + pending = read_shadow_management_state(runtime, "goal-a") + assert pending is not None and pending["status"] == "bootstrapping" + blocked = _cli(registry, runtime, "todo", "add", "--goal-id", "goal-a", "--role", "agent", + "--text", "Must not write while bootstrap is pending", "--claimed-by", "agent-a", success=False) + assert blocked["ok"] is False + assert state_path.read_bytes() == original + stopped = _cli(registry, runtime, "coordination-shadow", "rollback", "--goal-id", "goal-a", + "--bootstrap-operation-id", operation, "--execute")["rollback"] + assert stopped["status"] == "applied" + assert read_shadow_management_state(runtime, "goal-a")["status"] == "inactive" + assert _candidate(runtime, "goal-b").read_bytes() == other + current = _bootstrap(registry, runtime) + assert current["status"] == "applied" + assert current["capture_lineage_id"] != stopped["capture_lineage_id"] + finally: + if child.poll() is None: + child.kill() + child.communicate() diff --git a/tests/control_plane/test_shadow_writer_boundaries.py b/tests/control_plane/test_shadow_writer_boundaries.py new file mode 100644 index 0000000000..e9ec76104f --- /dev/null +++ b/tests/control_plane/test_shadow_writer_boundaries.py @@ -0,0 +1,717 @@ +from __future__ import annotations + +import hashlib +import json +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +from loopx.control_plane.coordination.legacy_writer_fence import ( + LegacyCoordinationWriterFenced, + legacy_coordination_writer_fence_path, + legacy_todo_write_transaction, +) +from loopx.control_plane.todos.handoff_mode import set_goal_handoff_mode +from loopx.todo_followups import capture_followup_todos + + +GOAL = "writer-boundary" +REPO = Path(__file__).resolve().parents[2] + + +pytestmark = pytest.mark.stage2c_e2e + + +def fixture(tmp_path: Path) -> tuple[Path, Path, Path]: + state = tmp_path / "ACTIVE_GOAL_STATE.md" + state.write_text( + "---\ngoal_id: writer-boundary\nhandoff_mode: legacy\n" + "updated_at: 2026-09-05T00:00:00Z\n---\n\n" + "## Agent Todo\n\n## Progress Ledger\n\n## Next Action\n\n- Review.\n", + encoding="utf-8", + ) + root = tmp_path / "runtime" + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"common_runtime_root": str(root), "goals": [{ + "id": GOAL, "repo": str(tmp_path), "state_file": state.name, + "coordination": {"agent_model": "peer_v1", "registered_agents": ["agent-a"]}, + }]}), encoding="utf-8") + return registry, state, root + + +@pytest.mark.parametrize("writer", ["handoff", "followups"]) +def test_omitted_writers_refuse_a_fence_before_primary( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, writer: str, +) -> None: + registry, state, root = fixture(tmp_path) + fence = legacy_coordination_writer_fence_path(runtime_root=root, goal_id=GOAL) + fence.parent.mkdir(parents=True) + fence.write_text("{}", encoding="utf-8") + monkeypatch.setattr( + "loopx.control_plane.coordination.legacy_writer_fence.effect_runtime_result", + lambda *_args, **_kwargs: { + "status": "blocked", "reason_code": "legacy_coordination_writer_fenced", + }, + ) + before = state.read_bytes() + with pytest.raises(LegacyCoordinationWriterFenced): + if writer == "handoff": + set_goal_handoff_mode(registry_path=registry, goal_id=GOAL, mode="soft_claim") + else: + capture_followup_todos( + registry_path=registry, goal_id=GOAL, + followups=["Review the durable boundary."], evidence="review fixture", + ) + assert state.read_bytes() == before + assert not (root / "authority-shadow").exists() + + +def test_corrupt_management_state_blocks_before_transaction_body(tmp_path: Path) -> None: + registry, state, root = fixture(tmp_path) + digest = hashlib.sha256(GOAL.encode()).hexdigest()[:16] + management = root / "authority-transition" / "file-v0" / f"shadow-management-{digest}" / "state.json" + management.parent.mkdir(parents=True) + management.write_text("{corrupt", encoding="utf-8") + reached = False + try: + with legacy_todo_write_transaction(registry, GOAL, state, None, "test", False): + reached = True + except RuntimeError as error: + assert getattr(error, "reason_code", "") + assert not reached, "maintenance errors must not fall through to a primary write" + + +def test_atomic_state_writer_keeps_original_on_failed_replace( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + from loopx.control_plane.todos import active_state_editing + + write = getattr(active_state_editing, "atomic_write_state_text", None) + assert callable(write), "all state writers need the shared durable text primitive" + state = tmp_path / "state.md" + state.write_bytes(b"original\r\n") + state.chmod(0o640) + def fail_replace(*_args: object) -> None: + raise OSError("replacement unavailable") + monkeypatch.setattr(active_state_editing.os, "replace", fail_replace) + with pytest.raises(OSError, match="replacement unavailable"): + write(state, "replacement\r\n") + assert state.read_bytes() == b"original\r\n" + assert state.stat().st_mode & 0o777 == 0o640 + assert list(tmp_path.iterdir()) == [state] + + +def reward_fixture(tmp_path: Path) -> tuple[Path, Path, Path, dict[str, str]]: + registry, state, root = fixture(tmp_path) + index = root / "goals" / GOAL / "runs" / "index.jsonl" + index.parent.mkdir(parents=True) + index.write_text(json.dumps({ + "generated_at": "2026-09-05T00:00:00Z", "json_path": "run.json", + "markdown_path": "run.md", "classification": "continue", + }) + "\n", encoding="utf-8") + reward = {"recorded_at": "2026-09-05T00:00:01Z", "decision": "continue", + "reward": "positive", "reason_summary": "Review accepted."} + return registry, state, index, reward + + +def test_reward_summary_cannot_inject_a_canonical_todo(tmp_path: Path) -> None: + from loopx.feedback import append_human_reward + from loopx.control_plane.coordination.runtime_shadow_writer_adapter import ActiveStateAuthorityMutationError + + registry, state, index, reward = reward_fixture(tmp_path) + # Insert before the first recognized Todo source, rather than after it. + state.write_text("---\nhandoff_mode: legacy\n---\n\n## Progress Ledger\n\n## Agent Todo\n", encoding="utf-8") + reward["reason_summary"] = "Review.\n## Agent Todo\n- [ ] Injected canonical task." + before = state.read_bytes(), index.read_bytes() + with pytest.raises(ActiveStateAuthorityMutationError): + append_human_reward(registry_path=registry, runtime_root_override=None, + goal_id=GOAL, run_generated_at=None, reward=reward, + write_active_state_summary=True) + assert (state.read_bytes(), index.read_bytes()) == before + + +def test_reward_rebases_its_owned_paragraph_after_a_concurrent_todo_write( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + from loopx import feedback + + registry, state, _index, reward = reward_fixture(tmp_path) + plan = feedback.plan_active_state_update + def plan_then_edit(**kwargs: object): + result = plan(**kwargs) + state.write_text(state.read_text().replace("## Agent Todo\n", "## Agent Todo\n\n- [ ] Concurrent task.\n")) + return result + monkeypatch.setattr(feedback, "plan_active_state_update", plan_then_edit) + feedback.append_human_reward(registry_path=registry, runtime_root_override=None, + goal_id=GOAL, run_generated_at=None, reward=reward, + write_active_state_summary=True) + assert "Concurrent task." in state.read_text() + assert "Review accepted." in state.read_text() + + +@pytest.mark.parametrize("preserve", [False, True]) +def test_force_bootstrap_cannot_erase_an_active_shadow_binding(tmp_path: Path, preserve: bool) -> None: + from loopx.bootstrap import bootstrap_project + + registry, state, root = fixture(tmp_path) + value = json.loads(registry.read_text()) + value["goals"][0]["coordination"]["runtime_shadow"] = { + "enabled": True, "schema_version": "loopx_coordination_runtime_shadow_config_v0", "provider": "file_v0", + } + registry.write_text(json.dumps(value)) + before = registry.read_bytes(), state.read_bytes() + with pytest.raises(RuntimeError, match="shadow"): + bootstrap_project(project=tmp_path, registry_path=registry, runtime_root=root, + goal_id=GOAL, objective="Rebuild safely.", domain="test", role="primary", + parent_goal_id=None, state_file=state, goal_doc=None, adapter_kind="generic_project_goal_v0", + adapter_status="connected", next_probe=None, spawn_allowed=False, max_children=0, + allowed_domains=[], write_scope=[], onboarding_scan_enabled=False, + force=True, preserve_todos=preserve, dry_run=False, sync_global=False) + assert (registry.read_bytes(), state.read_bytes()) == before + + +def cli(registry: Path, *args: str) -> dict: + result = subprocess.run([sys.executable, "-m", "loopx.entrypoint", "--registry", str(registry), "--format", "json", *args], + cwd=REPO, text=True, capture_output=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr + return json.loads(result.stdout) + + +def test_real_cli_handoff_and_followup_batch_have_one_receipt_each(tmp_path: Path) -> None: + registry, state, root = fixture(tmp_path) + value = json.loads(registry.read_text()) + value["goals"][0]["coordination"]["runtime_shadow"] = { + "enabled": True, "schema_version": "loopx_coordination_runtime_shadow_config_v0", "provider": "file_v0", + } + registry.write_text(json.dumps(value)) + cli(registry, "coordination-shadow", "bootstrap", "--goal-id", GOAL, "--execute") + handoff = cli(registry, "handoff-mode", "set", "--goal-id", GOAL, "--mode", "soft_claim") + followed = cli(registry, "todo", "capture-followups", "--goal-id", GOAL, + "--follow-up", "Inspect the read path.", "--follow-up", "Inspect the write path.", + "--evidence", "review fixture") + assert handoff["coordination_runtime_shadow"]["outcome"] == "delivered", handoff + assert followed["coordination_runtime_shadow"]["outcome"] == "delivered", followed + assert followed["recorded_count"] == 2 + digest = hashlib.sha256(GOAL.encode()).hexdigest()[:16] + candidate = json.loads((root / "authority-shadow" / "file-v0" / f"authority-store-{digest}.json").read_text()) + assert candidate["cursor"] == "3", "bootstrap plus two primary writes must not get CLI mirror receipts" + assert len(candidate["committed"]) == 3 + assert "Inspect the read path." in state.read_text() + noop = cli(registry, "todo", "capture-followups", "--goal-id", GOAL, + "--follow-up", "Inspect the read path.", "--evidence", "review fixture") + assert noop["changed"] is False + assert json.loads((root / "authority-shadow" / "file-v0" / f"authority-store-{digest}.json").read_text())["cursor"] == "3" + + +@pytest.mark.parametrize("phase", ["before", "after"]) +def test_real_process_kill_around_state_replace_preserves_complete_bytes(tmp_path: Path, phase: str) -> None: + state = tmp_path / "state.md" + state.write_bytes(b"original\r\n") + code = """ +import sys +from pathlib import Path +from loopx.control_plane.todos import active_state_editing as editing +original = editing.os.replace +def replace(source, target): + if sys.argv[2] == 'after': original(source, target) + print('replace-cut', flush=True) + sys.stdin.readline() + if sys.argv[2] == 'before': original(source, target) +editing.os.replace = replace +editing.atomic_write_state_text(Path(sys.argv[1]), 'replacement\\r\\n') +""" + child = subprocess.Popen([sys.executable, "-c", code, str(state), phase], cwd=REPO, + text=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + assert child.stdout is not None + assert child.stdout.readline().strip() == "replace-cut" + child.kill() + child.communicate(timeout=10) + finally: + if child.poll() is None: + child.kill() + child.communicate(timeout=10) + assert state.read_bytes() == (b"original\r\n" if phase == "before" else b"replacement\r\n") + + +def test_cli_waiting_for_todo_mutex_rechecks_fence_after_engagement(tmp_path: Path) -> None: + from loopx.file_lock import exclusive_cross_runtime_file_lock + from loopx.control_plane.coordination.legacy_writer_fence import legacy_coordination_todo_lock_path + + registry, state, root = fixture(tmp_path) + before = state.read_bytes() + code = """ +import sys +from contextlib import contextmanager +from loopx.control_plane.coordination import legacy_writer_fence as guard +original = guard.exclusive_cross_runtime_file_lock +@contextmanager +def traced(path, **kwargs): + if path.name.startswith('legacy-todo-writer-'): print('waiting-for-todo-lock', flush=True) + with original(path, **kwargs) as held: yield held +guard.exclusive_cross_runtime_file_lock = traced +from loopx.entrypoint import main +sys.argv = ['loopx', *sys.argv[1:]] +raise SystemExit(main()) +""" + target = legacy_coordination_todo_lock_path(runtime_root=root, goal_id=GOAL) + lease_lock = root / "goals" / GOAL / "task-leases" / ".task-leases" + request = {"schema_version": "loopx_legacy_coordination_writer_fence_engage_request_v0", + "runtime_root": str(root), "goal_id": GOAL, "state_path": str(state), + "fence": {"schema_version": "loopx_legacy_coordination_writer_fence_v0", + "state": "engaged", "goal_id": GOAL, "fence_id": "race-fence", + "source_version": "race-source", "source_projection_sha256": "a" * 64, + "expected_shadow_provider_revision": "file:1:aaaaaaaaaaaaaaaaaaaaaaaa"}} + engage_code = "from loopx.control_plane.effect_runtime import effect_runtime_result; import sys,json; print(json.dumps(effect_runtime_result('coordination.local_authority.legacy_writer_fence.engage', json.loads(sys.argv[1]))))" + children = [] + try: + # Real engagement owns T and waits behind K; the public writer then waits + # behind engagement's T. No fixture writes a fence marker on its behalf. + with exclusive_cross_runtime_file_lock(lease_lock): + engage = subprocess.Popen([sys.executable, "-c", engage_code, json.dumps(request)], + cwd=REPO, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + children.append(engage) + deadline = time.monotonic() + 10 + while not Path(str(target) + ".ts-effect.lock").exists(): + assert engage.poll() is None, engage.communicate(timeout=1) + assert time.monotonic() < deadline, "engagement did not acquire the Todo lock" + time.sleep(0.01) + child = subprocess.Popen([sys.executable, "-c", code, "--registry", str(registry), "--format", "json", + "todo", "capture-followups", "--goal-id", GOAL, "--follow-up", "Must be fenced.", + "--evidence", "race fixture"], cwd=REPO, + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + children.append(child) + assert child.stdout is not None + assert child.stdout.readline().strip() == "waiting-for-todo-lock" + assert child.poll() is None + engage_output, engage_error = engage.communicate(timeout=30) + assert json.loads(engage_output)["status"] == "applied", engage_output + engage_error + output, error = child.communicate(timeout=30) + payload = json.loads(output) + assert child.returncode == 1, output + error + assert payload["error_code"] == "legacy_coordination_writer_fenced" + assert state.read_bytes() == before + finally: + for child in children: + if child.poll() is None: + child.kill() + child.communicate(timeout=10) + + +@pytest.mark.parametrize("override_root", [False, True]) +def test_real_writer_commits_before_a_later_fence_is_published(tmp_path: Path, override_root: bool) -> None: + registry, state, root = fixture(tmp_path) + writer_code = """ +import sys +from loopx import todo_followups +original = todo_followups.atomic_write_state_text +def paused(*args): + print('primary-write-cut', flush=True) + sys.stdin.readline() + return original(*args) +todo_followups.atomic_write_state_text = paused +from loopx.entrypoint import main +raise SystemExit(main(sys.argv[1:])) +""" + request = {"schema_version": "loopx_legacy_coordination_writer_fence_engage_request_v0", + "runtime_root": str(root), "goal_id": GOAL, "state_path": str(state), + "fence": {"schema_version": "loopx_legacy_coordination_writer_fence_v0", + "state": "engaged", "goal_id": GOAL, "fence_id": "later-fence", + "source_version": "source-after-write", "source_projection_sha256": "a" * 64, + "expected_shadow_provider_revision": "file:1:aaaaaaaaaaaaaaaaaaaaaaaa"}} + children = [] + try: + writer = subprocess.Popen([sys.executable, "-c", writer_code, "--registry", str(registry), + "--runtime-root", str(tmp_path / "override" if override_root else root), + "--format", "json", "todo", "capture-followups", "--goal-id", GOAL, + "--follow-up", "Primary won the lock.", "--evidence", "ordering fixture"], + cwd=REPO, text=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + children.append(writer) + assert writer.stdout is not None + assert writer.stdout.readline().strip() == "primary-write-cut" + engage_code = "from loopx.control_plane.effect_runtime import effect_runtime_result; import sys,json; print(json.dumps(effect_runtime_result('coordination.local_authority.legacy_writer_fence.engage', json.loads(sys.argv[1]))))" + engager = subprocess.Popen([sys.executable, "-c", engage_code, json.dumps(request)], + cwd=REPO, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + children.append(engager) + from loopx.control_plane.coordination.shadow_management import shadow_maintenance_lock_target + mutex = Path(str(shadow_maintenance_lock_target(root, GOAL)) + ".ts-effect.lock") + deadline = time.monotonic() + 10 + while not mutex.exists(): + assert engager.poll() is None, engager.communicate(timeout=1) + assert time.monotonic() < deadline + time.sleep(0.01) + time.sleep(.2) + assert not legacy_coordination_writer_fence_path(runtime_root=root, goal_id=GOAL).exists() + assert engager.poll() is None + output, error = writer.communicate("continue\n", timeout=30) + assert writer.returncode == 0, output + error + assert json.loads(output)["recorded_count"] == 1 + output, error = engager.communicate(timeout=30) + assert json.loads(output)["status"] == "applied", output + error + assert "Primary won the lock." in state.read_text() + finally: + for child in children: + if child.poll() is None: + child.kill() + child.communicate(timeout=10) + + +def test_failed_primary_replace_never_marks_shadow_committed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from loopx.control_plane.todos import active_state_editing + registry, state, root = fixture(tmp_path) + value = json.loads(registry.read_text()) + value["goals"][0]["coordination"]["runtime_shadow"] = { + "enabled": True, "schema_version": "loopx_coordination_runtime_shadow_config_v0", "provider": "file_v0"} + registry.write_text(json.dumps(value)) + cli(registry, "coordination-shadow", "bootstrap", "--goal-id", GOAL, "--execute") + before = state.read_bytes() + original = active_state_editing.os.replace + def fail_primary(source: object, target: object) -> None: + if Path(str(target)) == state: + raise OSError("primary replace refused") + original(source, target) + monkeypatch.setattr(active_state_editing.os, "replace", fail_primary) + with pytest.raises(OSError, match="primary replace refused"): + capture_followup_todos(registry_path=registry, goal_id=GOAL, + followups=["Must stay prepared."], evidence="replace failure") + assert state.read_bytes() == before + directory = root / "authority-shadow" / "outbox" / GOAL / "todos" + assert len(list(directory.glob("*.prepared.json"))) == 1 + assert list(directory.glob("*.committed.json")) == [] + + +def test_prose_only_reward_remains_allowed_under_a_legacy_fence(tmp_path: Path) -> None: + from loopx.feedback import append_human_reward + registry, state, _index, reward = reward_fixture(tmp_path) + root = tmp_path / "runtime" + fence = legacy_coordination_writer_fence_path(runtime_root=root, goal_id=GOAL) + fence.parent.mkdir(parents=True) + # Prose never reads this authority marker, even if invalid; its projection + # comparison proves that no Todo/lease field is changed. + fence.write_text("{invalid", encoding="utf-8") + result = append_human_reward(registry_path=registry, runtime_root_override=None, + goal_id=GOAL, run_generated_at=None, reward=reward, write_active_state_summary=True) + assert result["appended"] is True + assert "Review accepted." in state.read_text() + assert not (root / "authority-shadow").exists() + + +def test_prose_only_reward_holds_before_index_append_during_maintenance(tmp_path: Path) -> None: + from loopx.feedback import append_human_reward + from loopx.control_plane.coordination.shadow_management import ShadowManagementError, shadow_management_state_path + registry, state, index, reward = reward_fixture(tmp_path) + path = shadow_management_state_path(tmp_path / "runtime", GOAL) + path.parent.mkdir(parents=True) + path.write_text("{}") + before = state.read_bytes(), index.read_bytes() + with pytest.raises(ShadowManagementError): + append_human_reward(registry_path=registry, runtime_root_override=None, + goal_id=GOAL, run_generated_at=None, reward=reward, write_active_state_summary=True) + assert (state.read_bytes(), index.read_bytes()) == before + + +def test_override_root_is_the_only_maintenance_authority(tmp_path: Path) -> None: + from loopx.control_plane.coordination.shadow_management import ShadowManagementError, shadow_management_state_path + registry, state, root = fixture(tmp_path) + override = tmp_path / "override" + management = shadow_management_state_path(override, GOAL) + management.parent.mkdir(parents=True) + management.write_text("{}") + with pytest.raises(ShadowManagementError): + capture_followup_todos(registry_path=registry, goal_id=GOAL, + runtime_root_arg=str(override), followups=["Hold override."], evidence="root fixture") + assert "Hold override." not in state.read_text() + assert not (root / "authority-transition").exists() + result = capture_followup_todos(registry_path=registry, goal_id=GOAL, + followups=["Default root remains writable."], evidence="root fixture") + assert result["recorded_count"] == 1 + + +@pytest.mark.parametrize("writer", ["todo", "prose"]) +def test_override_root_cannot_bypass_registry_source_maintenance(tmp_path: Path, writer: str) -> None: + from loopx.control_plane.coordination.shadow_management import ShadowManagementError, shadow_management_state_path + from loopx.state_refresh import refresh_state_run + registry, state, root = fixture(tmp_path) + override = tmp_path / "override" + management = shadow_management_state_path(root, GOAL) + management.parent.mkdir(parents=True) + management.write_text("{}") + before = state.read_bytes() + with pytest.raises(ShadowManagementError): + if writer == "todo": + capture_followup_todos(registry_path=registry, goal_id=GOAL, + runtime_root_arg=str(override), followups=["Cannot bypass source maintenance."], evidence="root fixture") + else: + refresh_state_run(registry_path=registry, runtime_root_override=str(override), goal_id=GOAL, + project=None, state_file=None, classification="continue", recommended_action="Continue inspection.", + next_action="Cannot bypass source maintenance.", dry_run=False, sync_global=False) + assert state.read_bytes() == before + + +def test_override_root_keeps_prose_writable_with_an_active_source_binding(tmp_path: Path) -> None: + from loopx.state_refresh import refresh_state_run + registry, state, _root = fixture(tmp_path) + value = json.loads(registry.read_text()) + value["goals"][0]["coordination"]["runtime_shadow"] = { + "enabled": True, "schema_version": "loopx_coordination_runtime_shadow_config_v0", "provider": "file_v0"} + registry.write_text(json.dumps(value)) + cli(registry, "coordination-shadow", "bootstrap", "--goal-id", GOAL, "--execute") + override = tmp_path / "override" + refreshed = refresh_state_run(registry_path=registry, runtime_root_override=str(override), goal_id=GOAL, + project=None, state_file=None, classification="continue", recommended_action="Continue inspection.", + next_action="Only this owned prose changes.", dry_run=False, sync_global=False) + assert refreshed["ok"] is True + assert "Only this owned prose changes." in state.read_text() + assert not (override / "authority-shadow" / "outbox" / GOAL).exists() + + +def test_waiting_override_writer_rechecks_registry_binding_inside_shared_state_lock(tmp_path: Path) -> None: + registry, state, _root = fixture(tmp_path) + value = json.loads(registry.read_text()) + value["goals"][0]["coordination"]["runtime_shadow"] = { + "enabled": True, "schema_version": "loopx_coordination_runtime_shadow_config_v0", "provider": "file_v0"} + registry.write_text(json.dumps(value)) + before = state.read_bytes() + waiting, proceed = tmp_path / "waiting", tmp_path / "proceed" + code = """ +import sys,time +from contextlib import contextmanager +from pathlib import Path +from loopx.control_plane.coordination import legacy_writer_fence as guard +state,waiting,proceed = map(Path, sys.argv[1:4]) +original = guard.exclusive_cross_runtime_file_lock +@contextmanager +def traced(path, **kwargs): + if path == state: + waiting.write_text('waiting for actual shared state lock') + deadline = time.monotonic()+20 + while not proceed.exists(): + if time.monotonic()>deadline: raise TimeoutError('parent did not release writer') + time.sleep(.01) + with original(path, **kwargs) as held: yield held +guard.exclusive_cross_runtime_file_lock = traced +from loopx.entrypoint import main +sys.argv = ['loopx', *sys.argv[4:]] +raise SystemExit(main()) +""" + child = subprocess.Popen([sys.executable, "-c", code, str(state), str(waiting), str(proceed), + "--registry", str(registry), "--runtime-root", str(tmp_path / "override"), "--format", "json", + "todo", "capture-followups", "--goal-id", GOAL, "--follow-up", "Must observe the new source binding.", + "--evidence", "cross-root race"], cwd=REPO, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + deadline = time.monotonic() + 10 + while not waiting.exists(): + assert child.poll() is None, child.communicate(timeout=1) + assert time.monotonic() < deadline, "writer did not reach shared state lock" + time.sleep(.01) + bootstrap = cli(registry, "coordination-shadow", "bootstrap", "--goal-id", GOAL, "--execute") + assert bootstrap["bootstrap"]["status"] == "applied" + proceed.write_text("source root is now active") + output, error = child.communicate(timeout=30) + payload = json.loads(output) + assert child.returncode == 1, output + error + assert payload["error_code"] == "shadow_source_runtime_root_mismatch" + assert state.read_bytes() == before + finally: + if child.poll() is None: + child.kill() + child.communicate(timeout=10) + + +def test_migration_refuses_overwriting_a_managed_target_before_copy(tmp_path: Path) -> None: + from loopx.state_migration import migrate_legacy_state + source = tmp_path / "source" + target = tmp_path / "target" + source.mkdir() + target.mkdir() + source_registry, source_state, source_root = fixture(source) + target_registry, target_state, target_root = fixture(target) + target_state.write_text(target_state.read_text() + "\nProtected target history.\n") + value = json.loads(target_registry.read_text()) + value["goals"][0]["coordination"]["runtime_shadow"] = {"enabled": True} + target_registry.write_text(json.dumps(value)) + before = target_state.read_bytes(), target_registry.read_bytes(), source_state.read_bytes() + with pytest.raises(RuntimeError, match="shadow source"): + migrate_legacy_state(legacy_registry_path=source_registry, target_registry_path=target_registry, + legacy_runtime_root=source_root, target_runtime_root=target_root, goal_ids=[GOAL], + goal_id_map={}, path_map={str(source): str(target)}, copy_active_state=True, + copy_runtime=True, execute=True) + assert (target_state.read_bytes(), target_registry.read_bytes(), source_state.read_bytes()) == before + + +def test_registry_missing_state_reconstruction_respects_maintenance(tmp_path: Path) -> None: + from loopx.control_plane.projects.registry import register_project_goal + from loopx.control_plane.coordination.shadow_management import ShadowManagementError, shadow_management_state_path + registry = tmp_path / "registry.json" + root = tmp_path / "runtime" + args = dict(registry_path=registry, runtime_root=root, project_id="project-a", project_kind="work", + knowledge_root=tmp_path, goal_id=GOAL, objective="Keep the source intact.", non_goals=[], + acceptance=["State exists."], unknowns=[], next_effect="Inspect state.", stop_condition="Done.", + repository_bindings=[], external_locator_bindings=[]) + created = register_project_goal(**args) + state = Path(created["state_file"]) + state.unlink() + before = registry.read_bytes() + management = shadow_management_state_path(root, GOAL) + management.parent.mkdir(parents=True, exist_ok=True) + management.write_text("{}") + with pytest.raises(ShadowManagementError): + register_project_goal(**args) + assert not state.exists() + assert registry.read_bytes() == before + + +def test_refresh_owned_next_action_holds_before_state_change(tmp_path: Path) -> None: + from loopx.state_refresh import refresh_state_run + from loopx.control_plane.coordination.shadow_management import ShadowManagementError, shadow_management_state_path + registry, state, root = fixture(tmp_path) + management = shadow_management_state_path(root, GOAL) + management.parent.mkdir(parents=True) + management.write_text("{}") + before = state.read_bytes() + with pytest.raises(ShadowManagementError): + refresh_state_run(registry_path=registry, runtime_root_override=None, goal_id=GOAL, + project=None, state_file=None, classification="continue", recommended_action="Continue inspection.", + next_action="Read the next source.", dry_run=False, sync_global=False) + assert state.read_bytes() == before + assert not (root / "goals" / GOAL / "runs" / "index.jsonl").exists() + + +def test_active_capture_prepare_failure_holds_primary_before_any_transition(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from loopx.control_plane.coordination import local_authority_shadow_outbox as outbox + from loopx.control_plane.coordination.shadow_management import ShadowManagementError + registry, state, root = fixture(tmp_path) + value = json.loads(registry.read_text()) + value["goals"][0]["coordination"]["runtime_shadow"] = { + "enabled": True, "schema_version": "loopx_coordination_runtime_shadow_config_v0", "provider": "file_v0"} + registry.write_text(json.dumps(value)) + cli(registry, "coordination-shadow", "bootstrap", "--goal-id", GOAL, "--execute") + before = state.read_bytes() + original = outbox.durable_write_json + def refuse_prepare(path: Path, record: object) -> None: + if path.name.endswith(".prepared.json"): + raise OSError("prepared durability unavailable") + original(path, record) + monkeypatch.setattr(outbox, "durable_write_json", refuse_prepare) + for mode in ["soft_claim", "hard_lease"]: + with pytest.raises(ShadowManagementError) as error: + set_goal_handoff_mode(registry_path=registry, goal_id=GOAL, mode=mode) + assert error.value.reason_code == "shadow_capture_prepare_failed" + assert state.read_bytes() == before + directory = root / "authority-shadow" / "outbox" / GOAL / "todos" + assert list(directory.glob("*.committed.json")) == [] + + +def test_public_preview_does_not_require_primary_write_permission(tmp_path: Path) -> None: + from loopx.control_plane.coordination.shadow_management import shadow_management_state_path + registry, state, root = fixture(tmp_path) + before = state.read_bytes() + management = shadow_management_state_path(root, GOAL) + management.parent.mkdir(parents=True) + management.write_text("{}") + fence = legacy_coordination_writer_fence_path(runtime_root=root, goal_id=GOAL) + fence.write_text("{invalid") + preview = cli(registry, "todo", "capture-followups", "--goal-id", GOAL, + "--follow-up", "Preview remains read-only.", "--evidence", "preview fixture", "--dry-run") + assert preview["dry_run"] is True + assert state.read_bytes() == before + assert not (root / "authority-shadow").exists() + + +@pytest.mark.parametrize("operation", ["add", "update", "complete", "supersede", "archive", "followups"]) +def test_all_todo_transaction_owners_enforce_active_preparation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, operation: str, +) -> None: + from loopx import todos + from loopx.control_plane.coordination import local_authority_shadow_outbox as outbox + from loopx.control_plane.coordination.shadow_management import ShadowManagementError + registry, state, root = fixture(tmp_path) + seed = todos.add_goal_todo(registry_path=registry, goal_id=GOAL, role="agent", + text="Characterize this transaction owner.", status="open", + task_class="advancement_task", action_kind="analyze", agent_id="agent-a") + if operation == "archive": + completed = todos.complete_goal_todo(registry_path=registry, goal_id=GOAL, todo_id=seed["todo_id"], + evidence="Seed completion.", next_agent_todo="Next bounded seed.", + next_task_class="advancement_task", agent_id="agent-a") + assert completed["ok"] is True + value = json.loads(registry.read_text()) + value["goals"][0]["coordination"]["runtime_shadow"] = { + "enabled": True, "schema_version": "loopx_coordination_runtime_shadow_config_v0", "provider": "file_v0"} + registry.write_text(json.dumps(value)) + cli(registry, "coordination-shadow", "bootstrap", "--goal-id", GOAL, "--execute") + original = outbox.durable_write_json + def fail_prepare(path: Path, record: object) -> None: + if path.name.endswith(".prepared.json"): + raise OSError("prepare is unavailable") + original(path, record) + monkeypatch.setattr(outbox, "durable_write_json", fail_prepare) + before = state.read_bytes() + identity = dict(registry_path=registry, goal_id=GOAL) + with pytest.raises(ShadowManagementError) as held: + if operation == "add": + todos.add_goal_todo(**identity, role="agent", text="A new obligation.", task_class="advancement_task") + elif operation == "update": + todos.update_goal_todo(**identity, todo_id=seed["todo_id"], note="Changed note.", agent_id="agent-a") + elif operation == "complete": + todos.complete_goal_todo(**identity, todo_id=seed["todo_id"], evidence="Check completed.", + next_agent_todo="Next bounded check.", next_task_class="advancement_task", agent_id="agent-a") + elif operation == "supersede": + todos.supersede_goal_todo(**identity, todo_id=seed["todo_id"], reason="Replace the approach.", + next_agent_todo="Use a better check.", next_task_class="advancement_task", agent_id="agent-a") + elif operation == "archive": + todos.archive_completed_todos(**identity, max_active_done=0, dry_run=False) + else: + capture_followup_todos(**identity, followups=["Capture another owner."], evidence="Boundary fixture.") + assert held.value.reason_code == "shadow_capture_prepare_failed" + assert state.read_bytes() == before + + +def test_concurrent_public_refresh_preserves_the_newer_owned_paragraph(tmp_path: Path) -> None: + registry, state, _root = fixture(tmp_path) + code = """ +import sys +from contextlib import contextmanager +from pathlib import Path +from loopx import state_refresh +original = state_refresh.exclusive_cross_runtime_file_lock +observed = 0 +@contextmanager +def paused(path, *args, **kwargs): + global observed + with original(path, *args, **kwargs) as held: + yield held + if Path(path).name == 'ACTIVE_GOAL_STATE.md': + observed += 1 + if observed == 1: + print('refresh-plan-ready', flush=True) + sys.stdin.readline() +state_refresh.exclusive_cross_runtime_file_lock = paused +from loopx.entrypoint import main +raise SystemExit(main(sys.argv[1:])) +""" + command = ["--registry", str(registry), "--format", "json", "refresh-state", "--goal-id", GOAL, + "--classification", "continue", "--recommended-action", "Retain the selected next action.", + "--next-action", "Stale planned paragraph.", "--no-global-sync"] + child = subprocess.Popen([sys.executable, "-c", code, *command], cwd=REPO, + text=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + assert child.stdout is not None + assert child.stdout.readline().strip() == "refresh-plan-ready" + second = cli(registry, "--runtime-root", str(tmp_path / "parallel-runtime"), + "refresh-state", "--goal-id", GOAL, "--classification", "continue", + "--recommended-action", "Retain the selected next action.", "--next-action", + "Newer committed paragraph.", "--no-global-sync") + assert second["ok"] is True + before = state.read_bytes() + output, error = child.communicate("continue\n", timeout=30) + assert child.returncode == 1, output + error + assert "changed while refresh-state was qualifying" in json.loads(output)["error"] + assert state.read_bytes() == before + assert "Newer committed paragraph." in state.read_text() + finally: + if child.poll() is None: + child.kill() + child.communicate(timeout=10) diff --git a/tests/control_plane/test_split_root_todo_writeback_fence.py b/tests/control_plane/test_split_root_todo_writeback_fence.py index c0ebaf3d8d..57f5215869 100644 --- a/tests/control_plane/test_split_root_todo_writeback_fence.py +++ b/tests/control_plane/test_split_root_todo_writeback_fence.py @@ -1,9 +1,7 @@ """Split-root fence regressions for the monitor-poll and Turn writebacks. -A promotion engages the legacy writer fence under the CLI ``--runtime-root`` -override. Every Python Todo writeback of the same composition must resolve -its fence and mutex from that same override root, never from the registry's -``common_runtime_root``. +Each writeback uses its effective runtime root and also respects the authority +of the registered source file. An override cannot bypass either source fence. """ from __future__ import annotations @@ -149,23 +147,33 @@ def test_monitor_poll_writeback_blocked_when_override_root_is_fenced( assert state.read_text(encoding="utf-8") == state_before -def test_monitor_poll_writeback_allows_when_only_registry_root_is_fenced( - monkeypatch: pytest.MonkeyPatch, +def test_monitor_poll_writeback_blocks_when_the_registry_source_is_fenced( tmp_path: Path, ) -> None: - """The override root alone decides; a registry-root fence must not block.""" + """An override shares the source state, so its original fence still applies.""" + + from loopx.control_plane.effect_runtime import effect_runtime_result registry, state, runtime_registry, runtime_override = ( _write_split_root_goal(tmp_path) ) - _engage_fence_at(runtime_registry) - monkeypatch.setattr( - "loopx.control_plane.coordination.legacy_writer_fence." - "effect_runtime_result", - lambda *_args, **_kwargs: pytest.fail( - "an override-root writeback must not consult another root's fence" - ), - ) + before = state.read_bytes() + engaged = effect_runtime_result("coordination.local_authority.legacy_writer_fence.engage", { + "schema_version": "loopx_legacy_coordination_writer_fence_engage_request_v0", + "runtime_root": str(runtime_registry), "goal_id": GOAL_ID, "state_path": str(state), + "fence": {"schema_version": "loopx_legacy_coordination_writer_fence_v0", "state": "engaged", + "goal_id": GOAL_ID, "fence_id": "original-source", "source_version": "source:1", + "source_projection_sha256": "a" * 64, "expected_shadow_provider_revision": "file:1:aaaaaaaaaaaaaaaaaaaaaaaa"}, + }) + assert engaged["status"] == "applied", engaged + with pytest.raises(LegacyCoordinationWriterFenced) as error: + write_monitor_poll_todo_state(**_poll_kwargs(registry, runtime_override)) + assert error.value.code == "legacy_coordination_writer_fenced" + assert state.read_bytes() == before + + +def test_monitor_poll_writeback_allows_an_unfenced_runtime_override(tmp_path: Path) -> None: + registry, state, _runtime_registry, runtime_override = _write_split_root_goal(tmp_path) receipt = write_monitor_poll_todo_state( **_poll_kwargs(registry, runtime_override) diff --git a/tests/control_plane/test_state_migration_authority_shadow.py b/tests/control_plane/test_state_migration_authority_shadow.py index 6f8f1efaf9..0ce33cccb5 100644 --- a/tests/control_plane/test_state_migration_authority_shadow.py +++ b/tests/control_plane/test_state_migration_authority_shadow.py @@ -189,7 +189,7 @@ def guarded_rglob(path: Path, pattern: str) -> Iterator[Path]: return original_rglob(path, pattern) monkeypatch.setattr( - "loopx.control_plane.coordination.local_authority_shadow_adapter." + "loopx.control_plane.coordination.local_authority_shadow_observation." "observe_local_authority_commit", forbidden_observer, ) @@ -228,7 +228,7 @@ def fail_observer(**_kwargs: object) -> object: raise RuntimeError("credential=private-provider-value") monkeypatch.setattr( - "loopx.control_plane.coordination.local_authority_shadow_adapter." + "loopx.control_plane.coordination.local_authority_shadow_observation." "observe_local_authority_commit", fail_observer, ) diff --git a/tests/control_plane/test_todo_machine_section_projection.py b/tests/control_plane/test_todo_machine_section_projection.py index 2a5bc63454..98d4acbbed 100644 --- a/tests/control_plane/test_todo_machine_section_projection.py +++ b/tests/control_plane/test_todo_machine_section_projection.py @@ -1,7 +1,7 @@ from __future__ import annotations import stat -import hashlib +from loopx.control_plane.todos import active_state_editing import json import subprocess import sys @@ -19,7 +19,7 @@ from loopx.cli_commands import todo as todo_command from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection -from loopx.control_plane.effect_runtime import effect_runtime_result +from canonical_authority_fixture import initialize_canonical_authority SOURCE = """--- @@ -302,11 +302,14 @@ def test_project_markdown_cli_publishes_with_atomic_replace( state_path.chmod(0o640) original_mode = stat.S_IMODE(state_path.stat().st_mode) parent_syncs: list[object] = [] - monkeypatch.setattr( - todo_command, - "_fsync_parent_directory", - lambda path: parent_syncs.append(path), - ) + real_fsync = active_state_editing.os.fsync + + def record_fsync(descriptor: int) -> None: + if stat.S_ISDIR(active_state_editing.os.fstat(descriptor).st_mode): + parent_syncs.append(state_path) + real_fsync(descriptor) + + monkeypatch.setattr(active_state_editing.os, "fsync", record_fsync) monkeypatch.setattr( todo_command, "load_registry", @@ -327,13 +330,14 @@ def test_project_markdown_cli_publishes_with_atomic_replace( lambda **_kwargs: (tmp_path, state_path), ) replacements: list[tuple[object, object]] = [] - real_replace = todo_command.os.replace + real_replace = active_state_editing.os.replace def record_replace(source, target) -> None: - replacements.append((source, target)) + if Path(target) == state_path: + replacements.append((source, target)) real_replace(source, target) - monkeypatch.setattr(todo_command.os, "replace", record_replace) + monkeypatch.setattr(active_state_editing.os, "replace", record_replace) result = todo_command.handle_todo_command( build_parser().parse_args( @@ -368,7 +372,7 @@ def test_atomic_projection_failure_preserves_original(monkeypatch, tmp_path, ope state_path = tmp_path / "ACTIVE_GOAL_STATE.md" state_path.write_bytes(b"original\r\n") opened = [] - real_fdopen = todo_command.os.fdopen + real_fdopen = active_state_editing.os.fdopen def capture_handle(*args, **kwargs): handle = real_fdopen(*args, **kwargs) @@ -378,8 +382,8 @@ def capture_handle(*args, **kwargs): def fail(*_args, **_kwargs): raise OSError("injected pre-publication failure") - monkeypatch.setattr(todo_command.os, "fdopen", capture_handle) - monkeypatch.setattr(todo_command.os, operation, fail) + monkeypatch.setattr(active_state_editing.os, "fdopen", capture_handle) + monkeypatch.setattr(active_state_editing.os, operation, fail) with pytest.raises(OSError, match="injected pre-publication failure"): todo_command._atomic_write_text(state_path, "replacement\n") assert state_path.read_bytes() == b"original\r\n" @@ -482,38 +486,7 @@ def run(revision: str, *extra: str) -> tuple[int, dict]: assert state.read_bytes() == source projection = build_todo_runtime_shadow_projection(goal_id="goal-a", todos=_records()) - digest = hashlib.sha256(json.dumps( - projection, ensure_ascii=False, sort_keys=True, separators=(",", ":") - ).encode()).hexdigest() - common = {"runtime_root": str(runtime), "goal_id": "goal-a"} - for action in ("bootstrap", "commit"): - applied = effect_runtime_result(f"coordination.runtime_shadow.{action}", { - **common, - "schema_version": f"loopx_coordination_runtime_shadow_{action}_v0", - "operation_id": f"projection-{action}", "source_version": f"source-{action}", - "projection": projection, - **({"event_kind": "todo_update"} if action == "commit" else {}), - }) - assert applied["status"] == "applied" - revision = applied["provider_revision"] - fence = { - "schema_version": "loopx_legacy_coordination_writer_fence_v0", - "state": "engaged", "goal_id": "goal-a", "fence_id": "projection-fence", - "source_version": "source-commit", "source_projection_sha256": digest, - "expected_shadow_provider_revision": revision, - } - engaged = effect_runtime_result("coordination.local_authority.legacy_writer_fence.engage", { - **common, "schema_version": "loopx_legacy_coordination_writer_fence_engage_request_v0", - "fence": fence, - }) - assert engaged["status"] == "applied" - promoted = effect_runtime_result("coordination.local_authority.promote", { - **common, "schema_version": "loopx_local_coordination_promotion_request_v0", - "operation_id": "projection-promote", "expected_shadow_provider_revision": revision, - "expected_shadow_projection_sha256": digest, "minimum_operations": 1, - "required_event_kinds": ["todo_update"], "writer_fence": fence, - }) - assert promoted["status"] == "applied" + initialize_canonical_authority(runtime, "goal-a", projection, state_path=state) before = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-a") revision = before["provider_revision"] code, preview = run(revision) diff --git a/tests/control_plane/test_todo_mutation_authority.py b/tests/control_plane/test_todo_mutation_authority.py index b7771de458..c48faeb950 100644 --- a/tests/control_plane/test_todo_mutation_authority.py +++ b/tests/control_plane/test_todo_mutation_authority.py @@ -1596,6 +1596,7 @@ def test_capability_binding_cannot_be_rebound_by_duplicate_add(tmp_path: Path) - def test_capability_binding_follows_event_projected_successor(tmp_path: Path) -> None: + registry, state = _write_fixture(tmp_path) event_log = tmp_path / "todo-events.jsonl" store = AppendOnlyStateEventStore(event_log) store.append( @@ -1621,6 +1622,8 @@ def test_capability_binding_follows_event_projected_successor(tmp_path: Path) -> result = complete_event_projected_goal_todo( goal_id=GOAL_ID, context={ + "registry_path": registry, + "state_path": state, "item": parent, "role": "agent", "event_log_path": event_log, @@ -1670,6 +1673,8 @@ def test_capability_binding_follows_event_projected_successor(tmp_path: Path) -> duplicate = complete_event_projected_goal_todo( goal_id=GOAL_ID, context={ + "registry_path": registry, + "state_path": state, "item": completed_parent, "role": "agent", "event_log_path": event_log, diff --git a/tests/control_plane_ts/coordination_runtime_shadow.test.ts b/tests/control_plane_ts/coordination_runtime_shadow.test.ts index f1ea1b7473..36668e3703 100644 --- a/tests/control_plane_ts/coordination_runtime_shadow.test.ts +++ b/tests/control_plane_ts/coordination_runtime_shadow.test.ts @@ -1,548 +1,169 @@ import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { mkdtemp, readdir } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { readFile, writeFile, unlink, symlink } from "node:fs/promises"; import { join } from "node:path"; import test from "node:test"; -import { canonicalAuthorityBytes } from "../../loopx/control_plane/coordination/authority_store_codec.ts"; -import { - TODO_CANONICAL_READ_RECORD_FIELDS, - TODO_CANONICAL_READ_RECORD_SCHEMA, -} from "../../loopx/control_plane/coordination/coordination_projection.ts"; - -import type { - AuthorityStoreCommit, - AuthorityStoreCommitResult, -} from "../../loopx/control_plane/coordination/authority_store.ts"; -import { - FileAuthorityStore, - type FileAuthorityArchiveResult, -} from "../../loopx/control_plane/coordination/file_authority_store.ts"; -import { - bootstrapCoordinationRuntimeShadow, - commitCoordinationRuntimeShadow, - inspectCoordinationRuntimeShadow, - qualifyCoordinationRuntimeShadow, - readCoordinationRuntimeShadowTodoCandidate, - rollbackCoordinationRuntimeShadow, - COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA, - COORDINATION_RUNTIME_SHADOW_INSPECT_REQUEST_SCHEMA, - COORDINATION_RUNTIME_SHADOW_QUALIFY_REQUEST_SCHEMA, - COORDINATION_RUNTIME_SHADOW_TODO_READ_REQUEST_SCHEMA, - COORDINATION_RUNTIME_SHADOW_REQUEST_SCHEMA, - COORDINATION_RUNTIME_SHADOW_ROLLBACK_REQUEST_SCHEMA, -} from "../../loopx/control_plane/coordination/runtime_shadow.ts"; - -function withTodoReadModel>(projection: T): T { - const todos = projection.todos as Record[]; - return { - ...projection, - todo_read_model: { - schema_version: TODO_CANONICAL_READ_RECORD_SCHEMA, - todo_count: todos.length, - records_sha256: createHash("sha256").update(canonicalAuthorityBytes(todos)).digest("hex"), - contract_fields: [...TODO_CANONICAL_READ_RECORD_FIELDS], - }, - }; -} - -async function request(root: string, operationId = "todo:goal-a:todo_one:v1") { - return { - schema_version: COORDINATION_RUNTIME_SHADOW_REQUEST_SCHEMA, - runtime_root: root, - goal_id: "goal-a", - operation_id: operationId, - event_kind: "todo_claim", - source_version: "state:1", - projection: withTodoReadModel({ - schema_version: "loopx_coordination_shadow_projection_v0", - goal_id: "goal-a", - todos: [{ - schema_version: "todo_item_v0", - todo_id: "todo_one", - role: "agent", - status: "open", - done: false, - text: "Qualify shadow semantics", - archive_state: "active", - source_section: "Agent Todo", - claimed_by: "agent-a", - }], - leases: [], - }), - }; +import type { JsonObject } from "../../loopx/control_plane/effect_program.ts"; +import { canonicalAuthoritySha256 } from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import * as schemas from "../../loopx/control_plane/coordination/coordination_state_contract.generated.ts"; +import { commitLocalAuthorityShadowEntry, readLocalAuthorityShadow } from "../../loopx/control_plane/coordination/local_authority_shadow.ts"; +import { bootstrapCoordinationRuntimeShadow, commitCoordinationRuntimeShadow, inspectCoordinationRuntimeShadow, + qualifyCoordinationRuntimeShadow, readCoordinationRuntimeShadowTodoCandidate, rollbackCoordinationRuntimeShadow } from "../../loopx/control_plane/coordination/runtime_shadow.ts"; +import { fixture, pendingEntry, projection, settleFiles, sourceRequest, todo, type ShadowFixture } from "./shadow_file_fixture.ts"; + +async function qualifiedFixture(t: test.TestContext): Promise<{ f: ShadowFixture; head: JsonObject }> { + const f = await fixture(t); let head = projection(); + for (let seq = 1; seq <= 3; seq++) { + const records = Array.from({ length: seq }, (_, index) => todo(`todo_${index + 1}`)); + head = projection(records); + const entry = await pendingEntry(f, seq, { handoff_mode: "hard_lease", todos: records }); + const result = await commitLocalAuthorityShadowEntry(entry); + assert.equal(result.outcome, "delivered"); await settleFiles(f, entry, result); + } + return { f, head }; } - -async function bootstrapRequest(root: string) { - const commit = await request(root); - return { - schema_version: COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA, - runtime_root: root, - goal_id: commit.goal_id, - operation_id: "bootstrap:goal-a:state-1", - source_version: "state:1", - projection: commit.projection, - }; +async function qualify(f: ShadowFixture, head: JsonObject, extra: JsonObject = {}): Promise { + return await qualifyCoordinationRuntimeShadow({ ...await sourceRequest(f, head), + schema_version: schemas.COORDINATION_RUNTIME_SHADOW_QUALIFY_REQUEST_SCHEMA, + minimum_operations: 3, required_event_kinds: ["todo_add"], ...extra }); } -test("runtime shadow bootstrap records a replayable baseline with no receipt", async () => { - const root = await mkdtemp(join(tmpdir(), "loopx-runtime-shadow-bootstrap-")); - const input = await bootstrapRequest(root); - - const applied = await bootstrapCoordinationRuntimeShadow(input); - assert.equal(applied.status, "applied"); - assert.equal(applied.cursor, "1"); - assert.equal(applied.mode_declaration, "legacy_canonical_shadow"); - assert.equal(applied.bootstrap_receipts_empty, true); - assert.equal(applied.decision_read_from_shadow, false); - - const replayed = await bootstrapCoordinationRuntimeShadow(input); - assert.equal(replayed.status, "replayed"); - assert.equal(replayed.provider_revision, applied.provider_revision); - - const store = new FileAuthorityStore( - join(root, "authority-shadow", "file-v0"), - "goal-a", - ); - const receipt = await store.readReceipt(input.operation_id); - assert.equal(receipt.status, "found"); - if (receipt.status === "found") assert.deepEqual(receipt.receipts, []); - const scan = await store.scanCommitted(null, 1); - assert.equal(scan.status, "page"); - if (scan.status === "page") { - assert.equal(scan.transactions[0]?.receipts.length, 0); - assert.equal( - (scan.transactions[0]?.events[0] as Record).source_version, - "state:1", - ); - } - - const next = await request(root, "todo:goal-a:todo_one:v2"); - const committed = await commitCoordinationRuntimeShadow(next); - assert.equal(committed.status, "applied"); - assert.equal(committed.cursor, "2"); +test("bootstrap is exactly replayable, has no mutation receipt and cannot overwrite a different baseline", async (t) => { + const f = await fixture(t); + const request = { ...await sourceRequest(f, f.baseline), schema_version: schemas.COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA, + operation_id: "bootstrap:test:first", source_version: "source:initial" }; + const replay = await bootstrapCoordinationRuntimeShadow(request); + assert.equal(replay.status, "replayed"); assert.equal(replay.bootstrap_receipts_empty, true); + const changed = await sourceRequest(f, projection([todo()])); + const rejected = await bootstrapCoordinationRuntimeShadow({ ...request, ...changed }); + assert.equal(rejected.status, "failed"); + const history = await f.store.scanCommitted(null, 10); assert.equal(history.status, "page"); + if (history.status === "page") { assert.equal(history.transactions.length, 1); assert.deepEqual(history.transactions[0]?.receipts, []); } }); -test("runtime shadow bootstrap fails closed against different initialized content", async () => { - const root = await mkdtemp(join(tmpdir(), "loopx-runtime-shadow-bootstrap-conflict-")); - assert.equal((await commitCoordinationRuntimeShadow(await request(root))).status, "applied"); - - const result = await bootstrapCoordinationRuntimeShadow(await bootstrapRequest(root)); - assert.equal(result.status, "failed"); - assert.equal(result.reason_code, "shadow_bootstrap_identity_mismatch"); - assert.equal(result.primary_writeback_preserved, true); -}); - -test("runtime shadow bootstrap reconciles an applied commit whose response was lost", async () => { - const root = await mkdtemp(join(tmpdir(), "loopx-runtime-shadow-bootstrap-ambiguous-")); - class LostBootstrapResponseStore extends FileAuthorityStore { - override async commitAuthority( - commit: AuthorityStoreCommit, - ): Promise { - const result = await super.commitAuthority(commit); - return result.status === "applied" - ? { - status: "ambiguous", - reason_code: "simulated_response_loss", - reason: "commit response was lost", - } - : result; - } - } - - const result = await bootstrapCoordinationRuntimeShadow( - await bootstrapRequest(root), - { - createStore: (directory, goalId) => - new LostBootstrapResponseStore(directory, goalId), - }, - ); - assert.equal(result.status, "recovered"); - assert.equal(result.cursor, "1"); - assert.equal(result.bootstrap_receipts_empty, true); -}); - -test("runtime shadow rollback quarantines and exactly replays one fenced lineage", async () => { - const root = await mkdtemp(join(tmpdir(), "loopx-runtime-shadow-rollback-")); - const bootstrap = await bootstrapCoordinationRuntimeShadow(await bootstrapRequest(root)); - assert.equal(bootstrap.status, "applied"); - const rollbackRequest = { - schema_version: COORDINATION_RUNTIME_SHADOW_ROLLBACK_REQUEST_SCHEMA, - runtime_root: root, - goal_id: "goal-a", - operation_id: `rollback:goal-a:${String(bootstrap.provider_revision)}`, - expected_provider_revision: bootstrap.provider_revision, - }; - - const applied = await rollbackCoordinationRuntimeShadow(rollbackRequest); - assert.equal(applied.status, "applied"); - assert.equal(applied.active_shadow_removed, true); - assert.equal(applied.archive_retained, true); - assert.equal(applied.decision_read_from_shadow, false); - - const store = new FileAuthorityStore( - join(root, "authority-shadow", "file-v0"), - "goal-a", - ); - assert.equal((await store.loadAuthority()).status, "missing"); - assert.equal( - (await readdir(join(root, "authority-shadow", "file-v0", "rollback"))).length, - 1, - ); - - const replayed = await rollbackCoordinationRuntimeShadow(rollbackRequest); - assert.equal(replayed.status, "replayed"); - assert.equal(replayed.archive_id, applied.archive_id); -}); - -test("runtime shadow rollback fences revision drift and operation reuse", async () => { - const root = await mkdtemp(join(tmpdir(), "loopx-runtime-shadow-rollback-fence-")); - const bootstrapInput = await bootstrapRequest(root); - const first = await bootstrapCoordinationRuntimeShadow(bootstrapInput); - assert.equal(first.status, "applied"); - const rollbackRequest = { - schema_version: COORDINATION_RUNTIME_SHADOW_ROLLBACK_REQUEST_SCHEMA, - runtime_root: root, - goal_id: "goal-a", - operation_id: `rollback:goal-a:${String(first.provider_revision)}`, - expected_provider_revision: first.provider_revision, - }; - const stale = await rollbackCoordinationRuntimeShadow({ - ...rollbackRequest, - expected_provider_revision: "file:stale-revision", - }); - assert.equal(stale.status, "failed"); - assert.equal(stale.reason_code, "provider_revision_mismatch"); - - assert.equal((await rollbackCoordinationRuntimeShadow(rollbackRequest)).status, "applied"); - const second = await bootstrapCoordinationRuntimeShadow({ - ...bootstrapInput, - operation_id: "bootstrap:goal-a:state-2", - source_version: "state:2", - }); - assert.equal(second.status, "applied"); - - const reused = await rollbackCoordinationRuntimeShadow(rollbackRequest); - assert.equal(reused.status, "failed"); - assert.equal(reused.reason_code, "archive_operation_reused_after_rebootstrap"); - assert.equal(reused.primary_writeback_preserved, true); +test("outbox qualification verifies bounded history coverage and never claims sustained parity", async (t) => { + const { f, head } = await qualifiedFixture(t); + const result = await qualify(f, head); + assert.equal(result.status, "qualified"); assert.equal(result.qualified, true); + assert.equal(result.scope, "bounded"); assert.equal(result.sustained_parity_verified, false); + assert.equal(result.sustained_parity_verdict, "not_evaluated"); + assert.equal(result.decision_read_from_shadow, false); + assert.equal((result.evidence as JsonObject).operation_count, 3); + assert.equal((await qualify(f, head, { minimum_operations: 4 })).status, "insufficient_evidence"); + assert.equal((await qualify(f, head, { required_event_kinds: ["task_lease_acquire"] })).status, "insufficient_evidence"); }); -test("runtime shadow rollback reconciles a quarantined lineage after response loss", async () => { - const root = await mkdtemp(join(tmpdir(), "loopx-runtime-shadow-rollback-ambiguous-")); - const bootstrap = await bootstrapCoordinationRuntimeShadow(await bootstrapRequest(root)); - assert.equal(bootstrap.status, "applied"); - const rollbackRequest = { - schema_version: COORDINATION_RUNTIME_SHADOW_ROLLBACK_REQUEST_SCHEMA, - runtime_root: root, - goal_id: "goal-a", - operation_id: `rollback:goal-a:${String(bootstrap.provider_revision)}`, - expected_provider_revision: bootstrap.provider_revision, - }; - class LostRollbackResponseStore extends FileAuthorityStore { - override async archiveAuthorityDocument( - expectedProviderRevision: string, - operationId: string, - ): Promise { - const result = await super.archiveAuthorityDocument( - expectedProviderRevision, - operationId, - ); - return result.status === "applied" - ? { - status: "ambiguous", - reason_code: "simulated_response_loss", - reason: "rollback response was lost", - } - : result; - } - } - - const ambiguous = await rollbackCoordinationRuntimeShadow( - rollbackRequest, - { - createFileStore: (directory, goalId) => - new LostRollbackResponseStore(directory, goalId), - }, - ); - assert.equal(ambiguous.status, "ambiguous"); - assert.equal(ambiguous.reconciliation_required, true); - - const recovered = await rollbackCoordinationRuntimeShadow(rollbackRequest); - assert.equal(recovered.status, "replayed"); - assert.equal(recovered.archive_retained, true); +test("handoff mode and complete Todo fields participate in the common semantic digest", async (t) => { + const { f, head } = await qualifiedFixture(t); + const changed = structuredClone(head); changed.handoff_mode = "soft_claim"; + assert.equal((await qualify(f, changed)).status, "drifted"); + const source = structuredClone(head); + (source.todos as JsonObject[])[0]!.source_section = "User Todo"; + (source.todo_read_model as JsonObject).records_sha256 = canonicalAuthoritySha256(source.todos); + assert.equal((await qualify(f, source)).status, "drifted"); }); -test("runtime shadow commits and exactly replays one legacy mutation", async () => { - const root = await mkdtemp(join(tmpdir(), "loopx-runtime-shadow-")); - const input = await request(root); - - const applied = await commitCoordinationRuntimeShadow(input); - assert.equal(applied.status, "applied"); - assert.equal(applied.cursor, "1"); - assert.equal(applied.primary_writeback_preserved, true); - assert.equal(applied.decision_read_from_shadow, false); - assert.equal((applied.parity as Record).receipt_matches, true); - assert.deepEqual( - (applied.parity as Record).projection_readback, - { - verified: true, - status: "matched_current_head", - projection_matches: true, - provider_revision: applied.provider_revision, - }, - ); - - const replayed = await commitCoordinationRuntimeShadow(input); - assert.equal(replayed.status, "replayed"); - assert.equal(replayed.cursor, "1"); - - const store = new FileAuthorityStore( - join(root, "authority-shadow", "file-v0"), - "goal-a", - ); - const scan = await store.scanCommitted(null, 10); - assert.equal(scan.status, "page"); - if (scan.status === "page") assert.equal(scan.transactions.length, 1); +test("pending entries and malformed cursor block eligibility without destroying evidence", async (t) => { + const { f, head } = await qualifiedFixture(t); + const cursorPath = join(f.root, "authority-shadow", "outbox", "goal-a", "todos", "drain-cursor.json"); + const original = await readFile(cursorPath); + const bad = JSON.parse(original.toString()); bad.last_seq = true; + await writeFile(cursorPath, JSON.stringify(bad)); + assert.equal((await qualify(f, head)).qualified, false); + assert.equal(JSON.parse(await readFile(cursorPath, "utf8")).last_seq, true); + await writeFile(cursorPath, original); + const pending = await pendingEntry(f, 4, { handoff_mode: "hard_lease", todos: head.todos }, { marker: false }); + const result = await qualify(f, head); + assert.equal(result.status, "not_ready"); assert.equal(result.qualified, false); + const proof = await readLocalAuthorityShadow({ schema_version: schemas.LOCAL_AUTHORITY_SHADOW_READ_REQUEST_SCHEMA, + runtime_root: f.root, goal_id: "goal-a", receipt_operation_id: (pending.entry as JsonObject).entry_id, scan_limit: 10000 }); + assert.equal(proof.status, "loaded"); assert.equal((proof.proof as JsonObject).receipt, null); }); -test("runtime shadow rejects operation-id content drift without changing history", async () => { - const root = await mkdtemp(join(tmpdir(), "loopx-runtime-shadow-drift-")); - const input = await request(root); - assert.equal((await commitCoordinationRuntimeShadow(input)).status, "applied"); - - const drifted = structuredClone(input); - (drifted.projection.todos[0] as Record).claimed_by = "agent-b"; - const result = await commitCoordinationRuntimeShadow(drifted); - assert.equal(result.status, "failed"); - assert.equal(result.reason_code, "shadow_operation_identity_mismatch"); - assert.equal(result.primary_writeback_preserved, true); - - const store = new FileAuthorityStore( - join(root, "authority-shadow", "file-v0"), - "goal-a", - ); - const scan = await store.scanCommitted(null, 10); - assert.equal(scan.status, "page"); - if (scan.status === "page") assert.equal(scan.transactions.length, 1); +test("qualification preserves unrecognized residue, symlink partitions and invalid UTF-8 cursors as ineligible", async (t) => { + const { f, head } = await qualifiedFixture(t); + const directory = join(f.root, "authority-shadow", "outbox", "goal-a", "todos"); + const residue = join(directory, ".tmp-unfinished"); + await writeFile(residue, "half a durable write"); + assert.equal((await qualify(f, head)).qualified, false); + assert.equal(await readFile(residue, "utf8"), "half a durable write"); + await unlink(residue); + const cursorPath = join(directory, "drain-cursor.json"); + const original = await readFile(cursorPath); + const cursor = JSON.parse(original.toString()); + cursor.last_provider_revision = "replacement-\ufffd"; + const invalid = Buffer.from(JSON.stringify(cursor)); + const offset = invalid.indexOf(Buffer.from("\ufffd")); + await writeFile(cursorPath, Buffer.concat([invalid.subarray(0, offset), Buffer.from([0xff]), invalid.subarray(offset + 3)])); + assert.equal((await qualify(f, head)).reason_code, "outbox_file_invalid"); + await writeFile(cursorPath, original); + await symlink(directory, join(f.root, "authority-shadow", "outbox", "goal-a", "leases")); + assert.equal((await qualify(f, head)).qualified, false); }); -test("runtime shadow reconciles an applied commit whose response was lost", async () => { - const root = await mkdtemp(join(tmpdir(), "loopx-runtime-shadow-ambiguous-")); - class LostResponseStore extends FileAuthorityStore { - override async commitAuthority( - commit: AuthorityStoreCommit, - ): Promise { - const result = await super.commitAuthority(commit); - return result.status === "applied" - ? { - status: "ambiguous", - reason_code: "simulated_response_loss", - reason: "commit response was lost", - } - : result; - } - } - const result = await commitCoordinationRuntimeShadow( - await request(root), - { - createStore: (directory, goalId) => - new LostResponseStore(directory, goalId), - }, - ); - assert.equal(result.status, "recovered"); - assert.equal(result.cursor, "1"); - assert.equal(result.primary_writeback_preserved, true); +test("qualification requires the active outbox manifest to match its exact capture binding", async (t) => { + const { f, head } = await qualifiedFixture(t); + const path = join(f.root, "authority-shadow", "outbox", "goal-a", "manifest.json"); + const original = await readFile(path); + await unlink(path); + assert.equal((await qualify(f, head)).qualified, false); + assert.equal((await readLocalAuthorityShadow({ schema_version: schemas.LOCAL_AUTHORITY_SHADOW_READ_REQUEST_SCHEMA, + runtime_root: f.root, goal_id: "goal-a", scan_limit: 10000 })).status, "loaded"); + const foreign = JSON.parse(original.toString()); foreign.capture_lineage_id = "foreign-manifest"; + await writeFile(path, JSON.stringify(foreign)); + assert.equal((await qualify(f, head)).qualified, false); + assert.equal(JSON.parse(await readFile(path, "utf8")).capture_lineage_id, "foreign-manifest"); + await writeFile(path, original); + assert.equal((await qualify(f, head)).qualified, true); }); -test("runtime shadow isolates provider failure from primary truth", async () => { - const root = await mkdtemp(join(tmpdir(), "loopx-runtime-shadow-failure-")); - class FailedStore extends FileAuthorityStore { - override async commitAuthority(): Promise { - return { - status: "failed", - reason_code: "simulated_unavailable", - reason: "shadow is offline", - }; - } - } - const result = await commitCoordinationRuntimeShadow( - await request(root), - { - createStore: (directory, goalId) => new FailedStore(directory, goalId), - }, - ); - assert.equal(result.status, "failed"); - assert.equal(result.reason_code, "simulated_unavailable"); - assert.equal(result.primary_writeback_preserved, true); - assert.equal(result.decision_read_from_shadow, false); +test("an observation transaction mixed into the candidate invalidates both qualification and read-candidate", async (t) => { + const { f, head } = await qualifiedFixture(t); + const loaded = await f.store.loadAuthority(); assert.equal(loaded.status, "loaded"); if (loaded.status !== "loaded") return; + await f.store.commitAuthority({ expected_provider_revision: loaded.provider_revision, operation_id: "foreign-mirror", + events: [{ schema_version: "loopx_coordination_runtime_shadow_event_v0" }], + next_projection: loaded.head, receipts: [{ schema_version: "loopx_coordination_runtime_shadow_receipt_v0" }] }); + assert.equal((await qualify(f, head)).qualified, false); + const read = await readCoordinationRuntimeShadowTodoCandidate({ ...await sourceRequest(f, head), + schema_version: schemas.COORDINATION_RUNTIME_SHADOW_TODO_READ_REQUEST_SCHEMA, todo_id: "todo_1" }); + assert.equal(read.read_candidate_qualified, false); }); -test("runtime shadow inspection reports missing, matched, and drifted evidence", async () => { - const root = await mkdtemp(join(tmpdir(), "loopx-runtime-shadow-inspect-")); - const input = await request(root); - const inspection = { - schema_version: COORDINATION_RUNTIME_SHADOW_INSPECT_REQUEST_SCHEMA, - runtime_root: root, - goal_id: input.goal_id, - projection: input.projection, - }; - - const missing = await inspectCoordinationRuntimeShadow(inspection); - assert.equal(missing.status, "missing"); - assert.equal(missing.bootstrap_required, true); - assert.equal(missing.parity_matches, false); - assert.equal(missing.decision_read_from_shadow, false); - - assert.equal((await commitCoordinationRuntimeShadow(input)).status, "applied"); - const matched = await inspectCoordinationRuntimeShadow(inspection); - assert.equal(matched.status, "matched"); - assert.equal(matched.bootstrap_required, false); - assert.equal(matched.parity_matches, true); - assert.equal(matched.decision_read_from_shadow, false); - - const driftedInput = structuredClone(inspection); - (driftedInput.projection.todos[0] as Record).claimed_by = "agent-b"; - const drifted = await inspectCoordinationRuntimeShadow(driftedInput); - assert.equal(drifted.status, "drifted"); - assert.equal(drifted.parity_matches, false); - assert.notEqual( - drifted.expected_projection_sha256, - drifted.observed_projection_sha256, - ); - assert.equal(drifted.decision_read_from_shadow, false); +test("source snapshot drift prevents inspection even when the supplied projection matches", async (t) => { + const f = await fixture(t); + const request = { ...await sourceRequest(f, f.baseline), schema_version: schemas.COORDINATION_RUNTIME_SHADOW_INSPECT_REQUEST_SCHEMA }; + await writeFile(f.statePath, "external edit after source read\n"); + const result = await inspectCoordinationRuntimeShadow(request); + assert.equal(result.status, "failed"); assert.equal(result.reason_code, "source_changed_retry"); + assert.equal((await f.store.loadAuthority() as { cursor: string }).cursor, "1"); }); -test("runtime shadow Todo read candidate requires exact legacy parity", async () => { - const root = await mkdtemp(join(tmpdir(), "loopx-runtime-shadow-todo-read-")); - const input = await request(root); - const readRequest = { - schema_version: COORDINATION_RUNTIME_SHADOW_TODO_READ_REQUEST_SCHEMA, - runtime_root: root, - goal_id: input.goal_id, - todo_id: "todo_one", - projection: input.projection, - }; - - const missing = await readCoordinationRuntimeShadowTodoCandidate(readRequest); - assert.equal(missing.status, "missing"); - assert.equal(missing.read_candidate_qualified, false); - - assert.equal((await commitCoordinationRuntimeShadow(input)).status, "applied"); - const matched = await readCoordinationRuntimeShadowTodoCandidate(readRequest); - assert.equal(matched.status, "matched"); - assert.equal(matched.read_candidate_qualified, true); - assert.equal(matched.decision_read_from_shadow, false); - assert.deepEqual(matched.todo, input.projection.todos[0]); - assert.deepEqual(matched.todo_ids, ["todo_one"]); - - const driftedProjection = structuredClone(input.projection); - (driftedProjection.todos[0] as Record).claimed_by = "agent-b"; - const drifted = await readCoordinationRuntimeShadowTodoCandidate({ - ...readRequest, - projection: driftedProjection, - }); - assert.equal(drifted.status, "drifted"); - assert.equal(drifted.read_candidate_qualified, false); - assert.equal(drifted.parity_matches, false); +test("read-candidate requires the same bounded eligibility before returning a Todo", async (t) => { + const { f, head } = await qualifiedFixture(t); + const request = { ...await sourceRequest(f, head), schema_version: schemas.COORDINATION_RUNTIME_SHADOW_TODO_READ_REQUEST_SCHEMA, todo_id: "todo_1" }; + const result = await readCoordinationRuntimeShadowTodoCandidate(request); + assert.equal(result.status, "matched"); assert.equal(result.read_candidate_qualified, true); + assert.equal(result.scope, "bounded"); assert.equal(result.decision_read_from_shadow, false); + assert.deepEqual(result.todo, todo("todo_1")); }); -test("runtime shadow Todo read candidate fails closed for missing or duplicate Todo ids", async () => { - const root = await mkdtemp(join(tmpdir(), "loopx-runtime-shadow-todo-read-invalid-")); - const input = await request(root); - assert.equal((await commitCoordinationRuntimeShadow(input)).status, "applied"); - - const absent = await readCoordinationRuntimeShadowTodoCandidate({ - schema_version: COORDINATION_RUNTIME_SHADOW_TODO_READ_REQUEST_SCHEMA, - runtime_root: root, - goal_id: input.goal_id, - todo_id: "todo_absent", - projection: input.projection, - }); - assert.equal(absent.status, "todo_missing"); - assert.equal(absent.read_candidate_qualified, false); - - const duplicateProjection = structuredClone(input.projection); - duplicateProjection.todos.push(structuredClone(duplicateProjection.todos[0]!)); - const duplicateRoot = await mkdtemp(join(tmpdir(), "loopx-runtime-shadow-todo-duplicate-")); - assert.equal((await commitCoordinationRuntimeShadow({ - ...input, - runtime_root: duplicateRoot, - operation_id: "todo:goal-a:duplicate:v1", - projection: duplicateProjection, - })).status, "applied"); - const duplicate = await readCoordinationRuntimeShadowTodoCandidate({ - schema_version: COORDINATION_RUNTIME_SHADOW_TODO_READ_REQUEST_SCHEMA, - runtime_root: duplicateRoot, - goal_id: input.goal_id, - todo_id: "todo_one", - projection: duplicateProjection, - }); - assert.equal(duplicate.status, "failed"); - assert.equal(duplicate.reason_code, "shadow_todo_read_unavailable"); - assert.equal(duplicate.read_candidate_qualified, false); +test("legacy mirror writes remain retired and cannot alter a new profile", async (t) => { + const f = await fixture(t); + const before = await f.store.loadAuthority(); + const result = await commitCoordinationRuntimeShadow({ runtime_root: f.root, goal_id: "goal-a" }); + assert.equal(result.reason_code, "legacy_lineage_read_only"); + assert.deepEqual(await f.store.loadAuthority(), before); }); -test("runtime shadow qualification requires sustained operation and event coverage", async () => { - const root = await mkdtemp(join(tmpdir(), "loopx-runtime-shadow-qualify-")); - const bootstrap = await bootstrapRequest(root); - assert.equal((await bootstrapCoordinationRuntimeShadow(bootstrap)).status, "applied"); - - const first = await request(root, "todo:goal-a:todo_one:v2"); - assert.equal((await commitCoordinationRuntimeShadow(first)).status, "applied"); - const second = await request(root, "lease:goal-a:todo_one:v3"); - second.event_kind = "task_lease_acquire"; - second.source_version = "state:3"; - assert.equal((await commitCoordinationRuntimeShadow(second)).status, "applied"); - const third = await request(root, "todo:goal-a:todo_one:v4"); - third.source_version = "state:4"; - assert.equal((await commitCoordinationRuntimeShadow(third)).status, "applied"); - - const input = { - schema_version: COORDINATION_RUNTIME_SHADOW_QUALIFY_REQUEST_SCHEMA, - runtime_root: root, - goal_id: "goal-a", - projection: third.projection, - minimum_operations: 3, - required_event_kinds: ["todo_claim", "task_lease_acquire"], - }; - const qualified = await qualifyCoordinationRuntimeShadow(input); - assert.equal(qualified.status, "qualified"); - assert.equal(qualified.qualified, true); - assert.equal(qualified.parity_matches, true); - assert.equal( - (qualified.evidence as Record).operation_count, - 3, - ); - assert.deepEqual( - (qualified.evidence as Record).observed_event_kinds, - ["task_lease_acquire", "todo_claim"], - ); - assert.equal(qualified.decision_read_from_shadow, false); - - const insufficient = await qualifyCoordinationRuntimeShadow({ - ...input, - minimum_operations: 4, - }); - assert.equal(insufficient.status, "insufficient_evidence"); - assert.equal(insufficient.qualified, false); - - const missingKind = await qualifyCoordinationRuntimeShadow({ - ...input, - required_event_kinds: ["todo_complete"], - }); - assert.equal(missingKind.status, "insufficient_evidence"); - assert.deepEqual( - (missingKind.evidence as Record).missing_required_event_kinds, - ["todo_complete"], - ); - - const driftedProjection = structuredClone(third.projection); - (driftedProjection.todos[0] as Record).status = "done"; - const drifted = await qualifyCoordinationRuntimeShadow({ - ...input, - projection: driftedProjection, - }); - assert.equal(drifted.status, "drifted"); - assert.equal(drifted.qualified, false); +test("rollback can archive invalid cursor and pending entries without reading or editing primary content", async (t) => { + const f = await fixture(t); + await pendingEntry(f, 1, { handoff_mode: "hard_lease", todos: [todo()] }, { marker: false }); + const primary = await readFile(f.statePath); + const loaded = await f.store.loadAuthority(); assert.equal(loaded.status, "loaded"); if (loaded.status !== "loaded") return; + await writeFile(join(f.root, "authority-shadow", "outbox", "goal-a", "todos", "drain-cursor.json"), "invalid cursor"); + const result = await rollbackCoordinationRuntimeShadow({ + schema_version: schemas.COORDINATION_RUNTIME_SHADOW_ROLLBACK_REQUEST_SCHEMA, runtime_root: f.root, goal_id: "goal-a", + projection: {}, source_snapshot: { state_path: f.statePath }, operation_id: "rollback:test", + expected_provider_revision: loaded.provider_revision, expected_bootstrap_operation_id: null }); + assert.equal(result.status, "applied"); assert.deepEqual(await readFile(f.statePath), primary); + assert.equal((await f.store.loadAuthority()).status, "missing"); }); diff --git a/tests/control_plane_ts/file_outbox_qualification.test.ts b/tests/control_plane_ts/file_outbox_qualification.test.ts new file mode 100644 index 0000000000..74152b088a --- /dev/null +++ b/tests/control_plane_ts/file_outbox_qualification.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { canonicalAuthorityBytes } from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import { LOCAL_AUTHORITY_SHADOW_COMMIT_ENTRY_REQUEST_SCHEMA } from "../../loopx/control_plane/coordination/coordination_state_contract.generated.ts"; +import { commitLocalAuthorityShadowEntry } from "../../loopx/control_plane/coordination/local_authority_shadow.ts"; + +test("a drained entry cannot silently bootstrap a missing candidate", async (t) => { + const root = await mkdtemp(join(tmpdir(), "loopx-entry-no-bootstrap-")); + t.after(() => rm(root, { recursive: true, force: true })); + const projection = { handoff_mode: "hard_lease", todos: [] }; + const digest = `sha256:${createHash("sha256").update(canonicalAuthorityBytes(projection)).digest("hex")}`; + const result = await commitLocalAuthorityShadowEntry({ + schema_version: LOCAL_AUTHORITY_SHADOW_COMMIT_ENTRY_REQUEST_SCHEMA, + runtime_root: root, + goal_id: "goal-a", + entry: { + capture_lineage_id: "lineage-a", prepared_sha256: `sha256:${"d".repeat(64)}`, committed_sha256: null, + entry_id: `local-shadow-tx-${"a".repeat(64)}`, + partition: "todos", seq: 1, + writer: { runtime: "python", write_class: "todo_add", operation_id: null }, + source: { kind: "markdown_active_state", previous_bytes_digest: null, + previous_partition_digest: digest, + bytes_digest: `sha256:${"b".repeat(64)}`, lease: null, event_id: null }, + source_root_digest: `sha256:${"c".repeat(64)}`, + prepared_at: "2026-09-06T00:00:00.000Z", committed_at: "2026-09-06T00:00:00.100Z", + resolution: "committed", + }, + partition_projection: projection, partition_digest: digest, + }); + assert.equal(result.outcome, "failed"); + assert.equal(result.reason_code, "bootstrap_required"); +}); diff --git a/tests/control_plane_ts/fixtures/shadow_management_crash_worker.ts b/tests/control_plane_ts/fixtures/shadow_management_crash_worker.ts new file mode 100644 index 0000000000..18e4542fd4 --- /dev/null +++ b/tests/control_plane_ts/fixtures/shadow_management_crash_worker.ts @@ -0,0 +1,37 @@ +/** Child process for real filesystem/crash recovery tests, never an active goal. */ +import fs from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +import { bootstrapManagedShadow, rollbackManagedShadow, shadowManagementStatePath } from "../../../loopx/control_plane/coordination/shadow_management.ts"; +import { withShadowSourceLocks, verifyShadowSourceSnapshot } from "../../../loopx/control_plane/coordination/runtime_shadow.ts"; +const [kind, raw, stopAt] = process.argv.slice(2); +const request = JSON.parse(raw); +async function barrier(phase: string): Promise { + process.stdout.write(`ready:${phase}\n`); + await new Promise(() => { setInterval(() => {}, 1000); }); +} +if (stopAt === "bootstrap_manifest_orphan" || stopAt === "rollback_manifest_orphan") { + const actualOpen = fs.promises.open; + const statePath = shadowManagementStatePath(request.runtime_root, request.goal_id); + // writeImmutable(manifest) has completed its actual write, fsync, rename and + // directory fsync before persistence opens the next state document. Pause + // before that real open; no filesystem effect or result is substituted. + fs.promises.open = async (path, flags, mode) => { + if (String(path).startsWith(`${statePath}.`) && flags === "wx") await barrier(stopAt); + return await actualOpen(path, flags, mode); + }; + syncBuiltinESMExports(); +} +const dependencies = { + withPrimaryLocks: async (operation: () => Promise) => kind === "bootstrap-public" + ? await withShadowSourceLocks(request, operation) : await operation(), + verifySourceSnapshot: async () => { if (kind === "bootstrap-public") await verifyShadowSourceSnapshot(request); }, + afterEffect: async (phase: string) => { + if (phase === stopAt) { + await barrier(phase); + } + }, +}; +const result = kind.startsWith("bootstrap") + ? await bootstrapManagedShadow(request, dependencies) + : await rollbackManagedShadow(request, dependencies); +process.stdout.write(`${JSON.stringify(result)}\n`); diff --git a/tests/control_plane_ts/local_authority_runtime.test.ts b/tests/control_plane_ts/local_authority_runtime.test.ts index f45291e3f4..d9614a9e59 100644 --- a/tests/control_plane_ts/local_authority_runtime.test.ts +++ b/tests/control_plane_ts/local_authority_runtime.test.ts @@ -42,10 +42,10 @@ import { } from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; import { bootstrapCoordinationRuntimeShadow, - commitCoordinationRuntimeShadow, COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA, - COORDINATION_RUNTIME_SHADOW_REQUEST_SCHEMA, } from "../../loopx/control_plane/coordination/runtime_shadow.ts"; +import { projection as fileProjection, sourceRequest, pendingEntry, settleFiles } from "./shadow_file_fixture.ts"; +import { commitLocalAuthorityShadowEntry } from "../../loopx/control_plane/coordination/local_authority_shadow.ts"; import { executeTaskLeaseAcquire } from "../../loopx/control_plane/work_items/task_lease_acquire.ts"; import { TASK_LEASE_LIFECYCLE_REQUEST_SCHEMA_VERSION, @@ -118,35 +118,26 @@ async function claimSeededTodo( } async function qualifiedShadow(root: string) { - const baseline = withTodoReadModel({ - goal_id: "goal-a", - todos: [todoRecord()], - leases: [], - }); + const baseline = fileProjection([todoRecord()], [], "soft_claim"); + const statePath = join(root, "ACTIVE_GOAL_STATE.md"); + await writeFile(statePath, "---\ngoal_id: goal-a\nhandoff_mode: soft_claim\n---\n\n## Agent Todo\n\n"); + const store = new FileAuthorityStore(join(root, "authority-shadow", "file-v0"), "goal-a"); + const f = {root, statePath, baseline, store}; const bootstrapped = await bootstrapCoordinationRuntimeShadow({ + ...await sourceRequest(f, baseline), schema_version: COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA, - runtime_root: root, - goal_id: "goal-a", - operation_id: "bootstrap:goal-a:state-0", - source_version: "state:0", - projection: baseline, - }); - assert.equal(bootstrapped.status, "applied"); - const projection = withTodoReadModel({ - ...baseline, - todos: [todoRecord({ claimed_by: "agent-a" })], - }); - const mirrored = await commitCoordinationRuntimeShadow({ - schema_version: COORDINATION_RUNTIME_SHADOW_REQUEST_SCHEMA, - runtime_root: root, - goal_id: "goal-a", - operation_id: "todo:goal-a:todo_a:claim-1", - event_kind: "todo_claim", - source_version: "state:1", - projection, - }); - assert.equal(mirrored.status, "applied"); - return { projection, providerRevision: String(mirrored.provider_revision) }; + operation_id: "bootstrap:goal-a:state-0", source_version: "state:0", + }); + assert.equal(bootstrapped.status, "applied", JSON.stringify(bootstrapped)); + const entry = await pendingEntry(f, 1, {handoff_mode: "soft_claim", todos: [todoRecord({claimed_by: "agent-a"})]}, + {writeClass: "todo_claim"}); + const mirrored = await commitLocalAuthorityShadowEntry(entry); + assert.equal(mirrored.outcome, "delivered", JSON.stringify(mirrored)); + await settleFiles(f, entry, mirrored); + const loaded = await store.loadAuthority(); + assert.equal(loaded.status, "loaded"); + if (loaded.status !== "loaded") throw new Error("fixture head missing"); + return { projection: loaded.head, providerRevision: loaded.provider_revision }; } function promotionRequest( @@ -181,6 +172,7 @@ async function engageFence(request: ReturnType) { schema_version: LEGACY_COORDINATION_WRITER_FENCE_ENGAGE_REQUEST_SCHEMA, runtime_root: request.runtime_root, goal_id: request.goal_id, + state_path: join(request.runtime_root, "ACTIVE_GOAL_STATE.md"), fence: request.writer_fence, }); assert.equal(result.status, "applied"); @@ -201,6 +193,7 @@ test("legacy write guard flips from allowed to fail-closed after the durable fen schema_version: LEGACY_COORDINATION_WRITER_FENCE_ENGAGE_REQUEST_SCHEMA, runtime_root: request.runtime_root, goal_id: request.goal_id, + state_path: join(request.runtime_root, "ACTIVE_GOAL_STATE.md"), fence: request.writer_fence, }); assert.equal(replayed.status, "replayed"); @@ -208,6 +201,7 @@ test("legacy write guard flips from allowed to fail-closed after the durable fen schema_version: LEGACY_COORDINATION_WRITER_FENCE_ENGAGE_REQUEST_SCHEMA, runtime_root: request.runtime_root, goal_id: request.goal_id, + state_path: join(request.runtime_root, "ACTIVE_GOAL_STATE.md"), fence: { ...request.writer_fence, fence_id: "legacy-writer-fence:other" }, }); assert.equal(conflict.status, "conflict"); @@ -217,17 +211,26 @@ test("legacy write guard flips from allowed to fail-closed after the durable fen assert.equal(blocked.authority_mode, "file_v0"); }); -test("explicit local promotion requires qualified shadow and creates replayable canonical authority", async () => { +test("new file outbox qualification does not implicitly enable canonical promotion", async () => { const root = await mkdtemp(join(tmpdir(), "loopx-local-authority-promote-")); const shadow = await qualifiedShadow(root); const request = promotionRequest(root, shadow.projection, shadow.providerRevision); await engageFence(request); - const applied = await promoteLocalCoordinationAuthority(request); + assert.equal(applied.status, "failed"); + assert.equal(applied.reason_code, "local_authority_shadow_not_qualified"); + const canonical = new FileAuthorityStore(join(root, "authority", "file-v0"), "goal-a", {existingOnly: true}); + assert.equal((await canonical.loadAuthority()).status, "missing"); +}); + +test("already canonical provider mutation preserves full Todo fields and receipt replay", async () => { + const root = await mkdtemp(join(tmpdir(), "loopx-canonical-todo-mutation-")); + const store = new FileAuthorityStore(join(root, "authority", "file-v0"), "goal-a"); + const applied = await store.commitAuthority({ expected_provider_revision: null, operation_id: "canonical-seed", + events: [], next_projection: withTodoReadModel({goal_id: "goal-a", handoff_mode: "soft_claim", + todos: [todoRecord({claimed_by: "agent-a"})], leases: []}), receipts: [] }); assert.equal(applied.status, "applied"); - assert.equal(applied.legacy_writer_fenced, true); - assert.equal(applied.legacy_fallback_used, false); - assert.equal(applied.canonical_authority, "file_v0"); + if (applied.status !== "applied") throw new Error("canonical fixture failed"); const advanced = await mutateLocalCoordinationAuthority({ schema_version: LOCAL_COORDINATION_MUTATION_REQUEST_SCHEMA, @@ -275,9 +278,10 @@ test("explicit local promotion requires qualified shadow and creates replayable assert.equal((unchanged.todo as Record).claimed_by, "agent-a"); assert.equal((unchanged.todo as Record).status, "in_progress"); - const replayed = await promoteLocalCoordinationAuthority(request); - assert.equal(replayed.status, "replayed"); - assert.equal(replayed.provider_revision, applied.provider_revision); + const receipt = await store.readReceipt("todo:goal-a:todo_a:advance-after-promotion"); + assert.equal(receipt.status, "found"); + if (receipt.status !== "found") throw new Error("mutation receipt missing"); + assert.equal(receipt.provider_revision, advanced.provider_revision); const read = await readLocalCoordinationTodo({ schema_version: LOCAL_COORDINATION_TODO_READ_REQUEST_SCHEMA, @@ -328,38 +332,16 @@ test("local promotion fences shadow revision, digest, and writer-fence identity" assert.equal((await canonical.loadAuthority()).status, "missing"); }); -test("promotion and provider list fail closed without exact Todo consumer semantics", async () => { +test("new bootstrap and provider list fail closed without exact Todo consumer semantics", async () => { const root = await mkdtemp(join(tmpdir(), "loopx-local-authority-semantic-fence-")); - const incomplete = { - goal_id: "goal-a", - todos: [{ todo_id: "todo_a", role: "agent", status: "open" }], - leases: [], - }; + const incomplete = { goal_id: "goal-a", todos: [{ todo_id: "todo_a", role: "agent", status: "open" }], leases: [] }; const bootstrapped = await bootstrapCoordinationRuntimeShadow({ schema_version: COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA, - runtime_root: root, - goal_id: "goal-a", - operation_id: "bootstrap:goal-a:incomplete", - source_version: "state:0", - projection: incomplete, + runtime_root: root, goal_id: "goal-a", operation_id: "bootstrap:goal-a:incomplete", + source_version: "state:0", projection: incomplete, }); - assert.equal(bootstrapped.status, "applied"); - const mirrored = await commitCoordinationRuntimeShadow({ - schema_version: COORDINATION_RUNTIME_SHADOW_REQUEST_SCHEMA, - runtime_root: root, - goal_id: "goal-a", - operation_id: "todo:goal-a:incomplete", - event_kind: "todo_update", - source_version: "state:1", - projection: incomplete, - }); - assert.equal(mirrored.status, "applied"); - const request = promotionRequest(root, incomplete, String(mirrored.provider_revision)); - request.required_event_kinds = ["todo_update"]; - await engageFence(request); - const rejected = await promoteLocalCoordinationAuthority(request); - assert.equal(rejected.status, "failed"); - assert.equal(rejected.reason_code, "local_authority_shadow_not_qualified"); + assert.equal(bootstrapped.status, "failed"); + assert.equal((await new FileAuthorityStore(join(root, "authority-shadow", "file-v0"), "goal-a", {existingOnly: true}).loadAuthority()).status, "missing"); const canonical = new FileAuthorityStore(join(root, "authority", "file-v0"), "goal-a"); const committed = await canonical.commitAuthority({ diff --git a/tests/control_plane_ts/local_authority_shadow_outbox.test.ts b/tests/control_plane_ts/local_authority_shadow_outbox.test.ts index b8358971a7..491a8cde5c 100644 --- a/tests/control_plane_ts/local_authority_shadow_outbox.test.ts +++ b/tests/control_plane_ts/local_authority_shadow_outbox.test.ts @@ -1,368 +1,196 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { readFile, writeFile, rename, unlink } from "node:fs/promises"; +import { join } from "node:path"; import test from "node:test"; import { promisify } from "node:util"; - -import { FileAuthorityStore } from "../../loopx/control_plane/coordination/file_authority_store.ts"; -import { - LOCAL_AUTHORITY_SHADOW_COMMIT_ENTRY_RESULT_SCHEMA, - LOCAL_AUTHORITY_SHADOW_PROJECTION_SCHEMA_V1, - LOCAL_AUTHORITY_SHADOW_READ_RESULT_SCHEMA, - LOCAL_AUTHORITY_SHADOW_TRANSACTION_RECEIPT_SCHEMA, - commitLocalAuthorityShadowEntry, - composeLocalAuthorityShadowHead, - localAuthorityShadowHeadDigest, - readLocalAuthorityShadow, -} from "../../loopx/control_plane/coordination/local_authority_shadow.ts"; -import { - LOCAL_AUTHORITY_SHADOW_OUTBOX_COMMIT_SCHEMA, - LOCAL_AUTHORITY_SHADOW_OUTBOX_ENTRY_SCHEMA, - beginLeaseOutboxEntry, - decodeLocalAuthorityShadowBinding, - leaseRecordDigest, - outboxEntryIdentity, - sha256Digest, -} from "../../loopx/control_plane/coordination/local_authority_shadow_outbox.ts"; +import type { JsonObject } from "../../loopx/control_plane/effect_program.ts"; +import { commitLocalAuthorityShadowEntry, readLocalAuthorityShadow } from "../../loopx/control_plane/coordination/local_authority_shadow.ts"; +import { outboxEntryIdentity, beginLeaseOutboxEntry } from "../../loopx/control_plane/coordination/local_authority_shadow_outbox.ts"; +import * as schemas from "../../loopx/control_plane/coordination/coordination_state_contract.generated.ts"; +import { fixture, pendingEntry, settleFiles, todo, sha } from "./shadow_file_fixture.ts"; const execFileAsync = promisify(execFile); -const GOAL = "goal-a"; -const HEX = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - -function entryId(seq: number, sourceRef: string): string { - return outboxEntryIdentity(GOAL, "todos", seq, sourceRef); -} - -function todoEntry(seq: number, digest: string, resolution = "committed") { - return { - entry_id: entryId(seq, `sha256:${HEX}`), - partition: "todos", - seq, - writer: { runtime: "python", write_class: "todo_add", operation_id: null }, - source: { - kind: "markdown_active_state", - previous_bytes_digest: null, - bytes_digest: `sha256:${HEX}`, - lease: null, - event_id: null, - }, - source_root_digest: `sha256:${HEX}`, - prepared_at: "2026-09-03T00:00:00.000Z", - committed_at: "2026-09-03T00:00:00.100Z", - resolution, - }; -} - -function commitRequest(root: string, seq: number, todos: object[], resolution = "committed") { - const projection = { handoff_mode: "hard_lease", todos }; - return { - schema_version: "loopx_coordination_runtime_shadow_commit_entry_request_v0", - runtime_root: root, - goal_id: GOAL, - entry: todoEntry(seq, `sha256:${HEX}`, resolution), - partition_projection: projection, - partition_digest: `sha256:${"b".repeat(64)}`, - }; -} - -function noOpRequest(root: string, seq: number, resolution: "abandoned" | "unproved") { - return { - schema_version: "loopx_coordination_runtime_shadow_commit_entry_request_v0", - runtime_root: root, - goal_id: GOAL, - entry: todoEntry(seq, `sha256:${HEX}`, resolution), - partition_projection: null, - partition_digest: null, - }; -} - -async function tempRoot(t: test.TestContext): Promise { - const root = await mkdtemp(join(tmpdir(), "loopx-shadow-outbox-")); - t.after(() => rm(root, { recursive: true, force: true })); - return root; -} - -test("commit_entry folds one partition into a v1 head and binds the receipt to the entry", async (t) => { - const root = await tempRoot(t); - const todos = [{ todo_id: "todo-a", status: "open" }]; - - const result = await commitLocalAuthorityShadowEntry(commitRequest(root, 1, todos)); - assert.equal(result.schema_version, LOCAL_AUTHORITY_SHADOW_COMMIT_ENTRY_RESULT_SCHEMA); - assert.equal(result.outcome, "delivered"); - assert.equal(result.no_op, false); - assert.equal(result.cursor, "1"); - const store = new FileAuthorityStore(join(root, "authority-shadow", "file-v0"), GOAL); - const loaded = await store.loadAuthority(); - assert.equal(loaded.status, "loaded"); +test("one primary entry commits exactly once after a complete baseline", async (t) => { + const f = await fixture(t); + const request = await pendingEntry(f, 1, { handoff_mode: "hard_lease", todos: [todo()] }); + const result = await commitLocalAuthorityShadowEntry(request); + assert.equal(result.outcome, "delivered"); assert.equal(result.cursor, "2"); + const loaded = await f.store.loadAuthority(); assert.equal(loaded.status, "loaded"); if (loaded.status !== "loaded") return; - assert.equal(loaded.head.schema_version, LOCAL_AUTHORITY_SHADOW_PROJECTION_SCHEMA_V1); - assert.equal(loaded.head.handoff_mode, "hard_lease"); - assert.deepEqual(loaded.head.todos, todos); - assert.deepEqual(loaded.head.leases, []); - assert.deepEqual(loaded.head.partitions, { - todos: { seq: 1, partition_digest: `sha256:${"b".repeat(64)}` }, - leases: null, - }); - assert.equal(result.head_digest, localAuthorityShadowHeadDigest(loaded.head)); - const receipt = await store.readReceipt(entryId(1, `sha256:${HEX}`)); + assert.deepEqual(loaded.head.todos, [todo()]); assert.deepEqual(loaded.head.leases, []); + assert.equal(loaded.head.capture_lineage_id, (request.entry as JsonObject).capture_lineage_id); + const receipt = await f.store.readReceipt(String((request.entry as JsonObject).entry_id)); assert.equal(receipt.status, "found"); - if (receipt.status !== "found") return; - const record = receipt.receipts[0] as Record; - assert.equal(record.schema_version, LOCAL_AUTHORITY_SHADOW_TRANSACTION_RECEIPT_SCHEMA); - assert.equal(record.entry_id, entryId(1, `sha256:${HEX}`)); - assert.equal(record.source_transaction_correlated, true); - assert.equal(record.durable_source_outbox, true); - assert.equal(record.parity_verdict, "not_evaluated"); - assert.equal(record.primary_authority, "legacy_local"); - assert.equal(record.provider_to_local_writes, false); - assert.equal(record.candidate_read_for_decision, false); + if (receipt.status === "found") { + assert.equal(receipt.receipts.length, 1); + assert.equal(receipt.receipts[0]?.prepared_sha256, (request.entry as JsonObject).prepared_sha256); + assert.equal(receipt.receipts[0]?.source_transaction_correlated, true); + assert.equal(receipt.receipts[0]?.parity_verdict, "not_evaluated"); + } + await settleFiles(f, request, result); + const replay = await commitLocalAuthorityShadowEntry(request); + assert.equal(replay.outcome, "replayed"); assert.equal(replay.cursor, "2"); + const history = await f.store.scanCommitted(null, 10); + assert.equal(history.status, "page"); if (history.status === "page") assert.equal(history.transactions.length, 2); }); -test("commit_entry replays only when the existing receipt carries the same partition digest", async (t) => { - const root = await tempRoot(t); - const todos = [{ todo_id: "todo-a", status: "open" }]; - assert.equal((await commitLocalAuthorityShadowEntry(commitRequest(root, 1, todos))).outcome, "delivered"); - - const replay = await commitLocalAuthorityShadowEntry(commitRequest(root, 1, todos)); - assert.equal(replay.outcome, "replayed"); - assert.equal(replay.cursor, "1"); - - const tampered = commitRequest(root, 1, todos); - tampered.partition_digest = `sha256:${"c".repeat(64)}`; - const mismatch = await commitLocalAuthorityShadowEntry(tampered); - assert.equal(mismatch.outcome, "protocol_mismatch"); - assert.equal(mismatch.reason_code, "transaction_receipt_mismatch"); - - const page = await new FileAuthorityStore(join(root, "authority-shadow", "file-v0"), GOAL) - .scanCommitted(null, 10); - assert.equal(page.status, "page"); - if (page.status === "page") assert.equal(page.transactions.length, 1); +test("receipt replay rejects every changed identity field even after pending cleanup", async (t) => { + const f = await fixture(t); + const request = await pendingEntry(f, 1, { handoff_mode: "hard_lease", todos: [todo()] }); + const result = await commitLocalAuthorityShadowEntry(request); await settleFiles(f, request, result); + for (const field of ["prepared_sha256", "committed_sha256", "prepared_at", "committed_at"] ) { + const changed = structuredClone(request); const entry = changed.entry as JsonObject; + entry[field] = field.endsWith("sha256") ? sha("foreign") : "2026-09-06T01:00:00Z"; + const replay = await commitLocalAuthorityShadowEntry(changed); + assert.equal(replay.outcome, "protocol_mismatch", field); + } + const changed = structuredClone(request); ((changed.entry as JsonObject).writer as JsonObject).operation_id = "foreign-operation"; + assert.equal((await commitLocalAuthorityShadowEntry(changed)).outcome, "protocol_mismatch"); }); -test("no-op resolutions keep the sequence auditable without touching compared fields", async (t) => { - const root = await tempRoot(t); - const todos = [{ todo_id: "todo-a", status: "open" }]; - const first = await commitLocalAuthorityShadowEntry(commitRequest(root, 1, todos)); - - const abandoned = await commitLocalAuthorityShadowEntry(noOpRequest(root, 2, "abandoned")); - const unproved = await commitLocalAuthorityShadowEntry(noOpRequest(root, 3, "unproved")); - - assert.equal(abandoned.outcome, "delivered"); - assert.equal(abandoned.no_op, true); - assert.equal(unproved.no_op, true); - assert.equal(unproved.cursor, "3"); - assert.equal(abandoned.head_digest, first.head_digest); - const store = new FileAuthorityStore(join(root, "authority-shadow", "file-v0"), GOAL); - const page = await store.scanCommitted(null, 10); - assert.equal(page.status, "page"); - if (page.status !== "page") return; - assert.deepEqual( - page.transactions.map((transaction) => (transaction.events[0] as Record).kind), - ["source_transaction_delivered", "source_transaction_abandoned", "source_transaction_unproved"], - ); - const loaded = await store.loadAuthority(); - if (loaded.status === "loaded") { - assert.deepEqual((loaded.head.partitions as Record).todos, { - seq: 1, - partition_digest: `sha256:${"b".repeat(64)}`, - }); +test("foreign root, lineage, source, sequence and digest cannot enter history", async (t) => { + const f = await fixture(t); + const request = await pendingEntry(f, 1, { handoff_mode: "hard_lease", todos: [] }); + for (const [field, value, expected] of [ + ["capture_lineage_id", "foreign", "stale_generation"], + ["source_root_digest", sha("foreign"), "source_root_mismatch"], + ["entry_id", `local-shadow-tx-${"f".repeat(64)}`, "entry_identity_mismatch"], + ]) { + const changed = structuredClone(request); (changed.entry as JsonObject)[field!] = value!; + assert.equal((await commitLocalAuthorityShadowEntry(changed)).reason_code, expected); } + const digest = structuredClone(request); digest.partition_digest = sha("different"); + assert.equal((await commitLocalAuthorityShadowEntry(digest)).reason_code, "partition_digest_mismatch"); + const second = await pendingEntry(f, 2, { handoff_mode: "hard_lease", todos: [] }); + assert.equal((await commitLocalAuthorityShadowEntry(second)).reason_code, "partition_sequence_mismatch"); + assert.equal((await f.store.loadAuthority() as { cursor: string }).cursor, "1"); }); -test("commit_entry rejects projection/resolution combinations that would misstate a transaction", async (t) => { - const root = await tempRoot(t); - const withProjection = noOpRequest(root, 1, "abandoned") as Record; - withProjection.partition_projection = { handoff_mode: "hard_lease", todos: [] }; - withProjection.partition_digest = `sha256:${"b".repeat(64)}`; - await assert.rejects(commitLocalAuthorityShadowEntry(withProjection), /must not carry/u); - - const withoutProjection = commitRequest(root, 1, []) as Record; - withoutProjection.partition_projection = null; - withoutProjection.partition_digest = null; - await assert.rejects(commitLocalAuthorityShadowEntry(withoutProjection), /requires partition_projection/u); - - const badId = commitRequest(root, 1, []); - badId.entry.entry_id = "local-shadow:abc"; - await assert.rejects(commitLocalAuthorityShadowEntry(badId), /entry\.entry_id/u); - - const extra = { ...commitRequest(root, 1, []), observation_id: "x" }; - await assert.rejects(commitLocalAuthorityShadowEntry(extra), /unsupported fields/u); +test("first commit verifies the actual pending bytes instead of trusting a supplied hash", async (t) => { + const f = await fixture(t); + const request = await pendingEntry(f, 1, { handoff_mode: "hard_lease", todos: [] }); + const entry = request.entry as JsonObject; + const path = join(f.root, "authority-shadow", "outbox", "goal-a", "todos", `0000000001-${entry.entry_id}.prepared.json`); + await writeFile(path, `${await readFile(path, "utf8")} `); + assert.equal((await commitLocalAuthorityShadowEntry(request)).reason_code, "outbox_prepared_bytes_mismatch"); + assert.equal((await f.store.loadAuthority() as { cursor: string }).cursor, "1"); }); -test("a v0 observation head is accepted as the starting point for partition folds", () => { - const v0 = { - schema_version: "loopx_local_authority_shadow_projection_v0", - goal_id: GOAL, - handoff_mode: "hard_lease", - todos: [{ todo_id: "todo-a", status: "open" }], - leases: [{ todo_id: "todo-a", version: 1 }], - }; - const folded = composeLocalAuthorityShadowHead( - v0, - GOAL, - { partition: "leases", seq: 4 }, - { leases: [] }, - `sha256:${"d".repeat(64)}`, - ); - assert.equal(folded.schema_version, LOCAL_AUTHORITY_SHADOW_PROJECTION_SCHEMA_V1); - assert.equal(folded.handoff_mode, "hard_lease"); - assert.deepEqual(folded.todos, v0.todos); - assert.deepEqual(folded.leases, []); - assert.deepEqual(folded.partitions, { - todos: null, - leases: { seq: 4, partition_digest: `sha256:${"d".repeat(64)}` }, - }); +test("a self-consistent foreign lineage entry cannot commit even with matching bytes and identity hashes", async (t) => { + const f = await fixture(t); + const request = await pendingEntry(f, 1, { handoff_mode: "hard_lease", todos: [todo()] }); + const entry = request.entry as JsonObject; + const directory = join(f.root, "authority-shadow", "outbox", "goal-a", "todos"); + const oldStem = `0000000001-${entry.entry_id}`; + entry.capture_lineage_id = "foreign-complete-lineage"; + entry.entry_id = outboxEntryIdentity("goal-a", "todos", 1, String((entry.source as JsonObject).bytes_digest), + String(entry.capture_lineage_id), String(entry.source_root_digest)); + const newStem = `0000000001-${entry.entry_id}`; + for (const [suffix, digestField] of [["prepared", "prepared_sha256"], ["committed", "committed_sha256"]]) { + const oldPath = join(directory, `${oldStem}.${suffix}.json`); + const value = JSON.parse(await readFile(oldPath, "utf8")); + value.entry_id = entry.entry_id; value.capture_lineage_id = entry.capture_lineage_id; + const raw = JSON.stringify(value); await writeFile(oldPath, raw); + await rename(oldPath, join(directory, `${newStem}.${suffix}.json`)); + entry[digestField!] = sha(raw); + } + const rejected = await commitLocalAuthorityShadowEntry(request); + assert.equal(rejected.outcome, "failed"); + assert.equal(rejected.reason_code, "stale_generation"); + assert.equal((await f.store.loadAuthority() as { cursor: string }).cursor, "1"); }); -test("read returns head, comparison digest, and a bounded scan page", async (t) => { - const root = await tempRoot(t); - const missing = await readLocalAuthorityShadow({ - schema_version: "loopx_coordination_runtime_shadow_outbox_read_v0", - runtime_root: root, - goal_id: GOAL, - scan_after_cursor: null, - scan_limit: 10, - }); - assert.equal(missing.schema_version, LOCAL_AUTHORITY_SHADOW_READ_RESULT_SCHEMA); - assert.equal(missing.status, "missing"); - assert.equal(missing.head, null); - - const todos = [{ todo_id: "todo-a", status: "open" }]; - await commitLocalAuthorityShadowEntry(commitRequest(root, 1, todos)); - await commitLocalAuthorityShadowEntry(noOpRequest(root, 2, "abandoned")); - const view = await readLocalAuthorityShadow({ - schema_version: "loopx_coordination_runtime_shadow_outbox_read_v0", - runtime_root: root, - goal_id: GOAL, - scan_after_cursor: null, - scan_limit: 1, - }); +test("abandoned settlement advances only settled sequence; unproved and implicit seeds hold", async (t) => { + const f = await fixture(t); + const abandoned = await pendingEntry(f, 1, { handoff_mode: "hard_lease", todos: [] }, { resolution: "abandoned", marker: false }); + const result = await commitLocalAuthorityShadowEntry(abandoned); assert.equal(result.outcome, "delivered"); + const view = await readLocalAuthorityShadow({ schema_version: schemas.LOCAL_AUTHORITY_SHADOW_READ_REQUEST_SCHEMA, + runtime_root: f.root, goal_id: "goal-a", receipt_operation_id: (abandoned.entry as JsonObject).entry_id, scan_limit: 10 }); assert.equal(view.status, "loaded"); - assert.equal(view.cursor, "2"); - assert.match(String(view.store_identity), /^file:[0-9a-f]{32}$/u); - assert.equal(view.head_digest, localAuthorityShadowHeadDigest(view.head as Record)); - const scan = view.scan as { transactions: Record[]; next_cursor: string | null; has_more: boolean }; - assert.equal(scan.transactions.length, 1); - assert.equal(scan.has_more, true); - assert.equal(scan.transactions[0].operation_id, entryId(1, `sha256:${HEX}`)); - assert.equal(scan.transactions[0].projection_digest, view.head_digest); - assert.equal("projection" in scan.transactions[0], false); + assert.deepEqual((view.proof as JsonObject).last_sequences, { todos: 1, leases: 0 }); + assert.deepEqual((view.proof as JsonObject).last_applied_sequences, { todos: 0, leases: 0 }); + assert.equal(((view.proof as JsonObject).receipt as JsonObject).operation_id, (abandoned.entry as JsonObject).entry_id); + const unproved = await pendingEntry(f, 2, { handoff_mode: "hard_lease", todos: [] }, { resolution: "unproved", marker: false }); + assert.equal((await commitLocalAuthorityShadowEntry(unproved)).reason_code, "source_transaction_unproved"); }); -test("lease outbox entries are two-phase, durable, and skipped without a binding", async (t) => { - const root = await tempRoot(t); - const leaseDirectory = join(root, "goals", GOAL, "task-leases"); - const other = { goal_id: GOAL, todo_id: "todo-b", version: 1, status: "active" }; - await mkdir(leaseDirectory, { recursive: true }); - await writeFile(join(leaseDirectory, "todo-b.json"), `${JSON.stringify(other, null, 2)}\n`); - const planned = { goal_id: GOAL, todo_id: "todo-a", version: 2, lease_epoch: 1, status: "active", updated_at: "t2" }; - - assert.equal(decodeLocalAuthorityShadowBinding(undefined), null); - assert.equal(decodeLocalAuthorityShadowBinding({ provider: "file_v0" }), null); - assert.deepEqual( - decodeLocalAuthorityShadowBinding({ - schema_version: "loopx_coordination_runtime_shadow_binding_v0", - provider: "file_v0", - }), - { schema_version: "loopx_coordination_runtime_shadow_binding_v0", provider: "file_v0" }, - ); +test("concurrent commit_entry callers produce one exact receipt", async (t) => { + const f = await fixture(t); + const request = await pendingEntry(f, 1, { handoff_mode: "hard_lease", todos: [todo()] }); + const results = await Promise.all([commitLocalAuthorityShadowEntry(request), commitLocalAuthorityShadowEntry(request)]); + assert.deepEqual(results.map((result) => result.outcome).sort(), ["delivered", "replayed"]); +}); - const capture = await beginLeaseOutboxEntry({ - runtime_root: root, - goal_id: GOAL, - lease_directory: leaseDirectory, - write_class: "task_lease_acquire", - operation_id: "op-1", - previous_lease: null, - planned_lease: planned, - }); +test("a lease writer with a missing cursor obtains its next sequence from proved committed history", async (t) => { + const f = await fixture(t); + const lease = { schema_version: "task_lease_v0", goal_id: "goal-a", todo_id: "todo_one", owner: "agent-a", version: 1, + lease_epoch: 1, status: "active", updated_at: "2026-09-06T00:00:00Z" }; + const entry = await pendingEntry(f, 1, { leases: [lease] }, { partition: "leases", writeClass: "task_lease_acquire" }); + const delivered = await commitLocalAuthorityShadowEntry(entry); + assert.equal(delivered.outcome, "delivered"); + await settleFiles(f, entry, delivered); + const directory = join(f.root, "authority-shadow", "outbox", "goal-a", "leases"); + await unlink(join(directory, "drain-cursor.json")); + const capture = await beginLeaseOutboxEntry({ runtime_root: f.root, goal_id: "goal-a", + lease_directory: join(f.root, "goals", "goal-a", "task-leases"), write_class: "task_lease_renew", + operation_id: null, previous_lease: lease, planned_lease: { ...lease, version: 2 } }); assert.equal(capture.failure, null); - assert.equal(capture.seq, 1); - assert.equal(capture.source_bytes_digest, leaseRecordDigest(planned)); - assert.equal(capture.entry_id, outboxEntryIdentity(GOAL, "leases", 1, leaseRecordDigest(planned))); - const directory = join(root, "authority-shadow", "outbox", GOAL, "leases"); - let names = await readdir(directory); - assert.deepEqual(names, [`0000000001-${capture.entry_id}.prepared.json`]); - const prepared = JSON.parse(await readFile(join(directory, names[0]), "utf8")); - assert.equal(prepared.schema_version, LOCAL_AUTHORITY_SHADOW_OUTBOX_ENTRY_SCHEMA); - assert.equal(prepared.partition, "leases"); - assert.equal(prepared.partition_digest, null); - assert.equal(prepared.source_root_digest, sha256Digest(resolve(root))); - assert.deepEqual(prepared.source.lease, { - todo_id: "todo-a", - version: 2, - lease_epoch: 1, - status: "active", - updated_at: "t2", + assert.equal(capture.seq, 2); + await assert.rejects(readFile(join(directory, "drain-cursor.json")), { code: "ENOENT" }); +}); + +for (const [marker, resolution, expected] of [ + [true, "committed", "delivered"], + [true, "abandoned", "failed"], + [true, "committed_proven_by_readback", "failed"], + [false, "committed", "failed"], + [false, "abandoned", "delivered"], + [false, "committed_proven_by_readback", "delivered"], +] as const) { + test(`marker presence ${marker} requires an independently proved ${resolution} resolution`, async (t) => { + const f = await fixture(t); + const request = await pendingEntry(f, 1, { handoff_mode: "hard_lease", todos: [todo()] }, { marker, resolution }); + const result = await commitLocalAuthorityShadowEntry(request); + assert.equal(result.outcome, expected, JSON.stringify(result)); + if (marker && resolution === "committed") { + await settleFiles(f, request, result); + const relabelled = structuredClone(request); + (relabelled.entry as JsonObject).resolution = "abandoned"; + relabelled.partition_projection = null; relabelled.partition_digest = null; + assert.equal((await commitLocalAuthorityShadowEntry(relabelled)).outcome, "protocol_mismatch"); + } }); - assert.deepEqual( - prepared.projection.leases.map((item: { file_stem: string }) => item.file_stem), - ["todo-a", "todo-b"], - ); - assert.deepEqual(prepared.projection.leases[0].record, planned); +} - await capture.commit(); - assert.equal(capture.failure, null); - names = (await readdir(directory)).sort((a, b) => (a < b ? -1 : 1)); - assert.deepEqual(names, [ - `0000000001-${capture.entry_id}.committed.json`, - `0000000001-${capture.entry_id}.prepared.json`, - ]); - const committed = JSON.parse(await readFile(join(directory, names[0]), "utf8")); - assert.equal(committed.schema_version, LOCAL_AUTHORITY_SHADOW_OUTBOX_COMMIT_SCHEMA); - assert.equal(committed.entry_id, capture.entry_id); +test("a missing primary mutation cannot hide behind continuous sequence numbers and a matching final projection", async (t) => { + const f = await fixture(t); + const request = await pendingEntry(f, 1, { handoff_mode: "hard_lease", todos: [todo()] }); + const entry = request.entry as JsonObject; + (entry.source as JsonObject).previous_partition_digest = sha("unrecorded intermediate canonical state"); + const path = join(f.root, "authority-shadow", "outbox", "goal-a", "todos", `0000000001-${entry.entry_id}.prepared.json`); + const prepared = JSON.parse(await readFile(path, "utf8")); + prepared.source.previous_partition_digest = (entry.source as JsonObject).previous_partition_digest; + const raw = JSON.stringify(prepared); await writeFile(path, raw); entry.prepared_sha256 = sha(raw); + const result = await commitLocalAuthorityShadowEntry(request); + assert.equal(result.reason_code, "source_partition_continuity_unproved"); + assert.equal((await f.store.loadAuthority() as { cursor: string }).cursor, "1"); +}); - const second = await beginLeaseOutboxEntry({ - runtime_root: root, - goal_id: GOAL, - lease_directory: leaseDirectory, - write_class: "task_lease_renew", - operation_id: null, - previous_lease: planned, - planned_lease: { ...planned, version: 3, updated_at: "t3" }, - }); - assert.equal(second.seq, 2); - const failing = await beginLeaseOutboxEntry({ - runtime_root: root, - goal_id: GOAL, - lease_directory: leaseDirectory, - write_class: "task_lease_renew", - operation_id: null, - previous_lease: null, - planned_lease: { goal_id: GOAL }, - }); - assert.equal(failing.entry_id, null); - assert.equal(failing.failure?.reason_code, "outbox_prepare_failed"); +test("prose bytes may change only while the canonical previous partition remains proved", async (t) => { + const f = await fixture(t); + await writeFile(f.statePath, `${await readFile(f.statePath, "utf8")}\n## Notes\nProse only.\n`); + const request = await pendingEntry(f, 1, { handoff_mode: "hard_lease", todos: [todo()] }); + assert.equal((await commitLocalAuthorityShadowEntry(request)).outcome, "delivered"); }); -test("entry identity derivation agrees byte-for-byte with the Python outbox module", async () => { - const python = process.env.LOOPX_TEST_PYTHON ?? "python3"; - const script = [ - "from loopx.control_plane.coordination.local_authority_shadow_outbox import entry_identity", - "from loopx.control_plane.coordination.local_authority_shadow_projection import sha256_digest", - `print(entry_identity(goal_id='${GOAL}', partition='leases', seq=7, source_ref='sha256:${HEX}'))`, - "print(sha256_digest({'handoff_mode': 'hard_lease', 'todos': [{'todo_id': 'todo-a', 'status': 'open'}], 'leases': []}))", - ].join("\n"); - let stdout: string; - try { - ({ stdout } = await execFileAsync(python, ["-c", script], { - cwd: join(import.meta.dirname, "..", ".."), - env: { ...process.env, PYTHONPATH: join(import.meta.dirname, "..", "..") }, - })); - } catch { - return; // Python without the loopx package: the Python suite pins the same fixture. - } - const [pythonEntryId, pythonHeadDigest] = stdout.trim().split("\n"); - assert.equal(pythonEntryId, outboxEntryIdentity(GOAL, "leases", 7, `sha256:${HEX}`)); - assert.equal( - pythonHeadDigest, - localAuthorityShadowHeadDigest({ - handoff_mode: "hard_lease", - todos: [{ todo_id: "todo-a", status: "open" }], - leases: [], - }), - ); +test("Python and TypeScript entry identity include the same root and lineage", async () => { + const source = sha("source"); const root = sha("root"); + const script = "from loopx.control_plane.coordination.local_authority_shadow_outbox import entry_identity\nprint(entry_identity(goal_id='goal-a',partition='leases',seq=7,source_ref='" + source + "',capture_lineage_id='lineage-a',source_root_digest='" + root + "'))"; + const result = await execFileAsync(process.env.LOOPX_TEST_PYTHON ?? "python3", ["-c", script], + { cwd: join(import.meta.dirname, "..", "..") }); + assert.equal(result.stdout.trim(), outboxEntryIdentity("goal-a", "leases", 7, source, "lineage-a", root)); + assert.notEqual(outboxEntryIdentity("goal-a", "leases", 7, source, "lineage-a", root), + outboxEntryIdentity("goal-a", "leases", 7, source, "lineage-b", root)); }); diff --git a/tests/control_plane_ts/shadow_cursor_safety.test.ts b/tests/control_plane_ts/shadow_cursor_safety.test.ts new file mode 100644 index 0000000000..74ec441cce --- /dev/null +++ b/tests/control_plane_ts/shadow_cursor_safety.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import * as outbox from "../../loopx/control_plane/coordination/local_authority_shadow_outbox.ts"; + +const cursor = { + schema_version: outbox.LOCAL_AUTHORITY_SHADOW_DRAIN_CURSOR_SCHEMA, + partition: "todos", + last_seq: 1, + last_entry_id: `local-shadow-tx-${"a".repeat(64)}`, + last_partition_digest: `sha256:${"b".repeat(64)}`, + last_cursor: "opaque-cursor", + last_provider_revision: "opaque-revision", + updated_at: "2026-09-05T01:02:03.123456+00:00", +}; + +test("cursor accepts opaque receipt coordinates and JSON integer semantics", () => { + assert.deepEqual(outbox.decodeOutboxCursor(cursor, "todos"), cursor); +}); + +for (const last_seq of [true, false, "1", null, -1, 0, 1.5, 10_000_000_000, NaN, Infinity]) { + test(`cursor rejects invalid sequence ${String(last_seq)}`, () => { + assert.throws(() => outbox.decodeOutboxCursor({ ...cursor, last_seq }, "todos"), /cursor/u); + }); +} + +for (const patch of [ + { partition: "leases" }, { last_entry_id: "local-shadow-tx-short" }, + { last_partition_digest: "sha256:short" }, { last_cursor: null }, + { last_provider_revision: "" }, { updated_at: "yesterday" }, + { updated_at: "2026-02-30T00:00:00Z" }, { unrecognized: true }, +]) { + test(`cursor rejects invalid binding ${JSON.stringify(patch)}`, () => { + assert.throws(() => outbox.decodeOutboxCursor({ ...cursor, ...patch }, "todos"), /cursor/u); + }); +} + +test("same source and sequence have distinct identity in another lineage or root", () => { + const source = `sha256:${"b".repeat(64)}`; + const root = `sha256:${"c".repeat(64)}`; + const first = outbox.outboxEntryIdentity("goal", "todos", 1, source, "epoch-a", root); + assert.notEqual(first, outbox.outboxEntryIdentity("goal", "todos", 1, source, "epoch-b", root)); + assert.notEqual(first, outbox.outboxEntryIdentity("goal", "todos", 1, source, "epoch-a", source)); +}); + +test("cursor reader distinguishes missing, invalid UTF8, and unavailable bytes", async (t) => { + const { mkdtemp, writeFile, rm, mkdir } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const directory = await mkdtemp(join(tmpdir(), "loopx-cursor-bytes-")); + t.after(() => rm(directory, { recursive: true, force: true })); + assert.equal(await outbox.readOutboxCursor(directory, "todos"), null); + const path = join(directory, "drain-cursor.json"); + const bytes = Buffer.from(JSON.stringify({ ...cursor, last_cursor: "opaque-INVALID" })); + bytes[bytes.indexOf("INVALID")] = 0xff; + await writeFile(path, bytes); + await assert.rejects(outbox.readOutboxCursor(directory, "todos"), { code: "outbox_file_invalid" }); + assert.deepEqual(await (await import("node:fs/promises")).readFile(path), bytes); + await rm(path); + await mkdir(path); + await assert.rejects(outbox.readOutboxCursor(directory, "todos"), { code: "outbox_file_unavailable" }); +}); diff --git a/tests/control_plane_ts/shadow_file_fixture.ts b/tests/control_plane_ts/shadow_file_fixture.ts new file mode 100644 index 0000000000..97a3be5a9f --- /dev/null +++ b/tests/control_plane_ts/shadow_file_fixture.ts @@ -0,0 +1,105 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { TestContext } from "node:test"; +import type { JsonObject } from "../../loopx/control_plane/effect_program.ts"; +import { canonicalAuthorityBytes, canonicalAuthoritySha256 } from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import { TODO_CANONICAL_READ_RECORD_FIELDS } from "../../loopx/control_plane/coordination/coordination_projection.ts"; +import * as schemas from "../../loopx/control_plane/coordination/coordination_state_contract.generated.ts"; +import { FileAuthorityStore } from "../../loopx/control_plane/coordination/file_authority_store.ts"; +import { outboxEntryIdentity } from "../../loopx/control_plane/coordination/local_authority_shadow_outbox.ts"; +import { bootstrapCoordinationRuntimeShadow } from "../../loopx/control_plane/coordination/runtime_shadow.ts"; +import { requireShadowCaptureBinding } from "../../loopx/control_plane/coordination/shadow_management.ts"; + +export function sha(value: Uint8Array | string): string { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} +export function todo(id = "todo_one", status = "open"): JsonObject { + return { schema_version: "todo_item_v0", todo_id: id, role: "agent", status, done: status === "done", + text: "Qualify file shadow", archive_state: "active", source_section: "Agent Todo" }; +} +export function projection(todos: JsonObject[] = [], leases: JsonObject[] = [], handoff = "hard_lease"): JsonObject { + return { schema_version: schemas.LOCAL_AUTHORITY_SHADOW_TRANSACTION_PROJECTION_SCHEMA, goal_id: "goal-a", + source_authority: "legacy_markdown_and_task_lease", handoff_mode: handoff, todos, leases, + todo_read_model: { schema_version: "loopx_todo_canonical_read_record_v0", todo_count: todos.length, + records_sha256: canonicalAuthoritySha256(todos), contract_fields: [...TODO_CANONICAL_READ_RECORD_FIELDS] }, + partitions: { todos: null, leases: null } }; +} +export interface ShadowFixture { root: string; statePath: string; store: FileAuthorityStore; baseline: JsonObject } +export async function sourceRequest(f: ShadowFixture, head: JsonObject): Promise { + const directory = join(f.root, "goals", "goal-a", "task-leases"); + let names: string[]; + try { names = (await readdir(directory)).filter((name) => /^[A-Za-z0-9_.-]+\.json$/.test(name)).sort(); } catch { names = []; } + const inventory = []; + for (const name of names) inventory.push({ name, bytes_sha256: sha(await readFile(join(directory, name))) }); + return { runtime_root: f.root, goal_id: "goal-a", projection: head, + source_snapshot: { state_path: f.statePath, registered_runtime_root: f.root, registered_state_path: f.statePath, + state_bytes_sha256: sha(await readFile(f.statePath)), + lease_inventory: inventory, projection_sha256: canonicalAuthoritySha256(head), evidence_files: [] } }; +} +export async function fixture(t: TestContext): Promise { + const root = await mkdtemp(join(tmpdir(), "loopx-file-outbox-test-")); + t.after(() => rm(root, { recursive: true, force: true })); + const statePath = join(root, "ACTIVE_GOAL_STATE.md"); + await writeFile(statePath, "---\ngoal_id: goal-a\nhandoff_mode: hard_lease\n---\n\n## Agent Todo\n\n"); + const f = { root, statePath, store: new FileAuthorityStore(join(root, "authority-shadow", "file-v0"), "goal-a"), baseline: projection() }; + const boot = await bootstrapCoordinationRuntimeShadow({ ...await sourceRequest(f, f.baseline), + schema_version: schemas.COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA, + operation_id: "bootstrap:test:first", source_version: "source:initial" }); + if (boot.status !== "applied") throw new Error(`fixture bootstrap failed: ${JSON.stringify(boot)}`); + return f; +} +export async function pendingEntry(f: ShadowFixture, seq: number, part: JsonObject, options: { + partition?: "todos" | "leases"; resolution?: string; writeClass?: string; marker?: boolean; +} = {}): Promise { + const partition = options.partition ?? "todos"; + const binding = await requireShadowCaptureBinding(f.root, "goal-a"); + const previous = await readFile(f.statePath); + const prior = await f.store.loadAuthority(); + if (prior.status !== "loaded") throw new Error("fixture must have a baseline"); + const previousPartition = partition === "todos" ? { handoff_mode: prior.head.handoff_mode, todos: prior.head.todos } : { leases: prior.head.leases }; + const sourceBytes = Buffer.from(`primary transaction ${partition}:${seq}\n`); + if (partition === "todos" && options.resolution !== "abandoned") await writeFile(f.statePath, sourceBytes); + const source = { kind: partition === "todos" ? "markdown_active_state" : "task_lease_record", + previous_partition_digest: `sha256:${canonicalAuthoritySha256(previousPartition)}`, + previous_bytes_digest: sha(previous), bytes_digest: sha(sourceBytes), lease: null, event_id: null }; + const entryId = outboxEntryIdentity("goal-a", partition, seq, source.bytes_digest, binding.capture_lineage_id, binding.source_root_digest); + const preparedAt = `2026-09-06T00:00:${String(seq).padStart(2, "0")}.000Z`; + const committedAt = preparedAt; + const directory = join(f.root, "authority-shadow", "outbox", "goal-a", partition); + await mkdir(directory, { recursive: true }); + const stem = `${String(seq).padStart(10, "0")}-${entryId}`; + const writer = { runtime: partition === "todos" ? "python" : "typescript", write_class: options.writeClass ?? "todo_add", operation_id: null }; + const recordedProjection = partition === "leases" ? { leases: (part.leases as JsonObject[]).map((record) => ({ file_stem: record.todo_id, record })) } : part; + const prepared = { schema_version: schemas.LOCAL_AUTHORITY_SHADOW_OUTBOX_ENTRY_SCHEMA, goal_id: "goal-a", + capture_lineage_id: binding.capture_lineage_id, entry_id: entryId, partition, seq, writer, source, + source_root_digest: binding.source_root_digest, projection: recordedProjection, + partition_digest: partition === "todos" ? `sha256:${canonicalAuthoritySha256(part)}` : null, prepared_at: preparedAt }; + const preparedBytes = `${JSON.stringify(prepared, null, 2)}\n`; + await writeFile(join(directory, `${stem}.prepared.json`), preparedBytes); + const marker = options.marker ?? true; + const markerBytes = `${JSON.stringify({ schema_version: schemas.LOCAL_AUTHORITY_SHADOW_OUTBOX_COMMIT_SCHEMA, + capture_lineage_id: binding.capture_lineage_id, entry_id: entryId, committed_at: committedAt }, null, 2)}\n`; + if (marker) await writeFile(join(directory, `${stem}.committed.json`), markerBytes); + const resolution = options.resolution ?? "committed"; + const noOp = resolution === "abandoned" || resolution === "unproved"; + return { schema_version: schemas.LOCAL_AUTHORITY_SHADOW_COMMIT_ENTRY_REQUEST_SCHEMA, runtime_root: f.root, goal_id: "goal-a", + entry: { capture_lineage_id: binding.capture_lineage_id, entry_id: entryId, partition, seq, writer, source, + source_root_digest: binding.source_root_digest, prepared_at: preparedAt, committed_at: marker ? committedAt : null, + prepared_sha256: sha(preparedBytes), committed_sha256: marker ? sha(markerBytes) : null, resolution }, + partition_projection: noOp ? null : part, partition_digest: noOp ? null : `sha256:${canonicalAuthoritySha256(part)}` }; +} +export async function settleFiles(f: ShadowFixture, request: JsonObject, result: JsonObject, previousDigest: string | null = null): Promise { + const entry = request.entry as JsonObject; + const directory = join(f.root, "authority-shadow", "outbox", "goal-a", String(entry.partition)); + const stem = `${String(entry.seq).padStart(10, "0")}-${entry.entry_id}`; + await writeFile(join(directory, "drain-cursor.json"), JSON.stringify({ + schema_version: schemas.LOCAL_AUTHORITY_SHADOW_DRAIN_CURSOR_SCHEMA, partition: entry.partition, + last_seq: entry.seq, last_entry_id: entry.entry_id, last_partition_digest: request.partition_digest ?? previousDigest, + last_cursor: result.cursor, last_provider_revision: result.provider_revision, + updated_at: "2026-09-06T00:00:00Z", + })); + await rm(join(directory, `${stem}.prepared.json`)); + await rm(join(directory, `${stem}.committed.json`), { force: true }); +} diff --git a/tests/control_plane_ts/shadow_management.test.ts b/tests/control_plane_ts/shadow_management.test.ts new file mode 100644 index 0000000000..f3b3d821bc --- /dev/null +++ b/tests/control_plane_ts/shadow_management.test.ts @@ -0,0 +1,319 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, readdir, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { FileAuthorityStore } from "../../loopx/control_plane/coordination/file_authority_store.ts"; +import { + bootstrapManagedShadow, rollbackManagedShadow, readShadowManagementState, + requireShadowPrimaryWriteAllowed, shadowMaintenanceLockPath, + ShadowManagementError, readShadowBootstrapSourcePath, +} from "../../loopx/control_plane/coordination/shadow_management.ts"; + +const primary = { + withPrimaryLocks: async (fn: () => Promise) => await fn(), + verifySourceSnapshot: async () => {}, +}; + +function bootstrap(root: string, operationId = "bootstrap:initial") { + return { + runtime_root: root, goal_id: "goal-a", operation_id: operationId, + source_version: "state:1", source_snapshot: { state_path: join(root, "state.md") }, + projection: { schema_version: "loopx_coordination_shadow_projection_v0", goal_id: "goal-a", todos: [], leases: [] }, + }; +} + +test("existing-only identity reads do not materialize a missing store", async () => { + const root = await mkdtemp(join(tmpdir(), "loopx-management-existing-")); + const store = new FileAuthorityStore(join(root, "missing"), "goal-a", { existingOnly: true }); + assert.equal((await store.storeIdentity()).status, "unavailable"); + assert.equal((await store.loadAuthority()).status, "missing"); + assert.deepEqual(await readdir(root), []); +}); + +async function killAt(kind: "bootstrap" | "rollback", request: object, phase: string): Promise { + const worker = fileURLToPath(new URL("./fixtures/shadow_management_crash_worker.ts", import.meta.url)); + const child = spawn(process.execPath, ["--no-warnings", "--experimental-strip-types", worker, kind, JSON.stringify(request), phase], { stdio: ["ignore", "pipe", "pipe"] }); + let output = ""; + let stderr = ""; + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { child.kill("SIGKILL"); reject(new Error(`crash barrier timed out: ${output} ${stderr}`)); }, 10000); + child.stderr.on("data", (data) => { stderr += String(data); }); + child.stdout.on("data", (data) => { + output += String(data); + if (output.includes(`ready:${phase}\n`)) child.kill("SIGKILL"); + }); + child.on("error", (error) => { clearTimeout(timer); reject(error); }); + child.on("exit", (_code, signal) => { + clearTimeout(timer); + if (signal !== "SIGKILL" || !output.includes(`ready:${phase}\n`)) reject(new Error(`worker exited before ${phase}: ${output} ${stderr}`)); + else resolve(); + }); + }); +} + +test("bootstrap retries an orphan manifest after real SIGKILL without replacing its lineage", async () => { + const root = await mkdtemp(join(tmpdir(), "loopx-management-bootstrap-orphan-")); + const request = bootstrap(root); + await killAt("bootstrap", request, "bootstrap_manifest_orphan"); + assert.equal(await readShadowManagementState(root, "goal-a"), null); + const directory = join(shadowMaintenanceLockPath(root, "goal-a"), "..", "operations"); + const [operation] = await readdir(directory); + const path = join(directory, operation, "manifest.json"); + const bytes = await readFile(path); + const manifest = JSON.parse(bytes.toString("utf8")); + const store = new FileAuthorityStore(join(root, "authority-shadow", "file-v0"), "goal-a", { existingOnly: true }); + assert.equal((await store.storeIdentity()).status, "unavailable"); + assert.equal((await store.loadAuthority()).status, "missing"); + const retried = await bootstrapManagedShadow(request, primary); + assert.equal(retried.status, "applied", JSON.stringify(retried)); + assert.equal(retried.capture_lineage_id, manifest.capture_lineage_id); + assert.deepEqual(await readFile(path), bytes); + const replayed = await bootstrapManagedShadow(request, primary); + assert.equal(replayed.status, "replayed"); + assert.equal(replayed.capture_lineage_id, manifest.capture_lineage_id); + const page = await store.scanCommitted(null, 10); + assert.equal(page.status, "page"); + if (page.status === "page") assert.equal(page.transactions.length, 1); +}); + +test("rollback retries an orphan manifest after real SIGKILL and archives the exact target", async () => { + const root = await mkdtemp(join(tmpdir(), "loopx-management-rollback-orphan-")); + const seed = await bootstrapManagedShadow(bootstrap(root), primary); + assert.equal(seed.status, "applied"); + const store = new FileAuthorityStore(join(root, "authority-shadow", "file-v0"), "goal-a"); + const other = new FileAuthorityStore(store.directory, "goal-b"); + await other.commitAuthority({ expected_provider_revision: null, operation_id: "other-goal", events: [], next_projection: { goal_id: "goal-b" }, receipts: [] }); + const candidateBytes = await readFile(store.path); + const identityBytes = await readFile(store.identityPath); + const otherBytes = await readFile(other.path); + const pending = join(root, "authority-shadow", "outbox", "goal-a", "todos"); + await mkdir(pending, { recursive: true }); + await writeFile(join(pending, "entry.prepared.json"), "pending source before intent"); + await writeFile(join(pending, "drain-cursor.json"), "{malformed retained"); + const request = { runtime_root: root, goal_id: "goal-a", operation_id: "rollback:orphan", expected_provider_revision: seed.provider_revision }; + await killAt("rollback", request, "rollback_manifest_orphan"); + assert.equal((await readShadowManagementState(root, "goal-a"))?.status, "active"); + assert.deepEqual(await readFile(store.path), candidateBytes); + assert.equal(await readFile(join(pending, "entry.prepared.json"), "utf8"), "pending source before intent"); + const directory = join(shadowMaintenanceLockPath(root, "goal-a"), "..", "operations"); + const manifests = await Promise.all((await readdir(directory)).map(async (operation) => { + const path = join(directory, operation, "manifest.json"); + const bytes = await readFile(path); + return { path, bytes, value: JSON.parse(bytes.toString("utf8")) }; + })); + const orphan = manifests.find((item) => item.value.kind === "rollback"); + assert.ok(orphan); + const retried = await rollbackManagedShadow(request, primary); + assert.equal(retried.status, "applied", JSON.stringify(retried)); + assert.deepEqual(await readFile(orphan.path), orphan.bytes); + assert.deepEqual(await readFile(String(retried.candidate_archive_path)), candidateBytes); + assert.equal(await readFile(join(String(retried.outbox_archive_path), "todos", "entry.prepared.json"), "utf8"), "pending source before intent"); + assert.equal(await readFile(join(String(retried.outbox_archive_path), "todos", "drain-cursor.json"), "utf8"), "{malformed retained"); + assert.deepEqual(await readFile(store.identityPath), identityBytes); + assert.deepEqual(await readFile(other.path), otherBytes); + assert.equal((await rollbackManagedShadow(request, primary)).status, "replayed"); + assert.equal(await requireShadowPrimaryWriteAllowed(root, "goal-a"), null); +}); + +for (const phase of ["bootstrap_prepared", "bootstrap_candidate_committed", "bootstrap_outbox_ready", "bootstrap_complete"]) { + test(`bootstrap recovers a real SIGKILL at ${phase}`, async () => { + const root = await mkdtemp(join(tmpdir(), "loopx-management-bootstrap-kill-")); + const request = bootstrap(root); + await killAt("bootstrap", request, phase); + const before = await readShadowManagementState(root, "goal-a"); + if (phase !== "bootstrap_complete") await assert.rejects(requireShadowPrimaryWriteAllowed(root, "goal-a"), { code: "shadow_management_in_progress" }); + const recovered = await bootstrapManagedShadow(request, primary); + assert.equal(recovered.status, phase === "bootstrap_complete" ? "replayed" : "recovered"); + assert.equal(recovered.cursor, "1"); + assert.equal((await readShadowManagementState(root, "goal-a"))?.binding?.capture_lineage_id, recovered.capture_lineage_id); + assert.equal(before?.operation.operation_id, request.operation_id); + const store = new FileAuthorityStore(join(root, "authority-shadow", "file-v0"), "goal-a", { existingOnly: true }); + const page = await store.scanCommitted(null, 10); + assert.equal(page.status, "page"); + if (page.status === "page") assert.equal(page.transactions.length, 1); + }); +} + +for (const phase of ["rollback_prepared", "rollback_candidate_renamed", "rollback_candidate_archived", "rollback_outbox_renamed", "rollback_outbox_archived", "rollback_complete"]) { + test(`rollback preserves all bytes after real SIGKILL at ${phase}`, async () => { + const root = await mkdtemp(join(tmpdir(), "loopx-management-rollback-kill-")); + const seed = await bootstrapManagedShadow(bootstrap(root), primary); + assert.equal(seed.status, "applied"); + const store = new FileAuthorityStore(join(root, "authority-shadow", "file-v0"), "goal-a"); + const other = new FileAuthorityStore(store.directory, "goal-b"); + await other.commitAuthority({ expected_provider_revision: null, operation_id: "other-goal", events: [], next_projection: { goal_id: "goal-b" }, receipts: [] }); + const candidateBytes = await readFile(store.path); + const otherBytes = await readFile(other.path); + const identityBytes = await readFile(store.identityPath); + const pending = join(root, "authority-shadow", "outbox", "goal-a", "todos"); + await mkdir(pending, { recursive: true }); + await writeFile(join(pending, "entry.prepared.json"), "exact pending source"); + await writeFile(join(pending, "drain-cursor.json"), "{malformed retained"); + const request = { runtime_root: root, goal_id: "goal-a", operation_id: "rollback:kill", expected_provider_revision: seed.provider_revision }; + await killAt("rollback", request, phase); + if (phase !== "rollback_complete") await assert.rejects(requireShadowPrimaryWriteAllowed(root, "goal-a"), { code: "shadow_management_in_progress" }); + const recovered = await rollbackManagedShadow(request, primary); + assert.equal(recovered.status, phase === "rollback_complete" ? "replayed" : "recovered", JSON.stringify(recovered)); + assert.deepEqual(await readFile(String(recovered.candidate_archive_path)), candidateBytes); + assert.equal(await readFile(join(String(recovered.outbox_archive_path), "todos", "entry.prepared.json"), "utf8"), "exact pending source"); + assert.equal(await readFile(join(String(recovered.outbox_archive_path), "todos", "drain-cursor.json"), "utf8"), "{malformed retained"); + assert.deepEqual(await readFile(other.path), otherBytes); + assert.deepEqual(await readFile(store.identityPath), identityBytes); + assert.equal(await requireShadowPrimaryWriteAllowed(root, "goal-a"), null); + }); +} + +for (const phase of ["bootstrap_prepared", "bootstrap_candidate_committed", "bootstrap_outbox_ready"]) { + test(`pending bootstrap can be explicitly aborted at ${phase}`, async () => { + const root = await mkdtemp(join(tmpdir(), "loopx-management-abort-")); + const request = bootstrap(root); + await killAt("bootstrap", request, phase); + const changed = await bootstrapManagedShadow(request, { ...primary, verifySourceSnapshot: async () => { throw new ShadowManagementError("source_changed_retry"); } }); + assert.equal(changed.reason_code, "source_changed_retry"); + const store = new FileAuthorityStore(join(root, "authority-shadow", "file-v0"), "goal-a", { existingOnly: true }); + const loaded = await store.loadAuthority(); + const abort = { runtime_root: root, goal_id: "goal-a", operation_id: "rollback:abort", expected_provider_revision: null, expected_bootstrap_operation_id: request.operation_id }; + const stopped = await rollbackManagedShadow(abort, primary); + assert.equal(stopped.status, "applied", JSON.stringify(stopped)); + assert.equal(await requireShadowPrimaryWriteAllowed(root, "goal-a"), null); + const delayed = await bootstrapManagedShadow(request, primary); + assert.equal(delayed.reason_code, "bootstrap_aborted"); + assert.equal((await readShadowManagementState(root, "goal-a"))?.status, "inactive"); + const again = await bootstrapManagedShadow(bootstrap(root, "bootstrap:after-abort"), primary); + assert.equal(again.status, "applied"); + }); +} + +test("rollback archives pending bytes and rebootstrap cannot reuse the old lineage", async () => { + const root = await mkdtemp(join(tmpdir(), "loopx-management-rollback-")); + const applied = await bootstrapManagedShadow(bootstrap(root), primary); + assert.equal(applied.status, "applied"); + const binding = await requireShadowPrimaryWriteAllowed(root, "goal-a"); + assert.ok(binding?.capture_lineage_id); + const store = new FileAuthorityStore(join(root, "authority-shadow", "file-v0"), "goal-a"); + const identity = await readFile(store.identityPath, "utf8"); + const bytes = await readFile(store.path); + const outbox = join(root, "authority-shadow", "outbox", "goal-a", "todos"); + await mkdir(outbox, { recursive: true }); + await writeFile(join(outbox, "pending.prepared.json"), "retained pending bytes"); + await writeFile(join(outbox, "drain-cursor.json"), "{broken cursor"); + const input = { runtime_root: root, goal_id: "goal-a", operation_id: "rollback:one", expected_provider_revision: applied.provider_revision, expected_bootstrap_operation_id: null }; + const result = await rollbackManagedShadow(input, primary); + assert.equal(result.status, "applied"); + assert.equal((await readShadowManagementState(root, "goal-a"))?.status, "inactive"); + assert.equal(await requireShadowPrimaryWriteAllowed(root, "goal-a"), null); + assert.equal(await readFile(store.identityPath, "utf8"), identity); + assert.deepEqual(await readFile(String(result.candidate_archive_path)), bytes); + assert.equal(await readFile(join(String(result.outbox_archive_path), "todos", "drain-cursor.json"), "utf8"), "{broken cursor"); + assert.equal((await rollbackManagedShadow(input, primary)).status, "replayed"); + assert.equal((await rollbackManagedShadow({ ...input, projection: { changed: true }, source_snapshot: { changed: true } }, primary)).status, "replayed"); + const next = await bootstrapManagedShadow(bootstrap(root, "bootstrap:after-rollback"), primary); + assert.equal(next.status, "applied"); + assert.notEqual(next.capture_lineage_id, applied.capture_lineage_id); + assert.notEqual(next.provider_revision, applied.provider_revision); + assert.equal((await bootstrapManagedShadow(bootstrap(root), primary)).status, "replayed"); + assert.equal((await requireShadowPrimaryWriteAllowed(root, "goal-a"))?.capture_lineage_id, next.capture_lineage_id); + const nextBytes = await readFile(store.path); + const historical = await rollbackManagedShadow({ ...input, projection: { newer: true }, source_snapshot: { newer: true } }, primary); + assert.equal(historical.status, "replayed"); + assert.equal(historical.current_capture_lineage_id, next.capture_lineage_id); + assert.deepEqual(await readFile(store.path), nextBytes); + assert.ok(!shadowMaintenanceLockPath(root, "goal-a").includes("/outbox/")); +}); + +test("rollback refuses a different valid candidate lineage without changing files", async () => { + const root = await mkdtemp(join(tmpdir(), "loopx-management-wrong-lineage-")); + await bootstrapManagedShadow(bootstrap(root), primary); + const store = new FileAuthorityStore(join(root, "authority-shadow", "file-v0"), "goal-a"); + const loaded = await store.loadAuthority(); + assert.equal(loaded.status, "loaded"); + if (loaded.status !== "loaded") return; + const committed = await store.commitAuthority({ expected_provider_revision: loaded.provider_revision, operation_id: "foreign-lineage", events: [], next_projection: { ...loaded.head, capture_lineage_id: "foreign" }, receipts: [] }); + assert.equal(committed.status, "applied"); + if (committed.status !== "applied") return; + const before = await readFile(store.path); + const rejected = await rollbackManagedShadow({ runtime_root: root, goal_id: "goal-a", operation_id: "rollback:foreign", expected_provider_revision: committed.provider_revision }, primary); + assert.equal(rejected.reason_code, "rollback_candidate_identity_mismatch"); + assert.deepEqual(await readFile(store.path), before); + assert.equal((await readShadowManagementState(root, "goal-a"))?.status, "active"); +}); + +test("management request digest rejects reuse of a completed operation", async () => { + const root = await mkdtemp(join(tmpdir(), "loopx-management-request-digest-")); + const request = bootstrap(root); + assert.equal((await bootstrapManagedShadow(request, primary)).status, "applied"); + const store = new FileAuthorityStore(join(root, "authority-shadow", "file-v0"), "goal-a"); + const before = await readFile(store.path); + const rejected = await bootstrapManagedShadow({ ...request, source_version: "state:changed" }, primary); + assert.equal(rejected.reason_code, "management_operation_identity_mismatch"); + assert.deepEqual(await readFile(store.path), before); +}); + +test("management manifest hash rejects changed pending bootstrap evidence", async () => { + const root = await mkdtemp(join(tmpdir(), "loopx-management-manifest-hash-")); + const request = bootstrap(root); + await killAt("bootstrap", request, "bootstrap_prepared"); + const directory = join(shadowMaintenanceLockPath(root, "goal-a"), "..", "operations"); + const [operation] = await readdir(directory); + const path = join(directory, operation, "manifest.json"); + const manifest = JSON.parse(await readFile(path, "utf8")); + await writeFile(path, JSON.stringify({ ...manifest, capture_lineage_id: "replaced-lineage" })); + const before = await readFile(path); + const rejected = await bootstrapManagedShadow(request, primary); + assert.equal(rejected.reason_code, "shadow_management_manifest_invalid"); + assert.deepEqual(await readFile(path), before); + assert.equal((await new FileAuthorityStore(join(root, "authority-shadow", "file-v0"), "goal-a", { existingOnly: true }).loadAuthority()).status, "missing"); +}); + +test("management phase validation rejects an impossible terminal phase", async () => { + const root = await mkdtemp(join(tmpdir(), "loopx-management-phase-")); + assert.equal((await bootstrapManagedShadow(bootstrap(root), primary)).status, "applied"); + const path = join(shadowMaintenanceLockPath(root, "goal-a"), "..", "state.json"); + const state = JSON.parse(await readFile(path, "utf8")); + state.operation.phase = "prepared"; + await writeFile(path, JSON.stringify(state)); + const before = await readFile(path); + await assert.rejects(requireShadowPrimaryWriteAllowed(root, "goal-a"), { code: "shadow_management_state_invalid" }); + assert.deepEqual(await readFile(path), before); +}); + +test("management goal binding rejects copied state from another goal", async () => { + const root = await mkdtemp(join(tmpdir(), "loopx-management-goal-binding-")); + assert.equal((await bootstrapManagedShadow(bootstrap(root), primary)).status, "applied"); + const source = join(shadowMaintenanceLockPath(root, "goal-a"), "..", "state.json"); + const target = join(shadowMaintenanceLockPath(root, "goal-b"), "..", "state.json"); + await mkdir(join(target, ".."), { recursive: true }); + await writeFile(target, await readFile(source)); + const before = await readFile(source); + await assert.rejects(requireShadowPrimaryWriteAllowed(root, "goal-b"), { code: "shadow_management_state_invalid" }); + assert.deepEqual(await readFile(source), before); + assert.equal((await readShadowManagementState(root, "goal-a"))?.status, "active"); +}); + +test("bootstrap source lookup is bound to active immutable management evidence", async () => { + const root = await mkdtemp(join(tmpdir(), "loopx-management-source-binding-")); + const request = bootstrap(root); + await bootstrapManagedShadow(request, primary); + const binding = await requireShadowPrimaryWriteAllowed(root, "goal-a"); + assert.ok(binding); + assert.equal(await readShadowBootstrapSourcePath(root, "goal-a", binding), join(root, "state.md")); + await assert.rejects(readShadowBootstrapSourcePath(root, "goal-a", { ...binding, capture_lineage_id: "foreign" }), { code: "stale_generation" }); + const directory = join(shadowMaintenanceLockPath(root, "goal-a"), "..", "operations"); + const [operation] = await readdir(directory); + const path = join(directory, operation, "manifest.json"); + const manifest = JSON.parse(await readFile(path, "utf8")); + manifest.request.source_snapshot.state_path = join(root, "unbound.md"); + await writeFile(path, JSON.stringify(manifest)); + await assert.rejects(readShadowBootstrapSourcePath(root, "goal-a", binding), { code: "shadow_management_manifest_invalid" }); +}); + +test("bootstrap source lookup is existing-only and rejects missing state", async () => { + const root = await mkdtemp(join(tmpdir(), "loopx-management-source-missing-")); + const binding = { capture_profile: "file_outbox_v1", capture_lineage_id: "missing", source_root_digest: "sha256:" + "0".repeat(64), + store_identity: "file:" + "0".repeat(32), bootstrap_operation_id: "none", bootstrap_provider_revision: "file:1:" + "0".repeat(24) }; + await assert.rejects(readShadowBootstrapSourcePath(root, "goal-a", binding), { code: "bootstrap_required" }); + assert.deepEqual(await readdir(root), []); +}); diff --git a/tests/control_plane_ts/shadow_native_writer_boundary.test.ts b/tests/control_plane_ts/shadow_native_writer_boundary.test.ts new file mode 100644 index 0000000000..024be5f3da --- /dev/null +++ b/tests/control_plane_ts/shadow_native_writer_boundary.test.ts @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { atomicWriteJson } from "../../loopx/control_plane/effect_runtime_io.ts"; +import { shadowManagementStatePath } from "../../loopx/control_plane/coordination/shadow_management.ts"; +import { + createLocalCoordinationTodo, claimLocalCoordinationTodo, + mutateLocalCoordinationAuthority, editLocalCoordinationTodo, + LOCAL_COORDINATION_TODO_CREATE_REQUEST_SCHEMA, LOCAL_COORDINATION_TODO_CLAIM_REQUEST_SCHEMA, + LOCAL_COORDINATION_MUTATION_REQUEST_SCHEMA, +} from "../../loopx/control_plane/coordination/local_authority_runtime.ts"; + +for (const [name, invoke, schema] of [ + ["create", createLocalCoordinationTodo, LOCAL_COORDINATION_TODO_CREATE_REQUEST_SCHEMA], + ["claim", claimLocalCoordinationTodo, LOCAL_COORDINATION_TODO_CLAIM_REQUEST_SCHEMA], + ["mutate", mutateLocalCoordinationAuthority, LOCAL_COORDINATION_MUTATION_REQUEST_SCHEMA], + ["edit", editLocalCoordinationTodo, "loopx_todo_compatibility_edit_request_v0"], +] as const) { + test(`promoted ${name} checks maintenance before opening a provider`, async (t) => { + const root = await mkdtemp(join(tmpdir(), "loopx-native-maintenance-")); + t.after(() => rm(root, {recursive: true, force: true})); + await atomicWriteJson(shadowManagementStatePath(root, "goal-a"), {}); + let opened = 0; + const result = await invoke({schema_version: schema, runtime_root: root, goal_id: "goal-a", dry_run: false}, { + createStore: () => { opened++; throw new Error("provider touched"); }, + }); + assert.equal(result.reason_code, "shadow_management_state_invalid"); + assert.equal(opened, 0); + }); +} + +import { engageLegacyCoordinationWriterFence, legacyCoordinationTodoLockPath, legacyCoordinationWriterFencePath } from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; +import { taskLeaseLockPath } from "../../loopx/control_plane/work_items/task_lease_acquire.ts"; +import { withFileMutationLock } from "../../loopx/control_plane/effect_runtime_io.ts"; +import { access } from "node:fs/promises"; + +function fenceRequest(root: string) { + return {schema_version: "loopx_legacy_coordination_writer_fence_engage_request_v0", + runtime_root: root, goal_id: "goal-a", state_path: join(root, "ACTIVE_GOAL_STATE.md"), fence: { + schema_version: "loopx_legacy_coordination_writer_fence_v0", state: "engaged", goal_id: "goal-a", + fence_id: "fence-a", source_version: "source-a", source_projection_sha256: "a".repeat(64), + expected_shadow_provider_revision: "file:1:aaaaaaaaaaaaaaaaaaaaaaaa", + }}; +} + +test("fence engagement refuses malformed durable maintenance before publishing", async (t) => { + const root = await mkdtemp(join(tmpdir(), "loopx-fence-maintenance-")); + t.after(() => rm(root, {recursive: true, force: true})); + await writeFile(join(root, "ACTIVE_GOAL_STATE.md"), "---\ngoal_id: goal-a\n---\n"); + await atomicWriteJson(shadowManagementStatePath(root, "goal-a"), {}); + const result = await engageLegacyCoordinationWriterFence(fenceRequest(root)); + assert.equal(result.reason_code, "shadow_management_state_invalid"); + await assert.rejects(access(legacyCoordinationWriterFencePath(root, "goal-a"))); +}); + +for (const kind of ["todo", "state", "lease"] as const) { + test(`fence engagement waits for an existing ${kind} writer before publication`, async (t) => { + const root = await mkdtemp(join(tmpdir(), "loopx-fence-lock-")); + t.after(() => rm(root, {recursive: true, force: true})); + const statePath = join(root, "ACTIVE_GOAL_STATE.md"); + await writeFile(statePath, "---\ngoal_id: goal-a\n---\n"); + const lock = kind === "todo" ? legacyCoordinationTodoLockPath(root, "goal-a") : kind === "state" ? statePath : taskLeaseLockPath({runtime_root: root, goal_id: "goal-a"}); + let pending: Promise> | undefined; + let completed = false; + await withFileMutationLock(lock, async () => { + pending = engageLegacyCoordinationWriterFence(fenceRequest(root)).then((result) => {completed = true; return result;}); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(completed, false, "the fence must not pass an active primary writer"); + await assert.rejects(access(legacyCoordinationWriterFencePath(root, "goal-a"))); + }); + assert.equal((await pending)!.status, "applied"); + }); +} + +test("fence engagement requires the actual source state path", async (t) => { + const root = await mkdtemp(join(tmpdir(), "loopx-fence-source-")); + t.after(() => rm(root, {recursive: true, force: true})); + const request = fenceRequest(root) as Record; + delete request.state_path; + const missing = await engageLegacyCoordinationWriterFence(request); + assert.equal(missing.status, "failed"); + assert.equal(missing.reason_code, "invalid_legacy_writer_fence_request"); + await assert.rejects(access(legacyCoordinationWriterFencePath(root, "goal-a"))); +}); + +import { fixture as bootstrapFixture } from "./shadow_file_fixture.ts"; +import { executeTaskLeaseAcquire, TASK_LEASE_ACQUIRE_REQUEST_SCHEMA_VERSION } from "../../loopx/control_plane/work_items/task_lease_acquire.ts"; +import { executeTaskLeaseLifecycle, TASK_LEASE_LIFECYCLE_REQUEST_SCHEMA_VERSION } from "../../loopx/control_plane/work_items/task_lease_lifecycle.ts"; +import { readFile, writeFile } from "node:fs/promises"; + +test("managed fence engagement rejects a different existing source file", async (t) => { + const f = await bootstrapFixture(t); + const other = join(f.root, "OTHER_GOAL_STATE.md"); + await writeFile(other, await readFile(f.statePath)); + const result = await engageLegacyCoordinationWriterFence({...fenceRequest(f.root), state_path: other}); + assert.equal(result.status, "failed"); + assert.equal(result.reason_code, "shadow_source_state_path_mismatch"); + await assert.rejects(access(legacyCoordinationWriterFencePath(f.root, "goal-a"))); +}); + +test("active native lease requires durable prepare even without a caller capture hint", async (t) => { + const f = await bootstrapFixture(t); + const part = join(f.root, "authority-shadow", "outbox", "goal-a", "leases"); + await writeFile(part, "prepare unavailable"); + const authority = {handoff_mode: "hard_lease", registered_agent_candidates: [["agent-a"]], + todos: [{todo_id: "todo_one", status: "open", claimed_by: null, excluded_agents: []}], + todo_projection_error: null, source_receipts: [{source_id: "state", path: f.statePath, state: "file", + sha256: createHash("sha256").update(await readFile(f.statePath)).digest("hex")}]}; + const request = {schema_version: TASK_LEASE_ACQUIRE_REQUEST_SCHEMA_VERSION, runtime_root: f.root, + goal_id: "goal-a", todo_id: "todo_one", owner: "agent-a", idempotency_key: "lease-a", + ttl_seconds: 600, write_scopes: [], expected_version: null, authority}; + const held = await executeTaskLeaseAcquire(request); + assert.equal(held.ok, false); + assert.equal(held.error_code, "shadow_capture_prepare_failed", JSON.stringify(held)); + const leasePath = join(f.root, "goals", "goal-a", "task-leases", "todo_one.json"); + await assert.rejects(access(leasePath)); + await rm(part); + const applied = await executeTaskLeaseAcquire(request); + assert.equal(applied.ok, true, JSON.stringify(applied)); + assert.equal(typeof (applied.coordination_runtime_shadow_capture as Record).entry_id, "string"); + const before = await readFile(leasePath, "utf8"); + await atomicWriteJson(join(part, "drain-cursor.json"), {}); + const released = await executeTaskLeaseLifecycle({schema_version: TASK_LEASE_LIFECYCLE_REQUEST_SCHEMA_VERSION, + operation: "release", runtime_root: f.root, goal_id: "goal-a", todo_id: "todo_one", + owner: "agent-a", idempotency_key: "lease-a", expected_version: 1, authority}); + assert.equal(released.ok, false); + assert.equal(released.error_code, "shadow_capture_prepare_failed", JSON.stringify(released)); + assert.equal(await readFile(leasePath, "utf8"), before); +}); diff --git a/tests/control_plane_ts/task_lease_lifecycle.test.ts b/tests/control_plane_ts/task_lease_lifecycle.test.ts index 4886f5942f..5d7248a4b5 100644 --- a/tests/control_plane_ts/task_lease_lifecycle.test.ts +++ b/tests/control_plane_ts/task_lease_lifecycle.test.ts @@ -15,9 +15,27 @@ import { TASK_LEASE_LIFECYCLE_REQUEST_SCHEMA_VERSION, } from "../../loopx/control_plane/work_items/task_lease_lifecycle.ts"; import { evaluateTaskLeaseLifecycleDecision } from "../../loopx/control_plane/work_items/task_lease_lifecycle_decision.ts"; +import { shadowManagementStatePath } from "../../loopx/control_plane/coordination/shadow_management.ts"; const ACQUIRE_NOW = new Date("2026-09-01T03:00:00.000Z"); +test("maintenance corruption blocks native acquire and release before lease mutation", async (t) => { + const root = await workspace(t); + const acquired = await executeTaskLeaseAcquire(await acquireRequest(root)); + assert.equal(acquired.ok, true); + const path = join(root, "runtime", "goals", "goal-a", "task-leases", "todo_target.json"); + const before = await readFile(path, "utf8"); + await atomicWriteJson(shadowManagementStatePath(join(root, "runtime"), "goal-a"), {}); + const released = await executeTaskLeaseLifecycle(await lifecycleRequest(root, "release")); + assert.equal(released.ok, false); + assert.equal(released.error_code, "shadow_management_state_invalid"); + assert.equal(await readFile(path, "utf8"), before); + const retry = await executeTaskLeaseAcquire(await acquireRequest(root, {idempotency_key: "new-key"})); + assert.equal(retry.ok, false); + assert.equal(retry.error_code, "shadow_management_state_invalid"); + assert.equal(await readFile(path, "utf8"), before); +}); + function lifecycleDecision( operation: "renew" | "transfer" | "release", overrides: Record = {}, @@ -625,8 +643,9 @@ test("user-gate auto-acquire returns a persistent fence that can be closed", asy ); assert.equal(checked.ok, true); const autoCapture = checked.coordination_runtime_shadow_capture as Record; - assert.equal(typeof autoCapture.entry_id, "string"); - assert.equal(autoCapture.seq, 1); + assert.equal(autoCapture.entry_id, null); + assert.equal(autoCapture.seq, null); + assert.equal(autoCapture.skipped_reason, "bootstrap_required"); assert.equal(autoCapture.failure, null); const fence = checked.fence as Record; assert.equal(fence.auto_acquired, true); @@ -650,8 +669,9 @@ test("user-gate auto-acquire returns a persistent fence that can be closed", asy assert.equal(closed.ok, true); assert.equal(closed.released, true); const closeCapture = closed.coordination_runtime_shadow_capture as Record; - assert.equal(typeof closeCapture.entry_id, "string"); - assert.equal(closeCapture.seq, 2); + assert.equal(closeCapture.entry_id, null); + assert.equal(closeCapture.seq, null); + assert.equal(closeCapture.skipped_reason, "bootstrap_required"); assert.equal(closeCapture.failure, null); assert.equal((await lease(root)).status, "released"); }); From 0415a6725a9a46b085a557f6b53453eb10584b05 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Sun, 6 Sep 2026 14:54:00 +1000 Subject: [PATCH 03/27] docs(coordination): explain bounded qualification and recoverable rollback Signed-off-by: wchwawa --- examples/shared-goal-authority-e2e/README.md | 50 +++--- .../shared-goal-authority-e2e/correctness.md | 155 ++++++++++++++++++ 2 files changed, 185 insertions(+), 20 deletions(-) create mode 100644 examples/shared-goal-authority-e2e/correctness.md diff --git a/examples/shared-goal-authority-e2e/README.md b/examples/shared-goal-authority-e2e/README.md index 82cd454934..f698d52def 100644 --- a/examples/shared-goal-authority-e2e/README.md +++ b/examples/shared-goal-authority-e2e/README.md @@ -84,23 +84,33 @@ confined to the `bindings` block nulls every binding, marks `summary.privacy_violations`, which no flag relaxes. Evidence therefore carries counters, cursors, outcome tokens, and sha256 prefixes only. -## Test seams later PRs must provide - -The pending `s2c2.*` rows will be implemented against these seams; a Stage 2C -parity PR that does not expose them cannot be ladder-verified: - -- a drain lock file at `/authority-shadow/outbox//drain` so the - ladder can hold the drain window with `loopx.file_lock.exclusive_file_lock` - exactly as it holds `/authority-shadow/file//observation` - today, then SIGKILL a writer before or during drain; -- one file per outbox entry under `/authority-shadow/outbox//` - with a prepared-then-committed marker, so pending entries are countable and - a rollback with pending entries is observable from disk; -- `drain` and `verify` product commands that emit JSON with `drained_count`, - `cursor_before`, `cursor_after`, `parity_verdict`, and the source and - candidate digests, so parity-equal and foreign-edit rows can assert on - typed fields rather than prose; -- the same commands must resolve the runtime root the way `todo` and - `task-lease` do (`effective_runtime_root`), so the one-lineage guarantee that - `s2c1.dual_runtime_root_consistency` proves for the observation hooks also - holds for drain and verify. +## Bounded outbox correctness and the pending ladder + +The independently runnable [correctness suite](correctness.md) covers the +`file_outbox_v1` capture lineage, strict cursor recovery, mixed writers, +source fences, and recoverable bootstrap/rollback. It uses real CLI and native +processes, the production `FileAuthorityStore`, and process death at persistence +boundaries. [Installed-package E2E](installed.py) repeats the public lifecycle +outside the checkout for both wheel and sdist. [Negative controls](mutants.py) +deliberately remove correctness checks in disposable source copies. + +These checks do not change the nine pending `s2c2.*` ladder declarations above. +Sustained production parity, the migration/growth gates, and promotion remain +separate obligations. A bounded qualification result reports +`sustained_parity_verdict=not_evaluated`. + +Future ladder rows must use the actual product interfaces: + +- `authority-shadow drain` for receipt-verified replay and cursor recovery; +- `coordination-shadow inspect / qualify / read-candidate` for comparison, + bounded historical qualification, and a qualified read from that same head; +- `coordination-shadow rollback` with an exact revision or unfinished bootstrap + operation selector, followed by explicit rebootstrap; +- the stable management lock outside the goal outbox for scheduling a management + boundary. Rollback moves the entire goal outbox, so a lock inside that directory + cannot coordinate it. The tests retain scheduling-only barriers around real + persistence calls rather than depending on the retired drain lock. + +There is no `verify` command or generic `reset`. The independent v0 observation +store and its runtime-root override behavior retain their original scope; its +historical evidence is not an outbox qualification receipt. diff --git a/examples/shared-goal-authority-e2e/correctness.md b/examples/shared-goal-authority-e2e/correctness.md new file mode 100644 index 0000000000..88b6934adb --- /dev/null +++ b/examples/shared-goal-authority-e2e/correctness.md @@ -0,0 +1,155 @@ +# Bounded file outbox correctness + +An active shadow captures each canonical primary mutation once. A cursor is a +position hint: only the actual file provider's committed transaction and receipt +can authorize deletion of an outbox file. Qualification folds the complete +baseline and transaction history, then compares the current Todo, handoff mode, +and lease state. Equal final snapshots alone are insufficient. + +This is a pre-promotion `file_outbox_v1` contract. Native canonical Todo keeps its +existing contract. Older file-v0 mirror or mixed histories remain unchanged, +read-only, and ineligible for this qualification; there is no automatic migration. +The separate observation handler remains available within its original scope. + +## Operator lifecycle + +Enable the existing per-goal `coordination.runtime_shadow` configuration with +`enabled: true` and `provider: file_v0`. Select the intended common runtime root +and state file in the registry before bootstrap. The new history binds that +source; conflicting `--runtime-root`, `--project`, or `--state-file` overrides +cannot establish a second authority for it. Default-off primary writes and the +independent observation path retain their own override behavior. + +Use the same registry and Goal throughout: + +```bash +loopx --registry registry.json --format json coordination-shadow bootstrap --goal-id goal-a +loopx --registry registry.json --format json coordination-shadow bootstrap --goal-id goal-a --execute +loopx --registry registry.json --format json authority-shadow status --goal-id goal-a +loopx --registry registry.json --format json authority-shadow drain --goal-id goal-a +loopx --registry registry.json --format json coordination-shadow inspect --goal-id goal-a +loopx --registry registry.json --format json coordination-shadow qualify --goal-id goal-a +loopx --registry registry.json --format json coordination-shadow read-candidate --goal-id goal-a --todo-id TODO_ID +``` + +Bootstrap imports the complete current Todo/handoff and lease baseline. It does +not count as a mutation. Qualification requires three actual primary mutations +by default; replay and proven abandonment also do not count. `read-candidate` +uses that default policy and repeats the same validator, returning from its +verified head rather than trusting a previous successful qualification. + +Turning off the configuration does not cancel an already active capture +obligation. Todo/handoff/followup writers and native lease writers must still +prepare before changing their bound primary. A failed durable prepare holds +that mutation. A failure after primary replacement retains recovery evidence; +it cannot truthfully report that the primary was unchanged. No-change, preview, +idempotent retry, and prose-only changes produce no mutation receipt. Event-only +Todo sources retain `event_log_writer_not_bound` and prevent qualification. + +To retire the candidate, obtain the exact current `provider_revision` from +inspection and preview the target before execution. If an invalid cursor or +outbox manifest blocks inspection, use `authority-shadow status` and its +read-only provider proof. If the provider itself cannot be proved, preserve the +scene and hold; do not guess a revision. + +```bash +loopx --registry registry.json --format json coordination-shadow rollback --goal-id goal-a --provider-revision EXACT_REVISION +loopx --registry registry.json --format json coordination-shadow rollback --goal-id goal-a --provider-revision EXACT_REVISION --execute +``` + +To terminate a bootstrap interrupted before active publication, use its exact +operation identity from the management result/journal: + +```bash +loopx --registry registry.json --format json coordination-shadow rollback --goal-id goal-a --bootstrap-operation-id EXACT_BOOTSTRAP_OPERATION --execute +``` + +The selectors are mutually exclusive. Neither is an unconditional cleanup. +Rollback preserves the candidate and the complete Goal outbox, including pending +entries, markers, and malformed cursor bytes. It publishes `inactive` only after +the archive matches the immutable manifest. The shared store identity and other +Goals are unchanged. Primary writes then resume; capture reports +`bootstrap_required`. A new bootstrap imports that current state and starts a +new capture lineage, even if its data equals the old baseline. + +## Recovery and holds + +Management uses `bootstrapping / active / rolling_back / inactive`. The stable +management directory is under `authority-transition/file-v0/`, outside the +outbox being archived. Its immutable manifest and intent bind the operation, +request, source, provider revision, and file hashes. Retrying the same operation +reconciles actual source/archive existence and hashes; a phase string alone +cannot prove completion. Conflicts hold without overwriting or deleting either +copy. A delayed exact old request only returns its historical result. Reusing +its operation ID with different request contents returns an identity mismatch. + +During an unfinished management operation, canonical and whole-state prose +writes return a maintenance hold before their first side effect. Management lock +order is M → T → S → L → K. Native lease writers check under their existing K; +ordinary writers release primary locks before drain. Legacy fence engagement +also takes the actual source S and therefore requires its absolute state path +in the internal RPC request. + +Drain checks the real receipt first, persists the cursor second, and reclaims +each individually verified file last. Missing cursors can be reconstructed from +complete continuous history; malformed or unreadable cursors are never silently +replaced. Unknown files, mismatched bytes, wrong identities, or incomplete proof +preserve the scene. An interrupted primary without a marker is either proven +under the source lock or left `unproved`; an A → B → A history cannot establish +that the first write was abandoned. TS repeats source proof under its own locks +after Python releases its locks to call the native commit operation. + +For unresolved evidence, retain the directory for inspection and use exact +rollback when abandoning the candidate. Do not delete the cursor to bypass a +failed history check, copy an old entry into a new lineage, or reseed implicitly. + +Qualification explicitly reports bounded scope and +`sustained_parity_verdict=not_evaluated`. The current proof boundary is 10,000 +transactions including the baseline; new commits hold at that boundary while +exact receipt replays remain valid. This is a bounded correctness limit, not a +performance result. Drain budgets bound work between operations; they do not +preempt an in-flight filesystem or RPC call. + +## Required validation + +Install the repository test extra and Node dependencies. Run the long tests +separately without skip or relaxation flags: + +```bash +python -m pip install -e '.[test]' 'build>=1,<2' +npm ci --ignore-scripts +python -m pytest -q -m stage2c_e2e --junitxml=stage2c-e2e.xml +python examples/shared-goal-authority-e2e/mutants.py --output .local/stage2c-mutants +python -m build +python examples/shared-goal-authority-e2e/installed.py --artifact dist/*.whl --report-json .local/installed-wheel.json +python examples/shared-goal-authority-e2e/installed.py --artifact dist/*.tar.gz --report-json .local/installed-sdist.json +python -m pytest -q -n 2 +npm run test:control-plane +npm run typecheck:control-plane +python -m mypy +python -m ruff check tests loopx/canary loopx/control_plane loopx/domain_packs loopx/presentation +python scripts/generate_coordination_state_contract.py --check +python examples/control_plane/cli-output-budget-regression-smoke.py +loopx --format json canary premerge --from-git-diff +``` + +The full TS suite's PostgreSQL conformance requires `LOOPX_TEST_POSTGRES_URL` +pointing to a disposable test database. Source smoke success does not replace +installed-package evidence: the package runner creates an empty environment, +clears source-path injection, runs outside the checkout, verifies installed +Python/TS/JSON provenance, and reads back through an independent native process. + +| Obligation | Retained oracle | +| --- | --- | +| Full baseline, mixed Python/native writers, handoff, followups, monitor successor, one receipt per mutation | `test_runtime_shadow_bounded_e2e.py`, `test_shadow_drain_e2e.py` | +| Cursor attacks, complete proof before bounded cleanup, missing cursor writer-first, dual drainers | `test_shadow_cursor_safety.py`, `test_shadow_drain_adversarial.py`, `shadow_cursor_safety.test.ts` | +| Primary and drain process death, lost ACK, prepared-only A → B → A | `test_shadow_drain_e2e.py`, `test_shadow_management_e2e.py` | +| Every bootstrap/rollback durable window, raw archive fidelity, late requests and other-Goal isolation | `shadow_management.test.ts`, `test_shadow_management_e2e.py` | +| Fence and maintenance boundaries, source override races, whole-file durability, paragraph injection, refresh CAS | `test_shadow_writer_boundaries.py`, `shadow_native_writer_boundary.test.ts`, `test_shadow_drain_adversarial.py` | +| History flaws despite equal snapshots, legacy mixed profile, source drift, event-only hold, qualified reads | `coordination_runtime_shadow.test.ts`, `file_outbox_qualification.test.ts`, `test_runtime_shadow_bounded_e2e.py` | +| Installed lifecycle and resource provenance in wheel and sdist | `installed.py` | +| Missing checks, lock placement, duplicate mirror, early marker, cursor regression | `mutants.py` with unchanged GREEN controls and assertion RED results | + +The mandatory repair set must have zero failures, skips, pending, or unverified +cases. Broader ladder rows retain their declared pending/environment gates; +these tests grant neither production promotion nor a completed Stage 2C claim. From 08b9c0d28a9b9ceaa831816d0bb4dba44c24f511 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Sun, 6 Sep 2026 15:16:43 +1000 Subject: [PATCH 04/27] fix(coordination): make validation inputs and ordering explicit Signed-off-by: wchwawa --- .github/workflows/python-tests.yml | 2 +- .../shared-goal-authority-e2e/installed.py | 6 ++--- examples/shared-goal-authority-e2e/mutants.py | 7 ++++++ .../coordination/runtime_shadow.ts | 8 +++++- .../test_runtime_shadow_bounded_e2e.py | 25 +++++++++++++++++++ 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 4f1f813f9b..a1454e1748 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -191,7 +191,7 @@ jobs: cache: npm - name: Install test and package build tools run: | - python -m pip install --disable-pip-version-check -e ".[test]" "build>=1,<2" + python -m pip install --disable-pip-version-check -e ".[test]" "build==1.6.0" npm ci --ignore-scripts - name: Qualify real CLI, mixed writers, process death, and recovery run: python -m pytest -q -m stage2c_e2e --junitxml=stage2c-e2e.xml diff --git a/examples/shared-goal-authority-e2e/installed.py b/examples/shared-goal-authority-e2e/installed.py index bfa64ada34..bfca9ac213 100644 --- a/examples/shared-goal-authority-e2e/installed.py +++ b/examples/shared-goal-authority-e2e/installed.py @@ -141,7 +141,7 @@ def run(self) -> None: self.report["provenance"] = provenance self.checked("installed_python_ts_json_resources", resource_count=len(provenance["resources"])) - initialized = self.cli("console_project_bootstrap", "bootstrap", "--project", str(self.project), + self.cli("console_project_bootstrap", "bootstrap", "--project", str(self.project), "--goal-id", GOAL, "--objective", "Qualify installed authority transactions.", "--no-onboarding-scan", "--onboarding-connection-validation", "provider-prevalidated", "--no-global-sync") # Set configuration only, before shadow bootstrap creates the real binding. @@ -151,8 +151,8 @@ def run(self) -> None: "runtime_shadow": {"schema_version": "loopx_coordination_runtime_shadow_config_v0", "enabled": True, "provider": "file_v0"}}) self.registry.write_text(json.dumps(registry)) - state = Path(initialized["state_file"]) - state.write_text(state.read_text().replace("---\n", "---\nhandoff_mode: hard_lease\n", 1)) + self.cli("console_handoff_mode_hard_lease", "handoff-mode", "set", "--goal-id", GOAL, + "--mode", "hard_lease") boot = self.cli("console_shadow_bootstrap", "coordination-shadow", "bootstrap", "--goal-id", GOAL, "--execute")["bootstrap"] require(boot.get("status") == "applied" and bool(boot.get("capture_lineage_id")), f"bootstrap not applied: {boot}") self.checked("real_baseline_bootstrap", capture_lineage_id=boot["capture_lineage_id"]) diff --git a/examples/shared-goal-authority-e2e/mutants.py b/examples/shared-goal-authority-e2e/mutants.py index fd855d191b..f31554ad5c 100644 --- a/examples/shared-goal-authority-e2e/mutants.py +++ b/examples/shared-goal-authority-e2e/mutants.py @@ -149,6 +149,13 @@ def apply(source: str) -> str: "if (resolve(String(snapshot.state_path)) !== resolve(String(snapshot.registered_state_path)))", "if (false && resolve(String(snapshot.state_path)) !== resolve(String(snapshot.registered_state_path)))")),), "tests/control_plane/test_runtime_shadow_bounded_e2e.py::test_controller_cannot_bind_an_alternate_state_file_before_or_after_bootstrap"), + Case("locale_dependent_lease_order", ((COORDINATION + "runtime_shadow.ts", replacement( + '''.sort((left, right) => { + if (left < right) return -1; + if (left > right) return 1; + return 0; + });''', ".sort((left, right) => left.localeCompare(right));")),), + "tests/control_plane/test_runtime_shadow_bounded_e2e.py::test_source_snapshot_preserves_ordinal_mixed_case_lease_inventory"), ]) # Restore both halves of the obsolete mirror: the public CLI hook and an actual diff --git a/loopx/control_plane/coordination/runtime_shadow.ts b/loopx/control_plane/coordination/runtime_shadow.ts index 187eaf1708..0acfc63fd1 100644 --- a/loopx/control_plane/coordination/runtime_shadow.ts +++ b/loopx/control_plane/coordination/runtime_shadow.ts @@ -142,7 +142,13 @@ export async function verifyShadowSourceSnapshot(request: ShadowRequest): Promis } const inventory: JsonObject[] = []; const leases: JsonObject[] = []; - for (const name of names.filter((name) => /^[A-Za-z0-9_.-]+\.json$/.test(name)).sort()) { + // ASCII filenames must use the same ordinal order as Python's source snapshot. + const leaseNames = names.filter((name) => /^[A-Za-z0-9_.-]+\.json$/.test(name)).sort((left, right) => { + if (left < right) return -1; + if (left > right) return 1; + return 0; + }); + for (const name of leaseNames) { const data = await readFile(join(directory, name)); const lease = canonicalAuthorityObject(JSON.parse(data.toString("utf8")), "lease"); if (lease.goal_id !== request.goal_id || lease.todo_id !== name.slice(0, -5)) throw new ShadowManagementError("source_lease_identity_mismatch"); diff --git a/tests/control_plane/test_runtime_shadow_bounded_e2e.py b/tests/control_plane/test_runtime_shadow_bounded_e2e.py index 276d9e4dbf..c8da588364 100644 --- a/tests/control_plane/test_runtime_shadow_bounded_e2e.py +++ b/tests/control_plane/test_runtime_shadow_bounded_e2e.py @@ -86,6 +86,31 @@ def acquire_native(tmp_path: Path, registry: Path, runtime: Path, todo_id: str) }) +def test_source_snapshot_preserves_ordinal_mixed_case_lease_inventory(tmp_path: Path) -> None: + registry, runtime, state = workspace(tmp_path) + directory = runtime / "goals" / "goal-a" / "task-leases" + directory.mkdir(parents=True) + # Imported baseline files use the snapshot contract's ASCII filename range. + # Their order is independent of locale and filesystem enumeration order. + for todo_id in ("todo_alpha", "todo_Zulu", "todo_Bravo"): + (directory / f"{todo_id}.json").write_text(json.dumps({ + "schema_version": "task_lease_v0", "goal_id": "goal-a", "todo_id": todo_id, + "owner": "agent-a", "status": "released", "version": 1, + "write_scopes": ["src/"], "idempotency_key": f"baseline-{todo_id}", + }), encoding="utf-8") + goal = enable(registry) + projection, snapshot = build_runtime_shadow_source_snapshot( + goal=goal, runtime_root=runtime, state_path=state, registry_path=registry) + expected = ["todo_Bravo", "todo_Zulu", "todo_alpha"] + assert [entry["name"] for entry in snapshot["lease_inventory"]] == [f"{name}.json" for name in expected] + assert [lease["todo_id"] for lease in projection["leases"]] == expected + boot = cli(registry, runtime, "coordination-shadow", "bootstrap", "--goal-id", "goal-a", "--execute") + assert boot["bootstrap"]["status"] == "applied", boot + inspected = cli(registry, runtime, "coordination-shadow", "inspect", "--goal-id", "goal-a") + assert inspected["inspection"]["status"] == "matched", inspected + assert history(tmp_path, runtime)[0]["projection"]["leases"] == projection["leases"] + + def test_public_cli_and_independent_native_writer_qualify_one_complete_lineage(tmp_path: Path) -> None: registry, runtime, state = workspace(tmp_path) cli(registry, runtime, "handoff-mode", "set", "--goal-id", "goal-a", "--mode", "soft_claim") From 6bbc06c2bd77a69b3771655274e11d0a4cdcc133 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Sun, 6 Sep 2026 15:34:17 +1000 Subject: [PATCH 05/27] ci(coordination): lock safe Stage 2C validation inputs Signed-off-by: wchwawa --- .github/workflows/python-tests.yml | 28 +- pyproject.toml | 2 +- tests/requirements-stage2c-linux-py311.txt | 966 +++++++++++++++++++++ tests/requirements-stage2c-tools.in | 6 + 4 files changed, 998 insertions(+), 4 deletions(-) create mode 100644 tests/requirements-stage2c-linux-py311.txt create mode 100644 tests/requirements-stage2c-tools.in diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index a1454e1748..3ea7e6b628 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -185,20 +185,42 @@ jobs: with: python-version: "3.11" cache: pip + cache-dependency-path: tests/requirements-stage2c-linux-py311.txt - uses: actions/setup-node@v6 with: node-version: "22.6" cache: npm - - name: Install test and package build tools + - name: Install locked test and package build tools run: | - python -m pip install --disable-pip-version-check -e ".[test]" "build==1.6.0" + python -m pip install --disable-pip-version-check --require-hashes --only-binary=:all: -r tests/requirements-stage2c-linux-py311.txt npm ci --ignore-scripts + - name: Build and verify the checked-out source package + run: | + python -m build --no-isolation --wheel --outdir .local/stage2c-source-install + python - <<'PYTHON' + import hashlib + from pathlib import Path + + directory = Path(".local/stage2c-source-install").resolve() + wheels = list(directory.glob("*.whl")) + if len(wheels) != 1: + raise SystemExit("Expected exactly one wheel from the checked-out source") + wheel = wheels[0] + digest = hashlib.sha256(wheel.read_bytes()).hexdigest() + (directory / "requirements.txt").write_text( + f"loopx @ {wheel.as_uri()} --hash=sha256:{digest}\n", encoding="utf-8" + ) + PYTHON + python -m pip install --disable-pip-version-check --require-hashes --no-deps --no-index -r .local/stage2c-source-install/requirements.txt + python -m pip check + # Remove the generated source copy before pytest's normal discovery. + python -c "import shutil; shutil.rmtree('build')" - name: Qualify real CLI, mixed writers, process death, and recovery run: python -m pytest -q -m stage2c_e2e --junitxml=stage2c-e2e.xml - name: Reject deliberate correctness regressions run: python examples/shared-goal-authority-e2e/mutants.py --output .local/stage2c-mutants - name: Build independently installed distributions - run: python -m build + run: python -m build --no-isolation - name: Qualify wheel outside the repository run: python examples/shared-goal-authority-e2e/installed.py --artifact dist/*.whl --report-json installed-wheel.json - name: Qualify sdist outside the repository diff --git a/pyproject.toml b/pyproject.toml index 6b11b31711..9cdddf48df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ test = [ "jsonschema>=4.23,<5", "mcp==1.28.1", "types-jsonschema>=4.23,<5", - "pytest>=8,<9", + "pytest>=9.0.3,<10", "pytest-cov>=5,<7", "pytest-xdist>=3,<4", "pytest-split>=0.10,<0.11", diff --git a/tests/requirements-stage2c-linux-py311.txt b/tests/requirements-stage2c-linux-py311.txt new file mode 100644 index 0000000000..a851bc5079 --- /dev/null +++ b/tests/requirements-stage2c-linux-py311.txt @@ -0,0 +1,966 @@ +# This file was autogenerated by uv via the following command: +# uvx --from uv==0.11.26 uv pip compile pyproject.toml tests/requirements-stage2c-tools.in --extra test --python-version 3.11 --python-platform x86_64-unknown-linux-gnu --generate-hashes --default-index https://pypi.org/simple --output-file tests/requirements-stage2c-linux-py311.txt +annotated-types==0.8.0 \ + --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 + # via pydantic +anyio==4.15.1 \ + --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \ + --hash=sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94 + # via + # httpx + # mcp + # sse-starlette + # starlette +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + # via + # jsonschema + # referencing +build==1.6.0 \ + --hash=sha256:bd2c8afc603e7a2e0ce70e2ea85f0a6d02043bafbd307f5bada0f98669eca5af \ + --hash=sha256:f7aaf1ebbb79178a02ba248bb524f2176b256017e17e8e4bd4289c7b38cc2bad + # via -r tests/requirements-stage2c-tools.in +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via + # httpcore + # httpx +cffi==2.1.1 \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via cryptography +click==8.5.0 \ + --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \ + --hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34 + # via uvicorn +coverage==7.16.0 \ + --hash=sha256:01b18b8a6c9cec8d5f45550e2501426ed982cf2c35016b0acd2ba9b5d8b2fb06 \ + --hash=sha256:0466f4a5c0370461b7d8c7eb259d7d1db0b5756f13d66230b04d22a1d380ee11 \ + --hash=sha256:050a291b3cfe5e0df5999ef2fa5a7aff6e2db329f069d47eb63f02bde2e7e96b \ + --hash=sha256:058631257350b31784ed43ceb808298b6f074edf4ebca4c7ce5082e6bf873a61 \ + --hash=sha256:0598aadae641f30a0796b75b45c0b9c5de8619bd5cfb251bb0cc254e86e6dd13 \ + --hash=sha256:06f20145a9eb5bf1fd1dde3c0bc2af2e7c22135ab07ca6284d6ada7cc3904c4e \ + --hash=sha256:077f0964087883176ff6ab9b074694cae29f8c708273b13ca62c183c6ed716cd \ + --hash=sha256:0bb04ee77e557d7476471969d35fbbfb5fc8a4152e9409aa5811780c36d9b23e \ + --hash=sha256:0ccc37c00e1a5d30840902c54557e104d04aead872cedf6d2281c8725a467e06 \ + --hash=sha256:0fca700cae4635656668ba6e2b66a85aac9f2622d7b2bcf82e844c409eaa1313 \ + --hash=sha256:136988df5bc5a48795d9c42c75c4bbda5d9a78e750a080c1233010edff93a1af \ + --hash=sha256:1420370276f1694b663207b8245c3628aafb9624fe3cebf313a13d860e55ee67 \ + --hash=sha256:151855767480be14db595cbc2040f6a4db965cdfeebd354d79b0256742b029e0 \ + --hash=sha256:1545c52ce756b8a97007f439a220297f1cd72a2cbbcdffccdf1c1f70e74f9a42 \ + --hash=sha256:17fc3628f99812fec24f40092af34c1c73274d331babab3d1d768a75de650cf7 \ + --hash=sha256:181c2906b9b3759955c1c33c51fbb91c754fbd0b82ea49e2c81061f5a052082c \ + --hash=sha256:183613f664718b340589d7f005c7e92b4b601cffd20a8a4117cfda3e983b080f \ + --hash=sha256:190ffa0f5af966254c249fb3aeaca2cef389785e3e287fd577d39e134d20f8a3 \ + --hash=sha256:1a03e78f53e4d2ab13adac19958a89322d1829913e5623d642627bf60b35da21 \ + --hash=sha256:1c2c45ee1853668f0ea1a0ddff396421c9dc5ad25a56bfb94a895970c2d8e7c2 \ + --hash=sha256:1c5a43cc0ef101637ae920a9eed24cf0549ef815621eae68b3ad577ec5a7ad2f \ + --hash=sha256:1f81cb1554c3712e41649ed5dc98656b50b958e4da12f0f5adb681ce3db92831 \ + --hash=sha256:22d8802827404be32f5a4d6ddc037f6fa0074b7d06702c0224cb598def8b665d \ + --hash=sha256:245f7de6d023a5bba375dbec9f2e0869bfa26ac0cc639bbb7b4c814884000b73 \ + --hash=sha256:26e7de0cb87960c6c9b5cad760068dab767b2b49a3b9376e1992c1e2691a015e \ + --hash=sha256:27461af9f3ed7d2cf2411eb083784f87055ebf42211789ae3a216c48609bc743 \ + --hash=sha256:289f2ed4d56eebf029b649e7dfc3c1153b111962a75e294cdd8e4a1598a04cc3 \ + --hash=sha256:2c3ff6580f2dfc5bec34717b85b2e6cf5ec993b721e7bb58a794babd525a8178 \ + --hash=sha256:2ddaa9e2af4760a329d80008b7a3b4762fbb0dbcb169199360f9a5179c32f2dc \ + --hash=sha256:30f5aee6d1d517abcdfd4f9cad027969ff79a1440a22da263f9514e31b5b66e9 \ + --hash=sha256:32c56b5b47c50635081445ac404dd08c2d591b9c837c22570aa9e182c3b42cd4 \ + --hash=sha256:34d8686bce035c8465b318a8c2890e69ba14a00801a27f4eb6bdc97c23944d87 \ + --hash=sha256:35a9676bf86097f790113ebd9fb67681804ef54d40941d2f10ba68c02239e575 \ + --hash=sha256:360967a6fd77794c167529eec2d16ff8e38216110619d23acc3fd466a1648bee \ + --hash=sha256:36aed4951aedf04cbe9465e76f8e71219980a52b73d07afe69746cba6ba7b97a \ + --hash=sha256:38b8e1e73750b8965d1154ed733f5303acd4e24ee2d5ee872bb1bfab744a31ce \ + --hash=sha256:3e8037e8213adf882e9d7eedd2c5c557933ab0b9632c42d98fe98ec9bcdb4025 \ + --hash=sha256:4080ad6bad9f14690e6b2104f5e8d137ccc65a4b5427a36662090637d4bd16d5 \ + --hash=sha256:4212cec9b42fd9929e70b462732fefd8b13406371871c82f3c14397499d6550b \ + --hash=sha256:47d5e1fc0b321c8308a2aacee0497c435b08acaa629b7059798fdf6fc3006352 \ + --hash=sha256:496277c8d7beed695e02c7be53516a0152e4caef8738a0feab6a638546cce449 \ + --hash=sha256:49fa72ead28c8216f8916398a4f3c4669acb30a061822810ee20a727a1be2897 \ + --hash=sha256:4b1d09cb5d8dc2c7164450f5217e6f0717497de9c588806a0780d352abef904a \ + --hash=sha256:4c1f16d5555a195295d0dc9c902612270e3dfed6a11f3bf7bc470b7b6a79ed3c \ + --hash=sha256:4fcb5f07a9b7083bfb715115d27ce263ba2b5b89dddeee536b295ba0e3c2c627 \ + --hash=sha256:507596cee23e9968b1934fe86d799b76166541af0a293930918b1b48a5c84bd2 \ + --hash=sha256:51e7d0e311d2fba3915f971236cbdd4ad821fc7a23988221c0b33c964b0eba22 \ + --hash=sha256:5205baea687133613dced668a3d0168ea1479349615bfc255849a7944988c889 \ + --hash=sha256:54b7fba6a74d010de34319a0419d5b65af8c00f539ad0b6f39fc6f342ab99697 \ + --hash=sha256:55957d350452017f523b9b03ffac078f9a214e23c04a3d0a674569203550c719 \ + --hash=sha256:577c2ac8c0036f6f8edd3a7783a9e67302b17771d1abf0fd2ed246e3158be51b \ + --hash=sha256:584896fb8b650e999e24ef57e9513e482c12f8e15a73ee9d4584e23c99465867 \ + --hash=sha256:5dad64d9c17cb1983adef07998e6e2e1cf870a156f1ea80f81ce1970f4c545ce \ + --hash=sha256:64f0611ee05364fc85cc3e5bc371804117a76fd337720e6017332fc7c534257a \ + --hash=sha256:69474d81f198774c9d2937599ca5da04c9e1c5de5032da23c607ce4960ce360e \ + --hash=sha256:6ad3bbad240ab937512156bc944fdee63ac4dd34a7558a3094548fd4c1150c02 \ + --hash=sha256:6c60cde430c0e7e3be612973af39b4cff90ec2e2defe7b2b701daea3a0ffff04 \ + --hash=sha256:6e2854b62601c89a63814ad5def3b90d99c6724cc4cb977f75b725e5fca4b1e3 \ + --hash=sha256:6e701938ec9081d3e400a0c9a9a8ae0f7ca44214741daeac4454b1c6ef6dbd19 \ + --hash=sha256:6fde65e0ea945920265dfe4a2108fc45eee2e2ea3d9c3073af6373ff9836aa71 \ + --hash=sha256:719a3feb6220dd32ed932d4c3676d17fb8739e2643b29c0e7c3af400ff80ac44 \ + --hash=sha256:72a0795cc6d34acc2b03dfeabdc82b61b72087f2737018b56ac92c1cf5446c54 \ + --hash=sha256:770d4244c423dcafb5c31db393f429fe952b1bba23bbff7cc3886f8133769ba5 \ + --hash=sha256:78103e79f9378cb0e43ddaa728629a373c070df903c5dfa98b63ba2cfb4e8c42 \ + --hash=sha256:785b114356c99c0dd5b3f57b9696cfd57b7704f4c53847df8dc88c6cc0d9bcb6 \ + --hash=sha256:78f8b56261d608be102c62edd3a60b66bcd0b581f3f86fdcabaf8b8d95adc950 \ + --hash=sha256:7cae7715afa51dd7c9c42e6603bb46daf424c3449fdf06519cc658aa8d46e2e4 \ + --hash=sha256:80cf547379ad6b1878fd03b033b51188beab4b41824c96e7839e014a4cb947be \ + --hash=sha256:80d7d5d744a041f08637df743ac086204ec5acbcd8432a42b00b49e607358024 \ + --hash=sha256:81d63b68b26304e3668edb103311c17fe13c2ed1c7fe973309819f27bf61c5b8 \ + --hash=sha256:857fceba6ff4b507ee0ad98798a33d544a8473df0c542bf04251ee4ed5ee6292 \ + --hash=sha256:87771ecf986cff55e87413238cd5e4f54d949c2074bd6fc1657d26a56314ee24 \ + --hash=sha256:916cf8d25c1ce148f7eceb1d45afc9724841200110adc4e53250391852debd91 \ + --hash=sha256:92cbc2bf4f7f67c79f1d3ca4fe8c50faddf48e852a3d07eaaf02dc014889832f \ + --hash=sha256:9421dde689e68d9fd2b6cd7d8c4498e79b5431467b6298517e3f3e60fdbe80a7 \ + --hash=sha256:949eae7e0f562b1518355aaef4b03523e49a6d3fea12aa3542d9e36c863f8267 \ + --hash=sha256:97051c4903689b1afedc2a354d6118223051e03588078b53048603bda9014577 \ + --hash=sha256:984e5430fc6f858385009e92549955157d79335b1f3e13e1031e0f89d1284261 \ + --hash=sha256:9b83f6ac575530783771c8dcf05284f7c8b5b12f1e7cb226d63445aac4497a3a \ + --hash=sha256:9c0690994b84a15a53bdd39e0b2fdb539b22533820623eb86ba75b93760c645b \ + --hash=sha256:a336b1e2990a64f5c356a9b8380fb9c029d56c832b801255250c44d603271bfd \ + --hash=sha256:a3cd34b9025d62180ce2b5dae8a985bfa6cb8c05ecd57fd34ffc1ff751b5a74d \ + --hash=sha256:a739bf08cdca0fad51b73322e4fade0102dd87794e278450b5ee87ef827954db \ + --hash=sha256:a89d07e48d9baead9a15599923a02f62c6df6c3d85aa84ef34be3c9fd6aeb91f \ + --hash=sha256:acadbf2f2a18d7f9c7f119ac798c00c540d7c79c93abd71ed648c87891303633 \ + --hash=sha256:b1374099dd1ad0d31fbb6c95d00a56a3c5e85fb3343dca14fc12f78323a2b42a \ + --hash=sha256:b2af58ecdcec37fe633d4865fccbc8c00d8aa3b31c099bcacb2720c9a0be6ab9 \ + --hash=sha256:b37ad5cbb77776f446e1b55b461eec2eef5c3e7130c72dc0e1447c3a9da2d199 \ + --hash=sha256:b670bd5fa93d9b6855b2837217b45a90863118e2de5e9e033aebd46d07cd08d3 \ + --hash=sha256:b7dbbbf6551eb94618e7bc76ab61cc2740a5b3d13294171bd6adb36e12346c3c \ + --hash=sha256:bbf08d951abaa1ce89e28c998361d56b952413846b459cd017f116ad4c9adbfa \ + --hash=sha256:c1bcfe470a796fbea6234accd81d258a31574dc0b7bf569e16be757572c4de17 \ + --hash=sha256:c5297028c8df849a61b29129cadfe682f90b5b396f528eb319a57d7678eefdad \ + --hash=sha256:c5612cc20ca76abc883e50269af47c1494b42958bb63dbb9aa79729a1ab5f7d3 \ + --hash=sha256:c5feffce90c3d602e149de1c477578efc34dee5f069f9764cc15808ce01ee15c \ + --hash=sha256:c72c9b201dc0e8c2c8821d49858fd865010d08181bf877d2320971b6464ebfd5 \ + --hash=sha256:c76a9b50a344261fe4a9bd20c322b48d3913cc48e8c37f78c21a596008296e68 \ + --hash=sha256:c94ef980f7b94d9dab9dac076d44ca706654cd51bad19734e029084adf528c8e \ + --hash=sha256:cb953835dbfa6d641ac3943e0986bc680f8abbdc2985af15b46c54985347146a \ + --hash=sha256:cc12e5e32acdd62fe5895939695579560639853219288519685c75b7e968d63a \ + --hash=sha256:cce4dc8528453128c6fae523b15f3887fbea1d4d7c9eb9639d3d4fdcbe570c73 \ + --hash=sha256:cd1e85abed2d2499c16664137ac802356316f92b4e2bf3c150bdf0c45f5dd9ae \ + --hash=sha256:ce2ba5e9f1842fe09165825abfb3bc6b527c71a27bc2eb3a10f2284ced64506d \ + --hash=sha256:d1c77c3579ac42798f8b7eed6d3dd258debacca32c8753fc8a1f6eaf1db644f5 \ + --hash=sha256:d568a8adcec0eda42ec23e5e65dfb8c184fc255120f9e99b484f7c869d923fb9 \ + --hash=sha256:d9a218d3f9c7d6916684ed5ba94f620661117a730e733cd6ef5e87accc5872eb \ + --hash=sha256:dcd3dafcdd78305d27c59a1006b53a4990acb89e68d8fbe0992f4f83503c827f \ + --hash=sha256:de24c62bf798940a14674a47489a81b79915ec4134f556d5199830e065225dd0 \ + --hash=sha256:e40e323711b485592354069b1c027ef879cc2d11657eac09a6e5ad0b49ab7406 \ + --hash=sha256:e6b2b9599e7513b0a9c5bf0357f9f8deaa4c2c821025b0693d420e6602748981 \ + --hash=sha256:e9883a2f8206ce3af59117dc278e5d043fea06912bca3f199816129e5e2de354 \ + --hash=sha256:ebaf39dd13f8af65fe5f0316b81046228ef4d91d3c3766192b418753649896d6 \ + --hash=sha256:ed35097438dfa980c1ec75bc83edf8acbe7a374d7007e571957a257fbd0e2fb3 \ + --hash=sha256:edc2be98e6c55ccc5ff7832bb64f023a4b03dba39dfa84b850046cf08a8249b0 \ + --hash=sha256:f093faf23df888518d273be6da65f0ec5a25b5d8b670231e4d87de07361042e7 \ + --hash=sha256:f6c9c21a8bf0d19788f3c5f3e020c90317a0a63ef60521b376003801e21250fb \ + --hash=sha256:f98d438add63546745e5e847192e3e9ab897ed6f2ca96f8281e2f5a15958ae62 \ + --hash=sha256:f99d12f8234c00b88b8077fedf288b25c77f746de312053b7db90fa756ecbdb3 \ + --hash=sha256:fa4ff0b3dd52208d2b30903022d5087f82000507b504753dfeee83e4f32d6883 \ + --hash=sha256:fddd26ed9a2527a7e23f7e4c1fd0734c4a5b45f77b261da1c536b20a7d2e6f0c \ + --hash=sha256:fe5aa402d02318db2f41e471320b2ecca6085b8f595a034c037085732e49c04a + # via pytest-cov +cryptography==50.0.1 \ + --hash=sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 \ + --hash=sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 \ + --hash=sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6 \ + --hash=sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e \ + --hash=sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361 \ + --hash=sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054 \ + --hash=sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f \ + --hash=sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6 \ + --hash=sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49 \ + --hash=sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5 \ + --hash=sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149 \ + --hash=sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88 \ + --hash=sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad \ + --hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a \ + --hash=sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f \ + --hash=sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2 \ + --hash=sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20 \ + --hash=sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45 \ + --hash=sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f \ + --hash=sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b \ + --hash=sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527 \ + --hash=sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3 \ + --hash=sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6 \ + --hash=sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367 \ + --hash=sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0 \ + --hash=sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94 \ + --hash=sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239 \ + --hash=sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b \ + --hash=sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a \ + --hash=sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9 \ + --hash=sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5 \ + --hash=sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc \ + --hash=sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648 \ + --hash=sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986 \ + --hash=sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959 \ + --hash=sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0 \ + --hash=sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17 \ + --hash=sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e \ + --hash=sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733 \ + --hash=sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f \ + --hash=sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8 \ + --hash=sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf \ + --hash=sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671 \ + --hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \ + --hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \ + --hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef + # via pyjwt +execnet==2.1.2 \ + --hash=sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd \ + --hash=sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec + # via pytest-xdist +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via + # httpcore + # uvicorn +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via mcp +httpx-sse==0.4.3 \ + --hash=sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc \ + --hash=sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d + # via mcp +idna==3.19 \ + --hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \ + --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 + # via + # anyio + # httpx +iniconfig==2.3.0 \ + --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ + --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 + # via pytest +jsonschema==4.26.0 \ + --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce + # via + # loopx (pyproject.toml) + # mcp +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ + --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d + # via jsonschema +librt==0.15.0 \ + --hash=sha256:04d5387b908676c0b8d5d2f5fb58373b4ea382d81f7a6f0fab8ea2a462bb4738 \ + --hash=sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302 \ + --hash=sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad \ + --hash=sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08 \ + --hash=sha256:0e2d0c0acf5b0ada7d045912b7cf787c21315c95b38b1fa939ef72d45d366b3d \ + --hash=sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785 \ + --hash=sha256:1172c6ad2a88b646e7fe3b480e3fac4ab4418b3443fd8a4061fdd531e0622fc7 \ + --hash=sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9 \ + --hash=sha256:1a1a8cd430c7dd0c083f455cb1b328d7fc682b05c31b940906f7845bdff80881 \ + --hash=sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890 \ + --hash=sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2 \ + --hash=sha256:1f4ef2e71db33df4309167ed7f1520c4fae5e611226e159fa9cf33f93e6ddb3d \ + --hash=sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab \ + --hash=sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13 \ + --hash=sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2 \ + --hash=sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56 \ + --hash=sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b \ + --hash=sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389 \ + --hash=sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6 \ + --hash=sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d \ + --hash=sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d \ + --hash=sha256:355e3a4c725225a14262004fc1872a552b9d3634b4f791a0dfc80804aafbfd55 \ + --hash=sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95 \ + --hash=sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714 \ + --hash=sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416 \ + --hash=sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54 \ + --hash=sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae \ + --hash=sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28 \ + --hash=sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d \ + --hash=sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40 \ + --hash=sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47 \ + --hash=sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19 \ + --hash=sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162 \ + --hash=sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c \ + --hash=sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8 \ + --hash=sha256:52e8db01f603f5da0ca30987479acff98769382efc8e142fa3962395dcf3ffdb \ + --hash=sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa \ + --hash=sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc \ + --hash=sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988 \ + --hash=sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451 \ + --hash=sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f \ + --hash=sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81 \ + --hash=sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d \ + --hash=sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95 \ + --hash=sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605 \ + --hash=sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108 \ + --hash=sha256:68242379c9b65a582b6e97318a1e9fbd6d445e58954f2d437991c4804ab11578 \ + --hash=sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf \ + --hash=sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db \ + --hash=sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8 \ + --hash=sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a \ + --hash=sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965 \ + --hash=sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b \ + --hash=sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218 \ + --hash=sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9 \ + --hash=sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd \ + --hash=sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b \ + --hash=sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d \ + --hash=sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622 \ + --hash=sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022 \ + --hash=sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60 \ + --hash=sha256:80811e1c42386ea95c6fb30571d3250ad43d7863f883f787f70517f441150e59 \ + --hash=sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef \ + --hash=sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52 \ + --hash=sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22 \ + --hash=sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8 \ + --hash=sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb \ + --hash=sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca \ + --hash=sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc \ + --hash=sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239 \ + --hash=sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0 \ + --hash=sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0 \ + --hash=sha256:88c2a17815c266e6d8180204ff62cb739ab869ada4a746d4c505331526ac58f1 \ + --hash=sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa \ + --hash=sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e \ + --hash=sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3 \ + --hash=sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0 \ + --hash=sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915 \ + --hash=sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953 \ + --hash=sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65 \ + --hash=sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0 \ + --hash=sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd \ + --hash=sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1 \ + --hash=sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570 \ + --hash=sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c \ + --hash=sha256:a5fa8f1f916988d0bf1afea005bda37f56ac41a18016e813ccf0097a8d460ca4 \ + --hash=sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101 \ + --hash=sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801 \ + --hash=sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1 \ + --hash=sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702 \ + --hash=sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2 \ + --hash=sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab \ + --hash=sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8 \ + --hash=sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18 \ + --hash=sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053 \ + --hash=sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c \ + --hash=sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162 \ + --hash=sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81 \ + --hash=sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a \ + --hash=sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63 \ + --hash=sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc \ + --hash=sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1 \ + --hash=sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656 \ + --hash=sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d \ + --hash=sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9 \ + --hash=sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad \ + --hash=sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879 \ + --hash=sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d \ + --hash=sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3 \ + --hash=sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328 \ + --hash=sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659 \ + --hash=sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285 \ + --hash=sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26 \ + --hash=sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9 \ + --hash=sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1 \ + --hash=sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6 \ + --hash=sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c \ + --hash=sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489 \ + --hash=sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38 \ + --hash=sha256:e4c911f15a1652ca94ae9f1abd92e74cbb1b3597d2d92fdd556202f94e8cd455 \ + --hash=sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b \ + --hash=sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e \ + --hash=sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21 \ + --hash=sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b \ + --hash=sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374 \ + --hash=sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa \ + --hash=sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8 \ + --hash=sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab \ + --hash=sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15 \ + --hash=sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091 \ + --hash=sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993 \ + --hash=sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db \ + --hash=sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a \ + --hash=sha256:f9ca190fe9edc0eb08eec558a509a16d28d91c35667b8f043cba40ed5e77a959 \ + --hash=sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9 \ + --hash=sha256:fc1ed11c4ad0b91af24def2050f2840ea4567828e3dd058fbe608d982f6e5465 \ + --hash=sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d + # via mypy +mcp==1.28.1 \ + --hash=sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df \ + --hash=sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683 + # via loopx (pyproject.toml) +mypy==1.20.2 \ + --hash=sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb \ + --hash=sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98 \ + --hash=sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99 \ + --hash=sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100 \ + --hash=sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744 \ + --hash=sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f \ + --hash=sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609 \ + --hash=sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6 \ + --hash=sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c \ + --hash=sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30 \ + --hash=sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4 \ + --hash=sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b \ + --hash=sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558 \ + --hash=sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2 \ + --hash=sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102 \ + --hash=sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc \ + --hash=sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c \ + --hash=sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2 \ + --hash=sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517 \ + --hash=sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58 \ + --hash=sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997 \ + --hash=sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6 \ + --hash=sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c \ + --hash=sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8 \ + --hash=sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14 \ + --hash=sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec \ + --hash=sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee \ + --hash=sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066 \ + --hash=sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563 \ + --hash=sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330 \ + --hash=sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67 \ + --hash=sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15 \ + --hash=sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac \ + --hash=sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3 \ + --hash=sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254 \ + --hash=sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3 \ + --hash=sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4 \ + --hash=sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9 \ + --hash=sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382 \ + --hash=sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943 \ + --hash=sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924 \ + --hash=sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665 \ + --hash=sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026 \ + --hash=sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd + # via loopx (pyproject.toml) +mypy-extensions==1.1.0 \ + --hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \ + --hash=sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558 + # via mypy +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via + # build + # pytest +pathspec==1.1.1 \ + --hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \ + --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 + # via mypy +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via + # pytest + # pytest-cov +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +pydantic==2.13.5 \ + --hash=sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73 \ + --hash=sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08 + # via + # mcp + # pydantic-settings +pydantic-core==2.46.5 \ + --hash=sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942 \ + --hash=sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821 \ + --hash=sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5 \ + --hash=sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c \ + --hash=sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184 \ + --hash=sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2 \ + --hash=sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc \ + --hash=sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5 \ + --hash=sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0 \ + --hash=sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931 \ + --hash=sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e \ + --hash=sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25 \ + --hash=sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d \ + --hash=sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6 \ + --hash=sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47 \ + --hash=sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038 \ + --hash=sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8 \ + --hash=sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b \ + --hash=sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a \ + --hash=sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e \ + --hash=sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074 \ + --hash=sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0 \ + --hash=sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433 \ + --hash=sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f \ + --hash=sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034 \ + --hash=sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21 \ + --hash=sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736 \ + --hash=sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0 \ + --hash=sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7 \ + --hash=sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c \ + --hash=sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4 \ + --hash=sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c \ + --hash=sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c \ + --hash=sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069 \ + --hash=sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb \ + --hash=sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061 \ + --hash=sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29 \ + --hash=sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3 \ + --hash=sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa \ + --hash=sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e \ + --hash=sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38 \ + --hash=sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f \ + --hash=sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b \ + --hash=sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8 \ + --hash=sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e \ + --hash=sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c \ + --hash=sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be \ + --hash=sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6 \ + --hash=sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793 \ + --hash=sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1 \ + --hash=sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b \ + --hash=sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64 \ + --hash=sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f \ + --hash=sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761 \ + --hash=sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d \ + --hash=sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168 \ + --hash=sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869 \ + --hash=sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111 \ + --hash=sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5 \ + --hash=sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b \ + --hash=sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7 \ + --hash=sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129 \ + --hash=sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e \ + --hash=sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655 \ + --hash=sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c \ + --hash=sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec \ + --hash=sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689 \ + --hash=sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f \ + --hash=sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf \ + --hash=sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea \ + --hash=sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9 \ + --hash=sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464 \ + --hash=sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519 \ + --hash=sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b \ + --hash=sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f \ + --hash=sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee \ + --hash=sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a \ + --hash=sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e \ + --hash=sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6 \ + --hash=sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2 \ + --hash=sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f \ + --hash=sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a \ + --hash=sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f \ + --hash=sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a \ + --hash=sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47 \ + --hash=sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda \ + --hash=sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0 \ + --hash=sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4 \ + --hash=sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d \ + --hash=sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3 \ + --hash=sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2 \ + --hash=sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355 \ + --hash=sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461 \ + --hash=sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed \ + --hash=sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f \ + --hash=sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5 \ + --hash=sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2 \ + --hash=sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829 \ + --hash=sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0 \ + --hash=sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266 \ + --hash=sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575 \ + --hash=sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290 \ + --hash=sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f \ + --hash=sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62 \ + --hash=sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f \ + --hash=sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a \ + --hash=sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7 \ + --hash=sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed \ + --hash=sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f \ + --hash=sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1 \ + --hash=sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615 \ + --hash=sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8 \ + --hash=sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3 \ + --hash=sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a \ + --hash=sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff \ + --hash=sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084 \ + --hash=sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0 \ + --hash=sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3 \ + --hash=sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13 \ + --hash=sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9 + # via pydantic +pydantic-settings==2.15.0 \ + --hash=sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42 \ + --hash=sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117 + # via mcp +pygments==2.21.0 \ + --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \ + --hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c + # via pytest +pyjwt==2.13.0 \ + --hash=sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423 \ + --hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728 + # via mcp +pyproject-hooks==1.2.0 \ + --hash=sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8 \ + --hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 + # via build +pytest==9.1.1 \ + --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \ + --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c + # via + # loopx (pyproject.toml) + # pytest-cov + # pytest-xdist +pytest-cov==6.3.0 \ + --hash=sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2 \ + --hash=sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749 + # via loopx (pyproject.toml) +pytest-xdist==3.8.0 \ + --hash=sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88 \ + --hash=sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1 + # via loopx (pyproject.toml) +python-dotenv==1.2.3 \ + --hash=sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9 \ + --hash=sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35 + # via pydantic-settings +python-multipart==0.0.32 \ + --hash=sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e \ + --hash=sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23 + # via mcp +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ + --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 + # via + # jsonschema + # jsonschema-specifications + # types-jsonschema +rpds-py==2026.6.3 \ + --hash=sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5 \ + --hash=sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680 \ + --hash=sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9 \ + --hash=sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538 \ + --hash=sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804 \ + --hash=sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf \ + --hash=sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4 \ + --hash=sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97 \ + --hash=sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6 \ + --hash=sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96 \ + --hash=sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a \ + --hash=sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187 \ + --hash=sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975 \ + --hash=sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f \ + --hash=sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703 \ + --hash=sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9 \ + --hash=sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127 \ + --hash=sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f \ + --hash=sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa \ + --hash=sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05 \ + --hash=sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171 \ + --hash=sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba \ + --hash=sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c \ + --hash=sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223 \ + --hash=sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4 \ + --hash=sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885 \ + --hash=sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698 \ + --hash=sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f \ + --hash=sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7 \ + --hash=sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed \ + --hash=sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f \ + --hash=sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf \ + --hash=sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e \ + --hash=sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f \ + --hash=sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24 \ + --hash=sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a \ + --hash=sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41 \ + --hash=sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc \ + --hash=sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d \ + --hash=sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146 \ + --hash=sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e \ + --hash=sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e \ + --hash=sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4 \ + --hash=sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12 \ + --hash=sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7 \ + --hash=sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261 \ + --hash=sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6 \ + --hash=sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5 \ + --hash=sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93 \ + --hash=sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7 \ + --hash=sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda \ + --hash=sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8 \ + --hash=sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342 \ + --hash=sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c \ + --hash=sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb \ + --hash=sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0 \ + --hash=sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77 \ + --hash=sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3 \ + --hash=sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885 \ + --hash=sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826 \ + --hash=sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617 \ + --hash=sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb \ + --hash=sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577 \ + --hash=sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80 \ + --hash=sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e \ + --hash=sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945 \ + --hash=sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90 \ + --hash=sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7 \ + --hash=sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0 \ + --hash=sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140 \ + --hash=sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822 \ + --hash=sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba \ + --hash=sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9 \ + --hash=sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4 \ + --hash=sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a \ + --hash=sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8 \ + --hash=sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf \ + --hash=sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4 \ + --hash=sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324 \ + --hash=sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53 \ + --hash=sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b \ + --hash=sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41 \ + --hash=sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9 \ + --hash=sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca \ + --hash=sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1 \ + --hash=sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d \ + --hash=sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690 \ + --hash=sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107 \ + --hash=sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2 \ + --hash=sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76 \ + --hash=sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d \ + --hash=sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af \ + --hash=sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6 \ + --hash=sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db \ + --hash=sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369 \ + --hash=sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd \ + --hash=sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911 \ + --hash=sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504 \ + --hash=sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a \ + --hash=sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9 \ + --hash=sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13 \ + --hash=sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc \ + --hash=sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278 \ + --hash=sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868 \ + --hash=sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2 \ + --hash=sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd \ + --hash=sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4 \ + --hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6 \ + --hash=sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9 \ + --hash=sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00 \ + --hash=sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f \ + --hash=sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e \ + --hash=sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442 \ + --hash=sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da \ + --hash=sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90 \ + --hash=sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef + # via + # jsonschema + # referencing +ruff==0.15.22 \ + --hash=sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f \ + --hash=sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296 \ + --hash=sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e \ + --hash=sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c \ + --hash=sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb \ + --hash=sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809 \ + --hash=sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8 \ + --hash=sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224 \ + --hash=sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64 \ + --hash=sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178 \ + --hash=sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576 \ + --hash=sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde \ + --hash=sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262 \ + --hash=sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697 \ + --hash=sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661 \ + --hash=sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf \ + --hash=sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a \ + --hash=sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74 + # via loopx (pyproject.toml) +setuptools==83.0.0 \ + --hash=sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef \ + --hash=sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3 + # via -r tests/requirements-stage2c-tools.in +sse-starlette==3.4.11 \ + --hash=sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade \ + --hash=sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453 + # via mcp +starlette==1.6.0 \ + --hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \ + --hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b + # via + # mcp + # sse-starlette +tomli==2.4.1 \ + --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ + --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \ + --hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \ + --hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \ + --hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \ + --hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \ + --hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \ + --hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \ + --hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \ + --hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \ + --hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \ + --hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \ + --hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \ + --hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \ + --hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \ + --hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \ + --hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \ + --hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \ + --hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \ + --hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \ + --hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \ + --hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \ + --hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \ + --hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \ + --hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \ + --hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \ + --hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \ + --hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \ + --hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \ + --hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \ + --hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \ + --hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \ + --hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \ + --hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \ + --hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \ + --hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \ + --hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \ + --hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \ + --hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \ + --hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \ + --hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \ + --hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \ + --hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \ + --hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \ + --hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \ + --hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \ + --hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049 + # via coverage +types-jsonschema==4.26.0.20260518 \ + --hash=sha256:30b30a518c7fe335df85c919fcbcc631b69c03d4a4b5b632fa916bea03065307 \ + --hash=sha256:e1dd53dc97a64f5eccdd6fa9839666e09bb500a8ebba2db6fdaf1789faea81a6 + # via loopx (pyproject.toml) +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # anyio + # mcp + # mypy + # pydantic + # pydantic-core + # referencing + # starlette + # typing-inspection +typing-inspection==0.4.4 \ + --hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \ + --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147 + # via + # mcp + # pydantic + # pydantic-settings +uvicorn==0.52.4 \ + --hash=sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86 \ + --hash=sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1 + # via mcp diff --git a/tests/requirements-stage2c-tools.in b/tests/requirements-stage2c-tools.in new file mode 100644 index 0000000000..8d0f0c6f0f --- /dev/null +++ b/tests/requirements-stage2c-tools.in @@ -0,0 +1,6 @@ +# Additional build tools for the Stage 2C Linux/Python 3.11 CI job. +# Test dependencies come from pyproject.toml's test extra. +# Regenerate requirements-stage2c-linux-py311.txt using uv==0.11.26 and the +# complete command recorded at the top of that generated lock file. +build==1.6.0 +setuptools==83.0.0 From 9f1fecf56d6fcc85460c5393eacfb7e5f8fa4ca2 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Sun, 6 Sep 2026 16:22:40 +1000 Subject: [PATCH 06/27] fix(coordination): fence native Todo updates during management Signed-off-by: wchwawa --- .../shared-goal-authority-e2e/correctness.md | 17 +- examples/shared-goal-authority-e2e/mutants.py | 15 + .../coordination/local_authority_runtime.ts | 42 +-- .../test_shadow_native_todo_update_e2e.py | 298 ++++++++++++++++++ 4 files changed, 350 insertions(+), 22 deletions(-) create mode 100644 tests/control_plane/test_shadow_native_todo_update_e2e.py diff --git a/examples/shared-goal-authority-e2e/correctness.md b/examples/shared-goal-authority-e2e/correctness.md index 88b6934adb..15e40aa445 100644 --- a/examples/shared-goal-authority-e2e/correctness.md +++ b/examples/shared-goal-authority-e2e/correctness.md @@ -1,6 +1,6 @@ # Bounded file outbox correctness -An active shadow captures each canonical primary mutation once. A cursor is a +An active shadow captures each mutation of its bound primary once. A cursor is a position hint: only the actual file provider's committed transaction and receipt can authorize deletion of an outbox file. Qualification folds the complete baseline and transaction history, then compares the current Todo, handoff mode, @@ -90,6 +90,12 @@ ordinary writers release primary locks before drain. Legacy fence engagement also takes the actual source S and therefore requires its absolute state path in the internal RPC request. +Canonical FileAuthorityStore Todo updates, including compatibility v0 records +already held by that authority, use the same M and maintenance boundary as +canonical Todo creation. They retain their own transaction receipts and do not +produce legacy shadow captures. A waiting update rechecks management state after +acquiring M; invalid or unfinished management state holds before any commit. + Drain checks the real receipt first, persists the cursor second, and reclaims each individually verified file last. Missing cursors can be reconstructed from complete continuous history; malformed or unreadable cursors are never silently @@ -116,7 +122,7 @@ Install the repository test extra and Node dependencies. Run the long tests separately without skip or relaxation flags: ```bash -python -m pip install -e '.[test]' 'build>=1,<2' +python -m pip install -e '.[test]' 'build==1.6.0' npm ci --ignore-scripts python -m pytest -q -m stage2c_e2e --junitxml=stage2c-e2e.xml python examples/shared-goal-authority-e2e/mutants.py --output .local/stage2c-mutants @@ -133,6 +139,12 @@ python examples/control_plane/cli-output-budget-regression-smoke.py loopx --format json canary premerge --from-git-diff ``` +The Linux/Python 3.11 CI job uses the exact dependency versions and hashes in +`tests/requirements-stage2c-linux-py311.txt`, including pytest 9.1.1. It builds +the checked-out source wheel, verifies its hash during installation, and removes +its generated build tree before normal pytest discovery. The workflow records +the complete installation sequence and retains normal source discovery. + The full TS suite's PostgreSQL conformance requires `LOOPX_TEST_POSTGRES_URL` pointing to a disposable test database. Source smoke success does not replace installed-package evidence: the package runner creates an empty environment, @@ -146,6 +158,7 @@ Python/TS/JSON provenance, and reads back through an independent native process. | Primary and drain process death, lost ACK, prepared-only A → B → A | `test_shadow_drain_e2e.py`, `test_shadow_management_e2e.py` | | Every bootstrap/rollback durable window, raw archive fidelity, late requests and other-Goal isolation | `shadow_management.test.ts`, `test_shadow_management_e2e.py` | | Fence and maintenance boundaries, source override races, whole-file durability, paragraph injection, refresh CAS | `test_shadow_writer_boundaries.py`, `shadow_native_writer_boundary.test.ts`, `test_shadow_drain_adversarial.py` | +| Canonical native/v0 Todo updates through CLI and native RPC, real pending management, M ordering, and unchanged authority on hold | `test_shadow_native_todo_update_e2e.py` | | History flaws despite equal snapshots, legacy mixed profile, source drift, event-only hold, qualified reads | `coordination_runtime_shadow.test.ts`, `file_outbox_qualification.test.ts`, `test_runtime_shadow_bounded_e2e.py` | | Installed lifecycle and resource provenance in wheel and sdist | `installed.py` | | Missing checks, lock placement, duplicate mirror, early marker, cursor regression | `mutants.py` with unchanged GREEN controls and assertion RED results | diff --git a/examples/shared-goal-authority-e2e/mutants.py b/examples/shared-goal-authority-e2e/mutants.py index f31554ad5c..b3a076bf9d 100644 --- a/examples/shared-goal-authority-e2e/mutants.py +++ b/examples/shared-goal-authority-e2e/mutants.py @@ -95,6 +95,18 @@ def remove_fence(source: str) -> str: return "".join(lines[:function.body[0].lineno - 1]) + " return\n" + "".join(lines[function.end_lineno:]) +def remove_native_update_maintenance(source: str) -> str: + start = source.index("export async function updateLocalCoordinationTodo(") + end = source.index("export async function editLocalCoordinationTodo(", start) + function = source[start:end] + function = replacement( + "return await withCanonicalWriter(root, goalId, input.dry_run === true, async () => {", + "return await (async () => {", + )(function) + function = replacement(" });\n } catch (error) {", " })();\n } catch (error) {")(function) + return source[:start] + function + source[end:] + + def move_guard_outside_lock(name: str) -> Callable[[str], str]: def apply(source: str) -> str: function = next(node for node in ast.parse(source).body @@ -120,6 +132,9 @@ def apply(source: str) -> str: WRITER_TEST = "tests/control_plane/test_shadow_writer_boundaries.py::" FENCE_TEST = WRITER_TEST + "test_cli_waiting_for_todo_mutex_rechecks_fence_after_engagement" CASES.extend([ + Case("native_update_maintenance", ((COORDINATION + "local_authority_runtime.ts", + remove_native_update_maintenance),), + "tests/control_plane/test_shadow_native_todo_update_e2e.py::test_native_update_holds_before_primary_for_management[native-bootstrapping-cli]"), Case("remove_fence", ((COORDINATION + "legacy_writer_fence.py", remove_fence),), FENCE_TEST), Case("fence_outside_lock", ((COORDINATION + "legacy_writer_fence.py", move_guard_outside_lock("require_legacy_coordination_write_allowed")),), FENCE_TEST), diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index aeac12c68c..f85a4aface 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -700,30 +700,32 @@ export async function updateLocalCoordinationTodo( if (input.schema_version !== COORDINATION_TODO_UPDATE_REQUEST_SCHEMA) { throw new TypeError("local coordination Todo update request schema mismatch"); } - if (!Array.isArray(input.registered_agents) || !Array.isArray(input.clear_fields)) { - throw new TypeError("registered_agents and clear_fields must be JSON arrays"); - } const root = runtimeRoot(input.runtime_root); const goalId = requireAuthorityStoreId(input.goal_id, "goal id"); - const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? - new FileAuthorityStore(authorityDirectory(root), goalId); - return {...await executeCoordinationTodoUpdate(store, { - goal_id: goalId, todo_id: requireAuthorityStoreId(input.todo_id, "todo id"), - expected_role: input.role === null || input.role === undefined ? null : - requireAuthorityStoreId(input.role, "role"), - actor_agent_id: input.actor_agent_id === null || input.actor_agent_id === undefined ? null : - claimAgentValue(input.actor_agent_id, "actor_agent_id"), - registered_agents: input.registered_agents.map((agent) => - claimAgentValue(agent, "registered agent")), - operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), - patch: requireJsonObject(input.patch, "Todo update patch"), - clear_fields: input.clear_fields.map((field) => claimAgentValue(field, "clear field")), - dry_run: input.dry_run as boolean, - now: claimObservedAt(input.observed_at), - }), ...providerEvidence}; + return await withCanonicalWriter(root, goalId, input.dry_run === true, async () => { + if (!Array.isArray(input.registered_agents) || !Array.isArray(input.clear_fields)) { + throw new TypeError("registered_agents and clear_fields must be JSON arrays"); + } + const store = dependencies.createStore?.(authorityDirectory(root), goalId) ?? + new FileAuthorityStore(authorityDirectory(root), goalId); + return {...await executeCoordinationTodoUpdate(store, { + goal_id: goalId, todo_id: requireAuthorityStoreId(input.todo_id, "todo id"), + expected_role: input.role === null || input.role === undefined ? null : + requireAuthorityStoreId(input.role, "role"), + actor_agent_id: input.actor_agent_id === null || input.actor_agent_id === undefined ? null : + claimAgentValue(input.actor_agent_id, "actor_agent_id"), + registered_agents: input.registered_agents.map((agent) => + claimAgentValue(agent, "registered agent")), + operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), + patch: requireJsonObject(input.patch, "Todo update patch"), + clear_fields: input.clear_fields.map((field) => claimAgentValue(field, "clear field")), + dry_run: input.dry_run as boolean, + now: claimObservedAt(input.observed_at), + }), ...providerEvidence}; + }); } catch (error) { return {schema_version: COORDINATION_TODO_UPDATE_RESULT_SCHEMA, status: "failed", - changed: false, reason_code: "invalid_local_coordination_todo_update_request", + changed: false, reason_code: error instanceof ShadowManagementError ? error.reason_code : "invalid_local_coordination_todo_update_request", reason: error instanceof Error ? error.message : "invalid Todo update request", ...providerEvidence}; } diff --git a/tests/control_plane/test_shadow_native_todo_update_e2e.py b/tests/control_plane/test_shadow_native_todo_update_e2e.py new file mode 100644 index 0000000000..f21ce584d8 --- /dev/null +++ b/tests/control_plane/test_shadow_native_todo_update_e2e.py @@ -0,0 +1,298 @@ +"""Canonical Todo updates share the durable management boundary. + +The CLI and native adapter use real file providers. Scheduling seams only pause +real commits or management effects; they never replace their results. Pending +journals are produced by the management primitive, independently of promotion: +these tests do not claim that shadow bootstrap can promote a canonical provider. +""" +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import json +from pathlib import Path +import select +import subprocess +import sys + +import pytest + +from canonical_authority_fixture import initialize_canonical_authority +from loopx.control_plane.coordination.coordination_state_contract import ( + TODO_DOMAIN_ITEM_SCHEMA_VERSION, + TODO_DOMAIN_READ_RECORD_SCHEMA_VERSION, + TODO_DOMAIN_RECORD_FIELDS, +) +from loopx.control_plane.coordination.local_authority_shadow_projection import canonical_bytes +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.coordination.shadow_management import ( + read_shadow_management_state, + shadow_management_state_path, +) +from loopx.control_plane.effect_runtime import effect_runtime_result + +REPO = Path(__file__).resolve().parents[2] +GOAL, TODO = "goal-update", "todo_update_probe" +pytestmark = pytest.mark.stage2c_e2e + + +@dataclass +class Workspace: + runtime: Path + registry: Path + state: Path + projection: dict + + def command(self, text: str = "Updated through the public CLI") -> list[str]: + return [sys.executable, "-m", "loopx.cli", "--format", "json", + "--registry", str(self.registry), "--runtime-root", str(self.runtime), + "todo", "update", "--goal-id", GOAL, "--todo-id", TODO, + "--agent-id", "agent-a", "--text", text] + + def request(self, **changes: object) -> dict: + return {"schema_version": "loopx_local_coordination_todo_update_request_v0", + "runtime_root": str(self.runtime), "goal_id": GOAL, "todo_id": TODO, + "role": "agent", "actor_agent_id": "agent-a", "registered_agents": ["agent-a", "agent-b"], + "operation_id": "native-update-operation", "patch": {"text": "Updated through native RPC"}, + "clear_fields": [], "dry_run": False, "observed_at": "2026-09-06T06:00:00Z", **changes} + + +@pytest.fixture(params=["native", "compat_v0"]) +def workspace(tmp_path: Path, request: pytest.FixtureRequest) -> Workspace: + runtime = tmp_path / "runtime" + state = tmp_path / "ACTIVE_GOAL_STATE.md" + state.write_text("# Canonical display is not a transaction input.\n", encoding="utf-8") + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"schema_version": 1, "common_runtime_root": str(runtime), + "goals": [{"id": GOAL, "repo": str(tmp_path), "state_file": state.name, + "coordination": {"registered_agents": ["agent-a", "agent-b"]}}]}), encoding="utf-8") + todo = {"schema_version": TODO_DOMAIN_ITEM_SCHEMA_VERSION, "todo_id": TODO, + "text": "Original provider text", "role": "agent", "status": "open", "done": False, + "archive_state": "active", "claimed_by": "agent-a", "note": "Keep the note", + "required_capabilities": ["code_review"], "excluded_agents": ["agent-b"], + "evidence": "Complete provider metadata"} + if request.param == "native": + projection = {"goal_id": GOAL, "handoff_mode": "soft_claim", "todos": [todo], "leases": [], + "todo_read_model": {"schema_version": TODO_DOMAIN_READ_RECORD_SCHEMA_VERSION, "todo_count": 1, + "records_sha256": hashlib.sha256(canonical_bytes([todo])).hexdigest(), + "contract_fields": list(TODO_DOMAIN_RECORD_FIELDS)}} + else: + todo.update(schema_version="todo_item_v0", index=7, source_section="Agent Todo") + projection = build_todo_runtime_shadow_projection(goal_id=GOAL, todos=[todo], handoff_mode="soft_claim") + initialize_canonical_authority(runtime, GOAL, projection, state_path=state) + state.unlink() # Neither update route may require or recreate Markdown. + return Workspace(runtime, registry, state, projection) + + +NODE = r""" +import fs from 'node:fs'; +import {syncBuiltinESMExports} from 'node:module'; +import {once} from 'node:events'; +import {join} from 'node:path'; +const input = JSON.parse(process.argv[1]); +const base = new URL(input.module_base); +const {FileAuthorityStore} = await import(new URL('file_authority_store.ts', base)); +const {updateLocalCoordinationTodo} = await import(new URL('local_authority_runtime.ts', base)); +const management = await import(new URL('shadow_management.ts', base)); +const store = new FileAuthorityStore(join(input.request.runtime_root, 'authority', 'file-v0'), input.request.goal_id, {existingOnly:true}); +const barrier = async (phase) => {process.stdout.write('BARRIER ' + phase + '\n'); await once(process.stdin, 'data');}; +if (input.mode.endsWith('_wait')) { + const actualOpen = fs.promises.open; + let notified = false; + const lock = management.shadowMaintenanceLockPath(input.request.runtime_root, input.request.goal_id) + '.ts-effect.lock'; + fs.promises.open = async (path, flags, ...args) => { + if (String(path) === lock && flags === 'wx' && !notified) { + notified = true; process.stdout.write('BARRIER lock-attempt\n'); + } + return await actualOpen(path, flags, ...args); + }; + syncBuiltinESMExports(); +} +let result; +if (input.mode === 'inspect') { + result = {head:await store.loadAuthority(), receipt:input.operation_id ? await store.readReceipt(input.operation_id) : null, + document_path:store.path}; +} else if (input.mode.startsWith('update')) { + if (input.mode === 'update_paused') { + const actualCommit = store.commitAuthority.bind(store); + store.commitAuthority = async (commit) => {await barrier('commit'); return await actualCommit(commit);}; + } + result = await updateLocalCoordinationTodo(input.request, {createStore:() => store}); +} else { + const dependencies = {withPrimaryLocks:async (fn) => await fn(), verifySourceSnapshot:async () => {}, + afterEffect:async (phase) => {if (phase === input.stop_at) await barrier(phase);}}; + result = input.mode.startsWith('bootstrap') ? await management.bootstrapManagedShadow(input.request, dependencies) + : await management.rollbackManagedShadow(input.request, dependencies); +} +process.stdout.write(JSON.stringify(result) + '\n'); +""" + + +def node_command(mode: str, request: dict, **options: object) -> list[str]: + base = (REPO / "loopx/control_plane/coordination").as_uri() + "/" + return ["node", "--no-warnings", "--experimental-strip-types", "--input-type=module", "-e", NODE, + json.dumps({"mode": mode, "request": request, "module_base": base, **options})] + + +def inspect(workspace: Workspace, operation_id: str | None = None) -> dict: + result = subprocess.run(node_command("inspect", workspace.request(), operation_id=operation_id), + cwd=REPO, capture_output=True, text=True, check=True, timeout=20) + value = json.loads(result.stdout) + assert value["head"]["status"] == "loaded", value + return value + + +def invoke(workspace: Workspace, transport: str, **changes: object) -> dict: + if transport == "rpc": + return effect_runtime_result("coordination.local_authority.todo_update", workspace.request(**changes)) + args = workspace.command() + if changes.get("dry_run"): + args.append("--dry-run") + result = subprocess.run(args, cwd=REPO, capture_output=True, text=True, timeout=20) + assert "Traceback" not in result.stderr, result.stderr + return json.loads(result.stdout) + + +def bootstrap_request(workspace: Workspace) -> dict: + return {"runtime_root": str(workspace.runtime), "goal_id": GOAL, "operation_id": "maintenance-bootstrap", + "source_version": "fixture-source", "source_snapshot": {"state_path": str(workspace.state)}, + "projection": workspace.projection} + + +def start(command: list[str]) -> subprocess.Popen[str]: + return subprocess.Popen(command, cwd=REPO, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + + +def expect_barrier(child: subprocess.Popen[str], phase: str) -> None: + assert child.stdout is not None + ready, _, _ = select.select([child.stdout], [], [], 10) + assert ready, "real process did not reach the scheduling boundary" + line = child.stdout.readline().strip() + assert line == "BARRIER " + phase, line + + +def stop(child: subprocess.Popen[str]) -> None: + if child.poll() is None: + child.kill() + child.communicate(timeout=10) + + +def pending(workspace: Workspace, kind: str) -> subprocess.Popen[str]: + request = bootstrap_request(workspace) + if kind == "rolling_back": + applied = subprocess.run(node_command("bootstrap", request), cwd=REPO, + capture_output=True, text=True, check=True, timeout=20) + seed = json.loads(applied.stdout) + assert seed["status"] == "applied", seed + request = {"runtime_root": str(workspace.runtime), "goal_id": GOAL, "operation_id": "maintenance-rollback", + "expected_provider_revision": seed["provider_revision"]} + mode = "rollback" if kind == "rolling_back" else "bootstrap" + phase = mode + "_prepared" + child = start(node_command(mode, request, stop_at=phase)) + try: + expect_barrier(child, phase) + assert read_shadow_management_state(workspace.runtime, GOAL)["status"] == kind + return child + except BaseException: + stop(child) + raise + + +@pytest.mark.parametrize("transport", ["cli", "rpc"]) +def test_native_update_preserves_complete_records_and_exact_receipts(workspace: Workspace, transport: str) -> None: + before = inspect(workspace) + preview = invoke(workspace, transport, dry_run=True) + assert preview["status"] == "planned", preview + assert inspect(workspace)["head"] == before["head"] + result = invoke(workspace, transport) + assert result["status"] == "applied", result + receipt = result["original_receipt"] + after = inspect(workspace, receipt["operation_id"]) + assert after["receipt"]["status"] == "found" + assert after["receipt"]["receipts"] == [receipt] + assert after["receipt"]["provider_revision"] == result["provider_revision"] == after["head"]["provider_revision"] + original = before["head"]["head"]["todos"][0] + updated = after["head"]["head"]["todos"][0] + assert updated == {**original, "text": "Updated through the public CLI" if transport == "cli" else "Updated through native RPC", + "last_actor_agent_id": "agent-a", "updated_at": updated["updated_at"]} + assert after["head"]["head"]["todo_read_model"]["schema_version"] == before["head"]["head"]["todo_read_model"]["schema_version"] + assert after["head"]["head"]["leases"] == [] + assert result["legacy_fallback_used"] is False + assert not workspace.state.exists() + assert not (workspace.runtime / "authority-shadow").exists() + if transport == "rpc": + replay = invoke(workspace, transport) + assert replay["status"] == "replayed" + assert replay["original_receipt"] == receipt + assert inspect(workspace)["head"] == after["head"] + + +@pytest.mark.parametrize("transport", ["cli", "rpc"]) +@pytest.mark.parametrize("management", ["corrupt", "bootstrapping", "rolling_back"]) +def test_native_update_holds_before_primary_for_management(workspace: Workspace, transport: str, management: str) -> None: + if management == "corrupt": + path = shadow_management_state_path(workspace.runtime, GOAL) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{corrupt", encoding="utf-8") + else: + child = pending(workspace, management) + stop(child) # The actual durable intent survives a real SIGKILL. + assert child.returncode == -9 + before = inspect(workspace) + document = Path(before["document_path"]) + original_bytes = document.read_bytes() + management_path = shadow_management_state_path(workspace.runtime, GOAL) + management_bytes = management_path.read_bytes() + result = invoke(workspace, transport) + expected = "shadow_management_state_invalid" if management == "corrupt" else "shadow_management_in_progress" + assert result.get("error_code", result.get("reason_code")) == expected, result + assert document.read_bytes() == original_bytes + assert management_path.read_bytes() == management_bytes + assert inspect(workspace, "native-update-operation")["receipt"]["status"] == "missing" + assert not workspace.state.exists() + + +def test_native_update_waits_for_management_then_rechecks_intent(workspace: Workspace) -> None: + manager = pending(workspace, "bootstrapping") + writer = start(node_command("update_wait", workspace.request())) + try: + expect_barrier(writer, "lock-attempt") + before = inspect(workspace) + assert writer.poll() is None + stop(manager) + output, error = writer.communicate(timeout=20) + assert writer.returncode == 0, output + error + result = json.loads(output) + assert result["reason_code"] == "shadow_management_in_progress", result + assert inspect(workspace)["head"] == before["head"] + finally: + stop(writer) + stop(manager) + + +def test_native_update_retains_maintenance_lock_through_actual_commit(workspace: Workspace) -> None: + writer = start(node_command("update_paused", workspace.request())) + manager = None + try: + expect_barrier(writer, "commit") + before = inspect(workspace) + manager = start(node_command("bootstrap_wait", bootstrap_request(workspace), stop_at="bootstrap_prepared")) + expect_barrier(manager, "lock-attempt") + # An actual manager attempts M while the provider commit is paused. + ready, _, _ = select.select([manager.stdout], [], [], .2) + assert not ready, "management published an intent before the native commit released M" + assert read_shadow_management_state(workspace.runtime, GOAL) is None + output, error = writer.communicate("continue\n", timeout=20) + assert writer.returncode == 0, output + error + result = json.loads(output) + assert result["status"] == "applied", result + expect_barrier(manager, "bootstrap_prepared") + after = inspect(workspace, result["original_receipt"]["operation_id"]) + assert after["receipt"]["status"] == "found" + assert after["head"]["provider_revision"] != before["head"]["provider_revision"] + assert read_shadow_management_state(workspace.runtime, GOAL)["status"] == "bootstrapping" + finally: + stop(writer) + if manager is not None: + stop(manager) From eb0b59960483c72d7070820bcfdbf9240ae34718 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Sun, 6 Sep 2026 21:09:48 +1000 Subject: [PATCH 07/27] fix(coordination): retain verified commits when cleanup fails Signed-off-by: wchwawa --- .../local_authority_shadow_adapter.py | 5 +-- .../test_shadow_drain_adversarial.py | 34 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/loopx/control_plane/coordination/local_authority_shadow_adapter.py b/loopx/control_plane/coordination/local_authority_shadow_adapter.py index 0fb18a673c..18e8be7006 100644 --- a/loopx/control_plane/coordination/local_authority_shadow_adapter.py +++ b/loopx/control_plane/coordination/local_authority_shadow_adapter.py @@ -789,8 +789,8 @@ def run(self) -> None: view, transactions = self._proof() if self._result.cursor_before is None: self._result.cursor_before = view.get("cursor") - pending = self._reconcile(transactions) self._record_view(view) + pending = self._reconcile(transactions) if not pending: return if self._budget.exhausted(): @@ -846,8 +846,9 @@ def run(self) -> None: "shadow_commit_entry_result_invalid", "ACK differs from exact receipt", ) - self._reconcile(transactions, delivered_entry_id=entry.entry_id) + # Preserve verified commit evidence even if local cleanup fails. self._record_view(view) + self._reconcile(transactions, delivered_entry_id=entry.entry_id) summary = { "entry_id": entry.entry_id, "partition": entry.partition, diff --git a/tests/control_plane/test_shadow_drain_adversarial.py b/tests/control_plane/test_shadow_drain_adversarial.py index 83f3a4cc84..3d5aca9a70 100644 --- a/tests/control_plane/test_shadow_drain_adversarial.py +++ b/tests/control_plane/test_shadow_drain_adversarial.py @@ -14,6 +14,40 @@ pytestmark = pytest.mark.stage2c_e2e +@pytest.mark.parametrize("window", ["before_commit", "after_commit", "after_cursor"]) +def test_cleanup_permission_failure_reports_verified_commit_and_recovers( + tmp_path: Path, window: str, +) -> None: + """A failed local checkpoint cannot hide a proven candidate transaction.""" + w = workspace(tmp_path) + w.crash(window, "todo", "add", "--role", "agent", "--text", "Durable despite cleanup failure") + directory = outbox.partition_directory(w.runtime, w.goal, "todos") + before = {path.name: path.read_bytes() for path in directory.iterdir()} + mode = directory.stat().st_mode & 0o777 + directory.chmod(0o500) + try: + # Exercise a real filesystem denial, with the provider still writable. + with pytest.raises(PermissionError): + (directory / "permission-control").write_bytes(b"must not be writable") + stopped = w.drain() + assert stopped["ok"] is False and stopped["reason_code"], stopped + assert {path.name: path.read_bytes() for path in directory.iterdir()} == before + view = adapter.read_local_authority_shadow(runtime_root=w.runtime, goal_id=w.goal, scan_limit=20) + assert len(view["proof"]["transactions"]) == 2 # Baseline and the actual mutation. + assert stopped["candidate_readback_verified"] is True, stopped + assert stopped["provider_revision"] == view["provider_revision"], stopped + finally: + directory.chmod(mode) + recovered = w.drain() + assert recovered["ok"] is True and recovered["replayed"] == 1, recovered + assert recovered["delivered"] == 0, recovered + assert outbox.read_cursor(directory)["last_seq"] == 1 + assert {path.name for path in directory.iterdir()} == {"drain-cursor.json"} + assert adapter.read_local_authority_shadow( + runtime_root=w.runtime, goal_id=w.goal, scan_limit=20, + )["proof"]["transactions"] == view["proof"]["transactions"] + + def test_missing_cursor_cannot_reuse_a_sequence_when_the_next_writer_arrives_first(tmp_path: Path) -> None: w = workspace(tmp_path) w.add("First complete transaction") From d64c2715f6fe51887eb71ca0238b9e27bdc52f56 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Sun, 6 Sep 2026 21:09:48 +1000 Subject: [PATCH 08/27] fix(coordination): protect declared sources across goal overrides Signed-off-by: wchwawa --- .../coordination/legacy_writer_fence.py | 48 ++- .../test_shadow_writer_variant_e2e.py | 302 ++++++++++++++++++ 2 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 tests/control_plane/test_shadow_writer_variant_e2e.py diff --git a/loopx/control_plane/coordination/legacy_writer_fence.py b/loopx/control_plane/coordination/legacy_writer_fence.py index 89263deb96..b64668d5fd 100644 --- a/loopx/control_plane/coordination/legacy_writer_fence.py +++ b/loopx/control_plane/coordination/legacy_writer_fence.py @@ -17,6 +17,8 @@ from ...file_lock import exclusive_cross_runtime_file_lock from ...history import load_registry from ...paths import resolve_runtime_root +from ...registry import registry_goals, resolve_state_file +from ..goals.active_state_metadata import parse_state_frontmatter from .shadow_management import ( ShadowManagementError, read_shadow_bootstrap_source_path, read_shadow_management_state, require_shadow_primary_write_allowed, ) @@ -61,6 +63,45 @@ def legacy_coordination_todo_lock_path(*, runtime_root: Path, goal_id: str) -> P ) +def _require_other_goal_source_write_allowed( + *, registry: dict[str, Any], runtime_roots: set[Path], goal_id: str, + state_file: Path, canonical_mutation: bool, +) -> None: + """A goal override cannot cancel the existing authority of this one source. + + The caller holds S. Reuse its frontmatter and current registry paths rather + than persisting another source index or acquiring another goal's locks. + Unbound legacy shared-state writes remain valid. + """ + + resolved_source = state_file.resolve(strict=False) + try: + owner = parse_state_frontmatter(state_file.read_text(encoding="utf-8")).get("goal_id") + except FileNotFoundError: + owner = None + owners = {owner} if owner else set() + for goal in registry_goals(registry): + repo, path = goal.get("repo"), goal.get("state_file") + if not isinstance(repo, str) or not isinstance(path, str): + continue + registered_source = resolve_state_file(Path(repo).expanduser(), path) + if registered_source is not None and registered_source.resolve(strict=False) == resolved_source: + owners.add(str(goal["id"])) + for owner in sorted(owners - {goal_id}): + for root in sorted(runtime_roots): + binding = require_shadow_primary_write_allowed(root, owner) + if not canonical_mutation: + continue + if binding is not None: + bound_source = read_shadow_bootstrap_source_path(root, owner, binding) + if bound_source.resolve(strict=False) == resolved_source: + raise ShadowManagementError( + "shadow_source_goal_mismatch", + "the state source has another goal's active capture binding; write through its goal", + ) + require_legacy_coordination_write_allowed(runtime_root=root, goal_id=owner) + + def require_registry_source_write_allowed( *, registry_path: Path, runtime_root: Path, goal_id: str, state_file: Path, canonical_mutation: bool = True, @@ -81,9 +122,14 @@ def require_registry_source_write_allowed( "the state file is not the source established by the active capture binding", ) registry = load_registry(registry_path) + registered_root = resolve_runtime_root(registry, None, registry_path=registry_path) + _require_other_goal_source_write_allowed( + registry=registry, + runtime_roots={runtime_root.expanduser().resolve(strict=False), registered_root.expanduser().resolve(strict=False)}, + goal_id=goal_id, state_file=state_file, canonical_mutation=canonical_mutation, + ) if not any(isinstance(goal, dict) and goal.get("id") == goal_id for goal in registry.get("goals", [])): return - registered_root = resolve_runtime_root(registry, None, registry_path=registry_path) if registered_root.expanduser().resolve(strict=False) == runtime_root.expanduser().resolve(strict=False): return binding = require_shadow_primary_write_allowed(registered_root, goal_id) diff --git a/tests/control_plane/test_shadow_writer_variant_e2e.py b/tests/control_plane/test_shadow_writer_variant_e2e.py new file mode 100644 index 0000000000..e26c156892 --- /dev/null +++ b/tests/control_plane/test_shadow_writer_variant_e2e.py @@ -0,0 +1,302 @@ +"""Public writer combinations with real persistence and scheduling-only seams.""" +from __future__ import annotations + +import json +from pathlib import Path +import select +import subprocess +import sys + +import pytest + +from shadow_e2e_fixture import ShadowWorkspace, workspace as make_workspace +from loopx.control_plane.coordination.coordination_state_contract_generated import TASK_LEASE_ACQUIRE_REQUEST_SCHEMA +from loopx.control_plane.coordination.legacy_writer_fence import legacy_coordination_writer_fence_path +from loopx.control_plane.work_items.task_lease_acquire_adapter import task_lease_acquire_authority_facts + +REPO = Path(__file__).resolve().parents[2] +pytestmark = pytest.mark.stage2c_e2e + + +def workspace(path: Path) -> ShadowWorkspace: + result = make_workspace(path, bootstrap=False) + result.state.write_text(result.state.read_text() + "\n## Progress Ledger\n\n## Next Action\n\n- Inspect.\n") + return result + + +def command(ws: ShadowWorkspace, *args: str, goal: str | None = None) -> list[str]: + return [sys.executable, "-m", "loopx.cli", "--registry", str(ws.registry), + "--runtime-root", str(ws.runtime), "--format", "json", *args, "--goal-id", goal or ws.goal] + + +def public(ws: ShadowWorkspace, *args: str, goal: str | None = None) -> dict: + result = subprocess.run(command(ws, *args, goal=goal), cwd=REPO, capture_output=True, text=True, timeout=30) + assert "Traceback" not in result.stderr, result.stderr + return json.loads(result.stdout) + + +def start(args: list[str]) -> subprocess.Popen[str]: + return subprocess.Popen(args, cwd=REPO, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + +def barrier(child: subprocess.Popen[str], phase: str) -> None: + assert child.stdout is not None + ready, _, _ = select.select([child.stdout], [], [], 10) + assert ready, "public writer did not reach its real scheduling boundary" + line = child.stdout.readline().strip() + assert line == "BARRIER " + phase, line + + +def finish(child: subprocess.Popen[str], *, resume: bool = False) -> dict: + output, error = child.communicate("continue\n" if resume else None, timeout=30) + assert "Traceback" not in error, output + error + return json.loads(output) + + +def stop(child: subprocess.Popen[str] | None) -> None: + if child is not None: + if child.poll() is None: + child.kill() + child.communicate(timeout=10) + + +NATIVE = r""" +import fs from 'node:fs'; +import {syncBuiltinESMExports} from 'node:module'; +import {once} from 'node:events'; +const input = JSON.parse(process.argv[1]); +if (input.wait_lock) { + const actual = fs.promises.open; + let notified = false; + fs.promises.open = async (path, flags, ...args) => { + if (String(path) === input.wait_lock + '.ts-effect.lock' && flags === 'wx' && !notified) { + notified = true; process.stdout.write('BARRIER native-lock\n'); + } + return await actual(path, flags, ...args); + }; + syncBuiltinESMExports(); +} +const owner = await import(input.module); +const dependencies = input.pause_write ? {beforeWrite:async () => { + process.stdout.write('BARRIER native-write\n'); await once(process.stdin, 'data'); +}} : {}; +process.stdout.write(JSON.stringify(await owner[input.function](input.request, dependencies)) + '\n'); +""" + + +def native(module: str, function: str, request: dict, **options: object) -> list[str]: + return ["node", "--no-warnings", "--experimental-strip-types", "--input-type=module", "-e", NATIVE, + json.dumps({"module": (REPO / module).as_uri(), "function": function, "request": request, **options})] + + +def fence_request(ws: ShadowWorkspace) -> dict: + return {"schema_version": "loopx_legacy_coordination_writer_fence_engage_request_v0", + "runtime_root": str(ws.runtime), "goal_id": ws.goal, "state_path": str(ws.state), + "fence": {"schema_version": "loopx_legacy_coordination_writer_fence_v0", "state": "engaged", + "goal_id": ws.goal, "fence_id": "variant-fence", "source_version": "variant-source", + "source_projection_sha256": "a" * 64, + "expected_shadow_provider_revision": "file:1:aaaaaaaaaaaaaaaaaaaaaaaa"}} + + +def fence_command(ws: ShadowWorkspace, **options: object) -> list[str]: + return native("loopx/control_plane/coordination/legacy_writer_fence.ts", + "engageLegacyCoordinationWriterFence", fence_request(ws), **options) + + +@pytest.mark.parametrize("authority", ["fence", "active_capture"]) +def test_other_goal_cannot_write_a_protected_goal_source_via_state_override(tmp_path: Path, authority: str) -> None: + ws = workspace(tmp_path) + other_state = tmp_path / "OTHER_GOAL_STATE.md" + other_state.write_text("---\ngoal_id: goal-other\n---\n\n## Agent Todo\n") + registry = json.loads(ws.registry.read_text()) + registry["goals"].append({"id": "goal-other", "repo": str(tmp_path), "state_file": other_state.name, + "coordination": {"registered_agents": ["agent-a"]}}) + ws.registry.write_text(json.dumps(registry)) + # Preserve the existing opt-out contract before any source authority exists. + legacy = public(ws, "todo", "capture-followups", "--state-file", str(ws.state), + "--follow-up", "An unbound shared-state write remains supported.", + "--evidence", "Legacy compatibility control.", goal="goal-other") + assert legacy["ok"] is True and legacy["recorded_count"] == 1, legacy + if authority == "active_capture": + # A registered source stays protected even without a frontmatter owner. + ws.state.write_text(ws.state.read_text().replace(f"goal_id: {ws.goal}\n", "")) + assert public(ws, "coordination-shadow", "bootstrap", "--execute")["bootstrap"]["status"] == "applied" + else: + result = subprocess.run(fence_command(ws), cwd=REPO, capture_output=True, text=True, check=True, timeout=20) + assert json.loads(result.stdout)["status"] == "applied" + before = ws.state.read_bytes(), other_state.read_bytes() + result = public(ws, "todo", "capture-followups", "--state-file", str(ws.state), + "--follow-up", "Must not bypass another goal's source authority.", + "--evidence", "Cross-goal source boundary.", goal="goal-other") + assert not result.get("ok"), json.dumps(result, indent=2) + expected = "shadow_source_goal_mismatch" if authority == "active_capture" else "legacy_coordination_writer_fenced" + assert result["error_code"] == expected, result + assert (ws.state.read_bytes(), other_state.read_bytes()) == before + assert not (ws.runtime / "authority-shadow" / "outbox" / "goal-other").exists() + if authority == "active_capture": + unknown = public(ws, "todo", "capture-followups", "--state-file", str(ws.state), + "--follow-up", "An unregistered goal cannot bypass source ownership.", + "--evidence", "Unregistered goal control.", goal="unregistered-goal") + assert not unknown.get("ok"), unknown + assert (ws.state.read_bytes(), other_state.read_bytes()) == before + + +PUBLIC_WORKER = r""" +import sys +from contextlib import contextmanager +from pathlib import Path +stage = sys.argv[1] +def pause(label): + print('BARRIER ' + label, flush=True) + sys.stdin.readline() +if stage == 'reward_plan': + from loopx import feedback + original = feedback.plan_active_state_update + def planned(*args, **kwargs): + result = original(*args, **kwargs) + pause('reward-plan') + return result + feedback.plan_active_state_update = planned +elif stage == 'reward_write': + from loopx import feedback + original = feedback.atomic_write_state_text + def write(*args, **kwargs): + pause('reward-write') + return original(*args, **kwargs) + feedback.atomic_write_state_text = write +elif stage == 'handoff_write': + from loopx.control_plane.todos import active_state_editing + original = active_state_editing.atomic_write_state_text + def write(*args, **kwargs): + pause('handoff-write') + return original(*args, **kwargs) + active_state_editing.atomic_write_state_text = write +elif stage == 'handoff_k': + from loopx import file_lock + original = file_lock.exclusive_cross_runtime_file_lock + @contextmanager + def lock(path, *args, **kwargs): + if Path(path).name == '.task-leases': + print('BARRIER handoff-lock', flush=True) + with original(path, *args, **kwargs) as held: + yield held + file_lock.exclusive_cross_runtime_file_lock = lock +from loopx.entrypoint import main +raise SystemExit(main(sys.argv[2:])) +""" + + +def paused_public(ws: ShadowWorkspace, stage: str, *args: str) -> subprocess.Popen[str]: + return start([sys.executable, "-c", PUBLIC_WORKER, stage, *command(ws, *args)[3:]]) + + +@pytest.mark.parametrize("first", ["lease", "handoff"]) +def test_handoff_and_native_acquire_serialize_quiescence_and_source_evidence(tmp_path: Path, first: str) -> None: + ws = workspace(tmp_path) + added = ws.add("Unclaimed work eligible for a lease.") + todo_id = added["todo_id"] + assert public(ws, "coordination-shadow", "bootstrap", "--execute")["bootstrap"]["status"] == "applied" + request = {"schema_version": TASK_LEASE_ACQUIRE_REQUEST_SCHEMA, "runtime_root": str(ws.runtime), "goal_id": ws.goal, + "todo_id": todo_id, "owner": "agent-a", "idempotency_key": "native-variant-acquire", "ttl_seconds": 120, + "write_scopes": [], "expected_version": None, + "authority": task_lease_acquire_authority_facts(registry_path=ws.registry, goal_id=ws.goal, todo_id=todo_id)} + lease_dir = ws.runtime / "goals" / ws.goal / "task-leases" + def lease_command(**options: object) -> list[str]: + return native("loopx/control_plane/work_items/task_lease_acquire.ts", + "executeTaskLeaseAcquire", request, **options) + handoff_args = ("handoff-mode", "set", "--mode", "soft_claim") + lease = handoff = None + original = ws.state.read_bytes() + try: + if first == "lease": + lease = start(lease_command(pause_write=True)) + barrier(lease, "native-write") + handoff = paused_public(ws, "handoff_k", *handoff_args) + barrier(handoff, "handoff-lock") + assert handoff.poll() is None + acquired = finish(lease, resume=True) + refused = finish(handoff) + assert acquired["acquired"] is True, acquired + assert refused["error_code"] == "handoff_mode_not_quiescent", refused + assert ws.state.read_bytes() == original + assert json.loads((lease_dir / f"{todo_id}.json").read_text())["owner"] == "agent-a" + else: + handoff = paused_public(ws, "handoff_write", *handoff_args) + barrier(handoff, "handoff-write") + lease = start(lease_command(wait_lock=str(lease_dir / ".task-leases"))) + barrier(lease, "native-lock") + assert lease.poll() is None + changed = finish(handoff, resume=True) + refused = finish(lease) + assert changed["changed"] is True, changed + assert refused["error_code"] == "authority_source_changed", refused + assert "handoff_mode: soft_claim" in ws.state.read_text() + assert not (lease_dir / f"{todo_id}.json").exists() + public(ws, "authority-shadow", "drain") + inspected = public(ws, "coordination-shadow", "inspect") + assert inspected["inspection"]["status"] == "matched", inspected + assert inspected["inspection"]["cursor"] == "2", inspected + finally: + stop(lease) + stop(handoff) + + +def prepare_rewards(ws: ShadowWorkspace) -> Path: + index = ws.runtime / "goals" / ws.goal / "runs" / "index.jsonl" + index.parent.mkdir(parents=True, exist_ok=True) + index.write_text(json.dumps({"generated_at": "2026-09-05T00:00:00Z", "json_path": "run.json", + "markdown_path": "run.md", "classification": "continue"}) + "\n") + return index + + +def reward_args(reason: str, timestamp: str) -> tuple[str, ...]: + return ("reward", "--decision", "continue", "--reward", "positive", "--reason-summary", reason, + "--recorded-at", timestamp, "--write-active-state-summary") + + +def test_concurrent_public_rewards_preserve_both_summaries_and_run_overlays(tmp_path: Path) -> None: + ws = workspace(tmp_path) + index = prepare_rewards(ws) + assert public(ws, "coordination-shadow", "bootstrap", "--execute")["bootstrap"]["status"] == "applied" + first = paused_public(ws, "reward_plan", *reward_args("First independent review.", "2026-09-05T00:01:00Z")) + try: + barrier(first, "reward-plan") + second = public(ws, *reward_args("Second independent review.", "2026-09-05T00:02:00Z")) + assert second["ok"] is True, second + result = finish(first, resume=True) + assert result["ok"] is True, result + text = ws.state.read_text() + assert text.count("First independent review.") == 1 + assert text.count("Second independent review.") == 1 + overlays = [json.loads(line)["human_reward"]["reason_summary"] for line in index.read_text().splitlines()[1:]] + assert sorted(overlays) == ["First independent review.", "Second independent review."] + inspection = public(ws, "coordination-shadow", "inspect")["inspection"] + assert inspection["status"] == "matched", inspection + assert inspection["cursor"] == "1", inspection + finally: + stop(first) + + +def test_native_fence_waits_for_public_prose_and_then_blocks_todo_writes(tmp_path: Path) -> None: + ws = workspace(tmp_path) + index = prepare_rewards(ws) + prose = paused_public(ws, "reward_write", *reward_args("Prose committed before the fence.", "2026-09-05T00:01:00Z")) + fence = None + try: + barrier(prose, "reward-write") + fence = start(fence_command(ws, wait_lock=str(ws.state))) + barrier(fence, "native-lock") + fence_path = legacy_coordination_writer_fence_path(runtime_root=ws.runtime, goal_id=ws.goal) + assert not fence_path.exists() + assert fence.poll() is None + assert finish(prose, resume=True)["ok"] is True + assert finish(fence)["status"] == "applied" + before = ws.state.read_bytes() + result = public(ws, "todo", "capture-followups", "--follow-up", "Must now be fenced.", "--evidence", "Boundary check.") + assert result["error_code"] == "legacy_coordination_writer_fenced", result + assert ws.state.read_bytes() == before + assert "Prose committed before the fence." in ws.state.read_text() + assert len(index.read_text().splitlines()) == 2 + finally: + stop(prose) + stop(fence) From da757102a00cecc070c47479018aec8cffc91bfe Mon Sep 17 00:00:00 2001 From: wchwawa Date: Sun, 6 Sep 2026 21:09:48 +1000 Subject: [PATCH 09/27] fix(coordination): bind management replay to its operation evidence Signed-off-by: wchwawa --- .../coordination/shadow_management.ts | 66 +++- .../test_shadow_management_variant_e2e.py | 288 ++++++++++++++++++ 2 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 tests/control_plane/test_shadow_management_variant_e2e.py diff --git a/loopx/control_plane/coordination/shadow_management.ts b/loopx/control_plane/coordination/shadow_management.ts index 0e32a5c10f..d532ca5e2a 100644 --- a/loopx/control_plane/coordination/shadow_management.ts +++ b/loopx/control_plane/coordination/shadow_management.ts @@ -252,25 +252,85 @@ async function replay(request: ManagementRequest, state: ShadowManagementState | const digest = requestDigest(request); if (state?.operation.operation_id === request.operation_id) { if (state.operation.request_digest !== digest) throw new ShadowManagementError("management_operation_identity_mismatch"); - return state.result ? { ...state.result, status: "replayed", current_management_status: state.status, current_capture_lineage_id: state.binding?.capture_lineage_id ?? null } : null; + if (!state.result) return null; + await validateReplayResult(request, await loadManifest(request, state), state.result, state); + return { ...state.result, status: "replayed", current_management_status: state.status, current_capture_lineage_id: state.binding?.capture_lineage_id ?? null }; } const prior = await readJson(resultPath(request)); if (!prior) return null; if (prior.request_digest !== digest) throw new ShadowManagementError("management_operation_identity_mismatch"); if (!isAuthorityJsonObject(prior.result)) throw new ShadowManagementError("shadow_management_state_invalid"); + const manifest = await loadManifest(request, { operation: { manifest_digest: prior.manifest_digest } }); + await validateReplayResult(request, manifest, prior.result, state); return { ...prior.result, status: "replayed", current_management_status: state?.status ?? "missing", current_capture_lineage_id: state?.binding?.capture_lineage_id ?? null }; } -async function loadManifest(request: ManagementRequest, state: ShadowManagementState): Promise { +async function loadManifest(request: ManagementRequest, state: { operation: JsonObject }): Promise { const manifest = await readJson(manifestPath(request)); if (!manifest || managementDigest(manifest) !== state.operation.manifest_digest || manifest.schema_version !== SHADOW_MANAGEMENT_MANIFEST_SCHEMA + || manifest.kind !== ("expected_provider_revision" in request ? "rollback" : "bootstrap") || manifest.goal_id !== request.goal_id || manifest.operation_id !== request.operation_id || manifest.source_root_digest !== shadowSourceRootDigest(request.runtime_root) - || manifest.request_digest !== requestDigest(request)) { + || manifest.request_digest !== requestDigest(request) + || !isAuthorityJsonObject(manifest.request) + || manifest.request.runtime_root !== request.runtime_root || manifest.request.goal_id !== request.goal_id + || manifest.request.operation_id !== request.operation_id + || requestDigest(manifest.request as ManagementRequest) !== manifest.request_digest) { throw new ShadowManagementError("shadow_management_manifest_invalid"); } return manifest; } + +/** A cached result is historical evidence, not an independent authority. */ +async function validateReplayResult(request: ManagementRequest, manifest: JsonObject, result: JsonObject, state: ShadowManagementState | null): Promise { + const invalid = () => { throw new ShadowManagementError("shadow_management_result_invalid"); }; + if (result.operation_id !== request.operation_id || result.capture_lineage_id !== manifest.capture_lineage_id + || result.primary_writeback_preserved !== true || result.decision_read_from_shadow !== false) return invalid(); + if (manifest.kind === "rollback") { + const candidate = manifest.candidate; + if (candidate !== null && !isAuthorityJsonObject(candidate)) return invalid(); + const store = provider(request); + if (!["applied", "recovered"].includes(String(result.status)) + || result.archive_id !== store.authorityArchiveId(request.operation_id) + || result.archived_provider_revision !== (candidate?.provider_revision ?? null) + || result.archived_cursor !== (candidate?.cursor ?? null) + || result.candidate_archive_path !== (candidate ? store.authorityArchivePath(request.operation_id) : null) + || result.outbox_archive_path !== (manifest.outbox ? archiveOutboxPath(request) : null) + || result.active_shadow_removed !== true || result.archive_retained !== true + || result.capture_status !== "bootstrap_required") return invalid(); + return; + } + if (result.status === "aborted") { + if (result.reason_code !== "bootstrap_aborted" || !text(result.rollback_operation_id)) return invalid(); + const locator = { ...request, operation_id: result.rollback_operation_id }; + const raw = await readJson(manifestPath(locator)); + if (!raw || !isAuthorityJsonObject(raw.request)) return invalid(); + const rollbackRequest = requestOf(raw.request); + if (rollbackRequest.goal_id !== request.goal_id || rollbackRequest.runtime_root !== request.runtime_root + || rollbackRequest.operation_id !== result.rollback_operation_id + || rollbackRequest.expected_bootstrap_operation_id !== request.operation_id) return invalid(); + const terminal = state?.operation.operation_id === result.rollback_operation_id + ? { ...state.operation, result: state.result } : await readJson(resultPath(locator)); + if (!terminal || terminal.request_digest !== requestDigest(rollbackRequest) + || !isAuthorityJsonObject(terminal.result)) return invalid(); + const rollback = await loadManifest(rollbackRequest, { operation: { manifest_digest: terminal.manifest_digest } }); + if (!same(rollback.aborted_bootstrap, manifest)) return invalid(); + await validateReplayResult(rollbackRequest, rollback, terminal.result, state); + return; + } + if (!["applied", "recovered"].includes(String(result.status)) + || result.capture_profile !== SHADOW_CAPTURE_PROFILE || result.source_root_digest !== manifest.source_root_digest + || result.bootstrap_operation_id !== request.operation_id || result.cursor !== "1" + || result.provider_revision !== result.bootstrap_provider_revision + || !/^file:1:[0-9a-f]{24}$/.test(String(result.bootstrap_provider_revision)) + || !/^file:[0-9a-f]{32}$/.test(String(result.store_identity)) + || result.bootstrap_receipts_empty !== true || result.mode_declaration !== "legacy_canonical_shadow") return invalid(); + // The active binding is an exact anchor when this bootstrap is still current. + // A historical manifest predates provider identity/revision assignment; its + // cached revision shape alone does not establish live candidate authority. + if (state?.binding?.bootstrap_operation_id === request.operation_id + && Object.entries(state.binding).some(([key, value]) => result[key] !== value)) return invalid(); +} function initialState(request: ManagementRequest, kind: "bootstrap" | "rollback", manifest: JsonObject, prior: ShadowManagementState | null): ShadowManagementState { return { schema_version: SHADOW_MANAGEMENT_STATE_SCHEMA, goal_id: request.goal_id, diff --git a/tests/control_plane/test_shadow_management_variant_e2e.py b/tests/control_plane/test_shadow_management_variant_e2e.py new file mode 100644 index 0000000000..ab045bfd02 --- /dev/null +++ b/tests/control_plane/test_shadow_management_variant_e2e.py @@ -0,0 +1,288 @@ +"""Management composition invariants through real CLI, RPC and file stores. + +Corruption is restricted to disposable fixtures. The crash seam pauses after +the actual rename; it never substitutes a management or provider result. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import select +import subprocess + +import pytest + +from loopx.cli_commands.coordination_shadow import _projection_version +from loopx.control_plane.coordination.coordination_state_contract_generated import ( + COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA, + COORDINATION_RUNTIME_SHADOW_ROLLBACK_REQUEST_SCHEMA, +) +from loopx.control_plane.coordination.runtime_shadow import build_runtime_shadow_source_snapshot +from loopx.control_plane.coordination.shadow_management import read_shadow_management_state +from loopx.control_plane.effect_runtime import effect_runtime_result +from tests.control_plane.shadow_e2e_fixture import ShadowWorkspace +from tests.control_plane.test_shadow_management_e2e import ( + REPO_ROOT, _bootstrap, _candidate, _cli, _workspace, +) + +pytestmark = pytest.mark.stage2c_e2e + + +_CRASH_RPC = r""" +import fs from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; +import { pathToFileURL } from 'node:url'; +const [modulePath, action, raw, target] = process.argv.slice(1); +const request = JSON.parse(raw); +const actualRename = fs.promises.rename; +fs.promises.rename = async (source, destination) => { + const result = await actualRename(source, destination); + const reached = action === 'bootstrap' + ? String(destination) === target : String(source) === target; + if (reached) { + process.stdout.write('BARRIER ' + JSON.stringify({source: String(source), destination: String(destination)}) + '\n'); + await new Promise(() => { setInterval(() => {}, 1000); }); + } + return result; +}; +syncBuiltinESMExports(); +const runtime = await import(pathToFileURL(modulePath).href); +const result = action === 'bootstrap' + ? await runtime.bootstrapCoordinationRuntimeShadow(request) + : await runtime.rollbackCoordinationRuntimeShadow(request); +process.stdout.write(JSON.stringify(result) + '\n'); +""" + + +def _kill_rpc(action: str, request: dict, target: Path) -> dict: + child = subprocess.Popen( + ["node", "--no-warnings", "--experimental-strip-types", "--input-type=module", "-e", + _CRASH_RPC, str(REPO_ROOT / "loopx/control_plane/coordination/runtime_shadow.ts"), + action, json.dumps(request), str(target)], + cwd=REPO_ROOT, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + assert child.stdout is not None + try: + ready, _, _ = select.select([child.stdout], [], [], 30) + assert ready, "real management RPC did not reach its rename window" + line = child.stdout.readline() + if not line.startswith("BARRIER "): + child.kill() + stdout, stderr = child.communicate(timeout=10) + raise AssertionError(f"RPC exited before rename: {line}{stdout}\n{stderr}") + child.kill() + child.communicate(timeout=10) + assert child.returncode == -9 + return json.loads(line.removeprefix("BARRIER ")) + finally: + if child.poll() is None: + child.kill() + child.communicate(timeout=10) + + +def _source(registry: Path, runtime: Path, goal_id: str = "goal-a") -> tuple[dict, Path]: + goal = next(goal for goal in json.loads(registry.read_text())["goals"] if goal["id"] == goal_id) + state = Path(goal["repo"]) / goal["state_file"] + projection, snapshot = build_runtime_shadow_source_snapshot( + goal=goal, runtime_root=runtime, state_path=state, registry_path=registry, + ) + return { + "schema_version": COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA, + "runtime_root": str(runtime), "goal_id": goal_id, + "operation_id": "bootstrap:variant", "projection": projection, + "source_version": f"legacy-projection:{_projection_version(projection)}", + "source_snapshot": snapshot, + }, state + + +def _rollback_request(runtime: Path, state: Path, revision: str, goal_id: str = "goal-a") -> dict: + return { + "schema_version": COORDINATION_RUNTIME_SHADOW_ROLLBACK_REQUEST_SCHEMA, + "runtime_root": str(runtime), "goal_id": goal_id, + "operation_id": f"shadow-rollback:{goal_id}:{revision}", + "expected_provider_revision": revision, "expected_bootstrap_operation_id": None, + "projection": {}, "source_snapshot": {"state_path": str(state)}, + } + + +def _management(runtime: Path, goal: str = "goal-a") -> Path: + digest = hashlib.sha256(goal.encode()).hexdigest()[:16] + return runtime / "authority-transition/file-v0" / f"shadow-management-{digest}" + + +def _durable_files(root: Path) -> dict[str, bytes]: + return { + str(path.relative_to(root)): path.read_bytes() + for path in root.rglob("*") + if path.is_file() and not any(part.endswith(".lock") for part in path.parts) + } + + +@pytest.mark.parametrize("archive", ["candidate", "outbox"]) +def test_pending_rollback_checks_corrupted_archive_before_finishing_and_can_retry_exact_repair( + tmp_path: Path, archive: str, +) -> None: + registry, runtime = _workspace(tmp_path) + first = _bootstrap(registry, runtime) + _bootstrap(registry, runtime, "goal-b") + _, state = _source(registry, runtime) + w = ShadowWorkspace(registry, runtime, state, "goal-a") + w.crash("before_commit", "todo", "add", "--role", "agent", "--text", "Pending retained mutation") + candidate, outbox = _candidate(runtime, "goal-a"), runtime / "authority-shadow/outbox/goal-a" + pending_bytes = _durable_files(outbox) + assert any(name.endswith(".prepared.json") for name in pending_bytes) + candidate_bytes, primary_bytes = candidate.read_bytes(), state.read_bytes() + request = _rollback_request(runtime, state, first["provider_revision"]) + moved = _kill_rpc("rollback", request, candidate if archive == "candidate" else outbox) + archived = Path(moved["destination"]) + damaged = archived if archive == "candidate" else next(archived.rglob("*.prepared.json")) + original = damaged.read_bytes() + damaged.write_bytes(original + b"\ncorrupted fixture archive") + before = _durable_files(runtime) + result = _cli(registry, runtime, "coordination-shadow", "rollback", "--goal-id", "goal-a", + "--provider-revision", first["provider_revision"], "--execute", success=False) + assert result["ok"] is False, result + assert result["rollback"]["reason_code"] == f"rollback_{archive}_identity_mismatch", result + after = _durable_files(runtime) + journal = str((_management(runtime) / "state.json").relative_to(runtime)) + # Recovery may persist the same verified phase in canonical JSON order; + # only representation may differ, never transition state or evidence bytes. + assert json.loads(after.pop(journal)) == json.loads(before.pop(journal)) + assert after == before + assert state.read_bytes() == primary_bytes + assert read_shadow_management_state(runtime, "goal-a")["status"] == "rolling_back" + damaged.write_bytes(original) + recovered = _cli(registry, runtime, "coordination-shadow", "rollback", "--goal-id", "goal-a", + "--provider-revision", first["provider_revision"], "--execute")["rollback"] + assert recovered["status"] == "recovered", recovered + assert Path(recovered["candidate_archive_path"]).read_bytes() == candidate_bytes + assert _durable_files(Path(recovered["outbox_archive_path"])) == pending_bytes + assert read_shadow_management_state(runtime, "goal-a")["status"] == "inactive" + assert state.read_bytes() == primary_bytes + + +def test_changed_source_after_bootstrap_commit_requires_abort_before_new_baseline(tmp_path: Path) -> None: + registry, runtime = _workspace(tmp_path) + request, state = _source(registry, runtime) + _kill_rpc("bootstrap", request, _candidate(runtime, "goal-a")) + state.write_text(state.read_text().replace("handoff_mode: hard_lease", "handoff_mode: soft_claim")) + before = _durable_files(runtime) + rejected = _cli(registry, runtime, "coordination-shadow", "bootstrap", "--goal-id", "goal-a", + "--execute", success=False) + assert rejected["ok"] is False, rejected + assert _durable_files(runtime) == before + assert read_shadow_management_state(runtime, "goal-a")["status"] == "bootstrapping" + aborted = _cli(registry, runtime, "coordination-shadow", "rollback", "--goal-id", "goal-a", + "--bootstrap-operation-id", request["operation_id"], "--execute")["rollback"] + current = _bootstrap(registry, runtime) + assert current["capture_lineage_id"] != aborted["capture_lineage_id"] + inspected = _cli(registry, runtime, "coordination-shadow", "inspect", "--goal-id", "goal-a") + assert inspected["inspection"]["status"] == "matched", inspected + candidate = json.loads(_candidate(runtime, "goal-a").read_text()) + assert candidate["head"]["handoff_mode"] == "soft_claim" + + +def test_same_rpc_operation_ids_are_scoped_to_each_goal_across_rollback_and_replay(tmp_path: Path) -> None: + registry, runtime = _workspace(tmp_path) + requests = {goal: _source(registry, runtime, goal)[0] for goal in ("goal-a", "goal-b")} + results = {goal: effect_runtime_result("coordination.runtime_shadow.bootstrap", request) + for goal, request in requests.items()} + assert all(value["status"] == "applied" for value in results.values()), results + assert results["goal-a"]["capture_lineage_id"] != results["goal-b"]["capture_lineage_id"] + identity_path = runtime / "authority-shadow/file-v0/store-identity" + identity = identity_path.read_bytes() + rollbacks = {} + for goal in requests: + request = _rollback_request(runtime, Path(requests[goal]["source_snapshot"]["state_path"]), + results[goal]["provider_revision"], goal) + request["operation_id"] = "rollback:shared-operation-id" + rollbacks[goal] = request + other = "goal-b" if goal == "goal-a" else "goal-a" + other_before = _durable_files(_management(runtime, other)) + retired = effect_runtime_result("coordination.runtime_shadow.rollback", request) + assert retired["status"] == "applied", retired + assert retired["capture_lineage_id"] == results[goal]["capture_lineage_id"] + assert _durable_files(_management(runtime, other)) == other_before + current = {goal: _bootstrap(registry, runtime, goal) for goal in requests} + before = _durable_files(runtime) + for goal, request in rollbacks.items(): + replayed = effect_runtime_result("coordination.runtime_shadow.rollback", request) + assert replayed["status"] == "replayed", replayed + assert replayed["capture_lineage_id"] == results[goal]["capture_lineage_id"] + assert replayed["current_capture_lineage_id"] == current[goal]["capture_lineage_id"] + assert _durable_files(runtime) == before + assert identity_path.read_bytes() == identity + + +def test_wrong_abort_selector_and_late_aborted_bootstrap_cannot_replace_same_data_new_lineage(tmp_path: Path) -> None: + registry, runtime = _workspace(tmp_path) + request, state = _source(registry, runtime) + primary = state.read_bytes() + _kill_rpc("bootstrap", request, _candidate(runtime, "goal-a")) + before = _durable_files(runtime) + wrong = _cli(registry, runtime, "coordination-shadow", "rollback", "--goal-id", "goal-a", + "--bootstrap-operation-id", "bootstrap:unrelated", "--execute", success=False) + assert wrong["ok"] is False, wrong + assert wrong["rollback"]["reason_code"] == "bootstrap_operation_not_pending", wrong + assert _durable_files(runtime) == before + stopped = _cli(registry, runtime, "coordination-shadow", "rollback", "--goal-id", "goal-a", + "--bootstrap-operation-id", request["operation_id"], "--execute")["rollback"] + current = _bootstrap(registry, runtime) + assert state.read_bytes() == primary + assert current["capture_lineage_id"] != stopped["capture_lineage_id"] + before = _durable_files(runtime) + delayed = effect_runtime_result("coordination.runtime_shadow.bootstrap", request) + assert delayed["reason_code"] == "bootstrap_aborted", delayed + assert delayed["current_capture_lineage_id"] == current["capture_lineage_id"] + assert _durable_files(runtime) == before + assert read_shadow_management_state(runtime, "goal-a")["binding"]["capture_lineage_id"] == current["capture_lineage_id"] + rollback_manifest = (_management(runtime) / "operations" + / hashlib.sha256(stopped["operation_id"].encode()).hexdigest() / "manifest.json") + original = rollback_manifest.read_bytes() + damaged = json.loads(original) + damaged["capture_lineage_id"] = "changed-rollback-evidence" + rollback_manifest.write_text(json.dumps(damaged)) + before = _durable_files(runtime) + rejected = effect_runtime_result("coordination.runtime_shadow.bootstrap", request) + assert rejected["status"] == "failed", rejected + assert rejected["reason_code"] == "shadow_management_manifest_invalid", rejected + assert _durable_files(runtime) == before + rollback_manifest.write_bytes(original) + assert effect_runtime_result("coordination.runtime_shadow.bootstrap", request)["reason_code"] == "bootstrap_aborted" + + +@pytest.mark.parametrize("storage", ["current", "historical"]) +def test_rollback_result_cannot_borrow_another_goals_archive_evidence(tmp_path: Path, storage: str) -> None: + registry, runtime = _workspace(tmp_path) + first = {goal: _bootstrap(registry, runtime, goal) for goal in ("goal-a", "goal-b")} + retired = { + goal: _cli(registry, runtime, "coordination-shadow", "rollback", "--goal-id", goal, + "--provider-revision", initial["provider_revision"], "--execute")["rollback"] + for goal, initial in first.items() + } + other_result = json.loads((_management(runtime, "goal-b") / "state.json").read_text())["result"] + current = {goal: _bootstrap(registry, runtime, goal) for goal in first} if storage == "historical" else {} + operation = retired["goal-a"]["operation_id"] + cache = (_management(runtime) / "state.json" if storage == "current" else + _management(runtime) / "operations" / hashlib.sha256(operation.encode()).hexdigest() / "result.json") + record = json.loads(cache.read_text()) + original_cache = cache.read_bytes() + # Keep A's outer request and manifest bindings while damaging the cached + # historical result with B's structurally valid, actually executed result. + record["result"] = other_result + cache.write_text(json.dumps(record)) + before = _durable_files(runtime) + result = _cli(registry, runtime, "coordination-shadow", "rollback", "--goal-id", "goal-a", + "--provider-revision", first["goal-a"]["provider_revision"], "--execute", success=False) + assert result["ok"] is False, json.dumps(result, indent=2) + assert _durable_files(runtime) == before + for goal in current: + assert read_shadow_management_state(runtime, goal)["binding"]["capture_lineage_id"] == current[goal]["capture_lineage_id"] + cache.write_bytes(original_cache) + replayed = _cli(registry, runtime, "coordination-shadow", "rollback", "--goal-id", "goal-a", + "--provider-revision", first["goal-a"]["provider_revision"], "--execute")["rollback"] + assert replayed["status"] == "replayed", replayed + assert replayed["capture_lineage_id"] == first["goal-a"]["capture_lineage_id"] + assert replayed["current_capture_lineage_id"] == (current["goal-a"]["capture_lineage_id"] if current else None) From c476418fb3d3099dee44f3e02005543a3d80fa65 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Sun, 6 Sep 2026 21:09:48 +1000 Subject: [PATCH 10/27] ci(testing): use a pytest-compatible sharding dependency Signed-off-by: wchwawa --- pyproject.toml | 2 +- tests/requirements-stage2c-linux-py311.txt | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9cdddf48df..5842baa84d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ test = [ "pytest>=9.0.3,<10", "pytest-cov>=5,<7", "pytest-xdist>=3,<4", - "pytest-split>=0.10,<0.11", + "pytest-split>=0.11,<0.12", "ruff>=0.12,<0.16", "mypy>=1.18,<2", ] diff --git a/tests/requirements-stage2c-linux-py311.txt b/tests/requirements-stage2c-linux-py311.txt index a851bc5079..674ba52479 100644 --- a/tests/requirements-stage2c-linux-py311.txt +++ b/tests/requirements-stage2c-linux-py311.txt @@ -710,11 +710,16 @@ pytest==9.1.1 \ # via # loopx (pyproject.toml) # pytest-cov + # pytest-split # pytest-xdist pytest-cov==6.3.0 \ --hash=sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2 \ --hash=sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749 # via loopx (pyproject.toml) +pytest-split==0.11.0 \ + --hash=sha256:899d7c0f5730da91e2daf283860eb73b503259cb416851a65599368849c7f382 \ + --hash=sha256:8ebdb29cc72cc962e8eb1ec07db1eeb98ab25e215ed8e3216f6b9fc7ce0ec2b5 + # via loopx (pyproject.toml) pytest-xdist==3.8.0 \ --hash=sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88 \ --hash=sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1 From 0c1c4a3e9214ef65e320d122ca7e3bb7d26ed8f0 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Sun, 6 Sep 2026 21:09:48 +1000 Subject: [PATCH 11/27] test(coordination): qualify cleanup and cross-goal regressions Signed-off-by: wchwawa --- examples/shared-goal-authority-e2e/correctness.md | 13 +++++++++++-- examples/shared-goal-authority-e2e/mutants.py | 12 ++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/examples/shared-goal-authority-e2e/correctness.md b/examples/shared-goal-authority-e2e/correctness.md index 15e40aa445..abbb772245 100644 --- a/examples/shared-goal-authority-e2e/correctness.md +++ b/examples/shared-goal-authority-e2e/correctness.md @@ -82,6 +82,8 @@ reconciles actual source/archive existence and hashes; a phase string alone cannot prove completion. Conflicts hold without overwriting or deleting either copy. A delayed exact old request only returns its historical result. Reusing its operation ID with different request contents returns an identity mismatch. +Cached results must match their immutable operation manifest, lineage and archive +references. A result copied from another Goal cannot establish successful replay. During an unfinished management operation, canonical and whole-state prose writes return a maintenance hold before their first side effect. Management lock @@ -89,6 +91,10 @@ order is M → T → S → L → K. Native lease writers check under their exist ordinary writers release primary locks before drain. Legacy fence engagement also takes the actual source S and therefore requires its absolute state path in the internal RPC request. +For source owners declared in frontmatter or registered to that state path, +selecting another Goal cannot bypass the existing binding or fence. Unbound +legacy shared-state writes retain their existing behavior; prose-only writes +retain their separate maintenance boundary. Canonical FileAuthorityStore Todo updates, including compatibility v0 records already held by that authority, use the same M and maintenance boundary as @@ -104,6 +110,9 @@ preserve the scene. An interrupted primary without a marker is either proven under the source lock or left `unproved`; an A → B → A history cannot establish that the first write was abandoned. TS repeats source proof under its own locks after Python releases its locks to call the native commit operation. +If cursor persistence or file reclamation fails after a verified commit, drain +still reports the verified candidate revision. The preserved outbox can be +replayed after repairing filesystem access; the failure does not undo the commit. For unresolved evidence, retain the directory for inspection and use exact rollback when abandoning the candidate. Do not delete the cursor to bypass a @@ -156,8 +165,8 @@ Python/TS/JSON provenance, and reads back through an independent native process. | Full baseline, mixed Python/native writers, handoff, followups, monitor successor, one receipt per mutation | `test_runtime_shadow_bounded_e2e.py`, `test_shadow_drain_e2e.py` | | Cursor attacks, complete proof before bounded cleanup, missing cursor writer-first, dual drainers | `test_shadow_cursor_safety.py`, `test_shadow_drain_adversarial.py`, `shadow_cursor_safety.test.ts` | | Primary and drain process death, lost ACK, prepared-only A → B → A | `test_shadow_drain_e2e.py`, `test_shadow_management_e2e.py` | -| Every bootstrap/rollback durable window, raw archive fidelity, late requests and other-Goal isolation | `shadow_management.test.ts`, `test_shadow_management_e2e.py` | -| Fence and maintenance boundaries, source override races, whole-file durability, paragraph injection, refresh CAS | `test_shadow_writer_boundaries.py`, `shadow_native_writer_boundary.test.ts`, `test_shadow_drain_adversarial.py` | +| Every bootstrap/rollback durable window, raw archive fidelity, late requests, cached-result binding and other-Goal isolation | `shadow_management.test.ts`, `test_shadow_management_e2e.py`, `test_shadow_management_variant_e2e.py` | +| Fence and maintenance boundaries, source and Goal override races, whole-file durability, paragraph injection, refresh CAS | `test_shadow_writer_boundaries.py`, `test_shadow_writer_variant_e2e.py`, `shadow_native_writer_boundary.test.ts`, `test_shadow_drain_adversarial.py` | | Canonical native/v0 Todo updates through CLI and native RPC, real pending management, M ordering, and unchanged authority on hold | `test_shadow_native_todo_update_e2e.py` | | History flaws despite equal snapshots, legacy mixed profile, source drift, event-only hold, qualified reads | `coordination_runtime_shadow.test.ts`, `file_outbox_qualification.test.ts`, `test_runtime_shadow_bounded_e2e.py` | | Installed lifecycle and resource provenance in wheel and sdist | `installed.py` | diff --git a/examples/shared-goal-authority-e2e/mutants.py b/examples/shared-goal-authority-e2e/mutants.py index b3a076bf9d..2ad5d47448 100644 --- a/examples/shared-goal-authority-e2e/mutants.py +++ b/examples/shared-goal-authority-e2e/mutants.py @@ -132,6 +132,18 @@ def apply(source: str) -> str: WRITER_TEST = "tests/control_plane/test_shadow_writer_boundaries.py::" FENCE_TEST = WRITER_TEST + "test_cli_waiting_for_todo_mutex_rechecks_fence_after_engagement" CASES.extend([ + Case("management_result_binding", ((COORDINATION + "shadow_management.ts", replacement( + " await validateReplayResult(request, manifest, prior.result, state);", + " // DELIBERATE MUTANT: trust a cached result from another operation.")),), + "tests/control_plane/test_shadow_management_variant_e2e.py::test_rollback_result_cannot_borrow_another_goals_archive_evidence[historical]"), + Case("cross_goal_source_guard", ((COORDINATION + "legacy_writer_fence.py", replacement( + " resolved_source = state_file.resolve(strict=False)", + " return # DELIBERATE MUTANT: allow another goal to bypass source authority.\n resolved_source = state_file.resolve(strict=False)")),), + "tests/control_plane/test_shadow_writer_variant_e2e.py::test_other_goal_cannot_write_a_protected_goal_source_via_state_override[active_capture]"), + Case("cleanup_hides_verified_commit", ((COORDINATION + "local_authority_shadow_adapter.py", replacement( + " self._record_view(view)\n self._reconcile(transactions, delivered_entry_id=entry.entry_id)", + " self._reconcile(transactions, delivered_entry_id=entry.entry_id)\n self._record_view(view)")),), + "tests/control_plane/test_shadow_drain_adversarial.py::test_cleanup_permission_failure_reports_verified_commit_and_recovers[before_commit]"), Case("native_update_maintenance", ((COORDINATION + "local_authority_runtime.ts", remove_native_update_maintenance),), "tests/control_plane/test_shadow_native_todo_update_e2e.py::test_native_update_holds_before_primary_for_management[native-bootstrapping-cli]"), From 8863bfb56fffa80606a4f19b9e5d217875c141f8 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Sun, 6 Sep 2026 21:16:20 +1000 Subject: [PATCH 12/27] test(coordination): refresh the manifest hash mutation locator Signed-off-by: wchwawa --- examples/shared-goal-authority-e2e/mutants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/shared-goal-authority-e2e/mutants.py b/examples/shared-goal-authority-e2e/mutants.py index 2ad5d47448..12bdbbcc3b 100644 --- a/examples/shared-goal-authority-e2e/mutants.py +++ b/examples/shared-goal-authority-e2e/mutants.py @@ -54,7 +54,7 @@ def command(self) -> list[str]: 'tests/control_plane_ts/coordination_runtime_shadow.test.ts', 'observation transaction mixed'), Case('management_request_digest', ((COORDINATION + "shadow_management.ts", replacement('if (state.operation.request_digest !== digest) throw new ShadowManagementError("management_operation_identity_mismatch");', 'if (false) throw new ShadowManagementError("management_operation_identity_mismatch");')),), "tests/control_plane_ts/shadow_management.test.ts", 'management request digest'), - Case('management_manifest_hash', ((COORDINATION + "shadow_management.ts", replacement('managementDigest(manifest) !== state.operation.manifest_digest\n || manifest.schema_version !== SHADOW_MANAGEMENT_MANIFEST_SCHEMA\n || manifest.goal_id', 'false\n || manifest.schema_version !== SHADOW_MANAGEMENT_MANIFEST_SCHEMA\n || manifest.goal_id')),), + Case('management_manifest_hash', ((COORDINATION + "shadow_management.ts", replacement('managementDigest(manifest) !== state.operation.manifest_digest\n || manifest.schema_version !== SHADOW_MANAGEMENT_MANIFEST_SCHEMA\n', 'false\n || manifest.schema_version !== SHADOW_MANAGEMENT_MANIFEST_SCHEMA\n')),), "tests/control_plane_ts/shadow_management.test.ts", 'management manifest hash'), Case('management_phase', ((COORDINATION + "shadow_management.ts", replacement('operation.kind !== kind || !phases.includes(String(operation.phase))', 'operation.kind !== kind')),), "tests/control_plane_ts/shadow_management.test.ts", 'management phase validation'), From a3980a6151cb8a265b050d2979efcc1c91cb6377 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Sun, 6 Sep 2026 21:27:50 +1000 Subject: [PATCH 13/27] test(coordination): remove unused rebased import Signed-off-by: wchwawa --- tests/control_plane/test_local_coordination_authority.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/control_plane/test_local_coordination_authority.py b/tests/control_plane/test_local_coordination_authority.py index baae6afae7..67fa9c0941 100644 --- a/tests/control_plane/test_local_coordination_authority.py +++ b/tests/control_plane/test_local_coordination_authority.py @@ -20,7 +20,7 @@ from loopx.control_plane.coordination.legacy_writer_fence import ( legacy_coordination_writer_fence_path, ) -from loopx.todos import add_goal_todo, list_goal_todos, update_goal_todo +from loopx.todos import add_goal_todo, list_goal_todos from canonical_authority_fixture import initialize_canonical_authority From b8176a3cc7930f39d62a43cd886c8b738199f16b Mon Sep 17 00:00:00 2001 From: wchwawa Date: Mon, 7 Sep 2026 11:15:31 +1000 Subject: [PATCH 14/27] test(coordination): reproduce cursor and caller review boundaries Signed-off-by: wchwawa --- .../test_shadow_cursor_recovery_e2e.py | 181 ++++++++ .../test_shadow_observable_e2e.py | 403 ++++++++++++++++++ .../test_shadow_observable_native_e2e.py | 174 ++++++++ 3 files changed, 758 insertions(+) create mode 100644 tests/control_plane/test_shadow_cursor_recovery_e2e.py create mode 100644 tests/control_plane/test_shadow_observable_e2e.py create mode 100644 tests/control_plane/test_shadow_observable_native_e2e.py diff --git a/tests/control_plane/test_shadow_cursor_recovery_e2e.py b/tests/control_plane/test_shadow_cursor_recovery_e2e.py new file mode 100644 index 0000000000..beae461ee4 --- /dev/null +++ b/tests/control_plane/test_shadow_cursor_recovery_e2e.py @@ -0,0 +1,181 @@ +"""A settled position and its last applied digest are separate cursor facts.""" + +from __future__ import annotations + +import json +from pathlib import Path +import select +import subprocess + +import pytest + +from shadow_e2e_fixture import REPO, ShadowWorkspace, workspace +from loopx.control_plane.coordination import local_authority_shadow_adapter as adapter +from loopx.control_plane.coordination import local_authority_shadow_outbox as outbox +from loopx.control_plane.coordination.coordination_state_contract_generated import TASK_LEASE_ACQUIRE_REQUEST_SCHEMA +from loopx.control_plane.work_items.task_lease_acquire_adapter import task_lease_acquire_authority_facts + +pytestmark = pytest.mark.stage2c_e2e + +# Pause only the actual primary rename, after durable prepare. No result is replaced. +LEASE_WORKER = r""" +import fs from 'node:fs'; +import {syncBuiltinESMExports} from 'node:module'; +const input = JSON.parse(process.argv[1]); +const rename = fs.promises.rename; +fs.promises.rename = async (source, target) => { + if (String(target) === input.stop_before) { + process.stdout.write('BARRIER primary-rename\n'); + await new Promise(resolve => setTimeout(resolve, 40000)); + throw new Error('parent did not terminate the paused writer'); + } + return await rename(source, target); +}; +syncBuiltinESMExports(); +const {executeTaskLeaseAcquire} = await import(input.module); +process.stdout.write(JSON.stringify(await executeTaskLeaseAcquire(input.request)) + '\n'); +""" + + +def acquire(w: ShadowWorkspace, todo: str, *, crash: bool = False) -> None: + lease = w.runtime / 'goals' / w.goal / 'task-leases' / f'{todo}.json' + request = { + 'schema_version': TASK_LEASE_ACQUIRE_REQUEST_SCHEMA, + 'runtime_root': str(w.runtime), 'goal_id': w.goal, 'todo_id': todo, + 'owner': 'agent-a', 'idempotency_key': f'acquire-{todo}', + 'ttl_seconds': 3600, 'write_scopes': [], 'expected_version': None, + 'authority': task_lease_acquire_authority_facts( + registry_path=w.registry, goal_id=w.goal, todo_id=todo), + } + args = ['node', '--no-warnings', '--experimental-strip-types', '--input-type=module', '-e', + LEASE_WORKER, json.dumps({'request': request, 'stop_before': str(lease) if crash else None, + 'module': (REPO / 'loopx/control_plane/work_items/task_lease_acquire.ts').as_uri()})] + if not crash: + result = subprocess.run(args, cwd=REPO, capture_output=True, text=True, timeout=30, check=True) + assert json.loads(result.stdout)['acquired'] is True, result.stdout + assert json.loads(lease.read_bytes())['owner'] == 'agent-a' + return + child = subprocess.Popen(args, cwd=REPO, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + assert child.stdout is not None + assert select.select([child.stdout], [], [], 30)[0], 'native writer missed primary rename' + assert child.stdout.readline().strip() == 'BARRIER primary-rename' + child.kill() + child.communicate(timeout=10) + assert child.returncode == -9 + assert not lease.exists() + finally: + if child.poll() is None: + child.kill() + child.communicate(timeout=10) + + +@pytest.mark.parametrize('partition', ['todos', 'leases']) +@pytest.mark.parametrize('prior_mutations', [0, 1]) +@pytest.mark.parametrize('abandoned', [1, 2]) +def test_abandoned_cursor_survives_all_consumers( + tmp_path: Path, partition: str, prior_mutations: int, abandoned: int, +) -> None: + w = workspace(tmp_path, bootstrap=False) + # A nonempty baseline must still have no applied-mutation digest. + ids = [w.add(f'Baseline task {index}')['todo_id'] for index in range(3)] + assert w.cli('coordination-shadow', 'bootstrap', '--execute')['bootstrap']['status'] == 'applied' + + def mutate(index: int) -> None: + if partition == 'todos': + ids.append(w.add(f'Applied task {index}')['todo_id']) + else: + acquire(w, ids[index]) + assert w.drain()['ok'] is True + + for index in range(prior_mutations): + mutate(index) + before = w.state.read_bytes() + for index in range(abandoned): + if partition == 'todos': + w.crash('before_replace', 'todo', 'add', '--role', 'agent', '--text', f'Never applied {index}') + else: + acquire(w, ids[prior_mutations], crash=True) + assert w.state.read_bytes() == before + recovered = w.drain() + assert recovered['ok'] is True and recovered['no_op'] == 1, recovered + inspected = w.cli('coordination-shadow', 'inspect', success=False)['inspection'] + assert inspected['status'] == 'matched', inspected + assert inspected['evidence']['operation_count'] == prior_mutations + + directory = outbox.partition_directory(w.runtime, w.goal, partition) + path = directory / 'drain-cursor.json' + cursor = json.loads(path.read_bytes()) + assert cursor['last_seq'] == prior_mutations + abandoned + assert (cursor['last_partition_digest'] is None) == (prior_mutations == 0) + assert w.drain()['outcome'] == 'nothing_pending' + # Legitimate cursor loss reconstructs the same facts from real history. + path.unlink() + assert w.drain()['ok'] is True + restored = json.loads(path.read_bytes()) + assert {k: v for k, v in restored.items() if k != 'updated_at'} == { + k: v for k, v in cursor.items() if k != 'updated_at'} + assert w.cli('coordination-shadow', 'inspect')['inspection']['status'] == 'matched' + early = w.cli('coordination-shadow', 'qualify', success=False)['qualification'] + assert early['qualified'] is False and early['evidence']['operation_count'] == prior_mutations + early_read = w.cli('coordination-shadow', 'read-candidate', '--todo-id', ids[-1], success=False) + assert early_read['read_candidate']['read_candidate_qualified'] is False + for index in range(prior_mutations, 3): + mutate(index) + qualified = w.cli('coordination-shadow', 'qualify')['qualification'] + assert qualified['qualified'] is True and qualified['scope'] == 'bounded', qualified + assert qualified['evidence']['operation_count'] == 3 + assert qualified['sustained_parity_verdict'] == 'not_evaluated' + transactions = adapter.read_local_authority_shadow( + runtime_root=w.runtime, goal_id=w.goal, scan_limit=100, + )['proof']['transactions'] + assert len(transactions) == 4 + abandoned + assert [tx['receipts'][0]['seq'] for tx in transactions[1:]] == list(range(1, 4 + abandoned)) + assert sum(tx['receipts'][0]['no_op'] is True for tx in transactions[1:]) == abandoned + candidate = w.cli('coordination-shadow', 'read-candidate', '--todo-id', ids[-1]) + assert candidate['read_candidate']['read_candidate_qualified'] is True, candidate + + +@pytest.mark.parametrize('partition', ['todos', 'leases']) +@pytest.mark.parametrize('applied', [False, True]) +def test_forged_applied_digest_holds_every_consumer_without_rewriting_bytes( + tmp_path: Path, partition: str, applied: bool, +) -> None: + import hashlib + + w = workspace(tmp_path, bootstrap=False) + todo = w.add('Baseline is not mutation coverage')['todo_id'] + w.cli('coordination-shadow', 'bootstrap', '--execute') + if applied: + if partition == 'todos': + w.add('Real mutation with an applied digest') + else: + acquire(w, todo) + elif partition == 'todos': + w.crash('before_replace', 'todo', 'add', '--role', 'agent', '--text', 'Abandoned') + else: + acquire(w, todo, crash=True) + assert w.drain()['ok'] is True + view = adapter.read_local_authority_shadow(runtime_root=w.runtime, goal_id=w.goal, scan_limit=20) + projection = view['proof']['transactions'][-1]['projection'] + fields = ('handoff_mode', 'todos') if partition == 'todos' else ('leases',) + snapshot = {key: projection[key] for key in fields} + snapshot_digest = 'sha256:' + hashlib.sha256(json.dumps( + snapshot, sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode()).hexdigest() + path = outbox.partition_directory(w.runtime, w.goal, partition) / 'drain-cursor.json' + cursor = json.loads(path.read_bytes()) + cursor['last_partition_digest'] = None if applied else snapshot_digest + path.write_text(json.dumps(cursor, indent=3) + '\n') + # Exact raw bytes matter even when all the cursor's identity fields are valid. + # Lock diagnostics change and dead writer locks are reclaimed; authority evidence does not. + before = {p.relative_to(w.runtime): p.read_bytes() for p in w.runtime.rglob('*') + if p.is_file() and not p.name.endswith('.lock')} + for command, key in [('inspect', 'inspection'), ('qualify', 'qualification'), ('read-candidate', 'read_candidate')]: + args = ('--todo-id', todo) if command == 'read-candidate' else () + result = w.cli('coordination-shadow', command, *args, success=False)[key] + assert result['reason_code'] == 'outbox_cursor_unproved', result + result = w.drain() + assert result['ok'] is False and result['reason_code'] == 'outbox_cursor_unproved', result + after = {p.relative_to(w.runtime): p.read_bytes() for p in w.runtime.rglob('*') + if p.is_file() and not p.name.endswith('.lock')} + assert after == before diff --git a/tests/control_plane/test_shadow_observable_e2e.py b/tests/control_plane/test_shadow_observable_e2e.py new file mode 100644 index 0000000000..b0f16fc8fb --- /dev/null +++ b/tests/control_plane/test_shadow_observable_e2e.py @@ -0,0 +1,403 @@ +"""Caller semantics across capture activation, with optional base/head evidence. + +LOOPX_SHADOW_COMPARISON_SOURCE selects an immutable checkout for every child; +LOOPX_SHADOW_COMPARISON_OUTPUT retains complete responses and persisted bytes. +Neither changes the oracle. No product result or persistence operation is mocked. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +pytestmark = pytest.mark.stage2c_e2e +SOURCE = Path(os.environ.get('LOOPX_SHADOW_COMPARISON_SOURCE', Path(__file__).resolve().parents[2])).resolve() + + +class Caller: + def __init__(self, path: Path, mode: str, name: str): + self.path, self.mode, self.name = path, mode, name + self.state, self.registry, self.root = path / 'STATE.md', path / 'registry.json', path / 'runtime' + self.env = dict(os.environ, PYTHONPATH=str(SOURCE)) + self.rows: list[dict] = [] + self.state.write_text('---\ngoal_id: observable\nhandoff_mode: soft_claim\n' + 'updated_at: 2026-09-01T00:00:00+00:00\n---\n\n## Agent Todo\n\n' + '## Progress Ledger\n\n## Next Action\n\n- Inspect.\n') + coordination = {'agent_model': 'peer_v1', 'registered_agents': ['agent-a', 'agent-b']} + if mode != 'absent': + coordination['runtime_shadow'] = {'schema_version': 'loopx_coordination_runtime_shadow_config_v0', + 'enabled': mode == 'enabled', 'provider': 'file_v0'} + self.registry.write_text(json.dumps({'common_runtime_root': str(self.root), 'goals': [{ + 'id': 'observable', 'status': 'active', 'repo': str(path), 'state_file': self.state.name, + 'coordination': coordination}]})) + actual = subprocess.check_output([sys.executable, '-c', 'import loopx; print(loopx.__file__)'], + env=self.env, cwd=path, text=True).strip() + assert Path(actual).resolve().is_relative_to(SOURCE) + if mode == 'enabled': + assert self.call('coordination-shadow', 'bootstrap', '--execute')['bootstrap']['status'] == 'applied' + + def files(self) -> dict[str, str]: + # All fixture files, including receipts. Locks are retained in raw evidence too. + return {str(p.relative_to(self.path)): p.read_bytes().hex() + for p in sorted(self.path.rglob('*')) if p.is_file()} + + def primary(self) -> dict[str, str]: + return {k: v for k, v in self.files().items() + if k == 'STATE.md' or k.startswith('runtime/goals/') and not k.endswith('.lock')} + + def call(self, *args: str, goal: str = 'observable') -> dict: + command = [sys.executable, '-m', 'loopx.cli', '--registry', str(self.registry), + '--runtime-root', str(self.root), '--format', 'json', *args, '--goal-id', goal] + return self.invoke(command, command[3:]) + + def invoke(self, command: list[str], arguments: list[str], *, cwd: Path | None = None) -> dict: + before = self.files() + result = subprocess.run(command, env=self.env, cwd=cwd or self.path, capture_output=True, text=True, timeout=45) + assert 'Traceback' not in result.stderr, result.stderr + row = {'arguments': arguments, 'exit': result.returncode, 'stdout': result.stdout, + 'stderr': result.stderr, 'files_before': before, 'files_after': self.files()} + self.rows.append(row) + destination = os.environ.get('LOOPX_SHADOW_COMPARISON_OUTPUT') + if destination: + output = Path(destination) + output.mkdir(parents=True, exist_ok=True) + (output / (self.name + '.json')).write_text(json.dumps({ + 'source': str(SOURCE), 'workspace': str(self.path), 'mode': self.mode, 'rows': self.rows}, indent=2)) + return json.loads(result.stdout) if result.stdout else {'stderr': result.stderr, 'exit': result.returncode} + + def add(self, text: str, *extra: str) -> str: + result = self.call('todo', 'add', '--role', 'agent', '--text', text, *extra) + assert result['ok'] is True, result + return result['todo_id'] + + def read(self, todo: str) -> dict: + result = self.call('todo', 'list') + assert result['ok'] is True, result + return next(item for item in result['todos'] if item['todo_id'] == todo) + + +@pytest.fixture(params=['absent', 'disabled', 'enabled']) +def caller(tmp_path: Path, request: pytest.FixtureRequest) -> Caller: + return Caller(tmp_path, request.param, request.node.name) + + +def test_todo_argument_intent_and_rejections(caller: Caller) -> None: + w = caller + before = w.primary() + preview = w.call('todo', 'add', '--role', 'agent', '--text', 'Retain operator intent', '--dry-run') + assert preview['ok'] is True and w.primary() == before + todo = w.add('Retain operator intent', '--claimed-by', 'agent-a', '--note', 'Initial context') + assert w.read(todo)['claimed_by'] == 'agent-a' + for args, expected_note in [(('--note', 'Updated context'), 'Updated context'), + (('--text', 'Corrected operator intent'), 'Updated context')]: + result = w.call('todo', 'update', '--todo-id', todo, '--agent-id', 'agent-a', *args) + assert result['ok'] is True, result + current = w.read(todo) + assert current['note'] == expected_note and current['claimed_by'] == 'agent-a' + assert w.read(todo)['text'] == 'Corrected operator intent' + # CLI mutable-field validation treats an empty note as absent, not a clear. + before = w.primary() + empty = w.call('todo', 'update', '--todo-id', todo, '--agent-id', 'agent-a', '--note', '') + assert empty['error'] == 'todo update requires at least one mutable todo field' + assert w.primary() == before + assert w.call('todo', 'update', '--todo-id', todo, '--agent-id', 'agent-a', '--clear-claim')['ok'] is True + assert not w.read(todo).get('claimed_by') + before = w.primary() + # Overlapping invalid target and actor: preserve the complete diagnostic/priority. + rejected = w.call('todo', 'update', '--todo-id', 'todo_missing', '--agent-id', 'unknown', '--note', 'Must not write') + assert rejected['ok'] is False and w.primary() == before, rejected + assert w.call('todo', 'claim', '--todo-id', todo, '--claimed-by', 'agent-a', '--agent-id', 'agent-a')['ok'] is True + complete = w.call('todo', 'complete', '--todo-id', todo, '--agent-id', 'agent-a', + '--evidence', 'validation://caller', '--no-follow-up') + assert complete['ok'] is True, complete + assert w.read(todo)['done'] is True + archived = w.call('todo', 'archive-completed', '--max-active-done', '0', '--execute') + assert archived['ok'] is True, archived + assert 'Corrected operator intent' in w.state.read_text() + + +def test_handoff_followup_preview_batch_and_quiescence(caller: Caller) -> None: + w = caller + args = ('todo', 'capture-followups', '--follow-up', 'First bounded followup', + '--follow-up', 'Second bounded followup', '--evidence', 'validation://followups') + before = w.primary() + assert w.call(*args, '--dry-run')['recorded_count'] == 2 + assert w.primary() == before + assert w.call(*args)['recorded_count'] == 2 + before = w.primary() + assert w.call(*args)['recorded_count'] == 0 + assert w.primary() == before + assert w.call('handoff-mode', 'set', '--mode', 'hard_lease')['changed'] is True + before = w.primary() + assert w.call('handoff-mode', 'set', '--mode', 'hard_lease')['changed'] is False + assert w.primary() == before + todo = w.add('Quiescence ownership') + acquired = w.call('task-lease', 'acquire', '--todo-id', todo, '--owner', 'agent-a', '--idempotency-key', 'quiescence') + assert acquired['acquired'] is True, acquired + before = w.primary() + blocked = w.call('handoff-mode', 'set', '--mode', 'soft_claim') + assert blocked['error_code'] == 'handoff_mode_not_quiescent' and w.primary() == before + malformed = w.call('handoff-mode', 'set', '--mode', 'invalid') + assert malformed['exit'] == 2 and w.primary() == before + + +def test_lease_arguments_cas_transfer_and_replay(caller: Caller) -> None: + w = caller + assert w.call('handoff-mode', 'set', '--mode', 'hard_lease')['ok'] is True + todo = w.add('Lease argument round trip') + args = ('task-lease', 'acquire', '--todo-id', todo, '--owner', 'agent-a', + '--idempotency-key', 'caller-acquire', '--ttl-seconds', '3600', '--write-scope', 'src/**') + result = w.call(*args) + assert result['acquired'] is True, result + lease_path = w.root / 'goals' / 'observable' / 'task-leases' / f'{todo}.json' + persisted = json.loads(lease_path.read_bytes()) + assert persisted['owner'] == 'agent-a' and persisted['write_scopes'] == ['src/**'] + assert persisted['acquire_ttl_seconds'] == 3600 + before = lease_path.read_bytes() + assert w.call(*args)['ok'] is True + assert lease_path.read_bytes() == before + before = w.primary() + rejected = w.call('task-lease', 'renew', '--todo-id', todo, '--owner', 'agent-b', + '--idempotency-key', 'wrong-key', '--expected-version', '999') + assert rejected['ok'] is False and w.primary() == before, rejected + renewed = w.call('task-lease', 'renew', '--todo-id', todo, '--owner', 'agent-a', + '--idempotency-key', 'caller-acquire', '--expected-version', str(persisted['version']), '--ttl-seconds', '7200') + assert renewed['ok'] is True, renewed + transferred = w.call('task-lease', 'transfer', '--todo-id', todo, '--owner', 'agent-a', + '--idempotency-key', 'caller-acquire', '--expected-version', str(renewed['lease']['version']), + '--new-owner', 'agent-b', '--new-idempotency-key', 'caller-transfer') + assert transferred['ok'] is True, transferred + assert json.loads(lease_path.read_bytes())['owner'] == 'agent-b' + released = w.call('task-lease', 'release', '--todo-id', todo, '--owner', 'agent-b', + '--idempotency-key', 'caller-transfer', '--expected-version', str(transferred['lease']['version'])) + assert released['ok'] is True, released + assert json.loads(lease_path.read_bytes())['status'] == 'released' + assert w.call('task-lease', 'inspect', '--todo-id', todo)['ok'] is True + + +def test_refresh_and_reward_owned_prose(caller: Caller) -> None: + w = caller + todo = w.add('Canonical record must survive prose') + record = w.read(todo) + args = ('refresh-state', '--agent-id', 'agent-a', '--progress-scope', 'goal', '--classification', 'continue', + '--recommended-action', 'Inspect persisted arguments.', '--vision-unchanged-reason', 'Same bounded validation.', + '--next-action', 'Read the independent lease snapshot.', '--no-global-sync') + before = w.primary() + assert w.call(*args, '--dry-run')['ok'] is True + assert w.primary() == before + refreshed = w.call(*args) + assert refreshed['ok'] is True, refreshed + assert 'Read the independent lease snapshot.' in w.state.read_text() + assert w.read(todo) == record + args = ('reward', '--recorded-at', '2026-09-01T12:00:00+00:00', '--decision', 'continue', + '--reward', 'positive', '--reason-summary', 'Retained argument evidence.', '--write-active-state-summary') + before = w.primary() + assert w.call(*args, '--dry-run')['ok'] is True + assert w.primary() == before + reward = w.call(*args) + assert reward['ok'] is True, reward + assert 'Retained argument evidence.' in w.state.read_text() + index = w.root / 'goals' / 'observable' / 'runs' / 'index.jsonl' + assert 'Retained argument evidence.' in index.read_text() + assert w.read(todo) == record + before = w.primary() + rejected = w.call('reward', '--run-generated-at', 'missing', '--decision', '', '--reward', 'positive', '--reason-summary', '') + assert rejected['ok'] is False and w.primary() == before, rejected + + +@pytest.mark.parametrize('replacement', ['force', 'missing']) +def test_bootstrap_replacement_preserves_existing_authority(caller: Caller, replacement: str) -> None: + w = caller + w.add('Existing canonical state') + args = ('bootstrap', '--project', str(w.path), '--state-file', 'STATE.md', + '--objective', 'Replacement objective', '--no-onboarding-scan', '--no-global-sync') + assert w.call(*args, '--dry-run')['ok'] is True + if replacement == 'missing': + w.state.unlink() + before = w.registry.read_bytes(), w.state.read_bytes() if w.state.exists() else None + result = w.call(*args, *(['--force'] if replacement == 'force' else [])) + if w.mode == 'enabled': + assert result['ok'] is False, result + assert (w.registry.read_bytes(), w.state.read_bytes() if w.state.exists() else None) == before + else: + assert result['ok'] is True, result + assert 'Replacement objective' in w.state.read_text() + assert json.loads(w.registry.read_bytes())['common_runtime_root'] == str(w.root) + + +def test_project_registration_and_missing_state_reconstruction(caller: Caller) -> None: + w = caller + args = ('project', 'register', '--project-id', 'registered-project', '--project-kind', 'work', + '--knowledge-root', str(w.path / 'knowledge'), '--objective', 'Preserve project intent', + '--acceptance', 'Independent state readback', '--non-goal', 'Remote promotion', + '--next-effect', 'Inspect the source', '--stop-condition', 'Readback matches') + created = w.call(*args, goal='registered') + assert created['ok'] is True, created + state = Path(created['state_file']) + assert 'Preserve project intent' in state.read_text() + assert 'Independent state readback' in state.read_text() + assert w.call(*args, goal='registered')['ok'] is True + if w.mode == 'enabled': + registry = json.loads(w.registry.read_bytes()) + goal = next(g for g in registry['goals'] if g['id'] == 'registered') + goal.setdefault('coordination', {})['runtime_shadow'] = { + 'schema_version': 'loopx_coordination_runtime_shadow_config_v0', 'enabled': True, 'provider': 'file_v0'} + w.registry.write_text(json.dumps(registry)) + assert w.call('coordination-shadow', 'bootstrap', '--execute', goal='registered')['bootstrap']['status'] == 'applied' + state.unlink() + before = w.registry.read_bytes() + rebuilt = w.call(*args, goal='registered') + if w.mode == 'enabled': + assert rebuilt['ok'] is False, rebuilt + assert not state.exists() and w.registry.read_bytes() == before + else: + assert rebuilt['ok'] is True, rebuilt + assert 'Preserve project intent' in state.read_text() + + +def test_migration_target_and_preview_ownership(caller: Caller) -> None: + w = caller + legacy = w.path / 'legacy' + legacy.mkdir() + source = legacy / 'STATE.md' + source.write_text('---\ngoal_id: legacy\nhandoff_mode: soft_claim\n---\n\n## Agent Todo\n\n- [ ] Migrated source.\n') + source_registry = legacy / 'registry.json' + source_registry.write_text(json.dumps({'goals': [{'id': 'legacy', 'repo': str(legacy), 'state_file': 'STATE.md'}]})) + args = ('migrate-state', '--legacy-registry', str(source_registry), + '--legacy-runtime-root', str(legacy / 'runtime'), '--target-runtime-root', str(w.root), + '--goal-id-map', 'legacy=observable', '--path-map', f'{legacy}={w.path}', + '--copy-active-state', '--no-global-sync') + before = w.registry.read_bytes(), w.state.read_bytes() + preview = w.call(*args, goal='legacy') + assert preview['ok'] is True, preview + assert (w.registry.read_bytes(), w.state.read_bytes()) == before + result = w.call(*args, '--execute', goal='legacy') + if w.mode == 'enabled': + assert result['ok'] is False, result + assert (w.registry.read_bytes(), w.state.read_bytes()) == before + else: + assert result['ok'] is True, result + assert 'goal_id: observable' in w.state.read_text() and 'Migrated source.' in w.state.read_text() + assert json.loads(w.registry.read_bytes())['goals'][0]['id'] == 'observable' + + +def test_operator_reads_and_invalid_selector_have_no_primary_effect(caller: Caller) -> None: + w = caller + todo = w.add('Read policy fixture') + before = w.primary() + for command in ['inspect', 'qualify', 'read-candidate']: + args = ('--todo-id', todo) if command == 'read-candidate' else () + result = w.call('coordination-shadow', command, *args) + assert result['ok'] is (w.mode == 'enabled' and command == 'inspect'), result + assert w.primary() == before + invalid = w.call('coordination-shadow', 'rollback', '--provider-revision', '', '--execute') + assert invalid['ok'] is False and w.primary() == before, invalid + # Availability alone never constructs a candidate/outbox in the disabled goal. + if w.mode != 'enabled': + assert not (w.root / 'authority-shadow').exists() + + +def test_monitor_successor_retains_caller_routing(caller: Caller) -> None: + w = caller + todo = w.add('Observe the public release', '--task-class', 'continuous_monitor', '--action-kind', 'monitor', + '--claimed-by', 'agent-a', '--target-key', 'release:bounded', '--cadence', '30m', + '--next-due-at', '2000-01-01T00:00:00+00:00', '--watch-only') + result = w.call('quota', 'monitor-poll', '--agent-id', 'agent-a', '--runtime-profile', 'generic_cli', + '--available-capability', 'network', '--todo-id', todo, '--target-key', 'release:bounded', + '--result-hash', 'release-v1', '--material-change', '--next-agent-todo', 'Validate released head', + '--next-action-kind', 'validate_release_head', '--next-task-repository', 'git:github.com/huangruiteng/loopx', + '--next-required-capability', 'network', '--next-continuation-policy', 'same_agent_non_delivery', + '--next-claimed-by', 'agent-a', '--execute') + assert result['ok'] is True and len(result['successor_todo_ids']) == 1, result + successor = w.read(result['successor_todo_ids'][0]) + assert successor['text'] == 'Validate released head' and successor['claimed_by'] == 'agent-a' + assert successor['action_kind'] == 'validate_release_head' and successor['required_capabilities'] == ['network'] + + +def test_observation_remains_independent_of_runtime_capture(caller: Caller) -> None: + w = caller + before = w.primary() + args = ('configure-goal', '--local-authority-shadow-file') + assert w.call(*args)['ok'] is True + assert w.primary() == before + assert w.call(*args, '--execute')['ok'] is True + todo = w.add('Independent observation contract') + assert w.read(todo)['text'] == 'Independent observation contract' + retained = sorted((w.root / 'authority-shadow' / 'file' / 'observable').glob('authority-store-*.json')) + assert len(retained) == 1 + snapshot = retained[0].read_bytes() + assert w.call('configure-goal', '--clear-local-authority-shadow', '--execute')['ok'] is True + assert retained[0].read_bytes() == snapshot + if w.mode != 'enabled': + assert not (w.root / 'authority-shadow' / 'file-v0').exists() + + +def test_turn_input_rejection_has_no_host_or_primary_effect(caller: Caller) -> None: + w = caller + before = w.primary() + result = w.call('turn', 'run-once', '--project', str(w.path), '--agent-id', 'unknown', '--no-global-sync') + assert result['ok'] is False, result + assert result['effects']['host_invoked'] is False and result['effects']['state_written'] is False + assert w.primary() == before + + +def test_registry_relative_root_does_not_depend_on_callers_cwd(caller: Caller) -> None: + w = caller + registry = json.loads(w.registry.read_bytes()) + registry['common_runtime_root'] = 'runtime' + w.registry.write_text(json.dumps(registry)) + cwd = w.path / 'unrelated-cwd' + cwd.mkdir() + command = [sys.executable, '-m', 'loopx.cli', '--registry', str(w.registry), '--format', 'json', + 'todo', 'add', '--goal-id', 'observable', '--role', 'agent', '--text', 'Registry relative root'] + added = w.invoke(command, command[3:], cwd=cwd) + assert added['ok'] is True, added + assert w.read(added['todo_id'])['text'] == 'Registry relative root' + assert (w.root / 'goals' / 'observable' / 'rollout-event-log.jsonl').exists() + assert not (cwd / 'runtime').exists() + assert w.call('handoff-mode', 'set', '--mode', 'hard_lease')['ok'] is True + command = [*command[:7], 'task-lease', 'acquire', '--goal-id', 'observable', + '--todo-id', added['todo_id'], '--owner', 'agent-a', '--idempotency-key', 'relative-lease'] + lease = w.invoke(command, command[3:], cwd=cwd) + assert lease['ok'] is True, lease + assert (w.root / 'goals' / 'observable' / 'task-leases' / f"{added['todo_id']}.json").exists() + assert not (cwd / 'runtime').exists() + + +def test_legacy_holder_verify_and_terminal_release(caller: Caller) -> None: + w = caller + w.call('handoff-mode', 'set', '--mode', 'hard_lease') + todo = w.add('Holder and terminal write boundaries') + before = w.primary() + rejected_claim = w.call('todo', 'claim', '--todo-id', todo, '--claimed-by', 'agent-a', '--agent-id', 'agent-a', + '--task-lease-idempotency-key', 'caller-holder', '--task-lease-expected-version', '0') + assert rejected_claim['error'] == '--task-lease-idempotency-key on todo claim requires promoted canonical authority; no legacy write attempted' + assert w.primary() == before + assert w.call('task-lease', 'acquire', '--todo-id', todo, '--owner', 'agent-a', '--idempotency-key', 'caller-holder')['ok'] is True + claim = w.call('todo', 'claim', '--todo-id', todo, '--claimed-by', 'agent-a', '--agent-id', 'agent-a') + assert claim['ok'] is True, claim + path = w.root / 'goals' / 'observable' / 'task-leases' / f'{todo}.json' + lease = json.loads(path.read_bytes()) + assert lease['owner'] == 'agent-a' and lease['status'] == 'active' + before = w.primary() + rejected = w.call('todo', 'complete', '--todo-id', todo, '--agent-id', 'agent-a', + '--evidence', 'validation://terminal', '--no-follow-up') + assert rejected['error_code'] == 'lease_fence_required', rejected + after = w.primary() + assert {key: after[key] for key in before} == before + # The existing verify protocol retains an acquired intent, never a lease or success receipt. + added = set(after) - set(before) + assert len(added) == 1 + intent = json.loads(bytes.fromhex(after[added.pop()])) + assert intent['schema_version'] == 'task_lease_fence_receipt_v0' and intent['state'] == 'acquired' + assert intent['lease'] is None and intent['response'] is None and intent['verify_response'] is None + result = w.call('todo', 'complete', '--todo-id', todo, '--agent-id', 'agent-a', + '--task-lease-idempotency-key', 'caller-holder', '--task-lease-expected-version', str(lease['version']), + '--evidence', 'validation://terminal', '--no-follow-up') + assert result['ok'] is True and result['task_lease_fence']['released'] is True, result + assert json.loads(path.read_bytes())['status'] == 'released' + assert w.read(todo)['done'] is True diff --git a/tests/control_plane/test_shadow_observable_native_e2e.py b/tests/control_plane/test_shadow_observable_native_e2e.py new file mode 100644 index 0000000000..05613e5778 --- /dev/null +++ b/tests/control_plane/test_shadow_observable_native_e2e.py @@ -0,0 +1,174 @@ +"""Independent native and HTTP consumers of the shared changed writer boundary.""" +from __future__ import annotations + +import json +import sys + +import pytest + +from test_shadow_observable_e2e import Caller, SOURCE, caller as caller + +pytestmark = pytest.mark.stage2c_e2e + +NATIVE = r""" +import {join} from 'node:path'; +const input = JSON.parse(process.argv[1]); +const {FileAuthorityStore} = await import(input.base + '/file_authority_store.ts'); +const store = new FileAuthorityStore(join(input.root, 'authority', 'file-v0'), 'observable'); +let result; +if (input.action === 'seed') { + result = await store.commitAuthority({expected_provider_revision:null, operation_id:'caller-fixture', + events:[], next_projection:input.request, receipts:[]}); + const {engageLegacyCoordinationWriterFence} = await import(input.base + '/legacy_writer_fence.ts'); + const {canonicalAuthoritySha256} = await import(input.base + '/authority_store_codec.ts'); + const fence = await engageLegacyCoordinationWriterFence({schema_version:'loopx_legacy_coordination_writer_fence_engage_request_v0', + runtime_root:input.root,goal_id:'observable',state_path:input.state, + fence:{schema_version:'loopx_legacy_coordination_writer_fence_v0',state:'engaged',goal_id:'observable', + fence_id:'caller-fixture',source_version:'caller-fixture',source_projection_sha256:canonicalAuthoritySha256(input.request), + expected_shadow_provider_revision:result.provider_revision}}); + if(fence.status !== 'applied') throw new Error(JSON.stringify(fence)); +} else if (input.action === 'read') { + result = {head:await store.loadAuthority(), history:await store.scanCommitted(null, 100)}; +} else { + const owner = await import(input.base + '/local_authority_runtime.ts'); + result = await owner[input.action](input.request); +} +process.stdout.write(JSON.stringify(result) + '\n'); +""" + + +def native(w: Caller, action: str, request: dict) -> dict: + value = {'base': (SOURCE / 'loopx/control_plane/coordination').as_uri(), + 'root': str(w.root), 'state': str(w.state), 'action': action, 'request': request} + return w.invoke(['node', '--no-warnings', '--experimental-strip-types', '--input-type=module', + '-e', NATIVE, json.dumps(value)], ['native', action, json.dumps(request)]) + + +def test_canonical_argument_intent_and_atomic_claim(caller: Caller) -> None: + w = caller + todo = w.add('Native canonical input', '--note', 'Preserve operator note', '--required-write-scope', 'src/**') + record = w.read(todo) + builder = "from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection as build; import json,sys; value=build(goal_id='observable', todos=[json.loads(sys.argv[1])]); value['handoff_mode']='hard_lease'; print(json.dumps(value))" + projection = w.invoke([sys.executable, '-c', builder, json.dumps(record)], ['fixture-projection', json.dumps(record)]) + assert native(w, 'seed', projection)['status'] == 'applied' + w.state.unlink() + before = native(w, 'read', {}) + claim = ('todo', 'claim', '--todo-id', todo, '--claimed-by', 'agent-a', '--agent-id', 'agent-a', + '--claim-operation-id', 'caller-atomic-claim', '--task-lease-idempotency-key', 'caller-ownership', + '--task-lease-expected-version', '0') + assert w.call(*claim, '--dry-run')['status'] == 'planned' + assert native(w, 'read', {}) == before + applied = w.call(*claim) + assert applied['status'] == 'applied', applied + replay = w.call(*claim) + assert replay['status'] == 'replayed' and replay['original_receipt'] == applied['original_receipt'] + stored = native(w, 'read', {})['head']['head'] + assert stored['todos'][0]['claimed_by'] == 'agent-a' + assert stored['leases'][0]['owner'] == 'agent-a' and stored['leases'][0]['write_scopes'] == ['src/**'] + before = native(w, 'read', {}) + rejected = w.call('todo', 'update', '--todo-id', todo, '--agent-id', 'agent-b', '--note', 'Foreign owner edit') + assert rejected['error_code'] == 'update_owner_mismatch' + assert rejected['error'] == rejected['reason'] == "Todo update cannot edit another claim owner's work" + assert native(w, 'read', {}) == before + assert not w.state.exists() + + +def test_native_unclaimed_edit_and_explicit_note_clear(caller: Caller) -> None: + w = caller + todo = w.add('Unclaimed correction', '--note', 'Keep until explicitly cleared') + record = w.read(todo) + builder = "from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection as build; import json,sys; value=build(goal_id='observable', todos=[json.loads(sys.argv[1])]); value['handoff_mode']='soft_claim'; print(json.dumps(value))" + projection = w.invoke([sys.executable, '-c', builder, json.dumps(record)], ['fixture-projection', json.dumps(record)]) + assert native(w, 'seed', projection)['status'] == 'applied' + w.state.unlink() + assert w.call('todo', 'update', '--todo-id', todo, '--agent-id', 'agent-b', '--note', 'Claim-neutral context')['ok'] is True + stored = native(w, 'read', {})['head']['head'] + assert stored['todos'][0]['note'] == 'Claim-neutral context' + assert not stored['todos'][0].get('claimed_by') and stored['leases'] == [] + request = {'schema_version': 'loopx_local_coordination_todo_update_request_v0', + 'runtime_root': str(w.root), 'goal_id': 'observable', 'todo_id': todo, 'role': 'agent', + 'actor_agent_id': 'agent-b', 'registered_agents': ['agent-a', 'agent-b'], + 'operation_id': 'caller-native-clear', 'patch': {}, 'clear_fields': ['note'], + 'dry_run': False, 'observed_at': '2026-09-07T00:00:00Z'} + cleared = native(w, 'updateLocalCoordinationTodo', request) + assert cleared['status'] == 'applied', cleared + after = native(w, 'read', {}) + assert 'note' not in after['head']['head']['todos'][0] + assert after['head']['head']['leases'] == stored['leases'] + rejected = native(w, 'updateLocalCoordinationTodo', {**request, 'operation_id': 'rejected', + 'todo_id': 'todo_missing', 'actor_agent_id': 'unknown', 'patch': {'text': 'Must not persist'}, 'clear_fields': []}) + assert rejected['status'] in {'failed', 'rejected'}, rejected + assert native(w, 'read', {}) == after + assert not w.state.exists() + + +HTTP = r""" +import http.client, json, pathlib, sys, threading +from loopx.status_server import StatusHTTPServer, StatusRequestHandler +args=json.loads(sys.argv[1]); server=StatusHTTPServer(('127.0.0.1',0),StatusRequestHandler) +server.verbose=False; server.registry_path=pathlib.Path(args['registry']); server.runtime_root_override=args['root'] +server.reward_write_enabled=True; server.reward_dry_run_path='/reward/dry-run'; server.reward_append_path='/reward/append' +thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() +rows=[] +def post(path,body): + conn=http.client.HTTPConnection('127.0.0.1',server.server_address[1],timeout=10) + conn.request('POST',path,json.dumps(body),{'Content-Type':'application/json','Origin':'http://localhost'}) + response=conn.getresponse(); payload=json.loads(response.read()); conn.close() + rows.append({'request':body,'status':response.status,'body':payload}); return payload +try: + body={'goal_id':'observable','run_generated_at':'2026-09-01T00:00:00Z', + 'recorded_at':'2026-09-01T01:00:00Z','decision':'continue','reward':'positive', + 'reason_summary':'HTTP argument readback','write_active_state_summary':True} + post('/reward/append',body) + preview=post('/reward/dry-run',{k:v for k,v in body.items() if k!='write_active_state_summary'}) + if 'preview_id' in preview: + post('/reward/append',{**body,'preview_id':preview['preview_id']}) +finally: + server.shutdown(); server.server_close(); thread.join(timeout=5) +print(json.dumps({'rows':rows})) +""" + + +def test_http_reward_preserves_preview_gate_and_persisted_arguments(caller: Caller) -> None: + w = caller + todo = w.add('HTTP must preserve Todo ownership', '--claimed-by', 'agent-a') + record = w.read(todo) + index = w.root / 'goals' / 'observable' / 'runs' / 'index.jsonl' + index.parent.mkdir(parents=True, exist_ok=True) + index.write_text(json.dumps({'generated_at': '2026-09-01T00:00:00Z', 'json_path': 'run.json', + 'markdown_path': 'run.md', 'classification': 'continue'}) + '\n') + result = w.invoke([sys.executable, '-c', HTTP, json.dumps({'registry': str(w.registry), 'root': str(w.root)})], + ['HTTP', 'reward preview then append']) + first, preview, appended = result['rows'] + assert first['status'] == 400 and first['body']['error'] == 'preview_id is required' + assert preview['status'] == 200 and preview['body']['dry_run'] is True + assert appended['status'] == 200 and appended['body']['appended'] is True + assert len(index.read_text().splitlines()) == 2 + assert 'HTTP argument readback' in index.read_text() and 'HTTP argument readback' in w.state.read_text() + assert w.read(todo) == record + + +def test_event_writer_retains_primary_semantics_and_cannot_qualify(caller: Caller) -> None: + w = caller + seed = """ +import json,sys +from pathlib import Path +from loopx.event_sourced_state import AppendOnlyStateEventStore, TODO_ADDED, make_state_event +store=AppendOnlyStateEventStore(Path(sys.argv[1])) +store.append(make_state_event(event_id='evt-caller-fixture', goal_id='observable',event_type=TODO_ADDED, + refs={'todo_id':'todo_event_fixture'},payload={'role':'agent','title':'Event-owned task', + 'task_class':'advancement_task','claimed_by':'agent-a'},recorded_at='2026-09-01T00:00:00Z')) +print(json.dumps({'events':len(store.load())})) +""" + log = w.path / 'events.jsonl' + assert w.invoke([sys.executable, '-c', seed, str(log)], ['event-source', 'seed'])['events'] == 1 + before = w.state.read_bytes() + completed = w.call('todo', 'complete', '--todo-id', 'todo_event_fixture', '--agent-id', 'agent-a', + '--evidence', 'validation://event-caller', '--no-follow-up') + assert completed['ok'] is True, completed + assert w.state.read_bytes() == before + assert w.read('todo_event_fixture')['done'] is True + assert len(log.read_text().splitlines()) > 1 + if w.mode == 'enabled': + result = w.call('coordination-shadow', 'qualify') + assert result['ok'] is False and result['error'] == 'event_log_writer_not_bound', result From 126eb84212a39b8525b87ccc89f3d0acd5cbf81d Mon Sep 17 00:00:00 2001 From: wchwawa Date: Mon, 7 Sep 2026 11:15:53 +1000 Subject: [PATCH 15/27] fix(coordination): preserve applied cursor digests across no-op prefixes Signed-off-by: wchwawa --- .../coordination/local_authority_shadow.ts | 3 +++ .../local_authority_shadow_adapter.py | 15 +++++++-------- .../control_plane/coordination/runtime_shadow.ts | 5 +++-- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/loopx/control_plane/coordination/local_authority_shadow.ts b/loopx/control_plane/coordination/local_authority_shadow.ts index 608ca461c0..d00a15624c 100644 --- a/loopx/control_plane/coordination/local_authority_shadow.ts +++ b/loopx/control_plane/coordination/local_authority_shadow.ts @@ -700,6 +700,9 @@ function todoReadModel(todos: readonly JsonObject[]): JsonObject { /** * Fold one partition into the candidate head. A v0 head (whole-snapshot * observation) is accepted as the starting point with no partition markers. + * Markers describe the last actual mutation, not the last settled entry. Both + * drain and qualification read this verified marker for the cursor digest; + * bootstrap and no-op prefixes retain null, even with a nonempty baseline. */ export function composeLocalAuthorityShadowHead( current: JsonObject | null, diff --git a/loopx/control_plane/coordination/local_authority_shadow_adapter.py b/loopx/control_plane/coordination/local_authority_shadow_adapter.py index 18e8be7006..8471c3ce5c 100644 --- a/loopx/control_plane/coordination/local_authority_shadow_adapter.py +++ b/loopx/control_plane/coordination/local_authority_shadow_adapter.py @@ -624,8 +624,7 @@ def _reconcile( or anchor.get("operation_id") != cursor["last_entry_id"] or anchor.get("cursor") != cursor["last_cursor"] or anchor.get("provider_revision") != cursor["last_provider_revision"] - or partition_digest(self._projection(anchor)) - != cursor["last_partition_digest"] + or self._cursor_digest(anchor) != cursor["last_partition_digest"] ): raise outbox.OutboxError( "outbox_cursor_unproved", "cursor has no exact history anchor" @@ -688,7 +687,7 @@ def _reconcile( raise outbox.OutboxError( "outbox_file_changed", "outbox changed during proof" ) - digest = partition_digest(self._projection(last)) + digest = self._cursor_digest(last) if cursor is None or cursor["last_seq"] != len(history): outbox.write_cursor( self._directory, @@ -722,11 +721,11 @@ def _reconcile( self._budget.consumed += 1 return [entry for entry in entries if entry.seq not in history] - def _projection(self, transaction: dict[str, Any]) -> dict[str, Any]: - head = transaction["projection"] - if self._partition == TODO_PARTITION: - return {"handoff_mode": head["handoff_mode"], "todos": head["todos"]} - return {"leases": head["leases"]} + def _cursor_digest(self, transaction: dict[str, Any]) -> str | None: + # The native history validator owns this applied-mutation marker. + # Settled no-ops advance position but never synthesize a baseline digest. + marker = transaction["projection"]["partitions"][self._partition] + return None if marker is None else marker["partition_digest"] def _resolve( self, entry: outbox.OutboxEntry, pending: list[outbox.OutboxEntry] diff --git a/loopx/control_plane/coordination/runtime_shadow.ts b/loopx/control_plane/coordination/runtime_shadow.ts index 0acfc63fd1..e03d2efb0e 100644 --- a/loopx/control_plane/coordination/runtime_shadow.ts +++ b/loopx/control_plane/coordination/runtime_shadow.ts @@ -232,8 +232,9 @@ async function pendingOutbox(root: string, goal: string, if (anchor === undefined || anchor.operation_id !== cursor.last_entry_id || anchor.receipts[0]?.seq !== cursor.last_seq || anchor.cursor !== cursor.last_cursor || anchor.provider_revision !== cursor.last_provider_revision) throw new ShadowLineageError("outbox_cursor_unproved"); - const applied = settled.filter((transaction) => transaction.receipts[0]?.no_op === false).at(-1); - if ((applied?.receipts[0]?.partition_digest ?? null) !== cursor.last_partition_digest) throw new ShadowLineageError("outbox_cursor_unproved"); + const marker = (anchor.projection.partitions as JsonObject)[partition]; + const digest = marker === null ? null : (marker as JsonObject).partition_digest; + if (digest !== cursor.last_partition_digest) throw new ShadowLineageError("outbox_cursor_unproved"); } return false; } From 963e44e933f1c8738efad4765dfe9665c78e72be Mon Sep 17 00:00:00 2001 From: wchwawa Date: Mon, 7 Sep 2026 11:15:53 +1000 Subject: [PATCH 16/27] fix(cli): keep rollout evidence on the registry runtime root Signed-off-by: wchwawa --- loopx/cli_commands/task_lease.py | 4 +--- loopx/cli_rollout.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/loopx/cli_commands/task_lease.py b/loopx/cli_commands/task_lease.py index 4b39adb8a1..6443d1aa3e 100644 --- a/loopx/cli_commands/task_lease.py +++ b/loopx/cli_commands/task_lease.py @@ -212,7 +212,6 @@ def handle_task_lease_command( "schema_version": "task_lease_v0", "action": getattr(args, "task_lease_command", None), "error": str(exc), - **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), "error_code": exc.code, **exc.payload, } @@ -222,7 +221,6 @@ def handle_task_lease_command( "schema_version": "task_lease_v0", "action": getattr(args, "task_lease_command", None), "error": str(exc), - **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), **exc.to_payload(), } except Exception as exc: @@ -231,7 +229,7 @@ def handle_task_lease_command( "schema_version": "task_lease_v0", "action": getattr(args, "task_lease_command", None), "error": str(exc), - **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), + **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), "error_code": getattr(exc, "code", exc.__class__.__name__), } print_payload(payload, output_format(args), render_task_lease_markdown) diff --git a/loopx/cli_rollout.py b/loopx/cli_rollout.py index da6bc3ecc7..b6714b5c7a 100644 --- a/loopx/cli_rollout.py +++ b/loopx/cli_rollout.py @@ -48,7 +48,7 @@ def append_cli_rollout_event( runtime_root = Path(str(runtime_root_value)).expanduser() else: registry = load_registry(registry_path) - runtime_root = resolve_runtime_root(registry, runtime_root_arg) + runtime_root = resolve_runtime_root(registry, runtime_root_arg, registry_path=registry_path) event = build_rollout_event( goal_id=goal_id, event_kind=event_kind, From 54aae588070c981fadac7e6eb20f78858332680c Mon Sep 17 00:00:00 2001 From: wchwawa Date: Mon, 7 Sep 2026 11:15:53 +1000 Subject: [PATCH 17/27] test(coordination): retain review counterexamples and retirement gates Signed-off-by: wchwawa --- .github/workflows/python-tests.yml | 3 ++ .../shared-goal-authority-e2e/correctness.md | 38 +++++++++++++++++++ examples/shared-goal-authority-e2e/mutants.py | 33 ++++++++++++++++ .../test_shadow_cursor_recovery_e2e.py | 2 +- .../test_shadow_observable_native_e2e.py | 4 +- 5 files changed, 77 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 3ea7e6b628..9b618f6684 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -216,6 +216,8 @@ jobs: # Remove the generated source copy before pytest's normal discovery. python -c "import shutil; shutil.rmtree('build')" - name: Qualify real CLI, mixed writers, process death, and recovery + env: + LOOPX_SHADOW_COMPARISON_OUTPUT: .local/stage2c-observables run: python -m pytest -q -m stage2c_e2e --junitxml=stage2c-e2e.xml - name: Reject deliberate correctness regressions run: python examples/shared-goal-authority-e2e/mutants.py --output .local/stage2c-mutants @@ -233,6 +235,7 @@ jobs: include-hidden-files: true path: | stage2c-e2e.xml + .local/stage2c-observables/ installed-wheel.json installed-sdist.json .local/stage2c-mutants/ diff --git a/examples/shared-goal-authority-e2e/correctness.md b/examples/shared-goal-authority-e2e/correctness.md index abbb772245..27c1baadb6 100644 --- a/examples/shared-goal-authority-e2e/correctness.md +++ b/examples/shared-goal-authority-e2e/correctness.md @@ -19,6 +19,9 @@ and state file in the registry before bootstrap. The new history binds that source; conflicting `--runtime-root`, `--project`, or `--state-file` overrides cannot establish a second authority for it. Default-off primary writes and the independent observation path retain their own override behavior. +Registry-relative runtime paths also bind rollout-event logging to the registry's +owning project, including when capture is disabled and the CLI runs elsewhere. +This corrects a split-log defect; it does not activate capture for that Goal. Use the same registry and Goal throughout: @@ -110,6 +113,10 @@ preserve the scene. An interrupted primary without a marker is either proven under the source lock or left `unproved`; an A → B → A history cannot establish that the first write was abandoned. TS repeats source proof under its own locks after Python releases its locks to call the native commit operation. +Cursor position includes settled no-ops; its digest is the verified head's last +applied partition marker. It stays null through bootstrap and an abandoned-only +prefix, even with a nonempty baseline. Recovery and qualification consume that +same marker rather than hashing the current snapshot independently. If cursor persistence or file reclamation fails after a verified commit, drain still reports the verified candidate revision. The preserved outbox can be replayed after repairing filesystem access; the failure does not undo the commit. @@ -125,6 +132,25 @@ exact receipt replays remain valid. This is a bounded correctness limit, not a performance result. Drain budgets bound work between operations; they do not preempt an in-flight filesystem or RPC call. +## Roadmap and retirement boundary + +This batch belongs to capture lane C in the [Shared Goal Authority RFC](../../docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md). +Its rollback retires a **pre-promotion candidate**. Lane F still needs a separate +fenced export/rollback after canonical writes, following lane I's exact profile, +import, consumer and recovery qualification and explicit maintainer approval. +The 10,000-transaction bound here grants no sustained parity or promotion. +Long-running local use also needs lane L: an embedded store, bounded live-state +and receipt access, accelerated capacity tests and at least ten natural days of +soak. L can proceed alongside native Todo callers without waiting for PostgreSQL P. + +Retire legacy capture only after every bound primary writer has cut over to the +qualified authority and its replay/recovery consumers have cut over too. Retire +the separate observation handler only with the owner's Q14 decision and verified +replacement of its remaining callers. Python guards stay until those writers no +longer own primary effects; remove the bridge/protocol once no production caller +needs it. The next cutover batch must report deleted product LOC, added bridge +LOC and happy/recovery runtime crossings under the [TS migration RFC](../../docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md). + ## Required validation Install the repository test extra and Node dependencies. Run the long tests @@ -164,6 +190,8 @@ Python/TS/JSON provenance, and reads back through an independent native process. | --- | --- | | Full baseline, mixed Python/native writers, handoff, followups, monitor successor, one receipt per mutation | `test_runtime_shadow_bounded_e2e.py`, `test_shadow_drain_e2e.py` | | Cursor attacks, complete proof before bounded cleanup, missing cursor writer-first, dual drainers | `test_shadow_cursor_safety.py`, `test_shadow_drain_adversarial.py`, `shadow_cursor_safety.test.ts` | +| Todo/lease abandoned prefixes, later mutations, all cursor consumers and forged applied digests | `test_shadow_cursor_recovery_e2e.py` | +| Full caller diagnostics, argument readback and no-effect controls with absent/disabled/enabled capture | `test_shadow_observable_e2e.py` | | Primary and drain process death, lost ACK, prepared-only A → B → A | `test_shadow_drain_e2e.py`, `test_shadow_management_e2e.py` | | Every bootstrap/rollback durable window, raw archive fidelity, late requests, cached-result binding and other-Goal isolation | `shadow_management.test.ts`, `test_shadow_management_e2e.py`, `test_shadow_management_variant_e2e.py` | | Fence and maintenance boundaries, source and Goal override races, whole-file durability, paragraph injection, refresh CAS | `test_shadow_writer_boundaries.py`, `test_shadow_writer_variant_e2e.py`, `shadow_native_writer_boundary.test.ts`, `test_shadow_drain_adversarial.py` | @@ -175,3 +203,13 @@ Python/TS/JSON provenance, and reads back through an independent native process. The mandatory repair set must have zero failures, skips, pending, or unverified cases. Broader ladder rows retain their declared pending/environment gates; these tests grant neither production promotion nor a completed Stage 2C claim. + +For a caller comparison, run both `test_shadow_observable*_e2e.py` files with +`LOOPX_SHADOW_COMPARISON_SOURCE` set to an immutable baseline checkout, then to +the reviewed source. Set `LOOPX_SHADOW_COMPARISON_OUTPUT` to separate private +directories to retain full diagnostics and before/after fixture bytes. The oracle +is unchanged: the baseline must fail cases for defects this batch intentionally +repairs. Inspect every difference; normalize only documented time, path and +generated-identity variation, and disclose changes to rejection or receipt +behavior. CI retains the reviewed source's synthetic observations alongside the +crash, mutant and installed-package evidence. diff --git a/examples/shared-goal-authority-e2e/mutants.py b/examples/shared-goal-authority-e2e/mutants.py index 12bdbbcc3b..ee9d154219 100644 --- a/examples/shared-goal-authority-e2e/mutants.py +++ b/examples/shared-goal-authority-e2e/mutants.py @@ -46,6 +46,39 @@ def command(self) -> list[str]: CASES = [ + Case('rollout_cwd_root', (('loopx/cli_rollout.py', replacement( + 'resolve_runtime_root(registry, runtime_root_arg, registry_path=registry_path)', + 'resolve_runtime_root(registry, runtime_root_arg)')),), + 'tests/control_plane/test_shadow_observable_e2e.py::test_registry_relative_root_does_not_depend_on_callers_cwd[disabled]'), + Case('native_note_dropped', ((COORDINATION + 'todo_update.ts', replacement( + ' const next: JsonObject = {...todo, ...input.patch};', + ' const next: JsonObject = {...todo, ...input.patch};\n if ("note" in input.patch) next.note = todo.note;')),), + 'tests/control_plane/test_shadow_observable_native_e2e.py::test_native_unclaimed_edit_and_explicit_note_clear[disabled]'), + Case('native_unclaimed_edit_rejected', ((COORDINATION + 'todo_update.ts', replacement( + ' if (todo.claimed_by && todo.claimed_by !== input.actor_agent_id) {', + ' if (!todo.claimed_by || todo.claimed_by !== input.actor_agent_id) {')),), + 'tests/control_plane/test_shadow_observable_native_e2e.py::test_native_unclaimed_edit_and_explicit_note_clear[disabled]'), + Case('native_diagnostic_truncated', ((COORDINATION + 'todo_update.ts', replacement( + 'return failure("update_owner_mismatch", "Todo update cannot edit another claim owner\'s work");', + 'return failure("update_owner_mismatch", "Update rejected");')),), + 'tests/control_plane/test_shadow_observable_native_e2e.py::test_canonical_argument_intent_and_atomic_claim[disabled]'), + Case('cursor_baseline_digest', ((COORDINATION + 'local_authority_shadow_adapter.py', replacement( + ' return None if marker is None else marker["partition_digest"]', + ''' head = transaction["projection"] + return partition_digest({"handoff_mode": head["handoff_mode"], "todos": head["todos"]} + if self._partition == TODO_PARTITION else {"leases": head["leases"]})''')),), + 'tests/control_plane/test_shadow_cursor_recovery_e2e.py::test_abandoned_cursor_survives_all_consumers[2-0-todos]'), + Case('qualification_baseline_digest', ((COORDINATION + 'runtime_shadow.ts', replacement( + 'const digest = marker === null ? null : (marker as JsonObject).partition_digest;', + 'const digest = marker === null ? localAuthorityShadowHeadDigest(anchor.projection) : (marker as JsonObject).partition_digest;')),), + 'tests/control_plane/test_shadow_cursor_recovery_e2e.py::test_abandoned_cursor_survives_all_consumers[2-0-leases]'), + Case('cursor_digest_unchecked', ( + (COORDINATION + 'runtime_shadow.ts', replacement( + 'if (digest !== cursor.last_partition_digest) throw new ShadowLineageError("outbox_cursor_unproved");', + '// DELIBERATE MUTANT: accept any syntactically valid cursor digest.')), + (COORDINATION + 'local_authority_shadow_adapter.py', replacement( + ' or self._cursor_digest(anchor) != cursor["last_partition_digest"]\n', ''))), + 'tests/control_plane/test_shadow_cursor_recovery_e2e.py::test_forged_applied_digest_holds_every_consumer_without_rewriting_bytes[True-todos]'), Case('lineage', ((COORDINATION + 'local_authority_shadow.ts', replacement(' requireLineage(entry.capture_lineage_id === binding.capture_lineage_id, "stale_generation");', ' // DELIBERATE MUTANT: omit active lineage validation.')),), 'tests/control_plane_ts/local_authority_shadow_outbox.test.ts', 'self-consistent foreign'), Case('previous_partition', ((COORDINATION + 'local_authority_shadow.ts', replacement(' requireLineage(request.entry.source.previous_partition_digest === digest, "source_partition_continuity_unproved");', ' // DELIBERATE MUTANT: omit previous partition proof.')),), diff --git a/tests/control_plane/test_shadow_cursor_recovery_e2e.py b/tests/control_plane/test_shadow_cursor_recovery_e2e.py index beae461ee4..105f632c6a 100644 --- a/tests/control_plane/test_shadow_cursor_recovery_e2e.py +++ b/tests/control_plane/test_shadow_cursor_recovery_e2e.py @@ -173,7 +173,7 @@ def test_forged_applied_digest_holds_every_consumer_without_rewriting_bytes( for command, key in [('inspect', 'inspection'), ('qualify', 'qualification'), ('read-candidate', 'read_candidate')]: args = ('--todo-id', todo) if command == 'read-candidate' else () result = w.cli('coordination-shadow', command, *args, success=False)[key] - assert result['reason_code'] == 'outbox_cursor_unproved', result + assert result.get('reason_code') == 'outbox_cursor_unproved', result result = w.drain() assert result['ok'] is False and result['reason_code'] == 'outbox_cursor_unproved', result after = {p.relative_to(w.runtime): p.read_bytes() for p in w.runtime.rglob('*') diff --git a/tests/control_plane/test_shadow_observable_native_e2e.py b/tests/control_plane/test_shadow_observable_native_e2e.py index 05613e5778..8dbd4b8f78 100644 --- a/tests/control_plane/test_shadow_observable_native_e2e.py +++ b/tests/control_plane/test_shadow_observable_native_e2e.py @@ -67,7 +67,7 @@ def test_canonical_argument_intent_and_atomic_claim(caller: Caller) -> None: assert stored['leases'][0]['owner'] == 'agent-a' and stored['leases'][0]['write_scopes'] == ['src/**'] before = native(w, 'read', {}) rejected = w.call('todo', 'update', '--todo-id', todo, '--agent-id', 'agent-b', '--note', 'Foreign owner edit') - assert rejected['error_code'] == 'update_owner_mismatch' + assert rejected.get('error_code') == 'update_owner_mismatch', rejected assert rejected['error'] == rejected['reason'] == "Todo update cannot edit another claim owner's work" assert native(w, 'read', {}) == before assert not w.state.exists() @@ -171,4 +171,4 @@ def test_event_writer_retains_primary_semantics_and_cannot_qualify(caller: Calle assert len(log.read_text().splitlines()) > 1 if w.mode == 'enabled': result = w.call('coordination-shadow', 'qualify') - assert result['ok'] is False and result['error'] == 'event_log_writer_not_bound', result + assert result['ok'] is False and result.get('error') == 'event_log_writer_not_bound', result From a48c63268a92c7271d6db8ea2c0a05d318f44dc5 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Mon, 7 Sep 2026 11:27:42 +1000 Subject: [PATCH 18/27] refactor(coordination): own captured primary persistence once Signed-off-by: wchwawa --- .../runtime_shadow_writer_adapter.py | 14 ++++++++ loopx/control_plane/todos/handoff_mode.py | 11 +++---- loopx/todo_followups.py | 9 ++--- loopx/todos.py | 33 +++++++------------ .../test_shadow_writer_boundaries.py | 6 ++-- 5 files changed, 35 insertions(+), 38 deletions(-) diff --git a/loopx/control_plane/coordination/runtime_shadow_writer_adapter.py b/loopx/control_plane/coordination/runtime_shadow_writer_adapter.py index 3b52cf557d..3498bc15dc 100644 --- a/loopx/control_plane/coordination/runtime_shadow_writer_adapter.py +++ b/loopx/control_plane/coordination/runtime_shadow_writer_adapter.py @@ -114,6 +114,20 @@ def require_runtime_shadow_capture_prepared( ) +def write_captured_todo_state( + capture: outbox.TodoPartitionCapture, *, runtime_root: Path, goal_id: str, + state_path: Path, text: str, +) -> None: + """Under the primary lock, prepare before replacement and mark only after durability.""" + + from ..todos.active_state_editing import atomic_write_state_text + + capture.prepare(text) + require_runtime_shadow_capture_prepared(capture, runtime_root=runtime_root, goal_id=goal_id) + atomic_write_state_text(state_path, text) + capture.committed() + + def settle_todo_runtime_shadow_capture( payload: dict[str, Any], *, diff --git a/loopx/control_plane/todos/handoff_mode.py b/loopx/control_plane/todos/handoff_mode.py index 30a7c70718..fec1706424 100644 --- a/loopx/control_plane/todos/handoff_mode.py +++ b/loopx/control_plane/todos/handoff_mode.py @@ -423,11 +423,10 @@ def set_goal_handoff_mode( ) from ..coordination.legacy_writer_fence import legacy_todo_write_transaction from ..coordination.runtime_shadow_writer_adapter import ( - require_runtime_shadow_capture_prepared, - begin_todo_runtime_shadow_capture, + write_captured_todo_state, + begin_todo_runtime_shadow_capture, settle_todo_runtime_shadow_capture, ) - from .active_state_editing import atomic_write_state_text requested = normalize_handoff_mode(mode) if not str(mode or "").strip(): @@ -533,10 +532,8 @@ def set_goal_handoff_mode( lines = original.splitlines() _write_handoff_mode_frontmatter(lines, requested) new_text = "\n".join(lines) + ("\n" if original.endswith("\n") else "") - capture.prepare(new_text) - require_runtime_shadow_capture_prepared(capture, runtime_root=runtime_root, goal_id=goal_id) - atomic_write_state_text(resolved_state_file, new_text) - capture.committed() + write_captured_todo_state(capture, runtime_root=runtime_root, goal_id=goal_id, + state_path=resolved_state_file, text=new_text) payload["changed"] = True from ..coordination.local_authority_shadow_observation import observe_local_authority_commit diff --git a/loopx/todo_followups.py b/loopx/todo_followups.py index b5e3d4f605..8d3a0a5ffb 100644 --- a/loopx/todo_followups.py +++ b/loopx/todo_followups.py @@ -7,11 +7,10 @@ from .control_plane.coordination.legacy_writer_fence import legacy_todo_write_transaction from .control_plane.coordination.local_authority_shadow_adapter import effective_runtime_root from .control_plane.coordination.runtime_shadow_writer_adapter import ( - require_runtime_shadow_capture_prepared, + write_captured_todo_state, begin_todo_runtime_shadow_capture, settle_todo_runtime_shadow_capture, ) -from .control_plane.todos.active_state_editing import atomic_write_state_text from .state_refresh import now_local from .control_plane.todos.contract import TODO_TASK_CLASS_ADVANCEMENT from .control_plane.todos.todo_summary import normalize_todo_text @@ -164,10 +163,8 @@ def capture_followup_todos( new_text = "\n".join(lines) + ("\n" if original.endswith("\n") else "") new_text = replace_updated_at(new_text, updated_at) if not dry_run: - capture.prepare(new_text) - require_runtime_shadow_capture_prepared(capture, runtime_root=runtime_root, goal_id=goal_id) - atomic_write_state_text(resolved_state_file, new_text) - capture.committed() + write_captured_todo_state(capture, runtime_root=runtime_root, goal_id=goal_id, + state_path=resolved_state_file, text=new_text) result = { "ok": True, diff --git a/loopx/todos.py b/loopx/todos.py index 030bb0db70..d24a7dafd7 100644 --- a/loopx/todos.py +++ b/loopx/todos.py @@ -119,7 +119,6 @@ resolve_user_gate_global_gate_update, ) from .control_plane.coordination.legacy_writer_fence import legacy_todo_write_transaction -from .control_plane.todos.active_state_editing import atomic_write_state_text from .control_plane.coordination.local_authority import ( canonical_todo_summary_fields, claim_canonical_todo_if_promoted, @@ -135,7 +134,7 @@ ) from .control_plane.coordination.local_authority_shadow_adapter import effective_runtime_root from .control_plane.coordination.runtime_shadow_writer_adapter import ( - require_runtime_shadow_capture_prepared, + write_captured_todo_state, begin_todo_runtime_shadow_capture, settle_todo_runtime_shadow_capture, ) @@ -970,10 +969,8 @@ def add_goal_todo( if changed: new_text = replace_updated_at(new_text, updated_at) if changed and not dry_run: - shadow_capture.prepare(new_text) - require_runtime_shadow_capture_prepared(shadow_capture, runtime_root=shadow_runtime_root, goal_id=goal_id) - atomic_write_state_text(resolved_state_file, new_text) - shadow_capture.committed() + write_captured_todo_state(shadow_capture, runtime_root=shadow_runtime_root, goal_id=goal_id, + state_path=resolved_state_file, text=new_text) payload = { "ok": True, @@ -1540,10 +1537,8 @@ def update_goal_todo( if changed: new_text = replace_updated_at(new_text, updated_at) if changed and not dry_run: - shadow_capture.prepare(new_text) - require_runtime_shadow_capture_prepared(shadow_capture, runtime_root=shadow_runtime_root, goal_id=goal_id) - atomic_write_state_text(resolved_state_file, new_text) - shadow_capture.committed() + write_captured_todo_state(shadow_capture, runtime_root=shadow_runtime_root, goal_id=goal_id, + state_path=resolved_state_file, text=new_text) write_class = "todo_claim" if claim_only else "todo_update" payload = { "ok": True, @@ -1958,10 +1953,8 @@ def complete_goal_todo( if changed: new_text = replace_updated_at(new_text, updated_at) if changed and not dry_run: - shadow_capture.prepare(new_text) - require_runtime_shadow_capture_prepared(shadow_capture, runtime_root=shadow_runtime_root, goal_id=goal_id) - atomic_write_state_text(resolved_state_file, new_text) - shadow_capture.committed() + write_captured_todo_state(shadow_capture, runtime_root=shadow_runtime_root, goal_id=goal_id, + state_path=resolved_state_file, text=new_text) release_verified_task_lease_fence( task_lease_fence, committed=changed and not dry_run, @@ -2194,10 +2187,8 @@ def supersede_goal_todo( if changed: new_text = replace_updated_at(new_text, updated_at) if changed and not dry_run: - shadow_capture.prepare(new_text) - require_runtime_shadow_capture_prepared(shadow_capture, runtime_root=shadow_runtime_root, goal_id=goal_id) - atomic_write_state_text(resolved_state_file, new_text) - shadow_capture.committed() + write_captured_todo_state(shadow_capture, runtime_root=shadow_runtime_root, goal_id=goal_id, + state_path=resolved_state_file, text=new_text) release_verified_task_lease_fence(task_lease_fence, committed=changed and not dry_run) result = { "ok": True, @@ -2266,10 +2257,8 @@ def archive_completed_todos( if changed: new_text = replace_updated_at(new_text, updated_at) if changed and not dry_run: - shadow_capture.prepare(new_text) - require_runtime_shadow_capture_prepared(shadow_capture, runtime_root=shadow_runtime_root, goal_id=goal_id) - atomic_write_state_text(resolved_state_file, new_text) - shadow_capture.committed() + write_captured_todo_state(shadow_capture, runtime_root=shadow_runtime_root, goal_id=goal_id, + state_path=resolved_state_file, text=new_text) result = { "ok": True, diff --git a/tests/control_plane/test_shadow_writer_boundaries.py b/tests/control_plane/test_shadow_writer_boundaries.py index e9ec76104f..3540eb1c76 100644 --- a/tests/control_plane/test_shadow_writer_boundaries.py +++ b/tests/control_plane/test_shadow_writer_boundaries.py @@ -306,13 +306,13 @@ def test_real_writer_commits_before_a_later_fence_is_published(tmp_path: Path, o registry, state, root = fixture(tmp_path) writer_code = """ import sys -from loopx import todo_followups -original = todo_followups.atomic_write_state_text +from loopx.control_plane.todos import active_state_editing +original = active_state_editing.atomic_write_state_text def paused(*args): print('primary-write-cut', flush=True) sys.stdin.readline() return original(*args) -todo_followups.atomic_write_state_text = paused +active_state_editing.atomic_write_state_text = paused from loopx.entrypoint import main raise SystemExit(main(sys.argv[1:])) """ From ee1b17217c4d21be0da348097bccf84a1517bb03 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Mon, 7 Sep 2026 11:30:34 +1000 Subject: [PATCH 19/27] test(coordination): recognize rewritten assertion failures Signed-off-by: wchwawa --- examples/shared-goal-authority-e2e/mutants.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/shared-goal-authority-e2e/mutants.py b/examples/shared-goal-authority-e2e/mutants.py index ee9d154219..5ece721091 100644 --- a/examples/shared-goal-authority-e2e/mutants.py +++ b/examples/shared-goal-authority-e2e/mutants.py @@ -14,6 +14,7 @@ import json import os from pathlib import Path +import re import shutil import subprocess import sys @@ -299,7 +300,10 @@ def main() -> int: shutil.rmtree(cache) mutant = run(case, frozen, output / (case.name + "-RED.log")) log = mutant.stdout + mutant.stderr - killed = (mutant.returncode == 1 and "AssertionError" in log + # Pytest assertion rewriting can render rich comparisons as + # "E assert ..." without spelling the exception class. + assertion = "AssertionError" in log or re.search(r"^E\s+assert ", log, re.MULTILINE) is not None + killed = (mutant.returncode == 1 and assertion and any(token in log for token in ("1 failed", "fail 1")) and not any(token in log for token in ("SyntaxError", "ImportError", "ModuleNotFoundError"))) finally: From 11cde6cb6c56580212d8dcf6d9f851d330d3d912 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Mon, 7 Sep 2026 16:45:28 +1000 Subject: [PATCH 20/27] fix(coordination): render the fence remediation at the writer adapter boundary The shared write check keeps owning only the stable typed reason and the fence binding facts. The two in-process caller adapters, the TypeScript requireLegacyCoordinationPrimaryWriteAllowed wrapper used by every native task-lease verb and the Python require_legacy_coordination_write_allowed wrapper used by every legacy Todo writer, now render one provider-neutral remediation from that data: legacy coordination writer is fenced; use the promoted canonical authority () for goal ; fence ; the primary record was not changed The previous head fell back to a generic "legacy coordination writer is fenced" on the TypeScript side because a blocked check carries no `reason`, and both trees hard-coded "use the canonical file authority" on the Python side. The template is substituted in one pass, so a data value is never re-scanned for tokens, and the profile label is opaque data, so a later provider-neutral binding needs no template change. The complete check result now travels under `write_check` instead of being spread flat: the flat spread let the check's own `schema_version` overwrite the task-lease envelope's, which every CLI lease rejection then failed shape validation on. The two Python-local failure branches carry the same nested shape with their technical reason. Tests pin the rendered text and payload, and a render-identity test runs the TypeScript renderer through node on seven guard shapes, including an opaque profile label and a `$&{goal_id}` value that must survive substitution. Signed-off-by: wchwawa --- .../coordination/legacy_writer_fence.py | 68 ++++++++++-- .../coordination/legacy_writer_fence.ts | 44 +++++++- .../test_legacy_coordination_writer_fence.py | 101 +++++++++++++++++- 3 files changed, 196 insertions(+), 17 deletions(-) diff --git a/loopx/control_plane/coordination/legacy_writer_fence.py b/loopx/control_plane/coordination/legacy_writer_fence.py index b64668d5fd..8fbc549e06 100644 --- a/loopx/control_plane/coordination/legacy_writer_fence.py +++ b/loopx/control_plane/coordination/legacy_writer_fence.py @@ -8,8 +8,10 @@ from __future__ import annotations +from collections.abc import Mapping from contextlib import contextmanager import hashlib +import re from pathlib import Path from typing import Any, Iterator @@ -24,6 +26,7 @@ ) from .coordination_state_contract_generated import ( LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA, + LEGACY_COORDINATION_WRITE_CHECK_RESULT_SCHEMA, ) @@ -32,8 +35,42 @@ ) +# Caller adapter: remediation is rendered here, never inside the TypeScript +# write check, which owns only the stable typed reason and the fence binding +# facts. Tokens are substituted in one pass, so a data value is never +# re-scanned for tokens. Keep byte-identical with the TypeScript +# LEGACY_WRITER_FENCED_REMEDIATION. +LEGACY_WRITER_FENCED_REMEDIATION = ( + "legacy coordination writer is fenced; use the promoted canonical authority " + "({authority_mode}) for goal {goal_id}; fence {fence_id}; " + "the primary record was not changed" +) +_REMEDIATION_TOKENS = re.compile(r"\{(authority_mode|goal_id|fence_id)\}") + + +def _guard_text(value: object, fallback: str) -> str: + return value if isinstance(value, str) and value != "" else fallback + + +def legacy_coordination_write_remediation(goal_id: str, result: Mapping[str, Any]) -> str: + """Operator-facing text for a non-allowed write check; ``reason_code`` stays the machine reason.""" + + if result.get("status") != "blocked": + return _guard_text(result.get("reason"), "legacy coordination writer fence check failed") + values = { + "authority_mode": _guard_text(result.get("authority_mode"), "unknown_fail_closed"), + "goal_id": goal_id, + "fence_id": _guard_text(result.get("fence_id"), "unknown"), + } + return _REMEDIATION_TOKENS.sub(lambda match: values[match.group(1)], LEGACY_WRITER_FENCED_REMEDIATION) + + class LegacyCoordinationWriterFenced(RuntimeError): - """Raised when a legacy writer is no longer an authority.""" + """Raised when a legacy writer is no longer an authority. + + ``payload`` carries the complete write-check result under ``write_check`` + so a CLI envelope can spread it without losing its own keys. + """ def __init__(self, message: str, *, code: str, payload: dict[str, Any]) -> None: super().__init__(message) @@ -190,7 +227,15 @@ def require_legacy_coordination_write_allowed( raise LegacyCoordinationWriterFenced( "legacy coordination writer fence cannot be inspected", code="legacy_writer_fence_read_failed", - payload={"authority_mode": "unknown_fail_closed"}, + payload={ + "write_check": { + "schema_version": LEGACY_COORDINATION_WRITE_CHECK_RESULT_SCHEMA, + "status": "failed", + "reason_code": "legacy_writer_fence_read_failed", + "reason": "legacy coordination writer fence cannot be inspected", + "authority_mode": "unknown_fail_closed", + } + }, ) from exc result = effect_runtime_result( @@ -205,7 +250,15 @@ def require_legacy_coordination_write_allowed( raise LegacyCoordinationWriterFenced( "legacy coordination writer fence returned an invalid result", code="legacy_writer_fence_invalid_result", - payload={"authority_mode": "unknown_fail_closed"}, + payload={ + "write_check": { + "schema_version": LEGACY_COORDINATION_WRITE_CHECK_RESULT_SCHEMA, + "status": "failed", + "reason_code": "legacy_writer_fence_invalid_result", + "reason": "legacy coordination writer fence returned an invalid result", + "authority_mode": "unknown_fail_closed", + } + }, ) if ( result.get("status") == "allowed" @@ -214,12 +267,11 @@ def require_legacy_coordination_write_allowed( return code = str(result.get("reason_code") or "legacy_writer_fence_check_failed") - message = ( - "legacy coordination writer is fenced; use the canonical file authority" - if result.get("status") == "blocked" - else str(result.get("reason") or "legacy coordination writer fence check failed") + raise LegacyCoordinationWriterFenced( + legacy_coordination_write_remediation(goal_id, result), + code=code, + payload={"write_check": result}, ) - raise LegacyCoordinationWriterFenced(message, code=code, payload=result) @contextmanager diff --git a/loopx/control_plane/coordination/legacy_writer_fence.ts b/loopx/control_plane/coordination/legacy_writer_fence.ts index fa0cdb0f9a..8a8f4507e1 100644 --- a/loopx/control_plane/coordination/legacy_writer_fence.ts +++ b/loopx/control_plane/coordination/legacy_writer_fence.ts @@ -31,13 +31,45 @@ export { LEGACY_COORDINATION_WRITE_CHECK_RESULT_SCHEMA, }; +// Caller adapter: remediation is rendered here, never inside +// checkLegacyCoordinationWriteAllowed, which owns only the stable typed reason +// and the fence binding facts. Tokens are substituted in one pass, so a data +// value is never re-scanned for tokens. Keep byte-identical with the Python +// LEGACY_WRITER_FENCED_REMEDIATION. +export const LEGACY_WRITER_FENCED_REMEDIATION = + "legacy coordination writer is fenced; use the promoted canonical authority ({authority_mode}) for goal {goal_id}; fence {fence_id}; the primary record was not changed"; + +function guardText(value: unknown, fallback: string): string { + return typeof value === "string" && value !== "" ? value : fallback; +} + +/** Operator-facing text for a non-allowed write check; the machine reason stays `reason_code`. */ +export function legacyCoordinationWriteRemediation(goalId: string, guard: JsonObject): string { + if (guard.status !== "blocked") { + return guardText(guard.reason, "legacy coordination writer fence check failed"); + } + const values: Record = { + authority_mode: guardText(guard.authority_mode, "unknown_fail_closed"), + goal_id: goalId, + fence_id: guardText(guard.fence_id, "unknown"), + }; + return LEGACY_WRITER_FENCED_REMEDIATION.replace( + /\{(authority_mode|goal_id|fence_id)\}/g, + (_match, token: string) => values[token], + ); +} + export class LegacyCoordinationWriteError extends Error { code: string; + write_check: JsonObject; payload: JsonObject; - constructor(code: string, payload: JsonObject) { - super(String(payload.reason ?? "legacy coordination writer is fenced")); + constructor(code: string, writeCheck: JsonObject, message: string) { + super(message); this.code = code; - this.payload = payload; + this.write_check = writeCheck; + // The complete check result travels under its contract name; spreading it + // into a caller envelope must never overwrite the envelope's own keys. + this.payload = { write_check: writeCheck }; } } @@ -49,7 +81,11 @@ export async function requireLegacyCoordinationPrimaryWriteAllowed(root: string, runtime_root: root, goal_id: goalId, }); if (guard.status !== "allowed") { - throw new LegacyCoordinationWriteError(String(guard.reason_code ?? "legacy_writer_fence_check_failed"), guard); + throw new LegacyCoordinationWriteError( + String(guard.reason_code ?? "legacy_writer_fence_check_failed"), + guard, + legacyCoordinationWriteRemediation(goalId, guard), + ); } } diff --git a/tests/control_plane/test_legacy_coordination_writer_fence.py b/tests/control_plane/test_legacy_coordination_writer_fence.py index 43f73241c1..b80d9d9b82 100644 --- a/tests/control_plane/test_legacy_coordination_writer_fence.py +++ b/tests/control_plane/test_legacy_coordination_writer_fence.py @@ -1,12 +1,17 @@ from __future__ import annotations import json +import subprocess from pathlib import Path import pytest +from loopx.control_plane.coordination.coordination_state_contract_generated import ( + LEGACY_COORDINATION_WRITE_CHECK_RESULT_SCHEMA, +) from loopx.control_plane.coordination.legacy_writer_fence import ( LegacyCoordinationWriterFenced, + legacy_coordination_write_remediation, legacy_coordination_todo_lock_path, legacy_coordination_writer_fence_path, legacy_todo_write_transaction, @@ -84,6 +89,9 @@ def _engage_fence(runtime_root: Path) -> None: fence_path.write_text(json.dumps({"state": "present"}), encoding="utf-8") +REPO = Path(__file__).resolve().parents[2] + + def _blocked_effect_runtime(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( "loopx.control_plane.coordination.legacy_writer_fence.effect_runtime_result", @@ -122,13 +130,17 @@ def test_present_fence_delegates_to_typescript_and_blocks( path.write_text(json.dumps({"state": "present"}), encoding="utf-8") captured: dict[str, object] = {} + blocked = { + "schema_version": LEGACY_COORDINATION_WRITE_CHECK_RESULT_SCHEMA, + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "fence-a", + } + def invoke(method: str, params: dict[str, object]) -> dict[str, object]: captured.update(method=method, params=params) - return { - "status": "blocked", - "reason_code": "legacy_coordination_writer_fenced", - "authority_mode": "file_v0", - } + return dict(blocked) monkeypatch.setattr( "loopx.control_plane.coordination.legacy_writer_fence.effect_runtime_result", @@ -143,6 +155,85 @@ def invoke(method: str, params: dict[str, object]) -> dict[str, object]: assert exc_info.value.code == "legacy_coordination_writer_fenced" assert captured["method"] == "coordination.local_authority.legacy_write_check" + # The adapter renders the operator remediation from the guard's data and + # keeps the complete check result under its contract name. + assert str(exc_info.value) == ( + "legacy coordination writer is fenced; use the promoted canonical authority " + "(file_v0) for goal goal-a; fence fence-a; the primary record was not changed" + ) + assert exc_info.value.payload == {"write_check": blocked} + + +_RENDER_CASES: list[tuple[str, dict[str, object], str]] = [ + ( + "blocked_file_v0", + {"status": "blocked", "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", "fence_id": "legacy-writer-fence:goal-a:state-1"}, + "legacy coordination writer is fenced; use the promoted canonical authority " + "(file_v0) for goal goal-a; fence legacy-writer-fence:goal-a:state-1; " + "the primary record was not changed", + ), + ( + "blocked_opaque_profile", + {"status": "blocked", "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "profile-x", "fence_id": "fence-b"}, + "legacy coordination writer is fenced; use the promoted canonical authority " + "(profile-x) for goal goal-a; fence fence-b; the primary record was not changed", + ), + ( + "blocked_single_pass_substitution", + {"status": "blocked", "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", "fence_id": "fence $&{goal_id}"}, + "legacy coordination writer is fenced; use the promoted canonical authority " + "(file_v0) for goal goal-a; fence fence $&{goal_id}; the primary record was not changed", + ), + ( + "blocked_without_binding_facts", + {"status": "blocked", "reason_code": "legacy_coordination_writer_fenced"}, + "legacy coordination writer is fenced; use the promoted canonical authority " + "(unknown_fail_closed) for goal goal-a; fence unknown; the primary record was not changed", + ), + ( + "failed_with_reason", + {"status": "failed", "reason_code": "legacy_writer_fence_read_failed", + "reason": "legacy coordination writer fence must be engaged", + "authority_mode": "unknown_fail_closed"}, + "legacy coordination writer fence must be engaged", + ), + ( + "failed_without_reason", + {"status": "failed", "reason_code": "legacy_writer_fence_read_failed", + "reason": "", "authority_mode": "unknown_fail_closed"}, + "legacy coordination writer fence check failed", + ), + ( + "invalid_check_request", + {"status": "failed", "reason_code": "invalid_legacy_coordination_write_check", + "reason": "legacy coordination write check request schema mismatch", + "authority_mode": "unknown_fail_closed"}, + "legacy coordination write check request schema mismatch", + ), +] + + +@pytest.mark.parametrize(("name", "guard", "expected"), _RENDER_CASES, ids=[c[0] for c in _RENDER_CASES]) +def test_remediation_renders_identically_in_python_and_typescript( + name: str, guard: dict[str, object], expected: str +) -> None: + """Both caller adapters render one text from the same guard data.""" + + assert legacy_coordination_write_remediation("goal-a", guard) == expected + module = (REPO / "loopx/control_plane/coordination/legacy_writer_fence.ts").as_uri() + script = ( + f"import {{legacyCoordinationWriteRemediation}} from {json.dumps(module)};" + "let input='';for await (const chunk of process.stdin) input += chunk;" + "process.stdout.write(legacyCoordinationWriteRemediation('goal-a', JSON.parse(input)));" + ) + rendered = subprocess.run( + ["node", "--no-warnings", "--experimental-strip-types", "--input-type=module", "-e", script], + input=json.dumps(guard), capture_output=True, text=True, check=True, timeout=45, + ) + assert rendered.stdout == expected, name def test_todo_write_transaction_fences_under_the_effective_runtime_root( From 4ee4ce5d634a05ed1cf6a7780cc6c0c9620f0486 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Mon, 7 Sep 2026 16:45:28 +1000 Subject: [PATCH 21/27] fix(work-items): classify a fenced task-lease write as a typed validation rejection A fenced legacy writer is a terminal permission decision taken by the promoted authority before the verb's first side effect, so no settlement step runs and no receipt exists. Acquire now types that error at its catch site, the way the lifecycle owner already does, to `{step: validation, kind: permission_denied}`; the baseline reported a committed validation receipt and a durable_writeback failure although no writeback was attempted, and the previous head reported the contradictory `validation` plus `writeback_rejected`. Only the `legacy_` prefix clause leaves the step rule; every non-fence code keeps its classification. Terminal and holder verification persist a receipt on every branch, not only on the user-gate auto-acquire branch, so the fence is checked once at the top of the verify path before that first write. A terminal preview under an engaged fence therefore reports the typed rejection instead of a successful preview of a write the fence forbids, and leaves no receipt behind. Signed-off-by: wchwawa --- .../work_items/task_lease_acquire.ts | 25 ++++++-- .../work_items/task_lease_lifecycle.ts | 6 +- .../task_lease_acquire.test.ts | 61 +++++++++++++++++++ 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/loopx/control_plane/work_items/task_lease_acquire.ts b/loopx/control_plane/work_items/task_lease_acquire.ts index bd0c786b47..42f7d811db 100644 --- a/loopx/control_plane/work_items/task_lease_acquire.ts +++ b/loopx/control_plane/work_items/task_lease_acquire.ts @@ -147,6 +147,8 @@ interface TaskLeaseFailure { code: string; message: string; payload: JsonObject; + stage?: "validation" | "durable_writeback"; + kind?: string; } interface ExecutionContext { @@ -1180,16 +1182,24 @@ function failureKind(code: string): string { return "writeback_rejected"; } +/** + * A fenced legacy writer is a terminal permission decision taken by the + * promoted authority before this verb's first side effect: no settlement step + * ran, so no receipt exists and the rejection is a validation-stage denial. + */ +function fencedFailure(error: LegacyCoordinationWriteError): TaskLeaseFailure { + return { code: error.code, message: error.message, payload: error.payload, stage: "validation", kind: "permission_denied" }; +} + function failureEnvelope( failure: TaskLeaseFailure, context: ExecutionContext, ): TaskLeaseAcquireEnvelope { - const step = (VALIDATION_FAILURE_CODES.has(failure.code) - || failure.code.startsWith("shadow_management_") - || failure.code.startsWith("legacy_")) + const step = failure.stage ?? ((VALIDATION_FAILURE_CODES.has(failure.code) + || failure.code.startsWith("shadow_management_")) ? "validation" - : "durable_writeback"; - const kind = failureKind(failure.code); + : "durable_writeback"); + const kind = failure.kind ?? failureKind(failure.code); const receipts = step === "validation" || context.effectId === null ? [] : [{ step: "validation", status: "committed", effect_id: context.effectId }]; @@ -1403,7 +1413,10 @@ export async function executeTaskLeaseAcquire( }), ); } catch (error) { - if (error instanceof TaskLeaseAcquireError || error instanceof ShadowManagementError || error instanceof LegacyCoordinationWriteError) { + if (error instanceof LegacyCoordinationWriteError) { + return failureEnvelope(fencedFailure(error), context); + } + if (error instanceof TaskLeaseAcquireError || error instanceof ShadowManagementError) { return failureEnvelope( { code: error.code, message: error.message, payload: error.payload }, context, diff --git a/loopx/control_plane/work_items/task_lease_lifecycle.ts b/loopx/control_plane/work_items/task_lease_lifecycle.ts index 519aca09cb..927c7444a8 100644 --- a/loopx/control_plane/work_items/task_lease_lifecycle.ts +++ b/loopx/control_plane/work_items/task_lease_lifecycle.ts @@ -2078,7 +2078,10 @@ async function fenceVerify( } else { receipt = lockedReceipt; } - await requireShadowPrimaryWriteAllowed(request.runtime_root, request.goal_id); + // Every legacy verify branch (held replay, holder verification, user-gate + // auto-acquire) persists a receipt; the promoted authority's fence is + // checked once here, before that first side effect. + await requireLegacyCoordinationPrimaryWriteAllowed(request.runtime_root, request.goal_id); const initialTodo = authorityTodo(request); // Publish the token before semantic validation. If the response is lost // after acquisition, a retry can adopt this exact lock and finish the @@ -2250,7 +2253,6 @@ async function fenceVerify( const explicitFence = request.idempotency_key !== null || request.expected_version !== null; const autoAcquire = allowsUserGateAutoAcquire(request, todo); if (autoAcquire && !effective && !active) { - await requireLegacyCoordinationPrimaryWriteAllowed(request.runtime_root, request.goal_id); if (!request.owner) { throw new TaskLeaseLifecycleError("hard_lease handoff mode auto-acquire requires an attributed actor; provide --agent-id", "handoff_mode_requires_lease", { goal_id: request.goal_id, todo_id: request.todo_id, reason: "missing_actor", lease_path: leasePath }); } diff --git a/tests/control_plane_ts/task_lease_acquire.test.ts b/tests/control_plane_ts/task_lease_acquire.test.ts index afc0eee5fa..386ddabd93 100644 --- a/tests/control_plane_ts/task_lease_acquire.test.ts +++ b/tests/control_plane_ts/task_lease_acquire.test.ts @@ -9,6 +9,12 @@ import { executeTaskLeaseAcquire, TASK_LEASE_ACQUIRE_REQUEST_SCHEMA_VERSION, } from "../../loopx/control_plane/work_items/task_lease_acquire.ts"; +import { + LEGACY_COORDINATION_WRITER_FENCE_ENGAGE_REQUEST_SCHEMA, + LEGACY_COORDINATION_WRITER_FENCE_SCHEMA, + LEGACY_COORDINATION_WRITE_CHECK_RESULT_SCHEMA, +} from "../../loopx/control_plane/coordination/coordination_state_contract.generated.ts"; +import { engageLegacyCoordinationWriterFence } from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; const FIXED_NOW = new Date("2026-08-27T03:00:00.000Z"); @@ -371,6 +377,61 @@ test("post-identity failures preserve the legacy validation receipt prefix", asy }); }); +test("an engaged legacy writer fence rejects acquire before validation without receipts", async (t) => { + // Baseline reported a committed validation receipt and a durable_writeback + // failure for this rejection although no writeback was attempted; the + // fence is a terminal permission decision taken by the promoted authority + // before the first side effect (RFC section 5 `rejected`, section 5.6, + // Appendix C), so it is typed like every other validation-stage denial and + // like its renew/transfer/release siblings. + const root = await workspace(t); + await mkdir(join(root, "runtime"), { recursive: true }); + await writeFile(join(root, "ACTIVE_GOAL_STATE.md"), "---\ngoal_id: goal-a\n---\n\n## Agent Todo\n\n", "utf8"); + const engaged = await engageLegacyCoordinationWriterFence({ + schema_version: LEGACY_COORDINATION_WRITER_FENCE_ENGAGE_REQUEST_SCHEMA, + runtime_root: join(root, "runtime"), + goal_id: "goal-a", + state_path: join(root, "ACTIVE_GOAL_STATE.md"), + fence: { + schema_version: LEGACY_COORDINATION_WRITER_FENCE_SCHEMA, + state: "engaged", + goal_id: "goal-a", + fence_id: "legacy-writer-fence:goal-a:state-1", + source_version: "state:1", + source_projection_sha256: "a".repeat(64), + expected_shadow_provider_revision: "file:1:aaaaaaaaaaaaaaaaaaaaaaaa", + }, + }); + assert.equal(engaged.status, "applied"); + + const fenced = await executeTaskLeaseAcquire(await request(root), { now: () => FIXED_NOW }); + + assert.equal(fenced.ok, false); + assert.equal(fenced.schema_version, "task_lease_v0"); + assert.equal(fenced.error_code, "legacy_coordination_writer_fenced"); + assert.equal( + fenced.error, + "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal goal-a; fence legacy-writer-fence:goal-a:state-1; the primary record was not changed", + ); + assert.deepEqual(fenced.write_check, { + schema_version: LEGACY_COORDINATION_WRITE_CHECK_RESULT_SCHEMA, + status: "blocked", + reason_code: "legacy_coordination_writer_fenced", + authority_mode: "file_v0", + fence_id: "legacy-writer-fence:goal-a:state-1", + }); + assert.deepEqual(fenced.settlement, { + effect_id: null, + receipts: [], + failure: { + step: "validation", + kind: "permission_denied", + code: "legacy_coordination_writer_fenced", + }, + }); + await assert.rejects(() => readFile(leasePath(root), "utf8"), { code: "ENOENT" }); +}); + test("invalid settlement identities fail validation without receipts", async (t) => { const root = await workspace(t); const invalidOwner = await executeTaskLeaseAcquire( From ac07b2d9ccc2b6c99987aa40bc4b39259fa0defc Mon Sep 17 00:00:00 2001 From: wchwawa Date: Mon, 7 Sep 2026 16:45:28 +1000 Subject: [PATCH 22/27] fix(cli): keep envelope keys ahead of typed exception payloads The task-lease and turn error envelopes spread a typed exception's payload after their own keys, so a payload that carries `schema_version` (the canonical Todo list result behind LocalCoordinationAuthorityUnavailable, or any check result) replaced the envelope's schema. Envelope-owned keys now win; the payload is spread first. Signed-off-by: wchwawa --- loopx/cli_commands/task_lease.py | 10 ++- loopx/cli_commands/turn.py | 2 +- .../test_task_lease_cli_native.py | 66 +++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/loopx/cli_commands/task_lease.py b/loopx/cli_commands/task_lease.py index 6443d1aa3e..140d10de8b 100644 --- a/loopx/cli_commands/task_lease.py +++ b/loopx/cli_commands/task_lease.py @@ -208,12 +208,12 @@ def handle_task_lease_command( ) except TaskLeaseError as exc: payload = { + **exc.payload, "ok": False, "schema_version": "task_lease_v0", "action": getattr(args, "task_lease_command", None), "error": str(exc), "error_code": exc.code, - **exc.payload, } except LockAcquireTimeoutError as exc: payload = { @@ -224,12 +224,18 @@ def handle_task_lease_command( **exc.to_payload(), } except Exception as exc: + # Envelope-owned keys always win over a typed exception payload. + typed_payload = ( + dict(getattr(exc, "payload", {}) or {}) + if isinstance(getattr(exc, "code", None), str) + else {} + ) payload = { + **typed_payload, "ok": False, "schema_version": "task_lease_v0", "action": getattr(args, "task_lease_command", None), "error": str(exc), - **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), "error_code": getattr(exc, "code", exc.__class__.__name__), } print_payload(payload, output_format(args), render_task_lease_markdown) diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py index f91caa105b..6b8eb8baa2 100644 --- a/loopx/cli_commands/turn.py +++ b/loopx/cli_commands/turn.py @@ -964,6 +964,7 @@ def resolve_built_in_session_binding( raise ValueError("turn requires the `plan` or `run-once` subcommand") except Exception as exc: # noqa: BLE001 - CLI boundary renders typed JSON failure payload = { + **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), "ok": False, "schema_version": ( LOOPX_TURN_EXECUTION_SCHEMA_VERSION @@ -972,7 +973,6 @@ def resolve_built_in_session_binding( ), "mode": "run_once" if args.turn_command == "run-once" else "plan", "error": str(exc), - **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), "effects": { "host_invoked": False, "state_written": False, diff --git a/tests/control_plane/test_task_lease_cli_native.py b/tests/control_plane/test_task_lease_cli_native.py index a8b18751e6..abe849eb25 100644 --- a/tests/control_plane/test_task_lease_cli_native.py +++ b/tests/control_plane/test_task_lease_cli_native.py @@ -42,6 +42,72 @@ def _run_acquire(monkeypatch, result: dict) -> dict: return captured[0] +def _run_acquire_raising(monkeypatch, error: Exception) -> dict: + captured: list[dict] = [] + import loopx.cli_commands.task_lease as task_lease_cli + + def raise_error(**_kwargs): + raise error + + monkeypatch.setattr(task_lease_cli, "execute_native_task_lease_acquire", raise_error) + args = build_parser().parse_args( + [ + "task-lease", + "acquire", + "--goal-id", + "cli-native-goal", + "--todo-id", + "todo_cli_native", + "--owner", + "codex-cli-agent", + "--idempotency-key", + "cli-native-key", + "--ttl-seconds", + "300", + ] + ) + status = handle_task_lease_command( + args, + registry_path=Path("/tmp/registry.json"), + runtime_root_arg=None, + output_format=lambda _args: "json", + print_payload=lambda payload, _fmt, _render: captured.append(payload), + ) + assert status == 1 + return captured[0] + + +def test_cli_envelope_keys_win_over_a_typed_exception_payload(monkeypatch) -> None: + """A typed payload that carries its own schema_version cannot clobber the envelope's.""" + + from loopx.control_plane.coordination.local_authority import ( + LocalCoordinationAuthorityUnavailable, + ) + + error = LocalCoordinationAuthorityUnavailable( + "canonical Todo authority is unavailable", + code="local_authority_todo_list_unavailable", + payload={ + "schema_version": "loopx_local_coordination_todo_list_result_v0", + "status": "missing", + "source_authority": "file_v0", + "decision_read_from_provider": True, + "legacy_fallback_used": False, + }, + ) + assert _run_acquire_raising(monkeypatch, error) == { + "ok": False, + "schema_version": "task_lease_v0", + "action": "acquire", + "error": "canonical Todo authority is unavailable", + "error_code": "local_authority_todo_list_unavailable", + "status": "missing", + "source_authority": "file_v0", + "decision_read_from_provider": True, + "legacy_fallback_used": False, + } + + def test_cli_acquire_passes_through_native_success(monkeypatch) -> None: result = { "ok": True, From 0ebc7042bbac3c8ea69cc386363d1ee8d5bc13f5 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Mon, 7 Sep 2026 19:00:03 +1000 Subject: [PATCH 23/27] test(coordination): pin fenced sibling-caller parity across native and CLI writers One versioned fixture holds the complete observable behaviour of every legacy writer entry point under a present fence: 21 TypeScript entry rows (acquire, renew, transfer, release, terminal verify with user-gate auto-acquire keyed and keyless, committed releasing fence-close; engaged, invalid, and unreadable fences; two absent controls) and 25 real-process CLI rows (the four lease verbs with and without capture, Todo complete, update, supersede, archive, capture-followups, handoff-mode set, four previews including a leased terminal preview, invalid and unreadable markers, and a fence without a canonical store). Every row compares the whole envelope, the exit status, and an exclusion-free effect snapshot of the runtime root; fence-close rows also check the declared after-state and the identical retry. Nothing is matched by prefix or substring, so a truncated remediation, a fabricated receipt, a drifted settlement kind, or a skipped guard fails exactly one row. The `baseline` entries record what 0fb497af8 and ee1b17217 returned and are never executed; a stale annotation fails the fixture test. Signed-off-by: wchwawa --- .../test_shadow_fence_caller_parity_e2e.py | 235 ++ .../legacy_writer_fence_caller_parity.test.ts | 78 + ...gacy_writer_fence_caller_parity_support.ts | 341 +++ .../legacy_writer_fence_caller_parity_v0.json | 1998 +++++++++++++++++ tsconfig.control-plane.json | 2 + 5 files changed, 2654 insertions(+) create mode 100644 tests/control_plane/test_shadow_fence_caller_parity_e2e.py create mode 100644 tests/control_plane_ts/legacy_writer_fence_caller_parity.test.ts create mode 100644 tests/control_plane_ts/legacy_writer_fence_caller_parity_support.ts create mode 100644 tests/fixtures/control_plane/legacy_writer_fence_caller_parity_v0.json diff --git a/tests/control_plane/test_shadow_fence_caller_parity_e2e.py b/tests/control_plane/test_shadow_fence_caller_parity_e2e.py new file mode 100644 index 0000000000..5f0ce639a1 --- /dev/null +++ b/tests/control_plane/test_shadow_fence_caller_parity_e2e.py @@ -0,0 +1,235 @@ +"""Fenced sibling-caller parity through the real CLI (Stage 2C E2E). + +Every row runs one public command against a workspace whose durable legacy +writer fence is in one state, and compares the complete printed envelope, the +exit status, and the primary-record effect with the literal expectation in +tests/fixtures/control_plane/legacy_writer_fence_caller_parity_v0.json. +Nothing is mocked; the fence is engaged by the real TypeScript owner. +""" +from __future__ import annotations + +import json +from pathlib import Path +import shutil +import sys + +import pytest + +from test_shadow_observable_e2e import Caller +from test_shadow_observable_native_e2e import native + +pytestmark = pytest.mark.stage2c_e2e + +FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "control_plane" / "legacy_writer_fence_caller_parity_v0.json" +BUILDER = ( + "from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection as build; " + "import json,sys; value=build(goal_id='observable', todos=json.loads(sys.argv[1])); " + "value['handoff_mode']='hard_lease'; print(json.dumps(value))" +) +PLACEHOLDERS = ("runtime_root", "todo_a", "todo_b", "todo_gate") + + +class Workspace: + """One seeded goal; rows are executed against it in fixture order.""" + + def __init__(self, path: Path, mode: str, name: str) -> None: + self.w = Caller(path, mode, name) + self.ids: dict[str, str] = {} + self.lease_version = 0 + self.control: dict[str, dict] | None = None + + def call(self, *args: str) -> dict: + return self.w.call(*args) + + def exit(self) -> int: + return int(self.w.rows[-1]["exit"]) + + def outbox(self) -> set[str]: + return {k for k in self.w.files() if k.startswith("runtime/authority-shadow/outbox/")} + + def fence_path(self) -> Path: + from loopx.control_plane.coordination.legacy_writer_fence import legacy_coordination_writer_fence_path + + return legacy_coordination_writer_fence_path(runtime_root=self.w.root, goal_id="observable") + + def normalize(self, value: object) -> object: + text = json.dumps(value) + text = text.replace(json.dumps(str(self.w.root))[1:-1], "{runtime_root}") + for key in ("todo_gate", "todo_b", "todo_a"): + if key in self.ids: + text = text.replace(self.ids[key], "{" + key + "}") + return json.loads(text) + + def observe(self, args: tuple[str, ...]) -> dict: + before, outbox_before = self.w.primary(), self.outbox() + envelope = self.call(*args) + after, outbox_after = self.w.primary(), self.outbox() + return { + "envelope": self.normalize(envelope), + "exit": self.exit(), + "effect": { + "added": sorted(k for k in after if k not in before), + "removed": sorted(k for k in before if k not in after), + "changed": sorted(k for k in after if k in before and after[k] != before[k]), + }, + "outbox_added": sorted(outbox_after - outbox_before), + } + + +def seed_and_fence(ws: Workspace, todo_keys: tuple[str, ...]) -> None: + records = [ws.w.read(ws.ids[key]) for key in todo_keys] + projection = ws.w.invoke( + [sys.executable, "-c", BUILDER, json.dumps(records)], ["fixture-projection", json.dumps(records)] + ) + assert native(ws.w, "seed", projection)["status"] == "applied" + + +def build_seeded(path: Path, mode: str, name: str, *, gate: bool) -> Workspace: + ws = Workspace(path, mode, name) + assert ws.call("handoff-mode", "set", "--mode", "hard_lease")["ok"] is True + ws.ids["todo_a"] = ws.w.add("Parity target A") + ws.ids["todo_b"] = ws.w.add("Parity leased B") + if gate: + gate_add = ws.call("todo", "add", "--role", "user", "--task-class", "user_gate", "--global-gate", + "--text", "Approve the parity plan") + assert gate_add["ok"] is True, gate_add + ws.ids["todo_gate"] = gate_add["todo_id"] + ws.control = {"cli-task_lease_acquire-absent": ws.observe(( + "task-lease", "acquire", "--todo-id", ws.ids["todo_b"], "--owner", "agent-a", + "--idempotency-key", "parity-lease", "--ttl-seconds", "3600"))} + lease = ws.control["cli-task_lease_acquire-absent"]["envelope"] + assert lease.get("acquired") is True, lease + ws.lease_version = int(lease["lease"]["version"]) + seed_and_fence(ws, ("todo_a", "todo_b", "todo_gate") if gate else ("todo_a", "todo_b")) + return ws + + +def build_w3(path: Path) -> Workspace: + ws = build_seeded(path, "absent", "fence-parity-invalid", gate=False) + marker = json.loads(ws.fence_path().read_text(encoding="utf-8")) + marker["state"] = "disengaged" + ws.fence_path().write_text(json.dumps(marker), encoding="utf-8") + return ws + + +def build_w4(path: Path) -> Workspace: + ws = build_seeded(path, "absent", "fence-parity-unreadable", gate=False) + ws.fence_path().unlink() + ws.fence_path().mkdir() + return ws + + +def build_w5(path: Path) -> Workspace: + ws = Workspace(path, "absent", "fence-parity-no-store") + assert ws.call("handoff-mode", "set", "--mode", "hard_lease")["ok"] is True + ws.ids["todo_a"] = ws.w.add("Parity target A") + ws.fence_path().parent.mkdir(parents=True, exist_ok=True) + ws.fence_path().write_text(json.dumps({ + "schema_version": "loopx_legacy_coordination_writer_fence_v0", "state": "engaged", "goal_id": "observable", + "fence_id": "caller-fixture", "source_version": "caller-fixture", + "source_projection_sha256": "a" * 64, "expected_shadow_provider_revision": "file:1:aaaaaaaaaaaaaaaaaaaaaaaa", + }), encoding="utf-8") + return ws + + +def lease_args(ws: Workspace, verb: str) -> tuple[str, ...]: + todo_b, version = ws.ids.get("todo_b", ""), str(ws.lease_version) + if verb == "acquire": + return ("task-lease", "acquire", "--todo-id", ws.ids.get("todo_a", ""), "--owner", "agent-a", + "--idempotency-key", "parity-acquire", "--ttl-seconds", "3600") + common = ("task-lease", verb, "--todo-id", todo_b, "--owner", "agent-a", + "--idempotency-key", "parity-lease", "--expected-version", version) + if verb == "renew": + return (*common, "--ttl-seconds", "7200") + if verb == "transfer": + return (*common, "--new-owner", "agent-b", "--new-idempotency-key", "parity-transfer") + return common + + +def row_args(ws: Workspace, caller: str) -> tuple[str, ...]: + a = ws.ids.get("todo_a", "") + b = ws.ids.get("todo_b", "") + gate = ws.ids.get("todo_gate", "") + version = str(ws.lease_version) + table: dict[str, tuple[str, ...]] = { + "task_lease_acquire": lease_args(ws, "acquire"), + "task_lease_renew": lease_args(ws, "renew"), + "task_lease_transfer": lease_args(ws, "transfer"), + "task_lease_release": lease_args(ws, "release"), + "todo_complete": ("todo", "complete", "--todo-id", a, "--agent-id", "agent-a", + "--evidence", "validation://parity", "--no-follow-up"), + "todo_update_status": ("todo", "update", "--todo-id", a, "--agent-id", "agent-a", "--status", "deferred"), + "todo_supersede": ("todo", "supersede", "--todo-id", a, "--agent-id", "agent-a", + "--text", "Parity successor", "--evidence", "validation://parity"), + "todo_archive_completed_execute": ("todo", "archive-completed", "--execute"), + "todo_archive_completed_preview": ("todo", "archive-completed"), + "todo_capture_followups": ("todo", "capture-followups", "--follow-up", "Parity follow-up.", + "--evidence", "parity fixture"), + "todo_capture_followups_dry_run": ("todo", "capture-followups", "--follow-up", "Parity follow-up.", + "--evidence", "parity fixture", "--dry-run"), + "handoff_mode_set": ("handoff-mode", "set", "--mode", "soft_claim"), + "todo_complete_dry_run_gate": ("todo", "complete", "--todo-id", gate, "--agent-id", "agent-a", + "--decision-outcome", "approve", "--evidence", "validation://parity", + "--no-follow-up", "--dry-run"), + "todo_supersede_dry_run": ("todo", "supersede", "--todo-id", a, "--agent-id", "agent-a", + "--text", "Parity successor", "--evidence", "validation://parity", "--dry-run"), + "todo_complete_dry_run_leased": ("todo", "complete", "--todo-id", b, "--agent-id", "agent-a", + "--task-lease-idempotency-key", "parity-lease", + "--task-lease-expected-version", version, + "--evidence", "validation://parity", "--no-follow-up", "--dry-run"), + } + return table[caller] + + +WORKSPACES = { + "w1": lambda path: build_seeded(path, "absent", "fence-parity-engaged", gate=True), + "w2": lambda path: build_seeded(path, "enabled", "fence-parity-engaged-capture", gate=False), + "w3": build_w3, + "w4": build_w4, + "w5": build_w5, +} + + +def load_rows() -> list[dict]: + if not FIXTURE.exists(): + # Keep the module importable for the fixture recorder; the test itself fails loudly. + return [{"id": "fixture-missing", "surface": "cli", "workspace": None}] + return [row for row in json.loads(FIXTURE.read_text(encoding="utf-8"))["rows"] if row["surface"] == "cli"] + + +@pytest.fixture(scope="module") +def workspaces(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Workspace]: + built: dict[str, Workspace] = {} + for key, build in WORKSPACES.items(): + built[key] = build(tmp_path_factory.mktemp(key)) + yield built + for ws in built.values(): + shutil.rmtree(ws.w.path, ignore_errors=True) + + +def observe_row(ws: Workspace, row: dict) -> dict: + if ws.control and row["id"] in ws.control: + return ws.control[row["id"]] + return ws.observe(row_args(ws, row["caller"])) + + +@pytest.mark.parametrize("row", load_rows(), ids=[row["id"] for row in load_rows()]) +def test_fence_caller_parity(workspaces: dict[str, Workspace], row: dict) -> None: + if row["id"] == "fixture-missing": + pytest.fail(f"parity fixture is missing: {FIXTURE}") + observed = observe_row(workspaces[row["workspace"]], row) + assert observed["exit"] == row["exit"], observed + if row.get("match") == "subset": + assert {key: observed["envelope"].get(key) for key in row["expect"]} == row["expect"], observed + else: + assert observed["envelope"] == row["expect"], observed + assert observed["effect"] == row["effect"], observed + assert observed["outbox_added"] == row.get("outbox_added", []), observed + + +def test_baseline_annotations_are_not_stale() -> None: + for row in load_rows(): + for revision, delta in (row.get("baseline") or {}).items(): + if revision == "note": + continue + assert delta != row["expect"], row["id"] diff --git a/tests/control_plane_ts/legacy_writer_fence_caller_parity.test.ts b/tests/control_plane_ts/legacy_writer_fence_caller_parity.test.ts new file mode 100644 index 0000000000..ea10a68d06 --- /dev/null +++ b/tests/control_plane_ts/legacy_writer_fence_caller_parity.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; + +import type { JsonObject } from "../../loopx/control_plane/effect_program.ts"; +import { observeRow, TS_ROWS, type ParityRow } from "./legacy_writer_fence_caller_parity_support.ts"; + +/** + * Data-driven sibling-caller parity for fenced legacy writes. + * + * Every row compares the complete envelope, the exclusion-free effect snapshot + * of the runtime root, the declared after-state of named files, and (for + * fence-close rows) the identical retry against the literal expectation in the + * fixture. Nothing is matched by prefix or substring, so a truncated + * remediation, a fabricated receipt, a drifted settlement kind, or a skipped + * guard each fails exactly one row. + */ + +interface FixtureRow { + id: string; + surface: "ts_entry" | "cli"; + caller: ParityRow["caller"]; + fence_state: ParityRow["fence_state"]; + expect: JsonObject; + effect: { added: string[]; removed: string[]; changed: string[] }; + retry: JsonObject | null; + after?: Record; + baseline: Record | null; +} + +const fixture = JSON.parse( + readFileSync(new URL("../fixtures/control_plane/legacy_writer_fence_caller_parity_v0.json", import.meta.url), "utf8"), +) as { schema_version: string; rows: FixtureRow[] }; +const rows = fixture.rows.filter((row) => row.surface === "ts_entry"); + +function globMatches(pattern: string, path: string): boolean { + const escaped = pattern.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*"); + return new RegExp(`^${escaped}$`).test(path); +} + +function listFiles(root: string, prefix = ""): string[] { + return readdirSync(join(root, prefix), { withFileTypes: true }).flatMap((entry) => { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + return entry.isDirectory() ? listFiles(root, rel) : [rel]; + }); +} + +test("the fixture declares every TypeScript entry row exactly once", () => { + assert.equal(fixture.schema_version, "loopx_legacy_writer_fence_caller_parity_v0"); + assert.deepEqual(rows.map((row) => row.id).sort(), TS_ROWS.map((row) => row.id).sort()); + for (const row of rows) { + for (const [revision, delta] of Object.entries(row.baseline ?? {})) { + if (revision === "note") continue; + assert.notDeepEqual(delta, row.expect, `${row.id}: stale baseline annotation for ${revision}`); + } + } +}); + +for (const row of rows) { + test(`fence parity: ${row.id}`, async () => { + const parity = TS_ROWS.find((candidate) => candidate.id === row.id); + assert.ok(parity, `${row.id} is not an executable row`); + const observed = await observeRow(parity); + assert.deepEqual(observed.envelope, row.expect); + assert.deepEqual(observed.effect, row.effect); + assert.deepEqual(observed.retry, row.retry); + const files = listFiles(observed.runtime_root); + for (const [target, subset] of Object.entries(row.after ?? {})) { + const matches = files.filter((path) => path === target || globMatches(target, path)); + assert.equal(matches.length, 1, `${row.id}: ${target} matched ${matches.length} files`); + const record = JSON.parse(readFileSync(join(observed.runtime_root, matches[0]), "utf8")) as JsonObject; + for (const [key, value] of Object.entries(subset)) { + assert.deepEqual(record[key], value, `${row.id}: ${target} ${key}`); + } + } + }); +} diff --git a/tests/control_plane_ts/legacy_writer_fence_caller_parity_support.ts b/tests/control_plane_ts/legacy_writer_fence_caller_parity_support.ts new file mode 100644 index 0000000000..0ed26ae7b1 --- /dev/null +++ b/tests/control_plane_ts/legacy_writer_fence_caller_parity_support.ts @@ -0,0 +1,341 @@ +/** + * Row executor for the fenced sibling-caller parity table. + * + * One implementation drives both the test and the fixture recorder: every row + * builds an isolated workspace, applies one fence state, runs one native + * task-lease caller, and reports the complete envelope together with an + * exclusion-free effect snapshot of the runtime root. Expectations live in + * tests/fixtures/control_plane/legacy_writer_fence_caller_parity_v0.json. + */ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; + +import type { JsonObject } from "../../loopx/control_plane/effect_program.ts"; +import { atomicWriteJson } from "../../loopx/control_plane/effect_runtime_io.ts"; +import { + LEGACY_COORDINATION_WRITER_FENCE_ENGAGE_REQUEST_SCHEMA, + LEGACY_COORDINATION_WRITER_FENCE_SCHEMA, +} from "../../loopx/control_plane/coordination/coordination_state_contract.generated.ts"; +import { + engageLegacyCoordinationWriterFence, + legacyCoordinationWriterFencePath, +} from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; +import { executeTaskLeaseAcquire } from "../../loopx/control_plane/work_items/task_lease_acquire.ts"; +import { + executeTaskLeaseLifecycle, + TASK_LEASE_LIFECYCLE_REQUEST_SCHEMA_VERSION, +} from "../../loopx/control_plane/work_items/task_lease_lifecycle.ts"; + +export type FenceState = "absent" | "engaged" | "invalid" | "unreadable"; +export type ParityCaller = + | "acquire" + | "renew" + | "transfer" + | "release" + | "terminal_verify_auto_acquire" + | "terminal_verify_auto_acquire_keyed" + | "fence_close_release"; + +export interface ParityRow { + id: string; + caller: ParityCaller; + fence_state: FenceState; +} + +export interface EffectDiff { + added: string[]; + removed: string[]; + changed: string[]; +} + +export interface Observation { + envelope: JsonObject; + effect: EffectDiff; + /** Parsed JSON of every added or changed file, keyed by runtime-relative path. */ + artifacts: Record; + /** The identical request issued a second time; fence-close rows only. */ + retry: JsonObject | null; + /** Runtime root of the workspace, for declared after-state checks. */ + runtime_root: string; +} + +export const GOAL = "goal-a"; +export const RUNTIME_ROOT_PLACEHOLDER = "{runtime_root}"; +const FENCE_ID = "legacy-writer-fence:goal-a:state-1"; +const PARITY_KEY = "lease:parity"; +/** Fixed clock so lease timestamps in the fixture are literal. */ +const FIXED_NOW = new Date("2026-09-07T00:00:00.000Z"); +const CLOCK = { now: () => FIXED_NOW }; + +const FENCED_CALLERS: ParityCaller[] = [ + "acquire", + "renew", + "transfer", + "release", + "terminal_verify_auto_acquire", + "fence_close_release", +]; + +export const TS_ROWS: ParityRow[] = [ + ...FENCED_CALLERS.flatMap((caller) => + (["engaged", "invalid", "unreadable"] as const).map((fence_state) => ({ + id: `ts-${caller}-${fence_state}`, + caller, + fence_state, + })) + ), + { id: "ts-terminal_verify_auto_acquire_keyed-engaged", caller: "terminal_verify_auto_acquire_keyed", fence_state: "engaged" }, + { id: "ts-acquire-absent", caller: "acquire", fence_state: "absent" }, + { id: "ts-release-absent", caller: "release", fence_state: "absent" }, +]; + +interface Workspace { + root: string; + runtimeRoot: string; + statePath: string; + authority: JsonObject; + lockToken: string | null; + fenceOperationId: string | null; + leaseVersion: number; + leaseEpoch: number; +} + +async function snapshot(root: string): Promise> { + const out: Record = {}; + async function walk(dir: string): Promise { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(path); + } else { + out[relative(root, path)] = createHash("sha256").update(await readFile(path)).digest("hex"); + } + } + } + await walk(root); + return out; +} + +function diff(before: Record, after: Record): EffectDiff { + return { + added: Object.keys(after).filter((key) => !(key in before)).sort(), + removed: Object.keys(before).filter((key) => !(key in after)).sort(), + changed: Object.keys(after).filter((key) => key in before && before[key] !== after[key]).sort(), + }; +} + +async function workspace(): Promise { + const root = await mkdtemp(join(tmpdir(), "loopx-fence-parity-")); + const runtimeRoot = join(root, "runtime"); + await mkdir(runtimeRoot, { recursive: true }); + const statePath = join(root, "ACTIVE_GOAL_STATE.md"); + await writeFile(statePath, "---\ngoal_id: goal-a\nhandoff_mode: hard_lease\n---\n\n## Agent Todo\n\n", "utf8"); + const authorityPath = join(root, "authority-source.json"); + await writeFile(authorityPath, "authority-v1", "utf8"); + const authority: JsonObject = { + handoff_mode: "hard_lease", + registered_agent_candidates: [["agent-a", "agent-b"]], + todos: [ + { todo_id: "todo_abc", status: "open", claimed_by: "agent-a", role: "agent", task_class: "advancement_task" }, + { todo_id: "todo_gate", status: "open", claimed_by: null, role: "user", task_class: "user_gate" }, + ], + todo_projection_error: null, + source_receipts: [{ + source_id: "authority", + path: authorityPath, + state: "file", + sha256: createHash("sha256").update("authority-v1").digest("hex"), + }], + }; + return { root, runtimeRoot, statePath, authority, lockToken: null, fenceOperationId: null, leaseVersion: 0, leaseEpoch: 0 }; +} + +function lifecycleRequest(ws: Workspace, operation: string, extra: JsonObject): JsonObject { + return { + schema_version: TASK_LEASE_LIFECYCLE_REQUEST_SCHEMA_VERSION, + operation, + runtime_root: ws.runtimeRoot, + goal_id: GOAL, + todo_id: "todo_abc", + owner: "agent-a", + idempotency_key: PARITY_KEY, + expected_version: ws.leaseVersion, + new_owner: null, + new_idempotency_key: null, + authority: ws.authority, + ...extra, + }; +} + +async function acquireParityLease(ws: Workspace): Promise { + const acquired = await executeTaskLeaseAcquire({ + schema_version: "loopx_task_lease_acquire_native_v0", + runtime_root: ws.runtimeRoot, + goal_id: GOAL, + todo_id: "todo_abc", + owner: "agent-a", + idempotency_key: PARITY_KEY, + write_scopes: [], + ttl_seconds: 600, + expected_version: null, + authority: ws.authority, + }, CLOCK); + if (acquired.ok !== true) throw new Error(`parity fixture acquire failed: ${JSON.stringify(acquired)}`); + const lease = acquired.lease as JsonObject; + ws.leaseVersion = lease.version as number; + ws.leaseEpoch = lease.lease_epoch as number; +} + +async function holdParityFence(ws: Workspace): Promise { + const verify = await executeTaskLeaseLifecycle(lifecycleRequest(ws, "terminal_verify", {}), CLOCK); + const fence = verify.fence as JsonObject | undefined; + if (verify.ok !== true || !fence) throw new Error(`parity fixture verify failed: ${JSON.stringify(verify)}`); + ws.lockToken = String(fence.lock_token); + ws.fenceOperationId = String(fence.fence_operation_id); +} + +function fenceRecord(state: "engaged" | "disengaged"): JsonObject { + return { + schema_version: LEGACY_COORDINATION_WRITER_FENCE_SCHEMA, + state, + goal_id: GOAL, + fence_id: FENCE_ID, + source_version: "state:1", + source_projection_sha256: "a".repeat(64), + expected_shadow_provider_revision: "file:1:aaaaaaaaaaaaaaaaaaaaaaaa", + }; +} + +async function applyFence(ws: Workspace, state: FenceState, lockHeld: boolean): Promise { + const fencePath = legacyCoordinationWriterFencePath(ws.runtimeRoot, GOAL); + if (state === "absent") return; + if (state === "unreadable") { + await mkdir(fencePath, { recursive: true }); + return; + } + if (state === "invalid") { + await atomicWriteJson(fencePath, fenceRecord("disengaged")); + return; + } + if (lockHeld) { + // A held terminal fence owns the lease lock, so the locked engage path + // cannot run; persist the exact record engagement would write. + await atomicWriteJson(fencePath, fenceRecord("engaged")); + return; + } + const engaged = await engageLegacyCoordinationWriterFence({ + schema_version: LEGACY_COORDINATION_WRITER_FENCE_ENGAGE_REQUEST_SCHEMA, + runtime_root: ws.runtimeRoot, + goal_id: GOAL, + state_path: ws.statePath, + fence: fenceRecord("engaged"), + }); + if (engaged.status !== "applied") throw new Error(`parity fixture engage failed: ${JSON.stringify(engaged)}`); +} + +function callerRequest(ws: Workspace, caller: ParityCaller): { kind: "acquire" | "lifecycle"; request: JsonObject } { + switch (caller) { + case "acquire": + return { + kind: "acquire", + request: { + schema_version: "loopx_task_lease_acquire_native_v0", + runtime_root: ws.runtimeRoot, + goal_id: GOAL, + todo_id: "todo_abc", + owner: "agent-a", + idempotency_key: "lease:parity-acquire", + write_scopes: [], + ttl_seconds: 600, + expected_version: null, + authority: ws.authority, + }, + }; + case "renew": + return { kind: "lifecycle", request: lifecycleRequest(ws, "renew", { ttl_seconds: 600 }) }; + case "transfer": + return { + kind: "lifecycle", + request: lifecycleRequest(ws, "transfer", { ttl_seconds: 600, new_owner: "agent-b", new_idempotency_key: "lease:parity-transfer" }), + }; + case "release": + return { kind: "lifecycle", request: lifecycleRequest(ws, "release", {}) }; + case "terminal_verify_auto_acquire": + return { + kind: "lifecycle", + request: lifecycleRequest(ws, "terminal_verify", { + todo_id: "todo_gate", + idempotency_key: null, + expected_version: null, + allow_user_gate_auto_acquire: true, + }), + }; + case "terminal_verify_auto_acquire_keyed": + return { + kind: "lifecycle", + request: lifecycleRequest(ws, "terminal_verify", { + todo_id: "todo_gate", + idempotency_key: "turn-1", + expected_version: null, + allow_user_gate_auto_acquire: true, + }), + }; + case "fence_close_release": + return { + kind: "lifecycle", + request: lifecycleRequest(ws, "fence_close", { + idempotency_key: null, + expected_version: null, + lock_token: ws.lockToken, + fence_operation_id: ws.fenceOperationId, + committed: true, + release_lease: true, + fence_owner: "agent-a", + fence_idempotency_key: PARITY_KEY, + fence_expected_version: ws.leaseVersion, + fence_expected_lease_epoch: ws.leaseEpoch, + owner_pid: process.pid, + }), + }; + } +} + +async function execute(ws: Workspace, caller: ParityCaller): Promise { + const { kind, request } = callerRequest(ws, caller); + return kind === "acquire" + ? await executeTaskLeaseAcquire(request, CLOCK) as JsonObject + : await executeTaskLeaseLifecycle(request, CLOCK); +} + +/** Replace the temporary runtime root inside an envelope with a stable placeholder. */ +export function normalize(value: unknown, runtimeRoot: string): JsonObject { + return JSON.parse(JSON.stringify(value).split(JSON.stringify(runtimeRoot).slice(1, -1)).join(RUNTIME_ROOT_PLACEHOLDER)); +} + +export async function observeRow(row: ParityRow): Promise { + const ws = await workspace(); + const needsLease = ["renew", "transfer", "release", "fence_close_release"].includes(row.caller); + if (needsLease) await acquireParityLease(ws); + const holdsFence = row.caller === "fence_close_release"; + if (holdsFence) await holdParityFence(ws); + await applyFence(ws, row.fence_state, holdsFence); + const before = await snapshot(ws.runtimeRoot); + const envelope = await execute(ws, row.caller); + const after = await snapshot(ws.runtimeRoot); + const effect = diff(before, after); + const artifacts: Record = {}; + for (const path of [...effect.added, ...effect.changed]) { + if (!path.endsWith(".json")) continue; + artifacts[path] = normalize(JSON.parse(await readFile(join(ws.runtimeRoot, path), "utf8")), ws.runtimeRoot); + } + const retry = holdsFence ? normalize(await execute(ws, row.caller), ws.runtimeRoot) : null; + return { envelope: normalize(envelope, ws.runtimeRoot), effect, artifacts, retry, runtime_root: ws.runtimeRoot }; +} diff --git a/tests/fixtures/control_plane/legacy_writer_fence_caller_parity_v0.json b/tests/fixtures/control_plane/legacy_writer_fence_caller_parity_v0.json new file mode 100644 index 0000000000..67271a22c3 --- /dev/null +++ b/tests/fixtures/control_plane/legacy_writer_fence_caller_parity_v0.json @@ -0,0 +1,1998 @@ +{ + "schema_version": "loopx_legacy_writer_fence_caller_parity_v0", + "source_baseline": "0fb497af8", + "description": "Complete observable behaviour of every fenced legacy writer entry point: whole-object envelopes, exit status, and exclusion-free effect snapshots. `baseline` entries are documentation of earlier revisions and are never executed.", + "placeholders": [ + "{runtime_root}", + "{todo_a}", + "{todo_b}", + "{todo_gate}" + ], + "rows": [ + { + "id": "ts-acquire-engaged", + "surface": "ts_entry", + "caller": "acquire", + "fence_state": "engaged", + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "acquire", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal goal-a; fence legacy-writer-fence:goal-a:state-1; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_abc.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "legacy-writer-fence:goal-a:state-1" + }, + "settlement": { + "effect_id": null, + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_coordination_writer_fenced" + } + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "retry": null, + "baseline": { + "note": "recorded with the same request against each revision", + "0fb497af8": { + "error": "legacy task-lease writer is fenced; use the canonical file authority", + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "settlement": { + "effect_id": "goal-a:agent-a:todo_abc:lease:probe-acquire", + "receipts": [ + { + "step": "validation", + "status": "committed", + "effect_id": "goal-a:agent-a:todo_abc:lease:probe-acquire" + } + ], + "failure": { + "step": "durable_writeback", + "kind": "writeback_rejected", + "code": "legacy_coordination_writer_fenced" + } + } + }, + "ee1b17217": { + "error": "legacy coordination writer is fenced", + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "settlement": { + "effect_id": null, + "receipts": [], + "failure": { + "step": "validation", + "kind": "writeback_rejected", + "code": "legacy_coordination_writer_fenced" + } + } + } + } + }, + { + "id": "ts-acquire-invalid", + "surface": "ts_entry", + "caller": "acquire", + "fence_state": "invalid", + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "acquire", + "error": "legacy coordination writer fence must be engaged", + "error_code": "legacy_writer_fence_read_failed", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_abc.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "failed", + "reason_code": "legacy_writer_fence_read_failed", + "reason": "legacy coordination writer fence must be engaged", + "authority_mode": "unknown_fail_closed" + }, + "settlement": { + "effect_id": null, + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_writer_fence_read_failed" + } + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "retry": null, + "baseline": null + }, + { + "id": "ts-acquire-unreadable", + "surface": "ts_entry", + "caller": "acquire", + "fence_state": "unreadable", + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "acquire", + "error": "EISDIR: illegal operation on a directory, read", + "error_code": "legacy_writer_fence_read_failed", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_abc.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "failed", + "reason_code": "legacy_writer_fence_read_failed", + "reason": "EISDIR: illegal operation on a directory, read", + "authority_mode": "unknown_fail_closed" + }, + "settlement": { + "effect_id": null, + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_writer_fence_read_failed" + } + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "retry": null, + "baseline": null + }, + { + "id": "ts-renew-engaged", + "surface": "ts_entry", + "caller": "renew", + "fence_state": "engaged", + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "renew", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal goal-a; fence legacy-writer-fence:goal-a:state-1; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_abc.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "legacy-writer-fence:goal-a:state-1" + }, + "settlement": { + "effect_id": "goal-a:agent-a:todo_abc:lease:parity", + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_coordination_writer_fenced" + } + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "retry": null, + "baseline": { + "note": "recorded with the same request against each revision", + "0fb497af8": { + "error": "legacy task-lease writer is fenced; use the canonical file authority", + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "settlement": { + "effect_id": "goal-a:agent-a:todo_abc:lease:probe", + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_coordination_writer_fenced" + } + } + }, + "ee1b17217": { + "error": "legacy coordination writer is fenced", + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "settlement": { + "effect_id": "goal-a:agent-a:todo_abc:lease:probe", + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_coordination_writer_fenced" + } + } + } + } + }, + { + "id": "ts-renew-invalid", + "surface": "ts_entry", + "caller": "renew", + "fence_state": "invalid", + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "renew", + "error": "legacy coordination writer fence must be engaged", + "error_code": "legacy_writer_fence_read_failed", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_abc.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "failed", + "reason_code": "legacy_writer_fence_read_failed", + "reason": "legacy coordination writer fence must be engaged", + "authority_mode": "unknown_fail_closed" + }, + "settlement": { + "effect_id": "goal-a:agent-a:todo_abc:lease:parity", + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_writer_fence_read_failed" + } + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "retry": null, + "baseline": null + }, + { + "id": "ts-renew-unreadable", + "surface": "ts_entry", + "caller": "renew", + "fence_state": "unreadable", + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "renew", + "error": "EISDIR: illegal operation on a directory, read", + "error_code": "legacy_writer_fence_read_failed", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_abc.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "failed", + "reason_code": "legacy_writer_fence_read_failed", + "reason": "EISDIR: illegal operation on a directory, read", + "authority_mode": "unknown_fail_closed" + }, + "settlement": { + "effect_id": "goal-a:agent-a:todo_abc:lease:parity", + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_writer_fence_read_failed" + } + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "retry": null, + "baseline": null + }, + { + "id": "ts-transfer-engaged", + "surface": "ts_entry", + "caller": "transfer", + "fence_state": "engaged", + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "transfer", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal goal-a; fence legacy-writer-fence:goal-a:state-1; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_abc.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "legacy-writer-fence:goal-a:state-1" + }, + "settlement": { + "effect_id": "goal-a:agent-a:todo_abc:lease:parity", + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_coordination_writer_fenced" + } + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "retry": null, + "baseline": { + "note": "recorded with the same request against each revision", + "0fb497af8": { + "error": "legacy task-lease writer is fenced; use the canonical file authority", + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "settlement": { + "effect_id": "goal-a:agent-a:todo_abc:lease:probe", + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_coordination_writer_fenced" + } + } + }, + "ee1b17217": { + "error": "legacy coordination writer is fenced", + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "settlement": { + "effect_id": "goal-a:agent-a:todo_abc:lease:probe", + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_coordination_writer_fenced" + } + } + } + } + }, + { + "id": "ts-transfer-invalid", + "surface": "ts_entry", + "caller": "transfer", + "fence_state": "invalid", + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "transfer", + "error": "legacy coordination writer fence must be engaged", + "error_code": "legacy_writer_fence_read_failed", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_abc.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "failed", + "reason_code": "legacy_writer_fence_read_failed", + "reason": "legacy coordination writer fence must be engaged", + "authority_mode": "unknown_fail_closed" + }, + "settlement": { + "effect_id": "goal-a:agent-a:todo_abc:lease:parity", + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_writer_fence_read_failed" + } + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "retry": null, + "baseline": null + }, + { + "id": "ts-transfer-unreadable", + "surface": "ts_entry", + "caller": "transfer", + "fence_state": "unreadable", + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "transfer", + "error": "EISDIR: illegal operation on a directory, read", + "error_code": "legacy_writer_fence_read_failed", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_abc.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "failed", + "reason_code": "legacy_writer_fence_read_failed", + "reason": "EISDIR: illegal operation on a directory, read", + "authority_mode": "unknown_fail_closed" + }, + "settlement": { + "effect_id": "goal-a:agent-a:todo_abc:lease:parity", + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_writer_fence_read_failed" + } + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "retry": null, + "baseline": null + }, + { + "id": "ts-release-engaged", + "surface": "ts_entry", + "caller": "release", + "fence_state": "engaged", + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "release", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal goal-a; fence legacy-writer-fence:goal-a:state-1; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_abc.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "legacy-writer-fence:goal-a:state-1" + }, + "settlement": { + "effect_id": "goal-a:agent-a:todo_abc:lease:parity", + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_coordination_writer_fenced" + } + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "retry": null, + "baseline": { + "note": "recorded with the same request against each revision", + "0fb497af8": { + "error": "legacy task-lease writer is fenced; use the canonical file authority", + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "settlement": { + "effect_id": "goal-a:agent-a:todo_abc:lease:probe", + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_coordination_writer_fenced" + } + } + }, + "ee1b17217": { + "error": "legacy coordination writer is fenced", + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "settlement": { + "effect_id": "goal-a:agent-a:todo_abc:lease:probe", + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_coordination_writer_fenced" + } + } + } + } + }, + { + "id": "ts-release-invalid", + "surface": "ts_entry", + "caller": "release", + "fence_state": "invalid", + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "release", + "error": "legacy coordination writer fence must be engaged", + "error_code": "legacy_writer_fence_read_failed", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_abc.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "failed", + "reason_code": "legacy_writer_fence_read_failed", + "reason": "legacy coordination writer fence must be engaged", + "authority_mode": "unknown_fail_closed" + }, + "settlement": { + "effect_id": "goal-a:agent-a:todo_abc:lease:parity", + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_writer_fence_read_failed" + } + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "retry": null, + "baseline": null + }, + { + "id": "ts-release-unreadable", + "surface": "ts_entry", + "caller": "release", + "fence_state": "unreadable", + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "release", + "error": "EISDIR: illegal operation on a directory, read", + "error_code": "legacy_writer_fence_read_failed", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_abc.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "failed", + "reason_code": "legacy_writer_fence_read_failed", + "reason": "EISDIR: illegal operation on a directory, read", + "authority_mode": "unknown_fail_closed" + }, + "settlement": { + "effect_id": "goal-a:agent-a:todo_abc:lease:parity", + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_writer_fence_read_failed" + } + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "retry": null, + "baseline": null + }, + { + "id": "ts-terminal_verify_auto_acquire-engaged", + "surface": "ts_entry", + "caller": "terminal_verify_auto_acquire", + "fence_state": "engaged", + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "terminal_verify", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal goal-a; fence legacy-writer-fence:goal-a:state-1; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_gate.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "legacy-writer-fence:goal-a:state-1" + }, + "settlement": { + "effect_id": null, + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_coordination_writer_fenced" + } + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "retry": null, + "baseline": { + "note": "0fb497af8 did not consult the fence on the auto-acquire branch", + "0fb497af8": { + "ok": true, + "lease_file_created": true + }, + "ee1b17217": { + "error": "legacy coordination writer is fenced", + "schema_version": "loopx_legacy_coordination_write_check_result_v0" + } + } + }, + { + "id": "ts-terminal_verify_auto_acquire-invalid", + "surface": "ts_entry", + "caller": "terminal_verify_auto_acquire", + "fence_state": "invalid", + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "terminal_verify", + "error": "legacy coordination writer fence must be engaged", + "error_code": "legacy_writer_fence_read_failed", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_gate.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "failed", + "reason_code": "legacy_writer_fence_read_failed", + "reason": "legacy coordination writer fence must be engaged", + "authority_mode": "unknown_fail_closed" + }, + "settlement": { + "effect_id": null, + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_writer_fence_read_failed" + } + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "retry": null, + "baseline": null + }, + { + "id": "ts-terminal_verify_auto_acquire-unreadable", + "surface": "ts_entry", + "caller": "terminal_verify_auto_acquire", + "fence_state": "unreadable", + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "terminal_verify", + "error": "EISDIR: illegal operation on a directory, read", + "error_code": "legacy_writer_fence_read_failed", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_gate.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "failed", + "reason_code": "legacy_writer_fence_read_failed", + "reason": "EISDIR: illegal operation on a directory, read", + "authority_mode": "unknown_fail_closed" + }, + "settlement": { + "effect_id": null, + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_writer_fence_read_failed" + } + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "retry": null, + "baseline": null + }, + { + "id": "ts-fence_close_release-engaged", + "surface": "ts_entry", + "caller": "fence_close_release", + "fence_state": "engaged", + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "fence_close", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal goal-a; fence legacy-writer-fence:goal-a:state-1; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_abc.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "legacy-writer-fence:goal-a:state-1" + }, + "settlement": { + "effect_id": null, + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_coordination_writer_fenced" + } + } + }, + "effect": { + "added": [], + "removed": [ + "goals/goal-a/task-leases/.task-leases.ts-effect.lock" + ], + "changed": [] + }, + "retry": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "fence_close", + "error": "task lease fence token is no longer held", + "error_code": "fence_token_invalid", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_abc.json", + "settlement": { + "effect_id": null, + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "fence_token_invalid" + } + } + }, + "after": { + "goals/goal-a/task-leases/todo_abc.json": { + "status": "active", + "owner": "agent-a" + }, + "goals/goal-a/task-leases/.lifecycle-fences/*.json": { + "state": "held" + } + }, + "baseline": { + "note": "0fb497af8 did not consult the fence on a committed releasing close", + "0fb497af8": { + "ok": false, + "lease_status_after": "active" + }, + "ee1b17217": { + "error": "legacy coordination writer is fenced", + "schema_version": "loopx_legacy_coordination_write_check_result_v0" + } + } + }, + { + "id": "ts-fence_close_release-invalid", + "surface": "ts_entry", + "caller": "fence_close_release", + "fence_state": "invalid", + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "fence_close", + "error": "legacy coordination writer fence must be engaged", + "error_code": "legacy_writer_fence_read_failed", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_abc.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "failed", + "reason_code": "legacy_writer_fence_read_failed", + "reason": "legacy coordination writer fence must be engaged", + "authority_mode": "unknown_fail_closed" + }, + "settlement": { + "effect_id": null, + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_writer_fence_read_failed" + } + } + }, + "effect": { + "added": [], + "removed": [ + "goals/goal-a/task-leases/.task-leases.ts-effect.lock" + ], + "changed": [] + }, + "retry": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "fence_close", + "error": "task lease fence token is no longer held", + "error_code": "fence_token_invalid", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_abc.json", + "settlement": { + "effect_id": null, + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "fence_token_invalid" + } + } + }, + "after": { + "goals/goal-a/task-leases/todo_abc.json": { + "status": "active", + "owner": "agent-a" + }, + "goals/goal-a/task-leases/.lifecycle-fences/*.json": { + "state": "held" + } + }, + "baseline": null + }, + { + "id": "ts-fence_close_release-unreadable", + "surface": "ts_entry", + "caller": "fence_close_release", + "fence_state": "unreadable", + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "fence_close", + "error": "EISDIR: illegal operation on a directory, read", + "error_code": "legacy_writer_fence_read_failed", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_abc.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "failed", + "reason_code": "legacy_writer_fence_read_failed", + "reason": "EISDIR: illegal operation on a directory, read", + "authority_mode": "unknown_fail_closed" + }, + "settlement": { + "effect_id": null, + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_writer_fence_read_failed" + } + } + }, + "effect": { + "added": [], + "removed": [ + "goals/goal-a/task-leases/.task-leases.ts-effect.lock" + ], + "changed": [] + }, + "retry": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "fence_close", + "error": "task lease fence token is no longer held", + "error_code": "fence_token_invalid", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_abc.json", + "settlement": { + "effect_id": null, + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "fence_token_invalid" + } + } + }, + "after": { + "goals/goal-a/task-leases/todo_abc.json": { + "status": "active", + "owner": "agent-a" + }, + "goals/goal-a/task-leases/.lifecycle-fences/*.json": { + "state": "held" + } + }, + "baseline": null + }, + { + "id": "ts-terminal_verify_auto_acquire_keyed-engaged", + "surface": "ts_entry", + "caller": "terminal_verify_auto_acquire_keyed", + "fence_state": "engaged", + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "terminal_verify", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal goal-a; fence legacy-writer-fence:goal-a:state-1; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced", + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_gate.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "legacy-writer-fence:goal-a:state-1" + }, + "settlement": { + "effect_id": "goal-a:agent-a:todo_gate:turn-1", + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_coordination_writer_fenced" + } + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "retry": null, + "baseline": { + "note": "0fb497af8 did not consult the fence on the auto-acquire branch", + "0fb497af8": { + "ok": true, + "lease_file_created": true + }, + "ee1b17217": { + "error": "legacy coordination writer is fenced", + "schema_version": "loopx_legacy_coordination_write_check_result_v0" + } + } + }, + { + "id": "ts-acquire-absent", + "surface": "ts_entry", + "caller": "acquire", + "fence_state": "absent", + "expect": { + "ok": true, + "schema_version": "task_lease_v0", + "action": "acquire", + "acquired": true, + "idempotent": false, + "lease": { + "schema_version": "task_lease_v0", + "goal_id": "goal-a", + "todo_id": "todo_abc", + "owner": "agent-a", + "idempotency_key": "lease:parity-acquire", + "write_scopes": [], + "acquire_ttl_seconds": 600, + "version": 1, + "lease_epoch": 1, + "acquired_at": "2026-09-07T00:00:00Z", + "updated_at": "2026-09-07T00:00:00Z", + "expires_at": "2026-09-07T00:10:00Z", + "status": "active" + }, + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_abc.json", + "settlement": { + "effect_id": "goal-a:agent-a:todo_abc:lease:parity-acquire", + "receipts": [ + { + "step": "validation", + "status": "committed", + "effect_id": "goal-a:agent-a:todo_abc:lease:parity-acquire" + }, + { + "step": "durable_writeback", + "status": "committed", + "effect_id": "goal-a:agent-a:todo_abc:lease:parity-acquire" + } + ] + } + }, + "effect": { + "added": [ + "goals/goal-a/task-leases/todo_abc.json" + ], + "removed": [], + "changed": [] + }, + "retry": null, + "baseline": null + }, + { + "id": "ts-release-absent", + "surface": "ts_entry", + "caller": "release", + "fence_state": "absent", + "expect": { + "ok": true, + "schema_version": "task_lease_v0", + "action": "release", + "released": true, + "lease": { + "schema_version": "task_lease_v0", + "goal_id": "goal-a", + "todo_id": "todo_abc", + "owner": "agent-a", + "idempotency_key": "lease:parity", + "write_scopes": [], + "acquire_ttl_seconds": 600, + "version": 1, + "lease_epoch": 1, + "acquired_at": "2026-09-07T00:00:00Z", + "updated_at": "2026-09-07T00:00:00Z", + "expires_at": "2026-09-07T00:10:00Z", + "status": "released", + "released_at": "2026-09-07T00:00:00Z" + }, + "lease_path": "{runtime_root}/goals/goal-a/task-leases/todo_abc.json", + "handoff_mode": "hard_lease", + "idempotent": false, + "settlement": { + "effect_id": "goal-a:agent-a:todo_abc:lease:parity", + "receipts": [ + { + "step": "validation", + "status": "committed", + "effect_id": "goal-a:agent-a:todo_abc:lease:parity" + }, + { + "step": "durable_writeback", + "status": "committed", + "effect_id": "goal-a:agent-a:todo_abc:lease:parity" + } + ] + } + }, + "effect": { + "added": [ + "goals/goal-a/task-leases/.lifecycle-operations/8ae0c1ce97a3c3086923d5d30b080554b40f2de7791e8e9c06cead71dbe23772.json" + ], + "removed": [], + "changed": [ + "goals/goal-a/task-leases/todo_abc.json" + ] + }, + "retry": null, + "baseline": null + }, + { + "id": "cli-task_lease_acquire-absent", + "surface": "cli", + "workspace": "w1", + "caller": "task_lease_acquire", + "fence_state": "absent", + "exit": 0, + "expect": { + "ok": true, + "schema_version": "task_lease_v0", + "action": "acquire", + "acquired": true, + "idempotent": false, + "lease_path": "{runtime_root}/goals/observable/task-leases/{todo_b}.json" + }, + "effect": { + "added": [ + "runtime/goals/observable/task-leases/todo_f49c7f1b755a.json" + ], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "match": "subset", + "baseline": null + }, + { + "id": "cli-task_lease_acquire-engaged", + "surface": "cli", + "workspace": "w1", + "caller": "task_lease_acquire", + "fence_state": "engaged", + "exit": 1, + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "acquire", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced", + "lease_path": "{runtime_root}/goals/observable/task-leases/{todo_a}.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "caller-fixture" + }, + "settlement": { + "effect_id": null, + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_coordination_writer_fenced" + } + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": { + "note": "recorded through the real CLI against each revision", + "0fb497af8": { + "ok": false, + "error": "native task-lease acquire result shape mismatch", + "error_code": "RuntimeError", + "schema_version": "task_lease_v0" + }, + "ee1b17217": { + "ok": false, + "error": "native task-lease acquire result shape mismatch", + "error_code": "RuntimeError", + "schema_version": "task_lease_v0" + } + } + }, + { + "id": "cli-task_lease_renew-engaged", + "surface": "cli", + "workspace": "w1", + "caller": "task_lease_renew", + "fence_state": "engaged", + "exit": 1, + "expect": { + "lease_path": "{runtime_root}/goals/observable/task-leases/{todo_b}.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "caller-fixture" + }, + "handoff_mode": "hard_lease", + "ok": false, + "schema_version": "task_lease_v0", + "action": "renew", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced" + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": { + "note": "recorded through the real CLI against each revision", + "0fb497af8": { + "ok": false, + "error": "native task-lease lifecycle schema mismatch", + "error_code": "RuntimeError", + "schema_version": "task_lease_v0" + }, + "ee1b17217": { + "ok": false, + "error": "native task-lease lifecycle schema mismatch", + "error_code": "RuntimeError", + "schema_version": "task_lease_v0" + } + } + }, + { + "id": "cli-task_lease_transfer-engaged", + "surface": "cli", + "workspace": "w1", + "caller": "task_lease_transfer", + "fence_state": "engaged", + "exit": 1, + "expect": { + "lease_path": "{runtime_root}/goals/observable/task-leases/{todo_b}.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "caller-fixture" + }, + "handoff_mode": "hard_lease", + "ok": false, + "schema_version": "task_lease_v0", + "action": "transfer", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced" + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": { + "note": "recorded through the real CLI against each revision", + "0fb497af8": { + "ok": false, + "error": "native task-lease lifecycle schema mismatch", + "error_code": "RuntimeError", + "schema_version": "task_lease_v0" + }, + "ee1b17217": { + "ok": false, + "error": "native task-lease lifecycle schema mismatch", + "error_code": "RuntimeError", + "schema_version": "task_lease_v0" + } + } + }, + { + "id": "cli-task_lease_release-engaged", + "surface": "cli", + "workspace": "w1", + "caller": "task_lease_release", + "fence_state": "engaged", + "exit": 1, + "expect": { + "lease_path": "{runtime_root}/goals/observable/task-leases/{todo_b}.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "caller-fixture" + }, + "handoff_mode": "hard_lease", + "ok": false, + "schema_version": "task_lease_v0", + "action": "release", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced" + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": { + "note": "recorded through the real CLI against each revision", + "0fb497af8": { + "ok": false, + "error": "native task-lease lifecycle schema mismatch", + "error_code": "RuntimeError", + "schema_version": "task_lease_v0" + }, + "ee1b17217": { + "ok": false, + "error": "native task-lease lifecycle schema mismatch", + "error_code": "RuntimeError", + "schema_version": "task_lease_v0" + } + } + }, + { + "id": "cli-todo_complete-engaged", + "surface": "cli", + "workspace": "w1", + "caller": "todo_complete", + "fence_state": "engaged", + "exit": 1, + "expect": { + "ok": false, + "dry_run": false, + "added": false, + "already_exists": false, + "goal_id": "observable", + "role": null, + "todo": "", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "caller-fixture" + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": { + "note": "recorded through the real CLI against each revision", + "0fb497af8": { + "ok": false, + "error": "legacy coordination writer is fenced; use the canonical file authority" + }, + "ee1b17217": { + "ok": false, + "error": "legacy coordination writer is fenced; use the canonical file authority", + "error_code": "legacy_coordination_writer_fenced", + "schema_version": "loopx_legacy_coordination_write_check_result_v0" + } + } + }, + { + "id": "cli-todo_update_status-engaged", + "surface": "cli", + "workspace": "w1", + "caller": "todo_update_status", + "fence_state": "engaged", + "exit": 1, + "expect": { + "ok": false, + "dry_run": false, + "added": false, + "already_exists": false, + "goal_id": "observable", + "role": null, + "todo": "", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "caller-fixture" + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": { + "note": "same typed rejection as the other Markdown Todo writers; not recorded on earlier revisions" + } + }, + { + "id": "cli-todo_supersede-engaged", + "surface": "cli", + "workspace": "w1", + "caller": "todo_supersede", + "fence_state": "engaged", + "exit": 1, + "expect": { + "ok": false, + "dry_run": false, + "added": false, + "already_exists": false, + "goal_id": "observable", + "role": null, + "todo": "Parity successor", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "caller-fixture" + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": { + "note": "recorded through the real CLI against each revision", + "0fb497af8": { + "ok": false, + "error": "legacy coordination writer is fenced; use the canonical file authority" + }, + "ee1b17217": { + "ok": false, + "error": "legacy coordination writer is fenced; use the canonical file authority", + "error_code": "legacy_coordination_writer_fenced", + "schema_version": "loopx_legacy_coordination_write_check_result_v0" + } + } + }, + { + "id": "cli-todo_archive_completed_execute-engaged", + "surface": "cli", + "workspace": "w1", + "caller": "todo_archive_completed_execute", + "fence_state": "engaged", + "exit": 1, + "expect": { + "ok": false, + "dry_run": false, + "added": false, + "already_exists": false, + "goal_id": "observable", + "role": null, + "todo": "", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "caller-fixture" + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": { + "note": "recorded through the real CLI against each revision", + "0fb497af8": { + "ok": false, + "error": "legacy coordination writer is fenced; use the canonical file authority" + }, + "ee1b17217": { + "ok": false, + "error": "legacy coordination writer is fenced; use the canonical file authority", + "error_code": "legacy_coordination_writer_fenced", + "schema_version": "loopx_legacy_coordination_write_check_result_v0" + } + } + }, + { + "id": "cli-todo_capture_followups-engaged", + "surface": "cli", + "workspace": "w1", + "caller": "todo_capture_followups", + "fence_state": "engaged", + "exit": 1, + "expect": { + "ok": false, + "dry_run": false, + "added": false, + "already_exists": false, + "goal_id": "observable", + "role": null, + "todo": "", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "caller-fixture" + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": { + "note": "recorded through the real CLI against each revision", + "0fb497af8": { + "ok": true + }, + "ee1b17217": { + "ok": false, + "error": "legacy coordination writer is fenced; use the canonical file authority", + "error_code": "legacy_coordination_writer_fenced", + "schema_version": "loopx_legacy_coordination_write_check_result_v0" + } + } + }, + { + "id": "cli-handoff_mode_set-engaged", + "surface": "cli", + "workspace": "w1", + "caller": "handoff_mode_set", + "fence_state": "engaged", + "exit": 1, + "expect": { + "ok": false, + "schema_version": "goal_handoff_mode_v0", + "action": "set", + "goal_id": "observable", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "caller-fixture" + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": { + "note": "recorded through the real CLI against each revision", + "0fb497af8": { + "ok": true, + "schema_version": "goal_handoff_mode_v0" + }, + "ee1b17217": { + "ok": false, + "error": "legacy coordination writer is fenced; use the canonical file authority", + "error_code": "legacy_coordination_writer_fenced", + "schema_version": "loopx_legacy_coordination_write_check_result_v0" + } + } + }, + { + "id": "cli-todo_archive_completed_preview-engaged", + "surface": "cli", + "workspace": "w1", + "caller": "todo_archive_completed_preview", + "fence_state": "engaged", + "exit": 0, + "expect": { + "ok": true, + "dry_run": true, + "changed": false + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": { + "note": "previews skip every fence check and write nothing; successful output holds workspace paths and timestamps, so only the outcome keys are pinned", + "0fb497af8": { + "ok": true + }, + "ee1b17217": { + "ok": true + } + }, + "match": "subset" + }, + { + "id": "cli-todo_capture_followups_dry_run-engaged", + "surface": "cli", + "workspace": "w1", + "caller": "todo_capture_followups_dry_run", + "fence_state": "engaged", + "exit": 0, + "expect": { + "ok": true, + "dry_run": true, + "changed": true + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": { + "note": "previews skip every fence check and write nothing; successful output holds workspace paths and timestamps, so only the outcome keys are pinned", + "0fb497af8": { + "ok": true + }, + "ee1b17217": { + "ok": true + } + }, + "match": "subset" + }, + { + "id": "cli-todo_complete_dry_run_gate-engaged", + "surface": "cli", + "workspace": "w1", + "caller": "todo_complete_dry_run_gate", + "fence_state": "engaged", + "exit": 1, + "expect": { + "ok": false, + "dry_run": true, + "added": false, + "already_exists": false, + "goal_id": "observable", + "role": null, + "todo": "", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced", + "lease_path": "{runtime_root}/goals/observable/task-leases/{todo_gate}.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "caller-fixture" + }, + "handoff_mode": "hard_lease" + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": { + "note": "ee1b17217 returned the untyped 'native task-lease lifecycle schema mismatch' and left an acquired intent receipt; the verify path now checks the fence before its first receipt" + } + }, + { + "id": "cli-todo_supersede_dry_run-engaged", + "surface": "cli", + "workspace": "w1", + "caller": "todo_supersede_dry_run", + "fence_state": "engaged", + "exit": 1, + "expect": { + "ok": false, + "dry_run": true, + "added": false, + "already_exists": false, + "goal_id": "observable", + "role": null, + "todo": "Parity successor", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced", + "lease_path": "{runtime_root}/goals/observable/task-leases/{todo_a}.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "caller-fixture" + }, + "handoff_mode": "hard_lease" + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": { + "note": "ee1b17217 reported handoff_mode_requires_lease and left an acquired intent receipt for this preview" + } + }, + { + "id": "cli-todo_complete_dry_run_leased-engaged", + "surface": "cli", + "workspace": "w1", + "caller": "todo_complete_dry_run_leased", + "fence_state": "engaged", + "exit": 1, + "expect": { + "ok": false, + "dry_run": true, + "added": false, + "already_exists": false, + "goal_id": "observable", + "role": null, + "todo": "", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced", + "lease_path": "{runtime_root}/goals/observable/task-leases/{todo_b}.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "caller-fixture" + }, + "handoff_mode": "hard_lease" + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": { + "note": "ee1b17217 previewed ok=true for a write the fence forbids and persisted a closed lifecycle-fence receipt" + } + }, + { + "id": "cli-task_lease_acquire-engaged-capture", + "surface": "cli", + "workspace": "w2", + "caller": "task_lease_acquire", + "fence_state": "engaged", + "exit": 1, + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "acquire", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced", + "lease_path": "{runtime_root}/goals/observable/task-leases/{todo_a}.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "caller-fixture" + }, + "settlement": { + "effect_id": null, + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_coordination_writer_fenced" + } + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": null + }, + { + "id": "cli-task_lease_renew-engaged-capture", + "surface": "cli", + "workspace": "w2", + "caller": "task_lease_renew", + "fence_state": "engaged", + "exit": 1, + "expect": { + "lease_path": "{runtime_root}/goals/observable/task-leases/{todo_b}.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "caller-fixture" + }, + "handoff_mode": "hard_lease", + "ok": false, + "schema_version": "task_lease_v0", + "action": "renew", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced" + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": null + }, + { + "id": "cli-task_lease_transfer-engaged-capture", + "surface": "cli", + "workspace": "w2", + "caller": "task_lease_transfer", + "fence_state": "engaged", + "exit": 1, + "expect": { + "lease_path": "{runtime_root}/goals/observable/task-leases/{todo_b}.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "caller-fixture" + }, + "handoff_mode": "hard_lease", + "ok": false, + "schema_version": "task_lease_v0", + "action": "transfer", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced" + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": null + }, + { + "id": "cli-task_lease_release-engaged-capture", + "surface": "cli", + "workspace": "w2", + "caller": "task_lease_release", + "fence_state": "engaged", + "exit": 1, + "expect": { + "lease_path": "{runtime_root}/goals/observable/task-leases/{todo_b}.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "blocked", + "reason_code": "legacy_coordination_writer_fenced", + "authority_mode": "file_v0", + "fence_id": "caller-fixture" + }, + "handoff_mode": "hard_lease", + "ok": false, + "schema_version": "task_lease_v0", + "action": "release", + "error": "legacy coordination writer is fenced; use the promoted canonical authority (file_v0) for goal observable; fence caller-fixture; the primary record was not changed", + "error_code": "legacy_coordination_writer_fenced" + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": null + }, + { + "id": "cli-task_lease_acquire-invalid", + "surface": "cli", + "workspace": "w3", + "caller": "task_lease_acquire", + "fence_state": "invalid", + "exit": 1, + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "acquire", + "error": "legacy coordination writer fence must be engaged", + "error_code": "legacy_writer_fence_read_failed", + "lease_path": "{runtime_root}/goals/observable/task-leases/{todo_a}.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "failed", + "reason_code": "legacy_writer_fence_read_failed", + "reason": "legacy coordination writer fence must be engaged", + "authority_mode": "unknown_fail_closed" + }, + "settlement": { + "effect_id": null, + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_writer_fence_read_failed" + } + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": null + }, + { + "id": "cli-todo_capture_followups-invalid", + "surface": "cli", + "workspace": "w3", + "caller": "todo_capture_followups", + "fence_state": "invalid", + "exit": 1, + "expect": { + "ok": false, + "dry_run": false, + "added": false, + "already_exists": false, + "goal_id": "observable", + "role": null, + "todo": "", + "error": "legacy coordination writer fence must be engaged", + "error_code": "legacy_writer_fence_read_failed", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "failed", + "reason_code": "legacy_writer_fence_read_failed", + "reason": "legacy coordination writer fence must be engaged", + "authority_mode": "unknown_fail_closed" + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": null + }, + { + "id": "cli-task_lease_acquire-unreadable", + "surface": "cli", + "workspace": "w4", + "caller": "task_lease_acquire", + "fence_state": "unreadable", + "exit": 1, + "expect": { + "ok": false, + "schema_version": "task_lease_v0", + "action": "acquire", + "error": "EISDIR: illegal operation on a directory, read", + "error_code": "legacy_writer_fence_read_failed", + "lease_path": "{runtime_root}/goals/observable/task-leases/{todo_a}.json", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "failed", + "reason_code": "legacy_writer_fence_read_failed", + "reason": "EISDIR: illegal operation on a directory, read", + "authority_mode": "unknown_fail_closed" + }, + "settlement": { + "effect_id": null, + "receipts": [], + "failure": { + "step": "validation", + "kind": "permission_denied", + "code": "legacy_writer_fence_read_failed" + } + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": null + }, + { + "id": "cli-todo_capture_followups-unreadable", + "surface": "cli", + "workspace": "w4", + "caller": "todo_capture_followups", + "fence_state": "unreadable", + "exit": 1, + "expect": { + "ok": false, + "dry_run": false, + "added": false, + "already_exists": false, + "goal_id": "observable", + "role": null, + "todo": "", + "error": "EISDIR: illegal operation on a directory, read", + "error_code": "legacy_writer_fence_read_failed", + "write_check": { + "schema_version": "loopx_legacy_coordination_write_check_result_v0", + "status": "failed", + "reason_code": "legacy_writer_fence_read_failed", + "reason": "EISDIR: illegal operation on a directory, read", + "authority_mode": "unknown_fail_closed" + } + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": null + }, + { + "id": "cli-task_lease_acquire-fence_without_store", + "surface": "cli", + "workspace": "w5", + "caller": "task_lease_acquire", + "fence_state": "engaged_without_store", + "exit": 1, + "expect": { + "schema_version": "task_lease_v0", + "status": "missing", + "source_authority": "file_v0", + "decision_read_from_provider": true, + "legacy_fallback_used": false, + "ok": false, + "action": "acquire", + "error": "canonical Todo authority is unavailable", + "error_code": "local_authority_todo_list_unavailable" + }, + "effect": { + "added": [], + "removed": [], + "changed": [] + }, + "outbox_added": [], + "baseline": { + "note": "ee1b17217 printed schema_version loopx_local_coordination_todo_list_result_v0 because the exception payload was spread after the envelope keys" + } + } + ] +} diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index 2ae2c43c86..53016ff5f4 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -101,6 +101,8 @@ "tests/control_plane_ts/todo_next_action.test.ts", "tests/control_plane_ts/task_lease_acquire.test.ts", "tests/control_plane_ts/task_lease_acquire_cli.test.ts", + "tests/control_plane_ts/legacy_writer_fence_caller_parity_support.ts", + "tests/control_plane_ts/legacy_writer_fence_caller_parity.test.ts", "tests/control_plane_ts/turn_journal.test.ts", "tests/control_plane_ts/turn_journal_effects.test.ts", "tests/control_plane_ts/vision_checkpoint.test.ts", From a096904f9abe272e58cabd67e582e54748727a13 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Mon, 7 Sep 2026 19:00:28 +1000 Subject: [PATCH 24/27] test(coordination): reject fence remediation, envelope, and settlement regressions Five semantic mutants, each killed by exactly one parity row with a passing unchanged control: the TypeScript remediation truncated to the generic text (killed through the real CLI), the Python remediation truncated, the check result spread flat so its schema overwrites the envelope's, the acquire fence failure left untyped so it fabricates a validation receipt and a durable_writeback failure, and the ordinary lifecycle guard deleted so a fenced release succeeds and rewrites the lease. Signed-off-by: wchwawa --- examples/shared-goal-authority-e2e/mutants.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/examples/shared-goal-authority-e2e/mutants.py b/examples/shared-goal-authority-e2e/mutants.py index 5ece721091..5645614cc1 100644 --- a/examples/shared-goal-authority-e2e/mutants.py +++ b/examples/shared-goal-authority-e2e/mutants.py @@ -249,6 +249,33 @@ def apply(source: str) -> str: MIRROR_COMMIT)), ), "tests/control_plane/test_shadow_drain_e2e.py::test_public_mutation_has_no_second_snapshot_mirror")) +# Fenced sibling-caller parity: each mutant is caught by exactly one parity row. +CASES.append(Case("native_fence_remediation_truncated", ( + (COORDINATION + "legacy_writer_fence.ts", replacement( + 'export const LEGACY_WRITER_FENCED_REMEDIATION =\n "legacy coordination writer is fenced; use the promoted canonical authority ({authority_mode}) for goal {goal_id}; fence {fence_id}; the primary record was not changed";', + 'export const LEGACY_WRITER_FENCED_REMEDIATION =\n "legacy coordination writer is fenced";')), +), "tests/control_plane/test_shadow_fence_caller_parity_e2e.py::test_fence_caller_parity[cli-task_lease_acquire-engaged]")) +CASES.append(Case("python_fence_remediation_truncated", ( + (COORDINATION + "legacy_writer_fence.py", replacement( + 'LEGACY_WRITER_FENCED_REMEDIATION = (\n "legacy coordination writer is fenced; use the promoted canonical authority "\n "({authority_mode}) for goal {goal_id}; fence {fence_id}; "\n "the primary record was not changed"\n)', + 'LEGACY_WRITER_FENCED_REMEDIATION = "legacy coordination writer is fenced"')), +), "tests/control_plane/test_shadow_fence_caller_parity_e2e.py::test_fence_caller_parity[cli-todo_complete-engaged]")) +CASES.append(Case("fence_envelope_schema_leak", ( + (COORDINATION + "legacy_writer_fence.ts", replacement( + " this.payload = { write_check: writeCheck };", + " this.payload = writeCheck;")), +), "tests/control_plane/test_shadow_fence_caller_parity_e2e.py::test_fence_caller_parity[cli-task_lease_acquire-engaged]")) +CASES.append(Case("fence_acquire_receipt_fabricated", ( + ("loopx/control_plane/work_items/task_lease_acquire.ts", replacement( + ' return { code: error.code, message: error.message, payload: error.payload, stage: "validation", kind: "permission_denied" };', + ' return { code: error.code, message: error.message, payload: error.payload };')), +), "tests/control_plane_ts/legacy_writer_fence_caller_parity.test.ts", pattern="^fence parity: ts-acquire-engaged$")) +CASES.append(Case("lifecycle_fence_guard_skipped", ( + ("loopx/control_plane/work_items/task_lease_lifecycle.ts", replacement( + " await requireLegacyCoordinationPrimaryWriteAllowed(request!.runtime_root, request!.goal_id);\n", + "")), +), "tests/control_plane_ts/legacy_writer_fence_caller_parity.test.ts", pattern="^fence parity: ts-release-engaged$")) + def run(case: Case, directory: Path, log: Path) -> subprocess.CompletedProcess[str]: environment = {key: value for key, value in os.environ.items() From 6bd6d88f13173bb136fec5e4fa63d9b7da05f972 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Mon, 7 Sep 2026 19:00:28 +1000 Subject: [PATCH 25/27] docs(coordination): state the fenced write response contract and its baseline delta The correctness note gains a "Fenced legacy write response" section: what the shared write check owns, what each caller adapter renders and carries, the settlement shape and its RFC basis, the preview and fence-close rules, the parity fixture that pins every row, a nine-row baseline delta table against 0fb497af8 and ee1b17217, and the pre-existing quota boundary gap. Appendix C of the RFC, in both languages, names the guarded verbs including verify and committed releasing fence-close and the typed, receipt-free rejection. Signed-off-by: wchwawa --- ...shared-goal-authority-state-provider-v0.md | 11 ++- ...-goal-authority-state-provider-v0.zh-CN.md | 7 +- .../shared-goal-authority-e2e/correctness.md | 87 +++++++++++++++++++ 3 files changed, 100 insertions(+), 5 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 f71a1cdbba..8d07894e42 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -2268,9 +2268,14 @@ the unresolved gates remain prerequisites for a real promotion. writer fence bound to that revision; provider-first `mutate` and `todo_read` that never fall back to Markdown. - The fence integration: every Python Todo mutation and every native task-lease - acquire, renew, transfer, and release checks the durable fence while holding - its own lock; an absent fence costs no runtime call; a present, unreadable, - or invalid fence fails closed. + acquire, renew, transfer, release, verify, and committed releasing + fence-close checks the durable fence while holding its own lock; an absent + fence costs no runtime call; a present, unreadable, or invalid fence fails + closed. A fenced write is rejected before its first side effect as a + validation-stage `permission_denied` with no receipt; the shared check owns + only the typed reason and the fence binding facts, and each caller adapter + renders one provider-neutral remediation from them and carries the check + result under `write_check`. - The complete Todo read model: `loopx_todo_canonical_read_record_v0` publishes a versioned field manifest, and the TypeScript projection rejects a replacement that drops fields already present on a stored record. 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 e928518ecb..673bcb082d 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 @@ -1799,8 +1799,11 @@ authority 语义与各 provider 的物理 retention 策略。本文档不实现 writer fence 的 `coordination.local_authority.promote`;永不回退到 Markdown 的 provider-first `mutate` 与 `todo_read`。 - fence 集成:每个 Python Todo mutation 与每个 native task-lease - acquire/renew/transfer/release 都在自己的锁内检查持久 fence;fence 不存在时零运行时 - 调用;fence 存在但不可读或无效时 fail closed。 + acquire/renew/transfer/release/verify 以及已提交的释放型 fence-close 都在自己的锁内 + 检查持久 fence;fence 不存在时零运行时调用;fence 存在但不可读或无效时 fail closed。 + 被 fence 拒绝的写在第一个副作用之前以 validation 阶段的 `permission_denied` + 返回且不产生回执;共享检查只拥有类型化原因与 fence 绑定事实,各调用方 adapter + 据此渲染同一条 provider 中立的 remediation,并把检查结果放在 `write_check` 下携带。 - 完整 Todo read model:`loopx_todo_canonical_read_record_v0` 发布带版本字段 manifest; TypeScript projection 拒绝 replacement 丢弃既有记录中已经存在的字段。 diff --git a/examples/shared-goal-authority-e2e/correctness.md b/examples/shared-goal-authority-e2e/correctness.md index 27c1baadb6..982f34921c 100644 --- a/examples/shared-goal-authority-e2e/correctness.md +++ b/examples/shared-goal-authority-e2e/correctness.md @@ -99,6 +99,93 @@ selecting another Goal cannot bypass the existing binding or fence. Unbound legacy shared-state writes retain their existing behavior; prose-only writes retain their separate maintenance boundary. +### Fenced legacy write response + +A legacy Todo, handoff-mode, followup, registry-state, or native task-lease +write against a goal whose durable legacy writer fence is present is rejected +before its first primary side effect: no lease record, Markdown state, +canonical store document, outbox entry, or lifecycle-fence receipt is written. +The shared write check (`coordination.local_authority.legacy_write_check`) owns +only the stable machine reason and the fence binding facts: `reason_code` +`legacy_coordination_writer_fenced` with `authority_mode` and `fence_id` for an +engaged fence; `legacy_writer_fence_read_failed` with the technical `reason` +for a present but unreadable or invalid fence; every non-allowed result fails +closed. Each caller adapter (the TypeScript +`requireLegacyCoordinationPrimaryWriteAllowed` wrapper used by native acquire, +renew, transfer, release, every terminal or holder verify branch, and a +committed releasing fence-close; the Python +`require_legacy_coordination_write_allowed` wrapper used by every legacy Todo, +handoff, followup, registry, bootstrap, and monitor writer) maps the reason to +`error_code`, carries the complete check result unchanged under `write_check` +(never spread into its own envelope, so the envelope keeps its own +`schema_version`), and renders one provider-neutral remediation for an engaged +fence: + +``` +legacy coordination writer is fenced; use the promoted canonical authority +() for goal ; fence ; the primary record +was not changed +``` + +A fence that cannot be validated reports the check's technical reason +verbatim. The profile label is opaque data from the check (`file_v0` today), +never a provider path, table, or command, so a provider-neutral binding needs +no template change. + +A native task-lease envelope keeps `schema_version: task_lease_v0` and its +`action`; its settlement is `{effect_id, receipts: [], failure: {step: +"validation", kind: "permission_denied", code: }}` at every +native guard site alike: no receipt exists because no settlement step ran, and +`permission_denied` marks the rejection terminal for this writer (RFC section +5 `rejected` and its no-fabricated-receipt rule, section 5.6, question 11, +Appendix C). `effect_id` follows each verb's existing rule: acquire reports +`null` whenever no receipt exists; a lifecycle operation reports the +settlement identity when the request carries `owner` and `idempotency_key` +and `null` otherwise. + +Previews: `archive-completed` without `--execute` and `capture-followups +--dry-run` skip every fence check and write nothing. A terminal Todo preview +(`todo complete --dry-run`, `todo supersede --dry-run`) still enters the +native verify path, which now checks the fence before its first receipt, so +under a fence the preview reports the typed rejection with `dry_run: true` +and leaves nothing behind; without a fence it behaves as before. A fenced +committed releasing `fence_close` releases the caller's claimed mutation lock +in `finally` while the lease stays `active` and its `held` receipt is +untouched; the caller's fence token is spent, a retry reports +`fence_token_invalid`, and recovery is lease expiry or a canonical release. + +The complete observable behaviour is pinned row by row in +`tests/fixtures/control_plane/legacy_writer_fence_caller_parity_v0.json` +(21 TypeScript entry rows, 25 real-process CLI rows; whole-object envelopes, +exit status, exclusion-free effect snapshots, declared after-state) and +enforced by `tests/control_plane_ts/legacy_writer_fence_caller_parity.test.ts` +and `tests/control_plane/test_shadow_fence_caller_parity_e2e.py`; the +`baseline` entries of that fixture document earlier revisions and are never +executed. + +Baseline delta (0fb497af8 is the PR's diff baseline; ee1b17217 the previously +reviewed head): + +| Row | 0fb497af8 | ee1b17217 | Now | Basis | +| --- | --- | --- | --- | --- | +| native acquire settlement | committed validation receipt, `durable_writeback` / `writeback_rejected` | `validation` / `writeback_rejected`, no receipt | `validation` / `permission_denied`, no receipt | the guard ran before `commitAcquire` on every revision; only the classifier drifted; RFC section 5 forbids a fabricated receipt for `rejected` | +| native renew, transfer, release settlement | `validation` / `permission_denied` | same | same | unchanged; acquire now matches its siblings | +| native error text | `legacy task-lease writer is fenced; use the canonical file authority` | `legacy coordination writer is fenced` | one rendered template | remediation belongs to the caller adapter and must not name a provider | +| Python error text | `legacy coordination writer is fenced; use the canonical file authority` | same | one rendered template | same | +| envelope `schema_version` | check schema overwrote `task_lease_v0`; every CLI lease rejection printed `RuntimeError` | same | `task_lease_v0` plus nested `write_check` | envelope-owned keys win; check result travels under its contract name | +| Python Todo rejections | no `error_code` | flat check keys, `schema_version` injected | `error_code` plus `write_check` | same | +| terminal or holder verify under a fence | fence bypassed on the held and holder branches; auto-acquire branch not consulted | auto-acquire branch guarded; held branch previews reported `ok: true` and wrote receipts | every verify branch guarded before its first receipt | a preview must not succeed for a write the fence forbids | +| committed releasing fence-close | fence bypassed | guarded | guarded; lock released in `finally`, retry `fence_token_invalid` | declared and pinned | +| generic CLI branch of `task-lease` and `turn` | typed payload spread after envelope keys | same | payload first, envelope keys win | `LocalCoordinationAuthorityUnavailable` carries a `schema_version` | + +Known gap, unchanged on both revisions: `loopx quota monitor-poll --execute` +(and the scheduler monitor writeback it wraps) re-types every non-validation +exception to `quota_unexpected_collection_error` with reason `quota collection +failed`, so a fenced monitor writeback loses its `error_code` and remediation +at that boundary. The fix belongs to the quota domain's public-safety +contract (map the typed fence exception to its own code and pass `write_check` +through) and is not part of this batch. + Canonical FileAuthorityStore Todo updates, including compatibility v0 records already held by that authority, use the same M and maintenance boundary as canonical Todo creation. They retain their own transaction receipts and do not From 1d58bc91e753700f549dddbc6d2d55321fbe5095 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Mon, 7 Sep 2026 19:04:23 +1000 Subject: [PATCH 26/27] chore(cli): drop two unused runtime shadow imports Signed-off-by: wchwawa --- loopx/cli_commands/coordination_shadow.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/loopx/cli_commands/coordination_shadow.py b/loopx/cli_commands/coordination_shadow.py index e258c818c9..23dc8e06cc 100644 --- a/loopx/cli_commands/coordination_shadow.py +++ b/loopx/cli_commands/coordination_shadow.py @@ -9,9 +9,7 @@ from ..control_plane.coordination.runtime_shadow import ( bootstrap_coordination_runtime_shadow, build_runtime_shadow_source_snapshot, - build_todo_runtime_shadow_projection, inspect_coordination_runtime_shadow, - load_task_lease_runtime_shadow_records, qualify_coordination_runtime_shadow, read_coordination_runtime_shadow_todo_candidate, resolve_coordination_runtime_shadow_config, From 507b9f17ad7296762aa72d84696699cfa04edb48 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Mon, 7 Sep 2026 19:29:22 +1000 Subject: [PATCH 27/27] Revert "chore(cli): drop two unused runtime shadow imports" The two names are reached through this module by the coordination-shadow command tests, which seed and read the shadow via the command surface, so removing the imports broke four of them. Restore the imports with a note so the seam is not mistaken for dead code again. Signed-off-by: wchwawa --- loopx/cli_commands/coordination_shadow.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/loopx/cli_commands/coordination_shadow.py b/loopx/cli_commands/coordination_shadow.py index 23dc8e06cc..cb10a82cb4 100644 --- a/loopx/cli_commands/coordination_shadow.py +++ b/loopx/cli_commands/coordination_shadow.py @@ -6,10 +6,15 @@ from collections.abc import Callable from pathlib import Path -from ..control_plane.coordination.runtime_shadow import ( +# The projection builder and lease loader are reached through this module by +# tests that seed and read the shadow through the command surface; keep them +# importable here even when the command does not call them directly. +from ..control_plane.coordination.runtime_shadow import ( # noqa: F401 bootstrap_coordination_runtime_shadow, build_runtime_shadow_source_snapshot, + build_todo_runtime_shadow_projection, inspect_coordination_runtime_shadow, + load_task_lease_runtime_shadow_records, qualify_coordination_runtime_shadow, read_coordination_runtime_shadow_todo_candidate, resolve_coordination_runtime_shadow_config,