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:
7
.changeset/recover-approved-intake-post-u11.md
Normal file
7
.changeset/recover-approved-intake-post-u11.md
Normal 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.
|
||||||
7
.github/workflows/pr-checks.yml
vendored
7
.github/workflows/pr-checks.yml
vendored
@@ -50,6 +50,13 @@ jobs:
|
|||||||
- name: Dashboard route modularity
|
- name: Dashboard route modularity
|
||||||
run: pnpm check:routes-modular
|
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:
|
typecheck:
|
||||||
name: Typecheck
|
name: Typecheck
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -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
|
asserting the compatibility flag really is unset, so the suite fails loudly if that ever changes
|
||||||
rather than silently testing a different code path.
|
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
|
### 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
|
I implemented the checklist above, ran the suites, and it does not work. Recording the disproof
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"check:line-count": "node scripts/check-file-line-count.mjs",
|
"check:line-count": "node scripts/check-file-line-count.mjs",
|
||||||
"check:routes-modular": "node scripts/check-routes-modular.mjs",
|
"check:routes-modular": "node scripts/check-routes-modular.mjs",
|
||||||
"check:changesets": "node scripts/check-changeset-format.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",
|
"census:lifecycle-columns": "node scripts/lifecycle-column-census.mjs",
|
||||||
"check:quarantine-ledger": "node scripts/check-quarantine-ledger.mjs",
|
"check:quarantine-ledger": "node scripts/check-quarantine-ledger.mjs",
|
||||||
"check:mock-completeness": "node scripts/check-mock-completeness.mjs",
|
"check:mock-completeness": "node scripts/check-mock-completeness.mjs",
|
||||||
|
|||||||
@@ -1003,6 +1003,11 @@ function TaskCardComponent({
|
|||||||
than scattered through the file. When flags are present — which is every card on a
|
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
|
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.
|
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
|
const isIntakeColumn = taskColumnFlags
|
||||||
? taskColumnFlags.intake === true
|
? taskColumnFlags.intake === true
|
||||||
@@ -2488,6 +2493,11 @@ function TaskCardComponent({
|
|||||||
the single fallback documented at the role helpers above.
|
the single fallback documented at the role helpers above.
|
||||||
*/
|
*/
|
||||||
const targetFlags = taskMoveColumns?.find((candidate) => candidate.id === column)?.flags;
|
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
|
const targetIsPreImplementation = targetFlags
|
||||||
? targetFlags.intake === true || targetFlags.hold === true
|
? targetFlags.intake === true || targetFlags.hold === true
|
||||||
: column === "todo" || column === "triage";
|
: column === "todo" || column === "triage";
|
||||||
|
|||||||
@@ -2537,6 +2537,11 @@ export function TaskDetailContent({
|
|||||||
ids when the destination has no resolved metadata.
|
ids when the destination has no resolved metadata.
|
||||||
*/
|
*/
|
||||||
const targetFlags = workflowMoveMetadata?.moveColumns?.find((candidate) => candidate.id === column)?.flags;
|
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
|
const targetIsPreImplementation = targetFlags
|
||||||
? targetFlags.intake === true || targetFlags.hold === true
|
? targetFlags.intake === true || targetFlags.hold === true
|
||||||
: column === "todo" || column === "triage";
|
: 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
|
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
|
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.
|
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
|
const isIntakeColumn = workflowMoveMetadata?.currentColumnFlags
|
||||||
? workflowMoveMetadata.currentColumnFlags.intake === true
|
? workflowMoveMetadata.currentColumnFlags.intake === true
|
||||||
|
|||||||
@@ -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
|
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
|
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.
|
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";
|
strandedSpecificationRetry = task.column === "triage" || task.column === "todo";
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,8 @@ import {
|
|||||||
resolvePlanApprovalRequired,
|
resolvePlanApprovalRequired,
|
||||||
resolveWorkflowIrForTask,
|
resolveWorkflowIrForTask,
|
||||||
resolveLifecycleColumns,
|
resolveLifecycleColumns,
|
||||||
|
resolveWorkflowIrForTaskWithProvenance,
|
||||||
|
workflowHasColumn,
|
||||||
getStepParser,
|
getStepParser,
|
||||||
computePlanApprovalFingerprint,
|
computePlanApprovalFingerprint,
|
||||||
extractIntentSignature,
|
extractIntentSignature,
|
||||||
@@ -1156,7 +1158,71 @@ export class TriageProcessor {
|
|||||||
the literal. Converting only the `todo` sites left this one rejecting every
|
the literal. Converting only the `todo` sites left this one rejecting every
|
||||||
card whose workflow renames its planner column, so the release below was
|
card whose workflow renames its planner column, so the release below was
|
||||||
unreachable for exactly the workflows the conversion was for. */
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
"totals": {
|
||||||
"column": 854,
|
"column": 776,
|
||||||
"role": 12,
|
"role": 0,
|
||||||
"status": 182,
|
"status": 182,
|
||||||
"deliberate": 3
|
"deliberate": 16
|
||||||
},
|
},
|
||||||
"byColumnId": {
|
"byColumnId": {
|
||||||
"done": 210,
|
"done": 205,
|
||||||
"in-progress": 153,
|
"in-progress": 146,
|
||||||
"in-review": 218,
|
"in-review": 208,
|
||||||
"archived": 152,
|
"archived": 152,
|
||||||
"triage": 38,
|
"todo": 60,
|
||||||
"todo": 83
|
"triage": 5
|
||||||
},
|
},
|
||||||
"byFile": {
|
"byFile": {
|
||||||
"packages/engine/src/self-healing.ts": 126,
|
"packages/engine/src/self-healing.ts": 111,
|
||||||
"packages/engine/src/executor.ts": 112,
|
"packages/engine/src/executor.ts": 104,
|
||||||
"packages/dashboard/app/components/TaskCard.tsx": 45,
|
"packages/dashboard/app/components/TaskCard.tsx": 42,
|
||||||
"packages/core/src/task-store/moves.ts": 44,
|
"packages/core/src/task-store/moves.ts": 39,
|
||||||
"packages/dashboard/app/components/TaskDetailModal.tsx": 34,
|
"packages/dashboard/app/components/TaskDetailModal.tsx": 31,
|
||||||
"packages/engine/src/scheduler.ts": 28,
|
"packages/engine/src/scheduler.ts": 28,
|
||||||
"packages/core/src/default-workflow-hooks.ts": 25,
|
"packages/dashboard/src/routes/register-task-workflow-routes.ts": 21,
|
||||||
"packages/dashboard/src/routes/register-task-workflow-routes.ts": 22,
|
|
||||||
"packages/core/src/store.ts": 12,
|
"packages/core/src/store.ts": 12,
|
||||||
"packages/engine/src/project-engine.ts": 12,
|
"packages/engine/src/project-engine.ts": 12,
|
||||||
"packages/cli/src/commands/task.ts": 11,
|
"packages/dashboard/app/components/TaskContextMenu.tsx": 10,
|
||||||
"packages/dashboard/app/components/TaskContextMenu.tsx": 11,
|
|
||||||
"packages/core/src/live-agent-count.ts": 10,
|
|
||||||
"packages/dashboard/app/components/Column.tsx": 10,
|
|
||||||
"packages/engine/src/mission-execution-loop.ts": 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/core/src/task-store/async-comments-attachments.ts": 9,
|
||||||
"packages/dashboard/src/github-tracking-comments.ts": 9,
|
"packages/dashboard/src/github-tracking-comments.ts": 9,
|
||||||
"packages/dashboard/src/github-tracking-reconciler.ts": 9,
|
"packages/dashboard/src/github-tracking-reconciler.ts": 9,
|
||||||
"packages/engine/src/notification/notification-service.ts": 9,
|
"packages/engine/src/notification/notification-service.ts": 9,
|
||||||
"packages/cli/src/commands/dashboard.ts": 8,
|
"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/core/src/task-store/update-task-deps.ts": 7,
|
||||||
|
"packages/dashboard/app/components/Column.tsx": 7,
|
||||||
"packages/engine/src/agent-tools.ts": 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-merge.ts": 6,
|
||||||
"packages/core/src/task-store/branch-group-ops.ts": 6,
|
"packages/core/src/task-store/branch-group-ops.ts": 6,
|
||||||
"packages/core/src/task-store/task-artifacts-ops.ts": 6,
|
"packages/core/src/task-store/task-artifacts-ops.ts": 6,
|
||||||
@@ -46,17 +46,12 @@
|
|||||||
"packages/cli/src/extension.ts": 5,
|
"packages/cli/src/extension.ts": 5,
|
||||||
"packages/core/src/task-store/merge-queue-ops-2.ts": 5,
|
"packages/core/src/task-store/merge-queue-ops-2.ts": 5,
|
||||||
"packages/dashboard/app/hooks/useTaskDiffStats.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/merger.ts": 5,
|
||||||
"packages/engine/src/mission-feature-sync.ts": 5,
|
|
||||||
"packages/engine/src/restart-recovery-coordinator.ts": 5,
|
"packages/engine/src/restart-recovery-coordinator.ts": 5,
|
||||||
"packages/core/src/agent-store.ts": 4,
|
"packages/core/src/agent-store.ts": 4,
|
||||||
"packages/core/src/blocker-fanout.ts": 4,
|
"packages/core/src/blocker-fanout.ts": 4,
|
||||||
"packages/core/src/task-age-staleness.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/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/TaskReviewTab.tsx": 4,
|
||||||
"packages/dashboard/app/components/taskSorting.ts": 4,
|
"packages/dashboard/app/components/taskSorting.ts": 4,
|
||||||
"packages/dashboard/app/utils/worktreeGrouping.ts": 4,
|
"packages/dashboard/app/utils/worktreeGrouping.ts": 4,
|
||||||
@@ -74,6 +69,7 @@
|
|||||||
"packages/dashboard/app/components/DockTaskList.tsx": 3,
|
"packages/dashboard/app/components/DockTaskList.tsx": 3,
|
||||||
"packages/dashboard/app/components/TaskChangesTab.tsx": 3,
|
"packages/dashboard/app/components/TaskChangesTab.tsx": 3,
|
||||||
"packages/dashboard/app/components/TaskChatTab.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/chat.ts": 3,
|
||||||
"packages/dashboard/src/routes/register-chat-routes.ts": 3,
|
"packages/dashboard/src/routes/register-chat-routes.ts": 3,
|
||||||
"packages/engine/src/cli-agent/state-machine.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-move-disposer.ts": 2,
|
||||||
"packages/core/src/task-store/archive-lifecycle-2.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/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/project-store-ops.ts": 2,
|
||||||
"packages/core/src/task-store/reads.ts": 2,
|
"packages/core/src/task-store/reads.ts": 2,
|
||||||
"packages/core/src/task-store/symbol-locks.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/team-analytics.ts": 2,
|
||||||
"packages/core/src/workflow-analytics.ts": 2,
|
"packages/core/src/workflow-analytics.ts": 2,
|
||||||
"packages/dashboard/app/components/Board.tsx": 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/effective-model-resolution.ts": 2,
|
||||||
"packages/dashboard/app/components/WorkflowResultsTab.tsx": 2,
|
"packages/dashboard/app/components/WorkflowResultsTab.tsx": 2,
|
||||||
"packages/dashboard/app/utils/prFeedback.ts": 2,
|
"packages/dashboard/app/utils/prFeedback.ts": 2,
|
||||||
|
|||||||
Reference in New Issue
Block a user