Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/backend/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
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", [])

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' };
}
}
);

/**
* 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';
}
}
}

// 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' };
}
}
);
}
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