diff --git a/internal/application/service/session_sandbox_entry_test.go b/internal/application/service/session_sandbox_entry_test.go index d588a6a99..1b6091ce3 100644 --- a/internal/application/service/session_sandbox_entry_test.go +++ b/internal/application/service/session_sandbox_entry_test.go @@ -96,3 +96,23 @@ func TestArtifactSessionSourceSkipsUnpinnedSession(t *testing.T) { require.Nil(t, collector.sessionSource(context.Background(), "s-1")) } + +// Docker (and other named backends) pin the workspace config on first +// execution. Collection must follow that pin rather than treating the +// session as having no sandbox. +func TestArtifactSessionSourceResolvesNamedPin(t *testing.T) { + pinner := NewSessionSandboxPinner(newPinTestDB(t)) + ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(7)) + _, err := pinner.Pin(ctx, "s-1", "cfg-docker") + require.NoError(t, err) + + named := &artifactFallbackManager{source: &fakeSandboxSource{}} + collector := &ArtifactCollector{ + source: &fakeSandboxSource{}, + resolver: stubSandboxResolver{mgr: named}, + pinner: pinner, + } + + got := collector.sessionSource(ctx, "s-1") + require.Equal(t, named, got) +} diff --git a/internal/application/service/session_sandbox_pin.go b/internal/application/service/session_sandbox_pin.go index 3321f0af8..0d3cb5b96 100644 --- a/internal/application/service/session_sandbox_pin.go +++ b/internal/application/service/session_sandbox_pin.go @@ -173,7 +173,10 @@ func resolveSandboxForExecution( if err != nil || mgr == nil { return mgr, configID, err } - if mgr.GetType() != sandbox.SandboxTypeCube && mgr.GetType() != sandbox.SandboxTypeE2B { + // Named backends (Cube, E2B, Docker) all keep a session-scoped sandbox. + // Artifact collection and teardown resolve that sandbox from this pin, so + // skipping Docker here leaves /workspace/output files uncollected. + if !sandbox.IsNamedSandboxBackendType(string(mgr.GetType())) { return mgr, configID, nil } if pinner == nil || strings.TrimSpace(sessionID) == "" { diff --git a/internal/application/service/session_sandbox_pin_test.go b/internal/application/service/session_sandbox_pin_test.go index 31f1a4f44..ca5d809d8 100644 --- a/internal/application/service/session_sandbox_pin_test.go +++ b/internal/application/service/session_sandbox_pin_test.go @@ -181,6 +181,26 @@ func TestResolveSandboxForExecutionPinsRemoteBackend(t *testing.T) { require.Equal(t, "cfg-cube", pinned) } +// Docker is a session-persistent remote backend, same as Cube/E2B. Skipping +// the pin used to make ArtifactCollector treat the turn as "no live sandbox" +// and leave generated HTML/files showing as unavailable in chat. +func TestResolveSandboxForExecutionPinsDockerBackend(t *testing.T) { + pinner := NewSessionSandboxPinner(newPinTestDB(t)) + want := &pinTestManager{typ: sandbox.SandboxTypeDocker} + + got, configID, err := resolveSandboxForExecution( + context.Background(), stubSandboxResolver{mgr: want}, nil, pinner, + 7, "s-1", "cfg-docker", nil, + ) + + require.NoError(t, err) + require.Same(t, want, got) + require.Equal(t, "cfg-docker", configID) + pinned, err := pinner.Read(context.Background(), "s-1") + require.NoError(t, err) + require.Equal(t, "cfg-docker", pinned) +} + func TestResolveSandboxForExecutionKeepsExistingRemotePin(t *testing.T) { pinner := NewSessionSandboxPinner(newPinTestDB(t)) _, err := pinner.Pin(context.Background(), "s-1", "cfg-existing")