@zgeoff/atc 2.11.0 → 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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +51 -0
  3. package/src/agents/build-args-without-flags.ts +25 -0
  4. package/src/agents/build-claude-override-args.ts +26 -0
  5. package/src/agents/claude-adapter.ts +49 -2
  6. package/src/agents/claude-effort-levels.ts +5 -0
  7. package/src/agents/codex-adapter.ts +43 -2
  8. package/src/agents/find-flag-value.ts +27 -0
  9. package/src/agents/gateway-adapter.ts +45 -2
  10. package/src/agents/grok-adapter.ts +19 -1
  11. package/src/cli.ts +18 -0
  12. package/src/client/daemon-client.ts +14 -2
  13. package/src/client/spawn-picker.ts +27 -4
  14. package/src/daemon/build-agent-list.ts +37 -1
  15. package/src/daemon/build-config-revision.ts +27 -0
  16. package/src/daemon/build-execution-targets.ts +34 -0
  17. package/src/daemon/build-fleet-events.ts +2 -1
  18. package/src/daemon/build-payload-hash.ts +31 -0
  19. package/src/daemon/build-scoped-context.ts +232 -0
  20. package/src/daemon/build-target-access.ts +33 -0
  21. package/src/daemon/build-target-forbidden-error.ts +13 -0
  22. package/src/daemon/build-target-identity.ts +22 -0
  23. package/src/daemon/build-target-list.ts +50 -0
  24. package/src/daemon/daemon-connection.ts +331 -95
  25. package/src/daemon/daemon.ts +452 -75
  26. package/src/daemon/effect-remains-error.ts +13 -0
  27. package/src/daemon/execution-provider.ts +103 -0
  28. package/src/daemon/find-execution-refusal.ts +104 -0
  29. package/src/daemon/idempotency-ledger.ts +164 -0
  30. package/src/daemon/local-pty-provider.ts +83 -0
  31. package/src/daemon/mint-session-id.ts +5 -5
  32. package/src/daemon/parse-spawn-overrides.ts +94 -0
  33. package/src/daemon/permission-registry.ts +14 -4
  34. package/src/daemon/restore-fleet.ts +66 -19
  35. package/src/daemon/session-runtime.ts +10 -0
  36. package/src/daemon/sessions.ts +282 -65
  37. package/src/daemon/start-headless-run.ts +11 -0
  38. package/src/daemon/start-headless-turn.ts +11 -2
  39. package/src/daemon/target-access.ts +36 -0
  40. package/src/mcp/answer-mcp-request.ts +12 -4
  41. package/src/mcp/answer-rpc-request.ts +28 -1
  42. package/src/mcp/build-principal-caller.ts +15 -0
  43. package/src/mcp/build-spawn-descriptions.ts +45 -0
  44. package/src/mcp/build-tool-list.ts +32 -4
  45. package/src/mcp/mcp-tools.ts +98 -8
  46. package/src/mcp/parse-idempotency-key.ts +29 -0
  47. package/src/mcp/reconnecting-caller.ts +39 -7
  48. package/src/mcp/require-daemon-features.ts +7 -0
  49. package/src/mcp/run-tool.ts +63 -11
  50. package/src/mcp/types.ts +2 -0
  51. package/src/protocol/daemon-error.ts +5 -1
  52. package/src/protocol/daemon-features.ts +24 -0
  53. package/src/protocol/protocol.ts +44 -11
  54. package/src/protocol/request-param-schemas.ts +60 -10
  55. package/src/shared/agent-session-id.ts +1 -1
  56. package/src/shared/collect-principals.ts +51 -0
  57. package/src/shared/collect-targets.ts +144 -0
  58. package/src/shared/config.ts +139 -14
  59. package/src/shared/daemon-id.ts +8 -0
  60. package/src/shared/format-json-kind.ts +21 -0
  61. package/src/shared/sort-json-keys.ts +19 -0
  62. package/src/shared/to-daemon-id.ts +11 -0
  63. package/src/store/fleet-entry.ts +42 -9
  64. package/src/store/idempotency-record.ts +48 -0
  65. package/src/store/message-owner.ts +1 -1
  66. package/src/store/run-migrations.ts +254 -5
  67. package/src/store/state-store.ts +433 -31
  68. package/src/workspace/check-workspace-completeness.ts +90 -0
  69. package/src/workspace/create-workspace-clone.ts +239 -0
  70. package/src/workspace/normalize-git-url.ts +59 -0
  71. package/src/workspace/read-workspace-tar.ts +38 -0
  72. package/src/workspace/resolve-path-source.ts +170 -0
  73. package/src/workspace/run-git.ts +103 -0
  74. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  75. package/src/workspace/workspace-provenance.ts +11 -0
  76. 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';
@@ -24,13 +29,14 @@ import type { TrailEntry } from './trail-entry';
24
29
  const TRAIL_FILTER = sql<boolean>`kind IS NOT NULL AND kind != 'heartbeat'`;
25
30
 
26
31
  /**
27
- * One session's slice of the event trail: rows under its atc id, plus rows
28
- * under its agent session id, since a restore re-mints the atc id while the
29
- * agent session id carries on.
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.
30
36
  */
31
37
  export interface EventScope {
32
- readonly atcID: SessionID;
33
- readonly agentSessionID: AgentSessionID | undefined;
38
+ readonly atcIDs: readonly SessionID[];
39
+ readonly agentSessionIDs: readonly AgentSessionID[];
34
40
  }
35
41
 
36
42
  export interface StoredEvent {
@@ -70,9 +76,14 @@ export class StateStore {
70
76
 
71
77
  private readonly db: Kysely<StateStoreSchema>;
72
78
 
73
- 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) {
74
84
  this.sqlite = sqlite;
75
85
  this.db = db;
86
+ this.daemonID = daemonID;
76
87
  }
77
88
 
78
89
  // Migrations run statements that cannot happen inside a constructor, so
@@ -93,7 +104,13 @@ export class StateStore {
93
104
 
94
105
  await runMigrations(db);
95
106
 
96
- 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));
97
114
 
98
115
  if (legacyFleetPath !== undefined) {
99
116
  await store.adoptLegacyFleet(legacyFleetPath);
@@ -102,10 +119,19 @@ export class StateStore {
102
119
  return store;
103
120
  }
104
121
 
122
+ // Only the rows this daemon owns: another daemon's sessions are its own
123
+ // to restore.
105
124
  async loadFleet(): Promise<FleetEntry[]> {
106
125
  const rows = await this.db
107
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`)
108
133
  .select([
134
+ 'session_id',
109
135
  'agent_session_id',
110
136
  'name',
111
137
  'cwd',
@@ -113,10 +139,14 @@ export class StateStore {
113
139
  'last_attached',
114
140
  'agent',
115
141
  'exited',
116
- 'parent',
142
+ 'parent_session_id',
117
143
  'prompt',
118
144
  'result',
119
145
  'transcript_path',
146
+ 'model',
147
+ 'effort',
148
+ 'target',
149
+ 'target_identity',
120
150
  ])
121
151
  .execute();
122
152
 
@@ -124,17 +154,24 @@ export class StateStore {
124
154
 
125
155
  for (const row of rows) {
126
156
  entries.push({
127
- agentSessionID: toAgentSessionID(row.agent_session_id),
157
+ sessionID: toSessionID(row.session_id),
128
158
  name: row.name,
129
159
  cwd: row.cwd,
160
+ ...(row.agent_session_id === null
161
+ ? {}
162
+ : { agentSessionID: toAgentSessionID(row.agent_session_id) }),
130
163
  agent: toAgentID(row.agent),
131
164
  ...(row.pinned === 0 ? {} : { pinned: true }),
132
165
  ...(row.last_attached === null ? {} : { lastAttachedAt: row.last_attached }),
133
166
  ...(row.exited === 0 ? {} : { exited: true }),
134
- ...(row.parent === null ? {} : { parent: toAgentSessionID(row.parent) }),
167
+ ...(row.parent_session_id === null ? {} : { parent: toSessionID(row.parent_session_id) }),
135
168
  ...(row.prompt === null ? {} : { prompt: row.prompt }),
136
169
  ...(row.result === null ? {} : { result: row.result }),
137
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 }),
138
175
  });
139
176
  }
140
177
 
@@ -164,27 +201,65 @@ export class StateStore {
164
201
  return recency;
165
202
  }
166
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.
167
208
  async writeFleet(entries: readonly FleetEntry[]): Promise<void> {
168
- await this.db.transaction().execute(async (trx) => {
169
- await trx.deleteFrom('fleet').execute();
209
+ const kept = buildFleetWithoutReplacedRows(entries);
170
210
 
171
- 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) {
172
233
  await trx
173
234
  .insertInto('fleet')
174
235
  .values({
175
- agent_session_id: entry.agentSessionID,
236
+ session_id: entry.sessionID,
237
+ agent_session_id: entry.agentSessionID ?? null,
176
238
  name: entry.name,
177
239
  cwd: entry.cwd,
178
240
  pinned: entry.pinned === true ? 1 : 0,
179
241
  last_attached: entry.lastAttachedAt ?? null,
180
242
  agent: entry.agent,
181
243
  exited: entry.exited === true ? 1 : 0,
182
- parent: entry.parent ?? null,
244
+ parent_session_id: entry.parent ?? null,
183
245
  prompt: entry.prompt ?? null,
184
246
  result: entry.result ?? null,
185
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,
186
262
  })
187
- .orReplace()
188
263
  .execute();
189
264
  }
190
265
  });
@@ -193,7 +268,7 @@ export class StateStore {
193
268
  // Rewrites fields on one existing row and leaves every other row as it
194
269
  // stands. A session without a row yet is a no-op: the next fleet write
195
270
  // inserts it with these fields.
196
- async updateFleetEntry(agentSessionID: AgentSessionID, fields: FleetEntryUpdate): Promise<void> {
271
+ async updateFleetEntry(sessionID: SessionID, fields: FleetEntryUpdate): Promise<void> {
197
272
  const values = {
198
273
  ...(fields.result === undefined ? {} : { result: fields.result }),
199
274
  ...(fields.transcriptPath === undefined ? {} : { transcript_path: fields.transcriptPath }),
@@ -203,11 +278,11 @@ export class StateStore {
203
278
  return;
204
279
  }
205
280
 
206
- await this.db
207
- .updateTable('fleet')
208
- .set(values)
209
- .where('agent_session_id', '=', agentSessionID)
210
- .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
+ });
211
286
  }
212
287
 
213
288
  async recordEvent(e: HookEvent, ev: Readonly<AdapterEvent> | null = null): Promise<void> {
@@ -282,8 +357,8 @@ export class StateStore {
282
357
  return buildStoredEvents(rows).toReversed();
283
358
  }
284
359
 
285
- // Matches the session by either id, since a restore re-mints the atc id
286
- // 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.
287
362
  async loadLastActivityAt(
288
363
  atcID: SessionID,
289
364
  agentSessionID: AgentSessionID | undefined,
@@ -496,6 +571,145 @@ export class StateStore {
496
571
  .execute();
497
572
  }
498
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
+
499
713
  async stop(): Promise<void> {
500
714
  await this.db.destroy();
501
715
 
@@ -519,21 +733,187 @@ export class StateStore {
519
733
  return;
520
734
  }
521
735
 
522
- const entries: FleetEntry[] = [];
736
+ const legacy: LegacyFleetEntry[] = [];
523
737
 
524
738
  for (const entry of parsed) {
525
739
  const parsedEntry = parseFleetEntry(entry);
526
740
 
527
741
  if (parsedEntry !== undefined) {
528
- entries.push(parsedEntry);
742
+ legacy.push(parsedEntry);
529
743
  }
530
744
  }
531
745
 
532
- await this.writeFleet(entries);
746
+ await this.writeFleet(buildFleetFromLegacy(legacy));
533
747
  } catch {}
534
748
  }
535
749
  }
536
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
+
537
917
  function buildScopeMatch(
538
918
  eb: ExpressionBuilder<StateStoreSchema, 'events'>, // oxlint-disable-line prefer-readonly-parameter-types -- a kysely expression builder bound to a live query; not meaningfully freezable
539
919
  scope: EventScope | null,
@@ -542,9 +922,13 @@ function buildScopeMatch(
542
922
  return eb.lit(true);
543
923
  }
544
924
 
925
+ if (scope.atcIDs.length === 0 && scope.agentSessionIDs.length === 0) {
926
+ return eb.lit(false);
927
+ }
928
+
545
929
  return eb.or([
546
- eb('atc_id', '=', scope.atcID),
547
- ...(scope.agentSessionID === undefined ? [] : [eb('session_id', '=', scope.agentSessionID)]),
930
+ ...(scope.atcIDs.length === 0 ? [] : [eb('atc_id', 'in', scope.atcIDs)]),
931
+ ...(scope.agentSessionIDs.length === 0 ? [] : [eb('session_id', 'in', scope.agentSessionIDs)]),
548
932
  ]);
549
933
  }
550
934
 
@@ -614,6 +998,24 @@ function buildOwnerFilter(
614
998
  : eb.or([byAtcID, eb('agent_session_id', '=', owner.agentSessionID)]);
615
999
  }
616
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
+
617
1019
  function toMessageRecord(row: Readonly<StateStoreSchema['messages']>): MessageRecord {
618
1020
  return {
619
1021
  id: toMessageID(row.id),