fix(core): legacy adoption must preserve statuses with live writers (FN-8504 incident)
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 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/legacy-adoption-live-status-stomp.md
Normal file
7
.changeset/legacy-adoption-live-status-stomp.md
Normal file
@@ -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.
|
||||
@@ -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<Partial<Task> & { 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);
|
||||
|
||||
@@ -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<Record<string, LegacyAdoptionAction>> = {
|
||||
/*
|
||||
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<Record<string, LegacyAdoptionActio
|
||||
after a restart). Preserve it: the status is self-resuming — triage picks it up as-is.
|
||||
*/
|
||||
"needs-replan": { kind: "preserve", note: "live graph replan signal — triage todo-rediscovery consumes it" },
|
||||
// ── Scheduler / dispatch transient states → re-pickup ─────────────────────
|
||||
"queued": { kind: "resume-graph", note: "scheduler re-queue" },
|
||||
// ── Scheduler / dispatch states — queued has live writers → preserve ──────
|
||||
"queued": { kind: "preserve", note: "live scheduler capacity/dependency marker — scheduler re-evaluates each poll" },
|
||||
"triaged": { kind: "resume-graph", note: "scheduler re-pickup" },
|
||||
// ── Merge substates (execute-seam/merge refinement DEFERRED to U9b) ───────
|
||||
"merging": { kind: "resume-graph", note: "resume merge node (U9b refines)" },
|
||||
"merging-pr": { kind: "resume-graph", note: "resume merge-pr node (U9b refines)" },
|
||||
"merging-fix": { kind: "resume-graph", note: "resume merge-fix node (U9b refines)" },
|
||||
// ── Merge substates — live merger writers → preserve (FN-8504 incident) ───
|
||||
"merging": { kind: "preserve", note: "live merge-active status — self-healing owns stale-merge recovery" },
|
||||
"merging-pr": { kind: "preserve", note: "live merge-active status — self-healing owns stale-merge recovery" },
|
||||
"merging-fix": { kind: "preserve", note: "live merge-active status — self-healing owns stale-merge recovery" },
|
||||
// ── Live human / terminal gates — do NOT disturb ──────────────────────────
|
||||
"awaiting-approval": { kind: "preserve", note: "manual plan approval gate" },
|
||||
"awaiting-user-input": { kind: "preserve", note: "awaiting operator input" },
|
||||
@@ -88,7 +106,8 @@ export const LEGACY_STATUS_ADOPTION: Readonly<Record<string, LegacyAdoptionActio
|
||||
"cancelled": { kind: "preserve", note: "operator-cancelled" },
|
||||
// ── Transient in-flight → clear so normal dispatch resumes ────────────────
|
||||
"cancelling": { kind: "clear", note: "transient cancel — clear on restart" },
|
||||
"stuck-killed": { kind: "resume-graph", note: "stuck-detector kill — clear and re-dispatch" },
|
||||
// stuck-killed has live writers (self-healing kill, restart-recovery-coordinator) → preserve.
|
||||
"stuck-killed": { kind: "preserve", note: "live stuck-detector kill park — restart-recovery owns re-dispatch" },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user