agentscope-ai / agentscope-ai/agentscope-java

`SandboxConfigurationException: No active sandbox` with concurrent sessions on a shared agent — the per-call binding fix (#2490) is partial; the legacy `volatile sandbox` fallback still races

Abierto
#2,849 0 comentarios 0 reacciones 0 asignados Ver en GitHub
bug
Lenguaje dominante
Java
Estrellas
5.6k
Forks
1.3k
Merge medio
4 d 12 h
PR fusionados (30 d)
77

Descripción

---
### Title
`SandboxConfigurationException: No active sandbox` with concurrent sessions on a shared agent — the per-call binding fix (#2490) is partial; the legacy `volatile sandbox` fallback still races

### Summary
After the #2490 fix (sandbox bound per-call on `RuntimeContext`), two **different sessions** running **concurrently on the same `HarnessAgent`** still intermittently throw:

io.agentscope.harness.agent.sandbox.SandboxException$SandboxConfigurationException:
No active sandbox — sandbox filesystem used outside of a call context
at io.agentscope.harness.agent.filesystem.sandbox.SandboxBackedFilesystem.requireSandbox(SandboxBackedFilesystem.java:245)

Direct tool calls are isolated correctly (the per-call binding works). The failure comes from the **context-free internal readers** — `WorkspaceMessageBus`, `WorkspaceAsyncToolRegistry`, `WorkspaceTaskRepository`, and the baked `getWorkspaceManager()` — which still
resolve the sandbox through the single shared `volatile Sandbox sandbox` field.

### Expected behavior
Two distinct sessions on one agent must never observe "No active sandbox", including for internal filesystem readers that do not carry a per-call `RuntimeContext`.

### Actual behavior / race timeline
Session A and B stream concurrently on the same agent:

| step | event | shared field |
|---|---|---|
| 1 | A `acquireForCall` → `setSandbox(sbA)` | `sbA` |
| 2 | B `acquireForCall` → `setSandbox(sbB)` | `sbB` (last-writer-wins overwrite) |
| 3 | B `releaseForCall` → `clearSandboxIfCurrent(sbB)` — field `== sbB` → cleared | `null` |
| 4 | A still streaming; next context-free read (bus / async registry / task repo / plan capture via `getWorkspaceManager()`) → `requireSandbox(emptyCtx)` → no per-call binding **and** field is `null` → throw at line 245 | `null` |

`clearSandboxIfCurrent`'s compare-and-clear only guards the *reverse* race (a releasing call never nulls a field that already points at a sibling's sandbox). It cannot help when the releasing call **overwrote** the field via `setSandbox` (last-writer-wins): at release
time the field points at *its own* sandbox, so it legitimately nulls it while the sibling session is still mid-call. The single slot has no memory that another call is in flight.

### Root cause
The #2490 fix made only the tool-call path context-bound. The internal machinery is still per-agent singleton and passes a shared static empty context to the filesystem:

- `WorkspaceMessageBus` — `private static final RuntimeContext RC = RuntimeContext.empty()`; every `fs.*(RC, ...)` call uses it
- `WorkspaceAsyncToolRegistry` — same static empty RC
- `WorkspaceTaskRepository` — `rc != null ? rc : RuntimeContext.empty()` fallbacks throughout (including the orphan sweeper)
- `BakedContextFilesystem` / `getWorkspaceManager()` — bakes only `userId`+`sessionId`, **no** `SandboxAcquireResult`

These callers fall through to the single `volatile Sandbox sandbox` field, which the javadoc itself describes as "last-writer-wins under concurrency and must not be relied on for isolation".

### Suggested fix
Make sandbox resolution **session-scoped** instead of a single global slot:

**Option 1 — session-keyed fallback map:** replace `volatile Sandbox sandbox` in `SandboxBackedFilesystem` with `ConcurrentHashMap`; `acquireForCall` binds by the call's session, `releaseForCall` removes only its own key; context-free readers
resolve by the session key they already know (bus/async/task records are keyed by `sessionId`).

**Option 2 — finish #2490: thread the per-call `RuntimeContext` into the internal readers.** Add a `RuntimeContext` parameter to the message bus / async registry / task repository methods and pass the current call's context at the call sites, so every filesystem
access resolves through the per-call binding and the shared field is no longer needed.

### App-level workaround (unblocks us)
Cache the `HarnessAgent` per `(agentId, sessionId)` so each session owns a distinct `SandboxBackedFilesystem` instance. This removes cross-session contention but does not fix the underlying framework gap.

### Environment
- agentscope-java `main` (includes #2490)
- `IsolationScope.SESSION`, `SandboxExecutionGuard` configured
- Two concurrent sessions sharing one `HarnessAgent`

---

Guía de contribución

Abrir la guía de contribución

Evaluación

Este issue todavía no se ha evaluado.

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.