feat(FN-5325): priority-aware overlap deferral and merge integration worktr
The merge delivers several meaningful features and fixes: a **priority-aware overlap deferral fix** (FN-5325) in the scheduler that aligns the queued-overlap helper with the priority-sortable type, preventing inversion; a **merge integration worktree feature** (FN-5279) with settings UI, reusable wo Fusion-Task-Id: FN-5325
This commit is contained in:
committed by
gsxdsm
parent
8f2d5e7e61
commit
62f11e69d3
@@ -200,6 +200,7 @@ Detailed mechanism logs live in `docs/architecture.md` and `docs/design/`. The c
|
||||
- **Raw worktree deletion must be paired with prune (FN-5058)**: any direct filesystem deletion of a worktree directory (`rm -rf` / `rmSync`) must be followed by best-effort `git worktree prune` via `pruneWorktreeAdminEntries` so `.git/worktrees/*` admin entries are not stranded in a missing-but-registered state (FN-5056 class).
|
||||
- **Meta-task auto-archive safety guards (FN-5064)**: `auto-archive-meta-resolved`/`auto-archive-meta-stalled` must skip archival (with `task:auto-archive-meta-*-skipped` audits) whenever guard checks detect substantive work signals such as unique branch commits, recent executor activity, pending `taskDoneRetryCount`, merge-in-progress state, or active worktree session.
|
||||
- **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.
|
||||
- **Scheduler overlap priority/age guard (FN-5325)**: with `groupOverlappingFiles=true`, scheduler now defers a lower-priority (or younger same-priority) candidate when an overlapping queued todo task exists, preserving priority→age→task-id order for overlap serialization without preempting in-progress work. If the inversion is against an already-running lower-priority blocker, scheduler still defers and emits `scheduler:overlap-priority-inversion` once per (candidate, blocker, pass).
|
||||
- **In-review branch-binding self-heal (FN-5083)**: `reconcile-in-review-branch-rebind` runs after `reconcile-task-worktree-metadata` and before `reclaim-stale-active-branches`. It restores `task.branch` (and clears `task.worktree` for fresh acquisition) for `in-review` tasks when exactly one case-insensitive `fusion/<id>` candidate branch has unique commits versus the integration base. Ambiguous candidates emit `task:auto-rebind-skipped` (`reason: "ambiguous-candidates"`) and are never auto-resolved. Branch construction across executor/worktree-pool/worktree-acquisition/merger/self-healing canonicalizes to lowercase via `canonicalFusionBranchName`; `fn_task_done` wrong-branch checks now auto-canonicalize case-only mismatches and emit `branch:auto-canonicalize-case`.
|
||||
- **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run.
|
||||
- **Auto-merge integration-root default (FN-5279)**: direct auto-merge now defaults `mergeIntegrationWorktree` to `reuse-task-worktree`; merger must pass the reuse handoff gates or emit `merge:reuse-handoff-refused` and leave the task in `in-review` without silently falling back to `cwd-main`.
|
||||
@@ -538,5 +539,6 @@ Reliability-layer changes are in scope. Interaction regression backstops live in
|
||||
- FN-5147 backstop: `packages/engine/src/__tests__/reliability-interactions/in-review-automerge-off.test.ts` covers `autoMerge: false` + long-quiet in-review + maintenance/startup sweep cycles, asserting no column move / no paused / no status mutation / no requeue, plus explicit regression guards for `surfaceInReviewStalls` and `surfaceInReviewStalled`.
|
||||
- FN-5168 backstop: `packages/engine/src/__tests__/reliability-interactions/non-progress-churn.test.ts` covers loop→compact recovery followed by ignored-step-update churn escalation, terminal `beforeRequeue(false)` behavior, audit/log payloads, and FN-5147 autoMerge-off composition.
|
||||
- FN-5219 backstop: `packages/engine/src/__tests__/reliability-interactions/in-progress-limbo-recovery.test.ts` covers `recoverInProgressLimbo` composition with `recoverOrphanedExecutions` (no double-recovery), `reconcile-task-worktree-metadata` (live rebindable worktree wins), `recoverMissingWorktreeReviewFailures` (in-review vs in-progress disjoint), and executor task-id claim skip, plus an explicit FN-5149 reproduction case.
|
||||
- FN-5325 backstop: `packages/engine/src/__tests__/reliability-interactions/scheduler-overlap-priority-inversion.test.ts` covers queued-overlap priority/age deferral, equal-priority age ordering, FN-4969 fanout composition, and one-shot per-pass `scheduler:overlap-priority-inversion` audit surfacing against running lower-priority blockers.
|
||||
|
||||
The auto-recovery dispatcher at `packages/engine/src/auto-recovery.ts` (FN-4533) composes on top of existing layers (FN-4500 fast-path, FN-4508 deterministic branch-conflict, FN-4499 bootstrap-misbinding, FN-4428 contamination, `mergeAuditAutoRecovery` Stages 1–5, self-healing) to handle six residual classes: file-scope violation at squash, branch misbinding / ghost worktree, verification-fix scope leak, contamination, `branch-conflict-unrecoverable` residuals, and room-post/message-send failures. Invocation is additive — no existing layer's behavior changes.
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Scheduler } from "../../scheduler.js";
|
||||
import type { Task, TaskStore } from "@fusion/core";
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-001",
|
||||
title: "task",
|
||||
description: "",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function createStore(tasks: Task[], scopes: Record<string, string[]>) {
|
||||
const updateTask = vi.fn(async (id: string, patch: Partial<Task>) => {
|
||||
const task = tasks.find((candidate) => candidate.id === id);
|
||||
if (task) Object.assign(task, patch);
|
||||
return task as Task;
|
||||
});
|
||||
const moveTask = vi.fn(async (id: string, column: Task["column"]) => {
|
||||
const task = tasks.find((candidate) => candidate.id === id);
|
||||
if (task) task.column = column;
|
||||
return task as Task;
|
||||
});
|
||||
const store = {
|
||||
listTasks: vi.fn(async () => tasks),
|
||||
getSettings: vi.fn(async () => ({ maxConcurrent: 10, maxWorktrees: 10, groupOverlappingFiles: true })),
|
||||
parseFileScopeFromPrompt: vi.fn(async (id: string) => scopes[id] ?? []),
|
||||
updateTask,
|
||||
moveTask,
|
||||
getTask: vi.fn(async (id: string) => tasks.find((task) => task.id === id) ?? null),
|
||||
logEntry: vi.fn(async () => undefined),
|
||||
getRootDir: vi.fn(() => "/tmp/project"),
|
||||
getTasksDir: vi.fn(() => "/tmp/project/.fusion/tasks"),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
recordRunAuditEvent: vi.fn(async () => undefined),
|
||||
} as unknown as TaskStore;
|
||||
return { store, updateTask, moveTask };
|
||||
}
|
||||
|
||||
describe("reliability interactions: FN-5325 scheduler overlap priority inversion", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(Scheduler.prototype as any, "validateTaskFilesystem").mockResolvedValue({ valid: true });
|
||||
});
|
||||
|
||||
it("defers lower-priority overlap while urgent queued task dispatches first", async () => {
|
||||
const tasks = [
|
||||
makeTask({ id: "FN-1", priority: "urgent", status: "queued", createdAt: "2026-01-01T00:00:00.000Z" }),
|
||||
makeTask({ id: "FN-2", priority: "normal", createdAt: "2026-01-01T00:01:00.000Z" }),
|
||||
];
|
||||
const { store, moveTask, updateTask } = createStore(tasks, { "FN-1": ["src/a.ts"], "FN-2": ["src/a.ts"] });
|
||||
|
||||
const scheduler = new Scheduler(store);
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(moveTask).toHaveBeenCalledWith("FN-1", "in-progress", expect.anything());
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-2", expect.objectContaining({ status: "queued", overlapBlockedBy: "FN-1" }));
|
||||
});
|
||||
|
||||
it("uses createdAt tiebreaker for equal-priority overlap", async () => {
|
||||
const tasks = [
|
||||
makeTask({ id: "FN-1", priority: "normal", status: "queued", createdAt: "2026-01-01T00:00:00.000Z" }),
|
||||
makeTask({ id: "FN-2", priority: "normal", createdAt: "2026-01-01T00:05:00.000Z" }),
|
||||
];
|
||||
const { store } = createStore(tasks, { "FN-1": ["src/a.ts"], "FN-2": ["src/a.ts"] });
|
||||
|
||||
const scheduler = new Scheduler(store);
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-2", "queued — deferred for higher-priority queued task FN-1 (overlap)");
|
||||
});
|
||||
|
||||
it("preserves FN-4969 fanout ordering and only defers when overlap exists", async () => {
|
||||
const sharedStamp = "2026-01-01T00:00:00.000Z";
|
||||
const tasks = [
|
||||
makeTask({ id: "FN-10", priority: "normal", createdAt: sharedStamp }),
|
||||
makeTask({ id: "FN-11", priority: "normal", createdAt: sharedStamp }),
|
||||
makeTask({ id: "FN-21", dependencies: ["FN-10"] }),
|
||||
makeTask({ id: "FN-22", dependencies: ["FN-10"] }),
|
||||
];
|
||||
const { store, moveTask, updateTask } = createStore(tasks, {
|
||||
"FN-10": ["src/a.ts"],
|
||||
"FN-11": ["src/b.ts"],
|
||||
"FN-21": ["src/c.ts"],
|
||||
"FN-22": ["src/d.ts"],
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store);
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(moveTask.mock.calls[0][0]).toBe("FN-10");
|
||||
expect(moveTask).toHaveBeenCalledWith("FN-11", "in-progress", expect.anything());
|
||||
expect(updateTask).not.toHaveBeenCalledWith("FN-11", expect.objectContaining({ overlapBlockedBy: expect.any(String) }));
|
||||
|
||||
tasks.find((task) => task.id === "FN-11")!.column = "todo";
|
||||
tasks.find((task) => task.id === "FN-10")!.column = "in-progress";
|
||||
(store.parseFileScopeFromPrompt as any).mockImplementation(async (id: string) => ({ "FN-10": ["src/a.ts"], "FN-11": ["src/a.ts"] }[id] ?? ["src/x.ts"]));
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-11", expect.objectContaining({ status: "queued", overlapBlockedBy: "FN-10" }));
|
||||
});
|
||||
|
||||
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" }),
|
||||
makeTask({ id: "FN-2", priority: "urgent", status: "queued", createdAt: "2026-01-01T00:00:00.000Z" }),
|
||||
];
|
||||
const { store } = createStore(tasks, { "FN-1": ["src/a.ts"], "FN-2": ["src/a.ts"] });
|
||||
|
||||
const scheduler = new Scheduler(store);
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
const calls = (store.recordRunAuditEvent as any).mock.calls.filter(
|
||||
(call: any[]) => call[0]?.mutationType === "scheduler:overlap-priority-inversion",
|
||||
);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0][0]).toMatchObject({
|
||||
target: "FN-2",
|
||||
metadata: expect.objectContaining({
|
||||
candidateId: "FN-2",
|
||||
blockerId: "FN-1",
|
||||
candidatePriority: "urgent",
|
||||
blockerPriority: "normal",
|
||||
blockerColumn: "in-progress",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { PrMonitor } from "../pr-monitor.js";
|
||||
import { Scheduler, pathsOverlap, filterPathsByIgnoreList, formatConcurrencyLimitMemoKey } from "../scheduler.js";
|
||||
import {
|
||||
Scheduler,
|
||||
pathsOverlap,
|
||||
filterPathsByIgnoreList,
|
||||
formatConcurrencyLimitMemoKey,
|
||||
findHigherPriorityQueuedOverlap,
|
||||
} from "../scheduler.js";
|
||||
import { AgentSemaphore } from "../concurrency.js";
|
||||
import type { TaskStore, Task, TaskDetail } from "@fusion/core";
|
||||
import { existsSync } from "node:fs";
|
||||
@@ -166,6 +172,64 @@ describe("filterPathsByIgnoreList", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("findHigherPriorityQueuedOverlap", () => {
|
||||
const overlap = (a: string[], b: string[]) => pathsOverlap(a, b);
|
||||
|
||||
it("returns higher-priority queued overlap", () => {
|
||||
const result = findHigherPriorityQueuedOverlap(
|
||||
{ id: "FN-2", priority: "normal", createdAt: "2026-01-02T00:00:00Z", scope: ["src/a.ts"] },
|
||||
[{ id: "FN-1", priority: "urgent", createdAt: "2026-01-03T00:00:00Z", scope: ["src/a.ts"] }],
|
||||
overlap,
|
||||
);
|
||||
expect(result?.id).toBe("FN-1");
|
||||
});
|
||||
|
||||
it("uses age tiebreaker at equal priority", () => {
|
||||
const result = findHigherPriorityQueuedOverlap(
|
||||
{ id: "FN-2", priority: "normal", createdAt: "2026-01-02T00:00:00Z", scope: ["src/a.ts"] },
|
||||
[{ id: "FN-1", priority: "normal", createdAt: "2026-01-01T00:00:00Z", scope: ["src/a.ts"] }],
|
||||
overlap,
|
||||
);
|
||||
expect(result?.id).toBe("FN-1");
|
||||
});
|
||||
|
||||
it("uses task id tiebreaker when priority and age match", () => {
|
||||
const result = findHigherPriorityQueuedOverlap(
|
||||
{ id: "FN-10", priority: "normal", createdAt: "2026-01-01T00:00:00Z", scope: ["src/a.ts"] },
|
||||
[{ id: "FN-2", priority: "normal", createdAt: "2026-01-01T00:00:00Z", scope: ["src/a.ts"] }],
|
||||
overlap,
|
||||
);
|
||||
expect(result?.id).toBe("FN-2");
|
||||
});
|
||||
|
||||
it("returns null when scopes do not overlap", () => {
|
||||
const result = findHigherPriorityQueuedOverlap(
|
||||
{ id: "FN-2", priority: "normal", createdAt: "2026-01-02T00:00:00Z", scope: ["src/a.ts"] },
|
||||
[{ id: "FN-1", priority: "urgent", createdAt: "2026-01-01T00:00:00Z", scope: ["src/b.ts"] }],
|
||||
overlap,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when candidate or queued scopes are empty", () => {
|
||||
expect(
|
||||
findHigherPriorityQueuedOverlap(
|
||||
{ id: "FN-2", priority: "normal", createdAt: "2026-01-02T00:00:00Z", scope: [] },
|
||||
[{ id: "FN-1", priority: "urgent", createdAt: "2026-01-01T00:00:00Z", scope: ["src/a.ts"] }],
|
||||
overlap,
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
findHigherPriorityQueuedOverlap(
|
||||
{ id: "FN-2", priority: "normal", createdAt: "2026-01-02T00:00:00Z", scope: ["src/a.ts"] },
|
||||
[{ id: "FN-1", priority: "urgent", createdAt: "2026-01-01T00:00:00Z", scope: [] }],
|
||||
overlap,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler", () => {
|
||||
beforeEach(() => {
|
||||
staleReporterReportMock.mockReset().mockResolvedValue({ surfaced: 0 });
|
||||
@@ -1406,7 +1470,7 @@ describe("Scheduler", () => {
|
||||
// Dependency-blocked urgent task should be queued, not started.
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-100", { status: "queued", blockedBy: "FN-900" });
|
||||
// Overlap-blocked urgent task should be queued with blocker id.
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-103", { status: "queued", blockedBy: "FN-001", overlapBlockedBy: "FN-001" });
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-103", { status: "queued", blockedBy: null, overlapBlockedBy: "FN-001" });
|
||||
// Paused and recovery-gated urgent tasks never enter scheduling.
|
||||
expect(moveTask).not.toHaveBeenCalledWith("FN-101", "in-progress");
|
||||
expect(moveTask).not.toHaveBeenCalledWith("FN-102", "in-progress");
|
||||
@@ -1748,7 +1812,7 @@ describe("Scheduler", () => {
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001", overlapBlockedBy: "FN-001" });
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: null, overlapBlockedBy: "FN-001" });
|
||||
expect(moveTask).not.toHaveBeenCalledWith("FN-002", "in-progress");
|
||||
});
|
||||
|
||||
@@ -1786,7 +1850,7 @@ describe("Scheduler", () => {
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001", overlapBlockedBy: "FN-001" });
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: null, overlapBlockedBy: "FN-001" });
|
||||
expect(moveTask).not.toHaveBeenCalledWith("FN-002", "in-progress");
|
||||
});
|
||||
});
|
||||
@@ -1923,7 +1987,7 @@ describe("Scheduler", () => {
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-T", { status: "queued", blockedBy: "FN-B", overlapBlockedBy: "FN-B" });
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-T", { status: "queued", blockedBy: null, overlapBlockedBy: "FN-B" });
|
||||
});
|
||||
|
||||
it("does not stamp blockedBy for todos without overlap, including empty scopes", async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
sortTasksByPriorityFanoutThenAgeAndId,
|
||||
buildUnblockWeightMap,
|
||||
computeBlockerFanoutMap,
|
||||
compareTasksByPriorityThenAgeAndId,
|
||||
HIGH_FANOUT_BLOCKER_TODO_THRESHOLD,
|
||||
type TaskStore,
|
||||
type Task,
|
||||
@@ -104,6 +105,35 @@ export function filterPathsByIgnoreList(paths: string[], ignorePaths?: string[])
|
||||
return paths.filter((path) => !normalizedIgnorePaths.some((ignore) => isIgnoredOverlapPath(path, ignore)));
|
||||
}
|
||||
|
||||
export interface QueuedOverlapCandidate {
|
||||
id: string;
|
||||
priority?: Task["priority"] | null;
|
||||
createdAt: string;
|
||||
scope: string[];
|
||||
}
|
||||
|
||||
export function findHigherPriorityQueuedOverlap(
|
||||
candidate: QueuedOverlapCandidate,
|
||||
queuedScopes: QueuedOverlapCandidate[],
|
||||
overlap: (a: string[], b: string[]) => boolean,
|
||||
): QueuedOverlapCandidate | null {
|
||||
let higher: QueuedOverlapCandidate | null = null;
|
||||
|
||||
for (const queued of queuedScopes) {
|
||||
if (queued.id === candidate.id) continue;
|
||||
if (!queued.scope.length || !candidate.scope.length) continue;
|
||||
if (!overlap(candidate.scope, queued.scope)) continue;
|
||||
|
||||
if (compareTasksByPriorityThenAgeAndId(queued, candidate) < 0) {
|
||||
if (!higher || compareTasksByPriorityThenAgeAndId(queued, higher) < 0) {
|
||||
higher = queued;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return higher;
|
||||
}
|
||||
|
||||
type ConcurrencyGateName = "maxConcurrent" | "maxWorktrees" | "semaphore";
|
||||
|
||||
interface ConcurrencyGateSnapshot {
|
||||
@@ -991,6 +1021,8 @@ export class Scheduler {
|
||||
* subsequent todo tasks in the same pass also see them.
|
||||
*/
|
||||
const activeScopes = new Map<string, string[]>();
|
||||
const inversionEmitted = new Set<string>();
|
||||
const queuedHigherPriorityScopes: QueuedOverlapCandidate[] = [];
|
||||
if (settings.groupOverlappingFiles) {
|
||||
const overlapIgnorePaths = settings.overlapIgnorePaths ?? [];
|
||||
// In-progress tasks
|
||||
@@ -999,6 +1031,19 @@ export class Scheduler {
|
||||
const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths);
|
||||
if (filteredScope.length > 0) activeScopes.set(t.id, filteredScope);
|
||||
}
|
||||
for (const t of todo) {
|
||||
if (t.status !== "queued" || t.paused || t.userPaused) continue;
|
||||
const scope = await this.store.parseFileScopeFromPrompt(t.id);
|
||||
const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths);
|
||||
if (filteredScope.length === 0) continue;
|
||||
queuedHigherPriorityScopes.push({
|
||||
id: t.id,
|
||||
priority: t.priority,
|
||||
createdAt: t.createdAt,
|
||||
scope: filteredScope,
|
||||
});
|
||||
}
|
||||
|
||||
// Only live in-review tasks with a worktree belong in activeScopes.
|
||||
// Paused in-review tasks (e.g., failed-merge tasks awaiting human triage) cannot
|
||||
// make progress, so they must not contribute to overlap blockers; including them
|
||||
@@ -1115,25 +1160,86 @@ export class Scheduler {
|
||||
? overlapBlockerId
|
||||
: activeScopeEntries.find(([, ipScope]) => this.pathsOverlap(taskScope, ipScope))?.[0] ?? null;
|
||||
|
||||
if (overlappingTaskId) {
|
||||
const unresolvedDeps = task.dependencies.filter((depId) => {
|
||||
const dep = tasks.find((t) => t.id === depId);
|
||||
return dep && dep.column !== "done" && dep.column !== "in-review" && dep.column !== "archived";
|
||||
});
|
||||
const targetBlockedBy = task.dependencies.length > 0
|
||||
? (unresolvedDeps[0] ?? null)
|
||||
: overlappingTaskId;
|
||||
const higherPriorityQueuedOverlap = findHigherPriorityQueuedOverlap(
|
||||
{
|
||||
id: task.id,
|
||||
priority: task.priority,
|
||||
createdAt: task.createdAt,
|
||||
scope: taskScope,
|
||||
},
|
||||
queuedHigherPriorityScopes,
|
||||
this.pathsOverlap.bind(this),
|
||||
);
|
||||
|
||||
if (higherPriorityQueuedOverlap) {
|
||||
const dependencyBlocker = unmetDeps[0] ?? null;
|
||||
if (
|
||||
task.status !== "queued"
|
||||
|| task.blockedBy !== targetBlockedBy
|
||||
|| task.blockedBy !== dependencyBlocker
|
||||
|| task.overlapBlockedBy !== higherPriorityQueuedOverlap.id
|
||||
) {
|
||||
await this.store.updateTask(task.id, {
|
||||
status: "queued",
|
||||
blockedBy: dependencyBlocker,
|
||||
overlapBlockedBy: higherPriorityQueuedOverlap.id,
|
||||
});
|
||||
}
|
||||
await this.rollbackRunningAgentsForQueuedTodoTask(task.id);
|
||||
await this.logDispatchQueuedReason(
|
||||
task.id,
|
||||
`queued — deferred for higher-priority queued task ${higherPriorityQueuedOverlap.id} (overlap)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (overlappingTaskId) {
|
||||
const dependencyBlocker = unmetDeps[0] ?? null;
|
||||
if (
|
||||
task.status !== "queued"
|
||||
|| task.blockedBy !== dependencyBlocker
|
||||
|| task.overlapBlockedBy !== overlappingTaskId
|
||||
) {
|
||||
await this.store.updateTask(task.id, {
|
||||
status: "queued",
|
||||
blockedBy: targetBlockedBy,
|
||||
blockedBy: dependencyBlocker,
|
||||
overlapBlockedBy: overlappingTaskId,
|
||||
});
|
||||
}
|
||||
|
||||
const overlapBlockerTask = tasks.find((candidate) => candidate.id === overlappingTaskId);
|
||||
const inversionKey = `${task.id}|${overlappingTaskId}`;
|
||||
if (
|
||||
overlapBlockerTask
|
||||
&& !inversionEmitted.has(inversionKey)
|
||||
&& compareTasksByPriorityThenAgeAndId(task, overlapBlockerTask) < 0
|
||||
) {
|
||||
inversionEmitted.add(inversionKey);
|
||||
try {
|
||||
await this.store.recordRunAuditEvent?.({
|
||||
taskId: task.id,
|
||||
agentId: "scheduler",
|
||||
runId: generateSyntheticRunId("scheduler", task.id),
|
||||
domain: "database",
|
||||
mutationType: "scheduler:overlap-priority-inversion",
|
||||
target: task.id,
|
||||
metadata: {
|
||||
candidateId: task.id,
|
||||
candidatePriority: task.priority ?? null,
|
||||
candidateCreatedAt: task.createdAt ?? null,
|
||||
blockerId: overlapBlockerTask.id,
|
||||
blockerPriority: overlapBlockerTask.priority ?? null,
|
||||
blockerCreatedAt: overlapBlockerTask.createdAt ?? null,
|
||||
blockerColumn: overlapBlockerTask.column,
|
||||
source: "scheduler.overlap-priority-inversion",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
schedulerLog.warn(
|
||||
`Task ${task.id} failed to emit overlap priority inversion audit: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.rollbackRunningAgentsForQueuedTodoTask(task.id);
|
||||
await this.logDispatchQueuedReason(task.id, `queued — file scope overlap with ${overlappingTaskId}`);
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user