diff --git a/.changeset/replan-card-stuck-in-planning.md b/.changeset/replan-card-stuck-in-planning.md new file mode 100644 index 0000000000..e0e23325ac --- /dev/null +++ b/.changeset/replan-card-stuck-in-planning.md @@ -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). diff --git a/packages/engine/src/__tests__/replan-target.test.ts b/packages/engine/src/__tests__/replan-target.test.ts index 3506e32e4b..20e7bb11f0 100644 --- a/packages/engine/src/__tests__/replan-target.test.ts +++ b/packages/engine/src/__tests__/replan-target.test.ts @@ -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: Pick + & Partial>; 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", () => { diff --git a/packages/engine/src/__tests__/triage-planning-wake.test.ts b/packages/engine/src/__tests__/triage-planning-wake.test.ts index fe875d8b82..4146ca2410 100644 --- a/packages/engine/src/__tests__/triage-planning-wake.test.ts +++ b/packages/engine/src/__tests__/triage-planning-wake.test.ts @@ -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" }); diff --git a/packages/engine/src/replan-target.ts b/packages/engine/src/replan-target.ts index c9b71f1645..42794330cc 100644 --- a/packages/engine/src/replan-target.ts +++ b/packages/engine/src/replan-target.ts @@ -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; }