Skip to content
7 changes: 7 additions & 0 deletions .changeset/embedded-owner-stop.md
Original file line number Diff line number Diff line change
@@ -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 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.
6 changes: 4 additions & 2 deletions docs/adr/0034-provider-plugin-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,16 @@ 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 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()`.

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. 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.
- **A runtime helper such as `definePlugin`.** It would make plugins depend on a runtime export of
Expand Down
55 changes: 18 additions & 37 deletions packages/daemon-contracts/src/daemon-registration-owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -44,7 +44,6 @@ import {
import {
buildDaemonShutdownReport,
resolveDaemonShutdownReportPath,
type DaemonShutdownCompletion,
type DaemonShutdownOutcome,
} from './daemon-shutdown-report.ts';
import { sweepDeadDaemonStartupPhasesFiles } from './daemon-startup-phases.ts';
Expand All @@ -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' }>
Expand All @@ -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<DaemonRegistrationRemoval | Readonly<{ state: 'retained' }>>;
}>;

Expand All @@ -98,7 +97,7 @@ export async function tryAcquireDaemonRegistration(
} catch (error) {
await releaseRegistrationAfterFailure(acquisition, error, boundPaths.logPath);
}
let published: Pick<DaemonRegistrationFields, 'ownership' | 'token'> | undefined;
let published: Pick<DaemonRegistrationFields, 'ownership'> | undefined;
return {
status: 'acquired',
owner: Object.freeze({
Expand All @@ -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 {
Expand All @@ -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();
}
Expand All @@ -159,27 +150,13 @@ export async function tryAcquireDaemonRegistration(
}),
};

async function writeShutdownRecord(
disposition: DaemonShutdownCompletion['outcome'],
outcome: DaemonShutdownOutcome | undefined,
unfinished: readonly string[],
): Promise<void> {
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<void> {
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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -534,7 +511,11 @@ async function proveExitWithoutSignaling(
observed: OwnerIdentity,
reason: 'identity-unverified' | 'embedded-owner' | 'owner-unproven' = 'identity-unverified',
): Promise<DaemonTerminationResult> {
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' }
Expand Down
4 changes: 1 addition & 3 deletions packages/daemon-contracts/src/daemon-registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}>;

/**
Expand All @@ -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 };
}

/**
Expand Down
21 changes: 6 additions & 15 deletions packages/daemon-contracts/src/daemon-resolution-quiesce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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', () => {
Expand Down
58 changes: 15 additions & 43 deletions packages/daemon-contracts/src/daemon-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -164,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, and a stop from outside reaches it only as a shutdown request.
* 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.
Expand Down Expand Up @@ -203,32 +198,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';
Expand All @@ -239,10 +210,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;

/**
Expand All @@ -262,8 +231,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. */
Expand All @@ -275,20 +243,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 },
Expand Down Expand Up @@ -327,3 +294,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(). Recovery with daemon stop or ensureDaemon() for this state directory retires the abandoned registration only when the recorded host lifetime has provably exited.`;
}
Loading
Loading