@zgeoff/atc 2.33.0 → 2.35.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.
@@ -32,6 +32,11 @@ interface MaterializeRequest {
32
32
  // Whether the target is the daemon's own host, where a directory outside
33
33
  // any git work tree runs in place instead of being materialized.
34
34
  readonly inPlace: boolean;
35
+
36
+ // Whether the daemon picked the directory, so one that exists or that
37
+ // another workspace holds moves the claim to the next attempt's
38
+ // directory instead of refusing the spawn.
39
+ readonly autoDir?: boolean;
35
40
  }
36
41
 
37
42
  // The host a workspace lands on, and the directory there it is built in.
@@ -48,8 +53,9 @@ interface MaterializeDeps {
48
53
  readonly log: (line: string) => void;
49
54
 
50
55
  // Readies the host on the target the workspace lands on and resolves to
51
- // it, once the source has resolved.
52
- readonly readyHost: () => Promise<Landing>;
56
+ // it, once the source has resolved, with the directory of an attempt: the
57
+ // first is the request's directory, and attempt n appends `-n` to it.
58
+ readonly readyHost: (attempt: number) => Promise<Landing>;
53
59
 
54
60
  // Removes the directory this call claimed after a failure, and resolves
55
61
  // to whether it did; one it leaves holds what another session needs, or
@@ -254,12 +260,13 @@ async function runMaterialization(
254
260
  const repoURL = secret === null ? pinned.repoURL : toRedacted(pinned.repoURL, secret);
255
261
  const ref = secret === null || pinned.ref === null ? pinned.ref : toRedacted(pinned.ref, secret);
256
262
 
257
- const landing = await deps.readyHost();
258
-
259
- updateProgress({ landing });
263
+ const landing = await claimLanding(request, deps, updateProgress);
260
264
 
261
- await claimTargetDir(request, deps, landing, updateProgress);
262
- await recordPhase(request, deps, updateProgress, 'cloning', { repoURL, ref });
265
+ await recordPhase(request, deps, updateProgress, 'cloning', {
266
+ repoURL,
267
+ ref,
268
+ ...(request.autoDir === true ? { dir: landing.dir } : {}),
269
+ });
263
270
 
264
271
  const clone = await createCleanClone(pinned, join(staging, 'clone'), transports);
265
272
 
@@ -401,6 +408,45 @@ async function requireNoURLCredentials(url: string, cwd: string): Promise<void>
401
408
  }
402
409
  }
403
410
 
411
+ // How many directories a spawn whose directory the daemon picked tries
412
+ // before it refuses.
413
+ const AUTO_DIR_ATTEMPTS = 100;
414
+
415
+ /**
416
+ * Readies the host and claims the directory the workspace lands in. A
417
+ * directory the daemon picked moves to the next attempt while the one
418
+ * before exists or another workspace holds it, so repeated and concurrent
419
+ * spawns of one repository land side by side; a directory the caller gave
420
+ * is claimed once.
421
+ */
422
+ async function claimLanding(
423
+ request: MaterializeRequest,
424
+ deps: MaterializeDeps,
425
+ updateProgress: ProgressTracker,
426
+ ): Promise<Landing> {
427
+ const attempts = request.autoDir === true ? AUTO_DIR_ATTEMPTS : 1;
428
+
429
+ for (let attempt = 1; ; attempt += 1) {
430
+ try {
431
+ const landing = await deps.readyHost(attempt);
432
+
433
+ updateProgress({ landing });
434
+
435
+ await claimTargetDir(request, deps, landing, updateProgress);
436
+
437
+ return landing;
438
+ } catch (error) {
439
+ const held =
440
+ error instanceof DaemonError &&
441
+ (error.code === 'workspace_exists' || error.code === 'workspace_overlap');
442
+
443
+ if (!held || attempt >= attempts) {
444
+ throw error;
445
+ }
446
+ }
447
+ }
448
+ }
449
+
404
450
  /**
405
451
  * Creates the target directory as the claim on it: `mkdir` without `-p`
406
452
  * fails when the directory exists, so a materialization never unpacks over
@@ -412,6 +458,9 @@ async function claimTargetDir(
412
458
  landing: Landing,
413
459
  updateProgress: ProgressTracker,
414
460
  ): Promise<void> {
461
+ // A directory the daemon picked is reported as it landed.
462
+ const shown = request.autoDir === true ? landing.dir : request.dir;
463
+
415
464
  const parent = await deps.requireProvider('run').runCommand({
416
465
  argv: ['mkdir', '-p', '--', dirname(landing.dir)],
417
466
  cwd: '/',
@@ -421,8 +470,8 @@ async function claimTargetDir(
421
470
  if (parent.exitCode !== 0) {
422
471
  throw new DaemonError(
423
472
  'transfer_failed',
424
- `cannot create ${dirname(request.dir)} on target '${request.target}': ${parent.stderr.trim()}`,
425
- { phase: 'resolving', dir: request.dir },
473
+ `cannot create ${dirname(shown)} on target '${request.target}': ${parent.stderr.trim()}`,
474
+ { phase: 'resolving', dir: shown },
426
475
  );
427
476
  }
428
477
 
@@ -430,23 +479,46 @@ async function claimTargetDir(
430
479
  .requireProvider('run')
431
480
  .runCommand({ argv: ['mkdir', '--', landing.dir], cwd: '/', host: landing.host });
432
481
 
482
+ // A directory that is not there failed for another reason, such as a
483
+ // parent the command cannot write, which no other attempt would fix.
484
+ if (claim.exitCode !== 0 && !(await isTargetPathPresent(deps, landing))) {
485
+ throw new DaemonError(
486
+ 'transfer_failed',
487
+ `cannot create ${shown} on target '${request.target}': ${claim.stderr.trim()}`,
488
+ { phase: 'resolving', dir: shown },
489
+ );
490
+ }
491
+
433
492
  if (claim.exitCode !== 0) {
434
493
  throw new DaemonError(
435
494
  'workspace_exists',
436
- `${request.dir} already exists on target '${request.target}'; a workspace is materialized only into a directory that does not exist`,
437
- { phase: 'resolving', dir: request.dir },
495
+ `${shown} already exists on target '${request.target}'; a workspace is materialized only into a directory that does not exist`,
496
+ { phase: 'resolving', dir: shown },
438
497
  );
439
498
  }
440
499
 
441
500
  updateProgress({ claimed: true });
442
501
  }
443
502
 
503
+ // Whether anything, a dangling symlink included, stands at the landing
504
+ // directory's path on its host.
505
+ async function isTargetPathPresent(deps: MaterializeDeps, landing: Landing): Promise<boolean> {
506
+ const probe = await deps.requireProvider('run').runCommand({
507
+ argv: ['sh', '-c', '[ -e "$1" ] || [ -L "$1" ]', 'sh', landing.dir],
508
+ cwd: '/',
509
+ host: landing.host,
510
+ });
511
+
512
+ return probe.exitCode === 0;
513
+ }
514
+
444
515
  async function recordPhase(
445
516
  request: MaterializeRequest,
446
517
  deps: MaterializeDeps,
447
518
  updateProgress: ProgressTracker,
448
519
  phase: MaterializationPhase,
449
520
  fields: Readonly<{
521
+ dir?: string;
450
522
  repoURL?: string;
451
523
  sha?: string;
452
524
  ref?: string | null;
@@ -16,6 +16,12 @@ export interface RestoreFleetParams {
16
16
  // it has booted before moving on regardless; zero waits on the signal
17
17
  // alone.
18
18
  readonly capMs: number;
19
+
20
+ // Whether a session spawned without its own choice gets one message to
21
+ // carry on a turn the previous daemon's stop cut off, and how that
22
+ // message is sent once the session's terminal is adopted.
23
+ readonly resumeInterruptedTurns: boolean;
24
+ readonly sendResumeMessage: (s: Session) => Promise<void>;
19
25
  }
20
26
 
21
27
  /**
@@ -23,7 +29,9 @@ export interface RestoreFleetParams {
23
29
  * the rest by recency, registers every one as a terminal-less session so
24
30
  * the whole fleet lists at once, then adopts terminals one at a time,
25
31
  * waiting for each session to report it has booted before starting the
26
- * next.
32
+ * next. A session that was mid-turn when the previous daemon stopped, and
33
+ * that resumes interrupted turns, gets one message to carry on once its
34
+ * terminal is adopted.
27
35
  */
28
36
  export async function restoreFleet(params: RestoreFleetParams): Promise<number> {
29
37
  const mgr = params.mgr;
@@ -32,6 +40,7 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
32
40
  const cols = params.cols;
33
41
  const rows = params.rows;
34
42
  const capMs = params.capMs;
43
+ const sendResumeMessage = params.sendResumeMessage;
35
44
 
36
45
  const hasLiveSession = (entry: FleetEntry) =>
37
46
  mgr.sessions.some(
@@ -83,11 +92,33 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
83
92
  .filter((r) => r.revive || r.session.state !== 'exited')
84
93
  .map((r) => r.session);
85
94
 
95
+ // Only a session this restore registers comes from the previous daemon's
96
+ // fleet; a listed one this daemon already ran is revived by hand. The
97
+ // trail is read before any terminal is adopted, since a revived agent's
98
+ // own events move it on.
99
+ const interrupted = await collectInterruptedTurns(
100
+ mgr,
101
+ store,
102
+ registered.filter((r) => !r.revive && r.session.state !== 'exited').map((r) => r.session),
103
+ params.resumeInterruptedTurns,
104
+ );
105
+
86
106
  // A session whose target refuses to start a terminal stays listed without
87
- // one, and the restore moves on to the next.
107
+ // one, and the restore moves on to the next. A resume message that fails
108
+ // to send is logged, and the session runs on without it.
88
109
  const adoptQueued = async (s: Session): Promise<boolean> => {
89
110
  const adopted = await tryAdoptTerminal(mgr, s.id, cols, rows);
90
111
 
112
+ if (adopted !== null && interrupted.has(s.id)) {
113
+ try {
114
+ await sendResumeMessage(s);
115
+ } catch (error) {
116
+ mgr.log(
117
+ `atc could not send session ${s.id} its resume message (${error instanceof Error ? error.message : String(error)})`,
118
+ );
119
+ }
120
+ }
121
+
91
122
  return adopted !== null;
92
123
  };
93
124
 
@@ -174,6 +205,39 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
174
205
  return registered.length;
175
206
  }
176
207
 
208
+ // The sessions whose last turn event in the trail is a submitted prompt:
209
+ // the previous daemon stopped while their turn ran. A session's own choice
210
+ // beats the config, and an agent that takes no atc messages has no path for
211
+ // the resume message. Only a harness on the daemon's own machine ends with
212
+ // the daemon; one on a host with a lifecycle of its own runs on, so its
213
+ // turn was never cut off and a revive attaches to it as it stands.
214
+ async function collectInterruptedTurns(
215
+ mgr: SessionManager,
216
+ store: StateStore,
217
+ sessions: readonly Session[],
218
+ configured: boolean,
219
+ ): Promise<ReadonlySet<SessionID>> {
220
+ const interrupted = new Set<SessionID>();
221
+
222
+ for (const s of sessions) {
223
+ if (
224
+ !(s.resumeInterruptedTurns ?? configured) ||
225
+ s.attachment !== 'local' ||
226
+ mgr.findAdapter(s.agent)?.takesMessages !== true
227
+ ) {
228
+ continue;
229
+ }
230
+
231
+ const kind = await store.findLatestTurnKind(s.id, s.agentSessionID);
232
+
233
+ if (kind === 'prompt-submitted') {
234
+ interrupted.add(s.id);
235
+ }
236
+ }
237
+
238
+ return interrupted;
239
+ }
240
+
177
241
  // A stored entry matches a listed session by its atc session id, or by its
178
242
  // agent session id when a listed session resumed the same agent session.
179
243
  function isSameSession(s: Session, entry: FleetEntry): boolean {
@@ -3,6 +3,7 @@ import { posix } from 'node:path';
3
3
  import type {
4
4
  AgentAdapter,
5
5
  GuestPaths,
6
+ GuestSpawnPlan,
6
7
  SpawnOptions,
7
8
  SpawnOverrides,
8
9
  SpawnPlan,
@@ -183,6 +184,10 @@ export interface Session {
183
184
  // The epoch of the session's latest harness start or attach, which the
184
185
  // daemon's bridge to that harness is bound to; 0 before the first.
185
186
  bridgeEpoch: number;
187
+
188
+ // Whether a fleet restore sends the session one message to carry on a
189
+ // turn a daemon restart cut off; absent follows the daemon's config.
190
+ resumeInterruptedTurns?: boolean;
186
191
  }
187
192
 
188
193
  // A session's ready workspace and the variables its harnesses go without.
@@ -194,14 +199,18 @@ interface MaterializedSpawn {
194
199
  // Builds a spawn's workspace on a target bound to an identity, calling
195
200
  // readyHost for the host it lands on once its source resolves and asking
196
201
  // canRemoveClaim before a failure removes the directory it claimed, or
197
- // resolves to null for a directory that runs as it stands.
202
+ // resolves to null for a directory that runs as it stands. readyHost takes
203
+ // the attempt the directory is for: the first is the spawn's directory, and
204
+ // attempt n is that directory with `-n` appended.
198
205
  type SpawnMaterializer = (
199
206
  host: SpawnHostAccess,
200
207
  targetIdentity: string,
201
208
  ) => Promise<MaterializedSpawn | null>;
202
209
 
203
210
  interface SpawnHostAccess {
204
- readonly readyHost: () => Promise<{ readonly host: SessionID; readonly dir: string }>;
211
+ readonly readyHost: (
212
+ attempt: number,
213
+ ) => Promise<{ readonly host: SessionID; readonly dir: string }>;
205
214
  readonly removeClaim: (dir: string) => Promise<boolean>;
206
215
  }
207
216
 
@@ -210,7 +219,7 @@ interface SpawnHostAccess {
210
219
  interface WorkspaceReservation {
211
220
  readonly hostKey: SessionID;
212
221
  readonly target: string;
213
- readonly dir: string;
222
+ dir: string;
214
223
  resolved: string | null;
215
224
 
216
225
  // A workspace spawn builds its directory and may remove it on a failure;
@@ -565,6 +574,9 @@ export class SessionManager {
565
574
  attachment: this.hasHostLifecycle(target) ? 'detached' : 'local',
566
575
  hostKey: entry.hostKey ?? entry.sessionID,
567
576
  bridgeEpoch: 0,
577
+ ...(entry.resumeInterruptedTurns === undefined
578
+ ? {}
579
+ : { resumeInterruptedTurns: entry.resumeInterruptedTurns }),
568
580
  };
569
581
 
570
582
  this.sessions.push(session);
@@ -905,6 +917,7 @@ export class SessionManager {
905
917
  target = 'local',
906
918
  materialize: SpawnMaterializer | null = null,
907
919
  requireInReach: () => void = () => {},
920
+ autoDir = false,
908
921
  ): Promise<Session> {
909
922
  const adapter = this.findAdapter(agent);
910
923
 
@@ -967,9 +980,14 @@ export class SessionManager {
967
980
  throw refusal;
968
981
  }
969
982
 
983
+ // A directory the daemon picked is checked on the host once per
984
+ // attempt, so a held one moves the spawn to the next attempt instead of
985
+ // refusing it.
970
986
  if (hostKey !== id) {
971
987
  if (materialize === null) {
972
988
  this.claimPlainDir(id, hostKey, target, cwd);
989
+ } else if (autoDir) {
990
+ this.reservations.set(id, { hostKey, target, dir: cwd, resolved: null, kind: 'workspace' });
973
991
  } else {
974
992
  this.claimWorkspace(id, hostKey, target, cwd);
975
993
  }
@@ -1041,6 +1059,9 @@ export class SessionManager {
1041
1059
  );
1042
1060
  });
1043
1061
 
1062
+ // A spawn whose directory the daemon picked runs in the one it claimed,
1063
+ // as the host resolves it.
1064
+ const dir = autoDir && prepared.root !== null ? prepared.root : cwd;
1044
1065
  const setup = prepared.setup;
1045
1066
  const materialized = prepared.materialized;
1046
1067
  const readied: SpawnReadied = { attemptID: setup.attemptID, root: prepared.root };
@@ -1059,7 +1080,7 @@ export class SessionManager {
1059
1080
  host: hostKey,
1060
1081
  bin: plan.bin,
1061
1082
  args: plan.args,
1062
- cwd,
1083
+ cwd: dir,
1063
1084
  env: { ...plan.env, ATC_SESSION_ID: id, ATC_SOCKET: socketPath },
1064
1085
  withheldEnv: materialized?.withheldEnv ?? [],
1065
1086
  cols,
@@ -1084,8 +1105,8 @@ export class SessionManager {
1084
1105
 
1085
1106
  const session: Session = {
1086
1107
  id,
1087
- name,
1088
- cwd,
1108
+ name: namedBy === 'auto' && dir !== cwd ? posix.basename(dir) : name,
1109
+ cwd: dir,
1089
1110
  kind: 'pty',
1090
1111
  pty,
1091
1112
  state: 'running',
@@ -1095,7 +1116,7 @@ export class SessionManager {
1095
1116
  agent,
1096
1117
  pinned: false,
1097
1118
  lastAttachedAt: Date.now(),
1098
- repoRoot,
1119
+ repoRoot: dir === cwd ? repoRoot : dir,
1099
1120
  namedBy,
1100
1121
  createdAt: Date.now(),
1101
1122
  parent,
@@ -1112,6 +1133,9 @@ export class SessionManager {
1112
1133
  suspended: false,
1113
1134
  hostKey,
1114
1135
  bridgeEpoch: binding.epoch,
1136
+ ...(overrides.resumeInterruptedTurns === undefined
1137
+ ? {}
1138
+ : { resumeInterruptedTurns: overrides.resumeInterruptedTurns }),
1115
1139
  };
1116
1140
 
1117
1141
  this.attachHarness(session, pty, this.hasHostLifecycle(target));
@@ -1306,6 +1330,7 @@ export class SessionManager {
1306
1330
  if (reservation !== undefined) {
1307
1331
  this.requireSeparateWorkspace(id, hostKey, target, dir, [dir, physical], listed.dirs);
1308
1332
 
1333
+ reservation.dir = dir;
1309
1334
  reservation.resolved = physical;
1310
1335
  }
1311
1336
 
@@ -1535,6 +1560,28 @@ export class SessionManager {
1535
1560
  return existing === '/' ? rest || '/' : `${existing}${rest}`;
1536
1561
  }
1537
1562
 
1563
+ // The home directory of the user commands run as on a host, with every
1564
+ // symlink in it resolved.
1565
+ private async resolveHostHome(provider: ExecutionProvider, hostKey: SessionID): Promise<string> {
1566
+ const result = await provider.runCommand({
1567
+ argv: ['sh', '-c', 'cd && pwd -P'],
1568
+ cwd: '/',
1569
+ host: hostKey,
1570
+ });
1571
+
1572
+ const home = result.stdout.replace(/\n$/u, '');
1573
+
1574
+ if (result.exitCode !== 0 || !posix.isAbsolute(home)) {
1575
+ throw new DaemonError(
1576
+ 'host_unavailable',
1577
+ `the host of session ${hostKey} cannot resolve its home directory: ${result.stderr.trim()}`,
1578
+ { phase: 'resolving' },
1579
+ );
1580
+ }
1581
+
1582
+ return home;
1583
+ }
1584
+
1538
1585
  // Materializes a spawn's workspace, readying its host once the source
1539
1586
  // resolves, or after the workspace for a directory that runs as it
1540
1587
  // stands, and returns the directory it created, null for one that runs
@@ -1559,15 +1606,26 @@ export class SessionManager {
1559
1606
  root: null,
1560
1607
  };
1561
1608
 
1609
+ const hostHome: { dir: string | null } = { dir: null };
1610
+
1562
1611
  try {
1563
1612
  const materialized = await materialize(
1564
1613
  {
1565
- readyHost: async () => {
1566
- readied.setup = await setupHost();
1614
+ readyHost: async (attempt) => {
1615
+ readied.setup ??= await setupHost();
1616
+
1617
+ // A directory the daemon picked on a remote target is relative
1618
+ // to the home there, which only the readied host can resolve.
1619
+ hostHome.dir ??= posix.isAbsolute(dir)
1620
+ ? ''
1621
+ : await this.resolveHostHome(provider, hostKey);
1622
+
1623
+ const base = posix.isAbsolute(dir) ? dir : posix.join(hostHome.dir, dir);
1624
+ const candidate = attempt === 1 ? base : `${base}-${attempt}`;
1567
1625
 
1568
1626
  const landing = this.hasHostLifecycle(target)
1569
- ? await this.claimHostDir(provider, id, hostKey, target, dir)
1570
- : await this.resolveHostDir(provider, null, dir);
1627
+ ? await this.claimHostDir(provider, id, hostKey, target, candidate)
1628
+ : await this.resolveHostDir(provider, null, candidate);
1571
1629
 
1572
1630
  readied.root = landing;
1573
1631
 
@@ -2035,7 +2093,9 @@ export class SessionManager {
2035
2093
  hostKey: SessionID,
2036
2094
  target: string,
2037
2095
  dir: string,
2038
- planned: Readonly<Record<string, string>>,
2096
+
2097
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- file bytes have no readonly form
2098
+ planned: GuestSpawnPlan['files'],
2039
2099
  ): Promise<void> {
2040
2100
  await provider.prepareHost({
2041
2101
  host: hostKey,
@@ -2058,7 +2118,11 @@ export class SessionManager {
2058
2118
  }
2059
2119
  }
2060
2120
 
2061
- const files = Object.entries(planned).map(([path, content]) => ({ path, content }));
2121
+ const files = Object.entries(planned).map(([path, file]) =>
2122
+ typeof file === 'string' || file instanceof Uint8Array
2123
+ ? { path, content: file }
2124
+ : { path, content: file.content, mode: file.mode },
2125
+ );
2062
2126
 
2063
2127
  if (files.length > 0) {
2064
2128
  await provider.transferArchive(buildTarArchive(files), dir, hostKey);
@@ -2848,6 +2912,9 @@ export class SessionManager {
2848
2912
  targetIdentity: s.targetIdentity,
2849
2913
  ...(s.desired === 'run' ? {} : { desired: s.desired }),
2850
2914
  ...(s.hostKey === s.id ? {} : { hostKey: s.hostKey }),
2915
+ ...(s.resumeInterruptedTurns === undefined
2916
+ ? {}
2917
+ : { resumeInterruptedTurns: s.resumeInterruptedTurns }),
2851
2918
  });
2852
2919
  }
2853
2920
 
@@ -44,7 +44,9 @@ const DIRS_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
44
44
  const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
45
45
  z.strictObject({
46
46
  daemon: DAEMON_FIELD,
47
- cwd: SPAWN_SCHEMA.shape.cwd.describe('Absolute path of the working directory'),
47
+ cwd: SPAWN_SCHEMA.shape.cwd.describe(
48
+ "Absolute path of the working directory. Required, except with a git workspace: omit it there and atc picks a new directory under the target user's home, ~/.local/share/atc/workspaces/<repo>-<ref>-<short sha> unless the config sets another root, adding -2, -3, and so on when that directory exists. The session's cwd in the result holds the path it landed in.",
49
+ ),
48
50
  name: SPAWN_SCHEMA.shape.name.describe('Session name; defaults to the directory basename'),
49
51
  prompt: SPAWN_SCHEMA.shape.prompt.describe('First message for the session'),
50
52
  agent: SPAWN_SCHEMA.shape.agent.describe(SPAWN_AGENT_DESCRIPTION),
@@ -58,11 +60,14 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
58
60
  'Execution target for the new session, one of the target ids in atc_agents_list. Omit it to run on the default target (spawnDefaults.target). An unknown or unavailable target is refused; atc never runs the session on another target instead.',
59
61
  ),
60
62
  workspace: SPAWN_SCHEMA.shape.workspace.describe(
61
- "Where the session's working directory comes from. Omit it to run the session in cwd as it stands. With it, atc materializes a clean checkout into cwd on the target, which must not exist yet: {kind:'path', path, allowDirty?} checks out the pushed HEAD of a git checkout on the atc host, leaving its uncommitted and untracked changes behind with a warning, or refusing them when allowDirty is 'refuse'; {kind:'git', url, ref or sha, credentialRef?} checks out a branch, tag, or full commit of a repository, with credentialRef {kind:'env', name} naming the atc daemon's environment variable that holds its token. A directory outside git runs in place only on a target on the atc host itself (provider local-pty), with cwd equal to its path. Submodules and Git LFS are refused, and so is a URL that carries a credential.",
63
+ "Where the session's working directory comes from. Omit it to run the session in cwd as it stands. With it, atc materializes a clean checkout into cwd on the target, which must not exist yet, or for a git source without cwd into a directory atc picks: {kind:'path', path, allowDirty?} checks out the pushed HEAD of a git checkout on the atc host, leaving its uncommitted and untracked changes behind with a warning, or refusing them when allowDirty is 'refuse'; {kind:'git', url, ref or sha, credentialRef?} checks out a branch, tag, or full commit of a repository, with credentialRef {kind:'env', name} naming the atc daemon's environment variable that holds its token. A directory outside git runs in place only on a target on the atc host itself (provider local-pty), with cwd equal to its path. Submodules and Git LFS are refused, and so is a URL that carries a credential.",
62
64
  ),
63
65
  trustClonedWorkspace: SPAWN_SCHEMA.shape.trustClonedWorkspace.describe(
64
66
  "Trust the exact verified clone for this launch. An explicit true or false overrides the configured target trustClonedWorkspace default; omitting both keeps trust off. Requires a workspace source and either stock Claude on the local target, which adds trust for the clone root alone to the user's Claude config, or, on an imp target, a brokered Claude gateway or stock Claude signed in through the broker, each with isolated guest config; other launches are refused. Accepts repository configuration and helpers without changing tool permission mode. Existing guest config is preserved.",
65
67
  ),
68
+ resumeInterruptedTurns: SPAWN_SCHEMA.shape.resumeInterruptedTurns.describe(
69
+ "Whether atc sends the session one message to carry on when a daemon restart cuts off its turn. The message goes out when the fleet is restored, only to a session whose last recorded event was a submitted prompt, and only to an agent that takes atc messages. Omit it to follow the daemon's resumeInterruptedTurns config, which is off by default.",
70
+ ),
66
71
  detached: z
67
72
  .boolean()
68
73
  .optional()
@@ -418,6 +423,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
418
423
  target: 'spawn.target',
419
424
  workspace: 'spawn.workspace',
420
425
  trustClonedWorkspace: 'spawn.workspace.trust',
426
+ resumeInterruptedTurns: 'spawn.resumeInterruptedTurns',
421
427
  },
422
428
  },
423
429
  },
@@ -20,11 +20,13 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
20
20
  'request.principal': 'the target limits of a remote MCP client',
21
21
  'spawn.workspace': "atc_session_spawn's workspace",
22
22
  'spawn.workspace.trust': "atc_session_spawn's trustClonedWorkspace",
23
+ 'spawn.workspace.autoDir': "atc_session_spawn's git workspace without a cwd",
23
24
  sources: 'sources.list and sources.interpret',
24
25
  'git.probe': 'git.probe',
25
26
  'transport.tcp': 'a TCP connection to the daemon',
26
27
  'idempotency.replayOnly': 'a resend that only replays a held idempotency key',
27
28
  'session.auth': 'session.auth.revoke and session.auth.rebind',
29
+ 'spawn.resumeInterruptedTurns': "atc_session_spawn's resumeInterruptedTurns",
28
30
  };
29
31
 
30
32
  /**
@@ -46,7 +46,7 @@ export function runTool(
46
46
 
47
47
  const params = {
48
48
  ...(typeof args['daemon'] === 'string' ? { daemon: args['daemon'] } : {}),
49
- cwd: args['cwd'],
49
+ ...(args['cwd'] === undefined ? {} : { cwd: args['cwd'] }),
50
50
  ...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
51
51
  ...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
52
52
  ...(rawAgent === undefined ? {} : { agent: rawAgent }),
@@ -58,6 +58,9 @@ export function runTool(
58
58
  ...(args['trustClonedWorkspace'] === undefined
59
59
  ? {}
60
60
  : { trustClonedWorkspace: args['trustClonedWorkspace'] }),
61
+ ...(args['resumeInterruptedTurns'] === undefined
62
+ ? {}
63
+ : { resumeInterruptedTurns: args['resumeInterruptedTurns'] }),
61
64
  cols: 100,
62
65
  rows: 30,
63
66
  };
@@ -78,12 +81,24 @@ export function runTool(
78
81
  const trustFeatures: readonly DaemonFeature[] =
79
82
  args['trustClonedWorkspace'] === undefined ? [] : ['spawn.workspace.trust'];
80
83
 
84
+ const resumeFeatures: readonly DaemonFeature[] =
85
+ args['resumeInterruptedTurns'] === undefined ? [] : ['spawn.resumeInterruptedTurns'];
86
+
87
+ // Only a daemon that picks a git workspace's directory takes one
88
+ // without a cwd.
89
+ const autoDirFeatures: readonly DaemonFeature[] =
90
+ args['cwd'] === undefined && args['workspace'] !== undefined
91
+ ? ['spawn.workspace.autoDir']
92
+ : [];
93
+
81
94
  const required = [
82
95
  ...optionFeatures,
83
96
  ...keyFeatures,
84
97
  ...targetFeatures,
85
98
  ...workspaceFeatures,
86
99
  ...trustFeatures,
100
+ ...resumeFeatures,
101
+ ...autoDirFeatures,
87
102
  ];
88
103
 
89
104
  const ok =
@@ -50,6 +50,11 @@ export const DAEMON_FEATURES = [
50
50
  // `session.spawn` takes an explicit trust decision for a cloned imp workspace.
51
51
  'spawn.workspace.trust',
52
52
 
53
+ // `session.spawn` with a git workspace takes no `cwd`, and the daemon
54
+ // picks a directory under the target user's home that no other
55
+ // workspace holds.
56
+ 'spawn.workspace.autoDir',
57
+
53
58
  // `session.forget` exists, and a kill of a session asleep on a target that
54
59
  // can destroy its host answers `confirmation_required`.
55
60
  'session.forget',
@@ -80,6 +85,9 @@ export const DAEMON_FEATURES = [
80
85
  // `session.auth.revoke` and `session.auth.rebind` exist, open to the
81
86
  // daemon's owner only.
82
87
  'session.auth',
88
+
89
+ // `session.spawn` takes `resumeInterruptedTurns`.
90
+ 'spawn.resumeInterruptedTurns',
83
91
  ] as const;
84
92
 
85
93
  export type DaemonFeature = (typeof DAEMON_FEATURES)[number];
@@ -151,7 +151,13 @@ export const REQUEST_PARAM_SCHEMAS = {
151
151
  rows: buildTerminalSize(24),
152
152
  }),
153
153
  'session.spawn': z.object({
154
- cwd: z.string({ error: 'session.spawn requires a cwd' }).min(1, 'session.spawn requires a cwd'),
154
+ // The working directory on the target. A spawn with a git workspace may
155
+ // leave it out, and the daemon then picks a directory under the target
156
+ // user's home; every other spawn requires it.
157
+ cwd: z
158
+ .string({ error: 'session.spawn requires a cwd' })
159
+ .min(1, 'session.spawn requires a cwd')
160
+ .optional(),
155
161
  name: buildDefaultedString(''),
156
162
  prompt: buildDefaultedString(''),
157
163
  cols: buildTerminalSize(80),
@@ -190,6 +196,12 @@ export const REQUEST_PARAM_SCHEMAS = {
190
196
  .boolean({ error: 'session.spawn trustClonedWorkspace must be a boolean' })
191
197
  .optional(),
192
198
 
199
+ // Whether a fleet restore after a daemon restart sends the session one
200
+ // message to carry on an interrupted turn; absent follows the config.
201
+ resumeInterruptedTurns: z
202
+ .boolean({ error: 'session.spawn resumeInterruptedTurns must be a boolean' })
203
+ .optional(),
204
+
193
205
  // The session the new one is a sub-session of; absent or empty spawns a
194
206
  // top-level session.
195
207
  parent: z.preprocess(