Skip to content

fix(harness): bind sandbox to per-call RuntimeContext to prevent concurrent-call corruption - #2491

Open
xseruer wants to merge 2 commits into
agentscope-ai:mainfrom
xseruer:fix/sandbox-per-call-binding
Open

fix(harness): bind sandbox to per-call RuntimeContext to prevent concurrent-call corruption#2491
xseruer wants to merge 2 commits into
agentscope-ai:mainfrom
xseruer:fix/sandbox-per-call-binding

Conversation

@xseruer

@xseruer xseruer commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

AgentScope-Java Version

2.0.1-SNAPSHOT (main branch); bug also present in 2.0.0

Description

Background

SandboxBackedFilesystem and SandboxLifecycleMiddleware stored per-call sandbox state in agent-level single slots (a volatile Sandbox field on the filesystem proxy and an AtomicReference<SandboxAcquireResult> on the middleware), implicitly assuming at most one in-flight call per agent instance.

However, a single shared agent bean serving many users/conversations is a common setup, and the SDK's serializeOnKey only serializes calls of the same (userId, sessionId) — cross-session calls run in parallel by design. When two calls run concurrently on one agent, both slots get corrupted in three ways:

  • a later call overwrote the earlier call's sandbox, silently routing its filesystem/tool operations into the wrong sandbox (cross-session file leakage);
  • a finishing call nulled the slot while another call was mid-run, failing it with SandboxConfigurationException: No active sandbox;
  • releaseForCall (getAndSet(null)) could stop the other call's sandbox session and leak its own (never released).

We hit the second failure mode in production at scale: an agent dispatching IM group messages across many conversations had a large fraction of runs fail with No active sandbox whenever two conversations were active at once. The only workaround was a custom SandboxExecutionGuard serializing all calls, degrading cross-session parallelism to FIFO.

Changes

  • SandboxLifecycleMiddleware: removed the agent-level currentAcquireResult slot. acquireForCall(ctx) now binds the acquired Sandbox and SandboxAcquireResult to the per-call RuntimeContext (ctx.put); releaseForCall(ctx) retrieves the acquire result from its own context, so persist/release/lease-close are strictly paired per call.
  • SandboxBackedFilesystem: requireSandbox(ctx) resolves the sandbox from the caller's RuntimeContext first, so concurrent calls each see their own sandbox. The agent-level SandboxAware slot is kept only as a fallback for context-less internal paths (e.g. RuntimeContext.empty() maintenance writes in SkillUsageStore / SkillCurator / SkillAuditLog), backed by an AtomicReference with a new CAS-based clearSandbox(expected) so a finishing call never wipes a newer call's binding.
  • No public API removed: SandboxAware#setSandbox/getSandbox behave as before for existing callers. A follow-up could thread a per-call context through the remaining context-less paths and then deprecate the fallback slot.

How to test

  • New regression test SandboxLifecycleMiddlewarePerCallBindingTest covers: concurrent calls resolving their own sandbox, a finishing call not disturbing (nor releasing the sandbox of) a still-running call, and idempotent release per context.
  • Extended SandboxBackedFilesystemTest with context-priority resolution and CAS-clear semantics.
  • Full agentscope-harness suite: 679 tests, 0 failures.

Checklist

Please check the following items before code is ready to be reviewed.

  • Code has been formatted with mvn spotless:apply
  • All tests are passing (mvn test)
  • Javadoc comments are complete and follow project conventions
  • Related documentation has been updated (e.g. links, examples, etc.) — N/A, internal concurrency fix with no doc-facing behavior change
  • Code is ready for review

…urrent-call corruption

SandboxBackedFilesystem and SandboxLifecycleMiddleware stored per-call
sandbox state in agent-level single slots (a volatile Sandbox field and
an AtomicReference<SandboxAcquireResult>), assuming at most one in-flight
call per agent instance. Concurrent calls on one agent (different
sessions run in parallel by design) corrupted both slots:

- a later call overwrote the earlier call's sandbox, routing its
  filesystem/tool operations into the wrong sandbox;
- a finishing call nulled the slot while another call was mid-run,
  failing it with "No active sandbox";
- release could stop the other call's sandbox session and leak its own.

Fix: bind the acquired Sandbox and SandboxAcquireResult to the per-call
RuntimeContext (ctx.put) at acquire time, resolve the sandbox from the
caller's RuntimeContext in the filesystem proxy, and pair release
strictly through the same context. The agent-level SandboxAware slot is
kept only as a fallback for context-less internal paths (e.g.
RuntimeContext.empty() maintenance writes) and is now CAS-cleared so a
finishing call never wipes a newer call's binding.
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.00000% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...s/agent/middleware/SandboxLifecycleMiddleware.java 58.33% 4 Missing and 1 partial ⚠️
...nt/filesystem/sandbox/SandboxBackedFilesystem.java 91.66% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant