@zgeoff/atc 0.1.4 → 0.1.6
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 +1 -1
- package/src/cli.ts +11 -1
- package/src/daemon.ts +120 -20
- package/src/sessions.ts +26 -0
- package/src/state-store.ts +12 -0
package/README.md
CHANGED
|
@@ -121,3 +121,9 @@ live fleet (name, cwd, Claude session id) to its SQLite store. If the daemon its
|
|
|
121
121
|
SIGKILL, reboot — the child claude processes die with it, but every session's transcript is already
|
|
122
122
|
on disk. Start atc and press `R`: the whole fleet respawns via `claude --resume`. Only deliberate
|
|
123
123
|
kills (`K`, `Y` eject) remove entries from the fleet, so it stays restorable.
|
|
124
|
+
|
|
125
|
+
Restoring shows the whole fleet immediately — every incoming session appears in the list marked
|
|
126
|
+
"waiting to restore" — and revives one at a time, most recently active first: the next
|
|
127
|
+
`claude --resume` starts only once the previous one has reported it is up (its `SessionStart` hook),
|
|
128
|
+
so bringing back a dozen sessions no longer launches a dozen Claude processes at the same instant
|
|
129
|
+
and pins the machine. Each row flips live as its session comes back.
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -50,16 +50,26 @@ const main = defineCommand({
|
|
|
50
50
|
// Test harnesses shrink the outbound queue to force overflow
|
|
51
51
|
// deterministically; unset means the production default.
|
|
52
52
|
const queueBytes = Number(process.env['ATC_QUEUE_BYTES']);
|
|
53
|
+
const cfg = config.loadConfig();
|
|
54
|
+
|
|
55
|
+
// Cap on how long a fleet restore waits for one revived session to
|
|
56
|
+
// report it has booted before moving to the next. Tests pin it to
|
|
57
|
+
// keep timing deterministic; unset means the production default.
|
|
58
|
+
const capOverride = Number(process.env['ATC_RESTORE_BOOT_TIMEOUT_MS']);
|
|
59
|
+
|
|
60
|
+
const restoreBootTimeoutMs =
|
|
61
|
+
Number.isFinite(capOverride) && capOverride >= 0 ? capOverride : 15_000;
|
|
53
62
|
|
|
54
63
|
const handle = daemon.startDaemon({
|
|
55
64
|
headlessRunner: (runOpts, hooks) => headless.startHeadlessRun(runOpts, hooks),
|
|
56
65
|
socketPath: config.daemonSocketPath,
|
|
57
66
|
reporterSocketPath: config.socketPath,
|
|
58
67
|
build: getBuild(),
|
|
59
|
-
adapter: new claude.ClaudeAdapter(
|
|
68
|
+
adapter: new claude.ClaudeAdapter(cfg),
|
|
60
69
|
dbPath: config.dbFile,
|
|
61
70
|
legacyFleetPath: config.legacyFleetFile,
|
|
62
71
|
pidPath: config.daemonPidFile,
|
|
72
|
+
restoreBootTimeoutMs,
|
|
63
73
|
...(Number.isFinite(queueBytes) && queueBytes > 0 ? { queueBytes } : {}),
|
|
64
74
|
onQuit: () => process.exit(0),
|
|
65
75
|
});
|
package/src/daemon.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { MAX_CHUNK, PROTOCOL_V } from './protocol';
|
|
|
10
10
|
import type { EventMsg } from './protocol';
|
|
11
11
|
import { ScreenModel } from './screen-model';
|
|
12
12
|
import { SessionManager } from './sessions';
|
|
13
|
-
import type { SessionDescriptor, SessionState } from './sessions';
|
|
13
|
+
import type { Session, SessionDescriptor, SessionState } from './sessions';
|
|
14
14
|
import { StateStore } from './state-store';
|
|
15
15
|
|
|
16
16
|
export interface DaemonOptions {
|
|
@@ -44,6 +44,13 @@ export interface DaemonOptions {
|
|
|
44
44
|
// before starting the headless run anyway.
|
|
45
45
|
readonly ejectSettleMs?: number;
|
|
46
46
|
|
|
47
|
+
// A fleet-wide restore revives one session at a time, waiting for each to
|
|
48
|
+
// report it has booted before starting the next so the machine is not
|
|
49
|
+
// buried under a dozen simultaneous agent boots. This caps how long a
|
|
50
|
+
// single revive waits for that signal before moving on regardless, so a
|
|
51
|
+
// session that never reports cannot stall the rest. Zero waits forever.
|
|
52
|
+
readonly restoreBootTimeoutMs?: number;
|
|
53
|
+
|
|
47
54
|
// Called after a client-requested quit has stopped the daemon; the real
|
|
48
55
|
// entrypoint exits the process, tests leave it unset.
|
|
49
56
|
readonly onQuit?: () => void;
|
|
@@ -126,6 +133,37 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
126
133
|
// (or a settle timeout) before the headless run starts.
|
|
127
134
|
const pendingEjects = new Map<string, () => void>();
|
|
128
135
|
|
|
136
|
+
// A staggered fleet restore parks a resolver here while it waits for the
|
|
137
|
+
// session it just revived to report it has booted; the reporter fires it on
|
|
138
|
+
// SessionStart, and a dying revive fires it too so a failed resume does not
|
|
139
|
+
// hold up the rest. Resolving a settled promise again is a no-op.
|
|
140
|
+
const bootWaiters = new Map<string, () => void>();
|
|
141
|
+
|
|
142
|
+
// Blocks until the session reports SessionStart, it dies, or the cap
|
|
143
|
+
// elapses (a positive cap only; zero waits on the signal alone).
|
|
144
|
+
const waitForBoot = (sessionID: string, capMs: number): Promise<void> => {
|
|
145
|
+
const settled = Promise.withResolvers<void>();
|
|
146
|
+
const timer = capMs > 0 ? setTimeout(settled.resolve, capMs) : undefined;
|
|
147
|
+
|
|
148
|
+
bootWaiters.set(sessionID, settled.resolve);
|
|
149
|
+
|
|
150
|
+
if (timer !== undefined) {
|
|
151
|
+
restoreTimers.add(timer);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return (async () => {
|
|
155
|
+
await settled.promise;
|
|
156
|
+
|
|
157
|
+
bootWaiters.delete(sessionID);
|
|
158
|
+
|
|
159
|
+
if (timer !== undefined) {
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
|
|
162
|
+
restoreTimers.delete(timer);
|
|
163
|
+
}
|
|
164
|
+
})();
|
|
165
|
+
};
|
|
166
|
+
|
|
129
167
|
const startHeadlessTurn = (sessionID: string, prompt: string): boolean => {
|
|
130
168
|
const runner = opts.headlessRunner;
|
|
131
169
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
@@ -172,6 +210,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
172
210
|
const screens = new Map<string, ScreenModel>();
|
|
173
211
|
const resizeTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
174
212
|
const detectTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
213
|
+
const restoreTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
175
214
|
|
|
176
215
|
// The screen tier of the detector stack: once a session's output has
|
|
177
216
|
// quiesced, judge the serialized screen and flip running/needs_you.
|
|
@@ -345,6 +384,12 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
345
384
|
mgr.onEvent = (kind, s) => {
|
|
346
385
|
recordAttention(kind, s);
|
|
347
386
|
|
|
387
|
+
// A revive that dies before it ever announces itself must still release
|
|
388
|
+
// the staggered restore, or a failed resume would hold up the fleet.
|
|
389
|
+
if (kind === 'removed' || (kind === 'state' && s.pty === null)) {
|
|
390
|
+
bootWaiters.get(s.id)?.();
|
|
391
|
+
}
|
|
392
|
+
|
|
348
393
|
if (kind === 'removed') {
|
|
349
394
|
attachments.removeSession(s.id);
|
|
350
395
|
seqs.delete(s.id);
|
|
@@ -397,6 +442,12 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
397
442
|
pendingEjects.get(e.atcId)?.();
|
|
398
443
|
}
|
|
399
444
|
|
|
445
|
+
// A revived session announcing itself is the cue a staggered restore
|
|
446
|
+
// waits on before booting the next one.
|
|
447
|
+
if (e.event === 'SessionStart') {
|
|
448
|
+
bootWaiters.get(e.atcId)?.();
|
|
449
|
+
}
|
|
450
|
+
|
|
400
451
|
mgr.applyHook(e);
|
|
401
452
|
}, opts.reporterSocketPath);
|
|
402
453
|
|
|
@@ -482,6 +533,10 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
482
533
|
|
|
483
534
|
ptyDims.set(id, { cols, rows });
|
|
484
535
|
|
|
536
|
+
if (!screens.has(id)) {
|
|
537
|
+
screens.set(id, new ScreenModel(cols, rows));
|
|
538
|
+
}
|
|
539
|
+
|
|
485
540
|
scheduleResize(id);
|
|
486
541
|
|
|
487
542
|
return 'ok';
|
|
@@ -563,33 +618,74 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
563
618
|
getEffectiveDims: (sessionID) =>
|
|
564
619
|
attachments.findEffectiveDims(sessionID) ?? ptyDims.get(sessionID) ?? { cols: 80, rows: 24 },
|
|
565
620
|
restoreFleet: (cols, rows) => {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
621
|
+
const hasSession = (claudeId: string) =>
|
|
622
|
+
mgr.sessions.some(
|
|
623
|
+
(s) =>
|
|
624
|
+
s.claudeId === claudeId &&
|
|
625
|
+
(s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited')),
|
|
626
|
+
);
|
|
570
627
|
|
|
571
|
-
|
|
572
|
-
continue;
|
|
573
|
-
}
|
|
628
|
+
const recency = store.collectFleetRecency();
|
|
574
629
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
'
|
|
583
|
-
entry.group,
|
|
630
|
+
// Most recently active sessions revive first, so the ones the user was
|
|
631
|
+
// just working in come back before long-idle ones; entries that never
|
|
632
|
+
// reported an event keep their stored order at the end.
|
|
633
|
+
const entries = store
|
|
634
|
+
.loadFleet()
|
|
635
|
+
.filter((entry) => !hasSession(entry.claudeId))
|
|
636
|
+
.toSorted((a, b) =>
|
|
637
|
+
(recency.get(b.claudeId) ?? '').localeCompare(recency.get(a.claudeId) ?? ''),
|
|
584
638
|
);
|
|
585
639
|
|
|
640
|
+
// The whole fleet registers as terminal-less sessions up front, so the
|
|
641
|
+
// list shows every incoming session immediately instead of revealing
|
|
642
|
+
// them one boot at a time.
|
|
643
|
+
const queued = entries.map((entry) => mgr.restore(entry));
|
|
644
|
+
|
|
645
|
+
const adoptQueued = (s: Session): boolean => {
|
|
646
|
+
if (mgr.adoptTerminal(s.id, cols, rows) === null) {
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
|
|
586
650
|
ptyDims.set(s.id, { cols, rows });
|
|
587
|
-
screens.set(s.id, new ScreenModel(cols, rows));
|
|
588
651
|
|
|
589
|
-
|
|
652
|
+
if (!screens.has(s.id)) {
|
|
653
|
+
screens.set(s.id, new ScreenModel(cols, rows));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return true;
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const [first, ...rest] = queued;
|
|
660
|
+
|
|
661
|
+
if (first === undefined) {
|
|
662
|
+
return 0;
|
|
590
663
|
}
|
|
591
664
|
|
|
592
|
-
|
|
665
|
+
// The first terminal attaches synchronously so a caller can attach at
|
|
666
|
+
// once; each later one waits for the previous session to report it has
|
|
667
|
+
// booted, so a heavy fleet comes up one process at a time instead of
|
|
668
|
+
// all at once. A per-session cap keeps a session that never reports
|
|
669
|
+
// from stalling the rest.
|
|
670
|
+
const cap = opts.restoreBootTimeoutMs ?? 0;
|
|
671
|
+
|
|
672
|
+
const adoptRest = async (previous: Session | null) => {
|
|
673
|
+
let prev = previous;
|
|
674
|
+
|
|
675
|
+
for (const s of rest) {
|
|
676
|
+
if (prev !== null) {
|
|
677
|
+
await waitForBoot(prev.id, cap);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
prev = adoptQueued(s) ? s : null;
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
const firstBooted = adoptQueued(first) ? first : null;
|
|
685
|
+
|
|
686
|
+
void adoptRest(firstBooted);
|
|
687
|
+
|
|
688
|
+
return queued.length;
|
|
593
689
|
},
|
|
594
690
|
};
|
|
595
691
|
|
|
@@ -628,6 +724,10 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
628
724
|
clearTimeout(timer);
|
|
629
725
|
}
|
|
630
726
|
|
|
727
|
+
for (const timer of restoreTimers) {
|
|
728
|
+
clearTimeout(timer);
|
|
729
|
+
}
|
|
730
|
+
|
|
631
731
|
server.stop(true);
|
|
632
732
|
reporter.stop(true);
|
|
633
733
|
mgr.killAll();
|
package/src/sessions.ts
CHANGED
|
@@ -141,6 +141,32 @@ export class SessionManager {
|
|
|
141
141
|
return s;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
// Registers a fleet entry as a session with no terminal yet, so a
|
|
145
|
+
// fleet-wide restore can show every incoming session at once; adopting it
|
|
146
|
+
// later attaches the terminal.
|
|
147
|
+
restore(entry: FleetEntry): Session {
|
|
148
|
+
const session: Session = {
|
|
149
|
+
id: `s${++counter}-${Date.now().toString(36)}`,
|
|
150
|
+
name: entry.name,
|
|
151
|
+
cwd: entry.cwd,
|
|
152
|
+
kind: 'jsonl',
|
|
153
|
+
pty: null,
|
|
154
|
+
state: 'running',
|
|
155
|
+
unread: false,
|
|
156
|
+
lastMsg: 'waiting to restore',
|
|
157
|
+
claudeId: entry.claudeId,
|
|
158
|
+
...(entry.group === undefined ? {} : { group: entry.group }),
|
|
159
|
+
namedBy: 'auto',
|
|
160
|
+
createdAt: Date.now(),
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
this.sessions.push(session);
|
|
164
|
+
this.writeStatus();
|
|
165
|
+
this.onEvent('added', session);
|
|
166
|
+
|
|
167
|
+
return session;
|
|
168
|
+
}
|
|
169
|
+
|
|
144
170
|
// Adopts a headless session back into a terminal: a fresh PTY resumes the
|
|
145
171
|
// same agent session id.
|
|
146
172
|
adoptTerminal(id: string, cols: number, rows: number): Session | null {
|
package/src/state-store.ts
CHANGED
|
@@ -77,6 +77,18 @@ export class StateStore {
|
|
|
77
77
|
return entries;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// The latest hook-event timestamp per agent session id, from the event
|
|
81
|
+
// trail. Sessions that never reported an event are absent.
|
|
82
|
+
collectFleetRecency(): Map<string, string> {
|
|
83
|
+
const rows = this.db
|
|
84
|
+
.query<{ session_id: string; ts: string }, []>(
|
|
85
|
+
'SELECT session_id, MAX(ts) AS ts FROM events WHERE session_id IS NOT NULL GROUP BY session_id',
|
|
86
|
+
)
|
|
87
|
+
.all();
|
|
88
|
+
|
|
89
|
+
return new Map(rows.map((row) => [row.session_id, row.ts]));
|
|
90
|
+
}
|
|
91
|
+
|
|
80
92
|
writeFleet(entries: readonly FleetEntry[]): void {
|
|
81
93
|
const replace = this.db.transaction((all: readonly FleetEntry[]) => {
|
|
82
94
|
this.db.run('DELETE FROM fleet');
|