Skip to content

Commit 2d685ff

Browse files
committed
feat: add stale device claim release and dead-end recovery guidance
Close the #1320 recovery loop for claims no daemon can settle on its own: - agent-device device release --stale settles a provably dead owner's durable resources through the same exact-owner reconciliation open and daemon startup use, then clears the claim last — daemonlessly, composing a local-only platform gateway in the CLI process. Live, uncertain, PID-reused, and corrupt claims always fail closed and are reported with the reason. - DEVICE_IN_USE conflicts whose recorded owner provably cannot release (dead or superseded) now carry the exact release command as their recovery instead of a status inspection that dead-ended. - device status --stale now offers the matching release command when provably dead owners are listed. - daemon stop now warns in text output when a claim was orphaned (previously visible only via --json) and names the status/release commands. Part of #1320.
1 parent adf5080 commit 2d685ff

10 files changed

Lines changed: 533 additions & 26 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import assert from 'node:assert/strict';
2+
import crypto from 'node:crypto';
3+
import fs from 'node:fs';
4+
import path from 'node:path';
5+
import { test, vi } from 'vitest';
6+
import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process';
7+
import { runCliCapture } from './cli-capture.ts';
8+
import { mkdtempForTestSync } from './test-utils/tmp-dir.ts';
9+
10+
vi.mock('@agent-device/host-kit/process', async (importOriginal) =>
11+
(await import('./test-utils/host-process-mock.ts')).pinOwnProcessStartTime(importOriginal),
12+
);
13+
14+
function hashedClaimPath(claimsDir: string, deviceKey: string): string {
15+
const hash = crypto.createHash('sha256').update(deviceKey).digest('hex');
16+
return path.join(claimsDir, `${hash}.json`);
17+
}
18+
19+
function writeClaim(
20+
claimsDir: string,
21+
params: {
22+
deviceKey: string;
23+
id: string;
24+
name: string;
25+
session: string;
26+
ownerPid: number;
27+
ownerStartTime: string | null;
28+
stateDir: string;
29+
},
30+
): void {
31+
fs.writeFileSync(
32+
hashedClaimPath(claimsDir, params.deviceKey),
33+
JSON.stringify({
34+
schemaVersion: 1,
35+
deviceKey: params.deviceKey,
36+
device: { platform: 'android', id: params.id, name: params.name, kind: 'emulator' },
37+
session: params.session,
38+
workspace: '/worktrees/release-test',
39+
stateDir: params.stateDir,
40+
ownerPid: params.ownerPid,
41+
ownerStartTime: params.ownerStartTime,
42+
ownerToken: `${params.session}-token`,
43+
createdAtMs: 1,
44+
updatedAtMs: 1,
45+
}),
46+
);
47+
}
48+
49+
test('device release requires --stale and explains how live owners are released', async () => {
50+
const result = await runCliCapture(['device', 'release', '--json']);
51+
assert.equal(result.calls.length, 0);
52+
const payload = JSON.parse(result.stdout || result.stderr);
53+
assert.equal(payload.success, false);
54+
assert.equal(payload.error.code, 'INVALID_ARGS');
55+
assert.match(payload.error.message, /pass --stale to confirm/);
56+
});
57+
58+
test('device release --stale settles a provably dead owner daemonlessly and refuses a live one', async () => {
59+
const claimsDir = mkdtempForTestSync('agent-device-cli-release-');
60+
const stateDir = mkdtempForTestSync('agent-device-cli-release-state-');
61+
const owner = readCurrentOwnerIdentity();
62+
try {
63+
writeClaim(claimsDir, {
64+
deviceKey: 'local:android:none:emulator-5554',
65+
id: 'emulator-5554',
66+
name: 'Dead Pixel',
67+
session: 'dead-session',
68+
ownerPid: 999_999_999,
69+
ownerStartTime: 'old-start-time',
70+
stateDir,
71+
});
72+
writeClaim(claimsDir, {
73+
deviceKey: 'local:android:none:emulator-5556',
74+
id: 'emulator-5556',
75+
name: 'Live Pixel',
76+
session: 'live-session',
77+
ownerPid: owner.pid,
78+
ownerStartTime: owner.startTime,
79+
stateDir: process.cwd(),
80+
});
81+
82+
const result = await runCliCapture(['device', 'release', '--stale', '--json'], {
83+
env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir },
84+
});
85+
assert.equal(result.calls.length, 0);
86+
const payload = JSON.parse(result.stdout);
87+
assert.equal(payload.success, true);
88+
assert.equal(payload.data.released.length, 1);
89+
assert.equal(payload.data.released[0].session, 'dead-session');
90+
assert.equal(payload.data.refused.length, 1);
91+
assert.equal(payload.data.refused[0].session, 'live-session');
92+
assert.equal(payload.data.refused[0].reason, 'live-owner');
93+
assert.equal(
94+
fs.existsSync(hashedClaimPath(claimsDir, 'local:android:none:emulator-5554')),
95+
false,
96+
);
97+
assert.equal(
98+
fs.existsSync(hashedClaimPath(claimsDir, 'local:android:none:emulator-5556')),
99+
true,
100+
);
101+
} finally {
102+
fs.rmSync(claimsDir, { recursive: true, force: true });
103+
fs.rmSync(stateDir, { recursive: true, force: true });
104+
}
105+
});
106+
107+
test('device status --stale offers the exact release command for provably dead owners', async () => {
108+
const claimsDir = mkdtempForTestSync('agent-device-cli-release-');
109+
try {
110+
writeClaim(claimsDir, {
111+
deviceKey: 'local:android:none:emulator-5554',
112+
id: 'emulator-5554',
113+
name: 'Dead Pixel',
114+
session: 'dead-session',
115+
ownerPid: 999_999_999,
116+
ownerStartTime: 'old-start-time',
117+
stateDir: process.cwd(),
118+
});
119+
120+
const result = await runCliCapture(['device', 'status', '--stale'], {
121+
env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir },
122+
});
123+
assert.match(
124+
result.stdout,
125+
/Release provably dead owners with: agent-device device release --stale/,
126+
);
127+
} finally {
128+
fs.rmSync(claimsDir, { recursive: true, force: true });
129+
}
130+
});

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,14 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = {
4646
},
4747
device: {
4848
text: {
49-
summary: 'Inspect enforced local device ownership without daemon side effects',
49+
summary: 'Inspect and recover enforced local device ownership without a daemon',
5050
description:
51-
'Inspect enforced host-local device ownership claims without starting or contacting a daemon. --stale only inspects proven-stale claims; automatic reclamation occurs during open or daemon startup after exact-owner resource reconciliation.',
51+
'Inspect enforced host-local device ownership claims without starting or contacting a daemon; status --stale only inspects proven-stale claims. release --stale settles a provably dead owner through exact-owner resource reconciliation and clears its claim last — live and uncertain owners always fail closed. Automatic reclamation still occurs during open and daemon startup.',
5252
},
5353
usageOverride:
54-
'device status [--platform <platform>] [--udid <udid>] [--serial <serial>] [--stale]',
54+
'device status|release [--platform <platform>] [--udid <udid>] [--serial <serial>] [--stale]',
5555
listUsageOverride: 'device status',
56-
positionalArgs: ['status'],
56+
positionalArgs: ['status|release'],
5757
allowedFlags: ['stale'],
5858
supportedFlags: ['platform', 'device', 'udid', 'serial'],
5959
},

‎src/cli/commands/daemon.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,11 @@ function mergeShutdownReport(
4646
claimsReleased: report.claims.released,
4747
claimsOrphaned: report.claims.orphaned,
4848
claimsSuperseded: report.claims.superseded,
49-
warnings: [...stopped.warnings, ...supersededClaimWarnings(report.claims.superseded)],
49+
warnings: [
50+
...stopped.warnings,
51+
...supersededClaimWarnings(report.claims.superseded),
52+
...orphanedClaimWarnings(report.claims.orphaned),
53+
],
5054
}
5155
: stopped;
5256
}
@@ -71,6 +75,17 @@ function supersededClaimWarnings(superseded: DaemonStopResult['claimsSuperseded'
7175
];
7276
}
7377

78+
/** An orphaned claim keeps holding its device after the daemon is gone, and
79+
* only `device release --stale` or the next open settles it — say so instead
80+
* of leaving the block discoverable through --json alone. */
81+
function orphanedClaimWarnings(orphaned: DaemonStopResult['claimsOrphaned']): string[] {
82+
if (orphaned.length === 0) return [];
83+
const devices = orphaned.map((claim) => claim.deviceId).join(', ');
84+
return [
85+
`Ownership of ${devices} was not released cleanly; the claim now blocks other owners until it is settled. Inspect with: agent-device device status --stale, then release with: agent-device device release --stale.`,
86+
];
87+
}
88+
7489
function renderDaemonStop(
7590
result: Pick<DaemonStopResult, 'stopped' | 'mode' | 'warnings'> & {
7691
clean: boolean;

‎src/cli/commands/device-release.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { SessionStore } from '../../daemon/session-store.ts';
2+
import { resolveDaemonPaths } from '../../daemon/config.ts';
3+
import { createDeviceClaimReconciler } from '../../daemon/device-claim-reconciliation.ts';
4+
import { createDaemonRecoveryPlatformScope } from '../../daemon/platform-request-scope.ts';
5+
import {
6+
releaseProvenStaleDeviceClaims,
7+
type DeviceClaimStaleReleaseOutcome,
8+
} from '../../daemon/device-claims.ts';
9+
import type { DeviceClaimSelectors } from '../../daemon/device-claim-inspection.ts';
10+
import { createPlatformRuntimeGateway } from '../../platform-runtime.ts';
11+
import { createOwnedProcessRecordStore } from '@agent-device/host-kit/process';
12+
13+
/**
14+
* Daemonless `device release --stale`: composes the same local platform
15+
* gateway and reconciliation transaction the daemon uses at `open` and
16+
* startup, so resources are settled through their exact-owner recovery paths
17+
* before the claim is cleared — without requiring the (typically dead) owner
18+
* daemon, or any daemon, to be running.
19+
*/
20+
export async function runStaleDeviceClaimRelease(
21+
selectors: DeviceClaimSelectors,
22+
): Promise<DeviceClaimStaleReleaseOutcome[]> {
23+
const daemonPaths = resolveDaemonPaths(process.env.AGENT_DEVICE_STATE_DIR);
24+
const sessionStore = new SessionStore(daemonPaths.sessionsDir);
25+
const ownedProcesses = createOwnedProcessRecordStore({
26+
stateDir: daemonPaths.baseDir,
27+
sessionsDir: daemonPaths.sessionsDir,
28+
resolveSessionDir: (sessionId) => sessionStore.resolveSessionDir(sessionId),
29+
});
30+
// Local-only gateway: claims exist only for local devices, so provider
31+
// runtimes stay out of the composition entirely.
32+
const gateway = createPlatformRuntimeGateway({
33+
sessionsDir: daemonPaths.sessionsDir,
34+
ownedProcesses,
35+
resolveSessionArtifacts: (sessionId) => ({
36+
outputPath: sessionStore.resolveAppLogPath(sessionId),
37+
pidPath: sessionStore.resolveAppLogPidPath(sessionId),
38+
}),
39+
});
40+
try {
41+
return await releaseProvenStaleDeviceClaims({
42+
selectors,
43+
reconcile: createDeviceClaimReconciler({
44+
gateway,
45+
scope: createDaemonRecoveryPlatformScope(),
46+
}),
47+
});
48+
} finally {
49+
await gateway.shutdown();
50+
}
51+
}

0 commit comments

Comments
 (0)