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

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