@zgeoff/atc 2.28.0 → 2.29.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.
@@ -90,6 +90,10 @@ export interface SessionDescriptor {
90
90
  // What the operator asked for, the host, the harness, and the daemon's
91
91
  // connection to it; the state derives from these and the attention.
92
92
  readonly lifecycle: SessionLifecycle;
93
+
94
+ // The model the session was spawned with; absent when it runs the
95
+ // agent's default.
96
+ readonly model?: string;
93
97
  }
94
98
 
95
99
  interface SessionLocator {
@@ -235,6 +239,15 @@ interface HarnessSetup {
235
239
  readonly attemptID: string | null;
236
240
  }
237
241
 
242
+ // What a spawn readied once its host was ready, which a failed start takes
243
+ // back: the auth attempt that provisioned the host, if one did, and the
244
+ // workspace directory the spawn created there, as the host resolves it,
245
+ // if it created one.
246
+ interface SpawnReadied {
247
+ readonly attemptID: string | null;
248
+ readonly root: string | null;
249
+ }
250
+
238
251
  // The identity of the implicit `local` target, which a fleet row without a
239
252
  // stored identity ran on.
240
253
  const LOCAL_TARGET_IDENTITY = buildTargetIdentity('local-pty', {});
@@ -352,6 +365,10 @@ export class SessionManager {
352
365
 
353
366
  private readonly unconfirmedKills = new Map<SessionID, HarnessHandle>();
354
367
 
368
+ // What each listed spawn still waiting on its harness's start readied,
369
+ // by spawn id, so a start that fails takes it back with the session.
370
+ private readonly startingSpawns = new Map<SessionID, SpawnReadied>();
371
+
355
372
  // The epoch the next harness start or attach takes, unique across every
356
373
  // session this manager holds.
357
374
  private nextBridgeEpoch = 1;
@@ -958,6 +975,7 @@ export class SessionManager {
958
975
  target,
959
976
  { prompt, resume, ...overrides },
960
977
  authSetup,
978
+ hostKey === id,
961
979
  );
962
980
 
963
981
  const trustWorkspace = trustClonedWorkspace
@@ -982,7 +1000,7 @@ export class SessionManager {
982
1000
  // gives its lease back or puts it to sleep in between.
983
1001
  const prepared = await this.withHostReadying(hostKey, async () => {
984
1002
  if (materialize === null) {
985
- return { setup: await setupHost(), materialized: null };
1003
+ return { setup: await setupHost(), materialized: null, root: null };
986
1004
  }
987
1005
 
988
1006
  return this.materializeOnSpawnHost(
@@ -1000,36 +1018,38 @@ export class SessionManager {
1000
1018
 
1001
1019
  const setup = prepared.setup;
1002
1020
  const materialized = prepared.materialized;
1021
+ const readied: SpawnReadied = { attemptID: setup.attemptID, root: prepared.root };
1003
1022
  const plan = setup.plan;
1023
+ const binding = this.mintBridgeBinding(id, target, execution.identity, hostKey);
1024
+ let pty: HarnessHandle;
1004
1025
 
1005
1026
  // The caller's check runs again once the host is ready, before the
1006
- // harness starts.
1027
+ // harness starts. A refusal there, or a harness its provider cannot
1028
+ // start, takes back what the spawn readied.
1007
1029
  try {
1008
1030
  requireInReach();
1031
+
1032
+ pty = provider.spawnHarness({
1033
+ session: id,
1034
+ host: hostKey,
1035
+ bin: plan.bin,
1036
+ args: plan.args,
1037
+ cwd,
1038
+ env: { ...plan.env, ATC_SESSION_ID: id, ATC_SOCKET: socketPath },
1039
+ withheldEnv: materialized?.withheldEnv ?? [],
1040
+ cols,
1041
+ rows,
1042
+ ...this.buildBrokerSpec(hostKey, plan.admission),
1043
+ onRelay: (relay) => {
1044
+ this.onRelay(binding, relay);
1045
+ },
1046
+ });
1009
1047
  } catch (error) {
1010
- await this.tryRemoveAuthAttempt(provider, hostKey, setup.attemptID);
1048
+ await this.removeFailedSpawnEffects(provider, id, hostKey, target, readied);
1011
1049
 
1012
1050
  throw error;
1013
1051
  }
1014
1052
 
1015
- const binding = this.mintBridgeBinding(id, target, execution.identity, hostKey);
1016
-
1017
- const pty = provider.spawnHarness({
1018
- session: id,
1019
- host: hostKey,
1020
- bin: plan.bin,
1021
- args: plan.args,
1022
- cwd,
1023
- env: { ...plan.env, ATC_SESSION_ID: id, ATC_SOCKET: socketPath },
1024
- withheldEnv: materialized?.withheldEnv ?? [],
1025
- cols,
1026
- rows,
1027
- ...this.buildBrokerSpec(hostKey, plan.admission),
1028
- onRelay: (relay) => {
1029
- this.onRelay(binding, relay);
1030
- },
1031
- });
1032
-
1033
1053
  let initialMsg = prompt;
1034
1054
 
1035
1055
  if (initialMsg === '') {
@@ -1070,6 +1090,7 @@ export class SessionManager {
1070
1090
 
1071
1091
  this.attachHarness(session, pty, this.hasHostLifecycle(target));
1072
1092
  this.sessions.push(session);
1093
+ this.startingSpawns.set(id, readied);
1073
1094
  this.releaseWorkspace(id);
1074
1095
  void this.tryWriteFleet(session.id);
1075
1096
  this.writeStatus();
@@ -1090,6 +1111,8 @@ export class SessionManager {
1090
1111
  await this.requireAuthBinder().updateReady(hostKey, setup.attemptID);
1091
1112
  }
1092
1113
 
1114
+ this.startingSpawns.delete(id);
1115
+
1093
1116
  return session;
1094
1117
  }
1095
1118
 
@@ -1270,7 +1293,30 @@ export class SessionManager {
1270
1293
  target: string,
1271
1294
  dir: string,
1272
1295
  ): Promise<boolean> {
1296
+ // Targets without hosts run their sessions on the daemon's machine, so
1297
+ // the directory stays while a session listed on any of them runs inside.
1273
1298
  if (!this.hasHostLifecycle(target)) {
1299
+ const isOnMachine = (s: Session) => s.id !== id && !this.hasHostLifecycle(s.target);
1300
+ const others = this.sessions.filter(isOnMachine);
1301
+
1302
+ const resolved = await Promise.all(
1303
+ others.map((s) => this.resolveHostDir(provider, null, s.cwd).catch(() => null)),
1304
+ );
1305
+
1306
+ if (this.sessions.some((s) => isOnMachine(s) && !others.includes(s))) {
1307
+ return this.removeClaimedDir(provider, id, hostKey, target, dir);
1308
+ }
1309
+
1310
+ const holds = others.some((s, index) => {
1311
+ const physical = resolved[index] ?? null;
1312
+
1313
+ return physical === null || isPathWithin(s.cwd, dir) || isPathWithin(physical, dir);
1314
+ });
1315
+
1316
+ if (holds) {
1317
+ return false;
1318
+ }
1319
+
1274
1320
  const removed = await provider.runCommand({
1275
1321
  argv: ['sh', '-c', REMOVE_DIR_SCRIPT, 'sh', dir, `${dir}\nx`],
1276
1322
  cwd: '/',
@@ -1459,7 +1505,8 @@ export class SessionManager {
1459
1505
 
1460
1506
  // Materializes a spawn's workspace, readying its host once the source
1461
1507
  // resolves, or after the workspace for a directory that runs as it
1462
- // stands. A failure once the host is ready takes it back.
1508
+ // stands, and returns the directory it created, null for one that runs
1509
+ // as it stands. A failure once the host is ready takes it back.
1463
1510
  private async materializeOnSpawnHost(
1464
1511
  provider: ExecutionProvider,
1465
1512
  id: SessionID,
@@ -1470,7 +1517,11 @@ export class SessionManager {
1470
1517
  materialize: SpawnMaterializer,
1471
1518
  setupHost: () => Promise<HarnessSetup>,
1472
1519
  trustWorkspace: ((root: string) => Promise<void>) | null,
1473
- ): Promise<{ readonly setup: HarnessSetup; readonly materialized: MaterializedSpawn | null }> {
1520
+ ): Promise<{
1521
+ readonly setup: HarnessSetup;
1522
+ readonly materialized: MaterializedSpawn | null;
1523
+ readonly root: string | null;
1524
+ }> {
1474
1525
  const readied: { setup: HarnessSetup | null; root: string | null } = {
1475
1526
  setup: null,
1476
1527
  root: null,
@@ -1527,7 +1578,11 @@ export class SessionManager {
1527
1578
  }
1528
1579
  }
1529
1580
 
1530
- return { setup: readied.setup, materialized };
1581
+ return {
1582
+ setup: readied.setup,
1583
+ materialized,
1584
+ root: materialized === null ? null : readied.root,
1585
+ };
1531
1586
  } catch (error) {
1532
1587
  if (readied.setup !== null) {
1533
1588
  await this.destroyFailedSpawnHost(provider, id, hostKey, readied.setup.attemptID);
@@ -1566,6 +1621,69 @@ export class SessionManager {
1566
1621
  }
1567
1622
  }
1568
1623
 
1624
+ // Takes back what a spawn readied when it fails once its host is ready,
1625
+ // before or after its session lists. An attempt that provisioned the
1626
+ // host takes back its imp and binding; a host of the spawn's own without
1627
+ // one is destroyed while no other session or launch uses it. On a host
1628
+ // that stays, such as a parent's, only the workspace directory the spawn
1629
+ // created goes. A directory it cannot confirm gone stays reserved, is
1630
+ // logged for an operator to remove, and throws that it may still stand.
1631
+ private async removeFailedSpawnEffects(
1632
+ provider: ExecutionProvider,
1633
+ id: SessionID,
1634
+ hostKey: SessionID,
1635
+ target: string,
1636
+ readied: SpawnReadied,
1637
+ ): Promise<void> {
1638
+ const isHostInUse =
1639
+ this.readying.has(hostKey) ||
1640
+ this.sessions.some((other) => other.hostKey === hostKey && other.target === target);
1641
+
1642
+ if (
1643
+ readied.attemptID !== null ||
1644
+ (hostKey === id && provider.capabilities.destroy && !isHostInUse)
1645
+ ) {
1646
+ await this.destroyFailedSpawnHost(provider, id, hostKey, readied.attemptID);
1647
+
1648
+ return;
1649
+ }
1650
+
1651
+ const root = readied.root;
1652
+
1653
+ if (root === null) {
1654
+ return;
1655
+ }
1656
+
1657
+ let removed = false;
1658
+ let failure: { readonly cause: unknown; readonly message: string } | null = null;
1659
+
1660
+ try {
1661
+ removed = await this.removeClaimedDir(provider, id, hostKey, target, root);
1662
+ } catch (error) {
1663
+ failure = { cause: error, message: error instanceof Error ? error.message : String(error) };
1664
+ }
1665
+
1666
+ if (removed) {
1667
+ return;
1668
+ }
1669
+
1670
+ const reason =
1671
+ failure === null
1672
+ ? "another session's directory lies inside it, or it no longer resolves to itself"
1673
+ : `removing it failed: ${failure.message}`;
1674
+
1675
+ const options = failure === null ? undefined : { cause: failure.cause };
1676
+
1677
+ this.log(
1678
+ `atc: left ${root} on target '${target}' after the spawn of session ${id} failed to start, since ${reason}; remove it by hand`,
1679
+ );
1680
+
1681
+ throw new EffectRemainsError(
1682
+ `spawn of session ${id} failed and its workspace ${root} on target '${target}' could not be removed`,
1683
+ options,
1684
+ );
1685
+ }
1686
+
1569
1687
  // Takes back what a spawn attempt bound when the spawn fails before its
1570
1688
  // session lists; a take-back that cannot be confirmed throws.
1571
1689
  private async tryRemoveAuthAttempt(
@@ -1584,7 +1702,9 @@ export class SessionManager {
1584
1702
 
1585
1703
  // Waits for a revived harness behind the broker to start. A refused start
1586
1704
  // already ended the harness; the host goes back to sleep when no other
1587
- // harness runs there, and the refusal is thrown.
1705
+ // harness runs there, and the refusal is thrown. A spawn that still has a
1706
+ // checkout to remove there leaves the sleep to its rollback, which needs
1707
+ // the host awake.
1588
1708
  private async waitForAuthStart(
1589
1709
  s: Session,
1590
1710
  provider: ExecutionProvider,
@@ -1593,7 +1713,9 @@ export class SessionManager {
1593
1713
  try {
1594
1714
  await pty.waitForStart?.();
1595
1715
  } catch (error) {
1596
- if (this.isHostIdle(s.hostKey, s.target) && provider.capabilities.suspend) {
1716
+ const hasCheckout = (this.startingSpawns.get(s.id)?.root ?? null) !== null;
1717
+
1718
+ if (!hasCheckout && this.isHostIdle(s.hostKey, s.target) && provider.capabilities.suspend) {
1597
1719
  await this.trySuspendIdleHost(provider, s.hostKey, s.target);
1598
1720
  }
1599
1721
 
@@ -1656,7 +1778,8 @@ export class SessionManager {
1656
1778
  // there first. Every refusal comes before the harness starts. A harness
1657
1779
  // behind the broker has its binding created or verified before the host
1658
1780
  // is readied, and a binding this call created is taken back when a later
1659
- // step fails; the attempt that created it comes back with the plan.
1781
+ // step fails; the attempt that created it comes back with the plan. A
1782
+ // spawn's new host of its own without a binding is destroyed instead.
1660
1783
  private setupHarness(
1661
1784
  adapter: AgentAdapter,
1662
1785
  provider: ExecutionProvider,
@@ -1667,7 +1790,7 @@ export class SessionManager {
1667
1790
  auth: HarnessAuthSetup | null,
1668
1791
  ): Promise<{ readonly plan: HarnessPlan; readonly attemptID: string | null }> {
1669
1792
  return this.withHostReadying(hostKey, () =>
1670
- this.setupHarnessOnHost(adapter, provider, id, hostKey, target, options, auth),
1793
+ this.setupHarnessOnHost(adapter, provider, id, hostKey, target, options, auth, false),
1671
1794
  );
1672
1795
  }
1673
1796
 
@@ -1696,6 +1819,10 @@ export class SessionManager {
1696
1819
  target: string,
1697
1820
  options: SpawnOptions,
1698
1821
  auth: HarnessAuthSetup | null,
1822
+
1823
+ // Whether the host is a spawn's new host of its own, which a failure
1824
+ // once it is readied destroys.
1825
+ isNewHost: boolean,
1699
1826
  ): Promise<{ readonly plan: HarnessPlan; readonly attemptID: string | null }> {
1700
1827
  const refusal = adapter.findSpawnRefusal?.() ?? null;
1701
1828
 
@@ -1756,7 +1883,9 @@ export class SessionManager {
1756
1883
  try {
1757
1884
  await this.setupGuest(adapter, provider, hostKey, target, dir, plan.files);
1758
1885
  } catch (error) {
1759
- await this.tryRemoveAuthAttempt(provider, hostKey, attemptID);
1886
+ if (attemptID !== null || isNewHost) {
1887
+ await this.destroyFailedSpawnHost(provider, id, hostKey, attemptID);
1888
+ }
1760
1889
 
1761
1890
  throw error;
1762
1891
  }
@@ -1908,10 +2037,11 @@ export class SessionManager {
1908
2037
  }
1909
2038
 
1910
2039
  // Takes back a spawn that failed after its process started: the process
1911
- // dies and the session leaves the list and the fleet, so the spawn leaves
1912
- // nothing behind. It resolves only once the process has exited and the
1913
- // fleet without the session is durable, and throws when the kill, the
1914
- // exit, or that write cannot be confirmed. A session whose exit is not
2040
+ // dies, the session leaves the list and the fleet, and what the spawn
2041
+ // readied on its host goes, so the spawn leaves nothing behind. It
2042
+ // resolves only once the process has exited, the fleet without the
2043
+ // session is durable, and that take-back is confirmed, and throws when
2044
+ // any of them cannot be confirmed. A session whose exit is not
1915
2045
  // confirmed stays listed, so the spawn's caller can still find it, and
1916
2046
  // no revive starts it until its process is found gone. A session that
1917
2047
  // never registered is left alone.
@@ -1923,7 +2053,9 @@ export class SessionManager {
1923
2053
  }
1924
2054
 
1925
2055
  const pty = s.pty;
2056
+ const readied = this.startingSpawns.get(id) ?? null;
1926
2057
 
2058
+ this.startingSpawns.delete(id);
1927
2059
  this.rollingBack.add(id);
1928
2060
 
1929
2061
  try {
@@ -1938,27 +2070,50 @@ export class SessionManager {
1938
2070
 
1939
2071
  s.pty = null;
1940
2072
 
2073
+ // The directory stays held on a shared host once the session leaves
2074
+ // the list, so no other workspace lands in or around it before it goes.
2075
+ if (readied !== null && readied.root !== null && s.hostKey !== id) {
2076
+ this.reservations.set(id, {
2077
+ hostKey: s.hostKey,
2078
+ target: s.target,
2079
+ dir: s.cwd,
2080
+ resolved: readied.root,
2081
+ kind: 'workspace',
2082
+ });
2083
+ }
2084
+
1941
2085
  this.remove(s);
1942
2086
  this.emitChange();
1943
2087
 
1944
2088
  await this.writeFleet();
1945
- await this.removeSpawnBinding(s);
1946
- } finally {
1947
- this.rollingBack.delete(id);
1948
- }
1949
- }
1950
2089
 
1951
- // Takes back the binding a failed spawn provisioned for its own host,
1952
- // while it is still the spawn's attempt.
1953
- private async removeSpawnBinding(s: Session): Promise<void> {
1954
- const provider = this.findProvider(s);
1955
- const held = s.hostKey === s.id ? await this.authBinder?.findBinding(s.id) : null;
2090
+ if (readied !== null) {
2091
+ const provider = this.findProvider(s);
1956
2092
 
1957
- if (provider === null || held === null || held === undefined || held.state !== 'provisioning') {
1958
- return;
1959
- }
2093
+ if (provider === null) {
2094
+ throw new Error(`the target of session ${id} is gone, so its failed spawn stays`);
2095
+ }
1960
2096
 
1961
- await this.tryRemoveAuthAttempt(provider, s.id, held.attemptID);
2097
+ try {
2098
+ await this.removeFailedSpawnEffects(provider, id, s.hostKey, s.target, readied);
2099
+ } finally {
2100
+ // A shared host stays awake for the checkout's removal, then goes
2101
+ // back to sleep when nothing else runs there.
2102
+ if (
2103
+ readied.root !== null &&
2104
+ s.hostKey !== id &&
2105
+ provider.capabilities.suspend &&
2106
+ this.isHostIdle(s.hostKey, s.target)
2107
+ ) {
2108
+ await this.trySuspendIdleHost(provider, s.hostKey, s.target);
2109
+ }
2110
+ }
2111
+ }
2112
+
2113
+ this.releaseWorkspace(id);
2114
+ } finally {
2115
+ this.rollingBack.delete(id);
2116
+ }
1962
2117
  }
1963
2118
 
1964
2119
  // Kills a failed spawn's harness and waits for its exit. A kill that
@@ -2042,6 +2197,7 @@ export class SessionManager {
2042
2197
  locator: { daemonID: this.store.daemonID, targetID: s.target },
2043
2198
  ...(s.workspace === undefined ? {} : { workspace: s.workspace }),
2044
2199
  lifecycle: buildLifecycle(s),
2200
+ ...(s.model === undefined ? {} : { model: s.model }),
2045
2201
  }));
2046
2202
  }
2047
2203
 
@@ -2102,10 +2258,16 @@ export class SessionManager {
2102
2258
  }
2103
2259
 
2104
2260
  switch (ev.kind) {
2261
+ // Clearing or resuming inside a live terminal ends the old agent session
2262
+ // and starts a new one in the same process, so the start supersedes the
2263
+ // end's message. A gone terminal keeps it.
2105
2264
  case 'started': {
2106
2265
  if (s.lastMsg === 'adopting…') {
2107
2266
  s.lastMsg = 'adopted';
2108
2267
  dirty = true;
2268
+ } else if (s.lastMsg === 'session ended' && s.pty !== null) {
2269
+ s.lastMsg = 'started';
2270
+ dirty = true;
2109
2271
  }
2110
2272
 
2111
2273
  break;
@@ -1,15 +0,0 @@
1
- import type { AgentID } from '../shared/agent-id';
2
-
3
- const BUILT_IN_MARKS: Readonly<Record<AgentID, string>> = { grok: 'g', codex: 'x' };
4
-
5
- /**
6
- * The reserved overlay column after unread: one character per agent, a space
7
- * for Claude, so session names stay aligned across a mixed fleet. A
8
- * configured mark wins, so a backend added by the user gets its own letter.
9
- */
10
- export function formatOverlayAgentMark(
11
- agent: AgentID,
12
- marks: Readonly<Record<AgentID, string>> = {},
13
- ): string {
14
- return marks[agent] ?? BUILT_IN_MARKS[agent] ?? ' ';
15
- }