[codex] fix(issues): supersede in-review ownership races - #1212
Conversation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
Superseded in practice by #1366 — flagging rather than closing, since this is your PR and the call is yours. This PR and #1117 were in flight at the same time and solved overlapping halves of BLO-22666 differently. #1117 merged first (2026-08-11,
#1366 re-cuts only the genuinely-remaining scope — AC2 (run-ownership fences) and AC3 (the No action needed from you beyond closing this if you agree. |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 67c966b
Note: this PR carried an explicit review request from 2026-08-09T12:34Z and had zero Ally reviews on either surface (pulls/1212/reviews and issues/1212/comments) for ~8 days. That is a dropped review wake, not a slow one — tracked separately; flagging it here so the gap is visible on the PR itself.
The CAS fencing is the right shape and the SQL is careful in the places that usually go wrong. Both findings below are about a predicate that exists in two forms — once in SQL, once in TypeScript — which have drifted apart, and about an invariant the code asserts that this same PR falsifies.
Critical Issues (0)
Important Issues (2)
-
[code/gstack]
server/src/services/issues.ts:9850— The TS gatecurrentHasPendingExecutionStageomits thecurrentParticipantcheck that its SQL counterpart requires, so a subset ofin_reviewrows can become permanently un-checkoutable.preserveInReviewExecutionStageCheckoutCondition()(issues.ts:147) requires three conjuncts —status = 'in_review',executionState->>'status' = 'pending', andcurrentParticipantnon-null. The TS predicate at9850checks only the first two. For a row withexecutionState = {"status":"pending"}and nocurrentParticipant, the two disagree by construction:- SQL classifies it as non-pending, so the primary UPDATE treats it as an ordinary checkout;
- TS classifies it as pending, so
currentCheckoutActorIsEligible(9855) demandsallowPendingExecutionParticipant === true. The route only sets that flag whenactorMatchesExecutionParticipant(...)succeeds againstcurrentParticipant— which is absent — so it is never set.
If such a row also holds a stale
executionRunId, the primary UPDATE misses onexecutionLockCondition, and both stale-lock repair branches (9944,10036) are gated off bycurrentCheckoutActorIsEligible. Nothing clears the lock and no actor can ever claim the issue. It fails closed rather than corrupting state, but the terminal state is a permanently stranded issue.That the SQL bothers with
coalesce(... -> 'currentParticipant', 'null'::jsonb) <> 'null'::jsonbis itself evidence the authors consider the participant-absent state reachable.- Derive both from one source. Simplest fix: add the
currentParticipantpresence check to9850so the TS predicate matchesissues.ts:147exactly, and add a regression case for{"status":"pending"}with nocurrentParticipantplus a staleexecutionRunId— the existing suite always populatescurrentParticipant, so this hole is untested.
-
[code/tests]
packages/mcp-server/src/tools.ts:576— Addingin_reviewto the defaultexpectedStatusesfalsifies an invariantissues.tsdocuments and depends on, making unassigned ordinaryin_reviewissues claimable.The comment at
server/src/services/issues.ts:10107states: "Ordinary in_review is intentionally not claimable via checkout (it is excluded from every caller's expectedStatuses…)". This PR makesin_reviewthe default for every MCP caller, so that premise no longer holds.Trace an ordinary (non-pending-stage)
in_reviewrow withassigneeAgentId IS NULL AND assigneeUserId IS NULL:nonPendingExecutionStageConditionis TRUE, sounassignedCheckoutConditionmatches, the primary UPDATE succeeds, andcheckoutStatusForCurrentRow()(issues.ts:156) resolves to'in_progress'. The issue is pulled out of review by any agent calling checkout with defaults.The
422guard at10112does not catch this — it fires only whencurrent.assigneeAgentId === agentId, i.e. for the assignee, never for the unassigned case. The tool description ("Ordinary work moves to in_progress; pending execution-policy review/approval stages stay in_review") does not describe this either.Reachability depends on
in_reviewrows with both assignee columns null, which is not the common shape — hence Important rather than Critical. But the widening is unconditional and the guarding invariant is now stale.- Either narrow the primary UPDATE so ordinary
in_reviewrequires the pending-stage path regardless ofexpectedStatuses, or keep the widening and update the10107comment to state the new invariant. Add a test for unassigned ordinaryin_review+ the new default list —1264/1416exercise the new default only against anin_progressrow, so this path is uncovered.
- Either narrow the primary UPDATE so ordinary
Suggestions (2)
- [types]
server/src/services/issues.ts:4764,server/src/services/external-objects.ts:778—dbOrTx: anyappears on every handle threaded through the guard. The whole correctness argument here is "this write executes inside the guarded transaction", andanyis precisely the annotation that stops the compiler from checking it: a callback that ignoresdbOrTxand closes over the module-leveldbcompiles clean, runs outside the transaction, and looks guarded at the call site. Drizzle exposes a usable union for this; typing it would make the invariant load-bearing rather than conventional. Current call sites are correct — this is about the next one. - [code]
server/src/services/external-objects.ts:904—refreshForIssuepasses the samepersistinto a per-object loop, and eachpersistopens its owndb.transaction. So an N-object refresh is N transactions, each re-acquiring the ownership lock; if ownership changes mid-loop, earlier objects have committed and later ones throw409, leaving a partially-applied refresh. Probably acceptable, but it is a different guarantee than the single-transaction framing in the PR description ("keeps issue ownership, execution policy, and optional comment persistence in the same transaction").
Strengths
- The JSONB CAS is correct in the way this usually gets wrong:
jsonb=is semantic (normalized key order and numerics), and nullable columns route toIS NULLinstead of a JSON compare, which would otherwise silently evaluate to NULL and never match. Thecoalesce(..., 'null'::jsonb)guard shows the SQL-NULL vs JSON-null distinction was thought about deliberately. - The
assertCanAssignTasksbypass for a pending participant (routes/issues.ts) reads alarming but is sound: the route-level read is only a fast path, and the real authorization is re-verified atomically inside the UPDATE'sWHEREviapendingExecutionParticipantCondition. The TOCTOU window is not load-bearing. publishLiveEventfires afterawait persist(...), which resolves post-commit — so no live event announces a state change that later rolls back.SELECT … FOR UPDATEunder READ COMMITTED re-qualifies after the lock is granted, so the receipt compare-and-swap is genuinely atomic against a concurrent writer rather than merely optimistic.- +753 lines of regression coverage on the stale-lock routes is real investment, and the reassignment-race and bundled-comment-atomicity cases are the right ones to have written.
Recommended Action
- Reconcile the two forms of the pending-stage predicate (
issues.ts:147vsissues.ts:9850) before merge — that one is a stranding bug, and this codebase pays a high price for stranded issues. - Decide whether ordinary unassigned
in_reviewshould be claimable, then make the code and the10107comment agree. Cover both paths with tests. - Consider the typing and per-object-transaction notes opportunistically.
Thinking Path
Linked Issues or Issue Description
What Changed
Verification
Risks
Model Used
Checklist