@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.
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +14 -2
- package/src/agents/build-claude-bundle-settings.ts +91 -0
- package/src/agents/build-claude-guest-launch.ts +21 -6
- package/src/agents/claude-adapter.ts +45 -9
- package/src/agents/claude-config-bundle-folder.ts +8 -0
- package/src/agents/load-claude-config-bundle.ts +169 -0
- package/src/cli.ts +2 -0
- package/src/client/index.ts +8 -0
- package/src/client/spawn-picker.ts +58 -6
- package/src/daemon/build-default-workspace-dir.ts +70 -0
- package/src/daemon/build-tar-archive.ts +33 -8
- package/src/daemon/daemon-connection.ts +28 -5
- package/src/daemon/daemon-context.ts +13 -0
- package/src/daemon/daemon.ts +77 -5
- package/src/daemon/materialize-workspace.ts +83 -11
- package/src/daemon/restore-fleet.ts +66 -2
- package/src/daemon/sessions.ts +80 -13
- package/src/mcp/mcp-tools.ts +8 -2
- package/src/mcp/require-daemon-features.ts +2 -0
- package/src/mcp/run-tool.ts +16 -1
- package/src/protocol/daemon-features.ts +8 -0
- package/src/protocol/request-param-schemas.ts +13 -1
- package/src/{client → shared}/build-workspace-destination.ts +19 -8
- package/src/shared/config.ts +9 -0
- package/src/statusline.ts +4 -3
- package/src/store/fleet-entry.ts +5 -0
- package/src/store/run-migrations.ts +12 -0
- package/src/store/state-store.ts +35 -0
- package/src/store/workspace-materialization.ts +4 -0
|
@@ -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
|
-
|
|
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
|
|
258
|
-
|
|
259
|
-
updateProgress({ landing });
|
|
263
|
+
const landing = await claimLanding(request, deps, updateProgress);
|
|
260
264
|
|
|
261
|
-
await
|
|
262
|
-
|
|
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(
|
|
425
|
-
{ phase: 'resolving', 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
|
-
`${
|
|
437
|
-
{ phase: 'resolving', 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 {
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -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: (
|
|
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
|
-
|
|
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
|
|
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,
|
|
1570
|
-
: await this.resolveHostDir(provider, null,
|
|
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
|
-
|
|
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,
|
|
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
|
|
package/src/mcp/mcp-tools.ts
CHANGED
|
@@ -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(
|
|
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
|
/**
|
package/src/mcp/run-tool.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|