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:
gsxdsm
2026-07-22 18:16:00 -07:00
parent 3f7c6e4848
commit edaa793b62
3 changed files with 79 additions and 26 deletions

View 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.

View File

@@ -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);

View File

@@ -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" },
};
/**