@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
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.
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -137,8 +137,10 @@ export function buildScopedContext(
|
|
|
137
137
|
|
|
138
138
|
// A session out of reach answers before any confirm token is handed
|
|
139
139
|
// out or taken, so its host is never touched.
|
|
140
|
-
forgetSession: (id, confirmToken) =>
|
|
141
|
-
canSee(id)
|
|
140
|
+
forgetSession: (id, confirmToken, refuse) =>
|
|
141
|
+
canSee(id)
|
|
142
|
+
? ctx.forgetSession(id, confirmToken, refuse)
|
|
143
|
+
: Promise.resolve('missing' as const),
|
|
142
144
|
revokeSessionAuth: (id) => (canSee(id) ? ctx.revokeSessionAuth(id) : Promise.resolve(false)),
|
|
143
145
|
updateSessionAuth: (id) => (canSee(id) ? ctx.updateSessionAuth(id) : Promise.resolve(null)),
|
|
144
146
|
updateSession: (id, name, pinned) => canSee(id) && ctx.updateSession(id, name, pinned),
|
|
@@ -650,7 +650,10 @@ export class DaemonConnection {
|
|
|
650
650
|
|
|
651
651
|
const id = parsed.data.session;
|
|
652
652
|
|
|
653
|
-
const forgotten = await ctx.forgetSession(id, parsed.data.confirmToken
|
|
653
|
+
const forgotten = await ctx.forgetSession(id, parsed.data.confirmToken, {
|
|
654
|
+
pinned: parsed.data.refusePinned === true,
|
|
655
|
+
live: parsed.data.refuseLive === true,
|
|
656
|
+
});
|
|
654
657
|
|
|
655
658
|
if (forgotten === 'missing') {
|
|
656
659
|
this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
|
|
@@ -63,6 +63,15 @@ type ForgetResult =
|
|
|
63
63
|
| { readonly confirmToken: string; readonly expiresAt: number }
|
|
64
64
|
| { readonly forgotten: true; readonly destroyed: boolean };
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The states a forget refuses to act on: a pinned session, or a sub-session
|
|
68
|
+
* of a pinned one, and a live session.
|
|
69
|
+
*/
|
|
70
|
+
interface ForgetRefusals {
|
|
71
|
+
readonly pinned: boolean;
|
|
72
|
+
readonly live: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
66
75
|
// A transcript page with the file it came from, so a cursor into a replaced
|
|
67
76
|
// transcript is distinguishable from one into a grown transcript.
|
|
68
77
|
interface SessionTranscriptRead {
|
|
@@ -223,10 +232,12 @@ export interface DaemonContext {
|
|
|
223
232
|
readonly killSession: (id: SessionID) => Promise<boolean>;
|
|
224
233
|
|
|
225
234
|
// Forgets a session, or answers with the token a forget that destroys a
|
|
226
|
-
// host must carry. Throws the refusal for a token it does not take
|
|
235
|
+
// host must carry. Throws the refusal for a token it does not take, and
|
|
236
|
+
// for a pinned or live session when the caller asks it to refuse one.
|
|
227
237
|
readonly forgetSession: (
|
|
228
238
|
id: SessionID,
|
|
229
239
|
confirmToken: string | undefined,
|
|
240
|
+
refuse: ForgetRefusals,
|
|
230
241
|
) => Promise<ForgetResult | 'missing'>;
|
|
231
242
|
|
|
232
243
|
// Withdraws the grants of the runtime auth binding on a session's host,
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -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(
|
|
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
|
};
|
|
@@ -1573,13 +1577,31 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1573
1577
|
// A forget on a target that cannot destroy its host forgets at once. On
|
|
1574
1578
|
// one that can, a forget without a token checks the target and answers
|
|
1575
1579
|
// with a token, and the forget that carries the token destroys the host.
|
|
1576
|
-
|
|
1580
|
+
// A refusal the caller asks for reads the session in the same step that
|
|
1581
|
+
// starts the forget, so a pin or revive that lands first is seen.
|
|
1582
|
+
forgetSession: async (id, confirmToken, refuse) => {
|
|
1577
1583
|
const s = mgr.sessions.find((x) => x.id === id);
|
|
1578
1584
|
|
|
1579
1585
|
if (s === undefined) {
|
|
1580
1586
|
return 'missing';
|
|
1581
1587
|
}
|
|
1582
1588
|
|
|
1589
|
+
if (refuse.pinned && (s.pinned || mgr.sessions.some((x) => x.id === s.parent && x.pinned))) {
|
|
1590
|
+
throw new DaemonError(
|
|
1591
|
+
'session_pinned',
|
|
1592
|
+
`session ${id} is pinned, or is a sub-session of a pinned session; unpin it before forgetting it`,
|
|
1593
|
+
{ session: id },
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
if (refuse.live && (s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'))) {
|
|
1598
|
+
throw new DaemonError(
|
|
1599
|
+
'session_live',
|
|
1600
|
+
`session ${id} is live; stop it before forgetting it`,
|
|
1601
|
+
{ session: id },
|
|
1602
|
+
);
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1583
1605
|
if (mgr.findProvider(s)?.capabilities.destroy === true) {
|
|
1584
1606
|
mgr.requireExecution(s, 'destroy');
|
|
1585
1607
|
|