Skip to content
23 changes: 23 additions & 0 deletions apps/backend/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -464,6 +471,22 @@ def _run_cli() -> None:
)
return

# Handle --unstick command
if args.unstick:
from services.recovery import clear_stuck_subtasks, get_stuck_subtasks

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}")
return
Comment on lines +473 to +490

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The aliases get_stuck and clear_stuck can be confusing, especially since clear_stuck is used as a function name but it's an alias for clear_stuck_subtasks. For better readability and maintainability, it's recommended to use the full function names directly by changing the import statement and the subsequent calls.

Suggested change
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
if args.unstick:
from services.recovery import get_stuck_subtasks, clear_stuck_subtasks
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}")
return

Comment on lines +472 to +490

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Wrap get_stuck_subtasks in the same error-handling block.

If get_stuck_subtasks on line 476 raises (e.g., corrupted/missing history file), the exception bubbles to the outer main() handler and the user sees a generic "Unexpected error: ..." instead of a contextual CLI message. Consider moving it inside the try block for a consistent experience.

Proposed fix
     # Handle --unstick command
     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
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Handle --unstick command
if args.unstick:
from services.recovery import clear_stuck_subtasks, get_stuck_subtasks
stuck = get_stuck_subtasks(spec_dir, project_dir)
if stuck:
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
# 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
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/cli/main.py` around lines 472 - 490, The current unstick flow
calls get_stuck_subtasks outside the try/except, so exceptions from
get_stuck_subtasks bubble up; move the call to get_stuck_subtasks(spec_dir,
project_dir) inside the same try block that calls clear_stuck_subtasks, and
catch exceptions there (same except Exception as e) to print a contextual
"Failed to clear stuck subtasks: {e}" message and sys.exit(1); keep the existing
logic that prints "No stuck subtasks found for {args.spec}" when
get_stuck_subtasks returns empty.


# Normal build flow
handle_build_command(
project_dir=project_dir,
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
check_and_recover,
clear_stuck_subtasks,
get_recovery_context,
get_stuck_subtasks,
reset_subtask,
)

Expand All @@ -17,5 +18,6 @@
"check_and_recover",
"clear_stuck_subtasks",
"get_recovery_context",
"get_stuck_subtasks",
"reset_subtask",
]
35 changes: 25 additions & 10 deletions apps/backend/services/recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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", [])
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def clear_stuck_subtasks(self) -> None:
"""Clear all stuck subtasks (for manual resolution)."""
history = self._load_attempt_history()
Expand Down Expand Up @@ -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()
88 changes: 88 additions & 0 deletions apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IPCResult<{ stuckSubtasks: Array<{ subtask_id: string; reason: string; escalated_at: string; attempt_count: number }> }>> => {
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' };
}
Comment on lines +1233 to +1251

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Return i18n keys (not hardcoded strings) in IPC error payloads.
These error messages are surfaced to the renderer (e.g., toast descriptions), so they should be localized. Prefer returning an error key/params and translating in the UI layer rather than emitting raw English strings here.

As per coding guidelines: **/*.{tsx,ts}: All frontend user-facing text MUST use react-i18next translation keys. Never hardcode strings in JSX/TSX. Add keys to both en/*.json and fr/*.json.

Also applies to: 1263-1309

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts` around lines
1233 - 1251, Replace hardcoded English error strings in this IPC handler with
i18n keys and optional params: instead of returning { success: false, error:
'Project not found' } return something like { success: false, errorKey:
'error.projectNotFound' }, and in the catch block return { success: false,
errorKey: 'error.failedToGetStuckInfo', errorParams: { message: error instanceof
Error ? error.message : undefined } }; update any other similar returns in this
function (references: project, specsBaseDir, specDir, attemptHistoryPath,
history) and ensure corresponding keys are added to both en/*.json and
fr/*.json; keep translation responsibility in the renderer (use react-i18next to
translate errorKey + errorParams when showing toasts).

}
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Clear stuck subtasks for a spec
*/
ipcMain.handle(
IPC_CHANNELS.TASK_UNSTICK_SUBTASKS,
async (_, projectId: string, specId: string): Promise<IPCResult<{ cleared: number }>> => {
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';
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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' };
}
}
);
Comment on lines +1228 to +1311

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

These new IPC handlers (TASK_GET_STUCK_INFO and TASK_UNSTICK_SUBTASKS) duplicate logic that exists or should exist in the Python backend. For example, they directly read and manipulate attempt_history.json, which is also handled by services/recovery.py.

Duplicating this logic across the TypeScript main process and the Python backend can lead to inconsistencies and maintenance issues. For instance, the logic in TASK_UNSTICK_SUBTASKS to reset subtask statuses from 'stuck' to 'pending' is more complete than the current Python clear_stuck_subtasks function, which only clears the stuck_subtasks array.

To improve maintainability and ensure a single source of truth, this logic should be centralized in the Python backend. The services/recovery.py module should be updated to fully handle unsticking tasks (including resetting statuses), and these IPC handlers should then execute the backend logic, for example, by shelling out to a CLI command. The --unstick flag has already been added, and a similar one could be added for getting stuck info.

}
14 changes: 14 additions & 0 deletions apps/frontend/src/preload/api/task-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export interface TaskAPI {
) => Promise<IPCResult<TaskRecoveryResult>>;
checkTaskRunning: (taskId: string) => Promise<IPCResult<boolean>>;
resumePausedTask: (taskId: string) => Promise<IPCResult>;
getStuckInfo: (
projectId: string,
specId: string
) => Promise<IPCResult<{ stuckSubtasks: Array<{ subtask_id: string; reason: string; escalated_at: string; attempt_count: number }> }>>;
unstickSubtasks: (projectId: string, specId: string) => Promise<IPCResult<{ cleared: number }>>;

// Worktree Change Detection
checkWorktreeChanges: (taskId: string) => Promise<IPCResult<{ hasChanges: boolean; worktreePath?: string; changedFileCount?: number }>>;
Expand Down Expand Up @@ -151,6 +156,15 @@ export const createTaskAPI = (): TaskAPI => ({
resumePausedTask: (taskId: string): Promise<IPCResult> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_RESUME_PAUSED, taskId),

getStuckInfo: (
projectId: string,
specId: string
): Promise<IPCResult<{ stuckSubtasks: Array<{ subtask_id: string; reason: string; escalated_at: string; attempt_count: number }> }>> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_GET_STUCK_INFO, projectId, specId),

unstickSubtasks: (projectId: string, specId: string): Promise<IPCResult<{ cleared: number }>> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_UNSTICK_SUBTASKS, projectId, specId),

// Worktree Change Detection
checkWorktreeChanges: (taskId: string): Promise<IPCResult<{ hasChanges: boolean; worktreePath?: string; changedFileCount?: number }>> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_CHECK_WORKTREE_CHANGES, taskId),
Expand Down
14 changes: 13 additions & 1 deletion apps/frontend/src/renderer/components/TaskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
Expand Down
Loading
Loading