From 2bf346a5d90a32480f1b69f76f018c4c6631d0cd Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:41:49 +0800 Subject: [PATCH 1/6] fix(coordination): bind promotion to current registration and retained intent Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../coordination/local_authority_runtime.ts | 36 +++++----- .../coordination/runtime_shadow.py | 28 +++++++- .../coordination/runtime_shadow.ts | 14 ++-- .../coordination/shadow_registry_source.ts | 67 +++++++++++++++++++ 4 files changed, 118 insertions(+), 27 deletions(-) create mode 100644 loopx/control_plane/coordination/shadow_registry_source.ts diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index d233c4d2bb..7fc2f6dc52 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -1,3 +1,4 @@ +import {requirePromotionRegisteredAgents} from "./shadow_registry_source.ts"; import {readPromotionReceipt, commitPromotionAndReadBack} from './promotion_receipt.ts'; import {reviewedPromotionPlan, promotionPlanDigest, decodeReviewedPromotionOperation, REVIEWED_PROMOTION_OPERATION_RESULT_SCHEMA} from './reviewed_promotion_plan.ts'; import {registryAuthoritySourceCheck} from "./authority_source.ts"; @@ -141,9 +142,7 @@ export async function reviewLocalCoordinationAuthorityPromotion( ): Promise { const schema = LOCAL_COORDINATION_PROMOTION_REVIEW_RESULT_SCHEMA; let writerFenceVerified = false; - const fenceEvidence: {current: {runtimeRoot: string; goalId: string; fence: JsonObject} | null} = { - current: null, - }; + let sourceScope: {runtimeRoot: string; goalId: string} | null = null; try { const input = decodeRuntimeShadowRequest( value, @@ -151,6 +150,7 @@ export async function reviewLocalCoordinationAuthorityPromotion( ["operation_id", "minimum_operations", "required_event_kinds", "execute", "handoff_mode_migration", "registered_agents", "expected_promotion_plan_sha256"], ); + sourceScope = {runtimeRoot: input.runtime_root, goalId: input.goal_id}; const operationId = requireAuthorityStoreId(input.operation_id, "operation id"); const minimumOperations = requiredPositiveSafeInteger( input.minimum_operations, @@ -278,7 +278,6 @@ export async function reviewLocalCoordinationAuthorityPromotion( } : {}), writer_fence: fence, }; - fenceEvidence.current = {runtimeRoot: input.runtime_root, goalId: input.goal_id, fence}; const existing = await canonical.loadAuthority(); if (existing.status === "loaded") { const readback = await promotionReadback(canonical, request); @@ -334,6 +333,7 @@ export async function reviewLocalCoordinationAuthorityPromotion( legacy_fallback_used: false, }; } + if (explicitHandoffMigration) requirePromotionRegisteredAgents(input.source_snapshot, registeredAgents); const plan = { reviewed_plan: reviewedPromotionPlan({schema_version:LOCAL_COORDINATION_PROMOTION_REQUEST_SCHEMA,...request}, promotionPlanSha256), operation_id: operationId, @@ -417,21 +417,15 @@ export async function reviewLocalCoordinationAuthorityPromotion( }), ); } catch (error) { - if (!writerFenceVerified && fenceEvidence.current !== null) { + // A fresh source rejection can occur before a plan is built. It must not + // tell an operator that legacy writes are available if an earlier cutover + // already fenced them. Presence and exact-plan ownership are distinct. + let fencePresence: boolean | null = writerFenceVerified; + if (sourceScope !== null) { try { - const evidence = fenceEvidence.current; - const persistedFence = await loadLegacyCoordinationWriterFence( - evidence.runtimeRoot, - evidence.goalId, - ); - writerFenceVerified = persistedFence.status === "loaded" - && canonicalAuthorityBytes(persistedFence.fence).equals( - canonicalAuthorityBytes(evidence.fence), - ); - } catch { - // The result below must not claim a fence that this call could not - // read back exactly. - } + const retained = await loadLegacyCoordinationWriterFence(sourceScope.runtimeRoot, sourceScope.goalId); + fencePresence = retained.status === "loaded" ? true : retained.status === "missing" ? false : null; + } catch { fencePresence = null; } } return { schema_version: schema, @@ -441,7 +435,7 @@ export async function reviewLocalCoordinationAuthorityPromotion( ? error.reason_code : "invalid_local_coordination_promotion_review_request", reason: error instanceof Error ? error.message : "promotion review unavailable", - legacy_writer_fenced: writerFenceVerified, + legacy_writer_fenced: fencePresence, legacy_fallback_used: false, ...localAuthorityOpenFailure(error), }; @@ -1518,6 +1512,10 @@ export async function executeReviewedCoordinationPromotion( operation_id:request.operation_id,minimum_operations:request.minimum_operations, required_event_kinds:request.required_event_kinds,execute:input.execute, expected_promotion_plan_sha256:input.expected_plan_sha256, + ...(request.handoff_mode_migration === undefined ? {} : { + handoff_mode_migration: request.handoff_mode_migration, + registered_agents: request.registered_agents, + }), projection:input.projection,source_snapshot:input.source_snapshot, },dependencies); return {...result, reviewed_plan_sha256:input.expected_plan_sha256, diff --git a/loopx/control_plane/coordination/runtime_shadow.py b/loopx/control_plane/coordination/runtime_shadow.py index ba58feb7c2..231f33e5b6 100644 --- a/loopx/control_plane/coordination/runtime_shadow.py +++ b/loopx/control_plane/coordination/runtime_shadow.py @@ -289,6 +289,32 @@ def capture_todo_archive_dependencies(todos: list[dict[str, Any]], state_text: s def build_runtime_shadow_source_snapshot( *, goal: Mapping[str, Any], runtime_root: Path, state_path: Path, registry_path: Path, +) -> tuple[dict[str, object], dict[str, object]]: + """Bind the supplied Goal and every derived fact to one registry observation.""" + from ...agent_registry import registered_agent_ids_for_goal + from ...history import load_registry + from ...registry import find_registry_goal + from .authority_source_capture import authority_registry_source + from .shadow_management import ShadowManagementError + + with authority_registry_source(registry_path) as witness: + registry = load_registry(registry_path) + current = find_registry_goal(registry, str(goal["id"])) + if current is None or current != dict(goal): + raise ShadowManagementError("source_registry_changed_retry") + projection, snapshot = _build_runtime_shadow_source_snapshot( + goal=current, runtime_root=runtime_root, state_path=state_path, + registry_path=registry_path, registry=registry, + ) + snapshot["registry_source"] = { + **witness, "registered_agents": registered_agent_ids_for_goal(current), + } + return projection, snapshot + + +def _build_runtime_shadow_source_snapshot( + *, goal: Mapping[str, Any], runtime_root: Path, state_path: Path, + registry_path: Path, registry: dict[str, Any], ) -> tuple[dict[str, object], dict[str, object]]: """Project exactly the bytes carried by one ephemeral source precondition. @@ -297,7 +323,6 @@ def build_runtime_shadow_source_snapshot( """ from ...event_sourced_state import build_state_projection, normalize_state_event, render_active_state_sections from ...rollout_event_log import ROLLOUT_EVENT_SCHEMA_VERSION, rollout_event_log_path - from ...history import load_registry from ...paths import resolve_runtime_root from ...state_refresh import resolve_goal_state from ..status.active_state_projection import state_event_log_candidates @@ -359,7 +384,6 @@ def read_evidence(path: Path) -> bytes | None: inventory.append({"name": path.name, "bytes_sha256": "sha256:" + hashlib.sha256(data).hexdigest()}) projection = build_todo_runtime_shadow_projection(goal_id=goal_id, todos=todos, leases=leases, handoff_mode=goal_handoff_mode(state_text)) - registry = load_registry(registry_path) registered_root = resolve_runtime_root(registry, None, registry_path=registry_path) _, _, registered_state = resolve_goal_state(registry=registry, goal_id=goal_id, project_override=None, state_file_override=None) diff --git a/loopx/control_plane/coordination/runtime_shadow.ts b/loopx/control_plane/coordination/runtime_shadow.ts index e6877bd2bb..308a7766d6 100644 --- a/loopx/control_plane/coordination/runtime_shadow.ts +++ b/loopx/control_plane/coordination/runtime_shadow.ts @@ -1,3 +1,4 @@ +import {verifyShadowRegistrySource, withShadowRegistrySource} from "./shadow_registry_source.ts"; import {projectCoordinationSource, SOURCE_PROJECTION_REQUEST_SCHEMA, currentGraphTodoIds} from "./source_projection.ts"; import { createHash } from "node:crypto"; import { readFile, readdir, lstat } from "node:fs/promises"; @@ -77,7 +78,7 @@ export function decodeRuntimeShadowRequest(value: unknown, schema: string, extra /** Source preconditions are ephemeral. They never become an alternative state ledger. */ function sourceSnapshot(request: ShadowRequest): JsonObject { const snapshot = request.source_snapshot; - exact(snapshot, ["state_path", "registered_runtime_root", "registered_state_path", "state_bytes_sha256", "lease_inventory", "projection_sha256", "evidence_files"], "source_snapshot"); + exact(snapshot, ["state_path", "registered_runtime_root", "registered_state_path", "state_bytes_sha256", "lease_inventory", "projection_sha256", "evidence_files", "registry_source"], "source_snapshot"); if (!isAbsolute(text(snapshot.state_path, "state_path")) || !isAbsolute(text(snapshot.registered_runtime_root, "registered_runtime_root")) || !isAbsolute(text(snapshot.registered_state_path, "registered_state_path")) || @@ -88,7 +89,7 @@ function sourceSnapshot(request: ShadowRequest): JsonObject { } return snapshot; } -export async function withShadowSourceLocks(request: ShadowRequest, operation: () => Promise): Promise { +export async function withShadowSourceLocks(request: ShadowRequest, operation: () => Promise, registryMode: "current" | "retained" = "current"): Promise { const snapshot = request.source_snapshot; if (!isAbsolute(text(snapshot.state_path, "state_path"))) throw new ShadowManagementError("source_snapshot_invalid"); const root = request.runtime_root; @@ -96,14 +97,14 @@ export async function withShadowSourceLocks(request: ShadowRequest, operation return await withFileMutationLock(legacyCoordinationTodoLockPath(root, goal), () => withFileMutationLock(String(snapshot.state_path), () => withFileMutationLock(legacyCoordinationLeaseLockPath(root, goal), () => - withFileMutationLock(join(root, "goals", goal, "task-leases", ".task-leases"), operation)))); + withFileMutationLock(join(root, "goals", goal, "task-leases", ".task-leases"), () => registryMode === "current" ? withShadowRegistrySource(snapshot, operation) : operation())))); } -async function withPrePromotionSourceLocks(request: ShadowRequest, operation: () => Promise): Promise { +async function withPrePromotionSourceLocks(request: ShadowRequest, operation: () => Promise, registryMode: "current" | "retained" = "current"): Promise { return await withShadowSourceLocks(request, async () => { const fence = await loadLegacyCoordinationWriterFence(request.runtime_root, request.goal_id); if (fence.status !== "missing") throw new ShadowManagementError(fence.status === "loaded" ? "legacy_authority_already_promoted" : fence.reason_code); return await operation(); - }); + }, registryMode); } function bytesDigest(value: Uint8Array): string { return `sha256:${createHash("sha256").update(value).digest("hex")}`; @@ -116,6 +117,7 @@ async function optionalBytes(path: string): Promise { } export async function verifyShadowSourceSnapshot(request: ShadowRequest): Promise { const snapshot = sourceSnapshot(request); + await verifyShadowRegistrySource(snapshot); if (resolve(String(snapshot.state_path)) !== resolve(String(snapshot.registered_state_path))) { throw new ShadowManagementError("shadow_source_state_path_mismatch"); } @@ -201,7 +203,7 @@ export async function rollbackCoordinationRuntimeShadow(value: unknown, _depende const revision = request.expected_provider_revision; const bootstrap = request.expected_bootstrap_operation_id; if ((typeof revision === "string") === (typeof bootstrap === "string")) throw new Error("rollback requires exactly one revision or bootstrap operation selector"); - const result = await rollbackManagedShadow(request, { withPrimaryLocks: (operation) => withPrePromotionSourceLocks(request, operation) }); + const result = await rollbackManagedShadow(request, { withPrimaryLocks: (operation) => withPrePromotionSourceLocks(request, operation, "retained") }); return { schema_version: schema, ...result, primary_writeback_preserved: true, decision_read_from_shadow: false }; } catch (error) { return failure(schema, error); } } diff --git a/loopx/control_plane/coordination/shadow_registry_source.ts b/loopx/control_plane/coordination/shadow_registry_source.ts new file mode 100644 index 0000000000..ad484786d6 --- /dev/null +++ b/loopx/control_plane/coordination/shadow_registry_source.ts @@ -0,0 +1,67 @@ +/** Registration is a source of migration facts, not an authorization grant. + * Hold its existing cross-runtime lock through source verification and cutover. + * Recovery of an already fenced operation deliberately does not use this owner. + */ +import {isAbsolute, resolve} from "node:path"; +import type {JsonObject} from "../effect_program.ts"; +import {withFileMutationLock} from "../effect_runtime_io.ts"; +import {EffectRuntimeLockTimeoutError} from "../effect_runtime_errors.ts"; +import {canonicalAuthorityBytes, hasExactAuthorityKeys} from "./authority_store_codec.ts"; +import {registryAuthoritySourceCheck} from "./authority_source.ts"; +import {ShadowManagementError} from "./shadow_management.ts"; +import {normalizeRegisteredTodoAgents} from "./todo_agents.ts"; + +interface ShadowRegistrySource { + path: string; + sha256: string; + registered_agents: string[]; +} + +function registrySource(snapshot: JsonObject): ShadowRegistrySource { + const raw = snapshot.registry_source; + if (raw === null || typeof raw !== "object" || Array.isArray(raw) || + !hasExactAuthorityKeys(raw as JsonObject, ["path", "sha256", "registered_agents"])) { + throw new ShadowManagementError("source_registry_witness_required"); + } + const value = raw as JsonObject; + if (typeof value.path !== "string" || !isAbsolute(value.path) || + typeof value.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(value.sha256) || + !Array.isArray(value.registered_agents) || + resolve(value.path) === resolve(String(snapshot.state_path))) { + throw new ShadowManagementError("source_registry_witness_invalid"); + } + return {path: value.path, sha256: value.sha256, + registered_agents: normalizeRegisteredTodoAgents(value.registered_agents as string[])}; +} + +export async function verifyShadowRegistrySource(snapshot: JsonObject): Promise { + const source = registrySource(snapshot); + if (!await registryAuthoritySourceCheck({registry_source: {...source}}, true)()) { + throw new ShadowManagementError("source_registry_changed_retry"); + } +} + +export function requirePromotionRegisteredAgents(snapshot: JsonObject, agents: readonly string[]): void { + const current = registrySource(snapshot).registered_agents; + if (!canonicalAuthorityBytes(current).equals(canonicalAuthorityBytes(normalizeRegisteredTodoAgents([...agents])))) { + throw new ShadowManagementError("promotion_registration_changed_retry"); + } +} + +export async function withShadowRegistrySource(snapshot: JsonObject, operation: () => Promise): Promise { + const source = registrySource(snapshot); + try { + // Existing registry administration can hold R before requesting a source + // lock. We already hold source locks: never wait for R in the reverse order. + // A busy registry releases our locks so its writer can finish and we retry. + return await withFileMutationLock(source.path, async () => { + await verifyShadowRegistrySource(snapshot); + return await operation(); + }, 0); + } catch (error) { + if (error instanceof EffectRuntimeLockTimeoutError) { + throw new ShadowManagementError("source_registry_busy_retry"); + } + throw error; + } +} From 09fe7399dda0d87f7f719e4792bc3c88f86edade Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:42:06 +0800 Subject: [PATCH 2/6] test(coordination): qualify saved cutover and registry races across providers Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../test_reviewed_promotion_cli.py | 205 ++++++------------ .../test_shadow_registry_source.py | 149 +++++++++++++ .../promotion_recovery_conformance.ts | 15 +- .../reviewed_promotion.test.ts | 70 +++++- tests/control_plane_ts/shadow_file_fixture.ts | 12 +- 5 files changed, 311 insertions(+), 140 deletions(-) create mode 100644 tests/control_plane/test_shadow_registry_source.py diff --git a/tests/control_plane/test_reviewed_promotion_cli.py b/tests/control_plane/test_reviewed_promotion_cli.py index 761d1bdbeb..c86f13fe4d 100644 --- a/tests/control_plane/test_reviewed_promotion_cli.py +++ b/tests/control_plane/test_reviewed_promotion_cli.py @@ -1,4 +1,4 @@ -"""Use the installed CLI transport and real local stores through cutover and recovery.""" +"""Real public CLI cutover, registration drift and receipt recovery on local stores.""" from __future__ import annotations @@ -9,29 +9,17 @@ import pytest -from tests.control_plane.test_local_authority_shadow_cli_e2e import ( - _workspace, - _cli, - _command, - _env, - REPO_ROOT, -) +from tests.control_plane.shadow_e2e_fixture import REPO, workspace -def command_result(registry, root, *args): - process = subprocess.run( - _command(registry, root, *args), - cwd=REPO_ROOT, - env=_env(), - capture_output=True, - text=True, - timeout=60, - ) - return process.returncode, json.loads(process.stdout) - - -def prepare(tmp_path: Path, provider: str): - registry, state, root = _workspace(tmp_path, goal_id="goal-a") +def prepare(tmp_path: Path, provider: str, strategy: str | None = None): + ws = workspace(tmp_path, bootstrap=False) + if strategy is not None: + ws.state.write_text( + ws.state.read_text().replace( + "handoff_mode: hard_lease", "handoff_mode: legacy" + ) + ) if provider == "sqlite": subprocess.run( [ @@ -41,105 +29,61 @@ def prepare(tmp_path: Path, provider: str): "--experimental-sqlite", "loopx/control_plane/coordination/local_authority_provider.ts", "--runtime-root", - str(root), + str(ws.runtime), "--goal-id", - "goal-a", + ws.goal, "--execute", ], - cwd=REPO_ROOT, + cwd=REPO, check=True, capture_output=True, text=True, ) - _cli( - registry, - root, - "configure-goal", - "--goal-id", - "goal-a", - "--coordination-runtime-shadow-file", - "--execute", - ) - bootstrap = _cli( - registry, - root, - "coordination-shadow", - "bootstrap", - "--goal-id", - "goal-a", - "--execute", + assert ( + ws.cli("coordination-shadow", "bootstrap", "--execute")["bootstrap"]["status"] + == "applied" ) - assert bootstrap["bootstrap"]["status"] == "applied" for index in range(3): - _cli( - registry, - root, - "todo", - "add", - "--goal-id", - "goal-a", - "--role", - "agent", - "--text", - f"Preserve migration record {index}", - ) - preview = _cli( - registry, root, "coordination-shadow", "promote", "--goal-id", "goal-a" - ) + ws.add(f"Preserve migration record {index}") + drained = ws.drain(budget_seconds="60") + assert drained["ok"] is True, drained + arguments = () if strategy is None else ("--handoff-mode-migration", strategy) + preview = ws.cli("coordination-shadow", "promote", *arguments) assert preview["promotion"]["status"] == "preview_ready", preview saved = tmp_path / "reviewed.json" saved.write_text(json.dumps(preview), encoding="utf-8") - return registry, state, root, saved, preview + return ws, saved, preview @pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("strategy", [None, "preserve", "hard_lease"]) def test_saved_plan_cutover_and_recovery_after_canonical_write_and_missing_legacy( - tmp_path, provider + tmp_path, provider, strategy ): - registry, state, root, saved, preview = prepare(tmp_path, provider) - args = ( - "coordination-shadow", - "promote", - "--goal-id", - "goal-a", - "--reviewed-plan", - str(saved), - ) - dry_run = _cli(registry, root, *args) + ws, saved, preview = prepare(tmp_path, provider, strategy) + arguments = ("coordination-shadow", "promote", "--reviewed-plan", str(saved)) + dry_run = ws.cli(*arguments) assert dry_run["promotion"]["status"] == "preview_ready" assert dry_run["executed"] is False - applied = _cli(registry, root, *args, "--execute") + applied = ws.cli(*arguments, "--execute") assert applied["promotion"]["status"] == "applied", applied assert applied["promotion"]["canonical_authority"] == f"{provider}_v0" assert ( applied["promotion"]["promotion_plan_sha256"] == preview["promotion"]["plan"]["promotion_plan_sha256"] ) - _cli( - registry, - root, - "todo", - "add", - "--goal-id", - "goal-a", - "--role", - "agent", - "--text", - "Continue after provider cutover", - ) - state.unlink() - # Recovery belongs to the durable cutover, not the transient shadow opt-in. - config = json.loads(registry.read_text()) + created = ws.add("Continue after provider cutover") + readback = ws.cli("todo", "list", "--todo-id", created["todo_id"]) + assert readback["authority_read"]["source_authority"] == f"{provider}_v0" + ws.state.unlink() + # Recovery follows durable proof, not a fresh source or shadow opt-in. + config = json.loads(ws.registry.read_text()) config["goals"][0]["coordination"].pop("runtime_shadow", None) - registry.write_text(json.dumps(config)) + ws.registry.write_text(json.dumps(config)) for execution in [(), ("--execute",)]: - replay = _cli( - registry, - root, + replay = ws.cli( "coordination-shadow", "recover-promotion", - "--goal-id", - "goal-a", "--reviewed-plan", str(saved), *execution, @@ -151,80 +95,67 @@ def test_saved_plan_cutover_and_recovery_after_canonical_write_and_missing_legac == applied["promotion"]["provider_revision"] ) assert replay["executed"] is False - assert not state.exists() + assert not ws.state.exists() def test_saved_plan_source_drift_does_not_freeze_legacy_writes(tmp_path): - registry, _state, root, saved, _preview = prepare(tmp_path, "file") - _cli( - registry, - root, - "todo", - "add", - "--goal-id", - "goal-a", - "--role", - "agent", - "--text", - "New work before cutover", - ) - code, result = command_result( - registry, - root, + ws, saved, _ = prepare(tmp_path, "file") + ws.add("New work before cutover") + result = ws.cli( "coordination-shadow", "promote", - "--goal-id", - "goal-a", "--reviewed-plan", str(saved), "--execute", + success=False, ) - assert code == 1 + assert result["ok"] is False assert result["promotion"]["reason_code"] == "local_authority_reviewed_plan_changed" assert result["promotion"]["legacy_writer_fenced"] is False - _cli( - registry, - root, - "todo", - "add", - "--goal-id", - "goal-a", - "--role", - "agent", - "--text", - "Legacy writer remains usable", - ) + assert ws.add("Legacy writer remains usable")["ok"] is True def test_saved_plan_rejects_policy_override_and_recovery_without_fence(tmp_path): - registry, _state, root, saved, _preview = prepare(tmp_path, "file") - code, result = command_result( - registry, - root, + ws, saved, _ = prepare(tmp_path, "file") + result = ws.cli( "coordination-shadow", "promote", - "--goal-id", - "goal-a", "--reviewed-plan", str(saved), "--minimum-operations", "1", "--execute", + success=False, ) - assert code == 1 and "owns qualification policy" in result["error"] - code, result = command_result( - registry, - root, + assert result["ok"] is False and "owns qualification policy" in result["error"] + result = ws.cli( "coordination-shadow", "recover-promotion", - "--goal-id", - "goal-a", "--reviewed-plan", str(saved), "--execute", + success=False, ) - assert code == 1 + assert result["ok"] is False assert ( result["promotion"]["reason_code"] == "local_authority_writer_fence_not_verified" ) + + +def test_saved_plan_rechecks_current_registered_agents(tmp_path): + ws, saved, _ = prepare(tmp_path, "file", "preserve") + registry = json.loads(ws.registry.read_text()) + registry["goals"][0]["coordination"]["registered_agents"].remove("agent-b") + ws.registry.write_text(json.dumps(registry)) + result = ws.cli( + "coordination-shadow", + "promote", + "--reviewed-plan", + str(saved), + "--execute", + success=False, + ) + assert result["ok"] is False + assert result["promotion"]["reason_code"] == "promotion_registration_changed_retry" + assert result["promotion"]["legacy_writer_fenced"] is False diff --git a/tests/control_plane/test_shadow_registry_source.py b/tests/control_plane/test_shadow_registry_source.py new file mode 100644 index 0000000000..03d5131c39 --- /dev/null +++ b/tests/control_plane/test_shadow_registry_source.py @@ -0,0 +1,149 @@ +"""Real registry/source races must reject before opening a capture lineage.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from loopx.file_lock import exclusive_cross_runtime_file_lock +from loopx.control_plane.coordination.runtime_shadow import ( + build_runtime_shadow_source_snapshot, + bootstrap_coordination_runtime_shadow, +) +from loopx.control_plane.coordination.shadow_management import ( + ShadowManagementError, + read_shadow_management_state, +) + + +def workspace(root: Path): + state = root / "state.md" + state.write_text( + "---\ngoal_id: witness-goal\nhandoff_mode: hard_lease\n---\n## Agent Todo\n" + ) + runtime, registry = root / "runtime", root / "registry.json" + goal = { + "id": "witness-goal", + "repo": str(root), + "state_file": state.name, + "coordination": { + "registered_agents": ["agent-a"], + "runtime_shadow": { + "schema_version": "loopx_coordination_runtime_shadow_config_v0", + "enabled": True, + "provider": "file_v0", + }, + }, + } + data = {"common_runtime_root": str(runtime), "goals": [goal]} + registry.write_text(json.dumps(data)) + return state, runtime, registry, goal, data + + +def capture(state, runtime, registry, goal): + return build_runtime_shadow_source_snapshot( + goal=goal, runtime_root=runtime, state_path=state, registry_path=registry + ) + + +def bootstrap(runtime, goal, projection, snapshot): + return bootstrap_coordination_runtime_shadow( + goal=goal, + runtime_root=runtime, + goal_id=goal["id"], + operation_id="bootstrap:registry-witness", + source_version="state:1", + projection=projection, + source_snapshot=snapshot, + ) + + +@pytest.mark.parametrize( + "change", + ["delete_goal", "move_state", "move_runtime", "remove_agent", "disable_capture"], +) +def test_stale_registry_rejects_before_bootstrap(tmp_path: Path, change: str): + state, runtime, registry, goal, data = workspace(tmp_path) + projection, snapshot = capture(state, runtime, registry, goal) + original_state = state.read_bytes() + changed = json.loads(json.dumps(data)) + if change == "delete_goal": + changed["goals"] = [] + elif change == "move_state": + changed["goals"][0]["state_file"] = "replacement.md" + elif change == "move_runtime": + changed["common_runtime_root"] = str(tmp_path / "replacement") + elif change == "remove_agent": + changed["goals"][0]["coordination"]["registered_agents"] = [] + else: + changed["goals"][0]["coordination"]["runtime_shadow"]["enabled"] = False + registry.write_text(json.dumps(changed)) + result = bootstrap(runtime, goal, projection, snapshot) + assert result["status"] == "failed", result + assert result["reason_code"] == "source_registry_changed_retry" + assert read_shadow_management_state(runtime, goal["id"]) is None + assert state.read_bytes() == original_state + + +def test_stale_caller_goal_is_not_bound_to_a_new_registry_digest(tmp_path: Path): + state, runtime, registry, goal, data = workspace(tmp_path) + changed = json.loads(json.dumps(data)) + changed["goals"][0]["coordination"]["registered_agents"] = [] + registry.write_text(json.dumps(changed)) + with pytest.raises(ShadowManagementError, match="source_registry_changed_retry"): + capture(state, runtime, registry, goal) + + +def test_registry_change_during_python_projection_is_rejected( + tmp_path: Path, monkeypatch +): + from loopx.control_plane.coordination import runtime_shadow + + state, runtime, registry, goal, data = workspace(tmp_path) + original = runtime_shadow._build_runtime_shadow_source_snapshot + + def racing_projection(**arguments): + result = original(**arguments) + registry.write_text(json.dumps({**data, "goals": []})) + return result + + monkeypatch.setattr( + runtime_shadow, "_build_runtime_shadow_source_snapshot", racing_projection + ) + with pytest.raises(ValueError, match="registration changed"): + capture(state, runtime, registry, goal) + + +def test_python_registry_writer_cannot_deadlock_native_bootstrap(tmp_path: Path): + state, runtime, registry, goal, _ = workspace(tmp_path) + projection, snapshot = capture(state, runtime, registry, goal) + with exclusive_cross_runtime_file_lock(registry, operation="registry-source-race"): + result = bootstrap(runtime, goal, projection, snapshot) + assert result["reason_code"] == "source_registry_busy_retry", result + assert read_shadow_management_state(runtime, goal["id"]) is None + assert bootstrap(runtime, goal, projection, snapshot)["status"] == "applied" + + +def test_strict_registry_envelope_keeps_existing_codec(tmp_path: Path): + from loopx.control_plane.projects.registry_codec import ( + _payload_digest, + STRICT_SCHEMA_VERSION, + ) + + state, runtime, registry, goal, data = workspace(tmp_path) + registry.write_text( + json.dumps( + [ + { + "schema_version": STRICT_SCHEMA_VERSION, + "minimum_writer_protocol": "goal_instance_v1", + "payload_sha256": _payload_digest(data), + }, + data, + ] + ) + ) + projection, snapshot = capture(state, runtime, registry, goal) + assert bootstrap(runtime, goal, projection, snapshot)["status"] == "applied" diff --git a/tests/control_plane_ts/promotion_recovery_conformance.ts b/tests/control_plane_ts/promotion_recovery_conformance.ts index c9772a9d30..b4cb911194 100644 --- a/tests/control_plane_ts/promotion_recovery_conformance.ts +++ b/tests/control_plane_ts/promotion_recovery_conformance.ts @@ -1,7 +1,7 @@ import { LOCAL_AUTHORITY_SHADOW_TRANSACTION_PROJECTION_SCHEMA } from "../../loopx/control_plane/coordination/coordination_state_contract.generated.ts"; import assert from "node:assert/strict"; import test from "node:test"; -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { JsonObject } from "../../loopx/control_plane/effect_program.ts"; @@ -97,6 +97,18 @@ export function registerPromotionRecoveryConformance( goal_id: "goal-a", reviewed_plan: envelope, }; + const registryPath = join(root, "registry.json"); + const originalRegistry = await readFile(registryPath); + await writeFile(registryPath, JSON.stringify({goals: []})); + const stale = await executeReviewedCoordinationPromotion({ + ...operation, action: "apply", execute: true, + projection: source.request.projection, source_snapshot: source.request.source_snapshot, + }, dependencies); + assert.equal(stale.reason_code, "source_registry_changed_retry"); + assert.equal(stale.legacy_writer_fenced, false); + assert.equal((await store.loadAuthority()).status, "missing"); + assert.equal((await loadLegacyCoordinationWriterFence(root, "goal-a")).status, "missing"); + await writeFile(registryPath, originalRegistry); const applied = await executeReviewedCoordinationPromotion( { ...operation, @@ -133,6 +145,7 @@ export function registerPromotionRecoveryConformance( }); assert.equal(changed.status, "applied"); await rm(source.statePath); + await rm(registryPath); const head = await store.loadAuthority(); const fence = await loadLegacyCoordinationWriterFence(root, "goal-a"); for (const execute of [false, true]) { diff --git a/tests/control_plane_ts/reviewed_promotion.test.ts b/tests/control_plane_ts/reviewed_promotion.test.ts index 4b6b061a1a..ee28544108 100644 --- a/tests/control_plane_ts/reviewed_promotion.test.ts +++ b/tests/control_plane_ts/reviewed_promotion.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { mkdtemp, rm, readFile } from "node:fs/promises"; +import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { qualifiedShadow } from "./local_promotion_fixture.ts"; @@ -285,3 +285,71 @@ test("the unmodified CLI preview envelope is accepted and cross-Goal wrappers ar ); assert.equal(rejected.reason_code, "invalid_reviewed_promotion_plan"); }); + + +test("registry removal rejects a captured preview before fencing", async (t) => { + const {root, request} = await fixture(t); + await rm(join(root, "registry.json")); + const result = await reviewLocalCoordinationAuthorityPromotion({...request, execute: true}); + assert.equal(result.reason_code, "source_registry_changed_retry"); + assert.equal(result.legacy_writer_fenced, false); + assert.equal((await loadLegacyCoordinationWriterFence(root, "goal-a")).status, "missing"); +}); + +test("saved plan cannot carry old registrations through a fresh source snapshot", async (t) => { + const {root, request} = await fixture(t); + const explicit = {...request, handoff_mode_migration: "preserve", registered_agents: ["agent-a", "agent-b"]}; + const plan = await reviewed(explicit); + await writeFile(join(root, "registry.json"), JSON.stringify({goals: [{id: "goal-a", coordination: {registered_agents: ["agent-a"]}}]})); + const snapshot = (request as JsonObject).source_snapshot as JsonObject; + const {createHash} = await import("node:crypto"); + const current = {...snapshot, registry_source: {path: join(root, "registry.json"), registered_agents: ["agent-a"], + sha256: createHash("sha256").update(await readFile(join(root, "registry.json"))).digest("hex")}}; + const result = await executeReviewedCoordinationPromotion(operation(root, plan, "apply", true, + {...request, source_snapshot: current})); + assert.equal(result.reason_code, "promotion_registration_changed_retry"); + assert.equal((await loadLegacyCoordinationWriterFence(root, "goal-a")).status, "missing"); +}); + +test("registry exclusion spans canonical commit and releases after success", async (t) => { + const {root, request} = await fixture(t); + const {withFileMutationLock} = await import("../../loopx/control_plane/effect_runtime_io.ts"); + const {EffectRuntimeLockTimeoutError} = await import("../../loopx/control_plane/effect_runtime_errors.ts"); + class RegistryCheckingStore extends FileAuthorityStore { + override async commitAuthority(input: Parameters[0]) { + await assert.rejects(withFileMutationLock(join(root, "registry.json"), async () => {}, 0), EffectRuntimeLockTimeoutError); + return await super.commitAuthority(input); + } + } + const store = new RegistryCheckingStore(join(root, "authority", "file-v0"), "goal-a"); + const result = await reviewLocalCoordinationAuthorityPromotion({...request, execute: true}, {createCanonicalStore: () => store}); + assert.equal(result.status, "applied", JSON.stringify(result)); + await withFileMutationLock(join(root, "registry.json"), async () => {}, 0); +}); + +test("stale source reports an existing fence and saved recovery needs no legacy registry", async (t) => { + const {root, request} = await fixture(t); + const plan = await reviewed(request); + const result = await executeReviewedCoordinationPromotion(operation(root, plan, "apply", true, request)); + assert.equal(result.status, "applied"); + await rm(join(root, "registry.json")); + const stale = await reviewLocalCoordinationAuthorityPromotion({...request, execute: true}); + assert.equal(stale.reason_code, "source_registry_changed_retry"); + assert.equal(stale.legacy_writer_fenced, true); + const replay = await executeReviewedCoordinationPromotion(operation(root, plan, "recover", true)); + assert.equal(replay.status, "replayed", JSON.stringify(replay)); + assert.equal(replay.executed, false); +}); + + +for (const strategy of ["preserve", "hard_lease"] as const) { + test(`saved reviewed apply preserves the explicit ${strategy} policy`, async (t) => { + const {root, request} = await fixture(t); + const explicit = {...request, handoff_mode_migration: strategy, registered_agents: ["agent-a", "agent-b"]}; + const plan = await reviewed(explicit); + const result = await executeReviewedCoordinationPromotion(operation(root, plan, "apply", true, request)); + assert.equal(result.status, "applied", JSON.stringify(result)); + const replay = await executeReviewedCoordinationPromotion(operation(root, plan, "recover", true)); + assert.equal(replay.status, "replayed"); + }); +} diff --git a/tests/control_plane_ts/shadow_file_fixture.ts b/tests/control_plane_ts/shadow_file_fixture.ts index fd54666ab8..4368196eeb 100644 --- a/tests/control_plane_ts/shadow_file_fixture.ts +++ b/tests/control_plane_ts/shadow_file_fixture.ts @@ -29,6 +29,15 @@ export function projection(todos: JsonObject[] = [], leases: JsonObject[] = [], } export interface ShadowFixture { root: string; statePath: string; store: FileAuthorityStore; baseline: JsonObject } export async function sourceRequest(f: ShadowFixture, head: JsonObject): Promise { + const registryPath = join(f.root, "registry.json"); + let registryBytes: Buffer; + try { registryBytes = await readFile(registryPath); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + registryBytes = Buffer.from(JSON.stringify({goals: [{id: "goal-a", coordination: {registered_agents: ["agent-a", "agent-b"]}}]})); + await writeFile(registryPath, registryBytes); + } + const agents = JSON.parse(registryBytes.toString("utf8")).goals[0].coordination.registered_agents; const directory = join(f.root, "goals", "goal-a", "task-leases"); let names: string[]; try { names = (await readdir(directory)).filter((name) => /^[A-Za-z0-9_.-]+\.json$/.test(name)).sort(); } catch { names = []; } @@ -37,7 +46,8 @@ export async function sourceRequest(f: ShadowFixture, head: JsonObject): Promise return { runtime_root: f.root, goal_id: "goal-a", projection: head, source_snapshot: { state_path: f.statePath, registered_runtime_root: f.root, registered_state_path: f.statePath, state_bytes_sha256: sha(await readFile(f.statePath)), - lease_inventory: inventory, projection_sha256: canonicalAuthoritySha256(head), evidence_files: [] } }; + lease_inventory: inventory, projection_sha256: canonicalAuthoritySha256(head), evidence_files: [], registry_source: {path: registryPath, + sha256: sha(registryBytes).slice(7), registered_agents: agents} } }; } export async function fixture(t: TestContext): Promise { const root = await mkdtemp(join(tmpdir(), "loopx-file-outbox-test-")); From ef712594b7b2900da795a2560f4a69f14f7da4b2 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:42:07 +0800 Subject: [PATCH 3/6] docs(authority): reconcile cutover backlog and current promotion recovery Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- ...26-09-24-default-cutover-reconciliation.md | 46 +++++++++++-------- ...24-default-cutover-reconciliation.zh-CN.md | 39 +++++++++------- ...shared-goal-authority-state-provider-v0.md | 22 ++++++--- ...-goal-authority-state-provider-v0.zh-CN.md | 16 +++++-- .../typescript-control-plane-migration-v0.md | 22 ++++++--- ...script-control-plane-migration-v0.zh-CN.md | 16 +++++-- .../reviewed-coordination-promotion.md | 37 +++++++++++---- .../reviewed-coordination-promotion.zh-CN.md | 28 +++++++++-- 8 files changed, 154 insertions(+), 72 deletions(-) diff --git a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.md b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.md index 2ad8abafa2..9f1c26faa2 100644 --- a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.md +++ b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.md @@ -1,8 +1,8 @@ # Default cutover: reconciled implementation frontier -- Baseline: `d64c4d377` on `main`, 2026-09-24; open PR states are a snapshot, not merge promises. +- Baseline: `37bbaec79` on `main`, 2026-09-25; open PR states are a snapshot, not merge promises. - Owners: overall roadmap #4574 R5/G2; shared authority L2–L9/D1–D3; TS migration T1–T4. -- Delivery: complete source transport through existing typed projection and shadow management owners. +- Delivery: current registration admission, complete saved migration intent and truthful fence recovery. - This checkpoint supersedes numerical remaining-PR estimates in earlier delivery entries. ## Correct the accounting @@ -18,30 +18,36 @@ them as though they were interchangeable PRs. | --- | --- | | #4870 claim-preserving writes; #4888 reviewed cutover; #4920 drain planning | Implemented. Exercise their combined head; do not commission replacements. | | #4922 complete canonical snapshot pagination; #4960 qualified SQLite runtime admission; #4961 display refresh recovery; #4964 shared source summaries | Implemented. Consumer and packaged-client acceptance still needs integration evidence; a whole new pagination/recovery implementation is not pending. | -| #4967 typed complete-source assembly; #4968 native outbox delivery/recovery | Implemented. Large source RPC failure below is a separate demonstrated gap, not absence of capture assembly. | -| #5003 atomic event-owned completion | Open. Solves batch publication/retry, **not** the event writer's shadow-capture binding. | -| #4994 explicit leased Agent handoff; #4995 generated Monitor proof; #4991 rejected poll reservation; #4992 deferred receipt-bound Turn | Open. Integrate their exact reviewed heads before deciding what caller work remains; do not recreate them under a new caller-refactor PR. | +| #4967 typed complete-source assembly; #4968 native outbox delivery/recovery | Implemented. Complete-source transport is also merged in #5013; capture assembly is not missing. | +| #5003 atomic event-owned completion | Merged. Solves batch publication/retry, **not** the event writer's shadow-capture binding. | +| #4994 explicit leased Agent handoff; #4995 generated Monitor proof; #4991 rejected poll reservation; #4992 deferred receipt-bound Turn | Merged. Audit the integrated callers before deciding what remains; do not recreate them under a new caller-refactor PR. | | #4931 retained SQLite proof encoding, contributor #4224 | Open optimization plus incomplete D2 qualification. A speedup is not capacity/recovery/soak acceptance. | | #4915 default `.loopx` filesystem placement | Separate configuration migration; does not select File/SQLite authority. | -There are six relevant open implementation PRs above (#5003, #4994, #4995, -#4991, #4992, #4931), plus the separately classified #4915 to avoid conflating -filesystem placement with authority. These are not six unstarted requirements, -nor a claim that every one is a mandatory storage-default dependency. +Only #4931 remains open among those implementation PRs; #4915 is separate +filesystem migration. #5011/#5012/#5013/#5014/#5016 are also merged; reuse their +transaction, complete-source and source-witness owners. The latest formal #4224 +1 MiB report still fails receipt p95 (269.03 ms versus 50 ms) and scan-100 p95 +(801.81 ms versus 250 ms). #4931 has not supplied a formal exact-head rerun. +Reaching the planned ten-day soak end date is not a passing report. -## Four concrete next delivery boundaries +## Three concrete next code boundaries -These are **four proposed new batches including this delivery**, in addition to -integrating existing work. They are not a guaranteed total remaining PR count. -The command inventory and exact-profile acceptance can reveal further defects; -record a new demonstrated gap rather than silently keeping a range unchanged. +This delivery repairs integrated migration admission: stale registry snapshots +could bootstrap a shadow and saved execution dropped migration policy. It does +not implement another store or close the whole migration package or D2 gate. -| Batch | Observable result and owning boundary | Exit and remaining dependency | +| Proposed PR | Observable result and owner | Exit | | --- | --- | --- | -| A. Complete source pipeline (this delivery) | A source larger than the RPC envelope can pass typed projection, bootstrap, writer capture, inspect, qualify and reviewed promotion without truncation. Python transports bytes; TS retains source admission and authority. | Large real CLI journey; File/SQLite complete reads; source-witness rejection; detached real-source rehearsal. Does not bind the event writer or qualify a provider default. | -| B. External-effect executor fence | Current execution proof protects the actual external-effect interval, including takeover, timeout, exit and uncertain completion, using the existing lease/effect owners. | Stale executors cannot execute or settle fenced work; exact receipt recovery. #4994/#4995 caller integration is reused; a point-in-time proof check alone is insufficient. | -| C. Event-writer binding and whole-Goal migration/rollback | Bind the actual event writer lock/publication lifecycle to the existing outbox lineage, then exercise mixed Markdown/event/lease writers, drain, reviewed cutover, canonical consumers and fenced export/rollback as one journey. Retire replaced Python decisions at their TS owner. | Integrate #5003 rather than reimplement atomic completion. Preserve `event_log_writer_not_bound` until the real binding passes. D1 consumers, command inventory and D3 cohort evidence must close; if this requires separate code, name the discovered boundary explicitly. | -| D. Default/onboarding and final bounded Python retirement | Qualified local profile is selected consistently by new Goal creation, settings, installation and packaged frontend/Lark/CLI; existing Goals follow explicit migration/disable guidance. Delete only business writers whose callers have switched. | B/C and applicable D1–D3 evidence, rollback and entrypoint readback. Keep permanent Python rendering, host IO and legal import/export. | +| 1. External-effect execution fencing | Lease/effect owners protect the actual execution interval, takeover, timeout, exit and uncertain completion. Reuse merged #4994/#4995. | Stale executors cannot continue or settle; real executor and receipt recovery matrix passes. A point-in-time proof check is insufficient. | +| 2. Event-writer binding and whole-Goal migration/rollback | Bind event writer locks/atomic publication to existing outbox; integrate Markdown/event/lease capture, drain, saved cutover, consumers and fenced export/rollback; delete Python decisions replaced by TS. | Reuse #5003. Retain `event_log_writer_not_bound` until binding passes; close D1, command inventory and D3 cohort. One Goal without an event overlay does not prove this package. | +| 3. Default entrypoints and bounded Python retirement | New Goals, settings, installation and packaged frontend/Lark/CLI select a qualified profile consistently; existing Goals have explicit migration/disable flows. | 1/2 and applicable D1–D3 pass; user entrypoints work; delete business writers only after their last callers migrate. Retain rendering, host IO and lawful import/export. | + +**Plan three named future implementation PRs, plus existing #4931 and outstanding +evidence; do not promise a total of four PRs.** Newly discovered defects must +name their own repair and evidence, not reset an unchanged “5–8” estimate. +Bounded File opt-in, qualified SQLite default and all-existing-Goal migration +are separate acceptance scopes. D2 capacity, crash/restore/upgrade/runtime coverage and **at least ten days of natural elapsed soak** are evidence gates on an exact SQLite profile, not an @@ -59,7 +65,7 @@ establish production service readiness. ## Complete-source transport and budget decision -At this baseline `test_canonical_snapshot_integration` fails before provider +Before #5013, `test_canonical_snapshot_integration` failed before provider admission: complete source projection exceeds the 2 MiB request limit. Paging canonical reads already exists, but source capture and management still send whole projections. Trimming source records would invalidate digests and parity; diff --git a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.zh-CN.md b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.zh-CN.md index 8a32e8e367..a32278b773 100644 --- a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.zh-CN.md +++ b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.zh-CN.md @@ -1,8 +1,8 @@ # 默认切换:按实现证据重算交付边界 -- 核对基线:2026-09-24 `main` 的 `d64c4d377`;开放 PR 状态是快照,不是合入承诺。 +- 核对基线:2026-09-25 `main` 的 `37bbaec79`;开放 PR 状态是快照,不是合入承诺。 - 归属:总目标 #4574 R5/G2;shared authority L2–L9/D1–D3;TS 迁移 T1–T4。 -- 本次交付:既有 typed projection 与 shadow management 的完整来源传输。 +- 本次交付:当前注册事实约束晋升,保存的模式意图完整执行,准确恢复 fence 状态。 - 本检查点取代此前交付记录中的剩余 PR 数量估算。 ## 先纠正统计口径 @@ -15,28 +15,33 @@ | --- | --- | | #4870 保留 claim 的写入、#4888 reviewed cutover、#4920 drain 规划 | 已实现。验收组合 head,不再重新安排一套替代实现。 | | #4922 完整 canonical 快照分页、#4960 SQLite runtime 准入、#4961 显示刷新恢复、#4964 共享来源摘要 | 已实现。消费者和打包客户端仍需组合验收,不等于还缺一个全新的分页/恢复实现。 | -| #4967 TS 完整来源组装、#4968 原生 outbox 交付/恢复 | 已实现。下述大型来源 RPC 失败是另一个已复现缺口,不能称为 capture 组装未做。 | -| #5003 event-owned completion 原子提交 | 开放。解决整批发布/重试,不负责 event writer 与 shadow capture 的绑定。 | -| #4994 带 lease 的显式 Agent 交接、#4995 Monitor 命令 proof、#4991 拒绝 poll 后释放预约、#4992 延期且绑定 receipt 的 Turn | 开放。组合各自经过评审的 head 后盘点 caller,不能再开一个 caller 重构 PR 重做它们。 | +| #4967 TS 完整来源组装、#4968 原生 outbox 交付/恢复 | 已实现。大型来源传输亦已通过 #5013 合入;不能再称为 capture 未做。 | +| #5003 event-owned completion 原子提交 | 已合入。解决整批发布/重试,不负责 event writer 与 shadow capture 的绑定。 | +| #4994 带 lease 的显式 Agent 交接、#4995 Monitor 命令 proof、#4991 拒绝 poll 后释放预约、#4992 延期且绑定 receipt 的 Turn | 已合入。组合现有实现盘点 caller,不能再开一个 caller 重构 PR 重做它们。 | | #4931 SQLite retained proof 编码、contributor #4224 | 优化 PR 开放,D2 资格未闭合。提速不等于容量、恢复和 soak 验收通过。 | | #4915 默认 `.loopx` 目录 | 独立的配置迁移,不会选择 File/SQLite authority。 | -上表有六个相关的开放实现 PR(#5003、#4994、#4995、#4991、#4992、#4931), -另列 #4915 排除目录迁移造成的混淆。它们不是六个尚未动手的新需求,也不宣称每个 -都是 storage default 的硬依赖。 +相关在途实现中现在只剩 #4931 的 SQLite 优化;#4915 是独立目录迁移。 +#5011/#5012/#5013/#5014/#5016 亦已合入,继续复用其事务、完整来源与来源见证。 +#4224 最新正式 1 MiB 报告仍有两项失败(receipt p95 269.03 ms / 50 ms; +scan 100 p95 801.81 ms / 250 ms),#4931 尚未提供精确 head 的正式复测。 +十日 soak 到了计划结束日期,不等于已有通过结果。 -## 四个可明确描述的后续交付边界 +## 三个明确的后续代码边界 -在整合已有工作之外,规划以下**四个新增交付批次,包含本次**。这是下一步开发 -安排,不是保证总共只剩四个 PR。命令清单和精确 profile 的验收仍可能发现缺陷; -届时记录新证据和新边界,不再悄悄维持一个范围数字。 +本次补的是整合后的真实晋升准入缺口:旧 registry 快照可初始化 shadow,以及保存 +后的模式转换参数被丢失。它是迁移闭环的缺陷修复,不是新的存储引擎,也不能据此 +将下表第三方资格门或整个迁移包标成完成。 -| 批次 | 可观察结果与 owner | 退出证据及剩余依赖 | +| 拟议 PR | 可观察结果与 owner | 退出条件 | | --- | --- | --- | -| A. 完整来源流水线(本次) | 大于 RPC envelope 的来源可完整经过 TS projection、bootstrap、writer capture、inspect、qualify、reviewed promotion。Python 只传字节,TS 保留来源准入及 authority。 | 大型真实 CLI 链路、File/SQLite 完整读取、source witness 拒绝反例、真实来源隔离副本演练。不绑定 event writer,也不宣布 provider 默认合格。 | -| B. 外部 effect executor fence | 复用 lease/effect owner,在真实外部 effect 执行区间保护当前 execution proof,覆盖接管、超时、退出与不确定完成。 | 过期 executor 不能执行或结算被围栏的工作,精确业务 receipt 可恢复。复用 #4994/#4995;执行前查一次 proof 不足以证明整个区间安全。 | -| C. Event writer 绑定与整 Goal 迁移/回滚 | 将真实 event writer 的锁及发布生命周期接入现有 outbox lineage,组合 Markdown/event/lease writer、drain、reviewed cutover、canonical 消费者和 fenced export/rollback。随 TS owner 收口删除替代的 Python 决策。 | 整合 #5003,不重做原子完成。真实绑定通过之前保留 `event_log_writer_not_bound`。闭合 D1 消费者、命令清单与 D3 cohort 证据;若发现需要独立代码批次,明确记录该缺口。 | -| D. 默认/onboarding 与最后一批有界 Python 退役 | 新 Goal、settings、安装和打包 frontend/Lark/CLI 一致选择合格本地 profile;已有 Goal 有显式迁移、停用指导。仅删除 caller 已切换的业务 writer。 | B/C、适用的 D1–D3、回滚及受影响入口读回。保留永久 Python renderer、宿主 IO 和合法 import/export。 | +| 1. 外部动作执行区间保护 | lease/effect owner 将执行身份验证覆盖到实际外部动作、接管、超时、退出及不确定完成。复用已合入 #4994/#4995。 | 过期 executor 不能继续执行/结算;真实执行器及 receipt 恢复矩阵通过。执行前查一次 proof 不够。 | +| 2. 事件 writer 绑定与整 Goal 迁移/回退闭环 | 将 event writer 锁和原子发布接入现有 outbox;组合 Markdown/event/lease writer、drain、saved cutover、消费者和 fenced export/rollback,删除被 TS 替代的 Python 决策。 | 复用 #5003,绑定通过前保留 `event_log_writer_not_bound`;闭合 D1、命令清单与 D3 cohort。单个无 event overlay 的 Goal 晋升不证明本项。 | +| 3. 默认入口与有界 Python 退役 | 新 Goal、settings、安装及 packaged frontend/Lark/CLI 一致选择合格 profile;存量有显式迁移与停用流程。 | 1/2 及适用 D1–D3 通过,验证用户入口,删除最后 caller 已转走的业务 writer;保留 renderer、host IO、合法导入导出。 | + +**计划是三个可命名的后续实现 PR,加已有 #4931 和未闭合证据;不是保证总计四个 +PR 即可切换。** 若验收发现新缺陷,记录具体缺陷与修复 PR,不能重新报一个不变 +的“5–8”。File-only 有界 opt-in、SQLite 合格默认、全部存量迁移分别验收。 D2 的容量、crash/restore/upgrade/runtime 覆盖和**至少十天自然经过时间的 soak**, 是精确 SQLite profile 的证据门,不预设为一个或两个 PR;#4224 继续拥有这项工作。 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 068a10f930..92150aaf13 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -23,12 +23,16 @@ [Chinese version](./shared-goal-authority-state-provider-v0.zh-CN.md) and this English version are semantic mirrors. A difference between them is a defect. -## Current delivery frontier (2026-09-24) +## Current delivery frontier (2026-09-25) -The `d64c4d377`/open-PR audit withdraws earlier “5–8 / 6–8 / 7–9” estimates. -Implemented code, six relevant open PRs, four proposed new batches (including -complete-source transport) and D1–D3 evidence are separate units; four batches -are not a guaranteed total PR count. Use the [reconciled inventory and exits](ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.md) as the current plan. +Audit `37bbaec79` and current PR states: complete-source transport, transaction +capture, source assembly and the five previously open caller/event fixes are +merged, not future implementation. After the current promotion-admission repair, +three named code boundaries remain planned: external-effect execution fencing; +event-writer binding plus whole-Goal migration/rollback; default onboarding plus +bounded Python retirement. #4931 and outstanding D2 evidence are tracked +separately. Three is a delivery plan, not a guaranteed total PR count. +[Current inventory and exits](ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.md). ## Persistence route for steward scale (2026-09-16) @@ -46,7 +50,13 @@ shadow lineage, still default-off and subject to explicit bootstrap. ## Current implementation checkpoint -The current [event transaction and default-cutover plan](ledger/shared-goal-authority-state-provider-v0/2026-09-24-event-completion-transaction.md) estimates 5–8 complete packages conditionally. #4967 source assembly and #4968 capture delivery are already delivered; event-writer binding remains open, with atomic completion repaired here as a prerequisite. Earlier counts below describe historical checkpoints, not additional current work. +Promotion admission now binds complete sources to a current registry witness +and rechecks it inside the TS lock scope. Saved execution retains the reviewed +handoff policy, and failures report durable fence presence. Recovery of a +committed operation still follows its original fence/receipt instead of requiring +the retired source to become valid again. This repairs demonstrated L7/L8 +integration defects; it neither recounts shipped capture nor flips global defaults. +[Operation and boundaries](../../reference/reviewed-coordination-promotion.md). Handoff-mode changes now share one TS ownership-fact classifier before and after promotion. Legacy event-only claims reject rather than disappear at a 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 c0c46cf263..a5fac9a549 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 @@ -20,11 +20,13 @@ - 语言说明:[英文版](./shared-goal-authority-state-provider-v0.md)与本中文版互为 语义镜像;两者不一致属于缺陷 -## 当前交付边界(2026-09-24) +## 当前交付边界(2026-09-25) -剩余 PR 估算已按 `d64c4d377` 和开放 PR 重新核对,旧“5–8 / 6–8 / 7–9”数字撤回。 -已合入实现、六个相关在途 PR、四个拟新增批次(含当前完整来源传输)和 D1–D3 -验收分开记录;四批不是承诺总计只剩四个 PR。唯一当前清单见[实现核对与退出证据](ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.zh-CN.md)。 +按 `37bbaec79` 与当前 PR 状态核对:完整来源传输、事务捕获、来源组装及此前五个 +在途 caller/event 修复都已合入,不再计入待开发。当前晋升准入修复之后,规划三个 +明确代码边界:外部动作执行区间保护、事件 writer 绑定与整 Goal 迁移/回退闭环、 +默认启用与最后一批有界 Python 退役。#4931 与 D2 的剩余资格证据单列;三个是 +可命名的开发批次,不是保证总 PR 数。[唯一当前清单与退出条件](ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.zh-CN.md)。 ## 旧观测退役检查点(2026-09-24) @@ -41,7 +43,11 @@ ## 当前实现检查点 -当前剩余交付以[事件事务与默认切换计划](ledger/shared-goal-authority-state-provider-v0/2026-09-24-event-completion-transaction.zh-CN.md)为准:条件估算 5–8 个完整包。#4967 来源组装、#4968 捕获交付已经完成;事件写入者绑定仍未完成,本批先修复完整完成事务。下文更早的包数属于历史检查点,不能作为当前待办重复计算。 +晋升准入现将完整来源绑定到当前 registry witness,并在 TS 持锁范围内重新校验; +保存计划执行保留已审核的 handoff 策略,失败结果如实报告持久 fence。 +已提交事务的恢复仍按原 fence/receipt,不要求失去权威的旧来源重新有效。 +这关闭 L7/L8 的已复现集成缺口,不重复计算已交付 capture,也不宣称全局默认已切换。 +[操作与边界](../../reference/reviewed-coordination-promotion.zh-CN.md)。 终结 caller 现将审核与验证绑定 canonical 来源,历史回执恢复不再依赖私有 argv。 Agent 完成和 Monitor 停止复用普通编辑的当前 head 显示确认。 diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 0f9a74055d..d1ffe7790a 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -14,12 +14,16 @@ --- -## Current delivery frontier (2026-09-24) +## Current delivery frontier (2026-09-25) -The `d64c4d377`/open-PR audit withdraws earlier “5–8 / 6–8 / 7–9” estimates. -Implemented code, six relevant open PRs, four proposed new batches (including -complete-source transport) and D1–D3 evidence are separate units; four batches -are not a guaranteed total PR count. Use the [reconciled inventory and exits](ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.md) as the current plan. +Audit `37bbaec79` and current PR states: complete-source transport, transaction +capture, source assembly and the five previously open caller/event fixes are +merged, not future implementation. After the current promotion-admission repair, +three named code boundaries remain planned: external-effect execution fencing; +event-writer binding plus whole-Goal migration/rollback; default onboarding plus +bounded Python retirement. #4931 and outstanding D2 evidence are tracked +separately. Three is a delivery plan, not a guaranteed total PR count. +[Current inventory and exits](ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.md). ## Observation writer retirement (2026-09-24) @@ -38,7 +42,13 @@ Retain T0 caller/parity inventory, T1/T2 transaction/effect convergence, T3 comp ## Current implementation checkpoint -The current [event transaction and default-cutover plan](ledger/shared-goal-authority-state-provider-v0/2026-09-24-event-completion-transaction.md) estimates 5–8 complete packages conditionally. #4967 source assembly and #4968 capture delivery are already delivered; event-writer binding remains open, with atomic completion repaired here as a prerequisite. Earlier counts below describe historical checkpoints, not additional current work. +Promotion admission now binds complete sources to a current registry witness +and rechecks it inside the TS lock scope. Saved execution retains the reviewed +handoff policy, and failures report durable fence presence. Recovery of a +committed operation still follows its original fence/receipt instead of requiring +the retired source to become valid again. This repairs demonstrated L7/L8 +integration defects; it neither recounts shipped capture nor flips global defaults. +[Operation and boundaries](../../reference/reviewed-coordination-promotion.md). Canonical collection transport now uses snapshot-bound, byte-bounded TS pages. The same `canonicalTodoCollection` owner validates both the retained direct list diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index 306371f9ca..623f91e129 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -14,11 +14,13 @@ --- -## 当前交付边界(2026-09-24) +## 当前交付边界(2026-09-25) -剩余 PR 估算已按 `d64c4d377` 和开放 PR 重新核对,旧“5–8 / 6–8 / 7–9”数字撤回。 -已合入实现、六个相关在途 PR、四个拟新增批次(含当前完整来源传输)和 D1–D3 -验收分开记录;四批不是承诺总计只剩四个 PR。唯一当前清单见[实现核对与退出证据](ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.zh-CN.md)。 +按 `37bbaec79` 与当前 PR 状态核对:完整来源传输、事务捕获、来源组装及此前五个 +在途 caller/event 修复都已合入,不再计入待开发。当前晋升准入修复之后,规划三个 +明确代码边界:外部动作执行区间保护、事件 writer 绑定与整 Goal 迁移/回退闭环、 +默认启用与最后一批有界 Python 退役。#4931 与 D2 的剩余资格证据单列;三个是 +可命名的开发批次,不是保证总 PR 数。[唯一当前清单与退出条件](ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.zh-CN.md)。 ## 旧观测写入退役(2026-09-24) @@ -43,7 +45,11 @@ shared-authority 的当前核对表区分已合入实现、在途 PR、新代码 ## 当前实现检查点 -当前剩余交付以[事件事务与默认切换计划](ledger/shared-goal-authority-state-provider-v0/2026-09-24-event-completion-transaction.zh-CN.md)为准:条件估算 5–8 个完整包。#4967 来源组装、#4968 捕获交付已经完成;事件写入者绑定仍未完成,本批先修复完整完成事务。下文更早的包数属于历史检查点,不能作为当前待办重复计算。 +晋升准入现将完整来源绑定到当前 registry witness,并在 TS 持锁范围内重新校验; +保存计划执行保留已审核的 handoff 策略,失败结果如实报告持久 fence。 +已提交事务的恢复仍按原 fence/receipt,不要求失去权威的旧来源重新有效。 +这关闭 L7/L8 的已复现集成缺口,不重复计算已交付 capture,也不宣称全局默认已切换。 +[操作与边界](../../reference/reviewed-coordination-promotion.zh-CN.md)。 Canonical command 的 receipt/head 观察顺序统一归属 TS:团队规划、Todo 创建/ 修改/领取/终态/归档、Monitor、lease 维护和 Goal acceptance 在读 head 后复查原 diff --git a/docs/reference/reviewed-coordination-promotion.md b/docs/reference/reviewed-coordination-promotion.md index 69fb6de85b..9e0e1361fa 100644 --- a/docs/reference/reviewed-coordination-promotion.md +++ b/docs/reference/reviewed-coordination-promotion.md @@ -14,9 +14,11 @@ fencing and receipt proof; Python only loads the file and transports the request Use an explicitly enabled, bootstrapped and qualified runtime shadow. Its qualification must cover real mutations and required event classes; an empty -shadow or a saved JSON file cannot substitute for that evidence. Existing v0 -promotion still requires `hard_lease`. Provider selection and migration approval -remain separate from these commands. +shadow or a saved JSON file cannot substitute for that evidence. Without an explicit migration, v0 +promotion still requires `hard_lease`. Use `--handoff-mode-migration preserve` +to retain the current mode, or `hard_lease` to review a claim-preserving mode +transition. Saved execution retains that choice and does not accept overrides. +Neither strategy weakens source, capture or transaction qualification. ```bash loopx --format json coordination-shadow promote \ @@ -48,6 +50,27 @@ new preview after legitimate source changes; do not edit the old digest to force acceptance. The digest detects changed intent; the durable fence and provider state establish whether that intent may proceed. +## Registration changes and retry + +The Python source adapter binds the current Goal, paths and registered Agent +facts to one registry byte digest. TS rechecks it under the existing cross-runtime +lock and retains that lock through admission/commit. Stale sources return +`source_registry_changed_retry`; changed Agent facts in a saved plan return +`promotion_registration_changed_retry`. Refresh the source and review a new +preview, never edit the digest. A held registry lock returns +`source_registry_busy_retry`, releasing source locks before retry so registry-first +configuration writers cannot deadlock. Ordinary shadow-disabled paths gain no lock. + +This is local source consistency, not an authority grant or cross-host database +transaction. Unrelated registry edits may conservatively require a retry. +Existing persisted receipts/fences remain recoverable without the new witness; +fresh bootstrap, inspect, qualify and promote must recapture it. Pre-promotion +rollback can still quarantine a shadow whose current registration is damaged. + +On failure, `legacy_writer_fenced=true` reports an actual retained fence, not +proof this invocation created it. Unknown presence is `null`, never permission +to use legacy writes. Recover with the original plan; do not delete the fence. + ## Recover the original cutover ```bash @@ -114,11 +137,9 @@ File and SQLite use their existing local stores. PostgreSQL follows the same transaction/readback contract through its service-owned factory; a local CLI selector alone does not provide a PostgreSQL connection or tenant authority. -The claim-preserving migration work in PR #4870 is a complementary prerequisite -for Goals that need explicit `preserve` or a claim-preserving `hard_lease` -transition. The two changes overlap the promotion orchestration and must be -integrated and tested together; this saved-plan feature alone does not enable -that policy conversion on a v0-only checkout. +The merged claim-preserving migration and saved-plan paths are now exercised +together: default, `preserve` and `hard_lease` strategies on File/SQLite share +the same qualification and recovery owners. Default-on promotion, SQLite long-duration qualification, post-promotion export or rollback, and retirement of remaining Python callers retain their RFC gates. diff --git a/docs/reference/reviewed-coordination-promotion.zh-CN.md b/docs/reference/reviewed-coordination-promotion.zh-CN.md index 557e90c12a..8c71e788f2 100644 --- a/docs/reference/reviewed-coordination-promotion.zh-CN.md +++ b/docs/reference/reviewed-coordination-promotion.zh-CN.md @@ -9,7 +9,9 @@ fence 与 receipt 证明由 TypeScript 协调边界负责;Python 只读文件 ## 操作 先显式启用并 bootstrap runtime shadow,让它捕获真实变更并通过资格校验。 -现有 v0 晋升仍要求 Goal 已处于 `hard_lease`;保存 JSON 不会降低这个条件。 +不指定模式转换时,v0 晋升仍要求 `hard_lease`。显式传 `--handoff-mode-migration preserve` +可保留当前模式;`hard_lease` 则审核转换及现有 claim/lease。保存的计划保留这个选择, +执行时不能覆盖它;两者都不降低捕获、来源与事务资格条件。 ```bash loopx --format json coordination-shadow promote \ @@ -36,6 +38,23 @@ loopx --format json coordination-shadow promote \ 来强行通过。digest 说明“执行的是哪份意图”,持久 fence 和 provider 状态说明 “这份意图现在能否执行”。 +## 注册变化与重试 + +Python 来源适配器将当前 Goal、路径和已注册 Agent 事实绑定到一次 registry 字节摘要; +TS 在现有跨 runtime 锁内再次核对,并持锁到准入/提交结束。来源已失效返回 +`source_registry_changed_retry`,保存计划的 Agent 事实改变返回 +`promotion_registration_changed_retry`。重新读取来源与审核预览,不要修改摘要。 +注册修改正在持锁时返回 `source_registry_busy_retry`;释放来源锁后可重试,避免与 +先锁 registry 再锁状态的配置命令互相等待。未启用 shadow 的普通路径不增加这把锁。 + +这是本机来源一致性,不是授权授予,也不是跨主机数据库事务。完整 registry 的无关 +修改也可能要求重试,优先保证来源证据明确。旧持久回执和 fence 的恢复不要求新增 +registry witness;新的 bootstrap、inspect、qualify 和 promote 必须重新捕获它。 +pre-promotion rollback 仍可隔离损坏来源对应的 shadow,不要求当前注册恢复正常。 + +晋升失败时 `legacy_writer_fenced=true` 表示实际存在持久 fence,不表示本次创建了它; +无法确定时为 `null`,不能当成旧路径可写。使用原计划恢复,不能删除 fence 逃过拒绝。 + ## 断点恢复 ```bash @@ -86,10 +105,9 @@ projection digest 和 partition marker;分配序号不返回事务行,drain capability grant。它们在获授权的切换后继续使用既有 canonical 路由和展示合同。 PostgreSQL 仍需要服务持有的 factory 与租户权限,不能仅靠本地 selector 接通数据库。 -PR #4870 提供保留 claim 的 `preserve`/`hard_lease` 转换,属于互补前置工作; -两者涉及同一个晋升编排,需要组合验证。本功能单独合入不会让 v0 checkout 自动获得 -这些模式转换。默认切换、SQLite 长时资格、晋升后导出/回退、剩余 Python 删除,仍 -遵守 RFC 的独立门槛。 +已合入的 claim-preserving 转换与保存计划现在组合验证:File/SQLite 的默认、 +`preserve`、`hard_lease` 三种路径均使用相同资格与恢复 owner。 +默认切换、SQLite 长时资格、晋升后导出/回退、剩余 Python 删除仍遵守 RFC 独立门槛。 恢复用于向前补齐或确认原切换,不是 rollback。不要删除活跃 fence、重置 canonical 存储或替换源文件来绕过拒绝。执行前放弃一份预览,只需停止使用该文件。 From 0168a63a0da45dae4e22d29cb07e939bf9cec29e Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:52:01 +0800 Subject: [PATCH 4/6] docs(authority): label retained transport evidence as historical Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../2026-09-24-default-cutover-reconciliation.md | 2 +- .../2026-09-24-default-cutover-reconciliation.zh-CN.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.md b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.md index 9f1c26faa2..bbddf9433f 100644 --- a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.md +++ b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.md @@ -63,7 +63,7 @@ capacity qualification remain its separate medium-term path. Local default does not wait for PostgreSQL deployment; a passing conformance suite does not establish production service readiness. -## Complete-source transport and budget decision +## Delivered #5013: complete-source transport and budget decision Before #5013, `test_canonical_snapshot_integration` failed before provider admission: complete source projection exceeds the 2 MiB request limit. Paging diff --git a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.zh-CN.md b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.zh-CN.md index a32278b773..483db469ec 100644 --- a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.zh-CN.md +++ b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.zh-CN.md @@ -53,9 +53,9 @@ PostgreSQL 复用 typed command 和 AuthorityStore,部署 transport、认证/t 策略、restore identity、运维和 capacity 资格仍是独立中期路线。本地默认不等待 PostgreSQL 部署,conformance 通过也不等于生产服务已合格。 -## 完整来源传输与预算决定 +## 已交付 #5013:完整来源传输与预算决定 -此基线的 `test_canonical_snapshot_integration` 在 provider 准入之前失败:完整 +在 #5013 之前,`test_canonical_snapshot_integration` 曾在 provider 准入之前失败:完整 来源投影超过 2 MiB request 上限。canonical 读取分页已实现,但 source capture 及管理命令仍传完整投影。裁剪来源记录会破坏 digest/parity;扩大通用 RPC 上限会 影响所有方法。 From 66ffb1272bcbc68415d07abf05b4ccd94e3632f5 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:12:44 +0800 Subject: [PATCH 5/6] fix(coordination): close promotion fence and global registry review gaps Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../reviewed-coordination-promotion.md | 7 ++- .../reviewed-coordination-promotion.zh-CN.md | 5 +- .../coordination/local_authority_runtime.ts | 42 +++++++------- .../coordination/shadow_registry_source.ts | 4 +- .../control_plane/goals/activation_service.py | 4 +- loopx/control_plane/goals/deletion_service.py | 3 +- loopx/global_registry.py | 4 +- .../test_shadow_registry_source.py | 55 +++++++++++++++++++ .../reviewed_promotion.test.ts | 25 +++++++++ ...est_global_registry_write_serialization.py | 10 ++-- 10 files changed, 122 insertions(+), 37 deletions(-) diff --git a/docs/reference/reviewed-coordination-promotion.md b/docs/reference/reviewed-coordination-promotion.md index 9e0e1361fa..e1dc807cf9 100644 --- a/docs/reference/reviewed-coordination-promotion.md +++ b/docs/reference/reviewed-coordination-promotion.md @@ -59,7 +59,12 @@ lock and retains that lock through admission/commit. Stale sources return `promotion_registration_changed_retry`. Refresh the source and review a new preview, never edit the digest. A held registry lock returns `source_registry_busy_retry`, releasing source locks before retry so registry-first -configuration writers cannot deadlock. Ordinary shadow-disabled paths gain no lock. +configuration writers cannot deadlock. Ordinary Todo writes gain no new lock. +Project and global registry mutations share the existing marker-plus-kernel lock +protocol, including global sync, Goal activation and deletion. This registry +interoperability applies even without shadow opt-in; read-only previews still +take no mutation lock. A timeout inside the protected operation retains its +original cause instead of being relabeled as registry contention. This is local source consistency, not an authority grant or cross-host database transaction. Unrelated registry edits may conservatively require a retry. diff --git a/docs/reference/reviewed-coordination-promotion.zh-CN.md b/docs/reference/reviewed-coordination-promotion.zh-CN.md index 8c71e788f2..64635f2c4b 100644 --- a/docs/reference/reviewed-coordination-promotion.zh-CN.md +++ b/docs/reference/reviewed-coordination-promotion.zh-CN.md @@ -45,7 +45,10 @@ TS 在现有跨 runtime 锁内再次核对,并持锁到准入/提交结束。 `source_registry_changed_retry`,保存计划的 Agent 事实改变返回 `promotion_registration_changed_retry`。重新读取来源与审核预览,不要修改摘要。 注册修改正在持锁时返回 `source_registry_busy_retry`;释放来源锁后可重试,避免与 -先锁 registry 再锁状态的配置命令互相等待。未启用 shadow 的普通路径不增加这把锁。 +先锁 registry 再锁状态的配置命令互相等待。普通 Todo 写入不新增锁。 +项目和全局 registry 的修改共用既有 marker + kernel 组合锁,包括全局同步、 +Goal 启停及删除;这一 registry 互操作协议也适用于未启用 shadow 的场景,只读 +预览仍不取写锁。已进入受保护操作后发生的其他锁超时保留原始原因,不冒充 registry 竞争。 这是本机来源一致性,不是授权授予,也不是跨主机数据库事务。完整 registry 的无关 修改也可能要求重试,优先保证来源证据明确。旧持久回执和 fence 的恢复不要求新增 diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index 7fc2f6dc52..60a53f4b15 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -142,6 +142,7 @@ export async function reviewLocalCoordinationAuthorityPromotion( ): Promise { const schema = LOCAL_COORDINATION_PROMOTION_REVIEW_RESULT_SCHEMA; let writerFenceVerified = false; + let result: JsonObject; let sourceScope: {runtimeRoot: string; goalId: string} | null = null; try { const input = decodeRuntimeShadowRequest( @@ -184,7 +185,7 @@ export async function reviewLocalCoordinationAuthorityPromotion( ) ?? await openRuntimeStore(input.runtime_root, input.goal_id, dependencies); const canonicalAuthority = sourceAuthorityFor(canonical); - return await withShadowMaintenanceLock(input.runtime_root, input.goal_id, () => + result = await withShadowMaintenanceLock(input.runtime_root, input.goal_id, () => withShadowSourceLocks(input, async () => { const qualification = await qualifyCoordinationRuntimeShadowUnderLocks( input, @@ -202,7 +203,6 @@ export async function reviewLocalCoordinationAuthorityPromotion( executed: false, reason_code: "local_authority_shadow_not_qualified", qualification: publicQualification, - legacy_writer_fenced: false, legacy_fallback_used: false, }; } @@ -222,7 +222,6 @@ export async function reviewLocalCoordinationAuthorityPromotion( reason: migration.reason ?? "handoff-mode migration is not ready", qualification: publicQualification, handoff_mode_migration: publicMigration, - legacy_writer_fenced: false, legacy_fallback_used: false, }; const providerRevision = requireAuthorityStoreId( @@ -250,7 +249,7 @@ export async function reviewLocalCoordinationAuthorityPromotion( reason:"The current promotion differs from the reviewed plan; preview and review the new plan.", expected_promotion_plan_sha256:expectedPlan, observed_promotion_plan_sha256:promotionPlanSha256, - legacy_writer_fenced:false, legacy_fallback_used:false, + legacy_fallback_used:false, }; const fence = canonicalAuthorityObject({ schema_version: LEGACY_COORDINATION_WRITER_FENCE_SCHEMA, @@ -294,7 +293,6 @@ export async function reviewLocalCoordinationAuthorityPromotion( executed: false, reason_code: readback.reason_code ?? "local_authority_already_initialized", reason: "canonical local authority is already initialized by different content", - legacy_writer_fenced: true, legacy_fallback_used: false, }; } @@ -302,7 +300,6 @@ export async function reviewLocalCoordinationAuthorityPromotion( schema_version: schema, ...existing, executed: false, - legacy_writer_fenced: false, legacy_fallback_used: false, }; const persistedFence = await loadLegacyCoordinationWriterFence( @@ -316,7 +313,6 @@ export async function reviewLocalCoordinationAuthorityPromotion( executed: false, reason_code: persistedFence.reason_code, reason: persistedFence.reason, - legacy_writer_fenced: false, legacy_fallback_used: false, }; if (recoveringFromFence) { @@ -329,7 +325,6 @@ export async function reviewLocalCoordinationAuthorityPromotion( executed: false, reason_code: "local_authority_writer_fence_conflict", reason: "durable legacy writer fence belongs to a different reviewed promotion", - legacy_writer_fenced: true, legacy_fallback_used: false, }; } @@ -375,7 +370,6 @@ export async function reviewLocalCoordinationAuthorityPromotion( reason_code: fenceResult.reason_code ?? "local_authority_writer_fence_failed", reason: fenceResult.reason ?? "legacy writer fence could not be verified", qualification: publicQualification, - legacy_writer_fenced: false, legacy_fallback_used: false, }; writerFenceVerified = true; @@ -411,23 +405,12 @@ export async function reviewLocalCoordinationAuthorityPromotion( reason: "promotion did not produce an exact canonical readback", reconciliation_required: attempted.interrupted || committed?.status === "ambiguous", qualification: publicQualification, - legacy_writer_fenced: true, legacy_fallback_used: false, }; }), ); } catch (error) { - // A fresh source rejection can occur before a plan is built. It must not - // tell an operator that legacy writes are available if an earlier cutover - // already fenced them. Presence and exact-plan ownership are distinct. - let fencePresence: boolean | null = writerFenceVerified; - if (sourceScope !== null) { - try { - const retained = await loadLegacyCoordinationWriterFence(sourceScope.runtimeRoot, sourceScope.goalId); - fencePresence = retained.status === "loaded" ? true : retained.status === "missing" ? false : null; - } catch { fencePresence = null; } - } - return { + result = { schema_version: schema, status: "failed", executed: false, @@ -435,11 +418,24 @@ export async function reviewLocalCoordinationAuthorityPromotion( ? error.reason_code : "invalid_local_coordination_promotion_review_request", reason: error instanceof Error ? error.message : "promotion review unavailable", - legacy_writer_fenced: fencePresence, legacy_fallback_used: false, ...localAuthorityOpenFailure(error), }; } + // Qualification and plan mismatches return normally; exceptions are not the + // only failed path. Report durable presence for every failed admission, not + // whether this invocation got far enough to build or engage its own fence. + if (result.status === "failed" || result.status === "not_ready") { + let presence: boolean | null = null; + if (sourceScope !== null) { + try { + const retained = await loadLegacyCoordinationWriterFence(sourceScope.runtimeRoot, sourceScope.goalId); + presence = retained.status === "loaded" ? true : retained.status === "missing" ? false : null; + } catch { /* Unreadable presence is unknown, never permission to write. */ } + } + result.legacy_writer_fenced = presence; + } + return result; } /** Monitor observation and successors share the existing writer/fence lifetime. */ @@ -1525,6 +1521,6 @@ export async function executeReviewedCoordinationPromotion( return {schema_version:REVIEWED_PROMOTION_OPERATION_RESULT_SCHEMA, status:"failed",executed:false,reason_code:"invalid_reviewed_promotion_plan", reason:error instanceof Error ? error.message : "reviewed promotion is unavailable", - legacy_writer_fenced:false,legacy_fallback_used:false}; + legacy_writer_fenced:null,legacy_fallback_used:false}; } } diff --git a/loopx/control_plane/coordination/shadow_registry_source.ts b/loopx/control_plane/coordination/shadow_registry_source.ts index ad484786d6..eef375f2bc 100644 --- a/loopx/control_plane/coordination/shadow_registry_source.ts +++ b/loopx/control_plane/coordination/shadow_registry_source.ts @@ -50,16 +50,18 @@ export function requirePromotionRegisteredAgents(snapshot: JsonObject, agents: r export async function withShadowRegistrySource(snapshot: JsonObject, operation: () => Promise): Promise { const source = registrySource(snapshot); + let acquired = false; try { // Existing registry administration can hold R before requesting a source // lock. We already hold source locks: never wait for R in the reverse order. // A busy registry releases our locks so its writer can finish and we retry. return await withFileMutationLock(source.path, async () => { + acquired = true; await verifyShadowRegistrySource(snapshot); return await operation(); }, 0); } catch (error) { - if (error instanceof EffectRuntimeLockTimeoutError) { + if (!acquired && error instanceof EffectRuntimeLockTimeoutError) { throw new ShadowManagementError("source_registry_busy_retry"); } throw error; diff --git a/loopx/control_plane/goals/activation_service.py b/loopx/control_plane/goals/activation_service.py index 8599146b7c..99eab4e745 100644 --- a/loopx/control_plane/goals/activation_service.py +++ b/loopx/control_plane/goals/activation_service.py @@ -10,7 +10,7 @@ from ..projects.registry_codec import project_registry_transaction from ...configuration_transaction import configuration_payload_revision -from ...file_lock import exclusive_file_lock +from ...file_lock import exclusive_cross_runtime_file_lock from ...global_registry import sync_project_registry_to_global from ...history import load_registry from ...registry import registry_goals @@ -383,7 +383,7 @@ def set_goal_activation_state( operation="set_goal_activation_state", ) as transaction: target_lock = ( - exclusive_file_lock( + exclusive_cross_runtime_file_lock( target_registry, operation="set_goal_activation_state", ) diff --git a/loopx/control_plane/goals/deletion_service.py b/loopx/control_plane/goals/deletion_service.py index 6ca270d547..d1e8b528e1 100644 --- a/loopx/control_plane/goals/deletion_service.py +++ b/loopx/control_plane/goals/deletion_service.py @@ -20,7 +20,6 @@ from ...file_lock import ( EFFECT_MUTATION_LOCK_SUFFIX, exclusive_cross_runtime_file_lock, - exclusive_file_lock, lock_holder_path, lock_incident_path, ) @@ -607,7 +606,7 @@ def _execute_deletion( ) ) stack.enter_context( - exclusive_file_lock(target_registry, operation="delete_stopped_goal") + exclusive_cross_runtime_file_lock(target_registry, operation="delete_stopped_goal") ) locked_state = _load_locked_payloads( requested_registry=requested_registry, diff --git a/loopx/global_registry.py b/loopx/global_registry.py index d2dde65a0b..68adcf3df8 100644 --- a/loopx/global_registry.py +++ b/loopx/global_registry.py @@ -12,7 +12,7 @@ from .control_plane.projects.contract import validate_project_record_bindings from .control_plane.projects.registry_codec import load_registry from .control_plane.runtime.time import now_local_iso -from .file_lock import exclusive_file_lock +from .file_lock import exclusive_cross_runtime_file_lock from .paths import DEFAULT_RUNTIME_ROOT, global_registry_path, resolve_runtime_root from .registry import read_json, registry_goals from .registry_writability import is_write_denied_error, probe_registry_write_path @@ -99,7 +99,7 @@ def mutate_global_registry( ) -> dict[str, Any]: """Apply one authoritative global-registry read-modify-write transaction.""" - with exclusive_file_lock(global_path, operation=operation): + with exclusive_cross_runtime_file_lock(global_path, operation=operation): return _mutate_global_registry_locked(global_path, reducer) diff --git a/tests/control_plane/test_shadow_registry_source.py b/tests/control_plane/test_shadow_registry_source.py index 03d5131c39..452a746469 100644 --- a/tests/control_plane/test_shadow_registry_source.py +++ b/tests/control_plane/test_shadow_registry_source.py @@ -147,3 +147,58 @@ def test_strict_registry_envelope_keeps_existing_codec(tmp_path: Path): ) projection, snapshot = capture(state, runtime, registry, goal) assert bootstrap(runtime, goal, projection, snapshot)["status"] == "applied" + + +@pytest.mark.parametrize("writer", ["sync", "activation", "deletion"]) +def test_global_registry_writers_respect_native_promotion_marker(tmp_path, writer): + from loopx.file_lock import exclusive_mutation_file_lock, LockAcquireTimeoutError + from loopx.global_registry import sync_project_registry_to_global + from loopx.control_plane.goals.activation_service import set_goal_activation_state + from loopx.control_plane.goals.deletion_service import delete_stopped_goal + + _state, runtime, registry, goal, _data = workspace(tmp_path) + global_path = runtime / "registry.global.json" + + def sync(): + return sync_project_registry_to_global( + registry_path=registry, runtime_root_override=str(runtime), dry_run=False + ) + + assert sync()["ok"] + if writer == "deletion": + assert set_goal_activation_state( + registry_path=global_path, + goal_id=goal["id"], + state="stopped", + actor_kind="owner", + execute=True, + )["ok"] + action = ( + sync + if writer == "sync" + else ( + lambda: set_goal_activation_state( + registry_path=global_path, + goal_id=goal["id"], + state="stopped", + actor_kind="owner", + execute=True, + ) + ) + if writer == "activation" + else ( + lambda: delete_stopped_goal( + registry_path=global_path, goal_id=goal["id"], execute=True + ) + ) + ) + before = (registry.read_bytes(), global_path.read_bytes()) + # This is the exact marker protocol held by native promotion. No mock can + # make a kernel-only writer pass this exclusion test. + with exclusive_mutation_file_lock( + global_path, operation="native-promotion-fixture" + ): + with pytest.raises(LockAcquireTimeoutError): + action() + assert (registry.read_bytes(), global_path.read_bytes()) == before + assert action()["ok"] diff --git a/tests/control_plane_ts/reviewed_promotion.test.ts b/tests/control_plane_ts/reviewed_promotion.test.ts index ee28544108..33b8ed4164 100644 --- a/tests/control_plane_ts/reviewed_promotion.test.ts +++ b/tests/control_plane_ts/reviewed_promotion.test.ts @@ -353,3 +353,28 @@ for (const strategy of ["preserve", "hard_lease"] as const) { assert.equal(replay.status, "replayed"); }); } + +for (const rejection of ["qualification", "plan", "migration"] as const) { + test(`failed ${rejection} admission observes the existing promotion fence`, async (t) => { + const {root, request} = await fixture(t); + assert.equal((await reviewLocalCoordinationAuthorityPromotion({...request, execute: true})).status, "applied"); + const changed = rejection === "qualification" ? {minimum_operations: 10000} + : rejection === "plan" ? {expected_promotion_plan_sha256: "0".repeat(64)} + : {handoff_mode_migration: "hard_lease", registered_agents: []}; + const result = await reviewLocalCoordinationAuthorityPromotion({...request, ...changed}); + assert.equal(result.status, "not_ready", JSON.stringify(result)); + assert.equal(result.legacy_writer_fenced, true); + assert.equal(result.executed, false); + assert.equal((await loadLegacyCoordinationWriterFence(root, "goal-a")).status, "loaded"); + }); +} + +test("a downstream timeout is not mislabeled as registry contention", async (t) => { + const {request} = await fixture(t); + const {withShadowRegistrySource} = await import("../../loopx/control_plane/coordination/shadow_registry_source.ts"); + const {EffectRuntimeLockTimeoutError} = await import("../../loopx/control_plane/effect_runtime_errors.ts"); + const downstream = new EffectRuntimeLockTimeoutError("canonical store lock timed out"); + await assert.rejects(withShadowRegistrySource(request.source_snapshot as JsonObject, async () => { + throw downstream; + }), (error) => error === downstream); +}); diff --git a/tests/test_global_registry_write_serialization.py b/tests/test_global_registry_write_serialization.py index 0e032aff6e..124a8d5eee 100644 --- a/tests/test_global_registry_write_serialization.py +++ b/tests/test_global_registry_write_serialization.py @@ -104,7 +104,7 @@ def recording_write(path: Path, payload: dict[str, Any]) -> None: events.append(f"write:{'locked' if held else 'unlocked'}") real_write(path, payload) - monkeypatch.setattr(global_registry, "exclusive_file_lock", recording_lock) + monkeypatch.setattr(global_registry, "exclusive_cross_runtime_file_lock", recording_lock) monkeypatch.setattr(global_registry, "_load_global_registry", recording_load) monkeypatch.setattr(global_registry, "write_json", recording_write) @@ -140,7 +140,7 @@ def recording_lock(path: Path, **kwargs: Any) -> Iterator[Path]: acquired.append(path) yield path - monkeypatch.setattr(global_registry, "exclusive_file_lock", recording_lock) + monkeypatch.setattr(global_registry, "exclusive_cross_runtime_file_lock", recording_lock) result = sync_project_registry_to_global( registry_path=registry_path, @@ -195,7 +195,7 @@ def recording_write(path: Path, payload: dict[str, Any]) -> None: events.append(f"backup:{'locked' if held else 'unlocked'}") real_write(path, payload) - monkeypatch.setattr(global_registry, "exclusive_file_lock", recording_lock) + monkeypatch.setattr(global_registry, "exclusive_cross_runtime_file_lock", recording_lock) monkeypatch.setattr(global_registry, "_load_global_registry", recording_load) monkeypatch.setattr(global_registry, "write_json", recording_write) @@ -231,7 +231,7 @@ def test_retire_rechecks_live_route_inside_the_global_registry_lock( registry_path.unlink() state_path.unlink() global_path = global_registry_path(runtime_root) - real_lock = global_registry.exclusive_file_lock + real_lock = global_registry.exclusive_cross_runtime_file_lock restored: list[Path] = [] @contextmanager @@ -247,7 +247,7 @@ def restore_live_route_before_lock(path: Path, **kwargs: Any) -> Iterator[Path]: monkeypatch.setattr( global_registry, - "exclusive_file_lock", + "exclusive_cross_runtime_file_lock", restore_live_route_before_lock, ) From e260813e749bc896b1fd13487db8579a8ed18b3a Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:16:44 +0800 Subject: [PATCH 6/6] test(coordination): type the captured promotion source explicitly Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- tests/control_plane_ts/reviewed_promotion.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/control_plane_ts/reviewed_promotion.test.ts b/tests/control_plane_ts/reviewed_promotion.test.ts index 33b8ed4164..77e4bf40de 100644 --- a/tests/control_plane_ts/reviewed_promotion.test.ts +++ b/tests/control_plane_ts/reviewed_promotion.test.ts @@ -374,7 +374,7 @@ test("a downstream timeout is not mislabeled as registry contention", async (t) const {withShadowRegistrySource} = await import("../../loopx/control_plane/coordination/shadow_registry_source.ts"); const {EffectRuntimeLockTimeoutError} = await import("../../loopx/control_plane/effect_runtime_errors.ts"); const downstream = new EffectRuntimeLockTimeoutError("canonical store lock timed out"); - await assert.rejects(withShadowRegistrySource(request.source_snapshot as JsonObject, async () => { + await assert.rejects(withShadowRegistrySource((request as JsonObject).source_snapshot as JsonObject, async () => { throw downstream; }), (error) => error === downstream); });