From c8be19f9e65d41fb832c769ee732f276ad631666 Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Thu, 25 Jun 2026 02:27:17 -0700 Subject: [PATCH 01/10] test(FN-783): cover poisoned write-scope metadata Fusion-Task-Id: FN-783 Co-authored-by: Fusion --- .../core/src/__tests__/store-parsing.test.ts | 90 +++++++++++++++++++ packages/engine/src/__tests__/triage.test.ts | 78 ++++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/packages/core/src/__tests__/store-parsing.test.ts b/packages/core/src/__tests__/store-parsing.test.ts index ed2bcad1e8..9bcd1fd30d 100644 --- a/packages/core/src/__tests__/store-parsing.test.ts +++ b/packages/core/src/__tests__/store-parsing.test.ts @@ -419,6 +419,96 @@ describe("TaskStore", () => { const paths = await store.parseFileScopeFromPrompt(task.id); expect(paths).toEqual(["packages/dashboard/app/components/TaskDetailModal.tsx"]); }); + + it("deduplicates effective write scope while preserving broad mixed-case source globs", async () => { + const task = await store.createTask({ description: "Duplicate effective scope" }); + const dir = join(rootDir, ".fusion", "tasks", task.id); + await writeFile( + join(dir, "PROMPT.md"), + `# ${task.id}: Duplicate effective scope + +## File Scope + +- \`packages/core/**\` +- \`packages/core/**\` +- \`Packages/MobileApp/**\` +- \`Tests/AtlasNotesMobileUITests/**\` +`, + ); + + const paths = await store.parseFileScopeFromPrompt(task.id); + expect(paths).toEqual([ + "packages/core/**", + "Packages/MobileApp/**", + "Tests/AtlasNotesMobileUITests/**", + ]); + }); + + it("excludes poisoned FN-779/FN-756 context-only paths from effective write scope", async () => { + const task = await store.createTask({ description: "Poisoned Fusion prompt" }); + const dir = join(rootDir, ".fusion", "tasks", task.id); + await writeFile( + join(dir, "PROMPT.md"), + `# ${task.id}: Poisoned Fusion prompt + +## File Scope + +Expected touched paths in \`/Users/plarson/src/Fusion-local-runtime\`: + +- \`packages/core/src/store.ts\` +- \`packages/engine/src/scheduler.ts\` +- \`packages/dashboard/**\` +- \`packages/cli/**\` +- \`packages/core/src/__tests__/store-parsing.test.ts\` + +Forbidden paths / non-goals: + +- Do not edit Atlas Notes Swift/mobile files: \`project.yml\`, \`AtlasNotes.xcodeproj/**\`, \`Tests/AtlasNotesMobileUITests/**\`, \`Packages/MobileApp/**\`, \`Sources/**\`. +- Do not hand-edit \`.fusion/fusion.db\` or \`.fusion/tasks/*/task.json\`. +- Generated locks such as \`Packages/*/Package.resolved\` are evidence only. +- \`.changeset/*.md\` is required only if published behavior changes. +- Operator routes/actions: \`/tasks/:id\`, \`fn_task_update\`, \`review\`, \`merge\`, \`retry\`, \`archive\`. +`, + ); + + const paths = await store.parseFileScopeFromPrompt(task.id); + expect(paths).toEqual([ + "packages/core/src/store.ts", + "packages/engine/src/scheduler.ts", + "packages/dashboard/**", + "packages/cli/**", + "packages/core/src/__tests__/store-parsing.test.ts", + ]); + }); + + it("keeps true Atlas mobile hot-file family writes when declared as implementation scope", async () => { + const task = await store.createTask({ description: "Atlas mobile scope" }); + const dir = join(rootDir, ".fusion", "tasks", task.id); + await writeFile( + join(dir, "PROMPT.md"), + `# ${task.id}: Atlas mobile scope + +## File Scope + +Expected touched paths: + +- \`project.yml\` +- \`AtlasNotes.xcodeproj/**\` +- \`Tests/AtlasNotesMobileUITests/**\` +- \`Packages/MobileApp/**\` +- \`Sources/AtlasNotesMobileApp/**\` +`, + ); + + const paths = await store.parseFileScopeFromPrompt(task.id); + expect(paths).toEqual([ + "project.yml", + "AtlasNotes.xcodeproj/**", + "Tests/AtlasNotesMobileUITests/**", + "Packages/MobileApp/**", + "Sources/AtlasNotesMobileApp/**", + ]); + }); }); describe("FN-5216 File Scope sanitization on copy paths", () => { diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 6e02f94282..c76e96a26b 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -1813,6 +1813,84 @@ describe("approved triage recovery", () => { ); }); + it("stamps source metadata from sanitized effective write scope during recovery", async () => { + await writeFile( + join(rootDir, ".fusion", "tasks", "FN-001", "PROMPT.md"), + `# Task: FN-001 - Fix poisoned scope + +**Size:** L + +## File Scope + +Expected touched paths: + +- \`packages/core/src/store.ts\` +- \`packages/engine/src/scheduler.ts\` +- \`packages/dashboard/**\` + +Forbidden paths / non-goals: + +- Do not edit Atlas files: \`AtlasNotes.xcodeproj/**\`, \`Tests/AtlasNotesMobileUITests/**\`, \`Packages/MobileApp/**\`. +- Evidence only: \`.fusion/fusion.db\`, \`.fusion/tasks/*/task.json\`, \`Packages/*/Package.resolved\`. +- Conditional only: \`.changeset/*.md\`. +`, + ); + + const store = createMockStore({ + getSettings: vi.fn().mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 10000, + groupOverlappingFiles: false, + autoMerge: true, + requirePlanApproval: false, + } as Settings), + parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), + }); + + const processor = new TriageProcessor(store, rootDir); + const recovered = await processor.recoverApprovedTask({ + id: "FN-001", + description: "Recovered triage task", + column: "triage", + status: "planning", + dependencies: [], + steps: [], + currentStep: 0, + log: [{ timestamp: "2026-01-01T00:00:00.000Z", action: "Spec review: APPROVE" }], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:02:00.000Z", + }); + + expect(recovered).toBe(true); + expect(store.updateTask).toHaveBeenCalledWith( + "FN-001", + expect.objectContaining({ + sourceMetadataPatch: expect.objectContaining({ + fileScope: [ + "packages/core/src/store.ts", + "packages/engine/src/scheduler.ts", + "packages/dashboard/**", + ], + intentSignature: expect.objectContaining({ + filePaths: [ + "packages/core/src/store.ts", + "packages/engine/src/scheduler.ts", + ], + }), + }), + }), + ); + const metadataPatch = (store.updateTask as ReturnType).mock.calls + .map(([, patch]) => patch?.sourceMetadataPatch) + .find(Boolean); + expect(metadataPatch.fileScope).not.toContain("AtlasNotes.xcodeproj/**"); + expect(metadataPatch.fileScope).not.toContain(".fusion/fusion.db"); + expect(metadataPatch.fileScope).not.toContain("Packages/*/Package.resolved"); + expect(metadataPatch.fileScope).not.toContain(".changeset/*.md"); + expect(metadataPatch.intentSignature.filePaths).not.toContain("AtlasNotes.xcodeproj/**"); + }); + it("updates malformed metadata title from prompt heading when task ID matches", async () => { await writeFile( join(rootDir, ".fusion", "tasks", "FN-001", "PROMPT.md"), From 41d1d5b74bb632505238befcc9ef79ee31605397 Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Thu, 25 Jun 2026 02:33:18 -0700 Subject: [PATCH 02/10] =?UTF-8?q?feat(FN-783):=20complete=20Step=203=20?= =?UTF-8?q?=E2=80=94=20shared=20write-scope=20classification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fusion-Task-Id: FN-783 Co-authored-by: Fusion --- .../core/src/file-scope-classification.ts | 181 ++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/src/store.ts | 77 +------- packages/engine/src/triage.ts | 16 +- 4 files changed, 187 insertions(+), 88 deletions(-) create mode 100644 packages/core/src/file-scope-classification.ts diff --git a/packages/core/src/file-scope-classification.ts b/packages/core/src/file-scope-classification.ts new file mode 100644 index 0000000000..adf4f8fc7d --- /dev/null +++ b/packages/core/src/file-scope-classification.ts @@ -0,0 +1,181 @@ +export type FileScopeClassificationReason = + | "included-write-scope" + | "invalid-entry" + | "duplicate-entry" + | "read-only-context" + | "forbidden-or-non-goal" + | "wrong-worktree-safeguard" + | "route-or-action" + | "fusion-metadata-evidence" + | "generated-lock" + | "conditional-changeset"; + +export interface FileScopeClassificationEntry { + token: string; + included: boolean; + reason: FileScopeClassificationReason; + line: string; +} + +export interface FileScopeClassificationResult { + entries: FileScopeClassificationEntry[]; + effectiveWriteScope: string[]; +} + +const KNOWN_FILE_SCOPE_ROOT_FILES = new Set([ + "makefile", + "dockerfile", + "justfile", + "license", + "readme", + "changelog", + "agents.md", + "project.yml", + "package.json", + "pnpm-lock.yaml", +]); + +const INCLUDE_CONTEXT_RE = /\b(expected|touched|touch|modify|modified|write|writes|implementation|must update|artifacts?|files? changed|source paths?)\b/i; +const EXCLUDE_CONTEXT_RE = /\b(forbidden|non-goals?|out of scope|do not edit|do not modify|must not edit|must not modify|do not hand-edit|hand-edit|read-only|context to read|evidence only|metadata|wrong[- ]worktree|safeguards?)\b/i; +const GENERATED_CONTEXT_RE = /\b(generated|lockfiles?|locks?)\b/i; +const CONDITIONAL_CONTEXT_RE = /\b(conditional|only if|if .*changes|if .*changed|expected if|required only if|unless)\b/i; +const ROUTE_OR_ACTION_RE = /^(?:\/[A-Za-z0-9:_*?./-]+|fn_task_[A-Za-z0-9_]+|review|merge|retry|archive)$/i; + +export function isValidFileScopeEntry(token: string): boolean { + const trimmed = token.trim(); + if (!trimmed) return false; + + const lower = trimmed.toLowerCase(); + if ( + lower.startsWith("origin/") + || lower.startsWith("upstream/") + || lower.startsWith("refs/") + || /^https?:\/\//i.test(trimmed) + || /^git@/i.test(trimmed) + || /^ssh:\/\//i.test(trimmed) + || /^[a-z]+\/fn-\d+$/i.test(trimmed) + || /^[a-f0-9]{7,}$/i.test(trimmed) + || trimmed.includes("..") + || trimmed.startsWith("/") + ) { + return false; + } + + const segments = trimmed.split("/"); + const lastSegment = segments[segments.length - 1] ?? ""; + const hasSlash = trimmed.includes("/"); + const hasDotInLastSegment = lastSegment.includes("."); + + if (KNOWN_FILE_SCOPE_ROOT_FILES.has(lastSegment.toLowerCase())) { + return true; + } + + if (trimmed.includes("**") || trimmed.endsWith("/*") || (lastSegment.includes("*") && hasDotInLastSegment)) { + return true; + } + + if (hasSlash && hasDotInLastSegment) { + return true; + } + + return false; +} + +export function extractFileScopeTokens(content: string): string[] { + const section = extractFileScopeSection(content); + if (!section) return []; + return extractBacktickedTokens(section); +} + +export function extractEffectiveWriteScopeFromPrompt(content: string): string[] { + return classifyFileScopeFromPrompt(content).effectiveWriteScope; +} + +export function classifyFileScopeFromPrompt(content: string): FileScopeClassificationResult { + const section = extractFileScopeSection(content); + if (!section) return { entries: [], effectiveWriteScope: [] }; + + const entries: FileScopeClassificationEntry[] = []; + const effectiveWriteScope: string[] = []; + const seen = new Set(); + let context: "include" | "exclude" | "conditional" = "include"; + + for (const rawLine of section.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + const lower = line.toLowerCase(); + + if (INCLUDE_CONTEXT_RE.test(line) && !EXCLUDE_CONTEXT_RE.test(line) && !CONDITIONAL_CONTEXT_RE.test(line)) { + context = "include"; + } + if (EXCLUDE_CONTEXT_RE.test(line)) { + context = lower.includes("wrong-worktree") || lower.includes("wrong worktree") ? "exclude" : "exclude"; + } + if (CONDITIONAL_CONTEXT_RE.test(line)) { + context = "conditional"; + } + + const tokens = extractBacktickedTokens(line); + for (const rawToken of tokens) { + const token = rawToken.trim(); + const reason = classifyToken(token, line, context); + if (reason !== "included-write-scope") { + entries.push({ token, included: false, reason, line }); + continue; + } + if (seen.has(token)) { + entries.push({ token, included: false, reason: "duplicate-entry", line }); + continue; + } + seen.add(token); + effectiveWriteScope.push(token); + entries.push({ token, included: true, reason, line }); + } + } + + return { entries, effectiveWriteScope }; +} + +function classifyToken( + token: string, + line: string, + context: "include" | "exclude" | "conditional", +): FileScopeClassificationReason { + if (ROUTE_OR_ACTION_RE.test(token)) return "route-or-action"; + if (!isValidFileScopeEntry(token)) return "invalid-entry"; + + const lowerToken = token.toLowerCase(); + const lowerLine = line.toLowerCase(); + if (lowerToken.startsWith(".fusion/") || lowerToken === ".fusion") return "fusion-metadata-evidence"; + if (/^packages\/[^/]+\/package\.resolved$/i.test(token) || /^packages\/\*\/package\.resolved$/i.test(token)) { + return "generated-lock"; + } + if (lowerToken.startsWith(".changeset/") && (context === "conditional" || CONDITIONAL_CONTEXT_RE.test(line))) { + return "conditional-changeset"; + } + if (context === "conditional") return "read-only-context"; + if (context === "exclude") { + if (lowerLine.includes("wrong-worktree") || lowerLine.includes("wrong worktree") || lowerLine.includes("safeguard")) { + return "wrong-worktree-safeguard"; + } + if (lowerLine.includes("forbidden") || lowerLine.includes("non-goal") || lowerLine.includes("do not edit") || lowerLine.includes("do not modify")) { + return "forbidden-or-non-goal"; + } + return "read-only-context"; + } + if (GENERATED_CONTEXT_RE.test(line) && lowerToken.endsWith("package.resolved")) return "generated-lock"; + return "included-write-scope"; +} + +function extractFileScopeSection(content: string): string | null { + const headingMatch = content.match(/^##\s+File\s+Scope\s*$/m); + if (!headingMatch) return null; + const startIdx = headingMatch.index! + headingMatch[0].length; + const rest = content.slice(startIdx); + const nextHeading = rest.search(/\n##?\s/); + return nextHeading === -1 ? rest : rest.slice(0, nextHeading); +} + +function extractBacktickedTokens(text: string): string[] { + return Array.from(text.matchAll(/`([^`]+)`/g), (match) => match[1]?.trim() ?? "").filter(Boolean); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7a16c53537..398067b035 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,6 +20,7 @@ export { redactSecrets } from "./redact-secrets.js"; export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "./near-duplicate-canonical.js"; export type { NearDuplicateCanonicalState } from "./near-duplicate-canonical.js"; export * from "./frontend-ux-policy.js"; +export * from "./file-scope-classification.js"; export { MAX_TASK_LIST_TEXT_CHARS, clampTaskListText, formatTaskListText } from "./task-list-format.js"; export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js"; export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js"; diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 64d26fca25..529f074e77 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -18,6 +18,7 @@ import { import { parseWorkflowIr, serializeWorkflowIr, downgradeIrToV1IfPure } from "./workflow-ir.js"; import { stepsToWorkflowIr, stepToFragmentIr, layoutForIr } from "./workflow-steps-to-ir.js"; import { resolveAllowedColumns, workflowHasColumn } from "./workflow-transitions.js"; +import { extractEffectiveWriteScopeFromPrompt, extractFileScopeTokens, isValidFileScopeEntry } from "./file-scope-classification.js"; function isWorkflowColumnsCompatibilityFlagEnabled(settings: Pick | undefined): boolean { /* @@ -1093,80 +1094,9 @@ export class InvalidFileScopeError extends Error { } } -const KNOWN_FILE_SCOPE_ROOT_FILES = new Set([ - "makefile", - "dockerfile", - "justfile", - "license", - "readme", - "changelog", - "agents.md", -]); - -// `parseStepHeadings` (the `### Step N:` parser, step-inversion U1) was extracted -// into `step-parsers.ts` as the `step-headings` built-in parser (U12, KTD-12). -// It is re-exported here for back-compat with callers/tests that import it from -// `store.ts`. `parseStepsFromPrompt` below delegates through the registry. +export { isValidFileScopeEntry } from "./file-scope-classification.js"; export { parseStepHeadings } from "./step-parsers.js"; -export function isValidFileScopeEntry(token: string): boolean { - const trimmed = token.trim(); - if (!trimmed) return false; - - const lower = trimmed.toLowerCase(); - if ( - lower.startsWith("origin/") - || lower.startsWith("upstream/") - || lower.startsWith("refs/") - || /^https?:\/\//i.test(trimmed) - || /^git@/i.test(trimmed) - || /^ssh:\/\//i.test(trimmed) - || /^[a-z]+\/fn-\d+$/i.test(trimmed) - || /^[a-f0-9]{7,}$/i.test(trimmed) - || trimmed.includes("..") - || trimmed.startsWith("/") - ) { - return false; - } - - const segments = trimmed.split("/"); - const lastSegment = segments[segments.length - 1]; - const hasSlash = trimmed.includes("/"); - const hasDotInLastSegment = lastSegment.includes("."); - - if (KNOWN_FILE_SCOPE_ROOT_FILES.has(lastSegment.toLowerCase())) { - return true; - } - - if (trimmed.includes("**") || trimmed.endsWith("/*") || (lastSegment.includes("*") && hasDotInLastSegment)) { - return true; - } - - if (hasSlash && hasDotInLastSegment) { - return true; - } - - return false; -} - -function extractFileScopeTokens(content: string): string[] { - const headingMatch = content.match(/^##\s+File\s+Scope\s*$/m); - if (!headingMatch) return []; - - const startIdx = headingMatch.index! + headingMatch[0].length; - const rest = content.slice(startIdx); - const nextHeading = rest.search(/\n##?\s/); - const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading); - const tokens: string[] = []; - const backtickRegex = /`([^`]+)`/g; - let match; - while ((match = backtickRegex.exec(section)) !== null) { - tokens.push(match[1]); - } - - return tokens; -} - function validateFileScopeInPromptContent(prompt: string): { valid: string[]; invalid: string[] } { const tokens = extractFileScopeTokens(prompt); const valid: string[] = []; @@ -10834,8 +10764,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} const content = await readFile(promptPath, "utf-8"); - const paths = extractFileScopeTokens(content); - return paths.filter((path) => isValidFileScopeEntry(path)); + return extractEffectiveWriteScopeFromPrompt(content); } private makeSyntheticDeleteRunId(taskId: string): string { diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 3e7eee5d9f..ad02b27e75 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -27,6 +27,7 @@ import { findNearDuplicates, isNearDuplicateCanonicalInactive, applyFrontendUxCriteria, + extractEffectiveWriteScopeFromPrompt, MAX_TASK_LIST_TEXT_CHARS, type NearDuplicateCandidate, } from "@fusion/core"; @@ -2450,20 +2451,7 @@ export class TriageProcessor { } function parseFileScopeFromPrompt(text: string): string[] { - const match = text.match(/^##\s+File Scope\s*\n([\s\S]*?)(?=^##\s+|$)/m); - if (!match) return []; - const entries: string[] = []; - for (const rawLine of match[1].split("\n")) { - const trimmed = rawLine.trim(); - if (!trimmed.startsWith("-")) continue; - const line = trimmed.replace(/^-+\s*/, "").replace(/`/g, "").trim(); - if (!line || /^out of scope/i.test(line)) break; - const pathOnly = line.split(" ")[0]?.trim(); - if (!pathOnly) continue; - entries.push(pathOnly); - if (entries.length >= 50) break; - } - return entries; + return extractEffectiveWriteScopeFromPrompt(text); } function extractPromptDeclaredTitle(prompt: string, taskId: string): string | null { From 53825ad0ab5a9708093ceb3b29ddc03562c88df9 Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Thu, 25 Jun 2026 02:52:54 -0700 Subject: [PATCH 03/10] fix(core): prefer explicit file scope for duplicate signatures --- .../core/src/__tests__/near-duplicate.test.ts | 18 ++++++++++++++++++ packages/core/src/near-duplicate.ts | 3 ++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/core/src/__tests__/near-duplicate.test.ts b/packages/core/src/__tests__/near-duplicate.test.ts index 38c10d376f..281a6ebe35 100644 --- a/packages/core/src/__tests__/near-duplicate.test.ts +++ b/packages/core/src/__tests__/near-duplicate.test.ts @@ -48,6 +48,24 @@ describe("extractIntentSignature", () => { ]), ); }); + + it("uses sanitized file scope for file-path intent when available", () => { + const sig = extractIntentSignature({ + title: "Fix poisoned file scope", + description: + "Forbidden context mentions packages/mobile/src/generated.ts, .fusion/tasks/FN-756/task.json, and packages/dashboard/src/routes/register-task-workflow-routes.ts.", + fileScope: [ + "packages/core/src/store.ts", + "packages/engine/src/scheduler.ts", + ], + }); + + expect(sig.filePaths).toEqual([ + "packages/core/src/store.ts", + "packages/engine/src/scheduler.ts", + ]); + expect(sig.filePaths).not.toContain("packages/dashboard/src/routes/register-task-workflow-routes.ts"); + }); }); describe("near-duplicate canonical activity predicates", () => { diff --git a/packages/core/src/near-duplicate.ts b/packages/core/src/near-duplicate.ts index 5a239645b9..401d5607b4 100644 --- a/packages/core/src/near-duplicate.ts +++ b/packages/core/src/near-duplicate.ts @@ -113,9 +113,10 @@ export function extractIntentSignature(input: NearDuplicateInput): IntentSignatu const title = input.title ?? ""; const fileScope = input.fileScope ?? []; const text = `${title}\n${input.description}\n${fileScope.join("\n")}`; + const filePathText = fileScope.length > 0 ? fileScope.join("\n") : input.description; return { routePaths: extractRoutePaths(text), - filePaths: extractFilePaths(text), + filePaths: extractFilePaths(filePathText), identifiers: extractIdentifiers(text), titleTokens: toUnique(tokenize(title).filter((token) => token.length >= 3)), }; From e2a84d4dd69a9758eb430da01512a557cf7b8ddc Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Thu, 25 Jun 2026 03:15:43 -0700 Subject: [PATCH 04/10] =?UTF-8?q?feat(FN-783):=20complete=20Step=204=20?= =?UTF-8?q?=E2=80=94=20reconcile=20stale=20overlap=20blockers=20safely?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fusion-Task-Id: FN-783 Co-authored-by: Fusion --- .../scheduler-overlap-starvation.test.ts | 95 +++++++++++++++++++ .../engine/src/__tests__/self-healing.test.ts | 28 +++++- packages/engine/src/self-healing.ts | 63 +++++++++--- 3 files changed, 170 insertions(+), 16 deletions(-) diff --git a/packages/engine/src/__tests__/scheduler-overlap-starvation.test.ts b/packages/engine/src/__tests__/scheduler-overlap-starvation.test.ts index 457282ee4a..46f5a71302 100644 --- a/packages/engine/src/__tests__/scheduler-overlap-starvation.test.ts +++ b/packages/engine/src/__tests__/scheduler-overlap-starvation.test.ts @@ -360,4 +360,99 @@ describe("scheduler overlap starvation regression (FN-057)", () => { ); }); + it("does not preserve FN-779/FN-756 poisoned cross-repository blocker after write-scope sanitization", async () => { + const tasks = [ + makeTask({ id: "FN-756", column: "in-progress", priority: "normal", title: "iPad mobile XCUITest work" }), + makeTask({ + id: "FN-779", + column: "todo", + status: "queued", + priority: "high", + overlapBlockedBy: "FN-756", + title: "Fusion engine heartbeat suppression", + }), + ]; + const store = createStore(tasks, { + "FN-756": ["project.yml", "AtlasNotes.xcodeproj/**", "Tests/AtlasNotesMobileUITests/**", "Packages/MobileApp/**"], + "FN-779": ["packages/core/**", "packages/engine/**", "packages/dashboard/**", "packages/cli/**"], + }); + + const scheduler = new Scheduler(store); + (scheduler as any).running = true; + await scheduler.schedule(); + + expect(store.updateTask).toHaveBeenCalledWith("FN-779", { overlapBlockedBy: null }); + expect(store.moveTask).toHaveBeenCalledWith("FN-779", "in-progress", expect.anything()); + expect(store.updateTask).not.toHaveBeenCalledWith( + "FN-779", + expect.objectContaining({ status: "queued", overlapBlockedBy: "FN-756" }), + ); + }); + + it("keeps true Atlas hot-file-family overlaps blocked", async () => { + const tasks = [ + makeTask({ id: "FN-756", column: "in-progress", priority: "normal" }), + makeTask({ id: "FN-800", column: "todo", priority: "high" }), + ]; + const store = createStore(tasks, { + "FN-756": ["project.yml", "AtlasNotes.xcodeproj/**", "Tests/AtlasNotesMobileUITests/**", "Packages/MobileApp/**"], + "FN-800": ["Tests/AtlasNotesMobileUITests/**", "Packages/MobileApp/**"], + }); + + const scheduler = new Scheduler(store); + (scheduler as any).running = true; + await scheduler.schedule(); + + expect(store.updateTask).toHaveBeenCalledWith("FN-800", { + status: "queued", + blockedBy: null, + overlapBlockedBy: "FN-756", + }); + expect(store.moveTask).not.toHaveBeenCalledWith("FN-800", "in-progress", expect.anything()); + }); + + it("reroutes a stale overlap blocker to another current active lease", async () => { + const tasks = [ + makeTask({ id: "FN-OLD", column: "done", priority: "normal" }), + makeTask({ id: "FN-NEW", column: "in-progress", priority: "normal" }), + makeTask({ id: "FN-900", column: "todo", status: "queued", priority: "high", overlapBlockedBy: "FN-OLD" }), + ]; + const store = createStore(tasks, { + "FN-OLD": ["packages/core/src/store.ts"], + "FN-NEW": ["packages/engine/src/scheduler.ts"], + "FN-900": ["packages/engine/src/scheduler.ts"], + }); + + const scheduler = new Scheduler(store); + (scheduler as any).running = true; + await scheduler.schedule(); + + expect(store.updateTask).toHaveBeenCalledWith("FN-900", { + status: "queued", + blockedBy: null, + overlapBlockedBy: "FN-NEW", + }); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-900", + "queued — blocked by active file-scope lease FN-NEW (column=in-progress)", + ); + expect(store.moveTask).not.toHaveBeenCalledWith("FN-900", "in-progress", expect.anything()); + }); + + it("clears an absent overlap blocker only after confirming no current overlap remains", async () => { + const tasks = [ + makeTask({ id: "FN-901", column: "todo", status: "queued", priority: "normal", overlapBlockedBy: "FN-MISSING" }), + ]; + const store = createStore(tasks, { + "FN-901": ["packages/engine/src/scheduler.ts"], + }); + + const scheduler = new Scheduler(store); + (scheduler as any).running = true; + await scheduler.schedule(); + + expect(store.updateTask).toHaveBeenCalledWith("FN-901", { overlapBlockedBy: null }); + expect(store.moveTask).toHaveBeenCalledWith("FN-901", "in-progress", expect.anything()); + }); + }); diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 4305fd3963..e494363222 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -7184,7 +7184,7 @@ describe("FN-4538 overlapBlockedBy self-healing", () => { return { id, column: "todo", paused: false, blockedBy: null, dependencies: [], mergeRetries: 0, ...overrides }; } - function makeStore(tasks: Record[]) { + function makeStore(tasks: Record[], scopes: Record = {}) { const store = createMockStore({ getSettings: vi.fn().mockResolvedValue({ autoUnpauseEnabled: false, @@ -7192,6 +7192,7 @@ describe("FN-4538 overlapBlockedBy self-healing", () => { globalPause: false, enginePaused: false, } as unknown as Settings), + parseFileScopeFromPrompt: vi.fn(async (taskId: string) => scopes[taskId] ?? ["packages/engine/src/scheduler.ts"]), }); (store.listTasks as ReturnType).mockImplementation(async (options?: { column?: string }) => { if (options?.column === "todo") return tasks.filter((task) => task.column === "todo"); @@ -7391,6 +7392,31 @@ describe("FN-4538 overlapBlockedBy self-healing", () => { expect(store.updateTask).not.toHaveBeenCalledWith("FN-TARGET", expect.objectContaining({ status: null })); manager.stop(); }); + it("FN-783: clearStaleBlockedBy clears queued status when overlap blocker no longer shares effective write scope", async () => { + const overlapBlocker = makeTask("FN-ACTIVE", { column: "in-progress" }); + const target = makeTask("FN-TARGET", { + column: "todo", + status: "queued", + blockedBy: undefined, + overlapBlockedBy: "FN-ACTIVE", + dependencies: [], + }); + const store = makeStore([target, overlapBlocker], { + "FN-ACTIVE": ["project.yml", "Tests/AtlasNotesMobileUITests/**"], + "FN-TARGET": ["packages/core/**", "packages/engine/**"], + }); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); + + await manager.clearStaleBlockedBy(); + + expect(store.updateTask).toHaveBeenCalledWith("FN-TARGET", { blockedBy: null, overlapBlockedBy: null, status: null }); + expect(store.logEntry).not.toHaveBeenCalledWith( + "FN-TARGET", + "Auto-recovered: preserved queued status — still blocked by file scope overlap with FN-ACTIVE", + ); + manager.stop(); + }); + }); describe("stale triage processing eviction before recovery", () => { diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 2a0807bf7e..217fe8ec70 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -3651,6 +3651,27 @@ export class SelfHealingManager { await this.reconcileTaskWorktreeMetadata({ includeTaskIds: new Set([taskId]) }); const allTasks = await this.store.listTasks({ slim: true, includeArchived: true }); const taskById = new Map(allTasks.map((t) => [t.id, t])); + const overlapIgnorePaths = settings.overlapIgnorePaths ?? []; + const filteredScopeByTaskId = new Map(); + const getFilteredFileScope = async (scopeTaskId: string): Promise => { + const cached = filteredScopeByTaskId.get(scopeTaskId); + if (cached) return cached; + const scope = await this.store.parseFileScopeFromPrompt(scopeTaskId); + const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths); + filteredScopeByTaskId.set(scopeTaskId, filteredScope); + return filteredScope; + }; + const hasActiveFileScopeOverlapBlocker = async (dependent: Task, blockerId: string | null | undefined): Promise => { + if (!blockerId) return false; + const blocker = taskById.get(blockerId); + if (!blocker || blocker.paused || blocker.userPaused) return false; + if (blocker.column !== "in-progress" && !(blocker.column === "in-review" && !blocker.paused)) return false; + const dependentScope = await getFilteredFileScope(dependent.id); + if (dependentScope.length === 0 || isCoordinationOnlyTask(dependent, dependentScope)) return false; + const blockerScope = await getFilteredFileScope(blocker.id); + if (blockerScope.length === 0 || isCoordinationOnlyTask(blocker, blockerScope)) return false; + return pathsOverlap(dependentScope, blockerScope); + }; const todoTasks = await this.store.listTasks({ column: "todo", slim: true }); const inProgressTasks = await this.store.listTasks({ column: "in-progress", slim: true }); const inReviewTasks = (await this.store.listTasks({ column: "in-review", slim: true })).filter((t) => !t.paused); @@ -3666,11 +3687,7 @@ export class SelfHealingManager { return dep && dep.column !== "done" && dep.column !== "in-review" && dep.column !== "archived"; }); const overlapBlockedBy = dependent.overlapBlockedBy === taskId ? null : (dependent.overlapBlockedBy ?? null); - const overlapBlockerTask = overlapBlockedBy ? taskById.get(overlapBlockedBy) : undefined; - const hasActiveOverlapBlocker = Boolean( - overlapBlockerTask - && (overlapBlockerTask.column === "in-progress" || (overlapBlockerTask.column === "in-review" && !overlapBlockerTask.paused)), - ); + const hasActiveOverlapBlocker = await hasActiveFileScopeOverlapBlocker(dependent, overlapBlockedBy); if (todoTaskIds.has(dependent.id)) { if (unresolvedDeps.length > 0) { @@ -4748,6 +4765,28 @@ export class SelfHealingManager { const allTasks = await this.store.listTasks({ includeArchived: true }); const taskById = new Map(allTasks.map((task) => [task.id, task])); + const overlapIgnorePaths = settings.overlapIgnorePaths ?? []; + const filteredScopeByTaskId = new Map(); + const getFilteredFileScope = async (taskId: string): Promise => { + const cached = filteredScopeByTaskId.get(taskId); + if (cached) return cached; + const scope = await this.store.parseFileScopeFromPrompt(taskId); + const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths); + filteredScopeByTaskId.set(taskId, filteredScope); + return filteredScope; + }; + const hasActiveFileScopeOverlapBlocker = async (task: Task, blockerId: string | null | undefined): Promise => { + if (!blockerId) return false; + const blocker = taskById.get(blockerId); + if (!blocker || blocker.paused || blocker.userPaused) return false; + if (blocker.column !== "in-progress" && !(blocker.column === "in-review" && !blocker.paused)) return false; + + const taskScope = await getFilteredFileScope(task.id); + if (taskScope.length === 0 || isCoordinationOnlyTask(task, taskScope)) return false; + const blockerScope = await getFilteredFileScope(blocker.id); + if (blockerScope.length === 0 || isCoordinationOnlyTask(blocker, blockerScope)) return false; + return pathsOverlap(taskScope, blockerScope); + }; let recovered = 0; const todoTaskIds = new Set(todoTasks.map((task) => task.id)); @@ -4759,11 +4798,9 @@ export class SelfHealingManager { for (const [taskId, lastLoggedBlockerId] of this.preservedQueuedOverlapLogged) { const memoTask = taskById.get(taskId); - const memoOverlapBlocker = memoTask?.overlapBlockedBy ? taskById.get(memoTask.overlapBlockedBy) : undefined; - const memoHasActiveOverlapBlocker = Boolean( - memoOverlapBlocker - && (memoOverlapBlocker.column === "in-progress" || (memoOverlapBlocker.column === "in-review" && !memoOverlapBlocker.paused)), - ); + const memoHasActiveOverlapBlocker = memoTask + ? await hasActiveFileScopeOverlapBlocker(memoTask, memoTask.overlapBlockedBy) + : false; if ( !candidates.has(taskId) || memoTask?.column !== "todo" @@ -4784,11 +4821,7 @@ export class SelfHealingManager { // treated as resolved here by design. return dep && !dep.deletedAt && dep.column !== "done" && dep.column !== "in-review" && dep.column !== "archived"; }); - const overlapBlocker = task.overlapBlockedBy ? taskById.get(task.overlapBlockedBy) : undefined; - const hasActiveOverlapBlocker = Boolean( - overlapBlocker - && (overlapBlocker.column === "in-progress" || (overlapBlocker.column === "in-review" && !overlapBlocker.paused)), - ); + const hasActiveOverlapBlocker = await hasActiveFileScopeOverlapBlocker(task, task.overlapBlockedBy); if (blockedTaskIds.has(task.id)) { if (!blockerId) continue; From 8cc5fd895ed7da4aec43b0ce8207181fdc12feae Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Thu, 25 Jun 2026 03:44:06 -0700 Subject: [PATCH 05/10] 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 { From 7cd660f5584d7228adef973c790d50f5675262e3 Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Thu, 25 Jun 2026 05:08:04 -0700 Subject: [PATCH 06/10] fix(FN-783): address overlap repair review blockers --- .changeset/sharp-overlap-repair.md | 5 ++ .../core/src/__tests__/store-parsing.test.ts | 11 ++++ .../core/src/file-scope-classification.ts | 16 ++++-- packages/core/src/store.ts | 12 +++-- .../app/components/TaskDetailModal.tsx | 14 ++++- .../TaskDetailModal.rendering.test.tsx | 7 ++- .../src/__tests__/routes-tasks-ops.test.ts | 36 +++++++++++++ .../routes/register-task-workflow-routes.ts | 12 ++++- packages/engine/src/scheduler.ts | 51 +++++++++++++++---- packages/engine/src/self-healing.ts | 30 ++++++++--- 10 files changed, 166 insertions(+), 28 deletions(-) create mode 100644 .changeset/sharp-overlap-repair.md diff --git a/.changeset/sharp-overlap-repair.md b/.changeset/sharp-overlap-repair.md new file mode 100644 index 0000000000..134e0d1c6f --- /dev/null +++ b/.changeset/sharp-overlap-repair.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix stale overlap-blocker repair edge cases and dashboard display synchronization. diff --git a/packages/core/src/__tests__/store-parsing.test.ts b/packages/core/src/__tests__/store-parsing.test.ts index c260e32447..b0d911d608 100644 --- a/packages/core/src/__tests__/store-parsing.test.ts +++ b/packages/core/src/__tests__/store-parsing.test.ts @@ -539,6 +539,17 @@ Expected touched paths: expect(repaired?.log.at(-1)?.action).toContain(`Repaired stale overlap blocker: cleared ${blocker.id}`); }); + it("returns structured not-found result instead of throwing", async () => { + const result = await store.repairOverlapBlocker("FN-MISSING"); + + expect(result).toMatchObject({ + taskId: "FN-MISSING", + repaired: false, + statusCleared: false, + reason: "task-not-found", + }); + }); + 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" }); diff --git a/packages/core/src/file-scope-classification.ts b/packages/core/src/file-scope-classification.ts index adf4f8fc7d..9054b082c7 100644 --- a/packages/core/src/file-scope-classification.ts +++ b/packages/core/src/file-scope-classification.ts @@ -22,6 +22,10 @@ export interface FileScopeClassificationResult { effectiveWriteScope: string[]; } +/* +FNXC:FileScopeClassification 2026-06-25-04:34: +Task File Scope is operator intent, not every path-like token in PROMPT.md. Keep this classifier conservative so read-only evidence, wrong-worktree safeguards, generated locks, route names, and conditional changesets do not create false write-scope leases or file-scope merge guards. +*/ const KNOWN_FILE_SCOPE_ROOT_FILES = new Set([ "makefile", "dockerfile", @@ -92,6 +96,10 @@ export function extractEffectiveWriteScopeFromPrompt(content: string): string[] } export function classifyFileScopeFromPrompt(content: string): FileScopeClassificationResult { + /* + FNXC:FileScopeClassification 2026-06-25-04:34: + Context headings inside `## File Scope` change the meaning of backticked tokens. The state machine is line-oriented on purpose: execution specs often mix write targets with forbidden paths and evidence-only metadata in the same section. + */ const section = extractFileScopeSection(content); if (!section) return { entries: [], effectiveWriteScope: [] }; @@ -103,13 +111,11 @@ export function classifyFileScopeFromPrompt(content: string): FileScopeClassific for (const rawLine of section.split("\n")) { const line = rawLine.trim(); if (!line) continue; - const lower = line.toLowerCase(); - if (INCLUDE_CONTEXT_RE.test(line) && !EXCLUDE_CONTEXT_RE.test(line) && !CONDITIONAL_CONTEXT_RE.test(line)) { context = "include"; } if (EXCLUDE_CONTEXT_RE.test(line)) { - context = lower.includes("wrong-worktree") || lower.includes("wrong worktree") ? "exclude" : "exclude"; + context = "exclude"; } if (CONDITIONAL_CONTEXT_RE.test(line)) { context = "conditional"; @@ -141,6 +147,10 @@ function classifyToken( line: string, context: "include" | "exclude" | "conditional", ): FileScopeClassificationReason { + /* + FNXC:FileScopeClassification 2026-06-25-04:34: + Classification reasons must be stable enough for diagnostics while the include/exclude decision stays binary. Preserve specific exclusion reasons after validation so review/spec gates explain why a token was ignored instead of silently shrinking File Scope. + */ if (ROUTE_OR_ACTION_RE.test(token)) return "route-or-action"; if (!isValidFileScopeEntry(token)) return "invalid-entry"; diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 0fb099bad3..8a87dbaf50 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -10841,9 +10841,15 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } async repairOverlapBlocker(id: string, options: RepairOverlapBlockerOptions = {}): Promise { + /* + FNXC:OverlapRepair 2026-06-25-04:34: + Dashboard-initiated overlap repair is a narrow stale-blocker cleanup, not a general task mutation endpoint. Return structured reasons for missing tasks and cache empty scopes so route handlers can map failures predictably and repair decisions stay deterministic. + */ const dryRun = options.dryRun === true; - const task = await this.getTask(id); - if (!task) { + let task: Task; + try { + task = await this.getTask(id); + } catch { return { taskId: id, dryRun, repaired: false, statusCleared: false, reason: "task-not-found", message: `Task ${id} not found` }; } @@ -10886,7 +10892,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} const scopeCache = new Map(); const getScope = async (taskId: string): Promise => { const cached = scopeCache.get(taskId); - if (cached) return cached; + if (cached !== undefined) return cached; const scope = filterRepairOverlapIgnoredPaths(await this.parseFileScopeFromPrompt(taskId), ignorePaths); scopeCache.set(taskId, scope); return scope; diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 27992cb695..3319f42f75 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -678,6 +678,10 @@ export function TaskDetailContent({ // FN-4161: board/restart flows open the modal from slim task rows where // `githubTracking` is intentionally omitted; preserve the fetched full-detail // tracking blob instead of letting the sparse parent prop overwrite it. + const [overlapBlockedByOverride, setOverlapBlockedByOverride] = useState(undefined); + useEffect(() => { + setOverlapBlockedByOverride(undefined); + }, [task.id]); const workingTask: TaskDetail = fullDetail ? ({ ...fullDetail, @@ -692,7 +696,13 @@ 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, + /* + FNXC:TaskDetailOverlapRepair 2026-06-25-04:34: + SSE task props are authoritative for live blocker changes, but the Clear repair flow needs a local override while stale parent props catch up. Only fall back to fetched detail when the slim parent omitted the field entirely. + */ + overlapBlockedBy: overlapBlockedByOverride !== undefined + ? overlapBlockedByOverride + : task.overlapBlockedBy === undefined ? fullDetail.overlapBlockedBy : task.overlapBlockedBy, } as TaskDetail) : ({ ...task, prompt: "" } as TaskDetail); const canRetryTask = @@ -2474,6 +2484,7 @@ export function TaskDetailContent({ return; } if (result.task) { + setOverlapBlockedByOverride(result.task.overlapBlockedBy ?? null); setFullDetail((prev) => prev ? ({ ...prev, ...result.task } as TaskDetail) : (result.task as TaskDetail)); onTaskUpdated?.(result.task); } else { @@ -2481,6 +2492,7 @@ export function TaskDetailContent({ if (activeTaskIdRef.current !== requestTaskId) { return; } + setOverlapBlockedByOverride(updatedTask.overlapBlockedBy ?? null); setFullDetail((prev) => prev ? ({ ...prev, ...updatedTask } as TaskDetail) : updatedTask); onTaskUpdated?.(updatedTask); } diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx index 742a0ff53f..048c63a0a7 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx @@ -513,7 +513,7 @@ describe("TaskDetailModal", () => { expect(screen.queryByText("File scope overlap blocker: FN-OVER (stale)")).toBeNull(); }); - it("renders clear overlap blocker button only when overlapBlockedBy is present", () => { + it("keeps clear overlap blocker button when slim live task omits overlapBlockedBy", () => { const { rerender } = render( { />, ); - expect(screen.queryByRole("button", { name: "Clear" })).toBeNull(); + expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument(); }); it("repairs overlap blocker when clicking Clear", async () => { @@ -579,6 +579,9 @@ describe("TaskDetailModal", () => { undefined, ); }); + await waitFor(() => { + expect(screen.queryByRole("button", { name: "Clear" })).toBeNull(); + }); }); it("applies rerouted overlap blocker returned by repair API", async () => { diff --git a/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts b/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts index 1b8bfcc4a8..771840cc34 100644 --- a/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts +++ b/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts @@ -2371,6 +2371,42 @@ describe("PATCH /tasks/:id", () => { expect(store.updateTask).not.toHaveBeenCalled(); }); + it("rejects overlap blocker repair when request body is not an object", async () => { + const res = await REQUEST( + buildApp(), + "POST", + "/api/tasks/KB-001/repair-overlap-blocker", + JSON.stringify(["not", "an", "object"]), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("body must be an object"); + expect(store.repairOverlapBlocker).not.toHaveBeenCalled(); + }); + + it("maps missing overlap repair target to 404", async () => { + (store.repairOverlapBlocker as ReturnType).mockResolvedValue({ + taskId: "MISSING", + dryRun: false, + repaired: false, + statusCleared: false, + reason: "task-not-found", + message: "Task MISSING not found", + }); + + const res = await REQUEST( + buildApp(), + "POST", + "/api/tasks/MISSING/repair-overlap-blocker", + JSON.stringify({}), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Task MISSING not found"); + }); + it("rejects overlap blocker repair when scopes still overlap", async () => { (store.repairOverlapBlocker as ReturnType).mockResolvedValue({ taskId: "KB-001", diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 5c1b11b9d5..dca248835a 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -3023,7 +3023,15 @@ 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 ?? {}; + /* + FNXC:OverlapRepair 2026-06-25-04:34: + This route can clear scheduler-visible blockers. Reject non-object JSON bodies before calling the store so malformed requests cannot accidentally run a real repair with every option undefined. + */ + const body = req.body ?? {}; + if (typeof body !== "object" || body === null || Array.isArray(body)) { + throw badRequest("body must be an object"); + } + const { dryRun, reason } = body as { dryRun?: unknown; reason?: unknown }; if (dryRun !== undefined && typeof dryRun !== "boolean") { throw badRequest("dryRun must be a boolean"); } @@ -3040,7 +3048,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork if (result.reason === "task-not-found") { throw notFound(result.message); } - if (!result.repaired && !result.dryRun && result.reason !== "repaired") { + if (!result.repaired && !result.dryRun) { const status = result.reason === "no-overlap-blocker" ? 400 : 409; throw new ApiError(status, result.message); } diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index d1279b8b9f..73aa405159 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -265,6 +265,29 @@ export function isRunnableQueuedOverlapCandidate( return true; } +export function shouldHoldActiveFileScopeLease( + task: Task, + tasks: Task[], + options?: { + mergeRequestContractShadowEnabled?: boolean; + handoffAccepted?: boolean; + schedulingDependencyOptions?: Parameters[2]; + }, +): boolean { + /* + FNXC:OverlapScheduling 2026-06-25-04:34: + Active file-scope leases are a scheduler contract, not just a column check. Self-healing and repair paths must use this same predicate so stale `overlapBlockedBy` cleanup does not preserve blockers the scheduler would ignore on the next tick. + */ + if (task.paused || task.userPaused) return false; + if (task.column === "in-progress") { + return getUnmetSchedulingDependencies(task, tasks, options?.schedulingDependencyOptions).length === 0; + } + if (task.column !== "in-review") return false; + if (!task.worktree || task.status === "failed") return false; + if (options?.mergeRequestContractShadowEnabled === true && options.handoffAccepted === true) return false; + return true; +} + export function findHigherPriorityQueuedOverlap( candidate: QueuedOverlapCandidate, queuedScopes: QueuedOverlapCandidate[], @@ -1446,7 +1469,7 @@ export class Scheduler { const filteredScopeByTaskId = new Map(); const getFilteredFileScope = async (taskId: string): Promise => { const cached = filteredScopeByTaskId.get(taskId); - if (cached) return cached; + if (cached !== undefined) return cached; const scope = await this.store.parseFileScopeFromPrompt(taskId); const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths); filteredScopeByTaskId.set(taskId, filteredScope); @@ -1455,12 +1478,10 @@ export class Scheduler { if (settings.groupOverlappingFiles) { // In-progress tasks for (const t of inProgress) { + if (!shouldHoldActiveFileScopeLease(t, tasks, { schedulingDependencyOptions })) continue; const filteredScope = await getFilteredFileScope(t.id); if (isCoordinationOnlyTask(t, filteredScope)) continue; if (filteredScope.length === 0) continue; - // FN-6292: a holder waiting on scheduling deps must not lease files - // that can block its own dependency and create a circular wait. - if (getUnmetSchedulingDependencies(t, tasks, schedulingDependencyOptions).length > 0) continue; setActiveScopeLease(t.id, filteredScope, "in-progress"); } // Only live in-review tasks with a worktree belong in activeScopes. @@ -1473,7 +1494,13 @@ export class Scheduler { // will never merge, so superseding re-implementation tasks (for example FN-4177 // replaced by FN-4198) must not stay queued behind them. (FN-4200) const inReviewWithWorktree = tasks.filter( - (t) => t.column === "in-review" && Boolean(t.worktree) && !t.paused && t.status !== "failed", + (t) => t.column === "in-review" && shouldHoldActiveFileScopeLease(t, tasks, { + mergeRequestContractShadowEnabled: settings.mergeRequestContractShadowEnabled, + handoffAccepted: settings.mergeRequestContractShadowEnabled === true + ? this.store.getCompletionHandoffAcceptedMarker(t.id) !== null + : false, + schedulingDependencyOptions, + }), ); for (const t of inReviewWithWorktree) { const filteredScope = await getFilteredFileScope(t.id); @@ -2118,7 +2145,7 @@ export class Scheduler { const filteredScopeByTaskId = new Map(); const getFilteredFileScope = async (taskId: string): Promise => { const cached = filteredScopeByTaskId.get(taskId); - if (cached) return cached; + if (cached !== undefined) return cached; const scope = await this.store.parseFileScopeFromPrompt(taskId); const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths); filteredScopeByTaskId.set(taskId, filteredScope); @@ -2145,18 +2172,22 @@ export class Scheduler { if (settings.groupOverlappingFiles) { for (const task of tasks) { if (task.column !== "in-progress") continue; + if (!shouldHoldActiveFileScopeLease(task, tasks, { schedulingDependencyOptions })) continue; const filteredScope = await getFilteredFileScope(task.id); if (isCoordinationOnlyTask(task, filteredScope)) continue; if (filteredScope.length === 0) continue; - // FN-6292: do not let a task with unmet deps lease files that can - // keep those deps queued behind their own dependent. - if (getUnmetSchedulingDependencies(task, tasks, schedulingDependencyOptions).length > 0) continue; activeScopes.set(task.id, filteredScope); activeScopeColumns.set(task.id, task.column); } const inReviewWithWorktree = tasks.filter( - (task) => task.column === "in-review" && Boolean(task.worktree) && !task.paused && task.status !== "failed", + (task) => task.column === "in-review" && shouldHoldActiveFileScopeLease(task, tasks, { + mergeRequestContractShadowEnabled: settings.mergeRequestContractShadowEnabled, + handoffAccepted: settings.mergeRequestContractShadowEnabled === true + ? this.store.getCompletionHandoffAcceptedMarker(task.id) !== null + : false, + schedulingDependencyOptions, + }), ); for (const task of inReviewWithWorktree) { const filteredScope = await getFilteredFileScope(task.id); diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 217fe8ec70..69a36f106d 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -74,7 +74,7 @@ import { } from "./notifier.js"; import type { GhostBugDecision } from "./triage-preflight.js"; import { DependencyBlockedTodoReporter } from "./dependency-blocked-todo-reporter.js"; -import { filterPathsByIgnoreList, getUnmetSchedulingDependencies, isCoordinationOnlyTask, pathsOverlap } from "./scheduler.js"; +import { filterPathsByIgnoreList, getUnmetSchedulingDependencies, isCoordinationOnlyTask, pathsOverlap, shouldHoldActiveFileScopeLease } from "./scheduler.js"; import { evaluateParkedAgentTaskLink, PARKED_AGENT_LINK_FRESH_RUN_MS } from "./task-agent-sync.js"; const log = createLogger("self-healing"); @@ -3653,9 +3653,13 @@ export class SelfHealingManager { const taskById = new Map(allTasks.map((t) => [t.id, t])); const overlapIgnorePaths = settings.overlapIgnorePaths ?? []; const filteredScopeByTaskId = new Map(); + /* + FNXC:OverlapSelfHealing 2026-06-25-04:34: + Completion fan-out may preserve queued overlap blockers only when the blocker still holds the scheduler's active file-scope lease. Cache empty filtered scopes too so coordination-only tasks stay deterministic within a reconciliation pass. + */ const getFilteredFileScope = async (scopeTaskId: string): Promise => { const cached = filteredScopeByTaskId.get(scopeTaskId); - if (cached) return cached; + if (cached !== undefined) return cached; const scope = await this.store.parseFileScopeFromPrompt(scopeTaskId); const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths); filteredScopeByTaskId.set(scopeTaskId, filteredScope); @@ -3664,8 +3668,12 @@ export class SelfHealingManager { const hasActiveFileScopeOverlapBlocker = async (dependent: Task, blockerId: string | null | undefined): Promise => { if (!blockerId) return false; const blocker = taskById.get(blockerId); - if (!blocker || blocker.paused || blocker.userPaused) return false; - if (blocker.column !== "in-progress" && !(blocker.column === "in-review" && !blocker.paused)) return false; + if (!blocker || !shouldHoldActiveFileScopeLease(blocker, allTasks, { + mergeRequestContractShadowEnabled: settings.mergeRequestContractShadowEnabled, + handoffAccepted: settings.mergeRequestContractShadowEnabled === true + ? this.store.getCompletionHandoffAcceptedMarker(blocker.id) !== null + : false, + })) return false; const dependentScope = await getFilteredFileScope(dependent.id); if (dependentScope.length === 0 || isCoordinationOnlyTask(dependent, dependentScope)) return false; const blockerScope = await getFilteredFileScope(blocker.id); @@ -4767,9 +4775,13 @@ export class SelfHealingManager { const taskById = new Map(allTasks.map((task) => [task.id, task])); const overlapIgnorePaths = settings.overlapIgnorePaths ?? []; const filteredScopeByTaskId = new Map(); + /* + FNXC:OverlapSelfHealing 2026-06-25-04:34: + Stale blockedBy cleanup must mirror scheduler lease semantics before preserving queued overlap state. Empty-scope cache hits matter here because no-write-scope advisory tasks should not repeatedly reparse specs or look active by accident. + */ const getFilteredFileScope = async (taskId: string): Promise => { const cached = filteredScopeByTaskId.get(taskId); - if (cached) return cached; + if (cached !== undefined) return cached; const scope = await this.store.parseFileScopeFromPrompt(taskId); const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths); filteredScopeByTaskId.set(taskId, filteredScope); @@ -4778,8 +4790,12 @@ export class SelfHealingManager { const hasActiveFileScopeOverlapBlocker = async (task: Task, blockerId: string | null | undefined): Promise => { if (!blockerId) return false; const blocker = taskById.get(blockerId); - if (!blocker || blocker.paused || blocker.userPaused) return false; - if (blocker.column !== "in-progress" && !(blocker.column === "in-review" && !blocker.paused)) return false; + if (!blocker || !shouldHoldActiveFileScopeLease(blocker, allTasks, { + mergeRequestContractShadowEnabled: settings.mergeRequestContractShadowEnabled, + handoffAccepted: settings.mergeRequestContractShadowEnabled === true + ? this.store.getCompletionHandoffAcceptedMarker(blocker.id) !== null + : false, + })) return false; const taskScope = await getFilteredFileScope(task.id); if (taskScope.length === 0 || isCoordinationOnlyTask(task, taskScope)) return false; From 973e167a9e55f03ec16aa121a9e39de3a84771f0 Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Thu, 25 Jun 2026 05:49:29 -0700 Subject: [PATCH 07/10] fix(FN-783): align overlap repair with paused lease state --- .changeset/sharp-overlap-repair.md | 4 +++- .../core/src/__tests__/store-parsing.test.ts | 22 +++++++++++++++++++ packages/core/src/store.ts | 11 +++++++++- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/.changeset/sharp-overlap-repair.md b/.changeset/sharp-overlap-repair.md index 134e0d1c6f..377c1fcf35 100644 --- a/.changeset/sharp-overlap-repair.md +++ b/.changeset/sharp-overlap-repair.md @@ -2,4 +2,6 @@ "@runfusion/fusion": patch --- -Fix stale overlap-blocker repair edge cases and dashboard display synchronization. +summary: Fix stale overlap-blocker repair edge cases and dashboard display synchronization. +category: fix +dev: Adds effective write-scope repair handling for scheduler/file-scope lease consistency. diff --git a/packages/core/src/__tests__/store-parsing.test.ts b/packages/core/src/__tests__/store-parsing.test.ts index b0d911d608..e9cc408efb 100644 --- a/packages/core/src/__tests__/store-parsing.test.ts +++ b/packages/core/src/__tests__/store-parsing.test.ts @@ -588,6 +588,28 @@ Expected touched paths: expect(rerouted?.overlapBlockedBy).toBe(current.id); expect(rerouted?.status).toBe("queued"); }); + + it("does not reroute stale overlap blockers to paused active tasks", async () => { + const stale = await store.createTask({ description: "stale blocker" }); + const pausedCurrent = await store.createTask({ description: "paused current blocker" }); + const target = await store.createTask({ description: "target" }); + await writePrompt(stale.id, ["packages/core/**"]); + await writePrompt(pausedCurrent.id, ["packages/engine/**"]); + await writePrompt(target.id, ["packages/engine/src/scheduler.ts"]); + await store.moveTask(stale.id, "todo"); + await store.moveTask(pausedCurrent.id, "todo"); + await store.moveTask(pausedCurrent.id, "in-progress"); + await store.updateTask(pausedCurrent.id, { paused: true, userPaused: true, pausedReason: "operator parked" }); + 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: true, reason: "repaired" }); + const repaired = await store.getTask(target.id); + expect(repaired?.overlapBlockedBy).toBeUndefined(); + expect(repaired?.status).toBeUndefined(); + }); }); describe("FN-5216 File Scope sanitization on copy paths", () => { diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 8a87dbaf50..1b742da059 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -10973,9 +10973,18 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} getScope: (taskId: string) => Promise, previousOverlapBlockedBy: string, ): Promise { + /* + FNXC:OverlapRepair 2026-06-25-05:49: + Stale-overlap repair must reroute only to tasks that the scheduler would still treat as active file-scope lease holders. Operator-paused or failed active rows are parked work, not live blockers, so the repair should clear stale state instead of creating a fresh blocker edge to them. + */ + const holdsRepairFileScopeLease = (candidate: Task) => { + if (candidate.paused || candidate.userPaused || candidate.status === "failed") return false; + if (candidate.column === "in-progress") return true; + return candidate.column === "in-review" && Boolean(candidate.worktree); + }; 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")) + .filter(holdsRepairFileScopeLease) .sort((a, b) => a.id.localeCompare(b.id)); for (const candidate of activeCandidates) { From e8c6a4a36f30e3df8e98d6f5bc10aa27b6e2450e Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Thu, 25 Jun 2026 06:26:40 -0700 Subject: [PATCH 08/10] fix(FN-783): clear missing overlap blocker references --- .../core/src/__tests__/store-parsing.test.ts | 14 +++++++ packages/core/src/store.ts | 42 +++++++------------ 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/core/src/__tests__/store-parsing.test.ts b/packages/core/src/__tests__/store-parsing.test.ts index e9cc408efb..9652dce3ca 100644 --- a/packages/core/src/__tests__/store-parsing.test.ts +++ b/packages/core/src/__tests__/store-parsing.test.ts @@ -550,6 +550,20 @@ Expected touched paths: }); }); + it("clears stale overlap blockers when the referenced blocker task is missing", async () => { + const target = await store.createTask({ description: "target" }); + await writePrompt(target.id, ["packages/engine/src/scheduler.ts"]); + await store.moveTask(target.id, "todo"); + await store.updateTask(target.id, { status: "queued", overlapBlockedBy: "FN-MISSING-BLOCKER" }); + + const result = await store.repairOverlapBlocker(target.id, { reason: "missing blocker" }); + + expect(result).toMatchObject({ repaired: true, statusCleared: true, previousOverlapBlockedBy: "FN-MISSING-BLOCKER", reason: "repaired" }); + const repaired = await store.getTask(target.id); + expect(repaired?.overlapBlockedBy).toBeUndefined(); + expect(repaired?.status).toBeUndefined(); + }); + 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" }); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 1b742da059..220424110a 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -10843,7 +10843,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} async repairOverlapBlocker(id: string, options: RepairOverlapBlockerOptions = {}): Promise { /* FNXC:OverlapRepair 2026-06-25-04:34: - Dashboard-initiated overlap repair is a narrow stale-blocker cleanup, not a general task mutation endpoint. Return structured reasons for missing tasks and cache empty scopes so route handlers can map failures predictably and repair decisions stay deterministic. + Dashboard-initiated overlap repair is a narrow stale-blocker cleanup, not a general task mutation endpoint. Missing target tasks still return structured failures, but a missing blocker reference is itself stale and should be cleared or rerouted after the current scheduler-visible blockers are checked. */ const dryRun = options.dryRun === true; let task: Task; @@ -10874,18 +10874,6 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} 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 ?? []; @@ -10899,19 +10887,21 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} }; 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, - }; + if (blocker) { + 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) => { From 6495875fb8883d36e321201fca17aebe30dfb775 Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Thu, 25 Jun 2026 06:39:56 -0700 Subject: [PATCH 09/10] fix(FN-783): ignore inactive prior overlap blockers --- .../core/src/__tests__/store-parsing.test.ts | 19 +++++++++++++++++++ packages/core/src/store.ts | 6 +++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/core/src/__tests__/store-parsing.test.ts b/packages/core/src/__tests__/store-parsing.test.ts index 9652dce3ca..0d05373b67 100644 --- a/packages/core/src/__tests__/store-parsing.test.ts +++ b/packages/core/src/__tests__/store-parsing.test.ts @@ -582,6 +582,25 @@ Expected touched paths: expect(unchanged?.status).toBe("queued"); }); + it("clears stale overlap blockers when the previous blocker is paused even if scopes still overlap", async () => { + const blocker = await store.createTask({ description: "paused 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.updateTask(blocker.id, { paused: true, userPaused: true, pausedReason: "operator parked" }); + 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: true, statusCleared: true, previousOverlapBlockedBy: blocker.id, reason: "repaired" }); + const repaired = await store.getTask(target.id); + expect(repaired?.overlapBlockedBy).toBeUndefined(); + expect(repaired?.status).toBeUndefined(); + }); + 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" }); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 220424110a..93df4affa1 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -10888,8 +10888,12 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} const taskScope = await getScope(task.id); if (blocker) { + const blockerHoldsActiveLease = !blocker.paused + && !blocker.userPaused + && blocker.status !== "failed" + && (blocker.column === "in-progress" || (blocker.column === "in-review" && Boolean(blocker.worktree))); const blockerScope = await getScope(blocker.id); - if (repairScopesOverlap(taskScope, blockerScope)) { + if (blockerHoldsActiveLease && repairScopesOverlap(taskScope, blockerScope)) { return { taskId: id, dryRun, From 88ccd9081cc53f05010f831938eaa1cf422be4a4 Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Thu, 25 Jun 2026 07:34:09 -0700 Subject: [PATCH 10/10] fix(FN-783): harden overlap repair race handling --- .../core/src/__tests__/store-parsing.test.ts | 80 +++++++++++++- packages/core/src/store.ts | 104 ++++++++++++++---- 2 files changed, 161 insertions(+), 23 deletions(-) diff --git a/packages/core/src/__tests__/store-parsing.test.ts b/packages/core/src/__tests__/store-parsing.test.ts index 0d05373b67..ff7ab71a6c 100644 --- a/packages/core/src/__tests__/store-parsing.test.ts +++ b/packages/core/src/__tests__/store-parsing.test.ts @@ -567,7 +567,7 @@ Expected touched paths: 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(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"); @@ -585,7 +585,7 @@ Expected touched paths: it("clears stale overlap blockers when the previous blocker is paused even if scopes still overlap", async () => { const blocker = await store.createTask({ description: "paused Fusion blocker" }); const target = await store.createTask({ description: "Fusion target" }); - await writePrompt(blocker.id, ["packages/engine/**"]); + 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"); @@ -606,7 +606,7 @@ Expected touched paths: 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(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"); @@ -627,7 +627,7 @@ Expected touched paths: const pausedCurrent = await store.createTask({ description: "paused current blocker" }); const target = await store.createTask({ description: "target" }); await writePrompt(stale.id, ["packages/core/**"]); - await writePrompt(pausedCurrent.id, ["packages/engine/**"]); + await writePrompt(pausedCurrent.id, ["packages/engine/*"]); await writePrompt(target.id, ["packages/engine/src/scheduler.ts"]); await store.moveTask(stale.id, "todo"); await store.moveTask(pausedCurrent.id, "todo"); @@ -643,6 +643,78 @@ Expected touched paths: expect(repaired?.overlapBlockedBy).toBeUndefined(); expect(repaired?.status).toBeUndefined(); }); + + it("does not treat double-star globs as overlaps beyond scheduler semantics", async () => { + const blocker = await store.createTask({ description: "scheduler-literal blocker" }); + const target = await store.createTask({ description: "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: true, statusCleared: true, reason: "repaired" }); + const repaired = await store.getTask(target.id); + expect(repaired?.overlapBlockedBy).toBeUndefined(); + expect(repaired?.status).toBeUndefined(); + }); + + it("keeps in-review dependencies blocked when clearing stale overlap blockers", async () => { + const dependency = await store.createTask({ description: "dependency under review" }); + const stale = await store.createTask({ description: "stale blocker" }); + const target = await store.createTask({ description: "target with review dependency" }); + await writePrompt(dependency.id, ["packages/core/src/dependency.ts"]); + await writePrompt(stale.id, ["packages/core/**"]); + await writePrompt(target.id, ["packages/engine/src/scheduler.ts"]); + await store.moveTask(dependency.id, "todo"); + await store.moveTask(dependency.id, "in-progress"); + await store.moveTask(dependency.id, "in-review"); + await store.moveTask(stale.id, "todo"); + await store.updateTask(target.id, { dependencies: [dependency.id] }); + 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: "dependency-blocker-remains" }); + const repaired = await store.getTask(target.id); + expect(repaired?.overlapBlockedBy).toBeUndefined(); + expect(repaired?.status).toBe("queued"); + expect(repaired?.blockedBy).toBe(dependency.id); + }); + + it("does not overwrite a fresh overlap blocker written during repair", 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 originalFinder = (store as any).findCurrentOverlapBlockerForRepair.bind(store); + const freshBlocker = "FN-FRESH-BLOCKER"; + const finderSpy = vi.spyOn(store as any, "findCurrentOverlapBlockerForRepair").mockImplementation(async (...args: any[]) => { + const result = await originalFinder(...args); + await store.updateTask(target.id, { overlapBlockedBy: freshBlocker }); + return result; + }); + + const result = await store.repairOverlapBlocker(target.id); + + expect(result).toMatchObject({ repaired: false, statusCleared: false, reason: "overlap-blocker-changed", currentOverlapBlockedBy: freshBlocker }); + const unchanged = await store.getTask(target.id); + expect(unchanged?.overlapBlockedBy).toBe(freshBlocker); + expect(unchanged?.status).toBe("queued"); + finderSpy.mockRestore(); + }); }); describe("FN-5216 File Scope sanitization on copy paths", () => { diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 93df4affa1..20c0aaf76c 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -27,6 +27,7 @@ export type OverlapBlockerRepairReason = | "blocker-missing" | "scopes-still-overlap" | "dependency-blocker-remains" + | "overlap-blocker-changed" | "rerouted-to-current-overlap" | "repaired"; @@ -1443,8 +1444,11 @@ function normalizeRepairOverlapPath(path: string): string { } function repairOverlapPathPrefix(path: string): string | null { + /* + FNXC:OverlapRepair 2026-06-25-11:50: + Store-side repair must mirror the scheduler's current file-scope overlap contract. Treat `/*` and trailing-slash entries as directory prefixes, but do not independently expand `/**`; otherwise repair can refuse or reroute blockers the next scheduler tick would immediately clear. + */ 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; @@ -10910,53 +10914,115 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} 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"; + return dep && !dep.deletedAt && dep.column !== "done" && dep.column !== "archived"; }); const currentOverlapBlocker = await this.findCurrentOverlapBlockerForRepair(task, taskScope, tasks, getScope, previousOverlapBlockedBy); const statusCleared = unresolvedDeps.length === 0 && !currentOverlapBlocker && task.status === "queued"; + /* + FNXC:OverlapRepair 2026-06-25-10:58: + Stale-blocker repair must not overwrite a fresh scheduler blocker that appears after the repair computation starts. Re-check overlapBlockedBy inside the task lock immediately before writing so operator repair can clear/reroute only the blocker it inspected. + */ + const overlapBlockerChangedResult = (current: Task): RepairOverlapBlockerResult => ({ + taskId: id, + dryRun, + repaired: false, + statusCleared: false, + previousOverlapBlockedBy, + currentOverlapBlockedBy: current.overlapBlockedBy, + reason: "overlap-blocker-changed", + message: `Task ${id} overlap blocker changed from ${previousOverlapBlockedBy} to ${current.overlapBlockedBy}; repair skipped`, + task: current, + }); + 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}` : ""}`); + if (dryRun) { + return { + taskId: id, + dryRun, + repaired: false, + statusCleared: false, + previousOverlapBlockedBy, + currentOverlapBlockedBy: currentOverlapBlocker, + reason: "rerouted-to-current-overlap", + message: `Stale overlap blocker ${previousOverlapBlockedBy} would reroute to ${currentOverlapBlocker}`, + task, + }; } + + let skipped: RepairOverlapBlockerResult | undefined; + const repairedTask = await this.updateTaskAtomic(id, (current) => { + if ((current.overlapBlockedBy ?? undefined) !== previousOverlapBlockedBy) { + skipped = overlapBlockerChangedResult(current); + return null; + } + return { overlapBlockedBy: currentOverlapBlocker, status: "queued" }; + }); + if (skipped) return skipped; + await this.logEntry(id, `Repaired stale overlap blocker: rerouted from ${previousOverlapBlockedBy} to ${currentOverlapBlocker}${options.reason ? ` — ${options.reason}` : ""}`); return { taskId: id, dryRun, - repaired: !dryRun, + repaired: true, 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, + task: repairedTask, }; } - 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}` : ""}`, - ); + if (dryRun) { + return { + taskId: id, + dryRun, + repaired: false, + statusCleared, + previousOverlapBlockedBy, + reason: unresolvedDeps.length > 0 ? "dependency-blocker-remains" : "repaired", + message: unresolvedDeps.length > 0 + ? `Stale overlap blocker ${previousOverlapBlockedBy} would be cleared; dependency blocker remains ${unresolvedDeps[0]}` + : `Stale overlap blocker ${previousOverlapBlockedBy} would be cleared`, + task, + }; } + let skipped: RepairOverlapBlockerResult | undefined; + const repairedTask = await this.updateTaskAtomic(id, (current) => { + if ((current.overlapBlockedBy ?? undefined) !== previousOverlapBlockedBy) { + skipped = overlapBlockerChangedResult(current); + return null; + } + const currentUnresolvedDeps = (current.dependencies ?? []).filter((depId) => { + const dep = taskById.get(depId); + return dep && !dep.deletedAt && dep.column !== "done" && dep.column !== "archived"; + }); + const currentStatusCleared = currentUnresolvedDeps.length === 0 && current.status === "queued"; + return { + overlapBlockedBy: null, + ...(currentStatusCleared ? { status: null } : {}), + ...(currentUnresolvedDeps.length > 0 ? { blockedBy: currentUnresolvedDeps[0] } : {}), + }; + }); + if (skipped) return skipped; + 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, + repaired: true, 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, + task: repairedTask, }; }