diff --git a/packages/engine/src/__tests__/reliability-interactions/non-progress-churn.test.ts b/packages/engine/src/__tests__/reliability-interactions/non-progress-churn.test.ts index bc3c2d84ce..17d29c4dcc 100644 --- a/packages/engine/src/__tests__/reliability-interactions/non-progress-churn.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/non-progress-churn.test.ts @@ -190,7 +190,7 @@ describe("reliability interactions: non-progress churn", () => { manager.stop(); }); - it("re-queues incomplete STUCK_LOOP_EXHAUSTED tasks in todo when the churn signal does not fire", async () => { + it("parks incomplete STUCK_LOOP_EXHAUSTED tasks in todo when the churn signal does not fire", async () => { const task = baseTask({ id: "FN-5168-LOOP", stuckKillCount: 6 }); const store = createStore(task); const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" }); @@ -210,19 +210,19 @@ describe("reliability interactions: non-progress churn", () => { await detector.killAndRetry(task.id, 60_000); - expect(task.error).toBeNull(); - expect(task.status).toBe("queued"); + expect(task.error).toContain("STUCK_LOOP_EXHAUSTED: incomplete task exhausted stuck kill budget"); + expect(task.status).toBe("failed"); expect(task.column).toBe("todo"); - expect(task.paused).toBe(false); + expect(task.paused).toBe(true); // FN-6252 / Move-Task contract: engine rebounds do not write userPaused, // so a never-user-paused task remains undefined while still not user-paused. expect(task.userPaused).not.toBe(true); - expect(task.pausedReason).toBeNull(); + expect(task.pausedReason).toBe("stuck-loop-exhausted-manual-intervention-required"); expect(task.stuckKillCount).toBe(7); expect(task.steps).toEqual([{ name: "Implement", status: "in-progress" }]); - expect(task.log?.some((entry) => entry.action.includes("incomplete task exhausted stuck kill budget"))).toBe(true); + expect(task.log?.some((entry) => entry.action.includes("Parked in todo with progress preserved"))).toBe(true); expect(store.handoffToReview).not.toHaveBeenCalled(); - expect(isRunnableQueuedOverlapCandidate(task, [task])).toBe(true); + expect(isRunnableQueuedOverlapCandidate(task, [task])).toBe(false); manager.stop(); }); diff --git a/packages/engine/src/__tests__/reliability-interactions/todo-inprogress-flapping.test.ts b/packages/engine/src/__tests__/reliability-interactions/todo-inprogress-flapping.test.ts index 445d1ac490..fe51c18528 100644 --- a/packages/engine/src/__tests__/reliability-interactions/todo-inprogress-flapping.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/todo-inprogress-flapping.test.ts @@ -290,7 +290,7 @@ describe("FN-5941 reliability interactions: todo/in-progress flapping", () => { manager.stop(); }); - it("still requeues a genuinely dead task when stuck-kill budget is exhausted", async () => { + it("parks a genuinely dead incomplete task when stuck-kill budget is exhausted", async () => { const task = makeTask(rootDir, { id: "FN-5941-DEAD", stuckKillCount: 6, @@ -314,7 +314,10 @@ describe("FN-5941 reliability interactions: todo/in-progress flapping", () => { })); expect(task.column).toBe("todo"); expect(task.stuckKillCount).toBe(7); - expect(task.status).toBe("queued"); + expect(task.status).toBe("failed"); + expect(task.paused).toBe(true); + expect(task.pausedReason).toBe("stuck-loop-exhausted-manual-intervention-required"); + expect(task.userPaused).not.toBe(true); manager.stop(); }); diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 1a408873b6..0d1ca86e46 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -1214,6 +1214,10 @@ export class SelfHealingManager { * progress preserved, marked failed, and paused for manual resume or * decomposition; tasks with only terminal steps keep the legacy failed * `in-review` handoff path. + * + * FNXC:SelfHealing 2026-06-14-10:51: + * Incomplete stuck-loop exhaustion must park work in a failed/paused state before moving columns, because a post-move patch failure must not leave the task scheduler-runnable. + * Engine-owned recovery must not mutate `userPaused`; user intent stays authoritative across races. * - `STUCK_NO_PROGRESS_CHURN`: skips the budget entirely and terminalizes on * the first trigger with operator guidance to decompose or rescope. * @@ -1316,9 +1320,7 @@ export class SelfHealingManager { } log.warn(`${taskId} exceeded stuck kill budget (${newCount}/${maxKills}, reason=${reason}) with incomplete steps — parking in todo with progress preserved`); - const exhaustedError = - `STUCK_LOOP_EXHAUSTED: incomplete task exhausted stuck kill budget (${newCount}/${maxKills}) after last reason=${reason}. ` + - "Progress was preserved; manually retry, decompose, or rescope before execution resumes."; + const exhaustedError = `STUCK_LOOP_EXHAUSTED: incomplete task exhausted stuck kill budget (${newCount}/${maxKills}) after last reason=${reason}. Progress was preserved; manually retry, decompose, or rescope before execution resumes.`; const parkUpdate = { stuckKillCount: newCount, status: "failed",