Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading