3495 lines
147 KiB
Go
3495 lines
147 KiB
Go
// Package orchestrator provides the main orchestrator service that ties all components together.
|
|
package orchestrator
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/agent/runtime/agentctl"
|
|
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
|
|
"github.com/kandev/kandev/internal/agent/runtime/routingerr"
|
|
"github.com/kandev/kandev/internal/orchestrator/dto"
|
|
"github.com/kandev/kandev/internal/orchestrator/executor"
|
|
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
|
|
"github.com/kandev/kandev/internal/orchestrator/queue"
|
|
"github.com/kandev/kandev/internal/sysprompt"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
wfmodels "github.com/kandev/kandev/internal/workflow/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
|
|
}
|
|
|
|
// CoordinatorTaskStopStatus is the idempotent product result returned to a
|
|
// parent task that asks to halt a direct child.
|
|
type CoordinatorTaskStopStatus string
|
|
|
|
const (
|
|
CoordinatorTaskStopStatusStopped CoordinatorTaskStopStatus = "stopped"
|
|
CoordinatorTaskStopStatusNotRunning CoordinatorTaskStopStatus = "not_running"
|
|
coordinatorMCPStopReason = "stopped by parent task via MCP"
|
|
)
|
|
|
|
// CoordinatorTaskStopResult reports whether at least one live child session
|
|
// accepted logical cancellation. Runtime teardown continues asynchronously.
|
|
type CoordinatorTaskStopResult struct {
|
|
Status CoordinatorTaskStopStatus `json:"status"`
|
|
}
|
|
|
|
// resumeReasonErrorRecovery is the resume reason returned when a session is in
|
|
// error-recovery state (WAITING_FOR_INPUT with a non-empty ErrorMessage).
|
|
const resumeReasonErrorRecovery = "error_recovery"
|
|
|
|
// resumeReasonFailedSessionResumable is the resume reason returned when a
|
|
// FAILED session is auto-resumed because its runtime is Resumable. Distinct
|
|
// from "agent_not_running" so log filtering can isolate FAILED auto-resumes.
|
|
const resumeReasonFailedSessionResumable = "failed_session_resumable"
|
|
|
|
var ErrAgentPromptInProgress = errors.New("agent is currently processing a prompt")
|
|
var ErrAgentNotReadyForPrompt = errors.New("agent not ready for prompt")
|
|
var ErrSessionResetInProgress = errors.New("session reset in progress")
|
|
|
|
// ErrSessionNotPromptable is returned when a session cannot accept a prompt
|
|
// because of its lifecycle state (STARTING, CREATED, FAILED, CANCELLED).
|
|
// Distinct from ErrAgentPromptInProgress, which is RUNNING-only — confusing
|
|
// the two misleads the UI and any caller doing errors.Is checks.
|
|
var ErrSessionNotPromptable = errors.New("session not promptable")
|
|
|
|
// errQueuedDispatchSuperseded is returned by promptTask when a queued
|
|
// message's dispatch loses its claim (see isCurrentQueuedDispatch) to a
|
|
// newer dispatch for the same session — e.g. a second parent interrupt
|
|
// cancelling and re-taking while the first dispatch's own goroutine was
|
|
// still settling. Not surfaced to callers of the public PromptTask; only
|
|
// executeQueuedMessage's internal call passes a claim token that can
|
|
// trigger this.
|
|
var errQueuedDispatchSuperseded = errors.New("queued dispatch superseded by a newer one for this session")
|
|
|
|
var (
|
|
// Backend restart recovery can restore the session state before the ACP
|
|
// stream is promptable again. Keep this above CI's slow-start tail so a
|
|
// valid resume waits instead of surfacing "Failed to send message to agent".
|
|
agentPromptReadyTimeout = 30 * time.Second
|
|
agentPromptReadyInterval = 100 * time.Millisecond
|
|
)
|
|
|
|
const promptReadinessRecoveryStopReason = "prompt readiness recovery"
|
|
|
|
type agentPromptStreamRecoverer interface {
|
|
RecoverAgentPromptStream(ctx context.Context, sessionID string) error
|
|
}
|
|
|
|
func isAgentPromptInProgressError(err error) bool {
|
|
return err != nil && errors.Is(err, ErrAgentPromptInProgress)
|
|
}
|
|
|
|
// isSessionBusyError reports whether the session is in a state where a queued
|
|
// or auto-started prompt should be retried later rather than dropped. Covers
|
|
// both "the agent is mid-turn" (ErrAgentPromptInProgress, RUNNING) and "the
|
|
// session isn't yet ready to accept input" (ErrSessionNotPromptable —
|
|
// STARTING, CREATED, FAILED, CANCELLED). The pre-PR code path collapsed both
|
|
// into ErrAgentPromptInProgress; this helper preserves the requeue behaviour
|
|
// after the error split so queued messages targeting a session that is
|
|
// briefly STARTING/CREATED don't get silently dropped (see TODO about the
|
|
// missing dead-letter queue in executeQueuedMessage).
|
|
func isSessionBusyError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
return errors.Is(err, ErrAgentPromptInProgress) || errors.Is(err, ErrSessionNotPromptable)
|
|
}
|
|
|
|
func isSessionResetInProgressError(err error) bool {
|
|
return err != nil && errors.Is(err, ErrSessionResetInProgress)
|
|
}
|
|
|
|
// isTransientPromptError reports whether a prompt error is worth retrying via
|
|
// the queue. ErrExecutionNotFound is intentionally NOT included here:
|
|
// callers that can recover (autoStartStepPrompt → fallbackFreshLaunchOnMissingExecution)
|
|
// detect it explicitly via errors.Is and route differently; callers that
|
|
// can't (executeQueuedMessage) should not infinite-requeue on it. Treating
|
|
// "execution not found" as transient blanket-applies a retry that loops
|
|
// forever when the execution is genuinely gone.
|
|
func isTransientPromptError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := strings.ToLower(err.Error())
|
|
return strings.Contains(msg, "agent stream disconnected") ||
|
|
strings.Contains(msg, "use of closed network connection")
|
|
}
|
|
|
|
func isAgentAlreadyRunningError(err error) bool {
|
|
return err != nil && errors.Is(err, lifecycle.ErrAgentAlreadyRunning)
|
|
}
|
|
|
|
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 WS handler to return the session ID immediately while workspace setup
|
|
// continues in the background. Use StartCreatedSession to continue with agent launch.
|
|
// When launchWorkspace is true, workspace infrastructure (agentctl) is launched asynchronously;
|
|
// the frontend receives preparation progress via executor.prepare.progress WS events.
|
|
func (s *Service) PrepareTaskSession(ctx context.Context, taskID string, agentProfileID string, executorID string, executorProfileID string, workflowStepID string, launchWorkspace bool) (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("executor_profile_id", executorProfileID),
|
|
zap.String("workflow_step_id", workflowStepID),
|
|
zap.Bool("launch_workspace", launchWorkspace))
|
|
|
|
// 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
|
|
}
|
|
|
|
// Resolve agent/executor profile from task metadata if not explicitly provided
|
|
if agentProfileID == "" {
|
|
if v, ok := task.Metadata[models.MetaKeyAgentProfileID].(string); ok && v != "" {
|
|
agentProfileID = v
|
|
}
|
|
}
|
|
if executorID == "" {
|
|
if v, ok := task.Metadata[models.MetaKeyExecutorID].(string); ok && v != "" {
|
|
executorID = v
|
|
}
|
|
}
|
|
if executorProfileID == "" {
|
|
if v, ok := task.Metadata[models.MetaKeyExecutorProfileID].(string); ok && v != "" {
|
|
executorProfileID = v
|
|
}
|
|
}
|
|
|
|
// Inherit agent/executor profile from parent task's primary session when not
|
|
// explicitly provided. This covers subtasks created with start_agent=false that
|
|
// are later opened manually from the UI. We check each field independently so
|
|
// that a caller providing only some fields still gets the rest filled in.
|
|
if (agentProfileID == "" || executorProfileID == "" || executorID == "") && task.ParentID != "" {
|
|
agentProfileID, executorProfileID, executorID = s.inheritFromParentSession(
|
|
ctx, task.ParentID, agentProfileID, executorProfileID, executorID,
|
|
)
|
|
|
|
// Fall back to workspace defaults for agent profile (subtasks only —
|
|
// regular tasks resolve defaults downstream in the executor layer).
|
|
if agentProfileID == "" {
|
|
workspace, err := s.repo.GetWorkspace(ctx, task.WorkspaceID)
|
|
if err == nil && workspace != nil && workspace.DefaultAgentProfileID != nil && *workspace.DefaultAgentProfileID != "" {
|
|
agentProfileID = *workspace.DefaultAgentProfileID
|
|
}
|
|
}
|
|
if executorID == "" && executorProfileID == "" {
|
|
executorID = models.ExecutorIDWorktree
|
|
}
|
|
}
|
|
|
|
// Fall back to the task's current workflow step when the caller didn't provide one.
|
|
// This ensures sessions created via the kanban card (which doesn't send workflow_step_id)
|
|
// inherit the task's step and participate in workflow events.
|
|
if workflowStepID == "" {
|
|
dbTask, err := s.repo.GetTask(ctx, taskID)
|
|
if err != nil {
|
|
s.logger.Warn("failed to fetch task for workflow step fallback",
|
|
zap.String("task_id", taskID),
|
|
zap.Error(err))
|
|
} else if dbTask.WorkflowStepID != "" {
|
|
workflowStepID = dbTask.WorkflowStepID
|
|
}
|
|
}
|
|
|
|
// Create session entry in database. Office tasks route through
|
|
// EnsureSessionForAgent so runs + advanced-mode reuse one row.
|
|
// prepareSessionForStart also propagates any inherited workspace
|
|
// environment (inherit_parent / shared_group) onto the new session.
|
|
sessionID, err := s.prepareSessionForStart(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID)
|
|
if err != nil {
|
|
s.logger.Error("failed to prepare session",
|
|
zap.String("task_id", taskID),
|
|
zap.Error(err))
|
|
return "", err
|
|
}
|
|
|
|
// Notify the frontend that a new CREATED session exists. The start path
|
|
// transitions through updateTaskSessionState which broadcasts; the prepare
|
|
// path writes the row directly, so without this the per-task session list
|
|
// stays empty until a manual reload.
|
|
s.publishSessionCreatedEvent(ctx, taskID, sessionID, workflowStepID)
|
|
|
|
if launchWorkspace {
|
|
// Launch workspace infrastructure (agentctl) in the background so the WS response
|
|
// returns the session ID immediately. The frontend navigates to the session page
|
|
// and shows preparation progress via executor.prepare.progress WS events.
|
|
go func() {
|
|
bgCtx := context.Background()
|
|
prepExec, launchErr := s.executor.LaunchPreparedSession(bgCtx, task, sessionID, executor.LaunchOptions{AgentProfileID: agentProfileID, ExecutorID: executorID, WorkflowStepID: workflowStepID})
|
|
if launchErr != nil {
|
|
// LaunchAgent failures persist FAILED in the executor. Earlier
|
|
// workspace failures return here and are recorded through the same
|
|
// session-level recovery claim.
|
|
s.handleEarlyMissingPRBranchLaunchFailure(bgCtx, taskID, sessionID, launchErr)
|
|
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(launchErr))
|
|
return
|
|
}
|
|
if prepExec != nil {
|
|
s.ensureSessionPRWatch(bgCtx, taskID, prepExec.SessionID, prepExec.WorktreeBranch)
|
|
}
|
|
}()
|
|
}
|
|
|
|
s.logger.Info("task session prepared",
|
|
zap.String("task_id", taskID),
|
|
zap.String("session_id", sessionID))
|
|
|
|
return sessionID, 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).
|
|
// When skipMessageRecord is true, only the session state is updated (the caller already stored the user message).
|
|
// When planMode is true, plan mode instructions are injected into the prompt and session metadata is set.
|
|
// autoStart marks the launch as having been triggered by an automated path
|
|
// (only consumed when skipMessageRecord is false — callers that store their
|
|
// own message control its metadata directly).
|
|
// references contains validated entity references whose exact server-generated
|
|
// context block may survive first-turn canonicalization.
|
|
//
|
|
//nolint:cyclop,funlen,gocognit // Existing complexity inherited from session-lifecycle handling.
|
|
func (s *Service) StartCreatedSession(
|
|
ctx context.Context,
|
|
taskID, sessionID, agentProfileID, prompt string,
|
|
skipMessageRecord, planMode, autoStart bool,
|
|
attachments []v1.MessageAttachment,
|
|
references []v1.EntityReference,
|
|
) (*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")
|
|
}
|
|
// Accept CREATED (normal) or WAITING_FOR_INPUT (after on_turn_start step transition).
|
|
// When the user sends the first message to a prepared session, on_turn_start may fire
|
|
// and move the step, which sets the session to WAITING_FOR_INPUT before we get here.
|
|
if session.State != models.TaskSessionStateCreated && session.State != models.TaskSessionStateWaitingForInput {
|
|
return nil, fmt.Errorf("session is not in CREATED or WAITING_FOR_INPUT state (current: %s)", session.State)
|
|
}
|
|
|
|
// Use agent profile from request, fall back to session's stored value.
|
|
effectiveProfileID := agentProfileID
|
|
if effectiveProfileID == "" {
|
|
effectiveProfileID = session.AgentProfileID
|
|
}
|
|
|
|
// Resolve the workflow step override / workflow default before the
|
|
// required-profile guard, so a session without its own agent_profile_id
|
|
// inherits the workflow's default agent. resolveEffectiveAgentProfile keeps
|
|
// the caller profile only when neither a step override nor a workflow
|
|
// default applies; either of those overrides a non-empty caller.
|
|
effectiveProfileID = s.resolveEffectiveAgentProfile(ctx, taskID, "", effectiveProfileID)
|
|
|
|
if effectiveProfileID == "" {
|
|
return nil, fmt.Errorf("agent_profile_id is required")
|
|
}
|
|
|
|
// If the workflow step overrode the profile, update the session record in DB
|
|
// so the frontend tab displays the correct agent (it reads session.agent_profile_id).
|
|
if effectiveProfileID != session.AgentProfileID {
|
|
observedState := session.State
|
|
s.logger.Info("updating session agent profile for workflow step override",
|
|
zap.String("session_id", sessionID),
|
|
zap.String("old_profile", session.AgentProfileID),
|
|
zap.String("new_profile", effectiveProfileID))
|
|
session.AgentProfileID = effectiveProfileID
|
|
// Re-resolve the agent profile snapshot so the tab shows the correct agent logo/name.
|
|
// Set a minimal snapshot first so stale data is never persisted if resolution fails.
|
|
session.AgentProfileSnapshot = map[string]interface{}{"id": effectiveProfileID}
|
|
if profileInfo, err := s.agentManager.ResolveAgentProfile(ctx, effectiveProfileID); err != nil {
|
|
s.logger.Warn("failed to resolve agent profile snapshot for workflow step override",
|
|
zap.String("session_id", sessionID),
|
|
zap.String("profile_id", effectiveProfileID),
|
|
zap.Error(err))
|
|
} else if profileInfo != nil {
|
|
session.AgentProfileSnapshot = map[string]interface{}{
|
|
"id": profileInfo.ProfileID,
|
|
"name": profileInfo.ProfileName,
|
|
"agent_id": profileInfo.AgentID,
|
|
"agent_name": profileInfo.AgentName,
|
|
"model": profileInfo.Model,
|
|
"mode": profileInfo.Mode,
|
|
"config_options": maps.Clone(profileInfo.ConfigOptions),
|
|
}
|
|
}
|
|
if err := s.persistFullTaskSessionIfCurrent(ctx, session, observedState); err != nil {
|
|
return nil, fmt.Errorf("persist workflow session profile: %w", err)
|
|
}
|
|
// Tag as workflow-spawned provenance only after the guarded profile
|
|
// write succeeds; a concurrent stop owns a rejected session.
|
|
s.tagSessionAsWorkflowSwitched(ctx, sessionID)
|
|
s.promoteSessionIfTaskHasNoPrimary(ctx, taskID, session)
|
|
}
|
|
|
|
// Transition task state: CREATED → SCHEDULING → (IN_PROGRESS via executor).
|
|
// Office tasks keep their workflow-owned status across run launches.
|
|
isOfficeTask, err := s.lookupOfficeTask(ctx, taskID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to determine office task status: %w", err)
|
|
}
|
|
if isOfficeTask {
|
|
s.logger.Debug("skipping SCHEDULING transition for office task",
|
|
zap.String("task_id", taskID))
|
|
} else if err := s.scheduleTaskForSession(ctx, taskID, sessionID); err != nil {
|
|
s.logger.Warn("failed to update task state to SCHEDULING",
|
|
zap.String("task_id", taskID),
|
|
zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
task, err := s.scheduler.GetTask(ctx, taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
effectivePrompt := prompt
|
|
if effectivePrompt == "" {
|
|
effectivePrompt = task.Description
|
|
}
|
|
|
|
// NOTE: on_turn_start is intentionally NOT processed here.
|
|
// - User-initiated path: dispatchPromptAsync (message_handlers.go) already
|
|
// calls ProcessOnTurnStart before invoking StartCreatedSession via
|
|
// forwardMessageAsPrompt, so on_turn_start has already fired.
|
|
// - Workflow auto-start path: autoStartStepPrompt calls us because the
|
|
// workflow just transitioned us into this step (via on_turn_complete or
|
|
// on_enter). Firing on_turn_start again here cascades the workflow back
|
|
// out before the step's auto-start prompt can be delivered to its agent.
|
|
//
|
|
// However, for the user-initiated path, on_turn_start may have switched the
|
|
// session profile already, in which case the session ID we were called with
|
|
// is now COMPLETED and we need to redirect to the new active session.
|
|
session, err = s.repo.GetTaskSession(ctx, sessionID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to reload session: %w", err)
|
|
}
|
|
if session.State == models.TaskSessionStateCompleted {
|
|
activeSession, activeErr := s.repo.GetActiveTaskSessionByTaskID(ctx, taskID)
|
|
if activeErr != nil || activeSession == nil {
|
|
return nil, fmt.Errorf("session was switched but no active session found: %w", activeErr)
|
|
}
|
|
session = activeSession
|
|
sessionID = activeSession.ID
|
|
effectiveProfileID = activeSession.AgentProfileID
|
|
}
|
|
|
|
// Apply workflow step prompt wrapping and plan mode injection.
|
|
// Called unconditionally so workflow-step prompt composition (prefix/suffix)
|
|
// applies even when plan mode is not requested.
|
|
// Re-read the task after on_turn_start may have changed the workflow step.
|
|
// Ephemeral tasks skip workflow step processing since they have no workflow.
|
|
dbTask, err := s.repo.GetTask(ctx, taskID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to reload task after on_turn_start: %w", err)
|
|
}
|
|
effectivePrompt, planModeActive, promptReferenceContext := s.applyWorkflowAndPlanMode(
|
|
ctx, effectivePrompt, taskID, sessionID, dbTask.WorkflowStepID,
|
|
planMode, task.IsEphemeral, session.IsPassthrough,
|
|
)
|
|
|
|
// Inject config context for config-mode sessions (dedicated settings chat)
|
|
configMode := false
|
|
if cm, ok := session.Metadata["config_mode"].(bool); ok && cm {
|
|
configMode = true
|
|
effectivePrompt = sysprompt.InjectConfigContext(sessionID, effectivePrompt)
|
|
}
|
|
|
|
// Wrap the first prompt with the Kandev MCP system block. See the
|
|
// matching block in startTask for the rationale (DB stores wrapped form;
|
|
// Message.ToAPI strips for display). The injectors canonicalize any upstream
|
|
// wrap from current server state before launch.
|
|
// Passthrough profiles skip the wrap: the prompt is typed straight into the
|
|
// agent CLI's TTY and the user sees it verbatim — they don't want a wall of
|
|
// MCP-tool boilerplate prepended to "hello".
|
|
if (effectivePrompt != "" || len(attachments) > 0) && !session.IsPassthrough {
|
|
referenceContext := EntityReferenceContext(references)
|
|
if isOfficeTask {
|
|
effectivePrompt = sysprompt.InjectOfficeContext(
|
|
taskID, sessionID, effectivePrompt,
|
|
referenceContext, promptReferenceContext,
|
|
)
|
|
} else {
|
|
effectivePrompt = sysprompt.InjectKandevContextWithOptions(taskID, sessionID, effectivePrompt, sysprompt.KandevContextOptions{
|
|
RequiresCompletionSignal: s.WorkflowStepRequiresCompletionSignal(ctx, dbTask.WorkflowStepID),
|
|
IncludeCoordinatorTaskControls: !configMode,
|
|
}, referenceContext, promptReferenceContext)
|
|
}
|
|
}
|
|
|
|
executorID := session.ExecutorID
|
|
|
|
// Cache the raw prompt so a transient-provider-error (529) retry can
|
|
// re-drive this first turn — initial launches bypass PromptTask.
|
|
s.rememberTurnPrompt(sessionID, prompt, "", planMode, attachments)
|
|
|
|
mcpMode := ""
|
|
if isOfficeTask {
|
|
mcpMode = executor.McpModeOffice
|
|
}
|
|
execution, err := s.executor.LaunchPreparedSession(ctx, task, sessionID, executor.LaunchOptions{AgentProfileID: effectiveProfileID, ExecutorID: executorID, Prompt: effectivePrompt, StartAgent: true, McpMode: mcpMode, Attachments: attachments})
|
|
if err != nil {
|
|
// The executor persists LaunchAgent failures. Cover earlier prepared-session
|
|
// failures here; the session-level claim makes either completion order safe.
|
|
s.handleEarlyMissingPRBranchLaunchFailure(ctx, taskID, sessionID, err)
|
|
return nil, err
|
|
}
|
|
|
|
// Record the initial user message and set plan mode metadata after launch.
|
|
// Note: we do NOT set session state here — the executor sets it to STARTING,
|
|
// and event handlers (handleAgentReady) transition it to WAITING_FOR_INPUT.
|
|
s.postLaunchCreated(ctx, taskID, sessionID, effectivePrompt, skipMessageRecord, planModeActive, autoStart, attachments)
|
|
|
|
// Ensure a PR watch exists so the poller can detect PRs created by the agent.
|
|
// PrepareTaskSession may have already created one, but if that goroutine failed
|
|
// or hadn't completed, this guarantees coverage.
|
|
go s.ensureSessionPRWatch(context.Background(), taskID, execution.SessionID, execution.WorktreeBranch)
|
|
|
|
return execution, nil
|
|
}
|
|
|
|
// handleEarlyMissingPRBranchLaunchFailure covers failures that occur before
|
|
// Executor reaches AgentManager.LaunchAgent. Those failures do not run the
|
|
// executor's launch-failure bookkeeping, so this fallback owns the terminal
|
|
// transition and lets the shared persisted claim create guidance once.
|
|
func (s *Service) handleEarlyMissingPRBranchLaunchFailure(
|
|
ctx context.Context,
|
|
taskID, sessionID string,
|
|
launchErr error,
|
|
) {
|
|
if !isMissingBranchError(launchErr) {
|
|
return
|
|
}
|
|
s.recordSessionLaunchFailure(ctx, taskID, sessionID, launchErr)
|
|
}
|
|
|
|
// recordSessionLaunchFailure transitions a still-active session to FAILED,
|
|
// attaches missing-branch guidance only after that CAS succeeds, and updates
|
|
// the task only while the same failed session still owns it.
|
|
func (s *Service) recordSessionLaunchFailure(ctx context.Context, taskID, sessionID string, launchErr error, preloadedSession ...*models.TaskSession) {
|
|
_, changed := s.updateTaskSessionStateWithHook(
|
|
ctx, taskID, sessionID, models.TaskSessionStateFailed, launchErr.Error(), false,
|
|
func() { s.handleSessionLaunchFailed(ctx, taskID, sessionID, "", launchErr) }, preloadedSession...,
|
|
)
|
|
if !changed {
|
|
// A resume may begin from an already FAILED session. Its state CAS is
|
|
// intentionally a no-op, but the persisted claim still lets this newly
|
|
// classified failure publish guidance exactly once. The claim itself is
|
|
// state-guarded, so a concurrently cancelled/completed session cannot
|
|
// receive stale recovery UI.
|
|
if len(preloadedSession) > 0 && preloadedSession[0] != nil && preloadedSession[0].State == models.TaskSessionStateFailed {
|
|
s.handleSessionLaunchFailed(ctx, taskID, sessionID, "", launchErr)
|
|
}
|
|
return
|
|
}
|
|
updated, err := s.taskRepo.UpdateTaskStateIfSessionState(
|
|
ctx, taskID, sessionID, models.TaskSessionStateFailed, v1.TaskStateFailed,
|
|
)
|
|
if err != nil {
|
|
s.logger.Warn("failed to update task state to FAILED after early launch error",
|
|
zap.String("task_id", taskID),
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
if !updated {
|
|
return
|
|
}
|
|
s.processParentChildrenCompletedForTaskState(ctx, taskID, v1.TaskStateFailed)
|
|
}
|
|
|
|
func (s *Service) scheduleTaskForSession(ctx context.Context, taskID, sessionID string) error {
|
|
s.taskRuntimeStateMu.Lock()
|
|
defer s.taskRuntimeStateMu.Unlock()
|
|
|
|
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if session == nil {
|
|
return fmt.Errorf("%w: agent session not found: %s", models.ErrTaskSessionNotFound, sessionID)
|
|
}
|
|
if session.State != models.TaskSessionStateCreated &&
|
|
session.State != models.TaskSessionStateWaitingForInput {
|
|
if isTerminalSessionState(session.State) {
|
|
return &executor.SessionStateSupersededError{SessionID: session.ID, State: session.State}
|
|
}
|
|
return fmt.Errorf("session %s is %s; cannot schedule task", session.ID, session.State)
|
|
}
|
|
return s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateScheduling)
|
|
}
|
|
|
|
func (s *Service) promoteSessionIfTaskHasNoPrimary(ctx context.Context, taskID string, session *models.TaskSession) {
|
|
if session == nil || session.IsPrimary {
|
|
return
|
|
}
|
|
sessions, err := s.repo.ListTaskSessions(ctx, taskID)
|
|
if err != nil {
|
|
s.logger.Warn("failed to inspect task sessions for missing primary",
|
|
zap.String("task_id", taskID),
|
|
zap.String("session_id", session.ID),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
for _, existing := range sessions {
|
|
if existing.IsPrimary {
|
|
return
|
|
}
|
|
}
|
|
if err := s.SetPrimarySession(ctx, session.ID); err != nil {
|
|
s.logger.Warn("failed to promote workflow session as primary",
|
|
zap.String("task_id", taskID),
|
|
zap.String("session_id", session.ID),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
session.IsPrimary = true
|
|
}
|
|
|
|
// postLaunchCreated handles post-launch bookkeeping for a created session:
|
|
// records the initial user message (unless skipped) and sets plan mode metadata.
|
|
// It does NOT modify session state — the executor sets STARTING, and event handlers
|
|
// (handleAgentReady) handle the transition to WAITING_FOR_INPUT.
|
|
// autoStart marks the message as having been created by an automated trigger
|
|
// (preserved through to recordInitialMessage's auto_start metadata tag); the
|
|
// flag is only consumed when skipMessage is false (callers that store their
|
|
// own message control its metadata directly).
|
|
func (s *Service) postLaunchCreated(ctx context.Context, taskID, sessionID, prompt string, skipMessage, planModeActive, autoStart bool, attachments []v1.MessageAttachment) {
|
|
if !skipMessage {
|
|
s.recordInitialMessage(ctx, taskID, sessionID, prompt, planModeActive, autoStart, attachments)
|
|
}
|
|
|
|
if planModeActive {
|
|
sess, err := s.repo.GetTaskSession(ctx, sessionID)
|
|
if err == nil {
|
|
s.setSessionPlanMode(ctx, sess, true)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
// autoStart marks the launch as having been triggered by an automated path
|
|
// (PR/issue/Jira/Linear watch, workflow auto-start) rather than direct user
|
|
// input — the seed prompt is tagged so the github cleanup loop can tell
|
|
// "agent ran on its own" from "user actually engaged".
|
|
func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID string, executorID string, executorProfileID string, priority string, prompt string, workflowStepID string, planMode, autoStart bool, attachments []v1.MessageAttachment) (*executor.TaskExecution, error) {
|
|
return s.startTask(ctx, taskID, agentProfileID, executorID, executorProfileID, priority, prompt, workflowStepID, planMode, autoStart, attachments, nil, nil)
|
|
}
|
|
|
|
// StartTaskWithEnv starts a task and carries launch-scoped environment variables
|
|
// through to the agent runtime. Existing StartTask callers keep the old behavior.
|
|
func (s *Service) StartTaskWithEnv(ctx context.Context, taskID string, agentProfileID string, executorID string, executorProfileID string, priority string, prompt string, workflowStepID string, planMode, autoStart bool, attachments []v1.MessageAttachment, env map[string]string) (*executor.TaskExecution, error) {
|
|
return s.startTask(ctx, taskID, agentProfileID, executorID, executorProfileID, priority, prompt, workflowStepID, planMode, autoStart, attachments, env, nil)
|
|
}
|
|
|
|
// StartTaskWithRoute launches a stable Office identity through a complete
|
|
// concrete execution profile selected by the routing dispatcher.
|
|
//
|
|
// launch carries the Office-built launch context (prompt, env, workflow
|
|
// step, attachments, plan-mode flag) so routed launches behave
|
|
// identically to the legacy launch path for everything except provider
|
|
// selection. Without launch, routed runs would fall back to
|
|
// task.Description and drop role framing / AGENTS.md / wake context.
|
|
//
|
|
// Office-routed launches use autoStart=false: the user kicked off the
|
|
// task; the office layer only chose the provider.
|
|
func (s *Service) StartTaskWithRoute(
|
|
ctx context.Context, taskID, agentProfileID string,
|
|
launch executor.LaunchContext, route executor.RouteOverride,
|
|
) error {
|
|
_, err := s.startTask(ctx, taskID, agentProfileID,
|
|
launch.ExecutorID, launch.ExecutorProfileID, launch.Priority,
|
|
launch.Prompt, launch.WorkflowStepID, launch.PlanMode, false,
|
|
launch.Attachments, launch.Env, &route)
|
|
return err
|
|
}
|
|
|
|
//nolint:cyclop,funlen // launch path threads many orthogonal concerns (workflow-step / agent-profile / office-task / config-mode / route / system-prompt wrapping); splitting it would require shared mutable state across helpers
|
|
func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID string, executorID string, executorProfileID string, priority string, prompt string, workflowStepID string, planMode, autoStart bool, attachments []v1.MessageAttachment, env map[string]string, route *executor.RouteOverride) (*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.String("priority", priority),
|
|
zap.Int("prompt_length", len(prompt)),
|
|
zap.String("workflow_step_id", workflowStepID),
|
|
zap.Bool("plan_mode", planMode),
|
|
zap.Bool("auto_start", autoStart),
|
|
zap.Int("attachments", len(attachments)))
|
|
|
|
isOfficeTask, err := s.lookupOfficeTask(ctx, taskID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to determine office task status: %w", err)
|
|
}
|
|
|
|
// Office tasks do NOT transition through SCHEDULING / IN_PROGRESS on
|
|
// every run. Their lifecycle status (todo / in_review / done /
|
|
// blocked / cancelled) reflects the *user-meaningful workflow*, not
|
|
// the orchestrator's runtime cycle. Runs schedule the *agent*,
|
|
// not the task — the agent's runtime state is shown via the topbar
|
|
// Working spinner + inline session timeline entry. Suppressing the
|
|
// transition here avoids gratuitous flicker (REVIEW → SCHEDULING →
|
|
// IN_PROGRESS → REVIEW for a single comment-reply cycle) and matches
|
|
// the user's mental model.
|
|
if isOfficeTask {
|
|
s.logger.Debug("skipping SCHEDULING transition for office task",
|
|
zap.String("task_id", taskID))
|
|
} else 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))
|
|
}
|
|
|
|
s.moveTaskToWorkflowStep(ctx, taskID, workflowStepID)
|
|
|
|
officeAgentProfileID := ""
|
|
if isOfficeTask {
|
|
officeAgentProfileID = agentProfileID
|
|
}
|
|
|
|
// Resolve the workflow step's agent profile override.
|
|
// The frontend may pass the workspace default profile, but the step may
|
|
// require a different agent (e.g., Codex on "In Progress", Auggie on "Review").
|
|
callerProfileID := agentProfileID
|
|
agentProfileID = s.resolveEffectiveAgentProfile(ctx, taskID, workflowStepID, agentProfileID)
|
|
overrideApplied := agentProfileID != callerProfileID
|
|
if route != nil && route.ExecutionProfileID != "" {
|
|
agentProfileID = route.ExecutionProfileID
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if executorID == "" {
|
|
if v, ok := task.Metadata[models.MetaKeyExecutorID].(string); ok && v != "" {
|
|
executorID = v
|
|
}
|
|
}
|
|
if executorProfileID == "" {
|
|
if v, ok := task.Metadata[models.MetaKeyExecutorProfileID].(string); ok && v != "" {
|
|
executorProfileID = v
|
|
}
|
|
}
|
|
|
|
// Override priority if provided in the request
|
|
if priority != "" {
|
|
task.Priority = priority
|
|
}
|
|
|
|
// Use provided prompt, fall back to task description
|
|
effectivePrompt := prompt
|
|
if effectivePrompt == "" {
|
|
effectivePrompt = task.Description
|
|
}
|
|
|
|
// Prepare session first so we have the sessionID for config context injection.
|
|
// For office tasks, replace the per-launch PrepareSession with the per-(task,
|
|
// agent) EnsureSessionForAgent so runs reuse one row across turns.
|
|
sessionID, err := s.prepareSessionForStart(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// When the workflow step overrode the caller's profile, tag the session
|
|
// for provenance: the profile came from workflow routing rather than
|
|
// direct user selection.
|
|
if overrideApplied {
|
|
s.tagSessionAsWorkflowSwitched(ctx, sessionID)
|
|
}
|
|
|
|
// Passthrough sessions skip both the workflow-prompt "@name" expansion below
|
|
// and the Kandev MCP wrap further down: the prompt is typed straight into
|
|
// the agent CLI's TTY and the user sees it verbatim — they don't want a
|
|
// wall of MCP-tool boilerplate or a hidden expansion block prepended to
|
|
// "hello". Use the session snapshot, not a live profile lookup, so a
|
|
// mid-run profile edit cannot change wrap behavior. Looked up once here and
|
|
// reused below so we don't hit GetTaskSession twice for the same fact.
|
|
// If the lookup fails, resolveIsPassthroughForLaunch fails safe by
|
|
// treating the session as passthrough: skipping the wrap/expansion is
|
|
// strictly less harmful than leaking hidden <kandev-system> content into
|
|
// a real passthrough session's PTY.
|
|
isPassthrough := s.resolveIsPassthroughForLaunch(ctx, sessionID)
|
|
|
|
effectivePrompt, planModeActive, promptReferenceContext := s.applyWorkflowAndPlanMode(
|
|
ctx, effectivePrompt, task.ID, sessionID, workflowStepID,
|
|
planMode, task.IsEphemeral, isPassthrough,
|
|
)
|
|
|
|
// Inject config context for config-mode sessions (dedicated settings chat)
|
|
configMode := false
|
|
if cm, ok := task.Metadata["config_mode"].(bool); ok && cm {
|
|
configMode = true
|
|
effectivePrompt = sysprompt.InjectConfigContext(sessionID, effectivePrompt)
|
|
}
|
|
|
|
// Wrap the first prompt with the Kandev MCP system block (task/session IDs +
|
|
// tool list). Done at the orchestrator layer so recordInitialMessage persists
|
|
// the wrapped form to task_session_messages; Message.ToAPI strips the
|
|
// <kandev-system> block for the UI bubble and exposes it via raw_content.
|
|
// Only the first launch carries this wrap — follow-up prompts and resumes
|
|
// rely on the agent CLI's conversation history retaining it.
|
|
// Idempotent: upstream call sites (wsAddMessage on CREATED sessions,
|
|
// recordAutoStartMessage) wrap before recording the user message so the DB
|
|
// row carries the block; the mode-aware injector canonicalizes it instead of
|
|
// double-wrapping.
|
|
skipKandevMCPWrap := isPassthrough
|
|
// `task` here is *v1.Task from the scheduler, which does NOT carry the
|
|
// orchestrator's WorkflowStepID — go through the task-ID variant so the
|
|
// repo lookup pulls the canonical step. Using the workflowStepID parameter
|
|
// directly is wrong because it can be empty on manual user-initiated starts
|
|
// while the task is already bound to a signal-gated step in the DB.
|
|
if (effectivePrompt != "" || len(attachments) > 0) && !skipKandevMCPWrap {
|
|
if isOfficeTask {
|
|
effectivePrompt = sysprompt.InjectOfficeContext(
|
|
task.ID, sessionID, effectivePrompt, promptReferenceContext,
|
|
)
|
|
} else {
|
|
effectivePrompt = sysprompt.InjectKandevContextWithOptions(task.ID, sessionID, effectivePrompt, sysprompt.KandevContextOptions{
|
|
RequiresCompletionSignal: s.StepRequiresCompletionSignal(ctx, task.ID),
|
|
IncludeCoordinatorTaskControls: !configMode,
|
|
}, promptReferenceContext)
|
|
}
|
|
}
|
|
|
|
// Office tasks restrict the MCP toolset: kanban tools (move/update/list
|
|
// task, etc.) are excluded because office agents call those via the
|
|
// kandev CLI ($KANDEV_CLI). See docs/specs/office-agent-cli/spec.md.
|
|
mcpMode := ""
|
|
if isOfficeTask {
|
|
mcpMode = executor.McpModeOffice
|
|
}
|
|
|
|
// Cache the raw prompt so a transient-provider-error (529) retry can
|
|
// re-drive this first turn — initial launches bypass PromptTask.
|
|
s.rememberTurnPrompt(sessionID, prompt, "", planMode, attachments)
|
|
|
|
execution, err := s.executor.LaunchPreparedSession(ctx, task, sessionID, executor.LaunchOptions{
|
|
AgentProfileID: agentProfileID,
|
|
OfficeAgentProfileID: officeAgentProfileID,
|
|
ExecutorID: executorID,
|
|
Prompt: effectivePrompt,
|
|
WorkflowStepID: workflowStepID,
|
|
StartAgent: true,
|
|
McpMode: mcpMode,
|
|
Attachments: attachments,
|
|
Env: env,
|
|
RouteOverride: route,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.postLaunchStart(ctx, taskID, execution, effectivePrompt, planModeActive || configMode, planModeActive, autoStart, attachments)
|
|
|
|
// 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
|
|
}
|
|
|
|
// isOfficeTask returns the repository's canonical Office ownership projection.
|
|
func (s *Service) isOfficeTask(ctx context.Context, taskID string) bool {
|
|
isOfficeTask, err := s.lookupOfficeTask(ctx, taskID)
|
|
return err == nil && isOfficeTask
|
|
}
|
|
|
|
func (s *Service) lookupOfficeTask(ctx context.Context, taskID string) (bool, error) {
|
|
dbTask, err := s.repo.GetTask(ctx, taskID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return dbTask != nil && dbTask.IsFromOffice, nil
|
|
}
|
|
|
|
// prepareSessionForStart creates the session for a launch and propagates any
|
|
// inherited workspace environment onto it.
|
|
//
|
|
// The propagation (inherit_parent / shared_group) lives here rather than only
|
|
// in PrepareTaskSession so every launch entry point inherits consistently —
|
|
// including the direct start path (startTask), which MCP-created subtasks reach
|
|
// via auto-start. Without this, an inherit_parent subtask launched through
|
|
// startTask would provision a fresh worktree instead of reusing the parent's.
|
|
// propagateInheritedEnvironment is a no-op for tasks without a workspace policy.
|
|
func (s *Service) prepareSessionForStart(
|
|
ctx context.Context, task *v1.Task,
|
|
agentProfileID, executorID, executorProfileID, workflowStepID string,
|
|
) (string, error) {
|
|
sessionID, err := s.createStartSession(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
s.propagateInheritedEnvironment(ctx, task, sessionID)
|
|
return sessionID, nil
|
|
}
|
|
|
|
// resolveIsPassthroughForLaunch reloads the session snapshot to decide
|
|
// whether this launch is a passthrough session (skip the Kandev MCP wrap and
|
|
// the "@name" prompt-reference expansion). If the reload fails, this fails
|
|
// safe by treating the session as passthrough: skipping the wrap/expansion is
|
|
// strictly less harmful than leaking hidden <kandev-system> content into a
|
|
// real passthrough session's PTY.
|
|
func (s *Service) resolveIsPassthroughForLaunch(ctx context.Context, sessionID string) bool {
|
|
launchSession, sessErr := s.repo.GetTaskSession(ctx, sessionID)
|
|
if sessErr != nil {
|
|
s.logger.Warn("failed to reload session for passthrough check; defaulting to passthrough (skip hidden-content injection) as the safer failure mode",
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(sessErr))
|
|
return true
|
|
}
|
|
return launchSession.IsPassthrough
|
|
}
|
|
|
|
// createStartSession picks the right session-creation path for the task:
|
|
// Office tasks with an assignee use the per-(task, agent) EnsureSessionForAgent
|
|
// (so runs reuse one row across turns); kanban / quick-chat fall through to
|
|
// the per-launch PrepareSession used since day one.
|
|
func (s *Service) createStartSession(
|
|
ctx context.Context, task *v1.Task,
|
|
agentProfileID, executorID, executorProfileID, workflowStepID string,
|
|
) (string, error) {
|
|
dbTask, err := s.repo.GetTask(ctx, task.ID)
|
|
if err == nil && dbTask != nil && dbTask.IsFromOffice && dbTask.AssigneeAgentProfileID != "" {
|
|
session, ensureErr := s.executor.EnsureSessionForAgent(
|
|
ctx, task, dbTask.AssigneeAgentProfileID, agentProfileID, executorID, executorProfileID,
|
|
)
|
|
if ensureErr != nil {
|
|
return "", ensureErr
|
|
}
|
|
return session.ID, nil
|
|
}
|
|
return s.executor.PrepareSession(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID)
|
|
}
|
|
|
|
// moveTaskToWorkflowStep moves a task to the target workflow step if provided and different from current.
|
|
func (s *Service) moveTaskToWorkflowStep(ctx context.Context, taskID, workflowStepID string) {
|
|
if workflowStepID == "" {
|
|
return
|
|
}
|
|
dbTask, err := s.repo.GetTask(ctx, taskID)
|
|
if err != nil || dbTask.WorkflowStepID == workflowStepID {
|
|
return
|
|
}
|
|
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))
|
|
return
|
|
}
|
|
s.publishTaskUpdated(ctx, dbTask)
|
|
}
|
|
|
|
// resolveEffectiveAgentProfile checks whether the task's workflow step overrides
|
|
// the agent profile. If the step (or workflow default) specifies a different
|
|
// profile, that profile is returned instead of the caller-provided one.
|
|
// This ensures the initial task start uses the step's agent — not just the
|
|
// workspace default the frontend sends.
|
|
func (s *Service) resolveEffectiveAgentProfile(ctx context.Context, taskID, workflowStepID, callerProfileID string) string {
|
|
if s.workflowStepGetter == nil {
|
|
s.logger.Debug("resolveEffectiveAgentProfile: no workflowStepGetter, using caller profile",
|
|
zap.String("task_id", taskID),
|
|
zap.String("caller_profile", callerProfileID))
|
|
return callerProfileID
|
|
}
|
|
|
|
// Determine the effective step ID: explicit param > task's current step.
|
|
effectiveStepID := workflowStepID
|
|
if effectiveStepID == "" {
|
|
dbTask, err := s.repo.GetTask(ctx, taskID)
|
|
if err != nil {
|
|
s.logger.Debug("resolveEffectiveAgentProfile: failed to load task from DB",
|
|
zap.String("task_id", taskID),
|
|
zap.Error(err))
|
|
return callerProfileID
|
|
}
|
|
s.logger.Debug("resolveEffectiveAgentProfile: loaded task from DB",
|
|
zap.String("task_id", taskID),
|
|
zap.String("db_workflow_step_id", dbTask.WorkflowStepID))
|
|
if dbTask.WorkflowStepID == "" {
|
|
s.logger.Debug("resolveEffectiveAgentProfile: task has no workflow step, using caller profile",
|
|
zap.String("task_id", taskID))
|
|
return callerProfileID
|
|
}
|
|
effectiveStepID = dbTask.WorkflowStepID
|
|
}
|
|
|
|
step, err := s.workflowStepGetter.GetStep(ctx, effectiveStepID)
|
|
if err != nil || step == nil {
|
|
s.logger.Debug("resolveEffectiveAgentProfile: failed to load step",
|
|
zap.String("task_id", taskID),
|
|
zap.String("step_id", effectiveStepID),
|
|
zap.Error(err))
|
|
return callerProfileID
|
|
}
|
|
|
|
s.logger.Debug("resolveEffectiveAgentProfile: loaded step",
|
|
zap.String("task_id", taskID),
|
|
zap.String("step_id", effectiveStepID),
|
|
zap.String("step_name", step.Name),
|
|
zap.String("step_agent_profile_id", step.AgentProfileID),
|
|
zap.String("step_workflow_id", step.WorkflowID))
|
|
|
|
stepProfile := s.resolveStepAgentProfile(ctx, step)
|
|
s.logger.Debug("resolveEffectiveAgentProfile: resolved step profile",
|
|
zap.String("task_id", taskID),
|
|
zap.String("step_profile", stepProfile),
|
|
zap.String("caller_profile", callerProfileID))
|
|
|
|
if stepProfile == "" || stepProfile == callerProfileID {
|
|
return callerProfileID
|
|
}
|
|
|
|
s.logger.Info("overriding agent profile with workflow step profile",
|
|
zap.String("task_id", taskID),
|
|
zap.String("step_id", effectiveStepID),
|
|
zap.String("step_name", step.Name),
|
|
zap.String("caller_profile", callerProfileID),
|
|
zap.String("step_profile", stepProfile))
|
|
return stepProfile
|
|
}
|
|
|
|
// postLaunchStart records the initial message and sets plan mode after a successful launch.
|
|
func (s *Service) postLaunchStart(ctx context.Context, taskID string, execution *executor.TaskExecution, prompt string, recordPlanMode, setPlanMode, autoStart bool, attachments []v1.MessageAttachment) {
|
|
if execution.SessionID != "" {
|
|
s.recordInitialMessage(ctx, taskID, execution.SessionID, prompt, recordPlanMode, autoStart, attachments)
|
|
|
|
if setPlanMode {
|
|
session, err := s.repo.GetTaskSession(ctx, execution.SessionID)
|
|
if err == nil {
|
|
s.setSessionPlanMode(ctx, session, true)
|
|
}
|
|
}
|
|
|
|
// Persist prepare_result using SetSessionMetadataKey (json_set) which
|
|
// atomically sets ONE key without touching others. This avoids the
|
|
// read-modify-write race where UpdateSessionMetadata clobbers plan_mode.
|
|
if execution.PrepareResult != nil && execution.PrepareResult.Success {
|
|
pr := lifecycle.SerializePrepareResult(execution.PrepareResult)
|
|
if err := s.repo.SetSessionMetadataKey(ctx, execution.SessionID, "prepare_result", pr); err != nil {
|
|
s.logger.Warn("failed to persist prepare_result",
|
|
zap.String("session_id", execution.SessionID), zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
go s.ensureSessionPRWatch(context.Background(), taskID, execution.SessionID, execution.WorktreeBranch)
|
|
}
|
|
|
|
// 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).
|
|
// For ephemeral tasks (quick chat), workflow step processing is skipped since they have no workflow.
|
|
func (s *Service) applyWorkflowAndPlanMode(ctx context.Context, prompt string, taskID string, sessionID string, workflowStepID string, planMode bool, isEphemeral bool, isPassthrough bool) (string, bool, string) {
|
|
effectivePrompt := prompt
|
|
promptReferenceContext := ""
|
|
|
|
stepHasPlanMode := false
|
|
// Skip workflow step prompt injection for ephemeral tasks - they don't have workflows
|
|
if !isEphemeral && 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.HasOnEnterAction(wfmodels.OnEnterEnablePlanMode)
|
|
effectivePrompt, promptReferenceContext = s.buildWorkflowPromptWithContext(
|
|
ctx, effectivePrompt, step, taskID, sessionID, isPassthrough,
|
|
)
|
|
}
|
|
}
|
|
|
|
if planMode && !stepHasPlanMode {
|
|
var parts []string
|
|
parts = append(parts, sysprompt.Wrap(sysprompt.DefaultPlanPrefix()))
|
|
parts = append(parts, effectivePrompt)
|
|
effectivePrompt = strings.Join(parts, "\n\n")
|
|
}
|
|
|
|
return effectivePrompt, planMode || stepHasPlanMode, promptReferenceContext
|
|
}
|
|
|
|
// backfillInitialUserMessageIfMissing records the task's description as the
|
|
// first user message when the session has no messages at all. This covers the
|
|
// edge case where the initial launch failed before recordInitialMessage was
|
|
// called (postLaunchStart only runs after a successful LaunchAgent), leaving
|
|
// the chat empty even though the task carries the prompt the user originally
|
|
// typed.
|
|
//
|
|
// The check requires *zero* messages, not just zero user messages: if any
|
|
// agent output already exists from a partial prior run, the backfilled
|
|
// message would land at the bottom of the chat (CreateMessage stamps
|
|
// CreatedAt=now), which is worse than leaving the chat alone.
|
|
func (s *Service) backfillInitialUserMessageIfMissing(ctx context.Context, taskID, sessionID, prompt string) {
|
|
if prompt == "" || s.messageCreator == nil {
|
|
return
|
|
}
|
|
msgs, err := s.repo.ListMessages(ctx, sessionID)
|
|
if err != nil {
|
|
s.logger.Warn("backfill initial message: list messages failed",
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
if len(msgs) > 0 {
|
|
return
|
|
}
|
|
s.recordInitialMessage(ctx, taskID, sessionID, prompt, false, false, nil)
|
|
}
|
|
|
|
// recordInitialMessage creates the initial user message and updates session state after launch.
|
|
// autoStart marks the message as having been created by an automated trigger
|
|
// (workflow auto-start, PR/issue watch, Jira/Linear integration) so cleanup
|
|
// logic can distinguish "agent ran on its own" from "user actually engaged".
|
|
func (s *Service) recordInitialMessage(ctx context.Context, taskID, sessionID, prompt string, planModeActive, autoStart bool, attachments []v1.MessageAttachment) {
|
|
if s.messageCreator != nil && (prompt != "" || len(attachments) > 0) {
|
|
meta := NewUserMessageMeta().WithPlanMode(planModeActive).WithAutoStart(autoStart).WithAttachments(attachments)
|
|
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.
|
|
// If step.Prompt contains {{task_prompt}}, it is replaced with the base prompt.
|
|
// Otherwise, step.Prompt fully replaces the base prompt.
|
|
// If the step has enable_plan_mode in on_enter events, plan mode prefix is also prepended.
|
|
// Only true internal instructions are wrapped in <kandev-system> tags so they can be stripped from the visible chat.
|
|
func (s *Service) buildWorkflowPrompt(ctx context.Context, basePrompt string, step *wfmodels.WorkflowStep, taskID string, sessionID string, isPassthrough bool) string {
|
|
prompt, _ := s.buildWorkflowPromptWithContext(ctx, basePrompt, step, taskID, sessionID, isPassthrough)
|
|
return prompt
|
|
}
|
|
|
|
func (s *Service) buildWorkflowPromptWithContext(
|
|
ctx context.Context,
|
|
basePrompt string,
|
|
step *wfmodels.WorkflowStep,
|
|
taskID string,
|
|
sessionID string,
|
|
isPassthrough bool,
|
|
) (string, string) {
|
|
_ = sessionID
|
|
var parts []string
|
|
|
|
// Build the prompt from step.Prompt template and base prompt
|
|
if step.Prompt != "" {
|
|
interpolatedPrompt := sysprompt.InterpolatePlaceholders(step.Prompt, taskID)
|
|
if strings.Contains(interpolatedPrompt, "{{task_prompt}}") {
|
|
// Replace placeholder with base prompt
|
|
combined := strings.Replace(interpolatedPrompt, "{{task_prompt}}", basePrompt, 1)
|
|
parts = append(parts, combined)
|
|
} else {
|
|
// A step prompt without {{task_prompt}} is treated as the full visible prompt.
|
|
parts = append(parts, interpolatedPrompt)
|
|
}
|
|
} else {
|
|
// No step prompt, just use base prompt
|
|
parts = append(parts, basePrompt)
|
|
}
|
|
|
|
joined := strings.Join(parts, "\n\n")
|
|
return s.expandPromptReferencesWithContext(ctx, joined, isPassthrough)
|
|
}
|
|
|
|
// expandPromptReferences resolves "@name" saved-prompt references in prompt
|
|
// via the configured PromptReferenceExpander. When no expander is set, prompt
|
|
// is returned unchanged. Passthrough sessions skip expansion entirely: the
|
|
// prompt is written raw to a PTY with no <kandev-system> stripping step, so
|
|
// the expansion's hidden wrapper block would be typed into the terminal
|
|
// verbatim instead of staying hidden.
|
|
func (s *Service) expandPromptReferences(ctx context.Context, prompt string, isPassthrough bool) string {
|
|
expanded, _ := s.expandPromptReferencesWithContext(ctx, prompt, isPassthrough)
|
|
return expanded
|
|
}
|
|
|
|
func (s *Service) expandPromptReferencesWithContext(
|
|
ctx context.Context,
|
|
prompt string,
|
|
isPassthrough bool,
|
|
) (string, string) {
|
|
if s.promptExpander == nil || isPassthrough {
|
|
return prompt, ""
|
|
}
|
|
var zapLogger *zap.Logger
|
|
if s.logger != nil {
|
|
zapLogger = s.logger.Zap()
|
|
}
|
|
return s.promptExpander.AppendReferenceExpansionsWithContext(ctx, prompt, zapLogger)
|
|
}
|
|
|
|
// 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) &&
|
|
session.State != models.TaskSessionStateCancelled &&
|
|
session.State != models.TaskSessionStateFailed {
|
|
// Executor record is required for non-terminal sessions. For cancelled/failed sessions
|
|
// the record may already have been cleaned up before the user clicked Resume — allow it.
|
|
return nil, fmt.Errorf("session is not resumable: no executor record")
|
|
}
|
|
if err := validateSessionWorktrees(session); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Completed sessions cannot be restarted — they require a new session.
|
|
// Failed and cancelled sessions keep the resume token so the relaunched
|
|
// agent continues the previous conversation (via ACP session/load for
|
|
// native-resume agents, or --resume on CLI). Users who want a fresh start
|
|
// after a failure can invoke RecoverSession with action="fresh_start".
|
|
if session.State == models.TaskSessionStateCompleted {
|
|
return nil, fmt.Errorf("session is completed and cannot be resumed; create a new session instead")
|
|
}
|
|
|
|
// Bury any open turns from the previous run before relaunching. Without
|
|
// this, startTurnForSession adopts the orphan on the next prompt and the
|
|
// UI's running timer counts from the orphan's started_at — which can be
|
|
// hours or days ago. Zero-duration completion keeps analytics honest about
|
|
// the dead window. A failure here shouldn't block the resume; the next
|
|
// completeTurnForSession sweep will mop up.
|
|
//
|
|
// Drop the activeTurns cache entry first, mirroring completeTurnForSession.
|
|
// Otherwise a stale entry would let getActiveTurnID return the now-abandoned
|
|
// turn ID without re-reading the DB, tagging new messages to a closed turn.
|
|
if s.turnService != nil {
|
|
s.activeTurns.Delete(sessionID)
|
|
if err := s.turnService.AbandonOpenTurns(ctx, sessionID); err != nil {
|
|
s.logger.Warn("failed to abandon orphan turns on resume; continuing",
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(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)
|
|
var readySession *models.TaskSession
|
|
if err != nil {
|
|
// If the execution is already running (duplicate resume request), return it as success.
|
|
if errors.Is(err, executor.ErrExecutionAlreadyRunning) {
|
|
execution, readySession, err = s.recoverAlreadyRunningResume(resumeCtx, taskID, sessionID)
|
|
if err != nil && errors.Is(err, ErrAgentNotReadyForPrompt) {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err != nil {
|
|
// Task was archived while the resume was in flight — return the error
|
|
// without mutating task/session state (archive already handled cleanup).
|
|
// Check both the sentinel (early rejection) and re-read the task to catch
|
|
// the race where archive completed after the executor's archived check.
|
|
if errors.Is(err, executor.ErrTaskArchived) {
|
|
return nil, err
|
|
}
|
|
if task, taskErr := s.repo.GetTask(resumeCtx, taskID); taskErr == nil && task != nil && task.ArchivedAt != nil {
|
|
return nil, executor.ErrTaskArchived
|
|
}
|
|
// Use resumeCtx (WithoutCancel) for the failure-recording writes too —
|
|
// if the caller's ctx was already cancelled (e.g. WS client navigated
|
|
// away), the SessionStateFailed and TaskStateFailed updates would
|
|
// themselves fail with "context canceled" and leave the task stuck
|
|
// looking "running" forever.
|
|
// ResumeSession launches the workspace directly rather than through
|
|
// LaunchPreparedSession. Record this failure with the same state CAS,
|
|
// persisted recovery claim, and archive-safe task CAS as early launch.
|
|
s.recordSessionLaunchFailure(resumeCtx, taskID, sessionID, err, session)
|
|
return nil, err
|
|
}
|
|
}
|
|
if readySession == nil {
|
|
readySession, err = s.waitForResumedSessionReady(resumeCtx, sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
execution.SessionState = v1.TaskSessionState(readySession.State)
|
|
|
|
// Backfill the initial user message when a prior failed launch never got
|
|
// to recordInitialMessage. Without this, the resume can succeed and the
|
|
// agent starts replying, but the chat shows agent output with no user
|
|
// prompt above it.
|
|
//
|
|
// We use task.Description (the raw user input) rather than the
|
|
// workflow-effective prompt produced by applyWorkflowAndPlanMode. The
|
|
// effective prompt may carry a plan-mode prefix or be templated through a
|
|
// workflow step, but reconstructing the exact prompt the original launch
|
|
// sent to the agent is brittle (workflow state may have advanced since).
|
|
// Surfacing the raw description is intentionally conservative: it shows
|
|
// what the user actually typed, which is what they expect to see in chat.
|
|
if task, taskErr := s.repo.GetTask(resumeCtx, taskID); taskErr != nil {
|
|
s.logger.Warn("resume: failed to load task for initial message backfill",
|
|
zap.String("task_id", taskID),
|
|
zap.Error(taskErr))
|
|
} else if task != nil {
|
|
s.backfillInitialUserMessageIfMissing(resumeCtx, taskID, sessionID, task.Description)
|
|
}
|
|
|
|
s.logger.Debug("task session resumed and ready for input",
|
|
zap.String("task_id", taskID),
|
|
zap.String("session_id", sessionID))
|
|
|
|
go s.ensureSessionPRWatch(context.Background(), taskID, execution.SessionID, execution.WorktreeBranch)
|
|
|
|
return execution, nil
|
|
}
|
|
|
|
func (s *Service) waitForResumedSessionReady(ctx context.Context, sessionID string) (*models.TaskSession, error) {
|
|
return s.waitForSessionAndAgentReady(ctx, sessionID, "after resume")
|
|
}
|
|
|
|
func (s *Service) recoverAlreadyRunningResume(
|
|
resumeCtx context.Context,
|
|
taskID string,
|
|
sessionID string,
|
|
) (*executor.TaskExecution, *models.TaskSession, error) {
|
|
existing, ok := s.executor.GetExecutionBySession(sessionID)
|
|
if !ok || existing == nil {
|
|
return nil, nil, executor.ErrExecutionAlreadyRunning
|
|
}
|
|
|
|
readySession, waitErr := s.waitForResumedSessionReady(resumeCtx, sessionID)
|
|
if waitErr == nil {
|
|
existing.SessionState = v1.TaskSessionState(readySession.State)
|
|
return existing, readySession, nil
|
|
}
|
|
if !errors.Is(waitErr, ErrAgentNotReadyForPrompt) {
|
|
return nil, nil, waitErr
|
|
}
|
|
|
|
if err := s.reapPromptUnreadyExecution(resumeCtx, sessionID, waitErr); err != nil {
|
|
return nil, nil, fmt.Errorf("%w: failed to stop prompt-unready execution: %w", ErrAgentNotReadyForPrompt, err)
|
|
}
|
|
|
|
session, err := s.repo.GetTaskSession(resumeCtx, sessionID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to reload session after prompt-readiness recovery: %w", err)
|
|
}
|
|
if session.TaskID != taskID {
|
|
return nil, nil, fmt.Errorf("task session does not belong to task")
|
|
}
|
|
|
|
execution, err := s.executor.ResumeSession(resumeCtx, session, true)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
readySession, err = s.waitForResumedSessionReady(resumeCtx, sessionID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
execution.SessionState = v1.TaskSessionState(readySession.State)
|
|
return execution, readySession, nil
|
|
}
|
|
|
|
func (s *Service) waitForSessionAndAgentReady(ctx context.Context, sessionID, waitContext string) (*models.TaskSession, error) {
|
|
if err := s.waitForSessionReady(ctx, sessionID); err != nil {
|
|
return nil, fmt.Errorf("session not ready %s: %w", waitContext, err)
|
|
}
|
|
if err := s.waitForAgentPromptReady(ctx, sessionID); err != nil {
|
|
return nil, fmt.Errorf("agent not ready %s: %w", waitContext, err)
|
|
}
|
|
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to reload session %s: %w", waitContext, err)
|
|
}
|
|
return session, nil
|
|
}
|
|
|
|
func (s *Service) waitForStartingSessionPromptable(ctx context.Context, taskID, sessionID string) (*models.TaskSession, error) {
|
|
s.logger.Debug("waiting for starting session to become promptable",
|
|
zap.String("task_id", taskID),
|
|
zap.String("session_id", sessionID))
|
|
session, err := s.waitForSessionAndAgentReady(ctx, sessionID, "for prompt")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.checkSessionPromptable(taskID, sessionID, session.State); err != nil {
|
|
return nil, err
|
|
}
|
|
return session, 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")
|
|
}
|
|
|
|
step, err := s.workflowStepGetter.GetStep(ctx, workflowStepID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get workflow step: %w", err)
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
dbTask, err := s.repo.GetTask(ctx, taskID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get task: %w", err)
|
|
}
|
|
|
|
if session.ReviewStatus == models.ReviewStatusPending {
|
|
return fmt.Errorf("session is pending approval - use Approve button to proceed or send a message to request changes")
|
|
}
|
|
|
|
s.advanceTaskWorkflowStep(ctx, dbTask, workflowStepID, session)
|
|
|
|
effectivePrompt := s.buildWorkflowPrompt(ctx, dbTask.Description, step, taskID, sessionID, session.IsPassthrough)
|
|
|
|
if err := s.ensureSessionRunning(ctx, sessionID, session); err != nil {
|
|
return err
|
|
}
|
|
|
|
stepPlanMode := step.HasOnEnterAction(wfmodels.OnEnterEnablePlanMode)
|
|
_, err = s.PromptTask(ctx, taskID, sessionID, effectivePrompt, "", stepPlanMode, nil, false)
|
|
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", stepPlanMode))
|
|
|
|
return nil
|
|
}
|
|
|
|
// advanceTaskWorkflowStep updates the task's workflow step and clears session review status if the step changed.
|
|
func (s *Service) advanceTaskWorkflowStep(ctx context.Context, task *models.Task, workflowStepID string, session *models.TaskSession) {
|
|
if task.WorkflowStepID == workflowStepID {
|
|
return
|
|
}
|
|
task.WorkflowStepID = workflowStepID
|
|
task.UpdatedAt = time.Now().UTC()
|
|
if err := s.repo.UpdateTask(ctx, task); err != nil {
|
|
s.logger.Warn("failed to update task workflow step",
|
|
zap.String("task_id", task.ID),
|
|
zap.String("workflow_step_id", workflowStepID),
|
|
zap.Error(err))
|
|
}
|
|
if session.ReviewStatus != models.ReviewStatusNone {
|
|
if err := s.repo.UpdateSessionReviewStatus(ctx, session.ID, ""); err != nil {
|
|
s.logger.Warn("failed to clear session review status",
|
|
zap.String("session_id", session.ID),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// ensureSessionRunning resumes the session if the agent is not actually running.
|
|
// After lazy recovery, a session may be in WAITING_FOR_INPUT with no agent process;
|
|
// this function detects that case and triggers a resume.
|
|
func (s *Service) ensureSessionRunning(ctx context.Context, sessionID string, session *models.TaskSession) error {
|
|
// Check if agent is genuinely running (in-memory execution store, not just DB state)
|
|
if exec, ok := s.executor.GetExecutionBySession(sessionID); ok && exec != nil {
|
|
s.recoverAgentPromptStreamIfNeeded(ctx, sessionID)
|
|
if err := s.waitForAgentPromptReady(ctx, sessionID); err != nil {
|
|
if !errors.Is(err, ErrAgentNotReadyForPrompt) {
|
|
return err
|
|
}
|
|
recoveryCtx := context.WithoutCancel(ctx)
|
|
if stopErr := s.reapPromptUnreadyExecution(recoveryCtx, sessionID, err); stopErr != nil {
|
|
return fmt.Errorf("failed to stop prompt-unready execution: %w", stopErr)
|
|
}
|
|
refreshed, refreshErr := s.repo.GetTaskSession(recoveryCtx, sessionID)
|
|
if refreshErr != nil {
|
|
return fmt.Errorf("failed to reload session after prompt-readiness recovery: %w", refreshErr)
|
|
}
|
|
session = refreshed
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
s.logger.Debug("agent not running for session, attempting resume",
|
|
zap.String("session_id", sessionID),
|
|
zap.String("session_state", string(session.State)))
|
|
|
|
// If the session is in CREATED state with an existing workspace (executors_running
|
|
// row exists), the workspace was prepared but the agent was never started. Use
|
|
// LaunchPreparedSession which routes to startAgentOnExistingWorkspace to reuse
|
|
// the workspace rather than ResumeSession which tries a full LaunchAgent and
|
|
// conflicts with the existing execution.
|
|
if session.State == models.TaskSessionStateCreated {
|
|
hasRunning, _ := s.repo.HasExecutorRunningRow(ctx, sessionID)
|
|
if hasRunning {
|
|
return s.startAgentOnPreparedWorkspace(ctx, sessionID, session)
|
|
}
|
|
}
|
|
|
|
running, err := s.repo.GetExecutorRunningBySessionID(ctx, sessionID)
|
|
if err != nil || running == nil {
|
|
return fmt.Errorf("session is not resumable: no executor record (state: %s)", session.State)
|
|
}
|
|
|
|
if err := validateSessionWorktrees(session); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Use context.WithoutCancel to prevent WebSocket request timeout from canceling the resume.
|
|
// The lifecycle layer publishes events.AgentBootReady (handled by handleAgentBootReady)
|
|
// when the agent's ACP session initializes — that's what unblocks waitForSessionReady,
|
|
// no flag-tracking needed.
|
|
resumeCtx := context.WithoutCancel(ctx)
|
|
if _, err = s.executor.ResumeSession(resumeCtx, session, true); err != nil {
|
|
if errors.Is(err, executor.ErrExecutionAlreadyRunning) {
|
|
s.recoverAgentPromptStreamIfNeeded(resumeCtx, sessionID)
|
|
if readyErr := s.waitForAgentPromptReady(resumeCtx, sessionID); readyErr != nil {
|
|
return fmt.Errorf("agent not ready after resume race: %w", readyErr)
|
|
}
|
|
return nil
|
|
}
|
|
s.updateTaskSessionState(resumeCtx, session.TaskID, sessionID, models.TaskSessionStateFailed, err.Error(), false, session)
|
|
if stateErr := s.taskRepo.UpdateTaskState(resumeCtx, session.TaskID, v1.TaskStateFailed); stateErr != nil {
|
|
s.logger.Warn("failed to update task state to FAILED after session ensure resume error",
|
|
zap.String("task_id", session.TaskID),
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(stateErr))
|
|
} else {
|
|
s.processParentChildrenCompletedForTaskState(resumeCtx, session.TaskID, v1.TaskStateFailed)
|
|
}
|
|
return fmt.Errorf("failed to resume session: %w", err)
|
|
}
|
|
|
|
// ResumeSession launches the agent asynchronously. Wait for it to finish
|
|
// initializing before returning, so the caller can send a prompt immediately.
|
|
//
|
|
// Use resumeCtx (context.WithoutCancel) here too, not the original ctx: the
|
|
// resume itself is already shielded from the caller's request deadline (see
|
|
// comment above), but a short-lived caller context (WebSocket request,
|
|
// MCP tool-call timeout, etc.) would otherwise still abort these polling
|
|
// waits early with a misleading "context deadline exceeded" even though the
|
|
// resume is progressing fine in the background and would succeed within its
|
|
// own bounded timeouts (waitForSessionReady's 90s, waitForAgentPromptReady's
|
|
// 30s below).
|
|
if err := s.waitForSessionReady(resumeCtx, sessionID); err != nil {
|
|
return fmt.Errorf("session not ready after resume: %w", err)
|
|
}
|
|
if err := s.waitForAgentPromptReady(resumeCtx, sessionID); err != nil {
|
|
return fmt.Errorf("agent not ready after resume: %w", err)
|
|
}
|
|
|
|
s.logger.Debug("session resumed and ready for prompt")
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) recoverAgentPromptStreamIfNeeded(ctx context.Context, sessionID string) {
|
|
if s.agentManager == nil || s.agentManager.IsAgentReadyForPrompt(ctx, sessionID) {
|
|
return
|
|
}
|
|
recoverer, ok := s.agentManager.(agentPromptStreamRecoverer)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := recoverer.RecoverAgentPromptStream(ctx, sessionID); err != nil {
|
|
s.logger.Debug("agent prompt stream recovery did not make session ready",
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
|
|
func (s *Service) reapPromptUnreadyExecution(ctx context.Context, sessionID string, cause error) error {
|
|
if s.agentManager == nil {
|
|
return fmt.Errorf("agent manager is not configured")
|
|
}
|
|
executionID, err := s.agentManager.GetExecutionIDForSession(ctx, sessionID)
|
|
if err != nil || executionID == "" {
|
|
if running, runErr := s.repo.GetExecutorRunningBySessionID(ctx, sessionID); runErr == nil && running != nil {
|
|
executionID = running.AgentExecutionID
|
|
}
|
|
}
|
|
if executionID == "" {
|
|
if err != nil {
|
|
return fmt.Errorf("execution id for session %s: %w", sessionID, err)
|
|
}
|
|
return fmt.Errorf("execution id for session %s not found", sessionID)
|
|
}
|
|
if !s.claimForcedExecutionCleanup(sessionID, executionID) {
|
|
return fmt.Errorf(
|
|
"execution %s teardown is already owned for session %s",
|
|
executionID,
|
|
sessionID,
|
|
)
|
|
}
|
|
|
|
s.logger.Warn("stopping prompt-unready agent execution before resume",
|
|
zap.String("session_id", sessionID),
|
|
zap.String("agent_execution_id", executionID),
|
|
zap.Error(cause))
|
|
if err := s.executor.StopExecution(ctx, executionID, promptReadinessRecoveryStopReason, true); err != nil {
|
|
return err
|
|
}
|
|
refreshed, err := s.repo.GetTaskSession(ctx, sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("reload session after prompt-readiness teardown: %w", err)
|
|
}
|
|
if refreshed == nil {
|
|
return fmt.Errorf("reload session after prompt-readiness teardown: session %s not found", sessionID)
|
|
}
|
|
if isTerminalSessionState(refreshed.State) {
|
|
return &executor.SessionStateSupersededError{
|
|
SessionID: refreshed.ID,
|
|
State: refreshed.State,
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) waitForAgentPromptReady(ctx context.Context, sessionID string) error {
|
|
if s.agentManager == nil {
|
|
return nil
|
|
}
|
|
|
|
readyCtx, cancel := context.WithTimeout(ctx, agentPromptReadyTimeout)
|
|
defer cancel()
|
|
|
|
ticker := time.NewTicker(agentPromptReadyInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
if s.agentManager.IsAgentReadyForPrompt(readyCtx, sessionID) {
|
|
return nil
|
|
}
|
|
|
|
select {
|
|
case <-readyCtx.Done():
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
return fmt.Errorf("%w: %w", ErrAgentNotReadyForPrompt, readyCtx.Err())
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
// startAgentOnPreparedWorkspace starts the agent subprocess on a session whose workspace
|
|
// was prepared (agentctl running) but whose agent process was never started. This avoids
|
|
// the "session already has an agent running" error from ResumeSession which tries a full
|
|
// LaunchAgent and conflicts with the existing execution in the lifecycle manager's store.
|
|
func (s *Service) startAgentOnPreparedWorkspace(ctx context.Context, sessionID string, session *models.TaskSession) error {
|
|
s.logger.Debug("session has prepared workspace but no agent, starting agent on existing workspace",
|
|
zap.String("session_id", sessionID))
|
|
|
|
// Boot ready is published as events.AgentBootReady by the lifecycle layer
|
|
// and routed to handleAgentBootReady, which flips the session to
|
|
// WAITING_FOR_INPUT — that's what waitForSessionReady polls for. No flag
|
|
// tracking required here.
|
|
launchCtx := context.WithoutCancel(ctx)
|
|
task, err := s.scheduler.GetTask(launchCtx, session.TaskID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get task for prepared session: %w", err)
|
|
}
|
|
if _, err = s.executor.LaunchPreparedSession(launchCtx, task, sessionID, executor.LaunchOptions{
|
|
AgentProfileID: session.AgentProfileID,
|
|
ExecutorID: session.ExecutorID,
|
|
StartAgent: true,
|
|
}); err != nil {
|
|
return fmt.Errorf("failed to start agent on prepared workspace: %w", err)
|
|
}
|
|
|
|
// Same reasoning as ensureSessionRunning's resume path: use launchCtx
|
|
// (already WithoutCancel'd for the launch call above) so a short-lived
|
|
// caller context doesn't abort these polling waits early.
|
|
if err := s.waitForSessionReady(launchCtx, sessionID); err != nil {
|
|
return fmt.Errorf("session not ready after starting agent: %w", err)
|
|
}
|
|
if err := s.waitForAgentPromptReady(launchCtx, sessionID); err != nil {
|
|
return fmt.Errorf("agent not ready after starting agent: %w", err)
|
|
}
|
|
s.logger.Debug("agent started on prepared workspace and ready for prompt")
|
|
return nil
|
|
}
|
|
|
|
// waitForSessionReady polls the session state until the agent is ready for prompts.
|
|
func (s *Service) waitForSessionReady(ctx context.Context, sessionID string) error {
|
|
const (
|
|
pollInterval = 500 * time.Millisecond
|
|
maxWait = 90 * time.Second
|
|
)
|
|
// Derive a bounded context so the overall wait AND each GetTaskSession query
|
|
// inside the loop honor maxWait. Callers pass context.WithoutCancel(ctx) so
|
|
// the wait survives the caller's request timeout during resume/launch — but
|
|
// that context carries no deadline of its own, so without this a single
|
|
// blocking query could hang well past maxWait (the loop only checks the
|
|
// wall clock between iterations). Mirrors waitForAgentPromptReady.
|
|
readyCtx, cancel := context.WithTimeout(ctx, maxWait)
|
|
defer cancel()
|
|
// Ticker rather than time.After(pollInterval) in the loop: time.After
|
|
// allocates a fresh timer each iteration that lives until it fires, so a
|
|
// long wait would pile up ~maxWait/pollInterval live timers. Mirrors
|
|
// waitForAgentPromptReady.
|
|
ticker := time.NewTicker(pollInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-readyCtx.Done():
|
|
return fmt.Errorf("timeout waiting for agent to become ready: %w", readyCtx.Err())
|
|
case <-ticker.C:
|
|
}
|
|
sess, err := s.repo.GetTaskSession(readyCtx, sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check session state: %w", err)
|
|
}
|
|
switch sess.State {
|
|
case models.TaskSessionStateWaitingForInput:
|
|
return nil
|
|
case models.TaskSessionStateFailed:
|
|
errMsg := sess.ErrorMessage
|
|
if errMsg == "" {
|
|
errMsg = "session failed during startup"
|
|
}
|
|
return fmt.Errorf("session failed: %s", errMsg)
|
|
case models.TaskSessionStateCancelled, models.TaskSessionStateCompleted:
|
|
return fmt.Errorf("session in unexpected state: %s", sess.State)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.UpdatedAt = session.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
|
resp.AgentProfileID = session.AgentProfileID
|
|
s.populateExecutorStatusInfo(ctx, session, &resp)
|
|
|
|
running, runErr := s.repo.GetExecutorRunningBySessionID(ctx, sessionID)
|
|
resumeToken := ""
|
|
if runErr == nil && running != nil {
|
|
resumeToken = running.ResumeToken
|
|
resp.ACPSessionID = running.ResumeToken
|
|
resp.Runtime = running.Runtime
|
|
if running.Resumable {
|
|
resp.IsResumable = true
|
|
}
|
|
s.applyRemoteRuntimeStatus(ctx, sessionID, &resp)
|
|
}
|
|
|
|
if shouldHealStuckStartingSession(session, running) {
|
|
s.logger.Info("healing stale STARTING session state from ready runtime status",
|
|
zap.String("task_id", taskID),
|
|
zap.String("session_id", sessionID),
|
|
zap.String("agent_execution_id", running.AgentExecutionID))
|
|
s.setSessionWaitingForInput(ctx, taskID, sessionID)
|
|
refreshedSession, refreshErr := s.repo.GetTaskSession(ctx, sessionID)
|
|
if refreshErr == nil && refreshedSession != nil {
|
|
session = refreshedSession
|
|
resp.State = string(session.State)
|
|
if !session.UpdatedAt.IsZero() {
|
|
resp.UpdatedAt = session.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract worktree info
|
|
populateWorktreeInfo(session, &resp)
|
|
s.populateEnvironmentWorkspaceInfo(ctx, session, &resp)
|
|
|
|
// 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 != "" {
|
|
// Auto-resume FAILED sessions when the runtime is resumable: PR #670 made
|
|
// terminal-state resume safe (cleanup + retry), so recover transparently
|
|
// before surfacing the error. Frontend falls back to restore_workspace if
|
|
// the resume itself fails.
|
|
if session.State == models.TaskSessionStateFailed && running != nil && running.Resumable {
|
|
out := s.validateResumeEligibility(session, resp)
|
|
if out.NeedsResume {
|
|
out.ResumeReason = resumeReasonFailedSessionResumable
|
|
}
|
|
return out, nil
|
|
}
|
|
// Don't auto-resume other terminal sessions (CANCELLED stays stopped, COMPLETED is done).
|
|
if !isActiveSessionState(session.State) {
|
|
resp.IsAgentRunning = false
|
|
resp.IsResumable = false
|
|
resp.NeedsResume = false
|
|
resp.NeedsWorkspaceRestore = canRestoreWorkspace(&resp)
|
|
return resp, nil
|
|
}
|
|
return s.validateResumeEligibility(session, resp), nil
|
|
}
|
|
|
|
// 4. No resume token — check if session can be started fresh.
|
|
return evaluateFreshStartResume(session, running, runErr, resp), nil
|
|
}
|
|
|
|
// evaluateFreshStartResume checks whether a session without a resume token can be
|
|
// started fresh. Sessions in error-recovery state (non-empty ErrorMessage) are marked
|
|
// resumable but not auto-resumed, so the user sees the error and can choose via action buttons.
|
|
func evaluateFreshStartResume(session *models.TaskSession, running *models.ExecutorRunning, runErr error, resp dto.TaskSessionStatusResponse) dto.TaskSessionStatusResponse {
|
|
if runErr == nil && running != nil && isActiveSessionState(session.State) {
|
|
if isErrorRecoveryState(session) {
|
|
resp.IsAgentRunning = false
|
|
resp.IsResumable = true
|
|
resp.NeedsResume = false
|
|
resp.ResumeReason = resumeReasonErrorRecovery
|
|
return resp
|
|
}
|
|
resp.IsAgentRunning = false
|
|
resp.IsResumable = true
|
|
resp.NeedsResume = true
|
|
resp.ResumeReason = "agent_not_running_fresh_start"
|
|
return resp
|
|
}
|
|
resp.IsAgentRunning = false
|
|
resp.IsResumable = false
|
|
resp.NeedsResume = false
|
|
resp.NeedsWorkspaceRestore = canRestoreWorkspace(&resp)
|
|
return resp
|
|
}
|
|
|
|
func (s *Service) populateExecutorStatusInfo(ctx context.Context, session *models.TaskSession, resp *dto.TaskSessionStatusResponse) {
|
|
if session == nil || resp == nil {
|
|
return
|
|
}
|
|
resp.ExecutorID = session.ExecutorID
|
|
if session.ExecutorID == "" {
|
|
return
|
|
}
|
|
execModel, err := s.repo.GetExecutor(ctx, session.ExecutorID)
|
|
if err != nil || execModel == nil {
|
|
return
|
|
}
|
|
resp.ExecutorType = string(execModel.Type)
|
|
resp.ExecutorName = execModel.Name
|
|
resp.IsRemoteExecutor = models.IsRemoteExecutorType(execModel.Type)
|
|
}
|
|
|
|
func (s *Service) applyRemoteRuntimeStatus(ctx context.Context, sessionID string, resp *dto.TaskSessionStatusResponse) {
|
|
if s.agentManager == nil || resp == nil || !resp.IsRemoteExecutor {
|
|
return
|
|
}
|
|
status, err := s.agentManager.GetRemoteRuntimeStatusBySession(ctx, sessionID)
|
|
if err != nil || status == nil {
|
|
return
|
|
}
|
|
if status.RuntimeName != "" {
|
|
resp.Runtime = status.RuntimeName
|
|
}
|
|
resp.RemoteState = status.State
|
|
resp.RemoteName = status.RemoteName
|
|
if status.ErrorMessage != "" {
|
|
resp.RemoteStatusErr = status.ErrorMessage
|
|
}
|
|
if status.CreatedAt != nil && !status.CreatedAt.IsZero() {
|
|
resp.RemoteCreatedAt = status.CreatedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
if !status.LastCheckedAt.IsZero() {
|
|
resp.RemoteCheckedAt = status.LastCheckedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
}
|
|
|
|
// populateWorktreeInfo copies worktree path and branch into the response if present.
|
|
func canRestoreWorkspace(resp *dto.TaskSessionStatusResponse) bool {
|
|
return resp != nil && resp.WorktreePath != nil && *resp.WorktreePath != ""
|
|
}
|
|
|
|
func populateWorktreeInfo(session *models.TaskSession, resp *dto.TaskSessionStatusResponse) {
|
|
if len(session.Worktrees) == 0 {
|
|
return
|
|
}
|
|
wt := session.Worktrees[0]
|
|
if wt.WorktreePath != "" {
|
|
resp.WorktreePath = &wt.WorktreePath
|
|
}
|
|
if wt.WorktreeBranch != "" {
|
|
resp.WorktreeBranch = &wt.WorktreeBranch
|
|
}
|
|
}
|
|
|
|
func (s *Service) populateEnvironmentWorkspaceInfo(ctx context.Context, session *models.TaskSession, resp *dto.TaskSessionStatusResponse) {
|
|
if hasWorktreeStatus(resp) {
|
|
return
|
|
}
|
|
env, err := s.repo.GetTaskEnvironmentByTaskID(ctx, session.TaskID)
|
|
if err != nil || env == nil {
|
|
return
|
|
}
|
|
if session.TaskEnvironmentID != "" && env.ID != session.TaskEnvironmentID {
|
|
return
|
|
}
|
|
if resp.WorktreePath == nil && env.WorktreePath != "" {
|
|
resp.WorktreePath = &env.WorktreePath
|
|
}
|
|
if resp.WorktreeBranch == nil && env.WorktreeBranch != "" {
|
|
resp.WorktreeBranch = &env.WorktreeBranch
|
|
}
|
|
}
|
|
|
|
func hasWorktreeStatus(resp *dto.TaskSessionStatusResponse) bool {
|
|
return resp != nil &&
|
|
resp.WorktreePath != nil && *resp.WorktreePath != "" &&
|
|
resp.WorktreeBranch != nil && *resp.WorktreeBranch != ""
|
|
}
|
|
|
|
// isActiveSessionState returns true for session states where lazy resume makes sense.
|
|
func isActiveSessionState(state models.TaskSessionState) bool {
|
|
switch state {
|
|
case models.TaskSessionStateWaitingForInput,
|
|
models.TaskSessionStateStarting,
|
|
models.TaskSessionStateRunning:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isErrorRecoveryState returns true when a session is in WAITING_FOR_INPUT with
|
|
// a non-empty ErrorMessage, indicating it was set by handleRecoverableFailure.
|
|
func isErrorRecoveryState(session *models.TaskSession) bool {
|
|
return session != nil &&
|
|
session.State == models.TaskSessionStateWaitingForInput &&
|
|
session.ErrorMessage != ""
|
|
}
|
|
|
|
func shouldHealStuckStartingSession(session *models.TaskSession, running *models.ExecutorRunning) bool {
|
|
if session == nil || running == nil {
|
|
return false
|
|
}
|
|
if session.State != models.TaskSessionStateStarting {
|
|
return false
|
|
}
|
|
if running.Status != "ready" {
|
|
return false
|
|
}
|
|
// Pre-refactor this also checked session.AgentExecutionID vs running.AgentExecutionID
|
|
// to skip healing on a divergent row. With the executors_running table now the
|
|
// single source of truth, that comparison is structurally always equal — drop it.
|
|
return true
|
|
}
|
|
|
|
// validateResumeEligibility performs final checks before marking a session as resumable.
|
|
func (s *Service) validateResumeEligibility(session *models.TaskSession, resp dto.TaskSessionStatusResponse) dto.TaskSessionStatusResponse {
|
|
if session.AgentProfileID == "" {
|
|
resp.Error = "session missing agent profile"
|
|
resp.IsResumable = false
|
|
return resp
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// Don't auto-resume sessions in error-recovery state.
|
|
if isErrorRecoveryState(session) {
|
|
resp.IsAgentRunning = false
|
|
resp.IsResumable = true
|
|
resp.NeedsResume = false
|
|
resp.ResumeReason = resumeReasonErrorRecovery
|
|
return resp
|
|
}
|
|
|
|
resp.IsAgentRunning = false
|
|
resp.NeedsResume = true
|
|
resp.ResumeReason = "agent_not_running"
|
|
return resp
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// StopTaskForCoordinator gracefully halts every currently-observed live
|
|
// execution for taskID without accepting caller-controlled lifecycle options.
|
|
// The operation is idempotent: no accepted cancellation is not_running.
|
|
func (s *Service) StopTaskForCoordinator(ctx context.Context, taskID string) (CoordinatorTaskStopResult, error) {
|
|
if s.executor == nil {
|
|
return CoordinatorTaskStopResult{}, errors.New("coordinator stop: executor is not configured")
|
|
}
|
|
sessions, err := s.repo.ListActiveTaskSessionsByTaskID(ctx, taskID)
|
|
if err != nil {
|
|
return CoordinatorTaskStopResult{}, fmt.Errorf("coordinator stop: list active sessions for task %q: %w", taskID, err)
|
|
}
|
|
|
|
// Repository ordering is launch-oriented. Coordinator control uses stable
|
|
// identity ordering so partial failures and retries are deterministic.
|
|
sort.SliceStable(sessions, func(i, j int) bool {
|
|
return coordinatorStopSessionID(sessions[i]) < coordinatorStopSessionID(sessions[j])
|
|
})
|
|
|
|
accepted := 0
|
|
failures := make([]error, 0)
|
|
for _, candidate := range sessions {
|
|
if candidate == nil || candidate.ID == "" {
|
|
failures = append(failures, errors.New("coordinator stop: active session candidate is nil or has an empty ID"))
|
|
continue
|
|
}
|
|
changed, stopErr := s.stopTaskSessionForCoordinator(ctx, taskID, candidate.ID)
|
|
if stopErr != nil {
|
|
failures = append(failures, stopErr)
|
|
continue
|
|
}
|
|
if changed {
|
|
accepted++
|
|
}
|
|
}
|
|
if accepted > 0 {
|
|
// This helper owns Office/archive/active-state guards and publishes
|
|
// through the task-service adapter. Remaining working sessions still
|
|
// block REVIEW on a partial stop.
|
|
s.writeTaskReviewState(ctx, taskID, "")
|
|
}
|
|
if len(failures) > 0 {
|
|
s.logger.Warn("coordinator stop partially failed",
|
|
zap.String("task_id", taskID),
|
|
zap.Int("accepted", accepted),
|
|
zap.Int("failed", len(failures)))
|
|
return CoordinatorTaskStopResult{}, errors.Join(failures...)
|
|
}
|
|
if accepted == 0 {
|
|
return CoordinatorTaskStopResult{Status: CoordinatorTaskStopStatusNotRunning}, nil
|
|
}
|
|
return CoordinatorTaskStopResult{Status: CoordinatorTaskStopStatusStopped}, nil
|
|
}
|
|
|
|
func coordinatorStopSessionID(session *models.TaskSession) string {
|
|
if session == nil {
|
|
return ""
|
|
}
|
|
return session.ID
|
|
}
|
|
|
|
func (s *Service) stopTaskSessionForCoordinator(ctx context.Context, taskID, sessionID string) (bool, error) {
|
|
lock, release := s.acquireCancelInFlightGuard(sessionID)
|
|
lock.Lock()
|
|
|
|
result, teardownClaimed, err := s.stopTaskSessionForCoordinatorLocked(ctx, taskID, sessionID)
|
|
lock.Unlock()
|
|
release()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if result.Changed && teardownClaimed {
|
|
result.ScheduleTeardown()
|
|
}
|
|
return result.Changed, nil
|
|
}
|
|
|
|
// stopTaskSessionForCoordinatorLocked commits cancellation and claims teardown
|
|
// ownership while sessionID's cancelInFlight guard is held. The caller must
|
|
// release that guard before scheduling the returned teardown.
|
|
func (s *Service) stopTaskSessionForCoordinatorLocked(
|
|
ctx context.Context,
|
|
taskID, sessionID string,
|
|
) (executor.SessionStopResult, bool, error) {
|
|
|
|
// Re-read after acquiring the shared cancel/ready/queue guard. A candidate
|
|
// may have become terminal while this call waited for another decision.
|
|
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
|
if err != nil {
|
|
return executor.SessionStopResult{}, false, fmt.Errorf("coordinator stop: reload session %q: %w", sessionID, err)
|
|
}
|
|
if session == nil {
|
|
return executor.SessionStopResult{}, false, fmt.Errorf("coordinator stop: reload session %q returned nil", sessionID)
|
|
}
|
|
if session.TaskID != taskID {
|
|
return executor.SessionStopResult{}, false, fmt.Errorf("coordinator stop: session %q belongs to task %q, not %q", sessionID, session.TaskID, taskID)
|
|
}
|
|
if !isCoordinatorStoppableSessionState(session.State) {
|
|
return executor.SessionStopResult{FinalState: session.State}, false, nil
|
|
}
|
|
|
|
s.taskRuntimeStateMu.Lock()
|
|
defer s.taskRuntimeStateMu.Unlock()
|
|
// Halt-only intent also disarms any provider-backoff retry. This must run
|
|
// even when the failed execution has already disappeared and the result is
|
|
// therefore not_running; otherwise its timer can launch replacement work.
|
|
s.resetTransientRetry(sessionID)
|
|
result, err := s.executor.StopSessionDetailed(ctx, session, coordinatorMCPStopReason, false)
|
|
if err != nil {
|
|
return result, false, fmt.Errorf("coordinator stop: session %q: %w", sessionID, err)
|
|
}
|
|
teardownClaimed := result.Changed && s.claimExecutionTeardown(
|
|
sessionID,
|
|
result.ExecutionID,
|
|
executionTeardownIntentGraceful,
|
|
)
|
|
return result, teardownClaimed, nil
|
|
}
|
|
|
|
func isCoordinatorStoppableSessionState(state models.TaskSessionState) bool {
|
|
switch state {
|
|
case models.TaskSessionStateCreated,
|
|
models.TaskSessionStateStarting,
|
|
models.TaskSessionStateRunning,
|
|
models.TaskSessionStateWaitingForInput:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// CancelTaskExecution stops active sessions for a task without mutating task state.
|
|
// It is used by office tree controls where pause/cancel state transitions are
|
|
// handled by the office service itself.
|
|
func (s *Service) CancelTaskExecution(ctx context.Context, taskID string, reason string, force bool) error {
|
|
s.logger.Info("cancelling task execution",
|
|
zap.String("task_id", taskID),
|
|
zap.String("reason", reason),
|
|
zap.Bool("force", force))
|
|
return s.executor.StopByTaskID(ctx, taskID, reason, force)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// DeleteSession deletes a session that is not currently running.
|
|
func (s *Service) DeleteSession(ctx context.Context, sessionID string) error {
|
|
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("session not found: %w", err)
|
|
}
|
|
|
|
// Prevent deleting active sessions
|
|
switch session.State {
|
|
case models.TaskSessionStateRunning, models.TaskSessionStateStarting:
|
|
return fmt.Errorf("cannot delete session in %s state — stop it first", session.State)
|
|
}
|
|
|
|
taskID := session.TaskID
|
|
wasPrimary := session.IsPrimary
|
|
|
|
s.logger.Info("deleting session",
|
|
zap.String("session_id", sessionID),
|
|
zap.String("task_id", taskID),
|
|
zap.String("state", string(session.State)),
|
|
zap.Bool("was_primary", wasPrimary))
|
|
|
|
if err := s.repo.DeleteTaskSession(ctx, sessionID); err != nil {
|
|
return fmt.Errorf("failed to delete session: %w", err)
|
|
}
|
|
|
|
// Drop the in-memory git snapshot throttle entry — the session will
|
|
// never receive another git event, so its cache slot is dead weight.
|
|
if s.gitSnapshotCache != nil {
|
|
s.gitSnapshotCache.forget(sessionID)
|
|
}
|
|
// Same reasoning for the push-detection tracker. Multi-repo sessions
|
|
// accumulate one entry per repo; pushTrackerForget walks them all.
|
|
s.pushTrackerForget(sessionID)
|
|
|
|
// Auto-promote another session if we deleted the primary
|
|
if wasPrimary {
|
|
s.promoteNextPrimaryAfterRemoval(ctx, taskID, sessionID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// promoteNextPrimaryAfterRemoval picks the best remaining session as primary
|
|
// after a session is deleted. Prefers RUNNING > active > any remaining.
|
|
func (s *Service) promoteNextPrimaryAfterRemoval(ctx context.Context, taskID, deletedSessionID string) {
|
|
sessions, err := s.repo.ListTaskSessions(ctx, taskID)
|
|
if err != nil || len(sessions) == 0 {
|
|
return
|
|
}
|
|
var candidate string
|
|
for _, sess := range sessions {
|
|
if sess.ID == deletedSessionID {
|
|
continue
|
|
}
|
|
if sess.State == models.TaskSessionStateRunning {
|
|
candidate = sess.ID
|
|
break
|
|
}
|
|
if candidate == "" {
|
|
candidate = sess.ID
|
|
} else if isActiveSessionState(sess.State) {
|
|
// Prefer active over terminal
|
|
candidate = sess.ID
|
|
}
|
|
}
|
|
if candidate != "" {
|
|
if err := s.SetPrimarySession(ctx, candidate); err != nil {
|
|
s.logger.Warn("failed to auto-promote primary after delete",
|
|
zap.String("task_id", taskID),
|
|
zap.String("candidate", candidate),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// SetPrimarySession marks a session as the primary session for its task
|
|
// and broadcasts a task.updated event so the frontend reflects the change.
|
|
func (s *Service) SetPrimarySession(ctx context.Context, sessionID string) error {
|
|
if err := s.repo.SetSessionPrimary(ctx, sessionID); err != nil {
|
|
return fmt.Errorf("failed to set session as primary: %w", err)
|
|
}
|
|
|
|
// Broadcast task.updated so frontend updates the primary star indicator.
|
|
// The task service's publisher loads primary-session info from the DB,
|
|
// which already reflects the SetSessionPrimary write above.
|
|
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
|
if err != nil {
|
|
s.logger.Warn("failed to fetch session after setting primary", zap.Error(err))
|
|
return nil
|
|
}
|
|
task, err := s.repo.GetTask(ctx, session.TaskID)
|
|
if err != nil {
|
|
s.logger.Warn("failed to fetch task after setting primary", zap.Error(err))
|
|
return nil
|
|
}
|
|
s.publishTaskUpdated(ctx, task)
|
|
return nil
|
|
}
|
|
|
|
// maxSessionNameLength caps user-supplied session names to keep tab labels sane.
|
|
const maxSessionNameLength = 120
|
|
|
|
// RenameSession sets the user-supplied name for a session and broadcasts a
|
|
// session.state_changed event (same state) so all clients update the tab label.
|
|
// An empty name clears the custom label, falling back to the derived title.
|
|
func (s *Service) RenameSession(ctx context.Context, sessionID, name string) error {
|
|
name = strings.TrimSpace(name)
|
|
// Truncate by runes, not bytes — a byte slice could split a multi-byte
|
|
// UTF-8 sequence and persist an invalid string.
|
|
if runes := []rune(name); len(runes) > maxSessionNameLength {
|
|
name = string(runes[:maxSessionNameLength])
|
|
}
|
|
// Fetch BEFORE the write so a post-write lookup failure can never leave a
|
|
// durably renamed row without its broadcast (clients would render stale
|
|
// labels until the next full hydration).
|
|
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load session for rename: %w", err)
|
|
}
|
|
if err := s.repo.RenameTaskSession(ctx, sessionID, name); err != nil {
|
|
return fmt.Errorf("failed to rename session: %w", err)
|
|
}
|
|
session.Name = name
|
|
updatedAt := time.Now().UTC()
|
|
s.publishTaskSessionStateChanged(ctx, session.TaskID, sessionID,
|
|
session.State, session.State, session.ErrorMessage, &updatedAt, session)
|
|
return nil
|
|
}
|
|
|
|
// StopExecution stops agent execution for a specific execution ID.
|
|
func (s *Service) StopExecution(ctx context.Context, executionID string, reason string, force bool) error {
|
|
s.logger.Info("stopping execution",
|
|
zap.String("execution_id", executionID),
|
|
zap.String("reason", reason),
|
|
zap.Bool("force", force))
|
|
return s.executor.StopExecution(ctx, executionID, reason, force)
|
|
}
|
|
|
|
// CaptureArchiveSnapshot captures git state (commits, cumulative diff) for a session before archiving.
|
|
// This preserves the final git state for historical purposes.
|
|
func (s *Service) CaptureArchiveSnapshot(ctx context.Context, sessionID string) error {
|
|
s.logger.Info("capturing archive snapshot", zap.String("session_id", sessionID))
|
|
|
|
baseCommit, baseBranch, err := s.resolveArchiveBaseCommitAndBranch(ctx, sessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Skip only if we have neither baseCommit nor baseBranch for merge-base calculation
|
|
if baseCommit == "" && baseBranch == "" {
|
|
s.logger.Debug("no base_commit or base_branch available, skipping archive snapshot capture",
|
|
zap.String("session_id", sessionID))
|
|
return nil
|
|
}
|
|
|
|
// Capture commits - baseBranch can be used for merge-base even if baseCommit is empty
|
|
if !s.captureArchiveCommits(ctx, sessionID, baseCommit, baseBranch) {
|
|
// Agent not running, skip diff capture as well
|
|
return nil
|
|
}
|
|
|
|
// Diff capture requires baseCommit
|
|
if baseCommit == "" {
|
|
s.logger.Debug("no base_commit available, skipping archive diff capture",
|
|
zap.String("session_id", sessionID))
|
|
return nil
|
|
}
|
|
|
|
s.captureArchiveDiff(ctx, sessionID, baseCommit)
|
|
return nil
|
|
}
|
|
|
|
// resolveArchiveBaseCommitAndBranch retrieves the base commit and branch for archive snapshot capture.
|
|
// It first checks the session's stored values, falling back to git status if empty.
|
|
func (s *Service) resolveArchiveBaseCommitAndBranch(ctx context.Context, sessionID string) (string, string, error) {
|
|
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to get session: %w", err)
|
|
}
|
|
|
|
baseCommit := session.BaseCommitSHA
|
|
baseBranch := session.BaseBranch
|
|
|
|
if baseCommit != "" {
|
|
return baseCommit, baseBranch, nil
|
|
}
|
|
|
|
// Fallback: try to get base commit from git status for legacy sessions
|
|
status, err := s.agentManager.GetGitStatus(ctx, sessionID)
|
|
if err != nil {
|
|
s.logger.Debug("failed to get git status for base commit fallback",
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(err))
|
|
return "", baseBranch, nil
|
|
}
|
|
if status != nil && status.BaseCommit != "" {
|
|
s.logger.Debug("using git status base commit as fallback for archive",
|
|
zap.String("session_id", sessionID),
|
|
zap.String("base_commit", status.BaseCommit))
|
|
return status.BaseCommit, baseBranch, nil
|
|
}
|
|
return "", baseBranch, nil
|
|
}
|
|
|
|
// captureArchiveCommits fetches and saves commits from baseCommit to HEAD.
|
|
// If targetBranch is provided, uses dynamic merge-base for accurate filtering.
|
|
// Returns false if the agent is not running (caller should skip remaining capture).
|
|
func (s *Service) captureArchiveCommits(ctx context.Context, sessionID, baseCommit, targetBranch string) bool {
|
|
logResult, err := s.agentManager.GetGitLog(ctx, sessionID, baseCommit, 0, targetBranch) // 0 = no limit
|
|
if err != nil {
|
|
s.logger.Warn("failed to capture git log for archive",
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(err))
|
|
return true // Continue with diff capture even if log fails
|
|
}
|
|
if logResult == nil {
|
|
s.logger.Debug("agent not running, skipping archive snapshot capture",
|
|
zap.String("session_id", sessionID))
|
|
return false
|
|
}
|
|
if logResult.Success && len(logResult.Commits) > 0 {
|
|
s.saveArchiveCommits(ctx, sessionID, logResult.Commits)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// captureGitStatusSnapshot fetches the current (cached) git status from agentctl
|
|
// and saves it as a DB snapshot. Called when a session's execution completes so
|
|
// the status is available when clients subscribe later.
|
|
func (s *Service) captureGitStatusSnapshot(ctx context.Context, sessionID string) {
|
|
_, _ = s.saveGitStatusSnapshot(ctx, sessionID, false)
|
|
}
|
|
|
|
// captureGitStatusSnapshotWithRetry attempts a fresh capture with up to 3
|
|
// retries at 1-second intervals if the first attempt returns stale 0/0 data
|
|
// (caused by git lock contention between concurrent worktrees). Returns
|
|
// immediately without retrying when no execution exists for the session
|
|
// (returns nil,nil) or the agent genuinely has no file changes.
|
|
func (s *Service) captureGitStatusSnapshotWithRetry(ctx context.Context, sessionID string) {
|
|
for attempt := 0; attempt < 3; attempt++ {
|
|
if attempt > 0 {
|
|
time.Sleep(time.Second)
|
|
}
|
|
wrote, noExec := s.saveGitStatusSnapshot(ctx, sessionID, true)
|
|
if noExec {
|
|
return // No execution exists — retrying won't help
|
|
}
|
|
if wrote {
|
|
return // Successfully wrote a snapshot (may be 0/0 for no-change turns, that's fine)
|
|
}
|
|
// saveGitStatusSnapshot returned false without noExec — means fresh
|
|
// returned 0/0 and was skipped due to possible lock contention. Retry.
|
|
}
|
|
}
|
|
|
|
// saveGitStatusSnapshot is the shared implementation for snapshot capture.
|
|
// When fresh is true, it bypasses the workspace tracker's poll cache.
|
|
// Returns (wrote, noExecution): wrote=true if a snapshot was persisted,
|
|
// noExecution=true if the session has no active execution (nil status).
|
|
func (s *Service) saveGitStatusSnapshot(ctx context.Context, sessionID string, fresh bool) (wrote, noExecution bool) {
|
|
var status *client.GitStatusResult
|
|
var err error
|
|
if fresh {
|
|
status, err = s.agentManager.GetGitStatusFresh(ctx, sessionID)
|
|
} else {
|
|
status, err = s.agentManager.GetGitStatus(ctx, sessionID)
|
|
}
|
|
if err != nil {
|
|
s.logger.Debug("failed to capture git status snapshot",
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(err))
|
|
return false, false
|
|
}
|
|
if status == nil {
|
|
return false, true // No execution — caller should not retry
|
|
}
|
|
if !status.Success {
|
|
return false, false
|
|
}
|
|
|
|
// When a fresh query returns zero additions AND zero deletions, the result
|
|
// may be stale due to git lock contention between concurrent worktrees
|
|
// (merge-base computation fails transiently). Don't overwrite a potentially
|
|
// better live_monitor snapshot that already has the correct non-zero values.
|
|
if fresh && status.BranchAdditions == 0 && status.BranchDeletions == 0 {
|
|
return false, false
|
|
}
|
|
|
|
metadata := map[string]interface{}{
|
|
"timestamp": status.Timestamp,
|
|
"modified": status.Modified,
|
|
"added": status.Added,
|
|
"deleted": status.Deleted,
|
|
"untracked": status.Untracked,
|
|
"renamed": status.Renamed,
|
|
"branch_additions": status.BranchAdditions,
|
|
"branch_deletions": status.BranchDeletions,
|
|
}
|
|
|
|
if err := s.repo.CreateGitSnapshot(ctx, &models.GitSnapshot{
|
|
SessionID: sessionID,
|
|
SnapshotType: models.SnapshotTypeStatusUpdate,
|
|
Branch: status.Branch,
|
|
RemoteBranch: status.RemoteBranch,
|
|
HeadCommit: status.HeadCommit,
|
|
BaseCommit: status.BaseCommit,
|
|
Ahead: status.Ahead,
|
|
Behind: status.Behind,
|
|
Files: status.Files,
|
|
TriggeredBy: "agent_completed",
|
|
Metadata: metadata,
|
|
}); err != nil {
|
|
s.logger.Warn("failed to save git status snapshot",
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(err))
|
|
return false, false
|
|
}
|
|
|
|
// Remove stale live_monitor snapshots so the authoritative agent_completed
|
|
// snapshot is always returned by GetLatestGitSnapshot. Without this, a
|
|
// live_monitor poll that raced with agent completion could persist a
|
|
// snapshot with a later timestamp but stale data.
|
|
if err := s.repo.DeleteLiveMonitorSnapshots(ctx, sessionID); err != nil {
|
|
s.logger.Debug("failed to clean up live monitor snapshots",
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(err))
|
|
}
|
|
|
|
s.logger.Debug("saved git status snapshot",
|
|
zap.String("session_id", sessionID),
|
|
zap.String("branch", status.Branch),
|
|
zap.Bool("fresh", fresh))
|
|
return true, false
|
|
}
|
|
|
|
// captureArchiveDiff fetches and saves the cumulative diff from baseCommit to the working tree
|
|
// (including uncommitted/unstaged changes).
|
|
func (s *Service) captureArchiveDiff(ctx context.Context, sessionID, baseCommit string) {
|
|
diffResult, err := s.agentManager.GetCumulativeDiff(ctx, sessionID, baseCommit)
|
|
if err != nil {
|
|
s.logger.Warn("failed to capture cumulative diff for archive",
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
if diffResult == nil || !diffResult.Success {
|
|
return
|
|
}
|
|
|
|
if err := s.repo.CreateGitSnapshot(ctx, &models.GitSnapshot{
|
|
SessionID: sessionID,
|
|
SnapshotType: models.SnapshotTypeArchive,
|
|
HeadCommit: diffResult.HeadCommit,
|
|
BaseCommit: diffResult.BaseCommit,
|
|
Files: diffResult.Files,
|
|
}); err != nil {
|
|
s.logger.Warn("failed to save archive snapshot",
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
|
|
s.logger.Debug("saved archive snapshot",
|
|
zap.String("session_id", sessionID),
|
|
zap.String("head_commit", diffResult.HeadCommit),
|
|
zap.Int("total_commits", diffResult.TotalCommits))
|
|
}
|
|
|
|
// parseCommitTime parses a commit timestamp from git log output.
|
|
// Returns UTC time to ensure consistent timestamps across environments.
|
|
func parseCommitTime(s string) time.Time {
|
|
t, err := time.Parse(time.RFC3339, s)
|
|
if err != nil {
|
|
return time.Now().UTC()
|
|
}
|
|
return t.UTC()
|
|
}
|
|
|
|
// saveArchiveCommits persists commits to the database for archive purposes.
|
|
func (s *Service) saveArchiveCommits(ctx context.Context, sessionID string, commits []*client.GitCommitInfo) {
|
|
for _, commit := range commits {
|
|
if err := s.repo.CreateSessionCommit(ctx, &models.SessionCommit{
|
|
SessionID: sessionID,
|
|
CommitSHA: commit.CommitSHA,
|
|
ParentSHA: commit.ParentSHA,
|
|
AuthorName: commit.AuthorName,
|
|
AuthorEmail: commit.AuthorEmail,
|
|
CommitMessage: commit.CommitMessage,
|
|
CommittedAt: parseCommitTime(commit.CommittedAt),
|
|
FilesChanged: commit.FilesChanged,
|
|
Insertions: commit.Insertions,
|
|
Deletions: commit.Deletions,
|
|
}); err != nil {
|
|
s.logger.Warn("failed to save commit for archive",
|
|
zap.String("session_id", sessionID),
|
|
zap.String("commit_sha", commit.CommitSHA),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
s.logger.Debug("saved archive commits",
|
|
zap.String("session_id", sessionID),
|
|
zap.Int("count", len(commits)))
|
|
}
|
|
|
|
// 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, dispatchOnly bool) (*PromptResult, error) {
|
|
return s.promptTask(ctx, taskID, sessionID, prompt, model, planMode, attachments, dispatchOnly, "")
|
|
}
|
|
|
|
// promptTask is PromptTask's implementation, taking an additional
|
|
// claimEntryID parameter used only by executeQueuedMessage's internal
|
|
// call. When non-empty, this acquires sessionID's cancelInFlight guard
|
|
// around a "confirm I still own this dispatch, then mark the session
|
|
// RUNNING" step, immediately before startTurnForSession and the
|
|
// (potentially long-blocking) executor.Prompt call below it — see
|
|
// isCurrentQueuedDispatch and the Service.dispatchingQueued field doc
|
|
// comment for the race this closes.
|
|
//
|
|
// A bare (unguarded) "check the token, then separately call
|
|
// setSessionRunning" would still let two settling dispatches for the same
|
|
// session both pass their own check before either actually marks the
|
|
// session RUNNING — the exact double-dispatch this mechanism exists to
|
|
// prevent, just narrowed rather than closed. Serializing the check
|
|
// *and* the mark through the same per-session guard used for every other
|
|
// cancel/take-and-dispatch decision (see the Service.cancelInFlight field
|
|
// doc comment) makes "exactly one dispatch wins" a property of mutual
|
|
// exclusion instead of a racy read. The guard is held only for this
|
|
// fast, DB-only step — released well before the long-running
|
|
// executor.Prompt call — matching every other guard holder's
|
|
// bounded-hold-time contract in this file.
|
|
//
|
|
// Every return path *before* this step (invalid session, not promptable,
|
|
// ensureSessionRunning failure, a model switch) never claims anything —
|
|
// those are covered by executeQueuedMessage's own deferred cleanup
|
|
// instead (clearQueuedDispatchInFlightIfCurrent), which is safe since
|
|
// none of them block on an agent turn.
|
|
func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prompt string, model string, planMode bool, attachments []v1.MessageAttachment, dispatchOnly bool, claimEntryID string) (*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)),
|
|
zap.Bool("dispatch_only", dispatchOnly))
|
|
if sessionID == "" {
|
|
return nil, fmt.Errorf("session_id is required")
|
|
}
|
|
if s.isSessionResetInProgress(sessionID) {
|
|
return nil, ErrSessionResetInProgress
|
|
}
|
|
|
|
// Only allow prompts when the session is ready for input.
|
|
// Reject when the agent is still starting, already processing, or in a terminal state.
|
|
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get session: %w", err)
|
|
}
|
|
if err := s.checkSessionPromptable(taskID, sessionID, session.State); err != nil {
|
|
if !errors.Is(err, ErrSessionNotPromptable) || session.State != models.TaskSessionStateStarting {
|
|
return nil, err
|
|
}
|
|
readySession, waitErr := s.waitForStartingSessionPromptable(ctx, taskID, sessionID)
|
|
if waitErr != nil {
|
|
return nil, waitErr
|
|
}
|
|
session = readySession
|
|
}
|
|
|
|
// Apply config-mode and plan-mode prompt transforms.
|
|
effectivePrompt := s.effectivePromptForSession(sessionID, prompt, planMode, session)
|
|
|
|
// Ensure the agent process is actually running. After a lazy backend restart,
|
|
// the session may be in WAITING_FOR_INPUT but no agent process exists yet.
|
|
_, hadExecutionBeforeEnsure := s.executor.GetExecutionBySession(sessionID)
|
|
resumedForPrompt := !hadExecutionBeforeEnsure
|
|
if err := s.ensureSessionRunning(ctx, sessionID, session); err != nil {
|
|
return nil, fmt.Errorf("failed to ensure session is running: %w", err)
|
|
}
|
|
|
|
// Reload session after ensureSessionRunning. If a resume happened, ResumeSession
|
|
// updated the session's AgentExecutionID via persistResumeState — but only on the
|
|
// pointer it received. If anything along the way swapped the pointer or the
|
|
// caller's struct is otherwise stale (e.g. a concurrent write), executor.Prompt
|
|
// would call PromptAgent with the OLD execution ID and get ErrExecutionNotFound.
|
|
// Re-reading from the DB after ensureSessionRunning guarantees we use the
|
|
// freshly-persisted AgentExecutionID.
|
|
if reloaded, err := s.repo.GetTaskSession(ctx, sessionID); err == nil && reloaded != nil {
|
|
session = reloaded
|
|
// Re-apply transforms in case metadata changed during ensureSessionRunning.
|
|
effectivePrompt = s.effectivePromptForSession(sessionID, prompt, planMode, session)
|
|
}
|
|
|
|
// Check if model switching is requested
|
|
if result, switched, err := s.trySwitchModel(ctx, taskID, sessionID, model, effectivePrompt, session); switched || err != nil {
|
|
return result, err
|
|
}
|
|
|
|
previousSessionState := session.State
|
|
|
|
// Cache the raw prompt so a transient-provider-error (529) retry can
|
|
// re-drive this turn after backoff without the caller's context. Stores
|
|
// the pre-injection prompt; PromptTask re-applies config/plan transforms.
|
|
s.rememberTurnPrompt(sessionID, prompt, model, planMode, attachments)
|
|
|
|
claimedSession, err := s.claimSessionRunningForPrompt(ctx, taskID, sessionID, claimEntryID, session)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
session = claimedSession
|
|
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, dispatchOnly, session)
|
|
if err != nil {
|
|
return s.handlePromptDispatchFailure(ctx, taskID, sessionID, prompt, planMode, resumedForPrompt, attachments, previousSessionState, err)
|
|
}
|
|
return &PromptResult{
|
|
StopReason: result.StopReason,
|
|
AgentMessage: result.AgentMessage,
|
|
}, nil
|
|
}
|
|
|
|
// handlePromptDispatchFailure handles a failed executor.Prompt call from
|
|
// promptTask. If the failure is the specific "missing execution" error a
|
|
// just-resumed session can hit (see promptTask's resumedForPrompt comment),
|
|
// it falls back to a fresh launch instead of surfacing the error to the
|
|
// caller. Otherwise — or if that fallback launch itself fails — it
|
|
// delegates to handlePromptError for the caller-facing result.
|
|
func (s *Service) handlePromptDispatchFailure(ctx context.Context, taskID, sessionID, prompt string, planMode, resumedForPrompt bool, attachments []v1.MessageAttachment, previousSessionState models.TaskSessionState, promptErr error) (*PromptResult, error) {
|
|
if resumedForPrompt && errors.Is(promptErr, executor.ErrExecutionNotFound) {
|
|
s.logger.Warn("prompt after lazy resume hit missing execution; falling back to fresh launch",
|
|
zap.String("task_id", taskID),
|
|
zap.String("session_id", sessionID))
|
|
if freshErr := s.fallbackFreshLaunchOnMissingExecution(
|
|
ctx, taskID, sessionID, prompt, planMode, nil, attachments, nil,
|
|
); freshErr == nil {
|
|
return &PromptResult{}, nil
|
|
} else {
|
|
promptErr = freshErr
|
|
}
|
|
}
|
|
return nil, s.handlePromptError(ctx, taskID, sessionID, previousSessionState, promptErr)
|
|
}
|
|
|
|
// effectivePromptForSession applies promptTask's config-mode and plan-mode
|
|
// prompt transforms to the raw prompt, in that order, using session's
|
|
// *current* Metadata. Each call always starts from the raw prompt argument
|
|
// (never a previously-transformed result), so it is safe for promptTask to
|
|
// call this twice — once on session load and again after a reload that
|
|
// follows ensureSessionRunning, in case metadata changed in between —
|
|
// without double-prepending either transform's system-prompt wrapper.
|
|
func (s *Service) effectivePromptForSession(sessionID, prompt string, planMode bool, session *models.TaskSession) string {
|
|
effectivePrompt := prompt
|
|
if cm, ok := session.Metadata["config_mode"].(bool); ok && cm {
|
|
effectivePrompt = sysprompt.InjectConfigContext(sessionID, prompt)
|
|
}
|
|
if planMode {
|
|
effectivePrompt = sysprompt.InjectPlanMode(effectivePrompt)
|
|
}
|
|
return effectivePrompt
|
|
}
|
|
|
|
// claimSessionRunningForPrompt marks sessionID RUNNING immediately before
|
|
// promptTask dispatches to the agent, and returns the session to use for
|
|
// the rest of the call (possibly reloaded).
|
|
//
|
|
// Direct and queued prompts both claim RUNNING under the per-session
|
|
// cancelInFlight guard. This makes the final "still promptable, now owned by
|
|
// this turn" decision atomic with coordinator stop; the potentially blocking
|
|
// executor.Prompt call remains outside the guard.
|
|
//
|
|
// When claimEntryID is set (a queued dispatch claiming its reserved token —
|
|
// see the Service.dispatchingQueued field doc comment), the claim happens
|
|
// under the same per-session cancelInFlightGuard every other cancel/take-
|
|
// and-dispatch decision in this file serializes through. Re-verifying
|
|
// ownership *and* promptability inside this same critical section,
|
|
// immediately before writing RUNNING, closes a gap that checking the token
|
|
// alone would leave open: nothing would otherwise stop this call from
|
|
// blindly marking the session RUNNING over top of a state some other
|
|
// guard-synchronized decision-maker (a cancel, another claim) already
|
|
// changed since promptTask's much-earlier, unguarded checkSessionPromptable
|
|
// call near its top. Reloading the session and re-running
|
|
// checkSessionPromptable here makes "is it actually still safe to mark this
|
|
// session RUNNING right now" an atomic fact, not a stale read.
|
|
func (s *Service) claimSessionRunningForPrompt(ctx context.Context, taskID, sessionID, claimEntryID string, session *models.TaskSession) (*models.TaskSession, error) {
|
|
lock, release := s.acquireCancelInFlightGuard(sessionID)
|
|
defer release()
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
|
|
if claimEntryID != "" && !s.isCurrentQueuedDispatch(sessionID, claimEntryID) {
|
|
return nil, errQueuedDispatchSuperseded
|
|
}
|
|
freshSession, sessErr := s.repo.GetTaskSession(ctx, sessionID)
|
|
if sessErr != nil {
|
|
return nil, fmt.Errorf("reload session before claiming queued dispatch: %w", sessErr)
|
|
}
|
|
if freshSession == nil {
|
|
return nil, errQueuedDispatchSuperseded
|
|
}
|
|
if promptErr := s.checkSessionPromptable(taskID, sessionID, freshSession.State); promptErr != nil {
|
|
return nil, promptErr
|
|
}
|
|
s.setSessionRunning(ctx, taskID, sessionID, freshSession)
|
|
// setSessionRunning refreshes freshSession in place when its guarded write
|
|
// loses, so a cancellation landing after the promptability check is visible
|
|
// here as a terminal state.
|
|
if isTerminalSessionState(freshSession.State) && freshSession.State != models.TaskSessionStateCompleted {
|
|
return nil, &executor.SessionStateSupersededError{
|
|
SessionID: freshSession.ID,
|
|
State: freshSession.State,
|
|
}
|
|
}
|
|
// Clear the token now, inside the same lock, rather than deferring to
|
|
// executeQueuedMessage's cleanup after this entire (potentially
|
|
// long-blocking) call returns: from this point session.State is the
|
|
// authoritative "busy" signal for this dispatch, so holding the marker
|
|
// any longer would only risk the natural agent.ready firing when *this*
|
|
// turn completes seeing it still set and skipping the *next* queued
|
|
// entry — see the Service.dispatchingQueued field doc comment.
|
|
if claimEntryID != "" {
|
|
s.clearQueuedDispatchInFlightIfCurrent(sessionID, claimEntryID)
|
|
}
|
|
return freshSession, nil
|
|
}
|
|
|
|
// checkSessionPromptable returns nil when the session's state accepts a new
|
|
// prompt. RUNNING is rejected with ErrAgentPromptInProgress; any other
|
|
// non-acceptable state (STARTING / CREATED / FAILED / CANCELLED) is rejected
|
|
// with ErrSessionNotPromptable so callers can distinguish "wait for the
|
|
// current turn" from "this session is not in a state where it can take a
|
|
// prompt". IDLE is acceptable: office sessions intentionally park in IDLE
|
|
// between turns (agent torn down, ACP session preserved) and the next prompt
|
|
// resumes them — see ensureSessionRunning.
|
|
func (s *Service) checkSessionPromptable(taskID, sessionID string, state models.TaskSessionState) error {
|
|
switch state {
|
|
case models.TaskSessionStateWaitingForInput,
|
|
models.TaskSessionStateCompleted,
|
|
models.TaskSessionStateIdle:
|
|
return nil
|
|
case 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(state)))
|
|
return fmt.Errorf("%w, please wait for completion", ErrAgentPromptInProgress)
|
|
default:
|
|
s.logger.Warn("rejected prompt: session not ready for input",
|
|
zap.String("task_id", taskID),
|
|
zap.String("session_id", sessionID),
|
|
zap.String("session_state", string(state)))
|
|
return fmt.Errorf("%w: session is in %s state", ErrSessionNotPromptable, state)
|
|
}
|
|
}
|
|
|
|
// handlePromptError reverts session state, logs the failure, transitions the
|
|
// task to REVIEW for non-transient errors, and completes the in-flight turn.
|
|
// Returns the (possibly remapped) error for the caller to surface.
|
|
func (s *Service) handlePromptError(ctx context.Context, taskID, sessionID string, previousSessionState models.TaskSessionState, err error) error {
|
|
if isTransientPromptError(err) && s.isSessionResetInProgress(sessionID) {
|
|
s.logger.Warn("prompt deferred while session reset is in progress; retry expected",
|
|
zap.String("task_id", taskID),
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(err))
|
|
err = ErrSessionResetInProgress
|
|
} else {
|
|
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. Route through the
|
|
// wrapper so WS subscribers are notified — the UI relies on the
|
|
// session.state_changed broadcast to flip the composer/pause button out of
|
|
// "Agent is running". The wrapper's terminal-state guard is also correct here:
|
|
// if a concurrent agent-failure handler moved the session to FAILED we don't
|
|
// want to overwrite that with previousSessionState. Do NOT pass the preloaded
|
|
// `session` — the wrapper must re-read the row to see any such concurrent
|
|
// terminal transition; the stale pre-RUNNING snapshot would defeat the guard.
|
|
// allowWakeFromWaiting=false — this is a revert away from RUNNING, never a
|
|
// wake transition; the flag only matters when going WAITING_FOR_INPUT → RUNNING.
|
|
s.updateTaskSessionState(ctx, taskID, sessionID, previousSessionState, "", false)
|
|
// ErrCancelEscalated means the user cancelled and the lifecycle manager had to
|
|
// force-unblock a hung agent. Service.CancelAgent owns the cancel reconcile
|
|
// (session → WAITING_FOR_INPUT, task → REVIEW, cancel message, complete
|
|
// turn); skip the REVIEW write here so we don't race that path with a
|
|
// duplicate update.
|
|
// A transient provider error (529 Overloaded) is owned by the async
|
|
// retry-with-backoff path (handleTransientFailure), which keeps the task
|
|
// in progress while it retries — so don't flap it to REVIEW here.
|
|
if !isTransientPromptError(err) && !errors.Is(err, lifecycle.ErrCancelEscalated) &&
|
|
!routingerr.IsTransientProviderError(err.Error()) {
|
|
s.writeTaskReviewState(ctx, taskID, sessionID)
|
|
}
|
|
s.completeTurnForSession(ctx, sessionID)
|
|
return err
|
|
}
|
|
|
|
// trySwitchModel handles model switching for a prompt. Returns (result, true, nil) if a switch was
|
|
// performed, (nil, false, err) on error, or (nil, false, nil) if no switch was needed.
|
|
func (s *Service) trySwitchModel(ctx context.Context, taskID, sessionID, model, effectivePrompt string, session *models.TaskSession) (*PromptResult, bool, error) {
|
|
if model == "" {
|
|
return nil, false, nil
|
|
}
|
|
var currentModel string
|
|
if session.AgentProfileSnapshot != nil {
|
|
if m, ok := session.AgentProfileSnapshot["model"].(string); ok {
|
|
currentModel = m
|
|
}
|
|
}
|
|
if currentModel == model {
|
|
return nil, false, nil
|
|
}
|
|
s.logger.Info("switching model",
|
|
zap.String("task_id", taskID),
|
|
zap.String("session_id", sessionID),
|
|
zap.String("from", currentModel),
|
|
zap.String("to", model))
|
|
switchCtx := context.WithoutCancel(ctx)
|
|
switchResult, err := s.executor.SwitchModel(switchCtx, taskID, sessionID, model, effectivePrompt)
|
|
if err != nil {
|
|
return nil, true, fmt.Errorf("model switch failed: %w", err)
|
|
}
|
|
s.runtimeModelBySession.Store(sessionID, model)
|
|
if switchResult.StopReason == "model_switched_in_place" {
|
|
// Agent is still running with the new model — let PromptTask send the prompt normally.
|
|
// Invalidate the message creator's model cache so the next message picks up the new model.
|
|
if s.messageCreator != nil {
|
|
s.messageCreator.InvalidateModelCache(sessionID)
|
|
}
|
|
return nil, false, nil
|
|
}
|
|
s.startTurnForSession(ctx, sessionID)
|
|
s.setSessionRunning(ctx, taskID, sessionID, session)
|
|
return &PromptResult{
|
|
StopReason: switchResult.StopReason,
|
|
AgentMessage: switchResult.AgentMessage,
|
|
}, true, nil
|
|
}
|
|
|
|
// RespondToPermission sends a response to a permission request for a session
|
|
func (s *Service) RespondToPermission(ctx context.Context, sessionID, pendingID, optionID string, cancelled, rejected 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),
|
|
zap.Bool("rejected", rejected))
|
|
|
|
// 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, models.PermissionStatusExpired); 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. cancelled=true means the user dismissed
|
|
// the dialog; rejected=true means the user explicitly clicked Deny with a
|
|
// reject option. Both map to "rejected" message status.
|
|
status := models.PermissionStatusApproved
|
|
if cancelled || rejected {
|
|
status = models.PermissionStatusRejected
|
|
}
|
|
|
|
// 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", string(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, session)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DrainQueuedMessage dispatches one queued message for a session that is ready
|
|
// for input. It is intentionally one-at-a-time: each successful prompt will
|
|
// complete its own turn and then drain the next entry through handleAgentReady.
|
|
func (s *Service) DrainQueuedMessage(ctx context.Context, sessionID string) (bool, error) {
|
|
if sessionID == "" {
|
|
return false, fmt.Errorf("session_id is required")
|
|
}
|
|
// Acquire the guard *before* checking promptability, not after: check-
|
|
// then-lock would leave a gap where a concurrent
|
|
// QueueAndInterruptForPeerMessage (or another drain) changes the
|
|
// session's state between this call's check and its take, letting a
|
|
// manual drain request race a parent interrupt for the same entry —
|
|
// see the Service.cancelInFlight field doc comment. A blocking Lock
|
|
// (not a TryLock skip) is deliberate: unlike handleAgentReady /
|
|
// handleAgentBootReady (where a losing side can rely on the winning
|
|
// side's own take-and-dispatch, or a future turn's own agent.ready, to
|
|
// retry), a manual drain request has no other future trigger —
|
|
// skipping here could strand an already-queued message instead of
|
|
// just reporting an accurate "not promptable right now".
|
|
lock, release := s.acquireCancelInFlightGuard(sessionID)
|
|
defer release()
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
|
|
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to get session: %w", err)
|
|
}
|
|
if err := s.checkSessionPromptable(session.TaskID, sessionID, session.State); err != nil {
|
|
return false, err
|
|
}
|
|
return s.drainQueuedMessageForPromptableSessionLocked(ctx, sessionID), nil
|
|
}
|
|
|
|
// cancelInFlightGuard is a per-session mutex serializing cancel/interrupt/
|
|
// queue-take decisions (see the Service.cancelInFlight field doc comment),
|
|
// paired with a reference count so the registry can safely reclaim the
|
|
// entry once every acquirer has released it — refs is guarded by
|
|
// Service.cancelInFlightMu, never by mu itself, since mu can be held for
|
|
// the caller's whole critical section while refs bookkeeping must stay
|
|
// brief and independent of that.
|
|
type cancelInFlightGuard struct {
|
|
mu sync.Mutex
|
|
refs int
|
|
}
|
|
|
|
// acquireCancelInFlightGuard returns the shared per-session mutex guarding
|
|
// cancel/interrupt/queue-take decisions for sessionID, creating one if it
|
|
// doesn't exist yet and registering the caller's reference to it. Callers
|
|
// MUST call the returned release func exactly once when done with the
|
|
// mutex — whether or not they actually acquired it (e.g. a TryLock that
|
|
// returned false still needs release to drop its reference). Pairing every
|
|
// acquire with a release is what keeps cancelInFlight bounded by
|
|
// concurrently-active sessions: once refs drops to zero the entry is
|
|
// deleted from the map instead of accumulating one permanent entry per
|
|
// session ever created.
|
|
func (s *Service) acquireCancelInFlightGuard(sessionID string) (*sync.Mutex, func()) {
|
|
s.cancelInFlightMu.Lock()
|
|
guard, ok := s.cancelInFlight[sessionID]
|
|
if !ok {
|
|
guard = &cancelInFlightGuard{}
|
|
if s.cancelInFlight == nil {
|
|
s.cancelInFlight = make(map[string]*cancelInFlightGuard)
|
|
}
|
|
s.cancelInFlight[sessionID] = guard
|
|
}
|
|
guard.refs++
|
|
s.cancelInFlightMu.Unlock()
|
|
|
|
var released bool
|
|
release := func() {
|
|
if released {
|
|
return
|
|
}
|
|
released = true
|
|
s.cancelInFlightMu.Lock()
|
|
guard.refs--
|
|
if guard.refs == 0 {
|
|
delete(s.cancelInFlight, sessionID)
|
|
}
|
|
s.cancelInFlightMu.Unlock()
|
|
}
|
|
return &guard.mu, release
|
|
}
|
|
|
|
// isCancelInFlight reports whether sessionID's cancelInFlight guard is
|
|
// currently held by someone else, without itself claiming it — a
|
|
// TryLock-then-immediately-Unlock peek rather than a real acquisition. The
|
|
// guard entry this creates to perform the peek is released (and pruned if
|
|
// nothing else references it) before this returns, so a passive readiness
|
|
// probe never leaves permanent state behind.
|
|
func (s *Service) isCancelInFlight(sessionID string) bool {
|
|
lock, release := s.acquireCancelInFlightGuard(sessionID)
|
|
defer release()
|
|
if lock.TryLock() {
|
|
lock.Unlock()
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// CancelAgent interrupts the current agent turn without terminating the process,
|
|
// allowing the user to send a new prompt.
|
|
//
|
|
// Idempotent w.r.t. missing executions: if the agent manager reports no live execution
|
|
// for the session (ErrNoExecutionForSession), the method still reconciles the session's
|
|
// DB state (transitions to WAITING_FOR_INPUT, records the cancel message, completes the
|
|
// turn) so the user can unstick a session whose agent subprocess crashed. Other errors
|
|
// still fail the cancel.
|
|
func (s *Service) CancelAgent(ctx context.Context, sessionID string) error {
|
|
s.logger.Debug("cancelling agent turn", zap.String("session_id", sessionID))
|
|
|
|
// Deduplicate concurrent retries. The UI's cancel button has no in-flight
|
|
// disable, so impatient users click it multiple times while the agent is
|
|
// still tearing down the turn (e.g. unwinding a Claude Monitor tool can take
|
|
// several seconds). Without this guard each click produces a duplicate
|
|
// "Turn cancelled by user" message and races on turn cleanup — the second
|
|
// call's getActiveTurnID lazily starts a phantom turn after the first call
|
|
// already closed the real one.
|
|
lock, release := s.acquireCancelInFlightGuard(sessionID)
|
|
defer release()
|
|
if !lock.TryLock() {
|
|
s.logger.Debug("cancel already in flight; skipping duplicate",
|
|
zap.String("session_id", sessionID))
|
|
return nil
|
|
}
|
|
unlocked := false
|
|
unlockGuard := func() {
|
|
if !unlocked {
|
|
unlocked = true
|
|
lock.Unlock()
|
|
}
|
|
}
|
|
defer unlockGuard()
|
|
|
|
// 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))
|
|
}
|
|
|
|
// Capture the active turn before cancelling so the cancel message attaches
|
|
// to the turn the user was actually cancelling. If we waited until after
|
|
// agentManager.CancelAgent, the agent's complete event could have already
|
|
// closed the turn, and getActiveTurnID would lazily create a phantom turn
|
|
// just to host the cancel message.
|
|
cancelTurnID := s.getActiveTurnID(sessionID)
|
|
|
|
// The agent manager routes the cancel to the right signal: ACP cancel for
|
|
// regular sessions, Ctrl-C via PTY stdin for passthrough sessions. Service
|
|
// stays protocol-agnostic; the seam is in lifecycle.Manager.CancelAgentBySessionID.
|
|
if err := s.agentManager.CancelAgent(ctx, sessionID); err != nil {
|
|
switch {
|
|
case errors.Is(err, lifecycle.ErrNoExecutionForSession):
|
|
// The session was live but there is no execution to cancel — the agent process
|
|
// crashed, exited, or never re-registered after a backend restart. Log at error
|
|
// level so operators notice the stuck state; we still reconcile DB state below
|
|
// so the UI unsticks.
|
|
s.logger.Error("agent process appears to have crashed: no live execution for session on cancel",
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(err))
|
|
case errors.Is(err, lifecycle.ErrCancelEscalated):
|
|
// The agent accepted the ACP cancel but never published a completion event.
|
|
// The lifecycle manager already unblocked the in-flight prompt and marked the
|
|
// execution ready; reconcile DB state below so the UI unsticks.
|
|
s.logger.Warn("agent did not acknowledge cancel; reconciling session state",
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(err))
|
|
default:
|
|
return fmt.Errorf("cancel agent: %w", err)
|
|
}
|
|
}
|
|
|
|
// Transition session to WAITING_FOR_INPUT so the user can send a new
|
|
// prompt, and reconcile the task row to REVIEW so the sidebar shows the
|
|
// green check rather than the yellow "needs input" question icon — a
|
|
// cancelled turn is treated as finished work the user may want to review.
|
|
if session != nil {
|
|
s.updateTaskSessionState(ctx, session.TaskID, sessionID, models.TaskSessionStateWaitingForInput, "", true, session)
|
|
s.writeTaskReviewStateOnCancel(ctx, session.TaskID, sessionID)
|
|
}
|
|
|
|
// 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),
|
|
cancelTurnID,
|
|
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. Idempotent w.r.t. a
|
|
// concurrent agent.complete event having already closed the turn.
|
|
s.completeTurnForSession(ctx, sessionID)
|
|
|
|
// Release the cancel/interrupt guard now that this session's cancel is
|
|
// fully resolved. Do not drain here: a user cancellation must not itself
|
|
// start the next queued message. Both unlockGuard and release are
|
|
// idempotent, so the deferred calls above become safe no-op error-path
|
|
// safety nets once this fires.
|
|
unlockGuard()
|
|
release()
|
|
|
|
s.logger.Debug("agent turn cancelled", zap.String("session_id", sessionID))
|
|
return nil
|
|
}
|
|
|
|
// takeAndDispatchEntryLocked takes entryID from sessionID's queue and
|
|
// dispatches it, falling back to draining the FIFO head only if the
|
|
// targeted take doesn't find it (a benign not-found, e.g. already taken by
|
|
// a concurrent path). A genuine repository error on the targeted take is
|
|
// propagated instead of falling back — see cancelAndTakeForPeerMessage's
|
|
// doc comment for why. The caller must already hold sessionID's
|
|
// cancelInFlight lock; this neither acquires nor releases it.
|
|
//
|
|
// Deliberately does NOT check isQueuedDispatchInFlight itself: callers
|
|
// that reach here have either just performed a cancel
|
|
// (cancelAndTakeForPeerMessage) — which supersedes whatever another
|
|
// dispatch was in the middle of settling, so a stale in-flight marker from
|
|
// that dispatch must not block taking a *different* entry here — or have
|
|
// already confirmed the session is genuinely idle (takeIfPromptableLocked,
|
|
// which checks the marker itself before delegating here). See
|
|
// drainQueuedMessageForPromptableSessionLocked for the *non-cancelling*
|
|
// FIFO-head drain path, which does check the marker directly.
|
|
func (s *Service) takeAndDispatchEntryLocked(ctx context.Context, sessionID, entryID string) (bool, error) {
|
|
if s.messageQueue != nil {
|
|
queuedMsg, ok, err := s.messageQueue.TakeQueuedEntry(ctx, sessionID, entryID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("take targeted queued message: %w", err)
|
|
}
|
|
if s.dispatchTakenQueuedMessage(ctx, sessionID, queuedMsg, ok) {
|
|
return true, nil
|
|
}
|
|
}
|
|
return s.drainQueuedMessageForPromptableSessionLocked(ctx, sessionID), nil
|
|
}
|
|
|
|
// takeIfPromptableLocked takes and dispatches entryID only if sessionID is
|
|
// currently promptable, without ever issuing a cancel — used by
|
|
// QueueAndInterruptForPeerMessage when cancelling would risk hitting a
|
|
// *different*, unrelated turn instead of the one the caller meant to
|
|
// interrupt (see its active-turn revalidation doc comment). Returns
|
|
// (false, nil), never an error, when the session turns out not to be
|
|
// promptable — or when a *different* dispatch is already settling for
|
|
// this session (isQueuedDispatchInFlight; see the Service.dispatchingQueued
|
|
// field doc comment) — mirroring cancelAndTakeForPeerMessage's own "cancel
|
|
// failed but already promptable" recovery except without ever attempting
|
|
// the cancel. Unlike cancelAndTakeForPeerMessage's path, this never
|
|
// cancels anything, so it has no way to supersede a settling dispatch the
|
|
// way a genuine cancel would — it must defer to it instead. The caller
|
|
// must already hold sessionID's cancelInFlight lock.
|
|
func (s *Service) takeIfPromptableLocked(ctx context.Context, taskID, sessionID, entryID string) (bool, error) {
|
|
if s.isQueuedDispatchInFlight(sessionID) {
|
|
return false, nil
|
|
}
|
|
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
|
if err != nil || session == nil || s.checkSessionPromptable(taskID, sessionID, session.State) != nil {
|
|
return false, nil
|
|
}
|
|
return s.takeAndDispatchEntryLocked(ctx, sessionID, entryID)
|
|
}
|
|
|
|
// cancelAndTakeForPeerMessage cancels sessionID's in-flight turn and takes
|
|
// and dispatches entryID — the specific message that triggered this
|
|
// interrupt — falling back to draining the FIFO head only if the targeted
|
|
// take doesn't find it (a benign not-found, e.g. already taken by a
|
|
// concurrent path). entryID must be non-empty: the only caller,
|
|
// QueueAndInterruptForPeerMessage, always passes the entry it just
|
|
// inserted. A genuine repository error on the targeted take is propagated
|
|
// instead of falling back: an error says nothing about which entry is
|
|
// actually at the FIFO head, and dispatching it anyway would risk
|
|
// delivering the wrong message while the caller still reports "sent" for
|
|
// the parent's.
|
|
//
|
|
// The caller must already hold sessionID's cancelInFlight lock; this
|
|
// neither acquires nor releases it — see QueueAndInterruptForPeerMessage.
|
|
//
|
|
// The returned bool reports whether this call actually dispatched a
|
|
// message; a false result means the caller's message is still only
|
|
// queued, to be delivered later by whichever drain gets to it.
|
|
//
|
|
// Deliberately mirrors cancelAgentSilent + dispatchTakenQueuedMessage rather
|
|
// than delegating to CancelAgent: the interrupt is an internal steering
|
|
// signal from the parent, not a user action, so unlike the cancel button it
|
|
// must not write a visible "Turn cancelled" message and must not move the
|
|
// task to REVIEW (writeTaskReviewStateOnCancel).
|
|
func (s *Service) cancelAndTakeForPeerMessage(ctx context.Context, taskID, sessionID, entryID string) (bool, error) {
|
|
if cancelErr := s.cancelAgentSilent(ctx, taskID, sessionID); cancelErr != nil {
|
|
// A genuine (non-tolerated — cancelAgentSilent already swallows "no
|
|
// active execution") cancel failure leaves session state untouched
|
|
// by this call: cancelAgentSilent returns before reaching its own
|
|
// updateTaskSessionState/completeTurnForSession reconciliation on
|
|
// that path. Every other decision-maker that could independently
|
|
// mark the session promptable — handleAgentReady,
|
|
// handleAgentBootReady, CancelAgent, clarification recovery — now
|
|
// serializes through this same cancelInFlight guard (see the
|
|
// Service.cancelInFlight field doc comment), so none of them can be
|
|
// racing this exact call while we hold it. This recheck is a
|
|
// defensive backstop for any not-yet-guarded completion path or a
|
|
// stale read further up the call chain: if the session turns out to
|
|
// already be promptable despite the cancel error, take-and-dispatch
|
|
// anyway instead of leaving the message stranded with nothing left
|
|
// to trigger it.
|
|
session, sessErr := s.repo.GetTaskSession(ctx, sessionID)
|
|
if sessErr != nil || session == nil || s.checkSessionPromptable(taskID, sessionID, session.State) != nil {
|
|
return false, fmt.Errorf("interrupt for peer message: %w", cancelErr)
|
|
}
|
|
s.logger.Warn("cancel failed but session is already promptable; taking queued message directly instead of relying on a future drain",
|
|
zap.String("task_id", taskID),
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(cancelErr))
|
|
}
|
|
return s.takeAndDispatchEntryLocked(ctx, sessionID, entryID)
|
|
}
|
|
|
|
// QueueAndInterruptForPeerMessage atomically queues prompt for sessionID
|
|
// and then interrupts the child's in-flight turn to deliver it immediately,
|
|
// instead of waiting for the turn to end naturally. It is used only by
|
|
// handleMessageTask (mcp/handlers) when the sender is the target task's
|
|
// parent: a long-running child otherwise leaves its parent's control/steer
|
|
// messages parked on the FIFO queue until the child's current turn finishes
|
|
// on its own, and with several such children running in parallel the
|
|
// per-session queue can fill up to messagequeue.DefaultMaxPerSession before
|
|
// any of them are delivered.
|
|
//
|
|
// This must be one atomic operation rather than "queue, then separately
|
|
// interrupt" (the original two-step split across the handlers/orchestrator
|
|
// boundary): between an insert becoming visible and a later, separate
|
|
// interrupt call acquiring the session's cancelInFlight lock, the child's
|
|
// turn can complete naturally and handleAgentReady's normal FIFO drain can
|
|
// 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. Taking the lock before the
|
|
// queue insert closes that: handleAgentReady and handleAgentBootReady's
|
|
// take decisions claim the exact same lock before their own take, so neither
|
|
// can ever observe — and steal — an
|
|
// entry this call is still in the process of queueing-and-claiming.
|
|
//
|
|
// The lock is a genuine, blocking claim (Lock, mirroring
|
|
// executor.Executor.getSessionLock's precedent for per-session mutual
|
|
// exclusion in this codebase) — not a try-once peek with a fallback
|
|
// unguarded insert. Working around a busy lock with an unguarded insert
|
|
// would leave that insert visible to nobody in particular: the current
|
|
// holder may have already finished its own take-or-skip decision before
|
|
// the insert lands, and nothing then guarantees a future drain (the
|
|
// session can go idle with no further agent.ready to retry on). Every
|
|
// existing holder already bounds its own hold time through an independent
|
|
// mechanism (cancelAgentSilent's underlying agent-cancel escalation
|
|
// timeout, or a single fast DB round-trip in the natural-drain paths), so
|
|
// blocking here adds no new deadlock risk.
|
|
//
|
|
// Active-turn revalidation: after acquiring the lock and queueing the
|
|
// message, this re-checks whether the turn active for sessionID is still
|
|
// the one that was active when the caller decided to interrupt (snapshotted
|
|
// via peekActiveTurnID *before* contending for the lock). If a *different*
|
|
// turn is now active — e.g. a workflow on_turn_complete transition
|
|
// auto-started a successor for this same session while this call waited
|
|
// for the guard — cancelling now would kill that unrelated successor turn
|
|
// instead of the one the parent meant to interrupt. In that case this
|
|
// falls back to takeIfPromptableLocked (dispatch directly only if the
|
|
// session happens to already be idle *and* no other dispatch is
|
|
// concurrently settling for it, per isQueuedDispatchInFlight — see the
|
|
// Service.dispatchingQueued field doc comment; otherwise leave the
|
|
// message queued for whichever turn is actually running to drain
|
|
// naturally) instead of ever calling cancelAndTakeForPeerMessage. The
|
|
// turn-identity comparison only trusts a positively-confirmed match — both
|
|
// snapshots read without error, both non-empty, and equal — before it
|
|
// will risk a cancel; any error or mismatch (including "no turn observed
|
|
// at all") takes the safe fallback. The one exception is turnService
|
|
// being unset entirely: with no turn identity obtainable at all, this
|
|
// preserves the exact unconditional-interrupt behavior every existing
|
|
// (turn-unaware) caller and test exercises.
|
|
//
|
|
// Note this deliberately does *not* also gate on isQueuedDispatchInFlight
|
|
// when the turn *is* confirmed unchanged: a cancel (via
|
|
// cancelAndTakeForPeerMessage below) supersedes whatever another dispatch
|
|
// for this same session was in the middle of settling — e.g. a second
|
|
// parent message arriving right behind a first one whose own dispatch
|
|
// hasn't yet reached PromptTask — so it must still be allowed to cancel
|
|
// and take its own entry rather than being blocked by a marker the cancel
|
|
// itself is about to invalidate.
|
|
func (s *Service) QueueAndInterruptForPeerMessage(ctx context.Context, taskID, sessionID, prompt string, metadata map[string]interface{}) (*messagequeue.QueuedMessage, bool, error) {
|
|
if s.messageQueue == nil {
|
|
return nil, false, errors.New("message queue not available")
|
|
}
|
|
|
|
turnBeforeWait, turnPeekErr := s.peekActiveTurnID(ctx, sessionID)
|
|
if turnPeekErr != nil {
|
|
s.logger.Warn("failed to snapshot active turn before peer-message interrupt; will fall back to a promptable-only dispatch instead of risking a cancel of unrelated work",
|
|
zap.String("task_id", taskID),
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(turnPeekErr))
|
|
}
|
|
|
|
lock, release := s.acquireCancelInFlightGuard(sessionID)
|
|
defer release()
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
|
|
queued, err := s.messageQueue.QueueMessageWithMetadata(ctx, sessionID, taskID, prompt, "", messagequeue.QueuedByAgent, false, nil, metadata)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
s.publishQueueStatusEvent(ctx, sessionID)
|
|
|
|
if s.turnService != nil {
|
|
turnNow, turnNowErr := s.peekActiveTurnID(ctx, sessionID)
|
|
confirmedUnchanged := turnPeekErr == nil && turnNowErr == nil && turnBeforeWait != "" && turnNow == turnBeforeWait
|
|
if !confirmedUnchanged {
|
|
s.logger.Warn("active turn could not be confirmed unchanged while waiting to interrupt; dispatching only if already idle instead of risking a cancel of unrelated work",
|
|
zap.String("task_id", taskID),
|
|
zap.String("session_id", sessionID),
|
|
zap.String("turn_before_wait", turnBeforeWait),
|
|
zap.Bool("turn_peek_error", turnPeekErr != nil || turnNowErr != nil))
|
|
dispatched, fallbackErr := s.takeIfPromptableLocked(ctx, taskID, sessionID, queued.ID)
|
|
return queued, dispatched, fallbackErr
|
|
}
|
|
}
|
|
|
|
dispatched, err := s.cancelAndTakeForPeerMessage(ctx, taskID, sessionID, queued.ID)
|
|
return queued, dispatched, err
|
|
}
|
|
|
|
// 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.processParentChildrenCompletedForTaskState(ctx, taskID, v1.TaskStateCompleted)
|
|
|
|
s.logger.Info("task marked as COMPLETED",
|
|
zap.String("task_id", taskID))
|
|
return nil
|
|
}
|
|
|
|
// ResetAgentContext resets the agent's conversation context for a session,
|
|
// clearing conversation history while preserving the workspace environment.
|
|
func (s *Service) ResetAgentContext(ctx context.Context, sessionID string) error {
|
|
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("session not found: %w", err)
|
|
}
|
|
if session.State != models.TaskSessionStateWaitingForInput {
|
|
return fmt.Errorf("agent must be idle to reset context, current state: %s", session.State)
|
|
}
|
|
if hasRunning, hasErr := s.repo.HasExecutorRunningRow(ctx, sessionID); hasErr != nil || !hasRunning {
|
|
return fmt.Errorf("no active agent execution for session %s", sessionID)
|
|
}
|
|
|
|
// Set STARTING so frontend disables input and shows restarting state
|
|
s.updateTaskSessionState(ctx, session.TaskID, sessionID, models.TaskSessionStateStarting, "", false, session)
|
|
|
|
if ok := s.resetAgentContext(ctx, session.TaskID, session, "user_request"); !ok {
|
|
// Restore WAITING_FOR_INPUT on failure
|
|
s.setSessionWaitingForInput(ctx, session.TaskID, sessionID)
|
|
return fmt.Errorf("failed to reset agent context for session %s", sessionID)
|
|
}
|
|
|
|
// Restore WAITING_FOR_INPUT — handleAgentReady ignores events during reset
|
|
s.setSessionWaitingForInput(ctx, session.TaskID, sessionID)
|
|
|
|
if s.messageCreator != nil {
|
|
if err := s.messageCreator.CreateSessionMessage(
|
|
ctx, session.TaskID,
|
|
"Context reset — new conversation started",
|
|
sessionID, string(v1.MessageTypeStatus),
|
|
s.getActiveTurnID(sessionID),
|
|
nil, false,
|
|
); err != nil {
|
|
s.logger.Warn("failed to create context reset message",
|
|
zap.String("session_id", sessionID), zap.Error(err))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetQueuedTasks returns tasks in the queue
|
|
func (s *Service) GetQueuedTasks() []*queue.QueuedTask {
|
|
return s.queue.List()
|
|
}
|