diff --git a/.changeset/fn-project-running-agents-renamed-board.md b/.changeset/fn-project-running-agents-renamed-board.md new file mode 100644 index 0000000000..b2de3d4fdb --- /dev/null +++ b/.changeset/fn-project-running-agents-renamed-board.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: `fn project` now counts running agents correctly on renamed workflow boards. +category: fix +dev: `runningAgentCount` fed raw task rows to `isRunningAgentTaskShape`, so its internal legacy column fallback applied and any board without the literal `in-progress`/`todo` ids reported 0. The command now resolves each task's workflow IR (cached per workflow) via `enrichRunningAgentTaskShape` before counting. diff --git a/packages/cli/src/commands/project.ts b/packages/cli/src/commands/project.ts index 2e7fe7881a..b189cec2f2 100644 --- a/packages/cli/src/commands/project.ts +++ b/packages/cli/src/commands/project.ts @@ -24,6 +24,8 @@ import { COLUMN_LABELS, type Column, countRunningAgentTasks, + enrichRunningAgentTaskShape, + resolveWorkflowIrForTask, readProjectIdentity, writeProjectIdentity, } from "@fusion/core"; @@ -163,7 +165,23 @@ async function getTaskCounts(projectPath: string): Promise { for (const task of tasks) { counts[task.column] = (counts[task.column] || 0) + 1; } - return { byColumn: counts, runningAgentCount: countRunningAgentTasks(tasks) }; + /* + FNXC:WorkflowLifecycleColumns 2026-07-30-12:20 (Phase B conversion — CLI project counts): + ENRICH before counting. `isRunningAgentTask` reads trait-derived fields and falls back to + the legacy `in-progress` / `in-review` literals when they are absent — so counting raw + rows reported ZERO running agents for a board whose wip column is renamed, in `fn project` + output an operator reads to decide whether the board is busy. + + The dashboard's `project-store-resolver` already enriches for exactly this reason + (FN-8453). This was the remaining unenriched caller: same helper, same pure predicate, one + of two call sites doing it correctly. The `irCache` keeps it one IR read per workflow + rather than per task. + */ + const irCache = new Map>>(); + const enriched = await Promise.all(tasks.map(async (task) => + enrichRunningAgentTaskShape(task, await resolveWorkflowIrForTask(resolvedStore, task.id, irCache)), + )); + return { byColumn: counts, runningAgentCount: countRunningAgentTasks(enriched) }; } catch { // Return empty counts if we can't read the project (not-found, corrupt // store, or lock-retry exhaustion — all fail soft here by design). diff --git a/packages/core/src/__tests__/running-agent-count-requires-enrichment.test.ts b/packages/core/src/__tests__/running-agent-count-requires-enrichment.test.ts new file mode 100644 index 0000000000..47f5f0a11c --- /dev/null +++ b/packages/core/src/__tests__/running-agent-count-requires-enrichment.test.ts @@ -0,0 +1,71 @@ +/* +FNXC:WorkflowLifecycleColumns 2026-07-30-12:40: + +WHY AN UNENRICHED `countRunningAgentTasks` MISCOUNTS A RENAMED BOARD. + +`isRunningAgentTask` reads trait-derived fields (`columnCountsTowardWip`, +`columnIsReviewOrMerge`, `columnTerminalKind`) and falls back to the legacy `in-progress` / +`in-review` literals when they are ABSENT. So the same task list yields different counts +depending on whether the caller enriched first — and on a renamed board the unenriched +answer is zero. + +This pins the mechanism, which is what makes the CLI fix (packages/cli/src/commands/project.ts, +`fn project` output) more than a plausible-looking edit: that caller passed raw rows while the +dashboard's `project-store-resolver` enriched, so an operator checking whether the board was +busy was told "0 running" for a fully occupied renamed board. + +SCOPE, stated rather than implied: this proves the PREDICATE needs enrichment and that +enrichment fixes it. It does NOT drive `fn project` end to end — `getTaskCounts` is private +behind project/central-store machinery, and standing that up would be a mock-the-world shell +(FN-5048) for a three-line change that mirrors an already-reviewed reference implementation. +*/ +import { describe, expect, it } from "vitest"; +import "../builtin-traits.js"; +import type { WorkflowIr } from "../workflow-ir-types.js"; +import { countRunningAgentTasks, enrichRunningAgentTaskShape } from "../live-agent-count.js"; + +/** A workflow whose wip column is `building` — no legacy id anywhere. */ +const RENAMED_IR = { + version: "v2", + id: "custom:renamed", + nodes: [{ id: "start", kind: "start", column: "queued" }, { id: "end", kind: "end", column: "shipped" }], + edges: [{ from: "start", to: "end" }], + columns: [ + { id: "queued", name: "Queued", traits: [{ trait: "hold", config: { release: "capacity" } }] }, + { id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] }, + { id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] }, + ], +} as WorkflowIr; + +const cardsInWip = [ + { id: "FN-1", column: "building", paused: false }, + { id: "FN-2", column: "building", paused: false }, +] as never[]; + +describe("countRunningAgentTasks needs enriched traits on a renamed board", () => { + it("UNENRICHED rows report zero running agents for a fully occupied wip column", () => { + /* The bug, stated as a fact rather than a risk: the legacy fallback compares against + `in-progress`, which this board does not have. */ + expect(countRunningAgentTasks(cardsInWip)).toBe(0); + }); + + it("ENRICHED rows report both cards — enrichment is what fixes it", () => { + const enriched = cardsInWip.map((t) => enrichRunningAgentTaskShape(t, RENAMED_IR)); + expect(countRunningAgentTasks(enriched)).toBe(2); + }); + + it("a DEFAULT-vocabulary board counts the same either way (why this stayed hidden)", () => { + /* The regression floor, and the explanation for the silence: on the built-in vocabulary + the literal fallback happens to be right, so an unenriched caller looks correct + forever and no test notices. */ + const legacy = [{ id: "FN-3", column: "in-progress", paused: false }] as never[]; + expect(countRunningAgentTasks(legacy)).toBe(1); + expect(countRunningAgentTasks(legacy.map((t) => enrichRunningAgentTaskShape(t, RENAMED_IR)))).toBe(0); + }); + + it("does NOT count a card in the renamed COMPLETE column even when enriched", () => { + /* The negative half: enrichment must not turn every card into a running agent. */ + const done = [{ id: "FN-4", column: "shipped", paused: false }] as never[]; + expect(countRunningAgentTasks(done.map((t) => enrichRunningAgentTaskShape(t, RENAMED_IR)))).toBe(0); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx b/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx index 61b69f9b57..91a1d0dc36 100644 --- a/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx @@ -146,7 +146,20 @@ describe("TaskContextMenu shared task action model", () => { expect(todoMoves.map((action) => action.column)).toEqual(["in-progress", "triage", "archived"]); expect(todoMoves.map((action) => action.label)).toEqual(["Move to in-progress", "Move to triage", "Move to archived"]); - const reviewMoves = buildTaskActionMenuModel({ task: makeTask({ column: "in-review" }), t, columnLabel: columnLabel as any }).moveTransitions; + /* + FNXC:WorkflowLifecycleColumns 2026-07-29-14:10 (stale expectation from #2521): + This expected "Back to In Progress" — a display label the PRE-#2521 code hardcoded next to the + `in-progress` literal. #2521 correctly made the label come from the host's `columnLabel`, and + this file's stub is `(column) => column`, so the honest output is the raw id. The old + expectation only ever passed because the label was hardcoded, and it has been RED on main since + #2521 landed. + + Matching the raw id would satisfy the test while proving nothing, so the label function is made + display-like for this case instead: the assertion now fails both if the "Back to" prefix + regresses AND if the label stops routing through `columnLabel`. Strengthened, not relaxed. + */ + const displayLabel = ((column: string) => (column === "in-progress" ? "In Progress" : column)) as any; + const reviewMoves = buildTaskActionMenuModel({ task: makeTask({ column: "in-review" }), t, columnLabel: displayLabel }).moveTransitions; expect(reviewMoves.map((action) => [action.column, action.label])).toEqual([ ["todo", "Move to todo"], ["in-progress", "Back to In Progress"], diff --git a/packages/dashboard/app/hooks/__tests__/useExecutorStats.queued-column-roles.test.ts b/packages/dashboard/app/hooks/__tests__/useExecutorStats.queued-column-roles.test.ts new file mode 100644 index 0000000000..099d1a54f1 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useExecutorStats.queued-column-roles.test.ts @@ -0,0 +1,98 @@ +/* +FNXC:WorkflowLifecycleColumns 2026-07-29-13:10 (evidence for `columnIsIntakeOrHold`): + +`live-agent-count.ts`'s waiting predicate is the last converted site in this program with NO +executable evidence. My own unproven-sites ledger listed it as unreachable because "its consumers +are dashboard-side" — which is a statement about the LANE, not about provability. It has exactly +one consumer, `deriveStatsFromTasks`, and that is an exported pure function, so the narrow seam +FN-5048 asks for is right here. Correcting the ledger rather than leaving the site unproven. + +WHAT THIS PINS. `isWaitingAgentTask` resolves membership as: + + task.columnIsIntakeOrHold ?? (task.column === "triage" || task.column === "todo") + +so the footer's queued total is correct on a renamed or merged board ONLY while flags are supplied +for the card's column. The code says as much in prose: + + "These id fallbacks are REACHABLE, not fixture-only ... A card in such a column then matches no + arm and is counted as neither running nor waiting, so the footer's queued total under-reports + it." + +That is an admitted, operator-visible defect deliberately left unconverted, because converting it +means deciding what an ABSENT flag set should mean and either choice moves a visible count. The +last case below is therefore a CHARACTERIZATION test: it asserts the undercount as it exists today +so the admission is executable instead of a comment, and so the number cannot drift further +without a test turning red. It is not an endorsement — if the fallback is ever converted, that case +is expected to change, and the comment explains what to change it to. +*/ +import { describe, it, expect } from "vitest"; +import type { Task } from "@fusion/core"; +import { deriveStatsFromTasks } from "../useExecutorStats"; + +type Flags = Parameters[3] extends ReadonlyMap ? F : never; + +function card(id: string, column: string): Task { + return { id, column, description: `card ${id}`, title: `card ${id}` } as unknown as Task; +} + +/** A renamed board: no id overlaps the legacy enum, so a literal fallback goes silent here. */ +const RENAMED_HOLD = "backlog"; +/** The U11 merged lane: one column carrying BOTH intake and hold. */ +const MERGED_LANE = "planning"; + +describe("footer queued count resolves the intake/hold ROLE, not the legacy column ids", () => { + it("counts a card in a RENAMED hold lane as queued when flags are supplied", () => { + const flags = new Map([[RENAMED_HOLD, { hold: true } as Flags]]); + + const stats = deriveStatsFromTasks([card("FN-Q-1", RENAMED_HOLD)], undefined, undefined, flags); + + expect(stats.queuedTaskCount).toBe(1); + }); + + it("counts a card in the MERGED intake+hold lane exactly ONCE", () => { + /* The merged shape's specific hazard: the predicate is `intake === true || hold === true`, and + both are true here. An implementation that added a count per matching role rather than per + card would double-count every card on the post-U11 default board. */ + const flags = new Map([[MERGED_LANE, { intake: true, hold: true } as Flags]]); + + const stats = deriveStatsFromTasks([card("FN-Q-2", MERGED_LANE)], undefined, undefined, flags); + + expect(stats.queuedTaskCount).toBe(1); + }); + + it("does NOT count a card whose resolved lane is neither intake nor hold", () => { + /* The differential. Without it, every assertion above would also pass for a predicate that + counted all cards — which is how this guard could go dead while looking covered. */ + const flags = new Map([["building", { countsTowardWip: true } as Flags]]); + + const stats = deriveStatsFromTasks([card("FN-Q-3", "building")], undefined, undefined, flags); + + expect(stats.queuedTaskCount).toBe(0); + }); + + it("counts a legacy-id card with no flags at all, via the documented fallback", () => { + /* The fallback's INTENDED use: an unresolved column on the legacy board still counts. This is + the behaviour the fallback exists to preserve, so it is pinned separately from the defect + below — otherwise a conversion could delete both and only one test would notice. */ + const stats = deriveStatsFromTasks([card("FN-Q-4", "todo")], undefined, undefined, undefined); + + expect(stats.queuedTaskCount).toBe(1); + }); + + it("CHARACTERIZATION — under-reports a RENAMED hold lane when no flags are supplied", () => { + /* + The admitted defect, made executable. `columnIsIntakeOrHold` is undefined with no flags, so the + `??` falls through to the legacy pair, which a renamed board does not contain: the card is + counted as neither running nor waiting and the operator's queued total is short by one. + + Asserting the WRONG-but-current number deliberately. If the fallback is converted to resolve + the role (or to treat an absent flag set as intake), this expectation becomes 1 and this test + is the one that tells you the operator-visible count moved. + */ + const stats = deriveStatsFromTasks([card("FN-Q-5", RENAMED_HOLD)], undefined, undefined, undefined); + + expect(stats.queuedTaskCount).toBe(0); + // ...and it is not silently absorbed into another bucket either — it vanishes from all of them. + expect(stats.runningTaskCount).toBe(0); + }); +});