@zgeoff/atc 2.10.4 → 2.12.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 (86) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/agents/agent-adapter.ts +72 -0
  4. package/src/agents/atc-bridge-files.ts +1 -1
  5. package/src/agents/build-args-without-flags.ts +25 -0
  6. package/src/agents/build-claude-override-args.ts +26 -0
  7. package/src/agents/claude-adapter.ts +52 -1
  8. package/src/agents/claude-effort-levels.ts +5 -0
  9. package/src/agents/codex-adapter.ts +46 -1
  10. package/src/agents/find-flag-value.ts +27 -0
  11. package/src/agents/gateway-adapter.ts +75 -1
  12. package/src/agents/grok-adapter.ts +22 -0
  13. package/src/cli.ts +22 -0
  14. package/src/client/boot-daemon.ts +6 -0
  15. package/src/client/daemon-client.ts +14 -2
  16. package/src/client/spawn-picker.ts +27 -4
  17. package/src/daemon/build-agent-list.ts +94 -0
  18. package/src/daemon/build-config-revision.ts +27 -0
  19. package/src/daemon/build-execution-targets.ts +34 -0
  20. package/src/daemon/build-fleet-events.ts +2 -1
  21. package/src/daemon/build-payload-hash.ts +31 -0
  22. package/src/daemon/build-scoped-context.ts +232 -0
  23. package/src/daemon/build-target-access.ts +33 -0
  24. package/src/daemon/build-target-forbidden-error.ts +13 -0
  25. package/src/daemon/build-target-identity.ts +22 -0
  26. package/src/daemon/build-target-list.ts +50 -0
  27. package/src/daemon/daemon-connection.ts +371 -99
  28. package/src/daemon/daemon.ts +537 -90
  29. package/src/daemon/effect-remains-error.ts +13 -0
  30. package/src/daemon/execution-provider.ts +103 -0
  31. package/src/daemon/find-execution-refusal.ts +104 -0
  32. package/src/daemon/idempotency-ledger.ts +164 -0
  33. package/src/daemon/local-pty-provider.ts +83 -0
  34. package/src/daemon/mint-session-id.ts +5 -5
  35. package/src/daemon/parse-report.ts +29 -3
  36. package/src/daemon/parse-spawn-overrides.ts +94 -0
  37. package/src/daemon/permission-registry.ts +14 -4
  38. package/src/daemon/restore-fleet.ts +66 -19
  39. package/src/daemon/session-runtime.ts +10 -0
  40. package/src/daemon/sessions.ts +287 -65
  41. package/src/daemon/start-headless-run.ts +11 -0
  42. package/src/daemon/start-headless-turn.ts +11 -2
  43. package/src/daemon/target-access.ts +36 -0
  44. package/src/mcp/answer-mcp-request.ts +12 -4
  45. package/src/mcp/answer-rpc-request.ts +40 -6
  46. package/src/mcp/build-principal-caller.ts +15 -0
  47. package/src/mcp/build-spawn-descriptions.ts +45 -0
  48. package/src/mcp/build-tool-list.ts +88 -7
  49. package/src/mcp/mcp-tools.ts +245 -24
  50. package/src/mcp/parse-idempotency-key.ts +29 -0
  51. package/src/mcp/reconnecting-caller.ts +77 -15
  52. package/src/mcp/require-daemon-features.ts +40 -0
  53. package/src/mcp/run-tool.ts +139 -35
  54. package/src/mcp/start-mcp-http-server.ts +130 -24
  55. package/src/mcp/types.ts +9 -1
  56. package/src/mcp-http-server.ts +6 -1
  57. package/src/mcp-server.ts +13 -1
  58. package/src/protocol/daemon-error.ts +5 -1
  59. package/src/protocol/daemon-features.ts +47 -0
  60. package/src/protocol/parse-daemon-features.ts +19 -0
  61. package/src/protocol/protocol.ts +44 -11
  62. package/src/protocol/request-param-schemas.ts +78 -12
  63. package/src/report.ts +20 -3
  64. package/src/shared/agent-session-id.ts +1 -1
  65. package/src/shared/collect-principals.ts +51 -0
  66. package/src/shared/collect-targets.ts +144 -0
  67. package/src/shared/config.ts +139 -14
  68. package/src/shared/daemon-id.ts +8 -0
  69. package/src/shared/format-json-kind.ts +21 -0
  70. package/src/shared/sort-json-keys.ts +19 -0
  71. package/src/shared/to-daemon-id.ts +11 -0
  72. package/src/store/fleet-entry.ts +42 -9
  73. package/src/store/idempotency-record.ts +48 -0
  74. package/src/store/message-owner.ts +1 -1
  75. package/src/store/message-record.ts +3 -0
  76. package/src/store/run-migrations.ts +272 -7
  77. package/src/store/state-store.ts +503 -34
  78. package/src/workspace/check-workspace-completeness.ts +90 -0
  79. package/src/workspace/create-workspace-clone.ts +239 -0
  80. package/src/workspace/normalize-git-url.ts +59 -0
  81. package/src/workspace/read-workspace-tar.ts +38 -0
  82. package/src/workspace/resolve-path-source.ts +170 -0
  83. package/src/workspace/run-git.ts +103 -0
  84. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  85. package/src/workspace/workspace-provenance.ts +11 -0
  86. package/src/workspace/workspace-source.ts +8 -0
@@ -1,19 +1,24 @@
1
1
  import { Database } from 'bun:sqlite';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { existsSync, readFileSync } from 'node:fs';
3
4
  import { Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, sql } from 'kysely';
4
- import type { Expression, ExpressionBuilder, SqlBool } from 'kysely';
5
+ import type { Expression, ExpressionBuilder, SqlBool, Transaction } from 'kysely';
5
6
  import { toAgentID } from '../agents/agent-adapter';
6
7
  import type { AdapterEvent, AgentID } from '../agents/agent-adapter';
7
8
  import type { HookEvent } from '../daemon/hooks';
9
+ import { DaemonError } from '../protocol/daemon-error';
8
10
  import type { AgentSessionID } from '../shared/agent-session-id';
11
+ import type { DaemonID } from '../shared/daemon-id';
9
12
  import type { MessageID } from '../shared/message-id';
10
13
  import type { SessionID } from '../shared/session-id';
11
14
  import { toAgentSessionID } from '../shared/to-agent-session-id';
15
+ import { toDaemonID } from '../shared/to-daemon-id';
12
16
  import { toMessageID } from '../shared/to-message-id';
13
17
  import { toSessionID } from '../shared/to-session-id';
14
18
  import { BunSqliteDriver } from './bun-sqlite-driver';
15
19
  import { parseFleetEntry } from './fleet-entry';
16
- import type { FleetEntry, FleetEntryUpdate } from './fleet-entry';
20
+ import type { FleetEntry, FleetEntryUpdate, LegacyFleetEntry } from './fleet-entry';
21
+ import type { EffectTarget, IdempotencyClaim, IdempotencyRecord } from './idempotency-record';
17
22
  import type { MessageOwner } from './message-owner';
18
23
  import type { MessageRecord } from './message-record';
19
24
  import { runMigrations } from './run-migrations';
@@ -23,6 +28,17 @@ import type { TrailEntry } from './trail-entry';
23
28
  // Spelled as the partial index's predicate so SQLite can match them.
24
29
  const TRAIL_FILTER = sql<boolean>`kind IS NOT NULL AND kind != 'heartbeat'`;
25
30
 
31
+ /**
32
+ * Some sessions' slice of the event trail: rows under their atc ids, plus
33
+ * rows under their agent session ids, since rows written before atc session
34
+ * ids stayed stable across restores carry an earlier atc id. A scope of no
35
+ * sessions matches no row.
36
+ */
37
+ export interface EventScope {
38
+ readonly atcIDs: readonly SessionID[];
39
+ readonly agentSessionIDs: readonly AgentSessionID[];
40
+ }
41
+
26
42
  export interface StoredEvent {
27
43
  readonly id: number;
28
44
 
@@ -60,9 +76,14 @@ export class StateStore {
60
76
 
61
77
  private readonly db: Kysely<StateStoreSchema>;
62
78
 
63
- private constructor(sqlite: Database, db: Kysely<StateStoreSchema>) {
79
+ // The daemon this store's fleet rows belong to, minted once by the
80
+ // migration that created session ownership.
81
+ readonly daemonID: DaemonID;
82
+
83
+ private constructor(sqlite: Database, db: Kysely<StateStoreSchema>, daemonID: DaemonID) {
64
84
  this.sqlite = sqlite;
65
85
  this.db = db;
86
+ this.daemonID = daemonID;
66
87
  }
67
88
 
68
89
  // Migrations run statements that cannot happen inside a constructor, so
@@ -83,7 +104,13 @@ export class StateStore {
83
104
 
84
105
  await runMigrations(db);
85
106
 
86
- const store = new StateStore(sqlite, db);
107
+ const daemonIDRow = await db
108
+ .selectFrom('prefs')
109
+ .select('value')
110
+ .where('key', '=', 'daemon_id')
111
+ .executeTakeFirstOrThrow();
112
+
113
+ const store = new StateStore(sqlite, db, toDaemonID(daemonIDRow.value));
87
114
 
88
115
  if (legacyFleetPath !== undefined) {
89
116
  await store.adoptLegacyFleet(legacyFleetPath);
@@ -92,10 +119,19 @@ export class StateStore {
92
119
  return store;
93
120
  }
94
121
 
122
+ // Only the rows this daemon owns: another daemon's sessions are its own
123
+ // to restore.
95
124
  async loadFleet(): Promise<FleetEntry[]> {
96
125
  const rows = await this.db
97
126
  .selectFrom('fleet')
127
+ .where('session_id', 'in', (eb) =>
128
+ eb.selectFrom('session_owner').select('session_id').where('daemon_id', '=', this.daemonID),
129
+ )
130
+
131
+ // Stored order, which a restore keeps for sessions with no recency.
132
+ .orderBy(sql`rowid`)
98
133
  .select([
134
+ 'session_id',
99
135
  'agent_session_id',
100
136
  'name',
101
137
  'cwd',
@@ -103,10 +139,14 @@ export class StateStore {
103
139
  'last_attached',
104
140
  'agent',
105
141
  'exited',
106
- 'parent',
142
+ 'parent_session_id',
107
143
  'prompt',
108
144
  'result',
109
145
  'transcript_path',
146
+ 'model',
147
+ 'effort',
148
+ 'target',
149
+ 'target_identity',
110
150
  ])
111
151
  .execute();
112
152
 
@@ -114,17 +154,24 @@ export class StateStore {
114
154
 
115
155
  for (const row of rows) {
116
156
  entries.push({
117
- agentSessionID: toAgentSessionID(row.agent_session_id),
157
+ sessionID: toSessionID(row.session_id),
118
158
  name: row.name,
119
159
  cwd: row.cwd,
160
+ ...(row.agent_session_id === null
161
+ ? {}
162
+ : { agentSessionID: toAgentSessionID(row.agent_session_id) }),
120
163
  agent: toAgentID(row.agent),
121
164
  ...(row.pinned === 0 ? {} : { pinned: true }),
122
165
  ...(row.last_attached === null ? {} : { lastAttachedAt: row.last_attached }),
123
166
  ...(row.exited === 0 ? {} : { exited: true }),
124
- ...(row.parent === null ? {} : { parent: toAgentSessionID(row.parent) }),
167
+ ...(row.parent_session_id === null ? {} : { parent: toSessionID(row.parent_session_id) }),
125
168
  ...(row.prompt === null ? {} : { prompt: row.prompt }),
126
169
  ...(row.result === null ? {} : { result: row.result }),
127
170
  ...(row.transcript_path === null ? {} : { transcriptPath: row.transcript_path }),
171
+ ...(row.model === null ? {} : { model: row.model }),
172
+ ...(row.effort === null ? {} : { effort: row.effort }),
173
+ ...(row.target === null ? {} : { target: row.target }),
174
+ ...(row.target_identity === null ? {} : { targetIdentity: row.target_identity }),
128
175
  });
129
176
  }
130
177
 
@@ -154,27 +201,65 @@ export class StateStore {
154
201
  return recency;
155
202
  }
156
203
 
204
+ // Replaces this daemon's rows wholesale and leaves every other daemon's
205
+ // rows as they stand. A session another daemon owns, or one whose stored
206
+ // ownership epoch has moved past this daemon's, rejects the whole write
207
+ // with stale_epoch.
157
208
  async writeFleet(entries: readonly FleetEntry[]): Promise<void> {
158
- await this.db.transaction().execute(async (trx) => {
159
- await trx.deleteFrom('fleet').execute();
209
+ const kept = buildFleetWithoutReplacedRows(entries);
160
210
 
161
- for (const entry of entries) {
211
+ await this.db.transaction().execute(async (trx) => {
212
+ await requireCurrentOwnership(
213
+ trx,
214
+ this.daemonID,
215
+ entries.map((entry) => entry.sessionID),
216
+ );
217
+
218
+ await trx
219
+ .deleteFrom('fleet')
220
+ .where('session_id', 'in', (eb) =>
221
+ eb
222
+ .selectFrom('session_owner')
223
+ .select('session_id')
224
+ .where('daemon_id', '=', this.daemonID),
225
+ )
226
+ .execute();
227
+
228
+ await trx.deleteFrom('session_owner').where('daemon_id', '=', this.daemonID).execute();
229
+
230
+ const updatedAt = Date.now();
231
+
232
+ for (const entry of kept) {
162
233
  await trx
163
234
  .insertInto('fleet')
164
235
  .values({
165
- agent_session_id: entry.agentSessionID,
236
+ session_id: entry.sessionID,
237
+ agent_session_id: entry.agentSessionID ?? null,
166
238
  name: entry.name,
167
239
  cwd: entry.cwd,
168
240
  pinned: entry.pinned === true ? 1 : 0,
169
241
  last_attached: entry.lastAttachedAt ?? null,
170
242
  agent: entry.agent,
171
243
  exited: entry.exited === true ? 1 : 0,
172
- parent: entry.parent ?? null,
244
+ parent_session_id: entry.parent ?? null,
173
245
  prompt: entry.prompt ?? null,
174
246
  result: entry.result ?? null,
175
247
  transcript_path: entry.transcriptPath ?? null,
248
+ model: entry.model ?? null,
249
+ effort: entry.effort ?? null,
250
+ target: entry.target ?? null,
251
+ target_identity: entry.targetIdentity ?? null,
252
+ })
253
+ .execute();
254
+
255
+ await trx
256
+ .insertInto('session_owner')
257
+ .values({
258
+ session_id: entry.sessionID,
259
+ daemon_id: this.daemonID,
260
+ owner_epoch: OWNER_EPOCH,
261
+ updated_at: updatedAt,
176
262
  })
177
- .orReplace()
178
263
  .execute();
179
264
  }
180
265
  });
@@ -183,7 +268,7 @@ export class StateStore {
183
268
  // Rewrites fields on one existing row and leaves every other row as it
184
269
  // stands. A session without a row yet is a no-op: the next fleet write
185
270
  // inserts it with these fields.
186
- async updateFleetEntry(agentSessionID: AgentSessionID, fields: FleetEntryUpdate): Promise<void> {
271
+ async updateFleetEntry(sessionID: SessionID, fields: FleetEntryUpdate): Promise<void> {
187
272
  const values = {
188
273
  ...(fields.result === undefined ? {} : { result: fields.result }),
189
274
  ...(fields.transcriptPath === undefined ? {} : { transcript_path: fields.transcriptPath }),
@@ -193,11 +278,11 @@ export class StateStore {
193
278
  return;
194
279
  }
195
280
 
196
- await this.db
197
- .updateTable('fleet')
198
- .set(values)
199
- .where('agent_session_id', '=', agentSessionID)
200
- .execute();
281
+ await this.db.transaction().execute(async (trx) => {
282
+ await requireCurrentOwnership(trx, this.daemonID, [sessionID]);
283
+
284
+ await trx.updateTable('fleet').set(values).where('session_id', '=', sessionID).execute();
285
+ });
201
286
  }
202
287
 
203
288
  async recordEvent(e: HookEvent, ev: Readonly<AdapterEvent> | null = null): Promise<void> {
@@ -238,12 +323,17 @@ export class StateStore {
238
323
  .execute();
239
324
  }
240
325
 
241
- async collectEventsAfter(afterID: number, limit: number): Promise<StoredEvent[]> {
326
+ async collectEventsAfter(
327
+ afterID: number,
328
+ limit: number,
329
+ scope: EventScope | null = null,
330
+ ): Promise<StoredEvent[]> {
242
331
  const rows = await this.db
243
332
  .selectFrom('events')
244
333
  .select(['id', 'ts', 'atc_id', 'session_id', 'kind', 'detail', 'message'])
245
334
  .where('id', '>', afterID)
246
335
  .where(TRAIL_FILTER)
336
+ .where((eb) => buildScopeMatch(eb, scope))
247
337
  .orderBy('id', 'asc')
248
338
  .limit(limit)
249
339
  .execute();
@@ -251,11 +341,15 @@ export class StateStore {
251
341
  return buildStoredEvents(rows);
252
342
  }
253
343
 
254
- async collectLatestEvents(limit: number): Promise<StoredEvent[]> {
344
+ async collectLatestEvents(
345
+ limit: number,
346
+ scope: EventScope | null = null,
347
+ ): Promise<StoredEvent[]> {
255
348
  const rows = await this.db
256
349
  .selectFrom('events')
257
350
  .select(['id', 'ts', 'atc_id', 'session_id', 'kind', 'detail', 'message'])
258
351
  .where(TRAIL_FILTER)
352
+ .where((eb) => buildScopeMatch(eb, scope))
259
353
  .orderBy('id', 'desc')
260
354
  .limit(limit)
261
355
  .execute();
@@ -263,8 +357,8 @@ export class StateStore {
263
357
  return buildStoredEvents(rows).toReversed();
264
358
  }
265
359
 
266
- // Matches the session by either id, since a restore re-mints the atc id
267
- // while the agent session id carries on.
360
+ // Matches the session by either id, since rows written before atc session
361
+ // ids stayed stable across restores carry an earlier atc id.
268
362
  async loadLastActivityAt(
269
363
  atcID: SessionID,
270
364
  agentSessionID: AgentSessionID | undefined,
@@ -375,6 +469,31 @@ export class StateStore {
375
469
  return row === undefined ? null : toMessageRecord(row);
376
470
  }
377
471
 
472
+ // The other messages of the same session the given message's turn
473
+ // answered, oldest first; none when the message has no turn.
474
+ async collectTurnSiblings(record: MessageRecord): Promise<MessageID[]> {
475
+ if (record.turn === undefined) {
476
+ return [];
477
+ }
478
+
479
+ const owner: MessageOwner =
480
+ record.agentSessionID === undefined
481
+ ? { atcID: record.atcID }
482
+ : { atcID: record.atcID, agentSessionID: record.agentSessionID };
483
+
484
+ const rows = await this.db
485
+ .selectFrom('messages')
486
+ .select('id')
487
+ .where('turn_id', '=', record.turn)
488
+ .where('id', '!=', record.id)
489
+ .where((eb) => buildOwnerFilter(eb, owner))
490
+ .orderBy('sent_at', 'asc')
491
+ .orderBy(sql`rowid`, 'asc')
492
+ .execute();
493
+
494
+ return rows.map((row) => toMessageID(row.id));
495
+ }
496
+
378
497
  async updateMessageDelivered(
379
498
  id: MessageID,
380
499
  owner: MessageOwner,
@@ -392,22 +511,30 @@ export class StateStore {
392
511
  return row === undefined ? null : toMessageRecord(row);
393
512
  }
394
513
 
395
- async updateMessageAnswered(
396
- id: MessageID,
514
+ // Answers every given message the owner holds unanswered in one statement,
515
+ // so a reader sees all of one turn's messages answered or none of them.
516
+ // Returns the messages it answered, oldest first.
517
+ async updateMessagesAnswered(
518
+ ids: readonly MessageID[],
397
519
  owner: MessageOwner,
398
520
  answer: string,
399
521
  at: number,
400
- ): Promise<MessageRecord | null> {
401
- const row = await this.db
522
+ turn: string | null = null,
523
+ ): Promise<MessageRecord[]> {
524
+ if (ids.length === 0) {
525
+ return [];
526
+ }
527
+
528
+ const rows = await this.db
402
529
  .updateTable('messages')
403
- .set({ status: 'answered', answered_at: at, answer })
404
- .where('id', '=', id)
530
+ .set({ status: 'answered', answered_at: at, answer, turn_id: turn })
531
+ .where('id', 'in', ids)
405
532
  .where('status', 'in', ['accepted', 'delivered'])
406
533
  .where((eb) => buildOwnerFilter(eb, owner))
407
534
  .returningAll()
408
- .executeTakeFirst();
535
+ .execute();
409
536
 
410
- return row === undefined ? null : toMessageRecord(row);
537
+ return rows.map((row) => toMessageRecord(row)).toSorted((a, b) => a.sentAt - b.sentAt);
411
538
  }
412
539
 
413
540
  // Messages sent before the agent reported its session id carry no agent
@@ -444,6 +571,145 @@ export class StateStore {
444
571
  .execute();
445
572
  }
446
573
 
574
+ // Records a key in progress unless one is already held under the same
575
+ // principal and operation. Returns null when this call claimed the key,
576
+ // else the record the key already holds.
577
+ async claimIdempotencyKey(claim: IdempotencyClaim): Promise<IdempotencyRecord | null> {
578
+ const inserted = await this.db
579
+ .insertInto('idempotency')
580
+ .values({
581
+ principal: claim.principal,
582
+ operation: claim.operation,
583
+ key: claim.key,
584
+ payload_hash: claim.payloadHash,
585
+ state: 'in_progress',
586
+ effect_ref: claim.effectRef,
587
+ result: null,
588
+ created_at: claim.at,
589
+ updated_at: claim.at,
590
+ effect_target: null,
591
+ effect_target_identity: null,
592
+ })
593
+ .onConflict((oc) => oc.columns(['principal', 'operation', 'key']).doNothing())
594
+ .returning('key')
595
+ .executeTakeFirst();
596
+
597
+ if (inserted !== undefined) {
598
+ return null;
599
+ }
600
+
601
+ const row = await this.db
602
+ .selectFrom('idempotency')
603
+ .selectAll()
604
+ .where('principal', '=', claim.principal)
605
+ .where('operation', '=', claim.operation)
606
+ .where('key', '=', claim.key)
607
+ .executeTakeFirstOrThrow();
608
+
609
+ return toIdempotencyRecord(row);
610
+ }
611
+
612
+ async updateIdempotencyCompleted(
613
+ record: Pick<IdempotencyRecord, 'principal' | 'operation' | 'key'>,
614
+ result: string,
615
+ at: number,
616
+ effectTarget: EffectTarget | null = null,
617
+ ): Promise<void> {
618
+ await this.db
619
+ .updateTable('idempotency')
620
+ .set({
621
+ state: 'completed',
622
+ result,
623
+ updated_at: at,
624
+ effect_target: effectTarget?.target ?? null,
625
+ effect_target_identity: effectTarget?.targetIdentity ?? null,
626
+ })
627
+ .where('principal', '=', record.principal)
628
+ .where('operation', '=', record.operation)
629
+ .where('key', '=', record.key)
630
+ .execute();
631
+ }
632
+
633
+ // Keeps a claim whose effect may still stand as outcome_unknown, so a
634
+ // retry answers from it and never runs the effect again.
635
+ async updateIdempotencyOutcomeUnknown(
636
+ record: Pick<IdempotencyRecord, 'principal' | 'operation' | 'key'>,
637
+ at: number,
638
+ ): Promise<void> {
639
+ await this.db
640
+ .updateTable('idempotency')
641
+ .set({ state: 'outcome_unknown', updated_at: at })
642
+ .where('principal', '=', record.principal)
643
+ .where('operation', '=', record.operation)
644
+ .where('key', '=', record.key)
645
+ .execute();
646
+ }
647
+
648
+ // Drops a claim whose effect never started, so a retry runs it fresh.
649
+ async removeIdempotencyKey(
650
+ record: Pick<IdempotencyRecord, 'principal' | 'operation' | 'key'>,
651
+ ): Promise<void> {
652
+ await this.db
653
+ .deleteFrom('idempotency')
654
+ .where('principal', '=', record.principal)
655
+ .where('operation', '=', record.operation)
656
+ .where('key', '=', record.key)
657
+ .execute();
658
+ }
659
+
660
+ // Runs once as a daemon starts, before it serves a request: every key
661
+ // still in progress belonged to a daemon that stopped mid-effect, so its
662
+ // outcome is unknown. A key whose effect left its row behind completes;
663
+ // one whose effect left no trace stays unknown.
664
+ async reconcileIdempotencyKeys(at: number): Promise<void> {
665
+ await this.db.transaction().execute(async (trx) => {
666
+ await trx
667
+ .updateTable('idempotency')
668
+ .set({ state: 'outcome_unknown', updated_at: at })
669
+ .where('state', '=', 'in_progress')
670
+ .execute();
671
+
672
+ // A spawn that completes here records the target its fleet row holds,
673
+ // as a spawn that completes as it runs does.
674
+ await trx
675
+ .updateTable('idempotency')
676
+ .set((eb) => ({
677
+ state: 'completed',
678
+ updated_at: at,
679
+ effect_target: eb
680
+ .selectFrom('fleet')
681
+ .select('fleet.target')
682
+ .whereRef('fleet.session_id', '=', 'idempotency.effect_ref'),
683
+ effect_target_identity: eb
684
+ .selectFrom('fleet')
685
+ .select('fleet.target_identity')
686
+ .whereRef('fleet.session_id', '=', 'idempotency.effect_ref'),
687
+ }))
688
+ .where('state', '=', 'outcome_unknown')
689
+ .where('operation', '=', 'session.spawn')
690
+ .where('effect_ref', 'in', (eb) => eb.selectFrom('fleet').select('session_id'))
691
+ .execute();
692
+
693
+ await trx
694
+ .updateTable('idempotency')
695
+ .set({ state: 'completed', updated_at: at })
696
+ .where('state', '=', 'outcome_unknown')
697
+ .where('operation', '=', 'session.message')
698
+ .where('effect_ref', 'in', (eb) => eb.selectFrom('messages').select('id'))
699
+ .execute();
700
+ });
701
+ }
702
+
703
+ // Only completed keys expire: a key whose outcome is unknown stays, so a
704
+ // retry of it keeps answering outcome_unknown instead of running anew.
705
+ async removeExpiredIdempotencyKeys(before: number): Promise<void> {
706
+ await this.db
707
+ .deleteFrom('idempotency')
708
+ .where('state', '=', 'completed')
709
+ .where('updated_at', '<', before)
710
+ .execute();
711
+ }
712
+
447
713
  async stop(): Promise<void> {
448
714
  await this.db.destroy();
449
715
 
@@ -467,21 +733,205 @@ export class StateStore {
467
733
  return;
468
734
  }
469
735
 
470
- const entries: FleetEntry[] = [];
736
+ const legacy: LegacyFleetEntry[] = [];
471
737
 
472
738
  for (const entry of parsed) {
473
739
  const parsedEntry = parseFleetEntry(entry);
474
740
 
475
741
  if (parsedEntry !== undefined) {
476
- entries.push(parsedEntry);
742
+ legacy.push(parsedEntry);
477
743
  }
478
744
  }
479
745
 
480
- await this.writeFleet(entries);
746
+ await this.writeFleet(buildFleetFromLegacy(legacy));
481
747
  } catch {}
482
748
  }
483
749
  }
484
750
 
751
+ // Two sessions share an agent session id when one resumes the other's agent
752
+ // session. The fleet keeps one row per agent session id, the entry written
753
+ // last, and relinks the survivors as a one-level hierarchy of rows it holds:
754
+ //
755
+ // 1. A link to a dropped row follows to the row that replaced it. A row that
756
+ // replaced its own parent follows that parent's link instead, and a link
757
+ // to a row the fleet does not hold makes the row top-level.
758
+ // 2. Where the links form a cycle, the row written first in the cycle
759
+ // becomes top-level.
760
+ // 3. A row whose parent is itself a sub-session moves up to the top-level
761
+ // row above it.
762
+ //
763
+ // Every walk takes at most one step per entry, so it ends on any input.
764
+ function buildFleetWithoutReplacedRows(entries: readonly FleetEntry[]): FleetEntry[] {
765
+ const survivors = new Map<AgentSessionID, SessionID>();
766
+
767
+ for (const entry of entries) {
768
+ if (entry.agentSessionID !== undefined) {
769
+ survivors.set(entry.agentSessionID, entry.sessionID);
770
+ }
771
+ }
772
+
773
+ const replaced = new Map<SessionID, SessionID>();
774
+
775
+ for (const entry of entries) {
776
+ const survivor =
777
+ entry.agentSessionID === undefined ? undefined : survivors.get(entry.agentSessionID);
778
+
779
+ if (survivor !== undefined && survivor !== entry.sessionID) {
780
+ replaced.set(entry.sessionID, survivor);
781
+ }
782
+ }
783
+
784
+ const kept = entries.filter((entry) => !replaced.has(entry.sessionID));
785
+
786
+ const order = new Map(kept.map((entry, index) => [entry.sessionID, index]));
787
+ const links = new Map(entries.map((entry) => [entry.sessionID, entry.parent]));
788
+
789
+ const bound = entries.length;
790
+
791
+ // The surviving row an entry's parent link resolves to, or undefined when
792
+ // it resolves to no row the fleet keeps.
793
+ const findSurvivingParent = (entry: FleetEntry): SessionID | undefined => {
794
+ let link = entry.parent;
795
+
796
+ for (let step = 0; step < bound && link !== undefined; step++) {
797
+ const target = replaced.get(link) ?? link;
798
+
799
+ if (target !== entry.sessionID) {
800
+ return order.has(target) ? target : undefined;
801
+ }
802
+
803
+ link = links.get(link);
804
+ }
805
+
806
+ return undefined;
807
+ };
808
+
809
+ const linked = new Map(kept.map((entry) => [entry.sessionID, findSurvivingParent(entry)]));
810
+
811
+ // The row written first in the cycle a walk up from this row enters, or
812
+ // undefined when the walk reaches a top-level row.
813
+ const findCycleStart = (sessionID: SessionID): SessionID | undefined => {
814
+ const seen: SessionID[] = [];
815
+ let current: SessionID | undefined = sessionID;
816
+
817
+ for (let step = 0; step <= bound && current !== undefined; step++) {
818
+ const at = seen.indexOf(current);
819
+
820
+ if (at !== -1) {
821
+ return seen.slice(at).toSorted((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0))[0];
822
+ }
823
+
824
+ seen.push(current);
825
+
826
+ current = linked.get(current);
827
+ }
828
+
829
+ return undefined;
830
+ };
831
+
832
+ for (const entry of kept) {
833
+ const cycleStart = findCycleStart(entry.sessionID);
834
+
835
+ if (cycleStart !== undefined) {
836
+ linked.set(cycleStart, undefined);
837
+ }
838
+ }
839
+
840
+ // The top-level row a walk up from this row reaches.
841
+ const findTopLevel = (sessionID: SessionID): SessionID => {
842
+ let current = sessionID;
843
+
844
+ for (let step = 0; step < bound; step++) {
845
+ const parent = linked.get(current);
846
+
847
+ if (parent === undefined) {
848
+ return current;
849
+ }
850
+
851
+ current = parent;
852
+ }
853
+
854
+ return current;
855
+ };
856
+
857
+ const relinked: FleetEntry[] = [];
858
+
859
+ for (const entry of kept) {
860
+ const { parent: _parent, ...rest } = entry;
861
+ const top = findTopLevel(entry.sessionID);
862
+ const row = top === entry.sessionID ? rest : { ...rest, parent: top };
863
+
864
+ relinked.push(row);
865
+ }
866
+
867
+ return relinked;
868
+ }
869
+
870
+ // The ownership epoch this daemon writes under. Nothing moves a session to
871
+ // a later epoch yet, so every row this daemon owns holds this one.
872
+ const OWNER_EPOCH = 1;
873
+
874
+ // Rejects a write that touches a session another daemon owns, or one whose
875
+ // stored epoch has moved past this daemon's: the write is stale.
876
+ async function requireCurrentOwnership(
877
+ trx: Transaction<StateStoreSchema>, // oxlint-disable-line prefer-readonly-parameter-types -- a kysely transaction bound to a live connection; not meaningfully freezable
878
+ daemonID: DaemonID,
879
+ sessionIDs: readonly SessionID[],
880
+ ): Promise<void> {
881
+ if (sessionIDs.length === 0) {
882
+ return;
883
+ }
884
+
885
+ const stale = await trx
886
+ .selectFrom('session_owner')
887
+ .select(['session_id', 'daemon_id', 'owner_epoch'])
888
+ .where('session_id', 'in', sessionIDs)
889
+ .where((eb) => eb.or([eb('daemon_id', '!=', daemonID), eb('owner_epoch', '!=', OWNER_EPOCH)]))
890
+ .executeTakeFirst();
891
+
892
+ if (stale !== undefined) {
893
+ throw new DaemonError(
894
+ 'stale_epoch',
895
+ `session '${stale.session_id}' is owned by daemon '${stale.daemon_id}' at epoch ${stale.owner_epoch}; this daemon writes as '${daemonID}' at epoch ${OWNER_EPOCH}`,
896
+ );
897
+ }
898
+ }
899
+
900
+ // Mints each legacy entry an atc session id, then moves each sub-session
901
+ // link from the parent's agent session id to the parent's minted id.
902
+ function buildFleetFromLegacy(legacy: readonly LegacyFleetEntry[]): FleetEntry[] {
903
+ const minted = new Map(legacy.map((entry) => [entry.agentSessionID, toSessionID(randomUUID())]));
904
+
905
+ return legacy.map((entry) => {
906
+ const { parent, ...rest } = entry;
907
+ const parentID = parent === undefined ? undefined : minted.get(parent);
908
+
909
+ return {
910
+ ...rest,
911
+ sessionID: minted.get(entry.agentSessionID) ?? toSessionID(randomUUID()),
912
+ ...(parentID === undefined ? {} : { parent: parentID }),
913
+ };
914
+ });
915
+ }
916
+
917
+ function buildScopeMatch(
918
+ eb: ExpressionBuilder<StateStoreSchema, 'events'>, // oxlint-disable-line prefer-readonly-parameter-types -- a kysely expression builder bound to a live query; not meaningfully freezable
919
+ scope: EventScope | null,
920
+ ): Expression<SqlBool> {
921
+ if (scope === null) {
922
+ return eb.lit(true);
923
+ }
924
+
925
+ if (scope.atcIDs.length === 0 && scope.agentSessionIDs.length === 0) {
926
+ return eb.lit(false);
927
+ }
928
+
929
+ return eb.or([
930
+ ...(scope.atcIDs.length === 0 ? [] : [eb('atc_id', 'in', scope.atcIDs)]),
931
+ ...(scope.agentSessionIDs.length === 0 ? [] : [eb('session_id', 'in', scope.agentSessionIDs)]),
932
+ ]);
933
+ }
934
+
485
935
  interface EventRow {
486
936
  readonly id: number;
487
937
  readonly ts: string;
@@ -548,6 +998,24 @@ function buildOwnerFilter(
548
998
  : eb.or([byAtcID, eb('agent_session_id', '=', owner.agentSessionID)]);
549
999
  }
550
1000
 
1001
+ function toIdempotencyRecord(row: Readonly<StateStoreSchema['idempotency']>): IdempotencyRecord {
1002
+ return {
1003
+ principal: row.principal,
1004
+ operation: row.operation,
1005
+ key: row.key,
1006
+ payloadHash: row.payload_hash,
1007
+ state: row.state,
1008
+ effectRef: row.effect_ref,
1009
+ result: row.result,
1010
+ effectTarget:
1011
+ row.effect_target === null || row.effect_target_identity === null
1012
+ ? null
1013
+ : { target: row.effect_target, targetIdentity: row.effect_target_identity },
1014
+ createdAt: row.created_at,
1015
+ updatedAt: row.updated_at,
1016
+ };
1017
+ }
1018
+
551
1019
  function toMessageRecord(row: Readonly<StateStoreSchema['messages']>): MessageRecord {
552
1020
  return {
553
1021
  id: toMessageID(row.id),
@@ -562,5 +1030,6 @@ function toMessageRecord(row: Readonly<StateStoreSchema['messages']>): MessageRe
562
1030
  ...(row.delivered_at === null ? {} : { deliveredAt: row.delivered_at }),
563
1031
  ...(row.answered_at === null ? {} : { answeredAt: row.answered_at }),
564
1032
  ...(row.answer === null ? {} : { answer: row.answer }),
1033
+ ...(row.turn_id === null ? {} : { turn: row.turn_id }),
565
1034
  };
566
1035
  }