@zgeoff/atc 2.10.0 → 2.10.2
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 +2 -1
- package/package.json +1 -1
- package/src/cli.ts +31 -18
- package/src/client/boot-daemon.ts +123 -26
- package/src/client/index.ts +22 -3
- package/src/client/pick-stale-daemon-pid.ts +31 -0
- package/src/client/ui.ts +4 -1
- package/src/daemon/claim-daemon-lock.ts +76 -0
- package/src/daemon/daemon.ts +38 -0
- package/src/daemon/start-headless-run.ts +6 -2
- package/src/daemon/write-daemon-record.ts +13 -0
- package/src/mcp-http-server.ts +2 -2
- package/src/shared/config.ts +4 -0
- package/src/shared/find-daemon-record.ts +37 -0
- package/src/shared/make-single-flight.ts +22 -0
package/README.md
CHANGED
|
@@ -93,7 +93,8 @@ list.
|
|
|
93
93
|
|
|
94
94
|
If the daemon dies, press `R` on the home screen and every session respawns from its transcript.
|
|
95
95
|
After you upgrade atc, the status bar shows `⟳ update ready`, and `u` restarts the daemon and
|
|
96
|
-
restores the fleet when you are ready.
|
|
96
|
+
restores the fleet when you are ready. The bar shows `⟳ restarting daemon` until the restart
|
|
97
|
+
finishes.
|
|
97
98
|
|
|
98
99
|
atc runs inside zellij or tmux. Give the pane locked mode so the leader key reaches atc.
|
|
99
100
|
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -191,24 +191,37 @@ const main = defineCommand({
|
|
|
191
191
|
),
|
|
192
192
|
);
|
|
193
193
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
194
|
+
let handle: Awaited<ReturnType<typeof daemon.startDaemon>>;
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
handle = await daemon.startDaemon({
|
|
198
|
+
socketPath: config.daemonSocketPath,
|
|
199
|
+
reporterSocketPath: config.socketPath,
|
|
200
|
+
eventsSocketPath: config.eventsSocketPath,
|
|
201
|
+
build: getBuild(),
|
|
202
|
+
adapter: claudeAdapter,
|
|
203
|
+
adapters: [claudeAdapter, grokAdapter, codexAdapter, ...gatewayAdapters],
|
|
204
|
+
dbPath: config.dbFile,
|
|
205
|
+
legacyFleetPath: config.legacyFleetFile,
|
|
206
|
+
pidPath: config.daemonPidFile,
|
|
207
|
+
hooks: cfg.hooks,
|
|
208
|
+
restoreBootTimeoutMs,
|
|
209
|
+
...(Number.isFinite(graceOverride) && graceOverride >= 0
|
|
210
|
+
? { tapGraceMs: graceOverride }
|
|
211
|
+
: {}),
|
|
212
|
+
...(Number.isFinite(queueBytes) && queueBytes > 0 ? { queueBytes } : {}),
|
|
213
|
+
onQuit: () => process.exit(0),
|
|
214
|
+
});
|
|
215
|
+
} catch (error) {
|
|
216
|
+
// A second daemon on the same state directory refuses to start
|
|
217
|
+
// and touches nothing; any other startup failure stays a crash.
|
|
218
|
+
if (error instanceof Error && Reflect.get(error, 'code') === 'daemon_locked') {
|
|
219
|
+
console.error(error.message);
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
212
225
|
|
|
213
226
|
process.on('SIGTERM', () => {
|
|
214
227
|
void (async () => {
|
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
import { spawn as spawnChild } from 'node:child_process';
|
|
2
|
+
import type { ChildProcess } from 'node:child_process';
|
|
2
3
|
import { readFileSync } from 'node:fs';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { toAgentID } from '../agents/agent-adapter';
|
|
5
6
|
import type { AgentID } from '../agents/agent-adapter';
|
|
6
|
-
import { daemonPidFile, daemonSocketPath } from '../shared/config';
|
|
7
|
+
import { daemonPidFile, daemonRecordFile, daemonSocketPath } from '../shared/config';
|
|
8
|
+
import { findDaemonRecord } from '../shared/find-daemon-record';
|
|
7
9
|
import { getBuild } from '../shared/get-build';
|
|
8
10
|
import { isCompiledBinary } from '../shared/is-compiled-binary';
|
|
11
|
+
import { makeSingleFlight } from '../shared/make-single-flight';
|
|
9
12
|
import { isRecord } from '../shared/report';
|
|
10
13
|
import { DaemonClient } from './daemon-client';
|
|
14
|
+
import { pickStaleDaemonPID } from './pick-stale-daemon-pid';
|
|
11
15
|
|
|
12
16
|
export interface DaemonBoot {
|
|
13
17
|
readonly client: DaemonClient;
|
|
14
18
|
readonly stale: boolean;
|
|
15
19
|
readonly lastUsedAgent: AgentID;
|
|
20
|
+
|
|
21
|
+
// The socket the client reached, which differs from the computed one when
|
|
22
|
+
// the daemon was found through its record in the state directory.
|
|
23
|
+
readonly socketPath: string;
|
|
16
24
|
}
|
|
17
25
|
|
|
18
26
|
/**
|
|
19
|
-
* Opens a handshaken client to the daemon, booting the daemon first when
|
|
20
|
-
*
|
|
27
|
+
* Opens a handshaken client to the daemon, booting the daemon first when
|
|
28
|
+
* neither the computed socket nor the one in the daemon's record answers.
|
|
29
|
+
* Overlapping calls in one process share a single boot. A daemon from an older build stays in service — killing
|
|
21
30
|
* it would kill every hosted session — and is reported as stale so the
|
|
22
31
|
* caller can offer a deliberate restart. Only a protocol mismatch, where
|
|
23
32
|
* talking would misbehave, forces the restart immediately. The expected
|
|
@@ -29,7 +38,9 @@ export async function bootDaemonClient(): Promise<DaemonBoot> {
|
|
|
29
38
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
30
39
|
const build = getBuild();
|
|
31
40
|
|
|
32
|
-
const
|
|
41
|
+
const opened = await openOrBootDaemon();
|
|
42
|
+
|
|
43
|
+
const client = opened.client;
|
|
33
44
|
|
|
34
45
|
try {
|
|
35
46
|
const hello = await client.sendHello(build);
|
|
@@ -38,6 +49,7 @@ export async function bootDaemonClient(): Promise<DaemonBoot> {
|
|
|
38
49
|
client,
|
|
39
50
|
stale: hello['daemon'] !== build,
|
|
40
51
|
lastUsedAgent: toAgentID(hello['lastUsedAgent']),
|
|
52
|
+
socketPath: opened.socketPath,
|
|
41
53
|
};
|
|
42
54
|
} catch (error) {
|
|
43
55
|
client.stop();
|
|
@@ -46,7 +58,7 @@ export async function bootDaemonClient(): Promise<DaemonBoot> {
|
|
|
46
58
|
throw error;
|
|
47
59
|
}
|
|
48
60
|
|
|
49
|
-
await stopStaleDaemon();
|
|
61
|
+
await stopStaleDaemon(opened.socketPath);
|
|
50
62
|
}
|
|
51
63
|
}
|
|
52
64
|
|
|
@@ -57,46 +69,117 @@ function isProtocolMismatch(error: unknown): boolean {
|
|
|
57
69
|
return isRecord(error) && error['code'] === 'protocol_mismatch';
|
|
58
70
|
}
|
|
59
71
|
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
interface OpenedDaemon {
|
|
73
|
+
readonly client: DaemonClient;
|
|
74
|
+
readonly socketPath: string;
|
|
75
|
+
}
|
|
62
76
|
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
async function openOrBootDaemon(): Promise<OpenedDaemon> {
|
|
78
|
+
const opened = await tryOpenKnownDaemon();
|
|
65
79
|
|
|
66
|
-
|
|
80
|
+
if (opened !== null) {
|
|
81
|
+
return opened;
|
|
82
|
+
}
|
|
67
83
|
|
|
68
|
-
|
|
69
|
-
await Bun.sleep(100);
|
|
84
|
+
await bootDaemonOnce();
|
|
70
85
|
|
|
71
|
-
|
|
72
|
-
|
|
86
|
+
const booted = await tryOpenKnownDaemon();
|
|
87
|
+
|
|
88
|
+
if (booted === null) {
|
|
89
|
+
throw new Error(formatBootFailure());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return booted;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Tries the socket this environment computes, then the one the running
|
|
97
|
+
* daemon recorded in the state directory: a client whose environment lacks
|
|
98
|
+
* XDG_RUNTIME_DIR computes a different path from the daemon's.
|
|
99
|
+
*/
|
|
100
|
+
async function tryOpenKnownDaemon(): Promise<OpenedDaemon | null> {
|
|
101
|
+
const computed = await tryOpenDaemon(daemonSocketPath);
|
|
102
|
+
|
|
103
|
+
if (computed !== null) {
|
|
104
|
+
return computed;
|
|
73
105
|
}
|
|
74
106
|
|
|
75
|
-
|
|
76
|
-
|
|
107
|
+
const record = findDaemonRecord(daemonRecordFile);
|
|
108
|
+
|
|
109
|
+
if (record === null || record.socketPath === daemonSocketPath) {
|
|
110
|
+
return null;
|
|
77
111
|
}
|
|
78
112
|
|
|
79
|
-
return
|
|
113
|
+
return tryOpenDaemon(record.socketPath);
|
|
80
114
|
}
|
|
81
115
|
|
|
82
|
-
async function tryOpenDaemon(): Promise<
|
|
116
|
+
async function tryOpenDaemon(socketPath: string): Promise<OpenedDaemon | null> {
|
|
83
117
|
try {
|
|
84
|
-
return await DaemonClient.open(
|
|
118
|
+
return { client: await DaemonClient.open(socketPath), socketPath };
|
|
85
119
|
} catch {
|
|
86
120
|
return null;
|
|
87
121
|
}
|
|
88
122
|
}
|
|
89
123
|
|
|
90
|
-
|
|
91
|
-
|
|
124
|
+
// How long a boot waits for a daemon to answer. It covers a daemon that
|
|
125
|
+
// waits out the state lock of one still shutting down.
|
|
126
|
+
const BOOT_DEADLINE_MS = 8000;
|
|
127
|
+
|
|
128
|
+
const bootDaemonOnce = makeSingleFlight(async () => {
|
|
129
|
+
let child = spawnDaemonDetached();
|
|
130
|
+
const deadline = Date.now() + BOOT_DEADLINE_MS;
|
|
131
|
+
|
|
132
|
+
while (Date.now() < deadline) {
|
|
133
|
+
await Bun.sleep(100);
|
|
134
|
+
|
|
135
|
+
const probe = await tryOpenKnownDaemon();
|
|
136
|
+
|
|
137
|
+
if (probe !== null) {
|
|
138
|
+
probe.client.stop();
|
|
139
|
+
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// A daemon that found the state lock still held by one shutting down
|
|
144
|
+
// exits; once the old one is gone, a fresh spawn takes the lock.
|
|
145
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
146
|
+
child = spawnDaemonDetached();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// A live daemon this process cannot reach, such as one whose runtime
|
|
152
|
+
// directory a sandbox hides, needs a different fix than one that never
|
|
153
|
+
// started, so the message tells them apart.
|
|
154
|
+
function formatBootFailure(): string {
|
|
155
|
+
const record = findDaemonRecord(daemonRecordFile);
|
|
92
156
|
|
|
157
|
+
if (record !== null && isProcessAlive(record.pid)) {
|
|
158
|
+
return `the atc daemon (pid ${record.pid}) is running, but its socket ${record.socketPath} is unreachable from here`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return 'the atc daemon did not come up; try `atc daemon` for its output';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isProcessAlive(pid: number): boolean {
|
|
93
165
|
try {
|
|
94
|
-
pid
|
|
166
|
+
process.kill(pid, 0);
|
|
167
|
+
|
|
168
|
+
return true;
|
|
95
169
|
} catch {
|
|
96
|
-
return;
|
|
170
|
+
return false;
|
|
97
171
|
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function stopStaleDaemon(socketPath: string): Promise<void> {
|
|
175
|
+
const pid = pickStaleDaemonPID({
|
|
176
|
+
socketPath,
|
|
177
|
+
record: findDaemonRecord(daemonRecordFile),
|
|
178
|
+
pidFileSocketPath: daemonSocketPath,
|
|
179
|
+
pidFilePID: findPidFilePID(),
|
|
180
|
+
});
|
|
98
181
|
|
|
99
|
-
if (
|
|
182
|
+
if (pid === null) {
|
|
100
183
|
return;
|
|
101
184
|
}
|
|
102
185
|
|
|
@@ -119,8 +202,22 @@ async function stopStaleDaemon(): Promise<void> {
|
|
|
119
202
|
}
|
|
120
203
|
}
|
|
121
204
|
|
|
122
|
-
|
|
205
|
+
// The pid file beside the sockets this environment computes.
|
|
206
|
+
function findPidFilePID(): number | null {
|
|
207
|
+
try {
|
|
208
|
+
const pid = Number(readFileSync(daemonPidFile, 'utf8'));
|
|
209
|
+
|
|
210
|
+
return Number.isInteger(pid) && pid > 1 ? pid : null;
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function spawnDaemonDetached(): ChildProcess {
|
|
123
217
|
const args = isCompiledBinary() ? ['daemon'] : [join(import.meta.dir, '..', 'cli.ts'), 'daemon'];
|
|
218
|
+
const child = spawnChild(process.execPath, args, { detached: true, stdio: 'ignore' });
|
|
219
|
+
|
|
220
|
+
child.unref();
|
|
124
221
|
|
|
125
|
-
|
|
222
|
+
return child;
|
|
126
223
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { AgentID } from '../agents/agent-adapter';
|
|
|
4
4
|
import { countSessionStates, sortGroupedSessionViews, sortSessionViews } from '../daemon/sessions';
|
|
5
5
|
import type { EventMsg } from '../protocol/protocol';
|
|
6
6
|
import { loadConfig } from '../shared/config';
|
|
7
|
+
import { makeSingleFlight } from '../shared/make-single-flight';
|
|
7
8
|
import { bootDaemonClient } from './boot-daemon';
|
|
8
9
|
import { buildClientMachine } from './build-client-machine';
|
|
9
10
|
import { buildLeaderChords } from './build-leader-chords';
|
|
@@ -82,6 +83,7 @@ function scheduleStatus() {
|
|
|
82
83
|
urgentName: urgent === undefined ? null : urgent.name,
|
|
83
84
|
leaderLabel: leader.label,
|
|
84
85
|
stale: daemonStale,
|
|
86
|
+
restarting: daemonRestarting,
|
|
85
87
|
});
|
|
86
88
|
}
|
|
87
89
|
}, 50);
|
|
@@ -214,7 +216,7 @@ function renderOverlay() {
|
|
|
214
216
|
selected: overlaySelected,
|
|
215
217
|
confirmKill,
|
|
216
218
|
filter: overlayFilter,
|
|
217
|
-
stale: daemonStale,
|
|
219
|
+
stale: daemonStale && !daemonRestarting,
|
|
218
220
|
grouped: overlayGrouped,
|
|
219
221
|
});
|
|
220
222
|
|
|
@@ -616,7 +618,7 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
616
618
|
}
|
|
617
619
|
|
|
618
620
|
if (ch === 'u' && daemonStale) {
|
|
619
|
-
void
|
|
621
|
+
void restartDaemonOnce();
|
|
620
622
|
|
|
621
623
|
return;
|
|
622
624
|
}
|
|
@@ -734,6 +736,7 @@ const boot = await bootDaemonClient();
|
|
|
734
736
|
|
|
735
737
|
let client = boot.client;
|
|
736
738
|
let daemonStale = boot.stale;
|
|
739
|
+
let daemonRestarting = false;
|
|
737
740
|
|
|
738
741
|
lastUsedAgent = boot.lastUsedAgent;
|
|
739
742
|
client.onEvent = applyDaemonEvent;
|
|
@@ -767,8 +770,24 @@ async function refreshMirror() {
|
|
|
767
770
|
/**
|
|
768
771
|
* The deliberate half of the stale-daemon story: quits the old daemon,
|
|
769
772
|
* boots one from the current build, and restores the whole fleet, so an
|
|
770
|
-
* update never interrupts sessions until the user picks the moment.
|
|
773
|
+
* update never interrupts sessions until the user picks the moment. A
|
|
774
|
+
* press while a restart runs joins it rather than starting another, and
|
|
775
|
+
* the status bar says a restart is running until it finishes.
|
|
771
776
|
*/
|
|
777
|
+
const restartDaemonOnce = makeSingleFlight(async () => {
|
|
778
|
+
daemonRestarting = true;
|
|
779
|
+
|
|
780
|
+
renderOverlay();
|
|
781
|
+
|
|
782
|
+
try {
|
|
783
|
+
await restartDaemon();
|
|
784
|
+
} finally {
|
|
785
|
+
daemonRestarting = false;
|
|
786
|
+
|
|
787
|
+
scheduleStatus();
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
|
|
772
791
|
async function restartDaemon() {
|
|
773
792
|
try {
|
|
774
793
|
await client.sendRequest('daemon.quit');
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { DaemonRecord } from '../shared/find-daemon-record';
|
|
2
|
+
|
|
3
|
+
export interface StaleDaemonEvidence {
|
|
4
|
+
// The socket whose daemon refused the handshake.
|
|
5
|
+
readonly socketPath: string;
|
|
6
|
+
readonly record: DaemonRecord | null;
|
|
7
|
+
|
|
8
|
+
// The socket the pid file sits beside, and the pid it holds.
|
|
9
|
+
readonly pidFileSocketPath: string;
|
|
10
|
+
readonly pidFilePID: number | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Picks the pid of the daemon behind the socket that refused the
|
|
15
|
+
* handshake, from whichever source belongs to that socket: the state
|
|
16
|
+
* directory's record when it lists that socket, else the pid file beside
|
|
17
|
+
* it. A record for any other socket belongs to a different daemon, so it
|
|
18
|
+
* never supplies the pid; with no source for that socket, there is no pid
|
|
19
|
+
* to stop.
|
|
20
|
+
*/
|
|
21
|
+
export function pickStaleDaemonPID(evidence: StaleDaemonEvidence): number | null {
|
|
22
|
+
if (evidence.record !== null && evidence.record.socketPath === evidence.socketPath) {
|
|
23
|
+
return evidence.record.pid;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (evidence.socketPath === evidence.pidFileSocketPath) {
|
|
27
|
+
return evidence.pidFilePID;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return null;
|
|
31
|
+
}
|
package/src/client/ui.ts
CHANGED
|
@@ -75,6 +75,7 @@ export interface StatusView {
|
|
|
75
75
|
readonly urgentName: string | null;
|
|
76
76
|
readonly leaderLabel: string;
|
|
77
77
|
readonly stale: boolean;
|
|
78
|
+
readonly restarting: boolean;
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
export function drawStatusBar(view: StatusView) {
|
|
@@ -101,7 +102,9 @@ export function drawStatusBar(view: StatusView) {
|
|
|
101
102
|
parts.push(`✗ ${c.exited}`);
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
if (view.
|
|
105
|
+
if (view.restarting) {
|
|
106
|
+
parts.push('⟳ restarting daemon');
|
|
107
|
+
} else if (view.stale) {
|
|
105
108
|
parts.push('⟳ update ready');
|
|
106
109
|
}
|
|
107
110
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { FFIType, dlopen, ptr } from 'bun:ffi';
|
|
2
|
+
import { closeSync, writeFileSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
export interface DaemonLock {
|
|
5
|
+
// Releases the lock; safe to call more than once.
|
|
6
|
+
readonly dispose: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const LOCK_EX = 2;
|
|
10
|
+
const LOCK_NB = 4;
|
|
11
|
+
const O_RDWR = 2;
|
|
12
|
+
|
|
13
|
+
// Kept off every child the daemon spawns, so an orphaned agent process can
|
|
14
|
+
// never hold a dead daemon's lock.
|
|
15
|
+
const O_CLOEXEC = process.platform === 'darwin' ? 0x01_00_00_00 : 0o200_0000;
|
|
16
|
+
const RETRY_MS = 50;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Takes the exclusive daemon lock on the file at `lockPath`, waiting up to
|
|
20
|
+
* `waitMs` for a daemon that is shutting down to let go, and resolves null
|
|
21
|
+
* while another daemon holds it.
|
|
22
|
+
*
|
|
23
|
+
* The lock is a kernel `flock` on an open file description: two daemons
|
|
24
|
+
* racing at the same instant cannot both win, and the kernel drops it when
|
|
25
|
+
* the holding process dies, so a crashed daemon never blocks the next one.
|
|
26
|
+
* The file is never removed: removing it would let a newcomer lock a fresh
|
|
27
|
+
* inode while an older daemon still holds the unlinked one.
|
|
28
|
+
*/
|
|
29
|
+
export async function claimDaemonLock(
|
|
30
|
+
lockPath: string,
|
|
31
|
+
waitMs: number,
|
|
32
|
+
): Promise<DaemonLock | null> {
|
|
33
|
+
writeFileSync(lockPath, '', { flag: 'a' });
|
|
34
|
+
|
|
35
|
+
const libc = openLibc();
|
|
36
|
+
const fd = libc.symbols.open(ptr(Buffer.from(`${lockPath}\0`)), O_RDWR | O_CLOEXEC);
|
|
37
|
+
|
|
38
|
+
if (fd < 0) {
|
|
39
|
+
throw new Error(`atc daemon: cannot open the lock file ${lockPath}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const deadline = Date.now() + waitMs;
|
|
43
|
+
|
|
44
|
+
while (libc.symbols.flock(fd, LOCK_EX | LOCK_NB) !== 0) {
|
|
45
|
+
if (Date.now() >= deadline) {
|
|
46
|
+
closeSync(fd);
|
|
47
|
+
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await Bun.sleep(RETRY_MS);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let held = true;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
dispose: () => {
|
|
58
|
+
if (held) {
|
|
59
|
+
held = false;
|
|
60
|
+
|
|
61
|
+
closeSync(fd);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// `open` is variadic in C; it is declared here with two arguments, which is
|
|
68
|
+
// sound because the lock file already exists and no mode is passed.
|
|
69
|
+
function openLibc() {
|
|
70
|
+
const libcPath = process.platform === 'darwin' ? '/usr/lib/libSystem.B.dylib' : 'libc.so.6';
|
|
71
|
+
|
|
72
|
+
return dlopen(libcPath, {
|
|
73
|
+
open: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
74
|
+
flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
|
|
75
|
+
});
|
|
76
|
+
}
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
2
3
|
import type { AdapterEvent, AgentAdapter } from '../agents/agent-adapter';
|
|
3
4
|
import { MAX_CHUNK, PROTOCOL_V } from '../protocol/protocol';
|
|
4
5
|
import type { EventMsg } from '../protocol/protocol';
|
|
5
6
|
import type { HooksConfig } from '../shared/collect-hooks';
|
|
7
|
+
import { findDaemonRecord } from '../shared/find-daemon-record';
|
|
6
8
|
import type { SessionID } from '../shared/session-id';
|
|
7
9
|
import { truncateToBytes } from '../shared/truncate-to-bytes';
|
|
8
10
|
import type { MessageOwner } from '../store/message-owner';
|
|
@@ -17,6 +19,7 @@ import { buildReportTrailEntry } from './build-report-trail-entry';
|
|
|
17
19
|
import { buildSessionEvent } from './build-session-event';
|
|
18
20
|
import { buildSessionMessageEvent } from './build-session-message-event';
|
|
19
21
|
import { buildSessionReportEvent } from './build-session-report-event';
|
|
22
|
+
import { claimDaemonLock } from './claim-daemon-lock';
|
|
20
23
|
import { DaemonConnection } from './daemon-connection';
|
|
21
24
|
import type { DaemonContext, OutputClient, TapClient } from './daemon-connection';
|
|
22
25
|
import { EventSignal } from './event-signal';
|
|
@@ -37,6 +40,7 @@ import type { Session, SessionDescriptor, SessionState } from './sessions';
|
|
|
37
40
|
import { startEventsServer } from './start-events-server';
|
|
38
41
|
import { startHeadlessTurn } from './start-headless-turn';
|
|
39
42
|
import { TapRegistry } from './tap-registry';
|
|
43
|
+
import { writeDaemonRecord } from './write-daemon-record';
|
|
40
44
|
|
|
41
45
|
export interface DaemonOptions {
|
|
42
46
|
readonly socketPath: string;
|
|
@@ -101,6 +105,10 @@ export interface DaemonHandle {
|
|
|
101
105
|
// it is refused.
|
|
102
106
|
const TAP_GRACE_MS = 15_000;
|
|
103
107
|
|
|
108
|
+
// How long startup waits for a daemon that is shutting down to release the
|
|
109
|
+
// state lock before refusing to start.
|
|
110
|
+
const LOCK_WAIT_MS = 2000;
|
|
111
|
+
|
|
104
112
|
/**
|
|
105
113
|
* The daemon: owns the sessions, the client-protocol listener, and the
|
|
106
114
|
* reporter listener. Protocol requests are NDJSON lines, one response per
|
|
@@ -113,6 +121,23 @@ const TAP_GRACE_MS = 15_000;
|
|
|
113
121
|
export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
114
122
|
let stopDaemon: (() => Promise<void>) | null = null;
|
|
115
123
|
|
|
124
|
+
// One daemon per state directory: the lock comes before the store, the
|
|
125
|
+
// sockets, or the fleet, so a second daemon touches none of them.
|
|
126
|
+
const stateDir = dirname(opts.dbPath);
|
|
127
|
+
const recordPath = join(stateDir, 'daemon.json');
|
|
128
|
+
|
|
129
|
+
const lock = await claimDaemonLock(join(stateDir, 'daemon.lock'), LOCK_WAIT_MS);
|
|
130
|
+
|
|
131
|
+
if (lock === null) {
|
|
132
|
+
const holder = findDaemonRecord(recordPath);
|
|
133
|
+
const where = holder === null ? '' : ` (pid ${holder.pid}, socket ${holder.socketPath})`;
|
|
134
|
+
|
|
135
|
+
throw Object.assign(
|
|
136
|
+
new Error(`atc daemon: another daemon already serves ${stateDir}${where}`),
|
|
137
|
+
{ code: 'daemon_locked' },
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
116
141
|
if (opts.pidPath !== undefined) {
|
|
117
142
|
writeFileSync(opts.pidPath, String(process.pid));
|
|
118
143
|
}
|
|
@@ -1068,13 +1093,26 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1068
1093
|
|
|
1069
1094
|
await store.stop();
|
|
1070
1095
|
|
|
1096
|
+
try {
|
|
1097
|
+
unlinkSync(recordPath);
|
|
1098
|
+
} catch {}
|
|
1099
|
+
|
|
1071
1100
|
if (opts.pidPath !== undefined) {
|
|
1072
1101
|
try {
|
|
1073
1102
|
unlinkSync(opts.pidPath);
|
|
1074
1103
|
} catch {}
|
|
1075
1104
|
}
|
|
1105
|
+
|
|
1106
|
+
lock.dispose();
|
|
1076
1107
|
};
|
|
1077
1108
|
|
|
1109
|
+
writeDaemonRecord(recordPath, {
|
|
1110
|
+
pid: process.pid,
|
|
1111
|
+
socketPath: opts.socketPath,
|
|
1112
|
+
reporterSocketPath: opts.reporterSocketPath,
|
|
1113
|
+
eventsSocketPath: opts.eventsSocketPath ?? null,
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1078
1116
|
return { stop: stopDaemon, countClients: () => clients.size };
|
|
1079
1117
|
}
|
|
1080
1118
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
2
1
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
3
2
|
import { socketPath } from '../shared/config';
|
|
4
3
|
import { isCompiledBinary } from '../shared/is-compiled-binary';
|
|
@@ -55,7 +54,12 @@ export function startHeadlessRun(
|
|
|
55
54
|
const mode = PERMISSION_MODES.find((m) => m === opts.permissionMode);
|
|
56
55
|
let stderrTail = '';
|
|
57
56
|
|
|
58
|
-
|
|
57
|
+
// The SDK is the heaviest module in the daemon's graph, and only a
|
|
58
|
+
// headless turn needs it, so loading it here keeps it off the daemon's
|
|
59
|
+
// cold start.
|
|
60
|
+
const sdk = await import('@anthropic-ai/claude-agent-sdk');
|
|
61
|
+
|
|
62
|
+
const stream = sdk.query({
|
|
59
63
|
prompt: opts.prompt,
|
|
60
64
|
options: {
|
|
61
65
|
cwd: opts.cwd,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import type { DaemonRecord } from '../shared/find-daemon-record';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Writes the running daemon's record through a temporary file and a
|
|
6
|
+
* rename, so a reader never sees half of it.
|
|
7
|
+
*/
|
|
8
|
+
export function writeDaemonRecord(recordPath: string, record: DaemonRecord): void {
|
|
9
|
+
const tmpPath = `${recordPath}.${process.pid}.tmp`;
|
|
10
|
+
|
|
11
|
+
writeFileSync(tmpPath, `${JSON.stringify(record)}\n`);
|
|
12
|
+
renameSync(tmpPath, recordPath);
|
|
13
|
+
}
|
package/src/mcp-http-server.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { collectClients } from './mcp/collect-clients';
|
|
|
4
4
|
import { openMCPAuth } from './mcp/open-mcp-auth';
|
|
5
5
|
import { ReconnectingCaller } from './mcp/reconnecting-caller';
|
|
6
6
|
import { startMCPHTTPServer } from './mcp/start-mcp-http-server';
|
|
7
|
-
import {
|
|
7
|
+
import { mcpAuthDBFile, stateDir } from './shared/config';
|
|
8
8
|
import { loadMCPHTTPConfig } from './shared/load-mcp-http-config';
|
|
9
9
|
|
|
10
10
|
interface MCPHTTPFlags {
|
|
@@ -27,7 +27,7 @@ export async function runMCPHTTPServer(build: string, flags: MCPHTTPFlags): Prom
|
|
|
27
27
|
|
|
28
28
|
mkdirSync(stateDir, { recursive: true });
|
|
29
29
|
|
|
30
|
-
const caller = new ReconnectingCaller(
|
|
30
|
+
const caller = new ReconnectingCaller(boot.socketPath, build);
|
|
31
31
|
|
|
32
32
|
const server = await startMCPHTTPServer({
|
|
33
33
|
caller,
|
package/src/shared/config.ts
CHANGED
|
@@ -62,6 +62,10 @@ export const mcpAuthDBFile = join(stateDir, 'mcp-auth.db');
|
|
|
62
62
|
export const legacyFleetFile = join(stateDir, 'fleet.json');
|
|
63
63
|
export const daemonPidFile = join(process.env['XDG_RUNTIME_DIR'] ?? stateDir, 'atc-daemon.pid');
|
|
64
64
|
|
|
65
|
+
// Written by the running daemon: its pid and the socket paths it listens
|
|
66
|
+
// on, for a client whose environment computes other socket paths.
|
|
67
|
+
export const daemonRecordFile = join(stateDir, 'daemon.json');
|
|
68
|
+
|
|
65
69
|
// A user-written config.json's top-level keys. An absent or wrong-typed
|
|
66
70
|
// field parses to undefined rather than failing the file, so a bad config
|
|
67
71
|
// falls back to a default instead of refusing to start atc.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
export interface DaemonRecord {
|
|
5
|
+
readonly pid: number;
|
|
6
|
+
readonly socketPath: string;
|
|
7
|
+
readonly reporterSocketPath: string;
|
|
8
|
+
readonly eventsSocketPath: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const RECORD_SCHEMA = z.object({
|
|
12
|
+
pid: z.number().int().positive(),
|
|
13
|
+
socketPath: z.string().min(1),
|
|
14
|
+
reporterSocketPath: z.string().min(1),
|
|
15
|
+
eventsSocketPath: z.string().min(1).nullable(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Reads the record a running daemon keeps in its state directory: its pid
|
|
20
|
+
* and the socket paths it listens on, which a client whose environment
|
|
21
|
+
* computes other socket paths uses to find it. A missing, torn, or
|
|
22
|
+
* malformed record reads as null; a record left by a crashed daemon still
|
|
23
|
+
* parses, so a caller proves liveness by connecting.
|
|
24
|
+
*/
|
|
25
|
+
export function findDaemonRecord(recordPath: string): DaemonRecord | null {
|
|
26
|
+
let raw: unknown;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
raw = JSON.parse(readFileSync(recordPath, 'utf8'));
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const parsed = RECORD_SCHEMA.safeParse(raw);
|
|
35
|
+
|
|
36
|
+
return parsed.success ? parsed.data : null;
|
|
37
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps an async function so overlapping calls share one run: a call made
|
|
3
|
+
* while a run is in flight gets that run's promise instead of starting
|
|
4
|
+
* another, and the first call after it settles starts a fresh run.
|
|
5
|
+
*/
|
|
6
|
+
export function makeSingleFlight<T>(run: () => Promise<T>): () => Promise<T> {
|
|
7
|
+
let inFlight: Promise<T> | null = null;
|
|
8
|
+
|
|
9
|
+
const runAndClear = async (): Promise<T> => {
|
|
10
|
+
try {
|
|
11
|
+
return await run();
|
|
12
|
+
} finally {
|
|
13
|
+
inFlight = null;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
return () => {
|
|
18
|
+
inFlight ??= runAndClear();
|
|
19
|
+
|
|
20
|
+
return inFlight;
|
|
21
|
+
};
|
|
22
|
+
}
|