@opengeni/runtime 0.13.0 → 0.13.4

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.
@@ -30,6 +30,8 @@
30
30
  import type { ExposedPortEndpoint } from "../stream-port";
31
31
  import { SelfhostedControlError } from "../selfhosted/control-rpc";
32
32
  import { renderSelfhostedFault } from "../selfhosted/fault-rendering";
33
+ import { isExecSessionLostBanner } from "../channel-a";
34
+ import { parseExecBannerExitCode, parseExecBannerSessionId } from "../exec-banner";
33
35
 
34
36
  /** The per-session active-sandbox pointer the proxy re-reads on every op. Mirror
35
37
  * of `@opengeni/db`'s `ActiveSandboxPointer` (structural, so the leaf does not
@@ -94,8 +96,44 @@ export interface ResolvedActiveBackend {
94
96
  sandboxId: string | null;
95
97
  /** A label for diagnostics ("modal" | "selfhosted" | the sandbox name). */
96
98
  kind: string;
99
+ /** Exact durable home-lease epoch for a persistable provider. Absent for
100
+ * connected-machine and other non-persistable route targets. */
101
+ leaseEpoch?: number;
102
+ /** Exact durable provider identity paired with `leaseEpoch`. This is internal
103
+ * routing metadata only; it is never projected to an agent or public API. */
104
+ providerInstanceId?: string;
105
+ /** Active-pointer epoch observed when this route was resolved. The proxy fills
106
+ * this internally even when a resolver omits it. */
107
+ activeEpoch?: number;
97
108
  }
98
109
 
110
+ /** Durable identity assigned to a provider exec that yielded instead of
111
+ * exiting. The UUID is OpenGeni authority; the numeric provider session id is
112
+ * only a locator within the exact copied backend route. */
113
+ export type RoutingRetainedProcess = {
114
+ id: string;
115
+ providerSessionId: number;
116
+ };
117
+
118
+ /** Exact copied route identity required to reconstruct an already-durable
119
+ * retained process after the request/runtime object that opened it is gone.
120
+ * The live session is deliberately not caller-supplied: adoption may bind only
121
+ * to this proxy's construction-time default backend, so it cannot resolve or
122
+ * follow the current active pointer. */
123
+ export type RoutingRetainedProcessAdoption = {
124
+ process: RoutingRetainedProcess;
125
+ backend: {
126
+ sandboxId: string | null;
127
+ leaseEpoch?: number;
128
+ providerInstanceId?: string;
129
+ activeEpoch: number;
130
+ };
131
+ };
132
+
133
+ export type RoutingRetainedProcessTerminalProof =
134
+ | { outcome: "exited"; exitCode: number; reason: "provider_exit_banner" }
135
+ | { outcome: "lost"; exitCode: null; reason: "provider_session_lost_banner" };
136
+
99
137
  export interface RoutingSandboxSessionDeps {
100
138
  /**
101
139
  * The DEFAULT backend resolved at construction time (the same shape `resolve()`
@@ -127,8 +165,67 @@ export interface RoutingSandboxSessionDeps {
127
165
  maxFenceRetries?: number;
128
166
  /** Optional structured-log sink for swap/fence transitions (diagnostics). */
129
167
  onTransition?: (event: RoutingTransitionEvent) => void;
168
+ /** Admit a filesystem-writing operation after resolving its exact route but
169
+ * before invoking the provider. A rejection fails closed and is deliberately
170
+ * outside provider fence-retry/error handling, so it can never replay the op
171
+ * against a rival backend. */
172
+ beforeMutation?: (input: { op: string; backend: ResolvedActiveBackend }) => Promise<unknown>;
173
+ /** Mark a mutation physically settled after its provider promise resolves OR
174
+ * rejects. A resolved result is also revalidated against the same durable
175
+ * route and attempt fence before its output is accepted. Settlement rejection
176
+ * leaves a fail-closed blocker and the mutation must never be replayed. */
177
+ afterMutation?: (input: {
178
+ op: string;
179
+ backend: ResolvedActiveBackend;
180
+ admission: unknown;
181
+ outcome: "resolved" | "rejected";
182
+ /** Provider result for a resolved call. Never present for rejection. */
183
+ result?: unknown;
184
+ /** Stable candidate generated before durable promotion is attempted. It is
185
+ * supplied only when the provider returned a positive yielded-session id. */
186
+ retainedProcess?: RoutingRetainedProcess;
187
+ }) => Promise<void>;
188
+ /** Admit one model/user-visible stdin mutation under the already-durable
189
+ * retained process authority. Control polling and helper execs never call it. */
190
+ beforeProcessMutation?: (input: {
191
+ op: string;
192
+ backend: ResolvedActiveBackend;
193
+ process: RoutingRetainedProcess;
194
+ }) => Promise<unknown>;
195
+ /** Physically settle one process-scoped stdin admission. */
196
+ afterProcessMutation?: (input: {
197
+ op: string;
198
+ backend: ResolvedActiveBackend;
199
+ process: RoutingRetainedProcess;
200
+ admission: unknown;
201
+ outcome: "resolved" | "rejected";
202
+ result?: unknown;
203
+ }) => Promise<void>;
204
+ /** Close the durable parent admission/process holder only after exact exit or
205
+ * matching provider-loss proof. Tracking is removed only after this resolves. */
206
+ settleProcess?: (input: {
207
+ backend: ResolvedActiveBackend;
208
+ process: RoutingRetainedProcess;
209
+ proof: RoutingRetainedProcessTerminalProof;
210
+ }) => Promise<void>;
211
+ /** Called only when an operation against the default/home backend throws a
212
+ * non-fence error. Wiring may classify definitive provider disappearance and
213
+ * atomically retire the exact lease epoch. Returning a result makes dispatch
214
+ * throw a typed recovery-required error WITHOUT replaying the operation (the
215
+ * original mutation may have reached the provider). */
216
+ onDefaultBackendError?: (input: {
217
+ op: string;
218
+ error: unknown;
219
+ kind: string;
220
+ backend: ResolvedActiveBackend;
221
+ }) => Promise<DefaultBackendLossResult | null>;
130
222
  }
131
223
 
224
+ export type DefaultBackendLossResult = {
225
+ leaseEpoch: number;
226
+ recovery: "pending" | "degraded" | "unrecoverable" | "superseded";
227
+ };
228
+
132
229
  export interface RoutingTransitionEvent {
133
230
  type: "resolved" | "fenced-retry" | "epoch-changed";
134
231
  fromEpoch: number;
@@ -146,6 +243,71 @@ export class RoutingUnsupportedError extends Error {
146
243
  }
147
244
  }
148
245
 
246
+ export class RoutingBackendRecoveryRequiredError extends Error {
247
+ readonly name = "RoutingBackendRecoveryRequiredError";
248
+ readonly retryable: boolean;
249
+
250
+ constructor(
251
+ public readonly op: string,
252
+ public readonly leaseEpoch: number,
253
+ public readonly recovery: DefaultBackendLossResult["recovery"],
254
+ ) {
255
+ super(
256
+ `sandbox backend disappeared during ${op}; recovery is ${recovery} at epoch ${leaseEpoch}`,
257
+ );
258
+ this.retryable = recovery === "pending" || recovery === "superseded";
259
+ }
260
+ }
261
+
262
+ /** A mutating provider call was admitted but could not be settled against the
263
+ * exact route that admitted it. The provider may have applied the effect, so the
264
+ * proxy rejects the output and explicitly forbids an automatic replay. */
265
+ export class RoutingMutationOutcomeUnknownError extends Error {
266
+ readonly name = "RoutingMutationOutcomeUnknownError";
267
+ readonly retryable = false;
268
+
269
+ constructor(
270
+ public readonly op: string,
271
+ message: string,
272
+ options?: { cause?: unknown },
273
+ ) {
274
+ super(message, options);
275
+ }
276
+ }
277
+
278
+ /** An explicit process-aware operation named a numeric provider locator that
279
+ * is not retained by this routing session. Callers must never fall back to the
280
+ * current active pointer for such an operation. */
281
+ export class RoutingRetainedProcessNotFoundError extends Error {
282
+ readonly name = "RoutingRetainedProcessNotFoundError";
283
+ readonly retryable = false;
284
+
285
+ constructor(public readonly providerSessionId: number) {
286
+ super(`retained sandbox process ${providerSessionId} is not tracked on its original route`);
287
+ }
288
+ }
289
+
290
+ type PendingParentPromotion = Parameters<
291
+ NonNullable<RoutingSandboxSessionDeps["afterMutation"]>
292
+ >[0];
293
+
294
+ type PendingProcessMutationSettlement = Parameters<
295
+ NonNullable<RoutingSandboxSessionDeps["afterProcessMutation"]>
296
+ >[0];
297
+
298
+ type RetainedProcessRecord = {
299
+ process: RoutingRetainedProcess;
300
+ backend: ResolvedActiveBackend;
301
+ durable: boolean;
302
+ pendingParentPromotion: PendingParentPromotion | null;
303
+ pendingMutationSettlement: PendingProcessMutationSettlement | null;
304
+ pendingTerminal: {
305
+ proof: RoutingRetainedProcessTerminalProof;
306
+ result: string;
307
+ } | null;
308
+ settlement: Promise<void> | null;
309
+ };
310
+
149
311
  /** Recognize a stale-epoch FENCE error from a backend op so the proxy retries
150
312
  * against the re-resolved active sandbox (the existing fenced-retry role). A
151
313
  * selfhosted `SelfhostedControlError` carries `.fenced`; a generic fence is
@@ -165,6 +327,82 @@ function isFenceError(error: unknown): boolean {
165
327
  return haystack.includes("fenced") || (haystack.includes("epoch") && haystack.includes("super"));
166
328
  }
167
329
 
330
+ function positiveProviderSessionId(value: unknown): number | null {
331
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null;
332
+ }
333
+
334
+ function canonicalUuid(value: string): boolean {
335
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
336
+ }
337
+
338
+ function providerSessionIdFromResult(result: unknown): number | null {
339
+ if (typeof result === "string") {
340
+ return positiveProviderSessionId(parseExecBannerSessionId(result));
341
+ }
342
+ if (!result || typeof result !== "object") return null;
343
+ const record = result as { sessionId?: unknown; session_id?: unknown };
344
+ return (
345
+ positiveProviderSessionId(record.sessionId) ?? positiveProviderSessionId(record.session_id)
346
+ );
347
+ }
348
+
349
+ function providerSessionIdFromArgs(args: unknown): number | null {
350
+ if (!args || typeof args !== "object") return null;
351
+ const record = args as { sessionId?: unknown; session_id?: unknown };
352
+ return (
353
+ positiveProviderSessionId(record.sessionId) ?? positiveProviderSessionId(record.session_id)
354
+ );
355
+ }
356
+
357
+ function retainedProcessTerminalProof(
358
+ result: string,
359
+ providerSessionId: number,
360
+ ): RoutingRetainedProcessTerminalProof | null {
361
+ if (isExecSessionLostBanner(result, providerSessionId)) {
362
+ return {
363
+ outcome: "lost",
364
+ exitCode: null,
365
+ reason: "provider_session_lost_banner",
366
+ };
367
+ }
368
+ const exitCode = parseExecBannerExitCode(result);
369
+ return exitCode === null ? null : { outcome: "exited", exitCode, reason: "provider_exit_banner" };
370
+ }
371
+
372
+ function formatExecResult(result: unknown): string {
373
+ if (typeof result === "string") return result;
374
+ if (!result || typeof result !== "object") {
375
+ throw new Error("sandbox process-control exec returned an invalid result");
376
+ }
377
+ const record = result as {
378
+ output?: unknown;
379
+ stdout?: unknown;
380
+ stderr?: unknown;
381
+ exitCode?: unknown;
382
+ exit_code?: unknown;
383
+ sessionId?: unknown;
384
+ session_id?: unknown;
385
+ };
386
+ const output = [record.output, record.stderr, record.stdout]
387
+ .filter((value): value is string => typeof value === "string" && value.length > 0)
388
+ .join("\n");
389
+ const sessionId =
390
+ positiveProviderSessionId(record.sessionId) ?? positiveProviderSessionId(record.session_id);
391
+ if (sessionId !== null) {
392
+ return `Process running with session ID ${sessionId}\n\nOutput:\n${output}`;
393
+ }
394
+ const exitCode =
395
+ typeof record.exitCode === "number"
396
+ ? record.exitCode
397
+ : typeof record.exit_code === "number"
398
+ ? record.exit_code
399
+ : null;
400
+ if (exitCode !== null && Number.isSafeInteger(exitCode)) {
401
+ return `Process exited with code ${exitCode}\n\nOutput:\n${output}`;
402
+ }
403
+ throw new Error("sandbox process-control exec reported neither session id nor exit code");
404
+ }
405
+
168
406
  /**
169
407
  * ONE stable session-shaped object the SDK binds to. Every method re-reads the
170
408
  * pointer, resolves the active backend (cached by epoch), and dispatches. A
@@ -198,6 +436,10 @@ export class RoutingSandboxSession implements RoutableBackendSession {
198
436
  // The last-resolved backend, exposed via the `state` getter (a method-free read
199
437
  // of the active backend's `state`). Updated on every resolve.
200
438
  private lastResolved: ResolvedActiveBackend | undefined;
439
+ /** Provider session ids are scoped to one backend instance. Each entry copies
440
+ * that exact resolved route so pointer movement can never redirect stdin,
441
+ * polling, or process-group helpers to another box. */
442
+ private readonly retainedProcesses = new Map<number, RetainedProcessRecord>();
201
443
 
202
444
  // The native-desktop control-plane ops (self-hosted / macOS). Declared as OPTIONAL
203
445
  // INSTANCE fields — NOT prototype methods — because their PRESENCE is the selection
@@ -233,14 +475,14 @@ export class RoutingSandboxSession implements RoutableBackendSession {
233
475
  const def = deps.defaultResolved?.session;
234
476
  if (typeof def?.desktopInput === "function" && typeof def?.screenshot === "function") {
235
477
  this.desktopInput = (event: unknown) =>
236
- this.dispatch("desktopInput", async (s) => {
478
+ this.dispatch("desktopInput", true, async (s) => {
237
479
  if (!s.desktopInput) {
238
480
  throw new RoutingUnsupportedError("desktopInput", this.cached?.kind ?? "unknown");
239
481
  }
240
482
  return s.desktopInput(event);
241
483
  });
242
484
  this.screenshot = () =>
243
- this.dispatch("screenshot", async (s) => {
485
+ this.dispatch("screenshot", false, async (s) => {
244
486
  if (!s.screenshot) {
245
487
  throw new RoutingUnsupportedError("screenshot", this.cached?.kind ?? "unknown");
246
488
  }
@@ -296,10 +538,14 @@ export class RoutingSandboxSession implements RoutableBackendSession {
296
538
  "RoutingSandboxSession.resolveActiveBackend returned the proxy itself as the active backend (re-entrancy) — the resolver must return the underlying box session, not the routing proxy.",
297
539
  );
298
540
  }
541
+ const routed: ResolvedActiveBackend = {
542
+ ...resolved,
543
+ activeEpoch: pointer.activeEpoch,
544
+ };
299
545
  this.cachedEpoch = pointer.activeEpoch;
300
546
  this.cachedSandboxId = pointer.activeSandboxId;
301
- this.cached = resolved;
302
- this.lastResolved = resolved;
547
+ this.cached = routed;
548
+ this.lastResolved = routed;
303
549
  this.deps.onTransition?.({
304
550
  type:
305
551
  this.cachedEpoch !== undefined && fromEpoch !== pointer.activeEpoch
@@ -307,10 +553,283 @@ export class RoutingSandboxSession implements RoutableBackendSession {
307
553
  : "resolved",
308
554
  fromEpoch,
309
555
  toEpoch: pointer.activeEpoch,
310
- sandboxId: resolved.sandboxId,
311
- kind: resolved.kind,
556
+ sandboxId: routed.sandboxId,
557
+ kind: routed.kind,
558
+ });
559
+ return routed;
560
+ }
561
+
562
+ private invalidate(backend: ResolvedActiveBackend): void {
563
+ this.cachedEpoch = undefined;
564
+ this.cachedSandboxId = undefined;
565
+ this.cached = undefined;
566
+ this.deps.onTransition?.({
567
+ type: "fenced-retry",
568
+ fromEpoch: backend.activeEpoch ?? 0,
569
+ toEpoch: 0,
570
+ sandboxId: backend.sandboxId,
571
+ kind: backend.kind,
572
+ });
573
+ }
574
+
575
+ private registerRetainedProcess(
576
+ process: RoutingRetainedProcess,
577
+ backend: ResolvedActiveBackend,
578
+ ): RetainedProcessRecord {
579
+ if (this.retainedProcesses.has(process.providerSessionId)) {
580
+ throw new RoutingMutationOutcomeUnknownError(
581
+ "retainProcess",
582
+ `Provider session ${process.providerSessionId} was yielded while that locator was already retained; neither process was rebound`,
583
+ );
584
+ }
585
+ const record: RetainedProcessRecord = {
586
+ process,
587
+ backend,
588
+ durable: !this.deps.afterMutation,
589
+ pendingParentPromotion: null,
590
+ pendingMutationSettlement: null,
591
+ pendingTerminal: null,
592
+ settlement: null,
593
+ };
594
+ this.retainedProcesses.set(process.providerSessionId, record);
595
+ return record;
596
+ }
597
+
598
+ private retainedProcess(providerSessionId: number): RetainedProcessRecord {
599
+ const retained = this.retainedProcesses.get(providerSessionId);
600
+ if (!retained) throw new RoutingRetainedProcessNotFoundError(providerSessionId);
601
+ return retained;
602
+ }
603
+
604
+ /** Reconstruct one process that was already promoted durably by an earlier
605
+ * request. Adoption never reads the active pointer and never repeats parent
606
+ * promotion. It can bind only to the exact construction-time default backend;
607
+ * callers must reject non-default routes until they can independently rebuild
608
+ * that provider session from its copied durable identity. */
609
+ adoptRetainedProcess(input: RoutingRetainedProcessAdoption): void {
610
+ const providerSessionId = positiveProviderSessionId(input.process.providerSessionId);
611
+ const seed = this.deps.defaultResolved;
612
+ if (
613
+ providerSessionId === null ||
614
+ !canonicalUuid(input.process.id) ||
615
+ !seed ||
616
+ seed.sandboxId !== input.backend.sandboxId ||
617
+ seed.leaseEpoch !== input.backend.leaseEpoch ||
618
+ seed.providerInstanceId !== input.backend.providerInstanceId ||
619
+ !Number.isSafeInteger(input.backend.activeEpoch) ||
620
+ input.backend.activeEpoch < 0
621
+ ) {
622
+ throw new RoutingMutationOutcomeUnknownError(
623
+ "adoptRetainedProcess",
624
+ `Retained provider session ${input.process.providerSessionId} did not match the exact default backend identity`,
625
+ );
626
+ }
627
+ const existing = this.retainedProcesses.get(providerSessionId);
628
+ if (existing) {
629
+ if (
630
+ existing.process.id === input.process.id &&
631
+ existing.backend.session === seed.session &&
632
+ existing.backend.sandboxId === input.backend.sandboxId &&
633
+ existing.backend.leaseEpoch === input.backend.leaseEpoch &&
634
+ existing.backend.providerInstanceId === input.backend.providerInstanceId &&
635
+ existing.backend.activeEpoch === input.backend.activeEpoch
636
+ ) {
637
+ return;
638
+ }
639
+ throw new RoutingMutationOutcomeUnknownError(
640
+ "adoptRetainedProcess",
641
+ `Provider session ${providerSessionId} is already bound to a different retained process identity`,
642
+ );
643
+ }
644
+ this.retainedProcesses.set(providerSessionId, {
645
+ process: { ...input.process },
646
+ backend: {
647
+ ...seed,
648
+ activeEpoch: input.backend.activeEpoch,
649
+ },
650
+ durable: true,
651
+ pendingParentPromotion: null,
652
+ pendingMutationSettlement: null,
653
+ pendingTerminal: null,
654
+ settlement: null,
655
+ });
656
+ }
657
+
658
+ private async ensureParentPromotion(record: RetainedProcessRecord): Promise<void> {
659
+ if (record.durable) return;
660
+ const pending = record.pendingParentPromotion;
661
+ if (!pending || !this.deps.afterMutation) {
662
+ throw new RoutingMutationOutcomeUnknownError(
663
+ "retainProcess",
664
+ `Retained provider session ${record.process.providerSessionId} has no confirmed durable parent admission`,
665
+ );
666
+ }
667
+ try {
668
+ await this.deps.afterMutation(pending);
669
+ record.pendingParentPromotion = null;
670
+ record.durable = true;
671
+ } catch (error) {
672
+ throw new RoutingMutationOutcomeUnknownError(
673
+ pending.op,
674
+ `Yielded sandbox process ${record.process.providerSessionId} could not confirm its durable promotion; tracking remains pinned to the original backend`,
675
+ { cause: error },
676
+ );
677
+ }
678
+ }
679
+
680
+ private async settleRetainedProcess(
681
+ record: RetainedProcessRecord,
682
+ proof: RoutingRetainedProcessTerminalProof,
683
+ result: string,
684
+ ): Promise<void> {
685
+ record.pendingTerminal ??= { proof, result };
686
+ const pending = record.pendingTerminal;
687
+ if (
688
+ pending.proof.outcome !== proof.outcome ||
689
+ pending.proof.exitCode !== proof.exitCode ||
690
+ pending.proof.reason !== proof.reason
691
+ ) {
692
+ throw new RoutingMutationOutcomeUnknownError(
693
+ "settleProcess",
694
+ `Retained provider session ${record.process.providerSessionId} produced conflicting terminal proof`,
695
+ );
696
+ }
697
+ record.settlement ??= (async () => {
698
+ await this.ensureParentPromotion(record);
699
+ await this.deps.settleProcess?.({
700
+ backend: record.backend,
701
+ process: record.process,
702
+ proof: pending.proof,
703
+ });
704
+ this.retainedProcesses.delete(record.process.providerSessionId);
705
+ record.pendingTerminal = null;
706
+ })();
707
+ try {
708
+ await record.settlement;
709
+ } catch (error) {
710
+ // A failed DB settlement is not permission to forget the physical process.
711
+ // Keep the exact route and immutable proof so the next control poll retries
712
+ // settlement without issuing a command against a new backend.
713
+ record.settlement = null;
714
+ throw new RoutingMutationOutcomeUnknownError(
715
+ "settleProcess",
716
+ `Retained provider session ${record.process.providerSessionId} reached a terminal state but durable settlement failed; tracking remains pinned`,
717
+ { cause: error },
718
+ );
719
+ }
720
+ }
721
+
722
+ private async flushPendingProcessMutation(record: RetainedProcessRecord): Promise<string | null> {
723
+ const pending = record.pendingMutationSettlement;
724
+ if (!pending) return null;
725
+ try {
726
+ await this.deps.afterProcessMutation?.(pending);
727
+ record.pendingMutationSettlement = null;
728
+ } catch (error) {
729
+ throw new RoutingMutationOutcomeUnknownError(
730
+ pending.op,
731
+ `Retained-process mutation ${pending.op} still lacks durable physical settlement; no later process mutation was admitted`,
732
+ { cause: error },
733
+ );
734
+ }
735
+ if (pending.outcome === "resolved" && typeof pending.result === "string") {
736
+ const proof = retainedProcessTerminalProof(pending.result, record.process.providerSessionId);
737
+ if (proof) {
738
+ await this.settleRetainedProcess(record, proof, pending.result);
739
+ return pending.result;
740
+ }
741
+ }
742
+ return null;
743
+ }
744
+
745
+ private async dispatchProcessMutation(args: unknown): Promise<string> {
746
+ const providerSessionId = providerSessionIdFromArgs(args);
747
+ if (providerSessionId === null) throw new RoutingRetainedProcessNotFoundError(-1);
748
+ const record = this.retainedProcess(providerSessionId);
749
+ const priorTerminal = await this.flushPendingProcessMutation(record);
750
+ if (priorTerminal !== null) return priorTerminal;
751
+ if (record.pendingTerminal) {
752
+ const terminal = record.pendingTerminal;
753
+ await this.settleRetainedProcess(record, terminal.proof, terminal.result);
754
+ return terminal.result;
755
+ }
756
+ await this.ensureParentPromotion(record);
757
+ const op = "writeStdin";
758
+ const admission = await this.deps.beforeProcessMutation?.({
759
+ op,
760
+ backend: record.backend,
761
+ process: record.process,
312
762
  });
313
- return resolved;
763
+ const write = record.backend.session.writeStdin;
764
+ if (!write) throw new RoutingUnsupportedError(op, record.backend.kind);
765
+ let result: string;
766
+ try {
767
+ result = await write.call(record.backend.session, args);
768
+ } catch (error) {
769
+ if (this.deps.afterProcessMutation) {
770
+ const pending: PendingProcessMutationSettlement = {
771
+ op,
772
+ backend: record.backend,
773
+ process: record.process,
774
+ admission,
775
+ outcome: "rejected",
776
+ };
777
+ try {
778
+ await this.deps.afterProcessMutation(pending);
779
+ } catch (settlementError) {
780
+ record.pendingMutationSettlement = pending;
781
+ throw new RoutingMutationOutcomeUnknownError(
782
+ op,
783
+ `Retained-process stdin rejected at the provider but lost durable settlement; it was not replayed`,
784
+ { cause: settlementError },
785
+ );
786
+ }
787
+ }
788
+ throw error;
789
+ }
790
+ if (this.deps.afterProcessMutation) {
791
+ const pending: PendingProcessMutationSettlement = {
792
+ op,
793
+ backend: record.backend,
794
+ process: record.process,
795
+ admission,
796
+ outcome: "resolved",
797
+ result,
798
+ };
799
+ try {
800
+ await this.deps.afterProcessMutation(pending);
801
+ } catch (error) {
802
+ record.pendingMutationSettlement = pending;
803
+ throw new RoutingMutationOutcomeUnknownError(
804
+ op,
805
+ `Retained-process stdin returned from the provider but lost durable settlement; tracking remains pinned and it was not replayed`,
806
+ { cause: error },
807
+ );
808
+ }
809
+ }
810
+ const proof = retainedProcessTerminalProof(result, providerSessionId);
811
+ if (proof) await this.settleRetainedProcess(record, proof, result);
812
+ return result;
813
+ }
814
+
815
+ private async dispatchProcessControl(args: unknown): Promise<string> {
816
+ const providerSessionId = providerSessionIdFromArgs(args);
817
+ if (providerSessionId === null) throw new RoutingRetainedProcessNotFoundError(-1);
818
+ const record = this.retainedProcess(providerSessionId);
819
+ const priorTerminal = await this.flushPendingProcessMutation(record);
820
+ if (priorTerminal !== null) return priorTerminal;
821
+ if (record.pendingTerminal) {
822
+ const terminal = record.pendingTerminal;
823
+ await this.settleRetainedProcess(record, terminal.proof, terminal.result);
824
+ return terminal.result;
825
+ }
826
+ await this.ensureParentPromotion(record);
827
+ const write = record.backend.session.writeStdin;
828
+ if (!write) throw new RoutingUnsupportedError("writeStdin", record.backend.kind);
829
+ const result = await write.call(record.backend.session, args);
830
+ const proof = retainedProcessTerminalProof(result, providerSessionId);
831
+ if (proof) await this.settleRetainedProcess(record, proof, result);
832
+ return result;
314
833
  }
315
834
 
316
835
  /**
@@ -326,34 +845,151 @@ export class RoutingSandboxSession implements RoutableBackendSession {
326
845
  */
327
846
  private async dispatch<T>(
328
847
  op: string,
848
+ mutatesWorkspace: boolean,
329
849
  fn: (session: RoutableBackendSession) => Promise<T>,
330
850
  ): Promise<T> {
331
851
  let attempt = 0;
332
852
  let lastError: unknown;
333
853
  while (attempt <= this.maxFenceRetries) {
334
854
  const backend = await this.resolve();
855
+ // Admission failures are NOT provider fence errors and must never enter
856
+ // the retry/rebind loop. If this exact route cannot advance its durable
857
+ // mutation generation, fail before the provider sees the operation.
858
+ const admission = mutatesWorkspace
859
+ ? await this.deps.beforeMutation?.({ op, backend })
860
+ : undefined;
861
+ let result: T;
335
862
  try {
336
- return await fn(backend.session);
863
+ result = await fn(backend.session);
337
864
  } catch (error) {
865
+ if (mutatesWorkspace && this.deps.afterMutation) {
866
+ try {
867
+ await this.deps.afterMutation({
868
+ op,
869
+ backend,
870
+ admission,
871
+ outcome: "rejected",
872
+ });
873
+ } catch (settlementError) {
874
+ this.invalidate(backend);
875
+ throw new RoutingMutationOutcomeUnknownError(
876
+ op,
877
+ `Mutating sandbox operation "${op}" rejected at the provider but lost its durable physical settlement; its outcome is unknown and it was not replayed`,
878
+ { cause: settlementError },
879
+ );
880
+ }
881
+ }
338
882
  if (!isFenceError(error)) {
883
+ if (backend.sandboxId === null && this.deps.onDefaultBackendError) {
884
+ const loss = await this.deps.onDefaultBackendError({
885
+ op,
886
+ error,
887
+ kind: backend.kind,
888
+ backend,
889
+ });
890
+ if (loss) {
891
+ // Never replay an operation after provider disappearance. Even a
892
+ // read may race a route change, and a mutation's provider outcome
893
+ // is ambiguous. The next independently-admitted call observes the
894
+ // advanced epoch and elected recovery state.
895
+ this.invalidate(backend);
896
+ throw new RoutingBackendRecoveryRequiredError(op, loss.leaseEpoch, loss.recovery);
897
+ }
898
+ }
339
899
  throw error;
340
900
  }
901
+ this.invalidate(backend);
902
+ if (mutatesWorkspace) {
903
+ throw new RoutingMutationOutcomeUnknownError(
904
+ op,
905
+ `Mutating sandbox operation "${op}" was fenced after provider admission; its outcome is unknown and it was not replayed`,
906
+ { cause: error },
907
+ );
908
+ }
341
909
  // Stale-epoch fence: the active pointer moved mid-op. Drop the cache so
342
910
  // the next resolve re-reads the NEW pointer and the op lands on the new
343
911
  // active sandbox (the fenced-retry role). Bounded by maxFenceRetries.
344
912
  lastError = error;
345
- this.cachedEpoch = undefined;
346
- this.cachedSandboxId = undefined;
347
- this.cached = undefined;
348
- this.deps.onTransition?.({
349
- type: "fenced-retry",
350
- fromEpoch: backend.sandboxId === null ? 0 : 0,
351
- toEpoch: 0,
352
- sandboxId: backend.sandboxId,
353
- kind: backend.kind,
354
- });
355
913
  attempt += 1;
914
+ continue;
915
+ }
916
+
917
+ const yieldedSessionId =
918
+ mutatesWorkspace && (op === "exec" || op === "execCommand")
919
+ ? providerSessionIdFromResult(result)
920
+ : null;
921
+ const retainedProcess =
922
+ yieldedSessionId === null
923
+ ? undefined
924
+ : { id: crypto.randomUUID(), providerSessionId: yieldedSessionId };
925
+ const retainedRecord = retainedProcess
926
+ ? this.registerRetainedProcess(retainedProcess, backend)
927
+ : null;
928
+
929
+ if (mutatesWorkspace && this.deps.afterMutation) {
930
+ const settlement: PendingParentPromotion = {
931
+ op,
932
+ backend,
933
+ admission,
934
+ outcome: "resolved",
935
+ result,
936
+ ...(retainedProcess ? { retainedProcess } : {}),
937
+ };
938
+ try {
939
+ await this.deps.afterMutation(settlement);
940
+ if (retainedRecord) retainedRecord.durable = true;
941
+ } catch (error) {
942
+ if (retainedRecord) retainedRecord.pendingParentPromotion = settlement;
943
+ this.invalidate(backend);
944
+ throw new RoutingMutationOutcomeUnknownError(
945
+ op,
946
+ retainedRecord
947
+ ? `Mutating sandbox operation "${op}" yielded provider session ${retainedRecord.process.providerSessionId} but lost durable process promotion; exact-backend tracking remains and the operation was not replayed`
948
+ : `Mutating sandbox operation "${op}" returned from the provider but lost its durable route settlement; its outcome is unknown and it was not replayed`,
949
+ { cause: error },
950
+ );
951
+ }
952
+ }
953
+
954
+ // Durable process promotion validated this exact route under the same
955
+ // transaction that retained the parent admission. Later pointer movement
956
+ // cannot invalidate or redirect that process, so return its locator and
957
+ // let process-aware methods use the copied backend identity.
958
+ if (retainedRecord) return result;
959
+
960
+ // Reject output produced by a route that changed while the provider call
961
+ // was in flight. Reads can safely retry on the new route. Mutations cannot:
962
+ // their provider effect may already have happened on the old route.
963
+ let current: ActivePointer;
964
+ try {
965
+ current = await this.deps.readPointer();
966
+ } catch (error) {
967
+ if (mutatesWorkspace) {
968
+ this.invalidate(backend);
969
+ throw new RoutingMutationOutcomeUnknownError(
970
+ op,
971
+ `Mutating sandbox operation "${op}" returned from the provider but its route could not be revalidated; its outcome is unknown and it was not replayed`,
972
+ { cause: error },
973
+ );
974
+ }
975
+ throw error;
976
+ }
977
+ if (
978
+ current.activeEpoch !== backend.activeEpoch ||
979
+ current.activeSandboxId !== backend.sandboxId
980
+ ) {
981
+ this.invalidate(backend);
982
+ if (mutatesWorkspace) {
983
+ throw new RoutingMutationOutcomeUnknownError(
984
+ op,
985
+ `Mutating sandbox operation "${op}" completed on a superseded route; its output was rejected and it was not replayed`,
986
+ );
987
+ }
988
+ lastError = new Error(`sandbox route changed while "${op}" was in flight`);
989
+ attempt += 1;
990
+ continue;
356
991
  }
992
+ return result;
357
993
  }
358
994
  // Exhausted retries against a relentless swap-storm: surface the fence so the
359
995
  // caller (turn) backs off — never loop forever.
@@ -391,7 +1027,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
391
1027
  // degrades via the natural fallback or RoutingUnsupportedError.
392
1028
 
393
1029
  async exec(args: unknown): Promise<unknown> {
394
- return this.dispatch("exec", async (s) => {
1030
+ return this.dispatch("exec", true, async (s) => {
395
1031
  if (s.exec) {
396
1032
  return s.exec(args);
397
1033
  }
@@ -405,7 +1041,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
405
1041
 
406
1042
  async execCommand(args: unknown): Promise<string> {
407
1043
  try {
408
- return await this.dispatch("execCommand", async (s) => {
1044
+ return await this.dispatch("execCommand", true, async (s) => {
409
1045
  if (s.execCommand) {
410
1046
  return s.execCommand(args);
411
1047
  }
@@ -423,7 +1059,11 @@ export class RoutingSandboxSession implements RoutableBackendSession {
423
1059
  }
424
1060
 
425
1061
  async writeStdin(args: unknown): Promise<string> {
426
- return this.dispatch("writeStdin", async (s) => {
1062
+ const providerSessionId = providerSessionIdFromArgs(args);
1063
+ if (providerSessionId !== null && this.retainedProcesses.has(providerSessionId)) {
1064
+ return await this.dispatchProcessMutation(args);
1065
+ }
1066
+ return this.dispatch("writeStdin", true, async (s) => {
427
1067
  if (!s.writeStdin) {
428
1068
  throw new RoutingUnsupportedError("writeStdin", this.cached?.kind ?? "unknown");
429
1069
  }
@@ -431,15 +1071,70 @@ export class RoutingSandboxSession implements RoutableBackendSession {
431
1071
  });
432
1072
  }
433
1073
 
1074
+ /** Whether a positive provider session locator is still pinned to the exact
1075
+ * backend that yielded it. This synchronous probe is used only to select the
1076
+ * process-aware routing surface; it is not itself durable authority. */
1077
+ hasRetainedProcess(providerSessionId: number): boolean {
1078
+ return (
1079
+ positiveProviderSessionId(providerSessionId) !== null &&
1080
+ this.retainedProcesses.has(providerSessionId)
1081
+ );
1082
+ }
1083
+
1084
+ /** Return only OpenGeni's durable UUID + provider locator. Backend/session
1085
+ * objects remain private so a caller cannot forge route authority from this
1086
+ * diagnostic handoff. */
1087
+ retainedProcessIdentity(providerSessionId: number): RoutingRetainedProcess | null {
1088
+ const record = this.retainedProcesses.get(providerSessionId);
1089
+ return record ? { ...record.process } : null;
1090
+ }
1091
+
1092
+ /** Model/user-visible stdin is a distinct workspace mutation admission under
1093
+ * the durable retained-process holder. It never re-reads the active pointer. */
1094
+ async writeStdinForProcessMutation(args: unknown): Promise<string> {
1095
+ return await this.dispatchProcessMutation(args);
1096
+ }
1097
+
1098
+ /** Cancellation and drain polling are control operations. They stay pinned to
1099
+ * the process backend and may prove terminal state, but never advance the
1100
+ * workspace generation. */
1101
+ async writeStdinForProcessControl(args: unknown): Promise<string> {
1102
+ return await this.dispatchProcessControl(args);
1103
+ }
1104
+
1105
+ /** Run a PID/PGID marker or signal helper on the retained process's exact
1106
+ * backend. The helper is control-plane work and therefore has no workspace
1107
+ * mutation admission of its own. */
1108
+ async execCommandForProcessControl(providerSessionId: number, args: unknown): Promise<string> {
1109
+ const exactProviderSessionId = positiveProviderSessionId(providerSessionId);
1110
+ if (exactProviderSessionId === null) throw new RoutingRetainedProcessNotFoundError(-1);
1111
+ const record = this.retainedProcess(exactProviderSessionId);
1112
+ const priorTerminal = await this.flushPendingProcessMutation(record);
1113
+ if (priorTerminal !== null) return priorTerminal;
1114
+ if (record.pendingTerminal) {
1115
+ const terminal = record.pendingTerminal;
1116
+ await this.settleRetainedProcess(record, terminal.proof, terminal.result);
1117
+ return terminal.result;
1118
+ }
1119
+ await this.ensureParentPromotion(record);
1120
+ if (record.backend.session.execCommand) {
1121
+ return await record.backend.session.execCommand(args);
1122
+ }
1123
+ if (record.backend.session.exec) {
1124
+ return formatExecResult(await record.backend.session.exec(args));
1125
+ }
1126
+ throw new RoutingUnsupportedError("execCommand", record.backend.kind);
1127
+ }
1128
+
434
1129
  async cancelExecCommand(opId: string): Promise<boolean> {
435
- return await this.dispatch("cancelExecCommand", async (session) => {
1130
+ return await this.dispatch("cancelExecCommand", false, async (session) => {
436
1131
  if (!session.cancelExecCommand) return false;
437
1132
  return await session.cancelExecCommand(opId);
438
1133
  });
439
1134
  }
440
1135
 
441
1136
  async readFile(args: unknown): Promise<string | Uint8Array> {
442
- return this.dispatch("readFile", async (s) => {
1137
+ return this.dispatch("readFile", false, async (s) => {
443
1138
  if (!s.readFile) {
444
1139
  throw new RoutingUnsupportedError("readFile", this.cached?.kind ?? "unknown");
445
1140
  }
@@ -448,7 +1143,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
448
1143
  }
449
1144
 
450
1145
  async writeFile(args: unknown): Promise<unknown> {
451
- return this.dispatch("writeFile", async (s) => {
1146
+ return this.dispatch("writeFile", true, async (s) => {
452
1147
  if (!s.writeFile) {
453
1148
  throw new RoutingUnsupportedError("writeFile", this.cached?.kind ?? "unknown");
454
1149
  }
@@ -457,7 +1152,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
457
1152
  }
458
1153
 
459
1154
  async listDir(args: unknown): Promise<unknown> {
460
- return this.dispatch("listDir", async (s) => {
1155
+ return this.dispatch("listDir", false, async (s) => {
461
1156
  if (!s.listDir) {
462
1157
  throw new RoutingUnsupportedError("listDir", this.cached?.kind ?? "unknown");
463
1158
  }
@@ -466,7 +1161,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
466
1161
  }
467
1162
 
468
1163
  async pathExists(path: string, runAs?: string): Promise<boolean> {
469
- return this.dispatch("pathExists", async (s) => {
1164
+ return this.dispatch("pathExists", false, async (s) => {
470
1165
  if (!s.pathExists) {
471
1166
  throw new RoutingUnsupportedError("pathExists", this.cached?.kind ?? "unknown");
472
1167
  }
@@ -475,7 +1170,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
475
1170
  }
476
1171
 
477
1172
  async viewImage(args: unknown): Promise<unknown> {
478
- return this.dispatch("viewImage", async (s) => {
1173
+ return this.dispatch("viewImage", false, async (s) => {
479
1174
  if (!s.viewImage) {
480
1175
  throw new RoutingUnsupportedError("viewImage", this.cached?.kind ?? "unknown");
481
1176
  }
@@ -484,7 +1179,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
484
1179
  }
485
1180
 
486
1181
  async materializeEntry(args: unknown): Promise<void> {
487
- return this.dispatch("materializeEntry", async (s) => {
1182
+ return this.dispatch("materializeEntry", true, async (s) => {
488
1183
  if (!s.materializeEntry) {
489
1184
  throw new RoutingUnsupportedError("materializeEntry", this.cached?.kind ?? "unknown");
490
1185
  }
@@ -512,16 +1207,15 @@ export class RoutingSandboxSession implements RoutableBackendSession {
512
1207
  * direct delegate returns undefined and every lazy turn would die at bind. Return a
513
1208
  * LAZY EDITOR PROXY instead: a non-null editor whose async ops resolve the active
514
1209
  * backend (establishing the box on first use, via `dispatch`) and delegate to its
515
- * real editor — mirroring how this proxy defers exec/readFile. */
1210
+ * real editor — mirroring how this proxy defers exec/readFile. Even when an
1211
+ * eager editor already exists, returning it directly would bypass per-edit
1212
+ * route resolution and mutation-generation admission, so every editor is
1213
+ * represented by this dispatching proxy. */
516
1214
  createEditor(runAs?: string): unknown {
517
- const eager = (this.lastResolved ?? this.deps.defaultResolved)?.session.createEditor?.(runAs);
518
- if (eager) {
519
- return eager;
520
- }
521
1215
  const op =
522
1216
  (name: "createFile" | "updateFile" | "deleteFile") =>
523
1217
  (operation: unknown, context?: unknown): Promise<unknown> =>
524
- this.dispatch(`editor.${name}`, async (s) => {
1218
+ this.dispatch(`editor.${name}`, true, async (s) => {
525
1219
  const editor = s.createEditor?.(runAs) as
526
1220
  | Record<string, (operation: unknown, context?: unknown) => Promise<unknown>>
527
1221
  | undefined;
@@ -538,7 +1232,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
538
1232
  }
539
1233
 
540
1234
  async resolveExposedPort(port: number): Promise<ExposedPortEndpoint> {
541
- return this.dispatch("resolveExposedPort", async (s) => {
1235
+ return this.dispatch("resolveExposedPort", false, async (s) => {
542
1236
  if (!s.resolveExposedPort) {
543
1237
  throw new RoutingUnsupportedError("resolveExposedPort", this.cached?.kind ?? "unknown");
544
1238
  }
@@ -549,7 +1243,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
549
1243
  /** Serialize the active backend's session state. Used by the resume-by-id seam
550
1244
  * to fold the live box onto the lease. Dispatches to the active backend. */
551
1245
  async serializeSessionState(): Promise<unknown> {
552
- return this.dispatch("serializeSessionState", async (s) => {
1246
+ return this.dispatch("serializeSessionState", false, async (s) => {
553
1247
  if (!s.serializeSessionState) {
554
1248
  // No-op for a backend with no serializable state (selfhosted state is
555
1249
  // re-addressed, not snapshotted) — surface undefined, not an error.