@zgeoff/atc 2.15.1 → 2.15.2

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.
@@ -0,0 +1,85 @@
1
+ import { PROTOCOL_V } from '../protocol/protocol';
2
+ import type { SessionID } from '../shared/session-id';
3
+ import type { MessageOwner } from '../store/message-owner';
4
+ import type { MessageRecord } from '../store/message-record';
5
+ import type { TapClient } from './daemon-context';
6
+ import type { TapGeneration, TapRegistry } from './tap-registry';
7
+
8
+ /**
9
+ * What a drain reads from: the taps, the owner a linked tap reads messages
10
+ * under (the session's atc id and agent session id, or null for a session
11
+ * the daemon does not hold), and the store's pending messages for an owner.
12
+ */
13
+ export interface InboxSource {
14
+ readonly taps: TapRegistry<TapClient>;
15
+ readonly findLinkedOwner: (sessionID: SessionID) => MessageOwner | null;
16
+ readonly collectPendingMessages: (owner: MessageOwner) => Promise<MessageRecord[]>;
17
+ }
18
+
19
+ /**
20
+ * Hands the session's tap the oldest pending message it has not taken. The
21
+ * backlog is read before anything is sent, so a tap's ok response is always
22
+ * queued ahead of its first message, and one message goes per call: the
23
+ * tap's ack calls it again, so the backlog never outgrows the connection's
24
+ * outbound queue. The drain is bound to the tap it found before the read: a
25
+ * tap another attach made while the read waited gets nothing from it.
26
+ */
27
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- the drain claims deliveries in the tap registry it is given
28
+ export async function drainInbox(sessionID: SessionID, source: InboxSource): Promise<void> {
29
+ const linkedOwner = source.findLinkedOwner(sessionID);
30
+ const tap = source.taps.findTap(sessionID);
31
+
32
+ if (linkedOwner === null || tap === null) {
33
+ return;
34
+ }
35
+
36
+ // A tap a principal holds takes only the messages sent to this atc id:
37
+ // another session that shares the agent session id may be out of its
38
+ // reach.
39
+ const owner = tap.linked ? linkedOwner : { atcID: linkedOwner.atcID };
40
+
41
+ try {
42
+ const pending = await source.collectPendingMessages(owner);
43
+
44
+ for (const record of pending) {
45
+ if (sendInboxMessage(sessionID, record, tap, source.taps)) {
46
+ return;
47
+ }
48
+ }
49
+ } catch {}
50
+ }
51
+
52
+ // Hands one pending message to the tap the drain was made for, once, and
53
+ // reports whether it did. An unlinked tap takes only a message sent to the
54
+ // session's own atc id. The event goes to the tap connection alone: it never
55
+ // reaches other clients, the events socket, or hooks.
56
+ function sendInboxMessage(
57
+ sessionID: SessionID,
58
+ record: MessageRecord,
59
+ tap: TapGeneration,
60
+
61
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a delivery claims the message in the registry
62
+ taps: TapRegistry<TapClient>,
63
+ ): boolean {
64
+ if (!tap.linked && record.atcID !== sessionID) {
65
+ return false;
66
+ }
67
+
68
+ const client = taps.claimDelivery(sessionID, record.id, tap.generation);
69
+
70
+ if (client === null) {
71
+ return false;
72
+ }
73
+
74
+ client.sendEvent({
75
+ v: PROTOCOL_V,
76
+ ev: 'InboxMessage',
77
+ s: sessionID,
78
+ message: record.id,
79
+ from: record.from,
80
+ text: record.text,
81
+ sentAt: record.sentAt,
82
+ });
83
+
84
+ return true;
85
+ }
@@ -0,0 +1,32 @@
1
+ import type { SessionID } from '../shared/session-id';
2
+ import type { TargetAccess, TargetGrant } from './target-access';
3
+
4
+ // A session as the tree check reads it: its id, its parent if it has one,
5
+ // and the target it is bound to.
6
+ interface TreeMember extends TargetGrant {
7
+ readonly id: SessionID;
8
+ readonly parent: SessionID | null;
9
+ }
10
+
11
+ /**
12
+ * Whether the access reaches every session in the tree the given session
13
+ * belongs to: its top-level session and each sub-session of that one. A
14
+ * session the list does not hold, or one whose parent it does not hold,
15
+ * is out of reach.
16
+ */
17
+ export function isTreeInReach(
18
+ sessions: readonly TreeMember[],
19
+ id: SessionID,
20
+ access: TargetAccess,
21
+ ): boolean {
22
+ const session = sessions.find((x) => x.id === id);
23
+
24
+ if (session === undefined) {
25
+ return false;
26
+ }
27
+
28
+ const root = session.parent ?? session.id;
29
+ const tree = sessions.filter((x) => x.id === root || x.parent === root);
30
+
31
+ return tree.some((x) => x.id === root) && tree.every((x) => access.canUse(x));
32
+ }
@@ -479,8 +479,15 @@ export class SessionManager {
479
479
  // Adopts a headless session back into a terminal: a fresh PTY resumes the
480
480
  // same agent session id. On a remote host the host wakes first, and a
481
481
  // harness still running inside it is attached rather than started again;
482
- // every other session left asleep on that host comes back with it.
483
- async adoptTerminal(id: SessionID, cols: number, rows: number): Promise<Session | null> {
482
+ // every other session left asleep on that host comes back with it. The
483
+ // caller's check runs again after each await, and a failed check leaves
484
+ // the session as it was.
485
+ async adoptTerminal(
486
+ id: SessionID,
487
+ cols: number,
488
+ rows: number,
489
+ canProceed: () => boolean = () => true,
490
+ ): Promise<Session | null> {
484
491
  const s = this.sessions.find((x) => x.id === id);
485
492
 
486
493
  if (!s || s.pty !== null || s.agentSessionID === undefined || this.adopting.has(id)) {
@@ -489,7 +496,7 @@ export class SessionManager {
489
496
 
490
497
  const settled = await this.isRollbackSettled(id);
491
498
 
492
- if (!settled) {
499
+ if (!settled || !canProceed()) {
493
500
  return null;
494
501
  }
495
502
 
@@ -516,9 +523,11 @@ export class SessionManager {
516
523
  this.adopting.delete(id);
517
524
  }
518
525
 
519
- // A kill, a second adopt, or a failed spawn's rollback can land while
520
- // the host wakes.
526
+ // A kill, a second adopt, a failed spawn's rollback, or a change that
527
+ // takes the session out of the caller's reach can land while the host
528
+ // wakes.
521
529
  if (
530
+ !canProceed() ||
522
531
  s.pty !== null ||
523
532
  !this.sessions.includes(s) ||
524
533
  this.rollingBack.has(id) ||
@@ -729,6 +738,7 @@ export class SessionManager {
729
738
  id: SessionID = mintSessionID(),
730
739
  target = 'local',
731
740
  materialized: MaterializedSpawn | null = null,
741
+ requireInReach: () => void = () => {},
732
742
  ): Promise<Session> {
733
743
  const adapter = this.findAdapter(agent);
734
744
 
@@ -751,6 +761,10 @@ export class SessionManager {
751
761
  ...overrides,
752
762
  });
753
763
 
764
+ // The caller's check runs again once the host is ready, before the
765
+ // harness starts.
766
+ requireInReach();
767
+
754
768
  const binding = this.mintBridgeBinding(id, target, execution.identity, hostKey);
755
769
 
756
770
  const pty = provider.spawnHarness({
@@ -1248,7 +1262,8 @@ export class SessionManager {
1248
1262
  // every harness on it kept inside, and fails whole when the host stays
1249
1263
  // awake. A dead session on a target that can destroy its host is not
1250
1264
  // forgotten by a kill: forgetting it destroys the host, which takes a
1251
- // confirmed forget.
1265
+ // confirmed forget. The set is taken before the first await, so a
1266
+ // sub-session spawned while the kill waits on a host is not part of it.
1252
1267
  async kill(id: SessionID): Promise<void> {
1253
1268
  const s = this.sessions.find((x) => x.id === id);
1254
1269
 
@@ -1256,11 +1271,15 @@ export class SessionManager {
1256
1271
  return;
1257
1272
  }
1258
1273
 
1274
+ const children = this.collectChildren(id);
1275
+
1259
1276
  if (s.pty) {
1260
1277
  await this.stopHarness(s);
1261
1278
 
1262
- for (const child of this.collectChildren(id)) {
1263
- await this.tryStopHarness(child);
1279
+ for (const child of children) {
1280
+ if (this.sessions.includes(child) && child.parent === id) {
1281
+ await this.tryStopHarness(child);
1282
+ }
1264
1283
  }
1265
1284
  } else {
1266
1285
  if (this.findProvider(s)?.capabilities.destroy === true) {
@@ -1271,8 +1290,7 @@ export class SessionManager {
1271
1290
  );
1272
1291
  }
1273
1292
 
1274
- this.updateForgottenChildren(id);
1275
- this.remove(s);
1293
+ this.removeWithChildren(s, children);
1276
1294
  }
1277
1295
 
1278
1296
  await this.writeFleet();
@@ -1288,7 +1306,9 @@ export class SessionManager {
1288
1306
  * kept asleep in that host is refused until the host wakes. Its dead
1289
1307
  * sub-sessions on other hosts go with it, unless their own target can
1290
1308
  * destroy their host, and its live ones become top-level. A failed destroy
1291
- * throws before anything is forgotten.
1309
+ * throws before anything is forgotten. The sub-sessions it may forget are
1310
+ * taken before the first await; one spawned while the forget waits on a
1311
+ * host becomes top-level.
1292
1312
  */
1293
1313
  async forget(id: SessionID): Promise<boolean> {
1294
1314
  const s = this.sessions.find((x) => x.id === id);
@@ -1297,6 +1317,7 @@ export class SessionManager {
1297
1317
  return false;
1298
1318
  }
1299
1319
 
1320
+ const children = this.collectChildren(id);
1300
1321
  const provider = this.findProvider(s);
1301
1322
  const destroys = provider !== null && provider.capabilities.destroy && s.hostKey === s.id;
1302
1323
 
@@ -1330,8 +1351,7 @@ export class SessionManager {
1330
1351
  this.killTerminal(s);
1331
1352
  }
1332
1353
 
1333
- this.updateForgottenChildren(id);
1334
- this.remove(s);
1354
+ this.removeWithChildren(s, children);
1335
1355
 
1336
1356
  await this.writeFleet();
1337
1357
 
@@ -1340,22 +1360,42 @@ export class SessionManager {
1340
1360
  return destroys;
1341
1361
  }
1342
1362
 
1343
- // A forgotten parent's dead sub-sessions go with it, except one whose own
1344
- // target can destroy its host: forgetting that one destroys the host, which
1345
- // takes its own confirmed forget. Every sub-session that stays becomes
1346
- // top-level.
1347
- private updateForgottenChildren(id: SessionID): void {
1348
- for (const child of this.collectChildren(id)) {
1363
+ // Removes a forgotten session and its dead sub-sessions, except one whose
1364
+ // own target can destroy its host: forgetting that one destroys the host,
1365
+ // which takes its own confirmed forget. Only a sub-session among the given
1366
+ // ones may go. Every sub-session that stays becomes top-level before the
1367
+ // session's removal is announced, and is announced after it, so no
1368
+ // announcement ever finds a sub-session whose parent is gone or a parent
1369
+ // standing without the sub-sessions that leave with it.
1370
+ private removeWithChildren(s: Session, forgettable: readonly Session[]): void {
1371
+ const kept: Session[] = [];
1372
+ const dropped: Session[] = [];
1373
+
1374
+ for (const child of this.collectChildren(s.id)) {
1349
1375
  const live = child.pty !== null || (child.kind === 'headless' && child.state !== 'exited');
1350
1376
 
1351
- if (live || this.findProvider(child)?.capabilities.destroy === true) {
1377
+ if (
1378
+ live ||
1379
+ !forgettable.includes(child) ||
1380
+ this.findProvider(child)?.capabilities.destroy === true
1381
+ ) {
1352
1382
  child.parent = null;
1353
1383
 
1354
- this.onEvent('state', child);
1384
+ kept.push(child);
1355
1385
  } else {
1356
- this.remove(child);
1386
+ dropped.push(child);
1357
1387
  }
1358
1388
  }
1389
+
1390
+ this.remove(s);
1391
+
1392
+ for (const child of kept) {
1393
+ this.onEvent('state', child);
1394
+ }
1395
+
1396
+ for (const child of dropped) {
1397
+ this.remove(child);
1398
+ }
1359
1399
  }
1360
1400
 
1361
1401
  /**
@@ -4,6 +4,23 @@ import type { SessionID } from '../shared/session-id';
4
4
  interface TapEntry<TClient> {
5
5
  readonly client: TClient;
6
6
  readonly claimed: Set<MessageID>;
7
+
8
+ // Whether the tap also takes messages held under the session's agent
9
+ // session id, as opposed to its atc id alone.
10
+ readonly linked: boolean;
11
+
12
+ // Which attach made this tap; every attach makes a new one.
13
+ readonly generation: number;
14
+ }
15
+
16
+ /**
17
+ * A session's tap as a drain finds it before it waits: the attach that made
18
+ * it and whether it is linked. A delivery made for it lands only while the
19
+ * same attach holds the tap.
20
+ */
21
+ export interface TapGeneration {
22
+ readonly generation: number;
23
+ readonly linked: boolean;
7
24
  }
8
25
 
9
26
  /**
@@ -13,14 +30,24 @@ interface TapEntry<TClient> {
13
30
  export class TapRegistry<TClient> {
14
31
  private readonly bySession = new Map<SessionID, TapEntry<TClient>>();
15
32
 
33
+ private nextGeneration = 0;
34
+
16
35
  /**
17
36
  * Makes the client the session's tap and returns the client it displaced,
18
- * or null when there was none or it is the same connection.
37
+ * or null when there was none or it is the same connection. A linked tap
38
+ * also takes the messages held under the session's agent session id.
19
39
  */
20
- attach(sessionID: SessionID, client: TClient): TClient | null {
40
+ attach(sessionID: SessionID, client: TClient, linked = true): TClient | null {
21
41
  const previous = this.bySession.get(sessionID)?.client ?? null;
22
42
 
23
- this.bySession.set(sessionID, { client, claimed: new Set() });
43
+ this.nextGeneration += 1;
44
+
45
+ this.bySession.set(sessionID, {
46
+ client,
47
+ claimed: new Set(),
48
+ linked,
49
+ generation: this.nextGeneration,
50
+ });
24
51
 
25
52
  return previous === client ? null : previous;
26
53
  }
@@ -45,6 +72,12 @@ export class TapRegistry<TClient> {
45
72
  return previous;
46
73
  }
47
74
 
75
+ findTap(sessionID: SessionID): TapGeneration | null {
76
+ const entry = this.bySession.get(sessionID);
77
+
78
+ return entry === undefined ? null : { generation: entry.generation, linked: entry.linked };
79
+ }
80
+
48
81
  hasTap(sessionID: SessionID): boolean {
49
82
  return this.bySession.has(sessionID);
50
83
  }
@@ -53,10 +86,15 @@ export class TapRegistry<TClient> {
53
86
  return this.bySession.get(sessionID)?.client === client;
54
87
  }
55
88
 
56
- claimDelivery(sessionID: SessionID, messageID: MessageID): TClient | null {
89
+ /**
90
+ * Claims the message for the session's tap and returns its client, or
91
+ * null when the tap already took it or another attach replaced the tap
92
+ * the delivery was made for.
93
+ */
94
+ claimDelivery(sessionID: SessionID, messageID: MessageID, generation: number): TClient | null {
57
95
  const entry = this.bySession.get(sessionID);
58
96
 
59
- if (entry === undefined || entry.claimed.has(messageID)) {
97
+ if (entry === undefined || entry.generation !== generation || entry.claimed.has(messageID)) {
60
98
  return null;
61
99
  }
62
100
 
@@ -56,6 +56,8 @@ interface EventsTable {
56
56
 
57
57
  interface SpawnHistoryTable {
58
58
  cwd: string;
59
+ target: string;
60
+ target_identity: string;
59
61
  last_spawn: number;
60
62
  }
61
63
 
@@ -152,8 +154,9 @@ export interface StateStoreSchema {
152
154
  // agent_session_id under its Claude-era name plus name and cwd, and each
153
155
  // later step adds one column the daemon grew to depend on, until the
154
156
  // rebuild that keys the table by the atc session id. events later gains
155
- // columns of its own, as does messages, while spawn_history and prefs have
156
- // carried one shape since they were added.
157
+ // columns of its own, as does messages, spawn_history is rebuilt to key each
158
+ // directory by the target it was spawned on, and prefs has carried one shape
159
+ // since it was added.
157
160
  const MIGRATIONS: Record<string, Migration> = {
158
161
  '001_create_initial_schema': {
159
162
  async up(db: Kysely<StateStoreSchema>) {
@@ -446,6 +449,29 @@ const MIGRATIONS: Record<string, Migration> = {
446
449
  await db.schema.alterTable('events').addColumn('report_text', 'text').execute();
447
450
  },
448
451
  },
452
+ '024_rebuild_spawn_history_keyed_by_target': {
453
+ async up(db: Kysely<StateStoreSchema>) {
454
+ // The ledger records this step only after it returns, so a crash in
455
+ // between leaves a rebuilt table behind a ledger that still lacks it.
456
+ const columns = await sql<ColumnInfoRow>`PRAGMA table_info(spawn_history)`.execute(db);
457
+
458
+ if (columns.rows.some((column) => column.name === 'target')) {
459
+ return;
460
+ }
461
+
462
+ await sql`BEGIN IMMEDIATE`.execute(db);
463
+
464
+ try {
465
+ await updateSpawnHistoryKeyToTarget(db);
466
+
467
+ await sql`COMMIT`.execute(db);
468
+ } catch (error) {
469
+ await sql`ROLLBACK`.execute(db);
470
+
471
+ throw error;
472
+ }
473
+ },
474
+ },
449
475
  };
450
476
 
451
477
  const PROVIDER: MigrationProvider = {
@@ -457,8 +483,9 @@ const PROVIDER: MigrationProvider = {
457
483
  * `Migrator`, one additive step at a time. A database from before the
458
484
  * ladder existed is recognized at whichever shape it stopped at by a
459
485
  * baselining pass that records the steps its columns already satisfy, so
460
- * only what is genuinely missing runs. One step rebuilds the fleet table
461
- * under a new key and carries every row across; no step drops a row.
486
+ * only what is genuinely missing runs. Two steps rebuild a table under a
487
+ * new key, the fleet and the spawn history, and carry every row across; no
488
+ * step drops a row.
462
489
  */
463
490
  export async function runMigrations(db: Kysely<StateStoreSchema>): Promise<void> {
464
491
  // A baselined step can land out of order relative to a step that turned
@@ -652,6 +679,30 @@ interface ColumnInfoRow {
652
679
  type: string;
653
680
  }
654
681
 
682
+ // The identity of the local target with no options. Every spawn from before
683
+ // spawns recorded their target ran there.
684
+ const LEGACY_SPAWN_TARGET_IDENTITY = 'local-pty:44136fa355b3678a';
685
+
686
+ async function updateSpawnHistoryKeyToTarget(db: Kysely<StateStoreSchema>): Promise<void> {
687
+ await sql`
688
+ CREATE TABLE spawn_history_rebuilt (
689
+ cwd TEXT NOT NULL,
690
+ target TEXT NOT NULL,
691
+ target_identity TEXT NOT NULL,
692
+ last_spawn INTEGER NOT NULL,
693
+ PRIMARY KEY (cwd, target, target_identity)
694
+ )
695
+ `.execute(db);
696
+
697
+ await sql`
698
+ INSERT INTO spawn_history_rebuilt (cwd, target, target_identity, last_spawn)
699
+ SELECT cwd, 'local', ${LEGACY_SPAWN_TARGET_IDENTITY}, last_spawn FROM spawn_history
700
+ `.execute(db);
701
+
702
+ await sql`DROP TABLE spawn_history`.execute(db);
703
+ await sql`ALTER TABLE spawn_history_rebuilt RENAME TO spawn_history`.execute(db);
704
+ }
705
+
655
706
  async function collectFleetColumns(db: Kysely<StateStoreSchema>): Promise<ReadonlySet<string>> {
656
707
  const result = await sql<ColumnInfoRow>`PRAGMA table_info(fleet)`.execute(db);
657
708
 
@@ -64,6 +64,27 @@ export interface StoredEvent {
64
64
  readonly label?: string;
65
65
  }
66
66
 
67
+ // The target a spawn ran on: its name and the identity the spawned session
68
+ // was bound to there.
69
+ interface SpawnTarget {
70
+ readonly target: string;
71
+ readonly targetIdentity: string;
72
+ }
73
+
74
+ // A directory a spawn ran in and the target it ran on.
75
+ export interface SpawnDir {
76
+ readonly cwd: string;
77
+ readonly grant: SpawnTarget;
78
+ }
79
+
80
+ /**
81
+ * Another message one turn answered, and the atc id it was sent to.
82
+ */
83
+ export interface TurnSibling {
84
+ readonly id: MessageID;
85
+ readonly atcID: SessionID;
86
+ }
87
+
67
88
  /**
68
89
  * One report as the trail holds it. A report recorded before the trail kept
69
90
  * whole texts has only its preview, so its text is that preview and
@@ -513,24 +534,38 @@ export class StateStore {
513
534
  return row?.ts === null || row?.ts === undefined ? null : Date.parse(row.ts);
514
535
  }
515
536
 
516
- async recordSpawnDir(cwd: string): Promise<void> {
537
+ async recordSpawnDir(cwd: string, grant: SpawnTarget): Promise<void> {
517
538
  await this.db
518
539
  .insertInto('spawn_history')
519
- .values({ cwd, last_spawn: Date.now() })
540
+ .values({
541
+ cwd,
542
+ target: grant.target,
543
+ target_identity: grant.targetIdentity,
544
+ last_spawn: Date.now(),
545
+ })
520
546
  .onConflict((oc) =>
521
- oc.column('cwd').doUpdateSet((eb) => ({ last_spawn: eb.ref('excluded.last_spawn') })),
547
+ oc
548
+ .columns(['cwd', 'target', 'target_identity'])
549
+ .doUpdateSet((eb) => ({ last_spawn: eb.ref('excluded.last_spawn') })),
522
550
  )
523
551
  .execute();
524
552
  }
525
553
 
526
- async collectSpawnDirs(): Promise<string[]> {
554
+ /**
555
+ * Each directory a spawn ran in, with the target it ran on, most recent
556
+ * first. A directory spawned on several targets has one entry for each.
557
+ */
558
+ async collectSpawnDirs(): Promise<SpawnDir[]> {
527
559
  const rows = await this.db
528
560
  .selectFrom('spawn_history')
529
- .select('cwd')
561
+ .select(['cwd', 'target', 'target_identity'])
530
562
  .orderBy('last_spawn', 'desc')
531
563
  .execute();
532
564
 
533
- return rows.map((row) => row.cwd);
565
+ return rows.map((row) => ({
566
+ cwd: row.cwd,
567
+ grant: { target: row.target, targetIdentity: row.target_identity },
568
+ }));
534
569
  }
535
570
 
536
571
  async loadLastUsedAgent(): Promise<AgentID> {
@@ -607,7 +642,7 @@ export class StateStore {
607
642
 
608
643
  // The other messages of the same session the given message's turn
609
644
  // answered, oldest first; none when the message has no turn.
610
- async collectTurnSiblings(record: MessageRecord): Promise<MessageID[]> {
645
+ async collectTurnSiblings(record: MessageRecord): Promise<TurnSibling[]> {
611
646
  if (record.turn === undefined) {
612
647
  return [];
613
648
  }
@@ -619,7 +654,7 @@ export class StateStore {
619
654
 
620
655
  const rows = await this.db
621
656
  .selectFrom('messages')
622
- .select('id')
657
+ .select(['id', 'atc_id'])
623
658
  .where('turn_id', '=', record.turn)
624
659
  .where('id', '!=', record.id)
625
660
  .where((eb) => buildOwnerFilter(eb, owner))
@@ -627,7 +662,7 @@ export class StateStore {
627
662
  .orderBy(sql`rowid`, 'asc')
628
663
  .execute();
629
664
 
630
- return rows.map((row) => toMessageID(row.id));
665
+ return rows.map((row) => ({ id: toMessageID(row.id), atcID: toSessionID(row.atc_id) }));
631
666
  }
632
667
 
633
668
  async updateMessageDelivered(