diff --git a/apps/server/dispatch/manager.ts b/apps/server/dispatch/manager.ts index b799e57..ad17f7e 100644 --- a/apps/server/dispatch/manager.ts +++ b/apps/server/dispatch/manager.ts @@ -237,6 +237,27 @@ export class DefaultDispatchManager implements DispatchManager { this.eventBus.emit(event); } + /** + * Retry a blocked task. + * Resets status to pending, clears block state, and re-queues for dispatch. + */ + async retryBlockedTask(taskId: string): Promise { + const task = await this.taskRepository.findById(taskId); + if (!task) throw new Error(`Task not found: ${taskId}`); + if (task.status !== 'blocked') throw new Error(`Task ${taskId} is not blocked (status: ${task.status})`); + + // Clear blocked state + this.blockedTasks.delete(taskId); + + // Reset DB status to pending + await this.taskRepository.update(taskId, { status: 'pending' }); + + log.info({ taskId }, 'retrying blocked task'); + + // Re-queue for dispatch + await this.queue(taskId); + } + /** * Dispatch next available task to an agent. */ diff --git a/apps/server/dispatch/phase-manager.test.ts b/apps/server/dispatch/phase-manager.test.ts index 246e141..bd57241 100644 --- a/apps/server/dispatch/phase-manager.test.ts +++ b/apps/server/dispatch/phase-manager.test.ts @@ -50,6 +50,7 @@ function createMockDispatchManager(): DispatchManager { dispatchNext: vi.fn().mockResolvedValue({ success: false, reason: 'mock' }), completeTask: vi.fn(), blockTask: vi.fn(), + retryBlockedTask: vi.fn(), getQueueState: vi.fn().mockResolvedValue({ queued: [], ready: [], blocked: [] }), }; } diff --git a/apps/server/dispatch/types.ts b/apps/server/dispatch/types.ts index 6c86111..6478ce2 100644 --- a/apps/server/dispatch/types.ts +++ b/apps/server/dispatch/types.ts @@ -102,6 +102,14 @@ export interface DispatchManager { */ blockTask(taskId: string, reason: string): Promise; + /** + * Retry a blocked task. + * Resets status to pending, removes from blocked map, and re-queues for dispatch. + * + * @param taskId - ID of the blocked task to retry + */ + retryBlockedTask(taskId: string): Promise; + /** * Get current queue state. * Returns all queued tasks with their dispatch readiness. diff --git a/apps/server/trpc/routers/dispatch.ts b/apps/server/trpc/routers/dispatch.ts index 13f4b0c..1a41dc5 100644 --- a/apps/server/trpc/routers/dispatch.ts +++ b/apps/server/trpc/routers/dispatch.ts @@ -35,5 +35,15 @@ export function dispatchProcedures(publicProcedure: ProcedureBuilder) { await dispatchManager.completeTask(input.taskId); return { success: true }; }), + + retryBlockedTask: publicProcedure + .input(z.object({ taskId: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const dispatchManager = requireDispatchManager(ctx); + await dispatchManager.retryBlockedTask(input.taskId); + // Kick dispatch loop to pick up the re-queued task + await dispatchManager.dispatchNext(); + return { success: true }; + }), }; } diff --git a/apps/web/src/components/execution/TaskSlideOver.tsx b/apps/web/src/components/execution/TaskSlideOver.tsx index e2df6bf..ff9a4d3 100644 --- a/apps/web/src/components/execution/TaskSlideOver.tsx +++ b/apps/web/src/components/execution/TaskSlideOver.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useMemo } from "react"; import { motion, AnimatePresence } from "motion/react"; -import { X, Trash2, MessageCircle } from "lucide-react"; +import { X, Trash2, MessageCircle, RotateCw } from "lucide-react"; import type { ChatTarget } from "@/components/chat/ChatSlideOver"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -20,6 +20,7 @@ interface TaskSlideOverProps { export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) { const { selectedEntry, setSelectedTaskId } = useExecutionContext(); const queueTaskMutation = trpc.queueTask.useMutation(); + const retryBlockedTaskMutation = trpc.retryBlockedTask.useMutation(); const deleteTaskMutation = trpc.deleteTask.useMutation(); const updateTaskMutation = trpc.updateTask.useMutation(); @@ -229,17 +230,32 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) { {/* Footer */}
- + {task.status === "blocked" ? ( + + ) : ( + + )}