From 4286152f42490a8cfc9a51f75022ab9f04942560 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Feb 2026 20:46:00 +0100 Subject: [PATCH 1/7] fix: resolve Kanban board stuck task state synchronization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an agent process exits with incomplete/stuck subtasks, the task gets stuck in the Kanban UI — it spins forever, can't be stopped, and can't be dragged back to planning. This fix addresses 5 specific state synchronization gaps between the backend process lifecycle and the frontend XState state machine: 1. Reset execution progress on terminal state transitions (task-store.ts) - When tasks reach terminal states (human_review, error, done, pr_created), execution progress is now reset to idle - Prevents stuck tasks from showing stale progress indicators in UI 2. Propagate final phase updates even after XState settles (agent-events-handlers.ts) - Final 'complete' or 'failed' phase updates are now sent to renderer - Previously these were silently dropped, causing UI to never show completion 3. Add fallback exit handler to force state transition (agent-events-handlers.ts) - If task remains in_progress 500ms after process exit, force to human_review - Safety net for when XState fails to properly handle PROCESS_EXITED event 4. Disable dragging for in_progress tasks (SortableTaskCard.tsx) - Prevents users from dragging tasks that are currently running or stuck - Adds disabled flag to useSortable hook when status is 'in_progress' 5. Allow stop button for stuck tasks (TaskCard.tsx) - Users can now force-stop stuck tasks using the stop button - Removed the isStuck check that prevented stopping stuck tasks Co-Authored-By: Claude Opus 4.6 --- .../ipc-handlers/agent-events-handlers.ts | 20 ++++++++++++++++--- .../renderer/components/SortableTaskCard.tsx | 5 ++++- .../src/renderer/components/TaskCard.tsx | 4 +++- .../src/renderer/stores/task-store.ts | 4 ++++ 4 files changed, 28 insertions(+), 5 deletions(-) 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..16df32fb23 100644 --- a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts @@ -96,6 +96,17 @@ export function registerAgenteventsHandlers( taskStateManager.handleProcessExited(taskId, code, exitTask, exitProject); + // Fallback safety net: If XState failed to transition the task out of in_progress, + // force it to human_review after a short delay. This prevents tasks from getting stuck + // in in_progress state when the process exits without XState properly handling it. + setTimeout(() => { + const { task: checkTask } = findTaskAndProject(taskId, projectId); + if (checkTask && checkTask.status === 'in_progress') { + console.warn(`[agent-events-handlers] Task ${taskId} still in_progress 500ms after exit, forcing to human_review`); + taskStateManager.forceTransition(taskId, 'human_review', 'errors', checkTask, exitProject); + } + }, 500); + // 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,9 +236,12 @@ 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. - if (xstateInTerminalState) { + // Skip sending execution-progress to renderer when XState has settled, + // UNLESS this is a final phase update (complete/failed). + // Final phase updates must still propagate to renderer even after XState settles, + // otherwise the UI never receives the final progress state. + const isFinalPhaseUpdate = progress.phase === 'complete' || progress.phase === 'failed'; + if (xstateInTerminalState && !isFinalPhaseUpdate) { console.debug(`[agent-events-handlers] Skipping execution-progress to renderer for ${taskId}: XState in '${currentXState}', ignoring phase '${progress.phase}'`); return; } 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..19c0cdf0e1 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 (status === 'human_review' || status === 'error' || status === 'done' || status === 'pr_created') { + // 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 From cb8ed52eca2e4d1e208f65597ba99402a5892b0b Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Feb 2026 20:58:09 +0100 Subject: [PATCH 2/7] fix: correct fallback timeout to use existing TaskStateManager API The 500ms fallback safety net was calling `taskStateManager.forceTransition()` which doesn't exist, causing TypeScript compilation errors. Fixed to: - Use `handleUiEvent()` with `USER_STOPPED` event (proper XState transition) - Look up both task and project fresh (avoid stale closure references) - Add null check for `checkProject` before proceeding This ensures the fallback actually works when XState fails to transition tasks out of in_progress after process exit. Co-Authored-By: Claude Opus 4.6 --- .../src/main/ipc-handlers/agent-events-handlers.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 16df32fb23..3ad35f9719 100644 --- a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts @@ -100,10 +100,10 @@ export function registerAgenteventsHandlers( // force it to human_review after a short delay. This prevents tasks from getting stuck // in in_progress state when the process exits without XState properly handling it. setTimeout(() => { - const { task: checkTask } = findTaskAndProject(taskId, projectId); - if (checkTask && checkTask.status === 'in_progress') { - console.warn(`[agent-events-handlers] Task ${taskId} still in_progress 500ms after exit, forcing to human_review`); - taskStateManager.forceTransition(taskId, 'human_review', 'errors', checkTask, exitProject); + const { task: checkTask, project: checkProject } = findTaskAndProject(taskId, projectId); + if (checkTask && checkTask.status === 'in_progress' && checkProject) { + console.warn(`[agent-events-handlers] Task ${taskId} still in_progress 500ms after exit, forcing USER_STOPPED`); + taskStateManager.handleUiEvent(taskId, { type: 'USER_STOPPED', hasPlan: true }, checkTask, checkProject); } }, 500); From 4c00536dc5c98f2df4236c627a09864ce6caa0f1 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sun, 15 Feb 2026 17:38:07 +0100 Subject: [PATCH 3/7] fix: address all review findings for PR #1833 - Add named constant STUCK_TASK_FALLBACK_TIMEOUT_MS for magic number - Use XState getCurrentState() instead of cached task status to avoid stale cache issues - Check XState active states directly (planning, coding, qa_review, qa_fixing) - Improve logging to show actual XState state name - Add inline comments explaining the stale cache avoidance strategy This resolves all 3 blocking issues from the Auto Claude review: 1. CRITICAL: forceTransition() method - fixed by using handleUiEvent() 2. MEDIUM: Stale closure - fixed by checking XState directly 3. MEDIUM: Magic number - fixed by using named constant Co-Authored-By: Claude Opus 4.6 --- .../ipc-handlers/agent-events-handlers.ts | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) 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 3ad35f9719..edd21966ea 100644 --- a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts @@ -20,6 +20,9 @@ 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; + /** * Register all agent-events-related IPC handlers */ @@ -96,16 +99,26 @@ export function registerAgenteventsHandlers( taskStateManager.handleProcessExited(taskId, code, exitTask, exitProject); - // Fallback safety net: If XState failed to transition the task out of in_progress, + // 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 - // in in_progress state when the process exits without XState properly handling it. + // when the process exits without XState properly handling it. + // We check XState's current state directly to avoid stale cache issues from projectStore. setTimeout(() => { - const { task: checkTask, project: checkProject } = findTaskAndProject(taskId, projectId); - if (checkTask && checkTask.status === 'in_progress' && checkProject) { - console.warn(`[agent-events-handlers] Task ${taskId} still in_progress 500ms after exit, forcing USER_STOPPED`); - taskStateManager.handleUiEvent(taskId, { type: 'USER_STOPPED', hasPlan: true }, checkTask, checkProject); + const currentState = taskStateManager.getCurrentState(taskId); + // Active states that should transition on process exit: planning, coding, qa_review, qa_fixing + const activeStates = ['planning', 'coding', 'qa_review', 'qa_fixing']; + + if (currentState && activeStates.includes(currentState)) { + const { task: checkTask, project: checkProject } = findTaskAndProject(taskId, projectId); + if (checkTask && checkProject) { + console.warn( + `[agent-events-handlers] Task ${taskId} still in XState ${currentState} ` + + `${STUCK_TASK_FALLBACK_TIMEOUT_MS}ms after exit, forcing USER_STOPPED` + ); + taskStateManager.handleUiEvent(taskId, { type: 'USER_STOPPED', hasPlan: true }, checkTask, checkProject); + } } - }, 500); + }, STUCK_TASK_FALLBACK_TIMEOUT_MS); // Send final plan state to renderer BEFORE unwatching // This ensures the renderer has the final subtask data (fixes 0/0 subtask bug) From b098d0d703bb75e3a1049f4a0cb4b21edd130d21 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sun, 15 Feb 2026 20:30:29 +0100 Subject: [PATCH 4/7] fix: address follow-up review findings - dynamic hasPlan and shared constants Resolves NEW-001 and CMT-001 from the follow-up review: - Extract XSTATE_ACTIVE_STATES to shared constant in task-state-utils.ts - Export XSTATE_ACTIVE_STATES from state-machines index - Replace hardcoded hasPlan: true with dynamic plan file check - Pattern matches TASK_STOP handler (execution-handlers.ts lines 299-310) - Tasks stuck in 'planning' state now correctly route to backlog, not human_review - Improved logging shows hasPlan value for debugging This ensures the fallback safety net routes tasks to the correct Kanban column based on whether a plan file exists with subtasks. Co-Authored-By: Claude Opus 4.6 --- .../ipc-handlers/agent-events-handlers.ts | 27 ++++++++++++++----- .../src/shared/state-machines/index.ts | 1 + .../shared/state-machines/task-state-utils.ts | 8 ++++++ 3 files changed, 30 insertions(+), 6 deletions(-) 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 edd21966ea..9847026628 100644 --- a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts @@ -7,7 +7,7 @@ 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"; @@ -105,17 +105,32 @@ export function registerAgenteventsHandlers( // We check XState's current state directly to avoid stale cache issues from projectStore. setTimeout(() => { const currentState = taskStateManager.getCurrentState(taskId); - // Active states that should transition on process exit: planning, coding, qa_review, qa_fixing - const activeStates = ['planning', 'coding', 'qa_review', 'qa_fixing']; - if (currentState && activeStates.includes(currentState)) { + if (currentState && XSTATE_ACTIVE_STATES.has(currentState)) { const { task: checkTask, project: checkProject } = findTaskAndProject(taskId, projectId); if (checkTask && checkProject) { + // Determine hasPlan by checking if a valid implementation_plan.json exists + // This matches the pattern from TASK_STOP handler (execution-handlers.ts lines 299-310) + let hasPlan = false; + try { + const planPath = getPlanPath(checkProject, checkTask); + const planContent = readFileSync(planPath, 'utf-8'); + if (planContent) { + 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; + hasPlan = totalCount > 0; + } + } catch { + hasPlan = false; + } + console.warn( `[agent-events-handlers] Task ${taskId} still in XState ${currentState} ` + - `${STUCK_TASK_FALLBACK_TIMEOUT_MS}ms after exit, forcing USER_STOPPED` + `${STUCK_TASK_FALLBACK_TIMEOUT_MS}ms after exit, forcing USER_STOPPED (hasPlan: ${hasPlan})` ); - taskStateManager.handleUiEvent(taskId, { type: 'USER_STOPPED', hasPlan: true }, checkTask, checkProject); + taskStateManager.handleUiEvent(taskId, { type: 'USER_STOPPED', hasPlan }, checkTask, checkProject); } } }, STUCK_TASK_FALLBACK_TIMEOUT_MS); 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', From 3628fba7f3bd3cf3fc7daa5bc69558d52588adba Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sun, 15 Feb 2026 22:15:54 +0100 Subject: [PATCH 5/7] refactor: address all PR review findings for code quality - Extract hasPlan logic into shared hasPlanWithSubtasks() utility in plan-file-utils.ts to eliminate duplication and centralize plan validation logic - Make setTimeout callback async to avoid blocking readFileSync in event loop - Replace hardcoded terminal status check with cleaner .includes() pattern - Add status gate for final phase updates to prevent UI flicker when failed phase arrives after task has transitioned to human_review - Import and use hasPlanWithSubtasks in agent-events-handlers.ts All review comments addressed: - Gemini: Critical/Medium - forceTransition method, magic number (already fixed in previous commit) - Gemini: Medium - hardcoded status list (now uses .includes) - CodeRabbit: Trivial - extract XSTATE_ACTIVE_STATES (already fixed in previous commit) - CodeRabbit: Minor - derive hasPlan from file check (now uses shared utility) - CodeRabbit: Minor - prevent UI flicker from final phase updates (now gates on status) Co-Authored-By: Claude Opus 4.6 --- .../ipc-handlers/agent-events-handlers.ts | 41 ++++++++----------- .../main/ipc-handlers/task/plan-file-utils.ts | 27 ++++++++++++ .../src/renderer/stores/task-store.ts | 2 +- 3 files changed, 45 insertions(+), 25 deletions(-) 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 9847026628..2556ccd6d5 100644 --- a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts @@ -13,7 +13,7 @@ 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"; @@ -103,28 +103,14 @@ export function registerAgenteventsHandlers( // 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. - setTimeout(() => { + setTimeout(async () => { const currentState = taskStateManager.getCurrentState(taskId); if (currentState && XSTATE_ACTIVE_STATES.has(currentState)) { const { task: checkTask, project: checkProject } = findTaskAndProject(taskId, projectId); if (checkTask && checkProject) { - // Determine hasPlan by checking if a valid implementation_plan.json exists - // This matches the pattern from TASK_STOP handler (execution-handlers.ts lines 299-310) - let hasPlan = false; - try { - const planPath = getPlanPath(checkProject, checkTask); - const planContent = readFileSync(planPath, 'utf-8'); - if (planContent) { - 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; - hasPlan = totalCount > 0; - } - } catch { - hasPlan = false; - } + // 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} ` + @@ -265,13 +251,20 @@ export function registerAgenteventsHandlers( } // Skip sending execution-progress to renderer when XState has settled, - // UNLESS this is a final phase update (complete/failed). - // Final phase updates must still propagate to renderer even after XState settles, - // otherwise the UI never receives the final progress state. + // 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 && !isFinalPhaseUpdate) { - console.debug(`[agent-events-handlers] Skipping execution-progress to renderer for ${taskId}: XState in '${currentXState}', ignoring phase '${progress.phase}'`); - return; + if (xstateInTerminalState) { + 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, 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/stores/task-store.ts b/apps/frontend/src/renderer/stores/task-store.ts index 19c0cdf0e1..1bc901672d 100644 --- a/apps/frontend/src/renderer/stores/task-store.ts +++ b/apps/frontend/src/renderer/stores/task-store.ts @@ -309,7 +309,7 @@ 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 (status === 'human_review' || status === 'error' || status === 'done' || status === 'pr_created') { + } 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 }; From 92a3c821ed00ea2883c622af0465d387d3f773c9 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Mon, 16 Feb 2026 08:48:06 +0100 Subject: [PATCH 6/7] fix: address all auto-claude review findings for PR #1833 Fixed all 3 findings from the latest auto-claude review: 1. [NCR-NEW-001] MEDIUM: Fallback safety net timeout race condition - Store setTimeout timer reference in a Map keyed by taskId - Export cancelFallbackTimer() function to clear pending timers - Call cancelFallbackTimer() at start of TASK_START handler - Prevents stale timer from incorrectly stopping newly restarted tasks - Clean up timer reference after it fires 2. [CMT-NEW-001] LOW: Duplicate hasPlan detection logic - Replace inline hasPlan logic in execution-handlers.ts TASK_STOP - Use shared hasPlanWithSubtasks() utility from plan-file-utils.ts - Eliminates code duplication and ensures consistent behavior 3. [CMT-001] LOW: Misleading async keyword - Remove async from setTimeout callback in agent-events-handlers.ts - No await expressions exist in the callback - All operations (getCurrentState, hasPlanWithSubtasks) are synchronous All changes maintain backward compatibility and fix the race condition where restarting a task within 500ms could be incorrectly stopped by the fallback timer from the previous process exit. Co-Authored-By: Claude Opus 4.6 --- .../ipc-handlers/agent-events-handlers.ts | 25 ++++++++++++++++++- .../ipc-handlers/task/execution-handlers.ts | 23 ++++++++--------- 2 files changed, 34 insertions(+), 14 deletions(-) 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 2556ccd6d5..b55827a7e4 100644 --- a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts @@ -23,6 +23,9 @@ 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 */ @@ -103,7 +106,8 @@ export function registerAgenteventsHandlers( // 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. - setTimeout(async () => { + // 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)) { @@ -119,8 +123,13 @@ export function registerAgenteventsHandlers( 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); @@ -320,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..775e63d567 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, From 9334b71da8e8a7336f1f1e04da91809383a3e19a Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Tue, 17 Feb 2026 15:34:01 +0100 Subject: [PATCH 7/7] fix: cancel fallback timer in all task restart paths The fallback safety net timer was only cancelled in the TASK_START handler, but not in the TASK_UPDATE_STATUS auto-start path or TASK_RECOVER_STUCK auto-restart path. This meant a stale timer could incorrectly stop a newly restarted task if it was restarted within the 500ms window via drag-to-in-progress or recovery auto-restart. Co-Authored-By: Claude Opus 4.6 --- .../src/main/ipc-handlers/task/execution-handlers.ts | 8 ++++++++ 1 file changed, 8 insertions(+) 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 775e63d567..7699c92e84 100644 --- a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts @@ -699,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); @@ -1117,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';