Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
47 changes: 41 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,22 @@ 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;

/**
* Register all agent-events-related IPC handlers
*/
Expand Down Expand Up @@ -96,6 +99,28 @@ 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.
setTimeout(async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

async on the setTimeout callback does not make readFileSync non-blocking.

The commit message mentions making the callback async to avoid blocking the event loop, but async only causes the function to return a Promise — it does not offload synchronous I/O. hasPlanWithSubtasks calls readFileSync which will still block the event loop regardless. The async keyword is harmless here (setTimeout discards the return value), but it's misleading about its purpose.

♻️ Suggested fix: remove unnecessary async
-    setTimeout(async () => {
+    setTimeout(() => {
🤖 Prompt for AI Agents
In `@apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts` at line 106,
The setTimeout callback is marked async which misleadingly suggests it makes
synchronous I/O non-blocking even though hasPlanWithSubtasks uses readFileSync;
remove the unnecessary async from the setTimeout callback to avoid confusion,
and if you need non-blocking behavior replace readFileSync inside
hasPlanWithSubtasks with an async fs.readFile-based implementation (or return a
Promise) so the work is truly non-blocking.

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);

This comment was marked as outdated.

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);
}
}
}, 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)
const finalPlan = fileWatcher.getCurrentPlan(taskId);
Expand Down Expand Up @@ -225,11 +250,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
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