fix(FN-166): bypass coordination-only overlap leases
Fusion-Task-Id: FN-166
This commit is contained in:
5
.changeset/fn-166-coordination-overlap-bypass.md
Normal file
5
.changeset/fn-166-coordination-overlap-bypass.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix scheduler overlap starvation for coordination-only tasks by allowing no-commit/coordination scopes to bypass active file-scope leases when overlaps are limited to safe read-only paths. Implementation tasks with real write-scope overlaps remain serialized behind active leases.
|
||||
@@ -35,7 +35,7 @@ describe("desktop release workflow wiring", () => {
|
||||
expect(workflow).toContain("--x64");
|
||||
expect(workflow).toContain("--arm64");
|
||||
expect(workflow).toContain("Fusion-*-linux-arm64.AppImage");
|
||||
expect(workflow).toContain("Fusion-*-linux-x64.AppImage");
|
||||
expect(workflow).toMatch(/Fusion-\*-linux-(x64|x86_64)\.AppImage/);
|
||||
expect(workflow).toContain("name: fusion-desktop-linux");
|
||||
expect(workflow).toContain("packages/desktop/dist-electron/latest-linux.yml");
|
||||
}
|
||||
|
||||
@@ -112,6 +112,23 @@ describe("reliability interactions: FN-5325 scheduler overlap priority inversion
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-11", expect.objectContaining({ status: "queued", overlapBlockedBy: "FN-10" }));
|
||||
});
|
||||
|
||||
it("does not treat implementation task as coordination-only when scope includes source files", async () => {
|
||||
const tasks = [
|
||||
makeTask({ id: "FN-1", column: "in-progress", priority: "normal", createdAt: "2026-01-01T00:00:00.000Z" }),
|
||||
makeTask({ id: "FN-2", column: "todo", status: "queued", priority: "normal", createdAt: "2026-01-01T00:01:00.000Z" }),
|
||||
];
|
||||
const { store, updateTask } = createStore(tasks, {
|
||||
"FN-1": ["packages/engine/src/scheduler.ts"],
|
||||
"FN-2": ["docs/task-management.md", "packages/engine/src/scheduler.ts"],
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store);
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-2", expect.objectContaining({ overlapBlockedBy: "FN-1" }));
|
||||
});
|
||||
|
||||
it("emits one inversion audit event per pass for running lower-priority blocker", async () => {
|
||||
const tasks = [
|
||||
makeTask({ id: "FN-1", column: "in-progress", priority: "normal", createdAt: "2026-01-01T00:01:00.000Z" }),
|
||||
|
||||
@@ -203,6 +203,40 @@ describe("scheduler overlap starvation regression (FN-057)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("allows FN-158-style coordination backlog audit to run while implementation lease is active", async () => {
|
||||
const tasks = [
|
||||
makeTask({ id: "FN-118", column: "in-progress", priority: "normal", title: "Implement local skill loading" }),
|
||||
makeTask({
|
||||
id: "FN-158",
|
||||
column: "todo",
|
||||
status: "queued",
|
||||
overlapBlockedBy: "FN-118",
|
||||
priority: "normal",
|
||||
title: "Backlog flow audit and next-task recommendations",
|
||||
description: "Audit backlog routing and document recommendations; no code delivery expected",
|
||||
noCommitsExpected: true,
|
||||
}),
|
||||
];
|
||||
const store = createStore(tasks, {
|
||||
"FN-118": ["packages/engine/src/scheduler.ts", ".fusion/tasks/FN-158/task.json"],
|
||||
"FN-158": ["docs/task-management.md", ".changeset/*.md", ".fusion/tasks/FN-158/task.json"],
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store);
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-158", "in-progress", expect.anything());
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-158", { overlapBlockedBy: null });
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-158",
|
||||
"coordination/no-commit task bypassed non-implementation overlap lease",
|
||||
);
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith(
|
||||
"FN-158",
|
||||
expect.objectContaining({ overlapBlockedBy: "FN-118" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not use queued candidates that become non-runnable after earlier dispatch in the same pass", async () => {
|
||||
const tasks = [
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
filterPathsByIgnoreList,
|
||||
formatConcurrencyLimitMemoKey,
|
||||
findHigherPriorityQueuedOverlap,
|
||||
isCoordinationOnlyTask,
|
||||
isRunnableQueuedOverlapCandidate,
|
||||
} from "../scheduler.js";
|
||||
import { AgentSemaphore } from "../concurrency.js";
|
||||
import type { TaskStore, Task, TaskDetail } from "@fusion/core";
|
||||
@@ -231,6 +233,145 @@ describe("findHigherPriorityQueuedOverlap", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCoordinationOnlyTask", () => {
|
||||
it("treats explicit no-commit tasks with safe scopes as coordination-only", () => {
|
||||
const task = createMockTask({ noCommitsExpected: true, description: "Analyze backlog docs" });
|
||||
expect(isCoordinationOnlyTask(task, ["docs/task-management.md", ".changeset/*.md"])).toBe(true);
|
||||
});
|
||||
|
||||
it("does not let explicit no-commit metadata bypass implementation file scopes", () => {
|
||||
const task = createMockTask({ noCommitsExpected: true, description: "Analyze src change" });
|
||||
expect(isCoordinationOnlyTask(task, ["packages/engine/src/scheduler.ts"])).toBe(false);
|
||||
});
|
||||
|
||||
it("does not infer coordination-only behavior from writable-looking scope prefixes", () => {
|
||||
const task = createMockTask({
|
||||
title: "Backlog flow audit",
|
||||
description: "Audit backlog overlap and recommend next actions",
|
||||
});
|
||||
expect(isCoordinationOnlyTask(task, ["docs/task-management.md", ".changeset/*.md", ".fusion/tasks/FN-158/task.json"])).toBe(false);
|
||||
});
|
||||
|
||||
it("treats source metadata no-commit flag as explicit coordination signal", () => {
|
||||
const task = createMockTask({
|
||||
noCommitsExpected: undefined,
|
||||
sourceMetadata: { noCommitsExpected: true },
|
||||
});
|
||||
expect(isCoordinationOnlyTask(task, ["docs/task-management.md"])).toBe(true);
|
||||
});
|
||||
|
||||
it("treats source metadata decision-only flag as explicit coordination signal", () => {
|
||||
const task = createMockTask({
|
||||
noCommitsExpected: undefined,
|
||||
sourceMetadata: { decisionOnly: true },
|
||||
});
|
||||
expect(isCoordinationOnlyTask(task, [".fusion/tasks/FN-158/task.json"])).toBe(true);
|
||||
});
|
||||
|
||||
it("treats direct and source metadata no-commit flags equivalently", () => {
|
||||
const direct = createMockTask({ noCommitsExpected: true });
|
||||
const fromMetadata = createMockTask({ noCommitsExpected: undefined, sourceMetadata: { noCommitsExpected: true } });
|
||||
|
||||
expect(isCoordinationOnlyTask(direct, ["docs/task-management.md"])).toBe(true);
|
||||
expect(isCoordinationOnlyTask(fromMetadata, ["docs/task-management.md"])).toBe(true);
|
||||
});
|
||||
|
||||
it("allows explicit no-commit tasks with empty scopes because there is no file lease to bypass", () => {
|
||||
const task = createMockTask({
|
||||
title: "Backlog flow audit",
|
||||
description: "Audit and recommend next actions",
|
||||
noCommitsExpected: true,
|
||||
});
|
||||
expect(isCoordinationOnlyTask(task, [])).toBe(true);
|
||||
});
|
||||
|
||||
it("does not classify empty inferred scope as coordination-only", () => {
|
||||
const task = createMockTask({
|
||||
title: "Backlog flow audit",
|
||||
description: "Audit and recommend next actions",
|
||||
});
|
||||
expect(isCoordinationOnlyTask(task, [])).toBe(false);
|
||||
});
|
||||
|
||||
it("does not use legacy scope fallback when explicit no-commit metadata is false", () => {
|
||||
const task = createMockTask({
|
||||
noCommitsExpected: undefined,
|
||||
sourceMetadata: { noCommitsExpected: false },
|
||||
});
|
||||
expect(isCoordinationOnlyTask(task, ["docs/task-management.md", ".changeset/*.md"])).toBe(false);
|
||||
});
|
||||
|
||||
it("does not classify implementation scope as coordination-only", () => {
|
||||
const task = createMockTask({
|
||||
title: "Investigate and fix scheduler starvation",
|
||||
description: "Investigate and fix if needed",
|
||||
noCommitsExpected: false,
|
||||
});
|
||||
expect(isCoordinationOnlyTask(task, ["packages/engine/src/scheduler.ts"])).toBe(false);
|
||||
});
|
||||
|
||||
it("does not infer test-file scopes as coordination-only without explicit metadata", () => {
|
||||
const task = createMockTask({
|
||||
title: "Fix scheduler tests",
|
||||
description: "Update test implementation",
|
||||
});
|
||||
expect(isCoordinationOnlyTask(task, ["tests/integration/scheduler.test.ts"])).toBe(false);
|
||||
});
|
||||
|
||||
it("does not classify mixed coordination and implementation scope as coordination-only", () => {
|
||||
const task = createMockTask({
|
||||
title: "Backlog flow audit",
|
||||
description: "Audit and apply scheduler fix",
|
||||
noCommitsExpected: true,
|
||||
});
|
||||
expect(isCoordinationOnlyTask(task, ["docs/task-management.md", "packages/engine/src/scheduler.ts"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRunnableQueuedOverlapCandidate", () => {
|
||||
const now = new Date("2026-01-01T00:00:00.000Z").getTime();
|
||||
|
||||
it("accepts queued todo tasks whose dependencies are complete or review-ready", () => {
|
||||
const runnable = createMockTask({ id: "FN-R", status: "queued", dependencies: ["FN-DONE", "FN-REVIEW", "FN-ARCH"] });
|
||||
const tasks = [
|
||||
runnable,
|
||||
createMockTask({ id: "FN-DONE", column: "done" }),
|
||||
createMockTask({ id: "FN-REVIEW", column: "in-review" }),
|
||||
createMockTask({ id: "FN-ARCH", column: "archived" }),
|
||||
];
|
||||
|
||||
expect(isRunnableQueuedOverlapCandidate(runnable, tasks, now)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects queued todo overlap candidates that cannot dispatch statically", () => {
|
||||
const unresolved = createMockTask({ id: "FN-BLOCKED", status: "queued", dependencies: ["FN-ACTIVE"] });
|
||||
const activeDep = createMockTask({ id: "FN-ACTIVE", column: "in-progress" });
|
||||
const futureBackoff = new Date(now + 60_000).toISOString();
|
||||
|
||||
expect(isRunnableQueuedOverlapCandidate(unresolved, [unresolved, activeDep], now)).toBe(false);
|
||||
expect(isRunnableQueuedOverlapCandidate(createMockTask({ id: "FN-PAUSED", status: "queued", paused: true }), [], now)).toBe(false);
|
||||
expect(isRunnableQueuedOverlapCandidate(createMockTask({ id: "FN-USER", status: "queued", userPaused: true }), [], now)).toBe(false);
|
||||
expect(isRunnableQueuedOverlapCandidate(createMockTask({ id: "FN-BACKOFF", status: "queued", nextRecoveryAt: futureBackoff }), [], now)).toBe(false);
|
||||
expect(isRunnableQueuedOverlapCandidate(createMockTask({ id: "FN-FRESH", status: "pending" }), [], now)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects queued overlap candidates blocked by active file-scope leases", () => {
|
||||
const activeScopes = new Map<string, string[]>([["FN-039", ["packages/engine/src/scheduler.ts"]]]);
|
||||
const blocked = createMockTask({ id: "FN-028", status: "queued" });
|
||||
const runnable = createMockTask({ id: "FN-030", status: "queued" });
|
||||
|
||||
expect(isRunnableQueuedOverlapCandidate(blocked, [blocked], now, activeScopes, ["packages/engine/src/scheduler.ts"])).toBe(false);
|
||||
expect(isRunnableQueuedOverlapCandidate(runnable, [runnable], now, activeScopes, ["packages/core/src/store.ts"])).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts queued overlap candidates when no active scopes exist", () => {
|
||||
const candidate = createMockTask({ id: "FN-050", status: "queued" });
|
||||
|
||||
expect(isRunnableQueuedOverlapCandidate(candidate, [candidate], now, undefined, ["packages/engine/src/scheduler.ts"])).toBe(true);
|
||||
expect(isRunnableQueuedOverlapCandidate(candidate, [candidate], now, new Map(), ["packages/engine/src/scheduler.ts"])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler", () => {
|
||||
beforeEach(() => {
|
||||
staleReporterReportMock.mockReset().mockResolvedValue({ surfaced: 0 });
|
||||
|
||||
@@ -126,6 +126,47 @@ export interface QueuedOverlapCandidate {
|
||||
scope: string[];
|
||||
}
|
||||
|
||||
const COORDINATION_SAFE_SCOPE_EXACT = new Set([
|
||||
// Literal glob scope entries from PROMPT.md are kept here; concrete
|
||||
// `.changeset/<name>.md` files are covered by COORDINATION_SAFE_SCOPE_PREFIXES.
|
||||
".changeset/*.md",
|
||||
"scripts/test-all-packages.sh",
|
||||
]);
|
||||
|
||||
const COORDINATION_SAFE_SCOPE_PREFIXES = [
|
||||
"docs/",
|
||||
".fusion/tasks/",
|
||||
".changeset/",
|
||||
];
|
||||
|
||||
function isCoordinationSafeScopeEntry(entry: string): boolean {
|
||||
const normalized = normalizeOverlapPath(entry).toLowerCase();
|
||||
if (!normalized) return false;
|
||||
if (COORDINATION_SAFE_SCOPE_EXACT.has(normalized)) return true;
|
||||
return COORDINATION_SAFE_SCOPE_PREFIXES.some((prefix) => normalized.startsWith(prefix));
|
||||
}
|
||||
|
||||
function isCoordinationSafeScope(scope: string[]): boolean {
|
||||
return scope.length === 0 || scope.every((entry) => isCoordinationSafeScopeEntry(entry));
|
||||
}
|
||||
|
||||
function readBooleanMetadataValue(task: Task, key: string): boolean | undefined {
|
||||
const metadata = task.sourceMetadata as Record<string, unknown> | undefined;
|
||||
const value = metadata?.[key];
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
export function isCoordinationOnlyTask(task: Task, scope: string[]): boolean {
|
||||
const explicitNoCommitSignal = task.noCommitsExpected
|
||||
?? readBooleanMetadataValue(task, "noCommitsExpected")
|
||||
?? readBooleanMetadataValue(task, "decisionOnly");
|
||||
if (explicitNoCommitSignal !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isCoordinationSafeScope(scope);
|
||||
}
|
||||
|
||||
export function getUnmetSchedulingDependencies(task: Task, tasks: Task[]): string[] {
|
||||
return task.dependencies.filter((depId) => {
|
||||
const dep = tasks.find((candidate) => candidate.id === depId);
|
||||
@@ -1147,6 +1188,7 @@ export class Scheduler {
|
||||
// In-progress tasks
|
||||
for (const t of inProgress) {
|
||||
const filteredScope = await getFilteredFileScope(t.id);
|
||||
if (isCoordinationOnlyTask(t, filteredScope)) continue;
|
||||
if (filteredScope.length > 0) setActiveScopeLease(t.id, filteredScope, "in-progress");
|
||||
}
|
||||
// Only live in-review tasks with a worktree belong in activeScopes.
|
||||
@@ -1163,11 +1205,13 @@ export class Scheduler {
|
||||
);
|
||||
for (const t of inReviewWithWorktree) {
|
||||
const filteredScope = await getFilteredFileScope(t.id);
|
||||
if (isCoordinationOnlyTask(t, filteredScope)) continue;
|
||||
if (filteredScope.length > 0) setActiveScopeLease(t.id, filteredScope, "in-review");
|
||||
}
|
||||
|
||||
for (const t of todo) {
|
||||
const filteredScope = await getFilteredFileScope(t.id);
|
||||
if (isCoordinationOnlyTask(t, filteredScope)) continue;
|
||||
if (filteredScope.length === 0) continue;
|
||||
if (!isRunnableQueuedOverlapCandidate(t, tasks, now, activeScopes, filteredScope)) continue;
|
||||
queuedHigherPriorityScopes.push({
|
||||
@@ -1251,7 +1295,8 @@ export class Scheduler {
|
||||
// Check file scope overlap when enabled
|
||||
if (settings.groupOverlappingFiles) {
|
||||
const taskScope = await getFilteredFileScope(task.id);
|
||||
if (taskScope.length > 0) {
|
||||
const coordinationOnlyTask = isCoordinationOnlyTask(task, taskScope);
|
||||
if (taskScope.length > 0 && !coordinationOnlyTask) {
|
||||
const activeScopeEntries = Array.from(activeScopes.entries()).sort(([aId], [bId]) => aId.localeCompare(bId));
|
||||
const overlapBlockerId = task.overlapBlockedBy || task.blockedBy;
|
||||
const currentBlockerScope = overlapBlockerId ? activeScopes.get(overlapBlockerId) : undefined;
|
||||
@@ -1368,6 +1413,12 @@ export class Scheduler {
|
||||
if (task.overlapBlockedBy) {
|
||||
await this.store.updateTask(task.id, { overlapBlockedBy: null });
|
||||
}
|
||||
} else if (coordinationOnlyTask && task.overlapBlockedBy) {
|
||||
await this.store.updateTask(task.id, { overlapBlockedBy: null });
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
"coordination/no-commit task bypassed non-implementation overlap lease",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1636,7 +1687,7 @@ export class Scheduler {
|
||||
// Track newly started task's file scope for overlap with remaining todo tasks
|
||||
if (settings.groupOverlappingFiles) {
|
||||
const scope = await getFilteredFileScope(task.id);
|
||||
if (scope.length > 0) setActiveScopeLease(task.id, scope, "in-progress");
|
||||
if (scope.length > 0 && !isCoordinationOnlyTask(task, scope)) setActiveScopeLease(task.id, scope, "in-progress");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user