@zgeoff/atc 3.4.0 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/package.json +2 -2
  2. package/src/agents/build-agent-adapters.ts +21 -3
  3. package/src/agents/codex-adapter.ts +20 -0
  4. package/src/agents/render-sdk-message.ts +57 -0
  5. package/src/agents/start-claude-headless-run.ts +2 -57
  6. package/src/agents/with-claude-config-lock.ts +28 -15
  7. package/src/client/boot-daemon.ts +75 -25
  8. package/src/client/resolve-workspace-root.ts +10 -5
  9. package/src/daemon/session-runtime.ts +0 -4
  10. package/src/gateway.ts +2 -179
  11. package/src/mcp-http-server.ts +13 -10
  12. package/src/run-gateway-cli.ts +327 -0
  13. package/src/run-gateway.ts +49 -18
  14. package/src/sources/github/run-gh.ts +9 -0
  15. package/src/test-utils/build-mock-execution-target.ts +25 -0
  16. package/src/test-utils/build-mock-imp-session-request.ts +53 -0
  17. package/src/test-utils/build-mock-message-record.ts +7 -3
  18. package/src/test-utils/build-mock-stored-event.ts +20 -0
  19. package/src/test-utils/build-mock-stored-row.ts +23 -0
  20. package/src/test-utils/build-stub-brokered-gateway-adapter.ts +50 -0
  21. package/src/test-utils/build-stub-mcp-stdio-server.ts +20 -0
  22. package/src/test-utils/build-stub-recording-filter.ts +9 -0
  23. package/src/test-utils/build-stub-recording-git.ts +8 -0
  24. package/src/test-utils/create-git-fixture.ts +5 -8
  25. package/src/test-utils/create-stub-echo-claude.ts +21 -0
  26. package/src/test-utils/{build-stub-imp-port.ts → create-stub-imp-port.ts} +30 -5
  27. package/src/test-utils/create-stub-recording-codex.ts +20 -0
  28. package/src/test-utils/create-stub-shared-server-codex.ts +56 -0
  29. package/src/test-utils/create-stub-systemd.ts +10 -4
  30. package/src/test-utils/get-only-imp-name.ts +2 -2
  31. package/src/test-utils/register-test-cleanup.ts +63 -0
  32. package/src/test-utils/run-legacy-daemon.ts +1 -1
  33. package/src/test-utils/run-mcp-authorization.ts +7 -4
  34. package/src/test-utils/setup-mcp-home.ts +24 -13
  35. package/src/test-utils/setup-mcp-http.ts +15 -7
  36. package/src/test-utils/setup-temp-dir.ts +7 -6
  37. package/src/test-utils/start-cut-proxy.ts +6 -2
  38. package/src/test-utils/start-daemon-process.ts +25 -24
  39. package/src/test-utils/start-git-http-server.ts +9 -3
  40. package/src/test-utils/start-mcp-stdio.ts +17 -22
  41. package/src/test-utils/start-stub-answering-listener.ts +65 -0
  42. package/src/test-utils/start-stub-closing-listener.ts +31 -0
  43. package/src/test-utils/start-stub-dropping-daemon.ts +10 -8
  44. package/src/test-utils/start-stub-impd-info.ts +10 -4
  45. package/src/test-utils/start-stub-impd.ts +8 -3
  46. package/src/test-utils/start-stub-legacy-daemon.ts +13 -8
  47. package/src/test-utils/start-stub-mcp-server.ts +7 -2
  48. package/src/test-utils/start-stub-recording-listener.ts +47 -0
  49. package/src/test-utils/start-stub-refusing-listener.ts +9 -4
  50. package/src/test-utils/start-stub-reporter-socket.ts +6 -4
  51. package/src/test-utils/start-stub-restore-daemon.ts +5 -4
  52. package/src/test-utils/start-stub-session-bridge.ts +6 -4
  53. package/src/test-utils/start-stub-silent-server.ts +10 -5
  54. package/src/test-utils/start-stub-source-daemon.ts +5 -3
  55. package/src/test-utils/start-stub-stalled-client.ts +61 -0
  56. package/src/test-utils/start-stub-stalled-listener.ts +13 -9
  57. package/src/test-utils/start-stub-tcp-daemon.ts +9 -4
  58. package/src/test-utils/start-stub-telemetry-collector.ts +9 -3
  59. package/src/test-utils/start-stub-unanswering-listener.ts +41 -0
  60. package/src/test-utils/start-stub-unanswering-unix-listener.ts +38 -0
  61. package/src/test-utils/start-test-daemon.ts +20 -6
  62. package/src/test-utils/start-tui-harness.ts +15 -8
  63. package/src/test-utils/subscribe-to-socket-lines.ts +25 -5
@@ -0,0 +1,47 @@
1
+ import type { Socket } from 'bun';
2
+ import { registerTestCleanup } from './register-test-cleanup';
3
+
4
+ interface StubRecordingListener {
5
+ // Resolves with the server's side of the first connection it accepts.
6
+ readonly accepted: Promise<Socket>;
7
+
8
+ // What each read took from any connection, decoded, in arrival order.
9
+ readonly received: readonly string[];
10
+ readonly [Symbol.dispose]: () => void;
11
+ }
12
+
13
+ /**
14
+ * A stand-in for a peer that a test writes through: it listens on the unix
15
+ * socket path, hands the test the server's side of the first connection it
16
+ * accepts, records what each read takes from any connection, and sends
17
+ * nothing of its own. It stops, dropping every connection it holds, once
18
+ * the current test finishes, so it must run inside a test; disposal stops it
19
+ * sooner, and a second stop does nothing.
20
+ */
21
+ export function startStubRecordingListener(path: string): StubRecordingListener {
22
+ const accepted = Promise.withResolvers<Socket>();
23
+ const received: string[] = [];
24
+
25
+ const server = Bun.listen({
26
+ unix: path,
27
+ socket: {
28
+ open(socket) {
29
+ accepted.resolve(socket);
30
+ },
31
+ data(_socket, data) {
32
+ received.push(data.toString());
33
+ },
34
+ error() {},
35
+ },
36
+ });
37
+
38
+ const stop = registerTestCleanup(() => {
39
+ server.stop(true);
40
+ });
41
+
42
+ return {
43
+ accepted: accepted.promise,
44
+ received,
45
+ [Symbol.dispose]: stop,
46
+ };
47
+ }
@@ -1,3 +1,5 @@
1
+ import { registerTestCleanup } from './register-test-cleanup';
2
+
1
3
  interface StubRefusingListener {
2
4
  readonly port: number;
3
5
 
@@ -11,7 +13,8 @@ interface StubRefusingListener {
11
13
  * A stand-in for a daemon's TCP listener refusing a connection whose first
12
14
  * line is not a handshake: on a loopback port, it records what each read
13
15
  * takes from a connection and ends that connection, answering nothing.
14
- * Disposal stops it; hold the result with `using`.
16
+ * It stops once the current test finishes, so it must run inside a test;
17
+ * disposal stops it sooner, and a second stop does nothing.
15
18
  */
16
19
  export function startStubRefusingListener(): StubRefusingListener {
17
20
  const received: string[] = [];
@@ -28,11 +31,13 @@ export function startStubRefusingListener(): StubRefusingListener {
28
31
  },
29
32
  });
30
33
 
34
+ const stop = registerTestCleanup(() => {
35
+ server.stop(true);
36
+ });
37
+
31
38
  return {
32
39
  port: server.port,
33
40
  received,
34
- [Symbol.dispose]: () => {
35
- server.stop(true);
36
- },
41
+ [Symbol.dispose]: stop,
37
42
  };
38
43
  }
@@ -1,3 +1,4 @@
1
+ import { registerTestCleanup } from './register-test-cleanup';
1
2
  import { waitFor } from './wait-for';
2
3
 
3
4
  interface StubReporterSocket {
@@ -19,8 +20,9 @@ interface StubReporterSocket {
19
20
  * hook and report commands send. It never writes back, and leaves each
20
21
  * connection for the sender to close. `waitForLine` resolves with the first
21
22
  * line once one has arrived, and rejects naming the socket when none arrives
22
- * within `timeoutMs`, 5 seconds by default. Disposal stops the listener; hold the
23
- * result with `using` or `await using`.
23
+ * within `timeoutMs`, 5 seconds by default. The listener stops once the
24
+ * current test finishes, so it must run inside a test; disposal stops it
25
+ * sooner, and a second stop does nothing.
24
26
  */
25
27
  export function startStubReporterSocket(path: string): StubReporterSocket {
26
28
  const lines: string[] = [];
@@ -45,9 +47,9 @@ export function startStubReporterSocket(path: string): StubReporterSocket {
45
47
  },
46
48
  });
47
49
 
48
- const stop = () => {
50
+ const stop = registerTestCleanup(() => {
49
51
  server.stop(true);
50
- };
52
+ });
51
53
 
52
54
  return {
53
55
  lines,
@@ -1,5 +1,6 @@
1
1
  import { LineDecoder } from '../protocol/line-decoder';
2
2
  import { PROTOCOL_V, decodeMessage, encodeMessage } from '../protocol/protocol';
3
+ import { registerTestCleanup } from './register-test-cleanup';
3
4
 
4
5
  /**
5
6
  * A daemon stand-in on a unix socket path that speaks the real wire format
@@ -7,8 +8,8 @@ import { PROTOCOL_V, decodeMessage, encodeMessage } from '../protocol/protocol';
7
8
  * result, answers each other request with the next reply the test pushes
8
9
  * onto `lists`, and withholds the answer to any request that finds `lists`
9
10
  * empty. `methods` holds the method of each request it took, in order.
10
- * Disposal stops the listener; hold the result with `using` or
11
- * `await using`.
11
+ * The listener stops once the current test finishes, so it must run inside
12
+ * a test; disposal stops it sooner, and a second stop does nothing.
12
13
  */
13
14
  export function startStubRestoreDaemon(socketPath: string) {
14
15
  const lists: Readonly<Record<string, unknown>>[] = [];
@@ -41,9 +42,9 @@ export function startStubRestoreDaemon(socketPath: string) {
41
42
  },
42
43
  });
43
44
 
44
- const stop = () => {
45
+ const stop = registerTestCleanup(() => {
45
46
  server.stop(true);
46
- };
47
+ });
47
48
 
48
49
  return {
49
50
  lists,
@@ -1,4 +1,5 @@
1
1
  import { isRecord } from '../shared/report';
2
+ import { registerTestCleanup } from './register-test-cleanup';
2
3
 
3
4
  type BridgeRequest = Readonly<Record<string, unknown>>;
4
5
 
@@ -26,8 +27,9 @@ interface StubSessionBridge {
26
27
  * `tap.open` request with `ok` under the request's id as the real bridge
27
28
  * does once it attaches the tap, and hands every other request to the
28
29
  * responder, writing back each line it returns. A line that is not a JSON
29
- * object reaches the responder as an empty request. Disposal stops the
30
- * listener; hold the result with `using` or `await using`.
30
+ * object reaches the responder as an empty request. The listener stops once
31
+ * the current test finishes, so it must run inside a test; disposal stops it
32
+ * sooner, and a second stop does nothing.
31
33
  */
32
34
  export function startStubSessionBridge(path: string, respond: BridgeResponder): StubSessionBridge {
33
35
  const requests: BridgeRequest[] = [];
@@ -69,9 +71,9 @@ export function startStubSessionBridge(path: string, respond: BridgeResponder):
69
71
  },
70
72
  });
71
73
 
72
- const stop = () => {
74
+ const stop = registerTestCleanup(() => {
73
75
  server.stop(true);
74
- };
76
+ });
75
77
 
76
78
  return {
77
79
  requests,
@@ -1,9 +1,12 @@
1
+ import { registerTestCleanup } from './register-test-cleanup';
2
+
1
3
  /**
2
4
  * An HTTP stand-in for an upstream that hangs: it listens on a real port of
3
5
  * the loopback address, takes every request, and never answers one. `paths`
4
- * holds the request path of each request it took, in order. Disposal stops
5
- * the server and drops the connections it holds; the asynchronous form
6
- * resolves once it has stopped.
6
+ * holds the request path of each request it took, in order. The server
7
+ * stops, dropping the connections it holds, once the current test finishes,
8
+ * so it must run inside a test; disposal stops it sooner, the asynchronous
9
+ * form resolving once it has stopped, and a second stop does nothing.
7
10
  */
8
11
  export function startStubSilentServer() {
9
12
  const paths: string[] = [];
@@ -22,12 +25,14 @@ export function startStubSilentServer() {
22
25
  },
23
26
  });
24
27
 
28
+ const stop = registerTestCleanup(() => server.stop(true));
29
+
25
30
  return {
26
31
  url: `http://127.0.0.1:${String(server.port)}/`,
27
32
  paths,
28
33
  [Symbol.dispose]: () => {
29
- void server.stop(true);
34
+ void stop();
30
35
  },
31
- [Symbol.asyncDispose]: () => server.stop(true),
36
+ [Symbol.asyncDispose]: stop,
32
37
  };
33
38
  }
@@ -1,4 +1,5 @@
1
1
  import { join } from 'node:path';
2
+ import { registerTestCleanup } from './register-test-cleanup';
2
3
 
3
4
  /**
4
5
  * Starts a daemon composed with a fixture source, in a process of its own
@@ -8,7 +9,8 @@ import { join } from 'node:path';
8
9
  * `ATC_TEST_SOURCES` picks its sources, the fixture one unless it holds
9
10
  * `none`; `ATC_TEST_FIXTURE_URL` holds the repository the fixture source
10
11
  * lists, and `ATC_TEST_SOURCE_LOG` a file it appends each listing to.
11
- * Disposal stops the process; hold the result with `await using`.
12
+ * The process stops once the current test finishes, so it must run inside
13
+ * a test; disposal stops it sooner, and a second stop does nothing.
12
14
  */
13
15
  export async function startStubSourceDaemon(env: Readonly<Record<string, string | undefined>>) {
14
16
  const daemon = Bun.spawn([process.execPath, join(import.meta.dir, 'run-source-daemon.ts')], {
@@ -17,11 +19,11 @@ export async function startStubSourceDaemon(env: Readonly<Record<string, string
17
19
  stderr: 'ignore',
18
20
  });
19
21
 
20
- const stop = async () => {
22
+ const stop = registerTestCleanup(async () => {
21
23
  daemon.kill();
22
24
 
23
25
  await daemon.exited;
24
- };
26
+ });
25
27
 
26
28
  const reader = daemon.stdout.getReader();
27
29
 
@@ -0,0 +1,61 @@
1
+ import { createConnection } from 'node:net';
2
+ import { PROTOCOL_V, encodeMessage } from '../protocol/protocol';
3
+ import { registerTestCleanup } from './register-test-cleanup';
4
+
5
+ interface StubStalledClient {
6
+ // Each chunk the client has read off its connection, in order.
7
+ readonly chunks: readonly unknown[];
8
+
9
+ // How many bytes have arrived on the connection and wait unread.
10
+ readonly countUnreadBytes: () => number;
11
+ readonly [Symbol.dispose]: () => void;
12
+ }
13
+
14
+ /**
15
+ * A stand-in for a daemon client that stops reading: it connects to the
16
+ * daemon's unix socket, sends a `daemon.hello` with no auth as `client`,
17
+ * reads the first chunk the daemon sends, and then holds its next read on
18
+ * a promise that never resolves, so whatever the daemon sends it after
19
+ * that backs up. Resolves once that first chunk arrives. The connection is
20
+ * destroyed once the current test finishes, so it must run inside a test;
21
+ * disposal destroys it sooner, and a second stop does nothing.
22
+ */
23
+ export async function startStubStalledClient(
24
+ socketPath: string,
25
+ client: string,
26
+ ): Promise<StubStalledClient> {
27
+ const held = Promise.withResolvers<void>();
28
+ const first = Promise.withResolvers<void>();
29
+ const chunks: unknown[] = [];
30
+ const socket = createConnection(socketPath);
31
+
32
+ const stop = registerTestCleanup(() => {
33
+ socket.destroy();
34
+ });
35
+
36
+ socket.write(
37
+ encodeMessage({
38
+ v: PROTOCOL_V,
39
+ id: 1,
40
+ m: 'daemon.hello',
41
+ p: { client, auth: { scheme: 'none' } },
42
+ }),
43
+ );
44
+
45
+ void (async () => {
46
+ for await (const chunk of socket) {
47
+ chunks.push(chunk);
48
+ first.resolve();
49
+
50
+ await held.promise;
51
+ }
52
+ })();
53
+
54
+ await first.promise;
55
+
56
+ return {
57
+ chunks,
58
+ countUnreadBytes: () => socket.readableLength,
59
+ [Symbol.dispose]: stop,
60
+ };
61
+ }
@@ -1,5 +1,6 @@
1
1
  import { createServer } from 'node:net';
2
2
  import type { Socket } from 'node:net';
3
+ import { registerTestCleanup } from './register-test-cleanup';
3
4
 
4
5
  interface StubStalledListener {
5
6
  readonly [Symbol.dispose]: () => void;
@@ -9,8 +10,9 @@ interface StubStalledListener {
9
10
  * A stand-in for a peer that stops reading: it listens on the unix socket
10
11
  * path and accepts every connection but never reads from it, so what the
11
12
  * kernel does not buffer stays queued on the sender's side. Resolves once
12
- * it listens. Disposal destroys every connection it accepted and stops it;
13
- * hold the result with `using`.
13
+ * it listens. Once the current test finishes, it destroys every connection
14
+ * it accepted and stops, so it must run inside a test; disposal does so
15
+ * sooner, and a second stop does nothing.
14
16
  */
15
17
  export async function startStubStalledListener(path: string): Promise<StubStalledListener> {
16
18
  const peers = new Set<Socket>();
@@ -28,13 +30,15 @@ export async function startStubStalledListener(path: string): Promise<StubStalle
28
30
 
29
31
  await listening.promise;
30
32
 
31
- return {
32
- [Symbol.dispose]: () => {
33
- for (const peer of peers) {
34
- peer.destroy();
35
- }
33
+ const stop = registerTestCleanup(() => {
34
+ for (const peer of peers) {
35
+ peer.destroy();
36
+ }
37
+
38
+ server.close();
39
+ });
36
40
 
37
- server.close();
38
- },
41
+ return {
42
+ [Symbol.dispose]: stop,
39
43
  };
40
44
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { PROTOCOL_V } from '../protocol/protocol';
3
+ import { registerTestCleanup } from './register-test-cleanup';
3
4
 
4
5
  interface StubTCPDaemon {
5
6
  readonly port: number;
@@ -23,7 +24,9 @@ const REQUEST = z.object({ id: z.number(), m: z.string() });
23
24
  * newline-delimited protocol requests and answers each with an ok that holds
24
25
  * the request's method, recording every method in `seen`. It sends no
25
26
  * handshake of its own and checks no token. `reads` counts the reads it has
26
- * taken from every connection. Disposal stops it.
27
+ * taken from every connection. It stops once the current test finishes, so
28
+ * it must run inside a test; disposal stops it sooner, and a second stop
29
+ * does nothing.
27
30
  */
28
31
  export function startStubTCPDaemon(): StubTCPDaemon {
29
32
  const seen: string[] = [];
@@ -57,14 +60,16 @@ export function startStubTCPDaemon(): StubTCPDaemon {
57
60
  },
58
61
  });
59
62
 
63
+ const stop = registerTestCleanup(() => {
64
+ server.stop(true);
65
+ });
66
+
60
67
  return {
61
68
  port: server.port,
62
69
  seen,
63
70
  get reads() {
64
71
  return reads;
65
72
  },
66
- [Symbol.dispose]: () => {
67
- server.stop(true);
68
- },
73
+ [Symbol.dispose]: stop,
69
74
  };
70
75
  }
@@ -1,9 +1,13 @@
1
+ import { registerTestCleanup } from './register-test-cleanup';
2
+
1
3
  /**
2
4
  * A stand-in for better-auth's telemetry endpoint on a real HTTP port of the
3
5
  * loopback address. A process whose `BETTER_AUTH_TELEMETRY_ENDPOINT` holds
4
6
  * `url` sends its telemetry here. Every request is answered with HTTP 204,
5
- * and `received` holds the URL of each request, in order. Disposal stops the
6
- * server and resolves once it has stopped.
7
+ * and `received` holds the URL of each request, in order. The server stops
8
+ * once the current test finishes, so it must run inside a test; disposal
9
+ * stops it sooner and resolves once it has stopped, and a second stop does
10
+ * nothing.
7
11
  */
8
12
  export function startStubTelemetryCollector() {
9
13
  const received: string[] = [];
@@ -18,9 +22,11 @@ export function startStubTelemetryCollector() {
18
22
  },
19
23
  });
20
24
 
25
+ const stop = registerTestCleanup(() => server.stop(true));
26
+
21
27
  return {
22
28
  url: `http://127.0.0.1:${String(server.port)}/`,
23
29
  received,
24
- [Symbol.asyncDispose]: () => server.stop(true),
30
+ [Symbol.asyncDispose]: stop,
25
31
  };
26
32
  }
@@ -0,0 +1,41 @@
1
+ import { registerTestCleanup } from './register-test-cleanup';
2
+
3
+ interface StubUnansweringListener {
4
+ readonly port: number;
5
+
6
+ // What each read took from any connection, decoded, in arrival order.
7
+ readonly received: readonly string[];
8
+
9
+ readonly [Symbol.dispose]: () => void;
10
+ }
11
+
12
+ /**
13
+ * A stand-in for a daemon that accepts a connection and never answers it:
14
+ * on a loopback port, it records what each read takes from a connection,
15
+ * sends nothing back, and keeps the connection open. It stops, dropping
16
+ * every connection it holds, once the current test finishes, so it must run
17
+ * inside a test; disposal stops it sooner, and a second stop does nothing.
18
+ */
19
+ export function startStubUnansweringListener(): StubUnansweringListener {
20
+ const received: string[] = [];
21
+
22
+ const server = Bun.listen({
23
+ hostname: '127.0.0.1',
24
+ port: 0,
25
+ socket: {
26
+ data(_socket, data) {
27
+ received.push(data.toString());
28
+ },
29
+ },
30
+ });
31
+
32
+ const stop = registerTestCleanup(() => {
33
+ server.stop(true);
34
+ });
35
+
36
+ return {
37
+ port: server.port,
38
+ received,
39
+ [Symbol.dispose]: stop,
40
+ };
41
+ }
@@ -0,0 +1,38 @@
1
+ import { registerTestCleanup } from './register-test-cleanup';
2
+
3
+ interface StubUnansweringUnixListener {
4
+ // What each read took from any connection, decoded, in arrival order.
5
+ readonly received: readonly string[];
6
+
7
+ readonly [Symbol.dispose]: () => void;
8
+ }
9
+
10
+ /**
11
+ * A stand-in for a daemon socket that takes a connection and never answers
12
+ * it: it listens on the unix socket path, records what each read takes from
13
+ * a connection, sends nothing back, and keeps the connection open. It
14
+ * stops, dropping every connection it holds, once the current test
15
+ * finishes, so it must run inside a test; disposal stops it sooner, and a
16
+ * second stop does nothing.
17
+ */
18
+ export function startStubUnansweringUnixListener(path: string): StubUnansweringUnixListener {
19
+ const received: string[] = [];
20
+
21
+ const server = Bun.listen({
22
+ unix: path,
23
+ socket: {
24
+ data(_socket, data) {
25
+ received.push(data.toString());
26
+ },
27
+ },
28
+ });
29
+
30
+ const stop = registerTestCleanup(() => {
31
+ server.stop(true);
32
+ });
33
+
34
+ return {
35
+ received,
36
+ [Symbol.dispose]: stop,
37
+ };
38
+ }
@@ -4,6 +4,7 @@ import { DaemonClient } from '../client/daemon-client';
4
4
  import { startDaemon } from '../daemon/daemon';
5
5
  import type { DaemonHandle, DaemonOptions } from '../daemon/daemon';
6
6
  import type { EventMsg } from '../protocol/protocol';
7
+ import { registerTestCleanup } from './register-test-cleanup';
7
8
  import { setupTempDir } from './setup-temp-dir';
8
9
 
9
10
  /**
@@ -60,12 +61,21 @@ const BUILD = 'atc/test-build';
60
61
  * connection. `stop` closes every client and stops the daemon; `restart`
61
62
  * does the same, then boots on the same paths and state with the options
62
63
  * given or the last ones, and opens a new main client. Disposal stops what
63
- * is running and removes the directory; hold the result with `await using`.
64
+ * is running and removes the directory. That disposal runs once the current
65
+ * test finishes, so it must run inside a test; disposing sooner runs it
66
+ * then, and a second disposal does nothing. A first boot that fails runs it
67
+ * before the start rejects.
64
68
  */
65
69
  export async function startTestDaemon(config: TestDaemonConfig = {}) {
66
- await using stack = new AsyncDisposableStack();
70
+ const tmp = setupTempDir(config.prefix ?? 'atc-test-daemon-');
67
71
 
68
- const tmp = stack.use(setupTempDir(config.prefix ?? 'atc-test-daemon-'));
72
+ // Registered after the directory, so it releases first: the daemon stops
73
+ // before its directory is removed.
74
+ const stack = new AsyncDisposableStack();
75
+
76
+ const dispose = registerTestCleanup(() => stack.disposeAsync());
77
+
78
+ stack.use(tmp);
69
79
 
70
80
  const paths: TestDaemonPaths = {
71
81
  dir: tmp.dir,
@@ -141,9 +151,13 @@ export async function startTestDaemon(config: TestDaemonConfig = {}) {
141
151
  return { daemon, client };
142
152
  };
143
153
 
144
- let current = await boot();
154
+ // A boot that fails stops what it started and removes the directory
155
+ // before the start rejects.
156
+ let current = await boot().catch(async (error: unknown) => {
157
+ await dispose();
145
158
 
146
- const owned = stack.move();
159
+ throw error;
160
+ });
147
161
 
148
162
  return {
149
163
  ...paths,
@@ -210,6 +224,6 @@ export async function startTestDaemon(config: TestDaemonConfig = {}) {
210
224
 
211
225
  current = await boot();
212
226
  },
213
- [Symbol.asyncDispose]: () => owned.disposeAsync(),
227
+ [Symbol.asyncDispose]: dispose,
214
228
  };
215
229
  }
@@ -6,6 +6,7 @@ import { buildStubTUIClaude } from './build-stub-tui-claude';
6
6
  import { buildStubTUIGrok } from './build-stub-tui-grok';
7
7
  import { createStubBin } from './create-stub-bin';
8
8
  import { mergeDeep } from './merge-deep';
9
+ import { registerTestCleanup } from './register-test-cleanup';
9
10
  import { setupTempDir } from './setup-temp-dir';
10
11
  import { waitFor } from './wait-for';
11
12
 
@@ -37,15 +38,24 @@ interface TUIHarnessOptions {
37
38
  * transports the fixture repositories need, with the fields given laid over
38
39
  * them. `env` is the environment the client runs with, so a daemon started
39
40
  * with it serves the client. A wait made before the client draws anything
40
- * gets `bootMs` for that first byte. Disposal stops the client and the daemon in
41
- * the home and removes it; hold the result with `await using`.
41
+ * gets `bootMs` for that first byte. Disposal stops the client and the
42
+ * daemon in the home and removes it. That disposal runs once the current
43
+ * test finishes, so it must run inside a test; disposing sooner runs it
44
+ * then, and a second disposal does nothing.
42
45
  */
43
46
  export function startTUIHarness(options: TUIHarnessOptions = {}) {
44
- using setup = new DisposableStack();
47
+ const tmp = setupTempDir('atc-tui-');
48
+
49
+ // Registered after the home, so it releases first: the client and its
50
+ // daemon stop before their home is removed.
51
+ const owned = new AsyncDisposableStack();
52
+
53
+ const dispose = registerTestCleanup(() => owned.disposeAsync());
54
+
55
+ owned.use(tmp);
45
56
 
46
57
  // The client boots with this home as its cwd and lists it first in the
47
58
  // picker, so the path is resolved the way the client reports it.
48
- const tmp = setup.use(setupTempDir('atc-tui-'));
49
59
  const home = realpathSync(tmp.dir);
50
60
  const configPath = join(home, '.config', 'atc', 'config.json');
51
61
  const clientLogPath = join(home, 'client.log');
@@ -97,9 +107,6 @@ export function startTUIHarness(options: TUIHarnessOptions = {}) {
97
107
  // the interaction budget.
98
108
  let firstOutputAt: number | null = null;
99
109
 
100
- const owned = new AsyncDisposableStack();
101
-
102
- owned.use(setup.move());
103
110
  owned.defer(() => stopClientAndDaemon(home, pty, firstOutputAt === null));
104
111
 
105
112
  return {
@@ -218,7 +225,7 @@ export function startTUIHarness(options: TUIHarnessOptions = {}) {
218
225
  );
219
226
  },
220
227
 
221
- [Symbol.asyncDispose]: () => owned.disposeAsync(),
228
+ [Symbol.asyncDispose]: dispose,
222
229
  };
223
230
  }
224
231
 
@@ -1,4 +1,5 @@
1
1
  import { OutboundQueue } from '../protocol/outbound-queue';
2
+ import { registerTestCleanup } from './register-test-cleanup';
2
3
  import { waitFor } from './wait-for';
3
4
 
4
5
  interface SocketLines {
@@ -16,15 +17,26 @@ interface SocketLines {
16
17
  interface SocketLinesOptions {
17
18
  // How many unsent bytes the connection holds before it refuses a write.
18
19
  readonly queueBytes?: number;
20
+
21
+ // The clock a line wait reads its deadline from; the wall clock when absent.
22
+ readonly now?: () => number;
23
+
24
+ // Waits out the interval between a line wait's polls; a real sleep when
25
+ // absent.
26
+ readonly wait?: (ms: number) => Promise<void>;
19
27
  }
20
28
 
21
29
  /**
22
30
  * Connects to a unix socket and collects every complete newline-terminated
23
31
  * line it sends, buffering a partial line across reads. `write` sends the
24
32
  * whole of what it is given, the rest going out as the socket drains,
25
- * and throws instead when the unsent bytes already fill the queue. `waitForLine` polls until the collected count reaches
26
- * `count` and returns the lines, throwing when `timeoutMs` passes first or
27
- * the connection closes short of the count. Disposal ends the connection.
33
+ * and throws instead when the unsent bytes already fill the queue.
34
+ * `waitForLine` polls until the collected count reaches `count` and returns
35
+ * the lines, throwing when `timeoutMs` passes first or the connection
36
+ * closes short of the count; a stub clock passed as `now` and `wait` steps
37
+ * that timeout without waiting it out. The connection ends once the current
38
+ * test finishes, so it must run inside a test; disposal ends it sooner, and
39
+ * a second end does nothing.
28
40
  */
29
41
  export async function subscribeToSocketLines(
30
42
  path: string,
@@ -60,6 +72,10 @@ export async function subscribeToSocketLines(
60
72
  },
61
73
  });
62
74
 
75
+ const stop = registerTestCleanup(() => {
76
+ socket.end();
77
+ });
78
+
63
79
  // The default leaves room for the largest payload a test sends in one
64
80
  // piece.
65
81
  queue = new OutboundQueue(socket, options.queueBytes ?? 8 * 1024 * 1024);
@@ -93,13 +109,17 @@ export async function subscribeToSocketLines(
93
109
  requireLines(count);
94
110
  }
95
111
  },
96
- { timeoutMs },
112
+ {
113
+ timeoutMs,
114
+ now: options.now ?? Date.now,
115
+ wait: options.wait ?? Bun.sleep,
116
+ },
97
117
  );
98
118
 
99
119
  return requireLines(count);
100
120
  },
101
121
  [Symbol.asyncDispose]: () => {
102
- socket.end();
122
+ stop();
103
123
 
104
124
  return Promise.resolve();
105
125
  },