From edaa793b6273bbcb95033af39e5fd2a15dfb3079 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 22 Jul 2026 18:16:00 -0700 Subject: [PATCH] fix(core): legacy adoption must preserve statuses with live writers (FN-8504 incident) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The KTD-8 adoption sweep runs on every store open, and a DB with active tasks never records the drained marker, so it re-runs constantly. Its resume-graph mapping for 'planning' cleared FN-8504's freshly written live planner status ~100ms after triage claimed it (audit: task:reconcile-legacy-adoption, priorStatus 'planning'), leaving a live replan planner rendered as an idle READY card and invisible to every Running count. Generalize the FN-8498 needs-replan fix: any status with a live post-cutover writer is preserved — planning (triage's stale-planning sweep owns crash recovery), queued (scheduler re-evaluates each poll), merging/merging-pr/ merging-fix (self-healing stale-merge recovery), stuck-killed (restart- recovery coordinator). Only writer-less statuses (plan-review-unavailable, triaged) keep resume-graph. Co-Authored-By: Claude Fable 5 --- .../legacy-adoption-live-status-stomp.md | 7 +++ .../src/__tests__/legacy-adoption.test.ts | 53 ++++++++++++++----- packages/core/src/legacy-adoption.ts | 45 +++++++++++----- 3 files changed, 79 insertions(+), 26 deletions(-) create mode 100644 .changeset/legacy-adoption-live-status-stomp.md diff --git a/.changeset/legacy-adoption-live-status-stomp.md b/.changeset/legacy-adoption-live-status-stomp.md new file mode 100644 index 0000000000..72bd22319e --- /dev/null +++ b/.changeset/legacy-adoption-live-status-stomp.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Stop the legacy-adoption sweep from clearing live task statuses (planning, queued, merging, stuck-killed) on store open. +category: fix +dev: LEGACY_STATUS_ADOPTION now preserves statuses with live post-cutover writers; only writer-less statuses (plan-review-unavailable, triaged) keep resume-graph. Generalizes the FN-8498 needs-replan fix after FN-8504's live planner status was cleared mid-session. diff --git a/packages/core/src/__tests__/legacy-adoption.test.ts b/packages/core/src/__tests__/legacy-adoption.test.ts index aa0e224e98..2ca6542ec8 100644 --- a/packages/core/src/__tests__/legacy-adoption.test.ts +++ b/packages/core/src/__tests__/legacy-adoption.test.ts @@ -120,12 +120,26 @@ describe("KTD-8 adoption table — write-site census completeness (build-failing }); describe("resolveLegacyStatusAdoption — every legacy (status) resumes owned", () => { - it("triage plan-review statuses resume the graph (writers deleted in U3)", () => { - for (const s of ["planning", "plan-review-unavailable"]) { + it("statuses whose writers U3 deleted resume the graph", () => { + for (const s of ["plan-review-unavailable", "triaged"]) { expect(resolveLegacyStatusAdoption(s)?.kind).toBe("resume-graph"); } }); + /* + FNXC:LegacyAdoption 2026-07-22-18:20 (FN-8504 incident — generalizes FN-8498): + Statuses with LIVE post-cutover writers are not legacy. The sweep runs on every store + open (active tasks withhold the drained marker), so a clearing action races live lanes: + FN-8504's replan planner wrote status:"planning" and a store-open adoption cleared it + ~100ms later, leaving a live planner rendered as an idle "Ready" card. Each of these has + its own crash-recovery owner; adoption must never touch them. + */ + it("live-writer statuses are preserved — planning/queued/merge pipeline/stuck-killed (FN-8504)", () => { + for (const s of ["planning", "queued", "merging", "merging-pr", "merging-fix", "stuck-killed", "needs-replan"]) { + expect(resolveLegacyStatusAdoption(s)?.kind, s).toBe("preserve"); + } + }); + /* FNXC:LegacyAdoption 2026-07-22-15:55 (FN-8498 incident): needs-replan is written LIVE by the graph's plan-replan seam and is the exact key @@ -194,7 +208,7 @@ describe("planLegacyAdoption (U9b consumers)", () => { const NOW = "2026-07-19T04:40:00.000Z"; it("clears every resume-graph status so the graph re-enters at its owning node", () => { - for (const status of ["planning", "plan-review-unavailable", "queued", "triaged"]) { + for (const status of ["plan-review-unavailable", "triaged"]) { const plan = planLegacyAdoption({ status }, NOW); expect(plan.action, status).toBe("resume-graph"); // Clearing the legacy status IS the re-entry: the graph owns the node again. @@ -220,6 +234,19 @@ describe("planLegacyAdoption (U9b consumers)", () => { } }); + /* + FNXC:LegacyAdoption 2026-07-22-18:20 (FN-8504 incident): + The end-to-end plan for a live-writer status must be a full skip — no clear, no stamp — + because the sweep can fire from any store open while the status is genuinely live. + */ + it("skips live-writer statuses entirely — a live planner/merge/queue marker is never cleared (FN-8504)", () => { + for (const status of ["planning", "queued", "merging", "merging-pr", "merging-fix", "stuck-killed"]) { + const plan = planLegacyAdoption({ status }, NOW); + expect(plan.action, status).toBe("skip"); + expect(plan.patch, status).toBeUndefined(); + } + }); + /* FNXC:LegacyAdoption 2026-07-22-15:55 (FN-8498 incident): A post-cutover todo row in the plan-replan loop carries status "needs-replan" and no @@ -258,7 +285,7 @@ describe("planLegacyAdoption (U9b consumers)", () => { un-parked. */ it("is idempotent — an already-adopted row is never re-adopted", () => { - const plan = planLegacyAdoption({ status: "planning", legacyAdoptedAt: NOW }, NOW); + const plan = planLegacyAdoption({ status: "plan-review-unavailable", legacyAdoptedAt: NOW }, NOW); expect(plan.action).toBe("skip"); expect(plan.patch).toBeUndefined(); }); @@ -405,7 +432,7 @@ describe("adoptLegacyTaskRowsOnOpen — paginates past the 500-row page cap", () // 1101 legacy rows → 3 pages (500 + 500 + 101); a capped scan would strand 601. const rows: Array & { id: string }> = Array.from({ length: 1101 }, (_, i) => ({ id: `task-${i + 1}`, - status: "planning", + status: "plan-review-unavailable", })); const { store, listCalls } = makeFakeStore(rows); @@ -418,7 +445,7 @@ describe("adoptLegacyTaskRowsOnOpen — paginates past the 500-row page cap", () }); it("stops after one page when the census fits under the cap, and stays idempotent", async () => { - const rows = Array.from({ length: 3 }, (_, i) => ({ id: `task-${i + 1}`, status: "queued" })); + const rows = Array.from({ length: 3 }, (_, i) => ({ id: `task-${i + 1}`, status: "triaged" })); const { store, listCalls } = makeFakeStore(rows); expect(await adoptLegacyTaskRowsOnOpen(store)).toBe(3); @@ -439,7 +466,7 @@ describe("adoptLegacyTaskRowsOnOpen — drained-marker completion short-circuit" it("writes the non-numeric marker after a clean drain (no mutating plan)", async () => { const rows = [ { id: "task-1", status: "done" }, // preserve gate → skip - { id: "task-2", status: "planning", legacyAdoptedAt: "2026-07-19" }, // already adopted → skip + { id: "task-2", status: "plan-review-unavailable", legacyAdoptedAt: "2026-07-19" }, // already adopted → skip { id: "task-3" }, // nothing legacy → skip ]; const { store, listCalls, markerWrites } = makeFakeStore(rows, { backend: true }); @@ -453,17 +480,17 @@ describe("adoptLegacyTaskRowsOnOpen — drained-marker completion short-circuit" }); it("skips the sweep entirely when the marker is present", async () => { - const rows = [{ id: "task-1", status: "planning" }]; + const rows = [{ id: "task-1", status: "plan-review-unavailable" }]; const { store, listCalls } = makeFakeStore(rows, { backend: true, markerPresent: true }); expect(await adoptLegacyTaskRowsOnOpen(store)).toBe(0); expect(listCalls.length).toBe(0); // The (hypothetical) legacy row is untouched — marker presence means it cannot exist. - expect(rows[0].status).toBe("planning"); + expect(rows[0].status).toBe("plan-review-unavailable"); }); it("a mutating drain adopts but does NOT write the marker that cycle", async () => { - const rows = [{ id: "task-1", status: "planning" }]; + const rows = [{ id: "task-1", status: "plan-review-unavailable" }]; const { store, markerWrites } = makeFakeStore(rows, { backend: true }); expect(await adoptLegacyTaskRowsOnOpen(store)).toBe(1); @@ -476,18 +503,18 @@ describe("adoptLegacyTaskRowsOnOpen — drained-marker completion short-circuit" }); it("a userPaused legacy row withholds the marker without being mutated", async () => { - const rows = [{ id: "task-1", status: "planning", userPaused: true }]; + const rows = [{ id: "task-1", status: "plan-review-unavailable", userPaused: true }]; const { store, markerWrites } = makeFakeStore(rows, { backend: true }); expect(await adoptLegacyTaskRowsOnOpen(store)).toBe(0); // Operator-paused rows are never adopted … - expect(rows[0].status).toBe("planning"); + expect(rows[0].status).toBe("plan-review-unavailable"); // … but they keep the census "not drained" so they stay adoptable after unpause. expect(markerWrites.length).toBe(0); }); it("falls back to sweeping when the marker read fails (fail-open toward correctness)", async () => { - const rows = [{ id: "task-1", status: "planning" }]; + const rows = [{ id: "task-1", status: "plan-review-unavailable" }]; const { store } = makeFakeStore(rows, { backend: true, markerReadThrows: true }); expect(await adoptLegacyTaskRowsOnOpen(store)).toBe(1); diff --git a/packages/core/src/legacy-adoption.ts b/packages/core/src/legacy-adoption.ts index 7af3463e82..73220884b5 100644 --- a/packages/core/src/legacy-adoption.ts +++ b/packages/core/src/legacy-adoption.ts @@ -9,11 +9,12 @@ fails the build if any lacks an adoption row here. So a status added during the cutover window fails the build instead of mass-parking rows `paused` at upgrade. Adoption action per legacy status (KTD-8), for the FOUNDATIONAL targets (U9 scope A): - - resume-graph : clear the legacy triage-owned status so the graph re-enters - cleanly at the owning node (planning → planning node, - plan-review-unavailable → plan-review retry, - queued/triaged → scheduler re-pickup). NOT needs-replan — - that is a LIVE graph signal, preserved (see its table row). + - resume-graph : clear a legacy status whose writers the cutover DELETED so the + graph re-enters cleanly at the owning node + (plan-review-unavailable → plan-review retry, + triaged → scheduler re-pickup). NEVER a status with a live + post-cutover writer — those are `preserve` (see the FN-8504 + incident note on the table). - preserve : a live human/terminal gate the graph must NOT disturb (awaiting-approval, awaiting-user-input, failed, error, blocked, done, cancelled). Pausing is NOT a status: it is @@ -55,8 +56,25 @@ export interface LegacyAdoptionAction { * build. `null`/`undefined` (no status) needs no row (nothing to adopt). */ export const LEGACY_STATUS_ADOPTION: Readonly> = { + /* + FNXC:LegacyAdoption 2026-07-22-18:20 (FN-8504 incident — generalizes FN-8498): + A status with a LIVE post-cutover writer is NOT legacy and must be `preserve`, never + resume-graph/clear. The adoption sweep runs on EVERY store open (a DB with any active + task never records the drained marker — a mutating cycle withholds it), so a clearing + action races live lanes: FN-8504's replan planner wrote status:"planning" and ~100ms + later a store-open adoption cleared it (audit: task:reconcile-legacy-adoption, + priorStatus "planning"), leaving a live planner rendered as an idle "Ready" card and + invisible to every Running count. Live-writer statuses each have their own crash- + recovery owner, so preserve loses nothing: + - planning → triage's startup stale-planning sweep clears crashed planners + - queued → the scheduler re-evaluates queued rows every poll + - merging/-pr/-fix→ self-healing recoverInterruptedMergingTasks / stale-merge recovery + - stuck-killed → restart-recovery-coordinator owns kill-park resume + Only statuses with NO live task-row writer may keep a clearing action + (plan-review-unavailable, triaged). + */ + "planning": { kind: "preserve", note: "live triage planner status — triage's stale-planning sweep owns crash recovery" }, // ── Triage plan-review statuses whose writers U3 deleted → graph re-entry ── - "planning": { kind: "resume-graph", note: "re-enter planning node" }, "plan-review-unavailable": { kind: "resume-graph", note: "plan-review retry (leased)" }, // ── Live graph signals with post-cutover writers — preserve, never clear ─── /* @@ -69,13 +87,13 @@ export const LEGACY_STATUS_ADOPTION: Readonly