Skip to content

Commit b1ee2a7

Browse files
authored
fix(daemon): reconcile device claims held by a replaced daemon (#2057)
A daemon that is replaced but keeps running keeps the device claim it took. Its sessions are only reachable through the `daemon.json` its state dir publishes, which now names the successor, so `DEVICE_IN_USE` named a session `session list` could not report and no `close` could release. Claim classification now proves that case: a live owner that is not the daemon published for its own state dir is `owner-daemon-superseded`, and reconciliation settles it exactly like a dead owner — after the same exact-owner durable-resource recovery. Proof runs one way only, on `ownerIdentityDiffers`: the mirror of `ownerIdentityMatches`, which treats equal pids with an unreadable start time as unproven rather than different. An absent or unreadable registration keeps the claim blocking, and the process reading one never supersedes itself: a caller had to reach it to ask. The unattended daemon-startup sweep settles a still running owner only inside the state dir it now serves; a superseded owner elsewhere is left to the acquisition path, at the one device a caller actually asked for. `daemon-registration.ts` owns reading the published identity and returns the canonical `OwnerIdentity`, so `daemon-stop.ts` drops its private copy of that parser rather than a third one being added. Closes #2031
1 parent 9f16fc8 commit b1ee2a7

13 files changed

Lines changed: 527 additions & 65 deletions

src/__tests__/cli-device-status.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path from 'node:path';
44
import { test, vi } from 'vitest';
55
import { readCurrentOwnerIdentity } from '../utils/owner-identity.ts';
66
import { runCliCapture } from './cli-capture.ts';
7+
import { publishDaemonRegistration } from './test-utils/device-claim-store.ts';
78
import { mkdtempForTestSync } from './test-utils/tmp-dir.ts';
89

910
vi.mock('../utils/host-process.ts', async (importOriginal) =>
@@ -193,3 +194,50 @@ test('states that nothing is claimed when every claim is stale', async () => {
193194
fs.rmSync(claimsDir, { recursive: true, force: true });
194195
}
195196
});
197+
198+
test('names a replaced-but-running daemon owner as stale rather than a live holder', async () => {
199+
const claimsDir = mkdtempForTestSync('agent-device-cli-claims-');
200+
const stateDir = path.join(claimsDir, 'state');
201+
try {
202+
// #2031: the recorded owner still runs, but we are the daemon published for
203+
// its state dir, so its session is one `session list` cannot report.
204+
publishDaemonRegistration(stateDir, readCurrentOwnerIdentity());
205+
fs.writeFileSync(
206+
path.join(claimsDir, 'superseded.json'),
207+
JSON.stringify({
208+
schemaVersion: 1,
209+
deviceKey: 'local:web:none:agent-browser-chrome',
210+
device: {
211+
platform: 'web',
212+
id: 'agent-browser-chrome',
213+
name: 'Chrome',
214+
kind: 'device',
215+
},
216+
session: 'cwd:/w:default',
217+
workspace: '/w',
218+
stateDir,
219+
ownerPid: process.ppid,
220+
ownerStartTime: null,
221+
ownerToken: 'superseded-token',
222+
createdAtMs: 1,
223+
updatedAtMs: 1,
224+
}),
225+
);
226+
227+
const normal = await runCliCapture(['device', 'status'], {
228+
env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir },
229+
});
230+
assert.match(normal.stdout, /No live local device claims found\./);
231+
assert.match(normal.stdout, /1 stale claim hidden/);
232+
233+
const stale = await runCliCapture(['device', 'status', '--stale', '--json'], {
234+
env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir },
235+
});
236+
const payload = JSON.parse(stale.stdout);
237+
assert.equal(payload.data.claims.length, 1);
238+
assert.equal(payload.data.claims[0].classification, 'owner-daemon-superseded');
239+
assert.equal(payload.data.claims[0].owner.session, 'cwd:/w:default');
240+
} finally {
241+
fs.rmSync(claimsDir, { recursive: true, force: true });
242+
}
243+
});

src/__tests__/test-utils/device-claim-store.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path from 'node:path';
33
import { afterEach } from 'vitest';
44
import { mkdtempForTestSync } from './tmp-dir.ts';
55
import type { DeviceClaimReconciler } from '../../daemon/device-claims.ts';
6+
import type { OwnerIdentity } from '../../utils/owner-identity.ts';
67

78
export type IsolatedDeviceClaimStore = {
89
/** Temporary root holding both the claim store and the daemon state dir. */
@@ -37,3 +38,23 @@ export const retainOrphanedDeviceClaims: DeviceClaimReconciler = async () => ({
3738
status: 'retained',
3839
reason: 'test-live-owner',
3940
});
41+
42+
/**
43+
* Publishes the daemon registration a running daemon writes for its state dir.
44+
* Claim ownership is only reachable through the daemon named there, so tests
45+
* that exercise reachability have to stand one in.
46+
*/
47+
export function publishDaemonRegistration(stateDir: string, owner: OwnerIdentity): void {
48+
fs.mkdirSync(stateDir, { recursive: true });
49+
fs.writeFileSync(
50+
path.join(stateDir, 'daemon.json'),
51+
JSON.stringify({
52+
port: 1,
53+
transport: 'socket',
54+
token: 'test-token',
55+
pid: owner.pid,
56+
...(owner.startTime === null ? {} : { processStartTime: owner.startTime }),
57+
stateDir,
58+
}),
59+
);
60+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import assert from 'node:assert/strict';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
import { afterEach, test } from 'vitest';
5+
import { isSupersededDaemonOwner, readRegisteredDaemonIdentity } from '../daemon-registration.ts';
6+
import { writeInfo } from '../server/server-lifecycle.ts';
7+
import { publishDaemonRegistration } from '../../__tests__/test-utils/device-claim-store.ts';
8+
import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts';
9+
10+
const roots: string[] = [];
11+
12+
afterEach(() => {
13+
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
14+
});
15+
16+
function useStateDir(): string {
17+
const root = mkdtempForTestSync('agent-device-daemon-registration-');
18+
roots.push(root);
19+
return root;
20+
}
21+
22+
function infoPathOf(stateDir: string): string {
23+
return path.join(stateDir, 'daemon.json');
24+
}
25+
26+
test('reads back the identity a running daemon publishes for its state dir', () => {
27+
const stateDir = useStateDir();
28+
writeInfo(stateDir, infoPathOf(stateDir), path.join(stateDir, 'daemon.log'), {
29+
socketPort: 1234,
30+
token: 'token',
31+
version: '0.0.0-test',
32+
codeSignature: 'signature',
33+
processStartTime: 'published-start',
34+
});
35+
36+
assert.deepEqual(readRegisteredDaemonIdentity(infoPathOf(stateDir)), {
37+
pid: process.pid,
38+
startTime: 'published-start',
39+
});
40+
});
41+
42+
test('reads no identity from an absent, corrupt, or pid-less registration', () => {
43+
const stateDir = useStateDir();
44+
assert.equal(readRegisteredDaemonIdentity(infoPathOf(stateDir)), null);
45+
46+
fs.writeFileSync(infoPathOf(stateDir), '{not json');
47+
assert.equal(readRegisteredDaemonIdentity(infoPathOf(stateDir)), null);
48+
49+
for (const pid of [0, -1, 1.5, '7', null]) {
50+
fs.writeFileSync(infoPathOf(stateDir), JSON.stringify({ pid, token: 'token' }));
51+
assert.equal(readRegisteredDaemonIdentity(infoPathOf(stateDir)), null);
52+
}
53+
});
54+
55+
test('a different published pid proves the owner no longer serves its state dir', () => {
56+
const stateDir = useStateDir();
57+
publishDaemonRegistration(stateDir, { pid: 4242, startTime: 'successor-start' });
58+
59+
assert.equal(isSupersededDaemonOwner({ stateDir, pid: 4141, startTime: 'owner-start' }), true);
60+
});
61+
62+
test('a published start time proves supersession only when both sides are readable', () => {
63+
const stateDir = useStateDir();
64+
publishDaemonRegistration(stateDir, { pid: 4242, startTime: 'successor-start' });
65+
assert.equal(isSupersededDaemonOwner({ stateDir, pid: 4242, startTime: 'owner-start' }), true);
66+
// An unreadable start time on either side leaves the identity unproven.
67+
assert.equal(isSupersededDaemonOwner({ stateDir, pid: 4242, startTime: null }), false);
68+
publishDaemonRegistration(stateDir, { pid: 4242, startTime: null });
69+
assert.equal(isSupersededDaemonOwner({ stateDir, pid: 4242, startTime: 'owner-start' }), false);
70+
});
71+
72+
test('the published owner itself is never superseded, and absence is not proof', () => {
73+
const stateDir = useStateDir();
74+
publishDaemonRegistration(stateDir, { pid: 4242, startTime: 'owner-start' });
75+
assert.equal(isSupersededDaemonOwner({ stateDir, pid: 4242, startTime: 'owner-start' }), false);
76+
77+
fs.rmSync(infoPathOf(stateDir));
78+
assert.equal(isSupersededDaemonOwner({ stateDir, pid: 4242, startTime: 'owner-start' }), false);
79+
});
80+
81+
test('the reading process is never superseded by a registration naming someone else', () => {
82+
const stateDir = useStateDir();
83+
publishDaemonRegistration(stateDir, { pid: 4242, startTime: 'successor-start' });
84+
85+
assert.equal(isSupersededDaemonOwner({ stateDir, pid: process.pid, startTime: 'ours' }), false);
86+
});

src/daemon/__tests__/device-claim-prune.test.ts

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { reconcileOrphanedDeviceClaims } from '../device-claims.ts';
77
import { resolveDeviceClaimPath } from '../device-claim-paths.ts';
88
import { acquireProcessLock } from '../../utils/process-lock.ts';
99
import { readCurrentOwnerIdentity } from '../../utils/owner-identity.ts';
10+
import { publishDaemonRegistration } from '../../__tests__/test-utils/device-claim-store.ts';
1011
import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts';
1112

1213
vi.mock('../../utils/host-process.ts', async (importOriginal) =>
@@ -63,7 +64,10 @@ test('reconciles claims whose owner is gone and keeps every other claim', async
6364
});
6465
fs.writeFileSync(path.join(claimsDir, 'garbage.json'), 'not json');
6566

66-
const summary = await reconcileOrphanedDeviceClaims(async () => ({ status: 'reconciled' }));
67+
const summary = await reconcileOrphanedDeviceClaims(
68+
async () => ({ status: 'reconciled' }),
69+
process.cwd(),
70+
);
6771

6872
const claimPathFor = (name: string) => resolveDeviceClaimPath(`local:android:none:${name}`);
6973
assert.deepEqual(summary, { examined: 1, reconciled: 1, retained: 0, changed: 0 });
@@ -81,12 +85,15 @@ test('reconciles nothing when the claim store does not exist', async () => {
8185
os.tmpdir(),
8286
'agent-device-reconciliation-absent-store',
8387
);
84-
assert.deepEqual(await reconcileOrphanedDeviceClaims(async () => ({ status: 'reconciled' })), {
85-
examined: 0,
86-
reconciled: 0,
87-
retained: 0,
88-
changed: 0,
89-
});
88+
assert.deepEqual(
89+
await reconcileOrphanedDeviceClaims(async () => ({ status: 'reconciled' }), process.cwd()),
90+
{
91+
examined: 0,
92+
reconciled: 0,
93+
retained: 0,
94+
changed: 0,
95+
},
96+
);
9097
});
9198

9299
test('leaves a live successor written while the reconciliation waited for the claim lock', async () => {
@@ -128,7 +135,10 @@ test('leaves a live successor written while the reconciliation waited for the cl
128135
description: 'test-held device claim lock',
129136
});
130137

131-
const reconciliation = reconcileOrphanedDeviceClaims(async () => ({ status: 'reconciled' }));
138+
const reconciliation = reconcileOrphanedDeviceClaims(
139+
async () => ({ status: 'reconciled' }),
140+
process.cwd(),
141+
);
132142
// The reconciliation is now blocked on the lock; stand in the live successor.
133143
writeContested(owner.pid, owner.startTime, 'live-token');
134144
await release();
@@ -145,3 +155,61 @@ test('leaves a live successor written while the reconciliation waited for the cl
145155
fs.rmSync(claimsDir, { recursive: true, force: true });
146156
}
147157
});
158+
159+
test('reconciles a claim whose owning daemon was replaced while still running', async () => {
160+
const claimsDir = mkdtempForTestSync('agent-device-reconciliation-superseded-');
161+
process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir;
162+
const stateDir = path.join(claimsDir, 'state');
163+
try {
164+
// #2031: the daemon-startup scan a replaced daemon leaves behind. Our own
165+
// registration serves the state dir, so the still-running owner recorded in
166+
// the claim can never be asked to release it.
167+
publishDaemonRegistration(stateDir, readCurrentOwnerIdentity());
168+
writeClaim(claimsDir, 'superseded.json', {
169+
ownerPid: process.ppid,
170+
ownerStartTime: null,
171+
stateDir,
172+
});
173+
174+
const summary = await reconcileOrphanedDeviceClaims(
175+
async () => ({ status: 'reconciled' }),
176+
stateDir,
177+
);
178+
179+
assert.deepEqual(summary, { examined: 1, reconciled: 1, retained: 0, changed: 0 });
180+
assert.equal(
181+
fs.existsSync(resolveDeviceClaimPath('local:android:none:superseded.json')),
182+
false,
183+
);
184+
} finally {
185+
fs.rmSync(claimsDir, { recursive: true, force: true });
186+
}
187+
});
188+
189+
test('leaves a superseded owner in another state dir to the device that asks for it', async () => {
190+
const claimsDir = mkdtempForTestSync('agent-device-reconciliation-foreign-superseded-');
191+
process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir;
192+
const stateDir = path.join(claimsDir, 'other-state');
193+
try {
194+
// The owner still runs, so finalizing the durable resources attributed to it
195+
// is only the business of the daemon that now serves ITS state dir.
196+
publishDaemonRegistration(stateDir, readCurrentOwnerIdentity());
197+
writeClaim(claimsDir, 'foreign.json', {
198+
ownerPid: process.ppid,
199+
ownerStartTime: null,
200+
stateDir,
201+
});
202+
const reconcile = vi.fn(async () => ({ status: 'reconciled' as const }));
203+
204+
const summary = await reconcileOrphanedDeviceClaims(
205+
reconcile,
206+
path.join(claimsDir, 'our-state'),
207+
);
208+
209+
assert.deepEqual(summary, { examined: 0, reconciled: 0, retained: 0, changed: 0 });
210+
assert.equal(reconcile.mock.calls.length, 0);
211+
assert.equal(fs.existsSync(resolveDeviceClaimPath('local:android:none:foreign.json')), true);
212+
} finally {
213+
fs.rmSync(claimsDir, { recursive: true, force: true });
214+
}
215+
});

0 commit comments

Comments
 (0)