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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/adr/0006-daemon-rpc-protocol-version.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,29 @@ the gap and its reason instead of implying coverage. See `test/wire-compat/READM
Legacy remote daemons without `rpcProtocolVersion` remain reachable. This keeps the first release of
the proxy compatible with older HTTP daemons, but it means absence of the marker is not proof of
compatibility.

## Daemon capabilities and quiesce

A daemon on the local state directory publishes `capabilities` in `daemon.json`. This is the
command-level metadata the decision above prefers to a protocol bump. A daemon that predates a
capability omits it, so a client treats absence as "cannot do this" and never infers it from the
package version.

`quiesce` is how a client replaces an incompatible daemon without destroying other clients' work.
The client sends `daemon_quiesce` with the daemon token and the pid and start time recorded in
`daemon.json`. The daemon checks the token and that it is that instance, refuses a request that
reached it as a remote-origin one, then closes admission and counts what it holds in a single
synchronous step: requests in flight, sessions, repair sessions and live leases, and whether the
daemon is still starting up (`startingUp`), because before that it has no drain path. Any of them
refuses with `daemon-busy` and reopens admission in that same step. Otherwise it answers
`draining`, keeps admission closed, and shuts down. The request is answered before dispatch, so
it joins neither the in-flight count nor the command registry.

The daemon accepts a quiesce only when it can prove its own start time and the request names both
the pid and that start time; a daemon that cannot is never addressed. A client never signals a
daemon that did not accept one, except to retire it after it answered `draining`.

The census counts what the contract names and nothing else. A resumable upload still streaming and
a host-administration human-control hold that names no lease are outside it, the same boundary
idle reap draws, so a quiesce can end them. A daemon started with an HTTP auth hook marks every
HTTP request remote-origin and so refuses a quiesce over HTTP; the socket transport is unaffected.
3 changes: 3 additions & 0 deletions packages/daemon-contracts/src/daemon-registration-owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { withDaemonDiagnosticsScope } from './daemon-diagnostics-scope.ts';
import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature';
import {
resolveDaemonPaths,
type DaemonCapability,
type DaemonPaths,
type DaemonServerMode,
} from './daemon-resolution.ts';
Expand All @@ -51,6 +52,8 @@ export type DaemonRegistrationFields = Readonly<{
codeOrigin: DaemonCodeOrigin;
codeSignature: string;
policyDigest?: string;
/** What this daemon can do beyond the base RPC contract; absent from daemons that predate it. */
capabilities?: readonly DaemonCapability[];
}>;
type DaemonRegistrationRemoval =
| Readonly<{ state: 'removed' }>
Expand Down
54 changes: 53 additions & 1 deletion packages/daemon-contracts/src/daemon-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ export type { DaemonServerMode, DaemonTransportPreference };
* daemon both read this subset — the client to find or launch a daemon, the daemon to bind one —
* so it sits at the process root rather than under `packages/daemon/src/`, where a client would have to
* reach daemon internals to resolve an address. `packages/daemon/src/core/config.ts` adds the request-scoping
* rules that only the daemon applies on top of this.
* rules that only the daemon applies on top of this. What a daemon publishes about itself
* (`capabilities`) and the one request that asks it to leave (`quiesce`) belong here for the same
* reason: the client addresses a daemon with them and the daemon answers by them.
*/

export type DaemonPaths = {
Expand Down Expand Up @@ -94,3 +96,53 @@ export function resolveDaemonTransportPreference(
if (normalized === 'dual') return 'auto';
return 'auto';
}

// The quiesce request is how a client asks an idle daemon to leave so a compatible one can take
// its state directory. The client and the daemon share this vocabulary, so neither owns it.

/** 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];

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';
Comment thread
thymikee marked this conversation as resolved.

export const DAEMON_QUIESCE_REASONS = {
busy: 'daemon-busy',
instanceMismatch: 'daemon-instance-mismatch',
} as const;

/** The daemon instance a quiesce request is addressed to; any other instance refuses it. */
export type DaemonQuiesceFence = Readonly<{
pid: number;
/** Null when the daemon could not prove its start time, as its `daemon.json` records. */
processStartTime: string | null;
}>;

/**
* What a daemon holds. It leaves a quiesce request only when all four counts are zero and it is
* not still starting up, because before that it has no drain path to run.
*/
export type DaemonQuiesceCensus = Readonly<{
/** True from process start until the daemon can drain. */
startingUp: boolean;
inFlightRequests: number;
sessions: number;
repairSessions: number;
leases: number;
}>;

export function readDaemonQuiesceFence(input: unknown): DaemonQuiesceFence | undefined {
if (typeof input !== 'object' || input === null) return undefined;
const { pid, processStartTime } = input as Record<string, unknown>;
if (!Number.isInteger(pid)) return undefined;
if (processStartTime !== null && typeof processStartTime !== 'string') return undefined;
return { pid: pid as number, processStartTime };
}

export function readDaemonCapabilities(value: unknown): readonly DaemonCapability[] {
if (!Array.isArray(value)) return [];
return DAEMON_CAPABILITIES.filter((capability) => value.includes(capability));
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import { test } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import { DAEMON_SHUTTING_DOWN_REASON } from '@agent-device/daemon-contracts/daemon-resolution';
import {
makeSession,
makeRepairCompleteSession,
Expand Down Expand Up @@ -110,7 +111,7 @@ test('shutdown closes draft admission while allowing the current lifetime to set
store.closeAdmission();
assert.throws(
() => store.publish('late-draft', makeSession('late-draft')),
(error) => error instanceof AppError && error.details?.reason === 'daemon_shutting_down',
(error) => error instanceof AppError && error.details?.reason === DAEMON_SHUTTING_DOWN_REASON,
);
store.update(ref, { appName: 'Settled' });
assert.equal(store.requireCurrent(ref).appName, 'Settled');
Expand Down
Loading
Loading