From 627d1c9606a691733c41ac6420fde07fb900150c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 29 Apr 2026 11:53:35 -0700 Subject: [PATCH] feat(core,dashboard): allow tasks to be respec'd from in-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `triage` to VALID_TRANSITIONS["in-review"] so the dashboard's `Request AI Revision` and `Rebuild Spec` actions work for in-review tasks. moveTask now applies the same full reset on in-review → triage as on in-review → todo (clears branch/baseBranch/baseCommitSha/summary/ recovery metadata and workflowStepResults) so the respec'd task starts from scratch. The in-review task card's Move menu also gains Planning as a destination. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/respec-from-in-review.md | 13 +++++++ packages/core/src/__tests__/board.test.ts | 2 +- packages/core/src/__tests__/store.test.ts | 39 +++++++++++++++++++ packages/core/src/store.ts | 9 +++-- packages/core/src/types.ts | 2 +- .../app/components/TaskDetailModal.tsx | 4 +- .../dashboard/src/__tests__/routes.test.ts | 23 ++++++----- 7 files changed, 75 insertions(+), 17 deletions(-) create mode 100644 .changeset/respec-from-in-review.md diff --git a/.changeset/respec-from-in-review.md b/.changeset/respec-from-in-review.md new file mode 100644 index 000000000..e2c0cca75 --- /dev/null +++ b/.changeset/respec-from-in-review.md @@ -0,0 +1,13 @@ +--- +"@runfusion/fusion": minor +"runfusion.ai": minor +"@fusion/core": minor +"@fusion/dashboard": minor +"@fusion/desktop": minor +"@fusion/engine": minor +"@fusion/mobile": minor +"@fusion/pi-claude-cli": minor +"@fusion/plugin-sdk": minor +--- + +Allow tasks to be respecified from `in-review`. `VALID_TRANSITIONS["in-review"]` now includes `triage`, so the dashboard's `Request AI Revision` and `Rebuild Spec` actions work for in-review tasks. Moving an in-review task to triage performs the same full reset as in-review → todo (clears branch/baseBranch/baseCommitSha/summary/recovery metadata and workflowStepResults) so the next run starts from scratch. The in-review card's `Move` menu also now offers `Planning` as a destination. diff --git a/packages/core/src/__tests__/board.test.ts b/packages/core/src/__tests__/board.test.ts index 7ba5f4eee..0ab9c31b6 100644 --- a/packages/core/src/__tests__/board.test.ts +++ b/packages/core/src/__tests__/board.test.ts @@ -79,7 +79,7 @@ describe("board", () => { }); it("returns correct transitions for in-review", () => { - expect(getValidTransitions("in-review")).toEqual(["done", "in-progress", "todo"]); + expect(getValidTransitions("in-review")).toEqual(["done", "in-progress", "todo", "triage"]); }); it("returns correct transitions for done", () => { diff --git a/packages/core/src/__tests__/store.test.ts b/packages/core/src/__tests__/store.test.ts index 59af6e3b3..b27d3e6f5 100644 --- a/packages/core/src/__tests__/store.test.ts +++ b/packages/core/src/__tests__/store.test.ts @@ -5991,6 +5991,45 @@ Task with acceptance criteria expect(retried.recoveryRetryCount).toBeUndefined(); expect(retried.nextRecoveryAt).toBeUndefined(); }); + + it("allows respec'ing in-review tasks back to triage and clears transient fields", async () => { + const task = await store.createTask({ description: "test respec in-review task to triage" }); + await store.moveTask(task.id, "todo"); + await store.moveTask(task.id, "in-progress"); + await store.moveTask(task.id, "in-review"); + await store.updateTask(task.id, { + status: "completed", + error: "stale error", + worktree: "stale-worktree", + blockedBy: "FN-456", + branch: "fn/stale-branch", + baseBranch: "main", + baseCommitSha: "abc123", + summary: "stale summary from prior attempt", + recoveryRetryCount: 2, + nextRecoveryAt: new Date().toISOString(), + workflowStepResults: [{ + workflowStepId: "wf-1", + workflowStepName: "Workflow step 1", + status: "passed", + startedAt: new Date().toISOString(), + }], + }); + + const respec = await store.moveTask(task.id, "triage"); + expect(respec.column).toBe("triage"); + expect(respec.status).toBeUndefined(); + expect(respec.error).toBeUndefined(); + expect(respec.worktree).toBeUndefined(); + expect(respec.blockedBy).toBeUndefined(); + expect(respec.workflowStepResults).toBeUndefined(); + expect(respec.branch).toBeUndefined(); + expect(respec.baseBranch).toBeUndefined(); + expect(respec.baseCommitSha).toBeUndefined(); + expect(respec.summary).toBeUndefined(); + expect(respec.recoveryRetryCount).toBeUndefined(); + expect(respec.nextRecoveryAt).toBeUndefined(); + }); }); describe("columnMovedAt", () => { diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 1587268eb..e5e229a2d 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -2640,15 +2640,16 @@ export class TaskStore extends EventEmitter { // Clear workflow step results when reopening from review/completed states. // This ensures fresh workflow step runs on retry if ( - (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "in-progress")) + (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "in-progress" || toColumn === "triage")) || (fromColumn === "done" && (toColumn === "todo" || toColumn === "triage")) ) { task.workflowStepResults = undefined; } - // Full reset when sending an in-review task back to todo: discard prior - // branch/summary/recovery state so the next run starts from scratch. - if (fromColumn === "in-review" && toColumn === "todo") { + // Full reset when sending an in-review task back to todo or triage + // (respec): discard prior branch/summary/recovery state so the next run + // starts from scratch. + if (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "triage")) { task.branch = undefined; task.baseBranch = undefined; task.baseCommitSha = undefined; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 689154200..4451dbb54 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1898,7 +1898,7 @@ export const VALID_TRANSITIONS: Record = { // NOTE: "in-progress" → "done" is enabled for mission validation tasks that complete directly. // Regular implementation tasks should move through "in-review" before "done". "in-progress": ["in-review", "todo", "triage", "done"], - "in-review": ["done", "in-progress", "todo"], + "in-review": ["done", "in-progress", "todo", "triage"], done: ["todo", "triage", "archived"], archived: ["done"], }; diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index f544964b2..73aeff307 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -1188,8 +1188,8 @@ export function TaskDetailModal({ onClose(); } catch (err) { const msg = getErrorMessage(err); - if (msg.includes("in-review") || msg.includes("done")) { - addToast("Cannot request revision: Task must be in 'todo' or 'in-progress' column.", "error"); + if (msg.includes("done") || msg.includes("archived")) { + addToast("Cannot request revision: Task must be in 'triage', 'todo', 'in-progress', or 'in-review' column.", "error"); } else { addToast(msg, "error"); } diff --git a/packages/dashboard/src/__tests__/routes.test.ts b/packages/dashboard/src/__tests__/routes.test.ts index 3e5adaae9..8ee96e703 100644 --- a/packages/dashboard/src/__tests__/routes.test.ts +++ b/packages/dashboard/src/__tests__/routes.test.ts @@ -7824,9 +7824,12 @@ describe("POST /tasks/:id/spec/revise", () => { } }); - it("returns 400 when task is in in-review", async () => { + it("allows spec revision when task is in in-review (in-review can transition to triage)", async () => { const inReviewTask = { ...FAKE_TASK_DETAIL, column: "in-review" as const }; + const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const }; (store.getTask as ReturnType).mockResolvedValue(inReviewTask); + (store.moveTask as ReturnType).mockResolvedValue(movedTask); + (store.updateTask as ReturnType).mockResolvedValue(movedTask); const res = await REQUEST( buildApp(), @@ -7836,10 +7839,9 @@ describe("POST /tasks/:id/spec/revise", () => { { "Content-Type": "application/json" } ); - expect(res.status).toBe(400); - expect(res.body.error).toContain("in-review"); - expect(res.body.error).toContain("Move task to 'todo' or 'in-progress' first"); - expect(store.moveTask).not.toHaveBeenCalled(); + expect(res.status).toBe(200); + expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage"); + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" }); }); it("allows spec revision when task is in done (done can transition to triage)", async () => { @@ -8059,15 +8061,18 @@ describe("POST /tasks/:id/spec/rebuild", () => { } }); - it("returns 400 when task is in in-review (cannot transition to triage)", async () => { + it("allows spec rebuild when task is in in-review (in-review can transition to triage)", async () => { const inReviewTask = { ...FAKE_TASK_DETAIL, column: "in-review" as const }; + const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const }; (store.getTask as ReturnType).mockResolvedValue(inReviewTask); + (store.moveTask as ReturnType).mockResolvedValue(movedTask); + (store.updateTask as ReturnType).mockResolvedValue(movedTask); const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild"); - expect(res.status).toBe(400); - expect(res.body.error).toContain("in-review"); - expect(store.moveTask).not.toHaveBeenCalled(); + expect(res.status).toBe(200); + expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage"); + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" }); }); it("returns 404 when task not found", async () => {