fix(FN-783): separate write-scope intent and repair stale overlap blockers (#1755)
## Summary - Separates prompt-derived write intent from read-only/context paths for file-scope classification. - Preserves true shared/hot-family overlaps while avoiding poisoned scheduler/near-duplicate signatures. - Adds store/API/dashboard support to safely repair or reroute stale `overlapBlockedBy` blockers instead of direct DB/JSON edits. ## Test Plan - `corepack pnpm --filter @fusion/core exec vitest run --silent=passed-only --reporter=dot src/__tests__/store-parsing.test.ts src/__tests__/near-duplicate.test.ts` - `corepack pnpm --filter @fusion/engine exec vitest run --silent=passed-only --reporter=dot src/__tests__/triage.test.ts src/__tests__/scheduler-overlap-starvation.test.ts src/__tests__/self-healing.test.ts` - `FUSION_DASHBOARD_DEEP=1 corepack pnpm --filter @fusion/dashboard exec vitest run --project dashboard-api --silent=passed-only --reporter=dot src/__tests__/routes-tasks-ops.test.ts` - `FUSION_DASHBOARD_DEEP=1 corepack pnpm --filter @fusion/dashboard exec vitest run --project dashboard-app --silent=passed-only --reporter=dot --exclude "**/build-output.test.ts" app/components/__tests__/TaskDetailModal.rendering.test.tsx` - `corepack pnpm --filter @fusion/core typecheck` - `corepack pnpm --filter @fusion/engine typecheck` - `corepack pnpm --filter @runfusion/fusion build` ## Notes - `corepack pnpm --filter @fusion/dashboard typecheck` was attempted after a clean branch install but failed on dependency-resolution/type baseline noise (`express`, `@earendil-works/pi-ai`, etc. reported missing across existing files); targeted dashboard API/app tests passed. <!-- stage-review-badge-begin --> --- <a href="https://stagereview.app/Runfusion/Fusion/pull/1755"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://stagereview.app/assets/gh-open-in-stage-dark.svg"> <img src="https://stagereview.app/assets/gh-open-in-stage-light.svg" alt="Open in Stage"> </picture> </a> <!-- stage-review-badge-end --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added an end-to-end “repair overlap blocker” action with a new backend endpoint, dashboard clear-overlap-blocker flow, and legacy API client support. * Introduced shared prompt file-scope classification to derive effective write scopes consistently. * **Bug Fixes** * Improved overlap-blocker liveness/recovery by using effective file-scope overlap analysis with ignore paths. * Refined overlap-lease eligibility and queued blocker/status clearing behavior. * Updated near-duplicate intent signature handling to honor file-scope constraints. * **Tests** * Expanded coverage for file-scope parsing/deduping, overlap-repair outcomes, and dashboard/modal behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
7
.changeset/sharp-overlap-repair.md
Normal file
7
.changeset/sharp-overlap-repair.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -419,6 +419,302 @@ 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("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("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("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" });
|
||||
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("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" });
|
||||
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");
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
||||
191
packages/core/src/file-scope-classification.ts
Normal file
191
packages/core/src/file-scope-classification.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
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[];
|
||||
}
|
||||
|
||||
/*
|
||||
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",
|
||||
"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 {
|
||||
/*
|
||||
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: [] };
|
||||
|
||||
const entries: FileScopeClassificationEntry[] = [];
|
||||
const effectiveWriteScope: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let context: "include" | "exclude" | "conditional" = "include";
|
||||
|
||||
for (const rawLine of section.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
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 = "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 {
|
||||
/*
|
||||
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";
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
@@ -18,6 +18,35 @@ 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";
|
||||
|
||||
export type OverlapBlockerRepairReason =
|
||||
| "task-not-found"
|
||||
| "no-overlap-blocker"
|
||||
| "not-repairable-state"
|
||||
| "blocker-missing"
|
||||
| "scopes-still-overlap"
|
||||
| "dependency-blocker-remains"
|
||||
| "overlap-blocker-changed"
|
||||
| "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<Settings, "experimentalFeatures"> | undefined): boolean {
|
||||
/*
|
||||
@@ -1093,80 +1122,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[] = [];
|
||||
@@ -1481,6 +1439,55 @@ 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 {
|
||||
/*
|
||||
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, -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<TaskStoreEvents> {
|
||||
private static readonly ACTIVE_TASKS_WHERE = '"deletedAt" IS NULL';
|
||||
/** U6: sentinel effective-workflow id for default-workflow (null-selection)
|
||||
@@ -10834,8 +10841,246 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
||||
|
||||
const content = await readFile(promptPath, "utf-8");
|
||||
|
||||
const paths = extractFileScopeTokens(content);
|
||||
return paths.filter((path) => isValidFileScopeEntry(path));
|
||||
return extractEffectiveWriteScopeFromPrompt(content);
|
||||
}
|
||||
|
||||
async repairOverlapBlocker(id: string, options: RepairOverlapBlockerOptions = {}): Promise<RepairOverlapBlockerResult> {
|
||||
/*
|
||||
FNXC:OverlapRepair 2026-06-25-04:34:
|
||||
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;
|
||||
try {
|
||||
task = await this.getTask(id);
|
||||
} catch {
|
||||
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);
|
||||
|
||||
const settings = await this.getSettings();
|
||||
const ignorePaths = settings.overlapIgnorePaths ?? [];
|
||||
const scopeCache = new Map<string, string[]>();
|
||||
const getScope = async (taskId: string): Promise<string[]> => {
|
||||
const cached = scopeCache.get(taskId);
|
||||
if (cached !== undefined) return cached;
|
||||
const scope = filterRepairOverlapIgnoredPaths(await this.parseFileScopeFromPrompt(taskId), ignorePaths);
|
||||
scopeCache.set(taskId, scope);
|
||||
return scope;
|
||||
};
|
||||
|
||||
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 (blockerHoldsActiveLease && 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 !== "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) {
|
||||
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: true,
|
||||
statusCleared: false,
|
||||
previousOverlapBlockedBy,
|
||||
currentOverlapBlockedBy: currentOverlapBlocker,
|
||||
reason: "rerouted-to-current-overlap",
|
||||
message: `Stale overlap blocker ${previousOverlapBlockedBy} rerouted to ${currentOverlapBlocker}`,
|
||||
task: repairedTask,
|
||||
};
|
||||
}
|
||||
|
||||
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: 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: repairedTask,
|
||||
};
|
||||
}
|
||||
|
||||
private async findCurrentOverlapBlockerForRepair(
|
||||
task: Task,
|
||||
taskScope: string[],
|
||||
tasks: Task[],
|
||||
getScope: (taskId: string) => Promise<string[]>,
|
||||
previousOverlapBlockedBy: string,
|
||||
): Promise<string | null> {
|
||||
/*
|
||||
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(holdsRepairFileScopeLease)
|
||||
.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<TaskPriority, number> = { 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 {
|
||||
|
||||
@@ -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<RepairOverlapBlockerResult> {
|
||||
return api<RepairOverlapBlockerResult>(withProjectId(`/tasks/${id}/repair-overlap-blocker`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(options),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTask(
|
||||
id: string,
|
||||
updates: {
|
||||
|
||||
@@ -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";
|
||||
@@ -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<string | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
setOverlapBlockedByOverride(undefined);
|
||||
}, [task.id]);
|
||||
const workingTask: TaskDetail = fullDetail
|
||||
? ({
|
||||
...fullDetail,
|
||||
@@ -692,6 +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,
|
||||
/*
|
||||
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 =
|
||||
@@ -2466,41 +2477,33 @@ 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) {
|
||||
setOverlapBlockedByOverride(result.task.overlapBlockedBy ?? null);
|
||||
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;
|
||||
}
|
||||
setOverlapBlockedByOverride(updatedTask.overlapBlockedBy ?? null);
|
||||
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 {
|
||||
|
||||
@@ -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(
|
||||
<TaskDetailModal
|
||||
initialTab="definition"
|
||||
@@ -542,13 +542,20 @@ describe("TaskDetailModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Clear" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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(
|
||||
<TaskDetailModal
|
||||
@@ -566,23 +573,34 @@ describe("TaskDetailModal", () => {
|
||||
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,
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: "Clear" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
<TaskDetailModal
|
||||
initialTab="definition"
|
||||
task={makeTask({ id: "FN-T", column: "todo", overlapBlockedBy: "FN-OVER", status: "planning" })}
|
||||
task={makeTask({ id: "FN-T", column: "todo", overlapBlockedBy: "FN-OLD", status: "queued" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
@@ -595,17 +613,13 @@ describe("TaskDetailModal", () => {
|
||||
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(
|
||||
<TaskDetailModal
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
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([]),
|
||||
|
||||
@@ -178,6 +178,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): 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,94 @@ 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<typeof vi.fn>).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 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(updatedTask);
|
||||
|
||||
@@ -3020,6 +3020,47 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/tasks/:id/repair-overlap-blocker", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
/*
|
||||
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");
|
||||
}
|
||||
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) {
|
||||
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 {
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>[]) {
|
||||
function makeStore(tasks: Record<string, unknown>[], scopes: Record<string, string[]> = {}) {
|
||||
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<typeof vi.fn>).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", () => {
|
||||
|
||||
@@ -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<typeof vi.fn>).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"),
|
||||
|
||||
@@ -265,6 +265,29 @@ export function isRunnableQueuedOverlapCandidate(
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldHoldActiveFileScopeLease(
|
||||
task: Task,
|
||||
tasks: Task[],
|
||||
options?: {
|
||||
mergeRequestContractShadowEnabled?: boolean;
|
||||
handoffAccepted?: boolean;
|
||||
schedulingDependencyOptions?: Parameters<typeof getUnmetSchedulingDependencies>[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<string, string[]>();
|
||||
const getFilteredFileScope = async (taskId: string): Promise<string[]> => {
|
||||
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<string, string[]>();
|
||||
const getFilteredFileScope = async (taskId: string): Promise<string[]> => {
|
||||
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);
|
||||
|
||||
@@ -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");
|
||||
@@ -3651,6 +3651,35 @@ 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<string, string[]>();
|
||||
/*
|
||||
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<string[]> => {
|
||||
const cached = filteredScopeByTaskId.get(scopeTaskId);
|
||||
if (cached !== undefined) 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<boolean> => {
|
||||
if (!blockerId) return false;
|
||||
const blocker = taskById.get(blockerId);
|
||||
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);
|
||||
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 +3695,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 +4773,36 @@ 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<string, string[]>();
|
||||
/*
|
||||
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<string[]> => {
|
||||
const cached = filteredScopeByTaskId.get(taskId);
|
||||
if (cached !== undefined) 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<boolean> => {
|
||||
if (!blockerId) return false;
|
||||
const blocker = taskById.get(blockerId);
|
||||
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;
|
||||
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 +4814,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 +4837,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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user