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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user