feat(code-interpreter): reuse a warm sandbox per conversation (E2B) (#21664)

## What

The E2B code-interpreter driver created a **fresh sandbox on every
execution** and killed it in `finally`, so every call in a conversation
paid full cold-start and started blank. This PR keeps **one warm sandbox
per conversation** and, on idle, **pauses** it rather than killing it.

## How

- **Discovery without a registry:** the sandbox is tagged with the chat
`threadId` (scoped `workspaceId:threadId`) via E2B **metadata**, found
with `Sandbox.list({ query: { state: ['running','paused'], metadata }
})` and resumed with `Sandbox.connect()` (which auto-resumes a paused
sandbox). E2B is the source of truth — no Redis/DB mapping.
- **Pause/resume (E2B 2.x):** session sandboxes are created with
`lifecycle: { onTimeout: 'pause', autoResume: true }`. When idle they
**pause** — compute billing stops, filesystem **and** kernel/memory
state are preserved — and resume in ~1s on the next call. This replaces
the earlier keepalive approach.
- **No premature pause mid-run:** the sandbox is kept alive for
`max(execution timeout, idle window)`, so a long execution is never
paused underneath itself.
- **Tenant isolation:** discovery filters by the `twentySessionId` tag
and **re-checks it client-side**, so a loose server-side match can never
hand one conversation's warm sandbox (with its files, kernel state,
token) to another.
- **Concurrency:** executions sharing a session are serialized
in-process (one active stream per thread, run as a single job — the chat
resolver queues concurrent messages), so parallel tool calls can't race
the shared kernel.
- **Output isolation:** `/home/user/output` is reset at the start of
each reused run, so a call only returns the artifacts it actually
produced; durable state lives elsewhere and persists.

## SDK upgrade

`@e2b/code-interpreter` **`^1.0.4` → `^2.6.0`** (pulls `e2b@2.x`). The
typed pause/resume API, `lifecycle`, and the `state`/`metadata` list
filter only exist in the 2.x line; 1.x exposed them only as untyped
OpenAPI internals. `Sandbox.list()` is now a paginator (handled).

## Config

| Var | Default | Purpose |
|---|---|---|
| `CODE_INTERPRETER_TIMEOUT_MS` | `300000` | Max single-execution
duration. |
| `CODE_INTERPRETER_IDLE_TIMEOUT_MS` | `300000` | Idle window before the
warm sandbox auto-pauses. |

Reuse is always-on when a session id is present (chat path). The
workflow-agent path and the dev-only `LocalDriver` are unaffected.

## ⚠️ Open item before merge: paused-sandbox GC

E2B retains paused sandboxes **indefinitely** (no TTL). Unlike the old
keepalive path (which auto-killed on idle), pause means a conversation's
sandbox persists after the chat ends — so without garbage collection,
paused sandboxes accumulate (≈ one per historical conversation) and
consume storage. A GC policy is required; the approach + retention
window are being decided (see PR discussion). Also: the E2B runtime path
can't run in CI, so this still needs a **live smoke test** (reuse hit,
idle→pause, resume) and confirmation of paused-storage pricing before
rollout.

## Tests / checks

- Resolver unit tests (`getOrCreateSessionSandbox`): reuse+extend,
create-when-absent, duplicate reaping, connect-failure fallback,
keep-first-connectable-when-earlier-dead, **ignore cross-tenant
metadata**, and **kill-on-timeout-refresh-failure**.
- `nx typecheck twenty-server` (against e2b 2.x), `oxlint --type-aware`,
`oxfmt --check` all clean.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-06-17 10:12:24 +02:00
committed by GitHub
parent 74b56ba66c
commit bb6da7b7d1
30 changed files with 986 additions and 58 deletions
@@ -230,6 +230,7 @@ export class StreamAgentChatJob {
await this.chatExecutionService.streamChat({
workspace,
userWorkspaceId: data.userWorkspaceId,
threadId: data.threadId,
messages: data.messages,
browsingContext: data.browsingContext,
modelId: data.modelId,
@@ -6,6 +6,7 @@ import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialE
import type { UIDataTypes, UIMessagePart, UITools } from 'ai';
import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter/code-interpreter.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message-part.entity';
import {
@@ -66,6 +67,7 @@ export class AgentChatService {
private readonly fileRepository: WorkspaceScopedRepository<FileEntity>,
private readonly titleGenerationService: AgentTitleGenerationService,
private readonly workspaceEventBroadcaster: WorkspaceEventBroadcaster,
private readonly codeInterpreterService: CodeInterpreterService,
) {}
async createThread({
@@ -512,6 +514,8 @@ export class AgentChatService {
await this.broadcastThreadUpdated(thread, ['deletedAt'], userWorkspaceId);
this.releaseThreadSandboxBestEffort(workspaceId, threadId);
return thread;
}
@@ -598,6 +602,23 @@ export class AgentChatService {
},
],
});
this.releaseThreadSandboxBestEffort(workspaceId, threadId);
}
private releaseThreadSandboxBestEffort(
workspaceId: string,
threadId: string,
): void {
void this.codeInterpreterService
.releaseThreadSandbox(workspaceId, threadId)
.catch((error) =>
this.logger.warn(
`Failed to release code interpreter sandbox for thread ${threadId}: ${
error instanceof Error ? error.message : String(error)
}`,
),
);
}
async notifyThreadActivityUpdated({
@@ -68,6 +68,7 @@ import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
export type ChatExecutionOptions = {
workspace: WorkspaceEntity;
userWorkspaceId: string;
threadId?: string;
messages: UIMessage<unknown, UIDataTypes, UITools>[];
browsingContext: BrowsingContextType | null;
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
@@ -105,6 +106,7 @@ export class ChatExecutionService {
async streamChat({
workspace,
userWorkspaceId,
threadId,
messages,
browsingContext,
onCodeExecutionUpdate,
@@ -125,6 +127,7 @@ export class ChatExecutionService {
actorContext,
userId,
userWorkspaceId,
threadId,
onCodeExecutionUpdate,
};