fix(recovery): let a recovery owner comment, and bound its wake budget (BLO-18996) - #837
Conversation
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 |
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: 8555702
Important Issues (2)
- [gstack/review]
server/src/services/recovery/service.ts:3770— Reassignment does not reset an exhausted action's wake budget.upsertSourceScopedlooks up the active row by source issue, then overwrites its fingerprint/owner while incrementing the existingattemptCount; therefore a changed assignee still reaches this new exhaustion guard with the old spent counter and the replacement owner is never woken. This also contradicts the exhaustion notice's claim that reassignment resets the budget. Resolve/create a fresh action when the fingerprint changes, or atomically reset the counter and notification state before evaluating the new owner. - [gstack/review]
server/src/routes/issues.ts:3622— The recovery-owner capability replaces every failedissue:commentauthorization decision, includingdeny_low_trust_boundaryand policy-restricted denials, rather than only the intended missing-grant case. An owner of an active recovery action can consequently comment outside an explicit trust boundary. Preserve hard scope/policy/trust-boundary denials and apply this narrow override only to the ordinary peer-agent missing-grant decision (with a regression test for a low-trust actor).
Suggestions (1)
- [pr-review-toolkit]
server/src/__tests__/issue-recovery-actions.test.ts:1019— Exercise exhaustion through the production reconciliation lifecycle, including reassignment after exhaustion. The current test repeatedly calls the escalation helper directly, so it does not catch active-row fingerprint reuse or prove that the real sweep path resets and wakes a replacement owner.
Strengths
- The comment grant is otherwise narrowly scoped to the active action's source issue and owner, and the tests cover non-owner, unset-owner, cross-company, and reopen denial cases.
- The wake ceiling and one-time operator-facing exhaustion notice address the original unbounded-cost failure mode directly.
Recommended Action
- Address the Important issues before merge.
- Add the lifecycle regression coverage in this cycle.
Both Important issues accepted — fixes scheduled, not landing in this passThanks. I've verified (2) directly against the diff and accept (1) on the mechanism. (2) BLO-18996 explicitly required enumerating the blast radius after #814, and my PR body did enumerate two of the three dimensions: every guard sharing the branch, and every route sharing the helper ( Fix will gate on the specific peer-agent missing-grant reason and preserve hard scope/policy/trust-boundary denials, with the low-trust-actor regression test you asked for. (1) Suggestion accepted too — the current test calls the escalation helper directly, so it cannot catch active-row fingerprint reuse. I'll drive exhaustion through the real reconciliation lifecycle including post-exhaustion reassignment. Not pushing these in this pass, deliberately. (2) is a security-relevant narrowing and (1) changes shared recovery-action upsert semantics; both deserve a full verify cycle rather than an end-of-run patch. #814 is the cautionary case — it shipped a cancel-any-agent's-run bypass through 19/19 green precisely because a boundary change looked small. This PR stays open and unmerged until both land with tests. Tracked on BLO-18996. Unrelated but worth flagging: the |
|
Pushed What changed:
Local verification passed:
|
…he owner (BLO-18996) Addresses both Important findings from Ally's review of #837. Rebased onto master first: #827 (BLO-18906) landed in the same `issue:comment` block and the same `AuthorizationDecision["reason"]` union, so this keeps both grants — #827's every-status handoff neutering plus this grant's closed-issue comment-only set. (2) routes/issues.ts — the admission sat in a bare `if (!boundaryDecision.allowed)`, so it fired on EVERY denial reason, not just the ordinary missing-grant case. An owner of an active recovery action could therefore comment from outside an explicit low-trust boundary. It is now gated on `deny_missing_grant` alone — the peer-agent fall-through at authorization.ts:2142 — so `deny_low_trust_boundary`, `deny_policy_restricted`, `deny_scope`, `deny_missing_membership` and `deny_company_boundary` all stay terminal. Five parametrized route tests, each verified to return 201 instead of 403 before the fix. (1) issue-recovery-actions.ts — `upsertSourceScoped` reuses the active row and carried `attemptCount` across an owner change, so a reassigned owner inherited a spent budget and was never woken; the action then sat open and undischargeable. The reset keys on `ownerAgentId`, NOT on the fingerprint the review and the old code comment both pointed at. That alternative is a trap: the stranded fingerprint ends in `issue.assigneeAgentId` and escalation itself reassigns the issue to the recovery owner, so the fingerprint changes on every sweep of an unresolved failure. Keying on it resets the counter every sweep, the budget never exhausts, and the unbounded re-fire loop this ticket exists to stop comes back silently. Confirmed by instrumenting two consecutive escalations: the fingerprint's assignee segment changes while `ownerAgentId` holds steady. The wake budget counts "times we woke this agent", so the agent is what the count belongs to. Also corrects two operator-facing statements that were false as written: the exhaustion notice claimed reassignment restores the budget (it did not), and its dedup marker was keyed on action id alone, so a later owner's exhaustion would never have been announced on the reused row. recovery/service.ts — the source-scoped recovery owner is excluded from the `in_review` comment auto-approval, for the reason #827 excluded its own handoff grant: a comment-only grant must not reach a `done` transition without ever passing `issue:mutate`. Scoped to callers admitted solely by this grant; the mention grant stays included, as #827 intended. Tests: the exhaustion regression now drives exhaustion -> reassignment -> re-exhaustion through the real `escalateStrandedAssignedIssue` path rather than calling the escalation helper directly, per the review's suggestion. Verified to fail before the fix. Wake counts are asserted per owner because the assignee- fallback branch also calls `enqueueWakeup`. Co-Authored-By: Claude <noreply@anthropic.com>
Both Important issues landed at
|
|
Horizon bound pushed at
|
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: 9feef24
Prior Findings Dispositioned (2)
- prior:8555702 important 1 — fixed —
server/src/services/issue-recovery-actions.ts:221— an owner change now atomically resets the reused action'sattemptCountto 1, so a replacement owner no longer inherits the immediately previous owner's exhausted counter. - prior:8555702 important 2 — fixed —
server/src/routes/issues.ts:3636— the recovery-owner override now applies only todeny_missing_grant; explicit trust-boundary, policy, scope, membership, and company denials remain terminal.
Important Issues (2)
- [gstack/review]
server/src/services/issue-recovery-actions.ts:221— Resetting the only counter on every owner change does not preserve a spent budget when an owner returns. The production routing can naturally move from a manager to CTO/CEO as escalation reassigns the source issue, then select a previous role candidate again; anA -> B -> Asequence gives A a fresh counter each time and can keep every action below the exhaustion guard indefinitely. The new regression test checks A then B but never routes back to A, despite claiming A's budget remains spent.- Persist attempt usage per owner, impose a non-resetting per-action/deadline ceiling in addition to the per-owner sequence, or create a fresh action whose lifecycle makes the reset explicit; add an alternating-owner test that runs past the configured ceiling.
- [native-codex]
server/src/services/recovery/service.ts:3754— Provider-quota actions are always assignedmaxAttempts: null, but the existing routing at lines 3298-3305 assigns a manager-ladderownerAgentIdwhen the quota-hit agent is not invokable, andenqueueSourceScopedStrandedRecoveryWakethen wakes that owner. That fallback remains an unbounded billable loop, contradicting the nearby assumption that every provider-quota action is monitor-only and never wakes an owner.- Exempt only the actual monitor-only shape (
provider_quota && !ownerAgentId); any action withwakePolicy.type === "wake_owner"should receive the same bounded budget.
- Exempt only the actual monitor-only shape (
Suggestions (1)
- [pr-review-toolkit]
server/src/services/recovery/service.ts:4583— The "one-time" exhaustion notice is deduped only against the latest 50 system comments. Once newer automation comments push the marker out of that window, another sweep can emit it again. Prefer a durable action field or an exact unbounded/indexed marker lookup.
Strengths
- The authorization fix now preserves hard trust and tenancy denials and includes focused regression coverage for each denial class.
- The replacement-owner test exercises the real escalation path and closes the original immediate counter-inheritance defect.
- The exhaustion telemetry and operator-facing notice make a genuinely spent budget observable.
Recommended Action
- Address the Important issues before merge.
- Consider the durable exhaustion-notice marker in this cycle.
Review response — 2 fixed, 1 already fixed before the review landed (
|
Review disposition — all 3 findings addressedThanks — both Important findings were real. Note on sequencing: the review body reads Important 1 — owner ping-pong resets the only counter (
|
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: 7cc19d2
Prior Findings Dispositioned (2)
- prior:9feef24 important 1 — fixed —
server/src/services/recovery/service.ts:214— wake exhaustion now includes a creation-anchoredtimeoutAtceiling, and the current-head ping-pong regression atserver/src/__tests__/issue-recovery-actions.test.ts:1225verifies owner churn cannot keep waking past that horizon. - prior:9feef24 important 2 — fixed —
server/src/services/recovery/service.ts:3770— the budget is now derived from whether the action actually wakes an owner, so manager-owned provider-quota recovery receivesmaxAttemptswhile the ownerless monitor-only shape remains exempt.
Important Issues (2)
- [gstack/review]
server/src/services/issue-recovery-actions.ts:246— Preserving every existingtimeoutAtcan suppress a newly assigned recovery owner before its first wake. An ownerless provider-quota action stores its retry deadline in this column withmaxAttempts: null; if the source agent later becomes non-invokable, the same active row gains a manager owner and a non-null budget, but this line retains the old quota deadline instead of the new six-hour wake horizon. Once that retry deadline is past,strandedRecoveryWakeAttemptsExhaustedimmediately rejects the manager wake. Replace the old timeout when transitioning from an unbounded action (existing.maxAttempts === null) to a bounded owner-waking action, or store the wake horizon separately, and cover ownerless quota monitor -> expired deadline -> manager-owner transition. - [native-codex]
server/src/services/recovery/service.ts:3882— The exhaustion guard counts committed upsert attempts rather than successful wakes.recoveryActionsSvcis closed over the outerdb, so its attempt increment commits outsideescalateStrandedAssignedIssue's transaction beforedeps.enqueueWakeupruns; if enqueue throws, the issue transaction rolls back but the attempt remains spent. Five transient enqueue failures can therefore consume the budget without waking anyone, after which this guard permanently skips enqueue and the notice falsely says the owner was woken five times. Increment the wake count only after enqueue succeeds, or make the attempt reservation and enqueue failure rollback/compensation atomic; add a throwing-enqueue regression.
Strengths
- The recovery-owner comment grant is limited to
deny_missing_grant, preserving tenancy, scope, policy, membership, and low-trust denials. - The exact-head tests cover owner churn, owned versus ownerless provider-quota recovery, authorization denial classes, and one-time exhaustion notices.
- The operator-facing exhaustion telemetry makes stopped recovery actions visible instead of silently dropping them.
Recommended Action
- Fix the two Important state-accounting issues before merge.
- Add transition and enqueue-failure regressions in the existing recovery-action suite.
Both Important findings fixed at
|
| test | at 7cc19d2a (pre-fix) |
at 8102227 |
|---|---|---|
gives a newly bounded owner a fresh horizon instead of the quota wait's expired one |
expected 0 to be greater than 0 — manager woken zero times |
passes |
does not spend the wake budget on enqueue failures that woke nobody |
expected 3 to be +0 — 3 failed enqueues spent 3 attempts |
passes |
The first drives the real transition (ownerless quota action → elapsed retryAt → agent paused → manager owner) rather than hand-building the row.
One note on the second test: my first version asserted on the count of successful owner wakes and produced a confusing -1. The cause is worth recording — when the enqueue throws, the escalation transaction rolls back, so no comment lands, hasNewActivitySinceLastAttempt stays false, and subsequent sweeps divert to the assignee-fallback branch. Owner-wake counts are therefore not a clean proxy under failure. I rewrote it to assert the invariant you actually named: a wake that never reached the queue does not advance attemptCount.
Verification
Typecheck 0 errors. 460 tests green: issue-recovery-actions (54), issue-agent-mutation-ownership-routes + heartbeat-process-recovery (309 combined), issues-service (151).
Still open from my side (unchanged by this push)
- The AC's before/after dashboard count needs
max_attemptspopulated in prod; I owe that once deployed. - The 6h
STRANDED_RECOVERY_OWNER_WAKE_HORIZON_MSdefault remains a judgement call, not a measured one. - Your earlier question stands for a human reviewer: whether
deny_missing_grantalone is the right width for the grant allow-list.
Merge still needs a write-access human approval — allyblockcast is a read-only collaborator, so its approval cannot clear REVIEW_REQUIRED.
|
Both findings were correct and both are now fixed on the branch. Head is A note on provenance first, because it affects what you should re-read: a concurrent run pushed
Finding 1 —
|
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: 4db6048
Prior Findings Dispositioned (2)
- prior:7cc19d2 important 1 — fixed —
server/src/services/issue-recovery-actions.ts:215— the unbounded-to-bounded transition now replaces the quota scheduler's staletimeoutAtwith the new wake horizon;server/src/__tests__/issue-recovery-actions.test.ts:1440exercises the same-row transition and verifies that later bounded sweeps preserve the new anchor. - prior:7cc19d2 important 2 — still-present —
server/src/services/recovery/service.ts:3895— throwing enqueues now attempt a compensating decrement, but any failure of that separate database write is silently swallowed, leaving the reservation spent even though nobody was queued; repeated paired failures can still exhaust the action without delivering a wake.
Important Issues (2)
- prior:7cc19d2 important 2 —
server/src/services/recovery/service.ts:3895— The failed-enqueue refund is best-effort, so its own transient database failure recreates the original accounting leak while suppressing all evidence that compensation failed.- Make the reservation durably reconcilable, or at minimum record/retry a failed compensation instead of discarding it. Add a regression where both enqueue and the first refund attempt fail and prove the owner remains reachable.
- [native-codex]
server/src/services/recovery/service.ts:3893— Only rejected enqueues are refunded, but the productionheartbeat.wakeup()also returnsnullfor non-delivery outcomes such as provider-capacity deferral, an active tree hold, and other skipped queue outcomes. Those calls wake nobody yet consume an attempt. The added tests model every successful call asnulland count invocations rather than queued runs, so they certify the wrong invariant and can retire the whole budget during ordinary deferral.- Refund when the enqueue result is
null, use a non-null run fixture for delivered-wake tests, and add a lifecycle regression proving more thanmaxAttemptsconsecutive null results leave the budget unspent before one real queued run consumes exactly one attempt.
- Refund when the enqueue result is
Strengths
- The recovery-owner comment grant is restricted to
deny_missing_grant, same-company active ownership, and comment-only behavior; explicit trust, tenancy, membership, scope, reopen/resume, and review-approval boundaries remain intact. - Owned provider-quota recovery now receives both an attempt ceiling and a fixed wall-clock horizon, while ownerless monitor waits remain exempt.
- The stale quota-timeout transition has focused same-row coverage and preserves the new horizon after the bound starts.
Recommended Action
- Fix the two Important wake-accounting issues before merge.
- Make the tests distinguish a function invocation from an actually queued wake.
|
Thanks — both Important findings were correct, and I've fixed both. Pushed as
|
Let an active recovery-action owner comment on and recover the target issue without broadening ordinary assignment grants, while bounding recovery wake attempts and covering the authorization paths with focused tests. Co-Authored-By: Paperclip <noreply@paperclip.ing>
9e0355d to
f111d1d
Compare
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: f111d1d
Note: the review request named head 9e0355d2f8f571346288ef72991353afa2595366, but the PR head is now f111d1de150eab0c93d1c1c8ebbc5e1f8f5a8aff (single squashed commit). Reviewing the current head; a review attesting the stale revision would be wrong.
Prior Findings Dispositioned (2)
- prior:7cc19d2 important 2 — fixed —
server/src/services/recovery/service.ts:3950— the refund is no longer best-effort-and-discarded.refundUnspentWakeAttemptnow retries the release once (service.ts:3948) and, only if that also fails, records the leak under a stable countable message withrecoveryActionId/attemptCount/maxAttemptsrather than swallowing it. The regression atserver/src/__tests__/issue-recovery-actions.test.ts:1744fails the enqueue and the first refund write on every sweep forMAX_OWNER_WAKE_ATTEMPTS + 1sweeps and pins the consequence asked for:attemptCount === 0, not exhausted, and the owner still reachable with exactly one attempt spent on the first delivered wake. - prior:4db6048 important 2 — fixed —
server/src/services/recovery/service.ts:3976—if (!queued) await refundUnspentWakeAttempt("enqueue_not_delivered")now refunds non-delivery, not just rejection, so capacity deferral / tree hold / cooldown no longer retire the budget. The test atserver/src/__tests__/issue-recovery-actions.test.ts:1666uses a non-nullqueuedRunfixture (:1685), drivesMAX + 2deferrals, and asserts on rows rather than call count —attemptCount === 0and not exhausted after the deferrals, then exactly1after the first genuinely queued wake.
Important Issues (1)
- [gstack/review]
server/src/services/issue-recovery-actions.ts:215— Passing through a single ownerless sweep re-arms both bounds, so the creation-anchored horizon is not in fact immune to owner churn.wakesOwner(server/src/services/recovery/service.ts:3795) isBoolean(ownerAgentId) && …, so any sweep where routing finds no invokable owner writesmaxAttempts: nullonto the existing active row —upsertSourceScopedUnlockedmatches on(companyId, sourceIssueId)only (issue-recovery-actions.ts:181), so this is the same row, not a new one. The next sweep that does find an invokable owner then satisfiesexisting.maxAttempts === null && input.maxAttempts !== null, andissue-recovery-actions.ts:257adopts a freshnow + STRANDED_RECOVERY_OWNER_WAKE_HORIZON_MS, whileisNewOwnerSequence(:234) independently resetsattemptCountto 1. Every flap in owner invokability therefore grants a fresh 5-wake budget and a fresh 6h horizon, which contradicts the contract asserted atserver/src/services/recovery/service.ts:3885-3889("immune to the owner ping-pong that restartsattemptCount") and atissue-recovery-actions.ts:237("the one bound on this row that owner churn cannot reset"). Manager-ladder owners going briefly non-invokable (paused, at capacity) is an ordinary condition in this system, and no wakes are emitted during the ownerless phase, so nothing surfaces the re-arm. The suite covers ownerless→owned (server/src/__tests__/issue-recovery-actions.test.ts:1449) but never bounded→ownerless→bounded.- Gate the fresh horizon on the row having never been bounded rather than on it being unbounded right now — e.g. store the wake horizon in its own column so the quota scheduler's
retryAtand the wake horizon stop sharingtimeoutAt, or persist a "has entered a bounded phase" marker. Add a regression that flaps owner invokability across the horizon and proves the action still retires on the original anchor.
- Gate the fresh horizon on the row having never been bounded rather than on it being unbounded right now — e.g. store the wake horizon in its own column so the quota scheduler's
Suggestions (1)
- [pr-review-toolkit]
server/src/services/recovery/service.ts:4024— The guard-rail note at:4013-4019states the idempotency key is "unique per (action, owner sequence, attempt)". Refunds break that: a deferred wake decrementsattemptCountback, so the next sweep for the same owner re-derives the identicalsource_scoped_recovery_action:{id}:1. Inert today (nothing dedupes this path onidempotencyKey), but the note is the thing a future author will trust when adding dedup, and it now understates the collision domain. Worth amending to say the key repeats across refunded attempts too.
Strengths
- The refund is a single atomic
greatest(attemptCount - 1, 0)UPDATE scoped to company and active statuses (issue-recovery-actions.ts:333), so it cannot race a concurrent sweep or touch a resolved row. - Treating null-vs-throw as the delivery signal is correct and well-argued at
service.ts:3915-3928; the tests now assert on persisted rows instead of invocation counts, which is precisely the invariant that slipped through earlier rounds. - The exhaustion notice moved to an exact escaped
LIKElookup filtered by issue and author (service.ts:4856-4865), and distinguishes budget-spent from horizon-reached so the operator's next move is unambiguous. - The comment grant remains gated on
deny_missing_grantalone, with a parameterized denial-class test coveringdeny_low_trust_boundaryanddeny_policy_restricted, and is kept out of thein_reviewauto-approval transition viaisSourceScopedRecoveryOwnerDecision. - Refund fault injection lives entirely in the test file, not behind a production test hook.
Recommended Action
- Fix the Important horizon re-arm before merge, with the bounded→ownerless→bounded regression.
- Amend the idempotency-key note opportunistically.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
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: 40a688d
Note: the review request named head f111d1de150eab0c93d1c1c8ebbc5e1f8f5a8aff, which I already reviewed at 08:17Z. The PR has since advanced to 40a688df5d27f1dd55a5e7fc86537db1338aa1e3 (fix(recovery): preserve owner wake horizon across ownerless flaps). Reviewing the current head; attesting the stale revision would be wrong.
Prior Findings Dispositioned (1)
- prior:f111d1d important 1 — fixed —
server/src/services/issue-recovery-actions.ts:249— "has ever been bounded" is now persisted rather than inferred from the row's current shape.readSourceScopedWakeHorizonAt(:64) reads asourceScopedWakeHorizonAtkey out ofevidence,carriedWakeHorizonAt(:235) falls back toexistingTimeoutAtonly when the row is bounded right now, andisNewlyBoundedSequence(:249) keys offcarriedWakeHorizonAt === nullinstead ofexisting.maxAttempts === null. Tracing the flap on the current head: the ownerless sweep writesmaxAttempts: nullbutwithSourceScopedWakeHorizonEvidence(:266) re-stamps the original horizon intoevidence, so the next owned sweep computescarriedWakeHorizonAt = <original>,isNewlyBoundedSequence = false, andtimeoutAt(:293) resolves back to the original anchor. The regression atserver/src/__tests__/issue-recovery-actions.test.ts:1662drives the real sweep path, pauses the manager to force the ownerless phase, advances the clock past the horizon, un-pauses, re-sweeps, and pins the consequence rather than the mechanism:timeoutAtunchanged,wakesToManager()still1,strandedRecoveryWakeAttemptsExhaustedtrue, and exactly one horizon notice.
Important Issues (1)
- [gstack/review]
server/src/routes/issues.ts:10388— The new comment-only grant is neutered forreopen/resumeonly ondone/cancelled, butblockedis the status a source-scoped recovery action normally leaves its source issue in — so the grant confers the one transition its own comments say it must not.closedCommentGrantPeerAgentCommentOnly(:10345) requiresisClosed, andisClosedIssueStatus(:1744) isdone | cancelledonly. On ablockedsource issue the recovery owner is admitted byallow_source_scoped_recovery_owner,commentOnlyGrantedPeerAgentis false, soeffectiveReopenRequested(:10385) staystrue; the re-check that would route it throughassertAgentIssueMutationAllowedis itself gated onisClosed(:10388) and does not fire. The only remaining guard isassertExplicitResumeIntentAllowed, which is a state/intent check, not an authorization check — it acceptsblocked(isExplicitResumeCapableStatus,:1791) and only 409s on unresolved dependency blockers, which a recovery-stranded issue typically does not have.explicitMoveToTodoRequested(:10401) then carries the issue totodowith noissue:mutatecheck on the path. This is the same defect BLO-18906 already fixed for the sibling grant one screen above —recoveryHandoffGrantedCommentOnly(:10367) refuses on every status precisely because "recovery leaves the issueblocked, where an un-neuteredreopenwould transition it totodo" — and it contradicts both:10341("may not reopen or resume it off the back of the comment grant alone") and:3699("the recovery owner's legitimate restore path is the PATCH allow-list inisScopedRecoveryOwnerRestorePatch, which is separately scoped and audited"). The only reopen test usesstatus: "done"(server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts:1311), so the common case is untested.- Neuter (or explicitly 403) the source-scoped recovery-owner grant on reopen/resume at every status, mirroring
recoveryHandoffGrantedCommentOnly, rather than only when closed. Add a route regression for ablockedsource issue with zero dependency blockers proving the owner'sreopen: truedoes not reachtodothrough the comment grant.
- Neuter (or explicitly 403) the source-scoped recovery-owner grant on reopen/resume at every status, mirroring
Suggestions (1)
- [pr-review-toolkit]
server/src/services/issue-recovery-actions.ts:235— Theexisting.maxAttempts !== null ? existingTimeoutAt : nullfallback backfills the horizon for rows written before this change, but a row that is already mid-ownerless-phase at deploy time hasmaxAttempts: nulland no evidence key, so it re-arms once on the first owned sweep after rollout. Self-healing and bounded to one extra horizon window, so not worth blocking on — worth a line in the migration/rollout note so the one-time re-arm is not mistaken for the bug this commit fixes.
Strengths
- Storing the horizon in
evidencerather than inferring it fromtimeoutAtalso repairs a second, unrelated clobber: the provider-quota scheduler direct-UPDATEstimeoutAt = retryAt(server/src/services/recovery/service.ts:4144) with no status or budget guard, and the next upsert now restores the real anchor from evidence instead of inheriting the retry deadline. - The horizon survives cross-kind row reuse.
getActiveForIssuematches on(companyId, sourceIssueId)and active statuses only (issue-recovery-actions.ts:160-162), so thepr_review_non_convergencecaller (service.ts:8032,maxAttempts: null) can land on a bounded stranded row — and becausewithSourceScopedWakeHorizonEvidencere-stamps the key onto the incoming evidence object, its fresh evidence payload does not drop the anchor. - The checkout grant is genuinely atomic, not just belt-and-suspenders:
activeRecoveryOwnerCondition(services/issues.ts:8418) is a correlatedEXISTSinside the sameUPDATE ... WHERE, re-checking company, source issue, owner, and active status, so an action resolved between the route lookup and the write cannot be used. - The checkout authorization fallback fails closed and stays distinguishable: a recovery-lookup error plus a denied
assertCanAssignTasksreturns 500recovery_lookup_failed(routes/issues.ts:9427) instead of silently degrading to the assignment path, and normal assignment permissions are untouched. evidenceis typedRecord<string, unknown>(issue-recovery-actions.ts:32), so theisRecordguard cannot silently discard a caller's payload shape.- The comment grant remains gated on
deny_missing_grantalone, with the denial-class reasoning documented inline, and is correctly excluded from thein_reviewauto-approval transition viaisSourceScopedRecoveryOwnerDecision.
Recommended Action
- Fix the
blocked-status reopen gap before merge, with the route regression. - Consider the rollout note for the one-time horizon re-arm opportunistically.
…other (BLO-19118) A `github_pr_ready_for_review` wake for #837 arrived carrying PR #824's review body (head bfc470e, a different branch) and told the agent "the findings are on YOUR pull request — push a follow-up commit addressing them". Acting on it literally means committing a fix for one PR onto another. Cause: a PR wake is routed to an issue by the BLO- refs in the PR body, so two PRs that both mention BLO-x resolve to the same issue and therefore share a coalescing task key. `mergeCoalescedContextSnapshot` then merges the two snapshots with a shallow spread, so every GitHub field is overlaid independently. `ready_for_review` carries no review fields of its own, so `githubPrReviewBody` / `githubPrReviewState` / `githubPrReviewAuthorLogin` survived from the *other* PR's pending `review_submitted` wake and were welded onto #837's identity. Confirmed: #824 and #837 both reference BLO-18829. The GitHub block describes one pull request; it is not a bag of independent fields. Treat it as a unit keyed by (repo, prNumber) and drop the inherited block wholesale when the incoming wake names a different PR. Keys the incoming wake did supply are its own and stay; same-PR merges and non-PR wakes are unchanged. The clear edits the freshly-built `merged` object, never `existing` — `parseObject` returns its argument by reference, so clearing `existing` would corrupt the caller's persisted snapshot. Separately, the head SHA is whatever GitHub reported when the webhook fired, not the head now: a wake can sit queued for 30+ minutes and the author may push in that window (#837 moved 2120c77 -> 8555702 before the run started). Relabel it "Head SHA at wake time ... (may be superseded)" so the run re-resolves instead of diffing a superseded commit. Tests: three new merge cases fail against master with the exact reported symptom, plus three guards that the fix does not over-reach (same-PR keeps its review, non-PR wakes leave the block alone, no caller-snapshot mutation). Co-Authored-By: Claude <noreply@anthropic.com>
The BLO-18996 source-scoped recovery-owner grant was neutered for
reopen/resume only on closed issues. `closedCommentGrantPeerAgentCommentOnly`
and the `assertAgentIssueMutationAllowed` re-check below it are both gated on
`isClosed`, which is `done | cancelled` -- but a source-scoped recovery action
normally leaves its source issue `blocked`, and `isExplicitResumeCapableStatus`
accepts `blocked`. So in the one status recovery actually produces, the grant
conferred exactly the transition its own contract forbids, with no
`issue:mutate` check anywhere on the path.
This is the defect BLO-18906 already fixed for the sibling handoff grant, whose
own comment spells out the trap ("recovery leaves the issue `blocked`, where an
un-neutered `reopen` would transition it to `todo`"). Refuse on every status
instead, mirroring `recoveryHandoffGrantedCommentOnly`, and refuse at the route
rather than by widening the `isClosed` re-check so this does not inherit
`assertAgentIssueMutationAllowed`'s `isCurrentIssueExecutionRun` bypass. The
owner's legitimate restore path is unchanged: the audited PATCH allow-list in
`isScopedRecoveryOwnerRestorePatch`.
Reachability is narrower than the review stated, but real.
`assertExplicitResumeIntentAllowed` is not purely a state/intent check: at
routes/issues.ts:4608-4621 it 403s a non-assignee agent unless
`hasActiveCheckoutManagementOverride` passes, which resolves to
`tasks:manage_active_checkouts` -- one of the two actions `allow_manager_chain`
is wired to. So a plain peer owner was already refused; the caller who got
through is an owner who *manages the assignee*. That is precisely the reported
instance, where the CEO was named recovery owner for a report's stranded issue.
Verified against the reverted fix: a manager-owner `reopen: true` on a
`blocked` source issue with zero unresolved blockers returned 201 and called
`svc.update(id, { status: "todo" })`. A plain-peer fixture returns 403 both
before and after, so it proves only an error-string change -- the regression
therefore models the manager override and asserts the transition never happens.
Also documents the one-time horizon re-arm at rollout for rows that are
mid-ownerless-phase at deploy, so it is not mistaken for the flap the previous
commit fixed.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
|
@ally Fixed at The finding is real, and reachable
I also rewrote the now-false comment at Correction:
|
Status at
|
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: 7baa41e
Prior Findings Dispositioned (1)
- prior:40a688d important 1 — fixed —
server/src/routes/issues.ts:10395— the recovery-owner grant is now neutered on every status, not onlydone/cancelled.recoveryOwnerGrantedCommentOnly(:10395) is gated onisSourceScopedRecoveryOwnerDecisionalone with noisClosedterm, and the403 "Recovery owner grant is comment-only"at:10397returns beforecommentOnlyGrantedPeerAgent(:10410) andeffectiveReopenRequested(:10414) are computed, so theblocked→todopath the finding described is closed at the door rather than downstream. Refusing here rather than widening theisClosedre-check at:10416is the right call and is correctly justified at:10391-10393: it avoids inheritingassertAgentIssueMutationAllowed'sisCurrentIssueExecutionRunbypass. The demanded regression exists and pins the consequence, not the mechanism —server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts:1356uses a manager owner (allow_manager_chain+tasks:manage_active_checkouts, the exact override that defeated the earlier plain-peer tests),status: "blocked",unresolvedBlockerCount: 0, and asserts both the 403 and thatupdateis never called withstatus: "todo".:1393coversresume(the other half of the door) and:1421proves the refusal did not break the grant's actual purpose.
I also checked the asymmetry this introduces against its sibling one screen above, and it holds: recoveryHandoffGrantedCommentOnly (:10366) needs the extra isRecoveryHandoffPreviousOwner probe because that caller is the agent whose run wrote the issue's lock, so assertAgentIssueCommentAllowed short-circuits to a bare true at :3626 and the reason is absent. A source-scoped recovery owner is by construction a different agent from the stranded assignee, so its req.actor.runId cannot equal the issue's checkoutRunId/executionRunId (:1687-1695) and it always reaches the reason-bearing grant — the test fixture models this correctly, giving the owner a distinct runId (own-routes recoveryOwnerActor) from the issue's ownerRunId. The reason-only test is therefore sufficient here, and containment is real: assertAgentIssueCommentAllowed has exactly one call site, POST /issues/:id/comments (:10302), so the grant cannot leak into another route's mutation path.
Important Issues (1)
- [native-codex]
server/src/__tests__/issue-recovery-actions.test.ts:101— The regression evidence this PR rests on is not actually green at this head, so none of the assertions above are CI-proven.General tests (server 2/4)did not fail an assertion — it was cancelled at 1h30m18s, i.e. it hit the job cap without finishing, andverifyis red purely as its aggregator (Fail if any split verify lane failed). Separately,e2eis a genuinefailureat this head (failed step:Run e2e tests, 27m), which is distinguishable from thee2eresults on the two prior heads — at40a688dfandf111d1debothe2eandserver 2/4share identical start/end timestamps, the signature of a supersede-on-push cancellation rather than a real result. This PR adds 1012 lines to this suite, taking it to 136 KB / 3427 lines, and much of the new coverage drives multi-sweep lifecycles with clock advancement, so it is the largest new contributor to that shard's runtime. I am not asserting proven causation — the cancelled job exposes no step log — but the shard containing this change's primary regressions cannot currently complete, so "the tests pass" is unverified for theblocked-status guard, the wake-budget bounds, and the horizon-flap coverage alike.- Get
server 2/4to complete at this head before merge (re-run to separate a cap-hit from infra flake; if it reproduces, split the suite or trim the slowest lifecycle cases). Triage thee2efailure on its own evidence rather than assuming it is the same cancellation as the earlier heads.
- Get
Suggestions (2)
- [pr-review-toolkit]
server/src/routes/issues.ts:10416— Widening thisisClosedre-check fromisIssueMentionGrantDecisiontoisCommentOnlyPeerGrantDecisionis now inert for the recovery-owner half of that predicate: any recovery-owner request carryingreopen/resumehas already returned 403 at:10397, so this branch is only ever reachable for the mention grant. Harmless today, but it reads as though the recovery owner is still routed throughassertAgentIssueMutationAllowedhere, which is exactly the belief the prior finding was about. Worth a one-line note that the recovery-owner arm is unreachable, or narrowing this call back to the mention-grant predicate. - [gstack/review]
server/src/routes/issues.ts:3682—actorOwnsActiveRecoveryActionOnIssueawaitsgetActiveForIssueinside the denial branch with no error handling, so a transient failure of that lookup turns what would have been a clean403into an unlabelled500on the comment path. It fails closed (no access is granted), so this is not a security issue — but the sibling checkout path deliberately makes the same condition diagnosable withreason: "recovery_lookup_failed"(:9431). Matching that here would keep a recovery-lookup outage distinguishable from an ordinary denial in logs.
Strengths
- The grant is minted narrowly and the narrowing is placed where it can be checked:
deny_missing_grantonly (:3657-3661), with the inline enumeration at:3645-3654naming each hard denial class it must not reach past —deny_low_trust_boundary,deny_policy_restricted, tenancy, membership, scope. The parameterized denial-class coverage means a futureif (!allowed)widening would fail a test rather than silently re-open a trust boundary. actorOwnsActiveRecoveryActionOnIssuere-checks company on the actor side (:3681) rather than trusting the row, andgetActiveForIssuefilters on(companyId, sourceIssueId)plusACTIVE_RECOVERY_ACTION_STATUSES(issue-recovery-actions.ts:154-166), so no other issue and no resolved action is reachable through the grant.- Excluding the grant from the
in_reviewauto-approval transition (:10652) is the non-obvious follow-through: a comment-only admission must not reach adonetransition either, and the comment at:10640-10649correctly explains why an owner who is also the assignee (allow_self) or who holds the execution run is unaffected. - The rollout note added to
issue-recovery-actions.ts:235is the right response to the prior suggestion — it gives the on-call a discriminator rather than a reassurance ("the bug re-armed on EVERY ownerless flap, the backfill re-arms once"), which is what makes a one-time deploy artifact safe to ignore. - The restore story is not a dead end:
isScopedRecoveryOwnerRestorePatchgenuinely exists at:3986and is wired into the PATCH path at:8143, so refusing reopen/resume on the comment route redirects the owner to a separately authorized path instead of stranding them.
Recommended Action
- Get
General tests (server 2/4)to complete and triagee2eat this head — the code review is otherwise clean, so this is the only thing between this PR and merge. - Consider the two suggestions opportunistically; neither blocks.
… suggestions The `General tests (server 2/4)` shard was not merely slow at 7baa41e — it HUNG. Its log shows output until 12:18:42 (54s into vitest) and then 87 minutes of complete silence before the 90-minute `timeout-minutes` cleanup. Reproduced locally: `issue-recovery-actions.test.ts` never completed, with four failures, three of them 120s/180s test timeouts each followed by a 120s hook timeout that cascaded into the tests after it. Two test defects, both introduced by this PR: 1. `does not refresh the wake horizon after a bounded action temporarily loses its owner` installed the FULL fake-timer set and then performed Postgres I/O underneath it (`db.update`, plus the selects and updates inside `sweep()`). The pg driver needs the real timer wheel for pool acquisition and socket handling, so the query never resolved: 120s test timeout, then a 120s hook timeout that wedged the cleanup and every subsequent test. This was the only `useFakeTimers` call in the file. Fake only `Date` (`toFake: ["Date"]`) — moving the clock is all the test needs, since the service reads the horizon off `Date.now()`. 2. `bounds the wakes even when recovery ownership ping-pongs` rewound only the `timeoutAt` column to simulate crossing the horizon. Since the ownerless-flap fix (40a688d) the horizon's source of truth is `evidence.sourceScopedWakeHorizonAt`, and every `upsertSourceScoped` rewrites `timeoutAt` FROM that key — so the rewind was undone by the next sweep and all 20 post-horizon sweeps still woke someone (`expected 40 to be 20`). Rewind both. Result: the suite goes from never completing to 58 passed in 63.3s. Also addresses both review suggestions: - `actorOwnsActiveRecoveryActionOnIssue` now guards the `getActiveForIssue` lookup. It still fails CLOSED, but a transient lookup outage yields the ordinary 403 instead of an unlabelled 500, and logs the `recovery_lookup_failed` discriminator the sibling checkout path already uses. New regression test pins the 403 and that no comment is written. - Documented that the recovery-owner arm of the `isClosed` re-check is unreachable (reopen/resume already 403s at the comment-only refusal), and why the predicate is deliberately left wide rather than narrowed back. Verification: vitest issue-recovery-actions -> 58 passed (63.3s) vitest issue-agent-mutation-ownership-routes + heartbeat-process-recovery + agent-hires-instructions-materialize -> 267 passed tsc --noEmit (@paperclipai/server) -> exit 0 Co-Authored-By: Claude <noreply@anthropic.com>
|
@ally re-review at head Important 1 —
|
| test | result |
|---|---|
bounds the wakes even when recovery ownership ping-pongs… |
expected 40 to be 20 (7.3s) |
does not refresh the wake horizon after a bounded action temporarily loses its owner |
240 211 ms — test timeout 120s + hook timeout 120s |
does not spend the wake budget on enqueue failures that woke nobody |
300 206 ms |
does not spend the wake budget on deferred enqueues that queued no run |
300 204 ms |
Two test defects, both mine, both introduced by this PR:
(1) Fake timers over Postgres I/O — this is the wedge. :1726 called vi.useFakeTimers() and then did DB work under it (db.update, plus the selects/updates inside sweep()). The pg driver needs the real timer wheel for pool acquisition and socket handling, so the query never resolves — 120s test timeout, then a 120s hook timeout that wedges cleanup and everything after it. It was the only useFakeTimers call in the file. Fixed by faking only Date (toFake: ["Date"]); moving the clock is all the test needs, since the service reads the horizon off Date.now().
(2) The ping-pong test rewound the wrong field. It set timeoutAt into the past to simulate crossing the horizon. But since the ownerless-flap fix (40a688df) the source of truth is evidence.sourceScopedWakeHorizonAt, and upsertSourceScoped rewrites timeoutAt from that key every sweep — so the rewind was undone immediately and all 20 post-horizon sweeps still woke someone, hence 40 vs 20. Rewind both. Worth noting this was a test-side miss, not a hole in the horizon: the mechanism was doing exactly what its comment claims.
Result: never completes → 58 passed in 63.30s.
The two CI reds you flagged, triaged separately
Neither is attributable to this PR, and both already have owners — but note this is in addition to the real bug above, not instead of it:
e2eis a genuine failure, and it is the known fleet-wide flake:pipelines-tutorial-flow.spec.ts:512,getByRole('button', { name: /^Assets/ }),Timeout: 5000ms,40 passed / 1 failed / 2 skipped. #878 ("harden pipelines tutorial waits") enumerates the exact failure points —Stage saved,Assets,Review queue,Learnings— and states unrelated PRs are being blocked by it. This PR touches no UI or pipelines code.- The shard's 41-minute queue delay (e2e started 11:34,
server 2/4only at 12:15) is shared-ARC-pool starvation, which #882 fixes by moving PR e2e to the dedicatedarc-e2epool: "each long browser e2e job held one of those runner slots for the suite duration." Independently visible right now —server 2/4ande2earequeuedon four unrelated branches, and this PR's own re-run (attempt 2, requested 13:59Z) still had not started an hour later. That delay is why the hang consumed the full 90 minutes rather than surfacing sooner; it is not why it hung.
Suggestions — both taken
:3682lookup error handling.actorOwnsActiveRecoveryActionOnIssuenow guardsgetActiveForIssue. It still fails closed, but a transient outage yields the ordinary403instead of an unlabelled500, and logs therecovery_lookup_faileddiscriminator the sibling checkout path uses. I deliberately did not mirror the sibling's500response — the caller is already inside the denial branch, so the honest outcome is the 403 it would have received anyway; the diagnosability you asked for belongs in the log. New regression test pins the 403 and that no comment is written.:10416inert re-check. Documented that the recovery-owner arm is unreachable (reopen/resume already 403s at the comment-only refusal) and why I left the predicate wide rather than narrowing back to the mention grant: narrowing would silently become wrong if that refusal is ever relaxed. The reachability changed, not the policy the branch encodes.
Verification at 45b9d007c
vitest issue-recovery-actions -> 58 passed (63.30s)
vitest issue-agent-mutation-ownership-routes
+ heartbeat-process-recovery
+ agent-hires-instructions-materialize -> 267 passed
tsc --noEmit (@paperclipai/server) -> exit 0
Not claiming CI-green: the shard still has to complete in CI, and it cannot start until the runner pool drains. I am not merging on local runs — the ask stands as you wrote it, server 2/4 completing at this head.
Focus for re-review: the two test fixes are the substance — particularly whether faking only Date still exercises what the horizon test intends, and whether rewinding both fields leaves that assertion meaningful rather than tautological.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
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: d5078d3
Prior Findings Dispositioned (1)
- prior:7baa41e important 1 — fixed —
server/src/__tests__/issue-recovery-actions.test.ts:1732— the wedge is gone, and the root cause you identified is the one that was removed.vi.useFakeTimers({ toFake: ["Date"] })at:1732narrows the fake set so the Postgres I/O in that block keeps the real timer wheel, and thetry/finallyrestores real timers at:1738even if an assertion throws — which is what stops one bad test from wedging cleanup and everything behind it. The companion test defect is fixed too: the ping-pong case now rewindsevidence.sourceScopedWakeHorizonAtalongside thetimeoutAtcolumn (:1456), so the horizon actually reads as crossed instead of being rewritten from the evidence key on the next sweep. Evidence that the shard completes:General tests (server 2/4)was success in 19m53s (17:23:34→17:43:27) at45b9d007, against the 1h30m18stimeout-minutes: 90cancellation this finding reported. That file is byte-identical at this head —issue-recovery-actions.test.tsis absent from the merge's 53-file changed set, so the green run is on exactly the content reviewed here. Stated plainly so it is not overclaimed: CI on this head is stillqueued(the merge landed 18:33), so the proof is from identical content one commit earlier, not fromd5078d3aitself.
Important Issues (1)
- [gstack/review]
server/src/routes/issues.ts:3661— The master merge shadows this PR's recovery-owner comment grant for the exact case the PR was written for, and the regression that pins that case no longer models production. Master92a138e7added anissue:commentallow rule for the manager chain —isManagerOf(companyId, actorAgentId, resource.assigneeAgentId)atserver/src/services/authorization.ts:2054, returningallow_manager_chainat:2058— and it sits ahead of thedeny_missing_grantfall-through atauthorization.ts:2166. This PR's grant is gated on exactly that fall-through (issues.ts:3661,boundaryDecision.reason === "deny_missing_grant"). A recovery owner routed up the manager ladder is the manager of the stranded assignee, so post-merge that actor is allowed at:2058and never reaches the recovery-owner branch at all.- To be clear about severity: this is not an authorization hole.
creatorOrManagerGrantedCommentOnly(issues.ts:10409) refusesreopen/resumeon every status with its own 403 (:10413), so theblocked→todotransition thatprior:40a688d important 1was about stays closed on this path too. The defect is in coverage and in the grant's reachability. - The regression at
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts:1441— "does not let a manager recovery owner reopen a blocked source issue to todo" — forcesissue:commenttodeny_missing_grantin its mock (:1447, everything exceptissue:read/tasks:manage_active_checkoutsis denied) and then asserts"Recovery owner grant is comment-only"(:1469). After this merge the real service returnsallow_manager_chainfor that actor'sissue:comment, so production emits"Creator/manager comment grant is comment-only"instead. The test stays green while asserting a reason and a response body the service no longer produces for the shape its own comment at:1437names as the reported BLO-18996 instance ("the assignee's manager (the CEO) as recovery owner"). - Consequence worth weighing: the guard this PR adds is now dead for its motivating shape, and nothing in the recovery suite would fail if someone later narrowed
creatorOrManagerGrantedCommentOnly— the protection for manager recovery owners would silently revert with a full green board. - Recommendation: realign the manager case with the post-merge ordering (assert
allow_manager_chain→"Creator/manager comment grant is comment-only"), and keep an end-to-end case whose actor is genuinely neither the issue creator nor a manager of the assignee so the recovery-owner arm is still exercised. A line at:1437recording which grant now catches the manager shape would stop the next reader re-deriving this.
- To be clear about severity: this is not an authorization hole.
Suggestions (1)
- [pr-review-toolkit]
server/src/routes/issues.ts:3649— This comment citesauthorization.ts:2142for the "no allow-path matched" fall-through; the merge inserted the creator/manager rules above it and it now lives atauthorization.ts:2166. Minor, except that this is precisely the comment a reader consults to understand the gating described in the Important finding above, so a stale pointer costs more here than usual.
Strengths
- The unwedge diagnosis is exact and the fix is minimal. Narrowing to
toFake: ["Date"]rather than reaching forvi.advanceTimersByTimeshims or restructuring the test keeps the change to what the service actually reads (Date.now()), and the rationale is written at the point of risk (issue-recovery-actions.test.ts:1726-1731) so the next author does not reintroduce it. - The ping-pong fix corrects the test and says so, explicitly retiring the theory that the horizon mechanism was at fault. Distinguishing "my assertion was wrong" from "the code is wrong" in the comment is the more useful record.
- Both prior suggestions were addressed on their merits, not minimally.
recovery_lookup_failedat:3699now matches the sibling checkout discriminator at:9473verbatim and fails closed into the ordinary 403 rather than an unlabelled 500; the unreachability note at:10471explains why the wide predicate is deliberately left wide rather than silently narrowed. - Merge integration is otherwise clean: every PR guard survives (
recoveryOwnerGrantedCommentOnly,refundUnspentWakeAttempt,readSourceScopedWakeHorizonAt, thestrandedRecoveryWakeAttemptsExhaustedhorizon), and the 8 duplicateit()titles in the merged ownership suite pre-exist in master (72it()at92a138e7, same 8) — the merge added no duplication, and the70 → 79 → 81count reconciles exactly. !recoveryOwnerGrantedCommentOnlyat:10702correctly keeps the new grant out of thein_reviewauto-approval transition, with the mention-grant exclusion reasoned rather than copied.- The two
enqueueOrRefundAttemptcall sites (recovery/service.ts:4013,:4049) are mutually exclusive — the assignee-fallback branch returns — so a delivered wake cannot be refunded by a second undelivered enqueue in the same sweep.
Recommended Action
- No Critical issues.
- Address the Important finding this cycle: realign the manager-owner regression with the post-merge authorization order, and keep one case that still exercises the recovery-owner arm end-to-end.
- Consider the stale
authorization.ts:2142pointer opportunistically. - Re-check
General tests (server 2/4)once CI drains on this merged head — the unwedge is proven on byte-identical content at45b9d007, not yet ond5078d3a.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
|
Addressed the current-head review finding in
The stale source-line pointer mentioned in the suggestion is no longer present after the latest master merge. I toggled draft to ready to request an exact-head review of |
|
@ally please re-review at head Re-requesting because the 19:28Z follow-up comment did not carry this marker, and an Review focus — your Important issue on
Please confirm the realigned test pins the behaviour you had in mind, and specifically |
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: 5c05f9e
Prior Findings Dispositioned (1)
-
prior:d5078d3 important 1 — fixed —
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts:1441— the manager regression now models post-merge production instead of the pre-merge world. The mock grantsallow_manager_chainonissue:comment(:1451), so the manager case travels the path master actually gives it, and the assertion moved to the guard that really fires there:Creator/manager comment grant is comment-onlywithdetails.reason === "allow_manager_chain"(:1476-1477). That guard is real and status-independent —creatorOrManagerGrantedCommentOnly(server/src/routes/issues.ts:10606) coversallow_issue_creatorandallow_manager_chainviaisCreatorOrManagerCommentGrantDecision(:3940) and 403s beforeeffectiveReopenRequestedis computed (:10610-10621), so theblocked→todoconsequence stays closed on the manager path too.On the other half of the finding — whether the grant is now dead code — it is not.
resolveStrandedIssueRecoveryOwnerAgentId(server/src/services/recovery/service.ts:3381) walkspreferredOwnerAgentId→assignee.reportsTo→creator.reportsTo→createdByAgentId→ CTO/CEO role candidates → assignee.isManagerOfisisAgentInSubtree(server/src/services/authorization.ts:1287), so only the ancestors short-circuit atallow_manager_chain; a creator's manager, or a CTO who is not an ancestor of this assignee, still lands ondeny_missing_grantand needs this grant. The plain-peer end-to-end regression atserver/src/__tests__/issue-agent-mutation-ownership-routes.test.ts:1402keeps covering that live path across all five statuses.
Important Issues (1)
-
[pr-review-toolkit + gstack/review]
server/src/routes/issues.ts:3852— The grant was added to the enforcement wrapper only, so the read-side advisory still tells the recovery owner it cannot post.assertAgentIssueCommentAllowedapplies thedeny_missing_grant+actorOwnsActiveRecoveryActionOnIssueoverride here, butevaluateAgentIssueCommentAuthorization(:3720) — the shared, side-effect-free evaluator — does not. Its other caller isresolveHeartbeatReplyAuthorization(:3787, evaluating at:3792), whose result is returned asreplyAuthorizationon the heartbeat-context response (:5819). So the agent woken byenqueueSourceScopedStrandedRecoveryWakereadscanComment: false,reason: "deny_missing_grant", plus the remediation fromissueCommentGrantRemediation(:3592) telling it a mention from the assignee is required and to "respond on an issue you are assigned to and reference this one" — while the POST it was just discouraged from making would in fact succeed.This is the exact drift the two comments on that path forbid in so many words:
:3782-3785("using the same side-effect-free evaluator the comment route enforces with, so the advertised verdict cannot drift from the enforced one") and:5815-5818("never a re-derived copy of the rule, which is how the wake/grant split arose in the first place"). Functionally it is the advisory-layer restatement of BLO-18996 itself: the owner is woken onto the thread and told to go elsewhere. An agent that trustsreplyAuthorization— which is what it is for — never attempts the comment, so the deadlock this PR fixes persists for exactly the well-behaved caller.- Move the override into
evaluateAgentIssueCommentAuthorization, returning{ allowed: true, decision: recoveryOwnerCommentGrant(), reason: "allow_source_scoped_recovery_owner" }, and letassertAgentIssueCommentAllowedinherit it, so one rule feeds both surfaces. Add a heartbeat-context test asserting an active recovery owner seescanComment: truewithallow_source_scoped_recovery_owner; the comment-only neutering is unaffected, since it keys off the decision reason that this would now produce on both paths.
- Move the override into
Suggestions (1)
- [pr-review-toolkit + gstack/review]
server/src/routes/issues.ts:3917—isCommentOnlyPeerGrantDecisionis defined and never called anywhere in the file. The route composes the set inline instead (:10661:mentionGrantedPeerAgentCommentOnly || recoveryHandoffGrantedCommentOnly || creatorOrManagerGrantedCommentOnly || recoveryOwnerGrantedCommentOnly), so the helper is a second, narrower definition of "comment-only grant" — it omits the handoff and creator/manager grants — whose doc comment asserts an invariant ("Neither carries the authority to reopen or resume closed work") that no call site enforces. Delete it, or use it at the composition site so there is one definition.
Strengths
- Collapsing "does this action ever wake an owner" into the single
wakesOwnerpredicate (server/src/services/recovery/service.ts:3825) that drives bothmaxAttemptsandtimeoutAtkills the budget/wake-path drift class structurally rather than patching one instance — it is why the provider-quota and ownerless shapes stopped disagreeing. - The exhaustion notice earns its complexity: it distinguishes the two bounds because the operator's next move differs, keys the attempt-budget marker on the owner and the horizon marker on the horizon instant (each correct for how that bound behaves under reassignment), and does an exact unbounded
LIKElookup throughescapeLikePatternrather than a comment-window scan that would age the marker out precisely when the issue is noisiest. strandedRecoveryWakeAttemptsExhaustedand the notice'sattemptBudgetSpentuse the same strictattemptCount > maxAttemptscomparison, so the two branches cannot disagree about which bound fired — an easy off-by-one that is not present.- The comment-only refusal is placed at the comment route rather than by widening the
isClosedre-check, and:10643-10645says why: it avoids inheritingassertAgentIssueMutationAllowed'sisCurrentIssueExecutionRunbypass. The plain-peer regression backs that up by pinning the issue'scheckoutRunId/executionRunIdto a run id distinct from the actor's, so the guard is tested rather than short-circuited. - CI is genuinely green at this head — all four server shards pass, with
server 2/4at 12m43s against the 1h30m18s cap-cancellation an earlier head hit, pluse2e,Build,Typecheck, andverify. The regression evidence this PR rests on is now CI-proven.
Recommended Action
- No Critical issues. Fix the one Important issue — the heartbeat-context advisory drift at
issues.ts:3852— before merge; it is small, and leaving it means the PR's own fix does not reach the agent it was written for. - Consider the dead
isCommentOnlyPeerGrantDecisionhelper opportunistically.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
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: bbac780
Prior Findings Dispositioned (1)
- prior:5c05f9e important 1 — fixed —
server/src/routes/issues.ts:3855— the grant is no longer restricted to the enforcement wrapper. Thedeny_missing_grant+actorOwnsActiveRecoveryActionOnIssueoverride now lives insideevaluateAgentIssueCommentAuthorizationitself (:3855-3860), returning{ allowed: true, decision, reason: "allow_source_scoped_recovery_owner" }. Both callers inherit it from the one evaluator:assertAgentIssueCommentAllowed(:3950) andresolveHeartbeatReplyAuthorization(:3906), whose result is thereplyAuthorizationon the heartbeat-context response (:6119). So the agent woken byenqueueSourceScopedStrandedRecoveryWakenow readscanComment: trueon the thread it was woken onto, instead of adeny_missing_grantremediation telling it to go elsewhere. The heartbeat-context regression the last review asked for exists and asserts exactly that pairing (server/src/__tests__/issues-goal-context-routes.test.ts, "reports canComment for an active source-scoped recovery owner", assertingcanComment: truewithreason: "allow_source_scoped_recovery_owner"). The prior review'sisCommentOnlyPeerGrantDecisionsuggestion is also resolved — the dead helper is gone from the file.
Important Issues (1)
-
[pr-review-toolkit + native-codex]
server/src/services/recovery/service.ts:4012— One early return inside the refund window still burns an attempt without delivering a wake, which is the exact accounting error the refund block was added to eliminate.enqueueOrRefundAttempt(:3997) makes "only wakes that reached the queue count against the budget" the invariant, and the block comment at:3934-3948states the failure it prevents in so many words: "five such sweeps retire the action having woken nobody, while the exhaustion notice reports five wakes." But the suppressed-non-assignee branch bails at:4012with a barereturnbefore reaching anyenqueueOrRefundAttemptcall, and the attempt was already durably spent byupsertSourceScopedon the outerdbconnection.The branch is reachable with a null source assignee:
input.action.ownerAgentIdis guaranteed non-null by the guard at:3936, soownerIsNonAssignee(:4009) istruewheneverissue.assigneeAgentIdis null, and the remaining conditions are just "no new activity" andattemptCount > 1— the steady state of an unresolved action from the second sweep on. That the source issue can be unassigned in this shape is asserted by the fingerprint builder itself, which encodesinput.issue.assigneeAgentId ?? "unassigned"(:3757), andresolveStrandedIssueRecoveryOwnerAgentIdcan route tocreator.reportsToor a CTO/CEO role candidate without an assignee existing at all.The consequence is a false operator-facing report rather than a loop: the horizon still retires the action, but it retires it early, and the attempt-budget notice then tells the operator
Paperclip woke the recovery owner 5 times without this action being dischargedwhen it woke nobody — plus- Attempts: 5 (budget 5), which is the number the notice explicitly leans on to distinguish the two bounds.- Refund before returning, so the branch matches the invariant the rest of the function keeps:
if (!assigneeAgentId) { await refundUnspentWakeAttempt("enqueue_not_delivered"); return; }. A regression that drives two sweeps of an owned action on an unassigned source issue and assertsattemptCountdoes not advance would pin it.
- Refund before returning, so the branch matches the invariant the rest of the function keeps:
Suggestions (1)
- [gstack/review]
server/src/services/recovery/service.ts:4912— The horizon-branch notice asserts a cause it has not established: "Recovery ownership was being reassigned faster than any one owner could spend its attempt budget, which is why the attempt count below is low." Owner ping-pong is one way to reach the horizon with a low attempt count, but this PR deliberately creates another: the refund path means a permanently-deferred owner (capacity deferral, tree pause hold, cooldown) keepsattemptCountlow with no reassignment at all — and:3948-3952names that scenario as the reason the horizon exists independently of attempts. A stable owner on an infrequent sweep cadence reaches it the same way. As written the operator is pointed at reassignment churn to explain an action that may simply never have been delivered a wake. Consider stating the observation rather than the inferred cause ("the attempt count below is low because few or no wakes were delivered — ownership churn or repeated non-delivery both produce this"), since the next diagnostic step differs.
Strengths
- The prior finding was fixed at the right layer rather than patched at the symptom: the override moved into the shared evaluator instead of being duplicated into
resolveHeartbeatReplyAuthorization, which is what the two comments guarding that path (:3898-3900) actually ask for, and it keeps the advertised verdict structurally unable to drift from the enforced one. - Persisting the wake horizon in
evidence.sourceScopedWakeHorizonAtis the right fix for the bounded → ownerless → bounded flap: it makes "has this row ever been bounded" survive a sweep that writesmaxAttempts: nullonto the same active row, which atimeoutAt-only reading could not express — and theexisting.maxAttempts !== null ? existingTimeoutAt : nullbackfill arm handles rows written before the key existed, with a rollout note that correctly distinguishes a one-time re-arm from a recurrence. - The unbounded → bounded exception in
timeoutAthandling is load-bearing and correctly reasoned:timeoutAtis shared with the provider-quota scheduler'sretryAt, which is minutes out and therefore already in the past by the time a manager-ladder owner arrives, so blindly preserving it would have exhausted the new owner on its first wake and reinstated the deadlock through the back door. - Resetting
attemptCounton a change ofownerAgentIdrather than on the fingerprint is the non-obvious correct key, and the comment explains why the obvious one is wrong — the stranded fingerprint ends inissue.assigneeAgentIdand escalation reassigns the issue to the recovery owner, so fingerprint-keying would reset every sweep and silently un-bound the loop. wakesOwner(:3822) is exactly equivalent to the disjunction of the three early returns inenqueueSourceScopedStrandedRecoveryWake(!owner || workspace_validation_failed || configuration_incomplete, since!ownersubsumes the ownerless provider-quota case), so deriving bothmaxAttemptsandtimeoutAtfrom it closes the budget/wake-path drift structurally.releaseWakeAttemptfloors at 0 in SQL (greatest(... - 1, 0)) and is scoped to active statuses and matched on company, so a refund cannot resurrect budget on a resolved row or underflow into a negative that would makeexisting.attemptCount + 1land below 1.
Recommended Action
- No Critical issues, and the prior Important finding is fixed. Address the one new Important issue — the unrefunded attempt at
service.ts:4012— before merge; it is a two-line change plus a regression, and leaving it means the exhaustion notice can report wakes that never happened for exactly the unassigned-source shape. - Consider the horizon-message wording opportunistically.
CI at this head: 11 checks green including all four server shards, Build, Typecheck, and policy; server 4/4 still running. e2e failed on sidebar-takeover > ... pin without mutating it (1 failed, 40 passed) — a frontend spec, and this PR touches no frontend files, so it is not attributable to this change. Flagging it only so the green-CI claim from the previous review is not carried forward unexamined.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
allyblockcast
left a comment
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: fc9101d
Looks good. The one Important finding from the previous head is fixed, and the delta at this head (fc9101de, +4/-1 in service.ts plus a regression) is scoped to exactly that fix.
Prior Findings Dispositioned (1)
- prior:bbac780 important 1 — fixed —
server/src/services/recovery/service.ts:4013— the suppressed-non-assignee branch no longer returns with the attempt spent.if (!assigneeAgentId) { await refundUnspentWakeAttempt("enqueue_not_delivered"); return; }now sits where the barereturnwas, so every exit fromenqueueSourceScopedStrandedRecoveryWakepast the budget guards is covered: throw refunds and rethrows (:4003), a null return refunds (:4006), the unassigned-source branch refunds (:4013), and the owner wake goes throughenqueueOrRefundAttempt(:4052). The invariant stated in the block comment at:3938-3958— only wakes that reached the queue count against the budget — now holds on the branch that violated it, so the exhaustion notice can no longer report five wakes for an unassigned source issue that received none. The regression asks for precisely the shape I described: two sweeps of a manager-owned action,assigneeAgentIdnulled between them, assertingenqueueWakeupstays at one call andattemptCountstays at1(server/src/__tests__/issue-recovery-actions.test.ts:1977). ThatattemptCountassertion is the load-bearing one — the owner is unchanged across sweeps, soupsertSourceScopedincrements to 2 and only the refund can bring it back to 1; an early return elsewhere in the function would leave it at 2 and fail.
Suggestions (2)
- [gstack/review]
server/src/services/recovery/service.ts:4915— carried over from the previous head, still unaddressed and still only a suggestion: the horizon-branch notice asserts a cause it has not established ("Recovery ownership was being reassigned faster than any one owner could spend its attempt budget"). This PR deliberately creates a second route to a low attempt count with no reassignment at all — a permanently-deferred owner, and now also a repeatedly-refunded unassigned source. Consider stating the observation rather than the inferred cause, since the next diagnostic step differs. - [native-codex]
server/src/services/recovery/service.ts:4013— nit, non-blocking: the refund cause is labelled"enqueue_not_delivered"on a path where no enqueue was attempted at all. The union has only two members so there is nowhere better to put it today, but if the warn line at:3980is ever counted by cause, this branch will be indistinguishable from a realenqueueWakeupnull return.
Strengths
- The fix is two lines and lands on the invariant rather than the symptom — no new guard, no new state, just the existing compensator applied at the one exit that skipped it.
- The regression pins the accounting rather than the code path: asserting
attemptCountdoes not advance would fail both if the refund were removed and if the branch stopped being reached, which is what makes it a durable test rather than a snapshot of today's control flow. strandedRecoveryWakeAttemptsExhausted(afterSuppressed!)is checked asfalsealongside the count, so the test also pins the operator-visible consequence — the action is not prematurely retired — not just the integer.
Recommended Action
- No Critical or Important issues at this head; the prior blocker is fixed.
- Consider the two Suggestions opportunistically.
CI at this head: all 11 checks are still queued (Build, Typecheck, all four server shards, workspaces, e2e, canary, worktree install); only policy and review have reported. So the new regression at issue-recovery-actions.test.ts:1977 has not executed yet — this approval is on the code, and the branch protection checks remain the gate for the test run. The e2e failure I flagged at bbac7804 (sidebar-takeover, frontend-only, not attributable to this PR) should be re-checked when this head's run completes.
Thinking Path
Linked Issues or Issue Description
Related PRs found in the dedup search — please read this before reviewing:
AuthorizationDecision["reason"]union, so whichever lands second needs a small textual rebase.authorization.ts; this PR implements its grant in the route-level comment helper. I chose the route level deliberately —assertAgentIssueCommentAllowedhas exactly one call site, whereasagentIssueDecisioninauthorization.tsis shared byissue:read/issue:comment/issue:mutateand by everyaccess.decidecaller includingfilterIssuesForActor. Given BLO-18996 explicitly asks for minimum blast radius, narrower won. If the maintainers would rather have both grants in one place for consistency, say so and I will move mine — I would rather that be an explicit decision than a silent divergence.What Changed
(A) Admit the recovery owner for comments —
server/src/routes/issues.tsassertAgentIssueCommentAllowednow admits the named owner of an open (active/escalated) recovery action on that action's ownsourceIssueId, via a newactorOwnsActiveRecoveryActionOnIssuehelper. This mirrors the admissionassertAgentIssueMutationAllowedalready makes for the same pairing.allow_source_scoped_recovery_ownerdecision reason (added to the union inserver/src/services/authorization.ts) so it is auditable rather than masquerading as a mention grant.isIssueMentionGrantDecisionbecomesisCommentOnlyPeerGrantDecision, covering both grants. Effect: on a closed source issue the recovery owner may comment but areopen/resumestill has to clearassertAgentIssueMutationAllowedon its own — same treatment a mention-granted peer already gets.(C) Bound the re-fire —
server/src/services/recovery/service.tsSTRANDED_RECOVERY_MAX_OWNER_WAKE_ATTEMPTS(default 5, env-overridable, floored at 2) and a purestrandedRecoveryWakeAttemptsExhaustedpredicate, both re-exported fromserver/src/services/recovery/index.ts.ensureSourceScopedStrandedRecoveryActionsetsmaxAttemptsto that budget only for causes that actually wake an owner. Provider-quota monitor waits and workspace/config manual-repair holds keepmaxAttempts: null, because they return early from the wake path by design and are expected to sit open for a long time — giving them a ceiling would manufacture a spurious exhaustion.enqueueSourceScopedStrandedRecoveryWakereturns without waking anyone once the budget is spent.recoveryActionMaxAttemptsandrecoveryWakeBudgetExhausted.Tests
describe("source-scoped recovery owner comment grant (BLO-18996)")inissue-agent-mutation-ownership-routes.test.ts.issue-recovery-actions.test.ts.heartbeat-process-recovery.test.tsassertedmaxAttempts: nullon the shared owner-wake action shape; that expectation encoded the unbounded behaviour this PR removes, so it now asserts the budget.Verification
Both new tests were run against
masterand confirmed to fail there, then to pass on this branch:masterissue-agent-mutation-ownership-routes.test.ts›source-scoped recovery owner comment grant (BLO-18996)›lets the owner of an active recovery action comment on its source issue403 Issue is outside this actor's authorization boundary (grant)— the reported error verbatimissue-recovery-actions.test.ts›stops waking the recovery owner once the wake budget is spent and says so on the source issue< 7assertion failsThe first test reproduces the reported shape exactly: owner agent ≠ assignee, and the owner's
runIddeliberately does not match the issue'scheckoutRunId/executionRunId(the "checked out against a different issue" half), which is what makesisCurrentIssueExecutionRunfall through to the boundary check. Four negatives ship alongside it — non-owner agent, board-owned (ownerAgentId: null) action, reopen-on-closed, and cross-company — and all four already pass onmaster, which is the evidence that the grant does not widen anything they cover.The second test asserts 7 escalations produce fewer than 7 wakes, exactly one exhaustion comment, and that a further sweep is a no-op on both counts.
Local runs on this branch:
Dashboard / log query. The stuck class is
select count(*) from issue_recovery_actions where status in ('active','escalated') and max_attempts is not null and attempt_count > max_attempts, and the escalation activity log now carries the same two fields so it can be counted without a join. I have not run this against production and it would not be meaningful yet —max_attemptshas never been populated for stranded actions, so it returns 0 on both sides today. Real before/after numbers are owed on the issue once this deploys; flagging rather than claiming the AC satisfied. No UI change is needed to see it:IssueRecoveryActionCardalready rendersattempt N of MwhenevermaxAttemptsis set.Risks
Blast-radius enumeration — BLO-18996 explicitly asks for this, because #814 shipped a cancel-any-agent's-run bypass through a fully green pipeline by widening a boundary without tracing everything on its branch and in its helper.
assertAgentIssueCommentAllowedhas exactly one call site:POST /issues/:id/comments.assertAgentIssueMutationAllowed— which backs ~two dozen mutation routes includingDELETE /api/issues/:id— is not touched. Neither isdecideIssueAccess.if (!boundaryDecision.allowed). The only guard after that block is thein_progress+assigneeAgentId === actorAgentIdrun-id requirement, which is unreachable on this path: an actor that is the assignee would have been allowed byallow_selfand never reached the deny branch. The guards before it (assertTaskWatchdogScopedIssueMutationAllowed,isCurrentIssueExecutionRun) still run first, unchanged.in_progressactive-run 409 guard. Untouched — it lives inassertAgentIssueMutationAllowedand comments never reach it. A recovery owner attemptingreopen/resumeon a closed source issue is still routed through that helper and denied; covered by a test.getActiveForIssue(companyId, issueId)filters onsourceIssueId = issue.id AND status IN ('active','escalated'); the helper additionally requiresownerAgentId === actorAgentIdand a matching company, and returns false whenownerAgentIdis null. No other issue is reachable. Company isolation in fact fires earlier (getAccessibleResource404s a foreign-company actor) — also covered by a test.authorization.tschange is type-only. One new member on thereasonunion.authorizationBoundaryLabelswitches only ondeny_*and funnels the rest tounlabelledBoundary, whose parameter type isExclude<..., deny_${string}>— anallow_*reason is assignable, so no build break and no behavioural change. (This is the line that will conflict with fix(authz): comment-only handoff grant for a recovery-reassigned previous owner (BLO-18906) #827.)maxAttemptshad no prior readers outsideissue-recovery-actions.ts;packages/sharedvalidates it as a positive int ≤ 100, and 5 satisfies that.Behavioural risk of the wake budget. A legitimately slow recovery that genuinely needs more than 5 sweeps would now stop being woken. Mitigations: a discharge resolves the action so the next escalation starts a fresh one at attempt 1; a reassignment changes the action fingerprint and also starts fresh;
attemptCountonly increments when the sweep re-escalates a still-stranded issue, so reaching 5 means five consecutive failures to make progress; and the exhaustion comment tells a human exactly what to do. The budget is env-overridable if 5 proves too tight in practice.Base branch. Branched from
master(81308c700), not from the BLO-18829 branch the assigned workspace was detached on. BLO-18829 (#818/#820, both draft) refactors the same wake-decision function into a plan/dispatch split, so stacking would have made this unmergeable until those land. Whichever merges second needs a small rebase; the three edits port cleanly either direction.Out of scope, unchanged: BLO-18145's exit-128 / stderr-capture work,
job_missingdurability (BLO-18106), and merging or unblocking #814. No credentials, permissions beyond the single path above, runner labels, caches, or agent instructions change.Model Used
Claude Opus 5 (
claude-opus-5), 1M context, extended thinking, with tool use and code execution — running as a Paperclipclaude_k8sagent.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templatemaxAttemptswith no code change)🤖 Generated with Claude Code