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;
|
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
|
// Log it
|
||||||
task.log.push({
|
task.log.push({
|
||||||
timestamp: task.updatedAt,
|
timestamp: task.updatedAt,
|
||||||
|
|||||||
Reference in New Issue
Block a user