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>
157 lines
5.5 KiB
TypeScript
157 lines
5.5 KiB
TypeScript
import type { Task } from "@fusion/core";
|
|
import { getPathBasename } from "./pathDisplay";
|
|
import { isHoldColumnRole } from "./columnRoles";
|
|
|
|
export interface WorktreeGroupData {
|
|
label: string;
|
|
activeTasks: Task[];
|
|
queuedTasks: Task[];
|
|
}
|
|
|
|
/**
|
|
* Extract a clean display name from a worktree path.
|
|
* e.g. ".worktrees/FN-001" → "FN-001", "/path/to/fn/fn-001" → "fn-001"
|
|
*/
|
|
export function getWorktreeLabel(worktreePath: string): string {
|
|
return getPathBasename(worktreePath) || worktreePath;
|
|
}
|
|
|
|
/**
|
|
* Topological sort of tasks by dependency order.
|
|
* Mirrors resolveDependencyOrder from @fusion/core but inlined to avoid
|
|
* build alias issues (Vite aliases @fusion/core to types.ts only).
|
|
*/
|
|
function resolveDependencyOrder(tasks: Task[]): string[] {
|
|
const taskMap = new Map(tasks.map((t) => [t.id, t]));
|
|
const ordered: string[] = [];
|
|
const visited = new Set<string>();
|
|
const visiting = new Set<string>();
|
|
|
|
function visit(id: string): void {
|
|
if (visited.has(id)) return;
|
|
if (visiting.has(id)) return;
|
|
visiting.add(id);
|
|
const task = taskMap.get(id);
|
|
if (task) {
|
|
for (const depId of task.dependencies || []) {
|
|
if (taskMap.has(depId)) visit(depId);
|
|
}
|
|
}
|
|
visiting.delete(id);
|
|
visited.add(id);
|
|
ordered.push(id);
|
|
}
|
|
|
|
for (const task of tasks) visit(task.id);
|
|
return ordered;
|
|
}
|
|
|
|
/**
|
|
* Group in-progress tasks by worktree and collect queued todo tasks
|
|
* as visual previews in the "Up Next" group.
|
|
*
|
|
* Queued tasks (eligible "todo" tasks whose dependencies are all satisfied)
|
|
* are always placed in the "Up Next" group — they are never distributed
|
|
* to worktree-specific groups since they have no worktree assignment yet.
|
|
* The number of queued tasks shown is capped at `maxConcurrent`.
|
|
*/
|
|
export function groupByWorktree(
|
|
inProgressTasks: Task[],
|
|
allTasks: Task[],
|
|
maxConcurrent: number,
|
|
/*
|
|
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
|
|
The ids of TASKS whose own column is a hold lane in their own workflow, when the caller
|
|
resolved them.
|
|
|
|
Task ids, not column ids (PR #2625 review — greptile). This helper scans `allTasks`, which
|
|
can span workflows, and column ids are namespaced per workflow — two workflows may both
|
|
declare `staging` with only one of them marking it `hold`. A unioned column-id set cannot
|
|
tell those apart and would list every executing card of the second workflow as waiting.
|
|
Only a card's own workflow can answer "is this waiting?", so the caller answers it per task
|
|
and passes the result.
|
|
|
|
Board resolves this; Lane does not pass it and keeps the legacy-id fallback.
|
|
*/
|
|
holdTaskIds?: ReadonlySet<string>,
|
|
): WorktreeGroupData[] {
|
|
// Separate assigned vs unassigned in-progress tasks
|
|
const assigned = inProgressTasks.filter((t) => t.worktree);
|
|
const unassigned = inProgressTasks.filter((t) => !t.worktree);
|
|
|
|
// Group assigned tasks by worktree
|
|
const worktreeMap = new Map<string, Task[]>();
|
|
for (const task of assigned) {
|
|
const key = task.worktree!;
|
|
const list = worktreeMap.get(key) || [];
|
|
list.push(task);
|
|
worktreeMap.set(key, list);
|
|
}
|
|
|
|
/*
|
|
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
|
|
The filter wants "cards waiting for capacity" — the HOLD role, resolved from the board's
|
|
columns rather than the id `todo`.
|
|
|
|
THE BUG THIS CLOSES, measured rather than assumed: on the default board the id and the
|
|
role coincide (U11 gave `todo` the hold trait), so this looked healthy. On a board whose
|
|
hold column is renamed the filter matched NOTHING, so the worktree view showed no upcoming
|
|
work at all and read as idle — a whole panel silently empty, with nothing failing.
|
|
|
|
Dependency satisfaction below still names terminal ids. That is a separate question from
|
|
the hold role and is left alone deliberately: it needs `complete`/`mergeBlocker`/`archived`
|
|
traits for the DEPENDENCY's column, which is another lookup and another unit of work.
|
|
*/
|
|
// Find queued hold-lane tasks: cards in the hold column with all deps satisfied.
|
|
const taskById = new Map(allTasks.map((t) => [t.id, t]));
|
|
const isWaitingTask = (task: Task): boolean =>
|
|
holdTaskIds ? holdTaskIds.has(task.id) : isHoldColumnRole(undefined, task.column);
|
|
const todoTasks = allTasks.filter(isWaitingTask);
|
|
const eligible = todoTasks.filter((t) =>
|
|
!t.paused &&
|
|
(t.dependencies || []).every((depId) => {
|
|
const dep = taskById.get(depId);
|
|
return dep && (dep.column === "done" || dep.column === "in-review" || dep.column === "archived");
|
|
}),
|
|
);
|
|
|
|
// Order eligible tasks by dependency order
|
|
const orderedIds = resolveDependencyOrder(eligible);
|
|
const orderedEligible = orderedIds
|
|
.map((id) => taskById.get(id))
|
|
.filter((t): t is Task => t !== undefined && eligible.includes(t));
|
|
|
|
// Build groups from worktree map
|
|
const groups: WorktreeGroupData[] = [];
|
|
const worktreeKeys = Array.from(worktreeMap.keys());
|
|
|
|
for (const key of worktreeKeys) {
|
|
groups.push({
|
|
label: getWorktreeLabel(key),
|
|
activeTasks: worktreeMap.get(key)!,
|
|
queuedTasks: [],
|
|
});
|
|
}
|
|
|
|
// Add unassigned group if needed
|
|
if (unassigned.length > 0) {
|
|
groups.push({
|
|
label: "Unassigned",
|
|
activeTasks: unassigned,
|
|
queuedTasks: [],
|
|
});
|
|
}
|
|
|
|
// All eligible queued tasks go into the "Up Next" group (capped at maxConcurrent)
|
|
const queued = orderedEligible.slice(0, maxConcurrent);
|
|
if (queued.length > 0) {
|
|
groups.push({
|
|
label: "Up Next",
|
|
activeTasks: [],
|
|
queuedTasks: queued,
|
|
});
|
|
}
|
|
|
|
return groups;
|
|
}
|