@rynx-ai/runtime 0.1.11-beta.37 → 0.1.11-beta.39

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.
@@ -8,10 +8,13 @@
8
8
  * needs an app-server of its own.
9
9
  */
10
10
  import { randomUUID } from "node:crypto";
11
+ import { appendFileSync, mkdirSync, renameSync, rmSync, statSync, } from "node:fs";
12
+ import { dirname } from "node:path";
11
13
  import { AgentRuntimeError, } from "@rynx-ai/core";
12
14
  import { TerminalRegistry, } from "../terminal/registry.js";
13
15
  import { RUNNER_IMAGE_CHUNK_CHARS, RUNNER_IMAGE_MAX_RESULT_CHARS, toWireError, } from "./protocol.js";
14
16
  import { isCodexLineageProvider } from "./startup-policy.js";
17
+ import { RunnerTransportWriteError, } from "./transport.js";
15
18
  const TERMINAL_PREPARATION_TTL_MS = 30_000;
16
19
  /** Maximum time for a fresh Codex-lineage TUI to publish its native thread. */
17
20
  const CODEX_THREAD_START_TIMEOUT_MS = 30_000;
@@ -54,6 +57,68 @@ const TRAEX_ESCAPE_CONFIRM_MS = 1_000;
54
57
  const TRAEX_ESCAPE_COOLDOWN_MS = 1_000;
55
58
  const TRAEX_REPEAT_PROMPT_CONFIRM_MS = 5_000;
56
59
  const MIRROR_IMAGE_ACK_TIMEOUT_MS = 30_000;
60
+ const MIRROR_ACK_TIMEOUT_MS = 30_000;
61
+ const MIRROR_DEAD_LETTER_MAX_BYTES = 50 * 1024 * 1024;
62
+ class MirrorDeliveryError extends Error {
63
+ classification;
64
+ deliveryId;
65
+ constructor(message, classification, deliveryId) {
66
+ super(message);
67
+ this.classification = classification;
68
+ this.deliveryId = deliveryId;
69
+ this.name = "MirrorDeliveryError";
70
+ }
71
+ }
72
+ class MirrorGenerationSupersededError extends Error {
73
+ generation;
74
+ deliveryId;
75
+ constructor(generation, deliveryId) {
76
+ super(`mirror generation ${generation} was superseded by Session rotation`);
77
+ this.generation = generation;
78
+ this.deliveryId = deliveryId;
79
+ this.name = "MirrorGenerationSupersededError";
80
+ }
81
+ }
82
+ /** Delivery retry schedule: 1, 2, 4, 8, 16, 30, 30… seconds.
83
+ * Clamp before exponentiation so an indefinitely transient outage cannot
84
+ * overflow after thousands of attempts. */
85
+ export function mirrorRetryDelayMs(failedAttempt) {
86
+ const exponent = Math.min(Math.max(failedAttempt - 1, 0), 32);
87
+ return Math.min(2 ** exponent, 30) * 1_000;
88
+ }
89
+ function defaultMirrorDeliveryPolicy(event) {
90
+ switch (event.type) {
91
+ case "response.output_item.done":
92
+ case "session.input.consumed":
93
+ case "session.interaction.requested":
94
+ case "session.interaction.resolved":
95
+ case "session.interaction.cancelled":
96
+ return "ordinary";
97
+ default:
98
+ return "best-effort";
99
+ }
100
+ }
101
+ function appendMirrorDeadLetter(path, entry) {
102
+ const line = `${JSON.stringify(entry)}\n`;
103
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
104
+ let currentBytes = 0;
105
+ try {
106
+ currentBytes = statSync(path).size;
107
+ }
108
+ catch {
109
+ // absent
110
+ }
111
+ if (currentBytes >= MIRROR_DEAD_LETTER_MAX_BYTES) {
112
+ rmSync(`${path}.1`, { force: true });
113
+ try {
114
+ renameSync(path, `${path}.1`);
115
+ }
116
+ catch {
117
+ // absent/raced — append creates the active file below
118
+ }
119
+ }
120
+ appendFileSync(path, line, { mode: 0o600 });
121
+ }
57
122
  function normalizeTraexPane(pane) {
58
123
  return pane.toLowerCase().replace(/\s+/g, " ").trim();
59
124
  }
@@ -133,11 +198,17 @@ export class RunnerSession {
133
198
  cancelledTerminalOpens = new Set();
134
199
  /** Session ids with a live codex forwarder started here (stopped on shutdown). */
135
200
  liveIds = new Set();
136
- mirrorQueue = Promise.resolve();
201
+ mirrorQueues = new Map();
202
+ mirrorAcks = new Map();
137
203
  mirrorImageAcks = new Map();
204
+ mirrorRetryWaiters = new Map();
205
+ rotationPublishAcks = new Map();
206
+ rotationAppliedAcks = new Map();
138
207
  /** Provider name retained for asynchronous Terminal-exit diagnostics. */
139
208
  liveRuntimes = new Map();
140
209
  terminalStatuses = new Map();
210
+ consecutiveMirrorFailures = 0;
211
+ mirrorDegraded = false;
141
212
  shuttingDown = false;
142
213
  shutdownPromise = null;
143
214
  constructor({ transport, executor, onShutdown }) {
@@ -150,16 +221,39 @@ export class RunnerSession {
150
221
  });
151
222
  this.transport.onMessage((msg) => this.handle(msg));
152
223
  this.transport.onClose((error) => {
224
+ this.rejectMirrorAcks(new MirrorDeliveryError(error?.message ?? "runner transport closed before mirror acknowledgement", "ambiguous"));
153
225
  this.rejectMirrorImageAcks(error ?? new Error("runner transport closed"));
226
+ this.rejectRotationAcks(error ?? new Error("runner transport closed"));
154
227
  });
155
228
  this.transport.send({ t: "ready" });
156
229
  }
157
230
  handle(msg) {
158
231
  switch (msg.t) {
232
+ case "mirror.ack": {
233
+ const pending = this.mirrorAcks.get(msg.deliveryId);
234
+ if (!pending || msg.generation !== pending.generation) {
235
+ return;
236
+ }
237
+ clearTimeout(pending.timer);
238
+ this.mirrorAcks.delete(msg.deliveryId);
239
+ pending.resolve();
240
+ return;
241
+ }
242
+ case "mirror.nack": {
243
+ const pending = this.mirrorAcks.get(msg.deliveryId);
244
+ if (!pending || msg.generation !== pending.generation) {
245
+ return;
246
+ }
247
+ clearTimeout(pending.timer);
248
+ this.mirrorAcks.delete(msg.deliveryId);
249
+ pending.reject(new MirrorDeliveryError(msg.message, msg.classification, msg.deliveryId));
250
+ return;
251
+ }
159
252
  case "mirror.image.ack": {
160
253
  const pending = this.mirrorImageAcks.get(msg.transferId);
161
- if (!pending)
254
+ if (!pending || msg.generation !== pending.generation) {
162
255
  return;
256
+ }
163
257
  if (pending.seq !== msg.seq) {
164
258
  pending.reject(new Error("generated image acknowledgement sequence changed"));
165
259
  return;
@@ -172,13 +266,54 @@ export class RunnerSession {
172
266
  }
173
267
  case "mirror.image.hold": {
174
268
  const pending = this.mirrorImageAcks.get(msg.transferId);
175
- if (!pending || pending.seq !== msg.seq)
269
+ if (!pending ||
270
+ pending.seq !== msg.seq ||
271
+ msg.generation !== pending.generation)
176
272
  return;
177
273
  if (pending.timer)
178
274
  clearTimeout(pending.timer);
179
275
  pending.timer = undefined;
180
276
  return;
181
277
  }
278
+ case "mirror.image.nack": {
279
+ const pending = this.mirrorImageAcks.get(msg.transferId);
280
+ if (!pending ||
281
+ pending.seq !== msg.seq ||
282
+ msg.generation !== pending.generation)
283
+ return;
284
+ if (pending.timer)
285
+ clearTimeout(pending.timer);
286
+ this.mirrorImageAcks.delete(msg.transferId);
287
+ pending.reject(new MirrorDeliveryError(msg.message, msg.classification));
288
+ return;
289
+ }
290
+ case "rotate.ack": {
291
+ const pending = this.rotationPublishAcks.get(msg.rotationId);
292
+ if (!pending || pending.generation !== msg.generation)
293
+ return;
294
+ clearTimeout(pending.timer);
295
+ this.rotationPublishAcks.delete(msg.rotationId);
296
+ pending.resolve();
297
+ return;
298
+ }
299
+ case "rotate.nack": {
300
+ const pending = this.rotationPublishAcks.get(msg.rotationId);
301
+ if (!pending || pending.generation !== msg.generation)
302
+ return;
303
+ clearTimeout(pending.timer);
304
+ this.rotationPublishAcks.delete(msg.rotationId);
305
+ pending.reject(new MirrorDeliveryError(msg.message, msg.classification));
306
+ return;
307
+ }
308
+ case "rotate.applied.ack": {
309
+ const pending = this.rotationAppliedAcks.get(msg.rotationId);
310
+ if (!pending || pending.generation !== msg.generation)
311
+ return;
312
+ clearTimeout(pending.timer);
313
+ this.rotationAppliedAcks.delete(msg.rotationId);
314
+ pending.resolve();
315
+ return;
316
+ }
182
317
  case "cap":
183
318
  void this.runCap(msg.capId, msg.name, msg.args);
184
319
  return;
@@ -283,29 +418,109 @@ export class RunnerSession {
283
418
  * new session AND tells the daemon to alias the runner (terminal transfer) so
284
419
  * the new session stays injectable. */
285
420
  mirrorChannel(localThreadId) {
286
- const target = { id: localThreadId };
287
- const emit = (event) => {
421
+ const target = { id: localThreadId, generation: 0 };
422
+ let pendingRetarget;
423
+ const emit = (event, delivery) => {
288
424
  const sessionId = target.id;
425
+ const generation = target.generation;
289
426
  if (event.type === "session.status") {
290
427
  this.terminalStatuses.set(sessionId, event.status);
291
428
  }
292
- this.enqueueMirror(() => this.sendMirroredEvent(sessionId, event));
429
+ return this.enqueueMirror(delivery?.lane ?? "main", () => this.sendMirroredEvent(sessionId, event, delivery, generation, () => generation === target.generation));
293
430
  };
294
431
  const retarget = (newId, meta) => {
295
- const previousId = target.id;
296
- this.transferNativeSessionResources(previousId, newId);
297
- target.id = newId;
298
- this.enqueueMirror(() => {
299
- this.transport.send({
300
- t: "rotate",
301
- from: previousId,
432
+ let publication = pendingRetarget;
433
+ if (!publication && target.id === newId)
434
+ return Promise.resolve();
435
+ if (publication && publication.to !== newId) {
436
+ return Promise.reject(new Error(`Session rotation to ${publication.to} is still pending; cannot retarget to ${newId}`));
437
+ }
438
+ if (!publication) {
439
+ const previousGeneration = target.generation;
440
+ publication = {
441
+ from: target.id,
302
442
  to: newId,
303
- kind: meta.kind,
304
- workspace: meta.workspace,
305
- execution: meta.execution,
306
- ...(meta.parentSessionId ? { parentSessionId: meta.parentSessionId } : {}),
443
+ generation: previousGeneration + 1,
444
+ rotationId: `rotate_${randomUUID()}`,
445
+ meta,
446
+ };
447
+ pendingRetarget = publication;
448
+ // Fence every source delivery immediately, but do not mutate the target
449
+ // identity or transfer resources until the parent ACKs publication.
450
+ target.generation = publication.generation;
451
+ this.cancelMirrorGeneration(previousGeneration);
452
+ }
453
+ if (publication.operation)
454
+ return publication.operation;
455
+ const current = publication;
456
+ const operation = this.enqueueMirror("control", async () => {
457
+ if (!current.resourcesApplied) {
458
+ let attempt = 0;
459
+ let publicationAcked = false;
460
+ while (!publicationAcked) {
461
+ attempt += 1;
462
+ try {
463
+ await this.sendRotationFrame(current.rotationId, current.generation, {
464
+ t: "rotate",
465
+ rotationId: current.rotationId,
466
+ generation: current.generation,
467
+ from: current.from,
468
+ to: current.to,
469
+ kind: current.meta.kind,
470
+ workspace: current.meta.workspace,
471
+ execution: current.meta.execution,
472
+ ...(current.meta.parentSessionId
473
+ ? { parentSessionId: current.meta.parentSessionId }
474
+ : {}),
475
+ });
476
+ publicationAcked = true;
477
+ }
478
+ catch (error) {
479
+ const failure = error instanceof MirrorDeliveryError
480
+ ? error
481
+ : new MirrorDeliveryError(error instanceof Error ? error.message : String(error), "transient");
482
+ if (failure.classification === "permanent" && attempt >= 3) {
483
+ // The parent still owns source/target reservations for this
484
+ // half-published cutover. End the child so failHandle releases
485
+ // them; leaving a live fenced pane would deadlock both ids.
486
+ await this.shutdown();
487
+ throw failure;
488
+ }
489
+ await this.waitForMirrorRetry(attempt, current.generation);
490
+ }
491
+ }
492
+ if (pendingRetarget !== current)
493
+ return;
494
+ this.transferNativeSessionResources(current.from, current.to);
495
+ target.id = current.to;
496
+ current.resourcesApplied = true;
497
+ }
498
+ if (pendingRetarget !== current)
499
+ return;
500
+ if (!current.hostApplied) {
501
+ await current.meta.beforeApplied?.();
502
+ if (pendingRetarget !== current)
503
+ return;
504
+ current.hostApplied = true;
505
+ }
506
+ // Ordered after the actual child-side ownership transfer. The parent
507
+ // keeps source and target admissions reserved until it observes this.
508
+ await this.sendRotationAppliedFrame(current.rotationId, current.generation, {
509
+ t: "rotate.applied",
510
+ rotationId: current.rotationId,
511
+ generation: current.generation,
512
+ from: current.from,
513
+ to: current.to,
307
514
  });
515
+ if (pendingRetarget !== current)
516
+ return;
517
+ pendingRetarget = undefined;
518
+ }, { recoverable: true }).finally(() => {
519
+ if (pendingRetarget === current)
520
+ delete current.operation;
308
521
  });
522
+ current.operation = operation;
523
+ return operation;
309
524
  };
310
525
  return { emit, retarget };
311
526
  }
@@ -339,62 +554,386 @@ export class RunnerSession {
339
554
  if (terminal)
340
555
  this.watchNativeTerminal(targetId, targetTerminalId, terminal);
341
556
  }
342
- enqueueMirror(operation) {
343
- this.mirrorQueue = this.mirrorQueue.then(operation).catch((error) => {
344
- if (!this.shuttingDown) {
557
+ enqueueMirror(lane, operation, options = {}) {
558
+ const previous = this.mirrorQueues.get(lane) ?? Promise.resolve();
559
+ const queued = previous.then(operation);
560
+ const tail = queued.catch((error) => {
561
+ if (!this.shuttingDown && !options.recoverable) {
345
562
  this.rejectMirrorImageAcks(error instanceof Error ? error : new Error(String(error)));
346
563
  void this.shutdown();
347
564
  }
348
565
  });
566
+ this.mirrorQueues.set(lane, tail);
567
+ void tail.finally(() => {
568
+ if (this.mirrorQueues.get(lane) === tail)
569
+ this.mirrorQueues.delete(lane);
570
+ });
571
+ return queued;
349
572
  }
350
- async sendMirroredEvent(sessionId, event) {
351
- const detached = detachGeneratedImageResult(event);
352
- if (!detached) {
353
- this.transport.send({ t: "mirror", sessionId, event });
573
+ async sendMirroredEvent(sessionId, event, delivery, generation = 0, isCurrent = () => true) {
574
+ if (!isCurrent())
354
575
  return;
576
+ try {
577
+ const detached = detachGeneratedImageResult(event);
578
+ if (!detached) {
579
+ await this.deliverMirrorEvent(sessionId, event, delivery, generation, isCurrent);
580
+ return;
581
+ }
582
+ if (detached.result.length > RUNNER_IMAGE_MAX_RESULT_CHARS) {
583
+ await this.deliverMirrorEvent(sessionId, generatedImagePreviewUnavailable(detached.event), delivery, generation, isCurrent);
584
+ return;
585
+ }
586
+ await this.deliverMirrorImageEvent(sessionId, detached.event, detached.result, delivery, generation, isCurrent);
355
587
  }
356
- if (detached.result.length > RUNNER_IMAGE_MAX_RESULT_CHARS) {
357
- this.transport.send({
358
- t: "mirror",
359
- sessionId,
360
- event: generatedImagePreviewUnavailable(detached.event),
361
- });
362
- return;
588
+ catch (error) {
589
+ if (error instanceof MirrorGenerationSupersededError)
590
+ return;
591
+ throw error;
363
592
  }
593
+ }
594
+ async deliverMirrorImageEvent(sessionId, event, result, delivery, generation, isCurrent) {
364
595
  const transferId = `img_${randomUUID()}`;
365
- let seq = 0;
366
- await this.sendMirrorImageFrame(transferId, seq, {
367
- t: "mirror.image.begin",
596
+ const abandon = () => this.transport.sendAndDrain({
597
+ t: "mirror.image.abandon",
368
598
  transferId,
369
- sessionId,
370
- event: detached.event,
371
- totalChars: detached.result.length,
599
+ generation,
600
+ }).catch(() => {
601
+ // Parent/child teardown already releases process-local transfer state.
372
602
  });
373
- for (let offset = 0; offset < detached.result.length; offset += RUNNER_IMAGE_CHUNK_CHARS) {
374
- seq += 1;
375
- await this.sendMirrorImageFrame(transferId, seq, {
376
- t: "mirror.image.chunk",
603
+ let seq = 0;
604
+ try {
605
+ await this.deliverMirrorImageTransportFrame(transferId, seq, generation, {
606
+ t: "mirror.image.begin",
377
607
  transferId,
378
- seq,
379
- data: detached.result.slice(offset, offset + RUNNER_IMAGE_CHUNK_CHARS),
380
- });
608
+ sessionId,
609
+ event,
610
+ totalChars: result.length,
611
+ generation,
612
+ }, isCurrent);
613
+ for (let offset = 0; offset < result.length; offset += RUNNER_IMAGE_CHUNK_CHARS) {
614
+ if (!isCurrent())
615
+ throw new MirrorGenerationSupersededError(generation);
616
+ seq += 1;
617
+ await this.deliverMirrorImageTransportFrame(transferId, seq, generation, {
618
+ t: "mirror.image.chunk",
619
+ transferId,
620
+ seq,
621
+ data: result.slice(offset, offset + RUNNER_IMAGE_CHUNK_CHARS),
622
+ generation,
623
+ }, isCurrent);
624
+ }
625
+ }
626
+ catch (error) {
627
+ await abandon();
628
+ throw error;
381
629
  }
382
630
  seq += 1;
383
- await this.sendMirrorImageFrame(transferId, seq, {
384
- t: "mirror.image.commit",
385
- transferId,
386
- seq,
631
+ const policy = delivery?.policy ?? defaultMirrorDeliveryPolicy(event);
632
+ let attempt = 0;
633
+ while (!this.shuttingDown) {
634
+ if (!isCurrent()) {
635
+ delivery?.onOutcome?.("superseded");
636
+ await abandon();
637
+ return;
638
+ }
639
+ attempt += 1;
640
+ try {
641
+ await this.sendMirrorImageFrame(transferId, seq, generation, {
642
+ t: "mirror.image.commit",
643
+ transferId,
644
+ seq,
645
+ generation,
646
+ }, "ambiguous");
647
+ this.noteMirrorDeliveryRecovered();
648
+ delivery?.onOutcome?.("confirmed");
649
+ return;
650
+ }
651
+ catch (error) {
652
+ if (error instanceof MirrorGenerationSupersededError || !isCurrent()) {
653
+ delivery?.onOutcome?.("superseded");
654
+ await abandon();
655
+ return;
656
+ }
657
+ if (this.shuttingDown)
658
+ break;
659
+ const failure = error instanceof MirrorDeliveryError
660
+ ? error
661
+ : new MirrorDeliveryError(error instanceof Error ? error.message : String(error), "ambiguous");
662
+ this.noteMirrorDeliveryFailed(failure);
663
+ if (policy === "best-effort") {
664
+ delivery?.onOutcome?.("dropped");
665
+ await abandon();
666
+ return;
667
+ }
668
+ if (failure.classification === "ambiguous" &&
669
+ (policy === "ordinary" || policy === "compaction" || policy === "compaction-hook")) {
670
+ this.noteMirrorDeliveryRecovered();
671
+ delivery?.onOutcome?.("ambiguous");
672
+ await abandon();
673
+ return;
674
+ }
675
+ if (failure.classification === "permanent" &&
676
+ policy !== "compaction" &&
677
+ attempt >= 3) {
678
+ if (policy === "ordinary" && delivery?.deadLetterPath) {
679
+ try {
680
+ appendMirrorDeadLetter(delivery.deadLetterPath, {
681
+ ts: Date.now() / 1_000,
682
+ session_id: sessionId,
683
+ event_type: "session_event",
684
+ reason: "permanent IPC persistence failure after retries",
685
+ delivered_ambiguous: false,
686
+ http_status: null,
687
+ transport_error: null,
688
+ payload: {
689
+ source_id: delivery.sourceId,
690
+ attempts: attempt,
691
+ event,
692
+ generated_image_result: result,
693
+ },
694
+ });
695
+ }
696
+ catch (deadLetterError) {
697
+ // The dead-letter writer is observability-only and never
698
+ // turns an exhausted poison item into a runner-wide failure.
699
+ console.error("[mirror] failed to append dead letter:", deadLetterError);
700
+ }
701
+ }
702
+ delivery?.onOutcome?.("dropped");
703
+ await abandon();
704
+ return;
705
+ }
706
+ await this.waitForMirrorRetry(attempt, generation);
707
+ }
708
+ }
709
+ await abandon();
710
+ throw new MirrorDeliveryError("runner shut down before generated image delivery was handled", "transient");
711
+ }
712
+ async deliverMirrorImageTransportFrame(transferId, seq, generation, frame, isCurrent) {
713
+ let attempt = 0;
714
+ while (!this.shuttingDown) {
715
+ if (!isCurrent())
716
+ throw new MirrorGenerationSupersededError(generation);
717
+ attempt += 1;
718
+ try {
719
+ await this.sendMirrorImageFrame(transferId, seq, generation, frame, "transient");
720
+ return;
721
+ }
722
+ catch (error) {
723
+ if (error instanceof MirrorGenerationSupersededError || !isCurrent())
724
+ throw error;
725
+ const failure = error instanceof MirrorDeliveryError
726
+ ? error
727
+ : new MirrorDeliveryError(error instanceof Error ? error.message : String(error), "transient");
728
+ this.noteMirrorDeliveryFailed(failure);
729
+ await this.waitForMirrorRetry(attempt, generation);
730
+ }
731
+ }
732
+ throw new MirrorDeliveryError("runner shut down during generated image transfer", "transient");
733
+ }
734
+ async deliverMirrorEvent(sessionId, event, delivery, generation = 0, isCurrent = () => true) {
735
+ const policy = delivery?.policy ?? defaultMirrorDeliveryPolicy(event);
736
+ let attempt = 0;
737
+ let lastFailedDeliveryId;
738
+ while (!this.shuttingDown) {
739
+ if (!isCurrent()) {
740
+ delivery?.onOutcome?.("superseded");
741
+ await this.abandonMirrorDelivery(lastFailedDeliveryId, generation);
742
+ return;
743
+ }
744
+ attempt += 1;
745
+ try {
746
+ await this.sendMirrorEventFrame(sessionId, event, generation, attempt);
747
+ this.noteMirrorDeliveryRecovered();
748
+ delivery?.onOutcome?.("confirmed");
749
+ return;
750
+ }
751
+ catch (error) {
752
+ if (error instanceof MirrorGenerationSupersededError || !isCurrent()) {
753
+ delivery?.onOutcome?.("superseded");
754
+ await this.abandonMirrorDelivery(error instanceof MirrorGenerationSupersededError
755
+ ? error.deliveryId
756
+ : error instanceof MirrorDeliveryError
757
+ ? error.deliveryId
758
+ : lastFailedDeliveryId, generation);
759
+ return;
760
+ }
761
+ if (this.shuttingDown)
762
+ break;
763
+ const failure = error instanceof MirrorDeliveryError
764
+ ? error
765
+ : new MirrorDeliveryError(error instanceof Error ? error.message : String(error), "ambiguous");
766
+ lastFailedDeliveryId = failure.deliveryId;
767
+ this.noteMirrorDeliveryFailed(failure);
768
+ if (policy === "best-effort") {
769
+ console.warn(`[mirror] best-effort event dropped: ${failure.message}`);
770
+ delivery?.onOutcome?.("dropped");
771
+ return;
772
+ }
773
+ if (failure.classification === "ambiguous" &&
774
+ (policy === "ordinary" || policy === "compaction" || policy === "compaction-hook")) {
775
+ console.warn(`[mirror] ordinary delivery result is ambiguous; treating as handled: ${failure.message}`);
776
+ this.noteMirrorDeliveryRecovered();
777
+ delivery?.onOutcome?.("ambiguous");
778
+ await this.abandonMirrorDelivery(failure.deliveryId, generation);
779
+ return;
780
+ }
781
+ if (failure.classification === "permanent" &&
782
+ policy !== "compaction" &&
783
+ attempt >= 3) {
784
+ if (policy === "ordinary" && delivery?.deadLetterPath) {
785
+ try {
786
+ appendMirrorDeadLetter(delivery.deadLetterPath, {
787
+ ts: Date.now() / 1_000,
788
+ session_id: sessionId,
789
+ event_type: "session_event",
790
+ reason: "permanent IPC persistence failure after retries",
791
+ delivered_ambiguous: false,
792
+ http_status: null,
793
+ transport_error: null,
794
+ payload: {
795
+ source_id: delivery.sourceId,
796
+ attempts: attempt,
797
+ event,
798
+ },
799
+ });
800
+ }
801
+ catch (deadLetterError) {
802
+ console.error("[mirror] failed to append dead letter:", deadLetterError);
803
+ }
804
+ }
805
+ console.error(`[mirror] permanent delivery failed after ${attempt} attempts; skipping: ${failure.message}`);
806
+ delivery?.onOutcome?.("dropped");
807
+ await this.abandonMirrorDelivery(failure.deliveryId, generation);
808
+ return;
809
+ }
810
+ await this.waitForMirrorRetry(attempt, generation);
811
+ }
812
+ }
813
+ throw new MirrorDeliveryError("runner shut down before mirror delivery was handled", "transient");
814
+ }
815
+ noteMirrorDeliveryFailed(error) {
816
+ this.consecutiveMirrorFailures += 1;
817
+ if (!this.mirrorDegraded && this.consecutiveMirrorFailures >= 5) {
818
+ this.mirrorDegraded = true;
819
+ console.warn(`[mirror] delivery degraded after ${this.consecutiveMirrorFailures} consecutive failures: ${error.message}`);
820
+ }
821
+ }
822
+ noteMirrorDeliveryRecovered() {
823
+ if (this.mirrorDegraded)
824
+ console.info("[mirror] delivery recovered");
825
+ this.consecutiveMirrorFailures = 0;
826
+ this.mirrorDegraded = false;
827
+ }
828
+ sendMirrorEventFrame(sessionId, event, generation, attempt) {
829
+ const deliveryId = `mirror_${randomUUID()}`;
830
+ return new Promise((resolve, reject) => {
831
+ const timer = setTimeout(() => {
832
+ this.mirrorAcks.delete(deliveryId);
833
+ reject(new MirrorDeliveryError("mirror acknowledgement timed out", "ambiguous", deliveryId));
834
+ }, MIRROR_ACK_TIMEOUT_MS);
835
+ timer.unref?.();
836
+ this.mirrorAcks.set(deliveryId, { generation, resolve, reject, timer });
837
+ void this.transport.sendAndDrain({
838
+ t: "mirror",
839
+ deliveryId,
840
+ generation,
841
+ attempt,
842
+ sessionId,
843
+ event,
844
+ }).catch((error) => {
845
+ const pending = this.mirrorAcks.get(deliveryId);
846
+ if (!pending)
847
+ return;
848
+ clearTimeout(pending.timer);
849
+ this.mirrorAcks.delete(deliveryId);
850
+ const classification = error instanceof RunnerTransportWriteError &&
851
+ error.delivery === "not-sent"
852
+ ? "transient"
853
+ : "ambiguous";
854
+ pending.reject(new MirrorDeliveryError(error instanceof Error ? error.message : String(error), classification, deliveryId));
855
+ });
856
+ });
857
+ }
858
+ async abandonMirrorDelivery(deliveryId, generation) {
859
+ if (!deliveryId)
860
+ return;
861
+ await this.transport.sendAndDrain({
862
+ t: "mirror.abandon",
863
+ deliveryId,
864
+ generation,
865
+ }).catch(() => {
866
+ // Parent/child teardown already releases process-local projection state.
387
867
  });
388
868
  }
389
- sendMirrorImageFrame(transferId, seq, frame) {
869
+ waitForMirrorRetry(failedAttempt, generation) {
870
+ return new Promise((resolve) => {
871
+ const waiters = this.mirrorRetryWaiters.get(generation) ?? new Set();
872
+ let timer;
873
+ const done = () => {
874
+ clearTimeout(timer);
875
+ waiters.delete(done);
876
+ if (waiters.size === 0)
877
+ this.mirrorRetryWaiters.delete(generation);
878
+ resolve();
879
+ };
880
+ timer = setTimeout(done, mirrorRetryDelayMs(failedAttempt));
881
+ timer.unref?.();
882
+ waiters.add(done);
883
+ this.mirrorRetryWaiters.set(generation, waiters);
884
+ });
885
+ }
886
+ cancelMirrorGeneration(generation) {
887
+ const error = new MirrorGenerationSupersededError(generation);
888
+ for (const [deliveryId, pending] of this.mirrorAcks) {
889
+ if (pending.generation !== generation)
890
+ continue;
891
+ clearTimeout(pending.timer);
892
+ this.mirrorAcks.delete(deliveryId);
893
+ pending.reject(new MirrorGenerationSupersededError(generation, deliveryId));
894
+ }
895
+ for (const [transferId, pending] of this.mirrorImageAcks) {
896
+ if (pending.generation !== generation)
897
+ continue;
898
+ if (pending.timer)
899
+ clearTimeout(pending.timer);
900
+ this.mirrorImageAcks.delete(transferId);
901
+ pending.reject(error);
902
+ }
903
+ for (const wake of this.mirrorRetryWaiters.get(generation) ?? [])
904
+ wake();
905
+ this.mirrorRetryWaiters.delete(generation);
906
+ }
907
+ rejectMirrorAcks(error) {
908
+ for (const pending of this.mirrorAcks.values()) {
909
+ clearTimeout(pending.timer);
910
+ pending.reject(error);
911
+ }
912
+ this.mirrorAcks.clear();
913
+ }
914
+ sendMirrorImageFrame(transferId, seq, generation, frame, timeoutClassification = "transient") {
390
915
  return new Promise((resolve, reject) => {
916
+ const pending = { generation, seq, resolve, reject };
391
917
  const timer = setTimeout(() => {
918
+ if (this.mirrorImageAcks.get(transferId) !== pending)
919
+ return;
392
920
  this.mirrorImageAcks.delete(transferId);
393
- reject(new Error("generated image transfer acknowledgement timed out"));
921
+ reject(new MirrorDeliveryError("generated image transfer acknowledgement timed out", timeoutClassification));
394
922
  }, MIRROR_IMAGE_ACK_TIMEOUT_MS);
395
923
  timer.unref?.();
396
- this.mirrorImageAcks.set(transferId, { seq, resolve, reject, timer });
397
- this.transport.send(frame);
924
+ pending.timer = timer;
925
+ this.mirrorImageAcks.set(transferId, pending);
926
+ void this.transport.sendAndDrain(frame).catch((error) => {
927
+ if (this.mirrorImageAcks.get(transferId) !== pending)
928
+ return;
929
+ clearTimeout(timer);
930
+ this.mirrorImageAcks.delete(transferId);
931
+ const classification = timeoutClassification === "ambiguous" &&
932
+ (!(error instanceof RunnerTransportWriteError) || error.delivery === "ambiguous")
933
+ ? "ambiguous"
934
+ : "transient";
935
+ reject(new MirrorDeliveryError(error instanceof Error ? error.message : String(error), classification));
936
+ });
398
937
  });
399
938
  }
400
939
  rejectMirrorImageAcks(error) {
@@ -405,6 +944,40 @@ export class RunnerSession {
405
944
  }
406
945
  this.mirrorImageAcks.clear();
407
946
  }
947
+ sendRotationFrame(rotationId, generation, frame) {
948
+ return new Promise((resolve, reject) => {
949
+ const timer = setTimeout(() => {
950
+ this.rotationPublishAcks.delete(rotationId);
951
+ reject(new Error("Session rotation acknowledgement timed out"));
952
+ }, MIRROR_ACK_TIMEOUT_MS);
953
+ timer.unref?.();
954
+ this.rotationPublishAcks.set(rotationId, { generation, resolve, reject, timer });
955
+ this.transport.send(frame);
956
+ });
957
+ }
958
+ sendRotationAppliedFrame(rotationId, generation, frame) {
959
+ return new Promise((resolve, reject) => {
960
+ const timer = setTimeout(() => {
961
+ this.rotationAppliedAcks.delete(rotationId);
962
+ reject(new Error("Session rotation applied acknowledgement timed out"));
963
+ }, MIRROR_ACK_TIMEOUT_MS);
964
+ timer.unref?.();
965
+ this.rotationAppliedAcks.set(rotationId, { generation, resolve, reject, timer });
966
+ this.transport.send(frame);
967
+ });
968
+ }
969
+ rejectRotationAcks(error) {
970
+ for (const pending of this.rotationPublishAcks.values()) {
971
+ clearTimeout(pending.timer);
972
+ pending.reject(error);
973
+ }
974
+ this.rotationPublishAcks.clear();
975
+ for (const pending of this.rotationAppliedAcks.values()) {
976
+ clearTimeout(pending.timer);
977
+ pending.reject(error);
978
+ }
979
+ this.rotationAppliedAcks.clear();
980
+ }
408
981
  /**
409
982
  * Eagerly bring up a session's codex-native live view. Fresh sessions connect
410
983
  * the discovery listener before launching the detached TUI; known-thread
@@ -819,7 +1392,7 @@ export class RunnerSession {
819
1392
  this.liveRuntimes.delete(localThreadId);
820
1393
  this.terminalStatuses.delete(localThreadId);
821
1394
  }
822
- this.enqueueMirror(() => {
1395
+ this.enqueueMirror("main", () => {
823
1396
  this.transport.send({
824
1397
  t: "terminal.lifecycle.ended",
825
1398
  localThreadId,
@@ -984,7 +1557,15 @@ export class RunnerSession {
984
1557
  return this.shutdownPromise;
985
1558
  this.shuttingDown = true;
986
1559
  this.shutdownPromise = (async () => {
987
- this.rejectMirrorImageAcks(new Error("runner is shutting down"));
1560
+ const shutdownError = new Error("runner is shutting down");
1561
+ this.rejectMirrorAcks(shutdownError);
1562
+ this.rejectMirrorImageAcks(shutdownError);
1563
+ this.rejectRotationAcks(shutdownError);
1564
+ for (const waiters of this.mirrorRetryWaiters.values()) {
1565
+ for (const wake of waiters)
1566
+ wake();
1567
+ }
1568
+ this.mirrorRetryWaiters.clear();
988
1569
  this.stopLive();
989
1570
  for (const pending of this.preparations.values()) {
990
1571
  clearTimeout(pending.timer);