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:
7
.changeset/replan-card-stuck-in-planning.md
Normal file
7
.changeset/replan-card-stuck-in-planning.md
Normal 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).
|
||||||
@@ -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
|
every parked-for-planning status, and the advancement signals that must still fire
|
||||||
(worktree, execution/terminal columns, planned-and-queued todo cards).
|
(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 = {
|
type PlanningGuardCase = {
|
||||||
label: string;
|
label: string;
|
||||||
task: Pick<Task, "column" | "worktree" | "steps" | "status">;
|
task: Pick<Task, "column" | "worktree" | "steps" | "status">
|
||||||
|
& Partial<Pick<Task, "firstExecutionAt" | "executionStartedAt">>;
|
||||||
stillPlanning: boolean;
|
stillPlanning: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,6 +106,72 @@ const planningGuardCases: PlanningGuardCase[] = [
|
|||||||
task: { column: "in-progress", steps: [planStep("step-1")], status: "needs-replan" },
|
task: { column: "in-progress", steps: [planStep("step-1")], status: "needs-replan" },
|
||||||
stillPlanning: false,
|
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", () => {
|
describe("planning-stage guard", () => {
|
||||||
|
|||||||
@@ -284,6 +284,68 @@ describe("TriageProcessor planning discovery: missing PROMPT.md", () => {
|
|||||||
expect(await discover(store, [task])).toEqual(["FN-REFINE"]);
|
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 () => {
|
it("does not admit a todo task that already has a real spec", async () => {
|
||||||
const { store } = createEventedStore();
|
const { store } = createEventedStore();
|
||||||
const task = createTask({ id: "FN-PLANNED", column: "todo", title: "Idea card", description: "desc" });
|
const task = createTask({ id: "FN-PLANNED", column: "todo", title: "Idea card", description: "desc" });
|
||||||
|
|||||||
@@ -56,6 +56,21 @@ export function hasAdvancedPastPlanning(
|
|||||||
return true;
|
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:
|
FNXC:NodeWorktreeIsolation 2026-07-25-22:40:
|
||||||
A worktree NO LONGER proves an executor claimed the card. Planning acquires the task's own
|
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
|
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
|
`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
|
forever while occupying a maxTriageConcurrent slot. Execution TIMESTAMPS are the durable evidence
|
||||||
instead; they are written when implementation actually starts, never by worktree acquisition.
|
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) {
|
if (task.firstExecutionAt != null || task.executionStartedAt != null) {
|
||||||
return true;
|
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") {
|
if (task.column === "triage") {
|
||||||
return false;
|
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;
|
return (task.steps?.length ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user