From b2a7425c7670ad1f68ea8a17314db47c432774c7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 19 Jul 2026 12:09:12 -0700 Subject: [PATCH] =?UTF-8?q?refactor(cutover=203/3):=20dashboard=20+=20chan?= =?UTF-8?q?gesets=20=E2=80=94=20IR-driven=20lifecycle=20cutover=20(#2335)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the IR-driven lifecycle cutover: **the workflow IR becomes the single source of truth for task lifecycle.** Node column assignments move cards at runtime, every lifecycle predicate re-keys on column traits instead of literal column ids, and the graph exclusively owns review gates. Plan (the spec for this work): [`docs/plans/2026-07-18-001-refactor-ir-driven-lifecycle-cutover-plan.md`](docs/plans/2026-07-18-001-refactor-ir-driven-lifecycle-cutover-plan.md) ## What changed **IR as runtime authority (R1, R2).** Graph traversal crossing a node column boundary moves the card through the store's trait-hook `moveTask` path, attributed `workflowMoveSource: "workflow-graph"` and emitting `task:column-transition`. This replaces the executor's hardcoded `moveTask(id, "in-review")` merge boundary and its handoff-invariant allowlist. Scheduler, hold/release, self-healing, merger and finalization now key on column traits (`intake`/`hold`/`wip`/`merge-blocker`/`human-review`/`merge`/`complete`/`archived`/`timing`/`abort-on-exit`/`reset-on-entry`/`stall-detection`), with rebound targets resolved per KTD-10. **Single ownership of review gates (R4, R5).** Triage's out-of-graph Plan Review gate is deleted; the graph is the sole author. `pending` step results are CAS-claimed leases with owner and staleness floor (KTD-4), so a crash/restart re-entry can no longer dispatch a second reviewer and silently discard the losing verdict. **Graph ownership is unconditional (R9).** The legacy execute fallback is gone: `maybeExecuteWorkflowGraph` is now `executeWorkflowGraph` returning `void`, `graphCompletion` is a required parameter, and a store that cannot resolve a workflow fails closed rather than silently running nothing. Also deleted, with a tombstone ratchet: `fn_review_step` and its RETHINK/session-rewind machinery, `workflow-cutover.ts`, `workflow-authoritative-driver.ts`, `workflow-parity-observer.ts`, and the `graphCompletionInterceptors` map. **`reviewLevel` becomes a creation-time preset (R6)** writing `enabledWorkflowSteps`, with zero runtime reads. **Upgrade path (R10).** Migration 0026 adds the durable per-node-entry IR pin (KTD-3) and the one-time adoption stamp (KTD-8); `planLegacyAdoption` is the single shared decision run by both the startup sweep and the store-open reconcile, so pre-cutover rows are adopted instead of freezing. A stale-binary guard refuses to open a database migrated by a newer binary. **Operator surfaces (R2, R11).** Four places still closed the column set: the dashboard coerced every ingested task's column through the legacy six-id enum (a card in a custom `Merging` column rendered in **Triage**), `POST /tasks/:id/move` answered 400 for any workflow-defined column, retry/reset/re-engage/unassign/spec-revise used hardcoded move targets, and GitHub issue open/closed mapping literal-compared `done`/`archived`. All now resolve from the task's workflow by trait, each with a legacy fallback so `builtin:coding` is byte-identical. ## Evidence `builtin:coding` keeps its column ids and observable behavior byte-compatible (R8, KTD-7), pinned by a characterization oracle. A new **6-column benchmark acceptance suite** drives a user-authored workflow — `Ideas → Todo → In-progress → In-review → Merging → Done` — asserting the ordered transition trail, single-mover at the hold→wip seam (KTD-2), column-role purity (R12), bounded review cycles from workflow config, and park-in-place on failure (R3). The same fixture is proven **editor-buildable** through the real save-validation path, plus negative cases. Verified locally on this branch, post-rebase: - `pnpm test:gate` — green (engine-core 294/294, pg-gate 126/126, ci-workflow 63/63) - characterization oracle 59/59, tombstones 5/5, 6-column benchmark 11/11 - `tsc --noEmit` clean for `@fusion/core`, `@fusion/engine`, `@fusion/dashboard` (both `tsconfig.json` and `tsconfig.app.json`) ## Known reds - **`executor-task-done-invariant` → "moves a cleanly completed task to in-review via the merge-node boundary"** — red on this branch. A real-Postgres test whose graph re-entry rebounds the card to `in-progress` after `execute()` returns. Not in the merge gate, so it does not gate CI. Honest status: I could **not** verify it green on pristine `main` — running main's tests in this worktree reuses built artifacts and produced obviously polluted results, so I am not claiming "pre-existing". It needs its own look. - **`html2canvas` / FN-8309 — fixed here by deleting dead code.** `packages/dashboard/app/utils/capture-screenshot.ts` imported `html2canvas`, which is not a dependency of `@fusion/dashboard` and is **not in `pnpm-lock.yaml` at all**, so it had never compiled in CI. The file had **zero importers**. Main never caught it because PR Checks runs only on pull requests (main's last PR Checks run was in June) while main's own pushes run just the non-blocking Full Suite — so the required **Typecheck** check was failing on *every* PR against main, including this one. Inherited from `88b0db0f4` (FN-8309). **To restore when the feature lands its dependency properly:** `git checkout 88b0db0f4 -- packages/dashboard/app/utils/capture-screenshot.ts` and add `html2canvas` to `packages/dashboard/package.json` in the same change. - **pg-gate rotating contention** — historically a different file set each run with zero assertion failures. It passed 126/126 on the final run here. Two entries that were on the provisional ledger turned out **not** to be pre-existing and are fixed in this PR: the `workflow-graph-optional-step-fix` replan-cap pair were stale assertions against U3's own contract change (cap-exhausted now *parks* awaiting-approval and reports handled, rather than silently leaving the task in place), and `executor-column-agent-seams` / `executor-fast-mode-workflows` are green. ## Deferred follow-ups - **Graph does not suspend at the ready-for-release seam.** A parked `onNodeEntry` returns `void` and the node executes anyway, so within one walk the card can run In-progress work while still displayed in Todo. The benchmark models the scheduler explicitly for this reason and says so at the seam. Making the graph actually suspend is U4-scope follow-up. - **`needs-replan` reader migration.** Post-U3 the durable write happens at the graph's own `plan-replan` seam, so the workflow *is* the writer and the 14 readers form one coherent graph-owned loop — it is the graph's durable replan signal wearing a legacy name, not un-migrated legacy. The adoption census guard requiring that literal in `executor.ts` is correct and stays. Migrating those readers to a purpose-built run-state signal is a post-cutover naming change with its own risk budget. - **U9b seam-node refinement.** The merge substates (`merging`/`merging-pr`/`merging-fix`) are adopted as `resume-graph` rather than mapped to an exact re-entry node; naming a precise node would require resolving the task's IR, which the adoption module deliberately cannot do. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **New Features** * Workflows now drive task lifecycle columns, transitions, capacity limits, review gates, and completion behavior—including custom workflows. * Tasks created with review levels automatically receive the corresponding workflow review steps. * Legacy in-progress tasks are automatically recovered during upgrades. * Dashboard status badges now show the active workflow step name. * Added safeguards for workflow changes, review ownership, database compatibility, and workflow validation. * **Bug Fixes** * Fixed custom-column rendering and task movement. * Improved merge-boundary handling and completion for workflows without merge steps. * Prevented cards from stalling, moving backward, or exceeding pooled WIP capacity. --------- Co-authored-by: Claude Fable 5 --- .changeset/cutover-delete-fn-review-step.md | 7 + .changeset/dashboard-custom-columns.md | 7 + .changeset/fix-builtin-workflow-lifecycle.md | 7 + .../fix-no-merge-workflow-completion.md | 7 + ...fix-no-selection-default-workflow-drift.md | 7 + .changeset/ir-driven-lifecycle-cutover.md | 7 + .changeset/legacy-adoption-and-ir-pin.md | 7 + .changeset/six-column-merge-boundary.md | 7 + .../dashboard/app/components/ListView.tsx | 10 +- .../dashboard/app/components/TaskCard.tsx | 8 +- .../dashboard/app/components/TaskForm.tsx | 16 +- .../components/__tests__/TaskCard.test.tsx | 5 +- packages/dashboard/app/hooks/useTasks.ts | 15 +- .../__tests__/taskStatusBadgeLabel.test.ts | 17 ++ .../dashboard/app/utils/capture-screenshot.ts | 22 -- packages/dashboard/app/utils/taskProgress.ts | 21 ++ .../app/utils/taskStatusBadgeLabel.ts | 26 +- .../src/__tests__/routes-trait-rekey.test.ts | 238 ++++++++++++++++++ .../dashboard/src/github-tracking-state.ts | 44 +++- .../routes/register-task-workflow-routes.ts | 158 ++++++++++-- 20 files changed, 564 insertions(+), 72 deletions(-) create mode 100644 .changeset/cutover-delete-fn-review-step.md create mode 100644 .changeset/dashboard-custom-columns.md create mode 100644 .changeset/fix-builtin-workflow-lifecycle.md create mode 100644 .changeset/fix-no-merge-workflow-completion.md create mode 100644 .changeset/fix-no-selection-default-workflow-drift.md create mode 100644 .changeset/ir-driven-lifecycle-cutover.md create mode 100644 .changeset/legacy-adoption-and-ir-pin.md create mode 100644 .changeset/six-column-merge-boundary.md delete mode 100644 packages/dashboard/app/utils/capture-screenshot.ts create mode 100644 packages/dashboard/src/__tests__/routes-trait-rekey.test.ts diff --git a/.changeset/cutover-delete-fn-review-step.md b/.changeset/cutover-delete-fn-review-step.md new file mode 100644 index 0000000000..48093f40ce --- /dev/null +++ b/.changeset/cutover-delete-fn-review-step.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Review gates now run only as workflow nodes — the in-session step reviewer is gone. +category: internal +dev: U10/R9 of the IR-driven lifecycle cutover deletes the `fn_review_step` executor tool, its RETHINK git-reset/session-rewind path, the per-step conversation checkpoint map, the deferred reviewer provider-error re-raise channel, and the review-level prompt scaffolding that told the model to call it. Plan/code/browser review are owned exclusively by workflow graph nodes. diff --git a/.changeset/dashboard-custom-columns.md b/.changeset/dashboard-custom-columns.md new file mode 100644 index 0000000000..6e31b29bfb --- /dev/null +++ b/.changeset/dashboard-custom-columns.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Boards built on custom workflows now show and move cards in their own columns. +category: fix +dev: Operator surfaces closed the column set in four places. The dashboard ran every ingested task through `normalizeColumn`, which keeps only the six legacy ids and rewrote everything else to `triage`, so a card in a user-authored column rendered in Triage (new `normalizeColumnId` sanitizes structurally instead). `POST /tasks/:id/move` validated against the `COLUMNS` enum and answered 400 for any workflow-defined column; it now validates against the task's resolved IR and keys worktree allocation on the `wip` trait. Retry / reset / re-engage / unassign / spec-revise moved cards with hardcoded `"todo"`/`"in-progress"`/`"triage"` targets and gated spec revision on `VALID_TRANSITIONS`, all now derived from the task's workflow by trait. GitHub issue open/closed mapping keys on the `complete`/`archived` traits via an injected classifier whose default reproduces the legacy literal mapping. Status badges prefer the running workflow step's IR-declared name over raw engine status tokens. diff --git a/.changeset/fix-builtin-workflow-lifecycle.md b/.changeset/fix-builtin-workflow-lifecycle.md new file mode 100644 index 0000000000..98fd64d184 --- /dev/null +++ b/.changeset/fix-builtin-workflow-lifecycle.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix built-in workflows sending cards backward to Todo and stalling the PR workflow. +category: fix +dev: Unseamed nodes in linear built-ins (`security` in Review-heavy, `design-review` in Design, `review-handoff`/`document` in Compound Engineering) defaulted to the capacity-hold column, so the graph moved live cards back into Todo mid-run; they now inherit the preceding node's column. Separately, the `hold` node kind had no default handler, so every hold node threw "No handler registered" — Pull Request workflow cards died at `await-review`; holds now park in place like `manual-merge-hold`. diff --git a/.changeset/fix-no-merge-workflow-completion.md b/.changeset/fix-no-merge-workflow-completion.md new file mode 100644 index 0000000000..a2cfc2e582 --- /dev/null +++ b/.changeset/fix-no-merge-workflow-completion.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Workflows without a merge step now finish in their completion column instead of stalling one column short. +category: fix +dev: `end` is a graph terminal and never a column destination (KTD-1), so a card only entered the `complete`-trait column when a real node lived there — true for merge-bearing workflows via `post-merge-verification`, false for any no-merge workflow (e.g. Lead Generation stranded in `outreach`, never `converted`, which also blocked its dependents). Adds `advanceNoMergeWorkflowToCompleteColumn` on the executor's completed-disposition branch, keyed on the absence of a merge-orchestration column so merge-bearing workflows are untouched and `done` still requires a confirmed merge. diff --git a/.changeset/fix-no-selection-default-workflow-drift.md b/.changeset/fix-no-selection-default-workflow-drift.md new file mode 100644 index 0000000000..98616d206d --- /dev/null +++ b/.changeset/fix-no-selection-default-workflow-drift.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix tasks with no saved workflow selection being unable to move between columns. +category: fix +dev: Two resolvers disagreed on the no-selection default IR (catalog `builtin:coding` vs the legacy `BUILTIN_CODING_WORKFLOW_IR` constant), so the move-policy preflight signature never matched and the move threw "workflow move policy preflight is stale". Both sides plus `resolveTaskWorkflowIrSync` now share `resolveDefaultWorkflowIr()`. diff --git a/.changeset/ir-driven-lifecycle-cutover.md b/.changeset/ir-driven-lifecycle-cutover.md new file mode 100644 index 0000000000..c21acf99b6 --- /dev/null +++ b/.changeset/ir-driven-lifecycle-cutover.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Your workflow now drives the board — cards move through the columns you defined, not a fixed six. +category: feature +dev: The IR-driven lifecycle cutover makes the workflow IR the single authority over task lifecycle. Node column assignments move cards at runtime through the store's trait-hook `moveTask` path (attributed `workflowMoveSource: "workflow-graph"`, emitting `task:column-transition`), replacing the executor's hardcoded `moveTask(id, "in-review")` merge boundary and its handoff-invariant allowlist. Scheduler, hold/release, self-healing, merger and finalization re-key on column traits (`intake`/`hold`/`wip`/`merge-blocker`/`human-review`/`merge`/`complete`/`archived`/`timing`/`abort-on-exit`/`reset-on-entry`/`stall-detection`) instead of literal column ids, with rebound targets resolved per KTD-10. The graph exclusively owns Plan Review — triage's out-of-graph gate is deleted and `pending` step results are CAS-claimed leases (KTD-4), so duplicate reviewers can no longer race. `reviewLevel` becomes a creation-time preset writing `enabledWorkflowSteps` with zero runtime reads. Graph ownership is unconditional: the legacy execute fallback is deleted, `graphCompletion` is a required parameter, and a store that cannot resolve a workflow fails closed rather than silently running nothing. `builtin:coding` keeps its column ids and observable behavior byte-compatible, pinned by a characterization oracle plus a 6-column benchmark acceptance suite that drives a user-authored workflow Ideas -> Todo -> In-progress -> In-review -> Merging -> Done. diff --git a/.changeset/legacy-adoption-and-ir-pin.md b/.changeset/legacy-adoption-and-ir-pin.md new file mode 100644 index 0000000000..ad7fdc17d7 --- /dev/null +++ b/.changeset/legacy-adoption-and-ir-pin.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Tasks left mid-flight by an older Fusion are now adopted on upgrade instead of sitting stuck. +category: feature +dev: Migration 0026 adds `workflow_ir_pin`/`workflow_ir_pin_node_id`/`workflow_ir_pin_column_id` (KTD-3 durable per-node-entry IR pin) and `legacy_adopted_at` (KTD-8 one-time adoption stamp); SCHEMA_BASELINE_VERSION 0025 -> 0026. `planLegacyAdoption` is the shared decision run by both new consumers — the `adopt-legacy-task-rows` startup step in self-healing (ordered first, emits `task:reconcile-legacy-adoption` / `-unmappable`) and `adoptLegacyTaskRowsOnOpen` in the backend-mode store open path. Adoption stamps only rows it mutates, never disturbs user pauses or preserve gates, and parks unknown statuses `paused` with the status left visible. `assertBinaryNotOlderThanDatabase` refuses to open a database migrated by a newer binary (numeric comparison, unparseable markers ignored). diff --git a/.changeset/six-column-merge-boundary.md b/.changeset/six-column-merge-boundary.md new file mode 100644 index 0000000000..da86bc17c0 --- /dev/null +++ b/.changeset/six-column-merge-boundary.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: A custom Merging column now receives the card at merge instead of being sent to In-review. +category: fix +dev: The workflow graph collapses the merge region into one seam recorded as node `merge`, but that synthetic node hardcoded `column: "in-review"`, so a user-authored workflow placing its merge nodes in a different column (e.g. `Merging`) had the card moved to `in-review` — a column such a workflow need not even declare. The column now derives from the merge-region node actually being entered, falling back to `in-review` so `builtin:coding` stays byte-identical. Caught by the new 6-column benchmark acceptance test. diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index fbe1da8b12..7184df0884 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -22,7 +22,7 @@ import type { ToastType } from "../hooks/useToast"; import { useViewportMode } from "../hooks/useViewportMode"; import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage"; import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection"; -import { getUnifiedTaskProgress, isPlanReviewRunning } from "../utils/taskProgress"; +import { getRunningWorkflowStepLabel, getUnifiedTaskProgress, isPlanReviewRunning } from "../utils/taskProgress"; import { isTaskAgentActive } from "../utils/taskActivity"; import { getTaskStatusBadgeLabel, shouldSuppressPlanningStatusBadge } from "../utils/taskStatusBadgeLabel"; import { isReviewBudgetExhaustedApproval } from "../utils/reviewBudgetApproval"; @@ -69,9 +69,9 @@ type SortField = "title" | "status" | "column" | "retries"; FNXC:MergeQueue 2026-07-15-10:45: List status column used to print raw engine statuses (landing/reviewing). Share the board badge mapper so list and card never diverge. */ -function getTaskStatusLabel(status: string, t: TFunction<"app">): string { +function getTaskStatusLabel(status: string, t: TFunction<"app">, workflowStepLabel?: string): string { if (status === "awaiting-approval") return t("tasks.awaitingApproval", "Awaiting Approval"); - return getTaskStatusBadgeLabel(status, t); + return getTaskStatusBadgeLabel(status, t, workflowStepLabel); } type SortDirection = "asc" | "desc"; @@ -2743,7 +2743,7 @@ export function ListView({ ? t("tasks.reviewBudgetExhausted", "Review budget exhausted") : isTransientPlannerActive ? t("tasks.statusPlanning", "Planning") - : getTaskStatusLabel(visualStatus ?? "", t)} + : getTaskStatusLabel(visualStatus ?? "", t, getRunningWorkflowStepLabel(task))} ) : null} {planReviewRunning && isAgentActive && ( @@ -2976,7 +2976,7 @@ export function ListView({ ? t("tasks.reviewBudgetExhausted", "Review budget exhausted") : isTransientPlannerActive ? t("tasks.statusPlanning", "Planning") - : getTaskStatusLabel(visualStatus ?? "", t)} + : getTaskStatusLabel(visualStatus ?? "", t, getRunningWorkflowStepLabel(task))} ) : ( - diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 1d870703a4..b58700f7e3 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -39,7 +39,7 @@ import { getStalledReviewSignal } from "../utils/taskStalledReview"; import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inReviewStallCopy"; import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../utils/stalePausedReviewCopy"; import { getTaskAgeStalenessCopy, shouldShowTaskAgeStalenessBadge } from "../utils/taskAgeStalenessCopy"; -import { getUnifiedTaskProgress, isPlanReviewRunning } from "../utils/taskProgress"; +import { getRunningWorkflowStepLabel, getUnifiedTaskProgress, isPlanReviewRunning } from "../utils/taskProgress"; import { ACTIVE_STATUSES, isTaskAgentActive } from "../utils/taskActivity"; import { getPrBadgeModifierClass } from "../utils/prBadgeClass"; import { getActiveRuntimeMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming"; @@ -300,8 +300,8 @@ const TIME_INDICATOR_COLUMNS = new Set([ ]); const LIVE_TIME_INDICATOR_POLL_MS = 30_000; -function getTaskStatusLabel(status: string, t: TFunction<"app">): string { - return getTaskStatusBadgeLabel(status, t); +function getTaskStatusLabel(status: string, t: TFunction<"app">, workflowStepLabel?: string): string { + return getTaskStatusBadgeLabel(status, t, workflowStepLabel); } function getDoneCompletionMs(task: Task): number | null { @@ -3082,7 +3082,7 @@ function TaskCardComponent({ ? t("tasks.statusPlanning", "Planning") : visualStatus === "merging-fix" ? t("tasks.statusMergingFix", "Merging fixes…") - : getTaskStatusLabel(visualStatus!, t)} + : getTaskStatusLabel(visualStatus!, t, getRunningWorkflowStepLabel(task))} )} {planReviewRunning && isAgentActive && ( diff --git a/packages/dashboard/app/components/TaskForm.tsx b/packages/dashboard/app/components/TaskForm.tsx index ece02b2089..a1a5267461 100644 --- a/packages/dashboard/app/components/TaskForm.tsx +++ b/packages/dashboard/app/components/TaskForm.tsx @@ -1604,6 +1604,14 @@ export function TaskForm({ )} {onReviewLevelChange && ( + /* + FNXC:ReviewLevelPreset 2026-07-19-10:50 (U8 / R6): + Review is now a CREATION-TIME preset over the workflow's optional review + steps (level -> enabledWorkflowSteps at create), not a runtime signal + triage decides. The default (unset) leaves the workflow's own default-on + steps in place; an explicit optional-step selection always wins over the + level preset (server-side applyReviewLevelPreset). + */
)} diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index 6dc42fe9ed..c4630178cd 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -2390,7 +2390,10 @@ describe("TaskCard", () => { expect(Boolean(badge)).toBe(shouldRender); if (shouldRender) { expect(badge).toHaveTextContent("Reviewing"); - expect(screen.getByText("planning")).toBeDefined(); + // FNXC:StatusBadge 2026-07-19-04:30: U12 — the status badge prefers the running + // workflow step's IR-declared name ("Plan Review") over the raw engine token + // ("planning"); this expectation tracks that intentional cutover behavior. + expect(screen.getByText("Plan Review")).toBeDefined(); } }); diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index 5f1a8e4276..a7c93eb306 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useRef } from "react"; import type { Task, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction, AgentLogEntry } from "@fusion/core"; -import { normalizeColumn } from "@fusion/core"; +import { normalizeColumnId } from "@fusion/core"; import * as api from "../api"; import { subscribeSse } from "../sse-bus"; import { clearCache, readCache, SWR_CACHE_KEYS, SWR_TASKS_MAX_AGE_MS, writeCache } from "../utils/swrCache"; @@ -10,10 +10,21 @@ import { recordResumeEvent } from "../utils/resumeInstrumentation"; const loggedTaskCacheHitProjects = new Set(); const TASK_VIEW_REENTRY_FRESHNESS_MS = SWR_TASKS_MAX_AGE_MS; +/* +FNXC:WorkflowColumns 2026-07-19-2b:05 (U12 / R2 / R11): +Every task the dashboard ingests — initial list, SWR revalidation, and each SSE event — passes +through here, so this one line decided whether custom columns exist in the UI at all. It used +`normalizeColumn`, which keeps only the six legacy ids and rewrites everything else to `triage`: +a card sitting in a user-authored `Merging` column rendered in Triage, and dragging it appeared to +do nothing. The move handler below already worked around this for its own `to` id ("normalizeColumn +alone would drop custom ids"), which fixed the symptom for one event and left the ingest path lossy. +`normalizeColumnId` sanitizes structurally (non-string/empty -> fallback) and passes real ids +through; membership belongs to the task's resolved workflow, not to a client-side enum. +*/ function normalizeTask(task: Task): Task { return { ...task, - column: normalizeColumn((task as Task & { column?: unknown }).column), + column: normalizeColumnId((task as Task & { column?: unknown }).column), dependencies: Array.isArray(task.dependencies) ? task.dependencies : [], steps: Array.isArray(task.steps) ? task.steps : [], log: Array.isArray((task as Task & { log?: unknown }).log) diff --git a/packages/dashboard/app/utils/__tests__/taskStatusBadgeLabel.test.ts b/packages/dashboard/app/utils/__tests__/taskStatusBadgeLabel.test.ts index d31d51d76c..7b9c115f6c 100644 --- a/packages/dashboard/app/utils/__tests__/taskStatusBadgeLabel.test.ts +++ b/packages/dashboard/app/utils/__tests__/taskStatusBadgeLabel.test.ts @@ -34,6 +34,23 @@ describe("getTaskStatusBadgeLabel", () => { expect(getTaskStatusBadgeLabel("merging-fix", t)).toBe("Merging fixes…"); }); + it("keeps merging-fix over a still-running workflow-step label", () => { + // A pre-merge step's running state can survive into a merge-fix retry; the badge must not regress to the step name. + expect(getTaskStatusBadgeLabel("merging-fix", t, "Plan Review")).toBe("Merging fixes…"); + }); + + it("keeps every active-merge status over a still-running workflow-step label", () => { + // The same stale startedAt-without-completedAt step state can survive into the whole merge pipeline. + for (const status of ["merging", "merging-pr", "reviewing", "landing"]) { + expect(getTaskStatusBadgeLabel(status, t, "Code Review")).toBe("Merging…"); + } + }); + + it("lets a running workflow-step label override other statuses", () => { + expect(getTaskStatusBadgeLabel("planning", t, "Plan Review")).toBe("Plan Review"); + expect(getTaskStatusBadgeLabel("needs-replan", t, "Plan Review")).toBe("Plan Review"); + }); + it("maps needs-replan to the operator-facing Replan label", () => { expect(getTaskStatusBadgeLabel("needs-replan", t)).toBe("Replan"); }); diff --git a/packages/dashboard/app/utils/capture-screenshot.ts b/packages/dashboard/app/utils/capture-screenshot.ts deleted file mode 100644 index c15be6fa1c..0000000000 --- a/packages/dashboard/app/utils/capture-screenshot.ts +++ /dev/null @@ -1,22 +0,0 @@ -import html2canvas from "html2canvas"; - -const MAX_SCREENSHOT_BYTES = 2 * 1024 * 1024; - -export interface CapturedScreenshot { - blob: Blob; - previewUrl: string; -} - -/** - * FNXC:ReportPipeline 2026-07-16-10:00: - * Screenshot capture deliberately snapshots only Fusion's app root, never the - * display. This avoids collecting other tabs or operating-system windows. - */ -export async function captureAppScreenshot(): Promise { - const root = document.querySelector("#root"); - if (!root) throw new Error("The dashboard view is not available for capture."); - const canvas = await html2canvas(root as HTMLElement, { scale: 0.75, useCORS: true, logging: false }); - const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.8)); - if (!blob || blob.size > MAX_SCREENSHOT_BYTES) throw new Error("The screenshot is too large. Try a smaller dashboard view."); - return { blob, previewUrl: URL.createObjectURL(blob) }; -} diff --git a/packages/dashboard/app/utils/taskProgress.ts b/packages/dashboard/app/utils/taskProgress.ts index 7008052593..eb6c987760 100644 --- a/packages/dashboard/app/utils/taskProgress.ts +++ b/packages/dashboard/app/utils/taskProgress.ts @@ -140,6 +140,27 @@ export function getUnifiedTaskProgress( return { total, completed, items }; } +/* +FNXC:TaskStatusBadge 2026-07-19-2b:55 (U12 / R2 / R11): +The workflow-step-derived badge label. Operator surfaces used to render raw engine status tokens +("planning", "needs-replan"), which name ENGINE bookkeeping rather than the stage the card is +actually in — and which no user-authored workflow has any reason to recognize. When a workflow step +is running, its own IR-declared name ("Plan Review", "Code Review") is both truer and workflow-owned, +so it takes precedence over the status vocabulary. + +Returns undefined when nothing is running, leaving the status mapping as the fallback. The engine +statuses themselves are unchanged — `needs-replan` remains the graph's durable replan signal; this +only decides what the operator READS. +*/ +export function getRunningWorkflowStepLabel( + task: Pick, +): string | undefined { + const running = getUnifiedTaskProgress(task).items.find( + (item) => item.source === "workflow" && item.status === "running", + ); + return running?.name; +} + /* FNXC:TaskCardPlanReviewBadge 2026-07-11-12:00: FN-7831 requires task cards and list rows to show a distinct "Reviewing" badge only while the optional `plan-review` workflow step is actively running. Reuse the unified progress item status so every board surface follows the same startedAt-without-completedAt semantics as the progress list. diff --git a/packages/dashboard/app/utils/taskStatusBadgeLabel.ts b/packages/dashboard/app/utils/taskStatusBadgeLabel.ts index 95c809ddc3..dc77ec9f8e 100644 --- a/packages/dashboard/app/utils/taskStatusBadgeLabel.ts +++ b/packages/dashboard/app/utils/taskStatusBadgeLabel.ts @@ -22,14 +22,30 @@ export function shouldSuppressPlanningStatusBadge({ export function getTaskStatusBadgeLabel( status: string | null | undefined, t: TFunction<"app">, + /* + FNXC:TaskStatusBadge 2026-07-19-02:55 (U12 / R2 / R11): + Workflow-step state wins over the raw status vocabulary. A card whose Plan Review is running + reads "Plan Review" — the step's own IR-declared name — instead of the engine token "planning" + or "needs-replan". Pass `getRunningWorkflowStepLabel(task)` here; omit it and the legacy status + mapping below is unchanged, so every existing caller keeps its behavior. + */ + workflowStepLabel?: string, ): string { - if (!status) return ""; - if (status === "merging-fix") { - return t("tasks.statusMergingFix", "Merging fixes…"); - } + /* + FNXC:TaskStatusBadge 2026-07-19-09:40: + Every active-merge status ("merging", "merging-pr", "merging-fix", "reviewing", "landing") must + win over a still-running workflow-step label (a pre-merge step's startedAt-without-completedAt + state can survive into the merge pipeline). Checking the status before the workflow-step override + enforces this for every caller (TaskCard, ListView grouped rows, ListView table rows) instead of + relying on per-call-site pre-checks. "merging-fix" keeps its distinct "Merging fixes…" label. + */ if (isActiveMergeStatus(status)) { - return t("tasks.statusMerging", "Merging…"); + return status === "merging-fix" + ? t("tasks.statusMergingFix", "Merging fixes…") + : t("tasks.statusMerging", "Merging…"); } + if (workflowStepLabel) return workflowStepLabel; + if (!status) return ""; /* FNXC:TaskStatusBadge 2026-07-28-00:00: FN-8195 requires the raw engine status "needs-replan" to appear as "Replan" on board cards diff --git a/packages/dashboard/src/__tests__/routes-trait-rekey.test.ts b/packages/dashboard/src/__tests__/routes-trait-rekey.test.ts new file mode 100644 index 0000000000..c8f5beb99f --- /dev/null +++ b/packages/dashboard/src/__tests__/routes-trait-rekey.test.ts @@ -0,0 +1,238 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { parseWorkflowIr, resolveReboundTarget, columnsWithFlag, normalizeColumnId } from "@fusion/core"; +import type { WorkflowIr } from "@fusion/core"; +import "@fusion/core"; // registers the built-in traits the trait resolvers read + +import { decideIssueAction, legacyColumnLifecycleClass } from "../github-tracking-state.js"; +import { sixColumnWorkflowIr } from "../../../engine/src/__tests__/fixtures/six-column-workflow-ir.js"; + +/* +FNXC:WorkflowColumns 2026-07-19-2c:10 (U12 / R2 / R11): +The operator-surface half of the cutover: every dashboard/API decision that used to key on the +closed six-id column enum must key on the TASK'S WORKFLOW instead. + +These are deliberately pure-function tests over the seams U12 re-keyed, not route integration +tests. The value is in the DECISION (which column, which GitHub state, which label), and pinning it +directly is both faster and harder to fool than asserting through an Express stack whose store stub +would have to fake IR resolution anyway. Route wiring is exercised by the existing +`routes-task-*.test.ts` suites. + +The fixture is U11's benchmark IR — the same object its acceptance runner drives — so "the editor +can build it" and "the runner can run it" are statements about ONE artifact, which is what R11 asks +for. +*/ + +const BENCHMARK_IR = sixColumnWorkflowIr(); + +describe("U12: column ids stay open across operator surfaces", () => { + /* + The defect this pins: the dashboard ran every ingested task through `normalizeColumn`, which + keeps only the six legacy ids and rewrites everything else to `triage`. A card in a custom + `merging` column rendered in Triage and dragging it appeared to do nothing. + */ + it("preserves a novel column id instead of coercing it to a legacy one", () => { + expect(normalizeColumnId("merging")).toBe("merging"); + expect(normalizeColumnId("ideas")).toBe("ideas"); + // Structurally unusable values still fall back — that is the only coercion left. + expect(normalizeColumnId("")).toBe("triage"); + expect(normalizeColumnId(undefined)).toBe("triage"); + expect(normalizeColumnId(null, "todo")).toBe("todo"); + }); + + it("resolves lifecycle move targets from the workflow, not from literals", () => { + // Rebound (retry / reset / unassign) targets the hold column... + expect(resolveReboundTarget(BENCHMARK_IR)).toBe("todo"); + // ...execution targets the wip column, and intake is its own thing. + expect(columnsWithFlag(BENCHMARK_IR, "countsTowardWip")[0]).toBe("in-progress"); + expect(columnsWithFlag(BENCHMARK_IR, "intake")[0]).toBe("ideas"); + + /* + The point of deriving rather than hardcoding: a workflow with NO `todo` and NO `triage` still + resolves a real destination. Under the old literals the operator's Retry button either threw + or parked the card in a column the workflow never declared. + */ + const renamed = parseWorkflowIr({ + version: "v2", + name: "renamed-lifecycle", + columns: [ + { id: "inbox", name: "Inbox", traits: [{ trait: "intake" }] }, + { id: "queue", name: "Queue", traits: [{ trait: "hold", config: { release: "capacity" } }] }, + { id: "building", name: "Building", traits: [{ trait: "wip" }] }, + { id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "inbox" }, + { id: "build", kind: "prompt", column: "building", config: { seam: "execute" } }, + { id: "end", kind: "end", column: "shipped" }, + ], + edges: [{ from: "start", to: "build" }, { from: "build", to: "end", condition: "success" }], + } as never) as WorkflowIr; + + expect(resolveReboundTarget(renamed)).toBe("queue"); + expect(columnsWithFlag(renamed, "countsTowardWip")[0]).toBe("building"); + expect(columnsWithFlag(renamed, "intake")[0]).toBe("inbox"); + }); +}); + +describe("U12: GitHub issue state keys on complete/archived traits", () => { + /** Classify by TRAIT, the way the re-keyed caller does, rather than by literal id. */ + const classifyByTrait = (ir: WorkflowIr) => { + const complete = new Set(columnsWithFlag(ir, "complete")); + const archived = new Set(columnsWithFlag(ir, "archived")); + return (columnId: string) => ({ complete: complete.has(columnId), archived: archived.has(columnId) }); + }; + + it("closes the issue when a card reaches a workflow's own complete column", () => { + const classify = classifyByTrait(BENCHMARK_IR); + // The benchmark's complete column happens to be `done`, but the decision is trait-driven: + expect(decideIssueAction("merging", "done", classify)).toEqual({ + action: "close", + stateReason: "completed", + }); + // A move between non-terminal columns is not a GitHub event at all. + expect(decideIssueAction("in-review", "merging", classify)).toBeNull(); + }); + + it("closes on a RENAMED complete column, which the literal mapping missed entirely", () => { + const renamed = parseWorkflowIr({ + version: "v2", + name: "renamed-terminal", + columns: [ + { id: "building", name: "Building", traits: [{ trait: "wip" }] }, + { id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] }, + { id: "dropped", name: "Dropped", traits: [{ trait: "archived" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "building" }, + { id: "end", kind: "end", column: "shipped" }, + ], + edges: [{ from: "start", to: "end" }], + } as never) as WorkflowIr; + const classify = classifyByTrait(renamed); + + expect(decideIssueAction("building", "shipped", classify)).toEqual({ + action: "close", + stateReason: "completed", + }); + expect(decideIssueAction("building", "dropped", classify)).toEqual({ + action: "close", + stateReason: "not_planned", + }); + expect(decideIssueAction("shipped", "building", classify)).toEqual({ + action: "reopen", + stateReason: "reopened", + }); + expect(decideIssueAction("dropped", "shipped", classify)).toEqual({ + action: "reopen", + stateReason: "reopened", + }); + + // The regression this replaces: with the legacy literal classifier, none of it fires. + expect(decideIssueAction("building", "shipped", legacyColumnLifecycleClass)).toBeNull(); + }); + + it("keeps the default workflow byte-identical under the default classifier", () => { + expect(decideIssueAction("in-review", "done")).toEqual({ action: "close", stateReason: "completed" }); + expect(decideIssueAction("done", "archived")).toEqual({ action: "close", stateReason: "completed" }); + expect(decideIssueAction("todo", "archived")).toEqual({ action: "close", stateReason: "not_planned" }); + expect(decideIssueAction("archived", "done")).toEqual({ action: "reopen", stateReason: "reopened" }); + expect(decideIssueAction("done", "todo")).toEqual({ action: "reopen", stateReason: "reopened" }); + expect(decideIssueAction("todo", "in-progress")).toBeNull(); + expect(decideIssueAction("archived", "archived")).toBeNull(); + }); +}); + +/* +FNXC:WorkflowColumns 2026-07-19-2c:20 (U12 / R11 first half): +EDITOR BUILDABILITY. R11 asks that the 6-column benchmark be constructible in the workflow editor, +not merely hand-written as a test fixture. The editor's save path validates through +`parseWorkflowIr` (via `validateWorkflowIrDryRun`), so parsing the benchmark IR and getting back a +usable v2 graph IS the buildability claim — and because U11's runner drives this exact object, the +saved artifact is provably byte-usable by the runtime. +*/ +describe("U12: the 6-column benchmark is editor-buildable (R11)", () => { + it("passes save validation and round-trips its columns, traits, nodes and caps", () => { + const parsed = parseWorkflowIr(BENCHMARK_IR as never) as WorkflowIr & { + columns: Array<{ id: string; traits: Array<{ trait: string }> }>; + nodes: Array<{ id: string; column?: string; config?: Record }>; + edges: Array<{ from: string; to: string; condition?: string; kind?: string }>; + }; + + expect(parsed.version).toBe("v2"); + // Six columns, in the operator's documented order. + expect(parsed.columns.map((c) => c.id)).toEqual([ + "ideas", + "todo", + "in-progress", + "in-review", + "merging", + "done", + ]); + // Each column kept the traits the contract assigns it. + const traitsOf = (id: string) => + parsed.columns.find((c) => c.id === id)!.traits.map((t) => t.trait).sort(); + expect(traitsOf("ideas")).toEqual(["intake"]); + expect(traitsOf("todo")).toEqual(["hold", "reset-on-entry"]); + expect(traitsOf("in-progress")).toEqual(["abort-on-exit", "timing", "wip"]); + expect(traitsOf("in-review")).toEqual(["merge-blocker", "stall-detection"]); + // Merging carries the human gate — the operator contract's settled placement. + expect(traitsOf("merging")).toEqual(["human-review", "merge"]); + expect(traitsOf("done")).toEqual(["complete"]); + + // Review nodes sit in the hold column (pre-release Plan Review) and In-review respectively. + const nodeColumn = (id: string) => parsed.nodes.find((n) => n.id === id)?.column; + expect(nodeColumn("plan-review")).toBe("todo"); + expect(nodeColumn("code-review")).toBe("in-review"); + // The remediation node sits in In-progress — that placement is what makes the REVISE + // round-trip a visible backward column move rather than an in-place retry. + expect(nodeColumn("code-review-remediation")).toBe("in-progress"); + + // The remediation edges survive as rework edges (the only legal cycles). + const reworkEdges = parsed.edges.filter((e) => e.kind === "rework"); + expect(reworkEdges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ from: "plan-replan", to: "plan-review" }), + expect.objectContaining({ from: "code-review-remediation", to: "code-review" }), + ]), + ); + + // Per-node caps are workflow config and survive the round trip. + const configOf = (id: string) => parsed.nodes.find((n) => n.id === id)?.config ?? {}; + expect(configOf("plan-review").maxReworkCycles).toBe(1); + expect(configOf("code-review").maxReworkCycles).toBe(3); + }); + + /* + The negative case R11 asks for. "Node references a missing column" must be REJECTED at save, not + silently accepted and then discovered at runtime when the boundary tries to move a card into a + column that does not exist. + */ + it("rejects a node pointing at a column the workflow does not declare", () => { + const broken = { + ...(BENCHMARK_IR as unknown as Record), + nodes: (BENCHMARK_IR as unknown as { nodes: Array> }).nodes.map((n) => + n.id === "merge-attempt" ? { ...n, column: "nonexistent-column" } : n, + ), + }; + + expect(() => parseWorkflowIr(broken as never)).toThrow( + /references undefined column 'nonexistent-column'/, + ); + }); + + it("still rejects the invalid graph when every column id is novel", () => { + const broken = { + version: "v2", + name: "novel-ids", + columns: [{ id: "inbox", name: "Inbox", traits: [{ trait: "intake" }] }], + nodes: [ + { id: "start", kind: "start", column: "inbox" }, + { id: "work", kind: "prompt", column: "not-declared" }, + { id: "end", kind: "end", column: "inbox" }, + ], + edges: [{ from: "start", to: "work" }, { from: "work", to: "end", condition: "success" }], + }; + expect(() => parseWorkflowIr(broken as never)).toThrow(/references undefined column/); + }); +}); diff --git a/packages/dashboard/src/github-tracking-state.ts b/packages/dashboard/src/github-tracking-state.ts index c746b2343e..ebe7f9d8d7 100644 --- a/packages/dashboard/src/github-tracking-state.ts +++ b/packages/dashboard/src/github-tracking-state.ts @@ -19,36 +19,62 @@ interface TaskMovedEvent { }; }; }; - // #1403: the store's `task:moved` event now carries `ColumnId` (custom column - // ids admitted). These handlers only literal-compare against legacy ids, so a - // string-widened field is safe. + // #1403: the store's `task:moved` event carries `ColumnId` (custom column ids + // admitted). U12 re-keys the decision on the complete/archived traits, so a + // workflow-defined terminal column maps to GitHub state like `done` does. from: string; to: string; } +/* +FNXC:WorkflowColumns 2026-07-19-2b:50 (U12 / R2): +GitHub open/closed state keys on the `complete` and `archived` TRAITS, not the literal ids `done` +and `archived`. A user-authored workflow whose terminal column is called something else never +closed its linked GitHub issue, and a custom archive column never mapped to `not_planned`. + +`classify` is injected rather than resolved here so this stays a pure decision function (the +caller owns IR resolution). Its default reproduces the legacy literal mapping exactly, so every +existing caller and the default workflow are byte-identical. +*/ +export interface ColumnLifecycleClass { + complete: boolean; + archived: boolean; +} + +export const legacyColumnLifecycleClass = (columnId: string): ColumnLifecycleClass => ({ + complete: columnId === "done", + archived: columnId === "archived", +}); + export function decideIssueAction( from: string, to: string, + classify: (columnId: string) => ColumnLifecycleClass = legacyColumnLifecycleClass, ): { action: "close" | "reopen"; stateReason: "completed" | "not_planned" | "reopened" } | null { - if (from === "archived" && to === "done") { + const fromClass = classify(from); + const toClass = classify(to); + + // Un-archiving back into the completed column re-opens the issue. + if (fromClass.archived && toClass.complete) { return { action: "reopen", stateReason: "reopened" }; } - if (to === "done" && from !== "done") { + if (toClass.complete && !fromClass.complete) { return { action: "close", stateReason: "completed" }; } - if (to === "archived") { - if (from === "done") { + if (toClass.archived) { + if (fromClass.complete) { return { action: "close", stateReason: "completed" }; } - if (from !== "archived") { + if (!fromClass.archived) { return { action: "close", stateReason: "not_planned" }; } return null; } - if (from === "done" && to !== "done") { + // Leaving the completed column re-opens the issue. + if (fromClass.complete && !toClass.complete) { return { action: "reopen", stateReason: "reopened" }; } diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 0bbb774cd4..15b7c8cbd7 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -45,6 +45,9 @@ import { isWorkflowColumnsEnabled, resolveWorkflowIrForTask, workflowHasColumn, + columnHasFlag, + columnsWithFlag, + resolveReboundTarget, resolveColumnFlags, TransitionRejectionError, getPlannerInterventionTimeline, @@ -109,6 +112,53 @@ async function clearRebuiltSpecWorkflowPins(store: TaskStore, taskId: string): P } } +/* +FNXC:WorkflowColumns 2026-07-19-2b:30 (U12 / R2 / R11): +IR-derived move targets for the operator lifecycle routes. + +Retry / reset / re-engage all moved the card with a hardcoded `"todo"` or `"in-progress"`. On a +user-authored workflow those ids may not exist at all, so the operator's Retry button either threw +or silently parked the card in a column the workflow never declared. These resolve the destination +from the TASK'S OWN workflow by TRAIT — the rebound target is the `hold` column (falling back to +`intake`), the execution target is the column carrying `wip`. + +Both fall back to the legacy literal when the IR cannot be resolved or declares no columns (v1), +so `builtin:coding` — whose hold column IS `todo` and whose wip column IS `in-progress` — keeps +byte-identical behavior (KTD-7). +*/ +async function resolveReboundColumnForTask(store: TaskStore, taskId: string): Promise { + try { + const ir = await resolveWorkflowIrForTask(store, taskId); + return resolveReboundTarget(ir) ?? "todo"; + } catch { + return "todo"; + } +} + +/* +FNXC:WorkflowColumns 2026-07-19-2b:35 (U12 / R2): +Spec revision rehomes to the workflow's INTAKE column (where specification happens), which is a +different preference from the rebound target above — rebound prefers `hold`, respecify prefers +`intake`. `builtin:coding`'s intake column IS `triage`, so the default path is unchanged. +*/ +async function resolveIntakeColumnForTask(store: TaskStore, taskId: string): Promise { + try { + const ir = await resolveWorkflowIrForTask(store, taskId); + return columnsWithFlag(ir, "intake")[0] ?? "triage"; + } catch { + return "triage"; + } +} + +async function resolveWipColumnForTask(store: TaskStore, taskId: string): Promise { + try { + const ir = await resolveWorkflowIrForTask(store, taskId); + return columnsWithFlag(ir, "countsTowardWip")[0] ?? "in-progress"; + } catch { + return "in-progress"; + } +} + function isArtifactType(value: string): value is ArtifactType { return ARTIFACT_TYPES.has(value as ArtifactType); } @@ -728,7 +778,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork await scopedStore.updateStep(task.id, lastDoneStep.index, "pending"); } - const reengagedTask = await scopedStore.moveTask(task.id, "in-progress", { preserveProgress: true }); + const reengageColumn = await resolveWipColumnForTask(scopedStore, task.id); + const reengagedTask = await scopedStore.moveTask(task.id, reengageColumn, { preserveProgress: true }); await triggerCommentWakeForAssignedAgent(scopedStore, reengagedTask, wake); return { task: reengagedTask, reengaged: true }; } @@ -1617,8 +1668,28 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork try { const { store: scopedStore } = await getProjectContext(req); const { column, preserveProgress } = req.body; - if (!column || !COLUMNS.includes(column as Column)) { - throw badRequest(`Invalid column. Must be one of: ${COLUMNS.join(", ")}`); + /* + FNXC:WorkflowColumns 2026-07-19-2b:15 (U12 / R2 / R11): + Validate against the TASK'S WORKFLOW, not the legacy six-id enum. This endpoint rejected + every workflow-defined column outright — a board built on a custom workflow could not move a + card into its own `Merging` column, the API answered 400 "Must be one of: triage, todo, ...". + That is the closed-enum blocker the cutover exists to remove. + Resolution failure or a v1 (columnless) IR falls back to the legacy set, so the default + workflow and older definitions behave exactly as before. + */ + if (typeof column !== "string" || !column) { + throw badRequest("Invalid column. Expected a non-empty column id."); + } + const moveTargetIr = await resolveWorkflowIrForTask(scopedStore, req.params.id).catch(() => undefined); + const declaresColumns = Array.isArray((moveTargetIr as { columns?: unknown[] } | undefined)?.columns); + const columnIsValid = moveTargetIr && declaresColumns + ? workflowHasColumn(moveTargetIr, column) + : COLUMNS.includes(column as Column); + if (!columnIsValid) { + const allowed = moveTargetIr && declaresColumns + ? ((moveTargetIr as unknown as { columns: Array<{ id: string }> }).columns.map((c) => c.id)) + : [...COLUMNS]; + throw badRequest(`Invalid column. Must be one of: ${allowed.join(", ")}`); } if (preserveProgress != null && typeof preserveProgress !== "boolean") { throw badRequest("preserveProgress must be a boolean"); @@ -1662,7 +1733,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork // already exists, so any prior committed progress survives even // though the on-disk worktree directory is freshly allocated. let allocateWorktree: ((reservedNames: Set) => string | null) | undefined; - if ((column as Column) === "in-progress") { + /* + FNXC:WorkflowColumns 2026-07-19-2b:20 (U12 / R2): + Allocate a worktree when promoting into a WIP column, keyed on the trait rather than the + literal `in-progress` id. A custom workflow's execution column carries `wip` under its own + name, and without this it landed in-progress with a null worktree. + */ + const targetIsWip = moveTargetIr && declaresColumns + ? columnHasFlag(moveTargetIr, column, "countsTowardWip") + : column === "in-progress"; + if (targetIsWip) { const existing = await scopedStore.getTask(req.params.id); if (existing) { const settings = await scopedStore.getSettings(); @@ -2455,6 +2535,12 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork const retryLogSuffix = clearedDeadlockAutoPause ? ", cleared deadlock auto-pause" : ""; if (isMissingWorktreeSessionRetry) { + /* + FNXC:WorkflowColumns 2026-07-19-11:05 (U12 review): + Resolve the rebound destination BEFORE logging so the audit entry reports the real + trait-derived column instead of a hardcoded "todo", which misleads on custom boards. + */ + const reboundColumn = await resolveReboundColumnForTask(scopedStore, req.params.id); await clearRebuiltSpecWorkflowPins(scopedStore, req.params.id); await scopedStore.updateTask(req.params.id, { status: null, @@ -2465,8 +2551,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork ...autoPauseClearPatch, ...buildManualRetryResetPatch({ resetMergeRetries: true }), }); - await scopedStore.logEntry(req.params.id, `Retry requested from dashboard (unusable worktree session-start recovery → todo, preserving progress${retryLogSuffix})`); - const updated = await scopedStore.moveTask(req.params.id, "todo", { preserveProgress: true }); + await scopedStore.logEntry(req.params.id, `Retry requested from dashboard (unusable worktree session-start recovery → ${reboundColumn}, preserving progress${retryLogSuffix})`); + const updated = await scopedStore.moveTask(req.params.id, reboundColumn, { preserveProgress: true }); res.json(updated); return; } @@ -2479,6 +2565,12 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork FNXC:WorkflowRetry 2026-06-29-02:18: Dashboard retry for an in-review execution failure re-enters the workflow graph from parse/execution, so it must clear persisted foreach step-instance pins. Otherwise a stale pin from the failed run makes the retry hit the same parse pin-mismatch immediately. */ + /* + FNXC:WorkflowColumns 2026-07-19-11:05 (U12 review): + Resolve the rebound destination BEFORE logging so the audit entry reports the real + trait-derived column instead of a hardcoded "todo", which misleads on custom boards. + */ + const reboundColumn = await resolveReboundColumnForTask(scopedStore, req.params.id); await clearRebuiltSpecWorkflowPins(scopedStore, req.params.id); await scopedStore.updateTask(req.params.id, { status: null, @@ -2489,10 +2581,10 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork await scopedStore.logEntry( req.params.id, isInReviewExecutionStall - ? `Retry requested from dashboard (stranded in-review execution retry → todo, preserving progress${retryLogSuffix})` - : `Retry requested from dashboard (execution failure in-review → todo, preserving progress${retryLogSuffix})`, + ? `Retry requested from dashboard (stranded in-review execution retry → ${reboundColumn}, preserving progress${retryLogSuffix})` + : `Retry requested from dashboard (execution failure in-review → ${reboundColumn}, preserving progress${retryLogSuffix})`, ); - const updated = await scopedStore.moveTask(req.params.id, "todo", { preserveProgress: true }); + const updated = await scopedStore.moveTask(req.params.id, reboundColumn, { preserveProgress: true }); res.json(updated); return; } @@ -2565,7 +2657,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } await scopedStore.logEntry(req.params.id, "Retry requested from dashboard (stuck kill budget reset)"); - const updated = await scopedStore.moveTask(req.params.id, "todo"); + const reboundColumn = await resolveReboundColumnForTask(scopedStore, req.params.id); + const updated = await scopedStore.moveTask(req.params.id, reboundColumn); res.json(updated); } catch (err: unknown) { if (err instanceof ApiError) { @@ -2644,7 +2737,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork "Task reset by user — all progress cleared, fresh worktree and branch will be allocated", ); - await scopedStore.moveTask(req.params.id, "todo"); + const resetColumn = await resolveReboundColumnForTask(scopedStore, req.params.id); + await scopedStore.moveTask(req.params.id, resetColumn); await clearRebuiltSpecWorkflowPins(scopedStore, req.params.id); let updated = await scopedStore.getTask(req.params.id); if (!updated) { @@ -3494,7 +3588,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } // Move to todo and clear status - const updated = await scopedStore.moveTask(task.id, "todo"); + const reboundColumn = await resolveReboundColumnForTask(scopedStore, task.id); + const updated = await scopedStore.moveTask(task.id, reboundColumn); await scopedStore.updateTask(task.id, { status: undefined, ...(approvedPlanFingerprint ? { approvedPlanFingerprint } : {}), @@ -4248,9 +4343,21 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork // Get current task state const task = await scopedStore.getTask(req.params.id); - // If task is already in triage, skip the transition check and moveTask. - // Just reset for replanning in place. - if (task.column === "triage") { + /* + FNXC:WorkflowColumns 2026-07-19-11:10 (U12 review): + The in-place-reset early return must key on the workflow-resolved intake target, not only + the literal "triage". On a custom board whose intake column isn't "triage" (e.g. "backlog"), + a task already sitting at intake would otherwise fall through to + `canTransition = task.column !== respecifyTarget` === false and be rejected — permanently + blocking spec revision in exactly the column where respecify belongs. The literal "triage" + check is kept alongside so legacy behavior stays byte-identical even if a custom workflow + declares a non-intake column literally named "triage". + */ + const respecifyTarget = await resolveIntakeColumnForTask(scopedStore, task.id); + + // If task is already at its workflow's intake column, skip the transition + // check and moveTask. Just reset for replanning in place. + if (task.column === "triage" || task.column === respecifyTarget) { // Log the revision request await scopedStore.logEntry(task.id, "AI spec revision requested", feedback); @@ -4273,11 +4380,20 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork // #1403: task.column is ColumnId; VALID_TRANSITIONS is keyed by the legacy // closed union. A non-legacy custom column id has no legacy transition row, // so it correctly resolves to "cannot transition" here. + /* + FNXC:WorkflowColumns 2026-07-19-02:40 (U12 / R2): + `VALID_TRANSITIONS` is keyed by the closed legacy enum, so `isColumn(task.column)` was false + for every workflow-defined column and spec revision was unreachable on a custom board — + rejected with "Move task to 'todo' or 'in-progress' first", naming columns that workflow may + not have. Custom workflow columns are always eligible: the already-at-intake case returned + above, and the workflow itself declares the intake column we send the card to, so there is + no legacy table to consult. Legacy columns keep the legacy table. + */ const canTransition = - isColumn(task.column) && VALID_TRANSITIONS[task.column].includes("triage"); + !isColumn(task.column) || VALID_TRANSITIONS[task.column].includes("triage"); if (!canTransition) { throw badRequest( - `Cannot request spec revision for tasks in '${task.column}' column. Move task to 'todo' or 'in-progress' first.`, + `Cannot request spec revision for tasks in '${task.column}' column.`, ); } @@ -4285,7 +4401,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork await scopedStore.logEntry(task.id, "AI spec revision requested", feedback); // Move to triage for replanning - const updated = await scopedStore.moveTask(task.id, "triage"); + const updated = await scopedStore.moveTask(task.id, respecifyTarget); // Remove the existing spec so replanning starts from the task // description and feedback rather than revising stale PROMPT.md content. @@ -5291,7 +5407,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork if (lastDoneStep) { await scopedStore.updateStep(task.id, lastDoneStep.index, "pending"); } - updatedTask = await scopedStore.moveTask(task.id, "in-progress", { preserveProgress: true }); + const prFeedbackColumn = await resolveWipColumnForTask(scopedStore, task.id); + updatedTask = await scopedStore.moveTask(task.id, prFeedbackColumn, { preserveProgress: true }); } const hasActiveSession = Boolean(updatedTask.sessionFile); @@ -5323,7 +5440,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork assigneeUserId: null, status: null, }); - const task = await scopedStore.moveTask(req.params.id, "todo"); + const unassignColumn = await resolveReboundColumnForTask(scopedStore, req.params.id); + const task = await scopedStore.moveTask(req.params.id, unassignColumn); res.json(task); } catch (err: unknown) { if (err instanceof ApiError) {