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", () => {
|
describe("resolveLegacyStatusAdoption — every legacy (status) resumes owned", () => {
|
||||||
it("triage plan-review statuses resume the graph (writers deleted in U3)", () => {
|
it("statuses whose writers U3 deleted resume the graph", () => {
|
||||||
for (const s of ["planning", "plan-review-unavailable"]) {
|
for (const s of ["plan-review-unavailable", "triaged"]) {
|
||||||
expect(resolveLegacyStatusAdoption(s)?.kind).toBe("resume-graph");
|
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):
|
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
|
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";
|
const NOW = "2026-07-19T04:40:00.000Z";
|
||||||
|
|
||||||
it("clears every resume-graph status so the graph re-enters at its owning node", () => {
|
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);
|
const plan = planLegacyAdoption({ status }, NOW);
|
||||||
expect(plan.action, status).toBe("resume-graph");
|
expect(plan.action, status).toBe("resume-graph");
|
||||||
// Clearing the legacy status IS the re-entry: the graph owns the node again.
|
// 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):
|
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
|
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.
|
un-parked.
|
||||||
*/
|
*/
|
||||||
it("is idempotent — an already-adopted row is never re-adopted", () => {
|
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.action).toBe("skip");
|
||||||
expect(plan.patch).toBeUndefined();
|
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.
|
// 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) => ({
|
const rows: Array<Partial<Task> & { id: string }> = Array.from({ length: 1101 }, (_, i) => ({
|
||||||
id: `task-${i + 1}`,
|
id: `task-${i + 1}`,
|
||||||
status: "planning",
|
status: "plan-review-unavailable",
|
||||||
}));
|
}));
|
||||||
const { store, listCalls } = makeFakeStore(rows);
|
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 () => {
|
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);
|
const { store, listCalls } = makeFakeStore(rows);
|
||||||
|
|
||||||
expect(await adoptLegacyTaskRowsOnOpen(store)).toBe(3);
|
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 () => {
|
it("writes the non-numeric marker after a clean drain (no mutating plan)", async () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
{ id: "task-1", status: "done" }, // preserve gate → skip
|
{ 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
|
{ id: "task-3" }, // nothing legacy → skip
|
||||||
];
|
];
|
||||||
const { store, listCalls, markerWrites } = makeFakeStore(rows, { backend: true });
|
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 () => {
|
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 });
|
const { store, listCalls } = makeFakeStore(rows, { backend: true, markerPresent: true });
|
||||||
|
|
||||||
expect(await adoptLegacyTaskRowsOnOpen(store)).toBe(0);
|
expect(await adoptLegacyTaskRowsOnOpen(store)).toBe(0);
|
||||||
expect(listCalls.length).toBe(0);
|
expect(listCalls.length).toBe(0);
|
||||||
// The (hypothetical) legacy row is untouched — marker presence means it cannot exist.
|
// 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 () => {
|
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 });
|
const { store, markerWrites } = makeFakeStore(rows, { backend: true });
|
||||||
|
|
||||||
expect(await adoptLegacyTaskRowsOnOpen(store)).toBe(1);
|
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 () => {
|
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 });
|
const { store, markerWrites } = makeFakeStore(rows, { backend: true });
|
||||||
|
|
||||||
expect(await adoptLegacyTaskRowsOnOpen(store)).toBe(0);
|
expect(await adoptLegacyTaskRowsOnOpen(store)).toBe(0);
|
||||||
// Operator-paused rows are never adopted …
|
// 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.
|
// … but they keep the census "not drained" so they stay adoptable after unpause.
|
||||||
expect(markerWrites.length).toBe(0);
|
expect(markerWrites.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to sweeping when the marker read fails (fail-open toward correctness)", async () => {
|
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 });
|
const { store } = makeFakeStore(rows, { backend: true, markerReadThrows: true });
|
||||||
|
|
||||||
expect(await adoptLegacyTaskRowsOnOpen(store)).toBe(1);
|
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.
|
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):
|
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
|
- resume-graph : clear a legacy status whose writers the cutover DELETED so the
|
||||||
cleanly at the owning node (planning → planning node,
|
graph re-enters cleanly at the owning node
|
||||||
plan-review-unavailable → plan-review retry,
|
(plan-review-unavailable → plan-review retry,
|
||||||
queued/triaged → scheduler re-pickup). NOT needs-replan —
|
triaged → scheduler re-pickup). NEVER a status with a live
|
||||||
that is a LIVE graph signal, preserved (see its table row).
|
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
|
- preserve : a live human/terminal gate the graph must NOT disturb
|
||||||
(awaiting-approval, awaiting-user-input, failed, error,
|
(awaiting-approval, awaiting-user-input, failed, error,
|
||||||
blocked, done, cancelled). Pausing is NOT a status: it is
|
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).
|
* build. `null`/`undefined` (no status) needs no row (nothing to adopt).
|
||||||
*/
|
*/
|
||||||
export const LEGACY_STATUS_ADOPTION: Readonly<Record<string, LegacyAdoptionAction>> = {
|
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 ──
|
// ── 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)" },
|
"plan-review-unavailable": { kind: "resume-graph", note: "plan-review retry (leased)" },
|
||||||
// ── Live graph signals with post-cutover writers — preserve, never clear ───
|
// ── 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.
|
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" },
|
"needs-replan": { kind: "preserve", note: "live graph replan signal — triage todo-rediscovery consumes it" },
|
||||||
// ── Scheduler / dispatch transient states → re-pickup ─────────────────────
|
// ── Scheduler / dispatch states — queued has live writers → preserve ──────
|
||||||
"queued": { kind: "resume-graph", note: "scheduler re-queue" },
|
"queued": { kind: "preserve", note: "live scheduler capacity/dependency marker — scheduler re-evaluates each poll" },
|
||||||
"triaged": { kind: "resume-graph", note: "scheduler re-pickup" },
|
"triaged": { kind: "resume-graph", note: "scheduler re-pickup" },
|
||||||
// ── Merge substates (execute-seam/merge refinement DEFERRED to U9b) ───────
|
// ── Merge substates — live merger writers → preserve (FN-8504 incident) ───
|
||||||
"merging": { kind: "resume-graph", note: "resume merge node (U9b refines)" },
|
"merging": { kind: "preserve", note: "live merge-active status — self-healing owns stale-merge recovery" },
|
||||||
"merging-pr": { kind: "resume-graph", note: "resume merge-pr node (U9b refines)" },
|
"merging-pr": { kind: "preserve", note: "live merge-active status — self-healing owns stale-merge recovery" },
|
||||||
"merging-fix": { kind: "resume-graph", note: "resume merge-fix node (U9b refines)" },
|
"merging-fix": { kind: "preserve", note: "live merge-active status — self-healing owns stale-merge recovery" },
|
||||||
// ── Live human / terminal gates — do NOT disturb ──────────────────────────
|
// ── Live human / terminal gates — do NOT disturb ──────────────────────────
|
||||||
"awaiting-approval": { kind: "preserve", note: "manual plan approval gate" },
|
"awaiting-approval": { kind: "preserve", note: "manual plan approval gate" },
|
||||||
"awaiting-user-input": { kind: "preserve", note: "awaiting operator input" },
|
"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" },
|
"cancelled": { kind: "preserve", note: "operator-cancelled" },
|
||||||
// ── Transient in-flight → clear so normal dispatch resumes ────────────────
|
// ── Transient in-flight → clear so normal dispatch resumes ────────────────
|
||||||
"cancelling": { kind: "clear", note: "transient cancel — clear on restart" },
|
"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