Files
kandev/apps/backend/internal/mcp/handlers/handlers.go
T
2026-08-12 17:12:56 +01:00

3795 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"
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
// 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
}
// 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)
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,
"status": "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("question_id", 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,
"status": 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})
}