@zgeoff/atc 3.6.1 → 3.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "3.6.1",
3
+ "version": "3.6.2",
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",
@@ -12,6 +12,14 @@ interface StubExecutionProviderConfig {
12
12
  // Capabilities that differ from a local pseudo-terminal's.
13
13
  readonly capabilities?: Partial<ExecutionCapabilities>;
14
14
 
15
+ // Whether the daemon treats the host as another machine; false when left
16
+ // out.
17
+ readonly remote?: boolean;
18
+
19
+ // The error every command rejects with instead of running; left out,
20
+ // commands run on this machine.
21
+ readonly commandFailure?: Readonly<Error>;
22
+
15
23
  /**
16
24
  * Called with each harness spec before the harness starts; a throw from
17
25
  * it aborts the spawn.
@@ -42,9 +50,10 @@ interface StubExecutionProvider extends ExecutionProvider {
42
50
  /**
43
51
  * An execution provider for daemon tests that runs harnesses, transfers, and
44
52
  * commands on this machine as the local pseudo-terminal provider does, with
45
- * the capabilities the config changes on top of its own. It reports each
46
- * harness spec to the config before starting it. A host suspend or
47
- * destroy does nothing to the machine: it records the host in `suspended` or
53
+ * the capabilities the config changes on top of its own. The config can
54
+ * mark the host remote, and can make every command reject with an error
55
+ * instead of running. It reports each harness spec to the config before
56
+ * starting it. A host suspend or destroy does nothing to the machine: it records the host in `suspended` or
48
57
  * `destroyed` and resolves, or rejects with the error a failure setter gave.
49
58
  * The daemon calls them only when the capabilities declare `suspend` or
50
59
  * `destroy`.
@@ -55,6 +64,7 @@ export function buildStubExecutionProvider(
55
64
  const local = new LocalPTYProvider();
56
65
 
57
66
  const onSpawn = config.onSpawn ?? (() => {});
67
+ const commandFailure = config.commandFailure;
58
68
  const suspended: string[] = [];
59
69
  const destroyed: string[] = [];
60
70
 
@@ -67,7 +77,7 @@ export function buildStubExecutionProvider(
67
77
 
68
78
  return {
69
79
  kind: config.kind ?? 'stub',
70
- remote: false,
80
+ remote: config.remote ?? false,
71
81
  capabilities: { ...local.capabilities, ...config.capabilities },
72
82
  prepareHost: local.prepareHost,
73
83
  spawnHarness: (spec) => {
@@ -76,7 +86,8 @@ export function buildStubExecutionProvider(
76
86
  return local.spawnHarness(spec);
77
87
  },
78
88
  transferArchive: local.transferArchive,
79
- runCommand: local.runCommand,
89
+ runCommand:
90
+ commandFailure === undefined ? local.runCommand : () => Promise.reject(commandFailure),
80
91
  suspendHost: (host) => {
81
92
  const failure = failures.suspend;
82
93
 
@@ -1,6 +1,7 @@
1
1
  import { randomBytes, randomUUID } from 'node:crypto';
2
2
  import { rmSync } from 'node:fs';
3
3
  import { posix } from 'node:path';
4
+ import type { Subprocess } from 'bun';
4
5
  import { spawn } from 'bun-pty';
5
6
  import type { IPty } from 'bun-pty';
6
7
  import type {
@@ -52,16 +53,14 @@ import { registerTestCleanup } from './register-test-cleanup';
52
53
  * broker variable, and when it would join a process that started without
53
54
  * the broker required. Once the current test finishes, the stand-in kills
54
55
  * every process and stops every forward it holds, so it must be created inside
55
- * a test; `stop` does so sooner, and a second stop does nothing.
56
+ * a test; `stop` does so sooner, and a second stop does nothing. Leases
57
+ * expire, and cold boots are stamped, by `now`, the wall clock when absent.
56
58
  */
57
- export function createStubImpPort(principal = 'token:atc'): StubImpPort {
58
- return new StubImpPort(principal);
59
- }
60
-
61
- interface StubLease {
62
- readonly principal: string;
63
- readonly label: string;
64
- readonly until: number;
59
+ export function createStubImpPort(
60
+ principal = 'token:atc',
61
+ now: () => number = Date.now,
62
+ ): StubImpPort {
63
+ return new StubImpPort(principal, now);
65
64
  }
66
65
 
67
66
  interface StubConnection {
@@ -88,6 +87,12 @@ interface StubProcess {
88
87
  readonly requireBroker: boolean;
89
88
  }
90
89
 
90
+ interface StubLease {
91
+ readonly principal: string;
92
+ readonly label: string;
93
+ readonly until: number;
94
+ }
95
+
91
96
  interface StubImp {
92
97
  readonly id: string;
93
98
  readonly name: string;
@@ -125,6 +130,9 @@ interface StubCommandHold {
125
130
  readonly stop: () => void;
126
131
  }
127
132
 
133
+ // The exit code of a guest command killed by SIGKILL.
134
+ const KILLED_CODE = 137;
135
+
128
136
  // impd keeps exactly this many bytes of each generation's output.
129
137
  const RING_BYTES = 262_144;
130
138
 
@@ -243,6 +251,12 @@ class StubImpPort implements ImpPort {
243
251
  // How many commands wait on a command hold.
244
252
  private heldCommands = 0;
245
253
 
254
+ // The guest commands running now, which a stop kills.
255
+ private readonly commands = new Set<Subprocess>();
256
+
257
+ // Set once the stand-in stops, so a command a hold released never runs.
258
+ private stopped = false;
259
+
246
260
  // Commands whose argv holds this text exit 1 without running, or null.
247
261
  private commandFailure: string | null = null;
248
262
 
@@ -268,12 +282,16 @@ class StubImpPort implements ImpPort {
268
282
 
269
283
  private readonly principal: string;
270
284
 
285
+ // The time in epoch milliseconds that leases expire against.
286
+ private readonly now: () => number;
287
+
271
288
  private readonly imps = new Map<string, StubImp>();
272
289
 
273
290
  private readonly forwards = new Set<{ stop: () => void; stopRelays: () => void }>();
274
291
 
275
- constructor(principal = 'token:atc') {
292
+ constructor(principal: string, now: () => number) {
276
293
  this.principal = principal;
294
+ this.now = now;
277
295
  }
278
296
 
279
297
  readFeatures(): Promise<ImpFeatures> {
@@ -456,7 +474,7 @@ class StubImpPort implements ImpPort {
456
474
 
457
475
  this.updateAwake(imp);
458
476
 
459
- const until = Date.now() + ttlSeconds * 1000;
477
+ const until = this.now() + ttlSeconds * 1000;
460
478
 
461
479
  imp.leases.set(`${this.principal}\u0000${label}`, { principal: this.principal, label, until });
462
480
 
@@ -470,11 +488,11 @@ class StubImpPort implements ImpPort {
470
488
  const key = `${this.principal}\u0000${label}`;
471
489
  const lease = imp?.leases.get(key);
472
490
 
473
- if (imp === undefined || lease === undefined || lease.until <= Date.now()) {
491
+ if (imp === undefined || lease === undefined || lease.until <= this.now()) {
474
492
  return Promise.reject(new ImpPortError('LEASE_NOT_HELD', 'the caller holds no such lease'));
475
493
  }
476
494
 
477
- const until = Date.now() + ttlSeconds * 1000;
495
+ const until = this.now() + ttlSeconds * 1000;
478
496
 
479
497
  imp.leases.set(key, { ...lease, until });
480
498
 
@@ -518,7 +536,7 @@ class StubImpPort implements ImpPort {
518
536
  return Promise.reject(buildNotFound(name));
519
537
  }
520
538
 
521
- const live = [...imp.leases.values()].filter((lease) => lease.until > Date.now());
539
+ const live = [...imp.leases.values()].filter((lease) => lease.until > this.now());
522
540
 
523
541
  if (imp.state === 'running' && live.length > 0) {
524
542
  const own = live.filter((lease) => lease.principal === this.principal);
@@ -690,6 +708,12 @@ class StubImpPort implements ImpPort {
690
708
  this.heldCommands -= 1;
691
709
  }
692
710
 
711
+ // A command that gets past the hold after a stop, held or not, ends as a
712
+ // killed one does, so no process outlives the stop.
713
+ if (this.stopped) {
714
+ return { code: KILLED_CODE, stdout: new Uint8Array(0), stderr: new Uint8Array(0) };
715
+ }
716
+
693
717
  if (this.commandFailure !== null && line.includes(this.commandFailure)) {
694
718
  const encoder = new TextEncoder();
695
719
 
@@ -706,12 +730,16 @@ class StubImpPort implements ImpPort {
706
730
  stderr: 'pipe',
707
731
  });
708
732
 
733
+ this.commands.add(proc);
734
+
709
735
  const [stdout, stderr, code] = await Promise.all([
710
736
  new Response(proc.stdout).bytes(),
711
737
  new Response(proc.stderr).bytes(),
712
738
  proc.exited,
713
739
  ]);
714
740
 
741
+ this.commands.delete(proc);
742
+
715
743
  return { code, stdout, stderr };
716
744
  }
717
745
 
@@ -902,7 +930,7 @@ class StubImpPort implements ImpPort {
902
930
  imp.leases.set(`${principal}\u0000${label}`, {
903
931
  principal,
904
932
  label,
905
- until: Date.now() + ttlSeconds * 1000,
933
+ until: this.now() + ttlSeconds * 1000,
906
934
  });
907
935
  }
908
936
 
@@ -1252,12 +1280,22 @@ class StubImpPort implements ImpPort {
1252
1280
  return proc.end;
1253
1281
  }
1254
1282
 
1255
- // Kills every process and stops every forward the stand-in holds. It runs
1283
+ // Kills every process and stops every forward the stand-in holds, and
1284
+ // resolves once every guest command it killed exits. A command a hold
1285
+ // still holds then exits as a killed one does, without running. It runs
1256
1286
  // once the current test finishes; calling it sooner runs it then, and a
1257
1287
  // second call does nothing.
1258
- readonly stop: () => void = registerTestCleanup(() => {
1288
+ readonly stop: () => Promise<void> = registerTestCleanup(async () => {
1289
+ this.stopped = true;
1290
+
1259
1291
  this.stopCommandHold();
1260
1292
 
1293
+ const commands = [...this.commands];
1294
+
1295
+ for (const command of commands) {
1296
+ command.kill('SIGKILL');
1297
+ }
1298
+
1261
1299
  for (const imp of this.imps.values()) {
1262
1300
  for (const proc of imp.sessions.values()) {
1263
1301
  tryKill(proc.pty, 'SIGCONT');
@@ -1270,6 +1308,8 @@ class StubImpPort implements ImpPort {
1270
1308
  }
1271
1309
 
1272
1310
  this.imps.clear();
1311
+
1312
+ await Promise.all(commands.map((command) => command.exited));
1273
1313
  });
1274
1314
 
1275
1315
  // impd's refusal of a grant or revoke: the scope and pattern checks run
@@ -1325,7 +1365,7 @@ class StubImpPort implements ImpPort {
1325
1365
  }
1326
1366
 
1327
1367
  private buildView(imp: StubImp): ImpView {
1328
- const live = [...imp.leases.values()].filter((lease) => lease.until > Date.now());
1368
+ const live = [...imp.leases.values()].filter((lease) => lease.until > this.now());
1329
1369
  const own = live.filter((lease) => lease.principal === this.principal);
1330
1370
  const others = live.filter((lease) => lease.principal !== this.principal);
1331
1371
 
@@ -1381,7 +1421,7 @@ class StubImpPort implements ImpPort {
1381
1421
  imp.bootId = randomUUID();
1382
1422
 
1383
1423
  imp.coldBoots = [
1384
- { bootId: imp.bootId, cause, at: new Date().toISOString() },
1424
+ { bootId: imp.bootId, cause, at: new Date(this.now()).toISOString() },
1385
1425
  ...imp.coldBoots,
1386
1426
  ].slice(0, 4);
1387
1427
  }
@@ -1725,6 +1765,25 @@ function buildLease(name: string, principal: string, label: string, until: numbe
1725
1765
  return { name, owner: { principal, display: principal, label }, until };
1726
1766
  }
1727
1767
 
1768
+ function tryKill(pty: IPty, signal: NodeJS.Signals): void {
1769
+ try {
1770
+ process.kill(pty.pid, signal);
1771
+ } catch {}
1772
+ }
1773
+
1774
+ // A gate that throws keeps the request from going out, as a closed one does.
1775
+ function tryPassGate(gate: () => boolean): boolean {
1776
+ try {
1777
+ return gate();
1778
+ } catch {
1779
+ return false;
1780
+ }
1781
+ }
1782
+
1783
+ function toSignal(name: string): NodeJS.Signals {
1784
+ return name === 'SIGKILL' || name === 'SIGTERM' || name === 'SIGINT' ? name : 'SIGHUP';
1785
+ }
1786
+
1728
1787
  // oxlint-disable-next-line prefer-readonly-parameter-types -- byte arrays have no readonly form
1729
1788
  function mergeTail(ring: Uint8Array, data: Uint8Array, limit: number): Uint8Array {
1730
1789
  const joined = new Uint8Array(ring.length + data.length);
@@ -1761,22 +1820,3 @@ function findSessionName(imp: StubImp, proc: StubProcess): string | undefined {
1761
1820
 
1762
1821
  return undefined;
1763
1822
  }
1764
-
1765
- function toSignal(name: string): NodeJS.Signals {
1766
- return name === 'SIGKILL' || name === 'SIGTERM' || name === 'SIGINT' ? name : 'SIGHUP';
1767
- }
1768
-
1769
- function tryKill(pty: IPty, signal: NodeJS.Signals): void {
1770
- try {
1771
- process.kill(pty.pid, signal);
1772
- } catch {}
1773
- }
1774
-
1775
- // A gate that throws keeps the request from going out, as a closed one does.
1776
- function tryPassGate(gate: () => boolean): boolean {
1777
- try {
1778
- return gate();
1779
- } catch {
1780
- return false;
1781
- }
1782
- }
@@ -1,4 +1,4 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import type { Subprocess } from 'bun';
4
4
  import { DaemonClient } from '../client/daemon-client';
@@ -39,17 +39,30 @@ interface DaemonBoot {
39
39
  * sending no handshake, and fails at once with the daemon's stderr when the
40
40
  * daemon exits before it listens. `restart` stops the daemon with the given
41
41
  * signal, waits for it to exit, and starts another on the same home with the
42
- * same config. `readStderr` reads what the current boot printed. `stop`
43
- * closes every client it opened, kills the daemon and waits for it to exit,
44
- * then kills the daemon the home's state directory records, which a restart
45
- * the test asked atc for may have started. That stop runs once the current
46
- * test finishes, so it must run inside a test; calling `stop` sooner runs it
47
- * then, and a second stop does nothing.
42
+ * same config, and throws once the daemon is stopped. `readStderr` reads
43
+ * what the current boot printed, from a file in `stderrDir`, a directory
44
+ * under the home that no other daemon on the home writes. `stop` closes
45
+ * every client it opened, kills the daemon and waits for it to exit, then
46
+ * kills the daemon the home's state directory records, which a restart the
47
+ * test asked atc for may have started. That stop runs once the current test
48
+ * finishes, so it must run inside a test; calling `stop` sooner runs it
49
+ * then, and a second stop does nothing. `stderrDir` is removed once the
50
+ * current test finishes, after the stop, so a test can still read the
51
+ * stderr after an early stop.
48
52
  */
49
53
  export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
50
54
  const socketPath = join(config.home, 'atc-daemon.sock');
51
55
  const stateDir = join(config.home, '.local', 'state', 'atc');
52
56
 
57
+ // A directory of this helper's own under the home, so two daemons started
58
+ // on one home never write into one stderr file.
59
+ const stderrDir = mkdtempSync(join(config.home, 'daemon-stderr-'));
60
+
61
+ // Registered before the stop, so it runs after the daemon is gone.
62
+ registerTestCleanup(() => {
63
+ rmSync(stderrDir, { recursive: true, force: true });
64
+ });
65
+
53
66
  const clients = new Set<DaemonClient>();
54
67
 
55
68
  let boots = 0;
@@ -58,7 +71,7 @@ export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
58
71
  const boot = (): DaemonBoot => {
59
72
  boots++;
60
73
 
61
- const stderrPath = join(config.home, `daemon-${boots}.stderr`);
74
+ const stderrPath = join(stderrDir, `boot-${boots}.stderr`);
62
75
 
63
76
  const proc = Bun.spawn([...config.command, 'daemon', ...(config.args ?? [])], {
64
77
  env: Object.fromEntries(
@@ -126,6 +139,11 @@ export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
126
139
  const watched = current.proc;
127
140
  let settled = false;
128
141
  const isSettled = () => settled;
142
+ const isWatchedExited = () => watched.exitCode !== null || watched.signalCode !== null;
143
+
144
+ // The poll's latest connect, so the wait after an exit can reuse one
145
+ // that was still in flight instead of dialing a second time.
146
+ let polled: Promise<DaemonClient> | null = null;
129
147
 
130
148
  // A daemon that has exited either refused to start or handed its socket
131
149
  // to a replacement, so one more connect tells the two apart.
@@ -136,6 +154,12 @@ export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
136
154
  return null;
137
155
  }
138
156
 
157
+ const inFlight = await polled?.catch(() => null);
158
+
159
+ if (inFlight !== undefined && inFlight !== null) {
160
+ return inFlight;
161
+ }
162
+
139
163
  try {
140
164
  return await openTrackedClient(isSettled);
141
165
  } catch {
@@ -145,16 +169,29 @@ export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
145
169
  }
146
170
  };
147
171
 
148
- // Polls the socket while the daemon runs. Once the other wait settles or
149
- // the daemon stops, each attempt resolves empty, which ends the
150
- // polling.
172
+ // Polls the socket while the daemon runs. Once the daemon exits, it dials
173
+ // no more and leaves the connect to the wait after the exit. Once the
174
+ // other wait settles or the daemon stops, each attempt resolves empty,
175
+ // which ends the polling.
151
176
  const openWhenListening = () =>
152
- waitFor(() => (settled || stopped ? null : openTrackedClient(isSettled)), {
153
- timeoutMs: 15_000,
154
- intervalMs: 50,
155
- });
177
+ waitFor(
178
+ () => {
179
+ if (settled || stopped) {
180
+ return null;
181
+ }
182
+
183
+ if (isWatchedExited()) {
184
+ throw new Error('the daemon exited');
185
+ }
186
+
187
+ polled = openTrackedClient(isSettled);
156
188
 
157
- const hasExited = watched.exitCode !== null || watched.signalCode !== null;
189
+ return polled;
190
+ },
191
+ { timeoutMs: 15_000, intervalMs: 50 },
192
+ );
193
+
194
+ const hasExited = isWatchedExited();
158
195
 
159
196
  try {
160
197
  const client = await (hasExited
@@ -176,12 +213,17 @@ export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
176
213
  socketPath,
177
214
  reporterSocketPath: join(config.home, 'atc.sock'),
178
215
  stateDir,
216
+ stderrDir,
179
217
  get proc(): Subprocess {
180
218
  return current.proc;
181
219
  },
182
220
  readStderr,
183
221
  openClient,
184
222
  async restart(signal: NodeJS.Signals): Promise<void> {
223
+ if (stopped) {
224
+ throw new Error('a stopped daemon process cannot restart');
225
+ }
226
+
185
227
  current.proc.kill(signal);
186
228
 
187
229
  await current.proc.exited;