Skip to content

engine: finalization parked ALREADY-MERGED work as failed on a renamed board - #2964

Merged
gsxdsm merged 2 commits into
mainfrom
finalize-parks-merged-work-failed
Jul 31, 2026
Merged

engine: finalization parked ALREADY-MERGED work as failed on a renamed board#2964
gsxdsm merged 2 commits into
mainfrom
finalize-parks-merged-work-failed

Conversation

@gsxdsm

@gsxdsm gsxdsm commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

The worst symptom in this family: the branch landed, and the board says the task failed.

project-engine's merge-confirmed finalization spread the task's real column into getTaskHardMergeBlocker with no reviewColumns, so the identity check ran against the literal in-review. On a renamed board it returned task is in 'signoff', must be in 'in-review', and the caller parked the card:

status: "failed"
error:  "Merge confirmed but finalization blocked: task is in 'signoff', must be in 'in-review'"

For work that had already merged.

Its sibling had already solved this

auto-merge-finalization.ts passes the review-eligible sentinel instead of the card's own column, with the reasoning recorded at that site: getTaskHardMergeBlocker asks "is this card blocked by anything other than where it sits?", and its callers are recovery paths for landed work that a graph crash can leave resting in any column. project-engine simply never got the same treatment.

One name instead of two spellings

Rather than write the sentinel a second time, it is exported once as REVIEW_ELIGIBLE_SENTINEL_COLUMN next to the helper whose contract gives it meaning, and both recovery paths use it. Two sites independently spelling a magic value is how one of them came to be missing it — that is the actual root cause here, not the literal itself.

This also answers the census, which flagged the new literal — correctly. Its guidance (which I wrote, in #2909) is to hoist a deliberate literal into a declaration, where a DELIBERATE-LITERAL marker actually attaches, instead of leaving it mid-expression where the marker is silently ignored. The shared constant is exactly that, and it lowers auto-merge-finalization's literal count too.

Revert result

reverted →
sentinel replaced by the card's own renamed column reproduces the shipped string

The middle test asserts that string deliberately — it is what landed in task.error, so a regression reports what the operator would actually have seen. A third case checks the sentinel does not suppress genuine blockers: incomplete steps still block finalization in any lane.

These drive the helper directly; reaching project-engine's finalization end to end needs a live engine, a merge run and a real repo, while the defect is entirely in what the blocker is asked.

Verification

pnpm test:gate 161 + 487 + 13 + 71; project-engine + auto-merge-finalization + the new suite, 207; tsc clean on core and engine; lint, census --strict, FNXC gate, changesets all clean.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed merge-confirmed tasks being finalized correctly when boards use renamed workflow columns.
    • Prevented already-merged tasks from being incorrectly marked as failed due to custom review-column names.
    • Preserved enforcement of genuine incomplete-step blockers.
  • Tests

    • Added coverage for finalization on renamed lanes and legitimate merge blockers.

…enamed board

The worst symptom in this family: the branch landed, and the board says the task
failed.

project-engine's merge-confirmed finalization spread the task's REAL column into
getTaskHardMergeBlocker with no `reviewColumns`, so the identity check ran against
the literal `in-review`. On a renamed board it returned `task is in 'signoff', must
be in 'in-review'` and the caller parked the card `failed` with "Merge confirmed but
finalization blocked".

Its sibling recovery path in auto-merge-finalization.ts had ALREADY solved this, by
passing the review-eligible sentinel instead of the card's own column — with the
rationale recorded at that site. project-engine simply never got the same treatment.

Rather than spell the sentinel a second time, it is now exported once as
REVIEW_ELIGIBLE_SENTINEL_COLUMN next to the helper whose contract defines it, and
both recovery paths use it. Two sites independently spelling a magic value is how one
of them came to be missing it.

This also answers the census, which flagged the new literal — correctly. Its guidance
(mine, from #2909) is to hoist a deliberate literal into a declaration where the
DELIBERATE-LITERAL marker actually attaches, rather than leaving it mid-expression
where the marker is silently ignored. That is what the shared constant does.

Revert measured: passing the card's own renamed column instead of the sentinel
reproduces the shipped string, which the second test pins.

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@gsxdsm, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 30 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b25ec704-62de-4e94-b3f5-fca72792862b

📥 Commits

Reviewing files that changed from the base of the PR and between dc41d24 and 940ac6f.

📒 Files selected for processing (7)
  • packages/core/src/index.gate.ts
  • packages/core/src/index.ts
  • packages/core/src/task-merge.ts
  • packages/engine/src/__tests__/finalize-merge-confirmed-renamed-lane.test.ts
  • packages/engine/src/auto-merge-finalization.ts
  • packages/engine/src/project-engine.ts
  • scripts/lib/lifecycle-column-census-baseline.json
📝 Walkthrough

Walkthrough

The core package exports REVIEW_ELIGIBLE_SENTINEL_COLUMN. Merge-confirmed finalization uses this sentinel for hard blocker checks, independent of renamed workflow columns. Tests cover eligible, renamed-column, and incomplete-step cases.

Changes

Renamed-board finalization

Layer / File(s) Summary
Review-eligibility sentinel contract
packages/core/src/task-merge.ts, packages/core/src/index.ts, packages/core/src/index.gate.ts
Defines and re-exports REVIEW_ELIGIBLE_SENTINEL_COLUMN with the value "in-review".
Merge-confirmed blocker checks
packages/engine/src/project-engine.ts, packages/engine/src/auto-merge-finalization.ts
Uses the shared sentinel for hard merge-blocker evaluation while preserving paused, error, and incomplete-step checks.
Renamed-lane regression coverage
packages/engine/src/__tests__/finalize-merge-confirmed-renamed-lane.test.ts, .changeset/finalize-parks-merged-work-failed.md
Tests renamed-lane finalization and records the patch changeset.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: preventing already-merged work from being marked as failed on boards with renamed columns.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch finalize-parks-merged-work-failed

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/task-merge.ts`:
- Around line 363-377: Update the contract comment above
REVIEW_ELIGIBLE_SENTINEL_COLUMN to begin with the required FNXC:Area-of-product
yyyy-MM-dd-hh:mm prefix, using the existing decision timestamp and an
appropriate product area; retain the DELIBERATE-LITERAL marker and the remaining
explanation unchanged.

In `@packages/engine/src/project-engine.ts`:
- Around line 3588-3602: Normalize the task status before the sentinel blocker
check used by getTaskHardMergeBlocker: in the recovery path around
REVIEW_ELIGIBLE_SENTINEL_COLUMN, clear queued in addition to merging and
merging-pr, preferably reusing the existing transient-status normalization. Add
a renamed-review-lane test covering a merge-confirmed task with status queued
and preserve the successful finalization outcome.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b7608417-dc10-46bf-ac99-badba6a5612a

📥 Commits

Reviewing files that changed from the base of the PR and between 1f0d371 and dc41d24.

📒 Files selected for processing (7)
  • .changeset/finalize-parks-merged-work-failed.md
  • packages/core/src/index.gate.ts
  • packages/core/src/index.ts
  • packages/core/src/task-merge.ts
  • packages/engine/src/__tests__/finalize-merge-confirmed-renamed-lane.test.ts
  • packages/engine/src/auto-merge-finalization.ts
  • packages/engine/src/project-engine.ts

Comment thread packages/core/src/task-merge.ts
Comment thread packages/engine/src/project-engine.ts
…s drop

Three findings from the #2964 review:

1. queued was not cleared before the sentinel blocker check in
   project-engine.ts, while auto-merge-finalization.ts cleared it.
   queued BLOCKS (SCHEDULER_TRANSIENT_STATUSES), so a merge-confirmed
   card the scheduler had queued was parked failed -- the same
   already-landed-work-parked-failed bug, one layer down. Fixed by
   extracting clearMergeConfirmedTransientStatus so the two paths cannot
   diverge a third time, matching how this change already de-duplicated
   the review-eligible sentinel.

2. The DELIBERATE-LITERAL sentinel comment lacked the FNXC prefix
   AGENTS.md requires. Marker detection is a substring search over
   ancestor comments, so the prefix does not disturb it -- verified via
   census --strict.

3. Not reported by either reviewer: census --strict already failed on
   this branch. Replacing the literal with the sentinel dropped
   auto-merge-finalization.ts 1 -> 0 without re-recording the baseline,
   leaving an allowance open for regrowth.

Mutation-verified: removing queued from the set fails exactly the queued
case (7 pass -> 1 fail).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gsxdsm

gsxdsm commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Both review threads addressed in a1b2 (pushed). Both findings were real. A third problem neither reviewer caught is fixed too.

1. queued not normalized before the sentinel check — real, and the more serious one

Confirmed rather than assumed:

packages/core/src/task-merge.ts
  SCHEDULER_TRANSIENT_STATUSES = new Set(["queued"])
  BLOCKING_TASK_STATUSES = new Set([...HARD_BLOCKING_TASK_STATUSES, ...SCHEDULER_TRANSIENT_STATUSES])

queued blocks. So a merge-confirmed card the scheduler had queued reached getTaskHardMergeBlocker with the status intact and got parked failed — already-landed work, the exact bug this PR fixes for renamed columns, surviving one layer down.

Fixed by extraction rather than by adding || status === "queued". The defect is not the missing value, it is that two finalization paths spell the same normalization independently — which is why they had already diverged. A third caller would diverge again. This change had already de-duplicated REVIEW_ELIGIBLE_SENTINEL_COLUMN for precisely this reason; clearMergeConfirmedTransientStatus is the same remedy applied to the status set, and both paths now call it.

2. Missing FNXC prefix on the DELIBERATE-LITERAL comment — real, with one thing worth checking first

Prefixed. I verified before editing that this could not break the census: hasDeliberateMarker searches for the marker as a substring across ancestor comments, not as a required prefix, so an added FNXC: line leaves detection intact. Re-confirmed with census --strict after the edit — the site stays classified deliberate and does not fall back into the backlog.

Worth stating because "add the required prefix" is the kind of quick win that can silently reclassify a reviewed literal as unreviewed work.

3. Not reported: census --strict was already failing on this branch

packages/engine/src/auto-merge-finalization.ts: allows 1, tree has 0

Replacing the literal with the sentinel dropped a guard without re-recording the baseline, leaving an allowance open for regrowth. I checked whether I had caused it by stashing my edits — it fails on the clean branch too. Baseline re-recorded (one line).

Verification

  • Merge gate green: 13 + 161 + 487 + 71 pass
  • Target suite 7 pass (was 3; added the three transient statuses plus a paired negative)
  • Mutation-verified: removing "queued" from the set fails exactly the queued case and nothing else — without it, the new cases pass against the broken code
  • census --strict, check-sql-column-literals, check-inert-flag-seams, check-fnxc-future-dates all exit 0
  • tsc --noEmit clean for @fusion/core and @fusion/engine; eslint clean

One judgement call, reversible: the new symbol is registered in index.gate.ts as well as index.ts. The gate barrel is deliberately slim to keep test:gate fast, so adding to it is not free — but the gate suite aliases @fusion/core to it and the engine call sites import the helper, so omitting it would break gate resolution. Happy to inline the set at both call sites instead if you would rather not grow that barrel.

gsxdsm added a commit that referenced this pull request Jul 31, 2026
THE GAP THAT LET THREE DEFECTS THROUGH IN ONE DAY.

unwired-lane-parameter.mjs catches a parameter that reaches NO caller. It is
deliberately satisfied by a mention anywhere, so PARTIAL wiring is invisible to it:

  #2956  getInReviewStallReason wired at 0 of 4 call sites, siblings wired
  #2963  both merge entry points unwired — merging IMPOSSIBLE on a renamed board
  #2964  merge-confirmed finalization unwired — already-landed work parked `failed`

Every one was a fix that added an optional parameter without the call-site sweep
that has to follow it. The guard was green throughout.

A CENSUS, NOT A GUARD, and the distinction is the point. Auditing the sites this
finds showed FOUR of seven were legitimately unwired: skipColumnIdentityCheck
callers have proven lane identity by a stronger means, a sentinel-column caller
wants the check satisfied by construction, and a dead export has no caller to wire.
Failing on those is ~57% false positives — and the sibling guard's own header says
why that is worse than a miss: it teaches people to disable the check.

So it ratchets like the lifecycle census: 36 known sites across 20 files, may only
shrink. A NEW unwired caller raises the count and fails; wiring one lowers it and
re-records.

Detection is AST-based (TypeScript), not regex: find exported functions accepting a
lane-named argument — directly or as an options-bag member — then find call sites
passing none of them. The ad-hoc regex scan I used during the audit produced false
negatives on multi-line calls, which is exactly how a caller gets missed.

Verified to FAIL on the recurrence: injecting one new unwired caller into
self-healing.ts reports `9 unwired now, baseline allows 8` and exits 1. A ratchet
that cannot fail is worse than none, so that was measured rather than assumed.

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
@gsxdsm
gsxdsm merged commit 126cee7 into main Jul 31, 2026
6 checks passed
@gsxdsm
gsxdsm deleted the finalize-parks-merged-work-failed branch July 31, 2026 05:06
gsxdsm added a commit that referenced this pull request Jul 31, 2026
**This is the gap that let three defects reach `main` in one day.**

`unwired-lane-parameter.mjs` catches a parameter that reaches **no**
caller. It is deliberately satisfied by a mention *anywhere*, so
**partial** wiring is invisible to it:

| | |
| --- | --- |
| #2956 | `getInReviewStallReason` wired at **0 of 4** call sites while
both siblings were wired |
| #2963 | both merge entry points unwired — merging was **impossible**
on a renamed board |
| #2964 | merge-confirmed finalization unwired — **already-landed work
parked `failed`** |

Every one was a fix that added an optional parameter without the
call-site sweep that has to follow it. The existing guard was green
throughout, correctly by its own contract.

## A census, not a guard — and that distinction is the whole design

Auditing the sites this finds showed **four of seven were legitimately
unwired**: `skipColumnIdentityCheck` callers have already proven lane
identity by a stronger means, a sentinel-column caller wants the
identity check satisfied by construction, and a dead export has no
caller to wire at all.

A check that failed on those is ~57% false positives. The sibling
guard's own header says why that is worse than a miss — *"it teaches
people to disable the check"* — and I agree, so this does not do it.

Instead it ratchets like the lifecycle census: **36 known unwired sites
across 20 files**, allowed to shrink and not to grow. A new unwired
caller raises the count and fails; wiring one lowers it and re-records.
The recurrence — adding a caller that forgets the lane answer — is
precisely what gets caught, and the legitimate sites cost one baseline
line each instead of a permanently red gate.

## Detection is AST-based, deliberately

It finds exported functions accepting a lane-named argument — directly
*or* as an options-bag member — then finds call sites passing none of
them.

Not regex: the ad-hoc scan I used during the audit produced false
negatives on multi-line calls, which is exactly how a caller gets missed
in the first place. Using a heuristic to police a defect caused by a
heuristic seemed like a poor trade.

## Verified to fail on the recurrence

A ratchet that cannot fail is worse than none, so this was measured
rather than assumed. Injecting one new unwired caller into
`self-healing.ts`:

```
[check-lane-wiring] call sites not passing a resolved lane argument INCREASED:

  packages/engine/src/self-healing.ts: 9 unwired now, baseline allows 8
```

exit 1, naming the file and the delta.

## Placement

Runs as a named `check:lane-wiring` step in `pr-checks.yml` beside the
lifecycle, SQL, inert-seam and FNXC ratchets — same convention, same
failure ergonomics, ~1s.

Note the baseline records today's state, which still includes the
#2963/#2964 sites because those fixes have not merged yet. When they
land the count drops and the baseline is re-recorded downward — the
ratchet working as intended rather than a conflict.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71; `tsc` engine clean; lint,
lifecycle census `--strict`, FNXC gate, and the new check all clean.
gsxdsm added a commit that referenced this pull request Jul 31, 2026
THE FAILURE MODE WORTH DISTINGUISHING: the rest of this family went QUIET on a
renamed board. This one SHOUTED.

getInReviewStallReason satisfied its own lane check from `context.reviewColumns`,
then called getTaskMergeBlocker WITHOUT them. That helper re-ran its column-identity
check against the literal `in-review` and returned

    task is in 'signoff', must be in 'in-review'

for a perfectly healthy card — which was surfaced as `{ code: "merge-blocker" }`. So
on a renamed board every in-review card was flagged as stalled, each citing a lane
the board does not have. That is how a signal stops being read at all.

SECOND SYMPTOM, found by the revert rather than by reading: on a GENUINELY failed
card the identity message wins over the real one, so the operator saw the bogus
column complaint instead of "task is marked 'failed': merge verification failed".
It did not only invent stalls; it masked the true reason for real ones.

The outer question was resolved and the inner one was not — the same
half-conversion the helper's own comment records for moves.ts, and #2963/#2964
fixed for the merge paths. Last one in the family.

Fixture note: `paused` is deliberately NOT the genuine-stall case. An earlier guard
returns undefined for a paused card before the merge blocker is consulted, so that
case would pass whether or not the lanes are forwarded.

Reverts measured: restoring the unforwarded call fails 2 of 3 — the healthy card
reports a merge-blocker stall, and the failed card reports the wrong reason.

Fusion-Task-Id: KB-SELF-HEALING-QUERIES
gsxdsm added a commit that referenced this pull request Jul 31, 2026
)

**The failure mode worth distinguishing: the rest of this family went
quiet on a renamed board. This one shouted.**

`getInReviewStallReason` satisfied its **own** lane check from
`context.reviewColumns` — then called `getTaskMergeBlocker` **without**
them. That helper re-ran its column-identity check against the literal
`in-review` and returned, for a perfectly healthy card:

```
task is in 'signoff', must be in 'in-review'
```

…which was surfaced as `{ code: "merge-blocker" }`. **Every in-review
card on a renamed board was flagged as stalled**, each citing a lane the
board does not have. That is how a signal stops being read at all.

## A second symptom, found by the revert rather than by reading

On a **genuinely failed** card, the identity message wins over the real
one. The operator saw the bogus column complaint instead of `task is
marked 'failed': merge verification failed`.

So it did not only invent stalls — it **masked the true reason for real
ones**. I would not have noticed that from the diff; it showed up
because the revert run asserted on the reason text.

## Same shape, last one in the family

The outer question was resolved and the inner one was not — the
half-conversion the helper's own comment records for `moves.ts`, and
#2963/#2964 fixed for the merge entry points. This is the last site the
audit turned up where the lane answer was already in scope and simply
not forwarded.

## Revert results

| | reverted → |
| --- | --- |
| the unforwarded call (what ships today) | **2 of 3 fail** — healthy
card reports a merge-blocker stall; failed card reports the wrong reason
|

**Fixture note worth keeping:** `paused` is deliberately *not* the
genuine-stall case. An earlier guard returns `undefined` for a paused
card before the merge blocker is ever consulted, so that case would pass
whether or not the lanes are forwarded — the vacuous shape this series
has produced eight times.

## Verification

`pnpm test:gate` 161 + 487 + 13 + 71; `@fusion/core` full suite **4878
passed** (457 files); `tsc` core clean; lint, lifecycle census
`--strict`, FNXC gate, changesets all clean.
gsxdsm added a commit that referenced this pull request Jul 31, 2026
…er was asked about `in-review` (#2976)

## PR merges silently never ran on a renamed board

`processPullRequestMergeTask` called its injected blocker with the task
alone:

```ts
if (getTaskMergeBlocker(task)) return "skipped";
```

So `options.reviewColumns` was undefined and the blocker's identity
check fell back to `task.column === "in-review"`. On a board whose merge
lane is named anything else it returns:

```
task is in 'checking', must be in 'in-review'
```

…which is truthy, so this function returns `"skipped"`. **Silently and
permanently** — nothing logs, nothing fails, the PR simply never merges.
`daemon.ts`, `serve.ts` and `dashboard.ts` all drain PR merges through
here, making this a third instance of the #2963/#2964 class ("merge
entry points unwired — merging was impossible on a renamed board").

Found via the baseline #2966 shipped:
`packages/cli/src/commands/task-lifecycle.ts` was a known-unwired call
site in it.

## Narrow resolution, deliberately

`resolveReviewColumns` is the **broad** set, and its own FNXC note warns
that a caller which admits on it *and then moves the card* will act on
cards the engine does not consider in review. This function merges and
moves to the complete lane — a state-changing admission — so it uses
`resolveMergeOrchestrationColumn`, the single lane the engine acts on.
That matches how `moves.ts` wires the same call.

Degradation is unchanged in both directions: `resolveWorkflowIrForTask`
substitutes the default IR rather than throwing, so a default board
resolves `in-review` and behaves identically; a v1-upgraded IR resolves
every role empty and keeps the documented legacy literal (covered by a
test).

## One shape choice worth flagging

The option is always **passed** and conditionally **valued**:

```ts
getTaskMergeBlocker(task, { reviewColumns: mergeLane ? new Set([mergeLane]) : undefined })
```

rather than making the whole argument conditional. These are identical
at runtime — the blocker treats an undefined `reviewColumns` exactly as
it treats absent options — but **only this shape is visible to
`lane-wiring-census.mjs`**, which matches an object-literal argument and
cannot see a ternary. I wrote the ternary first, and the gate still
reported the site as unwired; wiring a gate cannot check is how this
defect survived in the first place.

The gate then confirmed the fix and asked for the baseline in the same
commit:

```
[check-lane-wiring] unwired call sites decreased:
  packages/cli/src/commands/task-lifecycle.ts: 1 -> 0
```

Baseline re-recorded 9 → 8 in this commit, so the allowance cannot be
regrown into.

## Revert proof

**There was no test for this function at all** — that is why it went
unnoticed. Restoring only `task-lifecycle.ts`:

```
AssertionError: expected "vi.fn()" to be called with arguments: [ ObjectContaining{…}, …(1) ]
AssertionError: expected 'skipped' not to be 'skipped'
AssertionError: expected "vi.fn()" to be called with arguments: [ ObjectContaining{…}, undefined ]
      Tests  3 failed | 1 passed (4)
```

The one case that passes both ways is "still skips a card that is not in
any merge lane" — it guards against over-admission rather than proving
the fix, and I am not claiming it as coverage of the defect.

## Verification (measured)

- new suite **4/4**; with `pr-automerge-cleanup` **9 passed / 2 files**
- `tsc --noEmit`, `eslint` — clean
- `check-lane-wiring` (8, none added), `lifecycle-column-census
--strict`, `check-sql-column-literals`, `check-fnxc-future-dates` —
green

**Changeset added** (`patch`). `packages/cli` is the published
`@runfusion/fusion` and this changes user-facing merge behaviour, so
AGENTS.md requires one. My first pass hedged and left it to a maintainer
— that was wrong, the rule is not discretionary, and it is now in the
branch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant