refactor(cutover 3/3): dashboard + changesets — IR-driven lifecycle cutover (#2335)

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)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-19 12:09:12 -07:00
committed by GitHub
parent 3f7c32c95c
commit b2a7425c76
20 changed files with 564 additions and 72 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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()`.

View File

@@ -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.

View File

@@ -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).

View File

@@ -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.

View File

@@ -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))}
</span>
) : 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))}
</span>
) : (
<span className="list-status-badge">-</span>

View File

@@ -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<ColumnId>([
]);
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))}
</span>
)}
{planReviewRunning && isAgentActive && (

View File

@@ -1604,6 +1604,14 @@ export function TaskForm({
</div>
)}
{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).
*/
<div className="model-select-row">
<label htmlFor="review-level" className="model-select-label">{t("taskForm.reviewLabel", "Review")}</label>
<select
@@ -1612,11 +1620,11 @@ export function TaskForm({
onChange={(e) => onReviewLevelChange(e.target.value === "" ? undefined : parseInt(e.target.value, 10))}
disabled={disabled}
>
<option value="">{t("taskForm.reviewDefault", "Default (Auto — triage decides)")}</option>
<option value="">{t("taskForm.reviewDefault", "Default (workflow review steps)")}</option>
<option value="0">{t("taskForm.reviewLevel0", "0 — None")}</option>
<option value="1">{t("taskForm.reviewLevel1", "1 — Plan Only")}</option>
<option value="2">{t("taskForm.reviewLevel2", "2 — Plan and Code")}</option>
<option value="3">{t("taskForm.reviewLevel3", "3 — Full")}</option>
<option value="1">{t("taskForm.reviewLevel1", "1 — Code Review")}</option>
<option value="2">{t("taskForm.reviewLevel2", "2 — Plan + Code")}</option>
<option value="3">{t("taskForm.reviewLevel3", "3 — Plan + Browser + Code")}</option>
</select>
</div>
)}

View File

@@ -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();
}
});

View File

@@ -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<string>();
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)

View File

@@ -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");
});

View File

@@ -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<CapturedScreenshot> {
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<Blob | null>((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) };
}

View File

@@ -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<Task, "steps" | "enabledWorkflowSteps" | "workflowStepResults">,
): 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.

View File

@@ -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

View File

@@ -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<string, unknown> }>;
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<string, unknown>),
nodes: (BENCHMARK_IR as unknown as { nodes: Array<Record<string, unknown>> }).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/);
});
});

View File

@@ -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" };
}

View File

@@ -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<string> {
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<string> {
try {
const ir = await resolveWorkflowIrForTask(store, taskId);
return columnsWithFlag(ir, "intake")[0] ?? "triage";
} catch {
return "triage";
}
}
async function resolveWipColumnForTask(store: TaskStore, taskId: string): Promise<string> {
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>) => 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) {