From e8969c6894eccd3e0bfd100bd3b79a31d897c78f Mon Sep 17 00:00:00 2001 From: song Date: Tue, 15 Sep 2026 12:44:37 +0800 Subject: [PATCH] fix(coordination): keep the held fence when the authority source changed `captureLeaseWrite` revalidates the authority source receipts before a fence-close lease write. That check is retryable by design: the adapter answers `authority_source_changed` by re-reading the canonical graph and re-sending the same held fence. The throw landed inside `fenceClose`'s claim window, so the `finally` released the mutation lock that carries the fence token. The lease write never ran, leaving an active lease on disk, and the caller's retry was answered with `fence_token_invalid`. A completed Todo then kept an active lease until an explicit release or TTL expiry. Only the shadow-enabled path revalidates, so both shadow-disabled paths released normally, and inspect/qualify still reported matched because both sides observed the same active lease -- parity alone could not see it. Release only this attempt's claim for that one retryable code and leave the fence held, reusing the claim-versus-lock distinction this file already draws when a verify attempt does not own the lock it claimed. Source checks are unchanged. Signed-off-by: song --- .../work_items/task_lease_lifecycle.ts | 28 ++++++++ .../task_lease_lifecycle.test.ts | 65 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/loopx/control_plane/work_items/task_lease_lifecycle.ts b/loopx/control_plane/work_items/task_lease_lifecycle.ts index f9d65fc868..ea14ee5d5d 100644 --- a/loopx/control_plane/work_items/task_lease_lifecycle.ts +++ b/loopx/control_plane/work_items/task_lease_lifecycle.ts @@ -2495,6 +2495,19 @@ async function replayClosedFenceClose( } } +/** + * Whether a failure is the source-snapshot mismatch the caller retries. + * + * `revalidateAuthoritySources` re-reads the canonical registry before a write + * commits. The adapter answers this exact code by re-reading the graph and + * re-sending the same held fence, so it must stay distinguishable from the + * conflicts that genuinely end a fence. + */ +function isRetryableAuthoritySourceMismatch(error: unknown): boolean { + return error instanceof TaskLeaseAcquireError && + error.code === "authority_source_changed"; +} + async function fenceClose( request: LifecycleRequest, dependencies: LifecycleDependencies, @@ -2700,6 +2713,21 @@ async function fenceClose( closeRequestDigest: fenceCloseRequestDigest(request), }); return attachRuntimeShadowCapture(response, shadowCapture); + } catch (error) { + // A changed authority source is a retryable precondition, not a lost + // fence: the lease write never ran and the caller still holds this token. + // Releasing the lock here would answer the caller's retry with + // `fence_token_invalid` and strand the completed Todo's active lease, so + // drop only this attempt's claim and leave the fence held. + if (claim && isRetryableAuthoritySourceMismatch(error)) { + try { + await releaseFileMutationLockClaim(claim); + } catch { + // Claim cleanup is best effort; never replace the original error. + } + claim = null; + } + throw error; } finally { if (claim) { await releaseFileMutationLock( diff --git a/tests/control_plane_ts/task_lease_lifecycle.test.ts b/tests/control_plane_ts/task_lease_lifecycle.test.ts index b03224f8f8..95f78efd12 100644 --- a/tests/control_plane_ts/task_lease_lifecycle.test.ts +++ b/tests/control_plane_ts/task_lease_lifecycle.test.ts @@ -1632,3 +1632,68 @@ test("lifecycle boundary rejects non-boolean flags and unsafe fence tokens", asy assert.equal(invalidToken.ok, false); assert.equal(invalidToken.error_code, "invalid_lock_token"); }); + +test("fence close keeps its held fence when the authority source changed", async (t) => { + const root = await workspace(t); + const runtimeShadow = { + schema_version: "loopx_coordination_runtime_shadow_binding_v0", + provider: "file_v0", + }; + const hardAuthority = await authority(root, { + todos: [{ + todo_id: "todo_target", + status: "open", + claimed_by: "agent-a", + excluded_agents: [], + }], + }); + await executeTaskLeaseAcquire( + await acquireRequest(root, { authority: hardAuthority }), + { now: () => ACQUIRE_NOW }, + ); + const checked = await executeTaskLeaseLifecycle( + await lifecycleRequest(root, "holder_verify", { + authority: hardAuthority, + idempotency_key: null, + expected_version: null, + owner: "agent-a", + }), + { now: () => new Date("2026-09-01T03:01:00.000Z") }, + ); + assert.equal(checked.ok, true); + const fence = checked.fence as Record; + + const closeRequest = await lifecycleRequest(root, "fence_close", { + authority: hardAuthority, + owner: null, + idempotency_key: null, + expected_version: null, + lock_token: fence.lock_token, + committed: true, + release_lease: true, + fence_owner: "agent-a", + fence_idempotency_key: null, + fence_expected_version: 1, + runtime_shadow: runtimeShadow, + }); + // An equivalent rewrite of the canonical registry after the close snapshot. + // The decision facts are unchanged; only the source bytes moved. + const rewritten = await authoritySource(root, "authority-v2"); + + const stale = await executeTaskLeaseLifecycle(closeRequest, { + now: () => new Date("2026-09-01T03:02:00.000Z"), + }); + assert.equal(stale.ok, false); + assert.equal(stale.error_code, "authority_source_changed"); + assert.equal((await lease(root)).status, "active"); + + // The adapter answers a source mismatch by re-reading the graph and retrying + // the same held fence. A retryable precondition must not have consumed it. + const retried = await executeTaskLeaseLifecycle({ + ...closeRequest, + authority: { ...hardAuthority, source_receipts: [rewritten] }, + }, { now: () => new Date("2026-09-01T03:02:01.000Z") }); + assert.equal(retried.ok, true); + assert.equal(retried.released, true); + assert.equal((await lease(root)).status, "released"); +});