U12: the LAST triage guard — Plan was offered on executing cards named triage (#2664)
The final `column === "triage"` in production source, and it was a live
defect rather than dead vocabulary.
## The defect
`isPreExecutionHoldColumn` ORed the legacy id with the traits
**unconditionally**:
```ts
return column === "triage" || flags?.intake === true || flags?.hold === true;
```
That is not a fallback. A resolved column merely *named* `triage`
answered true even when its own traits said work was underway — so the
context menu offered **Plan**, which re-plans, on a card that is already
executing.
Now flags-first, with the id as the documented no-metadata answer.
## Why the file's earlier conversion missed it
Every existing case in `TaskContextMenu.test.tsx` passes a column with
**no flags**, or with `hold`/`intake` set. All of them agree under both
forms, so the suite could not distinguish them. Nothing exercised a
column whose **name and traits disagree**, which is the only shape that
separates an OR from a fallback.
Three new cases cover it. Revert check: restoring the OR form fails the
first one — Plan reappears on a mid-flight card.
## The asymmetry is preserved, and now tested
The degraded set stays `{triage}` **alone**, deliberately not the
`{todo, triage}` used by `isPreImplementationColumnRole`. That helper
drives the preserve-progress prompt, where a flagless `todo` *should*
prompt because losing steps is unrecoverable. This drives Plan, where a
flagless `todo` must **not** offer to re-plan a card that may already be
planned. The file documented that difference; nothing asserted it. Now a
test does.
## On reaching zero honestly
The surviving literal is marked `DELIBERATE-LITERAL`. It is the degraded
answer, not an unconverted guard — 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 most need
re-planning.
So **`triage → 0` means "no unconverted guards remain", not "the string
is gone"**, and I would rather say that than move a number by deleting a
fallback.
| branch | triage |
|---|---:|
| `origin/main` | 5 |
| this PR | **4** |
| #2655 (flag resolution, removes 4 in `moves.ts`) | 1 → **0** combined
|
I found it with the census's own AST classifier rather than grep — my
grep of the same tree returned only comment prose and would have had me
report the bar as met while a real defect sat in
`TaskContextMenu.tsx:179`.
## Verification
`pnpm lint` clean. `pnpm test:gate` green (10 / 158 / 487 / 71). `pnpm
check:lifecycle-columns` exits 0 with the baseline re-recorded in this
PR (column 769 → 768, deliberate 12 → 13). `tsc -p tsconfig.app.json`
clean. `TaskContextMenu.test.tsx` 18/18.
Depends on nothing; stacks cleanly with #2655 and #2661.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/u12-last-triage-guard.md
Normal file
7
.changeset/u12-last-triage-guard.md
Normal file
@@ -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.
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user