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
236 changes: 236 additions & 0 deletions src/daemon/__tests__/device-claim-settlement.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof acquireProductionDeviceClaim>[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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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);
});
Loading
Loading