@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
@@ -20,14 +20,18 @@
20
20
 
21
21
  import type { Settings } from "@opengeni/config";
22
22
  import { collectSandboxEnvironment, parseExposedPorts } from "@opengeni/config";
23
- import { DESKTOP_STREAM_PORT, TERMINAL_STREAM_PORT, type SandboxBackend } from "@opengeni/contracts";
23
+ import {
24
+ DESKTOP_STREAM_PORT,
25
+ TERMINAL_STREAM_PORT,
26
+ type SandboxBackend,
27
+ } from "@opengeni/contracts";
24
28
  import type {
25
29
  SandboxClient,
26
30
  SandboxSessionLike,
27
31
  SandboxSessionState,
28
32
  } from "@openai/agents/sandbox";
29
33
  import { PROVIDER_REGISTRY } from "./providers";
30
- import { SandboxConfigError } from "./errors";
34
+ import { SandboxConfigError, SandboxResumeStateUnavailableError } from "./errors";
31
35
  import { isSelfhostedProviderNotFoundError } from "./selfhosted/session";
32
36
  import type { RuntimeMetricsHooks } from "../metrics";
33
37
 
@@ -47,7 +51,11 @@ export {
47
51
  assertDescriptorRegistryInvariants,
48
52
  type CapabilityDescriptor,
49
53
  } from "./capabilities";
50
- export { SandboxConfigError, SandboxProviderUnavailableError } from "./errors";
54
+ export {
55
+ SandboxConfigError,
56
+ SandboxProviderUnavailableError,
57
+ SandboxResumeStateUnavailableError,
58
+ } from "./errors";
51
59
  export {
52
60
  PROVIDER_REGISTRY,
53
61
  assertProviderRegistryInvariants,
@@ -130,11 +138,12 @@ export {
130
138
  } from "./stream-port";
131
139
 
132
140
  // P4.3 recording loop — plain functions over a live session handle (no agent
133
- // loop); finalize reads bytes + PUTs to storage in the holding process (F10).
141
+ // loop); finalize uploads box -> object storage without buffering in the worker.
134
142
  export {
135
143
  startRecording,
136
144
  stopRecording,
137
- readRecordingBytes,
145
+ inspectRecordingArtifact,
146
+ uploadRecordingArtifact,
138
147
  deleteRecordingArtifacts,
139
148
  recordingStorageKey,
140
149
  contentTypeForCodec,
@@ -145,7 +154,7 @@ export {
145
154
  type RecordingContentType,
146
155
  type StartRecordingInput,
147
156
  type RecordingProcess,
148
- type FinalizeRecordingResult,
157
+ type RecordingArtifactMetadata,
149
158
  } from "./recording";
150
159
 
151
160
  // P4.4 Channel-A structured services — the provider-agnostic SandboxChannelAService
@@ -166,11 +175,14 @@ export {
166
175
  parsePorcelainV2,
167
176
  parseNumstatZ,
168
177
  parseUnifiedPatch,
178
+ REPOSITORY_DISCOVERY_LIMIT,
169
179
  type ChannelASession,
170
180
  type ChannelAExecArgs,
171
181
  type ChannelAExecResult,
172
182
  type ChannelAEmitter,
173
183
  type SandboxChannelAServiceOptions,
184
+ type RepositoryDiscoveryDegradedReason,
185
+ type RepositoryDiscoveryResult,
174
186
  type NumstatEntry,
175
187
  } from "./channel-a";
176
188
 
@@ -190,9 +202,43 @@ export {
190
202
  timeoutControlResponse,
191
203
  offlineAgentError,
192
204
  timeoutAgentError,
205
+ drainingExhaustedError,
206
+ payloadTooLargeMessage,
207
+ drainingMessage,
208
+ execDeadlineHint,
209
+ NEVER_SENT_DETAIL_KEY,
193
210
  type NatsRequestConnection,
194
211
  type SelfhostedUnavailableReason,
195
212
  } from "./selfhosted/control-rpc";
213
+ // The transport-agnostic per-op observation seam (out-of-band telemetry — metrics
214
+ // + machine.* events; the op-engine's op-stream client emits through this too).
215
+ export type { SelfhostedOpObserver, SelfhostedOpObservation } from "./selfhosted/op-observer";
216
+ // The four-field in-band fault renderer (the failure-visibility doctrine).
217
+ export {
218
+ selfhostedFaultClass,
219
+ SELFHOSTED_INFRASTRUCTURE_FAULT_CLASSES,
220
+ renderSelfhostedFault,
221
+ FAULT_FIELD_WHAT_HAPPENED,
222
+ FAULT_FIELD_WHICH_LAYER,
223
+ FAULT_FIELD_WHAT_PRESERVED,
224
+ FAULT_FIELD_WHAT_TO_TRY,
225
+ } from "./selfhosted/fault-rendering";
226
+ // The selfhosted control-op retry policy (the pure decision + the injected clock).
227
+ export {
228
+ decideSelfhostedRetry,
229
+ selfhostedRetryBackoffMs,
230
+ defaultSelfhostedRetryClock,
231
+ SELFHOSTED_IDEMPOTENT_READONLY_OPS,
232
+ SELFHOSTED_DRAINING_MAX_RETRIES,
233
+ SELFHOSTED_EXEC_DRAINING_MAX_RETRIES,
234
+ SELFHOSTED_TIMEOUT_MAX_RETRIES,
235
+ SELFHOSTED_NEVER_SENT_MAX_RETRIES,
236
+ SELFHOSTED_RETRY_BACKOFF_BASE_MS,
237
+ SELFHOSTED_RETRY_BACKOFF_FACTOR,
238
+ SELFHOSTED_RETRY_BACKOFF_CAP_MS,
239
+ type SelfhostedRetryClock,
240
+ type SelfhostedRetryDecision,
241
+ } from "./selfhosted/retry-policy";
196
242
  export {
197
243
  SelfhostedSession,
198
244
  SelfhostedSandboxClient,
@@ -210,7 +256,32 @@ export {
210
256
  type SelfhostedApplyDiff,
211
257
  type SelfhostedEditor,
212
258
  type SelfhostedImageOutput,
259
+ type SelfhostedOpStreamDeps,
213
260
  } from "./selfhosted/session";
261
+ // The op-stream exec transport (op-stream protocol v1.1 — streaming exec to a
262
+ // Connected Machine runner). The worker injects `NatsOpStreamTransport` (over
263
+ // the same bus connection as the control rpc) plus an `OpStreamJournal`
264
+ // adapted onto Temporal; sessions without the injection keep the legacy exec.
265
+ export {
266
+ NatsOpStreamTransport,
267
+ OpStreamUnavailableError,
268
+ opAckSubject,
269
+ opFrameSubject,
270
+ type NatsOpStreamConnection,
271
+ type OpStreamSubscription,
272
+ type OpStreamTransport,
273
+ } from "./selfhosted/op-transport";
274
+ export {
275
+ OP_STREAM_ACK_INTERVAL_MS,
276
+ OP_STREAM_DEFAULT_WINDOW_BYTES,
277
+ OP_STREAM_RECONNECT_HOLD_MS,
278
+ OP_STREAM_SILENCE_TIMEOUT_MS,
279
+ type OpStreamExecOutcome,
280
+ type OpStreamJournal,
281
+ } from "./selfhosted/op-stream";
282
+ // The durable op-id correlation seam (B1): the runtime barrel BINDS it around
283
+ // the SDK shell tool; the sandbox leaf READS it when minting op ids.
284
+ export { nextDurableOpId, runWithToolCallCorrelation, sanitizeOpIdToken } from "./op-correlation";
214
285
  export {
215
286
  negotiateSelfhostedCapabilities,
216
287
  selfhostedLiveness,
@@ -219,7 +290,11 @@ export {
219
290
  type SelfhostedLivenessState,
220
291
  type SelfhostedEnrollment,
221
292
  } from "./selfhosted/capabilities";
222
- export { MockAgentResponder, type MockAgentResponderOptions, type MockExecHandler } from "./selfhosted/testing";
293
+ export {
294
+ MockAgentResponder,
295
+ type MockAgentResponderOptions,
296
+ type MockExecHandler,
297
+ } from "./selfhosted/testing";
223
298
 
224
299
  // The hot-swap routing proxy (M7): ONE stable session-shaped object the SDK binds
225
300
  // to, which re-reads the per-session active pointer per op and dispatches to the
@@ -237,8 +312,11 @@ export {
237
312
  export {
238
313
  makeActiveBackendResolver,
239
314
  ActiveBackendUnresolvableError,
315
+ swapTargetEstablishability,
240
316
  type ActiveBackendResolverDeps,
241
317
  type RoutableSandbox,
318
+ type BackendUnresolvableCode,
319
+ type SwapTargetEstablishability,
242
320
  } from "./routing/backend-resolver";
243
321
 
244
322
  /**
@@ -252,8 +330,15 @@ export {
252
330
  * ports on demand (modal/runloop/e2b pre-declare; blaxel resolves on demand).
253
331
  * Existing modal/docker/local construction is behavior-preserved.
254
332
  */
255
- export function createSandboxClient(settings: Settings, environment = collectSandboxEnvironment(settings)): unknown {
256
- return createSandboxClientForBackend(settings.sandboxBackend as SandboxBackend, settings, environment);
333
+ export function createSandboxClient(
334
+ settings: Settings,
335
+ environment = collectSandboxEnvironment(settings),
336
+ ): unknown {
337
+ return createSandboxClientForBackend(
338
+ settings.sandboxBackend as SandboxBackend,
339
+ settings,
340
+ environment,
341
+ );
257
342
  }
258
343
 
259
344
  /**
@@ -287,10 +372,10 @@ export function createSandboxClientForBackend(
287
372
  // on-demand → must pre-declare). blaxel is on-demand → skipped here.
288
373
  const desktop = registration.descriptor.capabilities.DesktopStream;
289
374
  if (
290
- desktop.available
291
- && settings.sandboxDesktopEnabled
292
- && !registration.descriptor.portExposure.supportsOnDemandPorts
293
- && !exposedPorts.includes(DESKTOP_STREAM_PORT)
375
+ desktop.available &&
376
+ settings.sandboxDesktopEnabled &&
377
+ !registration.descriptor.portExposure.supportsOnDemandPorts &&
378
+ !exposedPorts.includes(DESKTOP_STREAM_PORT)
294
379
  ) {
295
380
  exposedPorts.push(DESKTOP_STREAM_PORT);
296
381
  }
@@ -299,10 +384,10 @@ export function createSandboxClientForBackend(
299
384
  // at construction for resolveExposedPort(7681) to succeed later on a fresh box.
300
385
  // Same condition as the 6080 merge (a desktop-capable image bakes ttyd too).
301
386
  if (
302
- desktop.available
303
- && settings.sandboxDesktopEnabled
304
- && !registration.descriptor.portExposure.supportsOnDemandPorts
305
- && !exposedPorts.includes(TERMINAL_STREAM_PORT)
387
+ desktop.available &&
388
+ settings.sandboxDesktopEnabled &&
389
+ !registration.descriptor.portExposure.supportsOnDemandPorts &&
390
+ !exposedPorts.includes(TERMINAL_STREAM_PORT)
306
391
  ) {
307
392
  exposedPorts.push(TERMINAL_STREAM_PORT);
308
393
  }
@@ -328,14 +413,48 @@ function withDockerNetwork(client: SandboxClient, network: string | undefined):
328
413
  };
329
414
  return {
330
415
  backendId: client.backendId,
331
- ...(client.supportsDefaultOptions !== undefined ? { supportsDefaultOptions: client.supportsDefaultOptions } : {}),
332
- ...(client.create ? { create: async (...args: any[]) => await wrapSession(await (client.create as any)(...args)) } : {}),
333
- ...(client.resume ? { resume: async (state: SandboxSessionState) => await wrapSession(await client.resume!(state)) } : {}),
334
- ...(client.delete ? { delete: async (state: SandboxSessionState) => await client.delete!(state) } : {}),
335
- ...(client.serializeSessionState ? { serializeSessionState: async (state: SandboxSessionState, options) => await client.serializeSessionState!(state, options) } : {}),
336
- ...(client.canPersistOwnedSessionState ? { canPersistOwnedSessionState: async (state: SandboxSessionState) => await client.canPersistOwnedSessionState!(state) } : {}),
337
- ...(client.canReusePreservedOwnedSession ? { canReusePreservedOwnedSession: async (state: SandboxSessionState) => await client.canReusePreservedOwnedSession!(state) } : {}),
338
- ...(client.deserializeSessionState ? { deserializeSessionState: async (state: Record<string, unknown>) => await client.deserializeSessionState!(state) } : {}),
416
+ ...(client.supportsDefaultOptions !== undefined
417
+ ? { supportsDefaultOptions: client.supportsDefaultOptions }
418
+ : {}),
419
+ ...(client.create
420
+ ? {
421
+ create: async (...args: any[]) =>
422
+ await wrapSession(await (client.create as any)(...args)),
423
+ }
424
+ : {}),
425
+ ...(client.resume
426
+ ? {
427
+ resume: async (state: SandboxSessionState) =>
428
+ await wrapSession(await client.resume!(state)),
429
+ }
430
+ : {}),
431
+ ...(client.delete
432
+ ? { delete: async (state: SandboxSessionState) => await client.delete!(state) }
433
+ : {}),
434
+ ...(client.serializeSessionState
435
+ ? {
436
+ serializeSessionState: async (state: SandboxSessionState, options) =>
437
+ await client.serializeSessionState!(state, options),
438
+ }
439
+ : {}),
440
+ ...(client.canPersistOwnedSessionState
441
+ ? {
442
+ canPersistOwnedSessionState: async (state: SandboxSessionState) =>
443
+ await client.canPersistOwnedSessionState!(state),
444
+ }
445
+ : {}),
446
+ ...(client.canReusePreservedOwnedSession
447
+ ? {
448
+ canReusePreservedOwnedSession: async (state: SandboxSessionState) =>
449
+ await client.canReusePreservedOwnedSession!(state),
450
+ }
451
+ : {}),
452
+ ...(client.deserializeSessionState
453
+ ? {
454
+ deserializeSessionState: async (state: Record<string, unknown>) =>
455
+ await client.deserializeSessionState!(state),
456
+ }
457
+ : {}),
339
458
  };
340
459
  }
341
460
 
@@ -351,7 +470,9 @@ async function connectDockerNetwork(network: string, containerId: string): Promi
351
470
  if (stderr.includes("already exists")) {
352
471
  return;
353
472
  }
354
- throw new Error(`Failed to connect Docker sandbox container to network ${network}: ${stderr.trim()}`);
473
+ throw new Error(
474
+ `Failed to connect Docker sandbox container to network ${network}: ${stderr.trim()}`,
475
+ );
355
476
  }
356
477
 
357
478
  /**
@@ -364,14 +485,15 @@ export function sandboxStateEntryFromRunState(state: unknown): Record<string, un
364
485
  if (!sandboxState) {
365
486
  return null;
366
487
  }
367
- const entry = sandboxState.sessionsByAgent?.[sandboxState.currentAgentKey]
368
- ?? (sandboxState.currentAgentKey && sandboxState.sessionState
488
+ const entry =
489
+ sandboxState.sessionsByAgent?.[sandboxState.currentAgentKey] ??
490
+ (sandboxState.currentAgentKey && sandboxState.sessionState
369
491
  ? {
370
- backendId: sandboxState.backendId,
371
- currentAgentKey: sandboxState.currentAgentKey,
372
- currentAgentName: sandboxState.currentAgentName,
373
- sessionState: sandboxState.sessionState,
374
- }
492
+ backendId: sandboxState.backendId,
493
+ currentAgentKey: sandboxState.currentAgentKey,
494
+ currentAgentName: sandboxState.currentAgentName,
495
+ sessionState: sandboxState.sessionState,
496
+ }
375
497
  : null);
376
498
  if (!entry || !entry.sessionState) {
377
499
  return null;
@@ -384,7 +506,10 @@ export function sandboxStateEntryFromRunState(state: unknown): Record<string, un
384
506
  * sandbox session state from a stored entry (as produced by
385
507
  * sandboxStateEntryFromRunState) instead of from a RunState blob.
386
508
  */
387
- export async function restoredSandboxSessionStateFromEntry(entry: Record<string, unknown>, client: unknown): Promise<SandboxSessionState | undefined> {
509
+ export async function restoredSandboxSessionStateFromEntry(
510
+ entry: Record<string, unknown>,
511
+ client: unknown,
512
+ ): Promise<SandboxSessionState | undefined> {
388
513
  if (!client || !entry || typeof entry !== "object" || !("sessionState" in entry)) {
389
514
  return undefined;
390
515
  }
@@ -409,7 +534,9 @@ export async function restoredSandboxSessionStateFromEntry(entry: Record<string,
409
534
  * and the SDK's deserializeSessionState must NOT receive it (it is an opaque
410
535
  * runtime-level field, not provider state).
411
536
  */
412
- export function readWorkspaceArchiveFromEnvelopeSessionState(sessionState: unknown): Uint8Array | undefined {
537
+ export function readWorkspaceArchiveFromEnvelopeSessionState(
538
+ sessionState: unknown,
539
+ ): Uint8Array | undefined {
413
540
  if (!sessionState || typeof sessionState !== "object") {
414
541
  return undefined;
415
542
  }
@@ -424,6 +551,24 @@ export function readWorkspaceArchiveFromEnvelopeSessionState(sessionState: unkno
424
551
  }
425
552
  }
426
553
 
554
+ function readWorkspaceArchivePairFromEnvelopeSessionState(sessionState: unknown): {
555
+ current?: Uint8Array;
556
+ previous?: Uint8Array;
557
+ } {
558
+ if (!sessionState || typeof sessionState !== "object") {
559
+ return {};
560
+ }
561
+ const state = sessionState as { workspaceArchivePrev?: unknown };
562
+ const current = readWorkspaceArchiveFromEnvelopeSessionState(sessionState);
563
+ const previous = readWorkspaceArchiveFromEnvelopeSessionState({
564
+ workspaceArchive: state.workspaceArchivePrev,
565
+ });
566
+ return {
567
+ ...(current ? { current } : {}),
568
+ ...(previous ? { previous } : {}),
569
+ };
570
+ }
571
+
427
572
  // The native snapshot-ref prefixes the @openai/agents-extensions modal client
428
573
  // encodes (snapshots.mjs `NATIVE_SNAPSHOT_PREFIXES`). The ref is
429
574
  // `<PREFIX>\n{"snapshot_id":"...",...}`. We re-implement the decode here because
@@ -473,7 +618,10 @@ export function decodeModalSnapshotId(archive: Uint8Array): string | undefined {
473
618
  * A tar archive (no snapshot id) is a no-op. Returns the deleted snapshot id (or
474
619
  * undefined when nothing was deleted) for observability.
475
620
  */
476
- export async function deletePriorPersistedSnapshot(session: unknown, priorArchiveBase64: string | null | undefined): Promise<string | undefined> {
621
+ export async function deletePriorPersistedSnapshot(
622
+ session: unknown,
623
+ priorArchiveBase64: string | null | undefined,
624
+ ): Promise<string | undefined> {
477
625
  if (!priorArchiveBase64) {
478
626
  return undefined;
479
627
  }
@@ -487,7 +635,8 @@ export async function deletePriorPersistedSnapshot(session: unknown, priorArchiv
487
635
  if (!snapshotId) {
488
636
  return undefined;
489
637
  }
490
- const modal = (session as { modal?: { images?: { delete?: (id: string) => Promise<unknown> } } }).modal;
638
+ const modal = (session as { modal?: { images?: { delete?: (id: string) => Promise<unknown> } } })
639
+ .modal;
491
640
  const del = modal?.images?.delete;
492
641
  if (typeof del !== "function") {
493
642
  return undefined;
@@ -500,12 +649,17 @@ export async function deletePriorPersistedSnapshot(session: unknown, priorArchiv
500
649
  }
501
650
  }
502
651
 
503
- export async function deserializeSandboxSessionStateEnvelope(client: SandboxClient, envelope: unknown): Promise<SandboxSessionState | undefined> {
652
+ export async function deserializeSandboxSessionStateEnvelope(
653
+ client: SandboxClient,
654
+ envelope: unknown,
655
+ ): Promise<SandboxSessionState | undefined> {
504
656
  if (!envelope || typeof envelope !== "object") {
505
657
  return undefined;
506
658
  }
507
659
  if (!client.deserializeSessionState) {
508
- throw new Error("Sandbox client must implement deserializeSessionState() to resume RunState sandbox state");
660
+ throw new Error(
661
+ "Sandbox client must implement deserializeSessionState() to resume RunState sandbox state",
662
+ );
509
663
  }
510
664
  const state = envelope as {
511
665
  providerState?: Record<string, unknown>;
@@ -520,8 +674,12 @@ export async function deserializeSandboxSessionStateEnvelope(client: SandboxClie
520
674
  ...(state.providerState ?? {}),
521
675
  manifest: state.manifest,
522
676
  ...(state.snapshot !== undefined ? { snapshot: state.snapshot } : {}),
523
- ...(state.snapshotFingerprint !== undefined ? { snapshotFingerprint: state.snapshotFingerprint } : {}),
524
- ...(state.snapshotFingerprintVersion !== undefined ? { snapshotFingerprintVersion: state.snapshotFingerprintVersion } : {}),
677
+ ...(state.snapshotFingerprint !== undefined
678
+ ? { snapshotFingerprint: state.snapshotFingerprint }
679
+ : {}),
680
+ ...(state.snapshotFingerprintVersion !== undefined
681
+ ? { snapshotFingerprintVersion: state.snapshotFingerprintVersion }
682
+ : {}),
525
683
  workspaceReady: state.workspaceReady,
526
684
  ...(state.exposedPorts ? { exposedPorts: structuredClone(state.exposedPorts) } : {}),
527
685
  });
@@ -531,14 +689,18 @@ export async function deserializeSandboxSessionStateEnvelope(client: SandboxClie
531
689
  // The ONE resume / recovery primitive (P1.2).
532
690
  //
533
691
  // establishSandboxSessionFromEnvelope is the single re-establish-from-envelope
534
- // path the stateless model leans on: a turn (or any API-direct op) resolves the
535
- // group lease, hands us the recovery envelope, and gets back a LIVE non-owned
536
- // session. On a warm box this is a no-lock warm reattach by id (Modal fromId,
537
- // e2b reconnect — R4-safe, a stray second handle never spawns a second box).
538
- // When the provider reports the box genuinely gone (NotFound) we cold-restore
539
- // from the snapshot via create(). NEVER create() on any OTHER resume error
540
- // (only on NotFound) — a resume-conflict means the box is alive and the caller
541
- // must back off, not spawn a rival.
692
+ // path the stateless model leans on. Creation authority is explicit:
693
+ //
694
+ // - `create-or-restore` is reserved for the caller that won the durable
695
+ // cold->warming lease transition. It may create a cold box, or replace a
696
+ // resumable box that the provider proves is gone.
697
+ // - `resume-only` is for attached turns and API-direct operations. It may
698
+ // resume the leased box by id, but a provider NotFound propagates to the
699
+ // caller so the lease can be atomically marked cold. It NEVER creates.
700
+ //
701
+ // This ownership boundary is the double-spawn guard. A provider NotFound alone
702
+ // is not creation authority: many attached callers can observe the same dead id
703
+ // concurrently, while only one caller may own cold->warming.
542
704
  // ============================================================================
543
705
 
544
706
  /** A live, externally-owned sandbox session re-established from the group lease
@@ -551,6 +713,16 @@ export type EstablishedSandboxSession = {
551
713
  sessionState: unknown;
552
714
  instanceId: string;
553
715
  backendId: string;
716
+ /** How this establish reached a live box: warm reattach by id ("resumed"),
717
+ * fresh create ("created"), or fresh create + archive hydration
718
+ * ("restored"). Callers use it to emit the durable sandbox.box.* lifecycle
719
+ * events — box transitions were previously unrecorded, which made both
720
+ * 2026-07-06 incidents near-unattributable. Optional so external/legacy
721
+ * constructors of this shape stay valid. */
722
+ origin?: "resumed" | "created" | "restored";
723
+ /** Set when a create-authorized reattach found the envelope's box GONE
724
+ * (provider NotFound) and fell through to cold-restore. */
725
+ lostInstanceId?: string;
554
726
  };
555
727
 
556
728
  export type SandboxCreatedCallback = (established: EstablishedSandboxSession) => Promise<void>;
@@ -590,14 +762,22 @@ export function isProviderSandboxNotFoundError(backendId: string, error: unknown
590
762
  if (!error) {
591
763
  return false;
592
764
  }
593
- const status = (error as { status?: unknown; statusCode?: unknown }).status
594
- ?? (error as { statusCode?: unknown }).statusCode;
765
+ const status =
766
+ (error as { status?: unknown; statusCode?: unknown }).status ??
767
+ (error as { statusCode?: unknown }).statusCode;
595
768
  if (status === 404) {
596
769
  return true;
597
770
  }
598
- const name = typeof (error as { name?: unknown }).name === "string" ? (error as { name: string }).name : "";
599
- const code = typeof (error as { code?: unknown }).code === "string" ? (error as { code: string }).code : "";
600
- const message = error instanceof Error ? error.message : typeof error === "string" ? error : String((error as { message?: unknown })?.message ?? "");
771
+ const name =
772
+ typeof (error as { name?: unknown }).name === "string" ? (error as { name: string }).name : "";
773
+ const code =
774
+ typeof (error as { code?: unknown }).code === "string" ? (error as { code: string }).code : "";
775
+ const message =
776
+ error instanceof Error
777
+ ? error.message
778
+ : typeof error === "string"
779
+ ? error
780
+ : String((error as { message?: unknown })?.message ?? "");
601
781
  const haystack = `${name} ${code} ${message}`.toLowerCase();
602
782
  // Provider-agnostic "gone" markers (Modal: "sandbox … not found" / terminated;
603
783
  // e2b/daytona/runloop: "not found" / "no longer running" / "terminated" /
@@ -619,7 +799,11 @@ export function isProviderSandboxNotFoundError(backendId: string, error: unknown
619
799
  ];
620
800
  // A "running"/"already exists" resume-conflict is explicitly NOT NotFound — the
621
801
  // box is alive; recreating would double-spawn.
622
- if (haystack.includes("already running") || haystack.includes("still running") || haystack.includes("already exists")) {
802
+ if (
803
+ haystack.includes("already running") ||
804
+ haystack.includes("still running") ||
805
+ haystack.includes("already exists")
806
+ ) {
623
807
  return false;
624
808
  }
625
809
  return goneMarkers.some((marker) => haystack.includes(marker));
@@ -627,53 +811,82 @@ export function isProviderSandboxNotFoundError(backendId: string, error: unknown
627
811
 
628
812
  function readInstanceId(session: unknown): string {
629
813
  const state = (session as { state?: Record<string, unknown> }).state ?? {};
630
- const candidate = state.sandboxId ?? state.instanceId ?? state.id ?? state.hostId ?? state.containerId;
814
+ const candidate =
815
+ state.sandboxId ??
816
+ state.instanceId ??
817
+ state.id ??
818
+ state.hostId ??
819
+ state.containerId ??
820
+ state.workspaceRootPath;
631
821
  return typeof candidate === "string" && candidate.length > 0 ? candidate : "";
632
822
  }
633
823
 
634
- async function terminateCreatedSandbox(client: ResumeCapableClient, session: unknown, sessionState: unknown): Promise<void> {
824
+ async function terminateCreatedSandbox(
825
+ client: ResumeCapableClient,
826
+ session: unknown,
827
+ sessionState: unknown,
828
+ ): Promise<void> {
635
829
  const clientWithDelete = client as { delete?: (state: unknown) => Promise<unknown> };
636
830
  if (typeof clientWithDelete.delete === "function" && sessionState !== undefined) {
637
- try { await clientWithDelete.delete(sessionState); } catch { /* best-effort */ }
831
+ try {
832
+ await clientWithDelete.delete(sessionState);
833
+ } catch {
834
+ /* best-effort */
835
+ }
638
836
  return;
639
837
  }
640
- const sess = session as { close?: () => Promise<unknown>; terminate?: () => Promise<unknown>; kill?: () => Promise<unknown> };
641
- try { await (sess.terminate ?? sess.kill ?? sess.close)?.(); } catch { /* best-effort */ }
838
+ const sess = session as {
839
+ close?: () => Promise<unknown>;
840
+ terminate?: () => Promise<unknown>;
841
+ kill?: () => Promise<unknown>;
842
+ };
843
+ try {
844
+ await (sess.terminate ?? sess.kill ?? sess.close)?.();
845
+ } catch {
846
+ /* best-effort */
847
+ }
642
848
  }
643
849
 
644
850
  /**
645
- * Resume the one box by id from its recovery envelope, or cold-restore from the
646
- * snapshot when the provider reports it gone. The envelope is the lease's
647
- * box-identity descriptor (the same per-turn `_sandbox` envelope upserted by the
648
- * turn activity). A null envelope means a cold session that was never warmed →
649
- * create() directly.
851
+ * Establish from one recovery envelope under an explicit creation policy. The
852
+ * envelope is the lease's box-identity descriptor (the same per-turn `_sandbox`
853
+ * envelope upserted by the turn activity).
650
854
  *
651
855
  * - `opts.backendOverride ?? envelope.backendId ?? settings.sandboxBackend`
652
856
  * selects the backend; the client is built for THAT backend (resume-by-id is
653
857
  * fenced to the original provider).
654
858
  * - warm reattach: deserialize the envelope sessionState → client.resume(state)
655
- * (no lock; R4-safe). On a provider NotFound, cold-restore via create().
656
- * - cold restore / cold session: client.create() — the ONLY create() site.
859
+ * (no lock; R4-safe). `resume-only` propagates provider NotFound.
860
+ * - `create-or-restore`: the elected owner may replace a missing warm instance,
861
+ * or create directly from a cold/null envelope.
657
862
  */
658
863
  export async function establishSandboxSessionFromEnvelope(
659
864
  settings: Settings,
660
865
  envelope: Record<string, unknown> | null,
661
866
  opts: {
662
867
  sessionId: string;
868
+ recovery: "create-or-restore" | "resume-only";
663
869
  backendOverride?: SandboxBackend;
664
870
  environment?: Record<string, string>;
665
871
  onSandboxCreated?: SandboxCreatedCallback;
666
872
  metrics?: RuntimeMetricsHooks;
667
873
  },
668
874
  ): Promise<EstablishedSandboxSession> {
669
- const envelopeBackend = typeof envelope?.backendId === "string" ? (envelope.backendId as SandboxBackend) : undefined;
670
- const backend = (opts.backendOverride ?? envelopeBackend ?? (settings.sandboxBackend as SandboxBackend));
875
+ const envelopeBackend =
876
+ typeof envelope?.backendId === "string" ? (envelope.backendId as SandboxBackend) : undefined;
877
+ const backend =
878
+ opts.backendOverride ?? envelopeBackend ?? (settings.sandboxBackend as SandboxBackend);
671
879
  const environment = opts.environment ?? collectSandboxEnvironment(settings);
672
- const client = createSandboxClientForBackend(backend, settings, environment) as ResumeCapableClient | undefined;
880
+ const client = createSandboxClientForBackend(backend, settings, environment) as
881
+ | ResumeCapableClient
882
+ | undefined;
673
883
  if (!client) {
674
- throw new SandboxConfigError(backend, `Cannot establish a sandbox session for backend "${backend}" (no client; sandboxBackend=none?)`);
884
+ throw new SandboxConfigError(
885
+ backend,
886
+ `Cannot establish a sandbox session for backend "${backend}" (no client; sandboxBackend=none?)`,
887
+ );
675
888
  }
676
- if (!client.create) {
889
+ if (opts.recovery === "create-or-restore" && !client.create) {
677
890
  throw new SandboxConfigError(backend, `Sandbox backend "${backend}" does not support create()`);
678
891
  }
679
892
 
@@ -693,7 +906,10 @@ export async function establishSandboxSessionFromEnvelope(
693
906
  // The serialized provider state the box was last persisted as. The envelope
694
907
  // shape is the per-turn `_sandbox` entry; its `sessionState` is the provider
695
908
  // payload deserializeSandboxSessionStateEnvelope re-hydrates.
696
- const envelopeSessionState = envelope && typeof envelope === "object" ? (envelope as { sessionState?: unknown }).sessionState : undefined;
909
+ const envelopeSessionState =
910
+ envelope && typeof envelope === "object"
911
+ ? (envelope as { sessionState?: unknown }).sessionState
912
+ : undefined;
697
913
 
698
914
  // The persisted /workspace snapshot the reaper folded onto the lease envelope
699
915
  // (sandbox-file-persistence). Present on a re-warm whose box was drain-persisted:
@@ -701,7 +917,8 @@ export async function establishSandboxSessionFromEnvelope(
701
917
  // - COLD lease re-warm (confirmDrainCold preserved a MINIMAL archive-only
702
918
  // envelope `{ sessionState: { workspaceArchive } }` — NO sandboxId, so the
703
919
  // warm-reattach branch must NOT try resume()-by-id; it cold-creates+hydrates).
704
- const workspaceArchive = readWorkspaceArchiveFromEnvelopeSessionState(envelopeSessionState);
920
+ const workspaceArchives = readWorkspaceArchivePairFromEnvelopeSessionState(envelopeSessionState);
921
+ const workspaceArchive = workspaceArchives.current;
705
922
 
706
923
  // create() a FRESH box, THEN replay the persisted /workspace snapshot via
707
924
  // session.hydrateWorkspace(archive) when one rode the envelope. hydrateWorkspace
@@ -709,7 +926,10 @@ export async function establishSandboxSessionFromEnvelope(
709
926
  // image (restoreSnapshotFilesystem); no archive -> a clean empty box. This is the
710
927
  // SOLE archive-replay seam, shared by the NotFound warm-reattach path AND the
711
928
  // cold-restore branch (b) below.
712
- const coldRestore = async (resumeFallbackState?: unknown): Promise<EstablishedSandboxSession> => {
929
+ const coldRestore = async (
930
+ resumeFallbackState?: unknown,
931
+ skipWorkspaceHydrate = false,
932
+ ): Promise<EstablishedSandboxSession> => {
713
933
  const createStarted = Date.now();
714
934
  let restored: Awaited<ReturnType<NonNullable<typeof client.create>>>;
715
935
  try {
@@ -735,25 +955,53 @@ export async function establishSandboxSessionFromEnvelope(
735
955
  throw createCallbackError;
736
956
  }
737
957
  }
738
- if (workspaceArchive) {
739
- const hydrate = (restored as { hydrateWorkspace?: (data: Uint8Array) => Promise<void> }).hydrateWorkspace;
958
+ // Whether an archive was ACTUALLY applied to /workspace — drives `origin`
959
+ // (and the worker's sandbox.box.created `hydrated` field). A clean-box
960
+ // fallback, a backend without hydrateWorkspace, or an all-archives-failed
961
+ // path leaves this false so we never report `hydrated: "archive"` for an
962
+ // empty workspace.
963
+ let hydrationApplied = false;
964
+ if (!skipWorkspaceHydrate && (workspaceArchive || workspaceArchives.previous)) {
965
+ const hydrate = (restored as { hydrateWorkspace?: (data: Uint8Array) => Promise<void> })
966
+ .hydrateWorkspace;
740
967
  if (typeof hydrate === "function") {
741
- try {
742
- // hydrateWorkspace may internally REPLACE the underlying box
743
- // (restoreSnapshotFilesystem creates a replacement sandbox and terminates
744
- // the placeholder), so the instanceId must be re-read AFTER.
745
- await hydrate.call(restored, workspaceArchive);
746
- } catch (hydrateError) {
747
- // sandbox-file-persistence: if hydrateWorkspace throws (snapshot GC'd,
748
- // provider timeout, corrupt archive), the placeholder box created above is
749
- // live but unhydrated — it would leak up to the full idle/hard lifetime
750
- // (3600s) if we just re-throw. Best-effort delete/terminate it BEFORE
751
- // re-throwing so no box leaks. The original error semantics are preserved
752
- // (the re-throw propagates to the caller). This mirrors the reaper's
753
- // discipline: NEVER leave an orphaned box running.
968
+ let hydrated = false;
969
+ for (const candidate of [
970
+ { archive: workspaceArchive, label: "current" },
971
+ { archive: workspaceArchives.previous, label: "previous" },
972
+ ]) {
973
+ if (!candidate.archive) {
974
+ continue;
975
+ }
976
+ try {
977
+ // hydrateWorkspace may internally REPLACE the underlying box
978
+ // (restoreSnapshotFilesystem creates a replacement sandbox and terminates
979
+ // the placeholder), so the instanceId must be re-read AFTER.
980
+ await hydrate.call(restored, candidate.archive);
981
+ console.info(
982
+ `[sandbox] cold-restore hydrated workspace from ${candidate.label} archive`,
983
+ );
984
+ hydrated = true;
985
+ break;
986
+ } catch (hydrateError) {
987
+ console.warn(
988
+ `[sandbox] cold-restore failed to hydrate workspace from ${candidate.label} archive${
989
+ candidate.label === "current" && workspaceArchives.previous
990
+ ? "; trying previous archive"
991
+ : ""
992
+ }`,
993
+ hydrateError,
994
+ );
995
+ }
996
+ }
997
+ if (!hydrated) {
998
+ // If both retained archives are unusable, drop the placeholder and
999
+ // fall through to the same clean-box behavior as an envelope with no
1000
+ // archive. Restore failure is fail-open; it must not strand a turn.
754
1001
  await terminateCreatedSandbox(client, restored, restoredState);
755
- throw hydrateError;
1002
+ return await coldRestore(resumeFallbackState, true);
756
1003
  }
1004
+ hydrationApplied = true;
757
1005
  const hydratedState = (restored as { state?: unknown }).state;
758
1006
  const hydratedInstanceId = readInstanceId(restored);
759
1007
  if (hydratedInstanceId && hydratedInstanceId !== established.instanceId) {
@@ -782,6 +1030,7 @@ export async function establishSandboxSessionFromEnvelope(
782
1030
  sessionState: restoredState ?? resumeFallbackState,
783
1031
  instanceId: readInstanceId(restored),
784
1032
  backendId: client.backendId,
1033
+ origin: hydrationApplied ? ("restored" as const) : ("created" as const),
785
1034
  };
786
1035
  };
787
1036
 
@@ -792,44 +1041,78 @@ export async function establishSandboxSessionFromEnvelope(
792
1041
  // NotFound, so it would propagate instead of cold-restoring. Gate the resume
793
1042
  // branch on a present sandbox identity so an archive-only envelope falls straight
794
1043
  // through to the cold-restore+hydrate path (b).
795
- const envelopeProviderState = envelopeSessionState && typeof envelopeSessionState === "object"
796
- ? (envelopeSessionState as { providerState?: Record<string, unknown> }).providerState
797
- : undefined;
1044
+ const envelopeProviderState =
1045
+ envelopeSessionState && typeof envelopeSessionState === "object"
1046
+ ? (envelopeSessionState as { providerState?: Record<string, unknown> }).providerState
1047
+ : undefined;
798
1048
  const hasResumableInstance = Boolean(
799
- envelopeProviderState
800
- && typeof envelopeProviderState === "object"
801
- && (envelopeProviderState.sandboxId
802
- || envelopeProviderState.instanceId
803
- || envelopeProviderState.id
804
- || envelopeProviderState.containerId),
1049
+ envelopeProviderState &&
1050
+ typeof envelopeProviderState === "object" &&
1051
+ (envelopeProviderState.sandboxId ||
1052
+ envelopeProviderState.instanceId ||
1053
+ envelopeProviderState.id ||
1054
+ envelopeProviderState.containerId ||
1055
+ envelopeProviderState.workspaceRootPath),
805
1056
  );
806
1057
 
807
1058
  // (a) WARM REATTACH BY ID — only when the envelope carries a resumable box id.
808
- if (hasResumableInstance && envelopeSessionState && client.resume && client.deserializeSessionState) {
1059
+ if (
1060
+ hasResumableInstance &&
1061
+ envelopeSessionState &&
1062
+ client.resume &&
1063
+ client.deserializeSessionState
1064
+ ) {
809
1065
  let resumedState: unknown;
810
1066
  try {
811
- resumedState = await deserializeSandboxSessionStateEnvelope(client as unknown as SandboxClient, envelopeSessionState);
1067
+ resumedState = await deserializeSandboxSessionStateEnvelope(
1068
+ client as unknown as SandboxClient,
1069
+ envelopeSessionState,
1070
+ );
812
1071
  } catch (error) {
813
- throw new SandboxConfigError(backend, `Failed to deserialize sandbox resume envelope for backend "${backend}": ${error instanceof Error ? error.message : String(error)}`);
1072
+ throw new SandboxConfigError(
1073
+ backend,
1074
+ `Failed to deserialize sandbox resume envelope for backend "${backend}": ${error instanceof Error ? error.message : String(error)}`,
1075
+ );
814
1076
  }
815
1077
  if (resumedState !== undefined) {
816
1078
  try {
817
1079
  const session = await client.resume(resumedState);
818
- return { client, session, sessionState: resumedState, instanceId: readInstanceId(session), backendId: client.backendId };
1080
+ return {
1081
+ client,
1082
+ session,
1083
+ sessionState: resumedState,
1084
+ instanceId: readInstanceId(session),
1085
+ backendId: client.backendId,
1086
+ origin: "resumed",
1087
+ };
819
1088
  } catch (error) {
820
- // ONLY a provider NotFound (box gone) licenses a cold-restore. Anything
821
- // else (transient/auth/network/resume-conflict) propagates: the caller
822
- // backs off and re-fences — NEVER spawns a rival box.
823
- if (!isProviderSandboxNotFoundError(client.backendId, error)) {
1089
+ // Attached callers never own replacement. Propagate the provider
1090
+ // NotFound so their lease-aware caller can atomically mark this exact
1091
+ // warm epoch/instance cold and re-enter normal admission. Only the
1092
+ // cold->warming winner may replace the missing box.
1093
+ if (
1094
+ !isProviderSandboxNotFoundError(client.backendId, error) ||
1095
+ opts.recovery === "resume-only"
1096
+ ) {
824
1097
  throw error;
825
1098
  }
826
1099
  // COLD-RESTORE: the box is genuinely gone. Modal does NOT restore via
827
1100
  // create({ snapshot }) — passing `snapshot` to ModalSandboxClient.create()
828
1101
  // THROWS (assertCoreSnapshotUnsupported). Modal's real persistence is an
829
- // OPAQUE ARCHIVE captured by session.persistWorkspace() at reaper-drain
830
- // time and folded onto the lease envelope (sandbox-file-persistence). The
831
- // shared coldRestore() seam creates a fresh box and replays that archive.
832
- return await coldRestore(resumedState);
1102
+ // OPAQUE ARCHIVE captured by session.persistWorkspace() at drain/turn-
1103
+ // snapshot time and folded onto the lease envelope
1104
+ // (sandbox-file-persistence). The shared coldRestore() seam creates a
1105
+ // fresh box and replays that archive. `lostInstanceId` carries the gone
1106
+ // box's id so the caller can emit the durable sandbox.box.lost event.
1107
+ const lostInstanceId = [
1108
+ envelopeProviderState?.sandboxId,
1109
+ envelopeProviderState?.instanceId,
1110
+ envelopeProviderState?.id,
1111
+ envelopeProviderState?.containerId,
1112
+ envelopeProviderState?.workspaceRootPath,
1113
+ ].find((value): value is string => typeof value === "string" && value.length > 0);
1114
+ const restoredSession = await coldRestore(resumedState);
1115
+ return lostInstanceId ? { ...restoredSession, lostInstanceId } : restoredSession;
833
1116
  }
834
1117
  }
835
1118
  }
@@ -839,6 +1122,9 @@ export async function establishSandboxSessionFromEnvelope(
839
1122
  // envelope confirmDrainCold preserves across draining->cold), replay it so
840
1123
  // /workspace survives the box churn (sandbox-file-persistence). No archive -> a
841
1124
  // clean empty box (a never-warmed session).
1125
+ if (opts.recovery === "resume-only") {
1126
+ throw new SandboxResumeStateUnavailableError(backend);
1127
+ }
842
1128
  return await coldRestore();
843
1129
  }
844
1130