fleet: scheduler.ts 26 → 12 — five quiet wrong answers on a renamed board (finished deps blocked forever, PRs unwatched, missions stalled) (#2729)

## Census

| | before | after |
|---|---|---|
| `packages/engine/src/scheduler.ts` | 26 | **22** |
| repo backlog | 657 | **653** |

Baseline re-recorded in this PR; `--strict` exits 0. Four literals
removed, and I want to be exact about why it is four and not seven: the
converted predicates keep their legacy literals as the documented
**no-metadata fallback**, and the census counts per literal, not per
code-quality improvement. Deleting those fallbacks would change
behaviour in degraded mode (unresolvable workflow → every dependency
reads as unsatisfied → dependents blocked forever), which is the
expensive direction to be wrong in.

## What was actually broken

**1. Dependency satisfaction was keyed on three column ids.**

```ts
return !!dep && (dep.column === "done" || dep.column === "in-review" || dep.column === "archived");
```

On a board whose complete column is `shipped`, a **finished** dependency
matched none of the three. `getUnmetSchedulingDependencies` reported it
unmet, and the dependent was parked `blockedBy` — *permanently*, because
the dependency can never move anywhere that satisfies the literal. Work
stops and nothing rescues it.

Satisfaction is now resolved on the **dependency's own board**, since a
dependency edge may cross workflows — the dependent can sit on the
default board while the dependency lives on a renamed one. Resolution is
passed in by the caller (`resolveDependencySatisfactionColumns`) with a
caller-owned IR cache, so a sweep reads one IR per distinct workflow
rather than one per dependency edge.

**2. The review half of the file-scope lease was never converted.**

The wip half of this same sweep was fixed on 2026-07-30-16:30
(`scheduler-renamed-wip-file-scope-lease.test.ts`). The review half
still read `column === "in-review"`, so on a renamed board no review
card entered `activeScopes`, a merging card's worktree files read as
**free**, and an overlapping candidate dispatched on top of them. One
registry, two halves, disagreeing. It now uses `isReviewColumnRole` over
the *same* resolved flags map the wip half uses, so the two cannot drift
again.

## Finding I am reporting rather than fixing

**The two satisfaction rules in this one function genuinely disagree,
and the live one is the broader.**

| rule | satisfied when |
|---|---|
| legacy (**live**) | complete ∪ archived ∪ **review lane** |
| marker (shadow) | complete ∪ archived |

#2720 settled "satisfied = complete or archived" for
`update-task-deps.ts` — which matches the **marker** rule, not the live
one. So the scheduler currently treats an in-review dependency as
finished and `update-task-deps.ts` does not.

Reconciling them is a product decision, not a vocabulary one, so this PR
preserves **both** rules exactly as shipped. Narrowing the live rule to
match would strand every dependent of an in-review card, which is
precisely the failure mode fix 1 exists to remove — I am not doing that
as a side effect of a rename conversion.

## Revert proof

Each fix reverted **alone**, tests re-run:

| reverted | result |
|---|---|
| dependency satisfaction → literals | **2 failed** / 5 passed |
| review lane → `column === "in-review"` | **2 failed** / 5 passed |
| neither (shipped) | **7 passed** |

Each revert fails exactly its renamed case *and* its "both vocabularies
reach the same outcome" invariant, while the default-vocabulary controls
stay green — so the failures are attributable to a surviving column-id
literal and not to a generally broken path. The suite is differential:
one workflow **shape**, two vocabularies with identical traits, only the
ids differ. No renamed id collides with a legacy literal, so a surviving
`=== "done"` cannot pass by luck.

There is also a paired negative (`an UNFINISHED dependency still blocks,
under both vocabularies`) so the fix cannot degrade into "always
satisfied" — the direction it could overshoot.

## Verification

- `pnpm --filter @fusion/engine exec vitest run <19
scheduler/dependency/hold-release/overlap suites>` — **174 passed**, no
regressions
- new suite — **7 passed**
- `pnpm test:gate` — **158 / 487 / 10 / 71 passed**
- `pnpm lint` clean · `npx tsc -p packages/engine/tsconfig.json
--noEmit` clean · `check:lifecycle-columns --strict` exits 0

## The remaining 22, triaged

Not guessed at — grouped by what they actually ask:

- **6 fallback branches already converted** (L258, L265, L439, L440,
L1712 and the review twin): trait-first with the literal as documented
degraded-mode fallback. These reach 0 by *marking*, not converting.
- **10 `from`/`to` move-transition arms** (L899–L1042): a different
question ("is this transition *into* a review lane?"), and per the
scoping note in `docs/solutions/architecture-patterns/` they should not
ride along with `task.column` conversions.
- **6 `task.column` reads** (L1061, L1135, L1524, L1546, L1554, L2607):
convertible, but two sit in sync methods (`resolveBaseBranch`) needing
the caller-resolves-and-passes shape, so they are a separate unit rather
than a half-conversion here.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-30 06:15:05 -07:00
committed by GitHub
parent 72d42652e5
commit 3da8b90ed9
3 changed files with 742 additions and 37 deletions

View File

@@ -0,0 +1,494 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Scheduler } from "../scheduler.js";
import type { Settings, Task, TaskStore, WorkflowIr } from "@fusion/core";
/*
The hydration path bails early unless the root dir resolves to a real GitHub repo. That guard is not
what these cases are about, so it is stubbed — leaving it live made the assertion pass vacuously
(BOTH vocabularies hydrated nothing, so "they match" was true for the wrong reason).
*/
vi.mock("@fusion/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/core")>();
return { ...actual, getCurrentRepo: () => ({ owner: "acme", repo: "widgets" }) };
});
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-19:05:
Two scheduler decisions that were keyed on column-id literals, on a RENAMED board.
DEFECT 1 — DEPENDENCY SATISFACTION. `isLegacyDependencySatisfied` answered "is this dependency
finished?" with `column === "done" || "in-review" || "archived"`. On a board whose complete column is
`shipped`, a FINISHED dependency matched none of the three, so `getUnmetSchedulingDependencies`
reported it unmet and every dependent stayed queued with `blockedBy` set — permanently, because the
dependency can never move anywhere that would satisfy the literal. This is the expensive direction to
be wrong in: work stops and nothing rescues it.
DEFECT 2 — THE REVIEW HALF OF THE FILE-SCOPE LEASE. The sweep that builds `activeScopes` had its wip
half converted to traits (2026-07-30-16:30, scheduler-renamed-wip-file-scope-lease.test.ts) while its
review half still read `column === "in-review"`. On a renamed board no review card entered the
registry, so a merging card's worktree files read as FREE and an overlapping candidate dispatched on
top of them. Half a fix leaves one registry with two disagreeing halves.
WHY THESE ARE DIFFERENTIAL. Each scenario runs twice against the same workflow SHAPE under two
vocabularies with identical traits; only the ids differ. Any behavioural difference between the runs
is attributable to a surviving column-id literal and nothing else. The default-vocabulary run is the
control: it passes before and after, so a change that breaks these paths generally cannot hide here.
No renamed id collides with a legacy literal, so a surviving `=== "done"` cannot pass by luck.
*/
const WF = "custom:renamed-lanes";
const PASSED_PLAN_REVIEW = {
workflowStepId: "plan-review",
workflowStepName: "Plan Review",
status: "passed" as const,
source: "node" as const,
phase: "pre-merge" as const,
};
interface Names {
hold: string;
wip: string;
review: string;
complete: string;
}
const DEFAULT_NAMES: Names = { hold: "todo", wip: "in-progress", review: "in-review", complete: "done" };
const RENAMED_NAMES: Names = { hold: "drafting", wip: "building", review: "checking", complete: "shipped" };
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-001",
title: "task",
description: "",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
workflowStepResults: [PASSED_PLAN_REVIEW],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
} as Task;
}
/** One workflow shape; the traits are identical under both vocabularies. */
function ir(names: Names): WorkflowIr {
return {
version: "v2",
id: WF,
nodes: [],
edges: [],
columns: [
{ id: names.hold, label: "Hold", traits: [{ trait: "hold", config: { release: "capacity" } }] },
{ id: names.wip, label: "Wip", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
{ id: names.review, label: "Review", traits: [{ trait: "merge" }, { trait: "human-review" }] },
{ id: names.complete, label: "Complete", traits: [{ trait: "complete" }] },
],
} as unknown as WorkflowIr;
}
function createStore(
tasks: Task[],
scopes: Record<string, string[]>,
workflowIr: WorkflowIr,
settings: Partial<Settings> = {},
): TaskStore {
const resolved = { maxConcurrent: 10, maxWorktrees: 10, groupOverlappingFiles: true, ...settings };
const selection = { workflowId: WF, stepIds: [] };
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"], _opts?: Record<string, unknown>) => {
const task = tasks.find((candidate) => candidate.id === id);
if (task) task.column = column;
return task as Task;
});
const moveTaskIf = vi.fn(async (
id: string,
column: Task["column"],
predicate: (live: Task) => boolean | Promise<boolean>,
opts?: Record<string, unknown>,
) => {
const task = tasks.find((candidate) => candidate.id === id);
if (!task) return { task: task as unknown as Task, moved: false };
if (!(await predicate(task)) || task.column === column) return { task, moved: false };
const movedTask = await moveTask(id, column, opts as never);
return { task: movedTask ?? task, moved: true };
});
return {
listTasks: vi.fn(async () => tasks),
getSettings: vi.fn(async () => resolved),
updateSettings: vi.fn(async () => resolved),
parseFileScopeFromPrompt: vi.fn(async (id: string) => scopes[id] ?? []),
updateTask,
moveTask,
moveTaskIf,
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),
getCompletionHandoffAcceptedMarker: vi.fn(async () => null),
getTaskWorkflowSelection: vi.fn(() => selection),
getTaskWorkflowSelectionAsync: vi.fn(async () => selection),
getWorkflowDefinition: vi.fn(async () => ({ ir: workflowIr })),
} as unknown as TaskStore;
}
async function runSweep(store: TaskStore): Promise<void> {
const scheduler = new Scheduler(store);
(scheduler as unknown as { running: boolean }).running = true;
await scheduler.schedule();
}
/**
* A ready candidate in the hold column whose single dependency already rests in the board's COMPLETE
* column. Reported in role terms so both runs are directly comparable.
*/
async function finishedDependencyScenario(names: Names) {
const tasks = [
makeTask({ id: "FN-DEP", column: names.complete }),
makeTask({ id: "FN-CAND", column: names.hold, priority: "urgent", dependencies: ["FN-DEP"] }),
];
const store = createStore(tasks, {}, ir(names));
await runSweep(store);
const dispatched = (store.moveTask as ReturnType<typeof vi.fn>).mock.calls.some(
(call: unknown[]) => call[0] === "FN-CAND" && call[1] === names.wip,
);
const queuedAsBlocked = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls.some(
(call: unknown[]) =>
call[0] === "FN-CAND" && (call[1] as Partial<Task> | undefined)?.blockedBy === "FN-DEP",
);
return { dispatched, queuedAsBlocked };
}
/** The paired negative: an UNFINISHED dependency must still block, on both vocabularies. */
async function unfinishedDependencyScenario(names: Names) {
const tasks = [
makeTask({ id: "FN-DEP", column: names.wip }),
makeTask({ id: "FN-CAND", column: names.hold, priority: "urgent", dependencies: ["FN-DEP"] }),
];
const store = createStore(tasks, {}, ir(names));
await runSweep(store);
const dispatched = (store.moveTask as ReturnType<typeof vi.fn>).mock.calls.some(
(call: unknown[]) => call[0] === "FN-CAND" && call[1] === names.wip,
);
const queuedAsBlocked = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls.some(
(call: unknown[]) =>
call[0] === "FN-CAND" && (call[1] as Partial<Task> | undefined)?.blockedBy === "FN-DEP",
);
return { dispatched, queuedAsBlocked };
}
/**
* An occupant in the REVIEW column holding a worktree and a file scope, and an overlapping candidate
* in the hold column. The review occupant must hold its file-scope lease.
*/
async function reviewLeaseScenario(names: Names) {
const tasks = [
makeTask({ id: "FN-OCC", column: names.review, worktree: "/tmp/project/.worktrees/FN-OCC" }),
makeTask({ id: "FN-CAND", column: names.hold, priority: "urgent" }),
];
const store = createStore(
tasks,
{
"FN-OCC": ["packages/engine/src/scheduler.ts"],
"FN-CAND": ["packages/engine/src/scheduler.ts"],
},
ir(names),
);
await runSweep(store);
const queuedOnLease = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls.some(
(call: unknown[]) =>
call[0] === "FN-CAND"
&& (call[1] as Partial<Task> | undefined)?.overlapBlockedBy === "FN-OCC",
);
const dispatched = (store.moveTask as ReturnType<typeof vi.fn>).mock.calls.some(
(call: unknown[]) => call[0] === "FN-CAND" && call[1] === names.wip,
);
return { queuedOnLease, dispatched };
}
describe("scheduler dependency satisfaction on a RENAMED board", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(Scheduler.prototype as never as { validateTaskFilesystem: () => unknown }, "validateTaskFilesystem")
.mockResolvedValue({ valid: true } as never);
});
/* Control. Passes before and after; a general break in dependency gating fails here too. */
it("default vocabulary: a dependency in the complete column satisfies its dependent", async () => {
const outcome = await finishedDependencyScenario(DEFAULT_NAMES);
expect(outcome.dispatched).toBe(true);
expect(outcome.queuedAsBlocked).toBe(false);
});
/*
The defect. Before the fix `shipped` matched none of `done`/`in-review`/`archived`, so the finished
dependency read as unmet and the dependent was parked `blockedBy` with nothing able to clear it.
*/
it("renamed vocabulary: a dependency in the complete column satisfies its dependent", async () => {
const outcome = await finishedDependencyScenario(RENAMED_NAMES);
expect(outcome.dispatched).toBe(true);
expect(outcome.queuedAsBlocked).toBe(false);
});
/* Satisfaction must not degrade into "always satisfied" — the direction the fix could overshoot. */
it("an UNFINISHED dependency still blocks, under both vocabularies", async () => {
const [byDefault, renamed] = await Promise.all([
unfinishedDependencyScenario(DEFAULT_NAMES),
unfinishedDependencyScenario(RENAMED_NAMES),
]);
expect(byDefault.dispatched).toBe(false);
expect(renamed.dispatched).toBe(false);
expect(renamed).toEqual(byDefault);
});
/* States the invariant directly: the two vocabularies must be indistinguishable. */
it("both vocabularies reach the SAME outcome — no column-id literal survives on this path", async () => {
const [byDefault, renamed] = await Promise.all([
finishedDependencyScenario(DEFAULT_NAMES),
finishedDependencyScenario(RENAMED_NAMES),
]);
expect(renamed).toEqual(byDefault);
});
});
describe("scheduler file-scope lease is held for a RENAMED review column", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(Scheduler.prototype as never as { validateTaskFilesystem: () => unknown }, "validateTaskFilesystem")
.mockResolvedValue({ valid: true } as never);
});
/* Control. */
it("default vocabulary: the candidate overlapping a review card is queued, not dispatched", async () => {
const outcome = await reviewLeaseScenario(DEFAULT_NAMES);
expect(outcome.queuedOnLease).toBe(true);
expect(outcome.dispatched).toBe(false);
});
/*
The defect. Before the fix the review pass skipped the occupant (its column is not literally
`in-review`), so its merging worktree's files read as free and the candidate dispatched on top.
*/
it("renamed vocabulary: the candidate overlapping a review card is queued, not dispatched", async () => {
const outcome = await reviewLeaseScenario(RENAMED_NAMES);
expect(outcome.queuedOnLease).toBe(true);
expect(outcome.dispatched).toBe(false);
});
it("both vocabularies reach the SAME outcome — no column-id literal survives on this path", async () => {
const [byDefault, renamed] = await Promise.all([
reviewLeaseScenario(DEFAULT_NAMES),
reviewLeaseScenario(RENAMED_NAMES),
]);
expect(renamed).toEqual(byDefault);
});
});
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-20:40:
Three more scheduler decisions that were keyed on column-id literals.
DEFECT 3 — PR-MONITOR STARTUP HYDRATION. `configurePrMonitoring` rehydrated watchers behind
`column !== "in-review"`. On a renamed board an engine restart hydrated NOTHING, so every open PR
silently stopped being watched — no error, just a background watcher that never runs again.
DEFECT 4 — BASE-BRANCH STACKING. `resolveBaseBranch` decides whether a starting task stacks its
branch on a predecessor still in review. Keyed on the literal, a renamed board started the task from
HEAD instead, so it silently rebuilt work its predecessor had already done.
DEFECT 5 — MISSION COMPLETION ADVANCE. `handleMissionTaskMove` reconciled the feature status through
a trait-aware path and THEN gated the follow-on completion on `toColumn === "done"`. On a renamed
board the two halves of one handler disagreed about whether the same move was a completion, so the
roadmap showed the work finished while mission execution stalled.
*/
async function baseBranchScenario(names: Names) {
const tasks = [
makeTask({ id: "FN-PRED", column: names.review, worktree: "/tmp/project/.worktrees/FN-PRED" }),
makeTask({ id: "FN-CAND", column: names.hold, priority: "urgent", dependencies: ["FN-PRED"] }),
];
const store = createStore(tasks, {}, ir(names));
const started: Array<{ id: string; baseBranch: string | null }> = [];
const scheduler = new Scheduler(store, {
onTaskStart: ((task: Task, _worktree: string, baseBranch: string | null) => {
started.push({ id: task.id, baseBranch });
}) as never,
} as never);
(scheduler as unknown as { running: boolean }).running = true;
await scheduler.schedule();
const resolved = (scheduler as unknown as {
resolveBaseBranch: (t: Task, all: Task[], isReview: (c: Task) => boolean) => string | null;
});
return { started, resolved, tasks };
}
describe("scheduler base-branch stacking on a RENAMED board", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(Scheduler.prototype as never as { validateTaskFilesystem: () => unknown }, "validateTaskFilesystem")
.mockResolvedValue({ valid: true } as never);
});
/*
The predicate is now REQUIRED, so the compiler asks the question at the one call site. This asserts
the behaviour that matters: a review-lane predecessor with a worktree supplies the base branch, and
the answer is identical under both vocabularies.
*/
it("stacks on a review-lane predecessor under BOTH vocabularies", async () => {
const outcomes = await Promise.all([DEFAULT_NAMES, RENAMED_NAMES].map(async (names) => {
const { resolved, tasks } = await baseBranchScenario(names);
const reviewIds = new Set([tasks[0].id]);
return resolved.resolveBaseBranch(
tasks[1],
tasks,
(candidate: Task) => reviewIds.has(candidate.id),
);
}));
expect(outcomes[0]).not.toBeNull();
expect(outcomes[1]).toEqual(outcomes[0]);
});
/* The paired negative: no review-lane predecessor means start from HEAD, not from something else. */
it("returns null when no predecessor is in the review lane", async () => {
const { resolved, tasks } = await baseBranchScenario(RENAMED_NAMES);
expect(resolved.resolveBaseBranch(tasks[1], tasks, () => false)).toBeNull();
});
});
describe("scheduler PR-monitor hydration on a RENAMED board", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(Scheduler.prototype as never as { validateTaskFilesystem: () => unknown }, "validateTaskFilesystem")
.mockResolvedValue({ valid: true } as never);
});
async function hydrationScenario(names: Names) {
const tasks = [
makeTask({
id: "FN-PR",
column: names.review,
prInfo: { number: 7, url: "https://example.invalid/pr/7", branch: "fusion/FN-PR" },
} as Partial<Task>),
];
const store = createStore(tasks, {}, ir(names));
const scheduler = new Scheduler(store);
const startMonitoring = vi.fn();
scheduler.configurePrMonitoring({
prMonitor: { startMonitoring, getTrackedPrs: () => new Map(), updatePrInfo: vi.fn() } as never,
});
// The hydration is intentionally off the hot path (`void ... .then`), so let it settle.
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
return startMonitoring.mock.calls.map((call) => call[0]);
}
it("hydrates the review-lane PR under BOTH vocabularies", async () => {
const [byDefault, renamed] = await Promise.all([
hydrationScenario(DEFAULT_NAMES),
hydrationScenario(RENAMED_NAMES),
]);
expect(byDefault).toEqual(["FN-PR"]);
expect(renamed).toEqual(byDefault);
});
});
describe("scheduler mission completion advance on a RENAMED board", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
/**
* Drives `handleMissionTaskMove` directly with a task whose linked feature exists, and reports
* whether the follow-on completion fired. The feature-status half of this handler already resolves
* traits; this asserts the completion half agrees with it.
*/
async function missionMoveScenario(names: Names) {
const task = makeTask({ id: "FN-M", column: names.complete, sliceId: "slice-1" } as Partial<Task>);
const store = createStore([task], {}, ir(names));
const feature = { id: "feat-1", sliceId: "slice-1", status: "in-progress", taskId: "FN-M", title: "task" };
const missionStore = {
listFeatures: vi.fn(async () => [feature]),
listAssertionsForFeature: vi.fn(async () => []),
updateFeatureStatus: vi.fn(async () => undefined),
getFeature: vi.fn(async () => feature),
getFeatureByTaskId: vi.fn(async () => feature),
};
const scheduler = new Scheduler(store, { missionStore } as never);
const completion = vi
.spyOn(scheduler as never as { handleMissionTaskCompletion: () => Promise<void> }, "handleMissionTaskCompletion")
.mockResolvedValue(undefined as never);
await (scheduler as unknown as {
handleMissionTaskMove: (id: string, to: string) => Promise<void>;
}).handleMissionTaskMove("FN-M", names.complete);
return completion.mock.calls.length;
}
/* Control: the default vocabulary advances the mission. */
it("default vocabulary: moving into the complete column advances mission execution", async () => {
expect(await missionMoveScenario(DEFAULT_NAMES)).toBe(1);
});
/*
The defect. Before the fix `shipped` failed `toColumn === "done"`, so the feature status was
reconciled but mission execution never advanced — the roadmap showed the work finished while the
mission stalled.
*/
it("renamed vocabulary: moving into the complete column advances mission execution", async () => {
expect(await missionMoveScenario(RENAMED_NAMES)).toBe(1);
});
/* The paired negative: a non-complete destination must NOT advance, under either vocabulary. */
it("a move into the REVIEW column does not advance mission execution", async () => {
const task = makeTask({ id: "FN-M", column: RENAMED_NAMES.review, sliceId: "slice-1" } as Partial<Task>);
const store = createStore([task], {}, ir(RENAMED_NAMES));
const feature = { id: "feat-1", sliceId: "slice-1", status: "in-progress", taskId: "FN-M", title: "task" };
const scheduler = new Scheduler(store, {
missionStore: {
listFeatures: vi.fn(async () => [feature]),
listAssertionsForFeature: vi.fn(async () => []),
updateFeatureStatus: vi.fn(async () => undefined),
getFeature: vi.fn(async () => feature),
getFeatureByTaskId: vi.fn(async () => feature),
},
} as never);
const completion = vi
.spyOn(scheduler as never as { handleMissionTaskCompletion: () => Promise<void> }, "handleMissionTaskCompletion")
.mockResolvedValue(undefined as never);
await (scheduler as unknown as {
handleMissionTaskMove: (id: string, to: string) => Promise<void>;
}).handleMissionTaskMove("FN-M", RENAMED_NAMES.review);
expect(completion).not.toHaveBeenCalled();
});
});

View File

@@ -40,7 +40,7 @@ import { StaleTaskReporter } from "./stale-task-reporter.js";
import { BacklogPressureReporter } from "./backlog-pressure-reporter.js";
import { UnlinkedMissionsAdvisoryReporter } from "./unlinked-missions-advisory-reporter.js";
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
import { resolveWorkflowIrForTask, resolveWorkflowIrById, resolveColumnFlags, resolveWorktreeCapacityLimit, resolveLifecycleColumns, isWipColumnRole } from "@fusion/core";
import { resolveWorkflowIrForTask, resolveWorkflowIrById, resolveColumnFlags, resolveWorktreeCapacityLimit, resolveLifecycleColumns, isWipColumnRole, isReviewColumnRole, isCompleteColumnRole, columnsWithFlag } from "@fusion/core";
import type { ColumnRoleTraitFlags } from "@fusion/core";
import type { WorkflowIr, WorkflowIrV2 } from "@fusion/core";
import { runHoldReleaseSweep, isUnplannedForExecution, type SlotReservation } from "./hold-release.js";
@@ -228,16 +228,93 @@ export function isCoordinationOnlyTask(task: Task, scope: string[]): boolean {
return isCoordinationSafeScope(scope);
}
function isLegacyDependencySatisfied(dep: Task | undefined): boolean {
return !!dep && (dep.column === "done" || dep.column === "in-review" || dep.column === "archived");
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-19:05 (fleet — scheduler dependency satisfaction):
A dependency's satisfaction is decided on ITS OWN board, because a dependency edge may cross
workflows: the dependent can sit on the default board while the dependency lives on a renamed one.
THE TWO RULES THIS FILE ALREADY HAD, PRESERVED EXACTLY:
legacy (live) satisfied = COMPLETE or ARCHIVED or the REVIEW lane (mergeBlocker/humanReview)
marker (shadow) satisfied = COMPLETE or ARCHIVED, else the handoff marker decides
They genuinely differ on the review lane, and the conversion does NOT reconcile them — that is a
product decision, not a vocabulary one. See the PR body: #2720 settled "satisfied = complete or
archived" for `update-task-deps.ts`, which matches the MARKER rule, so the live legacy rule here is
the broader of the two. Narrowing it silently would strand every dependent of an in-review card.
`columns` is resolved per dependency and passed in by the caller. Omitted (or absent for a given
dependency) → the legacy literals, i.e. exactly today's behaviour, so no unconverted call site
changes meaning and an unresolvable workflow fails soft rather than reading as unsatisfied forever.
*/
export interface DependencySatisfactionColumns {
/** COMPLETE ∪ ARCHIVED for the dependency's own workflow. */
terminal: ReadonlySet<string>;
/** The dependency's own review lane (mergeBlocker ∪ humanReview). */
review: ReadonlySet<string>;
}
function isMarkerDependencySatisfied(dep: Task | undefined, markerAccepted: boolean): boolean {
/*
DELIBERATE-LITERAL — the no-metadata fallback, reviewed 2026-07-30-20:40.
Resolved columns win when present; the literal answers only when the caller could not resolve the
dependency's workflow at all. DELETING it does not "finish the conversion" — it makes an
unresolvable workflow read as `terminal.size === 0`, i.e. NEVER satisfied, which blocks every
dependent forever. That is strictly worse than the legacy behaviour it would replace.
*/
function isTerminalDependencyColumn(dep: Task, columns: DependencySatisfactionColumns | undefined): boolean {
if (columns) return columns.terminal.has(dep.column);
return dep.column === "done" || dep.column === "archived";
}
/* DELIBERATE-LITERAL — same no-metadata fallback as above, reviewed 2026-07-30-20:40. */
function isLegacyDependencySatisfied(dep: Task | undefined, columns?: DependencySatisfactionColumns): boolean {
if (!dep) return false;
if (dep.column === "done" || dep.column === "archived") return true;
if (isTerminalDependencyColumn(dep, columns)) return true;
if (columns) return columns.review.has(dep.column);
return dep.column === "in-review";
}
function isMarkerDependencySatisfied(
dep: Task | undefined,
markerAccepted: boolean,
columns?: DependencySatisfactionColumns,
): boolean {
if (!dep) return false;
if (isTerminalDependencyColumn(dep, columns)) return true;
return markerAccepted;
}
/**
* FNXC:WorkflowLifecycleColumns 2026-07-30-19:05:
* Build the per-dependency column vocabulary for {@link getUnmetSchedulingDependencies}.
*
* `cache` is caller-owned for the reason documented on `resolveTaskLifecycleColumns`: a sweep over
* many dependents spanning three workflows must read three IRs, not one per dependency. A
* dependency whose workflow cannot be resolved is simply OMITTED from the map, which lands that
* dependency on the literal fallback rather than on an empty set (an empty set would read as
* "never satisfied" and block the dependent forever — the expensive direction to be wrong in).
*/
export async function resolveDependencySatisfactionColumns(
store: Parameters<typeof resolveWorkflowIrForTask>[0],
dependencies: readonly Task[],
cache?: Map<string, WorkflowIr>,
): Promise<Map<string, DependencySatisfactionColumns>> {
const resolved = new Map<string, DependencySatisfactionColumns>();
const irCache = cache ?? new Map<string, WorkflowIr>();
for (const dep of dependencies) {
try {
const ir = await resolveWorkflowIrForTask(store, dep.id, irCache);
if (!ir) continue;
const terminal = new Set([...columnsWithFlag(ir, "complete"), ...columnsWithFlag(ir, "archived")]);
const review = new Set([...columnsWithFlag(ir, "mergeBlocker"), ...columnsWithFlag(ir, "humanReview")]);
if (terminal.size === 0 && review.size === 0) continue;
resolved.set(dep.id, { terminal, review });
} catch {
// Unresolvable workflow: leave it unmapped so this dependency keeps the legacy literals.
}
}
return resolved;
}
export function computeShadowLeaseParityState(mergeRequestState: string | null): {
shadowExecutorLeaseApplied: boolean;
shadowMergeLockApplied: boolean;
@@ -269,13 +346,23 @@ export function getUnmetSchedulingDependencies(
options?: {
markerAcceptedByTaskId?: Map<string, boolean>;
onParityDiff?: (diff: SchedulingDependencyParityDiff) => void;
/**
* Per-dependency resolved column vocabulary from
* {@link resolveDependencySatisfactionColumns}. Omitted → the legacy literals.
*/
satisfactionColumnsByTaskId?: Map<string, DependencySatisfactionColumns>;
},
): string[] {
return task.dependencies.filter((depId) => {
const dep = tasks.find((candidate) => candidate.id === depId);
if (!dep) return false;
const legacySatisfied = isLegacyDependencySatisfied(dep);
const markerSatisfied = isMarkerDependencySatisfied(dep, options?.markerAcceptedByTaskId?.get(depId) === true);
const satisfactionColumns = options?.satisfactionColumnsByTaskId?.get(depId);
const legacySatisfied = isLegacyDependencySatisfied(dep, satisfactionColumns);
const markerSatisfied = isMarkerDependencySatisfied(
dep,
options?.markerAcceptedByTaskId?.get(depId) === true,
satisfactionColumns,
);
if (options?.onParityDiff && legacySatisfied !== markerSatisfied) {
options.onParityDiff({
taskId: task.id,
@@ -357,7 +444,15 @@ export function shouldHoldActiveFileScopeLease(
scheduler-renamed-wip-file-scope-lease.test.ts.
*/
if (task.paused || task.userPaused) return false;
/*
DELIBERATE-LITERAL — the documented default for an unconverted caller, reviewed 2026-07-30-20:40.
Both scheduler call sites now pass the resolved answer, so these defaults are dead on the
scheduler's own path; they exist for the self-healing / repair callers this predicate is shared
with. Removing them would silently change those callers' meaning, which is the coupling the
optional-parameter shape exists to avoid.
*/
const isWipColumn = options?.isWipColumn ?? task.column === "in-progress";
/* DELIBERATE-LITERAL — the review half of the same shared-caller default. */
const isReviewColumn = options?.isReviewColumn ?? task.column === "in-review";
if (isWipColumn) {
return getUnmetSchedulingDependencies(task, tasks, options?.schedulingDependencyOptions).length === 0;
@@ -869,6 +964,8 @@ export class Scheduler {
const settings = await this.store.getSettings();
if (!settings.globalPause && !settings.enginePaused) {
const todoTasks = await this.store.listTasks({ column: parked.hold, slim: true });
/* One IR cache for the whole reconciliation, per the caller-owned-cache contract. */
const dependencySatisfactionIrCache = new Map<string, WorkflowIr>();
for (const dependent of todoTasks) {
const mentionsCompletedTask = dependent.dependencies.includes(task.id);
const currentlyBlockedByCompletedTask = dependent.blockedBy === task.id;
@@ -884,17 +981,25 @@ export class Scheduler {
const dependencyTasks = (await Promise.all(
dependent.dependencies.map((dependencyId) => this.store.getTask(dependencyId).catch(() => null)),
)).filter((candidate) => candidate !== null);
const satisfactionColumnsByTaskId = await resolveDependencySatisfactionColumns(
this.store,
[task, ...dependencyTasks],
dependencySatisfactionIrCache,
);
const unresolvedDeps = getUnmetSchedulingDependencies(
dependent,
[dependent, task, ...dependencyTasks],
markerAcceptedByTaskId
? {
markerAcceptedByTaskId,
onParityDiff: (diff) => {
this.emitDependencyParityDiff(diff);
},
}
: undefined,
{
satisfactionColumnsByTaskId,
...(markerAcceptedByTaskId
? {
markerAcceptedByTaskId,
onParityDiff: (diff: SchedulingDependencyParityDiff) => {
this.emitDependencyParityDiff(diff);
},
}
: {}),
},
);
try {
@@ -1084,6 +1189,8 @@ export class Scheduler {
const todoTasks = await this.store.listTasks({ column: deletedParked.hold, slim: true });
const inProgressTasks = await this.store.listTasks({ column: "in-progress", slim: true });
const dependents = [...todoTasks, ...inProgressTasks];
/* One IR cache for the whole reconciliation, per the caller-owned-cache contract. */
const deletedDependencyIrCache = new Map<string, WorkflowIr>();
for (const dependent of dependents) {
const mentionsDeletedTask = dependent.dependencies.includes(task.id);
@@ -1096,17 +1203,25 @@ export class Scheduler {
const dependencyTasks = (await Promise.all(
dependent.dependencies.map((dependencyId) => this.store.getTask(dependencyId).catch(() => null)),
)).filter((candidate) => candidate !== null);
const satisfactionColumnsByTaskId = await resolveDependencySatisfactionColumns(
this.store,
dependencyTasks,
deletedDependencyIrCache,
);
const unresolvedDeps = getUnmetSchedulingDependencies(
dependent,
[dependent, ...dependencyTasks],
markerAcceptedByTaskId
? {
markerAcceptedByTaskId,
onParityDiff: (diff) => {
this.emitDependencyParityDiff(diff);
},
}
: undefined,
{
satisfactionColumnsByTaskId,
...(markerAcceptedByTaskId
? {
markerAcceptedByTaskId,
onParityDiff: (diff: SchedulingDependencyParityDiff) => {
this.emitDependencyParityDiff(diff);
},
}
: {}),
},
);
try {
@@ -1416,13 +1531,37 @@ export class Scheduler {
return;
}
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-20:40 (fleet — PR-monitor startup hydration):
Startup rehydration of PR monitoring resolves the review lane per task.
Keyed on `column !== "in-review"`, a renamed board hydrated NOTHING on engine restart: every
open PR silently stopped being watched, so merges, review comments and closures went unnoticed
until something else moved the card. The failure is invisible precisely because it is a missing
background watcher rather than an error.
This is a one-shot startup pass over an already-fetched list, and it is already off the hot path
(`void ... .then(...)`), so a per-task resolution with a shared IR cache costs one read per
distinct workflow. Fail-soft: an unresolvable workflow falls back to the legacy literal via
`isReviewColumnRole`'s documented no-metadata behaviour rather than dropping the watcher.
*/
void this.store.listTasks({ slim: true, includeArchived: false, startupMemo: true })
.then((tasks) => {
.then(async (tasks) => {
const repo = getCurrentRepo(this.store.getRootDir());
if (!repo) return;
const hydrationIrCache = new Map<string, WorkflowIr>();
for (const task of tasks) {
if (task.column !== "in-review" || !task.prInfo) continue;
if (!task.prInfo) continue;
let flags: ColumnRoleTraitFlags | undefined;
try {
const ir = await resolveWorkflowIrForTask(this.store, task.id, hydrationIrCache);
const column = (ir as WorkflowIrV2).columns?.find((candidate) => candidate.id === task.column);
if (column) flags = resolveColumnFlags(column);
} catch {
// Unresolvable workflow: `isReviewColumnRole` falls back to the legacy id below.
}
if (!isReviewColumnRole(flags, task.column)) continue;
options.prMonitor!.startMonitoring(task.id, repo.owner, repo.repo, task.prInfo);
}
})
@@ -1439,20 +1578,38 @@ export class Scheduler {
* or `null` if the task should start from HEAD (default).
*
* Priority: explicit dep in-review (first with worktree) > blockedBy in-review.
*
* FNXC:WorkflowLifecycleColumns 2026-07-30-20:40 (fleet — scheduler base-branch stacking):
* `isReviewColumn` is a REQUIRED resolved answer, not an optional one with a literal default.
*
* This decides whether a starting task stacks its branch on a predecessor still in review. Keyed
* on the literal, a renamed board resolved every predecessor as "not in review" and the task
* started from HEAD instead of from its dependency's branch — so the dependent silently rebuilt
* work its predecessor had already done, and the first merge of the two conflicted. That is a
* quiet wrong answer, not a crash, which is why it would survive unnoticed.
*
* Required rather than optional because this method has exactly ONE caller, inside
* `runHoldReleaseSweepPass`, which has already resolved per-task trait flags for the whole board.
* An optional parameter with a literal default would let a future caller reintroduce the bug by
* omission; making it required means the compiler asks the question.
*/
private resolveBaseBranch(task: Task, allTasks: Task[]): string | null {
// Check explicit dependencies for in-review tasks with worktrees
private resolveBaseBranch(
task: Task,
allTasks: Task[],
isReviewColumn: (candidate: Task) => boolean,
): string | null {
// Check explicit dependencies for review-lane tasks with worktrees
for (const depId of task.dependencies) {
const dep = allTasks.find((t) => t.id === depId);
if (dep && dep.column === "in-review" && dep.worktree) {
if (dep && isReviewColumn(dep) && dep.worktree) {
return resolveTaskWorkingBranch(dep);
}
}
// Check implicit blockedBy for in-review task with worktree
// Check implicit blockedBy for a review-lane task with worktree
if (task.blockedBy) {
const blocker = allTasks.find((t) => t.id === task.blockedBy);
if (blocker && blocker.column === "in-review" && blocker.worktree) {
if (blocker && isReviewColumn(blocker) && blocker.worktree) {
return resolveTaskWorkingBranch(blocker);
}
}
@@ -1610,6 +1767,9 @@ export class Scheduler {
*/
private async renewActiveMissionSymbolLocks(tasks: Task[]): Promise<void> {
for (const task of tasks) {
/* DELIBERATE-LITERAL — seeds the trait resolution immediately below, which overwrites it
whenever the workflow resolves; the literal survives only as the documented fallback for an
unresolvable workflow. Reviewed 2026-07-30-20:40. */
let isImplementationColumn = task.column === "in-progress";
try {
const ir = await resolveWorkflowIrForTask(this.store, task.id);
@@ -1722,6 +1882,18 @@ export class Scheduler {
columnFlagsByWorkflowId.get(workflowIdByTaskId.get(task.id) ?? "builtin:coding")?.get(task.column);
const isWipColumnTask = (task: Task): boolean =>
isWipColumnRole(columnFlagsForTask(task), task.column);
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-19:05:
The review-lane twin of `isWipColumnTask`, over the SAME resolved flags map.
The file-scope lease sweep below had a renamed-board hole in the shape 2026-07-30-16:30 already
fixed for the wip half: the review pass gated on the literal `column === "in-review"`, so on a
renamed board no review card entered `activeScopes` and its worktree's files read as free while
the merge was still in flight. Fixing only the wip half left the two halves of one registry
disagreeing. Same predicate, same flags, so they cannot drift.
*/
const isReviewColumnTask = (task: Task): boolean =>
isReviewColumnRole(columnFlagsForTask(task), task.column);
const wipTaskIds = tasks.filter(isWipColumnTask).map((task) => task.id);
let reservedWorktreeSlots = wipTaskIds.length;
let reservedConcurrentSlots = reservedWorktreeSlots;
@@ -1755,14 +1927,27 @@ export class Scheduler {
markerAcceptedByTaskId.set(depId, (await this.store.getCompletionHandoffAcceptedMarker(depId)) !== null);
}
}
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-19:05:
Resolved ONCE per sweep, over the dependency cards actually referenced — not per dependent,
and not over the whole board. `tasks` is already in memory, so this adds workflow-IR reads
bounded by the number of distinct workflows, not by the number of dependency edges.
*/
const referencedDependencyIds = new Set(tasks.flatMap((candidate) => candidate.dependencies));
const satisfactionColumnsByTaskId = await resolveDependencySatisfactionColumns(
this.store,
tasks.filter((candidate) => referencedDependencyIds.has(candidate.id)),
new Map<string, WorkflowIr>(),
);
const schedulingDependencyOptions = mergeShadowEnabled
? {
satisfactionColumnsByTaskId,
markerAcceptedByTaskId,
onParityDiff: (diff: SchedulingDependencyParityDiff) => {
this.emitDependencyParityDiff(diff);
},
}
: undefined;
: { satisfactionColumnsByTaskId };
if (settings.groupOverlappingFiles) {
for (const task of tasks) {
@@ -1792,16 +1977,18 @@ export class Scheduler {
const reviewHandoffMarkerMap = new Map<string, boolean>();
if (settings.mergeRequestContractShadowEnabled === true) {
for (const t of tasks) {
if (t.column === "in-review") {
if (isReviewColumnTask(t)) {
reviewHandoffMarkerMap.set(t.id, (await this.store.getCompletionHandoffAcceptedMarker(t.id)) !== null);
}
}
}
const inReviewWithWorktree = tasks.filter(
(task) => task.column === "in-review" && shouldHoldActiveFileScopeLease(task, tasks, {
(task) => isReviewColumnTask(task) && shouldHoldActiveFileScopeLease(task, tasks, {
mergeRequestContractShadowEnabled: settings.mergeRequestContractShadowEnabled,
handoffAccepted: reviewHandoffMarkerMap.get(task.id) ?? false,
schedulingDependencyOptions,
/* Both halves of the gate agree: the filter said review, so the predicate is told so. */
isReviewColumn: true,
}),
);
for (const task of inReviewWithWorktree) {
@@ -2328,7 +2515,7 @@ export class Scheduler {
}
dispatchPrepByTaskId.set(task.id, {
baseBranch: this.resolveBaseBranch(freshTask, tasks),
baseBranch: this.resolveBaseBranch(freshTask, tasks, isReviewColumnTask),
dispatchStormCount: nextDispatchStormCount,
dispatchTimestamp,
effectiveNodeId: effectiveNode.nodeId ?? null,
@@ -2478,7 +2665,27 @@ export class Scheduler {
);
}
if (toColumn === "done") {
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-20:40 (fleet — mission completion advance):
"Did this task just COMPLETE?" resolved from the destination column's own trait.
Keyed on `toColumn === "done"`, a renamed board never advanced mission execution: the feature
status above was reconciled (that path already resolves traits), but the follow-on completion
never fired, so the mission stalled with its roadmap showing the work finished. The two halves
of one handler disagreed about whether the same move was a completion.
Fail-soft to the legacy literal via `isCompleteColumnRole`'s documented no-metadata behaviour —
an unresolvable workflow keeps today's behaviour rather than stalling the mission.
*/
let completionFlags: ColumnRoleTraitFlags | undefined;
try {
const ir = await resolveWorkflowIrForTask(this.store, taskId);
const column = (ir as WorkflowIrV2).columns?.find((candidate) => candidate.id === toColumn);
if (column) completionFlags = resolveColumnFlags(column);
} catch {
// Unresolvable workflow: fall back to the legacy `done` literal below.
}
if (isCompleteColumnRole(completionFlags, toColumn)) {
await this.handleMissionTaskCompletion(taskId, sliceIdBeforeUpdate);
}
} catch (err) {

View File

@@ -3,8 +3,8 @@
"byFile": {
"packages/engine/src/self-healing.ts": 110,
"packages/engine/src/executor.ts": 57,
"packages/engine/src/scheduler.ts": 26,
"packages/core/src/store.ts": 12,
"packages/engine/src/scheduler.ts": 12,
"packages/core/src/task-store/async-comments-attachments.ts": 9,
"packages/dashboard/app/components/TaskContextMenu.tsx": 9,
"packages/dashboard/src/github-tracking-comments.ts": 9,
@@ -27,7 +27,6 @@
"packages/engine/src/merger.ts": 5,
"packages/engine/src/project-engine.ts": 5,
"packages/engine/src/restart-recovery-coordinator.ts": 5,
"packages/core/src/agent-store.ts": 2,
"packages/core/src/blocker-fanout.ts": 4,
"packages/core/src/task-store/task-store-helpers.ts": 4,
"packages/dashboard/app/components/TaskReviewTab.tsx": 4,
@@ -55,6 +54,7 @@
"packages/engine/src/planner-overseer.ts": 3,
"packages/engine/src/worktree-pool.ts": 3,
"packages/cli/src/commands/dashboard-tui/app.tsx": 2,
"packages/core/src/agent-store.ts": 2,
"packages/core/src/assigned-task-ranking.ts": 2,
"packages/core/src/duplicate-intake.ts": 2,
"packages/core/src/near-duplicate-canonical.ts": 2,
@@ -139,6 +139,8 @@
"deliberateByFile": {
"packages/dashboard/app/components/TaskCard.tsx\u0000triage": 2,
"packages/dashboard/app/components/TaskDetailModal.tsx\u0000triage": 2,
"packages/engine/src/scheduler.ts\u0000in-progress": 2,
"packages/engine/src/scheduler.ts\u0000in-review": 2,
"packages/cli/src/commands/task.ts\u0000archived": 1,
"packages/cli/src/commands/task.ts\u0000done": 1,
"packages/cli/src/extension.ts\u0000archived": 1,
@@ -158,6 +160,8 @@
"packages/engine/src/hold-release.ts\u0000archived": 1,
"packages/engine/src/hold-release.ts\u0000done": 1,
"packages/engine/src/hold-release.ts\u0000in-review": 1,
"packages/engine/src/scheduler.ts\u0000archived": 1,
"packages/engine/src/scheduler.ts\u0000done": 1,
"packages/engine/src/triage.ts\u0000triage": 1
},
"queryByFile": {