Skip to content
70 changes: 64 additions & 6 deletions apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, NodeJS.Timeout>();

/**
* Register all agent-events-related IPC handlers
*/
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}`);
}
}
31 changes: 18 additions & 13 deletions apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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';

Expand Down
27 changes: 27 additions & 0 deletions apps/frontend/src/main/ipc-handlers/task/plan-file-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> }> | undefined;
const totalCount = phases?.flatMap(p => p.subtasks || []).length || 0;
return totalCount > 0;
} catch {
// File doesn't exist or is malformed
return false;
}
}
5 changes: 4 additions & 1 deletion apps/frontend/src/renderer/components/SortableTaskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
4 changes: 3 additions & 1 deletion apps/frontend/src/renderer/components/TaskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions apps/frontend/src/renderer/stores/task-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,10 @@ export const useTaskStore = create<TaskState>((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
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/src/shared/state-machines/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
8 changes: 8 additions & 0 deletions apps/frontend/src/shared/state-machines/task-state-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ export const XSTATE_SETTLED_STATES: ReadonlySet<string> = new Set<TaskStateName>
'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<string> = new Set<TaskStateName>([
'planning', 'coding', 'qa_review', 'qa_fixing'
]);

/** Maps XState states to execution phases. */
export const XSTATE_TO_PHASE: Record<TaskStateName, ExecutionPhase> & Record<string, ExecutionPhase | undefined> = {
'backlog': 'idle',
Expand Down