fix: prevent invalid MCP task sessions (#1494)

This commit is contained in:
Carlos Florêncio
2026-06-25 18:30:33 +01:00
committed by GitHub
parent ec5a81f805
commit cb7aa3fa71
6 changed files with 944 additions and 115 deletions
+150 -22
View File
@@ -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 ""
}
@@ -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()}
@@ -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<string, string>): StoreApi<AppState> {
} as unknown as StoreApi<AppState>;
}
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<string, string>;
lastSessionByTaskId?: Record<string, string>;
sessionTaskIds?: Record<string, string>;
}): StoreApi<AppState> {
const items: Record<string, { id: string; task_id: string }> = {};
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<string, unknown[]> },
environmentIdBySessionId: args.envIds,
taskSessions: { items },
};
return {
getState: () => state as unknown as AppState,
setState: vi.fn(),
subscribe: vi.fn(),
} as unknown as StoreApi<AppState>;
}
function runSelect(store: StoreApi<AppState>) {
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<string, string>;
sessions?: Record<string, { id: string; task_id: string }>;
}) {
const listeners: Array<(state: AppState, previousState: AppState) => void> = [];
const state = {
tasks: {
activeTaskId: args.activeTaskId,
activeSessionId: args.activeSessionId,
lastSessionByTaskId: {},
},
taskPRs: { byTaskId: {} as Record<string, unknown[]> },
environmentIdBySessionId: args.envIds ?? {},
taskSessions: { items: (args.sessions ?? {}) as Record<string, TaskSession> },
};
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<AppState>;
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<TaskSession[]>((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<string, string>;
lastSessionByTaskId?: Record<string, string>;
sessionTaskIds?: Record<string, string>;
}): StoreApi<AppState> {
const items: Record<string, { id: string; task_id: string }> = {};
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<string, unknown[]> },
environmentIdBySessionId: args.envIds,
taskSessions: { items },
};
return {
getState: () => state as unknown as AppState,
setState: vi.fn(),
subscribe: vi.fn(),
} as unknown as StoreApi<AppState>;
}
function runSelect(store: StoreApi<AppState>) {
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);
});
});
+73 -31
View File
@@ -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<AppState>,
switchToSession: SwitchToSessionFn,
setPreparingTaskId: (id: string | null) => void,
shouldContinue: () => boolean = () => true,
): Promise<boolean> {
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);
}
@@ -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(
@@ -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<string | null>(null);
const previousTaskIdRef = useRef<string | null>(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(() => {