@zgeoff/atc 2.12.0 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/package.json +2 -1
- package/src/agents/agent-adapter.ts +48 -11
- package/src/agents/build-atc-bridge-files.ts +13 -0
- package/src/agents/build-claude-query-options.ts +72 -0
- package/src/agents/build-cli-command.ts +7 -5
- package/src/{daemon → agents}/build-headless-env.ts +12 -6
- package/src/agents/build-hook-settings.ts +8 -2
- package/src/agents/build-restore-mode-args.ts +23 -0
- package/src/agents/claude-adapter.ts +64 -17
- package/src/agents/codex-adapter.ts +5 -0
- package/src/agents/find-claude-permission-mode.ts +24 -0
- package/src/agents/gateway-adapter.ts +38 -11
- package/src/agents/grok-adapter.ts +5 -0
- package/src/agents/make-claude-headless-runner.ts +46 -0
- package/src/agents/plan-pasted-line-input.ts +22 -0
- package/src/agents/plan-typed-line-input.ts +8 -0
- package/src/agents/resolve-claude-permission-mode.ts +13 -0
- package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -53
- package/src/agents/write-atc-bridge.ts +2 -6
- package/src/cli.ts +8 -12
- package/src/client/index.ts +50 -1
- package/src/client/ui.ts +8 -0
- package/src/daemon/build-execution-targets.ts +49 -10
- package/src/daemon/build-imp-provider.ts +68 -0
- package/src/daemon/build-report-trail-entry.ts +4 -2
- package/src/daemon/build-scoped-context.ts +4 -1
- package/src/daemon/build-session-lifecycle.ts +52 -0
- package/src/daemon/build-tar-archive.ts +85 -0
- package/src/daemon/daemon-connection.ts +97 -4
- package/src/daemon/daemon.ts +298 -41
- package/src/daemon/execution-provider.ts +109 -4
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/imp-client-port.ts +343 -0
- package/src/daemon/imp-harness.ts +618 -0
- package/src/daemon/imp-port-error.ts +18 -0
- package/src/daemon/imp-port.ts +246 -0
- package/src/daemon/imp-provider.ts +601 -0
- package/src/daemon/is-binding-current.ts +43 -0
- package/src/daemon/local-pty-provider.ts +62 -3
- package/src/daemon/materialize-workspace.ts +571 -0
- package/src/daemon/parse-hook-line.ts +31 -0
- package/src/daemon/pick-session-state.ts +15 -0
- package/src/daemon/restore-fleet.ts +41 -23
- package/src/daemon/screen-model.ts +7 -0
- package/src/daemon/sessions.ts +574 -61
- package/src/daemon/start-headless-turn.ts +1 -1
- package/src/daemon/start-session-bridge.ts +294 -0
- package/src/mcp/mcp-tools.ts +9 -2
- package/src/mcp/require-daemon-features.ts +3 -0
- package/src/mcp/run-tool.ts +27 -8
- package/src/protocol/daemon-features.ts +11 -0
- package/src/protocol/protocol.ts +23 -0
- package/src/protocol/request-param-schemas.ts +66 -0
- package/src/report.ts +37 -2
- package/src/run-bridge-tap.ts +241 -0
- package/src/shared/collect-clean-env.ts +9 -1
- package/src/shared/open-bridge-socket.ts +86 -0
- package/src/shared/send-bridge-request.ts +47 -0
- package/src/statusline.ts +34 -3
- package/src/store/fleet-entry.ts +19 -0
- package/src/store/run-migrations.ts +76 -0
- package/src/store/state-store.ts +182 -21
- package/src/store/trail-entry.ts +4 -0
- package/src/store/workspace-materialization.ts +70 -0
- package/src/tap.ts +11 -0
- package/src/workspace/check-url-credentials.ts +59 -0
- package/src/workspace/create-workspace-clone.ts +13 -1
- package/src/workspace/repository-env-vars.ts +22 -0
- package/src/workspace/run-git.ts +2 -18
- /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
package/src/daemon/sessions.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs';
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
AdapterEvent,
|
|
4
|
+
AgentAdapter,
|
|
5
|
+
AgentID,
|
|
6
|
+
SpawnOptions,
|
|
7
|
+
SpawnOverrides,
|
|
8
|
+
SpawnPlan,
|
|
9
|
+
} from '../agents/agent-adapter';
|
|
3
10
|
import { truncateDetail } from '../agents/truncate-detail';
|
|
4
11
|
import { DaemonError } from '../protocol/daemon-error';
|
|
5
12
|
import type { ErrorCode } from '../protocol/protocol';
|
|
6
13
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
7
|
-
import { collectCleanEnv } from '../shared/collect-clean-env';
|
|
8
14
|
import type { TargetConfigError } from '../shared/collect-targets';
|
|
9
15
|
import { socketPath, statusFile } from '../shared/config';
|
|
10
16
|
import type { DaemonID } from '../shared/daemon-id';
|
|
@@ -12,13 +18,24 @@ import { resolveRepoRoot } from '../shared/resolve-repo-root';
|
|
|
12
18
|
import type { SessionID } from '../shared/session-id';
|
|
13
19
|
import { truncateToBytes } from '../shared/truncate-to-bytes';
|
|
14
20
|
import type { FleetEntry, FleetEntryUpdate, FleetStore } from '../store/fleet-entry';
|
|
21
|
+
import type { SessionWorkspace } from '../store/workspace-materialization';
|
|
15
22
|
import type { ExecutionTarget } from './build-execution-targets';
|
|
23
|
+
import { buildSessionLifecycle } from './build-session-lifecycle';
|
|
24
|
+
import type { SessionLifecycle } from './build-session-lifecycle';
|
|
25
|
+
import { buildTarArchive } from './build-tar-archive';
|
|
16
26
|
import { buildTargetIdentity } from './build-target-identity';
|
|
17
|
-
import type {
|
|
27
|
+
import type {
|
|
28
|
+
ExecutionCapability,
|
|
29
|
+
ExecutionProvider,
|
|
30
|
+
HarnessHandle,
|
|
31
|
+
HarnessRelay,
|
|
32
|
+
} from './execution-provider';
|
|
18
33
|
import { findExecutionRefusal } from './find-execution-refusal';
|
|
19
34
|
import type { HookEvent } from './hooks';
|
|
35
|
+
import type { BridgeBinding } from './is-binding-current';
|
|
20
36
|
import { LocalPTYProvider } from './local-pty-provider';
|
|
21
37
|
import { mintSessionID } from './mint-session-id';
|
|
38
|
+
import { pickSessionState } from './pick-session-state';
|
|
22
39
|
|
|
23
40
|
export type SessionState = 'running' | 'needs_you' | 'done' | 'exited';
|
|
24
41
|
|
|
@@ -55,6 +72,14 @@ export interface SessionDescriptor {
|
|
|
55
72
|
// Where the session runs: the daemon hosting it, and the execution target
|
|
56
73
|
// on that daemon's host.
|
|
57
74
|
readonly locator: SessionLocator;
|
|
75
|
+
|
|
76
|
+
// What the session's working directory was materialized from, for a
|
|
77
|
+
// session spawned with a workspace source.
|
|
78
|
+
readonly workspace?: SessionWorkspace;
|
|
79
|
+
|
|
80
|
+
// What the operator asked for, the host, the harness, and the daemon's
|
|
81
|
+
// connection to it; the state derives from these and the attention.
|
|
82
|
+
readonly lifecycle: SessionLifecycle;
|
|
58
83
|
}
|
|
59
84
|
|
|
60
85
|
interface SessionLocator {
|
|
@@ -117,6 +142,39 @@ export interface Session {
|
|
|
117
142
|
// while the target keeps that identity.
|
|
118
143
|
target: string;
|
|
119
144
|
targetIdentity: string;
|
|
145
|
+
|
|
146
|
+
// what the working directory was materialized from, when the spawn
|
|
147
|
+
// carried a workspace source
|
|
148
|
+
workspace?: SessionWorkspace;
|
|
149
|
+
|
|
150
|
+
// the environment variable names every harness the session starts goes
|
|
151
|
+
// without: the materialization's credential and askpass context
|
|
152
|
+
withheldEnv: readonly string[];
|
|
153
|
+
|
|
154
|
+
// What the operator asked of the harness, the last state seen of its
|
|
155
|
+
// host, and the daemon's connection to its output.
|
|
156
|
+
desired: SessionLifecycle['desired'];
|
|
157
|
+
vm: SessionLifecycle['vm'];
|
|
158
|
+
attachment: SessionLifecycle['attachment'];
|
|
159
|
+
|
|
160
|
+
// Whether the harness is kept inside a sleeping host, for a revive to
|
|
161
|
+
// find as it was.
|
|
162
|
+
suspended: boolean;
|
|
163
|
+
|
|
164
|
+
// The session whose host the harness runs on: its own id, or its
|
|
165
|
+
// parent's when the parent runs on the same target, so one host serves a
|
|
166
|
+
// top-level session and the sub-sessions beside it.
|
|
167
|
+
hostKey: SessionID;
|
|
168
|
+
|
|
169
|
+
// The epoch of the session's latest harness start or attach, which the
|
|
170
|
+
// daemon's bridge to that harness is bound to; 0 before the first.
|
|
171
|
+
bridgeEpoch: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// A session's ready workspace and the variables its harnesses go without.
|
|
175
|
+
interface MaterializedSpawn {
|
|
176
|
+
readonly workspace: SessionWorkspace;
|
|
177
|
+
readonly withheldEnv: readonly string[];
|
|
120
178
|
}
|
|
121
179
|
|
|
122
180
|
// The identity of the implicit `local` target, which a fleet row without a
|
|
@@ -139,6 +197,14 @@ export class SessionManager {
|
|
|
139
197
|
|
|
140
198
|
onChange: () => void = () => {};
|
|
141
199
|
|
|
200
|
+
// Called as a harness starts, before its first output can arrive, with
|
|
201
|
+
// the terminal size it starts at.
|
|
202
|
+
onBoot: (s: Session, cols: number, rows: number) => void = () => {};
|
|
203
|
+
|
|
204
|
+
// Takes each connection a harness's processes open through a provider's
|
|
205
|
+
// own relay, with the binding of the harness start that opened it.
|
|
206
|
+
onRelay: (binding: BridgeBinding, relay: HarnessRelay) => void = () => {};
|
|
207
|
+
|
|
142
208
|
onEvent: (kind: SessionEventKind, s: Session) => void = () => {};
|
|
143
209
|
|
|
144
210
|
// Where a background failure is reported, one line at a time: stderr,
|
|
@@ -162,6 +228,14 @@ export class SessionManager {
|
|
|
162
228
|
|
|
163
229
|
private readonly targetErrors: readonly TargetConfigError[];
|
|
164
230
|
|
|
231
|
+
// Sessions whose revive waits on their host waking, so a second revive
|
|
232
|
+
// of the same session does not start a second harness.
|
|
233
|
+
private readonly adopting = new Set<SessionID>();
|
|
234
|
+
|
|
235
|
+
// The epoch the next harness start or attach takes, unique across every
|
|
236
|
+
// session this manager holds.
|
|
237
|
+
private nextBridgeEpoch = 1;
|
|
238
|
+
|
|
165
239
|
constructor(
|
|
166
240
|
fallback: AgentAdapter,
|
|
167
241
|
store: FleetStore,
|
|
@@ -268,6 +342,24 @@ export class SessionManager {
|
|
|
268
342
|
return s;
|
|
269
343
|
}
|
|
270
344
|
|
|
345
|
+
// A sibling's revive failing leaves that sibling asleep; the session
|
|
346
|
+
// whose revive woke the host is already running, so the failure is
|
|
347
|
+
// logged rather than thrown.
|
|
348
|
+
private async tryAdoptSibling(
|
|
349
|
+
sibling: Session,
|
|
350
|
+
s: Session,
|
|
351
|
+
cols: number,
|
|
352
|
+
rows: number,
|
|
353
|
+
): Promise<void> {
|
|
354
|
+
try {
|
|
355
|
+
await this.adoptTerminal(sibling.id, cols, rows);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
this.log(
|
|
358
|
+
`atc could not revive session ${sibling.id} beside ${s.id} (${error instanceof Error ? error.message : String(error)})`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
271
363
|
// Registers a fleet entry as a session with no terminal yet, under the
|
|
272
364
|
// atc session id its row holds, so a fleet-wide restore can show every
|
|
273
365
|
// incoming session at once; adopting it later attaches the terminal.
|
|
@@ -290,7 +382,9 @@ export class SessionManager {
|
|
|
290
382
|
// reviving under another agent. Adopting a terminal for it is refused.
|
|
291
383
|
let lastMsg = 'waiting to restore';
|
|
292
384
|
|
|
293
|
-
if (
|
|
385
|
+
if (exited && entry.desired === 'sleep') {
|
|
386
|
+
lastMsg = 'asleep';
|
|
387
|
+
} else if (entry.exited !== true && entry.agentSessionID === undefined) {
|
|
294
388
|
lastMsg = 'nothing to resume';
|
|
295
389
|
} else if (entry.exited !== true && targetRefusal !== null) {
|
|
296
390
|
lastMsg = targetRefusal;
|
|
@@ -326,6 +420,14 @@ export class SessionManager {
|
|
|
326
420
|
...(entry.effort === undefined ? {} : { effort: entry.effort }),
|
|
327
421
|
target,
|
|
328
422
|
targetIdentity,
|
|
423
|
+
...(entry.workspace === undefined ? {} : { workspace: entry.workspace }),
|
|
424
|
+
withheldEnv: entry.withheldEnv ?? [],
|
|
425
|
+
desired: entry.desired ?? 'run',
|
|
426
|
+
suspended: exited && entry.desired === 'sleep',
|
|
427
|
+
vm: this.pickRestoredVM(target, entry.desired),
|
|
428
|
+
attachment: this.hasHostLifecycle(target) ? 'detached' : 'local',
|
|
429
|
+
hostKey: entry.hostKey ?? entry.sessionID,
|
|
430
|
+
bridgeEpoch: 0,
|
|
329
431
|
};
|
|
330
432
|
|
|
331
433
|
this.sessions.push(session);
|
|
@@ -335,12 +437,37 @@ export class SessionManager {
|
|
|
335
437
|
return session;
|
|
336
438
|
}
|
|
337
439
|
|
|
440
|
+
// Whether a target's host has a lifecycle of its own that the daemon
|
|
441
|
+
// follows: one that can sleep or be destroyed. The daemon's own machine
|
|
442
|
+
// has none.
|
|
443
|
+
private hasHostLifecycle(target: string): boolean {
|
|
444
|
+
const capabilities = this.targets.get(target)?.provider?.capabilities;
|
|
445
|
+
|
|
446
|
+
return capabilities !== undefined && (capabilities.suspend || capabilities.destroy);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// The host state a restored session lists with: asleep when the operator
|
|
450
|
+
// left it asleep, unknown for any other remote host until the daemon
|
|
451
|
+
// reaches it, and none on the daemon's own machine.
|
|
452
|
+
private pickRestoredVM(
|
|
453
|
+
target: string,
|
|
454
|
+
desired: 'sleep' | 'stop' | undefined,
|
|
455
|
+
): SessionLifecycle['vm'] {
|
|
456
|
+
if (!this.hasHostLifecycle(target)) {
|
|
457
|
+
return 'none';
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return desired === 'sleep' ? 'asleep' : 'unknown';
|
|
461
|
+
}
|
|
462
|
+
|
|
338
463
|
// Adopts a headless session back into a terminal: a fresh PTY resumes the
|
|
339
|
-
// same agent session id.
|
|
340
|
-
|
|
464
|
+
// same agent session id. On a remote host the host wakes first, and a
|
|
465
|
+
// harness still running inside it is attached rather than started again;
|
|
466
|
+
// every other session left asleep on that host comes back with it.
|
|
467
|
+
async adoptTerminal(id: SessionID, cols: number, rows: number): Promise<Session | null> {
|
|
341
468
|
const s = this.sessions.find((x) => x.id === id);
|
|
342
469
|
|
|
343
|
-
if (!s || s.pty !== null || s.agentSessionID === undefined) {
|
|
470
|
+
if (!s || s.pty !== null || s.agentSessionID === undefined || this.adopting.has(id)) {
|
|
344
471
|
return null;
|
|
345
472
|
}
|
|
346
473
|
|
|
@@ -352,49 +479,115 @@ export class SessionManager {
|
|
|
352
479
|
|
|
353
480
|
const provider = this.requireExecution(s, 'spawn').provider;
|
|
354
481
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
482
|
+
this.adopting.add(id);
|
|
483
|
+
|
|
484
|
+
let plan: SpawnPlan;
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
plan = await this.setupHarness(adapter, provider, s.id, s.hostKey, s.target, {
|
|
488
|
+
prompt: '',
|
|
489
|
+
resume: s.agentSessionID,
|
|
490
|
+
...(s.model === undefined ? {} : { model: s.model }),
|
|
491
|
+
...(s.effort === undefined ? {} : { effort: s.effort }),
|
|
492
|
+
});
|
|
493
|
+
} finally {
|
|
494
|
+
this.adopting.delete(id);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// A kill or a second adopt can land while the host wakes.
|
|
498
|
+
if (s.pty !== null || !this.sessions.includes(s)) {
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const binding = this.mintBridgeBinding(s.id, s.target, s.targetIdentity, s.hostKey);
|
|
361
503
|
|
|
362
504
|
const pty = provider.spawnHarness({
|
|
505
|
+
session: s.id,
|
|
506
|
+
host: s.hostKey,
|
|
363
507
|
bin: plan.bin,
|
|
364
508
|
args: plan.args,
|
|
365
509
|
cwd: s.cwd,
|
|
366
|
-
env:
|
|
510
|
+
env: { ATC_SESSION_ID: s.id, ATC_SOCKET: socketPath },
|
|
511
|
+
withheldEnv: s.withheldEnv,
|
|
367
512
|
cols,
|
|
368
513
|
rows,
|
|
514
|
+
onRelay: (relay) => {
|
|
515
|
+
this.onRelay(binding, relay);
|
|
516
|
+
},
|
|
369
517
|
});
|
|
370
518
|
|
|
519
|
+
s.bridgeEpoch = binding.epoch;
|
|
371
520
|
s.pty = pty;
|
|
372
521
|
s.kind = 'pty';
|
|
373
522
|
s.state = 'running';
|
|
374
523
|
s.lastMsg = 'revived';
|
|
524
|
+
s.desired = 'run';
|
|
525
|
+
s.suspended = false;
|
|
526
|
+
|
|
527
|
+
this.attachHarness(s, pty, this.hasHostLifecycle(s.target));
|
|
528
|
+
this.onBoot(s, cols, rows);
|
|
529
|
+
void this.tryWriteFleet(s.id);
|
|
530
|
+
this.onEvent('state', s);
|
|
531
|
+
this.emitChange();
|
|
532
|
+
|
|
533
|
+
for (const asleep of this.sessions) {
|
|
534
|
+
if (asleep.hostKey === s.hostKey && asleep.target === s.target && asleep.suspended) {
|
|
535
|
+
await this.tryAdoptSibling(asleep, s, cols, rows);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return s;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Follows a harness's output and its end. An exit, or a host that lost the
|
|
543
|
+
// process, leaves the session exited; a host that went to sleep with the
|
|
544
|
+
// process inside leaves it suspended, for a revive to find. A session
|
|
545
|
+
// mid-handoff keeps its headless state, since the terminal dying is
|
|
546
|
+
// expected there.
|
|
547
|
+
private attachHarness(s: Session, pty: HarnessHandle, hostLifecycle: boolean): void {
|
|
548
|
+
s.vm = hostLifecycle ? 'awake' : 'none';
|
|
549
|
+
s.attachment = hostLifecycle ? 'attached' : 'local';
|
|
375
550
|
|
|
376
551
|
pty.onData((d) => {
|
|
377
552
|
this.onOutput(s, d);
|
|
378
553
|
});
|
|
379
554
|
|
|
380
|
-
pty.
|
|
555
|
+
pty.onAttachment?.((attachment) => {
|
|
556
|
+
if (s.pty !== pty || s.attachment === attachment) {
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
s.attachment = attachment;
|
|
561
|
+
|
|
562
|
+
this.onEvent('state', s);
|
|
563
|
+
this.emitChange();
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
pty.onExit((exit) => {
|
|
567
|
+
if (s.pty !== pty) {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
381
571
|
s.pty = null;
|
|
382
572
|
|
|
573
|
+
if (hostLifecycle) {
|
|
574
|
+
s.attachment = 'detached';
|
|
575
|
+
}
|
|
576
|
+
|
|
383
577
|
if (s.kind === 'pty' && s.state !== 'exited') {
|
|
384
578
|
s.state = 'exited';
|
|
385
579
|
s.unread = this.focusedId !== s.id;
|
|
386
|
-
s.lastMsg =
|
|
580
|
+
s.lastMsg = pickExitMessage(exit);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (exit.reason === 'suspended') {
|
|
584
|
+
s.vm = 'asleep';
|
|
585
|
+
s.suspended = true;
|
|
387
586
|
}
|
|
388
587
|
|
|
389
588
|
this.onEvent('state', s);
|
|
390
589
|
this.emitChange();
|
|
391
590
|
});
|
|
392
|
-
|
|
393
|
-
void this.tryWriteFleet(s.id);
|
|
394
|
-
this.onEvent('state', s);
|
|
395
|
-
this.emitChange();
|
|
396
|
-
|
|
397
|
-
return s;
|
|
398
591
|
}
|
|
399
592
|
|
|
400
593
|
/**
|
|
@@ -491,8 +684,10 @@ export class SessionManager {
|
|
|
491
684
|
// the new process runs with, and the session keeps them for every revive.
|
|
492
685
|
// id is minted here unless the caller minted it ahead of the spawn. target
|
|
493
686
|
// is the execution target the harness runs on; one this daemon cannot use
|
|
494
|
-
// refuses the spawn before anything starts.
|
|
495
|
-
|
|
687
|
+
// refuses the spawn before anything starts. materialized holds what cwd
|
|
688
|
+
// was materialized from, when it was, and the variables the session's
|
|
689
|
+
// harnesses go without.
|
|
690
|
+
async spawn(
|
|
496
691
|
cwd: string,
|
|
497
692
|
name: string,
|
|
498
693
|
prompt: string,
|
|
@@ -505,7 +700,8 @@ export class SessionManager {
|
|
|
505
700
|
overrides: SpawnOverrides = {},
|
|
506
701
|
id: SessionID = mintSessionID(),
|
|
507
702
|
target = 'local',
|
|
508
|
-
|
|
703
|
+
materialized: MaterializedSpawn | null = null,
|
|
704
|
+
): Promise<Session> {
|
|
509
705
|
const adapter = this.findAdapter(agent);
|
|
510
706
|
|
|
511
707
|
if (adapter === null) {
|
|
@@ -516,17 +712,32 @@ export class SessionManager {
|
|
|
516
712
|
const provider = execution.provider;
|
|
517
713
|
|
|
518
714
|
// The repository root resolves before the process starts: resolving it
|
|
519
|
-
// can throw, and a spawn that throws must leave nothing running.
|
|
520
|
-
|
|
521
|
-
const
|
|
715
|
+
// can throw, and a spawn that throws must leave nothing running. A
|
|
716
|
+
// remote directory is not on the daemon's machine, so it is its own root.
|
|
717
|
+
const repoRoot = provider.remote ? cwd : resolveRepoRoot(cwd);
|
|
718
|
+
const hostKey = this.pickHostKey(id, parent, target, execution.identity);
|
|
719
|
+
|
|
720
|
+
const plan = await this.setupHarness(adapter, provider, id, hostKey, target, {
|
|
721
|
+
prompt,
|
|
722
|
+
resume,
|
|
723
|
+
...overrides,
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
const binding = this.mintBridgeBinding(id, target, execution.identity, hostKey);
|
|
522
727
|
|
|
523
728
|
const pty = provider.spawnHarness({
|
|
729
|
+
session: id,
|
|
730
|
+
host: hostKey,
|
|
524
731
|
bin: plan.bin,
|
|
525
732
|
args: plan.args,
|
|
526
733
|
cwd,
|
|
527
|
-
env:
|
|
734
|
+
env: { ATC_SESSION_ID: id, ATC_SOCKET: socketPath },
|
|
735
|
+
withheldEnv: materialized?.withheldEnv ?? [],
|
|
528
736
|
cols,
|
|
529
737
|
rows,
|
|
738
|
+
onRelay: (relay) => {
|
|
739
|
+
this.onRelay(binding, relay);
|
|
740
|
+
},
|
|
530
741
|
});
|
|
531
742
|
|
|
532
743
|
let initialMsg = prompt;
|
|
@@ -557,33 +768,123 @@ export class SessionManager {
|
|
|
557
768
|
...(overrides.effort === undefined ? {} : { effort: overrides.effort }),
|
|
558
769
|
target,
|
|
559
770
|
targetIdentity: execution.identity,
|
|
771
|
+
...(materialized === null ? {} : { workspace: materialized.workspace }),
|
|
772
|
+
withheldEnv: materialized?.withheldEnv ?? [],
|
|
773
|
+
desired: 'run',
|
|
774
|
+
vm: 'none',
|
|
775
|
+
attachment: 'local',
|
|
776
|
+
suspended: false,
|
|
777
|
+
hostKey,
|
|
778
|
+
bridgeEpoch: binding.epoch,
|
|
560
779
|
};
|
|
561
780
|
|
|
562
|
-
pty.
|
|
563
|
-
|
|
781
|
+
this.attachHarness(session, pty, this.hasHostLifecycle(target));
|
|
782
|
+
this.sessions.push(session);
|
|
783
|
+
void this.tryWriteFleet(session.id);
|
|
784
|
+
this.writeStatus();
|
|
785
|
+
this.onEvent('added', session);
|
|
786
|
+
this.onBoot(session, cols, rows);
|
|
787
|
+
|
|
788
|
+
return session;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// The binding a harness start or attach opens its bridge under, at a
|
|
792
|
+
// fresh epoch.
|
|
793
|
+
private mintBridgeBinding(
|
|
794
|
+
sessionID: SessionID,
|
|
795
|
+
target: string,
|
|
796
|
+
targetIdentity: string,
|
|
797
|
+
hostKey: SessionID,
|
|
798
|
+
): BridgeBinding {
|
|
799
|
+
const epoch = this.nextBridgeEpoch;
|
|
800
|
+
|
|
801
|
+
this.nextBridgeEpoch += 1;
|
|
802
|
+
|
|
803
|
+
return { sessionID, target, targetIdentity, hostKey, epoch };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Readies the host a harness is about to start on and plans the harness.
|
|
807
|
+
// On a remote host the agent plans a guest spawn, whose files unpack into
|
|
808
|
+
// the session's own guest folder, and the agent's sign-in check runs
|
|
809
|
+
// there first. Every refusal comes before the harness starts.
|
|
810
|
+
private async setupHarness(
|
|
811
|
+
adapter: AgentAdapter,
|
|
812
|
+
provider: ExecutionProvider,
|
|
813
|
+
id: SessionID,
|
|
814
|
+
hostKey: SessionID,
|
|
815
|
+
target: string,
|
|
816
|
+
options: SpawnOptions,
|
|
817
|
+
): Promise<SpawnPlan> {
|
|
818
|
+
if (!provider.remote) {
|
|
819
|
+
await provider.prepareHost({ host: hostKey, daemonID: this.store.daemonID });
|
|
820
|
+
|
|
821
|
+
return adapter.planSpawn(options);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const guest = provider.guest ?? { dir: '/tmp/atc', atc: null };
|
|
825
|
+
const dir = `${guest.dir}/sessions/${id}`;
|
|
826
|
+
|
|
827
|
+
const plan =
|
|
828
|
+
adapter.planGuestSpawn === undefined
|
|
829
|
+
? { ...adapter.planSpawn(options), files: {} }
|
|
830
|
+
: adapter.planGuestSpawn(options, { atc: guest.atc, dir });
|
|
831
|
+
|
|
832
|
+
if (plan === null) {
|
|
833
|
+
throw buildGuestRefusal(provider.kind, adapter.id, target, guest.atc === null);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
await provider.prepareHost({
|
|
837
|
+
host: hostKey,
|
|
838
|
+
daemonID: this.store.daemonID,
|
|
839
|
+
installATC: adapter.planGuestSpawn !== undefined,
|
|
564
840
|
});
|
|
565
841
|
|
|
566
|
-
|
|
567
|
-
session.pty = null;
|
|
842
|
+
const check = adapter.planAuthCheck?.();
|
|
568
843
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
844
|
+
if (check !== undefined) {
|
|
845
|
+
const result = await provider.runCommand({ argv: check, cwd: '/', host: hostKey });
|
|
846
|
+
|
|
847
|
+
if (result.exitCode !== 0) {
|
|
848
|
+
throw new DaemonError(
|
|
849
|
+
'auth_not_configured',
|
|
850
|
+
`agent '${adapter.id}' is not signed in on target '${target}'; sign it in inside the host's image`,
|
|
851
|
+
{ agent: adapter.id, target },
|
|
852
|
+
);
|
|
575
853
|
}
|
|
854
|
+
}
|
|
576
855
|
|
|
577
|
-
|
|
578
|
-
this.emitChange();
|
|
579
|
-
});
|
|
856
|
+
const files = Object.entries(plan.files).map(([path, content]) => ({ path, content }));
|
|
580
857
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
this.onEvent('added', session);
|
|
858
|
+
if (files.length > 0) {
|
|
859
|
+
await provider.transferArchive(buildTarArchive(files), dir, hostKey);
|
|
860
|
+
}
|
|
585
861
|
|
|
586
|
-
return
|
|
862
|
+
return plan;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// A sub-session runs on its parent's host when its resolved target, name
|
|
866
|
+
// and identity both, is the one its parent is bound to and that target's
|
|
867
|
+
// hosts have a lifecycle, so one host serves a top-level session and
|
|
868
|
+
// every sub-session beside it there; any other session has a host of its
|
|
869
|
+
// own.
|
|
870
|
+
private pickHostKey(
|
|
871
|
+
id: SessionID,
|
|
872
|
+
parent: SessionID | null,
|
|
873
|
+
target: string,
|
|
874
|
+
identity: string,
|
|
875
|
+
): SessionID {
|
|
876
|
+
const owner = parent === null ? undefined : this.sessions.find((s) => s.id === parent);
|
|
877
|
+
|
|
878
|
+
if (
|
|
879
|
+
owner === undefined ||
|
|
880
|
+
owner.target !== target ||
|
|
881
|
+
owner.targetIdentity !== identity ||
|
|
882
|
+
!this.hasHostLifecycle(target)
|
|
883
|
+
) {
|
|
884
|
+
return id;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
return owner.hostKey;
|
|
587
888
|
}
|
|
588
889
|
|
|
589
890
|
// Takes back a spawn that failed after its process started: the process
|
|
@@ -612,7 +913,7 @@ export class SessionManager {
|
|
|
612
913
|
id: s.id,
|
|
613
914
|
name: s.name,
|
|
614
915
|
cwd: s.cwd,
|
|
615
|
-
state: s.state,
|
|
916
|
+
state: pickSessionState(buildLifecycle(s), s.state),
|
|
616
917
|
unread: s.unread,
|
|
617
918
|
lastMsg: s.lastMsg,
|
|
618
919
|
...(s.lastDetail === undefined ? {} : { lastDetail: s.lastDetail }),
|
|
@@ -628,6 +929,8 @@ export class SessionManager {
|
|
|
628
929
|
canEject: (this.findAdapter(s.agent)?.headlessRunner ?? null) !== null,
|
|
629
930
|
...(s.parent === null ? {} : { parent: s.parent }),
|
|
630
931
|
locator: { daemonID: this.store.daemonID, targetID: s.target },
|
|
932
|
+
...(s.workspace === undefined ? {} : { workspace: s.workspace }),
|
|
933
|
+
lifecycle: buildLifecycle(s),
|
|
631
934
|
}));
|
|
632
935
|
}
|
|
633
936
|
|
|
@@ -835,7 +1138,12 @@ export class SessionManager {
|
|
|
835
1138
|
// archived, so the write it depends on must land before that response
|
|
836
1139
|
// goes out. A kill acts on the whole set: killing a session kills its
|
|
837
1140
|
// live sub-sessions with it, and forgetting a dead one forgets its dead
|
|
838
|
-
// sub-sessions and promotes any live ones to top level.
|
|
1141
|
+
// sub-sessions and promotes any live ones to top level. A live session
|
|
1142
|
+
// that owns a host that can sleep puts the host to sleep instead, with
|
|
1143
|
+
// every harness on it kept inside, and fails whole when the host stays
|
|
1144
|
+
// awake. A dead session on a target that can destroy its host is not
|
|
1145
|
+
// forgotten by a kill: forgetting it destroys the host, which takes a
|
|
1146
|
+
// confirmed forget.
|
|
839
1147
|
async kill(id: SessionID): Promise<void> {
|
|
840
1148
|
const s = this.sessions.find((x) => x.id === id);
|
|
841
1149
|
|
|
@@ -844,28 +1152,181 @@ export class SessionManager {
|
|
|
844
1152
|
}
|
|
845
1153
|
|
|
846
1154
|
if (s.pty) {
|
|
1155
|
+
await this.stopHarness(s);
|
|
1156
|
+
|
|
847
1157
|
for (const child of this.collectChildren(id)) {
|
|
848
|
-
this.
|
|
1158
|
+
await this.tryStopHarness(child);
|
|
849
1159
|
}
|
|
850
|
-
|
|
851
|
-
this.killTerminal(s);
|
|
852
1160
|
} else {
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1161
|
+
if (this.findProvider(s)?.capabilities.destroy === true) {
|
|
1162
|
+
throw new DaemonError(
|
|
1163
|
+
'confirmation_required',
|
|
1164
|
+
`forgetting session ${id} destroys its host on target '${s.target}'; confirm it with session.forget`,
|
|
1165
|
+
{ session: id },
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
this.updateForgottenChildren(id);
|
|
1170
|
+
this.remove(s);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
await this.writeFleet();
|
|
1174
|
+
|
|
1175
|
+
this.emitChange();
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Forgets a session for good, whether it runs or not, and returns whether
|
|
1180
|
+
* its host was destroyed. A session that owns a host its target can
|
|
1181
|
+
* destroy destroys the host, and every session on that host goes with it;
|
|
1182
|
+
* a session on its parent's host ends its own harness alone, and one
|
|
1183
|
+
* kept asleep in that host is refused until the host wakes. Its dead
|
|
1184
|
+
* sub-sessions on other hosts go with it, unless their own target can
|
|
1185
|
+
* destroy their host, and its live ones become top-level. A failed destroy
|
|
1186
|
+
* throws before anything is forgotten.
|
|
1187
|
+
*/
|
|
1188
|
+
async forget(id: SessionID): Promise<boolean> {
|
|
1189
|
+
const s = this.sessions.find((x) => x.id === id);
|
|
858
1190
|
|
|
859
|
-
|
|
1191
|
+
if (s === undefined) {
|
|
1192
|
+
return false;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const provider = this.findProvider(s);
|
|
1196
|
+
const destroys = provider !== null && provider.capabilities.destroy && s.hostKey === s.id;
|
|
1197
|
+
|
|
1198
|
+
// A harness kept inside a sleeping host it does not own still has a
|
|
1199
|
+
// process there, which the daemon can neither reach nor end while the
|
|
1200
|
+
// host sleeps. Its record keeps that process owned until the host wakes
|
|
1201
|
+
// or its owner's forget destroys the host.
|
|
1202
|
+
if (!destroys && s.pty === null && s.vm === 'asleep') {
|
|
1203
|
+
throw new DaemonError(
|
|
1204
|
+
'unsupported_operation',
|
|
1205
|
+
`session ${id} sleeps inside the host of session ${s.hostKey}; revive it or forget session ${s.hostKey} first`,
|
|
1206
|
+
{ provider: provider?.kind ?? null, problem: 'host_asleep', host: s.hostKey },
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
if (destroys) {
|
|
1211
|
+
await provider.destroyHost(s.hostKey);
|
|
1212
|
+
|
|
1213
|
+
for (const onHost of this.sessions) {
|
|
1214
|
+
if (onHost.hostKey === s.hostKey && onHost.target === s.target && onHost.id !== s.id) {
|
|
1215
|
+
onHost.pty?.detach();
|
|
1216
|
+
onHost.pty = null;
|
|
1217
|
+
|
|
1218
|
+
this.remove(onHost);
|
|
860
1219
|
}
|
|
861
1220
|
}
|
|
862
1221
|
|
|
863
|
-
|
|
1222
|
+
s.pty?.detach();
|
|
1223
|
+
s.pty = null;
|
|
1224
|
+
} else {
|
|
1225
|
+
this.killTerminal(s);
|
|
864
1226
|
}
|
|
865
1227
|
|
|
1228
|
+
this.updateForgottenChildren(id);
|
|
1229
|
+
this.remove(s);
|
|
1230
|
+
|
|
866
1231
|
await this.writeFleet();
|
|
867
1232
|
|
|
868
1233
|
this.emitChange();
|
|
1234
|
+
|
|
1235
|
+
return destroys;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// A forgotten parent's dead sub-sessions go with it, except one whose own
|
|
1239
|
+
// target can destroy its host: forgetting that one destroys the host, which
|
|
1240
|
+
// takes its own confirmed forget. Every sub-session that stays becomes
|
|
1241
|
+
// top-level.
|
|
1242
|
+
private updateForgottenChildren(id: SessionID): void {
|
|
1243
|
+
for (const child of this.collectChildren(id)) {
|
|
1244
|
+
const live = child.pty !== null || (child.kind === 'headless' && child.state !== 'exited');
|
|
1245
|
+
|
|
1246
|
+
if (live || this.findProvider(child)?.capabilities.destroy === true) {
|
|
1247
|
+
child.parent = null;
|
|
1248
|
+
|
|
1249
|
+
this.onEvent('state', child);
|
|
1250
|
+
} else {
|
|
1251
|
+
this.remove(child);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* The capability a kill of a live session needs on its target: `suspend`
|
|
1258
|
+
* for a session that owns a host that can sleep, `kill` for any other.
|
|
1259
|
+
*/
|
|
1260
|
+
pickKillCapability(s: Session): ExecutionCapability {
|
|
1261
|
+
return this.canSuspendHost(s) ? 'suspend' : 'kill';
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
private canSuspendHost(s: Session): boolean {
|
|
1265
|
+
return s.hostKey === s.id && this.findProvider(s)?.capabilities.suspend === true;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// Puts the session's host to sleep when it owns one that can sleep, and
|
|
1269
|
+
// ends its harness otherwise. A refused sleep throws before anything
|
|
1270
|
+
// changes.
|
|
1271
|
+
private async stopHarness(s: Session): Promise<void> {
|
|
1272
|
+
const provider = this.findProvider(s);
|
|
1273
|
+
|
|
1274
|
+
if (provider === null || !this.canSuspendHost(s)) {
|
|
1275
|
+
this.killTerminal(s);
|
|
1276
|
+
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
await provider.suspendHost(s.hostKey);
|
|
1281
|
+
|
|
1282
|
+
for (const onHost of this.sessions) {
|
|
1283
|
+
if (onHost.hostKey === s.hostKey && onHost.target === s.target) {
|
|
1284
|
+
this.updateSuspended(onHost);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// A sub-session whose host stays awake is ended instead, so a kill
|
|
1290
|
+
// never leaves part of its set running.
|
|
1291
|
+
private async tryStopHarness(s: Session): Promise<void> {
|
|
1292
|
+
if (s.pty === null) {
|
|
1293
|
+
this.killTerminal(s);
|
|
1294
|
+
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
try {
|
|
1299
|
+
await this.stopHarness(s);
|
|
1300
|
+
} catch (error) {
|
|
1301
|
+
this.log(
|
|
1302
|
+
`atc could not put the host of session ${s.id} to sleep (${error instanceof Error ? error.message : String(error)}); ending its harness instead`,
|
|
1303
|
+
);
|
|
1304
|
+
|
|
1305
|
+
this.killTerminal(s);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// A harness inside a host that went to sleep: the daemon lets go of it,
|
|
1310
|
+
// and the process stays inside the host for a revive to find.
|
|
1311
|
+
private updateSuspended(s: Session): void {
|
|
1312
|
+
s.vm = 'asleep';
|
|
1313
|
+
|
|
1314
|
+
if (s.pty === null) {
|
|
1315
|
+
this.onEvent('state', s);
|
|
1316
|
+
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
s.pty.detach();
|
|
1321
|
+
|
|
1322
|
+
s.pty = null;
|
|
1323
|
+
s.state = 'exited';
|
|
1324
|
+
s.lastMsg = 'asleep';
|
|
1325
|
+
s.desired = 'sleep';
|
|
1326
|
+
s.attachment = 'detached';
|
|
1327
|
+
s.suspended = true;
|
|
1328
|
+
|
|
1329
|
+
this.onEvent('state', s);
|
|
869
1330
|
}
|
|
870
1331
|
|
|
871
1332
|
// Ends a live session, terminal or headless, leaving a dead entry; a
|
|
@@ -881,6 +1342,7 @@ export class SessionManager {
|
|
|
881
1342
|
|
|
882
1343
|
s.state = 'exited';
|
|
883
1344
|
s.lastMsg = 'killed';
|
|
1345
|
+
s.desired = 'stop';
|
|
884
1346
|
|
|
885
1347
|
this.onEvent('state', s);
|
|
886
1348
|
}
|
|
@@ -895,9 +1357,12 @@ export class SessionManager {
|
|
|
895
1357
|
this.onEvent('removed', s);
|
|
896
1358
|
}
|
|
897
1359
|
|
|
898
|
-
|
|
1360
|
+
// Lets go of every harness as the daemon stops: one on the daemon's own
|
|
1361
|
+
// machine ends with it, and one on a remote host runs on for the next
|
|
1362
|
+
// daemon to attach.
|
|
1363
|
+
detachAll() {
|
|
899
1364
|
for (const s of this.sessions) {
|
|
900
|
-
s.pty?.
|
|
1365
|
+
s.pty?.detach();
|
|
901
1366
|
}
|
|
902
1367
|
}
|
|
903
1368
|
|
|
@@ -946,6 +1411,8 @@ export class SessionManager {
|
|
|
946
1411
|
...(s.effort === undefined ? {} : { effort: s.effort }),
|
|
947
1412
|
target: s.target,
|
|
948
1413
|
targetIdentity: s.targetIdentity,
|
|
1414
|
+
...(s.desired === 'run' ? {} : { desired: s.desired }),
|
|
1415
|
+
...(s.hostKey === s.id ? {} : { hostKey: s.hostKey }),
|
|
949
1416
|
});
|
|
950
1417
|
}
|
|
951
1418
|
|
|
@@ -1089,6 +1556,52 @@ export function sortGroupedSessionViews<
|
|
|
1089
1556
|
return [...buckets.values()].flat();
|
|
1090
1557
|
}
|
|
1091
1558
|
|
|
1559
|
+
function buildLifecycle(s: Session): SessionLifecycle {
|
|
1560
|
+
return buildSessionLifecycle({
|
|
1561
|
+
desired: s.desired,
|
|
1562
|
+
vm: s.vm,
|
|
1563
|
+
attachment: s.attachment,
|
|
1564
|
+
suspended: s.suspended,
|
|
1565
|
+
hasHarness: s.pty !== null,
|
|
1566
|
+
kind: s.kind,
|
|
1567
|
+
state: s.state,
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// An agent that plans no remote spawn: for want of an atc inside the host
|
|
1572
|
+
// when the host has none, or because the agent never runs remotely.
|
|
1573
|
+
function buildGuestRefusal(
|
|
1574
|
+
provider: string,
|
|
1575
|
+
agent: string,
|
|
1576
|
+
target: string,
|
|
1577
|
+
hasNoATC: boolean,
|
|
1578
|
+
): DaemonError {
|
|
1579
|
+
return hasNoATC
|
|
1580
|
+
? new DaemonError(
|
|
1581
|
+
'unsupported_operation',
|
|
1582
|
+
`agent '${agent}' cannot run on target '${target}': its host has no atc to report through; run a compiled atc daemon on Linux, or set the target's guestATC to an atc installed in its image`,
|
|
1583
|
+
{ provider, agent, problem: 'no_guest_atc' },
|
|
1584
|
+
)
|
|
1585
|
+
: new DaemonError(
|
|
1586
|
+
'unsupported_operation',
|
|
1587
|
+
`agent '${agent}' cannot run on a remote target such as '${target}'`,
|
|
1588
|
+
{ provider, agent, problem: 'remote_unsupported' },
|
|
1589
|
+
);
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// Why a harness stopped, as a session's last message.
|
|
1593
|
+
function pickExitMessage(exit: Readonly<{ reason?: string; detail?: string }>): string {
|
|
1594
|
+
if (exit.reason === 'suspended') {
|
|
1595
|
+
return 'asleep';
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
if (exit.reason === 'ended') {
|
|
1599
|
+
return exit.detail ?? 'host lost the process';
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
return 'process exited';
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1092
1605
|
// A target refusal as a session's last message, short enough for a list row.
|
|
1093
1606
|
function formatTargetRefusal(code: ErrorCode, target: string): string {
|
|
1094
1607
|
if (code === 'target_config_invalid') {
|