From 6dff00312554c5ca39511bcdaa89c20e5bb1f1a2 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Wed, 2 Sep 2026 07:42:43 +1000 Subject: [PATCH 1/8] feat(authority): add local post-commit shadow Signed-off-by: wchwawa --- loopx/cli_commands/registry_admin.py | 6 + .../cli_commands/registry_admin_configure.py | 16 + loopx/configuration_catalog.py | 51 ++ loopx/configure_goal.py | 56 ++ .../coordination/local_authority_shadow.ts | 313 +++++++++++ .../local_authority_shadow_adapter.py | 358 ++++++++++++ .../control_plane/effect_runtime_handlers.ts | 2 + loopx/control_plane/todos/handoff_mode.py | 12 + .../work_items/task_lease_acquire_adapter.py | 65 +++ loopx/state_migration.py | 134 ++++- loopx/todo_followups.py | 18 +- loopx/todos.py | 78 ++- pyproject.toml | 1 + .../test_local_authority_shadow_config.py | 159 ++++++ .../test_local_authority_shadow_runtime.py | 508 ++++++++++++++++++ .../test_state_migration_authority_shadow.py | 262 +++++++++ .../local_authority_shadow.test.ts | 197 +++++++ tsconfig.control-plane.json | 2 + 18 files changed, 2227 insertions(+), 11 deletions(-) create mode 100644 loopx/control_plane/coordination/local_authority_shadow.ts create mode 100644 loopx/control_plane/coordination/local_authority_shadow_adapter.py create mode 100644 tests/control_plane/test_local_authority_shadow_config.py create mode 100644 tests/control_plane/test_local_authority_shadow_runtime.py create mode 100644 tests/control_plane/test_state_migration_authority_shadow.py create mode 100644 tests/control_plane_ts/local_authority_shadow.test.ts diff --git a/loopx/cli_commands/registry_admin.py b/loopx/cli_commands/registry_admin.py index 1a8d96490c..2419449eb3 100644 --- a/loopx/cli_commands/registry_admin.py +++ b/loopx/cli_commands/registry_admin.py @@ -494,6 +494,12 @@ def handle_registry_admin_command( write_scope=args.write_scope, replace_write_scope=bool(args.replace_write_scope), clear_write_scope=bool(args.clear_write_scope), + local_authority_shadow_file=bool( + args.local_authority_shadow_file + ), + clear_local_authority_shadow=bool( + args.clear_local_authority_shadow + ), waiting_on=args.waiting_on, clear_waiting_on=bool(args.clear_waiting_on), boundary_authority_scopes=args.boundary_authority_scope, diff --git a/loopx/cli_commands/registry_admin_configure.py b/loopx/cli_commands/registry_admin_configure.py index c9b904920d..61e4482b65 100644 --- a/loopx/cli_commands/registry_admin_configure.py +++ b/loopx/cli_commands/registry_admin_configure.py @@ -251,6 +251,22 @@ def register_configure_goal_command(subparsers: argparse._SubParsersAction) -> N action="store_true", help="Clear coordination.write_scope.", ) + configure_goal_parser.add_argument( + "--local-authority-shadow-file", + action="store_true", + help=( + "Enable the default-off, one-way post-commit FileAuthorityStore " + "qualification shadow. Legacy local writers remain authoritative." + ), + ) + configure_goal_parser.add_argument( + "--clear-local-authority-shadow", + action="store_true", + help=( + "Disable the local authority shadow. This does not delete retained " + "qualification evidence." + ), + ) configure_goal_parser.add_argument( "--waiting-on", choices=["codex", "user_or_controller", "controller", "external_evidence"], diff --git a/loopx/configuration_catalog.py b/loopx/configuration_catalog.py index 11de69072f..36375a494b 100644 --- a/loopx/configuration_catalog.py +++ b/loopx/configuration_catalog.py @@ -77,6 +77,11 @@ def build_goal_configuration_catalog( if isinstance(feature_summary.get("peer_task_coordination"), Mapping) else {} ) + local_authority_shadow = ( + feature_summary.get("local_authority_shadow") + if isinstance(feature_summary.get("local_authority_shadow"), Mapping) + else {} + ) graph_enable_args = ("--explore-graph-enabled",) harness_enable_args = ( "--explore-harness-enabled", @@ -100,6 +105,52 @@ def build_goal_configuration_catalog( ), }, "features": [ + { + "feature_id": "local_authority_shadow", + "display_name": "Local authority parity shadow", + "availability": "qualification_opt_in", + "default": {"enabled": False}, + "current": { + "enabled": local_authority_shadow.get("enabled") is True, + "mode": local_authority_shadow.get("mode"), + "status": local_authority_shadow.get("status", "disabled"), + }, + "consider_when": ( + "A Goal needs post-commit parity evidence before any local " + "authority-source promotion." + ), + "effect": ( + "Observes committed Todo and task-lease state through the " + "FileAuthorityStore contract." + ), + "does_not": [ + "read the candidate for lifecycle decisions", + "write candidate state back into Markdown or task-lease files", + "promote shared authority or fence legacy writers", + ], + "commands": { + "preview_enable": _configure_command( + goal_id, "--local-authority-shadow-file" + ), + "apply_enable": _configure_command( + goal_id, "--local-authority-shadow-file", execute=True + ), + "preview_disable": _configure_command( + goal_id, "--clear-local-authority-shadow" + ), + "apply_disable": _configure_command( + goal_id, "--clear-local-authority-shadow", execute=True + ), + "verify": [inspect_command], + }, + "documentation": { + "path": "docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md", + "url": ( + "https://github.com/huangruiteng/loopx/blob/main/" + "docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md" + ), + }, + }, { "feature_id": "multi_subagent", "display_name": "Adaptive child capacity", diff --git a/loopx/configure_goal.py b/loopx/configure_goal.py index d73987af3c..c9eb0084bd 100644 --- a/loopx/configure_goal.py +++ b/loopx/configure_goal.py @@ -70,6 +70,9 @@ MULTI_SUBAGENT_FEATURE_CHOICES = ("off", "enabled") DEFAULT_MULTI_SUBAGENT_MAX_CHILDREN = 2 AGENT_MODEL_CHOICES = tuple(model.value for model in AgentRuntimeModel) +LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA_VERSION = ( + "loopx_local_authority_shadow_config_v0" +) def _control_plane(goal: dict[str, Any]) -> dict[str, Any]: @@ -233,6 +236,29 @@ def _clean_write_scope(values: list[str] | None) -> list[str] | None: return scopes +def _local_authority_shadow_summary(goal: Mapping[str, Any]) -> dict[str, Any]: + coordination = ( + goal.get("coordination") + if isinstance(goal.get("coordination"), Mapping) + else {} + ) + raw = coordination.get("authority_shadow") + if raw is None: + return {"enabled": False, "mode": None, "status": "disabled"} + valid = bool( + isinstance(raw, Mapping) + and set(raw) == {"schema_version", "mode"} + and raw.get("schema_version") + == LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA_VERSION + and raw.get("mode") == "file_one_way" + ) + return { + "enabled": valid, + "mode": raw.get("mode") if isinstance(raw, Mapping) else None, + "status": "enabled" if valid else "invalid", + } + + def _settings_summary(goal: dict[str, Any]) -> dict[str, Any]: quota = goal_quota_config(goal) control_plane = compact_control_plane_policy(goal.get("control_plane")) @@ -260,6 +286,7 @@ def _settings_summary(goal: dict[str, Any]) -> dict[str, Any]: "orchestration": orchestration, "waiting_on": goal.get("waiting_on"), "write_scope": _clean_write_scope(coordination.get("write_scope") or []) or [], + "local_authority_shadow": _local_authority_shadow_summary(goal), "checkpointed_boundary_authority": checkpointed_boundary_authority_summary( coordination ), @@ -461,6 +488,8 @@ def configure_goal( write_scope: list[str] | None = None, replace_write_scope: bool = False, clear_write_scope: bool = False, + local_authority_shadow_file: bool = False, + clear_local_authority_shadow: bool = False, waiting_on: str | None = None, clear_waiting_on: bool = False, boundary_authority_scopes: list[str] | None = None, @@ -535,6 +564,11 @@ def configure_goal( raise ValueError( "--clear-write-scope cannot be combined with --replace-write-scope" ) + if local_authority_shadow_file and clear_local_authority_shadow: + raise ValueError( + "--local-authority-shadow-file cannot be combined with " + "--clear-local-authority-shadow" + ) if clear_waiting_on and waiting_on: raise ValueError("--clear-waiting-on cannot be combined with --waiting-on") adding_boundary_authority = any( @@ -1241,6 +1275,24 @@ def configure_goal( coordination["checkpointed_boundary_authority"] = [*entries, entry] goal["coordination"] = coordination + if local_authority_shadow_file or clear_local_authority_shadow: + coordination = ( + goal.get("coordination") + if isinstance(goal.get("coordination"), dict) + else {} + ) + if clear_local_authority_shadow: + coordination.pop("authority_shadow", None) + else: + coordination["authority_shadow"] = { + "schema_version": LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA_VERSION, + "mode": "file_one_way", + } + if coordination: + goal["coordination"] = coordination + else: + goal.pop("coordination", None) + after = _settings_summary(goal) changed_fields = _changed_fields(before, after) if goal != before_goal and not changed_fields: @@ -1279,6 +1331,10 @@ def configure_goal( "peer_task_coordination": deepcopy( after.get("peer_task_coordination") or {"enabled": False} ), + "local_authority_shadow": deepcopy( + after.get("local_authority_shadow") + or {"enabled": False, "mode": None, "status": "disabled"} + ), "lark_event_inbox": _lark_event_inbox_config_summary(goal), "lark_kanban_heartbeat_sync": _lark_kanban_heartbeat_config_summary(goal), "reward_memory": reward_memory_goal_policy_summary(goal), diff --git a/loopx/control_plane/coordination/local_authority_shadow.ts b/loopx/control_plane/coordination/local_authority_shadow.ts new file mode 100644 index 0000000000..802d1666f5 --- /dev/null +++ b/loopx/control_plane/coordination/local_authority_shadow.ts @@ -0,0 +1,313 @@ +import { join } from "node:path"; + +import type { JsonObject } from "../effect_program.ts"; +import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; +import { requireJsonObject, requireNonEmptyString } from "../runtime_decode.ts"; +import type { + AuthorityStore, + AuthorityStoreLoadResult, + AuthorityStoreReceiptResult, +} from "./authority_store.ts"; +import { FileAuthorityStore } from "./file_authority_store.ts"; + +export const LOCAL_AUTHORITY_SHADOW_REQUEST_SCHEMA = + "loopx_local_authority_shadow_request_v0"; +export const LOCAL_AUTHORITY_SHADOW_PROJECTION_SCHEMA = + "loopx_local_authority_shadow_projection_v0"; +export const LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA = + "loopx_local_authority_shadow_evidence_v0"; +export const LOCAL_AUTHORITY_SHADOW_RECEIPT_SCHEMA = + "loopx_local_authority_shadow_receipt_v0"; + +const REQUEST_FIELDS = new Set([ + "schema_version", + "mode", + "runtime_root", + "goal_id", + "operation_id", + "source_operation", + "source_digest", + "source_projection", +]); +export type LocalAuthorityShadowOutcome = + | "advanced" + | "replayed" + | "ambiguous_reconciled" + | "ambiguous_unproved" + | "unavailable" + | "failed" + | "protocol_mismatch" + | "conflict_retry_required"; + +export interface LocalAuthorityShadowEvidence extends JsonObject { + schema_version: typeof LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA; + outcome: LocalAuthorityShadowOutcome; + reason_code: string | null; + goal_id: string; + operation_id: string; + source_digest: string; + primary_authority: "legacy_local"; + candidate_provider: "file"; + candidate_read_for_decision: false; + provider_to_local_writes: false; + primary_writeback_preserved: true; + store_identity: string | null; + provider_revision: string | null; + cursor: string | null; +} + +interface LocalAuthorityShadowRequest { + mode: "file_one_way"; + runtime_root: string; + goal_id: string; + operation_id: string; + source_operation: string; + source_digest: string; + source_projection: JsonObject; +} + +export interface LocalAuthorityShadowDependencies { + openStore?: (directory: string, goalId: string) => AuthorityStore; +} + +function decodeRequest(value: unknown): LocalAuthorityShadowRequest { + const request = requireJsonObject(value, "local authority shadow request"); + const unexpected = Object.keys(request).filter((field) => !REQUEST_FIELDS.has(field)); + if (unexpected.length > 0) { + throw new EffectRuntimeRequestError( + `Local authority shadow request has unsupported fields: ${unexpected.sort().join(", ")}`, + ); + } + if (request.schema_version !== LOCAL_AUTHORITY_SHADOW_REQUEST_SCHEMA) { + throw new EffectRuntimeRequestError("Local authority shadow request schema mismatch"); + } + if (request.mode !== "file_one_way") { + throw new EffectRuntimeRequestError("Local authority shadow mode must be file_one_way"); + } + const goalId = requireNonEmptyString(request.goal_id, "goal_id"); + if (goalId === "." || goalId === ".." || goalId.includes("/") || goalId.includes("\\")) { + throw new EffectRuntimeRequestError( + "Local authority shadow goal id must be a single path segment", + ); + } + const projection = requireJsonObject(request.source_projection, "source_projection"); + if ( + projection.schema_version !== LOCAL_AUTHORITY_SHADOW_PROJECTION_SCHEMA || + projection.goal_id !== goalId + ) { + throw new EffectRuntimeRequestError( + "Local authority shadow projection schema or goal identity mismatch", + ); + } + const sourceDigest = requireNonEmptyString(request.source_digest, "source_digest"); + if (!/^sha256:[a-f0-9]{64}$/u.test(sourceDigest)) { + throw new EffectRuntimeRequestError("source_digest must be sha256:<64 lowercase hex>"); + } + return { + mode: "file_one_way", + runtime_root: requireNonEmptyString(request.runtime_root, "runtime_root"), + goal_id: goalId, + operation_id: requireNonEmptyString(request.operation_id, "operation_id"), + source_operation: requireNonEmptyString( + request.source_operation, + "source_operation", + ), + source_digest: sourceDigest, + source_projection: structuredClone(projection), + }; +} + +function evidence( + request: LocalAuthorityShadowRequest, + outcome: LocalAuthorityShadowOutcome, + options: { + reasonCode?: string | null; + storeIdentity?: string | null; + providerRevision?: string | null; + cursor?: string | null; + } = {}, +): LocalAuthorityShadowEvidence { + return { + schema_version: LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA, + outcome, + reason_code: options.reasonCode ?? null, + goal_id: request.goal_id, + operation_id: request.operation_id, + source_digest: request.source_digest, + primary_authority: "legacy_local", + candidate_provider: "file", + candidate_read_for_decision: false, + provider_to_local_writes: false, + primary_writeback_preserved: true, + store_identity: options.storeIdentity ?? null, + provider_revision: options.providerRevision ?? null, + cursor: options.cursor ?? null, + }; +} + +function readFailureEvidence( + request: LocalAuthorityShadowRequest, + result: Extract, + storeIdentity: string | null, +): LocalAuthorityShadowEvidence { + return evidence(request, result.status, { + reasonCode: result.reason_code, + storeIdentity, + }); +} + +function receiptMatches( + request: LocalAuthorityShadowRequest, + result: Extract, +): boolean { + return result.receipts.some((raw) => { + const receipt = raw as Record; + return receipt.schema_version === LOCAL_AUTHORITY_SHADOW_RECEIPT_SCHEMA && + receipt.operation_id === request.operation_id && + receipt.source_digest === request.source_digest && + receipt.primary_authority === "legacy_local" && + receipt.provider_to_local_writes === false; + }); +} + +async function reconcileReceipt( + store: AuthorityStore, + request: LocalAuthorityShadowRequest, + storeIdentity: string, + reconciledOutcome: "replayed" | "ambiguous_reconciled", +): Promise { + const result = await store.readReceipt(request.operation_id); + if (result.status === "found" && receiptMatches(request, result)) { + return evidence(request, reconciledOutcome, { + storeIdentity, + providerRevision: result.provider_revision, + cursor: result.cursor, + }); + } + if (result.status === "unavailable") { + return evidence(request, "unavailable", { + reasonCode: result.reason_code, + storeIdentity, + }); + } + if (result.status === "failed") { + return evidence(request, "failed", { + reasonCode: result.reason_code, + storeIdentity, + }); + } + return evidence( + request, + reconciledOutcome === "ambiguous_reconciled" + ? "ambiguous_unproved" + : "protocol_mismatch", + { + reasonCode: result.status === "missing" + ? "operation_receipt_missing" + : "operation_receipt_mismatch", + storeIdentity, + }, + ); +} + +/** + * Record a post-commit observation in a candidate AuthorityStore. + * + * The legacy local writers remain the only decision authority. This function + * receives a completed source projection and has no route back to those files. + */ +export async function recordLocalAuthorityShadow( + value: unknown, + dependencies: LocalAuthorityShadowDependencies = {}, +): Promise { + const request = decodeRequest(value); + let store: AuthorityStore; + try { + const providerDirectory = join( + request.runtime_root, + "authority-shadow", + "file", + request.goal_id, + ); + store = (dependencies.openStore ?? ((directory, goalId) => + new FileAuthorityStore(directory, goalId)))( + providerDirectory, + request.goal_id, + ); + } catch { + return evidence(request, "unavailable", { + reasonCode: "provider_construction_failed", + }); + } + + try { + const identity = await store.storeIdentity(); + if (identity.status !== "available") { + return evidence(request, identity.status, { reasonCode: identity.reason_code }); + } + const storeIdentity = identity.store_identity; + const receipt = { + schema_version: LOCAL_AUTHORITY_SHADOW_RECEIPT_SCHEMA, + operation_id: request.operation_id, + source_digest: request.source_digest, + source_operation: request.source_operation, + primary_authority: "legacy_local", + candidate_read_for_decision: false, + provider_to_local_writes: false, + }; + const event = { + schema_version: "loopx_local_authority_shadow_event_v0", + kind: "source_observed", + operation_id: request.operation_id, + source_operation: request.source_operation, + source_digest: request.source_digest, + }; + + const loaded = await store.loadAuthority(); + if (loaded.status === "unavailable" || loaded.status === "failed") { + return readFailureEvidence(request, loaded, storeIdentity); + } + const committed = await store.commitAuthority({ + expected_provider_revision: + loaded.status === "loaded" ? loaded.provider_revision : null, + operation_id: request.operation_id, + events: [event], + next_projection: request.source_projection, + receipts: [receipt], + }); + if (committed.status === "applied") { + return evidence(request, "advanced", { + storeIdentity, + providerRevision: committed.provider_revision, + cursor: committed.cursor, + }); + } + if (committed.status === "ambiguous") { + return await reconcileReceipt( + store, + request, + storeIdentity, + "ambiguous_reconciled", + ); + } + if (committed.status === "failed") { + return evidence(request, "failed", { + reasonCode: committed.reason_code, + storeIdentity, + }); + } + if (committed.conflict_kind === "operation_id_exists") { + return await reconcileReceipt(store, request, storeIdentity, "replayed"); + } + return evidence(request, "conflict_retry_required", { + reasonCode: "provider_revision_mismatch", + storeIdentity, + providerRevision: committed.current_provider_revision, + cursor: committed.current_cursor, + }); + } catch { + return evidence(request, "unavailable", { + reasonCode: "provider_call_failed", + }); + } +} diff --git a/loopx/control_plane/coordination/local_authority_shadow_adapter.py b/loopx/control_plane/coordination/local_authority_shadow_adapter.py new file mode 100644 index 0000000000..c977cc7bf5 --- /dev/null +++ b/loopx/control_plane/coordination/local_authority_shadow_adapter.py @@ -0,0 +1,358 @@ +"""Post-commit bridge from legacy local authority into a file shadow. + +The adapter deliberately owns no lifecycle decision. It is entered only after +the existing Markdown or task-lease writer has succeeded, projects public-safe +facts, and asks the TypeScript authority-store boundary to retain an +observation. Missing configuration is a zero-effect fast path. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +from ...file_lock import LockAcquireTimeoutError, exclusive_file_lock +from ...history import load_registry +from ...paths import resolve_runtime_root +from ...registry import find_registry_goal +from ..effect_runtime import effect_runtime_result + + +LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA = "loopx_local_authority_shadow_config_v0" +LOCAL_AUTHORITY_SHADOW_REQUEST_SCHEMA = "loopx_local_authority_shadow_request_v0" +LOCAL_AUTHORITY_SHADOW_PROJECTION_SCHEMA = "loopx_local_authority_shadow_projection_v0" +LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA = "loopx_local_authority_shadow_evidence_v0" +_CONFIG_FIELDS = {"schema_version", "mode"} +_PROJECTION_ATTEMPTS = 3 +_CONFLICT_RETRY_ATTEMPTS = 3 +_EVIDENCE_OUTCOMES = { + "advanced", + "replayed", + "ambiguous_reconciled", + "ambiguous_unproved", + "unavailable", + "failed", + "protocol_mismatch", + "conflict_retry_required", +} +_TODO_FIELDS = ( + "todo_id", + "role", + "status", + "claimed_by", + "bound_agent", + "goal_bound", + "blocks_agent", + "excluded_agents", + "global_gate", + "task_class", + "action_kind", + "required_write_scopes", + "required_capabilities", + "continuation_policy", + "successor_todo_ids", + "no_followup", + "completion_continuation", +) +_LEASE_FIELDS = ( + "todo_id", + "owner", + "idempotency_key", + "write_scopes", + "version", + "lease_epoch", + "acquired_at", + "updated_at", + "expires_at", + "released_at", + "status", +) + + +def _base_evidence( + *, + goal_id: str, + outcome: str, + reason_code: str, +) -> dict[str, Any]: + return { + "schema_version": LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA, + "outcome": outcome, + "reason_code": reason_code, + "goal_id": goal_id, + "operation_id": None, + "source_digest": None, + "primary_authority": "legacy_local", + "candidate_provider": "file", + "candidate_read_for_decision": False, + "provider_to_local_writes": False, + "primary_writeback_preserved": True, + "store_identity": None, + "provider_revision": None, + "cursor": None, + } + + +def _shadow_config(registry: dict[str, Any], goal_id: str) -> dict[str, str] | None: + goal = find_registry_goal(registry, goal_id) + coordination = goal.get("coordination") if isinstance(goal, dict) else None + if not isinstance(coordination, dict) or "authority_shadow" not in coordination: + return None + raw = coordination.get("authority_shadow") + if ( + not isinstance(raw, dict) + or set(raw) != _CONFIG_FIELDS + or raw.get("schema_version") != LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA + or raw.get("mode") != "file_one_way" + ): + raise ValueError("authority_shadow must be a closed file_one_way config") + return {"mode": "file_one_way"} + + +def _canonical(value: object) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + + +def _compact_todo(raw: object) -> dict[str, Any] | None: + if not isinstance(raw, dict): + return None + todo_id = str(raw.get("todo_id") or "").strip() + if not todo_id: + return None + compact = {field: raw[field] for field in _TODO_FIELDS if field in raw} + compact["todo_id"] = todo_id + if "status" not in compact and isinstance(raw.get("done"), bool): + compact["status"] = "done" if raw["done"] else "open" + return json.loads(_canonical(compact)) + + +def _compact_lease(path: Path, *, goal_id: str) -> dict[str, Any]: + raw = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError("task lease must contain an object") + if raw.get("goal_id") != goal_id or raw.get("todo_id") != path.stem: + raise ValueError("task lease identity does not match its shadow source") + return json.loads( + _canonical({field: raw[field] for field in _LEASE_FIELDS if field in raw}) + ) + + +def _source_projection( + *, + registry_path: Path, + runtime_root: Path, + goal_id: str, +) -> dict[str, Any]: + from ...control_plane.todos.handoff_mode import goal_handoff_mode_for_goal + from ...todos import list_goal_todos + + todo_payload = list_goal_todos( + registry_path=registry_path, + goal_id=goal_id, + runtime_root_arg=str(runtime_root), + ) + todos = [ + compact + for raw in todo_payload.get("todos") or [] + if (compact := _compact_todo(raw)) is not None + ] + todos.sort(key=lambda item: str(item["todo_id"])) + lease_dir = runtime_root / "goals" / goal_id / "task-leases" + leases = ( + [ + _compact_lease(path, goal_id=goal_id) + for path in sorted(lease_dir.glob("*.json")) + ] + if lease_dir.exists() + else [] + ) + projection = { + "schema_version": LOCAL_AUTHORITY_SHADOW_PROJECTION_SCHEMA, + "goal_id": goal_id, + "handoff_mode": goal_handoff_mode_for_goal( + registry_path=registry_path, + goal_id=goal_id, + ), + "todos": todos, + "leases": leases, + } + return json.loads(_canonical(projection)) + + +def _stable_projection( + *, + registry_path: Path, + runtime_root: Path, + goal_id: str, +) -> dict[str, Any]: + previous = _source_projection( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + ) + for _attempt in range(_PROJECTION_ATTEMPTS): + current = _source_projection( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + ) + if current == previous: + return current + previous = current + raise RuntimeError("local authority sources did not stabilize for shadowing") + + +def _valid_evidence( + result: object, + *, + goal_id: str, + operation_id: str, + source_digest: str, +) -> bool: + if not isinstance(result, dict): + return False + return ( + result.get("schema_version") == LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA + and result.get("outcome") in _EVIDENCE_OUTCOMES + and result.get("goal_id") == goal_id + and result.get("operation_id") == operation_id + and result.get("source_digest") == source_digest + and result.get("primary_authority") == "legacy_local" + and result.get("candidate_provider") == "file" + and result.get("candidate_read_for_decision") is False + and result.get("provider_to_local_writes") is False + and result.get("primary_writeback_preserved") is True + and ( + result.get("reason_code") is None + or isinstance(result.get("reason_code"), str) + ) + ) + + +def observe_local_authority_commit( + *, + registry_path: Path, + runtime_root: Path | None, + goal_id: str, + source_operation: str, +) -> dict[str, Any] | None: + """Record one local post-commit observation without changing its verdict.""" + + if not goal_id or goal_id in {".", ".."} or "/" in goal_id or "\\" in goal_id: + return _base_evidence( + goal_id=goal_id, + outcome="failed", + reason_code="invalid_shadow_goal_id", + ) + try: + registry = load_registry(registry_path) + config = _shadow_config(registry, goal_id) + except Exception: + return _base_evidence( + goal_id=goal_id, + outcome="failed", + reason_code="invalid_shadow_config", + ) + if config is None: + return None + + try: + if runtime_root is None: + runtime_root = resolve_runtime_root( + registry, + None, + registry_path=registry_path, + ) + # Candidate-provider bytes live outside the legacy per-goal runtime + # tree. State migration may copy that tree, but it must never copy a + # store identity or revision and accidentally create a second lineage. + shadow_root = runtime_root / "authority-shadow" / "file" / goal_id + with exclusive_file_lock( + shadow_root / "observation", + timeout_seconds=1.0, + operation="local_authority_shadow_observe", + ): + result: dict[str, Any] | None = None + for _attempt in range(_CONFLICT_RETRY_ATTEMPTS): + projection = _stable_projection( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + ) + source_digest = ( + "sha256:" + hashlib.sha256(_canonical(projection)).hexdigest() + ) + operation_id = ( + "local-shadow:" + + hashlib.sha256( + _canonical( + { + "goal_id": goal_id, + "source_operation": source_operation, + "source_digest": source_digest, + } + ) + ).hexdigest() + ) + raw_result = effect_runtime_result( + "coordination.local_authority_shadow.record", + { + "schema_version": LOCAL_AUTHORITY_SHADOW_REQUEST_SCHEMA, + "mode": config["mode"], + "runtime_root": str(runtime_root), + "goal_id": goal_id, + "operation_id": operation_id, + "source_operation": source_operation, + "source_digest": source_digest, + "source_projection": projection, + }, + timeout=15.0, + ) + if not _valid_evidence( + raw_result, + goal_id=goal_id, + operation_id=operation_id, + source_digest=source_digest, + ): + return _base_evidence( + goal_id=goal_id, + outcome="failed", + reason_code="shadow_evidence_invalid", + ) + result = dict(raw_result) + if result["outcome"] != "conflict_retry_required": + return result + if result is not None: + return result + except LockAcquireTimeoutError: + return _base_evidence( + goal_id=goal_id, + outcome="unavailable", + reason_code="shadow_observation_lock_timeout", + ) + except Exception: + return _base_evidence( + goal_id=goal_id, + outcome="failed", + reason_code="shadow_observation_failed", + ) + return _base_evidence( + goal_id=goal_id, + outcome="failed", + reason_code="shadow_observation_failed", + ) + + +__all__ = [ + "LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA", + "LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA", + "observe_local_authority_commit", +] diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 1750ff8baa..dcff196bab 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -97,6 +97,7 @@ import { executeTaskLeaseAcquire, } from "./work_items/task_lease_acquire.ts"; import { executeTaskLeaseLifecycle } from "./work_items/task_lease_lifecycle.ts"; +import { recordLocalAuthorityShadow } from "./coordination/local_authority_shadow.ts"; import { evaluateTaskLeaseLifecycleDecision } from "./work_items/task_lease_lifecycle_decision.ts"; import { projectTodoPlanningInventory, @@ -359,6 +360,7 @@ export function createEffectRuntimeHandlers( ["task_lease.lifecycle.native", executeTaskLeaseLifecycle], ["task_lease.write_scopes.overlap", evaluateTaskLeaseWriteScopesOverlap], ["quota.monitor_poll.commit", evaluateQuotaMonitorPollCommit], + ["coordination.local_authority_shadow.record", recordLocalAuthorityShadow], [ "effect.program_from_ordered_steps", (params) => effectProgramFromOrderedSteps( diff --git a/loopx/control_plane/todos/handoff_mode.py b/loopx/control_plane/todos/handoff_mode.py index f0129b188f..7ad2e83e73 100644 --- a/loopx/control_plane/todos/handoff_mode.py +++ b/loopx/control_plane/todos/handoff_mode.py @@ -509,4 +509,16 @@ def set_goal_handoff_mode( new_text = "\n".join(lines) + ("\n" if original.endswith("\n") else "") resolved_state_file.write_text(new_text, encoding="utf-8") payload["changed"] = True + from ..coordination.local_authority_shadow_adapter import ( + observe_local_authority_commit, + ) + + evidence = observe_local_authority_commit( + registry_path=registry_path, + runtime_root=runtime_root_from_registry(registry_path, None), + goal_id=goal_id, + source_operation=f"handoff_mode_set:{previous}:{requested}", + ) + if evidence is not None: + payload["authority_shadow"] = evidence return payload diff --git a/loopx/control_plane/work_items/task_lease_acquire_adapter.py b/loopx/control_plane/work_items/task_lease_acquire_adapter.py index e96e335958..da6cc83c29 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_adapter.py +++ b/loopx/control_plane/work_items/task_lease_acquire_adapter.py @@ -24,6 +24,44 @@ TASK_LEASE_AUTHORITY_SNAPSHOT_ATTEMPTS = 3 +def _attach_local_authority_shadow( + result: dict[str, Any], + *, + registry_path: Path | None, + runtime_root: Path, + goal_id: str, + todo_id: str, + operation: str, +) -> dict[str, Any]: + """Observe a committed public lease mutation without changing its verdict.""" + + if registry_path is None: + return result + lease = result.get("lease") if isinstance(result.get("lease"), dict) else {} + source_operation = ":".join( + ( + f"task_lease_{operation}", + str(todo_id), + str(lease.get("version") or "none"), + str(lease.get("lease_epoch") or "none"), + str(lease.get("updated_at") or lease.get("released_at") or "unknown"), + ) + ) + from ..coordination.local_authority_shadow_adapter import ( + observe_local_authority_commit, + ) + + evidence = observe_local_authority_commit( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=str(goal_id), + source_operation=source_operation, + ) + if evidence is not None: + result["authority_shadow"] = evidence + return result + + def _authority_source_receipt(source_id: str, path: Path) -> dict[str, Any]: resolved = path.expanduser().resolve(strict=False) try: @@ -338,6 +376,15 @@ def execute_native_task_lease_acquire( authority.get("handoff_mode") or HANDOFF_MODE_LEGACY ) result.pop("settlement", None) + if result.get("ok") is True and result.get("acquired") is True: + result = _attach_local_authority_shadow( + result, + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=str(goal_id), + todo_id=str(todo_id), + operation="acquire", + ) return result raise RuntimeError("native task-lease acquire exhausted source-CAS retries") @@ -592,6 +639,24 @@ def execute_native_task_lease_lifecycle( result["handoff_mode"] = authority["handoff_mode"] if _legacy_provider_projection: result.pop("settlement", None) + committed_mutation = ( + normalized_operation == "renew" and result.get("renewed") is True + ) or ( + normalized_operation == "transfer" + and result.get("transferred") is True + ) or ( + normalized_operation == "release" + and result.get("released") is True + ) + if committed_mutation and result.get("idempotent") is not True: + result = _attach_local_authority_shadow( + result, + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=str(goal_id), + todo_id=str(todo_id), + operation=normalized_operation, + ) # lock_token is an internal bridge value. Callers that need a held # fence read it from the nested native payload before redacting it. return result diff --git a/loopx/state_migration.py b/loopx/state_migration.py index 59cf867058..5e9330b549 100644 --- a/loopx/state_migration.py +++ b/loopx/state_migration.py @@ -2,19 +2,29 @@ import copy import json -import os import shutil from datetime import datetime, timezone from pathlib import Path from typing import Any from .global_registry import write_json -from .paths import DEFAULT_RUNTIME_ROOT from .registry import registry_goals LEGACY_RUNTIME_ROOT = Path.home() / ".codex" / "goal-harness" LEGACY_GLOBAL_REGISTRY = LEGACY_RUNTIME_ROOT / "registry.global.json" +AUTHORITY_SHADOW_CONFIG_SCHEMA = "loopx_local_authority_shadow_config_v0" +MIGRATION_SHADOW_SEED_EVIDENCE_SCHEMA = "loopx_state_migration_shadow_seed_evidence_v0" +_SHADOW_EVIDENCE_OUTCOMES = { + "advanced", + "replayed", + "ambiguous_reconciled", + "ambiguous_unproved", + "unavailable", + "failed", + "protocol_mismatch", + "conflict_retry_required", +} def now_local() -> str: @@ -131,7 +141,7 @@ def copy_active_state_files( for source_goal, target_goal in pairs: source_path = resolve_goal_state(source_goal.get("repo"), source_goal.get("state_file")) target_path = resolve_goal_state(target_goal.get("repo"), target_goal.get("state_file")) - row = { + row: dict[str, Any] = { "goal_id": target_goal.get("id"), "source": str(source_path) if source_path else None, "target": str(target_path) if target_path else None, @@ -171,7 +181,7 @@ def copy_runtime_goal_dirs( new_goal_id = goal_id_map.get(old_goal_id, old_goal_id) source_dir = legacy_runtime_root.expanduser() / "goals" / old_goal_id target_dir = target_runtime_root.expanduser() / "goals" / new_goal_id - row = { + row: dict[str, Any] = { "source": str(source_dir), "target": str(target_dir), "goal_id": new_goal_id, @@ -206,6 +216,104 @@ def copy_runtime_goal_dirs( return results +def _uses_file_authority_shadow(goal: dict[str, Any]) -> bool: + coordination = goal.get("coordination") + if not isinstance(coordination, dict): + return False + config = coordination.get("authority_shadow") + return ( + isinstance(config, dict) + and config.get("schema_version") == AUTHORITY_SHADOW_CONFIG_SCHEMA + and config.get("mode") == "file_one_way" + ) + + +def _shadow_seed_evidence( + *, + goal_id: str, + attempted: bool, + outcome: str, + reason_code: str | None = None, +) -> dict[str, Any]: + return { + "schema_version": MIGRATION_SHADOW_SEED_EVIDENCE_SCHEMA, + "goal_id": goal_id, + "attempted": attempted, + "outcome": outcome, + "reason_code": reason_code, + } + + +def _shadow_seed_result(*, goal_id: str, result: object) -> dict[str, Any]: + outcome = result.get("outcome") if isinstance(result, dict) else None + if outcome not in _SHADOW_EVIDENCE_OUTCOMES: + return _shadow_seed_evidence( + goal_id=goal_id, + attempted=True, + outcome="failed", + reason_code="post_migration_shadow_seed_invalid_evidence", + ) + + reason_code = ( + None + if outcome in {"advanced", "replayed", "ambiguous_reconciled"} + else f"post_migration_shadow_seed_{outcome}" + ) + return _shadow_seed_evidence( + goal_id=goal_id, + attempted=True, + outcome=str(outcome), + reason_code=reason_code, + ) + + +def seed_migrated_authority_shadows( + *, + goals: list[dict[str, Any]], + target_registry_path: Path, + target_runtime_root: Path, + execute: bool, +) -> list[dict[str, Any]]: + """Plan or seed fresh candidate lineage from migrated local authority.""" + + results: list[dict[str, Any]] = [] + for goal in goals: + if not _uses_file_authority_shadow(goal): + continue + goal_id = str(goal.get("id") or "") + if not execute: + results.append( + _shadow_seed_evidence( + goal_id=goal_id, + attempted=False, + outcome="planned", + ) + ) + continue + try: + from .control_plane.coordination.local_authority_shadow_adapter import ( + observe_local_authority_commit, + ) + + result = observe_local_authority_commit( + registry_path=target_registry_path, + runtime_root=target_runtime_root, + goal_id=goal_id, + source_operation="state_migration_seed", + ) + results.append(_shadow_seed_result(goal_id=goal_id, result=result)) + except Exception: + results.append( + _shadow_seed_evidence( + goal_id=goal_id, + attempted=True, + outcome="failed", + reason_code="post_migration_shadow_seed_failed", + ) + ) + return results + + def migrate_legacy_state( *, legacy_registry_path: Path, @@ -282,6 +390,13 @@ def migrate_legacy_state( if execute: write_json(target_registry_path, target_payload) + authority_shadow_seeds = seed_migrated_authority_shadows( + goals=incoming_goals, + target_registry_path=target_registry_path, + target_runtime_root=target_runtime_root, + execute=execute, + ) + return { "ok": True, "schema_version": "loopx_state_migration_v0", @@ -299,6 +414,7 @@ def migrate_legacy_state( "project_registry_goal_count": len(target_payload.get("goals", [])), "active_state": active_state_results, "runtime_goals": runtime_results, + "authority_shadow_seeds": authority_shadow_seeds, } @@ -338,6 +454,16 @@ def render_state_migration_markdown(payload: dict[str, Any]) -> str: ) if row.get("skipped_reason"): lines.append(f" - skipped_reason: `{row.get('skipped_reason')}`") + authority_shadow_seeds = payload.get("authority_shadow_seeds") or [] + if authority_shadow_seeds: + lines.extend(["", "## Authority Shadow Seeds"]) + for row in authority_shadow_seeds: + lines.append( + f"- `{row.get('goal_id')}` outcome=`{row.get('outcome')}` " + f"attempted=`{row.get('attempted')}`" + ) + if row.get("reason_code"): + lines.append(f" - reason_code: `{row.get('reason_code')}`") global_sync = payload.get("global_sync") if isinstance(global_sync, dict): lines.extend(["", "## Global Sync"]) diff --git a/loopx/todo_followups.py b/loopx/todo_followups.py index 45024052ae..963cba7878 100644 --- a/loopx/todo_followups.py +++ b/loopx/todo_followups.py @@ -149,7 +149,7 @@ def capture_followup_todos( if not dry_run: resolved_state_file.write_text(new_text, encoding="utf-8") - return { + result = { "ok": True, "dry_run": dry_run, "changed": changed, @@ -166,3 +166,19 @@ def capture_followup_todos( "items": items, "updated_at": updated_at if changed else None, } + if changed and not dry_run: + from .control_plane.coordination.local_authority_shadow_adapter import ( + observe_local_authority_commit, + ) + + shadow = observe_local_authority_commit( + registry_path=registry_path, + runtime_root=None, + goal_id=goal_id, + source_operation=( + f"todo_capture_followups:{recorded_count}:{updated_at}" + ), + ) + if shadow is not None: + result["authority_shadow"] = shadow + return result diff --git a/loopx/todos.py b/loopx/todos.py index baac692e9f..ed5ef7b5a8 100644 --- a/loopx/todos.py +++ b/loopx/todos.py @@ -136,6 +136,38 @@ ARCHIVE_COMPLETED_DEFAULT_MAX_ACTIVE_DONE = max(0, MAX_ACTIVE_DONE_TODOS_BEFORE_ARCHIVE - 2) +def _attach_local_authority_shadow( + payload: dict[str, Any], + *, + registry_path: Path, + goal_id: str, + write_class: str, +) -> dict[str, Any]: + """Observe a completed local mutation without changing its result.""" + + changed = any( + payload.get(field) + for field in ("changed", "added", "metadata_updated", "completed", "superseded") + ) + if payload.get("dry_run") or not changed: + return payload + from .control_plane.coordination.local_authority_shadow_adapter import ( + observe_local_authority_commit, + ) + + todo_id = str(payload.get("todo_id") or "none") + updated_at = str(payload.get("updated_at") or "unknown") + evidence = observe_local_authority_commit( + registry_path=registry_path, + runtime_root=None, + goal_id=goal_id, + source_operation=f"{write_class}:{todo_id}:{updated_at}", + ) + if evidence is not None: + payload["authority_shadow"] = evidence + return payload + + def require_registered_todo_excluded_agents( *, registry_path: Path, @@ -1164,12 +1196,18 @@ def add_goal_todo( "updated_at": updated_at if changed else None, **handoff_gate, } - return _attach_todo_write_correctness_dry_run_packet( + payload = _attach_todo_write_correctness_dry_run_packet( payload, goal_id=goal_id, write_class="todo_add", state_text=original, ) + return _attach_local_authority_shadow( + payload, + registry_path=registry_path, + goal_id=goal_id, + write_class="todo_add", + ) def resolve_todo_state( @@ -1622,12 +1660,18 @@ def update_goal_todo( payload["external_wait_transition"] = external_wait_transition if monitor_poll_transition is not None: payload["monitor_poll_transition"] = monitor_poll_transition - return _attach_todo_write_correctness_dry_run_packet( + payload = _attach_todo_write_correctness_dry_run_packet( payload, goal_id=goal_id, write_class=write_class, state_text=original, ) + return _attach_local_authority_shadow( + payload, + registry_path=registry_path, + goal_id=goal_id, + write_class=write_class, + ) def complete_goal_todo( @@ -1858,7 +1902,12 @@ def complete_goal_todo( task_lease_fence, committed=bool(event_result.get("changed")) and not dry_run, ) - return event_result + return _attach_local_authority_shadow( + event_result, + registry_path=registry_path, + goal_id=goal_id, + write_class="todo_complete_event_projection", + ) if not isinstance(completion_state, dict): raise RuntimeError( "TypeScript Todo completion transaction did not authorize a commit" @@ -2016,7 +2065,12 @@ def complete_goal_todo( if effective_decision_outcome: result["decision_outcome"] = effective_decision_outcome result["self_merged"] = effective_self_merged - return result + return _attach_local_authority_shadow( + result, + registry_path=registry_path, + goal_id=goal_id, + write_class="todo_complete", + ) def supersede_goal_todo( *, @@ -2211,7 +2265,7 @@ def supersede_goal_todo( if changed and not dry_run: resolved_state_file.write_text(new_text, encoding="utf-8") release_verified_task_lease_fence(task_lease_fence, committed=changed and not dry_run) - return { + result = { "ok": True, "dry_run": dry_run, "superseded": True, @@ -2225,6 +2279,12 @@ def supersede_goal_todo( "project": str(resolved_project) if resolved_project else None, "updated_at": updated_at if changed else None, } + return _attach_local_authority_shadow( + result, + registry_path=registry_path, + goal_id=goal_id, + write_class="todo_supersede", + ) def archive_completed_todos( @@ -2266,7 +2326,7 @@ def archive_completed_todos( if changed and not dry_run: resolved_state_file.write_text(new_text, encoding="utf-8") - return { + result = { "ok": True, "dry_run": dry_run, "goal_id": goal_id, @@ -2275,3 +2335,9 @@ def archive_completed_todos( "project": str(resolved_project) if resolved_project else None, "updated_at": updated_at if changed else None, } + return _attach_local_authority_shadow( + result, + registry_path=registry_path, + goal_id=goal_id, + write_class="todo_archive_completed", + ) diff --git a/pyproject.toml b/pyproject.toml index 2e0609f367..d5c94b1f9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ include = ["loopx*"] "loopx" = ["web/chat/*.html", "web/chat/assets/*", "web/chat/manifest.webmanifest", "web/chat/pwa/*"] "loopx.control_plane" = ["*.json", "*.ts"] "loopx.control_plane.agents" = ["*.ts"] +"loopx.control_plane.coordination" = ["*.ts"] "loopx.control_plane.goals" = ["*.ts"] "loopx.control_plane.quota" = ["*.ts"] "loopx.control_plane.scheduler" = ["*.ts"] diff --git a/tests/control_plane/test_local_authority_shadow_config.py b/tests/control_plane/test_local_authority_shadow_config.py new file mode 100644 index 0000000000..af217d8381 --- /dev/null +++ b/tests/control_plane/test_local_authority_shadow_config.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from loopx.cli import main +from loopx.configure_goal import configure_goal + + +GOAL_ID = "local-authority-shadow-config" + + +def _registry(tmp_path: Path) -> Path: + state = tmp_path / "ACTIVE_GOAL_STATE.md" + state.write_text( + "---\n" + f"goal_id: {GOAL_ID}\n" + "handoff_mode: hard_lease\n" + "---\n\n" + "## Agent Todo\n\n", + encoding="utf-8", + ) + registry = tmp_path / "registry.json" + registry.write_text( + json.dumps( + { + "goals": [ + { + "id": GOAL_ID, + "repo": str(tmp_path), + "state_file": state.name, + "coordination": { + "agent_model": "peer_v1", + "registered_agents": ["agent-a", "agent-b"], + }, + } + ] + } + ), + encoding="utf-8", + ) + return registry + + +def test_configure_goal_enables_and_clears_closed_file_shadow_config( + tmp_path: Path, +) -> None: + registry = _registry(tmp_path) + + preview = configure_goal( + registry_path=registry, + goal_id=GOAL_ID, + local_authority_shadow_file=True, + execute=False, + ) + + assert preview["changed_fields"] == ["local_authority_shadow"] + assert preview["before"]["local_authority_shadow"] == { + "enabled": False, + "mode": None, + "status": "disabled", + } + assert preview["after"]["local_authority_shadow"] == { + "enabled": True, + "mode": "file_one_way", + "status": "enabled", + } + + applied = configure_goal( + registry_path=registry, + goal_id=GOAL_ID, + local_authority_shadow_file=True, + execute=True, + ) + assert applied["written"] is True + goal = json.loads(registry.read_text(encoding="utf-8"))["goals"][0] + assert goal["coordination"]["authority_shadow"] == { + "schema_version": "loopx_local_authority_shadow_config_v0", + "mode": "file_one_way", + } + assert goal["coordination"]["agent_model"] == "peer_v1" + + repeated = configure_goal( + registry_path=registry, + goal_id=GOAL_ID, + local_authority_shadow_file=True, + execute=True, + ) + assert repeated["written"] is False + + cleared = configure_goal( + registry_path=registry, + goal_id=GOAL_ID, + clear_local_authority_shadow=True, + execute=True, + ) + assert cleared["changed_fields"] == ["local_authority_shadow"] + goal = json.loads(registry.read_text(encoding="utf-8"))["goals"][0] + assert "authority_shadow" not in goal["coordination"] + assert goal["coordination"]["registered_agents"] == ["agent-a", "agent-b"] + + +def test_configure_goal_rejects_enable_and_clear_in_one_operation( + tmp_path: Path, +) -> None: + with pytest.raises(ValueError, match="cannot be combined"): + configure_goal( + registry_path=_registry(tmp_path), + goal_id=GOAL_ID, + local_authority_shadow_file=True, + clear_local_authority_shadow=True, + ) + + +def test_configure_goal_cli_exposes_default_off_shadow_boundary( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + registry = _registry(tmp_path) + + exit_code = main( + [ + "--registry", + str(registry), + "--runtime-root", + str(tmp_path / "runtime"), + "--format", + "json", + "configure-goal", + "--goal-id", + GOAL_ID, + "--local-authority-shadow-file", + ] + ) + + assert exit_code == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["dry_run"] is True + assert payload["written"] is False + assert payload["after"]["local_authority_shadow"]["enabled"] is True + feature = next( + item + for item in payload["configuration_catalog"]["features"] + if item["feature_id"] == "local_authority_shadow" + ) + assert feature["availability"] == "qualification_opt_in" + assert feature["does_not"] == [ + "read the candidate for lifecycle decisions", + "write candidate state back into Markdown or task-lease files", + "promote shared authority or fence legacy writers", + ] + assert feature["commands"]["apply_disable"].endswith( + "--clear-local-authority-shadow --execute" + ) + assert "authority_shadow" not in json.loads( + registry.read_text(encoding="utf-8") + )["goals"][0]["coordination"] diff --git a/tests/control_plane/test_local_authority_shadow_runtime.py b/tests/control_plane/test_local_authority_shadow_runtime.py new file mode 100644 index 0000000000..c054f54921 --- /dev/null +++ b/tests/control_plane/test_local_authority_shadow_runtime.py @@ -0,0 +1,508 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from loopx.control_plane.coordination.local_authority_shadow_adapter import ( + LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA, + observe_local_authority_commit, +) +from loopx.control_plane.todos.handoff_mode import set_goal_handoff_mode +from loopx.control_plane.work_items.task_lease import ( + acquire_task_lease, + release_task_lease, + renew_task_lease, + transfer_task_lease, +) +from loopx.event_sourced_state import ( + TODO_ADDED, + AppendOnlyStateEventStore, + make_state_event, +) +from loopx.todo_followups import capture_followup_todos +from loopx.todos import ( + add_goal_todo, + archive_completed_todos, + complete_goal_todo, + supersede_goal_todo, + update_goal_todo, +) + + +GOAL_ID = "goal-shadow" +AGENT_A = "agent-a" +AGENT_B = "agent-b" + + +def _fixture(tmp_path: Path, *, enabled: bool) -> tuple[Path, Path, Path]: + repo = tmp_path / "repo" + repo.mkdir() + state = repo / "ACTIVE_GOAL_STATE.md" + state.write_text( + "---\n" + f"goal_id: {GOAL_ID}\n" + "handoff_mode: hard_lease\n" + "updated_at: 2026-09-02T00:00:00+00:00\n" + "---\n\n" + "## Agent Todo\n\n", + encoding="utf-8", + ) + runtime_root = tmp_path / "runtime" + coordination: dict[str, object] = { + "agent_model": "peer_v1", + "registered_agents": [AGENT_A, AGENT_B], + } + if enabled: + coordination["authority_shadow"] = { + "schema_version": "loopx_local_authority_shadow_config_v0", + "mode": "file_one_way", + } + registry = tmp_path / "registry.json" + registry.write_text( + json.dumps( + { + "common_runtime_root": str(runtime_root), + "goals": [ + { + "id": GOAL_ID, + "domain": "harness_self_improvement", + "status": "active", + "repo": str(repo), + "state_file": state.name, + "adapter": {"kind": "harness_self_improvement"}, + "coordination": coordination, + } + ], + } + ), + encoding="utf-8", + ) + return registry, state, runtime_root + + +def _add(registry: Path) -> dict: + return add_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + role="agent", + text="Exercise one-way local authority shadowing.", + task_class="advancement_task", + ) + + +def _shadow_document(runtime_root: Path) -> dict: + paths = list( + ( + runtime_root + / "authority-shadow" + / "file" + / GOAL_ID + ).glob("authority-store-*.json") + ) + assert len(paths) == 1 + return json.loads(paths[0].read_text(encoding="utf-8")) + + +def test_default_off_public_writers_never_call_shadow_runtime( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry, _state, runtime_root = _fixture(tmp_path, enabled=False) + calls: list[object] = [] + + def forbidden(*args: object, **kwargs: object) -> object: + calls.append((args, kwargs)) + raise AssertionError("default-off path constructed the shadow runtime") + + monkeypatch.setattr( + "loopx.control_plane.coordination.local_authority_shadow_adapter.effect_runtime_result", + forbidden, + ) + + result = _add(registry) + lease_result = acquire_task_lease( + registry_path=registry, + runtime_root=runtime_root, + goal_id=GOAL_ID, + todo_id=str(result["todo_id"]), + owner=AGENT_A, + idempotency_key="default-off", + ttl_seconds=120, + ) + + assert result["ok"] is True + assert lease_result["ok"] is True + assert "authority_shadow" not in result + assert "authority_shadow" not in lease_result + assert calls == [] + assert not (runtime_root / "authority-shadow").exists() + + +def test_enabled_todo_public_facades_emit_post_commit_evidence(tmp_path: Path) -> None: + registry, _state, runtime_root = _fixture(tmp_path, enabled=True) + + added = _add(registry) + todo_id = str(added["todo_id"]) + acquired = acquire_task_lease( + registry_path=registry, + runtime_root=runtime_root, + goal_id=GOAL_ID, + todo_id=todo_id, + owner=AGENT_A, + idempotency_key="todo-terminal-a", + ttl_seconds=120, + ) + updated = update_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=todo_id, + note="A public-safe update.", + agent_id=AGENT_A, + ) + completed = complete_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=todo_id, + role="agent", + no_followup=True, + agent_id=AGENT_A, + task_lease_idempotency_key="todo-terminal-a", + task_lease_expected_version=int(acquired["lease"]["version"]), + ) + archived = archive_completed_todos( + registry_path=registry, + goal_id=GOAL_ID, + role="agent", + max_active_done=0, + dry_run=False, + ) + replacement = _add(registry) + replacement_lease = acquire_task_lease( + registry_path=registry, + runtime_root=runtime_root, + goal_id=GOAL_ID, + todo_id=str(replacement["todo_id"]), + owner=AGENT_A, + idempotency_key="todo-terminal-b", + ttl_seconds=120, + ) + superseded = supersede_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=str(replacement["todo_id"]), + role="agent", + reason="Replace obsolete work.", + next_agent_todo="Carry the bounded work forward.", + agent_id=AGENT_A, + task_lease_idempotency_key="todo-terminal-b", + task_lease_expected_version=int(replacement_lease["lease"]["version"]), + ) + + for result in (added, updated, completed, archived, replacement, superseded): + assert result["ok"] is True + assert result["authority_shadow"]["schema_version"] == ( + LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA + ) + assert result["authority_shadow"]["primary_writeback_preserved"] is True + assert result["authority_shadow"]["provider_to_local_writes"] is False + + +def test_enabled_task_lease_facades_shadow_only_committed_mutations( + tmp_path: Path, +) -> None: + registry, _state, runtime_root = _fixture(tmp_path, enabled=True) + todo_id = str(_add(registry)["todo_id"]) + + acquired = acquire_task_lease( + registry_path=registry, + runtime_root=runtime_root, + goal_id=GOAL_ID, + todo_id=todo_id, + owner=AGENT_A, + idempotency_key="lease-a", + ttl_seconds=120, + ) + replayed_acquire = acquire_task_lease( + registry_path=registry, + runtime_root=runtime_root, + goal_id=GOAL_ID, + todo_id=todo_id, + owner=AGENT_A, + idempotency_key="lease-a", + ttl_seconds=120, + ) + renewed = renew_task_lease( + registry_path=registry, + runtime_root=runtime_root, + goal_id=GOAL_ID, + todo_id=todo_id, + owner=AGENT_A, + idempotency_key="lease-a", + expected_version=1, + ttl_seconds=120, + ) + transferred = transfer_task_lease( + registry_path=registry, + runtime_root=runtime_root, + goal_id=GOAL_ID, + todo_id=todo_id, + owner=AGENT_A, + idempotency_key="lease-a", + new_owner=AGENT_B, + new_idempotency_key="lease-b", + expected_version=2, + ttl_seconds=120, + ) + released = release_task_lease( + registry_path=registry, + runtime_root=runtime_root, + goal_id=GOAL_ID, + todo_id=todo_id, + owner=AGENT_B, + idempotency_key="lease-b", + expected_version=3, + ) + + for result in (acquired, renewed, transferred, released): + assert result["authority_shadow"]["outcome"] in { + "advanced", + "replayed", + "ambiguous_reconciled", + } + assert replayed_acquire["idempotent"] is True + assert "authority_shadow" not in replayed_acquire + + +def test_handoff_mode_and_direct_followup_writers_refresh_the_same_shadow( + tmp_path: Path, +) -> None: + registry, _state, runtime_root = _fixture(tmp_path, enabled=True) + + mode = set_goal_handoff_mode( + registry_path=registry, + goal_id=GOAL_ID, + mode="legacy", + ) + followups = capture_followup_todos( + registry_path=registry, + goal_id=GOAL_ID, + followups=["Verify the migrated authority projection."], + evidence="validation://local-shadow-followup", + ) + + assert mode["changed"] is True + assert mode["authority_shadow"]["outcome"] == "advanced" + assert followups["changed"] is True + assert followups["authority_shadow"]["outcome"] == "advanced" + head = _shadow_document(runtime_root)["head"] + assert head["handoff_mode"] == "legacy" + assert [todo["todo_id"] for todo in head["todos"]] == [ + followups["items"][0]["todo_id"] + ] + + +def test_event_projected_completion_refreshes_shadow_after_releasing_lease( + tmp_path: Path, +) -> None: + registry, state, runtime_root = _fixture(tmp_path, enabled=True) + todo_id = "todo_event_shadow" + AppendOnlyStateEventStore(state.with_name("events.jsonl")).append( + make_state_event( + event_id="evt-event-shadow-parent", + goal_id=GOAL_ID, + event_type=TODO_ADDED, + refs={"todo_id": todo_id}, + payload={ + "role": "agent", + "title": "Complete the event-projected shadow task.", + "task_class": "advancement_task", + "claimed_by": AGENT_A, + }, + recorded_at="2026-09-02T00:00:00+00:00", + ) + ) + lease_key = "event-shadow-instance" + acquire_task_lease( + registry_path=registry, + runtime_root=runtime_root, + goal_id=GOAL_ID, + todo_id=todo_id, + owner=AGENT_A, + idempotency_key=lease_key, + ttl_seconds=120, + ) + + completed = complete_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + todo_id=todo_id, + claimed_by=AGENT_A, + agent_id=AGENT_A, + task_lease_idempotency_key=lease_key, + task_lease_expected_version=1, + evidence="validation://event-shadow-completion", + no_followup=True, + ) + + assert completed["source"] == "event_log" + assert completed["changed"] is True + assert completed["authority_shadow"]["outcome"] == "advanced" + head = _shadow_document(runtime_root)["head"] + assert len(head["leases"]) == 1 + assert head["leases"][0]["todo_id"] == todo_id + assert head["leases"][0]["status"] == "released" + projected = next(todo for todo in head["todos"] if todo["todo_id"] == todo_id) + assert projected["status"] == "done" + + +def test_candidate_failure_never_changes_committed_todo_result( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry, state, _runtime_root = _fixture(tmp_path, enabled=True) + + def unavailable( + _method: str, + params: dict[str, object], + **_kwargs: object, + ) -> dict[str, object]: + return { + "schema_version": LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA, + "outcome": "unavailable", + "reason_code": "injected_outage", + "goal_id": params["goal_id"], + "operation_id": params["operation_id"], + "source_digest": params["source_digest"], + "primary_authority": "legacy_local", + "candidate_provider": "file", + "candidate_read_for_decision": False, + "provider_to_local_writes": False, + "primary_writeback_preserved": True, + "store_identity": None, + "provider_revision": None, + "cursor": None, + } + + monkeypatch.setattr( + "loopx.control_plane.coordination.local_authority_shadow_adapter.effect_runtime_result", + unavailable, + ) + + result = _add(registry) + + assert result["ok"] is True + assert result["added"] is True + assert result["authority_shadow"]["outcome"] == "unavailable" + assert str(result["todo_id"]) in state.read_text(encoding="utf-8") + + +def test_invalid_shadow_config_is_typed_but_preserves_primary_write( + tmp_path: Path, +) -> None: + registry, state, _runtime_root = _fixture(tmp_path, enabled=True) + payload = json.loads(registry.read_text(encoding="utf-8")) + payload["goals"][0]["coordination"]["authority_shadow"]["mode"] = "remote" + registry.write_text(json.dumps(payload), encoding="utf-8") + + result = _add(registry) + + assert result["ok"] is True + assert result["authority_shadow"]["outcome"] == "failed" + assert result["authority_shadow"]["reason_code"] == "invalid_shadow_config" + assert str(result["todo_id"]) in state.read_text(encoding="utf-8") + + +def test_observer_is_default_off_without_creating_lock_or_provider_directory( + tmp_path: Path, +) -> None: + registry, _state, runtime_root = _fixture(tmp_path, enabled=False) + + assert ( + observe_local_authority_commit( + registry_path=registry, + runtime_root=runtime_root, + goal_id=GOAL_ID, + source_operation="todo_update", + ) + is None + ) + assert not (runtime_root / "authority-shadow").exists() + + +def test_provider_revision_conflict_resamples_source_under_same_observation_lock( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry, _state, runtime_root = _fixture(tmp_path, enabled=True) + projections = iter( + ( + { + "schema_version": "loopx_local_authority_shadow_projection_v0", + "goal_id": GOAL_ID, + "handoff_mode": "hard_lease", + "todos": [{"todo_id": "todo_old", "status": "open"}], + "leases": [], + }, + { + "schema_version": "loopx_local_authority_shadow_projection_v0", + "goal_id": GOAL_ID, + "handoff_mode": "hard_lease", + "todos": [{"todo_id": "todo_fresh", "status": "open"}], + "leases": [], + }, + ) + ) + requests: list[dict[str, object]] = [] + + monkeypatch.setattr( + "loopx.control_plane.coordination.local_authority_shadow_adapter._stable_projection", + lambda **_kwargs: next(projections), + ) + + def conflict_then_advance( + _method: str, + params: dict[str, object], + **_kwargs: object, + ) -> dict[str, object]: + requests.append(dict(params)) + outcome = "conflict_retry_required" if len(requests) == 1 else "advanced" + return { + "schema_version": LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA, + "outcome": outcome, + "reason_code": ( + "provider_revision_mismatch" if len(requests) == 1 else None + ), + "goal_id": GOAL_ID, + "operation_id": params["operation_id"], + "source_digest": params["source_digest"], + "primary_authority": "legacy_local", + "candidate_provider": "file", + "candidate_read_for_decision": False, + "provider_to_local_writes": False, + "primary_writeback_preserved": True, + "store_identity": "file:test", + "provider_revision": "file:2:test", + "cursor": "2", + } + + monkeypatch.setattr( + "loopx.control_plane.coordination.local_authority_shadow_adapter.effect_runtime_result", + conflict_then_advance, + ) + + result = observe_local_authority_commit( + registry_path=registry, + runtime_root=runtime_root, + goal_id=GOAL_ID, + source_operation="todo_update:todo_a:now", + ) + + assert result is not None + assert result["outcome"] == "advanced" + assert len(requests) == 2 + assert requests[0]["source_digest"] != requests[1]["source_digest"] + assert requests[0]["operation_id"] != requests[1]["operation_id"] + assert all(request["runtime_root"] == str(runtime_root) for request in requests) + assert all("provider_directory" not in request for request in requests) diff --git a/tests/control_plane/test_state_migration_authority_shadow.py b/tests/control_plane/test_state_migration_authority_shadow.py new file mode 100644 index 0000000000..b3cfa84bf2 --- /dev/null +++ b/tests/control_plane/test_state_migration_authority_shadow.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import json +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from loopx.state_migration import migrate_legacy_state, render_state_migration_markdown + + +OLD_GOAL_ID = "legacy-goal" +NEW_GOAL_ID = "migrated-goal" +OLD_STORE_IDENTITY = "file:11111111111111111111111111111111" + + +def _migration_fixture(tmp_path: Path) -> dict[str, Path]: + legacy_runtime = tmp_path / "legacy-runtime" + target_runtime = tmp_path / "target-runtime" + source_repo = tmp_path / "legacy-repo" + target_repo = tmp_path / "target-repo" + source_repo.mkdir() + target_repo.mkdir() + + source_state = source_repo / "ACTIVE_GOAL_STATE.md" + source_state.write_text( + "---\n" + f"goal_id: {OLD_GOAL_ID}\n" + "handoff_mode: soft_claim\n" + "updated_at: 2026-09-02T00:00:00+10:00\n" + "---\n\n" + "## Agent Todo\n\n" + "- [ ] Preserve the new local authority only.\n", + encoding="utf-8", + ) + + legacy_registry = tmp_path / "legacy-registry.json" + legacy_registry.write_text( + json.dumps( + { + "schema_version": "0.1", + "common_runtime_root": str(legacy_runtime), + "goals": [ + { + "id": OLD_GOAL_ID, + "status": "active", + "repo": str(source_repo), + "state_file": source_state.name, + "coordination": { + "agent_model": "peer_v1", + "registered_agents": ["agent-a", "agent-b"], + "authority_shadow": { + "schema_version": ( + "loopx_local_authority_shadow_config_v0" + ), + "mode": "file_one_way", + }, + }, + } + ], + } + ), + encoding="utf-8", + ) + + source_goal_runtime = legacy_runtime / "goals" / OLD_GOAL_ID + (source_goal_runtime / "task-leases").mkdir(parents=True) + (source_goal_runtime / "task-leases" / "safe-local.json").write_text( + json.dumps( + { + "goal_id": OLD_GOAL_ID, + "todo_id": "safe-local", + "owner": "agent-a", + "version": 1, + "lease_epoch": 1, + "status": "released", + } + ), + encoding="utf-8", + ) + source_shadow_store = ( + legacy_runtime / "authority-shadow" / "file" / OLD_GOAL_ID + ) + source_shadow_store.mkdir(parents=True) + (source_shadow_store / "store-identity").write_text( + OLD_STORE_IDENTITY, + encoding="utf-8", + ) + (source_shadow_store / "authority-store-legacy.json").write_text( + json.dumps( + { + "goal_id": OLD_GOAL_ID, + "store_identity": OLD_STORE_IDENTITY, + "provider_revision": "file:99:legacy-lineage", + "cursor": "99", + "private_provider_byte": "must-never-migrate", + "source_path": str(source_repo), + } + ), + encoding="utf-8", + ) + + return { + "legacy_registry": legacy_registry, + "target_registry": tmp_path / "target-registry.json", + "legacy_runtime": legacy_runtime, + "target_runtime": target_runtime, + "source_repo": source_repo, + "target_repo": target_repo, + } + + +def _migrate(paths: dict[str, Path], *, execute: bool) -> dict[str, object]: + return migrate_legacy_state( + legacy_registry_path=paths["legacy_registry"], + target_registry_path=paths["target_registry"], + legacy_runtime_root=paths["legacy_runtime"], + target_runtime_root=paths["target_runtime"], + goal_ids=[OLD_GOAL_ID], + goal_id_map={OLD_GOAL_ID: NEW_GOAL_ID}, + path_map={str(paths["source_repo"]): str(paths["target_repo"])}, + copy_active_state=True, + copy_runtime=True, + execute=execute, + ) + + +def test_execute_excludes_old_shadow_and_seeds_fresh_target_lineage( + tmp_path: Path, +) -> None: + paths = _migration_fixture(tmp_path) + + result = _migrate(paths, execute=True) + + assert result["ok"] is True + runtime_result = result["runtime_goals"][0] # type: ignore[index] + assert runtime_result["copied"] is True + + target_goal_runtime = paths["target_runtime"] / "goals" / NEW_GOAL_ID + copied_lease = json.loads( + (target_goal_runtime / "task-leases" / "safe-local.json").read_text( + encoding="utf-8" + ) + ) + assert copied_lease["goal_id"] == NEW_GOAL_ID + + target_store_dir = ( + paths["target_runtime"] / "authority-shadow" / "file" / NEW_GOAL_ID + ) + target_identity = (target_store_dir / "store-identity").read_text(encoding="utf-8") + assert target_identity.startswith("file:") + assert target_identity != OLD_STORE_IDENTITY + + store_paths = list(target_store_dir.glob("authority-store-*.json")) + assert len(store_paths) == 1 + store_payload = json.loads(store_paths[0].read_text(encoding="utf-8")) + assert store_payload["goal_id"] == NEW_GOAL_ID + assert store_payload["store_identity"] == target_identity + assert store_payload["cursor"] == "1" + assert len(store_payload["committed"]) == 1 + serialized_store = json.dumps(store_payload, sort_keys=True) + assert OLD_STORE_IDENTITY not in serialized_store + assert "file:99:legacy-lineage" not in serialized_store + assert str(paths["source_repo"]) not in serialized_store + assert "must-never-migrate" not in serialized_store + + seed = result["authority_shadow_seeds"][0] # type: ignore[index] + assert seed["goal_id"] == NEW_GOAL_ID + assert seed["attempted"] is True + assert seed["outcome"] == "advanced" + + +def test_dry_run_reports_exclusion_and_seed_plan_without_writing( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + paths = _migration_fixture(tmp_path) + sentinel = b'{"schema_version":"existing","goals":[]}\n' + paths["target_registry"].write_bytes(sentinel) + + def forbidden_observer(**_kwargs: object) -> object: + raise AssertionError("dry-run called the shadow observer") + + original_rglob = Path.rglob + + def guarded_rglob(path: Path, pattern: str) -> Iterator[Path]: + if path.name == "authority-shadow": + raise AssertionError("migration entered candidate-provider storage") + return original_rglob(path, pattern) + + monkeypatch.setattr( + "loopx.control_plane.coordination.local_authority_shadow_adapter." + "observe_local_authority_commit", + forbidden_observer, + ) + monkeypatch.setattr(Path, "rglob", guarded_rglob) + + result = _migrate(paths, execute=False) + + assert result["ok"] is True + assert result["dry_run"] is True + assert paths["target_registry"].read_bytes() == sentinel + assert not paths["target_runtime"].exists() + runtime_result = result["runtime_goals"][0] # type: ignore[index] + assert runtime_result["copied_file_count"] == 0 + seed = result["authority_shadow_seeds"][0] # type: ignore[index] + assert seed == { + "schema_version": "loopx_state_migration_shadow_seed_evidence_v0", + "goal_id": NEW_GOAL_ID, + "attempted": False, + "outcome": "planned", + "reason_code": None, + } + rendered = render_state_migration_markdown(result) + assert "Authority Shadow Seeds" in rendered + assert "outcome=`planned`" in rendered + assert "must-never-migrate" not in json.dumps(result, sort_keys=True) + assert "must-never-migrate" not in rendered + + +def test_seed_failure_is_public_safe_evidence_and_does_not_reverse_migration( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + paths = _migration_fixture(tmp_path) + + def fail_observer(**_kwargs: object) -> object: + raise RuntimeError("credential=private-provider-value") + + monkeypatch.setattr( + "loopx.control_plane.coordination.local_authority_shadow_adapter." + "observe_local_authority_commit", + fail_observer, + ) + + result = _migrate(paths, execute=True) + + assert result["ok"] is True + assert result["wrote_project_registry"] is True + migrated_registry = json.loads(paths["target_registry"].read_text(encoding="utf-8")) + assert migrated_registry["goals"][0]["id"] == NEW_GOAL_ID + assert ( + paths["target_runtime"] + / "goals" + / NEW_GOAL_ID + / "task-leases" + / "safe-local.json" + ).exists() + + seed = result["authority_shadow_seeds"][0] # type: ignore[index] + assert seed["outcome"] == "failed" + assert seed["reason_code"] == "post_migration_shadow_seed_failed" + assert seed["attempted"] is True + assert "credential" not in json.dumps(result, sort_keys=True) + assert "private-provider-value" not in json.dumps(result, sort_keys=True) + assert not ( + paths["target_runtime"] + / "authority-shadow" + / "file" + / NEW_GOAL_ID + / "authority-store-legacy.json" + ).exists() diff --git a/tests/control_plane_ts/local_authority_shadow.test.ts b/tests/control_plane_ts/local_authority_shadow.test.ts new file mode 100644 index 0000000000..d1499ee58b --- /dev/null +++ b/tests/control_plane_ts/local_authority_shadow.test.ts @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import type { + AuthorityStore, + AuthorityStoreCommit, + AuthorityStoreCommitResult, + AuthorityStoreIdentityResult, + AuthorityStoreLoadResult, + AuthorityStoreReceiptResult, + AuthorityStoreScanResult, +} from "../../loopx/control_plane/coordination/authority_store.ts"; +import { FileAuthorityStore } from "../../loopx/control_plane/coordination/file_authority_store.ts"; +import { + LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA, + recordLocalAuthorityShadow, +} from "../../loopx/control_plane/coordination/local_authority_shadow.ts"; + +function request(directory: string, operationId = "local-operation-a") { + return { + schema_version: "loopx_local_authority_shadow_request_v0", + mode: "file_one_way", + runtime_root: directory, + goal_id: "goal-a", + operation_id: operationId, + source_operation: "todo_update", + source_digest: `sha256:${"a".repeat(64)}`, + source_projection: { + schema_version: "loopx_local_authority_shadow_projection_v0", + goal_id: "goal-a", + handoff_mode: "hard_lease", + todos: [{ todo_id: "todo-a", status: "open", claimed_by: "agent-a" }], + leases: [{ todo_id: "todo-a", version: 2, lease_epoch: 1, status: "active" }], + }, + }; +} + +test("one-way file shadow commits an observation without becoming decision authority", async (t) => { + const root = await mkdtemp(join(tmpdir(), "loopx-local-authority-shadow-")); + t.after(() => rm(root, { recursive: true, force: true })); + + const evidence = await recordLocalAuthorityShadow(request(root)); + + assert.equal(evidence.schema_version, LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA); + assert.equal(evidence.outcome, "advanced"); + assert.equal(evidence.primary_authority, "legacy_local"); + assert.equal(evidence.candidate_read_for_decision, false); + assert.equal(evidence.provider_to_local_writes, false); + assert.equal(evidence.primary_writeback_preserved, true); + const loaded = await new FileAuthorityStore( + join(root, "authority-shadow", "file", "goal-a"), + "goal-a", + ).loadAuthority(); + assert.equal(loaded.status, "loaded"); + if (loaded.status === "loaded") { + assert.deepEqual(loaded.head, request(root).source_projection); + } +}); + +test("same operation replays its typed observation receipt", async (t) => { + const root = await mkdtemp(join(tmpdir(), "loopx-local-authority-shadow-")); + t.after(() => rm(root, { recursive: true, force: true })); + + assert.equal((await recordLocalAuthorityShadow(request(root))).outcome, "advanced"); + const replay = await recordLocalAuthorityShadow(request(root)); + + assert.equal(replay.outcome, "replayed"); + const page = await new FileAuthorityStore( + join(root, "authority-shadow", "file", "goal-a"), + "goal-a", + ).scanCommitted(null, 10); + assert.equal(page.status, "page"); + if (page.status === "page") assert.equal(page.transactions.length, 1); +}); + +class UnavailableStore implements AuthorityStore { + commits = 0; + + async storeIdentity(): Promise { + return { status: "unavailable", reason_code: "injected", reason: "offline" }; + } + async loadAuthority(): Promise { + return { status: "unavailable", reason_code: "injected", reason: "offline" }; + } + async commitAuthority(_commit: AuthorityStoreCommit): Promise { + this.commits += 1; + return { status: "failed", reason_code: "unexpected", reason: "must not commit" }; + } + async readReceipt(_operationId: string): Promise { + return { status: "missing" }; + } + async scanCommitted(_afterCursor: string | null, _limit: number): Promise { + return { status: "page", transactions: [], next_cursor: null, has_more: false }; + } +} + +test("candidate unavailability is typed evidence and attempts no commit", async () => { + const store = new UnavailableStore(); + const evidence = await recordLocalAuthorityShadow(request("/not-used"), { + openStore: () => store, + }); + + assert.equal(evidence.outcome, "unavailable"); + assert.equal(evidence.reason_code, "injected"); + assert.equal(evidence.primary_writeback_preserved, true); + assert.equal(store.commits, 0); +}); + +class RevisionConflictStore extends UnavailableStore { + override async storeIdentity(): Promise { + return { status: "available", store_identity: "file:test" }; + } + override async loadAuthority(): Promise { + return { + status: "loaded", + head: {}, + provider_revision: "provider-revision-a", + cursor: "1", + }; + } + override async commitAuthority( + _commit: AuthorityStoreCommit, + ): Promise { + this.commits += 1; + return { + status: "conflict", + conflict_kind: "provider_revision_mismatch", + current_provider_revision: "provider-revision-b", + current_cursor: "2", + }; + } +} + +test("provider revision conflict requests a fresh source projection without stale retry", async () => { + const store = new RevisionConflictStore(); + + const result = await recordLocalAuthorityShadow(request("/not-used"), { + openStore: () => store, + }); + + assert.equal(result.outcome, "conflict_retry_required"); + assert.equal(result.reason_code, "provider_revision_mismatch"); + assert.equal(store.commits, 1); +}); + +test("goal id cannot escape the fixed shadow directory", async () => { + await assert.rejects( + recordLocalAuthorityShadow({ + ...request("/not-used"), + goal_id: "../other-goal", + source_projection: { + ...request("/not-used").source_projection, + goal_id: "../other-goal", + }, + }), + /single path segment/u, + ); +}); + +test("ambiguous response is success only when the exact receipt is readable", async () => { + const root = await mkdtemp(join(tmpdir(), "loopx-local-authority-shadow-")); + tCleanup(root); + class AfterCommitStore extends FileAuthorityStore { + protected override async replaceDurably(path: string, payload: Uint8Array): Promise { + await super.replaceDurably(path, payload); + if (path === this.path) throw new Error("lost response after durable replace"); + } + } + const recovered = await recordLocalAuthorityShadow(request(root), { + openStore: (directory, goalId) => new AfterCommitStore(directory, goalId), + }); + assert.equal(recovered.outcome, "ambiguous_reconciled"); + + const beforeRoot = await mkdtemp(join(tmpdir(), "loopx-local-authority-shadow-")); + tCleanup(beforeRoot); + class BeforeCommitStore extends FileAuthorityStore { + protected override async replaceDurably(path: string, _payload: Uint8Array): Promise { + if (path === this.path) throw new Error("failed before durable replace"); + return await super.replaceDurably(path, _payload); + } + } + const unproved = await recordLocalAuthorityShadow(request(beforeRoot), { + openStore: (directory, goalId) => new BeforeCommitStore(directory, goalId), + }); + assert.equal(unproved.outcome, "ambiguous_unproved"); +}); + +const cleanupRoots: string[] = []; +function tCleanup(root: string): void { + cleanupRoots.push(root); +} +test.after(async () => { + await Promise.all(cleanupRoots.map((root) => rm(root, { recursive: true, force: true }))); +}); diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index 261938cc8e..f914f5c7bb 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -23,6 +23,7 @@ "loopx/control_plane/coordination/file_authority_store.ts", "loopx/control_plane/coordination/nokv_authority_store.ts", "loopx/control_plane/coordination/nokv_jsonl_transport.ts", + "loopx/control_plane/coordination/local_authority_shadow.ts", "loopx/control_plane/coordination/postgresql_authority_store.ts", "loopx/control_plane/agents/delivery_workspace.ts", "loopx/control_plane/goals/vision_checkpoint.ts", @@ -57,6 +58,7 @@ "tests/control_plane_ts/interaction_contract.test.ts", "tests/control_plane_ts/runtime_decode.test.ts", "tests/control_plane_ts/authority_store.test.ts", + "tests/control_plane_ts/local_authority_shadow.test.ts", "tests/control_plane_ts/authority_store_conformance.ts", "tests/control_plane_ts/nokv_authority_store.test.ts", "tests/control_plane_ts/nokv_jsonl_transport.test.ts", From ab35236bdd6e12c47b2c4ebaea6161c0ea78c7d7 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Wed, 2 Sep 2026 07:42:47 +1000 Subject: [PATCH 2/8] docs(authority): document local shadow boundary Signed-off-by: wchwawa --- ...shared-goal-authority-state-provider-v0.md | 27 +++++++++++++++++++ ...-goal-authority-state-provider-v0.zh-CN.md | 23 ++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index 6ff24b079d..0c904f1d01 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -1079,6 +1079,33 @@ The sequence is: cache, offline projection, and diagnostic material. Never keep a long-lived dual-write or dual-master mode. +#### Stage 2C qualification slice: local post-commit shadow + +The first half of Stage 2C is an explicit, default-off product path. Preview +and enable it with: + +```bash +loopx configure-goal --goal-id GOAL --local-authority-shadow-file +loopx configure-goal --goal-id GOAL --local-authority-shadow-file --execute +``` + +Todo, handoff-mode, follow-up, and task-lease facades observe their committed +local result through `FileAuthorityStore`. Candidate bytes live under +`authority-shadow/file/` outside the legacy per-Goal runtime tree, so state +migration never copies a store identity or revision; an executed migration +seeds a new target lineage from the migrated local state. Candidate failure is +reported as evidence but never reverses the completed local write. + +Disable the observer in one command with +`loopx configure-goal --goal-id GOAL --clear-local-authority-shadow --execute`. +This is rollback of observation only: the local Markdown and task-lease files +remain canonical throughout. The slice does not read the candidate for a +decision, fence a legacy writer, qualify a remote provider, or complete the +second Stage 2C promotion. A process crash after the local commit but before +the observer call may miss that individual observation; a later committed +write or migration seed refreshes the full current projection, but no durable +shadow outbox is claimed here. + ### Implementation prerequisite: put local file mode behind the same coordination contract Before wiring a live NoKV or another remote provider, the runtime should first diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index 4de2427c88..c9198e129d 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -867,6 +867,29 @@ Stage 3/4 qualification 必须保持以下 ownership 与 proof 边界: LoopX service 成为唯一 writer。本地 `.loopx` 退为 cache、offline projection 与 诊断材料。绝不长期维持 dual-write 或 dual-master。 +#### Stage 2C 资格验证切片:本地提交后 shadow + +Stage 2C 的前半段是一个显式开启、默认关闭的产品路径。先预览,再开启: + +```bash +loopx configure-goal --goal-id GOAL --local-authority-shadow-file +loopx configure-goal --goal-id GOAL --local-authority-shadow-file --execute +``` + +Todo、handoff-mode、follow-up 与 task-lease facade 会在本地主写成功后,通过 +`FileAuthorityStore` 观察完整当前投影。候选数据位于 legacy 单 Goal runtime tree +之外的 `authority-shadow/file/`,因此 state migration 不会复制 store identity 或 +revision;真正执行迁移时,会从迁移后的本地主状态为目标端建立一条新 lineage。 +候选失败只形成 evidence,不会推翻已经完成的本地写入。 + +用 +`loopx configure-goal --goal-id GOAL --clear-local-authority-shadow --execute` +即可关闭 observer。这里回退的只是观察路径:Markdown 与 task-lease 文件始终是 +canonical。本切片不会读取候选来决策,不会 fence legacy writer,不会资格化远端 +provider,也没有完成 Stage 2C 后半段的本地 canonical promotion。若进程恰好在本地 +提交后、observer 调用前崩溃,该次 observation 可能丢失;后续成功写入或 migration +seed 会刷新完整当前投影,但这里不宣称已有 durable shadow outbox。 + ### 实施前置条件:先让本地文件模式经过同一协调合同 在接入 live NoKV 或其他远端 provider 之前,runtime 应先把当前 todo/lease 写路径中的 From c143ec16977af4525346606c3f0f4321cd7d7206 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Wed, 2 Sep 2026 08:00:24 +1000 Subject: [PATCH 3/8] refactor(authority): keep shadow wiring within coordination Signed-off-by: wchwawa --- loopx/configure_goal.py | 87 ++++------------- .../coordination/configuration.py | 25 +++++ .../local_authority_shadow_adapter.py | 94 +++++++++++++++++++ loopx/todos.py | 78 ++------------- .../test_coordination_configuration.py | 11 +++ 5 files changed, 157 insertions(+), 138 deletions(-) create mode 100644 loopx/control_plane/coordination/configuration.py create mode 100644 tests/control_plane/test_coordination_configuration.py diff --git a/loopx/configure_goal.py b/loopx/configure_goal.py index c9eb0084bd..f71c2c7b62 100644 --- a/loopx/configure_goal.py +++ b/loopx/configure_goal.py @@ -34,6 +34,8 @@ ) from .control_plane.agents.supervisor import normalize_peer_supervisor from .control_plane.agents.work_mode import normalize_agent_work_modes +from .control_plane.coordination import local_authority_shadow_adapter as shadow +from .control_plane.coordination.configuration import normalize_goal_write_scope from .control_plane.operator_inbox_binding import local_private_config_digest from .control_plane.reward_memory import ( reward_memory_goal_policy, @@ -70,9 +72,6 @@ MULTI_SUBAGENT_FEATURE_CHOICES = ("off", "enabled") DEFAULT_MULTI_SUBAGENT_MAX_CHILDREN = 2 AGENT_MODEL_CHOICES = tuple(model.value for model in AgentRuntimeModel) -LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA_VERSION = ( - "loopx_local_authority_shadow_config_v0" -) def _control_plane(goal: dict[str, Any]) -> dict[str, Any]: @@ -224,41 +223,6 @@ def _clean_registered_agents(values: list[str] | None) -> list[str] | None: return agents -def _clean_write_scope(values: list[str] | None) -> list[str] | None: - if values is None: - return None - scopes: list[str] = [] - for value in values: - for part in str(value).split(","): - scope = part.strip() - if scope and scope not in scopes: - scopes.append(scope) - return scopes - - -def _local_authority_shadow_summary(goal: Mapping[str, Any]) -> dict[str, Any]: - coordination = ( - goal.get("coordination") - if isinstance(goal.get("coordination"), Mapping) - else {} - ) - raw = coordination.get("authority_shadow") - if raw is None: - return {"enabled": False, "mode": None, "status": "disabled"} - valid = bool( - isinstance(raw, Mapping) - and set(raw) == {"schema_version", "mode"} - and raw.get("schema_version") - == LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA_VERSION - and raw.get("mode") == "file_one_way" - ) - return { - "enabled": valid, - "mode": raw.get("mode") if isinstance(raw, Mapping) else None, - "status": "enabled" if valid else "invalid", - } - - def _settings_summary(goal: dict[str, Any]) -> dict[str, Any]: quota = goal_quota_config(goal) control_plane = compact_control_plane_policy(goal.get("control_plane")) @@ -285,8 +249,9 @@ def _settings_summary(goal: dict[str, Any]) -> dict[str, Any]: "explore_graph": compact_explore_graph_policy(goal.get("explore_graph")), "orchestration": orchestration, "waiting_on": goal.get("waiting_on"), - "write_scope": _clean_write_scope(coordination.get("write_scope") or []) or [], - "local_authority_shadow": _local_authority_shadow_summary(goal), + "write_scope": normalize_goal_write_scope(coordination.get("write_scope") or []) + or [], + "local_authority_shadow": shadow.local_authority_shadow_summary(goal), "checkpointed_boundary_authority": checkpointed_boundary_authority_summary( coordination ), @@ -564,11 +529,9 @@ def configure_goal( raise ValueError( "--clear-write-scope cannot be combined with --replace-write-scope" ) - if local_authority_shadow_file and clear_local_authority_shadow: - raise ValueError( - "--local-authority-shadow-file cannot be combined with " - "--clear-local-authority-shadow" - ) + shadow.validate_local_authority_shadow_change( + local_authority_shadow_file, clear_local_authority_shadow + ) if clear_waiting_on and waiting_on: raise ValueError("--clear-waiting-on cannot be combined with --waiting-on") adding_boundary_authority = any( @@ -668,7 +631,7 @@ def configure_goal( clear_todo_lifecycle_authority ) supervised_agents = _clean_registered_agents(supervised_agents) - write_scope = _clean_write_scope(write_scope) + write_scope = normalize_goal_write_scope(write_scope) issue_fix_reviewer_notification_config = _local_private_config_path( issue_fix_reviewer_notification_config, label="reviewer notification config", @@ -1252,10 +1215,12 @@ def configure_goal( coordination["write_scope"] = write_scope else: existing_write_scope = ( - _clean_write_scope(coordination.get("write_scope") or []) or [] + normalize_goal_write_scope(coordination.get("write_scope") or []) + or [] ) coordination["write_scope"] = ( - _clean_write_scope([*existing_write_scope, *write_scope]) or [] + normalize_goal_write_scope([*existing_write_scope, *write_scope]) + or [] ) if clear_boundary_authority: coordination.pop("checkpointed_boundary_authority", None) @@ -1275,24 +1240,9 @@ def configure_goal( coordination["checkpointed_boundary_authority"] = [*entries, entry] goal["coordination"] = coordination - if local_authority_shadow_file or clear_local_authority_shadow: - coordination = ( - goal.get("coordination") - if isinstance(goal.get("coordination"), dict) - else {} - ) - if clear_local_authority_shadow: - coordination.pop("authority_shadow", None) - else: - coordination["authority_shadow"] = { - "schema_version": LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA_VERSION, - "mode": "file_one_way", - } - if coordination: - goal["coordination"] = coordination - else: - goal.pop("coordination", None) - + shadow.apply_local_authority_shadow_change( + goal, local_authority_shadow_file, clear_local_authority_shadow + ) after = _settings_summary(goal) changed_fields = _changed_fields(before, after) if goal != before_goal and not changed_fields: @@ -1331,10 +1281,7 @@ def configure_goal( "peer_task_coordination": deepcopy( after.get("peer_task_coordination") or {"enabled": False} ), - "local_authority_shadow": deepcopy( - after.get("local_authority_shadow") - or {"enabled": False, "mode": None, "status": "disabled"} - ), + "local_authority_shadow": deepcopy(after["local_authority_shadow"]), "lark_event_inbox": _lark_event_inbox_config_summary(goal), "lark_kanban_heartbeat_sync": _lark_kanban_heartbeat_config_summary(goal), "reward_memory": reward_memory_goal_policy_summary(goal), diff --git a/loopx/control_plane/coordination/configuration.py b/loopx/control_plane/coordination/configuration.py new file mode 100644 index 0000000000..5a5e814966 --- /dev/null +++ b/loopx/control_plane/coordination/configuration.py @@ -0,0 +1,25 @@ +"""Normalization rules for goal-level coordination configuration.""" + +from __future__ import annotations + + +def normalize_goal_write_scope(values: list[str] | None) -> list[str] | None: + """Split comma-delimited goal scopes and preserve first-seen order. + + Goal configuration intentionally accepts a wider token vocabulary than a + Todo's ``required_write_scopes`` contract, so its stricter normalizer is + not interchangeable with this rule. + """ + + if values is None: + return None + scopes: list[str] = [] + for value in values: + for part in str(value).split(","): + scope = part.strip() + if scope and scope not in scopes: + scopes.append(scope) + return scopes + + +__all__ = ["normalize_goal_write_scope"] diff --git a/loopx/control_plane/coordination/local_authority_shadow_adapter.py b/loopx/control_plane/coordination/local_authority_shadow_adapter.py index c977cc7bf5..d729f0075f 100644 --- a/loopx/control_plane/coordination/local_authority_shadow_adapter.py +++ b/loopx/control_plane/coordination/local_authority_shadow_adapter.py @@ -10,6 +10,7 @@ import hashlib import json +from collections.abc import Mapping from pathlib import Path from typing import Any @@ -71,6 +72,68 @@ ) +def local_authority_shadow_summary(goal: Mapping[str, Any]) -> dict[str, Any]: + """Project the closed local-shadow configuration for operator readback.""" + + coordination = ( + goal.get("coordination") + if isinstance(goal.get("coordination"), Mapping) + else {} + ) + raw = coordination.get("authority_shadow") + if raw is None: + return {"enabled": False, "mode": None, "status": "disabled"} + valid = bool( + isinstance(raw, Mapping) + and set(raw) == _CONFIG_FIELDS + and raw.get("schema_version") == LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA + and raw.get("mode") == "file_one_way" + ) + return { + "enabled": valid, + "mode": raw.get("mode") if isinstance(raw, Mapping) else None, + "status": "enabled" if valid else "invalid", + } + + +def validate_local_authority_shadow_change( + enable_file: bool, + clear: bool, +) -> None: + """Reject contradictory CLI intent before reading or mutating the registry.""" + + if enable_file and clear: + raise ValueError( + "--local-authority-shadow-file cannot be combined with " + "--clear-local-authority-shadow" + ) + + +def apply_local_authority_shadow_change( + goal: dict[str, Any], + enable_file: bool, + clear: bool, +) -> None: + """Apply a validated default-off local-shadow configuration change.""" + + if not enable_file and not clear: + return + coordination = ( + goal.get("coordination") if isinstance(goal.get("coordination"), dict) else {} + ) + if clear: + coordination.pop("authority_shadow", None) + else: + coordination["authority_shadow"] = { + "schema_version": LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA, + "mode": "file_one_way", + } + if coordination: + goal["coordination"] = coordination + else: + goal.pop("coordination", None) + + def _base_evidence( *, goal_id: str, @@ -351,8 +414,39 @@ def observe_local_authority_commit( ) +def observe_todo_local_authority_commit( + payload: dict[str, Any], + registry_path: Path, + goal_id: str, + write_class: str, +) -> dict[str, Any]: + """Attach post-commit shadow evidence without changing the Todo verdict.""" + + changed = any( + payload.get(field) + for field in ("changed", "added", "metadata_updated", "completed", "superseded") + ) + if payload.get("dry_run") or not changed: + return payload + todo_id = str(payload.get("todo_id") or "none") + updated_at = str(payload.get("updated_at") or "unknown") + evidence = observe_local_authority_commit( + registry_path=registry_path, + runtime_root=None, + goal_id=goal_id, + source_operation=f"{write_class}:{todo_id}:{updated_at}", + ) + if evidence is not None: + payload["authority_shadow"] = evidence + return payload + + __all__ = [ "LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA", "LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA", + "apply_local_authority_shadow_change", + "local_authority_shadow_summary", "observe_local_authority_commit", + "observe_todo_local_authority_commit", + "validate_local_authority_shadow_change", ] diff --git a/loopx/todos.py b/loopx/todos.py index ed5ef7b5a8..ce0de7db1b 100644 --- a/loopx/todos.py +++ b/loopx/todos.py @@ -126,6 +126,9 @@ enter_todo_ownership_handoff_gate, resolve_todo_completion_handoff, ) +from .control_plane.coordination.local_authority_shadow_adapter import ( + observe_todo_local_authority_commit as _shadow_todo, +) from .control_plane.work_items.task_lease import ( enter_terminal_todo_lease_fence, hold_task_lease_mutation_fence, @@ -136,38 +139,6 @@ ARCHIVE_COMPLETED_DEFAULT_MAX_ACTIVE_DONE = max(0, MAX_ACTIVE_DONE_TODOS_BEFORE_ARCHIVE - 2) -def _attach_local_authority_shadow( - payload: dict[str, Any], - *, - registry_path: Path, - goal_id: str, - write_class: str, -) -> dict[str, Any]: - """Observe a completed local mutation without changing its result.""" - - changed = any( - payload.get(field) - for field in ("changed", "added", "metadata_updated", "completed", "superseded") - ) - if payload.get("dry_run") or not changed: - return payload - from .control_plane.coordination.local_authority_shadow_adapter import ( - observe_local_authority_commit, - ) - - todo_id = str(payload.get("todo_id") or "none") - updated_at = str(payload.get("updated_at") or "unknown") - evidence = observe_local_authority_commit( - registry_path=registry_path, - runtime_root=None, - goal_id=goal_id, - source_operation=f"{write_class}:{todo_id}:{updated_at}", - ) - if evidence is not None: - payload["authority_shadow"] = evidence - return payload - - def require_registered_todo_excluded_agents( *, registry_path: Path, @@ -1202,12 +1173,7 @@ def add_goal_todo( write_class="todo_add", state_text=original, ) - return _attach_local_authority_shadow( - payload, - registry_path=registry_path, - goal_id=goal_id, - write_class="todo_add", - ) + return _shadow_todo(payload, registry_path, goal_id, "todo_add") def resolve_todo_state( @@ -1666,12 +1632,7 @@ def update_goal_todo( write_class=write_class, state_text=original, ) - return _attach_local_authority_shadow( - payload, - registry_path=registry_path, - goal_id=goal_id, - write_class=write_class, - ) + return _shadow_todo(payload, registry_path, goal_id, write_class) def complete_goal_todo( @@ -1902,12 +1863,8 @@ def complete_goal_todo( task_lease_fence, committed=bool(event_result.get("changed")) and not dry_run, ) - return _attach_local_authority_shadow( - event_result, - registry_path=registry_path, - goal_id=goal_id, - write_class="todo_complete_event_projection", - ) + write_class = "todo_complete_event_projection" + return _shadow_todo(event_result, registry_path, goal_id, write_class) if not isinstance(completion_state, dict): raise RuntimeError( "TypeScript Todo completion transaction did not authorize a commit" @@ -2065,12 +2022,7 @@ def complete_goal_todo( if effective_decision_outcome: result["decision_outcome"] = effective_decision_outcome result["self_merged"] = effective_self_merged - return _attach_local_authority_shadow( - result, - registry_path=registry_path, - goal_id=goal_id, - write_class="todo_complete", - ) + return _shadow_todo(result, registry_path, goal_id, "todo_complete") def supersede_goal_todo( *, @@ -2279,12 +2231,7 @@ def supersede_goal_todo( "project": str(resolved_project) if resolved_project else None, "updated_at": updated_at if changed else None, } - return _attach_local_authority_shadow( - result, - registry_path=registry_path, - goal_id=goal_id, - write_class="todo_supersede", - ) + return _shadow_todo(result, registry_path, goal_id, "todo_supersede") def archive_completed_todos( @@ -2335,9 +2282,4 @@ def archive_completed_todos( "project": str(resolved_project) if resolved_project else None, "updated_at": updated_at if changed else None, } - return _attach_local_authority_shadow( - result, - registry_path=registry_path, - goal_id=goal_id, - write_class="todo_archive_completed", - ) + return _shadow_todo(result, registry_path, goal_id, "todo_archive_completed") diff --git a/tests/control_plane/test_coordination_configuration.py b/tests/control_plane/test_coordination_configuration.py new file mode 100644 index 0000000000..10c0ca4789 --- /dev/null +++ b/tests/control_plane/test_coordination_configuration.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from loopx.control_plane.coordination.configuration import normalize_goal_write_scope + + +def test_goal_write_scope_normalization_preserves_configuration_semantics() -> None: + assert normalize_goal_write_scope(None) is None + assert normalize_goal_write_scope([]) == [] + assert normalize_goal_write_scope( + [" docs/**, tests/** ", "docs/**", "src/**;generated/**", ""] + ) == ["docs/**", "tests/**", "src/**;generated/**"] From 4c9108cd819e7b3d48e0187fe2e2c4c3170502ca Mon Sep 17 00:00:00 2001 From: wchwawa Date: Wed, 2 Sep 2026 16:43:54 +1000 Subject: [PATCH 4/8] refactor(authority): define shadow as observation capture Signed-off-by: wchwawa --- ...shared-goal-authority-state-provider-v0.md | 29 ++++++--- ...-goal-authority-state-provider-v0.zh-CN.md | 25 +++++--- .../cli_commands/registry_admin_configure.py | 6 +- loopx/configuration_catalog.py | 15 +++-- .../coordination/local_authority_shadow.ts | 64 +++++++++++-------- .../local_authority_shadow_adapter.py | 40 ++++++++---- loopx/control_plane/todos/handoff_mode.py | 2 +- .../work_items/task_lease_acquire_adapter.py | 4 +- loopx/state_migration.py | 6 +- loopx/todo_followups.py | 2 +- .../test_local_authority_shadow_config.py | 27 +++++++- .../test_local_authority_shadow_runtime.py | 37 +++++++---- .../test_state_migration_authority_shadow.py | 2 +- .../local_authority_shadow.test.ts | 59 +++++++++++++++-- 14 files changed, 226 insertions(+), 92 deletions(-) diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index 0c904f1d01..3757580578 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -535,10 +535,13 @@ Unknown command types fail closed. Transfer or delegated assignment, arbitrary todo/gate mutation, quota reservation, and external effects still require later runtime contracts and qualification. Non-empty write scopes and cross-todo scope-overlap rejection likewise require a later command contract -and qualification. The recoverable-execution verbs below are the Stage 3 -slice; steps 1 through 4 and 7 through 10 of Section 5 (identity, digest, -replay, CAS, reload, rebase, budget) apply to every verb unchanged, and only -the per-verb preconditions and transition (steps 5 and 6) differ. +and qualification. The recoverable-execution verbs below were called Stage 3 +in the historical #3669 implementation sequence. Under the current delivery +sequence in Section 11, that merged work is part of the Stage 0 reference foundation, +not the Stage 3 remote-shadow phase in Section 11. Steps 1 through +4 and 7 through 10 of Section 5 (identity, digest, replay, CAS, reload, rebase, +budget) apply to every verb unchanged, and only the per-verb preconditions and +transition (steps 5 and 6) differ. ### 5.2 `renew_work` @@ -1079,7 +1082,7 @@ The sequence is: cache, offline projection, and diagnostic material. Never keep a long-lived dual-write or dual-master mode. -#### Stage 2C qualification slice: local post-commit shadow +#### Stage 2C observation foundation: local post-commit capture The first half of Stage 2C is an explicit, default-off product path. Preview and enable it with: @@ -1089,12 +1092,19 @@ loopx configure-goal --goal-id GOAL --local-authority-shadow-file loopx configure-goal --goal-id GOAL --local-authority-shadow-file --execute ``` -Todo, handoff-mode, follow-up, and task-lease facades observe their committed -local result through `FileAuthorityStore`. Candidate bytes live under +Todo, handoff-mode, follow-up, and task-lease facades sample the full current +local projection after their primary write returns committed, then ask +`FileAuthorityStore` to retain that snapshot. `observation_trigger` records +why sampling began; it is not the primary transaction identity. A concurrent +primary commit may therefore appear in the sampled snapshot. A `captured` or +`replayed` result proves only the candidate-side observation commit. It does +not compare the source and candidate and carries `parity_verdict=not_evaluated`. + +Candidate bytes live under `authority-shadow/file/` outside the legacy per-Goal runtime tree, so state migration never copies a store identity or revision; an executed migration seeds a new target lineage from the migrated local state. Candidate failure is -reported as evidence but never reverses the completed local write. +reported as an observation result but never reverses the completed local write. Disable the observer in one command with `loopx configure-goal --goal-id GOAL --clear-local-authority-shadow --execute`. @@ -1104,7 +1114,8 @@ decision, fence a legacy writer, qualify a remote provider, or complete the second Stage 2C promotion. A process crash after the local commit but before the observer call may miss that individual observation; a later committed write or migration seed refreshes the full current projection, but no durable -shadow outbox is claimed here. +shadow outbox or transaction-correlated receipt is claimed here. This plumbing +is not parity evidence and cannot by itself support Stage 2C promotion. ### Implementation prerequisite: put local file mode behind the same coordination contract diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index c9198e129d..45d9460b56 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -461,9 +461,11 @@ todo 已提交就要求调用方重新发一条 operation。 未知 command type fail closed。transfer 或 delegated assignment、任意 todo/gate mutation、quota reservation 与 external effect 仍需要后续 runtime 合同与 qualification;非空 write scope 与跨 todo scope-overlap 拒绝同样需要后续 command -contract 与 qualification。下面的可恢复执行动词是 Stage 3 切片;第 5 节的步骤 -1-4 与 7-10(identity、digest、replay、CAS、reload、rebase、budget)对每个动词 -原样适用,只有每动词的前置条件与迁移(步骤 5-6)不同。 +contract 与 qualification。下面的可恢复执行动词在 #3669 历史实施序列中曾称为 +Stage 3;按照第 11 节当前的交付编号,已合入的这部分属于 Stage 0 reference foundation, +不是第 11 节的 Stage 3 远端 shadow 阶段。第 5 节的步骤 1-4 与 7-10 +(identity、digest、replay、CAS、reload、rebase、budget)对每个动词原样适用,只有 +每动词的前置条件与迁移(步骤 5-6)不同。 ### 5.2 `renew_work` @@ -867,7 +869,7 @@ Stage 3/4 qualification 必须保持以下 ownership 与 proof 边界: LoopX service 成为唯一 writer。本地 `.loopx` 退为 cache、offline projection 与 诊断材料。绝不长期维持 dual-write 或 dual-master。 -#### Stage 2C 资格验证切片:本地提交后 shadow +#### Stage 2C 观察基础:本地提交后 capture Stage 2C 的前半段是一个显式开启、默认关闭的产品路径。先预览,再开启: @@ -876,11 +878,17 @@ loopx configure-goal --goal-id GOAL --local-authority-shadow-file loopx configure-goal --goal-id GOAL --local-authority-shadow-file --execute ``` -Todo、handoff-mode、follow-up 与 task-lease facade 会在本地主写成功后,通过 -`FileAuthorityStore` 观察完整当前投影。候选数据位于 legacy 单 Goal runtime tree +Todo、handoff-mode、follow-up 与 task-lease facade 会在本地主写返回成功后,采样 +完整当前本地投影,再让 `FileAuthorityStore` 保存该 snapshot。 +`observation_trigger` 只记录为何开始采样,不是主写 transaction identity;并发主写 +因此可能出现在该次 snapshot 中。`captured` 或 `replayed` 只证明候选侧 observation +commit,不表示已经对比 source 与 candidate;结果明确携带 +`parity_verdict=not_evaluated`。 + +候选数据位于 legacy 单 Goal runtime tree 之外的 `authority-shadow/file/`,因此 state migration 不会复制 store identity 或 revision;真正执行迁移时,会从迁移后的本地主状态为目标端建立一条新 lineage。 -候选失败只形成 evidence,不会推翻已经完成的本地写入。 +候选失败只形成 observation result,不会推翻已经完成的本地写入。 用 `loopx configure-goal --goal-id GOAL --clear-local-authority-shadow --execute` @@ -888,7 +896,8 @@ revision;真正执行迁移时,会从迁移后的本地主状态为目标端 canonical。本切片不会读取候选来决策,不会 fence legacy writer,不会资格化远端 provider,也没有完成 Stage 2C 后半段的本地 canonical promotion。若进程恰好在本地 提交后、observer 调用前崩溃,该次 observation 可能丢失;后续成功写入或 migration -seed 会刷新完整当前投影,但这里不宣称已有 durable shadow outbox。 +seed 会刷新完整当前投影,但这里不宣称已有 durable shadow outbox 或与主写 transaction +关联的 receipt。这套 plumbing 不是 parity evidence,不能单独支持 Stage 2C promotion。 ### 实施前置条件:先让本地文件模式经过同一协调合同 diff --git a/loopx/cli_commands/registry_admin_configure.py b/loopx/cli_commands/registry_admin_configure.py index 61e4482b65..a174c8e52c 100644 --- a/loopx/cli_commands/registry_admin_configure.py +++ b/loopx/cli_commands/registry_admin_configure.py @@ -255,8 +255,8 @@ def register_configure_goal_command(subparsers: argparse._SubParsersAction) -> N "--local-authority-shadow-file", action="store_true", help=( - "Enable the default-off, one-way post-commit FileAuthorityStore " - "qualification shadow. Legacy local writers remain authoritative." + "Enable default-off, one-way capture of post-commit local snapshots " + "in FileAuthorityStore. This does not compare source and candidate." ), ) configure_goal_parser.add_argument( @@ -264,7 +264,7 @@ def register_configure_goal_command(subparsers: argparse._SubParsersAction) -> N action="store_true", help=( "Disable the local authority shadow. This does not delete retained " - "qualification evidence." + "candidate observations." ), ) configure_goal_parser.add_argument( diff --git a/loopx/configuration_catalog.py b/loopx/configuration_catalog.py index 36375a494b..051a935040 100644 --- a/loopx/configuration_catalog.py +++ b/loopx/configuration_catalog.py @@ -107,8 +107,8 @@ def build_goal_configuration_catalog( "features": [ { "feature_id": "local_authority_shadow", - "display_name": "Local authority parity shadow", - "availability": "qualification_opt_in", + "display_name": "Local post-commit authority observation", + "availability": "experimental_opt_in", "default": {"enabled": False}, "current": { "enabled": local_authority_shadow.get("enabled") is True, @@ -116,17 +116,20 @@ def build_goal_configuration_catalog( "status": local_authority_shadow.get("status", "disabled"), }, "consider_when": ( - "A Goal needs post-commit parity evidence before any local " - "authority-source promotion." + "A Goal needs to exercise the first Stage 2C observation " + "plumbing while legacy local writers remain authoritative." ), "effect": ( - "Observes committed Todo and task-lease state through the " - "FileAuthorityStore contract." + "Captures a best-effort post-commit snapshot of Todo and " + "task-lease state through the FileAuthorityStore contract." ), "does_not": [ "read the candidate for lifecycle decisions", "write candidate state back into Markdown or task-lease files", "promote shared authority or fence legacy writers", + "bind the snapshot to the exact primary transaction", + "guarantee delivery through a durable outbox", + "compare source and candidate or issue a parity verdict", ], "commands": { "preview_enable": _configure_command( diff --git a/loopx/control_plane/coordination/local_authority_shadow.ts b/loopx/control_plane/coordination/local_authority_shadow.ts index 802d1666f5..e35e0f25bc 100644 --- a/loopx/control_plane/coordination/local_authority_shadow.ts +++ b/loopx/control_plane/coordination/local_authority_shadow.ts @@ -16,21 +16,21 @@ export const LOCAL_AUTHORITY_SHADOW_PROJECTION_SCHEMA = "loopx_local_authority_shadow_projection_v0"; export const LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA = "loopx_local_authority_shadow_evidence_v0"; -export const LOCAL_AUTHORITY_SHADOW_RECEIPT_SCHEMA = - "loopx_local_authority_shadow_receipt_v0"; +export const LOCAL_AUTHORITY_SHADOW_OBSERVATION_RECEIPT_SCHEMA = + "loopx_local_authority_shadow_observation_receipt_v0"; const REQUEST_FIELDS = new Set([ "schema_version", "mode", "runtime_root", "goal_id", - "operation_id", - "source_operation", + "observation_id", + "observation_trigger", "source_digest", "source_projection", ]); export type LocalAuthorityShadowOutcome = - | "advanced" + | "captured" | "replayed" | "ambiguous_reconciled" | "ambiguous_unproved" @@ -44,8 +44,13 @@ export interface LocalAuthorityShadowEvidence extends JsonObject { outcome: LocalAuthorityShadowOutcome; reason_code: string | null; goal_id: string; - operation_id: string; + observation_id: string; source_digest: string; + capture_kind: "post_commit_snapshot"; + source_transaction_correlated: false; + durable_source_outbox: false; + source_candidate_compared: false; + parity_verdict: "not_evaluated"; primary_authority: "legacy_local"; candidate_provider: "file"; candidate_read_for_decision: false; @@ -60,8 +65,8 @@ interface LocalAuthorityShadowRequest { mode: "file_one_way"; runtime_root: string; goal_id: string; - operation_id: string; - source_operation: string; + observation_id: string; + observation_trigger: string; source_digest: string; source_projection: JsonObject; } @@ -107,10 +112,10 @@ function decodeRequest(value: unknown): LocalAuthorityShadowRequest { mode: "file_one_way", runtime_root: requireNonEmptyString(request.runtime_root, "runtime_root"), goal_id: goalId, - operation_id: requireNonEmptyString(request.operation_id, "operation_id"), - source_operation: requireNonEmptyString( - request.source_operation, - "source_operation", + observation_id: requireNonEmptyString(request.observation_id, "observation_id"), + observation_trigger: requireNonEmptyString( + request.observation_trigger, + "observation_trigger", ), source_digest: sourceDigest, source_projection: structuredClone(projection), @@ -132,8 +137,13 @@ function evidence( outcome, reason_code: options.reasonCode ?? null, goal_id: request.goal_id, - operation_id: request.operation_id, + observation_id: request.observation_id, source_digest: request.source_digest, + capture_kind: "post_commit_snapshot", + source_transaction_correlated: false, + durable_source_outbox: false, + source_candidate_compared: false, + parity_verdict: "not_evaluated", primary_authority: "legacy_local", candidate_provider: "file", candidate_read_for_decision: false, @@ -162,8 +172,8 @@ function receiptMatches( ): boolean { return result.receipts.some((raw) => { const receipt = raw as Record; - return receipt.schema_version === LOCAL_AUTHORITY_SHADOW_RECEIPT_SCHEMA && - receipt.operation_id === request.operation_id && + return receipt.schema_version === LOCAL_AUTHORITY_SHADOW_OBSERVATION_RECEIPT_SCHEMA && + receipt.observation_id === request.observation_id && receipt.source_digest === request.source_digest && receipt.primary_authority === "legacy_local" && receipt.provider_to_local_writes === false; @@ -176,7 +186,7 @@ async function reconcileReceipt( storeIdentity: string, reconciledOutcome: "replayed" | "ambiguous_reconciled", ): Promise { - const result = await store.readReceipt(request.operation_id); + const result = await store.readReceipt(request.observation_id); if (result.status === "found" && receiptMatches(request, result)) { return evidence(request, reconciledOutcome, { storeIdentity, @@ -203,8 +213,8 @@ async function reconcileReceipt( : "protocol_mismatch", { reasonCode: result.status === "missing" - ? "operation_receipt_missing" - : "operation_receipt_mismatch", + ? "observation_receipt_missing" + : "observation_receipt_mismatch", storeIdentity, }, ); @@ -247,19 +257,21 @@ export async function recordLocalAuthorityShadow( } const storeIdentity = identity.store_identity; const receipt = { - schema_version: LOCAL_AUTHORITY_SHADOW_RECEIPT_SCHEMA, - operation_id: request.operation_id, + schema_version: LOCAL_AUTHORITY_SHADOW_OBSERVATION_RECEIPT_SCHEMA, + observation_id: request.observation_id, source_digest: request.source_digest, - source_operation: request.source_operation, + observation_trigger: request.observation_trigger, + source_transaction_correlated: false, + parity_verdict: "not_evaluated", primary_authority: "legacy_local", candidate_read_for_decision: false, provider_to_local_writes: false, }; const event = { schema_version: "loopx_local_authority_shadow_event_v0", - kind: "source_observed", - operation_id: request.operation_id, - source_operation: request.source_operation, + kind: "post_commit_snapshot_captured", + observation_id: request.observation_id, + observation_trigger: request.observation_trigger, source_digest: request.source_digest, }; @@ -270,13 +282,13 @@ export async function recordLocalAuthorityShadow( const committed = await store.commitAuthority({ expected_provider_revision: loaded.status === "loaded" ? loaded.provider_revision : null, - operation_id: request.operation_id, + operation_id: request.observation_id, events: [event], next_projection: request.source_projection, receipts: [receipt], }); if (committed.status === "applied") { - return evidence(request, "advanced", { + return evidence(request, "captured", { storeIdentity, providerRevision: committed.provider_revision, cursor: committed.cursor, diff --git a/loopx/control_plane/coordination/local_authority_shadow_adapter.py b/loopx/control_plane/coordination/local_authority_shadow_adapter.py index d729f0075f..121d7cab43 100644 --- a/loopx/control_plane/coordination/local_authority_shadow_adapter.py +++ b/loopx/control_plane/coordination/local_authority_shadow_adapter.py @@ -29,7 +29,7 @@ _PROJECTION_ATTEMPTS = 3 _CONFLICT_RETRY_ATTEMPTS = 3 _EVIDENCE_OUTCOMES = { - "advanced", + "captured", "replayed", "ambiguous_reconciled", "ambiguous_unproved", @@ -145,8 +145,13 @@ def _base_evidence( "outcome": outcome, "reason_code": reason_code, "goal_id": goal_id, - "operation_id": None, + "observation_id": None, "source_digest": None, + "capture_kind": "post_commit_snapshot", + "source_transaction_correlated": False, + "durable_source_outbox": False, + "source_candidate_compared": False, + "parity_verdict": "not_evaluated", "primary_authority": "legacy_local", "candidate_provider": "file", "candidate_read_for_decision": False, @@ -277,7 +282,7 @@ def _valid_evidence( result: object, *, goal_id: str, - operation_id: str, + observation_id: str, source_digest: str, ) -> bool: if not isinstance(result, dict): @@ -286,8 +291,13 @@ def _valid_evidence( result.get("schema_version") == LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA and result.get("outcome") in _EVIDENCE_OUTCOMES and result.get("goal_id") == goal_id - and result.get("operation_id") == operation_id + and result.get("observation_id") == observation_id and result.get("source_digest") == source_digest + and result.get("capture_kind") == "post_commit_snapshot" + and result.get("source_transaction_correlated") is False + and result.get("durable_source_outbox") is False + and result.get("source_candidate_compared") is False + and result.get("parity_verdict") == "not_evaluated" and result.get("primary_authority") == "legacy_local" and result.get("candidate_provider") == "file" and result.get("candidate_read_for_decision") is False @@ -305,9 +315,13 @@ def observe_local_authority_commit( registry_path: Path, runtime_root: Path | None, goal_id: str, - source_operation: str, + observation_trigger: str, ) -> dict[str, Any] | None: - """Record one local post-commit observation without changing its verdict.""" + """Capture a best-effort post-commit snapshot without changing its verdict. + + ``observation_trigger`` is diagnostic context, not a primary transaction + identity. The snapshot may include commits that landed after that trigger. + """ if not goal_id or goal_id in {".", ".."} or "/" in goal_id or "\\" in goal_id: return _base_evidence( @@ -353,13 +367,13 @@ def observe_local_authority_commit( source_digest = ( "sha256:" + hashlib.sha256(_canonical(projection)).hexdigest() ) - operation_id = ( + observation_id = ( "local-shadow:" + hashlib.sha256( _canonical( { "goal_id": goal_id, - "source_operation": source_operation, + "observation_trigger": observation_trigger, "source_digest": source_digest, } ) @@ -372,8 +386,8 @@ def observe_local_authority_commit( "mode": config["mode"], "runtime_root": str(runtime_root), "goal_id": goal_id, - "operation_id": operation_id, - "source_operation": source_operation, + "observation_id": observation_id, + "observation_trigger": observation_trigger, "source_digest": source_digest, "source_projection": projection, }, @@ -382,13 +396,13 @@ def observe_local_authority_commit( if not _valid_evidence( raw_result, goal_id=goal_id, - operation_id=operation_id, + observation_id=observation_id, source_digest=source_digest, ): return _base_evidence( goal_id=goal_id, outcome="failed", - reason_code="shadow_evidence_invalid", + reason_code="shadow_observation_result_invalid", ) result = dict(raw_result) if result["outcome"] != "conflict_retry_required": @@ -434,7 +448,7 @@ def observe_todo_local_authority_commit( registry_path=registry_path, runtime_root=None, goal_id=goal_id, - source_operation=f"{write_class}:{todo_id}:{updated_at}", + observation_trigger=f"{write_class}:{todo_id}:{updated_at}", ) if evidence is not None: payload["authority_shadow"] = evidence diff --git a/loopx/control_plane/todos/handoff_mode.py b/loopx/control_plane/todos/handoff_mode.py index 7ad2e83e73..ba5469ebdc 100644 --- a/loopx/control_plane/todos/handoff_mode.py +++ b/loopx/control_plane/todos/handoff_mode.py @@ -517,7 +517,7 @@ def set_goal_handoff_mode( registry_path=registry_path, runtime_root=runtime_root_from_registry(registry_path, None), goal_id=goal_id, - source_operation=f"handoff_mode_set:{previous}:{requested}", + observation_trigger=f"handoff_mode_set:{previous}:{requested}", ) if evidence is not None: payload["authority_shadow"] = evidence diff --git a/loopx/control_plane/work_items/task_lease_acquire_adapter.py b/loopx/control_plane/work_items/task_lease_acquire_adapter.py index da6cc83c29..ddd4193804 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_adapter.py +++ b/loopx/control_plane/work_items/task_lease_acquire_adapter.py @@ -38,7 +38,7 @@ def _attach_local_authority_shadow( if registry_path is None: return result lease = result.get("lease") if isinstance(result.get("lease"), dict) else {} - source_operation = ":".join( + observation_trigger = ":".join( ( f"task_lease_{operation}", str(todo_id), @@ -55,7 +55,7 @@ def _attach_local_authority_shadow( registry_path=registry_path, runtime_root=runtime_root, goal_id=str(goal_id), - source_operation=source_operation, + observation_trigger=observation_trigger, ) if evidence is not None: result["authority_shadow"] = evidence diff --git a/loopx/state_migration.py b/loopx/state_migration.py index 5e9330b549..ca8380d0cf 100644 --- a/loopx/state_migration.py +++ b/loopx/state_migration.py @@ -16,7 +16,7 @@ AUTHORITY_SHADOW_CONFIG_SCHEMA = "loopx_local_authority_shadow_config_v0" MIGRATION_SHADOW_SEED_EVIDENCE_SCHEMA = "loopx_state_migration_shadow_seed_evidence_v0" _SHADOW_EVIDENCE_OUTCOMES = { - "advanced", + "captured", "replayed", "ambiguous_reconciled", "ambiguous_unproved", @@ -256,7 +256,7 @@ def _shadow_seed_result(*, goal_id: str, result: object) -> dict[str, Any]: reason_code = ( None - if outcome in {"advanced", "replayed", "ambiguous_reconciled"} + if outcome in {"captured", "replayed", "ambiguous_reconciled"} else f"post_migration_shadow_seed_{outcome}" ) return _shadow_seed_evidence( @@ -299,7 +299,7 @@ def seed_migrated_authority_shadows( registry_path=target_registry_path, runtime_root=target_runtime_root, goal_id=goal_id, - source_operation="state_migration_seed", + observation_trigger="state_migration_seed", ) results.append(_shadow_seed_result(goal_id=goal_id, result=result)) except Exception: diff --git a/loopx/todo_followups.py b/loopx/todo_followups.py index 963cba7878..072c4730b6 100644 --- a/loopx/todo_followups.py +++ b/loopx/todo_followups.py @@ -175,7 +175,7 @@ def capture_followup_todos( registry_path=registry_path, runtime_root=None, goal_id=goal_id, - source_operation=( + observation_trigger=( f"todo_capture_followups:{recorded_count}:{updated_at}" ), ) diff --git a/tests/control_plane/test_local_authority_shadow_config.py b/tests/control_plane/test_local_authority_shadow_config.py index af217d8381..7390ddb84d 100644 --- a/tests/control_plane/test_local_authority_shadow_config.py +++ b/tests/control_plane/test_local_authority_shadow_config.py @@ -10,6 +10,7 @@ GOAL_ID = "local-authority-shadow-config" +REPO_ROOT = Path(__file__).resolve().parents[2] def _registry(tmp_path: Path) -> Path: @@ -145,11 +146,17 @@ def test_configure_goal_cli_exposes_default_off_shadow_boundary( for item in payload["configuration_catalog"]["features"] if item["feature_id"] == "local_authority_shadow" ) - assert feature["availability"] == "qualification_opt_in" + assert feature["display_name"] == "Local post-commit authority observation" + assert feature["availability"] == "experimental_opt_in" + assert "parity" not in feature["consider_when"].lower() + assert "post-commit snapshot" in feature["effect"] assert feature["does_not"] == [ "read the candidate for lifecycle decisions", "write candidate state back into Markdown or task-lease files", "promote shared authority or fence legacy writers", + "bind the snapshot to the exact primary transaction", + "guarantee delivery through a durable outbox", + "compare source and candidate or issue a parity verdict", ] assert feature["commands"]["apply_disable"].endswith( "--clear-local-authority-shadow --execute" @@ -157,3 +164,21 @@ def test_configure_goal_cli_exposes_default_off_shadow_boundary( assert "authority_shadow" not in json.loads( registry.read_text(encoding="utf-8") )["goals"][0]["coordination"] + + +def test_rfc_disambiguates_historical_and_current_stage_numbering() -> None: + english = ( + REPO_ROOT + / "docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md" + ).read_text(encoding="utf-8") + chinese = ( + REPO_ROOT + / "docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md" + ).read_text(encoding="utf-8") + + assert "historical #3669 implementation sequence" in english + assert "part of the Stage 0 reference foundation" in english + assert "not the Stage 3 remote-shadow phase in Section 11" in english + assert "#3669 历史实施序列" in chinese + assert "属于 Stage 0 reference foundation" in chinese + assert "不是第 11 节的 Stage 3 远端 shadow 阶段" in chinese diff --git a/tests/control_plane/test_local_authority_shadow_runtime.py b/tests/control_plane/test_local_authority_shadow_runtime.py index c054f54921..38d1025229 100644 --- a/tests/control_plane/test_local_authority_shadow_runtime.py +++ b/tests/control_plane/test_local_authority_shadow_runtime.py @@ -207,6 +207,11 @@ def test_enabled_todo_public_facades_emit_post_commit_evidence(tmp_path: Path) - ) assert result["authority_shadow"]["primary_writeback_preserved"] is True assert result["authority_shadow"]["provider_to_local_writes"] is False + assert result["authority_shadow"]["capture_kind"] == "post_commit_snapshot" + assert result["authority_shadow"]["source_transaction_correlated"] is False + assert result["authority_shadow"]["durable_source_outbox"] is False + assert result["authority_shadow"]["source_candidate_compared"] is False + assert result["authority_shadow"]["parity_verdict"] == "not_evaluated" def test_enabled_task_lease_facades_shadow_only_committed_mutations( @@ -267,7 +272,7 @@ def test_enabled_task_lease_facades_shadow_only_committed_mutations( for result in (acquired, renewed, transferred, released): assert result["authority_shadow"]["outcome"] in { - "advanced", + "captured", "replayed", "ambiguous_reconciled", } @@ -293,9 +298,9 @@ def test_handoff_mode_and_direct_followup_writers_refresh_the_same_shadow( ) assert mode["changed"] is True - assert mode["authority_shadow"]["outcome"] == "advanced" + assert mode["authority_shadow"]["outcome"] == "captured" assert followups["changed"] is True - assert followups["authority_shadow"]["outcome"] == "advanced" + assert followups["authority_shadow"]["outcome"] == "captured" head = _shadow_document(runtime_root)["head"] assert head["handoff_mode"] == "legacy" assert [todo["todo_id"] for todo in head["todos"]] == [ @@ -348,7 +353,7 @@ def test_event_projected_completion_refreshes_shadow_after_releasing_lease( assert completed["source"] == "event_log" assert completed["changed"] is True - assert completed["authority_shadow"]["outcome"] == "advanced" + assert completed["authority_shadow"]["outcome"] == "captured" head = _shadow_document(runtime_root)["head"] assert len(head["leases"]) == 1 assert head["leases"][0]["todo_id"] == todo_id @@ -373,13 +378,18 @@ def unavailable( "outcome": "unavailable", "reason_code": "injected_outage", "goal_id": params["goal_id"], - "operation_id": params["operation_id"], + "observation_id": params["observation_id"], "source_digest": params["source_digest"], "primary_authority": "legacy_local", "candidate_provider": "file", "candidate_read_for_decision": False, "provider_to_local_writes": False, "primary_writeback_preserved": True, + "capture_kind": "post_commit_snapshot", + "source_transaction_correlated": False, + "durable_source_outbox": False, + "source_candidate_compared": False, + "parity_verdict": "not_evaluated", "store_identity": None, "provider_revision": None, "cursor": None, @@ -424,7 +434,7 @@ def test_observer_is_default_off_without_creating_lock_or_provider_directory( registry_path=registry, runtime_root=runtime_root, goal_id=GOAL_ID, - source_operation="todo_update", + observation_trigger="todo_update", ) is None ) @@ -467,7 +477,7 @@ def conflict_then_advance( **_kwargs: object, ) -> dict[str, object]: requests.append(dict(params)) - outcome = "conflict_retry_required" if len(requests) == 1 else "advanced" + outcome = "conflict_retry_required" if len(requests) == 1 else "captured" return { "schema_version": LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA, "outcome": outcome, @@ -475,13 +485,18 @@ def conflict_then_advance( "provider_revision_mismatch" if len(requests) == 1 else None ), "goal_id": GOAL_ID, - "operation_id": params["operation_id"], + "observation_id": params["observation_id"], "source_digest": params["source_digest"], "primary_authority": "legacy_local", "candidate_provider": "file", "candidate_read_for_decision": False, "provider_to_local_writes": False, "primary_writeback_preserved": True, + "capture_kind": "post_commit_snapshot", + "source_transaction_correlated": False, + "durable_source_outbox": False, + "source_candidate_compared": False, + "parity_verdict": "not_evaluated", "store_identity": "file:test", "provider_revision": "file:2:test", "cursor": "2", @@ -496,13 +511,13 @@ def conflict_then_advance( registry_path=registry, runtime_root=runtime_root, goal_id=GOAL_ID, - source_operation="todo_update:todo_a:now", + observation_trigger="todo_update:todo_a:now", ) assert result is not None - assert result["outcome"] == "advanced" + assert result["outcome"] == "captured" assert len(requests) == 2 assert requests[0]["source_digest"] != requests[1]["source_digest"] - assert requests[0]["operation_id"] != requests[1]["operation_id"] + assert requests[0]["observation_id"] != requests[1]["observation_id"] assert all(request["runtime_root"] == str(runtime_root) for request in requests) assert all("provider_directory" not in request for request in requests) diff --git a/tests/control_plane/test_state_migration_authority_shadow.py b/tests/control_plane/test_state_migration_authority_shadow.py index b3cfa84bf2..6f8f1efaf9 100644 --- a/tests/control_plane/test_state_migration_authority_shadow.py +++ b/tests/control_plane/test_state_migration_authority_shadow.py @@ -167,7 +167,7 @@ def test_execute_excludes_old_shadow_and_seeds_fresh_target_lineage( seed = result["authority_shadow_seeds"][0] # type: ignore[index] assert seed["goal_id"] == NEW_GOAL_ID assert seed["attempted"] is True - assert seed["outcome"] == "advanced" + assert seed["outcome"] == "captured" def test_dry_run_reports_exclusion_and_seed_plan_without_writing( diff --git a/tests/control_plane_ts/local_authority_shadow.test.ts b/tests/control_plane_ts/local_authority_shadow.test.ts index d1499ee58b..a814514388 100644 --- a/tests/control_plane_ts/local_authority_shadow.test.ts +++ b/tests/control_plane_ts/local_authority_shadow.test.ts @@ -25,8 +25,8 @@ function request(directory: string, operationId = "local-operation-a") { mode: "file_one_way", runtime_root: directory, goal_id: "goal-a", - operation_id: operationId, - source_operation: "todo_update", + observation_id: operationId, + observation_trigger: "todo_update", source_digest: `sha256:${"a".repeat(64)}`, source_projection: { schema_version: "loopx_local_authority_shadow_projection_v0", @@ -38,18 +38,23 @@ function request(directory: string, operationId = "local-operation-a") { }; } -test("one-way file shadow commits an observation without becoming decision authority", async (t) => { +test("one-way file shadow captures a post-commit observation without claiming parity", async (t) => { const root = await mkdtemp(join(tmpdir(), "loopx-local-authority-shadow-")); t.after(() => rm(root, { recursive: true, force: true })); const evidence = await recordLocalAuthorityShadow(request(root)); assert.equal(evidence.schema_version, LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA); - assert.equal(evidence.outcome, "advanced"); + assert.equal(evidence.outcome, "captured"); assert.equal(evidence.primary_authority, "legacy_local"); assert.equal(evidence.candidate_read_for_decision, false); assert.equal(evidence.provider_to_local_writes, false); assert.equal(evidence.primary_writeback_preserved, true); + assert.equal(evidence.capture_kind, "post_commit_snapshot"); + assert.equal(evidence.source_transaction_correlated, false); + assert.equal(evidence.durable_source_outbox, false); + assert.equal(evidence.source_candidate_compared, false); + assert.equal(evidence.parity_verdict, "not_evaluated"); const loaded = await new FileAuthorityStore( join(root, "authority-shadow", "file", "goal-a"), "goal-a", @@ -60,11 +65,11 @@ test("one-way file shadow commits an observation without becoming decision autho } }); -test("same operation replays its typed observation receipt", async (t) => { +test("same observation reuses its candidate-side capture receipt", async (t) => { const root = await mkdtemp(join(tmpdir(), "loopx-local-authority-shadow-")); t.after(() => rm(root, { recursive: true, force: true })); - assert.equal((await recordLocalAuthorityShadow(request(root))).outcome, "advanced"); + assert.equal((await recordLocalAuthorityShadow(request(root))).outcome, "captured"); const replay = await recordLocalAuthorityShadow(request(root)); assert.equal(replay.outcome, "replayed"); @@ -76,6 +81,46 @@ test("same operation replays its typed observation receipt", async (t) => { if (page.status === "page") assert.equal(page.transactions.length, 1); }); +test("observation trigger does not imply exact source-transaction binding", async (t) => { + const root = await mkdtemp(join(tmpdir(), "loopx-local-authority-shadow-")); + t.after(() => rm(root, { recursive: true, force: true })); + const requestAfterConcurrentCommit = request(root); + requestAfterConcurrentCommit.observation_trigger = "todo_add:todo-a"; + requestAfterConcurrentCommit.source_projection.todos.push({ + todo_id: "todo-b", + status: "open", + claimed_by: "agent-b", + }); + + const observation = await recordLocalAuthorityShadow(requestAfterConcurrentCommit); + + assert.equal(observation.outcome, "captured"); + assert.equal(observation.source_transaction_correlated, false); + assert.equal(observation.parity_verdict, "not_evaluated"); + const page = await new FileAuthorityStore( + join(root, "authority-shadow", "file", "goal-a"), + "goal-a", + ).scanCommitted(null, 10); + assert.equal(page.status, "page"); + if (page.status === "page") { + assert.equal(page.transactions[0]?.events[0]?.observation_trigger, "todo_add:todo-a"); + const capturedTodos = page.transactions[0]?.projection.todos; + assert.ok(Array.isArray(capturedTodos)); + assert.equal(capturedTodos.length, 2); + } +}); + +test("legacy source_operation wording is rejected by the closed observation contract", async () => { + const legacyRequest = { ...request("/not-used") } as Record; + legacyRequest.source_operation = legacyRequest.observation_trigger; + delete legacyRequest.observation_trigger; + + await assert.rejects( + recordLocalAuthorityShadow(legacyRequest), + /unsupported fields: source_operation/u, + ); +}); + class UnavailableStore implements AuthorityStore { commits = 0; @@ -160,7 +205,7 @@ test("goal id cannot escape the fixed shadow directory", async () => { ); }); -test("ambiguous response is success only when the exact receipt is readable", async () => { +test("ambiguous response is captured only when its candidate observation receipt is readable", async () => { const root = await mkdtemp(join(tmpdir(), "loopx-local-authority-shadow-")); tCleanup(root); class AfterCommitStore extends FileAuthorityStore { From c9c978761d02864b93c46a9efcd7ac78cb8d7cfb Mon Sep 17 00:00:00 2001 From: wchwawa Date: Wed, 2 Sep 2026 16:43:59 +1000 Subject: [PATCH 5/8] test(authority): close post-commit observation product path Signed-off-by: wchwawa --- .../test_local_authority_shadow_cli_e2e.py | 343 ++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 tests/control_plane/test_local_authority_shadow_cli_e2e.py diff --git a/tests/control_plane/test_local_authority_shadow_cli_e2e.py b/tests/control_plane/test_local_authority_shadow_cli_e2e.py new file mode 100644 index 0000000000..bde83dcd82 --- /dev/null +++ b/tests/control_plane/test_local_authority_shadow_cli_e2e.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +import time + +import pytest + +from loopx.file_lock import exclusive_file_lock + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _workspace(tmp_path: Path, *, goal_id: str) -> tuple[Path, Path, Path]: + repo = tmp_path / goal_id + repo.mkdir() + state = repo / "ACTIVE_GOAL_STATE.md" + state.write_text( + "---\n" + f"goal_id: {goal_id}\n" + "handoff_mode: hard_lease\n" + "updated_at: 2026-09-02T00:00:00+00:00\n" + "---\n\n" + "## Agent Todo\n\n", + encoding="utf-8", + ) + runtime_root = tmp_path / f"{goal_id}-runtime" + registry = tmp_path / f"{goal_id}-registry.json" + registry.write_text( + json.dumps( + { + "common_runtime_root": str(runtime_root), + "goals": [ + { + "id": goal_id, + "status": "active", + "repo": str(repo), + "state_file": state.name, + "coordination": { + "agent_model": "peer_v1", + "registered_agents": ["agent-a", "agent-b"], + }, + } + ], + } + ), + encoding="utf-8", + ) + return registry, state, runtime_root + + +def _command(registry: Path, runtime_root: Path, *args: str) -> list[str]: + return [ + sys.executable, + "-m", + "loopx.cli", + "--registry", + str(registry), + "--runtime-root", + str(runtime_root), + "--format", + "json", + *args, + ] + + +def _env() -> dict[str, str]: + env = os.environ.copy() + env["PYTHONPATH"] = str(REPO_ROOT) + return env + + +def _cli(registry: Path, runtime_root: Path, *args: str) -> dict[str, object]: + completed = subprocess.run( + _command(registry, runtime_root, *args), + cwd=REPO_ROOT, + env=_env(), + check=True, + capture_output=True, + text=True, + timeout=30, + ) + return json.loads(completed.stdout) + + +def _store_document(runtime_root: Path, goal_id: str) -> tuple[Path, dict[str, object]]: + paths = list( + (runtime_root / "authority-shadow" / "file" / goal_id).glob( + "authority-store-*.json" + ) + ) + assert len(paths) == 1 + return paths[0], json.loads(paths[0].read_text(encoding="utf-8")) + + +def _add_todo( + registry: Path, + runtime_root: Path, + *, + goal_id: str, + text: str, +) -> dict[str, object]: + return _cli( + registry, + runtime_root, + "todo", + "add", + "--goal-id", + goal_id, + "--role", + "agent", + "--text", + text, + "--task-class", + "advancement_task", + ) + + +def test_product_cli_configure_capture_readback_disable_and_default_off_parity( + tmp_path: Path, +) -> None: + goal_id = "shadow-cli-e2e" + registry, _state, runtime_root = _workspace(tmp_path, goal_id=goal_id) + + preview = _cli( + registry, + runtime_root, + "configure-goal", + "--goal-id", + goal_id, + "--local-authority-shadow-file", + ) + assert preview["dry_run"] is True + assert preview["written"] is False + + enabled = _cli( + registry, + runtime_root, + "configure-goal", + "--goal-id", + goal_id, + "--local-authority-shadow-file", + "--execute", + ) + assert enabled["written"] is True + + text = "Capture one post-commit observation through the product CLI." + observed = _add_todo( + registry, + runtime_root, + goal_id=goal_id, + text=text, + ) + assert observed["authority_shadow"]["outcome"] == "captured" # type: ignore[index] + assert observed["authority_shadow"]["parity_verdict"] == "not_evaluated" # type: ignore[index] + lease = _cli( + registry, + runtime_root, + "task-lease", + "acquire", + "--goal-id", + goal_id, + "--todo-id", + str(observed["todo_id"]), + "--owner", + "agent-a", + "--idempotency-key", + "shadow-cli-lease", + "--ttl-seconds", + "120", + ) + assert lease["acquired"] is True + assert lease["authority_shadow"]["outcome"] == "captured" # type: ignore[index] + store_path, store = _store_document(runtime_root, goal_id) + assert len(store["head"]["todos"]) == 1 # type: ignore[index] + assert len(store["head"]["leases"]) == 1 # type: ignore[index] + + inspected = _cli( + registry, + runtime_root, + "configure-goal", + "--goal-id", + goal_id, + ) + assert inspected["after"]["local_authority_shadow"] == { # type: ignore[index] + "enabled": True, + "mode": "file_one_way", + "status": "enabled", + } + + disabled = _cli( + registry, + runtime_root, + "configure-goal", + "--goal-id", + goal_id, + "--clear-local-authority-shadow", + "--execute", + ) + assert disabled["written"] is True + candidate_before_disabled_write = store_path.read_bytes() + after_disable = _add_todo( + registry, + runtime_root, + goal_id=goal_id, + text="This local lifecycle write must not execute the observer.", + ) + assert after_disable["ok"] is True + assert after_disable["added"] is True + assert "authority_shadow" not in after_disable + assert store_path.read_bytes() == candidate_before_disabled_write + + baseline_registry, _baseline_state, baseline_runtime = _workspace( + tmp_path, goal_id="shadow-cli-baseline" + ) + baseline = _add_todo( + baseline_registry, + baseline_runtime, + goal_id="shadow-cli-baseline", + text=text, + ) + for field in ( + "ok", + "added", + "already_exists", + "metadata_updated", + "status_changed", + "role", + "status", + "task_class", + "action_kind", + "continuation_policy", + ): + assert observed[field] == baseline[field] + assert not (baseline_runtime / "authority-shadow").exists() + + +def test_product_cli_candidate_failure_preserves_the_primary_lifecycle_commit( + tmp_path: Path, +) -> None: + goal_id = "shadow-cli-failure" + registry, state, runtime_root = _workspace(tmp_path, goal_id=goal_id) + _cli( + registry, + runtime_root, + "configure-goal", + "--goal-id", + goal_id, + "--local-authority-shadow-file", + "--execute", + ) + runtime_root.mkdir(parents=True, exist_ok=True) + (runtime_root / "authority-shadow").write_text("block candidate directory", encoding="utf-8") + + result = _add_todo( + registry, + runtime_root, + goal_id=goal_id, + text="The primary write survives a candidate construction failure.", + ) + + assert result["ok"] is True + assert result["added"] is True + assert result["authority_shadow"]["outcome"] == "failed" # type: ignore[index] + assert result["authority_shadow"]["reason_code"] == "shadow_observation_failed" # type: ignore[index] + assert str(result["todo_id"]) in state.read_text(encoding="utf-8") + + +@pytest.mark.skipif(os.name == "nt", reason="requires POSIX cross-process flock and SIGKILL") +def test_product_cli_crash_gap_has_no_outbox_but_later_capture_refreshes_head( + tmp_path: Path, +) -> None: + goal_id = "shadow-cli-crash-gap" + registry, state, runtime_root = _workspace(tmp_path, goal_id=goal_id) + _cli( + registry, + runtime_root, + "configure-goal", + "--goal-id", + goal_id, + "--local-authority-shadow-file", + "--execute", + ) + first_text = "Primary commit that loses its post-commit observation." + observation_lock_target = ( + runtime_root / "authority-shadow" / "file" / goal_id / "observation" + ) + + with exclusive_file_lock(observation_lock_target, operation="e2e_crash_gap"): + process = subprocess.Popen( + _command( + registry, + runtime_root, + "todo", + "add", + "--goal-id", + goal_id, + "--role", + "agent", + "--text", + first_text, + "--task-class", + "advancement_task", + ), + cwd=REPO_ROOT, + env=_env(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + deadline = time.monotonic() + 5.0 + while first_text not in state.read_text(encoding="utf-8"): + if time.monotonic() >= deadline: + process.kill() + process.communicate(timeout=5) + raise AssertionError("primary Todo commit did not become visible") + time.sleep(0.01) + assert process.poll() is None + process.kill() + process.communicate(timeout=5) + + assert not list( + (runtime_root / "authority-shadow" / "file" / goal_id).glob( + "authority-store-*.json" + ) + ) + + recovered = _add_todo( + registry, + runtime_root, + goal_id=goal_id, + text="A later primary commit refreshes the current full snapshot.", + ) + assert recovered["authority_shadow"]["outcome"] == "captured" # type: ignore[index] + assert recovered["authority_shadow"]["durable_source_outbox"] is False # type: ignore[index] + assert recovered["authority_shadow"]["source_transaction_correlated"] is False # type: ignore[index] + assert recovered["authority_shadow"]["parity_verdict"] == "not_evaluated" # type: ignore[index] + _store_path, store = _store_document(runtime_root, goal_id) + assert len(store["head"]["todos"]) == 2 # type: ignore[index] From 9783d99de462750ddfc4aaecf9cfff8cf423236d Mon Sep 17 00:00:00 2001 From: wchwawa Date: Wed, 2 Sep 2026 16:53:01 +1000 Subject: [PATCH 6/8] test(authority): clarify observation E2E boundaries Signed-off-by: wchwawa --- tests/control_plane/test_local_authority_shadow_cli_e2e.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/control_plane/test_local_authority_shadow_cli_e2e.py b/tests/control_plane/test_local_authority_shadow_cli_e2e.py index bde83dcd82..aedeb092e1 100644 --- a/tests/control_plane/test_local_authority_shadow_cli_e2e.py +++ b/tests/control_plane/test_local_authority_shadow_cli_e2e.py @@ -120,7 +120,7 @@ def _add_todo( ) -def test_product_cli_configure_capture_readback_disable_and_default_off_parity( +def test_product_cli_configure_capture_readback_disable_and_default_off_lifecycle_isolation( tmp_path: Path, ) -> None: goal_id = "shadow-cli-e2e" @@ -271,7 +271,7 @@ def test_product_cli_candidate_failure_preserves_the_primary_lifecycle_commit( @pytest.mark.skipif(os.name == "nt", reason="requires POSIX cross-process flock and SIGKILL") -def test_product_cli_crash_gap_has_no_outbox_but_later_capture_refreshes_head( +def test_product_cli_loses_capture_between_commit_and_observer_then_refreshes_snapshot( tmp_path: Path, ) -> None: goal_id = "shadow-cli-crash-gap" From 6cc340f5d601cc541c1ad1730a03258216194e1a Mon Sep 17 00:00:00 2001 From: wchwawa Date: Thu, 3 Sep 2026 10:54:46 +1000 Subject: [PATCH 7/8] refactor(todos): move the goal todo projection out of loopx/todos.py loopx/todos.py sits exactly at its maintainability ceiling (2285 lines), so threading one effective runtime root through the writer hooks cannot land there without growing the module. Move the todo list projection block (filtered summaries, the Markdown/event merge, and goal_todo_summaries) into loopx/control_plane/todos/goal_todo_projection.py; list_goal_todos keeps its behaviour and delegates to the new module. Pure move: no call site outside loopx/todos.py used the moved helpers, and the todo list, thin/explicit-limit, and agent-lane projection suites are unchanged. Signed-off-by: wchwawa --- .../todos/goal_todo_projection.py | 345 ++++++++++++++++++ loopx/todos.py | 273 +------------- 2 files changed, 362 insertions(+), 256 deletions(-) create mode 100644 loopx/control_plane/todos/goal_todo_projection.py diff --git a/loopx/control_plane/todos/goal_todo_projection.py b/loopx/control_plane/todos/goal_todo_projection.py new file mode 100644 index 0000000000..532315fa47 --- /dev/null +++ b/loopx/control_plane/todos/goal_todo_projection.py @@ -0,0 +1,345 @@ +"""Project todo summaries and items from one active-state text. + +The text is a parameter rather than a file read so a writer that still holds +the state-file lock can project the exact bytes it is about to commit; +``loopx.todos.list_goal_todos`` passes the on-disk text. Everything here is a +deterministic function of the text, the goal record, and the event projection. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ...status import active_state_event_projection_fields +from .active_state_editing import TODO_SECTION_HEADINGS +from .active_state_todo_parser import parse_active_state_todos +from .list_projection import compact_explicit_limit_todo_summary +from .contract import ( + build_todo_id, + normalize_todo_blocks_agent, + normalize_todo_bound_agent, + normalize_todo_claimed_by, + normalize_todo_excluded_agents, + normalize_todo_id, + normalize_todo_status, +) +from .todo_summary import compact_todo_group, todo_item_status + + +def empty_todo_summary(*, role: str) -> dict[str, Any]: + return { + "schema_version": "todo_summary_v0", + "role": role, + "source_section": TODO_SECTION_HEADINGS[role], + "total_count": 0, + "open_count": 0, + "done_count": 0, + "items": [], + "first_open_items": [], + } + +def _user_todo_visible_to_agent(item: dict[str, Any], agent_id: str) -> bool: + if bool(item.get("global_gate")): + return True + blocks_agent = normalize_todo_blocks_agent(item.get("blocks_agent")) + if blocks_agent: + return blocks_agent == agent_id + bound_agent = normalize_todo_bound_agent(item.get("bound_agent")) + if bound_agent: + return bound_agent == agent_id + return True + +def filtered_todo_summary( + summary: dict[str, Any] | None, + *, + role: str, + status: str | None = None, + todo_id: str | None = None, + agent_id: str | None = None, + resume_source_items: list[dict[str, Any]] | None = None, + rollout_events: list[dict[str, Any]] | None = None, + item_limit: int | None = None, +) -> dict[str, Any]: + items = list((summary or {}).get("items") or []) + normalized_status = normalize_todo_status(status) + if normalized_status: + items = [item for item in items if todo_item_status(item) == normalized_status] + normalized_todo_id = normalize_todo_id(todo_id) if todo_id else None + if normalized_todo_id: + items = [ + item + for item in items + if normalize_todo_id(item.get("todo_id")) == normalized_todo_id + ] + normalized_agent_id = normalize_todo_claimed_by(agent_id) if agent_id else None + if normalized_agent_id: + if role == "agent": + items = [ + item + for item in items + if normalized_agent_id + not in normalize_todo_excluded_agents(item.get("excluded_agents")) + and ( + not normalize_todo_claimed_by(item.get("claimed_by")) + or normalize_todo_claimed_by(item.get("claimed_by")) + == normalized_agent_id + ) + ] + elif role == "user": + items = [ + item + for item in items + if _user_todo_visible_to_agent(item, normalized_agent_id) + ] + source_section = str((summary or {}).get("source_section") or TODO_SECTION_HEADINGS[role]) + return ( + compact_todo_group( + items, + source_section=source_section, + role=role, + resume_source_items=resume_source_items, + rollout_events=rollout_events, + item_limit=item_limit, + ) + or empty_todo_summary(role=role) + ) + +def summary_items(fields: dict[str, Any], role: str) -> list[dict[str, Any]]: + summary = fields.get(f"{role}_todos") if isinstance(fields, dict) else None + if not isinstance(summary, dict): + return [] + return [item for item in summary.get("items") or [] if isinstance(item, dict)] + +def merge_todo_projection_fields( + *, + markdown_fields: dict[str, Any], + event_fields: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, Any]]: + merged: dict[str, Any] = {} + merged_items: dict[str, list[dict[str, Any]]] = {"user": [], "agent": []} + source_sections: dict[str, str] = {} + overlay: dict[str, Any] = { + "schema_version": "todo_list_projection_overlay_v0", + "base": "markdown_active_state", + "overlay": "event_projection", + "markdown_only_todo_ids": [], + "event_only_todo_ids": [], + "overlaid_todo_ids": [], + } + + # A todo_id is goal-wide identity. Merge both sources before splitting by + # role so an event-projected role change replaces the stale Markdown item. + by_id: dict[str, dict[str, Any]] = {} + order: list[str] = [] + markdown_ids: set[str] = set() + markdown_ids_by_role: dict[str, set[str]] = {"user": set(), "agent": set()} + event_ids: set[str] = set() + event_order: list[str] = [] + for role in ("user", "agent"): + markdown_items = summary_items(markdown_fields, role) + for item in markdown_items: + todo_id = normalize_todo_id(item.get("todo_id")) or build_todo_id( + role=role, + source_section=item.get("source_section"), + index=item.get("index"), + text=item.get("text"), + ) + if todo_id not in by_id: + order.append(todo_id) + markdown_ids.add(todo_id) + markdown_ids_by_role[role].add(todo_id) + by_id[todo_id] = dict(item) + + for role in ("user", "agent"): + event_items = summary_items(event_fields, role) + for item in event_items: + todo_id = normalize_todo_id(item.get("todo_id")) or build_todo_id( + role=role, + source_section=item.get("source_section"), + index=item.get("index"), + text=item.get("text"), + ) + if todo_id not in by_id: + order.append(todo_id) + if todo_id not in event_ids: + event_order.append(todo_id) + event_ids.add(todo_id) + by_id[todo_id] = dict(item) + + markdown_only_todo_ids: list[str] = [] + seen_markdown_only_ids: set[str] = set() + for role in ("user", "agent"): + for todo_id in sorted(markdown_ids_by_role[role] - event_ids): + if todo_id not in seen_markdown_only_ids: + markdown_only_todo_ids.append(todo_id) + seen_markdown_only_ids.add(todo_id) + overlay["markdown_only_todo_ids"] = markdown_only_todo_ids + overlay["event_only_todo_ids"] = [ + todo_id for todo_id in event_order if todo_id not in markdown_ids + ] + overlay["overlaid_todo_ids"] = [ + todo_id for todo_id in event_order if todo_id in markdown_ids + ] + + for todo_id in order: + item = by_id[todo_id] + final_role = "user" if item.get("role") == "user" else "agent" + merged_items[final_role].append(item) + + for role in ("user", "agent"): + source_section = str( + (markdown_fields.get(f"{role}_todos") or {}).get("source_section") + or (event_fields.get(f"{role}_todos") or {}).get("source_section") + or TODO_SECTION_HEADINGS[role] + ) + source_sections[role] = source_section + + resume_source_items = [*merged_items["user"], *merged_items["agent"]] + for role in ("user", "agent"): + if not merged_items[role]: + continue + summary = compact_todo_group( + merged_items[role], + source_section=source_sections[role], + role=role, + resume_source_items=resume_source_items, + item_limit=None, + ) + if summary: + merged[f"{role}_todos"] = summary + return merged, overlay + +class GoalTodoSummaries: + """Role summaries and todo items projected from one active-state text.""" + + __slots__ = ( + "source", + "projection_fields", + "projection_overlay", + "summaries", + "todos", + "unfiltered_count", + "uncapped_todo_count", + ) + + def __init__( + self, + *, + source: str, + projection_fields: dict[str, Any], + projection_overlay: dict[str, Any] | None, + summaries: dict[str, dict[str, Any]], + todos: list[dict[str, Any]], + unfiltered_count: int, + uncapped_todo_count: int, + ) -> None: + self.source = source + self.projection_fields = projection_fields + self.projection_overlay = projection_overlay + self.summaries = summaries + self.todos = todos + self.unfiltered_count = unfiltered_count + self.uncapped_todo_count = uncapped_todo_count + +def goal_todo_summaries( + goal: dict[str, Any] | None, + *, + state_text: str, + state_path: Path, + rollout_events: list[dict[str, Any]], + roles: list[str], + status: str | None, + todo_id: str | None, + agent_id: str | None, + limit: int | None, +) -> GoalTodoSummaries: + """Project todo summaries from active-state text plus its event projection. + + The text is a parameter rather than a file read so a writer that still + holds the state-file lock can project the exact bytes it is about to + commit; ``list_goal_todos`` passes the on-disk text. + """ + + projection_fields = active_state_event_projection_fields( + goal or {}, + state_path=state_path, + item_limit=None, + rollout_events=rollout_events, + ) + projection_has_todos = bool( + projection_fields.get("user_todos") or projection_fields.get("agent_todos") + ) + markdown_fields = parse_active_state_todos( + state_text, + goal=goal, + state_path=state_path, + item_limit=None, + rollout_events=rollout_events, + ) + markdown_has_todos = bool( + markdown_fields.get("user_todos") or markdown_fields.get("agent_todos") + ) + projection_overlay: dict[str, Any] | None = None + if projection_has_todos and markdown_has_todos: + fields, projection_overlay = merge_todo_projection_fields( + markdown_fields=markdown_fields, + event_fields=projection_fields, + ) + source = "event_projection_with_markdown_overlay" + elif projection_has_todos: + fields = projection_fields + source = "event_projection" + else: + fields = markdown_fields + source = "markdown_active_state" + + resume_source_items = [ + *summary_items(fields, "user"), + *summary_items(fields, "agent"), + ] + summaries: dict[str, dict[str, Any]] = {} + todos: list[dict[str, Any]] = [] + unfiltered_count = 0 + uncapped_todo_count = 0 + for item_role in roles: + key = f"{item_role}_todos" + raw_summary = fields.get(key) if isinstance(fields, dict) else None + unfiltered_count += len((raw_summary or {}).get("items") or []) + summary = filtered_todo_summary( + raw_summary, + role=item_role, + status=status, + todo_id=todo_id, + agent_id=agent_id, + resume_source_items=resume_source_items, + rollout_events=rollout_events, + item_limit=limit, + ) + if limit is not None: + summary = compact_explicit_limit_todo_summary( + summary, + role=item_role, + item_limit=limit, + ) + summaries[key] = summary + todos.extend(summary.get("items") or []) + uncapped_todo_count += int(summary.get("total_count") or 0) + return GoalTodoSummaries( + source=source, + projection_fields=projection_fields, + projection_overlay=projection_overlay, + summaries=summaries, + todos=todos, + unfiltered_count=unfiltered_count, + uncapped_todo_count=uncapped_todo_count, + ) + +__all__ = [ + "GoalTodoSummaries", + "empty_todo_summary", + "filtered_todo_summary", + "goal_todo_summaries", + "merge_todo_projection_fields", + "summary_items", +] diff --git a/loopx/todos.py b/loopx/todos.py index ce0de7db1b..cde8c915a4 100644 --- a/loopx/todos.py +++ b/loopx/todos.py @@ -11,11 +11,7 @@ from .paths import resolve_runtime_root from .rollout_event_log import load_rollout_events, rollout_event_log_path from .state_refresh import now_local, resolve_goal_state -from .status import ( - MAX_ACTIVE_DONE_TODOS_BEFORE_ARCHIVE, - active_state_event_projection_fields, -) -from .control_plane.todos.active_state_todo_parser import parse_active_state_todos +from .status import MAX_ACTIVE_DONE_TODOS_BEFORE_ARCHIVE from .control_plane.todos.contract import ( TodoContinuationPolicy, TODO_STATUS_DEFERRED, @@ -91,16 +87,15 @@ AGENT_LANE_OVERLAY_FULL_DETAIL_COLD_PATH, EXPLICIT_LIMIT_OVERLAY_FULL_DETAIL_COLD_PATH, compact_agent_lane_todo_summary, - compact_explicit_limit_todo_summary, compact_thin_todo_list_payload, compact_todo_projection_overlay, todo_item_relations, todo_list_projection_contract, ) +from .control_plane.todos.goal_todo_projection import goal_todo_summaries from .control_plane.todos import monitor_metadata as todo_monitor_metadata from .control_plane.todos.external_wait_writeback import plan_todo_external_wait_update from .control_plane.todos.mutation_authority import authorize_todo_lifecycle_mutation, todo_update_authority_action -from .control_plane.todos.todo_summary import compact_todo_group, todo_item_status from .control_plane.todos.succession_warning import build_open_parent_successor_advisory from .control_plane.todos.todo_index import MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL from .control_plane.todos.text import ( @@ -178,194 +173,6 @@ def resolve_todo_state_path( return resolved_project, resolved_state_file -def empty_todo_summary(*, role: str) -> dict[str, Any]: - return { - "schema_version": "todo_summary_v0", - "role": role, - "source_section": TODO_SECTION_HEADINGS[role], - "total_count": 0, - "open_count": 0, - "done_count": 0, - "items": [], - "first_open_items": [], - } - - -def _user_todo_visible_to_agent(item: dict[str, Any], agent_id: str) -> bool: - if bool(item.get("global_gate")): - return True - blocks_agent = normalize_todo_blocks_agent(item.get("blocks_agent")) - if blocks_agent: - return blocks_agent == agent_id - bound_agent = normalize_todo_bound_agent(item.get("bound_agent")) - if bound_agent: - return bound_agent == agent_id - return True - - -def filtered_todo_summary( - summary: dict[str, Any] | None, - *, - role: str, - status: str | None = None, - todo_id: str | None = None, - agent_id: str | None = None, - resume_source_items: list[dict[str, Any]] | None = None, - rollout_events: list[dict[str, Any]] | None = None, - item_limit: int | None = None, -) -> dict[str, Any]: - items = list((summary or {}).get("items") or []) - normalized_status = normalize_todo_status(status) - if normalized_status: - items = [item for item in items if todo_item_status(item) == normalized_status] - normalized_todo_id = normalize_todo_id(todo_id) if todo_id else None - if normalized_todo_id: - items = [ - item - for item in items - if normalize_todo_id(item.get("todo_id")) == normalized_todo_id - ] - normalized_agent_id = normalize_todo_claimed_by(agent_id) if agent_id else None - if normalized_agent_id: - if role == "agent": - items = [ - item - for item in items - if normalized_agent_id - not in normalize_todo_excluded_agents(item.get("excluded_agents")) - and ( - not normalize_todo_claimed_by(item.get("claimed_by")) - or normalize_todo_claimed_by(item.get("claimed_by")) - == normalized_agent_id - ) - ] - elif role == "user": - items = [ - item - for item in items - if _user_todo_visible_to_agent(item, normalized_agent_id) - ] - source_section = str((summary or {}).get("source_section") or TODO_SECTION_HEADINGS[role]) - return ( - compact_todo_group( - items, - source_section=source_section, - role=role, - resume_source_items=resume_source_items, - rollout_events=rollout_events, - item_limit=item_limit, - ) - or empty_todo_summary(role=role) - ) - - -def _summary_items(fields: dict[str, Any], role: str) -> list[dict[str, Any]]: - summary = fields.get(f"{role}_todos") if isinstance(fields, dict) else None - if not isinstance(summary, dict): - return [] - return [item for item in summary.get("items") or [] if isinstance(item, dict)] - - -def _merge_todo_projection_fields( - *, - markdown_fields: dict[str, Any], - event_fields: dict[str, Any], -) -> tuple[dict[str, Any], dict[str, Any]]: - merged: dict[str, Any] = {} - merged_items: dict[str, list[dict[str, Any]]] = {"user": [], "agent": []} - source_sections: dict[str, str] = {} - overlay: dict[str, Any] = { - "schema_version": "todo_list_projection_overlay_v0", - "base": "markdown_active_state", - "overlay": "event_projection", - "markdown_only_todo_ids": [], - "event_only_todo_ids": [], - "overlaid_todo_ids": [], - } - - # A todo_id is goal-wide identity. Merge both sources before splitting by - # role so an event-projected role change replaces the stale Markdown item. - by_id: dict[str, dict[str, Any]] = {} - order: list[str] = [] - markdown_ids: set[str] = set() - markdown_ids_by_role: dict[str, set[str]] = {"user": set(), "agent": set()} - event_ids: set[str] = set() - event_order: list[str] = [] - for role in ("user", "agent"): - markdown_items = _summary_items(markdown_fields, role) - for item in markdown_items: - todo_id = normalize_todo_id(item.get("todo_id")) or build_todo_id( - role=role, - source_section=item.get("source_section"), - index=item.get("index"), - text=item.get("text"), - ) - if todo_id not in by_id: - order.append(todo_id) - markdown_ids.add(todo_id) - markdown_ids_by_role[role].add(todo_id) - by_id[todo_id] = dict(item) - - for role in ("user", "agent"): - event_items = _summary_items(event_fields, role) - for item in event_items: - todo_id = normalize_todo_id(item.get("todo_id")) or build_todo_id( - role=role, - source_section=item.get("source_section"), - index=item.get("index"), - text=item.get("text"), - ) - if todo_id not in by_id: - order.append(todo_id) - if todo_id not in event_ids: - event_order.append(todo_id) - event_ids.add(todo_id) - by_id[todo_id] = dict(item) - - markdown_only_todo_ids: list[str] = [] - seen_markdown_only_ids: set[str] = set() - for role in ("user", "agent"): - for todo_id in sorted(markdown_ids_by_role[role] - event_ids): - if todo_id not in seen_markdown_only_ids: - markdown_only_todo_ids.append(todo_id) - seen_markdown_only_ids.add(todo_id) - overlay["markdown_only_todo_ids"] = markdown_only_todo_ids - overlay["event_only_todo_ids"] = [ - todo_id for todo_id in event_order if todo_id not in markdown_ids - ] - overlay["overlaid_todo_ids"] = [ - todo_id for todo_id in event_order if todo_id in markdown_ids - ] - - for todo_id in order: - item = by_id[todo_id] - final_role = "user" if item.get("role") == "user" else "agent" - merged_items[final_role].append(item) - - for role in ("user", "agent"): - source_section = str( - (markdown_fields.get(f"{role}_todos") or {}).get("source_section") - or (event_fields.get(f"{role}_todos") or {}).get("source_section") - or TODO_SECTION_HEADINGS[role] - ) - source_sections[role] = source_section - - resume_source_items = [*merged_items["user"], *merged_items["agent"]] - for role in ("user", "agent"): - if not merged_items[role]: - continue - summary = compact_todo_group( - merged_items[role], - source_section=source_sections[role], - role=role, - resume_source_items=resume_source_items, - item_limit=None, - ) - if summary: - merged[f"{role}_todos"] = summary - return merged, overlay - - def list_goal_todos( *, registry_path: Path, @@ -406,71 +213,25 @@ def list_goal_todos( limit=MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL, ) - projection_fields = active_state_event_projection_fields( + roles = [role] if role else ["user", "agent"] + projected = goal_todo_summaries( goal, + state_text=resolved_state_file.read_text(encoding="utf-8"), state_path=resolved_state_file, - item_limit=None, - rollout_events=rollout_events, - ) - projection_has_todos = bool( - projection_fields.get("user_todos") or projection_fields.get("agent_todos") - ) - markdown_fields = parse_active_state_todos( - resolved_state_file.read_text(encoding="utf-8"), - goal=goal, - state_path=resolved_state_file, - item_limit=None, rollout_events=rollout_events, + roles=roles, + status=status, + todo_id=normalized_todo_id, + agent_id=normalized_agent_id, + limit=limit, ) - markdown_has_todos = bool( - markdown_fields.get("user_todos") or markdown_fields.get("agent_todos") - ) - projection_overlay: dict[str, Any] | None = None - if projection_has_todos and markdown_has_todos: - fields, projection_overlay = _merge_todo_projection_fields( - markdown_fields=markdown_fields, - event_fields=projection_fields, - ) - source = "event_projection_with_markdown_overlay" - elif projection_has_todos: - fields = projection_fields - source = "event_projection" - else: - fields = markdown_fields - source = "markdown_active_state" - - roles = [role] if role else ["user", "agent"] - resume_source_items = [ - *_summary_items(fields, "user"), - *_summary_items(fields, "agent"), - ] - summaries: dict[str, dict[str, Any]] = {} - todos: list[dict[str, Any]] = [] - unfiltered_count = 0 - uncapped_todo_count = 0 - for item_role in roles: - key = f"{item_role}_todos" - raw_summary = fields.get(key) if isinstance(fields, dict) else None - unfiltered_count += len((raw_summary or {}).get("items") or []) - summary = filtered_todo_summary( - raw_summary, - role=item_role, - status=status, - todo_id=normalized_todo_id, - agent_id=normalized_agent_id, - resume_source_items=resume_source_items, - rollout_events=rollout_events, - item_limit=limit, - ) - if limit is not None: - summary = compact_explicit_limit_todo_summary( - summary, - role=item_role, - item_limit=limit, - ) - summaries[key] = summary - todos.extend(summary.get("items") or []) - uncapped_todo_count += int(summary.get("total_count") or 0) + source = projected.source + projection_fields = projected.projection_fields + projection_overlay = projected.projection_overlay + summaries = projected.summaries + todos = projected.todos + unfiltered_count = projected.unfiltered_count + uncapped_todo_count = projected.uncapped_todo_count matched_todo_count = len(todos) agent_lane_hot_path = bool( From ea15267fed1b0f37274931aaacc2d32be60c17a6 Mon Sep 17 00:00:00 2001 From: wchwawa Date: Thu, 3 Sep 2026 10:54:46 +1000 Subject: [PATCH 8/8] fix(authority): route every observation hook through one effective runtime root Review finding on the previous head: with `common_runtime_root` different from the CLI `--runtime-root` override, Todo and follow-up hooks resolved the registry root while task-lease hooks used the override, so one goal produced two FileAuthorityStore lineages that both reported `captured`. The Todo-side head had no lease and the lease-side head had no Todo. - local_authority_shadow_adapter.effective_runtime_root resolves the one root of a CLI call: the override when given, else `common_runtime_root`, with a relative value anchored at the registry's project root instead of the caller's working directory. runtime_root_from_registry now anchors the same way, so lease files and observations of one goal share one root. - Todo add/update/complete/supersede/archive, follow-up capture, and handoff-mode set accept `runtime_root_arg`; the CLI passes its override through, and each writer hands the resolved root to its ownership gate, lease fence, and post-commit observation. Non-CLI callers keep the registry-root default. - E2E: a subprocess run with `common_runtime_root != --runtime-root` drives todo add, task-lease acquire, todo update, capture-followups, and a leased completion; exactly one store identity exists, the head holds both the todos and the released lease, and the registry root gains no lineage and no lease directory. A second test pins the relative-root anchoring for every hook from a foreign working directory. - SonarCloud: replace the default sort in local_authority_shadow.ts with an explicit comparator; split set_goal_handoff_mode and execute_native_task_lease_acquire into smaller helpers to bring their cognitive complexity under the limit; hoist the fixture out of the configure-goal exception test so only one call can raise. Signed-off-by: wchwawa --- loopx/cli.py | 1 + loopx/cli_commands/handoff_mode.py | 2 + loopx/cli_commands/todo.py | 7 + .../coordination/local_authority_shadow.ts | 3 +- .../local_authority_shadow_adapter.py | 29 +++- loopx/control_plane/todos/handoff_mode.py | 79 ++++++----- loopx/control_plane/work_items/task_lease.py | 23 +++- .../work_items/task_lease_acquire_adapter.py | 75 +++++++---- loopx/todo_followups.py | 4 +- loopx/todos.py | 31 ++++- .../test_local_authority_shadow_cli_e2e.py | 126 ++++++++++++++++++ .../test_local_authority_shadow_config.py | 3 +- .../test_local_authority_shadow_runtime.py | 44 ++++++ 13 files changed, 352 insertions(+), 75 deletions(-) diff --git a/loopx/cli.py b/loopx/cli.py index 82db882807..c2d2540c25 100644 --- a/loopx/cli.py +++ b/loopx/cli.py @@ -778,6 +778,7 @@ def main(argv: list[str] | None = None) -> int: registry_path=registry_path, output_format=output_format, print_payload=print_payload, + runtime_root_arg=args.runtime_root, ) if handoff_mode_result is not None: return handoff_mode_result diff --git a/loopx/cli_commands/handoff_mode.py b/loopx/cli_commands/handoff_mode.py index ace7b7d739..d6becd214a 100644 --- a/loopx/cli_commands/handoff_mode.py +++ b/loopx/cli_commands/handoff_mode.py @@ -101,6 +101,7 @@ def handle_handoff_mode_command( registry_path: Path, output_format: Callable[..., str], print_payload: PrintPayload, + runtime_root_arg: str | None = None, ) -> int | None: if args.command != "handoff-mode": return None @@ -124,6 +125,7 @@ def handle_handoff_mode_command( registry_path=registry_path, goal_id=args.goal_id, mode=args.mode, + runtime_root_arg=runtime_root_arg, **path_args, ) except HandoffModeError as exc: diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index 5f9a3a989f..6cdd9e1231 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -691,6 +691,7 @@ def handle_todo_command( ) payload = add_goal_todo( registry_path=registry_path, + runtime_root_arg=runtime_root_arg, goal_id=args.goal_id, role=args.role, text=args.text, @@ -751,6 +752,7 @@ def handle_todo_command( validate_todo_claim_options(args) payload = update_goal_todo( registry_path=registry_path, + runtime_root_arg=runtime_root_arg, goal_id=args.goal_id, todo_id=args.todo_id, role=args.role, @@ -764,6 +766,7 @@ def handle_todo_command( validate_todo_update_options(args) payload = update_goal_todo( registry_path=registry_path, + runtime_root_arg=runtime_root_arg, goal_id=args.goal_id, todo_id=args.todo_id, text=args.text, @@ -905,6 +908,7 @@ def handle_todo_command( if completion_error is None: payload = complete_goal_todo( registry_path=registry_path, + runtime_root_arg=runtime_root_arg, goal_id=args.goal_id, todo_id=args.todo_id, role=args.role, @@ -944,6 +948,7 @@ def handle_todo_command( validate_todo_supersede_options(args) payload = supersede_goal_todo( registry_path=registry_path, + runtime_root_arg=runtime_root_arg, goal_id=args.goal_id, todo_id=args.todo_id, role=args.role, @@ -969,6 +974,7 @@ def handle_todo_command( validate_todo_archive_completed_options(args) payload = archive_completed_todos( registry_path=registry_path, + runtime_root_arg=runtime_root_arg, goal_id=args.goal_id, role=args.role or "agent", max_active_done=args.max_active_done, @@ -993,6 +999,7 @@ def handle_todo_command( followups.append(args.text) payload = capture_followup_todos( registry_path=registry_path, + runtime_root_arg=runtime_root_arg, goal_id=args.goal_id, followups=followups, evidence=args.evidence or "", diff --git a/loopx/control_plane/coordination/local_authority_shadow.ts b/loopx/control_plane/coordination/local_authority_shadow.ts index e35e0f25bc..b455cf5e04 100644 --- a/loopx/control_plane/coordination/local_authority_shadow.ts +++ b/loopx/control_plane/coordination/local_authority_shadow.ts @@ -79,8 +79,9 @@ function decodeRequest(value: unknown): LocalAuthorityShadowRequest { const request = requireJsonObject(value, "local authority shadow request"); const unexpected = Object.keys(request).filter((field) => !REQUEST_FIELDS.has(field)); if (unexpected.length > 0) { + const listed = [...unexpected].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); throw new EffectRuntimeRequestError( - `Local authority shadow request has unsupported fields: ${unexpected.sort().join(", ")}`, + `Local authority shadow request has unsupported fields: ${listed.join(", ")}`, ); } if (request.schema_version !== LOCAL_AUTHORITY_SHADOW_REQUEST_SCHEMA) { diff --git a/loopx/control_plane/coordination/local_authority_shadow_adapter.py b/loopx/control_plane/coordination/local_authority_shadow_adapter.py index 121d7cab43..fed81cb32f 100644 --- a/loopx/control_plane/coordination/local_authority_shadow_adapter.py +++ b/loopx/control_plane/coordination/local_authority_shadow_adapter.py @@ -134,6 +134,24 @@ def apply_local_authority_shadow_change( goal.pop("coordination", None) +def effective_runtime_root( + registry_path: Path, + runtime_root_override: str | Path | None, +) -> Path: + """Resolve the one runtime root every writer hook of a CLI call must share. + + ``--runtime-root`` wins when given; otherwise the registry's + ``common_runtime_root`` applies, and a relative value resolves against the + registry's project root rather than the caller's working directory. Todo, + follow-up, handoff-mode, and task-lease hooks all consume this value so one + goal never splits into two candidate lineages. + """ + + registry = load_registry(registry_path) + override = str(runtime_root_override) if runtime_root_override is not None else None + return resolve_runtime_root(registry, override, registry_path=registry_path) + + def _base_evidence( *, goal_id: str, @@ -433,8 +451,14 @@ def observe_todo_local_authority_commit( registry_path: Path, goal_id: str, write_class: str, + *, + runtime_root: Path | None = None, ) -> dict[str, Any]: - """Attach post-commit shadow evidence without changing the Todo verdict.""" + """Attach post-commit shadow evidence without changing the Todo verdict. + + ``runtime_root`` is the effective root the writer resolved for this call; + ``None`` falls back to the registry root exactly as the other hooks do. + """ changed = any( payload.get(field) @@ -446,7 +470,7 @@ def observe_todo_local_authority_commit( updated_at = str(payload.get("updated_at") or "unknown") evidence = observe_local_authority_commit( registry_path=registry_path, - runtime_root=None, + runtime_root=runtime_root, goal_id=goal_id, observation_trigger=f"{write_class}:{todo_id}:{updated_at}", ) @@ -459,6 +483,7 @@ def observe_todo_local_authority_commit( "LOCAL_AUTHORITY_SHADOW_CONFIG_SCHEMA", "LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA", "apply_local_authority_shadow_change", + "effective_runtime_root", "local_authority_shadow_summary", "observe_local_authority_commit", "observe_todo_local_authority_commit", diff --git a/loopx/control_plane/todos/handoff_mode.py b/loopx/control_plane/todos/handoff_mode.py index ba5469ebdc..80e0af5d46 100644 --- a/loopx/control_plane/todos/handoff_mode.py +++ b/loopx/control_plane/todos/handoff_mode.py @@ -138,6 +138,7 @@ def enter_todo_ownership_handoff_gate( mutation_authority: dict[str, Any], actor_agent_id: str | None, ownership_mutation: bool, + runtime_root: Path | None = None, ) -> dict[str, Any]: """Gate one claimed_by mutation on an existing todo behind the goal mode. @@ -168,6 +169,7 @@ def enter_todo_ownership_handoff_gate( goal_id=goal_id, todo_id=todo_id, actor_agent_id=actor_agent_id, + runtime_root=runtime_root, ) ) return extras @@ -184,6 +186,7 @@ def enter_added_todo_ownership_handoff_gate( text: str, claimed_by: str | None, actor_agent_id: str | None, + runtime_root: Path | None = None, ) -> dict[str, Any]: """Gate the ``todo add`` path when it would reassign an existing todo. @@ -225,6 +228,7 @@ def enter_added_todo_ownership_handoff_gate( and requested is not None and requested != current ), + runtime_root=runtime_root, ) @@ -294,6 +298,7 @@ def _quiescence_offenders( registry_path: Path, goal_id: str, state_text: str, + runtime_root: Path | None = None, ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: """Return blockers visible to the v0 materialized-state scan. @@ -329,7 +334,8 @@ def _quiescence_offenders( } ) leases: list[dict[str, Any]] = [] - runtime_root = runtime_root_from_registry(registry_path, None) + if runtime_root is None: + runtime_root = runtime_root_from_registry(registry_path, None) lease_dir = task_lease_dir(runtime_root=runtime_root, goal_id=goal_id) if lease_dir.exists(): for path in sorted(lease_dir.glob("todo_*.json")): @@ -359,6 +365,34 @@ def _authority_offender_tokens( ) +def _previous_handoff_mode_fields( + previous_raw: object, +) -> tuple[str, dict[str, Any]]: + """Type the persisted front-matter mode; invalid values stay reportable.""" + + try: + previous = normalize_handoff_mode(previous_raw) + except HandoffModeError as exc: + previous = str(previous_raw or "").strip() + return previous, { + "previous_mode": previous, + "previous_mode_valid": False, + "previous_mode_error_code": exc.code, + } + return previous, {"previous_mode": previous, "previous_mode_valid": True} + + +def _write_handoff_mode_frontmatter(lines: list[str], requested: str) -> None: + """Replace or insert the handoff_mode key inside the front-matter block.""" + + open_index, close_index = _frontmatter_bounds(lines) + for index in range(open_index, close_index): + if lines[index].split(":", 1)[0].strip() == HANDOFF_MODE_FRONTMATTER_KEY: + lines[index] = f"{HANDOFF_MODE_FRONTMATTER_KEY}: {requested}" + return + lines.insert(close_index, f"{HANDOFF_MODE_FRONTMATTER_KEY}: {requested}") + + def set_goal_handoff_mode( *, registry_path: Path, @@ -366,6 +400,7 @@ def set_goal_handoff_mode( mode: str, project: Path | None = None, state_file: Path | None = None, + runtime_root_arg: str | None = None, ) -> dict[str, Any]: """Set the goal handoff mode; requires a quiescent goal for transitions. @@ -399,25 +434,14 @@ def set_goal_handoff_mode( project=project, state_file=state_file, ) + # One effective runtime root for the lease lock, the quiescence scan, and + # the post-commit observation of this call. + runtime_root = runtime_root_from_registry(registry_path, runtime_root_arg) with exclusive_file_lock(resolved_state_file, operation="handoff_mode_set"): original = resolved_state_file.read_text(encoding="utf-8") - previous_raw = parse_state_frontmatter(original).get( - HANDOFF_MODE_FRONTMATTER_KEY + previous, previous_mode_fields = _previous_handoff_mode_fields( + parse_state_frontmatter(original).get(HANDOFF_MODE_FRONTMATTER_KEY) ) - try: - previous = normalize_handoff_mode(previous_raw) - except HandoffModeError as exc: - previous = str(previous_raw or "").strip() - previous_mode_fields = { - "previous_mode": previous, - "previous_mode_valid": False, - "previous_mode_error_code": exc.code, - } - else: - previous_mode_fields = { - "previous_mode": previous, - "previous_mode_valid": True, - } payload = { "ok": True, "schema_version": HANDOFF_MODE_SCHEMA_VERSION, @@ -430,15 +454,13 @@ def set_goal_handoff_mode( if previous == requested: payload["changed"] = False return payload - lease_lock = task_lease_lock_path( - runtime_root=runtime_root_from_registry(registry_path, None), - goal_id=goal_id, - ) + lease_lock = task_lease_lock_path(runtime_root=runtime_root, goal_id=goal_id) with exclusive_file_lock(lease_lock, operation="handoff_mode_set"): claimed, leases = _quiescence_offenders( registry_path=registry_path, goal_id=goal_id, state_text=original, + runtime_root=runtime_root, ) requested_core_mode = HandoffMode(requested) if previous in HANDOFF_MODE_VALUES: @@ -494,18 +516,7 @@ def set_goal_handoff_mode( }, ) lines = original.splitlines() - open_index, close_index = _frontmatter_bounds(lines) - for index in range(open_index, close_index): - if ( - lines[index].split(":", 1)[0].strip() - == HANDOFF_MODE_FRONTMATTER_KEY - ): - lines[index] = f"{HANDOFF_MODE_FRONTMATTER_KEY}: {requested}" - break - else: - lines.insert( - close_index, f"{HANDOFF_MODE_FRONTMATTER_KEY}: {requested}" - ) + _write_handoff_mode_frontmatter(lines, requested) new_text = "\n".join(lines) + ("\n" if original.endswith("\n") else "") resolved_state_file.write_text(new_text, encoding="utf-8") payload["changed"] = True @@ -515,7 +526,7 @@ def set_goal_handoff_mode( evidence = observe_local_authority_commit( registry_path=registry_path, - runtime_root=runtime_root_from_registry(registry_path, None), + runtime_root=runtime_root, goal_id=goal_id, observation_trigger=f"handoff_mode_set:{previous}:{requested}", ) diff --git a/loopx/control_plane/work_items/task_lease.py b/loopx/control_plane/work_items/task_lease.py index ebcdf5e9d8..f46d97eea2 100644 --- a/loopx/control_plane/work_items/task_lease.py +++ b/loopx/control_plane/work_items/task_lease.py @@ -262,6 +262,7 @@ def hold_handoff_lease_holder_gate( goal_id: str, todo_id: str, actor_agent_id: str | None, + runtime_root: Path | None = None, ) -> Iterator[dict[str, Any]]: """Hold the per-goal lease lock while proving the actor owns the lease. @@ -289,7 +290,8 @@ def hold_handoff_lease_holder_gate( "reason": "missing_actor", }, ) - runtime_root = runtime_root_from_registry(registry_path, None) + if runtime_root is None: + runtime_root = runtime_root_from_registry(registry_path, None) result = _execute_native_task_lease_lifecycle( runtime_root=runtime_root, registry_path=registry_path, @@ -362,6 +364,7 @@ def hold_task_lease_mutation_fence( expected_version: int | None = None, require_active_when_key_supplied: bool = True, handoff: dict[str, Any] | None = None, + runtime_root: Path | None = None, ) -> Iterator[dict[str, Any]]: """Hold the per-goal lease lock while one todo lifecycle write commits. @@ -384,7 +387,8 @@ def hold_task_lease_mutation_fence( normalized_goal_id = normalize_goal_id(goal_id) normalized_todo_id = normalize_lease_todo_id(todo_id) - runtime_root = runtime_root_from_registry(registry_path, None) + if runtime_root is None: + runtime_root = runtime_root_from_registry(registry_path, None) handoff = handoff or {} result = _execute_native_task_lease_lifecycle( runtime_root=runtime_root, @@ -474,6 +478,7 @@ def enter_terminal_todo_lease_fence( mutation_authority: dict[str, Any], idempotency_key: str | None = None, expected_version: int | None = None, + runtime_root: Path | None = None, ) -> tuple[dict[str, Any], dict[str, Any]]: """Resolve the goal handoff and hold the lease fence for one terminal write. @@ -502,6 +507,7 @@ def enter_terminal_todo_lease_fence( idempotency_key is not None or expected_version is not None ), handoff=handoff, + runtime_root=runtime_root, ) ) return handoff, fence @@ -552,8 +558,19 @@ def runtime_root_from_registry( registry_path: Path, runtime_root_override: str | None, ) -> Path: + """Effective runtime root for lease state: override, else registry root. + + A relative ``common_runtime_root`` resolves against the registry's project + root, matching the observation hooks, so the lease files and every + candidate observation of one goal share a single root. + """ + registry = load_registry(registry_path) - return resolve_runtime_root(registry, runtime_root_override) + return resolve_runtime_root( + registry, + runtime_root_override, + registry_path=registry_path, + ) def task_lease_todo_projection( diff --git a/loopx/control_plane/work_items/task_lease_acquire_adapter.py b/loopx/control_plane/work_items/task_lease_acquire_adapter.py index ddd4193804..e277030df8 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_adapter.py +++ b/loopx/control_plane/work_items/task_lease_acquire_adapter.py @@ -318,6 +318,43 @@ def task_lease_acquire_authority_facts( ) +def _require_native_acquire_shape(payload: object) -> dict[str, Any]: + if ( + not isinstance(payload, dict) + or payload.get("schema_version") != TASK_LEASE_SCHEMA_VERSION + or payload.get("action") != "acquire" + or not isinstance(payload.get("ok"), bool) + ): + raise RuntimeError("native task-lease acquire result shape mismatch") + return payload + + +def _finalize_native_acquire_result( + payload: dict[str, Any], + *, + authority: dict[str, Any], + registry_path: Path, + runtime_root: Path, + goal_id: str, + todo_id: str, + legacy_provider_projection: bool, +) -> dict[str, Any]: + result = dict(payload) + if legacy_provider_projection and result.get("ok") is True: + result["handoff_mode"] = authority.get("handoff_mode") or HANDOFF_MODE_LEGACY + result.pop("settlement", None) + if result.get("ok") is True and result.get("acquired") is True: + result = _attach_local_authority_shadow( + result, + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + todo_id=todo_id, + operation="acquire", + ) + return result + + def execute_native_task_lease_acquire( *, registry_path: Path, @@ -353,39 +390,23 @@ def execute_native_task_lease_acquire( "expected_version": expected_version, "authority": authority, } - payload = effect_runtime_result( - "task_lease.acquire.native", - request, - timeout=15.0, + payload = _require_native_acquire_shape( + effect_runtime_result("task_lease.acquire.native", request, timeout=15.0) ) - if ( - not isinstance(payload, dict) - or payload.get("schema_version") != TASK_LEASE_SCHEMA_VERSION - or payload.get("action") != "acquire" - or not isinstance(payload.get("ok"), bool) - ): - raise RuntimeError("native task-lease acquire result shape mismatch") if ( payload.get("error_code") == "authority_source_changed" and attempt + 1 < TASK_LEASE_AUTHORITY_SNAPSHOT_ATTEMPTS ): continue - result = dict(payload) - if _legacy_provider_projection and result.get("ok") is True: - result["handoff_mode"] = ( - authority.get("handoff_mode") or HANDOFF_MODE_LEGACY - ) - result.pop("settlement", None) - if result.get("ok") is True and result.get("acquired") is True: - result = _attach_local_authority_shadow( - result, - registry_path=registry_path, - runtime_root=runtime_root, - goal_id=str(goal_id), - todo_id=str(todo_id), - operation="acquire", - ) - return result + return _finalize_native_acquire_result( + payload, + authority=authority, + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=str(goal_id), + todo_id=str(todo_id), + legacy_provider_projection=_legacy_provider_projection, + ) raise RuntimeError("native task-lease acquire exhausted source-CAS retries") diff --git a/loopx/todo_followups.py b/loopx/todo_followups.py index 072c4730b6..a3dd7910c1 100644 --- a/loopx/todo_followups.py +++ b/loopx/todo_followups.py @@ -66,6 +66,7 @@ def capture_followup_todos( project: Path | None = None, state_file: Path | None = None, dry_run: bool = False, + runtime_root_arg: str | None = None, ) -> dict[str, Any]: if not followups: raise ValueError("todo capture-followups requires at least one --follow-up") @@ -168,12 +169,13 @@ def capture_followup_todos( } if changed and not dry_run: from .control_plane.coordination.local_authority_shadow_adapter import ( + effective_runtime_root, observe_local_authority_commit, ) shadow = observe_local_authority_commit( registry_path=registry_path, - runtime_root=None, + runtime_root=effective_runtime_root(registry_path, runtime_root_arg), goal_id=goal_id, observation_trigger=( f"todo_capture_followups:{recorded_count}:{updated_at}" diff --git a/loopx/todos.py b/loopx/todos.py index cde8c915a4..9e76944e8f 100644 --- a/loopx/todos.py +++ b/loopx/todos.py @@ -122,6 +122,7 @@ resolve_todo_completion_handoff, ) from .control_plane.coordination.local_authority_shadow_adapter import ( + effective_runtime_root, observe_todo_local_authority_commit as _shadow_todo, ) from .control_plane.work_items.task_lease import ( @@ -637,6 +638,7 @@ def add_goal_todo( *, registry_path: Path, goal_id: str, + runtime_root_arg: str | None = None, role: str, text: str, status: str | None = None, @@ -672,6 +674,7 @@ def add_goal_todo( state_file: Path | None = None, dry_run: bool = False, ) -> dict[str, Any]: + shadow_runtime_root = effective_runtime_root(registry_path, runtime_root_arg) if role not in TODO_SECTION_HEADINGS: raise ValueError("todo role must be one of: user, agent") require_user_todo_task_class( @@ -836,6 +839,7 @@ def add_goal_todo( text=todo_text, claimed_by=effective_claimed_by, actor_agent_id=effective_agent_id or effective_claimed_by, + runtime_root=shadow_runtime_root, ) add_result = add_todo_to_lines( lines, @@ -934,7 +938,7 @@ def add_goal_todo( write_class="todo_add", state_text=original, ) - return _shadow_todo(payload, registry_path, goal_id, "todo_add") + return _shadow_todo(payload, registry_path, goal_id, "todo_add", runtime_root=shadow_runtime_root) def resolve_todo_state( @@ -958,6 +962,7 @@ def update_goal_todo( *, registry_path: Path, goal_id: str, + runtime_root_arg: str | None = None, todo_id: str, text: str | None = None, status: str | None = None, @@ -998,6 +1003,7 @@ def update_goal_todo( state_file: Path | None = None, dry_run: bool = False, ) -> dict[str, Any]: + shadow_runtime_root = effective_runtime_root(registry_path, runtime_root_arg) if excluded_agents and clear_excluded_agents: raise ValueError( "todo update accepts either excluded_agents or clear_excluded_agents, not both" @@ -1137,6 +1143,7 @@ def update_goal_todo( mutation_authority=mutation_authority, actor_agent_id=effective_agent_id or effective_claimed_by, ownership_mutation=(claimed_by is not None or clear_claim) and target_role == "agent", + runtime_root=shadow_runtime_root, ) target_task_class = task_class or str(existing_block.get("task_class") or "") if target_role == "user" and claimed_by: @@ -1393,13 +1400,14 @@ def update_goal_todo( write_class=write_class, state_text=original, ) - return _shadow_todo(payload, registry_path, goal_id, write_class) + return _shadow_todo(payload, registry_path, goal_id, write_class, runtime_root=shadow_runtime_root) def complete_goal_todo( *, registry_path: Path, goal_id: str, + runtime_root_arg: str | None = None, todo_id: str, role: str | None = None, decision_outcome: str | None = None, @@ -1429,6 +1437,7 @@ def complete_goal_todo( state_file: Path | None = None, dry_run: bool = False, ) -> dict[str, Any]: + shadow_runtime_root = effective_runtime_root(registry_path, runtime_root_arg) if next_task_repository and not next_agent_todo: raise ValueError("--next-task-repository requires --next-agent-todo") if next_required_capabilities and not next_agent_todo: @@ -1554,6 +1563,7 @@ def complete_goal_todo( or task_lease_expected_version is not None ), handoff=completion_handoff, + runtime_root=shadow_runtime_root, ) ) completion_state = completion_transaction.get("completion_state") @@ -1625,7 +1635,9 @@ def complete_goal_todo( committed=bool(event_result.get("changed")) and not dry_run, ) write_class = "todo_complete_event_projection" - return _shadow_todo(event_result, registry_path, goal_id, write_class) + return _shadow_todo( + event_result, registry_path, goal_id, write_class, runtime_root=shadow_runtime_root + ) if not isinstance(completion_state, dict): raise RuntimeError( "TypeScript Todo completion transaction did not authorize a commit" @@ -1783,12 +1795,13 @@ def complete_goal_todo( if effective_decision_outcome: result["decision_outcome"] = effective_decision_outcome result["self_merged"] = effective_self_merged - return _shadow_todo(result, registry_path, goal_id, "todo_complete") + return _shadow_todo(result, registry_path, goal_id, "todo_complete", runtime_root=shadow_runtime_root) def supersede_goal_todo( *, registry_path: Path, goal_id: str, + runtime_root_arg: str | None = None, todo_id: str, role: str | None = None, reason: str | None = None, @@ -1808,6 +1821,7 @@ def supersede_goal_todo( state_file: Path | None = None, dry_run: bool = False, ) -> dict[str, Any]: + shadow_runtime_root = effective_runtime_root(registry_path, runtime_root_arg) if next_task_repository and not next_agent_todo: raise ValueError("--next-task-repository requires --next-agent-todo") if next_required_capabilities and not next_agent_todo: @@ -1848,6 +1862,7 @@ def supersede_goal_todo( lease_fence_stack, registry_path=registry_path, goal_id=goal_id, todo_id=todo_id, todo=authority_todo, actor_agent_id=agent_id, state_text=original, mutation_authority=mutation_authority, idempotency_key=task_lease_idempotency_key, expected_version=task_lease_expected_version, + runtime_root=shadow_runtime_root, ) effective_next_claimed_by = ( require_registered_agent_id(registry_path=registry_path, goal_id=goal_id, agent_id=next_claimed_by, field="next_claimed_by") @@ -1992,19 +2007,21 @@ def supersede_goal_todo( "project": str(resolved_project) if resolved_project else None, "updated_at": updated_at if changed else None, } - return _shadow_todo(result, registry_path, goal_id, "todo_supersede") + return _shadow_todo(result, registry_path, goal_id, "todo_supersede", runtime_root=shadow_runtime_root) def archive_completed_todos( *, registry_path: Path, goal_id: str, + runtime_root_arg: str | None = None, role: str = "agent", max_active_done: int = ARCHIVE_COMPLETED_DEFAULT_MAX_ACTIVE_DONE, project: Path | None = None, state_file: Path | None = None, dry_run: bool = True, ) -> dict[str, Any]: + shadow_runtime_root = effective_runtime_root(registry_path, runtime_root_arg) if role not in TODO_SECTION_HEADINGS: raise ValueError("todo role must be one of: user, agent") if max_active_done < 0: @@ -2043,4 +2060,6 @@ def archive_completed_todos( "project": str(resolved_project) if resolved_project else None, "updated_at": updated_at if changed else None, } - return _shadow_todo(result, registry_path, goal_id, "todo_archive_completed") + return _shadow_todo( + result, registry_path, goal_id, "todo_archive_completed", runtime_root=shadow_runtime_root + ) diff --git a/tests/control_plane/test_local_authority_shadow_cli_e2e.py b/tests/control_plane/test_local_authority_shadow_cli_e2e.py index aedeb092e1..8ab7cccd0d 100644 --- a/tests/control_plane/test_local_authority_shadow_cli_e2e.py +++ b/tests/control_plane/test_local_authority_shadow_cli_e2e.py @@ -341,3 +341,129 @@ def test_product_cli_loses_capture_between_commit_and_observer_then_refreshes_sn assert recovered["authority_shadow"]["parity_verdict"] == "not_evaluated" # type: ignore[index] _store_path, store = _store_document(runtime_root, goal_id) assert len(store["head"]["todos"]) == 2 # type: ignore[index] + + +def test_product_cli_runtime_root_override_keeps_one_candidate_lineage( + tmp_path: Path, +) -> None: + """``--runtime-root`` differs from ``common_runtime_root``: one lineage, one head. + + Every writer family of one CLI invocation must observe into the same + candidate store: Todo add, task-lease acquire, Todo update, follow-up + capture, and a leased completion. The registry root must not gain a + candidate lineage of its own. + """ + + goal_id = "shadow-cli-one-root" + registry, state, registry_runtime = _workspace(tmp_path, goal_id=goal_id) + override_runtime = tmp_path / f"{goal_id}-override-runtime" + assert override_runtime != registry_runtime + _cli( + registry, + override_runtime, + "configure-goal", + "--goal-id", + goal_id, + "--local-authority-shadow-file", + "--execute", + ) + + added = _add_todo( + registry, + override_runtime, + goal_id=goal_id, + text="Every hook of this call shares one runtime root.", + ) + todo_id = str(added["todo_id"]) + lease = _cli( + registry, + override_runtime, + "task-lease", + "acquire", + "--goal-id", + goal_id, + "--todo-id", + todo_id, + "--owner", + "agent-a", + "--idempotency-key", + "one-root-lease", + "--ttl-seconds", + "120", + ) + assert lease["acquired"] is True + updated = _cli( + registry, + override_runtime, + "todo", + "update", + "--goal-id", + goal_id, + "--todo-id", + todo_id, + "--note", + "Observed under the override root.", + "--agent-id", + "agent-a", + ) + assert updated["changed"] is True + followups = _cli( + registry, + override_runtime, + "todo", + "capture-followups", + "--goal-id", + goal_id, + "--follow-up", + "Verify that one goal keeps one candidate lineage.", + "--evidence", + "validation://one-root-followup", + ) + assert followups["changed"] is True + completed = _cli( + registry, + override_runtime, + "todo", + "complete", + "--goal-id", + goal_id, + "--todo-id", + todo_id, + "--agent-id", + "agent-a", + "--task-lease-idempotency-key", + "one-root-lease", + "--task-lease-expected-version", + str(lease["lease"]["version"]), # type: ignore[index] + "--evidence", + "validation://one-root-complete", + "--no-follow-up", + ) + assert completed["completed"] is True + + responses = { + "todo add": added, + "task-lease acquire": lease, + "todo update": updated, + "todo capture-followups": followups, + "todo complete": completed, + } + for label, payload in responses.items(): + evidence = payload["authority_shadow"] + assert evidence["outcome"] == "captured", label # type: ignore[index] + identities = {payload["authority_shadow"]["store_identity"] for payload in responses.values()} # type: ignore[index] + assert len(identities) == 1 + store_path, store = _store_document(override_runtime, goal_id) + assert store["store_identity"] == identities.pop() + assert store["cursor"] == str(len(responses)) + head_todo_ids = {todo["todo_id"] for todo in store["head"]["todos"]} # type: ignore[index] + assert todo_id in head_todo_ids + assert len(head_todo_ids) == 2 + [lease_record] = store["head"]["leases"] # type: ignore[index] + assert lease_record["todo_id"] == todo_id + assert lease_record["status"] == "released" + assert (override_runtime / "goals" / goal_id / "task-leases" / f"{todo_id}.json").exists() + assert not (registry_runtime / "authority-shadow").exists() + assert not (registry_runtime / "goals" / goal_id / "task-leases").exists() + assert store_path.is_relative_to(override_runtime) + assert todo_id in state.read_text(encoding="utf-8") diff --git a/tests/control_plane/test_local_authority_shadow_config.py b/tests/control_plane/test_local_authority_shadow_config.py index 7390ddb84d..dc8c8a6b32 100644 --- a/tests/control_plane/test_local_authority_shadow_config.py +++ b/tests/control_plane/test_local_authority_shadow_config.py @@ -106,9 +106,10 @@ def test_configure_goal_enables_and_clears_closed_file_shadow_config( def test_configure_goal_rejects_enable_and_clear_in_one_operation( tmp_path: Path, ) -> None: + registry = _registry(tmp_path) with pytest.raises(ValueError, match="cannot be combined"): configure_goal( - registry_path=_registry(tmp_path), + registry_path=registry, goal_id=GOAL_ID, local_authority_shadow_file=True, clear_local_authority_shadow=True, diff --git a/tests/control_plane/test_local_authority_shadow_runtime.py b/tests/control_plane/test_local_authority_shadow_runtime.py index 38d1025229..614045a959 100644 --- a/tests/control_plane/test_local_authority_shadow_runtime.py +++ b/tests/control_plane/test_local_authority_shadow_runtime.py @@ -521,3 +521,47 @@ def conflict_then_advance( assert requests[0]["observation_id"] != requests[1]["observation_id"] assert all(request["runtime_root"] == str(runtime_root) for request in requests) assert all("provider_directory" not in request for request in requests) + + +def test_relative_common_runtime_root_resolves_against_the_project_root_for_every_hook( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from loopx.control_plane.work_items.task_lease import runtime_root_from_registry + from loopx.paths import registry_project_root + + registry, _state, _absolute_runtime = _fixture(tmp_path, enabled=True) + document = json.loads(registry.read_text(encoding="utf-8")) + document["common_runtime_root"] = "runtime-relative" + registry.write_text(json.dumps(document), encoding="utf-8") + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + expected_root = registry_project_root(registry) / "runtime-relative" + + added = _add(registry) + lease = acquire_task_lease( + registry_path=registry, + runtime_root=runtime_root_from_registry(registry, None), + goal_id=GOAL_ID, + todo_id=str(added["todo_id"]), + owner=AGENT_A, + idempotency_key="relative-root", + ttl_seconds=120, + ) + handoff = set_goal_handoff_mode( + registry_path=registry, + goal_id=GOAL_ID, + mode="hard_lease", + ) + + assert added["authority_shadow"]["outcome"] == "captured" + assert lease["authority_shadow"]["outcome"] == "captured" + assert handoff["changed"] is False + assert added["authority_shadow"]["store_identity"] == lease["authority_shadow"]["store_identity"] + assert (expected_root / "authority-shadow" / "file" / GOAL_ID).is_dir() + assert (expected_root / "goals" / GOAL_ID / "task-leases" / f"{added['todo_id']}.json").exists() + assert not (elsewhere / "runtime-relative").exists() + document = _shadow_document(expected_root) + assert document["cursor"] == "2" + assert len(document["head"]["leases"]) == 1