@opengeni/runtime 0.6.1 → 0.7.1
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/dist/{chunk-DYBF7ZEB.js → chunk-RYDCVTMW.js} +1958 -207
- package/dist/chunk-RYDCVTMW.js.map +1 -0
- package/dist/{index-BTo_bZvN.d.ts → index-DIiiwQY2.d.ts} +689 -52
- package/dist/index.d.ts +296 -123
- package/dist/index.js +1907 -809
- package/dist/index.js.map +1 -1
- package/dist/sandbox/index.d.ts +1 -1
- package/dist/sandbox/index.js +79 -3
- package/package.json +21 -20
- package/src/codex-tool-search.ts +185 -31
- package/src/context-compaction.ts +78 -243
- package/src/history-sanitizer.ts +172 -20
- package/src/index.ts +2687 -920
- package/src/metrics.ts +28 -2
- package/src/sandbox/capabilities.ts +12 -3
- package/src/sandbox/channel-a.ts +420 -72
- package/src/sandbox/display-stack.ts +18 -4
- package/src/sandbox/errors.ts +13 -0
- package/src/sandbox/index.ts +398 -112
- package/src/sandbox/op-correlation.ts +70 -0
- package/src/sandbox/providers/index.ts +9 -3
- package/src/sandbox/providers/modal.ts +84 -14
- package/src/sandbox/providers/selfhosted.ts +1 -4
- package/src/sandbox/recording.ts +146 -90
- package/src/sandbox/routing/backend-resolver.ts +144 -10
- package/src/sandbox/routing/routing-session.ts +129 -25
- package/src/sandbox/select.ts +60 -8
- package/src/sandbox/selfhosted/capabilities.ts +59 -25
- package/src/sandbox/selfhosted/control-rpc.ts +160 -28
- package/src/sandbox/selfhosted/fault-rendering.ts +226 -0
- package/src/sandbox/selfhosted/op-observer.ts +54 -0
- package/src/sandbox/selfhosted/op-stream.ts +801 -0
- package/src/sandbox/selfhosted/op-testing.ts +468 -0
- package/src/sandbox/selfhosted/op-transport.ts +151 -0
- package/src/sandbox/selfhosted/retry-policy.ts +177 -0
- package/src/sandbox/selfhosted/session.ts +498 -55
- package/src/sandbox/selfhosted/testing.ts +60 -19
- package/src/sandbox/stream-port.ts +17 -5
- package/src/sandbox/stream-token.ts +8 -2
- package/src/sandbox/terminal-server.ts +18 -4
- package/src/sandbox-computer.ts +332 -70
- package/src/usage-telemetry.ts +73 -0
- package/dist/chunk-DYBF7ZEB.js.map +0 -1
- package/src/image-history.ts +0 -149
- package/src/screenshot-omitted-card.ts +0 -15
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
// The op-stream EXEC client (op-stream protocol v1.1, server half; PROTOCOL.md
|
|
2
|
+
// + ENGINE-INTEGRATION.md step 6 in .agent/).
|
|
3
|
+
//
|
|
4
|
+
// Replaces the legacy monolithic exec request/reply with sequenced, acked,
|
|
5
|
+
// credit-flowed frames when the runner advertises `Capabilities.op_stream` AND
|
|
6
|
+
// the server flag is on. What it buys, in order of importance:
|
|
7
|
+
//
|
|
8
|
+
// * ATTACH-NOT-RERUN (B1/B2): the op id is durable (`{tool_call_id}:{ordinal}`)
|
|
9
|
+
// and OpStart is idempotent by it — a re-dispatched turn that re-executes the
|
|
10
|
+
// same function_call COLLECTS the already-running/completed op instead of
|
|
11
|
+
// re-running the command. The at-least-once re-run hazard dies here.
|
|
12
|
+
// * No reply-size wall: output streams in ≤128 KiB frames; the runner retains
|
|
13
|
+
// ring→spool for replay, and a too-big stream fails TYPED (OP_OVERFLOW with
|
|
14
|
+
// exact counters), never silently truncated.
|
|
15
|
+
// * Blip tolerance: a connection loss detaches, never kills; frames resume via
|
|
16
|
+
// OpAttach replay (gap-triggered attach is a ROUTINE heal, incl. seq-0 loss).
|
|
17
|
+
//
|
|
18
|
+
// ACK POLICY (design delta Δ2, DESIGN-OPSTREAM-CLIENT.md): mid-op acks are
|
|
19
|
+
// CREDIT-ONLY — `OpAck{acked_seq: 0, credit_bytes: received_total + window}`.
|
|
20
|
+
// The runner FREES retained frames at acked_seq, and a re-dispatched consumer
|
|
21
|
+
// must reassemble from seq 0 (its predecessor's buffered bytes died with it),
|
|
22
|
+
// so no mid-op acked_seq > 0 is ever durably backed. Credit is an ABSOLUTE
|
|
23
|
+
// window replacement on the wire, so growing it keeps the child flowing while
|
|
24
|
+
// the runner retains everything (bounded by its ring+spool quotas — the honest
|
|
25
|
+
// output ceiling, typed when hit). The acked frontier advances exactly once,
|
|
26
|
+
// at the FINAL ack, after the consumed result is durable (the hard gate's
|
|
27
|
+
// ordering: result durable → journal persist → wire final ack — see
|
|
28
|
+
// `finalizeSettledOps`).
|
|
29
|
+
//
|
|
30
|
+
// Everything above the transport is unchanged: the caller builds the same
|
|
31
|
+
// `ExecRequest`, receives the same `ExecResponse` shape, and every failure is a
|
|
32
|
+
// `SelfhostedControlError` in the existing taxonomy so the fault renderer and
|
|
33
|
+
// the op observer behave identically across the transport swap.
|
|
34
|
+
|
|
35
|
+
import { blake3 } from "@noble/hashes/blake3";
|
|
36
|
+
import { bytesToHex } from "@noble/hashes/utils";
|
|
37
|
+
import {
|
|
38
|
+
ControlRequest,
|
|
39
|
+
ErrorCode,
|
|
40
|
+
OpAck,
|
|
41
|
+
OpChannel,
|
|
42
|
+
OpFrame,
|
|
43
|
+
OpLostReason,
|
|
44
|
+
OpState,
|
|
45
|
+
type ExecRequest,
|
|
46
|
+
type ExecResponse,
|
|
47
|
+
type OpExit,
|
|
48
|
+
type OpStatus,
|
|
49
|
+
} from "@opengeni/agent-proto";
|
|
50
|
+
import {
|
|
51
|
+
agentErrorToControlError,
|
|
52
|
+
SelfhostedControlError,
|
|
53
|
+
timeoutAgentError,
|
|
54
|
+
type ControlRpc,
|
|
55
|
+
} from "./control-rpc";
|
|
56
|
+
import { selfhostedRetryBackoffMs, type SelfhostedRetryClock } from "./retry-policy";
|
|
57
|
+
import {
|
|
58
|
+
opAckSubject,
|
|
59
|
+
opFrameSubject,
|
|
60
|
+
OpStreamUnavailableError,
|
|
61
|
+
type OpStreamSubscription,
|
|
62
|
+
type OpStreamTransport,
|
|
63
|
+
} from "./op-transport";
|
|
64
|
+
|
|
65
|
+
/** Initial send-credit window (bytes) when the caller does not size it. Matches
|
|
66
|
+
* the wire default OpStart advertises (~4 MiB). */
|
|
67
|
+
export const OP_STREAM_DEFAULT_WINDOW_BYTES = 4 * 1024 * 1024;
|
|
68
|
+
/** Cumulative-ack repetition cadence while an op is live (ruling M1: acks are
|
|
69
|
+
* best-effort and healed by repetition). */
|
|
70
|
+
export const OP_STREAM_ACK_INTERVAL_MS = 5_000;
|
|
71
|
+
/** Frame-silence threshold before the client starts probing with OpQuery. The
|
|
72
|
+
* runner emits Progress every 5s while an op is alive and quiet, so 30s of
|
|
73
|
+
* TOTAL silence means frames are not reaching us (or the op is gone). */
|
|
74
|
+
export const OP_STREAM_SILENCE_TIMEOUT_MS = 30_000;
|
|
75
|
+
/** How long a silent op is held open across a machine reconnect window before
|
|
76
|
+
* the client fails it typed (PROTOCOL liveness rule: hold ≤120s, then only a
|
|
77
|
+
* definitive answer fails the op). */
|
|
78
|
+
export const OP_STREAM_RECONNECT_HOLD_MS = 120_000;
|
|
79
|
+
/** Bound on stashed out-of-order frames per op (each ≤128 KiB payload). A gap
|
|
80
|
+
* that outgrows this is healed by attach-replay, not by buffering forever. */
|
|
81
|
+
const OP_STREAM_MAX_STASHED_FRAMES = 4_096;
|
|
82
|
+
/** OpStart DRAINING retry budget — mirrors the legacy exec's patient budget
|
|
83
|
+
* (retry-policy.ts): op start is not latency-critical and a saturated machine
|
|
84
|
+
* should queue, not fail. OpStart is idempotent by op id, so timeout blips are
|
|
85
|
+
* also safe to re-issue (unlike legacy exec) — same small bounded budget. */
|
|
86
|
+
const OP_START_DRAINING_MAX_RETRIES = 10;
|
|
87
|
+
const OP_START_BLIP_MAX_RETRIES = 3;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The durable-resume journal the worker adapts onto Temporal (design delta Δ1:
|
|
91
|
+
* the attach generation is `Info.currentAttemptScheduledTimestampMs` — strictly
|
|
92
|
+
* monotonic across worker-death redispatches, which `Info.attempt` is NOT under
|
|
93
|
+
* `maximumAttempts: 1`). `persistSettled` folds the op's settled frontier into
|
|
94
|
+
* the activity heartbeat details and MUST complete before the wire final ack
|
|
95
|
+
* (the durable-before-wire-ack gate); the default no-journal client (tests,
|
|
96
|
+
* non-activity callers) uses generation "1" and skips persistence.
|
|
97
|
+
*/
|
|
98
|
+
export interface OpStreamJournal {
|
|
99
|
+
/** The consumer's attach generation (u64 decimal string). */
|
|
100
|
+
attachGeneration(): string;
|
|
101
|
+
/** Durably record `opId` as settled at `exitSeq` BEFORE its final ack. */
|
|
102
|
+
persistSettled(opId: string, exitSeq: string): void | Promise<void>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface OpStreamExecClientDeps {
|
|
106
|
+
workspaceId: string;
|
|
107
|
+
agentId: string;
|
|
108
|
+
/** The lease/active epoch every ControlRequest is fenced under. */
|
|
109
|
+
epoch: number;
|
|
110
|
+
/** The rpc request/reply seam (OpStart/OpQuery/OpAttach ride here). */
|
|
111
|
+
controlRpc: ControlRpc;
|
|
112
|
+
/** The rpc subject (`agent.<ws>.<id>.rpc`). */
|
|
113
|
+
rpcSubject: string;
|
|
114
|
+
/** The frame/ack seam (subscribe + publish). */
|
|
115
|
+
transport: OpStreamTransport;
|
|
116
|
+
journal?: OpStreamJournal;
|
|
117
|
+
windowBytes?: number;
|
|
118
|
+
/** Control-op round-trip timeout for OpStart/OpQuery/OpAttach (the SHORT
|
|
119
|
+
* control budget, not the exec deadline). */
|
|
120
|
+
controlTimeoutMs: number;
|
|
121
|
+
/** The retry clock (sleep + jitter), injected for deterministic tests. */
|
|
122
|
+
retryClock: SelfhostedRetryClock;
|
|
123
|
+
ackIntervalMs?: number;
|
|
124
|
+
silenceTimeoutMs?: number;
|
|
125
|
+
reconnectHoldMs?: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A completed op-stream exec: the legacy-shaped response plus the healing
|
|
129
|
+
* telemetry the session folds into its op observation. */
|
|
130
|
+
export interface OpStreamExecOutcome {
|
|
131
|
+
response: ExecResponse;
|
|
132
|
+
/** Attach/query heals that occurred after streaming began (gap replays,
|
|
133
|
+
* silence probes that re-attached). >0 marks the op `healed`. */
|
|
134
|
+
heals: number;
|
|
135
|
+
/** OpStart-level retries (draining/blip) before the op was accepted. */
|
|
136
|
+
startRetries: number;
|
|
137
|
+
/** Total reassembled Data payload bytes (the observer's replyBytes). */
|
|
138
|
+
replyBytes: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** One op settled but not yet final-acked (awaiting the turn's durable point). */
|
|
142
|
+
interface SettledOp {
|
|
143
|
+
opId: string;
|
|
144
|
+
exitSeq: string;
|
|
145
|
+
generation: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const OP_CHANNEL_NAMES: Partial<Record<OpChannel, "stdout" | "stderr">> = {
|
|
149
|
+
[OpChannel.OP_CHANNEL_STDOUT]: "stdout",
|
|
150
|
+
[OpChannel.OP_CHANNEL_STDERR]: "stderr",
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The per-session op-stream exec client. One instance per `SelfhostedSession`;
|
|
155
|
+
* `exec()` runs one op end-to-end; `finalizeSettledOps()` is the turn-end hook
|
|
156
|
+
* that advances the acked frontier durably (journal) and then on the wire.
|
|
157
|
+
*/
|
|
158
|
+
export class OpStreamExecClient {
|
|
159
|
+
private readonly deps: OpStreamExecClientDeps;
|
|
160
|
+
private readonly settled: SettledOp[] = [];
|
|
161
|
+
private readonly inFlight = new Set<string>();
|
|
162
|
+
|
|
163
|
+
constructor(deps: OpStreamExecClientDeps) {
|
|
164
|
+
this.deps = deps;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private generation(): string {
|
|
168
|
+
return this.deps.journal?.attachGeneration() ?? "1";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Run one exec over the op stream. `opId` is the durable id (B1) — a re-issue
|
|
173
|
+
* with the same id attaches instead of re-running. `deadlineMs` is the exec
|
|
174
|
+
* process budget (relative); the runner's enforcement is authoritative.
|
|
175
|
+
* `wallMs` is the client-side give-up wall (deadline + reply grace).
|
|
176
|
+
*/
|
|
177
|
+
async exec(
|
|
178
|
+
opId: string,
|
|
179
|
+
exec: ExecRequest,
|
|
180
|
+
deadlineMs: number,
|
|
181
|
+
wallMs: number,
|
|
182
|
+
): Promise<OpStreamExecOutcome> {
|
|
183
|
+
if (this.inFlight.has(opId)) {
|
|
184
|
+
throw new SelfhostedControlError({
|
|
185
|
+
message: `op-stream exec: duplicate concurrent op id ${opId}`,
|
|
186
|
+
code: ErrorCode.ERROR_CODE_PROTOCOL,
|
|
187
|
+
reason: null,
|
|
188
|
+
retryable: false,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
this.inFlight.add(opId);
|
|
192
|
+
try {
|
|
193
|
+
const consumer = new OpConsumer(this.deps, opId, this.generation());
|
|
194
|
+
try {
|
|
195
|
+
const outcome = await consumer.run(exec, deadlineMs, wallMs);
|
|
196
|
+
this.settled.push({ opId, exitSeq: outcome.exitSeq, generation: consumer.generation });
|
|
197
|
+
return outcome.outcome;
|
|
198
|
+
} finally {
|
|
199
|
+
consumer.teardown();
|
|
200
|
+
}
|
|
201
|
+
} finally {
|
|
202
|
+
this.inFlight.delete(opId);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The turn-end durability hook (the hard gate's ordering): for every op whose
|
|
208
|
+
* result the turn has durably consumed — (1) already true when this runs —
|
|
209
|
+
* (2) persist the settled frontier to the journal, then (3) publish the wire
|
|
210
|
+
* final ack (`acked_seq = exit_seq, final = true`), which licenses the runner
|
|
211
|
+
* to GC the op. A kill between (2) and (3) is safe: the re-dispatched turn
|
|
212
|
+
* does not re-execute a durably-recorded call, and the runner's retention TTL
|
|
213
|
+
* reaps the never-final-acked op — no loss, bounded residue. Publish failures
|
|
214
|
+
* are swallowed for the same reason.
|
|
215
|
+
*/
|
|
216
|
+
async finalizeSettledOps(): Promise<void> {
|
|
217
|
+
const ackSubject = opAckSubject(this.deps.workspaceId, this.deps.agentId);
|
|
218
|
+
while (this.settled.length > 0) {
|
|
219
|
+
// Non-null: length checked above (single-threaded event loop).
|
|
220
|
+
const op = this.settled.shift() as SettledOp;
|
|
221
|
+
await this.deps.journal?.persistSettled(op.opId, op.exitSeq);
|
|
222
|
+
const ack = OpAck.encode({
|
|
223
|
+
opId: op.opId,
|
|
224
|
+
ackedSeq: op.exitSeq,
|
|
225
|
+
creditBytes: "0",
|
|
226
|
+
final: true,
|
|
227
|
+
attachGeneration: op.generation,
|
|
228
|
+
}).finish();
|
|
229
|
+
try {
|
|
230
|
+
await this.deps.transport.publish(ackSubject, ack);
|
|
231
|
+
} catch {
|
|
232
|
+
// Best-effort: the runner's retention TTL owns the fallback.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Internal per-op consumer: subscription, reassembly, flow, liveness. */
|
|
239
|
+
class OpConsumer {
|
|
240
|
+
private readonly deps: OpStreamExecClientDeps;
|
|
241
|
+
private readonly opId: string;
|
|
242
|
+
readonly generation: string;
|
|
243
|
+
private readonly windowBytes: number;
|
|
244
|
+
|
|
245
|
+
/** Highest contiguously APPLIED seq (frames start at 1; 0 = none yet). */
|
|
246
|
+
private lastApplied = 0n;
|
|
247
|
+
private readonly stash = new Map<bigint, OpFrame>();
|
|
248
|
+
private readonly chunks: { stdout: Uint8Array[]; stderr: Uint8Array[] } = {
|
|
249
|
+
stdout: [],
|
|
250
|
+
stderr: [],
|
|
251
|
+
};
|
|
252
|
+
private receivedPayloadBytes = 0n;
|
|
253
|
+
private creditAtLastAck = 0n;
|
|
254
|
+
private exit: OpExit | undefined;
|
|
255
|
+
private exitSeq: bigint | undefined;
|
|
256
|
+
private heals = 0;
|
|
257
|
+
private lastFrameAt: number;
|
|
258
|
+
private silenceSince: number | undefined;
|
|
259
|
+
private attachInFlight = false;
|
|
260
|
+
private subscription: OpStreamSubscription | undefined;
|
|
261
|
+
private ackTimer: ReturnType<typeof setInterval> | undefined;
|
|
262
|
+
private settleResolve: (() => void) | undefined;
|
|
263
|
+
private settleReject: ((error: unknown) => void) | undefined;
|
|
264
|
+
private torn = false;
|
|
265
|
+
|
|
266
|
+
constructor(deps: OpStreamExecClientDeps, opId: string, generation: string) {
|
|
267
|
+
this.deps = deps;
|
|
268
|
+
this.opId = opId;
|
|
269
|
+
this.generation = generation;
|
|
270
|
+
this.windowBytes = deps.windowBytes ?? OP_STREAM_DEFAULT_WINDOW_BYTES;
|
|
271
|
+
this.lastFrameAt = Date.now();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private get ackIntervalMs(): number {
|
|
275
|
+
return this.deps.ackIntervalMs ?? OP_STREAM_ACK_INTERVAL_MS;
|
|
276
|
+
}
|
|
277
|
+
private get silenceTimeoutMs(): number {
|
|
278
|
+
return this.deps.silenceTimeoutMs ?? OP_STREAM_SILENCE_TIMEOUT_MS;
|
|
279
|
+
}
|
|
280
|
+
private get reconnectHoldMs(): number {
|
|
281
|
+
return this.deps.reconnectHoldMs ?? OP_STREAM_RECONNECT_HOLD_MS;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
teardown(): void {
|
|
285
|
+
this.torn = true;
|
|
286
|
+
if (this.ackTimer) {
|
|
287
|
+
clearInterval(this.ackTimer);
|
|
288
|
+
this.ackTimer = undefined;
|
|
289
|
+
}
|
|
290
|
+
this.subscription?.unsubscribe();
|
|
291
|
+
this.subscription = undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async run(
|
|
295
|
+
exec: ExecRequest,
|
|
296
|
+
deadlineMs: number,
|
|
297
|
+
wallMs: number,
|
|
298
|
+
): Promise<{
|
|
299
|
+
outcome: OpStreamExecOutcome;
|
|
300
|
+
exitSeq: string;
|
|
301
|
+
}> {
|
|
302
|
+
// Arm the settle promise FIRST: frames can start applying the moment the
|
|
303
|
+
// attach below returns (replay is asynchronous), and a completion that
|
|
304
|
+
// fires before the resolver exists would strand the op on its wall.
|
|
305
|
+
const settled = new Promise<void>((resolve, reject) => {
|
|
306
|
+
this.settleResolve = resolve;
|
|
307
|
+
this.settleReject = reject;
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Subscription BEFORE OpStart (protocol invariant): no frame can be
|
|
311
|
+
// published before the consumer exists on a healthy path.
|
|
312
|
+
this.subscription = await this.deps.transport.subscribe(
|
|
313
|
+
opFrameSubject(this.deps.workspaceId, this.deps.agentId, this.opId),
|
|
314
|
+
(payload) => this.onFramePayload(payload),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const startRetries = await this.startOp(exec, deadlineMs);
|
|
318
|
+
|
|
319
|
+
// The universal begin (B2): attach from our contiguous frontier under the
|
|
320
|
+
// initial window — for a FRESH op that is seq 0 (replay nothing, start live
|
|
321
|
+
// flow); for a KNOWN op (re-dispatch) it replays everything we do not hold.
|
|
322
|
+
await this.attach(this.windowBytes);
|
|
323
|
+
|
|
324
|
+
// Cumulative-ack repetition (M1) — the periodic leg.
|
|
325
|
+
this.ackTimer = setInterval(() => {
|
|
326
|
+
void this.sendAck();
|
|
327
|
+
}, this.ackIntervalMs);
|
|
328
|
+
const liveness = this.watchLiveness(wallMs);
|
|
329
|
+
try {
|
|
330
|
+
await Promise.race([settled, liveness.wall]);
|
|
331
|
+
} finally {
|
|
332
|
+
liveness.stop();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Settled: the exit frame and every frame before it are applied.
|
|
336
|
+
const exit = this.exit as OpExit;
|
|
337
|
+
const exitSeq = this.exitSeq as bigint;
|
|
338
|
+
this.verifyByteExact(exit);
|
|
339
|
+
if (exit.failureCode) {
|
|
340
|
+
throw runnerFailureToControlError(exit);
|
|
341
|
+
}
|
|
342
|
+
const stdout = concatChunks(this.chunks.stdout);
|
|
343
|
+
const stderr = concatChunks(this.chunks.stderr);
|
|
344
|
+
return {
|
|
345
|
+
outcome: {
|
|
346
|
+
response: {
|
|
347
|
+
exitCode: exit.exitCode,
|
|
348
|
+
stdout,
|
|
349
|
+
stderr,
|
|
350
|
+
timedOut: exit.timedOut,
|
|
351
|
+
durationMs: exit.durationMs,
|
|
352
|
+
},
|
|
353
|
+
heals: this.heals,
|
|
354
|
+
startRetries,
|
|
355
|
+
replyBytes: stdout.byteLength + stderr.byteLength,
|
|
356
|
+
},
|
|
357
|
+
exitSeq: exitSeq.toString(),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** OpStart with the patient DRAINING budget and a small blip budget. OpStart
|
|
362
|
+
* is IDEMPOTENT BY OP ID (the request id), so a timed-out/never-sent start
|
|
363
|
+
* is safe to re-issue even though exec mutates — the runner either never saw
|
|
364
|
+
* it or answers the SAME op. The stable request id is what makes that true. */
|
|
365
|
+
private async startOp(exec: ExecRequest, deadlineMs: number): Promise<number> {
|
|
366
|
+
let drainingRetries = 0;
|
|
367
|
+
let blipRetries = 0;
|
|
368
|
+
for (;;) {
|
|
369
|
+
try {
|
|
370
|
+
const result = await this.controlOp(
|
|
371
|
+
{
|
|
372
|
+
$case: "opStart",
|
|
373
|
+
opStart: {
|
|
374
|
+
op: { $case: "exec", exec },
|
|
375
|
+
windowBytes: String(this.windowBytes),
|
|
376
|
+
// Absolute epoch deadline (the runner's clock decides the actual
|
|
377
|
+
// budget; host clock skew shifts it — a known wire caveat).
|
|
378
|
+
deadlineMs: deadlineMs > 0 ? String(Date.now() + deadlineMs) : "0",
|
|
379
|
+
originId: this.deps.rpcSubject,
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
this.opId,
|
|
383
|
+
);
|
|
384
|
+
if (result.$case !== "opStart") {
|
|
385
|
+
throw protocolError(`op-stream start: unexpected result ${result.$case}`);
|
|
386
|
+
}
|
|
387
|
+
const status = result.opStart.status;
|
|
388
|
+
if (!result.opStart.accepted) {
|
|
389
|
+
throw this.refusedStart(status);
|
|
390
|
+
}
|
|
391
|
+
if (status?.state === OpState.OP_STATE_LOST) {
|
|
392
|
+
throw lostToControlError(status);
|
|
393
|
+
}
|
|
394
|
+
return drainingRetries + blipRetries;
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (!(error instanceof SelfhostedControlError)) {
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
// A PROTOCOL/UNSUPPORTED refusal of the START ITSELF means the runner
|
|
400
|
+
// does not speak op-stream (a capability/downgrade race — M8 latches
|
|
401
|
+
// the transport per-op, and the legacy exec is the permanent fallback
|
|
402
|
+
// wire form). The op provably never started, so falling back is safe.
|
|
403
|
+
if (
|
|
404
|
+
error.code === ErrorCode.ERROR_CODE_PROTOCOL ||
|
|
405
|
+
error.code === ErrorCode.ERROR_CODE_UNSUPPORTED
|
|
406
|
+
) {
|
|
407
|
+
throw new OpStreamUnavailableError(
|
|
408
|
+
`the runner refused OpStart (${error.message}); falling back to the legacy exec`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
const blip = error.reason === "agent_reconnecting" || error.neverSent;
|
|
412
|
+
if (error.draining && drainingRetries < OP_START_DRAINING_MAX_RETRIES) {
|
|
413
|
+
await this.deps.retryClock.sleep(
|
|
414
|
+
selfhostedRetryBackoffMs(drainingRetries, this.deps.retryClock.jitter()),
|
|
415
|
+
);
|
|
416
|
+
drainingRetries += 1;
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (blip && blipRetries < OP_START_BLIP_MAX_RETRIES) {
|
|
420
|
+
await this.deps.retryClock.sleep(
|
|
421
|
+
selfhostedRetryBackoffMs(blipRetries, this.deps.retryClock.jitter()),
|
|
422
|
+
);
|
|
423
|
+
blipRetries += 1;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private refusedStart(status: OpStatus | undefined): SelfhostedControlError {
|
|
432
|
+
if (status?.state === OpState.OP_STATE_LOST) {
|
|
433
|
+
return lostToControlError(status);
|
|
434
|
+
}
|
|
435
|
+
return protocolError("op-stream start: the runner refused the op without a typed reason");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** One control op on the rpc subject; throws the mapped
|
|
439
|
+
* `SelfhostedControlError` on any AgentError. `requestId` is overridable
|
|
440
|
+
* because OpStart's REQUEST ID IS THE OP ID (its idempotency key) and must
|
|
441
|
+
* be STABLE across retries — every other op uses a fresh UUID per attempt. */
|
|
442
|
+
private async controlOp(
|
|
443
|
+
op: NonNullable<ControlRequest["op"]>,
|
|
444
|
+
requestId: string = crypto.randomUUID(),
|
|
445
|
+
) {
|
|
446
|
+
const res = await this.deps.controlRpc.request(
|
|
447
|
+
this.deps.rpcSubject,
|
|
448
|
+
{ requestId, epoch: this.deps.epoch, op },
|
|
449
|
+
{ timeoutMs: this.deps.controlTimeoutMs },
|
|
450
|
+
);
|
|
451
|
+
if (res.error || !res.result) {
|
|
452
|
+
throw agentErrorToControlError(
|
|
453
|
+
res.error ?? {
|
|
454
|
+
code: ErrorCode.ERROR_CODE_PROTOCOL,
|
|
455
|
+
message: "agent returned an empty control response",
|
|
456
|
+
retryable: false,
|
|
457
|
+
detail: {},
|
|
458
|
+
},
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
return res.result;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** OpAttach from the contiguous frontier. `windowBytes` sizes the fresh
|
|
465
|
+
* replay window; 0 = reuse the OpStart grant (the gap-heal path). */
|
|
466
|
+
private async attach(windowBytes: number): Promise<OpStatus> {
|
|
467
|
+
const result = await this.controlOp({
|
|
468
|
+
$case: "opAttach",
|
|
469
|
+
opAttach: {
|
|
470
|
+
opId: this.opId,
|
|
471
|
+
fromSeq: this.lastApplied.toString(),
|
|
472
|
+
attachGeneration: this.generation,
|
|
473
|
+
windowBytes: String(windowBytes),
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
if (result.$case !== "opStatus") {
|
|
477
|
+
throw protocolError(`op-stream attach: unexpected result ${result.$case}`);
|
|
478
|
+
}
|
|
479
|
+
if (result.opStatus.state === OpState.OP_STATE_LOST) {
|
|
480
|
+
throw lostToControlError(result.opStatus);
|
|
481
|
+
}
|
|
482
|
+
return result.opStatus;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Gap/silence heal: one re-attach in flight at a time; each success counts
|
|
486
|
+
* as a heal (the observer's `healed` breadcrumb). Failures are left to the
|
|
487
|
+
* liveness watcher — a dead link fails typed there, not here. */
|
|
488
|
+
private requestAttachHeal(): void {
|
|
489
|
+
if (this.attachInFlight || this.torn) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
this.attachInFlight = true;
|
|
493
|
+
void this.attach(0)
|
|
494
|
+
.then(() => {
|
|
495
|
+
this.heals += 1;
|
|
496
|
+
})
|
|
497
|
+
.catch((error) => {
|
|
498
|
+
if (error instanceof SelfhostedControlError && !error.retryable && !error.agentOffline) {
|
|
499
|
+
this.settleReject?.(error);
|
|
500
|
+
}
|
|
501
|
+
})
|
|
502
|
+
.finally(() => {
|
|
503
|
+
this.attachInFlight = false;
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private onFramePayload(payload: Uint8Array): void {
|
|
508
|
+
let frame: OpFrame;
|
|
509
|
+
try {
|
|
510
|
+
frame = OpFrame.decode(payload);
|
|
511
|
+
} catch {
|
|
512
|
+
// A torn frame never kills the subscription; the seq gap it leaves is
|
|
513
|
+
// healed by attach-replay.
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (frame.opId !== this.opId) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
this.lastFrameAt = Date.now();
|
|
520
|
+
this.silenceSince = undefined;
|
|
521
|
+
const seq = BigInt(frame.seq);
|
|
522
|
+
if (seq <= this.lastApplied) {
|
|
523
|
+
return; // Duplicate (re-delivery / replay overlap) — reassembly is seq-idempotent.
|
|
524
|
+
}
|
|
525
|
+
if (seq !== this.lastApplied + 1n) {
|
|
526
|
+
// Out of order: stash and ask for the gap. Replay heals from the frontier,
|
|
527
|
+
// so an overgrown stash is dropped, not grown without bound.
|
|
528
|
+
if (this.stash.size < OP_STREAM_MAX_STASHED_FRAMES) {
|
|
529
|
+
this.stash.set(seq, frame);
|
|
530
|
+
}
|
|
531
|
+
this.requestAttachHeal();
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
this.applyFrame(frame);
|
|
535
|
+
// Drain any stashed continuation.
|
|
536
|
+
for (;;) {
|
|
537
|
+
const next = this.stash.get(this.lastApplied + 1n);
|
|
538
|
+
if (!next) {
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
this.stash.delete(this.lastApplied + 1n);
|
|
542
|
+
this.applyFrame(next);
|
|
543
|
+
}
|
|
544
|
+
this.maybeSettle();
|
|
545
|
+
this.maybeGrantCredit();
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private applyFrame(frame: OpFrame): void {
|
|
549
|
+
this.lastApplied = BigInt(frame.seq);
|
|
550
|
+
const body = frame.body;
|
|
551
|
+
if (!body) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
switch (body.$case) {
|
|
555
|
+
case "data": {
|
|
556
|
+
const channel = OP_CHANNEL_NAMES[body.data.channel];
|
|
557
|
+
if (channel) {
|
|
558
|
+
this.chunks[channel].push(body.data.bytes);
|
|
559
|
+
}
|
|
560
|
+
this.receivedPayloadBytes += BigInt(body.data.bytes.byteLength);
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
case "progress":
|
|
564
|
+
// A liveness tick; echo our cumulative ack (M1's on-progress leg).
|
|
565
|
+
void this.sendAck();
|
|
566
|
+
break;
|
|
567
|
+
case "exit":
|
|
568
|
+
this.exit = body.exit;
|
|
569
|
+
this.exitSeq = BigInt(frame.seq);
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private maybeSettle(): void {
|
|
575
|
+
if (this.exitSeq !== undefined && this.lastApplied >= this.exitSeq) {
|
|
576
|
+
this.settleResolve?.();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Prompt credit replenishment: the periodic ack alone would stall a fast
|
|
581
|
+
* child for up to the ack interval each window; grant as soon as half the
|
|
582
|
+
* window has been consumed since the last grant. */
|
|
583
|
+
private maybeGrantCredit(): void {
|
|
584
|
+
if (this.receivedPayloadBytes - this.creditAtLastAck >= BigInt(this.windowBytes) / 2n) {
|
|
585
|
+
void this.sendAck();
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** The mid-op cumulative ack: CREDIT-ONLY (Δ2 — acked_seq stays 0 so the
|
|
590
|
+
* runner retains every frame for a possible successor consumer; the credit
|
|
591
|
+
* is an absolute replacement sized to keep ~one window of live headroom). */
|
|
592
|
+
private async sendAck(): Promise<void> {
|
|
593
|
+
if (this.torn) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
this.creditAtLastAck = this.receivedPayloadBytes;
|
|
597
|
+
const ack = OpAck.encode({
|
|
598
|
+
opId: this.opId,
|
|
599
|
+
ackedSeq: "0",
|
|
600
|
+
creditBytes: (this.receivedPayloadBytes + BigInt(this.windowBytes)).toString(),
|
|
601
|
+
final: false,
|
|
602
|
+
attachGeneration: this.generation,
|
|
603
|
+
}).finish();
|
|
604
|
+
try {
|
|
605
|
+
await this.deps.transport.publish(
|
|
606
|
+
opAckSubject(this.deps.workspaceId, this.deps.agentId),
|
|
607
|
+
ack,
|
|
608
|
+
);
|
|
609
|
+
} catch {
|
|
610
|
+
// Acks are best-effort and healed by repetition (M1).
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* The liveness watcher: probes a silent op with OpQuery, re-attaches when the
|
|
616
|
+
* runner is ahead of us (frames lost in flight), holds a quiet-but-alive op
|
|
617
|
+
* open through the reconnect window, and enforces the client-side wall.
|
|
618
|
+
* Resolves only by rejection (typed failure) or by `stop()` at settle.
|
|
619
|
+
*/
|
|
620
|
+
private watchLiveness(wallMs: number): { wall: Promise<never>; stop: () => void } {
|
|
621
|
+
let stopped = false;
|
|
622
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
623
|
+
const wall = new Promise<never>((_, reject) => {
|
|
624
|
+
const startedAt = Date.now();
|
|
625
|
+
const tick = async () => {
|
|
626
|
+
if (stopped) {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
try {
|
|
630
|
+
if (Date.now() - startedAt >= wallMs) {
|
|
631
|
+
// The runner enforces the exec deadline and emits Exit{timed_out};
|
|
632
|
+
// reaching OUR wall means even that terminal frame never arrived —
|
|
633
|
+
// the same ambiguity as a legacy reply timeout, surfaced the same.
|
|
634
|
+
reject(agentErrorToControlError(timeoutAgentError()));
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
if (Date.now() - this.lastFrameAt >= this.silenceTimeoutMs) {
|
|
638
|
+
await this.probeSilence(reject);
|
|
639
|
+
}
|
|
640
|
+
} catch (error) {
|
|
641
|
+
reject(error);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
timer = setTimeout(tick, Math.min(this.silenceTimeoutMs / 3, 2_500));
|
|
645
|
+
};
|
|
646
|
+
timer = setTimeout(tick, Math.min(this.silenceTimeoutMs / 3, 2_500));
|
|
647
|
+
});
|
|
648
|
+
return {
|
|
649
|
+
wall,
|
|
650
|
+
stop: () => {
|
|
651
|
+
stopped = true;
|
|
652
|
+
if (timer) {
|
|
653
|
+
clearTimeout(timer);
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** One silence probe: OpQuery the op; a definitive answer acts, an offline /
|
|
660
|
+
* blip answer holds through the reconnect window, then fails typed. */
|
|
661
|
+
private async probeSilence(reject: (error: unknown) => void): Promise<void> {
|
|
662
|
+
this.silenceSince ??= Date.now();
|
|
663
|
+
try {
|
|
664
|
+
const result = await this.controlOp({ $case: "opQuery", opQuery: { opId: this.opId } });
|
|
665
|
+
if (result.$case !== "opStatus") {
|
|
666
|
+
throw protocolError(`op-stream query: unexpected result ${result.$case}`);
|
|
667
|
+
}
|
|
668
|
+
const status = result.opStatus;
|
|
669
|
+
if (status.state === OpState.OP_STATE_LOST) {
|
|
670
|
+
reject(lostToControlError(status));
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
// Alive on the runner. If it is ahead of our frontier, frames were lost
|
|
674
|
+
// in flight — re-attach to replay them (this also restores live flow
|
|
675
|
+
// after a reconnect). A quiet-but-current op just keeps holding.
|
|
676
|
+
if (BigInt(status.nextSeq) > this.lastApplied + 1n) {
|
|
677
|
+
this.requestAttachHeal();
|
|
678
|
+
}
|
|
679
|
+
} catch (error) {
|
|
680
|
+
if (!(error instanceof SelfhostedControlError)) {
|
|
681
|
+
throw error;
|
|
682
|
+
}
|
|
683
|
+
const transient =
|
|
684
|
+
error.agentOffline || error.reason === "agent_reconnecting" || error.draining;
|
|
685
|
+
if (!transient) {
|
|
686
|
+
throw error;
|
|
687
|
+
}
|
|
688
|
+
const heldFor = Date.now() - (this.silenceSince ?? Date.now());
|
|
689
|
+
if (heldFor >= this.reconnectHoldMs) {
|
|
690
|
+
// The hold window expired without the machine coming back — surface
|
|
691
|
+
// the LAST probe's typed error (offline/reconnecting), same taxonomy
|
|
692
|
+
// as the legacy path.
|
|
693
|
+
reject(error);
|
|
694
|
+
}
|
|
695
|
+
// Otherwise: keep holding — op ⊥ connection (the runner keeps the child
|
|
696
|
+
// running and retains its output through the blip).
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Byte-exactness proof: totals AND blake3 digests of the reassembled
|
|
701
|
+
* channels must match the runner's Exit record exactly. */
|
|
702
|
+
private verifyByteExact(exit: OpExit): void {
|
|
703
|
+
for (const channel of ["stdout", "stderr"] as const) {
|
|
704
|
+
const bytes = concatChunks(this.chunks[channel]);
|
|
705
|
+
const declaredTotal = exit.totals[channel];
|
|
706
|
+
if (declaredTotal !== undefined && BigInt(declaredTotal) !== BigInt(bytes.byteLength)) {
|
|
707
|
+
throw protocolError(
|
|
708
|
+
`op-stream reassembly: ${channel} total mismatch (got ${bytes.byteLength}, ` +
|
|
709
|
+
`runner declared ${declaredTotal})`,
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
const declaredDigest = exit.digests[channel];
|
|
713
|
+
if (declaredDigest) {
|
|
714
|
+
const digest = bytesToHex(blake3(bytes));
|
|
715
|
+
if (digest !== declaredDigest) {
|
|
716
|
+
throw protocolError(
|
|
717
|
+
`op-stream reassembly: ${channel} digest mismatch (got ${digest}, ` +
|
|
718
|
+
`runner declared ${declaredDigest})`,
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function concatChunks(chunks: Uint8Array[]): Uint8Array {
|
|
727
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
728
|
+
const out = new Uint8Array(total);
|
|
729
|
+
let offset = 0;
|
|
730
|
+
for (const chunk of chunks) {
|
|
731
|
+
out.set(chunk, offset);
|
|
732
|
+
offset += chunk.byteLength;
|
|
733
|
+
}
|
|
734
|
+
return out;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function protocolError(message: string): SelfhostedControlError {
|
|
738
|
+
return new SelfhostedControlError({
|
|
739
|
+
message,
|
|
740
|
+
code: ErrorCode.ERROR_CODE_PROTOCOL,
|
|
741
|
+
reason: null,
|
|
742
|
+
retryable: false,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** A definitive `lost` status → a typed op-level failure. The result was
|
|
747
|
+
* bounded-retention collateral (LRU eviction / a runner restart predating the
|
|
748
|
+
* persistence milestone) — honest, never silent. */
|
|
749
|
+
function lostToControlError(status: OpStatus): SelfhostedControlError {
|
|
750
|
+
const reason =
|
|
751
|
+
status.lostReason === OpLostReason.OP_LOST_REASON_EVICTED
|
|
752
|
+
? "its retained result was evicted before collection"
|
|
753
|
+
: status.lostReason === OpLostReason.OP_LOST_REASON_AGENT_RESTARTED
|
|
754
|
+
? "the machine agent restarted and the op did not survive"
|
|
755
|
+
: "the runner reported it lost";
|
|
756
|
+
return new SelfhostedControlError({
|
|
757
|
+
message:
|
|
758
|
+
`The command's result is no longer available on the machine (${reason}). ` +
|
|
759
|
+
"Check whether its effects already took place before re-running it.",
|
|
760
|
+
code: ErrorCode.ERROR_CODE_STREAM,
|
|
761
|
+
reason: null,
|
|
762
|
+
retryable: false,
|
|
763
|
+
detail: { lost_reason: String(status.lostReason) },
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* A runner-typed terminal failure (OpExit.failure_code — OP_OVERFLOW /
|
|
769
|
+
* OP_SPOOL_IO / OP_PIPE_IO; never an exit-code sentinel). OP_OVERFLOW maps to
|
|
770
|
+
* the PAYLOAD_TOO_LARGE taxonomy: the semantics differ (retention quota vs
|
|
771
|
+
* reply size) but the model's correct next action is identical — bound the
|
|
772
|
+
* output, redirect to a file, read it back in ranges — so it gets the same
|
|
773
|
+
* actionable rendering, with the runner's exact counters in the detail.
|
|
774
|
+
*/
|
|
775
|
+
function runnerFailureToControlError(exit: OpExit): SelfhostedControlError {
|
|
776
|
+
if (exit.failureCode === "OP_OVERFLOW") {
|
|
777
|
+
return new SelfhostedControlError({
|
|
778
|
+
message:
|
|
779
|
+
"The command produced more output than the machine link can retain for delivery. " +
|
|
780
|
+
"Redirect the command's output to a file (for example `<command> > /tmp/out.log 2>&1`) " +
|
|
781
|
+
"and then read that file back in ranges or chunks instead of returning it all at once.",
|
|
782
|
+
code: ErrorCode.ERROR_CODE_PAYLOAD_TOO_LARGE,
|
|
783
|
+
reason: null,
|
|
784
|
+
retryable: false,
|
|
785
|
+
payloadTooLarge: true,
|
|
786
|
+
// failure_code lets the fault renderer distinguish the retention-ceiling
|
|
787
|
+
// overflow (command TERMINATED at the ceiling) from the legacy reply-size
|
|
788
|
+
// wall (command ran to completion) — the four fields must tell the truth.
|
|
789
|
+
detail: { ...exit.failureDetail, failure_code: exit.failureCode },
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
return new SelfhostedControlError({
|
|
793
|
+
message:
|
|
794
|
+
`The command failed on the machine's streaming transport (${exit.failureCode}). ` +
|
|
795
|
+
"The machine kept running it, but its output could not be captured intact.",
|
|
796
|
+
code: ErrorCode.ERROR_CODE_STREAM,
|
|
797
|
+
reason: null,
|
|
798
|
+
retryable: false,
|
|
799
|
+
detail: { ...exit.failureDetail, failure_code: exit.failureCode },
|
|
800
|
+
});
|
|
801
|
+
}
|