fix: inherit current task profile for mcp tasks (#1546)

* fix: inherit current task profile for mcp tasks

* fix: respect workflow-switched mcp task profiles

* test: cover mixed mcp task profile inheritance

* fix: tag reused workflow sessions

* fix: tag matching workflow sessions

* fix: inherit routed session launch metadata

* fix: harden mcp profile inheritance

* fix: preserve routed executor inheritance

* test: simplify workflow move provenance assertion

---------

Co-authored-by: root <root@kandev.ts.net>
This commit is contained in:
Carlos Florêncio
2026-06-29 23:06:46 +01:00
committed by GitHub
parent ec942540bc
commit f7d519db78
7 changed files with 412 additions and 21 deletions
+65 -15
View File
@@ -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
}
@@ -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)
@@ -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{})
+6 -1
View File
@@ -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.")),
@@ -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",
@@ -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
@@ -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")