From 8ee438ba2bd3876af9d474ee16c1f8b9aa7ebea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 20 Oct 2026 18:12:09 +0200 Subject: [PATCH 1/8] refactor(daemon): let embedded runtime owners close their runtimes Remove outside shutdown admission, completion records and stop polling. Keep direct close reports and unfinished-work registration fences. Localize internal types and move TestMu v2 capabilities into code. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0034-provider-plugin-registry.md | 4 +- .../src/daemon-registration-owner.ts | 49 +- .../src/daemon-resolution-quiesce.test.ts | 21 +- .../daemon-contracts/src/daemon-resolution.ts | 52 +- .../daemon-shutdown-report-completion.test.ts | 58 -- .../src/daemon-shutdown-report.ts | 64 +- packages/daemon-contracts/src/daemon-stop.ts | 6 +- .../http/daemon-runtime-quiesce.test.ts | 249 +----- .../daemon/src/server/http/daemon-runtime.ts | 14 +- .../server/daemon-idle-reap-lifecycle.test.ts | 179 ++--- .../src/sessions/server/daemon-idle-reap.ts | 62 +- packages/provider-testmu/src/index.ts | 15 +- ...daemon-registration-embedded-owner.test.ts | 94 +-- src/cli/commands/__tests__/daemon.test.ts | 7 +- src/commands/schema/cli-help.ts | 2 +- src/daemon-client-embedded-stop.test.ts | 733 ++---------------- src/daemon-client-released-close.test.ts | 153 ---- .../daemon-client-embedded-lifecycle.test.ts | 7 +- .../daemon-client-explicit-lifecycle.test.ts | 45 +- src/daemon-client/daemon-client-ensure.ts | 2 +- .../daemon-client-lifecycle-request.ts | 54 +- src/daemon-client/daemon-client-metadata.ts | 8 +- src/daemon-client/daemon-client-stop.ts | 166 +--- src/daemon-client/daemon-launch-spec.ts | 4 +- src/daemon-client/daemon-lifecycle-errors.ts | 63 +- src/host.test.ts | 32 +- src/host.ts | 1 - src/sdk/host.ts | 2 +- src/sdk/index.ts | 5 - .../installed-package-metro.test.ts | 18 +- website/docs/docs/client-api.md | 14 +- website/docs/docs/commands.md | 2 +- 32 files changed, 364 insertions(+), 1821 deletions(-) delete mode 100644 packages/daemon-contracts/src/daemon-shutdown-report-completion.test.ts delete mode 100644 src/daemon-client-released-close.test.ts diff --git a/docs/adr/0034-provider-plugin-registry.md b/docs/adr/0034-provider-plugin-registry.md index e922901439..6d25d196f7 100644 --- a/docs/adr/0034-provider-plugin-registry.md +++ b/docs/adr/0034-provider-plugin-registry.md @@ -58,7 +58,7 @@ had to fake the store to load a plugin from its own dependencies (#3747). ## Daemon ownership -Clients retain managed and attach lifecycles. Managed clients may start a daemon; attach clients cannot start or replace one. An embedder calls `startAgentDevice({ stateDir, plugins?, host? })` from `agent-device` to own a runtime in its process. The main entry loads the runtime when called. The returned client and serializable `clientConfig` identify that exact instance. Attaching checks normal version and code compatibility before checking the instance pin. `close()` shuts the runtime down. The CLI cannot signal or replace an embedder-owned runtime. +Clients retain managed and attach lifecycles. Managed clients may start a daemon; attach clients cannot start or replace one. An embedder calls `startAgentDevice({ stateDir, plugins?, host? })` from `agent-device` to own a runtime in its process. The main entry loads the runtime when called. The returned client and serializable `clientConfig` identify that exact instance. Attaching checks normal version and code compatibility before checking the instance pin. `close()` in the host process is the only way to shut an embedded runtime down, and returns its `ShutdownReport`. `daemon stop` refuses embedded ownership with `daemon-owner-embedded` and unrecognized ownership with `daemon-owner-unrecognized`, without a request or signal. The embedded-ownership refusal names the host PID and directs the caller to close the runtime there. The CLI cannot replace an embedded runtime. Registration and its lock remain held while requests or platform owners are unfinished. The embedder supplies plugin objects directly. They follow the built-ins through the same registry validation; this runtime does not read the plugin store. Plugin failures remain inventory data. Logging adapters, a separate package entry, and disposal protocols are not required to own a daemon: the library exposes the client, its attach configuration, and `close()`. @@ -66,6 +66,8 @@ Decision history: the former explicit-start target is dropped; the client lifecy ## Alternatives refused +- **Outside shutdown of an embedded runtime.** The host that starts it owns its close. An authenticated shutdown request, completion record and outside waiter mix ownership and duplicate the host's close handle. + - **Keep the manifest as the contract.** It cannot describe options-dependent credentials, and a bundled provider has no package to hold one. - **A runtime helper such as `definePlugin`.** It would make plugins depend on a runtime export of diff --git a/packages/daemon-contracts/src/daemon-registration-owner.ts b/packages/daemon-contracts/src/daemon-registration-owner.ts index bde85047a6..dcb90ab8fb 100644 --- a/packages/daemon-contracts/src/daemon-registration-owner.ts +++ b/packages/daemon-contracts/src/daemon-registration-owner.ts @@ -29,7 +29,7 @@ import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature'; import { resolveDaemonPaths, type DaemonCapability, - type DaemonOwnership, + type DaemonRuntimeOwnership, type DaemonPaths, type DaemonServerMode, type ObservedDaemonIdentity, @@ -44,7 +44,6 @@ import { import { buildDaemonShutdownReport, resolveDaemonShutdownReportPath, - type DaemonShutdownCompletion, type DaemonShutdownOutcome, } from './daemon-shutdown-report.ts'; import { sweepDeadDaemonStartupPhasesFiles } from './daemon-startup-phases.ts'; @@ -62,8 +61,8 @@ export type DaemonRegistrationFields = Readonly<{ capabilities?: readonly DaemonCapability[]; /** The provider plugin inventory, so a client can read what this daemon registered. */ plugins?: readonly PluginInventoryEntry[]; - /** Who owns this process; see {@link DaemonOwnership}. Absent records are detached daemons. */ - ownership?: DaemonOwnership; + /** Who owns this process; see {@link DaemonRuntimeOwnership}. Absent records are detached daemons. */ + ownership?: DaemonRuntimeOwnership; }>; type DaemonRegistrationRemoval = | Readonly<{ state: 'removed' }> @@ -72,7 +71,7 @@ export type DaemonRegistrationOwner = Readonly<{ publish(fields: DaemonRegistrationFields): void; finish( outcome?: DaemonShutdownOutcome, - close?: Readonly<{ retainRegistration: boolean; unfinished: readonly string[] }>, + close?: Readonly<{ retainRegistration: boolean }>, ): Promise>; }>; @@ -98,7 +97,7 @@ export async function tryAcquireDaemonRegistration( } catch (error) { await releaseRegistrationAfterFailure(acquisition, error, boundPaths.logPath); } - let published: Pick | undefined; + let published: Pick | undefined; return { status: 'acquired', owner: Object.freeze({ @@ -124,11 +123,11 @@ export async function tryAcquireDaemonRegistration( ), mode: 0o600, }); - published = { ownership: fields.ownership, token: fields.token }; + published = { ownership: fields.ownership }; }, async finish( outcome?: DaemonShutdownOutcome, - close?: Readonly<{ retainRegistration: boolean; unfinished: readonly string[] }>, + close?: Readonly<{ retainRegistration: boolean }>, ) { let removal: DaemonRegistrationRemoval | Readonly<{ state: 'retained' }>; try { @@ -137,20 +136,12 @@ export async function tryAcquireDaemonRegistration( ? { state: 'retained' } : removeRegistrationUnderLock(boundPaths.infoPath, identity, acquisition); } catch (error) { - await writeShutdownRecord( - 'retained', - outcome, - close?.unfinished ?? ['Daemon shutdown: registration removal failed.'], - ); + await writeShutdownReport(outcome); if (published?.ownership === 'embedded') throw error; return await releaseRegistrationAfterFailure(acquisition, error, boundPaths.logPath); } const released = removal.state === 'removed' || removal.state === 'absent'; - await writeShutdownRecord( - released ? 'released' : 'retained', - outcome, - close?.unfinished ?? ['Daemon shutdown: close details are unavailable.'], - ); + await writeShutdownReport(outcome); if (removal.state !== 'retained' && (released || published?.ownership !== 'embedded')) { await acquisition.release(); } @@ -159,27 +150,13 @@ export async function tryAcquireDaemonRegistration( }), }; - async function writeShutdownRecord( - disposition: DaemonShutdownCompletion['outcome'], - outcome: DaemonShutdownOutcome | undefined, - unfinished: readonly string[], - ): Promise { - const completion = - published?.ownership === 'embedded' && identity.startTime !== null - ? { - pid: identity.pid, - processStartTime: identity.startTime, - token: published.token, - outcome: disposition, - unfinished, - } - : undefined; - if (!outcome && !completion) return; + async function writeShutdownReport(outcome: DaemonShutdownOutcome | undefined): Promise { + if (!outcome) return; try { acquisition.assertHeld(); publishFileSync({ destination: resolveDaemonShutdownReportPath(boundPaths.baseDir), - contents: `${JSON.stringify({ ...(outcome ? buildDaemonShutdownReport(outcome) : {}), ...(completion ? { completion } : {}) })}\n`, + contents: `${JSON.stringify(buildDaemonShutdownReport(outcome))}\n`, mode: 0o600, }); } catch (error) { @@ -419,7 +396,7 @@ export type DaemonRetirementResult = | 'lock-busy' | 'registration-replaced' | 'metadata-unreadable' - /** A host owns the observed runtime; only that host, or its shutdown request, ends it. */ + /** A host owns the observed runtime; only that host closes it. */ | 'embedded-owner' /** * The record states an owner this build cannot read, so nothing proves the pid it names is a diff --git a/packages/daemon-contracts/src/daemon-resolution-quiesce.test.ts b/packages/daemon-contracts/src/daemon-resolution-quiesce.test.ts index 2cff5adbc4..84e65c746a 100644 --- a/packages/daemon-contracts/src/daemon-resolution-quiesce.test.ts +++ b/packages/daemon-contracts/src/daemon-resolution-quiesce.test.ts @@ -2,10 +2,9 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { buildDaemonLifecycleRequest, - DAEMON_LIFECYCLE_COMMANDS, + DAEMON_QUIESCE_COMMAND, readDaemonLifecycleCensus, readDaemonLifecycleFence, - readDaemonLifecycleMode, readDaemonOwnership, } from './daemon-resolution.ts'; @@ -47,20 +46,12 @@ test('a fence names an integer pid and a start time or null, nothing else', () = assert.equal(readDaemonLifecycleFence('7'), undefined); }); -test('both lifecycle requests carry one fence shape under the command of their mode', () => { +test('quiesce carries its fence under its command', () => { const fence = { pid: 7, processStartTime: 'Fri Oct 17 09:00:00 2026' }; - for (const mode of ['quiesce', 'shutdown'] as const) { - const request = buildDaemonLifecycleRequest({ mode, token: 't0ken', fence, requestId: 'r-1' }); - assert.equal(request.command, DAEMON_LIFECYCLE_COMMANDS[mode]); - assert.deepEqual(request.input, fence); - assert.equal(request.meta?.requestId, 'r-1'); - assert.equal(readDaemonLifecycleMode(request.command), mode); - } - // A request that is not one of the two is dispatched like any other command, including a name the - // lookup would answer for through the object prototype. - assert.equal(readDaemonLifecycleMode('open'), undefined); - assert.equal(readDaemonLifecycleMode('toString'), undefined); - assert.equal(readDaemonLifecycleMode('__proto__'), undefined); + const request = buildDaemonLifecycleRequest({ token: 't0ken', fence, requestId: 'r-1' }); + assert.equal(request.command, DAEMON_QUIESCE_COMMAND); + assert.deepEqual(request.input, fence); + assert.equal(request.meta?.requestId, 'r-1'); }); test('ownership names who may end the process, and an older record is detached', () => { diff --git a/packages/daemon-contracts/src/daemon-resolution.ts b/packages/daemon-contracts/src/daemon-resolution.ts index 690e48b3eb..3540f9a172 100644 --- a/packages/daemon-contracts/src/daemon-resolution.ts +++ b/packages/daemon-contracts/src/daemon-resolution.ts @@ -147,12 +147,6 @@ export function resolveDaemonTransportPreference( return 'auto'; } -// The two ways a process asks a daemon to leave, and the fence that addresses the exact instance. -// Client and daemon share this vocabulary, so neither owns it. `quiesce` asks an idle daemon to -// leave so a compatible one can take its state directory; `shutdown` asks a runtime whose owner is a -// host process to close itself, work in hand included. Both ride one request envelope, one fence, -// and one admission rule, and differ only in what the daemon may still hold when it agrees. - /** What a daemon can do beyond the base RPC contract, published in its `daemon.json`. */ export const DAEMON_CAPABILITIES = ['quiesce'] as const; export type DaemonCapability = (typeof DAEMON_CAPABILITIES)[number]; @@ -164,7 +158,7 @@ export type DaemonCapability = (typeof DAEMON_CAPABILITIES)[number]; * predates the field names. * - `embedded`: the host that started the runtime owns the process. Nothing in the central * lifecycle signals that PID, not even hung recovery or a forced stop: the host closes the - * runtime through its own close, and a stop from outside reaches it only as a shutdown request. + * runtime through its own close. A stop from outside refuses without a request or signal. * - `unknown`: the record states an owner this build does not recognize, so no process may be * signaled on its strength. Signalling on a guess could end a host process, and a value this * build cannot read is exactly the case where the safe answer is to do nothing. @@ -203,32 +197,8 @@ export function readDaemonOwnership(value: unknown): DaemonOwnership { : 'unknown'; } -/** - * The lifecycle requests, each named by the command that carries it. Both are the same envelope, - * fence and admission rule; they differ only in what the daemon may hold while it agrees. - */ -export const DAEMON_LIFECYCLE_COMMANDS = { - /** Stop if idle: refuses while the daemon holds a request, a session, or a lease. */ - quiesce: 'daemon_quiesce', - /** Stop and drain: ends what the daemon holds, and only an embedder-owned runtime agrees. */ - shutdown: 'daemon_shutdown', -} as const; -export type DaemonLifecycleMode = keyof typeof DAEMON_LIFECYCLE_COMMANDS; - -/** - * The lifecycle mode `command` names, or `undefined` for every request that is not one. Compared - * explicitly, because a lookup in an object would answer for a command like `toString` too. - */ -export function readDaemonLifecycleMode(command: string): DaemonLifecycleMode | undefined { - switch (command) { - case DAEMON_LIFECYCLE_COMMANDS.quiesce: - return 'quiesce'; - case DAEMON_LIFECYCLE_COMMANDS.shutdown: - return 'shutdown'; - default: - return undefined; - } -} +/** Stop if idle: refuses while the detached daemon holds work. */ +export const DAEMON_QUIESCE_COMMAND = 'daemon_quiesce'; /** The reason a daemon gives every request it refuses once it has begun to leave. */ export const DAEMON_SHUTTING_DOWN_REASON = 'daemon_shutting_down'; @@ -239,10 +209,8 @@ export const DAEMON_LIFECYCLE_REASONS = { busy: 'daemon-busy', /** The request names a different daemon instance than the one answering it. */ instanceMismatch: 'daemon-instance-mismatch', - /** A quiesce was refused because a host owns this runtime and ends it through its own close. */ + /** A host owns this runtime and ends it through its own close. */ ownerEmbedded: 'daemon-owner-embedded', - /** A shutdown was refused because a detached daemon's owner ends it by signalling its process. */ - notEmbedded: 'daemon-not-embedded', } as const; /** @@ -262,8 +230,7 @@ export type DaemonLifecycleFence = Readonly<{ /** * What a daemon holds, and the census a busy refusal carries so a caller knows what to wait for. * A quiesce leaves only when all four counts are zero and it is not still starting up, because before - * that it has no drain path to run. A shutdown drains whatever it finds, and refuses with this census - * only while the daemon is still starting; its acknowledgement carries the one `draining` outcome. + * that it has no drain path to run. */ export type DaemonLifecycleCensus = Readonly<{ /** True from process start until the daemon can drain. */ @@ -275,20 +242,19 @@ export type DaemonLifecycleCensus = Readonly<{ }>; /** - * The request that asks the fenced instance to leave. Both modes carry the fence in `input`, so a - * daemon refuses a request addressed to a predecessor that reused its pid. + * The quiesce request carries its fence in `input`, so a daemon refuses a request addressed to a + * predecessor that reused its pid. */ export function buildDaemonLifecycleRequest(params: { - mode: DaemonLifecycleMode; token: string; fence: DaemonLifecycleFence; requestId: string; }): DaemonWireRequest { - const { mode, token, fence, requestId } = params; + const { token, fence, requestId } = params; return { token, session: 'default', - command: DAEMON_LIFECYCLE_COMMANDS[mode], + command: DAEMON_QUIESCE_COMMAND, positionals: [], input: { pid: fence.pid, processStartTime: fence.processStartTime }, meta: { requestId }, diff --git a/packages/daemon-contracts/src/daemon-shutdown-report-completion.test.ts b/packages/daemon-contracts/src/daemon-shutdown-report-completion.test.ts deleted file mode 100644 index 62902a7e0c..0000000000 --- a/packages/daemon-contracts/src/daemon-shutdown-report-completion.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import fs from 'node:fs'; -import { expect, test } from 'vitest'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; -import { - daemonShutdownCompletionAnswers, - readDaemonShutdownRecord, - readDaemonShutdownReport, - resolveDaemonShutdownReportPath, -} from './daemon-shutdown-report.ts'; - -const completion = { - pid: 4242, - processStartTime: 'birth', - token: 'runtime-a', - outcome: 'retained' as const, - unfinished: ['1 in-flight request ignored cancellation'], -}; - -test('one record reads the legacy report and completion independently', () => { - const dir = mkdtempForTestSync('agent-device-shutdown-record-'); - const file = resolveDaemonShutdownReportPath(dir); - const report = { - providerReleases: { released: [{ leaseId: 'lease-1' }], pending: [] }, - claims: { released: [], orphaned: [], superseded: [], unattributable: [] }, - }; - for (const present of [false, true]) { - fs.writeFileSync(file, JSON.stringify({ ...(present ? report : {}), completion })); - expect(readDaemonShutdownRecord(dir)).toEqual({ report: present ? report : null, completion }); - expect(readDaemonShutdownReport(dir)).toEqual(present ? report : null); - } - fs.writeFileSync(file, JSON.stringify({ ...report, completion: { ...completion, token: null } })); - expect(readDaemonShutdownRecord(dir)).toEqual({ report, completion: null }); -}); - -test.each([ - ['absent', undefined], - ['malformed', '{not json'], - ['empty', '{}'], - [ - 'missing lifetime', - JSON.stringify({ completion: { ...completion, processStartTime: undefined } }), - ], - ['missing token', JSON.stringify({ completion: { ...completion, token: undefined } })], - ['invalid outcome', JSON.stringify({ completion: { ...completion, outcome: 'maybe' } })], -])('unusable %s completion supplies no cleanup evidence', (_label, raw) => { - const dir = mkdtempForTestSync('agent-device-shutdown-record-'); - if (raw !== undefined) fs.writeFileSync(resolveDaemonShutdownReportPath(dir), raw); - expect(readDaemonShutdownRecord(dir)).toEqual({ report: null, completion: null }); -}); - -test.each([ - [completion, true], - [{ ...completion, pid: 9090 }, false], - [{ ...completion, processStartTime: 'another birth' }, false], - [{ ...completion, token: 'runtime-b' }, false], -])('completion matches the original runtime identity: %j', (query, matches) => { - expect(daemonShutdownCompletionAnswers(completion, query)).toBe(matches); -}); diff --git a/packages/daemon-contracts/src/daemon-shutdown-report.ts b/packages/daemon-contracts/src/daemon-shutdown-report.ts index 6b8d9fbd62..10cc14c87d 100644 --- a/packages/daemon-contracts/src/daemon-shutdown-report.ts +++ b/packages/daemon-contracts/src/daemon-shutdown-report.ts @@ -64,31 +64,19 @@ export function buildDaemonShutdownReport(outcome: DaemonShutdownOutcome): Daemo }; } -export type DaemonShutdownRecord = Readonly<{ - report: DaemonShutdownReport | null; - completion: DaemonShutdownCompletion | null; -}>; - -export function readDaemonShutdownRecord(stateDir: string): DaemonShutdownRecord { +export function readDaemonShutdownReport(stateDir: string): DaemonShutdownReport | null { try { const parsed: unknown = JSON.parse( fs.readFileSync(resolveDaemonShutdownReportPath(stateDir), 'utf8'), ); - return { - report: isProviderReleaseReport(parsed) - ? { providerReleases: parsed.providerReleases, claims: readClaimSection(parsed) } - : null, - completion: readCompletion(parsed), - }; + return isProviderReleaseReport(parsed) + ? { providerReleases: parsed.providerReleases, claims: readClaimSection(parsed) } + : null; } catch { - return { report: null, completion: null }; + return null; } } -export function readDaemonShutdownReport(stateDir: string): DaemonShutdownReport | null { - return readDaemonShutdownRecord(stateDir).report; -} - export function resolveDaemonShutdownReportPath(stateDir: string): string { return path.join(stateDir, SHUTDOWN_REPORT_FILE); } @@ -158,45 +146,3 @@ function isProviderReleaseRecord(value: unknown): value is ProviderReleaseRecord (record.provider === undefined || typeof record.provider === 'string') ); } - -/** The registration disposition proved before releasing its claim; lock release can still fail. */ -export type DaemonShutdownCompletion = Readonly<{ - pid: number; - processStartTime: string; - token: string; - outcome: 'released' | 'retained'; - unfinished: readonly string[]; -}>; - -export type DaemonShutdownCompletionQuery = Pick< - DaemonShutdownCompletion, - 'pid' | 'processStartTime' | 'token' ->; - -export function daemonShutdownCompletionAnswers( - completion: DaemonShutdownCompletion, - query: DaemonShutdownCompletionQuery, -): boolean { - return ( - completion.pid === query.pid && - completion.processStartTime === query.processStartTime && - completion.token === query.token - ); -} - -function readCompletion(value: unknown): DaemonShutdownCompletion | null { - if (!value || typeof value !== 'object') return null; - const record = (value as { completion?: Partial }).completion; - return record && - Number.isSafeInteger(record.pid) && - Number(record.pid) > 0 && - typeof record.processStartTime === 'string' && - record.processStartTime.trim().length > 0 && - typeof record.token === 'string' && - record.token.length > 0 && - (record.outcome === 'released' || record.outcome === 'retained') && - Array.isArray(record.unfinished) && - record.unfinished.every((line) => typeof line === 'string') - ? (record as DaemonShutdownCompletion) - : null; -} diff --git a/packages/daemon-contracts/src/daemon-stop.ts b/packages/daemon-contracts/src/daemon-stop.ts index aa3862f2b7..08b87a5734 100644 --- a/packages/daemon-contracts/src/daemon-stop.ts +++ b/packages/daemon-contracts/src/daemon-stop.ts @@ -47,10 +47,8 @@ export type DaemonStopResult = { /** * Retires the daemon whose lifetime the caller read, and shapes that retirement as a stop result. * - * The caller hands over the lifetime and the owner its own read proved, so routing and retirement judge - * one record. Ownership is judged here and nowhere else: an embedded runtime belongs to its host, and a - * record this build cannot place proves nothing about what is safe to signal, so both come back as the - * refusal that names their owner, with nothing signaled. + * The public stop router refuses embedded and unknown registrations before this path. + * Direct callers still supply ownership to retirement, which refuses to signal a non-detached owner. */ export async function stopDaemon(params: { paths: DaemonPaths; diff --git a/packages/daemon/src/server/http/daemon-runtime-quiesce.test.ts b/packages/daemon/src/server/http/daemon-runtime-quiesce.test.ts index 5800e23526..a9f471bc83 100644 --- a/packages/daemon/src/server/http/daemon-runtime-quiesce.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-quiesce.test.ts @@ -7,17 +7,13 @@ import { resolveDaemonPaths, type DaemonRuntimeOwnership, } from '@agent-device/daemon-contracts/daemon-resolution'; -import { - daemonShutdownCompletionAnswers, - readDaemonShutdownRecord, -} from '@agent-device/daemon-contracts/daemon-shutdown-report'; import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import * as shutdown from './server-shutdown.ts'; // A lifecycle request is the daemon's own answer to "may I leave?", so these tests boot the real // runtime and ask over its real socket: the census, the closing of admission, the drain and the -// completion record a stop waits for are one flow. +// runtime close are one flow. const probe = vi.hoisted(() => ({ /** Holds daemon startup after its servers listen, until released. */ startupGate: undefined as Promise | undefined, @@ -116,7 +112,6 @@ type Booted = { stateDir: string; exit: Promise; quiesce(overrides?: LifecycleAddress): Promise; - shutdown(overrides?: LifecycleAddress): Promise; sessionList(): Promise; }; @@ -163,9 +158,8 @@ async function bootDaemon( }; const socketPort = runtime.socketPort; const send = (request: object) => sendSocketRequest(socketPort, request); - const sendLifecycle = (mode: 'quiesce' | 'shutdown', overrides: LifecycleAddress) => { + const sendLifecycle = (overrides: LifecycleAddress) => { const request = buildDaemonLifecycleRequest({ - mode, token: overrides.token ?? runtime.token, fence: { pid: overrides.pid ?? info.pid, @@ -174,7 +168,7 @@ async function bootDaemon( ? overrides.processStartTime : (info.processStartTime ?? null), }, - requestId: overrides.requestId ?? `${mode}-test`, + requestId: overrides.requestId ?? 'quiesce-test', }); return overrides.omitMeta ? send({ ...request, meta: undefined }) : send(request); }; @@ -182,8 +176,7 @@ async function bootDaemon( runtime, stateDir, exit, - quiesce: (overrides = {}) => sendLifecycle('quiesce', overrides), - shutdown: (overrides = {}) => sendLifecycle('shutdown', overrides), + quiesce: (overrides = {}) => sendLifecycle(overrides), sessionList: () => send({ token: runtime.token, @@ -336,7 +329,6 @@ async function quiesceOverHttp( id: 'http-quiesce', method: 'agent_device.command', params: buildDaemonLifecycleRequest({ - mode: 'quiesce', token, fence: { pid: info.pid, processStartTime: info.processStartTime ?? null }, requestId: 'http-quiesce', @@ -364,68 +356,7 @@ test('a local HTTP quiesce drains an idle daemon like a socket one', async () => expect(await daemon.exit).toBe(0); }); -test('an embedded runtime records its close for every stop of that instance', async () => { - const daemon = await bootDaemon({ ownership: 'embedded' }); - const paths = resolveDaemonPaths(daemon.stateDir); - - expect(await daemon.shutdown({ requestId: 'embedded-stop-1' })).toEqual({ - ok: true, - data: { outcome: 'draining' }, - }); - expect(await daemon.exit).toBe(0); - - // The registration is gone by the time a close has a report, so this record is the only answer a - // stop of an embedded runtime can read. - const completion = readDaemonShutdownRecord(paths.baseDir).completion; - expect(completion?.outcome).toBe('released'); - expect(completion?.unfinished).toEqual([]); - // The record names the instance that wrote it, so every stop addressed to that instance — including - // a second one that arrived after the first was answered — reads this same answer. - expect(completion?.pid).toBe(process.pid); - const stopped = { - pid: completion!.pid, - processStartTime: completion!.processStartTime, - token: completion!.token, - }; - expect(daemonShutdownCompletionAnswers(completion!, stopped)).toBe(true); - // A different instance is not answered by this record, so it cannot be told somebody else's shutdown. - expect(daemonShutdownCompletionAnswers(completion!, { ...stopped, pid: 999_999 })).toBe(false); - expect(fs.existsSync(paths.infoPath)).toBe(false); -}); - -test('a shutdown carrying no request id is accepted and still writes a record a stop can read', async () => { - // The answer is fenced to the instance, so nothing about the close depends on the caller having an - // id: a host that tracks none gets the same completion record as one that does. - const daemon = await bootDaemon({ ownership: 'embedded' }); - const paths = resolveDaemonPaths(daemon.stateDir); - - expect(await daemon.shutdown({ omitMeta: true })).toEqual({ - ok: true, - data: { outcome: 'draining' }, - }); - expect(await daemon.exit).toBe(0); - - const completion = readDaemonShutdownRecord(paths.baseDir).completion; - expect(completion).toMatchObject({ pid: process.pid, outcome: 'released', unfinished: [] }); - expect( - daemonShutdownCompletionAnswers(completion!, { - pid: completion!.pid, - processStartTime: completion!.processStartTime, - token: completion!.token, - }), - ).toBe(true); -}); - -test('direct host close publishes the successful result once', async () => { - const daemon = await bootDaemon({ ownership: 'embedded' }); - await Promise.all([daemon.runtime.close(), daemon.runtime.close()]); - expect(readDaemonShutdownRecord(daemon.stateDir).completion).toMatchObject({ - outcome: 'released', - unfinished: [], - }); -}); - -test('a host that closes its own runtime with work in hand still records what it kept', async () => { +test('a host close retains registration and lock while a request ignores cancellation', async () => { const daemon = await bootDaemon({ ownership: 'embedded', requestDrainMs: 50 }); const paths = resolveDaemonPaths(daemon.stateDir); let release: () => void = () => {}; @@ -439,11 +370,9 @@ test('a host that closes its own runtime with work in hand still records what it await entered; try { - await daemon.runtime.close(); - - const completion = readDaemonShutdownRecord(paths.baseDir).completion; - expect(completion).toMatchObject({ pid: process.pid, outcome: 'retained' }); - expect(completion?.unfinished.join('\n')).toContain('ignored cancellation'); + const report = await daemon.runtime.close(); + expect(report.requests.abandoned).toBe(1); + expect(fs.existsSync(paths.lockPath)).toBe(true); expect(fs.existsSync(paths.infoPath)).toBe(true); } finally { release(); @@ -462,16 +391,9 @@ test('a diagnostic failure starts listener close, retains unproved work and pres expect(listeners).toHaveBeenCalledTimes(1); expect(fs.existsSync(paths.infoPath)).toBe(true); expect(fs.existsSync(paths.lockPath)).toBe(true); - expect(readDaemonShutdownRecord(paths.baseDir)).toMatchObject({ - report: null, - completion: { - outcome: 'retained', - unfinished: [expect.stringContaining('did not finish leaving')], - }, - }); }); -test('a close whose step rejects still answers the stop that asked for it', async () => { +test('a rejected platform close retains registration and lock', async () => { const stateDir = mkdtempForTestSync('agent-device-daemon-lifecycle-close-failure-'); const paths = resolveDaemonPaths(stateDir); const runtime = await startDaemonRuntime({ @@ -496,83 +418,9 @@ test('a close whose step rejects still answers the stop that asked for it', asyn stderr: { write: () => {} }, stdout: { write: () => {} }, }); - const info = JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')) as { - port: number; - token: string; - pid: number; - processStartTime?: string; - }; - const request = buildDaemonLifecycleRequest({ - mode: 'shutdown', - token: info.token, - fence: { pid: info.pid, processStartTime: info.processStartTime ?? null }, - requestId: 'failing-stop', - }); - - expect(await sendSocketRequest(info.port, request)).toEqual({ - ok: true, - data: { outcome: 'draining' }, - }); - await expect(runtime.closed).rejects.toThrow('gateway teardown rejected'); - - // A stop would otherwise sit out its whole budget on a close that had already failed, so the record - // says what happened instead of saying nothing. - const completion = readDaemonShutdownRecord(paths.baseDir).completion; - expect(completion?.pid).toBe(info.pid); - // A platform owner whose teardown rejected may still be driving devices, so the close keeps the - // registration and lock until this process exits — releasing them would let a second daemon take the - // same devices. The record says `retained` because that is what the state directory shows. - expect(completion?.outcome).toBe('retained'); - expect(completion?.unfinished.join('\n')).toContain('did not finish leaving'); + await expect(runtime.close()).rejects.toThrow('gateway teardown rejected'); expect(fs.existsSync(paths.infoPath)).toBe(true); -}); - -test('a shutdown that drains work it could not cancel records what it kept', async () => { - const daemon = await bootDaemon({ ownership: 'embedded', requestDrainMs: 50 }); - const paths = resolveDaemonPaths(daemon.stateDir); - let release: () => void = () => {}; - const entered = new Promise((resolve) => { - probe.entered = resolve; - }); - // One request that answers nothing. Its own connection is still open when the listeners go away, so - // nothing about it is knowable from the client side: that is work a drain has to give up on, which is - // the one outcome a stop must be told about. - probe.held = new Promise((resolve) => { - release = resolve; - }); - daemon.sessionList().catch(() => undefined); - await entered; - - try { - expect(await daemon.shutdown({ requestId: 'draining-stop' })).toEqual({ - ok: true, - data: { outcome: 'draining' }, - }); - await daemon.runtime.close(); - - const completion = readDaemonShutdownRecord(paths.baseDir).completion; - expect(completion?.pid).toBe(process.pid); - expect(completion?.outcome).toBe('retained'); - expect(completion?.unfinished.join('\n')).toContain('ignored cancellation'); - // The abandoned work holds the registration: releasing it would let a second daemon drive the same - // devices while this process may still be running one. - expect(fs.existsSync(paths.infoPath)).toBe(true); - } finally { - release(); - } -}); - -test('a detached daemon refuses a shutdown request and keeps serving', async () => { - const daemon = await bootDaemon(); - expect(reasonOf(await daemon.shutdown())).toBe('daemon-not-embedded'); - expect((await daemon.sessionList()).ok).toBe(true); - expect(readDaemonShutdownRecord(resolveDaemonPaths(daemon.stateDir).baseDir).completion).toBe( - null, - ); - await daemon.runtime.close(); - expect(readDaemonShutdownRecord(resolveDaemonPaths(daemon.stateDir).baseDir).completion).toBe( - null, - ); + expect(fs.existsSync(paths.lockPath)).toBe(true); }); test('an embedded runtime outlives the idle window a detached daemon reaps itself on', async () => { @@ -602,82 +450,10 @@ test('an embedded runtime refuses a quiesce, because its host decides when it le expect(reasonOf(await daemon.quiesce())).toBe('daemon-owner-embedded'); // Nothing was closed and no admission was left closed: the host's runtime still serves. expect((await daemon.sessionList()).ok).toBe(true); - expect(await daemon.shutdown({ requestId: 'asked-to-leave' })).toEqual({ - ok: true, - data: { outcome: 'draining' }, - }); + await daemon.runtime.close(); expect(await daemon.exit).toBe(0); }); -test('a shutdown that arrives while the runtime is starting is refused, then accepted once it can drain', async () => { - const stateDir = mkdtempForTestSync('agent-device-daemon-lifecycle-shutdown-startup-'); - const paths = resolveDaemonPaths(stateDir); - let openGate: () => void = () => {}; - probe.startupGate = new Promise((resolve) => { - openGate = resolve; - }); - const starting = startDaemonRuntime({ - composeDeps: daemonRuntimeDepsFixture(), - host: createTestHostEnvironment({ - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_SERVER_MODE: 'socket', - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - }, - }), - ownership: 'embedded', - stderr: { write: () => {} }, - stdout: { write: () => {} }, - }); - try { - for (let polls = 0; !fs.existsSync(paths.infoPath); polls += 1) { - if (polls >= 500) throw new Error('daemon did not publish daemon.json during startup'); - await new Promise((resolve) => setTimeout(resolve, 10)); - } - const info = JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')) as { - port: number; - token: string; - pid: number; - processStartTime?: string; - ownership?: string; - }; - // Published while it still had no drain path, and recorded as owned by a host. - expect(info.ownership).toBe('embedded'); - const shutdownRequest = buildDaemonLifecycleRequest({ - mode: 'shutdown', - token: info.token, - fence: { pid: info.pid, processStartTime: info.processStartTime ?? null }, - requestId: 'startup-shutdown', - }); - const refused = await sendSocketRequest(info.port, shutdownRequest); - expect(reasonOf(refused)).toBe('daemon-busy'); - - openGate(); - const runtime = await starting; - const exit = runtime.closed.then(() => 0); - expect(await sendSocketRequest(info.port, shutdownRequest)).toEqual({ - ok: true, - data: { outcome: 'draining' }, - }); - expect(await exit).toBe(0); - // The record answers the instance the stop was fenced to, which is the one the record named before - // the close removed it. - const completion = readDaemonShutdownRecord(paths.baseDir).completion; - expect( - completion && - daemonShutdownCompletionAnswers(completion, { - pid: info.pid, - processStartTime: info.processStartTime ?? '', - token: info.token, - }), - ).toBe(true); - } finally { - openGate(); - await starting.then((runtime) => runtime?.close()); - } -}); - test('a quiesce asked while the daemon is still starting up is refused as busy, then accepted', async () => { const stateDir = mkdtempForTestSync('agent-device-daemon-lifecycle-startup-'); const paths = resolveDaemonPaths(stateDir); @@ -712,7 +488,6 @@ test('a quiesce asked while the daemon is still starting up is refused as busy, }; const quiesceRequest = buildDaemonLifecycleRequest({ - mode: 'quiesce', token: info.token, fence: { pid: info.pid, processStartTime: info.processStartTime ?? null }, requestId: 'startup-quiesce', diff --git a/packages/daemon/src/server/http/daemon-runtime.ts b/packages/daemon/src/server/http/daemon-runtime.ts index 45db80179f..afba9a8ab3 100644 --- a/packages/daemon/src/server/http/daemon-runtime.ts +++ b/packages/daemon/src/server/http/daemon-runtime.ts @@ -46,7 +46,6 @@ import { type WritableOutput, createPluginClose, createStartupRollback, - describeUnfinishedShutdownWork, settleWithinBudget, type ShutdownReport, type StartupRollback, @@ -224,8 +223,8 @@ export type DaemonRuntimeOptions = { shutdownBudgets?: { requestDrainMs?: number; pluginMs?: number; ownerMs?: number }; /** * Who owns this process. A host that started this runtime in its own process passes `embedded`: the - * record says so, no idle timer ends the runtime, and it answers a shutdown request instead of a - * quiesce. Defaults to a detached daemon. `unknown` is not offered: it is what a reader answers for a + * record says so, no idle timer ends the runtime, and only the host closes it. Defaults to a detached + * daemon. `unknown` is not offered: it is what a reader answers for a * record it cannot place, and a runtime always knows which of these two it is. */ ownership?: DaemonRuntimeOwnership; @@ -559,8 +558,8 @@ async function startDaemonRuntimeOnHost( leaseSessionEnd.cancel(); }); - // Both lifecycle requests are answered before dispatch and never join `inFlightRequests`, so the - // count they read needs no allowance for them. The servers listen and `daemon.json` is published + // Quiesce is answered before dispatch and never joins `inFlightRequests`, so the + // count it reads needs no allowance for it. The servers listen and `daemon.json` is published // before `close` exists, and a daemon answers `draining` only once it has a drain path to run. let startingUp = true; const lifecycleAdmission = createDaemonLifecycleAdmission({ @@ -628,7 +627,7 @@ async function startDaemonRuntimeOnHost( registration, infoPath, logPath, - close: { retainRegistration: false, unfinished: ['Daemon startup rolled back.'] }, + close: { retainRegistration: false }, }); }); const startupPhasesPath = resolveDaemonStartupPhasesPath(baseDir, process.pid); @@ -919,9 +918,6 @@ async function startDaemonRuntimeOnHost( outcome: shutdownOutcome, close: { retainRegistration: hasUnfinishedWork(), - unfinished: report - ? describeUnfinishedShutdownWork(report) - : ['Daemon shutdown: a close step failed, so the runtime did not finish leaving.'], }, }), ]); diff --git a/packages/daemon/src/sessions/server/daemon-idle-reap-lifecycle.test.ts b/packages/daemon/src/sessions/server/daemon-idle-reap-lifecycle.test.ts index 9941a0a72e..291fb5a62d 100644 --- a/packages/daemon/src/sessions/server/daemon-idle-reap-lifecycle.test.ts +++ b/packages/daemon/src/sessions/server/daemon-idle-reap-lifecycle.test.ts @@ -4,8 +4,6 @@ import path from 'node:path'; import { afterEach, beforeEach, test, vi } from 'vitest'; import { buildDaemonLifecycleRequest, - DAEMON_LIFECYCLE_COMMANDS, - type DaemonLifecycleMode, type DaemonRuntimeOwnership, } from '@agent-device/daemon-contracts/daemon-resolution'; import { mkdtempForTestSync } from '@agent-device/test-kit'; @@ -15,10 +13,6 @@ import { SessionStore } from '../../core/session-store.ts'; import type { DaemonRequest } from '../../core/daemon-request.ts'; import { createDaemonLifecycleAdmission, daemonShuttingDownResponse } from './daemon-idle-reap.ts'; -// Both lifecycle requests are answered by one seam, so these tests ask that seam and check what it -// did to admission: the two modes share the token check, the instance fence, and the rule that an -// answer and the closing of admission are one step. - const TOKEN = 'daemon-token'; const FENCE = { pid: 4242, processStartTime: 'Fri Oct 17 09:00:00 2026' }; @@ -72,7 +66,6 @@ function makeSeam( } function lifecycleRequest( - mode: DaemonLifecycleMode, overrides: { token?: string; fence?: { pid: number; processStartTime: string | null }; @@ -80,10 +73,9 @@ function lifecycleRequest( } = {}, ): DaemonRequest { return buildDaemonLifecycleRequest({ - mode, token: overrides.token ?? TOKEN, fence: overrides.fence ?? FENCE, - requestId: overrides.requestId ?? `${mode}-test`, + requestId: overrides.requestId ?? 'quiesce-test', }); } @@ -106,7 +98,7 @@ async function settled(): Promise { test('a lifecycle request is answered at the seam and never reaches the handler it guards', async () => { const { admit, drained, served } = makeSeam(); - assert.deepEqual(await admit(served)(lifecycleRequest('quiesce')), { + assert.deepEqual(await admit(served)(lifecycleRequest()), { ok: true, data: { outcome: 'draining' }, }); @@ -115,126 +107,98 @@ test('a lifecycle request is answered at the seam and never reaches the handler assert.equal(served.mock.calls.length, 0); }); -test('a detached daemon refuses a shutdown, and a host-owned runtime refuses a quiesce', async () => { - for (const [ownership, refused, accepted] of [ - ['detached', 'shutdown', 'quiesce'], - ['embedded', 'quiesce', 'shutdown'], - ] as const) { - const { admit, drained, served } = makeSeam({ ownership }); - assert.equal( - reasonOf(await admit(served)(lifecycleRequest(refused))), - ownership === 'detached' ? 'daemon-not-embedded' : 'daemon-owner-embedded', - ownership, - ); - await settled(); - assert.equal(drained.mock.calls.length, 0, `${ownership}: a refusal never drained`); - // A refusal is not a retirement: the daemon keeps serving, and the other mode still works. - assert.deepEqual(await admit(served)(lifecycleRequest(accepted)), { - ok: true, - data: { outcome: 'draining' }, - }); - } +test('a host-owned runtime refuses quiesce and keeps serving', async () => { + const { admit, drained, served } = makeSeam({ ownership: 'embedded' }); + assert.equal(reasonOf(await admit(served)(lifecycleRequest())), 'daemon-owner-embedded'); + await settled(); + assert.equal(drained.mock.calls.length, 0); + assert.deepEqual(await admit(served)(ordinaryRequest()), { ok: true }); }); -test('a host-owned runtime drains work in hand, where a detached daemon refuses it', async () => { +test('a detached daemon refuses quiesce while it holds work', async () => { openOneSession(); const held = makeSeam({ ownership: 'detached' }); - const refusal = await held.admit(held.served)(lifecycleRequest('quiesce')); + const refusal = await held.admit(held.served)(lifecycleRequest()); assert.equal(reasonOf(refusal), 'daemon-busy'); if (!refusal.ok) assert.equal(refusal.error.details?.sessions, 1); await settled(); assert.equal(held.drained.mock.calls.length, 0); // The refusal reopened admission, so this daemon is still serving its sessions. assert.deepEqual(await held.admit(held.served)(ordinaryRequest()), { ok: true }); +}); - const draining = makeSeam({ ownership: 'embedded' }); - assert.deepEqual(await draining.admit(draining.served)(lifecycleRequest('shutdown')), { - ok: true, - data: { outcome: 'draining' }, +test('a daemon still starting refuses quiesce and keeps serving while it starts', async () => { + const { admit, drained, served } = makeSeam({ + ownership: 'detached', + startingUp: true, }); + const refusal = await admit(served)(lifecycleRequest()); + assert.equal(reasonOf(refusal), 'daemon-busy'); + if (!refusal.ok) assert.equal(refusal.error.details?.startingUp, true); await settled(); - assert.equal(draining.drained.mock.calls.length, 1); + assert.equal(drained.mock.calls.length, 0); + assert.deepEqual( + await admit(served)(ordinaryRequest()), + { ok: true }, + 'a refusal keeps admission open', + ); }); -test('a runtime still starting accepts neither mode and keeps serving while it starts', async () => { - for (const mode of ['quiesce', 'shutdown'] as const) { - const { admit, drained, served } = makeSeam({ - ownership: mode === 'shutdown' ? 'embedded' : 'detached', - startingUp: true, - }); - const refusal = await admit(served)(lifecycleRequest(mode)); - assert.equal(reasonOf(refusal), 'daemon-busy', mode); - if (!refusal.ok) assert.equal(refusal.error.details?.startingUp, true, mode); - await settled(); - assert.equal(drained.mock.calls.length, 0, mode); - assert.deepEqual( - await admit(served)(ordinaryRequest()), - { ok: true }, - `${mode}: a refusal keeps admission open`, - ); - } -}); +test('quiesce refuses a wrong token, a remote origin, and any other instance', async () => { + const ownership = 'detached'; -test('both modes refuse a wrong token, a remote origin, and any other instance', async () => { - for (const mode of ['quiesce', 'shutdown'] as const) { - const ownership = mode === 'shutdown' ? 'embedded' : 'detached'; - - const seam = makeSeam({ ownership }); - const wrongToken = await seam.admit(seam.served)(lifecycleRequest(mode, { token: 'other' })); - assert.equal(!wrongToken.ok && wrongToken.error.code, 'UNAUTHORIZED', mode); - assert.equal(seam.drained.mock.calls.length, 0, mode); - - const remote = await seam.admit(seam.served)({ - ...lifecycleRequest(mode), - internal: { publicNetworkOnly: true }, - }); - assert.equal(!remote.ok && remote.error.code, 'UNAUTHORIZED', mode); - - for (const [label, fence] of [ - ['recycled pid', { pid: FENCE.pid + 1, processStartTime: FENCE.processStartTime }], - ['other lifetime', { pid: FENCE.pid, processStartTime: 'Sat Oct 18 09:00:00 2026' }], - ['no recorded birth', { pid: FENCE.pid, processStartTime: null }], - ] as const) { - const addressed = makeSeam({ ownership }); - const response = await addressed.admit(addressed.served)(lifecycleRequest(mode, { fence })); - assert.equal(reasonOf(response), 'daemon-instance-mismatch', `${mode} ${label}`); - await settled(); - assert.equal(addressed.drained.mock.calls.length, 0, `${mode} ${label}`); - } + const seam = makeSeam({ ownership }); + const wrongToken = await seam.admit(seam.served)(lifecycleRequest({ token: 'other' })); + assert.equal(!wrongToken.ok && wrongToken.error.code, 'UNAUTHORIZED'); + assert.equal(seam.drained.mock.calls.length, 0); + + const remote = await seam.admit(seam.served)({ + ...lifecycleRequest(), + internal: { publicNetworkOnly: true }, + }); + assert.equal(!remote.ok && remote.error.code, 'UNAUTHORIZED'); + + for (const [label, fence] of [ + ['recycled pid', { pid: FENCE.pid + 1, processStartTime: FENCE.processStartTime }], + ['other lifetime', { pid: FENCE.pid, processStartTime: 'Sat Oct 18 09:00:00 2026' }], + ['no recorded birth', { pid: FENCE.pid, processStartTime: null }], + ] as const) { + const addressed = makeSeam({ ownership }); + const response = await addressed.admit(addressed.served)(lifecycleRequest({ fence })); + assert.equal(reasonOf(response), 'daemon-instance-mismatch', label); + await settled(); + assert.equal(addressed.drained.mock.calls.length, 0, label); } - // A runtime that cannot prove its own birth cannot be addressed at all, in either mode: a pid alone + // A runtime that cannot prove its own birth cannot be addressed at all, through quiesce: a pid alone // cannot tell this lifetime from the process that recycled its number, and a drain would end a // stranger's daemon. Admission stays open, so that daemon is still serving. - for (const mode of ['quiesce', 'shutdown'] as const) { - const unprovable = makeSeam({ - ownership: mode === 'shutdown' ? 'embedded' : 'detached', - identity: { ...FENCE, processStartTime: null }, - }); - const refused = unprovable.served; - assert.equal( - reasonOf(await unprovable.admit(refused)(lifecycleRequest(mode))), - 'daemon-instance-mismatch', - mode, - ); - await settled(); - assert.equal(unprovable.drained.mock.calls.length, 0, `${mode}: a refusal never drained`); - assert.deepEqual( - await unprovable.admit(refused)(ordinaryRequest()), - { ok: true }, - `${mode}: an unaddressable identity still serves its own requests`, - ); - } + const unprovable = makeSeam({ + ownership: 'detached', + identity: { ...FENCE, processStartTime: null }, + }); + const refused = unprovable.served; + assert.equal( + reasonOf(await unprovable.admit(refused)(lifecycleRequest())), + 'daemon-instance-mismatch', + ); + await settled(); + assert.equal(unprovable.drained.mock.calls.length, 0, 'a refusal never drained'); + assert.deepEqual( + await unprovable.admit(refused)(ordinaryRequest()), + { ok: true }, + 'an unaddressable identity still serves its own requests', + ); }); test('a second lifecycle request after one was accepted is answered as draining, not re-drained', async () => { - const { admit, drained } = makeSeam({ ownership: 'embedded' }); - assert.deepEqual(await admit(async () => ({ ok: true }))(lifecycleRequest('shutdown')), { + const { admit, drained } = makeSeam(); + assert.deepEqual(await admit(async () => ({ ok: true }))(lifecycleRequest()), { ok: true, data: { outcome: 'draining' }, }); assert.deepEqual( - await admit(async () => ({ ok: true }))(lifecycleRequest('shutdown', { requestId: 'second' })), + await admit(async () => ({ ok: true }))(lifecycleRequest({ requestId: 'second' })), { ok: true, data: { outcome: 'draining' } }, ); await settled(); @@ -242,13 +206,8 @@ test('a second lifecycle request after one was accepted is answered as draining, }); test('every request that is not a lifecycle one is refused once a drain was accepted', async () => { - const { admit, served } = makeSeam({ ownership: 'embedded' }); - await admit(served)(lifecycleRequest('shutdown')); + const { admit, served } = makeSeam(); + await admit(served)(lifecycleRequest()); assert.deepEqual(await admit(served)(ordinaryRequest()), daemonShuttingDownResponse()); assert.equal(served.mock.calls.length, 0); }); - -test('the two requests ride distinct commands', () => { - assert.equal(DAEMON_LIFECYCLE_COMMANDS.quiesce, 'daemon_quiesce'); - assert.equal(DAEMON_LIFECYCLE_COMMANDS.shutdown, 'daemon_shutdown'); -}); diff --git a/packages/daemon/src/sessions/server/daemon-idle-reap.ts b/packages/daemon/src/sessions/server/daemon-idle-reap.ts index a47ef7bcc8..a63abc271e 100644 --- a/packages/daemon/src/sessions/server/daemon-idle-reap.ts +++ b/packages/daemon/src/sessions/server/daemon-idle-reap.ts @@ -1,11 +1,10 @@ import { DAEMON_LIFECYCLE_REASONS, DAEMON_SHUTTING_DOWN_REASON, - readDaemonLifecycleMode, + DAEMON_QUIESCE_COMMAND, readDaemonLifecycleFence, type DaemonLifecycleCensus, type DaemonLifecycleFence, - type DaemonLifecycleMode, type DaemonRuntimeOwnership, } from '@agent-device/daemon-contracts/daemon-resolution'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; @@ -32,13 +31,8 @@ import { isUncommittedRepairSession } from '../../core/session-replay-transactio // their timer and listener observation cannot survive a handoff. Active eligible runners retain the // graceful-shutdown adoption policy. // -// - `createDaemonLifecycleAdmission` answers, in place of dispatch, the two requests that ask this -// daemon to leave: `daemon_quiesce` (stop if idle, for a daemon a client may replace) and -// `daemon_shutdown` (drain, for a runtime an embedder owns). Both decide from the same census the -// idle-reap rule reads, so a stop cannot be granted by one owner and refused by the other. -// -// TODO(#3757): the file is named for the older half. A rename to a lifecycle-named module is a -// follow-up, so this PR does not spend a gate's rename tolerance on it. +// - `createDaemonLifecycleAdmission` answers `daemon_quiesce` before dispatch, using the same +// work census as idle reap. Embedded runtimes refuse it; their host owns close(). const DAEMON_IDLE_REAP_DEFAULT_MS = 5 * 60_000; @@ -171,19 +165,9 @@ export function createDaemonIdleReap(params: { } /** - * The one seam that answers both ways a process asks this daemon to leave, in place of dispatch. - * - * ADR 0006: a daemon leaves for a newer one only when it holds nothing, and it decides that itself. - * A client that read "idle" and then signaled would race every other client of the state directory; - * the daemon is the one place the answer and the closing of admission can be one step. - * - * `quiesce` is that stop-if-idle verdict, and it is what a detached daemon answers: a host owns an - * embedded runtime and ends it itself, so a replacement asks the host instead of retiring it. - * `shutdown` is the explicit stop of an embedded runtime, where draining is the point: work in hand - * is canceled rather than refused, because the caller owns this process and asked for it to close. - * - * Both modes share the token check, the instance fence, the answer-before-dispatch rule, and the one - * `drain` the runtime owns, so a stop can never reach teardown by a route the host does not control. + * ADR 0006: a detached daemon accepts replacement only while idle. + * Admission and the work census change in one synchronous step to avoid racing new work. + * Embedded runtimes refuse replacement because their host owns close(). */ export type DaemonLifecycleAdmission = Readonly<{ /** @@ -216,7 +200,7 @@ export function createDaemonLifecycleAdmission(params: { startingUp: () => boolean; /** Starts the drain once, after sending the response. */ drain: () => void; - /** Who owns this process, and so which of the two requests it may answer. */ + /** Who owns this process and may close it. */ ownership: () => DaemonRuntimeOwnership; }): DaemonLifecycleAdmission { let admitting = true; @@ -225,7 +209,7 @@ export function createDaemonLifecycleAdmission(params: { error: normalizeError(error), }); - const leave = (req: DaemonRequest, mode: DaemonLifecycleMode): DaemonResponse => { + const leave = (req: DaemonRequest): DaemonResponse => { if (typeof req.token !== 'string' || !timingSafeStringEqual(req.token, params.token)) { return refuse(new AppError('UNAUTHORIZED', 'Invalid daemon token.')); } @@ -233,7 +217,7 @@ export function createDaemonLifecycleAdmission(params: { // host that owns the state directory may retire the daemon on it. if (req.internal?.publicNetworkOnly) { return refuse( - new AppError('UNAUTHORIZED', `Daemon ${mode} is not available to remote clients.`), + new AppError('UNAUTHORIZED', 'Daemon quiesce is not available to remote clients.'), ); } const fence = readDaemonLifecycleFence(req.input); @@ -244,28 +228,18 @@ export function createDaemonLifecycleAdmission(params: { if (fence.processStartTime !== params.identity.processStartTime) { return refuse(instanceMismatch()); } - // Each mode belongs to one owner: a host ends an embedded runtime it started, and the process that - // started a detached daemon ends it by signalling its pid. Neither asks a stranger. - if (mode === 'shutdown' && params.ownership() !== 'embedded') return refuse(notEmbedded()); - if (mode === 'quiesce' && params.ownership() === 'embedded') return refuse(ownerEmbedded()); + if (params.ownership() === 'embedded') return refuse(ownerEmbedded()); // Admission closes and the census is read in the same synchronous step, so no request can be // admitted between the two; a refusal reopens admission in that same step. if (!admitting) return { ok: true, data: { outcome: 'draining' } }; admitting = false; const census = readCensus(); - // A daemon that is still starting has no drain path yet, so it accepts nothing, and a quiesce - // that finds work in hand refuses too: replacing that daemon would end another client's session. - // A shutdown drains instead of refusing — the caller owns this process and asked for it to - // close, canceling its work included — but only once a drain path exists. The census says which - // of the two applied, so a caller knows whether to retry or to ask again later. - const held = mode === 'quiesce' ? isHeld(census) : census.startingUp; + const held = isHeld(census); if (held) { admitting = true; return refuse(busy(census)); } setImmediate(() => params.drain()); - // One ack shape for both modes, as quiesce's shipped one already is: the census belongs in a - // refusal that a caller must act on, and in the completion record, not in an agreement. return { ok: true, data: { outcome: 'draining' } }; }; @@ -283,8 +257,7 @@ export function createDaemonLifecycleAdmission(params: { return { admit: (handler) => async (req) => { - const mode = readDaemonLifecycleMode(req.command); - if (mode) return leave(req, mode); + if (req.command === DAEMON_QUIESCE_COMMAND) return leave(req); return admitting ? await handler(req) : daemonShuttingDownResponse(); }, }; @@ -301,9 +274,6 @@ function isHeld(census: DaemonLifecycleCensus): boolean { } function busy(census: DaemonLifecycleCensus): AppError { - // The refusal's shape is the one quiesce already shipped: the reason plus the census that explains - // it. A shutdown refuses for one of those counts (`startingUp`) and a stop reads that count rather - // than a second field naming the mode it already asked for. return new AppError('COMMAND_FAILED', 'Daemon is busy and was not asked to leave.', { reason: DAEMON_LIFECYCLE_REASONS.busy, ...census, @@ -316,14 +286,6 @@ function instanceMismatch(): AppError { }); } -function notEmbedded(): AppError { - return new AppError( - 'COMMAND_FAILED', - 'This daemon is not embedder-owned, so it does not accept a shutdown request.', - { reason: DAEMON_LIFECYCLE_REASONS.notEmbedded }, - ); -} - function ownerEmbedded(): AppError { return new AppError( 'COMMAND_FAILED', diff --git a/packages/provider-testmu/src/index.ts b/packages/provider-testmu/src/index.ts index 26a6b75674..4c98d6ffc0 100644 --- a/packages/provider-testmu/src/index.ts +++ b/packages/provider-testmu/src/index.ts @@ -1,7 +1,4 @@ import type { AgentDevicePlugin, ProviderPluginHost } from 'agent-device/plugins'; -import manifest from '../package.json' with { type: 'json' }; - -type ConnectionCapabilities = NonNullable['capabilities']; export type TestmuOptions = { /** Used instead of `LT_USERNAME` and `LT_ACCESS_KEY` when set. */ @@ -45,9 +42,15 @@ function testmu(options: TestmuOptions = {}): AgentDevicePlugin { provider: 'testmu', credentials: { variables: environmentVariables }, connection: { - // The declaration hosts without the version 2 contract read from package.json; the registry - // validates it when the plugin loads. - capabilities: manifest.agentDevicePlugin.connection as ConnectionCapabilities, + capabilities: { + leaseKind: 'direct-device-provider', + requiresAppAttachment: false, + requiresRemoteDaemon: false, + supportsArtifacts: true, + supportsDeferredAppSelection: false, + supportsDirectPortReverse: false, + usesCloudWebDriverLease: true, + }, resolve: async (context) => (await loadConnection(withCredentials(context.host))).resolve(context), verify: async (context) => diff --git a/src/__tests__/daemon-registration-embedded-owner.test.ts b/src/__tests__/daemon-registration-embedded-owner.test.ts index fcf8d02bd6..55784f210e 100644 --- a/src/__tests__/daemon-registration-embedded-owner.test.ts +++ b/src/__tests__/daemon-registration-embedded-owner.test.ts @@ -8,7 +8,7 @@ import { } from '@agent-device/daemon-contracts/daemon-registration-owner'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { - readDaemonShutdownRecord, + readDaemonShutdownReport, resolveDaemonShutdownReportPath, } from '@agent-device/daemon-contracts/daemon-shutdown-report'; import { mkdtempForTestSync } from '@agent-device/test-kit'; @@ -227,94 +227,44 @@ test.each([ownIdentity, deadIdentity])( }, ); -test('a close answers its stop through the release, and an acquisition clears the answer', async () => { - const { paths, owner } = await acquire(); - owner.publish({ ...fields, ownership: 'embedded' }); - - await owner.finish(undefined, { - retainRegistration: false, - unfinished: ['1 provider release left pending'], - }); - const record = readDaemonShutdownRecord(paths.baseDir).completion; - // The disposition is the removal this call performed, not the caller's guess about it. - assert.equal(record?.outcome, 'released'); - assert.deepEqual(record?.unfinished, ['1 provider release left pending']); - assert.equal(record?.pid, process.pid, 'the record names the instance a stop is fenced to'); - // This is the production write, so it is where the file mode is a fact rather than a fixture's - // choice: a state-directory file a stranger could read leaks what a daemon said about its own close. - assert.equal( - fs.statSync(resolveDaemonShutdownReportPath(paths.baseDir)).mode & 0o777, - 0o600, - 'the completion record is published at 0600', - ); - - const before = fs.readFileSync(resolveDaemonShutdownReportPath(paths.baseDir), 'utf8'); - await assert.rejects(owner.finish(undefined, { retainRegistration: true, unfinished: ['late'] })); - assert.equal(fs.readFileSync(resolveDaemonShutdownReportPath(paths.baseDir), 'utf8'), before); - - const next = await acquire(paths); - assert.equal( - readDaemonShutdownRecord(paths.baseDir).completion, - null, - "a new instance must not inherit an answer to somebody else's stop", - ); - await next.owner.finish(); -}); - -test.each(['intentional', 'unlink failure', 'publication failure'] as const)( - 'embedded finalization reports actual registration disposition: %s', +test.each(['intentional', 'unlink failure'] as const)( + 'embedded finalization retains its registration and lock for %s', async (scenario) => { const { paths, owner } = await acquire(); owner.publish({ ...fields, ownership: 'embedded' }); const primary = new Error('registration unlink failed'); const originalUnlink = fs.unlinkSync; - const originalRename = fs.renameSync; - const recordPath = resolveDaemonShutdownReportPath(paths.baseDir); - let registrationPresentAtPublish: boolean | undefined; vi.spyOn(fs, 'unlinkSync').mockImplementation((target) => { if (target === paths.infoPath && scenario === 'unlink failure') throw primary; return originalUnlink(target); }); - vi.spyOn(fs, 'renameSync').mockImplementation((source, target) => { - if (target === recordPath) { - assert.equal(fs.existsSync(paths.lockPath), true); - registrationPresentAtPublish = fs.existsSync(paths.infoPath); - if (scenario === 'publication failure') throw new Error('publication failed'); - } - return originalRename(source, target); - }); - const finished = owner.finish(undefined, { - retainRegistration: scenario === 'intentional', - unfinished: ['unfinished work'], - }); + const finished = owner.finish(undefined, { retainRegistration: scenario === 'intentional' }); if (scenario === 'unlink failure') await assert.rejects(finished, (error) => error === primary); - else await finished; - const retained = scenario !== 'publication failure'; - assert.equal(registrationPresentAtPublish, retained); - assert.equal(fs.existsSync(paths.infoPath), retained); - assert.equal(fs.existsSync(paths.lockPath), retained); - assert.equal(readDaemonShutdownRecord(paths.baseDir).report, null); - assert.equal( - readDaemonShutdownRecord(paths.baseDir).completion?.outcome ?? null, - retained ? 'retained' : null, - ); - const next = await tryAcquireDaemonRegistration(paths); - assert.equal(next.status, retained ? 'busy' : 'acquired'); - if (next.status === 'acquired') await next.owner.finish(); + else assert.deepEqual(await finished, { state: 'retained' }); + assert.equal(fs.existsSync(paths.infoPath), true); + assert.equal(fs.existsSync(paths.lockPath), true); + assert.equal(readDaemonShutdownReport(paths.baseDir), null); + assert.equal((await tryAcquireDaemonRegistration(paths)).status, 'busy'); }, ); -test('a spent embedded owner cannot overwrite a same-host successor', async () => { +test('a spent embedded owner cannot overwrite a same-host successor report', async () => { const { paths, owner } = await acquire(); owner.publish({ ...fields, ownership: 'embedded' }); - await owner.finish(undefined, { retainRegistration: false, unfinished: [] }); + await owner.finish(); const next = await acquire(paths); next.owner.publish({ ...fields, ownership: 'embedded', token: 'next-runtime' }); - await next.owner.finish(undefined, { retainRegistration: true, unfinished: ['next work'] }); - const before = fs.readFileSync(resolveDaemonShutdownReportPath(paths.baseDir), 'utf8'); - await assert.rejects(owner.finish(undefined, { retainRegistration: false, unfinished: [] })); - assert.equal(fs.readFileSync(resolveDaemonShutdownReportPath(paths.baseDir), 'utf8'), before); - assert.equal(readDaemonShutdownRecord(paths.baseDir).completion?.token, 'next-runtime'); + const outcome = { + providerReleases: { released: [], pending: [] }, + claims: { released: [], orphaned: [], superseded: [], unattributable: [] }, + }; + await next.owner.finish(outcome, { retainRegistration: true }); + const file = resolveDaemonShutdownReportPath(paths.baseDir); + const before = fs.readFileSync(file, 'utf8'); + assert.equal(fs.statSync(file).mode & 0o777, 0o600); + await assert.rejects(owner.finish(outcome)); + assert.equal(fs.readFileSync(file, 'utf8'), before); + assert.deepEqual(readDaemonShutdownReport(paths.baseDir), outcome); assert.equal(fs.existsSync(paths.infoPath), true); assert.equal(fs.existsSync(paths.lockPath), true); }); diff --git a/src/cli/commands/__tests__/daemon.test.ts b/src/cli/commands/__tests__/daemon.test.ts index 069eb7b05a..fbedff2757 100644 --- a/src/cli/commands/__tests__/daemon.test.ts +++ b/src/cli/commands/__tests__/daemon.test.ts @@ -385,11 +385,6 @@ test('daemon status names a record whose owner it cannot recognize', async () => expect(render()).toContain('pid 321, owner-not-recognized'); }); -// A host-owned record never reaches the signal path a detached stop uses: it is answered by a shutdown -// request over the record's own transport, so a record that states no usable transport is refused before -// anything is asked or signaled, and the record stays. What a reachable embedded stop answers, and what -// the retirement raises for a live owner without signaling it, are pinned at those seams by -// src/daemon-client-embedded-stop.test.ts and src/__tests__/daemon-registration-embedded-owner.test.ts. test('daemon stop routes a host-owned record through the one router and never the signal path', async () => { const stateDir = mkdtempForTestSync('agent-device-daemon-command-embedded-'); fs.writeFileSync( @@ -406,7 +401,7 @@ test('daemon stop routes a host-owned record through the one router and never th flags: { help: false, json: false, stateDir, version: false }, client: {} as never, }), - ).rejects.toMatchObject({ details: { reason: 'daemon-embedded-stop-unreachable' } }); + ).rejects.toMatchObject({ details: { reason: 'daemon-owner-embedded' } }); // The embedded route owns this record end to end: no detached retirement, no cleanup, no output. expect(mocks.stopDaemon).not.toHaveBeenCalled(); diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index a51b85ef8f..d3027faaf0 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -480,7 +480,7 @@ Runner and daemon lifecycle (applies to simulators too): 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. A client starting a local daemon waits while the daemon process stays alive and keeps publishing startup progress, so an overloaded host makes startup slower, not failed. It stops at a ceiling (default 120 s; set AGENT_DEVICE_DAEMON_STARTUP_TIMEOUT_MS to a positive integer number of milliseconds to override) and names the outcome in error details.reason: startup-exited (the process ended before registering), startup-stalled (alive, no startup progress for a fixed 60 s window the ceiling variable does not change), or startup-ceiling (a startup was still live when the ceiling passed — this client's daemon or another client's; the client stops its own launch and never signals another client's live daemon. Only a progressing own launch outlasts the 60 s stall window, so a ceiling set below 60 s also ends a silent own launch this way). details.phases carries the startup phase timings it observed and details.startupTimeoutMs the effective ceiling. Branch on details.reason, never on the message. The variable is read only when a client must start a daemon: a value that is not a positive integer fails with INVALID_ARGS before the daemon spawns, and a client that only attaches to or reuses a running daemon never reads it. A command that needs the shared local daemon starts it for its state dir when none runs (device status and device release --stale never contact one). When the running daemon comes from another version or build, the command replaces it only if the daemon reports no work: no in-flight request, session, repair session, or live lease (a streaming resumable upload and a host human-control hold that names no lease are not counted); otherwise it fails with details.reason daemon-busy and leaves the daemon alone. A daemon that agreed to leave is waited for up to 60 s and never signaled while it drains; one still running then fails with details.reason daemon-drain-timeout unless it answers nothing and is provably a daemon process, which is force-retired. A daemon from a release that predates this check is never replaced and fails with daemon-incompatible. End a busy or older daemon deliberately with agent-device daemon stop --state-dir . Node clients pick daemonLifecycle 'attach' (never starts or replaces a daemon; fails with daemon-not-running or daemon-incompatible) or 'managed' (the default until 0.22), and ensureDaemon() starts or replaces one explicitly. - daemon stop --state-dir stops a detached daemon with process signals after verifying its identity. For a host-owned runtime, it sends an authenticated shutdown request over the registered local transport and waits for the close report without signaling the host. daemon status shows host-owned or owner-not-recognized. Managed clients reuse a matching host-owned runtime. Takeover, drain, and timeout recovery ask the central retirement, which judges the record's owner once: a live host-owned or unrecognized owner is refused without a signal, while a record whose owner provably exited is retired like any abandoned one so the state directory stays usable. Stop can also ask a readable record with unrecognized ownership: an embedded runtime can close itself, but a detached answer fails with details.reason daemon-not-embedded and a record-repair hint, without signaling. For these shutdown requests, a record without usable transport or token fails with daemon-embedded-stop-unreachable. An unconfirmed close, including after connection failure, fails with daemon-shutdown-unconfirmed. Without --clean, an unreadable daemon.json fails stop with daemon-record-unreadable. Detached stop and cleanup use the lifetime and ownership from one registration read. A retained close returns stopped: true with warnings and cleanupConfidence: unknown (exit 0): it stopped serving, but resource cleanup is unconfirmed. --clean requires proven detached ownership; host-owned, unrecognized and unreadable records fail with daemon-clean-not-detached because that cleanup walks the owner's whole process tree. + daemon stop --state-dir stops a detached daemon with process signals after verifying its identity. For an embedded runtime, stop refuses with details.reason daemon-owner-embedded and a hint naming the host PID. Close that runtime in its host process with the handle returned by startAgentDevice(). Stop refuses unrecognized ownership with daemon-owner-unrecognized and sends no request or signal. Without --clean, an unreadable daemon.json fails with daemon-record-unreadable. daemon status shows host-owned or owner-not-recognized. Managed clients reuse a matching embedded runtime. Takeover, drain, and timeout recovery refuse a live embedded or unrecognized owner without a signal; a provably exited owner is retired so the state directory stays usable. Detached stop and cleanup use the lifetime and ownership from one registration read. --clean requires proven detached ownership; embedded, unrecognized and unreadable records fail with daemon-clean-not-detached because that cleanup walks the owner's whole process tree. 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). diff --git a/src/daemon-client-embedded-stop.test.ts b/src/daemon-client-embedded-stop.test.ts index d4b8267b07..bc9c6092df 100644 --- a/src/daemon-client-embedded-stop.test.ts +++ b/src/daemon-client-embedded-stop.test.ts @@ -1,680 +1,105 @@ -// One stop, end to end: a runtime a host process owns, the record it publishes, and the stop an outside -// caller performs. The host here is the test process itself, which is what makes the strongest assertion -// in this file: a stop that ever reached for a signal would take the test runner down with it. - import fs from 'node:fs'; -import net from 'node:net'; -import { once } from 'node:events'; -import { spawn } from 'node:child_process'; +import { spawnSync } from 'node:child_process'; import { afterEach, expect, test, vi } from 'vitest'; -import { startDaemonRuntime } from '@agent-device/daemon'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { readDaemonStopIdentity } from '@agent-device/daemon-contracts/daemon-registration'; -import { stopDaemon } from '@agent-device/daemon-contracts/daemon-stop'; -import { - daemonShutdownCompletionAnswers, - readDaemonShutdownRecord, - resolveDaemonShutdownReportPath, -} from '@agent-device/daemon-contracts/daemon-shutdown-report'; -import { readProcessStartTime } from '@agent-device/host-kit/process'; -import { - closeLoopbackServer, - createTestHostEnvironment, - listenOnLoopback, - mkdtempForTestSync, -} from '@agent-device/test-kit'; -import { createDaemonRuntimeDeps } from './daemon-composition.ts'; import { stopRecordedDaemon } from './daemon-client/daemon-client-stop.ts'; -import { readDaemonInfo } from './daemon-client/daemon-client-metadata.ts'; -import { sendRequest } from './daemon-client/daemon-client-transport.ts'; import * as transport from './daemon-client/daemon-client-transport.ts'; -import * as processWait from '@agent-device/daemon-contracts/daemon-process'; -import { AppError } from '@agent-device/kernel/errors'; - -const stopProbe = vi.hoisted(() => ({ - request: undefined as Promise | undefined, - owner: undefined as Promise | undefined, - entered: undefined as (() => void) | undefined, -})); -afterEach(() => { - vi.restoreAllMocks(); - vi.useRealTimers(); - stopProbe.request = undefined; - stopProbe.owner = undefined; - stopProbe.entered = undefined; -}); - -vi.mock('./platform-runtime.ts', () => ({ - androidObservation: {}, - createRequestPlatformProviders: () => ({ - run: async (_context: unknown, task: () => Promise) => await task(), - }), - createPlatformRuntimeGateway: () => ({ - applicationLifecycle: { - recoverStartupResources: async () => {}, - detachForDaemonShutdown: async () => [], - finalizeDaemonShutdown: async () => [], - }, - inspectFacts: async () => { - throw new Error('unused'); - }, - bind: async () => { - throw new Error('unused'); - }, - shutdown: async () => { - await stopProbe.owner; - return []; - }, - }), - createPlatformDeviceInventoryGateways: () => ({}), -})); - -vi.mock('./provider-device-runtimes.ts', async () => ({ - createDaemonProviderRuntimeComposition: async () => ({ - runtimes: [], - platformModules: [], - // The composition is the one plugin-cleanup owner: its close answers with every plugin's outcomes. - shutdown: async () => [], - inventory: await ( - await import('./__tests__/test-utils/builtin-plugin-inventory.ts') - ).builtinPluginInventory(), - }), -})); - -/** - * A record whose owner field this build cannot place — the state a newer build's vocabulary, or a hand - * edit, produces. A running runtime is never in this state: it always knows whether it owns itself, so - * `unknown` is a reader's verdict about a file. The record is rewritten under a live runtime to produce - * exactly that, with everything else about the instance left intact. - */ -async function bootRuntimeWithUnplaceableRecordOwner(ownership: 'embedded' | 'detached') { - const booted = await bootRuntime(ownership); - const record = JSON.parse(fs.readFileSync(booted.paths.infoPath, 'utf8')) as Record< - string, - unknown - >; - record.ownership = 'sideways'; - fs.writeFileSync(booted.paths.infoPath, JSON.stringify(record), { mode: 0o600 }); - expect(readDaemonStopIdentity(booted.paths.infoPath).ownership).toBe('unknown'); - return booted; -} - -async function bootRuntime(ownership: 'embedded' | 'detached' = 'embedded') { - const stateDir = mkdtempForTestSync('agent-device-embedded-stop-'); - const runtime = await startDaemonRuntime({ - host: createTestHostEnvironment({ - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - }, - }), - ownership, - shutdownBudgets: { requestDrainMs: 25, ownerMs: 25 }, - stderr: { write: () => {} }, - stdout: { write: () => {} }, - composeDeps: async (input) => { - const deps = await createDaemonRuntimeDeps(input); - return { - ...deps, - requestRouter: { - ...deps.requestRouter, - providerDeviceRuntimeScope: async (task) => { - if (stopProbe.request) { - stopProbe.entered?.(); - await stopProbe.request; - } - return await task(); - }, - }, - }; - }, - }); - return { stateDir, paths: resolveDaemonPaths(stateDir), runtime }; -} - -/** The stop as the CLI performs it: read the record once, then route on what it states. */ -function stopViaSocket(paths: ReturnType) { - return stopRecordedDaemon(readDaemonStopIdentity(paths.infoPath), { - paths, - host: createTestHostEnvironment(), - }); -} - -/** A port that was bound and given back, so a request to it is refused rather than served. */ -async function closedLoopbackPort(): Promise { - const server = net.createServer(); - const port = await listenOnLoopback(server); - await closeLoopbackServer(server); - return port; -} - -/** The lifetime a record can name for a child that has only just been spawned. */ -async function startTimeOf(pid: number): Promise { - for (let attempt = 0; attempt < 100; attempt += 1) { - const startTime = readProcessStartTime(pid); - if (startTime?.trim()) return startTime; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - throw new Error(`child ${pid} never became readable`); -} - -/** - * The signals this stop sent at a process. `0` is the liveness probe and is not one; a `kill(pid)` with - * no signal argument is SIGTERM, so an omitted signal counts as the signal it really is. - */ -function hostSignals(killSpy: { mock: { calls: Array } }) { - return killSpy.mock.calls.filter(([, signal]) => signal !== 0); -} - -test('a host-owned runtime publishes the owner a stop has to respect', async () => { - const { stateDir, paths, runtime } = await bootRuntime(); - try { - const record = JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')) as { - pid: number; - ownership?: string; - }; - // The record names the host's own process, which is why the signal path is closed here: stopping - // this runtime means asking it, not ending the program that asked. - expect(record.pid).toBe(process.pid); - expect(record.ownership).toBe('embedded'); - expect(readDaemonStopIdentity(paths.infoPath).ownership).toBe('embedded'); - expect(readDaemonInfo(paths.infoPath)?.ownership).toBe('embedded'); - } finally { - await runtime.close(); - fs.rmSync(stateDir, { recursive: true, force: true }); - } -}); - -test('stopping a host-owned runtime asks it to leave and reports what its close said', async () => { - const { stateDir, paths, runtime } = await bootRuntime(); - const signals = vi.spyOn(process, 'kill'); - try { - // The host here stays alive the whole time, which is the case a signal-based stop cannot answer: the - // runtime closes inside a process that outlives it, so the only proof of the close is the record that - // close writes. Reading it is what ends this wait, not a timeout. - const startedAt = Date.now(); - const stopped = await stopViaSocket(paths); +afterEach(() => vi.restoreAllMocks()); - expect(stopped).toMatchObject({ - stopped: true, - mode: 'graceful', - cleanupConfidence: 'known', - warnings: [], - }); - expect(Date.now() - startedAt).toBeLessThan(10_000); - // The runtime left by its own close, and the record it wrote is what the stop waited for. - await expect(runtime.closed).resolves.toMatchObject({ requests: { abandoned: 0 } }); - const completion = readDaemonShutdownRecord(paths.baseDir).completion; - expect(completion?.outcome).toBe('released'); - expect(completion?.unfinished).toEqual([]); - // Every stop addressed to that instance reads this answer, and a stop addressed to another one - // reads nothing — a recycled pid must not inherit an answer it was not part of. - expect( - daemonShutdownCompletionAnswers(completion!, { - pid: completion!.pid, - processStartTime: completion!.processStartTime, - token: completion!.token, - }), - ).toBe(true); - expect( - daemonShutdownCompletionAnswers(completion!, { - pid: 42, - processStartTime: 'x', - token: 'other', +test.each(['embedded', 'future-owner'])( + 'stop refuses %s ownership before any request or signal, even without transport or birth proof', + async (ownership) => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-stop-owner-')); + const record = JSON.stringify({ pid: process.pid, ownership }); + fs.writeFileSync(paths.infoPath, record); + const send = vi.spyOn(transport, 'sendRequest'); + const signals = vi.spyOn(process, 'kill'); + await expect( + stopRecordedDaemon(readDaemonStopIdentity(paths.infoPath), { + paths, + host: createTestHostEnvironment(), }), - ).toBe(false); - expect(fs.existsSync(paths.infoPath)).toBe(false); - expect(hostSignals(signals)).toEqual([]); - } finally { - signals.mockRestore(); - await runtime.close(); - fs.rmSync(stateDir, { recursive: true, force: true }); - } -}); - -test('a stop performed without the shutdown route is refused and leaves the runtime serving', async () => { - const { stateDir, paths, runtime } = await bootRuntime(); - const signals = vi.spyOn(process, 'kill'); - - try { - // A caller that goes straight to the retirement funnel skips the shutdown request the embedded route - // sends, and what it has left is a signal — exactly what must never reach a host process. `observed` - // carries the owner the caller read, and the funnel refuses on it rather than proving an exit it - // cannot prove. - const observed = { - pid: process.pid, - startTime: readProcessStartTime(process.pid), - ownership: 'embedded' as const, - }; - const refusal = await stopDaemon({ paths, observed }).then( - () => null, - (error: unknown) => error, - ); - expect(refusal).toMatchObject({ + ).rejects.toMatchObject({ code: 'COMMAND_FAILED', - details: { retirement: { reason: 'embedded-owner' } }, - }); - - expect(hostSignals(signals)).toEqual([]); - // The refusal changed nothing: the runtime the host owns is still there to close. - expect(readDaemonInfo(paths.infoPath)?.pid).toBe(process.pid); - expect(await runtime.close()).toMatchObject({ requests: { abandoned: 0 } }); - } finally { - signals.mockRestore(); - await runtime.close(); - fs.rmSync(stateDir, { recursive: true, force: true }); - } -}); - -test('two stops of one runtime both get the answer its close gives', async () => { - const { stateDir, paths, runtime } = await bootRuntime(); - const signals = vi.spyOn(process, 'kill'); - - try { - // Started in the same tick, so both stops read the same embedded record and neither can be told it - // is already gone. The runtime admits one shutdown and answers the other with the same - // `draining` ack, so both are waiting on the one completion record this instance writes. - const [first, second] = await Promise.all([stopViaSocket(paths), stopViaSocket(paths)]); - - for (const [label, stopped] of [ - ['first', first], - ['second', second], - ] as const) { - expect(stopped, label).toMatchObject({ - stopped: true, - mode: 'graceful', - cleanupConfidence: 'known', - }); - expect(stopped.warnings, label).toEqual([]); - } - await expect(runtime.closed).resolves.toMatchObject({ requests: { abandoned: 0 } }); - expect(fs.existsSync(paths.infoPath)).toBe(false); - expect(hostSignals(signals)).toEqual([]); - } finally { - signals.mockRestore(); - await runtime.close(); - fs.rmSync(stateDir, { recursive: true, force: true }); - } -}); - -test.each(['request', 'owner'] as const)( - 'direct close retaining %s work answers two later stops with an old timestamp field', - async (kind) => { - const { stateDir, paths, runtime } = await bootRuntime(); - const signals = vi.spyOn(process, 'kill'); - let release = () => {}; - const held = new Promise((resolve) => { - release = resolve; + details: { + reason: ownership === 'embedded' ? 'daemon-owner-embedded' : 'daemon-owner-unrecognized', + daemon: { pid: process.pid }, + }, }); - let pending: Promise | undefined; - if (kind === 'request') { - stopProbe.request = held; - const entered = new Promise((resolve) => { - stopProbe.entered = resolve; - }); - pending = sendRequest( - readDaemonInfo(paths.infoPath)!, - { token: runtime.token, command: 'session_list', positionals: [], session: 'default' }, - 'http', - paths, - 5_000, - { host: createTestHostEnvironment(), timeoutRecovery: 'none' }, - ).catch(() => {}); - await entered; - } else { - stopProbe.owner = held; - } - try { - await runtime.close(); - const file = resolveDaemonShutdownReportPath(stateDir); - const record = JSON.parse(fs.readFileSync(file, 'utf8')); - expect(record.completion.outcome).toBe('retained'); - fs.writeFileSync( - file, - JSON.stringify({ - ...record, - completion: { ...record.completion, writtenAtMs: Date.now() - 61_000 }, - }), - ); - for (let attempt = 0; attempt < 2; attempt++) { - const stopped = await stopViaSocket(paths); - expect(stopped).toMatchObject({ - stopped: true, - mode: 'graceful', - cleanupConfidence: 'unknown', - providerReleases: { status: 'completed' }, - }); - expect(stopped.warnings.join(' ')).toContain( - kind === 'request' ? 'ignored cancellation' : 'platform owners', - ); - expect(stopped.warnings.join(' ')).toContain('keeps its registration'); - } - expect(fs.existsSync(paths.infoPath)).toBe(true); - expect(fs.existsSync(paths.lockPath)).toBe(true); - expect(hostSignals(signals)).toEqual([]); - } finally { - release(); - await pending; - await runtime.close(); - fs.rmSync(stateDir, { recursive: true, force: true }); - } + expect(send).not.toHaveBeenCalled(); + expect(signals).not.toHaveBeenCalled(); + expect(fs.readFileSync(paths.infoPath, 'utf8')).toBe(record); }, ); -/** - * A record naming a child process that is about to be killed, with a port nothing listens on: the shape a - * host leaves when it dies, or when its age makes its record the only thing a later stop can read. - */ -async function plantDeadWriterRecord(paths: ReturnType) { - const writer = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60_000)'], { - stdio: 'ignore', - }); - const pid = writer.pid ?? -1; - const startTime = await startTimeOf(pid); - fs.writeFileSync( - paths.infoPath, - JSON.stringify({ - pid, - processStartTime: startTime, - token: 'local-secret', - // A port nothing listens on: the ask cannot reach a runtime that is already gone. - port: await closedLoopbackPort(), - ownership: 'embedded', - }), - ); - writer.kill('SIGKILL'); - await once(writer, 'exit'); - return { pid, processStartTime: startTime, token: 'local-secret' }; -} - -test('a stop whose runtime died before writing its report does not wait on a dead writer', async () => { - // A host killed between removing the registration and publishing its completion record leaves a stop - // with no writer. The lifetime is the second piece of evidence, and it has to answer: sitting out the - // whole budget to report an unconfirmed failure would describe a close that had plainly ended. - const stateDir = mkdtempForTestSync('agent-device-embedded-stop-dead-writer-'); - const paths = resolveDaemonPaths(stateDir); - await plantDeadWriterRecord(paths); +test('stop refuses an unreadable record without a request or signal', async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-stop-unreadable-')); + fs.writeFileSync(paths.infoPath, '{not json'); + const send = vi.spyOn(transport, 'sendRequest'); const signals = vi.spyOn(process, 'kill'); - - try { - const startedAt = Date.now(); - const stopped = await stopViaSocket(paths); - - expect(stopped).toMatchObject({ stopped: false, mode: 'not-running' }); - // Proof, not a timeout: the budget this stop would otherwise sit out is a minute long. - expect(Date.now() - startedAt).toBeLessThan(10_000); - expect(hostSignals(signals)).toEqual([]); - } finally { - signals.mockRestore(); - fs.rmSync(stateDir, { recursive: true, force: true }); - } + await expect( + stopRecordedDaemon(readDaemonStopIdentity(paths.infoPath), { + paths, + host: createTestHostEnvironment(), + }), + ).rejects.toMatchObject({ details: { reason: 'daemon-record-unreadable' } }); + expect(send).not.toHaveBeenCalled(); + expect(signals).not.toHaveBeenCalled(); + expect(fs.readFileSync(paths.infoPath, 'utf8')).toBe('{not json'); }); -test.each(['retained', 'released'] as const)( - 'a dead writer with %s completion and an old timestamp field reports its actual close', - async (outcome) => { - // A retained record answers a later stop while the lifetime that wrote it is still live. Once that - // process is gone, nothing holds the state directory any more, and reporting a graceful stop would - // send the operator away from the one thing left to do: the takeover path that clears the stale record. - const stateDir = mkdtempForTestSync('agent-device-embedded-stop-dead-retention-'); - const paths = resolveDaemonPaths(stateDir); - const instance = await plantDeadWriterRecord(paths); - fs.writeFileSync( - resolveDaemonShutdownReportPath(paths.baseDir), - `${JSON.stringify({ - completion: { - ...instance, - outcome, - unfinished: ['1 in-flight request ignored cancellation'], - }, - writtenAtMs: Date.now() - 60_000 - 1000, - })}\n`, - { mode: 0o600 }, - ); +test.each(['embedded', 'future-owner'])( + 'stop refuses %s ownership without a PID', + async (ownership) => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-stop-no-pid-')); + const record = JSON.stringify({ ownership }); + fs.writeFileSync(paths.infoPath, record); + const send = vi.spyOn(transport, 'sendRequest'); const signals = vi.spyOn(process, 'kill'); - - try { - const startedAt = Date.now(); - const stopped = await stopViaSocket(paths); - - expect(stopped).toMatchObject( - outcome === 'retained' - ? { stopped: false, mode: 'not-running' } - : { stopped: true, mode: 'graceful', cleanupConfidence: 'unknown' }, - ); - expect(Date.now() - startedAt).toBeLessThan(10_000); - expect(hostSignals(signals)).toEqual([]); - } finally { - signals.mockRestore(); - fs.rmSync(stateDir, { recursive: true, force: true }); - } - }, -); - -// A record can claim a host-owned runtime and still be unusable: a file that is not JSON, or one that -// names no transport. Neither is an absence, and `not-running` would tell the operator to move on while -// a host may be serving on that very state directory. The two refusals are separate because the fixes -// are: a file that cannot be parsed says nothing about who owns the process, so nothing is even addressed. -test('a stop of a record it cannot address refuses instead of reporting nothing runs', async () => { - for (const [label, record, reason, recordState] of [ - ['unreadable', '{not json', 'daemon-record-unreadable', undefined], - [ - 'no transport', - JSON.stringify({ - pid: process.pid, - processStartTime: readProcessStartTime(process.pid), - ownership: 'embedded', + await expect( + stopRecordedDaemon(readDaemonStopIdentity(paths.infoPath), { + paths, + host: createTestHostEnvironment(), }), - 'daemon-embedded-stop-unreachable', - 'incomplete', - ], - ] as const) { - const stateDir = mkdtempForTestSync('agent-device-embedded-stop-record-'); - const paths = resolveDaemonPaths(stateDir); - fs.mkdirSync(paths.baseDir, { recursive: true }); - fs.writeFileSync(paths.infoPath, record, { mode: 0o600 }); - const signals = vi.spyOn(process, 'kill'); - - try { - const refusal = await stopViaSocket(paths).then( - () => null, - (error: unknown) => error, - ); - expect(refusal, label).toMatchObject({ - code: 'COMMAND_FAILED', - details: { - reason, - stateDir: paths.baseDir, - ...(recordState ? { record: recordState } : {}), - }, - }); - expect(hostSignals(signals), label).toEqual([]); - // Refusing changes nothing: whatever owns that record still does. - expect(fs.readFileSync(paths.infoPath, 'utf8'), label).toBe(record); - } finally { - signals.mockRestore(); - fs.rmSync(stateDir, { recursive: true, force: true }); - } - } -}); - -// A record this build cannot place still gets the authenticated request rather than a signal: asking is -// free, and the runtime's own answer says whose process this is. What the answer must not do is name -// `daemon stop`, because that command refuses to signal a record whose owner it cannot prove either — the -// operator needs the record repair, which is the only fix for a file that states nothing usable. -test('a detached answer against an unreadable owner names the record repair, not daemon stop', async () => { - const { stateDir, paths, runtime } = await bootRuntimeWithUnplaceableRecordOwner('detached'); - const signals = vi.spyOn(process, 'kill'); - - try { - const refusal = await stopViaSocket(paths).then( - () => null, - (error: unknown) => error, - ); - // The runtime knows its own owner, and it is not a host: it answers the shutdown request the way a - // detached daemon does. - expect(refusal).toMatchObject({ - code: 'COMMAND_FAILED', - details: { reason: 'daemon-not-embedded' }, - }); - const hint = String((refusal as { details: { hint: string } }).details.hint); - expect(hint).toMatch(/daemon\.json/); - expect(hint).not.toMatch(/daemon stop/); - expect(hostSignals(signals)).toEqual([]); - expect(await runtime.close()).toMatchObject({ requests: { abandoned: 0 } }); - } finally { - signals.mockRestore(); - await runtime.close(); - fs.rmSync(stateDir, { recursive: true, force: true }); - } -}); - -// The same rewritten record under a runtime that really is host-owned. Asking is still the right move: -// the runtime answers for itself, agrees, and closes its own process. Nothing here signals, and the stop -// does not need the record to say who owns it — the answer is the proof. -test('an unrecognized owner under a host-owned runtime leaves by its own shutdown request', async () => { - const { stateDir, paths, runtime } = await bootRuntimeWithUnplaceableRecordOwner('embedded'); - const signals = vi.spyOn(process, 'kill'); - - try { - expect(await stopViaSocket(paths)).toMatchObject({ stopped: true, mode: 'graceful' }); - await expect(runtime.closed).resolves.toBeTruthy(); - expect(hostSignals(signals)).toEqual([]); - } finally { - signals.mockRestore(); - await runtime.close(); - fs.rmSync(stateDir, { recursive: true, force: true }); - } -}); - -test('a second stop of a host-owned runtime reports nothing left to stop', async () => { - const { stateDir, paths, runtime } = await bootRuntime(); - - try { - expect(await stopViaSocket(paths)).toMatchObject({ stopped: true, mode: 'graceful' }); - await expect(runtime.closed).resolves.toBeTruthy(); - - // The instance is gone, so this is not a refused stop or an unconfirmed one: there is nothing there. - expect(await stopViaSocket(paths)).toMatchObject({ - stopped: false, - mode: 'not-running', - }); - } finally { - await runtime.close(); - fs.rmSync(stateDir, { recursive: true, force: true }); - } -}); - -test.each(['no answer', 'same-host successor'] as const)( - 'a live host with %s never supplies false completion', - async (scenario) => { - const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-stop-fenced-')); - const original = { - pid: process.pid, - processStartTime: readProcessStartTime(process.pid)!, - token: 'runtime-a', - port: 4210, - ownership: 'embedded', - }; - fs.writeFileSync(paths.infoPath, JSON.stringify(original)); - const observed = readDaemonStopIdentity(paths.infoPath); - const successor = { ...original, token: 'runtime-b' }; - fs.writeFileSync(paths.infoPath, JSON.stringify(successor)); - vi.spyOn(processWait, 'waitForDaemonExit').mockResolvedValue({ exited: false, elapsedMs: 0 }); - const send = vi.spyOn(transport, 'sendRequest').mockImplementation(async (info, request) => { - expect(info.token).toBe('runtime-a'); - expect(request.token).toBe('runtime-a'); - if (scenario === 'same-host successor') - fs.writeFileSync( - resolveDaemonShutdownReportPath(paths.baseDir), - JSON.stringify({ - completion: { ...successor, outcome: 'released', unfinished: [] }, - providerReleases: { released: [{ leaseId: 'successor-only' }], pending: [] }, - }), - ); - throw new AppError('COMMAND_FAILED', 'connection refused', { - reason: 'daemon_transport_failure', - }); - }); - const signals = vi.spyOn(process, 'kill'); - vi.useFakeTimers(); - const stop = stopRecordedDaemon(observed, { paths, host: createTestHostEnvironment() }); - const result = expect(stop).rejects.toMatchObject({ - details: { reason: 'daemon-shutdown-unconfirmed' }, + ).rejects.toMatchObject({ + details: { + reason: ownership === 'embedded' ? 'daemon-owner-embedded' : 'daemon-owner-unrecognized', + hint: + ownership === 'embedded' + ? 'The runtime is owned by its host process. Close it there with the handle returned by startAgentDevice(). If the host has exited, call ensureDaemon() for this state directory to recover its abandoned registration.' + : expect.stringContaining('check what owns this registration'), + }, }); - await vi.advanceTimersByTimeAsync(60_000); - await result; - expect(send).toHaveBeenCalledTimes(1); - expect(JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')).token).toBe('runtime-b'); - expect(hostSignals(signals)).toEqual([]); + expect(send).not.toHaveBeenCalled(); + expect(signals).not.toHaveBeenCalled(); + expect(fs.readFileSync(paths.infoPath, 'utf8')).toBe(record); }, ); -test.each([true, false])('startup retry requires the typed busy reason (%s)', async (typed) => { - const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-stop-startup-')); - const original = { - pid: process.pid, - processStartTime: readProcessStartTime(process.pid)!, - token: 'starting-runtime', - port: 4210, - ownership: 'embedded', - }; - fs.writeFileSync(paths.infoPath, JSON.stringify(original)); - const error = new AppError('COMMAND_FAILED', 'The daemon is still starting up.', { - ...(typed ? { reason: 'daemon-busy' } : {}), - startingUp: true, - inFlightRequests: 0, - sessions: 0, - repairSessions: 0, - leases: 0, - }); - const send = vi.spyOn(transport, 'sendRequest').mockImplementation(async () => { - fs.writeFileSync( - resolveDaemonShutdownReportPath(paths.baseDir), - JSON.stringify({ - completion: { ...original, outcome: 'released', unfinished: [] }, - providerReleases: { released: [{ leaseId: 'original-close' }], pending: [] }, - }), - ); - throw error; - }); - vi.useFakeTimers(); - const stop = stopViaSocket(paths); - const checked = typed - ? expect(stop).resolves.toMatchObject({ - stopped: true, - mode: 'graceful', - providerReleases: { released: [{ leaseId: 'original-close' }] }, - }) - : expect(stop).rejects.toBe(error); - await vi.advanceTimersByTimeAsync(250); - await checked; - expect(send).toHaveBeenCalledTimes(1); -}); - -test('an unreachable embedded record without a birth fails promptly without guessing its lifetime', async () => { - const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-stop-no-birth-')); - const record = JSON.stringify({ - pid: process.pid, - token: 'unreachable-runtime', - httpPort: await closedLoopbackPort(), - ownership: 'embedded', - }); +test('stop refuses an embedded registration even after its host exits', async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-stop-exited-host-')); + const host = spawnSync(process.execPath, ['-e', '']); + expect(host.status).toBe(0); + const record = JSON.stringify({ pid: host.pid, ownership: 'embedded' }); fs.writeFileSync(paths.infoPath, record); + const send = vi.spyOn(transport, 'sendRequest'); const signals = vi.spyOn(process, 'kill'); - const startedAt = Date.now(); - try { - await expect(stopViaSocket(paths)).rejects.toMatchObject({ - code: 'COMMAND_FAILED', - details: { reason: 'daemon-shutdown-unconfirmed' }, - }); - expect(Date.now() - startedAt).toBeLessThan(5_000); - expect(fs.readFileSync(paths.infoPath, 'utf8')).toBe(record); - expect(hostSignals(signals)).toEqual([]); - } finally { - signals.mockRestore(); - } + await expect( + stopRecordedDaemon(readDaemonStopIdentity(paths.infoPath), { + paths, + host: createTestHostEnvironment(), + }), + ).rejects.toMatchObject({ + details: { + reason: 'daemon-owner-embedded', + daemon: { pid: host.pid }, + hint: expect.stringContaining('call ensureDaemon() for this state directory'), + }, + }); + expect(send).not.toHaveBeenCalled(); + expect(signals).not.toHaveBeenCalled(); + expect(fs.readFileSync(paths.infoPath, 'utf8')).toBe(record); }); diff --git a/src/daemon-client-released-close.test.ts b/src/daemon-client-released-close.test.ts deleted file mode 100644 index 689b27725c..0000000000 --- a/src/daemon-client-released-close.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import fs from 'node:fs'; -import net from 'node:net'; -import { expect, test, vi } from 'vitest'; -import { startDaemonRuntime } from '@agent-device/daemon'; -import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { readDaemonStopIdentity } from '@agent-device/daemon-contracts/daemon-registration'; -import { resolveDaemonShutdownReportPath } from '@agent-device/daemon-contracts/daemon-shutdown-report'; -import { isProcessAlive } from '@agent-device/host-kit/process'; -import { createTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; -import { createDaemonRuntimeDeps } from './daemon-composition.ts'; -import { readDaemonInfo } from './daemon-client/daemon-client-metadata.ts'; -import { stopRecordedDaemon } from './daemon-client/daemon-client-stop.ts'; -import * as transport from './daemon-client/daemon-client-transport.ts'; - -const closeStep = vi.hoisted(() => ({ - gate: undefined as Promise | undefined, - entered: undefined as (() => void) | undefined, -})); -vi.mock('./platform-runtime.ts', () => ({ - androidObservation: {}, - createRequestPlatformProviders: () => ({ - run: async (_: unknown, task: () => Promise) => await task(), - }), - createPlatformDeviceInventoryGateways: () => ({}), - createPlatformRuntimeGateway: () => ({ - applicationLifecycle: { - recoverStartupResources: async () => {}, - detachForDaemonShutdown: async () => [], - finalizeDaemonShutdown: async () => [], - }, - inspectFacts: async () => { - throw new Error('unused'); - }, - bind: async () => { - throw new Error('unused'); - }, - shutdown: async () => { - closeStep.entered?.(); - await closeStep.gate; - return []; - }, - }), -})); -vi.mock('./provider-device-runtimes.ts', () => ({ - createDaemonProviderRuntimeComposition: async () => ({ - runtimes: [], - platformModules: [], - shutdown: async () => [], - inventory: [], - }), -})); - -test('two stops after direct host close loses its listener receive its released cleanup', async () => { - const stateDir = mkdtempForTestSync('agent-device-direct-released-'); - const paths = resolveDaemonPaths(stateDir); - const host = createTestHostEnvironment({ - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - }, - }); - let release = () => {}; - closeStep.gate = new Promise((resolve) => { - release = resolve; - }); - const entered = new Promise((resolve) => { - closeStep.entered = resolve; - }); - const runtime = await startDaemonRuntime({ - host, - ownership: 'embedded', - composeDeps: createDaemonRuntimeDeps, - shutdownBudgets: { ownerMs: 10_000 }, - stdout: { write: () => {} }, - stderr: { write: () => {} }, - }); - const info = readDaemonInfo(paths.infoPath)!; - const original = fs.readFileSync(paths.infoPath, 'utf8'); - const signals = vi.spyOn(process, 'kill'); - const closing = runtime.close(); - try { - await entered; - await expect( - new Promise((resolve, reject) => { - const socket = net.createConnection({ host: '127.0.0.1', port: info.httpPort! }); - socket.on('connect', () => { - socket.destroy(); - resolve('still listening'); - }); - socket.on('error', reject); - }), - ).rejects.toMatchObject({ code: 'ECONNREFUSED' }); - expect(fs.readFileSync(paths.infoPath, 'utf8')).toBe(original); - const failures: unknown[] = []; - const send = transport.sendRequest; - vi.spyOn(transport, 'sendRequest').mockImplementation(async (...args) => { - try { - return await send(...args); - } catch (error) { - failures.push(error); - throw error; - } - }); - let settled = 0; - const started = Date.now(); - const stops = [0, 1].map(() => - stopRecordedDaemon(readDaemonStopIdentity(paths.infoPath), { paths, host }).then((result) => { - settled++; - return result; - }), - ); - await vi.waitFor(() => expect(failures).toHaveLength(2)); - for (const failure of failures) - expect(failure).toMatchObject({ details: { reason: 'daemon_transport_failure' } }); - expect(settled).toBe(0); - expect(fs.readFileSync(paths.infoPath, 'utf8')).toBe(original); - release(); - const report = await closing; - const results = await Promise.all(stops); - expect(Date.now() - started).toBeLessThan(10_000); - expect(report).toMatchObject({ requests: { abandoned: 0 }, providerReleases: { pending: [] } }); - for (const result of results) - expect(result).toMatchObject({ - stopped: true, - mode: 'graceful', - cleanupConfidence: 'known', - warnings: [], - providerReleases: { status: 'completed', released: [], pending: [] }, - }); - expect( - JSON.parse(fs.readFileSync(resolveDaemonShutdownReportPath(stateDir), 'utf8')).completion, - ).toMatchObject({ - token: info.token, - pid: info.pid, - processStartTime: info.processStartTime, - outcome: 'released', - unfinished: [], - }); - expect(fs.existsSync(paths.infoPath)).toBe(false); - expect(fs.existsSync(paths.lockPath)).toBe(false); - expect(isProcessAlive(process.pid)).toBe(true); - expect(signals.mock.calls.filter(([, signal]) => signal !== 0)).toEqual([]); - } finally { - release(); - await closing; - vi.restoreAllMocks(); - closeStep.gate = undefined; - closeStep.entered = undefined; - fs.rmSync(stateDir, { recursive: true, force: true }); - } -}); diff --git a/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts b/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts index d2c021232f..2c87474440 100644 --- a/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts +++ b/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts @@ -138,7 +138,11 @@ async function crashedStaleRegistration(ownership: 'embedded' | 'unrecognized' | } for (const [ownership, reason, hint] of [ - ['embedded', DAEMON_LIFECYCLE_REASONS.ownerEmbedded, /Close the host that started it/], + [ + 'embedded', + DAEMON_LIFECYCLE_REASONS.ownerEmbedded, + /owned by host process \d+\. Close it there/, + ], ['sideways', 'daemon-owner-unrecognized', /Update agent-device/], ] as const) test(`a managed client preserves a ${ownership} owner`, async (t) => { @@ -259,6 +263,7 @@ test('a matching pin does not bypass attach compatibility checks', async (t) => }), ); assert.equal(refusal.details?.reason, 'daemon-incompatible'); + assert.match(String(refusal.details?.hint), /owned by host process \d+\. Close it there/); assert.deepEqual(daemon.rpcCommands, []); assert.equal(spawn.mock.calls.length, 0); } finally { diff --git a/src/daemon-client/__tests__/daemon-client-explicit-lifecycle.test.ts b/src/daemon-client/__tests__/daemon-client-explicit-lifecycle.test.ts index 6e307b1a4d..0936fa2cc8 100644 --- a/src/daemon-client/__tests__/daemon-client-explicit-lifecycle.test.ts +++ b/src/daemon-client/__tests__/daemon-client-explicit-lifecycle.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; import { closeLoopbackServer, supportsLoopbackBind } from '@agent-device/test-kit'; import { AppError } from '@agent-device/kernel/errors'; -import { DAEMON_LIFECYCLE_COMMANDS } from '@agent-device/daemon-contracts/daemon-resolution'; +import { DAEMON_QUIESCE_COMMAND } from '@agent-device/daemon-contracts/daemon-resolution'; import { DAEMON_STARTUP_EXIT_CODES } from '@agent-device/daemon-contracts/daemon-registration-owner'; import { isProcessAlive } from '@agent-device/host-kit/process'; import { readVersion } from '@agent-device/host-kit/version'; @@ -138,20 +138,27 @@ test('attach reports an incompatible daemon with both identities and leaves it r } }); -test('attach reports a registered daemon that answers nothing as not running and leaves it', async (t) => { - if (!(await withLoopback(t))) return; - const { stateDir, paths } = stateDirAndPaths(); - const closed = await startScriptedDaemon(okResult({})); - await closeLoopbackServer(closed.server); - const { child } = await registerDaemon(paths, { httpPort: closed.port }); +for (const ownership of ['detached', 'embedded'] as const) + test(`attach leaves an unreachable ${ownership} daemon and names its owner recovery`, async (t) => { + if (!(await withLoopback(t))) return; + const { stateDir, paths } = stateDirAndPaths(); + const closed = await startScriptedDaemon(okResult({})); + await closeLoopbackServer(closed.server); + const { child } = await registerDaemon(paths, { httpPort: closed.port, ownership }); - const error = await failureOf(sendToDaemon(request(stateDir), { daemonLifecycle: 'attach' })); + const error = await failureOf(sendToDaemon(request(stateDir), { daemonLifecycle: 'attach' })); - assert.equal(error.details?.reason, 'daemon-not-running'); - assert.equal(identityIn(error, 'daemon').pid, child.pid); - assert.equal(isProcessAlive(child.pid), true); - assert.equal(spawn.mock.calls.length, 0); -}); + assert.equal(error.details?.reason, 'daemon-not-running'); + assert.equal(identityIn(error, 'daemon').pid, child.pid); + assert.match( + String(error.details?.hint), + ownership === 'embedded' + ? /owned by host process \d+\. Close it there/ + : /agent-device daemon stop/, + ); + assert.equal(isProcessAlive(child.pid), true); + assert.equal(spawn.mock.calls.length, 0); + }); test('attach uses a compatible daemon and never lets a timeout retire it', async (t) => { if (!(await withLoopback(t))) return; @@ -254,7 +261,7 @@ test('ensureDaemon replaces an incompatible idle daemon through a fenced quiesce }); assert.equal(connection.started, true); - assert.deepEqual(stale.rpcCommands, [DAEMON_LIFECYCLE_COMMANDS.quiesce]); + assert.deepEqual(stale.rpcCommands, [DAEMON_QUIESCE_COMMAND]); assert.equal(stale.rpcRequests[0]?.params.token, 'local-secret'); assert.deepEqual(stale.rpcRequests[0]?.params.input, { pid: info.pid, @@ -485,7 +492,7 @@ test('a live daemon on another transport is quiesced, never killed as unreachabl onStart: (reason) => started.push(reason), }); - assert.deepEqual(live.commands, [DAEMON_LIFECYCLE_COMMANDS.quiesce]); + assert.deepEqual(live.commands, [DAEMON_QUIESCE_COMMAND]); assert.deepEqual(started, ['incompatible']); assert.equal(isProcessAlive(child.pid), false, 'it left because it agreed to'); assert.deepEqual(leaver.clientSignals, [], 'the client signaled nothing'); @@ -543,7 +550,7 @@ test('ensureDaemon judges again after the daemon it addressed turned out to be g assert.equal(typeof actualSend, 'function'); assert.deepEqual( stale.rpcCommands, - [DAEMON_LIFECYCLE_COMMANDS.quiesce], + [DAEMON_QUIESCE_COMMAND], 'the second judgement asked again', ); assert.deepEqual(leaver.clientSignals, [], 'the client signaled nothing'); @@ -648,11 +655,7 @@ test('ensureDaemon quiesces an incompatible daemon that wins a startup race inst const connection = await ensureDaemon({ stateDir, daemonTransport: 'http' }); assert.equal(connection.started, true); - assert.deepEqual( - stale.rpcCommands, - [DAEMON_LIFECYCLE_COMMANDS.quiesce], - 'the racer was asked to leave', - ); + assert.deepEqual(stale.rpcCommands, [DAEMON_QUIESCE_COMMAND], 'the racer was asked to leave'); assert.ok(winner); assert.equal(isProcessAlive(winner.pid), false); assert.equal(spawn.mock.calls.length, 2, 'the second start follows the quiesce'); diff --git a/src/daemon-client/daemon-client-ensure.ts b/src/daemon-client/daemon-client-ensure.ts index 8fa83ec480..da84436e1f 100644 --- a/src/daemon-client/daemon-client-ensure.ts +++ b/src/daemon-client/daemon-client-ensure.ts @@ -188,7 +188,7 @@ async function quiesceOrRetireUnresponsive( settings: DaemonClientSettings, ): Promise<'draining' | 'gone' | 'retired'> { try { - return (await requestDaemonLifecycle(info, settings, 'quiesce')).outcome; + return (await requestDaemonLifecycle(info, settings)).outcome; } catch (error) { if (!(error instanceof AppError) || error.details?.reason !== 'daemon_transport_timeout') throw error; diff --git a/src/daemon-client/daemon-client-lifecycle-request.ts b/src/daemon-client/daemon-client-lifecycle-request.ts index b01226712c..e1847e1ccc 100644 --- a/src/daemon-client/daemon-client-lifecycle-request.ts +++ b/src/daemon-client/daemon-client-lifecycle-request.ts @@ -3,7 +3,6 @@ import { DAEMON_SHUTTING_DOWN_REASON, buildDaemonLifecycleRequest, readDaemonLifecycleCensus, - type DaemonLifecycleMode, type DaemonPaths, } from '@agent-device/daemon-contracts/daemon-resolution'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; @@ -15,14 +14,7 @@ import { daemonBusyError, daemonNotRetirableError } from './daemon-lifecycle-err const LIFECYCLE_REQUEST_TIMEOUT_MS = 10_000; -/** - * How long a daemon or runtime that agreed to leave gets to finish leaving. Its own shutdown is bounded - * in phases (server close, session and lease teardown, plugin disposal, provider release), so this is - * the longest a healthy close can take with margin, not a polling interval. Both waits a client keeps - * after that agreement — `ensureDaemon` replacing a detached daemon, and a stop of an embedder-owned - * runtime — use it, because the same close runs either way: a shorter one would report an unconfirmed - * failure for a close that was still succeeding. - */ +/** Maximum wait for an idle detached daemon to finish closing during replacement. */ export const DAEMON_LEAVE_WAIT_MS = 60_000; /** What a lifecycle request needs besides the daemon it addresses: where and how to reach it. */ @@ -37,31 +29,15 @@ function isConnectionFailure(error: unknown): boolean { return error instanceof AppError && error.details?.reason === 'daemon_transport_failure'; } -/** - * `draining`: the daemon accepted and is leaving, which is all its own close needs to know — what a - * stop waits for afterwards is the completion record, and that answers by instance, not by request. - * `gone`: the daemon the request was addressed to is no longer there to answer, because it left, is - * leaving already, or was replaced. - */ -export type DaemonLifecycleOutcome = Readonly<{ outcome: 'draining' | 'gone' }>; +type DaemonLifecycleOutcome = Readonly<{ outcome: 'draining' | 'gone' }>; -/** - * Asks the daemon `info` names to leave, by the rule `mode` states, and reports its answer. - * - * The request is fenced to that instance, so a replacement refuses it. A quiesce a daemon that holds - * work refuses with `daemon-busy`, and that refusal is the answer: nothing here signals it. A - * shutdown a detached daemon refuses names its owner instead, because a detached daemon is stopped by - * signalling its process. A timeout is not recovered here either; the caller decides what an - * unresponsive daemon deserves. - */ +/** Asks the fenced detached daemon to leave if idle, without timeout recovery. */ export async function requestDaemonLifecycle( info: DaemonInfo, target: DaemonLifecycleTarget, - mode: DaemonLifecycleMode, ): Promise { const requestId = createRequestId(); const request = buildDaemonLifecycleRequest({ - mode, token: info.token, fence: { pid: info.pid, processStartTime: info.processStartTime ?? null }, requestId, @@ -81,9 +57,7 @@ export async function requestDaemonLifecycle( } catch (error) { if (isDaemonTransportUnavailableError(error) || isConnectionFailure(error)) return { outcome: 'gone' }; - // Over HTTP a daemon's refusal arrives as an error carrying its reason, not as the response that - // carries it. The three ownership refusals are judged here so an embedded runtime says the same - // thing whichever transport it serves; every other reason keeps the path it shipped with. + // HTTP carries the ownership refusal as an error rather than a response. const refusal = readOwnerRefusal(error); const judged = refusal && judgeRefusedAnswer(refusal, info, target); if (judged) return judged; @@ -99,20 +73,10 @@ export async function requestDaemonLifecycle( /** A daemon's refusal, as read from either transport's shape of it. */ type RefusedDaemonAnswer = Readonly<{ reason: unknown; details: unknown }>; -/** - * The refusals that name who owns the process. Quiesce never answers with one, so judging them on the - * error path cannot change a shipped refusal; it only gives the shutdown request the same answer a - * socket client already got. - */ -const OWNER_REFUSALS: readonly string[] = [ - DAEMON_LIFECYCLE_REASONS.ownerEmbedded, - DAEMON_LIFECYCLE_REASONS.notEmbedded, -]; - function readOwnerRefusal(error: unknown): RefusedDaemonAnswer | undefined { if (!(error instanceof AppError)) return undefined; const reason = error.details?.reason; - return typeof reason === 'string' && OWNER_REFUSALS.includes(reason) + return reason === DAEMON_LIFECYCLE_REASONS.ownerEmbedded ? { reason, details: error.details } : undefined; } @@ -126,14 +90,8 @@ function judgeRefusedAnswer( if (refusal.reason === DAEMON_LIFECYCLE_REASONS.busy) { throw daemonBusyError(info, stateDir, readDaemonLifecycleCensus(refusal.details)); } - // A detached daemon has an owner that can signal it, and a host owns an embedded runtime: either - // way, this client is not the one that ends it. The daemon's own answer says which owner is on the - // other side of that line, so it is trusted over this process's possibly-stale read of the record. - if (refusal.reason === DAEMON_LIFECYCLE_REASONS.notEmbedded) { - throw daemonNotRetirableError({ info, stateDir, owner: 'detached' }); - } if (refusal.reason === DAEMON_LIFECYCLE_REASONS.ownerEmbedded) { - throw daemonNotRetirableError({ info, stateDir, owner: 'embedded' }); + throw daemonNotRetirableError({ info, stateDir }); } if ( refusal.reason === DAEMON_LIFECYCLE_REASONS.instanceMismatch || diff --git a/src/daemon-client/daemon-client-metadata.ts b/src/daemon-client/daemon-client-metadata.ts index 02c1a6c3a3..d5f6a1a4e4 100644 --- a/src/daemon-client/daemon-client-metadata.ts +++ b/src/daemon-client/daemon-client-metadata.ts @@ -40,17 +40,15 @@ export type DaemonInfo = { remoteUpstreamInstanceId?: string; }; -export type DaemonMetadataState = { +type DaemonMetadataState = { hasInfo: boolean; hasLock: boolean; }; export function readDaemonInfo(infoPath: string): DaemonInfo | null { const read = readDaemonRecord(infoPath); - return read.status === 'present' ? daemonInfoFromRecord(read.record) : null; -} - -export function daemonInfoFromRecord(parsed: Partial): DaemonInfo | null { + if (read.status !== 'present') return null; + const parsed = read.record; const token = readRequiredDaemonToken(parsed); const ports = readDaemonInfoPorts(parsed); return token && ports ? readDaemonInfoFromRecord(parsed, token, ports) : null; diff --git a/src/daemon-client/daemon-client-stop.ts b/src/daemon-client/daemon-client-stop.ts index 539ef908c8..5b0aaa8b59 100644 --- a/src/daemon-client/daemon-client-stop.ts +++ b/src/daemon-client/daemon-client-stop.ts @@ -1,36 +1,17 @@ -import { - daemonShutdownCompletionAnswers, - readDaemonShutdownRecord, - readDaemonShutdownReport, - type DaemonShutdownRecord, - type DaemonShutdownCompletionQuery, -} from '@agent-device/daemon-contracts/daemon-shutdown-report'; -import { waitForDaemonExit } from '@agent-device/daemon-contracts/daemon-process'; -import { - DAEMON_LIFECYCLE_REASONS, - readDaemonLifecycleCensus, - type DaemonPaths, -} from '@agent-device/daemon-contracts/daemon-resolution'; +import { readDaemonShutdownReport } from '@agent-device/daemon-contracts/daemon-shutdown-report'; +import type { DaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import type { DaemonStopIdentity } from '@agent-device/daemon-contracts/daemon-registration'; import { - daemonNotRunningStopResult, stopDaemon, mergeDaemonShutdownReport, type DaemonStopResult, } from '@agent-device/daemon-contracts/daemon-stop'; import { AppError } from '@agent-device/kernel/errors'; -import { sleep } from '@agent-device/host-kit/retry'; -import { daemonInfoFromRecord, type DaemonInfo } from './daemon-client-metadata.ts'; import { - DAEMON_LEAVE_WAIT_MS, - requestDaemonLifecycle, - type DaemonLifecycleTarget, -} from './daemon-client-lifecycle-request.ts'; - -const EMBEDDED_STOP_ASK_BUDGET_MS = 5_000; -const EMBEDDED_STOP_ASK_GAP_MS = 250; -const EMBEDDED_STOP_POLL_MS = 100; -const EMBEDDED_STOP_LIFETIME_POLL_MS = 1_000; + daemonNotRetirableError, + daemonRecordOwnerUnrecognizedError, +} from './daemon-lifecycle-errors.ts'; +import type { DaemonLifecycleTarget } from './daemon-client-lifecycle-request.ts'; export async function stopRecordedDaemon( record: DaemonStopIdentity, @@ -38,9 +19,13 @@ export async function stopRecordedDaemon( ): Promise { if (record.ownership === 'unreadable') throw daemonRecordUnreadableError(target.paths); if (record.ownership === 'embedded' || record.ownership === 'unknown') { - const info = record.record && daemonInfoFromRecord(record.record); - if (!info) throw daemonEmbeddedRecordUnaddressableError(target.paths); - return await stopEmbeddedDaemon(info, target); + const info = record.identity + ? { pid: record.identity.pid, processStartTime: record.identity.startTime ?? undefined } + : null; + const params = { info, stateDir: target.paths.baseDir }; + throw record.ownership === 'embedded' + ? daemonNotRetirableError(params) + : daemonRecordOwnerUnrecognizedError(params); } const observed = record.identity ? { ...record.identity, ownership: 'detached' as const } : null; const stopped = await stopDaemon({ paths: target.paths, observed }); @@ -61,128 +46,3 @@ function daemonRecordUnreadableError(paths: DaemonPaths): AppError { }, ); } - -async function stopEmbeddedDaemon( - info: DaemonInfo, - target: DaemonLifecycleTarget, -): Promise { - const instance = { - pid: info.pid, - processStartTime: info.processStartTime ?? '', - token: info.token, - }; - const deadlineMs = Date.now() + DAEMON_LEAVE_WAIT_MS; - const early = await askToLeave(info, target, instance); - if (early?.completion?.outcome === 'retained' && (await instanceEnded(instance))) - return daemonNotRunningStopResult(); - const waited = early ?? (await waitForClose(target.paths, instance, deadlineMs)); - if (waited === 'instance-gone') return daemonNotRunningStopResult(); - if (!waited) throw daemonShutdownUnconfirmedError(info, target.paths); - return stopResultFromCompletion(waited); -} - -async function askToLeave( - info: DaemonInfo, - target: DaemonLifecycleTarget, - instance: DaemonShutdownCompletionQuery, -): Promise { - const askUntilMs = Date.now() + EMBEDDED_STOP_ASK_BUDGET_MS; - for (;;) { - const record = readDaemonShutdownRecord(target.paths.baseDir); - if (record.completion && daemonShutdownCompletionAnswers(record.completion, instance)) - return record; - try { - const asked = await requestDaemonLifecycle(info, target, 'shutdown'); - if (asked.outcome === 'gone' && !instance.processStartTime.trim()) - throw daemonShutdownUnconfirmedError(info, target.paths); - return null; - } catch (error) { - if (!refusedWhileStartingUp(error) || Date.now() >= askUntilMs) throw error; - } - await sleep(EMBEDDED_STOP_ASK_GAP_MS); - } -} - -function refusedWhileStartingUp(error: unknown): boolean { - return ( - error instanceof AppError && - error.details?.reason === DAEMON_LIFECYCLE_REASONS.busy && - readDaemonLifecycleCensus(error.details)?.startingUp === true - ); -} - -async function waitForClose( - paths: DaemonPaths, - instance: DaemonShutdownCompletionQuery, - deadlineMs: number, -): Promise { - let nextLifetimeCheckMs = 0; - while (Date.now() < deadlineMs) { - const record = readDaemonShutdownRecord(paths.baseDir); - const matching = - record.completion && daemonShutdownCompletionAnswers(record.completion, instance); - if (matching && record.completion?.outcome === 'released') return record; - if (Date.now() >= nextLifetimeCheckMs || matching) { - nextLifetimeCheckMs = Date.now() + EMBEDDED_STOP_LIFETIME_POLL_MS; - if (await instanceEnded(instance)) return 'instance-gone'; - } - if (matching) return record; - await sleep(Math.min(EMBEDDED_STOP_POLL_MS, Math.max(0, deadlineMs - Date.now()))); - } - return null; -} - -async function instanceEnded(instance: DaemonShutdownCompletionQuery): Promise { - if (!instance.processStartTime.trim()) return false; - const identity = { pid: instance.pid, startTime: instance.processStartTime }; - return (await waitForDaemonExit(identity, { timeoutMs: 0 })).exited; -} - -function stopResultFromCompletion(record: DaemonShutdownRecord): DaemonStopResult { - const completion = record.completion!; - const released = completion.outcome === 'released'; - return mergeDaemonShutdownReport( - { - stopped: true, - mode: 'graceful', - cleanupConfidence: released && completion.unfinished.length === 0 ? 'known' : 'unknown', - claimsReleased: [], - claimsOrphaned: [], - claimsSuperseded: [], - claimsUnattributable: [], - providerReleases: { status: 'completed', released: [], pending: [] }, - warnings: [ - ...completion.unfinished, - ...(released - ? [] - : [ - 'The runtime stopped serving and keeps its registration: a host process still owns it, so nothing outside that process may signal it.', - ]), - ], - }, - record.report, - ); -} - -function daemonEmbeddedRecordUnaddressableError(paths: DaemonPaths): AppError { - return new AppError( - 'COMMAND_FAILED', - `The record on ${paths.baseDir} does not name a transport and a token, so the runtime it describes cannot be reached.`, - { - reason: 'daemon-embedded-stop-unreachable', - stateDir: paths.baseDir, - record: 'incomplete', - hint: 'Nothing was signaled. Inspect the record, or close the host that owns this runtime.', - }, - ); -} - -function daemonShutdownUnconfirmedError(info: DaemonInfo, paths: DaemonPaths): AppError { - return new AppError('COMMAND_FAILED', 'The runtime did not report that it finished leaving.', { - reason: 'daemon-shutdown-unconfirmed', - pid: info.pid, - ...(info.processStartTime ? { processStartTime: info.processStartTime } : {}), - stateDir: paths.baseDir, - hint: 'It was not signaled, and it may still be serving. Ask the host that owns it to close it, or retry the stop.', - }); -} diff --git a/src/daemon-client/daemon-launch-spec.ts b/src/daemon-client/daemon-launch-spec.ts index 2ef78c3adc..64ca84bcc8 100644 --- a/src/daemon-client/daemon-launch-spec.ts +++ b/src/daemon-client/daemon-launch-spec.ts @@ -15,7 +15,7 @@ import { } from '@agent-device/host-kit/code-signature'; import type { DaemonInfo } from './daemon-client-metadata.ts'; -export type DaemonLaunchSpec = { +type DaemonLaunchSpec = { root: string; distPath: string; distPaths: string[]; @@ -70,7 +70,7 @@ export function resolveDaemonLaunchSpec(): DaemonLaunchSpec { * each other's daemon — live session with it (#2458). A source checkout is the tree * whose code moves under a version that does not, so it keeps a fingerprint. */ -export type LocalDaemonCodeIdentity = +type LocalDaemonCodeIdentity = | { origin: 'installed' } | { origin: 'checkout'; codeSignature: string }; diff --git a/src/daemon-client/daemon-lifecycle-errors.ts b/src/daemon-client/daemon-lifecycle-errors.ts index 5313d6dc37..ff3fd7bde3 100644 --- a/src/daemon-client/daemon-lifecycle-errors.ts +++ b/src/daemon-client/daemon-lifecycle-errors.ts @@ -3,7 +3,6 @@ import { DAEMON_LIFECYCLE_REASONS, DAEMON_RECORD_OWNER_UNRECOGNIZED_REASON, type DaemonLifecycleCensus, - type DaemonOwnership, type DaemonPaths, } from '@agent-device/daemon-contracts/daemon-resolution'; import type { DaemonRetirementResult } from '@agent-device/daemon-contracts/daemon-registration-owner'; @@ -29,7 +28,7 @@ const DAEMON_UNAVAILABLE_REASONS = { drainTimeout: 'daemon-drain-timeout', } as const; -export function daemonIdentityOf(info: DaemonInfo): DaemonIdentity { +export function daemonIdentityOf(info: DaemonIdentity): DaemonIdentity { return { pid: info.pid, ...(info.version !== undefined ? { version: info.version } : {}), @@ -71,7 +70,7 @@ export function daemonNotRunningError(stateDir: string, registered?: DaemonInfo) reason: DAEMON_UNAVAILABLE_REASONS.notRunning, stateDir, ...(registered ? { daemon: daemonIdentityOf(registered) } : {}), - hint: `Start one with a local agent-device command that uses the daemon (agent-device devices --state-dir ${shellQuoteIfNeeded(stateDir)}, for example), or with ensureDaemon({ stateDir: ${JSON.stringify(stateDir)} }) from the SDK, before attaching.${registered ? ` The registered daemon (pid ${registered.pid}) does not answer; ${daemonStopCommand(stateDir)} clears it.` : ''}`, + hint: `Start one with a local agent-device command that uses the daemon (agent-device devices --state-dir ${shellQuoteIfNeeded(stateDir)}, for example), or with ensureDaemon({ stateDir: ${JSON.stringify(stateDir)} }) from the SDK, before attaching.${registered ? ` The registered daemon (pid ${registered.pid}) does not answer. ${registered.ownership === 'embedded' ? embeddedCloseHint(registered.pid) : `${daemonStopCommand(stateDir)} clears it.`}` : ''}`, }); } @@ -98,7 +97,7 @@ export function daemonTakeoverRetirementError( paths: DaemonPaths, ): AppError { if (retirement.reason === 'embedded-owner') - return daemonNotRetirableError({ info, stateDir: paths.baseDir, owner: 'embedded' }); + return daemonNotRetirableError({ info, stateDir: paths.baseDir }); if (retirement.reason === 'owner-unproven') return daemonRecordOwnerUnrecognizedError({ info, stateDir: paths.baseDir }); return daemonRetirementUnconfirmedError(retirement, paths); @@ -122,7 +121,10 @@ export async function daemonIncompatibleError(params: { stateDir, daemon: daemonIdentityOf(info), client: await readClientIdentity(), - hint: `Stop it with ${daemonStopCommand(stateDir)}, then retry. SDK clients call ensureDaemon() to start a compatible one.`, + hint: + info.ownership === 'embedded' + ? embeddedCloseHint(info.pid) + : `Stop it with ${daemonStopCommand(stateDir)}, then retry. SDK clients call ensureDaemon() to start a compatible one.`, }); } @@ -159,59 +161,42 @@ export function daemonBusyError( }); } -/** Refuses managed replacement when the record cannot prove a detached owner. */ -function daemonRecordOwnerUnrecognizedError(params: { - info: DaemonInfo; +/** Refuses stop or replacement when the record cannot prove a detached owner. */ +export function daemonRecordOwnerUnrecognizedError(params: { + info: DaemonIdentity | null; stateDir: string; }): AppError { const { info, stateDir } = params; return new AppError( 'COMMAND_FAILED', - 'This daemon record states an owner this version cannot read, so its daemon was not replaced.', + 'This daemon record states an owner this version cannot read, so this client did not stop or replace it.', { reason: DAEMON_RECORD_OWNER_UNRECOGNIZED_REASON, stateDir, - daemon: daemonIdentityOf(info), - hint: `Update agent-device if the record comes from a newer build. Otherwise check what owns pid ${info.pid}, then repair the record: stop that process by its own means and remove ${shellQuoteIfNeeded(stateDir)}/daemon.json so a daemon can register again.`, + ...(info ? { daemon: daemonIdentityOf(info) } : {}), + hint: `Update agent-device if the record comes from a newer build. Otherwise check what owns ${info ? `pid ${info.pid}` : 'this registration'}, then repair the record: stop that process by its own means and remove ${shellQuoteIfNeeded(stateDir)}/daemon.json so a daemon can register again.`, }, ); } -/** - * A caller wanted this daemon gone, and its owner's rule says otherwise: a host process owns an - * embedded runtime and ends it, and a detached daemon is stopped by signalling its own process, which is - * what `daemon stop` does for a record that proves that owner. - * - * Both cases mean the same thing to a caller: this daemon is not yours to retire. `owner` is the - * daemon's own answer about itself, so it is trusted over this process's read of the record — except for - * the hint, which reads the record too. A detached answer against a record this build cannot place is a - * corrupt state directory, and `daemon stop` refuses that record for the same reason this client just - * did, so its hint names the record repair instead of sending the operator to a second refusal. - */ +/** The host that started an embedded runtime owns its close. */ export function daemonNotRetirableError(params: { - info: DaemonInfo; + info: DaemonIdentity | null; stateDir: string; - owner: Exclude; }): AppError { - const { info, stateDir, owner } = params; - const embedded = owner === 'embedded'; - const recordUnreadable = !embedded && info.ownership === 'unknown'; + const { info, stateDir } = params; return new AppError( 'COMMAND_FAILED', - embedded - ? 'A host process owns this daemon, so it was not replaced.' - : 'This daemon is not embedder-owned, so a shutdown request is not how it is stopped.', + 'A host process owns this runtime, so it was not retired.', { - reason: embedded - ? DAEMON_LIFECYCLE_REASONS.ownerEmbedded - : DAEMON_LIFECYCLE_REASONS.notEmbedded, + reason: DAEMON_LIFECYCLE_REASONS.ownerEmbedded, stateDir, - daemon: daemonIdentityOf(info), - hint: embedded - ? `Close the host that started it, or ask this daemon to leave with ${daemonStopCommand(stateDir)}, which sends it a shutdown request instead of a signal.` - : recordUnreadable - ? `The daemon says it is a daemon of its own, so the owner its record states is wrong. Nothing was signaled. Check what owns pid ${info.pid}, then repair the record: stop that process by its own means and remove ${shellQuoteIfNeeded(stateDir)}/daemon.json so a daemon can register again.` - : `Stop it with ${daemonStopCommand(stateDir)}.`, + ...(info ? { daemon: daemonIdentityOf(info) } : {}), + hint: embeddedCloseHint(info?.pid), }, ); } + +function embeddedCloseHint(pid: number | undefined): string { + return `${pid === undefined ? 'The runtime is owned by its host process.' : `The runtime is owned by host process ${pid}.`} Close it there with the handle returned by startAgentDevice(). If the host has exited, call ensureDaemon() for this state directory to recover its abandoned registration.`; +} diff --git a/src/host.test.ts b/src/host.test.ts index eb42f47af9..4e7d3a8f65 100644 --- a/src/host.test.ts +++ b/src/host.test.ts @@ -265,7 +265,6 @@ test('a host without a state directory is refused, never a daemon in an inherite expect(refusal, JSON.stringify(stateDir)).toMatchObject({ code: 'INVALID_ARGS', details: { - reason: 'host_state_dir_required', hint: 'Pass an explicit stateDir to startAgentDevice({ stateDir }).', }, }); @@ -273,7 +272,7 @@ test('a host without a state directory is refused, never a daemon in an inherite expect(fs.existsSync('/tmp/agent-device-host-entry-inherited/daemon.json')).toBe(false); }); -test('a host ends its own runtime once, and an outside stop ends it the same way', async () => { +test('a host ends its own runtime once', async () => { const { stateDir, paths, host } = await bootHost(); const signals = vi.spyOn(process, 'kill'); const exit = vi.spyOn(process, 'exit'); @@ -309,31 +308,32 @@ test('a host ends its own runtime once, and an outside stop ends it the same way } }); -test("an operator's stop tears a host's runtime down exactly as its own close would", async () => { +test('an outside stop refuses and the host keeps serving until its owner closes it', async () => { const { stateDir, paths, host } = await bootHost(); const signals = vi.spyOn(process, 'kill'); - try { - const stopped = await stopRecordedDaemon(readDaemonStopIdentity(paths.infoPath), { - paths, - host: createTestHostEnvironment(), + await expect( + stopRecordedDaemon(readDaemonStopIdentity(paths.infoPath), { + paths, + host: createTestHostEnvironment(), + }), + ).rejects.toMatchObject({ + details: { + reason: 'daemon-owner-embedded', + hint: `The runtime is owned by host process ${process.pid}. Close it there with the handle returned by startAgentDevice(). If the host has exited, call ensureDaemon() for this state directory to recover its abandoned registration.`, + }, }); - - // The runtime answered with its own close: asked over its transport, never signaled. - expect(stopped).toMatchObject({ stopped: true, mode: 'graceful' }); expect(hostSignals(signals)).toEqual([]); - expect(fs.existsSync(paths.infoPath)).toBe(false); - // The host learns the same report it would have from closing itself, and closing again is not an - // error — the case of a scope that closes after an operator already stopped the runtime. + expect(fs.existsSync(paths.infoPath)).toBe(true); + await expect(host.client.sessions.list()).resolves.toEqual([]); const report = await host.close(); expect(report).toMatchObject({ - requests: { drained: 0, abandoned: 0 }, + requests: { abandoned: 0 }, platformOwners: 'closed', - sessions: { closed: 0, failed: 0 }, - runners: { stopped: [], handedOff: [] }, plugins: { failed: [], pending: [] }, }); await expect(host.close()).resolves.toEqual(report); + expect(fs.existsSync(paths.infoPath)).toBe(false); } finally { signals.mockRestore(); await host.close(); diff --git a/src/host.ts b/src/host.ts index 96217b1592..89ed840d3c 100644 --- a/src/host.ts +++ b/src/host.ts @@ -13,7 +13,6 @@ export async function startAgentDevice(options: StartAgentDeviceOptions): Promis const host = { env: { ...sourceHost.env }, cwd: sourceHost.cwd }; if (typeof options.stateDir !== 'string' || options.stateDir.trim().length === 0) { throw new AppError('INVALID_ARGS', 'startAgentDevice requires a stateDir.', { - reason: 'host_state_dir_required', hint: 'Pass an explicit stateDir to startAgentDevice({ stateDir }).', }); } diff --git a/src/sdk/host.ts b/src/sdk/host.ts index e949579b38..a91f70e955 100644 --- a/src/sdk/host.ts +++ b/src/sdk/host.ts @@ -2,7 +2,7 @@ import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { captureHostEnvironment } from '@agent-device/host-kit/process'; import { withCommandHost } from '@agent-device/host-kit/command'; -/** Released SDK defaults until the explicit host entry ships (#3759). */ +/** Released SDK defaults removed in 0.22 (#3759). */ export function captureSdkHost(): HostEnvironment { return captureHostEnvironment(process); } diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 00c6e95944..ba09a2ef0a 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -18,11 +18,6 @@ export { export { centerOfRect } from '@agent-device/kernel/snapshot'; export { isUnreadableCaptureContentError } from '@agent-device/contracts/android-snapshot-quality'; -// The types the root's two functions are written in. `createAgentDeviceClient` returns -// `AgentDeviceClient`, whose every option and result type is a named type of -// `client-types.ts`; until now a consumer reached them only through -// `ReturnType` and `Awaited>`, and typed the -// nodes a snapshot returns by hand. export type * from '../client/client-types.ts'; export type { DaemonConnection, diff --git a/test/integration/installed-package-metro.test.ts b/test/integration/installed-package-metro.test.ts index 64b47eecb8..1d011ebaf1 100644 --- a/test/integration/installed-package-metro.test.ts +++ b/test/integration/installed-package-metro.test.ts @@ -451,12 +451,18 @@ try { }, ); assert.deepEqual(await a.client.sessions.list(), []); - const stopped = await exec( - process.execPath, - [cli, 'daemon', 'stop', '--state-dir', a.clientConfig.stateDir, '--json'], - { cwd }, + await assert.rejects( + exec( + process.execPath, + [cli, 'daemon', 'stop', '--state-dir', a.clientConfig.stateDir, '--json'], + { cwd }, + ), + (error) => { + assert.equal(JSON.parse(error.stdout).error.details.reason, 'daemon-owner-embedded'); + return true; + }, ); - assert.equal(JSON.parse(stopped.stdout).data.stopped, true); + assert.deepEqual(await a.client.sessions.list(), []); const [first, second] = await Promise.all([a.close(), a.close()]); assert.strictEqual(first, second); assert.deepEqual(first.requests, { drained: 0, abandoned: 0 }); @@ -486,7 +492,7 @@ try { JSON.stringify({ attachedFromChild: true, cleanRefused: true, - externalStop: true, + externalStopRefused: true, siblingPreserved: true, replacementRefused: true, report: first, diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 8bdbafc5de..e24eadba39 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -92,9 +92,9 @@ if ('cloudArtifacts' in result) { A client talks to one local daemon per state directory. `daemonLifecycle` says who owns that daemon. -A host-owned daemon ends through its host's own close or an authenticated shutdown request over its registered local transport. Clients never signal the host. A `'managed'` client reuses a matching host-owned daemon. Takeover, drain, and timeout recovery ask the central retirement, which judges the record's owner once: a live host-owned or unrecognized owner is refused without a signal, while a record whose owner provably exited is retired like any abandoned one, so the state directory stays usable. `agent-device daemon stop --state-dir ` sends the shutdown request and waits for the close report. It can also ask a readable record with unrecognized ownership: an embedded runtime can close itself, but a detached answer fails with `daemon-not-embedded` and a record-repair hint, without signaling. A host's direct close also records its result, so stops already waiting can confirm a clean release. +Close an embedded runtime in its host process with the handle returned by `startAgentDevice()`. `agent-device daemon stop --state-dir ` refuses with `error.details.reason: 'daemon-owner-embedded'` and a hint naming the host PID. It sends no request or signal. If the host has exited, call `ensureDaemon({ stateDir })` to recover its abandoned registration. A record with unrecognized ownership is also refused, with `daemon-owner-unrecognized`. Without `--clean`, an unreadable `daemon.json` fails with `daemon-record-unreadable`. -For these shutdown requests, stop fails with `daemon-embedded-stop-unreachable` when the record lacks usable transport or token, `daemon-shutdown-unconfirmed` when the close cannot be confirmed (including after connection failure), or, without `--clean`, `daemon-record-unreadable` when `daemon.json` cannot be read. These are `error.details.reason` values. A retained close returns `stopped: true`, warnings and `cleanupConfidence: 'unknown'` with exit 0: the runtime stopped serving, but resource cleanup is unconfirmed. Later stops can read that retained answer while the same host holds the registration. +A `'managed'` client reuses a matching embedded runtime. Takeover, drain, and timeout recovery refuse a live embedded or unrecognized owner without a signal. A provably exited owner is retired so you can reuse the state directory. | Mode | What the client does with the daemon | | --- | --- | @@ -122,10 +122,10 @@ These failures carry a typed `error.details.reason`: | Reason | Meaning | | --- | --- | | `daemon-not-running` | An `'attach'` client found no daemon to connect to. Call `ensureDaemon()`. | -| `daemon-incompatible` | The running daemon does not match the client. `details.daemon` and `details.client` hold both identities. Stop it with `agent-device daemon stop --state-dir `, then call `ensureDaemon()`. A daemon from a release that predates quiescing cannot be asked to leave, so `ensureDaemon()` reports it the same way. | +| `daemon-incompatible` | The running daemon does not match the client. `details.daemon` and `details.client` hold both identities. For an embedded runtime, close it in its host process. For a detached daemon, use `agent-device daemon stop --state-dir `. Then call `ensureDaemon()`. A daemon from a release that predates quiescing cannot be asked to leave, so `ensureDaemon()` reports it the same way. | | `daemon-busy` | `ensureDaemon()` found an incompatible daemon that still holds work. `details` counts `inFlightRequests`, `sessions`, `repairSessions` and `leases`. Wait for them to end and retry. | -| `daemon-drain-timeout` | A daemon accepted the request to leave but was still running 60 s later and could not be force-retired, either because it still answers or because its process cannot be proven to be a daemon (an embedded host). It was not signaled. Retry once it has exited, or stop it with `agent-device daemon stop --state-dir `. | -| `daemon-owner-embedded` | A host process owns the daemon on `stateDir`, so this client neither replaced it nor retired it, and signaled nothing. A daemon that matches the client is still usable. Close that host, or stop it with `agent-device daemon stop --state-dir `. | +| `daemon-drain-timeout` | A daemon accepted the request to leave but was still running 60 s later and could not be force-retired, either because it still answers or because its process cannot be proven to be a detached daemon. It was not signaled. Retry once it has exited, or stop it with `agent-device daemon stop --state-dir `. | +| `daemon-owner-embedded` | A host process owns the daemon on `stateDir`, so this client neither replaced it nor retired it, and signaled nothing. A daemon that matches the client is still usable. Close the runtime in its host process with its `close()` handle. | | `daemon-owner-unrecognized` | The record on `stateDir` states an owner this version cannot read, so this client replaced nothing and signaled nothing. Update agent-device if the record comes from a newer build. Otherwise check what owns that process: a host that started it closes it with its own close, and otherwise remove `daemon.json` once that process is gone. | | `daemon-binding-mismatch` | A client pinned to one daemon instance reached another one instead. `details.mismatch` says which: `replaced`, `absent`, or `unaddressable`. Nothing was dispatched, started, or signaled. Take a fresh config from the host that owns the daemon, or start a host of your own. | @@ -146,9 +146,9 @@ const devices = await host.client.devices.list(); await host.close(); ``` -`stateDir` is required. If another daemon owns it, startup refuses with `daemon_registration_refused`; choose a separate directory or close its owner first. Pass imported plugin objects in `plugins`. They register after the built-ins without reading the plugin store, so `plugin add` does not change a running host. Plugin failures appear in `agent-device daemon status --state-dir `. `close()` reports unfinished teardown. Repeated calls share one close, including after an operator stops the runtime, and never exit your process. +`stateDir` is required. If another daemon owns it, startup refuses with `daemon_registration_refused`; choose a separate directory or close its owner first. Pass imported plugin objects in `plugins`. They register after the built-ins without reading the plugin store, so `plugin add` does not change a running host. Plugin failures appear in `agent-device daemon status --state-dir `. `close()` reports unfinished teardown. Repeated calls share one close and never exit your process. -The runtime stays alive until you close it or send `agent-device daemon stop --state-dir `; idle reaping is disabled. `daemon stop --clean` is refused because cleanup must stay within this runtime. If the shutdown report lists abandoned requests or unfinished cleanup, inspect those outcomes before reusing the state directory: the runtime may retain its registration to protect work still in progress. +The runtime stays alive until you call `close()` in its host process; idle reaping is disabled. `daemon stop` refuses embedded runtimes with `daemon-owner-embedded`. `daemon stop --clean` is refused because cleanup must stay within this runtime. If the shutdown report lists abandoned requests or unfinished cleanup, inspect those outcomes before reusing the state directory: the runtime may retain its registration to protect work still in progress. `host.clientConfig` is serializable JSON: pass it to another process or worker and build an attach client from it. The client must pass normal version and code compatibility checks, then match the pinned instance. An incompatible or replacement daemon is refused: diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index ea57f21105..d01963d276 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -87,7 +87,7 @@ Device claims and the daemon: - `device release --stale` settles the durable resources of an owner that provably can't release its claim (its process exited or a newer daemon replaced it), then clears the claim. Live, uncertain, PID-reused, corrupt, and deleted-state-directory claims are refused with the reason. See [Sessions](/docs/sessions#release-a-device-another-session-or-workspace-holds). - A command that needs the shared local daemon starts it for its state directory when none runs; `device status` and `device release --stale` never contact one. When the running daemon comes from another version or build, the command replaces it only if it reports no work: no request in flight, no session, no repair session and no 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. Otherwise the command fails with `daemon-busy` and leaves the daemon alone. A daemon from a release that predates this check is never replaced, and fails with `daemon-incompatible`. End a busy or older daemon deliberately with `daemon stop`. Node.js clients choose this per client; see [Daemon lifecycle](/docs/client-api#daemon-lifecycle). - `daemon stop --state-dir ` stops a detached local daemon with process signals after verifying its identity. `--clean` also removes the Apple runner processes and leases that daemon retained. -- For a host-owned runtime, `daemon stop` sends an authenticated shutdown request over its registered local transport and waits for the close report without signaling the host. It can also ask a record with unrecognized ownership: an embedded runtime can close itself, but a detached answer fails with `details.reason: daemon-not-embedded` and a record-repair hint, without signaling. For these shutdown requests, a record without usable transport or token fails with `daemon-embedded-stop-unreachable`. An unconfirmed close, including after connection failure, fails with `daemon-shutdown-unconfirmed`. Without `--clean`, an unreadable `daemon.json` fails stop with `daemon-record-unreadable`. A retained close returns `stopped: true`, warnings and `cleanupConfidence: unknown` (exit 0): it stopped serving, but resource cleanup is unconfirmed. `--clean` requires proven detached ownership; host-owned, unrecognized and unreadable records fail with `daemon-clean-not-detached` because that cleanup walks the owner's whole process tree. `daemon status` shows `host-owned` or `owner-not-recognized`. See [Daemon lifecycle](/docs/client-api#daemon-lifecycle). +- `daemon stop` refuses an embedded runtime with `details.reason: daemon-owner-embedded` and a hint naming the host PID. Close it in its host process with the handle returned by `startAgentDevice()`. Stop refuses unrecognized ownership with `daemon-owner-unrecognized`. Neither refusal sends a request or signal. Without `--clean`, an unreadable `daemon.json` fails with `daemon-record-unreadable`. `--clean` requires proven detached ownership; embedded, unrecognized and unreadable records fail with `daemon-clean-not-detached`. `daemon status` shows `host-owned` or `owner-not-recognized`. See [Daemon lifecycle](/docs/client-api#daemon-lifecycle). - `daemon status [--state-dir ] [--json]` reads the daemon's record without contacting it. For a running daemon it lists every provider it registered, built in or installed, and the reason any plugin failed to register. See [provider plugins](/docs/plugins). Open apps and links: From 86ae7b77b39ea249b7e04bc12263fe99163d6034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 20 Oct 2026 18:12:09 +0200 Subject: [PATCH 2/8] chore(gates): date SDK capture removal to 0.22 Co-Authored-By: Claude Opus 5.5 --- scripts/layering/process-globals-policy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/layering/process-globals-policy.ts b/scripts/layering/process-globals-policy.ts index 386dd1817a..037285457c 100644 --- a/scripts/layering/process-globals-policy.ts +++ b/scripts/layering/process-globals-policy.ts @@ -16,7 +16,7 @@ const PROCESS_ENTRIES = new Set([ 'packages/react-native/src/companion/companion-tunnel.ts', 'packages/react-native/src/companion/client-companion-tunnel-worker.ts', 'packages/maestro/src/daemon-port/run-script-http-child.ts', - // Released SDK defaults; #3759 removes these captures after startAgentDevice ships (#3757). + // Released SDK defaults; #3759 removes these captures in 0.22. 'src/io.ts', 'src/sdk/host.ts', 'src/mcp/command-tools.ts', From eb3db8f7accf4a54f0505dad00eed06f3854f803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 20 Oct 2026 19:57:02 +0200 Subject: [PATCH 3/8] fix(daemon): retire embedded records after proven host exit Centralize stop owner refusals and preserve released host diagnostics. Document the stop behavior and removed reasons in a patch changeset. Co-Authored-By: Claude Opus 5.5 --- .changeset/embedded-owner-stop.md | 7 ++ docs/adr/0034-provider-plugin-registry.md | 2 +- .../src/daemon-registration-owner.ts | 6 +- .../src/daemon-registration.ts | 4 +- .../daemon-contracts/src/daemon-resolution.ts | 5 + packages/daemon-contracts/src/daemon-stop.ts | 10 +- src/cli/commands/__tests__/daemon.test.ts | 9 +- src/commands/schema/cli-help.ts | 2 +- src/daemon-client-embedded-stop.test.ts | 112 +++++++++++------- src/daemon-client/daemon-client-stop.ts | 24 ++-- src/daemon-client/daemon-lifecycle-errors.ts | 5 +- src/host.test.ts | 3 +- src/host.ts | 1 + website/docs/docs/client-api.md | 4 +- website/docs/docs/commands.md | 2 +- 15 files changed, 117 insertions(+), 79 deletions(-) create mode 100644 .changeset/embedded-owner-stop.md diff --git a/.changeset/embedded-owner-stop.md b/.changeset/embedded-owner-stop.md new file mode 100644 index 0000000000..6160d1e0f0 --- /dev/null +++ b/.changeset/embedded-owner-stop.md @@ -0,0 +1,7 @@ +--- +'agent-device': patch +--- + +Make `daemon stop` fail with `daemon-owner-embedded` for a live embedded runtime instead of exiting successfully. Close the runtime in its host process with the handle returned by `startAgentDevice()`. Stop sends no shutdown request or signal to embedded or unrecognized owners. It can retire their abandoned registrations after proving the recorded host lifetime exited. + +Remove the `daemon-shutdown-unconfirmed`, `daemon-embedded-stop-unreachable`, and `daemon-not-embedded` reasons shipped in v0.21.29 along with the external embedded shutdown path. In-process `close()` still returns its shutdown report. diff --git a/docs/adr/0034-provider-plugin-registry.md b/docs/adr/0034-provider-plugin-registry.md index 6d25d196f7..7ff2788dad 100644 --- a/docs/adr/0034-provider-plugin-registry.md +++ b/docs/adr/0034-provider-plugin-registry.md @@ -58,7 +58,7 @@ had to fake the store to load a plugin from its own dependencies (#3747). ## Daemon ownership -Clients retain managed and attach lifecycles. Managed clients may start a daemon; attach clients cannot start or replace one. An embedder calls `startAgentDevice({ stateDir, plugins?, host? })` from `agent-device` to own a runtime in its process. The main entry loads the runtime when called. The returned client and serializable `clientConfig` identify that exact instance. Attaching checks normal version and code compatibility before checking the instance pin. `close()` in the host process is the only way to shut an embedded runtime down, and returns its `ShutdownReport`. `daemon stop` refuses embedded ownership with `daemon-owner-embedded` and unrecognized ownership with `daemon-owner-unrecognized`, without a request or signal. The embedded-ownership refusal names the host PID and directs the caller to close the runtime there. The CLI cannot replace an embedded runtime. Registration and its lock remain held while requests or platform owners are unfinished. +Clients retain managed and attach lifecycles. Managed clients may start a daemon; attach clients cannot start or replace one. An embedder calls `startAgentDevice({ stateDir, plugins?, host? })` from `agent-device` to own a runtime in its process. The main entry loads the runtime when called. The returned client and serializable `clientConfig` identify that exact instance. Attaching checks normal version and code compatibility before checking the instance pin. `close()` in the host process is the only way to shut an embedded runtime down, and returns its `ShutdownReport`. `daemon stop` refuses live embedded ownership with `daemon-owner-embedded` and live unrecognized ownership with `daemon-owner-unrecognized`, without a request or signal. The embedded-ownership refusal names the host PID and directs the caller to close the runtime there. Stop may retire an embedded or unrecognized registration when its recorded host lifetime has provably exited. The CLI cannot replace a live embedded runtime. Registration and its lock remain held while requests or platform owners are unfinished. The embedder supplies plugin objects directly. They follow the built-ins through the same registry validation; this runtime does not read the plugin store. Plugin failures remain inventory data. Logging adapters, a separate package entry, and disposal protocols are not required to own a daemon: the library exposes the client, its attach configuration, and `close()`. diff --git a/packages/daemon-contracts/src/daemon-registration-owner.ts b/packages/daemon-contracts/src/daemon-registration-owner.ts index dcb90ab8fb..6e16deb641 100644 --- a/packages/daemon-contracts/src/daemon-registration-owner.ts +++ b/packages/daemon-contracts/src/daemon-registration-owner.ts @@ -511,7 +511,11 @@ async function proveExitWithoutSignaling( observed: OwnerIdentity, reason: 'identity-unverified' | 'embedded-owner' | 'owner-unproven' = 'identity-unverified', ): Promise { - if (!observed.startTime?.trim()) return { status: 'retained', reason: 'missing-start-time' }; + if (!observed.startTime?.trim()) + return { + status: 'retained', + reason: reason === 'identity-unverified' ? 'missing-start-time' : reason, + }; const identity = { pid: observed.pid, startTime: observed.startTime }; return (await waitForDaemonExit(identity, { timeoutMs: 0 })).exited ? { status: 'exited', identity, mode: 'already-exited' } diff --git a/packages/daemon-contracts/src/daemon-registration.ts b/packages/daemon-contracts/src/daemon-registration.ts index f781e58988..0e31e08cec 100644 --- a/packages/daemon-contracts/src/daemon-registration.ts +++ b/packages/daemon-contracts/src/daemon-registration.ts @@ -209,7 +209,6 @@ export type DaemonStopIdentity = Readonly<{ /** `absent`: no record. `unreadable`: a record whose contents cannot be used, unreadable or malformed. */ ownership: DaemonOwnership | 'absent' | 'unreadable'; identity: OwnerIdentity | null; - record: DaemonRecord | null; }>; /** @@ -223,9 +222,8 @@ export function readDaemonStopIdentity(infoPath: string): DaemonStopIdentity { return { ownership: reading.status === 'absent' ? 'absent' : 'unreadable', identity: null, - record: null, }; - return { ownership: reading.ownership, identity: reading.identity, record: reading.record }; + return { ownership: reading.ownership, identity: reading.identity }; } /** diff --git a/packages/daemon-contracts/src/daemon-resolution.ts b/packages/daemon-contracts/src/daemon-resolution.ts index 3540f9a172..1965916e43 100644 --- a/packages/daemon-contracts/src/daemon-resolution.ts +++ b/packages/daemon-contracts/src/daemon-resolution.ts @@ -293,3 +293,8 @@ export function readDaemonLifecycleCensus(details: unknown): DaemonLifecycleCens leases: leases as number, }; } + +/** Directs embedded runtime callers to the host that owns close. */ +export function embeddedCloseHint(pid: number | undefined): string { + return `${pid === undefined ? 'The runtime is owned by its host process.' : `The runtime is owned by host process ${pid}.`} Close it there with the handle returned by startAgentDevice(). If the host has exited, use daemon stop or ensureDaemon() for this state directory to recover its abandoned registration.`; +} diff --git a/packages/daemon-contracts/src/daemon-stop.ts b/packages/daemon-contracts/src/daemon-stop.ts index 08b87a5734..90595c1e22 100644 --- a/packages/daemon-contracts/src/daemon-stop.ts +++ b/packages/daemon-contracts/src/daemon-stop.ts @@ -3,6 +3,7 @@ import type { DaemonRetirementResult } from './daemon-registration-owner.ts'; import { DAEMON_LIFECYCLE_REASONS, + embeddedCloseHint, DAEMON_RECORD_OWNER_UNRECOGNIZED_REASON, type DaemonPaths, type ObservedDaemonIdentity, @@ -47,8 +48,8 @@ export type DaemonStopResult = { /** * Retires the daemon whose lifetime the caller read, and shapes that retirement as a stop result. * - * The public stop router refuses embedded and unknown registrations before this path. - * Direct callers still supply ownership to retirement, which refuses to signal a non-detached owner. + * Retirement refuses to signal embedded and unknown owners, and retires their records only + * when the observed host lifetime has provably exited. */ export async function stopDaemon(params: { paths: DaemonPaths; @@ -106,11 +107,12 @@ export async function stopDaemon(params: { */ function ownerRefusal( reason: 'embedded-owner' | 'owner-unproven', + pid: number, ): Readonly<{ reason: string; hint: string }> { return reason === 'embedded-owner' ? { reason: DAEMON_LIFECYCLE_REASONS.ownerEmbedded, - hint: 'Nothing was signaled. Close the host that started this runtime, or repair its record after the process has exited.', + hint: embeddedCloseHint(pid), } : { reason: DAEMON_RECORD_OWNER_UNRECOGNIZED_REASON, @@ -129,7 +131,7 @@ function daemonRetirementError( const refusal = retirement.status === 'retained' && (retirement.reason === 'embedded-owner' || retirement.reason === 'owner-unproven') - ? ownerRefusal(retirement.reason) + ? ownerRefusal(retirement.reason, info.pid) : undefined; return new AppError( 'COMMAND_FAILED', diff --git a/src/cli/commands/__tests__/daemon.test.ts b/src/cli/commands/__tests__/daemon.test.ts index fbedff2757..ba393880f0 100644 --- a/src/cli/commands/__tests__/daemon.test.ts +++ b/src/cli/commands/__tests__/daemon.test.ts @@ -385,7 +385,7 @@ test('daemon status names a record whose owner it cannot recognize', async () => expect(render()).toContain('pid 321, owner-not-recognized'); }); -test('daemon stop routes a host-owned record through the one router and never the signal path', async () => { +test('daemon stop passes host ownership to shared retirement without detached cleanup', async () => { const stateDir = mkdtempForTestSync('agent-device-daemon-command-embedded-'); fs.writeFileSync( `${stateDir}/daemon.json`, @@ -393,6 +393,7 @@ test('daemon stop routes a host-owned record through the one router and never th { mode: 0o600 }, ); + mocks.stopDaemon.mockRejectedValueOnce({ details: { reason: 'daemon-owner-embedded' } }); try { await expect( daemonCommand({ @@ -403,8 +404,10 @@ test('daemon stop routes a host-owned record through the one router and never th }), ).rejects.toMatchObject({ details: { reason: 'daemon-owner-embedded' } }); - // The embedded route owns this record end to end: no detached retirement, no cleanup, no output. - expect(mocks.stopDaemon).not.toHaveBeenCalled(); + expect(mocks.stopDaemon).toHaveBeenCalledWith({ + paths: expect.objectContaining({ baseDir: stateDir }), + observed: { pid: 555, startTime: 'start-time', ownership: 'embedded' }, + }); expect(mocks.cleanupDaemonOwner).not.toHaveBeenCalled(); expect(mocks.writeCommandOutput).not.toHaveBeenCalled(); expect(fs.readFileSync(`${stateDir}/daemon.json`, 'utf8')).toContain('"embedded"'); diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index d3027faaf0..76c46e4869 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -480,7 +480,7 @@ Runner and daemon lifecycle (applies to simulators too): 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. A client starting a local daemon waits while the daemon process stays alive and keeps publishing startup progress, so an overloaded host makes startup slower, not failed. It stops at a ceiling (default 120 s; set AGENT_DEVICE_DAEMON_STARTUP_TIMEOUT_MS to a positive integer number of milliseconds to override) and names the outcome in error details.reason: startup-exited (the process ended before registering), startup-stalled (alive, no startup progress for a fixed 60 s window the ceiling variable does not change), or startup-ceiling (a startup was still live when the ceiling passed — this client's daemon or another client's; the client stops its own launch and never signals another client's live daemon. Only a progressing own launch outlasts the 60 s stall window, so a ceiling set below 60 s also ends a silent own launch this way). details.phases carries the startup phase timings it observed and details.startupTimeoutMs the effective ceiling. Branch on details.reason, never on the message. The variable is read only when a client must start a daemon: a value that is not a positive integer fails with INVALID_ARGS before the daemon spawns, and a client that only attaches to or reuses a running daemon never reads it. A command that needs the shared local daemon starts it for its state dir when none runs (device status and device release --stale never contact one). When the running daemon comes from another version or build, the command replaces it only if the daemon reports no work: no in-flight request, session, repair session, or live lease (a streaming resumable upload and a host human-control hold that names no lease are not counted); otherwise it fails with details.reason daemon-busy and leaves the daemon alone. A daemon that agreed to leave is waited for up to 60 s and never signaled while it drains; one still running then fails with details.reason daemon-drain-timeout unless it answers nothing and is provably a daemon process, which is force-retired. A daemon from a release that predates this check is never replaced and fails with daemon-incompatible. End a busy or older daemon deliberately with agent-device daemon stop --state-dir . Node clients pick daemonLifecycle 'attach' (never starts or replaces a daemon; fails with daemon-not-running or daemon-incompatible) or 'managed' (the default until 0.22), and ensureDaemon() starts or replaces one explicitly. - daemon stop --state-dir stops a detached daemon with process signals after verifying its identity. For an embedded runtime, stop refuses with details.reason daemon-owner-embedded and a hint naming the host PID. Close that runtime in its host process with the handle returned by startAgentDevice(). Stop refuses unrecognized ownership with daemon-owner-unrecognized and sends no request or signal. Without --clean, an unreadable daemon.json fails with daemon-record-unreadable. daemon status shows host-owned or owner-not-recognized. Managed clients reuse a matching embedded runtime. Takeover, drain, and timeout recovery refuse a live embedded or unrecognized owner without a signal; a provably exited owner is retired so the state directory stays usable. Detached stop and cleanup use the lifetime and ownership from one registration read. --clean requires proven detached ownership; embedded, unrecognized and unreadable records fail with daemon-clean-not-detached because that cleanup walks the owner's whole process tree. + daemon stop --state-dir stops a detached daemon with process signals after verifying its identity. For a live embedded runtime, stop refuses with details.reason daemon-owner-embedded and a hint naming the host PID. Close that runtime in its host process with the handle returned by startAgentDevice(). Stop refuses live unrecognized ownership with daemon-owner-unrecognized and sends no request or signal. Stop retires an embedded or unrecognized registration when its recorded host lifetime has provably exited. Without --clean, an unreadable daemon.json fails with daemon-record-unreadable. daemon status shows host-owned or owner-not-recognized. Managed clients reuse a matching embedded runtime. Takeover, drain, and timeout recovery refuse a live embedded or unrecognized owner without a signal; a provably exited owner is retired so the state directory stays usable. Detached stop and cleanup use the lifetime and ownership from one registration read. --clean requires proven detached ownership; embedded, unrecognized and unreadable records fail with daemon-clean-not-detached because that cleanup walks the owner's whole process tree. 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). diff --git a/src/daemon-client-embedded-stop.test.ts b/src/daemon-client-embedded-stop.test.ts index bc9c6092df..6c8c9c38c5 100644 --- a/src/daemon-client-embedded-stop.test.ts +++ b/src/daemon-client-embedded-stop.test.ts @@ -1,5 +1,7 @@ import fs from 'node:fs'; -import { spawnSync } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { readProcessStartTime } from '@agent-device/host-kit/process'; import { afterEach, expect, test, vi } from 'vitest'; import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; @@ -9,11 +11,18 @@ import * as transport from './daemon-client/daemon-client-transport.ts'; afterEach(() => vi.restoreAllMocks()); -test.each(['embedded', 'future-owner'])( - 'stop refuses %s ownership before any request or signal, even without transport or birth proof', - async (ownership) => { +test.each([ + ['embedded', false], + ['embedded', true], + ['future-owner', false], + ['future-owner', true], +] as const)( + 'stop refuses live %s ownership without transport (start time recorded: %s)', + async (ownership, withStartTime) => { const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-stop-owner-')); - const record = JSON.stringify({ pid: process.pid, ownership }); + const processStartTime = withStartTime ? readProcessStartTime(process.pid) : undefined; + if (withStartTime) expect(processStartTime).toBeTruthy(); + const record = JSON.stringify({ pid: process.pid, ownership, processStartTime }); fs.writeFileSync(paths.infoPath, record); const send = vi.spyOn(transport, 'sendRequest'); const signals = vi.spyOn(process, 'kill'); @@ -26,11 +35,11 @@ test.each(['embedded', 'future-owner'])( code: 'COMMAND_FAILED', details: { reason: ownership === 'embedded' ? 'daemon-owner-embedded' : 'daemon-owner-unrecognized', - daemon: { pid: process.pid }, + pid: process.pid, }, }); expect(send).not.toHaveBeenCalled(); - expect(signals).not.toHaveBeenCalled(); + expect(signals.mock.calls.filter(([, signal]) => signal !== 0)).toEqual([]); expect(fs.readFileSync(paths.infoPath, 'utf8')).toBe(record); }, ); @@ -47,12 +56,12 @@ test('stop refuses an unreadable record without a request or signal', async () = }), ).rejects.toMatchObject({ details: { reason: 'daemon-record-unreadable' } }); expect(send).not.toHaveBeenCalled(); - expect(signals).not.toHaveBeenCalled(); + expect(signals.mock.calls.filter(([, signal]) => signal !== 0)).toEqual([]); expect(fs.readFileSync(paths.infoPath, 'utf8')).toBe('{not json'); }); test.each(['embedded', 'future-owner'])( - 'stop refuses %s ownership without a PID', + 'stop has no process to retire for %s ownership without a PID', async (ownership) => { const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-stop-no-pid-')); const record = JSON.stringify({ ownership }); @@ -64,42 +73,61 @@ test.each(['embedded', 'future-owner'])( paths, host: createTestHostEnvironment(), }), - ).rejects.toMatchObject({ - details: { - reason: ownership === 'embedded' ? 'daemon-owner-embedded' : 'daemon-owner-unrecognized', - hint: - ownership === 'embedded' - ? 'The runtime is owned by its host process. Close it there with the handle returned by startAgentDevice(). If the host has exited, call ensureDaemon() for this state directory to recover its abandoned registration.' - : expect.stringContaining('check what owns this registration'), - }, - }); + ).resolves.toMatchObject({ stopped: false, mode: 'not-running' }); expect(send).not.toHaveBeenCalled(); expect(signals).not.toHaveBeenCalled(); expect(fs.readFileSync(paths.infoPath, 'utf8')).toBe(record); }, ); -test('stop refuses an embedded registration even after its host exits', async () => { - const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-stop-exited-host-')); - const host = spawnSync(process.execPath, ['-e', '']); - expect(host.status).toBe(0); - const record = JSON.stringify({ pid: host.pid, ownership: 'embedded' }); - fs.writeFileSync(paths.infoPath, record); - const send = vi.spyOn(transport, 'sendRequest'); - const signals = vi.spyOn(process, 'kill'); - await expect( - stopRecordedDaemon(readDaemonStopIdentity(paths.infoPath), { - paths, - host: createTestHostEnvironment(), - }), - ).rejects.toMatchObject({ - details: { - reason: 'daemon-owner-embedded', - daemon: { pid: host.pid }, - hint: expect.stringContaining('call ensureDaemon() for this state directory'), - }, - }); - expect(send).not.toHaveBeenCalled(); - expect(signals).not.toHaveBeenCalled(); - expect(fs.readFileSync(paths.infoPath, 'utf8')).toBe(record); -}); +test.each([ + ['embedded', true], + ['embedded', false], + ['future-owner', true], + ['future-owner', false], +] as const)( + 'stop requires lifetime proof to retire an exited %s host (start time recorded: %s)', + async (ownership, withStartTime) => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-stop-exited-host-')); + const host = spawn(process.execPath, ['-e', "process.stdin.resume(); console.log('ready')"], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + try { + await once(host.stdout!, 'data'); + const processStartTime = readProcessStartTime(host.pid!); + expect(processStartTime).toBeTruthy(); + fs.writeFileSync( + paths.infoPath, + JSON.stringify({ + pid: host.pid, + processStartTime: withStartTime ? processStartTime : undefined, + ownership, + }), + ); + const exited = once(host, 'exit'); + host.stdin!.end(); + await exited; + const send = vi.spyOn(transport, 'sendRequest'); + const signals = vi.spyOn(process, 'kill'); + const stopped = stopRecordedDaemon(readDaemonStopIdentity(paths.infoPath), { + paths, + host: createTestHostEnvironment(), + }); + if (withStartTime) { + await expect(stopped).resolves.toMatchObject({ stopped: false, mode: 'not-running' }); + } else { + await expect(stopped).rejects.toMatchObject({ + details: { + reason: + ownership === 'embedded' ? 'daemon-owner-embedded' : 'daemon-owner-unrecognized', + }, + }); + } + expect(send).not.toHaveBeenCalled(); + expect(signals.mock.calls.filter(([, signal]) => signal !== 0)).toEqual([]); + expect(fs.existsSync(paths.infoPath)).toBe(!withStartTime); + } finally { + if (host.exitCode === null) host.kill(); + } + }, +); diff --git a/src/daemon-client/daemon-client-stop.ts b/src/daemon-client/daemon-client-stop.ts index 5b0aaa8b59..c4ea5d4a23 100644 --- a/src/daemon-client/daemon-client-stop.ts +++ b/src/daemon-client/daemon-client-stop.ts @@ -1,16 +1,15 @@ import { readDaemonShutdownReport } from '@agent-device/daemon-contracts/daemon-shutdown-report'; import type { DaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import type { DaemonStopIdentity } from '@agent-device/daemon-contracts/daemon-registration'; +import { + observedDaemonIdentityOfRegistration, + type DaemonStopIdentity, +} from '@agent-device/daemon-contracts/daemon-registration'; import { stopDaemon, mergeDaemonShutdownReport, type DaemonStopResult, } from '@agent-device/daemon-contracts/daemon-stop'; import { AppError } from '@agent-device/kernel/errors'; -import { - daemonNotRetirableError, - daemonRecordOwnerUnrecognizedError, -} from './daemon-lifecycle-errors.ts'; import type { DaemonLifecycleTarget } from './daemon-client-lifecycle-request.ts'; export async function stopRecordedDaemon( @@ -18,17 +17,10 @@ export async function stopRecordedDaemon( target: DaemonLifecycleTarget, ): Promise { if (record.ownership === 'unreadable') throw daemonRecordUnreadableError(target.paths); - if (record.ownership === 'embedded' || record.ownership === 'unknown') { - const info = record.identity - ? { pid: record.identity.pid, processStartTime: record.identity.startTime ?? undefined } - : null; - const params = { info, stateDir: target.paths.baseDir }; - throw record.ownership === 'embedded' - ? daemonNotRetirableError(params) - : daemonRecordOwnerUnrecognizedError(params); - } - const observed = record.identity ? { ...record.identity, ownership: 'detached' as const } : null; - const stopped = await stopDaemon({ paths: target.paths, observed }); + const stopped = await stopDaemon({ + paths: target.paths, + observed: observedDaemonIdentityOfRegistration(record), + }); return mergeDaemonShutdownReport( stopped, stopped.mode === 'graceful' ? readDaemonShutdownReport(target.paths.baseDir) : null, diff --git a/src/daemon-client/daemon-lifecycle-errors.ts b/src/daemon-client/daemon-lifecycle-errors.ts index ff3fd7bde3..e4bb361028 100644 --- a/src/daemon-client/daemon-lifecycle-errors.ts +++ b/src/daemon-client/daemon-lifecycle-errors.ts @@ -1,6 +1,7 @@ import type { DaemonIdentity } from '@agent-device/contracts/client'; import { DAEMON_LIFECYCLE_REASONS, + embeddedCloseHint, DAEMON_RECORD_OWNER_UNRECOGNIZED_REASON, type DaemonLifecycleCensus, type DaemonPaths, @@ -196,7 +197,3 @@ export function daemonNotRetirableError(params: { }, ); } - -function embeddedCloseHint(pid: number | undefined): string { - return `${pid === undefined ? 'The runtime is owned by its host process.' : `The runtime is owned by host process ${pid}.`} Close it there with the handle returned by startAgentDevice(). If the host has exited, call ensureDaemon() for this state directory to recover its abandoned registration.`; -} diff --git a/src/host.test.ts b/src/host.test.ts index 4e7d3a8f65..65558f1566 100644 --- a/src/host.test.ts +++ b/src/host.test.ts @@ -265,6 +265,7 @@ test('a host without a state directory is refused, never a daemon in an inherite expect(refusal, JSON.stringify(stateDir)).toMatchObject({ code: 'INVALID_ARGS', details: { + reason: 'host_state_dir_required', hint: 'Pass an explicit stateDir to startAgentDevice({ stateDir }).', }, }); @@ -320,7 +321,7 @@ test('an outside stop refuses and the host keeps serving until its owner closes ).rejects.toMatchObject({ details: { reason: 'daemon-owner-embedded', - hint: `The runtime is owned by host process ${process.pid}. Close it there with the handle returned by startAgentDevice(). If the host has exited, call ensureDaemon() for this state directory to recover its abandoned registration.`, + hint: `The runtime is owned by host process ${process.pid}. Close it there with the handle returned by startAgentDevice(). If the host has exited, use daemon stop or ensureDaemon() for this state directory to recover its abandoned registration.`, }, }); expect(hostSignals(signals)).toEqual([]); diff --git a/src/host.ts b/src/host.ts index 89ed840d3c..96217b1592 100644 --- a/src/host.ts +++ b/src/host.ts @@ -13,6 +13,7 @@ export async function startAgentDevice(options: StartAgentDeviceOptions): Promis const host = { env: { ...sourceHost.env }, cwd: sourceHost.cwd }; if (typeof options.stateDir !== 'string' || options.stateDir.trim().length === 0) { throw new AppError('INVALID_ARGS', 'startAgentDevice requires a stateDir.', { + reason: 'host_state_dir_required', hint: 'Pass an explicit stateDir to startAgentDevice({ stateDir }).', }); } diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index e24eadba39..6b51528618 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -92,7 +92,7 @@ if ('cloudArtifacts' in result) { A client talks to one local daemon per state directory. `daemonLifecycle` says who owns that daemon. -Close an embedded runtime in its host process with the handle returned by `startAgentDevice()`. `agent-device daemon stop --state-dir ` refuses with `error.details.reason: 'daemon-owner-embedded'` and a hint naming the host PID. It sends no request or signal. If the host has exited, call `ensureDaemon({ stateDir })` to recover its abandoned registration. A record with unrecognized ownership is also refused, with `daemon-owner-unrecognized`. Without `--clean`, an unreadable `daemon.json` fails with `daemon-record-unreadable`. +Close an embedded runtime in its host process with the handle returned by `startAgentDevice()`. `agent-device daemon stop --state-dir ` refuses a live runtime with `error.details.reason: 'daemon-owner-embedded'` and a hint naming the host PID. It sends no request or signal. If the recorded host lifetime has provably exited, `daemon stop` retires its abandoned registration. A record with live unrecognized ownership is also refused, with `daemon-owner-unrecognized`. Without `--clean`, an unreadable `daemon.json` fails with `daemon-record-unreadable`. A `'managed'` client reuses a matching embedded runtime. Takeover, drain, and timeout recovery refuse a live embedded or unrecognized owner without a signal. A provably exited owner is retired so you can reuse the state directory. @@ -148,7 +148,7 @@ await host.close(); `stateDir` is required. If another daemon owns it, startup refuses with `daemon_registration_refused`; choose a separate directory or close its owner first. Pass imported plugin objects in `plugins`. They register after the built-ins without reading the plugin store, so `plugin add` does not change a running host. Plugin failures appear in `agent-device daemon status --state-dir `. `close()` reports unfinished teardown. Repeated calls share one close and never exit your process. -The runtime stays alive until you call `close()` in its host process; idle reaping is disabled. `daemon stop` refuses embedded runtimes with `daemon-owner-embedded`. `daemon stop --clean` is refused because cleanup must stay within this runtime. If the shutdown report lists abandoned requests or unfinished cleanup, inspect those outcomes before reusing the state directory: the runtime may retain its registration to protect work still in progress. +The runtime stays alive until you call `close()` in its host process; idle reaping is disabled. `daemon stop` refuses live embedded runtimes with `daemon-owner-embedded`. `daemon stop --clean` is refused because cleanup must stay within this runtime. If the shutdown report lists abandoned requests or unfinished cleanup, inspect those outcomes before reusing the state directory: the runtime may retain its registration to protect work still in progress. `host.clientConfig` is serializable JSON: pass it to another process or worker and build an attach client from it. The client must pass normal version and code compatibility checks, then match the pinned instance. An incompatible or replacement daemon is refused: diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index d01963d276..936cec7895 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -87,7 +87,7 @@ Device claims and the daemon: - `device release --stale` settles the durable resources of an owner that provably can't release its claim (its process exited or a newer daemon replaced it), then clears the claim. Live, uncertain, PID-reused, corrupt, and deleted-state-directory claims are refused with the reason. See [Sessions](/docs/sessions#release-a-device-another-session-or-workspace-holds). - A command that needs the shared local daemon starts it for its state directory when none runs; `device status` and `device release --stale` never contact one. When the running daemon comes from another version or build, the command replaces it only if it reports no work: no request in flight, no session, no repair session and no 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. Otherwise the command fails with `daemon-busy` and leaves the daemon alone. A daemon from a release that predates this check is never replaced, and fails with `daemon-incompatible`. End a busy or older daemon deliberately with `daemon stop`. Node.js clients choose this per client; see [Daemon lifecycle](/docs/client-api#daemon-lifecycle). - `daemon stop --state-dir ` stops a detached local daemon with process signals after verifying its identity. `--clean` also removes the Apple runner processes and leases that daemon retained. -- `daemon stop` refuses an embedded runtime with `details.reason: daemon-owner-embedded` and a hint naming the host PID. Close it in its host process with the handle returned by `startAgentDevice()`. Stop refuses unrecognized ownership with `daemon-owner-unrecognized`. Neither refusal sends a request or signal. Without `--clean`, an unreadable `daemon.json` fails with `daemon-record-unreadable`. `--clean` requires proven detached ownership; embedded, unrecognized and unreadable records fail with `daemon-clean-not-detached`. `daemon status` shows `host-owned` or `owner-not-recognized`. See [Daemon lifecycle](/docs/client-api#daemon-lifecycle). +- `daemon stop` refuses a live embedded runtime with `details.reason: daemon-owner-embedded` and a hint naming the host PID. Close it in its host process with the handle returned by `startAgentDevice()`. Stop refuses live unrecognized ownership with `daemon-owner-unrecognized`. Neither refusal sends a request or signal. Stop retires an embedded or unrecognized registration when its recorded host lifetime has provably exited. Without `--clean`, an unreadable `daemon.json` fails with `daemon-record-unreadable`. `--clean` requires proven detached ownership; embedded, unrecognized and unreadable records fail with `daemon-clean-not-detached`. `daemon status` shows `host-owned` or `owner-not-recognized`. See [Daemon lifecycle](/docs/client-api#daemon-lifecycle). - `daemon status [--state-dir ] [--json]` reads the daemon's record without contacting it. For a running daemon it lists every provider it registered, built in or installed, and the reason any plugin failed to register. See [provider plugins](/docs/plugins). Open apps and links: From ff383a633ef7f5a93f73c8cd239a5b2b5baff8c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 20 Oct 2026 19:59:25 +0200 Subject: [PATCH 4/8] refactor(daemon): localize stop refusal helpers Keep helpers private after the shared stop route removes their external callers. Co-Authored-By: Claude Opus 5.5 --- packages/daemon-contracts/src/daemon-stop.ts | 2 +- src/daemon-client/daemon-lifecycle-errors.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/daemon-contracts/src/daemon-stop.ts b/packages/daemon-contracts/src/daemon-stop.ts index 90595c1e22..dcf4e935ca 100644 --- a/packages/daemon-contracts/src/daemon-stop.ts +++ b/packages/daemon-contracts/src/daemon-stop.ts @@ -153,7 +153,7 @@ function daemonRetirementError( ); } -export function daemonNotRunningStopResult(): DaemonStopResult { +function daemonNotRunningStopResult(): DaemonStopResult { return { stopped: false, mode: 'not-running', diff --git a/src/daemon-client/daemon-lifecycle-errors.ts b/src/daemon-client/daemon-lifecycle-errors.ts index e4bb361028..0645a59743 100644 --- a/src/daemon-client/daemon-lifecycle-errors.ts +++ b/src/daemon-client/daemon-lifecycle-errors.ts @@ -163,7 +163,7 @@ export function daemonBusyError( } /** Refuses stop or replacement when the record cannot prove a detached owner. */ -export function daemonRecordOwnerUnrecognizedError(params: { +function daemonRecordOwnerUnrecognizedError(params: { info: DaemonIdentity | null; stateDir: string; }): AppError { From 96d8b8719a1c01c856aca859107798a1b60f825b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 20 Oct 2026 20:00:24 +0200 Subject: [PATCH 5/8] fix(host): remove accidentally released state directory reason Keep missing stateDir as plain INVALID_ARGS with its hint, per the maintainer decision. Name all four accidentally released reasons in the patch changeset. Co-Authored-By: Claude Opus 5.5 --- .changeset/embedded-owner-stop.md | 2 +- src/host.test.ts | 1 - src/host.ts | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.changeset/embedded-owner-stop.md b/.changeset/embedded-owner-stop.md index 6160d1e0f0..1235e69ef6 100644 --- a/.changeset/embedded-owner-stop.md +++ b/.changeset/embedded-owner-stop.md @@ -4,4 +4,4 @@ Make `daemon stop` fail with `daemon-owner-embedded` for a live embedded runtime instead of exiting successfully. Close the runtime in its host process with the handle returned by `startAgentDevice()`. Stop sends no shutdown request or signal to embedded or unrecognized owners. It can retire their abandoned registrations after proving the recorded host lifetime exited. -Remove the `daemon-shutdown-unconfirmed`, `daemon-embedded-stop-unreachable`, and `daemon-not-embedded` reasons shipped in v0.21.29 along with the external embedded shutdown path. In-process `close()` still returns its shutdown report. +Remove the accidentally released v0.21.29 reasons `host_state_dir_required`, `daemon-shutdown-unconfirmed`, `daemon-embedded-stop-unreachable`, and `daemon-not-embedded`. A missing `stateDir` now returns plain `INVALID_ARGS` with a hint. The external embedded shutdown path is removed. In-process `close()` still returns its shutdown report. diff --git a/src/host.test.ts b/src/host.test.ts index 65558f1566..672504b97c 100644 --- a/src/host.test.ts +++ b/src/host.test.ts @@ -265,7 +265,6 @@ test('a host without a state directory is refused, never a daemon in an inherite expect(refusal, JSON.stringify(stateDir)).toMatchObject({ code: 'INVALID_ARGS', details: { - reason: 'host_state_dir_required', hint: 'Pass an explicit stateDir to startAgentDevice({ stateDir }).', }, }); diff --git a/src/host.ts b/src/host.ts index 96217b1592..89ed840d3c 100644 --- a/src/host.ts +++ b/src/host.ts @@ -13,7 +13,6 @@ export async function startAgentDevice(options: StartAgentDeviceOptions): Promis const host = { env: { ...sourceHost.env }, cwd: sourceHost.cwd }; if (typeof options.stateDir !== 'string' || options.stateDir.trim().length === 0) { throw new AppError('INVALID_ARGS', 'startAgentDevice requires a stateDir.', { - reason: 'host_state_dir_required', hint: 'Pass an explicit stateDir to startAgentDevice({ stateDir }).', }); } From 7dc1986fac72f772ea7b58c7a673163967025e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 20 Oct 2026 20:03:16 +0200 Subject: [PATCH 6/8] docs(daemon): remove stale external stop guidance Align ownership comments, the client reason reference, and ADR 0034 with live-host refusal and proven-exit retirement. Keep cleanup comments about detached process-tree authority. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0034-provider-plugin-registry.md | 4 ++-- .../daemon-contracts/src/daemon-resolution.ts | 3 ++- src/cli/commands/daemon.ts | 16 ++-------------- website/docs/docs/client-api.md | 2 +- 4 files changed, 7 insertions(+), 18 deletions(-) diff --git a/docs/adr/0034-provider-plugin-registry.md b/docs/adr/0034-provider-plugin-registry.md index 7ff2788dad..6c3d1bc277 100644 --- a/docs/adr/0034-provider-plugin-registry.md +++ b/docs/adr/0034-provider-plugin-registry.md @@ -62,11 +62,11 @@ Clients retain managed and attach lifecycles. Managed clients may start a daemon The embedder supplies plugin objects directly. They follow the built-ins through the same registry validation; this runtime does not read the plugin store. Plugin failures remain inventory data. Logging adapters, a separate package entry, and disposal protocols are not required to own a daemon: the library exposes the client, its attach configuration, and `close()`. -Decision history: the former explicit-start target is dropped; the client lifecycle continues to choose startup behavior. The main-entry API replaces the proposed host subpath and optional host controls, keeping ownership and attachment on the existing runtime and client paths (#3757). +Decision history: owner-only close replaces external embedded shutdown so the CLI cannot end work its host owns; a proven host exit permits registration retirement. The former explicit-start target is dropped; the client lifecycle continues to choose startup behavior. The main-entry API replaces the proposed host subpath and optional host controls, keeping ownership and attachment on the existing runtime and client paths (#3757). ## Alternatives refused -- **Outside shutdown of an embedded runtime.** The host that starts it owns its close. An authenticated shutdown request, completion record and outside waiter mix ownership and duplicate the host's close handle. +- **Outside shutdown of an embedded runtime.** The host that starts it owns its close. Letting another process close the runtime would cross that ownership boundary and duplicate the host's close handle. - **Keep the manifest as the contract.** It cannot describe options-dependent credentials, and a bundled provider has no package to hold one. diff --git a/packages/daemon-contracts/src/daemon-resolution.ts b/packages/daemon-contracts/src/daemon-resolution.ts index 1965916e43..4d3aa0fed3 100644 --- a/packages/daemon-contracts/src/daemon-resolution.ts +++ b/packages/daemon-contracts/src/daemon-resolution.ts @@ -158,7 +158,8 @@ export type DaemonCapability = (typeof DAEMON_CAPABILITIES)[number]; * predates the field names. * - `embedded`: the host that started the runtime owns the process. Nothing in the central * lifecycle signals that PID, not even hung recovery or a forced stop: the host closes the - * runtime through its own close. A stop from outside refuses without a request or signal. + * runtime through its own close. An outside stop refuses a live host without a request or + * signal, and may retire the registration after proving the recorded host lifetime exited. * - `unknown`: the record states an owner this build does not recognize, so no process may be * signaled on its strength. Signalling on a guess could end a host process, and a value this * build cannot read is exactly the case where the safe answer is to do nothing. diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index 85786b8c3e..c9dbe3bc00 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -39,10 +39,7 @@ export const daemonCommand: ClientCommandHandler = async ({ positionals, flags, // One read decides every route below, so routing, retirement, and a `--clean` walk judge the same // record rather than three snapshots of a file another process may be rewriting. const record = readDaemonStopIdentity(paths.infoPath); - // Retained-runner cleanup works across the owner's whole process tree, which a host process shares - // with whatever else it runs. An embedded runtime's own close is the boundary a stop may cross, and - // a record that cannot prove its owner is not a licence to walk a process tree either. No record at all - // is not a refusal: there is nothing claimed, so there is nothing to clean. + // Cleanup may walk only a detached daemon's process tree; a host may own unrelated work. if (flags.clean === true && record.ownership !== 'absent' && record.ownership !== 'detached') { throw daemonCleanRefusedForUnprovenOwner(paths, record.ownership); } @@ -60,16 +57,7 @@ export const daemonCommand: ClientCommandHandler = async ({ positionals, flags, return true; }; -/** - * `--clean` walks the owner's whole process tree, which for a host process includes everything else - * that host runs. An embedded runtime's own close is the only boundary a stop may cross, so this - * refuses instead of reaching past it, and the stop without `--clean` still works. - * - * A record whose owner cannot be read is refused too: the tree this would walk may be a host's, and - * the reason cleanup is allowed for a detached daemon is that its record proves the process is a - * daemon of its own. This is not a bad flag — the flag is fine, the record is the problem — so it - * reports as a failed command, like the other ownership refusals. - */ +/** Refuses process-tree cleanup unless the record proves detached ownership. */ function daemonCleanRefusedForUnprovenOwner( paths: DaemonPaths, record: DaemonStopIdentity['ownership'], diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 6b51528618..e0c96b17ee 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -125,7 +125,7 @@ These failures carry a typed `error.details.reason`: | `daemon-incompatible` | The running daemon does not match the client. `details.daemon` and `details.client` hold both identities. For an embedded runtime, close it in its host process. For a detached daemon, use `agent-device daemon stop --state-dir `. Then call `ensureDaemon()`. A daemon from a release that predates quiescing cannot be asked to leave, so `ensureDaemon()` reports it the same way. | | `daemon-busy` | `ensureDaemon()` found an incompatible daemon that still holds work. `details` counts `inFlightRequests`, `sessions`, `repairSessions` and `leases`. Wait for them to end and retry. | | `daemon-drain-timeout` | A daemon accepted the request to leave but was still running 60 s later and could not be force-retired, either because it still answers or because its process cannot be proven to be a detached daemon. It was not signaled. Retry once it has exited, or stop it with `agent-device daemon stop --state-dir `. | -| `daemon-owner-embedded` | A host process owns the daemon on `stateDir`, so this client neither replaced it nor retired it, and signaled nothing. A daemon that matches the client is still usable. Close the runtime in its host process with its `close()` handle. | +| `daemon-owner-embedded` | A live host process owns the daemon on `stateDir`, so this client neither replaced it nor retired it, and signaled nothing. A daemon that matches the client is still usable. Close the runtime in its host process with its `close()` handle. | | `daemon-owner-unrecognized` | The record on `stateDir` states an owner this version cannot read, so this client replaced nothing and signaled nothing. Update agent-device if the record comes from a newer build. Otherwise check what owns that process: a host that started it closes it with its own close, and otherwise remove `daemon.json` once that process is gone. | | `daemon-binding-mismatch` | A client pinned to one daemon instance reached another one instead. `details.mismatch` says which: `replaced`, `absent`, or `unaddressable`. Nothing was dispatched, started, or signaled. Take a fresh config from the host that owns the daemon, or start a host of your own. | From 86dee15e8610c7800e82889b2331c307e8379de2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 21 Oct 2026 00:56:21 +0200 Subject: [PATCH 7/8] fix(daemon): share owner refusal hints and cover takeover without start time Co-Authored-By: Claude Opus 5.5 --- .../daemon-contracts/src/daemon-resolution.ts | 2 +- packages/daemon-contracts/src/daemon-stop.ts | 4 +-- src/cli/commands/__tests__/daemon.test.ts | 4 +-- src/cli/commands/daemon.ts | 29 ++++++++----------- .../daemon-client-embedded-lifecycle.test.ts | 21 ++++++++++++++ src/host.test.ts | 2 +- 6 files changed, 39 insertions(+), 23 deletions(-) diff --git a/packages/daemon-contracts/src/daemon-resolution.ts b/packages/daemon-contracts/src/daemon-resolution.ts index 4d3aa0fed3..8248cb7187 100644 --- a/packages/daemon-contracts/src/daemon-resolution.ts +++ b/packages/daemon-contracts/src/daemon-resolution.ts @@ -297,5 +297,5 @@ export function readDaemonLifecycleCensus(details: unknown): DaemonLifecycleCens /** Directs embedded runtime callers to the host that owns close. */ export function embeddedCloseHint(pid: number | undefined): string { - return `${pid === undefined ? 'The runtime is owned by its host process.' : `The runtime is owned by host process ${pid}.`} Close it there with the handle returned by startAgentDevice(). If the host has exited, use daemon stop or ensureDaemon() for this state directory to recover its abandoned registration.`; + return `${pid === undefined ? 'The runtime is owned by its host process.' : `The runtime is owned by host process ${pid}.`} Close it there with the handle returned by startAgentDevice(). Recovery with daemon stop or ensureDaemon() for this state directory retires the abandoned registration only when the recorded host lifetime has provably exited.`; } diff --git a/packages/daemon-contracts/src/daemon-stop.ts b/packages/daemon-contracts/src/daemon-stop.ts index dcf4e935ca..9fefd3317e 100644 --- a/packages/daemon-contracts/src/daemon-stop.ts +++ b/packages/daemon-contracts/src/daemon-stop.ts @@ -105,9 +105,9 @@ export async function stopDaemon(params: { * those two retained reasons become a stop's own reason: a caller that may not signal this process * hears the owner's rule, not a retirement that could not be confirmed. */ -function ownerRefusal( +export function ownerRefusal( reason: 'embedded-owner' | 'owner-unproven', - pid: number, + pid: number | undefined, ): Readonly<{ reason: string; hint: string }> { return reason === 'embedded-owner' ? { diff --git a/src/cli/commands/__tests__/daemon.test.ts b/src/cli/commands/__tests__/daemon.test.ts index ba393880f0..1f51fd282a 100644 --- a/src/cli/commands/__tests__/daemon.test.ts +++ b/src/cli/commands/__tests__/daemon.test.ts @@ -330,14 +330,14 @@ test.each([ true, JSON.stringify({ pid: 555, processStartTime: 'start', ownership: 'embedded' }), 'daemon-clean-not-detached', - /Stop it without --clean/, + /owned by host process 555\. Close it there with the handle returned by startAgentDevice\(\)\..*only when the recorded host lifetime has provably exited/, ], [ 'unknown', true, JSON.stringify({ pid: 555, processStartTime: 'start', ownership: 'sideways' }), 'daemon-clean-not-detached', - /Stop it without --clean/, + /Nothing was signaled\. Check what owns that process, then repair the record: stop it by its own means and remove its daemon\.json/, ], ['unreadable', true, '{not json', 'daemon-clean-not-detached', /remove .*daemon\.json/], ['unreadable', false, '{not json', 'daemon-record-unreadable', /Inspect or remove that record/], diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index c9dbe3bc00..da25427bb7 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -12,7 +12,7 @@ import { type DaemonStatus, type DaemonStopIdentity, } from '@agent-device/daemon-contracts/daemon-registration'; -import type { DaemonStopResult } from '@agent-device/daemon-contracts/daemon-stop'; +import { ownerRefusal, type DaemonStopResult } from '@agent-device/daemon-contracts/daemon-stop'; import { AppError } from '@agent-device/kernel/errors'; import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; import { writeCommandOutput } from './shared.ts'; @@ -41,7 +41,7 @@ export const daemonCommand: ClientCommandHandler = async ({ positionals, flags, const record = readDaemonStopIdentity(paths.infoPath); // Cleanup may walk only a detached daemon's process tree; a host may own unrelated work. if (flags.clean === true && record.ownership !== 'absent' && record.ownership !== 'detached') { - throw daemonCleanRefusedForUnprovenOwner(paths, record.ownership); + throw daemonCleanRefusedForUnprovenOwner(paths, record); } const result = await stopRecordedDaemon(record, { paths, host }); const cleanupOwner = readStopCleanupOwner(record); @@ -60,7 +60,7 @@ export const daemonCommand: ClientCommandHandler = async ({ positionals, flags, /** Refuses process-tree cleanup unless the record proves detached ownership. */ function daemonCleanRefusedForUnprovenOwner( paths: DaemonPaths, - record: DaemonStopIdentity['ownership'], + record: DaemonStopIdentity, ): AppError { return new AppError( 'COMMAND_FAILED', @@ -68,29 +68,24 @@ function daemonCleanRefusedForUnprovenOwner( { reason: 'daemon-clean-not-detached', stateDir: paths.baseDir, - ownership: record, + ownership: record.ownership, hint: daemonCleanRefusalHint(paths, record), }, ); } /** - * What to do next, which depends on whether a plain `daemon stop` has a route left for this record. - * A readable record naming a host or an unfamiliar owner does: that stop asks the running process to - * leave and never signals it. A record that cannot be read has none — the same stop refuses it for the - * same reason — so its hint names the record repair instead of sending the operator to a second refusal. + * Directs host-owned and unrecognized records to their owner's remedy. Only a registration whose + * host has provably exited can be retired by a plain stop. Unreadable records need repair first. */ -function daemonCleanRefusalHint( - paths: DaemonPaths, - record: DaemonStopIdentity['ownership'], -): string { - if (record === 'unreadable') { +function daemonCleanRefusalHint(paths: DaemonPaths, record: DaemonStopIdentity): string { + if (record.ownership === 'unreadable') { return `Nothing was signaled. Check what owns that process, then repair the record: stop it by its own means and remove ${shellQuoteIfNeeded(`${paths.baseDir}/daemon.json`)} so a daemon can register again.`; } - if (record === 'embedded') { - return 'Stop it without --clean, which drains it through its own close, or let the host that started it close it.'; - } - return 'Stop it without --clean, which asks the running process to leave and reports what it answers.'; + return ownerRefusal( + record.ownership === 'embedded' ? 'embedded-owner' : 'owner-unproven', + record.identity?.pid, + ).hint; } function renderDaemonStop( diff --git a/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts b/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts index 2c87474440..087ed2c94a 100644 --- a/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts +++ b/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts @@ -170,6 +170,27 @@ for (const [ownership, reason, hint] of [ } }); +test('takeover refuses an embedded registration without a recorded host start time', async () => { + const { paths } = stateDirAndPaths(); + await registerDaemon(paths, { httpPort: 1, ownership: 'embedded' }); + const record = JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')); + delete record.processStartTime; + fs.writeFileSync(paths.infoPath, JSON.stringify(record)); + const info = readDaemonInfo(paths.infoPath); + assert.ok(info); + assert.equal(info.processStartTime, undefined); + const before = fs.readFileSync(paths.infoPath, 'utf8'); + const killSpy = vi.spyOn(process, 'kill'); + + const refusal = await refusalFrom(startup.retireDaemonForTakeover(info, paths)); + + assert.equal(refusal.details?.reason, DAEMON_LIFECYCLE_REASONS.ownerEmbedded); + assert.match(String(refusal.details?.hint), /owned by host process \d+\. Close it there/); + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), before); + assert.deepEqual(signalsSent(killSpy), []); + assert.equal(spawn.mock.calls.length, 0); +}); + test('the same record with no host owner is still the takeover this client performs', async (t) => { if (await skipWhenLoopbackUnavailable(t)) return; // One field differs from the row above, and it changes everything: a detached contender of the wrong diff --git a/src/host.test.ts b/src/host.test.ts index 672504b97c..a645fc824a 100644 --- a/src/host.test.ts +++ b/src/host.test.ts @@ -320,7 +320,7 @@ test('an outside stop refuses and the host keeps serving until its owner closes ).rejects.toMatchObject({ details: { reason: 'daemon-owner-embedded', - hint: `The runtime is owned by host process ${process.pid}. Close it there with the handle returned by startAgentDevice(). If the host has exited, use daemon stop or ensureDaemon() for this state directory to recover its abandoned registration.`, + hint: `The runtime is owned by host process ${process.pid}. Close it there with the handle returned by startAgentDevice(). Recovery with daemon stop or ensureDaemon() for this state directory retires the abandoned registration only when the recorded host lifetime has provably exited.`, }, }); expect(hostSignals(signals)).toEqual([]); From 180e669fc42ddc6d489b744f9e5085851a3fff93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 21 Oct 2026 04:56:37 +0200 Subject: [PATCH 8/8] refactor(daemon): require identities for owner refusal errors Co-Authored-By: Claude Opus 5.5 --- .../daemon-client-embedded-lifecycle.test.ts | 11 +++++++++-- src/daemon-client/daemon-lifecycle-errors.ts | 16 ++++++++-------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts b/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts index 087ed2c94a..6432a9170e 100644 --- a/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts +++ b/src/daemon-client/__tests__/daemon-client-embedded-lifecycle.test.ts @@ -137,13 +137,19 @@ async function crashedStaleRegistration(ownership: 'embedded' | 'unrecognized' | return { stateDir, paths, ...registered }; } -for (const [ownership, reason, hint] of [ +for (const [ownership, reason, message, hint] of [ [ 'embedded', DAEMON_LIFECYCLE_REASONS.ownerEmbedded, + 'A host process owns this runtime, so it was not retired.', /owned by host process \d+\. Close it there/, ], - ['sideways', 'daemon-owner-unrecognized', /Update agent-device/], + [ + 'sideways', + 'daemon-owner-unrecognized', + 'This daemon record states an owner this version cannot read, so this client did not replace it.', + /Update agent-device/, + ], ] as const) test(`a managed client preserves a ${ownership} owner`, async (t) => { if (await skipWhenLoopbackUnavailable(t)) return; @@ -159,6 +165,7 @@ for (const [ownership, reason, hint] of [ createDaemonTransport(createTestHostEnvironment())(request(stateDir)), ); assert.equal(refusal.details?.reason, reason); + assert.equal(refusal.message, message); assert.match(String(refusal.details?.hint), hint); assert.deepEqual(daemon.rpcCommands, [], 'the takeover was never attempted'); assert.equal(spawn.mock.calls.length, 0); diff --git a/src/daemon-client/daemon-lifecycle-errors.ts b/src/daemon-client/daemon-lifecycle-errors.ts index 0645a59743..fb03bfd840 100644 --- a/src/daemon-client/daemon-lifecycle-errors.ts +++ b/src/daemon-client/daemon-lifecycle-errors.ts @@ -162,27 +162,27 @@ export function daemonBusyError( }); } -/** Refuses stop or replacement when the record cannot prove a detached owner. */ +/** Refuses replacement when the record cannot prove a detached owner. */ function daemonRecordOwnerUnrecognizedError(params: { - info: DaemonIdentity | null; + info: DaemonIdentity; stateDir: string; }): AppError { const { info, stateDir } = params; return new AppError( 'COMMAND_FAILED', - 'This daemon record states an owner this version cannot read, so this client did not stop or replace it.', + 'This daemon record states an owner this version cannot read, so this client did not replace it.', { reason: DAEMON_RECORD_OWNER_UNRECOGNIZED_REASON, stateDir, - ...(info ? { daemon: daemonIdentityOf(info) } : {}), - hint: `Update agent-device if the record comes from a newer build. Otherwise check what owns ${info ? `pid ${info.pid}` : 'this registration'}, then repair the record: stop that process by its own means and remove ${shellQuoteIfNeeded(stateDir)}/daemon.json so a daemon can register again.`, + daemon: daemonIdentityOf(info), + hint: `Update agent-device if the record comes from a newer build. Otherwise check what owns pid ${info.pid}, then repair the record: stop that process by its own means and remove ${shellQuoteIfNeeded(stateDir)}/daemon.json so a daemon can register again.`, }, ); } /** The host that started an embedded runtime owns its close. */ export function daemonNotRetirableError(params: { - info: DaemonIdentity | null; + info: DaemonIdentity; stateDir: string; }): AppError { const { info, stateDir } = params; @@ -192,8 +192,8 @@ export function daemonNotRetirableError(params: { { reason: DAEMON_LIFECYCLE_REASONS.ownerEmbedded, stateDir, - ...(info ? { daemon: daemonIdentityOf(info) } : {}), - hint: embeddedCloseHint(info?.pid), + daemon: daemonIdentityOf(info), + hint: embeddedCloseHint(info.pid), }, ); }