Skip to content

Commit 322abb2

Browse files
authored
improvement(sandbox): pause idle chat workbenches instead of keeping them running (#9452)
* 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. * chore(helm): bump chart version for the session sandbox cleanup cron * fix(sandbox): bound the session sandbox sweep by a deadline, not a batch size
1 parent 99d034a commit 322abb2

21 files changed

Lines changed: 320 additions & 42 deletions

‎apps/docs/content/docs/platform/self-hosting/background-jobs.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ Create a workflow with a Schedule trigger set to every minute, deploy it, and wa
188188
| Reconcile billing seats | `/api/cron/reconcile-billing-seats` | `0 * * * *` | Billing only — safe to disable when self-hosted |
189189
| Reconcile inbox entitlement | `/api/cron/reconcile-inbox-entitlement` | `0 3 * * *` | Inbox access reconciliation |
190190
| Cleanup sandbox images | `/api/cron/cleanup-sandbox-images` | `30 4 * * *` | Reclaims sandbox images |
191+
| Cleanup session sandboxes | `/api/cron/cleanup-session-sandboxes` | `*/5 * * * *` | Kills chat workbenches paused past their retention window |
191192
| Idempotency key cleanup | `/api/webhooks/cleanup/idempotency` | `0 5 * * *` | Deletes deduplication records older than seven days |
192193

193194
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.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { verifyCronAuth } from '@/lib/auth/internal'
5+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
6+
import { reapPausedSessionSandboxes } from '@/lib/execution/remote-sandbox/session'
7+
8+
export const dynamic = 'force-dynamic'
9+
export const maxDuration = 60
10+
11+
const logger = createLogger('CleanupSessionSandboxesAPI')
12+
13+
/**
14+
* Retention sweep for paused chat workbenches. An idle workbench pauses instead of dying and the
15+
* provider keeps it indefinitely, so this run is what bounds how long its files outlive the chat's
16+
* last use. A no-op on providers without session sandboxes.
17+
*/
18+
export const GET = withRouteHandler(async (request: NextRequest) => {
19+
const authError = verifyCronAuth(request, 'session sandbox cleanup')
20+
if (authError) return authError
21+
22+
try {
23+
const result = await reapPausedSessionSandboxes()
24+
logger.info('Swept paused session sandboxes', result)
25+
return NextResponse.json({ success: true, ...result })
26+
} catch (error) {
27+
logger.error('Failed to sweep paused session sandboxes', { error })
28+
return NextResponse.json(
29+
{ error: getErrorMessage(error, 'Failed to sweep paused session sandboxes') },
30+
{ status: 500 }
31+
)
32+
}
33+
})

‎apps/sim/lib/billing/sandbox-pricing.test.ts‎

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,30 +17,36 @@ describe('sandbox pricing', () => {
1717
['e2b', 0.1656],
1818
['daytona', 0.16668],
1919
] as const)('prices one hour of the Function profile on %s', (provider, expected) => {
20-
const pricing = createSandboxPricing(provider, 1)
20+
const pricing = createSandboxPricing(provider, 'code', 1)
2121

2222
expect(priceSandboxUsage(pricing, 3_600_000, 3_600_000).rawCost).toBeCloseTo(expected, 8)
2323
})
2424

25+
it('prices one hour of the Mothership workbench on its template resources, not the Function profile', () => {
26+
const pricing = createSandboxPricing('e2b', 'mothership', 1)
27+
28+
expect(priceSandboxUsage(pricing, 3_600_000, 3_600_000).rawCost).toBeCloseTo(0.117, 8)
29+
})
30+
2531
it('applies the multiplier once and rounds the final cost to eight decimals', () => {
26-
const pricing = createSandboxPricing('e2b', 1.75)
32+
const pricing = createSandboxPricing('e2b', 'code', 1.75)
2733

2834
expect(priceSandboxUsage(pricing, 1234, 10_000).billedCost).toBe(0.00009934)
2935
})
3036

3137
it('caps duration at the provider lifetime', () => {
32-
const pricing = createSandboxPricing('daytona', 1)
38+
const pricing = createSandboxPricing('daytona', 'code', 1)
3339

3440
expect(priceSandboxUsage(pricing, 90_000, 60_000).durationMs).toBe(60_000)
3541
})
3642

3743
it('allows a zero multiplier and rejects invalid multipliers', () => {
38-
const freePricing = createSandboxPricing('e2b', 0)
44+
const freePricing = createSandboxPricing('e2b', 'code', 0)
3945

4046
expect(priceSandboxUsage(freePricing, 1000, 1000).billedCost).toBe(0)
41-
expect(() => createSandboxPricing('e2b', -1)).toThrow('finite nonnegative')
42-
expect(() => createSandboxPricing('e2b', Number.NaN)).toThrow('finite nonnegative')
43-
expect(() => createSandboxPricing('e2b', Number.POSITIVE_INFINITY)).toThrow(
47+
expect(() => createSandboxPricing('e2b', 'code', -1)).toThrow('finite nonnegative')
48+
expect(() => createSandboxPricing('e2b', 'code', Number.NaN)).toThrow('finite nonnegative')
49+
expect(() => createSandboxPricing('e2b', 'code', Number.POSITIVE_INFINITY)).toThrow(
4450
'finite nonnegative'
4551
)
4652
})
@@ -49,7 +55,7 @@ describe('sandbox pricing', () => {
4955
it('coerces the string COST_MULTIPLIER that process.env delivers', () => {
5056
setEnv({ COST_MULTIPLIER: '1.1' })
5157

52-
const pricing = createSandboxPricing('e2b')
58+
const pricing = createSandboxPricing('e2b', 'code')
5359

5460
expect(pricing.multiplier).toBe(1.1)
5561
expect(priceSandboxUsage(pricing, 1000, 1000).billedCost).toBeCloseTo(0.0000506, 8)
@@ -58,18 +64,18 @@ describe('sandbox pricing', () => {
5864
it('falls back to 1 when COST_MULTIPLIER is unset', () => {
5965
setEnv({ COST_MULTIPLIER: undefined })
6066

61-
expect(createSandboxPricing('daytona').multiplier).toBe(1)
67+
expect(createSandboxPricing('daytona', 'code').multiplier).toBe(1)
6268
})
6369

6470
it('falls back to 1 instead of throwing when COST_MULTIPLIER is not a nonnegative number', () => {
6571
setEnv({ COST_MULTIPLIER: 'abc' })
66-
expect(createSandboxPricing('e2b').multiplier).toBe(1)
72+
expect(createSandboxPricing('e2b', 'code').multiplier).toBe(1)
6773

6874
setEnv({ COST_MULTIPLIER: '-2' })
69-
expect(createSandboxPricing('e2b').multiplier).toBe(1)
75+
expect(createSandboxPricing('e2b', 'code').multiplier).toBe(1)
7076

7177
setEnv({ COST_MULTIPLIER: ' ' })
72-
expect(createSandboxPricing('e2b').multiplier).toBe(1)
78+
expect(createSandboxPricing('e2b', 'code').multiplier).toBe(1)
7379
})
7480
})
7581
})

‎apps/sim/lib/billing/sandbox-pricing.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
FUNCTION_SANDBOX_CPU_COUNT,
55
FUNCTION_SANDBOX_MEMORY_GB,
66
} from '@/lib/execution/remote-sandbox/function-resources'
7-
import type { SandboxProviderId } from '@/lib/execution/remote-sandbox/types'
7+
import type { SandboxKind, SandboxProviderId } from '@/lib/execution/remote-sandbox/types'
88

99
const E2B_CPU_USD_PER_VCPU_SECOND = 0.000014
1010
const E2B_MEMORY_USD_PER_GIB_SECOND = 0.0000045
@@ -37,16 +37,23 @@ export interface PricedSandboxUsage {
3737
billedCost: number
3838
}
3939

40-
const PRICING_BY_PROVIDER: Record<
41-
SandboxProviderId,
42-
Pick<SandboxPricing, 'resources' | 'rates'>
43-
> = {
40+
interface ProviderPricing extends Pick<SandboxPricing, 'resources' | 'rates'> {
41+
/** Kinds whose image is provisioned differently from the Function profile. */
42+
resourcesByKind?: Partial<Record<SandboxKind, SandboxPricing['resources']>>
43+
}
44+
45+
const PRICING_BY_PROVIDER: Record<SandboxProviderId, ProviderPricing> = {
4446
e2b: {
4547
resources: {
4648
vcpu: FUNCTION_SANDBOX_CPU_COUNT,
4749
memoryGiB: FUNCTION_SANDBOX_MEMORY_GB,
4850
diskGiB: 0,
4951
},
52+
resourcesByKind: {
53+
// The Mothership template is built without explicit resources, so E2B runs it on its
54+
// default 2 vCPU and 1 GiB.
55+
mothership: { vcpu: 2, memoryGiB: 1, diskGiB: 0 },
56+
},
5057
rates: {
5158
cpuUsdPerVcpuSecond: E2B_CPU_USD_PER_VCPU_SECOND,
5259
memoryUsdPerGiBSecond: E2B_MEMORY_USD_PER_GIB_SECOND,
@@ -69,16 +76,18 @@ const PRICING_BY_PROVIDER: Record<
6976

7077
export function createSandboxPricing(
7178
provider: SandboxProviderId,
79+
kind: SandboxKind,
7280
multiplier = getCostMultiplier()
7381
): SandboxPricing {
7482
if (!Number.isFinite(multiplier) || multiplier < 0) {
7583
throw new Error('Sandbox pricing multiplier must be a finite nonnegative number')
7684
}
7785
const pricing = PRICING_BY_PROVIDER[provider]
86+
const resources = pricing.resourcesByKind?.[kind] ?? pricing.resources
7887
return {
7988
provider,
8089
multiplier,
81-
resources: { ...pricing.resources },
90+
resources: { ...resources },
8291
rates: { ...pricing.rates },
8392
}
8493
}

‎apps/sim/lib/execution/remote-sandbox/billing.live.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ describe.skipIf(!smokeEnabled)('sandbox billing smoke', () => {
2828
it(
2929
'bills the sandbox lifetime at the provider rate',
3030
async () => {
31-
const pricing = createSandboxPricing(resolveProvider().id)
31+
const pricing = createSandboxPricing(resolveProvider().id, 'code')
3232
const usdPerSecond =
3333
pricing.resources.vcpu * pricing.rates.cpuUsdPerVcpuSecond +
3434
pricing.resources.memoryGiB * pricing.rates.memoryUsdPerGiBSecond +

‎apps/sim/lib/execution/remote-sandbox/create.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export async function createSandbox(
3434
if (meterUsage && effectiveLifetimeMs === undefined) {
3535
throw new Error('Metered sandbox execution requires a provider lifetime')
3636
}
37-
const pricing = meterUsage ? createSandboxPricing(provider.id) : undefined
37+
const pricing = meterUsage ? createSandboxPricing(provider.id, kind) : undefined
3838
let startedAtMs = Date.now()
3939
const providerOptions = {
4040
...options,

‎apps/sim/lib/execution/remote-sandbox/e2b.ts‎

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { createHash } from 'node:crypto'
2-
import type { Sandbox as E2BSandbox, Template as E2BTemplate } from '@e2b/code-interpreter'
2+
import type {
3+
Sandbox as E2BSandbox,
4+
Template as E2BTemplate,
5+
SandboxInfo,
6+
SandboxPaginator,
7+
} from '@e2b/code-interpreter'
38
import { createLogger } from '@sim/logger'
49
import { getErrorMessage } from '@sim/utils/errors'
510
import { generateId, generateShortId } from '@sim/utils/id'
@@ -65,6 +70,7 @@ import {
6570
} from '@/lib/execution/remote-sandbox/spec'
6671
import type {
6772
CreateSandboxOptions,
73+
PausedSessionSandbox,
6874
RunCommandOptions,
6975
SandboxCodeResult,
7076
SandboxCommandResult,
@@ -307,6 +313,12 @@ const E2B_SESSION_METADATA_KEY = 'simSessionKey'
307313
const E2B_SESSION_OWNERSHIP_KEY = 'simSessionOwnership'
308314
const E2B_SESSION_OWNERSHIP_VERSION = 'tracked-v1'
309315

316+
async function listAllSandboxes(paginator: SandboxPaginator): Promise<SandboxInfo[]> {
317+
const sandboxes = await paginator.nextItems()
318+
while (paginator.hasNext) sandboxes.push(...(await paginator.nextItems()))
319+
return sandboxes
320+
}
321+
310322
async function stopSessionCommand(
311323
sandbox: E2BSandbox,
312324
processId: string,
@@ -1095,6 +1107,9 @@ export const e2bProvider: SandboxProvider = {
10951107
[E2B_SESSION_METADATA_KEY]: options.sessionKey,
10961108
[E2B_SESSION_OWNERSHIP_KEY]: E2B_SESSION_OWNERSHIP_VERSION,
10971109
},
1110+
// An idle workbench pauses instead of dying. Only a locked reconnect may resume it,
1111+
// which is what lets the retention sweep kill it safely.
1112+
lifecycle: { onTimeout: 'pause' as const, autoResume: false },
10981113
}
10991114
: {}),
11001115
}
@@ -1122,12 +1137,12 @@ export const e2bProvider: SandboxProvider = {
11221137
const apiKey = env.E2B_API_KEY
11231138
if (!apiKey) throw new Error('E2B_API_KEY is required when E2B is enabled')
11241139
const { Sandbox } = await import('@e2b/code-interpreter')
1125-
const paginator = Sandbox.list({
1126-
apiKey,
1127-
query: { metadata: { [E2B_SESSION_METADATA_KEY]: key }, state: ['running', 'paused'] },
1128-
})
1129-
const candidates = await paginator.nextItems()
1130-
while (paginator.hasNext) candidates.push(...(await paginator.nextItems()))
1140+
const candidates = await listAllSandboxes(
1141+
Sandbox.list({
1142+
apiKey,
1143+
query: { metadata: { [E2B_SESSION_METADATA_KEY]: key }, state: ['running', 'paused'] },
1144+
})
1145+
)
11311146
if (candidates.length === 0) return null
11321147
candidates.sort((a, b) => a.startedAt.getTime() - b.startedAt.getTime())
11331148
const candidate = candidates[0]
@@ -1172,4 +1187,44 @@ export const e2bProvider: SandboxProvider = {
11721187
options.lifetimeMs === undefined ? undefined : requestedAtMs + timeoutMs
11731188
)
11741189
},
1190+
1191+
async listPausedSessionSandboxes(): Promise<PausedSessionSandbox[]> {
1192+
const apiKey = env.E2B_API_KEY
1193+
if (!apiKey) return []
1194+
const { Sandbox } = await import('@e2b/code-interpreter')
1195+
const paused = await listAllSandboxes(Sandbox.list({ apiKey, query: { state: ['paused'] } }))
1196+
return paused.flatMap((info) => {
1197+
const sessionKey = info.metadata[E2B_SESSION_METADATA_KEY]
1198+
// A paused sandbox reports the moment it paused as its end.
1199+
return sessionKey
1200+
? [{ sandboxId: info.sandboxId, sessionKey, pausedAtMs: info.endAt.getTime() }]
1201+
: []
1202+
})
1203+
},
1204+
1205+
async killPausedSessionSandbox(
1206+
sandbox: PausedSessionSandbox,
1207+
pausedBeforeMs: number
1208+
): Promise<boolean> {
1209+
const apiKey = env.E2B_API_KEY
1210+
if (!apiKey) throw new Error('E2B_API_KEY is required when E2B is enabled')
1211+
const { Sandbox, NotFoundError } = await import('@e2b/code-interpreter')
1212+
const requestOptions = { apiKey, requestTimeoutMs: 5_000 }
1213+
// The list can trail a resume, so only the sandbox's own info decides.
1214+
const info = await Sandbox.getInfo(sandbox.sandboxId, requestOptions).catch(
1215+
(error: unknown) => {
1216+
if (error instanceof NotFoundError) return null
1217+
throw error
1218+
}
1219+
)
1220+
if (
1221+
!info ||
1222+
info.state !== 'paused' ||
1223+
info.metadata[E2B_SESSION_METADATA_KEY] !== sandbox.sessionKey ||
1224+
info.endAt.getTime() > pausedBeforeMs
1225+
) {
1226+
return false
1227+
}
1228+
return Sandbox.kill(sandbox.sandboxId, requestOptions)
1229+
},
11751230
}

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,9 @@ interface SandboxLease {
105105
*
106106
* A session lease never binds the abort signal to teardown — cancelling one
107107
* execution must not destroy state the next turn builds on — and is never
108-
* metered, because session sandboxes are server-owned rather than billed to
109-
* workspace compute. Providers without session support degrade to one-shot.
108+
* metered as workspace compute: the Mothership tool bills each call's execution
109+
* time, while the idle time between calls is server-owned. Providers without
110+
* session support degrade to one-shot.
110111
*/
111112
async function leaseSandbox(
112113
kind: SandboxKind,

‎apps/sim/lib/execution/remote-sandbox/pi-sandbox-billing.live.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ describe.skipIf(!smokeEnabled)('pi sandbox billing smoke', () => {
2929
it(
3030
'bills the session a Pi sandbox was held for',
3131
async () => {
32-
const pricing = createSandboxPricing(resolveProvider().id)
32+
const pricing = createSandboxPricing(resolveProvider().id, 'pi')
3333
const usdPerBilledSecond =
3434
(pricing.resources.vcpu * pricing.rates.cpuUsdPerVcpuSecond +
3535
pricing.resources.memoryGiB * pricing.rates.memoryUsdPerGiBSecond +

‎apps/sim/lib/execution/remote-sandbox/session-file-snapshot.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ vi.mock('@/lib/execution/remote-sandbox/session-lock', () => ({
3535
) => execute(signal),
3636
}))
3737

38+
import { SESSION_SANDBOX_IDLE_MS } from '@/lib/execution/remote-sandbox/session'
3839
import { openSessionFileSnapshot } from '@/lib/execution/remote-sandbox/session-file-snapshot'
3940
import { fileReadFailure } from '@/lib/mothership/agent-cli/engines/file-view'
4041

@@ -104,7 +105,6 @@ it('bounds the remote copy itself, not just the eventual response stream', async
104105
expect(mocks.read).not.toHaveBeenCalled()
105106
})
106107
it('asks the reconnect for the idle lease, which can outlast the runtime cap', async () => {
107-
const IDLE_MS = 20 * 60_000
108108
const capAtMs = Date.now() + 60_000
109109
let leaseEndAtMs = capAtMs
110110
const machine = await mocks.find()
@@ -124,7 +124,7 @@ it('asks the reconnect for the idle lease, which can outlast the runtime cap', a
124124
maxBytes: 100,
125125
})
126126
await snapshot.dispose()
127-
expect(leaseEndAtMs).toBeGreaterThanOrEqual(Date.now() + IDLE_MS - 1000)
127+
expect(leaseEndAtMs).toBeGreaterThanOrEqual(Date.now() + SESSION_SANDBOX_IDLE_MS - 1000)
128128
})
129129
it('never creates a replacement machine for a missing read', async () => {
130130
mocks.find.mockResolvedValue(null)

0 commit comments

Comments
 (0)