@zgeoff/atc 3.3.1 → 3.4.0

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 (179) hide show
  1. package/package.json +4 -2
  2. package/src/agents/agent-adapter.ts +10 -0
  3. package/src/agents/build-cli-argv.ts +2 -4
  4. package/src/agents/claude-adapter.ts +13 -3
  5. package/src/agents/codex-adapter.ts +11 -2
  6. package/src/agents/gateway-adapter.ts +20 -8
  7. package/src/agents/grok-adapter.ts +16 -13
  8. package/src/agents/load-claude-config-bundle.ts +6 -6
  9. package/src/agents/print-codex-hook-file.ts +7 -3
  10. package/src/agents/print-grok-hook-file.ts +8 -3
  11. package/src/agents/resolve-agent-home.ts +7 -3
  12. package/src/agents/resolve-claude-global-config-path.ts +4 -4
  13. package/src/agents/update-claude-project-trust.ts +41 -30
  14. package/src/agents/with-claude-config-lock.ts +13 -1
  15. package/src/agents/write-atc-bridge.ts +10 -7
  16. package/src/agents/write-hook-settings.ts +13 -7
  17. package/src/client/boot-daemon.ts +48 -15
  18. package/src/client/dirs.ts +4 -2
  19. package/src/client/index.ts +17 -1
  20. package/src/client/log-client-event.ts +20 -0
  21. package/src/client/spawn-picker.ts +200 -121
  22. package/src/client/ui.ts +7 -7
  23. package/src/clients.ts +41 -11
  24. package/src/daemon/daemon.ts +143 -33
  25. package/src/daemon/event-signal.ts +19 -3
  26. package/src/daemon/make-hook-runner.ts +82 -22
  27. package/src/daemon/permission-registry.ts +11 -6
  28. package/src/daemon/restore-fleet.ts +62 -17
  29. package/src/daemon/run-eject-handoff.ts +20 -2
  30. package/src/daemon/sessions.ts +6 -0
  31. package/src/daemon/start-tcp-listener.ts +12 -10
  32. package/src/federation/daemon-caller.ts +25 -4
  33. package/src/federation/daemon-pool.ts +3 -1
  34. package/src/federation/open-gateway-caller.ts +6 -1
  35. package/src/grants.ts +36 -11
  36. package/src/hook-report.ts +17 -3
  37. package/src/mcp/read-report-texts.ts +11 -7
  38. package/src/mcp/start-mcp-http-server.ts +5 -1
  39. package/src/mcp-http-server.ts +85 -32
  40. package/src/report.ts +21 -3
  41. package/src/run-bridge-tap.ts +36 -7
  42. package/src/run-config-migrate.ts +35 -11
  43. package/src/run-gateway.ts +4 -2
  44. package/src/shared/build-atc-command.ts +7 -3
  45. package/src/shared/bun-sqlite-driver.ts +10 -0
  46. package/src/shared/claim-daemon-lock.ts +10 -4
  47. package/src/shared/collect-dir-roots.ts +6 -6
  48. package/src/shared/collect-hooks.ts +7 -6
  49. package/src/shared/config.ts +19 -9
  50. package/src/shared/system-clock.ts +24 -0
  51. package/src/sources/github/collect-github-repos.ts +1 -1
  52. package/src/sources/github/read-git-protocol.ts +1 -1
  53. package/src/sources/github/run-gh.ts +15 -9
  54. package/src/store/state-store.ts +57 -7
  55. package/src/tap.ts +44 -13
  56. package/src/test-utils/build-mock-agent-adapter.ts +25 -0
  57. package/src/test-utils/build-mock-agent-entry.ts +32 -0
  58. package/src/test-utils/build-mock-auth-binding.ts +28 -0
  59. package/src/test-utils/build-mock-auth-profile.ts +62 -0
  60. package/src/test-utils/build-mock-daemon-record.ts +26 -0
  61. package/src/test-utils/build-mock-fleet-entry.ts +33 -0
  62. package/src/test-utils/build-mock-gateway-config.ts +32 -0
  63. package/src/test-utils/build-mock-imp-identity.ts +18 -0
  64. package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
  65. package/src/test-utils/build-mock-message-record.ts +22 -0
  66. package/src/test-utils/build-mock-mirror-session.ts +42 -0
  67. package/src/test-utils/build-mock-registry-daemon.ts +28 -0
  68. package/src/test-utils/build-mock-session-descriptor.ts +66 -0
  69. package/src/test-utils/build-mock-session.ts +43 -0
  70. package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
  71. package/src/test-utils/build-stub-attention-adapter.ts +22 -0
  72. package/src/test-utils/build-stub-bridge-context.ts +20 -0
  73. package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
  74. package/src/test-utils/build-stub-channel-opener.ts +45 -0
  75. package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
  76. package/src/test-utils/build-stub-client-screens.ts +33 -0
  77. package/src/test-utils/build-stub-clock.ts +67 -0
  78. package/src/test-utils/build-stub-daemon-context.ts +81 -0
  79. package/src/test-utils/build-stub-daemon-requests.ts +103 -0
  80. package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
  81. package/src/test-utils/build-stub-execution-provider.ts +112 -0
  82. package/src/test-utils/build-stub-fleet-caller.ts +60 -0
  83. package/src/test-utils/build-stub-forking-gh.ts +10 -0
  84. package/src/test-utils/build-stub-gh.ts +50 -0
  85. package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
  86. package/src/test-utils/build-stub-harness-relay.ts +50 -0
  87. package/src/test-utils/build-stub-headless-runner.ts +60 -0
  88. package/src/test-utils/build-stub-held-gh.ts +19 -0
  89. package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
  90. package/src/test-utils/build-stub-held-provider.ts +58 -0
  91. package/src/test-utils/build-stub-held-zoxide.ts +13 -0
  92. package/src/test-utils/build-stub-host-hold.ts +31 -0
  93. package/src/test-utils/build-stub-host-provider.ts +26 -0
  94. package/src/test-utils/{fixture-imp-port.ts → build-stub-imp-port.ts} +119 -63
  95. package/src/test-utils/build-stub-log.ts +15 -0
  96. package/src/test-utils/build-stub-mcp-claude.ts +26 -0
  97. package/src/test-utils/build-stub-mcp-grok.ts +19 -0
  98. package/src/test-utils/build-stub-peer-socket.ts +81 -0
  99. package/src/test-utils/build-stub-picker-host.ts +36 -0
  100. package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
  101. package/src/test-utils/build-stub-pty-provider.ts +53 -0
  102. package/src/test-utils/build-stub-recording-atc.ts +10 -0
  103. package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
  104. package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
  105. package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
  106. package/src/test-utils/build-stub-targets.ts +58 -0
  107. package/src/test-utils/build-stub-terminal.ts +19 -0
  108. package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
  109. package/src/test-utils/build-stub-tui-claude.ts +47 -0
  110. package/src/test-utils/build-stub-tui-grok.ts +53 -0
  111. package/src/test-utils/build-stub-wait-clock.ts +23 -0
  112. package/src/test-utils/build-target-options-from-config.ts +37 -0
  113. package/src/test-utils/collect-process-tree.ts +75 -0
  114. package/src/test-utils/create-git-fixture.ts +120 -0
  115. package/src/test-utils/create-migrated-state-db.ts +12 -10
  116. package/src/test-utils/create-stub-bin.ts +19 -0
  117. package/src/test-utils/create-stub-claude.ts +75 -0
  118. package/src/test-utils/create-stub-codex.ts +31 -0
  119. package/src/test-utils/create-stub-composer.ts +65 -0
  120. package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
  121. package/src/test-utils/create-stub-grok.ts +44 -0
  122. package/src/test-utils/create-stub-guest-clis.ts +55 -0
  123. package/src/test-utils/create-stub-harness-guest.ts +35 -0
  124. package/src/test-utils/create-stub-recording-claude.ts +20 -0
  125. package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +5 -5
  126. package/src/test-utils/get-gateway-config.ts +3 -3
  127. package/src/test-utils/get-only-effect-ref.ts +21 -0
  128. package/src/test-utils/get-only-imp-name.ts +19 -0
  129. package/src/test-utils/get-records.ts +20 -0
  130. package/src/test-utils/get-string.ts +14 -0
  131. package/src/test-utils/keys.ts +26 -0
  132. package/src/test-utils/merge-deep.ts +36 -0
  133. package/src/test-utils/mock-overrides.ts +13 -0
  134. package/src/test-utils/open-repo-step.ts +24 -0
  135. package/src/test-utils/read-query-plan.ts +22 -0
  136. package/src/test-utils/resolve-atc-command.ts +13 -0
  137. package/src/test-utils/resolve-gateway-command.ts +13 -0
  138. package/src/test-utils/run-atc.ts +56 -0
  139. package/src/test-utils/run-legacy-daemon.ts +27 -5
  140. package/src/test-utils/run-listener-daemon.ts +5 -10
  141. package/src/test-utils/run-source-daemon.ts +2 -2
  142. package/src/test-utils/seed-faker.ts +7 -0
  143. package/src/test-utils/send-lines-before-handshake.ts +35 -0
  144. package/src/test-utils/send-mcp-request.ts +25 -0
  145. package/src/test-utils/setup-mcp-home.ts +99 -0
  146. package/src/test-utils/setup-mcp-http.ts +19 -31
  147. package/src/test-utils/spawn-claude-session.ts +34 -0
  148. package/src/test-utils/spawn-grok-session.ts +39 -0
  149. package/src/test-utils/start-cut-proxy.ts +25 -8
  150. package/src/test-utils/start-daemon-process.ts +192 -0
  151. package/src/test-utils/start-git-http-server.ts +21 -3
  152. package/src/test-utils/start-mcp-stdio.ts +195 -0
  153. package/src/test-utils/start-output-capture.ts +20 -0
  154. package/src/test-utils/start-stub-dropping-daemon.ts +103 -0
  155. package/src/test-utils/start-stub-impd-info.ts +32 -0
  156. package/src/test-utils/start-stub-impd.ts +138 -0
  157. package/src/test-utils/start-stub-legacy-daemon.ts +166 -0
  158. package/src/test-utils/start-stub-mcp-server.ts +50 -0
  159. package/src/test-utils/start-stub-refusing-listener.ts +38 -0
  160. package/src/test-utils/start-stub-reporter-socket.ts +77 -0
  161. package/src/test-utils/start-stub-restore-daemon.ts +58 -0
  162. package/src/test-utils/start-stub-session-bridge.ts +98 -0
  163. package/src/test-utils/start-stub-silent-server.ts +33 -0
  164. package/src/test-utils/start-stub-source-daemon.ts +39 -0
  165. package/src/test-utils/start-stub-stalled-listener.ts +40 -0
  166. package/src/test-utils/start-stub-tap.ts +38 -0
  167. package/src/test-utils/start-stub-tcp-daemon.ts +70 -0
  168. package/src/test-utils/start-stub-telemetry-collector.ts +26 -0
  169. package/src/test-utils/start-test-daemon.ts +215 -0
  170. package/src/test-utils/start-tui-harness.ts +284 -0
  171. package/src/test-utils/subscribe-to-socket-lines.ts +59 -12
  172. package/src/test-utils/try-send-request.ts +29 -0
  173. package/src/test-utils/wait-for-event.ts +41 -0
  174. package/src/test-utils/wait-for.ts +15 -3
  175. package/src/verify-restored-fleet.ts +30 -17
  176. package/src/workspace/check-repository-access.ts +4 -0
  177. package/src/workspace/collect-remote-refs.ts +4 -2
  178. package/src/workspace/run-git.ts +6 -0
  179. package/src/test-utils/start-legacy-daemon.ts +0 -111
@@ -12,20 +12,68 @@ export interface HookScope {
12
12
 
13
13
  export type RunHooks = (event: EventMsg, scope: HookScope | null) => void;
14
14
 
15
+ /**
16
+ * How one hook run ended: its exit code, or the signal that ended it, or
17
+ * neither when the command failed to spawn.
18
+ */
19
+ export interface HookOutcome {
20
+ readonly command: string;
21
+ readonly exitCode: number | null;
22
+ readonly signalCode: string | null;
23
+ }
24
+
25
+ interface HookRunnerOptions {
26
+ /**
27
+ * Runs once per call, after every hook the call started has exited or
28
+ * failed to spawn, with each one's outcome in configured order; a call
29
+ * that starts none settles at once with none.
30
+ */
31
+ readonly onSettled?: (event: EventMsg, outcomes: readonly HookOutcome[]) => void;
32
+
33
+ /**
34
+ * Arms the kill of a run that outlives its timeout and returns the
35
+ * function that disarms it; a timer that never keeps the process alive
36
+ * when unset.
37
+ */
38
+ readonly scheduleKill?: (kill: () => void, timeoutMs: number) => () => void;
39
+ }
40
+
15
41
  /**
16
42
  * Builds the daemon's hook runner. Each call fires every configured command
17
43
  * for the event's name, with the wire-event JSON on stdin and the event name
18
44
  * in `ATC_EVENT`. Hooks are observational and fire-and-forget: the daemon
19
45
  * never waits on one, a run past its timeout is killed, and a nonzero exit
20
- * or spawn failure is logged to stderr and otherwise ignored.
46
+ * or spawn failure is logged to stderr and otherwise ignored. An event with
47
+ * no matching hook does no work beyond the match.
21
48
  */
22
- export function makeHookRunner(hooks: HooksConfig): RunHooks {
49
+ export function makeHookRunner(hooks: HooksConfig, options: HookRunnerOptions = {}): RunHooks {
50
+ const onSettled = options.onSettled ?? (() => {});
51
+ const scheduleKill = options.scheduleKill ?? scheduleUnrefKill;
52
+
23
53
  return (event, scope) => {
24
- for (const entry of hooks[event.ev] ?? []) {
25
- if (entry.dir === undefined || isInScope(entry.dir, scope)) {
26
- runHook(entry, event);
27
- }
54
+ const matched = (hooks[event.ev] ?? []).filter(
55
+ (entry) => entry.dir === undefined || isInScope(entry.dir, scope),
56
+ );
57
+
58
+ if (matched.length === 0) {
59
+ onSettled(event, []);
60
+
61
+ return;
28
62
  }
63
+
64
+ void runHooks(matched, event, scheduleKill, onSettled);
65
+ };
66
+ }
67
+
68
+ // Unref'd so an in-flight hook never keeps the daemon process alive; a hook
69
+ // orphaned by daemon exit is on its own.
70
+ function scheduleUnrefKill(kill: () => void, timeoutMs: number): () => void {
71
+ const timer = setTimeout(kill, timeoutMs);
72
+
73
+ timer.unref();
74
+
75
+ return () => {
76
+ clearTimeout(timer);
29
77
  };
30
78
  }
31
79
 
@@ -44,9 +92,25 @@ function isUnderDir(candidate: string, dir: string): boolean {
44
92
  return candidate === dir || candidate.startsWith(prefix);
45
93
  }
46
94
 
95
+ // Starts every hook at once and reports their outcomes once all have ended.
96
+ async function runHooks(
97
+ entries: readonly HookEntry[],
98
+ event: EventMsg,
99
+ scheduleKill: (kill: () => void, timeoutMs: number) => () => void,
100
+ onSettled: (event: EventMsg, outcomes: readonly HookOutcome[]) => void,
101
+ ): Promise<void> {
102
+ const outcomes = await Promise.all(entries.map((entry) => runHook(entry, event, scheduleKill)));
103
+
104
+ onSettled(event, outcomes);
105
+ }
106
+
47
107
  const DEFAULT_TIMEOUT_MS = 10_000;
48
108
 
49
- function runHook(entry: HookEntry, event: EventMsg): void {
109
+ async function runHook(
110
+ entry: HookEntry,
111
+ event: EventMsg,
112
+ scheduleKill: (kill: () => void, timeoutMs: number) => () => void,
113
+ ): Promise<HookOutcome> {
50
114
  let proc: ReturnType<typeof Bun.spawn>;
51
115
 
52
116
  try {
@@ -59,26 +123,22 @@ function runHook(entry: HookEntry, event: EventMsg): void {
59
123
  } catch (error) {
60
124
  console.error(`atc hook for ${event.ev} failed to spawn: ${String(error)}`);
61
125
 
62
- return;
126
+ return { command: entry.command, exitCode: null, signalCode: null };
63
127
  }
64
128
 
65
- // Unref'd so an in-flight hook never keeps the daemon process alive; a
66
- // hook orphaned by daemon exit is on its own.
67
- const timer = setTimeout(() => {
129
+ const disarm = scheduleKill(() => {
68
130
  proc.kill();
69
131
  }, entry.timeout ?? DEFAULT_TIMEOUT_MS);
70
132
 
71
- timer.unref();
72
-
73
- void (async () => {
74
- try {
75
- const code = await proc.exited;
133
+ try {
134
+ const code = await proc.exited;
76
135
 
77
- if (code !== 0) {
78
- console.error(`atc hook for ${event.ev} exited ${code}`);
79
- }
80
- } finally {
81
- clearTimeout(timer);
136
+ if (code !== 0) {
137
+ console.error(`atc hook for ${event.ev} exited ${code}`);
82
138
  }
83
- })();
139
+
140
+ return { command: entry.command, exitCode: proc.exitCode, signalCode: proc.signalCode };
141
+ } finally {
142
+ disarm();
143
+ }
84
144
  }
@@ -1,4 +1,6 @@
1
1
  import type { SessionID } from '../shared/session-id';
2
+ import { systemClock } from '../shared/system-clock';
3
+ import type { Clock } from '../shared/system-clock';
2
4
 
3
5
  export interface PermissionRequest {
4
6
  readonly id: string;
@@ -11,7 +13,7 @@ export type AnswerResult = 'ok' | 'already_answered' | 'unsupported' | 'unknown'
11
13
 
12
14
  interface PendingRequest {
13
15
  readonly req: PermissionRequest;
14
- readonly timer: ReturnType<typeof setTimeout>;
16
+ readonly cancelTimeout: () => void;
15
17
  }
16
18
 
17
19
  /**
@@ -28,6 +30,8 @@ export class PermissionRegistry {
28
30
 
29
31
  private readonly timeoutMs: number;
30
32
 
33
+ private readonly clock: Clock;
34
+
31
35
  private readonly pending = new Map<string, PendingRequest>();
32
36
 
33
37
  // Each answered request's session, kept to tell already_answered from
@@ -36,19 +40,21 @@ export class PermissionRegistry {
36
40
 
37
41
  private counter = 0;
38
42
 
39
- constructor(timeoutMs = 60_000) {
43
+ // The clock that times each request's timeout.
44
+ constructor(timeoutMs = 60_000, clock: Clock = systemClock) {
40
45
  this.timeoutMs = timeoutMs;
46
+ this.clock = clock;
41
47
  }
42
48
 
43
49
  open(sessionID: SessionID, message: string, respondable: boolean): PermissionRequest {
44
50
  const id = `p${++this.counter}`;
45
51
  const req: PermissionRequest = { id, sessionID, message, respondable };
46
52
 
47
- const timer = setTimeout(() => {
53
+ const cancelTimeout = this.clock.schedule(() => {
48
54
  this.removeRequest(id, 'deny');
49
55
  }, this.timeoutMs);
50
56
 
51
- this.pending.set(id, { req, timer });
57
+ this.pending.set(id, { req, cancelTimeout });
52
58
  this.onRequested(req);
53
59
 
54
60
  return req;
@@ -97,8 +103,7 @@ export class PermissionRegistry {
97
103
  return;
98
104
  }
99
105
 
100
- clearTimeout(entry.timer);
101
-
106
+ entry.cancelTimeout();
102
107
  this.pending.delete(id);
103
108
  this.resolved.set(id, entry.req.sessionID);
104
109
 
@@ -1,5 +1,7 @@
1
1
  import { DaemonError } from '../protocol/daemon-error';
2
2
  import type { SessionID } from '../shared/session-id';
3
+ import { systemClock } from '../shared/system-clock';
4
+ import type { Clock } from '../shared/system-clock';
3
5
  import type { FleetEntry } from '../store/fleet-entry';
4
6
  import type { StateStore } from '../store/state-store';
5
7
  import type { SessionRuntime } from './session-runtime';
@@ -16,15 +18,34 @@ export interface RestoreFleetParams {
16
18
  // it has booted before moving on regardless; zero waits on the signal
17
19
  // alone.
18
20
  readonly capMs: number;
21
+
22
+ // Whether the daemon has begun to stop; never when unset. A restore
23
+ // checks it before each terminal it starts and after each one it has
24
+ // started, and starts no more once it holds.
25
+ readonly isStopped?: () => boolean;
26
+
27
+ // The clock behind the per-session cap; the wall clock when unset.
28
+ readonly clock?: Clock;
29
+ }
30
+
31
+ // How the staggered terminal adoption behind a restore ended: every queued
32
+ // session was tried, the daemon began to stop first, or a revive threw.
33
+ type RestoreOutcome = 'finished' | 'stopped' | 'failed';
34
+
35
+ // How many sessions a restore registered, and how its staggered terminal
36
+ // adoption ended.
37
+ export interface RestoreSettled {
38
+ readonly restored: number;
39
+ readonly outcome: RestoreOutcome;
19
40
  }
20
41
 
21
42
  export interface RestoreFleetResult {
22
43
  // How many sessions the restore registered.
23
44
  readonly restored: number;
24
45
 
25
- // Resolves once the staggered terminal adoption behind the restore has
26
- // finished, also when it failed, and at once when nothing was queued.
27
- readonly settled: Promise<void>;
46
+ // Resolves with how the staggered terminal adoption behind the restore
47
+ // ended, at once when nothing was queued.
48
+ readonly settled: Promise<RestoreOutcome>;
28
49
  }
29
50
 
30
51
  /**
@@ -41,6 +62,8 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<RestoreF
41
62
  const cols = params.cols;
42
63
  const rows = params.rows;
43
64
  const capMs = params.capMs;
65
+ const clock = params.clock ?? systemClock;
66
+ const isStopped = () => params.isStopped?.() === true;
44
67
 
45
68
  const hasLiveSession = (entry: FleetEntry) =>
46
69
  mgr.sessions.some(
@@ -108,7 +131,7 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<RestoreF
108
131
  // A session whose target refuses to start a terminal stays listed without
109
132
  // one, and the restore moves on to the next.
110
133
  const adoptQueued = async (s: Session): Promise<boolean> => {
111
- const adopted = await tryAdoptTerminal(mgr, s.id, cols, rows);
134
+ const adopted = await tryAdoptTerminal(mgr, s.id, cols, rows, () => !isStopped());
112
135
 
113
136
  return adopted !== null;
114
137
  };
@@ -128,29 +151,24 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<RestoreF
128
151
  return Promise.resolve();
129
152
  }
130
153
 
154
+ // The runtime's dispose releases the waiter, which cancels the cap.
131
155
  const settled = Promise.withResolvers<void>();
132
- const timer = capMs > 0 ? setTimeout(settled.resolve, capMs) : undefined;
156
+ const cancelCap = capMs > 0 ? clock.schedule(settled.resolve, capMs) : null;
133
157
 
134
158
  runtime.bootWaiter = settled.resolve;
135
- runtime.bootTimer = timer;
136
159
 
137
160
  return (async () => {
138
161
  await settled.promise;
139
162
 
140
163
  runtime.bootWaiter = null;
141
-
142
- if (timer !== undefined) {
143
- clearTimeout(timer);
144
-
145
- runtime.bootTimer = undefined;
146
- }
164
+ cancelCap?.();
147
165
  })();
148
166
  };
149
167
 
150
168
  const [first, ...rest] = queued;
151
169
 
152
170
  if (first === undefined) {
153
- return { restored: registered.length, settled: Promise.resolve() };
171
+ return { restored: registered.length, settled: Promise.resolve('finished') };
154
172
  }
155
173
 
156
174
  // The first terminal attaches before the restore answers so a caller can
@@ -161,10 +179,14 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<RestoreF
161
179
  // A session whose revive fails is logged and left without a terminal,
162
180
  // and the restore moves on: one host's failure never keeps the sessions on
163
181
  // other hosts down, and a later one's runs where nothing awaits it.
182
+ let failed = false;
183
+
164
184
  const tryAdoptQueued = async (s: Session): Promise<boolean> => {
165
185
  try {
166
186
  return await adoptQueued(s);
167
187
  } catch (error) {
188
+ failed = true;
189
+
168
190
  mgr.log(
169
191
  `atc could not revive session ${s.id} (${error instanceof Error ? error.message : String(error)})`,
170
192
  );
@@ -173,7 +195,9 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<RestoreF
173
195
  }
174
196
  };
175
197
 
176
- const adoptRest = async (previous: Session | null) => {
198
+ // A daemon that stopped before a terminal started, or while one started
199
+ // or the previous session booted, starts no more terminals.
200
+ const adoptRest = async (previous: Session | null): Promise<RestoreOutcome> => {
177
201
  let prev = previous;
178
202
 
179
203
  for (const s of rest) {
@@ -181,23 +205,43 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<RestoreF
181
205
  await waitForBoot(prev.id);
182
206
  }
183
207
 
208
+ if (isStopped()) {
209
+ return 'stopped';
210
+ }
211
+
184
212
  const booted = await tryAdoptQueued(s);
185
213
 
214
+ if (isStopped()) {
215
+ return 'stopped';
216
+ }
217
+
186
218
  prev = booted ? s : null;
187
219
  }
220
+
221
+ return failed ? 'failed' : 'finished';
188
222
  };
189
223
 
224
+ if (isStopped()) {
225
+ return { restored: registered.length, settled: Promise.resolve('stopped') };
226
+ }
227
+
190
228
  const firstAdopted = await tryAdoptQueued(first);
191
229
 
230
+ if (isStopped()) {
231
+ return { restored: registered.length, settled: Promise.resolve('stopped') };
232
+ }
233
+
192
234
  const firstBooted = firstAdopted ? first : null;
193
235
 
194
- const adoptRestLogged = async (): Promise<void> => {
236
+ const adoptRestLogged = async (): Promise<RestoreOutcome> => {
195
237
  try {
196
- await adoptRest(firstBooted);
238
+ return await adoptRest(firstBooted);
197
239
  } catch (error) {
198
240
  mgr.log(
199
241
  `atc could not finish restoring the fleet (${error instanceof Error ? error.message : String(error)})`,
200
242
  );
243
+
244
+ return 'failed';
201
245
  }
202
246
  };
203
247
 
@@ -247,9 +291,10 @@ async function tryAdoptTerminal(
247
291
  id: SessionID,
248
292
  cols: number,
249
293
  rows: number,
294
+ canProceed: () => boolean,
250
295
  ): Promise<Session | null> {
251
296
  try {
252
- return await mgr.adoptTerminal(id, cols, rows);
297
+ return await mgr.adoptTerminal(id, cols, rows, canProceed);
253
298
  } catch (error) {
254
299
  if (error instanceof DaemonError && REFUSED_ADOPT_CODES.has(error.code)) {
255
300
  return null;
@@ -1,12 +1,21 @@
1
1
  import type { SessionID } from '../shared/session-id';
2
2
  import type { SessionRuntime } from './session-runtime';
3
3
 
4
+ /**
5
+ * Starts a timer that runs `onTimeout` after `ms` milliseconds, returning
6
+ * what cancels it.
7
+ */
8
+ export type SettleScheduler = (onTimeout: () => void, ms: number) => () => void;
9
+
4
10
  export interface EjectHandoffParams {
5
11
  readonly sessionID: SessionID;
6
12
  readonly prompt: string;
7
13
  readonly settleMs: number;
8
14
  readonly runtime: SessionRuntime;
9
15
  readonly startHeadlessTurn: (sessionID: SessionID, prompt: string) => boolean;
16
+
17
+ // Starts the settle timer; defaults to a real `setTimeout`.
18
+ readonly scheduleSettle?: SettleScheduler | undefined;
10
19
  }
11
20
 
12
21
  /**
@@ -17,17 +26,26 @@ export interface EjectHandoffParams {
17
26
  */
18
27
  export function runEjectHandoff(params: EjectHandoffParams): void {
19
28
  const settled = Promise.withResolvers<void>();
20
- const timer = setTimeout(settled.resolve, params.settleMs);
29
+ const scheduleSettle = params.scheduleSettle ?? scheduleRealSettle;
30
+ const cancelSettle = scheduleSettle(settled.resolve, params.settleMs);
21
31
 
22
32
  params.runtime.pendingEject = settled.resolve;
23
33
 
24
34
  void (async () => {
25
35
  await settled.promise;
26
36
 
27
- clearTimeout(timer);
37
+ cancelSettle();
28
38
 
29
39
  params.runtime.pendingEject = null;
30
40
 
31
41
  params.startHeadlessTurn(params.sessionID, params.prompt);
32
42
  })();
33
43
  }
44
+
45
+ function scheduleRealSettle(onTimeout: () => void, ms: number): () => void {
46
+ const timer = setTimeout(onTimeout, ms);
47
+
48
+ return () => {
49
+ clearTimeout(timer);
50
+ };
51
+ }
@@ -3010,6 +3010,12 @@ export class SessionManager {
3010
3010
  }
3011
3011
  }
3012
3012
 
3013
+ // A harness detaches once, so a disposal after a detach of every harness
3014
+ // lets go of nothing again.
3015
+ [Symbol.dispose]() {
3016
+ this.detachAll();
3017
+ }
3018
+
3013
3019
  private emitChange() {
3014
3020
  this.writeStatus();
3015
3021
  this.onChange();
@@ -1,3 +1,4 @@
1
+ import type { Clock } from '../shared/system-clock';
1
2
  import type { DaemonConnection, TCPPeer } from './daemon-connection';
2
3
  import { findTokenFingerprint } from './find-token-fingerprint';
3
4
  import { formatLogField } from './format-log-field';
@@ -30,12 +31,13 @@ interface TCPListenerOptions {
30
31
  readonly closeConnection: (connection: DaemonConnection) => void;
31
32
 
32
33
  // Where the listener start and each refusal are logged, one line at a
33
- // time, with the clock and the window that summarize repeated refusals
34
- // from one peer.
34
+ // time, and the window that summarizes repeated refusals from one peer.
35
35
  readonly log: (line: string) => void;
36
- readonly now: () => number;
37
36
  readonly refusalLogIntervalMs: number;
38
37
 
38
+ // The clock that times failures, handshake delays, and refusal windows.
39
+ readonly clock: Clock;
40
+
39
41
  // How many peers and kinds of refusal the refusal log tracks at once.
40
42
  readonly maxRefusalWindows: number;
41
43
  }
@@ -69,7 +71,7 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
69
71
 
70
72
  const refusals = new RefusalLog({
71
73
  log: opts.log,
72
- now: opts.now,
74
+ now: opts.clock.now,
73
75
  intervalMs: opts.refusalLogIntervalMs,
74
76
  maxWindows: opts.maxRefusalWindows,
75
77
  });
@@ -91,10 +93,10 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
91
93
 
92
94
  const peer: TCPPeer = {
93
95
  verifyHandshake: async (presented) => {
94
- const delay = throttle.getDelay(address, Date.now());
96
+ const delay = throttle.getDelay(address, opts.clock.now());
95
97
 
96
98
  if (delay > 0 && delayed >= opts.maxDelayedHandshakes) {
97
- throttle.recordFailure(address, Date.now());
99
+ throttle.recordFailure(address, opts.clock.now());
98
100
 
99
101
  refusals.record({
100
102
  event: 'handshake_refused',
@@ -110,13 +112,13 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
110
112
 
111
113
  const elapsed = Promise.withResolvers<'elapsed'>();
112
114
 
113
- const timer = setTimeout(() => {
115
+ const cancel = opts.clock.schedule(() => {
114
116
  elapsed.resolve('elapsed');
115
117
  }, delay);
116
118
 
117
119
  const waited = await Promise.race([elapsed.promise, closed.promise]);
118
120
 
119
- clearTimeout(timer);
121
+ cancel();
120
122
 
121
123
  delayed--;
122
124
 
@@ -137,7 +139,7 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
137
139
  : findTokenFingerprint(tokens, presented);
138
140
 
139
141
  if (fingerprint === null) {
140
- throttle.recordFailure(address, Date.now());
142
+ throttle.recordFailure(address, opts.clock.now());
141
143
 
142
144
  refusals.record({
143
145
  event: 'handshake_refused',
@@ -149,7 +151,7 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
149
151
  return fingerprint;
150
152
  },
151
153
  recordFailure: (reason) => {
152
- throttle.recordFailure(address, Date.now());
154
+ throttle.recordFailure(address, opts.clock.now());
153
155
  refusals.record({ event: 'handshake_refused', peer: address, reason });
154
156
  },
155
157
  recordRefusedPrincipal: () => {
@@ -32,6 +32,12 @@ export interface DaemonHello {
32
32
  readonly retentionMs: number | null;
33
33
  }
34
34
 
35
+ /**
36
+ * Runs `onTimeout` once `ms` milliseconds have passed, and returns what
37
+ * cancels it.
38
+ */
39
+ export type TimeoutScheduler = (onTimeout: () => void, ms: number) => () => void;
40
+
35
41
  interface DaemonCallerOptions {
36
42
  readonly daemon: RegistryDaemon;
37
43
  readonly build: string;
@@ -43,6 +49,9 @@ interface DaemonCallerOptions {
43
49
  // a sent request may wait for its response.
44
50
  readonly connectTimeoutMs?: number;
45
51
  readonly responseTimeoutMs?: number;
52
+
53
+ // Starts the connect and response timers; real timers when unset.
54
+ readonly scheduleTimeout?: TimeoutScheduler;
46
55
  }
47
56
 
48
57
  // One handshaken connection and what its handshake returned.
@@ -253,7 +262,7 @@ export class DaemonCaller {
253
262
  // gets its own wait on top of the response time.
254
263
  const waitMs = typeof sent['waitMs'] === 'number' && sent['waitMs'] > 0 ? sent['waitMs'] : 0;
255
264
 
256
- const timer = setTimeout(
265
+ const cancelTimeout = this.scheduleTimeout(
257
266
  () => {
258
267
  timeout.resolve('timeout');
259
268
  },
@@ -272,7 +281,7 @@ export class DaemonCaller {
272
281
 
273
282
  const raced = await Promise.race([settled, timeout.promise]);
274
283
 
275
- clearTimeout(timer);
284
+ cancelTimeout();
276
285
 
277
286
  if (raced === 'timeout') {
278
287
  this.closed.add(channel);
@@ -306,6 +315,10 @@ export class DaemonCaller {
306
315
  }
307
316
  }
308
317
 
318
+ private scheduleTimeout(onTimeout: () => void, ms: number): () => void {
319
+ return (this.opts.scheduleTimeout ?? scheduleRealTimeout)(onTimeout, ms);
320
+ }
321
+
309
322
  private buildOutcomeUnknown(m: string): DaemonError {
310
323
  return new DaemonError(
311
324
  'outcome_unknown',
@@ -340,7 +353,7 @@ export class DaemonCaller {
340
353
 
341
354
  const timeout = Promise.withResolvers<'timeout'>();
342
355
 
343
- const timer = setTimeout(() => {
356
+ const cancelTimeout = this.scheduleTimeout(() => {
344
357
  timeout.resolve('timeout');
345
358
  }, this.opts.connectTimeoutMs ?? CONNECT_TIMEOUT_MS);
346
359
 
@@ -396,7 +409,7 @@ export class DaemonCaller {
396
409
  resetConnection();
397
410
  throw error;
398
411
  } finally {
399
- clearTimeout(timer);
412
+ cancelTimeout();
400
413
  }
401
414
  }
402
415
 
@@ -471,3 +484,11 @@ function buildDaemonHello(
471
484
  retentionMs: typeof retention === 'number' && retention > 0 ? retention : null,
472
485
  };
473
486
  }
487
+
488
+ function scheduleRealTimeout(onTimeout: () => void, ms: number): () => void {
489
+ const timer = setTimeout(onTimeout, ms);
490
+
491
+ return () => {
492
+ clearTimeout(timer);
493
+ };
494
+ }
@@ -1,5 +1,5 @@
1
1
  import { DaemonCaller } from './daemon-caller';
2
- import type { GatewayChannel } from './daemon-caller';
2
+ import type { GatewayChannel, TimeoutScheduler } from './daemon-caller';
3
3
  import type { GatewayRegistry, RegistryDaemon } from './types';
4
4
 
5
5
  interface DaemonPoolOptions {
@@ -8,6 +8,7 @@ interface DaemonPoolOptions {
8
8
  readonly openChannel: (address: RegistryDaemon['address']) => Promise<GatewayChannel>;
9
9
  readonly connectTimeoutMs?: number;
10
10
  readonly responseTimeoutMs?: number;
11
+ readonly scheduleTimeout?: TimeoutScheduler;
11
12
  }
12
13
 
13
14
  /**
@@ -31,6 +32,7 @@ export class DaemonPool {
31
32
  ...(opts.responseTimeoutMs === undefined
32
33
  ? {}
33
34
  : { responseTimeoutMs: opts.responseTimeoutMs }),
35
+ ...(opts.scheduleTimeout === undefined ? {} : { scheduleTimeout: opts.scheduleTimeout }),
34
36
  }),
35
37
  ]),
36
38
  );
@@ -1,4 +1,4 @@
1
- import type { GatewayChannel } from './daemon-caller';
1
+ import type { GatewayChannel, TimeoutScheduler } from './daemon-caller';
2
2
  import { DaemonPool } from './daemon-pool';
3
3
  import { GatewayStore } from './gateway-store';
4
4
  import { RoutingCaller } from './routing-caller';
@@ -16,6 +16,10 @@ interface GatewayCallerOptions {
16
16
 
17
17
  // How long each daemon may take to answer a call asked of every daemon.
18
18
  readonly fanOutTimeoutMs?: number;
19
+
20
+ // Starts each daemon connection's connect and response timers; real
21
+ // timers when unset.
22
+ readonly scheduleTimeout?: TimeoutScheduler;
19
23
  }
20
24
 
21
25
  /**
@@ -35,6 +39,7 @@ export function openGatewayCaller(opts: GatewayCallerOptions): {
35
39
  registry: opts.registry,
36
40
  build: opts.build,
37
41
  openChannel: opts.openChannel,
42
+ ...(opts.scheduleTimeout === undefined ? {} : { scheduleTimeout: opts.scheduleTimeout }),
38
43
  });
39
44
 
40
45
  const caller = new RoutingCaller({