Skip to content

Commit e3be0cd

Browse files
committed
fix(cli): compact stale device status
1 parent 968db8b commit e3be0cd

8 files changed

Lines changed: 201 additions & 34 deletions

File tree

‎scripts/integration-progress-model.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ function summarizeProviderScenarioFlagExclusions() {
317317
'stepsFile',
318318
'proxyHost',
319319
'proxyPort',
320+
'stale',
320321
],
321322
},
322323
{
Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,126 @@
11
import assert from 'node:assert/strict';
2+
import fs from 'node:fs';
3+
import os from 'node:os';
4+
import path from 'node:path';
25
import { test } from 'vitest';
6+
import { readCurrentOwnerIdentity } from '../utils/owner-identity.ts';
37
import { runCliCapture } from './cli-capture.ts';
48

59
test('device status is daemonless and does not send a daemon request', async () => {
610
const result = await runCliCapture(['device', 'status', '--json']);
711
assert.equal(result.code, null);
812
assert.equal(result.calls.length, 0);
913
const payload = JSON.parse(result.stdout);
10-
assert.deepEqual(payload, { success: true, data: { claims: [] } });
14+
assert.deepEqual(payload, { success: true, data: { claims: [], hiddenStaleClaims: 0 } });
15+
});
16+
17+
test('keeps normal status compact while retaining proven-stale claims for explicit inspection', async () => {
18+
const claimsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cli-claims-'));
19+
const owner = readCurrentOwnerIdentity();
20+
try {
21+
fs.writeFileSync(
22+
path.join(claimsDir, 'live.json'),
23+
JSON.stringify({
24+
schemaVersion: 1,
25+
deviceKey: 'local:android:none:live',
26+
device: { platform: 'android', id: 'live', name: 'Live Pixel', kind: 'emulator' },
27+
session: 'live-session',
28+
workspace: '/worktrees/live',
29+
stateDir: process.cwd(),
30+
ownerPid: owner.pid,
31+
ownerStartTime: owner.startTime,
32+
ownerToken: 'live-token',
33+
createdAtMs: 1,
34+
updatedAtMs: 1,
35+
}),
36+
);
37+
fs.writeFileSync(
38+
path.join(claimsDir, 'stale.json'),
39+
JSON.stringify({
40+
schemaVersion: 1,
41+
deviceKey: 'local:android:none:stale',
42+
device: { platform: 'android', id: 'stale', name: 'Stale Pixel', kind: 'emulator' },
43+
session: 'stale-session',
44+
workspace: '/worktrees/stale',
45+
stateDir: process.cwd(),
46+
ownerPid: 999_999_999,
47+
ownerStartTime: 'old-start-time',
48+
ownerToken: 'stale-token',
49+
createdAtMs: 1,
50+
updatedAtMs: 1,
51+
}),
52+
);
53+
54+
const normal = await runCliCapture(['device', 'status'], {
55+
env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir },
56+
});
57+
assert.equal(normal.code, null);
58+
assert.equal(normal.calls.length, 0);
59+
assert.match(normal.stdout, /Live Pixel: live/);
60+
assert.match(
61+
normal.stdout,
62+
/1 stale claim hidden; inspect with: agent-device device status --stale/,
63+
);
64+
assert.doesNotMatch(normal.stdout, /Stale Pixel/);
65+
66+
const stale = await runCliCapture(['device', 'status', '--stale', '--json'], {
67+
env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir },
68+
});
69+
assert.equal(stale.code, null);
70+
assert.equal(stale.calls.length, 0);
71+
const payload = JSON.parse(stale.stdout);
72+
assert.equal(payload.data.claims.length, 1);
73+
assert.equal(payload.data.claims[0].device.name, 'Stale Pixel');
74+
assert.equal(payload.data.claims[0].recovery, undefined);
75+
76+
const scoped = await runCliCapture(['device', 'status', '--serial', 'stale'], {
77+
env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir },
78+
});
79+
assert.match(scoped.stdout, /inspect with: agent-device device status --serial stale --stale/);
80+
} finally {
81+
fs.rmSync(claimsDir, { recursive: true, force: true });
82+
}
83+
});
84+
85+
test('keeps corrupt and state-dir-gone claims visible in normal status', async () => {
86+
const claimsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cli-claims-'));
87+
const owner = readCurrentOwnerIdentity();
88+
try {
89+
fs.writeFileSync(path.join(claimsDir, 'corrupt.json'), '{bad json');
90+
fs.writeFileSync(
91+
path.join(claimsDir, 'state-dir-gone.json'),
92+
JSON.stringify({
93+
schemaVersion: 1,
94+
deviceKey: 'local:android:none:state-dir-gone',
95+
device: {
96+
platform: 'android',
97+
id: 'state-dir-gone',
98+
name: 'State-dir-gone Pixel',
99+
kind: 'emulator',
100+
},
101+
session: 'state-dir-gone-session',
102+
workspace: '/worktrees/state-dir-gone',
103+
stateDir: path.join(claimsDir, 'missing-state-dir'),
104+
ownerPid: owner.pid,
105+
ownerStartTime: owner.startTime,
106+
ownerToken: 'state-dir-gone-token',
107+
createdAtMs: 1,
108+
updatedAtMs: 1,
109+
}),
110+
);
111+
112+
const normal = await runCliCapture(['device', 'status'], {
113+
env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir },
114+
});
115+
assert.match(normal.stdout, /corrupt.json: inconsistent/);
116+
assert.doesNotMatch(normal.stdout, /State-dir-gone Pixel/);
117+
assert.match(normal.stdout, /1 stale claim hidden/);
118+
119+
const stale = await runCliCapture(['device', 'status', '--stale'], {
120+
env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir },
121+
});
122+
assert.match(stale.stdout, /State-dir-gone Pixel: owner-state-dir-gone/);
123+
} finally {
124+
fs.rmSync(claimsDir, { recursive: true, force: true });
125+
}
11126
});

‎src/cli-schema/command-overrides.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,14 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = {
4040
supportedFlags: ['stateDir'],
4141
},
4242
device: {
43-
usageOverride: 'device status [--platform <platform>] [--udid <udid>] [--serial <serial>]',
43+
usageOverride:
44+
'device status [--platform <platform>] [--udid <udid>] [--serial <serial>] [--stale]',
4445
listUsageOverride: 'device status',
4546
helpDescription:
46-
'Inspect advisory host-local device ownership claims without starting or contacting a daemon.',
47+
'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.',
4748
summary: 'Inspect local advisory device ownership without daemon side effects',
4849
positionalArgs: ['status'],
50+
allowedFlags: ['stale'],
4951
supportedFlags: ['platform', 'device', 'udid', 'serial'],
5052
},
5153
connect: {

‎src/cli/commands/device.ts‎

Lines changed: 69 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,41 @@ export const deviceCommand: ClientCommandHandler = async ({ positionals, flags }
1010
if (positionals[0] !== 'status' || positionals.length !== 1) {
1111
throw new AppError('INVALID_ARGS', 'device accepts only: status');
1212
}
13-
const claims = inspectDeviceClaims({
13+
const inspectedClaims = inspectDeviceClaims({
1414
platform: flags.platform,
1515
device: flags.device,
1616
udid: flags.udid,
1717
serial: flags.serial,
18-
}).map(serializeClaim);
19-
const data = { claims };
20-
writeCommandOutput(flags, data, () => renderDeviceStatus(claims));
18+
});
19+
const staleClaims = inspectedClaims.filter(isStaleClaim);
20+
const claims = (
21+
flags.stale ? staleClaims : inspectedClaims.filter((claim) => !isStaleClaim(claim))
22+
).map(serializeClaim);
23+
const data = {
24+
claims,
25+
...(flags.stale ? {} : { hiddenStaleClaims: staleClaims.length }),
26+
};
27+
writeCommandOutput(flags, data, () =>
28+
renderDeviceStatus(claims, {
29+
staleOnly: flags.stale === true,
30+
hiddenStaleClaims: staleClaims.length,
31+
staleCommand: buildStaleInspectionCommand(flags),
32+
}),
33+
);
2134
return true;
2235
};
2336

37+
function isStaleClaim(claim: InspectedDeviceClaim): boolean {
38+
return (
39+
claim.classification === 'owner-process-dead' || claim.classification === 'owner-state-dir-gone'
40+
);
41+
}
42+
2443
function serializeClaim(entry: InspectedDeviceClaim): Record<string, unknown> {
2544
const claim = entry.claim;
2645
return {
2746
...(entry.deviceKey ? { deviceKey: entry.deviceKey } : {}),
47+
...(!claim ? { fileName: entry.fileName } : {}),
2848
classification: entry.classification,
2949
...(claim
3050
? {
@@ -36,37 +56,58 @@ function serializeClaim(entry: InspectedDeviceClaim): Record<string, unknown> {
3656
pid: claim.ownerPid,
3757
startTime: claim.ownerStartTime,
3858
},
39-
recovery: {
40-
command: futureRecoveryCommand(claim.device.platform, claim.device.id),
41-
},
4259
}
4360
: {}),
4461
...(entry.error ? { error: entry.error } : {}),
4562
};
4663
}
4764

48-
function futureRecoveryCommand(platform: string, id: string): string | undefined {
49-
if (platform === 'ios' || platform === 'macos') {
50-
return `agent-device device release --platform ${platform} --udid ${id} --stale`;
65+
function renderDeviceStatus(
66+
claims: Record<string, unknown>[],
67+
options: { staleOnly: boolean; hiddenStaleClaims: number; staleCommand: string },
68+
): string {
69+
const claimLines = claims.map(renderClaimLine);
70+
if (claimLines.length === 0) {
71+
if (options.staleOnly) return 'No stale local advisory device claims found.';
72+
if (options.hiddenStaleClaims === 0) return 'No local advisory device claims found.';
5173
}
52-
if (platform === 'android') {
53-
return `agent-device device release --platform android --serial ${id} --stale`;
54-
}
55-
return undefined;
74+
return [
75+
...claimLines,
76+
!options.staleOnly && options.hiddenStaleClaims > 0
77+
? `${options.hiddenStaleClaims} stale ${options.hiddenStaleClaims === 1 ? 'claim' : 'claims'} hidden; inspect with: ${options.staleCommand}`
78+
: null,
79+
]
80+
.filter((line): line is string => Boolean(line))
81+
.join('\n');
5682
}
5783

58-
function renderDeviceStatus(claims: Record<string, unknown>[]): string {
59-
if (claims.length === 0) return 'No local advisory device claims found.';
60-
return claims
61-
.map((claim) => {
62-
const device = claim.device as { platform?: string; id?: string; name?: string } | undefined;
63-
const owner = claim.owner as { session?: string; workspace?: string } | undefined;
64-
return [
65-
`${device?.platform ?? 'unknown'} ${device?.name ?? device?.id ?? claim.deviceKey ?? 'claim'}: ${claim.classification}`,
66-
owner ? `session=${owner.session} workspace=${owner.workspace}` : null,
67-
]
68-
.filter((part): part is string => Boolean(part))
69-
.join(' ');
70-
})
71-
.join('\n');
84+
function renderClaimLine(claim: Record<string, unknown>): string {
85+
return `${renderClaimLabel(claim)}: ${claim.classification}${renderClaimOwner(claim.owner)}`;
86+
}
87+
88+
function renderClaimLabel(claim: Record<string, unknown>): string {
89+
const device = claim.device as { platform?: string; id?: string; name?: string } | undefined;
90+
if (!device) return String(claim.deviceKey ?? claim.fileName ?? 'claim');
91+
return `${device.platform ?? 'unknown'} ${device.name ?? device.id ?? 'claim'}`;
92+
}
93+
94+
function renderClaimOwner(value: unknown): string {
95+
if (!value || typeof value !== 'object') return '';
96+
const owner = value as { session?: string; workspace?: string };
97+
return ` session=${owner.session} workspace=${owner.workspace}`;
98+
}
99+
100+
function buildStaleInspectionCommand(flags: {
101+
platform?: string;
102+
device?: string;
103+
udid?: string;
104+
serial?: string;
105+
}): string {
106+
const selectors = [
107+
flags.platform ? `--platform ${flags.platform}` : null,
108+
flags.device ? `--device ${flags.device}` : null,
109+
flags.udid ? `--udid ${flags.udid}` : null,
110+
flags.serial ? `--serial ${flags.serial}` : null,
111+
].filter((part): part is string => Boolean(part));
112+
return ['agent-device device status', ...selectors, '--stale'].join(' ');
72113
}

‎src/commands/cli-grammar/flag-definitions-target.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
4141
usageLabel: '--serial <serial>',
4242
usageDescription: 'Android device serial',
4343
},
44+
{
45+
key: 'stale',
46+
names: ['--stale'],
47+
type: 'boolean',
48+
usageLabel: '--stale',
49+
usageDescription: 'Device status: show only claims with a provably stale owner',
50+
},
4451
{
4552
key: 'surface',
4653
names: ['--surface'],

‎src/contracts/cli-flags.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export type CliFlags = CloudProviderProfileFields &
5252
device?: string;
5353
udid?: string;
5454
serial?: string;
55+
stale?: boolean;
5556
iosSimulatorDeviceSet?: string;
5657
iosXctestrunFile?: string;
5758
iosXctestDerivedDataPath?: string;

‎test/skillgym/suites/agent-device-smoke-suite.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1108,10 +1108,10 @@ const SKILL_GUIDANCE_CASES: Case[] = [
11081108
contract: [
11091109
'Another worktree may have an advisory claim on Android emulator-5554',
11101110
'Need to inspect local ownership without starting or contacting any daemon',
1111-
'Stage 1 is observational only: device release and --stale are not available yet',
1111+
'Claims are observational only: device release is unavailable; device status --stale only inspects proven-stale records and does not clean them',
11121112
],
11131113
task: 'Plan the one command that inspects this Android device claim directly. Do not use ps, daemon commands, or an unavailable release command.',
1114-
outputs: [plannedCommand('device status')],
1114+
outputs: [plannedCommand('device status --platform android --serial emulator-5554')],
11151115
forbiddenOutputs: [/\bdaemon\b/i, /\bdevice\s+release\b/i, /\bps\b/i],
11161116
strictFinalOutput: true,
11171117
}),

‎website/docs/docs/commands.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ agent-device app-switcher
5959
- `shutdown` turns off the selected Apple simulator or Android emulator.
6060
- `shutdown` must not target an active session device; use `close --shutdown` to end the session and turn it off.
6161
- `daemon stop --state-dir <path>` 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.
62-
- `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.
62+
- `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.
6363
- `--platform apple` is an alias for the Apple automation backend (`ios`, `tvOS`, `macOS` selection).
6464
- Use `--target mobile|tv|desktop` with `--platform` (required) to select phone/tablet vs TV-class vs desktop-class targets.
6565
- `boot` is mainly needed when starting a new session and `open` fails because no booted simulator/emulator is available.

0 commit comments

Comments
 (0)