@zgeoff/atc 3.3.0 → 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 +62 -13
  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
package/src/clients.ts CHANGED
@@ -18,13 +18,42 @@ interface ClientsTarget {
18
18
  readonly command: string;
19
19
  }
20
20
 
21
+ // Where the command's lines and its exit code go, and how it exits: the
22
+ // console and the process by default.
23
+ interface ClientsIO {
24
+ readonly print: (line: string) => void;
25
+ readonly printError: (line: string) => void;
26
+ readonly setExitCode: (code: number) => void;
27
+ readonly exit: (code: number) => void;
28
+ }
29
+
30
+ const PROCESS_IO: ClientsIO = {
31
+ print: (line) => {
32
+ console.log(line);
33
+ },
34
+ printError: (line) => {
35
+ console.error(line);
36
+ },
37
+ setExitCode: (code) => {
38
+ process.exitCode = code;
39
+ },
40
+ exit: (code) => {
41
+ process.exit(code);
42
+ },
43
+ };
44
+
21
45
  /**
22
46
  * Runs `atc clients` or `atc-gateway clients`: lists the clients that may
23
47
  * connect to the MCP HTTP server, adds one, or removes one along with every
24
48
  * token and consent it holds. It opens the authorization server's database
25
- * directly, so it works whether or not the server is running.
49
+ * directly, so it works whether or not the server is running. A refused
50
+ * add exits 1 at once, and a refused remove sets exit code 1.
26
51
  */
27
- export async function runClients(action: ClientsAction, target: ClientsTarget): Promise<void> {
52
+ export async function runClients(
53
+ action: ClientsAction,
54
+ target: ClientsTarget,
55
+ io: ClientsIO = PROCESS_IO,
56
+ ): Promise<void> {
28
57
  if (action.kind === 'add') {
29
58
  const refused = action.redirectURIs.find((uri) => !isAllowedRedirectURI(uri));
30
59
 
@@ -34,8 +63,10 @@ export async function runClients(action: ClientsAction, target: ClientsTarget):
34
63
  ? `${target.command} add: give at least one --redirect-uri`
35
64
  : `${target.command} add: '${refused}' is not a redirect URI atc accepts; use https, or http on a loopback host, with no fragment`;
36
65
 
37
- console.error(message);
38
- process.exit(1);
66
+ io.printError(message);
67
+ io.exit(1);
68
+
69
+ return;
39
70
  }
40
71
  }
41
72
 
@@ -51,7 +82,7 @@ export async function runClients(action: ClientsAction, target: ClientsTarget):
51
82
  body: { name, redirectURIs: [...action.redirectURIs] },
52
83
  });
53
84
 
54
- console.log(`Added ${name}. Its client ID is ${created.clientID}`);
85
+ io.print(`Added ${name}. Its client ID is ${created.clientID}`);
55
86
 
56
87
  return;
57
88
  }
@@ -60,14 +91,13 @@ export async function runClients(action: ClientsAction, target: ClientsTarget):
60
91
  const removed = await removeClient(store.db, action.clientID);
61
92
 
62
93
  if (!removed) {
63
- console.error(`${target.command} remove: no client has the ID '${action.clientID}'`);
64
-
65
- process.exitCode = 1;
94
+ io.printError(`${target.command} remove: no client has the ID '${action.clientID}'`);
95
+ io.setExitCode(1);
66
96
 
67
97
  return;
68
98
  }
69
99
 
70
- console.log(`Removed client ${action.clientID} and revoked every grant it held`);
100
+ io.print(`Removed client ${action.clientID} and revoked every grant it held`);
71
101
 
72
102
  return;
73
103
  }
@@ -75,13 +105,13 @@ export async function runClients(action: ClientsAction, target: ClientsTarget):
75
105
  const clients = await collectClients(store.db);
76
106
 
77
107
  if (clients.length === 0) {
78
- console.log(`No clients. Add one with: ${target.command} add <name> --redirect-uri <uri>`);
108
+ io.print(`No clients. Add one with: ${target.command} add <name> --redirect-uri <uri>`);
79
109
 
80
110
  return;
81
111
  }
82
112
 
83
113
  for (const client of clients) {
84
- console.log(`${client.clientID} ${client.name} ${client.redirectURIs.join(' ')}`);
114
+ io.print(`${client.clientID} ${client.name} ${client.redirectURIs.join(' ')}`);
85
115
  }
86
116
  } finally {
87
117
  await store.close();
@@ -23,6 +23,8 @@ import type { MessageID } from '../shared/message-id';
23
23
  import { isRecord } from '../shared/report';
24
24
  import { resolveHomeDir } from '../shared/resolve-home-dir';
25
25
  import type { SessionID } from '../shared/session-id';
26
+ import { systemClock } from '../shared/system-clock';
27
+ import type { Clock } from '../shared/system-clock';
26
28
  import { toMessageID } from '../shared/to-message-id';
27
29
  import { truncateToBytes } from '../shared/truncate-to-bytes';
28
30
  import type { SourceProvider } from '../sources/types';
@@ -66,6 +68,7 @@ import { drainInbox } from './drain-inbox';
66
68
  import type { InboxSource } from './drain-inbox';
67
69
  import { EffectRemainsError } from './effect-remains-error';
68
70
  import { EventSignal } from './event-signal';
71
+ import { formatLogField } from './format-log-field';
69
72
  import { startHookServer } from './hooks';
70
73
  import { IdempotencyLedger } from './idempotency-ledger';
71
74
  import { isAllowedListenHost } from './is-allowed-listen-host';
@@ -81,7 +84,9 @@ import { parseReport } from './parse-report';
81
84
  import { PermissionRegistry } from './permission-registry';
82
85
  import { requireGitTransports } from './require-git-transports';
83
86
  import { restoreFleet } from './restore-fleet';
87
+ import type { RestoreSettled } from './restore-fleet';
84
88
  import { runEjectHandoff } from './run-eject-handoff';
89
+ import type { SettleScheduler } from './run-eject-handoff';
85
90
  import { RuntimeAuthBinder } from './runtime-auth-binder';
86
91
  import { ScreenModel } from './screen-model';
87
92
  import { SessionRuntime } from './session-runtime';
@@ -154,6 +159,9 @@ export interface DaemonOptions {
154
159
  // before starting the headless run anyway.
155
160
  readonly ejectSettleMs?: number;
156
161
 
162
+ // Starts the eject settle timer; defaults to a real `setTimeout`.
163
+ readonly scheduleEjectSettle?: SettleScheduler;
164
+
157
165
  // A fleet-wide restore revives one session at a time, waiting for each to
158
166
  // report it has booted before starting the next so the machine is not
159
167
  // buried under a dozen simultaneous agent boots. This caps how long a
@@ -162,13 +170,43 @@ export interface DaemonOptions {
162
170
  readonly restoreBootTimeoutMs?: number;
163
171
  readonly tapGraceMs?: number;
164
172
 
173
+ // The clock the event trail stamps each hook event with, and the clock
174
+ // behind the tap grace window, the held-read waits, the time a note is
175
+ // reported at, the permission request timeouts, the restore's per-session
176
+ // boot cap, and the TCP listener's failure times, delays, and refusal
177
+ // windows; the wall clock when unset.
178
+ readonly clock?: Clock;
179
+
165
180
  // Whether the daemon restores the stored fleet by itself once it is
166
181
  // listening, when that fleet holds sessions; off when unset.
167
182
  readonly restoreFleetOnRestart?: boolean;
168
183
 
184
+ // Called once each fleet restore has ended its staggered terminal
185
+ // adoption, with how many sessions it registered and how it ended; a
186
+ // restore that threw before registering any reports none and failed.
187
+ readonly onRestoreSettled?: (settled: RestoreSettled) => void;
188
+
189
+ // Called when a start declines to restore the stored fleet by itself:
190
+ // the option is off, or the stored fleet holds no sessions.
191
+ readonly onRestoreSkipped?: (reason: 'disabled' | 'empty') => void;
192
+
193
+ // Called when a Report line changes nothing: a note from a session the
194
+ // fleet does not list, or an answer that matches no unanswered message
195
+ // the reporting session owns.
196
+ readonly onReportIgnored?: (sessionID: string, kind: 'note' | 'answered') => void;
197
+
169
198
  // How long a confirm token from `session.forget` stays usable.
170
199
  readonly forgetConfirmMs?: number;
171
200
 
201
+ // The clock confirm tokens from `session.forget` are minted and checked
202
+ // against, in epoch milliseconds; the wall clock when unset.
203
+ readonly forgetClock?: () => number;
204
+
205
+ // Called with a session's id when it reports its start and the daemon
206
+ // leaves the last-used agent as it is, because no spawn is waiting on
207
+ // that start.
208
+ readonly onLastUsedUnchanged?: (sessionID: SessionID) => void;
209
+
172
210
  // When set, a TCP listener serves the client protocol on this address to
173
211
  // peers whose handshake presents a token from the token file.
174
212
  readonly listen?: ListenOptions;
@@ -181,6 +219,11 @@ export interface DaemonOptions {
181
219
  // when unset.
182
220
  readonly log?: (line: string) => void;
183
221
 
222
+ // Called each time the daemon leaves a session's new output unjudged
223
+ // because no screen detector covers it: no agent in the fleet has one,
224
+ // the session's own agent has none, or the session is no longer listed.
225
+ readonly onDetectSkipped?: (sessionID: SessionID) => void;
226
+
184
227
  // The sources the spawn picker offers, in order, each built with the
185
228
  // services it uses; none when unset.
186
229
  readonly sources?: readonly SourceProvider[];
@@ -197,6 +240,11 @@ export interface DaemonOptions {
197
240
  root: string | null;
198
241
  targetRoots: ReadonlyMap<string, string>;
199
242
  }>;
243
+
244
+ // The home a git workspace without a directory lands under on a target
245
+ // on the daemon's own machine when no root applies; the daemon user's
246
+ // home when unset.
247
+ readonly homeDir?: string;
200
248
  }
201
249
 
202
250
  // The TCP listener's address and the file holding the tokens a handshake
@@ -214,10 +262,8 @@ interface ListenOptions {
214
262
  // when unset.
215
263
  readonly maxDelayedHandshakes?: number;
216
264
 
217
- // The clock the refusal log reads, Date.now when unset, and how long it
218
- // folds repeated refusals from one peer into one line; a minute when
219
- // unset.
220
- readonly now?: () => number;
265
+ // How long the refusal log folds repeated refusals from one peer into one
266
+ // line; a minute when unset.
221
267
  readonly refusalLogIntervalMs?: number;
222
268
 
223
269
  // How many peers and kinds of refusal the refusal log tracks at once;
@@ -226,11 +272,18 @@ interface ListenOptions {
226
272
  }
227
273
 
228
274
  export interface DaemonHandle {
275
+ // A second stop, or a disposal after one, waits on the first and stops
276
+ // nothing again.
229
277
  readonly stop: () => Promise<void>;
230
278
 
279
+ readonly [Symbol.asyncDispose]: () => Promise<void>;
280
+
231
281
  // How many client-protocol connections are open right now.
232
282
  readonly countClients: () => number;
233
283
 
284
+ // How many event reads are waiting right now for the trail to grow.
285
+ readonly countEventWaiters: () => number;
286
+
234
287
  // The port the TCP listener bound, or null without one.
235
288
  readonly listenPort: number | null;
236
289
 
@@ -292,6 +345,10 @@ const LOCK_WAIT_MS = 2000;
292
345
  */
293
346
  export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
294
347
  let stopDaemon: (() => Promise<void>) | null = null;
348
+ const clock = opts.clock ?? systemClock;
349
+
350
+ // Set once the daemon begins to release what it holds.
351
+ let released = false;
295
352
 
296
353
  // The listener's address and tokens are checked before the daemon takes
297
354
  // anything, so a refused listener leaves no state behind.
@@ -406,8 +463,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
406
463
  return s === undefined ? null : { cwd: s.cwd, repoRoot: s.repoRoot };
407
464
  };
408
465
 
409
- const registry = new PermissionRegistry();
410
- const eventSignal = new EventSignal();
466
+ const registry = new PermissionRegistry(undefined, clock);
467
+ const eventSignal = new EventSignal(clock);
411
468
 
412
469
  // A trail write that fails never fails the message request or report behind it.
413
470
  // Returns false when the entry was not written: a failed write, or a
@@ -563,9 +620,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
563
620
  if (report.kind === 'note') {
564
621
  const sender = mgr.sessions.find((x) => x.id === e.atcId);
565
622
 
566
- if (sender !== undefined) {
623
+ if (sender === undefined) {
624
+ opts.onReportIgnored?.(e.atcId, 'note');
625
+ } else {
567
626
  const capped = { ...report, text: truncateToBytes(report.text, ANSWER_BYTE_CAP) };
568
- const reportedAt = Date.now();
627
+ const reportedAt = clock.now();
569
628
 
570
629
  const entry = buildReportTrailEntry(
571
630
  sender.id,
@@ -602,6 +661,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
602
661
  report.turn,
603
662
  );
604
663
 
664
+ if (answered.length === 0) {
665
+ opts.onReportIgnored?.(e.atcId, 'answered');
666
+ }
667
+
605
668
  for (const record of answered) {
606
669
  await recordMessageStatus(e.atcId, record);
607
670
  }
@@ -612,6 +675,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
612
675
  // quiesced, judge the serialized screen and flip running/needs_you.
613
676
  const scheduleDetect = (sessionID: SessionID) => {
614
677
  if (!mgr.hasScreenDetector) {
678
+ opts.onDetectSkipped?.(sessionID);
679
+
615
680
  return;
616
681
  }
617
682
 
@@ -619,6 +684,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
619
684
  const detector = s === undefined ? null : (mgr.findAdapter(s.agent)?.screenDetector ?? null);
620
685
  const runtime = runtimes.get(sessionID);
621
686
 
687
+ if (detector === null) {
688
+ opts.onDetectSkipped?.(sessionID);
689
+ }
690
+
622
691
  if (detector === null || runtime === undefined) {
623
692
  return;
624
693
  }
@@ -852,7 +921,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
852
921
  };
853
922
 
854
923
  const recordHookEvent = async (e: HookEvent, ev: Readonly<AdapterEvent> | null) => {
855
- await store.recordEvent(e, ev);
924
+ await store.recordEvent(e, ev, clock.now());
856
925
 
857
926
  eventSignal.emit();
858
927
  };
@@ -883,7 +952,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
883
952
  const runtime = runtimes.get(e.atcId);
884
953
 
885
954
  // A harness nested inside a session inherits its environment and reports
886
- // under its id; only the harness atc started may change the session.
955
+ // under its id; only the harness atc started may change the session, and
956
+ // a line from any other is logged and dropped.
887
957
  if (
888
958
  before !== undefined &&
889
959
  runtime !== undefined &&
@@ -893,6 +963,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
893
963
  runtime.hasAgentHookLines,
894
964
  )
895
965
  ) {
966
+ mgr.log(
967
+ `atc hook event=dropped session=${formatLogField(e.atcId)} agent=${formatLogField(e.agent ?? '')} hook=${formatLogField(e.event)}`,
968
+ );
969
+
896
970
  return;
897
971
  }
898
972
 
@@ -927,7 +1001,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
927
1001
  // waits on before booting the next one.
928
1002
  if (kind === 'started') {
929
1003
  if (runtime !== undefined) {
930
- runtime.startedAt = Date.now();
1004
+ runtime.startedAt = clock.now();
931
1005
  }
932
1006
 
933
1007
  runtime?.bootWaiter?.();
@@ -936,6 +1010,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
936
1010
  if (started !== undefined && runtime !== undefined && runtime.pendingLastUsed) {
937
1011
  runtime.pendingLastUsed = false;
938
1012
  void store.writeLastUsedAgent(started.agent);
1013
+ } else if (started !== undefined) {
1014
+ opts.onLastUsedUnchanged?.(started.id);
939
1015
  }
940
1016
  }
941
1017
  };
@@ -1210,9 +1286,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1210
1286
  const confirmTokens = new Map<string, ConfirmToken>();
1211
1287
 
1212
1288
  const forgetConfirmMs = opts.forgetConfirmMs ?? FORGET_CONFIRM_MS;
1289
+ const forgetClock = opts.forgetClock ?? Date.now;
1213
1290
 
1214
1291
  const claimConfirmToken = (sessionID: SessionID, token: string) => {
1215
- const now = Date.now();
1292
+ const now = forgetClock();
1216
1293
 
1217
1294
  for (const [held, entry] of confirmTokens) {
1218
1295
  if (entry.expiresAt + forgetConfirmMs < now) {
@@ -1268,7 +1345,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1268
1345
  runtime !== undefined &&
1269
1346
  runtime.startedAt !== null &&
1270
1347
  !runtime.tapAttached &&
1271
- Date.now() - runtime.startedAt >= (opts.tapGraceMs ?? TAP_GRACE_MS)
1348
+ clock.now() - runtime.startedAt >= (opts.tapGraceMs ?? TAP_GRACE_MS)
1272
1349
  ) {
1273
1350
  return 'no_tap';
1274
1351
  }
@@ -1386,13 +1463,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1386
1463
  cols,
1387
1464
  rows,
1388
1465
  capMs: opts.restoreBootTimeoutMs ?? 0,
1466
+ isStopped: () => released,
1467
+ clock,
1389
1468
  });
1390
1469
 
1391
1470
  restored.resolve(result.restored);
1392
1471
 
1393
- await result.settled;
1472
+ const outcome = await result.settled;
1473
+
1474
+ opts.onRestoreSettled?.({ restored: result.restored, outcome });
1394
1475
  } catch (error) {
1395
1476
  restored.reject(error);
1477
+ opts.onRestoreSettled?.({ restored: 0, outcome: 'failed' });
1396
1478
  } finally {
1397
1479
  fleetRestore = null;
1398
1480
  }
@@ -1480,7 +1562,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1480
1562
  buildDefaultWorkspaceDir(source, {
1481
1563
  root: opts.workspaceRoots?.targetRoots.get(target) ?? opts.workspaceRoots?.root ?? null,
1482
1564
  remote: mgr.requireExecution({ target, targetIdentity: null }, 'run').provider.remote,
1483
- home: resolveHomeDir(),
1565
+ home: opts.homeDir ?? resolveHomeDir(),
1484
1566
  }),
1485
1567
  requireAgentTarget: (agent, target) => {
1486
1568
  mgr.requireAgentTarget(agent, target);
@@ -1607,7 +1689,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1607
1689
 
1608
1690
  if (confirmToken === undefined) {
1609
1691
  const token = randomUUID();
1610
- const expiresAt = Date.now() + forgetConfirmMs;
1692
+ const expiresAt = forgetClock() + forgetConfirmMs;
1611
1693
 
1612
1694
  confirmTokens.set(token, { session: id, expiresAt, used: false });
1613
1695
 
@@ -1663,6 +1745,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1663
1745
  sessionID: id,
1664
1746
  prompt,
1665
1747
  settleMs: opts.ejectSettleMs ?? 4000,
1748
+ scheduleSettle: opts.scheduleEjectSettle,
1666
1749
  runtime,
1667
1750
  startHeadlessTurn: (sid, p) =>
1668
1751
  startHeadlessTurn(mgr, findRuntime, sid, p, recordHeadlessTurnEvent),
@@ -1911,7 +1994,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1911
1994
  return { path, page };
1912
1995
  },
1913
1996
  readEvents: async (afterID, limit, waitMs, sessionID, access) => {
1914
- const deadline = Date.now() + waitMs;
1997
+ const deadline = clock.now() + waitMs;
1915
1998
 
1916
1999
  // A wake for an event the read leaves out (a heartbeat, or another
1917
2000
  // session's event under a session filter) loops back to wait out the
@@ -1936,7 +2019,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1936
2019
  continue;
1937
2020
  }
1938
2021
 
1939
- const remaining = deadline - Date.now();
2022
+ const remaining = deadline - clock.now();
1940
2023
 
1941
2024
  if (rows.length > 0 || remaining <= 0 || eventSignal.disposed) {
1942
2025
  const naming = collectNamingDescriptors(access);
@@ -2077,7 +2160,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
2077
2160
  }
2078
2161
  },
2079
2162
  readMessage: async (messageID, waitMs) => {
2080
- const deadline = Date.now() + waitMs;
2163
+ const deadline = clock.now() + waitMs;
2081
2164
  let initialStatus: MessageStatus | null = null;
2082
2165
 
2083
2166
  // Every message status change writes a trail entry and wakes the
@@ -2095,7 +2178,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
2095
2178
 
2096
2179
  initialStatus ??= view.record.status;
2097
2180
 
2098
- const remaining = deadline - Date.now();
2181
+ const remaining = deadline - clock.now();
2099
2182
 
2100
2183
  if (
2101
2184
  view.record.status !== initialStatus ||
@@ -2139,6 +2222,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
2139
2222
  // Releases everything the daemon holds except its listeners, on a stop
2140
2223
  // and on a start that fails once it holds the lock.
2141
2224
  const releaseResources = async () => {
2225
+ released = true;
2226
+
2142
2227
  clearInterval(idempotencySweep);
2143
2228
  eventsServer?.stop();
2144
2229
  reporter.stop(true);
@@ -2206,9 +2291,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
2206
2291
  },
2207
2292
  closeConnection: detachConnection,
2208
2293
  log: listenerLog.log,
2209
- now: opts.listen.now ?? Date.now,
2210
2294
  refusalLogIntervalMs: opts.listen.refusalLogIntervalMs ?? REFUSAL_LOG_INTERVAL_MS,
2211
2295
  maxRefusalWindows: opts.listen.maxRefusalWindows ?? MAX_REFUSAL_WINDOWS,
2296
+ clock,
2212
2297
  });
2213
2298
  } catch (error) {
2214
2299
  await releaseResources();
@@ -2262,22 +2347,41 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
2262
2347
  );
2263
2348
  };
2264
2349
 
2350
+ // The stop under way or done, which a quit, a second stop, and a disposal
2351
+ // all wait on instead of releasing anything twice; a stop that fails is
2352
+ // forgotten, so the next one retries the release.
2353
+ let stopping: Promise<void> | null = null;
2354
+
2265
2355
  stopDaemon = async () => {
2266
- // Ends each client itself so every peer sees the close: a stopped
2267
- // listener does not reliably end the connections it already accepted.
2268
- for (const client of clients) {
2269
- client.dispose();
2270
- }
2356
+ stopping ??= (async () => {
2357
+ // Ends each client itself so every peer sees the close: a stopped
2358
+ // listener does not reliably end the connections it already accepted.
2359
+ for (const client of clients) {
2360
+ client.dispose();
2361
+ }
2362
+
2363
+ tcpListener?.stop();
2364
+ server.stop(true);
2271
2365
 
2272
- tcpListener?.stop();
2273
- server.stop(true);
2366
+ await releaseResources();
2367
+
2368
+ // The stopped listener logs no more lines, so this writes the ones the
2369
+ // log still holds, waiting no longer than the timeout when nothing
2370
+ // reads stderr.
2371
+ await listenerLog?.drain(LOG_DRAIN_TIMEOUT_MS);
2372
+ })();
2274
2373
 
2275
- await releaseResources();
2374
+ const current = stopping;
2276
2375
 
2277
- // The stopped listener logs no more lines, so this writes the ones the
2278
- // log still holds, waiting no longer than the timeout when nothing reads
2279
- // stderr.
2280
- await listenerLog?.drain(LOG_DRAIN_TIMEOUT_MS);
2376
+ try {
2377
+ await current;
2378
+ } catch (error) {
2379
+ if (stopping === current) {
2380
+ stopping = null;
2381
+ }
2382
+
2383
+ throw error;
2384
+ }
2281
2385
  };
2282
2386
 
2283
2387
  writeDaemonRecord(recordPath, {
@@ -2306,12 +2410,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
2306
2410
 
2307
2411
  if (stored.length > 0) {
2308
2412
  void tryRunFleetRestore();
2413
+ } else {
2414
+ opts.onRestoreSkipped?.('empty');
2309
2415
  }
2416
+ } else {
2417
+ opts.onRestoreSkipped?.('disabled');
2310
2418
  }
2311
2419
 
2312
2420
  return {
2313
2421
  stop: stopDaemon,
2422
+ [Symbol.asyncDispose]: stopDaemon,
2314
2423
  countClients: () => clients.size,
2424
+ countEventWaiters: () => eventSignal.countWaiters(),
2315
2425
  listenPort: tcpListener?.port ?? null,
2316
2426
  refreshTokens,
2317
2427
  };
@@ -1,3 +1,6 @@
1
+ import { systemClock } from '../shared/system-clock';
2
+ import type { Clock } from '../shared/system-clock';
3
+
1
4
  /**
2
5
  * Wakes long-polling readers when the event trail grows. A reader notes the
3
6
  * signal's count before it queries and waits from that count, so an event
@@ -10,6 +13,13 @@ export class EventSignal {
10
13
 
11
14
  private readonly waiters = new Set<() => void>();
12
15
 
16
+ private readonly clock: Clock;
17
+
18
+ // The clock that times each wait's timeout.
19
+ constructor(clock: Clock = systemClock) {
20
+ this.clock = clock;
21
+ }
22
+
13
23
  emit(): void {
14
24
  this.generation += 1;
15
25
 
@@ -23,20 +33,26 @@ export class EventSignal {
23
33
 
24
34
  const deferred = Promise.withResolvers<void>();
25
35
 
26
- const onWake = () => {
27
- clearTimeout(timer);
36
+ // Holds the timer's cancel once it is scheduled.
37
+ let cancel: (() => void) | null = null;
28
38
 
39
+ const onWake = () => {
40
+ cancel?.();
29
41
  this.waiters.delete(onWake);
30
42
  deferred.resolve();
31
43
  };
32
44
 
33
- const timer = setTimeout(onWake, timeoutMs);
45
+ cancel = this.clock.schedule(onWake, timeoutMs);
34
46
 
35
47
  this.waiters.add(onWake);
36
48
 
37
49
  return deferred.promise;
38
50
  }
39
51
 
52
+ countWaiters(): number {
53
+ return this.waiters.size;
54
+ }
55
+
40
56
  dispose(): void {
41
57
  this.disposed = true;
42
58
  this.generation += 1;