feat(FN-5485): clear userPaused state on manual retry reset with regression

Adds retry-reset logic that clears the user-paused flag on tasks, ensuring they can resume automatically after a retry is triggered, with regression tests covering the behavior across the CLI extension and core manual-reset module.

Fusion-Task-Id: FN-5485
This commit is contained in:
Fusion (runfusion.ai)
2026-05-22 08:16:49 -07:00
committed by gsxdsm
parent 2d661df870
commit 4b484fd818
5 changed files with 83 additions and 46 deletions

View File

@@ -2522,6 +2522,31 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expectRetryCountersReset(updated);
expect(updated?.mergeRetries).toBe(0);
});
it("clears userPaused when retrying a manually paused failed task", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const task = await store.createTask({
title: "manually paused failed task",
description: "test",
column: "todo",
});
await store.updateTask(task.id, {
status: "failed",
error: "verification failed",
userPaused: true,
});
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-user-paused", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("todo");
expect(updated?.status).toBeFalsy();
expect(updated?.userPaused).toBeUndefined();
});
});
describe("fn_list_agents", () => {

View File

@@ -35,4 +35,14 @@ describe("buildManualRetryResetPatch", () => {
it("clears nextRecoveryAt", () => {
expect(buildManualRetryResetPatch()).toMatchObject({ nextRecoveryAt: null });
});
it("clears userPaused for manual retry in all modes", () => {
const defaultPatch = buildManualRetryResetPatch();
expect(Object.prototype.hasOwnProperty.call(defaultPatch, "userPaused")).toBe(true);
expect(defaultPatch.userPaused).toBeUndefined();
const mergePatch = buildManualRetryResetPatch({ resetMergeRetries: true });
expect(Object.prototype.hasOwnProperty.call(mergePatch, "userPaused")).toBe(true);
expect(mergePatch.userPaused).toBeUndefined();
});
});

View File

@@ -16,9 +16,11 @@ export const MANUAL_RETRY_RESET_COUNTER_KEYS = [
"mergeAuditBounceCount",
] as const satisfies ReadonlyArray<keyof Task>;
/** Resets retry/recovery counters and clears `userPaused` for explicit manual retries. */
export function buildManualRetryResetPatch(options?: { resetMergeRetries?: boolean }): Partial<Task> {
const patch: Partial<Task> = {
nextRecoveryAt: null as unknown as Task["nextRecoveryAt"],
userPaused: undefined,
};
for (const key of MANUAL_RETRY_RESET_COUNTER_KEYS) {

View File

@@ -2,52 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<script>
// Append ?vpdebug to the URL to show a fixed overlay with the live
// viewport / element dimensions. Diagnostic only — remove once the
// Android tablet cut-off issue is understood.
(function () {
if (!/[?&]vpdebug\b/.test(location.search)) return;
var box = document.createElement('div');
box.style.cssText =
'position:fixed;top:0;right:0;z-index:2147483647;background:#000;color:#0f0;font:11px/1.3 monospace;padding:6px 8px;border:1px solid #0f0;white-space:pre;pointer-events:none;max-width:60vw;';
var update = function () {
var f = function (el) {
if (!el) return 'none';
var r = el.getBoundingClientRect();
return Math.round(r.width) + 'x' + Math.round(r.height) + ' @' + Math.round(r.left) + ',' + Math.round(r.top);
};
var vv = window.visualViewport;
box.textContent =
'win ' + window.innerWidth + 'x' + window.innerHeight + '\n' +
'vv ' + (vv ? Math.round(vv.width) + 'x' + Math.round(vv.height) + ' s' + vv.scale.toFixed(2) + ' o' + Math.round(vv.offsetLeft) + ',' + Math.round(vv.offsetTop) : 'n/a') + '\n' +
'dpr ' + window.devicePixelRatio + '\n' +
'html ' + f(document.documentElement) + '\n' +
'body ' + f(document.body) + '\n' +
'root ' + f(document.getElementById('root')) + '\n' +
'board ' + f(document.getElementById('board')) + '\n' +
'sx ' + window.scrollX + ' sy ' + window.scrollY + '\n' +
'ua ' + (navigator.userAgent || '').slice(0, 80);
};
var attach = function () {
if (!document.body) {
requestAnimationFrame(attach);
return;
}
document.body.appendChild(box);
update();
setInterval(update, 500);
window.addEventListener('resize', update);
window.addEventListener('orientationchange', update);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', update);
window.visualViewport.addEventListener('scroll', update);
}
};
attach();
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Fusion</title>
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="manifest" href="/manifest.json" />

View File

@@ -382,6 +382,24 @@ describe("POST /tasks/:id/retry", () => {
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
});
it("clears userPaused when retrying a failed todo task", async () => {
const failedTaskInTodo = { ...FAKE_TASK_DETAIL, column: "todo", status: "failed", userPaused: true };
const movedTask = { ...failedTaskInTodo, status: undefined, userPaused: undefined };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(failedTaskInTodo);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(failedTaskInTodo);
(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);
const updateCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(Object.prototype.hasOwnProperty.call(updateCall, "userPaused")).toBe(true);
expect(updateCall.userPaused).toBeUndefined();
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
});
it("retries a stuck-killed task and moves it to todo", async () => {
const stuckTask = { ...FAKE_TASK_DETAIL, status: "stuck-killed", column: "in-progress" };
const movedTask = { ...FAKE_TASK_DETAIL, column: "todo", status: undefined };
@@ -564,6 +582,33 @@ describe("POST /tasks/:id/retry", () => {
);
});
it("clears userPaused for merge-retry in-review tasks", async () => {
const mergeFailedTask = {
...FAKE_TASK_DETAIL,
column: "in-review" as const,
status: "failed",
userPaused: true,
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "done" },
],
};
(store.getTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(mergeFailedTask)
.mockResolvedValueOnce({ ...mergeFailedTask, userPaused: undefined });
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...mergeFailedTask, userPaused: undefined });
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
const updateCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(Object.prototype.hasOwnProperty.call(updateCall, "userPaused")).toBe(true);
expect(updateCall.userPaused).toBeUndefined();
expect(store.moveTask).not.toHaveBeenCalled();
});
it("retries zero-step merge-failed in-review task with prior merge attempts by staying in-review", async () => {
const mergeFailedTask = {
...FAKE_TASK_DETAIL,