feat(FN-4130): classify zero-step tasks as retryable to todo on failure
Adds zero-step retry classification to the task workflow routes and pi extension, with test coverage in both packages and documentation updates. Fusion-Task-Id: FN-4130
This commit is contained in:
5
.changeset/fn-4130-zero-step-retry-classification.md
Normal file
5
.changeset/fn-4130-zero-step-retry-classification.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix zero-step in-review retry classification to route execution-side failures correctly, closing the remaining gap from PR #59 and crediting HarryCordewener's original workstream.
|
||||
@@ -93,7 +93,7 @@ Fusion task columns:
|
||||
3. **in-progress** — executor active in isolated worktree
|
||||
4. **in-review** — implementation complete; awaiting finalization
|
||||
- If merge/finalization hits a terminal error, tasks can remain in `in-review` with `status: "failed"` for explicit follow-up. This state is intentionally preserved by recovery (not auto-bounced to `todo`).
|
||||
- Retry behavior splits by step completion: `in-review` tasks with incomplete steps (`pending`/`in-progress`) are treated as execution failures and retried back to `todo` with `preserveProgress: true`; `in-review` tasks with all steps `done` are treated as merge/finalization failures and stay in `in-review` with merge retry state reset.
|
||||
- Retry behavior splits by execution-vs-merge signals: `in-review` tasks with incomplete steps (`pending`/`in-progress`) are treated as execution failures and retried back to `todo` with `preserveProgress: true`; zero-step `in-review` tasks use `mergeRetries` as the tie-breaker (`mergeRetries === 0` or undefined → execution failure path back to `todo`, `mergeRetries > 0` → merge/finalization retry in `in-review` with merge retry state reset); tasks whose steps are all terminal (`done`/`skipped`/`failed`) also stay on the merge/finalization retry path.
|
||||
- Persisted executor session state is resumed only when it still matches the task's current worktree context. If a retry fails with `Refusing to start coding agent in missing worktree: ...` and the persisted session points at stale worktree metadata, recovery clears stale session pointers and retries fresh so review retries do not reopen deleted worktree paths.
|
||||
- Merge-confirmed tasks still respect `getTaskMergeBlocker()` before the final `in-review` → `done` move. If merge is confirmed but a blocker remains (for example, incomplete steps), Fusion parks the task in `in-review` with `status: "failed"` and an explicit blocker error instead of retry-looping auto-finalization.
|
||||
- Self-healing can still auto-finalize retry-exhausted failed review tasks when it can prove their branch content already landed on the merge target, so already-merged work does not deadlock in `in-review`.
|
||||
|
||||
@@ -1782,6 +1782,35 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
|
||||
expect(updated?.steps[1].status).toBe("in-progress");
|
||||
});
|
||||
|
||||
it("moves zero-step execution-failed in-review task to todo and clears failure state", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
|
||||
const task = await store.createTask({
|
||||
title: "zero-step execution-failed task",
|
||||
description: "test",
|
||||
column: "todo",
|
||||
});
|
||||
await writeFile(join(tmpDir, ".fusion", "tasks", task.id, "PROMPT.md"), "# zero-step execution-failed 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: "failed", error: "executor crashed", mergeRetries: 0, steps: [] });
|
||||
|
||||
const retryTool = api.tools.get("fn_task_retry")!;
|
||||
const result = await retryTool.execute("retry-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 merge-failed in-review task (all steps done) in in-review and resets merge state", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
@@ -1813,6 +1842,35 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
|
||||
expect(updated?.error).toBeFalsy();
|
||||
expect(updated?.mergeRetries).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps zero-step merge-failed 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: "zero-step merge-failed task",
|
||||
description: "test",
|
||||
column: "todo",
|
||||
});
|
||||
await writeFile(join(tmpDir, ".fusion", "tasks", task.id, "PROMPT.md"), "# zero-step merge-failed 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: "failed", error: "merge conflict", mergeRetries: 2, steps: [] });
|
||||
|
||||
const retryTool = api.tools.get("fn_task_retry")!;
|
||||
const result = await retryTool.execute("retry-zero-step-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?.steps).toEqual([]);
|
||||
expect(updated?.mergeRetries).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fn_list_agents", () => {
|
||||
|
||||
@@ -884,11 +884,15 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
|
||||
// In-review retry: distinguish between execution failures and merge failures.
|
||||
if (task.column === 'in-review') {
|
||||
const hasIncompleteSteps =
|
||||
task.steps.length > 0 &&
|
||||
task.steps.some((s: { status: string }) => s.status === "pending" || s.status === "in-progress");
|
||||
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 (hasIncompleteSteps) {
|
||||
if (isExecutionFailureInReview) {
|
||||
await store.updateTask(params.id, { status: null, error: null, stuckKillCount: 0 });
|
||||
await store.logEntry(params.id, "Retry requested via Fusion extension (execution failure in-review → todo, preserving progress)");
|
||||
await store.moveTask(params.id, "todo", { preserveProgress: true });
|
||||
|
||||
@@ -410,12 +410,18 @@ describe("POST /tasks/:id/retry", () => {
|
||||
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Retry requested from dashboard (stuck kill budget reset)");
|
||||
});
|
||||
|
||||
it("retries a failed in-review task without moving columns", async () => {
|
||||
const reviewTask = { ...FAKE_TASK_DETAIL, column: "in-review", status: "failed" };
|
||||
(store.getTask as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(reviewTask)
|
||||
.mockResolvedValueOnce(reviewTask);
|
||||
it("retries a failed zero-step in-review task with no merge attempts by moving to todo", async () => {
|
||||
const reviewTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
column: "in-review",
|
||||
status: "failed",
|
||||
steps: [],
|
||||
mergeRetries: 0,
|
||||
};
|
||||
const movedTask = { ...reviewTask, column: "todo", 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",
|
||||
@@ -426,18 +432,27 @@ describe("POST /tasks/:id/retry", () => {
|
||||
status: null,
|
||||
error: null,
|
||||
stuckKillCount: 0,
|
||||
mergeRetries: 0,
|
||||
});
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Retry requested from dashboard (in-review merge retry, mergeRetries reset)");
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true });
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"KB-001",
|
||||
"Retry requested from dashboard (execution failure in-review → todo, preserving progress)",
|
||||
);
|
||||
const updateCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
expect(updateCall).not.toHaveProperty("mergeRetries");
|
||||
});
|
||||
|
||||
it("retries a stuck-killed in-review task without moving columns", async () => {
|
||||
const reviewTask = { ...FAKE_TASK_DETAIL, column: "in-review", status: "stuck-killed" };
|
||||
(store.getTask as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(reviewTask)
|
||||
.mockResolvedValueOnce(reviewTask);
|
||||
it("retries a stuck-killed zero-step in-review task with no merge attempts by moving to todo", async () => {
|
||||
const reviewTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
column: "in-review",
|
||||
status: "stuck-killed",
|
||||
steps: [],
|
||||
};
|
||||
const movedTask = { ...reviewTask, column: "todo", 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",
|
||||
@@ -448,10 +463,14 @@ describe("POST /tasks/:id/retry", () => {
|
||||
status: null,
|
||||
error: null,
|
||||
stuckKillCount: 0,
|
||||
mergeRetries: 0,
|
||||
});
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Retry requested from dashboard (in-review merge retry, mergeRetries reset)");
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true });
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"KB-001",
|
||||
"Retry requested from dashboard (execution failure in-review → todo, preserving progress)",
|
||||
);
|
||||
const updateCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
expect(updateCall).not.toHaveProperty("mergeRetries");
|
||||
});
|
||||
|
||||
it("preserves worktree/branch when retrying in-review task", async () => {
|
||||
@@ -549,6 +568,37 @@ describe("POST /tasks/:id/retry", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("retries zero-step merge-failed in-review task with prior merge attempts by staying in-review", async () => {
|
||||
const mergeFailedTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
column: "in-review" as const,
|
||||
status: "failed",
|
||||
steps: [],
|
||||
mergeRetries: 2,
|
||||
};
|
||||
(store.getTask as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(mergeFailedTask)
|
||||
.mockResolvedValueOnce(mergeFailedTask);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(mergeFailedTask);
|
||||
|
||||
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,
|
||||
stuckKillCount: 0,
|
||||
mergeRetries: 0,
|
||||
});
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"KB-001",
|
||||
"Retry requested from dashboard (in-review merge retry, mergeRetries reset)",
|
||||
);
|
||||
});
|
||||
|
||||
it("retries stuck-killed in-review task with incomplete steps moves to todo", async () => {
|
||||
const stuckTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
|
||||
@@ -450,11 +450,15 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
// In-review retry: distinguish between execution failures (incomplete steps)
|
||||
// and merge failures (all steps done).
|
||||
if (isInReviewRetry) {
|
||||
const hasIncompleteSteps =
|
||||
task.steps.length > 0 &&
|
||||
task.steps.some((s: { status: string }) => s.status === "pending" || s.status === "in-progress");
|
||||
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 (hasIncompleteSteps) {
|
||||
if (isExecutionFailureInReview) {
|
||||
await scopedStore.updateTask(req.params.id, {
|
||||
status: null,
|
||||
error: null,
|
||||
|
||||
Reference in New Issue
Block a user