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
93 changes: 70 additions & 23 deletions packages/daemon-contracts/src/daemon-registration-owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ import {
resolveDaemonPaths,
type DaemonCapability,
type DaemonOwnership,
type ObservedDaemonIdentity,
type DaemonPaths,
type DaemonServerMode,
type ObservedDaemonIdentity,
} from './daemon-resolution.ts';
import { findUnrecoveredRepairCommitFailure } from './session-repair-tombstone.ts';
import {
Expand All @@ -44,6 +44,7 @@ import {
import {
buildDaemonShutdownReport,
resolveDaemonShutdownReportPath,
type DaemonShutdownCompletion,
type DaemonShutdownOutcome,
} from './daemon-shutdown-report.ts';
import { sweepDeadDaemonStartupPhasesFiles } from './daemon-startup-phases.ts';
Expand All @@ -61,14 +62,18 @@ 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;
}>;
type DaemonRegistrationRemoval =
| Readonly<{ state: 'removed' }>
| Exclude<RegisteredDaemonOwnership, { state: 'match' }>;
export type DaemonRegistrationOwner = Readonly<{
publish(fields: DaemonRegistrationFields): void;
finish(outcome?: DaemonShutdownOutcome): Promise<DaemonRegistrationRemoval>;
finish(
outcome?: DaemonShutdownOutcome,
close?: Readonly<{ retainRegistration: boolean; unfinished: readonly string[] }>,
): Promise<DaemonRegistrationRemoval | Readonly<{ state: 'retained' }>>;
}>;

export async function tryAcquireDaemonRegistration(
Expand All @@ -93,6 +98,7 @@ export async function tryAcquireDaemonRegistration(
} catch (error) {
await releaseRegistrationAfterFailure(acquisition, error, boundPaths.logPath);
}
let published: Pick<DaemonRegistrationFields, 'ownership' | 'token'> | undefined;
return {
status: 'acquired',
owner: Object.freeze({
Expand All @@ -118,20 +124,72 @@ export async function tryAcquireDaemonRegistration(
),
mode: 0o600,
});
published = { ownership: fields.ownership, token: fields.token };
},
async finish(outcome?: DaemonShutdownOutcome) {
let removal: DaemonRegistrationRemoval;
async finish(
outcome?: DaemonShutdownOutcome,
close?: Readonly<{ retainRegistration: boolean; unfinished: readonly string[] }>,
) {
let removal: DaemonRegistrationRemoval | Readonly<{ state: 'retained' }>;
try {
if (outcome) writeShutdownReport(boundPaths.baseDir, outcome, acquisition);
removal = removeRegistrationUnderLock(boundPaths.infoPath, identity, acquisition);
acquisition.assertHeld();
removal = close?.retainRegistration
? { state: 'retained' }
: removeRegistrationUnderLock(boundPaths.infoPath, identity, acquisition);
} catch (error) {
await writeShutdownRecord(
'retained',
outcome,
close?.unfinished ?? ['Daemon shutdown: registration removal failed.'],
);
if (published?.ownership === 'embedded') throw error;
return await releaseRegistrationAfterFailure(acquisition, error, boundPaths.logPath);
}
await acquisition.release();
const released = removal.state === 'removed' || removal.state === 'absent';
await writeShutdownRecord(
released ? 'released' : 'retained',
outcome,
close?.unfinished ?? ['Daemon shutdown: close details are unavailable.'],
);
if (removal.state !== 'retained' && (released || published?.ownership !== 'embedded')) {
await acquisition.release();
}
return removal;
},
}),
};

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;
try {
acquisition.assertHeld();
publishFileSync({
destination: resolveDaemonShutdownReportPath(boundPaths.baseDir),
contents: `${JSON.stringify({ ...(outcome ? buildDaemonShutdownReport(outcome) : {}), ...(completion ? { completion } : {}) })}\n`,
mode: 0o600,
});
} catch (error) {
await recordRegistrationWarning(
boundPaths.logPath,
'daemon_shutdown_record_failed',
error,
).catch(() => {});
}
}
}

function removeRegistrationUnderLock(
Expand Down Expand Up @@ -159,26 +217,15 @@ async function releaseRegistrationAfterFailure(
try {
await acquisition.release();
} catch (releaseError) {
await recordRegistrationWarning(logPath, 'daemon_registration_release_failed', releaseError);
await recordRegistrationWarning(
logPath,
'daemon_registration_release_failed',
releaseError,
).catch(() => {});
}
throw error;
}

function writeShutdownReport(
stateDir: string,
outcome: DaemonShutdownOutcome,
acquisition: ProcessLockAcquisition,
): void {
const filePath = resolveDaemonShutdownReportPath(stateDir);
const contents = `${JSON.stringify(buildDaemonShutdownReport(outcome))}\n`;
acquisition.assertHeld();
try {
publishFileSync({ destination: filePath, contents, mode: 0o600 });
} catch {
return;
}
}

/**
* The daemon's stdout and stderr append to this inode, so it is emptied in place, never
* replaced. On POSIX the append handle that creates the file also permits truncation, and
Expand Down
9 changes: 7 additions & 2 deletions packages/daemon-contracts/src/daemon-registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ 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 @@ -219,8 +220,12 @@ export type DaemonStopIdentity = Readonly<{
export function readDaemonStopIdentity(infoPath: string): DaemonStopIdentity {
const reading = readDaemonRegistration(infoPath);
if (reading.status !== 'present')
return { ownership: reading.status === 'absent' ? 'absent' : 'unreadable', identity: null };
return { ownership: reading.ownership, identity: reading.identity };
return {
ownership: reading.status === 'absent' ? 'absent' : 'unreadable',
identity: null,
record: null,
};
return { ownership: reading.ownership, identity: reading.identity, record: reading.record };
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ test('a fence names an integer pid and a start time or null, nothing else', () =
assert.equal(readDaemonLifecycleFence('7'), undefined);
});

test('a quiesce carries the original instance fence', () => {
test('both lifecycle requests carry one fence shape under the command of their mode', () => {
const fence = { pid: 7, processStartTime: 'Fri Oct 17 09:00:00 2026' };
for (const mode of ['quiesce'] as const) {
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);
Expand Down
23 changes: 20 additions & 3 deletions packages/daemon-contracts/src/daemon-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,12 @@ 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 @@ -158,7 +164,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 an external stop refuses it.
* runtime through its own close, and a stop from outside reaches it only as a shutdown request.
* - `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 @@ -197,10 +203,15 @@ export function readDaemonOwnership(value: unknown): DaemonOwnership {
: 'unknown';
}

/** The command carrying the fenced lifecycle request. */
/**
* 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;

Expand All @@ -212,6 +223,8 @@ export function readDaemonLifecycleMode(command: string): DaemonLifecycleMode |
switch (command) {
case DAEMON_LIFECYCLE_COMMANDS.quiesce:
return 'quiesce';
case DAEMON_LIFECYCLE_COMMANDS.shutdown:
return 'shutdown';
default:
return undefined;
}
Expand All @@ -228,6 +241,8 @@ export const DAEMON_LIFECYCLE_REASONS = {
instanceMismatch: 'daemon-instance-mismatch',
/** A quiesce was refused because 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 @@ -246,7 +261,9 @@ 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.
* 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.
*/
export type DaemonLifecycleCensus = Readonly<{
/** True from process start until the daemon can drain. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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);
});
Loading
Loading