From cc0646bebdc2f0e2de67fa012632029d2281d402 Mon Sep 17 00:00:00 2001 From: bsinikci Date: Tue, 17 Feb 2026 09:36:05 +0100 Subject: [PATCH 1/6] feat: add error feedback and unstick functionality for stuck tasks When task recovery fails (e.g., stuck subtasks with wrong file paths), the UI now provides clear feedback and an option to unstick subtasks. Changes: - Add error toast with stuck subtask count when recovery fails - Add "Unstick Subtasks" button in TaskWarnings component - Display stuck subtask reasons (file validation failures, etc.) - Add CLI --unstick command for manual recovery - Add get_stuck_subtasks() method to RecoveryManager Backend: - apps/backend/cli/main.py: Add --unstick flag - apps/backend/services/recovery.py: Add get_stuck_subtasks() Frontend: - Add IPC handlers TASK_GET_STUCK_INFO and TASK_UNSTICK_SUBTASKS - Add preload APIs getStuckInfo and unstickSubtasks - Update TaskWarnings to show stuck subtasks with unstick button - Update TaskCard to show error toast on recovery failure Co-Authored-By: Claude Opus 4.6 --- apps/backend/cli/main.py | 22 ++++ apps/backend/recovery.py | 2 + apps/backend/services/recovery.py | 25 ++++ .../ipc-handlers/task/execution-handlers.ts | 96 ++++++++++++++ apps/frontend/src/preload/api/task-api.ts | 14 +++ .../src/renderer/components/TaskCard.tsx | 14 ++- .../task-detail/TaskDetailModal.tsx | 2 + .../components/task-detail/TaskWarnings.tsx | 118 ++++++++++++++++-- .../src/renderer/lib/mocks/task-mock.ts | 4 + .../src/renderer/stores/task-store.ts | 39 ++++++ apps/frontend/src/shared/constants/ipc.ts | 2 + .../src/shared/i18n/locales/en/tasks.json | 25 +++- .../src/shared/i18n/locales/fr/tasks.json | 25 +++- apps/frontend/src/shared/types/ipc.ts | 2 + 14 files changed, 375 insertions(+), 15 deletions(-) diff --git a/apps/backend/cli/main.py b/apps/backend/cli/main.py index dc1f6a9c32..f567042dd0 100644 --- a/apps/backend/cli/main.py +++ b/apps/backend/cli/main.py @@ -215,6 +215,13 @@ def parse_args() -> argparse.Namespace: help="Add follow-up tasks to a completed spec (extends existing implementation plan)", ) + # Stuck subtask recovery + parser.add_argument( + "--unstick", + action="store_true", + help="Clear all stuck subtasks for a spec (allows task to continue after file validation failures)", + ) + # Review options parser.add_argument( "--review-status", @@ -464,6 +471,21 @@ def _run_cli() -> None: ) return + # Handle --unstick command + if args.unstick: + from services.recovery import get_stuck_subtasks as get_stuck + from services.recovery import clear_stuck_subtasks as clear_stuck + + stuck = get_stuck(spec_dir, project_dir) + if stuck: + clear_stuck(spec_dir, project_dir) + print(f"Cleared {len(stuck)} stuck subtasks for {args.spec}") + for s in stuck: + print(f" - {s.get('subtask_id', 'unknown')}: {s.get('reason', 'no reason')[:80]}") + else: + print(f"No stuck subtasks found for {args.spec}") + return + # Normal build flow handle_build_command( project_dir=project_dir, diff --git a/apps/backend/recovery.py b/apps/backend/recovery.py index fabf5f87f1..d0f6ccb6be 100644 --- a/apps/backend/recovery.py +++ b/apps/backend/recovery.py @@ -7,6 +7,7 @@ check_and_recover, clear_stuck_subtasks, get_recovery_context, + get_stuck_subtasks, reset_subtask, ) @@ -17,5 +18,6 @@ "check_and_recover", "clear_stuck_subtasks", "get_recovery_context", + "get_stuck_subtasks", "reset_subtask", ] diff --git a/apps/backend/services/recovery.py b/apps/backend/services/recovery.py index af9eb6f7a1..0008598c3e 100644 --- a/apps/backend/services/recovery.py +++ b/apps/backend/services/recovery.py @@ -577,6 +577,16 @@ def get_recovery_hints(self, subtask_id: str) -> list[str]: return hints + def get_stuck_subtasks(self) -> list[dict]: + """ + Return list of stuck subtasks with reasons. + + Returns: + List of stuck subtask dicts with subtask_id, reason, escalated_at, attempt_count + """ + history = self._load_attempt_history() + return history.get("stuck_subtasks", []) + def clear_stuck_subtasks(self) -> None: """Clear all stuck subtasks (for manual resolution).""" history = self._load_attempt_history() @@ -676,3 +686,18 @@ def clear_stuck_subtasks(spec_dir: Path, project_dir: Path) -> None: """ manager = RecoveryManager(spec_dir, project_dir) manager.clear_stuck_subtasks() + + +def get_stuck_subtasks(spec_dir: Path, project_dir: Path) -> list[dict]: + """ + Get list of stuck subtasks (module-level wrapper). + + Args: + spec_dir: Spec directory + project_dir: Project directory + + Returns: + List of stuck subtask dicts + """ + manager = RecoveryManager(spec_dir, project_dir) + return manager.get_stuck_subtasks() 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 95bcb9d8e6..eb7b91b32b 100644 --- a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts @@ -1221,4 +1221,100 @@ export function registerTaskExecutionHandlers( } } ); + + /** + * Get stuck subtask information for a spec + */ + ipcMain.handle( + IPC_CHANNELS.TASK_GET_STUCK_INFO, + async (_, projectId: string, specId: string): Promise }>> => { + try { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specDir = path.join(project.path, specsBaseDir, specId); + const attemptHistoryPath = path.join(specDir, 'memory', 'attempt_history.json'); + + if (!existsSync(attemptHistoryPath)) { + return { success: true, data: { stuckSubtasks: [] } }; + } + + const historyContent = safeReadFileSync(attemptHistoryPath); + if (!historyContent) { + return { success: true, data: { stuckSubtasks: [] } }; + } + + const history = JSON.parse(historyContent); + return { success: true, data: { stuckSubtasks: history.stuck_subtasks || [] } }; + } catch (error) { + console.error('Failed to get stuck info:', error); + return { success: false, error: error instanceof Error ? error.message : 'Failed to get stuck info' }; + } + } + ); + + /** + * Clear stuck subtasks for a spec + */ + ipcMain.handle( + IPC_CHANNELS.TASK_UNSTICK_SUBTASKS, + async (_, projectId: string, specId: string): Promise> => { + try { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specDir = path.join(project.path, specsBaseDir, specId); + const attemptHistoryPath = path.join(specDir, 'memory', 'attempt_history.json'); + + if (!existsSync(attemptHistoryPath)) { + return { success: true, data: { cleared: 0 } }; + } + + const historyContent = safeReadFileSync(attemptHistoryPath); + if (!historyContent) { + return { success: true, data: { cleared: 0 } }; + } + + const history = JSON.parse(historyContent); + const count = (history.stuck_subtasks || []).length; + + // Clear stuck subtasks list + history.stuck_subtasks = []; + + // Reset any subtasks marked as 'stuck' to 'pending' + if (history.subtasks) { + for (const subtaskId in history.subtasks) { + if (history.subtasks[subtaskId].status === 'stuck') { + history.subtasks[subtaskId].status = 'pending'; + } + } + } + + // Save updated history + writeFileAtomicSync(attemptHistoryPath, JSON.stringify(history, null, 2)); + + // Also update worktree copy if it exists + const worktreePath = findTaskWorktree(project.path, specId); + if (worktreePath) { + const worktreeSpecDir = path.join(worktreePath, specsBaseDir, specId); + const worktreeHistoryPath = path.join(worktreeSpecDir, 'memory', 'attempt_history.json'); + if (existsSync(worktreeHistoryPath)) { + writeFileAtomicSync(worktreeHistoryPath, JSON.stringify(history, null, 2)); + } + } + + console.log(`[Unstick] Cleared ${count} stuck subtasks for ${specId}`); + return { success: true, data: { cleared: count } }; + } catch (error) { + console.error('Failed to unstick subtasks:', error); + return { success: false, error: error instanceof Error ? error.message : 'Failed to unstick subtasks' }; + } + } + ); } diff --git a/apps/frontend/src/preload/api/task-api.ts b/apps/frontend/src/preload/api/task-api.ts index 857422fa81..f97a4d3568 100644 --- a/apps/frontend/src/preload/api/task-api.ts +++ b/apps/frontend/src/preload/api/task-api.ts @@ -52,6 +52,11 @@ export interface TaskAPI { ) => Promise>; checkTaskRunning: (taskId: string) => Promise>; resumePausedTask: (taskId: string) => Promise; + getStuckInfo: ( + projectId: string, + specId: string + ) => Promise }>>; + unstickSubtasks: (projectId: string, specId: string) => Promise>; // Worktree Change Detection checkWorktreeChanges: (taskId: string) => Promise>; @@ -151,6 +156,15 @@ export const createTaskAPI = (): TaskAPI => ({ resumePausedTask: (taskId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.TASK_RESUME_PAUSED, taskId), + getStuckInfo: ( + projectId: string, + specId: string + ): Promise }>> => + ipcRenderer.invoke(IPC_CHANNELS.TASK_GET_STUCK_INFO, projectId, specId), + + unstickSubtasks: (projectId: string, specId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TASK_UNSTICK_SUBTASKS, projectId, specId), + // Worktree Change Detection checkWorktreeChanges: (taskId: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.TASK_CHECK_WORKTREE_CHANGES, taskId), diff --git a/apps/frontend/src/renderer/components/TaskCard.tsx b/apps/frontend/src/renderer/components/TaskCard.tsx index 652346a7f6..3ad92bb5a3 100644 --- a/apps/frontend/src/renderer/components/TaskCard.tsx +++ b/apps/frontend/src/renderer/components/TaskCard.tsx @@ -31,7 +31,7 @@ import { JSON_ERROR_PREFIX, JSON_ERROR_TITLE_SUFFIX } from '../../shared/constants'; -import { stopTask, checkTaskRunning, recoverStuckTask, isIncompleteHumanReview, archiveTasks, hasRecentActivity, startTaskOrQueue } from '../stores/task-store'; +import { stopTask, checkTaskRunning, recoverStuckTask, getStuckInfo, isIncompleteHumanReview, archiveTasks, hasRecentActivity, startTaskOrQueue } from '../stores/task-store'; import { useToast } from '../hooks/use-toast'; import type { Task, TaskCategory, ReviewReason, TaskStatus } from '../../shared/types'; @@ -251,6 +251,18 @@ export const TaskCard = memo(function TaskCard({ const result = await recoverStuckTask(task.id, { autoRestart: true }); if (result.success) { setIsStuck(false); + } else { + // Check for stuck subtasks that might be blocking + const stuckResult = await getStuckInfo(task.projectId, task.specId); + const hasStuckSubtasks = stuckResult.success && stuckResult.stuckSubtasks && stuckResult.stuckSubtasks.length > 0; + + toast({ + title: t('tasks:errors.recoveryFailed'), + description: hasStuckSubtasks + ? t('tasks:errors.stuckSubtasksBlocking', { count: stuckResult.stuckSubtasks?.length ?? 0 }) + : result.message, + variant: 'destructive', + }); } setIsRecovering(false); }; diff --git a/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx b/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx index eebf65f2cf..27afe106cc 100644 --- a/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx +++ b/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx @@ -467,6 +467,8 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals, isIncomplete={state.isIncomplete} isRecovering={state.isRecovering} taskProgress={state.taskProgress} + projectId={task.projectId} + specId={task.specId} onRecover={handleRecover} onResume={handleStartStop} /> diff --git a/apps/frontend/src/renderer/components/task-detail/TaskWarnings.tsx b/apps/frontend/src/renderer/components/task-detail/TaskWarnings.tsx index 5b77f479c6..773d07ad48 100644 --- a/apps/frontend/src/renderer/components/task-detail/TaskWarnings.tsx +++ b/apps/frontend/src/renderer/components/task-detail/TaskWarnings.tsx @@ -1,11 +1,24 @@ -import { AlertTriangle, Play, RotateCcw, Loader2 } from 'lucide-react'; +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AlertTriangle, Play, RotateCcw, Loader2, Unlock } from 'lucide-react'; import { Button } from '../ui/button'; +import { getStuckInfo, unstickSubtasks } from '../../stores/task-store'; +import { useToast } from '../../hooks/use-toast'; + +interface StuckSubtask { + subtask_id: string; + reason: string; + escalated_at: string; + attempt_count: number; +} interface TaskWarningsProps { isStuck: boolean; isIncomplete: boolean; isRecovering: boolean; taskProgress: { completed: number; total: number }; + projectId?: string; + specId?: string; onRecover: () => void; onResume: () => void; } @@ -15,9 +28,49 @@ export function TaskWarnings({ isIncomplete, isRecovering, taskProgress, + projectId, + specId, onRecover, onResume }: TaskWarningsProps) { + const { t } = useTranslation('tasks'); + const { toast } = useToast(); + const [stuckSubtasks, setStuckSubtasks] = useState([]); + const [isLoadingStuck, setIsLoadingStuck] = useState(false); + const [isUnsticking, setIsUnsticking] = useState(false); + + // Load stuck subtask info when stuck + useEffect(() => { + if (isStuck && projectId && specId) { + setIsLoadingStuck(true); + getStuckInfo(projectId, specId) + .then(result => { + if (result.success && result.stuckSubtasks) { + setStuckSubtasks(result.stuckSubtasks); + } + }) + .finally(() => setIsLoadingStuck(false)); + } else { + setStuckSubtasks([]); + } + }, [isStuck, projectId, specId]); + + const handleUnstick = async () => { + if (!projectId || !specId) return; + setIsUnsticking(true); + try { + const result = await unstickSubtasks(projectId, specId); + if (result.success && result.cleared && result.cleared > 0) { + toast({ title: t('messages.subtasksUnstuck', { count: result.cleared }) }); + setStuckSubtasks([]); + } else if (result.error) { + toast({ title: t('errors.unstickFailed'), description: result.error, variant: 'destructive' }); + } + } finally { + setIsUnsticking(false); + } + }; + if (!isStuck && !isIncomplete) return null; return ( @@ -29,12 +82,58 @@ export function TaskWarnings({

- Task Appears Stuck + {t('warnings.taskStuck')}

- This task is marked as running but no active process was found. - This can happen if the app crashed or the process was terminated unexpectedly. + {t('warnings.taskStuckDescription')}

+ + {/* Stuck Subtasks Info */} + {stuckSubtasks.length > 0 && ( +
+

+ {t('labels.stuckSubtasks')} ({stuckSubtasks.length}) +

+
    + {stuckSubtasks.map((stuck) => ( +
  • + {stuck.subtask_id}:{' '} + + {stuck.reason.length > 80 ? `${stuck.reason.slice(0, 80)}...` : stuck.reason} + +
  • + ))} +
+ +
+ )} + + {/* Loading state for stuck info */} + {isLoadingStuck && stuckSubtasks.length === 0 && ( +
+ + {t('labels.checkingStuck')} +
+ )} + @@ -66,11 +165,10 @@ export function TaskWarnings({

- Task Incomplete + {t('warnings.taskIncomplete')}

- This task has a spec and implementation plan but never completed any subtasks ({taskProgress.completed}/{taskProgress.total}). - The process likely crashed during spec creation. Click Resume to continue implementation. + {t('warnings.taskIncompleteDescription', { completed: taskProgress.completed, total: taskProgress.total })}

diff --git a/apps/frontend/src/renderer/lib/mocks/task-mock.ts b/apps/frontend/src/renderer/lib/mocks/task-mock.ts index 8b1a368fe1..4b304a79f0 100644 --- a/apps/frontend/src/renderer/lib/mocks/task-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/task-mock.ts @@ -76,6 +76,10 @@ export const taskMock = { resumePausedTask: async () => ({ success: true }), + getStuckInfo: async () => ({ success: true, data: { stuckSubtasks: [] } }), + + unstickSubtasks: async () => ({ success: true, data: { cleared: 0 } }), + // Worktree change detection checkWorktreeChanges: async (_taskId: string) => ({ success: true as const, diff --git a/apps/frontend/src/renderer/stores/task-store.ts b/apps/frontend/src/renderer/stores/task-store.ts index b0ef7206a3..9a2d2540ae 100644 --- a/apps/frontend/src/renderer/stores/task-store.ts +++ b/apps/frontend/src/renderer/stores/task-store.ts @@ -959,6 +959,45 @@ export async function recoverStuckTask( } } +/** + * Get stuck subtask information for a spec + * @param projectId - The project ID + * @param specId - The spec ID to check + */ +export async function getStuckInfo( + projectId: string, + specId: string +): Promise<{ success: boolean; stuckSubtasks?: Array<{ subtask_id: string; reason: string; escalated_at: string; attempt_count: number }>; error?: string }> { + try { + const result = await window.electronAPI.getStuckInfo(projectId, specId); + if (result.success && result.data) { + return { success: true, stuckSubtasks: result.data.stuckSubtasks }; + } + return { success: false, error: result.error }; + } catch (error) { + console.error('Error getting stuck info:', error); + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } +} + +/** + * Clear stuck subtasks for a spec + * @param projectId - The project ID + * @param specId - The spec ID to unstick + */ +export async function unstickSubtasks( + projectId: string, + specId: string +): Promise<{ success: boolean; cleared?: number; error?: string }> { + try { + const result = await window.electronAPI.unstickSubtasks(projectId, specId); + return result; + } catch (error) { + console.error('Error unsticking subtasks:', error); + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } +} + /** * Delete a task and its spec directory */ diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index 4c62860856..b878232e11 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -30,6 +30,8 @@ export const IPC_CHANNELS = { TASK_REVIEW: 'task:review', TASK_UPDATE_STATUS: 'task:updateStatus', TASK_RECOVER_STUCK: 'task:recoverStuck', + TASK_GET_STUCK_INFO: 'task:getStuckInfo', // Get stuck subtask details + TASK_UNSTICK_SUBTASKS: 'task:unstickSubtasks', // Clear stuck subtasks TASK_CHECK_RUNNING: 'task:checkRunning', TASK_RESUME_PAUSED: 'task:resumePaused', // Resume a rate-limited or auth-paused task TASK_LOAD_IMAGE_THUMBNAIL: 'task:loadImageThumbnail', diff --git a/apps/frontend/src/shared/i18n/locales/en/tasks.json b/apps/frontend/src/shared/i18n/locales/en/tasks.json index ecd78827ad..1e6a7b85b6 100644 --- a/apps/frontend/src/shared/i18n/locales/en/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/en/tasks.json @@ -21,7 +21,12 @@ "viewPR": "View PR", "moveTo": "Move to", "taskActions": "Task actions", - "selectTask": "Select task: {{title}}" + "selectTask": "Select task: {{title}}", + "unstickSubtasks": "Unstick Subtasks", + "unsticking": "Unsticking...", + "recovering": "Recovering...", + "recoverRestart": "Recover & Restart Task", + "resumeTask": "Resume Task" }, "labels": { "running": "Running", @@ -32,7 +37,23 @@ "incomplete": "Incomplete", "recovering": "Recovering...", "needsRecovery": "Needs Recovery", - "needsResume": "Needs Resume" + "needsResume": "Needs Resume", + "stuckSubtasks": "Stuck Subtasks", + "checkingStuck": "Checking stuck subtasks..." + }, + "errors": { + "recoveryFailed": "Recovery failed", + "stuckSubtasksBlocking": "{{count}} stuck subtasks blocking progress", + "unstickFailed": "Failed to unstick subtasks" + }, + "messages": { + "subtasksUnstuck": "{{count}} subtasks unstuck" + }, + "warnings": { + "taskStuck": "Task Appears Stuck", + "taskStuckDescription": "This task is marked as running but no active process was found. This can happen if the app crashed or the process was terminated unexpectedly.", + "taskIncomplete": "Task Incomplete", + "taskIncompleteDescription": "This task has a spec and implementation plan but never completed any subtasks ({{completed}}/{{total}}). The process likely crashed during spec creation. Click Resume to continue implementation." }, "reviewReason": { "completed": "Completed", diff --git a/apps/frontend/src/shared/i18n/locales/fr/tasks.json b/apps/frontend/src/shared/i18n/locales/fr/tasks.json index 00af23a49e..63b0489300 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/fr/tasks.json @@ -21,7 +21,12 @@ "viewPR": "Voir la PR", "moveTo": "Déplacer vers", "taskActions": "Actions de la tâche", - "selectTask": "Sélectionner la tâche : {{title}}" + "selectTask": "Sélectionner la tâche : {{title}}", + "unstickSubtasks": "Débloquer les sous-tâches", + "unsticking": "Déblocage...", + "recovering": "Récupération...", + "recoverRestart": "Récupérer et redémarrer", + "resumeTask": "Reprendre la tâche" }, "labels": { "running": "En cours", @@ -32,7 +37,23 @@ "incomplete": "Incomplet", "recovering": "Récupération...", "needsRecovery": "Récupération requise", - "needsResume": "Reprise requise" + "needsResume": "Reprise requise", + "stuckSubtasks": "Sous-tâches bloquées", + "checkingStuck": "Vérification des sous-tâches bloquées..." + }, + "errors": { + "recoveryFailed": "Échec de la récupération", + "stuckSubtasksBlocking": "{{count}} sous-tâches bloquées empêchent la progression", + "unstickFailed": "Échec du déblocage des sous-tâches" + }, + "messages": { + "subtasksUnstuck": "{{count}} sous-tâches débloquées" + }, + "warnings": { + "taskStuck": "Tâche bloquée", + "taskStuckDescription": "Cette tâche est marquée comme en cours mais aucun processus actif n'a été trouvé. Cela peut arriver si l'application a planté ou si le processus a été terminé de manière inattendue.", + "taskIncomplete": "Tâche incomplète", + "taskIncompleteDescription": "Cette tâche a un spec et un plan d'implémentation mais n'a terminé aucune sous-tâche ({{completed}}/{{total}}). Le processus a probablement planté pendant la création du spec. Cliquez sur Reprendre pour continuer l'implémentation." }, "reviewReason": { "completed": "Terminé", diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index b1fc2c4b63..57e18216cc 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -205,6 +205,8 @@ export interface ElectronAPI { recoverStuckTask: (taskId: string, options?: TaskRecoveryOptions) => Promise>; checkTaskRunning: (taskId: string) => Promise>; resumePausedTask: (taskId: string) => Promise; + getStuckInfo: (projectId: string, specId: string) => Promise }>>; + unstickSubtasks: (projectId: string, specId: string) => Promise>; // Image operations loadImageThumbnail: (projectPath: string, specId: string, imagePath: string) => Promise>; From a5ad9d7675796ab85491da3ebd4c4fd5f57adcd9 Mon Sep 17 00:00:00 2001 From: bsinikci Date: Tue, 17 Feb 2026 10:06:20 +0100 Subject: [PATCH 2/6] fix: address PR review feedback for stuck subtasks feature - Fix Ruff I001: combine duplicate imports in cli/main.py - Fix Ruff F811: remove duplicate get_stuck_subtasks method in recovery.py - Fix bug where unstickSubtasks toast never fired (return raw IPCResult without flattening) - Add race condition fix in TaskWarnings useEffect with ignore flag - Remove redundant existsSync checks (TOCTOU fix) - Use Object.keys() instead of for...in for prototype safety - Use path aliases instead of deep relative imports - Extract StuckSubtaskInfo into shared type Co-Authored-By: Claude Opus 4.6 --- apps/backend/cli/main.py | 7 +++---- apps/backend/services/recovery.py | 10 ---------- .../ipc-handlers/task/execution-handlers.ts | 14 +++---------- .../components/task-detail/TaskWarnings.tsx | 20 ++++++++----------- .../src/renderer/stores/task-store.ts | 9 ++++++--- apps/frontend/src/shared/types/ipc.ts | 5 +++-- apps/frontend/src/shared/types/task.ts | 7 +++++++ 7 files changed, 30 insertions(+), 42 deletions(-) diff --git a/apps/backend/cli/main.py b/apps/backend/cli/main.py index f567042dd0..596fe92ba6 100644 --- a/apps/backend/cli/main.py +++ b/apps/backend/cli/main.py @@ -473,12 +473,11 @@ def _run_cli() -> None: # Handle --unstick command if args.unstick: - from services.recovery import get_stuck_subtasks as get_stuck - from services.recovery import clear_stuck_subtasks as clear_stuck + from services.recovery import clear_stuck_subtasks, get_stuck_subtasks - stuck = get_stuck(spec_dir, project_dir) + stuck = get_stuck_subtasks(spec_dir, project_dir) if stuck: - clear_stuck(spec_dir, project_dir) + clear_stuck_subtasks(spec_dir, project_dir) print(f"Cleared {len(stuck)} stuck subtasks for {args.spec}") for s in stuck: print(f" - {s.get('subtask_id', 'unknown')}: {s.get('reason', 'no reason')[:80]}") diff --git a/apps/backend/services/recovery.py b/apps/backend/services/recovery.py index 0008598c3e..85751c76bc 100644 --- a/apps/backend/services/recovery.py +++ b/apps/backend/services/recovery.py @@ -514,16 +514,6 @@ def mark_subtask_stuck(self, subtask_id: str, reason: str) -> None: self._save_attempt_history(history) - def get_stuck_subtasks(self) -> list[dict]: - """ - Get all subtasks marked as stuck. - - Returns: - List of stuck subtask entries - """ - history = self._load_attempt_history() - return history.get("stuck_subtasks", []) - def get_subtask_history(self, subtask_id: str) -> dict: """ Get the attempt history for a specific subtask. 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 eb7b91b32b..a10fa14bbf 100644 --- a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts @@ -1238,10 +1238,6 @@ export function registerTaskExecutionHandlers( const specDir = path.join(project.path, specsBaseDir, specId); const attemptHistoryPath = path.join(specDir, 'memory', 'attempt_history.json'); - if (!existsSync(attemptHistoryPath)) { - return { success: true, data: { stuckSubtasks: [] } }; - } - const historyContent = safeReadFileSync(attemptHistoryPath); if (!historyContent) { return { success: true, data: { stuckSubtasks: [] } }; @@ -1272,10 +1268,6 @@ export function registerTaskExecutionHandlers( const specDir = path.join(project.path, specsBaseDir, specId); const attemptHistoryPath = path.join(specDir, 'memory', 'attempt_history.json'); - if (!existsSync(attemptHistoryPath)) { - return { success: true, data: { cleared: 0 } }; - } - const historyContent = safeReadFileSync(attemptHistoryPath); if (!historyContent) { return { success: true, data: { cleared: 0 } }; @@ -1288,9 +1280,9 @@ export function registerTaskExecutionHandlers( history.stuck_subtasks = []; // Reset any subtasks marked as 'stuck' to 'pending' - if (history.subtasks) { - for (const subtaskId in history.subtasks) { - if (history.subtasks[subtaskId].status === 'stuck') { + if (history.subtasks && typeof history.subtasks === 'object') { + for (const subtaskId of Object.keys(history.subtasks)) { + if (history.subtasks[subtaskId]?.status === 'stuck') { history.subtasks[subtaskId].status = 'pending'; } } diff --git a/apps/frontend/src/renderer/components/task-detail/TaskWarnings.tsx b/apps/frontend/src/renderer/components/task-detail/TaskWarnings.tsx index 773d07ad48..c29a98a71d 100644 --- a/apps/frontend/src/renderer/components/task-detail/TaskWarnings.tsx +++ b/apps/frontend/src/renderer/components/task-detail/TaskWarnings.tsx @@ -2,15 +2,9 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { AlertTriangle, Play, RotateCcw, Loader2, Unlock } from 'lucide-react'; import { Button } from '../ui/button'; -import { getStuckInfo, unstickSubtasks } from '../../stores/task-store'; -import { useToast } from '../../hooks/use-toast'; - -interface StuckSubtask { - subtask_id: string; - reason: string; - escalated_at: string; - attempt_count: number; -} +import { getStuckInfo, unstickSubtasks } from '@/stores/task-store'; +import { useToast } from '@/hooks/use-toast'; +import type { StuckSubtaskInfo } from '@shared/types/task'; interface TaskWarningsProps { isStuck: boolean; @@ -35,24 +29,26 @@ export function TaskWarnings({ }: TaskWarningsProps) { const { t } = useTranslation('tasks'); const { toast } = useToast(); - const [stuckSubtasks, setStuckSubtasks] = useState([]); + const [stuckSubtasks, setStuckSubtasks] = useState([]); const [isLoadingStuck, setIsLoadingStuck] = useState(false); const [isUnsticking, setIsUnsticking] = useState(false); // Load stuck subtask info when stuck useEffect(() => { + let ignore = false; if (isStuck && projectId && specId) { setIsLoadingStuck(true); getStuckInfo(projectId, specId) .then(result => { - if (result.success && result.stuckSubtasks) { + if (!ignore && result.success && result.stuckSubtasks) { setStuckSubtasks(result.stuckSubtasks); } }) - .finally(() => setIsLoadingStuck(false)); + .finally(() => { if (!ignore) setIsLoadingStuck(false); }); } else { setStuckSubtasks([]); } + return () => { ignore = true; }; }, [isStuck, projectId, specId]); const handleUnstick = async () => { diff --git a/apps/frontend/src/renderer/stores/task-store.ts b/apps/frontend/src/renderer/stores/task-store.ts index 9a2d2540ae..fad7ffe8aa 100644 --- a/apps/frontend/src/renderer/stores/task-store.ts +++ b/apps/frontend/src/renderer/stores/task-store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { arrayMove } from '@dnd-kit/sortable'; -import type { Task, TaskStatus, SubtaskStatus, ImplementationPlan, Subtask, TaskMetadata, ExecutionProgress, ExecutionPhase, ReviewReason, TaskDraft, ImageAttachment, TaskOrderState } from '../../shared/types'; +import type { Task, TaskStatus, SubtaskStatus, ImplementationPlan, Subtask, TaskMetadata, ExecutionProgress, ExecutionPhase, ReviewReason, TaskDraft, ImageAttachment, TaskOrderState, StuckSubtaskInfo } from '../../shared/types'; import { debugLog, debugWarn } from '../../shared/utils/debug-logger'; import { useProjectStore } from './project-store'; @@ -967,7 +967,7 @@ export async function recoverStuckTask( export async function getStuckInfo( projectId: string, specId: string -): Promise<{ success: boolean; stuckSubtasks?: Array<{ subtask_id: string; reason: string; escalated_at: string; attempt_count: number }>; error?: string }> { +): Promise<{ success: boolean; stuckSubtasks?: StuckSubtaskInfo[]; error?: string }> { try { const result = await window.electronAPI.getStuckInfo(projectId, specId); if (result.success && result.data) { @@ -991,7 +991,10 @@ export async function unstickSubtasks( ): Promise<{ success: boolean; cleared?: number; error?: string }> { try { const result = await window.electronAPI.unstickSubtasks(projectId, specId); - return result; + if (result.success && result.data) { + return { success: true, cleared: result.data.cleared }; + } + return { success: false, error: result.error }; } catch (error) { console.error('Error unsticking subtasks:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index 57e18216cc..4ecbf12ccb 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -47,7 +47,8 @@ import type { TaskLogStreamChunk, ImageAttachment, ReviewReason, - MergeProgress + MergeProgress, + StuckSubtaskInfo } from './task'; import type { TerminalCreateOptions, @@ -205,7 +206,7 @@ export interface ElectronAPI { recoverStuckTask: (taskId: string, options?: TaskRecoveryOptions) => Promise>; checkTaskRunning: (taskId: string) => Promise>; resumePausedTask: (taskId: string) => Promise; - getStuckInfo: (projectId: string, specId: string) => Promise }>>; + getStuckInfo: (projectId: string, specId: string) => Promise>; unstickSubtasks: (projectId: string, specId: string) => Promise>; // Image operations diff --git a/apps/frontend/src/shared/types/task.ts b/apps/frontend/src/shared/types/task.ts index 495b707380..b375a350a6 100644 --- a/apps/frontend/src/shared/types/task.ts +++ b/apps/frontend/src/shared/types/task.ts @@ -506,6 +506,13 @@ export interface WorktreeListResult { } // Stuck task recovery types +export interface StuckSubtaskInfo { + subtask_id: string; + reason: string; + escalated_at: string; + attempt_count: number; +} + export interface StuckTaskInfo { taskId: string; specId: string; From 8403793e772f51330c02dec6ca1a2feadbf3b0a8 Mon Sep 17 00:00:00 2001 From: bsinikci Date: Tue, 17 Feb 2026 10:16:03 +0100 Subject: [PATCH 3/6] style: fix ruff formatting in cli/main.py Co-Authored-By: Claude Opus 4.6 --- apps/backend/cli/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/backend/cli/main.py b/apps/backend/cli/main.py index 596fe92ba6..5db2bc8c81 100644 --- a/apps/backend/cli/main.py +++ b/apps/backend/cli/main.py @@ -480,7 +480,9 @@ def _run_cli() -> None: clear_stuck_subtasks(spec_dir, project_dir) print(f"Cleared {len(stuck)} stuck subtasks for {args.spec}") for s in stuck: - print(f" - {s.get('subtask_id', 'unknown')}: {s.get('reason', 'no reason')[:80]}") + print( + f" - {s.get('subtask_id', 'unknown')}: {s.get('reason', 'no reason')[:80]}" + ) else: print(f"No stuck subtasks found for {args.spec}") return From e49ee5d4e986680848e1bedefaa4a3732d069abd Mon Sep 17 00:00:00 2001 From: bsinikci Date: Tue, 17 Feb 2026 10:28:34 +0100 Subject: [PATCH 4/6] fix: add --unstick to mutually exclusive build_group Ensures --unstick is rejected when combined with other action flags like --merge, --review, --discard, or --create-pr. Co-Authored-By: Claude Opus 4.6 --- apps/backend/cli/main.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/backend/cli/main.py b/apps/backend/cli/main.py index 5db2bc8c81..dd05e97710 100644 --- a/apps/backend/cli/main.py +++ b/apps/backend/cli/main.py @@ -159,6 +159,11 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Push branch and create a GitHub Pull Request", ) + build_group.add_argument( + "--unstick", + action="store_true", + help="Clear all stuck subtasks for a spec (allows task to continue after file validation failures)", + ) # PR options parser.add_argument( @@ -215,13 +220,6 @@ def parse_args() -> argparse.Namespace: help="Add follow-up tasks to a completed spec (extends existing implementation plan)", ) - # Stuck subtask recovery - parser.add_argument( - "--unstick", - action="store_true", - help="Clear all stuck subtasks for a spec (allows task to continue after file validation failures)", - ) - # Review options parser.add_argument( "--review-status", From c728f147c2258eaff7e1ada4dede45b131d86105 Mon Sep 17 00:00:00 2001 From: bsinikci Date: Tue, 17 Feb 2026 10:33:33 +0100 Subject: [PATCH 5/6] fix: add error handling for clear_stuck_subtasks in CLI Wraps the clear_stuck_subtasks call in try/except and exits with non-zero status on failure, providing clear error message to user. Co-Authored-By: Claude Opus 4.6 --- apps/backend/cli/main.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/backend/cli/main.py b/apps/backend/cli/main.py index dd05e97710..d2de583bf3 100644 --- a/apps/backend/cli/main.py +++ b/apps/backend/cli/main.py @@ -475,12 +475,16 @@ def _run_cli() -> None: stuck = get_stuck_subtasks(spec_dir, project_dir) if stuck: - clear_stuck_subtasks(spec_dir, project_dir) - print(f"Cleared {len(stuck)} stuck subtasks for {args.spec}") - for s in stuck: - print( - f" - {s.get('subtask_id', 'unknown')}: {s.get('reason', 'no reason')[:80]}" - ) + try: + clear_stuck_subtasks(spec_dir, project_dir) + print(f"Cleared {len(stuck)} stuck subtasks for {args.spec}") + for s in stuck: + print( + f" - {s.get('subtask_id', 'unknown')}: {s.get('reason', 'no reason')[:80]}" + ) + except Exception as e: + print(f"Failed to clear stuck subtasks: {e}") + sys.exit(1) else: print(f"No stuck subtasks found for {args.spec}") return From ca862842e478b29b491fb88654d2ef1abf045662 Mon Sep 17 00:00:00 2001 From: bsinikci Date: Tue, 17 Feb 2026 10:53:39 +0100 Subject: [PATCH 6/6] fix: move get_stuck_subtasks into try block for complete error handling Both get_stuck_subtasks and clear_stuck_subtasks are now protected by the same exception handler. Co-Authored-By: Claude Opus 4.6 --- apps/backend/cli/main.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/backend/cli/main.py b/apps/backend/cli/main.py index d2de583bf3..9d8dbb7e76 100644 --- a/apps/backend/cli/main.py +++ b/apps/backend/cli/main.py @@ -473,20 +473,20 @@ def _run_cli() -> None: if args.unstick: from services.recovery import clear_stuck_subtasks, get_stuck_subtasks - stuck = get_stuck_subtasks(spec_dir, project_dir) - if stuck: - try: + try: + stuck = get_stuck_subtasks(spec_dir, project_dir) + if stuck: clear_stuck_subtasks(spec_dir, project_dir) print(f"Cleared {len(stuck)} stuck subtasks for {args.spec}") for s in stuck: print( f" - {s.get('subtask_id', 'unknown')}: {s.get('reason', 'no reason')[:80]}" ) - except Exception as e: - print(f"Failed to clear stuck subtasks: {e}") - sys.exit(1) - else: - print(f"No stuck subtasks found for {args.spec}") + else: + print(f"No stuck subtasks found for {args.spec}") + except Exception as e: + print(f"Failed to clear stuck subtasks: {e}") + sys.exit(1) return # Normal build flow