@tanstack/ai-sandbox 0.3.4 → 0.5.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 (64) hide show
  1. package/README.md +26 -0
  2. package/dist/esm/approvals.js +5 -4
  3. package/dist/esm/approvals.js.map +1 -1
  4. package/dist/esm/bridge-events.js +7 -6
  5. package/dist/esm/bridge-events.js.map +1 -1
  6. package/dist/esm/checkpoint-store.d.ts +147 -0
  7. package/dist/esm/checkpoint-store.js +267 -0
  8. package/dist/esm/checkpoint-store.js.map +1 -0
  9. package/dist/esm/chunk-identity.d.ts +0 -17
  10. package/dist/esm/chunk-identity.js +28 -5
  11. package/dist/esm/chunk-identity.js.map +1 -1
  12. package/dist/esm/contracts.d.ts +19 -0
  13. package/dist/esm/index.d.ts +11 -1
  14. package/dist/esm/index.js +12 -7
  15. package/dist/esm/memory-snapshot-types.d.ts +129 -0
  16. package/dist/esm/memory-snapshots.d.ts +6 -0
  17. package/dist/esm/memory-snapshots.js +490 -0
  18. package/dist/esm/memory-snapshots.js.map +1 -0
  19. package/dist/esm/middleware.d.ts +33 -1
  20. package/dist/esm/middleware.js +339 -94
  21. package/dist/esm/middleware.js.map +1 -1
  22. package/dist/esm/ngrok.d.ts +1 -1
  23. package/dist/esm/sandbox.d.ts +16 -0
  24. package/dist/esm/sandbox.js +62 -9
  25. package/dist/esm/sandbox.js.map +1 -1
  26. package/dist/esm/snapshot-operations.d.ts +65 -0
  27. package/dist/esm/snapshot-operations.js +317 -0
  28. package/dist/esm/snapshot-operations.js.map +1 -0
  29. package/dist/esm/snapshot-tools.d.ts +185 -0
  30. package/dist/esm/snapshot-tools.js +160 -0
  31. package/dist/esm/snapshot-tools.js.map +1 -0
  32. package/dist/esm/snapshots.d.ts +51 -0
  33. package/dist/esm/snapshots.js +350 -0
  34. package/dist/esm/snapshots.js.map +1 -0
  35. package/dist/esm/testkit/checkpoint-conformance.d.ts +2 -0
  36. package/dist/esm/testkit/checkpoint-conformance.js +453 -0
  37. package/dist/esm/testkit/checkpoint-conformance.js.map +1 -0
  38. package/dist/esm/testkit/checkpoint-fork-conformance.d.ts +18 -0
  39. package/dist/esm/testkit/checkpoint-fork-conformance.js +191 -0
  40. package/dist/esm/testkit/checkpoint-fork-conformance.js.map +1 -0
  41. package/dist/esm/testkit/conformance.d.ts +4 -0
  42. package/dist/esm/testkit/conformance.js +3 -1
  43. package/dist/esm/testkit/conformance.js.map +1 -1
  44. package/dist/esm/tool-history.js +4 -5
  45. package/dist/esm/tool-history.js.map +1 -1
  46. package/package.json +8 -3
  47. package/skills/ai-sandbox/SKILL.md +96 -8
  48. package/src/approvals.ts +13 -12
  49. package/src/bridge-events.ts +16 -10
  50. package/src/checkpoint-store.ts +652 -0
  51. package/src/chunk-identity.ts +36 -5
  52. package/src/contracts.ts +12 -0
  53. package/src/index.ts +56 -0
  54. package/src/memory-snapshot-types.ts +167 -0
  55. package/src/memory-snapshots.ts +936 -0
  56. package/src/middleware.ts +610 -160
  57. package/src/sandbox.ts +107 -6
  58. package/src/snapshot-operations.ts +540 -0
  59. package/src/snapshot-tools.ts +208 -0
  60. package/src/snapshots.ts +711 -0
  61. package/src/testkit/checkpoint-conformance.ts +472 -0
  62. package/src/testkit/checkpoint-fork-conformance.ts +299 -0
  63. package/src/testkit/conformance.ts +7 -0
  64. package/src/tool-history.ts +9 -13
package/src/middleware.ts CHANGED
@@ -41,7 +41,7 @@ import { SandboxInstanceStoreCapability } from './instance-store'
41
41
  import { computeWorkspaceHash } from './key'
42
42
  import { buildFileHookEvent, resolveFileEvents } from './file-diff'
43
43
  import { ProjectionCapability, provideWorkspaceProjection } from './projection'
44
- import { resolveSecret } from './secrets'
44
+ import { resolveAllSecrets, resolveSecret } from './secrets'
45
45
  import {
46
46
  createToolHistoryRecorder,
47
47
  stripObservedToolCalls,
@@ -49,12 +49,26 @@ import {
49
49
  import { watchWorkspace } from './watch'
50
50
  import { DEFAULT_WORKSPACE_ROOT } from './bootstrap'
51
51
  import { resolveHarnessCwd } from './harness-cwd'
52
+ import { ensureSandboxWithOutcome } from './sandbox'
53
+ import {
54
+ restoreSandboxFiles,
55
+ captureSandboxFiles,
56
+ captureSandboxArtifacts,
57
+ resolveSandboxSnapshotPolicy,
58
+ } from './snapshots'
59
+ import type { SandboxSnapshotPolicy } from './snapshots'
60
+ import type {
61
+ SandboxCheckpointStore,
62
+ SandboxCheckpointWriterLease,
63
+ } from './checkpoint-store'
64
+ import { SandboxCheckpointError } from './checkpoint-store'
52
65
  import type { InternalLogger } from '@tanstack/ai/adapter-internals'
53
66
  import type { LockStore } from '@tanstack/ai/locks'
54
67
  import type {
55
68
  AbortInfo,
56
69
  ChatMiddlewareContext,
57
70
  DefinedChatMiddleware,
71
+ ModelMessage,
58
72
  RunStore,
59
73
  SandboxFileEvent,
60
74
  SandboxFileHookEvent,
@@ -75,6 +89,24 @@ import type { SandboxWatchHandle } from './watch'
75
89
 
76
90
  /** Per-request state we need to carry from `setup` to the terminal hooks. */
77
91
  interface SandboxRunState {
92
+ snapshotLease?: SandboxCheckpointWriterLease
93
+ snapshotRenewal?: ReturnType<typeof setTimeout>
94
+ snapshotRenewTask?: Promise<void>
95
+ snapshotCaptureTask?: Promise<void>
96
+ snapshotRenewalGeneration: number
97
+ snapshotStop?: Promise<void>
98
+ /** A detached or paused run cannot later publish portable state. */
99
+ snapshotClosed?: boolean
100
+ snapshotLost?: Error
101
+ snapshotCleaned?: boolean
102
+ snapshotConfig?: NonNullable<SandboxMiddlewareOptions['snapshots']>
103
+ snapshotPolicy?: SandboxSnapshotPolicy
104
+ snapshotRuntime?: {
105
+ persistence: NonNullable<
106
+ SandboxMiddlewareOptions['snapshots']
107
+ >['persistence']
108
+ completion: { waitForRunCompletion: () => Promise<void> }
109
+ }
78
110
  /**
79
111
  * OPTIONAL because the state is registered BEFORE `definition.ensure()` is
80
112
  * awaited, and `ensure` is the slowest thing in the whole run — cloning a repo
@@ -90,6 +122,7 @@ interface SandboxRunState {
90
122
  * completed.
91
123
  */
92
124
  handle?: SandboxHandle
125
+ privateHandle?: boolean
93
126
  ensureCtx: SandboxEnsureContext
94
127
  watcher?: SandboxWatchHandle
95
128
  /** In-flight `enriched.diff()` promises queued by the `fileEvents.diff`
@@ -114,6 +147,67 @@ interface SandboxRunState {
114
147
 
115
148
  const runState = new WeakMap<object, SandboxRunState>()
116
149
 
150
+ function stopSnapshotLease(
151
+ state: SandboxRunState,
152
+ options: { closePortable?: boolean } = {},
153
+ ): Promise<void> {
154
+ if (options.closePortable) state.snapshotClosed = true
155
+ if (state.snapshotStop) return state.snapshotStop
156
+ if (state.snapshotCleaned) return Promise.resolve()
157
+ state.snapshotCleaned = true
158
+ state.snapshotRenewalGeneration++
159
+ if (state.snapshotRenewal !== undefined) clearTimeout(state.snapshotRenewal)
160
+ state.snapshotRenewal = undefined
161
+ const renewTask = state.snapshotRenewTask
162
+ const captureTask = state.snapshotCaptureTask
163
+ const lease = state.snapshotLease
164
+ state.snapshotLease = undefined
165
+ state.snapshotStop = (async () => {
166
+ await renewTask?.catch(() => {})
167
+ await captureTask?.catch(() => {})
168
+ await lease?.release()
169
+ })()
170
+ return state.snapshotStop
171
+ }
172
+
173
+ function startSnapshotRenewal(state: SandboxRunState): void {
174
+ const lease = state.snapshotLease
175
+ if (!lease) return
176
+ const schedule = (): void => {
177
+ const generation = state.snapshotRenewalGeneration
178
+ state.snapshotRenewal = setTimeout(() => {
179
+ void (async (): Promise<void> => {
180
+ state.snapshotRenewal = undefined
181
+ if (
182
+ state.snapshotCleaned ||
183
+ generation !== state.snapshotRenewalGeneration
184
+ )
185
+ return
186
+ const renewal = Promise.resolve().then(async (): Promise<void> => {
187
+ await lease.renew()
188
+ })
189
+ state.snapshotRenewTask = renewal
190
+ try {
191
+ await renewal
192
+ } catch (error) {
193
+ state.snapshotLost =
194
+ error instanceof Error ? error : new Error(String(error))
195
+ } finally {
196
+ if (state.snapshotRenewTask === renewal)
197
+ state.snapshotRenewTask = undefined
198
+ }
199
+ if (state.snapshotLost) await stopSnapshotLease(state).catch(() => {})
200
+ else if (
201
+ !state.snapshotCleaned &&
202
+ generation === state.snapshotRenewalGeneration
203
+ )
204
+ schedule()
205
+ })()
206
+ }, lease.renewAfterMs)
207
+ }
208
+ schedule()
209
+ }
210
+
117
211
  /**
118
212
  * Stop the watcher and drain any in-flight `diff()` promises before teardown,
119
213
  * so the final file's diff isn't dropped when a run finishes/aborts/errors
@@ -122,7 +216,7 @@ const runState = new WeakMap<object, SandboxRunState>()
122
216
  */
123
217
  async function drainWatcher(
124
218
  state: SandboxRunState,
125
- phase: 'finish' | 'abort' | 'error',
219
+ phase: 'finish' | 'pause' | 'abort' | 'error',
126
220
  ): Promise<void> {
127
221
  // Guard `stop()`: a rejecting watcher teardown must NOT propagate out of
128
222
  // here, or the caller skips the `definition.destroy(...)` that follows —
@@ -136,6 +230,18 @@ async function drainWatcher(
136
230
  if (state.watcher) state.logger?.sandbox('sandbox watcher stopped', { phase })
137
231
  }
138
232
 
233
+ function canPublishPortableSnapshot(
234
+ state: SandboxRunState,
235
+ lease: SandboxCheckpointWriterLease,
236
+ ): boolean {
237
+ if (state.snapshotLost) throw state.snapshotLost
238
+ return (
239
+ !state.snapshotClosed &&
240
+ !state.snapshotCleaned &&
241
+ state.snapshotLease === lease
242
+ )
243
+ }
244
+
139
245
  /**
140
246
  * Record the two facts a later attach and the reaper both need, then publish the
141
247
  * detach verdict core reads.
@@ -232,6 +338,38 @@ function tenantFrom(
232
338
  * single process but NOT across replicas.
233
339
  */
234
340
  export interface SandboxMiddlewareOptions<TOffset extends string = string> {
341
+ snapshots?: {
342
+ persistence: {
343
+ stores: {
344
+ messages: {
345
+ loadThread: (threadId: string) => Promise<ReadonlyArray<ModelMessage>>
346
+ }
347
+ artifacts: {
348
+ listForThread: (threadId: string) => Promise<
349
+ ReadonlyArray<{
350
+ artifactId: string
351
+ runId: string
352
+ threadId: string
353
+ blobKey?: string
354
+ name: string
355
+ mimeType: string
356
+ size: number
357
+ createdAt: number
358
+ }>
359
+ >
360
+ }
361
+ blobs: {
362
+ get: (key: string) => Promise<{
363
+ arrayBuffer: () => Promise<ArrayBuffer>
364
+ } | null>
365
+ head: (key: string) => Promise<unknown>
366
+ put: (key: string, body: Uint8Array) => Promise<unknown>
367
+ }
368
+ }
369
+ }
370
+ checkpoints: SandboxCheckpointStore
371
+ policy?: SandboxSnapshotPolicy
372
+ }
235
373
  /**
236
374
  * Durable instance map (which provider sandbox to resume for a key). Pass
237
375
  * your own store to make resume survive across processes/replicas.
@@ -355,6 +493,88 @@ export function withSandbox<TOffset extends string = string>(
355
493
 
356
494
  async setup(ctx) {
357
495
  const ensureCtx = buildEnsureCtx(ctx, options)
496
+ const snapshotConfig = options?.snapshots
497
+ const snapshotWorkspaceHash = definition.workspace
498
+ ? computeWorkspaceHash(definition.workspace)
499
+ : undefined
500
+ const snapshotPolicy = snapshotConfig
501
+ ? resolveSandboxSnapshotPolicy(
502
+ snapshotConfig.policy,
503
+ snapshotWorkspaceHash,
504
+ )
505
+ : undefined
506
+ let snapshotRuntime:
507
+ | {
508
+ persistence: {
509
+ stores: {
510
+ messages: {
511
+ loadThread: (
512
+ id: string,
513
+ ) => Promise<ReadonlyArray<ModelMessage>>
514
+ }
515
+ artifacts: {
516
+ listForThread: (id: string) => Promise<
517
+ ReadonlyArray<{
518
+ artifactId: string
519
+ runId: string
520
+ threadId: string
521
+ blobKey?: string
522
+ name: string
523
+ mimeType: string
524
+ size: number
525
+ createdAt: number
526
+ }>
527
+ >
528
+ }
529
+ blobs: {
530
+ get: (key: string) => Promise<{
531
+ arrayBuffer: () => Promise<ArrayBuffer>
532
+ } | null>
533
+ head: (key: string) => Promise<unknown>
534
+ put: (key: string, body: Uint8Array) => Promise<unknown>
535
+ }
536
+ }
537
+ }
538
+ completion: { waitForRunCompletion: () => Promise<void> }
539
+ }
540
+ | undefined
541
+ let snapshotLease: SandboxCheckpointWriterLease | undefined
542
+ if (snapshotConfig) {
543
+ if (
544
+ !snapshotConfig.persistence?.stores?.messages ||
545
+ !snapshotConfig.persistence.stores.artifacts ||
546
+ !snapshotConfig.persistence.stores.blobs
547
+ )
548
+ throw new Error(
549
+ 'Sandbox snapshots require persistence stores.messages, stores.artifacts, and stores.blobs',
550
+ )
551
+ const persistenceModule = await import('@tanstack/ai-persistence')
552
+ const persistence = ctx.getOptional(
553
+ persistenceModule.PersistenceCapability,
554
+ )
555
+ if (persistence === undefined)
556
+ throw new Error(
557
+ 'Sandbox snapshots require withPersistence(snapshots.persistence) before withSandbox',
558
+ )
559
+ if (persistence !== snapshotConfig.persistence)
560
+ throw new Error(
561
+ 'Sandbox snapshots require the same persistence instance passed to withPersistence',
562
+ )
563
+ const completion = ctx.getOptional(
564
+ persistenceModule.PersistenceCompletionCapability,
565
+ )
566
+ if (!completion)
567
+ throw new Error(
568
+ 'Sandbox snapshots require withPersistence before withSandbox',
569
+ )
570
+ snapshotRuntime = {
571
+ persistence: snapshotConfig.persistence,
572
+ completion,
573
+ }
574
+ snapshotLease = await snapshotConfig.checkpoints.acquireWriter(
575
+ ctx.threadId,
576
+ )
577
+ }
358
578
 
359
579
  // Resolving here (not lazily on the abort path) is what keeps `setup` and
360
580
  // `onAbort` on one verdict: the payload the bus carries is the same object
@@ -399,12 +619,17 @@ export function withSandbox<TOffset extends string = string>(
399
619
  // sees the most complete state that exists at the moment it runs.
400
620
  const state: SandboxRunState = {
401
621
  ensureCtx,
622
+ snapshotRenewalGeneration: 0,
402
623
  pendingDiffs: [],
403
624
  toolHistory: createToolHistoryRecorder(),
404
625
  ...(logger ? { logger } : {}),
405
626
  ...(durability ? { durability } : {}),
406
627
  }
407
628
  runState.set(ctx, state)
629
+ if (snapshotLease) {
630
+ state.snapshotLease = snapshotLease
631
+ startSnapshotRenewal(state)
632
+ }
408
633
 
409
634
  // MAKE THE RUN FINDABLE BEFORE `ensure`, not after the run finally streams.
410
635
  //
@@ -487,6 +712,10 @@ export function withSandbox<TOffset extends string = string>(
487
712
  // because an agent that never ran wrote no journal to replay.
488
713
  if (durability !== undefined && durability.detachOnDisconnect) {
489
714
  getRunDisconnect(ctx, { optional: true })?.subscribe(async () => {
715
+ const snapshotStop = stopSnapshotLease(state, {
716
+ closePortable: true,
717
+ })
718
+ void snapshotStop.catch(() => {})
490
719
  // BOOKKEEPING ONLY — the run is still executing. Deliberately absent:
491
720
  // `drainWatcher` (would blind a live agent's file events for the whole
492
721
  // remainder) and `definition.destroy` (the run is still using the
@@ -496,164 +725,243 @@ export function withSandbox<TOffset extends string = string>(
496
725
  // A run with a cancel already recorded is left alone: that is `onAbort`'s
497
726
  // path, and stamping `detachedSince` on a deliberately-stopped run would
498
727
  // hand it to the reaper as reclaimable work.
499
- if (await cancelIntent(durability, ctx.runId, false)) return
728
+ if (await cancelIntent(durability, ctx.runId, false)) {
729
+ await snapshotStop.catch((error: unknown) => {
730
+ state.logger?.warn('sandbox snapshot writer release failed', {
731
+ runId: ctx.runId,
732
+ phase: 'disconnect',
733
+ error,
734
+ })
735
+ })
736
+ return
737
+ }
500
738
  if (
501
739
  await recordDetach(definition, state, durability, ctx, 'disconnect')
502
740
  ) {
741
+ try {
742
+ await snapshotStop
743
+ } catch (error) {
744
+ state.logger?.warn('sandbox snapshot writer release failed', {
745
+ runId: ctx.runId,
746
+ phase: 'disconnect',
747
+ error,
748
+ })
749
+ }
503
750
  state.logger?.sandbox(
504
751
  'sandbox run detached on disconnect; the run continues',
505
752
  { runId: ctx.runId },
506
753
  )
754
+ } else {
755
+ await snapshotStop.catch((error: unknown) => {
756
+ state.logger?.warn('sandbox snapshot writer release failed', {
757
+ runId: ctx.runId,
758
+ phase: 'disconnect',
759
+ error,
760
+ })
761
+ })
507
762
  }
508
763
  })
509
764
  }
510
765
 
511
- const handle = await definition.ensure(ensureCtx)
766
+ let outcome: 'resumed' | 'native-restored' | 'created' = 'created'
767
+ let handle: SandboxHandle
768
+ try {
769
+ if (snapshotConfig)
770
+ ({ handle, outcome } = await ensureSandboxWithOutcome(
771
+ definition,
772
+ ensureCtx,
773
+ ))
774
+ else handle = await definition.ensure(ensureCtx)
775
+ state.handle = handle
776
+ state.privateHandle = snapshotConfig ? outcome !== 'resumed' : true
777
+ if (snapshotConfig && outcome !== 'resumed') {
778
+ const head = await snapshotConfig.checkpoints.getHead(ctx.threadId)
779
+ if (head) {
780
+ const checkpoint = await snapshotConfig.checkpoints.get(head)
781
+ if (!checkpoint)
782
+ throw new SandboxCheckpointError(
783
+ 'SANDBOX_SNAPSHOT_CHECKPOINT_NOT_FOUND',
784
+ `Checkpoint '${head}' was not found`,
785
+ )
786
+ await restoreSandboxFiles(
787
+ handle,
788
+ {
789
+ blobs: snapshotConfig.persistence.stores.blobs,
790
+ workspaceRoot:
791
+ definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT,
792
+ },
793
+ checkpoint,
794
+ snapshotPolicy,
795
+ )
796
+ }
797
+ }
798
+ } catch (error) {
799
+ await stopSnapshotLease(state).catch(() => {})
800
+ if (state.handle && state.privateHandle)
801
+ await definition.destroy(ensureCtx).catch(() => {})
802
+ throw error
803
+ }
512
804
  // MUTATE, don't re-`set`: a disconnect that landed during `ensure` already
513
805
  // captured this object.
514
806
  state.handle = handle
515
- provideSandbox(ctx, handle)
516
- if (definition.policy) provideSandboxPolicy(ctx, definition.policy)
517
-
518
- // Deliberately placed AFTER `logger` is in scope rather than next to the
519
- // `provideSandboxDurability` call above — there is no logger to warn
520
- // through until the runtime has been read.
521
- //
522
- // `ensureCtx.locks === undefined` counts as in-memory: `defineSandbox`'s
523
- // `ensure` falls back to a process-lifetime `InMemoryLockStore` when no
524
- // lock is wired, so an unwired lock has exactly the deficiency being
525
- // warned about — it is the MOST in-memory case, not an exempt one.
526
- if (
527
- durability !== undefined &&
528
- (ensureCtx.locks === undefined ||
529
- ensureCtx.locks instanceof InMemoryLockStore)
530
- ) {
531
- logger?.warn(
532
- 'sandbox durability is wired over an InMemoryLockStore: run claims are ' +
533
- 'serialized within this process only and the lease never signals loss, ' +
534
- 'so two hosts can drive one run and duplicate its event log. Use a ' +
535
- 'distributed LockStore via withLocks for any multi-replica deploy.',
536
- { runId: ctx.runId },
537
- )
807
+ if (snapshotConfig) {
808
+ state.snapshotConfig = snapshotConfig
809
+ state.snapshotPolicy = snapshotPolicy
810
+ state.snapshotRuntime = snapshotRuntime
538
811
  }
539
-
540
- const watchRoot = definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT
541
- let baseSha = ''
542
812
  try {
543
- const shaRes = await handle.process.exec('git rev-parse HEAD', {
544
- cwd: watchRoot,
545
- })
546
- if (shaRes.exitCode === 0) {
547
- baseSha = shaRes.stdout.trim()
548
- logger?.sandbox('sandbox git baseline captured', {
549
- root: watchRoot,
550
- baseSha,
813
+ provideSandbox(ctx, handle)
814
+ if (definition.policy) provideSandboxPolicy(ctx, definition.policy)
815
+
816
+ // Deliberately placed AFTER `logger` is in scope rather than next to the
817
+ // `provideSandboxDurability` call above — there is no logger to warn
818
+ // through until the runtime has been read.
819
+ //
820
+ // `ensureCtx.locks === undefined` counts as in-memory: `defineSandbox`'s
821
+ // `ensure` falls back to a process-lifetime `InMemoryLockStore` when no
822
+ // lock is wired, so an unwired lock has exactly the deficiency being
823
+ // warned about — it is the MOST in-memory case, not an exempt one.
824
+ if (
825
+ durability !== undefined &&
826
+ (ensureCtx.locks === undefined ||
827
+ ensureCtx.locks instanceof InMemoryLockStore)
828
+ ) {
829
+ logger?.warn(
830
+ 'sandbox durability is wired over an InMemoryLockStore: run claims are ' +
831
+ 'serialized within this process only and the lease never signals loss, ' +
832
+ 'so two hosts can drive one run and duplicate its event log. Use a ' +
833
+ 'distributed LockStore via withLocks for any multi-replica deploy.',
834
+ { runId: ctx.runId },
835
+ )
836
+ }
837
+
838
+ const watchRoot = definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT
839
+ let baseSha = ''
840
+ try {
841
+ const shaRes = await handle.process.exec('git rev-parse HEAD', {
842
+ cwd: watchRoot,
551
843
  })
552
- } else {
553
- // Non-zero exit: either not a git repository (non-git workspace) or a
554
- // repo with no commits (no HEAD). Expected, but it silently degrades
555
- // every subsequent diff to a full-file add-patch, so surface it
556
- // under `sandbox` (with stderr) rather than leaving nothing to grep.
557
- logger?.sandbox('sandbox git baseline unavailable (non-zero exit)', {
844
+ if (shaRes.exitCode === 0) {
845
+ baseSha = shaRes.stdout.trim()
846
+ logger?.sandbox('sandbox git baseline captured', {
847
+ root: watchRoot,
848
+ baseSha,
849
+ })
850
+ } else {
851
+ // Non-zero exit: either not a git repository (non-git workspace) or a
852
+ // repo with no commits (no HEAD). Expected, but it silently degrades
853
+ // every subsequent diff to a full-file add-patch, so surface it
854
+ // under `sandbox` (with stderr) rather than leaving nothing to grep.
855
+ logger?.sandbox(
856
+ 'sandbox git baseline unavailable (non-zero exit)',
857
+ {
858
+ root: watchRoot,
859
+ exitCode: shaRes.exitCode,
860
+ stderr: shaRes.stderr,
861
+ },
862
+ )
863
+ }
864
+ } catch (error) {
865
+ // exec rejected (git not on PATH, exec seam broken) → baseSha stays ''
866
+ // and accessors fall back, but this is a real anomaly, not a plain
867
+ // non-git workspace, so warn.
868
+ logger?.warn('sandbox git baseline capture failed', {
558
869
  root: watchRoot,
559
- exitCode: shaRes.exitCode,
560
- stderr: shaRes.stderr,
870
+ error,
561
871
  })
562
872
  }
563
- } catch (error) {
564
- // exec rejected (git not on PATH, exec seam broken) → baseSha stays ''
565
- // and accessors fall back, but this is a real anomaly, not a plain
566
- // non-git workspace, so warn.
567
- logger?.warn('sandbox git baseline capture failed', {
568
- root: watchRoot,
569
- error,
570
- })
571
- }
572
873
 
573
- const workspace = definition.workspace
574
- if (workspace !== undefined) {
575
- const virtualRoot = workspace.root ?? DEFAULT_WORKSPACE_ROOT
576
- const root = resolveHarnessCwd(handle, virtualRoot)
577
- const workspaceHash = computeWorkspaceHash(workspace)
578
- const secrets = workspace.secrets
579
- provideWorkspaceProjection(ctx, {
580
- skills: workspace.skills ?? [],
581
- plugins: workspace.plugins ?? [],
582
- resolveSecret: (ref) => {
583
- if (secrets === undefined) {
584
- throw new Error(
585
- `resolveSecret: no secrets defined on this workspace (ref: "${ref.__secretName}")`,
586
- )
587
- }
588
- return resolveSecret(secrets, ref)
589
- },
590
- markerPath: `${root}/.tanstack-projected-${workspaceHash}`,
591
- root,
592
- ...(workspace.scripts !== undefined
593
- ? { scripts: workspace.scripts }
594
- : {}),
595
- })
596
- }
874
+ const workspace = definition.workspace
875
+ if (workspace !== undefined) {
876
+ const virtualRoot = workspace.root ?? DEFAULT_WORKSPACE_ROOT
877
+ const root = resolveHarnessCwd(handle, virtualRoot)
878
+ const workspaceHash = computeWorkspaceHash(workspace)
879
+ const secrets = workspace.secrets
880
+ provideWorkspaceProjection(ctx, {
881
+ skills: workspace.skills ?? [],
882
+ plugins: workspace.plugins ?? [],
883
+ resolveSecret: (ref) => {
884
+ if (secrets === undefined) {
885
+ throw new Error(
886
+ `resolveSecret: no secrets defined on this workspace (ref: "${ref.__secretName}")`,
887
+ )
888
+ }
889
+ return resolveSecret(secrets, ref)
890
+ },
891
+ markerPath: `${root}/.tanstack-projected-${workspaceHash}`,
892
+ root,
893
+ ...(workspace.scripts !== undefined
894
+ ? { scripts: workspace.scripts }
895
+ : {}),
896
+ })
897
+ }
597
898
 
598
- const hooks = definition.hooks
599
- await hooks?.onReady?.(handle)
600
-
601
- const fe = resolveFileEvents(definition.fileEvents)
602
- // THE SAME array the run state already holds, not a fresh one. The watcher
603
- // callback below closes over this reference, and `drainWatcher` awaits
604
- // `state.pendingDiffs` — a second array would silently drop every in-flight
605
- // diff from the teardown drain.
606
- const pendingDiffs = state.pendingDiffs
607
- let watcher: SandboxWatchHandle | undefined
608
- if (fe.enabled) {
609
- watcher = await watchWorkspace(handle, {
610
- onEvent: (event: SandboxFileEvent) => {
611
- const enriched = buildFileHookEvent(
612
- handle,
613
- watchRoot,
614
- baseSha,
615
- event,
616
- logger,
617
- )
618
- void dispatchDefinitionHooks(hooks, enriched, logger)
619
- runtime?.emit(enriched)
620
- if (fe.diff) {
621
- pendingDiffs.push(
622
- enriched
623
- .diff()
624
- .then((diff) => {
625
- runtime?.emitFileDiff({ path: event.path, diff })
626
- })
627
- .catch((error: unknown) => {
628
- logger?.warn('sandbox file diff emit failed', {
629
- path: event.path,
630
- error,
631
- })
632
- }),
899
+ const hooks = definition.hooks
900
+ await hooks?.onReady?.(handle)
901
+
902
+ const fe = resolveFileEvents(definition.fileEvents)
903
+ // THE SAME array the run state already holds, not a fresh one. The watcher
904
+ // callback below closes over this reference, and `drainWatcher` awaits
905
+ // `state.pendingDiffs` — a second array would silently drop every in-flight
906
+ // diff from the teardown drain.
907
+ const pendingDiffs = state.pendingDiffs
908
+ let watcher: SandboxWatchHandle | undefined
909
+ if (fe.enabled) {
910
+ watcher = await watchWorkspace(handle, {
911
+ onEvent: (event: SandboxFileEvent) => {
912
+ const enriched = buildFileHookEvent(
913
+ handle,
914
+ watchRoot,
915
+ baseSha,
916
+ event,
917
+ logger,
633
918
  )
634
- }
635
- },
636
- // Watch the SAME root the enrichment layer relativizes against
637
- // (`buildFileHookEvent(handle, watchRoot, …)` and the `baseSha`
638
- // capture). Without this the watcher defaults to `/workspace` while
639
- // enrichment uses `watchRoot`, so a custom `workspace.root` makes the
640
- // two look at different directories and git pathspecs break.
641
- root: watchRoot,
642
- ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
643
- ...(logger !== undefined ? { logger } : {}),
644
- })
645
- logger?.sandbox('sandbox watcher started', {
646
- root: watchRoot,
647
- diff: fe.diff,
648
- })
649
- }
919
+ void dispatchDefinitionHooks(hooks, enriched, logger)
920
+ runtime?.emit(enriched)
921
+ if (fe.diff) {
922
+ pendingDiffs.push(
923
+ enriched
924
+ .diff()
925
+ .then((diff) => {
926
+ runtime?.emitFileDiff({ path: event.path, diff })
927
+ })
928
+ .catch((error: unknown) => {
929
+ logger?.warn('sandbox file diff emit failed', {
930
+ path: event.path,
931
+ error,
932
+ })
933
+ }),
934
+ )
935
+ }
936
+ },
937
+ // Watch the SAME root the enrichment layer relativizes against
938
+ // (`buildFileHookEvent(handle, watchRoot, …)` and the `baseSha`
939
+ // capture). Without this the watcher defaults to `/workspace` while
940
+ // enrichment uses `watchRoot`, so a custom `workspace.root` makes the
941
+ // two look at different directories and git pathspecs break.
942
+ root: watchRoot,
943
+ ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
944
+ ...(logger !== undefined ? { logger } : {}),
945
+ })
946
+ logger?.sandbox('sandbox watcher started', {
947
+ root: watchRoot,
948
+ diff: fe.diff,
949
+ })
950
+ }
650
951
 
651
- // MUTATE the object registered above rather than `set`-ing a second one: an
652
- // abort that landed mid-setup already captured a reference to it (and may
653
- // already be draining `pendingDiffs`), so replacing the entry would hand the
654
- // teardown path a different object than the watcher writes into.
655
- // `pendingDiffs` needs no copying — it IS `state.pendingDiffs`.
656
- if (watcher) state.watcher = watcher
952
+ // MUTATE the object registered above rather than `set`-ing a second one: an
953
+ // abort that landed mid-setup already captured a reference to it (and may
954
+ // already be draining `pendingDiffs`), so replacing the entry would hand the
955
+ // teardown path a different object than the watcher writes into.
956
+ // `pendingDiffs` needs no copying — it IS `state.pendingDiffs`.
957
+ if (watcher) state.watcher = watcher
958
+ } catch (error) {
959
+ await drainWatcher(state, 'error')
960
+ await stopSnapshotLease(state).catch(() => {})
961
+ if (state.privateHandle)
962
+ await definition.destroy(ensureCtx).catch(() => {})
963
+ throw error
964
+ }
657
965
  },
658
966
 
659
967
  // Keep the recorded tool history OUT of the request to the model. It is stored
@@ -678,8 +986,17 @@ export function withSandbox<TOffset extends string = string>(
678
986
 
679
987
  // Record the harness's own tool calls as transcript messages. Observe only:
680
988
  // returning nothing passes every chunk through untouched.
681
- onChunk(ctx, chunk) {
682
- runState.get(ctx)?.toolHistory.observe(chunk, ctx)
989
+ async onChunk(ctx, chunk) {
990
+ const state = runState.get(ctx)
991
+ state?.toolHistory.observe(chunk, ctx)
992
+ if (
993
+ state &&
994
+ chunk.type === 'RUN_FINISHED' &&
995
+ chunk.outcome?.type === 'interrupt'
996
+ ) {
997
+ await drainWatcher(state, 'pause')
998
+ await stopSnapshotLease(state, { closePortable: true })
999
+ }
683
1000
  },
684
1001
 
685
1002
  async onFinish(ctx) {
@@ -694,34 +1011,151 @@ export function withSandbox<TOffset extends string = string>(
694
1011
 
695
1012
  await drainWatcher(state, 'finish')
696
1013
 
697
- const lifecycle = definition.lifecycle
1014
+ let primaryError: unknown
1015
+ try {
1016
+ const snapshotCaptureTask = Promise.resolve().then(
1017
+ async (): Promise<void> => {
1018
+ const config = state.snapshotConfig
1019
+ const runtime = state.snapshotRuntime
1020
+ const lease = state.snapshotLease
1021
+ if (!config || !runtime || !handle || !lease) {
1022
+ if (state.snapshotLost) throw state.snapshotLost
1023
+ return
1024
+ }
1025
+ if (!canPublishPortableSnapshot(state, lease)) return
698
1026
 
699
- // `handle` is absent only if `setup` never got past `definition.ensure`, in
700
- // which case there is no sandbox to snapshot.
701
- if (
702
- lifecycle?.snapshot === 'after-run' &&
703
- handle?.capabilities.snapshots &&
704
- handle.snapshot
705
- ) {
706
- const snapshot = await handle.snapshot(`after-run-${ctx.runId}`)
707
- const store = ensureCtx.store
708
- if (store) {
709
- const key = definition.key(ensureCtx)
710
- const existing = await store.get(key)
711
- if (existing) {
712
- await store.upsert({
713
- ...existing,
714
- latestSnapshotId: snapshot.id,
715
- updatedAt: Date.now(),
716
- })
1027
+ await runtime.completion.waitForRunCompletion()
1028
+ if (!canPublishPortableSnapshot(state, lease)) return
1029
+
1030
+ const conversation =
1031
+ await runtime.persistence.stores.messages.loadThread(ctx.threadId)
1032
+ if (!canPublishPortableSnapshot(state, lease)) return
1033
+
1034
+ const files = await captureSandboxFiles(
1035
+ handle,
1036
+ {
1037
+ blobs: config.persistence.stores.blobs,
1038
+ workspaceRoot:
1039
+ definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT,
1040
+ },
1041
+ state.snapshotPolicy,
1042
+ definition.workspace?.secrets !== undefined
1043
+ ? resolveAllSecrets(definition.workspace.secrets)
1044
+ : {},
1045
+ )
1046
+ if (!canPublishPortableSnapshot(state, lease)) return
1047
+
1048
+ const artifacts = await captureSandboxArtifacts(
1049
+ {
1050
+ blobs: config.persistence.stores.blobs,
1051
+ artifacts: config.persistence.stores.artifacts,
1052
+ },
1053
+ ctx.threadId,
1054
+ definition.workspace?.secrets !== undefined
1055
+ ? resolveAllSecrets(definition.workspace.secrets)
1056
+ : {},
1057
+ )
1058
+ if (!canPublishPortableSnapshot(state, lease)) return
1059
+
1060
+ const parentCheckpointId = await config.checkpoints.getHead(
1061
+ ctx.threadId,
1062
+ )
1063
+ if (!canPublishPortableSnapshot(state, lease)) return
1064
+
1065
+ try {
1066
+ await config.checkpoints.append({
1067
+ checkpoint: {
1068
+ id: `checkpoint-${ctx.runId}`,
1069
+ threadId: ctx.threadId,
1070
+ parentCheckpointId,
1071
+ createdAt: Date.now(),
1072
+ reason: 'automatic',
1073
+ sourceRunId: ctx.runId,
1074
+ files: files.files,
1075
+ conversation,
1076
+ artifacts,
1077
+ },
1078
+ expectedHeadId: parentCheckpointId,
1079
+ writer: lease,
1080
+ })
1081
+ } catch (error) {
1082
+ if (state.snapshotLost) throw state.snapshotLost
1083
+ throw error
1084
+ }
1085
+ if (state.snapshotLost) throw state.snapshotLost
1086
+ },
1087
+ )
1088
+ state.snapshotCaptureTask = snapshotCaptureTask
1089
+ try {
1090
+ await snapshotCaptureTask
1091
+ } finally {
1092
+ if (state.snapshotCaptureTask === snapshotCaptureTask)
1093
+ state.snapshotCaptureTask = undefined
1094
+ }
1095
+
1096
+ const lifecycle = definition.lifecycle
1097
+
1098
+ // `handle` is absent only if `setup` never got past `definition.ensure`, in
1099
+ // which case there is no sandbox to snapshot.
1100
+ if (
1101
+ lifecycle?.snapshot === 'after-run' &&
1102
+ handle?.capabilities.snapshots &&
1103
+ handle.snapshot
1104
+ ) {
1105
+ const snapshot = await handle.snapshot(`after-run-${ctx.runId}`)
1106
+ const store = ensureCtx.store
1107
+ if (store) {
1108
+ const key = definition.key(ensureCtx)
1109
+ const existing = await store.get(key)
1110
+ if (existing) {
1111
+ await store.upsert({
1112
+ ...existing,
1113
+ latestSnapshotId: snapshot.id,
1114
+ updatedAt: Date.now(),
1115
+ })
1116
+ }
1117
+ }
1118
+ }
1119
+
1120
+ if (lifecycle?.destroyOnComplete) {
1121
+ await definition.destroy(ensureCtx)
1122
+ await definition.hooks?.onDestroy?.()
1123
+ }
1124
+ } catch (error) {
1125
+ primaryError = error
1126
+ if (definition.lifecycle?.destroyOnComplete) {
1127
+ try {
1128
+ await definition.destroy(ensureCtx)
1129
+ await definition.hooks?.onDestroy?.()
1130
+ } catch (cleanupError) {
1131
+ state.logger?.warn(
1132
+ 'sandbox destroy after terminal failure failed',
1133
+ {
1134
+ runId: ctx.runId,
1135
+ phase: 'finish',
1136
+ error: cleanupError,
1137
+ },
1138
+ )
717
1139
  }
718
1140
  }
719
1141
  }
720
1142
 
721
- if (lifecycle?.destroyOnComplete) {
722
- await definition.destroy(ensureCtx)
723
- await definition.hooks?.onDestroy?.()
1143
+ let snapshotCleanupError: unknown
1144
+ try {
1145
+ await stopSnapshotLease(state, { closePortable: true })
1146
+ } catch (error) {
1147
+ snapshotCleanupError = error
724
1148
  }
1149
+ if (primaryError !== undefined) {
1150
+ if (snapshotCleanupError !== undefined)
1151
+ state.logger?.warn('sandbox snapshot writer release failed', {
1152
+ runId: ctx.runId,
1153
+ phase: 'finish',
1154
+ error: snapshotCleanupError,
1155
+ })
1156
+ throw primaryError
1157
+ }
1158
+ if (snapshotCleanupError !== undefined) throw snapshotCleanupError
725
1159
  },
726
1160
 
727
1161
  async onAbort(ctx, info: AbortInfo) {
@@ -732,6 +1166,12 @@ export function withSandbox<TOffset extends string = string>(
732
1166
  // the sandbox is about to be destroyed or merely detached, or the final
733
1167
  // file's diff is dropped.
734
1168
  await drainWatcher(state, 'abort')
1169
+ let releaseError: unknown
1170
+ try {
1171
+ await stopSnapshotLease(state, { closePortable: true })
1172
+ } catch (error) {
1173
+ releaseError = error
1174
+ }
735
1175
 
736
1176
  const durability = state.durability
737
1177
  const cancelled = await cancelIntent(
@@ -760,10 +1200,12 @@ export function withSandbox<TOffset extends string = string>(
760
1200
  // unreachable one — the same reasoning `drainWatcher` applies to its own
761
1201
  // guarded `stop()`.
762
1202
  if (await recordDetach(definition, state, durability, ctx, 'abort')) {
1203
+ if (releaseError) throw releaseError
763
1204
  return
764
1205
  }
765
1206
  await definition.destroy(state.ensureCtx)
766
1207
  await definition.hooks?.onDestroy?.()
1208
+ if (releaseError) throw releaseError
767
1209
  return
768
1210
  }
769
1211
 
@@ -775,6 +1217,7 @@ export function withSandbox<TOffset extends string = string>(
775
1217
  // `destroyOnComplete:false` governs *successful completion*, never cancel.
776
1218
  await definition.destroy(state.ensureCtx)
777
1219
  await definition.hooks?.onDestroy?.()
1220
+ if (releaseError) throw releaseError
778
1221
  },
779
1222
 
780
1223
  async onError(ctx, info) {
@@ -782,6 +1225,12 @@ export function withSandbox<TOffset extends string = string>(
782
1225
  if (!state) return
783
1226
 
784
1227
  await drainWatcher(state, 'error')
1228
+ let releaseError: unknown
1229
+ try {
1230
+ await stopSnapshotLease(state)
1231
+ } catch (error) {
1232
+ releaseError = error
1233
+ }
785
1234
  await definition.hooks?.onError?.(info.error)
786
1235
 
787
1236
  // On failure, only tear down when the lifecycle says so; otherwise leave
@@ -790,6 +1239,7 @@ export function withSandbox<TOffset extends string = string>(
790
1239
  await definition.destroy(state.ensureCtx)
791
1240
  await definition.hooks?.onDestroy?.()
792
1241
  }
1242
+ if (releaseError) throw releaseError
793
1243
  },
794
1244
  })
795
1245
  }