U7 item 3: the replan no-match fallback named a column no lineage declares (#2659)

**Item 3 from the closing bar.** Behaviour change, own commit.

## The defect

`resolveReplanTargetColumn` fell back to the literal `"triage"` when a
workflow declared neither legacy planner id. That names a column the
workflow doesn't declare — and since #2515 the **default lineage doesn't
declare it either**, so the fallback pointed at a column that exists
nowhere. The replan move then either failed outright or put the card
somewhere no sweep owns.

Resolved through `resolveReboundTarget` (KTD-10: hold → intake → first
declared) — the same helper every other rebound path uses, so replan
lanes and rebound lanes stay consistent instead of drifting.

## The catch path keeps its literal, deliberately

It's reached only when resolution **throws** — not when it silently
falls back to the default IR, which returns a real workflow and takes
the `todo` branch above. With no IR there's nothing to resolve, and
swapping one arbitrary literal for another changes behaviour without
evidence about the workflow. Documented at the site so the asymmetry
reads as a decision, not an oversight.

## Test

Written first and observed **red**. It asserts the target is a column
the workflow actually declares:

```ts
expect(workflowHasColumn(ir, target)).toBe(true);
```

rather than pinning a specific id — so it can't pass by naming a
*different* wrong column, which is how the previous version of this test
stayed green while the fallback was broken.

**Mutation-verified:** restoring the literal fails it.

## Verification

40 replan-target tests green, engine tsc clean, lint clean, merge gate
green (487 + 132 + 10).

No changeset: `@fusion/engine` is private.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
gsxdsm
2026-07-30 00:13:39 -07:00
committed by GitHub
parent be63e72f10
commit f5cc416ae4
2 changed files with 38 additions and 7 deletions

View File

@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { Task, TaskStep, TaskStore } from "@fusion/core";
import { resolveWorkflowIrForTask, workflowHasColumn } from "@fusion/core";
import { hasAdvancedPastPlanning, isTaskStillInPlanningStage, moveTaskToReplanColumn, resolveReplanTargetColumn } from "../replan-target.js";
/*
@@ -276,12 +277,24 @@ Updated to the post-merge truth, not loosened: each still pins one exact column.
await expect(resolveReplanTargetColumn(store, "FN-1")).resolves.toBe("todo");
});
it("falls back to triage for workflows declaring neither triage nor todo (never a custom column)", async () => {
// builtin:marketing declares ideation/backlog/drafting/... — no triage, no todo.
// A custom entry column would strand the needs-replan card (triage only scans
// "triage" and "todo") and the legacy move path throws on custom targets.
it("targets its OWN rebound lane for a workflow declaring neither triage nor todo", async () => {
/*
FNXC:WorkflowReplan 2026-07-31-13:30 (U11 — the flagged fallback, now converted):
builtin:marketing declares ideation/backlog/drafting/... — no `triage`, no `todo`.
The old fallback handed it the literal `triage`, a column that lineage does not
declare AND that the default lineage no longer declares either since #2515. So the
replan move targeted a nonexistent column: the card either failed to move or landed
somewhere no sweep owns.
Resolved through `resolveReboundTarget` (KTD-10: hold -> intake -> first declared),
which is the same helper every other rebound path uses. The card now lands in a
column its own workflow actually declares.
*/
const store = storeWithSelection("builtin:marketing");
await expect(resolveReplanTargetColumn(store, "FN-1")).resolves.toBe("triage");
const target = await resolveReplanTargetColumn(store, "FN-1");
expect(target).not.toBe("triage");
const ir = await resolveWorkflowIrForTask(store as never, "FN-1");
expect(workflowHasColumn(ir, target)).toBe(true);
});
/* Resolution failure no longer reaches the `triage` catch: the resolver swallows

View File

@@ -1,5 +1,5 @@
import type { Task, TaskStore } from "@fusion/core";
import { resolveLifecycleColumns, resolveWorkflowIrForTask, workflowHasColumn } from "@fusion/core";
import { resolveLifecycleColumns, resolveReboundTarget, resolveWorkflowIrForTask, workflowHasColumn } from "@fusion/core";
import type { WorkflowIr } from "@fusion/core";
/*
@@ -349,8 +349,26 @@ export async function resolveReplanTargetColumn(store: TaskStore, taskId: string
*/
if (workflowHasColumn(ir, "triage")) return "triage";
if (workflowHasColumn(ir, "todo")) return "todo";
return "triage";
/*
FNXC:WorkflowReplan 2026-07-31-13:35 (U11 — the flagged fallback, now converted):
Was `return "triage"`, naming a column this workflow does not declare AND that the
DEFAULT lineage stopped declaring at #2515. A workflow with neither legacy planner
id was therefore handed a nonexistent target, so the replan move either failed or
put the card somewhere no sweep owns.
`resolveReboundTarget` is the KTD-10 helper every other rebound path already uses
(hold -> intake -> first declared), so the card lands in a column its own workflow
declares and the replan lanes stay consistent with the rebound lanes.
*/
return resolveReboundTarget(ir) ?? "triage";
} catch {
/*
NO IR MEANS NOTHING TO RESOLVE, so this keeps the legacy literal deliberately
rather than guessing. It is reached only when resolution THROWS — not when it
falls back to the default IR, which returns a real workflow and takes the `todo`
branch above. Changing it to another literal would trade one arbitrary column for
another without evidence about the workflow.
*/
return "triage";
}
}