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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
- Fix `ensureDaemon()`, the CLI and MCP killing a daemon they had just asked to leave. After the daemon answers the quiesce request with `draining`, the client now waits up to 60 s for it to exit by itself and clean up its registration, so plugin disposal and lease finalization finish (on Windows a signal ends the process outright, and on POSIX a drain longer than 3 s was killed). A daemon still running at the deadline that fails its liveness probes is force-retired; one that still answers is left running and the call fails with `details.reason: 'daemon-drain-timeout'`, with a hint naming `agent-device daemon stop --state-dir <path>`.
- Behavior change: the CLI and MCP now call `ensureDaemon()` explicitly once their config is resolved, then attach to the daemon it leaves. A command that finds a daemon from another version or build replaces it only if the daemon reports no request, session, repair session or live lease. A resumable upload still streaming and a host human-control hold that names no lease are not counted, so a replacement can end them. A daemon that answers nothing on any transport is retired after repeated failed liveness probes, which cannot show it idle. A busy one fails the command with `details.reason: 'daemon-busy'` instead of being replaced with its sessions, and a daemon from a release that predates quiescing fails it with `'daemon-incompatible'`; stop either with `agent-device daemon stop --state-dir <path>`. The takeover notice moves from `Replacing daemon … : <reason>` to `Replaced idle daemon …` (an incompatible daemon) or `Replaced unreachable daemon …` (one that answered nothing). A request that times out no longer retires an unresponsive daemon from the CLI or MCP; the error names `agent-device daemon stop --state-dir <path>`. A one-shot `replay` or `test` with an explicit `--state-dir` leaves the daemon it started running until its idle reap instead of stopping it. Remote `--daemon-base-url` connections and replay on its private ephemeral daemon are unchanged. Node.js clients keep `daemonLifecycle: 'managed'` until 0.22.
- Add `ensureDaemon({ stateDir })` and the client config `daemonLifecycle: 'attach' | 'managed'`. An `attach` client connects to the daemon registered in the state directory and never starts, replaces or kills one, including after a request times out. It fails with `details.reason: 'daemon-not-running'` (the hint points at `ensureDaemon()`) or `'daemon-incompatible'` (both identities in `details`, the hint names `agent-device daemon stop`). `ensureDaemon()` starts a missing daemon and replaces an incompatible one that answers only through an authenticated quiesce request the daemon answers itself (a resumable upload still streaming and a human-control hold that names no lease are not counted, so a replacement can end them): it refuses with `daemon-busy` while any request is in flight or any session, repair session or live lease exists, and a daemon from a release before this one is never replaced. A registered daemon that answers nothing is retired, as is one that times out on the quiesce request and then fails repeated liveness probes; neither can be asked. A remote `daemonBaseUrl` connection never starts or retires a local daemon in either mode. `managed` stays the default and behaves as before until 0.22.
- `lease_allocate` (and `client.leases.allocate()`) with a `leaseProvider` that no registered runtime claims now refuses before recording a lease, with `UNSUPPORTED_OPERATION`, `error.details.reason: provider-not-registered`, `details.provider` and `details.registeredProviders`. Previously a plugin provider that the daemon never loaded passed admission and recorded a lease with no device behind it. Leases with no provider (a `leaseBackend` alone included), `proxy` and `cloud` need no runtime and still allocate. Restart the daemon with the provider configured, then retry.
- `lease_allocate` (and `client.leases.allocate()`) with a `leaseProvider` that no registered runtime claims now refuses before recording a lease, with `UNSUPPORTED_OPERATION`, `error.details.reason: provider-not-registered`, `details.provider`, `details.registeredProviders`, and `details.failedPlugins` naming a plugin that failed to register. Previously a plugin provider that the daemon never loaded passed admission and recorded a lease with no device behind it. Leases with no provider (a `leaseBackend` alone included), `proxy` and `cloud` need no runtime and still allocate. Restart the daemon with the provider configured, then retry.
- Add `uninstall <app>` and `client.apps.uninstall({ app })` to remove an installed app on adb-backed Android targets, iOS simulators, and CoreDevice iOS devices. Both report `wasInstalled`. A package name or bundle id that is not installed succeeds with `wasInstalled: false`. Other targets, Appium and Limrun sessions included, refuse it before anything runs.
- Add `settings mobile-data on|off` for adb-backed Android targets. It switches mobile data through `svc data` and reports the `mobileData` state stored in the global `mobile_data` row; multi-SIM read-back is not covered yet. Every other target refuses it before anything runs, and `--app` is refused because the switch is device-wide.
- On iOS, a `fill` that runs under a degraded XCTest snapshot channel now aims at a point, including ref, selector and replay fills. When the runner identifies no text input under that point, it refuses with `NO_TEXT_INPUT_AT_POINT` (`error.details.reason: no_text_input_at_point`, `dispatched: no`) before dispatching its focus tap, and no field changes; when the input lookup itself cannot answer, it refuses with `TEXT_INPUT_PROBE_UNAVAILABLE` (`error.details.reason: text_input_probe_unavailable`). Previously such a fill cleared and typed through the app-level first responder, so it could rewrite a field the caller never named, or report a clear failure after emptying it. A ref or selector fill whose rect centre misses the real field (an outer container, a non-input node) now needs a point inside the input's own rect.
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,10 @@
"types": "./src/lease-scope.ts",
"default": "./src/lease-scope.ts"
},
"./plugin-inventory": {
"types": "./src/plugin-inventory.ts",
"default": "./src/plugin-inventory.ts"
},
"./local-interactor-operation-set": {
"types": "./src/local-interactor-operation-set.ts",
"default": "./src/local-interactor-operation-set.ts"
Expand Down
41 changes: 41 additions & 0 deletions packages/contracts/src/plugin-inventory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { expect, test } from 'vitest';
import { pluginCredentialVariables, readPluginInventory } from './plugin-inventory.ts';

const credentials = { variables: ['A'], byBackend: { ios: ['A', 'B'] } };

test('the reader keeps well-formed entries and drops entries that lose plugin state', () => {
const active = { provider: 'p', source: 'store', state: 'active', credentials };
const failed = {
source: 'builtin',
state: 'failed',
error: { code: 'INVALID_ARGS', message: 'bad', reason: 'invalid_plugin' },
credentials,
};
expect(
readPluginInventory([
active,
failed,
{ ...active, credentials: undefined },
{ ...active, credentials: { variables: 'A' } },
{ ...failed, error: undefined },
{ ...active, state: 'unknown' },
'not an entry',
]),
).toEqual([active, failed]);
});

test('credential variables narrow to the lease backend of an active plugin only', () => {
const inventory = readPluginInventory([
{ provider: 'p', source: 'store', state: 'active', credentials },
{
provider: 'q',
source: 'store',
state: 'failed',
error: { code: 'X', message: 'm' },
credentials,
},
]);
expect(pluginCredentialVariables(inventory, 'p', 'ios')).toEqual(['A', 'B']);
expect(pluginCredentialVariables(inventory, 'p', 'android')).toEqual(['A']);
expect(pluginCredentialVariables(inventory, 'q')).toBeUndefined();
});
125 changes: 125 additions & 0 deletions packages/contracts/src/plugin-inventory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { isRecord } from '@agent-device/kernel/record';

export type PluginSource = 'builtin' | 'store' | 'embedder';

export type PluginInventoryError = Readonly<{
code: string;
message: string;
reason?: string;
hint?: string;
}>;

export type PluginInventoryCredentials = Readonly<{
variables: readonly string[];
byBackend: Readonly<Record<string, readonly string[]>>;
}>;

type PluginInventoryIdentity = Readonly<{
provider?: string;
source: PluginSource;
package?: string;
version?: string;
credentials: PluginInventoryCredentials;
}>;

/** One plugin as the daemon sees it: published in `daemon.json` and shown by `daemon status`. */
export type PluginInventoryEntry =
| (PluginInventoryIdentity & Readonly<{ state: 'active'; error?: undefined }>)
| (PluginInventoryIdentity & Readonly<{ state: 'failed'; error: PluginInventoryError }>);

/** One step of a plugin's cleanup: it completed, or it threw this typed error. */
export type PluginCleanupStep =
| Readonly<{ status: 'ok' }>
| Readonly<{ status: 'failed'; error: PluginInventoryError }>;

/**
* What closing the registry did for one registered plugin. A plugin without `dispose` has nothing to
* dispose, which reads as `ok`.
*/
export type PluginCleanupOutcome = Readonly<{
provider: string;
source: PluginSource;
shutdown: PluginCleanupStep;
dispose: PluginCleanupStep;
}>;

export const NO_PLUGIN_CREDENTIALS: PluginInventoryCredentials = Object.freeze({
variables: Object.freeze([]),
byBackend: Object.freeze({}),
});

/**
* The credential variable names a lease on `leaseBackend` reads for `provider`: the backend's own
* list when the plugin declares one, otherwise its flat list. Undefined when no active plugin
* claims the provider.
*/
export function pluginCredentialVariables(
inventory: readonly PluginInventoryEntry[],
provider: string,
leaseBackend?: string,
): readonly string[] | undefined {
const entry = inventory.find((candidate) => candidate.provider === provider);
if (entry?.state !== 'active') return undefined;
const { variables, byBackend } = entry.credentials;
return (
(leaseBackend !== undefined && Object.hasOwn(byBackend, leaseBackend)
? byBackend[leaseBackend]
: undefined) ?? variables
);
}

/** Reads the inventory a daemon published, dropping anything that is not a well-formed entry. */
export function readPluginInventory(value: unknown): readonly PluginInventoryEntry[] {
return Array.isArray(value) ? value.flatMap((entry) => readEntry(entry) ?? []) : [];
}

function readEntry(value: unknown): PluginInventoryEntry | undefined {
if (!isRecord(value) || !isSource(value.source)) return undefined;
const credentials = readCredentials(value.credentials);
if (!credentials) return undefined;
const identity = {
...(typeof value.provider === 'string' ? { provider: value.provider } : {}),
source: value.source,
...(typeof value.package === 'string' ? { package: value.package } : {}),
...(typeof value.version === 'string' ? { version: value.version } : {}),
credentials,
};
if (value.state === 'active') return { ...identity, state: 'active' };
const error = readError(value.error);
return value.state === 'failed' && error ? { ...identity, state: 'failed', error } : undefined;
}

function readCredentials(value: unknown): PluginInventoryCredentials | undefined {
if (!isRecord(value)) return undefined;
const variables = readNames(value.variables);
const byBackend = value.byBackend === undefined ? {} : value.byBackend;
if (!variables || !isRecord(byBackend)) return undefined;
const entries = Object.entries(byBackend).map(([backend, names]) => [backend, readNames(names)]);
if (entries.some(([, names]) => names === undefined)) return undefined;
return {
variables,
byBackend: Object.fromEntries(entries) as Record<string, readonly string[]>,
};
}

function isSource(value: unknown): value is PluginSource {
return value === 'builtin' || value === 'store' || value === 'embedder';
}

function readNames(value: unknown): readonly string[] | undefined {
return Array.isArray(value) && value.every((name) => typeof name === 'string')
? value
: undefined;
}

function readError(value: unknown): PluginInventoryError | undefined {
if (!isRecord(value) || typeof value.code !== 'string' || typeof value.message !== 'string') {
return undefined;
}
return {
code: value.code,
message: value.message,
...(typeof value.reason === 'string' ? { reason: value.reason } : {}),
...(typeof value.hint === 'string' ? { hint: value.hint } : {}),
};
}
3 changes: 3 additions & 0 deletions packages/daemon-contracts/src/daemon-registration-owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type ProcessLockAcquisition,
} from '@agent-device/host-kit/file';
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
import type { PluginInventoryEntry } from '@agent-device/contracts/plugin-inventory';
import { withDaemonDiagnosticsScope } from './daemon-diagnostics-scope.ts';
import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature';
import {
Expand Down Expand Up @@ -54,6 +55,8 @@ export type DaemonRegistrationFields = Readonly<{
policyDigest?: string;
/** What this daemon can do beyond the base RPC contract; absent from daemons that predate it. */
capabilities?: readonly DaemonCapability[];
/** The provider plugin inventory, so a client can read what this daemon registered. */
plugins?: readonly PluginInventoryEntry[];
}>;
type DaemonRegistrationRemoval =
| Readonly<{ state: 'removed' }>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ export function daemonRuntimeDepsFixture(
}),
hostDiagnostics: hostDiagnosticsFixture(),
ownerLifecycle: platformOwnerLifecycleFixture(),
providerDeviceRuntimes: [],
skippedProviderRuntimes: [],
shutdownProviderRuntimes: async () => [],
pluginInventory: [],
providerRuntimeIds: [],
recoverableProviderIds: [],
deviceRuntimeGateway: Object.freeze({
Expand Down
18 changes: 4 additions & 14 deletions packages/daemon/src/server/http/daemon-runtime-composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { expect, test, vi } from 'vitest';
import type { ProviderDeviceRuntime } from '@agent-device/contracts/device';
import {
DAEMON_STARTUP_EXIT_CODES,
tryAcquireDaemonRegistration,
Expand Down Expand Up @@ -65,14 +64,13 @@ test('daemon startup installs the interactor resolution the composition supplied
}
});

test('a daemon attempt losing the lock shuts down every constructed provider', async () => {
test('a daemon attempt losing the lock shuts the providers down through the composition', async () => {
const stateDir = mkdtempForTestSync('daemon-held-lock-');
const held = await tryAcquireDaemonRegistration(resolveDaemonPaths(stateDir));
if (held.status !== 'acquired') throw new Error('registration fixture refused');
const shutdown = vi.fn(() => {
const shutdownProviderRuntimes = vi.fn(async () => {
Comment thread
thymikee marked this conversation as resolved.
throw new Error('cleanup failed');
});
const otherShutdown = vi.fn(async () => {});
const exit = vi.fn();
const runtime = await startDaemonRuntime({
env: { AGENT_DEVICE_STATE_DIR: stateDir },
Expand All @@ -81,19 +79,11 @@ test('a daemon attempt losing the lock shuts down every constructed provider', a
stderr: { write: () => {} },
stdout: { write: () => {} },
composeDeps: daemonRuntimeDepsFixture({
providerDeviceRuntimes: [shutdown, otherShutdown].map(
(stop, index) =>
({
provider: `fixture-${index}`,
shutdown: stop,
leaseLifecycle: {},
}) as unknown as ProviderDeviceRuntime,
),
shutdownProviderRuntimes,
}),
});
expect(runtime).toBeNull();
expect(shutdown).toHaveBeenCalledOnce();
expect(otherShutdown).toHaveBeenCalledOnce();
expect(shutdownProviderRuntimes).toHaveBeenCalledOnce();
expect(exit).toHaveBeenCalledWith(DAEMON_STARTUP_EXIT_CODES.busy);
await held.owner.finish();
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import type {
PluginCleanupOutcome,
PluginInventoryEntry,
} from '@agent-device/contracts/plugin-inventory';
import fs from 'node:fs';
import path from 'node:path';
import { afterEach, expect, test, vi } from 'vitest';
Expand All @@ -6,7 +10,8 @@ import { mkdtempForTestSync, publishDaemonRegistration } from '@agent-device/tes
const lifecycleEvents = vi.hoisted(() => [] as string[]);
const startupFailure = vi.hoisted(() => ({ active: false }));
const providerComposition = vi.hoisted(() => ({
skipped: [] as Array<{ provider: string; error: Error & { code: string } }>,
inventory: [] as PluginInventoryEntry[],
cleanup: [] as PluginCleanupOutcome[],
}));

import {
Expand Down Expand Up @@ -42,7 +47,8 @@ function startRuntime(stateDir: string, exit: (code: number) => void) {
lifecycleEvents.push('gateway-shutdown');
},
}),
skippedProviderRuntimes: providerComposition.skipped,
pluginInventory: providerComposition.inventory,
shutdownProviderRuntimes: async () => providerComposition.cleanup,
})),
env: {
...process.env,
Expand Down Expand Up @@ -75,7 +81,8 @@ function publishSuccessor(paths: DaemonPaths): void {
afterEach(() => {
startupFailure.active = false;
lifecycleEvents.length = 0;
providerComposition.skipped = [];
providerComposition.inventory = [];
providerComposition.cleanup = [];
vi.restoreAllMocks();
});

Expand Down Expand Up @@ -147,16 +154,26 @@ test('a shutdown removes its own metadata and reports an unverified release', as
}
});

test('a skipped provider runtime is logged after this daemon publishes its record', async () => {
test('a failed provider plugin is logged and published after this daemon publishes its record', async () => {
const stateDir = mkdtempForTestSync('agent-device-daemon-provider-skip-');
const error = Object.assign(new Error('Limrun instance access is missing X.'), {
code: 'INVALID_ARGS',
});
providerComposition.skipped = [{ provider: 'limrun', error }];
const failed = {
provider: 'limrun',
source: 'builtin',
state: 'failed',
error: { code: 'INVALID_ARGS', message: 'Limrun instance access is missing X.' },
credentials: { variables: [], byBackend: {} },
} as const;
providerComposition.inventory = [failed];
const runtime = await startRuntime(stateDir, () => {});
try {
const skipped = logEvents(stateDir).find(({ phase }) => phase === 'provider_runtime_skipped');
expect(skipped?.data).toMatchObject({ provider: 'limrun', code: 'INVALID_ARGS' });
const event = logEvents(stateDir).find(({ phase }) => phase === 'provider_plugin_failed');
expect(event?.data).toMatchObject({
provider: 'limrun',
source: 'builtin',
code: 'INVALID_ARGS',
});
const published = JSON.parse(fs.readFileSync(resolveDaemonPaths(stateDir).infoPath, 'utf8'));
expect(published.plugins).toEqual([failed]);
} finally {
await runtime?.shutdown();
}
Expand Down Expand Up @@ -253,3 +270,36 @@ test('both exits tear the watch down before they touch daemon.json', () => {
expect(finishes[0]).toBeLessThan(stops[1]!);
expect(finishes[1]).toBeGreaterThan(stops[1]!);
});

test('a plugin whose cleanup failed at shutdown is recorded in daemon.log', async () => {
const stateDir = mkdtempForTestSync('agent-device-daemon-provider-cleanup-');
providerComposition.cleanup = [
{
provider: 'example',
source: 'store',
shutdown: { status: 'failed', error: { code: 'COMMAND_FAILED', message: 'shutdown broke' } },
dispose: { status: 'ok' },
},
{
provider: 'healthy',
source: 'builtin',
shutdown: { status: 'ok' },
dispose: { status: 'ok' },
},
];
const runtime = await startRuntime(stateDir, () => {});
try {
await runtime?.shutdown();
const events = logEvents(stateDir).filter(
({ phase }) => phase === 'provider_plugin_cleanup_failed',
);
expect(events).toHaveLength(1);
expect(events[0]?.data).toMatchObject({
provider: 'example',
source: 'store',
shutdown: { code: 'COMMAND_FAILED' },
});
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
Loading
Loading