From 18bd1bbe2d91bc9360a3b5bb6c8cf397a0b670de Mon Sep 17 00:00:00 2001 From: "duanjialing.777" Date: Wed, 23 Sep 2026 17:57:32 +0800 Subject: [PATCH 1/4] feat(goals): add source-session lifetime transaction Signed-off-by: duanjialing.777 --- loopx/authority.py | 15 +- loopx/bootstrap.py | 8 +- loopx/claude_goal_mode/scripts/connect.py | 11 +- loopx/cli_commands/project.py | 40 +- loopx/configure_goal.py | 5 + .../control_plane/effect_runtime_handlers.ts | 8 + .../goals/goal_instance_identity.ts | 47 +- .../goals/source_session_binding.py | 279 ++++++ .../goals/source_session_lifetime.ts | 386 ++++++++ .../goals/source_session_recreation.py | 321 ++++++ .../goals/source_session_registration.py | 378 ++++++++ .../goals/source_session_registry_state.py | 123 +++ .../goals/source_session_services.py | 27 + .../projects/registration_state.py | 97 ++ loopx/control_plane/projects/registry.py | 199 ++-- .../control_plane/projects/registry_codec.py | 168 +++- loopx/kunluncode_goal_mode/cli.py | 5 + loopx/semantics/project_registry_io.py | 7 +- .../project_registry_io_manifest_v1.json | 78 +- loopx/state_migration.py | 5 + .../test_project_registry_io_census.py | 4 + .../test_source_session_registry_denial.py | 54 ++ .../test_source_session_lifetime.py | 914 ++++++++++++++++++ .../test_project_registry_codec.py | 79 ++ .../effect_runtime_handlers.test.ts | 64 ++ .../source_session_lifetime.test.ts | 302 ++++++ 26 files changed, 3471 insertions(+), 153 deletions(-) create mode 100644 loopx/control_plane/goals/source_session_binding.py create mode 100644 loopx/control_plane/goals/source_session_lifetime.ts create mode 100644 loopx/control_plane/goals/source_session_recreation.py create mode 100644 loopx/control_plane/goals/source_session_registration.py create mode 100644 loopx/control_plane/goals/source_session_registry_state.py create mode 100644 loopx/control_plane/goals/source_session_services.py create mode 100644 loopx/control_plane/projects/registration_state.py create mode 100644 tests/architecture/test_source_session_registry_denial.py create mode 100644 tests/cli_commands/test_source_session_lifetime.py create mode 100644 tests/control_plane_ts/source_session_lifetime.test.ts diff --git a/loopx/authority.py b/loopx/authority.py index d5c8b1d1df..05850011a5 100644 --- a/loopx/authority.py +++ b/loopx/authority.py @@ -10,6 +10,7 @@ from .control_plane.projects.registry_codec import ( load_project_registry, mutate_project_registry, + require_runtime_compatible_project_registry, ) from .control_plane.runtime.time import now_local_iso from .public_safe_text import ( @@ -457,9 +458,12 @@ def reduce( ) if dry_run: - summary, previous_entry = reduce( - copy.deepcopy(load_project_registry(registry_path)) + registry = load_project_registry(registry_path) + require_runtime_compatible_project_registry( + registry, + operation="authority source registration", ) + summary, previous_entry = reduce(copy.deepcopy(registry)) else: summary, previous_entry = mutate_project_registry( registry_path, @@ -568,9 +572,12 @@ def reduce( ) if dry_run: - summary, previous_entry = reduce( - copy.deepcopy(load_project_registry(registry_path)) + registry = load_project_registry(registry_path) + require_runtime_compatible_project_registry( + registry, + operation="authority registry import", ) + summary, previous_entry = reduce(copy.deepcopy(registry)) else: summary, previous_entry = mutate_project_registry( registry_path, diff --git a/loopx/bootstrap.py b/loopx/bootstrap.py index 5e3308038d..8106dcc7bb 100644 --- a/loopx/bootstrap.py +++ b/loopx/bootstrap.py @@ -9,6 +9,7 @@ from .control_plane.projects.registry_codec import ( load_project_registry, project_registry_transaction, + require_runtime_compatible_project_registry, ) from typing import Any @@ -70,7 +71,12 @@ def now_iso() -> str: def read_json_if_exists(path: Path) -> dict[str, Any]: if not path.exists(): return {} - return load_project_registry(path) + payload = load_project_registry(path) + require_runtime_compatible_project_registry( + payload, + operation="bootstrap", + ) + return payload def resolve_project_path(project: Path, path: Path | None) -> Path | None: diff --git a/loopx/claude_goal_mode/scripts/connect.py b/loopx/claude_goal_mode/scripts/connect.py index 667c6c674f..dcf7d5f5d8 100644 --- a/loopx/claude_goal_mode/scripts/connect.py +++ b/loopx/claude_goal_mode/scripts/connect.py @@ -17,8 +17,10 @@ from pathlib import Path from loopx.control_plane.projects.registry_codec import ( + ProjectRegistryProtocolError, add_project_registry_backend, load_project_registry, + require_runtime_compatible_project_registry, ) PLUGIN_ROOT = Path(__file__).resolve().parents[1] @@ -70,10 +72,17 @@ def main(): reg = proj / ".goal-harness" / "registry.json" if reg.exists(): try: - load_project_registry(reg) + payload = load_project_registry(reg) + require_runtime_compatible_project_registry( + payload, + operation="Claude Goal adapter", + ) print(f"[registry] mark agent_backends += claude ({reg})") if not dry: add_project_registry_backend(reg, "claude") + except ProjectRegistryProtocolError as e: + print(f"[registry] FAILED: {e}") + sys.exit(1) except Exception as e: print(" (registry annotate skipped:", e, ")") else: diff --git a/loopx/cli_commands/project.py b/loopx/cli_commands/project.py index 42b618b1e9..503b9c4998 100644 --- a/loopx/cli_commands/project.py +++ b/loopx/cli_commands/project.py @@ -10,6 +10,7 @@ from ..control_plane.projects.registry import ( PROJECT_KINDS, bind_session, + recreate_goal, register_project_goal, resolve_project, unbind_session, @@ -44,6 +45,11 @@ def register_project_commands( register_parser.add_argument("--stop-condition", required=True) register_parser.add_argument("--repository", action="append", default=[]) register_parser.add_argument("--external-locator", action="append", default=[]) + register_parser.add_argument( + "--goal-instance-profile", + choices=("source_session_v1",), + ) + register_parser.add_argument("--operation-id") bind_parser = project_sub.add_parser( "bind-session", @@ -52,6 +58,8 @@ def register_project_commands( add_subcommand_format(bind_parser) bind_parser.add_argument("--session-id", required=True) bind_parser.add_argument("--goal-id", required=True) + bind_parser.add_argument("--goal-instance-id") + bind_parser.add_argument("--operation-id") unbind_parser = project_sub.add_parser( "unbind-session", @@ -60,6 +68,18 @@ def register_project_commands( add_subcommand_format(unbind_parser) unbind_parser.add_argument("--session-id", required=True) unbind_parser.add_argument("--goal-id", required=True) + unbind_parser.add_argument("--goal-instance-id") + unbind_parser.add_argument("--operation-id") + + recreate_parser = project_sub.add_parser( + "recreate-goal", + help="Retire one exact Goal instance and publish its reserved successor.", + ) + add_subcommand_format(recreate_parser) + recreate_parser.add_argument("--goal-id", required=True) + recreate_parser.add_argument("--goal-instance-id", required=True) + recreate_parser.add_argument("--operation-id", required=True) + recreate_parser.add_argument("--execute", action="store_true") resolve_parser = project_sub.add_parser( "resolve", @@ -68,6 +88,8 @@ def register_project_commands( add_subcommand_format(resolve_parser) resolve_parser.add_argument("--project-id") resolve_parser.add_argument("--session-id") + resolve_parser.add_argument("--goal-id") + resolve_parser.add_argument("--goal-instance-id") resolve_parser.add_argument("--repository") resolve_parser.add_argument("--external-locator") @@ -120,26 +142,42 @@ def handle_project_command( stop_condition=args.stop_condition, repository_bindings=args.repository, external_locator_bindings=args.external_locator, + goal_instance_profile=args.goal_instance_profile, + operation_id=args.operation_id, ) elif args.project_command == "bind-session": payload = bind_session( registry_path=registry_path, session_id=args.session_id, goal_id=args.goal_id, + goal_instance_id=args.goal_instance_id, + operation_id=args.operation_id, ) elif args.project_command == "unbind-session": payload = unbind_session( registry_path=registry_path, session_id=args.session_id, goal_id=args.goal_id, + goal_instance_id=args.goal_instance_id, + operation_id=args.operation_id, ) - else: + elif args.project_command == "resolve": payload = resolve_project( registry_path=registry_path, explicit_project_id=args.project_id, session_id=args.session_id, repository=args.repository, external_locator=args.external_locator, + goal_id=args.goal_id, + goal_instance_id=args.goal_instance_id, + ) + else: + payload = recreate_goal( + registry_path=registry_path, + goal_id=args.goal_id, + goal_instance_id=args.goal_instance_id, + operation_id=args.operation_id, + execute=args.execute, ) except (OSError, TypeError, ValueError, LegacyCoordinationWriterFenced, ShadowManagementError) as exc: payload = { diff --git a/loopx/configure_goal.py b/loopx/configure_goal.py index 56298fba10..2bc74d2d74 100644 --- a/loopx/configure_goal.py +++ b/loopx/configure_goal.py @@ -54,6 +54,7 @@ ProjectRegistryTransaction, load_project_registry, project_registry_transaction, + require_runtime_compatible_project_registry, ) from .control_plane.todos.contract import normalize_todo_claimed_by from .control_plane.todos.mutation_authority import ( @@ -709,6 +710,10 @@ def configure_goal( if _registry_transaction is not None else load_project_registry(registry_path) ) + require_runtime_compatible_project_registry( + payload, + operation="Goal configuration", + ) goals = registry_goals(payload) goal = next((item for item in goals if str(item.get("id")) == goal_id), None) if goal is None: diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 21970bb874..b93b6687c4 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -115,6 +115,11 @@ import { projectVisionWaitCoverage } from "./goals/vision_wait_coverage.ts"; import { admitGoalAmendmentProposal } from "./goals/goal_amendment_proposal.ts"; import { projectSharedGoalAlignment } from "./goals/shared_goal_alignment.ts"; import { projectGoalOperatorActions } from "./goals/operator_actions.ts"; +import { + decideGoalRecreation, + decideProjectSessionBind, + decideProjectSessionUnbind, +} from "./goals/source_session_lifetime.ts"; import { evaluateDeliveryRoute, } from "./turn_driver/delivery_continuity.ts"; @@ -503,6 +508,9 @@ export function createEffectRuntimeHandlers( ["goal.shared_goal_alignment.project", projectSharedGoalAlignment], ["goal.operator_actions.project", projectGoalOperatorActions], ["goal.amendment_proposal.admit", admitGoalAmendmentProposal], + ["goal.source_session.bind.decide", decideProjectSessionBind], + ["goal.source_session.unbind.decide", decideProjectSessionUnbind], + ["goal.source_session.recreate.decide", decideGoalRecreation], ["goal.acceptance.inspect", inspectLocalGoalAcceptance], ["goal.acceptance.configure", commitLocalGoalAcceptance], ["goal.acceptance.verify.commit", commitLocalGoalAcceptanceVerification], diff --git a/loopx/control_plane/goals/goal_instance_identity.ts b/loopx/control_plane/goals/goal_instance_identity.ts index 1556b88a84..24b59eb0d5 100644 --- a/loopx/control_plane/goals/goal_instance_identity.ts +++ b/loopx/control_plane/goals/goal_instance_identity.ts @@ -5,25 +5,37 @@ const GOAL_BINDING_MATCH_SCHEMA_VERSION = "loopx_goal_binding_match_v1"; const GOAL_ID = /^[A-Za-z0-9._:-]{1,200}$/; const GOAL_INSTANCE_ID = /^ginst_[0-9a-f]{32}$/; -type GoalId = Readonly<{ +export type GoalId = Readonly<{ kind: "goal_id"; value: string; }>; -type GoalInstanceId = Readonly<{ +export type GoalInstanceId = Readonly<{ kind: "goal_instance_id"; value: string; }>; +export type ExactGoalRef = Readonly<{ + kind: "goal_ref"; + goalId: GoalId; + goalInstanceId: GoalInstanceId; +}>; + type GoalRef = | Readonly<{ kind: "legacy_goal_ref"; goalId: GoalId; }> + | ExactGoalRef; + +export type ExactGoalRefParseResult = + | Readonly<{ kind: "parsed"; value: ExactGoalRef }> | Readonly<{ - kind: "goal_ref"; - goalId: GoalId; - goalInstanceId: GoalInstanceId; + kind: "invalid"; + issue: + | "invalid_goal_id" + | "missing_goal_instance_id" + | "invalid_goal_instance_id"; }>; type BindingOwner = "source_registry" | "global_projection"; @@ -42,10 +54,22 @@ type IdentityIssue = reason: UnavailableReason; }>; +type GoalRefIssue = Extract< + IdentityIssue, + Readonly<{ + kind: "invalid_goal_id" | "invalid_goal_instance_id"; + side: IdentitySide; + }> +>; + type Parsed = | Readonly<{ kind: "parsed"; value: Value }> | Readonly<{ kind: "invalid"; issue: IdentityIssue }>; +type ParsedGoalRef = + | Readonly<{ kind: "parsed"; value: GoalRef }> + | Readonly<{ kind: "invalid"; issue: GoalRefIssue }>; + type Authority = | Readonly<{ kind: "present"; goal: GoalRef }> | Readonly<{ kind: "absent" }> @@ -59,7 +83,7 @@ function parseBindingOwner(value: unknown): Parsed { return { kind: "invalid", issue: { kind: "invalid_binding_owner" } }; } -function parseGoalRef(value: unknown, side: IdentitySide): Parsed { +function parseGoalRef(value: unknown, side: IdentitySide): ParsedGoalRef { const raw = jsonObject(value); if (!raw || typeof raw.goal_id !== "string" || !GOAL_ID.test(raw.goal_id)) { return { @@ -93,6 +117,17 @@ function parseGoalRef(value: unknown, side: IdentitySide): Parsed { }; } +export function parseExactGoalRef(value: unknown): ExactGoalRefParseResult { + const parsed = parseGoalRef(value, "binding"); + if (parsed.kind === "invalid") { + return { kind: "invalid", issue: parsed.issue.kind }; + } + if (parsed.value.kind === "legacy_goal_ref") { + return { kind: "invalid", issue: "missing_goal_instance_id" }; + } + return { kind: "parsed", value: parsed.value }; +} + function parseAuthority(value: unknown): Parsed { const raw = jsonObject(value); if (!raw) { diff --git a/loopx/control_plane/goals/source_session_binding.py b/loopx/control_plane/goals/source_session_binding.py new file mode 100644 index 0000000000..64ea3633b1 --- /dev/null +++ b/loopx/control_plane/goals/source_session_binding.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +import copy +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +from ...file_lock import exclusive_cross_runtime_file_lock +from ..effect_runtime import effect_runtime_result +from ..projects.registry_codec import ( + SOURCE_SESSION_PROFILE_ID, + source_session_registry_transaction, +) +from ..runtime.time import now_local_iso +from .source_session_registry_state import ( + canonical_digest, + current_goal_ref, + exact_goal_ref, + guard_path, + prior_operation_receipt, + required_list, + session_binding_records, +) + + +@dataclass(frozen=True, slots=True) +class SessionBindingRequest: + registry_path: Path + session_id: str + goal_id: str + goal_instance_id: str + operation_id: str + + +def _session_request_digest( + request: SessionBindingRequest, + *, + operation: Literal["bind", "unbind"], +) -> str: + return canonical_digest( + { + "schema_version": "loopx_source_session_request_v1", + "operation": operation, + "operation_id": request.operation_id, + "session_id": request.session_id, + "goal_ref": exact_goal_ref( + request.goal_id, + request.goal_instance_id, + ), + } + ) + + +def _session_result( + request: SessionBindingRequest, + *, + operation: Literal["bind", "unbind"], + receipt: dict[str, Any], + replayed: bool, + project_id: str, +) -> dict[str, Any]: + binding = { + "session_id": request.session_id, + "foreground_goal_ref": copy.deepcopy(receipt["goal_ref"]), + } + return { + "ok": True, + "schema_version": ( + "loopx_session_binding_v1" + if operation == "bind" + else "loopx_session_unbinding_v1" + ), + "changed": bool(receipt["changed"]), + "replayed": replayed, + "registry": str(request.registry_path), + "project_id": project_id, + "goal_ref": copy.deepcopy(receipt["goal_ref"]), + "binding": ( + binding if operation == "bind" else binding if receipt["changed"] else None + ), + "receipt": copy.deepcopy(receipt), + "execution_authority": False, + } + + +def _commit_project_session_operation( + request: SessionBindingRequest, + *, + operation: Literal["bind", "unbind"], +) -> dict[str, Any]: + request_digest = _session_request_digest(request, operation=operation) + requested_goal_ref = exact_goal_ref( + request.goal_id, + request.goal_instance_id, + ) + guard = guard_path(request.registry_path, request.goal_id) + with exclusive_cross_runtime_file_lock( + guard, + operation="source_session_goal_lifetime", + ): + with source_session_registry_transaction( + request.registry_path, + operation=f"source_session_project_{operation}", + ) as transaction: + registry = transaction.payload_copy() + active_goal_ref, goal = current_goal_ref( + registry, + goal_id=request.goal_id, + ) + bindings = session_binding_records(registry) + receipts = required_list(registry, "session_receipts") + lifetime_receipts = required_list(registry, "lifetime_receipts") + if ( + prior_operation_receipt( + lifetime_receipts, + operation_id=request.operation_id, + ) + is not None + ): + raise ValueError( + "source-session operation_id was reused across lifecycle operations" + ) + current_binding = next( + ( + binding + for binding in bindings + if binding.get("session_id") == request.session_id + ), + None, + ) + prior_receipt = prior_operation_receipt( + receipts, + operation_id=request.operation_id, + ) + decision = effect_runtime_result( + f"goal.source_session.{operation}.decide", + { + "profile_id": registry["profile_id"], + "operation_id": request.operation_id, + "request_digest": request_digest, + "session_id": request.session_id, + "requested_goal_ref": requested_goal_ref, + "current_goal_ref": active_goal_ref, + "current_binding": current_binding, + "prior_receipt": prior_receipt, + "binding_count": len(bindings), + "receipt_count": len(receipts), + }, + ) + if not isinstance(decision, dict): + raise RuntimeError("source-session decision must be an object") + if decision.get("kind") == "reject": + raise ValueError( + f"source-session {operation} rejected: {decision.get('code')}" + ) + project_id = str(goal.get("project_id") or "") + if not project_id: + raise ValueError("source-session Goal is missing project_id") + if decision.get("kind") == "replay": + replay_receipt = decision.get("receipt") + if not isinstance(replay_receipt, dict): + raise RuntimeError("source-session replay omitted its receipt") + return _session_result( + request, + operation=operation, + receipt=replay_receipt, + replayed=True, + project_id=project_id, + ) + if decision.get("kind") != "commit": + raise RuntimeError("source-session decision kind is unsupported") + changed = decision.get("changed") + if not isinstance(changed, bool): + raise RuntimeError("source-session commit omitted changed") + + if operation == "bind": + next_binding = { + "session_id": request.session_id, + "foreground_goal_ref": requested_goal_ref, + } + registry["session_bindings"] = [ + binding + for binding in bindings + if binding.get("session_id") != request.session_id + ] + [next_binding] + elif changed: + registry["session_bindings"] = [ + binding + for binding in bindings + if binding.get("session_id") != request.session_id + ] + + receipt = { + "schema_version": "loopx_source_session_receipt_v1", + "operation": operation, + "operation_id": request.operation_id, + "request_digest": request_digest, + "session_id": request.session_id, + "goal_ref": requested_goal_ref, + "changed": changed, + "committed_at": now_local_iso(), + } + registry["session_receipts"] = [*receipts, receipt] + registry["updated_at"] = receipt["committed_at"] + transaction.commit(registry) + return _session_result( + request, + operation=operation, + receipt=receipt, + replayed=False, + project_id=project_id, + ) + + +def commit_project_session_binding( + request: SessionBindingRequest, +) -> dict[str, Any]: + return _commit_project_session_operation(request, operation="bind") + + +def commit_project_session_unbinding( + request: SessionBindingRequest, +) -> dict[str, Any]: + return _commit_project_session_operation(request, operation="unbind") + + +def resolve_source_session_project( + *, + registry: dict[str, Any], + registry_path: Path, + goal_id: str | None, + goal_instance_id: str | None, + session_id: str | None, +) -> dict[str, Any]: + """Classify exact source-session evidence without granting execution.""" + + if registry.get("profile_id") != SOURCE_SESSION_PROFILE_ID: + raise ValueError("source-session registry profile is unsupported") + if goal_id is None or goal_instance_id is None: + raise ValueError( + "source-session resolution requires goal_id and goal_instance_id" + ) + requested = exact_goal_ref(goal_id, goal_instance_id) + current, goal = current_goal_ref(registry, goal_id=goal_id) + project_id = str(goal.get("project_id") or "") + if not project_id: + raise ValueError("source-session Goal is missing project_id") + resolution = "current" if requested == current else "mismatched" + retired = required_list(registry, "retired_goal_instances") + if any(item.get("goal_ref") == requested for item in retired): + resolution = "retired" + binding = None + if session_id is not None: + binding = next( + ( + item + for item in session_binding_records(registry) + if item.get("session_id") == session_id + ), + None, + ) + if binding is None: + resolution = "absent" + elif binding.get("foreground_goal_ref") != requested: + resolution = "mismatched" + elif requested == current: + resolution = "current" + return { + "ok": resolution == "current", + "schema_version": "loopx_project_resolution_v1", + "resolution": resolution, + "source": "source_session", + "registry": str(registry_path), + "project_id": project_id if resolution == "current" else None, + "goal_ref": requested, + "current_goal_ref": current, + "binding": copy.deepcopy(binding), + "execution_authority": False, + } diff --git a/loopx/control_plane/goals/source_session_lifetime.ts b/loopx/control_plane/goals/source_session_lifetime.ts new file mode 100644 index 0000000000..a0b18957b2 --- /dev/null +++ b/loopx/control_plane/goals/source_session_lifetime.ts @@ -0,0 +1,386 @@ +import type { JsonObject } from "../effect_program.ts"; +import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; +import { jsonObject } from "../runtime_decode.ts"; +import { + parseExactGoalRef, + type ExactGoalRef, +} from "./goal_instance_identity.ts"; + +export const SOURCE_SESSION_PROFILE_ID = "source_session_v1"; +export const SOURCE_SESSION_BINDING_LIMIT = 256; +export const SOURCE_SESSION_RECEIPT_LIMIT = 4096; +export const SOURCE_SESSION_LIFETIME_RECEIPT_LIMIT = 1024; + +type WireGoalRef = Readonly<{ + goal_id: string; + goal_instance_id: string; +}>; + +export type SessionRejection = + | "unsupported_profile" + | "stale_goal_instance" + | "session_binding_conflict" + | "binding_capacity_exhausted" + | "history_capacity_exhausted" + | "operation_id_conflict"; + +export type SessionDecision = + | Readonly<{ + kind: "commit"; + goal_ref: WireGoalRef; + changed: boolean; + }> + | Readonly<{ + kind: "replay"; + receipt: JsonObject; + }> + | Readonly<{ + kind: "reject"; + code: SessionRejection; + }>; + +export type GoalRecreationRejection = + | "unsupported_profile" + | "stale_goal_instance" + | "history_capacity_exhausted" + | "session_history_capacity_exhausted" + | "operation_id_conflict"; + +export type GoalRecreationDecision = + | Readonly<{ + kind: "commit"; + retired_goal_ref: WireGoalRef; + new_goal_ref: WireGoalRef; + }> + | Readonly<{ + kind: "replay"; + receipt: JsonObject; + }> + | Readonly<{ + kind: "reject"; + code: GoalRecreationRejection; + }>; + +type SessionBinding = Readonly<{ + sessionId: string; + goalRef: ExactGoalRef; +}>; + +type SessionBindingFacts = Readonly<{ + profileId: string; + operationId: string; + requestDigest: string; + sessionId: string; + requestedGoalRef: ExactGoalRef; + currentGoalRef: ExactGoalRef; + currentBinding: SessionBinding | null; + priorReceipt: JsonObject | null; + bindingCount: number; + receiptCount: number; +}>; + +type GoalRecreationFacts = Readonly<{ + profileId: string; + operationId: string; + requestDigest: string; + requestedGoalRef: ExactGoalRef; + currentGoalRef: ExactGoalRef; + reservedGoalRef: ExactGoalRef; + priorReceipt: JsonObject | null; + lifetimeReceiptCount: number; + sessionReceiptCount: number; + retiringBindingCount: number; +}>; + +function requiredObject(value: unknown, label: string): JsonObject { + const result = jsonObject(value); + if (!result) { + throw new EffectRuntimeRequestError(`${label} must be an object`); + } + return result; +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== "string" || value.trim() === "") { + throw new EffectRuntimeRequestError(`${label} must be a non-empty string`); + } + return value; +} + +function requestDigest(value: unknown): string { + const digest = requiredString(value, "request_digest"); + if (!/^sha256:[0-9a-f]{64}$/.test(digest)) { + throw new EffectRuntimeRequestError("request_digest must be a SHA-256 digest"); + } + return digest; +} + +function nonNegativeInteger(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || Number(value) < 0) { + throw new EffectRuntimeRequestError(`${label} must be a non-negative integer`); + } + return Number(value); +} + +function exactGoalRef(value: unknown, label: string): ExactGoalRef { + const parsed = parseExactGoalRef(value); + if (parsed.kind === "invalid") { + throw new EffectRuntimeRequestError(`${label} ${parsed.issue}`); + } + return parsed.value; +} + +function wireGoalRef(value: ExactGoalRef): WireGoalRef { + return { + goal_id: value.goalId.value, + goal_instance_id: value.goalInstanceId.value, + }; +} + +function goalRefsEqual(left: ExactGoalRef, right: ExactGoalRef): boolean { + return left.goalId.value === right.goalId.value + && left.goalInstanceId.value === right.goalInstanceId.value; +} + +function sessionBinding(value: unknown): SessionBinding | null { + if (value === null) return null; + const binding = requiredObject(value, "current_binding"); + return { + sessionId: requiredString(binding.session_id, "current_binding.session_id"), + goalRef: exactGoalRef( + binding.foreground_goal_ref, + "current_binding.foreground_goal_ref", + ), + }; +} + +function sessionBindingFacts(value: unknown): SessionBindingFacts { + const facts = requiredObject(value, "source-session binding facts"); + const priorReceipt = facts.prior_receipt === null + ? null + : requiredObject(facts.prior_receipt, "prior_receipt"); + return { + profileId: requiredString(facts.profile_id, "profile_id"), + operationId: requiredString(facts.operation_id, "operation_id"), + requestDigest: requestDigest(facts.request_digest), + sessionId: requiredString(facts.session_id, "session_id"), + requestedGoalRef: exactGoalRef( + facts.requested_goal_ref, + "requested_goal_ref", + ), + currentGoalRef: exactGoalRef(facts.current_goal_ref, "current_goal_ref"), + currentBinding: sessionBinding(facts.current_binding), + priorReceipt, + bindingCount: nonNegativeInteger(facts.binding_count, "binding_count"), + receiptCount: nonNegativeInteger(facts.receipt_count, "receipt_count"), + }; +} + +function replaySessionDecision( + facts: SessionBindingFacts, + operation: "bind" | "unbind", +): SessionDecision | null { + const receipt = facts.priorReceipt; + if (receipt === null) return null; + if ( + receipt.schema_version !== "loopx_source_session_receipt_v1" + || receipt.operation !== operation + || receipt.operation_id !== facts.operationId + || receipt.session_id !== facts.sessionId + || typeof receipt.changed !== "boolean" + ) { + throw new EffectRuntimeRequestError(`prior ${operation} receipt is malformed`); + } + const receiptGoalRef = exactGoalRef(receipt.goal_ref, "prior_receipt.goal_ref"); + const receiptDigest = requestDigest(receipt.request_digest); + if (receiptDigest !== facts.requestDigest) { + return { kind: "reject", code: "operation_id_conflict" }; + } + if (!goalRefsEqual(receiptGoalRef, facts.requestedGoalRef)) { + throw new EffectRuntimeRequestError("prior bind receipt goal_ref is inconsistent"); + } + return { kind: "replay", receipt }; +} + +export function decideProjectSessionBind(value: unknown): SessionDecision { + const facts = sessionBindingFacts(value); + if (facts.profileId !== SOURCE_SESSION_PROFILE_ID) { + return { kind: "reject", code: "unsupported_profile" }; + } + const replay = replaySessionDecision(facts, "bind"); + if (replay !== null) return replay; + if (!goalRefsEqual(facts.requestedGoalRef, facts.currentGoalRef)) { + return { kind: "reject", code: "stale_goal_instance" }; + } + if (facts.receiptCount >= SOURCE_SESSION_RECEIPT_LIMIT) { + return { kind: "reject", code: "history_capacity_exhausted" }; + } + const current = facts.currentBinding; + if (current !== null) { + if ( + current.sessionId !== facts.sessionId + || !goalRefsEqual(current.goalRef, facts.requestedGoalRef) + ) { + return { kind: "reject", code: "session_binding_conflict" }; + } + return { + kind: "commit", + goal_ref: wireGoalRef(facts.requestedGoalRef), + changed: false, + }; + } + if (facts.bindingCount >= SOURCE_SESSION_BINDING_LIMIT) { + return { kind: "reject", code: "binding_capacity_exhausted" }; + } + return { + kind: "commit", + goal_ref: wireGoalRef(facts.requestedGoalRef), + changed: true, + }; +} + +export function decideProjectSessionUnbind(value: unknown): SessionDecision { + const facts = sessionBindingFacts(value); + if (facts.profileId !== SOURCE_SESSION_PROFILE_ID) { + return { kind: "reject", code: "unsupported_profile" }; + } + const replay = replaySessionDecision(facts, "unbind"); + if (replay !== null) return replay; + if (!goalRefsEqual(facts.requestedGoalRef, facts.currentGoalRef)) { + return { kind: "reject", code: "stale_goal_instance" }; + } + if (facts.receiptCount >= SOURCE_SESSION_RECEIPT_LIMIT) { + return { kind: "reject", code: "history_capacity_exhausted" }; + } + const current = facts.currentBinding; + if (current === null) { + return { + kind: "commit", + goal_ref: wireGoalRef(facts.requestedGoalRef), + changed: false, + }; + } + if ( + current.sessionId !== facts.sessionId + || !goalRefsEqual(current.goalRef, facts.requestedGoalRef) + ) { + return { kind: "reject", code: "session_binding_conflict" }; + } + return { + kind: "commit", + goal_ref: wireGoalRef(facts.requestedGoalRef), + changed: true, + }; +} + +function goalRecreationFacts(value: unknown): GoalRecreationFacts { + const facts = requiredObject(value, "goal recreation facts"); + const priorReceipt = facts.prior_receipt === null + ? null + : requiredObject(facts.prior_receipt, "prior_receipt"); + const requestedGoalRef = exactGoalRef( + facts.requested_goal_ref, + "requested_goal_ref", + ); + const reservedGoalRef = exactGoalRef( + facts.reserved_goal_ref, + "reserved_goal_ref", + ); + if ( + requestedGoalRef.goalId.value !== reservedGoalRef.goalId.value + || requestedGoalRef.goalInstanceId.value + === reservedGoalRef.goalInstanceId.value + ) { + throw new EffectRuntimeRequestError( + "reserved_goal_ref must name a new instance of the requested Goal", + ); + } + return { + profileId: requiredString(facts.profile_id, "profile_id"), + operationId: requiredString(facts.operation_id, "operation_id"), + requestDigest: requestDigest(facts.request_digest), + requestedGoalRef, + currentGoalRef: exactGoalRef(facts.current_goal_ref, "current_goal_ref"), + reservedGoalRef, + priorReceipt, + lifetimeReceiptCount: nonNegativeInteger( + facts.lifetime_receipt_count, + "lifetime_receipt_count", + ), + sessionReceiptCount: nonNegativeInteger( + facts.session_receipt_count, + "session_receipt_count", + ), + retiringBindingCount: nonNegativeInteger( + facts.retiring_binding_count, + "retiring_binding_count", + ), + }; +} + +function replayGoalRecreation( + facts: GoalRecreationFacts, +): GoalRecreationDecision | null { + const receipt = facts.priorReceipt; + if (receipt === null) return null; + if ( + receipt.schema_version !== "loopx_goal_recreation_receipt_v1" + || receipt.operation_id !== facts.operationId + || !Array.isArray(receipt.retired_session_ids) + || receipt.retired_session_ids.some((value) => typeof value !== "string") + ) { + throw new EffectRuntimeRequestError("prior recreation receipt is malformed"); + } + const receiptDigest = requestDigest(receipt.request_digest); + if (receiptDigest !== facts.requestDigest) { + return { kind: "reject", code: "operation_id_conflict" }; + } + const retiredGoalRef = exactGoalRef( + receipt.retired_goal_ref, + "prior_receipt.retired_goal_ref", + ); + const newGoalRef = exactGoalRef( + receipt.new_goal_ref, + "prior_receipt.new_goal_ref", + ); + if ( + !goalRefsEqual(retiredGoalRef, facts.requestedGoalRef) + || !goalRefsEqual(newGoalRef, facts.reservedGoalRef) + ) { + throw new EffectRuntimeRequestError( + "prior recreation receipt Goal references are inconsistent", + ); + } + return { kind: "replay", receipt }; +} + +export function decideGoalRecreation(value: unknown): GoalRecreationDecision { + const facts = goalRecreationFacts(value); + if (facts.profileId !== SOURCE_SESSION_PROFILE_ID) { + return { kind: "reject", code: "unsupported_profile" }; + } + const replay = replayGoalRecreation(facts); + if (replay !== null) return replay; + if (!goalRefsEqual(facts.requestedGoalRef, facts.currentGoalRef)) { + return { kind: "reject", code: "stale_goal_instance" }; + } + if ( + facts.lifetimeReceiptCount >= SOURCE_SESSION_LIFETIME_RECEIPT_LIMIT + ) { + return { kind: "reject", code: "history_capacity_exhausted" }; + } + if ( + facts.sessionReceiptCount + facts.retiringBindingCount + > SOURCE_SESSION_RECEIPT_LIMIT + ) { + return { + kind: "reject", + code: "session_history_capacity_exhausted", + }; + } + return { + kind: "commit", + retired_goal_ref: wireGoalRef(facts.requestedGoalRef), + new_goal_ref: wireGoalRef(facts.reservedGoalRef), + }; +} diff --git a/loopx/control_plane/goals/source_session_recreation.py b/loopx/control_plane/goals/source_session_recreation.py new file mode 100644 index 0000000000..64512cb50c --- /dev/null +++ b/loopx/control_plane/goals/source_session_recreation.py @@ -0,0 +1,321 @@ +from __future__ import annotations + +import copy +from dataclasses import dataclass +import hashlib +import json +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from ...file_lock import exclusive_cross_runtime_file_lock +from ..effect_runtime import effect_runtime_result +from ..projects.registry_codec import source_session_registry_transaction +from ..runtime.time import now_local_iso +from .source_session_registry_state import ( + GOAL_INSTANCE_ID, + alias_digest, + canonical_digest, + current_goal_ref, + exact_goal_ref, + guard_path, + lifetime_root, + prior_operation_receipt, + required_list, + session_binding_records, + write_journal, +) + + +@dataclass(frozen=True, slots=True) +class RecreateGoalRequest: + registry_path: Path + goal_id: str + goal_instance_id: str + operation_id: str + + +def _recreation_journal_path( + registry_path: Path, + *, + goal_id: str, + operation_id: str, +) -> Path: + operation_digest = hashlib.sha256(operation_id.encode("utf-8")).hexdigest() + return ( + lifetime_root(registry_path) + / "recreations" + / alias_digest(goal_id) + / f"{operation_digest}.json" + ) + + +def _read_recreation_journal(path: Path) -> dict[str, Any] | None: + if not path.exists(): + return None + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError("Goal recreation journal must be a JSON object") + required = { + "schema_version", + "operation_id", + "request_digest", + "retired_goal_ref", + "new_goal_ref", + "reserved_at", + "phase", + } + if set(value) != required: + raise ValueError("Goal recreation journal shape is invalid") + if ( + value.get("schema_version") != "loopx_goal_recreation_journal_v1" + or not isinstance(value.get("operation_id"), str) + or not isinstance(value.get("request_digest"), str) + or not isinstance(value.get("reserved_at"), str) + or value.get("phase") not in {"reserved", "published"} + ): + raise ValueError("Goal recreation journal content is invalid") + for field in ("retired_goal_ref", "new_goal_ref"): + goal_ref = value.get(field) + if ( + not isinstance(goal_ref, dict) + or not isinstance(goal_ref.get("goal_id"), str) + or not isinstance(goal_ref.get("goal_instance_id"), str) + or not GOAL_INSTANCE_ID.fullmatch(goal_ref["goal_instance_id"]) + ): + raise ValueError(f"Goal recreation journal {field} is invalid") + return value + + +def _recreation_digest(request: RecreateGoalRequest) -> str: + return canonical_digest( + { + "schema_version": "loopx_goal_recreation_request_v1", + "operation_id": request.operation_id, + "goal_ref": exact_goal_ref( + request.goal_id, + request.goal_instance_id, + ), + } + ) + + +def _recreation_result( + request: RecreateGoalRequest, + *, + receipt: dict[str, Any], + replayed: bool, +) -> dict[str, Any]: + return { + "ok": True, + "schema_version": "loopx_goal_recreation_v1", + "changed": True, + "replayed": replayed, + "registry": str(request.registry_path), + "retired_goal_ref": copy.deepcopy(receipt["retired_goal_ref"]), + "goal_ref": copy.deepcopy(receipt["new_goal_ref"]), + "retired_session_ids": list(receipt["retired_session_ids"]), + "receipt": copy.deepcopy(receipt), + "execution_authority": False, + } + + +def recreate_goal_instance(request: RecreateGoalRequest) -> dict[str, Any]: + """Retire exact A and publish one reserved B under the alias guard.""" + + requested_goal_ref = exact_goal_ref( + request.goal_id, + request.goal_instance_id, + ) + request_digest = _recreation_digest(request) + guard = guard_path(request.registry_path, request.goal_id) + journal_path = _recreation_journal_path( + request.registry_path, + goal_id=request.goal_id, + operation_id=request.operation_id, + ) + with exclusive_cross_runtime_file_lock( + guard, + operation="source_session_goal_lifetime", + ): + journal = _read_recreation_journal(journal_path) + if journal is not None and ( + journal["operation_id"] != request.operation_id + or journal["request_digest"] != request_digest + or journal["retired_goal_ref"] != requested_goal_ref + ): + raise ValueError("Goal recreation operation_id conflicts with its journal") + + with source_session_registry_transaction( + request.registry_path, + operation="source_session_goal_recreate", + ) as transaction: + registry = transaction.payload_copy() + active_goal_ref, _goal = current_goal_ref( + registry, + goal_id=request.goal_id, + ) + bindings = session_binding_records(registry) + session_receipts = required_list(registry, "session_receipts") + lifetime_receipts = required_list(registry, "lifetime_receipts") + prior_receipt = prior_operation_receipt( + lifetime_receipts, + operation_id=request.operation_id, + ) + session_operation_receipt = prior_operation_receipt( + session_receipts, + operation_id=request.operation_id, + ) + if session_operation_receipt is not None and ( + session_operation_receipt.get("schema_version") + != "loopx_source_session_retirement_receipt_v1" + or session_operation_receipt.get("request_digest") != request_digest + ): + raise ValueError( + "source-session operation_id was reused across lifecycle operations" + ) + if journal is not None: + reserved_goal_ref = copy.deepcopy(journal["new_goal_ref"]) + elif prior_receipt is not None: + candidate = prior_receipt.get("new_goal_ref") + if not isinstance(candidate, dict): + raise ValueError("Goal recreation receipt new_goal_ref is invalid") + reserved_goal_ref = copy.deepcopy(candidate) + else: + reserved_goal_ref = { + "goal_id": request.goal_id, + "goal_instance_id": f"ginst_{uuid4().hex}", + } + + retiring_bindings = [ + binding + for binding in bindings + if binding.get("foreground_goal_ref") == requested_goal_ref + ] + decision = effect_runtime_result( + "goal.source_session.recreate.decide", + { + "profile_id": registry["profile_id"], + "operation_id": request.operation_id, + "request_digest": request_digest, + "requested_goal_ref": requested_goal_ref, + "current_goal_ref": active_goal_ref, + "reserved_goal_ref": reserved_goal_ref, + "prior_receipt": prior_receipt, + "lifetime_receipt_count": len(lifetime_receipts), + "session_receipt_count": len(session_receipts), + "retiring_binding_count": len(retiring_bindings), + }, + ) + if not isinstance(decision, dict): + raise RuntimeError("Goal recreation decision must be an object") + if decision.get("kind") == "reject": + raise ValueError( + f"source-session recreation rejected: {decision.get('code')}" + ) + if decision.get("kind") == "replay": + replay_receipt = decision.get("receipt") + if not isinstance(replay_receipt, dict): + raise RuntimeError("Goal recreation replay omitted its receipt") + if journal is None: + journal = { + "schema_version": "loopx_goal_recreation_journal_v1", + "operation_id": request.operation_id, + "request_digest": request_digest, + "retired_goal_ref": copy.deepcopy(requested_goal_ref), + "new_goal_ref": copy.deepcopy(replay_receipt["new_goal_ref"]), + "reserved_at": replay_receipt["committed_at"], + "phase": "published", + } + write_journal(journal_path, journal) + elif journal["phase"] != "published": + write_journal(journal_path, {**journal, "phase": "published"}) + return _recreation_result( + request, + receipt=replay_receipt, + replayed=True, + ) + if decision.get("kind") != "commit": + raise RuntimeError("Goal recreation decision kind is unsupported") + + if journal is None: + journal = { + "schema_version": "loopx_goal_recreation_journal_v1", + "operation_id": request.operation_id, + "request_digest": request_digest, + "retired_goal_ref": copy.deepcopy(requested_goal_ref), + "new_goal_ref": copy.deepcopy(reserved_goal_ref), + "reserved_at": now_local_iso(), + "phase": "reserved", + } + write_journal(journal_path, journal) + + committed_at = now_local_iso() + retired_session_ids = sorted( + str(binding["session_id"]) for binding in retiring_bindings + ) + receipt = { + "schema_version": "loopx_goal_recreation_receipt_v1", + "operation_id": request.operation_id, + "request_digest": request_digest, + "retired_goal_ref": copy.deepcopy(requested_goal_ref), + "new_goal_ref": copy.deepcopy(reserved_goal_ref), + "retired_session_ids": retired_session_ids, + "committed_at": committed_at, + } + registry["goals"] = [ + { + **candidate, + "goal_instance_id": reserved_goal_ref["goal_instance_id"], + "execution_authority": False, + } + if candidate.get("id") == request.goal_id + else candidate + for candidate in required_list(registry, "goals") + ] + registry["session_bindings"] = [ + binding + for binding in bindings + if binding.get("foreground_goal_ref") != requested_goal_ref + ] + if retired_session_ids: + session_receipts = [ + *session_receipts, + { + "schema_version": ( + "loopx_source_session_retirement_receipt_v1" + ), + "operation": "retire_bindings", + "operation_id": request.operation_id, + "request_digest": request_digest, + "retired_goal_ref": copy.deepcopy(requested_goal_ref), + "session_ids": retired_session_ids, + "committed_at": committed_at, + }, + ] + registry["session_receipts"] = session_receipts + retired = registry.get("retired_goal_instances", []) + if not isinstance(retired, list) or any( + not isinstance(item, dict) for item in retired + ): + raise ValueError( + "source-session retired_goal_instances must be a list of objects" + ) + registry["retired_goal_instances"] = [ + *retired, + { + "goal_ref": copy.deepcopy(requested_goal_ref), + "successor_goal_ref": copy.deepcopy(reserved_goal_ref), + "operation_id": request.operation_id, + "retired_at": committed_at, + }, + ] + registry["lifetime_receipts"] = [*lifetime_receipts, receipt] + registry["updated_at"] = committed_at + transaction.commit(registry) + write_journal(journal_path, {**journal, "phase": "published"}) + return _recreation_result( + request, + receipt=receipt, + replayed=False, + ) diff --git a/loopx/control_plane/goals/source_session_registration.py b/loopx/control_plane/goals/source_session_registration.py new file mode 100644 index 0000000000..f89a547746 --- /dev/null +++ b/loopx/control_plane/goals/source_session_registration.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +import copy +from dataclasses import dataclass +import hashlib +import json +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from ...file_lock import exclusive_cross_runtime_file_lock +from ..projects.registration_state import ( + registration_state_matches, + render_registration_state, +) +from ..projects.registry_codec import ( + SOURCE_SESSION_PROFILE_ID, + source_session_registry_transaction, +) +from ..runtime.time import now_local_iso +from ..todos.active_state_editing import atomic_write_state_text +from .source_session_registry_state import ( + GOAL_INSTANCE_ID, + alias_digest, + canonical_digest, + guard_path, + lifetime_root, + require_goal_id, + write_journal, +) + + +_JOURNAL_SCHEMA = "loopx_source_session_lifetime_journal_v1" +_CREATION_RECEIPT_SCHEMA = "loopx_goal_creation_receipt_v1" + + +@dataclass(frozen=True, slots=True) +class FreshSourceSessionRegistration: + registry_path: Path + runtime_root: Path + operation_id: str + project_id: str + goal_id: str + objective: str + non_goals: list[str] + acceptance: list[str] + unknowns: list[str] + next_effect: str + stop_condition: str + project_record: dict[str, Any] + goal_record: dict[str, Any] + state_file: Path + + +def _registration_digest(request: FreshSourceSessionRegistration) -> str: + return canonical_digest( + { + "schema_version": "loopx_source_session_registration_request_v1", + "operation_id": request.operation_id, + "project_id": request.project_id, + "goal_id": request.goal_id, + "objective": request.objective, + "non_goals": request.non_goals, + "acceptance": request.acceptance, + "unknowns": request.unknowns, + "next_effect": request.next_effect, + "stop_condition": request.stop_condition, + "project": request.project_record, + "goal": request.goal_record, + "state_file": str(request.state_file), + "runtime_root": str(request.runtime_root), + } + ) + + +def _journal_path( + registry_path: Path, + *, + goal_id: str, + operation_id: str, +) -> Path: + operation_digest = hashlib.sha256(operation_id.encode("utf-8")).hexdigest() + return ( + lifetime_root(registry_path) + / "journals" + / alias_digest(goal_id) + / f"{operation_digest}.json" + ) + + +def _read_journal(path: Path) -> dict[str, Any] | None: + if not path.exists(): + return None + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError("source-session lifetime journal must be a JSON object") + required = { + "schema_version", + "operation_id", + "request_digest", + "goal_ref", + "created_at", + "phase", + } + if set(value) != required: + raise ValueError("source-session lifetime journal shape is invalid") + goal_ref = value.get("goal_ref") + if ( + value.get("schema_version") != _JOURNAL_SCHEMA + or not isinstance(value.get("operation_id"), str) + or not isinstance(value.get("request_digest"), str) + or not isinstance(value.get("created_at"), str) + or value.get("phase") not in {"reserved", "published"} + or not isinstance(goal_ref, dict) + or not isinstance(goal_ref.get("goal_id"), str) + or not isinstance(goal_ref.get("goal_instance_id"), str) + or not GOAL_INSTANCE_ID.fullmatch(goal_ref["goal_instance_id"]) + ): + raise ValueError("source-session lifetime journal content is invalid") + return value + + +def _new_registration_journal( + request: FreshSourceSessionRegistration, + *, + request_digest: str, +) -> dict[str, Any]: + return { + "schema_version": _JOURNAL_SCHEMA, + "operation_id": request.operation_id, + "request_digest": request_digest, + "goal_ref": { + "goal_id": request.goal_id, + "goal_instance_id": f"ginst_{uuid4().hex}", + }, + "created_at": now_local_iso(), + "phase": "reserved", + } + + +def _require_matching_journal( + journal: dict[str, Any], + request: FreshSourceSessionRegistration, + *, + request_digest: str, +) -> None: + if journal["operation_id"] != request.operation_id: + raise ValueError("source-session journal operation_id mismatch") + if journal["request_digest"] != request_digest: + raise ValueError("source-session operation_id was reused with different input") + if journal["goal_ref"]["goal_id"] != request.goal_id: + raise ValueError("source-session journal goal_id mismatch") + + +def _state_text( + request: FreshSourceSessionRegistration, + *, + updated_at: str, +) -> str: + return render_registration_state( + project_id=request.project_id, + goal_id=request.goal_id, + objective=request.objective, + non_goals=request.non_goals, + acceptance=request.acceptance, + unknowns=request.unknowns, + next_effect=request.next_effect, + stop_condition=request.stop_condition, + updated_at=updated_at, + ) + + +def _ensure_registration_state( + request: FreshSourceSessionRegistration, + *, + updated_at: str, +) -> bool: + expected = _state_text(request, updated_at=updated_at) + with exclusive_cross_runtime_file_lock( + request.state_file, + operation="source_session_registration_state", + ): + if request.state_file.exists(): + existing = request.state_file.read_text(encoding="utf-8") + if not registration_state_matches( + existing, + expected, + objective=request.objective, + ): + raise ValueError( + f"goal state file conflicts with registration: {request.state_file}" + ) + return False + atomic_write_state_text(request.state_file, expected, create_only=True) + return True + + +def _creation_receipt( + request: FreshSourceSessionRegistration, + journal: dict[str, Any], +) -> dict[str, Any]: + return { + "schema_version": _CREATION_RECEIPT_SCHEMA, + "operation_id": request.operation_id, + "request_digest": journal["request_digest"], + "goal_ref": copy.deepcopy(journal["goal_ref"]), + "created_at": journal["created_at"], + } + + +def _matching_creation_receipt( + registry: dict[str, Any], + request: FreshSourceSessionRegistration, + *, + request_digest: str, +) -> dict[str, Any] | None: + receipts = registry.get("lifetime_receipts") + if not isinstance(receipts, list): + raise ValueError("source-session lifetime_receipts must be a list") + matches = [ + receipt + for receipt in receipts + if isinstance(receipt, dict) + and receipt.get("operation_id") == request.operation_id + ] + if len(matches) > 1: + raise ValueError("source-session operation has duplicate lifetime receipts") + if not matches: + return None + receipt = matches[0] + if ( + receipt.get("schema_version") != _CREATION_RECEIPT_SCHEMA + or receipt.get("request_digest") != request_digest + or not isinstance(receipt.get("created_at"), str) + or not isinstance(receipt.get("goal_ref"), dict) + ): + raise ValueError("source-session operation_id conflicts with its receipt") + return receipt + + +def register_fresh_source_session_project( + request: FreshSourceSessionRegistration, +) -> dict[str, Any]: + """Create or replay the one fresh-project source-session publication.""" + + require_goal_id(request.goal_id) + request_digest = _registration_digest(request) + guard = guard_path(request.registry_path, request.goal_id) + journal_path = _journal_path( + request.registry_path, + goal_id=request.goal_id, + operation_id=request.operation_id, + ) + with exclusive_cross_runtime_file_lock( + guard, + operation="source_session_goal_lifetime", + ): + journal = _read_journal(journal_path) + if journal is not None: + _require_matching_journal( + journal, + request, + request_digest=request_digest, + ) + elif not request.registry_path.exists(): + journal = _new_registration_journal( + request, + request_digest=request_digest, + ) + write_journal(journal_path, journal) + + with source_session_registry_transaction( + request.registry_path, + operation="source_session_project_register", + create=lambda: { + "schema_version": "0.2", + "registry_role": "project-local", + "common_runtime_root": str(request.runtime_root), + "profile_id": SOURCE_SESSION_PROFILE_ID, + "projects": [], + "goals": [], + "session_bindings": [], + "session_receipts": [], + "lifetime_receipts": [], + "retired_goal_instances": [], + }, + ) as transaction: + registry = transaction.payload_copy() + existing_receipt = _matching_creation_receipt( + registry, + request, + request_digest=request_digest, + ) + if existing_receipt is not None: + goal_ref = existing_receipt["goal_ref"] + goals = registry.get("goals") + if ( + not isinstance(goals, list) + or len(goals) != 1 + or not isinstance(goals[0], dict) + or goals[0].get("id") != goal_ref.get("goal_id") + or goals[0].get("goal_instance_id") + != goal_ref.get("goal_instance_id") + ): + raise ValueError( + "source-session creation receipt does not match current Goal" + ) + state_changed = _ensure_registration_state( + request, + updated_at=existing_receipt["created_at"], + ) + if journal is None: + journal = { + "schema_version": _JOURNAL_SCHEMA, + "operation_id": request.operation_id, + "request_digest": request_digest, + "goal_ref": copy.deepcopy(goal_ref), + "created_at": existing_receipt["created_at"], + "phase": "published", + } + write_journal(journal_path, journal) + elif journal["phase"] != "published": + journal = {**journal, "phase": "published"} + write_journal(journal_path, journal) + return { + "ok": True, + "schema_version": "loopx_project_registration_v1", + "changed": state_changed, + "registry": str(request.registry_path), + "project": registry["projects"][0], + "goal": goals[0], + "goal_ref": copy.deepcopy(goal_ref), + "request_digest": request_digest, + "state_file": str(request.state_file), + "execution_authority": False, + } + + if request.registry_path.exists(): + raise ValueError( + "source_session_v1 registration requires an absent registry" + ) + if journal is None: + raise RuntimeError("source-session registration reservation is missing") + if journal["phase"] == "published": + raise ValueError( + "published source-session registry is missing; refusing recreation" + ) + + goal_record = { + **copy.deepcopy(request.goal_record), + "goal_instance_id": journal["goal_ref"]["goal_instance_id"], + "execution_authority": False, + } + receipt = _creation_receipt(request, journal) + _ensure_registration_state( + request, + updated_at=journal["created_at"], + ) + registry["projects"] = [copy.deepcopy(request.project_record)] + registry["goals"] = [goal_record] + registry["lifetime_receipts"] = [receipt] + registry["updated_at"] = journal["created_at"] + transaction.commit(registry) + journal = {**journal, "phase": "published"} + write_journal(journal_path, journal) + + return { + "ok": True, + "schema_version": "loopx_project_registration_v1", + "changed": True, + "registry": str(request.registry_path), + "project": copy.deepcopy(request.project_record), + "goal": goal_record, + "goal_ref": copy.deepcopy(journal["goal_ref"]), + "request_digest": request_digest, + "state_file": str(request.state_file), + "execution_authority": False, + } diff --git a/loopx/control_plane/goals/source_session_registry_state.py b/loopx/control_plane/goals/source_session_registry_state.py new file mode 100644 index 0000000000..0b9a8fa370 --- /dev/null +++ b/loopx/control_plane/goals/source_session_registry_state.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import re +from typing import Any + +from ...registry import atomic_write_json +from ..projects.registry_codec import SOURCE_SESSION_PROFILE_ID +from ..todos.active_state_editing import fsync_state_directory + + +GOAL_ID = re.compile(r"^[A-Za-z0-9._:-]{1,200}$") +GOAL_INSTANCE_ID = re.compile(r"^ginst_[0-9a-f]{32}$") + + +def canonical_digest(value: object) -> str: + encoded = json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return "sha256:" + hashlib.sha256(encoded).hexdigest() + + +def alias_digest(goal_id: str) -> str: + return hashlib.sha256(goal_id.encode("utf-8")).hexdigest() + + +def lifetime_root(registry_path: Path) -> Path: + return registry_path.parent / ".loopx" / "lifecycle" / "goal-instance" + + +def guard_path(registry_path: Path, goal_id: str) -> Path: + return lifetime_root(registry_path) / "guards" / f"{alias_digest(goal_id)}.guard" + + +def write_journal(path: Path, payload: dict[str, Any]) -> None: + atomic_write_json(path, payload, preserve_mode=True) + fsync_state_directory(path) + + +def require_goal_id(goal_id: str) -> None: + if not GOAL_ID.fullmatch(goal_id): + raise ValueError("source-session goal_id must be 1-200 safe characters") + + +def exact_goal_ref(goal_id: str, goal_instance_id: str) -> dict[str, str]: + require_goal_id(goal_id) + if not GOAL_INSTANCE_ID.fullmatch(goal_instance_id): + raise ValueError("goal_instance_id must be a Goal instance identifier") + return { + "goal_id": goal_id, + "goal_instance_id": goal_instance_id, + } + + +def required_list( + registry: dict[str, Any], + field: str, +) -> list[dict[str, Any]]: + value = registry.get(field) + if not isinstance(value, list) or any(not isinstance(item, dict) for item in value): + raise ValueError(f"source-session {field} must be a list of objects") + return value + + +def current_goal_ref( + registry: dict[str, Any], + *, + goal_id: str, +) -> tuple[dict[str, str], dict[str, Any]]: + if registry.get("profile_id") != SOURCE_SESSION_PROFILE_ID: + raise ValueError("source-session registry profile is unsupported") + goals = required_list(registry, "goals") + matches = [goal for goal in goals if goal.get("id") == goal_id] + if len(matches) != 1: + raise ValueError(f"goal_id is not registered exactly once: {goal_id}") + goal = matches[0] + if goal.get("status") != "active": + raise ValueError(f"foreground goal is not active: {goal_id}") + instance_id = goal.get("goal_instance_id") + if not isinstance(instance_id, str): + raise ValueError("source-session Goal is missing goal_instance_id") + return exact_goal_ref(goal_id, instance_id), goal + + +def session_binding_records( + registry: dict[str, Any], +) -> list[dict[str, Any]]: + bindings = required_list(registry, "session_bindings") + seen: set[str] = set() + for binding in bindings: + session_id = binding.get("session_id") + goal_ref = binding.get("foreground_goal_ref") + if ( + not isinstance(session_id, str) + or not session_id + or session_id in seen + or not isinstance(goal_ref, dict) + or not isinstance(goal_ref.get("goal_id"), str) + or not isinstance(goal_ref.get("goal_instance_id"), str) + or not GOAL_INSTANCE_ID.fullmatch(goal_ref["goal_instance_id"]) + ): + raise ValueError("source-session binding is invalid") + seen.add(session_id) + return bindings + + +def prior_operation_receipt( + receipts: list[dict[str, Any]], + *, + operation_id: str, +) -> dict[str, Any] | None: + matches = [ + receipt for receipt in receipts if receipt.get("operation_id") == operation_id + ] + if len(matches) > 1: + raise ValueError("source-session operation has duplicate receipts") + return matches[0] if matches else None diff --git a/loopx/control_plane/goals/source_session_services.py b/loopx/control_plane/goals/source_session_services.py new file mode 100644 index 0000000000..9d6e8fc1b3 --- /dev/null +++ b/loopx/control_plane/goals/source_session_services.py @@ -0,0 +1,27 @@ +"""Closed source-session lifecycle services.""" + +from .source_session_binding import ( + SessionBindingRequest, + commit_project_session_binding, + commit_project_session_unbinding, + resolve_source_session_project, +) +from .source_session_recreation import ( + RecreateGoalRequest, + recreate_goal_instance, +) +from .source_session_registration import ( + FreshSourceSessionRegistration, + register_fresh_source_session_project, +) + +__all__ = [ + "FreshSourceSessionRegistration", + "RecreateGoalRequest", + "SessionBindingRequest", + "commit_project_session_binding", + "commit_project_session_unbinding", + "recreate_goal_instance", + "register_fresh_source_session_project", + "resolve_source_session_project", +] diff --git a/loopx/control_plane/projects/registration_state.py b/loopx/control_plane/projects/registration_state.py new file mode 100644 index 0000000000..9d6861e9be --- /dev/null +++ b/loopx/control_plane/projects/registration_state.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import json +import re + +from ..goals.active_state_metadata import ( + markdown_blockquote, + markdown_frontmatter_string, + split_state_frontmatter, +) + + +def render_registration_state( + *, + project_id: str, + goal_id: str, + objective: str, + non_goals: list[str], + acceptance: list[str], + unknowns: list[str], + next_effect: str, + stop_condition: str, + updated_at: str, +) -> str: + def bullets(items: list[str], *, empty: str) -> str: + return "\n".join(f"- {item}" for item in items) if items else f"- {empty}" + + return f"""--- +status: active +owner_mode: goal +project_id: {json.dumps(project_id, ensure_ascii=False)} +objective: {markdown_frontmatter_string(objective)} +updated_at: {updated_at} +adapter_id: {goal_id} +--- + +# Active Goal State + +## Objective + +{markdown_blockquote(objective)} + +## Acceptance + +{bullets(acceptance, empty="No acceptance evidence recorded.")} + +## Non-Goals + +{bullets(non_goals, empty="No additional non-goals recorded.")} + +## Unknowns + +{bullets(unknowns, empty="No decision-relevant unknowns recorded.")} + +## User Todo / Owner Review Reading Queue + +## Agent Todo + +## Next Action + +- {next_effect} + +## Stop Condition + +- {stop_condition} + +## Progress Ledger + +- Registered Project `{project_id}` and Goal `{goal_id}`. +""" + + +def registration_state_matches( + existing: str, + expected: str, + *, + objective: str, +) -> bool: + """Compare metadata values and exact narrative without rewriting old state.""" + + existing_metadata, existing_body = split_state_frontmatter(existing) + expected_metadata, expected_body = split_state_frontmatter(expected) + if existing_metadata != expected_metadata: + return False + marker = "\n## Objective\n\n" + existing_prefix, separator, existing_section = existing_body.partition(marker) + expected_prefix, _, expected_section = expected_body.partition(marker) + if not separator or existing_prefix != expected_prefix: + return False + quoted = markdown_blockquote(objective) + remainder = expected_section[len(quoted) :] + return existing_section in (quoted + remainder, objective + remainder) + + +def registration_state_updated_at(state_text: str) -> str | None: + match = re.search(r"^updated_at: (.+)$", state_text, flags=re.MULTILINE) + return match.group(1) if match is not None else None diff --git a/loopx/control_plane/projects/registry.py b/loopx/control_plane/projects/registry.py index 9a89bbe04c..e7edf1064d 100644 --- a/loopx/control_plane/projects/registry.py +++ b/loopx/control_plane/projects/registry.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import re from pathlib import Path from typing import Any @@ -10,12 +9,24 @@ from ...paths import resolve_runtime_root from ..todos.active_state_editing import atomic_write_state_text as _atomic_write_text from ..coordination.legacy_writer_fence import legacy_todo_write_transaction, require_legacy_state_replacement_allowed -from ...paths import DEFAULT_RUNTIME_ROOT -from ..goals.active_state_metadata import ( - markdown_blockquote, markdown_frontmatter_string, split_state_frontmatter, +from ..goals.source_session_services import ( + FreshSourceSessionRegistration, + RecreateGoalRequest, + SessionBindingRequest, + commit_project_session_binding, + commit_project_session_unbinding, + recreate_goal_instance, + register_fresh_source_session_project, + resolve_source_session_project, ) +from ...paths import DEFAULT_RUNTIME_ROOT from ...repository_identity import normalize_repository_identity from .contract import validate_project_record_bindings +from .registration_state import ( + registration_state_matches, + registration_state_updated_at, + render_registration_state, +) from .registry_codec import ( load_project_registry, mutate_project_registry, @@ -120,82 +131,6 @@ def _resolve_exact_project_binding( } -def _state_markdown( - *, - project_id: str, - goal_id: str, - objective: str, - non_goals: list[str], - acceptance: list[str], - unknowns: list[str], - next_effect: str, - stop_condition: str, - updated_at: str, -) -> str: - def bullets(items: list[str], *, empty: str) -> str: - return "\n".join(f"- {item}" for item in items) if items else f"- {empty}" - - return f"""--- -status: active -owner_mode: goal -project_id: {json.dumps(project_id, ensure_ascii=False)} -objective: {markdown_frontmatter_string(objective)} -updated_at: {updated_at} -adapter_id: {goal_id} ---- - -# Active Goal State - -## Objective - -{markdown_blockquote(objective)} - -## Acceptance - -{bullets(acceptance, empty="No acceptance evidence recorded.")} - -## Non-Goals - -{bullets(non_goals, empty="No additional non-goals recorded.")} - -## Unknowns - -{bullets(unknowns, empty="No decision-relevant unknowns recorded.")} - -## User Todo / Owner Review Reading Queue - -## Agent Todo - -## Next Action - -- {next_effect} - -## Stop Condition - -- {stop_condition} - -## Progress Ledger - -- Registered Project `{project_id}` and Goal `{goal_id}`. -""" - - -def _registration_state_matches(existing: str, expected: str, *, objective: str) -> bool: - """Compare metadata values and exact narrative without rewriting old state.""" - existing_metadata, existing_body = split_state_frontmatter(existing) - expected_metadata, expected_body = split_state_frontmatter(expected) - if existing_metadata != expected_metadata: - return False - marker = "\n## Objective\n\n" - existing_prefix, separator, existing_section = existing_body.partition(marker) - expected_prefix, _, expected_section = expected_body.partition(marker) - if not separator or existing_prefix != expected_prefix: - return False - quoted = markdown_blockquote(objective) - remainder = expected_section[len(quoted):] - return existing_section in (quoted + remainder, objective + remainder) - - def register_project_goal( *, registry_path: Path, @@ -212,6 +147,8 @@ def register_project_goal( stop_condition: str, repository_bindings: list[str], external_locator_bindings: list[str], + goal_instance_profile: str | None = None, + operation_id: str | None = None, ) -> dict[str, Any]: project_id = _identifier(project_id, field="project_id") goal_id = _identifier(goal_id, field="goal_id") @@ -271,7 +208,7 @@ def register_project_goal( "next_effect": next_effect, "stop_condition": stop_condition, } - state_text = _state_markdown( + state_text = render_registration_state( project_id=project_id, goal_id=goal_id, objective=objective, @@ -282,6 +219,32 @@ def register_project_goal( stop_condition=stop_condition, updated_at=updated_at, ) + if goal_instance_profile is not None: + if goal_instance_profile != "source_session_v1": + raise ValueError("goal_instance_profile is unsupported") + operation_id = _identifier(operation_id or "", field="operation_id") + return register_fresh_source_session_project( + FreshSourceSessionRegistration( + registry_path=registry_path, + runtime_root=(runtime_root or DEFAULT_RUNTIME_ROOT) + .expanduser() + .resolve(), + operation_id=operation_id, + project_id=project_id, + goal_id=goal_id, + objective=objective, + non_goals=non_goals, + acceptance=acceptance, + unknowns=unknowns, + next_effect=next_effect, + stop_condition=stop_condition, + project_record=project_record, + goal_record=goal_record, + state_file=state_file, + ) + ) + if operation_id is not None: + raise ValueError("operation_id requires goal_instance_profile") with project_registry_transaction( registry_path, @@ -326,13 +289,9 @@ def register_project_goal( require_legacy_state_replacement_allowed(runtime_root=effective_root, goal_id=goal_id, goal=existing_goal) if state_file.exists(): existing_state = state_file.read_text(encoding="utf-8") - existing_updated_at = re.search( - r"^updated_at: (.+)$", - existing_state, - flags=re.MULTILINE, - ) + existing_updated_at = registration_state_updated_at(existing_state) matching_state = ( - _state_markdown( + render_registration_state( project_id=project_id, goal_id=goal_id, objective=objective, @@ -341,12 +300,12 @@ def register_project_goal( unknowns=unknowns, next_effect=next_effect, stop_condition=stop_condition, - updated_at=existing_updated_at.group(1), + updated_at=existing_updated_at, ) if existing_updated_at is not None else None ) - if matching_state is None or not _registration_state_matches( + if matching_state is None or not registration_state_matches( existing_state, matching_state, objective=objective, ): raise ValueError( @@ -405,12 +364,28 @@ def bind_session( registry_path: Path, session_id: str, goal_id: str, + goal_instance_id: str | None = None, + operation_id: str | None = None, ) -> dict[str, Any]: session_id = str(session_id or "").strip() if not session_id: raise ValueError("session_id is required") goal_id = _identifier(goal_id, field="goal_id") registry_path = registry_path.expanduser() + if goal_instance_id is not None or operation_id is not None: + if goal_instance_id is None or operation_id is None: + raise ValueError( + "goal_instance_id and operation_id are both required for exact binding" + ) + return commit_project_session_binding( + SessionBindingRequest( + registry_path=registry_path, + session_id=session_id, + goal_id=goal_id, + goal_instance_id=goal_instance_id, + operation_id=_identifier(operation_id, field="operation_id"), + ) + ) def reduce(registry: dict[str, Any]) -> dict[str, Any]: project_id = _project_id_for_goal( @@ -476,6 +451,8 @@ def unbind_session( registry_path: Path, session_id: str, goal_id: str, + goal_instance_id: str | None = None, + operation_id: str | None = None, ) -> dict[str, Any]: """Remove one exact session-to-goal binding without touching peer sessions.""" @@ -484,6 +461,20 @@ def unbind_session( raise ValueError("session_id is required") goal_id = _identifier(goal_id, field="goal_id") registry_path = registry_path.expanduser() + if goal_instance_id is not None or operation_id is not None: + if goal_instance_id is None or operation_id is None: + raise ValueError( + "goal_instance_id and operation_id are both required for exact unbinding" + ) + return commit_project_session_unbinding( + SessionBindingRequest( + registry_path=registry_path, + session_id=session_id, + goal_id=goal_id, + goal_instance_id=goal_instance_id, + operation_id=_identifier(operation_id, field="operation_id"), + ) + ) def reduce(registry: dict[str, Any]) -> dict[str, Any]: project_id = _project_id_for_goal( @@ -538,6 +529,26 @@ def reduce(registry: dict[str, Any]) -> dict[str, Any]: ) +def recreate_goal( + *, + registry_path: Path, + goal_id: str, + goal_instance_id: str, + operation_id: str, + execute: bool, +) -> dict[str, Any]: + if not execute: + raise ValueError("recreate-goal requires --execute") + return recreate_goal_instance( + RecreateGoalRequest( + registry_path=registry_path.expanduser(), + goal_id=_identifier(goal_id, field="goal_id"), + goal_instance_id=str(goal_instance_id or "").strip(), + operation_id=_identifier(operation_id, field="operation_id"), + ) + ) + + def resolve_project( *, registry_path: Path, @@ -545,11 +556,21 @@ def resolve_project( session_id: str | None, repository: str | None, external_locator: str | None, + goal_id: str | None = None, + goal_instance_id: str | None = None, ) -> dict[str, Any]: registry_path = registry_path.expanduser() if not registry_path.exists(): raise FileNotFoundError(f"registry file does not exist: {registry_path}") registry = load_project_registry(registry_path) + if registry.get("profile_id") == "source_session_v1": + return resolve_source_session_project( + registry=registry, + registry_path=registry_path, + goal_id=goal_id, + goal_instance_id=goal_instance_id, + session_id=session_id, + ) projects = _registry_records( registry, field="projects", diff --git a/loopx/control_plane/projects/registry_codec.py b/loopx/control_plane/projects/registry_codec.py index 51e44d66d4..ddd3815e06 100644 --- a/loopx/control_plane/projects/registry_codec.py +++ b/loopx/control_plane/projects/registry_codec.py @@ -18,7 +18,10 @@ STRICT_SCHEMA_VERSION = "loopx_project_registry_envelope_v1" +SOURCE_SESSION_SCHEMA_VERSION = "loopx_project_registry_envelope_v2" CURRENT_WRITER_PROTOCOL = "goal_instance_v1" +SOURCE_SESSION_WRITER_PROTOCOL = "goal_instance_v2" +SOURCE_SESSION_PROFILE_ID = "source_session_v1" _STRICT_HEADER_KEYS = { "schema_version", "minimum_writer_protocol", @@ -47,7 +50,8 @@ class ProjectRegistryRestoreError(ProjectRegistryMutationError): class _ProjectRegistryFormat(str, Enum): LEGACY_OBJECT = "legacy_object_v0" - STRICT_ENVELOPE = "strict_envelope_v1" + STRICT_ENVELOPE_V1 = "strict_envelope_v1" + STRICT_ENVELOPE_V2 = "strict_envelope_v2" @dataclass(frozen=True, slots=True) @@ -127,7 +131,12 @@ def _decode_document(raw_bytes: bytes) -> _ProjectRegistryDocument: "strict project registry header must contain exactly " "schema_version, minimum_writer_protocol, and payload_sha256" ) - if header["schema_version"] != STRICT_SCHEMA_VERSION: + schema_version = header["schema_version"] + strict_formats = { + STRICT_SCHEMA_VERSION: _ProjectRegistryFormat.STRICT_ENVELOPE_V1, + SOURCE_SESSION_SCHEMA_VERSION: _ProjectRegistryFormat.STRICT_ENVELOPE_V2, + } + if schema_version not in strict_formats: raise ProjectRegistryError( "strict project registry schema_version is unsupported" ) @@ -157,7 +166,7 @@ def _decode_document(raw_bytes: bytes) -> _ProjectRegistryDocument: ) return _ProjectRegistryDocument( payload=payload, - format=_ProjectRegistryFormat.STRICT_ENVELOPE, + format=strict_formats[schema_version], minimum_writer_protocol=protocol, raw_bytes=raw_bytes, ) @@ -187,7 +196,12 @@ def decode_registry_snapshot(path: Path, raw_bytes: bytes) -> dict[str, Any]: if not isinstance(payload, dict): raise ProjectRegistryError("global registry root must be a JSON object") return payload - return decode_project_registry(raw_bytes) + payload = decode_project_registry(raw_bytes) + require_runtime_compatible_project_registry( + payload, + operation="generic registry read", + ) + return payload def load_registry(path: Path) -> dict[str, Any]: @@ -199,6 +213,20 @@ def load_registry(path: Path) -> dict[str, Any]: return decode_registry_snapshot(expanded, expanded.read_bytes()) +def require_runtime_compatible_project_registry( + payload: dict[str, Any], + *, + operation: str, +) -> None: + """Reject the lifecycle-only M2 profile before host or business effects.""" + + if payload.get("profile_id") == SOURCE_SESSION_PROFILE_ID: + raise ProjectRegistryProtocolError( + f"{operation} rejects lifecycle-only profile " + f"{SOURCE_SESSION_PROFILE_ID}; use project lifecycle commands" + ) + + def _encode_document( payload: dict[str, Any], *, @@ -211,9 +239,14 @@ def _encode_document( root: object = payload allow_nan = True else: + schema_version = ( + STRICT_SCHEMA_VERSION + if format is _ProjectRegistryFormat.STRICT_ENVELOPE_V1 + else SOURCE_SESSION_SCHEMA_VERSION + ) root = [ { - "schema_version": STRICT_SCHEMA_VERSION, + "schema_version": schema_version, "minimum_writer_protocol": minimum_writer_protocol, "payload_sha256": _payload_digest(payload), }, @@ -254,7 +287,7 @@ def _atomic_write_bytes(path: Path, payload: bytes, *, mode: int | None) -> None def _require_supported_writer(document: _ProjectRegistryDocument) -> None: protocol = document.minimum_writer_protocol if ( - document.format is _ProjectRegistryFormat.STRICT_ENVELOPE + document.format is not _ProjectRegistryFormat.LEGACY_OBJECT and protocol != CURRENT_WRITER_PROTOCOL ): raise ProjectRegistryProtocolError( @@ -263,6 +296,20 @@ def _require_supported_writer(document: _ProjectRegistryDocument) -> None: ) +def _require_source_session_writer(document: _ProjectRegistryDocument) -> None: + if ( + document.format is not _ProjectRegistryFormat.STRICT_ENVELOPE_V2 + or document.minimum_writer_protocol != SOURCE_SESSION_WRITER_PROTOCOL + or document.payload.get("profile_id") != SOURCE_SESSION_PROFILE_ID + ): + raise ProjectRegistryProtocolError( + "source-session transaction requires " + f"{SOURCE_SESSION_SCHEMA_VERSION}, " + f"{SOURCE_SESSION_WRITER_PROTOCOL}, and " + f"profile_id={SOURCE_SESSION_PROFILE_ID}" + ) + + class ProjectRegistryTransaction: def __init__( self, @@ -356,15 +403,14 @@ def restore(self) -> None: @contextmanager -def project_registry_transaction( +def _registry_transaction( path: Path, *, operation: str, - create: Callable[[], dict[str, Any]] | None = None, + create_document: Callable[[], _ProjectRegistryDocument] | None, + require_writer: Callable[[_ProjectRegistryDocument], None], agent_id: str | None = None, ) -> Iterator[ProjectRegistryTransaction]: - """Hold one project-registry lock for a compound owner transaction.""" - expanded = path.expanduser() with exclusive_cross_runtime_file_lock( expanded, @@ -376,23 +422,13 @@ def project_registry_transaction( document = _read_document(expanded) mode = expanded.stat().st_mode & 0o777 else: - if create is None: + if create_document is None: raise FileNotFoundError( f"registry file does not exist: {expanded}" ) - payload = create() - if not isinstance(payload, dict): - raise TypeError( - "project registry initializer must return a JSON object" - ) - document = _ProjectRegistryDocument( - payload=copy.deepcopy(payload), - format=_ProjectRegistryFormat.LEGACY_OBJECT, - minimum_writer_protocol=None, - raw_bytes=b"", - ) + document = create_document() mode = None - _require_supported_writer(document) + require_writer(document) yield ProjectRegistryTransaction( expanded, document=document, @@ -401,6 +437,92 @@ def project_registry_transaction( ) +def _created_document( + create: Callable[[], dict[str, Any]], + *, + format: _ProjectRegistryFormat, + minimum_writer_protocol: str | None, +) -> _ProjectRegistryDocument: + payload = create() + if not isinstance(payload, dict): + raise TypeError("project registry initializer must return a JSON object") + return _ProjectRegistryDocument( + payload=copy.deepcopy(payload), + format=format, + minimum_writer_protocol=minimum_writer_protocol, + raw_bytes=b"", + ) + + +def _document_factory( + create: Callable[[], dict[str, Any]] | None, + *, + format: _ProjectRegistryFormat, + minimum_writer_protocol: str | None, +) -> Callable[[], _ProjectRegistryDocument] | None: + if create is None: + return None + + def build() -> _ProjectRegistryDocument: + return _created_document( + create, + format=format, + minimum_writer_protocol=minimum_writer_protocol, + ) + + return build + + +@contextmanager +def project_registry_transaction( + path: Path, + *, + operation: str, + create: Callable[[], dict[str, Any]] | None = None, + agent_id: str | None = None, +) -> Iterator[ProjectRegistryTransaction]: + """Hold one legacy/v1 project-registry transaction.""" + + create_document = _document_factory( + create, + format=_ProjectRegistryFormat.LEGACY_OBJECT, + minimum_writer_protocol=None, + ) + with _registry_transaction( + path, + operation=operation, + create_document=create_document, + require_writer=_require_supported_writer, + agent_id=agent_id, + ) as transaction: + yield transaction + + +@contextmanager +def source_session_registry_transaction( + path: Path, + *, + operation: str, + create: Callable[[], dict[str, Any]] | None = None, + agent_id: str | None = None, +) -> Iterator[ProjectRegistryTransaction]: + """Hold one source-session v2 project-registry transaction.""" + + create_document = _document_factory( + create, + format=_ProjectRegistryFormat.STRICT_ENVELOPE_V2, + minimum_writer_protocol=SOURCE_SESSION_WRITER_PROTOCOL, + ) + with _registry_transaction( + path, + operation=operation, + create_document=create_document, + require_writer=_require_source_session_writer, + agent_id=agent_id, + ) as transaction: + yield transaction + + def mutate_project_registry( path: Path, *, diff --git a/loopx/kunluncode_goal_mode/cli.py b/loopx/kunluncode_goal_mode/cli.py index 4e83b5011f..cff0cae4a7 100644 --- a/loopx/kunluncode_goal_mode/cli.py +++ b/loopx/kunluncode_goal_mode/cli.py @@ -15,6 +15,7 @@ from loopx.control_plane.projects.registry_codec import ( load_project_registry, mutate_project_registry, + require_runtime_compatible_project_registry, ) from loopx.goal_mode_context import registered_agent_ids from loopx.kunluncode_goal_mode import DEFAULT_AGENT_ID, MCP_SERVER_NAME @@ -314,6 +315,10 @@ def reduce(payload: dict[str, Any]) -> None: def _registered_agents_for_goal(registry: Path, goal_id: str) -> list[str]: payload = load_project_registry(registry) + require_runtime_compatible_project_registry( + payload, + operation="KunlunCode Goal adapter", + ) goal = next( ( item diff --git a/loopx/semantics/project_registry_io.py b/loopx/semantics/project_registry_io.py index 8418b9b5fc..bf240469ac 100644 --- a/loopx/semantics/project_registry_io.py +++ b/loopx/semantics/project_registry_io.py @@ -36,7 +36,12 @@ } ) APPROVED_WRITE_APIS = frozenset({"mutate_project_registry"}) -APPROVED_TRANSACTION_APIS = frozenset({"project_registry_transaction"}) +APPROVED_TRANSACTION_APIS = frozenset( + { + "project_registry_transaction", + "source_session_registry_transaction", + } +) DIRECT_READ_APIS = frozenset( {"read_json", "read_json_object", "_read_json", "parse_json_object"} ) diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index c8c570eaf3..7130bd4cf0 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -39,15 +39,15 @@ }, { "site": "loopx/authority.py::.import_doc_registry_authority::codec_read:load_project_registry#1", - "line": 572, - "column": 27, + "line": 575, + "column": 20, "kind": "codec_read", "api": "load_project_registry", "classification": "codec_api" }, { "site": "loopx/authority.py::.import_doc_registry_authority::codec_write:mutate_project_registry#1", - "line": 575, + "line": 582, "column": 35, "kind": "codec_write", "api": "mutate_project_registry", @@ -56,14 +56,14 @@ { "site": "loopx/authority.py::.register_authority_source::codec_read:load_project_registry#1", "line": 461, - "column": 27, + "column": 20, "kind": "codec_read", "api": "load_project_registry", "classification": "codec_api" }, { "site": "loopx/authority.py::.register_authority_source::codec_write:mutate_project_registry#1", - "line": 464, + "line": 468, "column": 35, "kind": "codec_write", "api": "mutate_project_registry", @@ -71,7 +71,7 @@ }, { "site": "loopx/bootstrap.py::.bootstrap_project::codec_transaction:project_registry_transaction#1", - "line": 496, + "line": 502, "column": 14, "kind": "codec_transaction", "api": "project_registry_transaction", @@ -79,8 +79,8 @@ }, { "site": "loopx/bootstrap.py::.read_json_if_exists::codec_read:load_project_registry#1", - "line": 73, - "column": 12, + "line": 74, + "column": 15, "kind": "codec_read", "api": "load_project_registry", "classification": "codec_api" @@ -407,8 +407,8 @@ }, { "site": "loopx/claude_goal_mode/scripts/connect.py::.main::codec_read:load_project_registry#1", - "line": 73, - "column": 13, + "line": 75, + "column": 23, "kind": "codec_read", "api": "load_project_registry", "classification": "codec_api" @@ -791,7 +791,7 @@ }, { "site": "loopx/configure_goal.py::.configure_goal::codec_transaction:project_registry_transaction#1", - "line": 507, + "line": 508, "column": 14, "kind": "codec_transaction", "api": "project_registry_transaction", @@ -799,7 +799,7 @@ }, { "site": "loopx/configure_goal.py::.configure_goal::codec_read:load_project_registry#1", - "line": 710, + "line": 711, "column": 14, "kind": "codec_read", "api": "load_project_registry", @@ -1093,6 +1093,30 @@ "api": "load_registry", "classification": "codec_api" }, + { + "site": "loopx/control_plane/goals/source_session_binding.py::._commit_project_session_operation::codec_transaction:source_session_registry_transaction#1", + "line": 101, + "column": 14, + "kind": "codec_transaction", + "api": "source_session_registry_transaction", + "classification": "codec_api" + }, + { + "site": "loopx/control_plane/goals/source_session_recreation.py::.recreate_goal_instance::codec_transaction:source_session_registry_transaction#1", + "line": 149, + "column": 14, + "kind": "codec_transaction", + "api": "source_session_registry_transaction", + "classification": "codec_api" + }, + { + "site": "loopx/control_plane/goals/source_session_registration.py::.register_fresh_source_session_project::codec_transaction:source_session_registry_transaction#1", + "line": 272, + "column": 14, + "kind": "codec_transaction", + "api": "source_session_registry_transaction", + "classification": "codec_api" + }, { "site": "loopx/control_plane/goals/start_goal_todo_delta.py::._read_registry::codec_read:load_registry#1", "line": 229, @@ -1127,7 +1151,7 @@ }, { "site": "loopx/control_plane/projects/registry.py::.bind_session::codec_write:mutate_project_registry#1", - "line": 467, + "line": 442, "column": 12, "kind": "codec_write", "api": "mutate_project_registry", @@ -1135,7 +1159,7 @@ }, { "site": "loopx/control_plane/projects/registry.py::.register_project_goal::codec_transaction:project_registry_transaction#1", - "line": 286, + "line": 249, "column": 10, "kind": "codec_transaction", "api": "project_registry_transaction", @@ -1143,7 +1167,7 @@ }, { "site": "loopx/control_plane/projects/registry.py::.resolve_project::codec_read:load_project_registry#1", - "line": 552, + "line": 565, "column": 16, "kind": "codec_read", "api": "load_project_registry", @@ -1151,7 +1175,7 @@ }, { "site": "loopx/control_plane/projects/registry.py::.unbind_session::codec_write:mutate_project_registry#1", - "line": 534, + "line": 525, "column": 12, "kind": "codec_write", "api": "mutate_project_registry", @@ -1159,7 +1183,7 @@ }, { "site": "loopx/control_plane/projects/registry_codec.py::.add_project_registry_backend::codec_write:mutate_project_registry#1", - "line": 442, + "line": 564, "column": 12, "kind": "codec_write", "api": "mutate_project_registry", @@ -1167,15 +1191,15 @@ }, { "site": "loopx/control_plane/projects/registry_codec.py::.decode_registry_snapshot::codec_read:decode_project_registry#1", - "line": 190, - "column": 12, + "line": 199, + "column": 15, "kind": "codec_read", "api": "decode_project_registry", "classification": "codec_api" }, { "site": "loopx/control_plane/projects/registry_codec.py::.load_registry::codec_read:decode_registry_snapshot#1", - "line": 199, + "line": 213, "column": 12, "kind": "codec_read", "api": "decode_registry_snapshot", @@ -1183,7 +1207,7 @@ }, { "site": "loopx/control_plane/projects/registry_codec.py::.mutate_project_registry::codec_transaction:project_registry_transaction#1", - "line": 414, + "line": 536, "column": 10, "kind": "codec_transaction", "api": "project_registry_transaction", @@ -1559,7 +1583,7 @@ }, { "site": "loopx/kunluncode_goal_mode/cli.py::._annotate_registry::codec_write:mutate_project_registry#1", - "line": 307, + "line": 308, "column": 5, "kind": "codec_write", "api": "mutate_project_registry", @@ -1567,7 +1591,7 @@ }, { "site": "loopx/kunluncode_goal_mode/cli.py::._registered_agents_for_goal::codec_read:load_project_registry#1", - "line": 316, + "line": 317, "column": 15, "kind": "codec_read", "api": "load_project_registry", @@ -1655,7 +1679,7 @@ }, { "site": "loopx/state_migration.py::.legacy_registry_goal_ids::direct_json_read:read_json_object#1", - "line": 71, + "line": 72, "column": 16, "kind": "direct_json_read", "api": "read_json_object", @@ -1663,7 +1687,7 @@ }, { "site": "loopx/state_migration.py::.migrate_legacy_state::codec_transaction:project_registry_transaction#1", - "line": 358, + "line": 359, "column": 25, "kind": "codec_transaction", "api": "project_registry_transaction", @@ -1671,7 +1695,7 @@ }, { "site": "loopx/state_migration.py::.migrate_legacy_state::direct_json_read:read_json_object#1", - "line": 375, + "line": 376, "column": 18, "kind": "direct_json_read", "api": "read_json_object", @@ -1679,7 +1703,7 @@ }, { "site": "loopx/state_migration.py::.migrate_legacy_state::codec_read:load_project_registry#1", - "line": 406, + "line": 407, "column": 18, "kind": "codec_read", "api": "load_project_registry", diff --git a/loopx/state_migration.py b/loopx/state_migration.py index 323a364f4f..774e6f8a82 100644 --- a/loopx/state_migration.py +++ b/loopx/state_migration.py @@ -16,6 +16,7 @@ ProjectRegistryTransaction, load_project_registry, project_registry_transaction, + require_runtime_compatible_project_registry, ) from .control_plane.todos.active_state_editing import atomic_write_state_text from .control_plane.coordination.legacy_writer_fence import legacy_coordination_todo_lock_path, require_legacy_state_replacement_allowed @@ -407,6 +408,10 @@ def migrate_legacy_state( if target_registry_path.exists() else {} ) + require_runtime_compatible_project_registry( + existing_registry, + operation="state migration", + ) existing_goals = existing_registry.get("goals") if not isinstance(existing_goals, list): existing_goals = [] diff --git a/tests/architecture/test_project_registry_io_census.py b/tests/architecture/test_project_registry_io_census.py index 2305c060e5..1782d0dbb8 100644 --- a/tests/architecture/test_project_registry_io_census.py +++ b/tests/architecture/test_project_registry_io_census.py @@ -29,6 +29,9 @@ def test_python_scan_separates_codec_calls_from_direct_json_io() -> None: "import json\n" "def update(registry_path, payload):\n" " current = load_registry(registry_path)\n" + " with source_session_registry_transaction(registry_path, " + "operation='test'):\n" + " pass\n" " raw = json.loads(registry_path.read_text())\n" " atomic_write_json(registry_path, payload)\n" " return current, raw\n" @@ -40,6 +43,7 @@ def test_python_scan_separates_codec_calls_from_direct_json_io() -> None: for row in observations ] == [ ("codec_read", "load_registry"), + ("codec_transaction", "source_session_registry_transaction"), ("direct_json_read", "json.loads"), ("direct_json_write", "atomic_write_json"), ] diff --git a/tests/architecture/test_source_session_registry_denial.py b/tests/architecture/test_source_session_registry_denial.py new file mode 100644 index 0000000000..d9d30e079e --- /dev/null +++ b/tests/architecture/test_source_session_registry_denial.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import ast +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +DIRECT_LOADER_ALLOWLIST = { + "loopx/authority.py", + "loopx/bootstrap.py", + "loopx/claude_goal_mode/scripts/connect.py", + "loopx/configure_goal.py", + "loopx/control_plane/projects/registry.py", + "loopx/kunluncode_goal_mode/cli.py", + "loopx/state_migration.py", +} + + +def test_direct_project_registry_loaders_have_source_session_denial() -> None: + callers: set[str] = set() + for path in (REPO_ROOT / "loopx").rglob("*.py"): + if path.name == "registry_codec.py": + continue + tree = ast.parse(path.read_text(encoding="utf-8")) + if any( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "load_project_registry" + for node in ast.walk(tree) + ): + callers.add(path.relative_to(REPO_ROOT).as_posix()) + + assert callers == DIRECT_LOADER_ALLOWLIST + for relative in callers - {"loopx/control_plane/projects/registry.py"}: + source = (REPO_ROOT / relative).read_text(encoding="utf-8") + assert "require_runtime_compatible_project_registry(" in source, relative + + +def test_generic_registry_decoder_enforces_source_session_denial() -> None: + source = (REPO_ROOT / "loopx/control_plane/projects/registry_codec.py").read_text( + encoding="utf-8" + ) + tree = ast.parse(source) + decoder = next( + node + for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == "decode_registry_snapshot" + ) + calls = { + node.func.id + for node in ast.walk(decoder) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + } + assert "require_runtime_compatible_project_registry" in calls diff --git a/tests/cli_commands/test_source_session_lifetime.py b/tests/cli_commands/test_source_session_lifetime.py new file mode 100644 index 0000000000..f52748599b --- /dev/null +++ b/tests/cli_commands/test_source_session_lifetime.py @@ -0,0 +1,914 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import time + +import pytest + +from loopx.cli import main +from loopx.claude_goal_mode.scripts import connect as claude_connect +from loopx.control_plane.goals import ( + source_session_recreation, + source_session_registration, +) +from loopx.control_plane.projects import registry_codec + + +def _registration_arguments(registry_path: Path, knowledge_root: Path) -> list[str]: + return [ + "--format", + "json", + "--registry", + str(registry_path), + "project", + "register", + "--goal-instance-profile", + "source_session_v1", + "--operation-id", + "create-atlas-import", + "--project-id", + "atlas", + "--project-kind", + "work", + "--knowledge-root", + str(knowledge_root), + "--goal-id", + "atlas-import", + "--objective", + "Deliver the Atlas import pipeline.", + "--acceptance", + "A verified import report is produced.", + "--next-effect", + "Inspect Atlas.", + "--stop-condition", + "Stop while Atlas is unavailable.", + ] + + +def _binding_arguments( + registry_path: Path, + *, + operation: str, + goal_instance_id: str, + operation_id: str, + session_id: str = "session-a", +) -> list[str]: + return [ + "--format", + "json", + "--registry", + str(registry_path), + "project", + f"{operation}-session", + "--session-id", + session_id, + "--goal-id", + "atlas-import", + "--goal-instance-id", + goal_instance_id, + "--operation-id", + operation_id, + ] + + +def _recreation_arguments( + registry_path: Path, + *, + goal_instance_id: str, + operation_id: str = "recreate-atlas-import", +) -> list[str]: + return [ + "--format", + "json", + "--registry", + str(registry_path), + "project", + "recreate-goal", + "--goal-id", + "atlas-import", + "--goal-instance-id", + goal_instance_id, + "--operation-id", + operation_id, + "--execute", + ] + + +def _register( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> tuple[Path, Path, dict[str, object]]: + knowledge_root = tmp_path / "atlas" + registry_path = knowledge_root / ".loopx" / "registry.json" + assert main(_registration_arguments(registry_path, knowledge_root)) == 0 + return knowledge_root, registry_path, json.loads(capsys.readouterr().out) + + +def _registry_payload(registry_path: Path) -> dict[str, object]: + return json.loads(registry_path.read_text(encoding="utf-8"))[1] + + +def test_registration_publishes_fresh_v2_without_global_sync( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + knowledge_root = tmp_path / "atlas" + registry_path = knowledge_root / ".loopx" / "registry.json" + runtime_root = tmp_path / "runtime" + global_registry = runtime_root / "registry.global.json" + global_registry.parent.mkdir(parents=True) + global_registry.write_text( + json.dumps({"schema_version": "0.1", "goals": []}), + encoding="utf-8", + ) + global_before = global_registry.read_bytes() + arguments = _registration_arguments(registry_path, knowledge_root) + arguments[4:4] = ["--runtime-root", str(runtime_root)] + + assert main(arguments) == 0 + payload = json.loads(capsys.readouterr().out) + envelope = json.loads(registry_path.read_text(encoding="utf-8")) + registry = envelope[1] + goal = registry["goals"][0] + + assert payload["changed"] is True + assert payload["execution_authority"] is False + assert payload["goal_ref"] == { + "goal_id": "atlas-import", + "goal_instance_id": goal["goal_instance_id"], + } + assert envelope[0]["schema_version"] == "loopx_project_registry_envelope_v2" + assert envelope[0]["minimum_writer_protocol"] == "goal_instance_v2" + assert registry["profile_id"] == "source_session_v1" + assert re.fullmatch(r"ginst_[0-9a-f]{32}", goal["goal_instance_id"]) + assert goal["status"] == "active" + assert registry["session_bindings"] == [] + assert registry["session_receipts"] == [] + assert registry["lifetime_receipts"] == [ + { + "schema_version": "loopx_goal_creation_receipt_v1", + "operation_id": "create-atlas-import", + "request_digest": payload["request_digest"], + "goal_ref": payload["goal_ref"], + "created_at": registry["updated_at"], + } + ] + assert global_registry.read_bytes() == global_before + + +def test_registration_rejects_goal_id_outside_exact_reference_contract( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + knowledge_root = tmp_path / "atlas" + registry_path = knowledge_root / ".loopx" / "registry.json" + arguments = _registration_arguments(registry_path, knowledge_root) + arguments[arguments.index("--goal-id") + 1] = "g" * 201 + + assert main(arguments) == 1 + rejection = json.loads(capsys.readouterr().out) + + assert "source-session goal_id" in rejection["error"] + assert not registry_path.exists() + + +def test_registration_persists_an_absolute_runtime_root( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + knowledge_root = tmp_path / "atlas" + registry_path = knowledge_root / ".loopx" / "registry.json" + arguments = _registration_arguments(registry_path, knowledge_root) + arguments[4:4] = ["--runtime-root", "runtime"] + + assert main(arguments) == 0 + capsys.readouterr() + + assert _registry_payload(registry_path)["common_runtime_root"] == str( + (tmp_path / "runtime").resolve() + ) + + +def test_registration_reuses_reserved_instance_after_interruption( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + knowledge_root = tmp_path / "atlas" + registry_path = knowledge_root / ".loopx" / "registry.json" + state_file = ( + knowledge_root / ".codex" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" + ) + arguments = _registration_arguments(registry_path, knowledge_root) + original_commit = registry_codec.ProjectRegistryTransaction.commit + + def interrupt_before_publication( + _transaction: registry_codec.ProjectRegistryTransaction, + _payload: dict[str, object], + ) -> bool: + raise KeyboardInterrupt + + monkeypatch.setattr( + registry_codec.ProjectRegistryTransaction, + "commit", + interrupt_before_publication, + ) + with pytest.raises(KeyboardInterrupt): + main(arguments) + + journals = list( + (registry_path.parent / ".loopx" / "lifecycle" / "goal-instance").glob( + "journals/*/*.json" + ) + ) + assert len(journals) == 1 + reserved = json.loads(journals[0].read_text(encoding="utf-8"))["goal_ref"] + assert state_file.exists() + assert not registry_path.exists() + + monkeypatch.setattr( + registry_codec.ProjectRegistryTransaction, + "commit", + original_commit, + ) + assert main(arguments) == 0 + recovered = json.loads(capsys.readouterr().out) + assert recovered["goal_ref"] == reserved + before_replay = registry_path.read_bytes() + + assert main(arguments) == 0 + replay = json.loads(capsys.readouterr().out) + assert replay["changed"] is False + assert replay["goal_ref"] == reserved + assert registry_path.read_bytes() == before_replay + + conflicting = list(arguments) + conflicting[conflicting.index("--operation-id") + 1] = "create-other" + assert main(conflicting) == 1 + conflict = json.loads(capsys.readouterr().out) + assert "requires an absent registry" in conflict["error"] + assert registry_path.read_bytes() == before_replay + + +def test_bind_and_unbind_commit_exact_receipts( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _, registry_path, registration = _register(tmp_path, capsys) + goal_instance_id = str(registration["goal_ref"]["goal_instance_id"]) + bind_arguments = _binding_arguments( + registry_path, + operation="bind", + goal_instance_id=goal_instance_id, + operation_id="bind-session-a", + ) + + assert main(bind_arguments) == 0 + bound = json.loads(capsys.readouterr().out) + registry = _registry_payload(registry_path) + assert bound["changed"] is True + assert bound["replayed"] is False + assert registry["session_bindings"] == [ + { + "session_id": "session-a", + "foreground_goal_ref": registration["goal_ref"], + } + ] + assert registry["session_receipts"] == [bound["receipt"]] + + before_replay = registry_path.read_bytes() + assert main(bind_arguments) == 0 + replay = json.loads(capsys.readouterr().out) + assert replay["replayed"] is True + assert replay["receipt"] == bound["receipt"] + assert registry_path.read_bytes() == before_replay + + unbind_arguments = _binding_arguments( + registry_path, + operation="unbind", + goal_instance_id=goal_instance_id, + operation_id="unbind-session-a", + ) + assert main(unbind_arguments) == 0 + unbound = json.loads(capsys.readouterr().out) + registry = _registry_payload(registry_path) + assert unbound["changed"] is True + assert unbound["replayed"] is False + assert registry["session_bindings"] == [] + assert registry["session_receipts"] == [ + bound["receipt"], + unbound["receipt"], + ] + + +def test_recreation_retires_bindings_and_fences_stale_bind( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _, registry_path, registration = _register(tmp_path, capsys) + instance_a = str(registration["goal_ref"]["goal_instance_id"]) + bind_arguments = _binding_arguments( + registry_path, + operation="bind", + goal_instance_id=instance_a, + operation_id="bind-session-a", + ) + assert main(bind_arguments) == 0 + capsys.readouterr() + recreate_arguments = _recreation_arguments( + registry_path, + goal_instance_id=instance_a, + ) + + assert main(recreate_arguments) == 0 + recreated = json.loads(capsys.readouterr().out) + instance_b = recreated["goal_ref"]["goal_instance_id"] + registry = _registry_payload(registry_path) + assert instance_b != instance_a + assert registry["goals"][0]["goal_instance_id"] == instance_b + assert registry["session_bindings"] == [] + assert registry["retired_goal_instances"] == [ + { + "goal_ref": { + "goal_id": "atlas-import", + "goal_instance_id": instance_a, + }, + "successor_goal_ref": recreated["goal_ref"], + "operation_id": "recreate-atlas-import", + "retired_at": recreated["receipt"]["committed_at"], + } + ] + assert registry["lifetime_receipts"][-1] == recreated["receipt"] + assert registry["session_receipts"][-1]["operation"] == "retire_bindings" + assert registry["session_receipts"][-1]["session_ids"] == ["session-a"] + + before_replay = registry_path.read_bytes() + assert main(recreate_arguments) == 0 + replay = json.loads(capsys.readouterr().out) + assert replay["replayed"] is True + assert replay["goal_ref"]["goal_instance_id"] == instance_b + assert registry_path.read_bytes() == before_replay + + competing_recreation = _recreation_arguments( + registry_path, + goal_instance_id=instance_a, + operation_id="competing-recreation", + ) + assert main(competing_recreation) == 1 + rejection = json.loads(capsys.readouterr().out) + assert "stale_goal_instance" in rejection["error"] + assert registry_path.read_bytes() == before_replay + recreation_journals = list( + ( + registry_path.parent + / ".loopx" + / "lifecycle" + / "goal-instance" + / "recreations" + ).glob("*/*.json") + ) + assert len(recreation_journals) == 1 + + stale_bind = list(bind_arguments) + stale_bind[stale_bind.index("--operation-id") + 1] = "bind-after-recreate" + assert main(stale_bind) == 1 + stale_bind_rejection = json.loads(capsys.readouterr().out) + assert "stale_goal_instance" in stale_bind_rejection["error"] + assert registry_path.read_bytes() == before_replay + + +def test_paused_bind_cannot_cross_recreation_aba( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _, registry_path, registration = _register(tmp_path, capsys) + instance_a = str(registration["goal_ref"]["goal_instance_id"]) + ready = tmp_path / "bind-ready" + resume = tmp_path / "bind-resume" + bind_arguments = _binding_arguments( + registry_path, + operation="bind", + goal_instance_id=instance_a, + operation_id="paused-bind-a", + session_id="paused-session", + ) + script = """ +import json +import os +import time +from contextlib import contextmanager +from pathlib import Path + +from loopx.cli import main +from loopx.control_plane.goals import source_session_binding + +ready = Path(os.environ["LOOPX_TEST_READY"]) +resume = Path(os.environ["LOOPX_TEST_RESUME"]) +real_lock = source_session_binding.exclusive_cross_runtime_file_lock + +@contextmanager +def paused_lock(path, **kwargs): + if kwargs.get("operation") == "source_session_goal_lifetime": + ready.write_text("ready", encoding="utf-8") + deadline = time.monotonic() + 10 + while not resume.exists(): + if time.monotonic() >= deadline: + raise TimeoutError("test bind pause timed out") + time.sleep(0.01) + with real_lock(path, **kwargs): + yield + +source_session_binding.exclusive_cross_runtime_file_lock = paused_lock +raise SystemExit(main(json.loads(os.environ["LOOPX_TEST_ARGS"]))) +""" + environment = { + **os.environ, + "LOOPX_TEST_READY": str(ready), + "LOOPX_TEST_RESUME": str(resume), + "LOOPX_TEST_ARGS": json.dumps(bind_arguments), + } + process = subprocess.Popen( + [sys.executable, "-c", script], + cwd=Path(__file__).resolve().parents[2], + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + deadline = time.monotonic() + 10 + while not ready.exists() and process.poll() is None: + if time.monotonic() >= deadline: + process.kill() + raise TimeoutError("paused bind process did not reach the barrier") + time.sleep(0.01) + assert ready.exists() + + assert ( + main( + _recreation_arguments( + registry_path, + goal_instance_id=instance_a, + operation_id="recreate-before-bind", + ) + ) + == 0 + ) + recreated = json.loads(capsys.readouterr().out) + resume.write_text("resume", encoding="utf-8") + stdout, stderr = process.communicate(timeout=15) + stale = json.loads(stdout) + registry = _registry_payload(registry_path) + + assert process.returncode == 1, stderr + assert "stale_goal_instance" in stale["error"] + assert ( + registry["goals"][0]["goal_instance_id"] + == (recreated["goal_ref"]["goal_instance_id"]) + ) + assert registry["session_bindings"] == [] + assert not any( + receipt.get("operation_id") == "paused-bind-a" + for receipt in registry["session_receipts"] + ) + + +def test_resolve_classifies_current_and_retired_exact_refs( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _, registry_path, registration = _register(tmp_path, capsys) + instance_a = str(registration["goal_ref"]["goal_instance_id"]) + assert ( + main( + _binding_arguments( + registry_path, + operation="bind", + goal_instance_id=instance_a, + operation_id="bind-session-a", + ) + ) + == 0 + ) + capsys.readouterr() + resolve_arguments = [ + "--format", + "json", + "--registry", + str(registry_path), + "project", + "resolve", + "--session-id", + "session-a", + "--goal-id", + "atlas-import", + "--goal-instance-id", + instance_a, + ] + + assert main(resolve_arguments) == 0 + current = json.loads(capsys.readouterr().out) + assert current["resolution"] == "current" + assert current["goal_ref"] == registration["goal_ref"] + assert current["execution_authority"] is False + + assert ( + main( + _recreation_arguments( + registry_path, + goal_instance_id=instance_a, + ) + ) + == 0 + ) + capsys.readouterr() + retired_arguments = [ + argument + for argument in resolve_arguments + if argument not in {"--session-id", "session-a"} + ] + assert main(retired_arguments) == 1 + retired = json.loads(capsys.readouterr().out) + assert retired["resolution"] == "retired" + assert retired["goal_ref"] == registration["goal_ref"] + assert retired["execution_authority"] is False + + +def test_registration_repairs_reserved_journal_after_v2_publication( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + knowledge_root = tmp_path / "atlas" + registry_path = knowledge_root / ".loopx" / "registry.json" + arguments = _registration_arguments(registry_path, knowledge_root) + real_write_journal = source_session_registration.write_journal + writes = 0 + + def fail_published_journal( + path: Path, + payload: dict[str, object], + ) -> None: + nonlocal writes + writes += 1 + if writes == 2: + raise OSError("injected registration response failure") + real_write_journal(path, payload) + + monkeypatch.setattr( + source_session_registration, + "write_journal", + fail_published_journal, + ) + + assert main(arguments) == 1 + failed = json.loads(capsys.readouterr().out) + committed_registry = json.loads(registry_path.read_text(encoding="utf-8"))[1] + instance_a = committed_registry["goals"][0]["goal_instance_id"] + + assert "injected registration response failure" in failed["error"] + assert committed_registry["lifetime_receipts"][0]["goal_ref"] == { + "goal_id": "atlas-import", + "goal_instance_id": instance_a, + } + + monkeypatch.setattr( + source_session_registration, + "write_journal", + real_write_journal, + ) + assert main(arguments) == 0 + repaired = json.loads(capsys.readouterr().out) + journal = next( + ( + registry_path.parent / ".loopx" / "lifecycle" / "goal-instance" / "journals" + ).glob("*/*.json") + ) + + assert repaired["changed"] is False + assert repaired["goal_ref"]["goal_instance_id"] == instance_a + assert json.loads(journal.read_text(encoding="utf-8"))["phase"] == "published" + + +def test_recreation_repairs_reserved_journal_after_b_publication( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + knowledge_root = tmp_path / "atlas" + registry_path = knowledge_root / ".loopx" / "registry.json" + assert main(_registration_arguments(registry_path, knowledge_root)) == 0 + registration = json.loads(capsys.readouterr().out) + instance_a = registration["goal_ref"]["goal_instance_id"] + arguments = [ + "--format", + "json", + "--registry", + str(registry_path), + "project", + "recreate-goal", + "--goal-id", + "atlas-import", + "--goal-instance-id", + instance_a, + "--operation-id", + "recreate-atlas-import", + "--execute", + ] + real_write_journal = source_session_recreation.write_journal + writes = 0 + + def fail_published_journal( + path: Path, + payload: dict[str, object], + ) -> None: + nonlocal writes + writes += 1 + if writes == 2: + raise OSError("injected recreation response failure") + real_write_journal(path, payload) + + monkeypatch.setattr( + source_session_recreation, + "write_journal", + fail_published_journal, + ) + + assert main(arguments) == 1 + failed = json.loads(capsys.readouterr().out) + committed_registry = json.loads(registry_path.read_text(encoding="utf-8"))[1] + instance_b = committed_registry["goals"][0]["goal_instance_id"] + + assert "injected recreation response failure" in failed["error"] + assert instance_b != instance_a + assert committed_registry["lifetime_receipts"][-1]["new_goal_ref"] == { + "goal_id": "atlas-import", + "goal_instance_id": instance_b, + } + + monkeypatch.setattr( + source_session_recreation, + "write_journal", + real_write_journal, + ) + assert main(arguments) == 0 + repaired = json.loads(capsys.readouterr().out) + journal = next( + ( + registry_path.parent + / ".loopx" + / "lifecycle" + / "goal-instance" + / "recreations" + ).glob("*/*.json") + ) + + assert repaired["replayed"] is True + assert repaired["goal_ref"]["goal_instance_id"] == instance_b + assert json.loads(journal.read_text(encoding="utf-8"))["phase"] == "published" + + +@pytest.mark.parametrize("operation", ["bind", "unbind"]) +def test_session_replacement_failure_restores_exact_v2_bytes( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + operation: str, +) -> None: + knowledge_root = tmp_path / "atlas" + registry_path = knowledge_root / ".loopx" / "registry.json" + assert main(_registration_arguments(registry_path, knowledge_root)) == 0 + registration = json.loads(capsys.readouterr().out) + goal_instance_id = registration["goal_ref"]["goal_instance_id"] + if operation == "unbind": + assert ( + main( + _binding_arguments( + registry_path, + operation="bind", + goal_instance_id=goal_instance_id, + operation_id="prepare-session-a", + ) + ) + == 0 + ) + capsys.readouterr() + before = registry_path.read_bytes() + real_read = registry_codec._read_document + reads = 0 + + def fail_first_readback( + candidate: Path, + ) -> registry_codec._ProjectRegistryDocument: + nonlocal reads + reads += 1 + if reads == 2: + raise OSError("injected v2 readback failure") + return real_read(candidate) + + monkeypatch.setattr(registry_codec, "_read_document", fail_first_readback) + arguments = _binding_arguments( + registry_path, + operation=operation, + goal_instance_id=goal_instance_id, + operation_id=f"{operation}-session-a", + ) + + assert main(arguments) == 1 + failed = json.loads(capsys.readouterr().out) + + assert "exact preimage restored" in failed["error"] + assert registry_path.read_bytes() == before + + monkeypatch.setattr(registry_codec, "_read_document", real_read) + assert main(arguments) == 0 + committed = json.loads(capsys.readouterr().out) + assert committed["changed"] is True + + +def test_session_receipt_capacity_preserves_exact_replay_and_rejects_new_work( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + knowledge_root = tmp_path / "atlas" + registry_path = knowledge_root / ".loopx" / "registry.json" + assert main(_registration_arguments(registry_path, knowledge_root)) == 0 + registration = json.loads(capsys.readouterr().out) + goal_instance_id = registration["goal_ref"]["goal_instance_id"] + replay_arguments = _binding_arguments( + registry_path, + operation="bind", + goal_instance_id=goal_instance_id, + operation_id="bind-session-a", + ) + assert main(replay_arguments) == 0 + committed = json.loads(capsys.readouterr().out) + + with registry_codec.source_session_registry_transaction( + registry_path, + operation="test_fill_session_receipt_capacity", + ) as transaction: + registry = transaction.payload_copy() + registry["session_receipts"] = [ + committed["receipt"], + *[ + {"operation_id": f"occupied-session-receipt-{index}"} + for index in range(4095) + ], + ] + transaction.commit(registry) + at_capacity = registry_path.read_bytes() + + assert main(replay_arguments) == 0 + replay = json.loads(capsys.readouterr().out) + assert replay["replayed"] is True + assert replay["receipt"] == committed["receipt"] + assert registry_path.read_bytes() == at_capacity + + new_arguments = _binding_arguments( + registry_path, + operation="bind", + goal_instance_id=goal_instance_id, + operation_id="bind-session-b", + session_id="session-b", + ) + assert main(new_arguments) == 1 + rejection = json.loads(capsys.readouterr().out) + assert "history_capacity_exhausted" in rejection["error"] + assert registry_path.read_bytes() == at_capacity + + +def test_operation_id_cannot_be_reused_across_session_and_lifetime_receipts( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _, registry_path, registration = _register(tmp_path, capsys) + instance_a = str(registration["goal_ref"]["goal_instance_id"]) + before_bind = registry_path.read_bytes() + conflicting_bind = _binding_arguments( + registry_path, + operation="bind", + goal_instance_id=instance_a, + operation_id="create-atlas-import", + ) + + assert main(conflicting_bind) == 1 + bind_rejection = json.loads(capsys.readouterr().out) + assert "operation_id was reused" in bind_rejection["error"] + assert registry_path.read_bytes() == before_bind + + valid_bind = _binding_arguments( + registry_path, + operation="bind", + goal_instance_id=instance_a, + operation_id="bind-session-a", + ) + assert main(valid_bind) == 0 + capsys.readouterr() + before_recreation = registry_path.read_bytes() + conflicting_recreation = _recreation_arguments( + registry_path, + goal_instance_id=instance_a, + operation_id="bind-session-a", + ) + + assert main(conflicting_recreation) == 1 + recreation_rejection = json.loads(capsys.readouterr().out) + assert "operation_id was reused" in recreation_rejection["error"] + assert registry_path.read_bytes() == before_recreation + + +def test_claude_adapter_stops_before_installing_for_source_session_profile( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + knowledge_root, registry_path, _registration = _register(tmp_path, capsys) + + def unexpected_install(*_args: object, **_kwargs: object) -> None: + pytest.fail("adapter install ran after source-session denial") + + monkeypatch.setattr(claude_connect.subprocess, "run", unexpected_install) + monkeypatch.setattr( + sys, + "argv", + [ + "connect.py", + "--project", + str(knowledge_root), + "--goal-id", + "atlas-import", + "--registry", + str(registry_path), + ], + ) + + with pytest.raises(SystemExit) as stopped: + claude_connect.main() + + assert stopped.value.code == 1 + assert "lifecycle-only profile" in capsys.readouterr().out + + +def test_lifetime_receipt_capacity_preserves_exact_replay_and_rejects_new_recreation( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + knowledge_root = tmp_path / "atlas" + registry_path = knowledge_root / ".loopx" / "registry.json" + assert main(_registration_arguments(registry_path, knowledge_root)) == 0 + registration = json.loads(capsys.readouterr().out) + instance_a = registration["goal_ref"]["goal_instance_id"] + replay_arguments = [ + "--format", + "json", + "--registry", + str(registry_path), + "project", + "recreate-goal", + "--goal-id", + "atlas-import", + "--goal-instance-id", + instance_a, + "--operation-id", + "recreate-atlas-import", + "--execute", + ] + assert main(replay_arguments) == 0 + committed = json.loads(capsys.readouterr().out) + instance_b = committed["goal_ref"]["goal_instance_id"] + + with registry_codec.source_session_registry_transaction( + registry_path, + operation="test_fill_lifetime_receipt_capacity", + ) as transaction: + registry = transaction.payload_copy() + registry["lifetime_receipts"] = [ + committed["receipt"], + *[ + {"operation_id": f"occupied-lifetime-receipt-{index}"} + for index in range(1023) + ], + ] + transaction.commit(registry) + at_capacity = registry_path.read_bytes() + + assert main(replay_arguments) == 0 + replay = json.loads(capsys.readouterr().out) + assert replay["replayed"] is True + assert replay["goal_ref"]["goal_instance_id"] == instance_b + assert registry_path.read_bytes() == at_capacity + + new_arguments = list(replay_arguments) + new_arguments[new_arguments.index("--goal-instance-id") + 1] = instance_b + new_arguments[new_arguments.index("--operation-id") + 1] = "recreate-again" + assert main(new_arguments) == 1 + rejection = json.loads(capsys.readouterr().out) + assert "history_capacity_exhausted" in rejection["error"] + assert registry_path.read_bytes() == at_capacity diff --git a/tests/control_plane/test_project_registry_codec.py b/tests/control_plane/test_project_registry_codec.py index c673b274c1..3d7983bdaa 100644 --- a/tests/control_plane/test_project_registry_codec.py +++ b/tests/control_plane/test_project_registry_codec.py @@ -13,6 +13,7 @@ ProjectRegistryProtocolError, load_project_registry, mutate_project_registry, + source_session_registry_transaction, ) from loopx.global_registry import ( GlobalRegistryReduction, @@ -129,6 +130,84 @@ def test_future_protocol_is_readable_but_not_mutable(tmp_path: Path) -> None: assert path.read_bytes() == before +def test_source_session_transaction_creates_and_preserves_v2( + tmp_path: Path, +) -> None: + path = tmp_path / "registry.json" + initial: dict[str, object] = { + "schema_version": "0.2", + "registry_role": "project-local", + "profile_id": "source_session_v1", + "goals": [], + } + + with source_session_registry_transaction( + path, + operation="test_source_session_create", + create=lambda: initial, + ) as transaction: + payload = transaction.payload_copy() + payload["created"] = True + assert transaction.commit(payload) is True + + created = json.loads(path.read_text(encoding="utf-8")) + assert created[0]["schema_version"] == "loopx_project_registry_envelope_v2" + assert created[0]["minimum_writer_protocol"] == "goal_instance_v2" + assert created[0]["payload_sha256"] == _digest(created[1]) + + with source_session_registry_transaction( + path, + operation="test_source_session_update", + ) as transaction: + payload = transaction.payload_copy() + payload["updated"] = True + assert transaction.commit(payload) is True + + updated = json.loads(path.read_text(encoding="utf-8")) + assert updated[0]["schema_version"] == "loopx_project_registry_envelope_v2" + assert updated[0]["minimum_writer_protocol"] == "goal_instance_v2" + assert updated[0]["payload_sha256"] == _digest(updated[1]) + assert updated[1] == {**initial, "created": True, "updated": True} + + before = path.read_bytes() + with pytest.raises(ProjectRegistryProtocolError, match="goal_instance_v2"): + mutate_project_registry( + path, + operation="test_legacy_writer_rejected", + reducer=lambda registry: registry.update({"legacy_write": True}), + ) + assert path.read_bytes() == before + + +def test_source_session_profile_is_not_a_generic_runtime_registry( + tmp_path: Path, +) -> None: + path = tmp_path / "registry.json" + payload: dict[str, object] = { + "schema_version": "0.2", + "profile_id": "source_session_v1", + "goals": [], + } + _write( + path, + [ + { + "schema_version": "loopx_project_registry_envelope_v2", + "minimum_writer_protocol": "goal_instance_v2", + "payload_sha256": _digest(payload), + }, + payload, + ], + ) + + assert load_project_registry(path) == payload + with pytest.raises( + ProjectRegistryProtocolError, + match="lifecycle-only", + ): + load_registry(path) + + def test_global_registry_mutation_remains_object_only(tmp_path: Path) -> None: path = tmp_path / "registry.global.json" _write(path, _strict({"schema_version": "0.1", "goals": []})) diff --git a/tests/control_plane_ts/effect_runtime_handlers.test.ts b/tests/control_plane_ts/effect_runtime_handlers.test.ts index 00b5063d82..03b9bf22bd 100644 --- a/tests/control_plane_ts/effect_runtime_handlers.test.ts +++ b/tests/control_plane_ts/effect_runtime_handlers.test.ts @@ -139,6 +139,70 @@ test("runtime exposes the canonical task-lease write-scope rule", async () => { assert.equal(result.overlap, true); }); +test("runtime exposes the source-session lifetime decisions", async () => { + const goalRef = { + goal_id: "release", + goal_instance_id: "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }; + const bindingFacts = { + profile_id: "source_session_v1", + operation_id: "bind-release", + request_digest: `sha256:${"a".repeat(64)}`, + session_id: "session-a", + requested_goal_ref: goalRef, + current_goal_ref: goalRef, + current_binding: null, + prior_receipt: null, + binding_count: 0, + receipt_count: 0, + }; + + assert.equal( + ( + await dispatchEffectRuntimeMethod( + handlers, + "goal.source_session.bind.decide", + bindingFacts, + ) as Record + ).kind, + "commit", + ); + assert.equal( + ( + await dispatchEffectRuntimeMethod( + handlers, + "goal.source_session.unbind.decide", + bindingFacts, + ) as Record + ).kind, + "commit", + ); + assert.equal( + ( + await dispatchEffectRuntimeMethod( + handlers, + "goal.source_session.recreate.decide", + { + profile_id: "source_session_v1", + operation_id: "recreate-release", + request_digest: `sha256:${"b".repeat(64)}`, + requested_goal_ref: goalRef, + current_goal_ref: goalRef, + reserved_goal_ref: { + goal_id: "release", + goal_instance_id: "ginst_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, + prior_receipt: null, + lifetime_receipt_count: 0, + session_receipt_count: 0, + retiring_binding_count: 0, + }, + ) as Record + ).kind, + "commit", + ); +}); + test("runtime boundary registers the quota monitor-poll transaction", async () => { await assert.rejects( dispatchEffectRuntimeMethod(handlers, "quota.monitor_poll.commit", {}), diff --git a/tests/control_plane_ts/source_session_lifetime.test.ts b/tests/control_plane_ts/source_session_lifetime.test.ts new file mode 100644 index 0000000000..c9fd621899 --- /dev/null +++ b/tests/control_plane_ts/source_session_lifetime.test.ts @@ -0,0 +1,302 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + decideGoalRecreation, + decideProjectSessionBind, + decideProjectSessionUnbind, +} from "../../loopx/control_plane/goals/source_session_lifetime.ts"; + +const INSTANCE_A = "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const INSTANCE_B = "ginst_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +function bindFacts(currentGoalInstanceId: string) { + return { + profile_id: "source_session_v1", + operation_id: "bind-session-a", + request_digest: `sha256:${"a".repeat(64)}`, + session_id: "session-a", + requested_goal_ref: { + goal_id: "release", + goal_instance_id: INSTANCE_A, + }, + current_goal_ref: { + goal_id: "release", + goal_instance_id: currentGoalInstanceId, + }, + current_binding: null, + prior_receipt: null, + binding_count: 0, + receipt_count: 0, + }; +} + +test("bind commits only while the captured Goal instance is current", () => { + assert.deepEqual( + decideProjectSessionBind(bindFacts(INSTANCE_A)), + { + kind: "commit", + goal_ref: { + goal_id: "release", + goal_instance_id: INSTANCE_A, + }, + changed: true, + }, + ); + + assert.deepEqual( + decideProjectSessionBind(bindFacts(INSTANCE_B)), + { + kind: "reject", + code: "stale_goal_instance", + }, + ); +}); + +test("bind replays an exact operation before current-state and capacity checks", () => { + const receipt = { + schema_version: "loopx_source_session_receipt_v1", + operation: "bind", + operation_id: "bind-session-a", + request_digest: `sha256:${"a".repeat(64)}`, + session_id: "session-a", + goal_ref: { + goal_id: "release", + goal_instance_id: INSTANCE_A, + }, + changed: true, + }; + + assert.deepEqual( + decideProjectSessionBind({ + ...bindFacts(INSTANCE_B), + prior_receipt: receipt, + binding_count: 256, + receipt_count: 4096, + }), + { kind: "replay", receipt }, + ); + + assert.deepEqual( + decideProjectSessionBind({ + ...bindFacts(INSTANCE_A), + request_digest: `sha256:${"b".repeat(64)}`, + prior_receipt: receipt, + }), + { + kind: "reject", + code: "operation_id_conflict", + }, + ); +}); + +test("unbind removes only the session binding to the captured instance", () => { + const facts = { + ...bindFacts(INSTANCE_A), + operation_id: "unbind-session-a", + current_binding: { + session_id: "session-a", + foreground_goal_ref: { + goal_id: "release", + goal_instance_id: INSTANCE_A, + }, + }, + }; + + assert.deepEqual( + decideProjectSessionUnbind(facts), + { + kind: "commit", + goal_ref: { + goal_id: "release", + goal_instance_id: INSTANCE_A, + }, + changed: true, + }, + ); + assert.deepEqual( + decideProjectSessionUnbind({ + ...facts, + current_goal_ref: { + goal_id: "release", + goal_instance_id: INSTANCE_B, + }, + }), + { kind: "reject", code: "stale_goal_instance" }, + ); +}); + +test("session capacity rejects new work after preserving exact replay", () => { + const unbindReceipt = { + schema_version: "loopx_source_session_receipt_v1", + operation: "unbind", + operation_id: "unbind-session-a", + request_digest: `sha256:${"a".repeat(64)}`, + session_id: "session-a", + goal_ref: { + goal_id: "release", + goal_instance_id: INSTANCE_A, + }, + changed: true, + }; + const unbindFacts = { + ...bindFacts(INSTANCE_A), + operation_id: "unbind-session-a", + current_binding: { + session_id: "session-a", + foreground_goal_ref: { + goal_id: "release", + goal_instance_id: INSTANCE_A, + }, + }, + }; + + assert.deepEqual( + decideProjectSessionUnbind({ + ...unbindFacts, + prior_receipt: unbindReceipt, + binding_count: 256, + receipt_count: 4096, + }), + { kind: "replay", receipt: unbindReceipt }, + ); + assert.deepEqual( + decideProjectSessionBind({ + ...bindFacts(INSTANCE_A), + binding_count: 256, + }), + { kind: "reject", code: "binding_capacity_exhausted" }, + ); + assert.deepEqual( + decideProjectSessionBind({ + ...bindFacts(INSTANCE_A), + receipt_count: 4096, + }), + { kind: "reject", code: "history_capacity_exhausted" }, + ); + assert.deepEqual( + decideProjectSessionUnbind({ + ...unbindFacts, + receipt_count: 4096, + }), + { kind: "reject", code: "history_capacity_exhausted" }, + ); +}); + +test("recreation publishes the reserved successor once and replays it exactly", () => { + const facts = { + profile_id: "source_session_v1", + operation_id: "recreate-release-b", + request_digest: `sha256:${"b".repeat(64)}`, + requested_goal_ref: { + goal_id: "release", + goal_instance_id: INSTANCE_A, + }, + current_goal_ref: { + goal_id: "release", + goal_instance_id: INSTANCE_A, + }, + reserved_goal_ref: { + goal_id: "release", + goal_instance_id: INSTANCE_B, + }, + prior_receipt: null, + lifetime_receipt_count: 0, + session_receipt_count: 0, + retiring_binding_count: 2, + }; + const committed = { + kind: "commit", + retired_goal_ref: { + goal_id: "release", + goal_instance_id: INSTANCE_A, + }, + new_goal_ref: { + goal_id: "release", + goal_instance_id: INSTANCE_B, + }, + }; + + assert.deepEqual(decideGoalRecreation(facts), committed); + assert.deepEqual( + decideGoalRecreation({ + ...facts, + current_goal_ref: { + goal_id: "release", + goal_instance_id: INSTANCE_B, + }, + }), + { kind: "reject", code: "stale_goal_instance" }, + ); + + const receipt = { + schema_version: "loopx_goal_recreation_receipt_v1", + operation_id: "recreate-release-b", + request_digest: `sha256:${"b".repeat(64)}`, + retired_goal_ref: committed.retired_goal_ref, + new_goal_ref: committed.new_goal_ref, + retired_session_ids: ["session-a", "session-b"], + }; + assert.deepEqual( + decideGoalRecreation({ + ...facts, + current_goal_ref: committed.new_goal_ref, + prior_receipt: receipt, + lifetime_receipt_count: 1024, + session_receipt_count: 4096, + }), + { kind: "replay", receipt }, + ); +}); + +test("recreation rejects new work that would exceed either history capacity", () => { + const facts = { + profile_id: "source_session_v1", + operation_id: "recreate-release-b", + request_digest: `sha256:${"b".repeat(64)}`, + requested_goal_ref: { + goal_id: "release", + goal_instance_id: INSTANCE_A, + }, + current_goal_ref: { + goal_id: "release", + goal_instance_id: INSTANCE_A, + }, + reserved_goal_ref: { + goal_id: "release", + goal_instance_id: INSTANCE_B, + }, + prior_receipt: null, + lifetime_receipt_count: 0, + session_receipt_count: 0, + retiring_binding_count: 0, + }; + + assert.deepEqual( + decideGoalRecreation({ + ...facts, + lifetime_receipt_count: 1024, + }), + { kind: "reject", code: "history_capacity_exhausted" }, + ); + assert.deepEqual( + decideGoalRecreation({ + ...facts, + session_receipt_count: 4096, + retiring_binding_count: 1, + }), + { kind: "reject", code: "session_history_capacity_exhausted" }, + ); + assert.deepEqual( + decideGoalRecreation({ + ...facts, + session_receipt_count: 4095, + retiring_binding_count: 1, + }), + { + kind: "commit", + retired_goal_ref: facts.requested_goal_ref, + new_goal_ref: facts.reserved_goal_ref, + }, + ); +}); From 04be5316b395ade128d3f1631589a6d637c50799 Mon Sep 17 00:00:00 2001 From: "duanjialing.777" Date: Wed, 23 Sep 2026 17:58:25 +0800 Subject: [PATCH 2/4] docs(rfc): record M2 source-session checkpoint Signed-off-by: duanjialing.777 --- ...nstance-identity-and-orphan-recovery-v0.md | 19 ++++++++++++++++--- ...e-identity-and-orphan-recovery-v0.zh-CN.md | 16 +++++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/docs/architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.md b/docs/architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.md index 8ef2050507..22ecc8ce9d 100644 --- a/docs/architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.md +++ b/docs/architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.md @@ -729,9 +729,10 @@ promotion retain their own acceptance. No new paid cohort or soak is authorized. 1. **Supported package/profile matrix:** release and host owners pin all supported object-only, codec-only and enforcement packages and exclusion evidence before M2 activation. v2 names are proposed; protocol support requires semantics. -2. **Exact commit guard:** Goal lifecycle and TS transaction owners must choose - and prove the local retirement/commit lock and external-effect drain contract - before M2. A digest recheck alone cannot discharge this hold. +2. **Exact commit guard:** The M2 source-session candidate uses one + alias-scoped cross-runtime lock around the project-registry transaction. + M3 must still prove the external-effect drain contract before activation. + A digest recheck alone cannot discharge that hold. 3. **Canonical destination/provider import:** reuse the current path owner; follow #4915 without assuming merge. Provider-state adoption needs its own reviewed import contract; the first file-only slice rejects it. @@ -755,6 +756,18 @@ promotion retain their own acceptance. No new paid cohort or soak is authorized. - **Effect on normative design:** Align with roadmap/TS/shared authority; separate codec compatibility from enforcement, specify commit fencing and legacy cleanup. +### 2026-09-23: M2 source-session implementation candidate + +- **Baseline:** `cbbdd837f65c8ba28161115cc4ce39093bfa2951` +- **Proposed:** A fresh-project-only `source_session_v1` profile, exact bind and + unbind receipts, journaled A-to-B recreation, and read-only exact resolution. +- **Evidence:** Real CLI tests cover ABA ordering, pre-publication retry, + post-publication forward repair, exact-byte replacement rollback, operation + ID conflicts, capacity rejection, and replay at capacity. +- **Remaining hold:** Every result has `execution_authority: false`. M3 must + qualify the remaining effect owners before existing-project activation or + global routing can open. + ## Appendix B: Decision log | Date | Decision | Owner / approval | Alternatives | Normative sections changed | diff --git a/docs/architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.zh-CN.md b/docs/architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.zh-CN.md index e0e99fe91f..6c2f12400b 100644 --- a/docs/architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.zh-CN.md +++ b/docs/architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.zh-CN.md @@ -661,9 +661,9 @@ service adoption、D1–D3 provider promotion 保留各自验收。不授权付 1. **受支持 package/profile matrix:** release/host owner 在 M2 activation 前固定 object-only、codec-only、enforcement 版本及排除证据。v2 名称是提案,support 必须包含语义。 -2. **精确 commit guard:** Goal lifecycle/TS transaction owner 在 M2 前选定并证明 - 本地 retirement/commit lock 与 external-effect drain contract;digest recheck - 不能解除 hold。 +2. **精确 commit guard:** M2 source-session 候选在 project-registry transaction + 外使用 alias-scoped cross-runtime lock。M3 仍须在 activation 前证明 + external-effect drain contract;digest recheck 不能解除该 hold。 3. **Canonical destination/provider import:** 复用当前 path owner,跟随 #4915 但不预设已合并。Provider-state adoption 要有独立审阅的 import contract;首个 file-only 切片拒绝它。 @@ -687,6 +687,16 @@ service adoption、D1–D3 provider promotion 保留各自验收。不授权付 - **对规范设计的影响:** 对齐 roadmap/TS/shared authority;分开 codec compatibility 与 enforcement,明确 commit fence、legacy cleanup。 +### 2026-09-23:M2 source-session 实现候选 + +- **基线:** `cbbdd837f65c8ba28161115cc4ce39093bfa2951` +- **候选实现:** 仅支持新项目的 `source_session_v1` profile、精确 bind/unbind + receipt、带 journal 的 A-to-B recreation,以及只读精确 resolution。 +- **证据:** 真实 CLI 测试覆盖 ABA 顺序、publication 前重试、publication 后前向 + 修复、replacement 原字节回滚、operation ID 冲突、容量拒绝和满容量 replay。 +- **剩余 hold:** 所有结果均为 `execution_authority: false`。M3 必须先完成其余 + effect owner 资格化,才能开放既有项目 activation 或 global routing。 + ## 附录 B:决策日志 | 日期 | 决策 | Owner/批准 | 替代方案 | 变更的规范章节 | From 44f4828520ea4f196d97474b68ade8a88a7df415 Mon Sep 17 00:00:00 2001 From: "duanjialing.777" Date: Wed, 23 Sep 2026 19:30:47 +0800 Subject: [PATCH 3/4] fix(goals): harden source-session activation boundary Signed-off-by: duanjialing.777 --- .../goals/source_session_registration.py | 17 ++ .../control_plane/projects/registry_codec.py | 10 +- .../project_registry_io_manifest_v1.json | 10 +- .../test_source_session_lifetime.py | 287 ++++++++++++++++++ .../test_project_registry_codec.py | 24 ++ 5 files changed, 342 insertions(+), 6 deletions(-) diff --git a/loopx/control_plane/goals/source_session_registration.py b/loopx/control_plane/goals/source_session_registration.py index f89a547746..64c8794e6a 100644 --- a/loopx/control_plane/goals/source_session_registration.py +++ b/loopx/control_plane/goals/source_session_registration.py @@ -88,6 +88,15 @@ def _journal_path( ) +def _registration_journals( + registry_path: Path, + *, + goal_id: str, +) -> list[Path]: + directory = lifetime_root(registry_path) / "journals" / alias_digest(goal_id) + return sorted(directory.glob("*.json")) + + def _read_journal(path: Path) -> dict[str, Any] | None: if not path.exists(): return None @@ -263,6 +272,14 @@ def register_fresh_source_session_project( request_digest=request_digest, ) elif not request.registry_path.exists(): + if _registration_journals(request.registry_path, goal_id=request.goal_id): + raise ValueError( + "source-session registration has another reservation journal" + ) + if request.state_file.exists(): + raise ValueError( + "source-session Goal state has no matching reservation journal" + ) journal = _new_registration_journal( request, request_digest=request_digest, diff --git a/loopx/control_plane/projects/registry_codec.py b/loopx/control_plane/projects/registry_codec.py index ddd3815e06..a7899c3196 100644 --- a/loopx/control_plane/projects/registry_codec.py +++ b/loopx/control_plane/projects/registry_codec.py @@ -164,9 +164,17 @@ def _decode_document(raw_bytes: bytes) -> _ProjectRegistryDocument: raise ProjectRegistryError( "strict project registry payload digest does not match" ) + document_format = strict_formats[schema_version] + if ( + document_format is _ProjectRegistryFormat.STRICT_ENVELOPE_V2 + and payload.get("profile_id") != SOURCE_SESSION_PROFILE_ID + ): + raise ProjectRegistryError( + "strict v2 project registry profile_id is unsupported" + ) return _ProjectRegistryDocument( payload=payload, - format=strict_formats[schema_version], + format=document_format, minimum_writer_protocol=protocol, raw_bytes=raw_bytes, ) diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index cab7860041..8f7aa8eb7d 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -1119,7 +1119,7 @@ }, { "site": "loopx/control_plane/goals/source_session_registration.py::.register_fresh_source_session_project::codec_transaction:source_session_registry_transaction#1", - "line": 272, + "line": 289, "column": 14, "kind": "codec_transaction", "api": "source_session_registry_transaction", @@ -1191,7 +1191,7 @@ }, { "site": "loopx/control_plane/projects/registry_codec.py::.add_project_registry_backend::codec_write:mutate_project_registry#1", - "line": 564, + "line": 572, "column": 12, "kind": "codec_write", "api": "mutate_project_registry", @@ -1199,7 +1199,7 @@ }, { "site": "loopx/control_plane/projects/registry_codec.py::.decode_registry_snapshot::codec_read:decode_project_registry#1", - "line": 199, + "line": 207, "column": 15, "kind": "codec_read", "api": "decode_project_registry", @@ -1207,7 +1207,7 @@ }, { "site": "loopx/control_plane/projects/registry_codec.py::.load_registry::codec_read:decode_registry_snapshot#1", - "line": 213, + "line": 221, "column": 12, "kind": "codec_read", "api": "decode_registry_snapshot", @@ -1215,7 +1215,7 @@ }, { "site": "loopx/control_plane/projects/registry_codec.py::.mutate_project_registry::codec_transaction:project_registry_transaction#1", - "line": 536, + "line": 544, "column": 10, "kind": "codec_transaction", "api": "project_registry_transaction", diff --git a/tests/cli_commands/test_source_session_lifetime.py b/tests/cli_commands/test_source_session_lifetime.py index f52748599b..171758ac3f 100644 --- a/tests/cli_commands/test_source_session_lifetime.py +++ b/tests/cli_commands/test_source_session_lifetime.py @@ -257,6 +257,191 @@ def interrupt_before_publication( assert registry_path.read_bytes() == before_replay +def test_registration_recovers_same_instance_after_process_kill( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + knowledge_root = tmp_path / "atlas" + registry_path = knowledge_root / ".loopx" / "registry.json" + state_file = ( + knowledge_root / ".codex" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" + ) + ready = tmp_path / "registration-ready" + resume = tmp_path / "registration-resume" + arguments = _registration_arguments(registry_path, knowledge_root) + script = """ +import json +import os +import time +from pathlib import Path + +from loopx.cli import main +from loopx.control_plane.projects import registry_codec + +ready = Path(os.environ["LOOPX_TEST_READY"]) +resume = Path(os.environ["LOOPX_TEST_RESUME"]) +real_commit = registry_codec.ProjectRegistryTransaction.commit + +def paused_commit(transaction, payload): + ready.write_text("ready", encoding="utf-8") + deadline = time.monotonic() + 10 + while not resume.exists(): + if time.monotonic() >= deadline: + raise TimeoutError("test registration pause timed out") + time.sleep(0.01) + return real_commit(transaction, payload) + +registry_codec.ProjectRegistryTransaction.commit = paused_commit +raise SystemExit(main(json.loads(os.environ["LOOPX_TEST_ARGS"]))) +""" + process = subprocess.Popen( + [sys.executable, "-c", script], + cwd=Path(__file__).resolve().parents[2], + env={ + **os.environ, + "LOOPX_TEST_READY": str(ready), + "LOOPX_TEST_RESUME": str(resume), + "LOOPX_TEST_ARGS": json.dumps(arguments), + }, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + deadline = time.monotonic() + 10 + while not ready.exists() and process.poll() is None: + if time.monotonic() >= deadline: + process.kill() + raise TimeoutError("registration process did not reach the barrier") + time.sleep(0.01) + assert ready.exists() + journals = list( + (registry_path.parent / ".loopx" / "lifecycle" / "goal-instance").glob( + "journals/*/*.json" + ) + ) + assert len(journals) == 1 + reserved = json.loads(journals[0].read_text(encoding="utf-8"))["goal_ref"] + assert state_file.exists() + assert not registry_path.exists() + + process.kill() + process.communicate(timeout=10) + assert process.returncode != 0 + + assert main(arguments) == 0 + recovered = json.loads(capsys.readouterr().out) + assert recovered["goal_ref"] == reserved + assert _registry_payload(registry_path)["goals"][0]["goal_instance_id"] == ( + reserved["goal_instance_id"] + ) + + +def test_registration_rejects_state_without_its_reservation_journal( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + knowledge_root = tmp_path / "atlas" + registry_path = knowledge_root / ".loopx" / "registry.json" + state_file = ( + knowledge_root / ".codex" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" + ) + arguments = _registration_arguments(registry_path, knowledge_root) + monkeypatch.setattr( + source_session_registration, + "now_local_iso", + lambda: "2026-09-23T12:00:00+00:00", + ) + original_commit = registry_codec.ProjectRegistryTransaction.commit + + def interrupt_before_publication( + _transaction: registry_codec.ProjectRegistryTransaction, + _payload: dict[str, object], + ) -> bool: + raise KeyboardInterrupt + + monkeypatch.setattr( + registry_codec.ProjectRegistryTransaction, + "commit", + interrupt_before_publication, + ) + with pytest.raises(KeyboardInterrupt): + main(arguments) + + journals = list( + (registry_path.parent / ".loopx" / "lifecycle" / "goal-instance").glob( + "journals/*/*.json" + ) + ) + assert len(journals) == 1 + assert state_file.exists() + journals[0].unlink() + monkeypatch.setattr( + registry_codec.ProjectRegistryTransaction, + "commit", + original_commit, + ) + + assert main(arguments) == 1 + rejection = json.loads(capsys.readouterr().out) + assert "reservation journal" in rejection["error"] + assert not registry_path.exists() + assert not list( + (registry_path.parent / ".loopx" / "lifecycle" / "goal-instance").glob( + "journals/*/*.json" + ) + ) + + +def test_registration_rejects_a_competing_reserved_operation( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + knowledge_root = tmp_path / "atlas" + registry_path = knowledge_root / ".loopx" / "registry.json" + state_file = ( + knowledge_root / ".codex" / "goals" / "atlas-import" / "ACTIVE_GOAL_STATE.md" + ) + arguments = _registration_arguments(registry_path, knowledge_root) + original_commit = registry_codec.ProjectRegistryTransaction.commit + + def interrupt_before_publication( + _transaction: registry_codec.ProjectRegistryTransaction, + _payload: dict[str, object], + ) -> bool: + raise KeyboardInterrupt + + monkeypatch.setattr( + registry_codec.ProjectRegistryTransaction, + "commit", + interrupt_before_publication, + ) + with pytest.raises(KeyboardInterrupt): + main(arguments) + + state_file.unlink() + monkeypatch.setattr( + registry_codec.ProjectRegistryTransaction, + "commit", + original_commit, + ) + competing = list(arguments) + competing[competing.index("--operation-id") + 1] = "competing-create" + + assert main(competing) == 1 + rejection = json.loads(capsys.readouterr().out) + assert "reservation journal" in rejection["error"] + assert not registry_path.exists() + assert len( + list( + (registry_path.parent / ".loopx" / "lifecycle" / "goal-instance").glob( + "journals/*/*.json" + ) + ) + ) == 1 + + def test_bind_and_unbind_commit_exact_receipts( tmp_path: Path, capsys: pytest.CaptureFixture[str], @@ -479,6 +664,108 @@ def paused_lock(path, **kwargs): ) +def test_recreation_waits_for_an_admitted_bind_commit( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _, registry_path, registration = _register(tmp_path, capsys) + instance_a = str(registration["goal_ref"]["goal_instance_id"]) + ready = tmp_path / "guard-held" + resume = tmp_path / "release-guard" + bind_arguments = _binding_arguments( + registry_path, + operation="bind", + goal_instance_id=instance_a, + operation_id="bind-before-recreate", + session_id="serialized-session", + ) + script = """ +import json +import os +import time +from contextlib import contextmanager +from pathlib import Path + +from loopx.cli import main +from loopx.control_plane.goals import source_session_binding + +ready = Path(os.environ["LOOPX_TEST_READY"]) +resume = Path(os.environ["LOOPX_TEST_RESUME"]) +real_lock = source_session_binding.exclusive_cross_runtime_file_lock + +@contextmanager +def paused_lock(path, **kwargs): + with real_lock(path, **kwargs): + if kwargs.get("operation") == "source_session_goal_lifetime": + ready.write_text("ready", encoding="utf-8") + deadline = time.monotonic() + 10 + while not resume.exists(): + if time.monotonic() >= deadline: + raise TimeoutError("test lifetime guard pause timed out") + time.sleep(0.01) + yield + +source_session_binding.exclusive_cross_runtime_file_lock = paused_lock +raise SystemExit(main(json.loads(os.environ["LOOPX_TEST_ARGS"]))) +""" + bind_process = subprocess.Popen( + [sys.executable, "-c", script], + cwd=Path(__file__).resolve().parents[2], + env={ + **os.environ, + "LOOPX_TEST_READY": str(ready), + "LOOPX_TEST_RESUME": str(resume), + "LOOPX_TEST_ARGS": json.dumps(bind_arguments), + }, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + deadline = time.monotonic() + 10 + while not ready.exists() and bind_process.poll() is None: + if time.monotonic() >= deadline: + bind_process.kill() + raise TimeoutError("bind process did not acquire the lifetime guard") + time.sleep(0.01) + assert ready.exists() + + recreate_process = subprocess.Popen( + [ + sys.executable, + "-m", + "loopx.cli", + *_recreation_arguments( + registry_path, + goal_instance_id=instance_a, + operation_id="recreate-after-bind", + ), + ], + cwd=Path(__file__).resolve().parents[2], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + time.sleep(0.2) + assert recreate_process.poll() is None + assert _registry_payload(registry_path)["goals"][0]["goal_instance_id"] == instance_a + + resume.write_text("resume", encoding="utf-8") + bind_stdout, bind_stderr = bind_process.communicate(timeout=15) + recreate_stdout, recreate_stderr = recreate_process.communicate(timeout=15) + assert bind_process.returncode == 0, bind_stderr + assert recreate_process.returncode == 0, recreate_stderr + bound = json.loads(bind_stdout) + recreated = json.loads(recreate_stdout) + registry = _registry_payload(registry_path) + + assert bound["changed"] is True + assert recreated["retired_session_ids"] == ["serialized-session"] + assert registry["session_bindings"] == [] + assert registry["goals"][0]["goal_instance_id"] == ( + recreated["goal_ref"]["goal_instance_id"] + ) + + def test_resolve_classifies_current_and_retired_exact_refs( tmp_path: Path, capsys: pytest.CaptureFixture[str], diff --git a/tests/control_plane/test_project_registry_codec.py b/tests/control_plane/test_project_registry_codec.py index 3d7983bdaa..2f8f9e0d14 100644 --- a/tests/control_plane/test_project_registry_codec.py +++ b/tests/control_plane/test_project_registry_codec.py @@ -9,6 +9,7 @@ from loopx.control_plane.projects import registry_codec from loopx.control_plane.projects.registry_codec import ( + ProjectRegistryError, ProjectRegistryMutationError, ProjectRegistryProtocolError, load_project_registry, @@ -208,6 +209,29 @@ def test_source_session_profile_is_not_a_generic_runtime_registry( load_registry(path) +def test_v2_envelope_rejects_an_unknown_profile(tmp_path: Path) -> None: + path = tmp_path / "registry.json" + payload: dict[str, object] = { + "schema_version": "0.2", + "profile_id": "unqualified_profile_v1", + "goals": [], + } + _write( + path, + [ + { + "schema_version": "loopx_project_registry_envelope_v2", + "minimum_writer_protocol": "goal_instance_v2", + "payload_sha256": _digest(payload), + }, + payload, + ], + ) + + with pytest.raises(ProjectRegistryError, match="profile_id"): + load_project_registry(path) + + def test_global_registry_mutation_remains_object_only(tmp_path: Path) -> None: path = tmp_path / "registry.global.json" _write(path, _strict({"schema_version": "0.1", "goals": []})) From 62376c0011722130e4a326a6d42fdfe202affb57 Mon Sep 17 00:00:00 2001 From: "duanjialing.777" Date: Thu, 24 Sep 2026 00:52:12 +0800 Subject: [PATCH 4/4] fix(goals): replay creation receipts before mutable state Signed-off-by: duanjialing.777 --- .../goals/source_session_registration.py | 87 ++++++++------- .../project_registry_io_manifest_v1.json | 2 +- .../test_source_session_lifetime.py | 101 ++++++++++++++++++ 3 files changed, 148 insertions(+), 42 deletions(-) diff --git a/loopx/control_plane/goals/source_session_registration.py b/loopx/control_plane/goals/source_session_registration.py index 64c8794e6a..9d7ad80d4e 100644 --- a/loopx/control_plane/goals/source_session_registration.py +++ b/loopx/control_plane/goals/source_session_registration.py @@ -217,6 +217,35 @@ def _creation_receipt( } +def _registration_result( + request: FreshSourceSessionRegistration, + *, + receipt: dict[str, Any], + changed: bool, + replayed: bool, +) -> dict[str, Any]: + goal_ref = copy.deepcopy(receipt["goal_ref"]) + goal = { + **copy.deepcopy(request.goal_record), + "goal_instance_id": goal_ref["goal_instance_id"], + "execution_authority": False, + } + return { + "ok": True, + "schema_version": "loopx_project_registration_v1", + "changed": changed, + "replayed": replayed, + "registry": str(request.registry_path), + "project": copy.deepcopy(request.project_record), + "goal": goal, + "goal_ref": goal_ref, + "request_digest": receipt["request_digest"], + "receipt": copy.deepcopy(receipt), + "state_file": str(request.state_file), + "execution_authority": False, + } + + def _matching_creation_receipt( registry: dict[str, Any], request: FreshSourceSessionRegistration, @@ -237,11 +266,15 @@ def _matching_creation_receipt( if not matches: return None receipt = matches[0] + goal_ref = receipt.get("goal_ref") if ( receipt.get("schema_version") != _CREATION_RECEIPT_SCHEMA or receipt.get("request_digest") != request_digest or not isinstance(receipt.get("created_at"), str) - or not isinstance(receipt.get("goal_ref"), dict) + or not isinstance(goal_ref, dict) + or goal_ref.get("goal_id") != request.goal_id + or not isinstance(goal_ref.get("goal_instance_id"), str) + or not GOAL_INSTANCE_ID.fullmatch(goal_ref["goal_instance_id"]) ): raise ValueError("source-session operation_id conflicts with its receipt") return receipt @@ -310,22 +343,6 @@ def register_fresh_source_session_project( ) if existing_receipt is not None: goal_ref = existing_receipt["goal_ref"] - goals = registry.get("goals") - if ( - not isinstance(goals, list) - or len(goals) != 1 - or not isinstance(goals[0], dict) - or goals[0].get("id") != goal_ref.get("goal_id") - or goals[0].get("goal_instance_id") - != goal_ref.get("goal_instance_id") - ): - raise ValueError( - "source-session creation receipt does not match current Goal" - ) - state_changed = _ensure_registration_state( - request, - updated_at=existing_receipt["created_at"], - ) if journal is None: journal = { "schema_version": _JOURNAL_SCHEMA, @@ -339,18 +356,12 @@ def register_fresh_source_session_project( elif journal["phase"] != "published": journal = {**journal, "phase": "published"} write_journal(journal_path, journal) - return { - "ok": True, - "schema_version": "loopx_project_registration_v1", - "changed": state_changed, - "registry": str(request.registry_path), - "project": registry["projects"][0], - "goal": goals[0], - "goal_ref": copy.deepcopy(goal_ref), - "request_digest": request_digest, - "state_file": str(request.state_file), - "execution_authority": False, - } + return _registration_result( + request, + receipt=existing_receipt, + changed=False, + replayed=True, + ) if request.registry_path.exists(): raise ValueError( @@ -381,15 +392,9 @@ def register_fresh_source_session_project( journal = {**journal, "phase": "published"} write_journal(journal_path, journal) - return { - "ok": True, - "schema_version": "loopx_project_registration_v1", - "changed": True, - "registry": str(request.registry_path), - "project": copy.deepcopy(request.project_record), - "goal": goal_record, - "goal_ref": copy.deepcopy(journal["goal_ref"]), - "request_digest": request_digest, - "state_file": str(request.state_file), - "execution_authority": False, - } + return _registration_result( + request, + receipt=receipt, + changed=True, + replayed=False, + ) diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 8f7aa8eb7d..35246503db 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -1119,7 +1119,7 @@ }, { "site": "loopx/control_plane/goals/source_session_registration.py::.register_fresh_source_session_project::codec_transaction:source_session_registry_transaction#1", - "line": 289, + "line": 322, "column": 14, "kind": "codec_transaction", "api": "source_session_registry_transaction", diff --git a/tests/cli_commands/test_source_session_lifetime.py b/tests/cli_commands/test_source_session_lifetime.py index 171758ac3f..5817e8a3a2 100644 --- a/tests/cli_commands/test_source_session_lifetime.py +++ b/tests/cli_commands/test_source_session_lifetime.py @@ -137,6 +137,7 @@ def test_registration_publishes_fresh_v2_without_global_sync( goal = registry["goals"][0] assert payload["changed"] is True + assert payload["replayed"] is False assert payload["execution_authority"] is False assert payload["goal_ref"] == { "goal_id": "atlas-import", @@ -158,6 +159,7 @@ def test_registration_publishes_fresh_v2_without_global_sync( "created_at": registry["updated_at"], } ] + assert payload["receipt"] == registry["lifetime_receipts"][0] assert global_registry.read_bytes() == global_before @@ -257,6 +259,105 @@ def interrupt_before_publication( assert registry_path.read_bytes() == before_replay +def test_registration_replays_original_receipt_after_goal_recreation( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + _, registry_path, registration = _register(tmp_path, capsys) + instance_a = str(registration["goal_ref"]["goal_instance_id"]) + assert main( + _recreation_arguments( + registry_path, + goal_instance_id=instance_a, + ) + ) == 0 + recreated = json.loads(capsys.readouterr().out) + assert recreated["goal_ref"]["goal_instance_id"] != instance_a + registry_before_replay = registry_path.read_bytes() + state_file = Path(str(registration["state_file"])) + state_before_replay = state_file.read_bytes() + journal = next( + ( + registry_path.parent / ".loopx" / "lifecycle" / "goal-instance" / "journals" + ).glob("*/*.json") + ) + journal_before_replay = journal.read_bytes() + + def reject_write(*_args: object, **_kwargs: object) -> None: + raise AssertionError("completed registration replay must not write") + + monkeypatch.setattr( + registry_codec.ProjectRegistryTransaction, + "commit", + reject_write, + ) + monkeypatch.setattr(source_session_registration, "write_journal", reject_write) + monkeypatch.setattr( + source_session_registration, + "atomic_write_state_text", + reject_write, + ) + assert main(_registration_arguments(registry_path, tmp_path / "atlas")) == 0 + replay = json.loads(capsys.readouterr().out) + + assert replay["changed"] is False + assert replay["replayed"] is True + assert replay["goal_ref"] == registration["goal_ref"] + assert replay["goal"] == registration["goal"] + assert replay["receipt"] == registration["receipt"] + assert registry_path.read_bytes() == registry_before_replay + assert state_file.read_bytes() == state_before_replay + assert journal.read_bytes() == journal_before_replay + + +def test_registration_replay_preserves_later_goal_state_progress( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + knowledge_root, registry_path, registration = _register(tmp_path, capsys) + state_file = Path(str(registration["state_file"])) + state_file.write_text( + f"{state_file.read_text(encoding='utf-8')}\nProgress: imported 10 records.\n", + encoding="utf-8", + ) + registry_before_replay = registry_path.read_bytes() + state_before_replay = state_file.read_bytes() + journal = next( + ( + registry_path.parent / ".loopx" / "lifecycle" / "goal-instance" / "journals" + ).glob("*/*.json") + ) + journal_before_replay = journal.read_bytes() + + def reject_write(*_args: object, **_kwargs: object) -> None: + raise AssertionError("completed registration replay must not write") + + monkeypatch.setattr( + registry_codec.ProjectRegistryTransaction, + "commit", + reject_write, + ) + monkeypatch.setattr(source_session_registration, "write_journal", reject_write) + monkeypatch.setattr( + source_session_registration, + "atomic_write_state_text", + reject_write, + ) + assert main(_registration_arguments(registry_path, knowledge_root)) == 0 + replay = json.loads(capsys.readouterr().out) + + assert replay["changed"] is False + assert replay["replayed"] is True + assert replay["goal_ref"] == registration["goal_ref"] + assert replay["goal"] == registration["goal"] + assert replay["receipt"] == registration["receipt"] + assert registry_path.read_bytes() == registry_before_replay + assert state_file.read_bytes() == state_before_replay + assert journal.read_bytes() == journal_before_replay + + def test_registration_recovers_same_instance_after_process_kill( tmp_path: Path, capsys: pytest.CaptureFixture[str],