@zgeoff/atc 3.4.0 → 3.4.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.
Files changed (71) hide show
  1. package/package.json +2 -2
  2. package/src/agents/build-agent-adapters.ts +21 -3
  3. package/src/agents/claude-adapter.ts +4 -3
  4. package/src/agents/codex-adapter.ts +20 -0
  5. package/src/agents/gateway-adapter.ts +2 -2
  6. package/src/agents/plan-claude-line-input.ts +44 -0
  7. package/src/agents/render-sdk-message.ts +57 -0
  8. package/src/agents/start-claude-headless-run.ts +2 -57
  9. package/src/agents/with-claude-config-lock.ts +28 -15
  10. package/src/client/boot-daemon.ts +75 -25
  11. package/src/client/resolve-workspace-root.ts +10 -5
  12. package/src/daemon/session-runtime.ts +0 -4
  13. package/src/gateway.ts +2 -179
  14. package/src/mcp/mcp-tools.ts +1 -1
  15. package/src/mcp-http-server.ts +13 -10
  16. package/src/run-gateway-cli.ts +327 -0
  17. package/src/run-gateway.ts +49 -18
  18. package/src/sources/github/run-gh.ts +9 -0
  19. package/src/test-utils/build-mock-execution-target.ts +25 -0
  20. package/src/test-utils/build-mock-imp-session-request.ts +53 -0
  21. package/src/test-utils/build-mock-message-record.ts +7 -3
  22. package/src/test-utils/build-mock-stored-event.ts +20 -0
  23. package/src/test-utils/build-mock-stored-row.ts +23 -0
  24. package/src/test-utils/build-stub-brokered-gateway-adapter.ts +50 -0
  25. package/src/test-utils/build-stub-held-gh.ts +5 -7
  26. package/src/test-utils/build-stub-held-zoxide.ts +4 -6
  27. package/src/test-utils/build-stub-mcp-stdio-server.ts +20 -0
  28. package/src/test-utils/build-stub-recording-filter.ts +9 -0
  29. package/src/test-utils/build-stub-recording-git.ts +8 -0
  30. package/src/test-utils/create-git-fixture.ts +5 -8
  31. package/src/test-utils/create-stub-claude.ts +3 -1
  32. package/src/test-utils/create-stub-echo-claude.ts +21 -0
  33. package/src/test-utils/{build-stub-imp-port.ts → create-stub-imp-port.ts} +39 -5
  34. package/src/test-utils/create-stub-recording-codex.ts +20 -0
  35. package/src/test-utils/create-stub-shared-server-codex.ts +56 -0
  36. package/src/test-utils/create-stub-systemd.ts +10 -4
  37. package/src/test-utils/get-only-imp-name.ts +2 -2
  38. package/src/test-utils/register-test-cleanup.ts +63 -0
  39. package/src/test-utils/run-mcp-authorization.ts +7 -4
  40. package/src/test-utils/{run-legacy-daemon.ts → run-stub-legacy-daemon.ts} +2 -2
  41. package/src/test-utils/setup-mcp-home.ts +24 -13
  42. package/src/test-utils/setup-mcp-http.ts +15 -7
  43. package/src/test-utils/setup-temp-dir.ts +7 -6
  44. package/src/test-utils/start-cut-proxy.ts +6 -2
  45. package/src/test-utils/start-daemon-process.ts +25 -24
  46. package/src/test-utils/start-git-http-server.ts +9 -3
  47. package/src/test-utils/start-mcp-stdio.ts +17 -22
  48. package/src/test-utils/start-stub-answering-listener.ts +65 -0
  49. package/src/test-utils/start-stub-closing-listener.ts +31 -0
  50. package/src/test-utils/start-stub-dropping-daemon.ts +10 -8
  51. package/src/test-utils/start-stub-impd-info.ts +10 -4
  52. package/src/test-utils/start-stub-impd.ts +8 -3
  53. package/src/test-utils/start-stub-legacy-daemon.ts +13 -8
  54. package/src/test-utils/start-stub-mcp-server.ts +7 -2
  55. package/src/test-utils/start-stub-recording-listener.ts +47 -0
  56. package/src/test-utils/start-stub-refusing-listener.ts +9 -4
  57. package/src/test-utils/start-stub-reporter-socket.ts +6 -4
  58. package/src/test-utils/start-stub-restore-daemon.ts +5 -4
  59. package/src/test-utils/start-stub-session-bridge.ts +6 -4
  60. package/src/test-utils/start-stub-silent-server.ts +10 -5
  61. package/src/test-utils/start-stub-source-daemon.ts +6 -4
  62. package/src/test-utils/start-stub-stalled-client.ts +61 -0
  63. package/src/test-utils/start-stub-stalled-listener.ts +13 -9
  64. package/src/test-utils/start-stub-tcp-daemon.ts +9 -4
  65. package/src/test-utils/start-stub-telemetry-collector.ts +9 -3
  66. package/src/test-utils/start-stub-unanswering-listener.ts +41 -0
  67. package/src/test-utils/start-stub-unanswering-unix-listener.ts +38 -0
  68. package/src/test-utils/start-test-daemon.ts +20 -6
  69. package/src/test-utils/start-tui-harness.ts +15 -8
  70. package/src/test-utils/subscribe-to-socket-lines.ts +25 -5
  71. /package/src/test-utils/{run-source-daemon.ts → run-stub-source-daemon.ts} +0 -0
@@ -0,0 +1,56 @@
1
+ import { join } from 'node:path';
2
+ import { createStubBin } from './create-stub-bin';
3
+
4
+ interface StubSharedServerCodexConfig {
5
+ // The command atc runs as; the stub reports each hook through its
6
+ // `hook-report --agent codex`, as the hook entries atc prints for Codex do.
7
+ readonly atc: readonly string[];
8
+
9
+ // The composer script the stub finishes in.
10
+ readonly composer: string;
11
+ }
12
+
13
+ /**
14
+ * Creates a stand-in for a Codex CLI that shares one background server
15
+ * between its terminals, as `fake-codex` under the directory, and returns
16
+ * its path. Without `--no-daemon`, the first start records its own
17
+ * `ATC_SESSION_ID` and `ATC_SOCKET` in `codex-shared-server.env` under the
18
+ * directory, as the server it starts keeps its environment, and every start
19
+ * runs its hooks with the recorded pair, as Codex runs hooks inside that
20
+ * server. With `--no-daemon`, a start runs its hooks with its own pair. The
21
+ * stub prints its arguments, reports a `SessionStart` for its thread, which
22
+ * is `fake-thread-<its own ATC_SESSION_ID>` or the id after `resume`, then
23
+ * a `Stop` whose last message is `done <thread>`, then runs the composer.
24
+ */
25
+ export function createStubSharedServerCodex(
26
+ dir: string,
27
+ config: StubSharedServerCodexConfig,
28
+ ): string {
29
+ const report = `${config.atc.map((part) => `"${part}"`).join(' ')} hook-report --agent codex`;
30
+ const server = join(dir, 'codex-shared-server.env');
31
+
32
+ return createStubBin(
33
+ dir,
34
+ 'fake-codex',
35
+ `#!/usr/bin/env bash
36
+ echo "FAKE_CODEX_UP args: $*"
37
+ thread="fake-thread-$ATC_SESSION_ID"
38
+ source=startup
39
+ prev=''
40
+ for arg in "$@"; do
41
+ if [ "$prev" = resume ]; then thread="$arg"; source=resume; fi
42
+ prev="$arg"
43
+ done
44
+ case " $* " in
45
+ *' --no-daemon '*) ;;
46
+ *)
47
+ (set -o noclobber; printf 'ATC_SESSION_ID=%q\\nATC_SOCKET=%q\\n' "$ATC_SESSION_ID" "$ATC_SOCKET" > '${server}') 2>/dev/null
48
+ . '${server}'
49
+ ;;
50
+ esac
51
+ printf '{"hook_event_name":"SessionStart","session_id":"%s","source":"%s"}' "$thread" "$source" | ${report}
52
+ printf '{"hook_event_name":"Stop","session_id":"%s","last_assistant_message":"done %s"}' "$thread" "$thread" | ${report}
53
+ exec "${process.execPath}" "${config.composer}"
54
+ `,
55
+ );
56
+ }
@@ -1,6 +1,7 @@
1
1
  import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
+ import { registerTestCleanup } from './register-test-cleanup';
4
5
 
5
6
  interface StubSystemd {
6
7
  // The directory that holds the stand-in `systemctl` and `systemd-run`, to put
@@ -34,10 +35,17 @@ interface StubSystemd {
34
35
  * `atcCommand daemon` in the background. `systemd-run` runs the command after
35
36
  * `--` in the background with only the `--setenv` variables, as a transient
36
37
  * unit sees them, and appends its output to the `StandardOutput=append:`
37
- * file. Every call is logged.
38
+ * file. Every call is logged. The stand-ins' directory is removed once the
39
+ * current test finishes, so it must run inside a test; disposal removes it
40
+ * sooner, and a second removal does nothing.
38
41
  */
39
42
  export function createStubSystemd(atcCommand: readonly string[]): StubSystemd {
40
43
  const root = mkdtempSync(join(tmpdir(), 'atc-stub-systemd-'));
44
+
45
+ const remove = registerTestCleanup(() => {
46
+ rmSync(root, { recursive: true, force: true });
47
+ });
48
+
41
49
  const binDir = join(root, 'bin');
42
50
  const procRoot = join(root, 'proc');
43
51
  const atcLine = atcCommand.map((part) => `"${part}"`).join(' ');
@@ -118,8 +126,6 @@ exit 0
118
126
  `0::/user.slice/user-${process.getuid?.() ?? 0}.slice/user@${process.getuid?.() ?? 0}.service/app.slice/${unit}\n`,
119
127
  );
120
128
  },
121
- [Symbol.dispose]: () => {
122
- rmSync(root, { recursive: true, force: true });
123
- },
129
+ [Symbol.dispose]: remove,
124
130
  };
125
131
  }
@@ -1,4 +1,4 @@
1
- import type { buildStubImpPort } from './build-stub-imp-port';
1
+ import type { createStubImpPort } from './create-stub-imp-port';
2
2
 
3
3
  /**
4
4
  * Returns the name of the one imp a stub imp port holds, and throws when
@@ -6,7 +6,7 @@ import type { buildStubImpPort } from './build-stub-imp-port';
6
6
  * reads another's.
7
7
  */
8
8
  export function getOnlyImpName(
9
- port: Readonly<Pick<ReturnType<typeof buildStubImpPort>, 'collectImpNames'>>,
9
+ port: Readonly<Pick<ReturnType<typeof createStubImpPort>, 'collectImpNames'>>,
10
10
  ): string {
11
11
  const names = port.collectImpNames();
12
12
  const [name] = names;
@@ -0,0 +1,63 @@
1
+ import { onTestFinished } from 'bun:test';
2
+
3
+ // The releases the current test registered, in registration order; null
4
+ // before its first registration and once its hook has started.
5
+ let pending: (() => unknown)[] | null = null;
6
+
7
+ /**
8
+ * Registers the release of a resource a test just acquired, to run once the
9
+ * current test finishes, and returns that release for the resource's own
10
+ * stop and dispose members. Every release a test registers runs from one
11
+ * hook, last registered first, as `using` declarations release, so a
12
+ * directory made before a process that lives in it is removed only after
13
+ * that process stops. A release that throws stops none of the others: the
14
+ * hook rethrows once every release has run, as an `AggregateError` when
15
+ * more than one threw. The returned release runs its callback once: every
16
+ * later call, from the hook or from the test, returns what the first call
17
+ * returned. Bun runs the hook after every `afterEach`, so a release works
18
+ * from the paths and handles it captured. Throws outside a test, where
19
+ * nothing would run the hook.
20
+ */
21
+ export function registerTestCleanup<T>(release: () => T): () => T {
22
+ let released: { readonly value: T } | null = null;
23
+
24
+ const releaseOnce = (): T => {
25
+ released ??= { value: release() };
26
+
27
+ return released.value;
28
+ };
29
+
30
+ if (pending === null) {
31
+ onTestFinished(releasePending);
32
+
33
+ pending = [];
34
+ }
35
+
36
+ pending.push(releaseOnce);
37
+
38
+ return releaseOnce;
39
+ }
40
+
41
+ async function releasePending(): Promise<void> {
42
+ const releases = pending ?? [];
43
+
44
+ pending = null;
45
+
46
+ const errors: unknown[] = [];
47
+
48
+ for (const release of releases.toReversed()) {
49
+ try {
50
+ await release();
51
+ } catch (error) {
52
+ errors.push(error);
53
+ }
54
+ }
55
+
56
+ if (errors.length === 1) {
57
+ throw errors[0];
58
+ }
59
+
60
+ if (errors.length > 1) {
61
+ throw new AggregateError(errors, 'more than one test cleanup failed');
62
+ }
63
+ }
@@ -18,6 +18,9 @@ interface MCPAuthorizationRequest {
18
18
  }
19
19
 
20
20
  interface MCPAuthorization {
21
+ // The consent page the approval code sent the browser to.
22
+ readonly consent: URL;
23
+
21
24
  // Where the consent page sent the browser back to.
22
25
  readonly callback: URL;
23
26
  readonly code: string;
@@ -29,9 +32,9 @@ interface MCPAuthorization {
29
32
  * browser and the operator do: opens the authorization request with PKCE,
30
33
  * types the approval code the server printed into the login page, and ticks
31
34
  * the given scopes on the consent page. Each request starts with no cookies,
32
- * as a fresh browser would. Returns where the browser landed and what a token
33
- * request needs, and throws when any step's response lacks the value the next
34
- * step depends on.
35
+ * as a fresh browser would. Returns the consent page it passed, where the
36
+ * browser landed, and what a token request needs, and throws when any step's
37
+ * response lacks the value the next step depends on.
35
38
  */
36
39
  export async function runMCPAuthorization(
37
40
  server: MCPAuthorizationServer,
@@ -114,5 +117,5 @@ export async function runMCPAuthorization(
114
117
  throw new Error(`the consent redirect holds no authorization code: ${location}`);
115
118
  }
116
119
 
117
- return { callback, code, verifier };
120
+ return { consent, callback, code, verifier };
118
121
  }
@@ -20,10 +20,10 @@ function main() {
20
20
  const stateDir = process.argv.at(3);
21
21
 
22
22
  if (socketPath === undefined || stateDir === undefined) {
23
- throw new Error('usage: run-legacy-daemon.ts <socket path> <state dir>');
23
+ throw new Error('usage: run-stub-legacy-daemon.ts <socket path> <state dir>');
24
24
  }
25
25
 
26
- startStubLegacyDaemon(socketPath, { protocol: PROTOCOL_V + 1 });
26
+ startStubLegacyDaemon(socketPath, { protocol: PROTOCOL_V + 1, owner: 'caller' });
27
27
 
28
28
  const session = Bun.spawn(['sleep', '60']);
29
29
 
@@ -5,6 +5,7 @@ import { stopDaemonProcess } from '../stop-daemon-process';
5
5
  import { buildStubMCPClaude } from './build-stub-mcp-claude';
6
6
  import { buildStubMCPGrok } from './build-stub-mcp-grok';
7
7
  import { createStubBin } from './create-stub-bin';
8
+ import { registerTestCleanup } from './register-test-cleanup';
8
9
  import { setupTempDir } from './setup-temp-dir';
9
10
  import { waitFor } from './wait-for';
10
11
 
@@ -17,13 +18,33 @@ import { waitFor } from './wait-for';
17
18
  * home. Disposal stops the daemon, when one wrote its pid file here, and
18
19
  * waits for it to exit; then it kills the daemon's process group and every
19
20
  * group `stub-pids` records, such as a reporter a stopped session left
20
- * behind, waits until each group is empty, and removes the home. Hold the
21
- * result with `await using`.
21
+ * behind, waits until each group is empty, and removes the home. That
22
+ * disposal runs once the current test finishes, so it must run inside a
23
+ * test; disposing sooner runs it then, and a second disposal does nothing.
22
24
  */
23
25
  export function setupMCPHome() {
24
26
  const tmp = setupTempDir('atc-mcp-');
27
+
28
+ // Registered after the home, so it releases first: the daemon and the
29
+ // stand-ins stop before the home that records them is removed.
30
+ const stack = new AsyncDisposableStack();
31
+
32
+ const dispose = registerTestCleanup(() => stack.disposeAsync());
33
+
34
+ stack.use(tmp);
35
+
25
36
  const home = tmp.dir;
26
37
 
38
+ stack.defer(async () => {
39
+ const pid = findPidFilePID(join(home, 'atc-daemon.pid'));
40
+
41
+ if (pid !== null) {
42
+ await stopDaemonProcess(pid);
43
+ }
44
+
45
+ await killProcessGroups([...(pid === null ? [] : [pid]), ...readStubPIDs(home)]);
46
+ });
47
+
27
48
  mkdirSync(join(home, '.config', 'atc'), { recursive: true });
28
49
  mkdirSync(join(home, '.local', 'state', 'atc'), { recursive: true });
29
50
 
@@ -39,17 +60,7 @@ export function setupMCPHome() {
39
60
  home,
40
61
  claudeBin,
41
62
  grokBin,
42
- async [Symbol.asyncDispose]() {
43
- const pid = findPidFilePID(join(home, 'atc-daemon.pid'));
44
-
45
- if (pid !== null) {
46
- await stopDaemonProcess(pid);
47
- }
48
-
49
- await killProcessGroups([...(pid === null ? [] : [pid]), ...readStubPIDs(home)]);
50
-
51
- tmp[Symbol.dispose]();
52
- },
63
+ [Symbol.asyncDispose]: dispose,
53
64
  };
54
65
  }
55
66
 
@@ -7,6 +7,7 @@ import { openMCPAuth } from '../mcp/open-mcp-auth';
7
7
  import { ReconnectingCaller } from '../mcp/reconnecting-caller';
8
8
  import { startMCPHTTPServer } from '../mcp/start-mcp-http-server';
9
9
  import { buildMockAgentAdapter } from './build-mock-agent-adapter';
10
+ import { registerTestCleanup } from './register-test-cleanup';
10
11
  import { setupTempDir } from './setup-temp-dir';
11
12
 
12
13
  interface MCPHTTPSetupOptions {
@@ -24,13 +25,22 @@ interface MCPHTTPSetupOptions {
24
25
  * through it and returns the client id. `restartDaemon` stops the daemon and
25
26
  * starts a fresh one on the same socket and database, the way an operator
26
27
  * restarts it, with the principals it is given; `countDaemonClients` reads
27
- * how many connections the current daemon holds open. Hold the result with
28
- * `await using`.
28
+ * how many connections the current daemon holds open. Everything it starts
29
+ * stops, and the directory is removed, once the current test finishes, so it
30
+ * must run inside a test; disposal does so sooner, and a second disposal
31
+ * does nothing.
29
32
  */
30
33
  export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
31
- await using stack = new AsyncDisposableStack();
34
+ const tmp = setupTempDir('atc-mcp-http-');
35
+
36
+ // Registered after the directory, so it releases first: each part stops
37
+ // before the one it depends on, and the directory goes last.
38
+ const stack = new AsyncDisposableStack();
39
+
40
+ const dispose = registerTestCleanup(() => stack.disposeAsync());
41
+
42
+ stack.use(tmp);
32
43
 
33
- const tmp = stack.use(setupTempDir('atc-mcp-http-'));
34
44
  const socketPath = join(tmp.dir, 'daemon.sock');
35
45
  const stateDir = join(tmp.dir, '.local', 'state', 'atc');
36
46
 
@@ -83,8 +93,6 @@ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
83
93
 
84
94
  stack.defer(() => store.close());
85
95
 
86
- const owned = stack.move();
87
-
88
96
  return {
89
97
  home: tmp.dir,
90
98
  url: server.url,
@@ -109,6 +117,6 @@ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
109
117
 
110
118
  daemon = await startTestDaemon(principals);
111
119
  },
112
- [Symbol.asyncDispose]: () => owned.disposeAsync(),
120
+ [Symbol.asyncDispose]: dispose,
113
121
  };
114
122
  }
@@ -1,6 +1,7 @@
1
1
  import { mkdtempSync, rmSync } from 'node:fs';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
+ import { registerTestCleanup } from './register-test-cleanup';
4
5
 
5
6
  interface TempDir {
6
7
  readonly dir: string;
@@ -9,17 +10,17 @@ interface TempDir {
9
10
  }
10
11
 
11
12
  /**
12
- * Creates a disposable directory under the system temp root, named by the
13
- * prefix. Disposal removes the tree; hold the result with `using` in a
14
- * synchronous test and `await using` in an asynchronous one, so the
15
- * directory outlives exactly the test that made it.
13
+ * Creates a directory under the system temp root, named by the prefix, and
14
+ * registers its removal to run once the current test finishes, so it must
15
+ * run inside a test. Disposal removes the tree sooner; whichever comes
16
+ * second does nothing.
16
17
  */
17
18
  export function setupTempDir(prefix: string): TempDir {
18
19
  const dir = mkdtempSync(join(tmpdir(), prefix));
19
20
 
20
- const remove = () => {
21
+ const remove = registerTestCleanup(() => {
21
22
  rmSync(dir, { recursive: true, force: true });
22
- };
23
+ });
23
24
 
24
25
  return {
25
26
  dir,
@@ -1,4 +1,5 @@
1
1
  import type { Socket } from 'bun';
2
+ import { registerTestCleanup } from './register-test-cleanup';
2
3
 
3
4
  interface CutProxyOptions {
4
5
  // The address the proxy forwards to.
@@ -47,6 +48,9 @@ interface ProxyLink {
47
48
  * connection byte for byte, except that for the first `cuts` requests of
48
49
  * the method it loses the target's next answer after the request reached
49
50
  * the target: the request runs, and the client never sees its response.
51
+ * It stops, ending every link, once the current test finishes, so it must
52
+ * run inside a test; `stop` or disposal stops it sooner, and a second stop
53
+ * does nothing.
50
54
  */
51
55
  export function startCutProxy(options: CutProxyOptions): CutProxy {
52
56
  let cutsLeft = options.cuts;
@@ -121,14 +125,14 @@ export function startCutProxy(options: CutProxyOptions): CutProxy {
121
125
  },
122
126
  });
123
127
 
124
- const stop = () => {
128
+ const stop = registerTestCleanup(() => {
125
129
  for (const client of links) {
126
130
  client.end();
127
131
  client.data.upstream?.end();
128
132
  }
129
133
 
130
134
  server.stop(true);
131
- };
135
+ });
132
136
 
133
137
  return {
134
138
  port: server.port,
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import type { Subprocess } from 'bun';
4
4
  import { DaemonClient } from '../client/daemon-client';
5
5
  import { findDaemonRecord } from '../shared/find-daemon-record';
6
+ import { registerTestCleanup } from './register-test-cleanup';
6
7
  import { waitFor } from './wait-for';
7
8
 
8
9
  interface DaemonProcessConfig {
@@ -41,7 +42,9 @@ interface DaemonBoot {
41
42
  * same config. `readStderr` reads what the current boot printed. Disposal
42
43
  * closes every client it opened, kills the daemon and waits for it to exit,
43
44
  * then kills the daemon the home's state directory records, which a restart
44
- * the test asked atc for may have started; a second disposal does nothing.
45
+ * the test asked atc for may have started. That disposal runs once the
46
+ * current test finishes, so it must run inside a test; disposing sooner
47
+ * runs it then, and a second disposal does nothing.
45
48
  */
46
49
  export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
47
50
  const socketPath = join(config.home, 'atc-daemon.sock');
@@ -76,6 +79,26 @@ export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
76
79
 
77
80
  let current = boot();
78
81
 
82
+ const dispose = registerTestCleanup(async () => {
83
+ disposed = true;
84
+
85
+ for (const client of clients) {
86
+ client.stop();
87
+ }
88
+
89
+ current.proc.kill('SIGKILL');
90
+
91
+ await current.proc.exited;
92
+
93
+ const recorded = findDaemonRecord(join(stateDir, 'daemon.json'));
94
+
95
+ if (recorded !== null && recorded.pid !== current.proc.pid) {
96
+ try {
97
+ process.kill(recorded.pid, 'SIGKILL');
98
+ } catch {}
99
+ }
100
+ });
101
+
79
102
  const readStderr = () => {
80
103
  try {
81
104
  return readFileSync(current.stderrPath, 'utf8');
@@ -165,28 +188,6 @@ export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
165
188
 
166
189
  current = boot();
167
190
  },
168
- async [Symbol.asyncDispose](): Promise<void> {
169
- if (disposed) {
170
- return;
171
- }
172
-
173
- disposed = true;
174
-
175
- for (const client of clients) {
176
- client.stop();
177
- }
178
-
179
- current.proc.kill('SIGKILL');
180
-
181
- await current.proc.exited;
182
-
183
- const recorded = findDaemonRecord(join(stateDir, 'daemon.json'));
184
-
185
- if (recorded !== null && recorded.pid !== current.proc.pid) {
186
- try {
187
- process.kill(recorded.pid, 'SIGKILL');
188
- } catch {}
189
- }
190
- },
191
+ [Symbol.asyncDispose]: dispose,
191
192
  };
192
193
  }
@@ -1,3 +1,5 @@
1
+ import { registerTestCleanup } from './register-test-cleanup';
2
+
1
3
  interface GitHTTPServer {
2
4
  // The base URL a repository under the root is served at, ending in `/`.
3
5
  readonly url: string;
@@ -27,7 +29,9 @@ interface GitHTTPServerOptions {
27
29
  * authenticated request waits that long before it is served, which makes a
28
30
  * clone through it slow. An `onRequest` callback runs, and is awaited, while
29
31
  * each authenticated request is held, so it sees the client that sent the
30
- * request still running. Stop it with `stop`, or hold it with `await using`.
32
+ * request still running. It stops once the current test finishes, so it
33
+ * must run inside a test; `stop` or disposal stops it sooner, and a second
34
+ * stop does nothing.
31
35
  */
32
36
  export function startGitHTTPServer(
33
37
  root: string,
@@ -87,11 +91,13 @@ export function startGitHTTPServer(
87
91
  },
88
92
  });
89
93
 
94
+ const stop = registerTestCleanup(() => server.stop(true));
95
+
90
96
  return {
91
97
  url: `http://127.0.0.1:${server.port}/`,
92
98
  authorizations,
93
- stop: () => server.stop(true),
94
- [Symbol.asyncDispose]: () => server.stop(true),
99
+ stop,
100
+ [Symbol.asyncDispose]: stop,
95
101
  };
96
102
  }
97
103
 
@@ -1,5 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import { isRecord } from '../shared/report';
3
+ import { registerTestCleanup } from './register-test-cleanup';
3
4
 
4
5
  const CLI_PATH = join(import.meta.dir, '..', 'cli.ts');
5
6
 
@@ -37,9 +38,10 @@ interface MCPToolResult {
37
38
  * an object. `spawnSession` spawns through `atc_session_spawn` and resolves
38
39
  * with the new session's id. A request still unanswered when the server's
39
40
  * stdout ends rejects. When `initialize` fails, the server is stopped
40
- * before the start rejects. Disposal stops the server and waits for it to exit,
41
- * and a second disposal waits for the same exit; the daemon stays up for
42
- * the home to stop.
41
+ * before the start rejects. The server stops once the current test
42
+ * finishes, so it must run inside a test; disposal stops it sooner and waits
43
+ * for it to exit, and a second disposal waits for the same exit. The daemon
44
+ * stays up for the home to stop.
43
45
  */
44
46
  export async function startMCPStdio(options: MCPStdioOptions) {
45
47
  const proc = Bun.spawn([...(options.command ?? [process.execPath, CLI_PATH, 'mcp'])], {
@@ -56,6 +58,13 @@ export async function startMCPStdio(options: MCPStdioOptions) {
56
58
  stderr: 'ignore',
57
59
  });
58
60
 
61
+ const stopServer = registerTestCleanup(async (): Promise<void> => {
62
+ void proc.stdin.end();
63
+ proc.kill();
64
+
65
+ await proc.exited;
66
+ });
67
+
59
68
  const pending = new Map<number, PromiseWithResolvers<Readonly<Record<string, unknown>>>>();
60
69
 
61
70
  void readResponses(proc.stdout, pending);
@@ -90,23 +99,6 @@ export async function startMCPStdio(options: MCPStdioOptions) {
90
99
  return toToolResult(response);
91
100
  };
92
101
 
93
- let stopped = false;
94
-
95
- const stopServer = async (): Promise<void> => {
96
- if (!stopped) {
97
- stopped = true;
98
- void proc.stdin.end();
99
- proc.kill();
100
- }
101
-
102
- await proc.exited;
103
- };
104
-
105
- // A failed initialize stops the server here, before the caller holds it.
106
- await using stack = new AsyncDisposableStack();
107
-
108
- stack.defer(stopServer);
109
-
110
102
  const server = {
111
103
  sendRequest,
112
104
  sendToolCall,
@@ -124,13 +116,16 @@ export async function startMCPStdio(options: MCPStdioOptions) {
124
116
  [Symbol.asyncDispose]: stopServer,
125
117
  };
126
118
 
119
+ // A failed initialize stops the server here, before the start rejects.
127
120
  await sendRequest('initialize', {
128
121
  protocolVersion: '2025-06-18',
129
122
  capabilities: {},
130
123
  clientInfo: { name: 'atc-test' },
131
- });
124
+ }).catch(async (error: unknown) => {
125
+ await stopServer();
132
126
 
133
- stack.move();
127
+ throw error;
128
+ });
134
129
 
135
130
  return server;
136
131
  }
@@ -0,0 +1,65 @@
1
+ import { createServer } from 'node:net';
2
+ import type { Socket } from 'node:net';
3
+ import { registerTestCleanup } from './register-test-cleanup';
4
+
5
+ interface StubAnsweringListener {
6
+ // The server's side of each connection it accepted, in order.
7
+ readonly peers: readonly Socket[];
8
+
9
+ // The first read each connection sent, decoded, in arrival order.
10
+ readonly lines: readonly string[];
11
+ readonly [Symbol.asyncDispose]: () => Promise<void>;
12
+ }
13
+
14
+ /**
15
+ * A stand-in for a daemon socket that answers a handshake: it listens on
16
+ * the unix socket path, records each connection it accepts and the first
17
+ * read each one sends, answers that read with the line `answer`, and
18
+ * leaves every later byte unread on the connection. Resolves once it
19
+ * listens. Once the current test finishes, it destroys every connection it
20
+ * accepted and closes, so it must run inside a test; disposal does so
21
+ * sooner and resolves once the server has closed, and a second stop does
22
+ * nothing.
23
+ */
24
+ export async function startStubAnsweringListener(path: string): Promise<StubAnsweringListener> {
25
+ const peers: Socket[] = [];
26
+ const lines: string[] = [];
27
+
28
+ const server = createServer((peer) => {
29
+ peers.push(peer);
30
+
31
+ peer.once('data', (data) => {
32
+ peer.pause();
33
+ lines.push(data.toString());
34
+ peer.write('answer\n');
35
+ });
36
+ });
37
+
38
+ const listening = Promise.withResolvers<void>();
39
+
40
+ server.listen(path, () => {
41
+ listening.resolve();
42
+ });
43
+
44
+ await listening.promise;
45
+
46
+ const stop = registerTestCleanup(async () => {
47
+ for (const peer of peers) {
48
+ peer.destroy();
49
+ }
50
+
51
+ const closed = Promise.withResolvers<void>();
52
+
53
+ server.close(() => {
54
+ closed.resolve();
55
+ });
56
+
57
+ await closed.promise;
58
+ });
59
+
60
+ return {
61
+ peers,
62
+ lines,
63
+ [Symbol.asyncDispose]: stop,
64
+ };
65
+ }
@@ -0,0 +1,31 @@
1
+ import { registerTestCleanup } from './register-test-cleanup';
2
+
3
+ interface StubClosingListener {
4
+ readonly [Symbol.dispose]: () => void;
5
+ }
6
+
7
+ /**
8
+ * A stand-in for a daemon that hangs up: it listens on the unix socket path
9
+ * and ends every connection as soon as it opens, reading nothing. It stops
10
+ * once the current test finishes, so it must run inside a test; disposal
11
+ * stops it sooner, and a second stop does nothing.
12
+ */
13
+ export function startStubClosingListener(path: string): StubClosingListener {
14
+ const server = Bun.listen({
15
+ unix: path,
16
+ socket: {
17
+ open(socket) {
18
+ socket.end();
19
+ },
20
+ data() {},
21
+ },
22
+ });
23
+
24
+ const stop = registerTestCleanup(() => {
25
+ server.stop(true);
26
+ });
27
+
28
+ return {
29
+ [Symbol.dispose]: stop,
30
+ };
31
+ }