## The bug
`recoverStaleTransitionPendingImpl` runs its whole per-task body inside
`store.withTaskLock(id, …)`. On the PostgreSQL arm it then read the task
with `store.getTask(id)` — and `getTaskImpl` opens with
`store.withTaskLock(id, …)` too.
**The per-task lock is non-reentrant.** This codebase states that
invariant in prose in two other files:
> "nesting inside `withTaskLock` would deadlock since the lock is
non-reentrant" — `branch-and-pr-entities.ts:561`
> "because the per-task lock is non-reentrant" — `workflow-ops.ts:464`
So the sweep waited forever on a lock its own frame was holding.
**PostgreSQL-only — which is every production install.** The SQLite arm
on the very next line reads through `readTaskFromDb`, a lock-free row
read. The backend-mode port swapped only the PostgreSQL arm to
`getTask`. The fix restores a lock-free read (`readTaskRow`) on that
arm; nothing else changes.
## Why it survived until now
The branch is entered **only** when a stale marker names a plugin hook
the trait registry still knows (`hasSurvivingPluginHook`). Three nearby
cases all miss it:
| marker | path |
|---|---|
| none | the row is never scanned |
| only `default-workflow:postCommit` | `hasSurvivingPluginHook` false —
marker just cleared |
| names an **uninstalled** plugin hook | reconciled away as degraded;
nothing survives to re-run |
| names a **registered** plugin hook | **reaches the in-lock read →
deadlock** |
Those first three are what the existing tests cover. The fourth is
precisely the state a crash mid-hook leaves behind. All four are
asserted in the new suite so the path cannot be re-narrowed and called
covered.
## Impact
This sweep runs at **startup**. A task left with such a marker deadlocks
startup recovery — and because it deadlocks *while holding the task's
lock*, that task is also left permanently unlockable.
## How it was found, including a correction
By **bisection**, not by reading. An earlier attempt of mine to drive
this recovery reported that "the sweep never returns". That was wrong in
a way worth recording: the sweep returns fine in three of the four
cases, and generalising the one hang to the whole function is what hid
the actual trigger across several sessions. Narrowing case by case —
empty store, plain task, default-only marker, unknown-hook marker,
registered-hook marker — put the fault on one line.
## Verification
- **Mutation-verified against the real defect.** With the fix reverted,
the regression case fails by name — `recoverStaleTransitionPendingImpl
did not settle within 8000ms — deadlock` — while the other three stay
green. That is the actual pre-fix behaviour, not a simulation of it.
- Every case is **timeboxed** on purpose: a deadlock otherwise surfaces
as a suite-level timeout naming no case, which is useless for locating
the fault. The deadline is not a flake knob — the fixed code settles in
~150 ms and the broken code never settles, so there is no value in
between to tune.
- A **vacuity guard** (no markers → scans nothing) so a change that
stopped listing marked rows can't leave the other cases green.
- `pnpm test:gate` — **exit 0**
- full live-PG E2E surface — **152/152**
- `pnpm lint` — clean
Changeset included (`patch`, category `fix`).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>