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:
gsxdsm
2026-07-30 00:37:08 -07:00
committed by GitHub
parent 3bf9bf5f74
commit efbbc45eb0
4 changed files with 87 additions and 5 deletions

View 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.

View File

@@ -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 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. 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";
} }

View File

@@ -376,3 +376,54 @@ describe("shouldShowActionsMenu by workflow shape (not by column id)", () => {
expect(model("backlog", { intake: true })).toBe(false); 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");
});
});

View File

@@ -1,10 +1,10 @@
{ {
"generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline", "generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline",
"totals": { "totals": {
"column": 769, "column": 768,
"role": 5, "role": 5,
"status": 187, "status": 187,
"deliberate": 12 "deliberate": 13
}, },
"byColumnId": { "byColumnId": {
"done": 201, "done": 201,
@@ -12,7 +12,7 @@
"in-review": 209, "in-review": 209,
"archived": 148, "archived": 148,
"todo": 60, "todo": 60,
"triage": 5 "triage": 4
}, },
"byFile": { "byFile": {
"packages/engine/src/self-healing.ts": 110, "packages/engine/src/self-healing.ts": 110,
@@ -24,9 +24,9 @@
"packages/dashboard/src/routes/register-task-workflow-routes.ts": 20, "packages/dashboard/src/routes/register-task-workflow-routes.ts": 20,
"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/dashboard/app/components/TaskContextMenu.tsx": 10,
"packages/engine/src/mission-execution-loop.ts": 10, "packages/engine/src/mission-execution-loop.ts": 10,
"packages/core/src/task-store/async-comments-attachments.ts": 9, "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-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,