Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions apps/backend/internal/orchestrator/session_launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,24 @@ func (s *Service) launchStart(ctx context.Context, req *LaunchSessionRequest) (*
return s.launchPrepare(ctx, req)
}

// Opening a task whose step lacks auto_start_agent calls session.ensure
// which creates a CREATED session via prepare. A subsequent explicit start
// (e.g. the edit-dialog "Start Agent" button) would otherwise create a
// duplicate session row and surface two tabs on a fresh task. Promote the
// prepared session to a start_created launch instead. ensureLocks is shared
// with EnsureSession so the two paths cannot race and both decide to create.
if req.SessionID == "" {
Comment thread
jcfs marked this conversation as resolved.
Outdated
release := acquireEnsureLock(req.TaskID)
defer release()
if reused := s.findReusableCreatedSession(ctx, req.TaskID); reused != nil {
Comment thread
jcfs marked this conversation as resolved.
Outdated
req.SessionID = reused.ID
if req.AgentProfileID == "" {
req.AgentProfileID = reused.AgentProfileID
}
return s.launchStartCreated(ctx, req)
}
}
Comment thread
jcfs marked this conversation as resolved.
Outdated

execution, err := s.StartTask(
ctx, req.TaskID, req.AgentProfileID, req.ExecutorID,
req.ExecutorProfileID, req.Priority, req.Prompt,
Expand All @@ -181,6 +199,32 @@ func (s *Service) launchStart(ctx context.Context, req *LaunchSessionRequest) (*
return executionToLaunchResponse(req.TaskID, execution), nil
}

// findReusableCreatedSession returns a CREATED session on the task that an
// explicit start should adopt instead of creating a new row. Primary wins; the
// most-recently-updated CREATED session is the fallback. Non-CREATED states
// (RUNNING, WAITING_FOR_INPUT, COMPLETED, FAILED, CANCELLED) are skipped — they
// reflect either an active agent or an explicit terminal state and must not be
// implicitly revived from a fresh start request.
func (s *Service) findReusableCreatedSession(ctx context.Context, taskID string) *models.TaskSession {
sessions, err := s.repo.ListTaskSessions(ctx, taskID)
if err != nil {
return nil
}
Comment thread
jcfs marked this conversation as resolved.
var best *models.TaskSession
for _, sess := range sessions {
if sess.State != models.TaskSessionStateCreated {
continue
}
if sess.IsPrimary {
return sess
}
if best == nil || sess.UpdatedAt.After(best.UpdatedAt) {
best = sess
}
}
return best
}

// shouldBlockAutoStart checks whether the task's workflow step allows auto-starting
// the agent. Returns true when the step exists but does not have auto_start_agent
// in its on_enter events. Tasks without a workflow step are never blocked.
Expand Down
128 changes: 128 additions & 0 deletions apps/backend/internal/orchestrator/session_launch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,3 +478,131 @@ func TestLaunchRestoreWorkspace_IncludesWorktreeInfo(t *testing.T) {
t.Errorf("expected worktree_branch 'feature/test', got %v", resp.WorktreeBranch)
}
}

// --- launchStart reuse of prepared sessions ---

// TestFindReusableCreatedSession pins the lookup that lets an explicit Start
// adopt a session.ensure-prepared row instead of creating a duplicate.
func TestFindReusableCreatedSession(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())

t.Run("returns nil when task has no sessions", func(t *testing.T) {
if got := svc.findReusableCreatedSession(ctx, "task-missing"); got != nil {
t.Fatalf("expected nil, got %+v", got)
}
})

t.Run("skips non-CREATED sessions", func(t *testing.T) {
seedTaskAndSession(t, repo, "task-running", "s-running", models.TaskSessionStateRunning)
if got := svc.findReusableCreatedSession(ctx, "task-running"); got != nil {
t.Fatalf("expected nil for RUNNING-only task, got %+v", got)
}
})

t.Run("prefers primary CREATED over newer non-primary", func(t *testing.T) {
seedTaskAndSession(t, repo, "task-primary", "s-primary", models.TaskSessionStateCreated)
if err := repo.SetSessionPrimary(ctx, "s-primary"); err != nil {
t.Fatalf("set primary: %v", err)
}
newer := &models.TaskSession{
ID: "s-newer",
TaskID: "task-primary",
State: models.TaskSessionStateCreated,
StartedAt: time.Now().UTC().Add(time.Minute),
UpdatedAt: time.Now().UTC().Add(time.Minute),
}
if err := repo.CreateTaskSession(ctx, newer); err != nil {
t.Fatalf("create newer: %v", err)
}
got := svc.findReusableCreatedSession(ctx, "task-primary")
if got == nil || got.ID != "s-primary" {
t.Fatalf("expected primary session, got %+v", got)
}
})

t.Run("falls back to newest CREATED when no primary", func(t *testing.T) {
seedTaskAndSession(t, repo, "task-no-primary", "s-old", models.TaskSessionStateCreated)
older := time.Now().UTC().Add(-time.Hour)
if old, err := repo.GetTaskSession(ctx, "s-old"); err == nil {
old.UpdatedAt = older
_ = repo.UpdateTaskSession(ctx, old)
Comment thread
jcfs marked this conversation as resolved.
Outdated
}
newer := &models.TaskSession{
ID: "s-new",
TaskID: "task-no-primary",
State: models.TaskSessionStateCreated,
StartedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
if err := repo.CreateTaskSession(ctx, newer); err != nil {
t.Fatalf("create newer: %v", err)
}
got := svc.findReusableCreatedSession(ctx, "task-no-primary")
if got == nil || got.ID != "s-new" {
t.Fatalf("expected newest CREATED session, got %+v", got)
}
})
}

// TestLaunchStart_ReusesPreparedSession regression-tests the duplicate-session
// bug: opening a task whose step lacks auto_start_agent calls session.ensure
// (creates a CREATED session via prepare); the subsequent edit-dialog "Start
// Agent" click must adopt that session instead of letting StartTask create a
// second row.
func TestLaunchStart_ReusesPreparedSession(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
taskRepo := newMockTaskRepo()
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, &mockAgentManager{})

seedTaskAndSession(t, repo, "task1", "session-prepared", models.TaskSessionStateCreated)
taskRepo.tasks["task1"] = &v1.Task{ID: "task1", WorkspaceID: "ws1", Title: "Test", Description: "desc"}
if err := repo.SetSessionPrimary(ctx, "session-prepared"); err != nil {
t.Fatalf("set primary: %v", err)
}
prepared, err := repo.GetTaskSession(ctx, "session-prepared")
if err != nil {
t.Fatalf("load seeded session: %v", err)
}
prepared.AgentProfileID = "profile1"
if err := repo.UpdateTaskSession(ctx, prepared); err != nil {
t.Fatalf("update seeded profile: %v", err)
}

// Mirrors buildStartRequest from the edit dialog: explicit start, no
// session_id, agent profile + prompt supplied. Downstream agent launch
// panics on the stub scheduler — that's expected and not what this test
// pins. Run the call in a recovered goroutine so we can still inspect
// the post-state session count from the main goroutine.
done := make(chan struct{})
go func() {
defer func() {
_ = recover()
close(done)
}()
_, _ = svc.LaunchSession(ctx, &LaunchSessionRequest{
TaskID: "task1",
Intent: IntentStart,
AgentProfileID: "profile1",
Prompt: "hello",
})
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("LaunchSession hung")
}

sessions, err := repo.ListTaskSessions(ctx, "task1")
if err != nil {
t.Fatalf("list sessions: %v", err)
}
if len(sessions) != 1 {
t.Fatalf("expected single session after reuse, got %d", len(sessions))
}
if sessions[0].ID != "session-prepared" {
t.Fatalf("expected reused session id, got %q", sessions[0].ID)
}
}
Loading