fix(FN-3305): reset mergeRetries when dispatching tasks to in-progress

A task whose previous run exhausted its merge budget (mergeRetries=MAX)
could land back in in-review with status=null, where the merger refused
it (canMergeTask false) and the ghost-review fallback bounced it back to
todo every taskStuckTimeoutMs (10 min) — beating the 30 min merge
cooldown reset. Each fresh execution now starts with mergeRetries=0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-03 17:17:04 -07:00
parent 66b673b18f
commit 07e9825d9b
3 changed files with 54 additions and 1 deletions

View File

@@ -247,6 +247,44 @@ describe("Scheduler", () => {
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
});
it("resets mergeRetries when dispatching a task to in-progress", async () => {
// Regression: a task whose previous run exhausted its merge budget
// (mergeRetries = MAX) would, after status was cleared, land back in
// in-review with the merger refusing it (canMergeTask false) and the
// ghost-review fallback bouncing it back every taskStuckTimeoutMs —
// infinite loop. Each fresh execution must get a fresh merge budget.
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
const listTasksMock = vi.fn()
.mockResolvedValueOnce([])
.mockResolvedValue([
createMockTask({ id: "FN-001", column: "todo", dependencies: [], mergeRetries: 3 }),
]);
const store = createMockStore({
listTasks: listTasksMock,
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
updateTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
});
const scheduler = new Scheduler(store);
scheduler.start();
await flushAsyncWork();
const onCalls = (store.on as any).mock.calls;
const createdHandler = onCalls.find((call: any) => call[0] === "task:created")?.[1];
await createdHandler(createMockTask({ id: "FN-001", column: "todo", mergeRetries: 3 }));
await flushAsyncWork();
expect(store.updateTask).toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({ mergeRetries: 0 }),
);
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
});
it("registers task:moved event listener", () => {
const store = createMockStore();
new Scheduler(store);
@@ -820,6 +858,7 @@ describe("Scheduler", () => {
worktree: "/test/project/.worktrees/fn-010",
effectiveNodeId: null,
effectiveNodeSource: "local",
mergeRetries: 0,
});
expect(moveTask).toHaveBeenCalledWith("FN-010", "in-progress");
expect(updateTask.mock.invocationCallOrder[0]).toBeLessThan(moveTask.mock.invocationCallOrder[0]);
@@ -858,6 +897,7 @@ describe("Scheduler", () => {
worktree: "/test/project/.worktrees/amber-aspen",
effectiveNodeId: null,
effectiveNodeSource: "local",
mergeRetries: 0,
});
expect(updateTask).toHaveBeenNthCalledWith(2, "FN-012", {
status: null,
@@ -866,6 +906,7 @@ describe("Scheduler", () => {
worktree: "/test/project/.worktrees/amber-aspen-2",
effectiveNodeId: null,
effectiveNodeSource: "local",
mergeRetries: 0,
});
randomSpy.mockRestore();