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:
Dustin Byrne
2026-03-25 23:50:11 -04:00
parent 95ec4bc4a1
commit 35177f448f
13 changed files with 243 additions and 12 deletions

View File

@@ -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: [],
};

View File

@@ -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}
/>
)}

View File

@@ -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");
}

View File

@@ -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>

View File

@@ -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" ? (
<>

View File

@@ -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", () => {

View File

@@ -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

View File

@@ -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 };
}

View File

@@ -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; }

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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);