From b8cd8f5a827eee9ff5fdf55fba0647bce6340b67 Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 22 Sep 2026 10:56:34 +0800 Subject: [PATCH 01/11] fix: bind checkpoint recovery to its read context Signed-off-by: Tartar --- .../control_plane/heartbeat-prompt-smoke.py | 3 +- loopx/cli_commands/project_lifecycle.py | 1 + .../project_lifecycle_refresh_state.py | 35 ++++ .../control_plane/effect_runtime_handlers.ts | 2 + .../goals/checkpoint_read_context.py | 157 +++++++++++++++ .../goals/checkpoint_read_context.ts | 115 +++++++++++ loopx/control_plane/heartbeat/rules.py | 5 +- loopx/control_plane/quota/refresh_recovery.ts | 4 + loopx/control_plane/quota/settlement.py | 10 +- loopx/state_refresh.py | 18 ++ .../test_checkpoint_read_context.py | 180 ++++++++++++++++++ .../test_refresh_checkpoint_isolation.py | 4 + .../test_refresh_checkpoint_recovery.py | 12 +- .../checkpoint_read_context.test.ts | 104 ++++++++++ .../control_plane_ts/refresh_recovery.test.ts | 10 + tsconfig.control-plane.json | 2 + 16 files changed, 656 insertions(+), 6 deletions(-) create mode 100644 loopx/control_plane/goals/checkpoint_read_context.py create mode 100644 loopx/control_plane/goals/checkpoint_read_context.ts create mode 100644 tests/control_plane/test_checkpoint_read_context.py create mode 100644 tests/control_plane_ts/checkpoint_read_context.test.ts diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index 38b806c597..dd260dda10 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -86,7 +86,8 @@ def assert_sole_notification_authority(task_body: str, *, mode: str) -> None: body = normalized(task_body) assert "no-change=surface_only/no spend" in body, mode assert "material=outcome+vision" in body, mode - assert "缺则同轮按返回命令补齐再terminal" in body, mode + assert "缺则同轮checkpoint-context重判" in body, mode + assert "按凭据仅补vision;过期重读" in body, mode assert "unchanged→真实--vision-unchanged-reason" in body, mode if mode == "full": diff --git a/loopx/cli_commands/project_lifecycle.py b/loopx/cli_commands/project_lifecycle.py index e54166e8c9..a80bde8716 100644 --- a/loopx/cli_commands/project_lifecycle.py +++ b/loopx/cli_commands/project_lifecycle.py @@ -42,6 +42,7 @@ PROJECT_LIFECYCLE_COMMANDS = { "refresh-state", + "checkpoint-context", "read-only-map", "reward", "operator-gate", diff --git a/loopx/cli_commands/project_lifecycle_refresh_state.py b/loopx/cli_commands/project_lifecycle_refresh_state.py index 55210a1fc4..b47308623c 100644 --- a/loopx/cli_commands/project_lifecycle_refresh_state.py +++ b/loopx/cli_commands/project_lifecycle_refresh_state.py @@ -75,6 +75,19 @@ def register_refresh_state_command( subparsers: argparse._SubParsersAction, add_subcommand_format: Callable[[argparse.ArgumentParser], None], ) -> None: + context_parser = subparsers.add_parser( + "checkpoint-context", help="Read a fresh decision basis for an existing Turn's missing checkpoint.", + ) + add_subcommand_format(context_parser) + for option in ("goal-id", "agent-id", "turn-instance-id"): + context_parser.add_argument(f"--{option}", required=True) + binding = context_parser.add_mutually_exclusive_group(required=True) + binding.add_argument("--todo-id") + binding.add_argument("--replan-obligation-id") + context_parser.add_argument("--project") + context_parser.add_argument("--state-file") + context_parser.add_argument("--dependency-todo-id", action="append", default=[], + help="Additional upstream Todo result used in the judgment; declared dependencies are included automatically.") refresh_state_parser = subparsers.add_parser( "refresh-state", help="Append a read-only run from active goal state after state-only updates.", @@ -280,6 +293,10 @@ def register_refresh_state_command( "Compact reason why a required vision checkpoint is intentionally unchanged." ), ) + refresh_state_parser.add_argument( + "--checkpoint-read-context", metavar="READ_CONTEXT_ID", + help="Echo the fresh checkpoint-context receipt when supplementing a missing checkpoint on the original Turn.", + ) refresh_state_parser.add_argument( "--agent-id", help=( @@ -369,6 +386,23 @@ def handle_refresh_state_command( post_writeback_hooks: Sequence[PostWritebackHookRegistration] | None = None, post_writeback_projection_builder: PostWritebackProjectionBuilder | None = None, ) -> int | None: + if args.command == "checkpoint-context": + from ..control_plane.goals.checkpoint_read_context import read_checkpoint_context, render_checkpoint_context + try: + payload = read_checkpoint_context( + registry_path=registry_path, runtime_root_override=args.runtime_root, + goal_id=args.goal_id, agent_id=args.agent_id, todo_id=args.todo_id, + turn_instance_id=args.turn_instance_id, replan_obligation_id=args.replan_obligation_id, + project=Path(args.project).expanduser() if args.project else None, + state_file=Path(args.state_file).expanduser() if args.state_file else None, + dependency_todo_ids=args.dependency_todo_id, + ) + except Exception as exc: + payload = {"ok": False, "error": str(exc), + **({"error_code": exc.code, **getattr(exc, "payload", {})} + if isinstance(getattr(exc, "code", None), str) else {})} + print_payload(payload, output_format(args), render_checkpoint_context) + return 0 if payload.get("ok") else 1 if args.command != "refresh-state": return None fmt = output_format(args) @@ -461,6 +495,7 @@ def handle_refresh_state_command( agent_vision_packet=agent_vision_packet, merge_agent_vision_patch=merge_agent_vision_patch, vision_unchanged_reason=args.vision_unchanged_reason, + checkpoint_read_context_id=getattr(args, "checkpoint_read_context", None), progress_observation=progress_observation, usage_measurement=usage_measurement, usage_codex_session=( diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 2c16d92cf2..d312a05013 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -105,6 +105,7 @@ import { writeSchedulerState, } from "./scheduler/state_store.ts"; import { buildVisionCheckpoint } from "./goals/vision_checkpoint.ts"; +import {evaluateCheckpointReadContext} from "./goals/checkpoint_read_context.ts"; import { projectVisionWaitCoverage } from "./goals/vision_wait_coverage.ts"; import { admitGoalAmendmentProposal } from "./goals/goal_amendment_proposal.ts"; import { projectSharedGoalAlignment } from "./goals/shared_goal_alignment.ts"; @@ -486,6 +487,7 @@ export function createEffectRuntimeHandlers( ["work_item.delivery_response.project", projectDeliveryResponse], ["work_item.delivery_claim.validate", validateDeliveryClaim], ["goal.vision_checkpoint.evaluate", buildVisionCheckpoint], + ["goal.checkpoint_read_context.evaluate", evaluateCheckpointReadContext], ["goal.vision_wait.coverage", projectVisionWaitCoverage], ["goal.shared_goal_alignment.project", projectSharedGoalAlignment], ["goal.operator_actions.project", projectGoalOperatorActions], diff --git a/loopx/control_plane/goals/checkpoint_read_context.py b/loopx/control_plane/goals/checkpoint_read_context.py new file mode 100644 index 0000000000..06d6fe08f3 --- /dev/null +++ b/loopx/control_plane/goals/checkpoint_read_context.py @@ -0,0 +1,157 @@ +"""Source/receipt I/O for the TypeScript-owned checkpoint read-basis contract.""" +from __future__ import annotations + +from contextlib import ExitStack, contextmanager +import hashlib +import json +from pathlib import Path +from typing import Any, Iterator +from uuid import uuid4 + +from ...file_lock import exclusive_cross_runtime_file_lock, exclusive_file_lock +from ...history import load_index, load_registry +from ...paths import resolve_runtime_root +from ...registry import atomic_write_json +from ...runtime import validate_goal_id_path_segment +from ..coordination.legacy_writer_fence import legacy_coordination_todo_lock_path +from ..coordination.local_authority import read_canonical_todos_if_promoted +from ..coordination.shadow_management import shadow_maintenance_lock_target, require_shadow_primary_write_allowed +from ..effect_runtime import effect_runtime_result +from ..quota.settlement import SettlementIdentity, read_heartbeat_settlement +from ..todos.active_state_todo_parser import parse_todo_source +from ..todos.machine_region import find_todo_source_regions +from .acceptance import inspect_goal_acceptance +from .active_state_metadata import split_state_frontmatter +from .goal_frontier import latest_agent_vision_from_runs + + +class CheckpointReadContextRejected(ValueError): + def __init__(self, result: dict[str, Any]) -> None: + super().__init__(result["error"]) + self.code = result["error_code"] + self.payload = {"checkpoint_read_context": result} + + +def _evaluate(**request: Any) -> dict[str, Any]: + result = effect_runtime_result("goal.checkpoint_read_context.evaluate", request) + if not isinstance(result, dict) or not isinstance(result.get("ok"), bool): + raise RuntimeError("invalid typed checkpoint read context result") + if not result["ok"]: + raise CheckpointReadContextRejected(result) + return result + + +def _receipt_path(root: Path, identity: SettlementIdentity) -> Path: + digest = hashlib.sha256(identity.effect_id.encode()).hexdigest() + return root / "goals" / identity.goal_id / "checkpoint-contexts" / f"{digest}.json" + + +@contextmanager +def _source_guard(root: Path, goal_id: str, state_file: Path) -> Iterator[None]: + """Caller holds runs/index first. Match promotion's M -> Todo -> state order. + + Canonical local writers hold M; legacy Todo writers hold Todo/state; prose + writers hold state. Hold all three until the checkpoint index row is appended. + Do not run projection sync or a new state mutation inside this guard. + """ + with ExitStack() as locks: + for target in ( + shadow_maintenance_lock_target(root, goal_id), + legacy_coordination_todo_lock_path(runtime_root=root, goal_id=goal_id), + state_file, + ): + locks.enter_context(exclusive_cross_runtime_file_lock(target, operation="checkpoint-read-context")) + require_shadow_primary_write_allowed(root, goal_id) + yield + + +def _source_facts( + root: Path, registry_path: Path, state_file: Path, identity: SettlementIdentity, +) -> dict[str, Any]: + # The existing provider adapter fails closed after cutover. Never repair or + # fall back to stale Markdown when a selected provider cannot answer. + canonical = read_canonical_todos_if_promoted(runtime_root=root, goal_id=identity.goal_id) + text = state_file.read_text(encoding="utf-8") + metadata, body = split_state_frontmatter(text) + lines = body.splitlines() + regions = find_todo_source_regions(lines) + owned = {i for region in regions for i in range(region.start, region.end)} + prose = "\n".join(line for i, line in enumerate(lines) if i not in owned).strip() + acceptance = None + if canonical is None: + active, archived, _ = parse_todo_source(text) + todos = [*active["user"], *active["agent"], *archived] + else: + todos = canonical["todos"] + inspected = inspect_goal_acceptance( + registry_path=registry_path, runtime_root=str(root), goal_id=identity.goal_id, + agent_id=identity.agent_id, + ) + # Exclude verifier observations and unrelated provider commits; retain + # the owner revision and the complete actual acceptance document. + acceptance = {key: inspected.get(key) for key in ("revision", "contract_digest", "contract")} + runs, _ = load_index(root / "goals" / identity.goal_id / "runs" / "index.jsonl") + newest = [run for _, run in sorted(enumerate(runs), + key=lambda pair: (str(pair[1].get("generated_at") or ""), pair[0]), reverse=True)] + return { + "todos": todos, "frontmatter": metadata, "goal_prose": prose, "acceptance": acceptance, + "agent_vision": latest_agent_vision_from_runs(newest, goal_id=identity.goal_id, agent_id=identity.agent_id), + "source": {"state_file": str(state_file.resolve()), "runtime_root": str(root.resolve()), + "authority": canonical["source_authority"] if canonical else "legacy_markdown"}, + } + + +def read_checkpoint_context( + *, registry_path: Path, runtime_root_override: str | None, goal_id: str, + agent_id: str, todo_id: str | None, turn_instance_id: str, + replan_obligation_id: str | None = None, project: Path | None = None, + state_file: Path | None = None, dependency_todo_ids: list[str] | None = None, +) -> dict[str, Any]: + # Local import avoids a cycle with refresh-state's persistence adapter. + from ...state_refresh import resolve_goal_state, registered_agents_for_goal + + goal_id = validate_goal_id_path_segment(goal_id) + registry = load_registry(registry_path) + root = resolve_runtime_root(registry, runtime_root_override, registry_path=registry_path) + goal, _, path = resolve_goal_state(registry=registry, goal_id=goal_id, + project_override=project, state_file_override=state_file) + if agent_id not in registered_agents_for_goal(goal): + raise ValueError("checkpoint-context requires a registered Agent") + with exclusive_file_lock(root / "goals" / goal_id / "runs" / "index.jsonl", operation="checkpoint-context"): + readback = read_heartbeat_settlement(root, goal_id=goal_id, agent_id=agent_id, + todo_id=todo_id, turn_instance_id=turn_instance_id, replan_obligation_id=replan_obligation_id) + if readback is None or readback.identity.value is None or readback.writeback_run is None: + raise ValueError("checkpoint-context requires the original committed Turn writeback") + identity = readback.identity.value + with _source_guard(root, goal_id, path): + result = _evaluate(phase="read", identity=identity.as_dict(), prior=readback.writeback_run, + read_context_id=uuid4().hex, dependency_todo_ids=dependency_todo_ids or [], + facts=_source_facts(root, registry_path, path, identity)) + receipt = result.pop("receipt") + atomic_write_json(_receipt_path(root, identity), receipt) + return {**result, "read_context_id": receipt["read_context_id"], "settlement_identity": identity.as_dict(), + "instructions": "Read this basis and judge the direction again. Echo read_context_id as " + "--checkpoint-read-context in the checkpoint-only refresh for this exact Turn. " + "A new checkpoint-context read replaces this receipt; do not run parallel confirmations " + "for the same Turn. On stale/replaced context, reread and rejudge; do not repeat task mutations or spend."} + + +@contextmanager +def checkpoint_commit_guard( + *, runtime_root: Path, registry_path: Path, state_file: Path, + identity: SettlementIdentity, read_context_id: str | None, +) -> Iterator[dict[str, Any]]: + """Compare and append under the same source locks, never check then unlock.""" + with _source_guard(runtime_root, identity.goal_id, state_file): + try: + receipt = json.loads(_receipt_path(runtime_root, identity).read_text(encoding="utf-8")) + except FileNotFoundError: + receipt = None + result = _evaluate(phase="check", identity=identity.as_dict(), read_context_id=read_context_id, + receipt=receipt, facts=_source_facts(runtime_root, registry_path, state_file, identity)) + yield result + + +def render_checkpoint_context(payload: dict[str, Any]) -> str: + # The decision basis is private local state, not a public/global projection. + return "# LoopX Checkpoint Context\n\n```json\n" + json.dumps(payload, ensure_ascii=False, indent=2) + "\n```" diff --git a/loopx/control_plane/goals/checkpoint_read_context.ts b/loopx/control_plane/goals/checkpoint_read_context.ts new file mode 100644 index 0000000000..95c2ee5819 --- /dev/null +++ b/loopx/control_plane/goals/checkpoint_read_context.ts @@ -0,0 +1,115 @@ +/** Read basis for a missing-checkpoint supplement, not an execution/permission lease. + * The host holds the Goal source writer locks through the checkpoint append. */ +import type {JsonObject} from "../effect_program.ts"; +import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; + +const RECEIPT_SCHEMA = "checkpoint_read_context_v0"; +// Exact presentation fields only. Unknown future fields remain part of the basis. +const DISPLAY_FIELDS = new Set(["index", "source_section", "schema_version"]); +const todoFacts = (todo: JsonObject): JsonObject => Object.fromEntries( + Object.entries(todo).filter(([key]) => !DISPLAY_FIELDS.has(key)), +); + +function ids(value: unknown, label: string): string[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) throw new EffectRuntimeRequestError(`${label} must be an array`); + return [...new Set(value.map(item => requireNonEmptyString(item, label)))].sort(); +} + +function dependencies(todo: JsonObject): string[] { + const result = ids(todo.depends_on_todo_ids, "depends_on_todo_ids"); + if (todo.depends_on_todo_id != null) result.push(requireNonEmptyString(todo.depends_on_todo_id, "depends_on_todo_id")); + // These are typed resume tokens, never a search through task prose. + if (typeof todo.resume_when === "string") { + const match = /^(?:todo_done|monitor_changed):([^:]+)$/.exec(todo.resume_when); + if (match) result.push(match[1]); + } + return [...new Set(result)].sort(); +} + +function snapshot(request: JsonObject): JsonObject { + const identity = requireJsonObject(request.identity, "identity"); + const facts = requireJsonObject(request.facts, "facts"); + if (!Array.isArray(facts.todos)) throw new EffectRuntimeRequestError("todos must be complete source records"); + const records = facts.todos.map(value => todoFacts(requireJsonObject(value, "todo"))); + const byId = new Map(); + for (const todo of records) { + // Anonymous legacy rows cannot be dependencies; retain them in an obligation frontier. + if (todo.todo_id == null) continue; + const id = requireNonEmptyString(todo.todo_id, "todo_id"); + if (byId.has(id)) throw new EffectRuntimeRequestError("checkpoint basis has duplicate Todo identities"); + byId.set(id, todo); + } + const todoId = identity.todo_id; + const task = typeof todoId === "string" ? byId.get(todoId) : null; + if (typeof todoId === "string" && !task) throw new EffectRuntimeRequestError("checkpoint Todo is absent; restore its authoritative record before rereading"); + const selected = new Map(); + const pending = [...ids(request.dependency_todo_ids, "dependency_todo_ids"), ...(task ? dependencies(task) : [])]; + while (pending.length) { + const id = pending.shift()!; + if (selected.has(id) || id === todoId) continue; + const todo = byId.get(id); + if (!todo) throw new EffectRuntimeRequestError(`checkpoint dependency ${id} is absent`); + selected.set(id, todo); + pending.push(...dependencies(todo)); + } + const metadata = requireJsonObject(facts.frontmatter, "frontmatter"); + const goal = { + frontmatter: Object.fromEntries(Object.entries(metadata).filter(([key]) => key !== "updated_at")), + prose: facts.goal_prose, + acceptance: facts.acceptance, + user_todos: records.filter(todo => todo.role === "user"), + }; + const basis: JsonObject = { + todo: task ?? records, + goal, + dependencies: [...selected.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, todo]) => todo), + agent_vision: facts.agent_vision, + source: facts.source, + }; + return {basis, versions: Object.fromEntries(Object.entries(basis).map(([key, value]) => + [key, canonicalAuthoritySha256(value)]))}; +} + +const rejected = (code: string, changed: string[] = []): JsonObject => ({ + ok: false, error_code: code, reread_required: true, changed_components: changed, + error: `${code}: run checkpoint-context for the same Goal/Agent/Todo or obligation/Turn, ` + + "read the returned state and judge again; submit its --checkpoint-read-context with only the vision decision. " + + "Do not repeat implementation, state mutations, or quota spend.", +}); + +export function evaluateCheckpointReadContext(value: unknown): JsonObject { + const request = requireJsonObject(value, "checkpoint read context"); + const identity = requireJsonObject(request.identity, "identity"); + const token = request.read_context_id; + if (request.phase === "read") { + const prior = requireJsonObject(request.prior, "committed writeback"); + const checkpoint = jsonObject(prior.vision_checkpoint); + if (checkpoint?.decision !== "missing_required" || checkpoint.satisfied !== false) { + return rejected("checkpoint_context_not_missing"); + } + const projected = snapshot(request); + return {ok: true, ...projected, receipt: { + schema_version: RECEIPT_SCHEMA, read_context_id: requireNonEmptyString(token, "read_context_id"), + identity, dependency_todo_ids: ids(request.dependency_todo_ids, "dependency_todo_ids"), + versions: projected.versions, + }}; + } + if (request.phase !== "check") throw new EffectRuntimeRequestError("unknown checkpoint read context phase"); + if (typeof token !== "string" || !token.trim()) return rejected("checkpoint_read_context_required"); + const receipt = jsonObject(request.receipt); + if (receipt?.schema_version !== RECEIPT_SCHEMA || receipt.read_context_id !== token) { + return rejected("checkpoint_read_context_unknown_or_replaced"); + } + if (canonicalAuthoritySha256(receipt.identity) !== canonicalAuthoritySha256(identity)) { + return rejected("checkpoint_read_context_identity_mismatch"); + } + const projected = snapshot({...request, dependency_todo_ids: receipt.dependency_todo_ids}); + const expected = requireJsonObject(receipt.versions, "receipt versions"); + const current = requireJsonObject(projected.versions, "current versions"); + const changed = Object.keys(current).filter(key => current[key] !== expected[key]); + if (changed.length) return rejected("checkpoint_read_context_stale", changed); + return {ok: true, read_context_id: token, versions: current}; +} diff --git a/loopx/control_plane/heartbeat/rules.py b/loopx/control_plane/heartbeat/rules.py index 3332f722ab..3ba3b091c2 100644 --- a/loopx/control_plane/heartbeat/rules.py +++ b/loopx/control_plane/heartbeat/rules.py @@ -22,9 +22,10 @@ "具体user todo未投影,需修复LoopX状态投影;静默时内部修复。" ) HEARTBEAT_VISION_WRITEBACK_RULE_SHORT = ( - "writeback: 本轮精确monitor-poll提交→不refresh/spend;" + "本轮精确monitor-poll提交→不refresh/spend;" "其余no-change=surface_only/no spend;material=outcome+vision;" - "缺则同轮按返回命令补齐再terminal;unchanged→真实--vision-unchanged-reason。" + "缺则同轮checkpoint-context重判,按凭据仅补vision;" + "过期重读;unchanged→真实--vision-unchanged-reason。" ) REWARD_MEMORY_OUTCOME_RULE = ( "`reward_memory_recall.experiment.automatic_ingest=true`: reusable Todo outcomes " diff --git a/loopx/control_plane/quota/refresh_recovery.ts b/loopx/control_plane/quota/refresh_recovery.ts index f80e0b750e..b78e6f27b1 100644 --- a/loopx/control_plane/quota/refresh_recovery.ts +++ b/loopx/control_plane/quota/refresh_recovery.ts @@ -8,6 +8,7 @@ import { normalizeDeliveryWorkspaceSnapshot } from "../agents/delivery_workspace import { decodeExternalDelivery, type ExternalDeliveryRequest } from "./refresh_external_delivery.ts"; export interface RefreshRetryRequest { + checkpoint_read_context_id?: string | null; external_delivery?: ExternalDeliveryRequest | null; vision: JsonObject | null; unchanged_reason: string | null; @@ -37,6 +38,7 @@ export function decodeRefreshRetry(value: unknown): RefreshRetryRequest | null { return value; }; return { + checkpoint_read_context_id: input.checkpoint_read_context_id == null ? null : nullableString("checkpoint_read_context_id"), external_delivery: decodeExternalDelivery(input.external_delivery), vision: input.vision === null ? null : requireJsonObject(input.vision, "refresh_retry.vision"), unchanged_reason: nullableString("unchanged_reason"), @@ -77,6 +79,7 @@ export function refreshRecovery( vision: request.vision, unchanged_reason: request.unchanged_reason, merge_patch: request.merge_patch, + ...(request.checkpoint_read_context_id ? {checkpoint_read_context_id: request.checkpoint_read_context_id} : {}), })).digest("hex") : null; const mutationDigest = createHash("sha256").update(canonical(request.mutation)).digest("hex"); const changesMutation = Object.values(request.mutation).some((value) => @@ -85,6 +88,7 @@ export function refreshRecovery( schema_version: "refresh_recovery_v0", decision, reason, vision_request_digest: digest, mutation_digest: mutationDigest, + ...(request.checkpoint_read_context_id ? {checkpoint_read_context_id: request.checkpoint_read_context_id} : {}), original_generated_at: jsonObject(prior?.refresh_recovery)?.original_generated_at ?? prior?.generated_at ?? null, }); diff --git a/loopx/control_plane/quota/settlement.py b/loopx/control_plane/quota/settlement.py index 7f9e67e024..b412095b65 100644 --- a/loopx/control_plane/quota/settlement.py +++ b/loopx/control_plane/quota/settlement.py @@ -42,6 +42,14 @@ def _checkpoint_instructions(checkpoint: Mapping[str, Any]) -> str: lines = [ "Submit a checkpoint-only refresh with the same Goal, Agent, Todo/obligation, " "Turn, and delivery fields, from the original working directory.", + "- Read first: Run `loopx checkpoint-context` with the same `--goal-id`, " + "`--agent-id`, `--todo-id` or `--replan-obligation-id`, `--turn-instance-id`, " + "and original registry/runtime/project/state-file options. Include " + "`--dependency-todo-id` for any additional upstream Todo result used in the " + "judgment. Read its returned basis and judge again; echo `read_context_id` " + "as `--checkpoint-read-context` in the supplement. Confirmations for the " + "same Turn are serial: a reread replaces the old receipt. If stale or " + "replaced, reread and rejudge; never substitute a new token onto an old judgment.", "- Preserve: Keep original values and presence for target, scope, and isolation " "options: `--registry`, `--runtime-root`, `--project`, `--state-file`, " "`--progress-scope`, `--agent-lane`, `--available-capability`, " @@ -59,7 +67,7 @@ def _checkpoint_instructions(checkpoint: Mapping[str, Any]) -> str: "values are unchanged: `--next-action`, `--autonomous-replan-recorded`, " "`--repair-delta-kind`, `--usage-json`, `--usage-codex-session`. " "Remove dependent options that become invalid without them.", - "- Add: Add only one valid vision decision: a valid `--agent-vision-json` packet " + "- Add: Echo `--checkpoint-read-context` from the read, and add only one valid vision decision: a valid `--agent-vision-json` packet " "or inline `--vision-*` patch containing your authored vision content.", ] if checkpoint.get("missing_baseline") is True: diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index 273b0cfb2d..40a0abee3a 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -92,6 +92,7 @@ prepare_vision_refresh, ) from .control_plane.goals.goal_frontier import latest_agent_vision_from_runs +from .control_plane.goals.checkpoint_read_context import checkpoint_commit_guard from .registry import registry_goals, resolve_state_file from .runtime import validate_goal_id_path_segment from .state_projection import ( @@ -796,6 +797,7 @@ def refresh_state_run( agent_vision_packet: dict[str, Any] | None = None, merge_agent_vision_patch: bool = False, vision_unchanged_reason: str | None = None, + checkpoint_read_context_id: str | None = None, progress_observation: dict[str, Any] | None = None, completion_todo_id: str | None = None, completion_turn_key: str | None = None, @@ -806,6 +808,8 @@ def refresh_state_run( external_delivery: dict[str, Any] | None = None, ) -> dict[str, Any]: safe_goal_id = validate_goal_id_path_segment(goal_id) + if checkpoint_read_context_id and not turn_instance_id: + raise ValueError("--checkpoint-read-context requires the original Turn identity") validate_public_safe_text("classification", classification) if usage_measurement is not None and usage_codex_session is not None: raise ValueError("--usage-json cannot be combined with --usage-codex-session") @@ -900,6 +904,7 @@ def refresh_state_run( turn_instance_id=turn_instance_id, replan_obligation_id=normalized_replan_obligation_id, refresh_retry={ + "checkpoint_read_context_id": checkpoint_read_context_id, "external_delivery": external_delivery, "vision": agent_vision_packet, "unchanged_reason": vision_unchanged_reason, @@ -942,6 +947,8 @@ def refresh_state_run( ) if recovery_payload is not None: return recovery_payload + if checkpoint_read_context_id and not checkpoint_supplement: + raise ValueError("--checkpoint-read-context applies only to a missing-checkpoint supplement") settlement_workspace_requirement = resolve_settlement_workspace_requirement( delivery_workspace_causality, settlement_binding_kind=settlement_identity.binding_kind.value ) @@ -1334,6 +1341,17 @@ def refresh_state_run( # spans ledger-basis read + row append so concurrent refreshes cannot fund # two deltas from one stale basis; the appended row advances the basis. with ExitStack() as usage_booking_guard: + if checkpoint_supplement: + assert settlement_identity is not None + context = usage_booking_guard.enter_context(checkpoint_commit_guard( + runtime_root=runtime_root, registry_path=registry_path, + state_file=resolved_state_file, identity=settlement_identity, + read_context_id=checkpoint_read_context_id, + )) + for projection in (record, index_record, payload): + projection["vision_checkpoint"] = { + **projection["vision_checkpoint"], "read_context": context, + } if usage_codex_session is not None: if not dry_run: runs_dir.mkdir(parents=True, exist_ok=True) diff --git a/tests/control_plane/test_checkpoint_read_context.py b/tests/control_plane/test_checkpoint_read_context.py new file mode 100644 index 0000000000..8b38b77ae5 --- /dev/null +++ b/tests/control_plane/test_checkpoint_read_context.py @@ -0,0 +1,180 @@ +"""Real local CLI: reread only the decision after a relevant source changes.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from tests.control_plane.test_quota_settlement_cli import ( + AGENT_ID, GOAL_ID, TODO_ID, TURN_ID, REPO_ROOT, _run_cli, _write_fixture, _spend_run_count, +) + + +def _missing(root: Path): + project, runtime, registry = _write_fixture(root) + binding = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID) + for args in ( + ("refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--vision-summary", "Validate the scoped change.", "--vision-acceptance", "Focused checks pass.", + "--no-global-sync", "--suppress-external-sinks"), + ("quota", "should-run", "--codex-app", *binding, "--scan-path", str(project)), + ): + rc, result = _run_cli(registry, runtime, *args, cwd=project) + assert rc == 0, result + delivery = ("refresh-state", *binding, "--classification", "validated_change", + "--delivery-batch-scale", "implementation", "--delivery-outcome", "outcome_progress", + "--no-global-sync", "--suppress-external-sinks") + rc, original = _run_cli(registry, runtime, *delivery, cwd=project) + assert rc == 0 and original["vision_checkpoint"]["decision"] == "missing_required", original + return project, runtime, registry, binding, delivery, original + + +def test_missing_replaced_stale_context_requires_reread_and_preserves_delivery(tmp_path): + project, runtime, registry, binding, delivery, original = _missing(tmp_path) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + index = runtime / f"goals/{GOAL_ID}/runs/index.jsonl" + before = index.read_bytes() + original_bytes = Path(original["json_path"]).read_bytes() + vision = ("--vision-unchanged-reason", "The current scope and acceptance still apply.") + rc, missing = _run_cli(registry, runtime, *delivery, *vision, cwd=project) + assert rc == 1 and missing["error_code"] == "checkpoint_read_context_required", missing + rc, a = _run_cli(registry, runtime, "checkpoint-context", *binding, cwd=project) + assert rc == 0, a + rc, b = _run_cli(registry, runtime, "checkpoint-context", *binding, cwd=project) + assert rc == 0 and b["read_context_id"] != a["read_context_id"], b + rc, replaced = _run_cli(registry, runtime, *delivery, *vision, + "--checkpoint-read-context", a["read_context_id"], cwd=project) + assert rc == 1 and replaced["error_code"] == "checkpoint_read_context_unknown_or_replaced", replaced + + # A synthetic source edit represents a peer's completed update. The separate + # concurrency test proves the cooperating writer cannot cross the commit guard. + state.write_text(state.read_text(encoding="utf-8").replace( + "Validate and settle the selected delivery.", "Validate the updated delivery scope."), encoding="utf-8") + rc, stale = _run_cli(registry, runtime, *delivery, *vision, + "--checkpoint-read-context", b["read_context_id"], cwd=project) + assert rc == 1 and stale["error_code"] == "checkpoint_read_context_stale", stale + assert "todo" in stale["checkpoint_read_context"]["changed_components"] + assert index.read_bytes() == before + assert Path(original["json_path"]).read_bytes() == original_bytes + assert _spend_run_count(runtime) == 0 + + rc, fresh = _run_cli(registry, runtime, "checkpoint-context", *binding, cwd=project) + assert rc == 0 and "updated delivery scope" in fresh["basis"]["todo"]["text"], fresh + supplement = (*delivery, *vision, "--checkpoint-read-context", fresh["read_context_id"]) + rc, result = _run_cli(registry, runtime, *supplement, cwd=project) + assert rc == 0 and result["appended"] and result["vision_checkpoint"]["satisfied"], result + assert result["vision_checkpoint"]["read_context"]["read_context_id"] == fresh["read_context_id"] + after = index.read_bytes() + state.write_text(state.read_text(encoding="utf-8") + "\n## Acceptance\n\nNew acceptance after commit.\n", encoding="utf-8") + rc, replay = _run_cli(registry, runtime, *supplement, cwd=project) + assert rc == 0 and replay["idempotent_replay"] and not replay["appended"], replay + assert index.read_bytes() == after + assert Path(original["json_path"]).read_bytes() == original_bytes + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_context_reads_real_canonical_todo_and_owner_acceptance(tmp_path, monkeypatch, provider): + from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + from loopx.control_plane.coordination.local_authority_shadow_projection import canonical_bytes + from loopx.control_plane.goals.checkpoint_read_context import _source_facts, _source_guard + from loopx.control_plane.quota.settlement import SettlementIdentity + import hashlib + + if provider == "sqlite": + isolate_sqlite_runtime(tmp_path, monkeypatch) + import tempfile + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + project, runtime, registry = _write_fixture(tmp_path) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + todo = {"schema_version": "todo_item_v0", "todo_id": TODO_ID, "index": 1, + "role": "agent", "status": "done", "done": True, "text": "Canonical delivered result", + "task_class": "advancement_task", "archive_state": "active", "source_section": "Agent Todo"} + projection = build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=[todo]) + document = {"objective": "Canonical owner objective", "non_goals": [], "bindings": [], + "criteria": [{"id": "renders", "description": "The page renders", "validation_argv": ["true"], + "validation_timeout_seconds": 5, "validation_files": []}]} + projection["goal_acceptance"] = {"schema_version": "loopx_goal_acceptance_v0", "enabled": True, + "revision": 1, "digest": hashlib.sha256(canonical_bytes(document)).hexdigest(), + "document": document, "bindings": [], "verification": None} + initialize_canonical_authority(runtime, GOAL_ID, projection, state_path=state, provider=provider) + identity = SettlementIdentity(GOAL_ID, AGENT_ID, TODO_ID, TURN_ID) + with _source_guard(runtime, GOAL_ID, state): + facts = _source_facts(runtime, registry, state, identity) + assert facts["todos"][0]["text"] == "Canonical delivered result" + assert facts["acceptance"]["contract"]["objective"] == "Canonical owner objective" + assert facts["source"]["authority"] == f"{provider}_v0" + + +def test_source_writers_remain_excluded_until_checkpoint_append(tmp_path, monkeypatch): + from loopx import state_refresh + from loopx.control_plane.coordination.shadow_management import shadow_maintenance_lock_target + from loopx.control_plane.goals.checkpoint_read_context import read_checkpoint_context + + project, runtime, registry, _, _, original = _missing(tmp_path) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + context = read_checkpoint_context(registry_path=registry, runtime_root_override=str(runtime), + goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID) + # Two independent processes exercise the same mutexes used by the actual + # canonical acceptance writer and legacy state writer, while refresh is at + # its real persistence seam (after validation and immediately before append). + script = """ +from pathlib import Path +import sys +from loopx.file_lock import exclusive_cross_runtime_file_lock, LockAcquireTimeoutError +try: + with exclusive_cross_runtime_file_lock(Path(sys.argv[1]), timeout_seconds=0): + print('acquired') +except LockAcquireTimeoutError: + print('held') +""" + + control = REPO_ROOT / "loopx/control_plane" + node_script = ( + f"import {{withFileMutationLock}} from {json.dumps((control / 'effect_runtime_io.ts').as_uri())};" + f"import {{EffectRuntimeLockTimeoutError}} from {json.dumps((control / 'effect_runtime_errors.ts').as_uri())};" + f"import {{shadowMaintenanceLockPath}} from {json.dumps((control / 'coordination/shadow_management.ts').as_uri())};" + "try {await withFileMutationLock(shadowMaintenanceLockPath(process.argv[1], process.argv[2])," + "async()=>process.stdout.write('acquired'),0);} catch(error) {" + "if(!(error instanceof EffectRuntimeLockTimeoutError))throw error;process.stdout.write('held');}" + ) + + def probe(target): + command = ([sys.executable, "-c", script, str(target)] if target == state else + ["node", "--no-warnings", "--experimental-strip-types", "--input-type=module", + "-e", node_script, str(runtime), GOAL_ID]) + result = subprocess.run(command, + capture_output=True, text=True, timeout=30, + env={**os.environ, "PYTHONPATH": str(REPO_ROOT)}) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + targets = (state, shadow_maintenance_lock_target(runtime, GOAL_ID)) + reserve = state_refresh.reserve_unique_run_paths + inspected = [] + + def before_append(*args, **kwargs): + for target in targets: + assert probe(target) == "held" + inspected.append(True) + return reserve(*args, **kwargs) + + monkeypatch.setattr(state_refresh, "reserve_unique_run_paths", before_append) + result = state_refresh.refresh_state_run(registry_path=registry, runtime_root_override=str(runtime), + goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID, + project=None, state_file=None, classification="validated_change", recommended_action=None, + delivery_batch_scale="implementation", delivery_outcome="outcome_progress", + vision_unchanged_reason="The current basis remains applicable.", + checkpoint_read_context_id=context["read_context_id"], dry_run=False, sync_global=False, + external_delivery={"suppress": True, "resume_key": None}) + assert result["appended"] and result["vision_checkpoint"]["satisfied"] + assert inspected == [True] + for target in targets: + assert probe(target) == "acquired" + assert result["settlement_identity"] == original["settlement_identity"] + assert _spend_run_count(runtime) == 0 diff --git a/tests/control_plane/test_refresh_checkpoint_isolation.py b/tests/control_plane/test_refresh_checkpoint_isolation.py index c8b9d12838..387add9f6c 100644 --- a/tests/control_plane/test_refresh_checkpoint_isolation.py +++ b/tests/control_plane/test_refresh_checkpoint_isolation.py @@ -181,6 +181,10 @@ def send_notification(**kwargs): if baseline else ["--vision-summary", "Validate the scoped change.", "--vision-acceptance", "Focused validation passes."] ) + assert "loopx checkpoint-context" in stdout + context = run([*prefix, "checkpoint-context", *binding, + "--project", str(project), "--state-file", str(state_path)]) + vision += ["--checkpoint-read-context", context["read_context_id"]] recovery = _recovery_argv(stdout, original, vision) # Independent oracle: recovery retains every fixture argument except this mutation. position = original.index("--next-action") diff --git a/tests/control_plane/test_refresh_checkpoint_recovery.py b/tests/control_plane/test_refresh_checkpoint_recovery.py index 094f02e355..fc344dafda 100644 --- a/tests/control_plane/test_refresh_checkpoint_recovery.py +++ b/tests/control_plane/test_refresh_checkpoint_recovery.py @@ -27,6 +27,8 @@ def _assert_checkpoint_instructions(rendered: str) -> None: + assert "checkpoint-context" in rendered + assert "--checkpoint-read-context" in rendered assert "same Goal, Agent, Todo/obligation, Turn, and delivery fields" in rendered assert "Remove previously executed state-mutation options" in rendered for option in ( @@ -129,7 +131,7 @@ def test_recovery_markdown_preserves_routing_and_error_precedence(decision): @pytest.mark.parametrize("decision", ["unchanged", "patch"]) -def test_same_turn_checkpoint_supplement_is_idempotent(tmp_path: Path, decision: str): +def test_same_turn_checkpoint_supplement_with_read_context_is_idempotent(tmp_path: Path, decision: str): project, runtime, registry = _write_fixture(tmp_path) rc, initial = _run_cli( registry, @@ -232,6 +234,9 @@ def test_same_turn_checkpoint_supplement_is_idempotent(tmp_path: Path, decision: assert Path(first["json_path"]).read_bytes() == original_bytes assert state_path.read_bytes() == original_state assert _spend_run_count(runtime) == 0 + rc, context = _run_cli(registry, runtime, "checkpoint-context", "--goal-id", GOAL_ID, *binding, cwd=project) + assert rc == 0, context + supplement += ("--checkpoint-read-context", context["read_context_id"]) rc, preview = _run_cli( registry, runtime, *args, *supplement, "--dry-run", cwd=tmp_path ) @@ -404,7 +409,10 @@ def test_checkpoint_only_recovery_bypasses_open_todo_completion_validation( assert rc == 1, wrong_identity assert "settlement binding does not match" in wrong_identity["error"] - rc, repaired = _run_cli(registry, runtime, *delivery, *vision, cwd=project) + rc, context = _run_cli(registry, runtime, "checkpoint-context", "--goal-id", GOAL_ID, *binding, cwd=project) + assert rc == 0, context + rc, repaired = _run_cli(registry, runtime, *delivery, *vision, + "--checkpoint-read-context", context["read_context_id"], cwd=project) assert rc == 0, repaired assert repaired["appended"] is True assert repaired["refresh_recovery"]["decision"] == "supplement_checkpoint" diff --git a/tests/control_plane_ts/checkpoint_read_context.test.ts b/tests/control_plane_ts/checkpoint_read_context.test.ts new file mode 100644 index 0000000000..9b8dd52d41 --- /dev/null +++ b/tests/control_plane_ts/checkpoint_read_context.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {evaluateCheckpointReadContext as evaluate} from "../../loopx/control_plane/goals/checkpoint_read_context.ts"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; + +const identity = {goal_id: "goal", agent_id: "agent", todo_id: "task", turn_instance_id: "turn", effect_id: "effect"}; +const facts = { + todos: [ + {todo_id: "task", status: "open", text: "Build a page", depends_on_todo_ids: ["upstream"]}, + {todo_id: "upstream", status: "done", evidence: "result:1", depends_on_todo_id: "ancestor"}, + {todo_id: "ancestor", status: "done", evidence: "basis:1"}, + {todo_id: "unrelated", status: "open"}, + ], + frontmatter: {objective: "A minimal page", updated_at: "old"}, goal_prose: "Acceptance: page renders", + acceptance: {revision: 1, contract: {criteria: ["page renders"]}}, + agent_vision: {summary: "Build the page"}, source: {authority: "legacy_markdown"}, +}; +const read = (overrides: JsonObject = {}) => evaluate({phase: "read", identity, facts, + prior: {vision_checkpoint: {decision: "missing_required", satisfied: false}}, + dependency_todo_ids: [], read_context_id: "read-A", ...overrides}); +const check = (receipt: unknown, overrides: JsonObject = {}) => evaluate({ + phase: "check", identity, facts, read_context_id: "read-A", receipt, ...overrides, +}); + +test("read captures the exact task, transitive upstream results and goal acceptance", () => { + const result = read(); + const basis = result.basis as JsonObject; + assert.equal(result.ok, true); + assert.deepEqual((basis.dependencies as JsonObject[]).map(row => row.todo_id), ["ancestor", "upstream"]); + assert.equal(check(result.receipt).ok, true); +}); + +test("each changed decision input rejects without permitting an append", () => { + const receipt = read().receipt; + for (const [component, mutate] of [ + ["todo", (value: typeof facts) => {value.todos[0].text = "Build a form";}], + ["todo", (value: typeof facts) => {value.todos[0].status = "done";}], + ["todo", (value: typeof facts) => {value.todos[0].depends_on_todo_ids = ["unrelated"];}], + ["dependencies", (value: typeof facts) => {value.todos[1].evidence = "result:2";}], + ["dependencies", (value: typeof facts) => {value.todos[2].evidence = "basis:2";}], + ["goal", (value: typeof facts) => {value.acceptance.revision++;}], + ["goal", (value: typeof facts) => {value.goal_prose = "Acceptance: include authentication";}], + ["agent_vision", (value: typeof facts) => {value.agent_vision.summary = "A different route";}], + ["source", (value: typeof facts) => {value.source.authority = "sqlite_v0";}], + ] as const) { + const current = structuredClone(facts); + mutate(current); + const result = check(receipt, {facts: current}); + assert.equal(result.error_code, "checkpoint_read_context_stale"); + assert.ok((result.changed_components as string[]).includes(component)); + assert.equal(result.reread_required, true); + } +}); + +test("an unrelated task and display/timestamp changes do not invalidate the basis", () => { + const current = structuredClone(facts); + current.todos[3].status = "done"; + current.frontmatter.updated_at = "new"; + Object.assign(current.todos[0], {index: 99, source_section: "Agent Todo"}); + assert.equal(check(read().receipt, {facts: current}).ok, true); +}); + +test("legacy User Todo requirements remain covered even without an assigned Todo id", () => { + const basis = {...facts, todos: [...facts.todos, {role: "user", text: "Owner requires a keyboard check"}]}; + const receipt = read({facts: basis}).receipt; + const current = {...basis, todos: [...facts.todos, {role: "user", text: "Owner requires a screen reader check"}]}; + const result = check(receipt, {facts: current}); + assert.equal(result.error_code, "checkpoint_read_context_stale"); + assert.deepEqual(result.changed_components, ["goal"]); +}); + +test("extra used results are captured; missing dependencies and duplicate identities fail closed", () => { + const receipt = read({dependency_todo_ids: ["unrelated"]}).receipt; + const current = structuredClone(facts); + current.todos[3].status = "done"; + assert.equal(check(receipt, {facts: current}).error_code, "checkpoint_read_context_stale"); + assert.throws(() => read({dependency_todo_ids: ["absent"]}), /absent/); + assert.throws(() => read({facts: {...facts, todos: [...facts.todos, facts.todos[0]]}}), /duplicate/); + assert.throws(() => read({facts: {...facts, todos: facts.todos.slice(1)}}), /Todo is absent/); +}); + +test("parallel reads cannot relabel an old operation as the latest read", () => { + const a = read().receipt; + const b = read({read_context_id: "read-B"}).receipt; + assert.equal(check(a).ok, true); + assert.equal(check(b).error_code, "checkpoint_read_context_unknown_or_replaced"); + assert.equal(check(b, {read_context_id: "read-B"}).ok, true); + assert.equal(check(a, {read_context_id: null}).error_code, "checkpoint_read_context_required"); + assert.equal(check(null).error_code, "checkpoint_read_context_unknown_or_replaced"); + for (const field of ["goal_id", "agent_id", "todo_id", "turn_instance_id", "effect_id"]) { + assert.equal(check(a, {identity: {...identity, [field]: "different"}}).error_code, + "checkpoint_read_context_identity_mismatch"); + } +}); + +test("obligations cover the full frontier and a completed checkpoint cannot acquire another receipt", () => { + const scope = {...identity, todo_id: null, replan_obligation_id: "obligation"}; + const receipt = read({identity: scope}).receipt; + const current = structuredClone(facts); + current.todos[3].status = "done"; + assert.equal(check(receipt, {identity: scope, facts: current}).error_code, "checkpoint_read_context_stale"); + assert.equal(read({prior: {vision_checkpoint: {decision: "patched", satisfied: true}}}).error_code, + "checkpoint_context_not_missing"); +}); diff --git a/tests/control_plane_ts/refresh_recovery.test.ts b/tests/control_plane_ts/refresh_recovery.test.ts index 79df0f1df0..9d90e381a6 100644 --- a/tests/control_plane_ts/refresh_recovery.test.ts +++ b/tests/control_plane_ts/refresh_recovery.test.ts @@ -41,6 +41,16 @@ test("digest uses JSON structure, not property insertion order", () => { assert.equal(first.vision_request_digest, second.vision_request_digest); }); +test("checkpoint replay is bound to the read receipt used for the committed judgment", () => { + const supplement = {...request, unchanged_reason: "Still applicable", checkpoint_read_context_id: "read-A"}; + const admitted = refreshRecovery(supplement, prior, true, "unknown", false); + const complete = {...prior, refresh_recovery: admitted, + vision_checkpoint: {...prior.vision_checkpoint, decision: "unchanged_with_reason", satisfied: true}}; + assert.equal(refreshRecovery(supplement, complete, true, "unknown", true).decision, "replay"); + assert.equal(refreshRecovery({...supplement, checkpoint_read_context_id: "read-B"}, complete, true, "unknown", false).decision, "reject"); + assert.equal(refreshRecovery({...supplement, checkpoint_read_context_id: null}, complete, true, "unknown", false).decision, "reject"); +}); + test("workspace supplements preserve the monitor compatibility boundary", () => { const monitor = { ...prior, classification: "quota_monitor_poll", material_change: true, vision_checkpoint: null, delivery_batch_scale: null }; diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index b0acc3d1b2..fce56f50ba 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -141,6 +141,8 @@ "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", + "tests/control_plane_ts/checkpoint_read_context.test.ts", + "tests/control_plane_ts/refresh_recovery.test.ts", "tests/control_plane_ts/vision_wait_coverage.test.ts", "tests/control_plane_ts/shared_goal_alignment.test.ts", "tests/control_plane_ts/goal_amendment_proposal.test.ts" From aca4cdd09c9c23ce76951cfa226552fc3f66c163 Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 22 Sep 2026 10:56:49 +0800 Subject: [PATCH 02/11] docs: explain checkpoint read receipts and freshness limits Signed-off-by: Tartar --- .../goal-vision-replan-contract-v0.md | 58 ++++++++++++++++++- docs/state-interaction-model.md | 16 ++++- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 5baeb1d85a..a8e3e10354 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -265,11 +265,67 @@ Valid checkpoint decisions are: A material closeout should carry its own vision patch or evidence-backed unchanged reason. If omitted, `refresh-state` still records the outcome and returns the checkpoint repair action. Follow that action in the same turn with the original -settlement identity, removing already executed state mutations. The supplement +settlement identity: first read `checkpoint-context`, then echo its +`read_context_id` as `--checkpoint-read-context` with a newly judged vision +decision, removing already executed state mutations. The supplement must satisfy the checkpoint before terminal closeout; it neither re-authors the outcome nor spends a second time. Never invent an unchanged reason to clear a gap. Typed in-flight continuations keep their existing exemption. +### Read basis for checkpoint-only recovery + +Missing-checkpoint supplementation now requires an explicit read receipt. This is +a default admission change for both legacy and newly committed Turn writebacks; +normal first writebacks and non-Turn vision authoring retain their existing rules. +From the original working directory and with the original registry/runtime/project/ +state-file options, read the basis for the exact settlement: + +```sh +loopx checkpoint-context --goal-id example --agent-id agent-a \ + --todo-id todo_page --turn-instance-id turn-1 --format json +``` + +Use `--replan-obligation-id` instead of `--todo-id` for an obligation-bound Turn. +Declared Todo dependencies are included; repeat `--dependency-todo-id` for any +additional upstream Todo results actually used in the judgment. Inspect the +returned `basis`, judge the direction again, and add +`--checkpoint-read-context ` to the checkpoint-only refresh. +The agent echoes this opaque receipt; LoopX retains the version manifest. + +The basis covers the selected Todo, its dependency closure and recorded results, +shared Goal prose and User Todos, the owner acceptance document/revision when +configured, the current agent vision, and the local source binding. A replan +obligation covers the full Todo frontier. Archived dependencies remain inputs. +Todo display positions, source headings, and the Goal's global `updated_at` are +excluded; an unrelated Agent Todo or run-history append does not invalidate an +otherwise unchanged Todo-bound basis. Shared prose is deliberately conservative: +editing it requires another judgment even if the edit was only editorial. + +The local file/SQLite path holds the existing Goal run-index lock and the shared +maintenance, legacy-Todo, and state-source writer locks through the final reread, +version comparison, and checkpoint index append. A participating Todo/acceptance/ +prose writer cannot change that basis between comparison and append. Provider +failures stay closed; this does not activate a PostgreSQL service authority or +introduce a distributed transaction across runtimes. + +Receipts are bound to the exact Goal/Agent/Todo or obligation/Turn. A new read for +that Turn replaces its previous receipt, so its confirmation operations must be +serial; other work may remain parallel. A missing, replaced, or stale receipt +rejects the supplement without appending delivery or spending quota. Rerun +`checkpoint-context`, reread, and rejudge. Never attach a new receipt to an old +judgment. The committed decision includes the receipt identity in its replay +digest: an exact retry returns the original result even if state changed after +commit. Acquiring a receipt for an already satisfied checkpoint is rejected. + +Versions are content revisions of the declared decision inputs, including native +revision fields where present. They cannot detect an unobserved change-and-revert +in legacy Markdown, raw writes bypassing the writer locks, or changed bytes behind +an unversioned external link. Upstream deliveries must be represented by their +recorded Todo results/references. The receipt verifies the declared basis, not +whether the model actually understood or used it. It grants no new permissions, +task-completion authority, or evidence of acceptance. Older binaries do not enforce +this admission rule; rolling back loses its freshness protection. + `missing_required` is not a chat reminder. Status keeps it in compact run history, quota filters it by current `agent_id`, and goal-frontier projection turns it into `acceptance_gaps[]`. If the current agent has no runnable diff --git a/docs/state-interaction-model.md b/docs/state-interaction-model.md index 6ccdf3e1d6..24927c33dc 100644 --- a/docs/state-interaction-model.md +++ b/docs/state-interaction-model.md @@ -771,8 +771,18 @@ For an accountable, Turn-bound refresh, a successful writeback and a satisfied vision checkpoint are separate facts. `ok=true` does not imply that an omitted vision decision was supplied. Inspect `vision_checkpoint.satisfied`. -If the checkpoint is `missing_required`, submit a checkpoint-only refresh with -the **same** Goal, Agent, Todo/obligation, Turn, and delivery fields. Preserve +If the checkpoint is `missing_required`, first run `checkpoint-context` for the +**same** Goal, Agent, Todo/obligation and Turn. Read its returned decision basis +and judge again. Echo its `read_context_id` as `--checkpoint-read-context` in a +checkpoint-only refresh with the original identity and delivery fields. Include +additional used upstream Todos with repeatable `--dependency-todo-id` on the read. +The control plane now rejects supplements with missing, replaced, or stale read +receipts. Reread and rejudge on conflict; do not repeat completed work or attach a +fresh token to an old judgment. A new read replaces the old receipt for that Turn, +so checkpoint confirmations within one Turn must be serial. An exact committed +retry remains idempotent, including when state changed after its commit. See the +[read-basis contract](reference/protocols/goal-vision-replan-contract-v0.md#read-basis-for-checkpoint-only-recovery) +for covered versions and concurrency boundaries. Preserve the original working directory and explicit target (`--registry`, `--runtime-root`, `--project`, `--state-file`), scope (`--progress-scope`, `--agent-lane`), and isolation (`--no-global-sync`, `--suppress-external-sinks`) options, with their @@ -822,7 +832,7 @@ without them. Repeating mutations is rejected as `checkpoint_supplement_must_not_repeat_mutations`; do not simply append vision arguments to an original command that contains these options. -Add only one vision decision: +Add the read receipt and only one vision decision: - `--vision-unchanged-reason 'Existing scope and acceptance still apply.'` when a persisted vision genuinely remains applicable; From 9d87cc81f48b7b83d0b2d355880623d41c12f9cc Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 22 Sep 2026 13:18:30 +0800 Subject: [PATCH 03/11] refactor: distinguish checkpoint source IO from typed decisions Signed-off-by: Tartar --- loopx/cli_commands/project_lifecycle_refresh_state.py | 2 +- .../{checkpoint_read_context.py => checkpoint_context_io.py} | 2 +- loopx/state_refresh.py | 2 +- tests/control_plane/test_checkpoint_read_context.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) rename loopx/control_plane/goals/{checkpoint_read_context.py => checkpoint_context_io.py} (99%) diff --git a/loopx/cli_commands/project_lifecycle_refresh_state.py b/loopx/cli_commands/project_lifecycle_refresh_state.py index b47308623c..949bbe7c88 100644 --- a/loopx/cli_commands/project_lifecycle_refresh_state.py +++ b/loopx/cli_commands/project_lifecycle_refresh_state.py @@ -387,7 +387,7 @@ def handle_refresh_state_command( post_writeback_projection_builder: PostWritebackProjectionBuilder | None = None, ) -> int | None: if args.command == "checkpoint-context": - from ..control_plane.goals.checkpoint_read_context import read_checkpoint_context, render_checkpoint_context + from ..control_plane.goals.checkpoint_context_io import read_checkpoint_context, render_checkpoint_context try: payload = read_checkpoint_context( registry_path=registry_path, runtime_root_override=args.runtime_root, diff --git a/loopx/control_plane/goals/checkpoint_read_context.py b/loopx/control_plane/goals/checkpoint_context_io.py similarity index 99% rename from loopx/control_plane/goals/checkpoint_read_context.py rename to loopx/control_plane/goals/checkpoint_context_io.py index 06d6fe08f3..94dd727d5f 100644 --- a/loopx/control_plane/goals/checkpoint_read_context.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -1,4 +1,4 @@ -"""Source/receipt I/O for the TypeScript-owned checkpoint read-basis contract.""" +"""Source/receipt I/O only; checkpoint_read_context.ts owns decision semantics.""" from __future__ import annotations from contextlib import ExitStack, contextmanager diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index 40a0abee3a..865cb8491b 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -92,7 +92,7 @@ prepare_vision_refresh, ) from .control_plane.goals.goal_frontier import latest_agent_vision_from_runs -from .control_plane.goals.checkpoint_read_context import checkpoint_commit_guard +from .control_plane.goals.checkpoint_context_io import checkpoint_commit_guard from .registry import registry_goals, resolve_state_file from .runtime import validate_goal_id_path_segment from .state_projection import ( diff --git a/tests/control_plane/test_checkpoint_read_context.py b/tests/control_plane/test_checkpoint_read_context.py index 8b38b77ae5..90ca482bce 100644 --- a/tests/control_plane/test_checkpoint_read_context.py +++ b/tests/control_plane/test_checkpoint_read_context.py @@ -82,7 +82,7 @@ def test_context_reads_real_canonical_todo_and_owner_acceptance(tmp_path, monkey from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from loopx.control_plane.coordination.local_authority_shadow_projection import canonical_bytes - from loopx.control_plane.goals.checkpoint_read_context import _source_facts, _source_guard + from loopx.control_plane.goals.checkpoint_context_io import _source_facts, _source_guard from loopx.control_plane.quota.settlement import SettlementIdentity import hashlib @@ -114,7 +114,7 @@ def test_context_reads_real_canonical_todo_and_owner_acceptance(tmp_path, monkey def test_source_writers_remain_excluded_until_checkpoint_append(tmp_path, monkeypatch): from loopx import state_refresh from loopx.control_plane.coordination.shadow_management import shadow_maintenance_lock_target - from loopx.control_plane.goals.checkpoint_read_context import read_checkpoint_context + from loopx.control_plane.goals.checkpoint_context_io import read_checkpoint_context project, runtime, registry, _, _, original = _missing(tmp_path) state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" From 0ebc03d955f26d84425a74d8f337179b3d245832 Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 22 Sep 2026 13:18:53 +0800 Subject: [PATCH 04/11] fix: carry explicit checkpoint read receipts through MCP recovery Signed-off-by: Tartar --- .../claude_goal_mode/scripts/goalmode_cmd.py | 6 ++- .../control_plane/host_adapter_settlement.py | 9 +++- .../turn_driver/host_todo_completion.ts | 22 +++++++- loopx/goal_mode_mcp.py | 9 +++- .../host_todo_completion.test.ts | 22 +++++++- .../test_claude_goal_release_qualification.py | 3 ++ tests/test_host_vision_recovery.py | 51 ++++++++++++++++--- 7 files changed, 107 insertions(+), 15 deletions(-) diff --git a/loopx/claude_goal_mode/scripts/goalmode_cmd.py b/loopx/claude_goal_mode/scripts/goalmode_cmd.py index faf7c30db6..05ed1244db 100644 --- a/loopx/claude_goal_mode/scripts/goalmode_cmd.py +++ b/loopx/claude_goal_mode/scripts/goalmode_cmd.py @@ -83,8 +83,10 @@ def loop_execution_content(goal_id, agent_id) -> str: "Use interaction_contract.mcp_channel for tool ownership and vision input limits.\n" "At material delivery, compare the Goal's vision/acceptance with actual evidence.\n" "Pass the resulting agent_vision or a justified vision_unchanged_reason to\n" - "complete_task. If omitted, use review_task_vision on that completed Todo to\n" - "repair its missing checkpoint without another spend. This is not a Goal-stop\n" + "complete_task. If omitted, call review_task_vision with only that Todo and\n" + "Agent to read the basis; rejudge, then submit read_context_id with the decision.\n" + "Stale/replaced receipts require rereading; lost replies retry the same receipt\n" + "and decision. Recovery never spends again. This is not a Goal-stop\n" "shortcut: open acceptance needs replan; vision_closed closes a stage and needs\n" "a successor vision; no_followup requires evidence of no remaining scoped work.\n" "For new replan work not covered by these tools, use the exact live\n" diff --git a/loopx/control_plane/host_adapter_settlement.py b/loopx/control_plane/host_adapter_settlement.py index d3e6f6ed38..ac450d5f57 100644 --- a/loopx/control_plane/host_adapter_settlement.py +++ b/loopx/control_plane/host_adapter_settlement.py @@ -61,6 +61,7 @@ class HostTodoSettlementRequest: no_follow_up: bool = False vision_path: str | None = None vision_unchanged_reason: str | None = None + checkpoint_read_context_id: str | None = None class HostCliRunner(Protocol): @@ -101,11 +102,12 @@ def _request_payload( } if provider_outcomes is not None: payload["provider_outcomes"] = provider_outcomes - if request.vision_path or request.vision_unchanged_reason or phase == "vision_refresh": + if request.vision_path or request.vision_unchanged_reason or phase in {"vision_refresh", "vision_context"}: payload.update( schema_version="loopx_host_todo_completion_transaction_v1", vision_path=request.vision_path, vision_unchanged_reason=request.vision_unchanged_reason, + checkpoint_read_context_id=request.checkpoint_read_context_id, ) return payload @@ -133,7 +135,10 @@ def host_vision_request(request: HostTodoSettlementRequest, vision: dict | None, def refresh_host_todo_vision(request: HostTodoSettlementRequest, *, run_cli: HostCliRunner) -> str: """Repair the original checkpoint; no lifecycle operation, new Turn or spend.""" - plan = _runtime_reduction(_request_payload(request, phase="vision_refresh"), phase="vision_refresh") + phase = "vision_refresh" if ( + request.vision_path or request.vision_unchanged_reason or request.checkpoint_read_context_id + ) else "vision_context" + plan = _runtime_reduction(_request_payload(request, phase=phase), phase=phase) _runtime_identity(plan.get("identity")) args = plan.get("args") if not isinstance(args, list) or any(not isinstance(arg, str) for arg in args): diff --git a/loopx/control_plane/turn_driver/host_todo_completion.ts b/loopx/control_plane/turn_driver/host_todo_completion.ts index 41bdeb7bd9..1d17b84dd4 100644 --- a/loopx/control_plane/turn_driver/host_todo_completion.ts +++ b/loopx/control_plane/turn_driver/host_todo_completion.ts @@ -27,7 +27,7 @@ export const HOST_TODO_COMPLETION_REDUCTION_SCHEMA_VERSION = export const HOST_ADAPTER_SETTLEMENT_SCHEMA_VERSION = "host_adapter_todo_settlement_v0"; -const PHASES = ["prepare", "finalize", "classify_guard", "vision_refresh", "project_guard"] as const; +const PHASES = ["prepare", "finalize", "classify_guard", "vision_refresh", "vision_context", "project_guard"] as const; const STEP_KINDS = [ "guard", "lifecycle_completion", @@ -54,6 +54,7 @@ interface HostTodoCompletionRequest { no_follow_up: boolean; vision_path: string | null; vision_unchanged_reason: string | null; + checkpoint_read_context_id: string | null; provider_outcomes: readonly ProviderOutcome[]; } @@ -130,16 +131,23 @@ function decodeRequest( ? null : requireNonEmptyString(value[field], field); const visionPath = optionalText("vision_path"); const unchanged = normalizeVisionUnchangedReason(optionalText("vision_unchanged_reason")); + const readContextId = optionalText("checkpoint_read_context_id"); if (visionPath && unchanged) { throw new EffectRuntimeRequestError("choose a vision patch or an unchanged reason, not both"); } - if ((visionPath || unchanged || phase === "vision_refresh") && + if ((visionPath || unchanged || readContextId || phase === "vision_refresh" || phase === "vision_context") && value.schema_version !== HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION) { throw new EffectRuntimeRequestError("host vision authoring requires v1"); } if (phase === "vision_refresh" && !visionPath && !unchanged) { throw new EffectRuntimeRequestError("vision refresh requires an authored decision"); } + if (phase === "vision_context" && (visionPath || unchanged || readContextId)) { + throw new EffectRuntimeRequestError("vision context reads cannot submit a decision or receipt"); + } + if (readContextId && phase !== "vision_refresh") { + throw new EffectRuntimeRequestError("checkpoint read context belongs only to vision recovery"); + } const request: HostTodoCompletionRequest = { phase, goal_id: requireNonEmptyString(value.goal_id, "goal_id"), @@ -168,6 +176,7 @@ function decodeRequest( no_follow_up: requireBoolean(value.no_follow_up, "no_follow_up"), vision_path: visionPath, vision_unchanged_reason: unchanged, + checkpoint_read_context_id: readContextId, provider_outcomes: [], }; if (phase === "finalize") { @@ -339,6 +348,7 @@ function writebackArgs(request: HostTodoCompletionRequest, identity: JsonObject) "--no-global-sync", "--suppress-external-sinks", ...(request.vision_path ? ["--agent-vision-json", request.vision_path] : []), ...(request.vision_unchanged_reason ? ["--vision-unchanged-reason", request.vision_unchanged_reason] : []), + ...(request.checkpoint_read_context_id ? ["--checkpoint-read-context", request.checkpoint_read_context_id] : []), ]; } @@ -966,6 +976,14 @@ export function evaluateHostTodoCompletion(value: JsonObject): JsonObject { const request = decodeRequest(value, phase); if (phase === "finalize") return finalize(request); const { payload: identity } = expectedIdentity(request); + if (phase === "vision_context") { + return { + schema_version: HOST_TODO_COMPLETION_REDUCTION_SCHEMA_VERSION, + phase, identity, args: ["checkpoint-context", "--goal-id", request.goal_id, + "--agent-id", request.agent_id, "--todo-id", request.todo_id, + "--turn-instance-id", String(identity.turn_instance_id)], + }; + } if (phase === "vision_refresh") { return { schema_version: HOST_TODO_COMPLETION_REDUCTION_SCHEMA_VERSION, diff --git a/loopx/goal_mode_mcp.py b/loopx/goal_mode_mcp.py index 813704d46d..1bec5811bb 100644 --- a/loopx/goal_mode_mcp.py +++ b/loopx/goal_mode_mcp.py @@ -259,6 +259,7 @@ def complete_task( def review_task_vision( self, todo_id: str, agent_id: str, agent_vision: dict[str, Any] | None = None, vision_unchanged_reason: str = "", + read_context_id: str = "", ) -> str: goal_id, _ = self.context() if not goal_id: @@ -272,6 +273,7 @@ def review_task_vision( legacy_host_surface=self.config.legacy_host_surface, scheduler_owner=self.config.scheduler_owner, execution_mode=self.config.execution_mode, completion_args=(), + checkpoint_read_context_id=read_context_id or None, ) with host_vision_request(request, agent_vision, vision_unchanged_reason) as authored: return refresh_host_todo_vision(authored, run_cli=self.run_cli) @@ -317,8 +319,13 @@ def claim_task(todo_id: str, agent_id: str) -> str: def review_task_vision( todo_id: str, agent_id: str, agent_vision: dict[str, Any] | None = None, vision_unchanged_reason: str = "", + read_context_id: str = "", ) -> str: """Supply a missing vision decision for a previously completed MCP Todo. + First call with only todo_id and agent_id to read the current basis. + Judge that basis, then call again with its read_context_id and one decision. + On stale/replaced context, read and judge again; do not reuse the old decision. + Retry a lost response with the same receipt and decision, without a new read. Uses its original Turn, never repeats work or spends again. agent_vision is a goal_vision_replan_contract_v0 packet with state and vision_patch fields. Compare Goal acceptance with evidence; vision_closed closes a stage and @@ -326,7 +333,7 @@ def review_task_vision( An unchanged reason requires an existing valid vision. Recheck should_run; checkpoint success alone does not certify Goal completion or clear gates. """ - return control.review_task_vision(todo_id, agent_id, agent_vision, vision_unchanged_reason) + return control.review_task_vision(todo_id, agent_id, agent_vision, vision_unchanged_reason, read_context_id) @server.tool() def complete_task( diff --git a/tests/control_plane_ts/host_todo_completion.test.ts b/tests/control_plane_ts/host_todo_completion.test.ts index 6074acd378..c608bb392c 100644 --- a/tests/control_plane_ts/host_todo_completion.test.ts +++ b/tests/control_plane_ts/host_todo_completion.test.ts @@ -17,15 +17,33 @@ test("vision refresh shares the original delivery command and identity without a const authored = {schema_version: HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION, vision_path: "fixture-vision.json"}; const first = prepare(authored); - const recovery = evaluateHostTodoCompletion(request("prepare", {...authored, phase: "vision_refresh"})); + const recovery = evaluateHostTodoCompletion(request("prepare", {...authored, phase: "vision_refresh", + checkpoint_read_context_id: "receipt-a"})); const steps = (first.provider_effect as {steps: {step_kind: string; args: string[]}[]}).steps; - assert.deepEqual(recovery.args, steps.find(step => step.step_kind === "durable_writeback")!.args); + assert.deepEqual(recovery.args, [...steps.find(step => step.step_kind === "durable_writeback")!.args, + "--checkpoint-read-context", "receipt-a"]); assert.deepEqual(recovery.identity, first.identity); assert.equal(recovery.provider_effect, undefined); assert.equal((recovery.args as string[]).includes("spend-slot"), false); assert.equal((recovery.args as string[]).includes("--next-action"), false); }); +test("host context read uses the original identity and cannot carry a decision", () => { + const input = {schema_version: HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION, phase: "vision_context"}; + const context = evaluateHostTodoCompletion(request("prepare", input)); + const identity = prepare().identity as Record; + assert.deepEqual(context.identity, identity); + assert.deepEqual(context.args, ["checkpoint-context", "--goal-id", "goal", + "--agent-id", "agent", "--todo-id", todoId, "--turn-instance-id", identity.turn_instance_id]); + assert.equal(context.provider_effect, undefined); + for (const field of ["vision_path", "vision_unchanged_reason", "checkpoint_read_context_id"]) { + assert.throws(() => evaluateHostTodoCompletion(request("prepare", {...input, [field]: "value"})), + /cannot submit/); + } + assert.throws(() => prepare({schema_version: HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION, + checkpoint_read_context_id: "receipt-a"}), /only to vision recovery/); +}); + test("vision decisions require v1 and cannot combine patch with unchanged", () => { assert.throws(() => prepare({vision_path: "vision.json"}), /requires v1/); assert.throws(() => prepare({schema_version: HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION, diff --git a/tests/test_claude_goal_release_qualification.py b/tests/test_claude_goal_release_qualification.py index 9ead9fd017..1d9b97a67a 100644 --- a/tests/test_claude_goal_release_qualification.py +++ b/tests/test_claude_goal_release_qualification.py @@ -156,6 +156,9 @@ async def exercise(): assert "successor_todo_ids" in complete.inputSchema["properties"] assert "agent_vision" in complete.inputSchema["properties"] assert "review_task_vision" in {t.name for t in tools.tools} + review = next(t for t in tools.tools if t.name == "review_task_vision") + assert review.inputSchema["required"] == ["todo_id", "agent_id"] + assert "read_context_id" in review.inputSchema["properties"] guard = await session.call_tool("should_run", {}) payload = json.loads(guard.content[0].text) assert payload["ok"] is True and payload["selected_todo"]["todo_id"] == "todo_reducer" diff --git a/tests/test_host_vision_recovery.py b/tests/test_host_vision_recovery.py index 941a62ff6a..deff2a2eb1 100644 --- a/tests/test_host_vision_recovery.py +++ b/tests/test_host_vision_recovery.py @@ -51,11 +51,15 @@ def test_completed_todos_require_vision_decision_not_automatic_goal_close(tmp_pa assert before["should_run"] is True assert before["interaction_contract"]["mode"] != "terminal_no_followup" assert len(spends(runtime)) == 2 - repaired = json.loads(control.review_task_vision("todo_cli", fixture.AGENT, vision(state))) + context = json.loads(control.review_task_vision("todo_cli", fixture.AGENT)) + assert context["ok"] is True, context + assert context["basis"]["todo"]["todo_id"] == "todo_cli" + receipt = context["read_context_id"] + repaired = json.loads(control.review_task_vision("todo_cli", fixture.AGENT, vision(state), read_context_id=receipt)) assert repaired["ok"] is True, repaired assert repaired["vision_checkpoint"]["satisfied"] is True assert repaired["refresh_recovery"]["decision"] == "supplement_checkpoint" - replay = json.loads(control.review_task_vision("todo_cli", fixture.AGENT, vision(state))) + replay = json.loads(control.review_task_vision("todo_cli", fixture.AGENT, vision(state), read_context_id=receipt)) assert replay["ok"] is True, replay assert replay["appended"] is False assert len(spends(runtime)) == 2 @@ -205,8 +209,11 @@ def test_checkpoint_recovery_missing_baseline_conflict_and_lost_response(tmp_pat result = json.loads(control.complete_task("todo_reducer", fixture.AGENT, "Synthetic acceptance", successor_todo_ids=["todo_cli"])) assert result["ok"] is True + context = json.loads(control.review_task_vision("todo_reducer", fixture.AGENT)) + assert context["ok"] is True, context + receipt = context["read_context_id"] unchanged = json.loads(control.review_task_vision("todo_reducer", fixture.AGENT, - vision_unchanged_reason="Still correct")) + vision_unchanged_reason="Still correct", read_context_id=receipt)) assert unchanged.get("vision_checkpoint", {}).get("satisfied") is not True assert len(spends(runtime)) == 1 original = control.run_cli @@ -218,12 +225,12 @@ def response_lost(args, **kwargs): monkeypatch.setattr(control, "run_cli", response_lost) with pytest.raises(TimeoutError): - control.review_task_vision("todo_reducer", fixture.AGENT, vision("vision_patch_proposed")) + control.review_task_vision("todo_reducer", fixture.AGENT, vision("vision_patch_proposed"), read_context_id=receipt) monkeypatch.setattr(control, "run_cli", original) - replay = json.loads(control.review_task_vision("todo_reducer", fixture.AGENT, vision("vision_patch_proposed"))) + replay = json.loads(control.review_task_vision("todo_reducer", fixture.AGENT, vision("vision_patch_proposed"), read_context_id=receipt)) assert replay["ok"] is True, replay assert replay["appended"] is False - conflict = json.loads(control.review_task_vision("todo_reducer", fixture.AGENT, vision("no_followup"))) + conflict = json.loads(control.review_task_vision("todo_reducer", fixture.AGENT, vision("no_followup"), read_context_id=receipt)) assert conflict["ok"] is False assert len(spends(runtime)) == 1 # A valid vision decision never consumes the independent open successor. @@ -238,3 +245,35 @@ def test_native_outer_controller_owns_new_vision_tool(monkeypatch): control = SimpleNamespace() guard_native_controller_writeback(control) assert json.loads(control.review_task_vision("todo_any", "agent", vision()))["ok"] is False + + +def test_host_recovery_requires_its_explicit_fresh_read_context(tmp_path, monkeypatch): + control, project, runtime = control_at(tmp_path) + monkeypatch.chdir(project) + completed = json.loads(control.complete_task("todo_reducer", fixture.AGENT, "Synthetic acceptance", + successor_todo_ids=["todo_cli"])) + assert completed["ok"] is True, completed + index = runtime / "goals" / fixture.GOAL / "runs/index.jsonl" + before = index.read_bytes() + missing = json.loads(control.review_task_vision("todo_reducer", fixture.AGENT, vision())) + assert missing["error_code"] == "checkpoint_read_context_required", missing + old = json.loads(control.review_task_vision("todo_reducer", fixture.AGENT)) + current = json.loads(control.review_task_vision("todo_reducer", fixture.AGENT)) + replaced = json.loads(control.review_task_vision("todo_reducer", fixture.AGENT, vision(), + read_context_id=old["read_context_id"])) + assert replaced["error_code"] == "checkpoint_read_context_unknown_or_replaced", replaced + # A synthetic owner acceptance edit between the read and the decision. + state = project / "ACTIVE_GOAL_STATE.md" + state.write_text(state.read_text(encoding="utf-8") + "\n## Acceptance\n\nVerify revised output.\n", encoding="utf-8") + stale = json.loads(control.review_task_vision("todo_reducer", fixture.AGENT, vision(), + read_context_id=current["read_context_id"])) + assert stale["error_code"] == "checkpoint_read_context_stale", stale + assert index.read_bytes() == before + fresh = json.loads(control.review_task_vision("todo_reducer", fixture.AGENT)) + assert "Verify revised output." in json.dumps(fresh["basis"]) + corrected = vision("vision_patch_proposed") + corrected["vision_patch"]["acceptance_summary"] = "Verify revised output." + result = json.loads(control.review_task_vision("todo_reducer", fixture.AGENT, corrected, + read_context_id=fresh["read_context_id"])) + assert result["ok"] is True and result["vision_checkpoint"]["satisfied"], result + assert len(spends(runtime)) == 1 From 29334935c8b7b723cc98aabce4ff754c1263644b Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 22 Sep 2026 13:18:54 +0800 Subject: [PATCH 05/11] docs: explain MCP checkpoint read and retry protocol Signed-off-by: Tartar --- docs/development/testing-and-quality.md | 10 ++++++++-- .../protocols/goal-vision-replan-contract-v0.md | 8 ++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index cf8f2adebb..ebdb54a9c1 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -1097,7 +1097,11 @@ the same completion intent and a corrected uncommitted vision. Checkpoint-only recovery is not a substitute for unfinished settlement. If a previously completed MCP Todo omitted its decision, call -`review_task_vision(todo_id, agent_id, agent_vision=...)` with that same Todo. +`review_task_vision(todo_id, agent_id)` with that same Todo to read its current +decision basis. Judge the returned basis, then submit `agent_vision=...` or +`vision_unchanged_reason=...` together with `read_context_id=...`. A stale or +replaced receipt requires another read and a new judgment; a lost response +requires the exact same receipt and decision, without reading again. It uses the original host Turn and the same writeback command constructor, delegating to the existing typed checkpoint recovery. It neither repeats Todo completion nor spends again. Exact replay is idempotent; a conflicting committed @@ -1115,7 +1119,9 @@ not a substitute for evidence; remaining acceptance gaps or gates still prevent terminal quota. Kernel validation does not independently prove arbitrary prose true, so behavior qualification must also inspect the delivered artifacts. -MCP 可随完成操作携带 vision 判断,也可用 `review_task_vision` 在原 Turn 补齐遗漏。 +MCP 可随完成操作携带 vision 判断,也可用 `review_task_vision` 在原 Turn 补齐遗漏: +先只传 Todo 和 Agent 读取依据,重新判断后携带 `read_context_id` 与判断提交。 +凭据过期或被替换时重读重判;响应丢失时原样重试凭据和判断,不重新读取。 复用 TS 的既有恢复规则,不新增结算引擎、不重扣额度;已提交的判断不能偷偷改写。 格式和预算预检在 Todo 完成前拒绝非法输入;若旧宿主已部分完成,则修正未提交的 vision 并重试原 `complete_task`,不能用仅补 checkpoint 的操作替代未完成结算。 diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index a8e3e10354..fb6a62a121 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -292,6 +292,14 @@ returned `basis`, judge the direction again, and add `--checkpoint-read-context ` to the checkpoint-only refresh. The agent echoes this opaque receipt; LoopX retains the version manifest. +MCP hosts use the same protocol through `review_task_vision`: call with only +`todo_id` and `agent_id` to read, then submit the returned `read_context_id` +with one newly judged `agent_vision` or `vision_unchanged_reason`. Reading never +automatically submits a decision. Missing receipts fail closed; stale receipts +require another read and judgment, while lost replies use the exact original +receipt and decision. The Python `checkpoint_context_io` adapter gathers and +locks sources; TypeScript `checkpoint_read_context` alone compares the basis. + The basis covers the selected Todo, its dependency closure and recorded results, shared Goal prose and User Todos, the owner acceptance document/revision when configured, the current agent vision, and the local source binding. A replan From 4e5cb459b039e0f15ed52ffcddc0237ae6a63f17 Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 23 Sep 2026 09:55:38 +0800 Subject: [PATCH 06/11] fix(checkpoint): fence final validation and append in local providers Signed-off-by: Tartar --- .../coordination/file_authority_store.ts | 15 ++ .../coordination/sqlite_authority_store.ts | 24 +- .../control_plane/effect_runtime_handlers.ts | 5 + .../goals/checkpoint_authority.ts | 50 ++++ .../control_plane/goals/checkpoint_commit.ts | 217 ++++++++++++++++++ .../goals/checkpoint_context_io.py | 103 ++++++--- .../goals/checkpoint_read_context.ts | 9 +- loopx/control_plane/heartbeat/rules.py | 2 +- .../runtime/runtime_projection_writer.py | 4 +- loopx/feedback.py | 4 +- loopx/file_lock.py | 64 ++++-- loopx/history.py | 6 +- loopx/operator_gate.py | 12 +- loopx/project_map.py | 9 +- loopx/state_refresh.py | 41 +++- 15 files changed, 487 insertions(+), 78 deletions(-) create mode 100644 loopx/control_plane/goals/checkpoint_authority.ts create mode 100644 loopx/control_plane/goals/checkpoint_commit.ts diff --git a/loopx/control_plane/coordination/file_authority_store.ts b/loopx/control_plane/coordination/file_authority_store.ts index 3a19aa4e23..0a75a6012f 100644 --- a/loopx/control_plane/coordination/file_authority_store.ts +++ b/loopx/control_plane/coordination/file_authority_store.ts @@ -11,6 +11,7 @@ import type { AuthorityStoreCommitResult, AuthorityStoreIdentityResult, AuthorityStoreLoadResult, + AuthorityStoreHead, AuthorityStoreReadFailure, AuthorityStoreReceiptResult, AuthorityStoreScanResult, @@ -249,6 +250,20 @@ export class FileAuthorityStore implements AuthorityStore { } } + /** Checkpoint-only external append: retain the real writer lock through the + * synchronous callback. This neither commits nor advances authority revision. */ + async withCheckpointHead(save: (head: AuthorityStoreHead, identity: string) => JsonObject): Promise { + return await withFileMutationLock(this.path, async () => { + const identity = await this.readStoreIdentity(false); + const current = await this.readDocument(); + if (!current) throw new FileStoreUnavailableError("checkpoint authority is missing"); + const result = save({head: structuredClone(current.head), + provider_revision: current.provider_revision, cursor: current.cursor}, identity); + if (result instanceof Promise) throw new Error("checkpoint save must be synchronous"); + return result; + }); + } + async commitAuthority(commit: AuthorityStoreCommit): Promise { let normalized: AuthorityStoreCommit; try { diff --git a/loopx/control_plane/coordination/sqlite_authority_store.ts b/loopx/control_plane/coordination/sqlite_authority_store.ts index ca54a225a3..79571dc61b 100644 --- a/loopx/control_plane/coordination/sqlite_authority_store.ts +++ b/loopx/control_plane/coordination/sqlite_authority_store.ts @@ -7,7 +7,7 @@ import type { DatabaseSync } from "node:sqlite"; import type { JsonObject } from "../effect_program.ts"; import type { AuthorityStore, AuthorityStoreCommit, AuthorityStoreCommitResult, AuthorityStoreCommittedTransaction, - AuthorityStoreIdentityResult, AuthorityStoreLoadResult, AuthorityStoreReadFailure, + AuthorityStoreIdentityResult, AuthorityStoreLoadResult, AuthorityStoreReadFailure, AuthorityStoreHead, AuthorityStoreReceiptResult, AuthorityStoreScanResult } from "./authority_store.ts"; import { AuthorityStoreProtocolError, canonicalAuthorityBytes, canonicalAuthorityObject, canonicalAuthorityObjectList, canonicalAuthoritySha256, normalizeAuthorityStoreCommit, @@ -422,6 +422,28 @@ export class SqliteAuthorityStore implements AuthorityStore { finally { db?.close(); } } + /** No await between BEGIN and ROLLBACK: another DatabaseSync request must not + * block this event loop while the transaction holder awaits filesystem I/O. + * The transaction excludes writers; it cannot roll back external run files. */ + async withCheckpointHead(save: (head: AuthorityStoreHead, identity: string) => JsonObject): Promise { + const db = this.open(true); + if (!db) throw new Error("checkpoint authority is missing"); + let active = false; + try { + db.exec("BEGIN IMMEDIATE"); + active = true; + const current = this.current(db); + if (!current) throw new Error("checkpoint authority head is missing"); + const result = save({head: current.state.projection, + provider_revision: current.provider_revision, cursor: current.state.cursor.toString()}, current.identity); + if (result instanceof Promise) throw new Error("checkpoint save must be synchronous"); + return result; + } finally { + try { if (active) db.exec("ROLLBACK"); } + finally { db.close(); } + } + } + async commitAuthority(commit: AuthorityStoreCommit): Promise { let normalized: AuthorityStoreCommit; try { diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 1e1717bc55..d312541a40 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -109,6 +109,8 @@ import { } from "./scheduler/state_store.ts"; import { buildVisionCheckpoint } from "./goals/vision_checkpoint.ts"; import {evaluateCheckpointReadContext} from "./goals/checkpoint_read_context.ts"; +import {readCheckpointAuthority} from "./goals/checkpoint_authority.ts"; +import {commitCheckpoint, inspectCheckpointReplay} from "./goals/checkpoint_commit.ts"; import { projectVisionWaitCoverage } from "./goals/vision_wait_coverage.ts"; import { admitGoalAmendmentProposal } from "./goals/goal_amendment_proposal.ts"; import { projectSharedGoalAlignment } from "./goals/shared_goal_alignment.ts"; @@ -494,6 +496,9 @@ export function createEffectRuntimeHandlers( ["work_item.delivery_claim.validate", validateDeliveryClaim], ["goal.vision_checkpoint.evaluate", buildVisionCheckpoint], ["goal.checkpoint_read_context.evaluate", evaluateCheckpointReadContext], + ["goal.checkpoint_read_context.source", readCheckpointAuthority], + ["goal.checkpoint_read_context.commit", commitCheckpoint], + ["goal.checkpoint_read_context.inspect_replay", inspectCheckpointReplay], ["goal.vision_wait.coverage", projectVisionWaitCoverage], ["goal.shared_goal_alignment.project", projectSharedGoalAlignment], ["goal.operator_actions.project", projectGoalOperatorActions], diff --git a/loopx/control_plane/goals/checkpoint_authority.ts b/loopx/control_plane/goals/checkpoint_authority.ts new file mode 100644 index 0000000000..d38967c163 --- /dev/null +++ b/loopx/control_plane/goals/checkpoint_authority.ts @@ -0,0 +1,50 @@ +/** Same-head checkpoint facts and the two shipped local provider fences. + * Not an AuthorityStore extension contract or a checkpoint authority migration. */ +import type {JsonObject} from "../effect_program.ts"; +import {requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; +import {authorityStoreSourceAuthority} from "../coordination/authority_store.ts"; +import {goalPathSegment} from "../rollout_receipt_log.ts"; +import {FileAuthorityStore} from "../coordination/file_authority_store.ts"; +import {SqliteAuthorityStore} from "../coordination/sqlite_authority_store.ts"; +import {openRuntimeAuthorityStore, requireLocalAuthorityRuntimeRoot} from "../coordination/local_authority_provider.ts"; +import {loadLegacyCoordinationWriterFence} from "../coordination/legacy_writer_fence.ts"; +import {indexCoordinationProjectionTodos, validateCoordinationTodoReadModel} from "../coordination/coordination_projection.ts"; +import {readGoalAcceptance} from "./acceptance_contract.ts"; + +export async function withCheckpointAuthority( + root: string, goalId: string, facts: JsonObject, save: (facts: JsonObject) => JsonObject, +): Promise { + const fence = await loadLegacyCoordinationWriterFence(root, goalId); + if (fence.status === "failed") throw new Error(fence.reason); + const source = requireJsonObject(facts.source, "checkpoint source"); + if (fence.status === "missing") { + return save({...facts, source: {...source, authority: "legacy_markdown", store_identity: null}}); + } + const store = await openRuntimeAuthorityStore(root, goalId, {}); + if (!(store instanceof FileAuthorityStore) && !(store instanceof SqliteAuthorityStore)) { + throw new Error("checkpoint supplement requires a supported local provider fence"); + } + return await store.withCheckpointHead((head, identity) => { + const projection = indexCoordinationProjectionTodos(head.head, goalId); + validateCoordinationTodoReadModel(head.head, goalId); + const acceptance = readGoalAcceptance(head.head, goalId); + return save({...facts, + todos: projection.todo_ids.map(id => projection.todos.get(id)!), + acceptance: {revision: acceptance?.revision ?? null, contract_digest: acceptance?.digest ?? null, + contract: acceptance?.enabled ? acceptance.document : null}, + provider_revision: head.provider_revision, + source: {...source, authority: authorityStoreSourceAuthority(store), store_identity: identity}, + }); + }); +} + +/** Called while the Python adapter holds the local source locks. Optimistic + * receipts are allowed to go stale after this operation returns. */ +export async function readCheckpointAuthority(value: unknown): Promise { + const request = requireJsonObject(value, "checkpoint source request"); + const root = requireLocalAuthorityRuntimeRoot(request.runtime_root); + const goalId = goalPathSegment(request.goal_id); + const facts = requireJsonObject(request.facts, "checkpoint facts"); + requireNonEmptyString(requireJsonObject(facts.source, "checkpoint source").state_file, "state_file"); + return await withCheckpointAuthority(root, goalId, facts, current => current); +} diff --git a/loopx/control_plane/goals/checkpoint_commit.ts b/loopx/control_plane/goals/checkpoint_commit.ts new file mode 100644 index 0000000000..42292cc782 --- /dev/null +++ b/loopx/control_plane/goals/checkpoint_commit.ts @@ -0,0 +1,217 @@ +/** Missing-checkpoint commit. Index/source claims survive the requesting CLI; + * the real provider fence lasts through the synchronous durable append. */ +import {createHash} from "node:crypto"; +import {closeSync, fsyncSync, lstatSync, openSync, readFileSync, writeFileSync} from "node:fs"; +import {dirname, join, resolve} from "node:path"; +import type {JsonObject} from "../effect_program.ts"; +import {settlementIdentity, settlementIdentityPayload} from "../effect_program.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {claimFileMutationLock, mutationLockOwner, releaseFileMutationLock, + releaseFileMutationLockClaim, withFileMutationLock, type FileMutationLockClaim} from "../effect_runtime_io.ts"; +import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; +import {requireLocalAuthorityRuntimeRoot} from "../coordination/local_authority_provider.ts"; +import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; +import {legacyCoordinationTodoLockPath} from "../coordination/legacy_writer_lock_paths.ts"; +import {shadowMaintenanceLockPath, requireShadowPrimaryWriteAllowed} from "../coordination/shadow_management.ts"; +import {readQuotaSettlement, QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA} from "../quota/settlement_readback.ts"; +import {evaluateCheckpointReadContext} from "./checkpoint_read_context.ts"; +import {withCheckpointAuthority} from "./checkpoint_authority.ts"; +import {goalPathSegment} from "../rollout_receipt_log.ts"; + +const digest = (bytes: Uint8Array): string => createHash("sha256").update(bytes).digest("hex"); +function unknown(message: string): never { + throw new EffectRuntimeRequestError(`${message}; read back the original Turn before retrying`, "checkpoint_commit_unknown"); +} + +function indexBytes(path: string): Buffer { + const bytes = readFileSync(path); + // The ordinary history reader tolerates damaged rows. A commit cannot infer + // absence from that reader or append behind a torn (even JSON-valid) tail. + if (bytes.length && bytes[bytes.length - 1] !== 10) unknown("checkpoint index has an incomplete tail"); + const checkpoints = new Map(); + for (const line of bytes.toString("utf8").split(/\r?\n/)) { + if (!line.trim()) continue; + try { + const row = requireJsonObject(JSON.parse(line), "run index row"); + const checkpoint = jsonObject(row.vision_checkpoint); + const effect = jsonObject(row.settlement_identity)?.effect_id; + if (checkpoint?.satisfied === true && checkpoint.read_context && typeof effect === "string") { + const version = canonicalAuthoritySha256({checkpoint, recovery: row.refresh_recovery, + json_path: row.json_path, markdown_path: row.markdown_path}); + if (checkpoints.has(effect) && checkpoints.get(effect) !== version) unknown("checkpoint index has conflicting decisions"); + checkpoints.set(effect, version); + } + } + catch { unknown("checkpoint index is malformed"); } + } + return bytes; +} + +function committedArtifacts(prior: JsonObject, runsDir: string): JsonObject { + const jsonPath = runPath(prior.json_path, runsDir, ".json"); + const markdownPath = runPath(prior.markdown_path, runsDir, ".md"); + let record: JsonObject; + try { + record = requireJsonObject(JSON.parse(readFileSync(jsonPath, "utf8")), "checkpoint record"); + readFileSync(markdownPath); + } catch { unknown("checkpoint artifacts are unavailable"); } + for (const field of ["settlement_identity", "refresh_recovery", "vision_checkpoint"]) { + if (canonicalAuthoritySha256(record[field]) !== canonicalAuthoritySha256(prior[field])) { + unknown("checkpoint artifacts disagree with the committed index"); + } + } + return {ok: true, replayed: true, context: jsonObject(prior.vision_checkpoint)?.read_context, + json_path: jsonPath, markdown_path: markdownPath}; +} + +/** Read-only verification before the Python adapter returns an early replay. + * Its index lock is already held. No receipt freshness check invalidates a + * historically committed decision. */ +export function inspectCheckpointReplay(value: unknown): JsonObject { + const request = requireJsonObject(value, "checkpoint replay inspection"); + const root = requireLocalAuthorityRuntimeRoot(request.runtime_root); + const prior = requireJsonObject(request.prior, "checkpoint prior"); + const goal = goalPathSegment(request.goal_id); + const runsDir = join(root, "goals", goal, "runs"); + const rows = indexBytes(join(runsDir, "index.jsonl")).toString("utf8").split(/\r?\n/).filter(line => line.trim()); + if (!rows.some(line => canonicalAuthoritySha256(JSON.parse(line)) === canonicalAuthoritySha256(prior))) { + unknown("checkpoint replay is not an indexed record"); + } + return committedArtifacts(prior, runsDir); +} + +function runPath(value: unknown, runsDir: string, extension: string): string { + const path = resolve(requireNonEmptyString(value, "run artifact path")); + if (dirname(path) !== resolve(runsDir) || !path.endsWith(extension)) { + unknown("checkpoint artifact path is invalid"); + } + try { if (!lstatSync(path).isFile()) unknown("checkpoint artifact is not a regular file"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + return path; +} + +function writeSynced(path: string, text: string, append = false): void { + const fd = openSync(path, append ? "a" : "w", 0o600); + try { writeFileSync(fd, text, {encoding: "utf8"}); fsyncSync(fd); } + finally { closeSync(fd); } +} + +export async function commitCheckpoint(value: unknown): Promise { + const request = requireJsonObject(value, "checkpoint commit"); + const root = requireLocalAuthorityRuntimeRoot(request.runtime_root); + const binding = requireJsonObject(request.identity, "settlement identity"); + const identity = settlementIdentity({goal_id: goalPathSegment(binding.goal_id), agent_id: String(binding.agent_id), + todo_id: binding.todo_id == null ? null : String(binding.todo_id), + turn_instance_id: String(binding.turn_instance_id), + replan_obligation_id: binding.replan_obligation_id == null ? null : String(binding.replan_obligation_id)}); + if (canonicalAuthoritySha256(binding) !== canonicalAuthoritySha256(settlementIdentityPayload(identity))) { + throw new EffectRuntimeRequestError("checkpoint settlement identity is invalid"); + } + const runsDir = join(root, "goals", identity.goal_id, "runs"); + const indexPath = join(runsDir, "index.jsonl"); + const statePath = resolve(requireNonEmptyString(request.state_file, "state_file")); + const targets = [indexPath, shadowMaintenanceLockPath(root, identity.goal_id), + legacyCoordinationTodoLockPath(root, identity.goal_id), statePath].map(path => resolve(path)); + const retry = requireJsonObject(request.refresh_retry, "refresh retry"); + const receiptPath = join(root, "goals", identity.goal_id, "checkpoint-contexts", + `${createHash("sha256").update(identity.effect_id).digest("hex")}.json`); + + async function admission(): Promise { + indexBytes(indexPath); + return await readQuotaSettlement({schema_version: QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, + runtime_root: root, goal_id: identity.goal_id, agent_id: identity.agent_id, todo_id: identity.todo_id, + replan_obligation_id: identity.replan_obligation_id, turn_instance_id: identity.turn_instance_id, + infer_turn_instance_id: false, allow_unbound_binding: false, refresh_retry: retry}); + } + function replay(readback: JsonObject): JsonObject | null { + const recovery = requireJsonObject(readback.refresh_recovery, "refresh recovery"); + if (recovery.decision !== "replay") return null; + const prior = requireJsonObject(readback.writeback_run, "committed checkpoint"); + if (jsonObject(prior.vision_checkpoint)?.satisfied !== true || + jsonObject(prior.refresh_recovery)?.vision_request_digest !== recovery.vision_request_digest) { + unknown("checkpoint replay does not identify a committed decision"); + } + return committedArtifacts(prior, runsDir); + } + + const claims: {target: string; token: string; claim: FileMutationLockClaim}[] = []; + let adopted = false; + try { + if (!Array.isArray(request.locks) || request.locks.length !== targets.length) { + throw new EffectRuntimeRequestError("checkpoint requires the complete internal lock handoff"); + } + for (const [i, value] of request.locks.entries()) { + const witness = requireJsonObject(value, "lock witness"); + const token = requireNonEmptyString(witness.token, "lock token"); + if (resolve(String(witness.target)) !== targets[i]) throw new EffectRuntimeRequestError("checkpoint lock target mismatch"); + const claim = await claimFileMutationLock(targets[i], token); + if (!claim) break; + claims.push({target: targets[i], token, claim}); + const owner = await mutationLockOwner(targets[i]); + if (owner?.token !== token || owner.pid !== witness.pid) break; + if (i === targets.length - 1) adopted = true; + } + if (!adopted) { + // A runtime retry can arrive after a successful save released the locks. + // It may only read an exact committed result, never reuse the old handoff. + for (const entry of claims.splice(0).reverse()) await releaseFileMutationLockClaim(entry.claim); + return await withFileMutationLock(indexPath, async () => { + const result = replay(await admission()); + if (result) return result; + unknown("checkpoint lock handoff expired"); + }); + } + const readback = await admission(); + const repeated = replay(readback); + if (repeated) return repeated; + const recovery = requireJsonObject(readback.refresh_recovery, "refresh recovery"); + if (recovery.decision !== "supplement_checkpoint") { + throw new EffectRuntimeRequestError(String(recovery.reason ?? "checkpoint supplement rejected"), "checkpoint_commit_rejected"); + } + await requireShadowPrimaryWriteAllowed(root, identity.goal_id); + const facts = requireJsonObject(request.facts, "checkpoint facts"); + const source = requireJsonObject(facts.source, "checkpoint source"); + if (resolve(String(source.state_file)) !== statePath || resolve(String(source.runtime_root)) !== resolve(root)) { + throw new EffectRuntimeRequestError("checkpoint adapted source binding mismatch"); + } + const expectedIndex = requireNonEmptyString(request.index_sha256, "index digest"); + const expectedState = requireNonEmptyString(request.state_sha256, "state digest"); + return await withCheckpointAuthority(root, identity.goal_id, facts, current => { + // No await from final head read through append, including for SQLite. + if (digest(indexBytes(indexPath)) !== expectedIndex || digest(readFileSync(statePath)) !== expectedState) { + unknown("checkpoint sources changed during lock handoff"); + } + let receipt: unknown = null; + try { receipt = JSON.parse(readFileSync(receiptPath, "utf8")); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const context = evaluateCheckpointReadContext({phase: "check", identity: binding, + read_context_id: retry.checkpoint_read_context_id, receipt, facts: current}); + if (context.ok !== true) return context; + const record = requireJsonObject(request.record, "checkpoint record"); + const row = requireJsonObject(request.index_record, "checkpoint index record"); + for (const projected of [record, row]) { + if (canonicalAuthoritySha256(projected.settlement_identity) !== canonicalAuthoritySha256(binding) || + canonicalAuthoritySha256(projected.refresh_recovery) !== canonicalAuthoritySha256(recovery) || + jsonObject(projected.vision_checkpoint)?.satisfied !== true) { + throw new EffectRuntimeRequestError("checkpoint projection does not match typed admission"); + } + projected.vision_checkpoint = {...requireJsonObject(projected.vision_checkpoint, "vision checkpoint"), read_context: context}; + } + const jsonPath = runPath(row.json_path, runsDir, ".json"); + const markdownPath = runPath(row.markdown_path, runsDir, ".md"); + try { + writeSynced(jsonPath, JSON.stringify(record, null, 2) + "\n"); + writeSynced(markdownPath, requireNonEmptyString(request.markdown, "checkpoint Markdown")); + writeSynced(indexPath, JSON.stringify(row) + "\n", true); + } catch { unknown("checkpoint append outcome is uncertain"); } + return {ok: true, replayed: false, context, json_path: jsonPath, markdown_path: markdownPath}; + }); + } finally { + // The effect owns the end of the handed-off critical section. Releasing the + // markers here also handles a caller that timed out but is still alive. + for (const entry of claims.reverse()) { + if (adopted) await releaseFileMutationLock(entry.target, entry.token, entry.claim, true); + else await releaseFileMutationLockClaim(entry.claim); + } + } +} diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index 94dd727d5f..557b968fcd 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -8,19 +8,17 @@ from typing import Any, Iterator from uuid import uuid4 -from ...file_lock import exclusive_cross_runtime_file_lock, exclusive_file_lock +from ...file_lock import exclusive_cross_runtime_file_lock, exclusive_run_index_lock, cross_runtime_lock_witness from ...history import load_index, load_registry from ...paths import resolve_runtime_root from ...registry import atomic_write_json from ...runtime import validate_goal_id_path_segment from ..coordination.legacy_writer_fence import legacy_coordination_todo_lock_path -from ..coordination.local_authority import read_canonical_todos_if_promoted from ..coordination.shadow_management import shadow_maintenance_lock_target, require_shadow_primary_write_allowed -from ..effect_runtime import effect_runtime_result +from ..effect_runtime import effect_runtime_result, EffectRuntimeRejected from ..quota.settlement import SettlementIdentity, read_heartbeat_settlement from ..todos.active_state_todo_parser import parse_todo_source from ..todos.machine_region import find_todo_source_regions -from .acceptance import inspect_goal_acceptance from .active_state_metadata import split_state_frontmatter from .goal_frontier import latest_agent_vision_from_runs @@ -32,8 +30,16 @@ def __init__(self, result: dict[str, Any]) -> None: self.payload = {"checkpoint_read_context": result} +def _checkpoint_effect(method: str, request: dict[str, Any]) -> Any: + try: + return effect_runtime_result(method, request) + except EffectRuntimeRejected as error: + raise CheckpointReadContextRejected({"ok": False, "error": str(error), + "error_code": error.diagnostic_code, "reread_required": False}) from error + + def _evaluate(**request: Any) -> dict[str, Any]: - result = effect_runtime_result("goal.checkpoint_read_context.evaluate", request) + result = _checkpoint_effect("goal.checkpoint_read_context.evaluate", request) if not isinstance(result, dict) or not isinstance(result.get("ok"), bool): raise RuntimeError("invalid typed checkpoint read context result") if not result["ok"]: @@ -46,12 +52,25 @@ def _receipt_path(root: Path, identity: SettlementIdentity) -> Path: return root / "goals" / identity.goal_id / "checkpoint-contexts" / f"{digest}.json" +def require_complete_checkpoint_index(index: Path) -> None: + """Framing check before replay too; typed settlement validates the rows.""" + try: + content = index.read_bytes() + except FileNotFoundError: + return + if content and not content.endswith(b"\n"): + raise CheckpointReadContextRejected({ + "ok": False, "error_code": "checkpoint_commit_unknown", "reread_required": False, + "error": "checkpoint index has an incomplete tail; inspect the original Turn before retrying", + }) + + @contextmanager def _source_guard(root: Path, goal_id: str, state_file: Path) -> Iterator[None]: """Caller holds runs/index first. Match promotion's M -> Todo -> state order. - Canonical local writers hold M; legacy Todo writers hold Todo/state; prose - writers hold state. Hold all three until the checkpoint index row is appended. + M prevents source cutover; it does not exclude canonical provider commits. + The native commit additionally fences the real provider through its append. Do not run projection sync or a new state mutation inside this guard. """ with ExitStack() as locks: @@ -65,42 +84,37 @@ def _source_guard(root: Path, goal_id: str, state_file: Path) -> Iterator[None]: yield -def _source_facts( +def _local_source_facts( root: Path, registry_path: Path, state_file: Path, identity: SettlementIdentity, ) -> dict[str, Any]: - # The existing provider adapter fails closed after cutover. Never repair or - # fall back to stale Markdown when a selected provider cannot answer. - canonical = read_canonical_todos_if_promoted(runtime_root=root, goal_id=identity.goal_id) text = state_file.read_text(encoding="utf-8") metadata, body = split_state_frontmatter(text) lines = body.splitlines() regions = find_todo_source_regions(lines) owned = {i for region in regions for i in range(region.start, region.end)} prose = "\n".join(line for i, line in enumerate(lines) if i not in owned).strip() - acceptance = None - if canonical is None: - active, archived, _ = parse_todo_source(text) - todos = [*active["user"], *active["agent"], *archived] - else: - todos = canonical["todos"] - inspected = inspect_goal_acceptance( - registry_path=registry_path, runtime_root=str(root), goal_id=identity.goal_id, - agent_id=identity.agent_id, - ) - # Exclude verifier observations and unrelated provider commits; retain - # the owner revision and the complete actual acceptance document. - acceptance = {key: inspected.get(key) for key in ("revision", "contract_digest", "contract")} + active, archived, _ = parse_todo_source(text) + todos = [*active["user"], *active["agent"], *archived] runs, _ = load_index(root / "goals" / identity.goal_id / "runs" / "index.jsonl") newest = [run for _, run in sorted(enumerate(runs), key=lambda pair: (str(pair[1].get("generated_at") or ""), pair[0]), reverse=True)] return { - "todos": todos, "frontmatter": metadata, "goal_prose": prose, "acceptance": acceptance, + "todos": todos, "frontmatter": metadata, "goal_prose": prose, "acceptance": None, "agent_vision": latest_agent_vision_from_runs(newest, goal_id=identity.goal_id, agent_id=identity.agent_id), "source": {"state_file": str(state_file.resolve()), "runtime_root": str(root.resolve()), - "authority": canonical["source_authority"] if canonical else "legacy_markdown"}, + "authority": "legacy_markdown"}, } +def _source_facts(root: Path, registry_path: Path, state_file: Path, identity: SettlementIdentity) -> dict[str, Any]: + # The typed owner derives Todo and complete acceptance from one head and + # fails closed after cutover. Local parsed Markdown cannot override it. + return _checkpoint_effect("goal.checkpoint_read_context.source", { + "runtime_root": str(root.resolve()), "goal_id": identity.goal_id, + "facts": _local_source_facts(root, registry_path, state_file, identity), + }) + + def read_checkpoint_context( *, registry_path: Path, runtime_root_override: str | None, goal_id: str, agent_id: str, todo_id: str | None, turn_instance_id: str, @@ -117,7 +131,8 @@ def read_checkpoint_context( project_override=project, state_file_override=state_file) if agent_id not in registered_agents_for_goal(goal): raise ValueError("checkpoint-context requires a registered Agent") - with exclusive_file_lock(root / "goals" / goal_id / "runs" / "index.jsonl", operation="checkpoint-context"): + with exclusive_run_index_lock(root / "goals" / goal_id / "runs" / "index.jsonl", operation="checkpoint-context"): + require_complete_checkpoint_index(root / "goals" / goal_id / "runs" / "index.jsonl") readback = read_heartbeat_settlement(root, goal_id=goal_id, agent_id=agent_id, todo_id=todo_id, turn_instance_id=turn_instance_id, replan_obligation_id=replan_obligation_id) if readback is None or readback.identity.value is None or readback.writeback_run is None: @@ -141,7 +156,8 @@ def checkpoint_commit_guard( *, runtime_root: Path, registry_path: Path, state_file: Path, identity: SettlementIdentity, read_context_id: str | None, ) -> Iterator[dict[str, Any]]: - """Compare and append under the same source locks, never check then unlock.""" + """Capture/preview under source locks. The native save repeats the check + under the real provider fence; this preliminary check is not the commit.""" with _source_guard(runtime_root, identity.goal_id, state_file): try: receipt = json.loads(_receipt_path(runtime_root, identity).read_text(encoding="utf-8")) @@ -152,6 +168,37 @@ def checkpoint_commit_guard( yield result +def commit_checkpoint_run( + *, runtime_root: Path, registry_path: Path, state_file: Path, identity: SettlementIdentity, + refresh_retry: dict[str, Any], record: dict[str, Any], index_record: dict[str, Any], markdown: str, +) -> dict[str, Any]: + """Handoff the held locks and parsed bytes to one native save operation.""" + root = runtime_root.resolve() + index = root / "goals" / identity.goal_id / "runs" / "index.jsonl" + targets = (index, shadow_maintenance_lock_target(root, identity.goal_id), + legacy_coordination_todo_lock_path(runtime_root=root, goal_id=identity.goal_id), state_file) + result = _checkpoint_effect("goal.checkpoint_read_context.commit", { + "runtime_root": str(root), "state_file": str(state_file.resolve()), "identity": identity.as_dict(), + "locks": [cross_runtime_lock_witness(target) for target in targets], + "state_sha256": hashlib.sha256(state_file.read_bytes()).hexdigest(), + "index_sha256": hashlib.sha256(index.read_bytes()).hexdigest(), + "facts": _local_source_facts(root, registry_path, state_file, identity), + "refresh_retry": refresh_retry, "record": record, "index_record": index_record, "markdown": markdown, + }) + if not isinstance(result, dict) or not isinstance(result.get("ok"), bool): + raise RuntimeError("invalid typed checkpoint commit result") + if not result["ok"]: + raise CheckpointReadContextRejected(result) + return result + + +def inspect_checkpoint_replay(runtime_root: Path, goal_id: str, prior: dict[str, Any]) -> None: + if isinstance(prior.get("vision_checkpoint"), dict) and prior["vision_checkpoint"].get("read_context"): + _checkpoint_effect("goal.checkpoint_read_context.inspect_replay", { + "runtime_root": str(runtime_root.resolve()), "goal_id": goal_id, "prior": prior, + }) + + def render_checkpoint_context(payload: dict[str, Any]) -> str: # The decision basis is private local state, not a public/global projection. return "# LoopX Checkpoint Context\n\n```json\n" + json.dumps(payload, ensure_ascii=False, indent=2) + "\n```" diff --git a/loopx/control_plane/goals/checkpoint_read_context.ts b/loopx/control_plane/goals/checkpoint_read_context.ts index 95c2ee5819..ebaaad1478 100644 --- a/loopx/control_plane/goals/checkpoint_read_context.ts +++ b/loopx/control_plane/goals/checkpoint_read_context.ts @@ -1,11 +1,11 @@ /** Read basis for a missing-checkpoint supplement, not an execution/permission lease. - * The host holds the Goal source writer locks through the checkpoint append. */ + * The commit effect holds source/index claims and the real provider fence. */ import type {JsonObject} from "../effect_program.ts"; import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; -const RECEIPT_SCHEMA = "checkpoint_read_context_v0"; +const RECEIPT_SCHEMA = "checkpoint_read_context_v1"; // Exact presentation fields only. Unknown future fields remain part of the basis. const DISPLAY_FIELDS = new Set(["index", "source_section", "schema_version"]); const todoFacts = (todo: JsonObject): JsonObject => Object.fromEntries( @@ -69,7 +69,8 @@ function snapshot(request: JsonObject): JsonObject { agent_vision: facts.agent_vision, source: facts.source, }; - return {basis, versions: Object.fromEntries(Object.entries(basis).map(([key, value]) => + return {basis, provider_revision: facts.provider_revision ?? null, + versions: Object.fromEntries(Object.entries(basis).map(([key, value]) => [key, canonicalAuthoritySha256(value)]))}; } @@ -94,7 +95,7 @@ export function evaluateCheckpointReadContext(value: unknown): JsonObject { return {ok: true, ...projected, receipt: { schema_version: RECEIPT_SCHEMA, read_context_id: requireNonEmptyString(token, "read_context_id"), identity, dependency_todo_ids: ids(request.dependency_todo_ids, "dependency_todo_ids"), - versions: projected.versions, + versions: projected.versions, provider_revision: projected.provider_revision, }}; } if (request.phase !== "check") throw new EffectRuntimeRequestError("unknown checkpoint read context phase"); diff --git a/loopx/control_plane/heartbeat/rules.py b/loopx/control_plane/heartbeat/rules.py index 3ba3b091c2..dcea72c7e0 100644 --- a/loopx/control_plane/heartbeat/rules.py +++ b/loopx/control_plane/heartbeat/rules.py @@ -23,7 +23,7 @@ ) HEARTBEAT_VISION_WRITEBACK_RULE_SHORT = ( "本轮精确monitor-poll提交→不refresh/spend;" - "其余no-change=surface_only/no spend;material=outcome+vision;" + "其余no-change=surface_only/no spend;writeback material=outcome+vision;" "缺则同轮checkpoint-context重判,按凭据仅补vision;" "过期重读;unchanged→真实--vision-unchanged-reason。" ) diff --git a/loopx/control_plane/runtime/runtime_projection_writer.py b/loopx/control_plane/runtime/runtime_projection_writer.py index bd6b0e789a..cfb3bee440 100644 --- a/loopx/control_plane/runtime/runtime_projection_writer.py +++ b/loopx/control_plane/runtime/runtime_projection_writer.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Any, Callable -from ...file_lock import exclusive_file_lock +from ...file_lock import exclusive_run_index_lock from ...history import load_index, reserve_unique_run_paths from .time import now_local_iso @@ -44,7 +44,7 @@ def write_compact_runtime_projection( runs_dir = target_runtime_root / "goals" / goal_id / "runs" index_path = runs_dir / "index.jsonl" - with exclusive_file_lock(index_path): + with exclusive_run_index_lock(index_path, operation="runtime_projection_append"): existing, _ = load_index(index_path) for item in existing: item_marker = item.get(marker_field) diff --git a/loopx/feedback.py b/loopx/feedback.py index 1efa077a87..f7aed55a7e 100644 --- a/loopx/feedback.py +++ b/loopx/feedback.py @@ -472,7 +472,9 @@ def append_human_reward( 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: + # Match refresh/history: index first, then the source state lock. + from .file_lock import exclusive_run_index_lock + with exclusive_run_index_lock(index_path, operation="reward_append"), 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( diff --git a/loopx/file_lock.py b/loopx/file_lock.py index de73e97bcb..b5272b7da1 100644 --- a/loopx/file_lock.py +++ b/loopx/file_lock.py @@ -886,8 +886,20 @@ def release_cross_runtime_mutation_lock(path: Path, *, token: str) -> bool: ) +def cross_runtime_lock_witness(path: Path) -> dict[str, object]: + """Internal handoff of a lock held by this process, never an Agent token. + + The native effect must claim and recheck it before using adapted facts. + Its final save owns release; Python's later release is token-checked. + """ + owner = _read_effect_mutation_owner(_effect_mutation_lock_path(path)) + if owner is None or owner.get("pid") != os.getpid(): + raise RuntimeError("checkpoint handoff requires the caller's held mutation lock") + return {"target": str(path.resolve()), **owner} + + @contextmanager -def exclusive_cross_runtime_file_lock( +def exclusive_mutation_file_lock( path: Path, *, policy: LockAcquisitionPolicy | str = LockAcquisitionPolicy.MUTATION, @@ -896,13 +908,7 @@ def exclusive_cross_runtime_file_lock( agent_id: str | None = None, operation: str | None = None, ) -> Iterator[Path]: - """Hold the TypeScript mutation lock, then the existing Python lock. - - This is a bounded migration lock for state whose writers span both - runtimes. TypeScript coordinates through exclusive creation of - ``.ts-effect.lock``; Python keeps its kernel lock underneath so - existing diagnostics and Python-to-Python exclusion remain unchanged. - """ + """Hold the existing TypeScript mutation marker and its token/claim protocol.""" selected_policy = _policy(policy) defaults = LOCK_POLICIES[selected_policy] @@ -966,15 +972,7 @@ def exclusive_cross_runtime_file_lock( break try: - with exclusive_file_lock( - path, - policy=selected_policy, - timeout_seconds=timeout, - poll_interval_seconds=poll_interval, - agent_id=agent_id, - operation=operation, - ) as lock_path: - yield lock_path + yield effect_lock_path finally: _release_effect_mutation_lock( effect_lock_path, @@ -984,3 +982,35 @@ def exclusive_cross_runtime_file_lock( # original exception; stale-owner recovery handles a later retry. suppress_errors=True, ) + + +@contextmanager +def exclusive_cross_runtime_file_lock( + path: Path, + *, + policy: LockAcquisitionPolicy | str = LockAcquisitionPolicy.MUTATION, + timeout_seconds: float | None = None, + poll_interval_seconds: float | None = None, + agent_id: str | None = None, + operation: str | None = None, +) -> Iterator[Path]: + """Source writers retain their existing order: mutation marker, then kernel.""" + options = dict(policy=policy, timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, agent_id=agent_id, operation=operation) + with exclusive_mutation_file_lock(path, **options): + with exclusive_file_lock(path, **options) as lock_path: + yield lock_path + + +@contextmanager +def exclusive_run_index_lock(path: Path, *, operation: str) -> Iterator[Path]: + """Goal indexes use kernel then marker, matching existing quota adapters. + + Native writers take only the marker, never the kernel lock. Python callers + must enter here before any source lock; no index path may use the reverse + order from exclusive_cross_runtime_file_lock. A native checkpoint effect + claims the marker until append completes, including after caller exit. + """ + with exclusive_file_lock(path, operation=operation) as lock_path: + with exclusive_mutation_file_lock(path, operation=operation): + yield lock_path diff --git a/loopx/history.py b/loopx/history.py index 983e5bf1ae..0fba9dc84e 100644 --- a/loopx/history.py +++ b/loopx/history.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Any -from .file_lock import exclusive_file_lock +from .file_lock import exclusive_run_index_lock from .authority import goal_authority_registry_summary from .control_plane import compact_control_plane_policy from .control_plane.goals.activation import ( @@ -175,7 +175,7 @@ def write_reserved_run_artifacts( ingest_usage_into_run_record(record, index_record=index_record) # GH-C07: one lock per goal history index, shared with the repair path. index_path = runs_dir / "index.jsonl" - with exclusive_file_lock(index_path, operation="history_run_append"): + with exclusive_run_index_lock(index_path, operation="history_run_append"): json_path, markdown_path = reserve_unique_run_paths(runs_dir, generated_at) index_record["json_path"] = str(json_path) index_record["markdown_path"] = str(markdown_path) @@ -683,7 +683,7 @@ def repair_index_duplicates( # GH-C07: read and rewrite the index under the same lock the append # path takes. A dry run only reports, so it must not block writers. lock = ( - exclusive_file_lock(index_path, operation="history_index_repair") + exclusive_run_index_lock(index_path, operation="history_index_repair") if execute else nullcontext() ) diff --git a/loopx/operator_gate.py b/loopx/operator_gate.py index 7a9d709094..562c93920f 100644 --- a/loopx/operator_gate.py +++ b/loopx/operator_gate.py @@ -395,11 +395,13 @@ def record_operator_gate( **record, } if not dry_run: - runs_dir.mkdir(parents=True, exist_ok=True) - json_path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - markdown_path.write_text(render_operator_gate_markdown(payload) + "\n", encoding="utf-8") - with index_path.open("a", encoding="utf-8") as f: - f.write(json.dumps(index_record, ensure_ascii=False) + "\n") + from .file_lock import exclusive_run_index_lock + with exclusive_run_index_lock(index_path, operation="operator_gate_append"): + runs_dir.mkdir(parents=True, exist_ok=True) + json_path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + markdown_path.write_text(render_operator_gate_markdown(payload) + "\n", encoding="utf-8") + with index_path.open("a", encoding="utf-8") as f: + f.write(json.dumps(index_record, ensure_ascii=False) + "\n") projection_result = finalize_material_projection( registry_path=registry_path, source_runtime_root=runtime_root, diff --git a/loopx/project_map.py b/loopx/project_map.py index d552376140..b43e821fc0 100644 --- a/loopx/project_map.py +++ b/loopx/project_map.py @@ -564,11 +564,10 @@ def read_only_project_map_run( **record, } if not dry_run: - runs_dir.mkdir(parents=True, exist_ok=True) - json_path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - markdown_path.write_text(render_read_only_project_map_markdown(payload) + "\n", encoding="utf-8") - with index_path.open("a", encoding="utf-8") as f: - f.write(json.dumps(index_record, ensure_ascii=False) + "\n") + from .history import write_reserved_run_artifacts + write_reserved_run_artifacts(runs_dir=runs_dir, generated_at=generated_at, + record=record, index_record=index_record, payload=payload, + render_markdown=render_read_only_project_map_markdown) projection_result = finalize_material_projection( registry_path=registry_path, source_runtime_root=runtime_root, diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index 865cb8491b..7301e9fd31 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -92,7 +92,10 @@ prepare_vision_refresh, ) from .control_plane.goals.goal_frontier import latest_agent_vision_from_runs -from .control_plane.goals.checkpoint_context_io import checkpoint_commit_guard +from .control_plane.goals.checkpoint_context_io import ( + checkpoint_commit_guard, commit_checkpoint_run, require_complete_checkpoint_index, inspect_checkpoint_replay, +) +from .file_lock import exclusive_run_index_lock from .registry import registry_goals, resolve_state_file from .runtime import validate_goal_id_path_segment from .state_projection import ( @@ -880,7 +883,7 @@ def refresh_state_run( runtime_root = resolve_runtime_root(registry, runtime_root_override, registry_path=registry_path) # State-dependent admission through the final append remains serialized. # Only pure input validation runs before this transitional persistence lock. - with (nullcontext() if dry_run else exclusive_file_lock( + with (nullcontext() if dry_run else exclusive_run_index_lock( runtime_root / "goals" / safe_goal_id / "runs" / "index.jsonl", operation="refresh-state" )): settlement_identity = None @@ -892,6 +895,8 @@ def refresh_state_run( prior_writeback_run = None checkpoint_supplement = False if todo_id or normalized_replan_obligation_id or turn_instance_id: + if checkpoint_read_context_id or agent_vision_packet or vision_unchanged_reason: + require_complete_checkpoint_index(runtime_root / "goals" / safe_goal_id / "runs" / "index.jsonl") if not turn_scoped_settlement_qualified: raise ValueError( TURN_SCOPED_SETTLEMENT_REQUIREMENT + ": " + turn_scoped_settlement_gap @@ -903,7 +908,7 @@ def refresh_state_run( todo_id=todo_id, turn_instance_id=turn_instance_id, replan_obligation_id=normalized_replan_obligation_id, - refresh_retry={ + refresh_retry=(refresh_retry_request := { "checkpoint_read_context_id": checkpoint_read_context_id, "external_delivery": external_delivery, "vision": agent_vision_packet, @@ -923,7 +928,7 @@ def refresh_state_run( "delivery_batch_scale": normalized_delivery_batch_scale, "delivery_boundary": normalized_delivery_boundary, "progress_observation": normalized_progress_observation, - }, + }), ) if settlement_readback is None: raise RuntimeError("exact settlement readback unexpectedly returned not-found") @@ -941,6 +946,8 @@ def refresh_state_run( checkpoint_supplement = bool( refresh_recovery["decision"] == "supplement_checkpoint" ) + if refresh_recovery.get("decision") in {"replay", "repair_receipt"} and prior_writeback_run: + inspect_checkpoint_replay(runtime_root, safe_goal_id, prior_writeback_run) recovery_payload = refresh_recovery_payload( settlement_readback, registry_path=registry_path, runtime_root=runtime_root, goal_id=safe_goal_id, dry_run=dry_run, @@ -1421,13 +1428,25 @@ def refresh_state_run( index_record["markdown_path"] = str(markdown_path) payload["json_path"] = str(json_path) payload["markdown_path"] = str(markdown_path) - json_path.write_text( - json.dumps(record, ensure_ascii=False, indent=2, allow_nan=False) + "\n", - encoding="utf-8", - ) - markdown_path.write_text(render_state_refresh_markdown(payload) + "\n", encoding="utf-8") - with index_path.open("a", encoding="utf-8") as f: - f.write(json.dumps(index_record, ensure_ascii=False, allow_nan=False) + "\n") + if checkpoint_supplement: + saved = commit_checkpoint_run(runtime_root=runtime_root, registry_path=registry_path, + state_file=resolved_state_file, identity=settlement_identity, + refresh_retry=refresh_retry_request, record=record, index_record=index_record, + markdown=render_state_refresh_markdown(payload) + "\n") + for projection in (record, index_record, payload): + projection["vision_checkpoint"]["read_context"] = saved["context"] + for projection in (index_record, payload): + projection.update({key: saved[key] for key in ("json_path", "markdown_path")}) + if saved["replayed"]: + payload.update(appended=False, idempotent_replay=True) + else: + json_path.write_text( + json.dumps(record, ensure_ascii=False, indent=2, allow_nan=False) + "\n", + encoding="utf-8", + ) + markdown_path.write_text(render_state_refresh_markdown(payload) + "\n", encoding="utf-8") + with index_path.open("a", encoding="utf-8") as f: + f.write(json.dumps(index_record, ensure_ascii=False, allow_nan=False) + "\n") if sync_global and route_status in {"missing", "ambiguous"}: payload["ok"] = False payload["partial_write"] = not dry_run From f6f435f913b8fd9016749edef26ed69f1f2bde16 Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 23 Sep 2026 09:59:26 +0800 Subject: [PATCH 07/11] test(checkpoint): qualify provider fences and crash-safe lock handoff Signed-off-by: Tartar --- tests/control_plane/checkpoint_process.py | 109 ++++++ .../test_checkpoint_provider_fence.py | 314 ++++++++++++++++++ .../checkpoint_commit_probe.ts | 77 +++++ .../checkpoint_provider_head.test.ts | 61 ++++ .../checkpoint_read_context.test.ts | 10 + .../test_history_index_write_serialization.py | 6 +- tsconfig.control-plane.json | 2 + 7 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 tests/control_plane/checkpoint_process.py create mode 100644 tests/control_plane/test_checkpoint_provider_fence.py create mode 100644 tests/control_plane_ts/checkpoint_commit_probe.ts create mode 100644 tests/control_plane_ts/checkpoint_provider_head.test.ts diff --git a/tests/control_plane/checkpoint_process.py b/tests/control_plane/checkpoint_process.py new file mode 100644 index 0000000000..32f07f2759 --- /dev/null +++ b/tests/control_plane/checkpoint_process.py @@ -0,0 +1,109 @@ +"""Process driver for public writer and caller-exit checkpoint regressions.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +import time + +REPO = Path(__file__).resolve().parents[2] +PROBE = REPO / "tests/control_plane_ts/checkpoint_commit_probe.ts" + + +def start_probe(request: dict, output=None): + child = subprocess.Popen(["node", "--no-warnings", "--experimental-strip-types", str(PROBE)], + stdin=subprocess.PIPE, stdout=output or subprocess.PIPE, stderr=subprocess.PIPE, text=True, + encoding="utf-8", cwd=REPO) + child.stdin.write(json.dumps(request)) + child.stdin.close() + child.stdin = None + return child + + +def wait_for(path: Path, child=None, timeout=20): + deadline = time.monotonic() + timeout + while not path.exists(): + if child is not None and child.poll() is not None: + raise AssertionError(child.communicate()) + if time.monotonic() >= deadline: + raise AssertionError(f"test barrier timed out: {path.name}") + time.sleep(0.01) + + +def refresh(registry, runtime, token): + from loopx.state_refresh import refresh_state_run + from tests.control_plane.test_quota_settlement_cli import GOAL_ID, AGENT_ID, TODO_ID, TURN_ID + return refresh_state_run(registry_path=Path(registry), runtime_root_override=str(runtime), + goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID, + project=None, state_file=None, classification="validated_change", recommended_action=None, + delivery_batch_scale="implementation", delivery_outcome="outcome_progress", + vision_unchanged_reason="The current basis remains applicable.", + checkpoint_read_context_id=token, dry_run=False, sync_global=False, + external_delivery={"suppress": True, "resume_key": None}) + + +def main(request): + from loopx.control_plane.todos import provider_update + from loopx.control_plane.goals import checkpoint_context_io + from tests.control_plane.test_quota_settlement_cli import GOAL_ID, AGENT_ID, TODO_ID + barrier = Path(request["barrier"]) + mode = request["mode"] + if mode == "reader": + from loopx.control_plane.goals.checkpoint_context_io import read_checkpoint_context, CheckpointReadContextRejected + from tests.control_plane.test_quota_settlement_cli import TURN_ID + try: + result = read_checkpoint_context(registry_path=Path(request["registry"]), + runtime_root_override=request["runtime"], goal_id=GOAL_ID, agent_id=AGENT_ID, + todo_id=TODO_ID, turn_instance_id=TURN_ID) + except CheckpointReadContextRejected as error: + result = {"ok": False, "error_code": error.code} + print(json.dumps(result)) + return 0 + if mode == "append": + from loopx.history import write_reserved_run_artifacts + row = {"goal_id": GOAL_ID, "generated_at": "2026-01-02T00:00:00+00:00", "classification": "synthetic_observation"} + write_reserved_run_artifacts(runs_dir=Path(request["runtime"]) / "goals" / GOAL_ID / "runs", + generated_at=row["generated_at"], record=row.copy(), index_record=row.copy(), payload={}, + render_markdown=lambda _: "Synthetic observation") + print(json.dumps({"ok": True})) + return 0 + adapter = provider_update if mode == "writer" else checkpoint_context_io + original = adapter.effect_runtime_result + + def native(method, params): + target = "coordination.local_authority.todo_update" if mode == "writer" else "goal.checkpoint_read_context.commit" + if method != target: + return original(method, params) + envelope = {"mode": "writer" if mode == "writer" else "checkpoint", "barrier": str(barrier), + "provider": request["provider"], "method": method, "params": params, + "provider_direct": request.get("provider_direct", False)} + if mode == "caller-exit": + output = (barrier / "orphan-result").open("w", encoding="utf-8") + child = start_probe(envelope, output=output) + wait_for(barrier / "head-read", child) + (barrier / "orphan-pid").write_text(str(child.pid)) + # Deliberately bypass context manager cleanup. The native claims + # must keep excluding a new index reader and receipt replacement. + os._exit(0) + child = start_probe(envelope) + stdout, stderr = child.communicate(timeout=30) + assert child.returncode == 0, stdout + stderr + return json.loads(stdout) + + adapter.effect_runtime_result = native + if mode == "writer": + # Use the public CLI, including its registry adaptation and projection + # settlement. Only the native process is instrumented at its real CAS. + from loopx.cli import main as cli + return cli(["--registry", request["registry"], "--format", "json", "todo", "update", + "--goal-id", GOAL_ID, "--todo-id", request.get("todo_id", TODO_ID), + "--agent-id", AGENT_ID, "--note", "peer-result-v2", + "--task-lease-idempotency-key", f"checkpoint-{request.get('todo_id', TODO_ID)}", + "--task-lease-expected-version", "1"]) + refresh(request["registry"], request["runtime"], request["token"]) + + +if __name__ == "__main__": + raise SystemExit(main(json.loads(sys.argv[1]))) diff --git a/tests/control_plane/test_checkpoint_provider_fence.py b/tests/control_plane/test_checkpoint_provider_fence.py new file mode 100644 index 0000000000..9a099afae4 --- /dev/null +++ b/tests/control_plane/test_checkpoint_provider_fence.py @@ -0,0 +1,314 @@ +"""Real File/SQLite and public writers, not a shadow-maintenance lock probe.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +import time + +import pytest +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +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.goals import checkpoint_context_io as context_io +from tests.control_plane.test_checkpoint_read_context import _missing +from tests.control_plane.test_quota_settlement_cli import GOAL_ID, AGENT_ID, TODO_ID, TURN_ID, _run_cli, _spend_run_count +from tests.control_plane.checkpoint_process import REPO, start_probe, wait_for, refresh + + +def fixture(tmp_path, monkeypatch, provider): + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry, binding, delivery, original = _missing(tmp_path) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + todos = [{"schema_version": "todo_item_v0", "todo_id": name, "index": index, + "role": "agent", "status": "open", "done": False, "text": f"Synthetic page work {name}", + "task_class": "advancement_task", "archive_state": "active", "source_section": "Agent Todo", + "claimed_by": AGENT_ID} for index, name in enumerate((TODO_ID, "todo_dependency", "todo_unrelated"), 1)] + projection = build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=todos, handoff_mode="hard_lease", leases=[]) + initialize_canonical_authority(runtime, GOAL_ID, projection, state_path=state, provider=provider) + for todo in todos: + rc, lease = _run_cli(registry, runtime, "task-lease", "acquire", "--goal-id", GOAL_ID, + "--todo-id", todo["todo_id"], "--owner", AGENT_ID, + "--idempotency-key", f"checkpoint-{todo['todo_id']}", "--expected-version", "0", + "--ttl-seconds", "600", "--write-scope", f"src/{todo['todo_id']}/**", cwd=project) + assert rc == 0, lease + def read(): + return context_io.read_checkpoint_context(registry_path=registry, runtime_root_override=str(runtime), + goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID, + dependency_todo_ids=["todo_dependency"]) + return project, runtime, registry, state, read, original + + +def public_writer(registry, runtime, barrier, provider, todo_id=TODO_ID, *, provider_direct=False): + return subprocess.Popen([sys.executable, "-m", "tests.control_plane.checkpoint_process", json.dumps({ + "mode": "writer", "registry": str(registry), "runtime": str(runtime), "barrier": str(barrier), + "provider": provider, "todo_id": todo_id, "provider_direct": provider_direct})], cwd=REPO, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, encoding="utf-8", env={**os.environ, "PYTHONPATH": str(REPO)}) + + +def finish(child): + stdout, stderr = child.communicate(timeout=30) + assert child.returncode == 0, stdout + stderr + result = json.loads(stdout) + assert result["ok"], result + return result + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_public_update_before_final_read_rejects_then_reread_succeeds(tmp_path, monkeypatch, provider): + _, runtime, registry, _, read, original = fixture(tmp_path, monkeypatch, provider) + context = read() + index = runtime / f"goals/{GOAL_ID}/runs/index.jsonl" + before = index.read_bytes() + barrier = tmp_path / "writer" + barrier.mkdir() + finish(public_writer(registry, runtime, barrier, provider, "todo_dependency")) + current = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) + assert next(todo for todo in current["todos"] if todo["todo_id"] == "todo_dependency")["note"] == "peer-result-v2" + with pytest.raises(context_io.CheckpointReadContextRejected) as rejected: + refresh(registry, runtime, context["read_context_id"]) + assert rejected.value.code == "checkpoint_read_context_stale" + assert "dependencies" in rejected.value.payload["checkpoint_read_context"]["changed_components"] + assert index.read_bytes() == before + fresh = read() + assert fresh["provider_revision"] != context["provider_revision"] + unrelated = tmp_path / "unrelated" + unrelated.mkdir() + finish(public_writer(registry, runtime, unrelated, provider, "todo_unrelated")) + result = refresh(registry, runtime, fresh["read_context_id"]) + assert result["vision_checkpoint"]["satisfied"] and result["appended"] + after = index.read_bytes() + replay = refresh(registry, runtime, fresh["read_context_id"]) + assert replay["idempotent_replay"] and index.read_bytes() == after + assert result["settlement_identity"] == original["settlement_identity"] + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_existing_cli_maintenance_guard_precedes_provider_commit(tmp_path, monkeypatch, provider): + """Characterize the reviewed head accurately: normal CLI already holds M.""" + _, runtime, registry, state, _, _ = fixture(tmp_path, monkeypatch, provider) + barrier = tmp_path / "cli-guard" + barrier.mkdir() + before = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID)["provider_revision"] + with context_io._source_guard(runtime, GOAL_ID, state): + writer = public_writer(registry, runtime, barrier, provider) + stdout, stderr = writer.communicate(timeout=15) + assert writer.returncode == 1, stdout + stderr + assert "lock timed out" in json.loads(stdout)["error"] + assert not (barrier / "writer-entered").exists() + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID)["provider_revision"] == before + assert finish(public_writer(registry, runtime, barrier, provider))["ok"] + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_provider_transaction_cannot_commit_between_final_head_and_checkpoint(tmp_path, monkeypatch, provider): + _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider) + context = read() + barrier = tmp_path / "barrier" + barrier.mkdir() + original = context_io.effect_runtime_result + observed = [] + + def native(method, params): + if method != "goal.checkpoint_read_context.commit": + return original(method, params) + checkpoint = start_probe({"mode": "checkpoint", "provider": provider, "barrier": str(barrier), + "params": params, "repeat": True}) + writer = None + try: + wait_for(barrier / "head-read", checkpoint) + writer = public_writer(registry, runtime, barrier, provider, provider_direct=True) + wait_for(barrier / "writer-entered", writer) + # Observe canonical storage independently while the writer is inside + # its actual commit method. A blocked projection/CLI is not proof. + time.sleep(0.1) + current = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) + held = json.loads((barrier / "head-read").read_text()) + assert current["provider_revision"] == held["provider_revision"] + assert not (barrier / "provider-result").exists() + observed.append(current["provider_revision"]) + (barrier / "release").touch() + stdout, stderr = checkpoint.communicate(timeout=20) + assert checkpoint.returncode == 0, stdout + stderr + saved = json.loads(stdout) + assert saved["ok"] and not saved["replayed"] + assert json.loads((barrier / "runtime-replay").read_text())["replayed"] + finish(writer) + assert json.loads((barrier / "provider-result").read_text())["status"] == "applied" + return saved + finally: + (barrier / "release").touch() + for child in (checkpoint, writer): + if child is not None and child.poll() is None: + child.kill() + child.communicate() + + monkeypatch.setattr(context_io, "effect_runtime_result", native) + result = refresh(registry, runtime, context["read_context_id"]) + assert result["vision_checkpoint"]["satisfied"] and len(observed) == 1 + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID)["provider_revision"] != observed[0] + rows = [json.loads(line) for line in (runtime / f"goals/{GOAL_ID}/runs/index.jsonl").read_text().splitlines()] + assert sum(row.get("vision_checkpoint", {}).get("read_context", {}).get("read_context_id") == context["read_context_id"] for row in rows) == 1 + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_provider_commit_after_python_check_is_revalidated_at_save(tmp_path, monkeypatch, provider): + """Provider contract: a transaction without the CLI's outer M protection. + + The reviewed implementation accepts this old checkpoint. The native save + must observe the committed update and reject. Ordinary CLI writers already + hold M before committing; this test does not pretend to reproduce that path. + """ + from loopx import state_refresh + _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider) + token = read()["read_context_id"] + index = runtime / f"goals/{GOAL_ID}/runs/index.jsonl" + before = index.read_bytes() + barrier = tmp_path / "race" + barrier.mkdir() + reserve = state_refresh.reserve_unique_run_paths + writers = [] + + def race(*args): + writer = public_writer(registry, runtime, barrier, provider, provider_direct=True) + writers.append(writer) + wait_for(barrier / "provider-result", writer) + assert json.loads((barrier / "provider-result").read_text())["status"] == "applied" + current = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) + assert next(todo for todo in current["todos"] if todo["todo_id"] == TODO_ID)["note"] == "peer-result-v2" + return reserve(*args) + + monkeypatch.setattr(state_refresh, "reserve_unique_run_paths", race) + try: + with pytest.raises(context_io.CheckpointReadContextRejected) as rejected: + refresh(registry, runtime, token) + assert rejected.value.code == "checkpoint_read_context_stale" + assert index.read_bytes() == before + finally: + for writer in writers: + finish(writer) + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("fault", ["throw", "kill"]) +def test_failed_save_releases_provider_and_requires_fresh_comparison(tmp_path, monkeypatch, provider, fault): + _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider) + token = read()["read_context_id"] + barrier = tmp_path / "fault" + barrier.mkdir() + original = context_io.effect_runtime_result + + def native(method, params): + if method != "goal.checkpoint_read_context.commit": + return original(method, params) + child = start_probe({"mode": "checkpoint", "provider": provider, "barrier": str(barrier), + "params": params, "fault": fault}) + try: + wait_for(barrier / "head-read", child) + if fault == "kill": + child.kill() + else: + (barrier / "release").touch() + stdout, stderr = child.communicate(timeout=20) + assert child.returncode != 0, stdout + stderr + raise RuntimeError("synthetic lost checkpoint response") + finally: + if child.poll() is None: + child.kill() + child.communicate() + + monkeypatch.setattr(context_io, "effect_runtime_result", native) + index = runtime / f"goals/{GOAL_ID}/runs/index.jsonl" + before = index.read_bytes() + with pytest.raises(RuntimeError, match="synthetic lost"): + refresh(registry, runtime, token) + assert index.read_bytes() == before + monkeypatch.setattr(context_io, "effect_runtime_result", original) + # Real public writes prove both SQLite rollback/close and File stale-owner + # reclaim. Reserved JSON files from the failed attempt cannot authorize save. + finish(public_writer(registry, runtime, barrier, provider)) + with pytest.raises(context_io.CheckpointReadContextRejected) as rejected: + refresh(registry, runtime, token) + assert rejected.value.code == "checkpoint_read_context_stale" + assert refresh(registry, runtime, read()["read_context_id"])["appended"] + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_cli_exit_keeps_native_claims_until_save(tmp_path, monkeypatch, provider): + _, runtime, registry, state, read, _ = fixture(tmp_path, monkeypatch, provider) + token = read()["read_context_id"] + barrier = tmp_path / "caller-exit" + barrier.mkdir() + owner = subprocess.Popen([sys.executable, "-m", "tests.control_plane.checkpoint_process", json.dumps({ + "mode": "caller-exit", "registry": str(registry), "runtime": str(runtime), "barrier": str(barrier), + "provider": provider, "token": token})], cwd=REPO, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env={**os.environ, "PYTHONPATH": str(REPO)}) + contenders = [] + try: + wait_for(barrier / "orphan-pid", owner) + assert owner.wait(timeout=10) == 0 + from loopx.file_lock import exclusive_mutation_file_lock, LockAcquireTimeoutError + index = runtime / f"goals/{GOAL_ID}/runs/index.jsonl" + for path in (index, state): + with pytest.raises(LockAcquireTimeoutError): + with exclusive_mutation_file_lock(path, timeout_seconds=0): + pytest.fail("dead caller's native claim was stolen") + for mode in ("reader", "append"): + contenders.append(subprocess.Popen([sys.executable, "-m", "tests.control_plane.checkpoint_process", json.dumps({ + "mode": mode, "registry": str(registry), "runtime": str(runtime), "barrier": str(barrier)})], + cwd=REPO, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", + env={**os.environ, "PYTHONPATH": str(REPO)})) + time.sleep(0.2) + assert all(child.poll() is None for child in contenders) + (barrier / "release").touch() + wait_for(barrier / "checkpoint-saved") + deadline = time.monotonic() + 15 + while not (barrier / "orphan-result").read_text() and time.monotonic() < deadline: + time.sleep(0.02) + assert json.loads((barrier / "orphan-result").read_text())["ok"] + assert refresh(registry, runtime, token)["idempotent_replay"] + stdout, stderr = contenders[0].communicate(timeout=20) + assert contenders[0].returncode == 0, stdout + stderr + assert json.loads(stdout)["error_code"] == "checkpoint_context_not_missing" + finish(contenders[1]) + assert "synthetic_observation" in index.read_text() + assert _spend_run_count(runtime) == 0 + finally: + (barrier / "release").touch() + if owner.poll() is None: + owner.kill() + owner.wait() + for child in contenders: + if child.poll() is None: + child.kill() + child.communicate() + + +@pytest.mark.parametrize("damage", ["tail", "artifact", "conflict"]) +def test_uncertain_committed_checkpoint_is_not_silently_replayed(tmp_path, damage): + _, runtime, registry, _, _, _ = _missing(tmp_path) + context = context_io.read_checkpoint_context(registry_path=registry, runtime_root_override=str(runtime), + goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID) + result = refresh(registry, runtime, context["read_context_id"]) + index = Path(result["index_path"]) + if damage == "tail": + index.write_bytes(index.read_bytes().rstrip(b"\r\n")) + elif damage == "artifact": + record = json.loads(Path(result["json_path"]).read_text()) + record["vision_checkpoint"]["satisfied"] = False + Path(result["json_path"]).write_text(json.dumps(record)) + else: + row = json.loads(index.read_text().splitlines()[-1]) + row["vision_checkpoint"]["read_context"]["read_context_id"] = "conflicting-token" + with index.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(row) + "\n") + before = index.read_bytes() + with pytest.raises(Exception) as error: + refresh(registry, runtime, context["read_context_id"]) + assert getattr(error.value, "code", None) == "checkpoint_commit_unknown" + assert index.read_bytes() == before diff --git a/tests/control_plane_ts/checkpoint_commit_probe.ts b/tests/control_plane_ts/checkpoint_commit_probe.ts new file mode 100644 index 0000000000..6c033f669f --- /dev/null +++ b/tests/control_plane_ts/checkpoint_commit_probe.ts @@ -0,0 +1,77 @@ +/** Isolated process barriers around the production persistence methods. No + * runtime test flag or alternate store implementation enters product code. */ +import {existsSync, readFileSync, writeFileSync} from "node:fs"; +import {join} from "node:path"; +import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; +import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; +import {createEffectRuntimeHandlers, dispatchEffectRuntimeMethod} from "../../loopx/control_plane/effect_runtime_handlers.ts"; +import {openLocalAuthorityStore} from "../../loopx/control_plane/coordination/local_authority_provider.ts"; +import {executeCoordinationTodoUpdate} from "../../loopx/control_plane/coordination/todo_update.ts"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; + +let raw = ""; +for await (const chunk of process.stdin) raw += chunk; +const input = JSON.parse(raw) as {mode: string; barrier: string; provider: string; + method: string; params: JsonObject; repeat?: boolean; fault?: string; provider_direct?: boolean}; +const signal = (name: string, value: unknown = true) => writeFileSync(join(input.barrier, name), JSON.stringify(value)); +const pause = () => { + const deadline = Date.now() + 20000; + const buffer = new Int32Array(new SharedArrayBuffer(4)); + while (!existsSync(join(input.barrier, "release"))) { + if (Date.now() >= deadline) throw new Error("checkpoint test barrier timed out"); + Atomics.wait(buffer, 0, 0, 10); + } +}; +const prototype = input.provider === "sqlite" ? SqliteAuthorityStore.prototype : FileAuthorityStore.prototype; +const commitCheckpoint = async (params: JsonObject): Promise => + await (await import("../../loopx/control_plane/goals/checkpoint_commit.ts")).commitCheckpoint(params); +if (input.mode === "checkpoint") { + const original = prototype.withCheckpointHead; + prototype.withCheckpointHead = function(save) { + return original.call(this as SqliteAuthorityStore & FileAuthorityStore, (head, identity) => { + signal("head-read", {provider_revision: head.provider_revision, pid: process.pid}); + pause(); + if (input.fault === "throw") throw new Error("synthetic checkpoint save failure"); + const result = save(head, identity); + signal("checkpoint-saved", result); + return result; + }); + }; +} else if (input.mode === "writer") { + const original = prototype.commitAuthority; + prototype.commitAuthority = async function(commit) { + signal("writer-entered"); + const result = await original.call(this as SqliteAuthorityStore & FileAuthorityStore, commit); + signal("provider-result", result); + return result; + }; +} +try { + async function writer(): Promise { + if (!input.provider_direct) return await dispatchEffectRuntimeMethod( + createEffectRuntimeHandlers({fingerprint: "checkpoint-test", requestShutdown() {}}), input.method, input.params); + // Deliberately exercise the exported provider-neutral transaction directly. + // The ordinary CLI wrapper ALREADY holds M; do not claim this lower-level + // test reproduces a race through that protected CLI path. + const p = input.params; + const store = await openLocalAuthorityStore(String(p.runtime_root), String(p.goal_id)); + return {...await executeCoordinationTodoUpdate(store, { + goal_id: String(p.goal_id), todo_id: String(p.todo_id), expected_role: null, + actor_agent_id: String(p.actor_agent_id), registered_agents: p.registered_agents as string[], + operation_id: String(p.operation_id), lease_idempotency_key: String(p.lease_idempotency_key), + lease_expected_version: Number(p.lease_expected_version), patch: p.patch as JsonObject, + clear_fields: [], dry_run: false, now: new Date(String(p.observed_at)), + }), source_authority: input.provider + "_v0", decision_read_from_provider: true, legacy_fallback_used: false}; + } + const result = input.mode === "checkpoint" ? await commitCheckpoint(input.params) + : await writer(); + if (input.repeat) { + const replay = await commitCheckpoint(input.params); + signal("runtime-replay", replay); + } + process.stdout.write(JSON.stringify(result)); +} catch (error) { + process.stdout.write(JSON.stringify({error: error instanceof Error ? error.message : String(error), + error_code: (error as {code?: string}).code})); + process.exitCode = 1; +} diff --git a/tests/control_plane_ts/checkpoint_provider_head.test.ts b/tests/control_plane_ts/checkpoint_provider_head.test.ts new file mode 100644 index 0000000000..42741383d6 --- /dev/null +++ b/tests/control_plane_ts/checkpoint_provider_head.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import {mkdtemp, rm} from "node:fs/promises"; +import {writeFileSync, readFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import test from "node:test"; +import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; +import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; + +for (const [name, Store] of [["File", FileAuthorityStore], ["SQLite", SqliteAuthorityStore]] as const) { + test(`${name} checkpoint fence releases on failure without advancing provider revision`, async t => { + const directory = await mkdtemp(join(tmpdir(), "checkpoint-fence-")); + t.after(() => rm(directory, {recursive: true, force: true})); + const store = new Store(directory, "goal"); + const created = await store.commitAuthority({expected_provider_revision: null, operation_id: "seed", + next_projection: {value: 1}, events: [], receipts: []}); + assert.equal(created.status, "applied"); + const before = await store.loadAuthority(); + await assert.rejects(store.withCheckpointHead(() => {throw new Error("save failed");}), /save failed/); + assert.deepEqual(await store.loadAuthority(), before); + assert.equal(before.status, "loaded"); + if (before.status !== "loaded") return; + const updated = await store.commitAuthority({expected_provider_revision: before.provider_revision, + operation_id: "next", next_projection: {value: 2}, events: [], receipts: []}); + assert.equal(updated.status, "applied"); + }); +} + +test("SQLite concurrent requests in one runtime do not busy-wait on an awaiting checkpoint holder", async t => { + const directory = await mkdtemp(join(tmpdir(), "checkpoint-sqlite-loop-")); + t.after(() => rm(directory, {recursive: true, force: true})); + const a = new SqliteAuthorityStore(directory, "goal"); + const b = new SqliteAuthorityStore(directory, "goal"); + const other = new SqliteAuthorityStore(directory, "other-goal"); + await a.commitAuthority({expected_provider_revision: null, operation_id: "seed", + next_projection: {value: 1}, events: [], receipts: []}); + let competing: Promise | undefined; + const order: string[] = []; + const started = performance.now(); + const result = await a.withCheckpointHead(head => { + // Queue the independent request at the final read. It must start after the + // synchronous append and rollback, rather than block an awaiting holder. + competing = Promise.resolve().then(async () => { + order.push("writer"); + return await b.commitAuthority({expected_provider_revision: head.provider_revision, + operation_id: "peer", next_projection: {value: 2}, events: [], receipts: []}); + }); + writeFileSync(join(directory, "checkpoint.json"), JSON.stringify(head)); + order.push("saved"); + return {ok: true}; + }); + const [updated, independent] = await Promise.all([competing, + other.commitAuthority({expected_provider_revision: null, operation_id: "other", + next_projection: {value: 3}, events: [], receipts: []})]); + assert.equal(result.ok, true); + assert.equal((updated as {status: string}).status, "applied"); + assert.equal(independent.status, "applied"); + assert.deepEqual(order, ["saved", "writer"]); + assert.equal(JSON.parse(readFileSync(join(directory, "checkpoint.json"), "utf8")).head.value, 1); + assert.ok(performance.now() - started < 4000, "must not incur the SQLite 5000ms busy timeout"); +}); diff --git a/tests/control_plane_ts/checkpoint_read_context.test.ts b/tests/control_plane_ts/checkpoint_read_context.test.ts index 9b8dd52d41..e496374c8f 100644 --- a/tests/control_plane_ts/checkpoint_read_context.test.ts +++ b/tests/control_plane_ts/checkpoint_read_context.test.ts @@ -30,6 +30,16 @@ test("read captures the exact task, transitive upstream results and goal accepta assert.equal(check(result.receipt).ok, true); }); +test("old receipts and identical content from a different provider lineage require reread", () => { + const source = {...facts.source, store_identity: "file:first"}; + const receipt = read({facts: {...facts, source}}).receipt as JsonObject; + assert.equal(check({...receipt, schema_version: "checkpoint_read_context_v0"}).error_code, + "checkpoint_read_context_unknown_or_replaced"); + const changed = check(receipt, {facts: {...facts, source: {...source, store_identity: "file:restored"}}}); + assert.equal(changed.error_code, "checkpoint_read_context_stale"); + assert.deepEqual(changed.changed_components, ["source"]); +}); + test("each changed decision input rejects without permitting an append", () => { const receipt = read().receipt; for (const [component, mutate] of [ diff --git a/tests/test_history_index_write_serialization.py b/tests/test_history_index_write_serialization.py index 11c292aa46..70cb421126 100644 --- a/tests/test_history_index_write_serialization.py +++ b/tests/test_history_index_write_serialization.py @@ -26,15 +26,15 @@ def record_lock_calls(monkeypatch: pytest.MonkeyPatch) -> list[Path]: """Capture every lock path taken through the history module.""" taken: list[Path] = [] - real_lock = history.exclusive_file_lock + real_lock = history.exclusive_run_index_lock @contextmanager def recording_lock(path: Path, **_kwargs: Any) -> Iterator[Path]: taken.append(path) - with real_lock(path) as locked: + with real_lock(path, **_kwargs) as locked: yield locked - monkeypatch.setattr(history, "exclusive_file_lock", recording_lock) + monkeypatch.setattr(history, "exclusive_run_index_lock", recording_lock) return taken diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index 0bc5c543de..22b354ccb4 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -145,6 +145,8 @@ "tests/control_plane_ts/turn_journal_effects.test.ts", "tests/control_plane_ts/vision_checkpoint.test.ts", "tests/control_plane_ts/checkpoint_read_context.test.ts", + "tests/control_plane_ts/checkpoint_provider_head.test.ts", + "tests/control_plane_ts/checkpoint_commit_probe.ts", "tests/control_plane_ts/refresh_recovery.test.ts", "tests/control_plane_ts/vision_wait_coverage.test.ts", "tests/control_plane_ts/shared_goal_alignment.test.ts", From 7401f693aee9776a747136e38776a689109b4982 Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 23 Sep 2026 09:59:43 +0800 Subject: [PATCH 08/11] docs(checkpoint): define provider fence and lock lifetime guarantees Signed-off-by: Tartar --- .../goal-vision-replan-contract-v0.md | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index fb6a62a121..303f6b658a 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -298,7 +298,9 @@ with one newly judged `agent_vision` or `vision_unchanged_reason`. Reading never automatically submits a decision. Missing receipts fail closed; stale receipts require another read and judgment, while lost replies use the exact original receipt and decision. The Python `checkpoint_context_io` adapter gathers and -locks sources; TypeScript `checkpoint_read_context` alone compares the basis. +locks local sources. TypeScript derives canonical Todos and the complete owner +acceptance document from one authority head; `checkpoint_read_context` compares +the basis and `checkpoint_commit` owns the final append. The basis covers the selected Todo, its dependency closure and recorded results, shared Goal prose and User Todos, the owner acceptance document/revision when @@ -309,12 +311,32 @@ excluded; an unrelated Agent Todo or run-history append does not invalidate an otherwise unchanged Todo-bound basis. Shared prose is deliberately conservative: editing it requires another judgment even if the edit was only editorial. -The local file/SQLite path holds the existing Goal run-index lock and the shared -maintenance, legacy-Todo, and state-source writer locks through the final reread, -version comparison, and checkpoint index append. A participating Todo/acceptance/ -prose writer cannot change that basis between comparison and append. Provider -failures stay closed; this does not activate a PostgreSQL service authority or -introduce a distributed transaction across runtimes. +The File/SQLite path retains the Goal index and local source protection, then +enters the real provider's writer fence: File uses the same mutation lock as +`commitAuthority`; SQLite uses one connection's `BEGIN IMMEDIATE`. Final head +read, version comparison and checkpoint append complete before release. SQLite +performs this short section synchronously, with no `await` while holding the +transaction. Model reasoning and projection sync remain outside it. The provider +revision is returned for diagnostics, but only relevant component changes or a +different store identity invalidate the basis. Old v0 receipts require a new read. + +The ordinary local Todo command wrapper already takes the maintenance lock +before committing. The provider fence additionally covers transactions through +the exported provider boundary that do not take that outer lock; these are +distinct concurrency tests. Provider failures stay closed. This adds no +PostgreSQL or cross-Goal transaction support and does not move checkpoint +authority into the Todo provider. SQLite cannot roll back the external run files. + +Index lock order is kernel then mutation marker for Python writers; existing +quota adapters retain their kernel lock around the native marker owner. Native +writers never wait for the kernel lock. Source writers retain marker then kernel, +in maintenance/Todo/state order. History append/repair, refresh, feedback, +operator-gate, project-map and runtime projection use this shared index boundary; +feedback takes the index before state. The checkpoint effect claims the caller's +index/source markers and owns their release through the durable append. Caller +exit or timeout does not release an in-flight effect's claims. Runtime death +allows the existing conservative PID/token reclaim; a live stalled owner times +out contenders rather than losing its lock. No model or Agent holds a store lock. Receipts are bound to the exact Goal/Agent/Todo or obligation/Turn. A new read for that Turn replaces its previous receipt, so its confirmation operations must be @@ -324,6 +346,9 @@ rejects the supplement without appending delivery or spending quota. Rerun judgment. The committed decision includes the receipt identity in its replay digest: an exact retry returns the original result even if state changed after commit. Acquiring a receipt for an already satisfied checkpoint is rejected. +Replay also verifies the committed artifact references. A malformed/torn index, +conflicting checkpoint rows, or inconsistent artifacts returns an explicit +unknown/error; prepared JSON/Markdown alone never authorizes a blind append. Versions are content revisions of the declared decision inputs, including native revision fields where present. They cannot detect an unobserved change-and-revert From a870725cc0c792c5b4bb9eb411a3f2bc846f5554 Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 23 Sep 2026 12:31:53 +0800 Subject: [PATCH 09/11] fix(checkpoint): qualify recovery across CI runtimes Signed-off-by: Tartar --- examples/blocker-push-runtime-smoke.py | 2 +- examples/control_plane/heartbeat-prompt-smoke.py | 6 +++--- loopx/control_plane/heartbeat/rules.py | 2 +- .../testing/authority_e2e_rows_stage2c2.py | 6 +++++- loopx/file_lock.py | 12 ++++++++---- .../checkpoint_provider_head.test.ts | 10 ++++++++-- 6 files changed, 26 insertions(+), 12 deletions(-) diff --git a/examples/blocker-push-runtime-smoke.py b/examples/blocker-push-runtime-smoke.py index ccf3689fd8..91bf02a3b8 100644 --- a/examples/blocker-push-runtime-smoke.py +++ b/examples/blocker-push-runtime-smoke.py @@ -222,7 +222,7 @@ def main() -> int: # The shipped writeback sentence moved to the mixed-language form in the # same change that updated examples/control_plane/heartbeat-prompt-smoke.py; # this assertion keeps the blocker-runtime path pinned to the same text. - assert "unchanged→真实--vision-unchanged-reason" in compact_prompt, prompt + assert "如实--vision-unchanged-reason" in compact_prompt, prompt print("blocker-push-runtime-smoke ok") return 0 diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index dd260dda10..9d4855f954 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -88,7 +88,7 @@ def assert_sole_notification_authority(task_body: str, *, mode: str) -> None: assert "material=outcome+vision" in body, mode assert "缺则同轮checkpoint-context重判" in body, mode assert "按凭据仅补vision;过期重读" in body, mode - assert "unchanged→真实--vision-unchanged-reason" in body, mode + assert "如实--vision-unchanged-reason" in body, mode if mode == "full": assert ( @@ -599,7 +599,7 @@ def main() -> int: "host_action=pause_or_delete_current_heartbeat->automation_update stop(no-spend)", "else RRULE/projected-fallback_hint/ack/fail", "no-change=surface_only/no spend", - "unchanged→真实--vision-unchanged-reason", + "如实--vision-unchanged-reason", "guard; 2 stalls->replan", "`agent_read_required`", "drain/read/triage before work; settle/ACK", @@ -701,7 +701,7 @@ def main() -> int: "host_action=pause_or_delete_current_heartbeat->automation_update stop(no-spend)", "else RRULE/projected-fallback_hint/ack/fail", "no-change=surface_only/no spend", - "unchanged→真实--vision-unchanged-reason", + "如实--vision-unchanged-reason", "guard; 2 stalls->replan", "P0 blocked: safe P1/P2", "monitor quiet/no-spend", diff --git a/loopx/control_plane/heartbeat/rules.py b/loopx/control_plane/heartbeat/rules.py index dcea72c7e0..875f5e9a51 100644 --- a/loopx/control_plane/heartbeat/rules.py +++ b/loopx/control_plane/heartbeat/rules.py @@ -25,7 +25,7 @@ "本轮精确monitor-poll提交→不refresh/spend;" "其余no-change=surface_only/no spend;writeback material=outcome+vision;" "缺则同轮checkpoint-context重判,按凭据仅补vision;" - "过期重读;unchanged→真实--vision-unchanged-reason。" + "过期重读;如实--vision-unchanged-reason。" ) REWARD_MEMORY_OUTCOME_RULE = ( "`reward_memory_recall.experiment.automatic_ingest=true`: reusable Todo outcomes " diff --git a/loopx/control_plane/testing/authority_e2e_rows_stage2c2.py b/loopx/control_plane/testing/authority_e2e_rows_stage2c2.py index ad561c83f9..50e3cbac80 100644 --- a/loopx/control_plane/testing/authority_e2e_rows_stage2c2.py +++ b/loopx/control_plane/testing/authority_e2e_rows_stage2c2.py @@ -803,7 +803,11 @@ def _mixed_writer_cycle(ledger: _MixedWriterLedger, cycle: int) -> None: def _assert_bounded_parity(ledger: _MixedWriterLedger, cycle: int, anchor_todo_id: str) -> JsonObject: workspace = ledger.workspace inspection = _object(inspect(workspace).get("inspection"), f"cycle {cycle} inspection") - expect(inspection.get("status") == "matched" and inspection.get("parity_matches") is True, f"cycle {cycle}: the candidate head must match the primary") + expect( + inspection.get("status") == "matched" and inspection.get("parity_matches") is True, + f"cycle {cycle}: the candidate head must match the primary " + f"(status={inspection.get('status')!r}, reason_code={inspection.get('reason_code')!r})", + ) flags = ["--minimum-operations", str(ledger.deliveries)] for write_class in PARITY_REQUIRED_WRITE_CLASSES: flags.extend(["--require-event-kind", write_class]) diff --git a/loopx/file_lock.py b/loopx/file_lock.py index b5272b7da1..cf27049a3b 100644 --- a/loopx/file_lock.py +++ b/loopx/file_lock.py @@ -995,10 +995,14 @@ def exclusive_cross_runtime_file_lock( operation: str | None = None, ) -> Iterator[Path]: """Source writers retain their existing order: mutation marker, then kernel.""" - options = dict(policy=policy, timeout_seconds=timeout_seconds, - poll_interval_seconds=poll_interval_seconds, agent_id=agent_id, operation=operation) - with exclusive_mutation_file_lock(path, **options): - with exclusive_file_lock(path, **options) as lock_path: + with exclusive_mutation_file_lock( + path, policy=policy, timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, agent_id=agent_id, operation=operation, + ): + with exclusive_file_lock( + path, policy=policy, timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, agent_id=agent_id, operation=operation, + ) as lock_path: yield lock_path diff --git a/tests/control_plane_ts/checkpoint_provider_head.test.ts b/tests/control_plane_ts/checkpoint_provider_head.test.ts index 42741383d6..8476580542 100644 --- a/tests/control_plane_ts/checkpoint_provider_head.test.ts +++ b/tests/control_plane_ts/checkpoint_provider_head.test.ts @@ -6,9 +6,14 @@ import {join} from "node:path"; import test from "node:test"; import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; +import {sqliteRuntimeIdentity} from "../../loopx/control_plane/coordination/sqlite_runtime.ts"; + +const sqliteSkip = sqliteRuntimeIdentity().sqlite_authority_qualified + ? false : "requires the qualified SQLite runtime"; for (const [name, Store] of [["File", FileAuthorityStore], ["SQLite", SqliteAuthorityStore]] as const) { - test(`${name} checkpoint fence releases on failure without advancing provider revision`, async t => { + test(`${name} checkpoint fence releases on failure without advancing provider revision`, + {skip: name === "SQLite" && sqliteSkip}, async t => { const directory = await mkdtemp(join(tmpdir(), "checkpoint-fence-")); t.after(() => rm(directory, {recursive: true, force: true})); const store = new Store(directory, "goal"); @@ -26,7 +31,8 @@ for (const [name, Store] of [["File", FileAuthorityStore], ["SQLite", SqliteAuth }); } -test("SQLite concurrent requests in one runtime do not busy-wait on an awaiting checkpoint holder", async t => { +test("SQLite concurrent requests in one runtime do not busy-wait on an awaiting checkpoint holder", + {skip: sqliteSkip}, async t => { const directory = await mkdtemp(join(tmpdir(), "checkpoint-sqlite-loop-")); t.after(() => rm(directory, {recursive: true, force: true})); const a = new SqliteAuthorityStore(directory, "goal"); From c6fd71b53f8473c3b9eaf4455b2e0327a891c241 Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 23 Sep 2026 13:06:56 +0800 Subject: [PATCH 10/11] test(heartbeat): isolate envelope boundary from prompt headroom Signed-off-by: Tartar --- examples/blocker-push-runtime-smoke.py | 2 +- examples/control_plane/heartbeat-prompt-smoke.py | 6 +++--- loopx/control_plane/heartbeat/rules.py | 2 +- tests/control_plane/test_heartbeat_prompt_support.py | 5 +++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/examples/blocker-push-runtime-smoke.py b/examples/blocker-push-runtime-smoke.py index 91bf02a3b8..ccf3689fd8 100644 --- a/examples/blocker-push-runtime-smoke.py +++ b/examples/blocker-push-runtime-smoke.py @@ -222,7 +222,7 @@ def main() -> int: # The shipped writeback sentence moved to the mixed-language form in the # same change that updated examples/control_plane/heartbeat-prompt-smoke.py; # this assertion keeps the blocker-runtime path pinned to the same text. - assert "如实--vision-unchanged-reason" in compact_prompt, prompt + assert "unchanged→真实--vision-unchanged-reason" in compact_prompt, prompt print("blocker-push-runtime-smoke ok") return 0 diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index 9d4855f954..dd260dda10 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -88,7 +88,7 @@ def assert_sole_notification_authority(task_body: str, *, mode: str) -> None: assert "material=outcome+vision" in body, mode assert "缺则同轮checkpoint-context重判" in body, mode assert "按凭据仅补vision;过期重读" in body, mode - assert "如实--vision-unchanged-reason" in body, mode + assert "unchanged→真实--vision-unchanged-reason" in body, mode if mode == "full": assert ( @@ -599,7 +599,7 @@ def main() -> int: "host_action=pause_or_delete_current_heartbeat->automation_update stop(no-spend)", "else RRULE/projected-fallback_hint/ack/fail", "no-change=surface_only/no spend", - "如实--vision-unchanged-reason", + "unchanged→真实--vision-unchanged-reason", "guard; 2 stalls->replan", "`agent_read_required`", "drain/read/triage before work; settle/ACK", @@ -701,7 +701,7 @@ def main() -> int: "host_action=pause_or_delete_current_heartbeat->automation_update stop(no-spend)", "else RRULE/projected-fallback_hint/ack/fail", "no-change=surface_only/no spend", - "如实--vision-unchanged-reason", + "unchanged→真实--vision-unchanged-reason", "guard; 2 stalls->replan", "P0 blocked: safe P1/P2", "monitor quiet/no-spend", diff --git a/loopx/control_plane/heartbeat/rules.py b/loopx/control_plane/heartbeat/rules.py index 875f5e9a51..dcea72c7e0 100644 --- a/loopx/control_plane/heartbeat/rules.py +++ b/loopx/control_plane/heartbeat/rules.py @@ -25,7 +25,7 @@ "本轮精确monitor-poll提交→不refresh/spend;" "其余no-change=surface_only/no spend;writeback material=outcome+vision;" "缺则同轮checkpoint-context重判,按凭据仅补vision;" - "过期重读;如实--vision-unchanged-reason。" + "过期重读;unchanged→真实--vision-unchanged-reason。" ) REWARD_MEMORY_OUTCOME_RULE = ( "`reward_memory_recall.experiment.automatic_ingest=true`: reusable Todo outcomes " diff --git a/tests/control_plane/test_heartbeat_prompt_support.py b/tests/control_plane/test_heartbeat_prompt_support.py index 22438afc6b..8367ee5318 100644 --- a/tests/control_plane/test_heartbeat_prompt_support.py +++ b/tests/control_plane/test_heartbeat_prompt_support.py @@ -88,8 +88,9 @@ def test_heartbeat_envelope_and_body_overflow_are_both_rejected() -> None: agent_scopes=["implementation", "review"], ) check("heartbeat_prompt_json", payload) - # Passing the inner body check cannot hide extra envelope metadata. - envelope = {**payload, "extra": ""} + # Check the envelope boundary independently of the real prompt's remaining + # headroom: adding a metadata key can already put a valid prompt over budget. + envelope = {"interface_budget": payload["interface_budget"], "extra": ""} envelope["extra"] = "x" * (4800 - smoke["json_size"](envelope)) check("heartbeat_prompt_json", envelope) envelope["extra"] += "x" From 6e551f8c8fffc24f689d38403fd0e012ebe2426b Mon Sep 17 00:00:00 2001 From: Tartar Date: Wed, 23 Sep 2026 13:25:22 +0800 Subject: [PATCH 11/11] test(registry): register checkpoint context reads Signed-off-by: Tartar --- .../project_registry_io_manifest_v1.json | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 166b0a3c2e..c6674cbaf6 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -415,7 +415,7 @@ }, { "site": "loopx/claude_goal_mode/scripts/goalmode_cmd.py::.goal_detail::codec_read:load_registry#1", - "line": 132, + "line": 134, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -607,7 +607,7 @@ }, { "site": "loopx/cli_commands/project_lifecycle_refresh_state.py::.handle_refresh_state_command::codec_read:load_registry#1", - "line": 525, + "line": 560, "column": 17, "kind": "codec_read", "api": "load_registry", @@ -615,7 +615,7 @@ }, { "site": "loopx/cli_commands/project_lifecycle_refresh_state.py::.handle_refresh_state_command::codec_read:load_registry#2", - "line": 604, + "line": 639, "column": 17, "kind": "codec_read", "api": "load_registry", @@ -933,6 +933,14 @@ "api": "project_registry_transaction", "classification": "codec_api" }, + { + "site": "loopx/control_plane/goals/checkpoint_context_io.py::.read_checkpoint_context::codec_read:load_registry#1", + "line": 128, + "column": 16, + "kind": "codec_read", + "api": "load_registry", + "classification": "codec_api" + }, { "site": "loopx/control_plane/goals/configure_goal_service.py::._readback::codec_read:load_registry#1", "line": 185, @@ -1679,7 +1687,7 @@ }, { "site": "loopx/state_refresh.py::.refresh_state_run::codec_read:load_registry#1", - "line": 875, + "line": 882, "column": 16, "kind": "codec_read", "api": "load_registry",