feat(FN-4969): merge fusion/fn-4969

This commit is contained in:
gsxdsm
2026-05-17 22:57:15 -07:00
parent e2938114c0
commit 02ba659dd8
8 changed files with 304 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Scheduler now prefers runnable todo tasks that unblock the most downstream dependents within the same priority class, so root blockers like FN-4766/FN-4867 stop sitting behind unrelated same-priority work. Urgent tasks still outrank everything.

View File

@@ -181,6 +181,7 @@ Detailed mechanism logs live in `docs/architecture.md` and `docs/design/`. The c
- **Worktrunk-managed lifecycles**: when `worktrunk.enabled`, self-healing defers prune/idle/worktree-cap sweeps to the worktrunk backend; branch-level reclaim and orphan rescue stay native. - **Worktrunk-managed lifecycles**: when `worktrunk.enabled`, self-healing defers prune/idle/worktree-cap sweeps to the worktrunk backend; branch-level reclaim and orphan rescue stay native.
- **Post-finalize verification no-op (FN-4944)**: when auto-merge receives a delayed `VerificationError` after a task is already `done` with `mergeDetails.mergeConfirmed === true` (already-on-main fast-path), it must log one `[verification] ... no action` diagnostic and must not bounce the task back to `in-progress` / `merging-fix`. Defense-in-depth now re-checks the done+mergeConfirmed condition immediately before each verification-failure status write site, and emits `task:post-finalize-verification-no-op` database audit events with failure metadata for forensics. - **Post-finalize verification no-op (FN-4944)**: when auto-merge receives a delayed `VerificationError` after a task is already `done` with `mergeDetails.mergeConfirmed === true` (already-on-main fast-path), it must log one `[verification] ... no action` diagnostic and must not bounce the task back to `in-progress` / `merging-fix`. Defense-in-depth now re-checks the done+mergeConfirmed condition immediately before each verification-failure status write site, and emits `task:post-finalize-verification-no-op` database audit events with failure metadata for forensics.
- **Worktree pool exclusivity (FN-4954)**: `WorktreePool.acquire(taskId)` / `release(path, taskId?)` track a `leased` map so every pooled path is either idle or leased, never both. Cross-task double-lease detection throws `PoolDoubleLeaseError` and emits `worktree:pool-double-lease-detected`; merger Step 8 now detaches HEAD and clears `task.worktree` / `task.branch` before releasing paths back to the pool. - **Worktree pool exclusivity (FN-4954)**: `WorktreePool.acquire(taskId)` / `release(path, taskId?)` track a `leased` map so every pooled path is either idle or leased, never both. Cross-task double-lease detection throws `PoolDoubleLeaseError` and emits `worktree:pool-double-lease-detected`; merger Step 8 now detaches HEAD and clears `task.worktree` / `task.branch` before releasing paths back to the pool.
- **Scheduler fanout tiebreaker (FN-4969)**: within the same priority class, scheduler dispatch prefers runnable `todo` tasks with the highest active dependency-dependent fanout; `urgent` always outranks lower priorities regardless of fanout, and `overlapBlockedBy`/file-scope overlap blockers are excluded from unblock weight.
## Engine Process Rules ## Engine Process Rules

View File

@@ -607,7 +607,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan.
- Advisory and blocking paths are both logged to task logs for operator visibility. - Advisory and blocking paths are both logged to task logs for operator visibility.
### Scheduling and execution ### Scheduling and execution
- `Scheduler` (`scheduler.ts`) — dependency-aware task scheduling that dispatches eligible todo tasks by priority first, then FIFO (`createdAt` ascending) within each priority tier. - `Scheduler` (`scheduler.ts`) — dependency-aware task scheduling that dispatches eligible todo tasks by priority first, then dependency-unblock fanout within the same priority class (FN-4969), then FIFO (`createdAt` ascending) with task-id fallback. `urgent` always stays ahead of lower priorities, and overlap/file-scope blockers are excluded from fanout weighting.
- `blockedBy` invariant (FN-3924/FN-4091): the field is only durable when it references a current unresolved explicit dependency (or, for dependency-free tasks, an active overlap blocker). Completion gating now validates `blockedBy` through live task resolution: missing blockers and blockers already in `done`/`archived` are treated as stale, while only still-active blockers continue to prevent `fn_task_done`. If no current blocker remains, scheduler/event reconciliation clears `blockedBy` to `null` and re-evaluates from live task state. - `blockedBy` invariant (FN-3924/FN-4091): the field is only durable when it references a current unresolved explicit dependency (or, for dependency-free tasks, an active overlap blocker). Completion gating now validates `blockedBy` through live task resolution: missing blockers and blockers already in `done`/`archived` are treated as stale, while only still-active blockers continue to prevent `fn_task_done`. If no current blocker remains, scheduler/event reconciliation clears `blockedBy` to `null` and re-evaluates from live task state.
#### BlockedBy stamping invariants #### BlockedBy stamping invariants

View File

@@ -6,12 +6,16 @@ import {
isTaskPriority, isTaskPriority,
normalizeTaskPriority, normalizeTaskPriority,
sortTasksByPriorityThenAgeAndId, sortTasksByPriorityThenAgeAndId,
sortTasksByPriorityFanoutThenAgeAndId,
compareTasksByPriorityFanoutThenAgeAndId,
buildUnblockWeightMap,
compareTaskIdNumeric, compareTaskIdNumeric,
sortTasksForDisplayColumn, sortTasksForDisplayColumn,
} from "../task-priority.js"; } from "../task-priority.js";
import { import {
DEFAULT_TASK_PRIORITY, DEFAULT_TASK_PRIORITY,
TASK_PRIORITIES, TASK_PRIORITIES,
type Task,
type TaskPriority, type TaskPriority,
} from "../types.js"; } from "../types.js";
import type { PrCheckState, PrCheckStatus } from "../index.js"; import type { PrCheckState, PrCheckStatus } from "../index.js";
@@ -63,6 +67,72 @@ describe("task-priority", () => {
expect(compareTaskIdNumeric("TASK-B", "TASK-A")).toBeGreaterThan(0); expect(compareTaskIdNumeric("TASK-B", "TASK-A")).toBeGreaterThan(0);
}); });
it("prefers higher fanout tasks within the same priority", () => {
const tasks = [
{ id: "FN-2", createdAt: "2026-01-01T00:00:00.000Z", priority: "normal" as const },
{ id: "FN-1", createdAt: "2026-01-01T00:00:00.000Z", priority: "normal" as const },
];
const sorted = sortTasksByPriorityFanoutThenAgeAndId(tasks, new Map([["FN-2", 3], ["FN-1", 1]]));
expect(sorted.map((task) => task.id)).toEqual(["FN-2", "FN-1"]);
});
it("keeps urgent ahead of lower priority regardless of fanout", () => {
const urgent = { id: "FN-10", createdAt: "2026-01-01T00:00:00.000Z", priority: "urgent" as const };
const high = { id: "FN-11", createdAt: "2026-01-01T00:00:00.000Z", priority: "high" as const };
const cmp = compareTasksByPriorityFanoutThenAgeAndId(urgent, high, {
unblockWeights: new Map([["FN-11", 9_999]]),
});
expect(cmp).toBeLessThan(0);
});
it("falls back to createdAt then id when fanout ties", () => {
const tasks = [
{ id: "FN-9", createdAt: "2026-01-01T00:00:00.000Z", priority: "normal" as const },
{ id: "FN-8", createdAt: "2026-01-01T00:00:00.000Z", priority: "normal" as const },
{ id: "FN-7", createdAt: "2025-12-31T00:00:00.000Z", priority: "normal" as const },
];
const sorted = sortTasksByPriorityFanoutThenAgeAndId(tasks, new Map([["FN-9", 2], ["FN-8", 2], ["FN-7", 2]]));
expect(sorted.map((task) => task.id)).toEqual(["FN-7", "FN-8", "FN-9"]);
});
it("uses zero weight for tasks without dependents and preserves legacy tie behavior", () => {
const tasks = [
{ id: "FN-2", createdAt: "2026-01-01T00:00:00.000Z", priority: "normal" as const },
{ id: "FN-1", createdAt: "2026-01-01T00:00:00.000Z", priority: "normal" as const },
];
const sorted = sortTasksByPriorityFanoutThenAgeAndId(tasks, new Map());
expect(sorted.map((task) => task.id)).toEqual(["FN-1", "FN-2"]);
});
it("buildUnblockWeightMap ignores done/archived dependents", () => {
const makeTask = (task: Partial<Task> & Pick<Task, "id" | "column" | "createdAt" | "updatedAt" | "description">): Task => ({
id: task.id,
description: task.description,
column: task.column,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
dependencies: task.dependencies ?? [],
steps: [],
currentStep: 1,
log: [],
...task,
});
const tasks: Task[] = [
makeTask({ id: "FN-1", description: "blocker", column: "todo", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }),
makeTask({ id: "FN-2", description: "active dependent", column: "todo", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", dependencies: ["FN-1"] }),
makeTask({ id: "FN-3", description: "done dependent", column: "done", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", dependencies: ["FN-1"] }),
makeTask({ id: "FN-4", description: "archived dependent", column: "archived", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", dependencies: ["FN-1"] }),
];
const weights = buildUnblockWeightMap(tasks);
expect(weights.get("FN-1")).toBe(1_000_001);
});
it("applies board/list default ordering semantics by column", () => { it("applies board/list default ordering semantics by column", () => {
const base = { const base = {
createdAt: "2026-01-01T00:00:00.000Z", createdAt: "2026-01-01T00:00:00.000Z",

View File

@@ -530,11 +530,19 @@ export {
getTaskPriorityRank, getTaskPriorityRank,
compareTaskPriority, compareTaskPriority,
compareTasksByPriorityThenAgeAndId, compareTasksByPriorityThenAgeAndId,
compareTasksByPriorityFanoutThenAgeAndId,
sortTasksByPriorityThenAgeAndId, sortTasksByPriorityThenAgeAndId,
sortTasksByPriorityFanoutThenAgeAndId,
buildUnblockWeightMap,
compareTaskIdNumeric, compareTaskIdNumeric,
sortTasksForDisplayColumn, sortTasksForDisplayColumn,
} from "./task-priority.js"; } from "./task-priority.js";
export type { TaskPrioritySortable, TaskColumnSortable } from "./task-priority.js"; export type {
TaskPrioritySortable,
TaskColumnSortable,
BuildUnblockWeightMapOptions,
PriorityFanoutComparatorContext,
} from "./task-priority.js";
// ── Mission Hierarchy Types ──────────────────────────────────────────── // ── Mission Hierarchy Types ────────────────────────────────────────────

View File

@@ -1,5 +1,6 @@
import { computeBlockerFanoutMap } from "./blocker-fanout.js";
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES } from "./types.js"; import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES } from "./types.js";
import type { TaskPriority } from "./types.js"; import type { Task, TaskPriority } from "./types.js";
export interface TaskPrioritySortable { export interface TaskPrioritySortable {
id: string; id: string;
@@ -84,6 +85,99 @@ export function sortTasksByPriorityThenAgeAndId<T extends TaskPrioritySortable>(
return [...tasks].sort(compareTasksByPriorityThenAgeAndId); return [...tasks].sort(compareTasksByPriorityThenAgeAndId);
} }
const FANOUT_SECONDARY_WEIGHT_MULTIPLIER = 1_000_000;
const UNBLOCK_ACTIVE_COLUMNS = new Set<Task["column"]>(["triage", "todo", "in-progress", "in-review"]);
const DONE_COLUMNS = new Set<Task["column"]>(["done", "archived"]);
export interface BuildUnblockWeightMapOptions {
maxAutoMergeRetries?: number;
}
function countUnmetDependencies(task: Task, taskById: Map<string, Task>): number {
let unmet = 0;
for (const dependencyId of task.dependencies ?? []) {
const dependency = taskById.get(dependencyId);
if (!dependency) {
unmet += 1;
continue;
}
if (DONE_COLUMNS.has(dependency.column)) {
continue;
}
unmet += 1;
}
return unmet;
}
export function buildUnblockWeightMap(
tasks: readonly Task[],
options: BuildUnblockWeightMapOptions = {},
): Map<string, number> {
const taskList = [...tasks];
const fanout = computeBlockerFanoutMap(taskList, options.maxAutoMergeRetries ?? 0);
const taskById = new Map(taskList.map((task) => [task.id, task]));
const weights = new Map<string, number>();
for (const [blockerId, entry] of fanout) {
let primaryOnlyUnmetCount = 0;
let secondaryActiveDependentCount = 0;
for (const dependentId of entry.dependencyDependentIds) {
const dependent = taskById.get(dependentId);
if (!dependent || !UNBLOCK_ACTIVE_COLUMNS.has(dependent.column)) {
continue;
}
secondaryActiveDependentCount += 1;
if (countUnmetDependencies(dependent, taskById) === 1) {
primaryOnlyUnmetCount += 1;
}
}
const weight = primaryOnlyUnmetCount * FANOUT_SECONDARY_WEIGHT_MULTIPLIER + secondaryActiveDependentCount;
weights.set(blockerId, weight);
}
return weights;
}
export interface PriorityFanoutComparatorContext {
unblockWeights: ReadonlyMap<string, number>;
}
/**
* FN-4969: within the same priority class, prefer tasks that unblock the most dependency-bound work.
* This must never reorder across priority classes — urgent user work always outranks fanout.
*/
export function compareTasksByPriorityFanoutThenAgeAndId<T extends TaskPrioritySortable>(
a: T,
b: T,
ctx: PriorityFanoutComparatorContext,
): number {
const priorityCmp = compareTaskPriority(a.priority, b.priority);
if (priorityCmp !== 0) {
return priorityCmp;
}
const aWeight = ctx.unblockWeights.get(a.id) ?? 0;
const bWeight = ctx.unblockWeights.get(b.id) ?? 0;
if (aWeight !== bWeight) {
return bWeight - aWeight;
}
if (a.createdAt !== b.createdAt) {
return a.createdAt.localeCompare(b.createdAt);
}
return compareTaskIdNumeric(a.id, b.id);
}
export function sortTasksByPriorityFanoutThenAgeAndId<T extends TaskPrioritySortable>(
tasks: readonly T[],
unblockWeights: ReadonlyMap<string, number>,
): T[] {
return [...tasks].sort((a, b) => compareTasksByPriorityFanoutThenAgeAndId(a, b, { unblockWeights }));
}
function getDoneSortTimestamp(task: TaskColumnSortable): number { function getDoneSortTimestamp(task: TaskColumnSortable): number {
const timestamp = task.columnMovedAt ?? task.updatedAt ?? task.createdAt; const timestamp = task.columnMovedAt ?? task.updatedAt ?? task.createdAt;
const parsed = Date.parse(timestamp); const parsed = Date.parse(timestamp);

View File

@@ -1046,6 +1046,115 @@ describe("Scheduler", () => {
}); });
}); });
describe("FN-4969 dependency-unblock prioritization", () => {
it("promotes higher-fanout todo task over older same-priority tasks", async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
const tasks = [
createMockTask({ id: "FN-010", column: "todo", priority: "normal", createdAt: "2026-01-01T00:02:00.000Z" }),
createMockTask({ id: "FN-011", column: "todo", priority: "normal", createdAt: "2026-01-01T00:01:00.000Z" }),
createMockTask({ id: "FN-012", column: "todo", priority: "normal", createdAt: "2026-01-01T00:03:00.000Z" }),
createMockTask({ id: "FN-101", column: "todo", dependencies: ["FN-010"] }),
createMockTask({ id: "FN-102", column: "todo", dependencies: ["FN-010"] }),
createMockTask({ id: "FN-103", column: "todo", dependencies: ["FN-010"] }),
];
const moveTask = vi.fn().mockResolvedValue(undefined);
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue(tasks),
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 1, maxWorktrees: 10, groupOverlappingFiles: false }),
moveTask,
});
const scheduler = new Scheduler(store);
(scheduler as any).running = true;
await scheduler.schedule();
expect(moveTask.mock.calls[0][0]).toBe("FN-010");
});
it("keeps urgent tasks ahead of lower-priority high-fanout tasks", async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
const tasks = [
createMockTask({ id: "FN-020", column: "todo", priority: "urgent", createdAt: "2026-01-01T00:02:00.000Z" }),
createMockTask({ id: "FN-021", column: "todo", priority: "normal", createdAt: "2026-01-01T00:01:00.000Z" }),
createMockTask({ id: "FN-201", column: "todo", dependencies: ["FN-021"] }),
createMockTask({ id: "FN-202", column: "todo", dependencies: ["FN-021"] }),
createMockTask({ id: "FN-203", column: "todo", dependencies: ["FN-021"] }),
createMockTask({ id: "FN-204", column: "todo", dependencies: ["FN-021"] }),
createMockTask({ id: "FN-205", column: "todo", dependencies: ["FN-021"] }),
];
const moveTask = vi.fn().mockResolvedValue(undefined);
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue(tasks),
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 1, maxWorktrees: 10, groupOverlappingFiles: false }),
moveTask,
});
const scheduler = new Scheduler(store);
(scheduler as any).running = true;
await scheduler.schedule();
expect(moveTask.mock.calls[0][0]).toBe("FN-020");
});
it("falls back to age then numeric id when fanout ties", async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
const tasks = [
createMockTask({ id: "FN-029", column: "todo", priority: "normal", createdAt: "2026-01-01T00:01:00.000Z" }),
createMockTask({ id: "FN-028", column: "todo", priority: "normal", createdAt: "2026-01-01T00:01:00.000Z" }),
createMockTask({ id: "FN-027", column: "todo", priority: "normal", createdAt: "2026-01-01T00:00:00.000Z" }),
createMockTask({ id: "FN-301", column: "todo", dependencies: ["FN-028"] }),
createMockTask({ id: "FN-302", column: "todo", dependencies: ["FN-029"] }),
createMockTask({ id: "FN-303", column: "todo", dependencies: ["FN-027"] }),
];
const moveTask = vi.fn().mockResolvedValue(undefined);
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue(tasks),
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 3, maxWorktrees: 10, groupOverlappingFiles: false }),
moveTask,
});
const scheduler = new Scheduler(store);
(scheduler as any).running = true;
await scheduler.schedule();
expect(moveTask.mock.calls.slice(0, 3).map((call: unknown[]) => call[0])).toEqual(["FN-027", "FN-028", "FN-029"]);
});
it("does not count done/archived dependents toward unblock priority", async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
const tasks = [
createMockTask({ id: "FN-040", column: "todo", priority: "normal", createdAt: "2026-01-01T00:00:00.000Z" }),
createMockTask({ id: "FN-041", column: "todo", priority: "normal", createdAt: "2026-01-01T00:01:00.000Z" }),
createMockTask({ id: "FN-401", column: "done", dependencies: ["FN-040"] }),
createMockTask({ id: "FN-402", column: "archived", dependencies: ["FN-040"] }),
];
const moveTask = vi.fn().mockResolvedValue(undefined);
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue(tasks),
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 10, groupOverlappingFiles: false }),
moveTask,
});
const scheduler = new Scheduler(store);
(scheduler as any).running = true;
await scheduler.schedule();
expect(moveTask.mock.calls.slice(0, 2).map((call: unknown[]) => call[0])).toEqual(["FN-040", "FN-041"]);
});
});
describe("overlap ignore paths", () => { describe("overlap ignore paths", () => {
it("allows scheduling when overlap is only on ignored files", async () => { it("allows scheduling when overlap is only on ignored files", async () => {
vi.mocked(existsSync).mockReturnValue(true); vi.mocked(existsSync).mockReturnValue(true);

View File

@@ -1,7 +1,8 @@
import { import {
getCurrentRepo, getCurrentRepo,
resolveDependencyOrder, resolveDependencyOrder,
sortTasksByPriorityThenAgeAndId, sortTasksByPriorityFanoutThenAgeAndId,
buildUnblockWeightMap,
computeBlockerFanoutMap, computeBlockerFanoutMap,
HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD,
type TaskStore, type TaskStore,
@@ -812,7 +813,18 @@ export class Scheduler {
if (todo.length === 0) return; if (todo.length === 0) return;
todo = sortTasksByPriorityThenAgeAndId(todo); const maxAutoMergeRetries =
typeof settings.maxAutoMergeRetries === "number" ? settings.maxAutoMergeRetries : undefined;
const unblockWeights = buildUnblockWeightMap(tasks, {
maxAutoMergeRetries,
});
todo = sortTasksByPriorityFanoutThenAgeAndId(todo, unblockWeights);
const topWeightedTask = todo.find((candidate) => (unblockWeights.get(candidate.id) ?? 0) >= 1);
if (topWeightedTask) {
schedulerLog.log(
`Dispatch ordering: priority+fanout (top: ${topWeightedTask.id}=${unblockWeights.get(topWeightedTask.id) ?? 0})`,
);
}
/** /**
* Pre-compute file scopes for all currently active tasks (in-progress * Pre-compute file scopes for all currently active tasks (in-progress