@tanstack/ai-sandbox 0.3.3 → 0.4.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 (51) hide show
  1. package/README.md +26 -0
  2. package/dist/esm/checkpoint-store.d.ts +147 -0
  3. package/dist/esm/checkpoint-store.js +267 -0
  4. package/dist/esm/checkpoint-store.js.map +1 -0
  5. package/dist/esm/contracts.d.ts +19 -0
  6. package/dist/esm/index.d.ts +11 -1
  7. package/dist/esm/index.js +12 -7
  8. package/dist/esm/memory-snapshot-types.d.ts +129 -0
  9. package/dist/esm/memory-snapshots.d.ts +6 -0
  10. package/dist/esm/memory-snapshots.js +490 -0
  11. package/dist/esm/memory-snapshots.js.map +1 -0
  12. package/dist/esm/middleware.d.ts +33 -1
  13. package/dist/esm/middleware.js +339 -94
  14. package/dist/esm/middleware.js.map +1 -1
  15. package/dist/esm/ngrok.d.ts +1 -1
  16. package/dist/esm/sandbox.d.ts +16 -0
  17. package/dist/esm/sandbox.js +62 -9
  18. package/dist/esm/sandbox.js.map +1 -1
  19. package/dist/esm/snapshot-operations.d.ts +65 -0
  20. package/dist/esm/snapshot-operations.js +317 -0
  21. package/dist/esm/snapshot-operations.js.map +1 -0
  22. package/dist/esm/snapshot-tools.d.ts +185 -0
  23. package/dist/esm/snapshot-tools.js +160 -0
  24. package/dist/esm/snapshot-tools.js.map +1 -0
  25. package/dist/esm/snapshots.d.ts +51 -0
  26. package/dist/esm/snapshots.js +350 -0
  27. package/dist/esm/snapshots.js.map +1 -0
  28. package/dist/esm/testkit/checkpoint-conformance.d.ts +2 -0
  29. package/dist/esm/testkit/checkpoint-conformance.js +453 -0
  30. package/dist/esm/testkit/checkpoint-conformance.js.map +1 -0
  31. package/dist/esm/testkit/checkpoint-fork-conformance.d.ts +18 -0
  32. package/dist/esm/testkit/checkpoint-fork-conformance.js +191 -0
  33. package/dist/esm/testkit/checkpoint-fork-conformance.js.map +1 -0
  34. package/dist/esm/testkit/conformance.d.ts +4 -0
  35. package/dist/esm/testkit/conformance.js +3 -1
  36. package/dist/esm/testkit/conformance.js.map +1 -1
  37. package/package.json +8 -3
  38. package/skills/ai-sandbox/SKILL.md +96 -8
  39. package/src/checkpoint-store.ts +652 -0
  40. package/src/contracts.ts +12 -0
  41. package/src/index.ts +56 -0
  42. package/src/memory-snapshot-types.ts +167 -0
  43. package/src/memory-snapshots.ts +936 -0
  44. package/src/middleware.ts +610 -160
  45. package/src/sandbox.ts +107 -6
  46. package/src/snapshot-operations.ts +540 -0
  47. package/src/snapshot-tools.ts +208 -0
  48. package/src/snapshots.ts +711 -0
  49. package/src/testkit/checkpoint-conformance.ts +472 -0
  50. package/src/testkit/checkpoint-fork-conformance.ts +299 -0
  51. package/src/testkit/conformance.ts +7 -0
@@ -1,13 +1,16 @@
1
1
  import { SandboxCapability, provideSandbox, provideSandboxPolicy } from "./capabilities.js";
2
2
  import { SandboxInstanceStoreCapability } from "./instance-store.js";
3
+ import { SandboxCheckpointError } from "./checkpoint-store.js";
4
+ import { captureSandboxArtifacts, captureSandboxFiles, resolveSandboxSnapshotPolicy, restoreSandboxFiles } from "./snapshots.js";
5
+ import { resolveAllSecrets, resolveSecret } from "./secrets.js";
6
+ import { computeWorkspaceHash } from "./key.js";
7
+ import { resolveHarnessCwd } from "./harness-cwd.js";
8
+ import "./bootstrap.js";
9
+ import { ensureSandboxWithOutcome } from "./sandbox.js";
3
10
  import { ProjectionCapability, provideWorkspaceProjection } from "./projection.js";
4
11
  import { provideSandboxDurability, resolveSandboxDurability } from "./durability.js";
5
- import { computeWorkspaceHash } from "./key.js";
6
12
  import { buildFileHookEvent, resolveFileEvents } from "./file-diff.js";
7
- import { resolveSecret } from "./secrets.js";
8
13
  import { createToolHistoryRecorder, stripObservedToolCalls } from "./tool-history.js";
9
- import { resolveHarnessCwd } from "./harness-cwd.js";
10
- import "./bootstrap.js";
11
14
  import { watchWorkspace } from "./watch.js";
12
15
  import { defineChatMiddleware, provideDetachableRun, provideRunDetached, wasCancelRequested } from "@tanstack/ai";
13
16
  import { InMemoryLockStore, LocksCapability } from "@tanstack/ai/locks";
@@ -32,6 +35,52 @@ import { getPendingTurn, getRunDisconnect, getSandboxRuntime } from "@tanstack/a
32
35
  * chunks), not from here — middleware setup runs before streaming begins.
33
36
  */
34
37
  var runState = /* @__PURE__ */ new WeakMap();
38
+ function stopSnapshotLease(state, options = {}) {
39
+ if (options.closePortable) state.snapshotClosed = true;
40
+ if (state.snapshotStop) return state.snapshotStop;
41
+ if (state.snapshotCleaned) return Promise.resolve();
42
+ state.snapshotCleaned = true;
43
+ state.snapshotRenewalGeneration++;
44
+ if (state.snapshotRenewal !== void 0) clearTimeout(state.snapshotRenewal);
45
+ state.snapshotRenewal = void 0;
46
+ const renewTask = state.snapshotRenewTask;
47
+ const captureTask = state.snapshotCaptureTask;
48
+ const lease = state.snapshotLease;
49
+ state.snapshotLease = void 0;
50
+ state.snapshotStop = (async () => {
51
+ await renewTask?.catch(() => {});
52
+ await captureTask?.catch(() => {});
53
+ await lease?.release();
54
+ })();
55
+ return state.snapshotStop;
56
+ }
57
+ function startSnapshotRenewal(state) {
58
+ const lease = state.snapshotLease;
59
+ if (!lease) return;
60
+ const schedule = () => {
61
+ const generation = state.snapshotRenewalGeneration;
62
+ state.snapshotRenewal = setTimeout(() => {
63
+ (async () => {
64
+ state.snapshotRenewal = void 0;
65
+ if (state.snapshotCleaned || generation !== state.snapshotRenewalGeneration) return;
66
+ const renewal = Promise.resolve().then(async () => {
67
+ await lease.renew();
68
+ });
69
+ state.snapshotRenewTask = renewal;
70
+ try {
71
+ await renewal;
72
+ } catch (error) {
73
+ state.snapshotLost = error instanceof Error ? error : new Error(String(error));
74
+ } finally {
75
+ if (state.snapshotRenewTask === renewal) state.snapshotRenewTask = void 0;
76
+ }
77
+ if (state.snapshotLost) await stopSnapshotLease(state).catch(() => {});
78
+ else if (!state.snapshotCleaned && generation === state.snapshotRenewalGeneration) schedule();
79
+ })();
80
+ }, lease.renewAfterMs);
81
+ };
82
+ schedule();
83
+ }
35
84
  /**
36
85
  * Stop the watcher and drain any in-flight `diff()` promises before teardown,
37
86
  * so the final file's diff isn't dropped when a run finishes/aborts/errors
@@ -50,6 +99,10 @@ async function drainWatcher(state, phase) {
50
99
  await Promise.allSettled(state.pendingDiffs);
51
100
  if (state.watcher) state.logger?.sandbox("sandbox watcher stopped", { phase });
52
101
  }
102
+ function canPublishPortableSnapshot(state, lease) {
103
+ if (state.snapshotLost) throw state.snapshotLost;
104
+ return !state.snapshotClosed && !state.snapshotCleaned && state.snapshotLease === lease;
105
+ }
53
106
  /**
54
107
  * Record the two facts a later attach and the reaper both need, then publish the
55
108
  * detach verdict core reads.
@@ -161,6 +214,25 @@ function withSandbox(definition, options) {
161
214
  optionalRequires: [SandboxInstanceStoreCapability, LocksCapability],
162
215
  async setup(ctx) {
163
216
  const ensureCtx = buildEnsureCtx(ctx, options);
217
+ const snapshotConfig = options?.snapshots;
218
+ const snapshotWorkspaceHash = definition.workspace ? computeWorkspaceHash(definition.workspace) : void 0;
219
+ const snapshotPolicy = snapshotConfig ? resolveSandboxSnapshotPolicy(snapshotConfig.policy, snapshotWorkspaceHash) : void 0;
220
+ let snapshotRuntime;
221
+ let snapshotLease;
222
+ if (snapshotConfig) {
223
+ if (!snapshotConfig.persistence?.stores?.messages || !snapshotConfig.persistence.stores.artifacts || !snapshotConfig.persistence.stores.blobs) throw new Error("Sandbox snapshots require persistence stores.messages, stores.artifacts, and stores.blobs");
224
+ const persistenceModule = await import("@tanstack/ai-persistence");
225
+ const persistence = ctx.getOptional(persistenceModule.PersistenceCapability);
226
+ if (persistence === void 0) throw new Error("Sandbox snapshots require withPersistence(snapshots.persistence) before withSandbox");
227
+ if (persistence !== snapshotConfig.persistence) throw new Error("Sandbox snapshots require the same persistence instance passed to withPersistence");
228
+ const completion = ctx.getOptional(persistenceModule.PersistenceCompletionCapability);
229
+ if (!completion) throw new Error("Sandbox snapshots require withPersistence before withSandbox");
230
+ snapshotRuntime = {
231
+ persistence: snapshotConfig.persistence,
232
+ completion
233
+ };
234
+ snapshotLease = await snapshotConfig.checkpoints.acquireWriter(ctx.threadId);
235
+ }
164
236
  const durability = resolveSandboxDurability(options);
165
237
  if (durability !== void 0) {
166
238
  provideSandboxDurability(ctx, durability);
@@ -170,12 +242,17 @@ function withSandbox(definition, options) {
170
242
  const logger = runtime?.logger;
171
243
  const state = {
172
244
  ensureCtx,
245
+ snapshotRenewalGeneration: 0,
173
246
  pendingDiffs: [],
174
247
  toolHistory: createToolHistoryRecorder(),
175
248
  ...logger ? { logger } : {},
176
249
  ...durability ? { durability } : {}
177
250
  };
178
251
  runState.set(ctx, state);
252
+ if (snapshotLease) {
253
+ state.snapshotLease = snapshotLease;
254
+ startSnapshotRenewal(state);
255
+ }
179
256
  if (durability !== void 0) {
180
257
  try {
181
258
  await durability.runs.createOrResume({
@@ -199,86 +276,148 @@ function withSandbox(definition, options) {
199
276
  }
200
277
  }
201
278
  if (durability !== void 0 && durability.detachOnDisconnect) getRunDisconnect(ctx, { optional: true })?.subscribe(async () => {
202
- if (await cancelIntent(durability, ctx.runId, false)) return;
203
- if (await recordDetach(definition, state, durability, ctx, "disconnect")) state.logger?.sandbox("sandbox run detached on disconnect; the run continues", { runId: ctx.runId });
279
+ const snapshotStop = stopSnapshotLease(state, { closePortable: true });
280
+ snapshotStop.catch(() => {});
281
+ if (await cancelIntent(durability, ctx.runId, false)) {
282
+ await snapshotStop.catch((error) => {
283
+ state.logger?.warn("sandbox snapshot writer release failed", {
284
+ runId: ctx.runId,
285
+ phase: "disconnect",
286
+ error
287
+ });
288
+ });
289
+ return;
290
+ }
291
+ if (await recordDetach(definition, state, durability, ctx, "disconnect")) {
292
+ try {
293
+ await snapshotStop;
294
+ } catch (error) {
295
+ state.logger?.warn("sandbox snapshot writer release failed", {
296
+ runId: ctx.runId,
297
+ phase: "disconnect",
298
+ error
299
+ });
300
+ }
301
+ state.logger?.sandbox("sandbox run detached on disconnect; the run continues", { runId: ctx.runId });
302
+ } else await snapshotStop.catch((error) => {
303
+ state.logger?.warn("sandbox snapshot writer release failed", {
304
+ runId: ctx.runId,
305
+ phase: "disconnect",
306
+ error
307
+ });
308
+ });
204
309
  });
205
- const handle = await definition.ensure(ensureCtx);
310
+ let outcome = "created";
311
+ let handle;
312
+ try {
313
+ if (snapshotConfig) ({handle, outcome} = await ensureSandboxWithOutcome(definition, ensureCtx));
314
+ else handle = await definition.ensure(ensureCtx);
315
+ state.handle = handle;
316
+ state.privateHandle = snapshotConfig ? outcome !== "resumed" : true;
317
+ if (snapshotConfig && outcome !== "resumed") {
318
+ const head = await snapshotConfig.checkpoints.getHead(ctx.threadId);
319
+ if (head) {
320
+ const checkpoint = await snapshotConfig.checkpoints.get(head);
321
+ if (!checkpoint) throw new SandboxCheckpointError("SANDBOX_SNAPSHOT_CHECKPOINT_NOT_FOUND", `Checkpoint '${head}' was not found`);
322
+ await restoreSandboxFiles(handle, {
323
+ blobs: snapshotConfig.persistence.stores.blobs,
324
+ workspaceRoot: definition.workspace?.root ?? "/workspace"
325
+ }, checkpoint, snapshotPolicy);
326
+ }
327
+ }
328
+ } catch (error) {
329
+ await stopSnapshotLease(state).catch(() => {});
330
+ if (state.handle && state.privateHandle) await definition.destroy(ensureCtx).catch(() => {});
331
+ throw error;
332
+ }
206
333
  state.handle = handle;
207
- provideSandbox(ctx, handle);
208
- if (definition.policy) provideSandboxPolicy(ctx, definition.policy);
209
- if (durability !== void 0 && (ensureCtx.locks === void 0 || ensureCtx.locks instanceof InMemoryLockStore)) logger?.warn("sandbox durability is wired over an InMemoryLockStore: run claims are serialized within this process only and the lease never signals loss, so two hosts can drive one run and duplicate its event log. Use a distributed LockStore via withLocks for any multi-replica deploy.", { runId: ctx.runId });
210
- const watchRoot = definition.workspace?.root ?? "/workspace";
211
- let baseSha = "";
334
+ if (snapshotConfig) {
335
+ state.snapshotConfig = snapshotConfig;
336
+ state.snapshotPolicy = snapshotPolicy;
337
+ state.snapshotRuntime = snapshotRuntime;
338
+ }
212
339
  try {
213
- const shaRes = await handle.process.exec("git rev-parse HEAD", { cwd: watchRoot });
214
- if (shaRes.exitCode === 0) {
215
- baseSha = shaRes.stdout.trim();
216
- logger?.sandbox("sandbox git baseline captured", {
340
+ provideSandbox(ctx, handle);
341
+ if (definition.policy) provideSandboxPolicy(ctx, definition.policy);
342
+ if (durability !== void 0 && (ensureCtx.locks === void 0 || ensureCtx.locks instanceof InMemoryLockStore)) logger?.warn("sandbox durability is wired over an InMemoryLockStore: run claims are serialized within this process only and the lease never signals loss, so two hosts can drive one run and duplicate its event log. Use a distributed LockStore via withLocks for any multi-replica deploy.", { runId: ctx.runId });
343
+ const watchRoot = definition.workspace?.root ?? "/workspace";
344
+ let baseSha = "";
345
+ try {
346
+ const shaRes = await handle.process.exec("git rev-parse HEAD", { cwd: watchRoot });
347
+ if (shaRes.exitCode === 0) {
348
+ baseSha = shaRes.stdout.trim();
349
+ logger?.sandbox("sandbox git baseline captured", {
350
+ root: watchRoot,
351
+ baseSha
352
+ });
353
+ } else logger?.sandbox("sandbox git baseline unavailable (non-zero exit)", {
217
354
  root: watchRoot,
218
- baseSha
355
+ exitCode: shaRes.exitCode,
356
+ stderr: shaRes.stderr
219
357
  });
220
- } else logger?.sandbox("sandbox git baseline unavailable (non-zero exit)", {
221
- root: watchRoot,
222
- exitCode: shaRes.exitCode,
223
- stderr: shaRes.stderr
224
- });
358
+ } catch (error) {
359
+ logger?.warn("sandbox git baseline capture failed", {
360
+ root: watchRoot,
361
+ error
362
+ });
363
+ }
364
+ const workspace = definition.workspace;
365
+ if (workspace !== void 0) {
366
+ const virtualRoot = workspace.root ?? "/workspace";
367
+ const root = resolveHarnessCwd(handle, virtualRoot);
368
+ const workspaceHash = computeWorkspaceHash(workspace);
369
+ const secrets = workspace.secrets;
370
+ provideWorkspaceProjection(ctx, {
371
+ skills: workspace.skills ?? [],
372
+ plugins: workspace.plugins ?? [],
373
+ resolveSecret: (ref) => {
374
+ if (secrets === void 0) throw new Error(`resolveSecret: no secrets defined on this workspace (ref: "${ref.__secretName}")`);
375
+ return resolveSecret(secrets, ref);
376
+ },
377
+ markerPath: `${root}/.tanstack-projected-${workspaceHash}`,
378
+ root,
379
+ ...workspace.scripts !== void 0 ? { scripts: workspace.scripts } : {}
380
+ });
381
+ }
382
+ const hooks = definition.hooks;
383
+ await hooks?.onReady?.(handle);
384
+ const fe = resolveFileEvents(definition.fileEvents);
385
+ const pendingDiffs = state.pendingDiffs;
386
+ let watcher;
387
+ if (fe.enabled) {
388
+ watcher = await watchWorkspace(handle, {
389
+ onEvent: (event) => {
390
+ const enriched = buildFileHookEvent(handle, watchRoot, baseSha, event, logger);
391
+ dispatchDefinitionHooks(hooks, enriched, logger);
392
+ runtime?.emit(enriched);
393
+ if (fe.diff) pendingDiffs.push(enriched.diff().then((diff) => {
394
+ runtime?.emitFileDiff({
395
+ path: event.path,
396
+ diff
397
+ });
398
+ }).catch((error) => {
399
+ logger?.warn("sandbox file diff emit failed", {
400
+ path: event.path,
401
+ error
402
+ });
403
+ }));
404
+ },
405
+ root: watchRoot,
406
+ ...ctx.signal !== void 0 ? { signal: ctx.signal } : {},
407
+ ...logger !== void 0 ? { logger } : {}
408
+ });
409
+ logger?.sandbox("sandbox watcher started", {
410
+ root: watchRoot,
411
+ diff: fe.diff
412
+ });
413
+ }
414
+ if (watcher) state.watcher = watcher;
225
415
  } catch (error) {
226
- logger?.warn("sandbox git baseline capture failed", {
227
- root: watchRoot,
228
- error
229
- });
230
- }
231
- const workspace = definition.workspace;
232
- if (workspace !== void 0) {
233
- const virtualRoot = workspace.root ?? "/workspace";
234
- const root = resolveHarnessCwd(handle, virtualRoot);
235
- const workspaceHash = computeWorkspaceHash(workspace);
236
- const secrets = workspace.secrets;
237
- provideWorkspaceProjection(ctx, {
238
- skills: workspace.skills ?? [],
239
- plugins: workspace.plugins ?? [],
240
- resolveSecret: (ref) => {
241
- if (secrets === void 0) throw new Error(`resolveSecret: no secrets defined on this workspace (ref: "${ref.__secretName}")`);
242
- return resolveSecret(secrets, ref);
243
- },
244
- markerPath: `${root}/.tanstack-projected-${workspaceHash}`,
245
- root,
246
- ...workspace.scripts !== void 0 ? { scripts: workspace.scripts } : {}
247
- });
416
+ await drainWatcher(state, "error");
417
+ await stopSnapshotLease(state).catch(() => {});
418
+ if (state.privateHandle) await definition.destroy(ensureCtx).catch(() => {});
419
+ throw error;
248
420
  }
249
- const hooks = definition.hooks;
250
- await hooks?.onReady?.(handle);
251
- const fe = resolveFileEvents(definition.fileEvents);
252
- const pendingDiffs = state.pendingDiffs;
253
- let watcher;
254
- if (fe.enabled) {
255
- watcher = await watchWorkspace(handle, {
256
- onEvent: (event) => {
257
- const enriched = buildFileHookEvent(handle, watchRoot, baseSha, event, logger);
258
- dispatchDefinitionHooks(hooks, enriched, logger);
259
- runtime?.emit(enriched);
260
- if (fe.diff) pendingDiffs.push(enriched.diff().then((diff) => {
261
- runtime?.emitFileDiff({
262
- path: event.path,
263
- diff
264
- });
265
- }).catch((error) => {
266
- logger?.warn("sandbox file diff emit failed", {
267
- path: event.path,
268
- error
269
- });
270
- }));
271
- },
272
- root: watchRoot,
273
- ...ctx.signal !== void 0 ? { signal: ctx.signal } : {},
274
- ...logger !== void 0 ? { logger } : {}
275
- });
276
- logger?.sandbox("sandbox watcher started", {
277
- root: watchRoot,
278
- diff: fe.diff
279
- });
280
- }
281
- if (watcher) state.watcher = watcher;
282
421
  },
283
422
  onConfig(_ctx, config) {
284
423
  const messages = stripObservedToolCalls(config.messages);
@@ -288,8 +427,13 @@ function withSandbox(definition, options) {
288
427
  onIteration(ctx) {
289
428
  runState.get(ctx)?.toolHistory.reconcile(ctx);
290
429
  },
291
- onChunk(ctx, chunk) {
292
- runState.get(ctx)?.toolHistory.observe(chunk, ctx);
430
+ async onChunk(ctx, chunk) {
431
+ const state = runState.get(ctx);
432
+ state?.toolHistory.observe(chunk, ctx);
433
+ if (state && chunk.type === "RUN_FINISHED" && chunk.outcome?.type === "interrupt") {
434
+ await drainWatcher(state, "pause");
435
+ await stopSnapshotLease(state, { closePortable: true });
436
+ }
293
437
  },
294
438
  async onFinish(ctx) {
295
439
  const state = runState.get(ctx);
@@ -297,49 +441,150 @@ function withSandbox(definition, options) {
297
441
  const { handle, ensureCtx } = state;
298
442
  state.toolHistory.reconcile(ctx);
299
443
  await drainWatcher(state, "finish");
300
- const lifecycle = definition.lifecycle;
301
- if (lifecycle?.snapshot === "after-run" && handle?.capabilities.snapshots && handle.snapshot) {
302
- const snapshot = await handle.snapshot(`after-run-${ctx.runId}`);
303
- const store = ensureCtx.store;
304
- if (store) {
305
- const key = definition.key(ensureCtx);
306
- const existing = await store.get(key);
307
- if (existing) await store.upsert({
308
- ...existing,
309
- latestSnapshotId: snapshot.id,
310
- updatedAt: Date.now()
444
+ let primaryError;
445
+ try {
446
+ const snapshotCaptureTask = Promise.resolve().then(async () => {
447
+ const config = state.snapshotConfig;
448
+ const runtime = state.snapshotRuntime;
449
+ const lease = state.snapshotLease;
450
+ if (!config || !runtime || !handle || !lease) {
451
+ if (state.snapshotLost) throw state.snapshotLost;
452
+ return;
453
+ }
454
+ if (!canPublishPortableSnapshot(state, lease)) return;
455
+ await runtime.completion.waitForRunCompletion();
456
+ if (!canPublishPortableSnapshot(state, lease)) return;
457
+ const conversation = await runtime.persistence.stores.messages.loadThread(ctx.threadId);
458
+ if (!canPublishPortableSnapshot(state, lease)) return;
459
+ const files = await captureSandboxFiles(handle, {
460
+ blobs: config.persistence.stores.blobs,
461
+ workspaceRoot: definition.workspace?.root ?? "/workspace"
462
+ }, state.snapshotPolicy, definition.workspace?.secrets !== void 0 ? resolveAllSecrets(definition.workspace.secrets) : {});
463
+ if (!canPublishPortableSnapshot(state, lease)) return;
464
+ const artifacts = await captureSandboxArtifacts({
465
+ blobs: config.persistence.stores.blobs,
466
+ artifacts: config.persistence.stores.artifacts
467
+ }, ctx.threadId, definition.workspace?.secrets !== void 0 ? resolveAllSecrets(definition.workspace.secrets) : {});
468
+ if (!canPublishPortableSnapshot(state, lease)) return;
469
+ const parentCheckpointId = await config.checkpoints.getHead(ctx.threadId);
470
+ if (!canPublishPortableSnapshot(state, lease)) return;
471
+ try {
472
+ await config.checkpoints.append({
473
+ checkpoint: {
474
+ id: `checkpoint-${ctx.runId}`,
475
+ threadId: ctx.threadId,
476
+ parentCheckpointId,
477
+ createdAt: Date.now(),
478
+ reason: "automatic",
479
+ sourceRunId: ctx.runId,
480
+ files: files.files,
481
+ conversation,
482
+ artifacts
483
+ },
484
+ expectedHeadId: parentCheckpointId,
485
+ writer: lease
486
+ });
487
+ } catch (error) {
488
+ if (state.snapshotLost) throw state.snapshotLost;
489
+ throw error;
490
+ }
491
+ if (state.snapshotLost) throw state.snapshotLost;
492
+ });
493
+ state.snapshotCaptureTask = snapshotCaptureTask;
494
+ try {
495
+ await snapshotCaptureTask;
496
+ } finally {
497
+ if (state.snapshotCaptureTask === snapshotCaptureTask) state.snapshotCaptureTask = void 0;
498
+ }
499
+ const lifecycle = definition.lifecycle;
500
+ if (lifecycle?.snapshot === "after-run" && handle?.capabilities.snapshots && handle.snapshot) {
501
+ const snapshot = await handle.snapshot(`after-run-${ctx.runId}`);
502
+ const store = ensureCtx.store;
503
+ if (store) {
504
+ const key = definition.key(ensureCtx);
505
+ const existing = await store.get(key);
506
+ if (existing) await store.upsert({
507
+ ...existing,
508
+ latestSnapshotId: snapshot.id,
509
+ updatedAt: Date.now()
510
+ });
511
+ }
512
+ }
513
+ if (lifecycle?.destroyOnComplete) {
514
+ await definition.destroy(ensureCtx);
515
+ await definition.hooks?.onDestroy?.();
516
+ }
517
+ } catch (error) {
518
+ primaryError = error;
519
+ if (definition.lifecycle?.destroyOnComplete) try {
520
+ await definition.destroy(ensureCtx);
521
+ await definition.hooks?.onDestroy?.();
522
+ } catch (cleanupError) {
523
+ state.logger?.warn("sandbox destroy after terminal failure failed", {
524
+ runId: ctx.runId,
525
+ phase: "finish",
526
+ error: cleanupError
311
527
  });
312
528
  }
313
529
  }
314
- if (lifecycle?.destroyOnComplete) {
315
- await definition.destroy(ensureCtx);
316
- await definition.hooks?.onDestroy?.();
530
+ let snapshotCleanupError;
531
+ try {
532
+ await stopSnapshotLease(state, { closePortable: true });
533
+ } catch (error) {
534
+ snapshotCleanupError = error;
317
535
  }
536
+ if (primaryError !== void 0) {
537
+ if (snapshotCleanupError !== void 0) state.logger?.warn("sandbox snapshot writer release failed", {
538
+ runId: ctx.runId,
539
+ phase: "finish",
540
+ error: snapshotCleanupError
541
+ });
542
+ throw primaryError;
543
+ }
544
+ if (snapshotCleanupError !== void 0) throw snapshotCleanupError;
318
545
  },
319
546
  async onAbort(ctx, info) {
320
547
  const state = runState.get(ctx);
321
548
  if (!state) return;
322
549
  await drainWatcher(state, "abort");
550
+ let releaseError;
551
+ try {
552
+ await stopSnapshotLease(state, { closePortable: true });
553
+ } catch (error) {
554
+ releaseError = error;
555
+ }
323
556
  const durability = state.durability;
324
557
  const cancelled = await cancelIntent(durability, ctx.runId, info.cancelRequested === true);
325
558
  if (durability !== void 0 && !cancelled && durability.detachOnDisconnect) {
326
- if (await recordDetach(definition, state, durability, ctx, "abort")) return;
559
+ if (await recordDetach(definition, state, durability, ctx, "abort")) {
560
+ if (releaseError) throw releaseError;
561
+ return;
562
+ }
327
563
  await definition.destroy(state.ensureCtx);
328
564
  await definition.hooks?.onDestroy?.();
565
+ if (releaseError) throw releaseError;
329
566
  return;
330
567
  }
331
568
  await definition.destroy(state.ensureCtx);
332
569
  await definition.hooks?.onDestroy?.();
570
+ if (releaseError) throw releaseError;
333
571
  },
334
572
  async onError(ctx, info) {
335
573
  const state = runState.get(ctx);
336
574
  if (!state) return;
337
575
  await drainWatcher(state, "error");
576
+ let releaseError;
577
+ try {
578
+ await stopSnapshotLease(state);
579
+ } catch (error) {
580
+ releaseError = error;
581
+ }
338
582
  await definition.hooks?.onError?.(info.error);
339
583
  if (definition.lifecycle?.destroyOnComplete) {
340
584
  await definition.destroy(state.ensureCtx);
341
585
  await definition.hooks?.onDestroy?.();
342
586
  }
587
+ if (releaseError) throw releaseError;
343
588
  }
344
589
  });
345
590
  }