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<>();