fix(authz): give the convergence-stall escape hatch an executor (BLO-21947) - #1358
Conversation
…21947) The BLO-18294 convergence guard stops re-arming a monitor after N consecutive re-checks fail to narrow the gate set, and deliberately bars the assignee from granting itself a fresh budget: "a non-assignee actor must make that re-arm decision." `issue-execution-policy.ts` already implements exactly that decision — `resetConvergenceAfterStalledClear` grants a fresh convergence budget on an explicit re-arm after a `convergence_stalled` clear, and `sameAssigneeResetAfterPriorStall` throws so the assignee still cannot self-serve one. But no non-assignee could reach that code: the route gate `isLapsedMonitorRearmPatch` admitted only `status: "triggered"`, while a convergence stall produces `status: "cleared"` / `clearReason: "convergence_stalled"`. So the guard's documented escape hatch had no executor at all, which is the gap BLO-21947 records. Admit that second lapse shape. The change is deliberately narrow: - Only `convergence_stalled` is added. Monitors cleared for `done`, `invalid_status`, `invalid_assignee` or `bounds_exhausted` are not stalled-recovery cases and stay refused, so this does not become a general cross-assignee policy write. - The issue-status requirement is unchanged. The guard force-sets the issue to `blocked`, but a monitor cannot be armed on a `blocked` issue (`issueAllowsMonitor` -> the service throws MONITOR_INVALID_MESSAGE on an explicit update), so admitting `blocked` here would be unreachable code. Recovery stays a deliberate two-step: return the issue to active work, then have a non-assignee re-arm it. - Every other gate is untouched: single-key body, monitor-only normalized policy (no `stages` / `reviewPreset` / `authorizationPolicy`), and the `allow_manager_chain` requirement. Tests: 6 new in issue-agent-mutation-ownership-routes.test.ts (216 pass, was 210). Control run with the route change reverted and the tests kept fails the new positive case with the exact 403 this issue documents (`deny_missing_grant`, `boundary: grant`), so the fix is load-bearing. Co-Authored-By: Claude <noreply@anthropic.com>
1 similar comment
|
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 |
|
@ally please review at head What it does. Relaxes the route gate on Please focus on:
Tests: |
Thinking Path
Linked Issues or Issue Description
Refs BLO-21947 — "Non-assignee actors get 403 on both run-cancel and issue-monitor management — the designated convergence-stall recovery actor has no permission to recover." This PR closes its monitor half only; see Risks for what is deliberately left open.
Refs BLO-18294 (the convergence guard whose escape hatch this makes executable) and BLO-23082 (same gap, reached from productivity-review adjudication).
Related PRs found while searching (none overlap this predicate — verified by diffing each):
issue:recover_monitordesign that was superseded by BLO-22860'smanagerMonitorRearmAuthorizedon master; currentlydirty, 209 behind. Deliberately not chained on, per the CEO ruling on BLO-21947.CONFLICTING.triggeredre-arm case (BLO-24149) in the same test file; complementary, does not touch this predicate.heartbeat.ts/recovery/service.ts; neither touchesisLapsedMonitorRearmPatch.What Changed
server/src/routes/issues.ts—isLapsedMonitorRearmPatchnow admits a second lapse shape: a monitorclearedwithclearReason: "convergence_stalled", alongside the existingtriggered. Extracted to a namedisRecoverableLapsedMonitorwith the rationale inline.server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts— 6 new cases: the manager-chain re-arm of a convergence-stalled monitor succeeds; an actor without manager-chain still gets 403; and anit.eachpinning that monitors cleared fordone/invalid_status/invalid_assignee/bounds_exhaustedstay refused.No change to the service layer:
resetConvergenceAfterStalledClearandsameAssigneeResetAfterPriorStallalready implement the recovery and the assignee bar. This PR only makes them reachable.Verification
npx tsc --noEmit— clean.Control run — production change reverted, new tests kept:
That is byte-for-byte the 403 BLO-21947 documents, so the fix is load-bearing rather than the test passing incidentally. The three negative cases pass in both arms, as they should.
CI:
General tests (server)covers the new cases.Risks
Low, and the blast radius is bounded by three unchanged gates. The re-arm path still requires a single-key body, a normalized policy carrying a monitor and no
stages/reviewPreset/authorizationPolicy, and anallow_manager_chaindecision. This PR widens only which monitor state qualifies.clearReason; theit.eachfails if anyone later broadens it to "any cleared monitor".blocked. The guard force-sets the issue toblocked, so admitting it here looks required — but it would be unreachable code:issueAllowsMonitorpermits monitors only onin_progress/in_review, somonitorClearReasonForIssuereturnsinvalid_statusand the service throwsMONITOR_INVALID_MESSAGEon an explicit update, withresetConvergenceAfterStalledClearitself gated on!invalidReason. Recovery is therefore a two-step: return the issue to active work, then have a non-assignee re-arm. Called out because the one-step reading is the natural mistake.sameAssigneeResetAfterPriorStallstill throws, so this does not let a stalled assignee self-serve a fresh budget.POST /heartbeat-runs/:runId/cancelremainsassertBoard, and there is norun:recover_strandedorrequireUndispatchedCAS on master (re-verified this run). That follows as a separate PR; this one does not claim to close BLO-21947.Model Used
Claude Opus 4.5 (
claude-opus-5[1m]) via Claude Code, running as the Paperclip CTO agent with extended thinking and tool use (repository read/edit,git,gh, localvitest/tscexecution). All test output and the control run quoted above were executed in this run, not predicted.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templateblockedis deliberately excluded, is inline at the predicate🤖 Generated with Claude Code