@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.
Files changed (78) hide show
  1. package/README.md +6 -0
  2. package/package.json +4 -3
  3. package/src/build-own-daemon-process.ts +16 -0
  4. package/src/build-restart-failure.ts +24 -0
  5. package/src/cli.ts +96 -0
  6. package/src/client/boot-daemon.ts +5 -32
  7. package/src/collect-restart-env.ts +18 -0
  8. package/src/collect-restart-plan.ts +157 -0
  9. package/src/daemon/build-scoped-context.ts +4 -2
  10. package/src/daemon/daemon-connection.ts +4 -1
  11. package/src/daemon/daemon-context.ts +12 -1
  12. package/src/daemon/daemon.ts +26 -4
  13. package/src/daemon/local-pty-provider.ts +83 -10
  14. package/src/daemon/read-native-env-keys.ts +74 -0
  15. package/src/daemon-process.ts +12 -0
  16. package/src/find-restart-unit.ts +45 -0
  17. package/src/format-restart-preflight.ts +74 -0
  18. package/src/format-restart-report.ts +45 -0
  19. package/src/is-daemon-command-line.ts +33 -0
  20. package/src/is-daemon-process.ts +33 -0
  21. package/src/mcp/require-daemon-features.ts +1 -0
  22. package/src/mcp/run-tool.ts +70 -15
  23. package/src/parse-daemon-flags.ts +33 -0
  24. package/src/parse-mismatch-build.ts +19 -0
  25. package/src/parse-restart-result.ts +65 -0
  26. package/src/parse-systemd-unit.ts +25 -0
  27. package/src/protocol/daemon-features.ts +5 -0
  28. package/src/protocol/protocol.ts +2 -0
  29. package/src/protocol/request-param-schemas.ts +6 -0
  30. package/src/read-daemon-process.ts +50 -0
  31. package/src/read-fleet-snapshot.ts +37 -0
  32. package/src/read-restart-result.ts +17 -0
  33. package/src/read-stored-fleet-file.ts +74 -0
  34. package/src/read-stored-rows.ts +36 -0
  35. package/src/remove-restart-result.ts +11 -0
  36. package/src/restart-daemon.ts +149 -0
  37. package/src/restart-plan.ts +33 -0
  38. package/src/run-daemon-restart-worker.ts +77 -0
  39. package/src/run-daemon-restart.ts +228 -0
  40. package/src/run-systemctl.ts +33 -0
  41. package/src/shared/build-atc-command.ts +13 -0
  42. package/src/shared/config.ts +5 -0
  43. package/src/shared/find-pid-file-pid.ts +15 -0
  44. package/src/shared/get-build.ts +4 -3
  45. package/src/shared/is-process-alive.ts +21 -0
  46. package/src/shared/spawn-atc-detached.ts +34 -0
  47. package/src/start-replacement-daemon.ts +31 -0
  48. package/src/stop-daemon-process.ts +46 -0
  49. package/src/store/state-store.ts +2 -2
  50. package/src/test-utils/can-bind-addresses.ts +19 -0
  51. package/src/test-utils/create-migrated-state-db.ts +38 -0
  52. package/src/test-utils/env-originals.ts +6 -0
  53. package/src/test-utils/fixture-dir-provider.ts +131 -0
  54. package/src/test-utils/fixture-imp-port.ts +1692 -0
  55. package/src/test-utils/get-agent-entry.ts +17 -0
  56. package/src/test-utils/get-gateway-config.ts +18 -0
  57. package/src/test-utils/isolate-home.ts +60 -0
  58. package/src/test-utils/read-json-record.ts +20 -0
  59. package/src/test-utils/remove-env-overrides.ts +18 -0
  60. package/src/test-utils/run-legacy-daemon.ts +38 -0
  61. package/src/test-utils/run-listener-daemon.ts +51 -0
  62. package/src/test-utils/run-mcp-authorization.ts +118 -0
  63. package/src/test-utils/run-source-daemon.ts +110 -0
  64. package/src/test-utils/setup-fake-systemd.ts +125 -0
  65. package/src/test-utils/setup-mcp-http.ts +126 -0
  66. package/src/test-utils/setup-temp-dir.ts +33 -0
  67. package/src/test-utils/spawn-named-session.ts +27 -0
  68. package/src/test-utils/start-cut-proxy.ts +175 -0
  69. package/src/test-utils/start-git-http-server.ts +102 -0
  70. package/src/test-utils/start-legacy-daemon.ts +111 -0
  71. package/src/test-utils/subscribe-to-socket-lines.ts +60 -0
  72. package/src/test-utils/update-env.ts +19 -0
  73. package/src/test-utils/wait-for.ts +38 -0
  74. package/src/verify-restored-fleet.ts +155 -0
  75. package/src/wait-for-replacement.ts +112 -0
  76. package/src/write-restart-result.ts +17 -0
  77. /package/src/{daemon → shared}/claim-daemon-lock.ts +0 -0
  78. /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
 
@@ -71,6 +71,8 @@ const ERROR_CODES = [
71
71
  'host_leased',
72
72
  'confirmation_required',
73
73
  'confirm_token_invalid',
74
+ 'session_pinned',
75
+ 'session_live',
74
76
  'already_answered',
75
77
  'too_slow',
76
78
  'stale_epoch',
@@ -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
+ }