@opengeni/worker-bundle 2.0.1 → 2.0.3

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 (28) hide show
  1. package/dist/activities/agent-turn/failure-settlement.d.ts +1 -1
  2. package/dist/activities/agent-turn/history.d.ts +2 -0
  3. package/dist/activities/agent-turn/media-artifacts.d.ts +5 -3
  4. package/dist/activities/retained-screenshots.d.ts +7 -2
  5. package/dist/activities/sandbox-lease.d.ts +3 -4
  6. package/dist/{activities-control-QA6OLRQY.js → activities-control-IVTL723D.js} +204 -23
  7. package/dist/activities-control-IVTL723D.js.map +1 -0
  8. package/dist/{activities-turn-YVVKCGAK.js → activities-turn-EZEDZTEZ.js} +108 -33
  9. package/dist/activities-turn-EZEDZTEZ.js.map +1 -0
  10. package/dist/{chunk-M3NVMF2V.js → chunk-JMO5ZUW3.js} +14 -7
  11. package/dist/{chunk-M3NVMF2V.js.map → chunk-JMO5ZUW3.js.map} +1 -1
  12. package/dist/index.js +7 -2
  13. package/dist/index.js.map +1 -1
  14. package/dist/workflow-bundle.js +4 -1
  15. package/package.json +15 -15
  16. package/src/activities/agent-turn/errors.ts +8 -3
  17. package/src/activities/agent-turn/failure-settlement.ts +40 -8
  18. package/src/activities/agent-turn/history.ts +14 -1
  19. package/src/activities/agent-turn/media-artifacts.ts +2 -1
  20. package/src/activities/agent-turn/sandbox-runtime.ts +25 -9
  21. package/src/activities/agent-turn/stream-attempt.ts +13 -2
  22. package/src/activities/knowledge-indexing.ts +133 -6
  23. package/src/activities/retained-screenshots.ts +82 -11
  24. package/src/activities/sandbox-lease.ts +141 -21
  25. package/src/sandbox-resume.ts +6 -0
  26. package/src/workflows/activities.ts +14 -1
  27. package/dist/activities-control-QA6OLRQY.js.map +0 -1
  28. package/dist/activities-turn-YVVKCGAK.js.map +0 -1
@@ -6,13 +6,16 @@ import {
6
6
  COMPUTER_SCREENSHOT_WORKSPACE_QUOTA_BYTES,
7
7
  RetainedArtifactMetadataSchema,
8
8
  retainedScreenshotReferenceFromFile,
9
+ retainedSessionScreenshotKindFromObjectKey,
9
10
  type RetainedArtifactMetadata,
10
11
  type RetainedArtifactReference,
11
12
  type RetainedOutputUnavailableReason,
13
+ type RetainedSessionScreenshotKind,
12
14
  } from "@opengeni/contracts";
13
15
  import {
14
16
  RetainedScreenshotQuotaExceededError,
15
17
  getRetainedScreenshotArtifact,
18
+ getRetainedScreenshotArtifactForToolCall,
16
19
  isDatabasePersistenceFailure,
17
20
  isSessionEventPersistenceError,
18
21
  prepareRetainedScreenshotArtifact,
@@ -209,14 +212,26 @@ export function toolOutputContainsInlineImage(output: unknown): boolean {
209
212
  return (
210
213
  (record.type === "input_image" &&
211
214
  sdkImageSourceContainsInlineImage(record.image ?? record.image_url ?? record.imageUrl)) ||
212
- (record.type === "image" && decodeInlineMcpImage(record.data, record.mimeType) !== null)
215
+ (record.type === "image" && isDeclaredInlineMcpImage(record))
213
216
  );
214
217
  });
215
218
  }
216
219
  if (!output || typeof output !== "object") return false;
217
220
  const record = output as Record<string, unknown>;
218
221
  if (Array.isArray(record.content)) return toolOutputContainsInlineImage(record.content);
219
- return record.type === "image" && sdkImageSourceContainsInlineImage(record.image);
222
+ return (
223
+ record.type === "image" &&
224
+ (sdkImageSourceContainsInlineImage(record.image) || isDeclaredInlineMcpImage(record))
225
+ );
226
+ }
227
+
228
+ /** Detect image-bearing MCP blocks even when decoding rejects oversized or malformed bytes. */
229
+ function isDeclaredInlineMcpImage(record: Record<string, unknown>): boolean {
230
+ return (
231
+ typeof record.data === "string" &&
232
+ typeof record.mimeType === "string" &&
233
+ record.mimeType.toLowerCase().startsWith("image/")
234
+ );
220
235
  }
221
236
 
222
237
  function decodeInlineMcpImage(
@@ -520,9 +535,14 @@ export function retainedScreenshotIdentity(input: {
520
535
  attemptId: string;
521
536
  toolCallId: string;
522
537
  toolOutputId: string;
538
+ kind?: RetainedSessionScreenshotKind | undefined;
523
539
  }): { artifactId: string; settlementKey: string } {
524
540
  const settlementKey = createHash("sha256")
525
- .update("opengeni:computer-screenshot:v1\0")
541
+ .update(
542
+ input.kind === "browser_screenshot"
543
+ ? "opengeni:browser-screenshot:v1\0"
544
+ : "opengeni:computer-screenshot:v1\0",
545
+ )
526
546
  .update(input.sessionId)
527
547
  .update("\0")
528
548
  .update(input.turnId)
@@ -551,12 +571,13 @@ export function unavailableRetainedSessionImage(input: {
551
571
  toolCallId: string;
552
572
  toolOutputId: string;
553
573
  reason: RetainedOutputUnavailableReason;
574
+ kind?: RetainedSessionScreenshotKind | undefined;
554
575
  }): RetainedArtifactMetadata {
555
576
  const identity = retainedScreenshotIdentity(input);
556
577
  return unavailable(identity.artifactId, input.reason);
557
578
  }
558
579
 
559
- export async function retainComputerScreenshot(input: {
580
+ export async function retainSessionScreenshot(input: {
560
581
  db: Database;
561
582
  objectStorage: ObjectStorage | null;
562
583
  accountId: string;
@@ -565,6 +586,7 @@ export async function retainComputerScreenshot(input: {
565
586
  turnId: string;
566
587
  attemptId: string;
567
588
  output: TypedScreenshotToolOutput;
589
+ kind?: RetainedSessionScreenshotKind | undefined;
568
590
  now?: Date;
569
591
  retentionMs?: number;
570
592
  workspaceQuotaBytes?: number;
@@ -575,6 +597,7 @@ export async function retainComputerScreenshot(input: {
575
597
  attemptId: input.attemptId,
576
598
  toolCallId: input.output.callId,
577
599
  toolOutputId: input.output.toolOutputId,
600
+ kind: input.kind,
578
601
  });
579
602
  let screenshot: ValidatedSessionImage;
580
603
  try {
@@ -594,7 +617,8 @@ export async function retainComputerScreenshot(input: {
594
617
  const retentionExpiresAt = new Date(
595
618
  now.getTime() + (input.retentionMs ?? COMPUTER_SCREENSHOT_RETENTION_MS),
596
619
  );
597
- const objectKey = `workspaces/${input.workspaceId}/files/${identity.artifactId}/retained/session-image.${screenshot.extension}`;
620
+ const objectName = input.kind === "browser_screenshot" ? "browser-screenshot" : "session-image";
621
+ const objectKey = `workspaces/${input.workspaceId}/files/${identity.artifactId}/retained/${objectName}.${screenshot.extension}`;
598
622
  let prepared: RetainedScreenshotArtifact;
599
623
  try {
600
624
  prepared = (
@@ -697,6 +721,9 @@ export async function retainComputerScreenshot(input: {
697
721
  }
698
722
  }
699
723
 
724
+ /** Existing computer callers keep their stable entry point. */
725
+ export const retainComputerScreenshot = retainSessionScreenshot;
726
+
700
727
  /** Replace only new screenshot bytes with a compact receipt before first persistence. */
701
728
  export function compactRetainedScreenshotHistory(
702
729
  history: Array<Record<string, unknown>>,
@@ -874,6 +901,26 @@ async function materializeRetainedScreenshotHistoryWithCache(
874
901
  cache: Map<string, string>,
875
902
  ): Promise<Array<Record<string, unknown>>> {
876
903
  const now = input.now ?? new Date();
904
+ const receiptForMarker = async (
905
+ marker: unknown,
906
+ callId: string | null,
907
+ ): Promise<RetainedArtifactMetadata | null> => {
908
+ if (!isRetainedImageMarker(marker)) return null;
909
+ const receipt = retainedReceipt(marker.artifact);
910
+ if (receipt) return receipt;
911
+ // Older canonical history could truncate the artifact UUID and reason as
912
+ // ordinary text. Recover only from this exact session and an unambiguous
913
+ // tool call; otherwise fail before constructing a malformed model image.
914
+ if (!callId) throw new Error("Retained screenshot receipt has no tool call identity");
915
+ const artifact = await getRetainedScreenshotArtifactForToolCall(
916
+ input.db,
917
+ input.workspaceId,
918
+ input.sessionId,
919
+ callId,
920
+ );
921
+ if (!artifact) throw new Error("Retained screenshot receipt cannot be recovered");
922
+ return reference(artifact);
923
+ };
877
924
  const dataUrlForReceipt = async (receipt: RetainedArtifactMetadata): Promise<string> => {
878
925
  let dataUrl = cache.get(receipt.artifactId);
879
926
  if (!dataUrl) {
@@ -921,8 +968,12 @@ async function materializeRetainedScreenshotHistoryWithCache(
921
968
  }
922
969
  return dataUrl;
923
970
  };
924
- const materializeEntry = async (entry: unknown): Promise<unknown> => {
925
- const receipt = retainedReceiptFromImageContent(entry);
971
+ const materializeEntry = async (entry: unknown, callId: string | null): Promise<unknown> => {
972
+ const image =
973
+ entry && typeof entry === "object" && !Array.isArray(entry)
974
+ ? (entry as Record<string, unknown>).image
975
+ : null;
976
+ const receipt = await receiptForMarker(image, callId);
926
977
  if (!receipt) return entry;
927
978
  const dataUrl = await dataUrlForReceipt(receipt);
928
979
  return { ...(entry as Record<string, unknown>), image: dataUrl };
@@ -930,7 +981,8 @@ async function materializeRetainedScreenshotHistoryWithCache(
930
981
 
931
982
  const materialized: Array<Record<string, unknown>> = [];
932
983
  for (const item of input.history) {
933
- const directReceipt = retainedReceiptFromDirectOutput(item.output);
984
+ const callId = historyCallId(item);
985
+ const directReceipt = await receiptForMarker(item.output, callId);
934
986
  if (directReceipt) {
935
987
  materialized.push({
936
988
  ...item,
@@ -954,7 +1006,7 @@ async function materializeRetainedScreenshotHistoryWithCache(
954
1006
  let changed = false;
955
1007
  const output: unknown[] = [];
956
1008
  for (const entry of outputEntries) {
957
- const next = await materializeEntry(entry);
1009
+ const next = await materializeEntry(entry, callId);
958
1010
  changed ||= next !== entry;
959
1011
  output.push(next);
960
1012
  }
@@ -1059,12 +1111,15 @@ function reference(artifact: RetainedScreenshotArtifact): RetainedArtifactRefere
1059
1111
  if (!artifact.sessionId) {
1060
1112
  throw new Error(`Ready retained screenshot is detached: ${artifact.artifactId}`);
1061
1113
  }
1114
+ const kind = retainedSessionScreenshotKindFromObjectKey(artifact.file.objectKey);
1115
+ if (!kind) throw new Error(`Ready retained screenshot has unknown key: ${artifact.artifactId}`);
1062
1116
  const value = retainedScreenshotReferenceFromFile({
1063
1117
  ...artifact.file,
1064
1118
  sessionId: artifact.sessionId,
1065
1119
  width: artifact.width,
1066
1120
  height: artifact.height,
1067
1121
  expiresAt: artifact.retentionExpiresAt.toISOString(),
1122
+ kind,
1068
1123
  });
1069
1124
  if (!value)
1070
1125
  throw new Error(`Ready retained screenshot receipt is invalid: ${artifact.artifactId}`);
@@ -1110,6 +1165,18 @@ function retainedReceiptFromImageContent(entry: unknown): RetainedArtifactMetada
1110
1165
  return retainedReceipt(marker.artifact);
1111
1166
  }
1112
1167
 
1168
+ function isRetainedImageMarker(value: unknown): value is {
1169
+ type: "retained_artifact";
1170
+ artifact: unknown;
1171
+ } {
1172
+ return Boolean(
1173
+ value &&
1174
+ typeof value === "object" &&
1175
+ !Array.isArray(value) &&
1176
+ (value as Record<string, unknown>).type === RETAINED_IMAGE_MARKER,
1177
+ );
1178
+ }
1179
+
1113
1180
  function retainedReceiptFromDirectOutput(output: unknown): RetainedArtifactMetadata | null {
1114
1181
  if (!output || typeof output !== "object" || Array.isArray(output)) return null;
1115
1182
  const marker = output as Record<string, unknown>;
@@ -1120,7 +1187,11 @@ function retainedReceiptFromDirectOutput(output: unknown): RetainedArtifactMetad
1120
1187
  function retainedReceipt(value: unknown): RetainedArtifactMetadata | null {
1121
1188
  const parsed = RetainedArtifactMetadataSchema.safeParse(value);
1122
1189
  if (!parsed.success) return null;
1123
- return parsed.data.available && parsed.data.kind !== "computer_screenshot" ? null : parsed.data;
1190
+ return parsed.data.available &&
1191
+ parsed.data.kind !== "computer_screenshot" &&
1192
+ parsed.data.kind !== "browser_screenshot"
1193
+ ? null
1194
+ : parsed.data;
1124
1195
  }
1125
1196
 
1126
1197
  function isInlineImageContent(entry: unknown): boolean {
@@ -1130,7 +1201,7 @@ function isInlineImageContent(entry: unknown): boolean {
1130
1201
  (record.type === "input_image" &&
1131
1202
  typeof record.image === "string" &&
1132
1203
  record.image.startsWith("data:image/")) ||
1133
- (record.type === "image" && decodeInlineMcpImage(record.data, record.mimeType) !== null)
1204
+ (record.type === "image" && isDeclaredInlineMcpImage(record))
1134
1205
  );
1135
1206
  }
1136
1207
 
@@ -39,10 +39,10 @@ import {
39
39
  countSandboxLeasesByLiveness,
40
40
  deferRetainedProcessReconciliation,
41
41
  forceDrainOverLimitViewerOnlyBoxes,
42
- getBillingBalance,
43
42
  listCreditBalancesByAccount,
44
43
  listLegacyModalCheckpointSlots,
45
44
  listLiveModalSandboxLeaseAttributions,
45
+ markWarmBillingStopCutoff,
46
46
  listMeterableWarmLeases,
47
47
  listSandboxViewerForceDrainWorkspaceIds,
48
48
  markSandboxCheckpointArtifactDeletePending,
@@ -186,8 +186,7 @@ export type ReapSandboxLeasesResult = {
186
186
  skipped: number;
187
187
  /** Warm viewer-only leases that accrued warm-seconds this tick (P2.1). */
188
188
  metered: number;
189
- /** Viewer-only boxes force-drained because their workspace is over a limit
190
- * (0 balance / over the warm cap) — turn-held boxes are never drained (P2.1). */
189
+ /** Holderless boxes accelerated to draining by the optional warm cap. */
191
190
  forceDrained: number;
192
191
  /** Provider-side Modal orphan sandboxes terminated by the defensive sweep. */
193
192
  modalOrphansTerminated: number;
@@ -256,6 +255,7 @@ export type TerminateBoxFn = (
256
255
  diskBackedArchives?: boolean,
257
256
  releaseFailedCapture?: () => Promise<void>,
258
257
  workspaceId?: string,
258
+ beforeProviderStop?: () => Promise<void>,
259
259
  ) => Promise<boolean | ProviderTerminationOutcome>;
260
260
 
261
261
  export type SweepModalOrphansFn = (
@@ -482,7 +482,14 @@ export function createSandboxLeaseActivities(
482
482
  options: SandboxLeaseActivityOptions = {},
483
483
  ) {
484
484
  const terminateBox: TerminateBoxFn =
485
- options.terminateBox ??
485
+ (options.terminateBox
486
+ ? async (...args: Parameters<TerminateBoxFn>) => {
487
+ // The injected test seam represents the stop itself; the real
488
+ // implementation invokes this only after capture has succeeded.
489
+ await args[10]?.();
490
+ return options.terminateBox!(...args);
491
+ }
492
+ : null) ??
486
493
  (async (
487
494
  settings,
488
495
  lease,
@@ -494,6 +501,7 @@ export function createSandboxLeaseActivities(
494
501
  diskBackedArchives,
495
502
  releaseFailedCapture,
496
503
  workspaceId,
504
+ beforeProviderStop,
497
505
  ) =>
498
506
  await terminateProviderBox(
499
507
  settings,
@@ -508,6 +516,7 @@ export function createSandboxLeaseActivities(
508
516
  diskBackedArchives,
509
517
  releaseFailedCapture,
510
518
  workspaceId,
519
+ beforeProviderStop,
511
520
  ));
512
521
  const sweepModalOrphans: SweepModalOrphansFn =
513
522
  options.sweepModalOrphans ?? sweepModalOrphansForConfiguredBackend;
@@ -735,8 +744,7 @@ export function createSandboxLeaseActivities(
735
744
  metered = meterResult.accrued;
736
745
  const forceDrainWorkspaceIds = new Set<string>();
737
746
  if (
738
- settings.billingMode === "stripe" ||
739
- settings.usageLimitsMode === "managed" ||
747
+ settings.sandboxWarmBillingMode === "credits" ||
740
748
  settings.sandboxMaxWarmSecondsPerWorkspace > 0
741
749
  ) {
742
750
  for (const workspaceId of meterResult.workspaceIds) {
@@ -1507,6 +1515,25 @@ async function reconcileTerminalRetainedProcesses(
1507
1515
  }
1508
1516
  }
1509
1517
 
1518
+ if (
1519
+ observation?.status === "deferred" &&
1520
+ process.providerBackend === "modal" &&
1521
+ !process.providerBindingKey &&
1522
+ !process.deadlineCancellationRequestedAt
1523
+ ) {
1524
+ // An unbound historical command cannot be signalled safely. Still
1525
+ // record deadline intent when its lease matches: a failed observer
1526
+ // must not block capture until the provider expires.
1527
+ try {
1528
+ await requestRetainedProcessDeadlineCancellation(db, processScope);
1529
+ } catch (error) {
1530
+ observability.warn("sandbox reaper: legacy deadline cancellation intent failed", {
1531
+ processId: process.id,
1532
+ error: error instanceof Error ? error.message : String(error),
1533
+ });
1534
+ }
1535
+ }
1536
+
1510
1537
  if (observation === null) {
1511
1538
  const lease = await readLease(db, process.workspaceId, process.sandboxGroupId).catch(
1512
1539
  () => null,
@@ -1583,11 +1610,19 @@ async function reconcileTerminalRetainedProcesses(
1583
1610
  );
1584
1611
  supervisionMetric("cancellation_intent");
1585
1612
  }
1586
- observation = await probe(
1613
+ const legacyDeadlineStop =
1614
+ lease!.rotationReason === "provider_deadline" &&
1615
+ !supervised &&
1616
+ !process.deadlineCancellationRequestedAt;
1617
+ const probeOutcome = await probe(
1587
1618
  settings,
1588
1619
  lease!,
1589
1620
  process,
1590
- claim.ownerState === "background_stopping" ? "cancel" : "observe",
1621
+ (claim.ownerState === "background_stopping" &&
1622
+ !process.deadlineCancellationRequestedAt) ||
1623
+ legacyDeadlineStop
1624
+ ? "cancel"
1625
+ : "observe",
1591
1626
  async (result, chunkId, stream, streamFidelity) => {
1592
1627
  if (
1593
1628
  typeof result !== "string" ||
@@ -1610,7 +1645,20 @@ async function reconcileTerminalRetainedProcesses(
1610
1645
  .catch(() => undefined);
1611
1646
  },
1612
1647
  commandPersistence,
1648
+ ).then(
1649
+ (value) => ({ ok: true as const, value }),
1650
+ (error: unknown) => ({ ok: false as const, error }),
1613
1651
  );
1652
+ // Legacy PTY input reserves Ctrl-C before the cancellation fence.
1653
+ // Non-PTY has no command signal in the Modal SDK: probe it without
1654
+ // injecting Ctrl-C as data, then fence and save after the grace.
1655
+ if (
1656
+ legacyDeadlineStop &&
1657
+ !(await requestRetainedProcessDeadlineCancellation(db, processScope))
1658
+ )
1659
+ throw new Error("Deadline cancellation no longer owns its rotating lease");
1660
+ if (!probeOutcome.ok) throw probeOutcome.error;
1661
+ observation = probeOutcome.value;
1614
1662
  if (supervised) {
1615
1663
  supervisionMetric(
1616
1664
  (await commandPersistence.loadSupervisionReceipt())
@@ -1899,7 +1947,13 @@ export async function probeRetainedProcessAtProvider(
1899
1947
  if (pending) {
1900
1948
  if (!captureOutput) throw new Error("Pending command output requires its persistence callback");
1901
1949
  await captureRetainedProbeOutput(process.id, pending.result, captureOutput);
1902
- return classifyRetainedProcessPollResult(pending.result, process.providerSessionId);
1950
+ const pendingObservation = classifyRetainedProcessPollResult(
1951
+ pending.result,
1952
+ process.providerSessionId,
1953
+ );
1954
+ // Capture of a previous page must not consume the only deadline stop
1955
+ // attempt. Terminal proof needs no further provider call.
1956
+ if (mode !== "cancel" || pendingObservation.status === "proved") return pendingObservation;
1903
1957
  }
1904
1958
  const envelopeBackend = (lease.resumeState as { backendId?: unknown }).backendId;
1905
1959
  if (
@@ -1996,6 +2050,7 @@ export async function probeRetainedProcessAtProvider(
1996
2050
  if (typeof session.writeStdin !== "function") {
1997
2051
  return { status: "deferred", reason: "backend_unsupported" };
1998
2052
  }
2053
+ let legacyPty = false;
1999
2054
  if (process.providerBackend === "modal") {
2000
2055
  let liveBinding: Awaited<ReturnType<typeof resolveModalCheckpointProviderBindingForSession>>;
2001
2056
  try {
@@ -2027,6 +2082,7 @@ export async function probeRetainedProcessAtProvider(
2027
2082
  if (!command || !providerPersistence || !session.bindProviderCommand)
2028
2083
  return { status: "deferred", reason: "process_observation_unavailable" };
2029
2084
  session.bindProviderCommand(process.providerSessionId, command, providerPersistence);
2085
+ legacyPty = command.pty === true;
2030
2086
  }
2031
2087
  const capturePage = async (value: unknown): Promise<void> => {
2032
2088
  if (!captureOutput) {
@@ -2062,7 +2118,14 @@ export async function probeRetainedProcessAtProvider(
2062
2118
  result = await withRetainedProcessProbeTimeout(
2063
2119
  session.writeStdin({
2064
2120
  sessionId: process.providerSessionId,
2065
- chars: mode === "cancel" && !supervisedCancelled ? "\u0003" : "",
2121
+ // Ctrl-C is a signal only through a PTY. For a non-PTY command it is
2122
+ // ordinary stdin data and can corrupt the command's input.
2123
+ chars:
2124
+ mode === "cancel" &&
2125
+ !supervisedCancelled &&
2126
+ (process.providerBackend !== "modal" || legacyPty)
2127
+ ? "\u0003"
2128
+ : "",
2066
2129
  yieldTimeMs: 1_000,
2067
2130
  maxOutputTokens: 2_000,
2068
2131
  }),
@@ -2092,6 +2155,9 @@ export async function probeRetainedProcessAtProvider(
2092
2155
  observation.status === "deferred" &&
2093
2156
  observation.reason === "provider_running" &&
2094
2157
  lease.rotationRequestedAt !== null &&
2158
+ mode !== "cancel" &&
2159
+ !process.cancellationRequestedAt &&
2160
+ legacyPty &&
2095
2161
  !(await providerPersistence
2096
2162
  ?.load()
2097
2163
  .then((command) => command?.kind === "modal-router-v1" && command.supervision))
@@ -2197,19 +2263,25 @@ async function accrueWarmTick(
2197
2263
  }
2198
2264
  await forEachWithConcurrency(leases, SANDBOX_MAINTENANCE_ITEM_CONCURRENCY, async (lease) => {
2199
2265
  try {
2200
- const rate = sandboxWarmRateMicrosPerSecond(settings, lease.backend);
2266
+ const rate =
2267
+ settings.sandboxWarmBillingMode === "usage_only"
2268
+ ? 0
2269
+ : sandboxWarmRateMicrosPerSecond(settings, lease.backend);
2201
2270
  const result = await accrueWarmSeconds(db, {
2202
2271
  accountId: lease.accountId,
2203
2272
  workspaceId: lease.workspaceId,
2204
2273
  sandboxGroupId: lease.sandboxGroupId,
2205
2274
  expectedEpoch: lease.leaseEpoch,
2206
2275
  warmRateMicrosPerSecond: rate,
2276
+ billingMode: settings.sandboxWarmBillingMode,
2207
2277
  subjectId: lease.sandboxGroupId,
2208
2278
  });
2209
2279
  if (result.accrued) {
2210
2280
  accrued += 1;
2211
2281
  }
2212
- recordCreditMicros(observability, "usage", result.costMicros);
2282
+ if (result.costMicros > 0 && settings.sandboxWarmBillingMode === "credits") {
2283
+ recordCreditMicros(observability, "usage", result.costMicros);
2284
+ }
2213
2285
  } catch (error) {
2214
2286
  observability.warn("sandbox reaper: warm-seconds accrual failed for lease", {
2215
2287
  workspaceId: lease.workspaceId,
@@ -2325,8 +2397,8 @@ async function refreshSandboxInventoryGauge(
2325
2397
 
2326
2398
  /**
2327
2399
  * The per-workspace warm-cap + force-drain pass (P2.1). For each workspace with a
2328
- * live warm box, under the usage lock: if it is at 0 balance (when a billing /
2329
- * managed mode is on) or over its warm cap, force-drain its VIEWER-ONLY boxes
2400
+ * live warm box, under the usage lock: if it is over its optional warm cap,
2401
+ * accelerate only holderless boxes to draining
2330
2402
  * (guarded turn_holders=0 — a paying turn is never killed). Returns the count of
2331
2403
  * viewer-only boxes force-drained. Per-workspace best-effort.
2332
2404
  */
@@ -2336,8 +2408,7 @@ async function forceDrainOverLimitWorkspaces(
2336
2408
  workspaceIds: Set<string>,
2337
2409
  observability: ActivityServices["observability"],
2338
2410
  ): Promise<number> {
2339
- const enforceBalance =
2340
- settings.billingMode === "stripe" || settings.usageLimitsMode === "managed";
2411
+ const enforceBalance = false;
2341
2412
  const cap = settings.sandboxMaxWarmSecondsPerWorkspace;
2342
2413
  let forceDrained = 0;
2343
2414
  await forEachWithConcurrency(
@@ -2345,13 +2416,8 @@ async function forceDrainOverLimitWorkspaces(
2345
2416
  SANDBOX_MAINTENANCE_ITEM_CONCURRENCY,
2346
2417
  async (workspaceId) => {
2347
2418
  try {
2348
- const { accountId } = await rlsContextForWorkspace(db, workspaceId);
2349
- const balance = enforceBalance
2350
- ? await getBillingBalance(db, accountId)
2351
- : ({ balanceMicros: 1 } as { balanceMicros: number });
2352
2419
  const result = await forceDrainOverLimitViewerOnlyBoxes(db, {
2353
2420
  workspaceId,
2354
- balanceMicros: balance.balanceMicros,
2355
2421
  enforceBalance,
2356
2422
  maxWarmSecondsPerWorkspace: cap,
2357
2423
  idleGraceMs: settings.sandboxIdleGraceMs,
@@ -2939,6 +3005,20 @@ async function terminateDrainableBox(
2939
3005
  ) {
2940
3006
  return false;
2941
3007
  }
3008
+ // Stamp the charge horizon after verified capture and immediately before
3009
+ // provider stop. Capture can fail and release its claim, allowing re-arm.
3010
+ // A retry after stop success cannot charge post-termination wall time.
3011
+ let stopCutoffMarked = false;
3012
+ const beforeProviderStop = async (): Promise<void> => {
3013
+ const cutoff = await markWarmBillingStopCutoff(db, {
3014
+ accountId,
3015
+ workspaceId: row.workspaceId,
3016
+ sandboxGroupId: row.sandboxGroupId,
3017
+ expectedEpoch: row.leaseEpoch,
3018
+ });
3019
+ if (!cutoff) throw new Error("sandbox stop cutoff was fenced before provider termination");
3020
+ stopCutoffMarked = true;
3021
+ };
2942
3022
  const termination: ProviderTerminationOutcome | boolean = providerMissingBeforeCapture
2943
3023
  ? { terminated: true, providerMissingBeforeCapture: true }
2944
3024
  : await terminateBox(
@@ -2962,12 +3042,49 @@ async function terminateDrainableBox(
2962
3042
  });
2963
3043
  },
2964
3044
  row.workspaceId,
3045
+ beforeProviderStop,
2965
3046
  );
2966
3047
  const terminated = typeof termination === "boolean" ? termination : termination.terminated;
2967
3048
  if (!terminated) {
2968
3049
  return false;
2969
3050
  }
2970
3051
 
3052
+ // Self-hosted/none and an unassigned lease have no provider stop callback;
3053
+ // finish their usage at logical detach without touching customer compute.
3054
+ // A managed instance that was stopped without its callback must fail closed.
3055
+ const providerMissingDuringStop =
3056
+ providerMissingBeforeCapture ||
3057
+ (typeof termination !== "boolean" && termination.providerMissingBeforeCapture);
3058
+ if (!stopCutoffMarked && !providerMissingDuringStop) {
3059
+ if (managedProvider && lease.instanceId) {
3060
+ throw new Error("managed sandbox stopped without a warm usage cutoff");
3061
+ }
3062
+ await beforeProviderStop();
3063
+ }
3064
+
3065
+ // Stop was confirmed but the lease is not cold yet. This final epoch-fenced
3066
+ // tick settles the interval since the last heartbeat/reaper sweep, including
3067
+ // a box drained before its first periodic tick. If settlement fails, keep
3068
+ // the draining receipt for retry instead of silently losing the charge.
3069
+ const finalRate =
3070
+ settings.sandboxWarmBillingMode === "usage_only"
3071
+ ? 0
3072
+ : sandboxWarmRateMicrosPerSecond(settings, backend);
3073
+ if (!providerMissingDuringStop) {
3074
+ const finalTick = await accrueWarmSeconds(db, {
3075
+ accountId,
3076
+ workspaceId: row.workspaceId,
3077
+ sandboxGroupId: row.sandboxGroupId,
3078
+ expectedEpoch: row.leaseEpoch,
3079
+ warmRateMicrosPerSecond: finalRate,
3080
+ billingMode: settings.sandboxWarmBillingMode,
3081
+ finalDrain: true,
3082
+ });
3083
+ if (finalTick.costMicros > 0 && settings.sandboxWarmBillingMode === "credits") {
3084
+ recordCreditMicros(observability, "usage", finalTick.costMicros);
3085
+ }
3086
+ }
3087
+
2971
3088
  const providerMissing =
2972
3089
  providerMissingBeforeCapture ||
2973
3090
  (typeof termination === "boolean" ? false : termination.providerMissingBeforeCapture);
@@ -3097,6 +3214,7 @@ export async function terminateProviderBox(
3097
3214
  diskBackedArchives = false,
3098
3215
  releaseFailedCapture?: () => Promise<void>,
3099
3216
  workspaceId?: string,
3217
+ beforeProviderStop?: () => Promise<void>,
3100
3218
  ): Promise<ProviderTerminationOutcome> {
3101
3219
  const durableBackendId = (lease.resumeBackendId ?? lease.backend) as string;
3102
3220
  const backend = sandboxBackendForSdkBackendId(durableBackendId) ?? durableBackendId;
@@ -3196,6 +3314,7 @@ export async function terminateProviderBox(
3196
3314
  return { terminated: false, providerMissingBeforeCapture: false };
3197
3315
  }
3198
3316
  try {
3317
+ await beforeProviderStop?.();
3199
3318
  await terminateModalById(settings, lease.instanceId);
3200
3319
  } catch (error) {
3201
3320
  if (!isProviderSandboxNotFoundError("modal", error)) {
@@ -3439,6 +3558,7 @@ export async function terminateProviderBox(
3439
3558
  throw new Error(`sandbox backend ${backend} returned no terminable session state`);
3440
3559
  }
3441
3560
  prepareProviderForTeardownAfterCapture(backend, session);
3561
+ await beforeProviderStop?.();
3442
3562
  await terminateManagedSandboxSession(client, sessionState, session);
3443
3563
  return { terminated: true, providerMissingBeforeCapture: false };
3444
3564
  } catch (error) {
@@ -24,6 +24,7 @@ import {
24
24
  effectiveSandboxLifecycle,
25
25
  sandboxArchiveCaptureTimeoutMs,
26
26
  sandboxLifecycleTransitionWaitMs,
27
+ sandboxWarmRateMicrosPerSecond,
27
28
  type Settings,
28
29
  } from "@opengeni/config";
29
30
  import { randomUUID } from "node:crypto";
@@ -1153,6 +1154,10 @@ export async function resumeBoxForTurn(
1153
1154
  holderId,
1154
1155
  subjectId: ids.sessionId,
1155
1156
  backend: ids.backend,
1157
+ warmBilling: {
1158
+ mode: settings.sandboxWarmBillingMode,
1159
+ rateMicrosPerSecond: sandboxWarmRateMicrosPerSecond(settings, ids.backend),
1160
+ },
1156
1161
  os,
1157
1162
  // IMAGE IS SHARED STATE (B3): thread the resolved image so the lease stamps it +
1158
1163
  // conflicts on a live box already running a different image. A
@@ -1283,6 +1288,7 @@ export async function resumeBoxForTurn(
1283
1288
  holderId,
1284
1289
  expectedEpoch: heartbeat.expectedEpoch,
1285
1290
  leaseTtlMs: heartbeat.leaseTtlMs,
1291
+ billingMode: services.settings.sandboxWarmBillingMode,
1286
1292
  })
1287
1293
  .then(async (status) => {
1288
1294
  if (heartbeat !== holderLeaseHeartbeat) return;
@@ -1,4 +1,9 @@
1
- import { ActivityCancellationType, proxyActivities } from "@temporalio/workflow";
1
+ import {
2
+ ActivityCancellationType,
3
+ ActivityFailure,
4
+ ApplicationFailure,
5
+ proxyActivities,
6
+ } from "@temporalio/workflow";
2
7
  import type * as activities from "../activities";
3
8
  import {
4
9
  KNOWLEDGE_SOURCE_SYNC_ACTIVITY_HEARTBEAT_TIMEOUT_MS,
@@ -160,6 +165,14 @@ export const knowledgeSourceSyncActivity = proxyActivities<
160
165
  });
161
166
 
162
167
  export function workflowFailureMessage(error: unknown): string {
168
+ if (
169
+ error instanceof ActivityFailure &&
170
+ error.activityType === "runAgentTurn" &&
171
+ error.cause instanceof ApplicationFailure &&
172
+ error.cause.type === "TurnExecutionPolicyDefinitionMismatchError"
173
+ ) {
174
+ return error.cause.message;
175
+ }
163
176
  if (error instanceof Error) {
164
177
  return error.message;
165
178
  }