From 43542e751c8bf7aaf59973af61eb1544f3073e50 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 29 Oct 2026 13:22:02 -0700 Subject: [PATCH 1/3] improvement(sandbox): pause idle chat workbenches instead of keeping them running - Session sandboxes now pause after a 5-minute idle lease (was: kept running for 20 minutes, then killed). Resume keeps files, packages and processes and takes well under a second; paused sandboxes are not billed and do not count toward the provider's concurrency limit. - A new cleanup-session-sandboxes cron kills workbenches paused for more than 15 minutes, so a workbench is still discarded about 20 minutes after its last use. Each kill runs under the session lock and re-checks the sandbox's own info, so a just-resumed workbench is never killed. - Streamed workbench writes and upload snapshots lease their full transfer budget so a machine cannot pause mid-transfer. - E2B Mothership sandboxes are priced at their template's 2 vCPU / 1 GiB instead of the 4 GiB Function profile. --- .../platform/self-hosting/background-jobs.mdx | 1 + .../cron/cleanup-session-sandboxes/route.ts | 33 +++++++++ apps/sim/lib/billing/sandbox-pricing.test.ts | 30 ++++---- apps/sim/lib/billing/sandbox-pricing.ts | 21 ++++-- .../remote-sandbox/billing.live.test.ts | 2 +- .../lib/execution/remote-sandbox/create.ts | 2 +- apps/sim/lib/execution/remote-sandbox/e2b.ts | 69 ++++++++++++++++-- .../sim/lib/execution/remote-sandbox/index.ts | 5 +- .../pi-sandbox-billing.live.test.ts | 2 +- .../session-file-snapshot.test.ts | 4 +- .../remote-sandbox/session-file-snapshot.ts | 5 +- .../remote-sandbox/session-files.test.ts | 6 +- .../execution/remote-sandbox/session-files.ts | 3 +- .../remote-sandbox/session-pause.live.test.ts | 71 +++++++++++++++++++ .../remote-sandbox/session-sandbox.test.ts | 3 +- .../lib/execution/remote-sandbox/session.ts | 65 ++++++++++++++++- .../sim/lib/execution/remote-sandbox/types.ts | 13 ++++ docker/crontab | 3 + helm/sim/values.yaml | 12 ++++ scripts/check-api-validation-contracts.ts | 1 + 20 files changed, 310 insertions(+), 41 deletions(-) create mode 100644 apps/sim/app/api/cron/cleanup-session-sandboxes/route.ts create mode 100644 apps/sim/lib/execution/remote-sandbox/session-pause.live.test.ts diff --git a/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx b/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx index 2f38820dd4f..47c8ad6eda9 100644 --- a/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx +++ b/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx @@ -188,6 +188,7 @@ Create a workflow with a Schedule trigger set to every minute, deploy it, and wa | Reconcile billing seats | `/api/cron/reconcile-billing-seats` | `0 * * * *` | Billing only — safe to disable when self-hosted | | Reconcile inbox entitlement | `/api/cron/reconcile-inbox-entitlement` | `0 3 * * *` | Inbox access reconciliation | | Cleanup sandbox images | `/api/cron/cleanup-sandbox-images` | `30 4 * * *` | Reclaims sandbox images | +| Cleanup session sandboxes | `/api/cron/cleanup-session-sandboxes` | `*/5 * * * *` | Kills chat workbenches paused past their retention window | | Idempotency key cleanup | `/api/webhooks/cleanup/idempotency` | `0 5 * * *` | Deletes deduplication records older than seven days | Subscription renewal covers Microsoft Teams chat triggers. Their Graph subscriptions last at most three days; rich notifications have a shorter limit. Gmail, Outlook, Drive, Calendar, and Sheets use the per-minute polling jobs instead. See [Microsoft Graph subscription lifetimes](https://learn.microsoft.com/en-us/graph/change-notifications-overview). SCIM reconciliation calls its authenticated endpoint directly each hour; it does not require Trigger.dev. diff --git a/apps/sim/app/api/cron/cleanup-session-sandboxes/route.ts b/apps/sim/app/api/cron/cleanup-session-sandboxes/route.ts new file mode 100644 index 00000000000..fd91e2c08f6 --- /dev/null +++ b/apps/sim/app/api/cron/cleanup-session-sandboxes/route.ts @@ -0,0 +1,33 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { reapPausedSessionSandboxes } from '@/lib/execution/remote-sandbox/session' + +export const dynamic = 'force-dynamic' +export const maxDuration = 60 + +const logger = createLogger('CleanupSessionSandboxesAPI') + +/** + * Retention sweep for paused chat workbenches. An idle workbench pauses instead of dying and the + * provider keeps it indefinitely, so this run is what bounds how long its files outlive the chat's + * last use. A no-op on providers without session sandboxes. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const authError = verifyCronAuth(request, 'session sandbox cleanup') + if (authError) return authError + + try { + const result = await reapPausedSessionSandboxes() + logger.info('Swept paused session sandboxes', result) + return NextResponse.json({ success: true, ...result }) + } catch (error) { + logger.error('Failed to sweep paused session sandboxes', { error }) + return NextResponse.json( + { error: getErrorMessage(error, 'Failed to sweep paused session sandboxes') }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/lib/billing/sandbox-pricing.test.ts b/apps/sim/lib/billing/sandbox-pricing.test.ts index 4ee43cbed67..c64230afc03 100644 --- a/apps/sim/lib/billing/sandbox-pricing.test.ts +++ b/apps/sim/lib/billing/sandbox-pricing.test.ts @@ -17,30 +17,36 @@ describe('sandbox pricing', () => { ['e2b', 0.1656], ['daytona', 0.16668], ] as const)('prices one hour of the Function profile on %s', (provider, expected) => { - const pricing = createSandboxPricing(provider, 1) + const pricing = createSandboxPricing(provider, 'code', 1) expect(priceSandboxUsage(pricing, 3_600_000, 3_600_000).rawCost).toBeCloseTo(expected, 8) }) + it('prices one hour of the Mothership workbench on its template resources, not the Function profile', () => { + const pricing = createSandboxPricing('e2b', 'mothership', 1) + + expect(priceSandboxUsage(pricing, 3_600_000, 3_600_000).rawCost).toBeCloseTo(0.117, 8) + }) + it('applies the multiplier once and rounds the final cost to eight decimals', () => { - const pricing = createSandboxPricing('e2b', 1.75) + const pricing = createSandboxPricing('e2b', 'code', 1.75) expect(priceSandboxUsage(pricing, 1234, 10_000).billedCost).toBe(0.00009934) }) it('caps duration at the provider lifetime', () => { - const pricing = createSandboxPricing('daytona', 1) + const pricing = createSandboxPricing('daytona', 'code', 1) expect(priceSandboxUsage(pricing, 90_000, 60_000).durationMs).toBe(60_000) }) it('allows a zero multiplier and rejects invalid multipliers', () => { - const freePricing = createSandboxPricing('e2b', 0) + const freePricing = createSandboxPricing('e2b', 'code', 0) expect(priceSandboxUsage(freePricing, 1000, 1000).billedCost).toBe(0) - expect(() => createSandboxPricing('e2b', -1)).toThrow('finite nonnegative') - expect(() => createSandboxPricing('e2b', Number.NaN)).toThrow('finite nonnegative') - expect(() => createSandboxPricing('e2b', Number.POSITIVE_INFINITY)).toThrow( + expect(() => createSandboxPricing('e2b', 'code', -1)).toThrow('finite nonnegative') + expect(() => createSandboxPricing('e2b', 'code', Number.NaN)).toThrow('finite nonnegative') + expect(() => createSandboxPricing('e2b', 'code', Number.POSITIVE_INFINITY)).toThrow( 'finite nonnegative' ) }) @@ -49,7 +55,7 @@ describe('sandbox pricing', () => { it('coerces the string COST_MULTIPLIER that process.env delivers', () => { setEnv({ COST_MULTIPLIER: '1.1' }) - const pricing = createSandboxPricing('e2b') + const pricing = createSandboxPricing('e2b', 'code') expect(pricing.multiplier).toBe(1.1) expect(priceSandboxUsage(pricing, 1000, 1000).billedCost).toBeCloseTo(0.0000506, 8) @@ -58,18 +64,18 @@ describe('sandbox pricing', () => { it('falls back to 1 when COST_MULTIPLIER is unset', () => { setEnv({ COST_MULTIPLIER: undefined }) - expect(createSandboxPricing('daytona').multiplier).toBe(1) + expect(createSandboxPricing('daytona', 'code').multiplier).toBe(1) }) it('falls back to 1 instead of throwing when COST_MULTIPLIER is not a nonnegative number', () => { setEnv({ COST_MULTIPLIER: 'abc' }) - expect(createSandboxPricing('e2b').multiplier).toBe(1) + expect(createSandboxPricing('e2b', 'code').multiplier).toBe(1) setEnv({ COST_MULTIPLIER: '-2' }) - expect(createSandboxPricing('e2b').multiplier).toBe(1) + expect(createSandboxPricing('e2b', 'code').multiplier).toBe(1) setEnv({ COST_MULTIPLIER: ' ' }) - expect(createSandboxPricing('e2b').multiplier).toBe(1) + expect(createSandboxPricing('e2b', 'code').multiplier).toBe(1) }) }) }) diff --git a/apps/sim/lib/billing/sandbox-pricing.ts b/apps/sim/lib/billing/sandbox-pricing.ts index 828fb83204a..6e0eb795891 100644 --- a/apps/sim/lib/billing/sandbox-pricing.ts +++ b/apps/sim/lib/billing/sandbox-pricing.ts @@ -4,7 +4,7 @@ import { FUNCTION_SANDBOX_CPU_COUNT, FUNCTION_SANDBOX_MEMORY_GB, } from '@/lib/execution/remote-sandbox/function-resources' -import type { SandboxProviderId } from '@/lib/execution/remote-sandbox/types' +import type { SandboxKind, SandboxProviderId } from '@/lib/execution/remote-sandbox/types' const E2B_CPU_USD_PER_VCPU_SECOND = 0.000014 const E2B_MEMORY_USD_PER_GIB_SECOND = 0.0000045 @@ -37,16 +37,23 @@ export interface PricedSandboxUsage { billedCost: number } -const PRICING_BY_PROVIDER: Record< - SandboxProviderId, - Pick -> = { +interface ProviderPricing extends Pick { + /** Kinds whose image is provisioned differently from the Function profile. */ + resourcesByKind?: Partial> +} + +const PRICING_BY_PROVIDER: Record = { e2b: { resources: { vcpu: FUNCTION_SANDBOX_CPU_COUNT, memoryGiB: FUNCTION_SANDBOX_MEMORY_GB, diskGiB: 0, }, + resourcesByKind: { + // The Mothership template is built without explicit resources, so E2B runs it on its + // default 2 vCPU and 1 GiB. + mothership: { vcpu: 2, memoryGiB: 1, diskGiB: 0 }, + }, rates: { cpuUsdPerVcpuSecond: E2B_CPU_USD_PER_VCPU_SECOND, memoryUsdPerGiBSecond: E2B_MEMORY_USD_PER_GIB_SECOND, @@ -69,16 +76,18 @@ const PRICING_BY_PROVIDER: Record< export function createSandboxPricing( provider: SandboxProviderId, + kind: SandboxKind, multiplier = getCostMultiplier() ): SandboxPricing { if (!Number.isFinite(multiplier) || multiplier < 0) { throw new Error('Sandbox pricing multiplier must be a finite nonnegative number') } const pricing = PRICING_BY_PROVIDER[provider] + const resources = pricing.resourcesByKind?.[kind] ?? pricing.resources return { provider, multiplier, - resources: { ...pricing.resources }, + resources: { ...resources }, rates: { ...pricing.rates }, } } diff --git a/apps/sim/lib/execution/remote-sandbox/billing.live.test.ts b/apps/sim/lib/execution/remote-sandbox/billing.live.test.ts index 4e191d0aff0..10840435b0c 100644 --- a/apps/sim/lib/execution/remote-sandbox/billing.live.test.ts +++ b/apps/sim/lib/execution/remote-sandbox/billing.live.test.ts @@ -28,7 +28,7 @@ describe.skipIf(!smokeEnabled)('sandbox billing smoke', () => { it( 'bills the sandbox lifetime at the provider rate', async () => { - const pricing = createSandboxPricing(resolveProvider().id) + const pricing = createSandboxPricing(resolveProvider().id, 'code') const usdPerSecond = pricing.resources.vcpu * pricing.rates.cpuUsdPerVcpuSecond + pricing.resources.memoryGiB * pricing.rates.memoryUsdPerGiBSecond + diff --git a/apps/sim/lib/execution/remote-sandbox/create.ts b/apps/sim/lib/execution/remote-sandbox/create.ts index bc6a2bf5681..b529464af46 100644 --- a/apps/sim/lib/execution/remote-sandbox/create.ts +++ b/apps/sim/lib/execution/remote-sandbox/create.ts @@ -34,7 +34,7 @@ export async function createSandbox( if (meterUsage && effectiveLifetimeMs === undefined) { throw new Error('Metered sandbox execution requires a provider lifetime') } - const pricing = meterUsage ? createSandboxPricing(provider.id) : undefined + const pricing = meterUsage ? createSandboxPricing(provider.id, kind) : undefined let startedAtMs = Date.now() const providerOptions = { ...options, diff --git a/apps/sim/lib/execution/remote-sandbox/e2b.ts b/apps/sim/lib/execution/remote-sandbox/e2b.ts index bc27ce5e20b..fb40976763d 100644 --- a/apps/sim/lib/execution/remote-sandbox/e2b.ts +++ b/apps/sim/lib/execution/remote-sandbox/e2b.ts @@ -1,5 +1,10 @@ import { createHash } from 'node:crypto' -import type { Sandbox as E2BSandbox, Template as E2BTemplate } from '@e2b/code-interpreter' +import type { + Sandbox as E2BSandbox, + Template as E2BTemplate, + SandboxInfo, + SandboxPaginator, +} from '@e2b/code-interpreter' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId, generateShortId } from '@sim/utils/id' @@ -65,6 +70,7 @@ import { } from '@/lib/execution/remote-sandbox/spec' import type { CreateSandboxOptions, + PausedSessionSandbox, RunCommandOptions, SandboxCodeResult, SandboxCommandResult, @@ -307,6 +313,12 @@ const E2B_SESSION_METADATA_KEY = 'simSessionKey' const E2B_SESSION_OWNERSHIP_KEY = 'simSessionOwnership' const E2B_SESSION_OWNERSHIP_VERSION = 'tracked-v1' +async function listAllSandboxes(paginator: SandboxPaginator): Promise { + const sandboxes = await paginator.nextItems() + while (paginator.hasNext) sandboxes.push(...(await paginator.nextItems())) + return sandboxes +} + async function stopSessionCommand( sandbox: E2BSandbox, processId: string, @@ -1095,6 +1107,9 @@ export const e2bProvider: SandboxProvider = { [E2B_SESSION_METADATA_KEY]: options.sessionKey, [E2B_SESSION_OWNERSHIP_KEY]: E2B_SESSION_OWNERSHIP_VERSION, }, + // An idle workbench pauses instead of dying. Only a locked reconnect may resume it, + // which is what lets the retention sweep kill it safely. + lifecycle: { onTimeout: 'pause' as const, autoResume: false }, } : {}), } @@ -1122,12 +1137,12 @@ export const e2bProvider: SandboxProvider = { const apiKey = env.E2B_API_KEY if (!apiKey) throw new Error('E2B_API_KEY is required when E2B is enabled') const { Sandbox } = await import('@e2b/code-interpreter') - const paginator = Sandbox.list({ - apiKey, - query: { metadata: { [E2B_SESSION_METADATA_KEY]: key }, state: ['running', 'paused'] }, - }) - const candidates = await paginator.nextItems() - while (paginator.hasNext) candidates.push(...(await paginator.nextItems())) + const candidates = await listAllSandboxes( + Sandbox.list({ + apiKey, + query: { metadata: { [E2B_SESSION_METADATA_KEY]: key }, state: ['running', 'paused'] }, + }) + ) if (candidates.length === 0) return null candidates.sort((a, b) => a.startedAt.getTime() - b.startedAt.getTime()) const candidate = candidates[0] @@ -1172,4 +1187,44 @@ export const e2bProvider: SandboxProvider = { options.lifetimeMs === undefined ? undefined : requestedAtMs + timeoutMs ) }, + + async listPausedSessionSandboxes(): Promise { + const apiKey = env.E2B_API_KEY + if (!apiKey) return [] + const { Sandbox } = await import('@e2b/code-interpreter') + const paused = await listAllSandboxes(Sandbox.list({ apiKey, query: { state: ['paused'] } })) + return paused.flatMap((info) => { + const sessionKey = info.metadata[E2B_SESSION_METADATA_KEY] + // A paused sandbox reports the moment it paused as its end. + return sessionKey + ? [{ sandboxId: info.sandboxId, sessionKey, pausedAtMs: info.endAt.getTime() }] + : [] + }) + }, + + async killPausedSessionSandbox( + sandbox: PausedSessionSandbox, + pausedBeforeMs: number + ): Promise { + const apiKey = env.E2B_API_KEY + if (!apiKey) throw new Error('E2B_API_KEY is required when E2B is enabled') + const { Sandbox, NotFoundError } = await import('@e2b/code-interpreter') + const requestOptions = { apiKey, requestTimeoutMs: 5_000 } + // The list can trail a resume, so only the sandbox's own info decides. + const info = await Sandbox.getInfo(sandbox.sandboxId, requestOptions).catch( + (error: unknown) => { + if (error instanceof NotFoundError) return null + throw error + } + ) + if ( + !info || + info.state !== 'paused' || + info.metadata[E2B_SESSION_METADATA_KEY] !== sandbox.sessionKey || + info.endAt.getTime() > pausedBeforeMs + ) { + return false + } + return Sandbox.kill(sandbox.sandboxId, requestOptions) + }, } diff --git a/apps/sim/lib/execution/remote-sandbox/index.ts b/apps/sim/lib/execution/remote-sandbox/index.ts index 3ddf05b05b7..abf685496c1 100644 --- a/apps/sim/lib/execution/remote-sandbox/index.ts +++ b/apps/sim/lib/execution/remote-sandbox/index.ts @@ -105,8 +105,9 @@ interface SandboxLease { * * A session lease never binds the abort signal to teardown — cancelling one * execution must not destroy state the next turn builds on — and is never - * metered, because session sandboxes are server-owned rather than billed to - * workspace compute. Providers without session support degrade to one-shot. + * metered as workspace compute: the Mothership tool bills each call's execution + * time, while the idle time between calls is server-owned. Providers without + * session support degrade to one-shot. */ async function leaseSandbox( kind: SandboxKind, diff --git a/apps/sim/lib/execution/remote-sandbox/pi-sandbox-billing.live.test.ts b/apps/sim/lib/execution/remote-sandbox/pi-sandbox-billing.live.test.ts index 1556aff5c35..6ae3d4466a4 100644 --- a/apps/sim/lib/execution/remote-sandbox/pi-sandbox-billing.live.test.ts +++ b/apps/sim/lib/execution/remote-sandbox/pi-sandbox-billing.live.test.ts @@ -29,7 +29,7 @@ describe.skipIf(!smokeEnabled)('pi sandbox billing smoke', () => { it( 'bills the session a Pi sandbox was held for', async () => { - const pricing = createSandboxPricing(resolveProvider().id) + const pricing = createSandboxPricing(resolveProvider().id, 'pi') const usdPerBilledSecond = (pricing.resources.vcpu * pricing.rates.cpuUsdPerVcpuSecond + pricing.resources.memoryGiB * pricing.rates.memoryUsdPerGiBSecond + diff --git a/apps/sim/lib/execution/remote-sandbox/session-file-snapshot.test.ts b/apps/sim/lib/execution/remote-sandbox/session-file-snapshot.test.ts index f669c8e7c21..debcf8aad3b 100644 --- a/apps/sim/lib/execution/remote-sandbox/session-file-snapshot.test.ts +++ b/apps/sim/lib/execution/remote-sandbox/session-file-snapshot.test.ts @@ -35,6 +35,7 @@ vi.mock('@/lib/execution/remote-sandbox/session-lock', () => ({ ) => execute(signal), })) +import { SESSION_SANDBOX_IDLE_MS } from '@/lib/execution/remote-sandbox/session' import { openSessionFileSnapshot } from '@/lib/execution/remote-sandbox/session-file-snapshot' import { fileReadFailure } from '@/lib/mothership/agent-cli/engines/file-view' @@ -104,7 +105,6 @@ it('bounds the remote copy itself, not just the eventual response stream', async expect(mocks.read).not.toHaveBeenCalled() }) it('asks the reconnect for the idle lease, which can outlast the runtime cap', async () => { - const IDLE_MS = 20 * 60_000 const capAtMs = Date.now() + 60_000 let leaseEndAtMs = capAtMs const machine = await mocks.find() @@ -124,7 +124,7 @@ it('asks the reconnect for the idle lease, which can outlast the runtime cap', a maxBytes: 100, }) await snapshot.dispose() - expect(leaseEndAtMs).toBeGreaterThanOrEqual(Date.now() + IDLE_MS - 1000) + expect(leaseEndAtMs).toBeGreaterThanOrEqual(Date.now() + SESSION_SANDBOX_IDLE_MS - 1000) }) it('never creates a replacement machine for a missing read', async () => { mocks.find.mockResolvedValue(null) diff --git a/apps/sim/lib/execution/remote-sandbox/session-file-snapshot.ts b/apps/sim/lib/execution/remote-sandbox/session-file-snapshot.ts index a026ea7f8d9..ac0b5abcf8f 100644 --- a/apps/sim/lib/execution/remote-sandbox/session-file-snapshot.ts +++ b/apps/sim/lib/execution/remote-sandbox/session-file-snapshot.ts @@ -130,9 +130,10 @@ export async function openSessionFileSnapshot( try { return await withSandboxSessionLock(sessionKey, signal, async (accessSignal) => { const provider = resolveProvider() - // The reconnect grants the idle lease itself, so it can reset a workbench near its runtime cap. + // The reconnect grants the lease itself, so it can reset a workbench near its runtime cap. The + // caller streams the snapshot after this returns, so the lease covers the whole snapshot budget. const sandbox = await provider.findSessionSandbox?.(sessionKey, { - lifetimeMs: SESSION_SANDBOX_IDLE_MS, + lifetimeMs: SESSION_SANDBOX_IDLE_MS + SNAPSHOT_TIMEOUT_MS, }) accessSignal.throwIfAborted() if (!sandbox) diff --git a/apps/sim/lib/execution/remote-sandbox/session-files.test.ts b/apps/sim/lib/execution/remote-sandbox/session-files.test.ts index da7e5b6de6c..f5356692e86 100644 --- a/apps/sim/lib/execution/remote-sandbox/session-files.test.ts +++ b/apps/sim/lib/execution/remote-sandbox/session-files.test.ts @@ -36,6 +36,7 @@ vi.mock('@/lib/execution/remote-sandbox/session-lock', () => ({ import { observeSandboxExecution } from '@/lib/execution/remote-sandbox/observer' import { SandboxOutputLimitError } from '@/lib/execution/remote-sandbox/output-limits' +import { SESSION_SANDBOX_IDLE_MS } from '@/lib/execution/remote-sandbox/session' import { readSessionSecretProvenance } from '@/lib/execution/remote-sandbox/session-file-provenance' import { readSessionSandboxFile, @@ -84,7 +85,6 @@ describe('workbench file cancellation', () => { }) it('asks the reconnect for the idle lease, which can outlast the runtime cap', async () => { - const IDLE_MS = 20 * 60_000 const capAtMs = Date.now() + 60_000 let leaseEndAtMs = capAtMs find.mockImplementation(async (_key: string, options: { lifetimeMs?: number }) => { @@ -97,7 +97,7 @@ describe('workbench file cancellation', () => { } }) expect(await readSessionSandboxFile('chat', 'input.txt')).toMatchObject({ outcome: 'read' }) - expect(leaseEndAtMs).toBeGreaterThanOrEqual(Date.now() + IDLE_MS - 1000) + expect(leaseEndAtMs).toBeGreaterThanOrEqual(Date.now() + SESSION_SANDBOX_IDLE_MS - 1000) }) it('does not write if Stop arrives during the sandbox lookup', async () => { @@ -379,7 +379,7 @@ describe('workbench file cancellation', () => { 'mothership', expect.objectContaining({ sessionKey: 'chat', - lifetimeMs: 20 * 60_000, + lifetimeMs: SESSION_SANDBOX_IDLE_MS + 30_000, }) ) expect(write).toHaveBeenCalledWith(expect.stringMatching(/^\/home\/user\/\.sim-write-/), 'rows') diff --git a/apps/sim/lib/execution/remote-sandbox/session-files.ts b/apps/sim/lib/execution/remote-sandbox/session-files.ts index c56ef365070..c94f4a4c87a 100644 --- a/apps/sim/lib/execution/remote-sandbox/session-files.ts +++ b/apps/sim/lib/execution/remote-sandbox/session-files.ts @@ -123,7 +123,8 @@ export async function writeSessionSandboxFile( const { created } = await ensureSessionSandbox({ provider, kind: 'mothership', - options: {}, + // A streamed transfer can outlast the idle lease; the machine must not pause mid-write. + options: { lifetimeMs: timeoutMs }, selected: null, session: { key: sessionKey }, signal, diff --git a/apps/sim/lib/execution/remote-sandbox/session-pause.live.test.ts b/apps/sim/lib/execution/remote-sandbox/session-pause.live.test.ts new file mode 100644 index 00000000000..d601d97b114 --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/session-pause.live.test.ts @@ -0,0 +1,71 @@ +/** + * Idle session sandboxes against the real E2B control plane: a workbench pauses when its lease + * runs out instead of dying, a reconnect resumes it with its files, and the retention sweep's kill + * spares a sandbox that is running or paused too recently. + * + * Enable with `SANDBOX_SESSION_SMOKE=1`. Requires `E2B_API_KEY` and `MOTHERSHIP_E2B_TEMPLATE_ID`. + * Creates one short-lived sandbox and always kills it. + */ +import { sleep } from '@sim/utils/helpers' +import { generateShortId } from '@sim/utils/id' +import { describe, expect, it } from 'vitest' +import { e2bProvider } from '@/lib/execution/remote-sandbox/e2b' +import type { PausedSessionSandbox } from '@/lib/execution/remote-sandbox/types' + +const smokeEnabled = process.env.SANDBOX_SESSION_SMOKE === '1' +const LEASE_MS = 15_000 +const PAUSE_WAIT_MS = 90_000 + +/** The paused list trails resumes, so a later pause must be newer than `after`. */ +async function waitForPause(sessionKey: string, after = 0): Promise { + const deadline = Date.now() + PAUSE_WAIT_MS + while (Date.now() < deadline) { + const paused = await e2bProvider.listPausedSessionSandboxes?.() + const match = paused?.find( + (sandbox) => sandbox.sessionKey === sessionKey && sandbox.pausedAtMs > after + ) + if (match) return match + await sleep(2_000) + } + throw new Error(`Session sandbox ${sessionKey} did not pause within ${PAUSE_WAIT_MS}ms`) +} + +describe.skipIf(!smokeEnabled)('session sandbox pause smoke', () => { + it( + 'pauses an idle workbench, resumes it intact, and reaps it only once expired', + async () => { + const apiKey = process.env.E2B_API_KEY + if (!apiKey) throw new Error('E2B_API_KEY is required') + const sessionKey = `mothership-chat:live-pause-${generateShortId(10)}` + const created = await e2bProvider.create('mothership', { sessionKey, lifetimeMs: LEASE_MS }) + const { Sandbox } = await import('@e2b/code-interpreter') + try { + await created.writeFile('/home/user/marker.txt', 'kept') + + const paused = await waitForPause(sessionKey) + expect(paused.sandboxId).toBe(created.sandboxId) + expect(paused.pausedAtMs).toBeLessThanOrEqual(Date.now()) + expect(await e2bProvider.killPausedSessionSandbox?.(paused, paused.pausedAtMs - 1)).toBe( + false + ) + + const resumed = await e2bProvider.findSessionSandbox?.(sessionKey, { lifetimeMs: LEASE_MS }) + expect(resumed?.sandboxId).toBe(created.sandboxId) + const marker = await resumed?.readFileWithLimit('/home/user/marker.txt', { + maxBytes: 64, + encoding: 'utf8', + }) + expect(marker?.content).toBe('kept') + expect(await e2bProvider.killPausedSessionSandbox?.(paused, Date.now())).toBe(false) + + // A reconnect grants at least the idle lease, so pause directly rather than wait it out. + await Sandbox.pause(created.sandboxId, { apiKey }) + const pausedAgain = await waitForPause(sessionKey, paused.pausedAtMs) + expect(await e2bProvider.killPausedSessionSandbox?.(pausedAgain, Date.now())).toBe(true) + } finally { + await Sandbox.kill(created.sandboxId, { apiKey }) + } + }, + 5 * 60_000 + ) +}) diff --git a/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts b/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts index 24c702d03a4..a38b777df7f 100644 --- a/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts +++ b/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts @@ -50,6 +50,7 @@ import { observeSandboxExecution, observeSandboxSessionInputs, } from '@/lib/execution/remote-sandbox/observer' +import { SESSION_SANDBOX_IDLE_MS } from '@/lib/execution/remote-sandbox/session' import { initializeSessionFileProvenance, recordSessionFileInput, @@ -797,7 +798,7 @@ describe('session sandbox lease', () => { }) expect(result.sandboxSession).toBe('reused') - expect(grantedUntilMs).toBeGreaterThanOrEqual(Date.now() + 20 * 60_000) + expect(grantedUntilMs).toBeGreaterThanOrEqual(Date.now() + SESSION_SANDBOX_IDLE_MS) expect(calls.extendLifetime).toHaveLength(0) } finally { clock.mockRestore() diff --git a/apps/sim/lib/execution/remote-sandbox/session.ts b/apps/sim/lib/execution/remote-sandbox/session.ts index 22d45aca2e6..1f4b767c318 100644 --- a/apps/sim/lib/execution/remote-sandbox/session.ts +++ b/apps/sim/lib/execution/remote-sandbox/session.ts @@ -1,8 +1,13 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { createSandboxPricing } from '@/lib/billing/sandbox-pricing' +import { mapSettledWithConcurrency } from '@/lib/core/utils/concurrency' import { type CreatedSandbox, createSelectedSandbox } from '@/lib/execution/remote-sandbox/create' +import { resolveProvider } from '@/lib/execution/remote-sandbox/provider' import type { ResolvedSandbox } from '@/lib/execution/remote-sandbox/resolve' import { ensureSessionCli } from '@/lib/execution/remote-sandbox/session-cli' import { initializeSessionFileProvenance } from '@/lib/execution/remote-sandbox/session-file-provenance' +import { withSandboxSessionLock } from '@/lib/execution/remote-sandbox/session-lock' import type { CreateSandboxOptions, SandboxKind, @@ -10,7 +15,24 @@ import type { SandboxSessionRequest, } from '@/lib/execution/remote-sandbox/types' -export const SESSION_SANDBOX_IDLE_MS = 20 * 60_000 +const logger = createLogger('SessionSandbox') + +/** + * How long a session sandbox keeps running after its last use. The provider then pauses it + * rather than killing it, so a later call resumes the same machine with its files intact. + */ +export const SESSION_SANDBOX_IDLE_MS = 5 * 60_000 + +/** + * How long a paused session sandbox is kept before the sweep kills it. With the idle lease, a + * workbench is discarded about 20 minutes after its last use. + */ +const SESSION_SANDBOX_PAUSED_RETENTION_MS = 15 * 60_000 + +const REAP_LOCK_TIMEOUT_MS = 10_000 +const REAP_CONCURRENCY = 8 +/** Bounds one sweep's runtime; a backlog drains over the following runs. */ +const REAP_BATCH_LIMIT = 200 interface SessionAcquisition { created: CreatedSandbox @@ -47,7 +69,7 @@ export async function ensureSessionSandbox(args: { providerId: provider.id, startedAtMs: Date.now(), effectiveLifetimeMs: provider.resolveLifetimeMs(lifetimeMs), - pricing: createSandboxPricing(provider.id), + pricing: createSandboxPricing(provider.id, kind), } : await createSelectedSandbox( kind, @@ -73,3 +95,42 @@ export async function ensureSessionSandbox(args: { signal.throwIfAborted() return { created, status: existing ? 'reused' : 'created' } } + +/** + * Kills session sandboxes paused past {@link SESSION_SANDBOX_PAUSED_RETENTION_MS}. Each kill runs + * under the session's lock, the same lock every resume takes, and the provider re-checks the + * sandbox there, so a workbench a caller has just resumed is never killed. + */ +export async function reapPausedSessionSandboxes(): Promise<{ + killed: number + skipped: number + failed: number +}> { + const provider = resolveProvider() + const result = { killed: 0, skipped: 0, failed: 0 } + if (!provider.listPausedSessionSandboxes || !provider.killPausedSessionSandbox) return result + const killPaused = provider.killPausedSessionSandbox.bind(provider) + + const pausedBeforeMs = Date.now() - SESSION_SANDBOX_PAUSED_RETENTION_MS + const expired = (await provider.listPausedSessionSandboxes()) + .filter((sandbox) => sandbox.pausedAtMs <= pausedBeforeMs) + .slice(0, REAP_BATCH_LIMIT) + const outcomes = await mapSettledWithConcurrency(expired, REAP_CONCURRENCY, (sandbox) => + withSandboxSessionLock(sandbox.sessionKey, AbortSignal.timeout(REAP_LOCK_TIMEOUT_MS), () => + killPaused(sandbox, pausedBeforeMs) + ) + ) + outcomes.forEach((outcome, index) => { + if (outcome.status === 'fulfilled') { + if (outcome.value) result.killed++ + else result.skipped++ + return + } + result.failed++ + logger.warn('Failed to reap paused session sandbox; the next sweep retries', { + sandboxId: expired[index].sandboxId, + error: getErrorMessage(outcome.reason), + }) + }) + return result +} diff --git a/apps/sim/lib/execution/remote-sandbox/types.ts b/apps/sim/lib/execution/remote-sandbox/types.ts index 47f48edc5e0..31dab9561ef 100644 --- a/apps/sim/lib/execution/remote-sandbox/types.ts +++ b/apps/sim/lib/execution/remote-sandbox/types.ts @@ -488,4 +488,17 @@ export interface SandboxProvider { key: string, options: { language?: CodeLanguage; lifetimeMs?: number } ): Promise + /** Session sandboxes the provider paused when their lease ran out, for the retention sweep. */ + listPausedSessionSandboxes?(): Promise + /** + * Kills the sandbox only if it is still paused and paused at or before `pausedBeforeMs`, + * resolving false when a resume got there first. Callers hold the session's lock. + */ + killPausedSessionSandbox?(sandbox: PausedSessionSandbox, pausedBeforeMs: number): Promise +} + +export interface PausedSessionSandbox { + sandboxId: string + sessionKey: string + pausedAtMs: number } diff --git a/docker/crontab b/docker/crontab index 91f8c6e07bf..4edef237578 100644 --- a/docker/crontab +++ b/docker/crontab @@ -71,6 +71,9 @@ SHELL=/bin/sh # Reclaims sandbox images 30 4 * * * curl -fsS -m 300 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/cron/cleanup-sandbox-images" +# Kills chat workbenches paused past their retention window +*/5 * * * * curl -fsS -m 60 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/cron/cleanup-session-sandboxes" + # Deletes idempotency records past the seven-day dedupe window 0 5 * * * curl -fsS -m 300 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/webhooks/cleanup/idempotency" diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 678b52905c6..3a4bb0905c3 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -1553,6 +1553,18 @@ cronjobs: successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 1 + # Kills chat workbenches the sandbox provider paused once they went idle. The + # provider keeps paused sandboxes indefinitely, so this bounds how long a + # workbench outlives its last use. A no-op without a session-capable provider. + cleanupSessionSandboxes: + enabled: true + name: cleanup-session-sandboxes + schedule: "*/5 * * * *" + path: "/api/cron/cleanup-session-sandboxes" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 1 + # Processes the transactional outbox (deployment side-effects retry / dead-letter) # and reaps stale workspace-fork background-work rows. Both are safety nets behind # the immediate post-commit processing, so frequent runs keep retries timely. diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 71be84f6021..1afbb402217 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -112,6 +112,7 @@ const INDIRECT_ZOD_ROUTES = new Set([ 'apps/sim/app/api/cron/cleanup-table-row-ttl/route.ts', 'apps/sim/app/api/cron/cleanup-stale-executions/route.ts', 'apps/sim/app/api/cron/cleanup-sandbox-images/route.ts', + 'apps/sim/app/api/cron/cleanup-session-sandboxes/route.ts', 'apps/sim/app/api/cron/cleanup-oauth-tokens/route.ts', 'apps/sim/app/api/cron/renew-subscriptions/route.ts', 'apps/sim/app/api/cron/billing-cycle-close/route.ts', From bd119ea5f1eb2e73d5d9c3142a1437ea4d1b4258 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 29 Oct 2026 13:23:28 -0700 Subject: [PATCH 2/3] chore(helm): bump chart version for the session sandbox cleanup cron --- helm/sim/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helm/sim/Chart.yaml b/helm/sim/Chart.yaml index c98331a9f4c..f074ea70ad2 100644 --- a/helm/sim/Chart.yaml +++ b/helm/sim/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: sim description: A Helm chart for Sim - the open-source AI workspace where teams build, deploy, and manage AI agents type: application -version: 1.11.10 +version: 1.11.11 appVersion: "v0.8.26" kubeVersion: ">=1.25.0-0" home: https://sim.ai From 4944839eaf73628447430c45513fb685e496c08a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 29 Oct 2026 13:39:46 -0700 Subject: [PATCH 3/3] fix(sandbox): bound the session sandbox sweep by a deadline, not a batch size --- .../lib/execution/remote-sandbox/session.ts | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/sim/lib/execution/remote-sandbox/session.ts b/apps/sim/lib/execution/remote-sandbox/session.ts index 1f4b767c318..6b3ac9dff86 100644 --- a/apps/sim/lib/execution/remote-sandbox/session.ts +++ b/apps/sim/lib/execution/remote-sandbox/session.ts @@ -31,8 +31,12 @@ const SESSION_SANDBOX_PAUSED_RETENTION_MS = 15 * 60_000 const REAP_LOCK_TIMEOUT_MS = 10_000 const REAP_CONCURRENCY = 8 -/** Bounds one sweep's runtime; a backlog drains over the following runs. */ -const REAP_BATCH_LIMIT = 200 +/** + * A sweep starts no kill after this. One kill takes at most the lock wait plus two 5-second + * provider requests, so the sweep reports within the cron route's 60-second limit; the rest waits + * for the next run. + */ +const REAP_DEADLINE_MS = 30_000 interface SessionAcquisition { created: CreatedSandbox @@ -104,26 +108,31 @@ export async function ensureSessionSandbox(args: { export async function reapPausedSessionSandboxes(): Promise<{ killed: number skipped: number + deferred: number failed: number }> { const provider = resolveProvider() - const result = { killed: 0, skipped: 0, failed: 0 } + const result = { killed: 0, skipped: 0, deferred: 0, failed: 0 } if (!provider.listPausedSessionSandboxes || !provider.killPausedSessionSandbox) return result const killPaused = provider.killPausedSessionSandbox.bind(provider) + const deadline = AbortSignal.timeout(REAP_DEADLINE_MS) const pausedBeforeMs = Date.now() - SESSION_SANDBOX_PAUSED_RETENTION_MS - const expired = (await provider.listPausedSessionSandboxes()) - .filter((sandbox) => sandbox.pausedAtMs <= pausedBeforeMs) - .slice(0, REAP_BATCH_LIMIT) - const outcomes = await mapSettledWithConcurrency(expired, REAP_CONCURRENCY, (sandbox) => - withSandboxSessionLock(sandbox.sessionKey, AbortSignal.timeout(REAP_LOCK_TIMEOUT_MS), () => - killPaused(sandbox, pausedBeforeMs) - ) + const expired = (await provider.listPausedSessionSandboxes()).filter( + (sandbox) => sandbox.pausedAtMs <= pausedBeforeMs ) + const outcomes = await mapSettledWithConcurrency(expired, REAP_CONCURRENCY, async (sandbox) => { + if (deadline.aborted) return 'deferred' as const + const killed = await withSandboxSessionLock( + sandbox.sessionKey, + AbortSignal.timeout(REAP_LOCK_TIMEOUT_MS), + () => killPaused(sandbox, pausedBeforeMs) + ) + return killed ? ('killed' as const) : ('skipped' as const) + }) outcomes.forEach((outcome, index) => { if (outcome.status === 'fulfilled') { - if (outcome.value) result.killed++ - else result.skipped++ + result[outcome.value]++ return } result.failed++