diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 5e29b2dcb2..09356da39a 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -691,11 +691,17 @@ func (h *Handlers) resolveMCPLaunchMetadata(ctx context.Context, task *models.Ta } func (h *Handlers) resolveMCPAutoStartConfigWithError(ctx context.Context, task *models.Task, agentProfileID, executorProfileID, sourceTaskID string) (mcpAutoStartConfig, error) { - executorID := h.inheritFromTask(ctx, task.ParentID, &agentProfileID, &executorProfileID) + executorID, err := h.inheritFromTask(ctx, task.ParentID, &agentProfileID, &executorProfileID) + if err != nil { + return mcpAutoStartConfig{}, fmt.Errorf("inherit from parent task %s: %w", task.ParentID, err) + } // For top-level tasks, inherit from the source task (the calling agent's task). if task.ParentID == "" && sourceTaskID != "" { - sourceExecutorID := h.inheritFromTask(ctx, sourceTaskID, &agentProfileID, &executorProfileID) + sourceExecutorID, err := h.inheritFromTask(ctx, sourceTaskID, &agentProfileID, &executorProfileID) + if err != nil { + return mcpAutoStartConfig{}, fmt.Errorf("inherit from source task %s: %w", sourceTaskID, err) + } if executorID == "" { executorID = sourceExecutorID } @@ -853,34 +859,78 @@ func (h *Handlers) launchAutoStartTask(ctx context.Context, task *models.Task, c } // inheritFromTask fills agentProfileID and executorProfileID from another task's -// primary session or durable metadata when not explicitly provided. It returns a -// bare ExecutorID only when no executor profile was resolved, because an -// executor profile already encodes its executor reference. -func (h *Handlers) inheritFromTask(ctx context.Context, taskID string, agentProfileID, executorProfileID *string) string { +// durable launch metadata or primary session when not explicitly provided. It +// returns a bare ExecutorID only when no executor profile was resolved, because +// an executor profile already encodes its executor reference. +func (h *Handlers) inheritFromTask(ctx context.Context, taskID string, agentProfileID, executorProfileID *string) (string, error) { if taskID == "" || h.taskSvc == nil { - return "" + return "", nil } - executorID := h.inheritFromPrimarySession(ctx, taskID, agentProfileID, executorProfileID) - needsMetadata := *agentProfileID == "" || *executorProfileID == "" || executorID != "" - if needsMetadata { - executorID = h.inheritFromTaskMetadata(ctx, taskID, agentProfileID, executorProfileID, executorID) + agentProfileExplicit := *agentProfileID != "" + executorProfileExplicit := *executorProfileID != "" + executorID := h.inheritFromTaskMetadata(ctx, taskID, agentProfileID, executorProfileID, "") + session, err := h.taskSvc.GetPrimarySession(ctx, taskID) + if err != nil { + if errors.Is(err, taskrepo.ErrNoPrimarySession) { + return executorID, nil + } + return "", err + } + if session != nil { + executorID = inheritWorkflowRoutedSession(session, agentProfileID, executorProfileID, executorID, agentProfileExplicit, executorProfileExplicit) + sessionExecutorID := inheritFromSession(session, agentProfileID, executorProfileID, executorID == "") + if executorID == "" { + executorID = sessionExecutorID + } } if *executorProfileID != "" { + return "", nil + } + return executorID, nil +} + +func inheritWorkflowRoutedSession( + session *models.TaskSession, + agentProfileID, executorProfileID *string, + executorID string, + agentProfileExplicit, executorProfileExplicit bool, +) string { + if !isWorkflowSwitchedSession(session) { + return executorID + } + if !agentProfileExplicit && session.AgentProfileID != "" { + *agentProfileID = session.AgentProfileID + } + if executorProfileExplicit { + return executorID + } + if session.ExecutorProfileID != "" { + *executorProfileID = session.ExecutorProfileID return "" } + if session.ExecutorID != "" { + *executorProfileID = "" + return session.ExecutorID + } return executorID } -func (h *Handlers) inheritFromPrimarySession(ctx context.Context, taskID string, agentProfileID, executorProfileID *string) string { - session, err := h.taskSvc.GetPrimarySession(ctx, taskID) - if err != nil || session == nil { - return "" +func isWorkflowSwitchedSession(session *models.TaskSession) bool { + if session == nil { + return false } + return metadataString(session.Metadata, models.SessionMetaKeyCreatedBy) == models.SessionCreatedByWorkflowSwitch +} + +func inheritFromSession(session *models.TaskSession, agentProfileID, executorProfileID *string, inheritExecutor bool) string { if *agentProfileID == "" { *agentProfileID = session.AgentProfileID } + if !inheritExecutor { + return "" + } if *executorProfileID == "" { *executorProfileID = session.ExecutorProfileID } diff --git a/apps/backend/internal/mcp/handlers/handlers_test.go b/apps/backend/internal/mcp/handlers/handlers_test.go index 2722af63ec..4114c7ccec 100644 --- a/apps/backend/internal/mcp/handlers/handlers_test.go +++ b/apps/backend/internal/mcp/handlers/handlers_test.go @@ -531,6 +531,275 @@ func TestHandleCreateTask_InheritsDeferredSourceTaskMetadata(t *testing.T) { assert.Equal(t, "metadata-executor", task.Metadata[models.MetaKeyExecutorID]) } +func TestHandleCreateTask_SourceTaskMetadataWinsOverSessionAndDefaults(t *testing.T) { + svc, repo := 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) + + workspaceDefaultProfileID := "workspace-default-profile" + _, err = svc.UpdateWorkspace(ctx, workspaces[0].ID, &service.UpdateWorkspaceRequest{ + DefaultAgentProfileID: &workspaceDefaultProfileID, + }) + require.NoError(t, err) + workflowProfileID := "workflow-default-profile" + _, err = svc.UpdateWorkflow(ctx, workflows[0].ID, &service.UpdateWorkflowRequest{ + AgentProfileID: &workflowProfileID, + }) + require.NoError(t, err) + + source, err := svc.CreateTask(ctx, &service.CreateTaskRequest{ + WorkspaceID: workspaces[0].ID, + WorkflowID: workflows[0].ID, + Title: "Current task", + Metadata: map[string]interface{}{ + models.MetaKeyAgentProfileID: "source-metadata-profile", + models.MetaKeyExecutorProfileID: "source-metadata-executor", + }, + }) + require.NoError(t, err) + require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "source-primary-session", + TaskID: source.ID, + AgentProfileID: workspaceDefaultProfileID, + ExecutorProfileID: "session-executor-profile", + State: models.TaskSessionStateWaitingForInput, + IsPrimary: true, + })) + + h := &Handlers{ + taskSvc: svc, + logger: testLogger(t).WithFields(), + } + msg := makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{ + "source_task_id": source.ID, + "workspace_id": workspaces[0].ID, + "workflow_id": workflows[0].ID, + "title": "Follow-up from current task", + "description": "This should inherit the current task's configured profile.", + "start_agent": false, + }) + + 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)) + + var created struct { + ID string `json:"id"` + } + require.NoError(t, json.Unmarshal(resp.Payload, &created)) + require.NotEmpty(t, created.ID) + + task, err := svc.GetTask(ctx, created.ID) + require.NoError(t, err) + require.NotNil(t, task.Metadata) + assert.Equal(t, "source-metadata-profile", task.Metadata[models.MetaKeyAgentProfileID]) + assert.Equal(t, "source-metadata-executor", task.Metadata[models.MetaKeyExecutorProfileID]) +} + +func TestHandleCreateTask_InheritsSourceTaskProfileAndSessionExecutor(t *testing.T) { + svc, repo := 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) + + source, err := svc.CreateTask(ctx, &service.CreateTaskRequest{ + WorkspaceID: workspaces[0].ID, + WorkflowID: workflows[0].ID, + Title: "Current task", + Metadata: map[string]interface{}{ + models.MetaKeyAgentProfileID: "source-metadata-profile", + }, + }) + require.NoError(t, err) + require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "source-session-executor", + TaskID: source.ID, + AgentProfileID: "session-profile", + ExecutorProfileID: "session-executor-profile", + State: models.TaskSessionStateWaitingForInput, + IsPrimary: true, + })) + + h := &Handlers{ + taskSvc: svc, + logger: testLogger(t).WithFields(), + } + msg := makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{ + "source_task_id": source.ID, + "workspace_id": workspaces[0].ID, + "workflow_id": workflows[0].ID, + "title": "Follow-up from mixed launch config", + "description": "This should inherit profile from metadata and executor from session.", + "start_agent": false, + }) + + 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)) + + var created struct { + ID string `json:"id"` + } + require.NoError(t, json.Unmarshal(resp.Payload, &created)) + require.NotEmpty(t, created.ID) + + task, err := svc.GetTask(ctx, created.ID) + require.NoError(t, err) + require.NotNil(t, task.Metadata) + assert.Equal(t, "source-metadata-profile", task.Metadata[models.MetaKeyAgentProfileID]) + assert.Equal(t, "session-executor-profile", task.Metadata[models.MetaKeyExecutorProfileID]) +} + +func TestHandleCreateTask_WorkflowSwitchedSessionProfileWinsOverStaleMetadata(t *testing.T) { + svc, repo := 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) + + source, err := svc.CreateTask(ctx, &service.CreateTaskRequest{ + WorkspaceID: workspaces[0].ID, + WorkflowID: workflows[0].ID, + Title: "Workflow-routed task", + Metadata: map[string]interface{}{ + models.MetaKeyAgentProfileID: "stale-task-profile", + models.MetaKeyExecutorProfileID: "stale-executor-profile", + }, + }) + require.NoError(t, err) + require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "workflow-switched-session", + TaskID: source.ID, + AgentProfileID: "effective-step-profile", + ExecutorProfileID: "effective-executor-profile", + State: models.TaskSessionStateWaitingForInput, + IsPrimary: true, + Metadata: map[string]interface{}{ + models.SessionMetaKeyCreatedBy: models.SessionCreatedByWorkflowSwitch, + }, + })) + + h := &Handlers{ + taskSvc: svc, + logger: testLogger(t).WithFields(), + } + msg := makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{ + "source_task_id": source.ID, + "workspace_id": workspaces[0].ID, + "workflow_id": workflows[0].ID, + "title": "Follow-up from workflow-routed task", + "description": "This should inherit the effective step profile.", + "start_agent": false, + }) + + 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)) + + var created struct { + ID string `json:"id"` + } + require.NoError(t, json.Unmarshal(resp.Payload, &created)) + require.NotEmpty(t, created.ID) + + task, err := svc.GetTask(ctx, created.ID) + require.NoError(t, err) + require.NotNil(t, task.Metadata) + assert.Equal(t, "effective-step-profile", task.Metadata[models.MetaKeyAgentProfileID]) + assert.Equal(t, "effective-executor-profile", task.Metadata[models.MetaKeyExecutorProfileID]) +} + +func TestHandleCreateTask_ExplicitAgentStillInheritsRoutedSessionExecutor(t *testing.T) { + svc, repo := 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) + + source, err := svc.CreateTask(ctx, &service.CreateTaskRequest{ + WorkspaceID: workspaces[0].ID, + WorkflowID: workflows[0].ID, + Title: "Workflow-routed task", + Metadata: map[string]interface{}{ + models.MetaKeyAgentProfileID: "stale-task-profile", + models.MetaKeyExecutorProfileID: "stale-executor-profile", + }, + }) + require.NoError(t, err) + require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "workflow-switched-session-explicit-agent", + TaskID: source.ID, + AgentProfileID: "effective-step-profile", + ExecutorProfileID: "effective-executor-profile", + State: models.TaskSessionStateWaitingForInput, + IsPrimary: true, + Metadata: map[string]interface{}{ + models.SessionMetaKeyCreatedBy: models.SessionCreatedByWorkflowSwitch, + }, + })) + + h := &Handlers{ + taskSvc: svc, + logger: testLogger(t).WithFields(), + } + msg := makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{ + "source_task_id": source.ID, + "workspace_id": workspaces[0].ID, + "workflow_id": workflows[0].ID, + "agent_profile_id": "explicit-agent-profile", + "title": "Follow-up with explicit agent", + "description": "This should keep the explicit agent and inherit the routed executor.", + "start_agent": false, + }) + + 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)) + + var created struct { + ID string `json:"id"` + } + require.NoError(t, json.Unmarshal(resp.Payload, &created)) + require.NotEmpty(t, created.ID) + + task, err := svc.GetTask(ctx, created.ID) + require.NoError(t, err) + require.NotNil(t, task.Metadata) + assert.Equal(t, "explicit-agent-profile", task.Metadata[models.MetaKeyAgentProfileID]) + assert.Equal(t, "effective-executor-profile", task.Metadata[models.MetaKeyExecutorProfileID]) +} + +func TestInheritFromTask_PrimarySessionLookupErrorFailsClosed(t *testing.T) { + svc, repo := newTestTaskService(t) + ctx := context.Background() + + h := &Handlers{ + taskSvc: svc, + logger: testLogger(t).WithFields(), + } + require.NoError(t, repo.DB().Close()) + + agentProfileID := "" + executorProfileID := "" + _, err := h.inheritFromTask(ctx, "source-task", &agentProfileID, &executorProfileID) + require.Error(t, err) +} + func TestHandleCreateTask_InheritsDeferredParentTaskMetadata(t *testing.T) { svc, _ := newTestTaskService(t) ctx := context.Background() @@ -820,6 +1089,35 @@ func TestResolveMCPAutoStartConfig_FallsBackToWorkflowAgentProfile(t *testing.T) assert.Equal(t, workflowProfileID, config.AgentProfileID) } +func TestResolveMCPAutoStartConfig_ExplicitAgentProfileWinsOverSourceTask(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) + + source, err := svc.CreateTask(ctx, &service.CreateTaskRequest{ + WorkspaceID: workspaces[0].ID, + WorkflowID: workflows[0].ID, + Title: "Current task", + Metadata: map[string]interface{}{ + models.MetaKeyAgentProfileID: "source-profile", + }, + }) + require.NoError(t, err) + h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()} + + config := h.resolveMCPAutoStartConfig(ctx, &models.Task{ + WorkspaceID: workspaces[0].ID, + WorkflowID: workflows[0].ID, + }, "explicit-profile", "", source.ID) + + assert.Equal(t, "explicit-profile", config.AgentProfileID) +} + func defaultWorkspaceAndWorkflow(t *testing.T, ctx context.Context, svc *service.Service) (*models.Workspace, *models.Workflow) { t.Helper() workspaces, err := svc.ListWorkspaces(ctx) diff --git a/apps/backend/internal/mcp/server/handlers_test.go b/apps/backend/internal/mcp/server/handlers_test.go index 665d139ad8..cdb80103fe 100644 --- a/apps/backend/internal/mcp/server/handlers_test.go +++ b/apps/backend/internal/mcp/server/handlers_test.go @@ -36,6 +36,14 @@ func TestCreateTask_ToolSchema_HasParentID(t *testing.T) { assert.Contains(t, props, "title") assert.Contains(t, props, "workspace_id") assert.Contains(t, props, "workflow_id") + assert.Contains(t, tool.Tool.Description, "explicit agent_profile_id > current/source task or parent task > workflow defaults > workspace default") + assert.Contains(t, tool.Tool.Description, "Do not rely on workspace defaults for follow-up work from an active task") + + agentProfileProp, ok := props["agent_profile_id"].(map[string]interface{}) + require.True(t, ok, "agent_profile_id schema should be an object") + agentProfileDesc, ok := agentProfileProp["description"].(string) + require.True(t, ok, "agent_profile_id should have a description") + assert.Contains(t, agentProfileDesc, "explicit value > current/source task or parent task > workflow defaults > workspace default") // parent_id, workspace_id, workflow_id, workflow_step_id should NOT be required required, _ := parsed["required"].([]interface{}) diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index 4314227129..892e2ea208 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -525,6 +525,8 @@ WHEN TO OMIT parent_id (top-level task): IMPORTANT: - Subtasks inherit workspace, workflow, agent profile, and executor from the parent +- Agent profile precedence is explicit agent_profile_id > current/source task or parent task > workflow defaults > workspace default +- When launched from a current task, omitting parent_id still uses the current task as the source for profile inheritance before workflow/workspace defaults. Do not rely on workspace defaults for follow-up work from an active task. - Every created task must have a resolvable agent profile. start_agent=false still records the profile for a later manual start. - Subtasks inherit the parent's repository unless you supply repository_url, repository_id, or local_path — in which case the subtask targets that repo instead (must live in the parent's workspace) - base_branch behaviour: @@ -536,6 +538,7 @@ IMPORTANT: - start_agent defaults to true and is what you want in nearly every case — the new task auto-launches an agent that immediately works on the description. Pass start_agent=false ONLY for an explicit placeholder (e.g. queuing work the user will start later, or creating a tracking task with no immediate work), and still pass agent_profile_id unless it can be inherited. When in doubt, leave it true. - Kanban subtasks cannot have their own subtasks (max nesting depth is 1). To break work down further, create a sibling under the same parent. (Office task trees are exempt.)` parentDesc := "Parent task ID for subtasks. Use 'self' to create a subtask of your current task (RECOMMENDED for plan phases, delegated work). Omit only for unrelated top-level tasks." + agentProfileDesc := "Agent profile ID to use. Precedence: explicit value > current/source task or parent task > workflow defaults > workspace default. Required unless one of those inheritance sources can resolve it. start_agent=false still needs a profile for later manual start." if s.mode == ModeExternal { toolDesc = `Create a new top-level task and auto-start an agent on it. @@ -543,11 +546,13 @@ IMPORTANT: IMPORTANT: - Provide a repository via repository_url, repository_id, or local_path - workspace_id and workflow_id are auto-resolved if only one exists; provide explicitly if ambiguous +- Agent profile precedence is explicit agent_profile_id > parent task > workflow defaults > workspace default. External mode has no current/source task. - Every created task must have a resolvable agent profile. start_agent=false still records the profile for a later manual start. - 'description' is the agent's initial prompt — be specific and detailed - start_agent defaults to true and is what you want in nearly every case — the new task auto-launches an agent that immediately works on the description. Pass start_agent=false ONLY for an explicit placeholder (e.g. queuing work the user will start later), and still pass agent_profile_id unless a default exists. When in doubt, leave it true. - Use parent_id only when delegating to a known existing task by its ID` parentDesc = "Optional parent task ID. Omit for top-level tasks; provide an existing task ID only to create a subtask of that task." + agentProfileDesc = "Agent profile ID to use. Precedence: explicit value > parent task > workflow defaults > workspace default. External mode has no current/source task. Required unless one of those inheritance sources can resolve it. start_agent=false still needs a profile for later manual start." } s.mcpServer.AddTool( @@ -559,7 +564,7 @@ IMPORTANT: mcp.WithString("workflow_step_id", mcp.Description("The workflow step ID (optional, auto-resolved if omitted)")), mcp.WithString("title", mcp.Required(), mcp.Description("The task title")), mcp.WithString("description", mcp.Description("The initial prompt for the sub-agent. This is the ONLY context the agent receives when it starts — treat it as the agent's first user message. REQUIRED for subtasks: without a description the sub-agent starts with no context and cannot do useful work. Be specific and detailed.")), - mcp.WithString("agent_profile_id", mcp.Description("Agent profile ID to use. Required unless it can be inherited from the parent/source task, workflow, or workspace defaults. start_agent=false still needs a profile for later manual start.")), + mcp.WithString("agent_profile_id", mcp.Description(agentProfileDesc)), mcp.WithString("executor_profile_id", mcp.Description("Executor profile ID to use (determines the runtime environment: local, worktree, docker, etc.). For subtasks, inherited from the parent session. For top-level tasks, ask the user which executor profile they want if not already known.")), mcp.WithBoolean("start_agent", mcp.Description("Whether to auto-start an agent on the created task. Default: true — leave it true unless you specifically want a placeholder task with no agent running. Setting false leaves the task waiting for the user to click 'Start agent' in the UI; the description is preserved but no work happens automatically.")), mcp.WithString("repository_id", mcp.Description("Repository ID. Required for top-level tasks unless local_path or repository_url is provided. For subtasks: optional — supply only when the subtask should target a different repo than the parent.")), diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 687cbf811e..daa8a722eb 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -360,7 +360,8 @@ func (s *Service) handleTaskMovedNoSession(ctx context.Context, data watcher.Tas return } - agentProfileID := s.resolveStepAgentProfile(ctx, step) + workflowAgentProfileID := s.resolveStepAgentProfile(ctx, step) + agentProfileID := workflowAgentProfileID if agentProfileID == "" { agentProfileID, _ = task.Metadata[models.MetaKeyAgentProfileID].(string) } @@ -379,7 +380,11 @@ func (s *Service) handleTaskMovedNoSession(ctx context.Context, data watcher.Tas // Async: event bus delivers synchronously; blocking here → HTTP timeout (see handleTaskMovedWithSession doc). go func() { asyncCtx := context.WithoutCancel(ctx) - _, err := s.StartTask(asyncCtx, task.ID, agentProfileID, executorID, executorProfileID, "", task.Description, data.ToStepID, planMode, true, nil) + startAgentProfileID := agentProfileID + if workflowAgentProfileID != "" { + startAgentProfileID = "" + } + _, err := s.StartTask(asyncCtx, task.ID, startAgentProfileID, executorID, executorProfileID, "", task.Description, data.ToStepID, planMode, true, nil) if err != nil { s.logger.Error("task.moved: failed to auto-start task", zap.String("task_id", data.TaskID), @@ -598,8 +603,7 @@ func (s *Service) reuseSessionForStep(ctx context.Context, taskID string, curren s.reviveReusedSession(ctx, existing) } - // Note: do not stamp created_by here. Reused sessions keep whatever - // provenance tag they had when first created. + s.tagSessionAsWorkflowSwitched(ctx, existing.ID) if err := s.SetPrimarySession(ctx, existing.ID); err != nil { s.logger.Warn("failed to set reused session as primary", @@ -779,6 +783,9 @@ func (s *Service) maybySwitchSessionForProfile( } effectiveProfile := s.resolveStepAgentProfile(ctx, step) if effectiveProfile == "" || effectiveProfile == session.AgentProfileID { + if effectiveProfile != "" { + s.tagSessionAsWorkflowSwitched(ctx, session.ID) + } if !session.IsPrimary { if err := s.SetPrimarySession(ctx, session.ID); err != nil { s.logger.Warn("failed to preserve session as primary for workflow step", diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go index d651687d6c..45bd5df5e5 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go @@ -179,7 +179,7 @@ func TestHandleTaskMovedNoSession(t *testing.T) { stepGetter := newMockStepGetter() stepGetter.steps["step2"] = &wfmodels.WorkflowStep{ - ID: "step2", WorkflowID: "wf1", Name: "Auto Start Step", Position: 1, + ID: "step2", WorkflowID: "wf1", Name: "Auto Start Step", Position: 1, AgentProfileID: "profile-step", Events: wfmodels.StepEvents{ OnEnter: []wfmodels.OnEnterAction{ {Type: wfmodels.OnEnterAutoStartAgent}, @@ -199,6 +199,12 @@ func TestHandleTaskMovedNoSession(t *testing.T) { launched := make(chan string, 1) agentMgr := &mockAgentManager{ launchAgentFunc: func(_ context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + session, err := repo.GetTaskSession(ctx, req.SessionID) + if err != nil { + t.Errorf("GetTaskSession(%q): %v", req.SessionID, err) + } else if session.Metadata == nil || session.Metadata[models.SessionMetaKeyCreatedBy] != models.SessionCreatedByWorkflowSwitch { + t.Errorf("created_by metadata before launch = %v, want %q", session.Metadata[models.SessionMetaKeyCreatedBy], models.SessionCreatedByWorkflowSwitch) + } executorID, _ := req.Metadata[models.MetaKeyExecutorID].(string) launched <- executorID return &executor.LaunchAgentResponse{AgentExecutionID: "exec-1"}, nil diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go index d20214dab8..62b72f0a5f 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go @@ -194,6 +194,7 @@ func TestSwitchSessionForStep_ReusesExistingProfileSession(t *testing.T) { ExecutorProfileID: "ep1", State: models.TaskSessionStateCompleted, IsPrimary: false, + Metadata: map[string]interface{}{"existing": "preserved"}, CompletedAt: &completedAt, StartedAt: now.Add(-3 * time.Minute), UpdatedAt: completedAt, @@ -271,6 +272,12 @@ func TestSwitchSessionForStep_ReusesExistingProfileSession(t *testing.T) { if !reused.IsPrimary { t.Error("reused session must be primary") } + if got := reused.Metadata[models.SessionMetaKeyCreatedBy]; got != models.SessionCreatedByWorkflowSwitch { + t.Errorf("reused session created_by metadata = %v, want %q", got, models.SessionCreatedByWorkflowSwitch) + } + if got := reused.Metadata["existing"]; got != "preserved" { + t.Errorf("reused session existing metadata = %v, want preserved", got) + } // The previous current session-b must now be COMPLETED, not primary. parked, _ := repo.GetTaskSession(ctx, "session-b") @@ -376,6 +383,9 @@ func TestSwitchSessionForStep_ReusesPreviouslyLaunchedSession(t *testing.T) { if reused.State != models.TaskSessionStateWaitingForInput { t.Errorf("previously-launched reused session must be WAITING_FOR_INPUT (so PromptTask lazy-resumes via ResumeSession), got %s", reused.State) } + if got := reused.Metadata[models.SessionMetaKeyCreatedBy]; got != models.SessionCreatedByWorkflowSwitch { + t.Errorf("reused session created_by metadata = %v, want %q", got, models.SessionCreatedByWorkflowSwitch) + } } // TestSwitchSessionForStep_ReusesFailedSession exercises the requirement that @@ -599,6 +609,7 @@ func TestProcessOnEnter_ProfileSwitch(t *testing.T) { AgentProfileID: "profile-a", State: models.TaskSessionStateRunning, IsPrimary: true, + Metadata: map[string]interface{}{"existing": "preserved"}, StartedAt: now, UpdatedAt: now, } @@ -624,6 +635,12 @@ func TestProcessOnEnter_ProfileSwitch(t *testing.T) { if updatedSession.State == models.TaskSessionStateCompleted { t.Error("session should not be completed when profile matches") } + if got := updatedSession.Metadata[models.SessionMetaKeyCreatedBy]; got != models.SessionCreatedByWorkflowSwitch { + t.Errorf("matching workflow session created_by metadata = %v, want %q", got, models.SessionCreatedByWorkflowSwitch) + } + if got := updatedSession.Metadata["existing"]; got != "preserved" { + t.Errorf("matching workflow session existing metadata = %v, want preserved", got) + } // No new sessions should be created sessions, err := repo.ListTaskSessions(ctx, "t1")