diff --git a/docs/agents/device-verification.md b/docs/agents/device-verification.md index acaaa8c9a4..33302c34ee 100644 --- a/docs/agents/device-verification.md +++ b/docs/agents/device-verification.md @@ -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 diff --git a/scripts/layering/daemon-platform-runtime-inventory.ts b/scripts/layering/daemon-platform-runtime-inventory.ts index 483c26850e..0b59d6c533 100644 --- a/scripts/layering/daemon-platform-runtime-inventory.ts +++ b/scripts/layering/daemon-platform-runtime-inventory.ts @@ -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', diff --git a/src/cli-schema/cli-help.ts b/src/cli-schema/cli-help.ts index f7b8dbc1cf..ed604a4e6b 100644 --- a/src/cli-schema/cli-help.ts +++ b/src/cli-schema/cli-help.ts @@ -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 (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 (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.`, }, diff --git a/src/daemon/__tests__/device-claim-reboot.test.ts b/src/daemon/__tests__/device-claim-reboot.test.ts new file mode 100644 index 0000000000..d803d18dd4 --- /dev/null +++ b/src/daemon/__tests__/device-claim-reboot.test.ts @@ -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); +}); diff --git a/src/daemon/__tests__/device-claim-settlement.test.ts b/src/daemon/__tests__/device-claim-settlement.test.ts index 73ccf68f7a..cf3af9e79b 100644 --- a/src/daemon/__tests__/device-claim-settlement.test.ts +++ b/src/daemon/__tests__/device-claim-settlement.test.ts @@ -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, @@ -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; + 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 { + 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'); +}); diff --git a/src/daemon/__tests__/request-router-open-claim.test.ts b/src/daemon/__tests__/request-router-open-claim.test.ts new file mode 100644 index 0000000000..991e8d30f6 --- /dev/null +++ b/src/daemon/__tests__/request-router-open-claim.test.ts @@ -0,0 +1,288 @@ +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { test, expect, vi, beforeEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { getResolveTargetDeviceMock } from './request-router-dispatch-mocks.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; + +vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); +vi.mock('@agent-device/host-kit/process', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readProcessStartTime: vi.fn(() => 'test-process-start') }; +}); +vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + detachIosSimulatorRunnerSessionsForShutdown: vi.fn(async () => {}), + notifyIosRunnerAppRelaunched: vi.fn(async () => {}), + prewarmAppleRunnerCache: vi.fn(async () => {}), + prewarmIosRunner: vi.fn(async () => {}), + prepareIosRunner: vi.fn(async () => ({ + runner: { currentUptimeMs: 42 }, + connectMs: 0, + healthCheckMs: 0, + })), + resolveRunnerAppBundleId: vi.fn(() => 'com.callstack.agentdevice.runner'), + scheduleIosRunnerIdleStop: vi.fn(), + stopIosRunnerSession: vi.fn(async () => {}), + stopAllIosRunnerSessions: vi.fn(async () => {}), + }; +}); +vi.mock('@agent-device/platform-apple/app-lifecycle', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, closeIosApp: vi.fn(async () => {}) }; +}); +vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + resolveIosApp: vi.fn(async (_device, app) => app), + }; +}); +// Claim settlement asks the device when its current boot began; these tests answer by hand rather +// than reading the host's real process table. +const mockObserveSimulatorBoot = vi.hoisted(() => + vi.fn(async (): Promise => ({ + observed: false, + reason: 'unobserved', + })), +); +vi.mock('@agent-device/platform-apple/simulator-boot', () => ({ + observeSimulatorBootTimeMs: mockObserveSimulatorBoot, +})); + +import { + createRequestHandler, + lifecycleDeviceRuntimeGateway, +} from './test-device-runtime-gateway.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { + awaitFixtureReadiness, + discoverReadyAndroidEmulators, +} from './application-lifecycle-runtime-fixture.ts'; +import { ensureDeviceReady } from '../device-ready.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { DeviceBootObservation } from '@agent-device/contracts/device-boot'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { inspectDeviceClaims } from '../device-claim-inspection.ts'; + +const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); +const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); +const mockDiscoverReadyAndroidEmulators = vi.mocked(discoverReadyAndroidEmulators); +const mockAwaitFixtureReadiness = vi.mocked(awaitFixtureReadiness); + +function makeIosDevice(id: string): DeviceInfo { + return { + platform: 'apple', + id, + name: `iPhone ${id}`, + kind: 'simulator', + target: 'mobile', + booted: true, + }; +} + +function createOpenHandler( + sessionStore: ReturnType, + leaseRegistry = new LeaseRegistry(), +) { + return createRequestHandler({ + logPath: path.join(os.tmpdir(), 'daemon.log'), + token: 'test-token', + sessionStore, + leaseRegistry, + deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, + deviceInventoryGateways: createTestDeviceInventoryGateways(), + trackDownloadableArtifact: () => 'artifact-id', + }); +} + +function openRequest( + session: string, + flags: Record, + requestId: string, + meta: Record = {}, + positionals: string[] = [], +) { + return { + token: 'test-token', + session, + command: 'open', + positionals, + flags, + meta: { requestId, ...meta }, + }; +} + +beforeEach(() => { + mockResolveTargetDevice.mockReset(); + mockEnsureDeviceReady.mockReset(); + mockEnsureDeviceReady.mockResolvedValue(undefined); + mockAwaitFixtureReadiness.mockReset(); + mockAwaitFixtureReadiness.mockResolvedValue(undefined); + mockObserveSimulatorBoot.mockReset(); + mockObserveSimulatorBoot.mockImplementation(async () => ({ + observed: false, + reason: 'unobserved', + })); + mockDiscoverReadyAndroidEmulators.mockReset(); +}); + +function seedLiveForeignClaim(claimsDir: string, device: DeviceInfo, foreignStateDir: string) { + const deviceKey = `local:apple:ios:${device.id}`; + fs.writeFileSync( + path.join(claimsDir, `${crypto.createHash('sha256').update(deviceKey).digest('hex')}.json`), + JSON.stringify({ + schemaVersion: 2, + deviceKey, + device: { + family: 'apple', + appleOs: 'ios', + id: device.id, + name: device.name, + kind: 'simulator', + }, + session: 'shared', + workspace: '/worktrees/live', + stateDir: foreignStateDir, + ownerPid: process.ppid, + ownerStartTime: 'test-process-start', + ownerToken: 'reboot-takeover-token', + createdAtMs: 1, + updatedAtMs: 1, + }), + ); +} + +test('open takes a live foreign claim whose device rebooted after the claim was taken', async () => { + // #2538: host-global claims outlive a device reboot while the claiming daemon stays alive, so + // the device's own boot is the only ownership proof that does, and `open` says what it released. + const sessionStore = makeSessionStore('agent-device-router-open-reboot-'); + const device = makeIosDevice('SIM-REBOOTED'); + mockResolveTargetDevice.mockResolvedValue(device); + const claimsDir = mkdtempForTestSync('agent-device-router-open-reboot-claims-'); + const foreignStateDir = mkdtempForTestSync('agent-device-router-open-reboot-foreign-'); + const previousClaimsDir = process.env.AGENT_DEVICE_CLAIMS_DIR; + process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; + const bootedAtMs = Date.now(); + mockObserveSimulatorBoot.mockResolvedValueOnce({ observed: true, bootedAtMs }); + + try { + seedLiveForeignClaim(claimsDir, device, foreignStateDir); + + const response = await createOpenHandler(sessionStore)( + openRequest('reboot-takeover', { platform: 'ios' }, 'req-open-reboot-takeover'), + ); + + expect(response.ok).toBe(true); + if (!response.ok) return; + expect(mockObserveSimulatorBoot).toHaveBeenCalledWith(device); + expect(response.data?.warnings).toContain( + 'Took the device from session "shared" in workspace "/worktrees/live": that device rebooted after its claim was taken, so its app and runner were already gone.', + ); + expect(inspectDeviceClaims({ udid: device.id })[0]?.claim?.session).toBe('reboot-takeover'); + } finally { + if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR; + else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir; + fs.rmSync(claimsDir, { recursive: true, force: true }); + fs.rmSync(foreignStateDir, { recursive: true, force: true }); + } +}); + +function storedClaimUpdatedAt(device: DeviceInfo): number { + const claim = inspectDeviceClaims({ udid: device.id })[0]?.claim; + expect(claim?.updatedAtMs).toBeTypeOf('number'); + return claim?.updatedAtMs ?? 0; +} + +// The production reopen path never re-acquires the claim, so renewal has to ride the successful +// existing-session open itself: without it, an owner that came back after a reboot still carries a +// pre-reboot stamp and loses the device to the next caller that asks. +test('open renews the claim of an existing session that reopened its app after a reboot', async () => { + const sessionStore = makeSessionStore('agent-device-router-open-renew-'); + const device = makeIosDevice('SIM-RENEWED'); + mockResolveTargetDevice.mockResolvedValue(device); + const claimsDir = mkdtempForTestSync('agent-device-router-open-renew-claims-'); + const previousClaimsDir = process.env.AGENT_DEVICE_CLAIMS_DIR; + process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; + + try { + const opened = await createOpenHandler(sessionStore)( + openRequest('renew-owner', { platform: 'ios' }, 'req-open-renew-owner', {}, ['FixtureApp']), + ); + expect(opened.ok).toBe(true); + const claimedAtMs = storedClaimUpdatedAt(device); + await new Promise((resolve) => setTimeout(resolve, 2)); + + const reopened = await createOpenHandler(sessionStore)( + openRequest('renew-owner', { platform: 'ios' }, 'req-open-renew-reopen', {}, ['FixtureApp']), + ); + expect(reopened.ok).toBe(true); + expect(storedClaimUpdatedAt(device)).toBeGreaterThan(claimedAtMs); + + mockObserveSimulatorBoot.mockResolvedValueOnce({ + observed: true, + bootedAtMs: claimedAtMs + 1, + }); + const foreign = await createOpenHandler(sessionStore)( + openRequest('renew-foreign', { platform: 'ios' }, 'req-open-renew-foreign', {}, [ + 'FixtureApp', + ]), + ); + + expect(foreign.ok).toBe(false); + if (foreign.ok) return; + expect(foreign.error.code).toBe('DEVICE_IN_USE'); + expect(inspectDeviceClaims({ udid: device.id })[0]?.claim?.session).toBe('renew-owner'); + } finally { + if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR; + else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir; + fs.rmSync(claimsDir, { recursive: true, force: true }); + } +}); + +// The renewal is the owner's check that it still holds the device, and it runs before any device +// work: a foreign daemon that took the device mid-reopen must produce a refusal, not a successful +// launch on a device this session no longer owns. +test('an owner reopen that lost the device mid-flight reports the loss instead of launching', async () => { + const sessionStore = makeSessionStore('agent-device-router-open-race-'); + const device = makeIosDevice('SIM-RACED'); + mockResolveTargetDevice.mockResolvedValue(device); + const claimsDir = mkdtempForTestSync('agent-device-router-open-race-claims-'); + const foreignStateDir = mkdtempForTestSync('agent-device-router-open-race-foreign-'); + const previousClaimsDir = process.env.AGENT_DEVICE_CLAIMS_DIR; + process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; + + try { + const opened = await createOpenHandler(sessionStore)( + openRequest('race-owner', { platform: 'ios' }, 'req-open-race-owner', {}, ['FixtureApp']), + ); + expect(opened.ok).toBe(true); + + seedLiveForeignClaim(claimsDir, device, foreignStateDir); + + const reopened = await createOpenHandler(sessionStore)( + openRequest('race-owner', { platform: 'ios' }, 'req-open-race-reopen', {}, ['FixtureApp']), + ); + + expect(reopened.ok).toBe(false); + if (!reopened.ok) { + expect(reopened.error.code).toBe('DEVICE_IN_USE'); + expect(reopened.error.message).toContain('shared'); + } + expect(inspectDeviceClaims({ udid: device.id })[0]?.claim?.ownerToken).toBe( + 'reboot-takeover-token', + ); + } finally { + if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR; + else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir; + fs.rmSync(claimsDir, { recursive: true, force: true }); + fs.rmSync(foreignStateDir, { recursive: true, force: true }); + } +}); diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index 38e581b4e7..54dfcd04af 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -46,6 +46,16 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => resolveIosApp: vi.fn(async (_device, app) => app), }; }); +// The reboot-stale claim probe (#2538) must never reach the host's real process table here. +const mockObserveSimulatorBoot = vi.hoisted(() => + vi.fn(async (): Promise => ({ + observed: false, + reason: 'unobserved', + })), +); +vi.mock('@agent-device/platform-apple/simulator-boot', () => ({ + observeSimulatorBootTimeMs: mockObserveSimulatorBoot, +})); import { createRequestHandler, @@ -60,6 +70,7 @@ import { discoverReadyAndroidEmulators, } from './application-lifecycle-runtime-fixture.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { DeviceBootObservation } from '@agent-device/contracts/device-boot'; import { AppError } from '@agent-device/kernel/errors'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { inspectDeviceClaims } from '../device-claim-inspection.ts'; @@ -145,6 +156,11 @@ beforeEach(() => { mockAwaitFixtureReadiness.mockReset(); mockAwaitFixtureReadiness.mockResolvedValue(undefined); mockDiscoverReadyAndroidEmulators.mockReset(); + mockObserveSimulatorBoot.mockReset(); + mockObserveSimulatorBoot.mockImplementation(async () => ({ + observed: false, + reason: 'unobserved', + })); mockDiscoverReadyAndroidEmulators.mockImplementation(async (device) => [ { ...device, @@ -778,3 +794,69 @@ test('open reconciles a foreign dead owner through that owner state dir, never t fs.rmSync(foreignStateDir, { recursive: true, force: true }); } }); + +function storedClaimUpdatedAt(device: DeviceInfo): number { + const claim = inspectDeviceClaims({ udid: device.id })[0]?.claim; + expect(claim?.updatedAtMs).toBeTypeOf('number'); + return claim?.updatedAtMs ?? 0; +} + +// Preparation can boot the device an open is returning to, and the claim has to cover that boot: +// otherwise the boot an owner caused for itself is what takes the device away from it. The device +// boots strictly between the two renewals of one reopen, so only the post-preparation renewal can +// raise the stamp above the boot, and the foreign open asks from its own store, which is where +// reboot-based claim settlement happens. +test('an open that booted the device keeps the device against a foreign open', async () => { + const sessionStore = makeSessionStore('agent-device-router-open-boot-'); + const foreignStore = makeSessionStore('agent-device-router-open-boot-foreign-'); + const device = makeIosDevice('SIM-COLD-BOOTED'); + mockResolveTargetDevice.mockResolvedValue(device); + const claimsDir = mkdtempForTestSync('agent-device-router-open-boot-claims-'); + const previousClaimsDir = process.env.AGENT_DEVICE_CLAIMS_DIR; + process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; + + // The fixture's platform tools answer device state through this seam, so a test that wants a boot + // during preparation arms it here and the fake device reports that boot to every later probe. + let bootedAtMs: number | undefined; + let bootArmed = false; + mockObserveSimulatorBoot.mockImplementation(async () => + bootedAtMs === undefined + ? { observed: false, reason: 'unobserved' } + : { observed: true, bootedAtMs }, + ); + mockAwaitFixtureReadiness.mockImplementation(async () => { + if (bootArmed) { + bootArmed = false; + bootedAtMs = Date.now(); + } + }); + + try { + const opened = await createOpenHandler(sessionStore)( + openRequest('boot-owner', { platform: 'ios' }, 'req-open-boot-owner', {}, ['FixtureApp']), + ); + expect(opened.ok).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 2)); + + bootArmed = true; + const reopened = await createOpenHandler(sessionStore)( + openRequest('boot-owner', { platform: 'ios' }, 'req-open-boot-reopen', {}, ['FixtureApp']), + ); + expect(reopened.ok).toBe(true); + expect(bootedAtMs).toBeTypeOf('number'); + expect(storedClaimUpdatedAt(device)).toBeGreaterThanOrEqual(bootedAtMs ?? 0); + + const foreign = await createOpenHandler(foreignStore)( + openRequest('boot-foreign', { platform: 'ios' }, 'req-open-boot-foreign', {}, ['FixtureApp']), + ); + + expect(foreign.ok).toBe(false); + if (foreign.ok) return; + expect(foreign.error.code).toBe('DEVICE_IN_USE'); + expect(inspectDeviceClaims({ udid: device.id })[0]?.claim?.session).toBe('boot-owner'); + } finally { + if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR; + else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir; + fs.rmSync(claimsDir, { recursive: true, force: true }); + } +}); diff --git a/src/daemon/device-claim-reboot.ts b/src/daemon/device-claim-reboot.ts new file mode 100644 index 0000000000..0613a5beaa --- /dev/null +++ b/src/daemon/device-claim-reboot.ts @@ -0,0 +1,58 @@ +import type { DeviceBootObservationService } from '@agent-device/contracts/device-boot'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import type { DeviceClaim } from './device-claim-record.ts'; + +/** + * The claim `open` released and replaced because its device rebooted after the claim was taken. The + * reboot destroyed everything the claim was asserting — the app process, the runner, and the + * accessibility connection — so the claim can no longer describe live device-side ownership, however + * healthy its recorded owner's process looks. + */ +export type TakenOverDeviceClaim = Readonly<{ + session: string; + workspace: string; + stateDir: string; + bootedAtMs: number; +}>; + +/** + * The instant the owner last vouched for this device — the claim's own write — is the bound a boot + * has to pass: a device that came up after that has been through a boot this claim never described, + * and an owner that reopened its app after a reboot has stamped the boot the device is running now. + * Claims carry no per-request activity stamp by design, and none is needed — an owner whose device + * rebooted and stayed rebooted lost the session at the reboot, not at the next stale claim check. + * + * Both operands are host-clock milliseconds, which is what {@link DeviceBootObservationService} + * promises; a device clock that disagrees with the host has to stay out of the comparison. + */ +export async function rebootedDeviceClaim(params: { + claim: DeviceClaim; + device: DeviceInfo; + observeDeviceBoot?: DeviceBootObservationService; +}): Promise { + const observation = await params.observeDeviceBoot?.observeBootTimeMs(params.device); + if (observation?.observed !== true) return undefined; + if (observation.bootedAtMs <= params.claim.updatedAtMs) return undefined; + const { session, workspace, stateDir } = params.claim; + return { session, workspace, stateDir, bootedAtMs: observation.bootedAtMs }; +} + +export function emitClaimReleasedAfterDeviceReboot(params: { + deviceKey: string; + claim: DeviceClaim; + bootedAtMs: number; +}): void { + emitDiagnostic({ + level: 'info', + phase: 'device_claim_reboot_released', + data: { + deviceKey: params.deviceKey, + ownerSession: params.claim.session, + ownerStateDir: params.claim.stateDir, + ownerWorkspace: params.claim.workspace, + claimUpdatedAtMs: params.claim.updatedAtMs, + deviceBootedAtMs: params.bootedAtMs, + }, + }); +} diff --git a/src/daemon/device-claim-settlement.ts b/src/daemon/device-claim-settlement.ts index be6e3a012c..2df6a7090c 100644 --- a/src/daemon/device-claim-settlement.ts +++ b/src/daemon/device-claim-settlement.ts @@ -1,28 +1,37 @@ import fs from 'node:fs'; +import type { DeviceBootObservationService } from '@agent-device/contracts/device-boot'; +import type { DeviceInfo } from '@agent-device/kernel/device'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { + ownerIdentityMatches, + type readCurrentOwnerIdentity, +} from '@agent-device/host-kit/process'; import { deviceClaimOwnerCannotRelease, inspectDeviceClaimFile, + type DeviceClaimClassification, type InspectedDeviceClaim, } from './device-claim-inspection.ts'; +import { + emitClaimReleasedAfterDeviceReboot, + rebootedDeviceClaim, + type TakenOverDeviceClaim, +} from './device-claim-reboot.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'; +import { writeDeviceClaim } from './device-claim-store.ts'; /** * 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. + * caller may claim the device, and `tookOver` names the stale claim it replaced; `held` means the + * same session already owns the device and keeps its ownership token. */ export type ExistingClaimResolution = - | { status: 'available' } + | { status: 'available'; tookOver?: TakenOverDeviceClaim } | { status: 'held'; ownership: DeviceClaimSessionOwnership } | { status: 'conflict'; conflict: InspectedDeviceClaim }; @@ -37,16 +46,19 @@ export type DeviceClaimReconciler = ( /** * 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. + * settled exactly as `device release --stale` settles it. A claim whose owner can still release it + * clears only on the device's own evidence, because a device that rebooted after the claim was + * taken destroyed the runner, the app, and the accessibility connection the claim described. */ export async function resolveExistingClaim(params: { + device: DeviceInfo; deviceKey: string; owner: ReturnType; session: string; workspace: string; stateDir: string; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; + observeDeviceBoot?: DeviceBootObservationService; }): Promise { const existing = inspectDeviceClaimFile(resolveDeviceClaimPath(params.deviceKey)); if (!existing) return { status: 'available' }; @@ -58,21 +70,79 @@ export async function resolveExistingClaim(params: { return { status: 'available' }; } if (existing.claim && isCurrentClaimOwner(existing.claim, params, params.owner)) { - return { status: 'held', ownership: ownershipFromClaim(existing.claim) }; + return { status: 'held', ownership: renewHeldClaim(existing.claim) }; + } + return await settleForeignClaim(existing, params); +} + +/** + * An owner asking for the device it already holds vouches for that device as of now, which is what + * the reboot bound measures. Without this write, an owner that reopened its app after a reboot would + * keep a claim stamped before the reboot, and the next foreign `open` would read that stamp as + * proof of a device nobody owns and take it out from under a session that is demonstrably running. + */ +function renewHeldClaim(claim: DeviceClaim): DeviceClaimSessionOwnership { + if (claim.updatedAtMs >= Date.now()) return ownershipFromClaim(claim); + const renewed: DeviceClaim = { ...claim, updatedAtMs: Date.now() }; + writeDeviceClaim(renewed); + return ownershipFromClaim(renewed); +} + +/** + * A settled foreign claim clears through the same transaction `device release --stale` and the + * startup sweep use: durable resources first, claim last, so a resource still owned by the foreign + * session keeps the device claimed rather than handing it over mid-cleanup. + */ +async function settleForeignClaim( + existing: InspectedDeviceClaim, + params: { + device: DeviceInfo; + deviceKey: string; + reconcileOrphanedDeviceClaim: DeviceClaimReconciler; + observeDeviceBoot?: DeviceBootObservationService; + }, +): Promise { + const claim = existing.claim; + if (!claim) { + emitClaimConflict(params.deviceKey, existing); + return { status: 'conflict', conflict: existing }; } - if (!existing.claim || !deviceClaimOwnerCannotRelease(existing.classification)) { + const settlement = await foreignClaimSettlement({ + classification: existing.classification, + claim, + device: params.device, + observeDeviceBoot: params.observeDeviceBoot, + }); + if (!settlement.settles) { emitClaimConflict(params.deviceKey, existing); return { status: 'conflict', conflict: existing }; } const reconciliation = await settleVerifiedOrphanedClaim( - existing.claim, + claim, params.reconcileOrphanedDeviceClaim, ); if (reconciliation.status === 'retained') { emitClaimConflict(params.deviceKey, existing, reconciliation.reason); return { status: 'conflict', conflict: existing }; } - return { status: 'available' }; + if (!settlement.tookOver) return { status: 'available' }; + emitClaimReleasedAfterDeviceReboot({ + deviceKey: params.deviceKey, + claim, + bootedAtMs: settlement.tookOver.bootedAtMs, + }); + return { status: 'available', tookOver: settlement.tookOver }; +} + +async function foreignClaimSettlement(params: { + classification: DeviceClaimClassification; + claim: DeviceClaim; + device: DeviceInfo; + observeDeviceBoot?: DeviceBootObservationService; +}): Promise<{ settles: true; tookOver?: TakenOverDeviceClaim } | { settles: false }> { + if (deviceClaimOwnerCannotRelease(params.classification)) return { settles: true }; + const tookOver = await rebootedDeviceClaim(params); + return tookOver ? { settles: true, tookOver } : { settles: false }; } /** @@ -104,6 +174,7 @@ export function isClaimOwnedByThisDaemon( ); } +/** An abandoned claim holds the device for nobody, so it grants no authority and fences no daemon. */ export function isAbandonedDeviceClaim(claim: DeviceClaim): boolean { return claim.abandonedAtMs !== undefined; } @@ -118,14 +189,13 @@ function isAbandonedClaimOfThisDaemon( function isCurrentClaimOwner( claim: DeviceClaim, - params: Pick[0], 'session' | 'workspace' | 'stateDir'>, + params: { session: string; workspace: string; stateDir: string }, owner: ReturnType, ): boolean { return ( claim.session === params.session && claim.workspace === params.workspace && - claim.stateDir === params.stateDir && - ownerIdentityMatches({ pid: claim.ownerPid, startTime: claim.ownerStartTime }, owner) + isClaimOwnedByThisDaemon(claim, params.stateDir, owner) ); } diff --git a/src/daemon/device-claims.ts b/src/daemon/device-claims.ts index 883fdb0bfe..2326ba9db0 100644 --- a/src/daemon/device-claims.ts +++ b/src/daemon/device-claims.ts @@ -8,6 +8,7 @@ import { type DeviceIdentity, type DeviceInfo, } from '@agent-device/kernel/device'; +import type { DeviceBootObservationService } from '@agent-device/contracts/device-boot'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { ownerIdentityMatches, readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; @@ -19,6 +20,7 @@ import { type DeviceClaimSelectors, type InspectedDeviceClaim, } from './device-claim-inspection.ts'; +import type { TakenOverDeviceClaim } from './device-claim-reboot.ts'; import { canonicalLocalDeviceKey, resolveDeviceClaimPath, @@ -44,7 +46,12 @@ export type { DeviceClaimReconciler } from './device-claim-settlement.ts'; export type { DeviceClaimSessionOwnership } from './device-claim-record.ts'; export type DeviceClaimAcquireResult = - | { status: 'acquired'; ownership: DeviceClaimSessionOwnership } + | { + status: 'acquired'; + ownership: DeviceClaimSessionOwnership; + /** The stale foreign claim this acquisition replaced, when there was one. */ + tookOver?: TakenOverDeviceClaim; + } | { status: 'conflict'; conflict: InspectedDeviceClaim }; /** @@ -71,6 +78,11 @@ export async function acquireDeviceClaim(params: { workspace: string; stateDir: string; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; + /** + * Asks the device whether it rebooted after a foreign claim was taken, which is the only proof + * that outlives a live owner. Callers that have no answer to give omit it and keep the conflict. + */ + observeDeviceBoot?: DeviceBootObservationService; }): Promise { const identity = deviceClaimIdentity(params.device); const deviceKey = canonicalLocalDeviceKey(identity); @@ -128,16 +140,13 @@ async function claimHeldDevice(params: { workspace: string; stateDir: string; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; + observeDeviceBoot?: DeviceBootObservationService; }): Promise { const { deviceKey, identity } = params; const owner = readCurrentOwnerIdentity(); const existing = await resolveExistingClaim({ - deviceKey, + ...params, owner, - session: params.session, - workspace: params.workspace, - stateDir: params.stateDir, - reconcileOrphanedDeviceClaim: params.reconcileOrphanedDeviceClaim, }); if (existing.status === 'conflict') return existing; if (existing.status === 'held') return { status: 'acquired', ownership: existing.ownership }; @@ -159,7 +168,11 @@ async function claimHeldDevice(params: { updatedAtMs: now, }; writeDeviceClaim(claim); - return { status: 'acquired', ownership: ownershipFromClaim(claim) }; + return { + status: 'acquired', + ownership: ownershipFromClaim(claim), + ...(existing.tookOver ? { tookOver: existing.tookOver } : {}), + }; } /** @@ -329,20 +342,19 @@ export async function clearDeviceClaim( ownership: DeviceClaimSessionOwnership | undefined, ): Promise { if (!ownership) return 'absent'; - return await withDeviceClaimLock(ownership.deviceKey, async () => { - const claimPath = resolveDeviceClaimPath(ownership.deviceKey); - const inspected = inspectDeviceClaimFile(claimPath); - if (!inspected) return 'absent'; - const claim = inspected.claim; - if (!claim || !claimMatchesOwnership(claim, ownership)) return 'ownership-changed'; - try { - fs.unlinkSync(claimPath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - return 'absent'; - } - return 'deleted'; - }); + return await writeOwnedDeviceClaim( + ownership, + (_claim, claimPath) => { + try { + fs.unlinkSync(claimPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + return 'absent'; + } + return 'deleted'; + }, + (conflict) => (conflict ? 'ownership-changed' : 'absent'), + ); } /** @@ -362,14 +374,64 @@ export async function abandonDeviceClaim( ownership: DeviceClaimSessionOwnership | undefined, ): Promise { if (!ownership) return 'absent'; + return await writeOwnedDeviceClaim( + ownership, + (claim) => { + const now = Date.now(); + writeDeviceClaim({ ...claim, abandonedAtMs: now, updatedAtMs: now }); + return 'abandoned'; + }, + (conflict) => (conflict ? 'ownership-changed' : 'absent'), + ); +} + +/** + * What a renewal found when the claim was no longer the one this session holds: the record that + * took the device, or nothing at all when the claim is gone. + */ +export type DeviceClaimRenewal = + | { status: 'renewed' } + | { status: 'lost'; conflict: InspectedDeviceClaim | undefined }; + +/** + * Stamps the instant this ownership was last seen holding the device, which is the instant a later + * device boot has to postdate before it can invalidate the claim. An existing session that reopened + * its app vouched for the device again; without this stamp its first claim timestamp would let a + * foreign `open` read a reboot the owner already came back from as a device nobody owns. + * + * Renewal is the owner's check that it still holds the device, so a caller that is about to touch + * the device on this session's behalf has to treat a lost renewal as losing the device. + */ +export async function renewDeviceClaim( + ownership: DeviceClaimSessionOwnership | undefined, +): Promise { + if (!ownership) return { status: 'lost', conflict: undefined }; + return await writeOwnedDeviceClaim( + ownership, + (claim) => { + writeDeviceClaim({ ...claim, updatedAtMs: Date.now() }); + return { status: 'renewed' } as const; + }, + (conflict) => ({ status: 'lost' as const, conflict }), + ); +} + +/** + * Runs one claim write under the claim lock, for the owner that acquired it, and reports the record + * that took the device when it is no longer the one that ownership took. + */ +async function writeOwnedDeviceClaim( + ownership: DeviceClaimSessionOwnership, + act: (claim: DeviceClaim, claimPath: string) => O, + lost: (conflict: InspectedDeviceClaim | undefined) => L, +): Promise { return await withDeviceClaimLock(ownership.deviceKey, async () => { - const inspected = inspectDeviceClaimFile(resolveDeviceClaimPath(ownership.deviceKey)); - if (!inspected) return 'absent'; + const claimPath = resolveDeviceClaimPath(ownership.deviceKey); + const inspected = inspectDeviceClaimFile(claimPath); + if (!inspected) return lost(undefined); const claim = inspected.claim; - if (!claim || !claimMatchesOwnership(claim, ownership)) return 'ownership-changed'; - const now = Date.now(); - writeDeviceClaim({ ...claim, abandonedAtMs: now, updatedAtMs: now }); - return 'abandoned'; + if (!claim || !claimMatchesOwnership(claim, ownership)) return lost(inspected); + return act(claim, claimPath); }); } diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-warnings.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-warnings.test.ts new file mode 100644 index 0000000000..5b59ddfee3 --- /dev/null +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-warnings.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { appendResponseWarning, readResponseWarnings } from '../session-open-warnings.ts'; + +test('a producer adds its note without dropping the notes already on the response', () => { + const responseData: Record = { warnings: ['the session is already open'] }; + + appendResponseWarning(responseData, 'the device was taken over'); + + assert.deepEqual(responseData.warnings, [ + 'the session is already open', + 'the device was taken over', + ]); +}); + +test('a response that carries no warnings yet starts from an empty list', () => { + const responseData: Record = {}; + + appendResponseWarning(responseData, 'the device was taken over'); + + assert.deepEqual(responseData.warnings, ['the device was taken over']); +}); + +test('reading warnings ignores anything that is not a note', () => { + assert.deepEqual(readResponseWarnings({ warnings: ['a note', 42, { nested: true }, null] }), [ + 'a note', + ]); + assert.deepEqual(readResponseWarnings({}), []); + assert.deepEqual(readResponseWarnings(undefined), []); +}); diff --git a/src/daemon/session-lifecycle/internal/session-open-execution.ts b/src/daemon/session-lifecycle/internal/session-open-execution.ts index b26acc5f4e..e13840645a 100644 --- a/src/daemon/session-lifecycle/internal/session-open-execution.ts +++ b/src/daemon/session-lifecycle/internal/session-open-execution.ts @@ -50,10 +50,14 @@ import { abandonDeviceClaim, acquireDeviceClaim, clearDeviceClaim, + renewDeviceClaim, type DeviceClaimAcquireResult, type DeviceClaimSessionOwnership, type DeviceClaimReconciler, } from '../../device-claims.ts'; +import type { TakenOverDeviceClaim } from '../../device-claim-reboot.ts'; +import { deviceBootObservation } from '../../../platform-runtime-device-boot.ts'; +import { appendResponseWarning } from './session-open-warnings.ts'; import { buildAllocatorHeldRefusal, buildDeviceClaimConflictError, @@ -103,13 +107,18 @@ function applyOrdinaryScriptRecordingOpenOutcome(params: { } if (!isAuthoringArmedSession(existingSession)) return; abortAuthoringOnSecondOpen(session); - const warnings = Array.isArray(responseData.warnings) - ? responseData.warnings.filter((warning): warning is string => typeof warning === 'string') - : []; - responseData.warnings = [ - ...warnings, + appendResponseWarning( + responseData, 'Script publication was aborted because this session completed a second open. Start a fresh session with open --save-script to author another script.', - ]; + ); +} + +/** What the caller's `open` output says when a claim was taken over because its device rebooted. */ +function deviceClaimTakeoverWarning(tookOver: TakenOverDeviceClaim): string { + return ( + `Took the device from session "${tookOver.session}" in workspace "${tookOver.workspace}": ` + + 'that device rebooted after its claim was taken, so its app and runner were already gone.' + ); } // Default-on for emulators, opt-in via --test-ime on real devices; --no-test-ime forces off. @@ -134,6 +143,32 @@ function buildStartupPerfSample( }; } +/** + * Stamps the moment an open established its device, which is the instant a later device boot has to + * postdate before it can release the claim. A claim this open took, or one its session already held, + * counts; a session that runs without a claim has nothing to stamp. Losing the claim mid-open ends + * the open with the refusal every other surface reports, rather than a launch on a device this + * session no longer owns. + */ +export async function renewOpenSessionClaim( + device: DeviceInfo, + ownership: DeviceClaimSessionOwnership | undefined, +): Promise { + if (!ownership) return undefined; + const renewal = await renewDeviceClaim(ownership); + if (renewal.status === 'renewed') return undefined; + if (renewal.conflict) return buildDeviceClaimConflictError(device, renewal.conflict); + return errorResponse( + 'DEVICE_IN_USE', + `${device.name} no longer holds the claim this open was made with.`, + { + reason: 'claim-lost-during-open', + deviceKey: ownership.deviceKey, + hint: 'Close this session and open the device again to claim it.', + }, + ); +} + // fallow-ignore-next-line complexity export async function completeOpenCommand(params: { req: DaemonRequest; @@ -151,6 +186,8 @@ export async function completeOpenCommand(params: { applyRuntimeHints?: RuntimeHintApplyOperation; existingSession?: SessionState; deviceClaim?: DeviceClaimSessionOwnership; + /** The stale claim this open released before taking the device, when there was one. */ + tookOverDeviceClaim?: TakenOverDeviceClaim; selection?: DeviceSelectionResult; }): Promise { const { @@ -169,6 +206,7 @@ export async function completeOpenCommand(params: { applyRuntimeHints, existingSession, deviceClaim, + tookOverDeviceClaim, selection, } = params; const shouldRelaunch = req.flags?.relaunch === true; @@ -274,6 +312,9 @@ export async function completeOpenCommand(params: { sessionReused: existingSession !== undefined, selection: preparedSelection, }); + if (tookOverDeviceClaim) { + appendResponseWarning(openResult, deviceClaimTakeoverWarning(tookOverDeviceClaim)); + } applyOrdinaryScriptRecordingOpenOutcome({ session: nextSession, existingSession, @@ -401,6 +442,7 @@ async function acquireDeviceClaimForOwner(params: { workspace: req.meta?.cwd ?? process.cwd(), stateDir: sessionStore.resolveDaemonStateDir(), reconcileOrphanedDeviceClaim, + observeDeviceBoot: deviceBootObservation, }); } } @@ -450,6 +492,7 @@ export async function openNewSessionWithDeviceClaim(params: { return buildDeviceClaimConflictError(device, ownerClaim.conflict); if (ownerClaim.status === 'refused') return ownerClaim.response; const deviceClaim = ownerClaim.status === 'acquired' ? ownerClaim.ownership : undefined; + const tookOverDeviceClaim = ownerClaim.status === 'acquired' ? ownerClaim.tookOver : undefined; const effects: NewSessionOpenEffects = { mayHaveStarted: false }; const rollbackClaim = async () => await rollbackNewSessionClaim({ @@ -474,8 +517,11 @@ export async function openNewSessionWithDeviceClaim(params: { return details.response; } // Preparation can boot the device or warm caches, but it cannot establish session ownership. - // `completeOpenCommand` can relaunch-close an app or write runtime hints before its main open - // dispatch, so a failure from that point cannot prove ownership was not established. + // Stamping here is what covers a boot preparation caused for this very open; from + // `completeOpenCommand` on, a relaunch-close or a runtime-hint write may already have touched the + // app, so a failure from that point cannot prove ownership was never established. + const reclaimed = await renewOpenSessionClaim(device, deviceClaim); + if (reclaimed) return reclaimed; effects.mayHaveStarted = true; const requestedPositionals = req.positionals ?? []; // `open ` carries both positionals; only `--foreground`, which has none, gets its @@ -502,6 +548,7 @@ export async function openNewSessionWithDeviceClaim(params: { applyRuntimeHints, surface, deviceClaim, + tookOverDeviceClaim, selection, }); if (!response.ok) await rollbackClaim(); diff --git a/src/daemon/session-lifecycle/internal/session-open-foreground.ts b/src/daemon/session-lifecycle/internal/session-open-foreground.ts index 43c7dbf78a..e9284455b4 100644 --- a/src/daemon/session-lifecycle/internal/session-open-foreground.ts +++ b/src/daemon/session-lifecycle/internal/session-open-foreground.ts @@ -7,6 +7,7 @@ import type { InspectDeviceRuntimeFacts, } from '../../request-runtime-binding.ts'; import { errorResponse } from '../../response.ts'; +import { readResponseWarnings } from './session-open-warnings.ts'; export type ForegroundOpenResolution = | { type: 'not-requested' } @@ -153,7 +154,7 @@ function openWithInitialSnapshotFailure( data: { ...openData, warnings: [ - ...readStringWarnings(openData), + ...readResponseWarnings(openData), `The session is open, but the initial interactive snapshot failed (${error.code}: ${error.message}). Run: agent-device snapshot -i`, ], // The FULL error shape (hint/details/diagnosticId/logPath), not a @@ -163,8 +164,3 @@ function openWithInitialSnapshotFailure( }, }; } - -function readStringWarnings(data: Record | undefined): string[] { - if (!data || !Array.isArray(data.warnings)) return []; - return data.warnings.filter((warning): warning is string => typeof warning === 'string'); -} diff --git a/src/daemon/session-lifecycle/internal/session-open-warnings.ts b/src/daemon/session-lifecycle/internal/session-open-warnings.ts new file mode 100644 index 0000000000..ed30e662ca --- /dev/null +++ b/src/daemon/session-lifecycle/internal/session-open-warnings.ts @@ -0,0 +1,17 @@ +/** + * Response-level warnings accumulate: every `open` producer adds its own note and keeps the ones + * already there, so a producer never has to know which other note ran first. + */ +export function appendResponseWarning( + responseData: Record, + warning: string, +): void { + responseData.warnings = [...readResponseWarnings(responseData), warning]; +} + +export function readResponseWarnings(responseData: Record | undefined): string[] { + const warnings = responseData?.warnings; + return Array.isArray(warnings) + ? warnings.filter((warning): warning is string => typeof warning === 'string') + : []; +} diff --git a/src/daemon/session-lifecycle/internal/session-open.ts b/src/daemon/session-lifecycle/internal/session-open.ts index 07c035e7e7..8897a7efa0 100644 --- a/src/daemon/session-lifecycle/internal/session-open.ts +++ b/src/daemon/session-lifecycle/internal/session-open.ts @@ -39,6 +39,7 @@ import { requireRuntimeBinding, requireRuntimeFacts } from '../../session-runtim import { completeOpenCommand, openNewSessionWithDeviceClaim, + renewOpenSessionClaim, type OpenApplicationRuntime, type RuntimeHintApplyOperation, type RuntimeHintClearOperation, @@ -190,6 +191,11 @@ async function handleOpenCommand(params: SessionOpenCommandInput): Promise` verifies the daemon PID/start-time identity, requests graceful shutdown, and reports whether provider-release state is known. Use `daemon stop --clean` to also remove retained Apple runner processes and leases owned by that daemon. -- `device status` reads host-local device claims without starting or contacting a daemon. Normal output shows live and attention-needed claims, then summarizes proven-stale records in one line; use `device status --stale` to inspect the hidden records. Scope either view with `--platform` plus `--udid` (Apple) or `--serial` (Android). A foreign live or uncertain claim blocks `open`; a proven-dead owner is replaced only after its session's exact-owner durable resources reconcile successfully. -- `device release --stale` settles a provably dead owner's durable resources through the same exact-owner reconciliation `open` uses and clears its claim last, all without a daemon. Live, uncertain, PID-reused, and corrupt claims always fail closed and are reported with the reason; a live owner is released by closing its session from its own workspace or with `daemon stop --state-dir `. +- `device status` reads host-local device claims without starting or contacting a daemon. Normal output shows live and attention-needed claims, then summarizes proven-stale records in one line; use `device status --stale` to inspect the hidden records. Scope either view with `--platform` plus `--udid` (Apple) or `--serial` (Android). A foreign live or uncertain claim blocks `open`, and a proven-dead owner is replaced only after its session's exact-owner durable resources reconcile successfully. +- `device release --stale` settles a provably dead owner's durable resources through the same exact-owner reconciliation `open` uses and clears its claim last, all without a daemon. Live, uncertain, PID-reused, and corrupt claims always fail closed and are reported with the reason; a live owner is released by closing its session from its own workspace or with `daemon stop --state-dir `. One claim condition is settled on the device instead: a device that rebooted after its claim was taken destroyed the app, runner, and accessibility session that claim described, so `open` reconciles that owner's resources, takes the claim, and reports the release in `warnings`. `device release --stale` never probes a device for its boot, so it keeps refusing such a claim. - `--platform apple` is an alias for the Apple automation backend (`ios`, `tvOS`, `macOS` selection). - Use `--target mobile|tv|desktop` with `--platform` (required) to select phone/tablet vs TV-class vs desktop-class targets. - `boot` is mainly needed when starting a new session and `open` fails because no booted simulator/emulator is available.