FN-6299: allow archiving tasks from any column

Allow task archiving from every live board column while preserving a safe restore target.

- Record the pre-archive column and restore archived tasks to that column, downgrading active execution columns to todo.
- Expose archive actions and CLI/tooling documentation for all non-archived tasks.
- Expand store, dashboard, and route tests for archive/unarchive behavior across columns.
- Quarantine the flaky core db test observed during verification and add the published package changeset.

Files changed:
 .changeset/fn-6299-archive-any-column.md           |  5 ++
 docs/cli-reference.md                              |  4 ++
 docs/task-management.md                            |  9 +--
 .../cli/skill/fusion/references/extension-tools.md |  6 +-
 .../skill/fusion/references/fusion-capabilities.md |  4 +-
 packages/cli/src/bin.ts                            |  2 +-
 packages/cli/src/extension.ts                      | 18 ++---
 .../src/__tests__/store-archive-search.test.ts     | 76 +++++++++++++++++-----
 packages/core/src/store.ts                         | 61 ++++++++++++-----
 packages/core/src/types.ts                         |  4 ++
 packages/core/vitest.config.ts                     |  1 +
 packages/dashboard/app/components/TaskCard.tsx     |  2 +-
 .../dashboard/app/components/TaskDetailModal.tsx   |  2 +-
 .../app/components/__tests__/TaskCard.test.tsx     | 35 +++++++++-
 ...etailModal.responsive-and-dependencies.test.tsx | 26 ++++++++
 .../src/__tests__/routes-tasks-ops.test.ts         |  8 +--
 .../src/routes/register-task-workflow-routes.ts    |  9 +--
 scripts/lib/test-quarantine.json                   |  5 ++
 18 files changed, 215 insertions(+), 62 deletions(-)

Fusion-Task-Id: FN-6299

Fusion-Task-Lineage: fd5b5c12-35b8-4843-ab4e-14608d1ea395
This commit is contained in:
gsxdsm
2026-06-12 20:56:06 -07:00
parent 0457144111
commit 508551c593
18 changed files with 216 additions and 63 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Allow tasks to be archived from any live board column and restored to their pre-archive column.

View File

@@ -577,6 +577,10 @@ fn task unarchive FN-001
fn task delete FN-001 --force
```
Notes:
- `fn task archive` accepts any live-board task (`triage`, `todo`, `in-progress`, `in-review`, or `done`) and preserves the original column for restore.
- `fn task unarchive` restores to the saved pre-archive column when available, with legacy archives falling back to `done`.
### Branch conflict handling
When executor branch allocation fails because `fusion/<task-id>` is already checked out, Fusion marks the task failed/investigable and logs conflict details (existing worktree path, tip SHA, stranded commits). Operators should inspect and resolve conflicting local branches/worktrees with standard git tooling, then retry the task.

View File

@@ -610,8 +610,9 @@ Behavior:
### Archive behavior
- `fn task archive <id>` moves done task to `archived`
- Dashboard delete confirmations for `done` tasks now include an **Archive Instead** action so users can preserve history without soft-deleting the task. This option is shown only for `done` tasks because the store-level archive contract only allows archiving from the `done` column.
- `fn task archive <id>` moves any live-board task (`triage`, `todo`, `in-progress`, `in-review`, or `done`) to `archived`; tasks already in `archived` are rejected.
- Archive records the task's `preArchiveColumn` so restore can return to the original live column instead of always assuming `done`.
- Dashboard delete confirmations for live tasks include an **Archive Instead** action so users can preserve history without soft-deleting the task.
- Cleanup mode can persist compact metadata and remove the task directory
- Archived tasks are read-only for task log/document writes:
- `logEntry()` throws `Task <id> is archived — logging is read-only`
@@ -627,7 +628,7 @@ Behavior:
Archive entries preserve key metadata needed for restoration, including:
- `id`, `title`, `description`, `priority`, `column`
- `id`, `title`, `description`, `priority`, `column`, `preArchiveColumn`
- `dependencies`, `steps`, `currentStep`
- `size`, `reviewLevel`, `prInfo` (primary mirror), `prInfos` (canonical linked PR list), `issueInfo`
- `attachments` metadata
@@ -643,7 +644,7 @@ Archive entries preserve key metadata needed for restoration, including:
- Restores archive entry if directory is missing
- Rebuilds `PROMPT.md`
- Moves task to `done`
- Moves task back to its recorded `preArchiveColumn` when available, falling back to the archived snapshot's prior `column`, then to `done` for legacy archive entries.
- Logs “Task restored from archive” when recovering from compact archive entry
### Task-ID collision safety and operator recovery

View File

@@ -104,15 +104,15 @@ Request a refinement of a completed or in-review task. Creates a new follow-up t
### 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.
Archive a task from any live column (move to archived). Archived tasks are preserved for historical reference but moved out of the main board view.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | ✓ | Task ID to archive (e.g. FN-001). Must be in 'done' column. |
| `id` | string | ✓ | Task ID to archive from any live column (e.g. FN-001). |
### fn_task_unarchive
Unarchive an archived task (move from archived → done). Restores the task to the done column.
Unarchive an archived task (move from archived → its restore column). Restores to the pre-archive column when available, with active execution columns downgraded to todo.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|

View File

@@ -22,8 +22,8 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
| `fn_task_retry` | Retry a failed task — clears the error state. Non-review failures move to todo; in-review execution failures move to todo preserving progress; in-review merge failures stay in-place for auto-merge retry. |
| `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_archive` | Archive a task from any live column (move to 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 → its restore column). Restores to the pre-archive column when available, with active execution columns downgraded to todo. |
| `fn_task_delete` | Soft-delete a task from active Fusion board views. The task row and artifacts are preserved; optional allowResurrection marks the ID for intentional recreation. |
| `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. |

View File

@@ -305,7 +305,7 @@ Usage:
fn task merge <id> Merge an in-review task and close it
fn task duplicate <id> Duplicate a task (creates copy in triage)
fn task refine <id> [opts] Create a refinement task from done/in-review
fn task archive <id> Archive a done task
fn task archive <id> Archive a task (from any column)
fn task unarchive <id> Unarchive an archived task
fn task delete <id> [--force] [--allow-resurrection]
Delete a task (use --force to skip confirmation; --allow-resurrection permits intentional ID recreation)

View File

@@ -1207,16 +1207,16 @@ export default function kbExtension(pi: ExtensionAPI) {
name: "fn_task_archive",
label: "fn: Archive Task",
description:
"Archive a done task (move from done → archived). " +
"Archive a task from any live column (move to archived). " +
"Archived tasks are preserved for historical reference but moved out of the main board view.",
promptSnippet: "Archive a done Fusion task (moves to archived column)",
promptSnippet: "Archive a Fusion task from any live column (moves to archived column)",
promptGuidelines: [
"Use to clean up old completed tasks from the done column",
"Only tasks in the 'done' column can be archived",
"Use to clean up tasks from any live board column when you want them hidden from active views",
"Already archived tasks cannot be archived again",
"Archived tasks can be unarchived later if needed",
],
parameters: Type.Object({
id: Type.String({ description: "Task ID to archive (e.g. FN-001). Must be in 'done' column." }),
id: Type.String({ description: "Task ID to archive from any live column (e.g. FN-001)." }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -1236,11 +1236,11 @@ export default function kbExtension(pi: ExtensionAPI) {
name: "fn_task_unarchive",
label: "fn: Unarchive Task",
description:
"Unarchive an archived task (move from archived → done). " +
"Restores the task to the done column.",
promptSnippet: "Unarchive a Fusion task (restores to done column)",
"Unarchive an archived task (move from archived → its restore column). " +
"Restores to the pre-archive column when available, with active execution columns downgraded to todo.",
promptSnippet: "Unarchive a Fusion task (restores to its pre-archive column)",
promptGuidelines: [
"Use to restore an archived task back to the done column",
"Use to restore an archived task back to its pre-archive column when available",
"Only tasks in the 'archived' column can be unarchived",
],
parameters: Type.Object({

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from "vitest";
import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { AgentStore } from "../agent-store.js";
@@ -24,16 +25,47 @@ describe("TaskStore Archive and Search", () => {
afterAll(harness.afterAll);
describe("archiveTask", () => {
it("archives a done task (moves done → archived)", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
it("archives tasks from every live column and emits the real source column", async () => {
const liveColumns = ["triage", "todo", "in-progress", "in-review", "done"] as const;
const archived = await store.archiveTask(task.id);
for (const column of liveColumns) {
const task = await store.createTask({ description: `Archive from ${column}` });
if (column === "todo") {
await store.moveTask(task.id, "todo");
} else if (column === "in-progress") {
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
} else if (column === "in-review") {
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
} else if (column === "done") {
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
}
const events: any[] = [];
store.on("task:moved", (data) => events.push(data));
const archived = await store.archiveTask(task.id, false);
expect(archived.column).toBe("archived");
expect(archived.preArchiveColumn).toBe(column);
expect(events).toHaveLength(1);
expect(events[0].from).toBe(column);
expect(events[0].to).toBe("archived");
}
});
it("archives a non-done task with cleanup enabled", async () => {
const task = await store.createTask({ description: "Cleanup archive from todo" });
await store.moveTask(task.id, "todo");
const archived = await store.archiveTask(task.id, true);
expect(archived.column).toBe("archived");
expect(archived.preArchiveColumn).toBe("todo");
});
it("adds log entry 'Task archived'", async () => {
@@ -78,11 +110,11 @@ describe("TaskStore Archive and Search", () => {
expect(fetched.column).toBe("archived");
});
it("throws error when task is not in 'done' column", async () => {
it("throws error when task is already archived", async () => {
const task = await store.createTask({ description: "Test task" });
// Task starts in triage, not done
await store.archiveTask(task.id, false);
await expect(store.archiveTask(task.id)).rejects.toThrow("must be in 'done'");
await expect(store.archiveTask(task.id)).rejects.toThrow("already archived");
});
it("updates columnMovedAt timestamp", async () => {
@@ -141,13 +173,27 @@ describe("TaskStore Archive and Search", () => {
});
describe("unarchiveTask", () => {
it("unarchives an archived task (moves archived → done)", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
it("unarchives to the pre-archive column, downgrading active execution columns to todo", async () => {
const todoTask = await store.createTask({ description: "Todo round trip" });
await store.moveTask(todoTask.id, "todo");
await store.archiveTask(todoTask.id, false);
await expect(store.unarchiveTask(todoTask.id)).resolves.toMatchObject({ column: "todo" });
const inProgressTask = await store.createTask({ description: "In progress round trip" });
await store.moveTask(inProgressTask.id, "todo");
await store.moveTask(inProgressTask.id, "in-progress");
await store.archiveTask(inProgressTask.id, false);
await expect(store.unarchiveTask(inProgressTask.id)).resolves.toMatchObject({ column: "todo" });
});
it("falls back to done for legacy archives without a pre-archive column", async () => {
const task = await store.createTask({ description: "Legacy archive" });
await store.archiveTask(task.id, false);
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
const raw = await readFile(join(dir, "task.json"), "utf-8");
const parsed = JSON.parse(raw);
delete parsed.preArchiveColumn;
await writeFile(join(dir, "task.json"), JSON.stringify(parsed));
const unarchived = await store.unarchiveTask(task.id);

View File

@@ -5,7 +5,7 @@ import { join } from "node:path";
import { existsSync, watch, type FSWatcher } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrEntityState, PrThreadState, PrThreadOutcome, PrConflictState, PrChecksRollup, PrReviewDecision } from "./types.js";
import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js";
import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isColumn, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js";
import {
MOVED_SETTINGS_KEYS,
@@ -2109,6 +2109,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
description: entry.description,
priority: normalizeTaskPriority(entry.priority),
column: "archived",
preArchiveColumn: entry.preArchiveColumn,
dependencies: entry.dependencies ?? [],
steps: entry.steps ?? [],
currentStep: entry.currentStep ?? 0,
@@ -2242,6 +2243,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
description: task.description,
priority: normalizeTaskPriority(task.priority),
column: "archived",
preArchiveColumn: task.preArchiveColumn,
dependencies: task.dependencies,
steps: task.steps,
currentStep: task.currentStep,
@@ -10657,7 +10659,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
}
/**
* Archive a done task (move from done → archived).
* Archive a live task (move from any non-archived column → archived).
* Logs the action and emits `task:moved` event.
* @param optionsOrCleanup - Boolean cleanup flag for backward compatibility,
* or an options object that also allows removeLineageReferences.
@@ -10675,12 +10677,15 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
task.log = [];
}
if (task.column !== "done") {
if (task.column === "archived") {
throw new Error(
`Cannot archive ${id}: task is in '${task.column}', must be in 'done'`,
`Cannot archive ${id}: task is already archived`,
);
}
const fromColumn = task.column as Column;
task.preArchiveColumn = fromColumn;
const cleanup = typeof optionsOrCleanup === "boolean" ? optionsOrCleanup : optionsOrCleanup.cleanup !== false;
const removeLineageReferences = typeof optionsOrCleanup === "object" && optionsOrCleanup.removeLineageReferences === true;
const lineageChildIds = this.findLiveLineageChildren(id);
@@ -10708,11 +10713,12 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
});
await this.atomicWriteTaskJson(dir, task);
await this.writeTaskJsonFile(dir, task);
if (this.isWatching) this.taskCache.set(id, { ...task });
for (const lineageChild of rewrittenLineageChildren) {
this.emit("task:updated", lineageChild);
}
this.emit("task:moved", { task, from: "done" as Column, to: "archived" as Column, source: "engine" });
this.emit("task:moved", { task, from: fromColumn, to: "archived" as Column, source: "engine" });
return task;
}
@@ -10745,7 +10751,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
for (const lineageChild of rewrittenLineageChildren) {
this.emit("task:updated", lineageChild);
}
this.emit("task:moved", { task, from: "done" as Column, to: "archived" as Column, source: "engine" });
this.emit("task:moved", { task, from: fromColumn, to: "archived" as Column, source: "engine" });
return this.archiveEntryToTask(entry, false);
});
}
@@ -10758,8 +10764,28 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
return this.archiveTask(id, true);
}
private resolveUnarchiveTargetColumn(preArchiveColumn: unknown): Column {
if (!isColumn(preArchiveColumn) || preArchiveColumn === "archived") {
return "done";
}
if (preArchiveColumn === "in-progress" || preArchiveColumn === "in-review") {
return "todo";
}
return preArchiveColumn;
}
private async readPreArchiveColumnFromTaskFile(dir: string): Promise<Column | undefined> {
try {
const raw = await readFile(join(dir, "task.json"), "utf-8");
const parsed = JSON.parse(raw) as { preArchiveColumn?: unknown };
return isColumn(parsed.preArchiveColumn) ? parsed.preArchiveColumn : undefined;
} catch {
return undefined;
}
}
/**
* Unarchive an archived task (move from archived → done).
* Unarchive an archived task (move from archived → its recorded source column).
* If the active task row was cleaned up, restores from archive.db first.
* Logs the action and emits `task:moved` event.
*/
@@ -10797,16 +10823,18 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
// NOTE: No getTaskMergeBlocker check here — intentionally.
// The merge blocker validates in-review → done transitions (ensuring code
// has been properly reviewed before merging). An unarchived task was already
// merged in its previous lifecycle; this is just a restoration. The transient
// field clearing above ensures no stale blocker state leaks through.
task.column = "done";
// archived in its previous lifecycle; this is just a restoration. The transient
// field clearing below ensures no stale blocker state leaks through.
const preArchiveColumn = task.preArchiveColumn ?? await this.readPreArchiveColumnFromTaskFile(dir);
const toColumn = this.resolveUnarchiveTargetColumn(preArchiveColumn);
task.column = toColumn;
task.preArchiveColumn = undefined;
task.columnMovedAt = new Date().toISOString();
task.updatedAt = task.columnMovedAt;
// Clear transient fields that should not persist into "done" column.
// Matches the clearing done by moveTask() for consistency — archived
// tasks may have been archived with stale state that should not reappear
// after unarchiving.
// Clear transient fields regardless of the restored column. Archived tasks
// may have been archived with stale execution state that should not reappear
// after unarchiving, especially when active columns are downgraded to todo.
this.clearDoneTransientFields(task);
task.log.push({
@@ -10820,7 +10848,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
// Update cache if watcher is active
if (this.isWatching) this.taskCache.set(id, { ...task });
this.emit("task:moved", { task, from: "archived" as Column, to: "done" as Column, source: "engine" });
this.emit("task:moved", { task, from: "archived" as Column, to: toColumn, source: "engine" });
return task;
});
}
@@ -13037,7 +13065,8 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
title: entry.title,
description: entry.description,
priority: normalizeTaskPriority(entry.priority),
column: "archived", // Will be changed to "done" by unarchiveTask
column: "archived", // Will be changed by unarchiveTask
preArchiveColumn: entry.preArchiveColumn,
dependencies: entry.dependencies,
steps: entry.steps,
currentStep: entry.currentStep,

View File

@@ -2070,6 +2070,8 @@ export interface Task {
/** The task's current column id. Widened to {@link ColumnId} so workflow-defined
* custom columns are representable; flag-OFF paths only ever store legacy ids. */
column: ColumnId;
/** Source column captured when this task is archived; used to restore sensibly. */
preArchiveColumn?: Column;
dependencies: string[];
/** User-requested hint for triage: prefer splitting into child tasks when appropriate. */
breakIntoSubtasks?: boolean;
@@ -4380,6 +4382,8 @@ export interface ArchivedTaskEntry {
*/
priority?: TaskPriority;
column: "archived"; // Always archived when in the log
/** Source column captured at archive time; absent on legacy archive entries. */
preArchiveColumn?: Column;
dependencies: string[];
steps: TaskStep[];
currentStep: number;

View File

@@ -15,6 +15,7 @@ export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
exclude: [
"src/__tests__/db.test.ts",
"src/__tests__/soft-delete-tasks.test.ts",
"src/__tests__/store-get-task-columns.test.ts",
"src/__tests__/task-dependency-mutation.test.ts",

View File

@@ -2043,7 +2043,7 @@ function TaskCardComponent({
<Trash2 size={12} />
</button>
)}
{task.column === "done" && onArchiveTask && (
{task.column !== "archived" && onArchiveTask && (
<button
className="card-archive-btn"
onClick={handleArchiveClick}

View File

@@ -1763,7 +1763,7 @@ export function TaskDetailContent({
const handleDelete = useCallback(async () => {
let allowResurrection = false;
if (task.column === "done" && onArchiveTask) {
if (task.column !== "archived" && onArchiveTask) {
const deleteChoice = await confirmWithChoice({
title: t("taskDetail.delete.title", "Delete Task"),
message: t("taskDetail.delete.message", "Delete {{id}}?", { id: task.id }),

View File

@@ -462,6 +462,37 @@ describe("TaskCard", () => {
expect(screen.getByLabelText("Archive task")).toBeDefined();
});
it.each(["triage", "todo", "in-progress", "in-review", "done"] as const)(
"renders archive action for %s tasks",
(column) => {
render(
<TaskCard
task={makeTask({ column })}
onOpenDetail={noop}
addToast={noop}
onArchiveTask={vi.fn(async () => makeTask({ column: "archived" }))}
/>,
);
expect(screen.getByLabelText("Archive task")).toBeDefined();
},
);
it("does not render archive action for archived tasks", () => {
render(
<TaskCard
task={makeTask({ column: "archived" })}
onOpenDetail={noop}
addToast={noop}
onArchiveTask={vi.fn(async () => makeTask({ column: "archived" }))}
onUnarchiveTask={vi.fn(async () => makeTask({ column: "done" }))}
/>,
);
expect(screen.queryByLabelText("Archive task")).toBeNull();
expect(screen.getByLabelText("Unarchive task")).toBeDefined();
});
it("keeps two-button delete flow for non-done task", async () => {
const onDeleteTask = vi.fn(async () => makeTask());
mockConfirm.mockResolvedValueOnce(false);
@@ -1963,10 +1994,10 @@ describe("TaskCard", () => {
expect(actionsContainer?.contains(editBtn)).toBe(true);
});
it("renders archive button inside card-header-actions for done column", () => {
it("renders archive button inside card-header-actions for live columns", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "done", size: "L" })}
task={makeTask({ column: "todo", size: "L" })}
onOpenDetail={noop}
addToast={noop}
onArchiveTask={async () => makeTask()}

View File

@@ -424,6 +424,32 @@ describe("TaskDetailModal", () => {
});
});
it("offers archive instead when deleting a non-done live task", async () => {
const onArchiveTask = vi.fn().mockResolvedValue({} as Task);
mockConfirmWithChoice.mockResolvedValueOnce("tertiary");
render(
<TaskDetailModal
task={makeTask({ column: "todo" as any })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onArchiveTask={onArchiveTask}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
await waitFor(() => {
expect(onArchiveTask).toHaveBeenCalledWith("FN-099");
});
expect(noopDelete).not.toHaveBeenCalled();
});
it("retries archive after lineage-conflict confirmation", async () => {
const onArchiveTask = vi.fn();
const conflict = new Error("Cannot archive task FN-099: still referenced as a lineage parent by FN-201.") as Error & {

View File

@@ -1487,7 +1487,7 @@ describe("POST /tasks/:id/archive", () => {
return app;
}
it("archives a done task and returns the updated task", async () => {
it("archives a task from any live column and returns the updated task", async () => {
const archivedTask = { ...FAKE_TASK_DETAIL, column: "archived" };
(store.archiveTask as ReturnType<typeof vi.fn>).mockResolvedValue(archivedTask);
@@ -1537,15 +1537,15 @@ describe("POST /tasks/:id/archive", () => {
});
});
it("returns 400 when task is not in done column", async () => {
(store.archiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Cannot archive FN-001: task is in 'triage', must be in 'done'"));
it("returns 400 when task is already archived", async () => {
(store.archiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Cannot archive FN-001: task is already archived"));
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/archive", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("must be in 'done'");
expect(res.body.error).toContain("already archived");
});
it("returns 500 on unexpected errors", async () => {

View File

@@ -1785,7 +1785,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
});
// Archive task (done → archived)
// Archive task (any live column → archived)
router.post("/tasks/:id/archive", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
@@ -1814,12 +1814,13 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
});
}
const status = (err instanceof Error ? err.message : String(err)).includes("must be in") ? 400 : 500;
throw new ApiError(status, err instanceof Error ? err.message : String(err));
const message = err instanceof Error ? err.message : String(err);
const status = message.includes("must be in") || message.includes("already archived") ? 400 : 500;
throw new ApiError(status, message);
}
});
// Unarchive task (archived → done)
// Unarchive task (archived → restored column)
router.post("/tasks/:id/unarchive", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);

View File

@@ -50,6 +50,11 @@
"file": "packages/core/src/__tests__/task-node-override.test.ts",
"reason": "Flake observed during FN-6324 verification: broad `pnpm test` failed with `Task FN-001 not found` after temp-root disappearance symptoms in adjacent core tests, alongside a leaked fusion-test-workers temp root. The task only changed agent role policy/settings/heartbeat routing, so this is unrelated temp redirect suite-order/concurrency sensitivity.",
"quarantinedAt": "2026-06-12"
},
{
"file": "packages/core/src/__tests__/db.test.ts",
"reason": "Flake observed during FN-6299 verification: broad `pnpm --filter @fusion/core test` timed out in `Database.recoverIfCorrupt startup guard > rebuilds a malformed database and preserves the corrupt original` after 15s; earlier `pnpm test` attempt SIGTERM'd the core package and leaked a fusion-test-workers temp dir. Follow-up FN-6334.",
"quarantinedAt": "2026-06-12"
}
]
}