From 8cc5fd895ed7da4aec43b0ce8207181fdc12feae Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Thu, 25 Jun 2026 03:44:06 -0700 Subject: [PATCH] feat(FN-783): add supported overlap blocker repair API --- .../core/src/__tests__/store-parsing.test.ts | 68 +++++ packages/core/src/store.ts | 241 ++++++++++++++++++ packages/dashboard/app/api/legacy.ts | 23 ++ .../app/components/TaskDetailModal.tsx | 41 ++- .../TaskDetailModal.rendering.test.tsx | 47 ++-- .../__tests__/TaskDetailModal.test-helpers.ts | 1 + .../src/__tests__/routes-tasks-ops.test.ts | 53 ++++ .../routes/register-task-workflow-routes.ts | 33 +++ 8 files changed, 464 insertions(+), 43 deletions(-) diff --git a/packages/core/src/__tests__/store-parsing.test.ts b/packages/core/src/__tests__/store-parsing.test.ts index 9bcd1fd30d..c260e32447 100644 --- a/packages/core/src/__tests__/store-parsing.test.ts +++ b/packages/core/src/__tests__/store-parsing.test.ts @@ -511,6 +511,74 @@ Expected touched paths: }); }); + describe("repairOverlapBlocker", () => { + async function writePrompt(taskId: string, scope: string[]) { + const dir = join(rootDir, ".fusion", "tasks", taskId); + await writeFile( + join(dir, "PROMPT.md"), + `# ${taskId}: repair fixture\n\n## File Scope\n\n${scope.map((entry) => `- \`${entry}\``).join("\n")}\n`, + ); + } + + it("clears stale false-positive overlap blockers through the store API", async () => { + const blocker = await store.createTask({ description: "Atlas blocker" }); + const target = await store.createTask({ description: "Fusion target" }); + await writePrompt(blocker.id, ["project.yml", "Tests/AtlasNotesMobileUITests/**"]); + await writePrompt(target.id, ["packages/core/**", "packages/engine/**"]); + await store.moveTask(blocker.id, "todo"); + await store.moveTask(blocker.id, "in-progress"); + await store.moveTask(target.id, "todo"); + await store.updateTask(target.id, { status: "queued", overlapBlockedBy: blocker.id }); + + const result = await store.repairOverlapBlocker(target.id, { reason: "test" }); + + expect(result).toMatchObject({ repaired: true, statusCleared: true, previousOverlapBlockedBy: blocker.id, reason: "repaired" }); + const repaired = await store.getTask(target.id); + expect(repaired?.overlapBlockedBy).toBeUndefined(); + expect(repaired?.status).toBeUndefined(); + expect(repaired?.log.at(-1)?.action).toContain(`Repaired stale overlap blocker: cleared ${blocker.id}`); + }); + + it("rejects repair when the stored blocker still overlaps", async () => { + const blocker = await store.createTask({ description: "Fusion blocker" }); + const target = await store.createTask({ description: "Fusion target" }); + await writePrompt(blocker.id, ["packages/engine/**"]); + await writePrompt(target.id, ["packages/engine/src/scheduler.ts"]); + await store.moveTask(blocker.id, "todo"); + await store.moveTask(blocker.id, "in-progress"); + await store.moveTask(target.id, "todo"); + await store.updateTask(target.id, { status: "queued", overlapBlockedBy: blocker.id }); + + const result = await store.repairOverlapBlocker(target.id); + + expect(result).toMatchObject({ repaired: false, statusCleared: false, reason: "scopes-still-overlap", currentOverlapBlockedBy: blocker.id }); + const unchanged = await store.getTask(target.id); + expect(unchanged?.overlapBlockedBy).toBe(blocker.id); + expect(unchanged?.status).toBe("queued"); + }); + + it("reroutes stale overlap blockers to another current overlap", async () => { + const stale = await store.createTask({ description: "stale blocker" }); + const current = await store.createTask({ description: "current blocker" }); + const target = await store.createTask({ description: "target" }); + await writePrompt(stale.id, ["packages/core/**"]); + await writePrompt(current.id, ["packages/engine/**"]); + await writePrompt(target.id, ["packages/engine/src/scheduler.ts"]); + await store.moveTask(stale.id, "todo"); + await store.moveTask(current.id, "todo"); + await store.moveTask(current.id, "in-progress"); + await store.moveTask(target.id, "todo"); + await store.updateTask(target.id, { status: "queued", overlapBlockedBy: stale.id }); + + const result = await store.repairOverlapBlocker(target.id); + + expect(result).toMatchObject({ repaired: true, statusCleared: false, reason: "rerouted-to-current-overlap", currentOverlapBlockedBy: current.id }); + const rerouted = await store.getTask(target.id); + expect(rerouted?.overlapBlockedBy).toBe(current.id); + expect(rerouted?.status).toBe("queued"); + }); + }); + describe("FN-5216 File Scope sanitization on copy paths", () => { const validScopeEntry = "packages/cli/src/extension.ts"; const invalidScopeEntries = [ diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 529f074e77..0fb099bad3 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -20,6 +20,33 @@ import { stepsToWorkflowIr, stepToFragmentIr, layoutForIr } from "./workflow-ste import { resolveAllowedColumns, workflowHasColumn } from "./workflow-transitions.js"; import { extractEffectiveWriteScopeFromPrompt, extractFileScopeTokens, isValidFileScopeEntry } from "./file-scope-classification.js"; +export type OverlapBlockerRepairReason = + | "task-not-found" + | "no-overlap-blocker" + | "not-repairable-state" + | "blocker-missing" + | "scopes-still-overlap" + | "dependency-blocker-remains" + | "rerouted-to-current-overlap" + | "repaired"; + +export interface RepairOverlapBlockerOptions { + dryRun?: boolean; + reason?: string; +} + +export interface RepairOverlapBlockerResult { + taskId: string; + dryRun: boolean; + repaired: boolean; + statusCleared: boolean; + previousOverlapBlockedBy?: string; + currentOverlapBlockedBy?: string; + reason: OverlapBlockerRepairReason; + message: string; + task?: Task; +} + function isWorkflowColumnsCompatibilityFlagEnabled(settings: Pick | undefined): boolean { /* FNXC:WorkflowColumns 2026-06-22-00:00: @@ -1411,6 +1438,52 @@ export interface LegacyAutoMergeStampReconcileResult { const LEGACY_AUTO_MERGE_STAMP_MARKER_KEY = "legacyAutoMergeStampMarkedVersion"; const LEGACY_AUTO_MERGE_STAMP_MARKER_VERSION = "1"; +function normalizeRepairOverlapPath(path: string): string { + return path.trim().replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function repairOverlapPathPrefix(path: string): string | null { + const normalized = normalizeRepairOverlapPath(path); + if (normalized.endsWith("/**")) return normalized.slice(0, -2); + if (normalized.endsWith("/*")) return normalized.slice(0, -1); + if (normalized.endsWith("/")) return normalized; + return null; +} + +function repairScopesOverlap(a: string[], b: string[]): boolean { + for (const rawA of a) { + const pa = normalizeRepairOverlapPath(rawA); + const prefixA = repairOverlapPathPrefix(pa); + const cleanA = prefixA ? prefixA.replace(/\/$/, "") : pa; + for (const rawB of b) { + const pb = normalizeRepairOverlapPath(rawB); + const prefixB = repairOverlapPathPrefix(pb); + const cleanB = prefixB ? prefixB.replace(/\/$/, "") : pb; + if (cleanA === cleanB || pa === pb) return true; + if (prefixA && (pb === cleanA || pb.startsWith(prefixA))) return true; + if (prefixB && (pa === cleanB || pa.startsWith(prefixB))) return true; + if (prefixA && prefixB && (prefixA.startsWith(prefixB) || prefixB.startsWith(prefixA))) return true; + } + } + return false; +} + +function repairIgnoredOverlapPath(path: string, ignorePath: string): boolean { + const normalizedPath = normalizeRepairOverlapPath(path); + const normalizedIgnore = normalizeRepairOverlapPath(ignorePath); + const prefix = repairOverlapPathPrefix(normalizedIgnore); + if (prefix) { + const clean = prefix.replace(/\/$/, ""); + return normalizedPath === clean || normalizedPath.startsWith(prefix); + } + return normalizedPath === normalizedIgnore || normalizedPath.startsWith(`${normalizedIgnore}/`); +} + +function filterRepairOverlapIgnoredPaths(paths: string[], ignorePaths: string[]): string[] { + if (ignorePaths.length === 0) return paths; + return paths.filter((path) => !ignorePaths.some((ignorePath) => repairIgnoredOverlapPath(path, ignorePath))); +} + export class TaskStore extends EventEmitter { private static readonly ACTIVE_TASKS_WHERE = '"deletedAt" IS NULL'; /** U6: sentinel effective-workflow id for default-workflow (null-selection) @@ -10767,6 +10840,174 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return extractEffectiveWriteScopeFromPrompt(content); } + async repairOverlapBlocker(id: string, options: RepairOverlapBlockerOptions = {}): Promise { + const dryRun = options.dryRun === true; + const task = await this.getTask(id); + if (!task) { + return { taskId: id, dryRun, repaired: false, statusCleared: false, reason: "task-not-found", message: `Task ${id} not found` }; + } + + const previousOverlapBlockedBy = task.overlapBlockedBy ?? undefined; + if (!previousOverlapBlockedBy) { + return { taskId: id, dryRun, repaired: false, statusCleared: false, reason: "no-overlap-blocker", message: `Task ${id} has no overlap blocker`, task }; + } + + if (task.column !== "todo") { + return { + taskId: id, + dryRun, + repaired: false, + statusCleared: false, + previousOverlapBlockedBy, + reason: "not-repairable-state", + message: `Task ${id} is in ${task.column}, not a repairable todo state`, + task, + }; + } + + const tasks = await this.listTasks({ includeArchived: true, slim: true }); + const taskById = new Map(tasks.map((candidate) => [candidate.id, candidate])); + const blocker = taskById.get(previousOverlapBlockedBy); + if (!blocker) { + return { + taskId: id, + dryRun, + repaired: false, + statusCleared: false, + previousOverlapBlockedBy, + reason: "blocker-missing", + message: `Overlap blocker ${previousOverlapBlockedBy} is missing; run scheduler/self-healing to reconcile current blockers`, + task, + }; + } + + const settings = await this.getSettings(); + const ignorePaths = settings.overlapIgnorePaths ?? []; + const scopeCache = new Map(); + const getScope = async (taskId: string): Promise => { + const cached = scopeCache.get(taskId); + if (cached) return cached; + const scope = filterRepairOverlapIgnoredPaths(await this.parseFileScopeFromPrompt(taskId), ignorePaths); + scopeCache.set(taskId, scope); + return scope; + }; + + const taskScope = await getScope(task.id); + const blockerScope = await getScope(blocker.id); + if (repairScopesOverlap(taskScope, blockerScope)) { + return { + taskId: id, + dryRun, + repaired: false, + statusCleared: false, + previousOverlapBlockedBy, + currentOverlapBlockedBy: previousOverlapBlockedBy, + reason: "scopes-still-overlap", + message: `Task ${id} still overlaps ${previousOverlapBlockedBy}`, + task, + }; + } + + const unresolvedDeps = (task.dependencies ?? []).filter((depId) => { + const dep = taskById.get(depId); + return dep && !dep.deletedAt && dep.column !== "done" && dep.column !== "in-review" && dep.column !== "archived"; + }); + + const currentOverlapBlocker = await this.findCurrentOverlapBlockerForRepair(task, taskScope, tasks, getScope, previousOverlapBlockedBy); + const statusCleared = unresolvedDeps.length === 0 && !currentOverlapBlocker && task.status === "queued"; + + if (currentOverlapBlocker) { + if (!dryRun) { + await this.updateTask(id, { overlapBlockedBy: currentOverlapBlocker, status: "queued" }); + await this.logEntry(id, `Repaired stale overlap blocker: rerouted from ${previousOverlapBlockedBy} to ${currentOverlapBlocker}${options.reason ? ` — ${options.reason}` : ""}`); + } + return { + taskId: id, + dryRun, + repaired: !dryRun, + statusCleared: false, + previousOverlapBlockedBy, + currentOverlapBlockedBy: currentOverlapBlocker, + reason: "rerouted-to-current-overlap", + message: `Stale overlap blocker ${previousOverlapBlockedBy} rerouted to ${currentOverlapBlocker}`, + task: dryRun ? task : await this.getTask(id) ?? undefined, + }; + } + + if (!dryRun) { + await this.updateTask(id, { + overlapBlockedBy: null, + ...(statusCleared ? { status: null } : {}), + ...(unresolvedDeps.length > 0 ? { blockedBy: unresolvedDeps[0] } : {}), + }); + await this.logEntry( + id, + `Repaired stale overlap blocker: cleared ${previousOverlapBlockedBy}; statusCleared=${statusCleared}${unresolvedDeps.length > 0 ? `; dependency blocker remains ${unresolvedDeps[0]}` : ""}${options.reason ? ` — ${options.reason}` : ""}`, + ); + } + + return { + taskId: id, + dryRun, + repaired: !dryRun, + statusCleared, + previousOverlapBlockedBy, + reason: unresolvedDeps.length > 0 ? "dependency-blocker-remains" : "repaired", + message: unresolvedDeps.length > 0 + ? `Cleared stale overlap blocker ${previousOverlapBlockedBy}; dependency blocker remains ${unresolvedDeps[0]}` + : `Cleared stale overlap blocker ${previousOverlapBlockedBy}`, + task: dryRun ? task : await this.getTask(id) ?? undefined, + }; + } + + private async findCurrentOverlapBlockerForRepair( + task: Task, + taskScope: string[], + tasks: Task[], + getScope: (taskId: string) => Promise, + previousOverlapBlockedBy: string, + ): Promise { + const activeCandidates = tasks + .filter((candidate) => candidate.id !== task.id && candidate.id !== previousOverlapBlockedBy) + .filter((candidate) => candidate.column === "in-progress" || (candidate.column === "in-review" && Boolean(candidate.worktree) && !candidate.paused && candidate.status !== "failed")) + .sort((a, b) => a.id.localeCompare(b.id)); + + for (const candidate of activeCandidates) { + const candidateScope = await getScope(candidate.id); + if (repairScopesOverlap(taskScope, candidateScope)) return candidate.id; + } + + const priorityRank: Record = { urgent: 0, high: 1, normal: 2, low: 3 }; + const taskRank = priorityRank[task.priority ?? "normal"] ?? 2; + const taskCreatedAt = Date.parse(task.createdAt); + const queuedCandidates = tasks + .filter((candidate) => candidate.id !== task.id && candidate.id !== previousOverlapBlockedBy && candidate.column === "todo") + .filter((candidate) => { + const candidateRank = priorityRank[candidate.priority ?? "normal"] ?? 2; + if (candidateRank < taskRank) return true; + if (candidateRank > taskRank) return false; + const candidateCreatedAt = Date.parse(candidate.createdAt); + if (Number.isFinite(candidateCreatedAt) && Number.isFinite(taskCreatedAt) && candidateCreatedAt !== taskCreatedAt) { + return candidateCreatedAt < taskCreatedAt; + } + return candidate.id.localeCompare(task.id) < 0; + }) + .sort((a, b) => { + const priorityDiff = (priorityRank[a.priority ?? "normal"] ?? 2) - (priorityRank[b.priority ?? "normal"] ?? 2); + if (priorityDiff !== 0) return priorityDiff; + const ageDiff = Date.parse(a.createdAt) - Date.parse(b.createdAt); + if (Number.isFinite(ageDiff) && ageDiff !== 0) return ageDiff; + return a.id.localeCompare(b.id); + }); + + for (const candidate of queuedCandidates) { + const candidateScope = await getScope(candidate.id); + if (repairScopesOverlap(taskScope, candidateScope)) return candidate.id; + } + + return null; + } + private makeSyntheticDeleteRunId(taskId: string): string { return `synthetic-task-delete-${taskId}-${Date.now()}-${randomUUID().slice(0, 8)}`; } diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 08d78d6d76..2ceee04899 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -458,6 +458,29 @@ export async function createTask( } } +export interface RepairOverlapBlockerResult { + taskId: string; + dryRun: boolean; + repaired: boolean; + statusCleared: boolean; + previousOverlapBlockedBy?: string; + currentOverlapBlockedBy?: string; + reason: string; + message: string; + task?: Task; +} + +export function repairOverlapBlocker( + id: string, + options: { dryRun?: boolean; reason?: string } = {}, + projectId?: string, +): Promise { + return api(withProjectId(`/tasks/${id}/repair-overlap-blocker`, projectId), { + method: "POST", + body: JSON.stringify(options), + }); +} + export function updateTask( id: string, updates: { diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 66efd7109b..27992cb695 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -24,7 +24,7 @@ import { } from "@fusion/core"; import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical"; import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge"; -import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, api } from "../api"; +import { uploadAttachment, deleteAttachment, updateTask, repairOverlapBlocker, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, api } from "../api"; import type { WorkflowFieldDefinition, CustomFieldRejection } from "../api"; import { ApiRequestError } from "../api"; import { TaskFieldsSection } from "./TaskFieldsSection"; @@ -692,6 +692,7 @@ export function TaskDetailContent({ paused: task.paused === undefined ? fullDetail.paused : task.paused, userPaused: task.userPaused === undefined ? fullDetail.userPaused : task.userPaused, pausedReason: task.pausedReason === undefined ? fullDetail.pausedReason : task.pausedReason, + overlapBlockedBy: task.overlapBlockedBy === undefined ? undefined : fullDetail.overlapBlockedBy, } as TaskDetail) : ({ ...task, prompt: "" } as TaskDetail); const canRetryTask = @@ -2466,41 +2467,31 @@ export function TaskDetailContent({ if (!workingTask.overlapBlockedBy) return; const requestTaskId = task.id; - const previousOverlapBlockedBy = workingTask.overlapBlockedBy; - const previousStatus = workingTask.status; - - setFullDetail((prev) => prev - ? { - ...prev, - overlapBlockedBy: undefined, - ...(previousStatus === "queued" ? { status: undefined } : {}), - } - : prev); try { - const updatedTask = await updateTask(task.id, { - overlapBlockedBy: null, - status: previousStatus === "queued" ? null : undefined, - }, projectId); + const result = await repairOverlapBlocker(task.id, { reason: "dashboard-clear-overlap-blocker" }, projectId); if (activeTaskIdRef.current !== requestTaskId) { return; } - setFullDetail((prev) => prev ? ({ ...prev, ...updatedTask } as TaskDetail) : (updatedTask as TaskDetail)); - onTaskUpdated?.(updatedTask); + if (result.task) { + setFullDetail((prev) => prev ? ({ ...prev, ...result.task } as TaskDetail) : (result.task as TaskDetail)); + onTaskUpdated?.(result.task); + } else { + const updatedTask = await fetchTaskDetail(task.id, projectId); + if (activeTaskIdRef.current !== requestTaskId) { + return; + } + setFullDetail((prev) => prev ? ({ ...prev, ...updatedTask } as TaskDetail) : updatedTask); + onTaskUpdated?.(updatedTask); + } + addToast(result.message, "success"); } catch (err) { if (activeTaskIdRef.current !== requestTaskId) { return; } - setFullDetail((prev) => prev - ? { - ...prev, - overlapBlockedBy: previousOverlapBlockedBy, - ...(previousStatus === "queued" ? { status: previousStatus } : {}), - } - : prev); addToast(getErrorMessage(err), "error"); } - }, [activeTaskIdRef, addToast, onTaskUpdated, projectId, task.id, workingTask.overlapBlockedBy, workingTask.status]); + }, [activeTaskIdRef, addToast, onTaskUpdated, projectId, task.id, workingTask.overlapBlockedBy]); const handleDepClick = useCallback(async (depId: string) => { try { diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx index 9628cb2e4a..742a0ff53f 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx @@ -545,10 +545,17 @@ describe("TaskDetailModal", () => { expect(screen.queryByRole("button", { name: "Clear" })).toBeNull(); }); - it("clears overlap blocker and queued status when clicking Clear", async () => { - vi.mocked(dashboardApi.updateTask).mockResolvedValueOnce( - makeTask({ id: "FN-T", column: "todo", overlapBlockedBy: undefined, status: undefined }), - ); + it("repairs overlap blocker when clicking Clear", async () => { + vi.mocked(dashboardApi.repairOverlapBlocker).mockResolvedValueOnce({ + taskId: "FN-T", + dryRun: false, + repaired: true, + statusCleared: true, + previousOverlapBlockedBy: "FN-OVER", + reason: "repaired", + message: "Cleared stale overlap blocker FN-OVER", + task: makeTask({ id: "FN-T", column: "todo", overlapBlockedBy: undefined, status: undefined }), + }); render( { await userEvent.click(screen.getByRole("button", { name: "Clear" })); await waitFor(() => { - expect(dashboardApi.updateTask).toHaveBeenCalledWith( + expect(dashboardApi.repairOverlapBlocker).toHaveBeenCalledWith( "FN-T", - { overlapBlockedBy: null, status: null }, + { reason: "dashboard-clear-overlap-blocker" }, undefined, ); }); }); - it("clears overlap blocker without status clear when task is not queued", async () => { - vi.mocked(dashboardApi.updateTask).mockResolvedValueOnce( - makeTask({ id: "FN-T", column: "todo", overlapBlockedBy: undefined }), - ); + it("applies rerouted overlap blocker returned by repair API", async () => { + vi.mocked(dashboardApi.repairOverlapBlocker).mockResolvedValueOnce({ + taskId: "FN-T", + dryRun: false, + repaired: true, + statusCleared: false, + previousOverlapBlockedBy: "FN-OLD", + currentOverlapBlockedBy: "FN-NEW", + reason: "rerouted-to-current-overlap", + message: "Stale overlap blocker FN-OLD rerouted to FN-NEW", + task: makeTask({ id: "FN-T", column: "todo", overlapBlockedBy: "FN-NEW", status: "queued" }), + }); render( { await userEvent.click(screen.getByRole("button", { name: "Clear" })); await waitFor(() => { - expect(dashboardApi.updateTask).toHaveBeenCalledWith( - "FN-T", - { overlapBlockedBy: null, status: undefined }, - undefined, - ); + expect(screen.getByText("File scope overlap blocker: FN-NEW (stale)")).toBeInTheDocument(); }); }); - it("shows toast and restores overlap blocker when clear fails", async () => { + it("shows toast and preserves overlap blocker when repair fails", async () => { const addToast = vi.fn(); - vi.mocked(dashboardApi.updateTask).mockRejectedValueOnce(new Error("boom")); + vi.mocked(dashboardApi.repairOverlapBlocker).mockRejectedValueOnce(new Error("boom")); render( { uploadAttachment: vi.fn(), deleteAttachment: vi.fn(), updateTask: vi.fn().mockResolvedValue({}), + repairOverlapBlocker: vi.fn().mockResolvedValue({ repaired: true, statusCleared: false, reason: "repaired", message: "Repaired", task: makeTask() }), summarizeTitle: vi.fn().mockResolvedValue("Generated Title"), fetchTaskDetail: vi.fn().mockResolvedValue(makeTask()), fetchAgentLogs: vi.fn().mockResolvedValue([]), diff --git a/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts b/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts index 91cf008167..1b8bfcc4a8 100644 --- a/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts +++ b/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts @@ -178,6 +178,7 @@ function createMockStore(overrides: Partial = {}): TaskStore { createTask: vi.fn(), moveTask: vi.fn(), updateTask: vi.fn(), + repairOverlapBlocker: vi.fn(), updateStep: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), @@ -2343,6 +2344,58 @@ describe("PATCH /tasks/:id", () => { expect(res.body.dependencies).toEqual(["FN-002"]); }); + it("repairs stale overlap blocker through supported route", async () => { + const result = { + taskId: "KB-001", + dryRun: false, + repaired: true, + statusCleared: true, + previousOverlapBlockedBy: "FN-756", + reason: "repaired", + message: "Cleared stale overlap blocker FN-756", + task: { ...FAKE_TASK_DETAIL, overlapBlockedBy: undefined, status: undefined }, + }; + (store.repairOverlapBlocker as ReturnType).mockResolvedValue(result); + + const res = await REQUEST( + buildApp(), + "POST", + "/api/tasks/KB-001/repair-overlap-blocker", + JSON.stringify({ reason: "operator" }), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(200); + expect(store.repairOverlapBlocker).toHaveBeenCalledWith("KB-001", { reason: "operator" }); + expect(res.body).toMatchObject({ repaired: true, statusCleared: true, previousOverlapBlockedBy: "FN-756" }); + expect(store.updateTask).not.toHaveBeenCalled(); + }); + + it("rejects overlap blocker repair when scopes still overlap", async () => { + (store.repairOverlapBlocker as ReturnType).mockResolvedValue({ + taskId: "KB-001", + dryRun: false, + repaired: false, + statusCleared: false, + previousOverlapBlockedBy: "FN-756", + currentOverlapBlockedBy: "FN-756", + reason: "scopes-still-overlap", + message: "Task KB-001 still overlaps FN-756", + }); + + const res = await REQUEST( + buildApp(), + "POST", + "/api/tasks/KB-001/repair-overlap-blocker", + JSON.stringify({}), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(409); + expect(res.body.error).toBe("Task KB-001 still overlaps FN-756"); + expect(store.updateTask).not.toHaveBeenCalled(); + }); + it("clears overlapBlockedBy when null is provided", async () => { const updatedTask = { ...FAKE_TASK_DETAIL, overlapBlockedBy: undefined }; (store.updateTask as ReturnType).mockResolvedValue(updatedTask); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index b991770a0d..5c1b11b9d5 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -3020,6 +3020,39 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } }); + router.post("/tasks/:id/repair-overlap-blocker", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const { dryRun, reason } = req.body ?? {}; + if (dryRun !== undefined && typeof dryRun !== "boolean") { + throw badRequest("dryRun must be a boolean"); + } + if (reason !== undefined && reason !== null && typeof reason !== "string") { + throw badRequest("reason must be a string"); + } + if (typeof scopedStore.repairOverlapBlocker !== "function") { + throw new ApiError(501, "Overlap blocker repair is unavailable for this store"); + } + const result = await scopedStore.repairOverlapBlocker(req.params.id, { + dryRun, + ...(typeof reason === "string" && reason.trim().length > 0 ? { reason: reason.trim() } : {}), + }); + if (result.reason === "task-not-found") { + throw notFound(result.message); + } + if (!result.repaired && !result.dryRun && result.reason !== "repaired") { + const status = result.reason === "no-overlap-blocker" ? 400 : 409; + throw new ApiError(status, result.message); + } + res.json(result); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + throw new ApiError(500, err instanceof Error ? err.message : String(err)); + } + }); + // Update task router.patch("/tasks/:id", async (req, res) => { try {