@opengeni/runtime 0.6.0 → 0.7.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.
Files changed (44) hide show
  1. package/dist/{chunk-BHA2SU3H.js → chunk-RYDCVTMW.js} +1959 -208
  2. package/dist/chunk-RYDCVTMW.js.map +1 -0
  3. package/dist/{index-otZkoLYq.d.ts → index-DIiiwQY2.d.ts} +693 -55
  4. package/dist/index.d.ts +293 -121
  5. package/dist/index.js +1819 -786
  6. package/dist/index.js.map +1 -1
  7. package/dist/sandbox/index.d.ts +1 -1
  8. package/dist/sandbox/index.js +79 -3
  9. package/package.json +21 -20
  10. package/src/codex-tool-search.ts +185 -31
  11. package/src/context-compaction.ts +78 -243
  12. package/src/history-sanitizer.ts +172 -20
  13. package/src/index.ts +2573 -897
  14. package/src/metrics.ts +28 -2
  15. package/src/sandbox/capabilities.ts +12 -3
  16. package/src/sandbox/channel-a.ts +420 -72
  17. package/src/sandbox/display-stack.ts +18 -4
  18. package/src/sandbox/errors.ts +13 -0
  19. package/src/sandbox/index.ts +398 -112
  20. package/src/sandbox/op-correlation.ts +70 -0
  21. package/src/sandbox/providers/index.ts +9 -3
  22. package/src/sandbox/providers/modal.ts +99 -24
  23. package/src/sandbox/providers/selfhosted.ts +1 -4
  24. package/src/sandbox/recording.ts +146 -90
  25. package/src/sandbox/routing/backend-resolver.ts +144 -10
  26. package/src/sandbox/routing/routing-session.ts +129 -25
  27. package/src/sandbox/select.ts +60 -8
  28. package/src/sandbox/selfhosted/capabilities.ts +59 -25
  29. package/src/sandbox/selfhosted/control-rpc.ts +160 -28
  30. package/src/sandbox/selfhosted/fault-rendering.ts +226 -0
  31. package/src/sandbox/selfhosted/op-observer.ts +54 -0
  32. package/src/sandbox/selfhosted/op-stream.ts +801 -0
  33. package/src/sandbox/selfhosted/op-testing.ts +468 -0
  34. package/src/sandbox/selfhosted/op-transport.ts +151 -0
  35. package/src/sandbox/selfhosted/retry-policy.ts +177 -0
  36. package/src/sandbox/selfhosted/session.ts +498 -55
  37. package/src/sandbox/selfhosted/testing.ts +60 -19
  38. package/src/sandbox/stream-port.ts +17 -5
  39. package/src/sandbox/stream-token.ts +8 -2
  40. package/src/sandbox/terminal-server.ts +18 -4
  41. package/src/sandbox-computer.ts +332 -70
  42. package/src/usage-telemetry.ts +73 -0
  43. package/dist/chunk-BHA2SU3H.js.map +0 -1
  44. package/src/image-history.ts +0 -149
@@ -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
+ }