Skip to content

feat(sandbox): retain E2B sandboxes between calls - #3413

Open
Shay-Deng wants to merge 4 commits into
agentscope-ai:mainfrom
Shay-Deng:feat/e2b-retain-sandbox-3390
Open

Shay-Deng wants to merge 4 commits into
agentscope-ai:mainfrom
Shay-Deng:feat/e2b-retain-sandbox-3390

Conversation

@Shay-Deng

@Shay-Deng Shay-Deng commented Oct 3, 2026 •

Copy link
Copy Markdown

AgentScope-Java Version

2.0.4-SNAPSHOT

Description

SDK-managed E2B sandboxes are destroyed after every call, forcing creation and workspace restoration on the next call. This adds SandboxReleasePolicy.RETAIN through the filesystem specification and sandbox lifecycle while keeping DELETE as the default. Retained calls still save their configured snapshot and reconnect to the same instance until provider expiry or explicit deletion.

  • Add explicit owned-instance deletion without starting the sandbox or taking a final snapshot; HTTP 404 is treated as already deleted.
  • Prune native snapshots after successful retained releases and explicit destruction, retaining failed/locked IDs for retry.
  • Recreate on connect HTTP 404 only. Preserve existing instances on transport/probe failures and clean up only allocations made by a failed start.
  • Preserve ready metadata only for failed read-only probes of existing instances. Failures during snapshot restoration, workspace entries, or projection leave the workspace unready and invalidate projection metadata, so retries with the same state do not trust a partially initialized tree.
  • Rebind E2B remote snapshots to the configured storage client while preserving snapshot IDs.
  • Document caller-owned persistence for explicit state without an isolation key, with a save/resume/delete example and regression test. Clarify local isRunning() semantics, non-owned deletion, and share the workspace-probe timeout.

E2B is the first supported backend; other clients reject RETAIN. Callers must ensure one writer per isolation key; the default guard remains a no-op. Explicit-state acquisition bypasses the harness guard and, without an isolation key, requires the caller to durably save updated state after release. Durable records and finite provider timeouts are necessary: crashes before saving an ID, record eviction, or key changes can orphan billed instances until provider expiry. There is no automatic instance discovery or reconciliation. Snapshot pruning is best effort, and explicit deletion does not erase all backups or state-store records. PAUSE is out of scope. English and Chinese docs describe these boundaries.

Validation

  • Full local mvn -B -T1 clean verify passed across all 96 modules (JDK 21, Java 17 compilation target): 9,892 tests, 0 failures, 0 errors, 212 skips. Totals include skipped cases.
  • 51 retention regression cases passed, including 5 added in this review update. Three new mutation/retry cases were reproduced failing before the fix and passing afterward. The complete E2B suite contains 87 passing tests.
  • Local JaCoCo reports cover all 119 changed executable lines and all 50 branches on changed lines.
  • Spotless, Javadoc generation and git diff --check passed.
  • Documentation: 12 tests passed, strict build validation and broken-link checks passed.

Tests use local HTTP fixtures and simulated guest/storage operations. No real E2B, Alibaba Cloud or Redis integration, or latency benchmark, was run.

Closes #3390. E2B-specific snapshot rebinding covers the path discussed in #2303 / #2423 without changing the generic implementation. Same-key coordination remains separate from #2846.

Checklist

  • Code has been formatted with mvn spotless:apply
  • All tests are passing — verification results and skipped cases noted above
  • Javadoc comments are complete and follow project conventions
  • Related documentation has been updated
  • Code is ready for review

@CLAassistant

CLAassistant commented Oct 3, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@codecov

codecov Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Adds SandboxReleasePolicy (DELETE default / RETAIN) through SandboxFilesystemSpec → SandboxContext → SandboxAcquireResult → SandboxManager, and implements E2B-side retention (non-destructive delete(), onRetained() pruning, start-failure isolation, 404-only recreation, remote-snapshot rebinding on resume). The design is careful and the risk modelling is explicit: RETAIN is gated on supportsRetention() plus a stable isolation key, failed starts only clean up the allocation made by that start, transport errors preserve the existing ID instead of recreating, and a failed probe no longer triggers a stale restore. Test coverage is strong (3 new suites, ~1100 lines) and EN/ZH docs are updated in lockstep.

Three points I would like resolved before approval:

  1. RETAIN can strand a billed instance with no state-store key — Priority 2 (explicit external state) bypasses the new scope-key guard, so the retained ID is never persisted and becomes unreachable.
  2. workspaceRootReady restore is broader than intended — it also covers failures that happen after the workspace was already mutated, which can leave a half-initialized tree marked as intact (next call takes Branch A and skips the restore).
  3. No reconciliation for retained instances that outlive their state entry — process death / key change leaves the sandbox running with nothing able to find or delete it.

Also: license/cla is still pending and both build jobs are still queued, so this is a COMMENT rather than an approval regardless of the findings above.

Nice touch: quoting the workspace root in the probe and threading the HTTP status separately instead of parsing error messages — that removes a whole class of "infer expiry from text" bugs.


Automated review by github-manager-bot

"[sandbox] Priority 2: resuming from explicit state: {}",
sandboxContext.getExternalSandboxState().getSessionId());
return SandboxAcquireResult.selfManaged(sandbox);
return SandboxAcquireResult.selfManaged(sandbox, SandboxLease.noop(), releasePolicy);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] Priority 2 (user-supplied state) returns before the RETAIN requires a stable sandbox isolation key guard added below (line ~102), so RETAIN + externalSandboxState + no resolvable scope key is accepted here. SandboxManager.persistState resolves the scope key itself and returns early when it is absent, so the newly retained sandboxId is never written to the state store — the E2B instance stays alive and billed, but nothing can ever reconnect to it or delete it through the SDK. Either apply the same scope-key requirement to this branch, or document/enforce that callers using RETAIN with an explicit state must persist the returned state themselves.

// AbstractBaseSandbox marks failed starts unready. Preserve the previous metadata
// when retrying the same state object, so a transient error does not force a stale
// snapshot restore over an existing workspace.
e2bState.setWorkspaceRootReady(workspaceReadyBeforeStart);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] Preserving workspaceRootReady whenever sandboxCreatedDuringStart == null also covers failures that happen after the workspace was already mutated. Reaching super.start() with workspaceRootReady == true and a probe that returns true still runs applyWorkspaceSpec(...)/projection work; if that throws mid-way, the tree is partially initialized while the restored flag now claims it is intact. On the next call Branch A applies only ephemeral entries, so a half-restored workspace is silently treated as good. Could the preserve be narrowed to the pre-mutation phase (failure from ensureSandbox() or the probe) — e.g. a workspaceMutated flag set right before doSetupWorkspace()/doHydrateWorkspace()/applyWorkspaceSpec() — and keep the old setWorkspaceRootReady(false) behaviour for everything after that point?

/** Persist the workspace and destroy the sandbox (the compatibility default). */
DELETE,
/**
* Persist the workspace and retain the sandbox for reuse until provider expiry or explicit

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] RETAIN's only cleanup paths are onRetained() (same JVM, same state object) and delete()/shutdown() with an in-memory handle. If the process dies, the state-store entry is evicted, or the isolation key changes, the retained instance is orphaned until provider expiry with no way to find it — a cost/leak risk for a feature aimed at long-running agents. A cheap mitigation would be to tag created sandboxes with the isolation key/metadata (E2B supports sandbox metadata) and expose a best-effort reconciliation/list-and-delete helper, or at minimum state this limitation in the enum javadoc + docs/harness/sandbox.md so operators know a crashed worker can leave billed instances behind.

}
try {
// Do not stop/snapshot here: explicit deletion must also work after provider expiry.
sandbox.shutdown();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] delete() now delegates to sandbox.shutdown(), which returns immediately when !e2bState.isSandboxOwned(), so deleting a non-owned/external sandbox is a silent no-op while the method's javadoc reads as an unconditional destroy. Worth saying "only when the SDK owns the instance" in the javadoc. Minor: the failure is wrapped as a generic SandboxRuntimeException(WORKSPACE_STOP_ERROR, ...) although the existing SandboxException.WorkspaceStopException carries the same code plus the cause type — using it would keep error handling consistent with the rest of the backend.

@Override
protected boolean probeWorkspaceRootForPreservedResume() {
try {
ExecResult result = doExec(null, "test -d " + shellSingleQuote(getWorkspaceRoot()), 10);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] The 10 here duplicates the private AbstractBaseSandbox.PROBE_TIMEOUT_SECONDS constant (also 10), and the javadoc of that constant ({@value #PROBE_TIMEOUT_SECONDS}) will drift from this override. Making the constant protected and reusing it would avoid the divergence. The shellSingleQuote(...) addition is a genuine improvement over the base implementation, which interpolates the root unquoted.

@Override
public boolean isRunning() {
// Explicit deletion bypasses stop() so it never uploads a final workspace snapshot.
return super.isRunning() && e2bState.getSandboxId() != null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] AbstractBaseSandbox.stop() already sets running = false, so a retained sandbox reports isRunning() == false right after a successful RETAIN release, and this override adds a second condition that is only false in the never-started case. If isRunning() is meant to mean "the remote instance is reachable", consider a distinct method (e.g. hasRemoteInstance()); as written the two semantics are conflated and callers cannot tell "retained and alive" from "gone".

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Re-reviewed after c750376a. This push resolves the substance of the previous pass, and does it in the right place — code, not just prose.

What changed and what I verified:

  • The over-broad metadata preservation is fixed properly. workspaceProbeFailed is set only from the catch in probeWorkspaceRootForPreservedResume(), so the "preserve the pre-start ready flag and projection hash" branch now covers exactly the failures that provably cannot have touched the workspace, and everything past the probe falls into invalidation. Nulling the projection hash alongside the workspaceRootReady=false that AbstractBaseSandbox.start() already applies is the correct pairing.
  • Hoisting ensureSandbox() out of the try does not strand a new allocation, and the 404-expiry recreate still converges on retry. Checked both.
  • A non-0/1 probe exit still routes through the same catch, so abnormalProbeExitPreservesWorkspaceAndCanRetry keeps its semantics.
  • The externalSandboxState + RETAIN + no-key case and the orphaned-instance case are now documented contracts with a worked EN/ZH example and a regression test, rather than enforced. That is an acceptable v1 for the API shape; my one remaining ask is on observability, inline below.
  • The smaller items from last time are all taken: PROBE_TIMEOUT_SECONDS widened to protected and reused instead of a second literal 10, isRunning() documented as local-handle state rather than a liveness probe, delete() stating that non-owned instances are left unchanged.

Not approving on this pass for one reason only: build (ubuntu-latest) and build (windows-latest) are still queued at c750376a (validate, Check License, Module Sync are green, license/cla is now signed). Code-wise nothing here blocks me — once the two build jobs go green this is an approve, and if the inline log.warn lands before then, even better.

Thanks for the responsive turnaround, and for the parameterized mutation-stage test — asserting that first.txt survives in the entries case is the kind of check that makes the semantics real rather than claimed.


Automated review by github-manager-bot

"[sandbox] Priority 2: resuming from explicit state: {}",
sandboxContext.getExternalSandboxState().getSessionId());
return SandboxAcquireResult.selfManaged(sandbox);
return SandboxAcquireResult.selfManaged(sandbox, SandboxLease.noop(), releasePolicy);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] The only one of the three points from the previous pass that is now resolved by documentation instead of by code. Priority 2 still returns here, ahead of the RETAIN requires a stable sandbox isolation key guard at ~line 100, so RETAIN + externalSandboxState with no resolvable key is accepted and the harness deliberately never writes the record — the current sandboxId exists only in the caller's object.

I am no longer asking you to change the API shape: the new externalSandboxState() javadoc, the Lost state records bullet and the worked EN/ZH example state the contract unambiguously, and callerCanPersistExplicitStateResumeAndDeleteWithoutIsolationKey pins exactly the caller-side sequence the docs prescribe. That is a defensible v1.

The residual gap is observability, not behavior. If a caller ignores the contract, the symptom is a billed instance nothing can find plus a workspace the next call cannot resume, and the only trace of the decision is a paragraph in the docs — log.debug here would not even show it at default level, because this branch is a successful acquire. A single log.warn on the conjunction releasePolicy == RETAIN && externalSandboxState != null && scopeKey.isEmpty() converts a silent footgun into a diagnosable one, at the cost of one line. Non-blocking, but I would like it before this becomes the documented pattern other callers copy.

try {
super.start();
} catch (Exception e) {
if (workspaceProbeFailed && sandboxCreatedDuringStart == null) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] Correct fix for the over-broad metadata preservation, and the narrowing holds in both directions.

workspaceProbeFailed is reachable only from the catch in probeWorkspaceRootForPreservedResume(), i.e. from the read-only test -d before anything is written, so the preserve branch now covers exactly the failures that provably cannot have mutated the tree. Everything later (doSetupWorkspace, snapshot.restore, spec apply, projection apply) falls into the else branch, and nulling the projection hash there is the right pairing with the workspaceRootReady=false AbstractBaseSandbox.start() already applied — otherwise a retry could take Branch A off a stale hash and skip reapplying a projection over a restored snapshot.

Three edge cases I traced and found clean, so you do not have to re-check them:

  • a non-0/1 probe exit throws ExecException inside the try, is caught by that same catch, so the flag is still set and abnormalProbeExitPreservesWorkspaceAndCanRetry keeps its meaning;
  • hoisting ensureSandbox() out of the try does not strand a new allocation: if applyDefaultDomain() throws after sandboxCreatedDuringStart is set, SandboxLifecycleMiddleware's catch still routes RETAIN failures through SandboxManager.discard() → cleanupAfterStartFailure(), which kills only that id; and metadata is left untouched, which is precisely the outcome you want for a connect failure;
  • if the 404-expiry recreate inside ensureSandbox() throws after already clearing ready/projection, the next attempt keeps the old expired id, re-connects, gets 404 again and recreates — it converges, and re-projection is forced rather than skipped.

failedProbeOfNewAllocationDoesNotRestoreStaleReadyMetadata is the case I was actually worried about (a fresh allocation inheriting ready=true from the instance it replaced) and the parameterized retryAfterWorkspaceMutationFailureRestoresInsteadOfTrustingPartialTree covers the three mutation stages separately, including the entries case where first.txt is deliberately asserted to survive. Good tests.

*
* <p>Retain durable state records and configure a finite provider timeout. A crash before state
* is saved, state eviction or an isolation-key change can orphan a running, billed instance
* until provider expiry. There is no automatic instance discovery or reconciliation. Explicit

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] Accepting the documentation resolution for the orphan case. Saying plainly that there is no automatic instance discovery or reconciliation, next to concrete mitigations (durable record, finite sandboxTimeoutSeconds, delete before evicting the record), is worth more to an operator than an implied guarantee would have been, and EN and ZH say the same thing here.

If the maintainers later want this closed rather than documented, the seam is on the client, not in this enum: tag the instance at create time with agentId + isolation key and let a startup sweep reconcile GET /sandboxes against the state store, deleting what has no record. Worth a follow-up issue — the docs you added are the right place to link it from. Not something to change in this PR.


/**
* Returns whether this local handle is between successful start and stop, and has not been
* explicitly deleted. This is not a remote liveness probe: a retained instance can still exist

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] Closing the loop on the two Info items from the previous pass:

  • this javadoc is the fix I was after for isRunning() — AbstractBaseSandbox.stop() clears running on a successful RETAIN, so the method reports local handle state and not remote existence, and saying so here is better than making the method try to be a liveness probe. The new retention test asserts the same semantics from outside (assertFalse(first.isRunning(), ...) with the remote instance still alive), which keeps the wording honest;
  • PROBE_TIMEOUT_SECONDS going private → protected and being referenced here instead of a second 10 removes the silent-coupling risk, and AgentRunSandbox's override keeps its own literal, so nothing else moved.

delete()'s added Non-owned instances are left unchanged line answers the last one too.

This branch has not been deployed

No deployments
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.

[Feature]: Option to retain (or pause) sandboxes between calls instead of deleting them on every release

3 participants