From 28a620079fb2ff2a7bd7e5877c7aee9869159015 Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:42:01 +0100 Subject: [PATCH] fix: clear terminalEventSeen on task restart to prevent stuck-after-planning (#1828) (#1840) * fix: clear terminalEventSeen on task restart to prevent stuck-after-planning (#1828) The terminalEventSeen Set in TaskStateManager was never cleared when a task was restarted. When spec_runner.py emits PLANNING_COMPLETE, the taskId is added to terminalEventSeen. If the subsequent coding process (run.py) fails, handleProcessExited() returns early because terminalEventSeen.has(taskId) is true, silently swallowing the PROCESS_EXITED event. The XState actor never transitions, leaving the task permanently stuck in 'coding' state. Additionally, lastSequenceByTask from the old process would cause events from a new process (starting at sequence 0) to be dropped as duplicates. Fix: Add prepareForRestart(taskId) method that clears both terminalEventSeen and lastSequenceByTask without stopping the XState actor. Call it in all 4 locations where a new agent process is started: - TASK_START handler - TASK_STOP handler (so subsequent restart works) - TASK_UPDATE_STATUS auto-start path - TASK_RECOVER_STUCK auto-restart path Co-Authored-By: Claude Opus 4.6 * fix: add prepareForRestart to TASK_REVIEW rejection path Add missing prepareForRestart(taskId) call before startQAProcess() in the TASK_REVIEW rejection handler. This is the 5th location where a new agent process is started for an existing task, but was missed in the original fix. Without this, if the QA fixer process crashes after a review rejection, terminalEventSeen would cause handleProcessExited() to swallow the exit event, leaving the task permanently stuck. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../main/ipc-handlers/task/execution-handlers.ts | 15 +++++++++++++++ apps/frontend/src/main/task-state-manager.ts | 12 ++++++++++++ 2 files changed, 27 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 8cf875e2..114c7767 100644 --- a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts @@ -177,6 +177,11 @@ export function registerTaskExecutionHandlers( console.warn('[TASK_START] Found task:', task.specId, 'status:', task.status, 'reviewReason:', task.reviewReason, 'subtasks:', task.subtasks.length); + // Clear stale tracking state from any previous execution so that: + // - terminalEventSeen doesn't suppress future PROCESS_EXITED events + // - lastSequenceByTask doesn't drop events from the new process + taskStateManager.prepareForRestart(taskId); + // Check if implementation_plan.json has valid subtasks BEFORE XState handling. // This is more reliable than task.subtasks.length which may not be loaded yet. const specsBaseDir = getSpecsDir(project.autoBuildPath); @@ -360,6 +365,9 @@ export function registerTaskExecutionHandlers( task, project ); + + // Clear stale tracking state so a subsequent restart works correctly + taskStateManager.prepareForRestart(taskId); }); /** @@ -529,6 +537,9 @@ export function registerTaskExecutionHandlers( return { success: false, error: 'Failed to write QA fix request file' }; } + // Clear stale tracking state before starting new QA process + taskStateManager.prepareForRestart(taskId); + // Restart QA process - use worktree path if it exists, otherwise main project // The QA process needs to run where the implementation_plan.json with completed subtasks is const qaProjectPath = hasWorktree ? worktreePath : project.path; @@ -703,6 +714,8 @@ export function registerTaskExecutionHandlers( // Auto-start task when status changes to 'in_progress' and no process is running if (status === 'in_progress' && !agentManager.isRunning(taskId)) { + // Clear stale tracking state before starting a new process + taskStateManager.prepareForRestart(taskId); const mainWindow = getMainWindow(); // Check git status before auto-starting @@ -1133,6 +1146,8 @@ export function registerTaskExecutionHandlers( // Auto-restart the task if requested let autoRestarted = false; if (autoRestart) { + // Clear stale tracking state before restarting + taskStateManager.prepareForRestart(taskId); // Check git status before auto-restarting const gitStatusForRestart = checkGitStatus(project.path); if (!gitStatusForRestart.isGitRepo || !gitStatusForRestart.hasCommits) { diff --git a/apps/frontend/src/main/task-state-manager.ts b/apps/frontend/src/main/task-state-manager.ts index e4a979f5..fffb7bea 100644 --- a/apps/frontend/src/main/task-state-manager.ts +++ b/apps/frontend/src/main/task-state-manager.ts @@ -169,6 +169,18 @@ export class TaskStateManager { return this.getCurrentState(taskId) === 'plan_review'; } + /** + * Reset tracking state for a task that is about to be restarted. + * Clears terminalEventSeen (so process exits aren't swallowed) and + * lastSequenceByTask (so events from the new process aren't dropped + * as duplicates). Does NOT stop or remove the XState actor, since + * the caller may still need to send events to it. + */ + prepareForRestart(taskId: string): void { + this.terminalEventSeen.delete(taskId); + this.lastSequenceByTask.delete(taskId); + } + clearTask(taskId: string): void { this.lastSequenceByTask.delete(taskId); this.lastStateByTask.delete(taskId);