Merge pull request #1709 from Runfusion/gsxdsm/stuck-kill-reset

fix(FN-5048): reset stuck-kill streak on forward progress
This commit is contained in:
gsxdsm
2026-06-21 11:49:08 -07:00
committed by GitHub
3 changed files with 99 additions and 0 deletions

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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,