@opengeni/worker-bundle 0.28.3 → 0.28.4-canary.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/activities/agent-turn/codex-selection-diagnostics.d.ts +1 -0
  2. package/dist/activities/agent-turn/mcp-operation-read-tool.d.ts +16 -0
  3. package/dist/activities/codex-fleet-shadow.d.ts +1 -1
  4. package/dist/activities/mcp-operation-authority.d.ts +16 -0
  5. package/dist/activities/mcp-operation-observer.d.ts +14 -0
  6. package/dist/activities/mcp-operation-reader.d.ts +45 -0
  7. package/dist/activities/mcp-operation-store.d.ts +6 -0
  8. package/dist/activities/sandbox-lease.d.ts +12 -4
  9. package/dist/{activities-control-UXGPMDL3.js → activities-control-MKGEOBXT.js} +90 -71
  10. package/dist/activities-control-MKGEOBXT.js.map +1 -0
  11. package/dist/{activities-turn-NOYDE7PX.js → activities-turn-5MZZ6HQA.js} +15691 -815
  12. package/dist/activities-turn-5MZZ6HQA.js.map +1 -0
  13. package/dist/artifact-materializer-entry.js +2 -0
  14. package/dist/artifact-materializer-entry.js.map +1 -1
  15. package/dist/artifact-outbox-entry.js +2 -0
  16. package/dist/artifact-outbox-entry.js.map +1 -1
  17. package/dist/{chunk-BARTLVBK.js → chunk-A4XQWVZK.js} +25 -25
  18. package/dist/chunk-A4XQWVZK.js.map +1 -0
  19. package/dist/{chunk-NNXLW6V7.js → chunk-OA5CSY5N.js} +147 -112
  20. package/dist/chunk-OA5CSY5N.js.map +1 -0
  21. package/dist/chunk-PZ5AY32C.js +10 -0
  22. package/dist/chunk-PZ5AY32C.js.map +1 -0
  23. package/dist/host-export.js +1 -0
  24. package/dist/index.js +3 -2
  25. package/dist/index.js.map +1 -1
  26. package/dist/observability-metrics.d.ts +12 -4
  27. package/dist/sandbox-archive-storage.d.ts +15 -3
  28. package/dist/sandbox-resume.d.ts +1 -1
  29. package/dist/sandbox-routing.d.ts +6 -1
  30. package/package.json +18 -17
  31. package/src/activities/agent-turn/claim.ts +3 -3
  32. package/src/activities/agent-turn/codex-capacity.ts +31 -53
  33. package/src/activities/agent-turn/codex-selection-diagnostics.ts +1 -0
  34. package/src/activities/agent-turn/finalization.ts +10 -5
  35. package/src/activities/agent-turn/mcp-operation-read-tool.ts +61 -0
  36. package/src/activities/agent-turn/sandbox-establish.ts +2 -0
  37. package/src/activities/agent-turn/skill-search.ts +2 -16
  38. package/src/activities/agent-turn/stream-attempt.ts +1 -1
  39. package/src/activities/agent-turn/tool-environment.ts +65 -0
  40. package/src/activities/codex-fleet-shadow.ts +3 -0
  41. package/src/activities/mcp-credentials.ts +94 -40
  42. package/src/activities/mcp-operation-authority.ts +76 -0
  43. package/src/activities/mcp-operation-observer.ts +86 -0
  44. package/src/activities/mcp-operation-reader.ts +108 -0
  45. package/src/activities/mcp-operation-store.ts +98 -0
  46. package/src/activities/sandbox-lease.ts +111 -73
  47. package/src/observability-metrics.ts +28 -24
  48. package/src/sandbox-archive-storage.ts +82 -18
  49. package/src/sandbox-resume.ts +79 -63
  50. package/src/sandbox-routing.ts +5 -1
  51. package/dist/activities-control-UXGPMDL3.js.map +0 -1
  52. package/dist/activities-turn-NOYDE7PX.js.map +0 -1
  53. package/dist/chunk-BARTLVBK.js.map +0 -1
  54. package/dist/chunk-NNXLW6V7.js.map +0 -1
@@ -26,6 +26,7 @@ import {
26
26
  appendSessionEventToSandboxGroup,
27
27
  bindRetainedProcessProviderIdentity,
28
28
  claimWorkspaceArchiveCapture,
29
+ releaseWorkspaceArchiveCapture,
29
30
  claimSandboxCheckpointArtifactsForGc,
30
31
  claimTerminalRetainedProcesses,
31
32
  countActiveRetainedProcessesByOwnerState,
@@ -82,7 +83,10 @@ import {
82
83
  // box is typed as provider loss. Stale capture reconciliation separately uses
83
84
  // establishSandboxSessionFromEnvelope in strict resume-only mode so it can
84
85
  // reuse the runtime's provider-ready proof without ever cold-restoring.
85
- captureVerifiedWorkspaceArchive,
86
+ captureWorkspaceArchiveForStorage,
87
+ disposeWorkspaceArchive,
88
+ type VerifiedWorkspaceArchivePayload,
89
+ type VerifiedHostWorkspaceArchive,
86
90
  cancelSelfhostedOp,
87
91
  assertConsistentSandboxProviderIdentity,
88
92
  createSandboxClientForBackend,
@@ -160,9 +164,7 @@ import {
160
164
  } from "../opensandbox-kubernetes-inventory";
161
165
  import type { ObjectStorage } from "@opengeni/storage";
162
166
  import {
163
- collectWorkspaceArchiveObjectKeys,
164
- deleteUnpublishedWorkspaceArchiveObject,
165
- deleteWorkspaceArchiveObjectKeys,
167
+ persistWorkspaceArchiveCandidate,
166
168
  putVersion1TarArchiveOrInline,
167
169
  } from "../sandbox-archive-storage";
168
170
 
@@ -210,7 +212,7 @@ type DrainSandboxClient = {
210
212
  * aborts the terminate before client.delete()).
211
213
  */
212
214
  export type PersistArchiveFn = (
213
- archiveBase64: string | null,
215
+ archiveBase64: string | VerifiedHostWorkspaceArchive | null,
214
216
  archiveMetadata?: WorkspaceArchiveDescriptor,
215
217
  providerSession?: unknown,
216
218
  providerBinding?: Awaited<
@@ -247,6 +249,8 @@ export type TerminateBoxFn = (
247
249
  providerCaptureRequestId?: string,
248
250
  captureDisposition?: DrainCaptureDisposition,
249
251
  capturePolicy?: ProviderWorkspaceCapturePolicy | null,
252
+ diskBackedArchives?: boolean,
253
+ releaseFailedCapture?: () => Promise<void>,
250
254
  ) => Promise<boolean | ProviderTerminationOutcome>;
251
255
 
252
256
  export type SweepModalOrphansFn = (
@@ -348,12 +352,13 @@ export class SandboxProviderCaptureTimeoutError extends Error {
348
352
  }
349
353
  }
350
354
 
351
- async function awaitProviderCaptureWithLatePublication<T>(input: {
355
+ export async function awaitProviderCaptureWithLatePublication<T>(input: {
352
356
  capture: Promise<T>;
353
357
  timeoutMs: number;
354
358
  timeoutError: SandboxProviderCaptureTimeoutError;
355
359
  publishLate: (value: T) => Promise<void>;
356
360
  observeLateFailure: (error: unknown) => void;
361
+ observeLatePublicationFailure: (error: unknown) => void;
357
362
  }): Promise<T> {
358
363
  let timeout: ReturnType<typeof setTimeout> | undefined;
359
364
  const timeoutError = input.timeoutError;
@@ -371,7 +376,9 @@ async function awaitProviderCaptureWithLatePublication<T>(input: {
371
376
  // durable archive publication: never provider teardown or a cold commit from
372
377
  // an activity Temporal has already rejected. A replaced claim fences this
373
378
  // callback; Modal candidates then flow to artifact-ledger GC.
374
- void input.capture.then(input.publishLate).catch(input.observeLateFailure);
379
+ void input.capture
380
+ .then(input.publishLate, input.observeLateFailure)
381
+ .catch(input.observeLatePublicationFailure);
375
382
  throw error;
376
383
  } finally {
377
384
  if (timeout) clearTimeout(timeout);
@@ -479,6 +486,8 @@ export function createSandboxLeaseActivities(
479
486
  providerCaptureRequestId,
480
487
  captureDisposition,
481
488
  capturePolicy,
489
+ diskBackedArchives,
490
+ releaseFailedCapture,
482
491
  ) =>
483
492
  await terminateProviderBox(
484
493
  settings,
@@ -490,6 +499,8 @@ export function createSandboxLeaseActivities(
490
499
  providerCaptureRequestId,
491
500
  captureDisposition,
492
501
  capturePolicy,
502
+ diskBackedArchives,
503
+ releaseFailedCapture,
493
504
  ));
494
505
  const sweepModalOrphans: SweepModalOrphansFn =
495
506
  options.sweepModalOrphans ?? sweepModalOrphansForConfiguredBackend;
@@ -2675,7 +2686,7 @@ async function terminateDrainableBox(
2675
2686
  ? (lease.recovery.archive.current?.revision ?? null)
2676
2687
  : null;
2677
2688
  const persistArchive: PersistArchiveFn = async (
2678
- archiveBase64: string | null,
2689
+ archiveBase64: string | VerifiedHostWorkspaceArchive | null,
2679
2690
  archiveMetadata?: WorkspaceArchiveDescriptor,
2680
2691
  _providerSession?: unknown,
2681
2692
  providerBinding?: Awaited<
@@ -2691,6 +2702,7 @@ async function terminateDrainableBox(
2691
2702
  const archiveMetrics = runtimeMetricsHooksForObservability(observability);
2692
2703
  let checkpointArtifactId: string | null = null;
2693
2704
  if (
2705
+ typeof archiveBase64 === "string" &&
2694
2706
  archiveBase64 &&
2695
2707
  archiveMetadata?.version === 2 &&
2696
2708
  (archiveMetadata.provider === "modal_snapshot_filesystem" ||
@@ -2735,59 +2747,33 @@ async function terminateDrainableBox(
2735
2747
  if (!archiveMetadata) {
2736
2748
  throw new Error("Sandbox snapshot publication requires a verified archive descriptor");
2737
2749
  }
2738
- const priorKeys = collectWorkspaceArchiveObjectKeys(
2739
- (lease.resumeState as Record<string, unknown> | null | undefined) ?? null,
2740
- );
2741
- let workspaceArchiveRef:
2742
- | Awaited<ReturnType<typeof putVersion1TarArchiveOrInline>>["workspaceArchiveRef"]
2743
- | undefined;
2744
- try {
2745
- const published = await putVersion1TarArchiveOrInline({
2746
- backend: lease.backend,
2747
- objectStorage,
2748
- accountId,
2749
- workspaceId: row.workspaceId,
2750
- sandboxGroupId: row.sandboxGroupId,
2751
- archive: {
2752
- bytes: Buffer.from(archiveBase64, "base64"),
2753
- descriptor: archiveMetadata,
2754
- base64: archiveBase64,
2755
- },
2756
- metrics: archiveMetrics,
2757
- });
2758
- workspaceArchiveRef = published.workspaceArchiveRef;
2759
- result = await persistDrainSnapshot(db, {
2760
- ...baseInput,
2761
- workspaceArchiveMeta: archiveMetadata,
2762
- ...published,
2763
- ...(checkpointArtifactId ? { checkpointArtifactId } : {}),
2764
- });
2765
- if (published.workspaceArchiveRef && objectStorage) {
2766
- if (!result.wrote) {
2767
- await deleteUnpublishedWorkspaceArchiveObject(
2768
- objectStorage,
2769
- published.workspaceArchiveRef,
2770
- archiveMetrics,
2771
- );
2772
- } else {
2773
- const afterLease = await readLease(db, row.workspaceId, row.sandboxGroupId);
2774
- const afterKeys = collectWorkspaceArchiveObjectKeys(
2775
- (afterLease?.resumeState as Record<string, unknown> | null | undefined) ?? null,
2776
- );
2777
- await deleteWorkspaceArchiveObjectKeys(
2778
- objectStorage,
2779
- [...priorKeys].filter((key) => !afterKeys.has(key)),
2780
- ).catch(() => undefined);
2781
- }
2782
- }
2783
- } catch (error) {
2784
- await deleteUnpublishedWorkspaceArchiveObject(
2785
- objectStorage,
2786
- workspaceArchiveRef,
2787
- archiveMetrics,
2788
- );
2789
- throw error;
2790
- }
2750
+ const published = await putVersion1TarArchiveOrInline({
2751
+ backend: lease.backend,
2752
+ objectStorage,
2753
+ accountId,
2754
+ workspaceId: row.workspaceId,
2755
+ sandboxGroupId: row.sandboxGroupId,
2756
+ archive:
2757
+ typeof archiveBase64 === "object"
2758
+ ? archiveBase64
2759
+ : {
2760
+ bytes: Buffer.from(archiveBase64, "base64"),
2761
+ descriptor: archiveMetadata,
2762
+ base64: archiveBase64,
2763
+ },
2764
+ metrics: archiveMetrics,
2765
+ });
2766
+ result = await persistWorkspaceArchiveCandidate({
2767
+ objectStorage,
2768
+ ...(published.workspaceArchiveRef ? { ref: published.workspaceArchiveRef } : {}),
2769
+ persist: () =>
2770
+ persistDrainSnapshot(db, {
2771
+ ...baseInput,
2772
+ workspaceArchiveMeta: archiveMetadata,
2773
+ ...published,
2774
+ ...(checkpointArtifactId ? { checkpointArtifactId } : {}),
2775
+ }),
2776
+ });
2791
2777
  }
2792
2778
  if (!result.wrote && checkpointArtifactId) {
2793
2779
  await markSandboxCheckpointArtifactDeletePending(db, {
@@ -2823,6 +2809,18 @@ async function terminateDrainableBox(
2823
2809
  captureClaim?.providerRequestId ?? attempt.operationId,
2824
2810
  captureDisposition,
2825
2811
  capturePolicy,
2812
+ Boolean(objectStorage),
2813
+ async () => {
2814
+ if (!captureClaim || !lease.instanceId) return;
2815
+ await releaseWorkspaceArchiveCapture(db, {
2816
+ accountId,
2817
+ workspaceId: row.workspaceId,
2818
+ sandboxGroupId: row.sandboxGroupId,
2819
+ captureId: captureClaim.id,
2820
+ expectedEpoch: row.leaseEpoch,
2821
+ expectedInstanceId: lease.instanceId,
2822
+ });
2823
+ },
2826
2824
  );
2827
2825
  const terminated = typeof termination === "boolean" ? termination : termination.terminated;
2828
2826
  if (!terminated) {
@@ -2955,10 +2953,22 @@ export async function terminateProviderBox(
2955
2953
  providerCaptureRequestId?: string,
2956
2954
  captureDisposition: DrainCaptureDisposition = "capture_required",
2957
2955
  claimedCapturePolicy?: ProviderWorkspaceCapturePolicy | null,
2956
+ diskBackedArchives = false,
2957
+ releaseFailedCapture?: () => Promise<void>,
2958
2958
  ): Promise<ProviderTerminationOutcome> {
2959
2959
  const durableBackendId = (lease.resumeBackendId ?? lease.backend) as string;
2960
2960
  const backend = sandboxBackendForSdkBackendId(durableBackendId) ?? durableBackendId;
2961
2961
  const logIdentity = providerDrainLogIdentity(lease, backend);
2962
+ const releaseAfterCaptureFailure = async (): Promise<void> => {
2963
+ try {
2964
+ await releaseFailedCapture?.();
2965
+ } catch (error) {
2966
+ observability.warn("sandbox reaper: failed capture claim release failed", {
2967
+ ...logIdentity,
2968
+ error: error instanceof Error ? error.message : String(error),
2969
+ });
2970
+ }
2971
+ };
2962
2972
  // 'none' / no backend -> nothing to terminate.
2963
2973
  if (!backend || backend === "none") {
2964
2974
  return { terminated: true, providerMissingBeforeCapture: false };
@@ -3147,7 +3157,7 @@ export async function terminateProviderBox(
3147
3157
  // snapshot must re-throw BEFORE any terminate so files are never lost — the next
3148
3158
  // sweep retries, the provider idle-timeout is the backstop. A NotFound here (the
3149
3159
  // box raced gone between resume and persist) is success: nothing to persist.
3150
- let verifiedArchive: Awaited<ReturnType<typeof captureVerifiedWorkspaceArchive>> | undefined;
3160
+ let verifiedArchive: VerifiedWorkspaceArchivePayload | undefined;
3151
3161
  const sessionWorkspacePersistence = (
3152
3162
  session as { state?: { workspacePersistence?: unknown } } | undefined
3153
3163
  )?.state?.workspacePersistence;
@@ -3162,10 +3172,15 @@ export async function terminateProviderBox(
3162
3172
  : null;
3163
3173
  try {
3164
3174
  if (captureDisposition !== "archive_published" && session?.persistWorkspace) {
3165
- const capture = captureVerifiedWorkspaceArchive(session, Date.now(), {
3166
- requestId: providerCaptureRequestId ?? randomUUID(),
3167
- strategy: liveCapturePolicy.strategy,
3168
- });
3175
+ const capture = captureWorkspaceArchiveForStorage(
3176
+ session,
3177
+ Date.now(),
3178
+ {
3179
+ requestId: providerCaptureRequestId ?? randomUUID(),
3180
+ strategy: liveCapturePolicy.strategy,
3181
+ },
3182
+ diskBackedArchives,
3183
+ );
3169
3184
  verifiedArchive = await awaitProviderCaptureWithLatePublication({
3170
3185
  capture,
3171
3186
  timeoutMs: settings.sandboxSnapshotTimeoutMs,
@@ -3179,7 +3194,7 @@ export async function terminateProviderBox(
3179
3194
  publishLate: async (archive) => {
3180
3195
  try {
3181
3196
  const result = await persistArchive(
3182
- archive.base64,
3197
+ archive.kind === "host_spool" ? archive : archive.base64,
3183
3198
  archive.descriptor,
3184
3199
  session,
3185
3200
  checkpointBinding,
@@ -3197,9 +3212,21 @@ export async function terminateProviderBox(
3197
3212
  ...logIdentity,
3198
3213
  error: error instanceof Error ? error.message : String(error),
3199
3214
  });
3215
+ } finally {
3216
+ await disposeWorkspaceArchive(archive);
3200
3217
  }
3201
3218
  },
3219
+ observeLatePublicationFailure: (error) => {
3220
+ observability.warn("sandbox reaper: late capture publication cleanup failed", {
3221
+ ...logIdentity,
3222
+ error: error instanceof Error ? error.message : String(error),
3223
+ });
3224
+ },
3202
3225
  observeLateFailure: (error) => {
3226
+ // Capture has now rejected and this timed-out path can never reach
3227
+ // provider teardown. Release only our unpublished claim so a waiting
3228
+ // turn can re-arm the intact live box; stale callbacks are DB-fenced.
3229
+ void releaseAfterCaptureFailure();
3203
3230
  observability.warn("sandbox reaper: timed-out provider capture later failed", {
3204
3231
  ...logIdentity,
3205
3232
  error: error instanceof Error ? error.message : String(error),
@@ -3221,6 +3248,12 @@ export async function terminateProviderBox(
3221
3248
  error: error instanceof Error ? error.message : String(error),
3222
3249
  },
3223
3250
  );
3251
+ // An unresolved timeout is not proof of capture failure: retain the fence
3252
+ // until its provider promise settles. A settled failure cannot enter the
3253
+ // teardown path below, so its unpublished claim may safely be released.
3254
+ if (!(error instanceof SandboxProviderCaptureTimeoutError)) {
3255
+ await releaseAfterCaptureFailure();
3256
+ }
3224
3257
  // NEVER terminate a box whose snapshot we could not capture.
3225
3258
  throw error;
3226
3259
  }
@@ -3237,12 +3270,17 @@ export async function terminateProviderBox(
3237
3270
  ...logIdentity,
3238
3271
  });
3239
3272
  } else if (verifiedArchive) {
3240
- const { wrote } = await persistArchive(
3241
- verifiedArchive.base64,
3242
- verifiedArchive.descriptor,
3243
- session,
3244
- checkpointBinding,
3245
- );
3273
+ let wrote: boolean;
3274
+ try {
3275
+ ({ wrote } = await persistArchive(
3276
+ verifiedArchive.kind === "host_spool" ? verifiedArchive : verifiedArchive.base64,
3277
+ verifiedArchive.descriptor,
3278
+ session,
3279
+ checkpointBinding,
3280
+ ));
3281
+ } finally {
3282
+ await disposeWorkspaceArchive(verifiedArchive);
3283
+ }
3246
3284
  if (!wrote) {
3247
3285
  observability.info(
3248
3286
  "sandbox reaper: lease re-armed during persist — leaving box RUNNING (no terminate)",
@@ -413,7 +413,7 @@ export function recordWorkerDeathRecoveryMetrics(
413
413
  }
414
414
 
415
415
  export class TurnLifecycleMetrics {
416
- private readonly turns = new Map<string, { startedAt: number; lastProgressAt: number }>();
416
+ private readonly attempts = new Map<string, { startedAt: number; lastProgressAt: number }>();
417
417
  private timer: ReturnType<typeof setInterval> | null = null;
418
418
 
419
419
  constructor(
@@ -424,42 +424,46 @@ export class TurnLifecycleMetrics {
424
424
  } = {},
425
425
  ) {}
426
426
 
427
- start(turnId: string): void {
427
+ start(input: { attemptId: string }): void {
428
428
  const now = this.now();
429
- this.turns.set(turnId, { startedAt: now, lastProgressAt: now });
429
+ this.attempts.set(input.attemptId, { startedAt: now, lastProgressAt: now });
430
430
  this.ensureTimer();
431
431
  this.refreshGauges();
432
432
  }
433
433
 
434
- progress(turnId: string): void {
435
- const turn = this.turns.get(turnId);
436
- if (!turn) return;
437
- turn.lastProgressAt = this.now();
434
+ progress(input: { attemptId: string }): void {
435
+ const attempt = this.attempts.get(input.attemptId);
436
+ if (!attempt) return;
437
+ attempt.lastProgressAt = this.now();
438
438
  }
439
439
 
440
- finish(turnId: string, outcome: TurnOutcome | null, durationSeconds?: number): void {
441
- const startedAt = this.turns.get(turnId)?.startedAt;
440
+ finish(input: {
441
+ attemptId: string;
442
+ outcome: TurnOutcome | null;
443
+ durationSeconds?: number;
444
+ }): void {
445
+ const startedAt = this.attempts.get(input.attemptId)?.startedAt;
442
446
  if (startedAt !== undefined) {
443
- this.turns.delete(turnId);
447
+ this.attempts.delete(input.attemptId);
444
448
  }
445
- if (outcome) {
449
+ if (input.outcome) {
446
450
  const observedDuration =
447
- durationSeconds ??
451
+ input.durationSeconds ??
448
452
  (startedAt === undefined ? 0 : Math.max(0, (this.now() - startedAt) / 1000));
449
453
  this.observability.incrementCounter({
450
454
  name: "opengeni_turns_total",
451
455
  help: "Total agent turns by terminal outcome.",
452
- labels: { outcome },
456
+ labels: { outcome: input.outcome },
453
457
  });
454
458
  this.observability.observeHistogram({
455
459
  name: "opengeni_turn_duration_seconds",
456
460
  help: "Agent turn duration in seconds by terminal outcome.",
457
- labels: { outcome },
461
+ labels: { outcome: input.outcome },
458
462
  value: observedDuration,
459
463
  });
460
464
  }
461
465
  this.refreshGauges();
462
- if (this.turns.size === 0) {
466
+ if (this.attempts.size === 0) {
463
467
  this.stopTimer();
464
468
  }
465
469
  }
@@ -467,42 +471,42 @@ export class TurnLifecycleMetrics {
467
471
  refreshGauges(): void {
468
472
  this.observability.setGauge({
469
473
  name: "opengeni_turns_inflight",
470
- help: "Current number of in-flight agent turns in this worker process.",
471
- value: this.turns.size,
474
+ help: "Current number of in-flight physical agent-turn attempts in this worker process.",
475
+ value: this.attempts.size,
472
476
  });
473
477
  this.observability.setGauge({
474
478
  name: "opengeni_turn_oldest_inflight_age_seconds",
475
- help: "Age in seconds of the oldest in-flight agent turn in this worker process.",
479
+ help: "Age in seconds of the oldest in-flight physical agent-turn attempt in this worker process.",
476
480
  value: this.oldestInflightAgeSeconds(),
477
481
  });
478
482
  this.observability.setGauge({
479
483
  name: "opengeni_turn_oldest_no_progress_age_seconds",
480
- help: "Seconds since durable progress for the least recently progressing in-flight turn.",
484
+ help: "Seconds since durable progress for the least recently progressing in-flight physical agent-turn attempt.",
481
485
  value: this.oldestNoProgressAgeSeconds(),
482
486
  });
483
487
  }
484
488
 
485
489
  stop(): void {
486
- this.turns.clear();
490
+ this.attempts.clear();
487
491
  this.refreshGauges();
488
492
  this.stopTimer();
489
493
  }
490
494
 
491
495
  private oldestInflightAgeSeconds(): number {
492
- if (this.turns.size === 0) {
496
+ if (this.attempts.size === 0) {
493
497
  return 0;
494
498
  }
495
499
  let oldest = Number.POSITIVE_INFINITY;
496
- for (const { startedAt } of this.turns.values()) {
500
+ for (const { startedAt } of this.attempts.values()) {
497
501
  oldest = Math.min(oldest, startedAt);
498
502
  }
499
503
  return Math.max(0, (this.now() - oldest) / 1000);
500
504
  }
501
505
 
502
506
  private oldestNoProgressAgeSeconds(): number {
503
- if (this.turns.size === 0) return 0;
507
+ if (this.attempts.size === 0) return 0;
504
508
  let leastRecentProgress = Number.POSITIVE_INFINITY;
505
- for (const { lastProgressAt } of this.turns.values()) {
509
+ for (const { lastProgressAt } of this.attempts.values()) {
506
510
  leastRecentProgress = Math.min(leastRecentProgress, lastProgressAt);
507
511
  }
508
512
  return Math.max(0, (this.now() - leastRecentProgress) / 1000);
@@ -1,4 +1,6 @@
1
- import type { ObjectStorage } from "@opengeni/storage";
1
+ import { uploadWorkspaceArchiveSpool, type ObjectStorage } from "@opengeni/storage";
2
+ import { randomUUID } from "node:crypto";
3
+ import type { VerifiedHostWorkspaceArchive } from "@opengeni/runtime/sandbox";
2
4
  import {
3
5
  parseWorkspaceArchiveObjectRef,
4
6
  workspaceArchiveObjectKey,
@@ -35,43 +37,102 @@ export async function putTarWorkspaceArchiveObject(input: {
35
37
  accountId: string;
36
38
  workspaceId: string;
37
39
  sandboxGroupId: string;
38
- archive: { bytes: Uint8Array; descriptor: WorkspaceArchiveDescriptor };
40
+ archive:
41
+ | { bytes: Uint8Array; descriptor: WorkspaceArchiveDescriptor }
42
+ | VerifiedHostWorkspaceArchive;
39
43
  }): Promise<WorkspaceArchiveObjectRef> {
40
- if (input.archive.descriptor.version !== 1) {
44
+ // Snapshot caller-owned metadata before any provider callback can mutate it.
45
+ const descriptor = structuredClone(input.archive.descriptor);
46
+ if (descriptor.version !== 1) {
41
47
  throw new Error("Object-storage workspace archives are portable tar only");
42
48
  }
43
49
  const key = workspaceArchiveObjectKey({
44
50
  accountId: input.accountId,
45
51
  workspaceId: input.workspaceId,
46
52
  sandboxGroupId: input.sandboxGroupId,
47
- revision: input.archive.descriptor.revision,
48
- });
49
- await input.objectStorage.putObject({
50
- key,
51
- contentType: "application/x-tar",
52
- body: input.archive.bytes,
53
- sha256: input.archive.descriptor.archiveSha256,
53
+ revision: descriptor.revision,
54
+ uploadId: randomUUID(),
54
55
  });
56
+ const archive = input.archive;
57
+ if ("spool" in archive) {
58
+ if (
59
+ archive.spool.byteSize !== descriptor.archiveBytes ||
60
+ archive.spool.sha256 !== descriptor.archiveSha256
61
+ ) {
62
+ throw new Error("Workspace archive spool does not match its verified descriptor");
63
+ }
64
+ }
65
+ const source =
66
+ "spool" in archive
67
+ ? archive.spool
68
+ : {
69
+ path: "",
70
+ byteSize: archive.bytes.byteLength,
71
+ sha256: descriptor.archiveSha256,
72
+ async *open() {
73
+ for (let offset = 0; offset < archive.bytes.length; offset += 1024 * 1024)
74
+ yield archive.bytes.subarray(offset, offset + 1024 * 1024);
75
+ },
76
+ async dispose() {},
77
+ };
78
+ try {
79
+ await uploadWorkspaceArchiveSpool(input.objectStorage, key, {
80
+ ...source,
81
+ byteSize: descriptor.archiveBytes,
82
+ sha256: descriptor.archiveSha256,
83
+ open: source.open.bind(source),
84
+ });
85
+ } catch (error) {
86
+ // This fresh locator has never been offered to database publication. An
87
+ // ambiguous provider upload can leave an orphan, never overwrite a retained
88
+ // archive or justify deleting another attempt's object.
89
+ await input.objectStorage.deleteObject(key).catch(() => undefined);
90
+ throw error;
91
+ }
55
92
  return {
56
93
  schema: "sandbox_archive_object_v1",
57
94
  key,
58
- sha256: input.archive.descriptor.archiveSha256,
59
- bytes: input.archive.descriptor.archiveBytes,
95
+ sha256: descriptor.archiveSha256,
96
+ bytes: descriptor.archiveBytes,
60
97
  backend: input.objectStorage.backend,
61
98
  };
62
99
  }
63
100
 
101
+ /** The caller owns a fresh candidate returned by this module. A thrown or lost
102
+ * database response is UNKNOWN: it may have committed, so never delete on catch.
103
+ * Only the transaction's exact candidate disposition can authorize cleanup. */
104
+ export async function persistWorkspaceArchiveCandidate<
105
+ T extends {
106
+ wrote: boolean;
107
+ candidateDisposition?: "adopted" | "already_referenced" | "unused";
108
+ },
109
+ >(input: {
110
+ objectStorage?: ObjectStorage | null;
111
+ ref?: WorkspaceArchiveObjectRef;
112
+ persist: () => Promise<T>;
113
+ }): Promise<T> {
114
+ const objectStorage = input.objectStorage;
115
+ const ref = input.ref ? { ...input.ref } : undefined;
116
+ const result = await input.persist();
117
+ if (objectStorage && ref && result.candidateDisposition === "unused") {
118
+ await deleteUnpublishedWorkspaceArchiveObject(objectStorage, ref);
119
+ }
120
+ return result;
121
+ }
122
+
64
123
  export async function putVersion1TarArchiveOrInline(input: {
65
124
  backend: string;
66
125
  objectStorage?: ObjectStorage | null | undefined;
67
126
  accountId: string;
68
127
  workspaceId: string;
69
128
  sandboxGroupId: string;
70
- archive: {
71
- bytes: Uint8Array;
72
- descriptor: WorkspaceArchiveDescriptor;
73
- base64: string;
74
- };
129
+ archive:
130
+ | VerifiedHostWorkspaceArchive
131
+ | {
132
+ bytes: Uint8Array;
133
+ descriptor: WorkspaceArchiveDescriptor;
134
+ base64: string;
135
+ };
75
136
  metrics?: {
76
137
  onWorkspaceArchiveObject?: (input: {
77
138
  outcome: "put" | "put_failed" | "deleted_unpublished";
@@ -83,12 +144,15 @@ export async function putVersion1TarArchiveOrInline(input: {
83
144
  workspaceArchiveRef?: WorkspaceArchiveObjectRef;
84
145
  }> {
85
146
  if (input.archive.descriptor.version !== 1) {
147
+ if ("spool" in input.archive) throw new Error("Native snapshots cannot use a portable spool");
86
148
  return { workspaceArchive: input.archive.base64 };
87
149
  }
88
150
  if (input.backend === "opensandbox" && !input.objectStorage) {
89
151
  throw new WorkspaceArchiveObjectStorageRequiredError("opensandbox");
90
152
  }
91
153
  if (!input.objectStorage) {
154
+ if ("spool" in input.archive)
155
+ throw new WorkspaceArchiveObjectStorageRequiredError(input.backend);
92
156
  return { workspaceArchive: input.archive.base64 };
93
157
  }
94
158
  try {
@@ -97,7 +161,7 @@ export async function putVersion1TarArchiveOrInline(input: {
97
161
  accountId: input.accountId,
98
162
  workspaceId: input.workspaceId,
99
163
  sandboxGroupId: input.sandboxGroupId,
100
- archive: { bytes: input.archive.bytes, descriptor: input.archive.descriptor },
164
+ archive: input.archive,
101
165
  });
102
166
  try {
103
167
  input.metrics?.onWorkspaceArchiveObject?.({