Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions loopx/control_plane/work_items/task_lease_lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
65 changes: 65 additions & 0 deletions tests/control_plane_ts/task_lease_lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

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");
});