diff --git a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts index c0625596cd..b55827a7e4 100644 --- a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts @@ -7,19 +7,25 @@ import type { AuthFailureInfo, ImplementationPlan, } from "../../shared/types"; -import { XSTATE_SETTLED_STATES, XSTATE_TO_PHASE, mapStateToLegacy } from "../../shared/state-machines"; +import { XSTATE_SETTLED_STATES, XSTATE_ACTIVE_STATES, XSTATE_TO_PHASE, mapStateToLegacy } from "../../shared/state-machines"; import { AgentManager } from "../agent"; import type { ProcessType, ExecutionProgressData } from "../agent"; import { titleGenerator } from "../title-generator"; import { fileWatcher } from "../file-watcher"; import { notificationService } from "../notification-service"; -import { persistPlanLastEventSync, getPlanPath, persistPlanPhaseSync, persistPlanStatusAndReasonSync } from "./task/plan-file-utils"; +import { persistPlanLastEventSync, getPlanPath, persistPlanPhaseSync, persistPlanStatusAndReasonSync, hasPlanWithSubtasks } from "./task/plan-file-utils"; import { findTaskWorktree } from "../worktree-paths"; import { findTaskAndProject } from "./task/shared"; import { safeSendToRenderer } from "./utils"; import { getClaudeProfileManager } from "../claude-profile-manager"; import { taskStateManager } from "../task-state-manager"; +// Timeout for fallback safety net to check if task is still stuck after process exit +const STUCK_TASK_FALLBACK_TIMEOUT_MS = 500; + +// Map to store active fallback timers so they can be cancelled on task restart +const fallbackTimers = new Map(); + /** * Register all agent-events-related IPC handlers */ @@ -96,6 +102,34 @@ export function registerAgenteventsHandlers( taskStateManager.handleProcessExited(taskId, code, exitTask, exitProject); + // Fallback safety net: If XState failed to transition the task out of an active state, + // force it to human_review after a short delay. This prevents tasks from getting stuck + // when the process exits without XState properly handling it. + // We check XState's current state directly to avoid stale cache issues from projectStore. + // Store timer reference so it can be cancelled if task restarts within the window. + const timer = setTimeout(() => { + const currentState = taskStateManager.getCurrentState(taskId); + + if (currentState && XSTATE_ACTIVE_STATES.has(currentState)) { + const { task: checkTask, project: checkProject } = findTaskAndProject(taskId, projectId); + if (checkTask && checkProject) { + // Use shared utility to determine if a valid implementation plan exists + const hasPlan = hasPlanWithSubtasks(checkProject, checkTask); + + console.warn( + `[agent-events-handlers] Task ${taskId} still in XState ${currentState} ` + + `${STUCK_TASK_FALLBACK_TIMEOUT_MS}ms after exit, forcing USER_STOPPED (hasPlan: ${hasPlan})` + ); + taskStateManager.handleUiEvent(taskId, { type: 'USER_STOPPED', hasPlan }, checkTask, checkProject); + } + } + // Clean up timer reference after it fires + fallbackTimers.delete(taskId); + }, STUCK_TASK_FALLBACK_TIMEOUT_MS); + + // Store timer reference for potential cancellation + fallbackTimers.set(taskId, timer); + // Send final plan state to renderer BEFORE unwatching // This ensures the renderer has the final subtask data (fixes 0/0 subtask bug) const finalPlan = fileWatcher.getCurrentPlan(taskId); @@ -225,11 +259,21 @@ export function registerAgenteventsHandlers( console.debug(`[agent-events-handlers] Skipping persistPlanPhaseSync for ${taskId}: XState in '${currentXState}', not overwriting with phase '${progress.phase}'`); } - // Skip sending execution-progress to renderer when XState has settled. - // XState's emitPhaseFromState already sent the correct phase to the renderer. + // Skip sending execution-progress to renderer when XState has settled, + // UNLESS this is a final phase update (complete/failed) AND the task is still in_progress. + // This prevents UI flicker where a failed phase arrives after the status has already changed to human_review. + const isFinalPhaseUpdate = progress.phase === 'complete' || progress.phase === 'failed'; if (xstateInTerminalState) { - console.debug(`[agent-events-handlers] Skipping execution-progress to renderer for ${taskId}: XState in '${currentXState}', ignoring phase '${progress.phase}'`); - return; + if (!isFinalPhaseUpdate) { + console.debug(`[agent-events-handlers] Skipping execution-progress to renderer for ${taskId}: XState in '${currentXState}', ignoring phase '${progress.phase}'`); + return; + } + // For final phase updates, only send if task is still in_progress to prevent flicker + const { task } = findTaskAndProject(taskId, taskProjectId); + if (task && task.status !== 'in_progress') { + console.debug(`[agent-events-handlers] Skipping final phase '${progress.phase}' for ${taskId}: task status is '${task.status}', not 'in_progress'`); + return; + } } safeSendToRenderer( getMainWindow, @@ -285,3 +329,17 @@ export function registerAgenteventsHandlers( safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_ERROR, taskId, error, project?.id); }); } + +/** + * Cancel any pending fallback timer for a task. + * Should be called when a task is restarted to prevent the stale timer + * from incorrectly stopping the new process. + */ +export function cancelFallbackTimer(taskId: string): void { + const timer = fallbackTimers.get(taskId); + if (timer) { + clearTimeout(timer); + fallbackTimers.delete(taskId); + console.debug(`[agent-events-handlers] Cancelled fallback timer for task ${taskId}`); + } +} diff --git a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts index 95bcb9d8e6..7699c92e84 100644 --- a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts @@ -15,12 +15,14 @@ import { getPlanPath, persistPlanStatus, createPlanIfNotExists, - resetStuckSubtasks + resetStuckSubtasks, + hasPlanWithSubtasks } from './plan-file-utils'; import { writeFileAtomicSync } from '../../utils/atomic-file'; import { findTaskWorktree } from '../../worktree-paths'; import { projectStore } from '../../project-store'; import { getIsolatedGitEnv, detectWorktreeBranch } from '../../utils/git-isolation'; +import { cancelFallbackTimer } from '../agent-events-handlers'; /** * Safe file read that handles missing files without TOCTOU issues. @@ -95,6 +97,11 @@ export function registerTaskExecutionHandlers( IPC_CHANNELS.TASK_START, async (_, taskId: string, _options?: TaskStartOptions) => { console.warn('[TASK_START] Received request for taskId:', taskId); + + // Cancel any pending fallback timer from previous process exit + // This prevents the stale timer from incorrectly stopping the newly restarted task + cancelFallbackTimer(taskId); + const mainWindow = getMainWindow(); if (!mainWindow) { console.warn('[TASK_START] No main window found'); @@ -296,18 +303,8 @@ export function registerTaskExecutionHandlers( if (!task || !project) return; - let hasPlan = false; - try { - const planPath = getPlanPath(project, task); - const planContent = safeReadFileSync(planPath); - if (planContent) { - const plan = JSON.parse(planContent); - const { totalCount } = checkSubtasksCompletion(plan); - hasPlan = totalCount > 0; - } - } catch { - hasPlan = false; - } + // Use shared utility to determine if a valid implementation plan exists + const hasPlan = hasPlanWithSubtasks(project, task); taskStateManager.handleUiEvent( taskId, @@ -702,6 +699,10 @@ export function registerTaskExecutionHandlers( console.warn('[TASK_UPDATE_STATUS] Auto-starting task:', taskId); + // Cancel any pending fallback timer from previous process exit + // This prevents the stale timer from incorrectly stopping the newly started task + cancelFallbackTimer(taskId); + // Reset any stuck subtasks before starting execution // This handles recovery from previous rate limits or crashes const resetResult = await resetStuckSubtasks(planPath, project.id); @@ -1120,6 +1121,10 @@ export function registerTaskExecutionHandlers( } try { + // Cancel any pending fallback timer from previous process exit + // This prevents the stale timer from incorrectly stopping the restarted task + cancelFallbackTimer(taskId); + // Set status to in_progress for the restart newStatus = 'in_progress'; diff --git a/apps/frontend/src/main/ipc-handlers/task/plan-file-utils.ts b/apps/frontend/src/main/ipc-handlers/task/plan-file-utils.ts index dfb08bf52f..da5c852aec 100644 --- a/apps/frontend/src/main/ipc-handlers/task/plan-file-utils.ts +++ b/apps/frontend/src/main/ipc-handlers/task/plan-file-utils.ts @@ -538,3 +538,30 @@ export function updateTaskMetadataPrUrl(metadataPath: string, prUrl: string): bo return false; } } + +/** + * Check if a task has a valid implementation plan with subtasks. + * A plan is considered valid if it has at least one subtask across all phases. + * + * @param project - The project containing the task + * @param task - The task to check + * @returns true if the task has a valid plan with subtasks, false otherwise + */ +export function hasPlanWithSubtasks(project: Project, task: Task): boolean { + try { + const planPath = getPlanPath(project, task); + const planContent = readFileSync(planPath, 'utf-8'); + if (!planContent) { + return false; + } + + const plan = JSON.parse(planContent); + // A plan exists if it has phases with subtasks (totalCount > 0) + const phases = plan.phases as Array<{ subtasks?: Array }> | undefined; + const totalCount = phases?.flatMap(p => p.subtasks || []).length || 0; + return totalCount > 0; + } catch { + // File doesn't exist or is malformed + return false; + } +} diff --git a/apps/frontend/src/renderer/components/SortableTaskCard.tsx b/apps/frontend/src/renderer/components/SortableTaskCard.tsx index 1bc21fb2ac..314af7e153 100644 --- a/apps/frontend/src/renderer/components/SortableTaskCard.tsx +++ b/apps/frontend/src/renderer/components/SortableTaskCard.tsx @@ -41,7 +41,10 @@ export const SortableTaskCard = memo(function SortableTaskCard({ task, onClick, transition, isDragging, isOver - } = useSortable({ id: task.id }); + } = useSortable({ + id: task.id, + disabled: task.status === 'in_progress' // Prevent dragging tasks that are currently running or stuck + }); const style = { transform: CSS.Transform.toString(transform), diff --git a/apps/frontend/src/renderer/components/TaskCard.tsx b/apps/frontend/src/renderer/components/TaskCard.tsx index 652346a7f6..837b7df58f 100644 --- a/apps/frontend/src/renderer/components/TaskCard.tsx +++ b/apps/frontend/src/renderer/components/TaskCard.tsx @@ -228,7 +228,9 @@ export const TaskCard = memo(function TaskCard({ const handleStartStop = async (e: React.MouseEvent) => { e.stopPropagation(); - if (isRunning && !isStuck) { + if (isRunning) { + // Allow stopping both running and stuck tasks + // User should be able to force-stop a stuck task stopTask(task.id); } else { const result = await startTaskOrQueue(task.id); diff --git a/apps/frontend/src/renderer/stores/task-store.ts b/apps/frontend/src/renderer/stores/task-store.ts index b0ef7206a3..1bc901672d 100644 --- a/apps/frontend/src/renderer/stores/task-store.ts +++ b/apps/frontend/src/renderer/stores/task-store.ts @@ -309,6 +309,10 @@ export const useTaskStore = create((set, get) => ({ // When starting a task and no phase is set yet, default to planning // This prevents the "no active phase" UI state during startup race condition executionProgress = { phase: 'planning' as ExecutionPhase, phaseProgress: 0, overallProgress: 0 }; + } else if (['human_review', 'error', 'done', 'pr_created'].includes(status)) { + // Reset execution progress when task reaches terminal states + // This prevents stuck tasks from showing stale progress indicators + executionProgress = { phase: 'idle' as ExecutionPhase, phaseProgress: 0, overallProgress: 0 }; } // Log status transitions to help diagnose flip-flop issues diff --git a/apps/frontend/src/shared/state-machines/index.ts b/apps/frontend/src/shared/state-machines/index.ts index 68cd1f3abb..f6c3eb4ddf 100644 --- a/apps/frontend/src/shared/state-machines/index.ts +++ b/apps/frontend/src/shared/state-machines/index.ts @@ -3,6 +3,7 @@ export type { TaskContext, TaskEvent } from './task-machine'; export { TASK_STATE_NAMES, XSTATE_SETTLED_STATES, + XSTATE_ACTIVE_STATES, XSTATE_TO_PHASE, mapStateToLegacy, } from './task-state-utils'; diff --git a/apps/frontend/src/shared/state-machines/task-state-utils.ts b/apps/frontend/src/shared/state-machines/task-state-utils.ts index 94799d76c7..2f117d0e9f 100644 --- a/apps/frontend/src/shared/state-machines/task-state-utils.ts +++ b/apps/frontend/src/shared/state-machines/task-state-utils.ts @@ -36,6 +36,14 @@ export const XSTATE_SETTLED_STATES: ReadonlySet = new Set 'plan_review', 'human_review', 'error', 'creating_pr', 'pr_created', 'done' ]); +/** + * XState states where an agent process is actively running and should transition + * when the process exits. Used by the fallback safety net to detect stuck tasks. + */ +export const XSTATE_ACTIVE_STATES: ReadonlySet = new Set([ + 'planning', 'coding', 'qa_review', 'qa_fixing' +]); + /** Maps XState states to execution phases. */ export const XSTATE_TO_PHASE: Record & Record = { 'backlog': 'idle',