@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
@@ -4,20 +4,29 @@ import type { AgentID } from '../agents/agent-adapter';
4
4
  import type { AgentSessionID } from '../shared/agent-session-id';
5
5
  import { buildOptionalBoolean } from '../shared/build-optional-boolean';
6
6
  import { buildOptionalString } from '../shared/build-optional-string';
7
+ import type { DaemonID } from '../shared/daemon-id';
7
8
  import { isRecord } from '../shared/report';
9
+ import type { SessionID } from '../shared/session-id';
8
10
  import { toAgentSessionID } from '../shared/to-agent-session-id';
11
+ import type { SessionWorkspace } from './workspace-materialization';
9
12
 
13
+ // One fleet row. The atc session id keys it and stays the same for the
14
+ // session's whole life, across daemon restarts and fleet restores.
10
15
  export interface FleetEntry {
16
+ readonly sessionID: SessionID;
11
17
  readonly name: string;
12
18
  readonly cwd: string;
13
- readonly agentSessionID: AgentSessionID;
19
+
20
+ // Absent until the agent reports its own session id; a row without one
21
+ // restores as an exited session, since there is nothing to resume.
22
+ readonly agentSessionID?: AgentSessionID;
14
23
  readonly agent: AgentID;
15
24
  readonly pinned?: boolean;
16
25
  readonly lastAttachedAt?: number;
17
26
  readonly exited?: boolean;
18
27
 
19
- // The agent session id of the session this one is a sub-session of.
20
- readonly parent?: AgentSessionID;
28
+ // The atc session id of the session this one is a sub-session of.
29
+ readonly parent?: SessionID;
21
30
 
22
31
  // The prompt the session was spawned with.
23
32
  readonly prompt?: string;
@@ -27,15 +36,44 @@ export interface FleetEntry {
27
36
 
28
37
  // The transcript file the agent's hooks last reported for the session.
29
38
  readonly transcriptPath?: string;
39
+
40
+ // The model and effort the session was spawned with; absent for a session
41
+ // that runs on the agent's configured default.
42
+ readonly model?: string;
43
+ readonly effort?: string;
44
+
45
+ // The execution target the session runs on; a row without one restores
46
+ // on the `local` target.
47
+ readonly target?: string;
48
+
49
+ // The identity the target had when the session started on it; a row
50
+ // without one is bound to the implicit `local` target's identity.
51
+ readonly targetIdentity?: string;
52
+
53
+ // What the session's workspace was materialized from, for a session
54
+ // spawned with a workspace source; a fleet write leaves it to the
55
+ // materialization record that holds it.
56
+ readonly workspace?: SessionWorkspace;
57
+
58
+ // The environment variable names every harness the session starts goes
59
+ // without, held with its ready workspace; never their values.
60
+ readonly withheldEnv?: readonly string[];
61
+
62
+ // What the operator asked of the harness: absent keeps it running,
63
+ // `sleep` keeps its host asleep, and `stop` leaves it ended.
64
+ readonly desired?: 'sleep' | 'stop';
65
+
66
+ // The session whose host the harness runs on: its own id, or its
67
+ // parent's when the two share one host. A row without one runs on a host
68
+ // of its own.
69
+ readonly hostKey?: SessionID;
30
70
  }
31
71
 
32
72
  export interface FleetStore {
73
+ readonly daemonID: DaemonID;
33
74
  readonly loadFleet: () => Promise<FleetEntry[]>;
34
75
  readonly writeFleet: (entries: readonly FleetEntry[]) => Promise<void>;
35
- readonly updateFleetEntry: (
36
- agentSessionID: AgentSessionID,
37
- fields: FleetEntryUpdate,
38
- ) => Promise<void>;
76
+ readonly updateFleetEntry: (sessionID: SessionID, fields: FleetEntryUpdate) => Promise<void>;
39
77
  }
40
78
 
41
79
  // The fields a session rewrites on its own row while it runs, without
@@ -45,7 +83,21 @@ export interface FleetEntryUpdate {
45
83
  readonly transcriptPath?: string;
46
84
  }
47
85
 
48
- // A stored fleet row's keys. name, cwd, and the resolved agentSessionID are
86
+ // One entry of a legacy fleet.json, from before the fleet moved into the
87
+ // state store: keyed by the agent session id, with the parent link held as
88
+ // the parent's agent session id.
89
+ export interface LegacyFleetEntry {
90
+ readonly name: string;
91
+ readonly cwd: string;
92
+ readonly agentSessionID: AgentSessionID;
93
+ readonly agent: AgentID;
94
+ readonly pinned?: boolean;
95
+ readonly lastAttachedAt?: number;
96
+ readonly exited?: boolean;
97
+ readonly parent?: AgentSessionID;
98
+ }
99
+
100
+ // A legacy fleet.json entry's keys. name, cwd, and the resolved agentSessionID are
49
101
  // required: a row missing any of them cannot restore a session, so the whole
50
102
  // row parses to undefined rather than a half-built entry. Fleet files
51
103
  // written before the id key was agent-neutral carry it under its Claude-era
@@ -71,7 +123,7 @@ const FLEET_ENTRY_SCHEMA = z.preprocess(
71
123
  }),
72
124
  );
73
125
 
74
- export function parseFleetEntry(raw: unknown): FleetEntry | undefined {
126
+ export function parseFleetEntry(raw: unknown): LegacyFleetEntry | undefined {
75
127
  const parsed = FLEET_ENTRY_SCHEMA.safeParse(raw);
76
128
 
77
129
  if (!parsed.success) {
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Where a keyed effect stands. `in_progress` holds while the daemon that
3
+ * claimed the key runs the effect; `completed` once the effect is durable;
4
+ * `outcome_unknown` when a daemon stopped mid-effect and its start-up
5
+ * reconciliation found no trace of the effect.
6
+ */
7
+ export type IdempotencyState = 'in_progress' | 'completed' | 'outcome_unknown';
8
+
9
+ // One idempotency key and the effect it names.
10
+ export interface IdempotencyRecord {
11
+ readonly principal: string;
12
+ readonly operation: string;
13
+ readonly key: string;
14
+ readonly payloadHash: string;
15
+ readonly state: IdempotencyState;
16
+
17
+ // The id the effect was minted before it ran: the session a spawn
18
+ // created, or the message a send wrote.
19
+ readonly effectRef: string;
20
+
21
+ // The effect's answer as JSON, once completed by the daemon that ran it.
22
+ readonly result: string | null;
23
+
24
+ // The target the completed effect's session was bound to, as it stood
25
+ // then; null for a key completed without one.
26
+ readonly effectTarget: EffectTarget | null;
27
+ readonly createdAt: number;
28
+ readonly updatedAt: number;
29
+ }
30
+
31
+ /**
32
+ * A target name and the identity it held, which a replay of the key is
33
+ * authorized against.
34
+ */
35
+ export interface EffectTarget {
36
+ readonly target: string;
37
+ readonly targetIdentity: string;
38
+ }
39
+
40
+ // The claim a keyed request makes before its effect runs.
41
+ export interface IdempotencyClaim {
42
+ readonly principal: string;
43
+ readonly operation: string;
44
+ readonly key: string;
45
+ readonly payloadHash: string;
46
+ readonly effectRef: string;
47
+ readonly at: number;
48
+ }
@@ -2,7 +2,7 @@ import type { AgentSessionID } from '../shared/agent-session-id';
2
2
  import type { SessionID } from '../shared/session-id';
3
3
 
4
4
  // Which session a message belongs to: the atc id it was sent to, or the
5
- // agent session id that survives a restore's re-minted atc id.
5
+ // agent session id, which links rows written under an earlier atc id.
6
6
  export interface MessageOwner {
7
7
  readonly atcID: SessionID;
8
8
  readonly agentSessionID?: AgentSessionID;
@@ -1,21 +1,38 @@
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';
8
+ import type { MaterializationPhase } from './workspace-materialization';
6
9
 
7
10
  interface FleetTable {
8
- agent_session_id: string;
11
+ session_id: string;
12
+ agent_session_id: string | null;
9
13
  name: string;
10
14
  cwd: string;
11
15
  pinned: number;
12
16
  last_attached: number | null;
13
17
  agent: string;
14
18
  exited: number;
15
- parent: string | null;
19
+ parent_session_id: string | null;
16
20
  prompt: string | null;
17
21
  result: string | null;
18
22
  transcript_path: string | null;
23
+ model: string | null;
24
+ effort: string | null;
25
+ target: string | null;
26
+ target_identity: string | null;
27
+
28
+ // What the operator asked of the session's harness: null keeps it
29
+ // running, `sleep` keeps its host asleep, and `stop` leaves it ended.
30
+ desired: string | null;
31
+
32
+ // The session whose host this session's harness runs on: its own id, or
33
+ // its parent's when the two share one host. Null for a row from before
34
+ // hosts were recorded, which runs on its own.
35
+ host_key: string | null;
19
36
  }
20
37
 
21
38
  interface EventsTable {
@@ -27,6 +44,10 @@ interface EventsTable {
27
44
  session_id: string | null;
28
45
  kind: string | null;
29
46
  detail: string | null;
47
+
48
+ // The id a remote session's reporter gave a report row, unique so a
49
+ // resent report lands once; null on every other row.
50
+ report_id: string | null;
30
51
  }
31
52
 
32
53
  interface SpawnHistoryTable {
@@ -39,6 +60,58 @@ interface PrefsTable {
39
60
  value: string;
40
61
  }
41
62
 
63
+ // Which daemon owns each persisted session, and under which ownership
64
+ // epoch. A write from a daemon whose epoch is behind the stored one is
65
+ // stale and rejected.
66
+ interface SessionOwnerTable {
67
+ session_id: string;
68
+ daemon_id: string;
69
+ owner_epoch: Generated<number>;
70
+ updated_at: number;
71
+ }
72
+
73
+ // One idempotency key per principal and operation: the hash of the payload
74
+ // it first arrived with, where the keyed effect stands, and the effect it
75
+ // names. A key whose outcome is unknown never expires, so a retry of it can
76
+ // never start a fresh effect.
77
+ interface IdempotencyTable {
78
+ principal: string;
79
+ operation: string;
80
+ key: string;
81
+ payload_hash: string;
82
+ state: IdempotencyState;
83
+ effect_ref: string;
84
+ result: string | null;
85
+ created_at: number;
86
+ updated_at: number;
87
+
88
+ // The target, and its identity, the completed effect's session was bound
89
+ // to; null for a key completed without one.
90
+ effect_target: string | null;
91
+ effect_target_identity: string | null;
92
+ }
93
+
94
+ // One spawn's workspace on its way to the execution target, keyed by the
95
+ // session it is for.
96
+ interface WorkspaceMaterializationTable {
97
+ session_id: string;
98
+ target: string;
99
+ dir: string;
100
+ source_kind: 'path' | 'git';
101
+ phase: MaterializationPhase;
102
+ repo_url: string | null;
103
+ sha: string | null;
104
+ ref: string | null;
105
+ error_code: string | null;
106
+ started_at: number;
107
+ updated_at: number;
108
+ materialized_at: number | null;
109
+
110
+ // JSON array of the environment variable names every harness the session
111
+ // starts goes without; names only, never their values.
112
+ withheld_env: string | null;
113
+ }
114
+
42
115
  interface MessagesTable {
43
116
  id: string;
44
117
  atc_id: string;
@@ -66,11 +139,15 @@ export interface StateStoreSchema {
66
139
  spawn_history: SpawnHistoryTable;
67
140
  prefs: PrefsTable;
68
141
  messages: MessagesTable;
142
+ session_owner: SessionOwnerTable;
143
+ idempotency: IdempotencyTable;
144
+ workspace_materialization: WorkspaceMaterializationTable;
69
145
  }
70
146
 
71
147
  // Every shape the fleet table has shipped with: the oldest carries only
72
148
  // 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
149
+ // later step adds one column the daemon grew to depend on, until the
150
+ // rebuild that keys the table by the atc session id. events later gains
74
151
  // columns of its own, as does messages, while spawn_history and prefs have
75
152
  // carried one shape since they were added.
76
153
  const MIGRATIONS: Record<string, Migration> = {
@@ -232,6 +309,134 @@ const MIGRATIONS: Record<string, Migration> = {
232
309
  .execute();
233
310
  },
234
311
  },
312
+ '014_add_fleet_model_effort': {
313
+ async up(db: Kysely<StateStoreSchema>) {
314
+ await db.schema.alterTable('fleet').addColumn('model', 'text').execute();
315
+ await db.schema.alterTable('fleet').addColumn('effort', 'text').execute();
316
+ },
317
+ },
318
+ '015_rebuild_fleet_keyed_by_session_id': {
319
+ async up(db: Kysely<StateStoreSchema>) {
320
+ // The ledger records this step only after it returns, so a crash in
321
+ // between leaves a rebuilt table behind a ledger that still lacks it.
322
+ const columns = await collectFleetColumns(db);
323
+
324
+ if (columns.has('session_id')) {
325
+ return;
326
+ }
327
+
328
+ // SQLite takes this DDL inside a transaction even though kysely's
329
+ // adapter declines to open one for it, so the rebuild lands whole or
330
+ // not at all.
331
+ await sql`BEGIN IMMEDIATE`.execute(db);
332
+
333
+ try {
334
+ await updateFleetKeyToSessionID(db);
335
+
336
+ await sql`COMMIT`.execute(db);
337
+ } catch (error) {
338
+ await sql`ROLLBACK`.execute(db);
339
+
340
+ throw error;
341
+ }
342
+ },
343
+ },
344
+ '016_create_session_owner': {
345
+ async up(db: Kysely<StateStoreSchema>) {
346
+ await sql`BEGIN IMMEDIATE`.execute(db);
347
+
348
+ try {
349
+ await createSessionOwners(db);
350
+
351
+ await sql`COMMIT`.execute(db);
352
+ } catch (error) {
353
+ await sql`ROLLBACK`.execute(db);
354
+
355
+ throw error;
356
+ }
357
+ },
358
+ },
359
+ '017_create_idempotency': {
360
+ async up(db: Kysely<StateStoreSchema>) {
361
+ await db.schema
362
+ .createTable('idempotency')
363
+ .ifNotExists()
364
+ .addColumn('principal', 'text', (c) => c.notNull())
365
+ .addColumn('operation', 'text', (c) => c.notNull())
366
+ .addColumn('key', 'text', (c) => c.notNull())
367
+ .addColumn('payload_hash', 'text', (c) => c.notNull())
368
+ .addColumn('state', 'text', (c) => c.notNull())
369
+ .addColumn('effect_ref', 'text', (c) => c.notNull())
370
+ .addColumn('result', 'text')
371
+ .addColumn('created_at', 'integer', (c) => c.notNull())
372
+ .addColumn('updated_at', 'integer', (c) => c.notNull())
373
+ .addPrimaryKeyConstraint('idempotency_pk', ['principal', 'operation', 'key'])
374
+ .execute();
375
+
376
+ await db.schema
377
+ .createIndex('idempotency_state_updated_at')
378
+ .ifNotExists()
379
+ .on('idempotency')
380
+ .columns(['state', 'updated_at'])
381
+ .execute();
382
+ },
383
+ },
384
+ '018_add_fleet_target': {
385
+ async up(db: Kysely<StateStoreSchema>) {
386
+ await db.schema.alterTable('fleet').addColumn('target', 'text').execute();
387
+ await db.schema.alterTable('fleet').addColumn('target_identity', 'text').execute();
388
+ },
389
+ },
390
+ '019_add_idempotency_effect_target': {
391
+ async up(db: Kysely<StateStoreSchema>) {
392
+ await db.schema.alterTable('idempotency').addColumn('effect_target', 'text').execute();
393
+
394
+ await db.schema
395
+ .alterTable('idempotency')
396
+ .addColumn('effect_target_identity', 'text')
397
+ .execute();
398
+ },
399
+ },
400
+ '020_create_workspace_materialization': {
401
+ async up(db: Kysely<StateStoreSchema>) {
402
+ await db.schema
403
+ .createTable('workspace_materialization')
404
+ .ifNotExists()
405
+ .addColumn('session_id', 'text', (c) => c.primaryKey())
406
+ .addColumn('target', 'text', (c) => c.notNull())
407
+ .addColumn('dir', 'text', (c) => c.notNull())
408
+ .addColumn('source_kind', 'text', (c) => c.notNull())
409
+ .addColumn('phase', 'text', (c) => c.notNull())
410
+ .addColumn('repo_url', 'text')
411
+ .addColumn('sha', 'text')
412
+ .addColumn('ref', 'text')
413
+ .addColumn('error_code', 'text')
414
+ .addColumn('started_at', 'integer', (c) => c.notNull())
415
+ .addColumn('updated_at', 'integer', (c) => c.notNull())
416
+ .addColumn('materialized_at', 'integer')
417
+ .addColumn('withheld_env', 'text')
418
+ .execute();
419
+ },
420
+ },
421
+ '021_add_fleet_lifecycle': {
422
+ async up(db: Kysely<StateStoreSchema>) {
423
+ await db.schema.alterTable('fleet').addColumn('desired', 'text').execute();
424
+ await db.schema.alterTable('fleet').addColumn('host_key', 'text').execute();
425
+ },
426
+ },
427
+ '022_add_events_report_id': {
428
+ async up(db: Kysely<StateStoreSchema>) {
429
+ await db.schema.alterTable('events').addColumn('report_id', 'text').execute();
430
+
431
+ await db.schema
432
+ .createIndex('events_report_id')
433
+ .ifNotExists()
434
+ .unique()
435
+ .on('events')
436
+ .column('report_id')
437
+ .execute();
438
+ },
439
+ },
235
440
  };
236
441
 
237
442
  const PROVIDER: MigrationProvider = {
@@ -243,8 +448,8 @@ const PROVIDER: MigrationProvider = {
243
448
  * `Migrator`, one additive step at a time. A database from before the
244
449
  * ladder existed is recognized at whichever shape it stopped at by a
245
450
  * 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.
451
+ * only what is genuinely missing runs. One step rebuilds the fleet table
452
+ * under a new key and carries every row across; no step drops a row.
248
453
  */
249
454
  export async function runMigrations(db: Kysely<StateStoreSchema>): Promise<void> {
250
455
  // A baselined step can land out of order relative to a step that turned
@@ -314,8 +519,128 @@ async function recordLegacyBaseline(db: Kysely<StateStoreSchema>): Promise<void>
314
519
  }
315
520
  }
316
521
 
522
+ // Mints the store's daemon id when it has none, then records that daemon as
523
+ // the owner of every fleet row, at the first ownership epoch.
524
+ async function createSessionOwners(db: Kysely<StateStoreSchema>): Promise<void> {
525
+ await db.schema
526
+ .createTable('session_owner')
527
+ .ifNotExists()
528
+ .addColumn('session_id', 'text', (c) => c.primaryKey())
529
+ .addColumn('daemon_id', 'text', (c) => c.notNull())
530
+ .addColumn('owner_epoch', 'integer', (c) => c.notNull().defaultTo(1))
531
+ .addColumn('updated_at', 'integer', (c) => c.notNull())
532
+ .execute();
533
+
534
+ await db
535
+ .insertInto('prefs')
536
+ .values({ key: 'daemon_id', value: randomUUID() })
537
+ .onConflict((oc) => oc.column('key').doNothing())
538
+ .execute();
539
+
540
+ await sql`
541
+ INSERT OR IGNORE INTO session_owner (session_id, daemon_id, updated_at)
542
+ SELECT fleet.session_id, prefs.value, ${Date.now()}
543
+ FROM fleet JOIN prefs ON prefs.key = 'daemon_id'
544
+ `.execute(db);
545
+ }
546
+
547
+ interface LegacyFleetIDRow {
548
+ old_rowid: number;
549
+ agent_session_id: string | null;
550
+ }
551
+
552
+ // The columns the rebuild writes by name. Any other column a legacy table
553
+ // carries moves across untouched, so the rebuild never loses data a step
554
+ // outside this ladder added.
555
+ const REBUILT_FLEET_COLUMNS: ReadonlySet<string> = new Set([
556
+ 'agent_session_id',
557
+ 'name',
558
+ 'cwd',
559
+ 'pinned',
560
+ 'last_attached',
561
+ 'agent',
562
+ 'exited',
563
+ 'parent',
564
+ 'prompt',
565
+ 'result',
566
+ 'transcript_path',
567
+ 'model',
568
+ 'effort',
569
+ ]);
570
+
571
+ // Gives every row a minted atc session id and moves the sub-session link
572
+ // from the parent's agent session id to the parent's atc session id. A link
573
+ // to an agent session id no row holds becomes no link.
574
+ async function updateFleetKeyToSessionID(db: Kysely<StateStoreSchema>): Promise<void> {
575
+ const columns = await sql<ColumnInfoRow>`PRAGMA table_info(fleet)`.execute(db);
576
+
577
+ const extras = columns.rows.filter((column) => !REBUILT_FLEET_COLUMNS.has(column.name));
578
+
579
+ // Keyed by rowid: SQLite lets a text primary key hold NULL, so a legacy
580
+ // row can lack the agent session id the mapping would otherwise key on.
581
+ await sql`CREATE TEMP TABLE fleet_ids (old_rowid INTEGER PRIMARY KEY, agent_session_id TEXT, session_id TEXT NOT NULL)`.execute(
582
+ db,
583
+ );
584
+
585
+ const rows =
586
+ await sql<LegacyFleetIDRow>`SELECT rowid AS old_rowid, agent_session_id FROM fleet`.execute(db);
587
+
588
+ for (const row of rows.rows) {
589
+ await sql`INSERT INTO fleet_ids (old_rowid, agent_session_id, session_id) VALUES (${row.old_rowid}, ${row.agent_session_id}, ${randomUUID()})`.execute(
590
+ db,
591
+ );
592
+ }
593
+
594
+ const extraDefinitions = extras.map(
595
+ (column) => sql`, ${sql.ref(column.name)} ${sql.raw(column.type)}`,
596
+ );
597
+
598
+ const extraTargets = extras.map((column) => sql`, ${sql.ref(column.name)}`);
599
+ const extraSources = extras.map((column) => sql`, ${sql.ref(`f.${column.name}`)}`);
600
+
601
+ await sql`
602
+ CREATE TABLE fleet_rebuilt (
603
+ session_id TEXT PRIMARY KEY,
604
+ agent_session_id TEXT UNIQUE,
605
+ name TEXT NOT NULL,
606
+ cwd TEXT NOT NULL,
607
+ pinned INTEGER NOT NULL DEFAULT 0,
608
+ last_attached INTEGER,
609
+ agent TEXT NOT NULL DEFAULT 'claude',
610
+ exited INTEGER NOT NULL DEFAULT 0,
611
+ parent_session_id TEXT,
612
+ prompt TEXT,
613
+ result TEXT,
614
+ transcript_path TEXT,
615
+ model TEXT,
616
+ effort TEXT
617
+ ${sql.join(extraDefinitions, sql``)}
618
+ )
619
+ `.execute(db);
620
+
621
+ await sql`
622
+ INSERT INTO fleet_rebuilt (
623
+ session_id, agent_session_id, name, cwd, pinned, last_attached, agent, exited,
624
+ parent_session_id, prompt, result, transcript_path, model, effort
625
+ ${sql.join(extraTargets, sql``)}
626
+ )
627
+ SELECT
628
+ ids.session_id, f.agent_session_id, f.name, f.cwd, f.pinned, f.last_attached, f.agent,
629
+ f.exited, parents.session_id, f.prompt, f.result, f.transcript_path, f.model, f.effort
630
+ ${sql.join(extraSources, sql``)}
631
+ FROM fleet f
632
+ JOIN fleet_ids ids ON ids.old_rowid = f.rowid
633
+ LEFT JOIN fleet_ids parents ON parents.agent_session_id = f.parent
634
+ `.execute(db);
635
+
636
+ await sql`DROP TABLE fleet`.execute(db);
637
+ await sql`ALTER TABLE fleet_rebuilt RENAME TO fleet`.execute(db);
638
+ await sql`DROP TABLE fleet_ids`.execute(db);
639
+ }
640
+
317
641
  interface ColumnInfoRow {
318
642
  name: string;
643
+ type: string;
319
644
  }
320
645
 
321
646
  async function collectFleetColumns(db: Kysely<StateStoreSchema>): Promise<ReadonlySet<string>> {