From 2fc9317264eec56a67e147abcd9bed00d1b17f6d Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:04:20 +0800 Subject: [PATCH 1/2] feat(coordination): preserve claims during authority promotion Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/cli_commands/coordination_shadow.py | 26 ++ .../coordination/local_authority_runtime.ts | 148 +++++++++++- .../promotion_handoff_migration.ts | 202 ++++++++++++++++ .../coordination/runtime_shadow.py | 10 + .../control_plane/coordination/todo_claim.ts | 21 +- .../coordination/todo_write_scopes.ts | 21 ++ .../test_coordination_shadow_command.py | 19 ++ .../local_authority_runtime.test.ts | 224 ++++++++++++++++++ .../local_promotion_fixture.ts | 2 +- .../promotion_handoff_migration.test.ts | 148 ++++++++++++ 10 files changed, 790 insertions(+), 31 deletions(-) create mode 100644 loopx/control_plane/coordination/promotion_handoff_migration.ts create mode 100644 loopx/control_plane/coordination/todo_write_scopes.ts create mode 100644 tests/control_plane_ts/promotion_handoff_migration.test.ts diff --git a/loopx/cli_commands/coordination_shadow.py b/loopx/cli_commands/coordination_shadow.py index a309f287c9..95902f8379 100644 --- a/loopx/cli_commands/coordination_shadow.py +++ b/loopx/cli_commands/coordination_shadow.py @@ -6,6 +6,8 @@ from collections.abc import Callable from pathlib import Path +from ..agent_registry import registered_agent_ids_for_goal + # The projection builder and lease loader are reached through this module by # tests that seed and read the shadow through the command surface; keep them # importable here even when the command does not call them directly. @@ -108,6 +110,15 @@ def register_coordination_shadow_command( default=[], help="Required verified outbox write class; repeat for multiple classes.", ) + action.add_argument( + "--handoff-mode-migration", + choices=("preserve", "hard_lease"), + help=( + "Explicitly preserve the source handoff mode or migrate it to hard_lease " + "inside the reviewed authority cutover. Omit to retain the v0 requirement " + "that the source already uses hard_lease." + ), + ) if name == "read-candidate": action.add_argument( "--todo-id", @@ -329,12 +340,21 @@ def handle_coordination_shadow_command( and read_candidate.get("decision_read_from_shadow") is False ) if args.coordination_shadow_command == "promote": + registered_agents = registered_agent_ids_for_goal(goal) operation_digest = _projection_version( { "goal_id": args.goal_id, "projection": projection, "minimum_operations": args.minimum_operations, "required_event_kinds": args.require_event_kind, + **( + { + "handoff_mode_migration": args.handoff_mode_migration, + "registered_agents": registered_agents, + } + if args.handoff_mode_migration is not None + else {} + ), } ) promotion = review_local_coordination_authority_promotion( @@ -346,6 +366,12 @@ def handle_coordination_shadow_command( source_snapshot=source_snapshot, minimum_operations=args.minimum_operations, required_event_kinds=args.require_event_kind, + handoff_mode_migration=args.handoff_mode_migration, + registered_agents=( + registered_agents + if args.handoff_mode_migration is not None + else None + ), execute=bool(args.execute), ) payload["executed"] = bool(args.execute) diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index 2708616d1f..33094d79ee 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -89,7 +89,13 @@ import { normalizeIdempotencyKey, normalizeTtl, } from "../work_items/task_lease_acquire.ts"; -import { compactPythonWhitespace } from "./todo_agents.ts"; +import {compactPythonWhitespace, normalizeRegisteredTodoAgents} from "./todo_agents.ts"; +import { + normalizePromotionHandoffModeMigration, + planPromotionHandoffMigration, + publicPromotionHandoffMigrationPlan, + type PromotionHandoffModeMigration, +} from "./promotion_handoff_migration.ts"; export const LOCAL_COORDINATION_TODO_CLAIM_REQUEST_SCHEMA = "loopx_local_coordination_todo_claim_request_v0"; @@ -141,7 +147,8 @@ export async function reviewLocalCoordinationAuthorityPromotion( const input = decodeRuntimeShadowRequest( value, LOCAL_COORDINATION_PROMOTION_REVIEW_REQUEST_SCHEMA, - ["operation_id", "minimum_operations", "required_event_kinds", "execute"], + ["operation_id", "minimum_operations", "required_event_kinds", "execute", + "handoff_mode_migration", "registered_agents"], ); const operationId = requireAuthorityStoreId(input.operation_id, "operation id"); const minimumOperations = requiredPositiveSafeInteger( @@ -153,6 +160,16 @@ export async function reviewLocalCoordinationAuthorityPromotion( "required_event_kinds", ); if (typeof input.execute !== "boolean") throw new Error("execute must be a JSON boolean"); + const handoffModeMigration = normalizePromotionHandoffModeMigration( + input.handoff_mode_migration, + ); + const explicitHandoffMigration = input.handoff_mode_migration !== undefined; + if (explicitHandoffMigration && !Array.isArray(input.registered_agents)) { + throw new Error("registered_agents must be supplied for an explicit handoff-mode migration"); + } + const registeredAgents = input.registered_agents === undefined || input.registered_agents === null + ? [] + : normalizeRegisteredTodoAgents(input.registered_agents as string[]); const statePath = await realpath(String(input.source_snapshot.state_path)); const shadow = dependencies.createShadowStore?.( shadowDirectory(input.runtime_root), @@ -186,13 +203,22 @@ export async function reviewLocalCoordinationAuthorityPromotion( legacy_fallback_used: false, }; } - if (head.handoff_mode !== "hard_lease") return { + const migration = planPromotionHandoffMigration( + head, + input.goal_id, + handoffModeMigration, + registeredAgents, + new Date(), + ); + const publicMigration = publicPromotionHandoffMigrationPlan(migration); + if (!migration.ready) return { schema_version: schema, status: "not_ready", executed: false, - reason_code: "local_authority_promotion_requires_hard_lease", - reason: "v0 whole-Goal coordination-authority promotion requires an already-qualified hard_lease Goal", + reason_code: migration.reason_code ?? "handoff_mode_migration_conflict", + reason: migration.reason ?? "handoff-mode migration is not ready", qualification: publicQualification, + handoff_mode_migration: publicMigration, legacy_writer_fenced: false, legacy_fallback_used: false, }; @@ -209,6 +235,11 @@ export async function reviewLocalCoordinationAuthorityPromotion( expected_shadow_projection_sha256: projectionSha256, minimum_operations: minimumOperations, required_event_kinds: requiredEventKinds, + ...(explicitHandoffMigration ? { + handoff_mode_migration: handoffModeMigration, + registered_agents: registeredAgents, + expected_target_projection_sha256: migration.target_projection_sha256, + } : {}), }); const fence = canonicalAuthorityObject({ schema_version: LEGACY_COORDINATION_WRITER_FENCE_SCHEMA, @@ -229,6 +260,11 @@ export async function reviewLocalCoordinationAuthorityPromotion( expected_shadow_projection_sha256: projectionSha256, minimum_operations: minimumOperations, required_event_kinds: requiredEventKinds, + ...(explicitHandoffMigration ? { + handoff_mode_migration: handoffModeMigration, + registered_agents: registeredAgents, + expected_target_projection_sha256: migration.target_projection_sha256, + } : {}), writer_fence: fence, }; fenceEvidence.current = {runtimeRoot: input.runtime_root, goalId: input.goal_id, fence}; @@ -295,6 +331,7 @@ export async function reviewLocalCoordinationAuthorityPromotion( expected_shadow_projection_sha256: projectionSha256, minimum_operations: minimumOperations, required_event_kinds: [...requiredEventKinds].sort(authorityUnicodeCompare), + handoff_mode_migration: publicMigration, writer_fence: fence, rollback_identity: { provider: "file_v0", @@ -339,8 +376,9 @@ export async function reviewLocalCoordinationAuthorityPromotion( ...identity, schema_version: "loopx_local_coordination_promotion_event_v0", mode_transition: `legacy_canonical_to_${canonicalAuthority}`, + handoff_mode_transition: `${migration.previous_mode}_to_${migration.target_mode}`, }], - next_projection: head, + next_projection: migration.target_projection, receipts: [identity], }); const readback = await promotionReadback(canonical, request); @@ -529,6 +567,9 @@ interface LocalCoordinationPromotionRequest { expected_shadow_projection_sha256: string; minimum_operations: number; required_event_kinds: string[]; + handoff_mode_migration?: PromotionHandoffModeMigration; + registered_agents?: string[]; + expected_target_projection_sha256?: string; writer_fence: JsonObject; } @@ -540,13 +581,15 @@ export interface LocalCoordinationPromotionPlanInput { expected_shadow_projection_sha256: string; minimum_operations: number; required_event_kinds: string[]; + handoff_mode_migration?: PromotionHandoffModeMigration; + registered_agents?: string[]; + expected_target_projection_sha256?: string; } export function localCoordinationPromotionPlanSha256( value: LocalCoordinationPromotionPlanInput, ): string { - const plan = canonicalAuthorityObject({ - schema_version: "loopx_local_coordination_promotion_plan_v0", + const base = { goal_id: requireAuthorityStoreId(value.goal_id, "goal id"), operation_id: requireAuthorityStoreId(value.operation_id, "operation id"), canonical_authority: requireAuthorityStoreId( @@ -569,6 +612,20 @@ export function localCoordinationPromotionPlanSha256( value.required_event_kinds, "required_event_kinds", ).sort(authorityUnicodeCompare), + }; + const explicitMigration = value.handoff_mode_migration !== undefined; + const plan = canonicalAuthorityObject(explicitMigration ? { + schema_version: "loopx_local_coordination_promotion_plan_v1", + ...base, + handoff_mode_migration: normalizePromotionHandoffModeMigration(value.handoff_mode_migration), + registered_agents: normalizeRegisteredTodoAgents(value.registered_agents ?? []), + expected_target_projection_sha256: requireAuthorityStoreId( + value.expected_target_projection_sha256, + "expected target projection sha256", + ), + } : { + schema_version: "loopx_local_coordination_promotion_plan_v0", + ...base, }, "local coordination promotion plan"); return canonicalAuthoritySha256(plan); } @@ -579,6 +636,10 @@ function decodePromotionRequest(value: unknown): LocalCoordinationPromotionReque throw new Error("local coordination promotion request schema mismatch"); } const fence = decodeLegacyCoordinationWriterFence(input.writer_fence); + const explicitMigration = input.handoff_mode_migration !== undefined; + if (explicitMigration && !Array.isArray(input.registered_agents)) { + throw new Error("registered_agents must accompany handoff_mode_migration"); + } return { runtime_root: runtimeRoot(input.runtime_root), goal_id: requireAuthorityStoreId(input.goal_id, "goal id"), @@ -603,6 +664,14 @@ function decodePromotionRequest(value: unknown): LocalCoordinationPromotionReque input.required_event_kinds, "required_event_kinds", ), + ...(explicitMigration ? { + handoff_mode_migration: normalizePromotionHandoffModeMigration(input.handoff_mode_migration), + registered_agents: normalizeRegisteredTodoAgents(input.registered_agents as string[]), + expected_target_projection_sha256: requireAuthorityStoreId( + input.expected_target_projection_sha256, + "expected target projection sha256", + ), + } : {}), writer_fence: fence, }; } @@ -617,9 +686,17 @@ function promotionIdentity(request: LocalCoordinationPromotionRequest): JsonObje writer_fence_id: request.writer_fence.fence_id, source_version: request.writer_fence.source_version, promotion_plan_sha256: localCoordinationPromotionPlanSha256(request), + ...(request.handoff_mode_migration === undefined ? {} : { + handoff_mode_migration: request.handoff_mode_migration, + target_projection_sha256: request.expected_target_projection_sha256, + }), }, "local coordination promotion identity"); } +function promotionTargetProjectionSha256(request: LocalCoordinationPromotionRequest): string { + return request.expected_target_projection_sha256 ?? request.expected_shadow_projection_sha256; +} + function matchingReceipt( result: AuthorityStoreReceiptResult, expected: JsonObject, @@ -650,7 +727,7 @@ async function promotionReadback( promotion === undefined || promotion.cursor !== "1" || promotion.operation_id !== request.operation_id || - canonicalAuthoritySha256(promotion.projection) !== request.expected_shadow_projection_sha256 + canonicalAuthoritySha256(promotion.projection) !== promotionTargetProjectionSha256(request) ) { return { matched: false, reason_code: "local_authority_promotion_lineage_mismatch" }; } @@ -675,10 +752,14 @@ function promotionResult( cursor: readback.cursor, source_shadow_provider_revision: request.expected_shadow_provider_revision, source_projection_sha256: request.expected_shadow_projection_sha256, + target_projection_sha256: promotionTargetProjectionSha256(request), writer_fence_id: request.writer_fence.fence_id, source_version: request.writer_fence.source_version, promotion_plan_sha256: localCoordinationPromotionPlanSha256(request), canonical_authority: canonicalAuthority, + ...(request.handoff_mode_migration === undefined ? {} : { + handoff_mode_migration: request.handoff_mode_migration, + }), legacy_writer_fenced: true, legacy_fallback_used: false, }; @@ -833,7 +914,6 @@ export async function promoteLocalCoordinationAuthority( legacy_fallback_used: false, }; indexCoordinationProjection(shadowHead.head, request.goal_id); - const qualification = await qualifyCoordinationRuntimeShadow({ schema_version: COORDINATION_RUNTIME_SHADOW_QUALIFY_REQUEST_SCHEMA, runtime_root: request.runtime_root, @@ -854,6 +934,33 @@ export async function promoteLocalCoordinationAuthority( legacy_fallback_used: false, }; + const migration = planPromotionHandoffMigration( + shadowHead.head, + request.goal_id, + request.handoff_mode_migration, + request.registered_agents ?? [], + new Date(), + ); + if (!migration.ready) return { + schema_version: LOCAL_COORDINATION_PROMOTION_RESULT_SCHEMA, + status: "failed", + reason_code: migration.reason_code ?? "handoff_mode_migration_conflict", + reason: migration.reason ?? "handoff-mode migration is not ready", + handoff_mode_migration: publicPromotionHandoffMigrationPlan(migration), + legacy_writer_fenced: true, + legacy_fallback_used: false, + }; + if (migration.target_projection_sha256 !== promotionTargetProjectionSha256(request)) return { + schema_version: LOCAL_COORDINATION_PROMOTION_RESULT_SCHEMA, + status: "failed", + reason_code: "local_authority_promotion_target_projection_mismatch", + reason: "handoff-mode migration target differs from the reviewed promotion plan", + observed_target_projection_sha256: migration.target_projection_sha256, + expected_target_projection_sha256: promotionTargetProjectionSha256(request), + legacy_writer_fenced: true, + legacy_fallback_used: false, + }; + return await withCanonicalWriter(request.runtime_root, request.goal_id, false, async () => { const finalShadowHead = await shadow.loadAuthority(); if ( @@ -869,6 +976,24 @@ export async function promoteLocalCoordinationAuthority( legacy_fallback_used: false, }; + const finalMigration = planPromotionHandoffMigration( + finalShadowHead.head, + request.goal_id, + request.handoff_mode_migration, + request.registered_agents ?? [], + new Date(), + ); + if (!finalMigration.ready || + finalMigration.target_projection_sha256 !== promotionTargetProjectionSha256(request)) return { + schema_version: LOCAL_COORDINATION_PROMOTION_RESULT_SCHEMA, + status: "failed", + reason_code: finalMigration.reason_code ?? "local_authority_promotion_target_projection_mismatch", + reason: finalMigration.reason ?? "handoff-mode migration changed during qualification", + handoff_mode_migration: publicPromotionHandoffMigrationPlan(finalMigration), + legacy_writer_fenced: true, + legacy_fallback_used: false, + }; + const identity = promotionIdentity(request); const committed = await canonical.commitAuthority({ expected_provider_revision: null, @@ -877,8 +1002,9 @@ export async function promoteLocalCoordinationAuthority( ...identity, schema_version: "loopx_local_coordination_promotion_event_v0", mode_transition: `legacy_canonical_to_${canonicalAuthority}`, + handoff_mode_transition: `${finalMigration.previous_mode}_to_${finalMigration.target_mode}`, }], - next_projection: finalShadowHead.head, + next_projection: finalMigration.target_projection, receipts: [identity], }); if (committed.status === "applied") { diff --git a/loopx/control_plane/coordination/promotion_handoff_migration.ts b/loopx/control_plane/coordination/promotion_handoff_migration.ts new file mode 100644 index 0000000000..0ab9c76345 --- /dev/null +++ b/loopx/control_plane/coordination/promotion_handoff_migration.ts @@ -0,0 +1,202 @@ +import type {JsonObject} from "../effect_program.ts"; +import {requireStringLiteral} from "../runtime_decode.ts"; +import { + canonicalAuthorityBytes, + canonicalAuthoritySha256, +} from "./authority_store_codec.ts"; +import { + indexCoordinationProjection, + validateCoordinationTodoReadModel, +} from "./coordination_projection.ts"; +import { + HANDOFF_MODES, + type HandoffMode, +} from "./handoff_mode_policy.ts"; +import {canonicalTaskLease, canonicalLeaseTodoFact} from "./task_lease_state.ts"; +import {leaseOwnerRejection} from "../work_items/task_lease_eligibility.ts"; +import {leaseEpoch, leaseIsActive, leaseVersion} from "../work_items/task_lease_acquire.ts"; +import {normalizeRegisteredTodoAgents, normalizeTodoAgent} from "./todo_agents.ts"; +import {coordinationTodoWriteScopes} from "./todo_write_scopes.ts"; + +export const PROMOTION_HANDOFF_MODE_MIGRATIONS = [ + "require_existing_hard_lease", + "preserve", + "hard_lease", +] as const; +export type PromotionHandoffModeMigration = typeof PROMOTION_HANDOFF_MODE_MIGRATIONS[number]; + +export interface PromotionHandoffMigrationPlan { + readonly ready: boolean; + readonly reason_code?: string; + readonly reason?: string; + readonly strategy: PromotionHandoffModeMigration; + readonly previous_mode: HandoffMode; + readonly target_mode: HandoffMode; + readonly changed: boolean; + readonly preserved_claims: readonly JsonObject[]; + readonly lease_dispositions: readonly JsonObject[]; + readonly conflicts: readonly JsonObject[]; + readonly target_projection: JsonObject; + readonly target_projection_sha256: string; +} + +export function normalizePromotionHandoffModeMigration( + value: unknown, +): PromotionHandoffModeMigration { + return requireStringLiteral( + value ?? "require_existing_hard_lease", + PROMOTION_HANDOFF_MODE_MIGRATIONS, + "handoff_mode_migration", + ); +} + +/** + * Plan the ownership-policy part of a reviewed authority promotion. + * + * Claims remain assignment facts. Moving to hard_lease never invents a live + * execution lease; the original owner must acquire one through the ordinary + * atomic claim+lease path before its next protected write. + */ +export function planPromotionHandoffMigration( + head: JsonObject, + goalId: string, + strategyValue: unknown, + registeredAgentsValue: readonly string[], + observedAt: Date, +): PromotionHandoffMigrationPlan { + const strategy = normalizePromotionHandoffModeMigration(strategyValue); + const registeredAgents = normalizeRegisteredTodoAgents(registeredAgentsValue); + const indexed = indexCoordinationProjection(head, goalId); + validateCoordinationTodoReadModel(head, goalId); + const previousMode = requireStringLiteral( + head.handoff_mode ?? "legacy", + HANDOFF_MODES, + "promotion source handoff_mode", + ); + const targetMode: HandoffMode = + strategy === "require_existing_hard_lease" || strategy === "hard_lease" + ? "hard_lease" + : previousMode; + const conflicts: JsonObject[] = []; + const validatesMigrationOwnership = strategy !== "require_existing_hard_lease"; + if (strategy === "require_existing_hard_lease" && previousMode !== "hard_lease") { + conflicts.push({ + kind: "source_mode_mismatch", + reason_code: "local_authority_promotion_requires_hard_lease", + previous_mode: previousMode, + required_mode: "hard_lease", + }); + } + + const preservedClaims = [...indexed.todos.values()] + .filter((todo) => todo.archive_state === "active" && todo.done !== true && + typeof todo.claimed_by === "string" && todo.claimed_by.trim().length > 0) + .map((todo) => { + const claimedBy = normalizeTodoAgent(todo.claimed_by, "todo.claimed_by"); + if (validatesMigrationOwnership && !registeredAgents.includes(claimedBy)) { + conflicts.push({ + kind: "claim_owner_not_registered", + reason_code: "claim_owner_not_registered", + todo_id: todo.todo_id, + claimed_by: claimedBy, + }); + } + return { + todo_id: todo.todo_id, + claimed_by: claimedBy, + status: todo.status, + disposition: targetMode === "hard_lease" + ? "preserved_assignment_requires_lease" + : "preserved_assignment", + }; + }); + + const leaseDispositions: JsonObject[] = []; + for (const [todoId, rawLease] of indexed.leases) { + const lease = canonicalTaskLease(rawLease, goalId, todoId); + const active = leaseIsActive(lease, observedAt); + let disposition = lease.status === "released" + ? "preserved_released" + : active ? "preserved_active" : "preserved_expired"; + if (validatesMigrationOwnership && active && targetMode === "soft_claim") { + conflicts.push({ + kind: "active_lease_incompatible_with_soft_claim", + reason_code: "active_lease_incompatible_with_soft_claim", + todo_id: todoId, + owner: lease.owner, + target_mode: targetMode, + }); + disposition = "conflict"; + } else if (validatesMigrationOwnership && active) { + const todo = canonicalLeaseTodoFact(indexed.todos.get(todoId)); + const owner = normalizeTodoAgent(lease.owner, "lease.owner"); + const rejection = leaseOwnerRejection(todo, owner, registeredAgents); + const expectedScopes = coordinationTodoWriteScopes(indexed.todos.get(todoId)!); + const observedScopes = Array.isArray(lease.write_scopes) ? lease.write_scopes : []; + const scopeMatch = canonicalAuthorityBytes(expectedScopes).equals( + canonicalAuthorityBytes(observedScopes), + ); + if (rejection !== null || !scopeMatch) { + conflicts.push({ + kind: "active_lease_not_safe_to_preserve", + todo_id: todoId, + owner, + reason_code: rejection ?? "active_lease_scope_mismatch", + scope_match: scopeMatch, + }); + disposition = "conflict"; + } + // Validate both fencing counters even when they are zero/defaulted. + leaseVersion(lease); + leaseEpoch(lease); + } + leaseDispositions.push({ + todo_id: todoId, + owner: lease.owner, + status: lease.status, + active, + version: leaseVersion(lease), + lease_epoch: leaseEpoch(lease), + disposition, + }); + } + + const targetProjection = previousMode === targetMode ? head : {...head, handoff_mode: targetMode}; + const firstConflict = conflicts[0]; + return { + ready: conflicts.length === 0, + ...(firstConflict === undefined ? {} : { + reason_code: String(firstConflict.reason_code ?? "handoff_mode_migration_conflict"), + reason: "claim or lease facts cannot be preserved under the requested handoff-mode migration", + }), + strategy, + previous_mode: previousMode, + target_mode: targetMode, + changed: previousMode !== targetMode, + preserved_claims: preservedClaims, + lease_dispositions: leaseDispositions, + conflicts, + target_projection: targetProjection, + target_projection_sha256: canonicalAuthoritySha256(targetProjection), + }; +} + +export function publicPromotionHandoffMigrationPlan( + plan: PromotionHandoffMigrationPlan, +): JsonObject { + return { + schema_version: "loopx_promotion_handoff_migration_plan_v0", + ready: plan.ready, + ...(plan.reason_code === undefined ? {} : {reason_code: plan.reason_code}), + ...(plan.reason === undefined ? {} : {reason: plan.reason}), + strategy: plan.strategy, + previous_mode: plan.previous_mode, + target_mode: plan.target_mode, + changed: plan.changed, + preserved_claim_count: plan.preserved_claims.length, + preserved_claims: [...plan.preserved_claims], + lease_dispositions: [...plan.lease_dispositions], + conflicts: [...plan.conflicts], + target_projection_sha256: plan.target_projection_sha256, + }; +} diff --git a/loopx/control_plane/coordination/runtime_shadow.py b/loopx/control_plane/coordination/runtime_shadow.py index 958dde4da1..3ecf8aae27 100644 --- a/loopx/control_plane/coordination/runtime_shadow.py +++ b/loopx/control_plane/coordination/runtime_shadow.py @@ -636,6 +636,8 @@ def review_local_coordination_authority_promotion( source_snapshot: Mapping[str, Any], minimum_operations: int, required_event_kinds: list[str], + handoff_mode_migration: str | None = None, + registered_agents: list[str] | None = None, execute: bool, runtime_invoker: RuntimeInvoker = effect_runtime_result, ) -> dict[str, object]: @@ -660,6 +662,14 @@ def review_local_coordination_authority_promotion( "source_snapshot": dict(source_snapshot), "minimum_operations": minimum_operations, "required_event_kinds": list(required_event_kinds), + **( + { + "handoff_mode_migration": handoff_mode_migration, + "registered_agents": list(registered_agents or []), + } + if handoff_mode_migration is not None + else {} + ), "execute": execute, } try: diff --git a/loopx/control_plane/coordination/todo_claim.ts b/loopx/control_plane/coordination/todo_claim.ts index 1dc1340946..9b27da6348 100644 --- a/loopx/control_plane/coordination/todo_claim.ts +++ b/loopx/control_plane/coordination/todo_claim.ts @@ -25,8 +25,8 @@ import { normalizeAgent, normalizeIdempotencyKey, normalizeTtl, - normalizeWriteScopes, } from "../work_items/task_lease_acquire.ts"; +import {coordinationTodoWriteScopes} from "./todo_write_scopes.ts"; export const COORDINATION_TODO_CLAIM_RESULT_SCHEMA = "loopx_coordination_todo_claim_result_v0"; @@ -300,23 +300,6 @@ function normalizeLeaseRequest(value: unknown): CoordinationTodoClaimLeaseReques }; } -function claimWriteScopes(todo: JsonObject): string[] { - const requiredWriteScopes = todo.required_write_scopes ?? []; - if (!Array.isArray(requiredWriteScopes) || - requiredWriteScopes.some((scope) => typeof scope !== "string")) { - throw new AuthorityStoreProtocolError( - "todo.required_write_scopes must be an array of strings", - ); - } - const writeScopes = normalizeWriteScopes(requiredWriteScopes); - if (writeScopes.length !== requiredWriteScopes.length) { - throw new AuthorityStoreProtocolError( - "todo.required_write_scopes contains an invalid or duplicate scope", - ); - } - return writeScopes; -} - function activeLeaseForOwner( lease: JsonObject | undefined, owner: string, @@ -509,7 +492,7 @@ export async function executeCoordinationTodoClaim( const currentLease = projection.leases.get(input.todo_id); if (handoffMode === "hard_lease" && leaseRequest !== null) { // Validate required scopes before planning; a caller cannot omit a required conflict. - const writeScopes = claimWriteScopes(todo); + const writeScopes = coordinationTodoWriteScopes(todo); const facts = canonicalTaskLeaseAcquireFacts(projection, input.goal_id, input.todo_id, input.registered_agents, input.now); const decision = evaluateTaskLeaseAcquireDecision({handoff_mode: handoffMode, registered_agents: [...input.registered_agents], ...facts, diff --git a/loopx/control_plane/coordination/todo_write_scopes.ts b/loopx/control_plane/coordination/todo_write_scopes.ts new file mode 100644 index 0000000000..9c6ad1ea8a --- /dev/null +++ b/loopx/control_plane/coordination/todo_write_scopes.ts @@ -0,0 +1,21 @@ +import type {JsonObject} from "../effect_program.ts"; +import {AuthorityStoreProtocolError} from "./authority_store_codec.ts"; +import {normalizeWriteScopes} from "../work_items/task_lease_acquire.ts"; + +/** Canonical Todo write scopes shared by claim/lease admission and migration. */ +export function coordinationTodoWriteScopes(todo: JsonObject): string[] { + const requiredWriteScopes = todo.required_write_scopes ?? []; + if (!Array.isArray(requiredWriteScopes) || + requiredWriteScopes.some((scope) => typeof scope !== "string")) { + throw new AuthorityStoreProtocolError( + "todo.required_write_scopes must be an array of strings", + ); + } + const writeScopes = normalizeWriteScopes(requiredWriteScopes); + if (writeScopes.length !== requiredWriteScopes.length) { + throw new AuthorityStoreProtocolError( + "todo.required_write_scopes contains an invalid or duplicate scope", + ); + } + return writeScopes; +} diff --git a/tests/control_plane/test_coordination_shadow_command.py b/tests/control_plane/test_coordination_shadow_command.py index f210cce787..b42bfa104f 100644 --- a/tests/control_plane/test_coordination_shadow_command.py +++ b/tests/control_plane/test_coordination_shadow_command.py @@ -11,6 +11,8 @@ def _goal() -> dict[str, object]: return { "id": "goal-a", "coordination": { + "agent_model": "peer_v1", + "registered_agents": ["agent-a", "agent-b"], "runtime_shadow": { "enabled": True, "schema_version": "loopx_coordination_runtime_shadow_config_v0", @@ -43,6 +45,7 @@ def _run( minimum_operations: int = 3, require_event_kind: list[str] | None = None, todo_id: str | None = None, + handoff_mode_migration: str | None = None, ) -> tuple[int, dict[str, object]]: monkeypatch.setattr(command, "load_registry", lambda _path: {"goals": [_goal()]}) monkeypatch.setattr( @@ -71,6 +74,7 @@ def print_payload(payload, *_args) -> None: minimum_operations=minimum_operations, require_event_kind=require_event_kind or [], todo_id=todo_id, + handoff_mode_migration=handoff_mode_migration, format="json", ) result = command.handle_coordination_shadow_command( @@ -233,11 +237,14 @@ def test_coordination_shadow_parser_exposes_explicit_execute_gate() -> None: "5", "--require-event-kind", "todo_claim", + "--handoff-mode-migration", + "hard_lease", "--execute", ] ) assert promote.minimum_operations == 5 assert promote.require_event_kind == ["todo_claim"] + assert promote.handoff_mode_migration == "hard_lease" assert promote.execute is True @@ -377,6 +384,14 @@ def promote(**kwargs) -> dict[str, object]: minimum_operations=5, require_event_kind=["todo_claim"], ) + migrated_result, migrated = _run( + monkeypatch, + tmp_path, + action="promote", + minimum_operations=5, + require_event_kind=["todo_claim"], + handoff_mode_migration="hard_lease", + ) assert preview_result == 0 assert preview["executed"] is False @@ -384,10 +399,14 @@ def promote(**kwargs) -> dict[str, object]: assert apply_result == 0 assert applied["executed"] is True assert applied["promotion"]["status"] == "applied" + assert migrated_result == 0 + assert migrated["promotion"]["status"] == "preview_ready" assert requests[0]["execute"] is False assert requests[1]["execute"] is True assert requests[0]["minimum_operations"] == 5 assert requests[0]["required_event_kinds"] == ["todo_claim"] + assert requests[2]["handoff_mode_migration"] == "hard_lease" + assert requests[2]["registered_agents"] == ["agent-a", "agent-b"] assert str(requests[0]["operation_id"]).startswith("promote:goal-a:") diff --git a/tests/control_plane_ts/local_authority_runtime.test.ts b/tests/control_plane_ts/local_authority_runtime.test.ts index fcc235eeb7..25727def35 100644 --- a/tests/control_plane_ts/local_authority_runtime.test.ts +++ b/tests/control_plane_ts/local_authority_runtime.test.ts @@ -414,6 +414,230 @@ test("reviewed promotion rejects a non-hard-lease Goal without fencing writers", assert.equal((await loadLegacyCoordinationWriterFence(root, "goal-a")).status, "missing"); }); +test("reviewed promotion preserves soft_claim when keep-mode is explicit", async () => { + const root = await mkdtemp(join(tmpdir(), "loopx-reviewed-promotion-keep-mode-")); + const shadow = await qualifiedShadow(root, "soft_claim"); + const sourceProjection: JsonObject = { + ...shadow.projection, + partitions: {todos: null, leases: null}, + }; + delete sourceProjection.capture_lineage_id; + delete sourceProjection.capture_profile; + delete sourceProjection.source_root_digest; + const statePath = join(root, "ACTIVE_GOAL_STATE.md"); + const source = await sourceRequest({ + root, + statePath, + store: new FileAuthorityStore(join(root, "authority-shadow", "file-v0"), "goal-a"), + baseline: sourceProjection, + }, sourceProjection); + const request = { + ...source, + schema_version: LOCAL_COORDINATION_PROMOTION_REVIEW_REQUEST_SCHEMA, + operation_id: "promote:goal-a:keep-soft-claim", + minimum_operations: 1, + required_event_kinds: ["todo_claim"], + handoff_mode_migration: "preserve", + registered_agents: ["agent-a", "agent-b"], + execute: false, + }; + + const preview = await reviewLocalCoordinationAuthorityPromotion(request); + assert.equal(preview.status, "preview_ready", JSON.stringify(preview)); + const migration = (preview.plan as JsonObject).handoff_mode_migration as JsonObject; + assert.equal(migration.changed, false); + assert.equal(migration.target_mode, "soft_claim"); + + const applied = await reviewLocalCoordinationAuthorityPromotion({...request, execute: true}); + assert.equal(applied.status, "applied", JSON.stringify(applied)); + const canonical = new FileAuthorityStore(join(root, "authority", "file-v0"), "goal-a"); + const loaded = await canonical.loadAuthority(); + assert.equal(loaded.status, "loaded"); + if (loaded.status !== "loaded") throw new Error("keep-mode canonical authority missing"); + assert.equal(loaded.head.handoff_mode, "soft_claim"); + assert.equal((loaded.head.todos as JsonObject[])[0]?.claimed_by, "agent-a"); + assert.deepEqual(loaded.head.leases, []); +}); + +test("reviewed promotion explicitly migrates claimed legacy work to hard_lease", async () => { + const root = await mkdtemp(join(tmpdir(), "loopx-reviewed-promotion-claimed-mode-")); + const shadow = await qualifiedShadow(root, "legacy"); + const sourceProjection: JsonObject = { + ...shadow.projection, + partitions: {todos: null, leases: null}, + }; + delete sourceProjection.capture_lineage_id; + delete sourceProjection.capture_profile; + delete sourceProjection.source_root_digest; + const statePath = join(root, "ACTIVE_GOAL_STATE.md"); + const source = await sourceRequest({ + root, + statePath, + store: new FileAuthorityStore(join(root, "authority-shadow", "file-v0"), "goal-a"), + baseline: sourceProjection, + }, sourceProjection); + const request = { + ...source, + schema_version: LOCAL_COORDINATION_PROMOTION_REVIEW_REQUEST_SCHEMA, + operation_id: "promote:goal-a:claimed-mode", + minimum_operations: 1, + required_event_kinds: ["todo_claim"], + handoff_mode_migration: "hard_lease", + registered_agents: ["agent-a", "agent-b"], + execute: false, + }; + + const preview = await reviewLocalCoordinationAuthorityPromotion(request); + assert.equal(preview.status, "preview_ready", JSON.stringify(preview)); + const previewPlan = preview.plan as JsonObject; + const previewMigration = previewPlan.handoff_mode_migration as JsonObject; + assert.equal(previewMigration.previous_mode, "legacy"); + assert.equal(previewMigration.target_mode, "hard_lease"); + assert.equal(previewMigration.preserved_claim_count, 1); + + const applied = await reviewLocalCoordinationAuthorityPromotion({...request, execute: true}); + assert.equal(applied.status, "applied", JSON.stringify(applied)); + const replayedPromotion = await reviewLocalCoordinationAuthorityPromotion({...request, execute: true}); + assert.equal(replayedPromotion.status, "replayed", JSON.stringify(replayedPromotion)); + const lateLegacyWrite = await checkLegacyCoordinationWriteAllowed({ + schema_version: LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA, + runtime_root: root, + goal_id: "goal-a", + }); + assert.equal(lateLegacyWrite.status, "blocked"); + assert.equal(lateLegacyWrite.reason_code, "legacy_coordination_writer_fenced"); + const canonical = new FileAuthorityStore(join(root, "authority", "file-v0"), "goal-a"); + const loaded = await canonical.loadAuthority(); + assert.equal(loaded.status, "loaded"); + if (loaded.status !== "loaded") throw new Error("canonical promotion missing"); + assert.equal(loaded.head.handoff_mode, "hard_lease"); + assert.equal((loaded.head.todos as JsonObject[])[0]?.claimed_by, "agent-a"); + assert.deepEqual(loaded.head.leases, []); + + const withoutLease = await claimLocalCoordinationTodo({ + schema_version: LOCAL_COORDINATION_TODO_CLAIM_REQUEST_SCHEMA, + runtime_root: root, + goal_id: "goal-a", + todo_id: "todo_a", + role: "agent", + claimed_by: "agent-a", + actor_agent_id: "agent-a", + registered_agents: ["agent-a", "agent-b"], + operation_id: "todo-claim:goal-a:claimed-mode:no-lease", + observed_at: "2026-09-21T12:00:00Z", + dry_run: false, + }); + assert.equal(withoutLease.status, "failed"); + assert.equal(withoutLease.reason_code, "handoff_mode_requires_lease"); + + const withLease = await claimLocalCoordinationTodo({ + schema_version: LOCAL_COORDINATION_TODO_CLAIM_REQUEST_SCHEMA, + runtime_root: root, + goal_id: "goal-a", + todo_id: "todo_a", + role: "agent", + claimed_by: "agent-a", + actor_agent_id: "agent-a", + registered_agents: ["agent-a", "agent-b"], + operation_id: "todo-claim:goal-a:claimed-mode:lease", + observed_at: "2026-09-21T12:00:00Z", + lease_request: { + idempotency_key: "turn:claimed-mode", + expected_version: null, + ttl_seconds: 2700, + }, + dry_run: false, + }); + assert.equal(withLease.status, "applied", JSON.stringify(withLease)); + assert.equal(withLease.todo_changed, false); + assert.equal(withLease.lease_changed, true); + + const foreignOwner = await claimLocalCoordinationTodo({ + schema_version: LOCAL_COORDINATION_TODO_CLAIM_REQUEST_SCHEMA, + runtime_root: root, + goal_id: "goal-a", + todo_id: "todo_a", + role: "agent", + claimed_by: "agent-b", + actor_agent_id: "agent-b", + registered_agents: ["agent-a", "agent-b"], + operation_id: "todo-claim:goal-a:claimed-mode:foreign", + observed_at: "2026-09-21T12:00:01Z", + lease_request: { + idempotency_key: "turn:foreign-owner", + expected_version: null, + ttl_seconds: 2700, + }, + dry_run: false, + }); + assert.equal(foreignOwner.status, "failed"); + assert.equal(foreignOwner.reason_code, "claim_owner_mismatch"); +}); + +test("claim-preserving migration recovers only the exact reviewed strategy", async () => { + const root = await mkdtemp(join(tmpdir(), "loopx-reviewed-promotion-mode-recovery-")); + const shadow = await qualifiedShadow(root, "legacy", 2); + const sourceProjection: JsonObject = { + ...shadow.projection, + partitions: {todos: null, leases: null}, + }; + delete sourceProjection.capture_lineage_id; + delete sourceProjection.capture_profile; + delete sourceProjection.source_root_digest; + const statePath = join(root, "ACTIVE_GOAL_STATE.md"); + const source = await sourceRequest({ + root, + statePath, + store: new FileAuthorityStore(join(root, "authority-shadow", "file-v0"), "goal-a"), + baseline: sourceProjection, + }, sourceProjection); + const request = { + ...source, + schema_version: LOCAL_COORDINATION_PROMOTION_REVIEW_REQUEST_SCHEMA, + operation_id: "promote:goal-a:claimed-mode-recovery", + minimum_operations: 2, + required_event_kinds: ["todo_claim"], + handoff_mode_migration: "hard_lease", + registered_agents: ["agent-a", "agent-b"], + execute: true, + }; + class InterruptOnceStore extends FileAuthorityStore { + private interrupt = true; + override async commitAuthority(commit: AuthorityStoreCommit) { + if (this.interrupt) { + this.interrupt = false; + throw new Error("synthetic interruption after claim-preserving fence"); + } + return await super.commitAuthority(commit); + } + } + const canonical = new InterruptOnceStore(join(root, "authority", "file-v0"), "goal-a"); + const dependencies = {createCanonicalStore: () => canonical}; + + const interrupted = await reviewLocalCoordinationAuthorityPromotion(request, dependencies); + assert.equal(interrupted.status, "failed", JSON.stringify(interrupted)); + assert.equal(interrupted.legacy_writer_fenced, true); + assert.equal((await canonical.loadAuthority()).status, "missing"); + + for (const changedPlan of [ + {...request, handoff_mode_migration: "preserve"}, + {...request, registered_agents: ["agent-a", "agent-c"]}, + ]) { + const rejected = await reviewLocalCoordinationAuthorityPromotion(changedPlan, dependencies); + assert.equal(rejected.status, "failed", JSON.stringify(rejected)); + assert.equal(rejected.reason_code, "local_authority_writer_fence_conflict"); + assert.equal((await canonical.loadAuthority()).status, "missing"); + } + + const recovered = await reviewLocalCoordinationAuthorityPromotion(request, dependencies); + assert.equal(recovered.status, "recovered", JSON.stringify(recovered)); + const loaded = await canonical.loadAuthority(); + assert.equal(loaded.status, "loaded"); + if (loaded.status !== "loaded") throw new Error("recovered canonical authority missing"); + assert.equal(loaded.head.handoff_mode, "hard_lease"); + assert.equal((loaded.head.todos as JsonObject[])[0]?.claimed_by, "agent-a"); +}); + test("new bootstrap and provider list fail closed without exact Todo consumer semantics", async () => { const root = await mkdtemp(join(tmpdir(), "loopx-local-authority-semantic-fence-")); const incomplete = { goal_id: "goal-a", todos: [{ todo_id: "todo_a", role: "agent", status: "open" }], leases: [] }; diff --git a/tests/control_plane_ts/local_promotion_fixture.ts b/tests/control_plane_ts/local_promotion_fixture.ts index eee48b3917..9d43c28dc3 100644 --- a/tests/control_plane_ts/local_promotion_fixture.ts +++ b/tests/control_plane_ts/local_promotion_fixture.ts @@ -22,7 +22,7 @@ function todoRecord(overrides: Record = {}) { export async function qualifiedShadow(root: string, handoffMode = "soft_claim", operationCount = 1) { const baseline = fileProjection([todoRecord()], [], handoffMode); const statePath = join(root, "ACTIVE_GOAL_STATE.md"); - await writeFile(statePath, "---\ngoal_id: goal-a\nhandoff_mode: soft_claim\n---\n\n## Agent Todo\n\n"); + await writeFile(statePath, `---\ngoal_id: goal-a\nhandoff_mode: ${handoffMode}\n---\n\n## Agent Todo\n\n`); const store = new FileAuthorityStore(join(root, "authority-shadow", "file-v0"), "goal-a"); const f = {root, statePath, baseline, store}; const bootstrapped = await bootstrapCoordinationRuntimeShadow({ diff --git a/tests/control_plane_ts/promotion_handoff_migration.test.ts b/tests/control_plane_ts/promotion_handoff_migration.test.ts new file mode 100644 index 0000000000..0a98fe7332 --- /dev/null +++ b/tests/control_plane_ts/promotion_handoff_migration.test.ts @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import { + planPromotionHandoffMigration, +} from "../../loopx/control_plane/coordination/promotion_handoff_migration.ts"; +import {projection, todo} from "./shadow_file_fixture.ts"; + +const agents = ["agent-a", "agent-b"]; +const observedAt = new Date("2026-09-21T12:00:00Z"); + +function claimedTodo(index: number, owner = "agent-a"): JsonObject { + return { + ...todo(`todo_${String(index).padStart(2, "0")}`), + claimed_by: owner, + required_write_scopes: ["src/**"], + }; +} + +function activeLease(todoId: string, owner = "agent-a"): JsonObject { + return { + schema_version: "task_lease_v0", + goal_id: "goal-a", + todo_id: todoId, + owner, + idempotency_key: `turn:${todoId}`, + write_scopes: ["src/**"], + acquire_ttl_seconds: 2700, + version: 2, + lease_epoch: 3, + status: "active", + acquired_at: "2026-09-21T11:30:00Z", + updated_at: "2026-09-21T11:30:00Z", + expires_at: "2026-09-21T12:30:00Z", + }; +} + +test("explicit legacy to hard_lease migration preserves fifty claims without inventing leases", () => { + const todos = Array.from({length: 50}, (_, index) => claimedTodo(index)); + const plan = planPromotionHandoffMigration( + projection(todos, [], "legacy"), + "goal-a", + "hard_lease", + agents, + observedAt, + ); + assert.equal(plan.ready, true, JSON.stringify(plan)); + assert.equal(plan.previous_mode, "legacy"); + assert.equal(plan.target_mode, "hard_lease"); + assert.equal(plan.preserved_claims.length, 50); + assert.equal(plan.lease_dispositions.length, 0); + assert.equal(plan.target_projection.handoff_mode, "hard_lease"); + assert.ok(plan.preserved_claims.every( + (claim) => claim.disposition === "preserved_assignment_requires_lease", + )); +}); + +test("preserve canonicalizes each existing mode without changing ownership policy", () => { + for (const mode of ["legacy", "soft_claim", "hard_lease"] as const) { + const head = projection([claimedTodo(1)], [], mode); + const plan = planPromotionHandoffMigration(head, "goal-a", "preserve", agents, observedAt); + assert.equal(plan.ready, true, JSON.stringify(plan)); + assert.equal(plan.target_mode, mode); + assert.equal(plan.changed, false); + assert.equal(plan.target_projection, head); + } +}); + +test("safe active same-owner lease is retained with its fencing identity", () => { + const lease = activeLease("todo_01"); + const plan = planPromotionHandoffMigration( + projection([claimedTodo(1)], [lease], "legacy"), + "goal-a", + "hard_lease", + agents, + observedAt, + ); + assert.equal(plan.ready, true, JSON.stringify(plan)); + assert.deepEqual(plan.lease_dispositions, [{ + todo_id: "todo_01", + owner: "agent-a", + status: "active", + active: true, + version: 2, + lease_epoch: 3, + disposition: "preserved_active", + }]); +}); + +test("keep-mode rejects an active lease that contradicts soft_claim semantics", () => { + const plan = planPromotionHandoffMigration( + projection([claimedTodo(1)], [activeLease("todo_01")], "soft_claim"), + "goal-a", + "preserve", + agents, + observedAt, + ); + assert.equal(plan.ready, false); + assert.equal(plan.conflicts[0]?.kind, "active_lease_incompatible_with_soft_claim"); +}); + +test("active lease owner or scope mismatch fails the reviewed migration", () => { + for (const lease of [ + activeLease("todo_01", "agent-b"), + {...activeLease("todo_01"), write_scopes: ["docs/**"]}, + ]) { + const plan = planPromotionHandoffMigration( + projection([claimedTodo(1)], [lease], "legacy"), + "goal-a", + "hard_lease", + agents, + observedAt, + ); + assert.equal(plan.ready, false); + assert.equal(plan.conflicts[0]?.kind, "active_lease_not_safe_to_preserve"); + } +}); + +test("explicit migration fails before fencing when a live claim owner is unregistered", () => { + const plan = planPromotionHandoffMigration( + projection([claimedTodo(1, "agent-c")], [], "legacy"), + "goal-a", + "hard_lease", + agents, + observedAt, + ); + assert.equal(plan.ready, false); + assert.deepEqual(plan.conflicts, [{ + kind: "claim_owner_not_registered", + reason_code: "claim_owner_not_registered", + todo_id: "todo_01", + claimed_by: "agent-c", + }]); +}); + +test("explicit migration rejects unsupported downgrade strategies", () => { + assert.throws( + () => planPromotionHandoffMigration( + projection([claimedTodo(1)], [activeLease("todo_01")], "hard_lease"), + "goal-a", + "soft_claim", + agents, + observedAt, + ), + /handoff_mode_migration/, + ); +}); From c395ac77f1eb060335345736580a8641e1f413dd Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:04:33 +0800 Subject: [PATCH 2/2] docs(coordination): define promotion handoff migration Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- ...shared-goal-authority-state-provider-v0.md | 34 +++++++-- ...-goal-authority-state-provider-v0.zh-CN.md | 27 +++++-- docs/reference/handoff-mode.md | 71 +++++++++++++++++++ 3 files changed, 120 insertions(+), 12 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 c3b88bb46a..5b2abe1c31 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -1805,6 +1805,14 @@ loopx coordination-shadow promote --goal-id \ loopx coordination-shadow promote --goal-id \ --minimum-operations 3 \ --require-event-kind todo_claim --execute +loopx coordination-shadow promote --goal-id \ + --minimum-operations 3 \ + --require-event-kind todo_claim \ + --handoff-mode-migration preserve +loopx coordination-shadow promote --goal-id \ + --minimum-operations 3 \ + --require-event-kind todo_claim \ + --handoff-mode-migration hard_lease --execute loopx coordination-shadow rollback --goal-id \ --provider-revision --execute ``` @@ -1823,10 +1831,18 @@ interruption. Apply holds the shared maintenance and legacy source locks while it revalidates the source snapshot, qualifies the exact shadow lineage, engages the durable writer fence, commits the canonical head, and reads back the promotion receipt. v0 rejects a Goal -whose already-qualified mode is not `hard_lease`; promotion never changes that -mode as a side effect. A successful write is immediately read back through -the typed parity inspection. The command remains unavailable unless the exact -goal-level `file_v0` shadow opt-in is active. +whose already-qualified mode is not `hard_lease` when the migration option is +omitted. An explicit `preserve` plan canonicalizes a `legacy` or `soft_claim` +Goal without changing its ownership policy. An explicit `hard_lease` plan may +combine the authority cutover with the one supported policy upgrade while +preserving claims and safe lease records. It never synthesizes leases: a +preserved claim owner acquires a lease through the ordinary atomic path before +its next protected write. Preview exposes preserved claims, lease dispositions, +conflicts, and the exact target digest; the mode intent, registered-agent set, +and target digest are part of the promotion-plan identity. Other mode +transitions remain subject to the ordinary quiescence rule. A successful write +is immediately read back through the typed parity inspection. The command +remains unavailable unless the exact goal-level `file_v0` shadow opt-in is active. Pre-promotion rollback is revision-fenced and non-destructive. TypeScript moves the exact active file-shadow lineage into a durable quarantine archive; exact @@ -2332,9 +2348,13 @@ remain reviewable in the same bounded slice. preserved. The maintainer must approve the named removal explicitly in the RFC decision log or PR review; absence of a discovered consumer is not approval.* -9. Does v0 promotion cover only `hard_lease` goals? *Proposed answer: yes. A - `legacy` or `soft_claim` goal first switches mode under the Appendix B - quiescence rule; promotion never changes the mode implicitly.* +9. Does v0 promotion cover only `hard_lease` goals? *Resolved answer: the + backward-compatible default still requires a qualified `hard_lease` source. + A reviewed operator may explicitly choose `preserve` to canonicalize a + `legacy` or `soft_claim` Goal without changing its policy, or `hard_lease` to + perform the one supported claim-preserving upgrade inside the fenced + cutover. No lease is invented, and every other mode change still uses the + Appendix B quiescence rule.* 10. After the provider-first read flip, Markdown and lease files are projections and the kernel forbids fallback to them. Which data belongs in the head, and how are compatibility views rendered? *Proposed answer: 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 f6ae5343a7..488c3d5678 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 @@ -1441,6 +1441,14 @@ loopx coordination-shadow promote --goal-id \ loopx coordination-shadow promote --goal-id \ --minimum-operations 3 \ --require-event-kind todo_claim --execute +loopx coordination-shadow promote --goal-id \ + --minimum-operations 3 \ + --require-event-kind todo_claim \ + --handoff-mode-migration preserve +loopx coordination-shadow promote --goal-id \ + --minimum-operations 3 \ + --require-event-kind todo_claim \ + --handoff-mode-migration hard_lease --execute loopx coordination-shadow rollback --goal-id \ --provider-revision --execute ``` @@ -1454,9 +1462,15 @@ operation 数与规范化后的必需 event kind;持久 fence、event 与 receipt 都携带同一 digest,因此 fence 已落盘而 canonical 尚未提交的中断只能由完全相同的受评审 plan 恢复。apply 会在同一段 maintenance 与 legacy source 锁生命周期内重新 验证 source snapshot、资格化精确 shadow lineage、engage 持久 writer fence、提交 -canonical head,并读回 promotion receipt。v0 会拒绝尚未资格化为 `hard_lease` 的 Goal, -且绝不会把 handoff mode 变化藏在 promotion 副作用中。写入成功后会立即通过 typed parity inspection -读回。除非目标开启精确的 goal-level `file_v0` shadow opt-in,否则该命令不可执行。 +canonical head,并读回 promotion receipt。未传迁移参数时,v0 继续拒绝尚未资格化为 +`hard_lease` 的 Goal。显式 `preserve` 可以只切换 authority 并保留 `legacy`/ +`soft_claim` policy;显式 `hard_lease` 可以在同一受评审 cutover 中执行唯一获支持的 +policy 升级,并保留 claim 与安全的 lease record。它不会伪造 lease:保留 claim 的 +owner 必须在下一次受保护写入前走普通原子路径取得 lease。preview 会展示保留 claim、 +lease 处置、冲突与精确目标 digest;mode 意图、registered-agent 集合和目标 digest 都 +进入 promotion-plan identity。其他 mode 变化仍受普通静止规则约束。写入成功后会立即 +通过 typed parity inspection 读回。除非目标开启精确的 goal-level `file_v0` shadow +opt-in,否则该命令不可执行。 promotion 前 rollback 带精确 revision fence,且不删除数据。TypeScript 会把命中的 file-shadow lineage 移入持久 quarantine archive;精确重试复用 archive receipt,revision @@ -1854,8 +1868,11 @@ decision authority,并且 caller-visible parity 与 rollback 能在同一有 reader。对应 PR 必须提供字段 inventory、producer/reader/writer 与静态引用调研、 历史和外部兼容性结论、migration/rollback,以及行为等价证明;maintainer 必须在 RFC decision log 或 PR review 中对点名字段显式批准。没有发现 consumer 不等于批准删除。* -9. v0 promotion 是否只覆盖 `hard_lease` goal?*拟议答案:是。`legacy` 或 - `soft_claim` goal 先按附录 B 的静止规则切换模式;promotion 从不隐式改变模式。* +9. v0 promotion 是否只覆盖 `hard_lease` goal?*已决议:向后兼容的默认路径仍要求 + 源端已资格化为 `hard_lease`。受评审的 operator 可以显式选择 `preserve`,在不改变 + policy 的情况下 canonicalize `legacy`/`soft_claim` Goal;也可以显式选择 + `hard_lease`,在 fenced cutover 内完成唯一获支持的 claim-preserving 升级。该路径 + 不伪造 lease;其他 mode 变化继续使用附录 B 的静止规则。* 10. provider-first read flip 后,Markdown 与 lease 文件成为投影,kernel 禁止回退。 哪些数据进入 head,兼容视图如何渲染?*拟议答案:canonical Todo/lease manifest 中的每个字段都持久化在 head,包括 monitor、dependency、resume、decision、 diff --git a/docs/reference/handoff-mode.md b/docs/reference/handoff-mode.md index c4e88847ce..67fdbae3f9 100644 --- a/docs/reference/handoff-mode.md +++ b/docs/reference/handoff-mode.md @@ -32,6 +32,55 @@ The unpromoted scan retains its older materialized-state scope: it does not claim to include event-only Todos. Its quiescence decision and the canonical transaction now share one typed policy. No default mode changes. +## Preserve claims during authority promotion + +The reviewed whole-Goal authority cutover has a narrower migration option for +an active Goal that cannot satisfy the ordinary quiescence rule: + +```bash +# Keep legacy or soft_claim while changing only the storage authority. +loopx coordination-shadow promote --goal-id example-goal \ + --minimum-operations 3 --require-event-kind todo_claim \ + --handoff-mode-migration preserve + +# Move legacy/soft_claim directly to hard_lease in the same reviewed cutover. +loopx coordination-shadow promote --goal-id example-goal \ + --minimum-operations 3 --require-event-kind todo_claim \ + --handoff-mode-migration hard_lease + +# Apply only the exact plan returned by preview. +loopx coordination-shadow promote --goal-id example-goal \ + --minimum-operations 3 --require-event-kind todo_claim \ + --handoff-mode-migration hard_lease --execute +``` + +This is not a general mode-change bypass. The only explicit choices are +`preserve` and `hard_lease`; omitting the option retains the older requirement +that the qualified source already be `hard_lease`. The TypeScript promotion +transaction preserves every Todo, claim, lease record, receipt and validation +field. It validates live claim owners against the Goal agent registry and +retains an active lease only when its owner, Todo scopes, expiry, version and +epoch are safe. It never invents a lease for a preserved claim. After a direct +move to `hard_lease`, the same claim owner must acquire a fresh lease through +the ordinary atomic claim-and-lease path before protected work; another owner +remains rejected. + +Preview reports the source revision/digest, target digest, preserved claims, +lease dispositions and conflicts. The target digest, selected migration and +registered-agent set enter the promotion-plan identity. Therefore an +interrupted cutover can recover only the same reviewed intent. The durable +legacy-writer fence blocks late old-session writes after cutover; a zero active +lease count alone is never treated as proof that no old Turn exists. + +The CLI is the only mutation surface for this reviewed administrative action. +Managed Turns invoke that same CLI contract. Dashboard delegation preflight and +Lark/Chat remain read-only here: they already project `promotion_required` or +the promoted canonical authority and direct an operator to the reviewed +preview. The migration choice is one-shot operation intent, not Goal +configuration, so adding it to the capability editor would create a second +source of truth. After apply, all ordinary Todo/lease actions and receipts on +those surfaces read the same promoted projection. + ## Recover a canonical request Choose an operation ID before a canonical set if a lost response must be retried: @@ -73,6 +122,28 @@ provider 失败明确报错,不回退旧文件。现有 Todo-section 投影不 并发修改使 CAS 冲突,不能在旧检查结果上继续切换。原 Todo、lease 和摘要不变。 未晋升路径仍仅扫描物化状态,不宣称覆盖 event-only Todo;两条路径共用 TS 切换规则。 +对于无法清空活跃 claim 的 Goal,整 Goal authority 晋升提供一个更窄的显式迁移入口: +`--handoff-mode-migration preserve` 只切换存储权威并保留 `legacy`/`soft_claim`; +`--handoff-mode-migration hard_lease` 在同一受评审事务中直接迁到 `hard_lease`。 +未传该参数时,继续沿用“源端已经是 `hard_lease`”的旧门禁。它不是通用 mode 绕过, +也不开放降级。 + +TypeScript 事务会原样保存 Todo、claim、lease record、receipt 与验证字段;校验活跃 +claim owner 是否仍在 Goal agent registry 中,并且只有 owner、Todo scope、expiry、 +version 与 epoch 都安全时才保留活跃 lease。迁到 `hard_lease` 不会为 claim 伪造 +lease:原 owner 下一次受保护写入前,必须走正常的原子 claim+lease 路径取得新 lease, +异主仍被拒绝。preview 会给出源 revision/digest、目标 digest、保留 claim、lease +处置与冲突;这些内容进入 promotion-plan identity,所以中断后只能恢复完全相同的 +评审意图。持久 legacy-writer fence 负责拦截旧 Turn 的迟到写入,不能用“当前 0 条 +active lease”推断没有在途 Turn。 + +该受评审管理动作只有 CLI 一个写入口,managed Turn 也调用同一 CLI contract。 +Dashboard 的 delegation preflight 与 Lark/Chat 在这里保持只读:它们已经投影 +`promotion_required` 或晋升后的 canonical authority,并把 operator 引导到受评审 +preview。migration choice 是单次 operation intent,不是 Goal 配置;把它再放进 +capability editor 会制造第二个 truth source。apply 之后,各入口的普通 Todo/lease +动作与回执统一读取同一份 promoted projection。 + 需支持丢响应恢复时,在首次 canonical set 前指定 `--operation-id`,重试沿用同一 目标 mode 和 ID。不同 mode 复用 ID 会被拒绝;即使最初 mode 未变,也记录耐久回执。 若后来已切到其他 mode,旧请求重放只返回原回执,不把 mode 改回去;用 `show` 读当前值。