fix(release): commit the approve() COMPLETED write under the release lock

approve() flushed the published proposal's COMPLETED status but left the
durable commit to the background caller (_run_approve_background), which runs
after approve()'s finally has already released the Redis lock. In that window
a concurrent reject() could acquire the freed lock, re-read a row whose
COMPLETED write was only flushed (invisible to its own session under READ
COMMITTED), pass its guard, and flip the just-published proposal to CANCELLED
— last writer winning the row. The bug-sweep (#638) fixed reject()'s side of
this but left approve()'s, so reject()'s 'fails closed' guarantee didn't hold
end to end.

Commit COMPLETED while still holding the lock (mirroring XPostService._post),
so it's durable before release and a racing reject sees it and refuses. A
cross-session regression test proves a fresh connection sees COMPLETED the
moment approve returns (it read 'pending' before the fix).
This commit is contained in:
Renn F
2026-07-22 15:53:02 +02:00
parent 17de29545a
commit f74131a122
3 changed files with 72 additions and 2 deletions
@@ -31,6 +31,7 @@ def _task(*, source: str = "release_manager") -> MagicMock:
def _session() -> MagicMock:
s = MagicMock()
s.flush = AsyncMock()
s.commit = AsyncMock()
return s