FN-6166: allow retry for stranded in-review tasks

Handle in-review tasks with status none as retryable when execution or merge retry state is stranded.

- treat in-review tasks with status "none" plus incomplete execution or prior merge attempts as retryable across the CLI, extension, and dashboard routes
- send stranded execution retries back to todo with preserved progress and keep stranded merge retries in-review while clearing merge retry state
- add CLI, extension, and dashboard regression coverage and publish a patch changeset for @runfusion/fusion

Files changed:
 .changeset/fuzzy-ravens-retry.md                   |   5 +
 packages/cli/src/__tests__/extension.test.ts       | 147 +++++++++++++++++++++
 packages/cli/src/commands/__tests__/task.test.ts   | 147 +++++++++++++++++++--
 packages/cli/src/commands/task.ts                  |  59 ++++++++-
 packages/cli/src/extension.ts                      |  39 ++++--
 packages/dashboard/src/__tests__/routes-tasks-ops.test.ts         | 134 +++++++++++++++++++
 packages/dashboard/src/routes/register-task-workflow-routes.ts    |  30 +++--
 7 files changed, 528 insertions(+), 33 deletions(-)

Fusion-Task-Id: FN-6166

Fusion-Task-Lineage: cf78b180-ded4-4b6f-8e98-35e6f309e1d4
This commit is contained in:
gsxdsm
2026-06-09 22:11:21 -07:00
parent 5a353317c4
commit e883a8df01
7 changed files with 528 additions and 33 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix retry handling for stranded in-review tasks whose status is unset by allowing retry when execution is incomplete or a merge retry has already been attempted.

View File

@@ -3124,6 +3124,153 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(updated?.mergeRetries).toBe(0); expect(updated?.mergeRetries).toBe(0);
}); });
it("moves status-none in-review task with incomplete steps to todo preserving progress", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const task = await store.createTask({
title: "status-none execution-stalled task",
description: "test",
column: "todo",
});
await store.updateTask(task.id, {
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "in-progress" },
{ name: "Step 2", status: "pending" },
],
});
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, { status: null, error: "stalled without failed status", mergeRetries: 5 });
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-status-none-exec", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
expect(result.details.newColumn).toBe("todo");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("todo");
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expect(updated?.steps[1].status).toBe("in-progress");
expect(updated?.mergeRetries).toBe(5);
});
it("moves status-none zero-step in-review task with no merge attempts to todo", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const task = await store.createTask({
title: "status-none zero-step execution-stalled task",
description: "test",
column: "todo",
});
await writeFile(join(tmpDir, ".fusion", "tasks", task.id, "PROMPT.md"), "# status-none zero-step execution-stalled task\n\nNo steps yet.\n");
await store.updateTask(task.id, { steps: [] });
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, { status: null, error: "stalled before planning steps", mergeRetries: 0, steps: [] });
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-status-none-zero-step-exec", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
expect(result.details.newColumn).toBe("todo");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("todo");
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expect(updated?.steps).toEqual([]);
expect(updated?.mergeRetries).toBe(0);
});
it("keeps status-none in-review task with prior merge attempts in-review and resets merge state", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const task = await store.createTask({
title: "status-none merge-stalled task",
description: "test",
column: "todo",
});
await store.updateTask(task.id, {
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "done" },
],
});
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, { status: null, error: "merge retry exhausted without failed status", mergeRetries: 2 });
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-status-none-merge", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
expect(result.details.newColumn).toBe("in-review");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("in-review");
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expect(updated?.mergeRetries).toBe(0);
});
it("rejects status-none in-review task with completed steps and no merge attempts", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const task = await store.createTask({
title: "status-none completed task with no merge attempts",
description: "test",
column: "todo",
});
await store.updateTask(task.id, {
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "done" },
],
});
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, { status: null, mergeRetries: 0 });
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-status-none-no-merge", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("not in a retryable state");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("in-review");
expect(updated?.mergeRetries).toBe(0);
});
it("rejects non-review task with status none", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const task = await store.createTask({
title: "status-none todo task",
description: "test",
column: "todo",
});
await store.updateTask(task.id, { status: null, steps: [{ name: "Step 0", status: "pending" }] });
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-status-none-todo", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("not in a retryable state");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("todo");
expect(updated?.status).toBeFalsy();
});
it("moves non-review failed task to todo and resets all retry counters", async () => { it("moves non-review failed task to todo and resets all retry counters", async () => {
const store = new TaskStore(tmpDir); const store = new TaskStore(tmpDir);
await store.init(); await store.init();

View File

@@ -24,7 +24,15 @@ vi.mock("node:child_process", async () => {
new Promise((resolve) => { new Promise((resolve) => {
execFn(cmd, opts, (_err: any, stdout: string, stderr: string) => resolve({ stdout, stderr })); execFn(cmd, opts, (_err: any, stdout: string, stderr: string) => resolve({ stdout, stderr }));
}); });
return { exec: execFn }; const execFileFn = vi.fn((_file: string, _args: any, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
if (typeof callback === "function") callback(null, "", "");
}) as any;
execFileFn[promisify.custom] = (file: string, args?: any, opts?: any) =>
new Promise((resolve) => {
execFileFn(file, args, opts, (_err: any, stdout: string, stderr: string) => resolve({ stdout, stderr }));
});
return { exec: execFn, execFile: execFileFn };
}); });
// Mock @fusion/core before importing the module under test // Mock @fusion/core before importing the module under test
@@ -39,9 +47,16 @@ vi.mock("@fusion/core", async (importActual) => {
done: "Done", done: "Done",
}; };
const TaskStoreMock = vi.fn(function () {});
const taskStoreMockImplementation = TaskStoreMock.mockImplementation.bind(TaskStoreMock);
TaskStoreMock.mockImplementation = ((impl: (...args: any[]) => unknown) =>
taskStoreMockImplementation(function (this: unknown, ...args: any[]) {
return impl(...args);
})) as typeof TaskStoreMock.mockImplementation;
return { return {
...actual, ...actual,
TaskStore: vi.fn(), TaskStore: TaskStoreMock,
COLUMNS, COLUMNS,
COLUMN_LABELS, COLUMN_LABELS,
runDeterministicDuplicateGuard: vi.fn(), runDeterministicDuplicateGuard: vi.fn(),
@@ -2379,13 +2394,18 @@ describe("runTaskRetry", () => {
mockMoveTask = vi.fn(); mockMoveTask = vi.fn();
mockLogEntry = vi.fn(); mockLogEntry = vi.fn();
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({ vi.mocked(resolveProject).mockResolvedValue({
init: vi.fn(), projectId: "proj_test",
projectPath: "/test",
projectName: "test",
isRegistered: true,
store: {
getTask: mockGetTask, getTask: mockGetTask,
updateTask: mockUpdateTask, updateTask: mockUpdateTask,
moveTask: mockMoveTask, moveTask: mockMoveTask,
logEntry: mockLogEntry, logEntry: mockLogEntry,
})); } as unknown as TaskStore,
});
}); });
afterEach(() => { afterEach(() => {
@@ -2514,6 +2534,115 @@ describe("runTaskRetry", () => {
expect(successLine![0]).toContain("FN-001"); expect(successLine![0]).toContain("FN-001");
expect(successLine![0]).toContain("todo"); expect(successLine![0]).toContain("todo");
}); });
it("retries stranded in-review task with status none and incomplete steps to todo preserving progress", async () => {
mockGetTask.mockResolvedValueOnce(makeTask({
id: "FN-001",
status: null,
column: "in-review",
mergeRetries: 4,
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "in-progress" },
{ name: "Step 2", status: "pending" },
],
}));
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(mockUpdateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({
status: null,
error: null,
}));
expect(mockUpdateTask.mock.calls[0][1]).not.toHaveProperty("mergeRetries");
expect(mockMoveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true });
expect(mockLogEntry).toHaveBeenCalledWith(
"FN-001",
"Retry requested from CLI (stranded in-review execution retry → todo, preserving progress)",
);
});
it("retries stranded zero-step in-review task with no merge attempts to todo", async () => {
mockGetTask.mockResolvedValueOnce(makeTask({
id: "FN-001",
status: null,
column: "in-review",
mergeRetries: 0,
steps: [],
}));
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(mockMoveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true });
expect(mockLogEntry).toHaveBeenCalledWith(
"FN-001",
"Retry requested from CLI (stranded in-review execution retry → todo, preserving progress)",
);
});
it("retries stranded in-review task with status none and prior merge attempts as merge retry", async () => {
mockGetTask.mockResolvedValueOnce(makeTask({
id: "FN-001",
status: null,
column: "in-review",
mergeRetries: 2,
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "done" },
],
}));
mockUpdateTask.mockResolvedValueOnce(makeTask({ id: "FN-001", status: undefined, error: undefined }));
mockLogEntry.mockResolvedValueOnce(makeTask({ id: "FN-001" }));
await runTaskRetry("FN-001");
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({
status: null,
error: null,
mergeRetries: 0,
}));
expect(mockMoveTask).not.toHaveBeenCalled();
expect(mockLogEntry).toHaveBeenCalledWith(
"FN-001",
"Retry requested from CLI (in-review merge retry, mergeRetries reset)",
);
});
it("rejects stranded in-review task with status none, completed steps, and no merge attempts", async () => {
mockGetTask.mockResolvedValueOnce(makeTask({
id: "FN-001",
status: null,
column: "in-review",
mergeRetries: 0,
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "done" },
],
}));
await expect(runTaskRetry("FN-001")).rejects.toThrow("Task FN-001 is not in a retryable state (status: none)");
expect(mockUpdateTask).not.toHaveBeenCalled();
expect(mockMoveTask).not.toHaveBeenCalled();
});
it("rejects non-review task with status none", async () => {
mockGetTask.mockResolvedValueOnce(makeTask({
id: "FN-001",
status: null,
column: "todo",
steps: [{ name: "Step 0", status: "pending" }],
}));
await expect(runTaskRetry("FN-001")).rejects.toThrow("Task FN-001 is not in a retryable state (status: none)");
expect(mockUpdateTask).not.toHaveBeenCalled();
expect(mockMoveTask).not.toHaveBeenCalled();
});
}); });
// --- Logs Tests --- // --- Logs Tests ---

View File

@@ -1027,13 +1027,70 @@ export async function runTaskRetry(id: string, projectName?: string) {
throw new Error(`Task ${id} not found`); throw new Error(`Task ${id} not found`);
} }
const isInReviewStatusNone =
task.column === "in-review" && (task.status === null || task.status === undefined);
const hasIncompleteSteps = task.steps.some(
(s: { status: string }) => s.status === "pending" || s.status === "in-progress",
);
// FN-4130 / PR #59 follow-up: zero-step review failures with no merge attempts
// (`mergeRetries ?? 0 === 0`) failed during execution, not merge finalization.
const isExecutionFailureInReview =
hasIncompleteSteps || (task.steps.length === 0 && (task.mergeRetries ?? 0) === 0);
const isInReviewExecutionStall = isInReviewStatusNone && isExecutionFailureInReview;
const isInReviewMergeRetryStall = isInReviewStatusNone && (task.mergeRetries ?? 0) > 0;
const isInReviewRetry =
task.column === "in-review" &&
(task.status === "failed" ||
task.status === "stuck-killed" ||
isInReviewExecutionStall ||
isInReviewMergeRetryStall);
// Validate task is in a retryable state // Validate task is in a retryable state
if (task.status !== 'failed' && task.status !== 'stuck-killed') { if (task.status !== 'failed' && task.status !== 'stuck-killed' && !isInReviewRetry) {
throw new Error(`Task ${id} is not in a retryable state (status: ${task.status || 'none'})`); throw new Error(`Task ${id} is not in a retryable state (status: ${task.status || 'none'})`);
} }
const autoPauseClearPatch = buildAutoPauseClearPatch(task); const autoPauseClearPatch = buildAutoPauseClearPatch(task);
const clearedDeadlockAutoPause = Object.keys(autoPauseClearPatch).length > 0; const clearedDeadlockAutoPause = Object.keys(autoPauseClearPatch).length > 0;
const retryLogSuffix = clearedDeadlockAutoPause ? ", cleared deadlock auto-pause" : "";
// In-review retry: distinguish between execution failures (incomplete steps)
// and merge failures (all steps done).
if (isInReviewRetry) {
if (isExecutionFailureInReview) {
await store.updateTask(id, {
status: null,
error: null,
...autoPauseClearPatch,
...buildManualRetryResetPatch(),
});
await store.logEntry(
id,
isInReviewExecutionStall
? `Retry requested from CLI (stranded in-review execution retry → todo, preserving progress${retryLogSuffix})`
: `Retry requested from CLI (execution failure in-review → todo, preserving progress${retryLogSuffix})`,
);
await store.moveTask(id, "todo", { preserveProgress: true });
console.log();
console.log(` ✓ Retried ${id} → todo (execution failure, preserving step progress)`);
console.log();
return;
}
await store.updateTask(id, {
status: null,
error: null,
...autoPauseClearPatch,
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
await store.logEntry(id, `Retry requested from CLI (in-review merge retry, mergeRetries reset${retryLogSuffix})`);
console.log();
console.log(` ✓ Retried ${id} → in-review (merge retry state cleared)`);
console.log();
return;
}
// Clear failure state and stale branch refs so retry can choose a fresh base. // Clear failure state and stale branch refs so retry can choose a fresh base.
await store.updateTask(id, { await store.updateTask(id, {

View File

@@ -1022,7 +1022,7 @@ export default function kbExtension(pi: ExtensionAPI) {
promptSnippet: "Retry a failed Fusion task (clears error, moves to todo or stays in in-review)", promptSnippet: "Retry a failed Fusion task (clears error, moves to todo or stays in in-review)",
promptGuidelines: [ promptGuidelines: [
"Use when a task has failed and needs to be retried", "Use when a task has failed and needs to be retried",
"Only tasks in 'failed' or 'stuck-killed' state can be retried", "Only tasks in 'failed' or 'stuck-killed' state, plus stranded in-review tasks with incomplete execution or prior merge attempts, can be retried",
"In-review tasks with incomplete steps (pending/in-progress) move to todo with preserveProgress so execution can resume", "In-review tasks with incomplete steps (pending/in-progress) move to todo with preserveProgress so execution can resume",
"In-review tasks with all steps done stay in in-review and reset merge retry state for auto-merge re-attempt", "In-review tasks with all steps done stay in in-review and reset merge retry state for auto-merge re-attempt",
"Tasks in other columns are moved to the todo column with error state cleared", "Tasks in other columns are moved to the todo column with error state cleared",
@@ -1046,8 +1046,26 @@ export default function kbExtension(pi: ExtensionAPI) {
}; };
} }
const isInReviewStatusNone =
task.column === "in-review" && (task.status === null || task.status === undefined);
const hasIncompleteSteps = task.steps.some(
(s: { status: string }) => s.status === "pending" || s.status === "in-progress",
);
// FN-4130 / PR #59 follow-up: zero-step review failures with no merge attempts
// (`mergeRetries ?? 0 === 0`) failed during execution, not merge finalization.
const isExecutionFailureInReview =
hasIncompleteSteps || (task.steps.length === 0 && (task.mergeRetries ?? 0) === 0);
const isInReviewExecutionStall = isInReviewStatusNone && isExecutionFailureInReview;
const isInReviewMergeRetryStall = isInReviewStatusNone && (task.mergeRetries ?? 0) > 0;
const isInReviewRetry =
task.column === "in-review" &&
(task.status === "failed" ||
task.status === "stuck-killed" ||
isInReviewExecutionStall ||
isInReviewMergeRetryStall);
// Validate task is in a retryable state // Validate task is in a retryable state
if (task.status !== 'failed' && task.status !== 'stuck-killed') { if (task.status !== 'failed' && task.status !== 'stuck-killed' && !isInReviewRetry) {
return { return {
content: [{ type: "text", text: `Task ${params.id} is not in a retryable state (status: ${task.status || 'none'})` }], content: [{ type: "text", text: `Task ${params.id} is not in a retryable state (status: ${task.status || 'none'})` }],
isError: true, isError: true,
@@ -1060,15 +1078,7 @@ export default function kbExtension(pi: ExtensionAPI) {
const retryLogSuffix = clearedDeadlockAutoPause ? ", cleared deadlock auto-pause" : ""; const retryLogSuffix = clearedDeadlockAutoPause ? ", cleared deadlock auto-pause" : "";
// In-review retry: distinguish between execution failures and merge failures. // In-review retry: distinguish between execution failures and merge failures.
if (task.column === 'in-review') { if (isInReviewRetry) {
const hasIncompleteSteps = task.steps.some(
(s: { status: string }) => s.status === "pending" || s.status === "in-progress",
);
// FN-4130 / PR #59 follow-up: zero-step review failures with no merge attempts
// (`mergeRetries ?? 0 === 0`) failed during execution, not merge finalization.
const isExecutionFailureInReview =
hasIncompleteSteps || (task.steps.length === 0 && (task.mergeRetries ?? 0) === 0);
if (isExecutionFailureInReview) { if (isExecutionFailureInReview) {
await store.updateTask(params.id, { await store.updateTask(params.id, {
status: null, status: null,
@@ -1076,7 +1086,12 @@ export default function kbExtension(pi: ExtensionAPI) {
...autoPauseClearPatch, ...autoPauseClearPatch,
...buildManualRetryResetPatch(), ...buildManualRetryResetPatch(),
}); });
await store.logEntry(params.id, `Retry requested via Fusion extension (execution failure in-review → todo, preserving progress${retryLogSuffix})`); await store.logEntry(
params.id,
isInReviewExecutionStall
? `Retry requested via Fusion extension (stranded in-review execution retry → todo, preserving progress${retryLogSuffix})`
: `Retry requested via Fusion extension (execution failure in-review → todo, preserving progress${retryLogSuffix})`,
);
await store.moveTask(params.id, "todo", { preserveProgress: true }); await store.moveTask(params.id, "todo", { preserveProgress: true });
return { return {
content: [{ type: "text", text: `Retried ${params.id} → todo (execution failure, preserving step progress)` }], content: [{ type: "text", text: `Retried ${params.id} → todo (execution failure, preserving step progress)` }],

View File

@@ -470,6 +470,140 @@ describe("POST /tasks/:id/retry", () => {
expect(updateCall).not.toHaveProperty("mergeRetries"); expect(updateCall).not.toHaveProperty("mergeRetries");
}); });
it("retries status-none in-review task with incomplete steps by moving to todo", async () => {
const reviewTask = {
...FAKE_TASK_DETAIL,
column: "in-review" as const,
status: null,
mergeRetries: 4,
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "in-progress" },
{ name: "Step 2", status: "pending" },
],
};
const movedTask = { ...reviewTask, column: "todo" as const, status: undefined };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(reviewTask);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(reviewTask);
(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: null,
error: null,
...buildManualRetryResetPatch(),
});
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true });
expect(store.logEntry).toHaveBeenCalledWith(
"KB-001",
"Retry requested from dashboard (stranded in-review execution retry → todo, preserving progress)",
);
});
it("retries status-none zero-step in-review task with no merge attempts by moving to todo", async () => {
const reviewTask = {
...FAKE_TASK_DETAIL,
column: "in-review" as const,
status: null,
steps: [],
mergeRetries: 0,
};
const movedTask = { ...reviewTask, column: "todo" as const, status: undefined };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(reviewTask);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(reviewTask);
(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.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true });
expect(store.logEntry).toHaveBeenCalledWith(
"KB-001",
"Retry requested from dashboard (stranded in-review execution retry → todo, preserving progress)",
);
});
it("retries status-none in-review task with prior merge attempts by staying in-review", async () => {
const reviewTask = {
...FAKE_TASK_DETAIL,
column: "in-review" as const,
status: null,
mergeRetries: 2,
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "done" },
],
};
(store.getTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(reviewTask)
.mockResolvedValueOnce({ ...reviewTask, status: undefined, mergeRetries: 0 });
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(reviewTask);
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: null,
error: null,
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith(
"KB-001",
"Retry requested from dashboard (in-review merge retry, mergeRetries reset)",
);
});
it("returns 400 for status-none in-review task with completed steps and no merge attempts", async () => {
const reviewTask = {
...FAKE_TASK_DETAIL,
column: "in-review" as const,
status: null,
mergeRetries: 0,
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "done" },
],
};
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(reviewTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("not in a retryable state");
expect(store.updateTask).not.toHaveBeenCalled();
expect(store.moveTask).not.toHaveBeenCalled();
});
it("returns 400 for non-review task with status none", async () => {
const task = {
...FAKE_TASK_DETAIL,
column: "todo" as const,
status: null,
steps: [{ name: "Step 0", status: "pending" }],
};
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("not in a retryable state");
expect(store.updateTask).not.toHaveBeenCalled();
expect(store.moveTask).not.toHaveBeenCalled();
});
it("preserves worktree/branch when retrying in-review task", async () => { it("preserves worktree/branch when retrying in-review task", async () => {
const reviewTask = { const reviewTask = {
...FAKE_TASK_DETAIL, ...FAKE_TASK_DETAIL,

View File

@@ -1541,10 +1541,24 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
task.status === "planning" || task.status === "planning" ||
task.status === "needs-replan" || task.status === "needs-replan" ||
(task.stuckKillCount ?? 0) > 0); (task.stuckKillCount ?? 0) > 0);
const isInReviewStatusNone =
task.column === "in-review" && (task.status === null || task.status === undefined);
const hasIncompleteSteps = task.steps.some(
(s: { status: string }) => s.status === "pending" || s.status === "in-progress",
);
// FN-4130 / PR #59 follow-up: zero-step review failures with no merge attempts
// (`mergeRetries ?? 0 === 0`) failed during execution, not merge finalization.
const isExecutionFailureInReview =
hasIncompleteSteps || (task.steps.length === 0 && (task.mergeRetries ?? 0) === 0);
const isInReviewExecutionStall = isInReviewStatusNone && isExecutionFailureInReview;
const isInReviewMergeRetryStall = isInReviewStatusNone && (task.mergeRetries ?? 0) > 0;
const isInReviewRetry = const isInReviewRetry =
task.column === "in-review" && task.column === "in-review" &&
(task.status === "failed" || task.status === "stuck-killed"); (task.status === "failed" ||
if (task.status !== "failed" && task.status !== "stuck-killed" && !retrySpecification) { task.status === "stuck-killed" ||
isInReviewExecutionStall ||
isInReviewMergeRetryStall);
if (task.status !== "failed" && task.status !== "stuck-killed" && !retrySpecification && !isInReviewRetry) {
throw badRequest(`Task is not in a retryable state (current status: ${task.status || 'none'})`); throw badRequest(`Task is not in a retryable state (current status: ${task.status || 'none'})`);
} }
@@ -1555,14 +1569,6 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
// In-review retry: distinguish between execution failures (incomplete steps) // In-review retry: distinguish between execution failures (incomplete steps)
// and merge failures (all steps done). // and merge failures (all steps done).
if (isInReviewRetry) { if (isInReviewRetry) {
const hasIncompleteSteps = task.steps.some(
(s: { status: string }) => s.status === "pending" || s.status === "in-progress",
);
// FN-4130 / PR #59 follow-up: zero-step review failures with no merge attempts
// (`mergeRetries ?? 0 === 0`) failed during execution, not merge finalization.
const isExecutionFailureInReview =
hasIncompleteSteps || (task.steps.length === 0 && (task.mergeRetries ?? 0) === 0);
if (isExecutionFailureInReview) { if (isExecutionFailureInReview) {
await scopedStore.updateTask(req.params.id, { await scopedStore.updateTask(req.params.id, {
status: null, status: null,
@@ -1572,7 +1578,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}); });
await scopedStore.logEntry( await scopedStore.logEntry(
req.params.id, req.params.id,
`Retry requested from dashboard (execution failure in-review → todo, preserving progress${retryLogSuffix})`, isInReviewExecutionStall
? `Retry requested from dashboard (stranded in-review execution retry → todo, preserving progress${retryLogSuffix})`
: `Retry requested from dashboard (execution failure in-review → todo, preserving progress${retryLogSuffix})`,
); );
const updated = await scopedStore.moveTask(req.params.id, "todo", { preserveProgress: true }); const updated = await scopedStore.moveTask(req.params.id, "todo", { preserveProgress: true });
res.json(updated); res.json(updated);