From 3403272569ba3a5f88bfddddc98851d417739214 Mon Sep 17 00:00:00 2001
From: Buktal <1171971708@qq.com>
Date: Wed, 19 Aug 2026 10:41:37 +0800
Subject: [PATCH] fix(harness): keep persisted snapshot id when resume falls
back to fresh create
When the Priority 3 resume fails (e.g. KubernetesSandboxClient.resume()
throws because its claim/pod was terminated on the previous release), the
Priority 4 fresh create built a brand-new snapshot id: the workspace
silently reset to the spec on every call and each previous archive was
orphaned. Carry the persisted snapshot id over to the fallback sandbox so
Sandbox#start() restores the workspace from the previous archive (start
Branch C) and Sandbox#stop() overwrites that same archive. The snapshot
is rebuilt from the current spec, so it carries a bound client regardless
of how the persisted state deserialized.
Fixes #2771
---
.../harness/agent/sandbox/SandboxManager.java | 38 +++++++++++++-
.../sandbox/SandboxManagerIsolationTest.java | 50 +++++++++++++++++++
2 files changed, 87 insertions(+), 1 deletion(-)
diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxManager.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxManager.java
index ec10dfeabf..a2baead8dc 100644
--- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxManager.java
+++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxManager.java
@@ -16,6 +16,7 @@
package io.agentscope.harness.agent.sandbox;
import io.agentscope.core.agent.RuntimeContext;
+import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec;
import java.util.Objects;
import java.util.Optional;
import org.slf4j.Logger;
@@ -26,7 +27,9 @@
*
*
Acquire priority: {@link SandboxContext#getExternalSandbox()} > {@link
* SandboxContext#getExternalSandboxState()} > persisted {@link SandboxState} > {@link
- * SandboxClient#create}.
+ * SandboxClient#create}. When a persisted state exists but the resume fails, the fresh-create
+ * fallback keeps the persisted snapshot id so the workspace restores from the previous archive
+ * instead of silently resetting.
*
*
When a {@link SandboxExecutionGuard} is configured, the manager acquires an execution
* {@link SandboxLease} before sandbox resume/create for isolation keys that are present. The
@@ -95,6 +98,7 @@ public SandboxAcquireResult acquire(
}
try {
+ String persistedSnapshotId = null;
if (scopeKey.isPresent()) {
try {
Optional stateJson = stateStore.load(scopeKey.get());
@@ -109,6 +113,9 @@ public SandboxAcquireResult acquire(
if (sandboxContext.getWorkspaceSpec() != null) {
state.setWorkspaceSpec(sandboxContext.getWorkspaceSpec().copy());
}
+ if (state.getSnapshot() != null) {
+ persistedSnapshotId = state.getSnapshot().getId();
+ }
Sandbox sandbox = client.resume(state);
return SandboxAcquireResult.selfManaged(sandbox, lease);
}
@@ -136,6 +143,8 @@ public SandboxAcquireResult acquire(
spec,
sandboxContext.getSnapshotSpec(),
sandboxContext.getClientOptions());
+ carryOverPersistedSnapshotId(
+ sandbox, persistedSnapshotId, sandboxContext.getSnapshotSpec());
return SandboxAcquireResult.selfManaged(sandbox, lease);
} catch (Exception e) {
@@ -145,6 +154,33 @@ public SandboxAcquireResult acquire(
}
}
+ /**
+ * Keeps workspace continuity when a failed resume falls through to a fresh create: points
+ * the new sandbox at the snapshot id persisted for this scope, so {@link Sandbox#start()}
+ * restores the workspace from the previous archive instead of silently resetting it, and
+ * {@link Sandbox#stop()} overwrites that same archive instead of orphaning it. The snapshot
+ * is rebuilt from the current spec, so it carries a bound client regardless of how the
+ * persisted state deserialized.
+ */
+ private static void carryOverPersistedSnapshotId(
+ Sandbox sandbox, String persistedSnapshotId, SandboxSnapshotSpec snapshotSpec) {
+ SandboxState state = sandbox.getState();
+ if (persistedSnapshotId == null
+ || persistedSnapshotId.isBlank()
+ || snapshotSpec == null
+ || state == null
+ || state.getSnapshot() == null
+ || persistedSnapshotId.equals(state.getSnapshot().getId())) {
+ return;
+ }
+ log.info(
+ "[sandbox] Resume fell back to fresh create; carrying over snapshot id {} (fresh"
+ + " id was {})",
+ persistedSnapshotId,
+ state.getSnapshot().getId());
+ state.setSnapshot(snapshotSpec.build(persistedSnapshotId));
+ }
+
public void release(SandboxAcquireResult result) {
if (result == null) {
return;
diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxManagerIsolationTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxManagerIsolationTest.java
index 5b0b4c2845..bacb22a754 100644
--- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxManagerIsolationTest.java
+++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxManagerIsolationTest.java
@@ -26,6 +26,7 @@
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.harness.agent.IsolationScope;
+import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshot;
import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
@@ -175,6 +176,55 @@ void priority3_stateStoreHit_passesSnapshotSpecToDeserialize() throws Exception
verify(client).deserializeState(STATE_JSON, snapshotSpec);
}
+ // ---- Priority 3 resume failure → Priority 4 fresh create keeps persisted snapshot id ----
+
+ @Test
+ void priority3_resumeFails_freshCreateCarriesOverPersistedSnapshotId() throws Exception {
+ SandboxSnapshot persistedSnapshot = mock(SandboxSnapshot.class);
+ SandboxSnapshot freshSnapshot = mock(SandboxSnapshot.class);
+ SandboxSnapshot carriedOverSnapshot = mock(SandboxSnapshot.class);
+ SandboxState freshState = mock(SandboxState.class);
+ when(persistedSnapshot.getId()).thenReturn("persisted-id");
+ when(freshSnapshot.getId()).thenReturn("fresh-id");
+ when(resumedState.getSnapshot()).thenReturn(persistedSnapshot);
+ when(freshSandbox.getState()).thenReturn(freshState);
+ when(freshState.getSnapshot()).thenReturn(freshSnapshot);
+ when(snapshotSpec.build("persisted-id")).thenReturn(carriedOverSnapshot);
+
+ SandboxAcquireResult result = acquireAfterFailedResume();
+
+ assertSame(freshSandbox, result.getSandbox());
+ verify(freshState).setSnapshot(carriedOverSnapshot);
+ }
+
+ @Test
+ void priority3_resumeFails_noPersistedSnapshot_keepsFreshSnapshot() throws Exception {
+ SandboxState freshState = mock(SandboxState.class);
+ when(freshSandbox.getState()).thenReturn(freshState);
+
+ SandboxAcquireResult result = acquireAfterFailedResume();
+
+ assertSame(freshSandbox, result.getSandbox());
+ verify(freshState, never()).setSnapshot(any());
+ verify(snapshotSpec, never()).build(any());
+ }
+
+ /** Session-scope acquire where persisted state exists but resume fails (claim/pod gone). */
+ private SandboxAcquireResult acquireAfterFailedResume() throws Exception {
+ when(stateStore.load(any())).thenReturn(Optional.of(STATE_JSON));
+ when(client.deserializeState(STATE_JSON, snapshotSpec)).thenReturn(resumedState);
+ when(client.resume(resumedState)).thenThrow(new RuntimeException("claim gone"));
+ when(client.create(any(), any(), any())).thenReturn(freshSandbox);
+
+ RuntimeContext rtx = RuntimeContext.builder().sessionId("sess-1").build();
+ SandboxContext sCtx =
+ SandboxContext.builder()
+ .isolationScope(IsolationScope.SESSION)
+ .snapshotSpec(snapshotSpec)
+ .build();
+ return manager.acquire(sCtx, rtx);
+ }
+
@Test
void userScope_withUserId_acquiresExecutionGuardWithUserKey() throws Exception {
AtomicReference capturedKey = new AtomicReference<>();