From bc39b9feedf083a1f61990c15c3d0ca8aba77941 Mon Sep 17 00:00:00 2001 From: Nicolas Lenepveu Date: Mon, 22 Jun 2026 12:26:38 +0200 Subject: [PATCH] fix(orchestrator): inherit workflow default agent profile when auto-starting a profile-less session (#1450) --- .../internal/orchestrator/task_operations.go | 13 +++-- .../orchestrator/task_operations_test.go | 54 +++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 121119109e..52f3b38091 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -266,18 +266,23 @@ func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, ag return nil, fmt.Errorf("session is not in CREATED or WAITING_FOR_INPUT state (current: %s)", session.State) } - // Use agent profile from request, fall back to session's stored value + // Use agent profile from request, fall back to session's stored value. effectiveProfileID := agentProfileID if effectiveProfileID == "" { effectiveProfileID = session.AgentProfileID } + + // Resolve the workflow step override / workflow default before the + // required-profile guard, so a session without its own agent_profile_id + // inherits the workflow's default agent. resolveEffectiveAgentProfile keeps + // the caller profile only when neither a step override nor a workflow + // default applies; either of those overrides a non-empty caller. + effectiveProfileID = s.resolveEffectiveAgentProfile(ctx, taskID, "", effectiveProfileID) + if effectiveProfileID == "" { return nil, fmt.Errorf("agent_profile_id is required") } - // Override with workflow step's agent profile if one is configured. - effectiveProfileID = s.resolveEffectiveAgentProfile(ctx, taskID, "", effectiveProfileID) - // If the workflow step overrode the profile, update the session record in DB // so the frontend tab displays the correct agent (it reads session.agent_profile_id). if effectiveProfileID != session.AgentProfileID { diff --git a/apps/backend/internal/orchestrator/task_operations_test.go b/apps/backend/internal/orchestrator/task_operations_test.go index 8a63fa5db8..d29fa19321 100644 --- a/apps/backend/internal/orchestrator/task_operations_test.go +++ b/apps/backend/internal/orchestrator/task_operations_test.go @@ -754,6 +754,60 @@ func TestStartCreatedSession_WorkflowOverridePromotesPreparedWhenTaskHasNoPrimar } } +// TestStartCreatedSession_EmptyProfileFallsBackToWorkflowDefault pins the bug +// where an auto-started session prepared without an agent_profile_id (e.g. a +// task imported from Linear whose metadata agent_profile_id is empty) recorded +// the auto-start step prompt but never launched the agent. StartCreatedSession +// aborted with "agent_profile_id is required" because the required-profile +// guard ran before the workflow-default resolution. The launch must instead +// inherit the workflow's default agent profile and persist it on the session. +func TestStartCreatedSession_EmptyProfileFallsBackToWorkflowDefault(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCreated) + // executors_running lets LaunchPreparedSession take the existing-workspace + // fast path instead of launching a real agent. + seedExecutorRunning(t, repo, "session1", "task1", "exec-1") + + // Bind the task to a workflow step whose workflow defines a default agent + // profile, with no step-level override — the Auto Dispatch Workflow shape. + dbTask, err := repo.GetTask(ctx, "task1") + if err != nil { + t.Fatalf("get task: %v", err) + } + dbTask.WorkflowStepID = "step1" + if err := repo.UpdateTask(ctx, dbTask); err != nil { + t.Fatalf("update task: %v", err) + } + + stepGetter := newMockStepGetter() + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ID: "step1", WorkflowID: "wf1"} + stepGetter.workflowAgentProfileID = "wf-default-profile" + + taskRepo := newMockTaskRepo() + taskRepo.tasks["task1"] = &v1.Task{ID: "task1", Title: "Test Task", State: v1.TaskStateInProgress} + agentMgr := &mockAgentManager{repoForExecutionLookup: repo} + svc := createTestServiceWithScheduler(repo, stepGetter, taskRepo, agentMgr) + svc.messageCreator = &mockMessageCreator{} + + // The auto-start path passes the session's stored profile, which is empty + // here. The previous code aborted with "agent_profile_id is required". + _, err = svc.StartCreatedSession(ctx, "task1", "session1", "", "Do the work", true, false, true, nil) + if err != nil { + t.Fatalf("StartCreatedSession must resolve the workflow default for an empty profile, got error: %v", err) + } + + // The resolved workflow default must be persisted on the session so the + // agent actually launches under it (and the UI shows the right agent). + got, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("reload session: %v", err) + } + if got.AgentProfileID != "wf-default-profile" { + t.Errorf("expected session to inherit workflow default %q, got %q", "wf-default-profile", got.AgentProfileID) + } +} + // --- recordInitialMessage --- // mockMessageCreator implements MessageCreator for testing.