Skip to content

Commit 01b3b77

Browse files
icecrasher321claude
andcommitted
fix(billing): keep the charge on completed runs that fail after execution
Three paths dropped cost the sandbox had already earned. A harvest that cannot return what the run produced — more files than the export limit, nesting past the listing depth, or an output directory the code deleted — was excluded from the billable-error set. All three arrive only after the sandbox has executed and all three are the caller's to fix, so they belong with the post-completion export failures the policy already bills rather than the provider failures it absorbs. A completed run whose code wrote one file too many went free. That also left the route with nothing to read: it already consults readTrustedSandboxOutputCost for these errors, so attaching the cost at the sandbox layer is what carries it into the response. Separately, a Function block whose handler succeeded could still fail in the steps that follow it — base64 hydration, and large-value redaction that throws rather than emit unredacted data. Those errors carry no cost of their own, so the completed sandbox went unbilled. The handler's cost is now held across that window, in the same way streamingPartialOutput already is, and used only when the error has none. The new conformance case was confirmed to fail against the narrower catch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1065097 commit 01b3b77

3 files changed

Lines changed: 74 additions & 5 deletions

File tree

apps/sim/executor/execution/block-executor.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,17 @@ export class BlockExecutor {
249249
cleanupSelfReference?.()
250250

251251
let streamingPartialOutput: Record<string, any> | undefined
252+
/**
253+
* Cost of a handler that already finished, kept for the catch below.
254+
*
255+
* A Function block's sandbox is paid for the moment it completes, but the
256+
* steps after the handler returns — base64 hydration, and large-value
257+
* redaction that deliberately throws rather than emit unredacted data — can
258+
* still fail the block. The error those raise carries no cost of its own, so
259+
* without holding it here the completed sandbox would go unbilled. Hoisted
260+
* for the same reason `streamingPartialOutput` above is.
261+
*/
262+
let completedHandlerCost: TrustedExecutionCost | undefined
252263
try {
253264
/**
254265
* Only the handler call is retried. A streaming handler returns before any
@@ -261,6 +272,8 @@ export class BlockExecutor {
261272
: handler.execute(blockCtx, block, resolvedInputs, nodeMetadata)
262273
)
263274

275+
completedHandlerCost = readTrustedExecutionCost(output)
276+
264277
const isStreamingExecution =
265278
output && typeof output === 'object' && 'stream' in output && 'execution' in output
266279

@@ -436,7 +449,8 @@ export class BlockExecutor {
436449
inputDisplayRegistry,
437450
isSentinel,
438451
'execution',
439-
streamingPartialOutput
452+
streamingPartialOutput,
453+
completedHandlerCost
440454
)
441455
} finally {
442456
commitBlockRegistry()
@@ -596,7 +610,8 @@ export class BlockExecutor {
596610
inputDisplayRegistry: ResolvedSecretTraceRegistry | undefined,
597611
isSentinel: boolean,
598612
phase: 'input_resolution' | 'execution',
599-
streamingPartialOutput?: Record<string, any>
613+
streamingPartialOutput?: Record<string, any>,
614+
completedHandlerCost?: TrustedExecutionCost
600615
): Promise<NormalizedBlockOutput> {
601616
const endedAt = new Date().toISOString()
602617
const duration = performance.now() - startTime
@@ -668,7 +683,7 @@ export class BlockExecutor {
668683
return softOutput
669684
}
670685

671-
const trustedExecutionCost = readTrustedExecutionCost(error)
686+
const trustedExecutionCost = readTrustedExecutionCost(error) ?? completedHandlerCost
672687
const errorOutput: NormalizedBlockOutput = {
673688
error: errorMessage,
674689
...(trustedExecutionCost ? { cost: trustedExecutionCost } : {}),

apps/sim/lib/execution/remote-sandbox/conformance.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ import {
134134
} from '@/lib/execution/remote-sandbox/e2b'
135135
import {
136136
MAX_SANDBOX_OUTPUT_BYTES,
137+
MAX_SANDBOX_OUTPUT_FILES,
137138
MAX_SANDBOX_PROCESS_OUTPUT_BYTES,
138139
MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES,
139140
readTrustedSandboxOutputCost,
@@ -713,6 +714,34 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => {
713714
}
714715
}
715716

717+
it('bills a completed run whose harvest produced more files than it can export', async () => {
718+
// The sandbox executed and was paid for; the refusal is about what the code
719+
// wrote, so it belongs with the post-completion export failures rather than
720+
// the provider failures the policy absorbs.
721+
stubCodeRun(provider, `${SIM_RESULT_PREFIX}null`)
722+
stubOutputDirListing(
723+
Array.from({ length: MAX_SANDBOX_OUTPUT_FILES + 1 }, (_, index) => ({
724+
path: `/tmp/sim/outputs/file-${index}.txt`,
725+
size: 1,
726+
}))
727+
)
728+
729+
const error = await executeInSandbox({
730+
code: 'x',
731+
language: CodeLanguage.Python,
732+
timeoutMs: 1000,
733+
outputSandboxDir: '/tmp/sim/outputs',
734+
meterUsage: true,
735+
}).catch((error: unknown) => error)
736+
737+
expect(error).toMatchObject({ code: 'sandbox_output_not_exportable' })
738+
expect(readTrustedSandboxOutputCost(error)).toEqual({
739+
input: 0,
740+
output: 0,
741+
total: expect.any(Number),
742+
})
743+
})
744+
716745
it('creates the output directory before user code runs', async () => {
717746
stubCodeRun(provider, `__SIM_RESULT__=${JSON.stringify('done')}`)
718747
stubOutputDirListing([])

apps/sim/lib/execution/remote-sandbox/index.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
attachTrustedSandboxOutputCost,
1919
isSandboxOutputFileError,
2020
isSandboxOutputLimitError,
21+
isSandboxOutputNotExportableError,
2122
MAX_SANDBOX_OUTPUT_BYTES,
2223
MAX_SANDBOX_OUTPUT_FILES,
2324
MAX_SANDBOX_PROCESS_OUTPUT_BYTES,
@@ -919,7 +920,19 @@ async function executeInSandboxWithinBudget(
919920
billableResult.exportedFiles = exportedFiles
920921
billableResult.collectedFiles = collectedFiles
921922
} catch (error) {
922-
if (isSandboxOutputLimitError(error) || isSandboxOutputFileError(error)) {
923+
/*
924+
* A harvest that cannot return what the run produced — too many files, too
925+
* deep, or an output directory the code deleted — is the caller's to fix
926+
* and arrives only after the sandbox has already executed. It belongs with
927+
* the other post-completion export failures the policy bills, not with the
928+
* provider failures it absorbs; leaving it out let a completed run whose
929+
* code wrote one file too many go free.
930+
*/
931+
if (
932+
isSandboxOutputLimitError(error) ||
933+
isSandboxOutputFileError(error) ||
934+
isSandboxOutputNotExportableError(error)
935+
) {
923936
billableOutputError = error
924937
}
925938
throw error
@@ -1050,7 +1063,19 @@ async function executeShellInSandboxWithinBudget(
10501063
billableResult.exportedFiles = exportedFiles
10511064
billableResult.collectedFiles = collectedFiles
10521065
} catch (error) {
1053-
if (isSandboxOutputLimitError(error) || isSandboxOutputFileError(error)) {
1066+
/*
1067+
* A harvest that cannot return what the run produced — too many files, too
1068+
* deep, or an output directory the code deleted — is the caller's to fix
1069+
* and arrives only after the sandbox has already executed. It belongs with
1070+
* the other post-completion export failures the policy bills, not with the
1071+
* provider failures it absorbs; leaving it out let a completed run whose
1072+
* code wrote one file too many go free.
1073+
*/
1074+
if (
1075+
isSandboxOutputLimitError(error) ||
1076+
isSandboxOutputFileError(error) ||
1077+
isSandboxOutputNotExportableError(error)
1078+
) {
10541079
billableOutputError = error
10551080
}
10561081
throw error

0 commit comments

Comments
 (0)