@zgeoff/atc 2.15.0 → 2.15.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 (67) hide show
  1. package/package.json +2 -1
  2. package/src/agents/agent-adapter.ts +3 -38
  3. package/src/agents/build-hook-settings.ts +1 -1
  4. package/src/agents/claude-adapter.ts +3 -3
  5. package/src/agents/codex-adapter.ts +3 -3
  6. package/src/agents/gateway-adapter.ts +3 -3
  7. package/src/agents/grok-adapter.ts +3 -3
  8. package/src/agents/start-claude-headless-run.ts +1 -1
  9. package/src/client/boot-daemon.ts +2 -2
  10. package/src/client/collect-agent-picks.ts +1 -1
  11. package/src/client/daemon-client.ts +2 -1
  12. package/src/client/format-overlay-agent-mark.ts +1 -1
  13. package/src/client/index.ts +4 -2
  14. package/src/client/pick-tab-target.ts +2 -2
  15. package/src/client/spawn-picker.ts +1 -1
  16. package/src/client/to-mirror-session.ts +3 -3
  17. package/src/client/ui.ts +3 -3
  18. package/src/daemon/build-fleet-events.ts +10 -4
  19. package/src/daemon/build-grant-from-fleet-entry.ts +14 -0
  20. package/src/daemon/build-message-trail-entry.ts +1 -1
  21. package/src/daemon/build-report-trail-entry.ts +1 -1
  22. package/src/daemon/build-report-view.ts +10 -4
  23. package/src/daemon/build-scoped-context.ts +64 -77
  24. package/src/daemon/build-session-message-event.ts +1 -1
  25. package/src/daemon/daemon-connection.ts +206 -288
  26. package/src/daemon/daemon-context.ts +291 -0
  27. package/src/daemon/daemon.ts +250 -94
  28. package/src/daemon/drain-inbox.ts +85 -0
  29. package/src/daemon/execution-provider.ts +12 -0
  30. package/src/daemon/hooks.ts +1 -7
  31. package/src/daemon/idempotency-ledger.ts +78 -18
  32. package/src/daemon/imp-harness.ts +41 -0
  33. package/src/daemon/is-tree-in-reach.ts +32 -0
  34. package/src/daemon/local-pty-provider.ts +45 -0
  35. package/src/daemon/parse-hook-line.ts +1 -1
  36. package/src/daemon/sessions.ts +175 -123
  37. package/src/daemon/start-headless-turn.ts +3 -3
  38. package/src/daemon/start-session-bridge.ts +2 -2
  39. package/src/daemon/tap-registry.ts +43 -5
  40. package/src/mcp/collect-clients.ts +1 -1
  41. package/src/mcp/collect-grants.ts +1 -1
  42. package/src/mcp/find-client-name.ts +1 -1
  43. package/src/mcp/mcp-auth-schema.ts +37 -0
  44. package/src/mcp/open-mcp-auth.ts +2 -2
  45. package/src/mcp/reconnecting-caller.ts +15 -6
  46. package/src/mcp/remove-client.ts +1 -1
  47. package/src/mcp/revoke-grant.ts +1 -1
  48. package/src/mcp/types.ts +0 -38
  49. package/src/mcp-http-server.ts +2 -1
  50. package/src/protocol/adapter-event.ts +25 -0
  51. package/src/protocol/count-session-states.ts +16 -0
  52. package/src/protocol/daemon-channel.ts +17 -0
  53. package/src/protocol/hook-event.ts +11 -0
  54. package/src/protocol/pinned-group-key.ts +2 -0
  55. package/src/protocol/session-state.ts +5 -0
  56. package/src/protocol/sort-grouped-session-views.ts +29 -0
  57. package/src/protocol/sort-session-views.ts +41 -0
  58. package/src/protocol/sortable-session-view.ts +13 -0
  59. package/src/shared/agent-id.ts +6 -0
  60. package/src/shared/collect-gateways.ts +1 -1
  61. package/src/shared/to-agent-id.ts +11 -0
  62. package/src/store/fleet-entry.ts +2 -2
  63. package/src/store/run-migrations.ts +55 -4
  64. package/src/store/state-store.ts +49 -13
  65. /package/src/{store → shared}/bun-sqlite-driver.ts +0 -0
  66. /package/src/{agents → shared}/truncate-detail.ts +0 -0
  67. /package/src/{daemon → shared}/truncate-summary.ts +0 -0
@@ -0,0 +1,85 @@
1
+ import { PROTOCOL_V } from '../protocol/protocol';
2
+ import type { SessionID } from '../shared/session-id';
3
+ import type { MessageOwner } from '../store/message-owner';
4
+ import type { MessageRecord } from '../store/message-record';
5
+ import type { TapClient } from './daemon-context';
6
+ import type { TapGeneration, TapRegistry } from './tap-registry';
7
+
8
+ /**
9
+ * What a drain reads from: the taps, the owner a linked tap reads messages
10
+ * under (the session's atc id and agent session id, or null for a session
11
+ * the daemon does not hold), and the store's pending messages for an owner.
12
+ */
13
+ export interface InboxSource {
14
+ readonly taps: TapRegistry<TapClient>;
15
+ readonly findLinkedOwner: (sessionID: SessionID) => MessageOwner | null;
16
+ readonly collectPendingMessages: (owner: MessageOwner) => Promise<MessageRecord[]>;
17
+ }
18
+
19
+ /**
20
+ * Hands the session's tap the oldest pending message it has not taken. The
21
+ * backlog is read before anything is sent, so a tap's ok response is always
22
+ * queued ahead of its first message, and one message goes per call: the
23
+ * tap's ack calls it again, so the backlog never outgrows the connection's
24
+ * outbound queue. The drain is bound to the tap it found before the read: a
25
+ * tap another attach made while the read waited gets nothing from it.
26
+ */
27
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- the drain claims deliveries in the tap registry it is given
28
+ export async function drainInbox(sessionID: SessionID, source: InboxSource): Promise<void> {
29
+ const linkedOwner = source.findLinkedOwner(sessionID);
30
+ const tap = source.taps.findTap(sessionID);
31
+
32
+ if (linkedOwner === null || tap === null) {
33
+ return;
34
+ }
35
+
36
+ // A tap a principal holds takes only the messages sent to this atc id:
37
+ // another session that shares the agent session id may be out of its
38
+ // reach.
39
+ const owner = tap.linked ? linkedOwner : { atcID: linkedOwner.atcID };
40
+
41
+ try {
42
+ const pending = await source.collectPendingMessages(owner);
43
+
44
+ for (const record of pending) {
45
+ if (sendInboxMessage(sessionID, record, tap, source.taps)) {
46
+ return;
47
+ }
48
+ }
49
+ } catch {}
50
+ }
51
+
52
+ // Hands one pending message to the tap the drain was made for, once, and
53
+ // reports whether it did. An unlinked tap takes only a message sent to the
54
+ // session's own atc id. The event goes to the tap connection alone: it never
55
+ // reaches other clients, the events socket, or hooks.
56
+ function sendInboxMessage(
57
+ sessionID: SessionID,
58
+ record: MessageRecord,
59
+ tap: TapGeneration,
60
+
61
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a delivery claims the message in the registry
62
+ taps: TapRegistry<TapClient>,
63
+ ): boolean {
64
+ if (!tap.linked && record.atcID !== sessionID) {
65
+ return false;
66
+ }
67
+
68
+ const client = taps.claimDelivery(sessionID, record.id, tap.generation);
69
+
70
+ if (client === null) {
71
+ return false;
72
+ }
73
+
74
+ client.sendEvent({
75
+ v: PROTOCOL_V,
76
+ ev: 'InboxMessage',
77
+ s: sessionID,
78
+ message: record.id,
79
+ from: record.from,
80
+ text: record.text,
81
+ sentAt: record.sentAt,
82
+ });
83
+
84
+ return true;
85
+ }
@@ -167,8 +167,20 @@ export interface HarnessHandle {
167
167
  ) => HarnessSubscription;
168
168
  readonly write: (data: string) => void;
169
169
  readonly resize: (cols: number, rows: number) => void;
170
+
171
+ // Sends the harness's process the signal that ends it, and returns without
172
+ // waiting for it to exit.
170
173
  readonly kill: () => void;
171
174
 
175
+ // Ends the harness's process with a signal it cannot catch or ignore, on
176
+ // a provider that can send one; absent on a provider that cannot.
177
+ readonly killForced?: () => void;
178
+
179
+ // Resolves true once the harness's process has exited, and false when the
180
+ // wait runs out first or the harness stops being followed without an
181
+ // exit, such as a host that went to sleep with the process inside.
182
+ readonly waitForExit: (timeoutMs: number) => Promise<boolean>;
183
+
172
184
  // Stops following the harness and leaves its process running, for a host
173
185
  // that keeps it after the daemon lets go; on a host that cannot keep it,
174
186
  // the process ends as a kill ends it. No listener fires after a detach.
@@ -1,15 +1,9 @@
1
1
  import { unlinkSync } from 'node:fs';
2
+ import type { HookEvent } from '../protocol/hook-event';
2
3
  import { LineDecoder } from '../protocol/line-decoder';
3
4
  import { socketPath } from '../shared/config';
4
- import type { SessionID } from '../shared/session-id';
5
5
  import { parseHookLine } from './parse-hook-line';
6
6
 
7
- export interface HookEvent {
8
- atcId: SessionID;
9
- event: string;
10
- payload: Record<string, unknown>;
11
- }
12
-
13
7
  // Per-connection read state: the connection's own line framing.
14
8
  interface HookConnection {
15
9
  readonly lines: LineDecoder;
@@ -23,7 +23,9 @@ interface IdempotentCall<T> {
23
23
  // effect may still stand, so the claim is kept as outcome_unknown.
24
24
  readonly start: () => T | Promise<T>;
25
25
 
26
- // Resolves once the effect is durable; the claim completes only after.
26
+ // Resolves once the effect is durable; the claim completes only after. A
27
+ // settle that throws leaves the effect standing, so the answer is
28
+ // outcome_unknown and the claim stays held.
27
29
  readonly settle: () => Promise<void>;
28
30
 
29
31
  // The answer to a retry of a completed key.
@@ -43,19 +45,27 @@ interface IdempotentCall<T> {
43
45
  * answer; one with a different payload is `idempotency_conflict`; one whose
44
46
  * effect a stopped daemon may or may not have run is `outcome_unknown` with
45
47
  * the effect id in `data.effectRef`, and never starts the effect again.
48
+ * An effect that may still stand once its request fails is answered with
49
+ * `outcome_unknown` and its effect id even when recording that outcome
50
+ * fails: the claim stays held either way, and the failed write goes to the
51
+ * log.
46
52
  */
47
53
  export class IdempotencyLedger {
48
54
  private readonly store: StateStore;
49
55
 
50
56
  private readonly principal: string;
51
57
 
58
+ // Where a failed write to the ledger is reported, one line at a time.
59
+ private readonly log: (line: string) => void;
60
+
52
61
  // One promise chain per key, so two requests under the same key never
53
62
  // interleave their claim and completion on one daemon.
54
63
  private readonly locks = new Map<string, Promise<void>>();
55
64
 
56
- constructor(store: StateStore, principal: string) {
65
+ constructor(store: StateStore, principal: string, log: (line: string) => void) {
57
66
  this.store = store;
58
67
  this.principal = principal;
68
+ this.log = log;
59
69
  }
60
70
 
61
71
  async run<T extends Readonly<Record<string, unknown>>>(call: IdempotentCall<T>): Promise<T> {
@@ -114,13 +124,11 @@ export class IdempotencyLedger {
114
124
  result = await call.start();
115
125
  } catch (error) {
116
126
  if (error instanceof EffectRemainsError) {
117
- await this.store.updateIdempotencyOutcomeUnknown(id, Date.now());
127
+ this.logFailure(call, 'start', error);
128
+
129
+ await this.tryRecordOutcomeUnknown(call, id);
118
130
 
119
- throw new DaemonError(
120
- 'outcome_unknown',
121
- `the ${call.operation} under idempotency key '${call.keyed.key}' failed and its effect may still stand; check ${call.effectRef} before retrying under a new key`,
122
- { effectRef: call.effectRef },
123
- );
131
+ throw buildOutcomeUnknownError(call, 'failed');
124
132
  }
125
133
 
126
134
  await this.store.removeIdempotencyKey(id);
@@ -128,20 +136,61 @@ export class IdempotencyLedger {
128
136
  throw error;
129
137
  }
130
138
 
131
- // A settle that fails leaves the claim in progress: the effect started,
132
- // so the next daemon start marks its outcome unknown rather than letting
133
- // a retry run it again.
134
- await call.settle();
139
+ // From here the effect stands. A settle or completion that fails keeps
140
+ // the claim in progress, so a retry answers outcome_unknown rather than
141
+ // running the effect again.
142
+ try {
143
+ await call.settle();
144
+ } catch (error) {
145
+ this.logFailure(call, 'settle', error);
146
+
147
+ await this.tryRecordOutcomeUnknown(call, id);
135
148
 
136
- await this.store.updateIdempotencyCompleted(
137
- id,
138
- JSON.stringify(result),
139
- Date.now(),
140
- call.findEffectTarget?.(result) ?? null,
141
- );
149
+ throw buildOutcomeUnknownError(call, 'could not be made durable');
150
+ }
151
+
152
+ try {
153
+ await this.store.updateIdempotencyCompleted(
154
+ id,
155
+ JSON.stringify(result),
156
+ Date.now(),
157
+ call.findEffectTarget?.(result) ?? null,
158
+ );
159
+ } catch (error) {
160
+ this.logFailure(call, 'completion write', error);
161
+ throw buildOutcomeUnknownError(call, 'could not be recorded as completed');
162
+ }
142
163
 
143
164
  return result;
144
165
  }
166
+
167
+ // A claim this write fails to update stays in progress, which a retry
168
+ // answers the same way, so the failure is logged and not thrown.
169
+ private async tryRecordOutcomeUnknown(
170
+ call: Pick<IdempotentCall<unknown>, 'operation' | 'keyed' | 'effectRef'>,
171
+ id: Pick<IdempotencyRecord, 'principal' | 'operation' | 'key'>,
172
+ ): Promise<void> {
173
+ try {
174
+ await this.store.updateIdempotencyOutcomeUnknown(id, Date.now());
175
+ } catch (error) {
176
+ this.logFailure(call, 'outcome_unknown write', error);
177
+ }
178
+ }
179
+
180
+ // The line holds the operation, key, effect id, the step that failed, and
181
+ // the error, never the request's payload or the effect's result.
182
+ private logFailure(
183
+ call: Pick<IdempotentCall<unknown>, 'operation' | 'keyed' | 'effectRef'>,
184
+ step: string,
185
+ error: unknown,
186
+ ): void {
187
+ const cause =
188
+ error instanceof Error && error.cause instanceof Error ? ` (${String(error.cause)})` : '';
189
+
190
+ this.log(
191
+ `atc ${call.operation} under idempotency key '${call.keyed.key}' (effect ${call.effectRef}): ${step} failed: ${String(error)}${cause}`,
192
+ );
193
+ }
145
194
  }
146
195
 
147
196
  function answerHeldKey<T>(call: IdempotentCall<T>, held: IdempotencyRecord): T | Promise<T> {
@@ -162,3 +211,14 @@ function answerHeldKey<T>(call: IdempotentCall<T>, held: IdempotencyRecord): T |
162
211
  { effectRef: held.effectRef },
163
212
  );
164
213
  }
214
+
215
+ function buildOutcomeUnknownError(
216
+ call: Pick<IdempotentCall<unknown>, 'operation' | 'keyed' | 'effectRef'>,
217
+ what: string,
218
+ ): DaemonError {
219
+ return new DaemonError(
220
+ 'outcome_unknown',
221
+ `the ${call.operation} under idempotency key '${call.keyed.key}' ${what} and its effect may still stand; check ${call.effectRef} before retrying under a new key`,
222
+ { effectRef: call.effectRef },
223
+ );
224
+ }
@@ -99,6 +99,15 @@ export class ImpHarness implements HarnessHandle {
99
99
 
100
100
  private killPending = false;
101
101
 
102
+ // Whether the process exited, once the harness stops being followed, and
103
+ // the waits on that answer until then.
104
+ private exitConfirmed: boolean | null = null;
105
+
106
+ private readonly exitWaiters = new Set<{
107
+ readonly waited: PromiseWithResolvers<boolean>;
108
+ readonly timer: ReturnType<typeof setTimeout>;
109
+ }>();
110
+
102
111
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
103
112
 
104
113
  // The terminal size the open connection's request asked for.
@@ -175,6 +184,28 @@ export class ImpHarness implements HarnessHandle {
175
184
  }
176
185
  };
177
186
 
187
+ // Only an exit impd reports counts: a host that lost the process or went
188
+ // to sleep with it inside confirms nothing.
189
+ readonly waitForExit = (timeoutMs: number): Promise<boolean> => {
190
+ if (this.exitConfirmed !== null) {
191
+ return Promise.resolve(this.exitConfirmed);
192
+ }
193
+
194
+ const waited = Promise.withResolvers<boolean>();
195
+
196
+ const waiter = {
197
+ waited,
198
+ timer: setTimeout(() => {
199
+ this.exitWaiters.delete(waiter);
200
+ waited.resolve(false);
201
+ }, timeoutMs),
202
+ };
203
+
204
+ this.exitWaiters.add(waiter);
205
+
206
+ return waited.promise;
207
+ };
208
+
178
209
  readonly detach = (): void => {
179
210
  if (this.done) {
180
211
  return;
@@ -521,6 +552,8 @@ export class ImpHarness implements HarnessHandle {
521
552
  private emitExit(exit: HarnessExit): void {
522
553
  const listeners = [...this.exitListeners];
523
554
 
555
+ this.exitConfirmed = exit.reason === undefined || exit.reason === 'exited';
556
+
524
557
  this.stopFollowing();
525
558
 
526
559
  for (const listener of listeners) {
@@ -537,7 +570,15 @@ export class ImpHarness implements HarnessHandle {
537
570
 
538
571
  this.done = true;
539
572
  this.connection = null;
573
+ this.exitConfirmed ??= false;
574
+
575
+ for (const waiter of this.exitWaiters) {
576
+ clearTimeout(waiter.timer);
577
+
578
+ waiter.waited.resolve(this.exitConfirmed);
579
+ }
540
580
 
581
+ this.exitWaiters.clear();
541
582
  this.dataListeners.clear();
542
583
  this.exitListeners.clear();
543
584
  this.attachmentListeners.clear();
@@ -0,0 +1,32 @@
1
+ import type { SessionID } from '../shared/session-id';
2
+ import type { TargetAccess, TargetGrant } from './target-access';
3
+
4
+ // A session as the tree check reads it: its id, its parent if it has one,
5
+ // and the target it is bound to.
6
+ interface TreeMember extends TargetGrant {
7
+ readonly id: SessionID;
8
+ readonly parent: SessionID | null;
9
+ }
10
+
11
+ /**
12
+ * Whether the access reaches every session in the tree the given session
13
+ * belongs to: its top-level session and each sub-session of that one. A
14
+ * session the list does not hold, or one whose parent it does not hold,
15
+ * is out of reach.
16
+ */
17
+ export function isTreeInReach(
18
+ sessions: readonly TreeMember[],
19
+ id: SessionID,
20
+ access: TargetAccess,
21
+ ): boolean {
22
+ const session = sessions.find((x) => x.id === id);
23
+
24
+ if (session === undefined) {
25
+ return false;
26
+ }
27
+
28
+ const root = session.parent ?? session.id;
29
+ const tree = sessions.filter((x) => x.id === root || x.parent === root);
30
+
31
+ return tree.some((x) => x.id === root) && tree.every((x) => access.canUse(x));
32
+ }
@@ -74,6 +74,35 @@ export class LocalPTYProvider implements ExecutionProvider {
74
74
  kill: () => {
75
75
  pty.kill();
76
76
  },
77
+
78
+ // A process already gone has nothing left to end.
79
+ killForced: () => {
80
+ try {
81
+ process.kill(pty.pid, 'SIGKILL');
82
+ } catch (error) {
83
+ if (!isMissingProcessError(error)) {
84
+ throw error;
85
+ }
86
+ }
87
+ },
88
+
89
+ // bun-pty's kill sends one SIGHUP and reports an exit at once, whether
90
+ // the process ended or not, so the exit is read from the process id
91
+ // instead: the library reaps its child, so the id stops answering a
92
+ // signal once the process is gone.
93
+ waitForExit: async (timeoutMs) => {
94
+ const deadline = Date.now() + timeoutMs;
95
+
96
+ while (isProcessRunning(pty.pid)) {
97
+ if (Date.now() >= deadline) {
98
+ return false;
99
+ }
100
+
101
+ await Bun.sleep(20);
102
+ }
103
+
104
+ return true;
105
+ },
77
106
  detach: () => {
78
107
  for (const subscription of subscriptions) {
79
108
  subscription.dispose();
@@ -140,3 +169,19 @@ export class LocalPTYProvider implements ExecutionProvider {
140
169
  return { exitCode, stdout, stderr };
141
170
  }
142
171
  }
172
+
173
+ // A process another user owns still runs, so only a missing process counts
174
+ // as gone.
175
+ function isProcessRunning(pid: number): boolean {
176
+ try {
177
+ process.kill(pid, 0);
178
+
179
+ return true;
180
+ } catch (error) {
181
+ return !isMissingProcessError(error);
182
+ }
183
+ }
184
+
185
+ function isMissingProcessError(error: unknown): boolean {
186
+ return error instanceof Error && 'code' in error && error.code === 'ESRCH';
187
+ }
@@ -1,6 +1,6 @@
1
+ import type { HookEvent } from '../protocol/hook-event';
1
2
  import { isRecord } from '../shared/report';
2
3
  import { toSessionID } from '../shared/to-session-id';
3
- import type { HookEvent } from './hooks';
4
4
 
5
5
  /**
6
6
  * One reporter line as a hook event, or null for a line that is not one.