@zgeoff/atc 3.2.0 → 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 (68) 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/daemon.ts +7 -3
  10. package/src/daemon-process.ts +12 -0
  11. package/src/find-restart-unit.ts +45 -0
  12. package/src/format-restart-preflight.ts +74 -0
  13. package/src/format-restart-report.ts +45 -0
  14. package/src/is-daemon-command-line.ts +33 -0
  15. package/src/is-daemon-process.ts +33 -0
  16. package/src/parse-daemon-flags.ts +33 -0
  17. package/src/parse-mismatch-build.ts +19 -0
  18. package/src/parse-restart-result.ts +65 -0
  19. package/src/parse-systemd-unit.ts +25 -0
  20. package/src/read-daemon-process.ts +50 -0
  21. package/src/read-fleet-snapshot.ts +37 -0
  22. package/src/read-restart-result.ts +17 -0
  23. package/src/read-stored-fleet-file.ts +74 -0
  24. package/src/read-stored-rows.ts +36 -0
  25. package/src/remove-restart-result.ts +11 -0
  26. package/src/restart-daemon.ts +149 -0
  27. package/src/restart-plan.ts +33 -0
  28. package/src/run-daemon-restart-worker.ts +77 -0
  29. package/src/run-daemon-restart.ts +228 -0
  30. package/src/run-systemctl.ts +33 -0
  31. package/src/shared/build-atc-command.ts +13 -0
  32. package/src/shared/config.ts +5 -0
  33. package/src/shared/find-pid-file-pid.ts +15 -0
  34. package/src/shared/get-build.ts +4 -3
  35. package/src/shared/is-process-alive.ts +21 -0
  36. package/src/shared/spawn-atc-detached.ts +34 -0
  37. package/src/start-replacement-daemon.ts +31 -0
  38. package/src/stop-daemon-process.ts +46 -0
  39. package/src/store/state-store.ts +2 -2
  40. package/src/test-utils/can-bind-addresses.ts +19 -0
  41. package/src/test-utils/create-migrated-state-db.ts +38 -0
  42. package/src/test-utils/env-originals.ts +6 -0
  43. package/src/test-utils/fixture-dir-provider.ts +131 -0
  44. package/src/test-utils/fixture-imp-port.ts +1692 -0
  45. package/src/test-utils/get-agent-entry.ts +17 -0
  46. package/src/test-utils/get-gateway-config.ts +18 -0
  47. package/src/test-utils/isolate-home.ts +60 -0
  48. package/src/test-utils/read-json-record.ts +20 -0
  49. package/src/test-utils/remove-env-overrides.ts +18 -0
  50. package/src/test-utils/run-legacy-daemon.ts +38 -0
  51. package/src/test-utils/run-listener-daemon.ts +51 -0
  52. package/src/test-utils/run-mcp-authorization.ts +118 -0
  53. package/src/test-utils/run-source-daemon.ts +110 -0
  54. package/src/test-utils/setup-fake-systemd.ts +125 -0
  55. package/src/test-utils/setup-mcp-http.ts +126 -0
  56. package/src/test-utils/setup-temp-dir.ts +33 -0
  57. package/src/test-utils/spawn-named-session.ts +27 -0
  58. package/src/test-utils/start-cut-proxy.ts +175 -0
  59. package/src/test-utils/start-git-http-server.ts +102 -0
  60. package/src/test-utils/start-legacy-daemon.ts +111 -0
  61. package/src/test-utils/subscribe-to-socket-lines.ts +60 -0
  62. package/src/test-utils/update-env.ts +19 -0
  63. package/src/test-utils/wait-for.ts +38 -0
  64. package/src/verify-restored-fleet.ts +155 -0
  65. package/src/wait-for-replacement.ts +112 -0
  66. package/src/write-restart-result.ts +17 -0
  67. /package/src/{daemon → shared}/claim-daemon-lock.ts +0 -0
  68. /package/src/{daemon → shared}/load-listener-tokens.ts +0 -0
package/README.md CHANGED
@@ -98,6 +98,12 @@ fleet when you are ready. The bar shows `⟳ restarting daemon` until the restar
98
98
  running daemon speaks another protocol version, `atc` asks before it restarts the daemon, since the
99
99
  restart ends every session the daemon hosts.
100
100
 
101
+ `atc daemon restart` does the same from a script or from inside a session: it stops the daemon,
102
+ starts one in its place, restores the fleet, and exits 0 only when every stored row came back.
103
+ `atc daemon restart --dry-run` prints what the restart would stop and which sessions are mid-turn.
104
+ [Daemon architecture](./docs/architecture/daemon.md#restarting-the-daemon) covers the handoff and
105
+ the exit codes.
106
+
101
107
  atc runs inside zellij or tmux. Give the pane locked mode so the leader key reaches atc.
102
108
 
103
109
  ## Grok and Codex
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -28,8 +28,9 @@
28
28
  "deadcode": "knip",
29
29
  "lint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error",
30
30
  "lint:fix": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error --fix",
31
- "test": "bash scripts/with-test-home.sh bun test",
32
- "test:binary": "bash scripts/with-test-home.sh bun test --timeout 20000 test/daemon-e2e.test.ts",
31
+ "test": "bash scripts/with-test-home.sh bun test --parallel --timings=bun-test-timings.json",
32
+ "test:timings": "bash scripts/with-test-home.sh bun test --parallel --timings=bun-test-timings.json --update-timings",
33
+ "test:binary": "bash scripts/with-test-home.sh bun test --timeout 20000 e2e/daemon-e2e.test.ts",
33
34
  "test:gateway-binary": "bash scripts/with-test-home.sh bun test --timeout 20000 src/gateway.test.ts",
34
35
  "test:atc-bridge": "bash scripts/with-test-home.sh bash scripts/test-atc-bridge.sh",
35
36
  "test:isolation": "bash scripts/check-test-isolation.sh",
@@ -0,0 +1,16 @@
1
+ import { collectRestartEnv } from './collect-restart-env';
2
+ import type { DaemonProcess } from './daemon-process';
3
+
4
+ /**
5
+ * The start a daemon gets when no running one supplies it: this process's
6
+ * environment without the session variables, no working directory, and no
7
+ * listener flags.
8
+ */
9
+ export function buildOwnDaemonProcess(): DaemonProcess {
10
+ return {
11
+ env: collectRestartEnv(process.env),
12
+ cwd: null,
13
+ flags: { listen: null, tokenFile: null },
14
+ fromProc: false,
15
+ };
16
+ }
@@ -0,0 +1,24 @@
1
+ import type { RestartResult } from './parse-restart-result';
2
+
3
+ /**
4
+ * The result of a restart that stopped with `error` before it could count
5
+ * the fleet. `interrupted` lists the sessions the stop already ended.
6
+ */
7
+ export function buildRestartFailure(
8
+ runID: string,
9
+ error: string,
10
+ interrupted: RestartResult['interrupted'] = [],
11
+ ): RestartResult {
12
+ return {
13
+ runID,
14
+ code: 1,
15
+ pid: null,
16
+ build: null,
17
+ listenPort: null,
18
+ restored: 0,
19
+ total: 0,
20
+ failed: [],
21
+ interrupted,
22
+ error,
23
+ };
24
+ }
package/src/cli.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // atc CLI entry: no subcommand opens the client TUI; `hook-report` and
2
2
  // `statusline` are the commands injected into wrangled sessions.
3
+ import { resolve } from 'node:path';
3
4
  import { defineCommand, runMain } from 'citty';
4
5
  import pkg from '../package.json';
5
6
  import { collectRedirectURIs } from './collect-redirect-uris';
@@ -252,6 +253,64 @@ const main = defineCommand({
252
253
  await id.runDaemonID(getBuild());
253
254
  },
254
255
  }),
256
+ restart: () =>
257
+ defineCommand({
258
+ meta: {
259
+ name: 'restart',
260
+ description: 'Restart the running daemon and restore its fleet',
261
+ },
262
+ args: {
263
+ ...DAEMON_ARGS,
264
+ timeout: {
265
+ type: 'string',
266
+ description: 'Seconds to wait for the restored fleet to come alive',
267
+ },
268
+ 'dry-run': {
269
+ type: 'boolean',
270
+ default: false,
271
+ description: 'Print what the restart would do and stop',
272
+ },
273
+ },
274
+ async run(ctx) {
275
+ const options = parseRestartArgs(ctx.args);
276
+
277
+ const restart = await import('./run-daemon-restart');
278
+
279
+ const code = await restart.runDaemonRestart({
280
+ ...options,
281
+ dryRun: ctx.args['dry-run'],
282
+ });
283
+
284
+ process.exit(code);
285
+ },
286
+ }),
287
+ 'restart-worker': () =>
288
+ defineCommand({
289
+ meta: {
290
+ name: 'restart-worker',
291
+ description: 'Run one daemon restart for atc daemon restart',
292
+ hidden: true,
293
+ },
294
+ args: {
295
+ ...DAEMON_ARGS,
296
+ runID: { type: 'positional', required: true, description: 'The run id' },
297
+ session: { type: 'string', description: 'The session that asked for the restart' },
298
+ timeout: { type: 'string', description: 'Seconds to wait for the restored fleet' },
299
+ },
300
+ async run(ctx) {
301
+ const options = parseRestartArgs(ctx.args);
302
+
303
+ const worker = await import('./run-daemon-restart-worker');
304
+
305
+ const code = await worker.runDaemonRestartWorker({
306
+ ...options,
307
+ runID: ctx.args.runID,
308
+ callerSession: ctx.args.session ?? null,
309
+ });
310
+
311
+ process.exit(code);
312
+ },
313
+ }),
255
314
  },
256
315
  }),
257
316
  events: () =>
@@ -379,6 +438,43 @@ const main = defineCommand({
379
438
  },
380
439
  });
381
440
 
441
+ interface RestartArgs {
442
+ readonly listen?: string | undefined;
443
+ readonly 'token-file'?: string | undefined;
444
+ readonly timeout?: string | undefined;
445
+ }
446
+
447
+ interface RestartFlags {
448
+ readonly listen: string | null;
449
+ readonly tokenFile: string | null;
450
+ readonly timeoutSeconds: number | null;
451
+ }
452
+
453
+ // Reads the flags the restart commands share, and stops the command on one
454
+ // the replacement daemon would refuse after the old one is already gone.
455
+ function parseRestartArgs(args: RestartArgs): RestartFlags {
456
+ const listen = args.listen ?? null;
457
+ const parsedListen = listen === null ? null : parseListenAddress(listen);
458
+
459
+ if (parsedListen !== null && !parsedListen.ok) {
460
+ console.error(`atc daemon restart: ${parsedListen.message}`);
461
+ process.exit(1);
462
+ }
463
+
464
+ const timeout = args.timeout === undefined ? null : Number(args.timeout);
465
+
466
+ if (timeout !== null && (!Number.isFinite(timeout) || timeout <= 0)) {
467
+ console.error('atc daemon restart: --timeout takes a number of seconds above 0');
468
+ process.exit(1);
469
+ }
470
+
471
+ // An explicit token file is read where the caller runs, not in the old
472
+ // daemon's directory, so a relative path is fixed here before the handoff.
473
+ const tokenFile = args['token-file'] === undefined ? null : resolve(args['token-file']);
474
+
475
+ return { listen, tokenFile, timeoutSeconds: timeout };
476
+ }
477
+
382
478
  // Runs the daemon in the foreground until SIGTERM, with a TCP listener when
383
479
  // --listen and --token-file are given.
384
480
  async function runDaemon(listenArg: string | null, tokenFile: string | null): Promise<void> {
@@ -1,7 +1,4 @@
1
- import { spawn as spawnChild } from 'node:child_process';
2
1
  import type { ChildProcess } from 'node:child_process';
3
- import { readFileSync } from 'node:fs';
4
- import { join } from 'node:path';
5
2
  import { DaemonError } from '../protocol/daemon-error';
6
3
  import type { DaemonFeature } from '../protocol/daemon-features';
7
4
  import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
@@ -9,9 +6,11 @@ import { PROTOCOL_V } from '../protocol/protocol';
9
6
  import type { AgentID } from '../shared/agent-id';
10
7
  import { daemonPidFile, daemonRecordFile, daemonSocketPath } from '../shared/config';
11
8
  import { findDaemonRecord } from '../shared/find-daemon-record';
9
+ import { findPidFilePID } from '../shared/find-pid-file-pid';
12
10
  import { getBuild } from '../shared/get-build';
13
- import { isCompiledBinary } from '../shared/is-compiled-binary';
11
+ import { isProcessAlive } from '../shared/is-process-alive';
14
12
  import { makeSingleFlight } from '../shared/make-single-flight';
13
+ import { spawnATCDetached } from '../shared/spawn-atc-detached';
15
14
  import { toAgentID } from '../shared/to-agent-id';
16
15
  import { DaemonClient } from './daemon-client';
17
16
  import { formatProtocolMismatch } from './format-protocol-mismatch';
@@ -275,23 +274,13 @@ function formatWaitFailure(timeoutMs: number): string {
275
274
  );
276
275
  }
277
276
 
278
- function isProcessAlive(pid: number): boolean {
279
- try {
280
- process.kill(pid, 0);
281
-
282
- return true;
283
- } catch {
284
- return false;
285
- }
286
- }
287
-
288
277
  // The pid of the daemon behind the socket that refused the handshake.
289
278
  function findDaemonPID(socketPath: string): number | null {
290
279
  return pickStaleDaemonPID({
291
280
  socketPath,
292
281
  record: findDaemonRecord(daemonRecordFile),
293
282
  pidFileSocketPath: daemonSocketPath,
294
- pidFilePID: findPidFilePID(),
283
+ pidFilePID: findPidFilePID(daemonPidFile),
295
284
  });
296
285
  }
297
286
 
@@ -315,22 +304,6 @@ async function stopDaemon(pid: number): Promise<void> {
315
304
  }
316
305
  }
317
306
 
318
- // The pid file beside the sockets this environment computes.
319
- function findPidFilePID(): number | null {
320
- try {
321
- const pid = Number(readFileSync(daemonPidFile, 'utf8'));
322
-
323
- return Number.isInteger(pid) && pid > 1 ? pid : null;
324
- } catch {
325
- return null;
326
- }
327
- }
328
-
329
307
  function spawnDaemonDetached(): ChildProcess {
330
- const args = isCompiledBinary() ? ['daemon'] : [join(import.meta.dir, '..', 'cli.ts'), 'daemon'];
331
- const child = spawnChild(process.execPath, args, { detached: true, stdio: 'ignore' });
332
-
333
- child.unref();
334
-
335
- return child;
308
+ return spawnATCDetached(['daemon']);
336
309
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Copies an environment without the variables that name the session or
3
+ * socket of whoever runs the restart, so the daemon it starts never mistakes
4
+ * itself for a hosted session. Entries with no value drop out.
5
+ */
6
+ export function collectRestartEnv(
7
+ env: Readonly<Record<string, string | undefined>>,
8
+ ): Record<string, string> {
9
+ const copy: Record<string, string> = {};
10
+
11
+ for (const [key, value] of Object.entries(env)) {
12
+ if (value !== undefined && key !== 'ATC_SESSION_ID' && key !== 'ATC_SOCKET') {
13
+ copy[key] = value;
14
+ }
15
+ }
16
+
17
+ return copy;
18
+ }
@@ -0,0 +1,157 @@
1
+ import { DaemonClient } from './client/daemon-client';
2
+ import { pickStaleDaemonPID } from './client/pick-stale-daemon-pid';
3
+ import { findRestartUnit } from './find-restart-unit';
4
+ import { isDaemonProcess } from './is-daemon-process';
5
+ import { DaemonError } from './protocol/daemon-error';
6
+ import { PROTOCOL_V } from './protocol/protocol';
7
+ import type { DaemonAnswer, PlanSession, ReplacementPlan, RestartPlan } from './restart-plan';
8
+ import { runSystemctl } from './run-systemctl';
9
+ import { buildATCCommand } from './shared/build-atc-command';
10
+ import { daemonPidFile, daemonRecordFile, daemonSocketPath, stateDir } from './shared/config';
11
+ import { findDaemonRecord } from './shared/find-daemon-record';
12
+ import { findPidFilePID } from './shared/find-pid-file-pid';
13
+ import { getBuild } from './shared/get-build';
14
+ import { isProcessAlive } from './shared/is-process-alive';
15
+ import { isRecord } from './shared/report';
16
+
17
+ // How long the handshake and the session list may take together.
18
+ const PROBE_DEADLINE_MS = 3000;
19
+
20
+ interface Probe {
21
+ readonly socketPath: string;
22
+ readonly answer: DaemonAnswer;
23
+ readonly sessions: readonly PlanSession[] | null;
24
+ }
25
+
26
+ /**
27
+ * Finds the daemon a restart would stop and the replacement it would start,
28
+ * without changing either. The daemon answers on the socket this
29
+ * environment computes or the one its record in the state directory holds,
30
+ * and a handshake that its protocol refuses still identifies it.
31
+ */
32
+ export async function collectRestartPlan(callerSession: string | null): Promise<RestartPlan> {
33
+ const record = findDaemonRecord(daemonRecordFile);
34
+
35
+ const paths = new Set([daemonSocketPath, ...(record === null ? [] : [record.socketPath])]);
36
+
37
+ let probe: Probe | null = null;
38
+
39
+ for (const path of paths) {
40
+ probe = await tryProbe(path);
41
+
42
+ if (probe !== null) {
43
+ break;
44
+ }
45
+ }
46
+
47
+ const pid =
48
+ probe === null
49
+ ? pickLivePID(record?.pid ?? null, findPidFilePID(daemonPidFile))
50
+ : (pickStaleDaemonPID({
51
+ socketPath: probe.socketPath,
52
+ record,
53
+ pidFileSocketPath: daemonSocketPath,
54
+ pidFilePID: findPidFilePID(daemonPidFile),
55
+ }) ?? pickLivePID(record?.pid ?? null, null));
56
+
57
+ return {
58
+ pid,
59
+ socketPath: probe?.socketPath ?? null,
60
+ answer: probe?.answer ?? null,
61
+ sessions: probe?.sessions ?? null,
62
+ callerSession,
63
+ replacement: await planReplacement(pid),
64
+ };
65
+ }
66
+
67
+ function pickLivePID(...candidates: readonly (number | null)[]): number | null {
68
+ return (
69
+ candidates.find(
70
+ (pid) => pid !== null && isProcessAlive(pid) && isDaemonProcess(pid, stateDir),
71
+ ) ?? null
72
+ );
73
+ }
74
+
75
+ async function planReplacement(pid: number | null): Promise<ReplacementPlan> {
76
+ const unit = pid === null ? null : await findRestartUnit(pid);
77
+
78
+ if (unit === null) {
79
+ return { kind: 'plain', build: getBuild(), command: buildATCCommand(['daemon']).join(' ') };
80
+ }
81
+
82
+ const shown = await runSystemctl(['show', '-p', 'ExecStart', '--value', unit]);
83
+
84
+ const execPath = /path=(?<path>[^\s;]+)/.exec(shown.stdout)?.groups?.['path'];
85
+
86
+ return {
87
+ kind: 'unit',
88
+ unit,
89
+ execStart: execPath ?? (shown.code === 0 && shown.stdout !== '' ? shown.stdout : null),
90
+ };
91
+ }
92
+
93
+ async function tryProbe(socketPath: string): Promise<Probe | null> {
94
+ let client: DaemonClient;
95
+
96
+ try {
97
+ client = await DaemonClient.open(socketPath);
98
+ } catch {
99
+ return null;
100
+ }
101
+
102
+ // A daemon that accepts the socket and never answers must not hang the
103
+ // restart: closing the client rejects every request still waiting.
104
+ const expiry = setTimeout(() => {
105
+ client.stop();
106
+ }, PROBE_DEADLINE_MS);
107
+
108
+ try {
109
+ const hello = await client.sendHello(getBuild());
110
+
111
+ const build = hello['daemon'];
112
+
113
+ return {
114
+ socketPath,
115
+ answer: {
116
+ kind: 'ok',
117
+ build: typeof build === 'string' ? build : 'unknown',
118
+ protocol: PROTOCOL_V,
119
+ },
120
+ sessions: await tryListSessions(client),
121
+ };
122
+ } catch (error) {
123
+ if (error instanceof DaemonError && error.code === 'protocol_mismatch') {
124
+ return { socketPath, answer: { kind: 'refused', message: error.message }, sessions: null };
125
+ }
126
+
127
+ return null;
128
+ } finally {
129
+ clearTimeout(expiry);
130
+
131
+ client.stop();
132
+ }
133
+ }
134
+
135
+ async function tryListSessions(
136
+ client: Pick<DaemonClient, 'sendRequest'>,
137
+ ): Promise<PlanSession[] | null> {
138
+ try {
139
+ const listed = await client.sendRequest('session.list');
140
+
141
+ const sessions = listed['sessions'];
142
+
143
+ if (!Array.isArray(sessions)) {
144
+ return null;
145
+ }
146
+
147
+ return sessions
148
+ .filter((entry) => isRecord(entry))
149
+ .map((entry) => ({
150
+ id: String(entry['id']),
151
+ name: String(entry['name']),
152
+ state: String(entry['state']),
153
+ }));
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
@@ -12,11 +12,13 @@ import type { EventMsg } from '../protocol/protocol';
12
12
  import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
13
13
  import type { SessionState } from '../protocol/session-state';
14
14
  import type { AgentID } from '../shared/agent-id';
15
+ import { claimDaemonLock } from '../shared/claim-daemon-lock';
15
16
  import type { HooksConfig } from '../shared/collect-hooks';
16
17
  import type { TargetConfigError } from '../shared/collect-targets';
17
18
  import type { InvalidGitTransports } from '../shared/collect-workspaces-config';
18
19
  import { DEFAULT_GIT_TRANSPORTS } from '../shared/default-git-transports';
19
20
  import { findDaemonRecord } from '../shared/find-daemon-record';
21
+ import { loadListenerTokens } from '../shared/load-listener-tokens';
20
22
  import type { MessageID } from '../shared/message-id';
21
23
  import { isRecord } from '../shared/report';
22
24
  import { resolveHomeDir } from '../shared/resolve-home-dir';
@@ -50,7 +52,6 @@ import { buildSessionReportEvent } from './build-session-report-event';
50
52
  import { buildTargetAccess } from './build-target-access';
51
53
  import { buildTargetForbiddenError } from './build-target-forbidden-error';
52
54
  import { buildTargetList } from './build-target-list';
53
- import { claimDaemonLock } from './claim-daemon-lock';
54
55
  import { createNonBlockingLog } from './create-non-blocking-log';
55
56
  import type { NonBlockingLog } from './create-non-blocking-log';
56
57
  import { DaemonConnection } from './daemon-connection';
@@ -70,7 +71,6 @@ import { IdempotencyLedger } from './idempotency-ledger';
70
71
  import { isAllowedListenHost } from './is-allowed-listen-host';
71
72
  import { isOwnHookEvent } from './is-own-hook-event';
72
73
  import { isTreeInReach } from './is-tree-in-reach';
73
- import { loadListenerTokens } from './load-listener-tokens';
74
74
  import { loadTranscriptPage } from './load-transcript-page';
75
75
  import { makeHookRunner } from './make-hook-runner';
76
76
  import type { HookScope } from './make-hook-runner';
@@ -1112,7 +1112,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1112
1112
  runtime.pendingLastUsed = true;
1113
1113
  }
1114
1114
 
1115
- void store.recordSpawnDir(s.cwd, { target: s.target, targetIdentity: s.targetIdentity });
1115
+ void store.recordSpawnDir(
1116
+ s.cwd,
1117
+ { target: s.target, targetIdentity: s.targetIdentity },
1118
+ Date.now(),
1119
+ );
1116
1120
 
1117
1121
  return getDescriptor(mgr, s.id);
1118
1122
  };
@@ -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
+ }