From d81e0864f7010d65bc893e1b19f3509f65955291 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 20:41:49 +0100 Subject: [PATCH] feat: Add retry mechanism for blocked tasks Blocked tasks (from spawn failures) were a dead-end with no way to recover. Add retryBlockedTask to DispatchManager that resets status to pending and re-queues, a tRPC mutation that also kicks dispatchNext, and a Retry button in the task slide-over when status is blocked. --- apps/server/dispatch/manager.ts | 21 ++++++++++ apps/server/dispatch/phase-manager.test.ts | 1 + apps/server/dispatch/types.ts | 8 ++++ apps/server/trpc/routers/dispatch.ts | 10 +++++ .../components/execution/TaskSlideOver.tsx | 40 +++++++++++++------ docs/dispatch-events.md | 2 + 6 files changed, 70 insertions(+), 12 deletions(-) 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" ? ( + + ) : ( + + )}