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

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