FN-6174: remove overlap priority inversion audit
Remove the unused scheduler overlap-priority inversion audit path. - drop the scheduler memoization and audit emission for overlap-priority inversion blockers - delete the reliability interaction test that only covered the removed audit behavior - update architecture documentation to note the audit removal and keep the overlap deferral rule Files changed: docs/architecture.md | 3 +- packages/engine/src/__tests__/reliability-interactions/scheduler-overlap-priority-inversion.test.ts | 212 --------------------- packages/engine/src/scheduler.ts | 51 ----- 3 files changed, 1 insertion(+), 265 deletions(-) Fusion-Task-Id: FN-6174 Fusion-Task-Lineage: 3062b405-1f1d-473d-a02f-09a1077376c0
This commit is contained in:
@@ -1772,7 +1772,7 @@ This section preserves the detailed lifecycle/self-healing contracts that were f
|
||||
- **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. The corresponding `task:auto-archive-meta-resolved-skipped` and `task:auto-archive-meta-stalled-skipped` run-audit rows are transition-only per task+guard-reason signature: emit once on first skip, suppress repeated sweeps while the same reasons persist, clear when the skip no longer applies, and re-emit if a different reason later blocks archival.
|
||||
- **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` on state transition only: once per `(candidate, blocker)` pairing while blocked, silent on repeated polls with the same blocker, cleared when the overlap condition resolves, and emitted again if a different blocker takes over or the same blocker reappears later.
|
||||
- **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 the candidate; the per-pairing audit event was removed in FN-6174 due to zero consumers and table bloat.
|
||||
- **Empty-commit refusal + early empty-own-diff finalize (FN-5345/FN-5377)**: Fusion task worktrees install a `prepare-commit-msg` hook that refuses `git commit --allow-empty` and other zero-staged-diff commits, preventing verification-only tasks from manufacturing empty handoff commits that defeat the merger's no-op classifier. The hook allows legitimate empty-tree paths (amend, merge, squash, cherry-pick, revert, rebase). Amend detection tokenizes the parent process command line (`ps -o args=` with `/proc/$PPID/cmdline` fallback for Alpine/busybox) and stops at the first message-supplying flag (`-m`/`-F`/`--message`/`--file`) so a commit message containing the substring `--amend` cannot bypass the guard. In `aiMergeTask`, an early empty-own-diff fast-path runs BEFORE any reuse-handoff acquisition: when integration mode is `reuse-task-worktree`, the branch exists, `git rev-list --count <mergeTarget>..<branch>` is > 0, and `git diff --quiet <mergeBase>..<branch>` exits 0, the task auto-finalizes as no-op with `mergeDetails.noOpMerge: true` and emits `task:auto-recover-finalize-already-on-main` with `reason: "empty-own-diff-early-fast-path"`. The fast-path best-effort removes the stranded worktree (FN-4811 same-task/foreign-owner guard) and deletes the `fusion/<id>` branch so empty-own-diff residuals do not accumulate. This unsticks tasks where a stale empty handoff commit combined with drifted worktree↔branch mapping would otherwise wedge the handoff gate with `registered-branch-mismatch`. The explicit `cwd-integration-branch` mode is unchanged (`cwd-main` remains a deprecated alias normalized to it). `classifyOwnedLandedEvidence` also detects empty-own-diff (aheadCount > 0, zero net diff) and returns `proven-no-op` so downstream self-healing and post-handoff finalize paths benefit too. Additionally, merger's reuse-fallback path now consults `git worktree list --porcelain` before creating a new worktree: extant usable registrations of `fusion/<id>` are reused directly (rather than blindly `git worktree add -f` producing a duplicate registration), and stale registrations are pruned first. The direct-reuse shortcut is guarded by FN-4811 (refuses paths owned by a different task in `activeSessionRegistry`) and FN-4954 (skipped when `recycleWorktrees=true` with a pool attached, so `WorktreePool.acquire` lease bookkeeping stays consistent). Two audit subtypes — `merge:reuse-fallback-pruned-stale-registration` and `merge:reuse-fallback-reused-existing-registration` — replace the prior overloading of `merge:reuse-fallback-new-worktree` for these cases.
|
||||
- **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`, `recoverPostDoneNonContinuableWedge`, `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. Scoped FN-5819 exception: shared-group members (`branchContext.assignmentMode === "shared"`) are still allowed through the member→`branch_groups.branchName` integration step while `autoMerge` is off; this is a soft pre-integration only and does not permit shared-branch → default-branch promotion. 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.
|
||||
@@ -1824,7 +1824,6 @@ Reliability-layer changes are in scope. Interaction regression backstops live in
|
||||
- FN-5770 backstop: `packages/engine/src/__tests__/reliability-interactions/workflow-interpreter-cutover.test.ts` guards the interpreter-authoritative lifecycle seam. The cutover remains opt-in (`workflowInterpreterAuthoritative` default OFF), readiness-gated by dual-observe parity evidence, reversible by flipping one flag back OFF, and must preserve file-scope, squash-overlap, `autoMerge:false`, hard-cancel, and self-healing interaction invariants.
|
||||
- FN-5337 backstop: `packages/engine/src/__tests__/reliability-interactions/orphan-detected-no-requeue.test.ts` locks observation-only orphan detection across FN-5279 repro metadata desync, worktree-present and worktree-missing candidates, FN-5219 ordering, FN-5147 in-review isolation, FN-5083 branch-cleared composition, lease-manager non-invocation, and per-sweep idempotent audit emission.
|
||||
- FN-5256 backstop: `packages/engine/src/__tests__/reliability-interactions/dependency-cycle-reconcile.test.ts` covers persisted dependency-cycle detection via `reconcileDependencyCycles`, bounded umbrella-back-edge auto-repair, ambiguous-cycle observe-only behavior, composition ordering with `reconcileSelfDefeatingDependencies`, and the post-sweep write-time guard invariant. Core write-boundary regressions (FN-5240/5241/5242 signature, indirect cycle, umbrella back-edge rejection) live in `packages/core/src/__tests__/store-dependency-cycle.test.ts`.
|
||||
- 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 transition-only `scheduler:overlap-priority-inversion` audit surfacing across unchanged, changed-blocker, and clear→reappear states.
|
||||
- FN-5223 backstop: `packages/engine/src/__tests__/reliability-interactions/engine-active-since-floor.test.ts` covers engine-activation floor + grace composition across startup, pause/unpause, global-pause gating, and StuckTaskDetector lifecycle interactions.
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
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 — blocked by active file-scope lease FN-1 (column=in-progress)");
|
||||
});
|
||||
|
||||
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("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 row across repeated unchanged polls", async () => {
|
||||
const tasks = [
|
||||
makeTask({ id: "FN-1", column: "in-progress", priority: undefined, 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();
|
||||
await scheduler.schedule();
|
||||
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: null,
|
||||
blockerColumn: "in-progress",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("re-emits inversion when the blocker changes", async () => {
|
||||
const firstBlocker = makeTask({ id: "FN-1", column: "in-progress", priority: "normal", createdAt: "2026-01-01T00:01:00.000Z" });
|
||||
const secondBlocker = makeTask({ id: "FN-3", column: "todo", priority: "low", createdAt: "2026-01-01T00:02:00.000Z" });
|
||||
const candidate = makeTask({ id: "FN-2", priority: "urgent", status: "queued", createdAt: "2026-01-01T00:00:00.000Z" });
|
||||
const tasks = [firstBlocker, secondBlocker, candidate];
|
||||
const { store } = createStore(tasks, { "FN-1": ["src/a.ts"], "FN-2": ["src/a.ts"], "FN-3": ["src/a.ts"] });
|
||||
|
||||
const scheduler = new Scheduler(store);
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
firstBlocker.column = "done";
|
||||
secondBlocker.column = "in-progress";
|
||||
await scheduler.schedule();
|
||||
|
||||
const calls = (store.recordRunAuditEvent as any).mock.calls.filter(
|
||||
(call: any[]) => call[0]?.mutationType === "scheduler:overlap-priority-inversion",
|
||||
);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0][0]?.metadata?.blockerId).toBe("FN-1");
|
||||
expect(calls[1][0]?.metadata?.blockerId).toBe("FN-3");
|
||||
});
|
||||
|
||||
it("re-emits inversion after overlap clears and later returns", async () => {
|
||||
const blocker = makeTask({ id: "FN-1", column: "in-progress", priority: "normal", createdAt: "2026-01-01T00:01:00.000Z" });
|
||||
const candidate = makeTask({ id: "FN-2", priority: "urgent", status: "queued", createdAt: "2026-01-01T00:00:00.000Z" });
|
||||
const tasks = [blocker, candidate];
|
||||
const scopes: Record<string, string[]> = { "FN-1": ["src/a.ts"], "FN-2": ["src/a.ts"] };
|
||||
const { store } = createStore(tasks, scopes);
|
||||
|
||||
const scheduler = new Scheduler(store);
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
blocker.column = "done";
|
||||
candidate.overlapBlockedBy = undefined;
|
||||
scopes["FN-2"] = ["src/b.ts"];
|
||||
await scheduler.schedule();
|
||||
|
||||
candidate.column = "todo";
|
||||
candidate.status = "queued";
|
||||
blocker.column = "in-progress";
|
||||
scopes["FN-2"] = ["src/a.ts"];
|
||||
await scheduler.schedule();
|
||||
|
||||
const calls = (store.recordRunAuditEvent as any).mock.calls.filter(
|
||||
(call: any[]) => call[0]?.mutationType === "scheduler:overlap-priority-inversion",
|
||||
);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls.every((call: any[]) => call[0]?.metadata?.blockerId === "FN-1")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -502,8 +502,6 @@ export class Scheduler {
|
||||
private wasPermanentAgentUnavailable = new Set<string>();
|
||||
/** Tracks dispatch-queued reason signatures to avoid per-tick log spam. */
|
||||
private wasDispatchQueuedReasonLogged = new Set<string>();
|
||||
/** Tracks the last overlap blocker that emitted a priority inversion audit for a task. */
|
||||
private overlapPriorityInversionMemo = new Map<string, string>();
|
||||
/** Tracks the last stable concurrency-block signature emitted for a task. */
|
||||
private dispatchQueuedConcurrencyAuditMemo = new Map<string, string>();
|
||||
/** Tracks per-task candidacy fingerprints for task:updated auto-claim invalidation gating. */
|
||||
@@ -793,7 +791,6 @@ export class Scheduler {
|
||||
this.wasNodeBlocked.delete(task.id);
|
||||
this.wasPermanentAgentUnavailable.delete(task.id);
|
||||
this.clearDispatchQueuedReasonMemo(task.id);
|
||||
this.clearOverlapPriorityInversionMemo(task.id);
|
||||
this.clearDispatchQueuedConcurrencyAuditMemo(task.id);
|
||||
|
||||
void (async () => {
|
||||
@@ -939,7 +936,6 @@ export class Scheduler {
|
||||
this.wasNodeDispatchValidationBlocked.clear();
|
||||
this.wasPermanentAgentUnavailable.clear();
|
||||
this.wasDispatchQueuedReasonLogged.clear();
|
||||
this.overlapPriorityInversionMemo.clear();
|
||||
this.dispatchQueuedConcurrencyAuditMemo.clear();
|
||||
schedulerLog.log("Stopped");
|
||||
}
|
||||
@@ -967,19 +963,6 @@ export class Scheduler {
|
||||
return true;
|
||||
}
|
||||
|
||||
private shouldEmitOverlapPriorityInversion(taskId: string, blockerId: string): boolean {
|
||||
const lastBlockerId = this.overlapPriorityInversionMemo.get(taskId);
|
||||
if (lastBlockerId === blockerId) {
|
||||
return false;
|
||||
}
|
||||
this.overlapPriorityInversionMemo.set(taskId, blockerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private clearOverlapPriorityInversionMemo(taskId: string): void {
|
||||
this.overlapPriorityInversionMemo.delete(taskId);
|
||||
}
|
||||
|
||||
private shouldEmitDispatchQueuedConcurrencyAudit(taskId: string, signature: string): boolean {
|
||||
const lastSignature = this.dispatchQueuedConcurrencyAuditMemo.get(taskId);
|
||||
if (lastSignature === signature) {
|
||||
@@ -1663,37 +1646,6 @@ export class Scheduler {
|
||||
}
|
||||
|
||||
const overlapBlockerTask = tasks.find((candidate) => candidate.id === overlappingTaskId);
|
||||
if (
|
||||
overlapBlockerTask
|
||||
&& this.shouldEmitOverlapPriorityInversion(task.id, overlappingTaskId)
|
||||
&& compareTasksByPriorityThenAgeAndId(task, overlapBlockerTask) < 0
|
||||
) {
|
||||
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: activeScopeColumns.get(overlappingTaskId) ?? 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);
|
||||
const activeLeaseColumn = activeScopeColumns.get(overlappingTaskId) ?? overlapBlockerTask?.column ?? "unknown";
|
||||
await this.logDispatchQueuedReason(
|
||||
@@ -1706,10 +1658,8 @@ export class Scheduler {
|
||||
if (task.overlapBlockedBy) {
|
||||
await this.store.updateTask(task.id, { overlapBlockedBy: null });
|
||||
}
|
||||
this.clearOverlapPriorityInversionMemo(task.id);
|
||||
} else if (coordinationOnlyTask && task.overlapBlockedBy) {
|
||||
await this.store.updateTask(task.id, { overlapBlockedBy: null });
|
||||
this.clearOverlapPriorityInversionMemo(task.id);
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
"coordination/no-commit task bypassed non-implementation overlap lease",
|
||||
@@ -2064,7 +2014,6 @@ export class Scheduler {
|
||||
this.wasNodeDispatchValidationBlocked.delete(task.id);
|
||||
this.wasPermanentAgentUnavailable.delete(task.id);
|
||||
this.clearDispatchQueuedReasonMemo(task.id);
|
||||
this.clearOverlapPriorityInversionMemo(task.id);
|
||||
this.clearDispatchQueuedConcurrencyAuditMemo(task.id);
|
||||
await this.store.logEntry(task.id, `Node routing resolved: ${effectiveNode.nodeId ?? "local"} (source: ${effectiveNode.source})`);
|
||||
this.options.onSchedule?.(task);
|
||||
|
||||
Reference in New Issue
Block a user