diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index f1854cdb86..a44218db4f 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -26,6 +26,7 @@ import ( taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" "github.com/kandev/kandev/internal/task/service" workflowctrl "github.com/kandev/kandev/internal/workflow/controller" + workflowmodels "github.com/kandev/kandev/internal/workflow/models" workflowsvc "github.com/kandev/kandev/internal/workflow/service" v1 "github.com/kandev/kandev/pkg/api/v1" ws "github.com/kandev/kandev/pkg/websocket" @@ -459,6 +460,23 @@ func (h *Handlers) handleCreateTask(ctx context.Context, msg *ws.Message) (*ws.M return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil) } + var autoStartConfig *mcpAutoStartConfig + if startAgent && h.sessionLauncher != nil { + pendingTask := &models.Task{ + ParentID: req.ParentID, + WorkspaceID: req.WorkspaceID, + WorkflowID: req.WorkflowID, + WorkflowStepID: req.WorkflowStepID, + } + config := h.resolveMCPAutoStartConfig(ctx, pendingTask, req.AgentProfileID, req.ExecutorProfileID, req.SourceTaskID) + if config.AgentProfileID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, + "agent_profile_id is required when start_agent is true and no agent profile can be resolved from the parent task, source task, workflow, or workspace defaults; pass agent_profile_id or set start_agent=false", + nil) + } + autoStartConfig = &config + } + task, err := h.taskSvc.CreateTask(ctx, &service.CreateTaskRequest{ ParentID: req.ParentID, WorkspaceID: req.WorkspaceID, @@ -481,8 +499,12 @@ func (h *Handlers) handleCreateTask(ctx context.Context, msg *ws.Message) (*ws.M } // Auto-start agent session asynchronously only if requested - if startAgent { - h.autoStartTask(task, req.AgentProfileID, req.ExecutorProfileID, req.SourceTaskID) + if startAgent && h.sessionLauncher != nil { + if autoStartConfig == nil { + config := h.resolveMCPAutoStartConfig(ctx, task, req.AgentProfileID, req.ExecutorProfileID, req.SourceTaskID) + autoStartConfig = &config + } + h.launchAutoStartTask(ctx, task, *autoStartConfig) } return ws.NewResponse(msg.ID, msg.Action, dto.FromTask(task)) @@ -625,28 +647,43 @@ func inheritedRepoInputs(src []*models.TaskRepository) []service.TaskRepositoryI return repos } +type mcpAutoStartConfig struct { + AgentProfileID string + ExecutorID string + ExecutorProfileID string +} + // autoStartTask launches an agent session for a newly created task in the background. -// It resolves the agent profile: explicit > parent's session > source task's session > workspace default. -// It resolves the executor: explicit executor_profile_id > parent's executor_profile_id > -// source task's executor_profile_id > parent's executor_id > "exec-worktree" (default for MCP-created tasks). +// It is kept as a small compatibility wrapper for direct tests; handleCreateTask +// uses resolveMCPAutoStartConfig before persisting so invalid auto-start +// requests fail synchronously. func (h *Handlers) autoStartTask(task *models.Task, agentProfileID, executorProfileID, sourceTaskID string) { - if h.sessionLauncher == nil { - return - } + ctx := context.Background() + config := h.resolveMCPAutoStartConfig(ctx, task, agentProfileID, executorProfileID, sourceTaskID) + h.launchAutoStartTask(ctx, task, config) +} - executorID := h.inheritFromParentSession(task.ParentID, &agentProfileID, &executorProfileID) +// resolveMCPAutoStartConfig resolves the agent profile and executor for +// create_task_kandev auto-start. Agent profile resolution follows the MCP +// ergonomics first (explicit > parent/source session), then the same durable +// defaults used by task opening where this handler can see them (workflow step +// when a workflow controller is wired, workflow default, workspace default). +func (h *Handlers) resolveMCPAutoStartConfig(ctx context.Context, task *models.Task, agentProfileID, executorProfileID, sourceTaskID string) mcpAutoStartConfig { + executorID := h.inheritFromTaskSession(ctx, task.ParentID, &agentProfileID, &executorProfileID) - // For top-level tasks, inherit from the source task (the calling agent's task) + // For top-level tasks, inherit from the source task (the calling agent's task). if task.ParentID == "" && sourceTaskID != "" { - sourceExecutorID := h.inheritFromParentSession(sourceTaskID, &agentProfileID, &executorProfileID) + sourceExecutorID := h.inheritFromTaskSession(ctx, sourceTaskID, &agentProfileID, &executorProfileID) if executorID == "" { executorID = sourceExecutorID } } - // Fall back to workspace defaults for agent profile and worktree executor if agentProfileID == "" { - workspace, err := h.taskSvc.GetWorkspace(context.Background(), task.WorkspaceID) + agentProfileID = h.resolveWorkflowAgentProfile(ctx, task.WorkflowStepID, task.WorkflowID) + } + if agentProfileID == "" && h.taskSvc != nil { + workspace, err := h.taskSvc.GetWorkspace(ctx, task.WorkspaceID) if err == nil && workspace.DefaultAgentProfileID != nil { agentProfileID = *workspace.DefaultAgentProfileID } @@ -655,22 +692,110 @@ func (h *Handlers) autoStartTask(task *models.Task, agentProfileID, executorProf executorID = models.ExecutorIDWorktree } - if agentProfileID == "" { + return mcpAutoStartConfig{ + AgentProfileID: agentProfileID, + ExecutorID: executorID, + ExecutorProfileID: executorProfileID, + } +} + +func (h *Handlers) resolveWorkflowAgentProfile(ctx context.Context, workflowStepID, workflowID string) string { + profileID, resolvedWorkflowID := h.resolveWorkflowControllerAgentProfile(ctx, workflowStepID, workflowID) + if profileID != "" { + return profileID + } + if workflowID == "" { + workflowID = resolvedWorkflowID + } + return h.workflowDefaultAgentProfile(ctx, workflowID) +} + +func (h *Handlers) resolveWorkflowControllerAgentProfile(ctx context.Context, workflowStepID, workflowID string) (string, string) { + if h.workflowCtrl == nil { + return "", workflowID + } + if workflowStepID != "" { + return h.resolveWorkflowStepAgentProfile(ctx, workflowStepID, workflowID) + } + if workflowID == "" { + return "", "" + } + return h.resolveWorkflowStartStepAgentProfile(ctx, workflowID), workflowID +} + +func (h *Handlers) resolveWorkflowStepAgentProfile(ctx context.Context, workflowStepID, workflowID string) (string, string) { + resp, err := h.workflowCtrl.GetStep(ctx, workflowStepID) + if err != nil || resp == nil || resp.Step == nil { + return "", workflowID + } + if workflowID == "" { + workflowID = resp.Step.WorkflowID + } + return resp.Step.AgentProfileID, workflowID +} + +func (h *Handlers) resolveWorkflowStartStepAgentProfile(ctx context.Context, workflowID string) string { + resp, err := h.workflowCtrl.ListStepsByWorkflow(ctx, workflowctrl.ListStepsRequest{WorkflowID: workflowID}) + if err != nil || resp == nil { + return "" + } + return startStepAgentProfile(resp.Steps) +} + +func (h *Handlers) workflowDefaultAgentProfile(ctx context.Context, workflowID string) string { + if workflowID == "" || h.taskSvc == nil { + return "" + } + workflow, err := h.taskSvc.GetWorkflow(ctx, workflowID) + if err != nil || workflow == nil { + return "" + } + return workflow.AgentProfileID +} + +func startStepAgentProfile(steps []*workflowmodels.WorkflowStep) string { + if len(steps) == 0 { + return "" + } + var firstByPosition *workflowmodels.WorkflowStep + for _, step := range steps { + if step == nil { + continue + } + if firstByPosition == nil || step.Position < firstByPosition.Position { + firstByPosition = step + } + if step.IsStartStep { + return step.AgentProfileID + } + } + if firstByPosition == nil { + return "" + } + return firstByPosition.AgentProfileID +} + +func (h *Handlers) launchAutoStartTask(ctx context.Context, task *models.Task, config mcpAutoStartConfig) { + if h.sessionLauncher == nil { + return + } + if config.AgentProfileID == "" { h.logger.Warn("no agent profile available, skipping auto-start", zap.String("task_id", task.ID)) return } go func() { - ctx, cancel := context.WithTimeout(context.Background(), constants.AgentLaunchTimeout) + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), constants.AgentLaunchTimeout) defer cancel() resp, err := h.sessionLauncher.LaunchSession(ctx, &orchestrator.LaunchSessionRequest{ TaskID: task.ID, Intent: orchestrator.IntentStart, - AgentProfileID: agentProfileID, - ExecutorID: executorID, - ExecutorProfileID: executorProfileID, + AgentProfileID: config.AgentProfileID, + ExecutorID: config.ExecutorID, + ExecutorProfileID: config.ExecutorProfileID, + WorkflowStepID: task.WorkflowStepID, Prompt: task.Description, }) if err != nil { @@ -684,16 +809,19 @@ func (h *Handlers) autoStartTask(task *models.Task, agentProfileID, executorProf }() } -// inheritFromParentSession fills agentProfileID and executorProfileID from the parent -// task's primary session when not explicitly provided. It returns the parent's ExecutorID +// inheritFromTaskSession fills agentProfileID and executorProfileID from another +// task's primary session when not explicitly provided. It returns that session's ExecutorID // as a fallback for when the parent session has no executor profile (common for // UI-created sessions). If ExecutorProfileID is resolved, ExecutorID is redundant // since the profile already encodes the executor reference. -func (h *Handlers) inheritFromParentSession(parentID string, agentProfileID, executorProfileID *string) string { +func (h *Handlers) inheritFromTaskSession(ctx context.Context, parentID string, agentProfileID, executorProfileID *string) string { if parentID == "" { return "" } - parent, err := h.taskSvc.GetPrimarySession(context.Background(), parentID) + if h.taskSvc == nil { + return "" + } + parent, err := h.taskSvc.GetPrimarySession(ctx, parentID) if err != nil || parent == nil { return "" } diff --git a/apps/backend/internal/mcp/handlers/handlers_test.go b/apps/backend/internal/mcp/handlers/handlers_test.go index 50e3c0840e..2942509f01 100644 --- a/apps/backend/internal/mcp/handlers/handlers_test.go +++ b/apps/backend/internal/mcp/handlers/handlers_test.go @@ -21,6 +21,10 @@ import ( "github.com/kandev/kandev/internal/task/repository" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" "github.com/kandev/kandev/internal/task/service" + workflowcontroller "github.com/kandev/kandev/internal/workflow/controller" + workflowmodels "github.com/kandev/kandev/internal/workflow/models" + workflowrepo "github.com/kandev/kandev/internal/workflow/repository" + workflowservice "github.com/kandev/kandev/internal/workflow/service" "github.com/kandev/kandev/internal/worktree" v1 "github.com/kandev/kandev/pkg/api/v1" ws "github.com/kandev/kandev/pkg/websocket" @@ -64,6 +68,45 @@ func newTestTaskService(t *testing.T) (*service.Service, *sqliterepo.Repository) return svc, repo } +func newTestTaskServiceWithWorkflow(t *testing.T) (*service.Service, *sqliterepo.Repository, *workflowcontroller.Controller, *workflowrepo.Repository) { + t.Helper() + dbConn, err := db.OpenSQLite(filepath.Join(t.TempDir(), "test.db")) + require.NoError(t, err) + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + t.Cleanup(func() { + _ = sqlxDB.Close() + }) + repo, cleanup, err := repository.Provide(sqlxDB, sqlxDB, nil) + require.NoError(t, err) + t.Cleanup(func() { + _ = cleanup() + }) + workflowRepo, err := workflowrepo.NewWithDB(sqlxDB, sqlxDB, testLogger(t)) + require.NoError(t, err) + _, err = worktree.NewSQLiteStore(sqlxDB, sqlxDB) + require.NoError(t, err) + + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + eventBus := bus.NewMemoryEventBus(log) + t.Cleanup(func() { eventBus.Close() }) + svc := service.NewService(service.Repos{ + Workspaces: repo, + Tasks: repo, + TaskRepos: repo, + Workflows: repo, + Messages: repo, + Turns: repo, + Sessions: repo, + GitSnapshots: repo, + RepoEntities: repo, + Executors: repo, + Environments: repo, + Reviews: repo, + }, eventBus, log, service.RepositoryDiscoveryConfig{}) + workflowSvc := workflowservice.NewService(workflowRepo, log) + return svc, repo, workflowcontroller.NewController(workflowSvc), workflowRepo +} + func seedMCPHandlerSession(t *testing.T, repo *sqliterepo.Repository, taskID, sessionID string, state models.TaskSessionState) { t.Helper() ctx := context.Background() @@ -264,21 +307,231 @@ func TestHandleCreateTask_TopLevel_MissingWorkflowID(t *testing.T) { assertWSError(t, resp, ws.ErrorCodeValidation) } +func TestHandleCreateTask_StartAgentRequiresResolvableAgentProfile(t *testing.T) { + svc, _ := newTestTaskService(t) + ctx := context.Background() + workspaces, err := svc.ListWorkspaces(ctx) + require.NoError(t, err) + require.Len(t, workspaces, 1) + workflows, err := svc.ListWorkflows(ctx, workspaces[0].ID, false) + require.NoError(t, err) + require.Len(t, workflows, 1) + + h := &Handlers{ + taskSvc: svc, + sessionLauncher: newMockSessionLauncher(), + logger: testLogger(t).WithFields(), + } + msg := makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{ + "workspace_id": workspaces[0].ID, + "workflow_id": workflows[0].ID, + "title": "Invalid auto-start task", + "description": "This should not be persisted without a profile.", + }) + + resp, err := h.handleCreateTask(ctx, msg) + require.NoError(t, err) + assertWSError(t, resp, ws.ErrorCodeValidation) + + tasks, err := svc.ListTasks(ctx, workflows[0].ID) + require.NoError(t, err) + assert.Empty(t, tasks, "failed auto-start preflight must not leave a broken task behind") +} + +func TestHandleCreateTask_StartAgentUsesWorkspaceDefaultAgentProfile(t *testing.T) { + svc, _ := newTestTaskService(t) + ctx := context.Background() + workspaces, err := svc.ListWorkspaces(ctx) + require.NoError(t, err) + require.Len(t, workspaces, 1) + workflows, err := svc.ListWorkflows(ctx, workspaces[0].ID, false) + require.NoError(t, err) + require.Len(t, workflows, 1) + + defaultProfileID := "workspace-default-profile" + _, err = svc.UpdateWorkspace(ctx, workspaces[0].ID, &service.UpdateWorkspaceRequest{ + DefaultAgentProfileID: &defaultProfileID, + }) + require.NoError(t, err) + + launcher := newMockSessionLauncher() + h := &Handlers{ + taskSvc: svc, + sessionLauncher: launcher, + logger: testLogger(t).WithFields(), + } + msg := makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{ + "workspace_id": workspaces[0].ID, + "workflow_id": workflows[0].ID, + "title": "Valid auto-start task", + "description": "This should launch with the workspace default profile.", + }) + + resp, err := h.handleCreateTask(ctx, msg) + require.NoError(t, err) + require.Equalf(t, ws.MessageTypeResponse, resp.Type, "create_task should succeed; payload: %s", string(resp.Payload)) + + select { + case <-launcher.called: + case <-time.After(2 * time.Second): + t.Fatal("LaunchSession was not called within timeout") + } + req := launcher.getRequest() + assert.Equal(t, defaultProfileID, req.AgentProfileID) +} + +func TestResolveMCPAutoStartConfig_UsesWorkflowStepAgentProfile(t *testing.T) { + svc, _, workflowCtrl, workflowRepo := newTestTaskServiceWithWorkflow(t) + ctx := context.Background() + workspace, workflow := defaultWorkspaceAndWorkflow(t, ctx, svc) + step := seedWorkflowStep(t, ctx, workflowRepo, &workflowmodels.WorkflowStep{ + WorkflowID: workflow.ID, + Name: "Implement", + Position: 1, + AgentProfileID: "step-profile", + AllowManualMove: true, + }) + h := &Handlers{taskSvc: svc, workflowCtrl: workflowCtrl, logger: testLogger(t).WithFields()} + + config := h.resolveMCPAutoStartConfig(ctx, &models.Task{ + WorkspaceID: workspace.ID, + WorkflowID: workflow.ID, + WorkflowStepID: step.ID, + }, "", "", "") + + assert.Equal(t, "step-profile", config.AgentProfileID) +} + +func TestResolveMCPAutoStartConfig_UsesLowestPositionStepAgentProfileWhenNoStartStep(t *testing.T) { + svc, _, workflowCtrl, workflowRepo := newTestTaskServiceWithWorkflow(t) + ctx := context.Background() + workspace, workflow := defaultWorkspaceAndWorkflow(t, ctx, svc) + seedWorkflowStep(t, ctx, workflowRepo, &workflowmodels.WorkflowStep{ + WorkflowID: workflow.ID, + Name: "Later", + Position: 10, + AgentProfileID: "later-profile", + AllowManualMove: true, + }) + seedWorkflowStep(t, ctx, workflowRepo, &workflowmodels.WorkflowStep{ + WorkflowID: workflow.ID, + Name: "First", + Position: 2, + AgentProfileID: "first-profile", + AllowManualMove: true, + }) + h := &Handlers{taskSvc: svc, workflowCtrl: workflowCtrl, logger: testLogger(t).WithFields()} + + config := h.resolveMCPAutoStartConfig(ctx, &models.Task{ + WorkspaceID: workspace.ID, + WorkflowID: workflow.ID, + }, "", "", "") + + assert.Equal(t, "first-profile", config.AgentProfileID) +} + +func TestStartStepAgentProfile_UsesLowestPositionWhenNoStartStep(t *testing.T) { + profileID := startStepAgentProfile([]*workflowmodels.WorkflowStep{ + {Position: 10, AgentProfileID: "later-profile"}, + nil, + {Position: 2, AgentProfileID: "first-profile"}, + }) + + assert.Equal(t, "first-profile", profileID) +} + +func TestResolveMCPAutoStartConfig_UsesMarkedStartStepAgentProfile(t *testing.T) { + svc, _, workflowCtrl, workflowRepo := newTestTaskServiceWithWorkflow(t) + ctx := context.Background() + workspace, workflow := defaultWorkspaceAndWorkflow(t, ctx, svc) + seedWorkflowStep(t, ctx, workflowRepo, &workflowmodels.WorkflowStep{ + WorkflowID: workflow.ID, + Name: "First", + Position: 1, + AgentProfileID: "first-profile", + AllowManualMove: true, + }) + seedWorkflowStep(t, ctx, workflowRepo, &workflowmodels.WorkflowStep{ + WorkflowID: workflow.ID, + Name: "Start Here", + Position: 2, + IsStartStep: true, + AgentProfileID: "start-profile", + AllowManualMove: true, + }) + h := &Handlers{taskSvc: svc, workflowCtrl: workflowCtrl, logger: testLogger(t).WithFields()} + + config := h.resolveMCPAutoStartConfig(ctx, &models.Task{ + WorkspaceID: workspace.ID, + WorkflowID: workflow.ID, + }, "", "", "") + + assert.Equal(t, "start-profile", config.AgentProfileID) +} + +func TestResolveMCPAutoStartConfig_FallsBackToWorkflowAgentProfile(t *testing.T) { + svc, _, workflowCtrl, workflowRepo := newTestTaskServiceWithWorkflow(t) + ctx := context.Background() + workspace, workflow := defaultWorkspaceAndWorkflow(t, ctx, svc) + workflowProfileID := "workflow-profile" + _, err := svc.UpdateWorkflow(ctx, workflow.ID, &service.UpdateWorkflowRequest{ + AgentProfileID: &workflowProfileID, + }) + require.NoError(t, err) + seedWorkflowStep(t, ctx, workflowRepo, &workflowmodels.WorkflowStep{ + WorkflowID: workflow.ID, + Name: "Unassigned", + Position: 1, + AllowManualMove: true, + }) + h := &Handlers{taskSvc: svc, workflowCtrl: workflowCtrl, logger: testLogger(t).WithFields()} + + config := h.resolveMCPAutoStartConfig(ctx, &models.Task{ + WorkspaceID: workspace.ID, + WorkflowID: workflow.ID, + }, "", "", "") + + assert.Equal(t, workflowProfileID, config.AgentProfileID) +} + +func defaultWorkspaceAndWorkflow(t *testing.T, ctx context.Context, svc *service.Service) (*models.Workspace, *models.Workflow) { + t.Helper() + workspaces, err := svc.ListWorkspaces(ctx) + require.NoError(t, err) + require.Len(t, workspaces, 1) + workflow, err := svc.CreateWorkflow(ctx, &service.CreateWorkflowRequest{ + WorkspaceID: workspaces[0].ID, + Name: "Workflow under test", + }) + require.NoError(t, err) + return workspaces[0], workflow +} + +func seedWorkflowStep(t *testing.T, ctx context.Context, repo *workflowrepo.Repository, step *workflowmodels.WorkflowStep) *workflowmodels.WorkflowStep { + t.Helper() + require.NoError(t, repo.CreateStep(ctx, step)) + return step +} + // mockSessionLauncher captures LaunchSession calls for testing autoStartTask. type mockSessionLauncher struct { - mu sync.Mutex - req *orchestrator.LaunchSessionRequest - called chan struct{} + mu sync.Mutex + req *orchestrator.LaunchSessionRequest + called chan struct{} + inspect func(context.Context) } func newMockSessionLauncher() *mockSessionLauncher { return &mockSessionLauncher{called: make(chan struct{})} } -func (m *mockSessionLauncher) LaunchSession(_ context.Context, req *orchestrator.LaunchSessionRequest) (*orchestrator.LaunchSessionResponse, error) { +func (m *mockSessionLauncher) LaunchSession(ctx context.Context, req *orchestrator.LaunchSessionRequest) (*orchestrator.LaunchSessionResponse, error) { m.mu.Lock() m.req = req m.mu.Unlock() + if m.inspect != nil { + m.inspect(ctx) + } close(m.called) return &orchestrator.LaunchSessionResponse{ Success: true, @@ -316,8 +569,9 @@ func TestAutoStartTask_DefaultsToWorktreeExecutor(t *testing.T) { } task := &models.Task{ - ID: "task-1", - WorkspaceID: "ws-1", + ID: "task-1", + WorkspaceID: "ws-1", + WorkflowStepID: "step-1", } // Call with agent profile but no executor info @@ -333,6 +587,7 @@ func TestAutoStartTask_DefaultsToWorktreeExecutor(t *testing.T) { assert.Equal(t, models.ExecutorIDWorktree, req.ExecutorID, "should default to exec-worktree") assert.Equal(t, "", req.ExecutorProfileID) assert.Equal(t, "agent-profile-1", req.AgentProfileID) + assert.Equal(t, "step-1", req.WorkflowStepID) } func TestAutoStartTask_ExplicitExecutorProfilePreserved(t *testing.T) { @@ -362,6 +617,45 @@ func TestAutoStartTask_ExplicitExecutorProfilePreserved(t *testing.T) { assert.Equal(t, "", req.ExecutorID, "executorID should be empty when profile is set") } +func TestLaunchAutoStartTask_PreservesContextValuesWithoutCallerCancellation(t *testing.T) { + type contextKey string + + launcher := newMockSessionLauncher() + log := testLogger(t) + h := &Handlers{ + sessionLauncher: launcher, + logger: log.WithFields(), + } + + key := contextKey("request-id") + var capturedValue any + var capturedErr error + launcher.inspect = func(ctx context.Context) { + capturedValue = ctx.Value(key) + capturedErr = ctx.Err() + } + + parentCtx, cancel := context.WithCancel(context.WithValue(context.Background(), key, "request-1")) + cancel() + + h.launchAutoStartTask(parentCtx, &models.Task{ + ID: "task-1", + WorkspaceID: "ws-1", + }, mcpAutoStartConfig{ + AgentProfileID: "agent-profile-1", + ExecutorID: models.ExecutorIDWorktree, + }) + + select { + case <-launcher.called: + case <-time.After(2 * time.Second): + t.Fatal("LaunchSession was not called within timeout") + } + + assert.Equal(t, "request-1", capturedValue) + assert.NoError(t, capturedErr) +} + func TestResolveTaskRepositories_ExplicitRepos(t *testing.T) { log := testLogger(t) h := &Handlers{logger: log.WithFields()} diff --git a/apps/web/components/task/task-select-helpers.test.ts b/apps/web/components/task/task-select-helpers.test.ts index 5f0d2ab95e..653ac94f09 100644 --- a/apps/web/components/task/task-select-helpers.test.ts +++ b/apps/web/components/task/task-select-helpers.test.ts @@ -25,8 +25,10 @@ vi.mock("@/lib/links", () => ({ import { launchSession, type LaunchSessionResponse } from "@/lib/services/session-launch-service"; import { performLayoutSwitch, releaseLayoutToDefault } from "@/lib/state/dockview-store"; +import { replaceTaskUrl } from "@/lib/links"; import type { StoreApi } from "zustand"; import type { AppState } from "@/lib/state/store"; +import type { TaskSession } from "@/lib/types/http"; const NEW_TASK_ID = "task-new"; const OLD_SESSION_ID = "old-session"; @@ -50,6 +52,123 @@ function makeEnvStore(envIds: Record): StoreApi { } as unknown as StoreApi; } +const TASK_ID = "task-A"; +const PRIMARY = "sess-primary"; +const ORIGINAL_TASK_ID = "task-original"; +const PENDING_TASK_ID = "task-pending"; +const ORIGINAL_SESSION_ID = "sess-original"; +const PENDING_SESSION_ID = "sess-pending"; +const ORIGINAL_ENV_ID = "env-original"; + +function makeKanbanStore(args: { + activeTaskId?: string | null; + activeSessionId: string | null; + envIds: Record; + lastSessionByTaskId?: Record; + sessionTaskIds?: Record; +}): StoreApi { + const items: Record = {}; + for (const [sid, tid] of Object.entries(args.sessionTaskIds ?? {})) { + items[sid] = { id: sid, task_id: tid }; + } + const state = { + tasks: { + activeTaskId: args.activeTaskId ?? null, + activeSessionId: args.activeSessionId, + lastSessionByTaskId: args.lastSessionByTaskId ?? {}, + }, + taskPRs: { byTaskId: {} as Record }, + environmentIdBySessionId: args.envIds, + taskSessions: { items }, + }; + return { + getState: () => state as unknown as AppState, + setState: vi.fn(), + subscribe: vi.fn(), + } as unknown as StoreApi; +} + +function runSelect(store: StoreApi) { + const switchToSession = vi.fn(); + selectTaskWithLayout({ + taskId: TASK_ID, + task: { primarySessionId: PRIMARY }, + store, + switchToSession, + loadTaskSessionsForTask: vi.fn(async () => []), + setActiveTask: vi.fn(), + setPreparingTaskId: vi.fn(), + }); + return switchToSession; +} + +async function flushTaskSelection() { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function makeSelectionHarness(args: { + activeTaskId: string; + activeSessionId: string | null; + envIds?: Record; + sessions?: Record; +}) { + const listeners: Array<(state: AppState, previousState: AppState) => void> = []; + const state = { + tasks: { + activeTaskId: args.activeTaskId, + activeSessionId: args.activeSessionId, + lastSessionByTaskId: {}, + }, + taskPRs: { byTaskId: {} as Record }, + environmentIdBySessionId: args.envIds ?? {}, + taskSessions: { items: (args.sessions ?? {}) as Record }, + }; + const snapshot = () => + ({ + ...state, + tasks: { ...state.tasks }, + }) as unknown as AppState; + const notify = (previousState: AppState) => { + for (const listener of listeners) { + listener(state as unknown as AppState, previousState); + } + }; + const store = { + getState: () => state as unknown as AppState, + setState: vi.fn(), + subscribe: vi.fn((listener: (state: AppState, previousState: AppState) => void) => { + listeners.push(listener); + return () => { + const index = listeners.indexOf(listener); + if (index >= 0) listeners.splice(index, 1); + }; + }), + } as unknown as StoreApi; + const setActiveTask = vi.fn((taskId: string) => { + const previousState = snapshot(); + state.tasks.activeTaskId = taskId; + state.tasks.activeSessionId = null; + notify(previousState); + }); + return { state, store, setActiveTask, getListenerCount: () => listeners.length }; +} + +function makeDeferredSessionLoader() { + let resolveLoad: (sessions: TaskSession[]) => void = () => {}; + const loadTaskSessionsForTask = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + return { + loadTaskSessionsForTask, + resolveLoad: (sessions: TaskSession[]) => resolveLoad(sessions), + }; +} + describe("prepareAndSwitchTask — outgoing-env panel cleanup", () => { beforeEach(() => { vi.clearAllMocks(); @@ -185,53 +304,10 @@ describe("buildSwitchToSession", () => { * re-entry, as long as the session still has a known environment. */ describe("selectTaskWithLayout — last-selected session preference", () => { - const TASK_ID = "task-A"; - const PRIMARY = "sess-primary"; - beforeEach(() => { vi.clearAllMocks(); }); - function makeKanbanStore(args: { - activeSessionId: string | null; - envIds: Record; - lastSessionByTaskId?: Record; - sessionTaskIds?: Record; - }): StoreApi { - const items: Record = {}; - for (const [sid, tid] of Object.entries(args.sessionTaskIds ?? {})) { - items[sid] = { id: sid, task_id: tid }; - } - const state = { - tasks: { - activeSessionId: args.activeSessionId, - lastSessionByTaskId: args.lastSessionByTaskId ?? {}, - }, - taskPRs: { byTaskId: {} as Record }, - environmentIdBySessionId: args.envIds, - taskSessions: { items }, - }; - return { - getState: () => state as unknown as AppState, - setState: vi.fn(), - subscribe: vi.fn(), - } as unknown as StoreApi; - } - - function runSelect(store: StoreApi) { - const switchToSession = vi.fn(); - selectTaskWithLayout({ - taskId: TASK_ID, - task: { primarySessionId: PRIMARY }, - store, - switchToSession, - loadTaskSessionsForTask: vi.fn(async () => []), - setActiveTask: vi.fn(), - setPreparingTaskId: vi.fn(), - }); - return switchToSession; - } - it("prefers the user's last-selected session over primarySessionId on re-entry", () => { const LAST = "sess-gpt"; const switchToSession = runSelect( @@ -292,3 +368,252 @@ describe("selectTaskWithLayout — last-selected session preference", () => { expect(switchToSession).toHaveBeenCalledWith(TASK_ID, PRIMARY, null); }); }); + +describe("selectTaskWithLayout — pending selection races", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not switch back to a sessionless task when its prepare failure resolves after another task was selected", async () => { + const SESSIONLESS = "task-sessionless"; + const OTHER = "task-other"; + const { state, store, setActiveTask } = makeSelectionHarness({ + activeTaskId: SESSIONLESS, + activeSessionId: null, + envIds: { "sess-other": "env-other" }, + sessions: { "sess-other": { id: "sess-other", task_id: OTHER } }, + }); + const { loadTaskSessionsForTask, resolveLoad } = makeDeferredSessionLoader(); + + selectTaskWithLayout({ + taskId: SESSIONLESS, + task: undefined, + store, + switchToSession: vi.fn(), + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + + state.tasks.activeTaskId = OTHER; + state.tasks.activeSessionId = "sess-other"; + selectTaskWithLayout({ + taskId: OTHER, + task: { primarySessionId: "sess-other" }, + store, + switchToSession: vi.fn(), + loadTaskSessionsForTask: vi.fn(async () => []), + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + resolveLoad([]); + await flushTaskSelection(); + + expect(setActiveTask).not.toHaveBeenCalledWith(SESSIONLESS); + expect(releaseLayoutToDefault).not.toHaveBeenCalled(); + expect(replaceTaskUrl).not.toHaveBeenCalledWith(SESSIONLESS); + }); + + it("does not switch to an old pending selection after the user clicks back to the original task", async () => { + const { store, setActiveTask } = makeSelectionHarness({ + activeTaskId: ORIGINAL_TASK_ID, + activeSessionId: ORIGINAL_SESSION_ID, + envIds: { [ORIGINAL_SESSION_ID]: ORIGINAL_ENV_ID }, + sessions: { + [ORIGINAL_SESSION_ID]: { id: ORIGINAL_SESSION_ID, task_id: ORIGINAL_TASK_ID }, + }, + }); + const { loadTaskSessionsForTask, resolveLoad } = makeDeferredSessionLoader(); + + selectTaskWithLayout({ + taskId: PENDING_TASK_ID, + task: undefined, + store, + switchToSession: vi.fn(), + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + + selectTaskWithLayout({ + taskId: ORIGINAL_TASK_ID, + task: { primarySessionId: ORIGINAL_SESSION_ID }, + store, + switchToSession: vi.fn(), + loadTaskSessionsForTask: vi.fn(async () => []), + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + resolveLoad([]); + await flushTaskSelection(); + + expect(setActiveTask).not.toHaveBeenCalledWith(PENDING_TASK_ID); + expect(replaceTaskUrl).not.toHaveBeenCalledWith(PENDING_TASK_ID); + }); +}); + +describe("selectTaskWithLayout — external active-task changes", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not apply a pending selection after an external active-task change", async () => { + const OTHER = "task-other"; + const switchToSession = vi.fn(); + const { state, store, setActiveTask } = makeSelectionHarness({ + activeTaskId: ORIGINAL_TASK_ID, + activeSessionId: ORIGINAL_SESSION_ID, + envIds: { [ORIGINAL_SESSION_ID]: ORIGINAL_ENV_ID }, + sessions: { + [PENDING_SESSION_ID]: { id: PENDING_SESSION_ID, task_id: PENDING_TASK_ID }, + [ORIGINAL_SESSION_ID]: { id: ORIGINAL_SESSION_ID, task_id: ORIGINAL_TASK_ID }, + }, + }); + const { loadTaskSessionsForTask, resolveLoad } = makeDeferredSessionLoader(); + + selectTaskWithLayout({ + taskId: PENDING_TASK_ID, + task: { primarySessionId: PENDING_SESSION_ID }, + store, + switchToSession, + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + + state.tasks.activeTaskId = OTHER; + state.tasks.activeSessionId = null; + resolveLoad([ + { id: PENDING_SESSION_ID, task_id: PENDING_TASK_ID, is_primary: true } as TaskSession, + ]); + await flushTaskSelection(); + + expect(switchToSession).not.toHaveBeenCalled(); + expect(replaceTaskUrl).not.toHaveBeenCalledWith(PENDING_TASK_ID); + }); + + it("does not apply a pending selection after an external task switch returns to the original task", async () => { + const OTHER = "task-other"; + const switchToSession = vi.fn(); + const { store, setActiveTask } = makeSelectionHarness({ + activeTaskId: ORIGINAL_TASK_ID, + activeSessionId: ORIGINAL_SESSION_ID, + envIds: { [ORIGINAL_SESSION_ID]: ORIGINAL_ENV_ID }, + sessions: { + [PENDING_SESSION_ID]: { id: PENDING_SESSION_ID, task_id: PENDING_TASK_ID }, + [ORIGINAL_SESSION_ID]: { id: ORIGINAL_SESSION_ID, task_id: ORIGINAL_TASK_ID }, + }, + }); + const { loadTaskSessionsForTask, resolveLoad } = makeDeferredSessionLoader(); + + selectTaskWithLayout({ + taskId: PENDING_TASK_ID, + task: { primarySessionId: PENDING_SESSION_ID }, + store, + switchToSession, + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + + setActiveTask(OTHER); + setActiveTask(ORIGINAL_TASK_ID); + resolveLoad([ + { id: PENDING_SESSION_ID, task_id: PENDING_TASK_ID, is_primary: true } as TaskSession, + ]); + await flushTaskSelection(); + + expect(switchToSession).not.toHaveBeenCalled(); + expect(replaceTaskUrl).not.toHaveBeenCalledWith(PENDING_TASK_ID); + }); +}); + +describe("selectTaskWithLayout — selection guard cleanup", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("disposes the selection guard when loading a primary task's sessions rejects", async () => { + const { store, setActiveTask, getListenerCount } = makeSelectionHarness({ + activeTaskId: ORIGINAL_TASK_ID, + activeSessionId: ORIGINAL_SESSION_ID, + envIds: { [ORIGINAL_SESSION_ID]: ORIGINAL_ENV_ID }, + }); + + selectTaskWithLayout({ + taskId: PENDING_TASK_ID, + task: { primarySessionId: PENDING_SESSION_ID }, + store, + switchToSession: vi.fn(), + loadTaskSessionsForTask: vi.fn(async () => { + throw new Error("load failed"); + }), + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + + expect(getListenerCount()).toBe(1); + await flushTaskSelection(); + + expect(getListenerCount()).toBe(0); + }); + + it("disposes the selection guard when loading a sessionless task rejects", async () => { + const { store, setActiveTask, getListenerCount } = makeSelectionHarness({ + activeTaskId: ORIGINAL_TASK_ID, + activeSessionId: ORIGINAL_SESSION_ID, + envIds: { [ORIGINAL_SESSION_ID]: ORIGINAL_ENV_ID }, + }); + + selectTaskWithLayout({ + taskId: PENDING_TASK_ID, + task: undefined, + store, + switchToSession: vi.fn(), + loadTaskSessionsForTask: vi.fn(async () => { + throw new Error("load failed"); + }), + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + + expect(getListenerCount()).toBe(1); + await flushTaskSelection(); + + expect(getListenerCount()).toBe(0); + }); +}); + +describe("selectTaskWithLayout — pending old-session changes", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("keeps a pending selection alive when only the old task's session id changes", async () => { + const OLD = "task-old"; + const SESSIONLESS = "task-sessionless"; + vi.mocked(launchSession).mockResolvedValue({} as never); + const { state, store, setActiveTask } = makeSelectionHarness({ + activeTaskId: OLD, + activeSessionId: "sess-old", + }); + const { loadTaskSessionsForTask, resolveLoad } = makeDeferredSessionLoader(); + + selectTaskWithLayout({ + taskId: SESSIONLESS, + task: undefined, + store, + switchToSession: vi.fn(), + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId: vi.fn(), + }); + + state.tasks.activeSessionId = "sess-old-replaced"; + resolveLoad([]); + await flushTaskSelection(); + + expect(setActiveTask).toHaveBeenCalledWith(SESSIONLESS); + expect(replaceTaskUrl).toHaveBeenCalledWith(SESSIONLESS); + }); +}); diff --git a/apps/web/components/task/task-select-helpers.ts b/apps/web/components/task/task-select-helpers.ts index 66e8496fe1..24acef28b8 100644 --- a/apps/web/components/task/task-select-helpers.ts +++ b/apps/web/components/task/task-select-helpers.ts @@ -19,6 +19,7 @@ import { buildPrepareRequest } from "@/lib/services/session-launch-helpers"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; const debug = createDebugLogger("dockview:task-select"); +let taskSelectionSequence = 0; export type SwitchToSessionFn = ( taskId: string, @@ -109,11 +110,21 @@ export function buildSwitchToSession( }; } +function nextTaskSelectionToken(): number { + taskSelectionSequence += 1; + return taskSelectionSequence; +} + +function taskSelectionWasSuperseded(selectionToken: number): boolean { + return selectionToken !== taskSelectionSequence; +} + export async function prepareAndSwitchTask( taskId: string, store: StoreApi, switchToSession: SwitchToSessionFn, setPreparingTaskId: (id: string | null) => void, + shouldContinue: () => boolean = () => true, ): Promise { setPreparingTaskId(taskId); // Capture before the async launch; WS events may update activeSessionId @@ -131,6 +142,7 @@ export async function prepareAndSwitchTask( try { const { request } = buildPrepareRequest(taskId); const resp = await launchSession(request); + if (!shouldContinue()) return false; if (resp.session_id) { // Pass `null` instead of the original oldSessionId — releaseLayoutToDefault // already saved + released the outgoing env, and the dockview now holds the @@ -163,7 +175,26 @@ export function selectTaskWithLayout(params: { }): void { const { taskId, task, store, switchToSession, loadTaskSessionsForTask } = params; const state = store.getState(); + const selectionToken = nextTaskSelectionToken(); + const startActiveTaskId = state.tasks.activeTaskId ?? null; const oldSessionId = state.tasks.activeSessionId; + let activeTaskChangedExternally = false; + const unsubscribeSelectionGuard = store.subscribe((current, previous) => { + const currentTaskId = current.tasks.activeTaskId ?? null; + const previousTaskId = previous.tasks.activeTaskId ?? null; + if (currentTaskId !== previousTaskId && currentTaskId !== taskId) { + activeTaskChangedExternally = true; + } + }); + const disposeSelectionGuard = () => { + if (typeof unsubscribeSelectionGuard === "function") unsubscribeSelectionGuard(); + }; + const selectionWasSuperseded = () => { + if (taskSelectionWasSuperseded(selectionToken)) return true; + if (activeTaskChangedExternally) return true; + const activeTaskId = store.getState().tasks.activeTaskId ?? null; + return activeTaskId !== startActiveTaskId && activeTaskId !== taskId; + }; if (isDebug()) { debug("selectTaskWithLayout: entry", { taskId, @@ -182,45 +213,56 @@ export function selectTaskWithLayout(params: { }); const hasEnvId = !!state.environmentIdBySessionId[targetSessionId]; if (hasEnvId) { + disposeSelectionGuard(); switchToSession(taskId, targetSessionId, oldSessionId); loadTaskSessionsForTask(taskId); replaceTaskUrl(taskId); return; } - loadTaskSessionsForTask(taskId).then((sessions) => { - switchToSession(taskId, resolveLoadedSessionId(sessions, targetSessionId), oldSessionId); - replaceTaskUrl(taskId); - }); + void loadTaskSessionsForTask(taskId) + .then((sessions) => { + if (selectionWasSuperseded()) return; + switchToSession(taskId, resolveLoadedSessionId(sessions, targetSessionId), oldSessionId); + replaceTaskUrl(taskId); + }) + .finally(disposeSelectionGuard) + .catch(() => undefined); return; } - loadTaskSessionsForTask(taskId).then(async (sessions) => { - const currentOldSessionId = store.getState().tasks.activeSessionId; - const primary = sessions.find((s) => s.is_primary); - const sessionId = primary?.id ?? sessions[0]?.id ?? null; - if (sessionId) { - switchToSession(taskId, sessionId, currentOldSessionId); - replaceTaskUrl(taskId); - return; - } + void loadTaskSessionsForTask(taskId) + .then(async (sessions) => { + if (selectionWasSuperseded()) return; + const currentOldSessionId = store.getState().tasks.activeSessionId; + const primary = sessions.find((s) => s.is_primary); + const sessionId = primary?.id ?? sessions[0]?.id ?? null; + if (sessionId) { + switchToSession(taskId, sessionId, currentOldSessionId); + replaceTaskUrl(taskId); + return; + } - const switched = await prepareAndSwitchTask( - taskId, - store, - switchToSession, - params.setPreparingTaskId, - ); - if (switched) { - replaceTaskUrl(taskId); - return; - } + const switched = await prepareAndSwitchTask( + taskId, + store, + switchToSession, + params.setPreparingTaskId, + () => !selectionWasSuperseded(), + ); + if (switched) { + replaceTaskUrl(taskId); + return; + } + if (selectionWasSuperseded()) return; - // Failure path: prepareAndSwitchTask already called releaseLayoutToDefault - // before awaiting, so the outgoing env's layout is already saved and the - // dockview is showing the default layout. A second release here would - // overwrite the just-saved env layout with `api.toJSON()` (the default), - // losing the user's real layout for the originating task. - params.setActiveTask(taskId); - replaceTaskUrl(taskId); - }); + // Failure path: prepareAndSwitchTask already called releaseLayoutToDefault + // before awaiting, so the outgoing env's layout is already saved and the + // dockview is showing the default layout. A second release here would + // overwrite the just-saved env layout with `api.toJSON()` (the default), + // losing the user's real layout for the originating task. + params.setActiveTask(taskId); + replaceTaskUrl(taskId); + }) + .finally(disposeSelectionGuard) + .catch(() => undefined); } diff --git a/apps/web/hooks/domains/session/use-ensure-task-session.test.ts b/apps/web/hooks/domains/session/use-ensure-task-session.test.ts index 87f223e4db..9ffa565d37 100644 --- a/apps/web/hooks/domains/session/use-ensure-task-session.test.ts +++ b/apps/web/hooks/domains/session/use-ensure-task-session.test.ts @@ -29,20 +29,22 @@ function flushMicrotasks() { return act(() => Promise.resolve()); } -describe("useEnsureTaskSession", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockLoadSessions.mockResolvedValue(undefined); - mockSessionsResult = { sessions: [], isLoaded: true, loadSessions: mockLoadSessions }; - mockEnsureTaskSession.mockResolvedValue({ - success: true, - task_id: "task-1", - session_id: "sess-new", - state: "CREATED", - source: "created_prepare", - newly_created: true, - }); +function resetEnsureTaskSessionMocks() { + vi.clearAllMocks(); + mockLoadSessions.mockResolvedValue(undefined); + mockSessionsResult = { sessions: [], isLoaded: true, loadSessions: mockLoadSessions }; + mockEnsureTaskSession.mockResolvedValue({ + success: true, + task_id: "task-1", + session_id: "sess-new", + state: "CREATED", + source: "created_prepare", + newly_created: true, }); +} + +describe("useEnsureTaskSession", () => { + beforeEach(resetEnsureTaskSessionMocks); it("calls the backend ensure endpoint once when the task has no sessions", async () => { const { result } = renderHook(() => useEnsureTaskSession(TASK)); @@ -117,6 +119,33 @@ describe("useEnsureTaskSession", () => { await flushMicrotasks(); expect(result.current.status).toBe("idle"); }); +}); + +describe("useEnsureTaskSession — task changes", () => { + beforeEach(resetEnsureTaskSessionMocks); + + it("clears a stale error when switching to a task that already has a session", async () => { + mockEnsureTaskSession.mockRejectedValueOnce(new Error("task one failed")); + const { result, rerender } = renderHook( + ({ task }: { task: { id: string } }) => useEnsureTaskSession(task), + { initialProps: { task: TASK } }, + ); + + await flushMicrotasks(); + expect(result.current.status).toBe("error"); + expect(result.current.error?.message).toBe("task one failed"); + + mockSessionsResult = { + sessions: [{ id: "sess-2" }], + isLoaded: true, + loadSessions: mockLoadSessions, + }; + rerender({ task: { id: "task-2" } }); + + expect(result.current.status).toBe("idle"); + expect(result.current.error).toBeNull(); + expect(mockEnsureTaskSession).toHaveBeenCalledTimes(1); + }); it("calls ensure again when the task id changes", () => { const { rerender } = renderHook( diff --git a/apps/web/hooks/domains/session/use-ensure-task-session.ts b/apps/web/hooks/domains/session/use-ensure-task-session.ts index ce18366b5f..121fefc9e3 100644 --- a/apps/web/hooks/domains/session/use-ensure-task-session.ts +++ b/apps/web/hooks/domains/session/use-ensure-task-session.ts @@ -43,6 +43,17 @@ export function useEnsureTaskSession( // Latch keyed by `${taskId}:${retryToken}` so a re-mount on the same task // doesn't refire, but switching tasks or calling retry() does. const launchedKeyRef = useRef(null); + const previousTaskIdRef = useRef(taskId); + + /* eslint-disable react-hooks/set-state-in-effect -- task changes must clear stale ensure-session errors before early returns */ + useEffect(() => { + if (previousTaskIdRef.current === taskId) return; + previousTaskIdRef.current = taskId; + launchedKeyRef.current = null; + setStatus("idle"); + setError(null); + }, [taskId]); + /* eslint-enable react-hooks/set-state-in-effect */ /* eslint-disable react-hooks/set-state-in-effect -- ensuring a session is a side effect; status mirrors that external work */ useEffect(() => {