feat(FN-2604): merge fusion/fn-2604 (auto-resolved)

- feat(FN-2604): complete Step 8 — add changeset and delivery docs
- test(FN-2604): complete Step 7 — align tests and regenerate extension skill docs
- test(FN-2604): complete Step 6 — update dashboard tests for planning statuses
- feat(FN-2604): complete Step 5 — update CLI planning terminology
- feat(FN-2604): complete Step 4 — update workflow route status strings
- feat(FN-2604): complete Step 3 — update settings planning terminology
- feat(FN-2604): complete Step 2 — rename replanning actions in column and spec editor
- feat(FN-2604): complete Step 1 — update dashboard component status strings
This commit is contained in:
Fusion
2026-04-26 14:06:48 -07:00
committed by gsxdsm
parent 651ae34307
commit 03a48ae9bb
14 changed files with 97 additions and 92 deletions

View File

@@ -70,7 +70,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
const [dragOver, setDragOver] = useState(false);
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isRespecifying, setIsRespecifying] = useState(false);
const [isReplanning, setIsReplanning] = useState(false);
const [isPausingAll, setIsPausingAll] = useState(false);
const [isMovingAllToTodo, setIsMovingAllToTodo] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
@@ -161,16 +161,16 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
setVisibleTaskCount((current) => Math.min(current + VISIBLE_TASKS_INCREMENT, tasks.length));
}, [tasks.length]);
const handleRespecifyAll = useCallback(async () => {
const handleReplanAll = useCallback(async () => {
setIsMenuOpen(false);
if (tasks.length === 0) return;
const confirmed = window.confirm(
`Move all ${tasks.length} todo task${tasks.length === 1 ? "" : "s"} back to triage to be respecified?`,
`Move all ${tasks.length} todo task${tasks.length === 1 ? "" : "s"} back to planning to be replanned?`,
);
if (!confirmed) return;
setIsRespecifying(true);
setIsReplanning(true);
try {
// Issue moves in parallel — onMoveTask is per-task, no bulk endpoint.
const results = await Promise.allSettled(
@@ -179,19 +179,19 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
const failed = results.filter((r) => r.status === "rejected").length;
const moved = results.length - failed;
if (failed === 0) {
addToast(`Moved ${moved} task${moved === 1 ? "" : "s"} to triage for respec`, "success");
addToast(`Moved ${moved} task${moved === 1 ? "" : "s"} to planning for replanning`, "success");
} else {
addToast(`Moved ${moved} of ${results.length} tasks; ${failed} failed`, "error");
}
} finally {
setIsRespecifying(false);
setIsReplanning(false);
}
}, [tasks, onMoveTask, addToast]);
const pauseEligibleTasks = useMemo(() => tasks.filter((task) => !task.paused), [tasks]);
const pauseEligibleCount = pauseEligibleTasks.length;
const hasColumnBulkActions = column === "todo" || column === "in-progress" || column === "in-review";
const isMenuBusy = isRespecifying || isPausingAll || isMovingAllToTodo;
const isMenuBusy = isReplanning || isPausingAll || isMovingAllToTodo;
const handlePauseAll = useCallback(async () => {
if (!onPauseTask) return;
@@ -332,12 +332,12 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
type="button"
role="menuitem"
className="column-menu-item"
onClick={() => void handleRespecifyAll()}
disabled={tasks.length === 0 || isRespecifying}
onClick={() => void handleReplanAll()}
disabled={tasks.length === 0 || isReplanning}
>
Respecify All
Replan All
<span className="column-menu-item-hint">
Move {tasks.length} task{tasks.length === 1 ? "" : "s"} to Triage
Move {tasks.length} task{tasks.length === 1 ? "" : "s"} to Planning
</span>
</button>
)}

View File

@@ -22,7 +22,7 @@ const COLUMN_COLOR_MAP: Record<Column, string> = {
archived: "var(--text-secondary)",
};
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]);
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging"]);
type SortField = "id" | "title" | "status" | "column";
type SortDirection = "asc" | "desc";

View File

@@ -882,7 +882,7 @@ export function SettingsModal({
globalModelKey: "planningGlobalModelId",
projectProviderKey: "planningProvider",
projectModelKey: "planningModelId",
helperText: "AI model used for task specification (triage).",
helperText: "AI model used for task planning.",
fallbackOrder: "Project override → Global planning lane → Global default lane → Automatic resolution",
},
{
@@ -2102,7 +2102,7 @@ export function SettingsModal({
setForm((f) => ({ ...f, maxTriageConcurrent: val === "" ? undefined : Number(val) } as SettingsFormState));
}}
/>
<small>Maximum concurrent triage/specification agents</small>
<small>Maximum concurrent planning agents</small>
</div>
<div className="form-group">
<label htmlFor="pollIntervalMs">Poll Interval (ms)</label>
@@ -2144,9 +2144,9 @@ export function SettingsModal({
setForm((f) => ({ ...f, specStalenessEnabled: e.target.checked }))
}
/>
Enable specification staleness enforcement
Enable plan staleness enforcement
</label>
<small>When enabled, tasks with stale specifications (PROMPT.md older than the threshold) are automatically sent back to triage for re-specification</small>
<small>When enabled, tasks with stale plans (PROMPT.md older than the threshold) are automatically sent back to planning for replanning</small>
</div>
<div className="form-group">
<label htmlFor="specStalenessMaxAgeMs">Stale Spec Threshold (hours)</label>
@@ -2163,7 +2163,7 @@ export function SettingsModal({
}}
disabled={!form.specStalenessEnabled}
/>
<small>Maximum age in hours before a specification is considered stale. Default: 6 hours.</small>
<small>Maximum age in hours before a plan is considered stale. Default: 6 hours.</small>
</div>
<div className="form-group">
<label htmlFor="autoArchiveDoneTasksEnabled" className="checkbox-label">

View File

@@ -163,7 +163,7 @@ export function SpecEditor({
<div className="spec-editor-revision">
<h4>Ask AI to Revise</h4>
<p className="spec-editor-revision-help">
Provide feedback for the AI to improve this specification. The task will move to triage for re-specification.
Provide feedback for the AI to improve this specification. The task will move to planning for replanning.
</p>
<textarea
ref={feedbackRef}

View File

@@ -81,7 +81,7 @@ function abbreviateBadge(text: string, max: number): string {
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]);
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging"]);
const COLUMN_PROGRESS_COLOR_MAP: Record<Column, string> = {
triage: "var(--triage)",

View File

@@ -299,8 +299,8 @@ export function TaskDetailModal({
const canRetryTask =
task.status === "failed" ||
task.status === "stuck-killed" ||
task.status === "specifying" ||
task.status === "needs-respecify" ||
task.status === "planning" ||
task.status === "needs-replan" ||
(task.stuckKillCount ?? 0) > 0 ||
(task.recoveryRetryCount ?? 0) > 0 ||
Boolean(task.nextRecoveryAt);
@@ -903,7 +903,7 @@ export function TaskDetailModal({
if (!confirm("Reject this plan? The specification will be discarded and regenerated.")) return;
try {
await rejectPlan(task.id, projectId);
addToast(`Plan rejected — ${task.id} returned to Triage for re-specification`, "info");
addToast(`Plan rejected — ${task.id} returned to Planning for replanning`, "info");
onClose();
} catch (err) {
addToast(getErrorMessage(err), "error");
@@ -911,11 +911,11 @@ export function TaskDetailModal({
}, [task.id, onClose, addToast]);
const handleRespecify = useCallback(async () => {
if (!confirm("Rebuild the specification for this task? The task will move to triage for re-specification.")) return;
if (!confirm("Rebuild the plan for this task? The task will move to planning for replanning.")) return;
try {
await rebuildTaskSpec(task.id, projectId);
onClose();
addToast(`Respecifying ${task.id}...`, "info");
addToast(`Replanning ${task.id}...`, "info");
} catch (err) {
addToast(getErrorMessage(err), "error");
}
@@ -1152,8 +1152,8 @@ export function TaskDetailModal({
setIsRequestingRevision(true);
try {
await requestSpecRevision(task.id, feedback, projectId);
addToast("AI revision requested. Task moved to triage.", "success");
// Task has been moved to triage, close modal
addToast("AI revision requested. Task moved to planning.", "success");
// Task has been moved to planning, close modal
onClose();
} catch (err) {
const msg = getErrorMessage(err);
@@ -1730,7 +1730,7 @@ export function TaskDetailModal({
<div className="spec-editor-revision">
<h4>Ask AI to Revise</h4>
<p className="spec-editor-revision-help">
Provide feedback for the AI to improve this specification. The task will move to triage for re-specification.
Provide feedback for the AI to improve this specification. The task will move to planning for replanning.
</p>
<textarea
className="spec-editor-feedback"

View File

@@ -374,10 +374,10 @@ describe("TaskDetailModal", () => {
expect(retryButtons).toHaveLength(1);
});
it("shows Retry for a stranded specifying triage task", () => {
it("shows Retry for a stranded planning triage task", () => {
render(
<TaskDetailModal
task={makeTask({ column: "triage", status: "specifying", stuckKillCount: 6 })}
task={makeTask({ column: "triage", status: "planning", stuckKillCount: 6 })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
@@ -2992,7 +2992,7 @@ describe("TaskDetailModal", () => {
await waitFor(() => {
expect(requestSpecRevision).toHaveBeenCalledWith("FN-099", "Please add more error handling details", undefined);
expect(addToast).toHaveBeenCalledWith("AI revision requested. Task moved to triage.", "success");
expect(addToast).toHaveBeenCalledWith("AI revision requested. Task moved to planning.", "success");
expect(onClose).toHaveBeenCalled();
});
});
@@ -3219,7 +3219,7 @@ describe("TaskDetailModal", () => {
<TaskDetailModal
task={makeTask({
column: "triage",
status: "specifying",
status: "planning",
prompt: "# Task Spec",
})}
onClose={noop}
@@ -3325,7 +3325,7 @@ describe("TaskDetailModal", () => {
expect(mockRejectPlan).toHaveBeenCalledWith("FN-001", undefined);
});
expect(addToast).toHaveBeenCalledWith(
"Plan rejected — FN-001 returned to Triage for re-specification",
"Plan rejected — FN-001 returned to Planning for replanning",
"info"
);
expect(onClose).toHaveBeenCalled();

View File

@@ -1946,30 +1946,30 @@ describe("POST /tasks/:id/retry", () => {
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Retry requested from dashboard (stuck kill budget reset)");
});
it("retries a stranded specifying triage task in triage and removes stale prompt", async () => {
it("retries a stranded planning triage task in triage and removes stale prompt", async () => {
const tempRoot = mkdtempSync(join(tmpdir(), "kb-task-retry-spec-"));
const taskDir = join(tempRoot, ".fusion", "tasks", "FN-001");
mkdirSync(taskDir, { recursive: true });
writeFileSync(join(taskDir, "PROMPT.md"), "# stale spec\n");
const specifyingTask = {
const planningTask = {
...FAKE_TASK_DETAIL,
column: "triage" as const,
status: "specifying",
status: "planning",
stuckKillCount: 6,
recoveryRetryCount: 2,
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
};
const retriedTask = {
...specifyingTask,
status: "needs-respecify",
...planningTask,
status: "needs-replan",
stuckKillCount: 0,
recoveryRetryCount: undefined,
nextRecoveryAt: undefined,
};
(store.getTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(specifyingTask)
.mockResolvedValueOnce(planningTask)
.mockResolvedValueOnce(retriedTask);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(retriedTask);
(store.getRootDir as ReturnType<typeof vi.fn>).mockReturnValue(tempRoot);
@@ -1981,7 +1981,7 @@ describe("POST /tasks/:id/retry", () => {
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
status: "needs-respecify",
status: "needs-replan",
error: null,
worktree: null,
branch: null,
@@ -1995,9 +1995,9 @@ describe("POST /tasks/:id/retry", () => {
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false);
expect(store.logEntry).toHaveBeenCalledWith(
"KB-001",
"Retry requested from dashboard (specification retry budget reset)",
"Retry requested from dashboard (planning retry budget reset)",
);
expect(res.body.status).toBe("needs-respecify");
expect(res.body.status).toBe("needs-replan");
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
@@ -6916,7 +6916,7 @@ describe("POST /tasks/:id/spec/revise", () => {
);
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-respecify" });
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" });
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
@@ -6944,7 +6944,7 @@ describe("POST /tasks/:id/spec/revise", () => {
it("allows spec revision for task already in triage", async () => {
const triageTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
const updatedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "needs-respecify" as const };
const updatedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "needs-replan" as const };
const tempRoot = mkdtempSync(join(tmpdir(), "kb-spec-revise-triage-"));
const taskDir = join(tempRoot, ".fusion", "tasks", "FN-001");
mkdirSync(taskDir, { recursive: true });
@@ -6973,7 +6973,7 @@ describe("POST /tasks/:id/spec/revise", () => {
);
expect(store.moveTask).not.toHaveBeenCalled();
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-respecify" });
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" });
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
@@ -7149,7 +7149,7 @@ describe("POST /tasks/:id/spec/rebuild", () => {
);
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-respecify" });
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" });
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
@@ -7167,7 +7167,7 @@ describe("POST /tasks/:id/spec/rebuild", () => {
expect(res.status).toBe(200);
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-respecify" });
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" });
});
it("rebuilds spec and moves task from done to triage", async () => {
@@ -7186,7 +7186,7 @@ describe("POST /tasks/:id/spec/rebuild", () => {
it("allows rebuild for task already in triage", async () => {
const triageTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
const updatedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "needs-respecify" as const };
const updatedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "needs-replan" as const };
const tempRoot = mkdtempSync(join(tmpdir(), "kb-spec-rebuild-triage-"));
const taskDir = join(tempRoot, ".fusion", "tasks", "FN-001");
mkdirSync(taskDir, { recursive: true });
@@ -7208,7 +7208,7 @@ describe("POST /tasks/:id/spec/rebuild", () => {
);
expect(store.moveTask).not.toHaveBeenCalled();
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-respecify" });
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-replan" });
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
@@ -7288,7 +7288,7 @@ describe("POST /tasks/:id/approve-plan", () => {
});
it("returns 400 when task does not have awaiting-approval status", async () => {
const triageTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "specifying" as const };
const triageTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "planning" as const };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(triageTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/approve-plan");
@@ -7364,7 +7364,7 @@ describe("POST /tasks/:id/reject-plan", () => {
});
it("returns 400 when task does not have awaiting-approval status", async () => {
const triageTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "specifying" as const };
const triageTask = { ...FAKE_TASK_DETAIL, column: "triage" as const, status: "planning" as const };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(triageTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/reject-plan");

View File

@@ -241,7 +241,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
});
// Retry failed, stuck-killed, or stranded triage/specification task
// Retry failed, stuck-killed, or stranded triage/planning task
router.post("/tasks/:id/retry", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
@@ -249,14 +249,14 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
const retrySpecification =
task.column === "triage" &&
(task.status === "failed" ||
task.status === "specifying" ||
task.status === "needs-respecify" ||
task.status === "planning" ||
task.status === "needs-replan" ||
(task.stuckKillCount ?? 0) > 0);
if (task.status !== "failed" && task.status !== "stuck-killed" && !retrySpecification) {
throw badRequest(`Task is not in a retryable state (current status: ${task.status || 'none'})`);
}
await scopedStore.updateTask(req.params.id, {
status: retrySpecification ? "needs-respecify" : null,
status: retrySpecification ? "needs-replan" : null,
error: null,
worktree: null,
branch: null,
@@ -272,7 +272,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
const { join } = await import("node:path");
const promptPath = join(scopedStore.getRootDir(), ".fusion", "tasks", task.id, "PROMPT.md");
await rm(promptPath, { force: true });
await scopedStore.logEntry(req.params.id, "Retry requested from dashboard (specification retry budget reset)");
await scopedStore.logEntry(req.params.id, "Retry requested from dashboard (planning retry budget reset)");
const updated = await scopedStore.getTask(req.params.id);
res.json(updated);
return;
@@ -1110,20 +1110,20 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
const task = await scopedStore.getTask(req.params.id);
// If task is already in triage, skip the transition check and moveTask.
// Just reset for re-specification in place.
// Just reset for replanning in place.
if (task.column === "triage") {
// Log the revision request
await scopedStore.logEntry(task.id, "AI spec revision requested", feedback);
// Remove the existing spec so re-specification starts from the task
// Remove the existing spec so replanning starts from the task
// description and feedback rather than revising stale PROMPT.md content.
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 re-specification
await scopedStore.updateTask(task.id, { status: "needs-respecify" });
// Update status to indicate needs replanning
await scopedStore.updateTask(task.id, { status: "needs-replan" });
const updated = await scopedStore.getTask(task.id);
res.json(updated);
@@ -1141,18 +1141,18 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
// Log the revision request
await scopedStore.logEntry(task.id, "AI spec revision requested", feedback);
// Move to triage for re-specification
// Move to triage for replanning
const updated = await scopedStore.moveTask(task.id, "triage");
// Remove the existing spec so re-specification starts from the task
// Remove the existing spec so replanning starts from the task
// description and feedback rather than revising stale PROMPT.md content.
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 re-specification
await scopedStore.updateTask(task.id, { status: "needs-respecify" });
// Update status to indicate needs replanning
await scopedStore.updateTask(task.id, { status: "needs-replan" });
res.json(updated);
} catch (err: unknown) {
@@ -1176,7 +1176,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
const task = await scopedStore.getTask(req.params.id);
// If task is already in triage, skip the transition check and moveTask.
// Just reset for re-specification in place.
// Just reset for replanning in place.
if (task.column === "triage") {
// Log the rebuild request
await scopedStore.logEntry(task.id, "Specification rebuild requested by user");
@@ -1188,8 +1188,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
const promptPath = join(scopedStore.getRootDir(), ".fusion", "tasks", task.id, "PROMPT.md");
await rm(promptPath, { force: true });
// Update status to indicate needs re-specification
await scopedStore.updateTask(task.id, { status: "needs-respecify" });
// Update status to indicate needs replanning
await scopedStore.updateTask(task.id, { status: "needs-replan" });
const updated = await scopedStore.getTask(task.id);
res.json(updated);
@@ -1205,7 +1205,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
// Log the rebuild request
await scopedStore.logEntry(task.id, "Specification rebuild requested by user");
// Move to triage for re-specification
// Move to triage for replanning
const updated = await scopedStore.moveTask(task.id, "triage");
// Remove the existing spec so rebuilds produce a fresh PROMPT.md instead
@@ -1215,8 +1215,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
const promptPath = join(scopedStore.getRootDir(), ".fusion", "tasks", task.id, "PROMPT.md");
await rm(promptPath, { force: true });
// Update status to indicate needs re-specification
await scopedStore.updateTask(task.id, { status: "needs-respecify" });
// Update status to indicate needs replanning
await scopedStore.updateTask(task.id, { status: "needs-replan" });
res.json(updated);
} catch (err: unknown) {