diff --git a/apps/backend/cli/main.py b/apps/backend/cli/main.py index dc1f6a9c32..9d8dbb7e76 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( @@ -464,6 +469,26 @@ def _run_cli() -> None: ) return + # Handle --unstick command + if args.unstick: + from services.recovery import clear_stuck_subtasks, get_stuck_subtasks + + 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]}" + ) + 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 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..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. @@ -577,6 +567,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 +676,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..a10fa14bbf 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,92 @@ 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'); + + 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'); + + 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 && typeof history.subtasks === 'object') { + for (const subtaskId of Object.keys(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..c29a98a71d 100644 --- a/apps/frontend/src/renderer/components/task-detail/TaskWarnings.tsx +++ b/apps/frontend/src/renderer/components/task-detail/TaskWarnings.tsx @@ -1,11 +1,18 @@ -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'; +import type { StuckSubtaskInfo } from '@shared/types/task'; interface TaskWarningsProps { isStuck: boolean; isIncomplete: boolean; isRecovering: boolean; taskProgress: { completed: number; total: number }; + projectId?: string; + specId?: string; onRecover: () => void; onResume: () => void; } @@ -15,9 +22,51 @@ 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(() => { + let ignore = false; + if (isStuck && projectId && specId) { + setIsLoadingStuck(true); + getStuckInfo(projectId, specId) + .then(result => { + if (!ignore && result.success && result.stuckSubtasks) { + setStuckSubtasks(result.stuckSubtasks); + } + }) + .finally(() => { if (!ignore) setIsLoadingStuck(false); }); + } else { + setStuckSubtasks([]); + } + return () => { ignore = true; }; + }, [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 +78,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 +161,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..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'; @@ -959,6 +959,48 @@ 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?: StuckSubtaskInfo[]; 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); + 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' }; + } +} + /** * 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..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,6 +206,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>; 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;