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]>
This commit is contained in:
Andy
2026-02-11 17:41:51 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent cd89147003
commit 7d714310cd
7 changed files with 127 additions and 45 deletions
@@ -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 (
@@ -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<NodeJS.Timeout | null>(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') });
}
}
};
@@ -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') });
}
}
};
@@ -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<string | null>(null);
const [notice, setNotice] = useState<string | null>(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) {
)}
</Button>
)}
{error && (
<p className="text-xs text-destructive mt-2">{error}</p>
)}
{notice && (
<p className="text-xs text-muted-foreground mt-2">{notice}</p>
)}
</div>
);
}
@@ -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<PersistStatusRe
return persistTaskStatus(taskId, 'done', { forceCleanup: true });
}
/**
* Check if the in_progress queue is at capacity.
* @param excludeTaskId - Task ID to exclude from the count (e.g., when restarting a stuck task already in in_progress)
*/
export function isQueueAtCapacity(excludeTaskId?: string): boolean {
const maxParallelTasks = useProjectStore.getState().getActiveProject()?.settings?.maxParallelTasks ?? DEFAULT_MAX_PARALLEL_TASKS;
const currentTasks = useTaskStore.getState().tasks;
const inProgressCount = currentTasks.filter((t) =>
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<StartTaskOrQueueResult> {
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
*/
@@ -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": {
@@ -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": {