diff --git a/.changeset/recover-approved-intake-post-u11.md b/.changeset/recover-approved-intake-post-u11.md new file mode 100644 index 0000000000..f94bd11fa1 --- /dev/null +++ b/.changeset/recover-approved-intake-post-u11.md @@ -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. diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index eb286db02d..59e5bea3c9 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -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 diff --git a/docs/solutions/architecture-patterns/u11-triage-literal-safety-audit.md b/docs/solutions/architecture-patterns/u11-triage-literal-safety-audit.md index 16fe2fc814..0d62f6bf41 100644 --- a/docs/solutions/architecture-patterns/u11-triage-literal-safety-audit.md +++ b/docs/solutions/architecture-patterns/u11-triage-literal-safety-audit.md @@ -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 diff --git a/package.json b/package.json index b8a1771ec7..e412bd4fc3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 64d95a6f64..ccab1077fa 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -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"; diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 772cc53ea8..ed9234cf03 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -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 diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index e12b3b0095..069ec004c8 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -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 { diff --git a/packages/engine/src/__tests__/recover-approved-intake-post-u11.test.ts b/packages/engine/src/__tests__/recover-approved-intake-post-u11.test.ts new file mode 100644 index 0000000000..d5fa59c1b5 --- /dev/null +++ b/packages/engine/src/__tests__/recover-approved-intake-post-u11.test.ts @@ -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 = { + 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 | 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) => 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 { + 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); + }); +}); diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 41910616bb..13aa5708dc 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -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; } diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index f3e7f01786..cc7a0aa6c8 100644 --- a/scripts/lib/lifecycle-column-census-baseline.json +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -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,