@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.
- package/README.md +26 -0
- package/dist/esm/checkpoint-store.d.ts +147 -0
- package/dist/esm/checkpoint-store.js +267 -0
- package/dist/esm/checkpoint-store.js.map +1 -0
- package/dist/esm/contracts.d.ts +19 -0
- package/dist/esm/index.d.ts +11 -1
- package/dist/esm/index.js +12 -7
- package/dist/esm/memory-snapshot-types.d.ts +129 -0
- package/dist/esm/memory-snapshots.d.ts +6 -0
- package/dist/esm/memory-snapshots.js +490 -0
- package/dist/esm/memory-snapshots.js.map +1 -0
- package/dist/esm/middleware.d.ts +33 -1
- package/dist/esm/middleware.js +339 -94
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/ngrok.d.ts +1 -1
- package/dist/esm/sandbox.d.ts +16 -0
- package/dist/esm/sandbox.js +62 -9
- package/dist/esm/sandbox.js.map +1 -1
- package/dist/esm/snapshot-operations.d.ts +65 -0
- package/dist/esm/snapshot-operations.js +317 -0
- package/dist/esm/snapshot-operations.js.map +1 -0
- package/dist/esm/snapshot-tools.d.ts +185 -0
- package/dist/esm/snapshot-tools.js +160 -0
- package/dist/esm/snapshot-tools.js.map +1 -0
- package/dist/esm/snapshots.d.ts +51 -0
- package/dist/esm/snapshots.js +350 -0
- package/dist/esm/snapshots.js.map +1 -0
- package/dist/esm/testkit/checkpoint-conformance.d.ts +2 -0
- package/dist/esm/testkit/checkpoint-conformance.js +453 -0
- package/dist/esm/testkit/checkpoint-conformance.js.map +1 -0
- package/dist/esm/testkit/checkpoint-fork-conformance.d.ts +18 -0
- package/dist/esm/testkit/checkpoint-fork-conformance.js +191 -0
- package/dist/esm/testkit/checkpoint-fork-conformance.js.map +1 -0
- package/dist/esm/testkit/conformance.d.ts +4 -0
- package/dist/esm/testkit/conformance.js +3 -1
- package/dist/esm/testkit/conformance.js.map +1 -1
- package/package.json +8 -3
- package/skills/ai-sandbox/SKILL.md +96 -8
- package/src/checkpoint-store.ts +652 -0
- package/src/contracts.ts +12 -0
- package/src/index.ts +56 -0
- package/src/memory-snapshot-types.ts +167 -0
- package/src/memory-snapshots.ts +936 -0
- package/src/middleware.ts +610 -160
- package/src/sandbox.ts +107 -6
- package/src/snapshot-operations.ts +540 -0
- package/src/snapshot-tools.ts +208 -0
- package/src/snapshots.ts +711 -0
- package/src/testkit/checkpoint-conformance.ts +472 -0
- package/src/testkit/checkpoint-fork-conformance.ts +299 -0
- package/src/testkit/conformance.ts +7 -0
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))
|
|
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
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
await
|
|
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
|
}
|