Skip to content

Commit 877e68f

Browse files
authored
fix(cli): compact stale device status (#1388)
* fix(cli): compact stale device status * fix(cli): quote stale status selectors
1 parent 11e0a1f commit 877e68f

8 files changed

Lines changed: 210 additions & 34 deletions

File tree

scripts/integration-progress-model.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ function summarizeProviderScenarioFlagExclusions() {
318318
'stepsFile',
319319
'proxyHost',
320320
'proxyPort',
321+
'stale',
321322
],
322323
},
323324
{
Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,134 @@
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: {
43+
platform: 'android',
44+
id: 'stale',
45+
name: 'Stale Pixel; echo no',
46+
kind: 'emulator',
47+
},
48+
session: 'stale-session',
49+
workspace: '/worktrees/stale',
50+
stateDir: process.cwd(),
51+
ownerPid: 999_999_999,
52+
ownerStartTime: 'old-start-time',
53+
ownerToken: 'stale-token',
54+
createdAtMs: 1,
55+
updatedAtMs: 1,
56+
}),
57+
);
58+
59+
const normal = await runCliCapture(['device', 'status'], {
60+
env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir },
61+
});
62+
assert.equal(normal.code, null);
63+
assert.equal(normal.calls.length, 0);
64+
assert.match(normal.stdout, /Live Pixel: live/);
65+
assert.match(
66+
normal.stdout,
67+
/1 stale claim hidden; inspect with: agent-device device status --stale/,
68+
);
69+
assert.doesNotMatch(normal.stdout, /Stale Pixel/);
70+
71+
const stale = await runCliCapture(['device', 'status', '--stale', '--json'], {
72+
env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir },
73+
});
74+
assert.equal(stale.code, null);
75+
assert.equal(stale.calls.length, 0);
76+
const payload = JSON.parse(stale.stdout);
77+
assert.equal(payload.data.claims.length, 1);
78+
assert.equal(payload.data.claims[0].device.name, 'Stale Pixel; echo no');
79+
assert.equal(payload.data.claims[0].recovery, undefined);
80+
81+
const scoped = await runCliCapture(['device', 'status', '--device', 'Stale Pixel; echo no'], {
82+
env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir },
83+
});
84+
assert.match(
85+
scoped.stdout,
86+
/inspect with: agent-device device status --device 'Stale Pixel; echo no' --stale/,
87+
);
88+
} finally {
89+
fs.rmSync(claimsDir, { recursive: true, force: true });
90+
}
91+
});
92+
93+
test('keeps corrupt and state-dir-gone claims visible in normal status', async () => {
94+
const claimsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cli-claims-'));
95+
const owner = readCurrentOwnerIdentity();
96+
try {
97+
fs.writeFileSync(path.join(claimsDir, 'corrupt.json'), '{bad json');
98+
fs.writeFileSync(
99+
path.join(claimsDir, 'state-dir-gone.json'),
100+
JSON.stringify({
101+
schemaVersion: 1,
102+
deviceKey: 'local:android:none:state-dir-gone',
103+
device: {
104+
platform: 'android',
105+
id: 'state-dir-gone',
106+
name: 'State-dir-gone Pixel',
107+
kind: 'emulator',
108+
},
109+
session: 'state-dir-gone-session',
110+
workspace: '/worktrees/state-dir-gone',
111+
stateDir: path.join(claimsDir, 'missing-state-dir'),
112+
ownerPid: owner.pid,
113+
ownerStartTime: owner.startTime,
114+
ownerToken: 'state-dir-gone-token',
115+
createdAtMs: 1,
116+
updatedAtMs: 1,
117+
}),
118+
);
119+
120+
const normal = await runCliCapture(['device', 'status'], {
121+
env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir },
122+
});
123+
assert.match(normal.stdout, /corrupt.json: inconsistent/);
124+
assert.doesNotMatch(normal.stdout, /State-dir-gone Pixel/);
125+
assert.match(normal.stdout, /1 stale claim hidden/);
126+
127+
const stale = await runCliCapture(['device', 'status', '--stale'], {
128+
env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir },
129+
});
130+
assert.match(stale.stdout, /State-dir-gone Pixel: owner-state-dir-gone/);
131+
} finally {
132+
fs.rmSync(claimsDir, { recursive: true, force: true });
133+
}
11134
});

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: 70 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,49 @@ import {
33
inspectDeviceClaims,
44
type InspectedDeviceClaim,
55
} from '../../daemon/device-claim-inspection.ts';
6+
import { shellQuoteIfNeeded } from '../../utils/shell-quote.ts';
67
import { writeCommandOutput } from './shared.ts';
78
import type { ClientCommandHandler } from './router-types.ts';
89

910
export const deviceCommand: ClientCommandHandler = async ({ positionals, flags }) => {
1011
if (positionals[0] !== 'status' || positionals.length !== 1) {
1112
throw new AppError('INVALID_ARGS', 'device accepts only: status');
1213
}
13-
const claims = inspectDeviceClaims({
14+
const inspectedClaims = inspectDeviceClaims({
1415
platform: flags.platform,
1516
device: flags.device,
1617
udid: flags.udid,
1718
serial: flags.serial,
18-
}).map(serializeClaim);
19-
const data = { claims };
20-
writeCommandOutput(flags, data, () => renderDeviceStatus(claims));
19+
});
20+
const staleClaims = inspectedClaims.filter(isStaleClaim);
21+
const claims = (
22+
flags.stale ? staleClaims : inspectedClaims.filter((claim) => !isStaleClaim(claim))
23+
).map(serializeClaim);
24+
const data = {
25+
claims,
26+
...(flags.stale ? {} : { hiddenStaleClaims: staleClaims.length }),
27+
};
28+
writeCommandOutput(flags, data, () =>
29+
renderDeviceStatus(claims, {
30+
staleOnly: flags.stale === true,
31+
hiddenStaleClaims: staleClaims.length,
32+
staleCommand: buildStaleInspectionCommand(flags),
33+
}),
34+
);
2135
return true;
2236
};
2337

38+
function isStaleClaim(claim: InspectedDeviceClaim): boolean {
39+
return (
40+
claim.classification === 'owner-process-dead' || claim.classification === 'owner-state-dir-gone'
41+
);
42+
}
43+
2444
function serializeClaim(entry: InspectedDeviceClaim): Record<string, unknown> {
2545
const claim = entry.claim;
2646
return {
2747
...(entry.deviceKey ? { deviceKey: entry.deviceKey } : {}),
48+
...(!claim ? { fileName: entry.fileName } : {}),
2849
classification: entry.classification,
2950
...(claim
3051
? {
@@ -36,37 +57,58 @@ function serializeClaim(entry: InspectedDeviceClaim): Record<string, unknown> {
3657
pid: claim.ownerPid,
3758
startTime: claim.ownerStartTime,
3859
},
39-
recovery: {
40-
command: futureRecoveryCommand(claim.device.platform, claim.device.id),
41-
},
4260
}
4361
: {}),
4462
...(entry.error ? { error: entry.error } : {}),
4563
};
4664
}
4765

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

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');
85+
function renderClaimLine(claim: Record<string, unknown>): string {
86+
return `${renderClaimLabel(claim)}: ${claim.classification}${renderClaimOwner(claim.owner)}`;
87+
}
88+
89+
function renderClaimLabel(claim: Record<string, unknown>): string {
90+
const device = claim.device as { platform?: string; id?: string; name?: string } | undefined;
91+
if (!device) return String(claim.deviceKey ?? claim.fileName ?? 'claim');
92+
return `${device.platform ?? 'unknown'} ${device.name ?? device.id ?? 'claim'}`;
93+
}
94+
95+
function renderClaimOwner(value: unknown): string {
96+
if (!value || typeof value !== 'object') return '';
97+
const owner = value as { session?: string; workspace?: string };
98+
return ` session=${owner.session} workspace=${owner.workspace}`;
99+
}
100+
101+
function buildStaleInspectionCommand(flags: {
102+
platform?: string;
103+
device?: string;
104+
udid?: string;
105+
serial?: string;
106+
}): string {
107+
const selectors = [
108+
flags.platform ? `--platform ${shellQuoteIfNeeded(flags.platform)}` : null,
109+
flags.device ? `--device ${shellQuoteIfNeeded(flags.device)}` : null,
110+
flags.udid ? `--udid ${shellQuoteIfNeeded(flags.udid)}` : null,
111+
flags.serial ? `--serial ${shellQuoteIfNeeded(flags.serial)}` : null,
112+
].filter((part): part is string => Boolean(part));
113+
return ['agent-device device status', ...selectors, '--stale'].join(' ');
72114
}

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)