diff --git a/apps/frontend/src/renderer/components/KanbanBoard.tsx b/apps/frontend/src/renderer/components/KanbanBoard.tsx index 784aeb34..25e37d56 100644 --- a/apps/frontend/src/renderer/components/KanbanBoard.tsx +++ b/apps/frontend/src/renderer/components/KanbanBoard.tsx @@ -29,7 +29,7 @@ import { SortableTaskCard } from './SortableTaskCard'; import { QueueSettingsModal } from './QueueSettingsModal'; import { TASK_STATUS_COLUMNS, TASK_STATUS_LABELS } from '../../shared/constants'; import { cn } from '../lib/utils'; -import { persistTaskStatus, forceCompleteTask, archiveTasks, deleteTasks, useTaskStore } from '../stores/task-store'; +import { persistTaskStatus, forceCompleteTask, archiveTasks, deleteTasks, useTaskStore, isQueueAtCapacity, DEFAULT_MAX_PARALLEL_TASKS } from '../stores/task-store'; import { updateProjectSettings, useProjectStore } from '../stores/project-store'; import { useKanbanSettingsStore, DEFAULT_COLUMN_WIDTH, MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH, COLLAPSED_COLUMN_WIDTH_REM, MIN_COLUMN_WIDTH_REM, MAX_COLUMN_WIDTH_REM, BASE_FONT_SIZE, pxToRem } from '../stores/kanban-settings-store'; import { useToast } from '../hooks/use-toast'; @@ -663,7 +663,7 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR // Get projectId from first task const projectId = tasks[0]?.projectId; const project = projectId ? projects.find((p) => p.id === projectId) : undefined; - const maxParallelTasks = project?.settings?.maxParallelTasks ?? 3; + const maxParallelTasks = project?.settings?.maxParallelTasks ?? DEFAULT_MAX_PARALLEL_TASKS; // Queue settings modal state const [showQueueSettings, setShowQueueSettings] = useState(false); @@ -947,8 +947,23 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR * Handle status change with worktree cleanup dialog support * Consolidated handler that accepts an optional task object for the dialog title */ - const handleStatusChange = async (taskId: string, newStatus: TaskStatus, providedTask?: Task) => { + const handleStatusChange = async (taskId: string, requestedStatus: TaskStatus, providedTask?: Task) => { const task = providedTask || tasks.find(t => t.id === taskId); + let newStatus = requestedStatus; + + // ============================================ + // QUEUE SYSTEM: Enforce parallel task limit + // Called from both the dropdown menu and the drag-and-drop handler. + // Excludes the task itself from the count to handle re-entry (e.g., redundant + // status change or race with auto-promotion). processQueue auto-promotion + // calls persistTaskStatus directly, never this function. + // ============================================ + if (newStatus === 'in_progress' && isQueueAtCapacity(taskId)) { + console.log('[Queue] In Progress full, redirecting task to Queue'); + newStatus = 'queue'; + } + + const oldStatus = task?.status; const result = await persistTaskStatus(taskId, newStatus); if (!result.success) { @@ -971,6 +986,9 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR }); } } + // Note: queue auto-promotion when a task leaves in_progress is handled by the + // useEffect task status change listener (registerTaskStatusChangeListener), so + // no explicit processQueue() call is needed here. }; /** @@ -1402,40 +1420,10 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR if (!newStatus || newStatus === oldStatus) return; - // ============================================ - // QUEUE SYSTEM: Enforce parallel task limit - // ============================================ - if (newStatus === 'in_progress') { - // Get CURRENT state from store directly to avoid stale prop/memo issues during rapid dragging - const currentTasks = useTaskStore.getState().tasks; - const inProgressCount = currentTasks.filter((t) => - t.status === 'in_progress' && !t.metadata?.archivedAt - ).length; - - // If limit reached, move to queue instead - if (inProgressCount >= maxParallelTasks) { - // Only bypass the capacity check if coming from queue AND queue is NOT being processed - // This prevents race condition where both auto-promotion and manual drag exceed the limit - const isAutoPromotionInProgress = oldStatus === 'queue' && isProcessingQueueRef.current; - - if (!isAutoPromotionInProgress) { - console.log(`[Queue] In Progress full (${inProgressCount}/${maxParallelTasks}), moving task to Queue`); - newStatus = 'queue'; - } - } - } - - // Persist status change to file and update local state - // Use handleStatusChange to properly handle worktree cleanup dialog + // Persist status change via handleStatusChange which enforces queue capacity, + // handles worktree cleanup dialogs, and calls processQueue() when a task + // leaves in_progress. await handleStatusChange(activeTaskId, newStatus, task); - - // ============================================ - // QUEUE SYSTEM: Auto-process queue when slot opens - // ============================================ - if (oldStatus === 'in_progress' && newStatus !== 'in_progress') { - // A task left In Progress - check if we can promote from queue - await processQueue(); - } }; return ( diff --git a/apps/frontend/src/renderer/components/TaskCard.tsx b/apps/frontend/src/renderer/components/TaskCard.tsx index d4ff08c2..652346a7 100644 --- a/apps/frontend/src/renderer/components/TaskCard.tsx +++ b/apps/frontend/src/renderer/components/TaskCard.tsx @@ -31,7 +31,8 @@ import { JSON_ERROR_PREFIX, JSON_ERROR_TITLE_SUFFIX } from '../../shared/constants'; -import { startTask, stopTask, checkTaskRunning, recoverStuckTask, isIncompleteHumanReview, archiveTasks, hasRecentActivity } from '../stores/task-store'; +import { stopTask, checkTaskRunning, recoverStuckTask, isIncompleteHumanReview, archiveTasks, hasRecentActivity, startTaskOrQueue } from '../stores/task-store'; +import { useToast } from '../hooks/use-toast'; import type { Task, TaskCategory, ReviewReason, TaskStatus } from '../../shared/types'; // Category icon mapping @@ -133,6 +134,7 @@ export const TaskCard = memo(function TaskCard({ onToggleSelect }: TaskCardProps) { const { t } = useTranslation(['tasks', 'errors']); + const { toast } = useToast(); const [isStuck, setIsStuck] = useState(false); const [isRecovering, setIsRecovering] = useState(false); const stuckIntervalRef = useRef(null); @@ -224,12 +226,21 @@ export const TaskCard = memo(function TaskCard({ }; }, [task.id, isRunning]); - const handleStartStop = (e: React.MouseEvent) => { + const handleStartStop = async (e: React.MouseEvent) => { e.stopPropagation(); if (isRunning && !isStuck) { stopTask(task.id); } else { - startTask(task.id); + const result = await startTaskOrQueue(task.id); + if (!result.success) { + toast({ + title: t('tasks:wizard.errors.startFailed'), + description: result.error, + variant: 'destructive', + }); + } else if (result.action === 'queued') { + toast({ title: t('tasks:queue.movedToQueue') }); + } } }; diff --git a/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx b/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx index b88591c7..eebf65f2 100644 --- a/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx +++ b/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx @@ -32,7 +32,7 @@ import { } from 'lucide-react'; import { cn } from '../../lib/utils'; import { calculateProgress } from '../../lib/utils'; -import { startTask, stopTask, submitReview, recoverStuckTask, deleteTask, useTaskStore } from '../../stores/task-store'; +import { stopTask, submitReview, recoverStuckTask, deleteTask, useTaskStore, startTaskOrQueue } from '../../stores/task-store'; import { useProjectStore } from '../../stores/project-store'; import { TASK_STATUS_LABELS } from '../../../shared/constants'; import { TaskEditDialog } from '../TaskEditDialog'; @@ -105,7 +105,16 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals, return; } } - startTask(task.id); + const result = await startTaskOrQueue(task.id); + if (!result.success) { + toast({ + title: t('tasks:wizard.errors.startFailed'), + description: result.error, + variant: 'destructive', + }); + } else if (result.action === 'queued') { + toast({ title: t('tasks:queue.movedToQueue') }); + } } }; diff --git a/apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceMessages.tsx b/apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceMessages.tsx index f77fbb59..e9def911 100644 --- a/apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceMessages.tsx +++ b/apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceMessages.tsx @@ -1,7 +1,8 @@ import { AlertCircle, GitMerge, Loader2, Check, RotateCcw, Play } from 'lucide-react'; import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Button } from '../../ui/button'; -import { persistTaskStatus, startTask } from '../../../stores/task-store'; +import { persistTaskStatus, startTaskOrQueue } from '../../../stores/task-store'; import type { Task } from '../../../../shared/types'; interface LoadingMessageProps { @@ -31,8 +32,11 @@ interface NoWorkspaceMessageProps { * Displays message when no workspace is found for the task */ export function NoWorkspaceMessage({ task, onClose }: NoWorkspaceMessageProps) { + const { t } = useTranslation(['tasks']); const [isMarkingDone, setIsMarkingDone] = useState(false); const [isProceeding, setIsProceeding] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); const isPlanReview = task?.status === 'human_review' && @@ -57,10 +61,18 @@ export function NoWorkspaceMessage({ task, onClose }: NoWorkspaceMessageProps) { if (!task) return; setIsProceeding(true); + setError(null); + setNotice(null); try { - await startTask(task.id); + const result = await startTaskOrQueue(task.id); + if (!result.success) { + setError(result.error || t('tasks:wizard.errors.startFailed')); + } else if (result.action === 'queued') { + setNotice(t('tasks:queue.movedToQueue')); + } } catch (err) { console.error('Error proceeding to coding:', err); + setError(err instanceof Error ? err.message : 'Failed to start task'); } finally { setIsProceeding(false); } @@ -120,6 +132,13 @@ export function NoWorkspaceMessage({ task, onClose }: NoWorkspaceMessageProps) { )} )} + + {error && ( +

{error}

+ )} + {notice && ( +

{notice}

+ )} ); } diff --git a/apps/frontend/src/renderer/stores/task-store.ts b/apps/frontend/src/renderer/stores/task-store.ts index 98e21f7c..6b28e88c 100644 --- a/apps/frontend/src/renderer/stores/task-store.ts +++ b/apps/frontend/src/renderer/stores/task-store.ts @@ -2,6 +2,10 @@ 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 { debugLog, debugWarn } from '../../shared/utils/debug-logger'; +import { useProjectStore } from './project-store'; + +/** Default max parallel tasks when no project setting is configured */ +export const DEFAULT_MAX_PARALLEL_TASKS = 3; interface TaskState { tasks: Task[]; @@ -826,6 +830,54 @@ export async function forceCompleteTask(taskId: string): Promise + t.status === 'in_progress' && !t.metadata?.archivedAt && (!excludeTaskId || t.id !== excludeTaskId) + ).length; + return inProgressCount >= maxParallelTasks; +} + +export interface StartTaskOrQueueResult { + /** Whether the task was started ('started') or redirected to queue ('queued') */ + action: 'started' | 'queued'; + success: boolean; + error?: string; +} + +/** + * Start a task or queue it if parallel task capacity is full. + * If the task is already in_progress (stuck restart), it is excluded from the + * capacity count so restarting is always allowed. + * Returns a result so callers can provide user-facing feedback. + * + * For action 'started', success indicates the IPC start command was dispatched. + * Backend failures are surfaced asynchronously through task status change events, + * not through this return value. + */ +export async function startTaskOrQueue(taskId: string): Promise { + const task = useTaskStore.getState().tasks.find(t => t.id === taskId); + // Exclude this task from the capacity check when it's already in_progress (stuck restart) + const excludeId = task?.status === 'in_progress' ? taskId : undefined; + + if (isQueueAtCapacity(excludeId)) { + const result = await persistTaskStatus(taskId, 'queue'); + if (!result.success) { + console.error('[Queue] Failed to queue task:', taskId, result.error); + return { action: 'queued', success: false, error: result.error }; + } + return { action: 'queued', success: true }; + } + + startTask(taskId); + return { action: 'started', success: true }; +} + /** * Update task title/description/metadata and persist to file */ diff --git a/apps/frontend/src/shared/i18n/locales/en/tasks.json b/apps/frontend/src/shared/i18n/locales/en/tasks.json index 22e3ddb8..ecd78827 100644 --- a/apps/frontend/src/shared/i18n/locales/en/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/en/tasks.json @@ -222,7 +222,8 @@ "useWorktreeDescription": "Creates changes in a separate git worktree for safe review before merging. Disable to build directly in your project (faster but riskier)." }, "errors": { - "createFailed": "Failed to create task. Please try again." + "createFailed": "Failed to create task. Please try again.", + "startFailed": "Failed to start task" } }, "feedback": { diff --git a/apps/frontend/src/shared/i18n/locales/fr/tasks.json b/apps/frontend/src/shared/i18n/locales/fr/tasks.json index 2442b2b3..00af23a4 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/fr/tasks.json @@ -2,6 +2,7 @@ "refreshTasks": "Actualiser les tâches", "status": { "backlog": "Backlog", + "queue": "File d'attente", "todo": "À faire", "in_progress": "En cours", "review": "Révision", @@ -221,7 +222,8 @@ "useWorktreeDescription": "Crée les changements dans un worktree git séparé pour une révision sécurisée avant la fusion. Désactivez pour travailler directement dans votre projet (plus rapide mais risqué)." }, "errors": { - "createFailed": "Échec de la création de la tâche. Veuillez réessayer." + "createFailed": "Échec de la création de la tâche. Veuillez réessayer.", + "startFailed": "Échec du démarrage de la tâche" } }, "feedback": {