Files
kandev/apps/backend/internal/orchestrator/task_operations.go
T
Carlos Florêncio f4d1dfd3a7 feat: better context files (#222)
feat: context files
2026-02-14 20:13:11 +00:00

879 lines
31 KiB
Go

// Package orchestrator provides the main orchestrator service that ties all components together.
package orchestrator
import (
"context"
"fmt"
"os"
"strings"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/orchestrator/dto"
"github.com/kandev/kandev/internal/orchestrator/executor"
"github.com/kandev/kandev/internal/orchestrator/queue"
"github.com/kandev/kandev/internal/sysprompt"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// PromptResult contains the result of a prompt operation
type PromptResult struct {
StopReason string // The reason the agent stopped (e.g., "end_turn")
AgentMessage string // The agent's accumulated response message
}
func validateSessionWorktrees(session *models.TaskSession) error {
for _, wt := range session.Worktrees {
if wt.WorktreePath == "" {
continue
}
if _, err := os.Stat(wt.WorktreePath); err != nil {
return fmt.Errorf("worktree path not found: %w", err)
}
}
return nil
}
// EnqueueTask manually adds a task to the queue
func (s *Service) EnqueueTask(ctx context.Context, task *v1.Task) error {
s.logger.Debug("manually enqueueing task",
zap.String("task_id", task.ID),
zap.String("title", task.Title))
return s.scheduler.EnqueueTask(task)
}
// PrepareTaskSession creates a session entry without launching the agent.
// This allows the HTTP handler to return the session ID immediately while the agent setup
// continues in the background. Use StartTaskWithSession to continue with agent launch.
func (s *Service) PrepareTaskSession(ctx context.Context, taskID string, agentProfileID string, executorID string, workflowStepID string) (string, error) {
s.logger.Debug("preparing task session",
zap.String("task_id", taskID),
zap.String("agent_profile_id", agentProfileID),
zap.String("executor_id", executorID),
zap.String("workflow_step_id", workflowStepID))
// Fetch the task to get workspace info
task, err := s.scheduler.GetTask(ctx, taskID)
if err != nil {
s.logger.Error("failed to fetch task for session preparation",
zap.String("task_id", taskID),
zap.Error(err))
return "", err
}
// Create session entry in database
sessionID, err := s.executor.PrepareSession(ctx, task, agentProfileID, executorID, workflowStepID)
if err != nil {
s.logger.Error("failed to prepare session",
zap.String("task_id", taskID),
zap.Error(err))
return "", err
}
// Launch workspace infrastructure (agentctl) without starting the agent subprocess.
// This enables file browsing, editing, etc. while the session is in CREATED state.
if _, err := s.executor.LaunchPreparedSession(ctx, task, sessionID, agentProfileID, executorID, "", workflowStepID, false); err != nil {
s.logger.Warn("failed to launch workspace for prepared session (file browsing may be unavailable)",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.Error(err))
// Non-fatal: session is still usable, workspace will be launched when agent starts
}
s.logger.Info("task session prepared",
zap.String("task_id", taskID),
zap.String("session_id", sessionID))
return sessionID, nil
}
// StartTaskWithSession starts agent execution for a task using a pre-created session.
// This is used after PrepareTaskSession to continue with the agent launch.
// If planMode is true and the workflow step doesn't already apply plan mode,
// default plan mode instructions are injected into the prompt.
func (s *Service) StartTaskWithSession(ctx context.Context, taskID string, sessionID string, agentProfileID string, executorID string, priority int, prompt string, workflowStepID string, planMode bool) (*executor.TaskExecution, error) {
s.logger.Debug("starting task with existing session",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("agent_profile_id", agentProfileID),
zap.Bool("plan_mode", planMode))
if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateScheduling); err != nil {
s.logger.Warn("failed to update task state to SCHEDULING",
zap.String("task_id", taskID),
zap.Error(err))
}
task, err := s.scheduler.GetTask(ctx, taskID)
if err != nil {
return nil, err
}
if priority > 0 {
task.Priority = priority
}
effectivePrompt := prompt
if effectivePrompt == "" {
effectivePrompt = task.Description
}
effectivePrompt, planModeActive := s.applyWorkflowAndPlanMode(ctx, effectivePrompt, task.ID, workflowStepID, planMode)
execution, err := s.executor.LaunchPreparedSession(ctx, task, sessionID, agentProfileID, executorID, effectivePrompt, workflowStepID, true)
if err != nil {
return nil, err
}
if execution.SessionID != "" {
s.recordInitialMessage(ctx, taskID, execution.SessionID, effectivePrompt, planModeActive)
}
return execution, nil
}
// StartCreatedSession starts agent execution for a task using a session that is in CREATED state.
// This is used when a session was prepared (via PrepareSession) but the agent was not launched,
// and the user now wants to start the agent with a prompt (e.g., from the plan panel or chat).
func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string) (*executor.TaskExecution, error) {
s.logger.Debug("starting created session",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("agent_profile_id", agentProfileID))
// Load and verify session
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil {
return nil, fmt.Errorf("failed to get session: %w", err)
}
if session.TaskID != taskID {
return nil, fmt.Errorf("session does not belong to task")
}
if session.State != models.TaskSessionStateCreated {
return nil, fmt.Errorf("session is not in CREATED state (current: %s)", session.State)
}
// Use agent profile from request, fall back to session's stored value
effectiveProfileID := agentProfileID
if effectiveProfileID == "" {
effectiveProfileID = session.AgentProfileID
}
if effectiveProfileID == "" {
return nil, fmt.Errorf("agent_profile_id is required")
}
// Transition task state: CREATED → SCHEDULING → (IN_PROGRESS via executor)
if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateScheduling); err != nil {
s.logger.Warn("failed to update task state to SCHEDULING",
zap.String("task_id", taskID),
zap.Error(err))
}
task, err := s.scheduler.GetTask(ctx, taskID)
if err != nil {
return nil, err
}
effectivePrompt := prompt
if effectivePrompt == "" {
effectivePrompt = task.Description
}
executorID := session.ExecutorID
execution, err := s.executor.LaunchPreparedSession(ctx, task, sessionID, effectiveProfileID, executorID, effectivePrompt, "", true)
if err != nil {
return nil, err
}
if execution.SessionID != "" {
s.updateTaskSessionState(ctx, taskID, execution.SessionID, models.TaskSessionStateRunning, "", true)
}
return execution, nil
}
// StartTask manually starts agent execution for a task.
// If workflowStepID is provided and workflowStepGetter is set, the prompt will be built
// using the step's prompt_prefix + base prompt + prompt_suffix, and plan mode will be
// applied if the step has plan_mode enabled.
// If planMode is true and the workflow step doesn't already apply plan mode,
// default plan mode instructions are injected into the prompt.
func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID string, executorID string, priority int, prompt string, workflowStepID string, planMode bool) (*executor.TaskExecution, error) {
s.logger.Debug("manually starting task",
zap.String("task_id", taskID),
zap.String("agent_profile_id", agentProfileID),
zap.String("executor_id", executorID),
zap.Int("priority", priority),
zap.Int("prompt_length", len(prompt)),
zap.String("workflow_step_id", workflowStepID),
zap.Bool("plan_mode", planMode))
if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateScheduling); err != nil {
s.logger.Warn("failed to update task state to SCHEDULING",
zap.String("task_id", taskID),
zap.Error(err))
}
// Move task to the target workflow step if provided and different from current
if workflowStepID != "" {
dbTask, err := s.repo.GetTask(ctx, taskID)
if err == nil && dbTask.WorkflowStepID != workflowStepID {
dbTask.WorkflowStepID = workflowStepID
dbTask.UpdatedAt = time.Now().UTC()
if err := s.repo.UpdateTask(ctx, dbTask); err != nil {
s.logger.Warn("failed to move task to workflow step",
zap.String("task_id", taskID),
zap.String("workflow_step_id", workflowStepID),
zap.Error(err))
} else if s.eventBus != nil {
_ = s.eventBus.Publish(ctx, events.TaskUpdated, bus.NewEvent(
events.TaskUpdated,
"orchestrator",
map[string]interface{}{
"task_id": dbTask.ID,
"board_id": dbTask.BoardID,
"workflow_step_id": dbTask.WorkflowStepID,
"title": dbTask.Title,
"description": dbTask.Description,
"state": string(dbTask.State),
"priority": dbTask.Priority,
"position": dbTask.Position,
},
))
}
}
}
// Fetch the task from the repository to get complete task info
task, err := s.scheduler.GetTask(ctx, taskID)
if err != nil {
s.logger.Error("failed to fetch task for manual start",
zap.String("task_id", taskID),
zap.Error(err))
return nil, err
}
// Override priority if provided in the request
if priority > 0 {
task.Priority = priority
}
// Use provided prompt, fall back to task description
effectivePrompt := prompt
if effectivePrompt == "" {
effectivePrompt = task.Description
}
effectivePrompt, planModeActive := s.applyWorkflowAndPlanMode(ctx, effectivePrompt, task.ID, workflowStepID, planMode)
execution, err := s.executor.ExecuteWithProfile(ctx, task, agentProfileID, executorID, effectivePrompt, workflowStepID)
if err != nil {
return nil, err
}
if execution.SessionID != "" {
s.recordInitialMessage(ctx, taskID, execution.SessionID, effectivePrompt, planModeActive)
}
// Note: Task stays in SCHEDULING state until the agent is fully initialized.
// The executor will transition to IN_PROGRESS after StartAgentProcess() succeeds.
return execution, nil
}
// applyWorkflowAndPlanMode applies workflow step configuration and plan mode injection to a prompt.
// Returns the effective prompt and whether plan mode is active (from either the step or the caller).
func (s *Service) applyWorkflowAndPlanMode(ctx context.Context, prompt string, taskID string, workflowStepID string, planMode bool) (string, bool) {
effectivePrompt := prompt
stepHasPlanMode := false
if workflowStepID != "" && s.workflowStepGetter != nil {
step, err := s.workflowStepGetter.GetStep(ctx, workflowStepID)
if err != nil {
s.logger.Warn("failed to get workflow step for prompt building",
zap.String("workflow_step_id", workflowStepID),
zap.Error(err))
} else {
stepHasPlanMode = step.PlanMode
effectivePrompt = s.buildWorkflowPrompt(effectivePrompt, step, taskID)
}
}
if planMode && !stepHasPlanMode {
var parts []string
parts = append(parts, sysprompt.Wrap(sysprompt.PlanMode))
parts = append(parts, sysprompt.Wrap(sysprompt.InterpolatePlaceholders(sysprompt.DefaultPlanPrefix, taskID)))
parts = append(parts, effectivePrompt)
effectivePrompt = strings.Join(parts, "\n\n")
}
return effectivePrompt, planMode || stepHasPlanMode
}
// recordInitialMessage creates the initial user message and updates session state after launch.
func (s *Service) recordInitialMessage(ctx context.Context, taskID, sessionID, prompt string, planModeActive bool) {
s.updateTaskSessionState(ctx, taskID, sessionID, models.TaskSessionStateRunning, "", true)
if s.messageCreator != nil && prompt != "" {
meta := NewUserMessageMeta().WithPlanMode(planModeActive)
if err := s.messageCreator.CreateUserMessage(ctx, taskID, prompt, sessionID, s.getActiveTurnID(sessionID), meta.ToMap()); err != nil {
s.logger.Error("failed to create initial user message",
zap.String("task_id", taskID),
zap.Error(err))
}
}
}
// buildWorkflowPrompt constructs the effective prompt using workflow step configuration.
// It combines: step.PromptPrefix + basePrompt + step.PromptSuffix
// If step.PlanMode is true, it also prepends the plan mode prefix.
// Placeholders like {task_id} are interpolated with actual values.
// System-injected content (prefix, suffix, plan mode) is wrapped in <kandev-system> tags
// so it can be stripped when displaying to users.
func (s *Service) buildWorkflowPrompt(basePrompt string, step *WorkflowStep, taskID string) string {
var parts []string
// Apply plan mode prefix if enabled (wrapped in system tags)
if step.PlanMode {
parts = append(parts, sysprompt.Wrap(sysprompt.PlanMode))
}
// Add prompt prefix if set (with interpolation, wrapped in system tags)
if step.PromptPrefix != "" {
parts = append(parts, sysprompt.Wrap(sysprompt.InterpolatePlaceholders(step.PromptPrefix, taskID)))
}
// Add base prompt (user's actual message - not wrapped)
parts = append(parts, basePrompt)
// Add prompt suffix if set (with interpolation, wrapped in system tags)
if step.PromptSuffix != "" {
parts = append(parts, sysprompt.Wrap(sysprompt.InterpolatePlaceholders(step.PromptSuffix, taskID)))
}
return strings.Join(parts, "\n\n")
}
// ResumeTaskSession restarts a specific task session using its stored worktree.
func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID string) (*executor.TaskExecution, error) {
s.logger.Debug("resuming task session",
zap.String("task_id", taskID),
zap.String("session_id", sessionID))
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil {
return nil, err
}
if session.TaskID != taskID {
return nil, fmt.Errorf("task session does not belong to task")
}
running, err := s.repo.GetExecutorRunningBySessionID(ctx, sessionID)
if err != nil || running == nil || running.ResumeToken == "" || !running.Resumable {
return nil, fmt.Errorf("session is not resumable")
}
if err := validateSessionWorktrees(session); err != nil {
return nil, err
}
// Use context.WithoutCancel to prevent WebSocket request timeout from canceling the resume.
// Session resume can take time and shouldn't be tied to the WS request lifecycle.
resumeCtx := context.WithoutCancel(ctx)
execution, err := s.executor.ResumeSession(resumeCtx, session, true)
if err != nil {
_ = s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateFailed, err.Error())
return nil, err
}
// Preserve persisted task/session state; resume should not mutate state/columns.
execution.SessionState = v1.TaskSessionState(session.State)
s.logger.Debug("task session resumed and ready for input",
zap.String("task_id", taskID),
zap.String("session_id", sessionID))
return execution, nil
}
// StartSessionForWorkflowStep starts an existing session with a workflow step's prompt configuration.
// If the session is not running, it will be resumed first. Then a prompt is sent using the
// step's prompt_prefix, prompt_suffix, and plan_mode settings combined with the task description.
func (s *Service) StartSessionForWorkflowStep(ctx context.Context, taskID, sessionID, workflowStepID string) error {
s.logger.Debug("starting session for workflow step",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("workflow_step_id", workflowStepID))
if workflowStepID == "" {
return fmt.Errorf("workflow_step_id is required")
}
if s.workflowStepGetter == nil {
return fmt.Errorf("workflow step getter not configured")
}
// Get the workflow step
step, err := s.workflowStepGetter.GetStep(ctx, workflowStepID)
if err != nil {
return fmt.Errorf("failed to get workflow step: %w", err)
}
// Get the session
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil {
return fmt.Errorf("failed to get session: %w", err)
}
if session.TaskID != taskID {
return fmt.Errorf("session does not belong to task")
}
// Get the task to use its description as the base prompt
task, err := s.scheduler.GetTask(ctx, taskID)
if err != nil {
return fmt.Errorf("failed to get task: %w", err)
}
// Check if session is in a review step with pending approval
// If so, reject the request - user must use Approve button or send a chat message
if session.ReviewStatus != nil && *session.ReviewStatus == "pending" {
if session.WorkflowStepID != nil && *session.WorkflowStepID != "" {
currentStep, err := s.workflowStepGetter.GetStep(ctx, *session.WorkflowStepID)
if err == nil && currentStep.RequireApproval {
return fmt.Errorf("session is pending approval - use Approve button to proceed or send a message to request changes")
}
}
}
// Update session's workflow step to the new step
if session.WorkflowStepID == nil || *session.WorkflowStepID != workflowStepID {
if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, workflowStepID); err != nil {
s.logger.Warn("failed to update session workflow step",
zap.String("session_id", sessionID),
zap.String("workflow_step_id", workflowStepID),
zap.Error(err))
}
// Clear review status when moving to a new step
if session.ReviewStatus != nil && *session.ReviewStatus != "" {
if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil {
s.logger.Warn("failed to clear session review status",
zap.String("session_id", sessionID),
zap.Error(err))
}
}
}
// Build prompt from workflow step configuration
effectivePrompt := s.buildWorkflowPrompt(task.Description, step, taskID)
// Check if session needs to be resumed first
// A session in "running" or "waiting_for_input" state can receive prompts directly
// Other states (created, starting, completed, failed, cancelled) need resume
sessionState := session.State
if sessionState != models.TaskSessionStateRunning && sessionState != models.TaskSessionStateWaitingForInput {
// Try to resume the session
s.logger.Debug("session not running, attempting resume",
zap.String("session_id", sessionID),
zap.String("session_state", string(session.State)))
running, err := s.repo.GetExecutorRunningBySessionID(ctx, sessionID)
if err != nil || running == nil || running.ResumeToken == "" || !running.Resumable {
return fmt.Errorf("session is not resumable (state: %s)", session.State)
}
if err := validateSessionWorktrees(session); err != nil {
return err
}
// Use context.WithoutCancel to prevent WebSocket request timeout from canceling the resume.
// Session resume can take time and shouldn't be tied to the WS request lifecycle.
resumeCtx := context.WithoutCancel(ctx)
_, err = s.executor.ResumeSession(resumeCtx, session, true)
if err != nil {
_ = s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateFailed, err.Error())
return fmt.Errorf("failed to resume session: %w", err)
}
s.logger.Debug("session resumed, now sending prompt")
}
// Send the prompt using PromptTask (with planMode from step)
// Note: PromptTask internally uses context.WithoutCancel for the executor call
// No attachments for workflow-initiated prompts
_, err = s.PromptTask(ctx, taskID, sessionID, effectivePrompt, "", step.PlanMode, nil)
if err != nil {
return fmt.Errorf("failed to prompt session: %w", err)
}
s.logger.Info("session started for workflow step",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("workflow_step_id", workflowStepID),
zap.String("step_name", step.Name),
zap.Bool("plan_mode", step.PlanMode))
return nil
}
// GetTaskSessionStatus returns the status of a task session including whether it's resumable
func (s *Service) GetTaskSessionStatus(ctx context.Context, taskID, sessionID string) (dto.TaskSessionStatusResponse, error) {
s.logger.Debug("checking task session status",
zap.String("task_id", taskID),
zap.String("session_id", sessionID))
resp := dto.TaskSessionStatusResponse{
SessionID: sessionID,
TaskID: taskID,
}
// 1. Load session from database
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil {
resp.Error = "session not found"
return resp, nil
}
if session.TaskID != taskID {
resp.Error = "session does not belong to task"
return resp, nil
}
resp.State = string(session.State)
resp.AgentProfileID = session.AgentProfileID
// Extract resume token from executor runtime state.
var resumeToken string
if running, err := s.repo.GetExecutorRunningBySessionID(ctx, sessionID); err == nil && running != nil {
resumeToken = running.ResumeToken
resp.ACPSessionID = resumeToken
if running.Resumable {
resp.IsResumable = true
}
}
// Extract worktree info
if len(session.Worktrees) > 0 {
wt := session.Worktrees[0]
if wt.WorktreePath != "" {
resp.WorktreePath = &wt.WorktreePath
}
if wt.WorktreeBranch != "" {
resp.WorktreeBranch = &wt.WorktreeBranch
}
}
// 2. Check if this session's agent is running
if exec, ok := s.executor.GetExecutionBySession(sessionID); ok && exec != nil {
resp.IsAgentRunning = true
resp.NeedsResume = false
return resp, nil
}
// 3. Session can be resumed if it has a resume token
if resumeToken == "" {
resp.IsAgentRunning = false
resp.IsResumable = false
resp.NeedsResume = false
return resp, nil
}
// 4. Additional validations for resumption
if session.AgentProfileID == "" {
resp.Error = "session missing agent profile"
resp.IsResumable = false
return resp, nil
}
// Check if worktree exists (if one was used)
if len(session.Worktrees) > 0 && session.Worktrees[0].WorktreePath != "" {
if _, err := os.Stat(session.Worktrees[0].WorktreePath); err != nil {
resp.Error = "worktree not found"
resp.IsResumable = false
return resp, nil
}
}
resp.IsAgentRunning = false
resp.NeedsResume = true
resp.ResumeReason = "agent_not_running"
return resp, nil
}
// StopTask stops agent execution for a task (stops all active sessions for the task)
func (s *Service) StopTask(ctx context.Context, taskID string, reason string, force bool) error {
s.logger.Info("stopping task execution",
zap.String("task_id", taskID),
zap.String("reason", reason),
zap.Bool("force", force))
// Stop all agents for this task
if err := s.executor.StopByTaskID(ctx, taskID, reason, force); err != nil {
return err
}
// Move task to REVIEW state for user review
if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateReview); err != nil {
s.logger.Error("failed to update task state to REVIEW after stop",
zap.String("task_id", taskID),
zap.Error(err))
// Don't return error - the stop was successful
} else {
s.logger.Info("task moved to REVIEW state after stop",
zap.String("task_id", taskID))
}
return nil
}
// StopSession stops agent execution for a specific session
func (s *Service) StopSession(ctx context.Context, sessionID string, reason string, force bool) error {
s.logger.Info("stopping session execution",
zap.String("session_id", sessionID),
zap.String("reason", reason),
zap.Bool("force", force))
return s.executor.Stop(ctx, sessionID, reason, force)
}
// PromptTask sends a follow-up prompt to a running agent for a task session.
// If planMode is true, a plan mode prefix is prepended to the prompt.
// Attachments (images) are passed through to the agent if provided.
func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prompt string, model string, planMode bool, attachments []v1.MessageAttachment) (*PromptResult, error) {
s.logger.Debug("PromptTask called",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.Int("prompt_length", len(prompt)),
zap.String("requested_model", model),
zap.Bool("plan_mode", planMode),
zap.Int("attachments_count", len(attachments)))
if sessionID == "" {
return nil, fmt.Errorf("session_id is required")
}
// Check if session is already processing a prompt (RUNNING state)
// This prevents concurrent prompts that can cause race conditions
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil {
return nil, fmt.Errorf("failed to get session: %w", err)
}
if session.State == models.TaskSessionStateRunning {
s.logger.Warn("rejected prompt while agent is already running",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("session_state", string(session.State)))
return nil, fmt.Errorf("agent is currently processing a prompt, please wait for completion")
}
// Apply plan mode prefix if enabled
effectivePrompt := prompt
if planMode {
effectivePrompt = sysprompt.InjectPlanMode(prompt)
}
// Check if session is in a review step - if so, move back to the previous step
// This handles the case where the user sends a message to iterate on the work
s.handleReviewStepRollback(ctx, taskID, sessionID)
// Check if model switching is requested
if model != "" {
// Get current model from agent profile snapshot (session already fetched above)
var currentModel string
if session.AgentProfileSnapshot != nil {
if m, ok := session.AgentProfileSnapshot["model"].(string); ok {
currentModel = m
}
}
// Trigger switch if models differ
if currentModel != model {
s.logger.Info("switching model",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("from", currentModel),
zap.String("to", model))
// Start a new turn for this prompt (model switch case)
s.startTurnForSession(ctx, sessionID)
// Use context.WithoutCancel to prevent WebSocket request timeout from canceling the operation.
switchCtx := context.WithoutCancel(ctx)
// SwitchModel will stop agent, rebuild with new model, restart, and send prompt
switchResult, err := s.executor.SwitchModel(switchCtx, taskID, sessionID, model, effectivePrompt)
if err != nil {
return nil, fmt.Errorf("model switch failed: %w", err)
}
// Update session state to RUNNING and publish event so frontend knows agent is active
s.setSessionRunning(ctx, taskID, sessionID)
return &PromptResult{
StopReason: switchResult.StopReason,
AgentMessage: switchResult.AgentMessage,
}, nil
}
// Model is the same, fall through to regular prompt
}
previousSessionState := session.State
s.setSessionRunning(ctx, taskID, sessionID)
s.startTurnForSession(ctx, sessionID)
// Use context.WithoutCancel to prevent WebSocket request timeout from canceling the prompt.
// Prompts can take a long time (minutes) while the WS request may timeout in 15 seconds.
// We still want to log and respond, but the prompt should continue regardless.
promptCtx := context.WithoutCancel(ctx)
result, err := s.executor.Prompt(promptCtx, taskID, sessionID, effectivePrompt, attachments)
if err != nil {
s.logger.Error("prompt failed",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.Error(err))
// Revert session state so it doesn't stay stuck in RUNNING.
// Use repo directly to bypass state machine guards that block transitions from terminal states.
_ = s.repo.UpdateTaskSessionState(ctx, sessionID, previousSessionState, "")
_ = s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateReview)
s.completeTurnForSession(ctx, sessionID)
return nil, err
}
return &PromptResult{
StopReason: result.StopReason,
AgentMessage: result.AgentMessage,
}, nil
}
// RespondToPermission sends a response to a permission request for a session
func (s *Service) RespondToPermission(ctx context.Context, sessionID, pendingID, optionID string, cancelled bool) error {
s.logger.Debug("responding to permission request",
zap.String("session_id", sessionID),
zap.String("pending_id", pendingID),
zap.String("option_id", optionID),
zap.Bool("cancelled", cancelled))
// Respond to the permission via agentctl
if err := s.executor.RespondToPermission(ctx, sessionID, pendingID, optionID, cancelled); err != nil {
// Permission likely expired — update message so frontend reflects this
if s.messageCreator != nil {
if updateErr := s.messageCreator.UpdatePermissionMessage(ctx, sessionID, pendingID, "expired"); updateErr != nil {
s.logger.Warn("failed to mark expired permission message",
zap.String("session_id", sessionID),
zap.String("pending_id", pendingID),
zap.Error(updateErr))
}
}
return err
}
// Determine status based on response
status := "approved"
if cancelled {
status = "rejected"
}
// Update the permission message with the new status
if s.messageCreator != nil {
if err := s.messageCreator.UpdatePermissionMessage(ctx, sessionID, pendingID, status); err != nil {
s.logger.Warn("failed to update permission message status",
zap.String("session_id", sessionID),
zap.String("pending_id", pendingID),
zap.String("status", status),
zap.Error(err))
// Don't fail the whole operation if message update fails
}
}
if !cancelled {
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil {
s.logger.Warn("failed to load task session after permission response",
zap.String("session_id", sessionID),
zap.Error(err))
return nil
}
s.setSessionRunning(ctx, session.TaskID, sessionID)
}
return nil
}
// CancelAgent interrupts the current agent turn without terminating the process,
// allowing the user to send a new prompt.
func (s *Service) CancelAgent(ctx context.Context, sessionID string) error {
s.logger.Debug("cancelling agent turn", zap.String("session_id", sessionID))
// Fetch session for state updates and message creation
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil {
s.logger.Warn("failed to get session for cancel",
zap.String("session_id", sessionID),
zap.Error(err))
}
if err := s.agentManager.CancelAgent(ctx, sessionID); err != nil {
return fmt.Errorf("cancel agent: %w", err)
}
// Transition to WAITING_FOR_INPUT so the user can send a new prompt
if session != nil {
s.updateTaskSessionState(ctx, session.TaskID, sessionID, models.TaskSessionStateWaitingForInput, "", true)
}
// Record cancellation in the message history
if s.messageCreator != nil && session != nil {
metadata := map[string]interface{}{
"cancelled": true,
"variant": "warning",
}
if err := s.messageCreator.CreateSessionMessage(
ctx,
session.TaskID,
"Turn cancelled by user",
sessionID,
string(v1.MessageTypeStatus),
s.getActiveTurnID(sessionID),
metadata,
false,
); err != nil {
s.logger.Warn("failed to create cancel message",
zap.String("session_id", sessionID),
zap.Error(err))
}
}
// Complete the turn since the agent was cancelled
s.completeTurnForSession(ctx, sessionID)
s.logger.Debug("agent turn cancelled", zap.String("session_id", sessionID))
return nil
}
// CompleteTask explicitly completes a task and stops all its agents
func (s *Service) CompleteTask(ctx context.Context, taskID string) error {
s.logger.Info("completing task",
zap.String("task_id", taskID))
// Stop all agents for this task (which will trigger AgentCompleted events and update session states)
if err := s.executor.StopByTaskID(ctx, taskID, "task completed by user", false); err != nil {
// If agents are already stopped, just update the task state directly
s.logger.Warn("failed to stop agents, updating task state directly",
zap.String("task_id", taskID),
zap.Error(err))
}
// Update task state to COMPLETED
if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateCompleted); err != nil {
return fmt.Errorf("failed to update task state: %w", err)
}
s.logger.Info("task marked as COMPLETED",
zap.String("task_id", taskID))
return nil
}
// GetQueuedTasks returns tasks in the queue
func (s *Service) GetQueuedTasks() []*queue.QueuedTask {
return s.queue.List()
}