feat(FN-5347): reset manual retry counters across task retry surfaces

- Add a shared manual retry reset helper in core and export it for consumers
- Wire retry counter resets into CLI task retry, extension task retry, and dashboard task workflow routes
- Align retry reset behavior with task typing updates and remove superseded task-helper reset paths
- Add focused core/CLI/extension/dashboard tests plus docs and a changeset describing retry reset behavior
This commit is contained in:
Fusion (runfusion.ai)
2026-05-21 15:45:48 -07:00
committed by gsxdsm
parent a0aa6c8c5b
commit 216c32bfe5
13 changed files with 188 additions and 90 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Manual task retry now resets the full persisted retry-budget counter set (and `nextRecoveryAt`) across CLI, pi extension, and dashboard retry surfaces, so retry badges/details no longer stay inflated after a user-triggered fresh attempt.

View File

@@ -232,6 +232,7 @@ Fusion task columns:
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 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.
- Manual retry now clears the full persisted retry-budget counter set (`stuckKillCount`, `recoveryRetryCount`, `taskDoneRetryCount`, `worktreeSessionRetryCount`, `workflowStepRetries`, `verificationFailureCount`, `postReviewFixCount`, `mergeConflictBounceCount`, `branchConflictRecoveryCount`, `reviewerContextRetryCount`, `reviewerFallbackRetryCount`, `completionHandoffLimboRecoveryCount`, `mergeAuditBounceCount`) plus `nextRecoveryAt`; merge retry counters clear only on merge-failure/generic retry paths. `retrySummary` is recomputed from persisted counters at read time, so manual retry resets the dashboard retry badge/details back to zero immediately.
- 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`.

View File

@@ -27,7 +27,7 @@ vi.mock("../commands/task.js", () => ({
}));
import kbExtension from "../extension.js";
import { TaskStore, AgentStore, RESEARCH_RUN_STATUSES } from "@fusion/core";
import { TaskStore, AgentStore, MANUAL_RETRY_RESET_COUNTER_KEYS, RESEARCH_RUN_STATUSES } from "@fusion/core";
import { isGhAvailable, isGhAuthenticated, runGhJsonAsync } from "@fusion/core/gh-cli";
import { runTaskPlan } from "../commands/task.js";
@@ -2331,6 +2331,20 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
});
describe("fn_task_retry", () => {
const nonZeroRetryCounters = Object.fromEntries(
MANUAL_RETRY_RESET_COUNTER_KEYS.map((key, index) => [key, index + 1]),
);
const expectRetryCountersReset = (task: Awaited<ReturnType<TaskStore["getTask"]>>) => {
expect(task).toBeTruthy();
if (!task) return;
for (const key of MANUAL_RETRY_RESET_COUNTER_KEYS) {
expect(task[key] ?? 0).toBe(0);
}
expect(task.nextRecoveryAt ?? null).toBeNull();
expect(task.retrySummary?.total ?? 0).toBe(0);
};
it("moves execution-failed in-review task (incomplete steps) to todo preserving progress", async () => {
const store = new TaskStore(tmpDir);
await store.init();
@@ -2352,9 +2366,9 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
await store.updateTask(task.id, {
status: "failed",
error: "429 rate limited",
taskDoneRetryCount: 2,
workflowStepRetries: 3,
stuckKillCount: 4,
mergeRetries: 9,
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
...nonZeroRetryCounters,
});
const retryTool = api.tools.get("fn_task_retry")!;
@@ -2368,9 +2382,8 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expect(updated?.steps[1].status).toBe("in-progress");
expect(updated?.taskDoneRetryCount).toBe(0);
expect(updated?.workflowStepRetries).toBe(0);
expect(updated?.stuckKillCount).toBe(0);
expectRetryCountersReset(updated);
expect(updated?.mergeRetries).toBe(9);
});
it("moves zero-step execution-failed in-review task to todo and clears failure state", async () => {
@@ -2423,9 +2436,8 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
status: "failed",
error: "merge conflict",
mergeRetries: 3,
taskDoneRetryCount: 5,
workflowStepRetries: 4,
stuckKillCount: 7,
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
...nonZeroRetryCounters,
});
const retryTool = api.tools.get("fn_task_retry")!;
@@ -2438,10 +2450,8 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(updated?.column).toBe("in-review");
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expectRetryCountersReset(updated);
expect(updated?.mergeRetries).toBe(0);
expect(updated?.taskDoneRetryCount).toBe(0);
expect(updated?.workflowStepRetries).toBe(0);
expect(updated?.stuckKillCount).toBe(0);
});
it("keeps zero-step merge-failed in-review task with prior merge attempts in-review and resets merge state", async () => {
@@ -2457,7 +2467,14 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
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: [] });
await store.updateTask(task.id, {
status: "failed",
error: "merge conflict",
mergeRetries: 2,
steps: [],
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
...nonZeroRetryCounters,
});
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-zero-step-merge", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
@@ -2470,6 +2487,39 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expect(updated?.steps).toEqual([]);
expectRetryCountersReset(updated);
expect(updated?.mergeRetries).toBe(0);
});
it("moves non-review failed task to todo and resets all retry counters", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const task = await store.createTask({
title: "failed in-progress task",
description: "test",
column: "todo",
});
await store.moveTask(task.id, "in-progress");
await store.updateTask(task.id, {
status: "failed",
error: "verification failed",
mergeRetries: 8,
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
...nonZeroRetryCounters,
});
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-generic", { 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();
expectRetryCountersReset(updated);
expect(updated?.mergeRetries).toBe(0);
});
});

View File

@@ -2413,11 +2413,21 @@ describe("runTaskRetry", () => {
branch: null,
baseBranch: null,
baseCommitSha: null,
recoveryRetryCount: null,
nextRecoveryAt: null,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
stuckKillCount: 0,
recoveryRetryCount: 0,
taskDoneRetryCount: 0,
worktreeSessionRetryCount: 0,
workflowStepRetries: 0,
verificationFailureCount: 0,
postReviewFixCount: 0,
mergeConflictBounceCount: 0,
branchConflictRecoveryCount: 0,
reviewerContextRetryCount: 0,
reviewerFallbackRetryCount: 0,
completionHandoffLimboRecoveryCount: 0,
mergeAuditBounceCount: 0,
mergeRetries: 0,
});
expect(mockMoveTask).toHaveBeenCalledWith("FN-001", "todo");
expect(mockLogEntry).toHaveBeenCalledWith("FN-001", "Retry requested from CLI", "Task reset to todo for retry");
@@ -2476,11 +2486,21 @@ describe("runTaskRetry", () => {
branch: null,
baseBranch: null,
baseCommitSha: null,
recoveryRetryCount: null,
nextRecoveryAt: null,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
stuckKillCount: 0,
recoveryRetryCount: 0,
taskDoneRetryCount: 0,
worktreeSessionRetryCount: 0,
workflowStepRetries: 0,
verificationFailureCount: 0,
postReviewFixCount: 0,
mergeConflictBounceCount: 0,
branchConflictRecoveryCount: 0,
reviewerContextRetryCount: 0,
reviewerFallbackRetryCount: 0,
completionHandoffLimboRecoveryCount: 0,
mergeAuditBounceCount: 0,
mergeRetries: 0,
});
expect(mockMoveTask).toHaveBeenCalledWith("FN-001", "todo");
expect(mockLogEntry).toHaveBeenCalledWith("FN-001", "Retry requested from CLI", "Task reset to todo for retry");

View File

@@ -1009,9 +1009,7 @@ export async function runTaskRetry(id: string, projectName?: string) {
branch: null,
baseBranch: null,
baseCommitSha: null,
recoveryRetryCount: null,
nextRecoveryAt: null,
...buildManualRetryResetPatch(),
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
// Move to todo column

View File

@@ -1016,8 +1016,7 @@ export default function kbExtension(pi: ExtensionAPI) {
await store.updateTask(params.id, {
status: null,
error: null,
...buildManualRetryResetPatch(),
mergeRetries: 0,
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
await store.logEntry(params.id, "Retry requested via Fusion extension (in-review merge retry, mergeRetries reset)");
return {
@@ -1030,7 +1029,7 @@ export default function kbExtension(pi: ExtensionAPI) {
await store.updateTask(params.id, {
status: null,
error: null,
...buildManualRetryResetPatch(),
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
// Move to todo column

View File

@@ -0,0 +1,38 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { buildManualRetryResetPatch, MANUAL_RETRY_RESET_COUNTER_KEYS } from "../manual-retry-reset.js";
const RETRY_SUMMARY_COUNTER_REGEX = /toCount\(task\.(\w+)\)/g;
describe("buildManualRetryResetPatch", () => {
it("resets all manual retry counters to zero", () => {
const patch = buildManualRetryResetPatch();
for (const key of MANUAL_RETRY_RESET_COUNTER_KEYS) {
expect(patch[key]).toBe(0);
}
});
it("includes all retry-summary counters in the reset key list", () => {
const retrySummarySource = readFileSync(new URL("../retry-summary.ts", import.meta.url), "utf-8");
const retrySummaryKeys = new Set<string>();
let match: RegExpExecArray | null = RETRY_SUMMARY_COUNTER_REGEX.exec(retrySummarySource);
while (match) {
retrySummaryKeys.add(match[1]);
match = RETRY_SUMMARY_COUNTER_REGEX.exec(retrySummarySource);
}
for (const key of retrySummaryKeys) {
expect(MANUAL_RETRY_RESET_COUNTER_KEYS).toContain(key);
}
});
it("sets mergeRetries only when requested", () => {
expect(buildManualRetryResetPatch()).not.toHaveProperty("mergeRetries");
expect(buildManualRetryResetPatch({ resetMergeRetries: true })).toMatchObject({ mergeRetries: 0 });
});
it("clears nextRecoveryAt", () => {
expect(buildManualRetryResetPatch()).toMatchObject({ nextRecoveryAt: null });
});
});

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildManualRetryResetPatch, getPrimaryPrInfo } from "../task-helpers.js";
import { getPrimaryPrInfo } from "../task-helpers.js";
describe("getPrimaryPrInfo", () => {
it("returns prInfo when only legacy field is set", () => {
const prInfo = { number: 1 } as any;
@@ -23,12 +24,3 @@ describe("getPrimaryPrInfo", () => {
});
});
describe("buildManualRetryResetPatch", () => {
it("resets only manual retry counters", () => {
expect(buildManualRetryResetPatch()).toEqual({
taskDoneRetryCount: 0,
workflowStepRetries: 0,
stuckKillCount: 0,
});
});
});

View File

@@ -201,7 +201,8 @@ export {
hasTitleIdDrift,
normalizeTitleForTaskId,
} from "./task-title-id-drift.js";
export { getPrimaryPrInfo, buildManualRetryResetPatch } from "./task-helpers.js";
export { getPrimaryPrInfo } from "./task-helpers.js";
export { MANUAL_RETRY_RESET_COUNTER_KEYS, buildManualRetryResetPatch } from "./manual-retry-reset.js";
export type {
TaskIdIntegrityAnomaly,
TaskIdIntegrityAnomalyKind,

View File

@@ -0,0 +1,33 @@
import type { Task } from "./types.js";
export const MANUAL_RETRY_RESET_COUNTER_KEYS = [
"stuckKillCount",
"recoveryRetryCount",
"taskDoneRetryCount",
"worktreeSessionRetryCount",
"workflowStepRetries",
"verificationFailureCount",
"postReviewFixCount",
"mergeConflictBounceCount",
"branchConflictRecoveryCount",
"reviewerContextRetryCount",
"reviewerFallbackRetryCount",
"completionHandoffLimboRecoveryCount",
"mergeAuditBounceCount",
] as const satisfies ReadonlyArray<keyof Task>;
export function buildManualRetryResetPatch(options?: { resetMergeRetries?: boolean }): Partial<Task> {
const patch: Partial<Task> = {
nextRecoveryAt: null as unknown as Task["nextRecoveryAt"],
};
for (const key of MANUAL_RETRY_RESET_COUNTER_KEYS) {
patch[key] = 0;
}
if (options?.resetMergeRetries) {
patch.mergeRetries = 0;
}
return patch;
}

View File

@@ -3,11 +3,3 @@ import type { PrInfo, Task } from "./types.js";
export function getPrimaryPrInfo(task: Pick<Task, "prInfo" | "prInfos">): PrInfo | undefined {
return task.prInfos?.[0] ?? task.prInfo;
}
export function buildManualRetryResetPatch(): Pick<Task, "taskDoneRetryCount" | "workflowStepRetries" | "stuckKillCount"> {
return {
taskDoneRetryCount: 0,
workflowStepRetries: 0,
stuckKillCount: 0,
};
}

View File

@@ -153,7 +153,7 @@ vi.mock("@fusion/engine", async () => {
});
});
import { AgentStore, Database, RoutineStore, TaskStore as CoreTaskStore, isGhAvailable, isGhAuthenticated } from "@fusion/core";
import { AgentStore, Database, RoutineStore, TaskStore as CoreTaskStore, buildManualRetryResetPatch, isGhAvailable, isGhAuthenticated } from "@fusion/core";
import { createFnAgent } from "@fusion/engine";
const mockIsGhAvailable = vi.mocked(isGhAvailable);
@@ -341,11 +341,7 @@ describe("POST /tasks/:id/retry", () => {
branch: null,
baseBranch: null,
baseCommitSha: null,
stuckKillCount: 0,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
recoveryRetryCount: null,
nextRecoveryAt: null,
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
});
@@ -381,11 +377,7 @@ describe("POST /tasks/:id/retry", () => {
branch: null,
baseBranch: null,
baseCommitSha: null,
stuckKillCount: 0,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
recoveryRetryCount: null,
nextRecoveryAt: null,
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
});
@@ -409,11 +401,7 @@ describe("POST /tasks/:id/retry", () => {
branch: null,
baseBranch: null,
baseCommitSha: null,
stuckKillCount: 0,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
recoveryRetryCount: null,
nextRecoveryAt: null,
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Retry requested from dashboard (stuck kill budget reset)");
@@ -440,9 +428,7 @@ describe("POST /tasks/:id/retry", () => {
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
status: null,
error: null,
stuckKillCount: 0,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
...buildManualRetryResetPatch(),
});
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true });
expect(store.logEntry).toHaveBeenCalledWith(
@@ -473,9 +459,7 @@ describe("POST /tasks/:id/retry", () => {
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
status: null,
error: null,
stuckKillCount: 0,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
...buildManualRetryResetPatch(),
});
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true });
expect(store.logEntry).toHaveBeenCalledWith(
@@ -510,8 +494,8 @@ describe("POST /tasks/:id/retry", () => {
expect(updateCall).not.toHaveProperty("branch");
expect(updateCall).not.toHaveProperty("baseBranch");
expect(updateCall).not.toHaveProperty("baseCommitSha");
expect(updateCall).not.toHaveProperty("recoveryRetryCount");
expect(updateCall).not.toHaveProperty("nextRecoveryAt");
expect(updateCall.recoveryRetryCount).toBe(0);
expect(updateCall.nextRecoveryAt).toBeNull();
});
it("retries execution-failed in-review task by moving to todo with progress preserved", async () => {
@@ -538,9 +522,7 @@ describe("POST /tasks/:id/retry", () => {
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
status: null,
error: null,
stuckKillCount: 0,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
...buildManualRetryResetPatch(),
});
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true });
expect(store.logEntry).toHaveBeenCalledWith(
@@ -573,10 +555,7 @@ describe("POST /tasks/:id/retry", () => {
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
status: null,
error: null,
stuckKillCount: 0,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
mergeRetries: 0,
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith(
@@ -606,10 +585,7 @@ describe("POST /tasks/:id/retry", () => {
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
status: null,
error: null,
stuckKillCount: 0,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
mergeRetries: 0,
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith(
@@ -688,11 +664,7 @@ describe("POST /tasks/:id/retry", () => {
branch: null,
baseBranch: null,
baseCommitSha: null,
stuckKillCount: 0,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
recoveryRetryCount: null,
nextRecoveryAt: null,
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
expect(store.moveTask).not.toHaveBeenCalled();
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false);

View File

@@ -1059,8 +1059,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
await scopedStore.updateTask(req.params.id, {
status: null,
error: null,
...buildManualRetryResetPatch(),
mergeRetries: 0,
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
await scopedStore.logEntry(req.params.id, "Retry requested from dashboard (in-review merge retry, mergeRetries reset)");
const updated = await scopedStore.getTask(req.params.id);
@@ -1075,9 +1074,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
branch: null,
baseBranch: null,
baseCommitSha: null,
recoveryRetryCount: null,
nextRecoveryAt: null,
...buildManualRetryResetPatch(),
...buildManualRetryResetPatch({ resetMergeRetries: true }),
});
if (retrySpecification) {