Files
fusion/packages/engine/src
gsxdsm 240a6be0aa fix(core): dependency update deadlocked on a self-blocked task (#2810)
## The bug

`updateTaskDependenciesImpl` wraps its whole body in
`store.withTaskLock(id, …)`, then reads the current blocker with
`readDepTask(task.blockedBy)` → `store.getTask()`. `getTaskImpl` opens
with `withTaskLock(id, …)` too, and the per-task lock is
**non-reentrant**.

So when `blockedBy` is the task's **own id**, the call waits forever on
a lock its own frame holds — and holds that lock while doing so, leaving
the row permanently unlockable.

## Found by generalising #2809, not by luck

#2809 removed one `getTask`-inside-`withTaskLock`. An AST scan for the
same shape across `packages/core` and `packages/engine` returned
**exactly three sites**:

| site | verdict |
|---|---|
| `lifecycle-ops.ts:1049` | the deadlock fixed in #2809 |
| `update-task-deps.ts:233` (`assertTaskExists`) | **safe** — a
self-dependency is rejected 15 lines earlier |
| `update-task-deps.ts:344` (`readDepTask`) | **this bug** |

Both surviving sites carry the same `FNXC:SqliteDualPathCleanup` note —
*"In backend mode, readTaskFromDb uses store.db (SQLite) which is
unavailable. Replace with async store.getTask() calls."* That port is
the common cause across the whole class: it swapped a **lock-free** read
for a **lock-acquiring** one.

## Why `blockedBy === id` is reachable

The dependencies list rejects self-reference explicitly (*"Task X cannot
depend on itself"*) — and that guard is precisely why the sibling
`assertTaskExists` read on this same lock is safe, so it is left
unchanged. **`blockedBy` has no such guard:** `updateTask({ blockedBy
})` accepts the task's own id.

The first test asserts that rather than assuming it. The whole
regression rests on that state being reachable, so it is proven, not
stipulated — and it also pins the asymmetry, so a future guard on
`blockedBy` will show up here as a deliberate change.

## The fix

Return the in-lock copy already in scope instead of re-reading. One
line, no new read path, and **strictly more correct than a re-read**: it
is the state this mutation is reasoning about, rather than whatever a
concurrent writer left behind.

## Verification

- **Mutation-verified against the real defect.** With the fix reverted
the regression case fails by name — `updateTaskDependencies did not
settle within 8000ms — deadlock` — while the precondition and the
ordinary-path cases stay green. That is the actual pre-fix behaviour.
- **A differential** covering the ordinary case (blocked by *another*
task). Without it, a fix that short-circuited *every* blocker read would
pass everything else.
- Timeboxed for the same reason as #2809: a deadlock otherwise surfaces
as a suite-level timeout naming no case. Not a flake knob — the fixed
path settles in ~0.5 s and the broken one never settles.
- `pnpm test:gate` — **exit 0**
- `pnpm lint` — clean

Changeset included (`patch`, category `fix`). Independent of #2809 —
different file, no overlap — but the same class, and the scan above is
the argument that the class is now closed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:12:33 -07:00
..
…