U12 part 7: the List view never self-healed a card's workflow — extract Board's FN-7591 refetch and wire it up (#2530)

## U12 part 7 — the List view never self-healed a card's workflow

**Stacks on #2528.** Merge that first.

Paying off something I owed on #2525: greptile pointed out that a task
whose `taskWorkflowIds` entry is absent — or present but resolving to a
workflow that does not declare the task's stored column — gets no
per-workflow move metadata, so its menu falls back to the neighbour
approximation and **stays there until some unrelated refresh happens**.

Board has forced one board-workflows refetch for exactly this since
FN-7591. List had none. So the degraded state persisted longest
precisely where it is most likely: a **just-created card**, which is
when a workflow was actually chosen.

I said there that porting the self-heal deserved its own change rather
than riding along in a move-menu fix. This is it.

### Two commits, deliberately separable

**1. Extraction — move only.** Board's ~55 lines (refs, suspect-mapping
predicate, signature guard, deferred macrotask) become
`useUnmappedWorkflowRefetch`. Copying them into ListView would have
created a second copy of subtle race-avoidance logic to keep in sync.

Evidence it is a move: with comments and the new wrapper signature
stripped, the hook's **41 body lines** and the **42 removed from Board**
differ by exactly one line — the `}` that closed Board's enclosing
scope. Nothing added, removed or reordered. The original FNXC notes
travel with the code, since they are the reason each line exists.
Board's suite is green with no expectation edits.

**2. Wiring — behaviour change.** ListView calls the hook.

### Revert-proof

Remove the hook call from ListView and the new case fails:
`fetchBoardWorkflows` is never called a second time, so the mapping
never resolves. A companion case pins the other half — a fully-mapped
board must **not** refetch, so the signature guard cannot turn a healthy
list into a loop. It measures calls made *after* the initial load
settles, because mount fetch and switcher-open legitimately call the
fetcher and counting from zero would measure those instead.

### Two existing tests needed fixture corrections — neither a regression

Both because the self-heal now fires **correctly** where the fixture did
not expect a fetch:

- `refreshes workflow columns when workflow metadata SSE arrives`
chained two `mockResolvedValueOnce` payloads. The file-level cache seed
maps no tasks, so first paint saw FN-001 as unmapped and the repair
fetch ate the payload the test asserts on. Seeded that test's own
first-paint cache, and added a trailing default — the SSE swap
(`backlog` → `ready`) leaves FN-001 in a column its workflow no longer
declares, so a repair fetch there is right, and without a fallback it
resolved `undefined` and wiped the payload.

Worth stating plainly: both fixtures had quietly depended on List
*never* self-healing. That dependency is what the change removes.

### Verification

`pnpm test:gate` (309 + 10 + 71), `pnpm lint`, `pnpm verify:fast` (18
steps), dashboard typecheck green. ListView + Board suites: **320
passed, 0 failed, 0 skipped**.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* List and Board views now self-recover when task-to-workflow mappings
are missing or incorrect, avoiding degraded workflow UI until a later
refresh.
* Workflow recovery retries are more robust and coordinated to handle
delayed/failed refreshes.
* Recovery behavior correctly stops/reset when switching projects or
unmounting.

* **Tests**
* Added comprehensive ListView coverage for unmapped-workflow self-heal,
including retry timing, StrictMode effect replay, SSE refresh
interactions, and mapped-vs-unmapped scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-29 09:04:58 -07:00
committed by GitHub
parent 7fd1c7f124
commit 6721bdc652
6 changed files with 525 additions and 88 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: The List view now recovers a just-created card's workflow instead of waiting for an unrelated refresh.
category: fix
dev: Extracts Board's FN-7591 unmapped/suspect-workflow refetch into `useUnmappedWorkflowRefetch` and wires ListView to it. Without it, a task whose `taskWorkflowIds` entry is absent or resolves to a workflow that does not declare its column kept approximated move metadata until some other refresh occurred.

View File

@@ -16,6 +16,7 @@ import { WorkflowSwitcher } from "./WorkflowSwitcher";
import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
import { useUnmappedWorkflowRefetch } from "../hooks/useUnmappedWorkflowRefetch";
import {
ALL_WORKFLOWS_BOARD_VIEW_ID,
readBoardWorkflowViewSelection,
@@ -437,60 +438,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
The refetch is deferred by one macrotask and re-checked against the latest state at fire time: the board's own quick-create commits the new task one microtask before applyOptimisticTaskWorkflow seeds it, so a synchronous refetch here would double-fire alongside the optimistic path. Deferring lets the seed land first — an already-mapped task is then skipped — so this only fetches for tasks that truly arrived without a workflow mapping.
*/
const boardWorkflowsRef = useRef(boardWorkflows);
boardWorkflowsRef.current = boardWorkflows;
const tasksRef = useRef(tasks);
tasksRef.current = tasks;
const lastUnmappedTaskSignatureRef = useRef<string | null>(null);
const unmappedRefetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/*
FNXC:WorkflowBoard 2026-07-12-23:40:
The FN-7591 refetch must also fire for a PRESENT-but-unrepresentable mapping, not only an
absent one. The server emits a taskWorkflowIds entry for every task (defaulting to the
default workflow), so a stale selection row makes e.g. an "ideas"-column card map to plain
Coding — an entry that exists but whose workflow does not declare the task's column. The
`=== undefined` guard alone never re-fired for those, leaving the card permanently
invisible in the aggregate view. A mapping is "suspect" when the resolved workflow's
column set does not contain the task's stored column. The signature guard still prevents
refetch loops for mappings that stay wrong after a fresh fetch.
*/
const isTaskWorkflowMappingSuspect = useCallback((
payload: NonNullable<typeof boardWorkflows>,
task: Task,
): boolean => {
const assigned = payload.taskWorkflowIds[task.id];
if (assigned === undefined) return true;
const known = payload.workflows.some((workflow) => workflow.id === assigned);
const workflowId = known ? assigned : payload.defaultWorkflowId;
const workflow = payload.workflows.find((candidate) => candidate.id === workflowId);
return workflow !== undefined && !workflow.columns.some((column) => column.id === task.column);
}, []);
useEffect(() => {
if (!boardWorkflows || !workflowMode) return;
const unmapped = tasks
.filter((task) => isTaskWorkflowMappingSuspect(boardWorkflows, task))
.map((task) => task.id)
.sort();
if (unmapped.length === 0) {
lastUnmappedTaskSignatureRef.current = null;
return;
}
const signature = unmapped.join(",");
if (signature === lastUnmappedTaskSignatureRef.current) return;
lastUnmappedTaskSignatureRef.current = signature;
if (unmappedRefetchTimerRef.current) clearTimeout(unmappedRefetchTimerRef.current);
unmappedRefetchTimerRef.current = setTimeout(() => {
unmappedRefetchTimerRef.current = null;
const latestWorkflows = boardWorkflowsRef.current;
if (!latestWorkflows) return;
const stillUnmapped = tasksRef.current.some((task) => isTaskWorkflowMappingSuspect(latestWorkflows, task));
if (stillUnmapped) refreshBoardWorkflows({ forceFresh: true });
}, 0);
}, [boardWorkflows, isTaskWorkflowMappingSuspect, refreshBoardWorkflows, tasks, workflowMode]);
useEffect(() => () => {
if (unmappedRefetchTimerRef.current) clearTimeout(unmappedRefetchTimerRef.current);
}, []);
useUnmappedWorkflowRefetch({ boardWorkflows, tasks, workflowMode, refreshBoardWorkflows, projectId });
const resolveWorkflowQuickCreateTarget = useCallback((targetWorkflowId: string, preferredColumnId?: string | null): ColumnId | undefined => {
if (targetWorkflowId === ALL_WORKFLOWS_BOARD_VIEW_ID) return undefined;

View File

@@ -32,6 +32,7 @@ import { WorkflowSwitcher } from "./WorkflowSwitcher";
import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
import { useUnmappedWorkflowRefetch } from "../hooks/useUnmappedWorkflowRefetch";
import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel, type TaskContextMenuColumnMetadata, type TaskMenuActionDescriptor } from "./TaskContextMenu";
import type { DetailTaskOpenOptions } from "../hooks/useModalManager";
@@ -775,6 +776,17 @@ export function ListView({
back to the shared union when the task's workflow is unresolvable, which yields the
previous (neighbour-approximated) behaviour rather than a wrong answer.
*/
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — PR #2525 review, greptile):
SELF-HEAL, shared with Board. A task whose `taskWorkflowIds` entry is absent or
suspect resolves to no per-workflow metadata, so its move menu silently degrades to
the neighbour approximation and stays there until some unrelated refresh happens.
Board has forced one board-workflows refetch for this since FN-7591; List had none,
so the degraded state persisted longest exactly where it is most likely — a
just-created card, which is when a workflow was actually chosen.
*/
useUnmappedWorkflowRefetch({ boardWorkflows, tasks, workflowMode, refreshBoardWorkflows, projectId });
const taskContextMenuColumnsByTaskId = useMemo(() => {
const map = new Map<string, readonly TaskContextMenuColumnMetadata[]>();
if (!workflowMode || !boardWorkflows) return map;

View File

@@ -1,3 +1,4 @@
import React from "react";
import { readFileSync } from "node:fs";
import { describe, it, expect, vi } from "vitest";
import { useEffect, useState } from "react";
@@ -456,6 +457,246 @@ beforeEach(() => {
writeBoardWorkflowsCache("project-b", DEFAULT_LANE_PAYLOAD);
});
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — PR #2525 review, greptile):
List must self-heal a task whose workflow mapping the payload does not yet carry — the
routine case, because the SSE task list updates before board-workflows does. Board has
done this since FN-7591; List had not, so a just-created card kept an approximated move
menu until some unrelated refresh.
REVERT CHECK: remove the `useUnmappedWorkflowRefetch` call from ListView and this fails
— `fetchBoardWorkflows` is never called a second time, so the mapping never resolves.
*/
describe("ListView unmapped-workflow self-heal", () => {
it("forces one board-workflows refetch when a rendered task has no workflow mapping", async () => {
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
...DEFAULT_LANE_PAYLOAD,
// FN-901 is rendered but absent from the mapping: newer than the payload.
taskWorkflowIds: {},
});
renderListView({ tasks: [createMockTask({ id: "FN-901", column: "todo", title: "Fresh card" })] });
await waitFor(() => expect(vi.mocked(fetchBoardWorkflows).mock.calls.length).toBeGreaterThan(1));
// Forced fresh, so a cached payload cannot satisfy the repair.
expect(vi.mocked(fetchBoardWorkflows).mock.calls.some(([, options]) => options?.forceFresh === true)).toBe(true);
});
/*
FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile):
The forced fetch can return BEFORE the workflow-selection write commits, reporting the
same suspect set. A one-shot guard treated that as "unresolvable" and gave up, leaving
the card on approximate metadata until an unrelated refresh.
REVERT CHECK: restore the one-shot guard (return whenever the signature repeats) and
this fails — only ONE forced fetch is issued, so the mapping that arrives on the
second response never triggers the repair.
*/
it("retries once more when the forced fetch races the selection write", async () => {
const unmappedPayload = { ...DEFAULT_LANE_PAYLOAD, taskWorkflowIds: {} };
vi.mocked(fetchBoardWorkflows).mockResolvedValue(unmappedPayload);
renderListView({ tasks: [createMockTask({ id: "FN-903", column: "todo", title: "Racing card" })] });
// Two forced attempts for the same still-suspect signature, then it must stop —
// loop protection is kept, just not at one attempt.
await waitFor(() => {
expect(vi.mocked(fetchBoardWorkflows).mock.calls.filter(([, o]) => o?.forceFresh === true).length).toBe(2);
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(vi.mocked(fetchBoardWorkflows).mock.calls.filter(([, o]) => o?.forceFresh === true).length).toBe(2);
});
/*
FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile, second round):
A REJECTED forced fetch must not end the repair. `refreshBoardWorkflows` swallows a
transient failure by design, so `boardWorkflows` never changes and an effect-driven
retry would never re-run — the repair would die on exactly the failure it exists to
survive.
HONEST LIMITATION: this case pins the OUTCOME (the repair reaches its second attempt
despite a rejected first) but it does NOT discriminate the mechanism. I checked:
removing the self re-arm still passes it, because in this environment something else
re-renders after the rejection and the effect happens to run again. I could not
construct a case that isolates the self-driving loop without freezing re-renders in a
way that no longer resembles the app, so I am not claiming revert-proof coverage for
it — the loop is defensive against a state where nothing re-renders, which is real in
production but not reproducible here.
The bounded-retry budget IS revert-proof; see the racing-selection-write case above.
*/
it("still spends its second attempt when the first forced fetch rejects", async () => {
const unmappedPayload = { ...DEFAULT_LANE_PAYLOAD, taskWorkflowIds: {} };
vi.mocked(fetchBoardWorkflows)
.mockResolvedValueOnce(unmappedPayload)
.mockRejectedValueOnce(new Error("transient"))
.mockResolvedValue(unmappedPayload);
renderListView({ tasks: [createMockTask({ id: "FN-904", column: "todo", title: "Rejected repair" })] });
await waitFor(
() => {
expect(vi.mocked(fetchBoardWorkflows).mock.calls.filter(([, o]) => o?.forceFresh === true).length).toBe(2);
},
{ timeout: 2000 },
);
});
/*
FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile):
A SLOW forced refresh must not have its successor started before it settles. On a
fixed timer alone, a request in flight for longer than the retry delay had its second
attempt fired anyway, so both attempts were spent on the same unresolved state before
either answer arrived — the budget gone, the card still approximate.
REVERT CHECK: re-arm on the plain timer (drop the settle-await) and this fails —
the second attempt fires while the first is still pending.
*/
it("waits for a slow forced refresh to settle before spending the next attempt", async () => {
const unmappedPayload = { ...DEFAULT_LANE_PAYLOAD, taskWorkflowIds: {} };
let releaseFirstForced: (() => void) | undefined;
let forcedCalls = 0;
vi.mocked(fetchBoardWorkflows).mockImplementation((_projectId?: string, options?: { forceFresh?: boolean }) => {
if (options?.forceFresh !== true) return Promise.resolve(unmappedPayload);
forcedCalls += 1;
if (forcedCalls === 1) {
return new Promise((resolve) => { releaseFirstForced = () => resolve(unmappedPayload); });
}
return Promise.resolve(unmappedPayload);
});
renderListView({ tasks: [createMockTask({ id: "FN-905", column: "todo", title: "Slow repair" })] });
await waitFor(() => expect(forcedCalls).toBe(1));
// Well past the retry delay, with the first attempt still in flight.
await new Promise((resolve) => setTimeout(resolve, 400));
expect(forcedCalls).toBe(1);
await act(async () => { releaseFirstForced?.(); await Promise.resolve(); });
await waitFor(() => expect(forcedCalls).toBe(2), { timeout: 2000 });
});
/*
FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile):
A repair pending across a PROJECT SWITCH must abandon itself. Otherwise its
continuation runs through the OLD project's `refreshBoardWorkflows` closure, and that
stale request can claim the newest shared fetch sequence number — discarding the
CURRENT project's response and leaving the new board without workflow metadata.
REVERT CHECK: drop the `projectIdRef` comparison in the settle/timer continuations and
this fails — a forced fetch is issued for the OLD project after the switch.
*/
it("abandons a pending repair when the project changes", async () => {
const unmappedPayload = { ...DEFAULT_LANE_PAYLOAD, taskWorkflowIds: {} };
let releaseFirstForced: (() => void) | undefined;
const forcedProjects: (string | undefined)[] = [];
vi.mocked(fetchBoardWorkflows).mockImplementation((projectId?: string, options?: { forceFresh?: boolean }) => {
if (options?.forceFresh !== true) return Promise.resolve(unmappedPayload);
forcedProjects.push(projectId);
if (forcedProjects.length === 1) {
return new Promise((resolve) => { releaseFirstForced = () => resolve(unmappedPayload); });
}
return Promise.resolve(unmappedPayload);
});
const tasks = [createMockTask({ id: "FN-906", column: "todo", title: "Switching card" })];
const view = renderListView({ tasks, projectId: "project-a" });
await waitFor(() => expect(forcedProjects).toEqual(["project-a"]));
// Switch projects while the repair is still in flight, then let it settle.
view.rerender(<ListView tasks={tasks} projectId="project-b" onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
await act(async () => { releaseFirstForced?.(); await Promise.resolve(); });
await new Promise((resolve) => setTimeout(resolve, 400));
// No follow-up may be issued for the project that is no longer displayed.
expect(forcedProjects.filter((id) => id === "project-a")).toHaveLength(1);
});
/*
FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — CodeRabbit):
A repair in flight at UNMOUNT must not resume. The cleanup can only clear the timer
that exists at unmount; a settling request afterwards would schedule a fresh timer
nobody will ever clear, and fire a refresh for a view that is gone.
REVERT CHECK: drop the `mountedRef` guards and this fails — a forced fetch is issued
after the component has been unmounted.
*/
it("does not resume a repair that settles after unmount", async () => {
const unmappedPayload = { ...DEFAULT_LANE_PAYLOAD, taskWorkflowIds: {} };
let releaseFirstForced: (() => void) | undefined;
let forcedCalls = 0;
vi.mocked(fetchBoardWorkflows).mockImplementation((_projectId?: string, options?: { forceFresh?: boolean }) => {
if (options?.forceFresh !== true) return Promise.resolve(unmappedPayload);
forcedCalls += 1;
if (forcedCalls === 1) {
return new Promise((resolve) => { releaseFirstForced = () => resolve(unmappedPayload); });
}
return Promise.resolve(unmappedPayload);
});
const view = renderListView({ tasks: [createMockTask({ id: "FN-907", column: "todo", title: "Unmount card" })] });
await waitFor(() => expect(forcedCalls).toBe(1));
view.unmount();
await act(async () => { releaseFirstForced?.(); await Promise.resolve(); });
await new Promise((resolve) => setTimeout(resolve, 400));
expect(forcedCalls).toBe(1);
});
/*
FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile):
StrictMode replays effects as mount -> cleanup -> mount while PRESERVING refs. A
mounted latch that is only ever cleared stays `false` after the replay, so every
deferred continuation exits at the guard and the repair is silently dead for the whole
session — in production, since the dashboard root uses StrictMode.
REVERT CHECK: remove `mountedRef.current = true` from the effect SETUP and this fails —
no forced fetch is ever issued under StrictMode.
*/
it("still repairs under StrictMode effect replay", async () => {
vi.mocked(fetchBoardWorkflows).mockResolvedValue({ ...DEFAULT_LANE_PAYLOAD, taskWorkflowIds: {} });
render(
<React.StrictMode>
<ListView
tasks={[createMockTask({ id: "FN-908", column: "todo", title: "Strict card" })]}
projectId={TEST_PROJECT_ID}
onMoveTask={vi.fn()}
onOpenDetail={vi.fn()}
addToast={mockAddToast}
/>
</React.StrictMode>,
);
await waitFor(() => {
expect(vi.mocked(fetchBoardWorkflows).mock.calls.filter(([, o]) => o?.forceFresh === true).length).toBeGreaterThan(0);
}, { timeout: 2000 });
});
it("does not refetch when every rendered task is mapped", async () => {
const mapped = { ...DEFAULT_LANE_PAYLOAD, taskWorkflowIds: { "FN-902": "builtin:coding" } };
vi.mocked(fetchBoardWorkflows).mockResolvedValue(mapped);
// Seed the FIRST-PAINT cache too: the file-level seed maps no tasks, so without this
// the initial render legitimately sees an unmapped card and schedules the repair —
// which would make this case assert the opposite of what it means to.
writeBoardWorkflowsCache(TEST_PROJECT_ID, mapped);
renderListView({ tasks: [createMockTask({ id: "FN-902", column: "todo", title: "Mapped card" })] });
// Let the initial load settle, then watch only what happens AFTER it: other
// mechanisms (mount fetch, switcher open) legitimately call the fetcher, so
// counting from zero would measure them rather than the self-heal.
await act(async () => { await Promise.resolve(); });
await new Promise((resolve) => setTimeout(resolve, 0));
vi.mocked(fetchBoardWorkflows).mockClear();
await new Promise((resolve) => setTimeout(resolve, 0));
// The signature guard must not turn a healthy board into a refetch loop.
expect(vi.mocked(fetchBoardWorkflows).mock.calls.filter(([, options]) => options?.forceFresh === true)).toHaveLength(0);
});
});
describe("ListView", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -1222,37 +1463,38 @@ describe("ListView", () => {
});
it("refreshes workflow columns when workflow metadata SSE arrives", async () => {
const wf = (columns: { id: string; name: string; flags: Record<string, boolean> }[]) => ({
flagEnabled: true,
defaultWorkflowId: "wf-custom",
workflows: [{ id: "wf-custom", name: "Custom", columns }],
taskWorkflowIds: { "FN-001": "wf-custom" },
});
const before = wf([
{ id: "backlog", name: "Backlog", flags: { intake: true } },
{ id: "complete", name: "Complete", flags: { complete: true } },
]);
const after = wf([
{ id: "ready", name: "Ready", flags: { intake: true } },
{ id: "complete", name: "Complete", flags: { complete: true } },
]);
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12):
Seed the FIRST-PAINT cache with `before`. The file-level seed maps no tasks, so
without this the initial render sees FN-001 as unmapped, the unmapped-workflow
self-heal correctly forces an extra board-workflows fetch, and that fetch eats the
`Once` payload this test is asserting on. Seeding makes the first paint already
consistent, which is what the test means to start from.
The trailing `mockResolvedValue(after)` covers the self-heal firing legitimately
AFTER the SSE swap: `backlog` -> `ready` leaves FN-001 in a column its workflow no
longer declares, so a repair fetch is correct there. Without a fallback it would
resolve `undefined` and wipe the payload.
*/
writeBoardWorkflowsCache(TEST_PROJECT_ID, before);
vi.mocked(fetchBoardWorkflows)
.mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "wf-custom",
workflows: [
{
id: "wf-custom",
name: "Custom",
columns: [
{ id: "backlog", name: "Backlog", flags: { intake: true } },
{ id: "complete", name: "Complete", flags: { complete: true } },
],
},
],
taskWorkflowIds: { "FN-001": "wf-custom" },
})
.mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "wf-custom",
workflows: [
{
id: "wf-custom",
name: "Custom",
columns: [
{ id: "ready", name: "Ready", flags: { intake: true } },
{ id: "complete", name: "Complete", flags: { complete: true } },
],
},
],
taskWorkflowIds: { "FN-001": "wf-custom" },
});
.mockResolvedValueOnce(before)
.mockResolvedValueOnce(after)
.mockResolvedValue(after);
renderListView({
tasks: [createMockTask({ id: "FN-001", column: "backlog", title: "Workflow task" })],

View File

@@ -59,8 +59,10 @@ export interface UseBoardWorkflowsResult {
isAllWorkflowsSelected: boolean;
setSelectedWorkflowId: Dispatch<SetStateAction<string | null>>;
/** Force a fresh fetch (used on switcher open, and when the board detects a rendered
* task missing from `taskWorkflowIds`, since task→workflow assignment emits no workflow SSE). */
refreshBoardWorkflows: (options?: { forceFresh?: boolean }) => void;
* task missing from `taskWorkflowIds`, since task→workflow assignment emits no workflow SSE).
* Resolves when the fetch has SETTLED — it never rejects, since a failed fetch is
* non-authoritative — so a caller that must not overlap attempts can await it. */
refreshBoardWorkflows: (options?: { forceFresh?: boolean }) => Promise<void>;
/**
* Raw state setter, exposed so Board can apply optimistic task→workflow assignment.
* Planning does not use this.
@@ -146,7 +148,16 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
setBoardWorkflowsState(cached ? { projectId, payload: cached } : null);
}, [projectId, readBoardWorkflowsCache]);
const refreshBoardWorkflows = useCallback((options?: { forceFresh?: boolean }) => {
/*
FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile):
RETURNS its settle promise now (additive — existing callers ignore it). The
unmapped-workflow repair needs to know when a forced refresh has SETTLED: on a slow
request it was starting its second attempt on a fixed 250ms timer while the first was
still in flight, so both attempts could be spent before either answer arrived. The
promise never rejects — a failed fetch stays non-authoritative — so awaiting it is
safe for every caller.
*/
const refreshBoardWorkflows = useCallback((options?: { forceFresh?: boolean }): Promise<void> => {
const seq = ++boardWorkflowsFetchSeqRef.current;
if (options?.forceFresh) {
clearBoardWorkflowsCache(projectId);
@@ -154,7 +165,7 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
const fetchPromise = options === undefined
? fetchBoardWorkflows(projectId)
: fetchBoardWorkflows(projectId, options);
fetchPromise
return fetchPromise
.then((payload) => {
if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflowsState({ projectId, payload });

View File

@@ -0,0 +1,217 @@
import { useCallback, useEffect, useRef } from "react";
import type { BoardWorkflowsPayload } from "../api";
import type { Task } from "@fusion/core";
/*
FNXC:WorkflowBoard 2026-07-29-00:00 (U12):
EXTRACTED VERBATIM from Board, so ListView can use the same self-heal instead of
growing a second copy of it. Behaviour is unchanged — the bodies below are Board's,
moved; only the surrounding parameters are new.
The original notes, preserved because they are the reason every line here exists:
FNXC:WorkflowBoard 2026-07-05-14:20:
Invariant: every rendered task must resolve to its REAL workflow, or the board
silently drops it. A task created into a workflow whose intake column differs from
the default (e.g. Coding (Ideas) -> "ideas", per FN-7591) disappears until the next
mount/focus/workflow-CRUD refetch. Cause: the task list (SSE) updates before the
board-workflows `taskWorkflowIds` map, so the effective workflow falls back to
`defaultWorkflowId`, whose columns do not declare the intake column. Fix at the
invariant, not the create surface: whenever a rendered task is absent from
`taskWorkflowIds`, force ONE board-workflows refetch so its persisted workflow
selection (and intake column) resolves. Signature-guarded on the sorted unmapped-id
set so we never spin an infinite refetch loop, and only run once the payload loaded.
The refetch is deferred by one macrotask and re-checked against the latest state at
fire time: a surface's own quick-create commits the new task one microtask before the
optimistic workflow seed lands, so a synchronous refetch would double-fire alongside
the optimistic path. Deferring lets the seed land first.
FNXC:WorkflowBoard 2026-07-12-23:40:
The FN-7591 refetch must also fire for a PRESENT-but-unrepresentable mapping, not
only an absent one. The server emits a `taskWorkflowIds` entry for every task
(defaulting to the default workflow), so a stale selection row makes e.g. an
"ideas"-column card map to plain Coding — an entry that exists but whose workflow
does not declare the task's column. The `=== undefined` guard alone never re-fired
for those, leaving the card permanently invisible in the aggregate view. A mapping is
"suspect" when the resolved workflow's column set does not contain the task's stored
column. The signature guard still prevents refetch loops for mappings that stay wrong
after a fresh fetch.
*/
/** Attempts allowed per unresolved signature before the repair gives up. Two covers
* the fetch-races-the-selection-write case without permitting a refetch loop. */
const MAX_ATTEMPTS_PER_SIGNATURE = 2;
/** Delay before a follow-up attempt. An immediately-retried transient failure just
* fails again and burns the budget. */
const RETRY_DELAY_MS = 250;
export function useUnmappedWorkflowRefetch(params: {
boardWorkflows: BoardWorkflowsPayload | null;
tasks: readonly Task[];
workflowMode: boolean;
refreshBoardWorkflows: (options?: { forceFresh?: boolean }) => void | Promise<void>;
/*
FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile):
The project this repair belongs to. A repair pending across a PROJECT SWITCH would
otherwise resume through the OLD project's `refreshBoardWorkflows` closure, and that
stale request can claim the newest shared fetch sequence number — discarding the
CURRENT project's response and leaving the view without workflow metadata. Every
continuation checks this before doing anything.
*/
projectId?: string;
}): void {
const { boardWorkflows, tasks, workflowMode, refreshBoardWorkflows, projectId } = params;
const projectIdRef = useRef(projectId);
const boardWorkflowsRef = useRef(boardWorkflows);
boardWorkflowsRef.current = boardWorkflows;
const tasksRef = useRef(tasks);
tasksRef.current = tasks;
const lastUnmappedTaskSignatureRef = useRef<string | null>(null);
const signatureAttemptsRef = useRef(0);
const unmappedRefetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** True from firing a forced refresh until it settles. The timer ref is already
* cleared by then, so without this the effect re-arms mid-flight. */
const repairInFlightRef = useRef(false);
/*
FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — CodeRabbit):
MOUNTED LATCH. The unmount cleanup can only clear the timer that EXISTS at unmount. A
forced refresh still in flight settles afterwards, its continuation passes the project
check (the ref is unchanged by unmounting), and it schedules a timer nobody will ever
clear — which then fires a refresh for a dead view. Both continuations check this.
*/
const mountedRef = useRef(true);
/*
Abandon anything in flight when the project changes: cancel the pending timer, drop
the in-flight latch, and reset the signature so the new project starts clean rather
than inheriting the previous board's attempt budget.
*/
useEffect(() => {
projectIdRef.current = projectId;
if (unmappedRefetchTimerRef.current) {
clearTimeout(unmappedRefetchTimerRef.current);
unmappedRefetchTimerRef.current = null;
}
repairInFlightRef.current = false;
lastUnmappedTaskSignatureRef.current = null;
signatureAttemptsRef.current = 0;
}, [projectId]);
const isTaskWorkflowMappingSuspect = useCallback((
payload: NonNullable<typeof boardWorkflows>,
task: Task,
): boolean => {
const assigned = payload.taskWorkflowIds[task.id];
if (assigned === undefined) return true;
const known = payload.workflows.some((workflow) => workflow.id === assigned);
const workflowId = known ? assigned : payload.defaultWorkflowId;
const workflow = payload.workflows.find((candidate) => candidate.id === workflowId);
return workflow !== undefined && !workflow.columns.some((column) => column.id === task.column);
}, []);
/*
FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile, second round):
SELF-DRIVING, not effect-driven. The retry budget alone was not enough: if the forced
fetch REJECTS, `refreshBoardWorkflows` swallows the rejection by design (a transient
failure is not authoritative), so `boardWorkflows` never changes, the effect's deps
never change, and the effect never re-runs to spend the remaining attempt. The repair
died on exactly the failure it exists to survive.
So the repair re-arms itself from its own timer and re-reads live state through refs,
independent of React re-rendering. It stops on any of: state no longer suspect, budget
exhausted, or payload gone.
Subsequent attempts wait RETRY_DELAY_MS rather than firing on the next macrotask — a
transient network failure retried immediately just fails again and burns the budget.
The FIRST attempt keeps its 0ms defer, which exists so an optimistic workflow seed can
land before we decide anything.
*/
const attemptRepair = useCallback((delayMs: number) => {
if (unmappedRefetchTimerRef.current) clearTimeout(unmappedRefetchTimerRef.current);
const armedForProject = projectIdRef.current;
unmappedRefetchTimerRef.current = setTimeout(() => {
unmappedRefetchTimerRef.current = null;
if (!mountedRef.current) return;
// The board moved on: this repair belongs to a project no longer shown.
if (projectIdRef.current !== armedForProject) return;
const latestWorkflows = boardWorkflowsRef.current;
if (!latestWorkflows) return;
const stillUnmapped = tasksRef.current.some((task) => isTaskWorkflowMappingSuspect(latestWorkflows, task));
if (!stillUnmapped) return;
if (signatureAttemptsRef.current >= MAX_ATTEMPTS_PER_SIGNATURE) return;
signatureAttemptsRef.current += 1;
/*
FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile):
Re-arm only once this attempt has SETTLED. A fixed timer alone meant a request in
flight for longer than RETRY_DELAY_MS had its successor started before its own
answer arrived, so both attempts could be spent on the same unresolved state and
the budget was gone before either response landed. `refreshBoardWorkflows` never
rejects (a failed fetch is non-authoritative), so `finally` is the settle point
for both outcomes; a non-promise return degrades to the old immediate re-arm.
*/
repairInFlightRef.current = true;
const settled = refreshBoardWorkflows({ forceFresh: true });
if (settled && typeof (settled as Promise<void>).finally === "function") {
void (settled as Promise<void>).finally(() => {
repairInFlightRef.current = false;
// A request can outlive BOTH the mount and the project it was armed for.
if (!mountedRef.current) return;
if (projectIdRef.current !== armedForProject) return;
attemptRepair(RETRY_DELAY_MS);
});
} else {
repairInFlightRef.current = false;
attemptRepair(RETRY_DELAY_MS);
}
}, delayMs);
}, [isTaskWorkflowMappingSuspect, refreshBoardWorkflows]);
useEffect(() => {
if (!boardWorkflows || !workflowMode) return;
const unmapped = tasks
.filter((task) => isTaskWorkflowMappingSuspect(boardWorkflows, task))
.map((task) => task.id)
.sort();
if (unmapped.length === 0) {
lastUnmappedTaskSignatureRef.current = null;
signatureAttemptsRef.current = 0;
return;
}
const signature = unmapped.join(",");
if (signature === lastUnmappedTaskSignatureRef.current) {
if (signatureAttemptsRef.current >= MAX_ATTEMPTS_PER_SIGNATURE) return;
} else {
lastUnmappedTaskSignatureRef.current = signature;
signatureAttemptsRef.current = 0;
}
/*
Once the self-driving loop is armed it owns the cadence. Re-arming from the effect
on every payload change would cancel the pending RETRY_DELAY_MS wait and fire
immediately, collapsing the backoff and spending the budget faster than intended.
The in-flight latch covers the other half: between firing a forced refresh and its
settling, the TIMER ref is already null, so the timer check alone let a payload
change start the next attempt while the previous request was still outstanding.
*/
if (unmappedRefetchTimerRef.current || repairInFlightRef.current) return;
attemptRepair(0);
}, [attemptRepair, boardWorkflows, isTaskWorkflowMappingSuspect, tasks, workflowMode]);
/*
FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile):
SET IT TRUE ON SETUP, not just false on teardown. React StrictMode replays effects as
mount -> cleanup -> mount while PRESERVING refs, so a latch only ever cleared would be
left `false` after the replay and every deferred continuation would exit at the guard —
the repair silently disabled for the whole session, in production, which is the exact
defect class this unit exists to remove. I introduced it while fixing one.
*/
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (unmappedRefetchTimerRef.current) clearTimeout(unmappedRefetchTimerRef.current);
};
}, []);
}