fix(FN-5048): reset stuck-kill streak on forward progress
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) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/stuck-kill-reset-on-progress.md
Normal file
5
.changeset/stuck-kill-reset-on-progress.md
Normal 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.
|
||||
73
packages/core/src/__tests__/store-stuck-kill-reset.test.ts
Normal file
73
packages/core/src/__tests__/store-stuck-kill-reset.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user