feat(core,dashboard): allow tasks to be respec'd from in-review

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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-29 11:53:35 -07:00
parent 601e206f74
commit 627d1c9606
7 changed files with 75 additions and 17 deletions

View File

@@ -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.

View File

@@ -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", () => {

View File

@@ -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", () => {

View File

@@ -2640,15 +2640,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// 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;

View File

@@ -1898,7 +1898,7 @@ export const VALID_TRANSITIONS: Record<Column, Column[]> = {
// 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"],
};

View File

@@ -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");
}

View File

@@ -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<typeof vi.fn>).mockResolvedValue(inReviewTask);
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
(store.updateTask as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(inReviewTask);
(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(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 () => {