fix(authz): make stranded-execution recovery reachable and race-safe (BLO-21947) - #1320
Merged
kkroo merged 1 commit intoAug 14, 2026
Conversation
…(BLO-21947) Two fixes on top of #1229, both for findings Ally raised against it. 1. Critical — TOCTOU on the recovery cancel. `evaluateStrandedRunRecovery` runs against a snapshot read *before* `await access.decide(...)`, then the generic `heartbeat.cancelRun` re-reads and cancels anything in {queued, running, scheduled_retry}, terminating the process group. A dispatcher claim inside that window turns a cancel authorized *only* because the run never started into one that kills live work. The route's whole safety argument is `startedAt === null`, and that was evaluated at read time, not at write time. `cancelRunInternal` gains `requireUndispatched`, which compare-and-swaps on `status IN (queued, scheduled_retry) AND started_at IS NULL` and throws 409 when it loses. Ordering matters as much as the CAS: the claim is taken *before* the process teardown, because teardown runs ahead of the status write — swapping in its usual place would let this call kill a freshly-dispatched process and only then refuse, reporting "conflict, nothing happened" over work already destroyed. `setRunStatusIfCurrentStatus` (BLO-20396) is generalized to accept a status set plus an extra SQL condition, so this reuses the existing primitive rather than adding a second one. 2. Important — the monitor half was dead code for its intended actor. `assertCanManageIssueMonitor` grew a manager convergence-stall branch, but a manager never reached it: `PATCH /issues/:id` calls `assertAgentIssueMutationAllowed` first, `issue:mutate` denies there (`allow_manager_chain` is gated to `issue:comment`), and the guard returns 403 before the monitor check runs. I confirmed this finding on 08-10, then wrongly retracted it on 08-11 after finding that `hasActiveCheckoutManagementOverride` admits a manager. It does — but it sits *below* the `!boundaryDecision.allowed` early return, so it is only reachable once `issue:mutate` has already been allowed. Both my confirmation and my retraction were traces; this commit settles it by execution instead. The new route test fails on #1229 as-is with exactly the 403 described above. The fix is a shape-gated opt-in on the mutation guard, mirroring `allowCoordinationMetadata`: only `PATCH /issues/:id` sets it, the monitor must already be cleared for `convergence_stalled` (re-derived from stored state, never asserted by the caller), and the body may carry nothing but the monitor re-arm plus an optional comment. It also fails closed when the issue's existing policy carries stages / reviewPreset / authorizationPolicy, since a policy write replaces rather than merges and would otherwise erase another agent's review configuration as a side effect. Tests: `issue-execution-policy-routes` (3 new, driving the full PATCH route with a managing agent — the authorization-service tests call `access.decide` directly and cannot see this composition) and `agent-permissions-routes` (3 new, pinning the CAS flag, that board cancels stay unconditional, and 409 propagation). Co-Authored-By: Claude <noreply@anthropic.com>
Author
1 similar comment
Author
Author
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
Author
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: dffb745
Critical Issues (0)
Important Issues (0)
Strengths
- Claims the undispatched run before any cancellation teardown, turning a dispatcher race into a no-op conflict rather than terminating newly started work.
- Limits the manager recovery path to a persisted convergence-stalled monitor and a monitor-only PATCH; policy configurations that could be overwritten remain board-only.
- Adds route-level authorization and conflict coverage for both recovery paths.
Recommended Action
- No blocking issues found.
11 tasks
kkroo
merged commit Aug 14, 2026
77e5220
into
kkroo/blo-21947-stranded-run-recovery
2 of 3 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Thinking Path
Linked Issues or Issue Description
Refs BLO-21947 · Refs BLO-20396 (generalizes the CAS primitive that issue added)
Targets
kkroo/blo-21947-stranded-run-recovery, notmaster— this is a follow-up onto #1229, deliberately kept on a human-authored PR so it stays independently reviewable. A fresh App-authored PR ontomasterrecreates the review gate that stranded #1161. Take it, cherry-pick it, or tell me to drop it.Related PRs searched and linked: #1229 (base, supersedes #1161), #1161 (my earlier attempt — left open and unmodified at its author's request; carries the identical ordering defect fixed here).
What Changed
heartbeat.ts:cancelRunInternalgainsrequireUndispatched— compare-and-swaps the cancel onstatus IN (queued, scheduled_retry) AND started_at IS NULL, throwing 409 when it loses.heartbeat.ts: the claim is taken before the process teardown. Teardown runs ahead of the status write, so swapping in the write's usual place would let a lost race kill a freshly-dispatched process and only then refuse — reporting "conflict, nothing happened" over work already destroyed.heartbeat.ts:setRunStatusIfCurrentStatus(BLO-20396) generalized to accept a status set plus an extra SQL condition, so this reuses the existing primitive instead of adding a parallel one.setRunStatusIfUndispatchedis the new thin wrapper.agents.ts: the stranded-recovery cancel passesrequireUndispatched; board cancels stay unconditional.issues.ts:assertAgentIssueMutationAllowedgains the opt-inallowConvergenceStallMonitorRecovery, set only byPATCH /issues/:id, plus the shape predicateisConvergenceStallMonitorRecoveryPatch.stranded-run-recovery.ts: exportsUNDISPATCHED_HEARTBEAT_RUN_STATUSESso the CAS and the route predicate share one definition.issue-execution-policy-routes.test.ts, 3 new inagent-permissions-routes.test.ts.Why the monitor half needed anything at all.
assertCanManageIssueMonitorgrew a manager convergence-stall branch in #1229, but a manager never reaches it:PATCH /issues/:idcallsassertAgentIssueMutationAllowedfirst,issue:mutatedenies there (allow_manager_chainis gated toissue:comment), and the guard 403s before the monitor check runs.I confirmed this finding on 08-10, then retracted it on 08-11 after finding that
hasActiveCheckoutManagementOverridedoes admit a manager. It does — but it sits below the!boundaryDecision.allowedearly return, so it is only reachable onceissue:mutatehas already been allowed by some other path. If you acted on my retraction, it was wrong, and I'm sorry for the noise. Both my confirmation and my retraction were traces, which is why they could disagree; this settles it by execution instead.The new guard is shape-gated, mirroring
allowCoordinationMetadata:DELETE /issues/:id) are structurally unaffected — the PR fix(authz): wire allow_manager_chain + allow_issue_creator into issue:comment/issue:mutate (BLO-18797) #814 lesson;convergence_stalled, re-derived from stored state and never asserted by the caller;comment(a manager already holdsissue:commenthere, so that widens nothing);stages/reviewPreset/authorizationPolicy— a policy write replaces rather than merges, so a monitor-only body would otherwise silently erase another agent's review configuration as a side effect of recovering a monitor. Board stays available for those.Implementation note worth keeping: the route normalizes
req.body.executionPolicyin place before the guard runs, somode/commentRequired/stagesare present with schema defaults even when the caller sent onlymonitor. A shape predicate that counts keys there silently never matches.Verification
The load-bearing test is
issue-execution-policy-routes.test.ts→ "lets a managing agent re-arm a monitor cleared for convergence_stalled", which drives the full PATCH route with a managing agent. Against #1229 unmodified it fails with exactly:assertCanManageIssueMonitornever appears in that stack. The authorization-service tests callaccess.decidedirectly and cannot observe this composition, which is how the gap survived review in the first place.Controls included so the bypass can't quietly widen: a non-stalled monitor still 403s; a manager may not ride the bypass to also rewrite
description; board cancels are asserted to stay unconstrained; a lost CAS is asserted to surface as 409.Not verified, stated plainly: no DB-level test that a concurrent dispatch actually loses the CAS — race safety currently rests on the SQL predicate plus the route tests. The embedded-Postgres harness exists (
execution-lock-orphan-cleanup.test.ts) and I'll write that test if this is taken. A full local suite run was still in progress when this was opened; I am not claiming it green.Risks
permissionForActionso the generic grant fallback cannot satisfy them.setRunStatusIfCurrentStatusis used by four existing cancel paths. The signature change is additive (string | readonly string[], optional condition) and all existing callers pass a single status with no extra condition, so their SQL is byte-identical.Model Used
Claude Opus 4.5 (
claude-opus-5[1m], 1M context), extended thinking, with tool use and code execution — running as the Paperclip CTO agent.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template