@zgeoff/atc 2.12.0 → 2.14.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 +12 -11
- 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/boot-daemon.ts +47 -21
- package/src/client/format-protocol-mismatch.ts +37 -0
- package/src/client/index.ts +104 -2
- 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 +6 -2
- package/src/daemon/build-report-view.ts +41 -0
- package/src/daemon/build-scoped-context.ts +9 -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 +134 -4
- package/src/daemon/daemon.ts +306 -42
- 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 +574 -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 +41 -3
- package/src/mcp/reconnecting-caller.ts +1 -0
- package/src/mcp/require-daemon-features.ts +4 -0
- package/src/mcp/run-tool.ts +32 -8
- package/src/protocol/daemon-features.ts +14 -0
- package/src/protocol/protocol.ts +23 -0
- package/src/protocol/request-param-schemas.ts +72 -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 +85 -0
- package/src/store/state-store.ts +226 -21
- package/src/store/trail-entry.ts +7 -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
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import { isRecord } from '../shared/report';
|
|
2
|
+
import type { HarnessAttachment, HarnessExit, HarnessHandle } from './execution-provider';
|
|
3
|
+
import type {
|
|
4
|
+
ImpPort,
|
|
5
|
+
ImpSessionConnection,
|
|
6
|
+
ImpSessionOutcome,
|
|
7
|
+
ImpSessionRequest,
|
|
8
|
+
ImpSessionStarted,
|
|
9
|
+
PreviousGeneration,
|
|
10
|
+
} from './imp-port';
|
|
11
|
+
|
|
12
|
+
// What the harness asks of the provider that started it.
|
|
13
|
+
interface ImpHarnessHost {
|
|
14
|
+
// Whether the provider is putting the harness's host to sleep, so a
|
|
15
|
+
// connection the sleep ends is the sleep's and not the harness's end.
|
|
16
|
+
readonly isSuspending: () => boolean;
|
|
17
|
+
|
|
18
|
+
// Called once, when the harness ends or the daemon lets go of it.
|
|
19
|
+
readonly onDone: () => void;
|
|
20
|
+
|
|
21
|
+
// Whether impd carries output offsets, so a reconnect can resume.
|
|
22
|
+
readonly offsets: boolean;
|
|
23
|
+
|
|
24
|
+
// The wait before each reconnect after a connection ends without an
|
|
25
|
+
// exit; the harness ends once they run out.
|
|
26
|
+
readonly reconnectDelaysMs: readonly number[];
|
|
27
|
+
|
|
28
|
+
// Settles once the harness may start, such as when its report socket
|
|
29
|
+
// listens; a rejection ends the harness before it starts.
|
|
30
|
+
readonly ready?: Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The bytes a fresh attach sends listeners ahead of its replay: reset the
|
|
34
|
+
// attributes, home the cursor, and clear the screen. A full terminal reset
|
|
35
|
+
// would also undo the modes of every attached client's own terminal.
|
|
36
|
+
const SCREEN_RESET = '\u001B[0m\u001B[H\u001B[2J';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A harness running in an imp session, as the daemon's one connection to
|
|
40
|
+
* it. Input written before the session starts waits for it. A kill sends
|
|
41
|
+
* the process SIGHUP, as closing a terminal does; a detach closes the
|
|
42
|
+
* connection and leaves the process running under its session name.
|
|
43
|
+
*
|
|
44
|
+
* A connection that ends without an exit reconnects without waking the
|
|
45
|
+
* imp. Where impd carries offsets, a reconnect resumes after the last byte
|
|
46
|
+
* the daemon has and drops any byte below it; a gap, or an imp without
|
|
47
|
+
* offsets, gets a fresh attach that clears the screen before its replay.
|
|
48
|
+
*/
|
|
49
|
+
export class ImpHarness implements HarnessHandle {
|
|
50
|
+
private readonly port: ImpPort;
|
|
51
|
+
|
|
52
|
+
private readonly host: ImpHarnessHost;
|
|
53
|
+
|
|
54
|
+
private readonly name: string;
|
|
55
|
+
|
|
56
|
+
private readonly session: string;
|
|
57
|
+
|
|
58
|
+
private readonly dataListeners = new Set<(data: string) => void>();
|
|
59
|
+
|
|
60
|
+
private readonly exitListeners = new Set<(exit: HarnessExit) => void>();
|
|
61
|
+
|
|
62
|
+
private readonly attachmentListeners = new Set<(attachment: HarnessAttachment) => void>();
|
|
63
|
+
|
|
64
|
+
private decoder = new TextDecoder();
|
|
65
|
+
|
|
66
|
+
private connection: ImpSessionConnection | null = null;
|
|
67
|
+
|
|
68
|
+
private started = false;
|
|
69
|
+
|
|
70
|
+
private done = false;
|
|
71
|
+
|
|
72
|
+
private pending: Uint8Array[] = [];
|
|
73
|
+
|
|
74
|
+
private cols: number;
|
|
75
|
+
|
|
76
|
+
private rows: number;
|
|
77
|
+
|
|
78
|
+
// The boot and generation the daemon's output came from, while impd
|
|
79
|
+
// carries offsets for the session.
|
|
80
|
+
private cursor: { readonly bootId: string; readonly generation: string } | null = null;
|
|
81
|
+
|
|
82
|
+
// The offset after the last byte the daemon has, the offset the current
|
|
83
|
+
// connection's next byte sits at, and the mode bytes still owed before
|
|
84
|
+
// it, which have no offset.
|
|
85
|
+
private highWater = 0;
|
|
86
|
+
|
|
87
|
+
private nextOffset = 0;
|
|
88
|
+
|
|
89
|
+
private preludeLeft = 0;
|
|
90
|
+
|
|
91
|
+
// Whether the next connection replaces the screen rather than resuming.
|
|
92
|
+
private fresh = false;
|
|
93
|
+
|
|
94
|
+
private failures = 0;
|
|
95
|
+
|
|
96
|
+
// Connections in a row that a handler of ours ended. A start never clears
|
|
97
|
+
// it, since the next start can fail the same way.
|
|
98
|
+
private localErrors = 0;
|
|
99
|
+
|
|
100
|
+
private killPending = false;
|
|
101
|
+
|
|
102
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
103
|
+
|
|
104
|
+
// The terminal size the open connection's request asked for.
|
|
105
|
+
private requestedSize: { cols: number; rows: number } = { cols: 0, rows: 0 };
|
|
106
|
+
|
|
107
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- the host holds a live promise
|
|
108
|
+
constructor(port: ImpPort, start: ImpSessionRequest, host: ImpHarnessHost) {
|
|
109
|
+
this.port = port;
|
|
110
|
+
this.host = host;
|
|
111
|
+
this.name = start.name;
|
|
112
|
+
this.session = start.session;
|
|
113
|
+
this.cols = start.cols;
|
|
114
|
+
this.rows = start.rows;
|
|
115
|
+
|
|
116
|
+
if (host.ready === undefined) {
|
|
117
|
+
this.openConnection(start);
|
|
118
|
+
} else {
|
|
119
|
+
void this.startWhenReady(host.ready, start);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
readonly onData = (listener: (data: string) => void) => {
|
|
124
|
+
this.dataListeners.add(listener);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
dispose: () => {
|
|
128
|
+
this.dataListeners.delete(listener);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
readonly onExit = (listener: (exit: HarnessExit) => void) => {
|
|
134
|
+
this.exitListeners.add(listener);
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
dispose: () => {
|
|
138
|
+
this.exitListeners.delete(listener);
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
readonly onAttachment = (listener: (attachment: HarnessAttachment) => void) => {
|
|
144
|
+
this.attachmentListeners.add(listener);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
dispose: () => {
|
|
148
|
+
this.attachmentListeners.delete(listener);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
readonly write = (data: string): void => {
|
|
154
|
+
const bytes = new TextEncoder().encode(data);
|
|
155
|
+
|
|
156
|
+
if (this.started && this.connection !== null) {
|
|
157
|
+
this.connection.write(bytes);
|
|
158
|
+
} else if (!this.done) {
|
|
159
|
+
this.pending.push(bytes);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
readonly resize = (cols: number, rows: number): void => {
|
|
164
|
+
this.cols = cols;
|
|
165
|
+
this.rows = rows;
|
|
166
|
+
this.connection?.resize(cols, rows);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// A kill between connections goes out once the next one starts.
|
|
170
|
+
readonly kill = (): void => {
|
|
171
|
+
if (this.started && this.connection !== null) {
|
|
172
|
+
this.connection.sendSignal('SIGHUP');
|
|
173
|
+
} else if (!this.done) {
|
|
174
|
+
this.killPending = true;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
readonly detach = (): void => {
|
|
179
|
+
if (this.done) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const connection = this.connection;
|
|
184
|
+
|
|
185
|
+
this.stopFollowing();
|
|
186
|
+
connection?.close();
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a promise is a live handle
|
|
190
|
+
private async startWhenReady(ready: Promise<void>, start: ImpSessionRequest): Promise<void> {
|
|
191
|
+
try {
|
|
192
|
+
await ready;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (!this.done) {
|
|
195
|
+
this.emitExit({
|
|
196
|
+
exitCode: 1,
|
|
197
|
+
reason: 'ended',
|
|
198
|
+
detail: `imp refused the report socket (${error instanceof Error ? error.message : String(error)})`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// A resize while the host readied changed the size the start asks for.
|
|
206
|
+
if (!this.done) {
|
|
207
|
+
this.openConnection({ ...start, cols: this.cols, rows: this.rows });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private openConnection(request: ImpSessionRequest): void {
|
|
212
|
+
const connection = this.port.openSession(request, {
|
|
213
|
+
onStarted: (started) => {
|
|
214
|
+
if (this.connection === connection) {
|
|
215
|
+
this.applyStarted(connection, started);
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
onOutput: (data) => {
|
|
219
|
+
if (this.connection === connection) {
|
|
220
|
+
this.applyOutput(data);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
this.connection = connection;
|
|
226
|
+
this.started = false;
|
|
227
|
+
this.requestedSize = { cols: request.cols, rows: request.rows };
|
|
228
|
+
void this.waitForOutcome(connection);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private applyStarted(connection: ImpSessionConnection, started: ImpSessionStarted): void {
|
|
232
|
+
const output = started.output;
|
|
233
|
+
const offsets = this.host.offsets && output.continuity === 'offsets' ? output : null;
|
|
234
|
+
|
|
235
|
+
if (offsets?.resume?.kind === 'gap') {
|
|
236
|
+
this.restartFreshAttach(connection);
|
|
237
|
+
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (offsets?.resume?.kind === 'generation_changed') {
|
|
242
|
+
this.emitReplacedExit(connection, offsets.previous);
|
|
243
|
+
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (offsets === null || offsets.resume === undefined) {
|
|
248
|
+
if (this.fresh) {
|
|
249
|
+
this.decoder = new TextDecoder();
|
|
250
|
+
|
|
251
|
+
this.emitData(SCREEN_RESET);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
this.highWater = offsets?.offset ?? 0;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
this.cursor =
|
|
258
|
+
offsets === null ? null : { bootId: offsets.bootId, generation: offsets.executionGeneration };
|
|
259
|
+
|
|
260
|
+
this.nextOffset = offsets?.offset ?? 0;
|
|
261
|
+
this.preludeLeft = offsets?.prelude ?? 0;
|
|
262
|
+
this.fresh = false;
|
|
263
|
+
this.failures = 0;
|
|
264
|
+
this.started = true;
|
|
265
|
+
|
|
266
|
+
this.emitAttachment('attached');
|
|
267
|
+
|
|
268
|
+
// impd drops a resize that reaches it before the session starts, so the
|
|
269
|
+
// size asked for since the request goes out now.
|
|
270
|
+
if (this.requestedSize.cols !== this.cols || this.requestedSize.rows !== this.rows) {
|
|
271
|
+
connection.resize(this.cols, this.rows);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
for (const bytes of this.pending) {
|
|
275
|
+
connection.write(bytes);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
this.pending = [];
|
|
279
|
+
|
|
280
|
+
if (this.killPending) {
|
|
281
|
+
this.killPending = false;
|
|
282
|
+
|
|
283
|
+
connection.sendSignal('SIGHUP');
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// The mode bytes ahead of a fresh replay go to the screen uncounted; every
|
|
288
|
+
// byte after them sits at an offset, and one below the high-water mark is
|
|
289
|
+
// a repeat the daemon already has.
|
|
290
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- output bytes have no readonly form
|
|
291
|
+
private applyOutput(data: Uint8Array): void {
|
|
292
|
+
let bytes = data;
|
|
293
|
+
|
|
294
|
+
if (this.preludeLeft > 0) {
|
|
295
|
+
const prelude = bytes.subarray(0, this.preludeLeft);
|
|
296
|
+
|
|
297
|
+
this.preludeLeft -= prelude.length;
|
|
298
|
+
|
|
299
|
+
this.emitData(this.decoder.decode(prelude, { stream: true }));
|
|
300
|
+
|
|
301
|
+
bytes = bytes.subarray(prelude.length);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (this.cursor !== null) {
|
|
305
|
+
const start = this.nextOffset;
|
|
306
|
+
|
|
307
|
+
this.nextOffset += bytes.length;
|
|
308
|
+
bytes = bytes.subarray(Math.max(0, this.highWater - start));
|
|
309
|
+
this.highWater = Math.max(this.highWater, this.nextOffset);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (bytes.length > 0) {
|
|
313
|
+
this.emitData(this.decoder.decode(bytes, { stream: true }));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private async waitForOutcome(connection: ImpSessionConnection): Promise<void> {
|
|
318
|
+
const outcome = await connection.outcome;
|
|
319
|
+
|
|
320
|
+
if (this.connection !== connection || this.done || this.host.isSuspending()) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const wasStarted = this.started;
|
|
325
|
+
|
|
326
|
+
this.connection = null;
|
|
327
|
+
this.started = false;
|
|
328
|
+
|
|
329
|
+
this.applyOutcome(outcome, wasStarted);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// A drop resumes at once only on a connection that had started, so a
|
|
333
|
+
// connection impd drops before it answers counts as a failed reconnect.
|
|
334
|
+
private applyOutcome(outcome: ImpSessionOutcome, wasStarted: boolean): void {
|
|
335
|
+
if (outcome.kind === 'local_error') {
|
|
336
|
+
this.applyLocalError(outcome);
|
|
337
|
+
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
this.localErrors = 0;
|
|
342
|
+
|
|
343
|
+
if (outcome.kind === 'exit') {
|
|
344
|
+
this.emitExit({ exitCode: outcome.code ?? 1, reason: 'exited' });
|
|
345
|
+
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (outcome.kind === 'failed') {
|
|
350
|
+
this.applyRefusal(outcome);
|
|
351
|
+
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (outcome.kind === 'unauthorized' || outcome.kind === 'bad_message') {
|
|
356
|
+
this.emitExit({ exitCode: 1, reason: 'ended', detail: formatOutcome(outcome) });
|
|
357
|
+
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// impd closes a connection whose send it dropped under backpressure
|
|
362
|
+
// with 1011, and detaches a slow one: either resumes at once.
|
|
363
|
+
const isDropped =
|
|
364
|
+
(outcome.kind === 'closed' && outcome.closeCode === 1011) ||
|
|
365
|
+
(outcome.kind === 'detached' && outcome.reason === 'slow');
|
|
366
|
+
|
|
367
|
+
const delay = isDropped && wasStarted ? 0 : null;
|
|
368
|
+
|
|
369
|
+
this.scheduleReconnect(outcome, delay);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// A handler that throws on every connection would reconnect without end,
|
|
373
|
+
// so these failures take the host's delays on their own count and end the
|
|
374
|
+
// harness once they run out.
|
|
375
|
+
private applyLocalError(outcome: Extract<ImpSessionOutcome, { kind: 'local_error' }>): void {
|
|
376
|
+
const delay = this.host.reconnectDelaysMs[this.localErrors];
|
|
377
|
+
|
|
378
|
+
if (delay === undefined) {
|
|
379
|
+
this.emitExit({ exitCode: 1, reason: 'ended', detail: formatOutcome(outcome) });
|
|
380
|
+
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
this.localErrors += 1;
|
|
385
|
+
|
|
386
|
+
this.scheduleReconnect(outcome, delay);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private applyRefusal(outcome: Extract<ImpSessionOutcome, { kind: 'failed' }>): void {
|
|
390
|
+
if (outcome.code === 'INVALID_RESUME') {
|
|
391
|
+
this.fresh = true;
|
|
392
|
+
this.cursor = null;
|
|
393
|
+
|
|
394
|
+
this.scheduleReconnect(outcome, 0);
|
|
395
|
+
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (outcome.code === 'NO_SESSION') {
|
|
400
|
+
this.emitExit(buildLostExit(outcome.data, this.cursor));
|
|
401
|
+
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (outcome.code === 'INVALID_STATE') {
|
|
406
|
+
const state = isRecord(outcome.data) ? outcome.data['state'] : undefined;
|
|
407
|
+
|
|
408
|
+
const exit: HarnessExit =
|
|
409
|
+
state === 'sleeping'
|
|
410
|
+
? { exitCode: 0, reason: 'suspended' }
|
|
411
|
+
: {
|
|
412
|
+
exitCode: 1,
|
|
413
|
+
reason: 'ended',
|
|
414
|
+
detail: `imp is ${typeof state === 'string' ? state : 'not running'}`,
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
this.emitExit(exit);
|
|
418
|
+
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
this.emitExit({ exitCode: 1, reason: 'ended', detail: formatOutcome(outcome) });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// A delay of null takes the next one from the host's list; running out of
|
|
426
|
+
// them ends the harness with what ended its last connection.
|
|
427
|
+
private scheduleReconnect(
|
|
428
|
+
outcome: Exclude<ImpSessionOutcome, { kind: 'exit' }>,
|
|
429
|
+
delay: number | null,
|
|
430
|
+
): void {
|
|
431
|
+
const wait = delay ?? this.host.reconnectDelaysMs[this.failures];
|
|
432
|
+
|
|
433
|
+
if (wait === undefined) {
|
|
434
|
+
this.emitExit({ exitCode: 1, reason: 'ended', detail: formatOutcome(outcome) });
|
|
435
|
+
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (delay === null) {
|
|
440
|
+
this.failures += 1;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
this.emitAttachment('reattaching');
|
|
444
|
+
|
|
445
|
+
this.reconnectTimer = setTimeout(() => {
|
|
446
|
+
this.reconnectTimer = null;
|
|
447
|
+
|
|
448
|
+
if (!this.done) {
|
|
449
|
+
this.openConnection(this.buildAttach());
|
|
450
|
+
}
|
|
451
|
+
}, wait);
|
|
452
|
+
|
|
453
|
+
this.reconnectTimer.unref();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// A reconnect never wakes the imp: a sleeping imp answers that it sleeps,
|
|
457
|
+
// and only a revive wakes it.
|
|
458
|
+
private buildAttach(): ImpSessionRequest {
|
|
459
|
+
const cursor = this.fresh ? null : this.cursor;
|
|
460
|
+
|
|
461
|
+
if (cursor === null) {
|
|
462
|
+
this.fresh = true;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
kind: 'attach',
|
|
467
|
+
name: this.name,
|
|
468
|
+
session: this.session,
|
|
469
|
+
cols: this.cols,
|
|
470
|
+
rows: this.rows,
|
|
471
|
+
wake: false,
|
|
472
|
+
...(cursor === null
|
|
473
|
+
? {}
|
|
474
|
+
: { resumeFrom: { executionGeneration: cursor.generation, offset: this.highWater } }),
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Bytes after a gap can start inside an escape sequence, so the screen
|
|
479
|
+
// takes a fresh attach instead. The old connection stops counting first,
|
|
480
|
+
// so its close is not taken for a drop.
|
|
481
|
+
private restartFreshAttach(connection: ImpSessionConnection): void {
|
|
482
|
+
this.connection = null;
|
|
483
|
+
|
|
484
|
+
connection.close();
|
|
485
|
+
|
|
486
|
+
this.fresh = true;
|
|
487
|
+
|
|
488
|
+
this.openConnection(this.buildAttach());
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// A generation under the session's name other than its own is not the
|
|
492
|
+
// daemon's harness: the harness ended, with its exit code when impd kept
|
|
493
|
+
// it.
|
|
494
|
+
private emitReplacedExit(
|
|
495
|
+
connection: ImpSessionConnection,
|
|
496
|
+
previous: PreviousGeneration | undefined,
|
|
497
|
+
): void {
|
|
498
|
+
const exit: HarnessExit =
|
|
499
|
+
previous !== undefined && previous.executionGeneration === this.cursor?.generation
|
|
500
|
+
? { exitCode: previous.exitCode ?? 1, reason: 'exited' }
|
|
501
|
+
: { exitCode: 1, reason: 'ended', detail: 'imp replaced the process' };
|
|
502
|
+
|
|
503
|
+
this.connection = null;
|
|
504
|
+
|
|
505
|
+
connection.close();
|
|
506
|
+
this.emitExit(exit);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private emitData(text: string): void {
|
|
510
|
+
for (const listener of this.dataListeners) {
|
|
511
|
+
listener(text);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private emitAttachment(attachment: HarnessAttachment): void {
|
|
516
|
+
for (const listener of this.attachmentListeners) {
|
|
517
|
+
listener(attachment);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private emitExit(exit: HarnessExit): void {
|
|
522
|
+
const listeners = [...this.exitListeners];
|
|
523
|
+
|
|
524
|
+
this.stopFollowing();
|
|
525
|
+
|
|
526
|
+
for (const listener of listeners) {
|
|
527
|
+
listener(exit);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private stopFollowing(): void {
|
|
532
|
+
if (this.reconnectTimer !== null) {
|
|
533
|
+
clearTimeout(this.reconnectTimer);
|
|
534
|
+
|
|
535
|
+
this.reconnectTimer = null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
this.done = true;
|
|
539
|
+
this.connection = null;
|
|
540
|
+
|
|
541
|
+
this.dataListeners.clear();
|
|
542
|
+
this.exitListeners.clear();
|
|
543
|
+
this.attachmentListeners.clear();
|
|
544
|
+
this.host.onDone();
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// What ended a process impd no longer holds. In the same boot, the kept
|
|
549
|
+
// previous generation gives its exit. Across a cold boot, the first boot
|
|
550
|
+
// after the daemon's own gives the cause; without the daemon's boot among
|
|
551
|
+
// impd's last cold boots, the cause is unknown.
|
|
552
|
+
function buildLostExit(
|
|
553
|
+
data: unknown,
|
|
554
|
+
cursor: Readonly<{ bootId: string; generation: string }> | null,
|
|
555
|
+
): HarnessExit {
|
|
556
|
+
const record = isRecord(data) ? data : {};
|
|
557
|
+
|
|
558
|
+
if (cursor !== null && record['bootId'] === cursor.bootId) {
|
|
559
|
+
const previous = record['previous'];
|
|
560
|
+
const isOwn = isRecord(previous) && previous['executionGeneration'] === cursor.generation;
|
|
561
|
+
const exitCode = isOwn && typeof previous['exitCode'] === 'number' ? previous['exitCode'] : 1;
|
|
562
|
+
|
|
563
|
+
return isOwn
|
|
564
|
+
? { exitCode, reason: 'exited' }
|
|
565
|
+
: { exitCode: 1, reason: 'ended', detail: 'ended, cause unknown' };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const boots = collectColdBoots(record['coldBoots']);
|
|
569
|
+
const own = cursor === null ? -1 : boots.findIndex((boot) => boot.bootId === cursor.bootId);
|
|
570
|
+
const next = own > 0 ? boots[own - 1] : undefined;
|
|
571
|
+
|
|
572
|
+
return next === undefined
|
|
573
|
+
? { exitCode: 1, reason: 'ended', detail: 'ended, cause unknown' }
|
|
574
|
+
: { exitCode: 1, reason: 'ended', detail: `imp rebooted (${next.cause})` };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// impd's cold boots, newest first, as far as each one reads.
|
|
578
|
+
function collectColdBoots(value: unknown): { readonly bootId: unknown; readonly cause: string }[] {
|
|
579
|
+
if (!Array.isArray(value)) {
|
|
580
|
+
return [];
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return value
|
|
584
|
+
.filter((boot) => isRecord(boot))
|
|
585
|
+
.map((boot) => ({
|
|
586
|
+
bootId: boot['bootId'],
|
|
587
|
+
cause: typeof boot['cause'] === 'string' ? boot['cause'] : 'unknown',
|
|
588
|
+
}));
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// An end other than an exit, as a session's last message.
|
|
592
|
+
function formatOutcome(outcome: Exclude<ImpSessionOutcome, { kind: 'exit' }>): string {
|
|
593
|
+
if (outcome.kind === 'failed') {
|
|
594
|
+
return `imp refused the session (${outcome.code ?? 'error'})`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (outcome.kind === 'detached') {
|
|
598
|
+
return `imp detached the session (${outcome.reason})`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (outcome.kind === 'closed') {
|
|
602
|
+
return `imp connection closed (${outcome.reason})`;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (outcome.kind === 'unreachable') {
|
|
606
|
+
return 'imp unreachable';
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (outcome.kind === 'unauthorized') {
|
|
610
|
+
return 'imp refused the token';
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (outcome.kind === 'local_error') {
|
|
614
|
+
return `imp connection failed in the daemon (${outcome.detail})`;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return 'imp sent a bad message';
|
|
618
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A call impd refused or could not serve, with impd's error code (`LEASED`,
|
|
3
|
+
* `NOT_FOUND`, `FORBIDDEN`, and the rest) and the data that code defines.
|
|
4
|
+
* A transport failure carries `UNREACHABLE` or `UNAUTHORIZED`.
|
|
5
|
+
*/
|
|
6
|
+
export class ImpPortError extends Error {
|
|
7
|
+
readonly code: string;
|
|
8
|
+
|
|
9
|
+
readonly data: unknown;
|
|
10
|
+
|
|
11
|
+
constructor(code: string, message: string, data?: unknown) {
|
|
12
|
+
super(message);
|
|
13
|
+
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.data = data;
|
|
16
|
+
this.name = 'ImpPortError';
|
|
17
|
+
}
|
|
18
|
+
}
|