@zgeoff/atc 2.11.0 → 2.13.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 (119) hide show
  1. package/README.md +10 -10
  2. package/package.json +2 -1
  3. package/src/agents/agent-adapter.ts +98 -10
  4. package/src/agents/build-args-without-flags.ts +25 -0
  5. package/src/agents/build-atc-bridge-files.ts +13 -0
  6. package/src/agents/build-claude-override-args.ts +26 -0
  7. package/src/agents/build-claude-query-options.ts +72 -0
  8. package/src/agents/build-cli-command.ts +7 -5
  9. package/src/{daemon → agents}/build-headless-env.ts +12 -6
  10. package/src/agents/build-hook-settings.ts +8 -2
  11. package/src/agents/build-restore-mode-args.ts +23 -0
  12. package/src/agents/claude-adapter.ts +112 -18
  13. package/src/agents/claude-effort-levels.ts +5 -0
  14. package/src/agents/codex-adapter.ts +48 -2
  15. package/src/agents/find-claude-permission-mode.ts +24 -0
  16. package/src/agents/find-flag-value.ts +27 -0
  17. package/src/agents/gateway-adapter.ts +83 -13
  18. package/src/agents/grok-adapter.ts +24 -1
  19. package/src/agents/make-claude-headless-runner.ts +46 -0
  20. package/src/agents/plan-pasted-line-input.ts +22 -0
  21. package/src/agents/plan-typed-line-input.ts +8 -0
  22. package/src/agents/resolve-claude-permission-mode.ts +13 -0
  23. package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -42
  24. package/src/agents/write-atc-bridge.ts +2 -6
  25. package/src/cli.ts +23 -9
  26. package/src/client/daemon-client.ts +14 -2
  27. package/src/client/index.ts +50 -1
  28. package/src/client/spawn-picker.ts +27 -4
  29. package/src/client/ui.ts +8 -0
  30. package/src/daemon/build-agent-list.ts +37 -1
  31. package/src/daemon/build-config-revision.ts +27 -0
  32. package/src/daemon/build-execution-targets.ts +73 -0
  33. package/src/daemon/build-fleet-events.ts +2 -1
  34. package/src/daemon/build-imp-provider.ts +68 -0
  35. package/src/daemon/build-payload-hash.ts +31 -0
  36. package/src/daemon/build-report-trail-entry.ts +4 -2
  37. package/src/daemon/build-scoped-context.ts +235 -0
  38. package/src/daemon/build-session-lifecycle.ts +52 -0
  39. package/src/daemon/build-tar-archive.ts +85 -0
  40. package/src/daemon/build-target-access.ts +33 -0
  41. package/src/daemon/build-target-forbidden-error.ts +13 -0
  42. package/src/daemon/build-target-identity.ts +22 -0
  43. package/src/daemon/build-target-list.ts +50 -0
  44. package/src/daemon/daemon-connection.ts +425 -96
  45. package/src/daemon/daemon.ts +730 -96
  46. package/src/daemon/effect-remains-error.ts +13 -0
  47. package/src/daemon/execution-provider.ts +208 -0
  48. package/src/daemon/find-execution-refusal.ts +104 -0
  49. package/src/daemon/hooks.ts +5 -17
  50. package/src/daemon/idempotency-ledger.ts +164 -0
  51. package/src/daemon/imp-client-port.ts +343 -0
  52. package/src/daemon/imp-harness.ts +618 -0
  53. package/src/daemon/imp-port-error.ts +18 -0
  54. package/src/daemon/imp-port.ts +246 -0
  55. package/src/daemon/imp-provider.ts +601 -0
  56. package/src/daemon/is-binding-current.ts +43 -0
  57. package/src/daemon/local-pty-provider.ts +142 -0
  58. package/src/daemon/materialize-workspace.ts +571 -0
  59. package/src/daemon/mint-session-id.ts +5 -5
  60. package/src/daemon/parse-hook-line.ts +31 -0
  61. package/src/daemon/parse-spawn-overrides.ts +94 -0
  62. package/src/daemon/permission-registry.ts +14 -4
  63. package/src/daemon/pick-session-state.ts +15 -0
  64. package/src/daemon/restore-fleet.ts +103 -38
  65. package/src/daemon/screen-model.ts +7 -0
  66. package/src/daemon/session-runtime.ts +10 -0
  67. package/src/daemon/sessions.ts +835 -105
  68. package/src/daemon/start-headless-turn.ts +12 -3
  69. package/src/daemon/start-session-bridge.ts +294 -0
  70. package/src/daemon/target-access.ts +36 -0
  71. package/src/mcp/answer-mcp-request.ts +12 -4
  72. package/src/mcp/answer-rpc-request.ts +28 -1
  73. package/src/mcp/build-principal-caller.ts +15 -0
  74. package/src/mcp/build-spawn-descriptions.ts +45 -0
  75. package/src/mcp/build-tool-list.ts +32 -4
  76. package/src/mcp/mcp-tools.ts +107 -10
  77. package/src/mcp/parse-idempotency-key.ts +29 -0
  78. package/src/mcp/reconnecting-caller.ts +39 -7
  79. package/src/mcp/require-daemon-features.ts +10 -0
  80. package/src/mcp/run-tool.ts +87 -16
  81. package/src/mcp/types.ts +2 -0
  82. package/src/protocol/daemon-error.ts +5 -1
  83. package/src/protocol/daemon-features.ts +35 -0
  84. package/src/protocol/protocol.ts +67 -11
  85. package/src/protocol/request-param-schemas.ts +126 -10
  86. package/src/report.ts +37 -2
  87. package/src/run-bridge-tap.ts +241 -0
  88. package/src/shared/agent-session-id.ts +1 -1
  89. package/src/shared/collect-clean-env.ts +9 -1
  90. package/src/shared/collect-principals.ts +51 -0
  91. package/src/shared/collect-targets.ts +144 -0
  92. package/src/shared/config.ts +139 -14
  93. package/src/shared/daemon-id.ts +8 -0
  94. package/src/shared/format-json-kind.ts +21 -0
  95. package/src/shared/open-bridge-socket.ts +86 -0
  96. package/src/shared/send-bridge-request.ts +47 -0
  97. package/src/shared/sort-json-keys.ts +19 -0
  98. package/src/shared/to-daemon-id.ts +11 -0
  99. package/src/statusline.ts +34 -3
  100. package/src/store/fleet-entry.ts +61 -9
  101. package/src/store/idempotency-record.ts +48 -0
  102. package/src/store/message-owner.ts +1 -1
  103. package/src/store/run-migrations.ts +330 -5
  104. package/src/store/state-store.ts +607 -44
  105. package/src/store/trail-entry.ts +4 -0
  106. package/src/store/workspace-materialization.ts +70 -0
  107. package/src/tap.ts +11 -0
  108. package/src/workspace/check-url-credentials.ts +59 -0
  109. package/src/workspace/check-workspace-completeness.ts +90 -0
  110. package/src/workspace/create-workspace-clone.ts +251 -0
  111. package/src/workspace/normalize-git-url.ts +59 -0
  112. package/src/workspace/read-workspace-tar.ts +38 -0
  113. package/src/workspace/repository-env-vars.ts +22 -0
  114. package/src/workspace/resolve-path-source.ts +170 -0
  115. package/src/workspace/run-git.ts +87 -0
  116. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  117. package/src/workspace/workspace-provenance.ts +11 -0
  118. package/src/workspace/workspace-source.ts +8 -0
  119. /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
@@ -1,36 +1,47 @@
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';
20
25
  import type { StateStoreSchema } from './run-migrations';
21
26
  import type { TrailEntry } from './trail-entry';
27
+ import type {
28
+ MaterializationUpdate,
29
+ SessionWorkspace,
30
+ WorkspaceMaterialization,
31
+ } from './workspace-materialization';
22
32
 
23
33
  // Spelled as the partial index's predicate so SQLite can match them.
24
34
  const TRAIL_FILTER = sql<boolean>`kind IS NOT NULL AND kind != 'heartbeat'`;
25
35
 
26
36
  /**
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.
37
+ * Some sessions' slice of the event trail: rows under their atc ids, plus
38
+ * rows under their agent session ids, since rows written before atc session
39
+ * ids stayed stable across restores carry an earlier atc id. A scope of no
40
+ * sessions matches no row.
30
41
  */
31
42
  export interface EventScope {
32
- readonly atcID: SessionID;
33
- readonly agentSessionID: AgentSessionID | undefined;
43
+ readonly atcIDs: readonly SessionID[];
44
+ readonly agentSessionIDs: readonly AgentSessionID[];
34
45
  }
35
46
 
36
47
  export interface StoredEvent {
@@ -70,9 +81,14 @@ export class StateStore {
70
81
 
71
82
  private readonly db: Kysely<StateStoreSchema>;
72
83
 
73
- private constructor(sqlite: Database, db: Kysely<StateStoreSchema>) {
84
+ // The daemon this store's fleet rows belong to, minted once by the
85
+ // migration that created session ownership.
86
+ readonly daemonID: DaemonID;
87
+
88
+ private constructor(sqlite: Database, db: Kysely<StateStoreSchema>, daemonID: DaemonID) {
74
89
  this.sqlite = sqlite;
75
90
  this.db = db;
91
+ this.daemonID = daemonID;
76
92
  }
77
93
 
78
94
  // Migrations run statements that cannot happen inside a constructor, so
@@ -93,7 +109,13 @@ export class StateStore {
93
109
 
94
110
  await runMigrations(db);
95
111
 
96
- const store = new StateStore(sqlite, db);
112
+ const daemonIDRow = await db
113
+ .selectFrom('prefs')
114
+ .select('value')
115
+ .where('key', '=', 'daemon_id')
116
+ .executeTakeFirstOrThrow();
117
+
118
+ const store = new StateStore(sqlite, db, toDaemonID(daemonIDRow.value));
97
119
 
98
120
  if (legacyFleetPath !== undefined) {
99
121
  await store.adoptLegacyFleet(legacyFleetPath);
@@ -102,21 +124,48 @@ export class StateStore {
102
124
  return store;
103
125
  }
104
126
 
127
+ // Only the rows this daemon owns: another daemon's sessions are its own
128
+ // to restore.
105
129
  async loadFleet(): Promise<FleetEntry[]> {
106
130
  const rows = await this.db
107
131
  .selectFrom('fleet')
132
+
133
+ // A session's ready workspace comes back with its row.
134
+ .leftJoin('workspace_materialization as workspace', (join) =>
135
+ join
136
+ .onRef('workspace.session_id', '=', 'fleet.session_id')
137
+ .on('workspace.phase', '=', 'ready'),
138
+ )
139
+ .where('fleet.session_id', 'in', (eb) =>
140
+ eb.selectFrom('session_owner').select('session_id').where('daemon_id', '=', this.daemonID),
141
+ )
142
+
143
+ // Stored order, which a restore keeps for sessions with no recency.
144
+ .orderBy(sql`fleet.rowid`)
108
145
  .select([
109
- 'agent_session_id',
110
- 'name',
111
- 'cwd',
112
- 'pinned',
113
- 'last_attached',
114
- 'agent',
115
- 'exited',
116
- 'parent',
117
- 'prompt',
118
- 'result',
119
- 'transcript_path',
146
+ 'fleet.session_id',
147
+ 'fleet.agent_session_id',
148
+ 'fleet.name',
149
+ 'fleet.cwd',
150
+ 'fleet.pinned',
151
+ 'fleet.last_attached',
152
+ 'fleet.agent',
153
+ 'fleet.exited',
154
+ 'fleet.parent_session_id',
155
+ 'fleet.prompt',
156
+ 'fleet.result',
157
+ 'fleet.transcript_path',
158
+ 'fleet.model',
159
+ 'fleet.effort',
160
+ 'fleet.target',
161
+ 'fleet.target_identity',
162
+ 'workspace.repo_url',
163
+ 'workspace.sha',
164
+ 'workspace.ref',
165
+ 'workspace.materialized_at',
166
+ 'workspace.withheld_env',
167
+ 'fleet.desired',
168
+ 'fleet.host_key',
120
169
  ])
121
170
  .execute();
122
171
 
@@ -124,17 +173,28 @@ export class StateStore {
124
173
 
125
174
  for (const row of rows) {
126
175
  entries.push({
127
- agentSessionID: toAgentSessionID(row.agent_session_id),
176
+ sessionID: toSessionID(row.session_id),
128
177
  name: row.name,
129
178
  cwd: row.cwd,
179
+ ...(row.agent_session_id === null
180
+ ? {}
181
+ : { agentSessionID: toAgentSessionID(row.agent_session_id) }),
130
182
  agent: toAgentID(row.agent),
131
183
  ...(row.pinned === 0 ? {} : { pinned: true }),
132
184
  ...(row.last_attached === null ? {} : { lastAttachedAt: row.last_attached }),
133
185
  ...(row.exited === 0 ? {} : { exited: true }),
134
- ...(row.parent === null ? {} : { parent: toAgentSessionID(row.parent) }),
186
+ ...(row.parent_session_id === null ? {} : { parent: toSessionID(row.parent_session_id) }),
135
187
  ...(row.prompt === null ? {} : { prompt: row.prompt }),
136
188
  ...(row.result === null ? {} : { result: row.result }),
137
189
  ...(row.transcript_path === null ? {} : { transcriptPath: row.transcript_path }),
190
+ ...(row.model === null ? {} : { model: row.model }),
191
+ ...(row.effort === null ? {} : { effort: row.effort }),
192
+ ...(row.target === null ? {} : { target: row.target }),
193
+ ...(row.target_identity === null ? {} : { targetIdentity: row.target_identity }),
194
+ ...buildWorkspaceField(row),
195
+ ...buildWithheldEnvField(row.withheld_env),
196
+ ...(row.desired === 'sleep' || row.desired === 'stop' ? { desired: row.desired } : {}),
197
+ ...(row.host_key === null ? {} : { hostKey: toSessionID(row.host_key) }),
138
198
  });
139
199
  }
140
200
 
@@ -164,27 +224,67 @@ export class StateStore {
164
224
  return recency;
165
225
  }
166
226
 
227
+ // Replaces this daemon's rows wholesale and leaves every other daemon's
228
+ // rows as they stand. A session another daemon owns, or one whose stored
229
+ // ownership epoch has moved past this daemon's, rejects the whole write
230
+ // with stale_epoch.
167
231
  async writeFleet(entries: readonly FleetEntry[]): Promise<void> {
168
- await this.db.transaction().execute(async (trx) => {
169
- await trx.deleteFrom('fleet').execute();
232
+ const kept = buildFleetWithoutReplacedRows(entries);
170
233
 
171
- for (const entry of entries) {
234
+ await this.db.transaction().execute(async (trx) => {
235
+ await requireCurrentOwnership(
236
+ trx,
237
+ this.daemonID,
238
+ entries.map((entry) => entry.sessionID),
239
+ );
240
+
241
+ await trx
242
+ .deleteFrom('fleet')
243
+ .where('session_id', 'in', (eb) =>
244
+ eb
245
+ .selectFrom('session_owner')
246
+ .select('session_id')
247
+ .where('daemon_id', '=', this.daemonID),
248
+ )
249
+ .execute();
250
+
251
+ await trx.deleteFrom('session_owner').where('daemon_id', '=', this.daemonID).execute();
252
+
253
+ const updatedAt = Date.now();
254
+
255
+ for (const entry of kept) {
172
256
  await trx
173
257
  .insertInto('fleet')
174
258
  .values({
175
- agent_session_id: entry.agentSessionID,
259
+ session_id: entry.sessionID,
260
+ agent_session_id: entry.agentSessionID ?? null,
176
261
  name: entry.name,
177
262
  cwd: entry.cwd,
178
263
  pinned: entry.pinned === true ? 1 : 0,
179
264
  last_attached: entry.lastAttachedAt ?? null,
180
265
  agent: entry.agent,
181
266
  exited: entry.exited === true ? 1 : 0,
182
- parent: entry.parent ?? null,
267
+ parent_session_id: entry.parent ?? null,
183
268
  prompt: entry.prompt ?? null,
184
269
  result: entry.result ?? null,
185
270
  transcript_path: entry.transcriptPath ?? null,
271
+ model: entry.model ?? null,
272
+ effort: entry.effort ?? null,
273
+ target: entry.target ?? null,
274
+ target_identity: entry.targetIdentity ?? null,
275
+ desired: entry.desired ?? null,
276
+ host_key: entry.hostKey ?? null,
277
+ })
278
+ .execute();
279
+
280
+ await trx
281
+ .insertInto('session_owner')
282
+ .values({
283
+ session_id: entry.sessionID,
284
+ daemon_id: this.daemonID,
285
+ owner_epoch: OWNER_EPOCH,
286
+ updated_at: updatedAt,
186
287
  })
187
- .orReplace()
188
288
  .execute();
189
289
  }
190
290
  });
@@ -193,7 +293,7 @@ export class StateStore {
193
293
  // Rewrites fields on one existing row and leaves every other row as it
194
294
  // stands. A session without a row yet is a no-op: the next fleet write
195
295
  // inserts it with these fields.
196
- async updateFleetEntry(agentSessionID: AgentSessionID, fields: FleetEntryUpdate): Promise<void> {
296
+ async updateFleetEntry(sessionID: SessionID, fields: FleetEntryUpdate): Promise<void> {
197
297
  const values = {
198
298
  ...(fields.result === undefined ? {} : { result: fields.result }),
199
299
  ...(fields.transcriptPath === undefined ? {} : { transcript_path: fields.transcriptPath }),
@@ -203,11 +303,11 @@ export class StateStore {
203
303
  return;
204
304
  }
205
305
 
206
- await this.db
207
- .updateTable('fleet')
208
- .set(values)
209
- .where('agent_session_id', '=', agentSessionID)
210
- .execute();
306
+ await this.db.transaction().execute(async (trx) => {
307
+ await requireCurrentOwnership(trx, this.daemonID, [sessionID]);
308
+
309
+ await trx.updateTable('fleet').set(values).where('session_id', '=', sessionID).execute();
310
+ });
211
311
  }
212
312
 
213
313
  async recordEvent(e: HookEvent, ev: Readonly<AdapterEvent> | null = null): Promise<void> {
@@ -231,8 +331,10 @@ export class StateStore {
231
331
  .execute();
232
332
  }
233
333
 
234
- async recordTrailEntry(entry: TrailEntry): Promise<void> {
235
- await this.db
334
+ // Returns whether the entry was written: a report whose id the trail
335
+ // already holds is left out.
336
+ async recordTrailEntry(entry: TrailEntry): Promise<boolean> {
337
+ const result = await this.db
236
338
  .insertInto('events')
237
339
  .values({
238
340
  ts: new Date(entry.at).toISOString(),
@@ -244,8 +346,12 @@ export class StateStore {
244
346
  session_id: entry.agentSessionID,
245
347
  kind: entry.kind,
246
348
  detail: entry.detail,
349
+ report_id: entry.kind === 'report' ? (entry.reportID ?? null) : null,
247
350
  })
248
- .execute();
351
+ .onConflict((oc) => oc.column('report_id').doNothing())
352
+ .executeTakeFirst();
353
+
354
+ return result.numInsertedOrUpdatedRows !== 0n;
249
355
  }
250
356
 
251
357
  async collectEventsAfter(
@@ -282,8 +388,8 @@ export class StateStore {
282
388
  return buildStoredEvents(rows).toReversed();
283
389
  }
284
390
 
285
- // Matches the session by either id, since a restore re-mints the atc id
286
- // while the agent session id carries on.
391
+ // Matches the session by either id, since rows written before atc session
392
+ // ids stayed stable across restores carry an earlier atc id.
287
393
  async loadLastActivityAt(
288
394
  atcID: SessionID,
289
395
  agentSessionID: AgentSessionID | undefined,
@@ -496,6 +602,215 @@ export class StateStore {
496
602
  .execute();
497
603
  }
498
604
 
605
+ // Records a key in progress unless one is already held under the same
606
+ // principal and operation. Returns null when this call claimed the key,
607
+ // else the record the key already holds.
608
+ async claimIdempotencyKey(claim: IdempotencyClaim): Promise<IdempotencyRecord | null> {
609
+ const inserted = await this.db
610
+ .insertInto('idempotency')
611
+ .values({
612
+ principal: claim.principal,
613
+ operation: claim.operation,
614
+ key: claim.key,
615
+ payload_hash: claim.payloadHash,
616
+ state: 'in_progress',
617
+ effect_ref: claim.effectRef,
618
+ result: null,
619
+ created_at: claim.at,
620
+ updated_at: claim.at,
621
+ effect_target: null,
622
+ effect_target_identity: null,
623
+ })
624
+ .onConflict((oc) => oc.columns(['principal', 'operation', 'key']).doNothing())
625
+ .returning('key')
626
+ .executeTakeFirst();
627
+
628
+ if (inserted !== undefined) {
629
+ return null;
630
+ }
631
+
632
+ const row = await this.db
633
+ .selectFrom('idempotency')
634
+ .selectAll()
635
+ .where('principal', '=', claim.principal)
636
+ .where('operation', '=', claim.operation)
637
+ .where('key', '=', claim.key)
638
+ .executeTakeFirstOrThrow();
639
+
640
+ return toIdempotencyRecord(row);
641
+ }
642
+
643
+ async updateIdempotencyCompleted(
644
+ record: Pick<IdempotencyRecord, 'principal' | 'operation' | 'key'>,
645
+ result: string,
646
+ at: number,
647
+ effectTarget: EffectTarget | null = null,
648
+ ): Promise<void> {
649
+ await this.db
650
+ .updateTable('idempotency')
651
+ .set({
652
+ state: 'completed',
653
+ result,
654
+ updated_at: at,
655
+ effect_target: effectTarget?.target ?? null,
656
+ effect_target_identity: effectTarget?.targetIdentity ?? null,
657
+ })
658
+ .where('principal', '=', record.principal)
659
+ .where('operation', '=', record.operation)
660
+ .where('key', '=', record.key)
661
+ .execute();
662
+ }
663
+
664
+ // Keeps a claim whose effect may still stand as outcome_unknown, so a
665
+ // retry answers from it and never runs the effect again.
666
+ async updateIdempotencyOutcomeUnknown(
667
+ record: Pick<IdempotencyRecord, 'principal' | 'operation' | 'key'>,
668
+ at: number,
669
+ ): Promise<void> {
670
+ await this.db
671
+ .updateTable('idempotency')
672
+ .set({ state: 'outcome_unknown', updated_at: at })
673
+ .where('principal', '=', record.principal)
674
+ .where('operation', '=', record.operation)
675
+ .where('key', '=', record.key)
676
+ .execute();
677
+ }
678
+
679
+ // Drops a claim whose effect never started, so a retry runs it fresh.
680
+ async removeIdempotencyKey(
681
+ record: Pick<IdempotencyRecord, 'principal' | 'operation' | 'key'>,
682
+ ): Promise<void> {
683
+ await this.db
684
+ .deleteFrom('idempotency')
685
+ .where('principal', '=', record.principal)
686
+ .where('operation', '=', record.operation)
687
+ .where('key', '=', record.key)
688
+ .execute();
689
+ }
690
+
691
+ // Runs once as a daemon starts, before it serves a request: every key
692
+ // still in progress belonged to a daemon that stopped mid-effect, so its
693
+ // outcome is unknown. A key whose effect left its row behind completes;
694
+ // one whose effect left no trace stays unknown.
695
+ async reconcileIdempotencyKeys(at: number): Promise<void> {
696
+ await this.db.transaction().execute(async (trx) => {
697
+ await trx
698
+ .updateTable('idempotency')
699
+ .set({ state: 'outcome_unknown', updated_at: at })
700
+ .where('state', '=', 'in_progress')
701
+ .execute();
702
+
703
+ // A spawn that completes here records the target its fleet row holds,
704
+ // as a spawn that completes as it runs does.
705
+ await trx
706
+ .updateTable('idempotency')
707
+ .set((eb) => ({
708
+ state: 'completed',
709
+ updated_at: at,
710
+ effect_target: eb
711
+ .selectFrom('fleet')
712
+ .select('fleet.target')
713
+ .whereRef('fleet.session_id', '=', 'idempotency.effect_ref'),
714
+ effect_target_identity: eb
715
+ .selectFrom('fleet')
716
+ .select('fleet.target_identity')
717
+ .whereRef('fleet.session_id', '=', 'idempotency.effect_ref'),
718
+ }))
719
+ .where('state', '=', 'outcome_unknown')
720
+ .where('operation', '=', 'session.spawn')
721
+ .where('effect_ref', 'in', (eb) => eb.selectFrom('fleet').select('session_id'))
722
+ .execute();
723
+
724
+ await trx
725
+ .updateTable('idempotency')
726
+ .set({ state: 'completed', updated_at: at })
727
+ .where('state', '=', 'outcome_unknown')
728
+ .where('operation', '=', 'session.message')
729
+ .where('effect_ref', 'in', (eb) => eb.selectFrom('messages').select('id'))
730
+ .execute();
731
+ });
732
+ }
733
+
734
+ // Only completed keys expire: a key whose outcome is unknown stays, so a
735
+ // retry of it keeps answering outcome_unknown instead of running anew.
736
+ async removeExpiredIdempotencyKeys(before: number): Promise<void> {
737
+ await this.db
738
+ .deleteFrom('idempotency')
739
+ .where('state', '=', 'completed')
740
+ .where('updated_at', '<', before)
741
+ .execute();
742
+ }
743
+
744
+ // Records a materialization as it starts, in the resolving phase.
745
+ async createMaterialization(
746
+ row: Pick<
747
+ WorkspaceMaterialization,
748
+ 'sessionID' | 'target' | 'dir' | 'sourceKind' | 'withheldEnv'
749
+ >,
750
+ at: number,
751
+ ): Promise<void> {
752
+ await this.db
753
+ .insertInto('workspace_materialization')
754
+ .values({
755
+ session_id: row.sessionID,
756
+ target: row.target,
757
+ dir: row.dir,
758
+ source_kind: row.sourceKind,
759
+ phase: 'resolving',
760
+ repo_url: null,
761
+ sha: null,
762
+ ref: null,
763
+ error_code: null,
764
+ started_at: at,
765
+ updated_at: at,
766
+ materialized_at: null,
767
+ withheld_env: JSON.stringify(row.withheldEnv),
768
+ })
769
+ .execute();
770
+ }
771
+
772
+ async updateMaterialization(
773
+ sessionID: SessionID,
774
+ fields: MaterializationUpdate,
775
+ at: number,
776
+ ): Promise<void> {
777
+ await this.db
778
+ .updateTable('workspace_materialization')
779
+ .set({
780
+ phase: fields.phase,
781
+ ...(fields.repoURL === undefined ? {} : { repo_url: fields.repoURL }),
782
+ ...(fields.sha === undefined ? {} : { sha: fields.sha }),
783
+ ...(fields.ref === undefined ? {} : { ref: fields.ref }),
784
+ ...(fields.errorCode === undefined ? {} : { error_code: fields.errorCode }),
785
+ ...(fields.materializedAt === undefined ? {} : { materialized_at: fields.materializedAt }),
786
+ updated_at: at,
787
+ })
788
+ .where('session_id', '=', sessionID)
789
+ .execute();
790
+ }
791
+
792
+ async findMaterialization(sessionID: SessionID): Promise<WorkspaceMaterialization | null> {
793
+ const row = await this.db
794
+ .selectFrom('workspace_materialization')
795
+ .selectAll()
796
+ .where('session_id', '=', sessionID)
797
+ .executeTakeFirst();
798
+
799
+ return row === undefined ? null : toWorkspaceMaterialization(row);
800
+ }
801
+
802
+ // Runs once as a daemon starts, before it serves a request: a
803
+ // materialization still short of ready or failed belonged to a daemon that
804
+ // stopped partway through it, and its workspace was never verified, so it
805
+ // fails as interrupted.
806
+ async reconcileMaterializations(at: number): Promise<void> {
807
+ await this.db
808
+ .updateTable('workspace_materialization')
809
+ .set({ phase: 'failed', error_code: 'workspace_interrupted', updated_at: at })
810
+ .where('phase', 'not in', ['ready', 'failed'])
811
+ .execute();
812
+ }
813
+
499
814
  async stop(): Promise<void> {
500
815
  await this.db.destroy();
501
816
 
@@ -519,21 +834,227 @@ export class StateStore {
519
834
  return;
520
835
  }
521
836
 
522
- const entries: FleetEntry[] = [];
837
+ const legacy: LegacyFleetEntry[] = [];
523
838
 
524
839
  for (const entry of parsed) {
525
840
  const parsedEntry = parseFleetEntry(entry);
526
841
 
527
842
  if (parsedEntry !== undefined) {
528
- entries.push(parsedEntry);
843
+ legacy.push(parsedEntry);
529
844
  }
530
845
  }
531
846
 
532
- await this.writeFleet(entries);
847
+ await this.writeFleet(buildFleetFromLegacy(legacy));
533
848
  } catch {}
534
849
  }
535
850
  }
536
851
 
852
+ // A fleet row's ready workspace as its entry field, or nothing for a row
853
+ // that ran without one.
854
+ function buildWorkspaceField(
855
+ row: Readonly<{
856
+ repo_url: string | null;
857
+ sha: string | null;
858
+ ref: string | null;
859
+ materialized_at: number | null;
860
+ }>,
861
+ ): { readonly workspace?: SessionWorkspace } {
862
+ if (row.repo_url === null || row.sha === null || row.materialized_at === null) {
863
+ return {};
864
+ }
865
+
866
+ return {
867
+ workspace: {
868
+ repoURL: row.repo_url,
869
+ sha: row.sha,
870
+ ...(row.ref === null ? {} : { ref: row.ref }),
871
+ materializedAt: row.materialized_at,
872
+ },
873
+ };
874
+ }
875
+
876
+ // Two sessions share an agent session id when one resumes the other's agent
877
+ // session. The fleet keeps one row per agent session id, the entry written
878
+ // last, and relinks the survivors as a one-level hierarchy of rows it holds:
879
+ //
880
+ // 1. A link to a dropped row follows to the row that replaced it. A row that
881
+ // replaced its own parent follows that parent's link instead, and a link
882
+ // to a row the fleet does not hold makes the row top-level.
883
+ // 2. Where the links form a cycle, the row written first in the cycle
884
+ // becomes top-level.
885
+ // 3. A row whose parent is itself a sub-session moves up to the top-level
886
+ // row above it.
887
+ //
888
+ // Every walk takes at most one step per entry, so it ends on any input.
889
+ // A ready workspace's withheld variable names as their entry field, or
890
+ // nothing for a row that withholds none.
891
+ function buildWithheldEnvField(stored: string | null): { readonly withheldEnv?: string[] } {
892
+ const names = parseWithheldEnv(stored);
893
+
894
+ return names.length === 0 ? {} : { withheldEnv: names };
895
+ }
896
+
897
+ function parseWithheldEnv(stored: string | null): string[] {
898
+ const parsed: unknown = stored === null ? [] : JSON.parse(stored);
899
+
900
+ return Array.isArray(parsed)
901
+ ? parsed.filter((name): name is string => typeof name === 'string')
902
+ : [];
903
+ }
904
+
905
+ function buildFleetWithoutReplacedRows(entries: readonly FleetEntry[]): FleetEntry[] {
906
+ const survivors = new Map<AgentSessionID, SessionID>();
907
+
908
+ for (const entry of entries) {
909
+ if (entry.agentSessionID !== undefined) {
910
+ survivors.set(entry.agentSessionID, entry.sessionID);
911
+ }
912
+ }
913
+
914
+ const replaced = new Map<SessionID, SessionID>();
915
+
916
+ for (const entry of entries) {
917
+ const survivor =
918
+ entry.agentSessionID === undefined ? undefined : survivors.get(entry.agentSessionID);
919
+
920
+ if (survivor !== undefined && survivor !== entry.sessionID) {
921
+ replaced.set(entry.sessionID, survivor);
922
+ }
923
+ }
924
+
925
+ const kept = entries.filter((entry) => !replaced.has(entry.sessionID));
926
+
927
+ const order = new Map(kept.map((entry, index) => [entry.sessionID, index]));
928
+ const links = new Map(entries.map((entry) => [entry.sessionID, entry.parent]));
929
+
930
+ const bound = entries.length;
931
+
932
+ // The surviving row an entry's parent link resolves to, or undefined when
933
+ // it resolves to no row the fleet keeps.
934
+ const findSurvivingParent = (entry: FleetEntry): SessionID | undefined => {
935
+ let link = entry.parent;
936
+
937
+ for (let step = 0; step < bound && link !== undefined; step++) {
938
+ const target = replaced.get(link) ?? link;
939
+
940
+ if (target !== entry.sessionID) {
941
+ return order.has(target) ? target : undefined;
942
+ }
943
+
944
+ link = links.get(link);
945
+ }
946
+
947
+ return undefined;
948
+ };
949
+
950
+ const linked = new Map(kept.map((entry) => [entry.sessionID, findSurvivingParent(entry)]));
951
+
952
+ // The row written first in the cycle a walk up from this row enters, or
953
+ // undefined when the walk reaches a top-level row.
954
+ const findCycleStart = (sessionID: SessionID): SessionID | undefined => {
955
+ const seen: SessionID[] = [];
956
+ let current: SessionID | undefined = sessionID;
957
+
958
+ for (let step = 0; step <= bound && current !== undefined; step++) {
959
+ const at = seen.indexOf(current);
960
+
961
+ if (at !== -1) {
962
+ return seen.slice(at).toSorted((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0))[0];
963
+ }
964
+
965
+ seen.push(current);
966
+
967
+ current = linked.get(current);
968
+ }
969
+
970
+ return undefined;
971
+ };
972
+
973
+ for (const entry of kept) {
974
+ const cycleStart = findCycleStart(entry.sessionID);
975
+
976
+ if (cycleStart !== undefined) {
977
+ linked.set(cycleStart, undefined);
978
+ }
979
+ }
980
+
981
+ // The top-level row a walk up from this row reaches.
982
+ const findTopLevel = (sessionID: SessionID): SessionID => {
983
+ let current = sessionID;
984
+
985
+ for (let step = 0; step < bound; step++) {
986
+ const parent = linked.get(current);
987
+
988
+ if (parent === undefined) {
989
+ return current;
990
+ }
991
+
992
+ current = parent;
993
+ }
994
+
995
+ return current;
996
+ };
997
+
998
+ const relinked: FleetEntry[] = [];
999
+
1000
+ for (const entry of kept) {
1001
+ const { parent: _parent, ...rest } = entry;
1002
+ const top = findTopLevel(entry.sessionID);
1003
+ const row = top === entry.sessionID ? rest : { ...rest, parent: top };
1004
+
1005
+ relinked.push(row);
1006
+ }
1007
+
1008
+ return relinked;
1009
+ }
1010
+
1011
+ // The ownership epoch this daemon writes under. Nothing moves a session to
1012
+ // a later epoch yet, so every row this daemon owns holds this one.
1013
+ const OWNER_EPOCH = 1;
1014
+
1015
+ // Rejects a write that touches a session another daemon owns, or one whose
1016
+ // stored epoch has moved past this daemon's: the write is stale.
1017
+ async function requireCurrentOwnership(
1018
+ trx: Transaction<StateStoreSchema>, // oxlint-disable-line prefer-readonly-parameter-types -- a kysely transaction bound to a live connection; not meaningfully freezable
1019
+ daemonID: DaemonID,
1020
+ sessionIDs: readonly SessionID[],
1021
+ ): Promise<void> {
1022
+ if (sessionIDs.length === 0) {
1023
+ return;
1024
+ }
1025
+
1026
+ const stale = await trx
1027
+ .selectFrom('session_owner')
1028
+ .select(['session_id', 'daemon_id', 'owner_epoch'])
1029
+ .where('session_id', 'in', sessionIDs)
1030
+ .where((eb) => eb.or([eb('daemon_id', '!=', daemonID), eb('owner_epoch', '!=', OWNER_EPOCH)]))
1031
+ .executeTakeFirst();
1032
+
1033
+ if (stale !== undefined) {
1034
+ throw new DaemonError(
1035
+ 'stale_epoch',
1036
+ `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}`,
1037
+ );
1038
+ }
1039
+ }
1040
+
1041
+ // Mints each legacy entry an atc session id, then moves each sub-session
1042
+ // link from the parent's agent session id to the parent's minted id.
1043
+ function buildFleetFromLegacy(legacy: readonly LegacyFleetEntry[]): FleetEntry[] {
1044
+ const minted = new Map(legacy.map((entry) => [entry.agentSessionID, toSessionID(randomUUID())]));
1045
+
1046
+ return legacy.map((entry) => {
1047
+ const { parent, ...rest } = entry;
1048
+ const parentID = parent === undefined ? undefined : minted.get(parent);
1049
+
1050
+ return {
1051
+ ...rest,
1052
+ sessionID: minted.get(entry.agentSessionID) ?? toSessionID(randomUUID()),
1053
+ ...(parentID === undefined ? {} : { parent: parentID }),
1054
+ };
1055
+ });
1056
+ }
1057
+
537
1058
  function buildScopeMatch(
538
1059
  eb: ExpressionBuilder<StateStoreSchema, 'events'>, // oxlint-disable-line prefer-readonly-parameter-types -- a kysely expression builder bound to a live query; not meaningfully freezable
539
1060
  scope: EventScope | null,
@@ -542,9 +1063,13 @@ function buildScopeMatch(
542
1063
  return eb.lit(true);
543
1064
  }
544
1065
 
1066
+ if (scope.atcIDs.length === 0 && scope.agentSessionIDs.length === 0) {
1067
+ return eb.lit(false);
1068
+ }
1069
+
545
1070
  return eb.or([
546
- eb('atc_id', '=', scope.atcID),
547
- ...(scope.agentSessionID === undefined ? [] : [eb('session_id', '=', scope.agentSessionID)]),
1071
+ ...(scope.atcIDs.length === 0 ? [] : [eb('atc_id', 'in', scope.atcIDs)]),
1072
+ ...(scope.agentSessionIDs.length === 0 ? [] : [eb('session_id', 'in', scope.agentSessionIDs)]),
548
1073
  ]);
549
1074
  }
550
1075
 
@@ -614,6 +1139,24 @@ function buildOwnerFilter(
614
1139
  : eb.or([byAtcID, eb('agent_session_id', '=', owner.agentSessionID)]);
615
1140
  }
616
1141
 
1142
+ function toIdempotencyRecord(row: Readonly<StateStoreSchema['idempotency']>): IdempotencyRecord {
1143
+ return {
1144
+ principal: row.principal,
1145
+ operation: row.operation,
1146
+ key: row.key,
1147
+ payloadHash: row.payload_hash,
1148
+ state: row.state,
1149
+ effectRef: row.effect_ref,
1150
+ result: row.result,
1151
+ effectTarget:
1152
+ row.effect_target === null || row.effect_target_identity === null
1153
+ ? null
1154
+ : { target: row.effect_target, targetIdentity: row.effect_target_identity },
1155
+ createdAt: row.created_at,
1156
+ updatedAt: row.updated_at,
1157
+ };
1158
+ }
1159
+
617
1160
  function toMessageRecord(row: Readonly<StateStoreSchema['messages']>): MessageRecord {
618
1161
  return {
619
1162
  id: toMessageID(row.id),
@@ -631,3 +1174,23 @@ function toMessageRecord(row: Readonly<StateStoreSchema['messages']>): MessageRe
631
1174
  ...(row.turn_id === null ? {} : { turn: row.turn_id }),
632
1175
  };
633
1176
  }
1177
+
1178
+ function toWorkspaceMaterialization(
1179
+ row: Readonly<StateStoreSchema['workspace_materialization']>,
1180
+ ): WorkspaceMaterialization {
1181
+ return {
1182
+ sessionID: toSessionID(row.session_id),
1183
+ target: row.target,
1184
+ dir: row.dir,
1185
+ sourceKind: row.source_kind,
1186
+ phase: row.phase,
1187
+ repoURL: row.repo_url,
1188
+ sha: row.sha,
1189
+ ref: row.ref,
1190
+ errorCode: row.error_code,
1191
+ startedAt: row.started_at,
1192
+ updatedAt: row.updated_at,
1193
+ materializedAt: row.materialized_at,
1194
+ withheldEnv: parseWithheldEnv(row.withheld_env),
1195
+ };
1196
+ }