diff --git a/backend/internal/session/manager.go b/backend/internal/session/manager.go index 79d440273..6ae2a60e9 100644 --- a/backend/internal/session/manager.go +++ b/backend/internal/session/manager.go @@ -101,6 +101,11 @@ func New(d Deps) *Manager { // step eagerly rolls back the steps that already succeeded. func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) { id := m.newID(cfg) + if _, ok, err := m.store.Get(ctx, id); err != nil { + return domain.Session{}, fmt.Errorf("spawn %s: check existing: %w", id, err) + } else if ok { + return domain.Session{}, fmt.Errorf("spawn %s: already exists", id) + } ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ ProjectID: cfg.ProjectID, @@ -315,6 +320,9 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess } if err := m.lcm.OnSpawnCompleted(ctx, id, outcome); err != nil { m.rollbackRuntime(ctx, handle) + if revertErr := m.lcm.OnSpawnInitiated(ctx, rec); revertErr != nil { + return domain.Session{}, fmt.Errorf("restore %s: revert after spawn completed failure: %w (original error: %v)", id, revertErr, err) + } return domain.Session{}, fmt.Errorf("restore %s: on spawn completed: %w", id, err) } return m.Get(ctx, id) diff --git a/backend/internal/session/manager_test.go b/backend/internal/session/manager_test.go index cf9407bea..b50555580 100644 --- a/backend/internal/session/manager_test.go +++ b/backend/internal/session/manager_test.go @@ -121,6 +121,32 @@ func TestSpawn_RuntimeCreateFailure_RollsBack(t *testing.T) { } } +func TestSpawn_ExistingSessionIDRejectedBeforeWork(t *testing.T) { + h := newHarness("sess-1") + ctx := context.Background() + if err := h.store.Upsert(ctx, domain.SessionRecord{ + ID: "sess-1", + ProjectID: testProject, + Lifecycle: lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.PRNone, ""), + }); err != nil { + t.Fatalf("seed existing row: %v", err) + } + + _, err := h.sm.Spawn(ctx, spawnCfg()) + if err == nil { + t.Fatal("spawn: want error for existing session id, got nil") + } + if len(h.workspace.created) != 0 { + t.Error("workspace should not be created when session id already exists") + } + if len(h.runtime.created) != 0 { + t.Error("runtime should not be created when session id already exists") + } + if h.log.indexOf("OnSpawnInitiated") != -1 || h.log.indexOf("OnSpawnCompleted") != -1 { + t.Error("LCM should not be called when session id already exists") + } +} + func TestSpawn_OnSpawnCompletedFailure_RoutesOrphanToErrored(t *testing.T) { h := newHarness("sess-1") ctx := context.Background() @@ -457,6 +483,11 @@ func TestRestore_OnSpawnCompletedFailure_RollsBackRuntime(t *testing.T) { t.Fatal("restore: want error, got nil") } + rec, _, _ := h.store.Get(ctx, "sess-1") + if got := rec.Lifecycle.Session; got.State != domain.SessionTerminated || got.Reason != domain.ReasonManuallyKilled { + t.Fatalf("restore failure should restore terminal lifecycle, got %+v", got) + } + // The runtime created during restore is torn back down so no process is // stranded; the workspace is left intact (it holds the agent's prior work). if len(h.runtime.destroyed) != destroyedBefore+1 {