fix(FN-954): allow retry for stuck-killed tasks across all interfaces
- Extend retry API to accept 'stuck-killed' as a retryable status alongside 'failed' - Update CLI retry command to recognize stuck-killed tasks - Update pi extension retry tool to handle stuck-killed tasks - Show retry button in TaskDetailModal for stuck-killed task status - Add changeset for patch release
This commit is contained in:
@@ -1849,7 +1849,7 @@ describe("runTaskRetry", () => {
|
||||
column: "in-progress"
|
||||
}));
|
||||
|
||||
await expect(runTaskRetry("FN-001")).rejects.toThrow("Task FN-001 is not failed (status: none)");
|
||||
await expect(runTaskRetry("FN-001")).rejects.toThrow("Task FN-001 is not in a retryable state (status: none)");
|
||||
});
|
||||
|
||||
it("throws error with correct status when task has different status", async () => {
|
||||
@@ -1859,7 +1859,32 @@ describe("runTaskRetry", () => {
|
||||
column: "in-progress"
|
||||
}));
|
||||
|
||||
await expect(runTaskRetry("FN-001")).rejects.toThrow("Task FN-001 is not failed (status: paused)");
|
||||
await expect(runTaskRetry("FN-001")).rejects.toThrow("Task FN-001 is not in a retryable state (status: paused)");
|
||||
});
|
||||
|
||||
it("retries stuck-killed task successfully", async () => {
|
||||
mockGetTask.mockResolvedValueOnce(makeTask({
|
||||
id: "FN-001",
|
||||
status: "stuck-killed",
|
||||
column: "in-progress"
|
||||
}));
|
||||
mockUpdateTask.mockResolvedValueOnce(makeTask({ id: "FN-001", status: undefined, error: undefined }));
|
||||
mockMoveTask.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "todo" }));
|
||||
mockLogEntry.mockResolvedValueOnce(makeTask({ id: "FN-001" }));
|
||||
|
||||
await runTaskRetry("FN-001");
|
||||
|
||||
expect(mockGetTask).toHaveBeenCalledWith("FN-001");
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", { status: null, error: null });
|
||||
expect(mockMoveTask).toHaveBeenCalledWith("FN-001", "todo");
|
||||
expect(mockLogEntry).toHaveBeenCalledWith("FN-001", "Retry requested from CLI", "Task reset to todo for retry");
|
||||
|
||||
const successLine = logSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("✓ Retried"),
|
||||
);
|
||||
expect(successLine).toBeDefined();
|
||||
expect(successLine![0]).toContain("FN-001");
|
||||
expect(successLine![0]).toContain("todo");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -628,9 +628,9 @@ export async function runTaskRetry(id: string, projectName?: string) {
|
||||
throw new Error(`Task ${id} not found`);
|
||||
}
|
||||
|
||||
// Validate task is in failed state
|
||||
if (task.status !== 'failed') {
|
||||
throw new Error(`Task ${id} is not failed (status: ${task.status || 'none'})`);
|
||||
// Validate task is in a retryable state
|
||||
if (task.status !== 'failed' && task.status !== 'stuck-killed') {
|
||||
throw new Error(`Task ${id} is not in a retryable state (status: ${task.status || 'none'})`);
|
||||
}
|
||||
|
||||
// Clear failure state
|
||||
|
||||
@@ -451,10 +451,10 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
};
|
||||
}
|
||||
|
||||
// Validate task is in failed state
|
||||
if (task.status !== 'failed') {
|
||||
// Validate task is in a retryable state
|
||||
if (task.status !== 'failed' && task.status !== 'stuck-killed') {
|
||||
return {
|
||||
content: [{ type: "text", text: `Task ${params.id} is not failed (status: ${task.status || 'none'})` }],
|
||||
content: [{ type: "text", text: `Task ${params.id} is not in a retryable state (status: ${task.status || 'none'})` }],
|
||||
isError: true,
|
||||
details: { taskId: params.id, currentStatus: task.status },
|
||||
};
|
||||
|
||||
@@ -1243,7 +1243,7 @@ export function TaskDetailModal({
|
||||
Refine
|
||||
</button>
|
||||
)}
|
||||
{task.status === "failed" && onRetryTask && (
|
||||
{(task.status === "failed" || task.status === "stuck-killed") && onRetryTask && (
|
||||
<button className="btn btn-warning btn-sm" onClick={handleRetry}>
|
||||
Retry
|
||||
</button>
|
||||
|
||||
@@ -704,7 +704,7 @@ describe("POST /tasks/:id/retry", () => {
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
|
||||
});
|
||||
|
||||
it("returns 400 when task is not in failed state", async () => {
|
||||
it("returns 400 when task is not in a retryable state", async () => {
|
||||
const activeTask = { ...FAKE_TASK_DETAIL, status: "executing" };
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(activeTask);
|
||||
|
||||
@@ -713,7 +713,7 @@ describe("POST /tasks/:id/retry", () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("not in a failed state");
|
||||
expect(res.body.error).toContain("not in a retryable state");
|
||||
});
|
||||
|
||||
it("retries a failed task in any column (not just in-progress)", async () => {
|
||||
@@ -731,6 +731,23 @@ describe("POST /tasks/:id/retry", () => {
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: undefined, error: undefined });
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
|
||||
});
|
||||
|
||||
it("retries a stuck-killed task and moves it to todo", async () => {
|
||||
const stuckTask = { ...FAKE_TASK_DETAIL, status: "stuck-killed", column: "in-progress" };
|
||||
const movedTask = { ...FAKE_TASK_DETAIL, column: "todo", status: undefined };
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(stuckTask);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(stuckTask);
|
||||
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: undefined, error: undefined });
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
|
||||
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Retry requested from dashboard");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tasks/:id/duplicate", () => {
|
||||
|
||||
@@ -1784,13 +1784,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// Retry failed task
|
||||
// Retry failed or stuck-killed task
|
||||
router.post("/tasks/:id/retry", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const task = await scopedStore.getTask(req.params.id);
|
||||
if (task.status !== "failed") {
|
||||
res.status(400).json({ error: "Task is not in a failed state" });
|
||||
if (task.status !== "failed" && task.status !== "stuck-killed") {
|
||||
res.status(400).json({ error: `Task is not in a retryable state (current status: ${task.status || 'none'})` });
|
||||
return;
|
||||
}
|
||||
await scopedStore.updateTask(req.params.id, { status: undefined, error: undefined });
|
||||
|
||||
Reference in New Issue
Block a user