diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index ceb16e1f2a..db43d773b5 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -317,6 +317,7 @@ function summarizeProviderScenarioFlagExclusions() { 'stepsFile', 'proxyHost', 'proxyPort', + 'stale', ], }, { diff --git a/src/__tests__/cli-device-status.test.ts b/src/__tests__/cli-device-status.test.ts index 4ae4e9c67c..32599f211b 100644 --- a/src/__tests__/cli-device-status.test.ts +++ b/src/__tests__/cli-device-status.test.ts @@ -1,5 +1,9 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { test } from 'vitest'; +import { readCurrentOwnerIdentity } from '../utils/owner-identity.ts'; import { runCliCapture } from './cli-capture.ts'; test('device status is daemonless and does not send a daemon request', async () => { @@ -7,5 +11,124 @@ test('device status is daemonless and does not send a daemon request', async () assert.equal(result.code, null); assert.equal(result.calls.length, 0); const payload = JSON.parse(result.stdout); - assert.deepEqual(payload, { success: true, data: { claims: [] } }); + assert.deepEqual(payload, { success: true, data: { claims: [], hiddenStaleClaims: 0 } }); +}); + +test('keeps normal status compact while retaining proven-stale claims for explicit inspection', async () => { + const claimsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cli-claims-')); + const owner = readCurrentOwnerIdentity(); + try { + fs.writeFileSync( + path.join(claimsDir, 'live.json'), + JSON.stringify({ + schemaVersion: 1, + deviceKey: 'local:android:none:live', + device: { platform: 'android', id: 'live', name: 'Live Pixel', kind: 'emulator' }, + session: 'live-session', + workspace: '/worktrees/live', + stateDir: process.cwd(), + ownerPid: owner.pid, + ownerStartTime: owner.startTime, + ownerToken: 'live-token', + createdAtMs: 1, + updatedAtMs: 1, + }), + ); + fs.writeFileSync( + path.join(claimsDir, 'stale.json'), + JSON.stringify({ + schemaVersion: 1, + deviceKey: 'local:android:none:stale', + device: { + platform: 'android', + id: 'stale', + name: 'Stale Pixel; echo no', + kind: 'emulator', + }, + session: 'stale-session', + workspace: '/worktrees/stale', + stateDir: process.cwd(), + ownerPid: 999_999_999, + ownerStartTime: 'old-start-time', + ownerToken: 'stale-token', + createdAtMs: 1, + updatedAtMs: 1, + }), + ); + + const normal = await runCliCapture(['device', 'status'], { + env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir }, + }); + assert.equal(normal.code, null); + assert.equal(normal.calls.length, 0); + assert.match(normal.stdout, /Live Pixel: live/); + assert.match( + normal.stdout, + /1 stale claim hidden; inspect with: agent-device device status --stale/, + ); + assert.doesNotMatch(normal.stdout, /Stale Pixel/); + + const stale = await runCliCapture(['device', 'status', '--stale', '--json'], { + env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir }, + }); + assert.equal(stale.code, null); + assert.equal(stale.calls.length, 0); + const payload = JSON.parse(stale.stdout); + assert.equal(payload.data.claims.length, 1); + assert.equal(payload.data.claims[0].device.name, 'Stale Pixel; echo no'); + assert.equal(payload.data.claims[0].recovery, undefined); + + const scoped = await runCliCapture(['device', 'status', '--device', 'Stale Pixel; echo no'], { + env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir }, + }); + assert.match( + scoped.stdout, + /inspect with: agent-device device status --device 'Stale Pixel; echo no' --stale/, + ); + } finally { + fs.rmSync(claimsDir, { recursive: true, force: true }); + } +}); + +test('keeps corrupt and state-dir-gone claims visible in normal status', async () => { + const claimsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cli-claims-')); + const owner = readCurrentOwnerIdentity(); + try { + fs.writeFileSync(path.join(claimsDir, 'corrupt.json'), '{bad json'); + fs.writeFileSync( + path.join(claimsDir, 'state-dir-gone.json'), + JSON.stringify({ + schemaVersion: 1, + deviceKey: 'local:android:none:state-dir-gone', + device: { + platform: 'android', + id: 'state-dir-gone', + name: 'State-dir-gone Pixel', + kind: 'emulator', + }, + session: 'state-dir-gone-session', + workspace: '/worktrees/state-dir-gone', + stateDir: path.join(claimsDir, 'missing-state-dir'), + ownerPid: owner.pid, + ownerStartTime: owner.startTime, + ownerToken: 'state-dir-gone-token', + createdAtMs: 1, + updatedAtMs: 1, + }), + ); + + const normal = await runCliCapture(['device', 'status'], { + env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir }, + }); + assert.match(normal.stdout, /corrupt.json: inconsistent/); + assert.doesNotMatch(normal.stdout, /State-dir-gone Pixel/); + assert.match(normal.stdout, /1 stale claim hidden/); + + const stale = await runCliCapture(['device', 'status', '--stale'], { + env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir }, + }); + assert.match(stale.stdout, /State-dir-gone Pixel: owner-state-dir-gone/); + } finally { + fs.rmSync(claimsDir, { recursive: true, force: true }); + } }); diff --git a/src/cli-schema/command-overrides.ts b/src/cli-schema/command-overrides.ts index 0ad0f999d1..77e59cd994 100644 --- a/src/cli-schema/command-overrides.ts +++ b/src/cli-schema/command-overrides.ts @@ -40,12 +40,14 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { supportedFlags: ['stateDir'], }, device: { - usageOverride: 'device status [--platform ] [--udid ] [--serial ]', + usageOverride: + 'device status [--platform ] [--udid ] [--serial ] [--stale]', listUsageOverride: 'device status', helpDescription: - 'Inspect advisory host-local device ownership claims without starting or contacting a daemon.', + 'Inspect advisory host-local device ownership claims without starting or contacting a daemon. --stale only inspects proven-stale claims; it does not reclaim claims or clean platform resources.', summary: 'Inspect local advisory device ownership without daemon side effects', positionalArgs: ['status'], + allowedFlags: ['stale'], supportedFlags: ['platform', 'device', 'udid', 'serial'], }, connect: { diff --git a/src/cli/commands/device.ts b/src/cli/commands/device.ts index 82378c5c81..b1c6580d02 100644 --- a/src/cli/commands/device.ts +++ b/src/cli/commands/device.ts @@ -3,6 +3,7 @@ import { inspectDeviceClaims, type InspectedDeviceClaim, } from '../../daemon/device-claim-inspection.ts'; +import { shellQuoteIfNeeded } from '../../utils/shell-quote.ts'; import { writeCommandOutput } from './shared.ts'; import type { ClientCommandHandler } from './router-types.ts'; @@ -10,21 +11,41 @@ export const deviceCommand: ClientCommandHandler = async ({ positionals, flags } if (positionals[0] !== 'status' || positionals.length !== 1) { throw new AppError('INVALID_ARGS', 'device accepts only: status'); } - const claims = inspectDeviceClaims({ + const inspectedClaims = inspectDeviceClaims({ platform: flags.platform, device: flags.device, udid: flags.udid, serial: flags.serial, - }).map(serializeClaim); - const data = { claims }; - writeCommandOutput(flags, data, () => renderDeviceStatus(claims)); + }); + const staleClaims = inspectedClaims.filter(isStaleClaim); + const claims = ( + flags.stale ? staleClaims : inspectedClaims.filter((claim) => !isStaleClaim(claim)) + ).map(serializeClaim); + const data = { + claims, + ...(flags.stale ? {} : { hiddenStaleClaims: staleClaims.length }), + }; + writeCommandOutput(flags, data, () => + renderDeviceStatus(claims, { + staleOnly: flags.stale === true, + hiddenStaleClaims: staleClaims.length, + staleCommand: buildStaleInspectionCommand(flags), + }), + ); return true; }; +function isStaleClaim(claim: InspectedDeviceClaim): boolean { + return ( + claim.classification === 'owner-process-dead' || claim.classification === 'owner-state-dir-gone' + ); +} + function serializeClaim(entry: InspectedDeviceClaim): Record { const claim = entry.claim; return { ...(entry.deviceKey ? { deviceKey: entry.deviceKey } : {}), + ...(!claim ? { fileName: entry.fileName } : {}), classification: entry.classification, ...(claim ? { @@ -36,37 +57,58 @@ function serializeClaim(entry: InspectedDeviceClaim): Record { pid: claim.ownerPid, startTime: claim.ownerStartTime, }, - recovery: { - command: futureRecoveryCommand(claim.device.platform, claim.device.id), - }, } : {}), ...(entry.error ? { error: entry.error } : {}), }; } -function futureRecoveryCommand(platform: string, id: string): string | undefined { - if (platform === 'ios' || platform === 'macos') { - return `agent-device device release --platform ${platform} --udid ${id} --stale`; +function renderDeviceStatus( + claims: Record[], + options: { staleOnly: boolean; hiddenStaleClaims: number; staleCommand: string }, +): string { + const claimLines = claims.map(renderClaimLine); + if (claimLines.length === 0) { + if (options.staleOnly) return 'No stale local advisory device claims found.'; + if (options.hiddenStaleClaims === 0) return 'No local advisory device claims found.'; } - if (platform === 'android') { - return `agent-device device release --platform android --serial ${id} --stale`; - } - return undefined; + return [ + ...claimLines, + !options.staleOnly && options.hiddenStaleClaims > 0 + ? `${options.hiddenStaleClaims} stale ${options.hiddenStaleClaims === 1 ? 'claim' : 'claims'} hidden; inspect with: ${options.staleCommand}` + : null, + ] + .filter((line): line is string => Boolean(line)) + .join('\n'); } -function renderDeviceStatus(claims: Record[]): string { - if (claims.length === 0) return 'No local advisory device claims found.'; - return claims - .map((claim) => { - const device = claim.device as { platform?: string; id?: string; name?: string } | undefined; - const owner = claim.owner as { session?: string; workspace?: string } | undefined; - return [ - `${device?.platform ?? 'unknown'} ${device?.name ?? device?.id ?? claim.deviceKey ?? 'claim'}: ${claim.classification}`, - owner ? `session=${owner.session} workspace=${owner.workspace}` : null, - ] - .filter((part): part is string => Boolean(part)) - .join(' '); - }) - .join('\n'); +function renderClaimLine(claim: Record): string { + return `${renderClaimLabel(claim)}: ${claim.classification}${renderClaimOwner(claim.owner)}`; +} + +function renderClaimLabel(claim: Record): string { + const device = claim.device as { platform?: string; id?: string; name?: string } | undefined; + if (!device) return String(claim.deviceKey ?? claim.fileName ?? 'claim'); + return `${device.platform ?? 'unknown'} ${device.name ?? device.id ?? 'claim'}`; +} + +function renderClaimOwner(value: unknown): string { + if (!value || typeof value !== 'object') return ''; + const owner = value as { session?: string; workspace?: string }; + return ` session=${owner.session} workspace=${owner.workspace}`; +} + +function buildStaleInspectionCommand(flags: { + platform?: string; + device?: string; + udid?: string; + serial?: string; +}): string { + const selectors = [ + flags.platform ? `--platform ${shellQuoteIfNeeded(flags.platform)}` : null, + flags.device ? `--device ${shellQuoteIfNeeded(flags.device)}` : null, + flags.udid ? `--udid ${shellQuoteIfNeeded(flags.udid)}` : null, + flags.serial ? `--serial ${shellQuoteIfNeeded(flags.serial)}` : null, + ].filter((part): part is string => Boolean(part)); + return ['agent-device device status', ...selectors, '--stale'].join(' '); } diff --git a/src/commands/cli-grammar/flag-definitions-target.ts b/src/commands/cli-grammar/flag-definitions-target.ts index 26443f4498..595a6947f7 100644 --- a/src/commands/cli-grammar/flag-definitions-target.ts +++ b/src/commands/cli-grammar/flag-definitions-target.ts @@ -41,6 +41,13 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--serial ', usageDescription: 'Android device serial', }, + { + key: 'stale', + names: ['--stale'], + type: 'boolean', + usageLabel: '--stale', + usageDescription: 'Device status: show only claims with a provably stale owner', + }, { key: 'surface', names: ['--surface'], diff --git a/src/contracts/cli-flags.ts b/src/contracts/cli-flags.ts index 02e798d5c2..173e2ad08c 100644 --- a/src/contracts/cli-flags.ts +++ b/src/contracts/cli-flags.ts @@ -52,6 +52,7 @@ export type CliFlags = CloudProviderProfileFields & device?: string; udid?: string; serial?: string; + stale?: boolean; iosSimulatorDeviceSet?: string; iosXctestrunFile?: string; iosXctestDerivedDataPath?: string; diff --git a/test/skillgym/suites/agent-device-smoke-suite.ts b/test/skillgym/suites/agent-device-smoke-suite.ts index 6614a5092e..6939a1cdd4 100644 --- a/test/skillgym/suites/agent-device-smoke-suite.ts +++ b/test/skillgym/suites/agent-device-smoke-suite.ts @@ -1108,10 +1108,10 @@ const SKILL_GUIDANCE_CASES: Case[] = [ contract: [ 'Another worktree may have an advisory claim on Android emulator-5554', 'Need to inspect local ownership without starting or contacting any daemon', - 'Stage 1 is observational only: device release and --stale are not available yet', + 'Claims are observational only: device release is unavailable; device status --stale only inspects proven-stale records and does not clean them', ], task: 'Plan the one command that inspects this Android device claim directly. Do not use ps, daemon commands, or an unavailable release command.', - outputs: [plannedCommand('device status')], + outputs: [plannedCommand('device status --platform android --serial emulator-5554')], forbiddenOutputs: [/\bdaemon\b/i, /\bdevice\s+release\b/i, /\bps\b/i], strictFinalOutput: true, }), diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index c78371b196..2caea0c257 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -59,7 +59,7 @@ agent-device app-switcher - `shutdown` turns off the selected Apple simulator or Android emulator. - `shutdown` must not target an active session device; use `close --shutdown` to end the session and turn it off. - `daemon stop --state-dir ` 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 advisory device claims without starting or contacting a daemon. Scope it with `--platform` plus `--udid` (Apple) or `--serial` (Android) to inspect one target. Stage 1 claims are observational: a live claim is reported but does not yet block `open` or offer a release command. +- `device status` reads host-local advisory 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). Claims remain observational: a live claim does not yet block `open`, and stale inspection does not delete a claim or clean platform resources. - `--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.