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 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`). - 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. - 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. - 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. - 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`. - 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 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 { isGhAvailable, isGhAuthenticated, runGhJsonAsync } from "@fusion/core/gh-cli";
import { runTaskPlan } from "../commands/task.js"; import { runTaskPlan } from "../commands/task.js";
@@ -2331,6 +2331,20 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
}); });
describe("fn_task_retry", () => { 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 () => { it("moves execution-failed in-review task (incomplete steps) to todo preserving progress", async () => {
const store = new TaskStore(tmpDir); const store = new TaskStore(tmpDir);
await store.init(); await store.init();
@@ -2352,9 +2366,9 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
await store.updateTask(task.id, { await store.updateTask(task.id, {
status: "failed", status: "failed",
error: "429 rate limited", error: "429 rate limited",
taskDoneRetryCount: 2, mergeRetries: 9,
workflowStepRetries: 3, nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
stuckKillCount: 4, ...nonZeroRetryCounters,
}); });
const retryTool = api.tools.get("fn_task_retry")!; 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?.status).toBeFalsy();
expect(updated?.error).toBeFalsy(); expect(updated?.error).toBeFalsy();
expect(updated?.steps[1].status).toBe("in-progress"); expect(updated?.steps[1].status).toBe("in-progress");
expect(updated?.taskDoneRetryCount).toBe(0); expectRetryCountersReset(updated);
expect(updated?.workflowStepRetries).toBe(0); expect(updated?.mergeRetries).toBe(9);
expect(updated?.stuckKillCount).toBe(0);
}); });
it("moves zero-step execution-failed in-review task to todo and clears failure state", async () => { 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", status: "failed",
error: "merge conflict", error: "merge conflict",
mergeRetries: 3, mergeRetries: 3,
taskDoneRetryCount: 5, nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
workflowStepRetries: 4, ...nonZeroRetryCounters,
stuckKillCount: 7,
}); });
const retryTool = api.tools.get("fn_task_retry")!; 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?.column).toBe("in-review");
expect(updated?.status).toBeFalsy(); expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy(); expect(updated?.error).toBeFalsy();
expectRetryCountersReset(updated);
expect(updated?.mergeRetries).toBe(0); 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 () => { 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.updateTask(task.id, { steps: [] });
await store.moveTask(task.id, "in-progress"); await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review"); 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 retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-zero-step-merge", { id: task.id }, undefined, undefined, makeCtx(tmpDir)); 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?.status).toBeFalsy();
expect(updated?.error).toBeFalsy(); expect(updated?.error).toBeFalsy();
expect(updated?.steps).toEqual([]); 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); expect(updated?.mergeRetries).toBe(0);
}); });
}); });

View File

@@ -2413,11 +2413,21 @@ describe("runTaskRetry", () => {
branch: null, branch: null,
baseBranch: null, baseBranch: null,
baseCommitSha: null, baseCommitSha: null,
recoveryRetryCount: null,
nextRecoveryAt: null, nextRecoveryAt: null,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
stuckKillCount: 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(mockMoveTask).toHaveBeenCalledWith("FN-001", "todo");
expect(mockLogEntry).toHaveBeenCalledWith("FN-001", "Retry requested from CLI", "Task reset to todo for retry"); expect(mockLogEntry).toHaveBeenCalledWith("FN-001", "Retry requested from CLI", "Task reset to todo for retry");
@@ -2476,11 +2486,21 @@ describe("runTaskRetry", () => {
branch: null, branch: null,
baseBranch: null, baseBranch: null,
baseCommitSha: null, baseCommitSha: null,
recoveryRetryCount: null,
nextRecoveryAt: null, nextRecoveryAt: null,
taskDoneRetryCount: 0,
workflowStepRetries: 0,
stuckKillCount: 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(mockMoveTask).toHaveBeenCalledWith("FN-001", "todo");
expect(mockLogEntry).toHaveBeenCalledWith("FN-001", "Retry requested from CLI", "Task reset to todo for retry"); 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, branch: null,
baseBranch: null, baseBranch: null,
baseCommitSha: null, baseCommitSha: null,
recoveryRetryCount: null, ...buildManualRetryResetPatch({ resetMergeRetries: true }),
nextRecoveryAt: null,
...buildManualRetryResetPatch(),
}); });
// Move to todo column // Move to todo column

View File

@@ -1016,8 +1016,7 @@ export default function kbExtension(pi: ExtensionAPI) {
await store.updateTask(params.id, { await store.updateTask(params.id, {
status: null, status: null,
error: null, error: null,
...buildManualRetryResetPatch(), ...buildManualRetryResetPatch({ resetMergeRetries: true }),
mergeRetries: 0,
}); });
await store.logEntry(params.id, "Retry requested via Fusion extension (in-review merge retry, mergeRetries reset)"); await store.logEntry(params.id, "Retry requested via Fusion extension (in-review merge retry, mergeRetries reset)");
return { return {
@@ -1030,7 +1029,7 @@ export default function kbExtension(pi: ExtensionAPI) {
await store.updateTask(params.id, { await store.updateTask(params.id, {
status: null, status: null,
error: null, error: null,
...buildManualRetryResetPatch(), ...buildManualRetryResetPatch({ resetMergeRetries: true }),
}); });
// Move to todo column // 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 { describe, expect, it } from "vitest";
import { buildManualRetryResetPatch, getPrimaryPrInfo } from "../task-helpers.js"; import { getPrimaryPrInfo } from "../task-helpers.js";
describe("getPrimaryPrInfo", () => { describe("getPrimaryPrInfo", () => {
it("returns prInfo when only legacy field is set", () => { it("returns prInfo when only legacy field is set", () => {
const prInfo = { number: 1 } as any; 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, hasTitleIdDrift,
normalizeTitleForTaskId, normalizeTitleForTaskId,
} from "./task-title-id-drift.js"; } 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 { export type {
TaskIdIntegrityAnomaly, TaskIdIntegrityAnomaly,
TaskIdIntegrityAnomalyKind, 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 { export function getPrimaryPrInfo(task: Pick<Task, "prInfo" | "prInfos">): PrInfo | undefined {
return task.prInfos?.[0] ?? task.prInfo; 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"; import { createFnAgent } from "@fusion/engine";
const mockIsGhAvailable = vi.mocked(isGhAvailable); const mockIsGhAvailable = vi.mocked(isGhAvailable);
@@ -341,11 +341,7 @@ describe("POST /tasks/:id/retry", () => {
branch: null, branch: null,
baseBranch: null, baseBranch: null,
baseCommitSha: null, baseCommitSha: null,
stuckKillCount: 0, ...buildManualRetryResetPatch({ resetMergeRetries: true }),
taskDoneRetryCount: 0,
workflowStepRetries: 0,
recoveryRetryCount: null,
nextRecoveryAt: null,
}); });
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo"); expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
}); });
@@ -381,11 +377,7 @@ describe("POST /tasks/:id/retry", () => {
branch: null, branch: null,
baseBranch: null, baseBranch: null,
baseCommitSha: null, baseCommitSha: null,
stuckKillCount: 0, ...buildManualRetryResetPatch({ resetMergeRetries: true }),
taskDoneRetryCount: 0,
workflowStepRetries: 0,
recoveryRetryCount: null,
nextRecoveryAt: null,
}); });
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo"); expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
}); });
@@ -409,11 +401,7 @@ describe("POST /tasks/:id/retry", () => {
branch: null, branch: null,
baseBranch: null, baseBranch: null,
baseCommitSha: null, baseCommitSha: null,
stuckKillCount: 0, ...buildManualRetryResetPatch({ resetMergeRetries: true }),
taskDoneRetryCount: 0,
workflowStepRetries: 0,
recoveryRetryCount: null,
nextRecoveryAt: null,
}); });
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo"); expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Retry requested from dashboard (stuck kill budget reset)"); 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", { expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
status: null, status: null,
error: null, error: null,
stuckKillCount: 0, ...buildManualRetryResetPatch(),
taskDoneRetryCount: 0,
workflowStepRetries: 0,
}); });
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true }); expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true });
expect(store.logEntry).toHaveBeenCalledWith( expect(store.logEntry).toHaveBeenCalledWith(
@@ -473,9 +459,7 @@ describe("POST /tasks/:id/retry", () => {
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
status: null, status: null,
error: null, error: null,
stuckKillCount: 0, ...buildManualRetryResetPatch(),
taskDoneRetryCount: 0,
workflowStepRetries: 0,
}); });
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true }); expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true });
expect(store.logEntry).toHaveBeenCalledWith( expect(store.logEntry).toHaveBeenCalledWith(
@@ -510,8 +494,8 @@ describe("POST /tasks/:id/retry", () => {
expect(updateCall).not.toHaveProperty("branch"); expect(updateCall).not.toHaveProperty("branch");
expect(updateCall).not.toHaveProperty("baseBranch"); expect(updateCall).not.toHaveProperty("baseBranch");
expect(updateCall).not.toHaveProperty("baseCommitSha"); expect(updateCall).not.toHaveProperty("baseCommitSha");
expect(updateCall).not.toHaveProperty("recoveryRetryCount"); expect(updateCall.recoveryRetryCount).toBe(0);
expect(updateCall).not.toHaveProperty("nextRecoveryAt"); expect(updateCall.nextRecoveryAt).toBeNull();
}); });
it("retries execution-failed in-review task by moving to todo with progress preserved", async () => { 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", { expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
status: null, status: null,
error: null, error: null,
stuckKillCount: 0, ...buildManualRetryResetPatch(),
taskDoneRetryCount: 0,
workflowStepRetries: 0,
}); });
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true }); expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true });
expect(store.logEntry).toHaveBeenCalledWith( expect(store.logEntry).toHaveBeenCalledWith(
@@ -573,10 +555,7 @@ describe("POST /tasks/:id/retry", () => {
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
status: null, status: null,
error: null, error: null,
stuckKillCount: 0, ...buildManualRetryResetPatch({ resetMergeRetries: true }),
taskDoneRetryCount: 0,
workflowStepRetries: 0,
mergeRetries: 0,
}); });
expect(store.moveTask).not.toHaveBeenCalled(); expect(store.moveTask).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith( expect(store.logEntry).toHaveBeenCalledWith(
@@ -606,10 +585,7 @@ describe("POST /tasks/:id/retry", () => {
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
status: null, status: null,
error: null, error: null,
stuckKillCount: 0, ...buildManualRetryResetPatch({ resetMergeRetries: true }),
taskDoneRetryCount: 0,
workflowStepRetries: 0,
mergeRetries: 0,
}); });
expect(store.moveTask).not.toHaveBeenCalled(); expect(store.moveTask).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith( expect(store.logEntry).toHaveBeenCalledWith(
@@ -688,11 +664,7 @@ describe("POST /tasks/:id/retry", () => {
branch: null, branch: null,
baseBranch: null, baseBranch: null,
baseCommitSha: null, baseCommitSha: null,
stuckKillCount: 0, ...buildManualRetryResetPatch({ resetMergeRetries: true }),
taskDoneRetryCount: 0,
workflowStepRetries: 0,
recoveryRetryCount: null,
nextRecoveryAt: null,
}); });
expect(store.moveTask).not.toHaveBeenCalled(); expect(store.moveTask).not.toHaveBeenCalled();
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false); 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, { await scopedStore.updateTask(req.params.id, {
status: null, status: null,
error: null, error: null,
...buildManualRetryResetPatch(), ...buildManualRetryResetPatch({ resetMergeRetries: true }),
mergeRetries: 0,
}); });
await scopedStore.logEntry(req.params.id, "Retry requested from dashboard (in-review merge retry, mergeRetries reset)"); await scopedStore.logEntry(req.params.id, "Retry requested from dashboard (in-review merge retry, mergeRetries reset)");
const updated = await scopedStore.getTask(req.params.id); const updated = await scopedStore.getTask(req.params.id);
@@ -1075,9 +1074,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
branch: null, branch: null,
baseBranch: null, baseBranch: null,
baseCommitSha: null, baseCommitSha: null,
recoveryRetryCount: null, ...buildManualRetryResetPatch({ resetMergeRetries: true }),
nextRecoveryAt: null,
...buildManualRetryResetPatch(),
}); });
if (retrySpecification) { if (retrySpecification) {