Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions scripts/layering/session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ export const STORE_OWNED_SESSION_STATE_FIELDS: ReadonlySet<string> = new Set([
'audioProbe',
'createdAt',
'device',
// #2833: the request path reports session activity through `SessionStore.noteSessionActivity`, so
// the only writer of this field is the store that owns the record.
'lastActivityAtMs',
'lastPerfProfile',
'name',
'recordOnlySession',
Expand Down
48 changes: 46 additions & 2 deletions src/cli/commands/__tests__/daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const GRACEFUL_RESULT: DaemonStopResult = {
claimsReleased: [],
claimsOrphaned: [],
claimsSuperseded: [],
claimsUnattributable: [],
providerReleases: { status: 'completed', released: [], pending: [] },
warnings: [],
};
Expand Down Expand Up @@ -77,7 +78,7 @@ test('merges a graceful shutdown report and cleans runner leases with the start-
released: [{ leaseId: 'lease-1', provider: 'limrun' }],
pending: [],
},
claims: { released: [claim], orphaned: [], superseded: [] },
claims: { released: [claim], orphaned: [], superseded: [], unattributable: [] },
});

try {
Expand All @@ -104,6 +105,7 @@ test('merges a graceful shutdown report and cleans runner leases with the start-
claimsReleased: [claim],
claimsOrphaned: [],
claimsSuperseded: [],
claimsUnattributable: [],
}),
expect.any(Function),
);
Expand Down Expand Up @@ -152,7 +154,7 @@ test('warns in text output when a graceful stop leaves an orphaned claim', async
};
mocks.readDaemonShutdownReport.mockReturnValue({
providerReleases: { released: [], pending: [] },
claims: { released: [], orphaned: [claim], superseded: [] },
claims: { released: [], orphaned: [claim], superseded: [], unattributable: [] },
});

try {
Expand All @@ -176,3 +178,45 @@ test('warns in text output when a graceful stop leaves an orphaned claim', async
fs.rmSync(stateDir, { recursive: true, force: true });
}
});

test('routes an unattributable claim to the default status view, never to --stale', async () => {
const stateDir = mkdtempForTestSync('agent-device-daemon-command-');
mocks.readDaemonStopIdentity.mockReturnValue({ pid: 123, processStartTime: 'start-time' });
mocks.stopDaemon.mockResolvedValue(GRACEFUL_RESULT);
const claim = {
deviceKey: 'local:android:none:emulator-5554',
session: 'default',
platform: 'android',
deviceId: 'emulator-5554',
};
mocks.readDaemonShutdownReport.mockReturnValue({
providerReleases: { released: [], pending: [] },
claims: { released: [], orphaned: [], superseded: [], unattributable: [claim] },
});

try {
await daemonCommand({
positionals: ['stop'],
flags: { clean: false, help: false, json: false, stateDir, version: false },
client: {} as never,
});

const [, data, renderHuman] = mocks.writeCommandOutput.mock.calls.at(-1) ?? [];
expect(data).toEqual(
expect.objectContaining({
claimsUnattributable: [claim],
claimsOrphaned: [],
warnings: [expect.stringContaining('whose owner could not be read')],
}),
);
const rendered = (renderHuman as () => string)();
expect(rendered).toContain('Inspect with: agent-device device status.');
// The whole reason this is its own bucket: both stale routes refuse a record with no recorded
// owner, so the warning may name them only to forbid them and must not end in a release.
expect(rendered).toContain('No device release route settles it');
expect(rendered).not.toContain('then release with');
expect(rendered).not.toContain('Inspect with: agent-device device status --stale');
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
29 changes: 28 additions & 1 deletion src/cli/commands/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,12 @@ function mergeShutdownReport(
claimsReleased: report.claims.released,
claimsOrphaned: report.claims.orphaned,
claimsSuperseded: report.claims.superseded,
claimsUnattributable: report.claims.unattributable,
warnings: [
...stopped.warnings,
...supersededClaimWarnings(report.claims.superseded),
...orphanedClaimWarnings(report.claims.orphaned),
...unattributableClaimWarnings(report.claims.unattributable),
],
}
: stopped;
Expand Down Expand Up @@ -77,7 +79,11 @@ function supersededClaimWarnings(superseded: DaemonStopResult['claimsSuperseded'

/** An orphaned claim keeps holding its device after the daemon is gone, and
* only `device release --stale` or the next open settles it — say so instead
* of leaving the block discoverable through --json alone. */
* of leaving the block discoverable through --json alone.
*
* A record whose owner could not be attributed is NOT reported here. Those are what
* `--stale` hides and refuses, so this sentence would send the operator to a route that cannot
* succeed; {@link unattributableClaimWarnings} names the view that does show them. */
function orphanedClaimWarnings(orphaned: DaemonStopResult['claimsOrphaned']): string[] {
if (orphaned.length === 0) return [];
const devices = orphaned.map((claim) => claim.deviceId).join(', ');
Expand All @@ -86,6 +92,27 @@ function orphanedClaimWarnings(orphaned: DaemonStopResult['claimsOrphaned']): st
];
}

/**
* What the three provable buckets each get an exact remedy for, this one does not: settling a claim
* always rests on a proof about its owner, and this record names none. So the warning states only
* what is known, points at the view that lists these records — the default one, because `--stale`
* filters records without a decodable owner out — and does not promise a release that would clear
* them. `device release --stale` refuses every record that names no owner, and the next `open` refuses
* to overwrite one. The one principal that can clear an allocator-held record is the allocator that
* issued it, which is named on the refusal rather than guessed at here.
*/
function unattributableClaimWarnings(
unattributable: DaemonStopResult['claimsUnattributable'],
): string[] {
if (unattributable.length === 0) return [];
const devices = unattributable.map((claim) => claim.deviceId).join(', ');
return [
`A claim for ${devices} remains whose owner could not be read, so this daemon cannot say whether those devices are free. ` +
'No device release route settles it: --stale proves staleness from the recorded owner and refuses records that name none, ' +
'and open refuses to overwrite them. Inspect with: agent-device device status.',
];
}

function renderDaemonStop(
result: Pick<DaemonStopResult, 'stopped' | 'mode' | 'warnings'> & {
clean: boolean;
Expand Down
1 change: 1 addition & 0 deletions src/commands/schema/cli-help-topics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ test('usageForCommand resolves physical-device help topic', async () => {
);
assert.match(help, /AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS/);
assert.match(help, /AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS/);
assert.match(help, /AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS/);
assert.match(
help,
/a stale iOS runner lease — its owner process dead, or its AGENT_DEVICE_STATE_DIR deleted — is reclaimed automatically/i,
Expand Down
1 change: 1 addition & 0 deletions src/commands/schema/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,7 @@ Runner and daemon lifecycle (applies to simulators too):
No runner read launches a session app that is not running: snapshot, wait, is, get, a reading find, and an interaction's leading reads (a gesture's viewport read, the capture that resolves a selector click/fill) answer the retriable APP_NOT_RUNNING instead of bare-launching over a launch SpringBoard still holds behind its deep-link confirmation. Only open, activate, and a command that mutates without a leading read bring a stopped app up.
close keeps a healthy iOS simulator XCTest runner warm by default so the next open on that simulator (same udid in the same simulator set) skips the runner build, unless --shutdown was requested, the session was recording, or the session held a device lease. A retained runner auto-stops after an idle window (default 5 minutes); set AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS to override, or 0 to disable idle stop and retain until daemon exit.
Each AGENT_DEVICE_STATE_DIR runs its own daemon. It self-exits after an idle window (default 5 minutes, matching the runner idle-stop default) once it has no open sessions, no in-flight requests, and no active recording; set AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS to override, or 0 to disable idle reap.
On a machine shared by several agents, set AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS to also expire an individual session that has taken a host-global device claim and then received no commands for that long; the claim is released and the next command on that session answers SESSION_NOT_FOUND with details.reason SESSION_IDLE_EXPIRED naming the window and the device. Off by default, because on a shared host "idle" and "thinking" are indistinguishable: only set it where an abandoned claim blocking every other agent is the worse failure. Sessions holding a remote lease or any active capture (recording, logs, audio, performance, or trace) are never expired this way.
A stale iOS runner lease — its owner process dead, or its AGENT_DEVICE_STATE_DIR deleted — is reclaimed automatically instead of failing with "is already owned by another agent-device daemon". A live owner's runner is also reclaimed when the requesting daemon holds the host-global device claim for that device: claims are exclusive, so holding one proves the runner's owner released the device and merely kept the runner warm. The error remains only for owners outside claim arbitration (a pre-claims build, or daemons pointed at different claim stores).

For iOS SpringBoard, widget, or other system-UI surfaces, read agent-device help ios-system-ui.`,
Expand Down
24 changes: 24 additions & 0 deletions src/daemon/__tests__/application-lifecycle-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,30 @@ test('daemon lifecycle finalization admits facts once, binds once, and disposes
expect(dispose).toHaveBeenCalledOnce();
});

// #2833: an idle-session expiry is the one daemon-owned teardown with no successor to hand a healthy
// execution host to. Deferring runner termination to the gateway's shutdown phase would park the
// runner — and the device behind it — until process exit, which is the opposite of why the expiry ran.
test('a daemon that stays alive finalizes without the shutdown deferral', async () => {
const finalize = vi.fn(async () => {});
const runtime = gateway({ finalize });

await finalizeDaemonSessionApplicationLifecycle({
gateway: runtime.gateway,
scope: scope(),
session,
stateDir: '/state',
runtimeHints: {},
daemonLeaving: false,
});

expect(finalize).toHaveBeenCalledWith({
appBundleId: undefined,
surface: 'app',
retainRunner: false,
stateDir: '/state',
});
});

test.each([
{
name: 'Android provider device',
Expand Down
36 changes: 33 additions & 3 deletions src/daemon/__tests__/daemon-shutdown-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ test('round-trips provider release and device claim records without lease creden
try {
writeDaemonShutdownReport(stateDir, {
providerReleases: { released: [lease], pending: [lease] },
claims: { released: [claim], orphaned: [], superseded: [claim] },
claims: { released: [claim], orphaned: [], superseded: [claim], unattributable: [] },

@cubic-dev-ai cubic-dev-ai Bot Sep 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: No test round-trips the new unattributable bucket with actual records: the write/read cases all use unattributable: [], and the non-empty values elsewhere (device-claims / daemon-shutdown-claims tests) only exercise the producer, not the report serialization. Put a claim in unattributable on one write+read pair so the new bucket's serialization (write spread + readClaimSection filter) is validated.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/daemon/__tests__/daemon-shutdown-report.test.ts, line 30:

<comment>No test round-trips the new `unattributable` bucket with actual records: the write/read cases all use `unattributable: []`, and the non-empty values elsewhere (device-claims / daemon-shutdown-claims tests) only exercise the producer, not the report serialization. Put a claim in `unattributable` on one write+read pair so the new bucket's serialization (write spread + `readClaimSection` filter) is validated.</comment>

<file context>
@@ -27,7 +27,7 @@ test('round-trips provider release and device claim records without lease creden
     writeDaemonShutdownReport(stateDir, {
       providerReleases: { released: [lease], pending: [lease] },
-      claims: { released: [claim], orphaned: [], superseded: [claim] },
+      claims: { released: [claim], orphaned: [], superseded: [claim], unattributable: [] },
     });
 
</file context>
Fix with cubic

});

expect(readDaemonShutdownReport(stateDir)).toEqual({
providerReleases: {
released: [{ leaseId: lease.leaseId, provider: 'limrun' }],
pending: [{ leaseId: lease.leaseId, provider: 'limrun' }],
},
claims: { released: [claim], orphaned: [], superseded: [claim] },
claims: { released: [claim], orphaned: [], superseded: [claim], unattributable: [] },
});
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
Expand All @@ -54,7 +54,7 @@ test('a report written before claim reporting still reads its provider releases'

expect(readDaemonShutdownReport(stateDir)).toEqual({
providerReleases: { released: [], pending: [] },
claims: { released: [], orphaned: [], superseded: [] },
claims: { released: [], orphaned: [], superseded: [], unattributable: [] },
});
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
Expand Down Expand Up @@ -82,3 +82,33 @@ test('ignores malformed shutdown reports and clear removes a prior report', () =
fs.rmSync(stateDir, { recursive: true, force: true });
}
});

test('a report written before unattributable claims were separated still reads its three buckets', () => {
const stateDir = mkdtempForTestSync('agent-device-shutdown-report-');
const reportPath = path.join(stateDir, 'daemon-shutdown.json');
const claim = {
deviceKey: 'local:android:none:emulator-5554',
session: 'default',
platform: 'android',
deviceId: 'emulator-5554',
};

try {
// The shape a previous daemon writes. `unattributable` is additive, so a reader of a report from
// before it existed must still get every bucket it can describe rather than dropping the section.
fs.writeFileSync(
reportPath,
JSON.stringify({
providerReleases: { released: [], pending: [] },
claims: { released: [claim], orphaned: [claim], superseded: [claim] },
}),
);

expect(readDaemonShutdownReport(stateDir)).toEqual({
providerReleases: { released: [], pending: [] },
claims: { released: [claim], orphaned: [claim], superseded: [claim], unattributable: [] },
});
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
2 changes: 1 addition & 1 deletion src/daemon/__tests__/filesystem-boundary-faults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ function createShutdownReportFixture(root: string): FilesystemBoundaryFixture {
run: async () =>
writeDaemonShutdownReport(root, {
providerReleases: { released: [], pending: [] },
claims: { released: [], orphaned: [], superseded: [] },
claims: { released: [], orphaned: [], superseded: [], unattributable: [] },
}),
expected: 'return',
};
Expand Down
Loading
Loading