Two commits: a behaviour-preserving extraction, then the behaviour
change.
## ⚠️ Stack note worth acting on
**#2550 and #2554 both report MERGED, but their content is not on
`main`.** They merged into their *base branches*, and the bottom of that
stack (**#2544**) is still open. Nothing in this chain has reached
`main` yet.
Nothing is lost — everything is in
`origin/feature/workflow-e2e-merge-rebound`, which is why this PR
targets it. But "merged" reads as "landed" and here it doesn't.
**Merging #2544 flows the whole chain down.**
## The bug
`parkCompletedBlockedTask` opens with *"is this card already finished?"*
and answered it with:
```ts
if (task.column === "done" || task.column === "archived") return false;
```
On a renamed board neither matches, so **the guard was inert** — and the
very next branch (`if (task.column !== "todo")`) would then have **moved
a completed card back out of its own terminal column**.
A guard that never fires does not fail a test. This one was found by
tracing the last ledger site, not by anything going red.
## Why a shared owner, not a local fix
`merger-ai`'s `isAlreadyFinalizedColumn` held the **only** copy of the
per-role terminal-pair rule — a P1 learned the hard way (PR #2471
review): a per-**set** fallback collapses to one element for a workflow
declaring `complete` but no `archived`, silently dropping the archived
half of every already-finished check.
Executor's guard was the raw literal pair, so **whoever converted it
next would have re-made exactly that mistake** — the lesson lived in a
comment in another file. Hence `resolveTerminalColumns(ir)` in core: one
owner, one place for the rule.
## Evidence, and its limits
**Commit 1 (extraction) is proven behaviour-preserving**:
`workflow-already-finalized-live-e2e` is unchanged and green through the
delegation, and the per-set mutation **still fails** through the shared
helper.
**Commit 2 (the fix) is unproven at the call site, and I'm labelling it
rather than implying otherwise.** `parkCompletedBlockedTask` is private
and reached only from inside executor dispatch — I could not drive it
end to end. So the shared helper gets its **own** tests, in both
partial-role directions, precisely because its other consumer can't
vouch for it. The call site is a one-line delegation to a tested
function.
Weaker evidence than the rest of this unit's work. Saying so, because
quietly counting it as proven is the exact failure this unit exists to
catch.
## Census
417 → 416. That ratchet (#2557) is a **ceiling**, so it stays green
without coordination; lower the pin when convenient.
## Verification
- E2E suites 10/10; helper unit tests 5/5
- core + engine `tsc --noEmit` clean
- `pnpm test:gate` green (414 + 10 + 71)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---
## Note on the conflict status (2026-07-31)
GitHub reports this PR `CONFLICTING / DIRTY`. **It is not.** Three
independent checks:
- `git rebase origin/main` on the pushed head reports *"up to date"* and
leaves the SHA unchanged — the branch is already on top of main.
- `git merge-tree` against the merge base produces **zero** conflict
markers.
- `origin/main` is unchanged at the commit this was rebased onto.
The remote SHA matches the local head, so the push landed. The
`mergeable` field is a **stale computation** — it goes stale after a
force-push and doesn't always recompute.
This branch has now been rebased and force-pushed four times against
that cached value. Worth guarding at the source: the auto-retry treats
`mergeable` as ground truth, so a stale value generates conflict notices
indefinitely. Confirming with a trial rebase or `git merge-tree` before
dispatching distinguishes "actually conflicting" from "GitHub hasn't
recomputed" — one command, and it ends the loop.
Verification on the current head: merge gate green (487 + 158 + 10),
engine + core tsc clean, lint clean, 20 tests in the affected suite,
zero unresolved threads.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>