fix: offer the planning retry for needs-replan Todo cards in triage-less workflows

The task cards already show Retry for needs-replan/planning/failed states, but
the retry route only offered the planning retry when the card sat in "triage",
so plan-in-place workflows (Coding (Ideas) replans in Todo) got a 400 "not in a
retryable state". The retrySpecification gate is now workflow-aware: a Todo card
whose workflow declares no "triage" column takes the planning-retry path;
default-workflow Todo cards keep the generic-retry semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-12 19:51:10 -07:00
parent d4bbbcccc6
commit 0e6f94dbe6
3 changed files with 63 additions and 7 deletions

View File

@@ -2,6 +2,6 @@
"@runfusion/fusion": patch
---
summary: Ideas-intake cards no longer auto-process on restart, replan stays in Todo, and All-workflows shows every card.
summary: Ideas-intake cards no longer auto-process on restart; replan and Retry work from Todo; All-workflows shows every card.
category: fix
dev: Store init now always runs the workflow-aware integrity pass instead of the retired flag-off evacuation (`evacuateCustomColumnsToLegacy` remains toggle-only), with a mis-mapping guard so stale selections are never physically rehomed into auto-triaged lanes; engine replan/stale-spec/fs-validation rebounds resolve `resolveReplanTargetColumn` instead of hardcoding `triage`; `needs-replan` counts as unplanned for hold-release dispatch; triage discovers `needs-replan` todo cards and refinement seed prompts via `isUnplannedSeedPrompt`/`buildRefinementSeedPrompt`; Board's aggregate grouping renders column-orphaned tasks (hidden columns stay hidden) and the FN-7591 refetch also fires on present-but-unrepresentable mappings.

View File

@@ -878,6 +878,47 @@ describe("POST /tasks/:id/retry", () => {
expect(engine.clearTaskPauseAbortState).not.toHaveBeenCalled();
});
/*
FNXC:ManualRetry 2026-07-13-12:25:
Plan-in-place workflows (Coding (Ideas): no "triage" column) keep needs-replan cards in
"todo"; the Retry button the cards already show must map to the planning retry there
instead of a 400. Default-workflow todo cards keep the generic-retry semantics.
*/
it("offers the planning retry for a needs-replan todo card in a workflow without a triage column", async () => {
const replanTask = { ...FAKE_TASK_DETAIL, column: "todo", status: "needs-replan" };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(replanTask);
(store as unknown as Record<string, unknown>).getTaskWorkflowSelection = vi.fn().mockReturnValue({ workflowId: "builtin:coding-ideas", stepIds: [] });
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(replanTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
// Planning-retry semantics: status reset to needs-replan, no column move.
expect(store.updateTask).toHaveBeenCalledWith("KB-001", expect.objectContaining({ status: "needs-replan" }));
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Retry requested from dashboard (planning retry budget reset)");
});
it("keeps generic retry semantics for a needs-replan todo card in the default workflow", async () => {
const replanTask = { ...FAKE_TASK_DETAIL, column: "todo", status: "needs-replan" };
const movedTask = { ...FAKE_TASK_DETAIL, column: "todo", status: undefined };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(replanTask);
(store as unknown as Record<string, unknown>).getTaskWorkflowSelection = vi.fn().mockReturnValue(undefined);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(replanTask);
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
"Content-Type": "application/json",
});
// Default workflow declares "triage": a needs-replan todo card is not a planning
// retry there — and needs-replan alone is not a generic-retryable status.
expect(res.status).toBe(400);
expect(res.body.error).toContain("not in a retryable state");
});
it("retries a failed task in any column (not just in-progress)", async () => {
const failedTaskInTodo = { ...FAKE_TASK_DETAIL, column: "todo", status: "failed" };
const movedTask = { ...FAKE_TASK_DETAIL, column: "todo", status: undefined };

View File

@@ -43,6 +43,8 @@ import {
isEphemeralAgent,
parseExplicitDuplicateMarker,
isWorkflowColumnsEnabled,
resolveWorkflowIrForTask,
workflowHasColumn,
TransitionRejectionError,
getPlannerInterventionTimeline,
isBuiltinWorkflowId,
@@ -2289,12 +2291,25 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
try {
const { store: scopedStore, engine } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
const retrySpecification =
task.column === "triage" &&
(task.status === "failed" ||
task.status === "planning" ||
task.status === "needs-replan" ||
(task.stuckKillCount ?? 0) > 0);
const retrySpecificationStatus =
task.status === "failed" ||
task.status === "planning" ||
task.status === "needs-replan" ||
(task.stuckKillCount ?? 0) > 0;
let retrySpecification = task.column === "triage" && retrySpecificationStatus;
/*
FNXC:ManualRetry 2026-07-13-12:20:
Plan-in-place workflows (Coding (Ideas): no "triage" column) keep planning/replanning
cards in "todo", so the manual Retry button — which the cards already show for
needs-replan/planning/failed states — must offer the planning retry there too instead
of 400ing with "not in a retryable state". Gated on the task's OWN workflow declaring
no "triage" column, so default-workflow todo cards (where todo failures are execution
failures) keep the existing generic-retry semantics.
*/
if (!retrySpecification && task.column === "todo" && retrySpecificationStatus) {
const workflowIr = await resolveWorkflowIrForTask(scopedStore, task.id);
retrySpecification = !workflowHasColumn(workflowIr, "triage");
}
const isInReviewStatusNone =
task.column === "in-review" && (task.status === null || task.status === undefined);
const hasIncompleteSteps = task.steps.some(