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:
7
.changeset/fn-8148-respecify-fix.md
Normal file
7
.changeset/fn-8148-respecify-fix.md
Normal 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.
|
||||
@@ -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: "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) {
|
||||
actions.push({ id: "retry", label: t("taskDetail.retry.btn", "Retry"), onSelect: options.onRetry });
|
||||
|
||||
@@ -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 })).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", () => {
|
||||
@@ -199,7 +199,7 @@ describe("TaskContextMenu shared task action model", () => {
|
||||
workflowMoveColumns,
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -254,7 +254,7 @@ describe("TaskContextMenu shared task action model", () => {
|
||||
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("reset");
|
||||
archivedModel.actions.find((action) => action.id === "delete")?.onSelect?.();
|
||||
|
||||
@@ -2271,7 +2271,7 @@ describe("POST /tasks/:id/spec/rebuild", () => {
|
||||
"FN-001",
|
||||
"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(existsSync(join(taskDir, "PROMPT.md"))).toBe(false);
|
||||
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");
|
||||
|
||||
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.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");
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -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(existsSync(join(taskDir, "PROMPT.md"))).toBe(false);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" });
|
||||
expect(res.body.status).toBe("needs-replan");
|
||||
} finally {
|
||||
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");
|
||||
|
||||
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.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 () => {
|
||||
const error = new Error("Task not found") as Error & { code?: string };
|
||||
error.code = "ENOENT";
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
isWorkflowColumnsEnabled,
|
||||
resolveWorkflowIrForTask,
|
||||
workflowHasColumn,
|
||||
resolveColumnFlags,
|
||||
TransitionRejectionError,
|
||||
getPlannerInterventionTimeline,
|
||||
isBuiltinWorkflowId,
|
||||
@@ -4260,44 +4261,39 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
// Get current task state
|
||||
const task = await scopedStore.getTask(req.params.id);
|
||||
|
||||
// If task is already in triage, skip the transition check and moveTask.
|
||||
// Just reset for replanning in place.
|
||||
if (task.column === "triage") {
|
||||
// Log the rebuild request
|
||||
await scopedStore.logEntry(task.id, "Specification rebuild requested by user");
|
||||
clearRebuiltSpecWorkflowPins(scopedStore, task.id);
|
||||
|
||||
// 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;
|
||||
const workflowIr = await resolveWorkflowIrForTask(scopedStore, task.id);
|
||||
const currentColumn = "columns" in workflowIr
|
||||
? workflowIr.columns.find((column) => column.id === task.column)
|
||||
: undefined;
|
||||
const isArchived = task.column === "archived" || (currentColumn != null && resolveColumnFlags(currentColumn).archived);
|
||||
if (isArchived) {
|
||||
throw badRequest("Respecify is not available for archived tasks; unarchive first.");
|
||||
}
|
||||
|
||||
// Check if task can transition to triage
|
||||
// #1403: task.column is ColumnId; VALID_TRANSITIONS is keyed by the legacy
|
||||
// closed union. A non-legacy custom column id has no legacy transition row,
|
||||
// so it correctly resolves to "cannot transition" here.
|
||||
const canTransition =
|
||||
isColumn(task.column) && VALID_TRANSITIONS[task.column].includes("triage");
|
||||
if (!canTransition) {
|
||||
throw badRequest(`Cannot rebuild spec for tasks in '${task.column}' column. Move task to a valid column first.`);
|
||||
}
|
||||
/*
|
||||
FNXC:WorkflowReplan 2026-07-16-12:00:
|
||||
Respecify must park work in a planner lane belonging to the task's own workflow:
|
||||
triage when declared, otherwise plan-in-place todo, then legacy triage for workflows
|
||||
with neither. The legacy fallback is intentionally recovery-rehomed: plain moves reject
|
||||
an undeclared triage target as unknown-column (and reject non-adjacent sources), which
|
||||
previously stranded no-triage workflows before their needs-replan status was written.
|
||||
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");
|
||||
clearRebuiltSpecWorkflowPins(scopedStore, task.id);
|
||||
|
||||
// Move to triage for replanning
|
||||
const updated = await scopedStore.moveTask(task.id, "triage");
|
||||
if (task.column !== replanColumn) {
|
||||
await scopedStore.moveTask(task.id, replanColumn, {
|
||||
moveSource: "user",
|
||||
recoveryRehome: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Remove the existing spec so rebuilds produce a fresh PROMPT.md instead
|
||||
// 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
|
||||
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);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
|
||||
Reference in New Issue
Block a user