diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/AbstractBaseSandbox.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/AbstractBaseSandbox.java index 89f6a7adca..a39859abed 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/AbstractBaseSandbox.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/AbstractBaseSandbox.java @@ -86,8 +86,8 @@ public void start() throws Exception { log.debug("[sandbox] Branch B: workspace lost, restoring from snapshot"); Optional archive = openSnapshotArchive(snapshot); if (archive.isPresent()) { - doSetupWorkspace(); try (InputStream stream = archive.get()) { + doSetupWorkspace(); doHydrateWorkspace(stream); } workspaceSpecApplier.applyWorkspaceSpec(spec, true); @@ -104,8 +104,8 @@ public void start() throws Exception { if (archive.isPresent()) { // Branch C: restore from snapshot + all spec entries log.debug("[sandbox] Branch C: restoring from snapshot"); - doSetupWorkspace(); try (InputStream stream = archive.get()) { + doSetupWorkspace(); doHydrateWorkspace(stream); } workspaceSpecApplier.applyWorkspaceSpec(spec, false); diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/AbstractBaseSandboxSnapshotCleanupTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/AbstractBaseSandboxSnapshotCleanupTest.java new file mode 100644 index 0000000000..64795107bd --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/AbstractBaseSandboxSnapshotCleanupTest.java @@ -0,0 +1,112 @@ +/* + * 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.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshot; +import java.io.IOException; +import java.io.InputStream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InOrder; + +class AbstractBaseSandboxSnapshotCleanupTest { + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void closesSnapshotAfterSuccessfulHydration(boolean previouslyReady) throws Exception { + InputStream archive = mock(InputStream.class); + AbstractBaseSandbox sandbox = sandboxForRestore(previouslyReady, archive); + + sandbox.start(); + + InOrder order = inOrder(sandbox, archive); + order.verify(sandbox).doSetupWorkspace(); + order.verify(sandbox).doHydrateWorkspace(archive); + order.verify(archive).close(); + assertTrue(sandbox.isRunning()); + assertTrue(sandbox.getState().isWorkspaceRootReady()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void closesOpenedSnapshotWhenWorkspaceSetupFails(boolean previouslyReady) throws Exception { + InputStream archive = mock(InputStream.class); + AbstractBaseSandbox sandbox = sandboxForRestore(previouslyReady, archive); + IOException setupFailure = new IOException("workspace setup failed"); + doThrow(setupFailure).when(sandbox).doSetupWorkspace(); + + SandboxException.WorkspaceStartException failure = + assertThrows(SandboxException.WorkspaceStartException.class, sandbox::start); + + assertSame(setupFailure, failure.getCause()); + assertFalse(sandbox.isRunning()); + assertFalse(sandbox.getState().isWorkspaceRootReady()); + verify(sandbox, never()).doHydrateWorkspace(any()); + verify(archive).close(); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void preservesSetupFailureWhenClosingSnapshotAlsoFails(boolean previouslyReady) + throws Exception { + InputStream archive = mock(InputStream.class); + IOException closeFailure = new IOException("archive close failed"); + doThrow(closeFailure).when(archive).close(); + AbstractBaseSandbox sandbox = sandboxForRestore(previouslyReady, archive); + IOException setupFailure = new IOException("workspace setup failed"); + doThrow(setupFailure).when(sandbox).doSetupWorkspace(); + + SandboxException.WorkspaceStartException failure = + assertThrows(SandboxException.WorkspaceStartException.class, sandbox::start); + + assertSame(setupFailure, failure.getCause()); + verify(archive).close(); + assertArrayEquals(new Throwable[] {closeFailure}, setupFailure.getSuppressed()); + } + + private AbstractBaseSandbox sandboxForRestore(boolean previouslyReady, InputStream archive) + throws Exception { + SandboxSnapshot snapshot = mock(SandboxSnapshot.class); + when(snapshot.isRestorable()).thenReturn(true); + when(snapshot.restore()).thenReturn(archive); + SandboxState state = new SandboxState() {}; + state.setWorkspaceSpec(new WorkspaceSpec()); + state.setWorkspaceRootReady(previouslyReady); + state.setSnapshot(snapshot); + AbstractBaseSandbox sandbox = + mock( + AbstractBaseSandbox.class, + withSettings().useConstructor(state).defaultAnswer(CALLS_REAL_METHODS)); + doReturn(false).when(sandbox).probeWorkspaceRootForPreservedResume(); + return sandbox; + } +} diff --git a/docs/v2/en/docs/harness/sandbox.md b/docs/v2/en/docs/harness/sandbox.md index a5da5e3075..c89ecdd33c 100644 --- a/docs/v2/en/docs/harness/sandbox.md +++ b/docs/v2/en/docs/harness/sandbox.md @@ -65,6 +65,8 @@ The sandbox snapshots its workspace at each `call()` end and restores at the nex - Container gone → reboot from snapshot, restore workspace - No snapshot → full init from `WorkspaceSpec` (cold start) +An opened snapshot archive is closed even if workspace setup fails during restoration. + Where snapshots land is decided by `snapshotSpec`: | Option | When | diff --git a/docs/v2/zh/docs/harness/sandbox.md b/docs/v2/zh/docs/harness/sandbox.md index 6d5ace317d..0fd33a5060 100644 --- a/docs/v2/zh/docs/harness/sandbox.md +++ b/docs/v2/zh/docs/harness/sandbox.md @@ -65,6 +65,8 @@ agent.call(msg, RuntimeContext.builder() - 容器没了 → 拿快照重新起一个,恢复工作区 - 没快照 → 按 `WorkspaceSpec` 全量初始化(冷启动) +恢复时已经打开的快照归档流,即使工作区初始化失败也会关闭。 + 快照存到哪里取决于你配的 `snapshotSpec`: | 选项 | 适合 |