fix(engine): re-plan cards that Plan Review sends back instead of stranding them

Plan Review REVISE rebounds a card to a planner lane with status
needs-replan, but hasAdvancedPastPlanning read the sticky
firstExecutionAt/executionStartedAt stamps as proof the card had left
planning. Triage discovery filters on that guard, so a rebounded card was
never re-admitted and sat in triage/needs-replan forever ("stuck in
planning"). An explicit planning-stage status now outranks the stamps in
both planner lanes; a triage card stamped with no planning status is still
excluded so self-healing's advanced recovery keeps owning it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-25 22:41:18 -07:00
parent 147398f2c8
commit 2dbfe3d312
4 changed files with 167 additions and 6 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Cards sent back for re-planning by Plan Review now actually get re-planned instead of sitting in Planning.
category: fix
dev: `hasAdvancedPastPlanning` now lets an explicit planning-stage status (`needs-replan`, `plan-review-unavailable`, `planning`) outrank the sticky `firstExecutionAt`/`executionStartedAt` evidence added in the plan-worktree cutover, so triage discovery re-admits a rebounded card. A triage card carrying an execution timestamp with no planning status is still excluded for self-healing's advanced recovery (PR #2360).

View File

@@ -27,9 +27,19 @@ explicit needs-replan status), the plan-in-place "todo" planner lane used by Cod
every parked-for-planning status, and the advancement signals that must still fire
(worktree, execution/terminal columns, planned-and-queued todo cards).
*/
/*
FNXC:WorkflowReplan 2026-07-26-06:10:
Regression surfaces for the sticky-execution-timestamp strand (FN-8594). A card that executed
once and was rebounded to a planner lane by Plan Review keeps `firstExecutionAt` forever, so
execution timestamps must never outrank the planner-lane checks — otherwise triage discovery
drops the card and it sits "stuck in planning". Enumerated surfaces: the "triage" column under
every parked-for-planning status AND no status, the plan-in-place "todo" lane, both timestamp
fields independently, and the queued-todo cards where a timestamp must still read as advanced.
*/
type PlanningGuardCase = {
label: string;
task: Pick<Task, "column" | "worktree" | "steps" | "status">;
task: Pick<Task, "column" | "worktree" | "steps" | "status">
& Partial<Pick<Task, "firstExecutionAt" | "executionStartedAt">>;
stillPlanning: boolean;
};
@@ -96,6 +106,72 @@ const planningGuardCases: PlanningGuardCase[] = [
task: { column: "in-progress", steps: [planStep("step-1")], status: "needs-replan" },
stillPlanning: false,
},
// Sticky execution timestamps must not survive a legitimate rebound into a planner lane.
{
label: "triage replan card that already executed once (firstExecutionAt)",
task: {
column: "triage",
steps: [planStep("step-1")],
status: "needs-replan",
firstExecutionAt: "2026-07-26T04:35:29.068Z",
},
stillPlanning: true,
},
{
label: "triage replan card whose last execution start is still stamped",
task: {
column: "triage",
steps: [planStep("step-1")],
status: "needs-replan",
executionStartedAt: "2026-07-26T04:35:29.068Z",
},
stillPlanning: true,
},
// A triage card with a timestamp but NO planning status is the stranded-advanced class that
// self-healing's advanced recovery owns (PR #2360) — planning must keep excluding it.
{
label: "stranded-advanced triage card with execution timestamps and no planning status",
task: {
column: "triage",
steps: [planStep("step-1")],
firstExecutionAt: "2026-07-26T04:35:29.068Z",
executionStartedAt: "2026-07-26T04:35:29.068Z",
},
stillPlanning: false,
},
{
label: "triage card parked by a reviewer outage after an execution attempt",
task: {
column: "triage",
steps: [planStep("step-1")],
status: "plan-review-unavailable",
firstExecutionAt: "2026-07-26T04:35:29.068Z",
},
stillPlanning: true,
},
{
label: "plan-in-place todo replan card that already executed once",
task: {
column: "todo",
steps: [planStep("step-1")],
status: "needs-replan",
firstExecutionAt: "2026-07-26T04:35:29.068Z",
},
stillPlanning: true,
},
// A queued todo card with an execution timestamp and no planning status HAS advanced:
// this is the FN-7977 race where the stamp lands just before the move to in-progress.
{
label: "queued todo card stamped with firstExecutionAt just before the dispatch move",
task: { column: "todo", steps: [], firstExecutionAt: "2026-07-26T04:35:29.068Z" },
stillPlanning: false,
},
{
label: "queued todo card stamped with executionStartedAt just before the dispatch move",
task: { column: "todo", steps: [], executionStartedAt: "2026-07-26T04:35:29.068Z" },
stillPlanning: false,
},
];
describe("planning-stage guard", () => {

View File

@@ -284,6 +284,68 @@ describe("TriageProcessor planning discovery: missing PROMPT.md", () => {
expect(await discover(store, [task])).toEqual(["FN-REFINE"]);
});
/*
FNXC:WorkflowReplan 2026-07-26-06:10:
FN-8594 symptom: a card that executed once, failed Plan Review, and was rebounded to triage with
status `needs-replan` was never re-admitted for planning — it sat in triage/needs-replan forever
("stuck in planning" on the board) because execution timestamps are sticky and outranked the
planner-lane checks in hasAdvancedPastPlanning. Discovery is the surface that stranded the card,
so assert admission here and not only on the pure guard. `firstExecutionAt` and
`executionStartedAt` are stamped independently, so both are covered.
*/
it("re-admits a rebounded triage replan card that already executed once", async () => {
for (const [label, stamps] of [
["firstExecutionAt", { firstExecutionAt: "2026-07-26T04:35:29.068Z" }],
["executionStartedAt", { executionStartedAt: "2026-07-26T04:35:29.068Z" }],
[
"both stamps",
{
firstExecutionAt: "2026-07-26T04:35:29.068Z",
executionStartedAt: "2026-07-26T04:35:29.068Z",
},
],
] as const) {
const { store } = createEventedStore();
const task = createTask({
id: "FN-REPLAN-EXECUTED",
column: "triage",
status: "needs-replan",
// A replan card carries the steps and spec of its previous (rejected) planning pass.
steps: [{ name: "step-1", status: "pending" }],
...stamps,
});
const dir = join(rootDir, ".fusion", "tasks", task.id);
await mkdir(dir, { recursive: true });
await writeFile(
join(dir, "PROMPT.md"),
"# FN-REPLAN-EXECUTED: Idea card\n\n## Mission\n\nRejected spec.\n",
"utf-8",
);
expect(await discover(store, [task]), label).toEqual(["FN-REPLAN-EXECUTED"]);
}
});
it("re-admits a plan-in-place todo replan card that already executed once", async () => {
const { store } = createEventedStore();
const task = createTask({
id: "FN-IDEAS-REPLAN",
column: "todo",
status: "needs-replan",
steps: [{ name: "step-1", status: "pending" }],
firstExecutionAt: "2026-07-26T04:35:29.068Z",
});
const dir = join(rootDir, ".fusion", "tasks", task.id);
await mkdir(dir, { recursive: true });
await writeFile(
join(dir, "PROMPT.md"),
"# FN-IDEAS-REPLAN: Idea card\n\n## Mission\n\nRejected spec.\n",
"utf-8",
);
expect(await discover(store, [task])).toEqual(["FN-IDEAS-REPLAN"]);
});
it("does not admit a todo task that already has a real spec", async () => {
const { store } = createEventedStore();
const task = createTask({ id: "FN-PLANNED", column: "todo", title: "Idea card", description: "desc" });

View File

@@ -56,6 +56,21 @@ export function hasAdvancedPastPlanning(
return true;
}
/*
FNXC:WorkflowReplan 2026-07-26-06:10:
An explicit parked-for-planning STATUS outranks execution evidence, because that evidence is
STICKY while a replan is a legitimate BACKWARD move. `firstExecutionAt`/`executionStartedAt` are
never cleared once implementation starts, so a card that executed, failed Plan Review, and was
rebounded to a planner lane (`needs-replan`) read as "advanced past planning" forever: triage's
discovery filter (`column === "triage" && isTaskStillInPlanningStage`) never re-admitted it and
the card sat in triage/needs-replan permanently — "stuck in planning" on the board (FN-8594). It
hit every triage-column workflow (builtin:coding, the default); plan-in-place Ideas cards escaped
only because todo discovery admits `needs-replan` without consulting this guard.
This check covers BOTH planner lanes — the "triage" column and the plan-in-place "todo" lane.
*/
if (task.status != null && PLANNING_STAGE_STATUSES.has(task.status)) {
return false;
}
/*
FNXC:NodeWorktreeIsolation 2026-07-25-22:40:
A worktree NO LONGER proves an executor claimed the card. Planning acquires the task's own
worktree up front (so no lane runs in the shared checkout), which means a card being planned right
@@ -63,18 +78,19 @@ export function hasAdvancedPastPlanning(
`status:"planning"` never lands, the spec finalization is refused, and the card is re-claimed
forever while occupying a maxTriageConcurrent slot. Execution TIMESTAMPS are the durable evidence
instead; they are written when implementation actually starts, never by worktree acquisition.
A triage card carrying a timestamp with NO planning status is the stranded-advanced class that
self-healing's advanced recovery owns (PR #2360): planning must exclude it so it cannot burn a
maxTriageConcurrent slot in a claim/skip loop.
*/
if (task.firstExecutionAt != null || task.executionStartedAt != null) {
return true;
}
// The planner column itself is never "advanced" — nothing executes out of triage.
// The planner column itself is never "advanced" — nothing executes out of triage, and the steps
// below belong to the card's previous planning pass.
if (task.column === "triage") {
return false;
}
// Plan-in-place planner lane ("todo"): a card explicitly parked for planning has not advanced.
if (task.status != null && PLANNING_STAGE_STATUSES.has(task.status)) {
return false;
}
return (task.steps?.length ?? 0) > 0;
}