ci: enforce the lifecycle-column ratchet — it has never actually run (#2654)

**The ratchet was advisory.** `scripts/lifecycle-column-census.mjs`
existed only as `pnpm census:lifecycle-columns` — without `--strict` —
and **no workflow invoked it**. Nothing has ever compared the tree to
the baseline. Every "the baseline ratchet holds them" assumption in this
program rested on a check that does not run.

That explains both classes of hole:

**1. Three PRs lowered counts without re-recording,** leaving allowances
the deleted guards could return through while every check stayed green.
I've tightened them across #2593 and earlier PRs, but nothing stops the
next one.

**2. #2621 GREW the count while its own title claimed "count 0 → 0".**
It added `column === "triage"` and `column === "todo"` at
`register-task-workflow-routes.ts:2681`, taking that file to **23
against an allowance of 22**. It landed unchallenged. This is the
failure mode the ratchet exists to prevent, and it happened *inside this
program*, in a PR that asserted the opposite.

## The change

Adds `check:lifecycle-columns` (the census with `--strict`) to the
`pr-checks.yml` lint job, next to `check:changesets` and
`check:routes-modular` — the established pattern. **~1.8s over ~1950
files**, so this is not a slow-test addition.

## Proven to fail, in both directions

A guard that reports success without checking anything is worse than no
guard, so:

| injected defect | result |
|---|---|
| `const __probe = (c: string) => c === "triage"` added to `moves.ts` |
`count ROSE — moves.ts: 39 -> 40`, exit 1 |
| run against main's current baseline | exit 1 on
`mission-feature-sync.ts: allows 5, tree has 0` |

Both reverted; exit 0 restored. Note the second row: **this check is RED
on main right now**, which is the point.

## Merge order

**Stacked on #2593**, which carries the `DELIBERATE-LITERAL` marker for
the #2621 site (a v1 IR declares no roles, so no trait can answer that
question) plus the baseline re-record. Standalone on main this PR is red
— correctly. **Merge #2593 first**, then this.

I stacked rather than duplicating those two edits because I already
caused one conflict today by appending related content from two
branches, and #2651 merged a correction ahead of the section it
corrected. Same-content edits in two PRs is the same mistake.

## Census

Unchanged by this PR: **776 total, triage 5, reviewed 16** — it adds no
guards and converts none. It only makes the numbers enforceable.

## For the fleet

This should land before the 776-guard fleet launches. The brief says
"the baseline ratchet must shrink by exactly the converted count" —
until now nothing verified that claim, so a batch worker could report a
shrink that did not happen, or grow the count while converting, and CI
would agree.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-30 00:20:31 -07:00
committed by GitHub
parent 642a4fa264
commit 2771408bba
10 changed files with 410 additions and 26 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: A stuck planner's approved plan is recovered again instead of being discarded and re-planned from scratch.
category: fix
dev: U11 (#2515) audit. Main now resolves the intake lane for recovery, which fixed merged/renamed workflows and silently broke cards still SITTING in the legacy `triage` column — the migration population U11 re-homing has not reached. `recoverApprovedTask` gated on `task.column !== "triage"`, so after the Planning merge it refused every default-workflow card and the approved spec was discarded — the stale-planning sweeps cleared the status and ordinary discovery re-planned the card, burning a fresh LLM pass on the exact path FN-1312 built to avoid that. Now accepts the task's resolved INTAKE column OR the legacy `triage` id: additive, so cards still awaiting U11 re-homing keep recovering too. Intake-only scope preserved, not widened.

View File

@@ -50,6 +50,13 @@ jobs:
- name: Dashboard route modularity
run: pnpm check:routes-modular
# The lifecycle-column ratchet was advisory until now: the census existed only as
# `pnpm census:lifecycle-columns` (no --strict) and nothing ran it, so three PRs
# lowered counts without re-recording and left allowances the deleted guards could
# return through while this gate stayed green. ~2s over ~1950 files.
- name: Lifecycle-column ratchet
run: pnpm check:lifecycle-columns
typecheck:
name: Typecheck
runs-on: ubuntu-latest

View File

@@ -252,6 +252,47 @@ undeclared column on purpose (the path that rescues already-stranded cards). Plu
asserting the compatibility flag really is unset, so the suite fails loudly if that ever changes
rather than silently testing a different code path.
## Coding (Ideas): the U11 merge is already applied — and collapsing `ideas` is a different change
Recorded 2026-07-29, because "coding-ideas IR merge" stayed on the owed list after it had shipped,
and the remaining half of that request is not the same kind of change as the one it shipped.
**The discipline is applied.** `builtin-coding-ideas-workflow-ir.ts` already declares
`{ id: "todo", name: "Planning", traits: [hold(capacity), reset-on-entry] }`, and the node re-home
loop no longer places planning nodes at all — the comment at the loop says why:
> The explicit planning-node re-home is GONE: the cloned default graph is itself plan-in-place now,
> so plan / plan-review / plan-replan already declare "todo".
So this preset plans in place in a column named "Planning", exactly like the default lineage. What
remains undone is only the collapse of the separate `ideas` intake into that column.
**Why that collapse is a product decision, not a conversion.** `builtin-workflows.ts` states the
preset's whole purpose: it "adds a manual 'Ideas' intake in front of the default stepwise pipeline
... from there the graph is identical to the default Coding workflow." The Ideas inbox
(`intake` with `autoTriage: false`) is the only thing distinguishing this preset from the default.
Merging it into `todo` does not simplify the lifecycle — it makes the preset a duplicate of the
default workflow with one trait config changed, so the honest form of that change is "delete the
Coding (Ideas) preset", which is an operator call about a shipped board layout.
**One concrete consequence, stated at its real size.** `isUnplannedStartCreate` in
`task-store/task-creation.ts` discriminates with `task.column !== intakeFacts.intake &&
task.column === intakeFacts.hold` — a card created DIRECTLY into the hold column of a
manual-intake workflow, bypassing intake. If `ideas` and `todo` become one column then
`intake === hold` and that conjunction is unsatisfiable, so the arm becomes dead.
It is NOT a correctness regression: the sibling arm (`task.column === resolvedEntryColumn`) still
classifies the card as intake, so it still receives the bootstrap prompt rather than a generated
spec prompt. I checked that specifically, having first assumed it was a live break — the difference
matters, because "this gate silently stops firing" would block the merge and "this arm becomes
dead code" merely means deleting it in the same change.
**If the collapse proceeds**, the checklist is: delete the `ideas` column and its `start`-node
anchor, repoint `start` to `todo`, add `intake` with `autoTriage: false` to `todo`'s traits, delete
the now-dead `isUnplannedStartCreate` arm, and rely on the U9b legacy-adoption sweep
(`reconcileUndeclaredTaskColumns`) to re-home cards resting in `ideas` — the same mechanism that
carries `triage` rows through the default lineage's merge.
### CORRECTION (same day): the collapse is NOT mechanical — it is contradictory
I implemented the checklist above, ran the suites, and it does not work. Recording the disproof

View File

@@ -19,6 +19,7 @@
"check:line-count": "node scripts/check-file-line-count.mjs",
"check:routes-modular": "node scripts/check-routes-modular.mjs",
"check:changesets": "node scripts/check-changeset-format.mjs",
"check:lifecycle-columns": "node scripts/lifecycle-column-census.mjs --strict",
"census:lifecycle-columns": "node scripts/lifecycle-column-census.mjs",
"check:quarantine-ledger": "node scripts/check-quarantine-ledger.mjs",
"check:mock-completeness": "node scripts/check-mock-completeness.mjs",

View File

@@ -1003,6 +1003,11 @@ function TaskCardComponent({
than scattered through the file. When flags are present — which is every card on a
loaded board whose column its workflow declares — the traits decide and the U11 merge
is a non-event. The fallback retires with the load window, not with this change.
FNXC:WorkflowLifecycleColumns 2026-07-29-23:40 DELIBERATE-LITERAL: the fallback arm only.
The trait path above is the live answer; this arm runs ONLY when the board has no resolved flags,
and in that state there is nothing to resolve FROM. Deleting it does not remove a guard, it picks a
different guess ("not intake") and silently drops planning affordances during first paint.
*/
const isIntakeColumn = taskColumnFlags
? taskColumnFlags.intake === true
@@ -2488,6 +2493,11 @@ function TaskCardComponent({
the single fallback documented at the role helpers above.
*/
const targetFlags = taskMoveColumns?.find((candidate) => candidate.id === column)?.flags;
/*
FNXC:WorkflowLifecycleColumns 2026-07-29-23:40 DELIBERATE-LITERAL: the fallback arm only. Guessing "not
pre-implementation" here skips the preserve-progress PROMPT, and losing completed steps is
unrecoverable — the safe degraded answer is the legacy one. Reason in full above.
*/
const targetIsPreImplementation = targetFlags
? targetFlags.intake === true || targetFlags.hold === true
: column === "todo" || column === "triage";

View File

@@ -2537,6 +2537,11 @@ export function TaskDetailContent({
ids when the destination has no resolved metadata.
*/
const targetFlags = workflowMoveMetadata?.moveColumns?.find((candidate) => candidate.id === column)?.flags;
/*
FNXC:WorkflowLifecycleColumns 2026-07-29-23:40 DELIBERATE-LITERAL: the fallback arm only. Same reasoning as
the TaskCard site: a wrong guess skips the preserve-progress prompt and discards steps with
no way back. Reason in full above.
*/
const targetIsPreImplementation = targetFlags
? targetFlags.intake === true || targetFlags.hold === true
: column === "todo" || column === "triage";
@@ -3041,6 +3046,10 @@ export function TaskDetailContent({
The INTAKE lane's approval hold. `task.column === "triage"` is deleted by U11, which
would silently drop the Approve/Reject controls from a parked planning card — the
operator sees a task stuck "awaiting approval" with no way to answer it.
FNXC:WorkflowLifecycleColumns 2026-07-29-23:40 DELIBERATE-LITERAL: the fallback arm only.
Reachable only with no resolved flags; guessing "not intake" hides Approve/Reject from a parked
planning card, which is an operator dead end. Retires with the pre-load window.
*/
const isIntakeColumn = workflowMoveMetadata?.currentColumnFlags
? workflowMoveMetadata.currentColumnFlags.intake === true

View File

@@ -2677,6 +2677,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
the generic branch then clears worktree/branch/retry counters and rebounds the card — losing
live execution or review state that was never in question. A v1 IR yields no roles, so the
legacy pre-implementation ids are the only pre-WIP signal available.
FNXC:WorkflowLifecycleColumns 2026-07-29-23:40 DELIBERATE-LITERAL: the v1-IR arm only.
A v1 workflow declares no roles, so there is no trait to read — this is not an unconverted
guard, it is the answer for IRs that cannot express the question. The v2 branch below
resolves it properly. Retires when v1 IRs do.
*/
strandedSpecificationRetry = task.column === "triage" || task.column === "todo";
} else {

View File

@@ -0,0 +1,240 @@
/*
FNXC:RecoverApprovedIntakePostU11 2026-07-29-21:10 (U11 #2515 audit — U7's site 1088):
`recoverApprovedTask` is self-healing's recovery for a planner that wrote a good
PROMPT.md and then died before handing the card off. It gates on
`task.column !== "triage"`.
AUDIT ANSWERS for the coordinator's three questions:
(a) Does it still fire for a default-workflow card? NO. U11 (#2515) merged Todo
into Planning keeping the id `todo`, so the default lineage declares no
`triage` column and every default card fails this gate.
(b) What silently stops happening? Recovery of an APPROVED, already-written plan.
The card is not stranded — triage's stale-planning sweeps still match `todo`
and clear its status — but clearing the status makes the card an ordinary
planning candidate again, so it is RE-PLANNED FROM SCRATCH. An approved spec
is discarded and a fresh LLM planning pass is burned, every time, on the exact
path built to avoid that (FN-1312: "auto-recovered specified task stuck in
planning — moved to todo").
Worth being precise about the severity: this is waste and lost work, not a
stall. The card does keep moving.
(c) Fix: resolve the INTAKE column from the task's own workflow.
The intake-ONLY scope is preserved, not widened: plan-in-place cards specified while
resting in the HOLD column are still out of reach of this path. That is a real
pre-existing gap, and widening it is a behavior change that does not belong in a
vocabulary fix. It is pinned below so it stays a decision rather than an accident.
The other four sites the drift review assigned me (613, 651, 741, 769) SURVIVE
#2515, because U11 kept the id `todo` and each of them tests `todo` as well as
`triage`. Measured, not assumed — they are asserted here so the audit is checkable
rather than a claim in a PR body.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Settings, Task, TaskStore, WorkflowIr } from "@fusion/core";
import { TriageProcessor } from "../triage.js";
import { planLog } from "../logger.js";
const WF = "custom:recovery-vocab";
/** Post-U11 default shape: ONE pre-implementation column carrying intake + hold. */
const MERGED = { intake: "todo", hold: "todo" };
/** A workflow that renamed it as well, so the fix cannot pass by naming `todo`. */
const RENAMED = { intake: "backlog", hold: "backlog" };
const REAL_SPEC = [
"# Task: FN-001 - Real spec", "", "## Mission", "", "Do the thing.", "",
"## Steps", "", "### Step 0: Implement", "- [ ] do the work", "",
].join("\n");
function ir(names: { intake: string; hold: string }): WorkflowIr {
return {
version: "v2", id: WF, name: WF, nodes: [], edges: [],
columns: [
{
id: names.intake,
name: "Planning",
traits: [{ trait: "intake" }, { trait: "hold", config: { release: "capacity" } }],
},
{ id: "in-progress", name: "In progress", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
],
} as unknown as WorkflowIr;
}
function createStore(task: Task, workflowIr: WorkflowIr): TaskStore {
const selection = { workflowId: WF, stepIds: [] };
const store: Record<string, unknown> = {
listTasks: vi.fn(async () => [task]),
getTask: vi.fn(async () => task),
getSettings: vi.fn(async () => ({ requirePlanApproval: false } as Settings)),
parseDependenciesFromPrompt: vi.fn(async () => []),
parseStepsFromPrompt: vi.fn(async () => []),
parseFileScopeFromPrompt: vi.fn(async () => []),
updateTask: vi.fn(async () => undefined),
updateTaskAtomic: vi.fn(async (_id: string, patch: unknown) => {
const next = typeof patch === "function" ? (patch as (t: Task) => Partial<Task> | null)(task) : patch;
if (next) Object.assign(task, next);
return task;
}),
moveTask: vi.fn(async () => undefined),
moveTaskIf: vi.fn(async (_id: string, column: string) => ({ moved: true, task: { ...task, column } })),
withTaskLock: vi.fn(async (_id: string, fn: () => Promise<unknown>) => fn()),
readTaskForMove: vi.fn(async () => task),
logEntry: vi.fn(async () => undefined),
recordActivity: vi.fn(async () => undefined),
getTaskWorkflowSelection: vi.fn(() => selection),
/*
Main's `resolvePlannerLanes` resolves SYNCHRONOUSLY via
`resolveTaskWorkflowIrSync` — the planner-lane reads happen inside synchronous
handlers and predicates, so there is no await available. A fixture without it
silently takes the legacy `{ intake: "triage" }` fallback, which reads as "the
conversion does not work" rather than "the fake is incomplete".
*/
resolveTaskWorkflowIrSync: vi.fn(() => workflowIr),
getTaskWorkflowSelectionAsync: vi.fn(async () => selection),
getWorkflowDefinition: vi.fn(async () => ({ ir: workflowIr })),
on: vi.fn(), off: vi.fn(),
};
return store as unknown as TaskStore;
}
describe("approved-plan recovery resolves the intake column (U11 #2515)", () => {
let rootDir = "";
beforeEach(async () => {
vi.clearAllMocks();
rootDir = await mkdtemp(join(tmpdir(), "fusion-recovery-vocab-"));
await mkdir(join(rootDir, ".fusion", "tasks", "FN-001"), { recursive: true });
await writeFile(join(rootDir, ".fusion", "tasks", "FN-001", "PROMPT.md"), REAL_SPEC);
vi.spyOn(planLog, "log").mockImplementation(() => {});
vi.spyOn(planLog, "warn").mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(rootDir, { recursive: true, force: true });
});
const stuckPlanner = (column: string): Task => ({
id: "FN-001", title: "t", description: "d", column, status: "planning",
dependencies: [], steps: [], currentStep: 0, log: [],
createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z",
} as unknown as Task);
async function recovers(names: { intake: string; hold: string }): Promise<boolean> {
const task = stuckPlanner(names.intake);
const store = createStore(task, ir(names));
return new TriageProcessor(store, rootDir).recoverApprovedTask(task);
}
it("recovers a stuck planner in the MERGED planning column (the post-U11 default)", async () => {
// Pre-fix this returned false for every default-workflow card: the gate named
// `triage`, which U11 removed. The approved spec was then discarded and the card
// re-planned from scratch by ordinary discovery.
expect(await recovers(MERGED)).toBe(true);
});
it("recovers a stuck planner in a RENAMED planning column", async () => {
expect(await recovers(RENAMED)).toBe(true);
});
it("STILL recovers a card sitting in the legacy `triage` column (the migration window)", async () => {
/*
FNXC:RecoverApprovedIntakePostU11 2026-07-29-23:20:
THIS is what this PR uniquely adds. Main already resolves the intake lane, which
fixed merged and renamed workflows — and silently broke the cards still SITTING in
`triage`, the population U11's re-homing has not reached yet. Such a card resolves
its intake to `todo`, fails the gate, and has its approved spec discarded: the
stale-planning sweep clears the status and ordinary discovery re-plans it from
scratch.
`triage` remains a legal stored column id (R11), so recovery must accept both the
resolved lane and the legacy one during the migration window.
*/
const task = stuckPlanner("triage");
const store = createStore(task, ir(MERGED));
await expect(new TriageProcessor(store, rootDir).recoverApprovedTask(task)).resolves.toBe(true);
});
it("still refuses a card outside its own intake column — the gate is narrowed, not removed", async () => {
const task = stuckPlanner("in-progress");
const store = createStore(task, ir(MERGED));
await expect(new TriageProcessor(store, rootDir).recoverApprovedTask(task)).resolves.toBe(false);
});
});
/*
FNXC:RecoverApprovedIntakePostU11 2026-07-30-00:50 (PR #2593 review — greptile P1):
The legacy acceptance is SCOPED to an ORPHANED `triage` row. A custom workflow may
legitimately name a NON-intake lane `triage`, and accepting a planning-status card
from there would finalize its plan and move it to the hold lane — bypassing whatever
transition that column represents.
The migration case is precisely "the row sits in a column its workflow no longer
has", which is also what `reconcileUndeclaredTaskColumns` is about to re-home. When
the workflow DOES declare `triage`, its declared role governs.
*/
describe("legacy `triage` acceptance is scoped to orphaned rows", () => {
let rootDir = "";
beforeEach(async () => {
vi.clearAllMocks();
rootDir = await mkdtemp(join(tmpdir(), "fusion-recovery-scope-"));
await mkdir(join(rootDir, ".fusion", "tasks", "FN-001"), { recursive: true });
await writeFile(join(rootDir, ".fusion", "tasks", "FN-001", "PROMPT.md"), REAL_SPEC);
vi.spyOn(planLog, "log").mockImplementation(() => {});
vi.spyOn(planLog, "warn").mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(rootDir, { recursive: true, force: true });
});
/** A workflow that names its REVIEW lane `triage` — legal, and not a planner lane. */
function triageIsReviewIr(): WorkflowIr {
return {
version: "v2", id: WF, name: WF, nodes: [], edges: [],
columns: [
{ id: "todo", name: "Planning", traits: [{ trait: "intake" }, { trait: "hold", config: { release: "capacity" } }] },
{ id: "in-progress", name: "In progress", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
// Deliberately named `triage`, but it is the REVIEW lane.
{ id: "triage", name: "Review", traits: [{ trait: "merge-blocker" }, { trait: "human-review" }] },
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
],
} as unknown as WorkflowIr;
}
it("REFUSES a card in a `triage` column the workflow declares as a non-planner lane", async () => {
const task = {
id: "FN-001", title: "t", description: "d", column: "triage", status: "planning",
dependencies: [], steps: [], currentStep: 0, log: [],
createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z",
} as unknown as Task;
const store = createStore(task, triageIsReviewIr());
await expect(new TriageProcessor(store, rootDir).recoverApprovedTask(task)).resolves.toBe(false);
});
it("still ACCEPTS a card in `triage` when the workflow declares no such column (migration window)", async () => {
const task = {
id: "FN-001", title: "t", description: "d", column: "triage", status: "planning",
dependencies: [], steps: [], currentStep: 0, log: [],
createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z",
} as unknown as Task;
const store = createStore(task, ir(MERGED));
await expect(new TriageProcessor(store, rootDir).recoverApprovedTask(task)).resolves.toBe(true);
});
});

View File

@@ -35,6 +35,8 @@ import {
resolvePlanApprovalRequired,
resolveWorkflowIrForTask,
resolveLifecycleColumns,
resolveWorkflowIrForTaskWithProvenance,
workflowHasColumn,
getStepParser,
computePlanApprovalFingerprint,
extractIntentSignature,
@@ -1156,7 +1158,71 @@ export class TriageProcessor {
the literal. Converting only the `todo` sites left this one rejecting every
card whose workflow renames its planner column, so the release below was
unreachable for exactly the workflows the conversion was for. */
if (task.column !== resolvePlannerLanes(this.store, task.id).intake || !recoverableStatus) {
/*
FNXC:RecoverApprovedIntakePostU11 2026-07-29-23:10 (U11 #2515 audit):
ADDITIVE: the resolved intake lane OR the legacy `triage` id.
Resolving the lane (above) fixed recovery for renamed and merged workflows and
silently broke it for cards still SITTING in `triage` — the migration population
U11's re-homing has not reached yet. A default-workflow card there resolves
intake to `todo`, fails this gate, and its approved spec is discarded: the
stale-planning sweep clears the status, ordinary discovery re-plans from scratch,
and a fresh LLM pass is burned on the path FN-1312 built to avoid exactly that.
Trading "cannot recover post-U11 cards" for "cannot recover pre-U11 cards" is not
a fix. `triage` stays a legal column id for stored rows (R11), so accepting both
is compatibility, not a second source of truth — once a row is re-homed the
resolved lane is what matches.
*/
const lanes = resolvePlannerLanes(this.store, task.id);
/*
FNXC:RecoverApprovedIntakePostU11 2026-07-30-00:50 (PR #2593 review — greptile P1):
The legacy acceptance is SCOPED to an ORPHANED `triage` row — one whose workflow
does not declare a `triage` column at all. A custom workflow is free to name a
non-intake lane `triage` (its review or wip column), and accepting a
planning-status card from there would finalize its plan and move it to the hold
lane, bypassing whatever transition that custom column represents.
Unqualified `|| task.column === "triage"` could not tell those two apart. This
can: the migration case is precisely "the row sits in a column its workflow no
longer has", which is also exactly what `reconcileUndeclaredTaskColumns` is about
to re-home. If the workflow DOES declare `triage`, its declared role governs and
only the resolved intake lane is accepted.
*/
/*
FNXC:RecoverApprovedIntakePostU11 2026-07-30-00:20 (PR #2593 review — greptile, PG defaults):
THE SYNC READER CANNOT BE USED HERE, and it is production that breaks. `resolveTaskWorkflowIrSync`
returns `WorkflowIr`, never undefined: in backend/PostgreSQL mode it "cannot synchronously read
PostgreSQL, so return undefined and let the sync readers fall back to their defaults"
(`workflow-definitions.ts`). So the value arriving here was the DEFAULT coding IR, which post-U11
declares no `triage` column — making `declaresLegacyTriage` false for every task under PG and the
scoping this guard exists for unable to fire at all. A custom workflow that legitimately names a
non-intake lane `triage` would have had its planning-status card accepted and finalized, which is
the precise regression the earlier review asked me to prevent.
The provenance API is the fix, not a bigger try/catch: `source: "selection"` is verified by IR
identity, so it is only reported when the store really resolved the task's own workflow. Anything
else — PG's sync gap, no selection, a missing or malformed definition, a throwing lookup — is
`"default"`, and we then FAIL CLOSED by assuming the workflow declares `triage` and declining to
widen. Declining costs a deferred recovery that the next sweep retries; widening wrongly
finalizes a plan in someone's custom lane.
*/
const resolved = await resolveWorkflowIrForTaskWithProvenance(this.store, task.id);
const declaresLegacyTriage = resolved.source === "selection"
? workflowHasColumn(resolved.ir, "triage")
: true;
/*
FNXC:RecoverApprovedIntakePostU11 2026-07-29-23:55 DELIBERATE-LITERAL: the migration arm only.
The census flagged this as a NEW guard, correctly — it is a literal, and it is new. It is also
irreducible: the condition is "this row sits in a column its workflow no longer declares", so
there is no trait to resolve and no IR that can answer it. Resolving `triage` from the workflow
is what the `!declaresLegacyTriage` half already does, and it is what makes this the orphan case
rather than a blanket acceptance. Same class as the markers in `replan-target.ts` and
`hold-release.ts`; retires with the U11 migration window, when no row can rest in `triage`.
*/
const inPlannerColumn = task.column === lanes.intake
|| (task.column === "triage" && !declaresLegacyTriage);
if (!inPlannerColumn || !recoverableStatus) {
return false;
}

View File

@@ -1,42 +1,42 @@
{
"generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline (AST classifier)",
"generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline",
"totals": {
"column": 854,
"role": 12,
"column": 776,
"role": 0,
"status": 182,
"deliberate": 3
"deliberate": 16
},
"byColumnId": {
"done": 210,
"in-progress": 153,
"in-review": 218,
"done": 205,
"in-progress": 146,
"in-review": 208,
"archived": 152,
"triage": 38,
"todo": 83
"todo": 60,
"triage": 5
},
"byFile": {
"packages/engine/src/self-healing.ts": 126,
"packages/engine/src/executor.ts": 112,
"packages/dashboard/app/components/TaskCard.tsx": 45,
"packages/core/src/task-store/moves.ts": 44,
"packages/dashboard/app/components/TaskDetailModal.tsx": 34,
"packages/engine/src/self-healing.ts": 111,
"packages/engine/src/executor.ts": 104,
"packages/dashboard/app/components/TaskCard.tsx": 42,
"packages/core/src/task-store/moves.ts": 39,
"packages/dashboard/app/components/TaskDetailModal.tsx": 31,
"packages/engine/src/scheduler.ts": 28,
"packages/core/src/default-workflow-hooks.ts": 25,
"packages/dashboard/src/routes/register-task-workflow-routes.ts": 22,
"packages/dashboard/src/routes/register-task-workflow-routes.ts": 21,
"packages/core/src/store.ts": 12,
"packages/engine/src/project-engine.ts": 12,
"packages/cli/src/commands/task.ts": 11,
"packages/dashboard/app/components/TaskContextMenu.tsx": 11,
"packages/core/src/live-agent-count.ts": 10,
"packages/dashboard/app/components/Column.tsx": 10,
"packages/dashboard/app/components/TaskContextMenu.tsx": 10,
"packages/engine/src/mission-execution-loop.ts": 10,
"packages/cli/src/commands/task.ts": 9,
"packages/core/src/task-store/async-comments-attachments.ts": 9,
"packages/dashboard/src/github-tracking-comments.ts": 9,
"packages/dashboard/src/github-tracking-reconciler.ts": 9,
"packages/engine/src/notification/notification-service.ts": 9,
"packages/cli/src/commands/dashboard.ts": 8,
"packages/core/src/default-workflow-hooks.ts": 7,
"packages/core/src/task-store/update-task-deps.ts": 7,
"packages/dashboard/app/components/Column.tsx": 7,
"packages/engine/src/agent-tools.ts": 7,
"packages/core/src/live-agent-count.ts": 6,
"packages/core/src/task-merge.ts": 6,
"packages/core/src/task-store/branch-group-ops.ts": 6,
"packages/core/src/task-store/task-artifacts-ops.ts": 6,
@@ -46,17 +46,12 @@
"packages/cli/src/extension.ts": 5,
"packages/core/src/task-store/merge-queue-ops-2.ts": 5,
"packages/dashboard/app/hooks/useTaskDiffStats.ts": 5,
"packages/dashboard/app/utils/taskActivity.ts": 5,
"packages/engine/src/merger.ts": 5,
"packages/engine/src/mission-feature-sync.ts": 5,
"packages/engine/src/restart-recovery-coordinator.ts": 5,
"packages/core/src/agent-store.ts": 4,
"packages/core/src/blocker-fanout.ts": 4,
"packages/core/src/task-age-staleness.ts": 4,
"packages/core/src/task-store/comments-ops.ts": 4,
"packages/core/src/task-store/task-store-helpers.ts": 4,
"packages/dashboard/app/components/command-center/MissionControlPanel.tsx": 4,
"packages/dashboard/app/components/DocumentsView.tsx": 4,
"packages/dashboard/app/components/TaskReviewTab.tsx": 4,
"packages/dashboard/app/components/taskSorting.ts": 4,
"packages/dashboard/app/utils/worktreeGrouping.ts": 4,
@@ -74,6 +69,7 @@
"packages/dashboard/app/components/DockTaskList.tsx": 3,
"packages/dashboard/app/components/TaskChangesTab.tsx": 3,
"packages/dashboard/app/components/TaskChatTab.tsx": 3,
"packages/dashboard/app/utils/taskActivity.ts": 3,
"packages/dashboard/src/chat.ts": 3,
"packages/dashboard/src/routes/register-chat-routes.ts": 3,
"packages/engine/src/cli-agent/state-machine.ts": 3,
@@ -88,6 +84,7 @@
"packages/core/src/task-move-disposer.ts": 2,
"packages/core/src/task-store/archive-lifecycle-2.ts": 2,
"packages/core/src/task-store/audit-ops.ts": 2,
"packages/core/src/task-store/comments-ops.ts": 2,
"packages/core/src/task-store/project-store-ops.ts": 2,
"packages/core/src/task-store/reads.ts": 2,
"packages/core/src/task-store/symbol-locks.ts": 2,
@@ -95,6 +92,7 @@
"packages/core/src/team-analytics.ts": 2,
"packages/core/src/workflow-analytics.ts": 2,
"packages/dashboard/app/components/Board.tsx": 2,
"packages/dashboard/app/components/DocumentsView.tsx": 2,
"packages/dashboard/app/components/effective-model-resolution.ts": 2,
"packages/dashboard/app/components/WorkflowResultsTab.tsx": 2,
"packages/dashboard/app/utils/prFeedback.ts": 2,