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
- 主要語言
- Java
- 星號
- 5.6k
- 分支
- 1.3k
- 平均合併
- 4 天 12 小時
- 30 天內合併 PR
- 77
描述
---
### 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`
---
貢獻指南
評估
這個 Issue 還沒有評估資料。