feat(FN-4969): merge fusion/fn-4969
This commit is contained in:
5
.changeset/fn-4969-scheduler-dependency-fanout.md
Normal file
5
.changeset/fn-4969-scheduler-dependency-fanout.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user