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

@@ -10,7 +10,7 @@ All tools are registered via the pi extension. They are available in any pi agen
### fn_task_create
Create a new task on the Fusion task board. The task enters the triage column where the AI triage agent will specify it into a full prompt with steps, file scope, and acceptance criteria.
Create a new task on the Fusion task board. The task enters the planning column where the AI planning agent will plan it into a full prompt with steps, file scope, and acceptance criteria.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
@@ -82,7 +82,7 @@ Retry a failed task — clears the error state and moves it back to the todo col
### fn_task_duplicate
Duplicate an existing task, creating a fresh copy in triage. Copies the title and description but resets all execution state. The AI triage agent will re-specify the new task.
Duplicate an existing task, creating a fresh copy in planning. Copies the title and description but resets all execution state. The AI planning agent will replan the new task.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
@@ -90,7 +90,7 @@ Duplicate an existing task, creating a fresh copy in triage. Copies the title an
### fn_task_refine
Request a refinement of a completed or in-review task. Creates a new follow-up task in triage that references the original task as a dependency. Use this when a done or in-review task needs additional work, improvements, or follow-up changes.
Request a refinement of a completed or in-review task. Creates a new follow-up task in planning that references the original task as a dependency. Use this when a done or in-review task needs additional work, improvements, or follow-up changes.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
@@ -133,7 +133,7 @@ Create a task via AI-guided planning mode — interactive conversation to refine
### fn_task_import_github
Import GitHub issues as Fusion tasks. Fetches open issues from a repository and creates tasks in the triage column. Each task includes the issue title and body with a link to the source issue.
Import GitHub issues as Fusion tasks. Fetches open issues from a repository and creates tasks in the planning column. Each task includes the issue title and body with a link to the source issue.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
@@ -143,7 +143,7 @@ Import GitHub issues as Fusion tasks. Fetches open issues from a repository and
### fn_task_import_github_issue
Import a specific GitHub issue as a Fusion task. Fetches the issue by number and creates a single task in the triage column with the issue title and body.
Import a specific GitHub issue as a Fusion task. Fetches the issue by number and creates a single task in the planning column with the issue title and body.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|

View File

@@ -12,7 +12,7 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
<!-- BEGIN: fusion-capabilities-tool-table (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->
| Tool | Purpose |
|------|---------|
| `fn_task_create` | Create a new task on the Fusion task board. The task enters the triage column where the AI triage agent will specify it into a full prompt with steps, file scope, and acceptance criteria. |
| `fn_task_create` | Create a new task on the Fusion task board. The task enters the planning column where the AI planning agent will plan it into a full prompt with steps, file scope, and acceptance criteria. |
| `fn_task_update` | Update fields on an existing task. Supports modifying the title, description, dependencies, and assigned agent after task creation. |
| `fn_task_list` | List all tasks on the Fusion board, grouped by column. |
| `fn_task_show` | Show full details for a task including steps, progress, and log entries. |
@@ -20,13 +20,13 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
| `fn_task_pause` | Pause a task — stops all automated agent and scheduler interaction for this task. |
| `fn_task_unpause` | Unpause a task — resumes automated agent and scheduler interaction. |
| `fn_task_retry` | Retry a failed task — clears the error state and moves it back to the todo column for re-execution. |
| `fn_task_duplicate` | Duplicate an existing task, creating a fresh copy in triage. Copies the title and description but resets all execution state. The AI triage agent will re-specify the new task. |
| `fn_task_refine` | Request a refinement of a completed or in-review task. Creates a new follow-up task in triage that references the original task as a dependency. Use this when a done or in-review task needs additional work, improvements, or follow-up changes. |
| `fn_task_duplicate` | Duplicate an existing task, creating a fresh copy in planning. Copies the title and description but resets all execution state. The AI planning agent will replan the new task. |
| `fn_task_refine` | Request a refinement of a completed or in-review task. Creates a new follow-up task in planning that references the original task as a dependency. Use this when a done or in-review task needs additional work, improvements, or follow-up changes. |
| `fn_task_archive` | Archive a done task (move from done → archived). Archived tasks are preserved for historical reference but moved out of the main board view. |
| `fn_task_unarchive` | Unarchive an archived task (move from archived → done). Restores the task to the done column. |
| `fn_task_delete` | Permanently delete a task from the Fusion board. Tasks are deleted immediately and cannot be recovered. |
| `fn_task_import_github` | Import GitHub issues as Fusion tasks. Fetches open issues from a repository and creates tasks in the triage column. Each task includes the issue title and body with a link to the source issue. |
| `fn_task_import_github_issue` | Import a specific GitHub issue as a Fusion task. Fetches the issue by number and creates a single task in the triage column with the issue title and body. |
| `fn_task_import_github` | Import GitHub issues as Fusion tasks. Fetches open issues from a repository and creates tasks in the planning column. Each task includes the issue title and body with a link to the source issue. |
| `fn_task_import_github_issue` | Import a specific GitHub issue as a Fusion task. Fetches the issue by number and creates a single task in the planning column with the issue title and body. |
| `fn_task_browse_github_issues` | List open GitHub issues from a repository to browse before importing. Returns issue numbers, titles, and URLs for selection. Use with fn_task_import_github_issue to import specific issues by number. |
| `fn_task_plan` | Create a task via AI-guided planning mode — interactive conversation to refine your idea into a well-specified task. |
| `fn_mission_create` | Create a new mission — a high-level objective that can span multiple milestones. Missions contain milestones that break down work into phases. |

View File

@@ -2315,7 +2315,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
console.log(` AI engine: ✗ disabled (dev mode)`);
} else {
console.log(` AI engine: ✓ active`);
console.log(`triage: auto-specifying tasks`);
console.log(`planning: auto-planning tasks`);
console.log(` • scheduler: dependency-aware execution`);
console.log(` • cron: scheduled task execution`);
}

View File

@@ -137,12 +137,12 @@ export default function kbExtension(pi: ExtensionAPI) {
name: "fn_task_create",
label: "fn: Create Task",
description:
"Create a new task on the Fusion task board. The task enters the triage column " +
"where the AI triage agent will specify it into a full prompt with steps, " +
"Create a new task on the Fusion task board. The task enters the planning column " +
"where the AI planning agent will plan it into a full prompt with steps, " +
"file scope, and acceptance criteria.",
promptSnippet: "Create a task on the Fusion AI-orchestrated task board",
promptGuidelines: [
"Use fn_task_create for task tracking — be descriptive so the triage agent can write a good spec.",
"Use fn_task_create for task tracking — be descriptive so the planning agent can write a good plan.",
"Include the problem AND desired outcome. For bugs, describe current vs expected behavior.",
],
parameters: Type.Object({
@@ -584,13 +584,13 @@ export default function kbExtension(pi: ExtensionAPI) {
name: "fn_task_duplicate",
label: "fn: Duplicate Task",
description:
"Duplicate an existing task, creating a fresh copy in triage. " +
"Duplicate an existing task, creating a fresh copy in planning. " +
"Copies the title and description but resets all execution state. " +
"The AI triage agent will re-specify the new task.",
promptSnippet: "Duplicate a Fusion task (creates copy in triage)",
"The AI planning agent will replan the new task.",
promptSnippet: "Duplicate a Fusion task (creates copy in planning)",
promptGuidelines: [
"Use when a task needs to be re-done, split, or used as a template",
"The duplicated task will be placed in triage for re-specification",
"The duplicated task will be placed in planning for replanning",
"Dependencies, attachments, and execution state are NOT copied",
],
parameters: Type.Object({
@@ -615,13 +615,13 @@ export default function kbExtension(pi: ExtensionAPI) {
label: "fn: Refine Task",
description:
"Request a refinement of a completed or in-review task. " +
"Creates a new follow-up task in triage that references the original task as a dependency. " +
"Creates a new follow-up task in planning that references the original task as a dependency. " +
"Use this when a done or in-review task needs additional work, improvements, or follow-up changes.",
promptSnippet: "Create a refinement task for follow-up work on a completed task",
promptGuidelines: [
"Use when a completed or in-review task needs follow-up work or improvements",
"The original task must be in 'done' or 'in-review' column",
"The refinement task will be created in triage and depend on the original task",
"The refinement task will be created in planning and depend on the original task",
"Provide clear feedback about what needs to be refined or improved",
],
parameters: Type.Object({
@@ -739,7 +739,7 @@ export default function kbExtension(pi: ExtensionAPI) {
label: "fn: Import GitHub Issues",
description:
"Import GitHub issues as Fusion tasks. Fetches open issues from a repository " +
"and creates tasks in the triage column. Each task includes the issue title " +
"and creates tasks in the planning column. Each task includes the issue title " +
"and body with a link to the source issue.",
promptSnippet: "Import GitHub issues as Fusion tasks",
promptGuidelines: [
@@ -830,7 +830,7 @@ export default function kbExtension(pi: ExtensionAPI) {
label: "fn: Import GitHub Issue",
description:
"Import a specific GitHub issue as a Fusion task. Fetches the issue by number " +
"and creates a single task in the triage column with the issue title and body.",
"and creates a single task in the planning column with the issue title and body.",
promptSnippet: "Import a specific GitHub issue as a Fusion task",
promptGuidelines: [
"Use for importing a single known issue by its number",
@@ -1055,7 +1055,7 @@ export default function kbExtension(pi: ExtensionAPI) {
content: [
{
type: "text",
text: summaryLine + (taskId ? `\n\nPlanning session completed. Task ${taskId} is now in triage and will be auto-specified by the AI triage agent.` : ""),
text: summaryLine + (taskId ? `\n\nPlanning session completed. Task ${taskId} is now in planning and will be auto-planned by the AI planning agent.` : ""),
},
],
details: { taskId, logs },

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) {