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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-02-18 14:42:01 +01:00
committed by GitHub
parent fb3a3fbda7
commit 28a620079f
2 changed files with 27 additions and 0 deletions
@@ -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) {
@@ -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);