fleet: scheduler.ts 28 → 27 + make the column-role predicates reachable from the other 80% of the backlog (#2690)

## Census

**722 → 721**; `packages/engine/src/scheduler.ts` **28 → 27**. Exactly
the one site converted. Baseline re-recorded in the same commit —
`--strict` flagged the stale allowance itself, and `in-progress` went
138 → 137.

## The unblocker (commit 1)

The role helpers live in `packages/dashboard/app/utils/columnRoles.ts`,
a dashboard-**app** module. Measured against the census:

| Location | Guards | Share | Helpers importable? |
|---|---:|---:|---|
| `packages/engine/**` | 316 | 43% | no |
| `packages/dashboard/app/**` | 150 | 20% | **yes** |
| `packages/core/**` | 148 | 20% | no |
| `packages/dashboard/src/**` | 78 | 10% | no |
| `packages/cli/**` | 24 | 3% | no |

**Only 20% of the backlog can call them at all.** #2685 widens the
helper *set* correctly; that is coverage, not location.
`packages/core/src/column-roles.ts` is the same flags-first /
legacy-id-fallback predicate placed where the other 80% can reach it —
core already exports `resolveColumnFlags`, so no new resolution
machinery comes with it.

**Semantics are mirrored from #2685, not invented**, so the two sets
cannot answer the same question differently: `complete` EXCLUDES
`archived`; `wip` keys on `countsTowardWip` (the same flag capacity
arithmetic uses); `review` accepts `mergeBlocker` OR `humanReview`. One
addition — `isTerminalColumnRole` for the `!== "done" && !== "archived"`
union, the most repeated shape in the backlog.

10 tests cover both modes of all 8 predicates, including the **degraded
no-flags fallback** — the half with no coverage when these lived only in
the dashboard app — plus the two cases that prove the predicate does
something rather than nothing: a renamed column carrying the right trait
answers yes, and a legacy id carrying the WRONG trait answers no.

## A trap every fleet worker converting engine code will hit

A new core export must be added to **both** `index.ts` and
`index.gate.ts`.

The `engine-core` gate project resolves `@fusion/core` to a bundle built
from `index.gate.ts` (`scripts/build-engine-core-gate-bundle.mjs`). An
export present only in `index.ts` is `undefined` at runtime under the
gate: 13 `scheduler-workflow-cutover` tests failed with `isWipColumnRole
is not a function`, in a file that does not mock `@fusion/core` at all.
The symptom points at the consumer, the cause is the barrel.

I nearly mis-attributed this. Baseline first:
`scheduler-workflow-cutover` is **42 passed on clean main**, so the 13
were mine — not pre-existing. That measurement is the only reason I
looked at the barrel instead of "fixing" the tests.

## The conversion (commit 2)

`scheduler.ts:1690`'s `isWipColumnTask` was a hand-rolled copy of
`isWipColumnRole` — it stored only `countsTowardWip` as a boolean and
re-implemented flags-first-then-legacy-id inline. It now stores the
resolved flags object and lets the shared predicate decide.

Behaviour is identical in all four states: column present with the flag
true or false (flags win), column absent from a resolved IR, and IR
resolution failed (both defer to the legacy id).

| Check | Result |
|---|---|
| `scheduler-workflow-cutover` | **42 passed** before and after |
| 21 scheduler/capacity/hold-release files | **372 passed** |
| `pnpm test:gate` | **726 passed** |
| `pnpm lint` | clean |

## Flagged and skipped, not guessed

**`scheduler.ts:1736` — a latent legacy-vocabulary defect, not a
conversion.** `if (task.column !== "in-progress") continue;` gates the
file-scope-lease loop on the literal, ~40 lines below capacity
arithmetic that is trait-aware. On a renamed WIP column the loop
silently does nothing while capacity counts the same cards correctly.
Converting it *changes behaviour* on renamed boards (from wrong to
right), which the fleet rules put out of scope — so it is flagged here
for whoever owns that fix. It is the same class as U10's six
legacy-vocabulary defects.

**`hold-release.ts:343`** — already marked `DELIBERATE-LITERAL`. It is
the legacy half of FN-5719's dual-accept pair; converting it would make
both halves compute the same answer, deleting the compatibility signal
*and* its divergence detector while looking like a cleanup. Untouched.

**`task-merge.ts:254`** — the documented fallback for callers that have
not proven lane identity; trait-aware callers pass
`skipColumnIdentityCheck`. Untouched.

**The other 14 `scheduler.ts` sites** have no flags in scope (e.g.
`isLegacyDependencySatisfied(dep: Task | undefined)`,
`shouldHoldActiveFileScopeLease(...)` — task-only pure functions).
Threading an IR in changes signatures and call graphs: behaviour change,
out of scope. This is why the cluster is 28 → 27 and not 28 → 0, and the
reachability measurement behind it is #2687.

No changeset: `@fusion/*` are private and no `@runfusion/fusion`
behaviour changes.
This commit is contained in:
gsxdsm
2026-07-30 03:01:36 -07:00
committed by GitHub
parent bb3bdab999
commit aa02db5782
6 changed files with 285 additions and 16 deletions

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import {
isArchivedColumnRole,
isCompleteColumnRole,
isHoldColumnRole,
isIntakeColumnRole,
isPreImplementationColumnRole,
isReviewColumnRole,
isTerminalColumnRole,
isWipColumnRole,
type ColumnRoleTraitFlags,
} from "../column-roles.js";
/*
FNXC:WorkflowResolvedColumns 2026-07-30-15:20:
Covers BOTH modes of every predicate — the flags path and the degraded no-flags fallback. The
fallback is the half that had no test when these lived only in the dashboard app, and it is the half
that matters: it runs for any caller holding a task row without a resolved IR, and for a card resting
in a column its workflow no longer declares.
The renamed-column cases are the point of the whole conversion: a column carrying the right trait
under a NON-legacy id must answer yes, and a column carrying the legacy id but the WRONG trait must
answer no. A predicate that only ever saw default boards would pass while doing nothing.
*/
const F = (f: ColumnRoleTraitFlags): ColumnRoleTraitFlags => f;
describe("column-role predicates — flags decide when present", () => {
it("reads each role off its own trait, under a renamed column id", () => {
expect(isIntakeColumnRole(F({ intake: true }), "Inbox")).toBe(true);
expect(isHoldColumnRole(F({ hold: true }), "Parking")).toBe(true);
expect(isWipColumnRole(F({ countsTowardWip: true }), "Building")).toBe(true);
expect(isReviewColumnRole(F({ mergeBlocker: true }), "Checking")).toBe(true);
expect(isCompleteColumnRole(F({ complete: true }), "Shipped")).toBe(true);
expect(isArchivedColumnRole(F({ archived: true }), "Attic")).toBe(true);
});
it("a legacy id with the WRONG traits answers no — the id must not win over resolved flags", () => {
// The conversion's whole claim: once flags resolve, the id is not consulted.
expect(isCompleteColumnRole(F({ archived: true }), "done")).toBe(false);
expect(isWipColumnRole(F({ hold: true }), "in-progress")).toBe(false);
expect(isIntakeColumnRole(F({ hold: true }), "triage")).toBe(false);
expect(isReviewColumnRole(F({ complete: true }), "in-review")).toBe(false);
});
it("pre-implementation is the union of intake and hold", () => {
expect(isPreImplementationColumnRole(F({ intake: true }), "x")).toBe(true);
expect(isPreImplementationColumnRole(F({ hold: true }), "x")).toBe(true);
expect(isPreImplementationColumnRole(F({ countsTowardWip: true }), "x")).toBe(false);
});
it("review accepts either separable trait", () => {
expect(isReviewColumnRole(F({ humanReview: true }), "x")).toBe(true);
expect(isReviewColumnRole(F({ mergeBlocker: true }), "x")).toBe(true);
expect(isReviewColumnRole(F({}), "x")).toBe(false);
});
/*
The distinction #2685 established, asserted rather than only documented: an archived card is
finished but NOT completed, so a throughput surface counting `complete` must not see it.
*/
it("complete EXCLUDES archived, while terminal includes both", () => {
expect(isCompleteColumnRole(F({ archived: true }), "x")).toBe(false);
expect(isArchivedColumnRole(F({ complete: true }), "x")).toBe(false);
expect(isTerminalColumnRole(F({ complete: true }), "x")).toBe(true);
expect(isTerminalColumnRole(F({ archived: true }), "x")).toBe(true);
expect(isTerminalColumnRole(F({ countsTowardWip: true }), "x")).toBe(false);
});
it("an empty resolved-flags object is authoritative — it does NOT fall back to the id", () => {
// `{}` means "traits resolved, this column has none", which is different from "unresolved".
expect(isCompleteColumnRole(F({}), "done")).toBe(false);
expect(isWipColumnRole(F({}), "in-progress")).toBe(false);
expect(isTerminalColumnRole(F({}), "archived")).toBe(false);
});
});
describe("column-role predicates — degraded fallback when flags are absent", () => {
it("falls back to the legacy id for each role", () => {
expect(isIntakeColumnRole(undefined, "triage")).toBe(true);
expect(isHoldColumnRole(undefined, "todo")).toBe(true);
expect(isWipColumnRole(undefined, "in-progress")).toBe(true);
expect(isReviewColumnRole(undefined, "in-review")).toBe(true);
expect(isCompleteColumnRole(undefined, "done")).toBe(true);
expect(isArchivedColumnRole(undefined, "archived")).toBe(true);
});
it("pre-implementation falls back to BOTH planning ids, merged and pre-merge", () => {
// `todo` is the post-U11 merged Planning column; `triage` its pre-merge predecessor, retained
// for projects upgraded mid-flight that still hold cards there.
expect(isPreImplementationColumnRole(undefined, "todo")).toBe(true);
expect(isPreImplementationColumnRole(undefined, "triage")).toBe(true);
expect(isPreImplementationColumnRole(undefined, "in-progress")).toBe(false);
});
it("terminal falls back to done OR archived", () => {
expect(isTerminalColumnRole(undefined, "done")).toBe(true);
expect(isTerminalColumnRole(undefined, "archived")).toBe(true);
expect(isTerminalColumnRole(undefined, "in-review")).toBe(false);
});
it("a RENAMED column with no resolved flags answers no — the fallback cannot invent a role", () => {
// The honest limit of the degraded mode, asserted so nobody mistakes it for trait resolution.
expect(isCompleteColumnRole(undefined, "Shipped")).toBe(false);
expect(isWipColumnRole(undefined, "Building")).toBe(false);
expect(isIntakeColumnRole(undefined, "Inbox")).toBe(false);
});
});

View File

@@ -0,0 +1,123 @@
import type { TraitFlags } from "./trait-types.js";
/*
FNXC:WorkflowResolvedColumns 2026-07-30-15:05:
Column-ROLE predicates reachable from every package.
WHY THIS EXISTS AND WHY IT IS NOT A SECOND ABSTRACTION. The role helpers were introduced in
`packages/dashboard/app/utils/columnRoles.ts` — a dashboard-APP module. Measured against the census,
only 150 of 722 lifecycle guards (20%) sit where that module can be imported; engine (316), core
(148), the dashboard SERVER (78) and the CLI (24) cannot reach it at all. This is the SAME
flags-first / legacy-id-fallback predicate placed where the other 80% can call it, and core already
exports `resolveColumnFlags`, so no new resolution machinery comes with it. See
`docs/plans/workflow-owned-merge-stack/fleet-conversion-reachability.md` for the measurement.
SEMANTICS ARE MIRRORED, NOT INVENTED. The three distinctions below are the ones #2685 established
for the dashboard-side set, restated here deliberately so the two cannot answer the same question
differently:
- `isCompleteColumnRole` does NOT count `archived`. An archived card is finished but not
*completed*; a throughput surface counting both double-counts it.
- `isWipColumnRole` keys on `countsTowardWip` — the same flag capacity arithmetic uses, so a board
cannot have a column that counts toward WIP for capacity but not for this predicate.
- `isReviewColumnRole` accepts `mergeBlocker` OR `humanReview`. They are separable traits, but
every caller converted so far asks "is this card in review", for which both qualify. A caller
that needs exactly one should read the flag directly rather than widen this.
WHY THE LEGACY FALLBACK IS NOT DEAD CODE. Flags are absent in two real states: a card resting in a
column its workflow no longer declares (mid-flight upgrade), and any caller holding a task row
without a resolved IR. A bare `flags.complete === true` returns false in both — silent degradation,
not a visible failure. The fallback is the degraded mode, named once and covered by tests, rather
than an inline id comparison repeated per call site.
*/
/**
* Legacy column ids, used ONLY when a column has no resolved trait flags.
*
* NOT lifecycle rules. `todo` is the post-U11 merged planning column; `triage` is its pre-merge
* predecessor, retained because a project upgraded mid-flight can still hold cards there while its
* workflow no longer declares the column.
*/
const LEGACY_INTAKE_COLUMN_ID = "triage";
const LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS: ReadonlySet<string> = new Set(["todo", "triage"]);
const LEGACY_HOLD_COLUMN_ID = "todo";
const LEGACY_WIP_COLUMN_ID = "in-progress";
const LEGACY_REVIEW_COLUMN_ID = "in-review";
const LEGACY_COMPLETE_COLUMN_ID = "done";
const LEGACY_ARCHIVED_COLUMN_ID = "archived";
/** The subset of resolved trait flags these role questions read. */
export type ColumnRoleTraitFlags = Pick<
TraitFlags,
"intake" | "hold" | "countsTowardWip" | "mergeBlocker" | "humanReview" | "complete" | "archived"
>;
/** Does this column play the INTAKE role — the lane a card enters before implementation? */
export function isIntakeColumnRole(flags: ColumnRoleTraitFlags | undefined, columnId: string): boolean {
return flags ? flags.intake === true : columnId === LEGACY_INTAKE_COLUMN_ID;
}
/**
* Is this column a PRE-IMPLEMENTATION lane — intake or a hold?
*
* Either trait qualifies: both mean work has not started there, so moving a part-done card in
* risks discarding steps.
*/
export function isPreImplementationColumnRole(
flags: ColumnRoleTraitFlags | undefined,
columnId: string,
): boolean {
return flags
? Boolean(flags.intake || flags.hold)
: LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS.has(columnId);
}
/** Does this column play the HOLD role — a lane a card WAITS in rather than works in? */
export function isHoldColumnRole(flags: ColumnRoleTraitFlags | undefined, columnId: string): boolean {
return flags ? flags.hold === true : columnId === LEGACY_HOLD_COLUMN_ID;
}
/**
* Does this column count as WORK IN PROGRESS?
*
* Keyed on `countsTowardWip` so this predicate and capacity arithmetic cannot disagree.
*/
export function isWipColumnRole(flags: ColumnRoleTraitFlags | undefined, columnId: string): boolean {
return flags ? flags.countsTowardWip === true : columnId === LEGACY_WIP_COLUMN_ID;
}
/**
* Is a card here awaiting REVIEW — a merge-blocking gate or an explicit human approval?
*
* Either trait qualifies; see the header note on why this is deliberately the union.
*/
export function isReviewColumnRole(flags: ColumnRoleTraitFlags | undefined, columnId: string): boolean {
return flags
? Boolean(flags.mergeBlocker || flags.humanReview)
: columnId === LEGACY_REVIEW_COLUMN_ID;
}
/**
* Is this a terminal-SUCCESS column — work completed, dependencies satisfied?
*
* Excludes archived: see the header note. Use `isTerminalColumnRole` for "finished either way".
*/
export function isCompleteColumnRole(flags: ColumnRoleTraitFlags | undefined, columnId: string): boolean {
return flags ? flags.complete === true : columnId === LEGACY_COMPLETE_COLUMN_ID;
}
/** Is this column ARCHIVED — globally hidden and out of the lifecycle? */
export function isArchivedColumnRole(flags: ColumnRoleTraitFlags | undefined, columnId: string): boolean {
return flags ? flags.archived === true : columnId === LEGACY_ARCHIVED_COLUMN_ID;
}
/**
* Is a card here FINISHED either way — completed or archived?
*
* Exists because the pattern `column !== "done" && column !== "archived"` is the single most
* repeated shape in the backlog (e.g. `task-merge.ts` dependency/blocker checks). Naming the union
* keeps callers from re-deriving it and from accidentally dropping one half.
*/
export function isTerminalColumnRole(flags: ColumnRoleTraitFlags | undefined, columnId: string): boolean {
return isCompleteColumnRole(flags, columnId) || isArchivedColumnRole(flags, columnId);
}

View File

@@ -350,6 +350,20 @@ export type {
TraitViolationSeverity,
TraitAuditWarning,
} from "./trait-registry.js";
/* FNXC:WorkflowResolvedColumns 2026-07-30-15:05: column-ROLE predicates must be in the GATE barrel
too — the engine-core gate project resolves @fusion/core to the bundle built from THIS file, so an
export present only in index.ts is undefined at runtime under the gate. */
export {
isIntakeColumnRole,
isPreImplementationColumnRole,
isHoldColumnRole,
isWipColumnRole,
isReviewColumnRole,
isCompleteColumnRole,
isArchivedColumnRole,
isTerminalColumnRole,
} from "./column-roles.js";
export type { ColumnRoleTraitFlags } from "./column-roles.js";
export {
BUILTIN_TRAIT_IDS,
BUILTIN_TRAIT_DEFINITIONS,

View File

@@ -381,6 +381,19 @@ export type {
TraitViolationSeverity,
TraitAuditWarning,
} from "./trait-registry.js";
/* FNXC:WorkflowResolvedColumns 2026-07-30-15:05: column-ROLE predicates, reachable from every
package (the dashboard-app helper set is not importable from engine/core/cli). */
export {
isIntakeColumnRole,
isPreImplementationColumnRole,
isHoldColumnRole,
isWipColumnRole,
isReviewColumnRole,
isCompleteColumnRole,
isArchivedColumnRole,
isTerminalColumnRole,
} from "./column-roles.js";
export type { ColumnRoleTraitFlags } from "./column-roles.js";
export {
BUILTIN_TRAIT_IDS,
BUILTIN_TRAIT_DEFINITIONS,

View File

@@ -40,7 +40,8 @@ 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 } from "@fusion/core";
import { resolveWorkflowIrForTask, resolveWorkflowIrById, resolveColumnFlags, resolveWorktreeCapacityLimit, resolveLifecycleColumns, isWipColumnRole } 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";
import { moveTaskToReplanColumn } from "./replan-target.js";
@@ -1668,27 +1669,36 @@ export class Scheduler {
workflowIdByTaskId.set(task.id, "builtin:coding");
}
}));
// Per distinct workflow: columnId → countsTowardWip flag; null when the IR
// failed to resolve or has no v2 columns (forces the literal fallback).
const wipFlagsByWorkflowId = new Map<string, Map<string, boolean> | null>();
/*
FNXC:WorkflowResolvedColumns 2026-07-30-15:40 (fleet conversion, scheduler.ts):
Per distinct workflow: columnId → resolved trait flags; null when the IR failed to resolve or
has no v2 columns, which leaves every lookup undefined and defers to the helper's documented
degraded mode.
Was a hand-rolled copy of `isWipColumnRole`: it stored only `countsTowardWip` as a boolean and
re-implemented flags-first-then-legacy-id inline. Storing the flags object instead lets the
shared predicate decide, so this scheduler and the role helpers cannot drift on what "counts as
WIP" means. Behaviour is identical in all four states — column present with the flag true or
false (flags win), column absent from a resolved IR, and IR resolution failed (both fall back
to the legacy id).
*/
const columnFlagsByWorkflowId = new Map<string, Map<string, ColumnRoleTraitFlags> | null>();
for (const workflowId of new Set(workflowIdByTaskId.values())) {
try {
const ir = await resolveWorkflowIrById(this.store, workflowId, wipIrCache);
const columns = (ir as WorkflowIrV2).columns;
wipFlagsByWorkflowId.set(
columnFlagsByWorkflowId.set(
workflowId,
columns ? new Map(columns.map((c) => [c.id, resolveColumnFlags(c).countsTowardWip === true])) : null,
columns ? new Map(columns.map((c) => [c.id, resolveColumnFlags(c)])) : null,
);
} catch {
wipFlagsByWorkflowId.set(workflowId, null);
columnFlagsByWorkflowId.set(workflowId, null);
}
}
const isWipColumnTask = (task: Task): boolean => {
const flags = wipFlagsByWorkflowId.get(workflowIdByTaskId.get(task.id) ?? "builtin:coding");
const wip = flags?.get(task.column);
if (wip !== undefined) return wip;
return task.column === "in-progress";
};
const columnFlagsForTask = (task: Task): ColumnRoleTraitFlags | undefined =>
columnFlagsByWorkflowId.get(workflowIdByTaskId.get(task.id) ?? "builtin:coding")?.get(task.column);
const isWipColumnTask = (task: Task): boolean =>
isWipColumnRole(columnFlagsForTask(task), task.column);
const wipTaskIds = tasks.filter(isWipColumnTask).map((task) => task.id);
let reservedWorktreeSlots = wipTaskIds.length;
let reservedConcurrentSlots = reservedWorktreeSlots;

View File

@@ -1,14 +1,14 @@
{
"generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline",
"totals": {
"column": 722,
"column": 721,
"role": 5,
"status": 186,
"deliberate": 17
},
"byColumnId": {
"done": 195,
"in-progress": 138,
"in-progress": 137,
"in-review": 200,
"archived": 147,
"todo": 42
@@ -18,7 +18,7 @@
"packages/engine/src/executor.ts": 85,
"packages/dashboard/app/components/TaskCard.tsx": 42,
"packages/dashboard/app/components/TaskDetailModal.tsx": 30,
"packages/engine/src/scheduler.ts": 28,
"packages/engine/src/scheduler.ts": 27,
"packages/dashboard/src/routes/register-task-workflow-routes.ts": 20,
"packages/core/src/task-store/moves.ts": 15,
"packages/core/src/store.ts": 12,