@zgeoff/atc 3.1.1 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/package.json +4 -3
- package/src/build-own-daemon-process.ts +16 -0
- package/src/build-restart-failure.ts +24 -0
- package/src/cli.ts +96 -0
- package/src/client/boot-daemon.ts +5 -32
- package/src/collect-restart-env.ts +18 -0
- package/src/collect-restart-plan.ts +157 -0
- package/src/daemon/build-scoped-context.ts +4 -2
- package/src/daemon/daemon-connection.ts +4 -1
- package/src/daemon/daemon-context.ts +12 -1
- package/src/daemon/daemon.ts +26 -4
- package/src/daemon/local-pty-provider.ts +83 -10
- package/src/daemon/read-native-env-keys.ts +74 -0
- package/src/daemon-process.ts +12 -0
- package/src/find-restart-unit.ts +45 -0
- package/src/format-restart-preflight.ts +74 -0
- package/src/format-restart-report.ts +45 -0
- package/src/is-daemon-command-line.ts +33 -0
- package/src/is-daemon-process.ts +33 -0
- package/src/mcp/require-daemon-features.ts +1 -0
- package/src/mcp/run-tool.ts +70 -15
- package/src/parse-daemon-flags.ts +33 -0
- package/src/parse-mismatch-build.ts +19 -0
- package/src/parse-restart-result.ts +65 -0
- package/src/parse-systemd-unit.ts +25 -0
- package/src/protocol/daemon-features.ts +5 -0
- package/src/protocol/protocol.ts +2 -0
- package/src/protocol/request-param-schemas.ts +6 -0
- package/src/read-daemon-process.ts +50 -0
- package/src/read-fleet-snapshot.ts +37 -0
- package/src/read-restart-result.ts +17 -0
- package/src/read-stored-fleet-file.ts +74 -0
- package/src/read-stored-rows.ts +36 -0
- package/src/remove-restart-result.ts +11 -0
- package/src/restart-daemon.ts +149 -0
- package/src/restart-plan.ts +33 -0
- package/src/run-daemon-restart-worker.ts +77 -0
- package/src/run-daemon-restart.ts +228 -0
- package/src/run-systemctl.ts +33 -0
- package/src/shared/build-atc-command.ts +13 -0
- package/src/shared/config.ts +5 -0
- package/src/shared/find-pid-file-pid.ts +15 -0
- package/src/shared/get-build.ts +4 -3
- package/src/shared/is-process-alive.ts +21 -0
- package/src/shared/spawn-atc-detached.ts +34 -0
- package/src/start-replacement-daemon.ts +31 -0
- package/src/stop-daemon-process.ts +46 -0
- package/src/store/state-store.ts +2 -2
- package/src/test-utils/can-bind-addresses.ts +19 -0
- package/src/test-utils/create-migrated-state-db.ts +38 -0
- package/src/test-utils/env-originals.ts +6 -0
- package/src/test-utils/fixture-dir-provider.ts +131 -0
- package/src/test-utils/fixture-imp-port.ts +1692 -0
- package/src/test-utils/get-agent-entry.ts +17 -0
- package/src/test-utils/get-gateway-config.ts +18 -0
- package/src/test-utils/isolate-home.ts +60 -0
- package/src/test-utils/read-json-record.ts +20 -0
- package/src/test-utils/remove-env-overrides.ts +18 -0
- package/src/test-utils/run-legacy-daemon.ts +38 -0
- package/src/test-utils/run-listener-daemon.ts +51 -0
- package/src/test-utils/run-mcp-authorization.ts +118 -0
- package/src/test-utils/run-source-daemon.ts +110 -0
- package/src/test-utils/setup-fake-systemd.ts +125 -0
- package/src/test-utils/setup-mcp-http.ts +126 -0
- package/src/test-utils/setup-temp-dir.ts +33 -0
- package/src/test-utils/spawn-named-session.ts +27 -0
- package/src/test-utils/start-cut-proxy.ts +175 -0
- package/src/test-utils/start-git-http-server.ts +102 -0
- package/src/test-utils/start-legacy-daemon.ts +111 -0
- package/src/test-utils/subscribe-to-socket-lines.ts +60 -0
- package/src/test-utils/update-env.ts +19 -0
- package/src/test-utils/wait-for.ts +38 -0
- package/src/verify-restored-fleet.ts +155 -0
- package/src/wait-for-replacement.ts +112 -0
- package/src/write-restart-result.ts +17 -0
- /package/src/{daemon → shared}/claim-daemon-lock.ts +0 -0
- /package/src/{daemon → shared}/load-listener-tokens.ts +0 -0
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
HarnessHandle,
|
|
11
11
|
HarnessSpec,
|
|
12
12
|
} from './execution-provider';
|
|
13
|
+
import { readNativeEnvKeys } from './read-native-env-keys';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* The `local-pty` provider: harnesses run as child processes of the daemon
|
|
@@ -51,13 +52,21 @@ export class LocalPTYProvider implements ExecutionProvider {
|
|
|
51
52
|
);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
const env = buildPTYEnv(spec);
|
|
56
|
+
const bin = resolveHarnessBin(spec.bin, env, spec.cwd);
|
|
57
|
+
const unset = buildUnsetNames(env, [...readNativeEnvKeys(), ...Object.keys(process.env)]);
|
|
58
|
+
|
|
59
|
+
const pty = spawn(
|
|
60
|
+
ENV_BIN,
|
|
61
|
+
buildEnvArgs(unset, buildDYLDEntries(env, process.platform), bin, spec.args),
|
|
62
|
+
{
|
|
63
|
+
name: 'xterm-256color',
|
|
64
|
+
cols: spec.cols,
|
|
65
|
+
rows: spec.rows,
|
|
66
|
+
cwd: spec.cwd,
|
|
67
|
+
env,
|
|
68
|
+
},
|
|
69
|
+
);
|
|
61
70
|
|
|
62
71
|
const subscriptions = new Set<{ readonly dispose: () => void }>();
|
|
63
72
|
|
|
@@ -185,9 +194,7 @@ const USABLE_TERM = 'xterm-256color';
|
|
|
185
194
|
|
|
186
195
|
// A pseudo-terminal always has a terminal on its far side, so a harness never
|
|
187
196
|
// starts with an empty or dumb TERM, which leaves an agent CLI drawing with no
|
|
188
|
-
// colour.
|
|
189
|
-
// daemon itself started with, and the keys passed here only add to it or
|
|
190
|
-
// override it, so leaving TERM out hands the child the daemon's own value.
|
|
197
|
+
// colour.
|
|
191
198
|
function buildPTYEnv(spec: HarnessSpec): Record<string, string> {
|
|
192
199
|
const env = collectCleanEnv(spec.env, spec.withheldEnv);
|
|
193
200
|
const term = env['TERM'];
|
|
@@ -198,6 +205,72 @@ function buildPTYEnv(spec: HarnessSpec): Record<string, string> {
|
|
|
198
205
|
};
|
|
199
206
|
}
|
|
200
207
|
|
|
208
|
+
// The PTY library starts its child from the environment the daemon started
|
|
209
|
+
// with and lays the map over it, so the harness starts behind `env`, which
|
|
210
|
+
// unsets every name the map leaves out before it replaces itself with the
|
|
211
|
+
// harness. The pid stays the harness's own.
|
|
212
|
+
const ENV_BIN = '/usr/bin/env';
|
|
213
|
+
|
|
214
|
+
// The program is found on the map's PATH before the harness starts, so a
|
|
215
|
+
// missing program fails the spawn as the PTY library fails it, instead of
|
|
216
|
+
// starting `env` only to exit.
|
|
217
|
+
function resolveHarnessBin(
|
|
218
|
+
bin: string,
|
|
219
|
+
env: Readonly<Record<string, string>>,
|
|
220
|
+
cwd: string,
|
|
221
|
+
): string {
|
|
222
|
+
const resolved = Bun.which(bin, { PATH: env['PATH'] ?? '', cwd });
|
|
223
|
+
|
|
224
|
+
if (resolved === null) {
|
|
225
|
+
throw new Error(`PTY spawn failed: ${bin} is not a program the harness can start`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return resolved;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// A name the native environment block cannot hold, such as one with `=`,
|
|
232
|
+
// is left alone, since `env` refuses to unset it.
|
|
233
|
+
function buildUnsetNames(
|
|
234
|
+
env: Readonly<Record<string, string>>,
|
|
235
|
+
inherited: readonly string[],
|
|
236
|
+
): string[] {
|
|
237
|
+
return [...new Set(inherited)].filter(
|
|
238
|
+
(name) => name !== '' && !name.includes('=') && !Object.hasOwn(env, name),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// macOS clears every `DYLD_` variable on the way into `env`, a protected
|
|
243
|
+
// system program, so the map's own ones are set again after the unsets, the
|
|
244
|
+
// one place the argv holds a value.
|
|
245
|
+
function buildDYLDEntries(
|
|
246
|
+
env: Readonly<Record<string, string>>,
|
|
247
|
+
platform: NodeJS.Platform,
|
|
248
|
+
): string[] {
|
|
249
|
+
if (platform !== 'darwin') {
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return Object.entries(env)
|
|
254
|
+
.filter(([name]) => name.startsWith('DYLD_'))
|
|
255
|
+
.map(([name, value]) => `${name}=${value}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// `env` reads a word holding `=` as an assignment even after `--`, so a
|
|
259
|
+
// program path holding one starts through a shell that runs it by its first
|
|
260
|
+
// argument.
|
|
261
|
+
const SHELL_EXEC = 'exec "$0" "$@"';
|
|
262
|
+
|
|
263
|
+
function buildEnvArgs(
|
|
264
|
+
unset: readonly string[],
|
|
265
|
+
entries: readonly string[],
|
|
266
|
+
bin: string,
|
|
267
|
+
args: readonly string[],
|
|
268
|
+
): string[] {
|
|
269
|
+
const program = bin.includes('=') ? ['/bin/sh', '-c', SHELL_EXEC, bin] : [bin];
|
|
270
|
+
|
|
271
|
+
return [...unset.flatMap((name) => ['-u', name]), '--', ...entries, ...program, ...args];
|
|
272
|
+
}
|
|
273
|
+
|
|
201
274
|
// A process another user owns still runs, so only a missing process counts
|
|
202
275
|
// as gone.
|
|
203
276
|
function isProcessRunning(pid: number): boolean {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { CString, FFIType, dlopen, read } from 'bun:ffi';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The names in the daemon process's native environment: the block a
|
|
6
|
+
* native library reads when it starts a child, as opposed to the copy Bun
|
|
7
|
+
* keeps for `process.env`. Changes to `process.env` never reach that
|
|
8
|
+
* block, so it keeps every variable the daemon started with, including
|
|
9
|
+
* ones deleted from `process.env` since.
|
|
10
|
+
*
|
|
11
|
+
* Linux reads the block from `/proc/self/environ`; macOS walks the live
|
|
12
|
+
* `environ` array that `_NSGetEnviron` returns. Any other platform throws,
|
|
13
|
+
* since a harness started without the list could inherit a withheld
|
|
14
|
+
* variable.
|
|
15
|
+
*/
|
|
16
|
+
export function readNativeEnvKeys(): string[] {
|
|
17
|
+
const keys = new Set<string>();
|
|
18
|
+
|
|
19
|
+
for (const entry of readNativeEnvEntries()) {
|
|
20
|
+
const eq = entry.indexOf('=');
|
|
21
|
+
|
|
22
|
+
if (eq > 0) {
|
|
23
|
+
keys.add(entry.slice(0, eq));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return [...keys];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readNativeEnvEntries(): string[] {
|
|
31
|
+
if (process.platform === 'linux') {
|
|
32
|
+
return readFileSync('/proc/self/environ', 'utf8').split('\0');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (process.platform === 'darwin') {
|
|
36
|
+
return readDarwinEnviron();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
throw new Error(`atc cannot read the native environment on ${process.platform}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const POINTER_BYTES = 8;
|
|
43
|
+
|
|
44
|
+
// `_NSGetEnviron` returns a pointer to `environ`, a NULL-terminated array of
|
|
45
|
+
// `NAME=value` C strings.
|
|
46
|
+
function readDarwinEnviron(): string[] {
|
|
47
|
+
const libSystem = dlopen('/usr/lib/libSystem.B.dylib', {
|
|
48
|
+
_NSGetEnviron: { args: [], returns: FFIType.ptr },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// oxlint-disable-next-line no-underscore-dangle -- the libSystem symbol carries the underscore
|
|
53
|
+
const environ = libSystem.symbols._NSGetEnviron();
|
|
54
|
+
|
|
55
|
+
if (environ === null) {
|
|
56
|
+
throw new Error('atc cannot read the native environment: _NSGetEnviron returned NULL');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const array = read.ptr(environ);
|
|
60
|
+
const entries: string[] = [];
|
|
61
|
+
|
|
62
|
+
for (let offset = 0; ; offset += POINTER_BYTES) {
|
|
63
|
+
const entry = read.ptr(array, offset);
|
|
64
|
+
|
|
65
|
+
if (entry === 0) {
|
|
66
|
+
return entries;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
entries.push(new CString(entry));
|
|
70
|
+
}
|
|
71
|
+
} finally {
|
|
72
|
+
libSystem.close();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DaemonFlags } from './parse-daemon-flags';
|
|
2
|
+
|
|
3
|
+
// What a daemon was started with.
|
|
4
|
+
export interface DaemonProcess {
|
|
5
|
+
readonly env: Readonly<Record<string, string>>;
|
|
6
|
+
readonly cwd: string | null;
|
|
7
|
+
readonly flags: DaemonFlags;
|
|
8
|
+
|
|
9
|
+
// False when `/proc` could not be read, in which case `env` is this
|
|
10
|
+
// process's own.
|
|
11
|
+
readonly fromProc: boolean;
|
|
12
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parseSystemdUnit } from './parse-systemd-unit';
|
|
4
|
+
import { runSystemctl } from './run-systemctl';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The user service whose main process is the daemon with this pid, or null
|
|
8
|
+
* when the daemon is not one.
|
|
9
|
+
*
|
|
10
|
+
* Two facts must hold. The daemon's cgroup sits in a `<name>.service` below
|
|
11
|
+
* the user manager, and the manager reports that unit's `MainPID` as this
|
|
12
|
+
* pid. The cgroup alone proves nothing: every process started from a
|
|
13
|
+
* session inside the unit, such as a daemon a test starts, shares the
|
|
14
|
+
* unit's cgroup without being the process the unit runs. `ATC_PROC_ROOT`
|
|
15
|
+
* moves the cgroup read from `/proc` to another directory, so a test can
|
|
16
|
+
* place a daemon in a unit without a user manager.
|
|
17
|
+
*/
|
|
18
|
+
export async function findRestartUnit(pid: number): Promise<string | null> {
|
|
19
|
+
const uid = process.getuid?.();
|
|
20
|
+
|
|
21
|
+
if (uid === undefined) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let cgroup: string;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
cgroup = readFileSync(
|
|
29
|
+
join(process.env['ATC_PROC_ROOT'] ?? '/proc', String(pid), 'cgroup'),
|
|
30
|
+
'utf8',
|
|
31
|
+
);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const unit = parseSystemdUnit(cgroup, uid);
|
|
37
|
+
|
|
38
|
+
if (unit === null) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const shown = await runSystemctl(['show', '-p', 'MainPID', '--value', unit]);
|
|
43
|
+
|
|
44
|
+
return shown.code === 0 && shown.stdout === String(pid) ? unit : null;
|
|
45
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { parseMismatchBuild } from './parse-mismatch-build';
|
|
2
|
+
import type { RestartPlan } from './restart-plan';
|
|
3
|
+
|
|
4
|
+
const INTERRUPTION_POLICY =
|
|
5
|
+
'Stopping the daemon ends every agent process it hosts. A session that is mid-turn loses that turn; the restore resumes each session from its transcript, and the interrupted turn does not continue.';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Renders what a restart is about to do: the daemon it stops, the sessions
|
|
9
|
+
* that are mid-turn, the replacement it starts, and the interruption
|
|
10
|
+
* policy.
|
|
11
|
+
*/
|
|
12
|
+
export function formatRestartPreflight(plan: RestartPlan): string[] {
|
|
13
|
+
return [...formatTarget(plan), ...formatReplacement(plan), INTERRUPTION_POLICY];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatTarget(plan: RestartPlan): string[] {
|
|
17
|
+
const pid = plan.pid === null ? 'pid unknown' : `pid ${plan.pid}`;
|
|
18
|
+
|
|
19
|
+
if (plan.answer === null) {
|
|
20
|
+
return [
|
|
21
|
+
plan.pid === null
|
|
22
|
+
? 'no daemon answers and no live pid is recorded; the restart only starts one and restores the fleet'
|
|
23
|
+
: `the daemon (${pid}) does not answer; the restart stops it, starts one, and restores the fleet`,
|
|
24
|
+
];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (plan.answer.kind === 'refused') {
|
|
28
|
+
const parsed = parseMismatchBuild(plan.answer.message);
|
|
29
|
+
|
|
30
|
+
return [
|
|
31
|
+
`daemon: ${pid} refused this build's handshake: ${plan.answer.message}`,
|
|
32
|
+
...(parsed === null
|
|
33
|
+
? []
|
|
34
|
+
: [`daemon build ${parsed.build} speaks protocol v${parsed.protocol}`]),
|
|
35
|
+
'The session states cannot be read across the protocol mismatch, so the sessions that are mid-turn are unknown.',
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return [
|
|
40
|
+
`daemon: ${pid}, build ${plan.answer.build}, protocol v${plan.answer.protocol}`,
|
|
41
|
+
...formatSessions(plan),
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatSessions(plan: RestartPlan): string[] {
|
|
46
|
+
if (plan.sessions === null) {
|
|
47
|
+
return ['The session list could not be read, so the sessions that are mid-turn are unknown.'];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const running = plan.sessions.filter((session) => session.state === 'running');
|
|
51
|
+
|
|
52
|
+
if (running.length === 0) {
|
|
53
|
+
return ['no session is mid-turn'];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return [
|
|
57
|
+
`${running.length} session${running.length === 1 ? ' is' : 's are'} mid-turn:`,
|
|
58
|
+
...running.map(
|
|
59
|
+
(session) =>
|
|
60
|
+
` ${session.name} (${session.id})${session.id === plan.callerSession ? ' (this session)' : ''}`,
|
|
61
|
+
),
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatReplacement(plan: RestartPlan): string[] {
|
|
66
|
+
if (plan.replacement.kind === 'unit') {
|
|
67
|
+
return [
|
|
68
|
+
`replacement: systemd unit ${plan.replacement.unit}${plan.replacement.execStart === null ? '' : `, ExecStart ${plan.replacement.execStart}`}`,
|
|
69
|
+
'The unit decides the build the replacement runs.',
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return [`replacement: build ${plan.replacement.build}, started with ${plan.replacement.command}`];
|
|
74
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { RestartResult } from './parse-restart-result';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Renders a finished restart as the lines the run prints: the daemon now
|
|
5
|
+
* serving, how much of the fleet came back, each row that did not, and the
|
|
6
|
+
* sessions the restart interrupted.
|
|
7
|
+
*/
|
|
8
|
+
export function formatRestartReport(result: RestartResult): string[] {
|
|
9
|
+
const lines: string[] = [];
|
|
10
|
+
|
|
11
|
+
if (result.error !== null) {
|
|
12
|
+
lines.push(`restart failed: ${result.error}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (result.pid !== null) {
|
|
16
|
+
lines.push(`daemon pid ${result.pid}, build ${result.build ?? 'unknown'}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (result.listenPort !== null) {
|
|
20
|
+
lines.push(`listening on port ${result.listenPort}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (result.error === null) {
|
|
24
|
+
lines.push(`restored ${result.restored} of ${result.total}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const row of result.failed) {
|
|
28
|
+
lines.push(`failed: ${row.name} (${row.id}): ${row.reason}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (result.interrupted.length > 0) {
|
|
32
|
+
lines.push(`interrupted ${result.interrupted.length} mid-turn:`);
|
|
33
|
+
|
|
34
|
+
for (const row of result.interrupted) {
|
|
35
|
+
lines.push(` ${row.name} (${row.id})`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const verdict =
|
|
40
|
+
result.code === 0 ? 'restart succeeded' : `restart failed with exit code ${result.code}`;
|
|
41
|
+
|
|
42
|
+
lines.push(verdict);
|
|
43
|
+
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether a command line starts the atc daemon: the atc entry (the `atc`
|
|
5
|
+
* shim or a compiled `atc` binary as the program, or the CLI source file as
|
|
6
|
+
* the runtime's script) directly followed by the `daemon` subcommand, and
|
|
7
|
+
* then nothing, a flag, or `serve`. Any other program that merely passes a
|
|
8
|
+
* `daemon` argument is not the daemon.
|
|
9
|
+
*/
|
|
10
|
+
export function isDaemonCommandLine(args: readonly string[]): boolean {
|
|
11
|
+
const at = args.indexOf('daemon');
|
|
12
|
+
|
|
13
|
+
if (at !== 1 && at !== 2) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const entry = args[at - 1] ?? '';
|
|
18
|
+
const next = args[at + 1];
|
|
19
|
+
|
|
20
|
+
return isATCEntry(entry, at) && (next === undefined || next === 'serve' || next.startsWith('--'));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The program itself when it sits first, or the script a runtime loads when
|
|
24
|
+
// it sits second.
|
|
25
|
+
function isATCEntry(entry: string, at: number): boolean {
|
|
26
|
+
const name = basename(entry);
|
|
27
|
+
|
|
28
|
+
if (at === 1 && /^atc(?:-[a-z0-9]+)*$/.test(name)) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return at === 2 && (entry.endsWith('/src/cli.ts') || entry.endsWith('/bin/atc'));
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { userInfo } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { isDaemonCommandLine } from './is-daemon-command-line';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether the process with this pid runs `atc daemon` on this state
|
|
8
|
+
* directory: its command line starts the daemon, and the home in its
|
|
9
|
+
* environment puts its state directory at `stateDir`. A daemon of another
|
|
10
|
+
* checkout or another test home is not this one. A pid that cannot be read
|
|
11
|
+
* is not verified, so it reads as false: a recorded pid may have been reused
|
|
12
|
+
* by an unrelated process since the daemon died.
|
|
13
|
+
*/
|
|
14
|
+
export function isDaemonProcess(pid: number, stateDir: string): boolean {
|
|
15
|
+
let cmdline: string;
|
|
16
|
+
let environ: string;
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
cmdline = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
20
|
+
environ = readFileSync(`/proc/${pid}/environ`, 'utf8');
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!isDaemonCommandLine(cmdline.split('\0').filter((arg) => arg !== ''))) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const homeVar = environ.split('\0').find((entry) => entry.startsWith('HOME='));
|
|
30
|
+
const home = homeVar === undefined || homeVar === 'HOME=' ? userInfo().homedir : homeVar.slice(5);
|
|
31
|
+
|
|
32
|
+
return join(home, '.local', 'state', 'atc') === stateDir;
|
|
33
|
+
}
|
|
@@ -11,6 +11,7 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
|
|
|
11
11
|
'message.turn': "atc_message_get's turn and answeredWith",
|
|
12
12
|
'message.wait': "atc_message_get's waitMs",
|
|
13
13
|
'session.forget': 'atc_session_forget',
|
|
14
|
+
'session.forget.preconditions': "atc_session_forget's pinned and live checks in the daemon",
|
|
14
15
|
'session.locator': "a session's locator",
|
|
15
16
|
'session.submit': 'atc_session_input',
|
|
16
17
|
'spawn.idempotency': "atc_session_spawn's idempotencyKey",
|
package/src/mcp/run-tool.ts
CHANGED
|
@@ -147,18 +147,26 @@ export function runTool(
|
|
|
147
147
|
return { text: 'killed', structured: null };
|
|
148
148
|
})
|
|
149
149
|
.with('atc_session_forget', async () => {
|
|
150
|
-
|
|
150
|
+
const params = {
|
|
151
|
+
session: args['session'],
|
|
152
|
+
...(typeof args['confirmToken'] === 'string' ? { confirmToken: args['confirmToken'] } : {}),
|
|
153
|
+
};
|
|
151
154
|
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
155
|
+
const stops = args['stop'] === true;
|
|
156
|
+
|
|
157
|
+
const features = await caller.readFeatures();
|
|
158
|
+
|
|
159
|
+
if (features.has('session.forget.preconditions')) {
|
|
160
|
+
const ok = await trySendGuardedForget(caller, params, stops);
|
|
161
|
+
|
|
162
|
+
if (ok !== null) {
|
|
163
|
+
return buildObjectResult(ok);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await requireForgettable(caller, args['session'], stops);
|
|
168
|
+
|
|
169
|
+
const ok = await caller.sendRequest('session.forget', params, ['session.forget']);
|
|
162
170
|
|
|
163
171
|
return buildObjectResult(ok);
|
|
164
172
|
})
|
|
@@ -285,6 +293,55 @@ function buildObjectResult(value: unknown): ToolResult {
|
|
|
285
293
|
};
|
|
286
294
|
}
|
|
287
295
|
|
|
296
|
+
// The refusals of a forget, worded for an MCP client.
|
|
297
|
+
const SESSION_PINNED_REFUSAL =
|
|
298
|
+
'session_pinned: the session is pinned, or is a sub-session of a pinned session. Unpin it with atc_session_update before forgetting it.';
|
|
299
|
+
|
|
300
|
+
const SESSION_LIVE_REFUSAL =
|
|
301
|
+
'session_live: the session is live. Pass stop: true to stop and forget it.';
|
|
302
|
+
|
|
303
|
+
// The daemon checks pinned and live in the same step that forgets, so a pin
|
|
304
|
+
// or revive that lands just before the forget still refuses it. Null when
|
|
305
|
+
// the daemon a gateway routes the forget to lacks the checks; the caller
|
|
306
|
+
// then checks the session itself. A refusal keeps the wording of the
|
|
307
|
+
// caller's own check.
|
|
308
|
+
async function trySendGuardedForget(
|
|
309
|
+
caller: FleetCaller,
|
|
310
|
+
params: Readonly<Record<string, unknown>>,
|
|
311
|
+
stops: boolean,
|
|
312
|
+
): Promise<Readonly<Record<string, unknown>> | null> {
|
|
313
|
+
try {
|
|
314
|
+
return await caller.sendRequest(
|
|
315
|
+
'session.forget',
|
|
316
|
+
{ ...params, refusePinned: true, refuseLive: !stops },
|
|
317
|
+
['session.forget', 'session.forget.preconditions'],
|
|
318
|
+
);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (!(error instanceof Error) || !('code' in error)) {
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (
|
|
325
|
+
error.code === 'daemon_outdated' &&
|
|
326
|
+
'data' in error &&
|
|
327
|
+
isRecord(error.data) &&
|
|
328
|
+
error.data['feature'] === 'session.forget.preconditions'
|
|
329
|
+
) {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (error.code === 'session_pinned') {
|
|
334
|
+
throw new Error(SESSION_PINNED_REFUSAL, { cause: error });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (error.code === 'session_live') {
|
|
338
|
+
throw new Error(SESSION_LIVE_REFUSAL, { cause: error });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
288
345
|
// A forget is for good, so it reads the session first: an unseen or unknown
|
|
289
346
|
// session fails here before the daemon mints a token, and a pinned or live
|
|
290
347
|
// session is refused with the step that unblocks it.
|
|
@@ -306,13 +363,11 @@ async function requireForgettable(
|
|
|
306
363
|
}
|
|
307
364
|
|
|
308
365
|
if (pinned) {
|
|
309
|
-
throw new Error(
|
|
310
|
-
'session_pinned: the session is pinned, or is a sub-session of a pinned session. Unpin it with atc_session_update before forgetting it.',
|
|
311
|
-
);
|
|
366
|
+
throw new Error(SESSION_PINNED_REFUSAL);
|
|
312
367
|
}
|
|
313
368
|
|
|
314
369
|
if (descriptor['alive'] === true && !stops) {
|
|
315
|
-
throw new Error(
|
|
370
|
+
throw new Error(SESSION_LIVE_REFUSAL);
|
|
316
371
|
}
|
|
317
372
|
}
|
|
318
373
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface DaemonFlags {
|
|
2
|
+
readonly listen: string | null;
|
|
3
|
+
readonly tokenFile: string | null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `--listen` and `--token-file` values on a daemon's command line, read
|
|
8
|
+
* from the NUL-separated text of `/proc/<pid>/cmdline`. Both the
|
|
9
|
+
* `--flag value` and `--flag=value` forms count; a flag the line lacks reads
|
|
10
|
+
* as null.
|
|
11
|
+
*/
|
|
12
|
+
export function parseDaemonFlags(cmdline: string): DaemonFlags {
|
|
13
|
+
const args = cmdline.split('\0').filter((arg) => arg !== '');
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
listen: findFlagValue(args, '--listen'),
|
|
17
|
+
tokenFile: findFlagValue(args, '--token-file'),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function findFlagValue(args: readonly string[], flag: string): string | null {
|
|
22
|
+
for (const [i, arg] of args.entries()) {
|
|
23
|
+
if (arg === flag) {
|
|
24
|
+
return args[i + 1] ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (arg.startsWith(`${flag}=`)) {
|
|
28
|
+
return arg.slice(flag.length + 1);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
interface MismatchBuild {
|
|
2
|
+
readonly build: string;
|
|
3
|
+
readonly protocol: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The daemon's build and protocol version in its `protocol_mismatch`
|
|
8
|
+
* refusal text (`daemon <build> speaks v<N>`), or null when the text does
|
|
9
|
+
* not match that wording.
|
|
10
|
+
*/
|
|
11
|
+
export function parseMismatchBuild(text: string): MismatchBuild | null {
|
|
12
|
+
const match = /\bdaemon (?<build>\S+) speaks v(?<protocol>\d+)/.exec(text);
|
|
13
|
+
|
|
14
|
+
if (match === null) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return { build: match.groups?.['build'] ?? '', protocol: Number(match.groups?.['protocol']) };
|
|
19
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export interface RestartFailedRow {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly reason: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface RestartInterruptedRow {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly id: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What a finished restart reports: the daemon now serving, how much of the
|
|
16
|
+
* fleet came back, the rows that did not, and the sessions the restart
|
|
17
|
+
* interrupted. `code` is the exit code of the restart, and `error` holds the
|
|
18
|
+
* reason the restart stopped before it could count the fleet.
|
|
19
|
+
*/
|
|
20
|
+
export interface RestartResult {
|
|
21
|
+
readonly runID: string;
|
|
22
|
+
readonly code: number;
|
|
23
|
+
readonly pid: number | null;
|
|
24
|
+
readonly build: string | null;
|
|
25
|
+
readonly listenPort: number | null;
|
|
26
|
+
readonly restored: number;
|
|
27
|
+
readonly total: number;
|
|
28
|
+
readonly failed: readonly RestartFailedRow[];
|
|
29
|
+
readonly interrupted: readonly RestartInterruptedRow[];
|
|
30
|
+
readonly error: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const FAILED_ROW_SCHEMA = z.object({ name: z.string(), id: z.string(), reason: z.string() });
|
|
34
|
+
const INTERRUPTED_ROW_SCHEMA = z.object({ name: z.string(), id: z.string() });
|
|
35
|
+
|
|
36
|
+
const RESULT_SCHEMA = z.object({
|
|
37
|
+
runID: z.string(),
|
|
38
|
+
code: z.number().int(),
|
|
39
|
+
pid: z.number().int().nullable(),
|
|
40
|
+
build: z.string().nullable(),
|
|
41
|
+
listenPort: z.number().int().nullable(),
|
|
42
|
+
restored: z.number().int(),
|
|
43
|
+
total: z.number().int(),
|
|
44
|
+
failed: z.array(FAILED_ROW_SCHEMA),
|
|
45
|
+
interrupted: z.array(INTERRUPTED_ROW_SCHEMA),
|
|
46
|
+
error: z.string().nullable(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reads a restart result from a line of JSON, or null when the line holds
|
|
51
|
+
* anything else.
|
|
52
|
+
*/
|
|
53
|
+
export function parseRestartResult(line: string): RestartResult | null {
|
|
54
|
+
let raw: unknown;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
raw = JSON.parse(line);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const parsed = RESULT_SCHEMA.safeParse(raw);
|
|
63
|
+
|
|
64
|
+
return parsed.success ? parsed.data : null;
|
|
65
|
+
}
|