@opengeni/worker-bundle 1.0.0-canary.0 → 1.0.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 (244) hide show
  1. package/dist/activities/agent-run-admission.d.ts +3 -1
  2. package/dist/activities/agent-turn/admission.d.ts +24 -3
  3. package/dist/activities/agent-turn/agent-build.d.ts +4 -0
  4. package/dist/activities/agent-turn/claude-usage-observer.d.ts +58 -0
  5. package/dist/activities/agent-turn/code-search.d.ts +60 -0
  6. package/dist/activities/agent-turn/codex-capacity.d.ts +13 -0
  7. package/dist/activities/agent-turn/codex-plan-entitlement.d.ts +105 -0
  8. package/dist/activities/agent-turn/codex.d.ts +3 -2
  9. package/dist/activities/agent-turn/compaction-prep.d.ts +21 -5
  10. package/dist/activities/agent-turn/credential-leases.d.ts +19 -33
  11. package/dist/activities/agent-turn/credential-restriction.d.ts +3 -0
  12. package/dist/activities/agent-turn/errors.d.ts +82 -11
  13. package/dist/activities/agent-turn/failure-settlement.d.ts +6 -3
  14. package/dist/activities/agent-turn/final-reply.d.ts +18 -0
  15. package/dist/activities/agent-turn/finalization-monitor.d.ts +17 -0
  16. package/dist/activities/agent-turn/finalization.d.ts +4 -2
  17. package/dist/activities/agent-turn/github-repository-bindings.d.ts +2 -0
  18. package/dist/activities/agent-turn/governance-model.d.ts +9 -2
  19. package/dist/activities/agent-turn/history-sink.d.ts +2 -0
  20. package/dist/activities/agent-turn/history.d.ts +2 -0
  21. package/dist/activities/agent-turn/input-wait-reply.d.ts +41 -0
  22. package/dist/activities/agent-turn/media-artifacts.d.ts +5 -3
  23. package/dist/activities/agent-turn/model-call-admission.d.ts +15 -0
  24. package/dist/activities/agent-turn/model-usage.d.ts +6 -0
  25. package/dist/activities/agent-turn/model-wire-dispatch.d.ts +15 -0
  26. package/dist/activities/agent-turn/optional-repositories.d.ts +59 -0
  27. package/dist/activities/agent-turn/provider-dispatch-barrier.d.ts +12 -1
  28. package/dist/activities/agent-turn/quiescence.d.ts +7 -7
  29. package/dist/activities/agent-turn/recovery-warning.d.ts +9 -0
  30. package/dist/activities/agent-turn/refresh-credentials.d.ts +6 -0
  31. package/dist/activities/agent-turn/run-credentials.d.ts +8 -3
  32. package/dist/activities/agent-turn/sandbox-establish.d.ts +2 -1
  33. package/dist/activities/agent-turn/sandbox-provision.d.ts +3 -1
  34. package/dist/activities/agent-turn/sandbox-route.d.ts +3 -20
  35. package/dist/activities/agent-turn/sandbox-runtime.d.ts +2 -2
  36. package/dist/activities/agent-turn/session-title.d.ts +34 -2
  37. package/dist/activities/agent-turn/skill-checkout.d.ts +25 -6
  38. package/dist/activities/agent-turn/skill-read.d.ts +70 -2
  39. package/dist/activities/agent-turn/skill-remove.d.ts +14 -0
  40. package/dist/activities/agent-turn/skill-search.d.ts +3 -1
  41. package/dist/activities/agent-turn/skill-tools.d.ts +15 -2
  42. package/dist/activities/agent-turn/skill-transfer.d.ts +18 -6
  43. package/dist/activities/agent-turn/stream-attempt.d.ts +7 -3
  44. package/dist/activities/agent-turn/subscription-lease.d.ts +38 -0
  45. package/dist/activities/agent-turn/tool-environment.d.ts +56 -6
  46. package/dist/activities/agent-turn/tool-policy.d.ts +43 -0
  47. package/dist/activities/agent-turn/turn-context.d.ts +29 -0
  48. package/dist/activities/agent-turn/turn-sandbox-access.d.ts +3 -0
  49. package/dist/activities/agent-turn/xai-capacity.d.ts +2 -1
  50. package/dist/activities/automations.d.ts +3 -1
  51. package/dist/activities/browser-deadline-checkpoint.d.ts +29 -0
  52. package/dist/activities/codemode-dispatcher.d.ts +9 -2
  53. package/dist/activities/codex-capacity.d.ts +18 -2
  54. package/dist/activities/codex-rotation.d.ts +7 -1
  55. package/dist/activities/context-compaction.d.ts +16 -2
  56. package/dist/activities/gmail-files.d.ts +32 -0
  57. package/dist/activities/goals.d.ts +8 -0
  58. package/dist/activities/knowledge-indexing.d.ts +19 -0
  59. package/dist/activities/mcp-account-routes.d.ts +3 -0
  60. package/dist/activities/mcp-credentials.d.ts +12 -13
  61. package/dist/activities/mcp-operation-authority.d.ts +0 -5
  62. package/dist/activities/model-call-fact-reconciler.d.ts +34 -0
  63. package/dist/activities/open-suffix-resume.d.ts +13 -0
  64. package/dist/activities/parent-wake.d.ts +6 -12
  65. package/dist/activities/retained-screenshots.d.ts +7 -2
  66. package/dist/activities/rig-verification.d.ts +2 -3
  67. package/dist/activities/run-credential-renewal.d.ts +1 -0
  68. package/dist/activities/run-credentials.d.ts +15 -3
  69. package/dist/activities/sandbox-images.d.ts +1 -1
  70. package/dist/activities/sandbox-lease.d.ts +47 -9
  71. package/dist/activities/scheduled-tasks.d.ts +4 -35
  72. package/dist/activities/session-state.d.ts +70 -3
  73. package/dist/activities/types.d.ts +55 -6
  74. package/dist/activities/workspace-capture.d.ts +2 -0
  75. package/dist/activities/workspace-credential-provider.d.ts +46 -0
  76. package/dist/{activities-control-HMFGSKC5.js → activities-control-BNPTRTOE.js} +1896 -418
  77. package/dist/activities-control-BNPTRTOE.js.map +1 -0
  78. package/dist/activities-control.d.ts +182 -4
  79. package/dist/{activities-turn-AP67JADS.js → activities-turn-5LGZOSVU.js} +4476 -1327
  80. package/dist/activities-turn-5LGZOSVU.js.map +1 -0
  81. package/dist/activities.d.ts +182 -4
  82. package/dist/artifact-materializer-entry.js +5 -2
  83. package/dist/artifact-materializer-entry.js.map +1 -1
  84. package/dist/artifact-outbox-entry.js +5 -2
  85. package/dist/artifact-outbox-entry.js.map +1 -1
  86. package/dist/browser-deadline-checkpoint-contract.d.ts +2 -0
  87. package/dist/{chunk-A4XQWVZK.js → chunk-3ZTRWDMN.js} +307 -4
  88. package/dist/chunk-3ZTRWDMN.js.map +1 -0
  89. package/dist/{chunk-RAMNYSAW.js → chunk-4ER3YXOT.js} +22 -4
  90. package/dist/chunk-4ER3YXOT.js.map +1 -0
  91. package/dist/{chunk-CYITU5UH.js → chunk-LJX7Z2KP.js} +1445 -343
  92. package/dist/chunk-LJX7Z2KP.js.map +1 -0
  93. package/dist/editable-artifact-materializer-service.d.ts +2 -2
  94. package/dist/editable-artifact-outbox-service.d.ts +2 -2
  95. package/dist/host-export-pump.d.ts +3 -1
  96. package/dist/host-export.d.ts +1 -1
  97. package/dist/host-export.js +1 -1
  98. package/dist/http.d.ts +1 -1
  99. package/dist/index.d.ts +95 -3
  100. package/dist/index.js +137 -43
  101. package/dist/index.js.map +1 -1
  102. package/dist/observability-metrics.d.ts +118 -8
  103. package/dist/retained-process-diagnostics.d.ts +4 -0
  104. package/dist/retained-process-retry.d.ts +7 -0
  105. package/dist/sandbox-resume.d.ts +55 -13
  106. package/dist/sandbox-routing.d.ts +5 -3
  107. package/dist/sandbox-snapshot-diagnostics.d.ts +20 -0
  108. package/dist/session-event-fanout.d.ts +6 -0
  109. package/dist/warm-capture-holder.d.ts +3 -0
  110. package/dist/worker-service-lifecycle.d.ts +12 -0
  111. package/dist/workflow-bundle.js +273 -41
  112. package/dist/workflows/activities.d.ts +1 -1
  113. package/dist/workflows/browser-deadline-checkpoint.d.ts +9 -0
  114. package/dist/workflows/scheduled-tasks.d.ts +4 -0
  115. package/dist/workflows/session.d.ts +36 -0
  116. package/dist/workflows.d.ts +1 -0
  117. package/package.json +21 -19
  118. package/src/activities/agent-run-admission.ts +17 -1
  119. package/src/activities/agent-turn/admission.ts +66 -3
  120. package/src/activities/agent-turn/agent-build.ts +84 -10
  121. package/src/activities/agent-turn/claim.ts +186 -43
  122. package/src/activities/agent-turn/claude-usage-observer.ts +221 -0
  123. package/src/activities/agent-turn/code-search.ts +275 -0
  124. package/src/activities/agent-turn/codex-capacity.ts +172 -8
  125. package/src/activities/agent-turn/codex-plan-entitlement.ts +254 -0
  126. package/src/activities/agent-turn/codex.ts +13 -5
  127. package/src/activities/agent-turn/compaction-prep.ts +170 -102
  128. package/src/activities/agent-turn/credential-leases.ts +122 -216
  129. package/src/activities/agent-turn/credential-restriction.ts +14 -0
  130. package/src/activities/agent-turn/errors.ts +647 -48
  131. package/src/activities/agent-turn/failure-settlement.ts +682 -130
  132. package/src/activities/agent-turn/final-reply.ts +46 -0
  133. package/src/activities/agent-turn/finalization-monitor.ts +120 -0
  134. package/src/activities/agent-turn/finalization.ts +146 -43
  135. package/src/activities/agent-turn/github-repository-bindings.ts +3 -1
  136. package/src/activities/agent-turn/governance-model.ts +69 -28
  137. package/src/activities/agent-turn/history-sink.ts +16 -2
  138. package/src/activities/agent-turn/history.ts +38 -11
  139. package/src/activities/agent-turn/input-wait-reply.ts +103 -0
  140. package/src/activities/agent-turn/knowledge-source-tools.ts +2 -0
  141. package/src/activities/agent-turn/media-artifacts.ts +2 -1
  142. package/src/activities/agent-turn/model-call-admission.ts +94 -0
  143. package/src/activities/agent-turn/model-usage.ts +64 -3
  144. package/src/activities/agent-turn/model-wire-dispatch.ts +27 -0
  145. package/src/activities/agent-turn/optional-repositories.ts +224 -0
  146. package/src/activities/agent-turn/provider-dispatch-barrier.ts +22 -0
  147. package/src/activities/agent-turn/quiescence.ts +10 -7
  148. package/src/activities/agent-turn/recovery-warning.ts +16 -0
  149. package/src/activities/agent-turn/refresh-credentials.ts +44 -0
  150. package/src/activities/agent-turn/run-credentials.ts +116 -55
  151. package/src/activities/agent-turn/run.ts +320 -93
  152. package/src/activities/agent-turn/sandbox-establish.ts +84 -8
  153. package/src/activities/agent-turn/sandbox-provision.ts +3 -1
  154. package/src/activities/agent-turn/sandbox-route.ts +4 -21
  155. package/src/activities/agent-turn/sandbox-runtime.ts +343 -18
  156. package/src/activities/agent-turn/session-title.ts +81 -5
  157. package/src/activities/agent-turn/skill-checkout.ts +111 -24
  158. package/src/activities/agent-turn/skill-install.ts +1 -1
  159. package/src/activities/agent-turn/skill-read.ts +374 -28
  160. package/src/activities/agent-turn/skill-remove.ts +46 -0
  161. package/src/activities/agent-turn/skill-save.ts +1 -1
  162. package/src/activities/agent-turn/skill-search.ts +10 -1
  163. package/src/activities/agent-turn/skill-selection.ts +4 -0
  164. package/src/activities/agent-turn/skill-tools.ts +112 -18
  165. package/src/activities/agent-turn/skill-transfer.ts +68 -23
  166. package/src/activities/agent-turn/stream-attempt.ts +313 -77
  167. package/src/activities/agent-turn/subscription-lease.ts +115 -0
  168. package/src/activities/agent-turn/tool-environment.ts +316 -167
  169. package/src/activities/agent-turn/tool-policy.ts +80 -1
  170. package/src/activities/agent-turn/turn-context.ts +35 -0
  171. package/src/activities/agent-turn/turn-sandbox-access.ts +7 -1
  172. package/src/activities/agent-turn/xai-capacity.ts +94 -51
  173. package/src/activities/automations.ts +76 -4
  174. package/src/activities/browser-deadline-checkpoint.ts +279 -0
  175. package/src/activities/capabilities.ts +108 -11
  176. package/src/activities/codemode-dispatcher.ts +36 -8
  177. package/src/activities/codex-capacity.ts +60 -25
  178. package/src/activities/codex-rotation.ts +39 -17
  179. package/src/activities/context-compaction.ts +75 -18
  180. package/src/activities/gmail-files.ts +88 -0
  181. package/src/activities/goals.ts +116 -121
  182. package/src/activities/google-drive-publication.ts +1 -3
  183. package/src/activities/image-generation-operation.ts +1 -1
  184. package/src/activities/knowledge-indexing.ts +269 -15
  185. package/src/activities/knowledge-source-sync.ts +10 -0
  186. package/src/activities/mcp-account-routes.ts +17 -0
  187. package/src/activities/mcp-credentials.ts +83 -194
  188. package/src/activities/mcp-operation-authority.ts +16 -41
  189. package/src/activities/model-call-fact-reconciler.ts +112 -0
  190. package/src/activities/open-suffix-resume.ts +15 -2
  191. package/src/activities/parent-wake.ts +46 -21
  192. package/src/activities/retained-screenshots.ts +82 -11
  193. package/src/activities/rig-verification.ts +38 -37
  194. package/src/activities/run-credential-renewal.ts +13 -1
  195. package/src/activities/run-credentials.ts +59 -17
  196. package/src/activities/run-input.ts +25 -16
  197. package/src/activities/sandbox-images.ts +0 -1
  198. package/src/activities/sandbox-lease.ts +798 -82
  199. package/src/activities/scheduled-tasks.ts +550 -178
  200. package/src/activities/session-state.ts +245 -6
  201. package/src/activities/site-auth-maintenance.ts +3 -0
  202. package/src/activities/types.ts +72 -5
  203. package/src/activities/workflow-wake.ts +2 -17
  204. package/src/activities/workspace-capture.ts +13 -2
  205. package/src/activities/workspace-credential-provider.ts +283 -0
  206. package/src/activities-control.ts +4 -0
  207. package/src/activities-turn.ts +5 -0
  208. package/src/activities.ts +13 -0
  209. package/src/activity-services.ts +16 -4
  210. package/src/browser-deadline-checkpoint-contract.ts +5 -0
  211. package/src/editable-artifact-hint-broker.ts +3 -0
  212. package/src/editable-artifact-materializer-service.ts +7 -3
  213. package/src/editable-artifact-outbox-service.ts +4 -4
  214. package/src/github-rest-mcp.ts +1 -2
  215. package/src/host-export-pump.ts +26 -4
  216. package/src/host-export.ts +3 -0
  217. package/src/http.ts +4 -2
  218. package/src/index.ts +99 -37
  219. package/src/observability-metrics.ts +510 -5
  220. package/src/personal-github-git-credentials.ts +2 -1
  221. package/src/pr-review-credentials.ts +1 -3
  222. package/src/retained-process-diagnostics.ts +50 -0
  223. package/src/retained-process-retry.ts +36 -0
  224. package/src/sandbox-resume.ts +388 -101
  225. package/src/sandbox-routing.ts +150 -25
  226. package/src/sandbox-snapshot-diagnostics.ts +112 -0
  227. package/src/session-event-fanout.ts +23 -0
  228. package/src/warm-capture-holder.ts +15 -0
  229. package/src/worker-service-lifecycle.ts +53 -0
  230. package/src/workflows/activities.ts +16 -1
  231. package/src/workflows/browser-deadline-checkpoint.ts +50 -0
  232. package/src/workflows/file-upload-reaper.ts +2 -0
  233. package/src/workflows/scheduled-tasks.ts +26 -13
  234. package/src/workflows/session.ts +242 -22
  235. package/src/workflows.ts +4 -0
  236. package/dist/activities/agent-turn/knowledge-sources.d.ts +0 -20
  237. package/dist/activities/packs.d.ts +0 -55
  238. package/dist/activities-control-HMFGSKC5.js.map +0 -1
  239. package/dist/activities-turn-AP67JADS.js.map +0 -1
  240. package/dist/chunk-A4XQWVZK.js.map +0 -1
  241. package/dist/chunk-CYITU5UH.js.map +0 -1
  242. package/dist/chunk-RAMNYSAW.js.map +0 -1
  243. package/src/activities/agent-turn/knowledge-sources.ts +0 -86
  244. package/src/activities/packs.ts +0 -166
@@ -13,14 +13,19 @@
13
13
  // harness and executes admitted drains concurrently. There is no per-session
14
14
  // timer, viewer activity, owner task queue, or provider-specific lifecycle path
15
15
  // in the normal drain state machine.
16
+ import { warnDrainSnapshotFailure } from "../sandbox-snapshot-diagnostics";
17
+ import { publishDurableSessionEvents } from "../session-event-fanout";
18
+ import { warnRetainedProcessProofFailure } from "../retained-process-diagnostics";
19
+ import { retainedProcessDeadlineRetryMs } from "../retained-process-retry";
16
20
 
17
21
  import { createHash, randomUUID } from "node:crypto";
18
- import { retainedProviderCommandPersistence } from "@opengeni/db/retained-provider-commands";
22
+ import { requestRetainedProcessDeadlineCancellation } from "@opengeni/db/retained-provider-commands";
19
23
  import type { ProviderCommandPersistence, ProviderCommandSession } from "@opengeni/runtime";
20
24
  import { Context } from "@temporalio/activity";
21
25
  import { OpLostReason, OpState, type OpStatus } from "@opengeni/agent-proto";
22
26
  import {
23
27
  accrueWarmSeconds,
28
+ retainedProviderCommandPersistence,
24
29
  adoptLegacyModalCheckpointArtifact,
25
30
  confirmDrainCold,
26
31
  appendSessionEventToSandboxGroup,
@@ -36,10 +41,17 @@ import {
36
41
  countSandboxLeasesByLiveness,
37
42
  deferRetainedProcessReconciliation,
38
43
  forceDrainOverLimitViewerOnlyBoxes,
39
- getBillingBalance,
40
44
  listCreditBalancesByAccount,
45
+ countActiveUsers,
46
+ readCreditGrantTotals,
47
+ readManagedAuthNewSignupsSwitch,
48
+ readVerifiedSignupTrialSwitch,
41
49
  listLegacyModalCheckpointSlots,
42
50
  listLiveModalSandboxLeaseAttributions,
51
+ listPendingModalProviderCreates,
52
+ claimModalProviderCreateRecovery,
53
+ recordRecoveredModalProviderCreate,
54
+ markWarmBillingStopCutoff,
43
55
  listMeterableWarmLeases,
44
56
  listSandboxViewerForceDrainWorkspaceIds,
45
57
  markSandboxCheckpointArtifactDeletePending,
@@ -49,13 +61,16 @@ import {
49
61
  recordRetainedProcessReconciliationProof,
50
62
  replaceWorkspaceArchiveCaptureAfterProof,
51
63
  readLease,
64
+ readWorkspaceArchiveCapturePreflight,
52
65
  reapExpiredSessionListSnapshots,
53
66
  reapStaleLeaseHoldersGlobal,
54
67
  requestDueSandboxRotationsGlobal,
55
68
  readSandboxRotationBacklog,
69
+ readRecentSandboxRecoveryObservations,
56
70
  workspaceArchiveCaptureDeadlineElapsed,
57
71
  retainedProcessReconciliationProof,
58
72
  retainedProcessSettlementIdentity,
73
+ recoverManagedSessionBackgroundCommand,
59
74
  settleClaimedConnectedMachineBackgroundCommand,
60
75
  settleSandboxCheckpointArtifactGc,
61
76
  rlsContextForWorkspace,
@@ -67,9 +82,12 @@ import {
67
82
  type LeaseSnapshot,
68
83
  } from "@opengeni/db";
69
84
  import {
85
+ claimConnectedCommandOutputReleases,
70
86
  claimConnectedMachineSessionBackgroundCommands,
71
87
  deferConnectedMachineBackgroundCommandReconciliation,
72
88
  recordConnectedMachineBackgroundCommandProof,
89
+ recordConnectedCommandOutputConsumption,
90
+ settleConnectedCommandOutputReleaseClaim,
73
91
  type ConnectedMachineBackgroundCommandClaim,
74
92
  type ConnectedMachineBackgroundCommandProof,
75
93
  } from "@opengeni/db/session-background-commands";
@@ -107,6 +125,7 @@ import {
107
125
  providerWorkspaceCapturePolicy,
108
126
  resolveModalCheckpointProviderBindingForLiveSandbox,
109
127
  resolveModalCheckpointProviderBinding,
128
+ findModalProviderCreateReceipt,
110
129
  resolveModalCheckpointProviderBindingForSession,
111
130
  querySelfhostedOp,
112
131
  resumeExactSandboxSession,
@@ -139,7 +158,9 @@ import { assertSandboxDrainInputTiming, sandboxDrainTiming } from "../sandbox-re
139
158
  import type { ControlActivityServices as ActivityServices } from "./types";
140
159
  import { reconcilePendingParentSystemUpdates } from "./parent-wake";
141
160
  import {
161
+ recordActiveUserGauges,
142
162
  recordCreditBalanceGauges,
163
+ recordCreditGrantGauges,
143
164
  recordCreditMicros,
144
165
  recordExpiredDrainingSandboxLeaseGauges,
145
166
  recordOpenSandboxKubernetesInventoryGauges,
@@ -154,8 +175,14 @@ import {
154
175
  type SandboxInventoryProjectionDomain,
155
176
  recordSandboxLeaseGauges,
156
177
  recordSandboxOrphansTerminated,
178
+ recordSandboxCommandContainment,
179
+ recordSandboxProviderMissingBeforeCapture,
180
+ recordSandboxRecoveryObservationGauges,
157
181
  recordSandboxRotationBacklogGauges,
158
182
  recordTurnsQueuedGauge,
183
+ recordVerifiedSignupTrialDeploymentFlagGauge,
184
+ recordManagedAuthNewSignupsSwitchGauge,
185
+ recordVerifiedSignupTrialSwitchGauge,
159
186
  runtimeMetricsHooksForObservability,
160
187
  } from "../observability-metrics";
161
188
  import {
@@ -182,8 +209,7 @@ export type ReapSandboxLeasesResult = {
182
209
  skipped: number;
183
210
  /** Warm viewer-only leases that accrued warm-seconds this tick (P2.1). */
184
211
  metered: number;
185
- /** Viewer-only boxes force-drained because their workspace is over a limit
186
- * (0 balance / over the warm cap) — turn-held boxes are never drained (P2.1). */
212
+ /** Holderless boxes accelerated to draining by the optional warm cap. */
187
213
  forceDrained: number;
188
214
  /** Provider-side Modal orphan sandboxes terminated by the defensive sweep. */
189
215
  modalOrphansTerminated: number;
@@ -251,6 +277,8 @@ export type TerminateBoxFn = (
251
277
  capturePolicy?: ProviderWorkspaceCapturePolicy | null,
252
278
  diskBackedArchives?: boolean,
253
279
  releaseFailedCapture?: () => Promise<void>,
280
+ workspaceId?: string,
281
+ beforeProviderStop?: () => Promise<void>,
254
282
  ) => Promise<boolean | ProviderTerminationOutcome>;
255
283
 
256
284
  export type SweepModalOrphansFn = (
@@ -270,6 +298,8 @@ export type SandboxLeaseActivityOptions = {
270
298
  /** Override the provider-side Modal orphan sweep (tests spy this; defaults to
271
299
  * Modal list+tag comparison when Modal is configured). */
272
300
  sweepModalOrphans?: SweepModalOrphansFn;
301
+ /** Only provider discovery is injected; durable recovery remains real. */
302
+ findModalProviderCreateReceipt?: typeof findModalProviderCreateReceipt;
273
303
  /** Override the read-only bounded OpenSandbox Kubernetes projection. */
274
304
  inspectOpenSandboxKubernetesInventory?: InspectOpenSandboxKubernetesInventoryFn;
275
305
  /** Override only the read-only provider process probe. The canonical DB
@@ -303,6 +333,8 @@ export type RetainedProcessProbeResult =
303
333
  | "provider_binding_missing"
304
334
  | "provider_binding_mismatch"
305
335
  | "process_observation_unavailable";
336
+ /** A running command whose last read still returned a large backlog. */
337
+ outputBacklog?: boolean;
306
338
  };
307
339
 
308
340
  export type RetainedProcessProbeFn = (
@@ -317,8 +349,12 @@ export type RetainedProcessProbeFn = (
317
349
  streamFidelity?: "separate" | "merged",
318
350
  ) => Promise<void>,
319
351
  providerPersistence?: ProviderCommandPersistence,
352
+ drainBudget?: RetainedProcessDrainBudget,
320
353
  ) => Promise<RetainedProcessProbeResult>;
321
354
 
355
+ /** Shared wall-clock bound on output draining within one reconciliation sweep. */
356
+ export type RetainedProcessDrainBudget = { until: number };
357
+
322
358
  export type HistoricalModalSandboxLifecycleProbeFn = typeof inspectModalSandboxLifecycle;
323
359
 
324
360
  export type DrainableProviderProbeFn = (
@@ -329,6 +365,16 @@ export type DrainableProviderProbeFn = (
329
365
  export const RETAINED_PROCESS_RECONCILIATION_LIMIT = 20;
330
366
  export const RETAINED_PROCESS_RECONCILIATION_CLAIM_TTL_MS = 5 * 60_000;
331
367
  export const RETAINED_PROCESS_PROVIDER_PROBE_TIMEOUT_MS = 5_000;
368
+ /** Bounds on reading a command's output backlog: per claim, and across one
369
+ * reconciliation sweep so the reaper activity stays well inside its timeout.
370
+ * Each read returns up to one provider page per stream. */
371
+ export const RETAINED_PROCESS_OUTPUT_DRAIN_MAX_READS = 8;
372
+ export const RETAINED_PROCESS_OUTPUT_DRAIN_BUDGET_MS = 10_000;
373
+ export const RETAINED_PROCESS_OUTPUT_DRAIN_SWEEP_BUDGET_MS = 60_000;
374
+ /** A live process whose read returned at least this much output left a backlog
375
+ * behind it. A server or watcher that only trickles output stays on ordinary
376
+ * backoff; a process the provider reports exited is always read to EOF. */
377
+ export const RETAINED_PROCESS_OUTPUT_BACKLOG_BYTES = 256 * 1024;
332
378
  export const RETAINED_PROCESS_BINDING_QUARANTINE_AFTER_ATTEMPTS = 5;
333
379
  export const RETAINED_PROCESS_BINDING_QUARANTINE_RETRY_MS = 24 * 60 * 60_000;
334
380
  export const CONNECTED_COMMAND_RECONCILIATION_LIMIT = 20;
@@ -477,7 +523,14 @@ export function createSandboxLeaseActivities(
477
523
  options: SandboxLeaseActivityOptions = {},
478
524
  ) {
479
525
  const terminateBox: TerminateBoxFn =
480
- options.terminateBox ??
526
+ (options.terminateBox
527
+ ? async (...args: Parameters<TerminateBoxFn>) => {
528
+ // The injected test seam represents the stop itself; the real
529
+ // implementation invokes this only after capture has succeeded.
530
+ await args[10]?.();
531
+ return options.terminateBox!(...args);
532
+ }
533
+ : null) ??
481
534
  (async (
482
535
  settings,
483
536
  lease,
@@ -488,6 +541,8 @@ export function createSandboxLeaseActivities(
488
541
  capturePolicy,
489
542
  diskBackedArchives,
490
543
  releaseFailedCapture,
544
+ workspaceId,
545
+ beforeProviderStop,
491
546
  ) =>
492
547
  await terminateProviderBox(
493
548
  settings,
@@ -501,6 +556,8 @@ export function createSandboxLeaseActivities(
501
556
  capturePolicy,
502
557
  diskBackedArchives,
503
558
  releaseFailedCapture,
559
+ workspaceId,
560
+ beforeProviderStop,
504
561
  ));
505
562
  const sweepModalOrphans: SweepModalOrphansFn =
506
563
  options.sweepModalOrphans ?? sweepModalOrphansForConfiguredBackend;
@@ -513,10 +570,15 @@ export function createSandboxLeaseActivities(
513
570
  try {
514
571
  const { db, settings, observability } = await services();
515
572
  const timing = sandboxDrainTiming(settings);
516
- const onUnobservableCommandDrainError = (error: unknown) => {
517
- observability.warn("sandbox reaper: unobservable command drain inspection failed", {
518
- error: error instanceof Error ? error.message : String(error),
519
- });
573
+ const commandContainment = {
574
+ idleCommandContainmentMs: settings.sandboxIdleCommandContainmentMs,
575
+ onCommandContainment: (outcome: Parameters<typeof recordSandboxCommandContainment>[1]) =>
576
+ recordSandboxCommandContainment(observability, outcome),
577
+ onCommandContainmentError: (error: unknown) => {
578
+ observability.warn("sandbox reaper: retained command containment inspection failed", {
579
+ error: error instanceof Error ? error.message : String(error),
580
+ });
581
+ },
520
582
  };
521
583
  if (!settings.sandboxOwnershipEnabled) {
522
584
  // Turns skip leases when the flag is off, but Computer/Browser attach
@@ -524,7 +586,7 @@ export function createSandboxLeaseActivities(
524
586
  // behind rotation_in_progress forever. Inventory and drain those rows;
525
587
  // do not request NEW deadline rotations or meter warm time.
526
588
  const drainableInventory = await reapStaleLeaseHoldersGlobal(db, {
527
- onUnobservableCommandDrainError,
589
+ ...commandContainment,
528
590
  viewerHolderTtlMs: settings.sandboxViewerHolderTtlMs,
529
591
  turnHolderTtlMs: settings.sandboxLeaseTtlMs,
530
592
  interactionHolderTtlMs: settings.sandboxInteractionHolderTtlMs,
@@ -561,7 +623,7 @@ export function createSandboxLeaseActivities(
561
623
  // Billing, reconciliation, provider-orphan cleanup, artifact GC, and gauges
562
624
  // run only after every drainable box has its own durable child.
563
625
  const drainableInventory = await reapStaleLeaseHoldersGlobal(db, {
564
- onUnobservableCommandDrainError,
626
+ ...commandContainment,
565
627
  viewerHolderTtlMs: settings.sandboxViewerHolderTtlMs,
566
628
  // Dead-worker turn holders: a live holder is touched every 10s from the
567
629
  // moment it is registered (resumeBoxForTurn's holder-liveness loop covers
@@ -605,7 +667,7 @@ export function createSandboxLeaseActivities(
605
667
  instanceId: input.target.instanceId,
606
668
  });
607
669
  try {
608
- const { db, settings, observability, objectStorage } = await services();
670
+ const { db, settings, observability, objectStorage, bus } = await services();
609
671
  assertSandboxDrainInputTiming(input);
610
672
  const drainSettings =
611
673
  settings.sandboxSnapshotTimeoutMs === input.snapshotTimeoutMs
@@ -629,6 +691,7 @@ export function createSandboxLeaseActivities(
629
691
  probeDrainableProvider,
630
692
  captureAttempt,
631
693
  objectStorage,
694
+ bus,
632
695
  );
633
696
  return { status: drainedCold ? "terminated" : "skipped" };
634
697
  } catch (error) {
@@ -703,6 +766,23 @@ export function createSandboxLeaseActivities(
703
766
  // immutable launch locators must reconcile even when managed ownership is
704
767
  // disabled, otherwise an exact runner exit/loss proof can remain stranded.
705
768
  await reconcileConnectedMachineBackgroundCommands(db, settings, observability, service.bus);
769
+ await reconcileConnectedCommandOutputReleases(db, settings, observability, service.bus);
770
+
771
+ // Disabling new lease ownership cannot strand a previously dispatched
772
+ // operation. This only attributes a positive receipt; normal draining
773
+ // already runs in both ownership modes.
774
+ try {
775
+ await reconcileModalProviderCreates(
776
+ db,
777
+ settings,
778
+ observability,
779
+ options.findModalProviderCreateReceipt,
780
+ );
781
+ } catch (error) {
782
+ observability.warn("sandbox reaper: Modal create recovery inventory failed", {
783
+ error: error instanceof Error ? error.message : String(error),
784
+ });
785
+ }
706
786
 
707
787
  if (!settings.sandboxOwnershipEnabled) {
708
788
  // Inventory remains useful while provider ownership is intentionally
@@ -728,8 +808,7 @@ export function createSandboxLeaseActivities(
728
808
  metered = meterResult.accrued;
729
809
  const forceDrainWorkspaceIds = new Set<string>();
730
810
  if (
731
- settings.billingMode === "stripe" ||
732
- settings.usageLimitsMode === "managed" ||
811
+ settings.sandboxWarmBillingMode === "credits" ||
733
812
  settings.sandboxMaxWarmSecondsPerWorkspace > 0
734
813
  ) {
735
814
  for (const workspaceId of meterResult.workspaceIds) {
@@ -903,6 +982,9 @@ type ConnectedCommandReconciliationOutcome =
903
982
  | "proof_checkpoint_failed"
904
983
  | "settled_exited"
905
984
  | "settled_lost"
985
+ | "output_unavailable"
986
+ | "output_ack_published"
987
+ | "output_released"
906
988
  | "settlement_failed"
907
989
  | "defer_failed";
908
990
 
@@ -1103,6 +1185,7 @@ export async function reconcileConnectedMachineBackgroundCommands(
1103
1185
  );
1104
1186
  await forEachWithConcurrency(claims, SANDBOX_MAINTENANCE_ITEM_CONCURRENCY, async (claim) => {
1105
1187
  let proof = claim.proof;
1188
+ let outputReplay: Awaited<ReturnType<OpStreamExecClient["readExisting"]>> | null = null;
1106
1189
  const connectionKey = JSON.stringify([
1107
1190
  claim.controlWorkspaceId,
1108
1191
  claim.enrollmentId,
@@ -1160,18 +1243,14 @@ export async function reconcileConnectedMachineBackgroundCommands(
1160
1243
  retryClock: defaultSelfhostedRetryClock,
1161
1244
  journal: { attachGeneration: () => String(Date.now()), persistSettled: () => {} },
1162
1245
  });
1163
- const replay = await client.readExisting(
1246
+ outputReplay = await replayConnectedCommandOutput(
1247
+ client,
1164
1248
  claim.opId,
1165
- proof ? 5_000 : 250,
1249
+ Boolean(proof),
1166
1250
  async (frames) => {
1167
1251
  await captureConnectedCommandOutput(db, claim, bus)(claim.commandId, frames);
1168
1252
  },
1169
1253
  );
1170
- if (proof && replay.status !== "completed" && !replay.terminal) {
1171
- throw new Error(
1172
- "Connected command terminal output replay has not reached its exit frontier",
1173
- );
1174
- }
1175
1254
  }
1176
1255
  if (!proof) {
1177
1256
  await deferConnectedCommandClaim(
@@ -1226,6 +1305,18 @@ export async function reconcileConnectedMachineBackgroundCommands(
1226
1305
  if (!settlement.settled) {
1227
1306
  throw new Error("Connected command settlement lost its exact claim or proof");
1228
1307
  }
1308
+ const receipt =
1309
+ outputReplay?.outputReceipt ??
1310
+ (outputReplay?.status === "running" ? outputReplay.terminal?.outputReceipt : undefined);
1311
+ const terminal =
1312
+ outputReplay?.status === "completed" ? outputReplay : outputReplay?.terminal;
1313
+ if (proof.outcome === "exited" && receipt && terminal) {
1314
+ await recordConnectedCommandOutputConsumption(db, {
1315
+ ...claim,
1316
+ receipt,
1317
+ exitCode: terminal.outcome.response.exitCode,
1318
+ });
1319
+ }
1229
1320
  if (settlement.events.length > 0) {
1230
1321
  try {
1231
1322
  await bus.publish(claim.workspaceId, claim.sessionId, settlement.events);
@@ -1250,6 +1341,168 @@ export async function reconcileConnectedMachineBackgroundCommands(
1250
1341
  } while (Date.now() < deadline);
1251
1342
  }
1252
1343
 
1344
+ export async function replayConnectedCommandOutput(
1345
+ client: Pick<OpStreamExecClient, "readExisting">,
1346
+ opId: string,
1347
+ terminalKnown: boolean,
1348
+ capture: Parameters<OpStreamExecClient["readExisting"]>[2],
1349
+ ): Promise<Awaited<ReturnType<OpStreamExecClient["readExisting"]>>> {
1350
+ // A completed operation can need several bounded reads to replay its retained
1351
+ // frames. Keep the reader's integrity checkpoint until the exit is verified;
1352
+ // replacing it after each partial read would restart forever at frame one.
1353
+ let capturedThrough = 0n;
1354
+ while (true) {
1355
+ const before = capturedThrough;
1356
+ const replay = await client.readExisting(opId, terminalKnown ? 5_000 : 250, capture);
1357
+ if (!terminalKnown || replay.status === "completed" || replay.terminal) return replay;
1358
+ // Quiet jobs can retain many heartbeat frames (or an incomplete UTF-8
1359
+ // chunk). These are real replay progress even when capture receives no
1360
+ // stdout/stderr. Use the reader's verified, contiguous protocol frontier.
1361
+ capturedThrough = BigInt(replay.replaySequence);
1362
+ // This is a finite terminal drain, not a poller for a running command.
1363
+ // Failed persistence, transport/integrity errors, and no-progress reads
1364
+ // return control to normal reconciliation without licensing settlement.
1365
+ if (capturedThrough === before) {
1366
+ throw new Error("Connected command terminal output replay has not reached its exit frontier");
1367
+ }
1368
+ }
1369
+ }
1370
+
1371
+ /** Drain the durable terminal-output obligation independently of the launching
1372
+ * attempt or model completion notice. Existing terminal rows receive no inferred
1373
+ * receipt: they must replay and capture all retained bytes before any final ACK. */
1374
+ export async function reconcileConnectedCommandOutputReleases(
1375
+ db: ActivityServices["db"],
1376
+ settings: ActivityServices["settings"],
1377
+ observability: ActivityServices["observability"],
1378
+ bus: ActivityServices["bus"],
1379
+ controlRpcOverride?: ControlRpc,
1380
+ ): Promise<void> {
1381
+ const dueBefore = new Date();
1382
+ const deadline = Date.now() + settings.sandboxLeaseReaperPeriodMs;
1383
+ const controlRpc =
1384
+ controlRpcOverride ?? new NatsControlRpc(async () => bus.getRequestConnection());
1385
+ do {
1386
+ let claims: Awaited<ReturnType<typeof claimConnectedCommandOutputReleases>>;
1387
+ try {
1388
+ claims = await claimConnectedCommandOutputReleases(db, {
1389
+ claimId: crypto.randomUUID(),
1390
+ dueBefore,
1391
+ limit: CONNECTED_COMMAND_RECONCILIATION_LIMIT,
1392
+ claimTtlMs: CONNECTED_COMMAND_RECONCILIATION_CLAIM_TTL_MS,
1393
+ });
1394
+ } catch (error) {
1395
+ recordConnectedCommandReconciliation(observability, "claim_failed");
1396
+ observability.warn("sandbox reaper: connected-command output claim failed", {
1397
+ error: error instanceof Error ? error.message : String(error),
1398
+ });
1399
+ return;
1400
+ }
1401
+ await forEachWithConcurrency(claims, SANDBOX_MAINTENANCE_ITEM_CONCURRENCY, async (claim) => {
1402
+ try {
1403
+ if (!bus.getOpStreamConnection)
1404
+ throw new Error("Connected command output transport unavailable");
1405
+ const rpcSubject = subjectFor(
1406
+ claim.controlWorkspaceId,
1407
+ claim.enrollmentId,
1408
+ claim.connectionInstanceId,
1409
+ );
1410
+ const controlTimeoutMs = Math.min(
1411
+ settings.sandboxSelfhostedControlTimeoutMs,
1412
+ RETAINED_PROCESS_PROVIDER_PROBE_TIMEOUT_MS,
1413
+ );
1414
+ const client = new OpStreamExecClient({
1415
+ workspaceId: claim.controlWorkspaceId,
1416
+ agentId: claim.enrollmentId,
1417
+ connectionInstanceId: claim.connectionInstanceId,
1418
+ epoch: 0,
1419
+ controlRpc,
1420
+ rpcSubject,
1421
+ transport: new NatsOpStreamTransport(async () => bus.getOpStreamConnection?.() ?? null),
1422
+ controlTimeoutMs,
1423
+ retryClock: defaultSelfhostedRetryClock,
1424
+ journal: {
1425
+ attachGeneration: () => String(Date.now()),
1426
+ persistSettled: () => {
1427
+ throw new Error("Background output release requires its database receipt");
1428
+ },
1429
+ },
1430
+ });
1431
+ let receipt = claim.receipt;
1432
+ if (!receipt) {
1433
+ const status = await querySelfhostedOp({
1434
+ controlRpc,
1435
+ rpcSubject,
1436
+ opId: claim.opId,
1437
+ controlTimeoutMs,
1438
+ });
1439
+ if (status.state === OpState.OP_STATE_LOST) {
1440
+ if (
1441
+ !(await settleConnectedCommandOutputReleaseClaim(db, {
1442
+ claim,
1443
+ outcome: "unavailable",
1444
+ retryAfterMs: 0,
1445
+ }))
1446
+ )
1447
+ throw new Error("Connected command output loss no longer owns its exact claim");
1448
+ recordConnectedCommandReconciliation(observability, "output_unavailable");
1449
+ return;
1450
+ }
1451
+ if (status.state !== OpState.OP_STATE_COMPLETE)
1452
+ throw new Error("Terminal command output returned a live operation");
1453
+ const replay = await replayConnectedCommandOutput(
1454
+ client,
1455
+ claim.opId,
1456
+ true,
1457
+ async (frames) =>
1458
+ captureConnectedCommandOutput(db, claim, bus)(claim.commandId, frames),
1459
+ );
1460
+ const terminal = replay.status === "completed" ? replay : replay.terminal;
1461
+ receipt =
1462
+ replay.outputReceipt ??
1463
+ (replay.status === "running" ? replay.terminal?.outputReceipt : undefined) ??
1464
+ null;
1465
+ if (!terminal || !receipt)
1466
+ throw new Error("Connected command output replay has no complete custody receipt");
1467
+ await recordConnectedCommandOutputConsumption(db, {
1468
+ ...claim,
1469
+ receipt,
1470
+ exitCode: terminal.outcome.response.exitCode,
1471
+ });
1472
+ }
1473
+ const outcome = await client.releaseCapturedOutput(claim.opId, receipt);
1474
+ if (
1475
+ !(await settleConnectedCommandOutputReleaseClaim(db, {
1476
+ claim,
1477
+ outcome,
1478
+ retryAfterMs: outcome === "published" ? settings.sandboxLeaseReaperPeriodMs : 0,
1479
+ }))
1480
+ )
1481
+ throw new Error("Connected command output release no longer owns its exact claim");
1482
+ recordConnectedCommandReconciliation(
1483
+ observability,
1484
+ outcome === "published" ? "output_ack_published" : "output_released",
1485
+ );
1486
+ } catch (error) {
1487
+ await settleConnectedCommandOutputReleaseClaim(db, {
1488
+ claim,
1489
+ outcome: "retry",
1490
+ retryAfterMs: Math.min(
1491
+ 5 * 60_000,
1492
+ Math.max(settings.sandboxLeaseReaperPeriodMs, 30_000) *
1493
+ 2 ** Math.min(4, claim.reconcileAttempts - 1),
1494
+ ),
1495
+ }).catch(() => false);
1496
+ observability.warn("sandbox reaper: connected-command output release deferred", {
1497
+ commandId: claim.commandId,
1498
+ error: error instanceof Error ? error.message : String(error),
1499
+ });
1500
+ }
1501
+ });
1502
+ if (claims.length < CONNECTED_COMMAND_RECONCILIATION_LIMIT) break;
1503
+ } while (Date.now() < deadline);
1504
+ }
1505
+
1253
1506
  export async function probeConnectedMachineBackgroundCommand(
1254
1507
  claim: ConnectedMachineBackgroundCommandClaim,
1255
1508
  controlRpc: ControlRpc,
@@ -1377,10 +1630,80 @@ async function reconcileTerminalRetainedProcesses(
1377
1630
  return;
1378
1631
  }
1379
1632
 
1633
+ const drainBudget: RetainedProcessDrainBudget = {
1634
+ until: Date.now() + RETAINED_PROCESS_OUTPUT_DRAIN_SWEEP_BUDGET_MS,
1635
+ };
1380
1636
  for (const claim of claims) {
1381
1637
  let process = claim.process;
1382
1638
  const expected = retainedProcessSettlementIdentity(process);
1383
1639
  let proof = retainedProcessReconciliationProof(process);
1640
+ const processScope = {
1641
+ accountId: process.accountId,
1642
+ workspaceId: process.workspaceId,
1643
+ sessionId: process.sessionId,
1644
+ processId: process.id,
1645
+ };
1646
+ // Recover a yield whose observation failed before normal receipt adoption.
1647
+ // Do this before provider I/O: an unavailable observer must not fence every
1648
+ // subsequent turn. Physical retention and unknown outcome stay unchanged.
1649
+ if (
1650
+ process.ownerActorKind === "turn" &&
1651
+ process.providerBackend === "modal" &&
1652
+ !proof &&
1653
+ !claim.ownerState.startsWith("background_")
1654
+ ) {
1655
+ try {
1656
+ const command = await recoverManagedSessionBackgroundCommand(db, {
1657
+ ...processScope,
1658
+ expected,
1659
+ reconciliationClaimId: claim.claimId,
1660
+ });
1661
+ if (command) {
1662
+ claim.ownerState = `background_${command.state}`;
1663
+ recordRetainedProcessReconciliation(observability, "background_recovered");
1664
+ }
1665
+ } catch (error) {
1666
+ observability.warn("sandbox reaper: retained-command background recovery failed", {
1667
+ processId: process.id,
1668
+ error: error instanceof Error ? error.message : String(error),
1669
+ });
1670
+ }
1671
+ }
1672
+ const storedCommandPersistence = retainedProviderCommandPersistence(
1673
+ db,
1674
+ processScope,
1675
+ bus ? (events) => bus.publish(process.workspaceId, process.sessionId, events) : undefined,
1676
+ );
1677
+ const commandPersistence = {
1678
+ ...storedCommandPersistence,
1679
+ requestCancellation: async (reason: "provider_deadline" | "explicit_stop") => {
1680
+ if (reason === "provider_deadline") {
1681
+ await requestRetainedProcessDeadlineCancellation(db, processScope);
1682
+ if (!(await storedCommandPersistence.cancellationRequested()))
1683
+ throw new Error(
1684
+ "Supervised deadline cancellation no longer owns its original rotating lease",
1685
+ );
1686
+ } else await storedCommandPersistence.requestCancellation(reason);
1687
+ },
1688
+ };
1689
+ if (proof && process.providerBackend === "modal") {
1690
+ try {
1691
+ const command = await commandPersistence.load();
1692
+ if (
1693
+ command?.kind === "modal-router-v1" &&
1694
+ command.supervision &&
1695
+ (!(await commandPersistence.loadSupervisionReceipt()) ||
1696
+ ![command.streams.stdout, command.streams.stderr].every(
1697
+ (stream) => stream.eof && stream.exitCode === 0,
1698
+ ))
1699
+ )
1700
+ proof = null;
1701
+ } catch {
1702
+ // An earlier terminal observation cannot make missing control/output
1703
+ // proof unreachable. Reprobe the original binding; DB remains the gate.
1704
+ proof = null;
1705
+ }
1706
+ }
1384
1707
  if (!proof) {
1385
1708
  let observation: RetainedProcessProbeResult | null = null;
1386
1709
 
@@ -1434,6 +1757,25 @@ async function reconcileTerminalRetainedProcesses(
1434
1757
  }
1435
1758
  }
1436
1759
 
1760
+ if (
1761
+ observation?.status === "deferred" &&
1762
+ process.providerBackend === "modal" &&
1763
+ !process.providerBindingKey &&
1764
+ !process.deadlineCancellationRequestedAt
1765
+ ) {
1766
+ // An unbound historical command cannot be signalled safely. Still
1767
+ // record deadline intent when its lease matches: a failed observer
1768
+ // must not block capture until the provider expires.
1769
+ try {
1770
+ await requestRetainedProcessDeadlineCancellation(db, processScope);
1771
+ } catch (error) {
1772
+ observability.warn("sandbox reaper: legacy deadline cancellation intent failed", {
1773
+ processId: process.id,
1774
+ error: error instanceof Error ? error.message : String(error),
1775
+ });
1776
+ }
1777
+ }
1778
+
1437
1779
  if (observation === null) {
1438
1780
  const lease = await readLease(db, process.workspaceId, process.sandboxGroupId).catch(
1439
1781
  () => null,
@@ -1490,11 +1832,39 @@ async function reconcileTerminalRetainedProcesses(
1490
1832
  }
1491
1833
  } else {
1492
1834
  try {
1493
- observation = await probe(
1835
+ const command = await commandPersistence.load();
1836
+ const supervised = command?.kind === "modal-router-v1" && Boolean(command.supervision);
1837
+ const supervisionMetric = (outcome: string) =>
1838
+ observability.incrementCounter({
1839
+ name: "opengeni_command_supervision_total",
1840
+ help: "Native retained command supervision reconciliation outcomes.",
1841
+ labels: { outcome },
1842
+ });
1843
+ if (
1844
+ supervised &&
1845
+ (lease!.rotationReason === "provider_deadline" ||
1846
+ claim.ownerState === "background_stopping")
1847
+ ) {
1848
+ await commandPersistence.requestCancellation(
1849
+ lease!.rotationReason === "provider_deadline"
1850
+ ? "provider_deadline"
1851
+ : "explicit_stop",
1852
+ );
1853
+ supervisionMetric("cancellation_intent");
1854
+ }
1855
+ const legacyDeadlineStop =
1856
+ lease!.rotationReason === "provider_deadline" &&
1857
+ !supervised &&
1858
+ !process.deadlineCancellationRequestedAt;
1859
+ const probeOutcome = await probe(
1494
1860
  settings,
1495
1861
  lease!,
1496
1862
  process,
1497
- claim.ownerState === "background_stopping" ? "cancel" : "observe",
1863
+ (claim.ownerState === "background_stopping" &&
1864
+ !process.deadlineCancellationRequestedAt) ||
1865
+ legacyDeadlineStop
1866
+ ? "cancel"
1867
+ : "observe",
1498
1868
  async (result, chunkId, stream, streamFidelity) => {
1499
1869
  if (
1500
1870
  typeof result !== "string" ||
@@ -1516,13 +1886,35 @@ async function reconcileTerminalRetainedProcesses(
1516
1886
  .publish(process.workspaceId, process.sessionId, events)
1517
1887
  .catch(() => undefined);
1518
1888
  },
1519
- retainedProviderCommandPersistence(db, {
1520
- accountId: process.accountId,
1521
- workspaceId: process.workspaceId,
1522
- sessionId: process.sessionId,
1523
- processId: process.id,
1524
- }),
1889
+ commandPersistence,
1890
+ drainBudget,
1891
+ ).then(
1892
+ (value) => ({ ok: true as const, value }),
1893
+ (error: unknown) => ({ ok: false as const, error }),
1525
1894
  );
1895
+ // Legacy PTY input reserves Ctrl-C before the cancellation fence.
1896
+ // Non-PTY has no command signal in the Modal SDK: probe it without
1897
+ // injecting Ctrl-C as data, then fence and save after the grace.
1898
+ if (
1899
+ legacyDeadlineStop &&
1900
+ !(await requestRetainedProcessDeadlineCancellation(db, processScope))
1901
+ )
1902
+ throw new Error("Deadline cancellation no longer owns its rotating lease");
1903
+ if (!probeOutcome.ok) throw probeOutcome.error;
1904
+ observation = probeOutcome.value;
1905
+ if (supervised) {
1906
+ supervisionMetric(
1907
+ (await commandPersistence.loadSupervisionReceipt())
1908
+ ? "proof_retained"
1909
+ : "proof_missing",
1910
+ );
1911
+ if (observation.status === "deferred")
1912
+ supervisionMetric(
1913
+ observation.reason === "provider_running"
1914
+ ? "checkpoint_blocked"
1915
+ : "provider_failure",
1916
+ );
1917
+ }
1526
1918
  } catch (error) {
1527
1919
  observability.warn("sandbox reaper: retained-process provider probe failed", {
1528
1920
  processId: process.id,
@@ -1564,6 +1956,7 @@ async function reconcileTerminalRetainedProcesses(
1564
1956
  expected,
1565
1957
  claim.claimId,
1566
1958
  observation.reason,
1959
+ observation.outputBacklog === true,
1567
1960
  );
1568
1961
  continue;
1569
1962
  }
@@ -1581,10 +1974,7 @@ async function reconcileTerminalRetainedProcesses(
1581
1974
  recordRetainedProcessReconciliation(observability, `proof_${proof.outcome}`);
1582
1975
  } catch (error) {
1583
1976
  recordRetainedProcessReconciliation(observability, "proof_checkpoint_failed");
1584
- observability.warn("sandbox reaper: retained-process proof checkpoint failed", {
1585
- processId: process.id,
1586
- error: error instanceof Error ? error.message : String(error),
1587
- });
1977
+ warnRetainedProcessProofFailure(observability, error, process);
1588
1978
  continue;
1589
1979
  }
1590
1980
  }
@@ -1647,9 +2037,17 @@ async function deferRetainedProcessClaim(
1647
2037
  expected: ReturnType<typeof retainedProcessSettlementIdentity>,
1648
2038
  claimId: string,
1649
2039
  outcome: RetainedProcessDeferralOutcome,
2040
+ outputBacklog = false,
1650
2041
  ): Promise<void> {
1651
- const deferral = retainedProcessReconciliationDeferral(settings, process, outcome);
2042
+ const deferral = retainedProcessReconciliationDeferral(settings, process, outcome, outputBacklog);
1652
2043
  try {
2044
+ // A healthy long-lived command can already be on a five-minute backoff
2045
+ // when rotation becomes due. Wake it at the lead boundary, then at the
2046
+ // reaper cadence, so observation does not consume the capture window.
2047
+ const lease =
2048
+ process.providerBackend === "modal" && process.routeTargetId === null
2049
+ ? await readLease(db, process.workspaceId, process.sandboxGroupId)
2050
+ : null;
1653
2051
  await deferRetainedProcessReconciliation(db, {
1654
2052
  accountId: process.accountId,
1655
2053
  workspaceId: process.workspaceId,
@@ -1658,7 +2056,7 @@ async function deferRetainedProcessClaim(
1658
2056
  expected,
1659
2057
  claimId,
1660
2058
  outcome: deferral.durableOutcome,
1661
- retryAfterMs: deferral.retryAfterMs,
2059
+ retryAfterMs: retainedProcessDeadlineRetryMs(process, lease, settings, deferral.retryAfterMs),
1662
2060
  });
1663
2061
  recordRetainedProcessReconciliation(observability, deferral.metricOutcome);
1664
2062
  } catch (error) {
@@ -1678,6 +2076,7 @@ export function retainedProcessReconciliationDeferral(
1678
2076
  settings: Pick<ActivityServices["settings"], "sandboxLeaseReaperPeriodMs">,
1679
2077
  process: Pick<SandboxRetainedProcess, "reconcileAttempts">,
1680
2078
  outcome: RetainedProcessDeferralOutcome,
2079
+ outputBacklog = false,
1681
2080
  ): {
1682
2081
  durableOutcome: string;
1683
2082
  metricOutcome: RetainedProcessReconciliationOutcome;
@@ -1702,11 +2101,14 @@ export function retainedProcessReconciliationDeferral(
1702
2101
  quarantined: true,
1703
2102
  };
1704
2103
  }
1705
- const exponent = Math.min(4, Math.max(0, process.reconcileAttempts - 1));
1706
- const retryAfterMs = Math.min(
1707
- 5 * 60_000,
1708
- Math.max(settings.sandboxLeaseReaperPeriodMs, 30_000) * 2 ** exponent,
1709
- );
2104
+ const baseRetryMs = Math.max(settings.sandboxLeaseReaperPeriodMs, 30_000);
2105
+ // A backlog still being drained is progress, not an unhealthy provider:
2106
+ // come back at the reaper cadence, without backoff.
2107
+ const exponent =
2108
+ outcome === "provider_running" && outputBacklog
2109
+ ? 0
2110
+ : Math.min(4, Math.max(0, process.reconcileAttempts - 1));
2111
+ const retryAfterMs = Math.min(5 * 60_000, baseRetryMs * 2 ** exponent);
1710
2112
  return {
1711
2113
  durableOutcome: outcome,
1712
2114
  metricOutcome: outcome,
@@ -1733,6 +2135,77 @@ type RetainedProcessProbeSession = ProviderCommandSession & {
1733
2135
 
1734
2136
  const RETAINED_PROCESS_PROBE_TIMEOUT = Symbol("retained-process-provider-probe-timeout");
1735
2137
 
2138
+ /** A command's exit is reported only after both output streams reach EOF, so a
2139
+ * finished command with a large backlog looks running until it is read. While a
2140
+ * backlog remains, keep reading within the per-claim and per-sweep budgets, so
2141
+ * it settles in this claim instead of one page per backoff step. Every page,
2142
+ * including `first`, is captured here. */
2143
+ export async function drainRetainedCommandBacklog(
2144
+ session: RetainedProcessProbeSession,
2145
+ providerSessionId: number,
2146
+ first: unknown,
2147
+ capturePage: (value: unknown) => Promise<void>,
2148
+ budget?: RetainedProcessDrainBudget,
2149
+ ): Promise<RetainedProcessProbeResult> {
2150
+ // Measure before capture: capturing a byte-offset page consumes its receipt.
2151
+ let backlog = retainedBacklogRemains(session, first);
2152
+ await capturePage(first);
2153
+ let observation = classifyRetainedProcessPollResult(first, providerSessionId, session);
2154
+ if (typeof session.writeStdin !== "function") return observation;
2155
+ const until = Math.min(
2156
+ Date.now() + RETAINED_PROCESS_OUTPUT_DRAIN_BUDGET_MS,
2157
+ budget?.until ?? Number.POSITIVE_INFINITY,
2158
+ );
2159
+ for (
2160
+ let reads = 0;
2161
+ backlog &&
2162
+ observation.status === "deferred" &&
2163
+ observation.reason === "provider_running" &&
2164
+ reads < RETAINED_PROCESS_OUTPUT_DRAIN_MAX_READS &&
2165
+ Date.now() < until;
2166
+ reads++
2167
+ ) {
2168
+ let next: unknown;
2169
+ try {
2170
+ next = await withRetainedProcessProbeTimeout(
2171
+ session.writeStdin({
2172
+ sessionId: providerSessionId,
2173
+ chars: "",
2174
+ yieldTimeMs: 1_000,
2175
+ maxOutputTokens: 2_000,
2176
+ }),
2177
+ );
2178
+ } catch {
2179
+ // The observation already made stays authoritative; the next sweep
2180
+ // classifies any provider failure through the ordinary path.
2181
+ break;
2182
+ }
2183
+ backlog = retainedBacklogRemains(session, next);
2184
+ await capturePage(next);
2185
+ observation = classifyRetainedProcessPollResult(next, providerSessionId, session);
2186
+ }
2187
+ return backlog && observation.status === "deferred" && observation.reason === "provider_running"
2188
+ ? { ...observation, outputBacklog: true }
2189
+ : observation;
2190
+ }
2191
+
2192
+ /** Whether a byte-offset page leaves a backlog worth reading now: the provider
2193
+ * reports the process exited but its output is not yet read to EOF, or a live
2194
+ * process produced a large page. Must run before the page is captured, which
2195
+ * consumes its receipt. Supervised commands are left to their proof path. */
2196
+ function retainedBacklogRemains(session: RetainedProcessProbeSession, result: unknown): boolean {
2197
+ const page = session.getProviderCommandOutput?.(result);
2198
+ if (!page?.expected || page.command.kind !== "modal-router-v1") return false;
2199
+ if (page.command.supervision || page.exitCode !== null) return false;
2200
+ if (page.providerExited) return true;
2201
+ const { command, expected } = page;
2202
+ return (["stdout", "stderr"] as const).some(
2203
+ (stream) =>
2204
+ command.streams[stream].byteOffset - expected.streams[stream].byteOffset >=
2205
+ RETAINED_PROCESS_OUTPUT_BACKLOG_BYTES,
2206
+ );
2207
+ }
2208
+
1736
2209
  async function withRetainedProcessProbeTimeout<T>(promise: Promise<T>): Promise<T> {
1737
2210
  let timer: ReturnType<typeof setTimeout> | undefined;
1738
2211
  try {
@@ -1781,6 +2254,7 @@ export async function probeRetainedProcessAtProvider(
1781
2254
  streamFidelity?: "separate" | "merged",
1782
2255
  ) => Promise<void>,
1783
2256
  providerPersistence?: ProviderCommandPersistence,
2257
+ drainBudget?: RetainedProcessDrainBudget,
1784
2258
  ): Promise<RetainedProcessProbeResult> {
1785
2259
  if (
1786
2260
  lease.id !== process.leaseId ||
@@ -1788,7 +2262,8 @@ export async function probeRetainedProcessAtProvider(
1788
2262
  lease.leaseEpoch !== process.leaseEpoch ||
1789
2263
  lease.backend !== process.providerBackend ||
1790
2264
  lease.instanceId !== process.providerInstanceId ||
1791
- lease.resumeBackendId !== process.providerBackend ||
2265
+ !lease.resumeBackendId ||
2266
+ sandboxBackendForSdkBackendId(lease.resumeBackendId) !== process.providerBackend ||
1792
2267
  process.routeTargetId !== null
1793
2268
  ) {
1794
2269
  return { status: "deferred", reason: "identity_mismatch" };
@@ -1800,10 +2275,20 @@ export async function probeRetainedProcessAtProvider(
1800
2275
  if (pending) {
1801
2276
  if (!captureOutput) throw new Error("Pending command output requires its persistence callback");
1802
2277
  await captureRetainedProbeOutput(process.id, pending.result, captureOutput);
1803
- return classifyRetainedProcessPollResult(pending.result, process.providerSessionId);
2278
+ const pendingObservation = classifyRetainedProcessPollResult(
2279
+ pending.result,
2280
+ process.providerSessionId,
2281
+ );
2282
+ // Capture of a previous page must not consume the only deadline stop
2283
+ // attempt. Terminal proof needs no further provider call.
2284
+ if (mode !== "cancel" || pendingObservation.status === "proved") return pendingObservation;
1804
2285
  }
1805
2286
  const envelopeBackend = (lease.resumeState as { backendId?: unknown }).backendId;
1806
- if (envelopeBackend !== undefined && envelopeBackend !== process.providerBackend) {
2287
+ if (
2288
+ envelopeBackend !== undefined &&
2289
+ (typeof envelopeBackend !== "string" ||
2290
+ sandboxBackendForSdkBackendId(envelopeBackend) !== process.providerBackend)
2291
+ ) {
1807
2292
  return { status: "deferred", reason: "identity_mismatch" };
1808
2293
  }
1809
2294
  if (sandboxProviderInstanceIdFromEnvelope(lease.resumeState) !== process.providerInstanceId) {
@@ -1837,7 +2322,11 @@ export async function probeRetainedProcessAtProvider(
1837
2322
  } catch {
1838
2323
  return { status: "deferred", reason: "provider_error" };
1839
2324
  }
1840
- if (!client || client.backendId !== process.providerBackend || !client.resume) {
2325
+ if (
2326
+ !client ||
2327
+ sandboxBackendForSdkBackendId(client.backendId) !== process.providerBackend ||
2328
+ !client.resume
2329
+ ) {
1841
2330
  return { status: "deferred", reason: "backend_unsupported" };
1842
2331
  }
1843
2332
  const envelopeSessionState =
@@ -1889,6 +2378,7 @@ export async function probeRetainedProcessAtProvider(
1889
2378
  if (typeof session.writeStdin !== "function") {
1890
2379
  return { status: "deferred", reason: "backend_unsupported" };
1891
2380
  }
2381
+ let legacyPty = false;
1892
2382
  if (process.providerBackend === "modal") {
1893
2383
  let liveBinding: Awaited<ReturnType<typeof resolveModalCheckpointProviderBindingForSession>>;
1894
2384
  try {
@@ -1920,6 +2410,7 @@ export async function probeRetainedProcessAtProvider(
1920
2410
  if (!command || !providerPersistence || !session.bindProviderCommand)
1921
2411
  return { status: "deferred", reason: "process_observation_unavailable" };
1922
2412
  session.bindProviderCommand(process.providerSessionId, command, providerPersistence);
2413
+ legacyPty = command.pty === true;
1923
2414
  }
1924
2415
  const capturePage = async (value: unknown): Promise<void> => {
1925
2416
  if (!captureOutput) {
@@ -1928,6 +2419,11 @@ export async function probeRetainedProcessAtProvider(
1928
2419
  return;
1929
2420
  }
1930
2421
  const page = session.getProviderCommandOutput?.(value);
2422
+ if (page?.command.kind === "modal-router-v1" && typeof value === "string") {
2423
+ if (!session.captureCommandOutput || !(await session.captureCommandOutput(value)))
2424
+ throw new Error("Byte-offset output requires atomic capture before settlement");
2425
+ return;
2426
+ }
1931
2427
  if (page) {
1932
2428
  for (const chunk of page.chunks)
1933
2429
  if (chunk.text)
@@ -1940,10 +2436,24 @@ export async function probeRetainedProcessAtProvider(
1940
2436
 
1941
2437
  let result: unknown;
1942
2438
  try {
2439
+ const supervisedCancelled =
2440
+ mode === "cancel" || lease.rotationReason === "provider_deadline"
2441
+ ? ((await session.cancelSupervisedCommand?.(
2442
+ process.providerSessionId,
2443
+ lease.rotationReason === "provider_deadline" ? "provider_deadline" : "explicit_stop",
2444
+ )) ?? false)
2445
+ : false;
1943
2446
  result = await withRetainedProcessProbeTimeout(
1944
2447
  session.writeStdin({
1945
2448
  sessionId: process.providerSessionId,
1946
- chars: mode === "cancel" ? "\u0003" : "",
2449
+ // Ctrl-C is a signal only through a PTY. For a non-PTY command it is
2450
+ // ordinary stdin data and can corrupt the command's input.
2451
+ chars:
2452
+ mode === "cancel" &&
2453
+ !supervisedCancelled &&
2454
+ (process.providerBackend !== "modal" || legacyPty)
2455
+ ? "\u0003"
2456
+ : "",
1947
2457
  yieldTimeMs: 1_000,
1948
2458
  maxOutputTokens: 2_000,
1949
2459
  }),
@@ -1967,12 +2477,25 @@ export async function probeRetainedProcessAtProvider(
1967
2477
  }
1968
2478
  return { status: "deferred", reason: "provider_error" };
1969
2479
  }
1970
- await capturePage(result);
1971
- const observation = classifyRetainedProcessPollResult(result, process.providerSessionId, session);
2480
+ // Cancellation keeps draining with empty reads too: a stopped command's exit
2481
+ // is also reported only after its unread output reaches EOF.
2482
+ const observation = await drainRetainedCommandBacklog(
2483
+ session,
2484
+ process.providerSessionId,
2485
+ result,
2486
+ capturePage,
2487
+ drainBudget,
2488
+ );
1972
2489
  if (
1973
2490
  observation.status === "deferred" &&
1974
2491
  observation.reason === "provider_running" &&
1975
- lease.rotationRequestedAt !== null
2492
+ lease.rotationRequestedAt !== null &&
2493
+ mode !== "cancel" &&
2494
+ !process.cancellationRequestedAt &&
2495
+ legacyPty &&
2496
+ !(await providerPersistence
2497
+ ?.load()
2498
+ .then((command) => command?.kind === "modal-router-v1" && command.supervision))
1976
2499
  ) {
1977
2500
  // A background PTY cannot outlive the finite provider box. Interrupt only
1978
2501
  // this exact durable provider session after rotation admission is fenced;
@@ -2075,19 +2598,25 @@ async function accrueWarmTick(
2075
2598
  }
2076
2599
  await forEachWithConcurrency(leases, SANDBOX_MAINTENANCE_ITEM_CONCURRENCY, async (lease) => {
2077
2600
  try {
2078
- const rate = sandboxWarmRateMicrosPerSecond(settings, lease.backend);
2601
+ const rate =
2602
+ settings.sandboxWarmBillingMode === "usage_only"
2603
+ ? 0
2604
+ : sandboxWarmRateMicrosPerSecond(settings, lease.backend);
2079
2605
  const result = await accrueWarmSeconds(db, {
2080
2606
  accountId: lease.accountId,
2081
2607
  workspaceId: lease.workspaceId,
2082
2608
  sandboxGroupId: lease.sandboxGroupId,
2083
2609
  expectedEpoch: lease.leaseEpoch,
2084
2610
  warmRateMicrosPerSecond: rate,
2611
+ billingMode: settings.sandboxWarmBillingMode,
2085
2612
  subjectId: lease.sandboxGroupId,
2086
2613
  });
2087
2614
  if (result.accrued) {
2088
2615
  accrued += 1;
2089
2616
  }
2090
- recordCreditMicros(observability, "usage", result.costMicros);
2617
+ if (result.costMicros > 0 && settings.sandboxWarmBillingMode === "credits") {
2618
+ recordCreditMicros(observability, "usage", result.costMicros);
2619
+ }
2091
2620
  } catch (error) {
2092
2621
  observability.warn("sandbox reaper: warm-seconds accrual failed for lease", {
2093
2622
  workspaceId: lease.workspaceId,
@@ -2129,6 +2658,17 @@ async function refreshQueueLeaseAndCreditGauges(
2129
2658
  );
2130
2659
  },
2131
2660
  ),
2661
+ refreshSandboxInventoryGauge(
2662
+ observability,
2663
+ "recovery_observations",
2664
+ "recovery-observations",
2665
+ async () => {
2666
+ recordSandboxRecoveryObservationGauges(
2667
+ observability,
2668
+ await readRecentSandboxRecoveryObservations(db),
2669
+ );
2670
+ },
2671
+ ),
2132
2672
  refreshSandboxInventoryGauge(
2133
2673
  observability,
2134
2674
  "rotation_backlog",
@@ -2181,6 +2721,51 @@ async function refreshQueueLeaseAndCreditGauges(
2181
2721
  });
2182
2722
  }
2183
2723
  })(),
2724
+ (async () => {
2725
+ try {
2726
+ recordActiveUserGauges(observability, await countActiveUsers(db));
2727
+ } catch (error) {
2728
+ observability.warn("sandbox reaper: active-user gauge refresh failed", {
2729
+ error: error instanceof Error ? error.message : String(error),
2730
+ });
2731
+ }
2732
+ })(),
2733
+ (async () => {
2734
+ try {
2735
+ recordCreditGrantGauges(observability, await readCreditGrantTotals(db));
2736
+ } catch (error) {
2737
+ observability.warn("sandbox reaper: credit-grant gauge refresh failed", {
2738
+ error: error instanceof Error ? error.message : String(error),
2739
+ });
2740
+ }
2741
+ })(),
2742
+ (async () => {
2743
+ recordVerifiedSignupTrialDeploymentFlagGauge(
2744
+ observability,
2745
+ settings.verifiedSignupTrialCreditsEnabled,
2746
+ );
2747
+ try {
2748
+ const trialSwitch = await readVerifiedSignupTrialSwitch(db);
2749
+ recordVerifiedSignupTrialSwitchGauge(observability, trialSwitch?.grantsEnabled === true);
2750
+ } catch (error) {
2751
+ observability.warn("sandbox reaper: trial-credit switch gauge refresh failed", {
2752
+ error: error instanceof Error ? error.message : String(error),
2753
+ });
2754
+ }
2755
+ })(),
2756
+ (async () => {
2757
+ try {
2758
+ const signupsSwitch = await readManagedAuthNewSignupsSwitch(db);
2759
+ recordManagedAuthNewSignupsSwitchGauge(
2760
+ observability,
2761
+ signupsSwitch?.signupsEnabled !== false,
2762
+ );
2763
+ } catch (error) {
2764
+ observability.warn("sandbox reaper: new-signups switch gauge refresh failed", {
2765
+ error: error instanceof Error ? error.message : String(error),
2766
+ });
2767
+ }
2768
+ })(),
2184
2769
  ]);
2185
2770
  }
2186
2771
 
@@ -2203,8 +2788,8 @@ async function refreshSandboxInventoryGauge(
2203
2788
 
2204
2789
  /**
2205
2790
  * The per-workspace warm-cap + force-drain pass (P2.1). For each workspace with a
2206
- * live warm box, under the usage lock: if it is at 0 balance (when a billing /
2207
- * managed mode is on) or over its warm cap, force-drain its VIEWER-ONLY boxes
2791
+ * live warm box, under the usage lock: if it is over its optional warm cap,
2792
+ * accelerate only holderless boxes to draining
2208
2793
  * (guarded turn_holders=0 — a paying turn is never killed). Returns the count of
2209
2794
  * viewer-only boxes force-drained. Per-workspace best-effort.
2210
2795
  */
@@ -2214,8 +2799,7 @@ async function forceDrainOverLimitWorkspaces(
2214
2799
  workspaceIds: Set<string>,
2215
2800
  observability: ActivityServices["observability"],
2216
2801
  ): Promise<number> {
2217
- const enforceBalance =
2218
- settings.billingMode === "stripe" || settings.usageLimitsMode === "managed";
2802
+ const enforceBalance = false;
2219
2803
  const cap = settings.sandboxMaxWarmSecondsPerWorkspace;
2220
2804
  let forceDrained = 0;
2221
2805
  await forEachWithConcurrency(
@@ -2223,13 +2807,8 @@ async function forceDrainOverLimitWorkspaces(
2223
2807
  SANDBOX_MAINTENANCE_ITEM_CONCURRENCY,
2224
2808
  async (workspaceId) => {
2225
2809
  try {
2226
- const { accountId } = await rlsContextForWorkspace(db, workspaceId);
2227
- const balance = enforceBalance
2228
- ? await getBillingBalance(db, accountId)
2229
- : ({ balanceMicros: 1 } as { balanceMicros: number });
2230
2810
  const result = await forceDrainOverLimitViewerOnlyBoxes(db, {
2231
2811
  workspaceId,
2232
- balanceMicros: balance.balanceMicros,
2233
2812
  enforceBalance,
2234
2813
  maxWarmSecondsPerWorkspace: cap,
2235
2814
  idleGraceMs: settings.sandboxIdleGraceMs,
@@ -2253,10 +2832,50 @@ async function forceDrainOverLimitWorkspaces(
2253
2832
  return forceDrained;
2254
2833
  }
2255
2834
 
2835
+ export async function reconcileModalProviderCreates(
2836
+ db: ActivityServices["db"],
2837
+ settings: ActivityServices["settings"],
2838
+ observability: ActivityServices["observability"],
2839
+ discover: typeof findModalProviderCreateReceipt = findModalProviderCreateReceipt,
2840
+ ): Promise<void> {
2841
+ if (settings.sandboxBackend !== "modal" && !settings.modalTokenId && !settings.modalTokenSecret)
2842
+ return;
2843
+ const candidates = await listPendingModalProviderCreates(db);
2844
+ await forEachWithConcurrency(candidates, 2, async (candidate) => {
2845
+ try {
2846
+ const { accountId } = await rlsContextForWorkspace(db, candidate.workspaceId);
2847
+ const identity = { ...candidate, accountId };
2848
+ const attempt = await claimModalProviderCreateRecovery(db, identity);
2849
+ if (!attempt) return;
2850
+ const instanceId = await discover(settings, attempt);
2851
+ if (!instanceId) return; // Absence never settles an unknown dispatch.
2852
+ if (await recordRecoveredModalProviderCreate(db, { ...identity, attempt, instanceId })) {
2853
+ observability.info("sandbox reaper: recovered Modal create receipt", {
2854
+ ...candidate,
2855
+ operationId: attempt.operationId,
2856
+ instanceId,
2857
+ });
2858
+ }
2859
+ } catch (error) {
2860
+ observability.warn("sandbox reaper: Modal create remains fenced pending discovery", {
2861
+ ...candidate,
2862
+ error: error instanceof Error ? error.message : String(error),
2863
+ });
2864
+ }
2865
+ });
2866
+ }
2867
+
2256
2868
  export function modalOrphanTerminationStillEligible(
2257
2869
  latest: Awaited<ReturnType<typeof listLiveModalSandboxLeaseAttributions>>,
2258
2870
  candidate: ModalOrphanSweepTermination,
2259
2871
  ): boolean {
2872
+ // The provider may have accepted a create whose reply has not arrived.
2873
+ // Before attribution, neither its ID nor its separately-applied tags are
2874
+ // available here. Postpone orphan deletion while any such owner exists;
2875
+ // age/absence cannot distinguish its provider from an abandoned one.
2876
+ if (latest.some((lease) => lease.liveness === "warming" && lease.instanceId === null)) {
2877
+ return false;
2878
+ }
2260
2879
  if (latest.some((lease) => lease.instanceId === candidate.sandboxId)) {
2261
2880
  return false;
2262
2881
  }
@@ -2444,6 +3063,7 @@ async function terminateDrainableBox(
2444
3063
  probeDrainableProvider: DrainableProviderProbeFn,
2445
3064
  attempt: SandboxDrainCaptureAttempt,
2446
3065
  objectStorage: ObjectStorage | null,
3066
+ bus: ActivityServices["bus"] | null = null,
2447
3067
  ): Promise<boolean> {
2448
3068
  // Resolve the account for the RLS-scoped confirmDrainCold (the global sweep
2449
3069
  // returns no account_id; the workspace->account map is the bootstrap read).
@@ -2799,6 +3419,38 @@ async function terminateDrainableBox(
2799
3419
  // lease draining for a later sweep (NEVER terminate a box whose files we
2800
3420
  // could not capture). A persist CAS miss means the box was re-armed and left
2801
3421
  // running, so the cold commit is skipped.
3422
+ // A published capture retry skips persistence. Revalidate legacy containment
3423
+ // even on that path: stale enrollment is not native-supervisor exit proof.
3424
+ // Typed provider absence is independently fenced by the canonical loss commit.
3425
+ if (
3426
+ !providerMissingBeforeCapture &&
3427
+ backend === "modal" &&
3428
+ lease.instanceId &&
3429
+ !(await readWorkspaceArchiveCapturePreflight(db, {
3430
+ accountId,
3431
+ workspaceId: row.workspaceId,
3432
+ sandboxGroupId: row.sandboxGroupId,
3433
+ expectedEpoch: row.leaseEpoch,
3434
+ expectedInstanceId: lease.instanceId,
3435
+ liveness: "draining",
3436
+ }))
3437
+ ) {
3438
+ return false;
3439
+ }
3440
+ // Stamp the charge horizon after verified capture and immediately before
3441
+ // provider stop. Capture can fail and release its claim, allowing re-arm.
3442
+ // A retry after stop success cannot charge post-termination wall time.
3443
+ let stopCutoffMarked = false;
3444
+ const beforeProviderStop = async (): Promise<void> => {
3445
+ const cutoff = await markWarmBillingStopCutoff(db, {
3446
+ accountId,
3447
+ workspaceId: row.workspaceId,
3448
+ sandboxGroupId: row.sandboxGroupId,
3449
+ expectedEpoch: row.leaseEpoch,
3450
+ });
3451
+ if (!cutoff) throw new Error("sandbox stop cutoff was fenced before provider termination");
3452
+ stopCutoffMarked = true;
3453
+ };
2802
3454
  const termination: ProviderTerminationOutcome | boolean = providerMissingBeforeCapture
2803
3455
  ? { terminated: true, providerMissingBeforeCapture: true }
2804
3456
  : await terminateBox(
@@ -2821,12 +3473,50 @@ async function terminateDrainableBox(
2821
3473
  expectedInstanceId: lease.instanceId,
2822
3474
  });
2823
3475
  },
3476
+ row.workspaceId,
3477
+ beforeProviderStop,
2824
3478
  );
2825
3479
  const terminated = typeof termination === "boolean" ? termination : termination.terminated;
2826
3480
  if (!terminated) {
2827
3481
  return false;
2828
3482
  }
2829
3483
 
3484
+ // Self-hosted/none and an unassigned lease have no provider stop callback;
3485
+ // finish their usage at logical detach without touching customer compute.
3486
+ // A managed instance that was stopped without its callback must fail closed.
3487
+ const providerMissingDuringStop =
3488
+ providerMissingBeforeCapture ||
3489
+ (typeof termination !== "boolean" && termination.providerMissingBeforeCapture);
3490
+ if (!stopCutoffMarked && !providerMissingDuringStop) {
3491
+ if (managedProvider && lease.instanceId) {
3492
+ throw new Error("managed sandbox stopped without a warm usage cutoff");
3493
+ }
3494
+ await beforeProviderStop();
3495
+ }
3496
+
3497
+ // Stop was confirmed but the lease is not cold yet. This final epoch-fenced
3498
+ // tick settles the interval since the last heartbeat/reaper sweep, including
3499
+ // a box drained before its first periodic tick. If settlement fails, keep
3500
+ // the draining receipt for retry instead of silently losing the charge.
3501
+ const finalRate =
3502
+ settings.sandboxWarmBillingMode === "usage_only"
3503
+ ? 0
3504
+ : sandboxWarmRateMicrosPerSecond(settings, backend);
3505
+ if (!providerMissingDuringStop) {
3506
+ const finalTick = await accrueWarmSeconds(db, {
3507
+ accountId,
3508
+ workspaceId: row.workspaceId,
3509
+ sandboxGroupId: row.sandboxGroupId,
3510
+ expectedEpoch: row.leaseEpoch,
3511
+ warmRateMicrosPerSecond: finalRate,
3512
+ billingMode: settings.sandboxWarmBillingMode,
3513
+ finalDrain: true,
3514
+ });
3515
+ if (finalTick.costMicros > 0 && settings.sandboxWarmBillingMode === "credits") {
3516
+ recordCreditMicros(observability, "usage", finalTick.costMicros);
3517
+ }
3518
+ }
3519
+
2830
3520
  const providerMissing =
2831
3521
  providerMissingBeforeCapture ||
2832
3522
  (typeof termination === "boolean" ? false : termination.providerMissingBeforeCapture);
@@ -2834,15 +3524,36 @@ async function terminateDrainableBox(
2834
3524
  // with draining->cold. Until this succeeds, arrivals remain fenced by that
2835
3525
  // exact claim; a timestamp or a failed provider call can never reopen a box
2836
3526
  // while termination may still be in flight.
2837
- const { wentCold } = await confirmDrainCold(db, {
3527
+ const { wentCold, backgroundCommandEvents } = await confirmDrainCold(db, {
2838
3528
  accountId,
2839
3529
  workspaceId: row.workspaceId,
2840
3530
  sandboxGroupId: row.sandboxGroupId,
2841
3531
  expectedEpoch: row.leaseEpoch,
2842
3532
  ...(captureClaim ? { expectedCaptureId: captureClaim.id } : {}),
2843
3533
  providerMissingBeforeCapture: providerMissing,
3534
+ idleCommandContainmentMs: settings.sandboxIdleCommandContainmentMs,
3535
+ });
3536
+ // The command terminal events and agent inputs are already durable in the
3537
+ // cold commit; this is only best-effort live fanout.
3538
+ await publishDurableSessionEvents(bus, row.workspaceId, backgroundCommandEvents, (error) => {
3539
+ observability.warn("sandbox reaper: contained command event fanout failed", {
3540
+ sandboxGroupId: row.sandboxGroupId,
3541
+ error: error instanceof Error ? error.message : String(error),
3542
+ });
2844
3543
  });
2845
3544
  if (wentCold) {
3545
+ // Only the exact successful cold commit counts provider loss. A missing
3546
+ // probe, a stale capture, a failed commit, or a retried child is not another
3547
+ // observed loss. Keep this outside the best-effort session event writer.
3548
+ if (providerMissing) {
3549
+ recordSandboxProviderMissingBeforeCapture(observability, backend);
3550
+ }
3551
+ if (lease.unobservableCommandDrainIds?.length) {
3552
+ recordSandboxCommandContainment(
3553
+ observability,
3554
+ providerMissing ? "provider_missing" : "contained",
3555
+ );
3556
+ }
2846
3557
  // Durable termination record (sandbox-file-persistence observability): who
2847
3558
  // ended this box and whether its /workspace was captured first, appended to
2848
3559
  // every session sharing the group's box. Best-effort: attribution must
@@ -2955,18 +3666,23 @@ export async function terminateProviderBox(
2955
3666
  claimedCapturePolicy?: ProviderWorkspaceCapturePolicy | null,
2956
3667
  diskBackedArchives = false,
2957
3668
  releaseFailedCapture?: () => Promise<void>,
3669
+ workspaceId?: string,
3670
+ beforeProviderStop?: () => Promise<void>,
2958
3671
  ): Promise<ProviderTerminationOutcome> {
2959
3672
  const durableBackendId = (lease.resumeBackendId ?? lease.backend) as string;
2960
3673
  const backend = sandboxBackendForSdkBackendId(durableBackendId) ?? durableBackendId;
2961
3674
  const logIdentity = providerDrainLogIdentity(lease, backend);
3675
+ const warnCaptureFailure = (message: string, error: unknown): void =>
3676
+ warnDrainSnapshotFailure(observability, message, error, {
3677
+ sandboxGroupId: lease.sandboxGroupId,
3678
+ leaseEpoch: lease.leaseEpoch,
3679
+ ...(workspaceId ? { workspaceId } : {}),
3680
+ });
2962
3681
  const releaseAfterCaptureFailure = async (): Promise<void> => {
2963
3682
  try {
2964
3683
  await releaseFailedCapture?.();
2965
3684
  } catch (error) {
2966
- observability.warn("sandbox reaper: failed capture claim release failed", {
2967
- ...logIdentity,
2968
- error: error instanceof Error ? error.message : String(error),
2969
- });
3685
+ warnCaptureFailure("sandbox reaper: failed capture claim release failed", error);
2970
3686
  }
2971
3687
  };
2972
3688
  // 'none' / no backend -> nothing to terminate.
@@ -3016,6 +3732,11 @@ export async function terminateProviderBox(
3016
3732
  // fail closed instead of silently leaving a live provider behind while the
3017
3733
  // caller commits the lease cold.
3018
3734
  if (!lease.instanceId) {
3735
+ if (lease.providerCreateAttempt?.instanceId === null) {
3736
+ throw new Error(
3737
+ "provider_create_outcome_unknown: cannot infer termination from missing instance ID",
3738
+ );
3739
+ }
3019
3740
  if (persistedInstanceId) {
3020
3741
  throw new Error(
3021
3742
  `sandbox backend ${backend} has persisted provider identity ${persistedInstanceId} but no authoritative lease instance; refusing teardown`,
@@ -3051,7 +3772,13 @@ export async function terminateProviderBox(
3051
3772
  return { terminated: false, providerMissingBeforeCapture: false };
3052
3773
  }
3053
3774
  try {
3054
- await terminateModalById(settings, lease.instanceId);
3775
+ await beforeProviderStop?.();
3776
+ await terminateModalById(
3777
+ settings,
3778
+ lease.instanceId,
3779
+ undefined,
3780
+ lease.providerCreateAttempt?.providerBindingKey,
3781
+ );
3055
3782
  } catch (error) {
3056
3783
  if (!isProviderSandboxNotFoundError("modal", error)) {
3057
3784
  throw error;
@@ -3208,29 +3935,20 @@ export async function terminateProviderBox(
3208
3935
  },
3209
3936
  );
3210
3937
  } catch (error) {
3211
- observability.warn("sandbox reaper: late workspace capture publication failed", {
3212
- ...logIdentity,
3213
- error: error instanceof Error ? error.message : String(error),
3214
- });
3938
+ warnCaptureFailure("sandbox reaper: late workspace capture publication failed", error);
3215
3939
  } finally {
3216
3940
  await disposeWorkspaceArchive(archive);
3217
3941
  }
3218
3942
  },
3219
3943
  observeLatePublicationFailure: (error) => {
3220
- observability.warn("sandbox reaper: late capture publication cleanup failed", {
3221
- ...logIdentity,
3222
- error: error instanceof Error ? error.message : String(error),
3223
- });
3944
+ warnCaptureFailure("sandbox reaper: late capture publication cleanup failed", error);
3224
3945
  },
3225
3946
  observeLateFailure: (error) => {
3226
3947
  // Capture has now rejected and this timed-out path can never reach
3227
3948
  // provider teardown. Release only our unpublished claim so a waiting
3228
3949
  // turn can re-arm the intact live box; stale callbacks are DB-fenced.
3229
3950
  void releaseAfterCaptureFailure();
3230
- observability.warn("sandbox reaper: timed-out provider capture later failed", {
3231
- ...logIdentity,
3232
- error: error instanceof Error ? error.message : String(error),
3233
- });
3951
+ warnCaptureFailure("sandbox reaper: timed-out provider capture later failed", error);
3234
3952
  },
3235
3953
  });
3236
3954
  }
@@ -3241,12 +3959,9 @@ export async function terminateProviderBox(
3241
3959
  });
3242
3960
  return { terminated: true, providerMissingBeforeCapture: true };
3243
3961
  }
3244
- observability.warn(
3962
+ warnCaptureFailure(
3245
3963
  "sandbox reaper: persistWorkspace failed — leaving box draining (files NOT lost)",
3246
- {
3247
- ...logIdentity,
3248
- error: error instanceof Error ? error.message : String(error),
3249
- },
3964
+ error,
3250
3965
  );
3251
3966
  // An unresolved timeout is not proof of capture failure: retain the fence
3252
3967
  // until its provider promise settles. A settled failure cannot enter the
@@ -3306,6 +4021,7 @@ export async function terminateProviderBox(
3306
4021
  throw new Error(`sandbox backend ${backend} returned no terminable session state`);
3307
4022
  }
3308
4023
  prepareProviderForTeardownAfterCapture(backend, session);
4024
+ await beforeProviderStop?.();
3309
4025
  await terminateManagedSandboxSession(client, sessionState, session);
3310
4026
  return { terminated: true, providerMissingBeforeCapture: false };
3311
4027
  } catch (error) {