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>
173 lines
8.0 KiB
TypeScript
173 lines
8.0 KiB
TypeScript
import type { Task, WorkflowStepResult, WorkflowStepPhase, StepStatus } from "@fusion/core";
|
|
|
|
/*
|
|
FNXC:WorkflowSteps 2026-06-25-00:00:
|
|
Graph-native workflow steps (plan U3). Optional workflow step status now comes from graph-written
|
|
`task.workflowStepResults` entries keyed by node id === enabledWorkflowSteps[i]; top-level workflow nodes can also record explicit `source:"node"` progress for workflows that do not project every stage into `task.steps`. The legacy
|
|
`/api/workflow-steps` DB-row name lookup was dropped, so step names resolve from `result.workflowStepName`
|
|
with a fallback to the raw id.
|
|
|
|
Render states (design-lens): the progress model distinguishes
|
|
- `pending` (enabled, never started — no `startedAt`)
|
|
- `running` (graph node active — `pending` status with a `startedAt` and no `completedAt`)
|
|
- `done` (passed)
|
|
- `advisory_failure` (non-blocking REVISE — amber, counts as completed; does not block merge)
|
|
- `failed` (blocking gate failure — red)
|
|
- `skipped`
|
|
Disabled optional steps are simply absent from `enabledWorkflowSteps`, so they never appear in the
|
|
counter/bar. Recorded workflow-node progress is included independently because it represents an actual graph stage that ran, not a toggle placeholder.
|
|
*/
|
|
|
|
export type UnifiedTaskProgressStatus = StepStatus | "failed" | "advisory_failure" | "running";
|
|
|
|
export interface UnifiedTaskProgressItem {
|
|
id: string;
|
|
name: string;
|
|
status: UnifiedTaskProgressStatus;
|
|
source: "step" | "workflow";
|
|
phase: WorkflowStepPhase;
|
|
}
|
|
|
|
export interface UnifiedTaskProgress {
|
|
total: number;
|
|
completed: number;
|
|
items: UnifiedTaskProgressItem[];
|
|
}
|
|
|
|
function mapWorkflowStatus(result: WorkflowStepResult): UnifiedTaskProgressStatus {
|
|
switch (result.status) {
|
|
case "passed":
|
|
return "done";
|
|
case "failed":
|
|
return "failed";
|
|
case "advisory_failure":
|
|
return "advisory_failure";
|
|
case "skipped":
|
|
return "skipped";
|
|
case "pending":
|
|
default:
|
|
// The graph upserts a `pending` entry when a step starts running. A started-but-not-completed
|
|
// entry is the in-progress/`running` display state; a bare `pending` (no `startedAt`) is an
|
|
// enabled step that has not begun yet.
|
|
return result.startedAt && !result.completedAt ? "running" : "pending";
|
|
}
|
|
}
|
|
|
|
function isCompleted(status: UnifiedTaskProgressStatus): boolean {
|
|
// advisory_failure is non-blocking: the step ran and returned feedback, so it counts as completed
|
|
// (overall progress reads complete when only advisory steps returned REVISE).
|
|
return status === "done" || status === "skipped" || status === "advisory_failure";
|
|
}
|
|
|
|
/*
|
|
FNXC:WorkflowStepResults 2026-06-26-16:30:
|
|
An enabled-but-not-yet-run workflow step has no recorded result yet, so there is no
|
|
`workflowStepName` to show. Rather than render the raw graph node id (e.g. `code-review`,
|
|
`browser-verification`), humanize it into a Title Case label ("Code Review",
|
|
"Browser Verification"). Once the graph records the step it carries the workflow's exact
|
|
`config.name`, which always wins; humanization is only the pre-run fallback. The UI must
|
|
show proper casing for workflow steps (e.g. "Code Review"), never the lowercase hyphenated id.
|
|
*/
|
|
function humanizeWorkflowStepId(workflowStepId: string): string {
|
|
const words = workflowStepId
|
|
.replace(/^plugin:/, "")
|
|
.split(/[-_:\s]+/)
|
|
.filter(Boolean);
|
|
if (words.length === 0) return workflowStepId;
|
|
return words
|
|
.map((w) => (/^(ux|ui|qa|ai|api|pr|id)$/i.test(w) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1)))
|
|
.join(" ");
|
|
}
|
|
|
|
function resolveWorkflowStepName(workflowStepId: string, result: WorkflowStepResult | undefined): string {
|
|
const resultName = result?.workflowStepName?.trim();
|
|
if (resultName) {
|
|
return resultName;
|
|
}
|
|
return humanizeWorkflowStepId(workflowStepId);
|
|
}
|
|
|
|
export function getUnifiedTaskProgress(
|
|
task: Pick<Task, "steps" | "enabledWorkflowSteps" | "workflowStepResults">,
|
|
): UnifiedTaskProgress {
|
|
const stepItems: UnifiedTaskProgressItem[] = (task.steps ?? []).map((step, index) => ({
|
|
id: `step-${index}`,
|
|
name: step.name,
|
|
status: step.status,
|
|
source: "step",
|
|
phase: "pre-merge",
|
|
}));
|
|
|
|
const workflowResultsById = new Map(
|
|
(task.workflowStepResults ?? []).map((result) => [result.workflowStepId, result] as const),
|
|
);
|
|
|
|
const workflowItems: UnifiedTaskProgressItem[] = (task.enabledWorkflowSteps ?? []).map((workflowStepId) => {
|
|
const result = workflowResultsById.get(workflowStepId);
|
|
return {
|
|
id: `workflow-${workflowStepId}`,
|
|
name: resolveWorkflowStepName(workflowStepId, result),
|
|
status: result ? mapWorkflowStatus(result) : "pending",
|
|
source: "workflow",
|
|
phase: result?.phase ?? "pre-merge",
|
|
};
|
|
});
|
|
const enabledWorkflowStepIds = new Set(task.enabledWorkflowSteps ?? []);
|
|
/*
|
|
FNXC:TaskCardWorkflowProgress 2026-06-29-15:05:
|
|
Compound Engineering runs top-level skill nodes (Plan, Execute, Commit/PR, Resolve feedback) that do real work but are not optional toggles and do not update `task.steps`. Include recorded `source:"node"` results even when `enabledWorkflowSteps` is empty so task cards and detail progress match the graph's actual active stage, while stale disabled optional-group results remain hidden.
|
|
*/
|
|
const recordedNodeItems: UnifiedTaskProgressItem[] = (task.workflowStepResults ?? [])
|
|
.filter((result) => result.source === "node" && !enabledWorkflowStepIds.has(result.workflowStepId))
|
|
.map((result) => ({
|
|
id: `workflow-${result.workflowStepId}`,
|
|
name: resolveWorkflowStepName(result.workflowStepId, result),
|
|
status: mapWorkflowStatus(result),
|
|
source: "workflow",
|
|
phase: result.phase ?? "pre-merge",
|
|
}));
|
|
|
|
/*
|
|
FNXC:TaskCardWorkflowProgress 2026-06-29-00:41:
|
|
Plan Review is a pre-execution optional step in the default stepwise Coding workflow, so task cards must show it before parsed implementation steps. End-of-work optional steps such as Code Review stay after implementation steps so the card order matches workflow execution order.
|
|
*/
|
|
const preExecutionWorkflowItems = workflowItems.filter((item) => item.id === "workflow-plan-review");
|
|
const remainingWorkflowItems = workflowItems.filter((item) => item.id !== "workflow-plan-review");
|
|
const items = [...preExecutionWorkflowItems, ...stepItems, ...remainingWorkflowItems, ...recordedNodeItems];
|
|
const total = items.length;
|
|
const completed = items.filter((item) => isCompleted(item.status)).length;
|
|
|
|
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.
|
|
*/
|
|
export function isPlanReviewRunning(task: Pick<Task, "steps" | "enabledWorkflowSteps" | "workflowStepResults">): boolean {
|
|
return getUnifiedTaskProgress(task).items.some(
|
|
(item) => item.id === "workflow-plan-review" && item.status === "running",
|
|
);
|
|
}
|