From b9b9447d6ecc1701532c398421db640009a9a1be Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 11:15:34 -0700 Subject: [PATCH] fix(FN-5048): reset stuck-kill streak on forward progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stuckKillCount is a lifetime counter — incremented by self-healing on each stuck-kill and reset ONLY by a manual retry — so a long task that genuinely advances between intermittent stalls could be terminalized by accumulation toward maxStuckKills (default 6), even though it is making progress. Reset the streak in TaskStore.updateStep when a step reaches a terminal forward status (done/skipped), the single chokepoint every step-completion surface routes through (legacy fn_task_update, graph markStepDone, fn_task_done). It deliberately does NOT rescue a task wedged re-running the same failing step — no step completes between those kills, so it still terminalizes as designed; it only bounds the budget to consecutive stalls. Complements the verification fan-out cap (PR #1708) that keeps verification fast in the first place. New regression suite asserts reset on done/skipped/graph-source-done and NO reset on in-progress advance or ignored out-of-order transitions. Merge gate + full @fusion/core suite (6242 tests) + engine self-healing/stuck-detector (442 tests) green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/stuck-kill-reset-on-progress.md | 5 ++ .../__tests__/store-stuck-kill-reset.test.ts | 73 +++++++++++++++++++ packages/core/src/store.ts | 21 ++++++ 3 files changed, 99 insertions(+) create mode 100644 .changeset/stuck-kill-reset-on-progress.md create mode 100644 packages/core/src/__tests__/store-stuck-kill-reset.test.ts diff --git a/.changeset/stuck-kill-reset-on-progress.md b/.changeset/stuck-kill-reset-on-progress.md new file mode 100644 index 0000000000..b9ce5ec9f4 --- /dev/null +++ b/.changeset/stuck-kill-reset-on-progress.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Reset a task's stuck-kill streak on genuine forward progress. `stuckKillCount` was a lifetime counter — incremented by self-healing on each stuck-kill and cleared only by a manual retry — so a long, genuinely-progressing task could be terminalized by accumulation toward the stuck-kill budget. It now resets when a step reaches a terminal forward status (done/skipped), so only consecutive no-progress stalls count toward the budget. diff --git a/packages/core/src/__tests__/store-stuck-kill-reset.test.ts b/packages/core/src/__tests__/store-stuck-kill-reset.test.ts new file mode 100644 index 0000000000..6a9ff25934 --- /dev/null +++ b/packages/core/src/__tests__/store-stuck-kill-reset.test.ts @@ -0,0 +1,73 @@ +import { beforeAll, beforeEach, afterEach, afterAll, describe, expect, it } from "vitest"; +import { createSharedTaskStoreTestHarness } from "./store-test-helpers.js"; + +/* +FNXC:SelfHealing 2026-06-21-12:45: +Forward progress (a step reaching a terminal forward status) must clear the lifetime +stuck-kill streak so only CONSECUTIVE no-progress stalls count toward maxStuckKills. +stuckKillCount is otherwise incremented by self-healing on each stuck-kill and reset ONLY +by a manual retry, so a long task that genuinely advances between intermittent stalls could +be terminalized by accumulation. Asserted across every updateStep surface (legacy done, +skipped, graph-source done) and proven NOT to reset on non-forward transitions (in-progress +advance, ignored regressions). Complements the FN-5048 verification-fan-out cap. +*/ +describe("TaskStore.updateStep stuck-kill streak reset on forward progress", () => { + const harness = createSharedTaskStoreTestHarness(); + + beforeAll(harness.beforeAll); + beforeEach(harness.beforeEach); + afterEach(harness.afterEach); + afterAll(harness.afterAll); + + const withStreak = async (streak: number) => { + const store = harness.store(); + const task = await harness.createTaskWithSteps(); + await store.updateTask(task.id, { stuckKillCount: streak }); + return { store, task }; + }; + + it("done clears the streak and logs the reset", async () => { + const { store, task } = await withStreak(4); + const updated = await store.updateStep(task.id, 0, "done"); + expect(updated.stuckKillCount ?? 0).toBe(0); + expect(updated.log.some((e) => e.action.includes("Reset stuck-kill streak"))).toBe(true); + }); + + it("skipped clears the streak", async () => { + const { store, task } = await withStreak(5); + const updated = await store.updateStep(task.id, 0, "skipped"); + expect(updated.stuckKillCount ?? 0).toBe(0); + }); + + it("graph-source done clears the streak (graph surface)", async () => { + const store = harness.store(); + const task = await harness.createTaskWithSteps(); + // Graph-source writes bypass lazy step-init from PROMPT.md, so materialize the + // step list with a legacy write first (mirrors store-update-step-order's graph tests). + await store.updateStep(task.id, 0, "in-progress"); + await store.updateTask(task.id, { stuckKillCount: 3 }); + const updated = await store.updateStep(task.id, 0, "done", { source: "graph" }); + expect(updated.stuckKillCount ?? 0).toBe(0); + }); + + it("in-progress (step advance) does NOT clear the streak — only terminal forward progress does", async () => { + const { store, task } = await withStreak(3); + const updated = await store.updateStep(task.id, 0, "in-progress"); + expect(updated.stuckKillCount ?? 0).toBe(3); + }); + + it("an IGNORED out-of-order done does NOT clear the streak (no real progress)", async () => { + const { store, task } = await withStreak(2); + // step 0 still pending → done on step 2 is rejected/ignored, so no forward progress. + const updated = await store.updateStep(task.id, 2, "done"); + expect(updated.steps[2].status).toBe("pending"); + expect(updated.stuckKillCount ?? 0).toBe(2); + }); + + it("a no-op write does not log a spurious reset when there is no streak", async () => { + const store = harness.store(); + const task = await harness.createTaskWithSteps(); + const updated = await store.updateStep(task.id, 0, "done"); + expect(updated.log.some((e) => e.action.includes("Reset stuck-kill streak"))).toBe(false); + }); +}); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 66822b7f80..115bb82384 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -9096,6 +9096,27 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} task.currentStep = stepIndex; } + /* + FNXC:SelfHealing 2026-06-21-12:45: + Forward progress clears the stuck-kill streak. stuckKillCount is otherwise a lifetime + counter — incremented by self-healing on each stuck-kill (checkStuckBudget) and reset + ONLY by a manual retry (manual-retry-reset) — so a long task that genuinely advances + between intermittent stalls could still be terminalized by accumulation toward + maxStuckKills (default 6). Resetting when a step reaches a terminal forward status + (done/skipped) makes only CONSECUTIVE stalls count toward the budget. This does NOT + rescue a task wedged re-running the same failing step (no step completes between those + kills, so the streak keeps climbing and the task still terminalizes as designed); it + bounds the budget to consecutive no-progress stalls. Complements the FN-5048 + verification-fan-out cap that keeps verification from being slow in the first place. + */ + if ((status === "done" || status === "skipped") && (task.stuckKillCount ?? 0) > 0) { + task.stuckKillCount = undefined; + task.log.push({ + timestamp: task.updatedAt, + action: `Reset stuck-kill streak (forward progress: step ${stepIndex} (${task.steps[stepIndex].name}) → ${status})`, + }); + } + // Log it task.log.push({ timestamp: task.updatedAt,