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.
This commit is contained in:
Lukas May
2026-03-05 20:41:49 +01:00
parent 2eac5b9908
commit d81e0864f7
6 changed files with 70 additions and 12 deletions

View File

@@ -237,6 +237,27 @@ export class DefaultDispatchManager implements DispatchManager {
this.eventBus.emit(event); 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<void> {
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. * Dispatch next available task to an agent.
*/ */

View File

@@ -50,6 +50,7 @@ function createMockDispatchManager(): DispatchManager {
dispatchNext: vi.fn().mockResolvedValue({ success: false, reason: 'mock' }), dispatchNext: vi.fn().mockResolvedValue({ success: false, reason: 'mock' }),
completeTask: vi.fn(), completeTask: vi.fn(),
blockTask: vi.fn(), blockTask: vi.fn(),
retryBlockedTask: vi.fn(),
getQueueState: vi.fn().mockResolvedValue({ queued: [], ready: [], blocked: [] }), getQueueState: vi.fn().mockResolvedValue({ queued: [], ready: [], blocked: [] }),
}; };
} }

View File

@@ -102,6 +102,14 @@ export interface DispatchManager {
*/ */
blockTask(taskId: string, reason: string): Promise<void>; blockTask(taskId: string, reason: string): Promise<void>;
/**
* 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<void>;
/** /**
* Get current queue state. * Get current queue state.
* Returns all queued tasks with their dispatch readiness. * Returns all queued tasks with their dispatch readiness.

View File

@@ -35,5 +35,15 @@ export function dispatchProcedures(publicProcedure: ProcedureBuilder) {
await dispatchManager.completeTask(input.taskId); await dispatchManager.completeTask(input.taskId);
return { success: true }; 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 };
}),
}; };
} }

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useMemo } from "react"; import { useCallback, useEffect, useRef, useMemo } from "react";
import { motion, AnimatePresence } from "motion/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 type { ChatTarget } from "@/components/chat/ChatSlideOver";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -20,6 +20,7 @@ interface TaskSlideOverProps {
export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) { export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
const { selectedEntry, setSelectedTaskId } = useExecutionContext(); const { selectedEntry, setSelectedTaskId } = useExecutionContext();
const queueTaskMutation = trpc.queueTask.useMutation(); const queueTaskMutation = trpc.queueTask.useMutation();
const retryBlockedTaskMutation = trpc.retryBlockedTask.useMutation();
const deleteTaskMutation = trpc.deleteTask.useMutation(); const deleteTaskMutation = trpc.deleteTask.useMutation();
const updateTaskMutation = trpc.updateTask.useMutation(); const updateTaskMutation = trpc.updateTask.useMutation();
@@ -229,17 +230,32 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
{/* Footer */} {/* Footer */}
<div className="flex items-center gap-2 border-t border-border px-5 py-3"> <div className="flex items-center gap-2 border-t border-border px-5 py-3">
<Button {task.status === "blocked" ? (
variant="outline" <Button
size="sm" variant="outline"
disabled={!canQueue} size="sm"
onClick={() => { className="gap-1.5"
queueTaskMutation.mutate({ taskId: task.id }); onClick={() => {
close(); retryBlockedTaskMutation.mutate({ taskId: task.id });
}} close();
> }}
Queue Task >
</Button> <RotateCw className="h-3.5 w-3.5" />
Retry
</Button>
) : (
<Button
variant="outline"
size="sm"
disabled={!canQueue}
onClick={() => {
queueTaskMutation.mutate({ taskId: task.id });
close();
}}
>
Queue Task
</Button>
)}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"

View File

@@ -68,6 +68,7 @@ InitiativeReviewApprovedEvent { initiativeId, branch, strategy: 'push_branch' |
6. **Planning skip** — Planning-category tasks (research, discuss, plan, detail, refine) skip auto-dispatch — they use the architect flow 6. **Planning skip** — Planning-category tasks (research, discuss, plan, detail, refine) skip auto-dispatch — they use the architect flow
7. **Summary propagation**`completeTask()` reads the completing agent's `result.message` and stores it on the task's `summary` column. Dependent tasks see this summary in `context/tasks/<id>.md` frontmatter. 7. **Summary propagation**`completeTask()` reads the completing agent's `result.message` and stores it on the task's `summary` column. Dependent tasks see this summary in `context/tasks/<id>.md` frontmatter.
8. **Spawn failure** — If `agentManager.spawn()` throws, the task is blocked via `blockTask()` with the error message. The dispatch cycle continues instead of crashing. 8. **Spawn failure** — If `agentManager.spawn()` throws, the task is blocked via `blockTask()` with the error message. The dispatch cycle continues instead of crashing.
9. **Retry blocked**`retryBlockedTask(taskId)` resets a blocked task to pending and re-queues it. Exposed via tRPC `retryBlockedTask` mutation. The UI shows a Retry button in the task slide-over when status is `blocked`.
### DispatchManager Methods ### DispatchManager Methods
@@ -78,6 +79,7 @@ InitiativeReviewApprovedEvent { initiativeId, branch, strategy: 'push_branch' |
| `getNextDispatchable()` | Get next task without dispatching | | `getNextDispatchable()` | Get next task without dispatching |
| `completeTask(taskId, agentId?)` | Complete task | | `completeTask(taskId, agentId?)` | Complete task |
| `blockTask(taskId, reason)` | Block task with reason | | `blockTask(taskId, reason)` | Block task with reason |
| `retryBlockedTask(taskId)` | Reset blocked task to pending and re-queue |
| `getQueueState()` | Return queued, ready, blocked tasks | | `getQueueState()` | Return queued, ready, blocked tasks |
## Phase Dispatch ## Phase Dispatch