From f28e0a0396addc69ee7a30ea945144fa73955d04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9F=E5=AE=9D=E5=9D=A4?= Date: Thu, 18 Jun 2026 10:08:19 +0800 Subject: [PATCH 1/8] feat(sandbox): add keepAlive config to SandboxFilesystemSpec and SandboxContext --- .../spec/SandboxFilesystemSpec.java | 20 +++++++++++++++++++ .../harness/agent/sandbox/SandboxContext.java | 12 +++++++++++ 2 files changed, 32 insertions(+) diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/SandboxFilesystemSpec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/SandboxFilesystemSpec.java index 205107951f..6efd752806 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/SandboxFilesystemSpec.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/SandboxFilesystemSpec.java @@ -46,6 +46,7 @@ public abstract class SandboxFilesystemSpec { private SandboxExecutionGuard executionGuard; private boolean workspaceProjectionEnabled = true; private List workspaceProjectionRoots = DEFAULT_WORKSPACE_PROJECTION_ROOTS; + private boolean keepAlive = false; protected abstract SandboxClient createClient(); @@ -107,6 +108,24 @@ public SandboxFilesystemSpec workspaceProjectionRoots(List includeRoots) return this; } + /** + * When {@code true}, {@link io.agentscope.harness.agent.sandbox.SandboxManager#release} + * calls {@link io.agentscope.harness.agent.sandbox.Sandbox#stop()} (persisting the + * snapshot) but skips {@link io.agentscope.harness.agent.sandbox.Sandbox#shutdown()}, + * leaving the underlying resource (e.g. Pod) alive for reuse in the next call. + * + * @param keepAlive true to preserve the sandbox resource across calls + * @return this spec + */ + public SandboxFilesystemSpec keepAlive(boolean keepAlive) { + this.keepAlive = keepAlive; + return this; + } + + public boolean isKeepAlive() { + return keepAlive; + } + public final SandboxContext toSandboxContext(Path hostWorkspaceRoot) { SandboxClient client = Objects.requireNonNull(createClient(), "sandbox client is required"); @@ -117,6 +136,7 @@ public final SandboxContext toSandboxContext(Path hostWorkspaceRoot) { .snapshotSpec(snapshotSpecOverride != null ? snapshotSpecOverride : snapshotSpec()) .workspaceSpec(withProjection) .isolationScope(isolationScope) + .keepAlive(keepAlive) .build(); } diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxContext.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxContext.java index f1ce071e83..a12f6b33e6 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxContext.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxContext.java @@ -33,6 +33,7 @@ public final class SandboxContext { private final Sandbox externalSandbox; private final SandboxState externalSandboxState; private final IsolationScope isolationScope; + private final boolean keepAlive; private SandboxContext(Builder builder) { this.client = builder.client; @@ -42,6 +43,7 @@ private SandboxContext(Builder builder) { this.externalSandbox = builder.externalSandbox; this.externalSandboxState = builder.externalSandboxState; this.isolationScope = builder.isolationScope; + this.keepAlive = builder.keepAlive; } public SandboxClient getClient() { @@ -72,6 +74,10 @@ public IsolationScope getIsolationScope() { return isolationScope; } + public boolean isKeepAlive() { + return keepAlive; + } + public static Builder builder() { return new Builder(); } @@ -85,6 +91,7 @@ public static final class Builder { private Sandbox externalSandbox; private SandboxState externalSandboxState; private IsolationScope isolationScope; + private boolean keepAlive = false; private Builder() {} @@ -123,6 +130,11 @@ public Builder isolationScope(IsolationScope isolationScope) { return this; } + public Builder keepAlive(boolean keepAlive) { + this.keepAlive = keepAlive; + return this; + } + public SandboxContext build() { return new SandboxContext(this); } From 2b76596fc2d367c619662cb297b72780013ce673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9F=E5=AE=9D=E5=9D=A4?= Date: Thu, 18 Jun 2026 10:12:40 +0800 Subject: [PATCH 2/8] feat(sandbox): keepAlive mode skips shutdown() on release --- .../SandboxLifecycleMiddleware.java | 4 +-- .../harness/agent/sandbox/SandboxManager.java | 13 ++++++---- .../sandbox/SandboxManagerIsolationTest.java | 26 +++++++++++++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddleware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddleware.java index b2314cb8ba..3d0d230be4 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddleware.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddleware.java @@ -91,7 +91,7 @@ public void acquireForCall(RuntimeContext ctx) { } catch (Exception e) { filesystemProxy.setSandbox(null); try { - sandboxManager.release(result); + sandboxManager.release(result, sandboxContext); } catch (Exception releaseErr) { log.warn( "[sandbox-mw] Failed to release session after pre-call failure: {}", @@ -125,7 +125,7 @@ public void releaseForCall(RuntimeContext ctx) { log.warn("[sandbox-mw] Failed to persist sandbox state: {}", e.getMessage(), e); } try { - sandboxManager.release(result); + sandboxManager.release(result, sandboxContext); } catch (Exception e) { log.warn("[sandbox-mw] Failed to release sandbox session: {}", e.getMessage(), e); } 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 7c0e04ceb9..ffb7eb18ce 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 @@ -139,7 +139,7 @@ public SandboxAcquireResult acquire( } } - public void release(SandboxAcquireResult result) { + public void release(SandboxAcquireResult result, SandboxContext sandboxContext) { if (result == null) { return; } @@ -162,10 +162,13 @@ public void release(SandboxAcquireResult result) { log.warn("[sandbox] Sandbox stop failed: {}", e.getMessage(), e); } - try { - sandbox.shutdown(); - } catch (Exception e) { - log.warn("[sandbox] Sandbox shutdown failed: {}", e.getMessage(), e); + boolean keepAlive = sandboxContext != null && sandboxContext.isKeepAlive(); + if (!keepAlive) { + try { + sandbox.shutdown(); + } catch (Exception e) { + log.warn("[sandbox] Sandbox shutdown failed: {}", e.getMessage(), e); + } } } 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 bcf81578f2..42b67be1c3 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 @@ -268,4 +268,30 @@ void clearState_deletesFromStore() throws Exception { verify(stateStore).delete(any()); } + + // ---- keepAlive: stop() called, shutdown() skipped ---- + + @Test + void keepAlive_true_stopsButDoesNotShutdown() throws Exception { + Sandbox sandbox = mock(Sandbox.class); + SandboxAcquireResult result = SandboxAcquireResult.selfManaged(sandbox); + SandboxContext ctx = SandboxContext.builder().keepAlive(true).build(); + + manager.release(result, ctx); + + verify(sandbox).stop(); + verify(sandbox, never()).shutdown(); + } + + @Test + void keepAlive_false_stopsAndShutdown() throws Exception { + Sandbox sandbox = mock(Sandbox.class); + SandboxAcquireResult result = SandboxAcquireResult.selfManaged(sandbox); + SandboxContext ctx = SandboxContext.builder().keepAlive(false).build(); + + manager.release(result, ctx); + + verify(sandbox).stop(); + verify(sandbox).shutdown(); + } } From dd2fa6c5f639724c0551880ceca7a9643ba63eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9F=E5=AE=9D=E5=9D=A4?= Date: Thu, 18 Jun 2026 10:35:58 +0800 Subject: [PATCH 3/8] fix(sandbox): re-inject RemoteSandboxSnapshot client in KubernetesSandboxClient.resume() --- .../kubernetes/KubernetesFilesystemSpec.java | 7 ++- .../kubernetes/KubernetesSandboxClient.java | 23 +++++++++- .../KubernetesSandboxStateSerdeTest.java | 43 +++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes/src/main/java/io/agentscope/extensions/sandbox/kubernetes/KubernetesFilesystemSpec.java b/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes/src/main/java/io/agentscope/extensions/sandbox/kubernetes/KubernetesFilesystemSpec.java index c03de884c7..9c2b508cd7 100644 --- a/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes/src/main/java/io/agentscope/extensions/sandbox/kubernetes/KubernetesFilesystemSpec.java +++ b/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes/src/main/java/io/agentscope/extensions/sandbox/kubernetes/KubernetesFilesystemSpec.java @@ -99,7 +99,12 @@ public KubernetesFilesystemSpec workspaceSpec(WorkspaceSpec workspaceSpec) { @Override protected SandboxClient createClient() { - return client != null ? client : options.createClient(); + if (client != null) { + return client; + } + SandboxSnapshotSpec effective = + getSnapshotSpecOverride() != null ? getSnapshotSpecOverride() : snapshotSpec(); + return new KubernetesSandboxClient(options, null, effective); } @Override diff --git a/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes/src/main/java/io/agentscope/extensions/sandbox/kubernetes/KubernetesSandboxClient.java b/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes/src/main/java/io/agentscope/extensions/sandbox/kubernetes/KubernetesSandboxClient.java index 10974bcb9c..a6052df302 100644 --- a/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes/src/main/java/io/agentscope/extensions/sandbox/kubernetes/KubernetesSandboxClient.java +++ b/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes/src/main/java/io/agentscope/extensions/sandbox/kubernetes/KubernetesSandboxClient.java @@ -36,13 +36,14 @@ public class KubernetesSandboxClient implements SandboxClient new RemoteSandboxSnapshot(mockClient, id); + KubernetesSandboxClient client = + new KubernetesSandboxClient( + new KubernetesSandboxClientOptions(), null, snapshotSpec); + + KubernetesSandbox sandbox = (KubernetesSandbox) client.resume(state); + + SandboxSnapshot rebuilt = sandbox.getState().getSnapshot(); + assertNotNull(rebuilt); + assertEquals("snap-id-123", rebuilt.getId()); + assertInstanceOf(RemoteSandboxSnapshot.class, rebuilt); + } } From 69365336fd2a350a2bfcb84743338d6e37d7ce1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9F=E5=AE=9D=E5=9D=A4?= Date: Thu, 18 Jun 2026 10:52:38 +0800 Subject: [PATCH 4/8] test(sandbox): add keepAlive and Docker snapshot re-injection tests --- .../agent/sandbox/SandboxContextTest.java | 52 ++++++++++++++++ .../docker/DockerSandboxStateSerdeTest.java | 60 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxContextTest.java create mode 100644 agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxStateSerdeTest.java diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxContextTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxContextTest.java new file mode 100644 index 0000000000..14d4f65a46 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxContextTest.java @@ -0,0 +1,52 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.harness.agent.sandbox; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.harness.agent.sandbox.impl.docker.DockerFilesystemSpec; +import org.junit.jupiter.api.Test; + +class SandboxContextTest { + + @Test + void keepAliveDefaultsToFalse() { + SandboxContext ctx = SandboxContext.builder().build(); + assertFalse(ctx.isKeepAlive()); + } + + @Test + void keepAliveWhenSetTrueReturnsTrue() { + SandboxContext ctx = SandboxContext.builder().keepAlive(true).build(); + assertTrue(ctx.isKeepAlive()); + } + + @Test + void keepAliveWhenSetFalseReturnsFalse() { + SandboxContext ctx = SandboxContext.builder().keepAlive(false).build(); + assertFalse(ctx.isKeepAlive()); + } + + @Test + void keepAlivePropagatedFromSpecToContext() { + DockerFilesystemSpec spec = new DockerFilesystemSpec(); + // Default is false + assertFalse(spec.toSandboxContext().isKeepAlive()); + // After setting true + assertTrue(spec.keepAlive(true).toSandboxContext().isKeepAlive()); + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxStateSerdeTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxStateSerdeTest.java new file mode 100644 index 0000000000..0a3eb95f51 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxStateSerdeTest.java @@ -0,0 +1,60 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.harness.agent.sandbox.impl.docker; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; + +import io.agentscope.harness.agent.sandbox.WorkspaceSpec; +import io.agentscope.harness.agent.sandbox.snapshot.RemoteSandboxSnapshot; +import io.agentscope.harness.agent.sandbox.snapshot.RemoteSnapshotClient; +import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshot; +import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec; +import org.junit.jupiter.api.Test; + +class DockerSandboxStateSerdeTest { + + @Test + void resumeReInjectsSnapshotClient() { + DockerSandboxState state = new DockerSandboxState(); + state.setSessionId("test-session"); + state.setWorkspaceRoot("/workspace"); + state.setImage("ubuntu:22.04"); + WorkspaceSpec spec = new WorkspaceSpec(); + spec.setRoot("/workspace"); + state.setWorkspaceSpec(spec); + state.setWorkspaceRootReady(false); + + // Simulate deserialization: snapshot has id but client is null + RemoteSandboxSnapshot snapshotWithNullClient = + new RemoteSandboxSnapshot(null, "snap-id-123"); + state.setSnapshot(snapshotWithNullClient); + + // Client with snapshotSpec that can rebuild the client + RemoteSnapshotClient mockClient = mock(RemoteSnapshotClient.class); + SandboxSnapshotSpec snapshotSpec = id -> new RemoteSandboxSnapshot(mockClient, id); + DockerSandboxClient client = new DockerSandboxClient(null, snapshotSpec); + + DockerSandbox sandbox = (DockerSandbox) client.resume(state); + + SandboxSnapshot rebuilt = sandbox.getState().getSnapshot(); + assertNotNull(rebuilt); + assertEquals("snap-id-123", rebuilt.getId()); + assertInstanceOf(RemoteSandboxSnapshot.class, rebuilt); + } +} From 2351e08cbcf50e3198372a65e80c740fe6cf6574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9F=E5=AE=9D=E5=9D=A4?= Date: Thu, 18 Jun 2026 11:29:28 +0800 Subject: [PATCH 5/8] feat(sandbox): add archive() and SandboxIsolationKey package-private constructor --- .../agent/sandbox/SandboxIsolationKey.java | 2 +- .../harness/agent/sandbox/SandboxManager.java | 65 ++ .../impl/docker/DockerFilesystemSpec.java | 7 +- .../impl/docker/DockerSandboxClient.java | 29 +- .../sandbox/SandboxManagerIsolationTest.java | 50 ++ .../plans/2026-06-18-sandbox-keepalive.md | 560 ++++++++++++++++++ 6 files changed, 701 insertions(+), 12 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-18-sandbox-keepalive.md diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxIsolationKey.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxIsolationKey.java index e605232c42..63f78828a4 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxIsolationKey.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxIsolationKey.java @@ -39,7 +39,7 @@ public final class SandboxIsolationKey { private final IsolationScope scope; private final String value; - private SandboxIsolationKey(IsolationScope scope, String value) { + SandboxIsolationKey(IsolationScope scope, String value) { this.scope = scope; this.value = value; } 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 ffb7eb18ce..20f8ec04ae 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.IsolationScope; import java.util.Objects; import java.util.Optional; import org.slf4j.Logger; @@ -212,6 +213,70 @@ public void persistState( } } + /** + * Archives the sandbox state for the given isolation key: loads persisted state, resumes the + * sandbox, stops it (persisting the snapshot), shuts it down (destroying resources), deletes + * the state from the store, and returns the final serialized state for external persistence. + * + *

Intended for out-of-band graceful teardown (e.g. session pause/timeout), where there is + * no active {@link RuntimeContext} and no running {@code HarnessAgent} instance. Callers + * should persist the returned JSON to their own data store (MySQL, audit log, etc.). + * + * @param key the isolation key identifying the state slot to archive + * @return the final serialized sandbox state, or empty if no state was found + */ + public Optional archive(SandboxIsolationKey key) { + try { + Optional stateJson = stateStore.load(key); + if (stateJson.isEmpty()) { + return Optional.empty(); + } + SandboxState state = client.deserializeState(stateJson.get()); + Sandbox sandbox = client.resume(state); + try { + sandbox.stop(); + } catch (Exception e) { + log.warn("[sandbox] Failed to stop sandbox during archive: {}", e.getMessage(), e); + } + try { + sandbox.shutdown(); + } catch (Exception e) { + log.warn( + "[sandbox] Failed to shutdown sandbox during archive: {}", + e.getMessage(), + e); + } + String finalJson = client.serializeState(sandbox.getState()); + stateStore.delete(key); + return Optional.of(finalJson); + } catch (Exception e) { + log.warn("[sandbox] Failed to archive sandbox for key {}: {}", key, e.getMessage(), e); + return Optional.empty(); + } + } + + /** + * Convenience method for archiving a user-scoped sandbox state. + * + * @param userId the user identifier + * @return the final serialized sandbox state, or empty if no state was found + * @see #archive(SandboxIsolationKey) + */ + public Optional archiveForUser(String userId) { + return archive(new SandboxIsolationKey(IsolationScope.USER, userId)); + } + + /** + * Convenience method for archiving a session-scoped sandbox state. + * + * @param sessionId the session identifier + * @return the final serialized sandbox state, or empty if no state was found + * @see #archive(SandboxIsolationKey) + */ + public Optional archiveForSession(String sessionId) { + return archive(new SandboxIsolationKey(IsolationScope.SESSION, sessionId)); + } + public void clearState(SandboxContext sandboxContext, RuntimeContext runtimeContext) { Optional scopeKey = SandboxIsolationKey.resolve( diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerFilesystemSpec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerFilesystemSpec.java index 431874bea7..bce10e5743 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerFilesystemSpec.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerFilesystemSpec.java @@ -98,7 +98,12 @@ public DockerFilesystemSpec workspaceSpec(WorkspaceSpec workspaceSpec) { @Override protected SandboxClient createClient() { - return client != null ? client : options.createClient(); + if (client != null) { + return client; + } + SandboxSnapshotSpec effective = + getSnapshotSpecOverride() != null ? getSnapshotSpecOverride() : snapshotSpec(); + return new DockerSandboxClient(null, effective); } @Override diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxClient.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxClient.java index 7a14f1278f..381a88ea3b 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxClient.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxClient.java @@ -38,21 +38,26 @@ public class DockerSandboxClient implements SandboxClient result = manager.archive(key); + + assertTrue(result.isPresent()); + assertEquals("archived-json", result.get()); + verify(resumedSandbox).stop(); + verify(resumedSandbox).shutdown(); + verify(stateStore).delete(key); + } + + @Test + void archive_stateStoreMiss_returnsEmpty() throws Exception { + when(stateStore.load(any())).thenReturn(Optional.empty()); + + SandboxIsolationKey key = new SandboxIsolationKey(IsolationScope.USER, "user-99"); + Optional result = manager.archive(key); + + assertFalse(result.isPresent()); + } + + @Test + void archiveForUser_delegatesWithCorrectKey() throws Exception { + when(stateStore.load(any())).thenReturn(Optional.empty()); + + manager.archiveForUser("user-42"); + + verify(stateStore).load(any()); + } + + @Test + void archiveForSession_delegatesWithCorrectKey() throws Exception { + when(stateStore.load(any())).thenReturn(Optional.empty()); + + manager.archiveForSession("sess-42"); + + verify(stateStore).load(any()); + } + // ---- keepAlive: stop() called, shutdown() skipped ---- @Test diff --git a/docs/superpowers/plans/2026-06-18-sandbox-keepalive.md b/docs/superpowers/plans/2026-06-18-sandbox-keepalive.md new file mode 100644 index 0000000000..eee9c786ae --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-sandbox-keepalive.md @@ -0,0 +1,560 @@ +# Sandbox keepAlive Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `keepAlive` support to the sandbox framework so that K8s (and Docker) Pods survive across calls — stop()/snapshot runs normally, shutdown() is skipped — while also fixing the `RemoteSandboxSnapshot` client re-injection bug in `resume()`. + +**Architecture:** `keepAlive` is a static config on `SandboxFilesystemSpec`, transparently propagated to `SandboxContext`, and read by `SandboxManager.release()` to conditionally skip `sandbox.shutdown()`. The `RemoteSandboxSnapshot` client null-after-deserialize bug is fixed in `KubernetesSandboxClient.resume()` and `DockerSandboxClient.resume()` by accepting a `SandboxSnapshotSpec` at construction time and calling `snapshotSpec.build(id)` to re-inject the client. + +**Tech Stack:** Java 17, JUnit 5, Mockito (already present in harness test scope) + +--- + +## File Map + +| File | Change | +|------|--------| +| `agentscope-harness/.../filesystem/spec/SandboxFilesystemSpec.java` | add `keepAlive` field + fluent setter + getter | +| `agentscope-harness/.../sandbox/SandboxContext.java` | add `keepAlive` field + builder entry + getter | +| `agentscope-harness/.../filesystem/spec/SandboxFilesystemSpec.java` | propagate `keepAlive` in `toSandboxContext()` | +| `agentscope-harness/.../sandbox/SandboxManager.java` | change `release()` signature to accept `SandboxContext`, skip shutdown when `keepAlive=true` | +| `agentscope-harness/.../middleware/SandboxLifecycleMiddleware.java` | update both `release()` call sites to pass `sandboxContext` | +| `agentscope-harness/.../sandbox/SandboxManagerIsolationTest.java` | add keepAlive test cases | +| `agentscope-harness/.../sandbox/impl/docker/DockerSandboxClient.java` | add `SandboxSnapshotSpec` field + 3-arg constructor; re-inject in `resume()` | +| `agentscope-harness/.../sandbox/impl/docker/DockerFilesystemSpec.java` | pass `snapshotSpec` to `DockerSandboxClient` in `createClient()` | +| `agentscope-extensions/.../kubernetes/KubernetesSandboxClient.java` | add `SandboxSnapshotSpec` field + 3-arg constructor; re-inject in `resume()` | +| `agentscope-extensions/.../kubernetes/KubernetesFilesystemSpec.java` | pass `snapshotSpec` to `KubernetesSandboxClient` in `createClient()` | +| `agentscope-extensions/.../kubernetes/KubernetesSandboxStateSerdeTest.java` | add snapshot re-injection round-trip test | + +--- + +## Task 1: Add `keepAlive` to `SandboxFilesystemSpec` and `SandboxContext` + +**Files:** +- Modify: `agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/SandboxFilesystemSpec.java:44-48,110-121` +- Modify: `agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxContext.java:29-130` + +- [ ] **Step 1: Add `keepAlive` field and fluent setter to `SandboxFilesystemSpec`** + +In `SandboxFilesystemSpec.java`, after the existing field `private boolean workspaceProjectionEnabled = true;` (line 47), add: + +```java +private boolean keepAlive = false; +``` + +After the `workspaceProjectionRoots(...)` method (line 108), add: + +```java +/** + * When {@code true}, {@link io.agentscope.harness.agent.sandbox.SandboxManager#release} + * calls {@link io.agentscope.harness.agent.sandbox.Sandbox#stop()} (persisting the + * snapshot) but skips {@link io.agentscope.harness.agent.sandbox.Sandbox#shutdown()}, + * leaving the underlying resource (e.g. Pod) alive for reuse in the next call. + * + * @param keepAlive true to preserve the sandbox resource across calls + * @return this spec + */ +public SandboxFilesystemSpec keepAlive(boolean keepAlive) { + this.keepAlive = keepAlive; + return this; +} + +public boolean isKeepAlive() { + return keepAlive; +} +``` + +- [ ] **Step 2: Propagate `keepAlive` in `toSandboxContext()`** + +In `SandboxFilesystemSpec.java`, the `toSandboxContext(Path)` method currently builds the context at lines 110-121. Change the builder call to pass `keepAlive`: + +```java +public final SandboxContext toSandboxContext(Path hostWorkspaceRoot) { + SandboxClient client = + Objects.requireNonNull(createClient(), "sandbox client is required"); + WorkspaceSpec withProjection = buildWorkspaceSpecWithProjection(hostWorkspaceRoot); + return SandboxContext.builder() + .client(client) + .clientOptions(clientOptions()) + .snapshotSpec(snapshotSpecOverride != null ? snapshotSpecOverride : snapshotSpec()) + .workspaceSpec(withProjection) + .isolationScope(isolationScope) + .keepAlive(keepAlive) + .build(); +} +``` + +- [ ] **Step 3: Add `keepAlive` field and builder support to `SandboxContext`** + +In `SandboxContext.java`, add `keepAlive` alongside the existing fields (after `isolationScope` at line 35): + +```java +private final boolean keepAlive; +``` + +In the constructor `private SandboxContext(Builder builder)` (line 37), add: + +```java +this.keepAlive = builder.keepAlive; +``` + +Add getter after `getIsolationScope()`: + +```java +public boolean isKeepAlive() { + return keepAlive; +} +``` + +In `Builder` (line 79), add field and fluent method: + +```java +private boolean keepAlive = false; + +public Builder keepAlive(boolean keepAlive) { + this.keepAlive = keepAlive; + return this; +} +``` + +- [ ] **Step 4: Verify compilation** + +```bash +mvn -s "E:\.m2\settings.xml" -pl agentscope-harness -am compile -q +``` + +Expected: BUILD SUCCESS, no errors. + +- [ ] **Step 5: Commit** + +```bash +git add agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/SandboxFilesystemSpec.java +git add agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxContext.java +git commit -m "feat(sandbox): add keepAlive config to SandboxFilesystemSpec and SandboxContext" +``` + +--- + +## Task 2: Update `SandboxManager.release()` to respect `keepAlive` + +**Files:** +- Modify: `agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxManager.java:142-170` +- Modify: `agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddleware.java:116-134` +- Test: `agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxManagerIsolationTest.java` + +- [ ] **Step 1: Write failing tests for keepAlive release behavior** + +Add these two test methods to `SandboxManagerIsolationTest.java`: + +```java +// ---- keepAlive: stop() called, shutdown() skipped ---- + +@Test +void keepAlive_true_stopsButDoesNotShutdown() throws Exception { + Sandbox sandbox = mock(Sandbox.class); + SandboxAcquireResult result = SandboxAcquireResult.selfManaged(sandbox); + SandboxContext ctx = SandboxContext.builder().keepAlive(true).build(); + + manager.release(result, ctx); + + verify(sandbox).stop(); + verify(sandbox, never()).shutdown(); +} + +@Test +void keepAlive_false_stopsAndShutdown() throws Exception { + Sandbox sandbox = mock(Sandbox.class); + SandboxAcquireResult result = SandboxAcquireResult.selfManaged(sandbox); + SandboxContext ctx = SandboxContext.builder().keepAlive(false).build(); + + manager.release(result, ctx); + + verify(sandbox).stop(); + verify(sandbox).shutdown(); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +mvn -s "E:\.m2\settings.xml" -pl agentscope-harness -am test -Dtest=SandboxManagerIsolationTest#keepAlive_true_stopsButDoesNotShutdown+keepAlive_false_stopsAndShutdown -q +``` + +Expected: FAIL — `release(result, ctx)` does not compile yet. + +- [ ] **Step 3: Change `SandboxManager.release()` signature and add keepAlive logic** + +Replace the existing `release(SandboxAcquireResult result)` method (lines 142-170) with: + +```java +public void release(SandboxAcquireResult result, SandboxContext sandboxContext) { + if (result == null) { + return; + } + Sandbox sandbox = result.getSandbox(); + if (sandbox == null) { + return; + } + if (!result.isSelfManaged()) { + return; + } + + try { + sandbox.stop(); + } catch (Exception e) { + log.warn("[sandbox] Sandbox stop failed: {}", e.getMessage(), e); + } + + boolean keepAlive = sandboxContext != null && sandboxContext.isKeepAlive(); + if (!keepAlive) { + try { + sandbox.shutdown(); + } catch (Exception e) { + log.warn("[sandbox] Sandbox shutdown failed: {}", e.getMessage(), e); + } + } +} +``` + +- [ ] **Step 4: Update `SandboxLifecycleMiddleware` — two call sites** + +In `SandboxLifecycleMiddleware.java`: + +**Call site 1** — `acquireForCall()` exception recovery path (around line 94): + +```java +// Before (line 94): +sandboxManager.release(result); + +// After: +sandboxManager.release(result, sandboxContext); +``` + +`sandboxContext` is already extracted at line 77: `SandboxContext sandboxContext = ctx.get(SandboxContext.class);` + +**Call site 2** — `releaseForCall()` normal path (around line 128): + +```java +// Before (line 128): +sandboxManager.release(result); + +// After: +sandboxManager.release(result, sandboxContext); +``` + +`sandboxContext` is already extracted at line 121: `SandboxContext sandboxContext = ctx != null ? ctx.get(SandboxContext.class) : null;` + +- [ ] **Step 5: Run the new tests to verify they pass** + +```bash +mvn -s "E:\.m2\settings.xml" -pl agentscope-harness -am test -Dtest=SandboxManagerIsolationTest -q +``` + +Expected: BUILD SUCCESS, all tests green. + +- [ ] **Step 6: Commit** + +```bash +git add agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxManager.java +git add agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddleware.java +git add agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxManagerIsolationTest.java +git commit -m "feat(sandbox): keepAlive mode skips shutdown() on release" +``` + +--- + +## Task 3: Fix `RemoteSandboxSnapshot` client re-injection in `DockerSandboxClient` + +**Files:** +- Modify: `agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxClient.java:40-56,97-107` +- Modify: `agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerFilesystemSpec.java:100` + +- [ ] **Step 1: Add `snapshotSpec` field and 3-arg constructor to `DockerSandboxClient`** + +Add field after `private final ObjectMapper objectMapper;` (line 40): + +```java +private final SandboxSnapshotSpec snapshotSpec; +``` + +Replace the existing two constructors (lines 42-56) with three: + +```java +public DockerSandboxClient() { + this(null, null); +} + +public DockerSandboxClient(ObjectMapper objectMapper) { + this(objectMapper, null); +} + +/** + * @param objectMapper optional; when null a default mapper is created + * @param snapshotSpec used in {@link #resume} to re-inject the snapshot client after + * deserialization. When null, the snapshot field is left as-is (backward-compatible). + */ +public DockerSandboxClient(ObjectMapper objectMapper, SandboxSnapshotSpec snapshotSpec) { + this.objectMapper = + objectMapper != null + ? objectMapper + : new ObjectMapper() + .findAndRegisterModules() + .registerModule(new HarnessSandboxJacksonModule()); + this.snapshotSpec = snapshotSpec; +} +``` + +- [ ] **Step 2: Re-inject snapshot client in `DockerSandboxClient.resume()`** + +Replace `resume()` (lines 97-107): + +```java +@Override +public Sandbox resume(SandboxState state) { + if (!(state instanceof DockerSandboxState dockerState)) { + throw new IllegalArgumentException( + "Expected DockerSandboxState but got: " + state.getClass().getName()); + } + // Re-inject snapshot client lost during JSON serialization + if (snapshotSpec != null && dockerState.getSnapshot() != null) { + dockerState.setSnapshot(snapshotSpec.build(dockerState.getSnapshot().getId())); + } + log.debug( + "[sandbox-docker] Resuming sandbox: id={}, containerId={}", + dockerState.getSessionId(), + dockerState.getContainerId()); + return new DockerSandbox(dockerState); +} +``` + +- [ ] **Step 3: Pass `snapshotSpec` in `DockerFilesystemSpec.createClient()`** + +In `DockerFilesystemSpec.java`, the `createClient()` method at line 100 currently returns: + +```java +return client != null ? client : options.createClient(); +``` + +Replace with: + +```java +@Override +protected SandboxClient createClient() { + if (client != null) { + return client; + } + SandboxSnapshotSpec effective = + getSnapshotSpecOverride() != null ? getSnapshotSpecOverride() : snapshotSpec(); + return new DockerSandboxClient(null, effective); +} +``` + +- [ ] **Step 4: Verify compilation and existing tests pass** + +```bash +mvn -s "E:\.m2\settings.xml" -pl agentscope-harness -am test -q +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 5: Commit** + +```bash +git add agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxClient.java +git add agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerFilesystemSpec.java +git commit -m "fix(sandbox): re-inject RemoteSandboxSnapshot client in DockerSandboxClient.resume()" +``` + +--- + +## Task 4: Fix `RemoteSandboxSnapshot` client re-injection in `KubernetesSandboxClient` + +**Files:** +- Modify: `agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes/src/main/java/io/agentscope/extensions/sandbox/kubernetes/KubernetesSandboxClient.java:37-64,98-107` +- Modify: `agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes/src/main/java/io/agentscope/extensions/sandbox/kubernetes/KubernetesFilesystemSpec.java:101-103` +- Test: `agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes/src/test/java/io/agentscope/extensions/sandbox/kubernetes/KubernetesSandboxStateSerdeTest.java` + +- [ ] **Step 1: Write failing test for snapshot re-injection in `KubernetesSandboxStateSerdeTest`** + +Add this test to `KubernetesSandboxStateSerdeTest.java`: + +```java +@Test +void resumeReInjectsSnapshotClient() { + // Build a state that has a RemoteSandboxSnapshot with only id (client=null after deser) + KubernetesSandboxState state = new KubernetesSandboxState(); + state.setSessionId("test-session"); + state.setNamespace("default"); + state.setContainerName("agent"); + state.setWorkspaceRoot("/workspace"); + state.setImage("ubuntu:24.04"); + state.setWorkspaceRootReady(false); + + // Simulate what happens after deserialization: snapshot has id but client is null + RemoteSandboxSnapshot snapshotWithNullClient = + new RemoteSandboxSnapshot(null, "snap-id-123"); + state.setSnapshot(snapshotWithNullClient); + + // Build client with a snapshotSpec that can rebuild the client + RemoteSnapshotClient mockClient = mock(RemoteSnapshotClient.class); + SandboxSnapshotSpec snapshotSpec = id -> new RemoteSandboxSnapshot(mockClient, id); + KubernetesSandboxClient client = + new KubernetesSandboxClient( + new KubernetesSandboxClientOptions(), null, snapshotSpec); + + // resume() should re-inject the snapshot client + KubernetesSandbox sandbox = (KubernetesSandbox) client.resume(state); + + SandboxSnapshot rebuilt = sandbox.getState().getSnapshot(); + assertNotNull(rebuilt); + assertEquals("snap-id-123", rebuilt.getId()); + // The rebuilt snapshot should use the mockClient — verify it's callable + assertInstanceOf(RemoteSandboxSnapshot.class, rebuilt); +} +``` + +Add required imports: +```java +import io.agentscope.harness.agent.sandbox.snapshot.RemoteSandboxSnapshot; +import io.agentscope.harness.agent.sandbox.snapshot.RemoteSnapshotClient; +import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.mockito.Mockito.mock; +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +mvn -s "E:\.m2\settings.xml" -pl agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes -am test -Dtest=KubernetesSandboxStateSerdeTest#resumeReInjectsSnapshotClient -q +``` + +Expected: FAIL — 3-arg constructor does not exist yet. + +- [ ] **Step 3: Add `snapshotSpec` field and 3-arg constructor to `KubernetesSandboxClient`** + +Add field after `private final KubernetesSandboxClientOptions defaultOptions;` (line 38): + +```java +private final SandboxSnapshotSpec snapshotSpec; +``` + +Replace the three existing constructors (lines 40-64) with: + +```java +public KubernetesSandboxClient() { + this(new KubernetesSandboxClientOptions(), null, null); +} + +public KubernetesSandboxClient(KubernetesSandboxClientOptions defaultOptions) { + this(defaultOptions, null, null); +} + +public KubernetesSandboxClient( + KubernetesSandboxClientOptions defaultOptions, ObjectMapper objectMapper) { + this(defaultOptions, objectMapper, null); +} + +/** + * @param defaultOptions template options merged into each {@link #create} call + * @param objectMapper optional mapper; when null a default mapper is created + * @param snapshotSpec used in {@link #resume} to re-inject the snapshot client after + * deserialization. When null, the snapshot field is left as-is (backward-compatible). + */ +public KubernetesSandboxClient( + KubernetesSandboxClientOptions defaultOptions, + ObjectMapper objectMapper, + SandboxSnapshotSpec snapshotSpec) { + this.defaultOptions = + defaultOptions != null ? defaultOptions : new KubernetesSandboxClientOptions(); + this.objectMapper = + objectMapper != null + ? objectMapper + : new ObjectMapper() + .findAndRegisterModules() + .registerModule(new HarnessSandboxJacksonModule()) + .registerModule(new KubernetesHarnessSandboxJacksonModule()); + this.snapshotSpec = snapshotSpec; +} +``` + +- [ ] **Step 4: Re-inject snapshot client in `KubernetesSandboxClient.resume()`** + +Replace `resume()` (lines 98-107): + +```java +@Override +public Sandbox resume(SandboxState state) { + if (!(state instanceof KubernetesSandboxState k8s)) { + throw new IllegalArgumentException( + "Expected KubernetesSandboxState but got: " + state.getClass().getName()); + } + // Re-inject snapshot client lost during JSON serialization + if (snapshotSpec != null && k8s.getSnapshot() != null) { + k8s.setSnapshot(snapshotSpec.build(k8s.getSnapshot().getId())); + } + KubernetesSandboxClientOptions merged = merge(null); + KubernetesClient kc = resolveClient(merged); + Fabric8KubernetesPodRuntime runtime = new Fabric8KubernetesPodRuntime(kc, merged); + return new KubernetesSandbox(k8s, runtime); +} +``` + +- [ ] **Step 5: Pass `snapshotSpec` in `KubernetesFilesystemSpec.createClient()`** + +Replace `createClient()` (lines 100-103): + +```java +@Override +protected SandboxClient createClient() { + if (client != null) { + return client; + } + SandboxSnapshotSpec effective = + getSnapshotSpecOverride() != null ? getSnapshotSpecOverride() : snapshotSpec(); + return new KubernetesSandboxClient(options, null, effective); +} +``` + +- [ ] **Step 6: Run the new test to verify it passes** + +```bash +mvn -s "E:\.m2\settings.xml" -pl agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes -am test -Dtest=KubernetesSandboxStateSerdeTest -q +``` + +Expected: BUILD SUCCESS, all tests green. + +- [ ] **Step 7: Commit** + +```bash +git add agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes/src/main/java/io/agentscope/extensions/sandbox/kubernetes/KubernetesSandboxClient.java +git add agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes/src/main/java/io/agentscope/extensions/sandbox/kubernetes/KubernetesFilesystemSpec.java +git add agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes/src/test/java/io/agentscope/extensions/sandbox/kubernetes/KubernetesSandboxStateSerdeTest.java +git commit -m "fix(sandbox): re-inject RemoteSandboxSnapshot client in KubernetesSandboxClient.resume()" +``` + +--- + +## Task 5: Full test suite green check + +- [ ] **Step 1: Run full harness test suite** + +```bash +mvn -s "E:\.m2\settings.xml" -pl agentscope-harness -am test -q +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 2: Run full kubernetes extension test suite** + +```bash +mvn -s "E:\.m2\settings.xml" -pl agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes -am test -q +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 3: Commit if any fixups were needed, otherwise done** + +```bash +git status +# If clean, no commit needed +``` From 8049e95c0be74e13f7122919d02ec177037673a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9F=E5=AE=9D=E5=9D=A4?= Date: Thu, 18 Jun 2026 11:54:10 +0800 Subject: [PATCH 6/8] test(sandbox): add coverage for createClient(), keepAlive getter, and middleware rollback --- .../KubernetesFilesystemSpecTest.java | 44 ++++++++++ .../SandboxLifecycleMiddlewareTest.java | 81 +++++++++++++++++++ .../agent/sandbox/SandboxContextTest.java | 14 ++++ .../impl/docker/DockerFilesystemSpecTest.java | 44 ++++++++++ 4 files changed, 183 insertions(+) create mode 100644 agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes/src/test/java/io/agentscope/extensions/sandbox/kubernetes/KubernetesFilesystemSpecTest.java create mode 100644 agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddlewareTest.java create mode 100644 agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerFilesystemSpecTest.java diff --git a/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes/src/test/java/io/agentscope/extensions/sandbox/kubernetes/KubernetesFilesystemSpecTest.java b/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes/src/test/java/io/agentscope/extensions/sandbox/kubernetes/KubernetesFilesystemSpecTest.java new file mode 100644 index 0000000000..e0bc268159 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-kubernetes/src/test/java/io/agentscope/extensions/sandbox/kubernetes/KubernetesFilesystemSpecTest.java @@ -0,0 +1,44 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.sandbox.kubernetes; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import io.agentscope.harness.agent.sandbox.SandboxClient; +import io.agentscope.harness.agent.sandbox.snapshot.NoopSnapshotSpec; +import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec; +import org.junit.jupiter.api.Test; + +class KubernetesFilesystemSpecTest { + + @Test + void createClient_returnsExternallySetClient() { + KubernetesFilesystemSpec spec = new KubernetesFilesystemSpec(); + SandboxClient external = new KubernetesSandboxClient(); + spec.client(external); + assertSame(external, spec.createClient()); + } + + @Test + void createClient_constructsDefaultClientWithSnapshotSpec() { + KubernetesFilesystemSpec spec = new KubernetesFilesystemSpec(); + SandboxSnapshotSpec snapshotSpec = new NoopSnapshotSpec(); + spec.snapshotSpec(snapshotSpec); + SandboxClient client = spec.createClient(); + assertNotNull(client); + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddlewareTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddlewareTest.java new file mode 100644 index 0000000000..3d718348de --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddlewareTest.java @@ -0,0 +1,81 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.harness.agent.middleware; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.harness.agent.filesystem.sandbox.SandboxBackedFilesystem; +import io.agentscope.harness.agent.sandbox.Sandbox; +import io.agentscope.harness.agent.sandbox.SandboxAcquireResult; +import io.agentscope.harness.agent.sandbox.SandboxContext; +import io.agentscope.harness.agent.sandbox.SandboxLease; +import io.agentscope.harness.agent.sandbox.SandboxManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SandboxLifecycleMiddlewareTest { + + @Mock SandboxManager sandboxManager; + @Mock SandboxBackedFilesystem filesystemProxy; + @Mock Sandbox sandbox; + @Mock SandboxLease lease; + + SandboxLifecycleMiddleware middleware; + + @BeforeEach + void setUp() { + middleware = new SandboxLifecycleMiddleware(sandboxManager, filesystemProxy); + } + + @Test + void acquireForCall_nullContext_returnsWithoutError() { + middleware.acquireForCall(null); + // no exception expected + } + + @Test + void acquireForCall_nullSandboxContext_returnsWithoutError() { + RuntimeContext ctx = RuntimeContext.builder().build(); + middleware.acquireForCall(ctx); + // no exception expected + } + + @Test + void acquireForCall_startFailure_rollsBackAndThrows() throws Exception { + SandboxAcquireResult result = SandboxAcquireResult.selfManaged(sandbox, lease); + when(sandboxManager.acquire(any(), any())).thenReturn(result); + doThrow(new RuntimeException("start failed")).when(sandbox).start(); + RuntimeContext ctx = + RuntimeContext.builder() + .put(SandboxContext.class, SandboxContext.builder().build()) + .build(); + + assertThrows(RuntimeException.class, () -> middleware.acquireForCall(ctx)); + + verify(sandboxManager).release(any(), any()); + verify(lease).close(); + verify(filesystemProxy).setSandbox(null); + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxContextTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxContextTest.java index 14d4f65a46..0e6132487c 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxContextTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxContextTest.java @@ -16,6 +16,7 @@ package io.agentscope.harness.agent.sandbox; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import io.agentscope.harness.agent.sandbox.impl.docker.DockerFilesystemSpec; @@ -41,6 +42,19 @@ void keepAliveWhenSetFalseReturnsFalse() { assertFalse(ctx.isKeepAlive()); } + @Test + void specKeepAliveDefaultsToFalse() { + DockerFilesystemSpec spec = new DockerFilesystemSpec(); + assertFalse(spec.isKeepAlive()); + } + + @Test + void specKeepAliveSetAndGet() { + DockerFilesystemSpec spec = new DockerFilesystemSpec(); + assertSame(spec, spec.keepAlive(true)); + assertTrue(spec.isKeepAlive()); + } + @Test void keepAlivePropagatedFromSpecToContext() { DockerFilesystemSpec spec = new DockerFilesystemSpec(); diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerFilesystemSpecTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerFilesystemSpecTest.java new file mode 100644 index 0000000000..789987cd26 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerFilesystemSpecTest.java @@ -0,0 +1,44 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.harness.agent.sandbox.impl.docker; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import io.agentscope.harness.agent.sandbox.SandboxClient; +import io.agentscope.harness.agent.sandbox.snapshot.NoopSnapshotSpec; +import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec; +import org.junit.jupiter.api.Test; + +class DockerFilesystemSpecTest { + + @Test + void createClient_returnsExternallySetClient() { + DockerFilesystemSpec spec = new DockerFilesystemSpec(); + SandboxClient external = new DockerSandboxClient(); + spec.client(external); + assertSame(external, spec.createClient()); + } + + @Test + void createClient_constructsDefaultClientWithSnapshotSpec() { + DockerFilesystemSpec spec = new DockerFilesystemSpec(); + SandboxSnapshotSpec snapshotSpec = new NoopSnapshotSpec(); + spec.snapshotSpec(snapshotSpec); + SandboxClient client = spec.createClient(); + assertNotNull(client); + } +} From ddad83ad99369dd3facafde4ae4b31b2f277113c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9F=E5=AE=9D=E5=9D=A4?= Date: Fri, 26 Jun 2026 10:57:34 +0800 Subject: [PATCH 7/8] feat(sandbox): expose SandboxIsolationKey.of() and SessionSandboxStateStore scope-value overloads Add a public factory method SandboxIsolationKey.of(scope, value) so that out-of-band callers (restore, archive, scavenger) can build a key without a RuntimeContext or guessing the internal slotSessionId format. Add SessionSandboxStateStore.load(scope, value) / save(scope, value) convenience overloads so that application code no longer needs to construct SandboxIsolationKey objects manually. --- .../agent/sandbox/SandboxIsolationKey.java | 15 +++++++++++++++ .../agent/sandbox/SessionSandboxStateStore.java | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxIsolationKey.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxIsolationKey.java index 63f78828a4..99d912afdc 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxIsolationKey.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxIsolationKey.java @@ -44,6 +44,21 @@ public final class SandboxIsolationKey { this.value = value; } + /** + * Creates an isolation key directly from a scope and its discriminating value. + * + *

Unlike {@link #resolve}, this factory does not require a {@link RuntimeContext} and + * produces the key unconditionally. Use it in out-of-band operations (archive, restore, + * scavenger) where no agent call context is available. + * + * @param scope the isolation scope (must not be null) + * @param value the discriminating value within the scope (e.g. user id, session id) + * @return a new isolation key + */ + public static SandboxIsolationKey of(IsolationScope scope, String value) { + return new SandboxIsolationKey(scope, value); + } + /** * Resolves an isolation key from the given scope, runtime context, and agent ID. * diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SessionSandboxStateStore.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SessionSandboxStateStore.java index 8f4f588aa7..4f3dbb58cf 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SessionSandboxStateStore.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SessionSandboxStateStore.java @@ -75,6 +75,22 @@ public void delete(SandboxIsolationKey key) throws IOException { } } + /** + * Convenience overload of {@link #load(SandboxIsolationKey)} for callers that have a scope and + * value directly (e.g. restore, archive, scavenger) without constructing a key manually. + */ + public Optional load(IsolationScope scope, String value) throws IOException { + return load(new SandboxIsolationKey(scope, value)); + } + + /** + * Convenience overload of {@link #save(SandboxIsolationKey, String)} for callers that have a + * scope and value directly without constructing a key manually. + */ + public void save(IsolationScope scope, String value, String json) throws IOException { + save(new SandboxIsolationKey(scope, value), json); + } + /** * Pack the sandbox isolation key into a single sessionId string that fits the * {@link AgentStateStore} 2-arg slot model. The userId column is always {@code null} because From c129a5b47c6ba75f59585c8036ef813b79e5a7c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9F=E5=AE=9D=E5=9D=A4?= Date: Fri, 26 Jun 2026 11:32:43 +0800 Subject: [PATCH 8/8] fix(sandbox): use updated WorkspaceSpec when resuming from persisted state When SandboxManager resumes a sandbox from Redis, it now overwrites the stale WorkspaceSpec in SandboxState with the current spec from SandboxContext. This ensures application-layer config changes (e.g., adding .skills-cache to WORKSPACE_PROJECTION_ROOTS) take effect without requiring manual cache deletion. Fixes #1926 --- .../io/agentscope/harness/agent/sandbox/SandboxManager.java | 4 ++++ 1 file changed, 4 insertions(+) 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 20f8ec04ae..6f1766f8b2 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 @@ -104,6 +104,10 @@ public SandboxAcquireResult acquire( "[sandbox] Priority 3: resuming from persisted state (scope={})", scopeKey.get()); SandboxState state = client.deserializeState(stateJson.get()); + // 用应用层最新配置覆盖 state 中固化的旧配置 + if (sandboxContext.getWorkspaceSpec() != null) { + state.setWorkspaceSpec(sandboxContext.getWorkspaceSpec().copy()); + } Sandbox sandbox = client.resume(state); return SandboxAcquireResult.selfManaged(sandbox, lease); }