FN-8148: fix Respecify workflow replanning

Make Respecify reliably return tasks to their workflow's planning lane.

- Resolve task-specific planner lanes with recovery rehoming for custom workflows
- Return the persisted needs-replan task for in-place replans and cover legacy triage
- Hide unsupported Respecify actions for archived tasks

Files changed:
 .changeset/fn-8148-respecify-fix.md                |   7 ++
 .../dashboard/app/components/TaskContextMenu.tsx   |  10 +-
 .../components/__tests__/TaskContextMenu.test.tsx  |   6 +-
 .../dashboard/src/__tests__/routes-github.test.ts  | 103 ++++++++++++++++++++-
 .../src/routes/register-task-workflow-routes.ts    |  66 ++++++-------
 5 files changed, 152 insertions(+), 40 deletions(-)

Fusion-Task-Id: FN-8148

Fusion-Task-Lineage: 7184bd58-e841-4bb1-a7fa-c1c336d6270a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 19:57:17 -07:00
parent 26cb0cc2e1
commit aaa95305cb
5 changed files with 152 additions and 40 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Make Respecify replan tasks across workflow board layouts.
category: fix
dev: Resolves workflow planner lanes with recovery rehome and hides the unsupported archived action.

View File

@@ -264,7 +264,15 @@ export function buildTaskActionMenuModel(options: BuildTaskActionMenuModelOption
actions.push({ id: "refine", label: t("taskDetail.refine.btn", "Refine"), onSelect: options.onOpenRefine }); actions.push({ id: "refine", label: t("taskDetail.refine.btn", "Refine"), onSelect: options.onOpenRefine });
} }
actions.push({ id: "respecify", label: t("taskDetail.respecify.btn", "Respecify"), onSelect: options.onRespecify }); /*
FNXC:TaskContextMenu 2026-07-16-12:00:
Archived is an unsupported Respecify source: the rebuild route rejects it rather than
resurrecting intentionally archived work into a planner lane. Check both the semantic
workflow trait and legacy id so every menu host omits this dead affordance.
*/
if (task.column !== "archived" && currentColumnFlags?.archived !== true) {
actions.push({ id: "respecify", label: t("taskDetail.respecify.btn", "Respecify"), onSelect: options.onRespecify });
}
if (canRetryTask && hasRetryHandler) { if (canRetryTask && hasRetryHandler) {
actions.push({ id: "retry", label: t("taskDetail.retry.btn", "Retry"), onSelect: options.onRetry }); actions.push({ id: "retry", label: t("taskDetail.retry.btn", "Retry"), onSelect: options.onRetry });

View File

@@ -45,7 +45,7 @@ describe("TaskContextMenu shared task action model", () => {
]); ]);
expect(actionIds(makeTask({ column: "done" }), { hasResetHandler: true, onOpenRefine: vi.fn() })).toEqual(["refine", "respecify", "delete"]); expect(actionIds(makeTask({ column: "done" }), { hasResetHandler: true, onOpenRefine: vi.fn() })).toEqual(["refine", "respecify", "delete"]);
expect(actionIds(makeTask({ column: "done" }), { hasResetHandler: true })).toEqual(["respecify", "delete"]); expect(actionIds(makeTask({ column: "done" }), { hasResetHandler: true })).toEqual(["respecify", "delete"]);
expect(actionIds(makeTask({ column: "archived" }), { hasResetHandler: true })).toEqual(["respecify", "delete"]); expect(actionIds(makeTask({ column: "archived" }), { hasResetHandler: true })).toEqual(["delete"]);
}); });
it("exposes Plan only for pre-execution hold columns with a host callback", () => { it("exposes Plan only for pre-execution hold columns with a host callback", () => {
@@ -199,7 +199,7 @@ describe("TaskContextMenu shared task action model", () => {
workflowMoveColumns, workflowMoveColumns,
hasResetHandler: true, hasResetHandler: true,
}); });
expect(archivedModel.actions.map((action) => action.id)).toEqual(["respecify", "delete"]); expect(archivedModel.actions.map((action) => action.id)).toEqual(["delete"]);
expect(archivedModel.actions.map((action) => action.id)).not.toContain("reset"); expect(archivedModel.actions.map((action) => action.id)).not.toContain("reset");
}); });
@@ -254,7 +254,7 @@ describe("TaskContextMenu shared task action model", () => {
onDelete, onDelete,
}); });
expect(archivedModel.actions.map((action) => action.id)).toEqual(["respecify", "delete"]); expect(archivedModel.actions.map((action) => action.id)).toEqual(["delete"]);
expect(archivedModel.actions.map((action) => action.id)).not.toContain("pause"); expect(archivedModel.actions.map((action) => action.id)).not.toContain("pause");
expect(archivedModel.actions.map((action) => action.id)).not.toContain("reset"); expect(archivedModel.actions.map((action) => action.id)).not.toContain("reset");
archivedModel.actions.find((action) => action.id === "delete")?.onSelect?.(); archivedModel.actions.find((action) => action.id === "delete")?.onSelect?.();

View File

@@ -2271,7 +2271,7 @@ describe("POST /tasks/:id/spec/rebuild", () => {
"FN-001", "FN-001",
"Specification rebuild requested by user" "Specification rebuild requested by user"
); );
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage"); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage", { moveSource: "user", recoveryRehome: true });
expect((store as unknown as { clearWorkflowRunStepInstances: ReturnType<typeof vi.fn> }).clearWorkflowRunStepInstances).toHaveBeenCalledWith("FN-001"); expect((store as unknown as { clearWorkflowRunStepInstances: ReturnType<typeof vi.fn> }).clearWorkflowRunStepInstances).toHaveBeenCalledWith("FN-001");
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false); expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" }); expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" });
@@ -2291,7 +2291,7 @@ describe("POST /tasks/:id/spec/rebuild", () => {
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild"); const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage"); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage", { moveSource: "user", recoveryRehome: true });
expect((store as unknown as { clearWorkflowRunStepInstances: ReturnType<typeof vi.fn> }).clearWorkflowRunStepInstances).toHaveBeenCalledWith("FN-001"); expect((store as unknown as { clearWorkflowRunStepInstances: ReturnType<typeof vi.fn> }).clearWorkflowRunStepInstances).toHaveBeenCalledWith("FN-001");
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" }); expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" });
}); });
@@ -2307,7 +2307,7 @@ describe("POST /tasks/:id/spec/rebuild", () => {
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild"); const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage"); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage", { moveSource: "user", recoveryRehome: true });
expect((store as unknown as { clearWorkflowRunStepInstances: ReturnType<typeof vi.fn> }).clearWorkflowRunStepInstances).toHaveBeenCalledWith("FN-001"); expect((store as unknown as { clearWorkflowRunStepInstances: ReturnType<typeof vi.fn> }).clearWorkflowRunStepInstances).toHaveBeenCalledWith("FN-001");
}); });
@@ -2337,6 +2337,7 @@ describe("POST /tasks/:id/spec/rebuild", () => {
expect((store as unknown as { clearWorkflowRunStepInstances: ReturnType<typeof vi.fn> }).clearWorkflowRunStepInstances).toHaveBeenCalledWith("FN-001"); expect((store as unknown as { clearWorkflowRunStepInstances: ReturnType<typeof vi.fn> }).clearWorkflowRunStepInstances).toHaveBeenCalledWith("FN-001");
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false); expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" }); expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" });
expect(res.body.status).toBe("needs-replan");
} finally { } finally {
rmSync(tempRoot, { recursive: true, force: true }); rmSync(tempRoot, { recursive: true, force: true });
} }
@@ -2352,11 +2353,105 @@ describe("POST /tasks/:id/spec/rebuild", () => {
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild"); const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage"); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage", { moveSource: "user", recoveryRehome: true });
expect((store as unknown as { clearWorkflowRunStepInstances: ReturnType<typeof vi.fn> }).clearWorkflowRunStepInstances).toHaveBeenCalledWith("FN-001"); expect((store as unknown as { clearWorkflowRunStepInstances: ReturnType<typeof vi.fn> }).clearWorkflowRunStepInstances).toHaveBeenCalledWith("FN-001");
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" }); expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" });
}); });
function selectWorkflow(columns: Array<{ id: string; traits?: Array<{ trait: string }> }>) {
const workflowIr = {
version: 2,
columns: columns.map((column) => ({ name: column.id, traits: [], ...column })),
nodes: [],
edges: [],
};
(store as unknown as { getTaskWorkflowSelection: ReturnType<typeof vi.fn> }).getTaskWorkflowSelection = vi.fn()
.mockReturnValue({ workflowId: "WF-rebuild-test" });
(store as unknown as { getWorkflowDefinition: ReturnType<typeof vi.fn> }).getWorkflowDefinition = vi.fn()
.mockResolvedValue({ id: "WF-rebuild-test", ir: workflowIr });
}
it("rebuilds a no-triage/no-todo workflow through legacy triage recovery rehome", async () => {
const customTask = { ...FAKE_TASK_DETAIL, column: "publish" as any };
const movedTask = { ...customTask, column: "triage" as any };
const tempRoot = mkdtempSync(join(tmpdir(), "kb-spec-rebuild-legacy-rehome-"));
const taskDir = join(tempRoot, ".fusion", "tasks", "FN-001");
mkdirSync(taskDir, { recursive: true });
writeFileSync(join(taskDir, "PROMPT.md"), "# stale spec\n");
selectWorkflow([{ id: "publish" }]);
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(customTask);
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
(store.getRootDir as ReturnType<typeof vi.fn>).mockReturnValue(tempRoot);
try {
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
expect(res.status).toBe(200);
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage", { moveSource: "user", recoveryRehome: true });
expect((store as unknown as { clearWorkflowRunStepInstances: ReturnType<typeof vi.fn> }).clearWorkflowRunStepInstances).toHaveBeenCalledWith("FN-001");
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" });
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
});
it("returns the updated task when a plan-in-place workflow is already in todo", async () => {
const todoTask = { ...FAKE_TASK_DETAIL, column: "todo" as any };
const updatedTask = { ...todoTask, status: "needs-replan" as const };
selectWorkflow([{ id: "todo" }, { id: "in-review" }]);
(store.getTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(todoTask)
.mockResolvedValueOnce(updatedTask);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(updatedTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
expect(res.status).toBe(200);
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" });
expect(res.body.status).toBe("needs-replan");
});
it("rebuilds a plan-in-place workflow into todo", async () => {
const customTask = { ...FAKE_TASK_DETAIL, column: "in-review" as any };
const movedTask = { ...customTask, column: "todo" as any };
selectWorkflow([{ id: "todo" }, { id: "in-review" }]);
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(customTask);
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
expect(res.status).toBe(200);
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { moveSource: "user", recoveryRehome: true });
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" });
});
it("rejects legacy and semantic archived tasks before rebuilding", async () => {
const tempRoot = mkdtempSync(join(tmpdir(), "kb-spec-rebuild-archived-"));
const taskDir = join(tempRoot, ".fusion", "tasks", "FN-001");
mkdirSync(taskDir, { recursive: true });
writeFileSync(join(taskDir, "PROMPT.md"), "# retained spec\n");
const archivedTask = { ...FAKE_TASK_DETAIL, column: "cold-storage" as any };
selectWorkflow([{ id: "cold-storage", traits: [{ trait: "archived" }] }]);
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(archivedTask);
(store.getRootDir as ReturnType<typeof vi.fn>).mockReturnValue(tempRoot);
try {
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
expect(res.status).toBe(400);
expect(res.body.error).toContain("not available for archived tasks");
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalled();
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(true);
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
});
it("returns 404 when task not found", async () => { it("returns 404 when task not found", async () => {
const error = new Error("Task not found") as Error & { code?: string }; const error = new Error("Task not found") as Error & { code?: string };
error.code = "ENOENT"; error.code = "ENOENT";

View File

@@ -45,6 +45,7 @@ import {
isWorkflowColumnsEnabled, isWorkflowColumnsEnabled,
resolveWorkflowIrForTask, resolveWorkflowIrForTask,
workflowHasColumn, workflowHasColumn,
resolveColumnFlags,
TransitionRejectionError, TransitionRejectionError,
getPlannerInterventionTimeline, getPlannerInterventionTimeline,
isBuiltinWorkflowId, isBuiltinWorkflowId,
@@ -4260,44 +4261,39 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
// Get current task state // Get current task state
const task = await scopedStore.getTask(req.params.id); const task = await scopedStore.getTask(req.params.id);
// If task is already in triage, skip the transition check and moveTask. const workflowIr = await resolveWorkflowIrForTask(scopedStore, task.id);
// Just reset for replanning in place. const currentColumn = "columns" in workflowIr
if (task.column === "triage") { ? workflowIr.columns.find((column) => column.id === task.column)
// Log the rebuild request : undefined;
await scopedStore.logEntry(task.id, "Specification rebuild requested by user"); const isArchived = task.column === "archived" || (currentColumn != null && resolveColumnFlags(currentColumn).archived);
clearRebuiltSpecWorkflowPins(scopedStore, task.id); if (isArchived) {
throw badRequest("Respecify is not available for archived tasks; unarchive first.");
// Remove the existing spec so rebuilds produce a fresh PROMPT.md instead
// of asking triage to revise whatever was already on disk.
const { rm } = await import("node:fs/promises");
const { join } = await import("node:path");
const promptPath = join(scopedStore.getRootDir(), ".fusion", "tasks", task.id, "PROMPT.md");
await rm(promptPath, { force: true });
// Update status to indicate needs replanning
await scopedStore.updateTask(task.id, { status: "needs-replan" });
const updated = await scopedStore.getTask(task.id);
res.json(updated);
return;
} }
// Check if task can transition to triage /*
// #1403: task.column is ColumnId; VALID_TRANSITIONS is keyed by the legacy FNXC:WorkflowReplan 2026-07-16-12:00:
// closed union. A non-legacy custom column id has no legacy transition row, Respecify must park work in a planner lane belonging to the task's own workflow:
// so it correctly resolves to "cannot transition" here. triage when declared, otherwise plan-in-place todo, then legacy triage for workflows
const canTransition = with neither. The legacy fallback is intentionally recovery-rehomed: plain moves reject
isColumn(task.column) && VALID_TRANSITIONS[task.column].includes("triage"); an undeclared triage target as unknown-column (and reject non-adjacent sources), which
if (!canTransition) { previously stranded no-triage workflows before their needs-replan status was written.
throw badRequest(`Cannot rebuild spec for tasks in '${task.column}' column. Move task to a valid column first.`); Archived cards are rejected above rather than resurrected into a planner lane.
} */
const replanColumn = workflowHasColumn(workflowIr, "triage")
? "triage"
: workflowHasColumn(workflowIr, "todo")
? "todo"
: "triage";
// Log the rebuild request
await scopedStore.logEntry(task.id, "Specification rebuild requested by user"); await scopedStore.logEntry(task.id, "Specification rebuild requested by user");
clearRebuiltSpecWorkflowPins(scopedStore, task.id); clearRebuiltSpecWorkflowPins(scopedStore, task.id);
// Move to triage for replanning if (task.column !== replanColumn) {
const updated = await scopedStore.moveTask(task.id, "triage"); await scopedStore.moveTask(task.id, replanColumn, {
moveSource: "user",
recoveryRehome: true,
});
}
// Remove the existing spec so rebuilds produce a fresh PROMPT.md instead // Remove the existing spec so rebuilds produce a fresh PROMPT.md instead
// of asking triage to revise whatever was already on disk. // of asking triage to revise whatever was already on disk.
@@ -4309,6 +4305,12 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
// Update status to indicate needs replanning // Update status to indicate needs replanning
await scopedStore.updateTask(task.id, { status: "needs-replan" }); await scopedStore.updateTask(task.id, { status: "needs-replan" });
/*
FNXC:WorkflowReplan 2026-07-16-12:00:
Respecify responses must re-read the persisted task after setting needs-replan so
planner-lane-in-place requests, including legacy triage, never return stale status.
*/
const updated = await scopedStore.getTask(task.id);
res.json(updated); res.json(updated);
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) { if (err instanceof ApiError) {