From 5749359a0052678f98d84e49e784d28ea002830f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 13 Sep 2026 22:46:06 +0200 Subject: [PATCH] refactor(move): settle a device claim in its own module --- .../__tests__/device-claim-settlement.test.ts | 236 ++++++++++++++++++ src/daemon/__tests__/device-claims.test.ts | 174 ------------- src/daemon/device-claim-allocator.ts | 3 +- src/daemon/device-claim-record.ts | 21 ++ src/daemon/device-claim-settlement.ts | 172 +++++++++++++ src/daemon/device-claims.ts | 166 ++---------- 6 files changed, 445 insertions(+), 327 deletions(-) create mode 100644 src/daemon/__tests__/device-claim-settlement.test.ts create mode 100644 src/daemon/device-claim-settlement.ts diff --git a/src/daemon/__tests__/device-claim-settlement.test.ts b/src/daemon/__tests__/device-claim-settlement.test.ts new file mode 100644 index 0000000000..73ccf68f7a --- /dev/null +++ b/src/daemon/__tests__/device-claim-settlement.test.ts @@ -0,0 +1,236 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, test, vi } from 'vitest'; +import { acquireDeviceClaim as acquireProductionDeviceClaim } from '../device-claims.ts'; +import { canonicalLocalDeviceKey } from '../device-claim-paths.ts'; +import { inspectDeviceClaims } from '../device-claim-inspection.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +vi.mock('@agent-device/host-kit/process', async (importOriginal) => + (await import('../../__tests__/test-utils/host-process-mock.ts')).pinOwnProcessStartTime( + importOriginal, + ), +); + +const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +}; + +const roots: string[] = []; + +function acquireDeviceClaim( + params: Omit< + Parameters[0], + 'reconcileOrphanedDeviceClaim' + > & { + reconcileOrphanedDeviceClaim?: Parameters< + typeof acquireProductionDeviceClaim + >[0]['reconcileOrphanedDeviceClaim']; + }, +) { + return acquireProductionDeviceClaim({ + ...params, + reconcileOrphanedDeviceClaim: + params.reconcileOrphanedDeviceClaim ?? + (async () => ({ status: 'retained', reason: 'test-no-recovery' })), + }); +} + +afterEach(() => { + vi.restoreAllMocks(); + delete process.env.AGENT_DEVICE_CLAIMS_DIR; + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function useClaimsRoot(): string { + const root = mkdtempForTestSync('agent-device-claims-'); + roots.push(root); + process.env.AGENT_DEVICE_CLAIMS_DIR = root; + return root; +} + +function claimPath(root: string): string { + const key = canonicalLocalDeviceKey(device); + const hash = crypto.createHash('sha256').update(key).digest('hex'); + return path.join(root, `${hash}.json`); +} + +test('preserves and reports a live foreign claim without overwriting it', async () => { + const root = useClaimsRoot(); + const first = await acquireDeviceClaim({ + device, + session: 'first', + workspace: '/worktrees/first', + stateDir: root, + }); + assert.equal(first.status, 'acquired'); + const persisted = JSON.parse(fs.readFileSync(claimPath(root), 'utf8')) as Record; + assert.equal(persisted.schemaVersion, 2); + assert.deepEqual(persisted.device, { + id: device.id, + family: device.platform, + kind: device.kind, + name: device.name, + }); + const second = await acquireDeviceClaim({ + device, + session: 'second', + workspace: '/worktrees/second', + stateDir: root, + }); + assert.equal(second.status, 'conflict'); + if (second.status !== 'conflict') return; + assert.equal(second.conflict.classification, 'live'); + assert.equal(inspectDeviceClaims({ serial: device.id })[0]?.claim?.session, 'first'); +}); + +test('does not treat a same-named session in another worktree as its claim owner', async () => { + const root = useClaimsRoot(); + const first = await acquireDeviceClaim({ + device, + session: 'default', + workspace: '/worktrees/first', + stateDir: root, + }); + assert.equal(first.status, 'acquired'); + const second = await acquireDeviceClaim({ + device, + session: 'default', + workspace: '/worktrees/second', + stateDir: path.join(root, 'second-state'), + }); + assert.equal(second.status, 'conflict'); + assert.equal(inspectDeviceClaims({ serial: device.id })[0]?.claim?.workspace, '/worktrees/first'); +}); + +test('reconciles a proven-dead owner and replaces it while acquiring the same claim', async () => { + const root = useClaimsRoot(); + const first = await acquireDeviceClaim({ + device, + session: 'dead-owner', + workspace: '/worktrees/dead', + stateDir: root, + }); + assert.equal(first.status, 'acquired'); + const stored = JSON.parse(fs.readFileSync(claimPath(root), 'utf8')) as Record; + fs.writeFileSync( + claimPath(root), + JSON.stringify({ ...stored, ownerPid: 999_999_999, ownerStartTime: 'dead-start' }), + ); + let reconciledSession: string | undefined; + const reconcile = vi.fn(async (claim: { session: string }) => { + reconciledSession = claim.session; + return { status: 'reconciled' as const }; + }); + + const second = await acquireDeviceClaim({ + device, + session: 'replacement', + workspace: '/worktrees/replacement', + stateDir: root, + reconcileOrphanedDeviceClaim: reconcile, + }); + + assert.equal(second.status, 'acquired'); + assert.equal(reconciledSession, 'dead-owner'); + assert.equal(inspectDeviceClaims({ serial: device.id })[0]?.claim?.session, 'replacement'); +}); + +test('retains a proven-dead claim when exact-owner cleanup remains pending', async () => { + const root = useClaimsRoot(); + await acquireDeviceClaim({ + device, + session: 'cleanup-pending', + workspace: '/worktrees/dead', + stateDir: root, + }); + const stored = JSON.parse(fs.readFileSync(claimPath(root), 'utf8')) as Record; + fs.writeFileSync( + claimPath(root), + JSON.stringify({ ...stored, ownerPid: 999_999_999, ownerStartTime: 'dead-start' }), + ); + + const second = await acquireDeviceClaim({ + device, + session: 'blocked', + workspace: '/worktrees/blocked', + stateDir: root, + reconcileOrphanedDeviceClaim: async () => ({ + status: 'retained', + reason: 'cleanup-pending', + }), + }); + + assert.equal(second.status, 'conflict'); + if (second.status !== 'conflict') return; + assert.equal(second.conflict.classification, 'owner-process-dead'); + assert.equal(inspectDeviceClaims({ serial: device.id })[0]?.claim?.session, 'cleanup-pending'); +}); + +test('PID reuse is uncertain ownership and never authorizes reconciliation', async () => { + const root = useClaimsRoot(); + await acquireDeviceClaim({ + device, + session: 'reused-pid-owner', + workspace: '/worktrees/old', + stateDir: root, + }); + const stored = JSON.parse(fs.readFileSync(claimPath(root), 'utf8')) as Record; + fs.writeFileSync(claimPath(root), JSON.stringify({ ...stored, ownerStartTime: 'other-start' })); + const reconcile = vi.fn(async () => ({ status: 'reconciled' as const })); + + const result = await acquireDeviceClaim({ + device, + session: 'blocked', + workspace: '/worktrees/new', + stateDir: root, + reconcileOrphanedDeviceClaim: reconcile, + }); + + assert.equal(result.status, 'conflict'); + if (result.status !== 'conflict') return; + assert.equal(result.conflict.classification, 'owner-process-reused'); + assert.equal(reconcile.mock.calls.length, 0); + assert.equal(inspectDeviceClaims({ serial: device.id })[0]?.claim?.session, 'reused-pid-owner'); +}); + +test('an internally inconsistent dead claim never authorizes reconciliation', async () => { + const root = useClaimsRoot(); + await acquireDeviceClaim({ + device, + session: 'inconsistent-owner', + workspace: '/worktrees/old', + stateDir: root, + }); + const stored = JSON.parse(fs.readFileSync(claimPath(root), 'utf8')) as Record; + fs.writeFileSync( + claimPath(root), + JSON.stringify({ + ...stored, + device: { platform: 'android', id: 'different-device', name: 'Other', kind: 'emulator' }, + ownerPid: 999_999_999, + ownerStartTime: 'dead-start', + }), + ); + const reconcile = vi.fn(async () => ({ status: 'reconciled' as const })); + + const result = await acquireDeviceClaim({ + device, + session: 'blocked', + workspace: '/worktrees/new', + stateDir: root, + reconcileOrphanedDeviceClaim: reconcile, + }); + + assert.equal(result.status, 'conflict'); + if (result.status !== 'conflict') return; + assert.equal(result.conflict.classification, 'inconsistent'); + assert.equal(reconcile.mock.calls.length, 0); + assert.equal(fs.existsSync(claimPath(root)), true); +}); diff --git a/src/daemon/__tests__/device-claims.test.ts b/src/daemon/__tests__/device-claims.test.ts index 6505ba76b3..a3d66d4f6f 100644 --- a/src/daemon/__tests__/device-claims.test.ts +++ b/src/daemon/__tests__/device-claims.test.ts @@ -72,180 +72,6 @@ function claimPath(root: string): string { return path.join(root, `${hash}.json`); } -test('preserves and reports a live foreign claim without overwriting it', async () => { - const root = useClaimsRoot(); - const first = await acquireDeviceClaim({ - device, - session: 'first', - workspace: '/worktrees/first', - stateDir: root, - }); - assert.equal(first.status, 'acquired'); - const persisted = JSON.parse(fs.readFileSync(claimPath(root), 'utf8')) as Record; - assert.equal(persisted.schemaVersion, 2); - assert.deepEqual(persisted.device, { - id: device.id, - family: device.platform, - kind: device.kind, - name: device.name, - }); - const second = await acquireDeviceClaim({ - device, - session: 'second', - workspace: '/worktrees/second', - stateDir: root, - }); - assert.equal(second.status, 'conflict'); - if (second.status !== 'conflict') return; - assert.equal(second.conflict.classification, 'live'); - assert.equal(inspectDeviceClaims({ serial: device.id })[0]?.claim?.session, 'first'); -}); - -test('does not treat a same-named session in another worktree as its claim owner', async () => { - const root = useClaimsRoot(); - const first = await acquireDeviceClaim({ - device, - session: 'default', - workspace: '/worktrees/first', - stateDir: root, - }); - assert.equal(first.status, 'acquired'); - const second = await acquireDeviceClaim({ - device, - session: 'default', - workspace: '/worktrees/second', - stateDir: path.join(root, 'second-state'), - }); - assert.equal(second.status, 'conflict'); - assert.equal(inspectDeviceClaims({ serial: device.id })[0]?.claim?.workspace, '/worktrees/first'); -}); - -test('reconciles a proven-dead owner and replaces it while acquiring the same claim', async () => { - const root = useClaimsRoot(); - const first = await acquireDeviceClaim({ - device, - session: 'dead-owner', - workspace: '/worktrees/dead', - stateDir: root, - }); - assert.equal(first.status, 'acquired'); - const stored = JSON.parse(fs.readFileSync(claimPath(root), 'utf8')) as Record; - fs.writeFileSync( - claimPath(root), - JSON.stringify({ ...stored, ownerPid: 999_999_999, ownerStartTime: 'dead-start' }), - ); - let reconciledSession: string | undefined; - const reconcile = vi.fn(async (claim: { session: string }) => { - reconciledSession = claim.session; - return { status: 'reconciled' as const }; - }); - - const second = await acquireDeviceClaim({ - device, - session: 'replacement', - workspace: '/worktrees/replacement', - stateDir: root, - reconcileOrphanedDeviceClaim: reconcile, - }); - - assert.equal(second.status, 'acquired'); - assert.equal(reconciledSession, 'dead-owner'); - assert.equal(inspectDeviceClaims({ serial: device.id })[0]?.claim?.session, 'replacement'); -}); - -test('retains a proven-dead claim when exact-owner cleanup remains pending', async () => { - const root = useClaimsRoot(); - await acquireDeviceClaim({ - device, - session: 'cleanup-pending', - workspace: '/worktrees/dead', - stateDir: root, - }); - const stored = JSON.parse(fs.readFileSync(claimPath(root), 'utf8')) as Record; - fs.writeFileSync( - claimPath(root), - JSON.stringify({ ...stored, ownerPid: 999_999_999, ownerStartTime: 'dead-start' }), - ); - - const second = await acquireDeviceClaim({ - device, - session: 'blocked', - workspace: '/worktrees/blocked', - stateDir: root, - reconcileOrphanedDeviceClaim: async () => ({ - status: 'retained', - reason: 'cleanup-pending', - }), - }); - - assert.equal(second.status, 'conflict'); - if (second.status !== 'conflict') return; - assert.equal(second.conflict.classification, 'owner-process-dead'); - assert.equal(inspectDeviceClaims({ serial: device.id })[0]?.claim?.session, 'cleanup-pending'); -}); - -test('PID reuse is uncertain ownership and never authorizes reconciliation', async () => { - const root = useClaimsRoot(); - await acquireDeviceClaim({ - device, - session: 'reused-pid-owner', - workspace: '/worktrees/old', - stateDir: root, - }); - const stored = JSON.parse(fs.readFileSync(claimPath(root), 'utf8')) as Record; - fs.writeFileSync(claimPath(root), JSON.stringify({ ...stored, ownerStartTime: 'other-start' })); - const reconcile = vi.fn(async () => ({ status: 'reconciled' as const })); - - const result = await acquireDeviceClaim({ - device, - session: 'blocked', - workspace: '/worktrees/new', - stateDir: root, - reconcileOrphanedDeviceClaim: reconcile, - }); - - assert.equal(result.status, 'conflict'); - if (result.status !== 'conflict') return; - assert.equal(result.conflict.classification, 'owner-process-reused'); - assert.equal(reconcile.mock.calls.length, 0); - assert.equal(inspectDeviceClaims({ serial: device.id })[0]?.claim?.session, 'reused-pid-owner'); -}); - -test('an internally inconsistent dead claim never authorizes reconciliation', async () => { - const root = useClaimsRoot(); - await acquireDeviceClaim({ - device, - session: 'inconsistent-owner', - workspace: '/worktrees/old', - stateDir: root, - }); - const stored = JSON.parse(fs.readFileSync(claimPath(root), 'utf8')) as Record; - fs.writeFileSync( - claimPath(root), - JSON.stringify({ - ...stored, - device: { platform: 'android', id: 'different-device', name: 'Other', kind: 'emulator' }, - ownerPid: 999_999_999, - ownerStartTime: 'dead-start', - }), - ); - const reconcile = vi.fn(async () => ({ status: 'reconciled' as const })); - - const result = await acquireDeviceClaim({ - device, - session: 'blocked', - workspace: '/worktrees/new', - stateDir: root, - reconcileOrphanedDeviceClaim: reconcile, - }); - - assert.equal(result.status, 'conflict'); - if (result.status !== 'conflict') return; - assert.equal(result.conflict.classification, 'inconsistent'); - assert.equal(reconcile.mock.calls.length, 0); - assert.equal(fs.existsSync(claimPath(root)), true); -}); - test('clears only the exact owner token and identity, never a successor claim', async () => { const root = useClaimsRoot(); const acquired = await acquireDeviceClaim({ diff --git a/src/daemon/device-claim-allocator.ts b/src/daemon/device-claim-allocator.ts index 190bbe7c38..4a842ce34f 100644 --- a/src/daemon/device-claim-allocator.ts +++ b/src/daemon/device-claim-allocator.ts @@ -20,7 +20,8 @@ import { type AllocatorHeldDeviceClaim, } from './device-claim-record.ts'; import { withDeviceClaimLock, writeDeviceClaim } from './device-claim-store.ts'; -import { deviceClaimIdentity, emitClaimConflict } from './device-claims.ts'; +import { emitClaimConflict } from './device-claim-settlement.ts'; +import { deviceClaimIdentity } from './device-claims.ts'; /** * The full principal of an allocator-held claim: one installation's state dir, the allocator diff --git a/src/daemon/device-claim-record.ts b/src/daemon/device-claim-record.ts index 48f653e93d..5b71b10d50 100644 --- a/src/daemon/device-claim-record.ts +++ b/src/daemon/device-claim-record.ts @@ -41,6 +41,27 @@ export type DeviceClaim = { abandonedAtMs?: number; }; +/** + * The ownership token a claim grants its holder: everything a clearing surface must match to let + * that holder release, abandon, or keep fencing the claim, and nothing else. + */ +export type DeviceClaimSessionOwnership = { + deviceKey: string; + ownerToken: string; + ownerPid: number; + ownerStartTime: string | null; +}; + +/** The ownership token carried by a persisted claim record. */ +export function ownershipFromClaim(claim: DeviceClaim): DeviceClaimSessionOwnership { + return { + deviceKey: claim.deviceKey, + ownerToken: claim.ownerToken, + ownerPid: claim.ownerPid, + ownerStartTime: claim.ownerStartTime, + }; +} + /** * ADR 0021 §4: a claim held for the pool lifetime of one allocator-managed identity. Its principal * is an INSTALLATION — the state dir, the allocator instance, and the identity incarnation — and diff --git a/src/daemon/device-claim-settlement.ts b/src/daemon/device-claim-settlement.ts new file mode 100644 index 0000000000..be6e3a012c --- /dev/null +++ b/src/daemon/device-claim-settlement.ts @@ -0,0 +1,172 @@ +import fs from 'node:fs'; +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { + deviceClaimOwnerCannotRelease, + inspectDeviceClaimFile, + type InspectedDeviceClaim, +} from './device-claim-inspection.ts'; +import { resolveDeviceClaimPath } from './device-claim-paths.ts'; +import { + ownershipFromClaim, + type DeviceClaim, + type DeviceClaimSessionOwnership, +} from './device-claim-record.ts'; +import { + ownerIdentityMatches, + type readCurrentOwnerIdentity, +} from '@agent-device/host-kit/process'; + +/** + * What the claim file says before an acquisition writes its own record. `available` means the + * caller may claim the device; `held` means the same session already owns the device and keeps its + * ownership token. + */ +export type ExistingClaimResolution = + | { status: 'available' } + | { status: 'held'; ownership: DeviceClaimSessionOwnership } + | { status: 'conflict'; conflict: InspectedDeviceClaim }; + +export type DeviceClaimReconciliationResult = + | { status: 'reconciled' } + | { status: 'retained'; reason: string }; + +export type DeviceClaimReconciler = ( + claim: DeviceClaim, +) => Promise; + +/** + * Settles the claim file an acquisition found, and decides whether this caller may write its own. + * The recorded owner's own state answers first — a dead, unreachable, or superseded owner is + * settled exactly as `device release --stale` settles it — and a claim whose owner can still + * release it holds the device. + */ +export async function resolveExistingClaim(params: { + deviceKey: string; + owner: ReturnType; + session: string; + workspace: string; + stateDir: string; + reconcileOrphanedDeviceClaim: DeviceClaimReconciler; +}): Promise { + const existing = inspectDeviceClaimFile(resolveDeviceClaimPath(params.deviceKey)); + if (!existing) return { status: 'available' }; + if ( + existing.claim && + isAbandonedClaimOfThisDaemon(existing.claim, params.stateDir, params.owner) + ) { + emitClaimSupersede(params.deviceKey, existing.claim); + return { status: 'available' }; + } + if (existing.claim && isCurrentClaimOwner(existing.claim, params, params.owner)) { + return { status: 'held', ownership: ownershipFromClaim(existing.claim) }; + } + if (!existing.claim || !deviceClaimOwnerCannotRelease(existing.classification)) { + emitClaimConflict(params.deviceKey, existing); + return { status: 'conflict', conflict: existing }; + } + const reconciliation = await settleVerifiedOrphanedClaim( + existing.claim, + params.reconcileOrphanedDeviceClaim, + ); + if (reconciliation.status === 'retained') { + emitClaimConflict(params.deviceKey, existing, reconciliation.reason); + return { status: 'conflict', conflict: existing }; + } + return { status: 'available' }; +} + +/** + * Clears the claim only once its durable resources are settled, so a claim never disappears while + * something owned by its session is still running. + */ +export async function settleVerifiedOrphanedClaim( + claim: DeviceClaim, + reconcile: DeviceClaimReconciler, +): Promise { + const result = await reconcile(claim); + if (result.status === 'retained') return result; + try { + fs.unlinkSync(resolveDeviceClaimPath(claim.deviceKey)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + return { status: 'reconciled' }; +} + +export function isClaimOwnedByThisDaemon( + claim: DeviceClaim, + stateDir: string, + owner: ReturnType, +): boolean { + return ( + claim.stateDir === stateDir && + ownerIdentityMatches({ pid: claim.ownerPid, startTime: claim.ownerStartTime }, owner) + ); +} + +export function isAbandonedDeviceClaim(claim: DeviceClaim): boolean { + return claim.abandonedAtMs !== undefined; +} + +function isAbandonedClaimOfThisDaemon( + claim: DeviceClaim, + stateDir: string, + owner: ReturnType, +): boolean { + return isAbandonedDeviceClaim(claim) && isClaimOwnedByThisDaemon(claim, stateDir, owner); +} + +function isCurrentClaimOwner( + claim: DeviceClaim, + params: Pick[0], 'session' | 'workspace' | 'stateDir'>, + owner: ReturnType, +): boolean { + return ( + claim.session === params.session && + claim.workspace === params.workspace && + claim.stateDir === params.stateDir && + ownerIdentityMatches({ pid: claim.ownerPid, startTime: claim.ownerStartTime }, owner) + ); +} + +/** The one diagnostic that names who holds a device when an acquisition is refused. */ +export function emitClaimConflict( + deviceKey: string, + existing: InspectedDeviceClaim, + reconciliationReason?: string, +): void { + emitDiagnostic({ + level: 'warn', + phase: 'device_claim_conflict', + data: { + deviceKey, + classification: existing.classification, + ...describeClaimOwner(existing), + ...(reconciliationReason ? { reconciliationReason } : {}), + }, + }); +} + +/** The owner projection of either claim kind, for diagnostics that name who holds the device. */ +function describeClaimOwner(existing: InspectedDeviceClaim): Record { + if (existing.allocatorClaim) { + return { + ownerStateDir: existing.allocatorClaim.stateDir, + allocatorInstanceId: existing.allocatorClaim.allocator.instanceId, + identityIncarnationId: existing.allocatorClaim.allocator.identityIncarnationId, + }; + } + return { ownerSession: existing.claim?.session, ownerStateDir: existing.claim?.stateDir }; +} + +function emitClaimSupersede(deviceKey: string, abandoned: DeviceClaim): void { + emitDiagnostic({ + level: 'info', + phase: 'device_claim_abandoned_superseded', + data: { + deviceKey, + abandonedSession: abandoned.session, + abandonedAtMs: abandoned.abandonedAtMs, + }, + }); +} diff --git a/src/daemon/device-claims.ts b/src/daemon/device-claims.ts index 86b84e3ec4..883fdb0bfe 100644 --- a/src/daemon/device-claims.ts +++ b/src/daemon/device-claims.ts @@ -26,25 +26,22 @@ import { } from './device-claim-paths.ts'; import { DEVICE_CLAIM_SCHEMA_VERSION, + ownershipFromClaim, type AllocatorClaimIdentity, type DeviceClaim, + type DeviceClaimSessionOwnership, } from './device-claim-record.ts'; +import { + isAbandonedDeviceClaim, + isClaimOwnedByThisDaemon, + resolveExistingClaim, + settleVerifiedOrphanedClaim, + type DeviceClaimReconciler, +} from './device-claim-settlement.ts'; import { withDeviceClaimLock, writeDeviceClaim } from './device-claim-store.ts'; -export type DeviceClaimReconciliationResult = - | { status: 'reconciled' } - | { status: 'retained'; reason: string }; - -export type DeviceClaimReconciler = ( - claim: DeviceClaim, -) => Promise; - -export type DeviceClaimSessionOwnership = { - deviceKey: string; - ownerToken: string; - ownerPid: number; - ownerStartTime: string | null; -}; +export type { DeviceClaimReconciler } from './device-claim-settlement.ts'; +export type { DeviceClaimSessionOwnership } from './device-claim-record.ts'; export type DeviceClaimAcquireResult = | { status: 'acquired'; ownership: DeviceClaimSessionOwnership } @@ -134,7 +131,7 @@ async function claimHeldDevice(params: { }): Promise { const { deviceKey, identity } = params; const owner = readCurrentOwnerIdentity(); - const existingResult = await resolveExistingClaim({ + const existing = await resolveExistingClaim({ deviceKey, owner, session: params.session, @@ -142,7 +139,8 @@ async function claimHeldDevice(params: { stateDir: params.stateDir, reconcileOrphanedDeviceClaim: params.reconcileOrphanedDeviceClaim, }); - if (existingResult.status !== 'available') return existingResult; + if (existing.status === 'conflict') return existing; + if (existing.status === 'held') return { status: 'acquired', ownership: existing.ownership }; const now = Date.now(); const claim: DeviceClaim = { schemaVersion: DEVICE_CLAIM_SCHEMA_VERSION, @@ -164,21 +162,6 @@ async function claimHeldDevice(params: { return { status: 'acquired', ownership: ownershipFromClaim(claim) }; } -function isClaimOwnedByThisDaemon( - claim: DeviceClaim, - stateDir: string, - owner: ReturnType, -): boolean { - return ( - claim.stateDir === stateDir && - ownerIdentityMatches({ pid: claim.ownerPid, startTime: claim.ownerStartTime }, owner) - ); -} - -function isAbandonedDeviceClaim(claim: DeviceClaim): boolean { - return claim.abandonedAtMs !== undefined; -} - /** * Does this process hold the claim for exactly this device right now? The * Apple runner's device-claim arbitration probe (#1320 retained-runner rule): @@ -203,14 +186,6 @@ export function processOwnsActiveDeviceClaim(device: DeviceInfo): boolean { ); } -function isAbandonedClaimOfThisDaemon( - claim: DeviceClaim, - stateDir: string, - owner: ReturnType, -): boolean { - return isAbandonedDeviceClaim(claim) && isClaimOwnedByThisDaemon(claim, stateDir, owner); -} - /** The canonical claim-facing identity of a local device: family, Apple OS, and id. */ export function deviceClaimIdentity(device: DeviceInfo): DeviceIdentity { return deviceIdentity({ @@ -219,54 +194,6 @@ export function deviceClaimIdentity(device: DeviceInfo): DeviceIdentity { }); } -async function resolveExistingClaim(params: { - deviceKey: string; - owner: ReturnType; - session: string; - workspace: string; - stateDir: string; - reconcileOrphanedDeviceClaim: DeviceClaimReconciler; -}): Promise { - const existing = inspectDeviceClaimFile(resolveDeviceClaimPath(params.deviceKey)); - if (!existing) return { status: 'available' }; - if ( - existing.claim && - isAbandonedClaimOfThisDaemon(existing.claim, params.stateDir, params.owner) - ) { - emitClaimSupersede(params.deviceKey, existing.claim); - return { status: 'available' }; - } - if (existing.claim && isCurrentClaimOwner(existing.claim, params, params.owner)) { - return { status: 'acquired', ownership: ownershipFromClaim(existing.claim) }; - } - if (!existing.claim || !deviceClaimOwnerCannotRelease(existing.classification)) { - emitClaimConflict(params.deviceKey, existing); - return { status: 'conflict', conflict: existing }; - } - const reconciliation = await settleVerifiedOrphanedClaim( - existing.claim, - params.reconcileOrphanedDeviceClaim, - ); - if (reconciliation.status === 'retained') { - emitClaimConflict(params.deviceKey, existing, reconciliation.reason); - return { status: 'conflict', conflict: existing }; - } - return { status: 'available' }; -} - -function isCurrentClaimOwner( - claim: DeviceClaim, - params: Pick[0], 'session' | 'workspace' | 'stateDir'>, - owner: ReturnType, -): boolean { - return ( - claim.session === params.session && - claim.workspace === params.workspace && - claim.stateDir === params.stateDir && - ownerIdentityMatches({ pid: claim.ownerPid, startTime: claim.ownerStartTime }, owner) - ); -} - /** * One claim's outcome from `device release --stale`. `released` cleared the * claim after resource reconciliation; `retained` has positive stale proof but @@ -549,68 +476,3 @@ function sweepMayReconcile( if (classification !== 'owner-daemon-superseded') return true; return path.resolve(ownerStateDir) === path.resolve(daemonStateDir); } - -/** The one diagnostic that names who holds a device when an acquisition is refused. */ -export function emitClaimConflict( - deviceKey: string, - existing: InspectedDeviceClaim, - reconciliationReason?: string, -): void { - emitDiagnostic({ - level: 'warn', - phase: 'device_claim_conflict', - data: { - deviceKey, - classification: existing.classification, - ...describeClaimOwner(existing), - ...(reconciliationReason ? { reconciliationReason } : {}), - }, - }); -} - -/** The owner projection of either claim kind, for diagnostics that name who holds the device. */ -function describeClaimOwner(existing: InspectedDeviceClaim): Record { - if (existing.allocatorClaim) { - return { - ownerStateDir: existing.allocatorClaim.stateDir, - allocatorInstanceId: existing.allocatorClaim.allocator.instanceId, - identityIncarnationId: existing.allocatorClaim.allocator.identityIncarnationId, - }; - } - return { ownerSession: existing.claim?.session, ownerStateDir: existing.claim?.stateDir }; -} - -function emitClaimSupersede(deviceKey: string, abandoned: DeviceClaim): void { - emitDiagnostic({ - level: 'info', - phase: 'device_claim_abandoned_superseded', - data: { - deviceKey, - abandonedSession: abandoned.session, - abandonedAtMs: abandoned.abandonedAtMs, - }, - }); -} - -async function settleVerifiedOrphanedClaim( - claim: DeviceClaim, - reconcile: DeviceClaimReconciler, -): Promise { - const result = await reconcile(claim); - if (result.status === 'retained') return result; - try { - fs.unlinkSync(resolveDeviceClaimPath(claim.deviceKey)); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - } - return { status: 'reconciled' }; -} - -function ownershipFromClaim(claim: DeviceClaim): DeviceClaimSessionOwnership { - return { - deviceKey: claim.deviceKey, - ownerToken: claim.ownerToken, - ownerPid: claim.ownerPid, - ownerStartTime: claim.ownerStartTime, - }; -}