@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
@@ -20,21 +20,27 @@
20
20
  //
21
21
  // Liveness between turns is the lease refcount; there is no keepalive loop.
22
22
 
23
+ import type { EventBus } from "@opengeni/events";
24
+ import { publishDurableSessionEvents } from "./session-event-fanout";
23
25
  import {
24
26
  effectiveSandboxLifecycle,
25
27
  sandboxArchiveCaptureTimeoutMs,
26
28
  sandboxLifecycleTransitionWaitMs,
29
+ sandboxWarmRateMicrosPerSecond,
27
30
  type Settings,
28
31
  } from "@opengeni/config";
29
32
  import { randomUUID } from "node:crypto";
33
+ import { retainsHolderForWarmCapture } from "./warm-capture-holder";
30
34
  import {
31
35
  acquireLease,
32
36
  adoptLegacyModalCheckpointArtifact,
33
37
  beginSandboxRematerialization,
38
+ sessionHoldsFreshWorkspaceRecovery,
34
39
  claimWorkspaceArchiveCapture,
35
40
  commitWarmingToWarm,
36
41
  failSandboxRematerialization,
37
42
  failWarmingToCold,
43
+ beginModalProviderCreate,
38
44
  getSandboxSessionEnvelope,
39
45
  heartbeatLeaseHolderStatus,
40
46
  markSandboxRestoreVerifying,
@@ -79,6 +85,7 @@ import {
79
85
  withoutSandboxProviderIdentity,
80
86
  type EstablishedSandboxSession,
81
87
  type RuntimeMetricsHooks,
88
+ type SandboxReadinessReplacementOutcome,
82
89
  type WorkspaceArchiveDescriptor,
83
90
  } from "@opengeni/runtime";
84
91
  import {
@@ -86,7 +93,9 @@ import {
86
93
  WorkspaceArchiveStorageError,
87
94
  type ObjectStorage,
88
95
  } from "@opengeni/storage";
89
- import type { Observability } from "@opengeni/observability";
96
+ import { sandboxLeaseTelemetryKey, type Observability } from "@opengeni/observability";
97
+ import { safeSnapshotError } from "./sandbox-snapshot-diagnostics";
98
+ export { safeSnapshotError } from "./sandbox-snapshot-diagnostics";
90
99
  import { parseWorkspaceArchiveObjectRef } from "@opengeni/contracts";
91
100
  import {
92
101
  persistWorkspaceArchiveCandidate,
@@ -125,9 +134,7 @@ export type SandboxResumeServices = {
125
134
  db: Database;
126
135
  settings: Settings;
127
136
  objectStorage?: ObjectStorage | null;
128
- /** Exact settings before a verified rig provider image overlaid the logical
129
- * pack/deployment image. Fresh-create NotFound fallback must preserve an
130
- * ID-only logical base instead of selecting the provider default. */
137
+
131
138
  logicalFallbackSettings?: Settings;
132
139
  sandboxMetrics?: RuntimeMetricsHooks;
133
140
  /** Structured logger for lifecycle decisions (holder release reasons). Falls
@@ -148,6 +155,26 @@ export type SandboxResumeServices = {
148
155
  /** Test seam for the bounded command-readiness proof performed before an
149
156
  * attached provider box is handed to the agent. */
150
157
  verifyAttachedSandboxReadiness?: (established: EstablishedSandboxSession) => Promise<void>;
158
+ /** Test seam for the bounded command-readiness proof the elected spawner runs
159
+ * on its freshly created box before publishing the lease warm. Production
160
+ * uses {@link waitForSandboxExecReadiness} with the shared 60 s budget. */
161
+ verifySpawnedSandboxReadiness?: (
162
+ established: EstablishedSandboxSession,
163
+ identity: { sandboxGroupId: string },
164
+ ) => Promise<void>;
165
+ /** Test seam for the jittered pause before the single fresh-box readiness
166
+ * replacement. Production uses {@link freshSandboxReadinessReplacementDelayMs}.
167
+ * It may be async so a test can observe the rolled-back lease in between. */
168
+ freshSandboxReadinessReplacementDelayMs?: () => number | Promise<number>;
169
+ /**
170
+ * The turn attempt's fresh-box readiness replacement budget. The lazy
171
+ * provisioner may call resumeBoxForTurn again after a typed lease
172
+ * supersession, so the turn creates the budget once and shares it with every
173
+ * call. Absent, the call gets its own single replacement.
174
+ */
175
+ freshSandboxReadinessReplacementBudget?: FreshSandboxReadinessReplacementBudget;
176
+ /** Live fanout for background commands a provider loss settled. */
177
+ bus?: EventBus | null;
151
178
  /** Called only by the observer that wins the exact warm->cold loss CAS. */
152
179
  onSandboxLost?: (input: {
153
180
  sandboxGroupId: string;
@@ -187,6 +214,9 @@ export type ResumeBoxIds = {
187
214
  * never conflicts).
188
215
  */
189
216
  image?: string;
217
+ /** Deployment/workspace image pins apply only to new creates; an existing
218
+ * group keeps its image. Omission preserves explicit image matching (B3). */
219
+ imagePolicy?: "require_match" | "new_creates_only";
190
220
  /**
191
221
  * RIG IS SHARED STATE (M3): the frozen rig version this run rides. Threaded to
192
222
  * acquireLease, which stamps it on the cold-create and conflicts on a live box
@@ -316,6 +346,80 @@ export class SandboxSiblingWarmingTimeoutError extends SandboxWarmingTimeoutErro
316
346
  }
317
347
  }
318
348
 
349
+ /**
350
+ * Out-of-band proof attached to one exact {@link SandboxExecReadinessTimeoutError}
351
+ * thrown by the elected spawner: the box that missed its readiness budget was
352
+ * freshly created by this caller (not an attached, resumed, or provider-continuity
353
+ * box), it was never published warm, the provider terminated it, and the holder
354
+ * was released. Only such an error may be replaced by re-entering admission.
355
+ */
356
+ const disposedUnreadyFreshSandboxErrors = new WeakSet<object>();
357
+
358
+ /** True only for a spawner readiness timeout whose unready fresh box is proven gone. */
359
+ export function isReplaceableFreshSandboxReadinessTimeout(error: unknown): boolean {
360
+ return (
361
+ error instanceof SandboxExecReadinessTimeoutError &&
362
+ disposedUnreadyFreshSandboxErrors.has(error)
363
+ );
364
+ }
365
+
366
+ /**
367
+ * A burst of simultaneous turn starts can leave many freshly created provider
368
+ * boxes command-unready at once. The single replacement re-enters admission
369
+ * after a jittered pause so the replacement creates do not re-synchronize into
370
+ * the same burst. Bounded to [2 s, 10 s).
371
+ */
372
+ export const FRESH_SANDBOX_READINESS_REPLACEMENT_BASE_DELAY_MS = 2_000;
373
+ export const FRESH_SANDBOX_READINESS_REPLACEMENT_JITTER_MS = 8_000;
374
+
375
+ export function freshSandboxReadinessReplacementDelayMs(
376
+ random: () => number = Math.random,
377
+ ): number {
378
+ const sample = random();
379
+ const unit = Number.isFinite(sample) ? Math.min(Math.max(sample, 0), 1 - Number.EPSILON) : 0;
380
+ return (
381
+ FRESH_SANDBOX_READINESS_REPLACEMENT_BASE_DELAY_MS +
382
+ Math.floor(unit * FRESH_SANDBOX_READINESS_REPLACEMENT_JITTER_MS)
383
+ );
384
+ }
385
+
386
+ /** Replacements allowed per turn attempt, across every resumeBoxForTurn call. */
387
+ export const FRESH_SANDBOX_READINESS_REPLACEMENTS_PER_TURN_ATTEMPT = 1;
388
+
389
+ export type FreshSandboxReadinessReplacementBudget = { remaining: number };
390
+
391
+ export function createFreshSandboxReadinessReplacementBudget(): FreshSandboxReadinessReplacementBudget {
392
+ return { remaining: FRESH_SANDBOX_READINESS_REPLACEMENTS_PER_TURN_ATTEMPT };
393
+ }
394
+
395
+ function recordFreshSandboxReadinessReplacement(
396
+ metrics: RuntimeMetricsHooks | undefined,
397
+ backend: string,
398
+ outcome: SandboxReadinessReplacementOutcome,
399
+ ): void {
400
+ try {
401
+ metrics?.onSandboxReadinessReplacement?.({ backend, outcome });
402
+ } catch {
403
+ // Metrics emission must never affect sandbox recovery or error propagation.
404
+ }
405
+ }
406
+
407
+ async function sleepUnlessCancelled(ms: number, signal: AbortSignal | undefined): Promise<boolean> {
408
+ if (signal?.aborted) return false;
409
+ if (ms <= 0) return true;
410
+ return await new Promise<boolean>((resolve) => {
411
+ const onAbort = () => {
412
+ clearTimeout(timer);
413
+ resolve(false);
414
+ };
415
+ const timer = setTimeout(() => {
416
+ signal?.removeEventListener("abort", onAbort);
417
+ resolve(true);
418
+ }, ms);
419
+ signal?.addEventListener("abort", onAbort, { once: true });
420
+ });
421
+ }
422
+
319
423
  /** The exact attached caller that won warm->cold after proving the provider
320
424
  * instance gone. It is a lease supersession (the same logical turn recovers),
321
425
  * plus the lost id needed for one durable observability event. */
@@ -348,9 +452,10 @@ export async function waitForSandboxExecReadiness(
348
452
  established: EstablishedSandboxSession,
349
453
  timeoutMs = MODAL_EXEC_READINESS_TIMEOUT_MS,
350
454
  identity: { sandboxGroupId?: string | null } = {},
455
+ signal?: AbortSignal,
351
456
  ): Promise<void> {
352
457
  try {
353
- await verifySandboxExecReadiness(established, timeoutMs);
458
+ await verifySandboxExecReadiness(established, timeoutMs, signal);
354
459
  } catch (error) {
355
460
  if (error instanceof SandboxExecReadinessError && error.code === "exec_probe_timeout") {
356
461
  throw new SandboxExecReadinessTimeoutError(established.backendId, timeoutMs, {
@@ -369,54 +474,6 @@ class SnapshotTimeoutError extends Error {
369
474
  }
370
475
  }
371
476
 
372
- export function safeSnapshotError(error: unknown): {
373
- errorClass: "SnapshotOperationError";
374
- errorCode: "snapshot_operation_failed";
375
- status?: number;
376
- origin: "sandbox-resume";
377
- causeName?: string;
378
- integrityCode?: string;
379
- } {
380
- const fields: {
381
- errorClass: "SnapshotOperationError";
382
- errorCode: "snapshot_operation_failed";
383
- status?: number;
384
- origin: "sandbox-resume";
385
- causeName?: string;
386
- integrityCode?: string;
387
- } = {
388
- errorClass: "SnapshotOperationError",
389
- errorCode: "snapshot_operation_failed",
390
- origin: "sandbox-resume",
391
- };
392
- try {
393
- if (error && typeof error === "object") {
394
- const candidate = error as {
395
- name?: unknown;
396
- code?: unknown;
397
- status?: unknown;
398
- statusCode?: unknown;
399
- };
400
- if (
401
- typeof candidate.name === "string" &&
402
- /^[A-Z][A-Za-z0-9]{2,62}Error$/u.test(candidate.name)
403
- ) {
404
- fields.causeName = candidate.name;
405
- }
406
- if (typeof candidate.code === "string" && /^[a-z0-9_]{1,64}$/u.test(candidate.code)) {
407
- fields.integrityCode = candidate.code;
408
- }
409
- const rawStatus = candidate.status ?? candidate.statusCode;
410
- const status = Number(rawStatus);
411
- if (Number.isInteger(status) && status >= 100 && status <= 599) fields.status = status;
412
- }
413
- } catch {
414
- // Public diagnostics are best-effort and must never replace the exact
415
- // internal snapshot failure.
416
- }
417
- return fields;
418
- }
419
-
420
477
  export async function waitForWarmSnapshot(
421
478
  snapshot: Promise<unknown>,
422
479
  timeoutMs: number,
@@ -582,8 +639,9 @@ async function materializeSpawnEnvelopeArchive(
582
639
  }
583
640
  if (!objectStorage) {
584
641
  throw new WorkspaceArchiveIntegrityError(
585
- "archive_base64_invalid",
642
+ "archive_storage_unavailable",
586
643
  "workspace archive object storage is not configured",
644
+ { retryable: true },
587
645
  );
588
646
  }
589
647
  const descriptor = parseWorkspaceArchiveDescriptor(sessionState.workspaceArchiveMeta);
@@ -787,6 +845,19 @@ async function persistWarmWorkspaceSnapshot(
787
845
  registerOwnedContinuation: (continuation: Promise<void>) => void,
788
846
  ): Promise<boolean> {
789
847
  const { db, settings } = services;
848
+ const warnSnapshotFailure = (message: string, error: unknown): void => {
849
+ const fields = {
850
+ ...safeSnapshotError(error),
851
+ sandboxLeaseKey: sandboxLeaseTelemetryKey(ids.workspaceId, ids.sandboxGroupId),
852
+ leaseEpoch,
853
+ };
854
+ try {
855
+ if (services.observability) services.observability.warn(message, fields);
856
+ else console.warn(message, fields);
857
+ } catch {
858
+ // Diagnostics cannot change provider settlement or capture-gate cleanup.
859
+ }
860
+ };
790
861
  const intervalMs = settings.sandboxSnapshotIntervalMs;
791
862
  if (intervalMs <= 0 && !force) {
792
863
  return false;
@@ -896,6 +967,9 @@ async function persistWarmWorkspaceSnapshot(
896
967
  : {}),
897
968
  });
898
969
  if (claimed.status !== "claimed") {
970
+ // A scheduled interval not being due is normal, including after a failed
971
+ // attempt. Do not turn every ten-second heartbeat into an error log.
972
+ if (claimed.status === "throttled") return false;
899
973
  console.error("mid-session workspace snapshot skipped (capture claim)", {
900
974
  sandboxGroupId: ids.sandboxGroupId,
901
975
  status: claimed.status,
@@ -951,6 +1025,8 @@ async function persistWarmWorkspaceSnapshot(
951
1025
  // turn signal resolves first. Its finally block is the only normal release
952
1026
  // of the exact admission gate; a late callback cannot release a successor.
953
1027
  const captureAndPublish = (async (): Promise<boolean> => {
1028
+ const captureStarted = performance.now();
1029
+ let captureOutcome: "completed" | "failed" = "failed";
954
1030
  let archive: VerifiedWorkspaceArchivePayload | undefined;
955
1031
  let candidate: { id: string } | null = null;
956
1032
  let publicationAttempted = false;
@@ -1001,6 +1077,7 @@ async function persistWarmWorkspaceSnapshot(
1001
1077
  if (!wrote && candidate) {
1002
1078
  await abandonCandidate(candidate.id, "snapshot_publication_fenced");
1003
1079
  }
1080
+ if (wrote) captureOutcome = "completed";
1004
1081
  return wrote;
1005
1082
  } catch (error) {
1006
1083
  if (candidate && !publicationAttempted)
@@ -1018,20 +1095,24 @@ async function persistWarmWorkspaceSnapshot(
1018
1095
  expectedEpoch: leaseEpoch,
1019
1096
  expectedInstanceId: instanceId,
1020
1097
  }).catch((error) => {
1021
- console.error(
1022
- "mid-session workspace capture gate release failed",
1023
- safeSnapshotError(error),
1024
- );
1098
+ captureOutcome = "failed";
1099
+ warnSnapshotFailure("mid-session workspace capture gate release failed", error);
1025
1100
  });
1101
+ try {
1102
+ services.sandboxMetrics?.onWorkspaceCapture?.({
1103
+ backend: lease.backend,
1104
+ outcome: captureOutcome,
1105
+ durationSeconds: Math.max(0, performance.now() - captureStarted) / 1_000,
1106
+ });
1107
+ } catch {
1108
+ // A completed capture and released gate cannot depend on telemetry.
1109
+ }
1026
1110
  }
1027
1111
  })();
1028
1112
  const settled = captureAndPublish.then(
1029
1113
  (persisted) => ({ kind: "settled" as const, persisted }),
1030
1114
  (error) => {
1031
- console.error(
1032
- "mid-session workspace snapshot failed (turn unaffected)",
1033
- safeSnapshotError(error),
1034
- );
1115
+ warnSnapshotFailure("mid-session workspace snapshot failed (turn unaffected)", error);
1035
1116
  return { kind: "settled" as const, persisted: false };
1036
1117
  },
1037
1118
  );
@@ -1063,12 +1144,10 @@ async function persistWarmWorkspaceSnapshot(
1063
1144
  // exact claim cleanup, so it cannot leak an unhandled promise or artifact.
1064
1145
  return outcome.kind === "settled" ? outcome.persisted : false;
1065
1146
  } catch (error) {
1066
- // Protection, not a dependency: a failed snapshot must never fail (or slow
1067
- // down retrying) the turn. The next heartbeat/turn-end tick retries.
1068
- console.error(
1069
- "mid-session workspace snapshot failed (turn unaffected)",
1070
- safeSnapshotError(error),
1071
- );
1147
+ // A failed snapshot does not fail the turn. Physical captures can still
1148
+ // fence commands until their exact gate settles; the caller timeout is not
1149
+ // permission to release that gate. The next eligible tick can retry.
1150
+ warnSnapshotFailure("mid-session workspace snapshot failed (turn unaffected)", error);
1072
1151
  return false;
1073
1152
  }
1074
1153
  }
@@ -1082,12 +1161,103 @@ async function persistWarmWorkspaceSnapshot(
1082
1161
  * holderId is the globally unique durable turn-attempt id. It must not be a
1083
1162
  * Temporal activity id, because activity ids are only workflow-local and
1084
1163
  * collide when sibling sessions share one sandbox group.
1164
+ *
1165
+ * A freshly created box that misses its bounded command-readiness budget is
1166
+ * replaced at most once per turn attempt (the budget is shared by every call
1167
+ * the turn's provisioner makes). The first establish has already terminated that exact
1168
+ * unpublished box, rolled its exact warming epoch back to cold (advancing the
1169
+ * epoch), and released its holder, so the replacement is an ordinary new
1170
+ * admission: it re-runs the cold->warming CAS, persists its own provider
1171
+ * instance before readiness, and may instead attach to a sibling that won the
1172
+ * CAS first. Nothing model- or tool-visible ran on the discarded box, so no
1173
+ * side effect is replayed. A second readiness timeout fails the turn exactly
1174
+ * as before; attached/resumed boxes and unconfirmed terminations never
1175
+ * replace.
1085
1176
  */
1086
1177
  export async function resumeBoxForTurn(
1087
1178
  services: SandboxResumeServices,
1088
1179
  ids: ResumeBoxIds,
1089
1180
  kind: "turn",
1090
1181
  holderId: TurnSandboxLeaseHolderId,
1182
+ ): Promise<ResumedTurnSandbox> {
1183
+ try {
1184
+ return await resumeBoxForTurnOnce(services, ids, kind, holderId);
1185
+ } catch (error) {
1186
+ if (!isReplaceableFreshSandboxReadinessTimeout(error)) throw error;
1187
+ const readiness = error as SandboxExecReadinessTimeoutError;
1188
+ const warn = (message: string, fields: Parameters<Observability["warn"]>[1]) => {
1189
+ if (services.observability) services.observability.warn(message, fields);
1190
+ else console.warn(message, fields);
1191
+ };
1192
+ const identity = {
1193
+ workspaceId: ids.workspaceId,
1194
+ sessionId: ids.sessionId,
1195
+ sandboxGroupId: ids.sandboxGroupId,
1196
+ backend: readiness.backend,
1197
+ instanceId: readiness.instanceId,
1198
+ readinessTimeoutMs: readiness.timeoutMs,
1199
+ };
1200
+ const budget =
1201
+ services.freshSandboxReadinessReplacementBudget ??
1202
+ createFreshSandboxReadinessReplacementBudget();
1203
+ if (budget.remaining <= 0) {
1204
+ recordFreshSandboxReadinessReplacement(
1205
+ services.sandboxMetrics,
1206
+ readiness.backend,
1207
+ "budget_spent",
1208
+ );
1209
+ warn(
1210
+ "sandbox command-readiness timed out on a fresh box; this turn attempt already used its replacement",
1211
+ identity,
1212
+ );
1213
+ throw error;
1214
+ }
1215
+ budget.remaining -= 1;
1216
+ const delayMs = await (
1217
+ services.freshSandboxReadinessReplacementDelayMs ?? freshSandboxReadinessReplacementDelayMs
1218
+ )();
1219
+ warn("sandbox command-readiness timed out on a fresh box; replacing it once after jitter", {
1220
+ ...identity,
1221
+ replacementDelayMs: delayMs,
1222
+ });
1223
+ if (!(await sleepUnlessCancelled(delayMs, services.cancellationSignal))) {
1224
+ // Cancellation owns the turn boundary; surface the original typed
1225
+ // readiness failure rather than starting a replacement.
1226
+ recordFreshSandboxReadinessReplacement(
1227
+ services.sandboxMetrics,
1228
+ readiness.backend,
1229
+ "cancelled",
1230
+ );
1231
+ throw error;
1232
+ }
1233
+ try {
1234
+ const replaced = await resumeBoxForTurnOnce(services, ids, kind, holderId);
1235
+ recordFreshSandboxReadinessReplacement(
1236
+ services.sandboxMetrics,
1237
+ readiness.backend,
1238
+ "replaced",
1239
+ );
1240
+ return replaced;
1241
+ } catch (replacementError) {
1242
+ recordFreshSandboxReadinessReplacement(
1243
+ services.sandboxMetrics,
1244
+ readiness.backend,
1245
+ services.cancellationSignal?.aborted
1246
+ ? "cancelled"
1247
+ : replacementError instanceof SandboxExecReadinessTimeoutError
1248
+ ? "failed_again"
1249
+ : "replacement_failed",
1250
+ );
1251
+ throw replacementError;
1252
+ }
1253
+ }
1254
+ }
1255
+
1256
+ async function resumeBoxForTurnOnce(
1257
+ services: SandboxResumeServices,
1258
+ ids: ResumeBoxIds,
1259
+ kind: "turn",
1260
+ holderId: TurnSandboxLeaseHolderId,
1091
1261
  ): Promise<ResumedTurnSandbox> {
1092
1262
  const { db, settings } = services;
1093
1263
  const os = ids.os ?? "linux";
@@ -1179,13 +1349,18 @@ export async function resumeBoxForTurn(
1179
1349
  holderId,
1180
1350
  subjectId: ids.sessionId,
1181
1351
  backend: ids.backend,
1352
+ warmBilling: {
1353
+ mode: settings.sandboxWarmBillingMode,
1354
+ rateMicrosPerSecond: sandboxWarmRateMicrosPerSecond(settings, ids.backend),
1355
+ },
1182
1356
  os,
1183
1357
  // IMAGE IS SHARED STATE (B3): thread the resolved image so the lease stamps it +
1184
- // conflicts on a live box already running a different image. A
1358
+ // conflicts on a live box already running a different required image. A
1185
1359
  // SandboxImageConflictError propagates while another holder is active; a
1186
1360
  // solo change requests a capture-and-drain rotation and this attempt retries
1187
1361
  // after the cold successor can safely stamp the new image.
1188
1362
  ...(ids.image ? { image: ids.image } : {}),
1363
+ ...(ids.imagePolicy ? { imagePolicy: ids.imagePolicy } : {}),
1189
1364
  // RIG IS SHARED STATE (M3): thread the frozen rig version so the lease stamps it
1190
1365
  // + conflicts on a live box under a different rig. A SandboxRigConflictError
1191
1366
  // propagates while another holder is active; a solo change uses the same
@@ -1276,7 +1451,16 @@ export async function resumeBoxForTurn(
1276
1451
  // provider operation may still be mutating /workspace. Both loops therefore
1277
1452
  // converge on "holder alive -> never release"; finalization performs the
1278
1453
  // proof-bearing release.
1279
- const releaseDeadHolder = (reason: string): void => {
1454
+ const releaseDeadHolder = async (reason: string): Promise<void> => {
1455
+ // Logical closure deliberately fences heartbeat authority. It is not proof
1456
+ // that the already-admitted capture has physically settled: the finalizer
1457
+ // owns that wait and the original deadline bounds dead-worker recovery.
1458
+ // Match the reaper's retention rule without touching holder/lease TTLs.
1459
+ if (reason === "holder_gone" && kind === "turn" && holderLeaseHeartbeat) {
1460
+ const expectedEpoch = holderLeaseHeartbeat.expectedEpoch;
1461
+ const lease = await readLease(db, ids.workspaceId, ids.sandboxGroupId);
1462
+ if (retainsHolderForWarmCapture(lease, expectedEpoch)) return;
1463
+ }
1280
1464
  const message = "sandbox resume holder released: holder no longer live";
1281
1465
  const fields = {
1282
1466
  workspaceId: ids.workspaceId,
@@ -1300,14 +1484,15 @@ export async function resumeBoxForTurn(
1300
1484
  holderId,
1301
1485
  expectedEpoch: heartbeat.expectedEpoch,
1302
1486
  leaseTtlMs: heartbeat.leaseTtlMs,
1487
+ billingMode: services.settings.sandboxWarmBillingMode,
1303
1488
  })
1304
- .then((status) => {
1489
+ .then(async (status) => {
1305
1490
  if (heartbeat !== holderLeaseHeartbeat) return;
1306
1491
  if (!status.holderAlive) {
1307
1492
  // Reaped, released, or its exact attempt is no longer the active
1308
1493
  // writer. Stop this otherwise-unbounded provider operation and
1309
1494
  // idempotently drop any remaining holder state.
1310
- releaseDeadHolder(status.fence);
1495
+ await releaseDeadHolder(status.fence);
1311
1496
  return;
1312
1497
  }
1313
1498
  if (status.fence === "epoch") {
@@ -1316,7 +1501,7 @@ export async function resumeBoxForTurn(
1316
1501
  // instance replaced, re-established by a later turn). The holder
1317
1502
  // row is stale authority for an instance that no longer exists;
1318
1503
  // drop it promptly instead of pinning a successor's lease.
1319
- releaseDeadHolder(status.fence);
1504
+ await releaseDeadHolder(status.fence);
1320
1505
  return;
1321
1506
  }
1322
1507
  // The box is still serving this live holder: keep the provider TTL
@@ -1338,13 +1523,13 @@ export async function resumeBoxForTurn(
1338
1523
  kind,
1339
1524
  holderId,
1340
1525
  })
1341
- .then((touched) => {
1526
+ .then(async (touched) => {
1342
1527
  if (heartbeat !== holderLeaseHeartbeat) return;
1343
1528
  // A canonical turn holder is rejected once its exact attempt is no
1344
1529
  // longer the active writer (touch is attempt-fenced only; it never
1345
1530
  // consults lease authority, so false here always means holder death).
1346
1531
  if (!touched) {
1347
- releaseDeadHolder("holder_gone");
1532
+ await releaseDeadHolder("holder_gone");
1348
1533
  return;
1349
1534
  }
1350
1535
  void maybeRenewProviderExpiration().catch(() => undefined);
@@ -1391,6 +1576,8 @@ export async function resumeBoxForTurn(
1391
1576
  if (acquired.role === "spawner") {
1392
1577
  const expectedEpoch = acquired.lease.leaseEpoch;
1393
1578
  let createdEstablished: EstablishedSandboxSession | null = null;
1579
+ let providerCreateOperationId: string | undefined;
1580
+ let providerCreateBindingKey: string | undefined;
1394
1581
  let rematerialization: {
1395
1582
  id: string;
1396
1583
  selectedRevision: string;
@@ -1402,29 +1589,53 @@ export async function resumeBoxForTurn(
1402
1589
  > | null;
1403
1590
  } | null = null;
1404
1591
  try {
1405
- const envelope = await getSandboxSessionEnvelope(db, ids.workspaceId, ids.sessionId);
1592
+ // An audited decision to continue a definitively lost workspace on a new
1593
+ // EMPTY box: hydrate nothing (not even a per-session legacy archive) and
1594
+ // resume no prior provider identity. Every group member was already told
1595
+ // the workspace is empty; the DB accepts only this exact publication.
1596
+ const freshWorkspaceRecoveryId = acquired.lease.freshWorkspaceRecoveryId ?? null;
1597
+ const envelope = freshWorkspaceRecoveryId
1598
+ ? null
1599
+ : await getSandboxSessionEnvelope(db, ids.workspaceId, ids.sessionId);
1406
1600
  // The lease is authoritative. A legacy per-session fallback archive may
1407
1601
  // only be used after beginSandboxRematerialization imports its archive
1408
1602
  // fields under the warming-row lock and records one selected revision.
1409
1603
  // Select the fallback only when the lease has no archive truth at all. A
1410
1604
  // lease-carried unverified/invalid archive must fail closed rather than
1411
1605
  // silently substituting another revision.
1606
+ // Backstop: a session already told its workspace is empty never gets a
1607
+ // legacy per-session archive back, even if the lease marker was lost.
1608
+ const legacyFallbackRefused =
1609
+ !freshWorkspaceRecoveryId &&
1610
+ acquired.lease.recovery.archive.status === "none" &&
1611
+ workspaceArchiveFieldsFromEnvelope(envelope) !== null &&
1612
+ (await sessionHoldsFreshWorkspaceRecovery(db, ids.workspaceId, ids.sessionId));
1412
1613
  const fallbackArchiveEnvelope =
1614
+ !freshWorkspaceRecoveryId &&
1615
+ !legacyFallbackRefused &&
1413
1616
  acquired.lease.recovery.archive.status === "none" &&
1414
1617
  workspaceArchiveFieldsFromEnvelope(envelope) !== null
1415
1618
  ? withoutSandboxProviderIdentity(envelope)
1416
1619
  : null;
1417
- let spawnEnvelope = fallbackArchiveEnvelope ?? acquired.lease.resumeState ?? envelope;
1620
+ let spawnEnvelope = freshWorkspaceRecoveryId
1621
+ ? null
1622
+ : legacyFallbackRefused
1623
+ ? (acquired.lease.resumeState ?? null)
1624
+ : (fallbackArchiveEnvelope ?? acquired.lease.resumeState ?? envelope);
1418
1625
  const archiveSource =
1419
1626
  acquired.lease.recovery.archive.status === "none"
1420
1627
  ? fallbackArchiveEnvelope
1421
1628
  : acquired.lease.resumeState;
1422
1629
  const continuityRecovery = acquired.lease.recovery.continuity;
1630
+ // A fresh-workspace spawn is deliberately archive-free; commitWarmingToWarm
1631
+ // verifies the exact decision before publication.
1423
1632
  if (
1424
- (acquired.lease.recovery.archive.status === "available" &&
1425
- acquired.lease.archiveComplete) ||
1426
- (acquired.lease.recovery.archive.status === "none" &&
1427
- workspaceArchiveFieldsFromEnvelope(archiveSource) !== null)
1633
+ !freshWorkspaceRecoveryId &&
1634
+ ((acquired.lease.recovery.archive.status === "available" &&
1635
+ (acquired.lease.archiveComplete ||
1636
+ acquired.lease.historicalRecoveryAuthorized === true)) ||
1637
+ (acquired.lease.recovery.archive.status === "none" &&
1638
+ workspaceArchiveFieldsFromEnvelope(archiveSource) !== null))
1428
1639
  ) {
1429
1640
  const rematerializationId = crypto.randomUUID();
1430
1641
  const legacyNativeArchive = legacyNativeArchiveFromEnvelope(archiveSource);
@@ -1467,7 +1678,11 @@ export async function resumeBoxForTurn(
1467
1678
  legacyCheckpoint: begun.checkpointArtifact === null ? legacyNativeArchive : null,
1468
1679
  legacyProviderBinding: null,
1469
1680
  };
1470
- } else if (acquired.lease.recovery.archive.status !== "none" && !continuityRecovery) {
1681
+ } else if (
1682
+ !freshWorkspaceRecoveryId &&
1683
+ acquired.lease.recovery.archive.status !== "none" &&
1684
+ !continuityRecovery
1685
+ ) {
1471
1686
  throw new SandboxLeaseRecoveryBlockedError(
1472
1687
  ids.sandboxGroupId,
1473
1688
  expectedEpoch,
@@ -1526,6 +1741,34 @@ export async function resumeBoxForTurn(
1526
1741
  ...(services.logicalFallbackSettings
1527
1742
  ? { logicalFallbackSettings: services.logicalFallbackSettings }
1528
1743
  : {}),
1744
+ onBeforeSandboxCreate: async (createSettings, intent, providerContext) => {
1745
+ if (ids.backend !== "modal") return;
1746
+ if (!intent || !providerContext)
1747
+ throw new Error("Modal create requires the provider dispatch boundary");
1748
+ throwIfReleasedOrCancelled();
1749
+ const binding = await resolveModalCheckpointProviderBindingForSession(
1750
+ createSettings,
1751
+ providerContext,
1752
+ );
1753
+ const operationId = intent.operationId;
1754
+ await beginModalProviderCreate(db, {
1755
+ accountId: ids.accountId,
1756
+ workspaceId: ids.workspaceId,
1757
+ sandboxGroupId: ids.sandboxGroupId,
1758
+ expectedEpoch,
1759
+ operationId,
1760
+ providerBindingKey: binding.key,
1761
+ rematerializationId: rematerialization?.id ?? null,
1762
+ selectedRevision: rematerialization?.selectedRevision ?? null,
1763
+ imageId: intent.imageId,
1764
+ imageRef: createSettings.modalImageRef ?? null,
1765
+ appId: intent.appId,
1766
+ providerName: intent.name,
1767
+ requestSha256: intent.requestSha256,
1768
+ });
1769
+ providerCreateOperationId = operationId;
1770
+ providerCreateBindingKey = binding.key;
1771
+ },
1529
1772
  onSandboxCreated: async (created) => {
1530
1773
  createdEstablished = created;
1531
1774
  providerRenewalTarget = {
@@ -1533,7 +1776,16 @@ export async function resumeBoxForTurn(
1533
1776
  instanceId: created.instanceId,
1534
1777
  };
1535
1778
  providerRenewedAtMs = Date.now();
1536
- throwIfReleasedOrCancelled();
1779
+ if (
1780
+ providerCreateBindingKey &&
1781
+ !(await modalSessionMatchesCheckpointProviderBinding(
1782
+ settings,
1783
+ created.session,
1784
+ providerCreateBindingKey,
1785
+ ))
1786
+ ) {
1787
+ throw new Error("Modal creation receipt crossed the fenced provider namespace");
1788
+ }
1537
1789
  if (
1538
1790
  rematerialization &&
1539
1791
  (rematerialization.providerBindingKey || rematerialization.legacyCheckpoint)
@@ -1584,6 +1836,7 @@ export async function resumeBoxForTurn(
1584
1836
  sandboxGroupId: ids.sandboxGroupId,
1585
1837
  expectedEpoch,
1586
1838
  rematerializationId: rematerialization?.id ?? null,
1839
+ ...(providerCreateOperationId ? { providerCreateOperationId } : {}),
1587
1840
  ...(created.providerContinuity
1588
1841
  ? { continuityRecovery: created.providerContinuity }
1589
1842
  : {}),
@@ -1666,11 +1919,22 @@ export async function resumeBoxForTurn(
1666
1919
  // A sandbox handle is not sufficient evidence that an asynchronous
1667
1920
  // provider's command router is live. Do not publish a warm lease until
1668
1921
  // one bounded no-op exec works.
1669
- // On timeout the catch below terminates the box and rolls warming -> cold,
1670
- // so the next turn cold-creates instead of hanging forever on first use.
1671
- await waitForSandboxExecReadiness(established, MODAL_EXEC_READINESS_TIMEOUT_MS, {
1672
- sandboxGroupId: ids.sandboxGroupId,
1673
- });
1922
+ // On timeout the catch below terminates the box and rolls warming -> cold;
1923
+ // resumeBoxForTurn may then replace a proven-terminated fresh box once.
1924
+ if (services.verifySpawnedSandboxReadiness) {
1925
+ await services.verifySpawnedSandboxReadiness(established, {
1926
+ sandboxGroupId: ids.sandboxGroupId,
1927
+ });
1928
+ } else {
1929
+ await waitForSandboxExecReadiness(
1930
+ established,
1931
+ MODAL_EXEC_READINESS_TIMEOUT_MS,
1932
+ {
1933
+ sandboxGroupId: ids.sandboxGroupId,
1934
+ },
1935
+ cancellationSignal,
1936
+ );
1937
+ }
1674
1938
  await maybeRenewProviderExpiration(true);
1675
1939
  throwIfReleasedOrCancelled();
1676
1940
  // Fold the LIVE box into a re-resumable envelope and persist it as the
@@ -1716,16 +1980,18 @@ export async function resumeBoxForTurn(
1716
1980
  dataPlaneUrl: null,
1717
1981
  resumeBackendId: established.backendId,
1718
1982
  resumeState: resumeEnvelope,
1719
- ...(established.providerContinuity
1720
- ? { continuityRecovery: established.providerContinuity }
1721
- : rematerialization
1722
- ? {
1723
- rematerialization: {
1724
- id: rematerialization.id,
1725
- verifiedRevision: rematerialization.selectedRevision,
1726
- },
1727
- }
1728
- : {}),
1983
+ ...(freshWorkspaceRecoveryId
1984
+ ? { freshWorkspace: { operationId: freshWorkspaceRecoveryId } }
1985
+ : established.providerContinuity
1986
+ ? { continuityRecovery: established.providerContinuity }
1987
+ : rematerialization
1988
+ ? {
1989
+ rematerialization: {
1990
+ id: rematerialization.id,
1991
+ verifiedRevision: rematerialization.selectedRevision,
1992
+ },
1993
+ }
1994
+ : {}),
1729
1995
  leaseTtlMs,
1730
1996
  });
1731
1997
  if (!committed.committed || !committed.lease) {
@@ -1806,6 +2072,16 @@ export async function resumeBoxForTurn(
1806
2072
  }
1807
2073
  await release();
1808
2074
  recordSandboxWarmingTimeout(services.sandboxMetrics, error);
2075
+ if (
2076
+ terminated &&
2077
+ createdEstablished &&
2078
+ error instanceof SandboxExecReadinessTimeoutError &&
2079
+ error.instanceId === createdEstablished.instanceId &&
2080
+ (createdEstablished.origin === "created" || createdEstablished.origin === "restored") &&
2081
+ !createdEstablished.providerContinuity
2082
+ ) {
2083
+ disposedUnreadyFreshSandboxErrors.add(error);
2084
+ }
1809
2085
  throw sandboxProvisionStageError(rematerialization ? "archive_recovery" : "create", error);
1810
2086
  }
1811
2087
  }
@@ -1855,9 +2131,14 @@ export async function resumeBoxForTurn(
1855
2131
  if (services.verifyAttachedSandboxReadiness) {
1856
2132
  await services.verifyAttachedSandboxReadiness(established);
1857
2133
  } else {
1858
- await waitForSandboxExecReadiness(established, MODAL_EXEC_READINESS_TIMEOUT_MS, {
1859
- sandboxGroupId: ids.sandboxGroupId,
1860
- });
2134
+ await waitForSandboxExecReadiness(
2135
+ established,
2136
+ MODAL_EXEC_READINESS_TIMEOUT_MS,
2137
+ {
2138
+ sandboxGroupId: ids.sandboxGroupId,
2139
+ },
2140
+ cancellationSignal,
2141
+ );
1861
2142
  }
1862
2143
  providerRenewalTarget = {
1863
2144
  backend: ids.backend,
@@ -1875,8 +2156,14 @@ export async function resumeBoxForTurn(
1875
2156
  sandboxGroupId: ids.sandboxGroupId,
1876
2157
  expectedEpoch: leaseEpoch,
1877
2158
  expectedInstanceId: live.instanceId,
2159
+ expectedBackend: ids.backend,
1878
2160
  });
1879
2161
  if (marked.status === "marked") {
2162
+ await publishDurableSessionEvents(
2163
+ services.bus,
2164
+ ids.workspaceId,
2165
+ marked.backgroundCommandEvents,
2166
+ );
1880
2167
  await services.onSandboxLost?.({
1881
2168
  sandboxGroupId: ids.sandboxGroupId,
1882
2169
  instanceId: live.instanceId,