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:
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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: [] }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user