@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
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The name of the user service a process runs in, read from its
|
|
3
|
+
* `/proc/<pid>/cgroup` text: the first `<name>.service` below
|
|
4
|
+
* `user@<uid>.service`. A process in a login session, a scope, or a system
|
|
5
|
+
* service reads as null, since a user manager cannot restart it.
|
|
6
|
+
*/
|
|
7
|
+
export function parseSystemdUnit(cgroupText: string, uid: number): string | null {
|
|
8
|
+
for (const line of cgroupText.split('\n')) {
|
|
9
|
+
const path = line.slice(line.indexOf(':', line.indexOf(':') + 1) + 1);
|
|
10
|
+
const segments = path.split('/').filter((segment) => segment !== '');
|
|
11
|
+
const manager = segments.indexOf(`user@${uid}.service`);
|
|
12
|
+
|
|
13
|
+
if (manager === -1) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const unit = segments.slice(manager + 1).find((segment) => segment.endsWith('.service'));
|
|
18
|
+
|
|
19
|
+
if (unit !== undefined) {
|
|
20
|
+
return unit;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
@@ -59,6 +59,11 @@ export const DAEMON_FEATURES = [
|
|
|
59
59
|
// can destroy its host answers `confirmation_required`.
|
|
60
60
|
'session.forget',
|
|
61
61
|
|
|
62
|
+
// `session.forget` takes `refusePinned` and `refuseLive`, checked in the
|
|
63
|
+
// same step that forgets, and refuses with `session_pinned` or
|
|
64
|
+
// `session_live`.
|
|
65
|
+
'session.forget.preconditions',
|
|
66
|
+
|
|
62
67
|
// `session.submit` exists.
|
|
63
68
|
'session.submit',
|
|
64
69
|
|
package/src/protocol/protocol.ts
CHANGED
|
@@ -213,6 +213,12 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
213
213
|
.string({ error: 'session.forget confirmToken must be a string' })
|
|
214
214
|
.min(1, 'session.forget confirmToken must not be empty')
|
|
215
215
|
.optional(),
|
|
216
|
+
|
|
217
|
+
// Refuse a pinned session, or a sub-session of a pinned one.
|
|
218
|
+
refusePinned: z.boolean({ error: 'session.forget refusePinned must be a boolean' }).optional(),
|
|
219
|
+
|
|
220
|
+
// Refuse a live session.
|
|
221
|
+
refuseLive: z.boolean({ error: 'session.forget refuseLive must be a boolean' }).optional(),
|
|
216
222
|
}),
|
|
217
223
|
|
|
218
224
|
// Owner-only: withdraw the grants of the runtime auth binding on the
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFileSync, readlinkSync, statSync } from 'node:fs';
|
|
2
|
+
import { buildOwnDaemonProcess } from './build-own-daemon-process';
|
|
3
|
+
import { collectRestartEnv } from './collect-restart-env';
|
|
4
|
+
import type { DaemonProcess } from './daemon-process';
|
|
5
|
+
import { parseDaemonFlags } from './parse-daemon-flags';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reads what a running daemon was started with from `/proc/<pid>`: its
|
|
9
|
+
* environment, its working directory, and the listener flags on its command
|
|
10
|
+
* line. Where `/proc` has no entry for it, as on macOS, the environment is
|
|
11
|
+
* this process's own and the flags are empty.
|
|
12
|
+
*/
|
|
13
|
+
export function readDaemonProcess(pid: number): DaemonProcess {
|
|
14
|
+
let environ: string;
|
|
15
|
+
let cmdline: string;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
environ = readFileSync(`/proc/${pid}/environ`, 'utf8');
|
|
19
|
+
cmdline = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
20
|
+
} catch {
|
|
21
|
+
return buildOwnDaemonProcess();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const env: Record<string, string> = {};
|
|
25
|
+
|
|
26
|
+
for (const entry of environ.split('\0')) {
|
|
27
|
+
const eq = entry.indexOf('=');
|
|
28
|
+
|
|
29
|
+
if (eq > 0) {
|
|
30
|
+
env[entry.slice(0, eq)] = entry.slice(eq + 1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
env: collectRestartEnv(env),
|
|
36
|
+
cwd: readCwd(pid),
|
|
37
|
+
flags: parseDaemonFlags(cmdline),
|
|
38
|
+
fromProc: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readCwd(pid: number): string | null {
|
|
43
|
+
try {
|
|
44
|
+
const cwd = readlinkSync(`/proc/${pid}/cwd`);
|
|
45
|
+
|
|
46
|
+
return statSync(cwd).isDirectory() ? cwd : null;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { DaemonClient } from './client/daemon-client';
|
|
2
|
+
import { readStoredRows } from './read-stored-rows';
|
|
3
|
+
import type { StoredRow } from './read-stored-rows';
|
|
4
|
+
import { getBuild } from './shared/get-build';
|
|
5
|
+
|
|
6
|
+
const SNAPSHOT_DEADLINE_MS = 3000;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reads the stored fleet rows from the daemon at this socket, or null when
|
|
10
|
+
* it does not answer in time. The restart takes this before it stops the
|
|
11
|
+
* daemon, so the rows keep their original exit status.
|
|
12
|
+
*/
|
|
13
|
+
export async function readFleetSnapshot(socketPath: string): Promise<StoredRow[] | null> {
|
|
14
|
+
let client: DaemonClient;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
client = await DaemonClient.open(socketPath);
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const expiry = setTimeout(() => {
|
|
23
|
+
client.stop();
|
|
24
|
+
}, SNAPSHOT_DEADLINE_MS);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await client.sendHello(getBuild());
|
|
28
|
+
|
|
29
|
+
return await readStoredRows(client);
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
} finally {
|
|
33
|
+
clearTimeout(expiry);
|
|
34
|
+
|
|
35
|
+
client.stop();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parseRestartResult } from './parse-restart-result';
|
|
4
|
+
import type { RestartResult } from './parse-restart-result';
|
|
5
|
+
import { restartsDir } from './shared/config';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The result of the last finished restart, or null when none was written or
|
|
9
|
+
* the file holds anything else.
|
|
10
|
+
*/
|
|
11
|
+
export function readRestartResult(): RestartResult | null {
|
|
12
|
+
try {
|
|
13
|
+
return parseRestartResult(readFileSync(join(restartsDir, 'last.json'), 'utf8').trim());
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import type { StoredRow } from './read-stored-rows';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reads the stored fleet rows straight from the state database, read-only,
|
|
6
|
+
* for a daemon whose protocol this build cannot speak. Each column is read
|
|
7
|
+
* under the name the file's schema gives it, and one the schema lacks reads
|
|
8
|
+
* as its default, so an older daemon's database reads without a migration. On a schema that records
|
|
9
|
+
* session ownership, only the rows this state directory's daemon owns are
|
|
10
|
+
* read, as the daemon's own fleet list reads them. Returns null when the
|
|
11
|
+
* file is missing or cannot be read.
|
|
12
|
+
*/
|
|
13
|
+
export function readStoredFleetFile(dbPath: string): StoredRow[] | null {
|
|
14
|
+
let db: Database;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
db = new Database(dbPath, { readonly: true });
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const columns = new Set(
|
|
24
|
+
db
|
|
25
|
+
.query<{ name: string }, []>('PRAGMA table_info(fleet)')
|
|
26
|
+
.all()
|
|
27
|
+
.map((column) => column.name),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
// Before the store keyed rows by atc session id, a row held only the
|
|
31
|
+
// agent's session id, under one of two names; the new daemon assigns
|
|
32
|
+
// the atc id as it migrates, so such a row is matched by the agent's id.
|
|
33
|
+
const agentColumn = ['agent_session_id', 'claude_id'].find((name) => columns.has(name));
|
|
34
|
+
|
|
35
|
+
if (!columns.has('session_id') && agentColumn === undefined) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const id = columns.has('session_id') ? 'fleet.session_id' : `fleet.${agentColumn}`;
|
|
40
|
+
const agentID = agentColumn === undefined ? 'NULL' : `fleet.${agentColumn}`;
|
|
41
|
+
const exited = columns.has('exited') ? 'fleet.exited' : '0';
|
|
42
|
+
|
|
43
|
+
const hasOwners =
|
|
44
|
+
db
|
|
45
|
+
.query<{ name: string }, []>(
|
|
46
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_owner'",
|
|
47
|
+
)
|
|
48
|
+
.get() !== null;
|
|
49
|
+
|
|
50
|
+
const owned = hasOwners
|
|
51
|
+
? " WHERE fleet.session_id IN (SELECT session_id FROM session_owner WHERE daemon_id = (SELECT value FROM prefs WHERE key = 'daemon_id'))"
|
|
52
|
+
: '';
|
|
53
|
+
|
|
54
|
+
const rows = db
|
|
55
|
+
.query<
|
|
56
|
+
{ id: string; agent_session_id: string | null; name: string | null; exited: number | null },
|
|
57
|
+
[]
|
|
58
|
+
>(
|
|
59
|
+
`SELECT ${id} AS id, ${agentID} AS agent_session_id, fleet.name, ${exited} AS exited FROM fleet${owned} ORDER BY fleet.rowid`,
|
|
60
|
+
)
|
|
61
|
+
.all();
|
|
62
|
+
|
|
63
|
+
return rows.map((row) => ({
|
|
64
|
+
id: row.id,
|
|
65
|
+
name: row.name ?? row.id,
|
|
66
|
+
exited: row.exited === 1,
|
|
67
|
+
agentSessionID: row.agent_session_id,
|
|
68
|
+
}));
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
} finally {
|
|
72
|
+
db.close();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { DaemonClient } from './client/daemon-client';
|
|
2
|
+
import { isRecord } from './shared/report';
|
|
3
|
+
|
|
4
|
+
export interface StoredRow {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly exited: boolean;
|
|
8
|
+
|
|
9
|
+
// The agent's own session id, which matches the row when its atc id is
|
|
10
|
+
// assigned only as the new daemon migrates the store; null when unknown.
|
|
11
|
+
readonly agentSessionID: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Reads the fleet rows a daemon has stored, exited ones included.
|
|
16
|
+
*/
|
|
17
|
+
export async function readStoredRows(
|
|
18
|
+
client: Pick<DaemonClient, 'sendRequest'>,
|
|
19
|
+
): Promise<StoredRow[]> {
|
|
20
|
+
const listed = await client.sendRequest('fleet.list');
|
|
21
|
+
|
|
22
|
+
const fleet = listed['fleet'];
|
|
23
|
+
|
|
24
|
+
if (!Array.isArray(fleet)) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return fleet
|
|
29
|
+
.filter((entry) => isRecord(entry))
|
|
30
|
+
.map((entry) => ({
|
|
31
|
+
id: String(entry['sessionID']),
|
|
32
|
+
name: String(entry['name']),
|
|
33
|
+
exited: entry['exited'] === true,
|
|
34
|
+
agentSessionID: typeof entry['agentSessionID'] === 'string' ? entry['agentSessionID'] : null,
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { restartsDir } from './shared/config';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Removes the result of the last finished restart, so a reader finds a
|
|
7
|
+
* result only from a restart that ran after this call.
|
|
8
|
+
*/
|
|
9
|
+
export function removeRestartResult(): void {
|
|
10
|
+
rmSync(join(restartsDir, 'last.json'), { force: true });
|
|
11
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { buildOwnDaemonProcess } from './build-own-daemon-process';
|
|
3
|
+
import { buildRestartFailure } from './build-restart-failure';
|
|
4
|
+
import { collectRestartPlan } from './collect-restart-plan';
|
|
5
|
+
import { formatRestartPreflight } from './format-restart-preflight';
|
|
6
|
+
import type { RestartResult } from './parse-restart-result';
|
|
7
|
+
import { readDaemonProcess } from './read-daemon-process';
|
|
8
|
+
import { readFleetSnapshot } from './read-fleet-snapshot';
|
|
9
|
+
import { readStoredFleetFile } from './read-stored-fleet-file';
|
|
10
|
+
import { runSystemctl } from './run-systemctl';
|
|
11
|
+
import { dbFile } from './shared/config';
|
|
12
|
+
import { getBuild } from './shared/get-build';
|
|
13
|
+
import { loadListenerTokens } from './shared/load-listener-tokens';
|
|
14
|
+
import { startReplacementDaemon } from './start-replacement-daemon';
|
|
15
|
+
import { stopDaemonProcess } from './stop-daemon-process';
|
|
16
|
+
import { verifyRestoredFleet } from './verify-restored-fleet';
|
|
17
|
+
import { waitForReplacement } from './wait-for-replacement';
|
|
18
|
+
|
|
19
|
+
export interface RestartOptions {
|
|
20
|
+
readonly runID: string;
|
|
21
|
+
readonly callerSession: string | null;
|
|
22
|
+
readonly listen: string | null;
|
|
23
|
+
readonly tokenFile: string | null;
|
|
24
|
+
readonly timeoutSeconds: number | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const START_WAIT_MS = 30_000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stops the running daemon, starts exactly one in its place, restores the
|
|
31
|
+
* stored fleet on it, and reports what came back. The preflight prints
|
|
32
|
+
* before anything stops. A failure on the way is a result, never a throw.
|
|
33
|
+
*/
|
|
34
|
+
export async function restartDaemon(options: RestartOptions): Promise<RestartResult> {
|
|
35
|
+
const plan = await collectRestartPlan(options.callerSession);
|
|
36
|
+
|
|
37
|
+
for (const line of formatRestartPreflight(plan)) {
|
|
38
|
+
console.log(line);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const interrupted = (plan.sessions ?? [])
|
|
42
|
+
.filter((session) => session.state === 'running')
|
|
43
|
+
.map((session) => ({ name: session.name, id: session.id }));
|
|
44
|
+
|
|
45
|
+
// Taken while the old daemon still runs, so a row that the restore marks
|
|
46
|
+
// exited is not mistaken for one that was archived before. A daemon this
|
|
47
|
+
// build cannot speak to is read from its database instead.
|
|
48
|
+
const snapshot =
|
|
49
|
+
(plan.answer?.kind === 'ok' && plan.socketPath !== null
|
|
50
|
+
? await readFleetSnapshot(plan.socketPath)
|
|
51
|
+
: null) ?? readStoredFleetFile(dbFile);
|
|
52
|
+
|
|
53
|
+
if (snapshot === null) {
|
|
54
|
+
console.log(
|
|
55
|
+
'cannot read the stored fleet before the stop: the report checks the rows the new daemon lists, and a row it marks exited counts as restored',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const buildFailure = (error: string): RestartResult =>
|
|
60
|
+
buildRestartFailure(options.runID, error, interrupted);
|
|
61
|
+
|
|
62
|
+
let expectedBuild: string | null = null;
|
|
63
|
+
|
|
64
|
+
if (plan.replacement.kind === 'unit') {
|
|
65
|
+
if (options.listen !== null || options.tokenFile !== null) {
|
|
66
|
+
console.log('--listen and --token-file are ignored: the unit decides how the daemon starts');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const restarted = await runSystemctl(['restart', plan.replacement.unit]);
|
|
70
|
+
|
|
71
|
+
if (restarted.code !== 0) {
|
|
72
|
+
return buildFailure(`systemctl restart ${plan.replacement.unit} failed: ${restarted.stderr}`);
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
expectedBuild = getBuild();
|
|
76
|
+
|
|
77
|
+
const old = plan.pid === null ? buildOwnDaemonProcess() : readDaemonProcess(plan.pid);
|
|
78
|
+
|
|
79
|
+
if (plan.pid !== null && !old.fromProc) {
|
|
80
|
+
console.log(
|
|
81
|
+
'cannot read /proc for the daemon: the replacement starts with this restart’s environment and without the old listener flags',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const listen = options.listen ?? old.flags.listen;
|
|
86
|
+
const tokenFile = options.tokenFile ?? old.flags.tokenFile;
|
|
87
|
+
|
|
88
|
+
if ((listen === null) !== (tokenFile === null)) {
|
|
89
|
+
return buildFailure('--listen and --token-file go together; the daemon was left running');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (listen !== null && tokenFile !== null) {
|
|
93
|
+
const loaded = loadListenerTokens(resolve(old.cwd ?? process.cwd(), tokenFile));
|
|
94
|
+
|
|
95
|
+
if (!loaded.ok) {
|
|
96
|
+
return buildFailure(`${loaded.reason}; the daemon was left running`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (plan.pid !== null) {
|
|
101
|
+
const stopped = await stopDaemonProcess(plan.pid);
|
|
102
|
+
|
|
103
|
+
const stoppedLine =
|
|
104
|
+
stopped === 'killed'
|
|
105
|
+
? `the daemon (pid ${plan.pid}) ignored SIGTERM for 10 s, so it was killed with SIGKILL`
|
|
106
|
+
: `stopped the daemon (pid ${plan.pid})`;
|
|
107
|
+
|
|
108
|
+
console.log(stoppedLine);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
startReplacementDaemon(old, { listen: options.listen, tokenFile: options.tokenFile });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const replacement = await waitForReplacement(plan.pid, expectedBuild, START_WAIT_MS);
|
|
115
|
+
|
|
116
|
+
if (!replacement.ok) {
|
|
117
|
+
return buildFailure(replacement.reason);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
console.log(`daemon pid ${replacement.pid} is up (${replacement.build}); restoring the fleet`);
|
|
122
|
+
|
|
123
|
+
const verdict = await verifyRestoredFleet(replacement.client, options.timeoutSeconds, snapshot);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
runID: options.runID,
|
|
127
|
+
code: verdict.failed.length === 0 ? 0 : 1,
|
|
128
|
+
pid: replacement.pid,
|
|
129
|
+
build: replacement.build,
|
|
130
|
+
listenPort: replacement.listenPort,
|
|
131
|
+
restored: verdict.total - verdict.failed.length,
|
|
132
|
+
total: verdict.total,
|
|
133
|
+
failed: [...verdict.failed],
|
|
134
|
+
interrupted,
|
|
135
|
+
error: null,
|
|
136
|
+
};
|
|
137
|
+
} catch (error) {
|
|
138
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
...buildFailure(`the fleet could not be verified: ${reason}`),
|
|
142
|
+
pid: replacement.pid,
|
|
143
|
+
build: replacement.build,
|
|
144
|
+
listenPort: replacement.listenPort,
|
|
145
|
+
};
|
|
146
|
+
} finally {
|
|
147
|
+
replacement.client.stop();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// What a restart is about to stop and what it starts in its place.
|
|
2
|
+
export interface RestartPlan {
|
|
3
|
+
// The pid of the daemon to stop, or null when no live pid is recorded.
|
|
4
|
+
readonly pid: number | null;
|
|
5
|
+
|
|
6
|
+
// The socket that answered the handshake, or null when none did.
|
|
7
|
+
readonly socketPath: string | null;
|
|
8
|
+
|
|
9
|
+
// How the daemon answered this build's handshake.
|
|
10
|
+
readonly answer: DaemonAnswer | null;
|
|
11
|
+
|
|
12
|
+
// The session list the daemon returned, or null when it could not be read.
|
|
13
|
+
readonly sessions: readonly PlanSession[] | null;
|
|
14
|
+
|
|
15
|
+
// The session the restart was run from, when it runs inside one.
|
|
16
|
+
readonly callerSession: string | null;
|
|
17
|
+
|
|
18
|
+
readonly replacement: ReplacementPlan;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type DaemonAnswer =
|
|
22
|
+
| { readonly kind: 'ok'; readonly build: string; readonly protocol: number }
|
|
23
|
+
| { readonly kind: 'refused'; readonly message: string };
|
|
24
|
+
|
|
25
|
+
export interface PlanSession {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly state: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ReplacementPlan =
|
|
32
|
+
| { readonly kind: 'unit'; readonly unit: string; readonly execStart: string | null }
|
|
33
|
+
| { readonly kind: 'plain'; readonly build: string; readonly command: string };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { buildRestartFailure } from './build-restart-failure';
|
|
3
|
+
import { formatRestartReport } from './format-restart-report';
|
|
4
|
+
import type { RestartResult } from './parse-restart-result';
|
|
5
|
+
import { readRestartResult } from './read-restart-result';
|
|
6
|
+
import { removeRestartResult } from './remove-restart-result';
|
|
7
|
+
import { restartDaemon } from './restart-daemon';
|
|
8
|
+
import type { RestartOptions } from './restart-daemon';
|
|
9
|
+
import { claimDaemonLock } from './shared/claim-daemon-lock';
|
|
10
|
+
import { restartLockFile, restartsDir } from './shared/config';
|
|
11
|
+
import { writeRestartResult } from './write-restart-result';
|
|
12
|
+
|
|
13
|
+
// How long a restart that finds another in flight waits for it to finish.
|
|
14
|
+
const JOIN_WAIT_MS = 15 * 60_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Runs one restart as the handoff worker: it takes the restart lock and
|
|
18
|
+
* restarts the daemon, or, when another restart holds the lock, waits for
|
|
19
|
+
* that restart to finish and reports its result without touching the
|
|
20
|
+
* daemon. Prints each step to stdout, which the caller follows in the run
|
|
21
|
+
* log, and ends the log with the result record. Resolves the exit code.
|
|
22
|
+
*/
|
|
23
|
+
export async function runDaemonRestartWorker(options: RestartOptions): Promise<number> {
|
|
24
|
+
console.log(`worker pid ${process.pid}`);
|
|
25
|
+
|
|
26
|
+
mkdirSync(restartsDir, { recursive: true });
|
|
27
|
+
|
|
28
|
+
const lock = await claimDaemonLock(restartLockFile, 0);
|
|
29
|
+
|
|
30
|
+
let result: RestartResult;
|
|
31
|
+
|
|
32
|
+
if (lock === null) {
|
|
33
|
+
result = await waitForRestartInFlight(options.runID);
|
|
34
|
+
} else {
|
|
35
|
+
try {
|
|
36
|
+
// A joiner reads last.json once it gets the lock, so a result left by
|
|
37
|
+
// an earlier restart must not outlive this run's start.
|
|
38
|
+
removeRestartResult();
|
|
39
|
+
|
|
40
|
+
result = await restartDaemon(options).catch((error: unknown) => {
|
|
41
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
42
|
+
|
|
43
|
+
return buildRestartFailure(options.runID, reason);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
writeRestartResult(result);
|
|
47
|
+
} finally {
|
|
48
|
+
lock.dispose();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const line of formatRestartReport(result)) {
|
|
53
|
+
console.log(line);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log(JSON.stringify(result));
|
|
57
|
+
|
|
58
|
+
return result.code;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function waitForRestartInFlight(runID: string): Promise<RestartResult> {
|
|
62
|
+
console.log('another restart is in flight; this run joins it and reports its result');
|
|
63
|
+
|
|
64
|
+
const held = await claimDaemonLock(restartLockFile, JOIN_WAIT_MS);
|
|
65
|
+
|
|
66
|
+
if (held === null) {
|
|
67
|
+
return buildRestartFailure(runID, 'timed out waiting for the restart in flight to finish');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const finished = readRestartResult();
|
|
71
|
+
|
|
72
|
+
held.dispose();
|
|
73
|
+
|
|
74
|
+
return finished === null
|
|
75
|
+
? buildRestartFailure(runID, 'the restart in flight left no result')
|
|
76
|
+
: { ...finished, runID };
|
|
77
|
+
}
|