diff --git a/.changeset/u12-last-triage-guard.md b/.changeset/u12-last-triage-guard.md new file mode 100644 index 0000000000..b868e1797c --- /dev/null +++ b/.changeset/u12-last-triage-guard.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: The Plan action no longer appears on cards that are already executing. +category: fix +dev: isPreExecutionHoldColumn ORed the legacy `triage` id with the column's traits unconditionally, so a resolved column merely named `triage` was treated as a planning target even when its traits said work was underway. Now flags-first with the id as the documented no-metadata fallback. diff --git a/packages/dashboard/app/components/TaskContextMenu.tsx b/packages/dashboard/app/components/TaskContextMenu.tsx index b5df2178cd..4989912900 100644 --- a/packages/dashboard/app/components/TaskContextMenu.tsx +++ b/packages/dashboard/app/components/TaskContextMenu.tsx @@ -176,7 +176,31 @@ export function isPreExecutionHoldColumn(column: string, flags?: TaskContextMenu Same shape, different degraded answer: the trait path is identical and the fallbacks are not interchangeable. Kept separate with the difference recorded, rather than made to look shared. */ - return column === "triage" || flags?.intake === true || flags?.hold === true; + /* + FNXC:WorkflowLifecycleColumns 2026-07-31-08:00 (U12 — the LAST `triage` column guard): + FLAGS-FIRST, id only as the degraded answer. It used to OR the legacy id with the traits + UNCONDITIONALLY, which is not a fallback: a resolved column that happens to be named `triage` but + whose traits say it is mid-flight answered true, offering Plan on a card that is already executing. + + The degraded set stays {triage} ALONE — deliberately not the {todo, triage} used by + `isPreImplementationColumnRole`, for the reason recorded above: that helper drives the + preserve-progress prompt where a flagless `todo` should prompt, while this drives the Plan + affordance where a flagless `todo` must not offer to re-plan a possibly-planned card. + + Behaviour delta is exactly the inversion. Flags absent: unchanged (`column === "triage"`). Flags + present and intake/hold: unchanged (true). Flags present, name `triage`, traits mid-flight: was + true, now false — which is the defect. + + DELIBERATE-LITERAL: the surviving `triage` is the DEGRADED answer, not an unconverted guard, and it + is the last `triage` comparison in production source. Converting it is not available — there is no + trait to read when `flags` is undefined, which happens during first paint and for a card in a column + its workflow no longer declares. Deleting it would silently withdraw Plan from exactly the stranded + cards that need re-planning most. + + So the census reaching zero for `triage` means "no unconverted guards remain", not "the string is + gone". Recorded here rather than achieved by deleting a fallback to move a number. + */ + return flags ? (flags.intake === true || flags.hold === true) : column === "triage"; } diff --git a/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx b/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx index 293abe926e..1237cb1e7f 100644 --- a/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx @@ -376,3 +376,54 @@ describe("shouldShowActionsMenu by workflow shape (not by column id)", () => { expect(model("backlog", { intake: true })).toBe(false); }); }); + +/* +FNXC:WorkflowLifecycleColumns 2026-07-31-08:00 (U12 — the last `triage` column guard): +THE INVERSION. `isPreExecutionHoldColumn` ORed the legacy id with the traits unconditionally, so a +resolved column merely NAMED `triage` answered true even when its own traits said work was underway +— offering Plan, which re-plans, on a card that is already executing. + +The existing cases here all pass a column with no flags or with hold/intake set, so every one of them +agrees under both the old and new form. That is why this defect survived the file's earlier +conversion: nothing exercised a resolved column whose name and traits disagree. + +REVERT CHECK: restore the `column === "triage" ||` prefix and the first case fails — Plan reappears +on a mid-flight card. +*/ +describe("pre-execution hold resolves traits, not the column's name", () => { + it("does NOT treat a mid-flight column NAMED `triage` as a planning target", () => { + const model = buildTaskActionMenuModel({ + task: makeTask({ column: "triage" }), + t, + columnLabel: columnLabel as any, + currentColumnFlags: { intake: false, hold: false, countsTowardWip: true } as any, + onPlan: vi.fn(), + } as never); + expect(model.actions.map((a: { id: string }) => a.id)).not.toContain("plan"); + }); + + it("still offers Plan on a RENAMED hold column", () => { + // The narrowing guard: traits decide, so a board that never uses the legacy name still works. + const model = buildTaskActionMenuModel({ + task: makeTask({ column: "backlog" as never }), + t, + columnLabel: columnLabel as any, + currentColumnFlags: { intake: true, hold: true } as any, + onPlan: vi.fn(), + } as never); + expect(model.actions.map((a: { id: string }) => a.id)).toContain("plan"); + }); + + it("keeps the flagless degraded answer for `triage` and withholds it for flagless `todo`", () => { + /* + The asymmetry the file documents: with no flags, `triage` is the only pre-execution hold. A + flagless `todo` must NOT offer Plan, because re-planning an already-planned card is not + recoverable by the operator. + */ + const forColumn = (column: string) => + buildTaskActionMenuModel({ task: makeTask({ column: column as never }), t, columnLabel: columnLabel as any, onPlan: vi.fn() } as never) + .actions.map((a: { id: string }) => a.id); + expect(forColumn("triage")).toContain("plan"); + expect(forColumn("todo")).not.toContain("plan"); + }); +}); diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index 96a24daa8c..a15c3846bf 100644 --- a/scripts/lib/lifecycle-column-census-baseline.json +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -1,10 +1,10 @@ { "generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline", "totals": { - "column": 769, + "column": 768, "role": 5, "status": 187, - "deliberate": 12 + "deliberate": 13 }, "byColumnId": { "done": 201, @@ -12,7 +12,7 @@ "in-review": 209, "archived": 148, "todo": 60, - "triage": 5 + "triage": 4 }, "byFile": { "packages/engine/src/self-healing.ts": 110, @@ -24,9 +24,9 @@ "packages/dashboard/src/routes/register-task-workflow-routes.ts": 20, "packages/core/src/store.ts": 12, "packages/engine/src/project-engine.ts": 12, - "packages/dashboard/app/components/TaskContextMenu.tsx": 10, "packages/engine/src/mission-execution-loop.ts": 10, "packages/core/src/task-store/async-comments-attachments.ts": 9, + "packages/dashboard/app/components/TaskContextMenu.tsx": 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,