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
6 changes: 5 additions & 1 deletion docs/agents/device-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ physical devices. Live verification steps apply when exercising a device-facing
- `DEVICE_IN_USE` has two flavors. "already in use by session X" is this daemon — follow its
`close --session` hint. "owned by session X in workspace Y" is another worktree's device
claim — non-retriable; run the error's `device status`/`device release --stale` recovery,
never PID hunting.
never PID hunting. One claim settles itself: if that device rebooted after the last `open` its owner
made, its app, runner, and accessibility session were destroyed, so `open` reconciles the owner's
resources, takes the claim, and says so in its warnings. A reboot you caused yourself during
verification looks exactly like that to the next `open` — until the owner reopens, which stamps the
boot it is now running on and makes the claim live again.

The OS-neutral Apple runner lives under `packages/platform-apple/src/runner/`. For connection errors,
retry policy, or command typing, start at `runner-contract.ts`; transport stays below session/client
Expand Down
10 changes: 10 additions & 0 deletions scripts/layering/daemon-platform-runtime-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,16 @@ export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[]
'daemon-owned device refresh uses the neutral runner-session observation as boot ' +
'evidence; inventory selection and provider exclusions remain local policy.',
},
{
file: 'src/daemon/session-lifecycle/internal/session-open-execution.ts',
target: 'src/platform-runtime-device-boot.ts',
symbols: ['deviceBootObservation'],
classification: 'daemon-policy-essential',
rationale:
'daemon-owned claim reconciliation asks the device when it last booted to decide whether a ' +
'foreign claim can still describe live ownership; the per-family probe mechanics stay in the ' +
'Apple and Android packages behind the neutral observation contract (#2538).',
},
{
file: 'src/daemon/handlers/session-selector-dispatch.ts',
target: 'src/platform-runtime-open-target.ts',
Expand Down
2 changes: 1 addition & 1 deletion src/cli-schema/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,7 @@ Example:
Device busy and ownership:
DEVICE_IN_USE has two flavors. "already in use by session X" is this daemon: reuse it with --session X, or run close --session X first. "owned by session X in workspace Y" is another worktree's daemon holding the host-global device claim: it is never retriable — run the error's exact recovery command instead of retrying.
Inspect ownership without any daemon: agent-device device status (add --stale for proven-dead owners; settle and release those with agent-device device release --stale). devices marks rows that are claimed, so pick an unclaimed device instead of contending.
A live foreign owner is released only by closing its session from its own workspace or stopping its daemon: agent-device daemon stop --state-dir <owner state dir> (the error names the state dir). Never recover by hunting PIDs with ps/kill. boot/install/shutdown take the same claims as open and refuse foreign-claimed devices identically.
A live foreign owner is released by closing its session from its own workspace or stopping its daemon: agent-device daemon stop --state-dir <owner state dir> (the error names the state dir). The device itself can settle a claim: when it rebooted after that claim was taken, its app, runner, and accessibility session were destroyed, so open reconciles the owner's resources, takes the claim, and reports the release in warnings. Never recover by hunting PIDs with ps/kill. boot/install/shutdown take the same claims as open and refuse foreign-claimed devices identically; they do not ask the device about its boot.

Use snapshot, screenshot, logs, network, perf frames, and perf memory for device/app runtime evidence. Use react-devtools when component internals or React rendering behavior matters.`,
},
Expand Down
90 changes: 90 additions & 0 deletions src/daemon/__tests__/device-claim-reboot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import type {
DeviceBootObservation,
DeviceBootObservationService,
} from '@agent-device/contracts/device-boot';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { rebootedDeviceClaim } from '../device-claim-reboot.ts';
import type { DeviceClaim } from '../device-claim-record.ts';

const device: DeviceInfo = {
platform: 'apple',
id: 'SIM-1',
name: 'iPhone 17 Pro',
kind: 'simulator',
appleOs: 'ios',
booted: true,
};

const CLAIM: DeviceClaim = {
schemaVersion: 2,
deviceKey: 'local:apple:ios:SIM-1',
device: { family: 'apple', id: device.id, name: device.name, kind: device.kind },
session: 'cwd:/w:default',
workspace: '/w',
stateDir: '/state/owner',
ownerPid: 4242,
ownerStartTime: 'start',
ownerToken: 'token',
createdAtMs: 1_000,
updatedAtMs: 1_000,
};

function observes(answer: DeviceBootObservation): DeviceBootObservationService {
return { observeBootTimeMs: async () => answer };
}

test('a device that came up after the claim destroyed what the claim was asserting', async () => {
const tookOver = await rebootedDeviceClaim({
claim: CLAIM,
device,
observeDeviceBoot: observes({ observed: true, bootedAtMs: 1_001 }),
});

assert.deepEqual(tookOver, {
session: 'cwd:/w:default',
workspace: '/w',
stateDir: '/state/owner',
bootedAtMs: 1_001,
});
});

test('a boot the claim already covers proves nothing', async () => {
for (const bootedAtMs of [CLAIM.updatedAtMs, CLAIM.updatedAtMs - 1]) {
assert.equal(
await rebootedDeviceClaim({
claim: CLAIM,
device,
observeDeviceBoot: observes({ observed: true, bootedAtMs }),
}),
undefined,
String(bootedAtMs),
);
}
});

test('a claim its owner renewed after the boot describes that boot', async () => {
assert.equal(
await rebootedDeviceClaim({
claim: { ...CLAIM, updatedAtMs: 5_000 },
device,
observeDeviceBoot: observes({ observed: true, bootedAtMs: 1_001 }),
}),
undefined,
);
});

test('an unanswered boot question leaves the claim exactly as protected as it was', async () => {
const unanswered: DeviceBootObservation[] = [
{ observed: false, reason: 'unobserved' },
{ observed: false, reason: 'unsupported-device' },
];
for (const answer of unanswered) {
assert.equal(
await rebootedDeviceClaim({ claim: CLAIM, device, observeDeviceBoot: observes(answer) }),
undefined,
);
}
assert.equal(await rebootedDeviceClaim({ claim: CLAIM, device }), undefined);
});
178 changes: 178 additions & 0 deletions src/daemon/__tests__/device-claim-settlement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ 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 { DeviceBootObservationService } from '@agent-device/contracts/device-boot';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts';
import { publishDaemonRegistration } from '../../__tests__/test-utils/device-claim-store.ts';
vi.mock('@agent-device/host-kit/process', async (importOriginal) =>
(await import('../../__tests__/test-utils/host-process-mock.ts')).pinOwnProcessStartTime(
importOriginal,
Expand Down Expand Up @@ -234,3 +236,179 @@ test('an internally inconsistent dead claim never authorizes reconciliation', as
assert.equal(reconcile.mock.calls.length, 0);
assert.equal(fs.existsSync(claimPath(root)), true);
});

const reconciled = async () => ({ status: 'reconciled' as const });

function storedClaim() {
const claim = inspectDeviceClaims({ serial: device.id })[0]?.claim;
assert.ok(claim);
return claim;
}

function rewriteClaimOwner(root: string, ownerPid: number): void {
const stored = JSON.parse(fs.readFileSync(claimPath(root), 'utf8')) as Record<string, unknown>;
fs.writeFileSync(claimPath(root), JSON.stringify({ ...stored, ownerPid, ownerStartTime: null }));
}

/** The claim as this process wrote it, but held by another live process from another state dir. */
async function seedForeignLiveClaim(root: string, stateDir: string): Promise<void> {
fs.mkdirSync(stateDir, { recursive: true });
const seeded = await acquireDeviceClaim({
device,
session: 'cwd:/w:default',
workspace: '/w',
stateDir,
});
assert.equal(seeded.status, 'acquired');
rewriteClaimOwner(root, process.ppid);
}

function observesDeviceBootAt(bootedAtMs: number): DeviceBootObservationService {
return { observeBootTimeMs: async () => ({ observed: true, bootedAtMs }) };
}

const UNOBSERVED_BOOT: DeviceBootObservationService = {
observeBootTimeMs: async () => ({ observed: false, reason: 'unobserved' }),
};

// #2538: a claim that predates the device's current boot cannot describe live device-side ownership
// — the reboot destroyed the app process, the runner, and the accessibility connection — so it loses
// the device even while its recorded owner's process looks healthy from the host.
test('takes a live foreign claim whose device rebooted after the claim was taken', async () => {
const root = useClaimsRoot();
const stateDir = path.join(root, 'owner-state');
await seedForeignLiveClaim(root, stateDir);
publishDaemonRegistration(stateDir, { pid: process.ppid, startTime: null });
const bootedAtMs = storedClaim().createdAtMs + 1;
let reconciledSession: string | undefined;

const second = await acquireDeviceClaim({
device,
session: 'other',
workspace: '/w',
stateDir,
reconcileOrphanedDeviceClaim: async (claim) => {
reconciledSession = claim.session;
return { status: 'reconciled' as const };
},
observeDeviceBoot: observesDeviceBootAt(bootedAtMs),
});

assert.equal(second.status, 'acquired');
if (second.status !== 'acquired') return;
assert.deepEqual(second.tookOver, {
session: 'cwd:/w:default',
workspace: '/w',
stateDir,
bootedAtMs,
});
assert.equal(reconciledSession, 'cwd:/w:default');
assert.equal(storedClaim().session, 'other');
});

test('keeps a live foreign claim blocking until the device boot is answered', async () => {
for (const observeDeviceBoot of [UNOBSERVED_BOOT, undefined]) {
const root = useClaimsRoot();
const stateDir = path.join(root, 'unobserved-state');
await seedForeignLiveClaim(root, stateDir);
publishDaemonRegistration(stateDir, { pid: process.ppid, startTime: null });

const second = await acquireDeviceClaim({
device,
session: 'other',
workspace: '/w',
stateDir,
reconcileOrphanedDeviceClaim: reconciled,
...(observeDeviceBoot ? { observeDeviceBoot } : {}),
});

assert.equal(second.status, 'conflict');
if (second.status !== 'conflict') return;
assert.equal(second.conflict.classification, 'live');
assert.equal(storedClaim().session, 'cwd:/w:default');
}
});

test('a boot that predates the claim proves nothing about it and keeps the conflict', async () => {
const root = useClaimsRoot();
const stateDir = path.join(root, 'pre-claim-boot-state');
await seedForeignLiveClaim(root, stateDir);
publishDaemonRegistration(stateDir, { pid: process.ppid, startTime: null });

const second = await acquireDeviceClaim({
device,
session: 'other',
workspace: '/w',
stateDir,
reconcileOrphanedDeviceClaim: reconciled,
observeDeviceBoot: observesDeviceBootAt(storedClaim().createdAtMs),
});

assert.equal(second.status, 'conflict');
if (second.status !== 'conflict') return;
assert.equal(second.conflict.classification, 'live');
});

test('a rebooted device stays claimed while its owner has resources left to settle', async () => {
const root = useClaimsRoot();
const stateDir = path.join(root, 'reboot-cleanup-state');
await seedForeignLiveClaim(root, stateDir);
publishDaemonRegistration(stateDir, { pid: process.ppid, startTime: null });

const second = await acquireDeviceClaim({
device,
session: 'other',
workspace: '/w',
stateDir,
observeDeviceBoot: observesDeviceBootAt(storedClaim().createdAtMs + 1),
});

assert.equal(second.status, 'conflict');
if (second.status !== 'conflict') return;
assert.equal(second.conflict.classification, 'live');
assert.equal(storedClaim().session, 'cwd:/w:default');
});

// The reboot bound is the last instant the owner vouched for the device, not the instant the claim
// was first written: an owner that came back on the rebooted device holds it against a later caller.
test('an owner that reopened its app after the reboot keeps the device', async () => {
const root = useClaimsRoot();
const stateDir = path.join(root, 'renewed-state');
const first = await acquireDeviceClaim({
device,
session: 'cwd:/w:default',
workspace: '/w',
stateDir,
});
assert.equal(first.status, 'acquired');
const bootedAtMs = storedClaim().createdAtMs + 1;
await new Promise((resolve) => setTimeout(resolve, 2));

const reopened = await acquireDeviceClaim({
device,
session: 'cwd:/w:default',
workspace: '/w',
stateDir,
observeDeviceBoot: observesDeviceBootAt(bootedAtMs),
});
assert.equal(reopened.status, 'acquired');
if (reopened.status !== 'acquired') return;
assert.equal(reopened.tookOver, undefined);
assert.ok(storedClaim().updatedAtMs >= bootedAtMs);

rewriteClaimOwner(root, process.ppid);
publishDaemonRegistration(stateDir, { pid: process.ppid, startTime: null });
const foreign = await acquireDeviceClaim({
device,
session: 'other',
workspace: '/w',
stateDir,
reconcileOrphanedDeviceClaim: reconciled,
observeDeviceBoot: observesDeviceBootAt(bootedAtMs),
});

assert.equal(foreign.status, 'conflict');
if (foreign.status !== 'conflict') return;
assert.equal(foreign.conflict.classification, 'live');
assert.equal(storedClaim().session, 'cwd:/w:default');
});
Loading
Loading