Skip to content

Commit 1d64615

Browse files
AndyMik90claude
andauthored
211-when-a-task-is-set-to-planning-column-on-the-kanba__JSON_ERROR_SUFFIX__ (#1786)
* auto-claude: subtask-1-1 - Add queue capacity check to handleStatusChange When a task status is changed to 'in_progress' via handleStatusChange (e.g., from column header buttons or context menus), enforce the maxParallelTasks limit by redirecting to 'queue' if capacity is full. Also auto-process the queue when a task leaves in_progress. This mirrors the existing logic in handleDragEnd. Co-Authored-By: Claude Opus 4.6 <[email protected]> * auto-claude: subtask-1-2 - Add queue capacity check before startTask() in TaskCard, TaskDetailModal, WorkspaceMessages Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: extract shared queue capacity logic and fix stuck task restart regression - Extract `startTaskOrQueue()`, `isQueueAtCapacity()`, and `DEFAULT_MAX_PARALLEL_TASKS` into task-store.ts to eliminate identical queue capacity logic duplicated across 4 files (DRY violation) - Fix stuck task restart regression: exclude the current task from the in_progress count so restarting a stuck task doesn't incorrectly queue it - Fix inconsistent default: use ?? 3 everywhere (was ?? 1 in 3 new files vs ?? 3 in KanbanBoard, causing different behavior per UI element) - Fix unawaited persistTaskStatus in TaskCard (was fire-and-forget in a sync handler) and TaskDetailModal (missing await in async handler) - Add explanatory comment in KanbanBoard handleStatusChange about why isAutoPromotionInProgress guard is not needed (only user interactions) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: remove duplicate processQueue() call in handleDragEnd handleStatusChange already calls processQueue() when a task leaves in_progress, so the second call in handleDragEnd was redundant. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: log queue failures, remove dead bypass code, fix comment - startTaskOrQueue now logs an error when persistTaskStatus fails instead of silently discarding the result - Remove dead isAutoPromotionInProgress bypass from drag handler since handleStatusChange enforces capacity independently (the bypass was negated by the second check) - Fix inaccurate comment: handleStatusChange is called from both the dropdown menu and the drag handler, not just the dropdown Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: return queue failure result from startTaskOrQueue and remove duplicate processQueue startTaskOrQueue now returns a result object so callers can surface errors to the user (toast in TaskDetailModal, console.error in WorkspaceMessages). Removed explicit processQueue() from handleStatusChange since the useEffect task status change listener already handles queue auto-promotion. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: correct i18n key path and surface startTaskOrQueue failures to users Fix wrong i18n key path (tasks:errors → tasks:wizard.errors) so the toast shows the translated message instead of a raw key. Add toast feedback in TaskCard on start failure. Add inline error display in WorkspaceMessages when Proceed to Coding fails. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: show user feedback when task is queued instead of started All three startTaskOrQueue callers (TaskCard, TaskDetailModal, WorkspaceMessages) now notify the user when a task is redirected to the queue due to the parallel task limit. Uses existing i18n keys (tasks:queue.movedToQueue). Also clarifies startTaskOrQueue JSDoc regarding fire-and-forget semantics of the 'started' action. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: use i18n and neutral styling for queued notice in WorkspaceMessages Replace hardcoded English string with t('tasks:queue.movedToQueue') and use a separate notice state with text-muted-foreground styling instead of reusing the destructive error state. Also add missing status.queue key to French translations. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
1 parent cd89147 commit 1d64615

7 files changed

Lines changed: 127 additions & 45 deletions

File tree

apps/frontend/src/renderer/components/KanbanBoard.tsx

Lines changed: 24 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { SortableTaskCard } from './SortableTaskCard';
2929
import { QueueSettingsModal } from './QueueSettingsModal';
3030
import { TASK_STATUS_COLUMNS, TASK_STATUS_LABELS } from '../../shared/constants';
3131
import { cn } from '../lib/utils';
32-
import { persistTaskStatus, forceCompleteTask, archiveTasks, deleteTasks, useTaskStore } from '../stores/task-store';
32+
import { persistTaskStatus, forceCompleteTask, archiveTasks, deleteTasks, useTaskStore, isQueueAtCapacity, DEFAULT_MAX_PARALLEL_TASKS } from '../stores/task-store';
3333
import { updateProjectSettings, useProjectStore } from '../stores/project-store';
3434
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';
3535
import { useToast } from '../hooks/use-toast';
@@ -663,7 +663,7 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
663663
// Get projectId from first task
664664
const projectId = tasks[0]?.projectId;
665665
const project = projectId ? projects.find((p) => p.id === projectId) : undefined;
666-
const maxParallelTasks = project?.settings?.maxParallelTasks ?? 3;
666+
const maxParallelTasks = project?.settings?.maxParallelTasks ?? DEFAULT_MAX_PARALLEL_TASKS;
667667

668668
// Queue settings modal state
669669
const [showQueueSettings, setShowQueueSettings] = useState(false);
@@ -947,8 +947,23 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
947947
* Handle status change with worktree cleanup dialog support
948948
* Consolidated handler that accepts an optional task object for the dialog title
949949
*/
950-
const handleStatusChange = async (taskId: string, newStatus: TaskStatus, providedTask?: Task) => {
950+
const handleStatusChange = async (taskId: string, requestedStatus: TaskStatus, providedTask?: Task) => {
951951
const task = providedTask || tasks.find(t => t.id === taskId);
952+
let newStatus = requestedStatus;
953+
954+
// ============================================
955+
// QUEUE SYSTEM: Enforce parallel task limit
956+
// Called from both the dropdown menu and the drag-and-drop handler.
957+
// Excludes the task itself from the count to handle re-entry (e.g., redundant
958+
// status change or race with auto-promotion). processQueue auto-promotion
959+
// calls persistTaskStatus directly, never this function.
960+
// ============================================
961+
if (newStatus === 'in_progress' && isQueueAtCapacity(taskId)) {
962+
console.log('[Queue] In Progress full, redirecting task to Queue');
963+
newStatus = 'queue';
964+
}
965+
966+
const oldStatus = task?.status;
952967
const result = await persistTaskStatus(taskId, newStatus);
953968

954969
if (!result.success) {
@@ -971,6 +986,9 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
971986
});
972987
}
973988
}
989+
// Note: queue auto-promotion when a task leaves in_progress is handled by the
990+
// useEffect task status change listener (registerTaskStatusChangeListener), so
991+
// no explicit processQueue() call is needed here.
974992
};
975993

976994
/**
@@ -1402,40 +1420,10 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
14021420

14031421
if (!newStatus || newStatus === oldStatus) return;
14041422

1405-
// ============================================
1406-
// QUEUE SYSTEM: Enforce parallel task limit
1407-
// ============================================
1408-
if (newStatus === 'in_progress') {
1409-
// Get CURRENT state from store directly to avoid stale prop/memo issues during rapid dragging
1410-
const currentTasks = useTaskStore.getState().tasks;
1411-
const inProgressCount = currentTasks.filter((t) =>
1412-
t.status === 'in_progress' && !t.metadata?.archivedAt
1413-
).length;
1414-
1415-
// If limit reached, move to queue instead
1416-
if (inProgressCount >= maxParallelTasks) {
1417-
// Only bypass the capacity check if coming from queue AND queue is NOT being processed
1418-
// This prevents race condition where both auto-promotion and manual drag exceed the limit
1419-
const isAutoPromotionInProgress = oldStatus === 'queue' && isProcessingQueueRef.current;
1420-
1421-
if (!isAutoPromotionInProgress) {
1422-
console.log(`[Queue] In Progress full (${inProgressCount}/${maxParallelTasks}), moving task to Queue`);
1423-
newStatus = 'queue';
1424-
}
1425-
}
1426-
}
1427-
1428-
// Persist status change to file and update local state
1429-
// Use handleStatusChange to properly handle worktree cleanup dialog
1423+
// Persist status change via handleStatusChange which enforces queue capacity,
1424+
// handles worktree cleanup dialogs, and calls processQueue() when a task
1425+
// leaves in_progress.
14301426
await handleStatusChange(activeTaskId, newStatus, task);
1431-
1432-
// ============================================
1433-
// QUEUE SYSTEM: Auto-process queue when slot opens
1434-
// ============================================
1435-
if (oldStatus === 'in_progress' && newStatus !== 'in_progress') {
1436-
// A task left In Progress - check if we can promote from queue
1437-
await processQueue();
1438-
}
14391427
};
14401428

14411429
return (

apps/frontend/src/renderer/components/TaskCard.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ import {
3131
JSON_ERROR_PREFIX,
3232
JSON_ERROR_TITLE_SUFFIX
3333
} from '../../shared/constants';
34-
import { startTask, stopTask, checkTaskRunning, recoverStuckTask, isIncompleteHumanReview, archiveTasks, hasRecentActivity } from '../stores/task-store';
34+
import { stopTask, checkTaskRunning, recoverStuckTask, isIncompleteHumanReview, archiveTasks, hasRecentActivity, startTaskOrQueue } from '../stores/task-store';
35+
import { useToast } from '../hooks/use-toast';
3536
import type { Task, TaskCategory, ReviewReason, TaskStatus } from '../../shared/types';
3637

3738
// Category icon mapping
@@ -133,6 +134,7 @@ export const TaskCard = memo(function TaskCard({
133134
onToggleSelect
134135
}: TaskCardProps) {
135136
const { t } = useTranslation(['tasks', 'errors']);
137+
const { toast } = useToast();
136138
const [isStuck, setIsStuck] = useState(false);
137139
const [isRecovering, setIsRecovering] = useState(false);
138140
const stuckIntervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -224,12 +226,21 @@ export const TaskCard = memo(function TaskCard({
224226
};
225227
}, [task.id, isRunning]);
226228

227-
const handleStartStop = (e: React.MouseEvent) => {
229+
const handleStartStop = async (e: React.MouseEvent) => {
228230
e.stopPropagation();
229231
if (isRunning && !isStuck) {
230232
stopTask(task.id);
231233
} else {
232-
startTask(task.id);
234+
const result = await startTaskOrQueue(task.id);
235+
if (!result.success) {
236+
toast({
237+
title: t('tasks:wizard.errors.startFailed'),
238+
description: result.error,
239+
variant: 'destructive',
240+
});
241+
} else if (result.action === 'queued') {
242+
toast({ title: t('tasks:queue.movedToQueue') });
243+
}
233244
}
234245
};
235246

apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
} from 'lucide-react';
3333
import { cn } from '../../lib/utils';
3434
import { calculateProgress } from '../../lib/utils';
35-
import { startTask, stopTask, submitReview, recoverStuckTask, deleteTask, useTaskStore } from '../../stores/task-store';
35+
import { stopTask, submitReview, recoverStuckTask, deleteTask, useTaskStore, startTaskOrQueue } from '../../stores/task-store';
3636
import { useProjectStore } from '../../stores/project-store';
3737
import { TASK_STATUS_LABELS } from '../../../shared/constants';
3838
import { TaskEditDialog } from '../TaskEditDialog';
@@ -105,7 +105,16 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
105105
return;
106106
}
107107
}
108-
startTask(task.id);
108+
const result = await startTaskOrQueue(task.id);
109+
if (!result.success) {
110+
toast({
111+
title: t('tasks:wizard.errors.startFailed'),
112+
description: result.error,
113+
variant: 'destructive',
114+
});
115+
} else if (result.action === 'queued') {
116+
toast({ title: t('tasks:queue.movedToQueue') });
117+
}
109118
}
110119
};
111120

apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceMessages.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { AlertCircle, GitMerge, Loader2, Check, RotateCcw, Play } from 'lucide-react';
22
import { useState } from 'react';
3+
import { useTranslation } from 'react-i18next';
34
import { Button } from '../../ui/button';
4-
import { persistTaskStatus, startTask } from '../../../stores/task-store';
5+
import { persistTaskStatus, startTaskOrQueue } from '../../../stores/task-store';
56
import type { Task } from '../../../../shared/types';
67

78
interface LoadingMessageProps {
@@ -31,8 +32,11 @@ interface NoWorkspaceMessageProps {
3132
* Displays message when no workspace is found for the task
3233
*/
3334
export function NoWorkspaceMessage({ task, onClose }: NoWorkspaceMessageProps) {
35+
const { t } = useTranslation(['tasks']);
3436
const [isMarkingDone, setIsMarkingDone] = useState(false);
3537
const [isProceeding, setIsProceeding] = useState(false);
38+
const [error, setError] = useState<string | null>(null);
39+
const [notice, setNotice] = useState<string | null>(null);
3640

3741
const isPlanReview =
3842
task?.status === 'human_review' &&
@@ -57,10 +61,18 @@ export function NoWorkspaceMessage({ task, onClose }: NoWorkspaceMessageProps) {
5761
if (!task) return;
5862

5963
setIsProceeding(true);
64+
setError(null);
65+
setNotice(null);
6066
try {
61-
await startTask(task.id);
67+
const result = await startTaskOrQueue(task.id);
68+
if (!result.success) {
69+
setError(result.error || t('tasks:wizard.errors.startFailed'));
70+
} else if (result.action === 'queued') {
71+
setNotice(t('tasks:queue.movedToQueue'));
72+
}
6273
} catch (err) {
6374
console.error('Error proceeding to coding:', err);
75+
setError(err instanceof Error ? err.message : 'Failed to start task');
6476
} finally {
6577
setIsProceeding(false);
6678
}
@@ -120,6 +132,13 @@ export function NoWorkspaceMessage({ task, onClose }: NoWorkspaceMessageProps) {
120132
)}
121133
</Button>
122134
)}
135+
136+
{error && (
137+
<p className="text-xs text-destructive mt-2">{error}</p>
138+
)}
139+
{notice && (
140+
<p className="text-xs text-muted-foreground mt-2">{notice}</p>
141+
)}
123142
</div>
124143
);
125144
}

apps/frontend/src/renderer/stores/task-store.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { create } from 'zustand';
22
import { arrayMove } from '@dnd-kit/sortable';
33
import type { Task, TaskStatus, SubtaskStatus, ImplementationPlan, Subtask, TaskMetadata, ExecutionProgress, ExecutionPhase, ReviewReason, TaskDraft, ImageAttachment, TaskOrderState } from '../../shared/types';
44
import { debugLog, debugWarn } from '../../shared/utils/debug-logger';
5+
import { useProjectStore } from './project-store';
6+
7+
/** Default max parallel tasks when no project setting is configured */
8+
export const DEFAULT_MAX_PARALLEL_TASKS = 3;
59

610
interface TaskState {
711
tasks: Task[];
@@ -826,6 +830,54 @@ export async function forceCompleteTask(taskId: string): Promise<PersistStatusRe
826830
return persistTaskStatus(taskId, 'done', { forceCleanup: true });
827831
}
828832

833+
/**
834+
* Check if the in_progress queue is at capacity.
835+
* @param excludeTaskId - Task ID to exclude from the count (e.g., when restarting a stuck task already in in_progress)
836+
*/
837+
export function isQueueAtCapacity(excludeTaskId?: string): boolean {
838+
const maxParallelTasks = useProjectStore.getState().getActiveProject()?.settings?.maxParallelTasks ?? DEFAULT_MAX_PARALLEL_TASKS;
839+
const currentTasks = useTaskStore.getState().tasks;
840+
const inProgressCount = currentTasks.filter((t) =>
841+
t.status === 'in_progress' && !t.metadata?.archivedAt && (!excludeTaskId || t.id !== excludeTaskId)
842+
).length;
843+
return inProgressCount >= maxParallelTasks;
844+
}
845+
846+
export interface StartTaskOrQueueResult {
847+
/** Whether the task was started ('started') or redirected to queue ('queued') */
848+
action: 'started' | 'queued';
849+
success: boolean;
850+
error?: string;
851+
}
852+
853+
/**
854+
* Start a task or queue it if parallel task capacity is full.
855+
* If the task is already in_progress (stuck restart), it is excluded from the
856+
* capacity count so restarting is always allowed.
857+
* Returns a result so callers can provide user-facing feedback.
858+
*
859+
* For action 'started', success indicates the IPC start command was dispatched.
860+
* Backend failures are surfaced asynchronously through task status change events,
861+
* not through this return value.
862+
*/
863+
export async function startTaskOrQueue(taskId: string): Promise<StartTaskOrQueueResult> {
864+
const task = useTaskStore.getState().tasks.find(t => t.id === taskId);
865+
// Exclude this task from the capacity check when it's already in_progress (stuck restart)
866+
const excludeId = task?.status === 'in_progress' ? taskId : undefined;
867+
868+
if (isQueueAtCapacity(excludeId)) {
869+
const result = await persistTaskStatus(taskId, 'queue');
870+
if (!result.success) {
871+
console.error('[Queue] Failed to queue task:', taskId, result.error);
872+
return { action: 'queued', success: false, error: result.error };
873+
}
874+
return { action: 'queued', success: true };
875+
}
876+
877+
startTask(taskId);
878+
return { action: 'started', success: true };
879+
}
880+
829881
/**
830882
* Update task title/description/metadata and persist to file
831883
*/

apps/frontend/src/shared/i18n/locales/en/tasks.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,8 @@
222222
"useWorktreeDescription": "Creates changes in a separate git worktree for safe review before merging. Disable to build directly in your project (faster but riskier)."
223223
},
224224
"errors": {
225-
"createFailed": "Failed to create task. Please try again."
225+
"createFailed": "Failed to create task. Please try again.",
226+
"startFailed": "Failed to start task"
226227
}
227228
},
228229
"feedback": {

apps/frontend/src/shared/i18n/locales/fr/tasks.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"refreshTasks": "Actualiser les tâches",
33
"status": {
44
"backlog": "Backlog",
5+
"queue": "File d'attente",
56
"todo": "À faire",
67
"in_progress": "En cours",
78
"review": "Révision",
@@ -221,7 +222,8 @@
221222
"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é)."
222223
},
223224
"errors": {
224-
"createFailed": "Échec de la création de la tâche. Veuillez réessayer."
225+
"createFailed": "Échec de la création de la tâche. Veuillez réessayer.",
226+
"startFailed": "Échec du démarrage de la tâche"
225227
}
226228
},
227229
"feedback": {

0 commit comments

Comments
 (0)