Files
kandev/apps/backend/internal/mcp/handlers/handlers.go
T

3806 lines
153 KiB
Go

// Package handlers provides WebSocket handlers for MCP tool requests.
// These handlers are called by agentctl via the WS tunnel and execute
// operations against the backend services directly.
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/kandev/kandev/internal/agent/mcpconfig"
agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller"
"github.com/kandev/kandev/internal/auth/authn"
"github.com/kandev/kandev/internal/clarification"
"github.com/kandev/kandev/internal/common/constants"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/orchestrator"
"github.com/kandev/kandev/internal/orchestrator/executor"
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
"github.com/kandev/kandev/internal/plugins"
promptservice "github.com/kandev/kandev/internal/prompts/service"
"github.com/kandev/kandev/internal/sysprompt"
"github.com/kandev/kandev/internal/task/dto"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/planws"
taskrepository "github.com/kandev/kandev/internal/task/repository"
taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite"
"github.com/kandev/kandev/internal/task/service"
usermodels "github.com/kandev/kandev/internal/user/models"
workflowctrl "github.com/kandev/kandev/internal/workflow/controller"
workflowmodels "github.com/kandev/kandev/internal/workflow/models"
workflowsvc "github.com/kandev/kandev/internal/workflow/service"
"github.com/kandev/kandev/internal/workflow/signalmetrics"
v1 "github.com/kandev/kandev/pkg/api/v1"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
)
// ClarificationService defines the interface for clarification operations.
type ClarificationService interface {
CreateRequest(req *clarification.Request) (string, bool)
WaitForResponse(ctx context.Context, pendingID string) (*clarification.Response, error)
CancelRequest(pendingID string) bool
}
type workspaceSourceJSON struct {
Kind string `json:"kind"`
RepositoryID string `json:"repository_id"`
LocalPath string `json:"local_path"`
GitHubURL string `json:"github_url"`
RemoteURL string `json:"remote_url"`
Provider string `json:"provider"`
ProviderRepoID string `json:"provider_repo_id"`
ProviderOwner string `json:"provider_owner"`
ProviderName string `json:"provider_name"`
BaseBranch string `json:"base_branch"`
CheckoutBranch string `json:"checkout_branch"`
DisplayName string `json:"display_name"`
}
// SessionCanceller detaches in-memory clarification waiters while keeping DB
// messages pending. Used by the MCP-timeout handler.
type SessionCanceller interface {
DetachSessionAndNotify(ctx context.Context, sessionID string) int
}
// ClarificationInputPauser performs the orchestrator-owned hard pause for
// ask_user_question calls that end without an answer. The returned int is the
// number of clarification bundles detached while pausing.
type ClarificationInputPauser interface {
PauseForClarificationInput(ctx context.Context, sessionID string) (int, error)
}
// MessageCreator creates messages for clarification requests.
type MessageCreator interface {
CreateClarificationRequestMessages(ctx context.Context, taskID, sessionID, pendingID string, questions []clarification.Question, clarificationContext string) ([]string, error)
}
// SessionRepository interface for updating session state.
type SessionRepository interface {
UpdateTaskSessionState(ctx context.Context, sessionID string, state models.TaskSessionState, errorMessage string) error
GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error)
// SetSessionMetadataKeyIfAbsentOrDifferentStep atomically claims the
// pending-completion bag for a step. It preserves first-signal-wins for
// concurrent requests and replaces a stale signal from an older step.
SetSessionMetadataKeyIfAbsentOrDifferentStep(ctx context.Context, sessionID, key, stepID string, value interface{}) (bool, error)
}
// conditionalSessionStateUpdater is implemented by repositories that can
// reject stale session-state writers. Keeping it optional preserves existing
// handler fakes and alternate repositories while production SQLite gets CAS
// protection against coordinator stops.
type conditionalSessionStateUpdater interface {
UpdateTaskSessionStateIfCurrent(
ctx context.Context,
sessionID string,
expected, state models.TaskSessionState,
errorMessage string,
) (bool, time.Time, error)
}
// TaskRepository interface for updating task state.
type TaskRepository interface {
UpdateTaskState(ctx context.Context, taskID string, state v1.TaskState) error
}
// RemoteContributionService resolves provider URLs before task creation and
// associates an already-existing PR/MR after the target task-repository row
// exists. Implementations must return only server-authored identity data.
type RemoteContributionService interface {
Resolve(ctx context.Context, workspaceID, userID, rawURL string) (*models.RemoteContributionResolution, bool, error)
Associate(ctx context.Context, workspaceID, userID, taskID, repositoryID string, resolution *models.RemoteContributionResolution) error
}
// sessionOwnedTaskStateUpdater atomically guards a task-state write with the
// current state of its owning session. Production SQLite implements this so a
// clarification answer cannot restore IN_PROGRESS after coordinator stop has
// already committed CANCELLED.
type sessionOwnedTaskStateUpdater interface {
UpdateTaskStateIfSessionState(
ctx context.Context,
taskID, sessionID string,
expectedSessionState models.TaskSessionState,
state v1.TaskState,
) (v1.TaskState, bool, error)
}
// EventBus interface for publishing events.
type EventBus interface {
Publish(ctx context.Context, topic string, event *bus.Event) error
}
// SessionLauncher provides session launch and prompt-dispatch capabilities.
// Implemented by *orchestrator.Service.
type SessionLauncher interface {
LaunchSession(ctx context.Context, req *orchestrator.LaunchSessionRequest) (*orchestrator.LaunchSessionResponse, error)
PromptTask(ctx context.Context, taskID, sessionID, prompt, model string, planMode bool, attachments []v1.MessageAttachment, dispatchOnly bool) (*orchestrator.PromptResult, error)
StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord, planMode, autoStart bool, attachments []v1.MessageAttachment, references []v1.EntityReference) (*executor.TaskExecution, error)
ResumeTaskSession(ctx context.Context, taskID, sessionID string) (*executor.TaskExecution, error)
ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error
GetMessageQueue() *messagequeue.Service
// QueueAndInterruptForPeerMessage atomically queues prompt for sessionID
// then interrupts the session's in-flight turn to dispatch it right
// away, bypassing FIFO order. Used only by queueThenInterruptTaskMessage
// when the message_task_kandev sender is the target task's parent (see
// handleMessageTask) — see the orchestrator implementation's doc
// comment for why "queue" and "interrupt" must be one atomic call
// rather than two separate steps. The returned bool reports whether
// this call actually dispatched the message immediately; callers must
// not report "sent" when it's false — the message is still only
// queued, to be delivered later by whichever drain gets to it.
QueueAndInterruptForPeerMessage(ctx context.Context, taskID, sessionID, prompt string, metadata map[string]interface{}) (*messagequeue.QueuedMessage, bool, error)
// RenameSession sets the user-visible session tab label and broadcasts
// the change. Used by spawn_session_kandev's optional name parameter.
RenameSession(ctx context.Context, sessionID, name string) error
}
// TaskStopper exposes the narrow coordinator halt operation used by
// stop_task_kandev. The MCP layer owns authorization; lifecycle semantics stay
// in the orchestrator.
type TaskStopper interface {
StopTaskForCoordinator(ctx context.Context, taskID string) (orchestrator.CoordinatorTaskStopResult, error)
}
// TaskTitleBranchRenamer performs the best-effort branch side effect after an
// owner session accepts a prompt-first task title.
type TaskTitleBranchRenamer interface {
RenameGeneratedBranchesForTaskTitle(ctx context.Context, taskID, sessionID, title string) (orchestrator.TitleBranchRenameResult, error)
}
// MessageQueuer queues a prompt message for delivery to a session on its next turn.
// TakeQueued is exposed so move_task can roll back the hand-off prompt when the
// underlying MoveTask call fails — without it, a queued "you were moved..."
// message would survive a failed move and be delivered on the next agent turn.
type MessageQueuer interface {
QueueMessage(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []messagequeue.MessageAttachment) (*messagequeue.QueuedMessage, error)
SetPendingMove(ctx context.Context, sessionID string, move *messagequeue.PendingMove)
TakeQueued(ctx context.Context, sessionID string) (*messagequeue.QueuedMessage, bool)
}
// PromptReferenceResolver expands saved prompt references that appear inside
// agent-sent prompts while preserving the original @mentions in the visible
// prompt body.
type PromptReferenceResolver interface {
ResolvePromptReferences(ctx context.Context, content string) ([]promptservice.PromptReferenceExpansion, error)
}
// UserSettingsProvider supplies the single portable preference used when an
// MCP-created task omits agent_profile_id.
type UserSettingsProvider interface {
GetUserSettings(ctx context.Context) (*usermodels.UserSettings, error)
}
// Handlers provides MCP WebSocket handlers.
type Handlers struct {
taskSvc *service.Service
workflowCtrl *workflowctrl.Controller
clarificationSvc ClarificationService
sessionCanceller SessionCanceller
inputPauser ClarificationInputPauser
messageCreator MessageCreator
sessionRepo SessionRepository
taskRepo TaskRepository
eventBus EventBus
planService *service.PlanService
walkthroughService *service.WalkthroughService
sessionLauncher SessionLauncher
taskStopper TaskStopper
titleBranchRenamer TaskTitleBranchRenamer
stopTaskGetter func(context.Context, string) (*models.Task, error)
messageQueue MessageQueuer
promptResolver PromptReferenceResolver
userSettingsProvider UserSettingsProvider
logger *logger.Logger
// Config-mode dependencies (optional, set via SetConfigDeps)
workflowSvc *workflowsvc.Service
agentSettingsCtrl *agentsettingscontroller.Controller
mcpConfigSvc *mcpconfig.Service
// Cross-task handoff service (optional, set via SetHandoffService).
// Wires the list_related_tasks_kandev / *_task_document_kandev
// MCP tools introduced in office task handoffs phase 2.
handoffSvc *service.HandoffService
// Optional PR lister (set via SetTaskPRLister) used to enrich
// task-listing responses with associated pull requests.
taskPRLister TaskPRLister
// Native code review (optional, set via SetReviewService /
// SetReviewRunner). Without them the review actions are simply not
// registered — see registerReviewHandlers.
reviewService *service.ReviewService
reviewRunner ReviewRunner
pluginSvc *plugins.Service
// Optional task-bound GitHub PR automation controls.
taskPRAutomation TaskPRAutomationService
remoteContributionSvc RemoteContributionService
diagnosticBundles DiagnosticBundleProvider
diagnosticMaterializer DiagnosticBundleMaterializer
// Optional task-bound GitLab MR automation controls.
taskMRAutomation TaskMRAutomationService
}
// NewHandlers creates new MCP handlers.
func NewHandlers(
taskSvc *service.Service,
workflowCtrl *workflowctrl.Controller,
clarificationSvc ClarificationService,
sessionCanceller SessionCanceller,
messageCreator MessageCreator,
sessionRepo SessionRepository,
taskRepo TaskRepository,
eventBus EventBus,
planService *service.PlanService,
walkthroughService *service.WalkthroughService,
sessionLauncher SessionLauncher,
messageQueue MessageQueuer,
log *logger.Logger,
) *Handlers {
h := &Handlers{
taskSvc: taskSvc,
workflowCtrl: workflowCtrl,
clarificationSvc: clarificationSvc,
sessionCanceller: sessionCanceller,
messageCreator: messageCreator,
sessionRepo: sessionRepo,
taskRepo: taskRepo,
eventBus: eventBus,
planService: planService,
walkthroughService: walkthroughService,
sessionLauncher: sessionLauncher,
messageQueue: messageQueue,
logger: log.WithFields(zap.String("component", "mcp-handlers")),
}
if taskSvc != nil {
h.stopTaskGetter = taskSvc.GetTask
}
if stopper, ok := sessionLauncher.(TaskStopper); ok {
h.taskStopper = stopper
}
return h
}
// SetClarificationInputPauser wires the orchestrator-owned hard pause used when
// a clarification tool call ends without delivering an answer to the agent.
func (h *Handlers) SetClarificationInputPauser(pauser ClarificationInputPauser) {
h.inputPauser = pauser
}
func (h *Handlers) SetPromptReferenceResolver(resolver PromptReferenceResolver) {
h.promptResolver = resolver
}
// SetTaskStopper wires the orchestrator-owned halt operation.
func (h *Handlers) SetTaskStopper(stopper TaskStopper) {
h.taskStopper = stopper
}
// SetTaskTitleBranchRenamer wires the best-effort branch rename performed
// after an accepted agent-generated title.
func (h *Handlers) SetTaskTitleBranchRenamer(renamer TaskTitleBranchRenamer) {
h.titleBranchRenamer = renamer
}
// SetUserSettingsProvider wires portable user preferences into MCP task creation.
func (h *Handlers) SetUserSettingsProvider(provider UserSettingsProvider) {
h.userSettingsProvider = provider
}
// SetRemoteContributionService wires provider-backed PR/MR resolution for
// repository_url values that identify an existing contribution.
func (h *Handlers) SetRemoteContributionService(svc RemoteContributionService) {
h.remoteContributionSvc = svc
}
// SetConfigDeps sets the config-mode dependencies for agent-native configuration handlers.
// These are optional and only needed when config-mode MCP sessions are used.
func (h *Handlers) SetConfigDeps(
workflowSvc *workflowsvc.Service,
agentSettingsCtrl *agentsettingscontroller.Controller,
mcpConfigSvc *mcpconfig.Service,
) {
h.workflowSvc = workflowSvc
h.agentSettingsCtrl = agentSettingsCtrl
h.mcpConfigSvc = mcpConfigSvc
}
// SetPluginService wires the plugin agent-tool catalog and invocation bridge.
func (h *Handlers) SetPluginService(svc *plugins.Service) {
h.pluginSvc = svc
}
// RegisterHandlers registers all MCP handlers with the dispatcher.
func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) {
before := d.HandlerCount()
// Task-mode handlers (always registered)
d.RegisterFunc(ws.ActionMCPListWorkspaces, h.handleListWorkspaces)
if h.pluginSvc != nil {
d.RegisterFunc(ws.ActionMCPListPluginTools, h.handleListPluginTools)
d.RegisterFunc(ws.ActionMCPInvokePluginTool, h.handleInvokePluginTool)
}
d.RegisterFunc(ws.ActionMCPListWorkflows, h.handleListWorkflows)
d.RegisterFunc(ws.ActionMCPListWorkflowSteps, h.handleListWorkflowSteps)
d.RegisterFunc(ws.ActionMCPListRepositories, h.handleListRepositories)
d.RegisterFunc(ws.ActionMCPListTasks, h.handleListTasks)
d.RegisterFunc(ws.ActionMCPCreateTask, h.handleCreateTask)
d.RegisterFunc(ws.ActionMCPUpdateTask, h.handleUpdateTask)
d.RegisterFunc(ws.ActionMCPSetTaskTitle, h.handleSetTaskTitle)
d.RegisterFunc(ws.ActionMCPGetTaskPRAutomation, h.handleGetTaskPRAutomation)
d.RegisterFunc(ws.ActionMCPUpdateTaskPRAutomation, h.handleUpdateTaskPRAutomation)
d.RegisterFunc(ws.ActionMCPGetTaskMRAutomation, h.handleGetTaskMRAutomation)
d.RegisterFunc(ws.ActionMCPUpdateTaskMRAutomation, h.handleUpdateTaskMRAutomation)
d.RegisterFunc(ws.ActionMCPAddBranchToTask, h.handleAddBranchToTask)
d.RegisterFunc(ws.ActionMCPAddWorkspaceSources, h.handleAddWorkspaceSources)
d.RegisterFunc(ws.ActionMCPUpdateRepositoryBaseBranch, h.handleUpdateRepositoryBaseBranch)
d.RegisterFunc(ws.ActionMCPStepComplete, h.handleStepComplete)
d.RegisterFunc(ws.ActionMCPMessageTask, h.handleMessageTask)
d.RegisterFunc(ws.ActionMCPStopTask, h.handleStopTask)
d.RegisterFunc(ws.ActionMCPSpawnSession, h.handleSpawnSession)
d.RegisterFunc(ws.ActionMCPGetTaskConversation, h.handleGetTaskConversation)
d.RegisterFunc(ws.ActionMCPListTaskSessions, h.handleListTaskSessions)
d.RegisterFunc(ws.ActionMCPAskUserQuestion, h.handleAskUserQuestion)
d.RegisterFunc(ws.ActionMCPAskParentQuestion, h.handleAskParentQuestion)
d.RegisterFunc(ws.ActionMCPCreateTaskPlan, h.handleCreateTaskPlan)
d.RegisterFunc(ws.ActionMCPGetTaskPlan, h.handleGetTaskPlan)
d.RegisterFunc(ws.ActionMCPUpdateTaskPlan, h.handleUpdateTaskPlan)
d.RegisterFunc(ws.ActionMCPDeleteTaskPlan, h.handleDeleteTaskPlan)
d.RegisterFunc(ws.ActionMCPShowWalkthrough, h.handleShowWalkthrough)
d.RegisterFunc(ws.ActionMCPGetWalkthrough, h.handleGetWalkthrough)
d.RegisterFunc(ws.ActionMCPDeleteWalkthrough, h.handleDeleteWalkthrough)
// Plain (non-MCP) action so the web UI can backfill the current walkthrough
// on mount — live task.walkthrough.created events can fire before the page's
// WS subscription is established. Reuses the same read handler.
d.RegisterFunc(ws.ActionTaskWalkthroughGet, h.handleGetWalkthrough)
d.RegisterFunc(ws.ActionTaskWalkthroughDelete, h.handleDeleteWalkthrough)
d.RegisterFunc(ws.ActionMCPClarificationTimeout, h.handleClarificationTimeout)
if h.diagnosticBundles != nil && h.diagnosticMaterializer != nil {
d.RegisterFunc(ws.ActionMCPGetDiagnosticBundle, h.handleGetDiagnosticBundle)
}
h.registerReviewHandlers(d)
// Config-mode handlers (registered when config deps are set)
if h.workflowSvc != nil {
d.RegisterFunc(ws.ActionMCPCreateWorkflow, h.handleCreateWorkflow)
d.RegisterFunc(ws.ActionMCPUpdateWorkflow, h.handleUpdateWorkflow)
d.RegisterFunc(ws.ActionMCPDeleteWorkflow, h.handleDeleteWorkflow)
d.RegisterFunc(ws.ActionMCPImportWorkflow, h.handleImportWorkflow)
d.RegisterFunc(ws.ActionMCPCreateWorkflowStep, h.handleCreateWorkflowStep)
d.RegisterFunc(ws.ActionMCPUpdateWorkflowStep, h.handleUpdateWorkflowStep)
d.RegisterFunc(ws.ActionMCPDeleteWorkflowStep, h.handleDeleteWorkflowStep)
d.RegisterFunc(ws.ActionMCPReorderWorkflowStep, h.handleReorderWorkflowSteps)
}
if h.agentSettingsCtrl != nil {
d.RegisterFunc(ws.ActionMCPListAgents, h.handleListAgents)
d.RegisterFunc(ws.ActionMCPUpdateAgent, h.handleUpdateAgent)
d.RegisterFunc(ws.ActionMCPListAgentProfiles, h.handleListAgentProfiles)
d.RegisterFunc(ws.ActionMCPCreateAgentProfile, h.handleCreateAgentProfile)
d.RegisterFunc(ws.ActionMCPUpdateAgentProfile, h.handleUpdateAgentProfile)
d.RegisterFunc(ws.ActionMCPDeleteAgentProfile, h.handleDeleteAgentProfile)
}
// Executor discovery/profile listing is always available (read-only, used in task mode for create_task)
if h.taskSvc != nil {
d.RegisterFunc(ws.ActionMCPListExecutors, h.handleListExecutors)
d.RegisterFunc(ws.ActionMCPListExecutorProfiles, h.handleListExecutorProfiles)
}
if h.mcpConfigSvc != nil {
d.RegisterFunc(ws.ActionMCPGetMcpConfig, h.handleGetMcpConfig)
d.RegisterFunc(ws.ActionMCPUpdateMcpConfig, h.handleUpdateMcpConfig)
}
if h.handoffSvc != nil {
d.RegisterFunc(ws.ActionMCPListRelatedTasks, h.handleListRelatedTasks)
d.RegisterFunc(ws.ActionMCPListTaskDocuments, h.handleListTaskDocuments)
d.RegisterFunc(ws.ActionMCPGetTaskDocument, h.handleGetTaskDocument)
d.RegisterFunc(ws.ActionMCPWriteTaskDocument, h.handleWriteTaskDocument)
}
if h.taskSvc != nil {
d.RegisterFunc(ws.ActionMCPMoveTask, h.handleMoveTask)
d.RegisterFunc(ws.ActionMCPDeleteTask, h.handleDeleteTask)
d.RegisterFunc(ws.ActionMCPArchiveTask, h.handleArchiveTask)
d.RegisterFunc(ws.ActionMCPUpdateTaskState, h.handleUpdateTaskState)
// Executor mutation handlers (config-mode only)
if h.workflowSvc != nil {
d.RegisterFunc(ws.ActionMCPCreateExecutorProfile, h.handleCreateExecutorProfile)
d.RegisterFunc(ws.ActionMCPUpdateExecutorProfile, h.handleUpdateExecutorProfile)
d.RegisterFunc(ws.ActionMCPDeleteExecutorProfile, h.handleDeleteExecutorProfile)
}
}
after := d.HandlerCount()
h.logger.Info("registered MCP handlers", zap.Int("count", after-before))
}
// handleListWorkspaces lists all workspaces.
func (h *Handlers) handleListWorkspaces(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
workspaces, err := h.taskSvc.ListWorkspaces(ctx)
if err != nil {
h.logger.Error("failed to list workspaces", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list workspaces", nil)
}
dtos := make([]dto.WorkspaceDTO, 0, len(workspaces))
for _, w := range workspaces {
dtos = append(dtos, dto.FromWorkspace(w))
}
return ws.NewResponse(msg.ID, msg.Action, dto.ListWorkspacesResponse{Workspaces: dtos, Total: len(dtos)})
}
// unmarshalStringField unmarshals a JSON payload and returns the value of a single string field.
func unmarshalStringField(payload json.RawMessage, fieldName string) (string, error) {
var m map[string]string
if err := json.Unmarshal(payload, &m); err != nil {
return "", err
}
return m[fieldName], nil
}
// handleListByField is a generic handler for listing resources identified by a single string field.
func (h *Handlers) handleListByField(
ctx context.Context, msg *ws.Message,
fieldName, logErrMsg, clientErrMsg string,
fn func(context.Context, string) (any, error),
) (*ws.Message, error) {
value, err := unmarshalStringField(msg.Payload, fieldName)
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if value == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, fieldName+" is required", nil)
}
resp, err := fn(ctx, value)
if err != nil {
h.logger.Error(logErrMsg, zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, clientErrMsg, nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// handleDeleteByField is a generic handler for deleting a resource identified by a single string field.
func (h *Handlers) handleDeleteByField(
ctx context.Context, msg *ws.Message,
fieldName, logErrMsg, clientErrMsg string,
fn func(context.Context, string) error,
) (*ws.Message, error) {
value, err := unmarshalStringField(msg.Payload, fieldName)
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if value == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, fieldName+" is required", nil)
}
if err := fn(ctx, value); err != nil {
h.logger.Error(logErrMsg, zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, clientErrMsg+": "+err.Error(), nil)
}
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"success": true})
}
// handleListWorkflows lists workflows for a workspace.
func (h *Handlers) handleListWorkflows(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
return h.handleListByField(ctx, msg, "workspace_id", "failed to list workflows", "Failed to list workflows",
func(ctx context.Context, workspaceID string) (any, error) {
workflows, err := h.taskSvc.ListWorkflows(ctx, workspaceID, false)
if err != nil {
return nil, err
}
dtos := make([]dto.WorkflowDTO, 0, len(workflows))
for _, w := range workflows {
dtos = append(dtos, dto.FromWorkflow(w))
}
return dto.ListWorkflowsResponse{Workflows: dtos, Total: len(dtos)}, nil
})
}
// handleListWorkflowSteps lists workflow steps for a workflow.
func (h *Handlers) handleListWorkflowSteps(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
return h.handleListByField(ctx, msg, "workflow_id", "failed to list workflow steps", "Failed to list workflow steps",
func(ctx context.Context, workflowID string) (any, error) {
return h.workflowCtrl.ListStepsByWorkflow(ctx, workflowctrl.ListStepsRequest{WorkflowID: workflowID})
})
}
// handleListRepositories lists repositories for a workspace. Exposes the same
// data the kanban "Edit task → Repositories" picker reads, so an MCP-driven
// agent can match a request against an actual repo instead of guessing or
// making up an ID.
func (h *Handlers) handleListRepositories(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
return h.handleListByField(ctx, msg, "workspace_id", "failed to list repositories", "Failed to list repositories",
func(ctx context.Context, workspaceID string) (any, error) {
repos, err := h.taskSvc.ListRepositories(ctx, workspaceID)
if err != nil {
return nil, err
}
dtos := make([]dto.RepositoryDTO, 0, len(repos))
for _, r := range repos {
dtos = append(dtos, dto.FromRepository(r))
}
return dto.ListRepositoriesResponse{Repositories: dtos, Total: len(dtos)}, nil
})
}
// handleListTasks lists tasks for a workflow.
func (h *Handlers) handleListTasks(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
return h.handleListByField(ctx, msg, "workflow_id", "failed to list tasks", "Failed to list tasks",
func(ctx context.Context, workflowID string) (any, error) {
tasks, err := h.taskSvc.ListTasks(ctx, workflowID)
if err != nil {
return nil, err
}
dtos := make([]dto.TaskDTO, 0, len(tasks))
for _, t := range tasks {
dtos = append(dtos, dto.FromTask(t))
}
h.enrichTasksWithPRs(ctx, dtos)
return dto.ListTasksResponse{Tasks: dtos, Total: len(dtos)}, nil
})
}
// mcpRepositoryInput matches the repository input structure from MCP create_task
type mcpRepositoryInput struct {
RepositoryID string `json:"repository_id"`
LocalPath string `json:"local_path"`
GitHubURL string `json:"github_url"`
BaseBranch string `json:"base_branch"`
}
// handleCreateTask creates a new task and optionally auto-starts an agent session.
func (h *Handlers) handleCreateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
// Use local struct with JSON tags since dto.CreateTaskRequest lacks them
var req struct {
ParentID string `json:"parent_id"`
SourceTaskID string `json:"source_task_id"`
WorkspaceID string `json:"workspace_id"`
WorkflowID string `json:"workflow_id"`
WorkflowStepID string `json:"workflow_step_id"`
WorkspaceMode string `json:"workspace_mode"`
Title string `json:"title"`
Description string `json:"description"`
Autopilot bool `json:"autopilot"`
AgentProfileID string `json:"agent_profile_id"`
ExecutorProfileID string `json:"executor_profile_id"`
StartAgent *bool `json:"start_agent"` // nil means default to true for backward compatibility
Repositories []mcpRepositoryInput `json:"repositories"` // explicit repositories for top-level tasks
BaseBranch string `json:"base_branch"` // top-level fallback applied to every resolved repo only when no per-repo entries are supplied; explicit per-repo BaseBranch is authoritative when Repositories is set
BlockedBy []string `json:"blocked_by"` // task IDs that must complete before this task
AssigneeAgentProfileID string `json:"assignee_agent_profile_id"` // agent instance to assign the task to
ExternalID string `json:"external_id"` // caller-supplied create-idempotency key
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.Title == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "title is required", nil)
}
if req.AssigneeAgentProfileID != "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "assignee_agent_profile_id is office-only and cannot be set via create_task_kandev", nil)
}
// Default start_agent to true for backward compatibility
startAgent := req.StartAgent == nil || *req.StartAgent
// Only require description for subtasks if we're starting an agent
if req.ParentID != "" && req.Description == "" && startAgent {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "description is required for subtasks: it is the sub-agent's initial prompt and the only context it receives to start working", nil)
}
// Resolve repositories and default workspace/workflow from parent if needed.
explicitWorkspaceID := req.WorkspaceID != ""
explicitWorkflowID := req.WorkflowID != ""
resolved, err := h.resolveTaskRepositories(ctx, req.ParentID, req.SourceTaskID, req.Repositories)
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
}
repos := resolved.Repos
// Top-level base_branch override: when the caller passes base_branch
// without any per-repo entries, apply it to every repo in the resolved
// list. This is the only path that lets a same-repo subtask override
// the parent's inherited base_branch without also restating the
// repository identifier. When the caller provided explicit per-repo
// entries we leave their BaseBranch alone — those are authoritative.
if req.BaseBranch != "" && len(req.Repositories) == 0 {
for i := range repos {
repos[i].BaseBranch = req.BaseBranch
}
}
req.WorkspaceID, req.WorkflowID, req.WorkflowStepID = applyMCPTaskScopeDefaults(
req.ParentID,
req.WorkspaceID,
req.WorkflowID,
req.WorkflowStepID,
explicitWorkspaceID,
explicitWorkflowID,
resolved,
)
// Auto-resolve workspace/workflow when not provided and there's exactly one option.
if req.WorkspaceID == "" && h.taskSvc != nil {
if workspaces, wsErr := h.taskSvc.ListWorkspaces(ctx); wsErr != nil {
h.logger.Warn("failed to auto-resolve workspace", zap.Error(wsErr))
} else if len(workspaces) == 1 {
req.WorkspaceID = workspaces[0].ID
}
}
if req.WorkspaceID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workspace_id is required", nil)
}
if req.WorkflowID == "" && h.taskSvc != nil {
if workflows, wfErr := h.taskSvc.ListWorkflows(ctx, req.WorkspaceID, false); wfErr != nil {
h.logger.Warn("failed to auto-resolve workflow", zap.String("workspace_id", req.WorkspaceID), zap.Error(wfErr))
} else if len(workflows) == 1 {
req.WorkflowID = workflows[0].ID
}
}
if req.WorkflowID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil)
}
if code, message, err := h.validateMCPWorkflowWorkspace(ctx, req.WorkflowID, req.WorkspaceID); code != "" {
if err != nil && h.logger != nil {
h.logger.Error("failed to validate MCP workflow workspace",
zap.String("workflow_id", req.WorkflowID),
zap.String("workspace_id", req.WorkspaceID),
zap.Error(err))
}
return ws.NewError(msg.ID, msg.Action, code, message, nil)
}
identity, _ := authn.IdentityFromContext(ctx)
contributions, err := h.resolveMCPRemoteContributions(ctx, req.WorkspaceID, identity.UserID, repos)
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
}
workspacePolicy, err := h.resolveMCPWorkspacePolicy(req.ParentID, req.WorkspaceMode)
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
}
pendingTask := &models.Task{
ParentID: req.ParentID,
WorkspaceID: req.WorkspaceID,
WorkflowID: req.WorkflowID,
WorkflowStepID: req.WorkflowStepID,
}
launchConfig, metadata, err := h.resolveMCPLaunchMetadata(ctx, pendingTask, req.AgentProfileID, req.ExecutorProfileID, req.SourceTaskID)
if err != nil {
code := ws.ErrorCodeInternalError
if errors.Is(err, errMCPAgentProfileRequired) {
code = ws.ErrorCodeValidation
}
return ws.NewError(msg.ID, msg.Action, code, err.Error(), nil)
}
metadata = workspacePolicy.MergeMetadataBlock(metadata)
var deferredLaunch map[string]interface{}
if startAgent {
deferredLaunch = map[string]interface{}{
"intent": "start", "agent_profile_id": launchConfig.AgentProfileID,
"executor_id": launchConfig.ExecutorID, "executor_profile_id": launchConfig.ExecutorProfileID,
"prompt": req.Description,
}
}
result, err := h.taskSvc.CreateTask(ctx, &service.CreateTaskRequest{
ParentID: req.ParentID,
WorkspaceID: req.WorkspaceID,
WorkflowID: req.WorkflowID,
WorkflowStepID: req.WorkflowStepID,
Title: req.Title,
Description: req.Description,
Autopilot: req.Autopilot,
Repositories: repos,
BlockedBy: req.BlockedBy,
AssigneeAgentProfileID: req.AssigneeAgentProfileID,
Metadata: metadata,
DeferredLaunch: deferredLaunch,
ExternalID: req.ExternalID,
})
if err != nil {
h.logger.Error("failed to create task", zap.Error(err))
code := classifyCreateTaskError(err)
message := "Failed to create task"
if code != ws.ErrorCodeInternalError {
message = err.Error()
}
return ws.NewError(msg.ID, msg.Action, code, message, nil)
}
// The MCP skip is a data-loss guard, not just an optimization: the steps
// below resolve remote contributions from the REQUEST (resolveMCPRemote
// Contributions above) but index them against the RETURNED task's
// repositories, and every rollback path on a mismatch calls DeleteTask.
// A retry landing on a Found outcome — the existing task, whose
// repository list need not match this retry's payload — would then
// misindex, roll back, and delete the task the caller was trying to
// recover. Both Found outcomes have no side effects, so skip everything
// below and return the existing task as-is.
if result.Outcome != service.CreateTaskOutcomeCreated {
return ws.NewResponse(msg.ID, msg.Action, mcpCreateTaskResult{
TaskDTO: dto.FromTask(result.Task),
Deduplicated: true,
CreationComplete: result.Outcome == service.CreateTaskOutcomeFoundSettled,
})
}
task := result.Task
for index, resolution := range contributions {
if resolution == nil {
continue
}
if index >= len(task.Repositories) || task.Repositories[index] == nil {
if delErr := h.taskSvc.DeleteTask(ctx, task.ID); delErr != nil {
h.logger.Error("rollback delete failed after missing task repository",
zap.String("task_id", task.ID), zap.Error(delErr))
}
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "failed to attach remote contribution: task repository is missing", nil)
}
if err := h.remoteContributionSvc.Associate(ctx, req.WorkspaceID, identity.UserID, task.ID, task.Repositories[index].ID, resolution); err != nil {
h.logger.Error("associate remote contribution; rolling back task creation",
zap.String("task_id", task.ID), zap.Error(err))
if delErr := h.taskSvc.DeleteTask(ctx, task.ID); delErr != nil {
h.logger.Error("rollback delete failed after contribution association error",
zap.String("task_id", task.ID), zap.Error(delErr))
}
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "failed to attach remote contribution: "+err.Error(), nil)
}
}
if h.handoffSvc != nil && workspacePolicy.NeedsAttachment() {
if attachErr := h.handoffSvc.AttachWorkspacePolicy(ctx, task.ID, req.ParentID, workspacePolicy); attachErr != nil {
h.logger.Error("attach workspace policy; rolling back task creation",
zap.String("task_id", task.ID), zap.Error(attachErr))
if delErr := h.taskSvc.DeleteTask(ctx, task.ID); delErr != nil {
h.logger.Error("rollback delete failed; task left in inconsistent state",
zap.String("task_id", task.ID), zap.Error(delErr))
}
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "failed to attach workspace policy: "+attachErr.Error(), nil)
}
}
// Settlement (create-sequence step 7): after policy attach, before
// auto-start dispatch.
settled, survivor, settleErr := h.taskSvc.SettleExternalID(ctx, task.ID, task.ExternalID)
if settleErr != nil {
if errors.Is(settleErr, taskrepo.ErrTaskNotFound) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "task not found", nil)
}
h.logger.Error("failed to settle external_id", zap.String("task_id", task.ID), zap.Error(settleErr))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "failed to create task", nil)
}
if !settled {
// CreatedIdentityLost: another actor released the identity while
// this create was running. The task survives holding no
// external_id; per the spec, no asynchronous work (auto-start) is
// dispatched for it.
return ws.NewResponse(msg.ID, msg.Action, mcpCreateTaskResult{
TaskDTO: dto.FromTask(survivor),
Deduplicated: false,
CreationComplete: true,
})
}
// Auto-start agent session asynchronously only if requested and admitted.
if startAgent && task.QueuedForStepID == "" && h.sessionLauncher != nil {
h.launchAutoStartTask(ctx, task, launchConfig)
}
return ws.NewResponse(msg.ID, msg.Action, mcpCreateTaskResult{
TaskDTO: dto.FromTask(task),
Deduplicated: false,
CreationComplete: true,
})
}
// mcpCreateTaskResult is create_task_kandev's tool-result shape: the task
// DTO plus deduplicated/creation_complete, per
// docs/specs/tasks/external-id-idempotency/spec.md, "MCP" — required
// booleans, not presence-only markers, mirroring the REST create response.
type mcpCreateTaskResult struct {
dto.TaskDTO
Deduplicated bool `json:"deduplicated"`
CreationComplete bool `json:"creation_complete"`
}
func classifyCreateTaskError(err error) string {
switch {
case errors.Is(err, service.ErrWIPLimitExceeded):
return ws.ErrorCodeConflict
case errors.Is(err, service.ErrTaskTitleTooLong):
return ws.ErrorCodeValidation
case errors.Is(err, service.ErrSubtaskDepthExceeded),
errors.Is(err, service.ErrInvalidTaskWorkflow),
errors.Is(err, service.ErrExternalIDInvalid),
isMCPWorkflowNotFoundError(err):
return ws.ErrorCodeValidation
default:
return ws.ErrorCodeInternalError
}
}
// taskRepoResult holds the output of resolveTaskRepositories.
type taskRepoResult struct {
Repos []service.TaskRepositoryInput
WorkspaceID string // inherited from parent, empty otherwise
WorkflowID string // inherited from parent, empty otherwise
}
func (h *Handlers) resolveMCPRemoteContributions(
ctx context.Context, workspaceID, userID string, repos []service.TaskRepositoryInput,
) ([]*models.RemoteContributionResolution, error) {
resolutions := make([]*models.RemoteContributionResolution, len(repos))
if h.remoteContributionSvc == nil {
return resolutions, nil
}
for index := range repos {
rawURL := strings.TrimSpace(repos[index].RemoteURL)
if rawURL == "" {
rawURL = strings.TrimSpace(repos[index].GitHubURL)
}
if rawURL == "" {
continue
}
resolution, matched, err := h.remoteContributionSvc.Resolve(ctx, workspaceID, userID, rawURL)
if err != nil {
return nil, err
}
if !matched {
continue
}
if resolution == nil {
return nil, errors.New("remote contribution resolver returned no binding")
}
owner, name, err := splitRemoteContributionTarget(resolution.TargetPath)
if err != nil {
return nil, err
}
repo := &repos[index]
repo.RepositoryID = ""
repo.LocalPath = ""
repo.GitHubURL = ""
repo.RemoteURL = resolution.TargetRemoteURL
repo.Provider = resolution.TargetProvider
repo.ProviderHost = resolution.TargetHost
repo.ProviderRepoID = resolution.TargetProviderID
repo.ProviderOwner = owner
repo.ProviderName = name
if resolution.TargetDefaultBranch != "" {
repo.DefaultBranch = resolution.TargetDefaultBranch
}
repo.BaseBranch = resolution.Binding.BaseBranch
repo.CheckoutBranch = resolution.Binding.HeadBranch
repo.RemoteContribution = &resolution.Binding
repo.TrustedRemote = true
resolutions[index] = resolution
}
return resolutions, nil
}
func splitRemoteContributionTarget(path string) (string, string, error) {
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) < 2 || parts[len(parts)-1] == "" {
return "", "", fmt.Errorf("remote contribution target repository %q is invalid", path)
}
return strings.Join(parts[:len(parts)-1], "/"), parts[len(parts)-1], nil
}
// resolveTaskRepositories builds the repository list for a new task.
//
// For subtasks (parentID set), workspace and workflow default from the parent
// when the caller omits explicit scope. Explicit repositories override the
// parent's repos when supplied, otherwise the parent's repos are inherited
// verbatim — letting an agent spin up a subtask that targets a sibling repo
// while staying in the same workspace/workflow by default.
//
// For top-level tasks (parentID empty) explicit repos win over source-task
// inheritance; workspace falls back to the source task when available.
func (h *Handlers) resolveTaskRepositories(
ctx context.Context,
parentID, sourceTaskID string,
explicit []mcpRepositoryInput,
) (taskRepoResult, error) {
explicitRepos := h.explicitRepoInputsWithDefaults(ctx, explicit)
if parentID != "" {
parent, err := h.taskSvc.GetTask(ctx, parentID)
if err != nil {
return taskRepoResult{}, fmt.Errorf("invalid parent_id: %w", err)
}
if parent.IsEphemeral {
return taskRepoResult{}, fmt.Errorf("cannot create subtasks of an ephemeral task (quick chat); omit parent_id to create a top-level task")
}
if parent.ParentID != "" && !parent.IsFromOffice {
return taskRepoResult{}, service.ErrSubtaskDepthExceeded
}
repos := explicitRepos
if repos == nil {
repos = inheritedRepoInputs(parent.Repositories)
}
return taskRepoResult{
Repos: repos,
WorkspaceID: parent.WorkspaceID,
WorkflowID: parent.WorkflowID,
}, nil
}
if explicitRepos != nil {
result := taskRepoResult{Repos: explicitRepos}
// Inherit workspace from source task so multi-workspace installs don't
// fail auto-resolution when the agent supplies an explicit repository.
if sourceTaskID != "" && h.taskSvc != nil {
src, srcErr := h.taskSvc.GetTask(ctx, sourceTaskID)
if srcErr != nil {
h.logger.Warn("source task lookup failed, skipping workspace inheritance",
zap.String("source_task_id", sourceTaskID), zap.Error(srcErr))
} else {
result.WorkspaceID = src.WorkspaceID
}
}
return result, nil
}
// For top-level tasks, inherit repos and workspace from the calling agent's current task.
if sourceTaskID != "" {
sourceTask, err := h.taskSvc.GetTask(ctx, sourceTaskID)
if err != nil {
h.logger.Warn("source task not found, skipping inheritance",
zap.String("source_task_id", sourceTaskID), zap.Error(err))
return taskRepoResult{}, nil
}
return taskRepoResult{
Repos: inheritedRepoInputs(sourceTask.Repositories),
WorkspaceID: sourceTask.WorkspaceID,
}, nil
}
return taskRepoResult{}, nil
}
const (
mcpWorkspaceModeInheritParent = "inherit_parent"
mcpWorkspaceModeNewWorkspace = "new_workspace"
)
func (h *Handlers) resolveMCPWorkspacePolicy(parentID, workspaceMode string) (service.WorkspacePolicy, error) {
mode := strings.TrimSpace(workspaceMode)
if mode == "" && parentID != "" {
mode = mcpWorkspaceModeInheritParent
}
if mode == "" {
return service.WorkspacePolicy{}, nil
}
switch mode {
case mcpWorkspaceModeInheritParent:
if parentID == "" {
return service.WorkspacePolicy{}, fmt.Errorf("workspace_mode=%s requires parent_id", mcpWorkspaceModeInheritParent)
}
case mcpWorkspaceModeNewWorkspace:
default:
return service.WorkspacePolicy{}, fmt.Errorf("invalid workspace_mode: %s (allowed: inherit_parent, new_workspace)", mode)
}
return service.WorkspacePolicy{Mode: mode}, nil
}
func applyMCPTaskScopeDefaults(parentID, workspaceID, workflowID, workflowStepID string, explicitWorkspaceID, explicitWorkflowID bool, resolved taskRepoResult) (string, string, string) {
if parentID == "" {
return firstNonEmptyString(workspaceID, resolved.WorkspaceID), firstNonEmptyString(workflowID, resolved.WorkflowID), workflowStepID
}
workspaceID = firstNonEmptyString(workspaceID, resolved.WorkspaceID)
if workflowID == "" && !explicitWorkspaceID {
workflowID = resolved.WorkflowID
}
// A caller-supplied step is only safe when the caller also supplies the
// workflow it belongs to. Otherwise the target workflow is inherited or
// auto-resolved and the step can straddle workflow boundaries.
if !explicitWorkflowID {
workflowStepID = ""
}
return workspaceID, workflowID, workflowStepID
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func (h *Handlers) validateMCPWorkflowWorkspace(ctx context.Context, workflowID, workspaceID string) (string, string, error) {
if h.taskSvc == nil || workflowID == "" || workspaceID == "" {
return "", "", nil
}
workflow, err := h.taskSvc.GetWorkflow(ctx, workflowID)
if err != nil {
if isMCPWorkflowNotFoundError(err) {
return ws.ErrorCodeValidation, fmt.Sprintf("workflow_id %q was not found", workflowID), nil
}
return ws.ErrorCodeInternalError, "Failed to validate workflow_id", err
}
if workflow.WorkspaceID != workspaceID {
return ws.ErrorCodeValidation, fmt.Sprintf("workflow_id %q belongs to workspace_id %q, not %q", workflowID, workflow.WorkspaceID, workspaceID), nil
}
return "", "", nil
}
func isMCPWorkflowNotFoundError(err error) bool {
return err != nil && strings.Contains(strings.ToLower(err.Error()), "workflow not found")
}
// explicitRepoInputsWithDefaults maps the MCP-side explicit repo list to
// service inputs. Returns nil when no explicit repos were supplied so callers
// can distinguish "agent didn't pass repos" from "agent passed an empty list".
//
// When an explicit entry pins a repository_id without a base_branch, the
// repository's default_branch is filled in. This anchors cross-repo subtasks
// (a parent on feature/foo creating a child in another repo) to a known-good
// branch instead of an empty value that would force every downstream consumer
// to recompute the default.
func (h *Handlers) explicitRepoInputsWithDefaults(ctx context.Context, explicit []mcpRepositoryInput) []service.TaskRepositoryInput {
if len(explicit) == 0 {
return nil
}
repos := make([]service.TaskRepositoryInput, 0, len(explicit))
for _, r := range explicit {
baseBranch := r.BaseBranch
if baseBranch == "" && r.RepositoryID != "" && h.taskSvc != nil {
if repo, err := h.taskSvc.GetRepository(ctx, r.RepositoryID); err == nil && repo != nil {
baseBranch = repo.DefaultBranch
}
}
repos = append(repos, service.TaskRepositoryInput{
RepositoryID: r.RepositoryID,
LocalPath: r.LocalPath,
GitHubURL: r.GitHubURL,
BaseBranch: baseBranch,
})
}
return repos
}
// inheritedRepoInputs maps an existing task's repository list onto service
// inputs for a new task that inherits from it. RepositoryID and BaseBranch
// carry over so a same-repo subtask branches off the same point as the
// parent (sibling branches off the same base, ergonomically aligned for
// stacked PRs). CheckoutBranch is dropped on purpose: two worktrees cannot
// share a working branch, so the subtask's session generates a fresh one.
// Agents that need a different base for a same-repo subtask must pass
// base_branch explicitly. If the inherited base_branch is missing on the
// remote at launch time, the worktree manager's fallback recovers to the
// repository's default_branch and surfaces a warning.
func inheritedRepoInputs(src []*models.TaskRepository) []service.TaskRepositoryInput {
if len(src) == 0 {
return nil
}
repos := make([]service.TaskRepositoryInput, 0, len(src))
for _, r := range src {
if r == nil {
continue
}
repos = append(repos, service.TaskRepositoryInput{
RepositoryID: r.RepositoryID,
BaseBranch: r.BaseBranch,
})
}
return repos
}
type mcpAutoStartConfig struct {
AgentProfileID string
ExecutorID string
ExecutorProfileID string
}
var errMCPAgentProfileRequired = errors.New("agent_profile_id is required because the selected task profile policy, workflow, and workspace defaults did not resolve a profile")
// autoStartTask launches an agent session for a newly created task in the background.
// 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) {
ctx := context.Background()
config := h.resolveMCPAutoStartConfig(ctx, task, agentProfileID, executorProfileID, sourceTaskID)
h.launchAutoStartTask(ctx, task, config)
}
// 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 {
config, _ := h.resolveMCPAutoStartConfigWithError(ctx, task, agentProfileID, executorProfileID, sourceTaskID)
return config
}
func (h *Handlers) resolveMCPLaunchMetadata(ctx context.Context, task *models.Task, agentProfileID, executorProfileID, sourceTaskID string) (mcpAutoStartConfig, map[string]interface{}, error) {
launchConfig, err := h.resolveMCPAutoStartConfigWithError(ctx, task, agentProfileID, executorProfileID, sourceTaskID)
if err != nil {
return mcpAutoStartConfig{}, nil, fmt.Errorf("failed to resolve launch profile: %w", err)
}
if launchConfig.AgentProfileID == "" {
return mcpAutoStartConfig{}, nil, errMCPAgentProfileRequired
}
metadata := map[string]interface{}{
models.MetaKeyAgentProfileID: launchConfig.AgentProfileID,
}
if launchConfig.ExecutorID != "" {
metadata[models.MetaKeyExecutorID] = launchConfig.ExecutorID
}
if launchConfig.ExecutorProfileID != "" {
metadata[models.MetaKeyExecutorProfileID] = launchConfig.ExecutorProfileID
}
return launchConfig, metadata, nil
}
func (h *Handlers) resolveMCPAutoStartConfigWithError(ctx context.Context, task *models.Task, agentProfileID, executorProfileID, sourceTaskID string) (mcpAutoStartConfig, error) {
profileDefault, err := h.mcpTaskAgentProfileDefault(ctx, agentProfileID)
if err != nil {
return mcpAutoStartConfig{}, fmt.Errorf("read MCP task agent profile default: %w", err)
}
profileForInheritance := &agentProfileID
// Workspace-default mode keeps executor inheritance but discards inherited agent profiles.
ignoredInheritedProfile := ""
if profileDefault == usermodels.MCPTaskAgentProfileDefaultWorkspaceDefault {
profileForInheritance = &ignoredInheritedProfile
}
executorID, err := h.inheritFromTask(ctx, task.ParentID, profileForInheritance, &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, err := h.inheritFromTask(ctx, sourceTaskID, profileForInheritance, &executorProfileID)
if err != nil {
return mcpAutoStartConfig{}, fmt.Errorf("inherit from source task %s: %w", sourceTaskID, err)
}
if executorID == "" {
executorID = sourceExecutorID
}
}
// Mirror the orchestrator's launch-time precedence so the profile reported
// back to the caller (and stored in task metadata) equals the one that will
// actually run. At launch resolveEffectiveAgentProfile applies the step's
// launch profile (the step's pinned profile, or the workflow default when the
// step has none) over any caller-provided profile, but only when the task
// sits on a workflow step. A create_task_kandev task lands on a step whenever
// its workflow has steps: the explicit workflow_step_id, or the start step
// CreateTask assigns when the step is omitted. When the task will be on a
// step, the workflow-derived profile overrides the caller; otherwise it only
// fills an omitted profile.
workflowProfileID, onStepAtLaunch, err := h.resolveWorkflowLaunchProfile(ctx, task.WorkflowStepID, task.WorkflowID)
if err != nil {
return mcpAutoStartConfig{}, fmt.Errorf("resolve workflow agent profile: %w", err)
}
if workflowProfileID != "" && (onStepAtLaunch || agentProfileID == "") {
agentProfileID = workflowProfileID
}
if agentProfileID == "" && h.taskSvc != nil {
workspace, err := h.taskSvc.GetWorkspace(ctx, task.WorkspaceID)
if err != nil {
return mcpAutoStartConfig{}, fmt.Errorf("get workspace %s: %w", task.WorkspaceID, err)
}
if workspace != nil && workspace.DefaultAgentProfileID != nil {
agentProfileID = *workspace.DefaultAgentProfileID
}
}
if executorID == "" && executorProfileID == "" {
executorID = models.ExecutorIDWorktree
}
return mcpAutoStartConfig{
AgentProfileID: agentProfileID,
ExecutorID: executorID,
ExecutorProfileID: executorProfileID,
}, nil
}
func (h *Handlers) mcpTaskAgentProfileDefault(ctx context.Context, explicitAgentProfileID string) (string, error) {
if explicitAgentProfileID != "" || h.userSettingsProvider == nil {
return usermodels.MCPTaskAgentProfileDefaultCurrentTask, nil
}
settings, err := h.userSettingsProvider.GetUserSettings(ctx)
if err != nil {
return "", err
}
if settings == nil {
return usermodels.MCPTaskAgentProfileDefaultCurrentTask, nil
}
return usermodels.NormalizeMCPTaskAgentProfileDefault(settings.MCPTaskAgentProfileDefault), nil
}
// resolveWorkflowLaunchProfile returns the workflow-derived agent profile a task
// launches with and whether the task will sit on a workflow step at launch.
// onStepAtLaunch is true when the task has an explicit step, or when it has no
// step yet but its workflow has at least one step (CreateTask assigns that start
// step). Callers apply the returned profile over an explicit caller profile only
// when onStepAtLaunch is true; off a step it may only fill an omitted profile.
func (h *Handlers) resolveWorkflowLaunchProfile(ctx context.Context, workflowStepID, workflowID string) (string, bool, error) {
profileID, err := h.resolveWorkflowAgentProfileWithError(ctx, workflowStepID, workflowID)
if err != nil {
return "", false, err
}
if workflowStepID != "" {
return profileID, true, nil
}
return profileID, h.workflowHasSteps(ctx, workflowID), nil
}
// workflowHasSteps reports whether the workflow has at least one step, i.e. a
// stepless task created on it will be assigned a start step at CreateTask time.
func (h *Handlers) workflowHasSteps(ctx context.Context, workflowID string) bool {
if workflowID == "" || h.workflowCtrl == nil {
return false
}
resp, err := h.workflowCtrl.ListStepsByWorkflow(ctx, workflowctrl.ListStepsRequest{WorkflowID: workflowID})
if err != nil || resp == nil {
return false
}
return len(resp.Steps) > 0
}
func (h *Handlers) resolveWorkflowAgentProfileWithError(ctx context.Context, workflowStepID, workflowID string) (string, error) {
profileID, resolvedWorkflowID := h.resolveWorkflowControllerAgentProfile(ctx, workflowStepID, workflowID)
if profileID != "" {
return profileID, nil
}
if workflowID == "" {
workflowID = resolvedWorkflowID
}
profileID, err := h.workflowDefaultAgentProfileWithError(ctx, workflowID)
if err != nil {
return "", err
}
return profileID, nil
}
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) workflowDefaultAgentProfileWithError(ctx context.Context, workflowID string) (string, error) {
if workflowID == "" || h.taskSvc == nil {
return "", nil
}
workflow, err := h.taskSvc.GetWorkflow(ctx, workflowID)
if err != nil || workflow == nil {
return "", err
}
return workflow.AgentProfileID, nil
}
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.WithoutCancel(ctx), constants.AgentLaunchTimeout)
defer cancel()
resp, err := h.sessionLauncher.LaunchSession(ctx, &orchestrator.LaunchSessionRequest{
TaskID: task.ID,
Intent: orchestrator.IntentStart,
AgentProfileID: config.AgentProfileID,
ExecutorID: config.ExecutorID,
ExecutorProfileID: config.ExecutorProfileID,
WorkflowStepID: task.WorkflowStepID,
Prompt: task.Description,
})
if err != nil {
h.logger.Error("failed to auto-start task",
zap.String("task_id", task.ID), zap.Error(err))
return
}
h.logger.Info("auto-started agent for MCP-created task",
zap.String("task_id", task.ID),
zap.String("session_id", resp.SessionID))
}()
}
// inheritFromTask fills agentProfileID and executorProfileID from another task's
// 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 "", nil
}
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 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
}
if *executorProfileID != "" {
return ""
}
return session.ExecutorID
}
func (h *Handlers) inheritFromTaskMetadata(ctx context.Context, taskID string, agentProfileID, executorProfileID *string, executorID string) string {
task, err := h.taskSvc.GetTask(ctx, taskID)
if err != nil || task == nil {
return executorID
}
inheritMetadataValue(task.Metadata, models.MetaKeyAgentProfileID, agentProfileID)
inheritMetadataValue(task.Metadata, models.MetaKeyExecutorProfileID, executorProfileID)
if executorID == "" && *executorProfileID == "" {
executorID = metadataString(task.Metadata, models.MetaKeyExecutorID)
}
return executorID
}
func inheritMetadataValue(metadata map[string]interface{}, key string, target *string) {
if *target != "" {
return
}
*target = metadataString(metadata, key)
}
func metadataString(metadata map[string]interface{}, key string) string {
if v, ok := metadata[key].(string); ok && v != "" {
return v
}
return ""
}
// handleUpdateTask updates an existing task.
func (h *Handlers) handleUpdateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
// Use local struct with JSON tags since dto.UpdateTaskRequest lacks them
var req struct {
TaskID string `json:"task_id"`
Title *string `json:"title"`
Description *string `json:"description"`
State *string `json:"state"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.TaskID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
var state *v1.TaskState
if req.State != nil {
normalized := normalizeTaskState(*req.State)
if !isValidTaskState(normalized) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "invalid task state: "+*req.State, nil)
}
state = &normalized
}
task, err := h.taskSvc.UpdateTask(ctx, req.TaskID, &service.UpdateTaskRequest{
Title: req.Title,
Description: req.Description,
State: state,
})
if err != nil {
h.logger.Error("failed to update task", zap.Error(err))
if errors.Is(err, service.ErrTaskTitleTooLong) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
}
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update task", nil)
}
return ws.NewResponse(msg.ID, msg.Action, dto.FromTask(task))
}
// handleSetTaskTitle resolves the one-shot provisional title created for a
// prompt-first task. The MCP server supplies the bound task and session IDs;
// only the atomically claimed owner may resolve it.
func (h *Handlers) handleSetTaskTitle(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
Title string `json:"title"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.TaskID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
if req.SessionID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
}
if strings.TrimSpace(req.Title) == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "title is required", nil)
}
task, accepted, reason, err := h.taskSvc.SetPendingAgentTitle(ctx, req.TaskID, req.SessionID, req.Title)
if err != nil {
if errors.Is(err, taskrepo.ErrTaskNotFound) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "task not found", nil)
}
if errors.Is(err, service.ErrTaskTitleTooLong) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
}
h.logger.Error("failed to set pending task title", zap.String("task_id", req.TaskID), zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to set task title", nil)
}
result := map[string]interface{}{
"accepted": accepted,
"task_id": req.TaskID,
"title": task.Title,
}
if !accepted {
result["reason"] = reason
return ws.NewResponse(msg.ID, msg.Action, result)
}
if h.titleBranchRenamer != nil {
branchResult, branchErr := h.titleBranchRenamer.RenameGeneratedBranchesForTaskTitle(ctx, req.TaskID, req.SessionID, task.Title)
if branchErr != nil {
h.logger.Warn("failed to rename generated task branches", zap.String("task_id", req.TaskID), zap.Error(branchErr))
branchResult = orchestrator.TitleBranchRenameResult{
Status: orchestrator.TitleBranchStatusFailed,
Failed: []orchestrator.TitleBranchFailure{{Message: branchErr.Error()}},
}
}
result["branch_rename"] = branchResult
} else {
result["branch_rename"] = orchestrator.TitleBranchRenameResult{Status: orchestrator.TitleBranchStatusNotApplicable}
}
return ws.NewResponse(msg.ID, msg.Action, result)
}
// handleAddBranchToTask attaches a new (repository, checkout_branch) pair to
// an existing task. Mirrors create-time multi-repo attachment but additive:
// the same repository may be added on a different branch, materializing a
// second worktree under the task's directory.
func (h *Handlers) handleAddBranchToTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
TaskID string `json:"task_id"`
RepositoryID string `json:"repository_id"`
LocalPath string `json:"local_path"`
GitHubURL string `json:"github_url"`
BaseBranch string `json:"base_branch"`
CheckoutBranch string `json:"checkout_branch"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.TaskID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
// Mutual exclusion across the three repo identifiers. resolveRepoInput
// applies a silent precedence (repository_id > github_url > local_path),
// so an agent that accidentally passes two of them gets a behaviour
// change with no signal. Reject early instead so the agent sees the
// mistake.
if locatorCount := boolCount(req.RepositoryID != "", req.LocalPath != "", req.GitHubURL != ""); locatorCount > 1 {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation,
"pass at most one of repository_id, github_url, local_path", nil)
}
// repository_id / local_path / github_url are all optional: the service
// defaults to the task's only repository (or its primary row) when none
// is supplied. Multi-repo tasks force the agent to pass one explicitly
// via the service-level error. local_path and github_url are
// agent-ergonomic alternatives — when supplied the service resolves
// them through the same workspace-scoped find-or-create path used by
// create_task.
taskRepo, err := h.taskSvc.AddBranchToTask(ctx, service.AddBranchToTaskRequest{
TaskID: req.TaskID,
RepositoryID: req.RepositoryID,
LocalPath: req.LocalPath,
GitHubURL: req.GitHubURL,
BaseBranch: req.BaseBranch,
CheckoutBranch: req.CheckoutBranch,
})
if err != nil {
h.logger.Error("failed to add branch to task", zap.Error(err))
code := classifyAddBranchError(err)
return ws.NewError(msg.ID, msg.Action, code, "Failed to add branch: "+err.Error(), nil)
}
response := map[string]interface{}{
"id": taskRepo.ID,
keyTaskID: taskRepo.TaskID,
keyRepositoryID: taskRepo.RepositoryID,
keyBaseBranch: taskRepo.BaseBranch,
keyCheckoutBranch: taskRepo.CheckoutBranch,
keyPosition: taskRepo.Position,
"agent_cwd_changed": taskRepo.AgentCWDChanged,
}
if taskRepo.WorktreePath != "" {
response["worktree_path"] = taskRepo.WorktreePath
}
if taskRepo.TaskWorkspacePath != "" {
response["task_workspace_path"] = taskRepo.TaskWorkspacePath
}
return ws.NewResponse(msg.ID, msg.Action, response)
}
// handleAddWorkspaceSources forwards the documented discriminated source
// union to the shared attachment service. It deliberately rejects fields from
// the other variant so callers cannot get a silently ambiguous attachment.
func (h *Handlers) handleAddWorkspaceSources(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
TaskID string `json:"task_id"`
Sources []json.RawMessage `json:"sources"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.TaskID == "" || len(req.Sources) == 0 {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id and at least one source are required", nil)
}
sources, err := parseWorkspaceSources(req.Sources)
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
}
result, err := h.taskSvc.AttachWorkspaceSources(ctx, service.AttachWorkspaceSourcesRequest{TaskID: req.TaskID, Sources: sources})
if err != nil {
return ws.NewError(msg.ID, msg.Action, classifyWorkspaceSourceError(err), "Failed to attach workspace sources: "+err.Error(), nil)
}
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"task_id": result.Task.ID, "repositories": result.Task.Repositories, "workspace_folders": result.Task.WorkspaceFolders, "workspace_path": result.WorkspacePath, "session_ids": result.SessionIDs,
})
}
func parseWorkspaceSources(raw []json.RawMessage) ([]service.WorkspaceSourceInput, error) {
sources := make([]service.WorkspaceSourceInput, 0, len(raw))
for _, item := range raw {
var fields map[string]json.RawMessage
if err := json.Unmarshal(item, &fields); err != nil {
return nil, fmt.Errorf("source must be an object")
}
var kind string
if err := json.Unmarshal(fields["kind"], &kind); err != nil {
return nil, fmt.Errorf("source kind is required")
}
allowed := map[string]bool{"kind": true, "local_path": true}
switch kind {
case string(service.WorkspaceSourceRepository):
for _, key := range []string{"repository_id", "remote_url", "github_url", "provider", "provider_repo_id", "provider_owner", "provider_name", "base_branch", "checkout_branch"} {
allowed[key] = true
}
case string(service.WorkspaceSourceFolder):
allowed["display_name"] = true
default:
return nil, fmt.Errorf("unsupported workspace source kind %q", kind)
}
for key := range fields {
if !allowed[key] {
return nil, fmt.Errorf("field %q is not allowed for %s source", key, kind)
}
}
var source workspaceSourceJSON
if err := json.Unmarshal(item, &source); err != nil {
return nil, err
}
sources = append(sources, service.WorkspaceSourceInput{Kind: service.WorkspaceSourceKind(source.Kind), RepositoryID: source.RepositoryID, LocalPath: source.LocalPath, GitHubURL: source.GitHubURL, RemoteURL: source.RemoteURL, Provider: source.Provider, ProviderRepoID: source.ProviderRepoID, ProviderOwner: source.ProviderOwner, ProviderName: source.ProviderName, BaseBranch: source.BaseBranch, CheckoutBranch: source.CheckoutBranch, DisplayName: source.DisplayName})
}
return sources, nil
}
func classifyWorkspaceSourceError(err error) string {
switch {
case errors.Is(err, service.ErrInvalidWorkspaceSource):
return ws.ErrorCodeValidation
case errors.Is(err, taskrepo.ErrTaskNotFound), errors.Is(err, taskrepository.ErrRepositoryNotFound), errors.Is(err, service.ErrTaskRepositoryNotFound):
return ws.ErrorCodeNotFound
case errors.Is(err, service.ErrWorkspaceSourceConflict), errors.Is(err, service.ErrWorkspaceSourceActive):
return ws.ErrorCodeConflict
case errors.Is(err, service.ErrUnsupportedWorkspaceSource), errors.Is(err, service.ErrWorkspaceSourceMaterialize):
return ws.ErrorCodeValidation
default:
return ws.ErrorCodeInternalError
}
}
// handleUpdateRepositoryBaseBranch updates the base_branch on a single
// task_repositories row. The agentctl side is notified live via the service's
// AgentBaseBranchPusher hook so the changes-panel diff stats reflect the new
// base immediately, not just at next session start.
func (h *Handlers) handleUpdateRepositoryBaseBranch(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
TaskID string `json:"task_id"`
TaskRepositoryID string `json:"task_repository_id"`
BaseBranch string `json:"base_branch"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.TaskID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
if req.TaskRepositoryID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_repository_id is required", nil)
}
taskRepo, err := h.taskSvc.UpdateRepositoryBaseBranch(ctx, service.UpdateRepositoryBaseBranchRequest{
TaskID: req.TaskID,
TaskRepositoryID: req.TaskRepositoryID,
BaseBranch: req.BaseBranch,
})
if err != nil {
h.logger.Error("failed to update repository base branch", zap.Error(err))
if errors.Is(err, service.ErrTaskRepositoryNotFound) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, err.Error(), nil)
}
// Caller-facing validation messages (required-field, invalid ref
// name) pass through verbatim so MCP agents can react; internal
// faults stay opaque so DB-level details don't leak.
if isValidationError(err) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
}
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update base branch", nil)
}
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"id": taskRepo.ID,
keyTaskID: taskRepo.TaskID,
keyRepositoryID: taskRepo.RepositoryID,
keyBaseBranch: taskRepo.BaseBranch,
keyCheckoutBranch: taskRepo.CheckoutBranch,
keyPosition: taskRepo.Position,
})
}
// boolCount returns how many of the supplied boolean flags are true. Used
// to enforce mutual exclusion across optional input fields without a chain
// of nested ifs.
func boolCount(flags ...bool) int {
n := 0
for _, b := range flags {
if b {
n++
}
}
return n
}
// isValidationError matches the user-facing fragments emitted by the
// service-layer validators (required fields, invalid ref names). Shared by
// every MCP write handler so service-side message tweaks need only one
// place to flow through to the MCP error classification. Kept narrow on
// purpose — DB / IO failures often carry "invalid" in their message and
// must surface as InternalError, not Validation.
func isValidationError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "is required") ||
strings.Contains(msg, "not allowed in a git ref name")
}
// classifyAddBranchError maps service-layer add_branch failures to ws error
// codes so MCP agents can react to user-fixable input mistakes (missing
// task, duplicate branch, wrong executor) instead of treating them as
// backend faults.
func classifyAddBranchError(err error) string {
if err == nil {
return ws.ErrorCodeInternalError
}
if errors.Is(err, taskrepo.ErrTaskNotFound) {
return ws.ErrorCodeNotFound
}
msg := err.Error()
switch {
case strings.Contains(msg, "does not belong to task's workspace"):
return ws.ErrorCodeNotFound
case strings.Contains(msg, "is already attached"),
strings.Contains(msg, "conflicts with existing branch"):
return ws.ErrorCodeConflict
case strings.Contains(msg, "repository_id is required"),
strings.Contains(msg, "only supported on the worktree executor"),
strings.Contains(msg, "task_id is required"),
strings.Contains(msg, "cannot resolve base_branch"):
return ws.ErrorCodeValidation
case strings.Contains(msg, "GitHub URL"),
strings.Contains(msg, "github.com/owner/repo"),
strings.Contains(msg, "does not belong to workspace"):
// User-fixable failures from ResolveRepositoryRef / parseGitHubRepoURL:
// malformed URL, non-github host, cross-workspace repository_id.
// Narrow patterns (not a broad "resolve repository:" prefix) so
// downstream DB / system errors from CreateRepository / ListRepositories
// still classify as InternalError.
return ws.ErrorCodeValidation
}
return ws.ErrorCodeInternalError
}
// handleStepComplete records the agent's explicit step-completion signal
// (ADR 0015). The handler:
//
// - Loads the session and the task to identify the current workflow step.
// - Atomically claims the pending signal for the current step. A signal for
// an older step is replaced, but concurrent signals for the same step use
// the first successful database claim.
// - If the current step is already claimed, returns {accepted: false,
// reason: "already_signaled"}. When the session is WAITING_FOR_INPUT, the
// bus event is re-published so a failed first-attempt publish can still
// drive the subscriber.
// - Publishes events.WorkflowStepCompletionSignaled so the orchestrator
// subscriber can drive the on_turn_complete transition for steps with
// AutoAdvanceRequiresSignal=true. Steps that don't opt in ignore the
// signal entirely; the bag entry is cleared on the next turn start
// (no separate audit trail is persisted).
//
// Idempotency is intentionally lossy — a second call within the same step
// silently keeps the first signal's payload (summary/handoff/blockers). The
// orchestrator treats the first signal as authoritative.
func (h *Handlers) handleStepComplete(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
Summary string `json:"summary"`
Handoff string `json:"handoff"`
Blockers string `json:"blockers"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.TaskID == "" || req.SessionID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id and session_id are required", nil)
}
if strings.TrimSpace(req.Summary) == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "summary is required", nil)
}
session, task, errMsg, err := h.resolveStepCompleteTarget(ctx, msg, req.TaskID, req.SessionID)
if errMsg != nil {
return errMsg, err
}
signal := models.PendingStepCompletionSignal{
StepID: task.WorkflowStepID,
Source: models.StepCompletionSourceAgent,
Summary: strings.TrimSpace(req.Summary),
Handoff: strings.TrimSpace(req.Handoff),
Blockers: strings.TrimSpace(req.Blockers),
SignaledAt: time.Now().UTC(),
}
stored, err := h.sessionRepo.SetSessionMetadataKeyIfAbsentOrDifferentStep(
ctx,
req.SessionID,
models.SessionMetaKeyPendingStepCompletion,
task.WorkflowStepID,
signal,
)
if err != nil {
h.logger.Error("failed to persist step-completion signal",
zap.String("task_id", req.TaskID),
zap.String("session_id", req.SessionID),
zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "failed to record signal", nil)
}
if !stored {
return h.handleDuplicateStepComplete(ctx, msg, req.TaskID, req.SessionID, task.WorkflowStepID, session)
}
// Counted here, at the durable bag write, not after publishStepCompletionEvent
// below: publish is a delivery concern, and a publish failure sends the agent
// down the already_signaled dedup retry path, which never reaches this call
// again for the same signal.
//
// agent_name, not agent_id: agent_id is the store's auto-generated UUID for
// the agent row (internal/agent/settings/store/sqlite.go CreateAgent), unique
// per install and per re-creation. agent_name is the registry-facing type
// ("claude", "codex") that internal/agent/runtime/lifecycle/manager_profile.go
// keys the agent registry on.
agentType, _ := session.AgentProfileSnapshot["agent_name"].(string)
signalmetrics.RecordSignalReceived(signal.Source, agentType)
if errMsg, err := h.publishStepCompletionEvent(ctx, msg, req.TaskID, req.SessionID, task.WorkflowStepID, signal); errMsg != nil {
return errMsg, err
}
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"accepted": true,
"step_id": task.WorkflowStepID,
"signaled_at": signal.SignaledAt,
})
}
func (h *Handlers) handleDuplicateStepComplete(
ctx context.Context,
msg *ws.Message,
taskID, sessionID, stepID string,
session *models.TaskSession,
) (*ws.Message, error) {
// If the initial snapshot already contained this step's signal, this is a
// retry after a possible publish failure and the event must be re-published
// while the session waits. If the initial snapshot was empty, another
// concurrent request won the claim and will publish the event itself.
existing, ok := models.LoadPendingStepSignal(session.Metadata)
if ok && existing.StepID == stepID && session.State == models.TaskSessionStateWaitingForInput {
if errMsg, err := h.publishStepCompletionEvent(ctx, msg, taskID, sessionID, stepID, existing); errMsg != nil {
return errMsg, err
}
}
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"accepted": false,
"reason": "already_signaled",
})
}
// resolveStepCompleteTarget loads the session + task the signal applies to
// and runs the up-front validation (ownership, terminal-state guard,
// workflow-step presence). Returns a populated session+task pair on success,
// or a ready-to-send WS error envelope (and its marshal error if any) on
// any failed precondition.
func (h *Handlers) resolveStepCompleteTarget(
ctx context.Context, msg *ws.Message, taskID, sessionID string,
) (*models.TaskSession, *models.Task, *ws.Message, error) {
session, err := h.sessionRepo.GetTaskSession(ctx, sessionID)
if err != nil {
// Session repo has no exported not-found sentinel; classify by
// substring and treat anything else as transient so the agent
// retries instead of abandoning the session.
if strings.Contains(err.Error(), "not found") {
errMsg, mErr := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "session not found", nil)
return nil, nil, errMsg, mErr
}
h.logger.Error("step_complete: failed to load session",
zap.String("session_id", sessionID), zap.Error(err))
errMsg, mErr := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "failed to load session", nil)
return nil, nil, errMsg, mErr
}
if session.TaskID != taskID {
errMsg, mErr := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session does not belong to task", nil)
return nil, nil, errMsg, mErr
}
// Terminal sessions cannot consume a signal: the orchestrator's
// out-of-band subscriber short-circuits on every state other than
// WAITING_FOR_INPUT, and no future turn-end will fire on a closed
// session. Reject up front so the agent gets a clear error instead of
// `accepted: true` followed by silent no-op.
switch session.State {
case models.TaskSessionStateCompleted,
models.TaskSessionStateFailed,
models.TaskSessionStateCancelled:
errMsg, mErr := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation,
"cannot signal completion for a terminal session (state: "+string(session.State)+")", nil)
return nil, nil, errMsg, mErr
}
task, err := h.taskSvc.GetTask(ctx, taskID)
if err != nil {
// Task repo exports ErrTaskNotFound; anything else is a transient
// load failure that the agent should retry rather than interpret
// as "task gone".
if errors.Is(err, taskrepo.ErrTaskNotFound) {
errMsg, mErr := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "task not found", nil)
return nil, nil, errMsg, mErr
}
h.logger.Error("step_complete: failed to load task",
zap.String("task_id", taskID), zap.Error(err))
errMsg, mErr := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "failed to load task", nil)
return nil, nil, errMsg, mErr
}
if task.WorkflowStepID == "" {
errMsg, mErr := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task has no current workflow step", nil)
return nil, nil, errMsg, mErr
}
return session, task, nil, nil
}
// publishStepCompletionEvent emits the bus event the orchestrator's
// out-of-band subscriber listens for. If publish fails the bag is already
// persisted but the subscriber will not fire — surface the error to the
// agent so it can retry. The handler-level idempotency guard guarantees the
// retry either succeeds end-to-end or short-circuits with `already_signaled`
// once the publish lands. Returns (nil, nil) on success or when no bus is wired.
func (h *Handlers) publishStepCompletionEvent(
ctx context.Context, msg *ws.Message, taskID, sessionID, stepID string,
signal models.PendingStepCompletionSignal,
) (*ws.Message, error) {
if h.eventBus == nil {
return nil, nil
}
if err := h.eventBus.Publish(ctx, events.WorkflowStepCompletionSignaled, bus.NewEvent(
events.WorkflowStepCompletionSignaled, "mcp-handlers",
map[string]interface{}{
"task_id": taskID,
"session_id": sessionID,
"step_id": stepID,
"source": signal.Source,
"summary": signal.Summary,
"signaled_at": signal.SignaledAt,
},
)); err != nil {
h.logger.Error("failed to publish step-completion signal",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError,
"failed to notify orchestrator (signal persisted; retry)", nil)
}
return nil, nil
}
// handleMessageTask sends a prompt to an existing task on behalf of an agent
// in another task. The MCP server (agentctl) injects the sender's task_id and
// session_id into the payload; this handler validates the sender, looks up its
// title, wraps the prompt in a <kandev-system> attribution block (so the
// receiving agent knows the message is from a peer agent), and dispatches via
// one of three paths depending on the target session state:
//
// - RUNNING/STARTING : message is queued and drained at turn end
// - WAITING/COMPLETED: message is recorded and the agent is prompted (auto-resuming if needed)
// - CREATED : message is recorded then the agent is started with it as initial prompt
//
// Strict validation: missing sender_task_id, self-message, and unknown sender
// task all reject with an MCP error rather than silently delivering an
// unattributed message.
func (h *Handlers) handleMessageTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
Prompt string `json:"prompt"`
SenderTaskID string `json:"sender_task_id"`
SenderSessionID string `json:"sender_session_id"`
DeliveryMode string `json:"delivery_mode"`
ReplyToQuestionID string `json:"reply_to_question_id"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.TaskID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
if req.Prompt == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "prompt is required", nil)
}
if req.SenderTaskID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "sender_task_id is required (the calling agent's MCP server must supply this)", nil)
}
// Same-task messaging is allowed only between distinct sibling sessions:
// an explicit session_id proves the sender is targeting a specific peer
// rather than echoing into its own conversation.
if req.SenderTaskID == req.TaskID && req.SessionID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation,
"task cannot send a message to itself (pass session_id to message a sibling session on your own task)", nil)
}
if req.SessionID != "" && req.SessionID == req.SenderSessionID {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session cannot send a message to itself", nil)
}
if req.DeliveryMode != "" && req.DeliveryMode != deliveryModeQueued && req.DeliveryMode != deliveryModeInterrupt {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation,
fmt.Sprintf("delivery_mode must be %q or %q", deliveryModeQueued, deliveryModeInterrupt), nil)
}
// Sender lookup is global, not workspace-scoped: cross-workspace agent
// messaging is intentionally allowed (badge URL handles cross-workspace
// nav). Task IDs are UUIDs, so this is not exploitable in practice — and
// scoping would require a product-level decision about cross-workspace
// auth/visibility/discovery that we don't want to bake in here.
senderTask, err := h.taskSvc.GetTask(ctx, req.SenderTaskID)
if err != nil || senderTask == nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "sender task not found", nil)
}
// Verify the target task exists before looking up its session, so a bad
// task_id (e.g. a truncated UUID prefix) reports "task not found" instead
// of the misleading "no primary session" error from the session lookup.
// This is purely an existence check — GetTask returns a wrapped
// ErrTaskNotFound on no-rows, never (nil, nil). The loaded task's
// ParentID also tells us whether the sender is the target's parent,
// which decides interrupt eligibility below.
targetTask, err := h.taskSvc.GetTask(ctx, req.TaskID)
if err != nil {
if errors.Is(err, taskrepo.ErrTaskNotFound) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound,
"target task not found: "+req.TaskID+" (pass the full task UUID, not a truncated prefix)", nil)
}
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError,
"failed to look up target task: "+err.Error(), nil)
}
parentReply, err := h.validateParentQuestionReply(ctx, req.ReplyToQuestionID, req.TaskID, senderTask, targetTask)
if err != nil {
var parentQuestionErr *parentQuestionError
if errors.As(err, &parentQuestionErr) {
return ws.NewError(msg.ID, msg.Action, parentQuestionErr.code, parentQuestionErr.message, nil)
}
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
}
if parentReply != nil && parentReply.alreadyAnswered {
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"task_id": req.TaskID,
"reply_to_question_id": req.ReplyToQuestionID,
stopTaskStatusKey: "already_answered",
})
}
session, errResp := h.resolveMessageTargetSession(ctx, msg, req.TaskID, req.SessionID)
if errResp != nil {
return errResp, nil
}
prompt := h.appendPromptReferenceExpansionContext(ctx, req.Prompt)
senderSessionName := h.lookupSenderSessionName(ctx, req.SenderTaskID, req.SenderSessionID)
wrappedPrompt, senderMeta := wrapAgentMessage(prompt, senderTask, req.SenderSessionID, senderSessionName, req.SenderTaskID == req.TaskID)
if req.ReplyToQuestionID != "" {
senderMeta[models.MetaKeyParentQuestionID] = req.ReplyToQuestionID
senderMeta[models.MetaKeyParentQuestionResponse] = req.Prompt
}
// Interrupt intent is explicit, never inferred: a parent/child
// relationship alone no longer drives interruption (see
// dispatchTaskMessage's interruptIfBusy parameter and
// queueThenInterruptTaskMessage). The server still authorizes
// delivery_mode="interrupt" only when the sender is the target's
// direct parent — the same relationship check as before, now gating
// an explicit request instead of driving an implicit one. A non-parent
// sender explicitly requesting "interrupt" is hard-rejected rather than
// silently downgraded to "queued": a silent downgrade would misreport
// what happened and hide caller misuse instead of telling the caller
// its request was rejected. Omitted or "queued" keeps the default
// queue-and-wait behavior documented on message_task_kandev, even for
// a parent sender.
isParentToChild := targetTask.ParentID != "" && targetTask.ParentID == senderTask.ID
wantsInterrupt := req.DeliveryMode == deliveryModeInterrupt
if wantsInterrupt && !isParentToChild {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeForbidden,
`delivery_mode="interrupt" is only allowed when the sender is the target task's direct parent`, nil)
}
if parentReply != nil {
// Claim the durable question before dispatch. A failed status update after
// delivery would make a retry send the same answer a second time.
if err := h.markParentQuestionAnswered(ctx, parentReply.message, req.Prompt); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "parent question could not be claimed: "+err.Error(), nil)
}
}
result, err := h.dispatchTaskMessage(ctx, req.TaskID, session, wrappedPrompt, senderMeta, wantsInterrupt, req.SessionID != "")
if err != nil {
if parentReply != nil {
if restoreErr := h.restoreParentQuestionPending(ctx, parentReply.message); restoreErr != nil {
h.logger.Error("failed to restore parent question after answer dispatch failure",
zap.String(parentQuestionIDKey, parentReply.message.ID), zap.Error(restoreErr))
}
}
var qfErr *queueFullDispatchError
if errors.As(err, &qfErr) {
return ws.NewError(msg.ID, msg.Action, messagequeue.QueueFullErrorCode,
fmt.Sprintf("target task has %d queued messages (max %d) — retry after the next turn completes", qfErr.queueSize, qfErr.max),
qfErr.toPayload())
}
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
}
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"task_id": req.TaskID,
"session_id": result.sessionID,
stopTaskStatusKey: result.status,
})
}
// lookupSenderSessionName resolves the sender session's user-supplied name for
// badge metadata. Best-effort: returns "" when the session is unknown, unnamed,
// or does not belong to the claimed sender task (so a caller can't stamp an
// arbitrary session's name onto its messages).
func (h *Handlers) lookupSenderSessionName(ctx context.Context, senderTaskID, senderSessionID string) string {
if senderSessionID == "" || h.taskSvc == nil {
return ""
}
session, err := h.taskSvc.GetTaskSession(ctx, senderSessionID)
if err != nil || session == nil || session.TaskID != senderTaskID {
return ""
}
return session.Name
}
// resolveMessageTargetSession picks the session a message_task call targets:
// the explicit session_id (validated to belong to taskID) or the task's
// primary session. Returns a ready-to-send WS error message on failure.
func (h *Handlers) resolveMessageTargetSession(ctx context.Context, msg *ws.Message, taskID, sessionID string) (*models.TaskSession, *ws.Message) {
if sessionID != "" {
return h.resolveExplicitTargetSession(ctx, msg, taskID, sessionID)
}
session, err := h.taskSvc.GetPrimarySession(ctx, taskID)
if err != nil {
if errors.Is(err, taskrepo.ErrNoPrimarySession) {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "target task exists but has no active session")
}
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "failed to get session for task: "+err.Error())
}
return session, nil
}
// resolveExplicitTargetSession loads and validates a caller-named target
// session for message_task. Only genuine no-row lookups are NotFound;
// transient DB errors surface as internal so callers keep retrying instead of
// treating a live session as gone.
func (h *Handlers) resolveExplicitTargetSession(ctx context.Context, msg *ws.Message, taskID, sessionID string) (*models.TaskSession, *ws.Message) {
session, err := h.taskSvc.GetTaskSession(ctx, sessionID)
if err != nil {
if errors.Is(err, models.ErrTaskSessionNotFound) {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "target session not found: "+sessionID)
}
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "failed to look up target session: "+err.Error())
}
if session == nil {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "target session not found: "+sessionID)
}
if session.TaskID != taskID {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id does not belong to task_id")
}
return session, nil
}
// appendPromptReferenceExpansionContext expands "@name" saved-prompt
// references found in prompt via h.promptResolver. The formatting logic
// lives in promptservice.FormatPromptReferenceExpansions (shared with
// promptservice.Service.AppendReferenceExpansions) so this stays a thin
// wiring layer over the resolver interface, which in tests may be a fake
// that only implements ResolvePromptReferences.
func (h *Handlers) appendPromptReferenceExpansionContext(ctx context.Context, prompt string) string {
if h.promptResolver == nil {
return prompt
}
if !strings.Contains(prompt, "@") {
return prompt
}
expansions, err := h.promptResolver.ResolvePromptReferences(ctx, prompt)
if err != nil {
h.logger.Warn("failed to resolve prompt references for message_task", zap.Error(err))
return prompt
}
if len(expansions) == 0 {
return prompt
}
return prompt + "\n\n" + sysprompt.Wrap(formatPromptReferenceExpansions(expansions))
}
// formatPromptReferenceExpansions delegates to the shared formatter in
// prompts/service so the rendered block stays byte-identical across callers.
func formatPromptReferenceExpansions(expansions []promptservice.PromptReferenceExpansion) string {
return promptservice.FormatPromptReferenceExpansions(expansions)
}
// handleGetTaskConversation returns paginated conversation history for a task.
// If session_id is omitted, it uses the task's primary session and falls back
// to the latest session when no primary session exists.
func (h *Handlers) handleGetTaskConversation(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
req, errResp := parseTaskConversationRequest(msg)
if errResp != nil {
return errResp, nil
}
session, errResp := h.resolveConversationSession(ctx, msg, req.TaskID, req.SessionID)
if errResp != nil {
return errResp, nil
}
messages, hasMore, err := h.taskSvc.ListMessagesPaginated(ctx, service.ListMessagesRequest{
TaskSessionID: session.ID,
Limit: conversationLimit(req.Limit),
Before: req.Before,
After: req.After,
Sort: req.Sort,
})
if err != nil {
h.logger.Error("failed to list task conversation", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list task conversation", nil)
}
result := filterAndConvertMessages(messages, req.Types)
cursor := conversationCursor(messages)
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"task_id": req.TaskID,
"session_id": session.ID,
"messages": result,
"total": len(result),
"has_more": hasMore,
"cursor": cursor,
})
}
type taskConversationRequest struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
Limit int `json:"limit"`
Before string `json:"before"`
After string `json:"after"`
Sort string `json:"sort"`
Types []string `json:"message_types"`
}
func parseTaskConversationRequest(msg *ws.Message) (*taskConversationRequest, *ws.Message) {
var req taskConversationRequest
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error())
}
if req.TaskID == "" {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required")
}
if req.Before != "" && req.After != "" {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeValidation, "only one of before or after can be set")
}
if req.Sort != "" && req.Sort != "asc" && req.Sort != "desc" {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeValidation, "sort must be asc or desc")
}
if req.Limit < 0 {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeValidation, "limit must be non-negative")
}
return &req, nil
}
func (h *Handlers) resolveConversationSession(ctx context.Context, msg *ws.Message, taskID, sessionID string) (*models.TaskSession, *ws.Message) {
if sessionID != "" {
session, err := h.taskSvc.GetTaskSession(ctx, sessionID)
if err != nil || session == nil {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "session not found")
}
if session.TaskID != taskID {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id does not belong to task_id")
}
return session, nil
}
session, err := h.taskSvc.GetPrimarySession(ctx, taskID)
if err == nil && session != nil {
return session, nil
}
if err != nil && !errors.Is(err, taskrepo.ErrNoPrimarySession) {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "failed to get task session")
}
sessions, listErr := h.taskSvc.ListTaskSessions(ctx, taskID)
if listErr != nil {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "failed to list task sessions")
}
if len(sessions) == 0 {
return nil, wsError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "task has no session")
}
return sessions[0], nil
}
func wsError(id, action, code, message string) *ws.Message {
resp, _ := ws.NewError(id, action, code, message, nil)
return resp
}
func filterAndConvertMessages(messages []*models.Message, types []string) []*v1.Message {
filterTypes := make(map[string]struct{}, len(types))
for _, mt := range types {
if mt == "" {
continue
}
filterTypes[mt] = struct{}{}
}
result := make([]*v1.Message, 0, len(messages))
for _, message := range messages {
if len(filterTypes) > 0 {
if _, ok := filterTypes[string(message.Type)]; !ok {
continue
}
}
result = append(result, message.ToAPI())
}
return result
}
func conversationLimit(requested int) int {
if requested > 0 {
return requested
}
return service.DefaultMessagesPageSize
}
func conversationCursor(messages []*models.Message) string {
if len(messages) == 0 {
return ""
}
return messages[len(messages)-1].ID
}
// queueFullDispatchError is returned by dispatchTaskMessage when an inter-task
// message can't be queued because the target session's queue is full. It
// carries enough metadata for handleMessageTask to surface a structured
// "queue_full" error to the calling agent so its LLM can decide whether to
// retry, abort, or message a different task.
type queueFullDispatchError struct {
sessionID string
queueSize int
max int
entries []messagequeue.QueuedMessage
}
func (e *queueFullDispatchError) Error() string {
return fmt.Sprintf("queue full: %d/%d messages pending for session %s", e.queueSize, e.max, e.sessionID)
}
// toPayload builds the structured "data" body for the MCP error response.
// Sender / queued_at fields surface enough context for the LLM to reason about
// the wedge state without leaking the queued message contents.
func (e *queueFullDispatchError) toPayload() map[string]interface{} {
queued := make([]map[string]interface{}, 0, len(e.entries))
for _, entry := range e.entries {
queued = append(queued, map[string]interface{}{
"id": entry.ID,
"sender": entry.QueuedBy,
"queued_at": entry.QueuedAt,
})
}
// The WS error envelope already carries the code; we duplicate it here so
// callers reading the structured details body still see it without parsing
// the envelope. Tests assert on details.error directly.
return map[string]interface{}{
errorField: messagequeue.QueueFullErrorCode,
"queue_size": e.queueSize,
"max": e.max,
"retry_after": "next_turn",
"queued_messages": queued,
}
}
// errorField names the well-known structured details key used to surface error
// codes in MCP tool responses (extracted to satisfy goconst's repeated-string rule).
const errorField = "error"
// MCP payload / response keys reused across multiple handlers. Extracted so
// goconst doesn't flag the literals as repeated, and so a future rename of
// a wire-protocol key updates every handler in one place.
const (
keyTaskID = "task_id"
keyTotal = "total"
keyRepositoryID = "repository_id"
keyTaskRepositoryID = "task_repository_id"
keyBaseBranch = "base_branch"
keyCheckoutBranch = "checkout_branch"
keyPosition = "position"
)
// taskMessageStatusSent is the taskMessageDispatchResult.status value used
// when a message_task_kandev prompt was dispatched immediately rather than
// left in the FIFO queue for later delivery — either because the target
// session was idle (promptWithAutoResume) or because a parent's interrupt
// just dispatched it (queueThenInterruptTaskMessage). Extracted to satisfy
// goconst's repeated-string rule; see dispatchTaskMessage's doc comment for
// the full status enum ("queued", "sent", "started").
const taskMessageStatusSent = "sent"
// taskMessageStatusQueued is the taskMessageDispatchResult.status value
// used when a message_task_kandev prompt is left in the FIFO queue for
// later delivery rather than dispatched immediately. Extracted alongside
// taskMessageStatusSent to satisfy goconst's repeated-string rule.
const taskMessageStatusQueued = "queued"
// deliveryModeQueued and deliveryModeInterrupt are the two accepted values
// for message_task_kandev's delivery_mode request parameter — distinct
// from taskMessageStatusQueued/taskMessageStatusSent above, which describe
// the *response* status enum instead. "queued" happens to be a shared
// literal between the two concepts (a delivery_mode request value and a
// dispatch-result status value), but they are different fields on
// different structs (request vs. response) and are kept as separate
// constants so a change to one enum's spelling can't silently drift the
// other.
const (
deliveryModeQueued = "queued"
deliveryModeInterrupt = "interrupt"
)
// queuedEntryID is set only by queueTaskMessage (the message queue's
// returned entry id) so queueThenInterruptTaskMessage can target that exact
// entry when interrupting instead of the FIFO head — see
// InterruptForPeerMessage's doc comment. Never serialized to the wire; the
// MCP response only reads status/sessionID (see handleMessageTask).
type taskMessageDispatchResult struct {
status string
sessionID string
queuedEntryID string
}
type taskMessageReviewRollback struct {
changed bool
restoreTask bool
taskState v1.TaskState
workflowStepID string
sessions []taskMessageSessionRollback
sessionIDs map[string]struct{}
selectedID string
queues map[string]taskMessageQueueRollback
}
type taskMessageSessionRollback struct {
sessionID string
state models.TaskSessionState
error string
completedAt *time.Time
isPrimary bool
agentProfileID string
executorProfileID string
agentProfileSnapshot map[string]interface{}
metadata map[string]interface{}
}
type taskMessageQueueRollback struct {
entries []messagequeue.QueuedMessage
hadPendingMove bool
pendingMove *messagequeue.PendingMove
}
type taskMessageSessionRollbackRepository interface {
GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error)
ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error)
UpdateTaskSessionIfCurrentState(
ctx context.Context,
session *models.TaskSession,
expected models.TaskSessionState,
) (bool, error)
SetSessionPrimary(ctx context.Context, sessionID string) error
DeleteTaskSession(ctx context.Context, id string) error
UpdateSessionMetadata(ctx context.Context, sessionID string, metadata map[string]interface{}) error
}
var errTaskMessageRollbackSuperseded = errors.New("task message rollback superseded by coordinator cancellation")
type taskMessageExecutorReader interface {
GetExecutorRunningBySessionID(ctx context.Context, sessionID string) (*models.ExecutorRunning, error)
}
func (r *taskMessageReviewRollback) captureSessions(ctx context.Context, repo SessionRepository, taskID string, fallback *models.TaskSession) error {
if !r.changed || fallback == nil {
return nil
}
sessions := []*models.TaskSession{fallback}
if snapshotRepo, ok := repo.(interface {
ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error)
}); ok {
listed, err := snapshotRepo.ListTaskSessions(ctx, taskID)
if err != nil {
return err
}
sessions = listed
}
r.sessionIDs = make(map[string]struct{}, len(sessions))
r.sessions = make([]taskMessageSessionRollback, 0, len(sessions))
for _, session := range sessions {
if session == nil {
continue
}
r.sessionIDs[session.ID] = struct{}{}
r.sessions = append(r.sessions, captureTaskMessageSession(session))
}
return nil
}
func (r *taskMessageReviewRollback) captureQueues(ctx context.Context, queue *messagequeue.Service) error {
if !r.changed || queue == nil || len(r.sessions) == 0 {
return nil
}
queues := make(map[string]taskMessageQueueRollback, len(r.sessions))
for _, session := range r.sessions {
entries, move, err := queue.SnapshotSession(ctx, session.sessionID)
if err != nil {
return err
}
snapshot := taskMessageQueueRollback{
entries: cloneTaskMessageQueuedMessages(entries),
}
if move != nil {
snapshot.hadPendingMove = true
snapshot.pendingMove = cloneTaskMessagePendingMove(move)
}
queues[session.sessionID] = snapshot
}
r.queues = queues
return nil
}
func (r *taskMessageReviewRollback) captureSelectedSession(session *models.TaskSession) {
if !r.changed || session == nil {
return
}
r.selectedID = session.ID
}
func captureTaskMessageSession(session *models.TaskSession) taskMessageSessionRollback {
var completedAt *time.Time
if session.CompletedAt != nil {
copy := *session.CompletedAt
completedAt = &copy
}
snapshot := taskMessageSessionRollback{
sessionID: session.ID,
state: session.State,
error: session.ErrorMessage,
completedAt: completedAt,
isPrimary: session.IsPrimary,
agentProfileID: session.AgentProfileID,
executorProfileID: session.ExecutorProfileID,
agentProfileSnapshot: cloneTaskMessageMetadataMap(session.AgentProfileSnapshot),
}
if session.Metadata != nil {
snapshot.metadata = cloneTaskMessageMetadataMap(session.Metadata)
}
return snapshot
}
func cloneTaskMessageMetadataMap(metadata map[string]interface{}) map[string]interface{} {
if metadata == nil {
return nil
}
cloned := make(map[string]interface{}, len(metadata))
for key, value := range metadata {
cloned[key] = cloneTaskMessageMetadataValue(value)
}
return cloned
}
func cloneTaskMessageMetadataValue(value interface{}) interface{} {
data, err := json.Marshal(value)
if err != nil {
return value
}
var cloned interface{}
if err := json.Unmarshal(data, &cloned); err != nil {
return value
}
return cloned
}
func cloneTaskMessageQueuedMessages(entries []messagequeue.QueuedMessage) []messagequeue.QueuedMessage {
if len(entries) == 0 {
return nil
}
cloned := make([]messagequeue.QueuedMessage, 0, len(entries))
for _, entry := range entries {
copy := entry
copy.Metadata = cloneTaskMessageMetadataMap(entry.Metadata)
copy.Attachments = append([]messagequeue.MessageAttachment(nil), entry.Attachments...)
cloned = append(cloned, copy)
}
return cloned
}
func cloneTaskMessagePendingMove(move *messagequeue.PendingMove) *messagequeue.PendingMove {
if move == nil {
return nil
}
copy := *move
return &copy
}
// dispatchTaskMessage routes a message to the right delivery path based on session state.
// Returns the action taken: "queued", "sent", or "started".
//
// metadata is the Message.Metadata map to attach to the resulting user message
// row (sender_task_id, sender_task_title, sender_session_id when called from
// handleMessageTask). It is propagated to all three delivery paths so the
// receiving task's chat displays the sender badge consistently.
//
// pinnedTarget marks a session the caller named explicitly (message_task's
// session_id param). Idle-path dispatch must then stay on THAT session:
// resolveSessionAfterTaskMessageTurnStart's primary-switch preference would
// otherwise reroute the message to the task's primary session — which, for a
// sibling-session message, is typically the sender itself.
func (h *Handlers) dispatchTaskMessage(ctx context.Context, taskID string, session *models.TaskSession, prompt string, metadata map[string]interface{}, interruptIfBusy, pinnedTarget bool) (taskMessageDispatchResult, error) {
if h.sessionLauncher == nil {
return taskMessageDispatchResult{}, errors.New("orchestrator not available")
}
switch session.State {
case models.TaskSessionStateFailed, models.TaskSessionStateCancelled:
return taskMessageDispatchResult{}, fmt.Errorf("session is %s — cannot send message", session.State)
case models.TaskSessionStateRunning, models.TaskSessionStateStarting:
if interruptIfBusy {
return h.queueThenInterruptTaskMessage(ctx, taskID, session, prompt, metadata)
}
return h.queueTaskMessage(ctx, taskID, session, prompt, metadata)
default:
reviewRollback, err := h.ensureTaskInProgressForTaskMessage(ctx, taskID)
if err != nil {
return taskMessageDispatchResult{}, err
}
if err := reviewRollback.captureSessions(ctx, h.sessionRepo, taskID, session); err != nil {
h.restoreTaskReviewForTaskMessage(ctx, taskID, reviewRollback)
return taskMessageDispatchResult{}, err
}
if err := reviewRollback.captureQueues(ctx, h.sessionLauncher.GetMessageQueue()); err != nil {
h.restoreTaskReviewForTaskMessage(ctx, taskID, reviewRollback)
return taskMessageDispatchResult{}, err
}
session, err := h.prepareSessionForTaskMessage(ctx, taskID, session, pinnedTarget)
if err != nil {
h.restoreTaskReviewForTaskMessage(ctx, taskID, reviewRollback)
return taskMessageDispatchResult{}, err
}
reviewRollback.captureSelectedSession(session)
result, err := h.dispatchPreparedTaskMessage(ctx, taskID, session, prompt, metadata)
if err != nil {
h.restoreTaskReviewForTaskMessage(ctx, taskID, reviewRollback)
}
return result, err
}
}
func (h *Handlers) dispatchPreparedTaskMessage(ctx context.Context, taskID string, session *models.TaskSession, prompt string, metadata map[string]interface{}) (taskMessageDispatchResult, error) {
switch session.State {
case models.TaskSessionStateFailed, models.TaskSessionStateCancelled:
return taskMessageDispatchResult{}, fmt.Errorf("session is %s — cannot send message", session.State)
case models.TaskSessionStateRunning, models.TaskSessionStateStarting:
return h.queueTaskMessage(ctx, taskID, session, prompt, metadata)
default:
if h.shouldStartTaskMessageSession(ctx, session) {
// Record before starting so the message is tied to the turn produced
// by launch. If launch fails, delete the row below.
recorded := h.recordUserMessage(ctx, taskID, session.ID, prompt, metadata)
if _, err := h.sessionLauncher.StartCreatedSession(ctx, taskID, session.ID, session.AgentProfileID, prompt, true, false, true, nil, nil); err != nil {
h.deleteRecordedUserMessage(ctx, recorded)
return taskMessageDispatchResult{}, fmt.Errorf("failed to start session: %w", err)
}
return taskMessageDispatchResult{status: "started", sessionID: session.ID}, nil
}
// Record before prompting so the message is tied to the turn that
// PromptTask dispatches. If dispatch fails, delete the row below so a
// REVIEW rollback does not keep a prompt the agent never saw.
recorded := h.recordUserMessage(ctx, taskID, session.ID, prompt, metadata)
status, err := h.promptWithAutoResume(ctx, taskID, session.ID, prompt)
if err != nil {
h.deleteRecordedUserMessage(ctx, recorded)
return taskMessageDispatchResult{}, err
}
return taskMessageDispatchResult{status: status, sessionID: session.ID}, nil
}
}
func (h *Handlers) shouldStartTaskMessageSession(ctx context.Context, session *models.TaskSession) bool {
if session.State == models.TaskSessionStateCreated {
return true
}
// on_turn_start may mark a never-launched CREATED session as
// WAITING_FOR_INPUT before an executor row exists. It may also switch to a
// fresh waiting primary session. In both cases the first message still needs
// the launch path; already-bound waiting sessions use prompt/resume.
if session.State != models.TaskSessionStateWaitingForInput {
return false
}
if session.AgentExecutionID == "" {
return true
}
reader, ok := h.sessionRepo.(taskMessageExecutorReader)
if !ok {
return false
}
running, err := reader.GetExecutorRunningBySessionID(ctx, session.ID)
return err == nil && running != nil && running.Status == models.ExecutorRunningStatusPrepared
}
// queueTaskMessage appends prompt to the target session's FIFO message
// queue for delivery on its next turn boundary (normal turn completion, or
// an explicit interrupt — see queueThenInterruptTaskMessage). Returns the
// queued entry's id via taskMessageDispatchResult.queuedEntryID so callers
// that need to target this exact entry (rather than the FIFO head) can do
// so later.
func (h *Handlers) queueTaskMessage(ctx context.Context, taskID string, session *models.TaskSession, prompt string, metadata map[string]interface{}) (taskMessageDispatchResult, error) {
queue := h.sessionLauncher.GetMessageQueue()
if queue == nil {
return taskMessageDispatchResult{}, errors.New("message queue not available")
}
queued, err := queue.QueueMessageWithMetadata(ctx, session.ID, taskID, prompt, "", messagequeue.QueuedByAgent, false, nil, metadata)
if err != nil {
if errors.Is(err, messagequeue.ErrQueueFull) {
status := queue.GetStatus(ctx, session.ID)
return taskMessageDispatchResult{}, &queueFullDispatchError{
sessionID: session.ID,
queueSize: status.Count,
max: status.Max,
entries: status.Entries,
}
}
return taskMessageDispatchResult{}, fmt.Errorf("failed to queue message: %w", err)
}
h.publishQueueStatusEvent(ctx, session.ID, queue)
return taskMessageDispatchResult{status: taskMessageStatusQueued, sessionID: session.ID, queuedEntryID: queued.ID}, nil
}
// queueThenInterruptTaskMessage atomically queues prompt for a running/
// starting session and interrupts its current turn so the message is
// delivered right away instead of waiting for the turn to end naturally.
// Used only when the sender is the target's parent task (see
// handleMessageTask); regular peer messages keep the default queue-and-wait
// behavior documented on message_task_kandev via queueTaskMessage.
//
// "Queue" and "interrupt" must be one atomic orchestrator call
// (QueueAndInterruptForPeerMessage), not queueTaskMessage followed by a
// separate interrupt call: between an insert becoming visible and a later,
// separate interrupt call claiming the session's cancel lock, the child's
// turn could complete naturally and the orchestrator's normal FIFO drain
// could grab the just-queued entry and start dispatching it as an ordinary
// turn — only for the later interrupt's cancel to land on and kill that
// very turn, orphaning the parent's message mid-delivery. See
// QueueAndInterruptForPeerMessage's doc comment for the full race this
// closes.
//
// The returned status reflects what actually happened: "sent" only when
// the interrupt actually dispatched the message immediately (the returned
// bool), or "queued" when the cancel-and-take step ran but genuinely
// failed to dispatch anything (see cancelAndTakeForPeerMessage's doc
// comment for that case — it does not include lock contention, since
// QueueAndInterruptForPeerMessage always waits for the lock rather than
// skipping a busy one).
// A failure past the queue insert is deliberately NOT surfaced as an error to the caller —
// the message is already safely persisted and will still be delivered by
// the normal turn-completion drain, so the interrupt is purely a latency
// optimization on top of that always-safe default. The calling agent has
// no useful recovery action on a hard error here besides retrying
// message_task_kandev, and retrying would enqueue a second copy of the
// same message since queuing is not idempotent — reporting the accurate
// "queued" status avoids inviting that duplicate. The failure is still
// logged server-side for operators.
func (h *Handlers) queueThenInterruptTaskMessage(ctx context.Context, taskID string, session *models.TaskSession, prompt string, metadata map[string]interface{}) (taskMessageDispatchResult, error) {
queued, dispatched, err := h.sessionLauncher.QueueAndInterruptForPeerMessage(ctx, taskID, session.ID, prompt, metadata)
if err != nil {
if errors.Is(err, messagequeue.ErrQueueFull) {
queue := h.sessionLauncher.GetMessageQueue()
status := queue.GetStatus(ctx, session.ID)
return taskMessageDispatchResult{}, &queueFullDispatchError{
sessionID: session.ID,
queueSize: status.Count,
max: status.Max,
entries: status.Entries,
}
}
if queued == nil {
return taskMessageDispatchResult{}, fmt.Errorf("failed to queue and interrupt message: %w", err)
}
// The message is already safely queued (queued != nil); only the
// interrupt step failed — see the doc comment above for why that
// is not surfaced as an error to the caller.
h.logger.Warn("failed to interrupt child session's turn; message stays queued for normal drain",
zap.String("task_id", taskID),
zap.String("session_id", session.ID),
zap.Error(err))
return taskMessageDispatchResult{status: taskMessageStatusQueued, sessionID: session.ID, queuedEntryID: queued.ID}, nil
}
result := taskMessageDispatchResult{status: taskMessageStatusQueued, sessionID: session.ID, queuedEntryID: queued.ID}
if dispatched {
result.status = taskMessageStatusSent
}
return result, nil
}
func (h *Handlers) prepareSessionForTaskMessage(ctx context.Context, taskID string, session *models.TaskSession, pinnedTarget bool) (*models.TaskSession, error) {
if err := h.sessionLauncher.ProcessOnTurnStart(ctx, taskID, session.ID); err != nil {
return nil, fmt.Errorf("failed to process on_turn_start for task message: %w", err)
}
if pinnedTarget {
// The caller addressed this exact session — never reroute to the
// task's primary session, just pick up any state change from
// on_turn_start.
reloaded, err := h.taskSvc.GetTaskSession(ctx, session.ID)
if err != nil {
return nil, fmt.Errorf("failed to reload pinned target session after on_turn_start: %w", err)
}
return reloaded, nil
}
resolved, err := h.resolveSessionAfterTaskMessageTurnStart(ctx, taskID, session)
if err != nil {
return nil, err
}
return resolved, nil
}
func (h *Handlers) ensureTaskInProgressForTaskMessage(ctx context.Context, taskID string) (taskMessageReviewRollback, error) {
if h.taskSvc == nil {
return taskMessageReviewRollback{}, errors.New("task service not available")
}
task, err := h.taskSvc.GetTask(ctx, taskID)
if err != nil {
return taskMessageReviewRollback{}, err
}
rollback := taskMessageReviewRollback{
changed: true,
restoreTask: true,
taskState: task.State,
workflowStepID: task.WorkflowStepID,
}
if task.State != v1.TaskStateReview {
return rollback, nil
}
if task.IsFromOffice {
return rollback, nil
}
if _, err := h.taskSvc.UpdateTaskState(ctx, taskID, v1.TaskStateInProgress); err != nil {
return taskMessageReviewRollback{}, fmt.Errorf("failed to transition task from REVIEW to IN_PROGRESS for task message: %w", err)
}
h.logger.Info("task transitioned from REVIEW to IN_PROGRESS for task message",
zap.String("task_id", taskID))
return rollback, nil
}
func (h *Handlers) restoreTaskReviewForTaskMessage(ctx context.Context, taskID string, rollback taskMessageReviewRollback) {
if !rollback.changed {
return
}
if err := h.restoreTaskMessageSessions(ctx, rollback); err != nil {
h.logger.Warn("failed to restore task session after task message dispatch failure",
zap.String("task_id", taskID),
zap.Error(err))
// A coordinator cancellation owns the terminal session/task state and
// intentionally preserves the current queue. Never continue into task
// or queue snapshot restoration after that ownership is observed.
return
}
if rollback.restoreTask {
ownerID, ownerState, ok := rollback.taskRestoreOwner()
if !ok {
h.logger.Warn("skipping task message rollback without an owning session",
zap.String("task_id", taskID))
return
}
taskState := rollback.taskState
if taskState == "" {
taskState = v1.TaskStateReview
}
_, restored, err := h.taskSvc.RestoreTaskMessageRollback(
ctx,
taskID,
ownerID,
ownerState,
taskState,
rollback.workflowStepID,
)
if err != nil {
h.logger.Warn("failed to restore task after task message dispatch failure",
zap.String("task_id", taskID),
zap.Error(err))
return
}
if !restored {
h.logger.Info("skipping task and queue rollback because session ownership changed",
zap.String("task_id", taskID),
zap.String("session_id", ownerID))
return
}
}
if err := h.restoreTaskMessageQueues(ctx, rollback); err != nil {
h.logger.Warn("failed to restore task message queue after dispatch failure",
zap.String("task_id", taskID),
zap.Error(err))
}
}
func (r taskMessageReviewRollback) taskRestoreOwner() (string, models.TaskSessionState, bool) {
primaryID := r.primarySessionID()
for _, snapshot := range r.sessions {
if snapshot.sessionID == primaryID {
return snapshot.sessionID, snapshot.state, true
}
}
if len(r.sessions) == 0 {
return "", "", false
}
return r.sessions[0].sessionID, r.sessions[0].state, true
}
func (h *Handlers) restoreTaskMessageSessions(ctx context.Context, rollback taskMessageReviewRollback) error {
if len(rollback.sessions) == 0 {
return nil
}
repo, ok := h.sessionRepo.(taskMessageSessionRollbackRepository)
if !ok {
return errors.New("session rollback repository not available")
}
for _, snapshot := range rollback.sessions {
if err := restoreTaskMessageSessionSnapshot(ctx, repo, snapshot); err != nil {
return err
}
}
return h.restoreSelectedTaskMessageSession(ctx, repo, rollback)
}
func (h *Handlers) restoreSelectedTaskMessageSession(ctx context.Context, repo taskMessageSessionRollbackRepository, rollback taskMessageReviewRollback) error {
if rollback.selectedID == "" {
return nil
}
primaryID := rollback.primarySessionID()
if _, ok := rollback.sessionIDs[rollback.selectedID]; ok {
return nil
}
selected, err := repo.GetTaskSession(ctx, rollback.selectedID)
if err != nil {
return err
}
if selected != nil && selected.State == models.TaskSessionStateCancelled {
return errTaskMessageRollbackSuperseded
}
if primaryID != "" && rollback.selectedID != primaryID {
if err := h.restoreTaskMessageQueueOwner(ctx, rollback.selectedID, primaryID); err != nil {
return err
}
}
return repo.DeleteTaskSession(ctx, rollback.selectedID)
}
func (r taskMessageReviewRollback) primarySessionID() string {
for _, session := range r.sessions {
if session.isPrimary {
return session.sessionID
}
}
return ""
}
func (h *Handlers) restoreTaskMessageQueues(ctx context.Context, rollback taskMessageReviewRollback) error {
if len(rollback.queues) == 0 {
return nil
}
queue := h.sessionLauncher.GetMessageQueue()
if queue == nil {
return nil
}
for sessionID, snapshot := range rollback.queues {
if err := h.restoreTaskMessageQueue(ctx, queue, sessionID, snapshot); err != nil {
return err
}
}
return nil
}
func (h *Handlers) restoreTaskMessageQueue(ctx context.Context, queue *messagequeue.Service, sessionID string, snapshot taskMessageQueueRollback) error {
var pendingMove *messagequeue.PendingMove
if snapshot.hadPendingMove {
pendingMove = cloneTaskMessagePendingMove(snapshot.pendingMove)
}
return queue.RestoreSession(ctx, sessionID, cloneTaskMessageQueuedMessages(snapshot.entries), pendingMove)
}
func (h *Handlers) restoreTaskMessageQueueOwner(ctx context.Context, selectedID, primaryID string) error {
queue := h.sessionLauncher.GetMessageQueue()
if queue == nil {
return nil
}
return queue.TransferSession(ctx, selectedID, primaryID)
}
func restoreTaskMessageSessionSnapshot(ctx context.Context, repo taskMessageSessionRollbackRepository, rollback taskMessageSessionRollback) error {
session, err := repo.GetTaskSession(ctx, rollback.sessionID)
if err != nil {
return err
}
if session == nil {
return fmt.Errorf("task message rollback session %q is nil", rollback.sessionID)
}
if session.State == models.TaskSessionStateCancelled {
return errTaskMessageRollbackSuperseded
}
expectedState := session.State
session.State = rollback.state
session.ErrorMessage = rollback.error
session.CompletedAt = rollback.completedAt
session.IsPrimary = rollback.isPrimary
session.AgentProfileID = rollback.agentProfileID
session.ExecutorProfileID = rollback.executorProfileID
session.AgentProfileSnapshot = cloneTaskMessageMetadataMap(rollback.agentProfileSnapshot)
changed, err := repo.UpdateTaskSessionIfCurrentState(ctx, session, expectedState)
if err != nil {
return err
}
if !changed {
latest, loadErr := repo.GetTaskSession(ctx, rollback.sessionID)
if loadErr != nil {
return loadErr
}
if latest != nil && latest.State == models.TaskSessionStateCancelled {
return errTaskMessageRollbackSuperseded
}
return fmt.Errorf("task message rollback lost session %q state ownership", rollback.sessionID)
}
if rollback.isPrimary {
if err := repo.SetSessionPrimary(ctx, rollback.sessionID); err != nil {
return err
}
}
if err := repo.UpdateSessionMetadata(ctx, rollback.sessionID, cloneTaskMessageMetadataMap(rollback.metadata)); err != nil {
return err
}
return nil
}
func (h *Handlers) resolveSessionAfterTaskMessageTurnStart(ctx context.Context, taskID string, submitted *models.TaskSession) (*models.TaskSession, error) {
if h.taskSvc == nil {
return nil, errors.New("task service not available")
}
reloaded, err := h.taskSvc.GetTaskSession(ctx, submitted.ID)
if err != nil {
return nil, fmt.Errorf("failed to reload session after on_turn_start: %w", err)
}
primary, err := h.taskSvc.GetPrimarySession(ctx, taskID)
if err != nil {
return nil, fmt.Errorf("failed to resolve primary session after on_turn_start: %w", err)
}
if primary != nil && primary.ID != submitted.ID {
return primary, nil
}
if reloaded.State != models.TaskSessionStateCompleted {
return reloaded, nil
}
if primary == nil {
return nil, errors.New("session was switched but no active session found")
}
if primary.ID != submitted.ID {
return primary, nil
}
if submitted.State == models.TaskSessionStateCompleted {
return reloaded, nil
}
return nil, errors.New("session was marked completed by on_turn_start but primary was not switched")
}
// recordUserMessage writes the prompt to the task's chat as a user message so it
// is visible in the conversation. Mirrors the message.add path used by the UI.
// metadata is attached to the resulting Message row (used for sender_task_id /
// sender_task_title / sender_session_id when called from handleMessageTask).
func (h *Handlers) recordUserMessage(ctx context.Context, taskID, sessionID, prompt string, metadata map[string]interface{}) *models.Message {
if h.taskSvc == nil {
return nil
}
message, err := h.taskSvc.CreateMessage(ctx, &service.CreateMessageRequest{
TaskSessionID: sessionID,
TaskID: taskID,
Content: prompt,
AuthorType: "user",
Metadata: metadata,
})
if err != nil {
h.logger.Warn("failed to record user message for message_task",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.Error(err))
return nil
}
return message
}
func (h *Handlers) deleteRecordedUserMessage(ctx context.Context, message *models.Message) {
if h.taskSvc == nil || message == nil {
return
}
if err := h.taskSvc.DeleteMessage(ctx, message.ID); err != nil {
h.logger.Warn("failed to delete rejected user message for message_task",
zap.String("message_id", message.ID),
zap.String("task_id", message.TaskID),
zap.String("session_id", message.TaskSessionID),
zap.Error(err))
}
if err := h.taskSvc.AbandonOpenTurns(ctx, message.TaskSessionID); err != nil {
h.logger.Warn("failed to abandon rejected user message turn for message_task",
zap.String("message_id", message.ID),
zap.String("task_id", message.TaskID),
zap.String("session_id", message.TaskSessionID),
zap.Error(err))
}
}
// promptWithAutoResume sends a prompt to a session and resumes the agent
// transparently if it has been torn down (mirrors message.add behaviour).
// Uses dispatch-only mode so the MCP tool returns once the prompt is accepted
// rather than blocking for the entire target turn.
func (h *Handlers) promptWithAutoResume(ctx context.Context, taskID, sessionID, prompt string) (string, error) {
_, err := h.sessionLauncher.PromptTask(ctx, taskID, sessionID, prompt, "", false, nil, true)
if err == nil {
return taskMessageStatusSent, nil
}
if !errors.Is(err, executor.ErrExecutionNotFound) {
return "", fmt.Errorf("failed to send prompt: %w", err)
}
if _, resumeErr := h.sessionLauncher.ResumeTaskSession(ctx, taskID, sessionID); resumeErr != nil {
return "", fmt.Errorf("failed to resume session: %w", resumeErr)
}
// ResumeTaskSession starts the agent asynchronously. Poll until the session
// is ready to accept prompts so the retry doesn't race the agent boot.
if waitErr := h.taskSvc.WaitForSessionReady(ctx, sessionID); waitErr != nil {
return "", fmt.Errorf("session not ready after resume: %w", waitErr)
}
if _, retryErr := h.sessionLauncher.PromptTask(ctx, taskID, sessionID, prompt, "", false, nil, true); retryErr != nil {
return "", fmt.Errorf("failed to send prompt after resume: %w", retryErr)
}
return taskMessageStatusSent, nil
}
// publishQueueStatusEvent fires a queue.status_changed event so the frontend
// can update the queue indicator.
func (h *Handlers) publishQueueStatusEvent(ctx context.Context, sessionID string, queue *messagequeue.Service) {
if h.eventBus == nil {
return
}
status := queue.GetStatus(ctx, sessionID)
_ = h.eventBus.Publish(ctx, events.MessageQueueStatusChanged, bus.NewEvent(
events.MessageQueueStatusChanged,
"mcp-handlers",
map[string]interface{}{
"session_id": sessionID,
"entries": status.Entries,
"count": status.Count,
"max": status.Max,
},
))
}
// handleAskUserQuestion creates a clarification request and blocks until the user responds.
// The agent's MCP tool call stays open (same turn) while waiting. If the agent times out,
// the event-based fallback in the orchestrator handles resuming with a new turn.
func (h *Handlers) handleAskUserQuestion(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
SessionID string `json:"session_id"`
TaskID string `json:"task_id"`
Questions []clarification.Question `json:"questions"`
Context string `json:"context"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.SessionID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
}
// Single source of truth — same validator the HTTP handler uses, so
// duplicate IDs / bad option counts / empty prompts can't slip through
// either path.
if errMsg := clarification.NormalizeAndValidateQuestions(req.Questions); errMsg != "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, errMsg, nil)
}
// Look up task ID from session if not provided
taskID := req.TaskID
if taskID == "" {
session, err := h.sessionRepo.GetTaskSession(ctx, req.SessionID)
if err != nil {
h.logger.Warn("failed to look up task for session",
zap.String("session_id", req.SessionID),
zap.Error(err))
} else if session != nil {
taskID = session.TaskID
}
}
// Create the clarification request
clarificationReq := &clarification.Request{
SessionID: req.SessionID,
TaskID: taskID,
Questions: req.Questions,
Context: req.Context,
}
pendingID, isNew := h.clarificationSvc.CreateRequest(clarificationReq)
// Create one chat message per question (triggers WS events to frontend).
// If the create fails, the in-store pending entry must be cancelled too —
// otherwise the agent's WaitForResponse would block for the full 2-hour
// timeout while the user never sees clarification cards.
// When dedup fires (isNew=false) the messages already exist, so skip creation.
if isNew && h.messageCreator != nil {
if _, err := h.messageCreator.CreateClarificationRequestMessages(
ctx, taskID, req.SessionID, pendingID, req.Questions, req.Context,
); err != nil {
h.logger.Error("failed to create clarification request messages",
zap.String("pending_id", pendingID),
zap.String("session_id", req.SessionID),
zap.Error(err))
h.clarificationSvc.CancelRequest(pendingID)
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError,
"failed to create clarification messages: "+err.Error(), nil)
}
}
// Update session and task states to waiting for input
h.setSessionWaitingForInput(ctx, taskID, req.SessionID)
h.logger.Info("clarification request created, waiting for user response",
zap.String("pending_id", pendingID),
zap.String("session_id", req.SessionID),
zap.String("task_id", taskID))
// Block until user responds or context is cancelled (agent MCP timeout).
// With MCP_TIMEOUT set to 2h for Claude Code, this will wait long enough.
// If the agent times out, the entry is cleaned up and the event-based
// fallback in the orchestrator handles resuming with a new turn.
resp, err := h.clarificationSvc.WaitForResponse(ctx, pendingID)
if err != nil {
if h.inputPauser != nil {
if _, pauseErr := h.inputPauser.PauseForClarificationInput(context.WithoutCancel(ctx), req.SessionID); pauseErr != nil {
h.logger.Warn("failed to pause session after clarification ended without answer",
zap.String("pending_id", pendingID),
zap.String("session_id", req.SessionID),
zap.Error(pauseErr))
}
}
h.logger.Warn("clarification wait ended without response",
zap.String("pending_id", pendingID),
zap.String("session_id", req.SessionID),
zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError,
"Clarification request timed out or was cancelled", nil)
}
// User responded — set session back to running
h.setSessionRunning(ctx, taskID, req.SessionID)
h.logger.Info("clarification answered, returning to agent",
zap.String("pending_id", pendingID),
zap.String("session_id", req.SessionID),
zap.Bool("rejected", resp.Rejected))
// Return response in format expected by agentctl's extractQuestionAnswer
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// setSessionRunning restores the session state to running after a clarification is answered.
func (h *Handlers) setSessionRunning(ctx context.Context, taskID, sessionID string) {
changed, updatedAt, err := h.updateClarificationSessionState(
ctx,
sessionID,
models.TaskSessionStateWaitingForInput,
models.TaskSessionStateRunning,
)
if err != nil {
h.logger.Warn("failed to update session state to RUNNING",
zap.String("session_id", sessionID),
zap.Error(err))
return
}
if !changed {
return
}
if taskID != "" {
taskStateChanged, err := h.setTaskInProgressForClarification(ctx, taskID, sessionID)
if err != nil {
h.logger.Warn("failed to update task state to IN_PROGRESS",
zap.String("task_id", taskID),
zap.Error(err))
} else if !taskStateChanged {
h.logger.Debug("skipping stale clarification resume after session state changed",
zap.String("task_id", taskID),
zap.String("session_id", sessionID))
return
}
}
// Publish session state changed event after any task event for this task.
if h.eventBus == nil {
return
}
eventData := map[string]any{
"task_id": taskID,
"session_id": sessionID,
"new_state": string(models.TaskSessionStateRunning),
}
if !updatedAt.IsZero() {
eventData["updated_at"] = updatedAt.UTC().Format(time.RFC3339Nano)
} else if persistedUpdatedAt, ok := h.sessionUpdatedAtForStateEvent(ctx, sessionID); ok {
eventData["updated_at"] = persistedUpdatedAt
} else {
h.logger.Warn("skipping session state_changed publish; could not load authoritative updated_at",
zap.String("session_id", sessionID))
return
}
publish := func(publicationCtx context.Context) {
_ = h.eventBus.Publish(publicationCtx, events.TaskSessionStateChanged, bus.NewEvent(
events.TaskSessionStateChanged,
"mcp-handlers",
eventData,
))
}
if h.taskSvc != nil && taskID != "" {
h.taskSvc.PublishAfterTaskEvents(ctx, taskID, events.TaskSessionStateChanged, publish)
return
}
publish(ctx)
}
func (h *Handlers) setTaskInProgressForClarification(
ctx context.Context,
taskID, sessionID string,
) (bool, error) {
if h.taskSvc != nil {
return h.taskSvc.UpdateTaskStateIfSessionState(
ctx,
taskID,
sessionID,
models.TaskSessionStateRunning,
v1.TaskStateInProgress,
)
}
if updater, ok := h.taskRepo.(sessionOwnedTaskStateUpdater); ok {
_, updated, err := updater.UpdateTaskStateIfSessionState(
ctx,
taskID,
sessionID,
models.TaskSessionStateRunning,
v1.TaskStateInProgress,
)
return updated, err
}
if err := h.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateInProgress); err != nil {
return false, err
}
return true, nil
}
// setSessionWaitingForInput updates the session and task states to waiting for input
func (h *Handlers) setSessionWaitingForInput(ctx context.Context, taskID, sessionID string) {
changed, updatedAt, err := h.updateSessionWaitingForClarification(ctx, sessionID)
if err != nil {
h.logger.Warn("failed to update session state to WAITING_FOR_INPUT",
zap.String("session_id", sessionID),
zap.Error(err))
return
}
if !changed {
return
}
// Update task state to REVIEW. Use the task service when available so the
// state change publishes the pending-action projection after the
// clarification message is persisted. The repository fallback keeps this
// helper usable in small handler tests and alternate integrations.
if taskID != "" {
var stateErr error
if h.taskSvc != nil {
_, stateErr = h.taskSvc.UpdateTaskStateIfSessionState(
ctx,
taskID,
sessionID,
models.TaskSessionStateWaitingForInput,
v1.TaskStateReview,
)
} else if h.taskRepo != nil {
stateErr = h.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateReview)
}
if stateErr != nil {
h.logger.Warn("failed to update task state to REVIEW",
zap.String("task_id", taskID),
zap.Error(stateErr))
}
}
// Publish session state changed event
if h.eventBus != nil {
eventData := map[string]interface{}{
"task_id": taskID,
"session_id": sessionID,
"new_state": string(models.TaskSessionStateWaitingForInput),
}
if !updatedAt.IsZero() {
eventData["updated_at"] = updatedAt.UTC().Format(time.RFC3339Nano)
} else if persistedUpdatedAt, ok := h.sessionUpdatedAtForStateEvent(ctx, sessionID); ok {
eventData["updated_at"] = persistedUpdatedAt
} else {
h.logger.Warn("skipping session state_changed publish; could not load authoritative updated_at",
zap.String("session_id", sessionID))
return
}
_ = h.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent(
events.TaskSessionStateChanged,
"mcp-handlers",
eventData,
))
}
}
func (h *Handlers) updateSessionWaitingForClarification(
ctx context.Context,
sessionID string,
) (bool, time.Time, error) {
// A fast MCP clarification can arrive before agent startup promotes the
// session from STARTING to RUNNING. Re-read and retry once if that promotion
// races the CAS; terminal states still reject the write.
for attempt := 0; attempt < 2; attempt++ {
session, err := h.sessionRepo.GetTaskSession(ctx, sessionID)
if err != nil {
return false, time.Time{}, err
}
if session == nil {
return false, time.Time{}, fmt.Errorf("session %q not found", sessionID)
}
if session.State != models.TaskSessionStateStarting &&
session.State != models.TaskSessionStateRunning {
return false, time.Time{}, nil
}
changed, updatedAt, err := h.updateClarificationSessionState(
ctx,
sessionID,
session.State,
models.TaskSessionStateWaitingForInput,
)
if err != nil || changed {
return changed, updatedAt, err
}
}
return false, time.Time{}, nil
}
func (h *Handlers) updateClarificationSessionState(
ctx context.Context,
sessionID string,
expected, state models.TaskSessionState,
) (bool, time.Time, error) {
if updater, ok := h.sessionRepo.(conditionalSessionStateUpdater); ok {
return updater.UpdateTaskSessionStateIfCurrent(ctx, sessionID, expected, state, "")
}
if err := h.sessionRepo.UpdateTaskSessionState(ctx, sessionID, state, ""); err != nil {
return false, time.Time{}, err
}
return true, time.Time{}, nil
}
func (h *Handlers) sessionUpdatedAtForStateEvent(ctx context.Context, sessionID string) (string, bool) {
if session, err := h.sessionRepo.GetTaskSession(ctx, sessionID); err == nil && session != nil && !session.UpdatedAt.IsZero() {
return session.UpdatedAt.UTC().Format(time.RFC3339Nano), true
}
return "", false
}
// handleCreateTaskPlan creates a new task plan.
func (h *Handlers) handleCreateTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
TaskID string `json:"task_id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedBy string `json:"created_by"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.Content == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "content is required", nil)
}
createdBy := req.CreatedBy
if createdBy == "" {
createdBy = "agent"
}
plan, err := h.planService.CreatePlan(ctx, service.CreatePlanRequest{
TaskID: req.TaskID,
Title: req.Title,
Content: req.Content,
CreatedBy: createdBy,
})
if err != nil {
return planws.CreateError(msg, err)
}
return ws.NewResponse(msg.ID, msg.Action, dto.TaskPlanFromModel(plan))
}
// handleGetTaskPlan retrieves a task plan.
func (h *Handlers) handleGetTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req planws.TaskIDRequest
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
plan, err := h.planService.GetPlan(ctx, req.TaskID)
if err != nil {
return planws.GetError(msg, err)
}
if plan == nil {
// Return empty object if no plan exists
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{})
}
return ws.NewResponse(msg.ID, msg.Action, dto.TaskPlanFromModel(plan))
}
// handleUpdateTaskPlan updates an existing task plan.
func (h *Handlers) handleUpdateTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
TaskID string `json:"task_id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedBy string `json:"created_by"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.Content == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "content is required", nil)
}
createdBy := req.CreatedBy
if createdBy == "" {
createdBy = "agent"
}
plan, err := h.planService.UpdatePlan(ctx, service.UpdatePlanRequest{
TaskID: req.TaskID,
Title: req.Title,
Content: req.Content,
CreatedBy: createdBy,
})
if err != nil {
return planws.UpdateError(msg, err)
}
return ws.NewResponse(msg.ID, msg.Action, dto.TaskPlanFromModel(plan))
}
// handleDeleteTaskPlan deletes a task plan.
func (h *Handlers) handleDeleteTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req planws.TaskIDRequest
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
err := h.planService.DeletePlan(ctx, req.TaskID)
if err != nil {
return planws.DeleteError(msg, err)
}
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"success": true})
}
// handleShowWalkthrough creates or replaces a task's agent-authored code walkthrough.
func (h *Handlers) handleShowWalkthrough(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
TaskID string `json:"task_id"`
Title string `json:"title"`
Steps []models.WalkthroughStep `json:"steps"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if len(req.Steps) == 0 {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "at least one step is required", nil)
}
wt, err := h.walkthroughService.ShowWalkthrough(ctx, service.ShowWalkthroughRequest{
TaskID: req.TaskID,
Title: req.Title,
Steps: req.Steps,
})
if err != nil {
if errors.Is(err, service.ErrTaskIDRequired) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
if errors.Is(err, service.ErrInvalidWalkthrough) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
}
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to save walkthrough: "+err.Error(), nil)
}
return ws.NewResponse(msg.ID, msg.Action, wt)
}
// parseTaskIDPayload unmarshals a `{task_id}` payload, returning a ready error
// response (non-nil) when the payload is malformed.
func parseTaskIDPayload(msg *ws.Message) (string, *ws.Message, error) {
var req struct {
TaskID string `json:"task_id"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
m, e := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
return "", m, e
}
return req.TaskID, nil, nil
}
// handleGetWalkthrough retrieves a task's walkthrough.
func (h *Handlers) handleGetWalkthrough(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
taskID, errMsg, errErr := parseTaskIDPayload(msg)
if errMsg != nil || errErr != nil {
return errMsg, errErr
}
wt, err := h.walkthroughService.GetWalkthrough(ctx, taskID)
if err != nil {
if errors.Is(err, service.ErrTaskIDRequired) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get walkthrough", nil)
}
if wt == nil {
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{})
}
return ws.NewResponse(msg.ID, msg.Action, wt)
}
// handleDeleteWalkthrough deletes a task's walkthrough.
func (h *Handlers) handleDeleteWalkthrough(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
taskID, errMsg, errErr := parseTaskIDPayload(msg)
if errMsg != nil || errErr != nil {
return errMsg, errErr
}
switch err := h.walkthroughService.DeleteWalkthrough(ctx, taskID); {
case err == nil:
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"success": true})
case errors.Is(err, service.ErrTaskIDRequired):
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
case errors.Is(err, service.ErrTaskWalkthroughNotFound):
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Task walkthrough not found", nil)
default:
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete walkthrough: "+err.Error(), nil)
}
}
// handleClarificationTimeout is called by agentctl when the agent's MCP client
// disconnects while waiting for a clarification response. It cancels the pending
// clarification so the user's eventual answer goes through the event fallback path
// (new turn) instead of the primary path (which would be dropped).
func (h *Handlers) handleClarificationTimeout(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
SessionID string `json:"session_id"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.SessionID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
}
if h.inputPauser != nil {
cancelled, err := h.inputPauser.PauseForClarificationInput(context.WithoutCancel(ctx), req.SessionID)
if err != nil {
h.logger.Warn("failed to pause session after clarification timeout",
zap.String("session_id", req.SessionID),
zap.Error(err))
if h.sessionCanceller == nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError,
"failed to pause session for clarification input", nil)
}
cancelled = h.sessionCanceller.DetachSessionAndNotify(context.WithoutCancel(ctx), req.SessionID)
h.logger.Info("detached clarification waiters after pause failure",
zap.String("session_id", req.SessionID),
zap.Int("count", cancelled))
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"ok": true, "cancelled": cancelled, "paused": false})
}
h.logger.Info("paused session after agent MCP clarification timeout",
zap.String("session_id", req.SessionID),
zap.Int("count", cancelled))
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"ok": true, "cancelled": cancelled, "paused": true})
}
if h.sessionCanceller == nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "sessionCanceller is required", nil)
}
cancelled := h.sessionCanceller.DetachSessionAndNotify(ctx, req.SessionID)
h.logger.Info("detached pending clarifications on agent MCP timeout",
zap.String("session_id", req.SessionID),
zap.Int("count", cancelled))
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"ok": true, "cancelled": cancelled})
}