@zgeoff/atc 2.11.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 +98 -10
- package/src/agents/build-args-without-flags.ts +25 -0
- package/src/agents/build-atc-bridge-files.ts +13 -0
- package/src/agents/build-claude-override-args.ts +26 -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 +112 -18
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +48 -2
- package/src/agents/find-claude-permission-mode.ts +24 -0
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +83 -13
- package/src/agents/grok-adapter.ts +24 -1
- 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 -42
- package/src/agents/write-atc-bridge.ts +2 -6
- package/src/cli.ts +23 -9
- package/src/client/daemon-client.ts +14 -2
- package/src/client/index.ts +50 -1
- package/src/client/spawn-picker.ts +27 -4
- package/src/client/ui.ts +8 -0
- package/src/daemon/build-agent-list.ts +37 -1
- package/src/daemon/build-config-revision.ts +27 -0
- package/src/daemon/build-execution-targets.ts +73 -0
- package/src/daemon/build-fleet-events.ts +2 -1
- package/src/daemon/build-imp-provider.ts +68 -0
- package/src/daemon/build-payload-hash.ts +31 -0
- package/src/daemon/build-report-trail-entry.ts +4 -2
- package/src/daemon/build-scoped-context.ts +235 -0
- package/src/daemon/build-session-lifecycle.ts +52 -0
- package/src/daemon/build-tar-archive.ts +85 -0
- package/src/daemon/build-target-access.ts +33 -0
- package/src/daemon/build-target-forbidden-error.ts +13 -0
- package/src/daemon/build-target-identity.ts +22 -0
- package/src/daemon/build-target-list.ts +50 -0
- package/src/daemon/daemon-connection.ts +425 -96
- package/src/daemon/daemon.ts +730 -96
- package/src/daemon/effect-remains-error.ts +13 -0
- package/src/daemon/execution-provider.ts +208 -0
- package/src/daemon/find-execution-refusal.ts +104 -0
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/idempotency-ledger.ts +164 -0
- 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 +142 -0
- package/src/daemon/materialize-workspace.ts +571 -0
- package/src/daemon/mint-session-id.ts +5 -5
- package/src/daemon/parse-hook-line.ts +31 -0
- package/src/daemon/parse-spawn-overrides.ts +94 -0
- package/src/daemon/permission-registry.ts +14 -4
- package/src/daemon/pick-session-state.ts +15 -0
- package/src/daemon/restore-fleet.ts +103 -38
- package/src/daemon/screen-model.ts +7 -0
- package/src/daemon/session-runtime.ts +10 -0
- package/src/daemon/sessions.ts +835 -105
- package/src/daemon/start-headless-turn.ts +12 -3
- package/src/daemon/start-session-bridge.ts +294 -0
- package/src/daemon/target-access.ts +36 -0
- package/src/mcp/answer-mcp-request.ts +12 -4
- package/src/mcp/answer-rpc-request.ts +28 -1
- package/src/mcp/build-principal-caller.ts +15 -0
- package/src/mcp/build-spawn-descriptions.ts +45 -0
- package/src/mcp/build-tool-list.ts +32 -4
- package/src/mcp/mcp-tools.ts +107 -10
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +39 -7
- package/src/mcp/require-daemon-features.ts +10 -0
- package/src/mcp/run-tool.ts +87 -16
- package/src/mcp/types.ts +2 -0
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +35 -0
- package/src/protocol/protocol.ts +67 -11
- package/src/protocol/request-param-schemas.ts +126 -10
- package/src/report.ts +37 -2
- package/src/run-bridge-tap.ts +241 -0
- package/src/shared/agent-session-id.ts +1 -1
- package/src/shared/collect-clean-env.ts +9 -1
- package/src/shared/collect-principals.ts +51 -0
- package/src/shared/collect-targets.ts +144 -0
- package/src/shared/config.ts +139 -14
- package/src/shared/daemon-id.ts +8 -0
- package/src/shared/format-json-kind.ts +21 -0
- package/src/shared/open-bridge-socket.ts +86 -0
- package/src/shared/send-bridge-request.ts +47 -0
- package/src/shared/sort-json-keys.ts +19 -0
- package/src/shared/to-daemon-id.ts +11 -0
- package/src/statusline.ts +34 -3
- package/src/store/fleet-entry.ts +61 -9
- package/src/store/idempotency-record.ts +48 -0
- package/src/store/message-owner.ts +1 -1
- package/src/store/run-migrations.ts +330 -5
- package/src/store/state-store.ts +607 -44
- 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/check-workspace-completeness.ts +90 -0
- package/src/workspace/create-workspace-clone.ts +251 -0
- package/src/workspace/normalize-git-url.ts +59 -0
- package/src/workspace/read-workspace-tar.ts +38 -0
- package/src/workspace/repository-env-vars.ts +22 -0
- package/src/workspace/resolve-path-source.ts +170 -0
- package/src/workspace/run-git.ts +87 -0
- package/src/workspace/sanitize-workspace-clone.ts +146 -0
- package/src/workspace/workspace-provenance.ts +11 -0
- package/src/workspace/workspace-source.ts +8 -0
- /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
package/src/daemon/sessions.ts
CHANGED
|
@@ -1,24 +1,48 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import type {
|
|
3
|
+
AdapterEvent,
|
|
4
|
+
AgentAdapter,
|
|
5
|
+
AgentID,
|
|
6
|
+
SpawnOptions,
|
|
7
|
+
SpawnOverrides,
|
|
8
|
+
SpawnPlan,
|
|
9
|
+
} from '../agents/agent-adapter';
|
|
5
10
|
import { truncateDetail } from '../agents/truncate-detail';
|
|
11
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
12
|
+
import type { ErrorCode } from '../protocol/protocol';
|
|
6
13
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
7
|
-
import {
|
|
14
|
+
import type { TargetConfigError } from '../shared/collect-targets';
|
|
8
15
|
import { socketPath, statusFile } from '../shared/config';
|
|
16
|
+
import type { DaemonID } from '../shared/daemon-id';
|
|
9
17
|
import { resolveRepoRoot } from '../shared/resolve-repo-root';
|
|
10
18
|
import type { SessionID } from '../shared/session-id';
|
|
11
19
|
import { truncateToBytes } from '../shared/truncate-to-bytes';
|
|
12
|
-
import type { FleetEntry, FleetStore } from '../store/fleet-entry';
|
|
20
|
+
import type { FleetEntry, FleetEntryUpdate, FleetStore } from '../store/fleet-entry';
|
|
21
|
+
import type { SessionWorkspace } from '../store/workspace-materialization';
|
|
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';
|
|
26
|
+
import { buildTargetIdentity } from './build-target-identity';
|
|
27
|
+
import type {
|
|
28
|
+
ExecutionCapability,
|
|
29
|
+
ExecutionProvider,
|
|
30
|
+
HarnessHandle,
|
|
31
|
+
HarnessRelay,
|
|
32
|
+
} from './execution-provider';
|
|
33
|
+
import { findExecutionRefusal } from './find-execution-refusal';
|
|
13
34
|
import type { HookEvent } from './hooks';
|
|
35
|
+
import type { BridgeBinding } from './is-binding-current';
|
|
36
|
+
import { LocalPTYProvider } from './local-pty-provider';
|
|
14
37
|
import { mintSessionID } from './mint-session-id';
|
|
38
|
+
import { pickSessionState } from './pick-session-state';
|
|
15
39
|
|
|
16
40
|
export type SessionState = 'running' | 'needs_you' | 'done' | 'exited';
|
|
17
41
|
|
|
18
42
|
export type SessionEventKind = 'added' | 'state' | 'renamed' | 'removed';
|
|
19
43
|
|
|
20
|
-
// The over-the-wire view of a session: everything but the
|
|
21
|
-
// the surface kind.
|
|
44
|
+
// The over-the-wire view of a session: everything but the harness handle,
|
|
45
|
+
// plus the surface kind.
|
|
22
46
|
export interface SessionDescriptor {
|
|
23
47
|
readonly id: SessionID;
|
|
24
48
|
readonly name: string;
|
|
@@ -44,6 +68,23 @@ export interface SessionDescriptor {
|
|
|
44
68
|
// The session this one is a sub-session of, when it has one: it lists
|
|
45
69
|
// under that session and takes its pin from it.
|
|
46
70
|
readonly parent?: SessionID;
|
|
71
|
+
|
|
72
|
+
// Where the session runs: the daemon hosting it, and the execution target
|
|
73
|
+
// on that daemon's host.
|
|
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;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface SessionLocator {
|
|
86
|
+
readonly daemonID: DaemonID;
|
|
87
|
+
readonly targetID: string;
|
|
47
88
|
}
|
|
48
89
|
|
|
49
90
|
export interface Session {
|
|
@@ -51,7 +92,10 @@ export interface Session {
|
|
|
51
92
|
name: string;
|
|
52
93
|
cwd: string;
|
|
53
94
|
kind: 'pty' | 'headless';
|
|
54
|
-
|
|
95
|
+
|
|
96
|
+
// The running harness its execution provider handed back, or null when
|
|
97
|
+
// the session has no terminal.
|
|
98
|
+
pty: HarnessHandle | null;
|
|
55
99
|
state: SessionState;
|
|
56
100
|
unread: boolean;
|
|
57
101
|
lastMsg: string;
|
|
@@ -87,6 +131,61 @@ export interface Session {
|
|
|
87
131
|
// the transcript file the agent's hooks last reported. Kept apart from the
|
|
88
132
|
// resume check so a restored path never changes whether a revive is allowed.
|
|
89
133
|
transcriptPath?: string;
|
|
134
|
+
|
|
135
|
+
// the model and effort the session was spawned with; absent leaves the
|
|
136
|
+
// agent's configured default, and every revive passes them again.
|
|
137
|
+
model?: string;
|
|
138
|
+
effort?: string;
|
|
139
|
+
|
|
140
|
+
// the execution target the session runs on, and the identity it had
|
|
141
|
+
// when the session started there; every revive runs there too, and only
|
|
142
|
+
// while the target keeps that identity.
|
|
143
|
+
target: string;
|
|
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[];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The identity of the implicit `local` target, which a fleet row without a
|
|
181
|
+
// stored identity ran on.
|
|
182
|
+
const LOCAL_TARGET_IDENTITY = buildTargetIdentity('local-pty', {});
|
|
183
|
+
|
|
184
|
+
// The target a session runs on and the identity it is bound to there; null
|
|
185
|
+
// binds a new session to the target as it stands now.
|
|
186
|
+
interface TargetBinding {
|
|
187
|
+
readonly target: string;
|
|
188
|
+
readonly targetIdentity: string | null;
|
|
90
189
|
}
|
|
91
190
|
|
|
92
191
|
export class SessionManager {
|
|
@@ -98,8 +197,22 @@ export class SessionManager {
|
|
|
98
197
|
|
|
99
198
|
onChange: () => void = () => {};
|
|
100
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
|
+
|
|
101
208
|
onEvent: (kind: SessionEventKind, s: Session) => void = () => {};
|
|
102
209
|
|
|
210
|
+
// Where a background failure is reported, one line at a time: stderr,
|
|
211
|
+
// like the daemon's other background failures.
|
|
212
|
+
log: (line: string) => void = (line) => {
|
|
213
|
+
console.error(line);
|
|
214
|
+
};
|
|
215
|
+
|
|
103
216
|
// Whether any registered adapter has a screen detector, decided once at
|
|
104
217
|
// construction since the registry never changes afterward. Lets a hot path
|
|
105
218
|
// that only cares about this answer skip walking live sessions to find it.
|
|
@@ -111,11 +224,33 @@ export class SessionManager {
|
|
|
111
224
|
|
|
112
225
|
private readonly statusPath: string;
|
|
113
226
|
|
|
227
|
+
private readonly targets: ReadonlyMap<string, ExecutionTarget>;
|
|
228
|
+
|
|
229
|
+
private readonly targetErrors: readonly TargetConfigError[];
|
|
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
|
+
|
|
114
239
|
constructor(
|
|
115
240
|
fallback: AgentAdapter,
|
|
116
241
|
store: FleetStore,
|
|
117
242
|
statusPath: string | undefined = statusFile,
|
|
118
243
|
adapters: readonly AgentAdapter[] = [],
|
|
244
|
+
targets: readonly ExecutionTarget[] = [
|
|
245
|
+
{
|
|
246
|
+
id: 'local',
|
|
247
|
+
kind: 'local-pty',
|
|
248
|
+
options: {},
|
|
249
|
+
identity: LOCAL_TARGET_IDENTITY,
|
|
250
|
+
provider: new LocalPTYProvider(),
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
targetErrors: readonly TargetConfigError[] = [],
|
|
119
254
|
) {
|
|
120
255
|
// Each adapter names the id it answers to, so a registry key can never
|
|
121
256
|
// disagree with the adapter behind it. A later one wins the id.
|
|
@@ -123,6 +258,51 @@ export class SessionManager {
|
|
|
123
258
|
this.hasScreenDetector = Object.values(this.adapters).some((a) => a.screenDetector !== null);
|
|
124
259
|
this.store = store;
|
|
125
260
|
this.statusPath = statusPath ?? statusFile;
|
|
261
|
+
|
|
262
|
+
this.targets = new Map(targets.map((t) => [t.id, t]));
|
|
263
|
+
|
|
264
|
+
this.targetErrors = targetErrors;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The provider a binding's work runs on, after every target check: throws
|
|
269
|
+
* the refusal for a target the config leaves unusable, one that is gone,
|
|
270
|
+
* one whose identity is not the binding's, one with no provider here, and
|
|
271
|
+
* a provider that lacks the capability. Returns the identity a new session
|
|
272
|
+
* binds to as well.
|
|
273
|
+
*/
|
|
274
|
+
requireExecution(
|
|
275
|
+
binding: Readonly<TargetBinding>,
|
|
276
|
+
capability: ExecutionCapability,
|
|
277
|
+
): { readonly provider: ExecutionProvider; readonly identity: string } {
|
|
278
|
+
const refusal = this.findExecutionRefusal(binding, capability);
|
|
279
|
+
const target = this.targets.get(binding.target);
|
|
280
|
+
|
|
281
|
+
if (refusal !== null) {
|
|
282
|
+
throw refusal;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (target === undefined || target.provider === null) {
|
|
286
|
+
throw new Error(`execution target '${binding.target}' passed its checks without a provider`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { provider: target.provider, identity: target.identity };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The refusal for running work of a capability on a binding's target, or
|
|
293
|
+
// null when the target serves it. A null target is a spawn that names
|
|
294
|
+
// none when the config gives no default, which is always refused.
|
|
295
|
+
findExecutionRefusal(
|
|
296
|
+
binding: Readonly<{ target: string | null; targetIdentity: string | null }>,
|
|
297
|
+
capability: ExecutionCapability,
|
|
298
|
+
): DaemonError | null {
|
|
299
|
+
return findExecutionRefusal(this.targets, this.targetErrors, binding, capability);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// The provider a session's harness runs on, or null when its target is
|
|
303
|
+
// gone from the config or has no provider here.
|
|
304
|
+
findProvider(s: Session): ExecutionProvider | null {
|
|
305
|
+
return this.targets.get(s.target)?.provider ?? null;
|
|
126
306
|
}
|
|
127
307
|
|
|
128
308
|
/**
|
|
@@ -162,26 +342,62 @@ export class SessionManager {
|
|
|
162
342
|
return s;
|
|
163
343
|
}
|
|
164
344
|
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
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
|
+
|
|
363
|
+
// Registers a fleet entry as a session with no terminal yet, under the
|
|
364
|
+
// atc session id its row holds, so a fleet-wide restore can show every
|
|
365
|
+
// incoming session at once; adopting it later attaches the terminal.
|
|
366
|
+
// Exited entries come back as killed sessions: still listed and revivable,
|
|
367
|
+
// never auto-adopted. An entry without an agent session id has nothing to
|
|
368
|
+
// resume, so it comes back exited too.
|
|
169
369
|
restore(entry: FleetEntry): Session {
|
|
170
|
-
const
|
|
370
|
+
const target = entry.target ?? 'local';
|
|
371
|
+
const targetIdentity = entry.targetIdentity ?? LOCAL_TARGET_IDENTITY;
|
|
372
|
+
const refusal = this.findExecutionRefusal({ target, targetIdentity }, 'spawn');
|
|
373
|
+
const targetRefusal = refusal === null ? null : formatTargetRefusal(refusal.code, target);
|
|
374
|
+
|
|
375
|
+
// A session whose target this daemon cannot use comes back exited, so
|
|
376
|
+
// nothing runs it anywhere else: neither a terminal nor a headless turn.
|
|
377
|
+
const exited =
|
|
378
|
+
entry.exited === true || entry.agentSessionID === undefined || targetRefusal !== null;
|
|
171
379
|
|
|
172
380
|
// An entry whose agent is no longer registered still gets its row, so a
|
|
173
381
|
// backend dropped from the config shows as itself instead of vanishing or
|
|
174
382
|
// reviving under another agent. Adopting a terminal for it is refused.
|
|
175
383
|
let lastMsg = 'waiting to restore';
|
|
176
384
|
|
|
177
|
-
if (exited) {
|
|
385
|
+
if (exited && entry.desired === 'sleep') {
|
|
386
|
+
lastMsg = 'asleep';
|
|
387
|
+
} else if (entry.exited !== true && entry.agentSessionID === undefined) {
|
|
388
|
+
lastMsg = 'nothing to resume';
|
|
389
|
+
} else if (entry.exited !== true && targetRefusal !== null) {
|
|
390
|
+
lastMsg = targetRefusal;
|
|
391
|
+
} else if (exited) {
|
|
178
392
|
lastMsg = 'killed';
|
|
179
393
|
} else if (this.findAdapter(entry.agent) === null) {
|
|
180
394
|
lastMsg = `no adapter for '${entry.agent}'`;
|
|
181
395
|
}
|
|
182
396
|
|
|
397
|
+
const parent = entry.parent ?? null;
|
|
398
|
+
|
|
183
399
|
const session: Session = {
|
|
184
|
-
id:
|
|
400
|
+
id: entry.sessionID,
|
|
185
401
|
name: entry.name,
|
|
186
402
|
cwd: entry.cwd,
|
|
187
403
|
kind: 'headless',
|
|
@@ -189,17 +405,29 @@ export class SessionManager {
|
|
|
189
405
|
state: exited ? 'exited' : 'running',
|
|
190
406
|
unread: false,
|
|
191
407
|
lastMsg,
|
|
192
|
-
agentSessionID: entry.agentSessionID,
|
|
408
|
+
...(entry.agentSessionID === undefined ? {} : { agentSessionID: entry.agentSessionID }),
|
|
193
409
|
agent: entry.agent,
|
|
194
410
|
pinned: entry.pinned ?? false,
|
|
195
411
|
lastAttachedAt: entry.lastAttachedAt ?? Date.now(),
|
|
196
412
|
repoRoot: resolveRepoRoot(entry.cwd),
|
|
197
413
|
namedBy: 'auto',
|
|
198
414
|
createdAt: Date.now(),
|
|
199
|
-
parent: this.
|
|
415
|
+
parent: parent !== null && this.sessions.some((s) => s.id === parent) ? parent : null,
|
|
200
416
|
...(entry.prompt === undefined ? {} : { prompt: entry.prompt }),
|
|
201
417
|
...(entry.result === undefined ? {} : { result: entry.result }),
|
|
202
418
|
...(entry.transcriptPath === undefined ? {} : { transcriptPath: entry.transcriptPath }),
|
|
419
|
+
...(entry.model === undefined ? {} : { model: entry.model }),
|
|
420
|
+
...(entry.effort === undefined ? {} : { effort: entry.effort }),
|
|
421
|
+
target,
|
|
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,
|
|
203
431
|
};
|
|
204
432
|
|
|
205
433
|
this.sessions.push(session);
|
|
@@ -209,20 +437,37 @@ export class SessionManager {
|
|
|
209
437
|
return session;
|
|
210
438
|
}
|
|
211
439
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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';
|
|
215
458
|
}
|
|
216
459
|
|
|
217
|
-
return
|
|
460
|
+
return desired === 'sleep' ? 'asleep' : 'unknown';
|
|
218
461
|
}
|
|
219
462
|
|
|
220
463
|
// Adopts a headless session back into a terminal: a fresh PTY resumes the
|
|
221
|
-
// same agent session id.
|
|
222
|
-
|
|
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> {
|
|
223
468
|
const s = this.sessions.find((x) => x.id === id);
|
|
224
469
|
|
|
225
|
-
if (!s || s.pty !== null || s.agentSessionID === undefined) {
|
|
470
|
+
if (!s || s.pty !== null || s.agentSessionID === undefined || this.adopting.has(id)) {
|
|
226
471
|
return null;
|
|
227
472
|
}
|
|
228
473
|
|
|
@@ -232,43 +477,117 @@ export class SessionManager {
|
|
|
232
477
|
return null;
|
|
233
478
|
}
|
|
234
479
|
|
|
235
|
-
const
|
|
480
|
+
const provider = this.requireExecution(s, 'spawn').provider;
|
|
481
|
+
|
|
482
|
+
this.adopting.add(id);
|
|
236
483
|
|
|
237
|
-
|
|
238
|
-
|
|
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);
|
|
503
|
+
|
|
504
|
+
const pty = provider.spawnHarness({
|
|
505
|
+
session: s.id,
|
|
506
|
+
host: s.hostKey,
|
|
507
|
+
bin: plan.bin,
|
|
508
|
+
args: plan.args,
|
|
509
|
+
cwd: s.cwd,
|
|
510
|
+
env: { ATC_SESSION_ID: s.id, ATC_SOCKET: socketPath },
|
|
511
|
+
withheldEnv: s.withheldEnv,
|
|
239
512
|
cols,
|
|
240
513
|
rows,
|
|
241
|
-
|
|
242
|
-
|
|
514
|
+
onRelay: (relay) => {
|
|
515
|
+
this.onRelay(binding, relay);
|
|
516
|
+
},
|
|
243
517
|
});
|
|
244
518
|
|
|
519
|
+
s.bridgeEpoch = binding.epoch;
|
|
245
520
|
s.pty = pty;
|
|
246
521
|
s.kind = 'pty';
|
|
247
522
|
s.state = 'running';
|
|
248
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';
|
|
249
550
|
|
|
250
551
|
pty.onData((d) => {
|
|
251
552
|
this.onOutput(s, d);
|
|
252
553
|
});
|
|
253
554
|
|
|
254
|
-
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
|
+
|
|
255
571
|
s.pty = null;
|
|
256
572
|
|
|
573
|
+
if (hostLifecycle) {
|
|
574
|
+
s.attachment = 'detached';
|
|
575
|
+
}
|
|
576
|
+
|
|
257
577
|
if (s.kind === 'pty' && s.state !== 'exited') {
|
|
258
578
|
s.state = 'exited';
|
|
259
579
|
s.unread = this.focusedId !== s.id;
|
|
260
|
-
s.lastMsg =
|
|
580
|
+
s.lastMsg = pickExitMessage(exit);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (exit.reason === 'suspended') {
|
|
584
|
+
s.vm = 'asleep';
|
|
585
|
+
s.suspended = true;
|
|
261
586
|
}
|
|
262
587
|
|
|
263
588
|
this.onEvent('state', s);
|
|
264
589
|
this.emitChange();
|
|
265
590
|
});
|
|
266
|
-
|
|
267
|
-
void this.writeFleet();
|
|
268
|
-
this.onEvent('state', s);
|
|
269
|
-
this.emitChange();
|
|
270
|
-
|
|
271
|
-
return s;
|
|
272
591
|
}
|
|
273
592
|
|
|
274
593
|
/**
|
|
@@ -301,7 +620,7 @@ export class SessionManager {
|
|
|
301
620
|
this.onEvent('state', s);
|
|
302
621
|
}
|
|
303
622
|
|
|
304
|
-
void this.
|
|
623
|
+
void this.tryWriteFleet(s.id);
|
|
305
624
|
this.writeStatus();
|
|
306
625
|
this.emitChange();
|
|
307
626
|
|
|
@@ -324,10 +643,7 @@ export class SessionManager {
|
|
|
324
643
|
if (result !== undefined) {
|
|
325
644
|
s.result = truncateToBytes(result, 16_384);
|
|
326
645
|
s.lastDetail = truncateDetail(result);
|
|
327
|
-
|
|
328
|
-
if (s.agentSessionID !== undefined) {
|
|
329
|
-
void this.store.updateFleetEntry(s.agentSessionID, { result: s.result });
|
|
330
|
-
}
|
|
646
|
+
void this.tryUpdateFleetEntry(s.id, { result: s.result });
|
|
331
647
|
}
|
|
332
648
|
|
|
333
649
|
this.onEvent('state', s);
|
|
@@ -364,8 +680,14 @@ export class SessionManager {
|
|
|
364
680
|
|
|
365
681
|
// resume: true opens the agent's own session picker; an agent session id
|
|
366
682
|
// resumes that specific session (fleet restore). parent makes the new
|
|
367
|
-
// session a sub-session of that one.
|
|
368
|
-
|
|
683
|
+
// session a sub-session of that one. overrides hold the model and effort
|
|
684
|
+
// the new process runs with, and the session keeps them for every revive.
|
|
685
|
+
// id is minted here unless the caller minted it ahead of the spawn. target
|
|
686
|
+
// is the execution target the harness runs on; one this daemon cannot use
|
|
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(
|
|
369
691
|
cwd: string,
|
|
370
692
|
name: string,
|
|
371
693
|
prompt: string,
|
|
@@ -375,22 +697,47 @@ export class SessionManager {
|
|
|
375
697
|
namedBy: 'user' | 'auto' = 'auto',
|
|
376
698
|
agent: AgentID = 'claude',
|
|
377
699
|
parent: SessionID | null = null,
|
|
378
|
-
|
|
700
|
+
overrides: SpawnOverrides = {},
|
|
701
|
+
id: SessionID = mintSessionID(),
|
|
702
|
+
target = 'local',
|
|
703
|
+
materialized: MaterializedSpawn | null = null,
|
|
704
|
+
): Promise<Session> {
|
|
379
705
|
const adapter = this.findAdapter(agent);
|
|
380
706
|
|
|
381
707
|
if (adapter === null) {
|
|
382
708
|
throw new Error(`no adapter for agent '${agent}'`);
|
|
383
709
|
}
|
|
384
710
|
|
|
385
|
-
const
|
|
386
|
-
const
|
|
711
|
+
const execution = this.requireExecution({ target, targetIdentity: null }, 'spawn');
|
|
712
|
+
const provider = execution.provider;
|
|
713
|
+
|
|
714
|
+
// The repository root resolves before the process starts: resolving it
|
|
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);
|
|
387
727
|
|
|
388
|
-
const pty =
|
|
389
|
-
|
|
728
|
+
const pty = provider.spawnHarness({
|
|
729
|
+
session: id,
|
|
730
|
+
host: hostKey,
|
|
731
|
+
bin: plan.bin,
|
|
732
|
+
args: plan.args,
|
|
733
|
+
cwd,
|
|
734
|
+
env: { ATC_SESSION_ID: id, ATC_SOCKET: socketPath },
|
|
735
|
+
withheldEnv: materialized?.withheldEnv ?? [],
|
|
390
736
|
cols,
|
|
391
737
|
rows,
|
|
392
|
-
|
|
393
|
-
|
|
738
|
+
onRelay: (relay) => {
|
|
739
|
+
this.onRelay(binding, relay);
|
|
740
|
+
},
|
|
394
741
|
});
|
|
395
742
|
|
|
396
743
|
let initialMsg = prompt;
|
|
@@ -412,38 +759,153 @@ export class SessionManager {
|
|
|
412
759
|
agent,
|
|
413
760
|
pinned: false,
|
|
414
761
|
lastAttachedAt: Date.now(),
|
|
415
|
-
repoRoot
|
|
762
|
+
repoRoot,
|
|
416
763
|
namedBy,
|
|
417
764
|
createdAt: Date.now(),
|
|
418
765
|
parent,
|
|
419
766
|
...(prompt === '' ? {} : { prompt }),
|
|
767
|
+
...(overrides.model === undefined ? {} : { model: overrides.model }),
|
|
768
|
+
...(overrides.effort === undefined ? {} : { effort: overrides.effort }),
|
|
769
|
+
target,
|
|
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,
|
|
420
779
|
};
|
|
421
780
|
|
|
422
|
-
pty.
|
|
423
|
-
|
|
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,
|
|
424
840
|
});
|
|
425
841
|
|
|
426
|
-
|
|
427
|
-
session.pty = null;
|
|
842
|
+
const check = adapter.planAuthCheck?.();
|
|
428
843
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
+
);
|
|
435
853
|
}
|
|
854
|
+
}
|
|
436
855
|
|
|
437
|
-
|
|
438
|
-
this.emitChange();
|
|
439
|
-
});
|
|
856
|
+
const files = Object.entries(plan.files).map(([path, content]) => ({ path, content }));
|
|
440
857
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
this.onEvent('added', session);
|
|
858
|
+
if (files.length > 0) {
|
|
859
|
+
await provider.transferArchive(buildTarArchive(files), dir, hostKey);
|
|
860
|
+
}
|
|
445
861
|
|
|
446
|
-
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;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Takes back a spawn that failed after its process started: the process
|
|
891
|
+
// dies and the session leaves the list and the fleet, so the spawn leaves
|
|
892
|
+
// nothing behind. It resolves only once the fleet without the session is
|
|
893
|
+
// durable, and throws when the kill or that write fails. A session that
|
|
894
|
+
// never registered is left alone.
|
|
895
|
+
async removeFailedSpawn(id: SessionID): Promise<void> {
|
|
896
|
+
const s = this.sessions.find((x) => x.id === id);
|
|
897
|
+
|
|
898
|
+
if (s === undefined) {
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
s.pty?.kill();
|
|
903
|
+
s.pty = null;
|
|
904
|
+
|
|
905
|
+
this.remove(s);
|
|
906
|
+
this.emitChange();
|
|
907
|
+
|
|
908
|
+
await this.writeFleet();
|
|
447
909
|
}
|
|
448
910
|
|
|
449
911
|
collectDescriptors(): SessionDescriptor[] {
|
|
@@ -451,7 +913,7 @@ export class SessionManager {
|
|
|
451
913
|
id: s.id,
|
|
452
914
|
name: s.name,
|
|
453
915
|
cwd: s.cwd,
|
|
454
|
-
state: s.state,
|
|
916
|
+
state: pickSessionState(buildLifecycle(s), s.state),
|
|
455
917
|
unread: s.unread,
|
|
456
918
|
lastMsg: s.lastMsg,
|
|
457
919
|
...(s.lastDetail === undefined ? {} : { lastDetail: s.lastDetail }),
|
|
@@ -466,6 +928,9 @@ export class SessionManager {
|
|
|
466
928
|
alive: s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'),
|
|
467
929
|
canEject: (this.findAdapter(s.agent)?.headlessRunner ?? null) !== null,
|
|
468
930
|
...(s.parent === null ? {} : { parent: s.parent }),
|
|
931
|
+
locator: { daemonID: this.store.daemonID, targetID: s.target },
|
|
932
|
+
...(s.workspace === undefined ? {} : { workspace: s.workspace }),
|
|
933
|
+
lifecycle: buildLifecycle(s),
|
|
469
934
|
}));
|
|
470
935
|
}
|
|
471
936
|
|
|
@@ -584,12 +1049,12 @@ export class SessionManager {
|
|
|
584
1049
|
}
|
|
585
1050
|
|
|
586
1051
|
// A fleet rewrite marks every session without a PTY as exited, so only a
|
|
587
|
-
// new agent session id
|
|
588
|
-
//
|
|
1052
|
+
// new agent session id takes it. Anything else touches this session's
|
|
1053
|
+
// own row.
|
|
589
1054
|
if (persist) {
|
|
590
|
-
void this.
|
|
591
|
-
} else
|
|
592
|
-
void this.
|
|
1055
|
+
void this.tryWriteFleet(s.id);
|
|
1056
|
+
} else {
|
|
1057
|
+
void this.tryUpdateFleetEntry(s.id, rowUpdate);
|
|
593
1058
|
}
|
|
594
1059
|
|
|
595
1060
|
if (dirty) {
|
|
@@ -619,7 +1084,7 @@ export class SessionManager {
|
|
|
619
1084
|
s.namedBy = update.namedBy;
|
|
620
1085
|
}
|
|
621
1086
|
|
|
622
|
-
void this.
|
|
1087
|
+
void this.tryWriteFleet(s.id);
|
|
623
1088
|
this.onEvent('renamed', s);
|
|
624
1089
|
this.emitChange();
|
|
625
1090
|
}
|
|
@@ -644,7 +1109,7 @@ export class SessionManager {
|
|
|
644
1109
|
|
|
645
1110
|
s.unread = false;
|
|
646
1111
|
s.lastAttachedAt = Date.now();
|
|
647
|
-
void this.
|
|
1112
|
+
void this.tryWriteFleet(s.id);
|
|
648
1113
|
|
|
649
1114
|
// Attaching answers the attention request: a still-pending prompt
|
|
650
1115
|
// re-flags it via the next notification.
|
|
@@ -673,7 +1138,12 @@ export class SessionManager {
|
|
|
673
1138
|
// archived, so the write it depends on must land before that response
|
|
674
1139
|
// goes out. A kill acts on the whole set: killing a session kills its
|
|
675
1140
|
// live sub-sessions with it, and forgetting a dead one forgets its dead
|
|
676
|
-
// 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.
|
|
677
1147
|
async kill(id: SessionID): Promise<void> {
|
|
678
1148
|
const s = this.sessions.find((x) => x.id === id);
|
|
679
1149
|
|
|
@@ -682,28 +1152,181 @@ export class SessionManager {
|
|
|
682
1152
|
}
|
|
683
1153
|
|
|
684
1154
|
if (s.pty) {
|
|
1155
|
+
await this.stopHarness(s);
|
|
1156
|
+
|
|
685
1157
|
for (const child of this.collectChildren(id)) {
|
|
686
|
-
this.
|
|
1158
|
+
await this.tryStopHarness(child);
|
|
687
1159
|
}
|
|
688
|
-
|
|
689
|
-
this.killTerminal(s);
|
|
690
1160
|
} else {
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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();
|
|
696
1174
|
|
|
697
|
-
|
|
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);
|
|
1190
|
+
|
|
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);
|
|
698
1219
|
}
|
|
699
1220
|
}
|
|
700
1221
|
|
|
701
|
-
|
|
1222
|
+
s.pty?.detach();
|
|
1223
|
+
s.pty = null;
|
|
1224
|
+
} else {
|
|
1225
|
+
this.killTerminal(s);
|
|
702
1226
|
}
|
|
703
1227
|
|
|
1228
|
+
this.updateForgottenChildren(id);
|
|
1229
|
+
this.remove(s);
|
|
1230
|
+
|
|
704
1231
|
await this.writeFleet();
|
|
705
1232
|
|
|
706
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);
|
|
707
1330
|
}
|
|
708
1331
|
|
|
709
1332
|
// Ends a live session, terminal or headless, leaving a dead entry; a
|
|
@@ -719,6 +1342,7 @@ export class SessionManager {
|
|
|
719
1342
|
|
|
720
1343
|
s.state = 'exited';
|
|
721
1344
|
s.lastMsg = 'killed';
|
|
1345
|
+
s.desired = 'stop';
|
|
722
1346
|
|
|
723
1347
|
this.onEvent('state', s);
|
|
724
1348
|
}
|
|
@@ -733,9 +1357,12 @@ export class SessionManager {
|
|
|
733
1357
|
this.onEvent('removed', s);
|
|
734
1358
|
}
|
|
735
1359
|
|
|
736
|
-
|
|
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() {
|
|
737
1364
|
for (const s of this.sessions) {
|
|
738
|
-
s.pty?.
|
|
1365
|
+
s.pty?.detach();
|
|
739
1366
|
}
|
|
740
1367
|
}
|
|
741
1368
|
|
|
@@ -759,42 +1386,78 @@ export class SessionManager {
|
|
|
759
1386
|
// Deliberate kills rewrite the file; unexpected session/atc deaths do not,
|
|
760
1387
|
// so the last known fleet survives for `R` restore. Sessions whose
|
|
761
1388
|
// terminal is gone persist as exited entries, so the killed archive
|
|
762
|
-
// survives a daemon restart too.
|
|
1389
|
+
// survives a daemon restart too. A session the agent has not yet given
|
|
1390
|
+
// its own session id persists as well, under its atc session id alone.
|
|
763
1391
|
async writeFleet(): Promise<void> {
|
|
764
1392
|
const fleet: FleetEntry[] = [];
|
|
765
1393
|
|
|
766
1394
|
for (const s of this.sessions) {
|
|
767
|
-
if (s.agentSessionID === undefined) {
|
|
768
|
-
continue;
|
|
769
|
-
}
|
|
770
|
-
|
|
771
1395
|
const live = s.pty !== null || (s.kind === 'headless' && s.state !== 'exited');
|
|
772
1396
|
|
|
773
|
-
// The link persists by the parent's agent session id, since atc ids
|
|
774
|
-
// are minted afresh on restore.
|
|
775
|
-
const parent =
|
|
776
|
-
s.parent === null
|
|
777
|
-
? undefined
|
|
778
|
-
: this.sessions.find((x) => x.id === s.parent)?.agentSessionID;
|
|
779
|
-
|
|
780
1397
|
fleet.push({
|
|
1398
|
+
sessionID: s.id,
|
|
781
1399
|
name: s.name,
|
|
782
1400
|
cwd: s.cwd,
|
|
783
|
-
agentSessionID: s.agentSessionID,
|
|
1401
|
+
...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
|
|
784
1402
|
agent: s.agent,
|
|
785
1403
|
...(s.pinned ? { pinned: true } : {}),
|
|
786
1404
|
lastAttachedAt: s.lastAttachedAt,
|
|
787
1405
|
...(live ? {} : { exited: true }),
|
|
788
|
-
...(parent ===
|
|
1406
|
+
...(s.parent === null ? {} : { parent: s.parent }),
|
|
789
1407
|
...(s.prompt === undefined ? {} : { prompt: s.prompt }),
|
|
790
1408
|
...(s.result === undefined ? {} : { result: s.result }),
|
|
791
1409
|
...(s.transcriptPath === undefined ? {} : { transcriptPath: s.transcriptPath }),
|
|
1410
|
+
...(s.model === undefined ? {} : { model: s.model }),
|
|
1411
|
+
...(s.effort === undefined ? {} : { effort: s.effort }),
|
|
1412
|
+
target: s.target,
|
|
1413
|
+
targetIdentity: s.targetIdentity,
|
|
1414
|
+
...(s.desired === 'run' ? {} : { desired: s.desired }),
|
|
1415
|
+
...(s.hostKey === s.id ? {} : { hostKey: s.hostKey }),
|
|
792
1416
|
});
|
|
793
1417
|
}
|
|
794
1418
|
|
|
795
1419
|
await this.store.writeFleet(fleet);
|
|
796
1420
|
}
|
|
797
1421
|
|
|
1422
|
+
// A write nobody awaits that fails leaves the stored rows as they were,
|
|
1423
|
+
// and must not take the daemon down unhandled. A stale ownership epoch is
|
|
1424
|
+
// expected and surfaces on the next write a request awaits; any other
|
|
1425
|
+
// failure is logged with the session whose change it was writing.
|
|
1426
|
+
private async tryWriteFleet(sessionID: SessionID): Promise<boolean> {
|
|
1427
|
+
try {
|
|
1428
|
+
await this.writeFleet();
|
|
1429
|
+
} catch (error) {
|
|
1430
|
+
this.logStoreFailure(sessionID, error);
|
|
1431
|
+
|
|
1432
|
+
return false;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
return true;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
private async tryUpdateFleetEntry(id: SessionID, fields: FleetEntryUpdate): Promise<boolean> {
|
|
1439
|
+
try {
|
|
1440
|
+
await this.store.updateFleetEntry(id, fields);
|
|
1441
|
+
} catch (error) {
|
|
1442
|
+
this.logStoreFailure(id, error);
|
|
1443
|
+
|
|
1444
|
+
return false;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
return true;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
private logStoreFailure(sessionID: SessionID, error: unknown): void {
|
|
1451
|
+
if (error instanceof DaemonError && error.code === 'stale_epoch') {
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
const code = error instanceof DaemonError ? error.code : 'internal';
|
|
1456
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1457
|
+
|
|
1458
|
+
this.log(`atc fleet write for session ${sessionID} failed (${code}): ${message}`);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
798
1461
|
countStates() {
|
|
799
1462
|
return countSessionStates(this.sessions);
|
|
800
1463
|
}
|
|
@@ -892,3 +1555,70 @@ export function sortGroupedSessionViews<
|
|
|
892
1555
|
|
|
893
1556
|
return [...buckets.values()].flat();
|
|
894
1557
|
}
|
|
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
|
+
|
|
1605
|
+
// A target refusal as a session's last message, short enough for a list row.
|
|
1606
|
+
function formatTargetRefusal(code: ErrorCode, target: string): string {
|
|
1607
|
+
if (code === 'target_config_invalid') {
|
|
1608
|
+
return `target '${target}' misconfigured`;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
if (code === 'unknown_target') {
|
|
1612
|
+
return `no target '${target}'`;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
if (code === 'target_changed') {
|
|
1616
|
+
return `target '${target}' changed`;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
if (code === 'target_unavailable') {
|
|
1620
|
+
return `target '${target}' unavailable`;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
return `target '${target}' cannot start a terminal`;
|
|
1624
|
+
}
|