feat(HAI-055): add failed task status handling with retry support
- Set task status to 'failed' on execution failure in engine executor - Add failed indicator styling on TaskCard component - Add POST /tasks/:id/retry API endpoint and client function - Add retry button in TaskDetailModal for failed tasks - Add tests for failed indicator, retry endpoint, and modal behavior
This commit is contained in:
@@ -115,7 +115,7 @@ export const COLUMN_DESCRIPTIONS: Record<Column, string> = {
|
||||
export const VALID_TRANSITIONS: Record<Column, Column[]> = {
|
||||
triage: ["todo"],
|
||||
todo: ["in-progress", "triage"],
|
||||
"in-progress": ["in-review"],
|
||||
"in-progress": ["in-review", "todo"],
|
||||
"in-review": ["done", "in-progress"],
|
||||
done: [],
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ function AppInner() {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
||||
const [autoMerge, setAutoMerge] = useState(false);
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask } = useTasks();
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask } = useTasks();
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
@@ -78,6 +78,7 @@ function AppInner() {
|
||||
onMoveTask={moveTask}
|
||||
onDeleteTask={deleteTask}
|
||||
onMergeTask={mergeTask}
|
||||
onRetryTask={retryTask}
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -52,6 +52,10 @@ export function mergeTask(id: string): Promise<MergeResult> {
|
||||
return api<MergeResult>(`/tasks/${id}/merge`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function retryTask(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/retry`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function fetchConfig(): Promise<{ maxConcurrent: number }> {
|
||||
return api<{ maxConcurrent: number }>("/config");
|
||||
}
|
||||
|
||||
@@ -51,8 +51,9 @@ export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps
|
||||
}
|
||||
}, [task.id, onOpenDetail, addToast]);
|
||||
|
||||
const isAgentActive = !queued && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
||||
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}`;
|
||||
const isFailed = task.status === "failed";
|
||||
const isAgentActive = !queued && !isFailed && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
||||
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -67,11 +68,11 @@ export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps
|
||||
<span className="card-id">{task.id}</span>
|
||||
{task.status && task.status !== "queued" && (
|
||||
<span
|
||||
className={`card-status-badge${ACTIVE_STATUSES.has(task.status) ? " pulsing" : ""}`}
|
||||
style={{
|
||||
background: COLUMN_COLOR_MAP[task.column],
|
||||
color: COLUMN_TEXT_COLOR_MAP[task.column],
|
||||
}}
|
||||
className={`card-status-badge${ACTIVE_STATUSES.has(task.status) ? " pulsing" : ""}${isFailed ? " failed" : ""}`}
|
||||
style={isFailed
|
||||
? { background: "rgba(218,54,51,0.15)", color: "#da3633" }
|
||||
: { background: COLUMN_COLOR_MAP[task.column], color: COLUMN_TEXT_COLOR_MAP[task.column] }
|
||||
}
|
||||
>
|
||||
{task.status}
|
||||
</span>
|
||||
|
||||
@@ -33,6 +33,7 @@ interface TaskDetailModalProps {
|
||||
onMoveTask: (id: string, column: Column) => Promise<Task>;
|
||||
onDeleteTask: (id: string) => Promise<Task>;
|
||||
onMergeTask: (id: string) => Promise<MergeResult>;
|
||||
onRetryTask?: (id: string) => Promise<Task>;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
@@ -42,6 +43,7 @@ export function TaskDetailModal({
|
||||
onMoveTask,
|
||||
onDeleteTask,
|
||||
onMergeTask,
|
||||
onRetryTask,
|
||||
addToast,
|
||||
}: TaskDetailModalProps) {
|
||||
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
||||
@@ -102,6 +104,17 @@ export function TaskDetailModal({
|
||||
});
|
||||
}, [task.id, onMergeTask, onClose, addToast]);
|
||||
|
||||
const handleRetry = useCallback(async () => {
|
||||
if (!onRetryTask) return;
|
||||
try {
|
||||
await onRetryTask(task.id);
|
||||
onClose();
|
||||
addToast(`Retrying ${task.id}...`, "info");
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
}, [task.id, onRetryTask, onClose, addToast]);
|
||||
|
||||
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -270,6 +283,11 @@ export function TaskDetailModal({
|
||||
<button className="btn btn-danger btn-sm" onClick={handleDelete}>
|
||||
Delete
|
||||
</button>
|
||||
{task.status === "failed" && onRetryTask && (
|
||||
<button className="btn btn-warning btn-sm" onClick={handleRetry}>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
{task.column === "in-review" ? (
|
||||
<>
|
||||
|
||||
@@ -13,8 +13,9 @@ const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finali
|
||||
/** Mirrors the cardClass computation from TaskCard.tsx */
|
||||
function computeCardClass(opts: { dragging?: boolean; queued?: boolean; status?: string; column?: Column }): string {
|
||||
const { dragging = false, queued = false, status, column = "todo" } = opts;
|
||||
const isAgentActive = !queued && (column === "in-progress" || ACTIVE_STATUSES.has(status as string));
|
||||
return `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}`;
|
||||
const isFailed = status === "failed";
|
||||
const isAgentActive = !queued && !isFailed && (column === "in-progress" || ACTIVE_STATUSES.has(status as string));
|
||||
return `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}`;
|
||||
}
|
||||
|
||||
describe("TaskCard agent-active class", () => {
|
||||
@@ -89,6 +90,45 @@ describe("TaskCard agent-active class", () => {
|
||||
expect(cls).not.toContain("agent-active");
|
||||
expect(cls).toContain("queued");
|
||||
});
|
||||
|
||||
it("does NOT apply agent-active when status is 'failed' even in in-progress column", () => {
|
||||
const cls = computeCardClass({ column: "in-progress", status: "failed" });
|
||||
expect(cls).not.toContain("agent-active");
|
||||
expect(cls).toContain("failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard failed status", () => {
|
||||
it("applies 'failed' class to card when status is 'failed'", () => {
|
||||
const cls = computeCardClass({ status: "failed", column: "in-progress" });
|
||||
expect(cls).toContain("failed");
|
||||
expect(cls).not.toContain("agent-active");
|
||||
});
|
||||
|
||||
it("does NOT apply 'failed' class for non-failed statuses", () => {
|
||||
const cls = computeCardClass({ status: "executing", column: "in-progress" });
|
||||
expect(cls).not.toContain("failed");
|
||||
});
|
||||
|
||||
it("does NOT apply 'failed' class when status is undefined", () => {
|
||||
const cls = computeCardClass({ column: "in-progress" });
|
||||
expect(cls).not.toContain("failed");
|
||||
});
|
||||
|
||||
/** Mirrors the badge style condition from TaskCard.tsx */
|
||||
function shouldShowFailedBadge(status?: string | null): boolean {
|
||||
return status === "failed";
|
||||
}
|
||||
|
||||
it("shows failed badge when status is 'failed'", () => {
|
||||
expect(shouldShowFailedBadge("failed")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT show failed badge for other statuses", () => {
|
||||
expect(shouldShowFailedBadge("executing")).toBe(false);
|
||||
expect(shouldShowFailedBadge(undefined)).toBe(false);
|
||||
expect(shouldShowFailedBadge(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard queued badge logic", () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ const noop = vi.fn();
|
||||
const noopMove = vi.fn(async () => ({}) as Task);
|
||||
const noopDelete = vi.fn(async () => ({}) as Task);
|
||||
const noopMerge = vi.fn(async () => ({ merged: false }) as MergeResult);
|
||||
const noopRetry = vi.fn(async () => ({}) as Task);
|
||||
|
||||
describe("TaskDetailModal", () => {
|
||||
it("renders markdown-body without detail-prompt class when prompt exists", () => {
|
||||
@@ -91,6 +92,53 @@ describe("TaskDetailModal", () => {
|
||||
expect(screen.queryByText("PROMPT.md")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders Retry button when task status is 'failed'", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ status: "failed" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onRetryTask={noopRetry}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Retry")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT render Retry button when task status is not 'failed'", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ status: "executing" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onRetryTask={noopRetry}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Retry")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT render Retry button when onRetryTask is not provided", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ status: "failed" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Retry")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows description exactly once for a task without title", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
|
||||
@@ -68,5 +68,9 @@ export function useTasks() {
|
||||
return api.mergeTask(id);
|
||||
}, []);
|
||||
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask };
|
||||
const retryTask = useCallback(async (id: string): Promise<Task> => {
|
||||
return api.retryTask(id);
|
||||
}, []);
|
||||
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask };
|
||||
}
|
||||
|
||||
@@ -87,6 +87,13 @@ html, body {
|
||||
}
|
||||
.btn-danger:hover { background: #f85149; }
|
||||
|
||||
.btn-warning {
|
||||
background: #d29922;
|
||||
border-color: #e3b341;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-warning:hover { background: #e3b341; }
|
||||
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
|
||||
/* === Board === */
|
||||
@@ -256,6 +263,15 @@ html, body {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.card-status-badge.failed {
|
||||
background: rgba(218,54,51,0.15);
|
||||
color: #da3633;
|
||||
}
|
||||
|
||||
.card.failed {
|
||||
border-left: 3px solid #da3633;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
|
||||
@@ -16,6 +16,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
mergeTask: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn(),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
@@ -143,6 +144,61 @@ describe("GET /tasks/:id", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tasks/:id/retry", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("retries a failed task and moves it to todo", async () => {
|
||||
const failedTask = { ...FAKE_TASK_DETAIL, status: "failed" };
|
||||
const movedTask = { ...FAKE_TASK_DETAIL, column: "todo", status: undefined };
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(failedTask);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(failedTask);
|
||||
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/retry", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-001", { status: undefined });
|
||||
expect(store.moveTask).toHaveBeenCalledWith("HAI-001", "todo");
|
||||
});
|
||||
|
||||
it("returns 400 when task is not in failed state", async () => {
|
||||
const activeTask = { ...FAKE_TASK_DETAIL, status: "executing" };
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(activeTask);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/retry", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("not in a failed state");
|
||||
});
|
||||
|
||||
it("returns 400 when task is not in in-progress column", async () => {
|
||||
const doneTask = { ...FAKE_TASK_DETAIL, column: "done", status: "failed" };
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/retry", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("not in a failed state");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Attachment routes", () => {
|
||||
const FAKE_ATTACHMENT: TaskAttachment = {
|
||||
filename: "1234-screenshot.png",
|
||||
|
||||
@@ -108,6 +108,23 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// Retry failed task
|
||||
router.post("/tasks/:id/retry", async (req, res) => {
|
||||
try {
|
||||
const task = await store.getTask(req.params.id);
|
||||
if (task.column !== "in-progress" || task.status !== "failed") {
|
||||
res.status(400).json({ error: "Task is not in a failed state" });
|
||||
return;
|
||||
}
|
||||
await store.updateTask(req.params.id, { status: undefined });
|
||||
await store.logEntry(req.params.id, "Retry requested from dashboard");
|
||||
const updated = await store.moveTask(req.params.id, "todo");
|
||||
res.json(updated);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload attachment
|
||||
router.post("/tasks/:id/attachments", upload.single("file"), async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -143,6 +143,31 @@ describe("TaskExecutor with semaphore", () => {
|
||||
expect(onError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets task status to 'failed' when execution throws", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
mockedCreateHaiAgent.mockRejectedValue(new Error("agent crashed"));
|
||||
|
||||
const onError = vi.fn();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
||||
|
||||
await executor.execute({
|
||||
id: "HAI-001",
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-001", { status: "failed" });
|
||||
expect(onError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("concurrent executions respect semaphore limit", async () => {
|
||||
const sem = new AgentSemaphore(1);
|
||||
const store = createMockStore();
|
||||
|
||||
@@ -351,6 +351,7 @@ export class TaskExecutor {
|
||||
} catch (err: any) {
|
||||
console.error(`[executor] ✗ ${task.id} execution failed:`, err.message);
|
||||
await this.store.logEntry(task.id, `Execution failed: ${err.message}`);
|
||||
await this.store.updateTask(task.id, { status: "failed" });
|
||||
this.options.onError?.(task, err);
|
||||
} finally {
|
||||
this.executing.delete(task.id);
|
||||
|
||||
Reference in New Issue
Block a user