Repository navigation
Expand file tree
/
Copy pathdaemon-lifecycle-errors.ts
More file actions
196 lines (182 loc) · 8.08 KB
/
Copy pathdaemon-lifecycle-errors.ts
File metadata and controls
196 lines (182 loc) · 8.08 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
import type { DaemonIdentity } from '@agent-device/contracts/client';
import {
DAEMON_LIFECYCLE_REASONS,
type DaemonLifecycleCensus,
type DaemonOwnership,
type DaemonPaths,
} from '@agent-device/daemon-contracts/daemon-resolution';
import type { DaemonRetirementResult } from '@agent-device/daemon-contracts/daemon-registration-owner';
import { AppError } from '@agent-device/kernel/errors';
import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell';
import { readVersion } from '@agent-device/host-kit/version';
import type { DaemonClientSettings } from './daemon-client-lifecycle.ts';
import {
getDaemonMetadataState,
resolveDaemonStartupHint,
type DaemonInfo,
} from './daemon-client-metadata.ts';
import { resolveLocalDaemonCodeIdentity, type DaemonTakeoverCause } from './daemon-launch-spec.ts';
// The ways an explicit lifecycle stops short of a usable daemon, whether this client judged it or the
// daemon answered it: `busy` is the daemon's own refusal, carried through so one constant names it on
// both sides. Callers branch on `details.reason`, so each reason is one constant here and the messages
// are free to change.
const DAEMON_UNAVAILABLE_REASONS = {
notRunning: 'daemon-not-running',
incompatible: 'daemon-incompatible',
busy: DAEMON_LIFECYCLE_REASONS.busy,
drainTimeout: 'daemon-drain-timeout',
} as const;
export function daemonIdentityOf(info: DaemonInfo): DaemonIdentity {
return {
pid: info.pid,
...(info.version !== undefined ? { version: info.version } : {}),
...(info.codeOrigin !== undefined ? { codeOrigin: info.codeOrigin } : {}),
...(info.codeSignature !== undefined ? { codeSignature: info.codeSignature } : {}),
...(info.processStartTime !== undefined ? { processStartTime: info.processStartTime } : {}),
};
}
async function readClientIdentity(): Promise<Omit<DaemonIdentity, 'pid' | 'processStartTime'>> {
const code = await resolveLocalDaemonCodeIdentity();
return {
version: readVersion(),
codeOrigin: code.origin,
...(code.origin === 'checkout' ? { codeSignature: code.codeSignature } : {}),
};
}
/** The command that ends a daemon deliberately; the one recovery every surface can name. */
export function daemonStopCommand(stateDir: string): string {
return `agent-device daemon stop --state-dir ${shellQuoteIfNeeded(stateDir)}`;
}
/** A daemon won a startup race that this caller may not settle by retiring it. */
export const DAEMON_CONTENDED_REASON = 'daemon-contended';
export function daemonContendedError(): AppError {
return new AppError(
'COMMAND_FAILED',
'Another process started a daemon while this one was starting.',
{
reason: DAEMON_CONTENDED_REASON,
},
);
}
export function daemonNotRunningError(stateDir: string, registered?: DaemonInfo): AppError {
return new AppError('COMMAND_FAILED', 'No daemon is running for this state directory.', {
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.` : ''}`,
});
}
export function daemonRetirementUnconfirmedError(
retirement: Extract<DaemonRetirementResult, { status: 'retained' }>,
paths: DaemonPaths,
): AppError {
return new AppError('COMMAND_FAILED', 'Daemon replacement could not be confirmed.', {
reason: 'daemon_retirement_unconfirmed',
retirement,
hint: retirement.error?.hint ?? resolveDaemonStartupHint(getDaemonMetadataState(paths), paths),
});
}
export function isDaemonNotRunningError(error: unknown): boolean {
return (
error instanceof AppError && error.details?.reason === DAEMON_UNAVAILABLE_REASONS.notRunning
);
}
export async function daemonIncompatibleError(params: {
info: DaemonInfo;
mismatch: DaemonTakeoverCause | 'newer' | 'no-quiesce';
stateDir: string;
}): Promise<AppError> {
const { info, mismatch, stateDir } = params;
return new AppError('COMMAND_FAILED', 'The running daemon cannot serve this client.', {
reason: DAEMON_UNAVAILABLE_REASONS.incompatible,
mismatch,
stateDir,
daemon: daemonIdentityOf(info),
client: await readClientIdentity(),
hint: `Stop it with ${daemonStopCommand(stateDir)}, then retry. SDK clients call ensureDaemon() to start a compatible one.`,
});
}
export async function daemonDrainTimeoutError(
info: DaemonInfo,
settings: Pick<DaemonClientSettings, 'paths'>,
): Promise<AppError> {
const stateDir = settings.paths.baseDir;
return new AppError(
'COMMAND_FAILED',
'The daemon accepted the request to leave but is still running.',
{
reason: DAEMON_UNAVAILABLE_REASONS.drainTimeout,
stateDir,
daemon: daemonIdentityOf(info),
hint: `It was not signaled. Retry once it has exited, or stop it deliberately with ${daemonStopCommand(stateDir)}.`,
},
);
}
export function daemonBusyError(
info: DaemonInfo,
stateDir: string,
census: DaemonLifecycleCensus | undefined,
): AppError {
return new AppError('COMMAND_FAILED', 'The running daemon is busy and was not replaced.', {
reason: DAEMON_UNAVAILABLE_REASONS.busy,
stateDir,
daemon: daemonIdentityOf(info),
...census,
hint: census?.startingUp
? `The daemon is still starting up. Retry in a moment; if it never finishes starting, stop it with ${daemonStopCommand(stateDir)}.`
: `Wait for its requests, sessions and leases to end and retry, or stop it deliberately with ${daemonStopCommand(stateDir)}.`,
});
}
/**
* A record states an owner this build does not recognize, so replacing the daemon it names would mean
* signaling a process whose owner is unknown — the one outcome the ownership field exists to prevent.
* Nothing was signaled, and the operator decides: a record written by a newer build is a version
* problem, and a hand-edited one is a corrupt state directory.
*/
export function daemonOwnershipUnrecognizedError(info: DaemonInfo, stateDir: string): AppError {
return new AppError(
'COMMAND_FAILED',
'This daemon record states an owner this version cannot read, so its daemon was not replaced.',
{
reason: DAEMON_LIFECYCLE_REASONS.ownerUnrecognized,
stateDir,
daemon: daemonIdentityOf(info),
ownership: info.ownership,
hint: `Update agent-device, or stop this daemon with ${daemonStopCommand(stateDir)} after checking what owns it.`,
},
);
}
/**
* 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 it.
*
* Both cases mean the same thing to a caller: this daemon is not yours to retire. `owner` says which
* side of that line it is on, so the hint names the route that does work. It is never an unreadable
* ownership: only the daemon's own answer about itself produces this refusal, and a record this build
* cannot read answers with no refusal at all — see {@link resolveTimeoutRecovery}.
*/
export function daemonNotRetirableError(params: {
info: DaemonInfo;
stateDir: string;
owner: Exclude<DaemonOwnership, 'unknown'>;
}): AppError {
const { info, stateDir, owner } = params;
const embedded = owner === 'embedded';
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.',
{
reason: embedded
? DAEMON_LIFECYCLE_REASONS.ownerEmbedded
: DAEMON_LIFECYCLE_REASONS.notEmbedded,
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.`
: `Stop it with ${daemonStopCommand(stateDir)}.`,
},
);
}