One branch, one PR, per the consolidation directive. Contents file-by-file below. **Supersedes #2625** (its overlapping conversions landed via U11's #2624/#2626/#2636; only the parts nobody else did are folded here). **#2630 and #2639 stay open** — both green with zero threads, per rule 3. ## Four live defects, each measured **1. Every planning card renders an actions menu.** `TaskContextMenu.tsx` still had `shouldShowActionsMenu: task.column !== "triage"` on main *after* the rest of that file was converted. Since #2515 removed the id, the condition is TRUE for every card, so the suppression stopped applying anywhere — including on cards whose menu is empty, the orphaned click target the Surface Enumeration rule exists to catch. Found **twice independently**: by reading the guard, and again by the invariance test below, which failed on main with `shouldShowActionsMenu` true on one lineage and false on another. That is the argument for an invariance property over per-site conversion — the file had already been converted "2 → 1" and the survivor was the live one. **2. Worktree upcoming-work list empty on renamed boards.** `groupByWorktree` filtered `t.column === "todo"`. On the default board the id and the role coincide so every existing test passed; renamed, it matched nothing and a whole panel read as idle. **3. Hold-lane FIFO ordering lost on renamed boards.** `sortTasksForDisplayColumn` gated priority-then-FIFO on `column === "todo"`, degrading to the generic id-ordered sort elsewhere. Cards simply appear in the wrong order, silently. **4. The AST ratchet still failed open** — fourth time in that file, third found by review. `receiverName` understood only one-level property access and bare identifiers, so `task["column"]`, `metadataColumn(entry, "to")`, ternaries, `(task!.column)` and backtick literals were dropped. **Measured on main: `in-progress` 196 → 197, `in-review` 211 → 213** — three real guards nobody counted, including `metadataColumn(entry, "to") === "in-review"` in `reliability-metrics.ts`. Now walks wrappers, resolves calls to the callee name, and emits a `<SyntaxKind>` **sentinel** for anything unnameable: counted *and* trips the classification guard, so a human judges it instead of it vanishing. ## Per-file guard counts | file | before | after | |---|---:|---:| | `app/components/TaskContextMenu.tsx` | 1 | **0** | | `app/utils/worktreeGrouping.ts` | 1 | **0** | | `app/components/taskSorting.ts` | 1 | **0** | The other dashboard files I had converted reached 0 via U11's PRs; where our work overlapped I took theirs during the rebase, including two places where theirs was **stronger** than mine — they deleted Column's unreachable quick-create arm outright (with fixtures migrated) where I had converted it, and they verified the same `isPreExecutionHoldColumn` degraded-set asymmetry I did, independently. ## Flip precondition: the moves.ts flag is scoped, not flipped `move-target-declared-census.test.ts` answers precondition 2 with measurement. 41 engine `moveTask` calls have literal targets — `todo` 27, `in-progress` 7, `done` 6, `archived` 1 — and **all four are declared by the default lineage**, so the default board is not the exposure. `triage` appears only in a comment noting `replan-target.ts` used to hardcode it. My own grep had said `todo=29`; the AST says 27, because grep counts comments. The exposure is **custom** lineages: 20 of the 41 carry no `recoveryRehome` and would reject with unknown-column post-flip; 21 are exempt via the #1411 carve-out, which makes that carve-out load-bearing. I did not flip the flag. It is six seams, not the `789`/`837` pair every summary including mine described, and seam 2 turns on *new refusals* rather than swapping equivalent implementations — a green suite says nothing about that. #2639 pins the blast radius. ## Tests - `column-role-id-invariance.test.tsx` — hold traits fixed, vary only the column id across MERGED / LEGACY / RENAMED; every decision must agree. Drives the real consumers, so a component keeping an inline comparison fails it. Includes a unanimous-and-**false** case so it can't be satisfied by a predicate hardwired to true. **This is the test that caught defect 1 on main.** - `worktreeGrouping.test.ts` — includes two cards both in a column named `staging`, one hold and one not, asserting opposite answers. That assertion is impossible under a board-wide column-id set, which is why hold resolution is keyed per task via `getEffectiveTaskWorkflowId` (#2625 review). - `taskSorting.test.ts` — discriminates on the **tiebreak**, not priority: both branches sort by priority, so my first version passed for the wrong reason. Equal-priority cards whose `createdAt` order disagrees with their id order. - `no-hardcoded-lifecycle-columns.test.ts` — 16 detector cases: 11 shapes counted, 4 legitimate ignored, one asserting the sentinel path. Revert checks, all run: menu suppression → diff names the field; worktree → `expected [] to include 'FN-50'`; sort → `FN-2, FN-9` instead of `FN-9, FN-2`; ratchet → the 3 recovered guards disappear. ## One site that should never be converted `MissionControlPanel.tsx:46` — `{ id: "triage", match: (c) => c === "triage" || c === "signal" || c === "backlog" }` is a deliberate name-similarity heuristic for the SDLC funnel; it matches synonyms and folds unknown columns into an "other" bucket so custom columns still contribute. Converting it changes what the funnel displays. Like the `live-agent-count` fallbacks, it belongs in a documented floor — **the ratchet's target is that floor, not zero.** `DocumentsView.tsx:73` is convertible but the file has no column flags at all, so a real fix means plumbing board-workflow metadata into a view that doesn't fetch it — its own unit of work. ## Verification `pnpm lint` clean. `pnpm test:gate` green (10 / 482 / 71). `tsc -p packages/dashboard/tsconfig.app.json` and `packages/core/tsconfig.json` clean. Core ratchet + seam suites 24/24. Dashboard target suites 37/38 — the one failure is the pre-existing `"Back to In Progress"` label casing, confirmed identical on the base. --- ## Added after the initial push **5. `TaskCard` lost inline editing on renamed boards; `TaskDetailModal` kept it.** Still live on main: the modal resolved field editability from traits in U10/R8, the card used a hardcoded `{triage, todo}` set with **no trait path at all** — even though `taskColumnFlags` was already in scope. On a renamed board the title was editable in the modal and the pencil was missing from the card. Body moved unchanged into `isFieldEditableColumnRole` so the two surfaces cannot drift again. The veto traits are the substance: a column can legally carry `hold` **and** a WIP or review trait, and a plain `intake || hold` check would let an operator rewrite a description while a session executes against it. Coverage gap **measured, not assumed**: mutating `canEdit` back to the hardcoded set left `TaskCard*` at the same failure count as the unmutated run — nothing caught it. The four render cases assert the real `aria-label`; that mutation now fails with `Unable to find an accessible element ... name 'Edit task'`. **6. The ratchet's target is a documented FLOOR, not zero** — and this changes the completion bar. Zero is not reachable, and chasing it means breaking working code. Two categories are permanent, now protected as positive assertions so a future sweep cannot "finish the job" by deleting them: - `MissionControlPanel.tsx`'s `FUNNEL_STAGES` is a deliberate **name-similarity** heuristic — it matches `signal`, `backlog`, `to-do`, `ready`, `shipped` and folds unrecognised columns into an "other" bucket so a custom board still contributes counts. It is not asking whether a column has the intake trait; it buckets arbitrary column *names* for display. Asserted on the **synonym list**, because the synonyms are what prove it is name matching — if they disappear the site has changed character and the exemption stops applying. - `live-agent-count.ts`'s no-flags arm is reachable (a remote store is deliberately given an empty flag map; a card in an undeclared column has no flags at all) and deleting the literal makes such a card match **no** arm, so the queued total silently under-reports a stranded card. A count with an undocumented floor invites someone to drive it to zero. **Not done, and why:** `DocumentsView.tsx:73` is convertible but that file has no column flags anywhere, so a real fix means plumbing board-workflow metadata into a view that does not fetch it — its own unit of work, not something to smuggle into a conversion. **Re-verified after these commits:** `pnpm lint` clean, `pnpm test:gate` green (10 / 482 / 71), `tsc` clean on core and `tsconfig.app.json`, core ratchet suite 26/26, `columnRoles` 10/10, `TaskCard.test.tsx` 384/386 (the 2 are pre-existing CSS assertions). `TaskDetail*` is 130 failed / 551 passed **both with and without** this change — verified by stashing, so pre-existing and unrelated. --- ## Flag resolution: preconditions 1 and 2 are now DISCHARGED. Precondition 3 is blocked, and by evidence. **Precondition 1 — the side-effect equivalence proof — done.** `moves-flag-equivalence.test.ts` runs the same journey under both flag states against live PG and diffs the persisted row. **Result: identical** — whole-row equality across 128 fields plus an equal timing shape, over `todo → in-progress → in-review → todo → in-progress`. That test was **wrong twice** before it meant anything, and both times it was passing: 1. **It proved nothing.** `experimentalFeatures` is **global-only**, and `moves.ts` reads `getSettingsFast()`, which filters global-only keys out of the project layer. My `updateSettings` write was silently discarded, `useWorkflow` was false in *both* runs, and the "proof" compared the legacy path against itself. Found by stamping the flag-ON branch and observing the test still passed. Now written via `updateGlobalSettings`, and the helper **asserts the flag took effect** before the journey runs. 2. **The journey was forward-only**, so it never reached the reopen hook's field resets (`status`, `error`, `blockedBy`, pause clearing) — a mutation there passed. Extended with a backward move and a re-entry. Mutation-verified after both fixes: stamping seam 3, and diverging the reopen hook, each fail the comparison. **Precondition 2 — done, and its answer is a blocker.** The census says the default board is safe: all 41 literal engine move targets are declared by the default lineage. But **20 of those 41 carry no `recoveryRehome`**, so on a custom lineage that does not declare `todo` / `in-progress` / `done`, seam 2 would start rejecting them with unknown-column. That is a user-facing break on custom boards, not a theoretical one, and it is not fixed by the equivalence proof — seam 2 adds *new refusals* rather than swapping implementations. **So the flip is one step away, and the step is not mine to take alone:** those 20 call sites need to resolve their target from the task's workflow (or justify `recoveryRehome`), and they live across engine lanes in `moves.ts` caller territory — U2b/MAIN. Flipping before that trades a dormant flag for broken custom boards. What remains for precondition 3 once those land: flip both readers **atomically** (`moves.ts` + `workflow-task-create-ops.ts`, since the latter computes the preflight the former consumes), delete the flag-OFF branch with its guards, and drop the settings key. --- ## CORRECTION: seam 2 is not a blocker. My earlier claim was wrong. I stated in #2639 and above that "with the flag off there is **no** target-column validation on the move path", so flipping would introduce new refusals. **That is not what happens.** Reproduced against live PG: the identical custom-lineage move rejects with the flag **OFF** as well — ``` Error: Invalid transition: 'backlog' -> 'todo'. Valid targets: building ``` Transition validation is already in force on the flag-OFF path. So for the shape in question — an engine move to a column the task's own workflow does not declare — **the move already fails today**, and seam 2 introduces no new break for it. The 20 census sites lacking `recoveryRehome` are broken on a custom lineage *now*, not broken by the flip. I found this because the discriminator I added to prove "the flag is the cause" failed. Had I written the test to my assumption it would have passed and the false claim would have shipped — the same way the equivalence test passed while proving nothing until I tried to make it fail. **Revised precondition status:** | precondition | status | |---|---| | 1 — side-effect equivalence | **discharged** — identical rows, mutation-verified both directions | | 2 — seam-2 exposure census | **discharged, and it is not a blocker** — the rejection predates the flag | | 3 — flip both readers atomically, delete the flag-OFF branch, drop the settings key | **the remaining work** | So the flip is no longer gated on fixing 20 engine call sites. What it is still gated on is precondition 3 being done atomically across `moves.ts` and `workflow-task-create-ops.ts` (the latter computes the preflight the former consumes), which is `moves.ts` caller territory. Three cases now cover seam 2: the flag-ON rejection, the flag-OFF rejection (asserting the error *message*, so a change in which guard rejects stays visible rather than reading as agreement), and the #1411 `recoveryRehome` carve-out succeeding — pinning why that carve-out is load-bearing and must not be tidied away. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
247 lines
10 KiB
TypeScript
247 lines
10 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { groupByWorktree, getWorktreeLabel } from "../worktreeGrouping";
|
|
import type { Task } from "@fusion/core";
|
|
|
|
function makeTask(overrides: Partial<Task> & { id: string }): Task {
|
|
return {
|
|
description: "",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: "2026-01-01T00:00:00Z",
|
|
updatedAt: "2026-01-01T00:00:00Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("getWorktreeLabel", () => {
|
|
it("extracts last path segment", () => {
|
|
expect(getWorktreeLabel(".worktrees/FN-001")).toBe("FN-001");
|
|
expect(getWorktreeLabel("/path/to/kb/kb-001")).toBe("kb-001");
|
|
});
|
|
|
|
it("extracts humanized worktree names", () => {
|
|
expect(getWorktreeLabel(".worktrees/swirly-monkey")).toBe("swirly-monkey");
|
|
expect(getWorktreeLabel("/tmp/project/.worktrees/quiet-falcon")).toBe("quiet-falcon");
|
|
expect(getWorktreeLabel("C:\\repo\\.worktrees\\quiet-falcon")).toBe("quiet-falcon");
|
|
expect(getWorktreeLabel(".worktrees/bright-orchid-2")).toBe("bright-orchid-2");
|
|
});
|
|
});
|
|
|
|
describe("groupByWorktree", () => {
|
|
it("groups active in-progress tasks by worktree", () => {
|
|
const t1 = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
|
|
const t2 = makeTask({ id: "FN-002", worktree: ".worktrees/quiet-robin" });
|
|
|
|
const groups = groupByWorktree([t1, t2], [t1, t2], 2);
|
|
|
|
expect(groups).toHaveLength(2);
|
|
expect(groups[0].label).toBe("swift-falcon");
|
|
expect(groups[0].activeTasks).toEqual([t1]);
|
|
expect(groups[1].label).toBe("quiet-robin");
|
|
expect(groups[1].activeTasks).toEqual([t2]);
|
|
});
|
|
|
|
it("places queued tasks only in the Up Next group, never in worktree groups", () => {
|
|
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
|
|
const queued = makeTask({
|
|
id: "FN-002",
|
|
column: "todo",
|
|
dependencies: [],
|
|
});
|
|
|
|
const groups = groupByWorktree([active], [active, queued], 2);
|
|
|
|
// Worktree group should have no queued tasks
|
|
const worktreeGroup = groups.find((g) => g.label === "swift-falcon");
|
|
expect(worktreeGroup).toBeDefined();
|
|
expect(worktreeGroup!.queuedTasks).toEqual([]);
|
|
|
|
// Up Next should contain the queued task
|
|
const upNext = groups.find((g) => g.label === "Up Next");
|
|
expect(upNext).toBeDefined();
|
|
expect(upNext!.queuedTasks).toEqual([queued]);
|
|
expect(upNext!.activeTasks).toEqual([]);
|
|
});
|
|
|
|
it("does not create Up Next group when there are no eligible queued tasks", () => {
|
|
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
|
|
|
|
const groups = groupByWorktree([active], [active], 2);
|
|
|
|
expect(groups.find((g) => g.label === "Up Next")).toBeUndefined();
|
|
});
|
|
|
|
it("does not create Up Next when queued tasks have unsatisfied dependencies", () => {
|
|
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
|
|
const blocked = makeTask({
|
|
id: "FN-002",
|
|
column: "todo",
|
|
dependencies: ["FN-003"], // KB-003 doesn't exist or isn't done
|
|
});
|
|
|
|
const groups = groupByWorktree([active], [active, blocked], 2);
|
|
|
|
expect(groups.find((g) => g.label === "Up Next")).toBeUndefined();
|
|
});
|
|
|
|
it("respects maxConcurrent limit on queued tasks shown", () => {
|
|
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
|
|
const q1 = makeTask({ id: "FN-010", column: "todo" });
|
|
const q2 = makeTask({ id: "FN-011", column: "todo" });
|
|
const q3 = makeTask({ id: "FN-012", column: "todo" });
|
|
|
|
const groups = groupByWorktree([active], [active, q1, q2, q3], 2);
|
|
|
|
const upNext = groups.find((g) => g.label === "Up Next");
|
|
expect(upNext).toBeDefined();
|
|
expect(upNext!.queuedTasks).toHaveLength(2);
|
|
});
|
|
|
|
it("places unassigned in-progress tasks in Unassigned group", () => {
|
|
const unassigned = makeTask({ id: "FN-001" }); // no worktree
|
|
|
|
const groups = groupByWorktree([unassigned], [unassigned], 2);
|
|
|
|
expect(groups).toHaveLength(1);
|
|
expect(groups[0].label).toBe("Unassigned");
|
|
expect(groups[0].activeTasks).toEqual([unassigned]);
|
|
});
|
|
|
|
it("excludes paused todo tasks from Up Next", () => {
|
|
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
|
|
const paused = makeTask({
|
|
id: "FN-002",
|
|
column: "todo",
|
|
dependencies: [],
|
|
paused: true,
|
|
});
|
|
const normal = makeTask({
|
|
id: "FN-003",
|
|
column: "todo",
|
|
dependencies: [],
|
|
});
|
|
|
|
const groups = groupByWorktree([active], [active, paused, normal], 2);
|
|
|
|
const upNext = groups.find((g) => g.label === "Up Next");
|
|
expect(upNext).toBeDefined();
|
|
expect(upNext!.queuedTasks.map((t) => t.id)).toEqual(["FN-003"]);
|
|
expect(upNext!.queuedTasks.map((t) => t.id)).not.toContain("FN-002");
|
|
});
|
|
|
|
it("queued tasks with satisfied deps appear in Up Next", () => {
|
|
const done = makeTask({ id: "FN-001", column: "done" });
|
|
const queued = makeTask({
|
|
id: "FN-002",
|
|
column: "todo",
|
|
dependencies: ["FN-001"],
|
|
});
|
|
|
|
const groups = groupByWorktree([], [done, queued], 2);
|
|
|
|
const upNext = groups.find((g) => g.label === "Up Next");
|
|
expect(upNext).toBeDefined();
|
|
expect(upNext!.queuedTasks).toEqual([queued]);
|
|
});
|
|
});
|
|
|
|
/*
|
|
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
|
|
The upcoming-work list must find the HOLD lane by trait, not by the id `todo`.
|
|
|
|
WHY THIS ONE HID. On the default board the id and the role coincide — U11 gave `todo` the
|
|
hold trait — so every existing case here passed and the site looked healthy. Rename the
|
|
hold column and the filter matched nothing: the worktree view showed no upcoming work at
|
|
all and read as idle. A whole panel silently empty, nothing thrown.
|
|
|
|
Board resolves the hold ids across ALL workflows on the board (a card in another
|
|
workflow's hold lane is still upcoming work); Lane passes nothing and keeps the legacy
|
|
fallback, which is why the default case below omits the argument entirely.
|
|
|
|
REVERT CHECK, measured: dropping the parameter back to `t.column === "todo"` fails the
|
|
renamed case with an empty queue.
|
|
*/
|
|
describe("upcoming-work queue resolves the hold lane by trait", () => {
|
|
const mkTask = (id: string, column: string, extra: Record<string, unknown> = {}) =>
|
|
({
|
|
id, title: id, description: "", column, dependencies: [], steps: [], currentStep: 0,
|
|
log: [], createdAt: "2026-07-01T00:00:00.000Z", updatedAt: "2026-07-01T00:00:00.000Z",
|
|
...extra,
|
|
} as never);
|
|
|
|
it("finds waiting cards in a RENAMED hold column when the ids are supplied", () => {
|
|
const waiting = mkTask("FN-50", "backlog");
|
|
const groups = groupByWorktree([], [waiting], 3, new Set(["FN-50"]));
|
|
const queued = groups.flatMap((group) => group.queuedTasks ?? []);
|
|
expect(queued.map((task: { id: string }) => task.id)).toContain("FN-50");
|
|
});
|
|
|
|
it("finds nothing in a renamed hold column when the ids are NOT supplied", () => {
|
|
/*
|
|
Pins the fallback's real limit rather than pretending it covers custom boards: with no
|
|
resolved ids the legacy guess is all there is, and it cannot know about `backlog`. This
|
|
is the case that used to be the ONLY behaviour, on every board.
|
|
*/
|
|
const groups = groupByWorktree([], [mkTask("FN-51", "backlog")], 3);
|
|
const queued = groups.flatMap((group) => group.queuedTasks ?? []);
|
|
expect(queued.map((task: { id: string }) => task.id)).not.toContain("FN-51");
|
|
});
|
|
|
|
it("still finds legacy `todo` cards with no ids supplied, so Lane is unaffected", () => {
|
|
const groups = groupByWorktree([], [mkTask("FN-52", "todo")], 3);
|
|
const queued = groups.flatMap((group) => group.queuedTasks ?? []);
|
|
expect(queued.map((task: { id: string }) => task.id)).toContain("FN-52");
|
|
});
|
|
|
|
it("does not treat a task outside the resolved set as waiting", () => {
|
|
/*
|
|
The narrowing guard: a lookup that ignored its set would pass the first case. It is keyed
|
|
on TASK id, so this also pins the per-workflow scoping — a card whose own workflow does
|
|
not mark its column `hold` is absent from the set even if another workflow reuses the id
|
|
(PR #2625 review).
|
|
*/
|
|
const groups = groupByWorktree([], [mkTask("FN-53", "building")], 3, new Set(["FN-99"]));
|
|
const queued = groups.flatMap((group) => group.queuedTasks ?? []);
|
|
expect(queued.map((task: { id: string }) => task.id)).not.toContain("FN-53");
|
|
});
|
|
});
|
|
|
|
/*
|
|
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (PR #2625 review — greptile):
|
|
TWO WORKFLOWS, ONE COLUMN NAME, DIFFERENT TRAITS — the case that killed the first design.
|
|
|
|
My first version passed a board-wide set of hold COLUMN ids. Column ids are namespaced per
|
|
workflow, so workflow A can declare `staging` as its hold lane while workflow B declares
|
|
`staging` as executing. A unioned id set answers "is `staging` a hold column?" — a question
|
|
with no single answer — and every executing card in workflow B would have been listed under
|
|
Up Next as waiting work. Confidently wrong, which is worse than the renamed-board emptiness
|
|
the change set out to fix.
|
|
|
|
Keying on TASK id moves the decision to the only place that can make it: the caller, which
|
|
knows each task's own workflow. This case is the regression test for that, expressed the way
|
|
the helper now sees it — one card in the set, one not, both in a column called `staging`.
|
|
*/
|
|
describe("hold resolution is scoped per workflow, not per column name", () => {
|
|
const mkTask = (id: string, column: string) =>
|
|
({
|
|
id, title: id, description: "", column, dependencies: [], steps: [], currentStep: 0,
|
|
log: [], createdAt: "2026-07-01T00:00:00.000Z", updatedAt: "2026-07-01T00:00:00.000Z",
|
|
} as never);
|
|
|
|
it("lists only the card whose OWN workflow marks `staging` as hold", () => {
|
|
const waitingInA = mkTask("FN-60", "staging");
|
|
const executingInB = mkTask("FN-61", "staging");
|
|
|
|
// What Board computes: FN-60's workflow declares `staging` hold; FN-61's does not.
|
|
const groups = groupByWorktree([], [waitingInA, executingInB], 3, new Set(["FN-60"]));
|
|
const queued = groups.flatMap((group) => group.queuedTasks ?? []).map((task: { id: string }) => task.id);
|
|
|
|
expect(queued).toContain("FN-60");
|
|
// The assertion the column-id design could not satisfy: same column name, opposite answer.
|
|
expect(queued).not.toContain("FN-61");
|
|
});
|
|
});
|