@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,21 +1,28 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { sql } from 'kysely';
2
3
  import type { Generated, Kysely } from 'kysely';
3
4
  import { DEFAULT_MIGRATION_TABLE, Migrator } from 'kysely/migration';
4
5
  import type { Migration, MigrationProvider, MigrationResultSet } from 'kysely/migration';
6
+ import type { IdempotencyState } from './idempotency-record';
5
7
  import type { MessageStatus } from './message-record';
6
8
 
7
9
  interface FleetTable {
8
- agent_session_id: string;
10
+ session_id: string;
11
+ agent_session_id: string | null;
9
12
  name: string;
10
13
  cwd: string;
11
14
  pinned: number;
12
15
  last_attached: number | null;
13
16
  agent: string;
14
17
  exited: number;
15
- parent: string | null;
18
+ parent_session_id: string | null;
16
19
  prompt: string | null;
17
20
  result: string | null;
18
21
  transcript_path: string | null;
22
+ model: string | null;
23
+ effort: string | null;
24
+ target: string | null;
25
+ target_identity: string | null;
19
26
  }
20
27
 
21
28
  interface EventsTable {
@@ -39,6 +46,37 @@ interface PrefsTable {
39
46
  value: string;
40
47
  }
41
48
 
49
+ // Which daemon owns each persisted session, and under which ownership
50
+ // epoch. A write from a daemon whose epoch is behind the stored one is
51
+ // stale and rejected.
52
+ interface SessionOwnerTable {
53
+ session_id: string;
54
+ daemon_id: string;
55
+ owner_epoch: Generated<number>;
56
+ updated_at: number;
57
+ }
58
+
59
+ // One idempotency key per principal and operation: the hash of the payload
60
+ // it first arrived with, where the keyed effect stands, and the effect it
61
+ // names. A key whose outcome is unknown never expires, so a retry of it can
62
+ // never start a fresh effect.
63
+ interface IdempotencyTable {
64
+ principal: string;
65
+ operation: string;
66
+ key: string;
67
+ payload_hash: string;
68
+ state: IdempotencyState;
69
+ effect_ref: string;
70
+ result: string | null;
71
+ created_at: number;
72
+ updated_at: number;
73
+
74
+ // The target, and its identity, the completed effect's session was bound
75
+ // to; null for a key completed without one.
76
+ effect_target: string | null;
77
+ effect_target_identity: string | null;
78
+ }
79
+
42
80
  interface MessagesTable {
43
81
  id: string;
44
82
  atc_id: string;
@@ -66,11 +104,14 @@ export interface StateStoreSchema {
66
104
  spawn_history: SpawnHistoryTable;
67
105
  prefs: PrefsTable;
68
106
  messages: MessagesTable;
107
+ session_owner: SessionOwnerTable;
108
+ idempotency: IdempotencyTable;
69
109
  }
70
110
 
71
111
  // Every shape the fleet table has shipped with: the oldest carries only
72
112
  // agent_session_id under its Claude-era name plus name and cwd, and each
73
- // later step adds one column the daemon grew to depend on. events later gains
113
+ // later step adds one column the daemon grew to depend on, until the
114
+ // rebuild that keys the table by the atc session id. events later gains
74
115
  // columns of its own, as does messages, while spawn_history and prefs have
75
116
  // carried one shape since they were added.
76
117
  const MIGRATIONS: Record<string, Migration> = {
@@ -232,6 +273,94 @@ const MIGRATIONS: Record<string, Migration> = {
232
273
  .execute();
233
274
  },
234
275
  },
276
+ '014_add_fleet_model_effort': {
277
+ async up(db: Kysely<StateStoreSchema>) {
278
+ await db.schema.alterTable('fleet').addColumn('model', 'text').execute();
279
+ await db.schema.alterTable('fleet').addColumn('effort', 'text').execute();
280
+ },
281
+ },
282
+ '015_rebuild_fleet_keyed_by_session_id': {
283
+ async up(db: Kysely<StateStoreSchema>) {
284
+ // The ledger records this step only after it returns, so a crash in
285
+ // between leaves a rebuilt table behind a ledger that still lacks it.
286
+ const columns = await collectFleetColumns(db);
287
+
288
+ if (columns.has('session_id')) {
289
+ return;
290
+ }
291
+
292
+ // SQLite takes this DDL inside a transaction even though kysely's
293
+ // adapter declines to open one for it, so the rebuild lands whole or
294
+ // not at all.
295
+ await sql`BEGIN IMMEDIATE`.execute(db);
296
+
297
+ try {
298
+ await updateFleetKeyToSessionID(db);
299
+
300
+ await sql`COMMIT`.execute(db);
301
+ } catch (error) {
302
+ await sql`ROLLBACK`.execute(db);
303
+
304
+ throw error;
305
+ }
306
+ },
307
+ },
308
+ '016_create_session_owner': {
309
+ async up(db: Kysely<StateStoreSchema>) {
310
+ await sql`BEGIN IMMEDIATE`.execute(db);
311
+
312
+ try {
313
+ await createSessionOwners(db);
314
+
315
+ await sql`COMMIT`.execute(db);
316
+ } catch (error) {
317
+ await sql`ROLLBACK`.execute(db);
318
+
319
+ throw error;
320
+ }
321
+ },
322
+ },
323
+ '017_create_idempotency': {
324
+ async up(db: Kysely<StateStoreSchema>) {
325
+ await db.schema
326
+ .createTable('idempotency')
327
+ .ifNotExists()
328
+ .addColumn('principal', 'text', (c) => c.notNull())
329
+ .addColumn('operation', 'text', (c) => c.notNull())
330
+ .addColumn('key', 'text', (c) => c.notNull())
331
+ .addColumn('payload_hash', 'text', (c) => c.notNull())
332
+ .addColumn('state', 'text', (c) => c.notNull())
333
+ .addColumn('effect_ref', 'text', (c) => c.notNull())
334
+ .addColumn('result', 'text')
335
+ .addColumn('created_at', 'integer', (c) => c.notNull())
336
+ .addColumn('updated_at', 'integer', (c) => c.notNull())
337
+ .addPrimaryKeyConstraint('idempotency_pk', ['principal', 'operation', 'key'])
338
+ .execute();
339
+
340
+ await db.schema
341
+ .createIndex('idempotency_state_updated_at')
342
+ .ifNotExists()
343
+ .on('idempotency')
344
+ .columns(['state', 'updated_at'])
345
+ .execute();
346
+ },
347
+ },
348
+ '018_add_fleet_target': {
349
+ async up(db: Kysely<StateStoreSchema>) {
350
+ await db.schema.alterTable('fleet').addColumn('target', 'text').execute();
351
+ await db.schema.alterTable('fleet').addColumn('target_identity', 'text').execute();
352
+ },
353
+ },
354
+ '019_add_idempotency_effect_target': {
355
+ async up(db: Kysely<StateStoreSchema>) {
356
+ await db.schema.alterTable('idempotency').addColumn('effect_target', 'text').execute();
357
+
358
+ await db.schema
359
+ .alterTable('idempotency')
360
+ .addColumn('effect_target_identity', 'text')
361
+ .execute();
362
+ },
363
+ },
235
364
  };
236
365
 
237
366
  const PROVIDER: MigrationProvider = {
@@ -243,8 +372,8 @@ const PROVIDER: MigrationProvider = {
243
372
  * `Migrator`, one additive step at a time. A database from before the
244
373
  * ladder existed is recognized at whichever shape it stopped at by a
245
374
  * baselining pass that records the steps its columns already satisfy, so
246
- * only what is genuinely missing runs. Nothing here ever drops a column or
247
- * a row.
375
+ * only what is genuinely missing runs. One step rebuilds the fleet table
376
+ * under a new key and carries every row across; no step drops a row.
248
377
  */
249
378
  export async function runMigrations(db: Kysely<StateStoreSchema>): Promise<void> {
250
379
  // A baselined step can land out of order relative to a step that turned
@@ -314,8 +443,128 @@ async function recordLegacyBaseline(db: Kysely<StateStoreSchema>): Promise<void>
314
443
  }
315
444
  }
316
445
 
446
+ // Mints the store's daemon id when it has none, then records that daemon as
447
+ // the owner of every fleet row, at the first ownership epoch.
448
+ async function createSessionOwners(db: Kysely<StateStoreSchema>): Promise<void> {
449
+ await db.schema
450
+ .createTable('session_owner')
451
+ .ifNotExists()
452
+ .addColumn('session_id', 'text', (c) => c.primaryKey())
453
+ .addColumn('daemon_id', 'text', (c) => c.notNull())
454
+ .addColumn('owner_epoch', 'integer', (c) => c.notNull().defaultTo(1))
455
+ .addColumn('updated_at', 'integer', (c) => c.notNull())
456
+ .execute();
457
+
458
+ await db
459
+ .insertInto('prefs')
460
+ .values({ key: 'daemon_id', value: randomUUID() })
461
+ .onConflict((oc) => oc.column('key').doNothing())
462
+ .execute();
463
+
464
+ await sql`
465
+ INSERT OR IGNORE INTO session_owner (session_id, daemon_id, updated_at)
466
+ SELECT fleet.session_id, prefs.value, ${Date.now()}
467
+ FROM fleet JOIN prefs ON prefs.key = 'daemon_id'
468
+ `.execute(db);
469
+ }
470
+
471
+ interface LegacyFleetIDRow {
472
+ old_rowid: number;
473
+ agent_session_id: string | null;
474
+ }
475
+
476
+ // The columns the rebuild writes by name. Any other column a legacy table
477
+ // carries moves across untouched, so the rebuild never loses data a step
478
+ // outside this ladder added.
479
+ const REBUILT_FLEET_COLUMNS: ReadonlySet<string> = new Set([
480
+ 'agent_session_id',
481
+ 'name',
482
+ 'cwd',
483
+ 'pinned',
484
+ 'last_attached',
485
+ 'agent',
486
+ 'exited',
487
+ 'parent',
488
+ 'prompt',
489
+ 'result',
490
+ 'transcript_path',
491
+ 'model',
492
+ 'effort',
493
+ ]);
494
+
495
+ // Gives every row a minted atc session id and moves the sub-session link
496
+ // from the parent's agent session id to the parent's atc session id. A link
497
+ // to an agent session id no row holds becomes no link.
498
+ async function updateFleetKeyToSessionID(db: Kysely<StateStoreSchema>): Promise<void> {
499
+ const columns = await sql<ColumnInfoRow>`PRAGMA table_info(fleet)`.execute(db);
500
+
501
+ const extras = columns.rows.filter((column) => !REBUILT_FLEET_COLUMNS.has(column.name));
502
+
503
+ // Keyed by rowid: SQLite lets a text primary key hold NULL, so a legacy
504
+ // row can lack the agent session id the mapping would otherwise key on.
505
+ await sql`CREATE TEMP TABLE fleet_ids (old_rowid INTEGER PRIMARY KEY, agent_session_id TEXT, session_id TEXT NOT NULL)`.execute(
506
+ db,
507
+ );
508
+
509
+ const rows =
510
+ await sql<LegacyFleetIDRow>`SELECT rowid AS old_rowid, agent_session_id FROM fleet`.execute(db);
511
+
512
+ for (const row of rows.rows) {
513
+ await sql`INSERT INTO fleet_ids (old_rowid, agent_session_id, session_id) VALUES (${row.old_rowid}, ${row.agent_session_id}, ${randomUUID()})`.execute(
514
+ db,
515
+ );
516
+ }
517
+
518
+ const extraDefinitions = extras.map(
519
+ (column) => sql`, ${sql.ref(column.name)} ${sql.raw(column.type)}`,
520
+ );
521
+
522
+ const extraTargets = extras.map((column) => sql`, ${sql.ref(column.name)}`);
523
+ const extraSources = extras.map((column) => sql`, ${sql.ref(`f.${column.name}`)}`);
524
+
525
+ await sql`
526
+ CREATE TABLE fleet_rebuilt (
527
+ session_id TEXT PRIMARY KEY,
528
+ agent_session_id TEXT UNIQUE,
529
+ name TEXT NOT NULL,
530
+ cwd TEXT NOT NULL,
531
+ pinned INTEGER NOT NULL DEFAULT 0,
532
+ last_attached INTEGER,
533
+ agent TEXT NOT NULL DEFAULT 'claude',
534
+ exited INTEGER NOT NULL DEFAULT 0,
535
+ parent_session_id TEXT,
536
+ prompt TEXT,
537
+ result TEXT,
538
+ transcript_path TEXT,
539
+ model TEXT,
540
+ effort TEXT
541
+ ${sql.join(extraDefinitions, sql``)}
542
+ )
543
+ `.execute(db);
544
+
545
+ await sql`
546
+ INSERT INTO fleet_rebuilt (
547
+ session_id, agent_session_id, name, cwd, pinned, last_attached, agent, exited,
548
+ parent_session_id, prompt, result, transcript_path, model, effort
549
+ ${sql.join(extraTargets, sql``)}
550
+ )
551
+ SELECT
552
+ ids.session_id, f.agent_session_id, f.name, f.cwd, f.pinned, f.last_attached, f.agent,
553
+ f.exited, parents.session_id, f.prompt, f.result, f.transcript_path, f.model, f.effort
554
+ ${sql.join(extraSources, sql``)}
555
+ FROM fleet f
556
+ JOIN fleet_ids ids ON ids.old_rowid = f.rowid
557
+ LEFT JOIN fleet_ids parents ON parents.agent_session_id = f.parent
558
+ `.execute(db);
559
+
560
+ await sql`DROP TABLE fleet`.execute(db);
561
+ await sql`ALTER TABLE fleet_rebuilt RENAME TO fleet`.execute(db);
562
+ await sql`DROP TABLE fleet_ids`.execute(db);
563
+ }
564
+
317
565
  interface ColumnInfoRow {
318
566
  name: string;
567
+ type: string;
319
568
  }
320
569
 
321
570
  async function collectFleetColumns(db: Kysely<StateStoreSchema>): Promise<ReadonlySet<string>> {