feat(FN-5139): wire lineage unlink delete and archive flows

Fusion-Task-Id: FN-5139
Fusion-Task-Lineage: c16f4245-bfda-4b9a-bd98-ffc44b45f5a0
This commit is contained in:
Fusion (runfusion.ai)
2026-05-19 18:28:50 -07:00
committed by gsxdsm
parent 2d42476dac
commit e6c7dcdd64
17 changed files with 753 additions and 57 deletions

View File

@@ -693,9 +693,15 @@ The dashboard server exposes a REST API at `/api`:
- `POST /api/tasks/:id/move` - Move task to column
- `POST /api/tasks/:id/pause` - Pause task
- `POST /api/tasks/:id/unpause` - Unpause task
- `DELETE /api/tasks/:id` - Delete task. Default mode is safe: if other tasks still reference this ID in `dependencies`, the route returns `409` with `{ error, details: { code: "TASK_HAS_DEPENDENTS", taskId, dependentIds } }`.
- To explicitly remove those incoming dependency references and then delete, call `DELETE /api/tasks/:id?removeDependencyReferences=true`.
- This opt-in path rewrites each dependent task's `dependencies` array atomically before deleting the target task, so no live task is left pointing at a missing task ID.
- `DELETE /api/tasks/:id` - Delete task. Default mode is safe:
- If other tasks still reference this ID in `dependencies`, the route returns `409` with `{ error, details: { code: "TASK_HAS_DEPENDENTS", taskId, dependentIds } }`.
- If other live tasks still reference this ID as `sourceParentTaskId`, the route returns `409` with `{ error, details: { code: "TASK_HAS_LINEAGE_CHILDREN", taskId, lineageChildIds } }`.
- To explicitly remove incoming dependency references and then delete, call `DELETE /api/tasks/:id?removeDependencyReferences=true`.
- To explicitly remove incoming lineage references and then delete, call `DELETE /api/tasks/:id?removeLineageReferences=true`.
- Both opt-in paths rewrite the referencing tasks atomically before deleting the target task, so no live task is left pointing at a missing task ID.
- `POST /api/tasks/:id/archive` - Archive a done task.
- Default mode is safe: if live lineage children still reference this task as `sourceParentTaskId`, the route returns `409` with `{ error, details: { code: "TASK_HAS_LINEAGE_CHILDREN", taskId, lineageChildIds } }`.
- To unlink those lineage references first, call `POST /api/tasks/:id/archive?removeLineageReferences=true`.
### Git Operations
- `GET /api/git/status` - Current branch and status

View File

@@ -510,6 +510,18 @@ describe("Git Management API", () => {
});
});
it("sends removeLineageReferences=true when archive lineage unlink is requested", async () => {
const archivedTask: Task = { ...FAKE_DETAIL, column: "archived" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, archivedTask));
await archiveTask("FN-001", undefined, { removeLineageReferences: true });
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/archive?removeLineageReferences=true", {
headers: { "Content-Type": "application/json" },
method: "POST",
});
});
it("throws on error", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Task not in done" }, 400));
@@ -576,6 +588,18 @@ describe("Git Management API", () => {
});
});
it("sends removeLineageReferences=true when lineage unlink is requested", async () => {
const deletedTask: Task = { ...FAKE_DETAIL, column: "done" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, deletedTask));
await deleteTask("FN-001", undefined, { removeLineageReferences: true });
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001?removeLineageReferences=true", {
headers: { "Content-Type": "application/json" },
method: "DELETE",
});
});
it("throws ApiRequestError on error and preserves details payload", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(

View File

@@ -109,9 +109,14 @@ export class ApiRequestError extends Error {
/** Options that shape the soft-delete request payload/query, not hard-delete behavior. */
export interface DeleteTaskOptions {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction;
}
export interface ArchiveTaskOptions {
removeLineageReferences?: boolean;
}
function looksLikeHtml(body: string): boolean {
const trimmed = body.trim();
return trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
@@ -517,6 +522,9 @@ export function deleteTask(id: string, projectId?: string, options?: DeleteTaskO
if (options?.removeDependencyReferences) {
search.set("removeDependencyReferences", "true");
}
if (options?.removeLineageReferences) {
search.set("removeLineageReferences", "true");
}
if (options?.githubIssueAction) {
search.set("githubIssueAction", options.githubIssueAction);
}
@@ -561,8 +569,14 @@ export function unpauseTask(id: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/unpause`, projectId), { method: "POST" });
}
export function archiveTask(id: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/archive`, projectId), { method: "POST" });
export function archiveTask(id: string, projectId?: string, options?: ArchiveTaskOptions): Promise<Task> {
const search = new URLSearchParams();
if (options?.removeLineageReferences) {
search.set("removeLineageReferences", "true");
}
const suffix = search.size > 0 ? `?${search.toString()}` : "";
return api<Task>(withProjectId(`/tasks/${id}/archive${suffix}`, projectId), { method: "POST" });
}
export function unarchiveTask(id: string, projectId?: string): Promise<Task> {

View File

@@ -54,9 +54,13 @@ interface AppModalsProps {
taskHandlers: Pick<UseTaskHandlersResult, "handleModalCreate" | "handlePlanningTaskCreated" | "handlePlanningTasksCreated" | "handleSubtaskTasksCreated" | "handleGitHubImport">;
taskOperations: {
moveTask: (taskId: string, column: Column, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
deleteTask: (taskId: string, options?: { removeDependencyReferences?: boolean; githubIssueAction?: GithubIssueAction }) => Promise<Task>;
deleteTask: (taskId: string, options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction;
}) => Promise<Task>;
mergeTask: (taskId: string) => Promise<MergeResult>;
archiveTask: (taskId: string) => Promise<Task>;
archiveTask: (taskId: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
retryTask: (taskId: string) => Promise<Task>;
resetTask: (taskId: string) => Promise<Task>;
duplicateTask: (taskId: string) => Promise<Task>;

View File

@@ -25,9 +25,13 @@ interface BoardProps {
updates: { title?: string; description?: string; dependencies?: string[] }
) => Promise<Task>;
onRetryTask?: (id: string) => Promise<Task>;
onArchiveTask?: (id: string) => Promise<Task>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean; githubIssueAction?: GithubIssueAction }) => Promise<Task>;
onDeleteTask?: (id: string, options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction;
}) => Promise<Task>;
onArchiveAllDone?: () => Promise<Task[]>;
/** Lazy-load archived tasks. Called the first time the user expands the archived column. */
onLoadArchivedTasks?: () => Promise<void>;

View File

@@ -36,9 +36,13 @@ interface ColumnProps {
updates: { title?: string; description?: string; dependencies?: string[] }
) => Promise<Task>;
onRetryTask?: (id: string) => Promise<Task>;
onArchiveTask?: (id: string) => Promise<Task>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean; githubIssueAction?: GithubIssueAction }) => Promise<Task>;
onDeleteTask?: (id: string, options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction;
}) => Promise<Task>;
onArchiveAllDone?: () => Promise<Task[]>;
collapsed?: boolean;
onToggleCollapse?: () => void;

View File

@@ -16,7 +16,7 @@ import { useViewportMode } from "../hooks/useViewportMode";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
import { getUnifiedTaskProgress } from "../utils/taskProgress";
import { useConfirm } from "../hooks/useConfirm";
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
const COLUMN_COLOR_MAP: Record<Column, string> = {
triage: "var(--triage)",
@@ -180,10 +180,14 @@ interface ListViewProps {
tasks: Task[];
onMoveTask: (id: string, column: Column, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
onRetryTask?: (id: string) => Promise<Task>;
onDeleteTask: (id: string, options?: { removeDependencyReferences?: boolean; githubIssueAction?: GithubIssueAction }) => Promise<Task>;
onDeleteTask: (id: string, options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction;
}) => Promise<Task>;
onPauseTask?: (id: string) => Promise<Task>;
onUnpauseTask?: (id: string) => Promise<Task>;
onArchiveTask?: (id: string) => Promise<Task>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
onMergeTask: (id: string) => Promise<MergeResult>;
onResetTask?: (id: string) => Promise<Task>;
onDuplicateTask?: (id: string) => Promise<Task>;
@@ -745,8 +749,34 @@ export function ListView({
try {
await onArchiveTask(task.id);
archivedIds.push(task.id);
} catch {
failedIds.push(task.id);
} catch (err) {
const lineageConflict = extractLineageDeleteConflict(err);
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
failedIds.push(task.id);
continue;
}
const confirmedArchive = await confirm({
title: "Force Delete Task",
message:
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
"Archive anyway by unlinking these references first?",
confirmLabel: "Archive",
cancelLabel: "Skip",
danger: true,
});
if (!confirmedArchive) {
failedIds.push(task.id);
continue;
}
try {
await onArchiveTask(task.id, { removeLineageReferences: true });
archivedIds.push(task.id);
} catch {
failedIds.push(task.id);
}
}
}
}
@@ -756,15 +786,73 @@ export function ListView({
await onDeleteTask(task.id);
deletedIds.push(task.id);
} catch (err) {
const conflict = extractDependencyDeleteConflict(err);
if (!conflict) {
const dependencyConflict = extractDependencyDeleteConflict(err);
if (dependencyConflict) {
const forceDelete = await confirm({
title: "Force Delete Task",
message: `Task ${task.id} has dependents: ${dependencyConflict.dependentIds.join(", ")}. Remove dependency references and force delete?`,
confirmLabel: "Force Delete",
cancelLabel: "Skip",
danger: true,
});
if (!forceDelete) {
failedIds.push(task.id);
continue;
}
try {
await onDeleteTask(task.id, {
removeDependencyReferences: true,
removeLineageReferences: true,
});
deletedIds.push(task.id);
} catch (retryErr) {
const lineageConflict = extractLineageDeleteConflict(retryErr);
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
failedIds.push(task.id);
continue;
}
const forceLineageDelete = await confirm({
title: "Force Delete Task",
message:
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
"Delete anyway by unlinking these references first?",
confirmLabel: "Force Delete",
cancelLabel: "Skip",
danger: true,
});
if (!forceLineageDelete) {
failedIds.push(task.id);
continue;
}
try {
await onDeleteTask(task.id, {
removeDependencyReferences: true,
removeLineageReferences: true,
});
deletedIds.push(task.id);
} catch {
failedIds.push(task.id);
}
}
continue;
}
const lineageConflict = extractLineageDeleteConflict(err);
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
failedIds.push(task.id);
continue;
}
const forceDelete = await confirm({
title: "Force Delete Task",
message: `Task ${task.id} has dependents: ${conflict.dependentIds.join(", ")}. Remove dependency references and force delete?`,
message:
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
"Delete anyway by unlinking these references first?",
confirmLabel: "Force Delete",
cancelLabel: "Skip",
danger: true,
@@ -776,7 +864,10 @@ export function ListView({
}
try {
await onDeleteTask(task.id, { removeDependencyReferences: true });
await onDeleteTask(task.id, {
removeDependencyReferences: true,
removeLineageReferences: true,
});
deletedIds.push(task.id);
} catch {
failedIds.push(task.id);
@@ -946,8 +1037,34 @@ export function ListView({
try {
await onArchiveTask(task.id);
archivedIds.push(task.id);
} catch {
failedIds.push(task.id);
} catch (err) {
const lineageConflict = extractLineageDeleteConflict(err);
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
failedIds.push(task.id);
continue;
}
const confirmedArchive = await confirm({
title: "Force Delete Task",
message:
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
"Archive anyway by unlinking these references first?",
confirmLabel: "Archive",
cancelLabel: "Skip",
danger: true,
});
if (!confirmedArchive) {
failedIds.push(task.id);
continue;
}
try {
await onArchiveTask(task.id, { removeLineageReferences: true });
archivedIds.push(task.id);
} catch {
failedIds.push(task.id);
}
}
}
} finally {

View File

@@ -27,7 +27,7 @@ import { getUnifiedTaskProgress } from "../utils/taskProgress";
import { getActiveRuntimeMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming";
import type { ToastType } from "../hooks/useToast";
import { useConfirm } from "../hooks/useConfirm";
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
import { MAX_AUTO_MERGE_RETRIES, type BlockerFanoutEntry } from "../hooks/useBlockerFanout";
import { useRetryWarning } from "../context/RetryWarningContext";
@@ -276,9 +276,13 @@ interface TaskCardProps {
id: string,
updates: { title?: string; description?: string; dependencies?: string[] }
) => Promise<Task>;
onArchiveTask?: (id: string) => Promise<Task>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean; githubIssueAction?: GithubIssueAction }) => Promise<Task>;
onDeleteTask?: (id: string, options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction;
}) => Promise<Task>;
onRetryTask?: (id: string) => Promise<Task>;
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries") => void;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
@@ -1214,10 +1218,32 @@ function TaskCardComponent({
void onArchiveTask(task.id).then(() => {
addToast(`Archived ${task.id}`, "success");
}).catch((err) => {
addToast(`Failed to archive ${task.id}: ${getErrorMessage(err)}`, "error");
}).catch(async (err) => {
const lineageConflict = extractLineageDeleteConflict(err);
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
addToast(`Failed to archive ${task.id}: ${getErrorMessage(err)}`, "error");
return;
}
const confirmed = await confirm({
title: "Force Delete Task",
message:
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
"Archive anyway by unlinking these references first?",
danger: true,
});
if (!confirmed) {
return;
}
try {
await onArchiveTask(task.id, { removeLineageReferences: true });
addToast(`Archived ${task.id} after unlinking lineage references`, "success");
} catch (retryErr) {
addToast(`Failed to archive ${task.id}: ${getErrorMessage(retryErr)}`, "error");
}
});
}, [addToast, onArchiveTask, task.id]);
}, [addToast, confirm, onArchiveTask, task.id]);
const handleUnarchiveClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
@@ -1279,18 +1305,70 @@ function TaskCardComponent({
: "";
addToast(`Deleted ${task.id}${issueSuffix}`, "success");
} catch (err) {
const conflict = extractDependencyDeleteConflict(err);
if (!conflict || conflict.dependentIds.length === 0) {
const dependencyConflict = extractDependencyDeleteConflict(err);
if (dependencyConflict && dependencyConflict.dependentIds.length > 0) {
const dependentList = dependencyConflict.dependentIds.join(", ");
const confirmed = await confirm({
title: "Force Delete Task",
message:
`${task.id} is a dependency of ${dependentList}.\n\n` +
"Delete anyway by removing these dependency references first?",
danger: true,
});
if (!confirmed) {
return;
}
try {
await onDeleteTask(task.id, {
removeDependencyReferences: true,
removeLineageReferences: true,
githubIssueAction,
});
addToast(`Deleted ${task.id} after removing dependency references`, "success");
} catch (retryErr) {
const lineageConflict = extractLineageDeleteConflict(retryErr);
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
addToast(`Failed to delete ${task.id}: ${getErrorMessage(retryErr)}`, "error");
return;
}
const confirmedLineage = await confirm({
title: "Force Delete Task",
message:
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
"Delete anyway by unlinking these references first?",
danger: true,
});
if (!confirmedLineage) {
return;
}
try {
await onDeleteTask(task.id, {
removeDependencyReferences: true,
removeLineageReferences: true,
githubIssueAction,
});
addToast(`Deleted ${task.id} after unlinking lineage references`, "success");
} catch (lineageRetryErr) {
addToast(`Failed to delete ${task.id}: ${getErrorMessage(lineageRetryErr)}`, "error");
}
}
return;
}
const lineageConflict = extractLineageDeleteConflict(err);
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
addToast(`Failed to delete ${task.id}: ${getErrorMessage(err)}`, "error");
return;
}
const dependentList = conflict.dependentIds.join(", ");
const confirmed = await confirm({
title: "Force Delete Task",
message:
`${task.id} is a dependency of ${dependentList}.\n\n` +
"Delete anyway by removing these dependency references first?",
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
"Delete anyway by unlinking these references first?",
danger: true,
});
if (!confirmed) {
@@ -1298,8 +1376,12 @@ function TaskCardComponent({
}
try {
await onDeleteTask(task.id, { removeDependencyReferences: true, githubIssueAction });
addToast(`Deleted ${task.id} after removing dependency references`, "success");
await onDeleteTask(task.id, {
removeDependencyReferences: true,
removeLineageReferences: true,
githubIssueAction,
});
addToast(`Deleted ${task.id} after unlinking lineage references`, "success");
} catch (retryErr) {
addToast(`Failed to delete ${task.id}: ${getErrorMessage(retryErr)}`, "error");
}

View File

@@ -43,7 +43,7 @@ import { ProviderIcon } from "./ProviderIcon";
import { subscribeSse } from "../sse-bus";
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
import { appendTokenQuery } from "../auth";
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
import { MAX_AUTO_MERGE_RETRIES, computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
import { resolveEffectiveGithubRepoDefault } from "./githubTracking";
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
@@ -284,8 +284,12 @@ export interface TaskDetailModalProps {
onClose: () => void;
onOpenDetail: (task: Task | TaskDetail) => void; // For clicking dependencies
onMoveTask: (id: string, column: Column, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
onDeleteTask: (id: string, options?: { removeDependencyReferences?: boolean; githubIssueAction?: GithubIssueAction }) => Promise<Task>;
onArchiveTask?: (id: string) => Promise<Task>;
onDeleteTask: (id: string, options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction;
}) => Promise<Task>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
onMergeTask: (id: string) => Promise<MergeResult>;
onRetryTask?: (id: string) => Promise<Task>;
onResetTask?: (id: string) => Promise<Task>;
@@ -1449,7 +1453,30 @@ export function TaskDetailContent({
addToast(`Archived ${task.id}`, "success");
requestClose();
} catch (err) {
addToast(getErrorMessage(err), "error");
const lineageConflict = extractLineageDeleteConflict(err);
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
addToast(getErrorMessage(err), "error");
return;
}
const confirmedArchive = await confirm({
title: "Force Delete Task",
message:
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
"Archive anyway by unlinking these references first?",
danger: true,
});
if (!confirmedArchive) {
return;
}
try {
await onArchiveTask(task.id, { removeLineageReferences: true });
addToast(`Archived ${task.id} after unlinking lineage references`, "success");
requestClose();
} catch (retryErr) {
addToast(getErrorMessage(retryErr), "error");
}
}
return;
}
@@ -1502,18 +1529,72 @@ export function TaskDetailContent({
: "";
addToast(`Deleted ${task.id}${issueSuffix}`, "info");
} catch (err) {
const conflict = extractDependencyDeleteConflict(err);
if (!conflict || conflict.dependentIds.length === 0) {
const dependencyConflict = extractDependencyDeleteConflict(err);
if (dependencyConflict && dependencyConflict.dependentIds.length > 0) {
const dependentList = dependencyConflict.dependentIds.join(", ");
const confirmed = await confirm({
title: "Force Delete Task",
message:
`${task.id} is a dependency of ${dependentList}.\n\n` +
"Delete anyway by removing these dependency references first?",
danger: true,
});
if (!confirmed) {
return;
}
try {
await onDeleteTask(task.id, {
removeDependencyReferences: true,
removeLineageReferences: true,
githubIssueAction,
});
requestClose();
addToast(`Deleted ${task.id} after removing dependency references`, "info");
} catch (retryErr) {
const lineageConflict = extractLineageDeleteConflict(retryErr);
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
addToast(getErrorMessage(retryErr), "error");
return;
}
const confirmedLineage = await confirm({
title: "Force Delete Task",
message:
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
"Delete anyway by unlinking these references first?",
danger: true,
});
if (!confirmedLineage) {
return;
}
try {
await onDeleteTask(task.id, {
removeDependencyReferences: true,
removeLineageReferences: true,
githubIssueAction,
});
requestClose();
addToast(`Deleted ${task.id} after unlinking lineage references`, "info");
} catch (lineageRetryErr) {
addToast(getErrorMessage(lineageRetryErr), "error");
}
}
return;
}
const lineageConflict = extractLineageDeleteConflict(err);
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
addToast(getErrorMessage(err), "error");
return;
}
const dependentList = conflict.dependentIds.join(", ");
const confirmed = await confirm({
title: "Force Delete Task",
message:
`${task.id} is a dependency of ${dependentList}.\n\n` +
"Delete anyway by removing these dependency references first?",
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
"Delete anyway by unlinking these references first?",
danger: true,
});
if (!confirmed) {
@@ -1521,9 +1602,13 @@ export function TaskDetailContent({
}
try {
await onDeleteTask(task.id, { removeDependencyReferences: true, githubIssueAction });
await onDeleteTask(task.id, {
removeDependencyReferences: true,
removeLineageReferences: true,
githubIssueAction,
});
requestClose();
addToast(`Deleted ${task.id} after removing dependency references`, "info");
addToast(`Deleted ${task.id} after unlinking lineage references`, "info");
} catch (retryErr) {
addToast(getErrorMessage(retryErr), "error");
}

View File

@@ -2871,7 +2871,7 @@ describe("ListView - Bulk Selection", () => {
details: { code: "TASK_HAS_DEPENDENTS", dependentIds: ["FN-100"] },
});
const onDeleteTask = vi
.fn<(...args: [string, { removeDependencyReferences?: boolean }?]) => Promise<Task>>()
.fn<(...args: [string, { removeDependencyReferences?: boolean; removeLineageReferences?: boolean }?]) => Promise<Task>>()
.mockRejectedValueOnce(conflictError)
.mockResolvedValueOnce(createMockTask());
mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
@@ -2885,7 +2885,37 @@ describe("ListView - Bulk Selection", () => {
expect(onDeleteTask).toHaveBeenCalledTimes(2);
});
expect(onDeleteTask).toHaveBeenNthCalledWith(1, "FN-001");
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-001", { removeDependencyReferences: true });
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-001", {
removeDependencyReferences: true,
removeLineageReferences: true,
});
expect(mockAddToast).toHaveBeenCalledWith("Deleted 1 task · 0 archived skipped · 0 failed", "success");
});
it("force deletes when lineage conflict is confirmed", async () => {
const user = userEvent.setup();
const tasks = [createMockTask({ id: "FN-001" })];
const conflictError = Object.assign(new Error("lineage conflict"), {
details: { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: ["FN-200"] },
});
const onDeleteTask = vi
.fn<(...args: [string, { removeDependencyReferences?: boolean; removeLineageReferences?: boolean }?]) => Promise<Task>>()
.mockRejectedValueOnce(conflictError)
.mockResolvedValueOnce(createMockTask());
mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
renderListView({ tasks, onDeleteTask });
enterBulkEditMode();
await user.click(screen.getByLabelText("Select FN-001"));
await user.click(screen.getByRole("button", { name: /delete selected/i }));
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledTimes(2);
});
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-001", {
removeDependencyReferences: true,
removeLineageReferences: true,
});
expect(mockAddToast).toHaveBeenCalledWith("Deleted 1 task · 0 archived skipped · 0 failed", "success");
});
@@ -2912,6 +2942,30 @@ describe("ListView - Bulk Selection", () => {
});
});
it("retries archive after lineage-conflict confirmation", async () => {
const user = userEvent.setup();
const tasks = [createMockTask({ id: "FN-001", column: "done" })];
const conflictError = Object.assign(new Error("lineage conflict"), {
details: { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: ["FN-300"] },
});
const onArchiveTask = vi
.fn<(...args: [string, { removeLineageReferences?: boolean }?]) => Promise<Task>>()
.mockRejectedValueOnce(conflictError)
.mockResolvedValueOnce(createMockTask({ id: "FN-001", column: "archived" }));
mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
renderListView({ tasks, onArchiveTask });
enterBulkEditMode();
await user.click(screen.getByLabelText("Select FN-001"));
await user.click(screen.getByRole("button", { name: /archive selected/i }));
await waitFor(() => {
expect(onArchiveTask).toHaveBeenCalledTimes(2);
});
expect(onArchiveTask).toHaveBeenNthCalledWith(2, "FN-001", { removeLineageReferences: true });
expect(mockAddToast).toHaveBeenCalledWith("Archived 1 · 0 skipped · 0 failed", "success");
});
it("persists selection to localStorage", () => {
const tasks = [createMockTask({ id: "FN-001" })];
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} />);

View File

@@ -267,7 +267,56 @@ describe("TaskCard", () => {
});
await waitFor(() => {
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-001", { removeDependencyReferences: true, githubIssueAction: "delete" });
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-001", {
removeDependencyReferences: true,
removeLineageReferences: true,
githubIssueAction: "delete",
});
});
});
it("retries delete after lineage-conflict confirmation", async () => {
const conflict = new Error("Cannot delete task FN-001: still referenced as a lineage parent by FN-010.") as Error & {
status: number;
details: { code: string; lineageChildIds: string[] };
};
conflict.status = 409;
conflict.details = { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: ["FN-010", "FN-011"] };
const onDeleteTask = vi.fn()
.mockRejectedValueOnce(conflict)
.mockResolvedValueOnce(makeTask());
mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
render(
<TaskCard
task={makeTask({
column: "triage",
githubTracking: {
enabled: true,
issue: { owner: "owner", repo: "repo", number: 42, url: "https://github.com/owner/repo/issues/42", createdAt: "2026-01-01T00:00:00Z" },
},
} as any)}
onOpenDetail={noop}
addToast={noop}
onDeleteTask={onDeleteTask}
/>,
);
await act(async () => {
fireEvent.click(screen.getByLabelText("Delete task"));
});
await waitFor(() => {
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-001", {
removeDependencyReferences: true,
removeLineageReferences: true,
githubIssueAction: "delete",
});
});
});

View File

@@ -9,6 +9,7 @@ import {
noopMove,
noopOpenDetail,
mockConfirm,
mockConfirmWithChoice,
mockUsePluginUiSlots,
expectBaseRule,
readDashboardStylesSource,
@@ -269,6 +270,7 @@ describe("TaskDetailModal", () => {
expect(onDeleteTask).toHaveBeenNthCalledWith(1, "FN-099", { githubIssueAction: "delete" });
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", {
removeDependencyReferences: true,
removeLineageReferences: true,
githubIssueAction: "delete",
});
expect(noop).toHaveBeenCalledWith("Deleted FN-099 after removing dependency references", "info");
@@ -341,11 +343,90 @@ describe("TaskDetailModal", () => {
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
await waitFor(() => {
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", { removeDependencyReferences: true });
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", {
removeDependencyReferences: true,
removeLineageReferences: true,
});
expect(noop).toHaveBeenCalledWith("Retry failed", "error");
});
});
it("retries delete after lineage-conflict confirmation", async () => {
const onDeleteTask = vi.fn();
const conflict = new Error("Cannot delete task FN-099: still referenced as a lineage parent by FN-103.") as Error & {
status: number;
details: { code: string; lineageChildIds: string[] };
};
conflict.status = 409;
conflict.details = { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: ["FN-103"] };
onDeleteTask
.mockRejectedValueOnce(conflict)
.mockResolvedValueOnce({} as Task);
mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
render(
<TaskDetailModal
task={makeTask({ githubTracking: { enabled: true, issue: { owner: "owner", repo: "repo", number: 42, url: "https://github.com/owner/repo/issues/42", createdAt: "2026-01-01T00:00:00.000Z" } } })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={onDeleteTask}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
await waitFor(() => {
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", {
removeDependencyReferences: true,
removeLineageReferences: true,
githubIssueAction: "close",
});
});
});
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 & {
status: number;
details: { code: string; lineageChildIds: string[] };
};
conflict.status = 409;
conflict.details = { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: ["FN-201"] };
onArchiveTask
.mockRejectedValueOnce(conflict)
.mockResolvedValueOnce({} as Task);
mockConfirmWithChoice.mockResolvedValueOnce("tertiary");
mockConfirm.mockResolvedValueOnce(true);
render(
<TaskDetailModal
task={makeTask({ column: "done" 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).toHaveBeenNthCalledWith(2, "FN-099", { removeLineageReferences: true });
});
});
it("in-review modal-actions contains Merge & Close and Back to In Progress buttons", () => {
render(
<TaskDetailModal

View File

@@ -491,7 +491,11 @@ export function useTasks(options?: UseTasksOptions) {
const deleteTask = useCallback(async (
id: string,
options?: { removeDependencyReferences?: boolean; githubIssueAction?: GithubIssueAction },
options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction;
},
): Promise<Task> => {
return normalizeTask(await api.deleteTask(id, projectId, options));
}, [projectId]);
@@ -548,8 +552,11 @@ export function useTasks(options?: UseTasksOptions) {
}
}, [projectId]);
const archiveTask = useCallback(async (id: string): Promise<Task> => {
const task = normalizeTask(await api.archiveTask(id, projectId));
const archiveTask = useCallback(async (
id: string,
options?: { removeLineageReferences?: boolean },
): Promise<Task> => {
const task = normalizeTask(await api.archiveTask(id, projectId, options));
setTasks((prev) =>
prev.map((t) => (t.id === id ? task : t))
);

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { extractDependencyDeleteConflict } from "../taskDelete";
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../taskDelete";
describe("extractDependencyDeleteConflict", () => {
it("returns dependent ids from details.code payload", () => {
@@ -32,3 +32,39 @@ describe("extractDependencyDeleteConflict", () => {
expect(extractDependencyDeleteConflict("boom")).toBeNull();
});
});
describe("extractLineageDeleteConflict", () => {
it("returns lineage child ids from details.code payload", () => {
const err = Object.assign(new Error("conflict"), {
details: { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: ["FN-3", "FN-4", 5] },
});
expect(extractLineageDeleteConflict(err)).toEqual({ lineageChildIds: ["FN-3", "FN-4"] });
});
it("returns null for missing details", () => {
expect(extractLineageDeleteConflict(new Error("failed"))).toBeNull();
});
it("returns null for the wrong conflict code", () => {
const err = Object.assign(new Error("conflict"), {
details: { code: "TASK_HAS_DEPENDENTS", lineageChildIds: ["FN-3"] },
});
expect(extractLineageDeleteConflict(err)).toBeNull();
});
it("returns null when lineageChildIds is not an array", () => {
const err = Object.assign(new Error("conflict"), {
details: { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: "FN-3" },
});
expect(extractLineageDeleteConflict(err)).toBeNull();
});
it("returns null for non-Error inputs", () => {
expect(extractLineageDeleteConflict(null)).toBeNull();
expect(extractLineageDeleteConflict({ details: { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: ["FN-3"] } })).toBeNull();
expect(extractLineageDeleteConflict("boom")).toBeNull();
});
});

View File

@@ -2,6 +2,10 @@ export interface DependencyDeleteConflict {
dependentIds: string[];
}
export interface LineageDeleteConflict {
lineageChildIds: string[];
}
export function extractDependencyDeleteConflict(err: unknown): DependencyDeleteConflict | null {
if (!(err instanceof Error)) {
return null;
@@ -19,3 +23,18 @@ export function extractDependencyDeleteConflict(err: unknown): DependencyDeleteC
return null;
}
export function extractLineageDeleteConflict(err: unknown): LineageDeleteConflict | null {
if (!(err instanceof Error)) {
return null;
}
const details = (err as { details?: { code?: string; lineageChildIds?: unknown } }).details;
if (details?.code !== "TASK_HAS_LINEAGE_CHILDREN" || !Array.isArray(details.lineageChildIds)) {
return null;
}
return {
lineageChildIds: details.lineageChildIds.filter((id): id is string => typeof id === "string"),
};
}

View File

@@ -988,6 +988,7 @@ describe("DELETE /tasks/:id", () => {
expect(res.body.id).toBe("KB-001");
expect(store.deleteTask).toHaveBeenCalledWith("KB-001", {
removeDependencyReferences: false,
removeLineageReferences: false,
githubIssueAction: undefined,
});
});
@@ -1018,6 +1019,21 @@ describe("DELETE /tasks/:id", () => {
expect(res.status).toBe(200);
expect(store.deleteTask).toHaveBeenCalledWith("KB-001", {
removeDependencyReferences: true,
removeLineageReferences: false,
githubIssueAction: undefined,
});
});
it("passes the removeLineageReferences flag when explicitly requested", async () => {
const deletedTask = { ...FAKE_TASK_DETAIL, id: "KB-001" };
(store.deleteTask as ReturnType<typeof vi.fn>).mockResolvedValue(deletedTask);
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/KB-001?removeLineageReferences=true");
expect(res.status).toBe(200);
expect(store.deleteTask).toHaveBeenCalledWith("KB-001", {
removeDependencyReferences: false,
removeLineageReferences: true,
githubIssueAction: undefined,
});
});
@@ -1031,10 +1047,28 @@ describe("DELETE /tasks/:id", () => {
expect(res.status).toBe(200);
expect(store.deleteTask).toHaveBeenCalledWith("KB-001", {
removeDependencyReferences: false,
removeLineageReferences: false,
githubIssueAction,
});
});
it("returns structured 409 conflict when delete is blocked by lineage children", async () => {
const err = new Error("Cannot delete task KB-001: still referenced as a lineage parent by KB-002.");
err.name = "TaskHasLineageChildrenError";
(err as Error & { childIds: string[] }).childIds = ["KB-002", "KB-003"];
(store.deleteTask as ReturnType<typeof vi.fn>).mockRejectedValue(err);
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/KB-001");
expect(res.status).toBe(409);
expect(res.body.error).toContain("Cannot delete task KB-001");
expect(res.body.details).toEqual({
code: "TASK_HAS_LINEAGE_CHILDREN",
taskId: "KB-001",
lineageChildIds: ["KB-002", "KB-003"],
});
});
it("rejects invalid githubIssueAction values", async () => {
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/KB-001?githubIssueAction=bad-value");
@@ -1070,7 +1104,44 @@ describe("POST /tasks/:id/archive", () => {
expect(res.status).toBe(200);
expect(res.body.column).toBe("archived");
expect(store.archiveTask).toHaveBeenCalledWith("KB-001");
expect(store.archiveTask).toHaveBeenCalledWith("KB-001", {
cleanup: true,
removeLineageReferences: false,
});
});
it("passes removeLineageReferences to archive when explicitly requested", async () => {
const archivedTask = { ...FAKE_TASK_DETAIL, column: "archived" };
(store.archiveTask as ReturnType<typeof vi.fn>).mockResolvedValue(archivedTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/archive?removeLineageReferences=true", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.archiveTask).toHaveBeenCalledWith("KB-001", {
cleanup: true,
removeLineageReferences: true,
});
});
it("returns structured 409 conflict when archive is blocked by lineage children", async () => {
const err = new Error("Cannot archive task KB-001: still referenced as a lineage parent by KB-002.");
err.name = "TaskHasLineageChildrenError";
(err as Error & { childIds: string[] }).childIds = ["KB-002", "KB-003"];
(store.archiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(err);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/archive", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(409);
expect(res.body.error).toContain("Cannot archive task KB-001");
expect(res.body.details).toEqual({
code: "TASK_HAS_LINEAGE_CHILDREN",
taskId: "KB-001",
lineageChildIds: ["KB-002", "KB-003"],
});
});
it("returns 400 when task is not in done column", async () => {

View File

@@ -973,12 +973,31 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
router.post("/tasks/:id/archive", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.archiveTask(req.params.id);
const removeLineageReferences = req.query.removeLineageReferences === "1"
|| req.query.removeLineageReferences === "true";
const task = await scopedStore.archiveTask(req.params.id, {
cleanup: true,
removeLineageReferences,
});
res.json(task);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
const isTaskHasLineageChildrenError =
err instanceof Error
&& err.name === "TaskHasLineageChildrenError"
&& Array.isArray((err as { childIds?: unknown }).childIds);
if (isTaskHasLineageChildrenError) {
const childIds = (err as { childIds: string[] }).childIds;
throw new ApiError(409, err instanceof Error ? err.message : "Task has lineage children", {
code: "TASK_HAS_LINEAGE_CHILDREN",
taskId: req.params.id,
lineageChildIds: childIds,
});
}
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));
}
@@ -2744,6 +2763,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
const { store: scopedStore } = await getProjectContext(req);
const removeDependencyReferences = req.query.removeDependencyReferences === "1"
|| req.query.removeDependencyReferences === "true";
const removeLineageReferences = req.query.removeLineageReferences === "1"
|| req.query.removeLineageReferences === "true";
const githubIssueActionRaw = req.query.githubIssueAction;
const githubIssueActionValues: readonly GithubIssueAction[] = ["close", "delete", "leave", "auto"];
let githubIssueAction: GithubIssueAction | undefined;
@@ -2753,7 +2774,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
githubIssueAction = githubIssueActionRaw as GithubIssueAction;
}
const task = await scopedStore.deleteTask(req.params.id, { removeDependencyReferences, githubIssueAction });
const task = await scopedStore.deleteTask(req.params.id, {
removeDependencyReferences,
removeLineageReferences,
githubIssueAction,
});
res.json(task);
} catch (err: unknown) {
if (err instanceof ApiError) {
@@ -2773,6 +2798,20 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
});
}
const isTaskHasLineageChildrenError =
err instanceof Error
&& err.name === "TaskHasLineageChildrenError"
&& Array.isArray((err as { childIds?: unknown }).childIds);
if (isTaskHasLineageChildrenError) {
const childIds = (err as { childIds: string[] }).childIds;
throw new ApiError(409, err instanceof Error ? err.message : "Task has lineage children", {
code: "TASK_HAS_LINEAGE_CHILDREN",
taskId: req.params.id,
lineageChildIds: childIds,
});
}
rethrowAsApiError(err);
}
});