fix(heartbeat): close the monitor-lapse recovery gap + manager-chain monitor re-arm (BLO-22860) - #1187
Conversation
1 similar comment
|
@ally please review at head f2fcc73. Focus, in priority order:
Known scope limit stated in the PR body: the prose-parser path is deliberately NOT capped here. |
|
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: f2fcc73
Critical Issues (0)
Important Issues (1)
- [native-codex]
server/src/routes/issues.ts:5181— The manager-chain exception authorizes any PATCH whose top-level body contains onlyexecutionPolicyand a monitor, but it does not constrain the policy itself to a monitor re-arm. A manager withallow_manager_chaincan therefore replacestages,authorizationPolicy, or the policy mode while bypassing the normal issue-mutation authorization path. This exceeds the stated narrow recovery capability and lets a manager alter a report's workflow/authorization configuration.- Restrict the exception to a policy patch that carries only
monitor, or merge only the requested monitor fields into the existing policy while preserving stages and authorization policy. Add rejection coverage for a manager-chain patch that includes stages or authorizationPolicy.
- Restrict the exception to a policy patch that carries only
Suggestions (0)
Strengths
- The retry curve and the attempt-ceiling invariant are explicit and tested, preventing the capped schedule from silently exhausting before the longest observed provider horizon.
- The dispatch-lapse recovery records a concrete gate signal and refunds the undelivered attempt, which keeps the recovery behavior observable.
Recommended Action
- Fix the Important authorization-boundary issue this cycle.
…(BLO-22860) Ally review on #1187 (native-codex, Important). The manager-chain exception gated on the request body having a single `executionPolicy` key and that policy *containing* a monitor. It never constrained the policy itself, and `executionPolicy` is a whole-policy replace — so a manager holding only `allow_manager_chain` could send {"executionPolicy": {"monitor": {...}, "stages": [...], "authorizationPolicy": {...}}} and rewrite a report's workflow and authorization configuration through a path that deliberately skips the ordinary issue-mutation boundary. Verified exploitable: both new rejection tests return 200 against f2fcc73. Two independent halves, because narrowing the gate alone leaves a second defect. 1. Gate — the requested policy must carry a monitor and no stages, reviewPreset or authorizationPolicy. Checked on the *normalized* policy, not the request's key set: `validate(updateIssueRouteSchema)` has already replaced req.body with the parsed result, and unlike the top-level `.partial()` object the nested policy schema does fire its defaults, so a monitor-only policy arrives as `{mode, commentRequired, stages, monitor}`. Key presence proves nothing here; only the values do. 2. Write — merge the monitor into the report's existing policy instead of replacing it. Without this, even a correctly-narrowed, legitimately authorized re-arm silently drops the assignee's stages, reviewPreset, authorizationPolicy and mode as a side effect of restoring a timer. This also makes the escalation structurally impossible rather than only gated: nothing but the monitor is ever taken from the request. Coverage: two rejection cases (stages, authorizationPolicy) and one preservation case. All three fail on f2fcc73 and pass here. Tests: issue-agent-mutation-ownership-routes 187/187 (was 184), tsc clean.
Addressed at
|
|
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: ccd33d8
Prior Findings Dispositioned (1)
- prior:f2fcc73 important 1 — fixed —
server/src/routes/issues.ts:5182— The manager-only predicate now rejects non-emptystages,reviewPreset, andauthorizationPolicy, and the write merges only the monitor into the existing policy atserver/src/routes/issues.ts:10123.
Critical Issues (0)
Important Issues (1)
- [native-codex]
server/src/services/heartbeat.ts:10757— The dispatch-lapse recovery re-arms a watchdog monitor but leaves the original stale run inqueued. When that monitor fires,enqueueWakeupsees the same-scope queued run and coalesces into it without callingstartNextQueuedRunForAgent, so the original undeliverable run remains queued and the issue is again left withmonitorNextCheckAt: null. The recovery does not actually create or dispatch a replacement wake.- Cancel or otherwise evict the lapsed queued run before issuing the watchdog wake, or make the watchdog path explicitly re-dispatch the existing queue entry. Add an end-to-end scheduler test that advances through the re-armed monitor and verifies a fresh run starts (or the lapsed run is deliberately retried).
Suggestions (0)
Strengths
- The retry horizon curve retains the provider reset as an upper bound and the attempt-ceiling invariant is explicitly tested.
- The manager-chain recovery path now correctly preserves execution stages and authorization policy.
Recommended Action
- Fix the Important dispatch-lapse recovery issue before merge.
…ck run (BLO-22860) Ally's Important finding on #1187: the watchdog re-armed a monitor but left the stale run queued. When that monitor fired, enqueueWakeup's coalescePendingTaskScopeWake merged into the same queued run and returned without ever calling startNextQueuedRunForAgent, and the triggered patch then cleared monitorNextCheckAt — so the issue re-lapsed with the same undeliverable run. The recovery "recovered" the strand forever without moving it. The watchdog now, when the watched run is still queued: - runs an explicit dispatch pass for the run's agent instead of enqueueing a wake that can only coalesce; - clears the monitor if that (or anything else) got the run moving; - otherwise re-arms rather than consuming the timer, counting the attempt so the loop is bounded by maxAttempts and eventually surfaces via clearIssueMonitorAndRecover instead of polling a run that never moves. buildIssueMonitorDispatchRearmPatch takes an optional attemptCount so the tick-detected lapse path keeps restoring the attempt an undelivered wake never used, while this dispatch path consumes one. Test: "re-dispatches and re-arms when the watchdog fires on a run that is still queued" fails on the parent commit with "expected null not to be null" on monitorNextCheckAt — the exact strand — and passes here. Paired test covers the stand-down case. Co-Authored-By: Claude <noreply@anthropic.com>
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: 0269c62
Prior Findings Dispositioned (1)
- prior:ccd33d8 important 1 — fixed —
server/src/services/heartbeat.ts:10526— The watchdog now callsstartNextQueuedRunForAgentfor the stranded run's agent, then re-reads that run atserver/src/services/heartbeat.ts:10529; it clears the watchdog only if the run moved and otherwise re-arms it with a bounded attempt count atserver/src/services/heartbeat.ts:10548.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The capped retry curve retains the provider reset as a strict upper bound while making a long capacity interval observable through bounded re-probes.
- The lapsed-monitor watchdog now handles both dispatch success and continued scheduler failure without silently consuming its only timer.
- The manager-chain recovery path remains limited to a monitor merge, preserving the existing policy's workflow and authorization fields.
Recommended Action
- No Critical or Important issues found.
A monitor whose wake-carrying run never dispatches consumes its wake and leaves monitorNextCheckAt null, so the issue is unattended while still reading as healthy. Measured on BLO-18278: the monitor fired, the run it created sat queued 13h32m, and the only thing that eventually moved it was an unrelated github_pr_closed webhook. Adds ISSUE_MONITOR_DISPATCH_LAPSE_MS: when a triggered monitor's run has not dispatched within the bound, re-arm rather than leaving the timer null, so a park never depends on a single droppable wake to end (AC3). Also lets a manager-chain agent restore a lapsed monitor on a report's issue, scoped to a monitor-only patch shape (AC4). The horizon cap (AC1) and per-probe attempt advancement (AC2) this commit originally carried are dropped: BLO-23438 and BLO-24011 landed the same 15m cap via resolveCcrotateCapacityRetry while this branch sat, with jitter and clamp provenance this version lacked. Keeping both would have been two implementations of one shipped behaviour, and it was the sole cause of every heartbeat.ts conflict on the rebase.
…(BLO-22860) Ally review on #1187 (native-codex, Important). The manager-chain exception gated on the request body having a single `executionPolicy` key and that policy *containing* a monitor. It never constrained the policy itself, and `executionPolicy` is a whole-policy replace — so a manager holding only `allow_manager_chain` could send {"executionPolicy": {"monitor": {...}, "stages": [...], "authorizationPolicy": {...}}} and rewrite a report's workflow and authorization configuration through a path that deliberately skips the ordinary issue-mutation boundary. Verified exploitable: both new rejection tests return 200 against f2fcc73. Two independent halves, because narrowing the gate alone leaves a second defect. 1. Gate — the requested policy must carry a monitor and no stages, reviewPreset or authorizationPolicy. Checked on the *normalized* policy, not the request's key set: `validate(updateIssueRouteSchema)` has already replaced req.body with the parsed result, and unlike the top-level `.partial()` object the nested policy schema does fire its defaults, so a monitor-only policy arrives as `{mode, commentRequired, stages, monitor}`. Key presence proves nothing here; only the values do. 2. Write — merge the monitor into the report's existing policy instead of replacing it. Without this, even a correctly-narrowed, legitimately authorized re-arm silently drops the assignee's stages, reviewPreset, authorizationPolicy and mode as a side effect of restoring a timer. This also makes the escalation structurally impossible rather than only gated: nothing but the monitor is ever taken from the request. Coverage: two rejection cases (stages, authorizationPolicy) and one preservation case. All three fail on f2fcc73 and pass here. Tests: issue-agent-mutation-ownership-routes 187/187 (was 184), tsc clean.
…ck run (BLO-22860) Ally's Important finding on #1187: the watchdog re-armed a monitor but left the stale run queued. When that monitor fired, enqueueWakeup's coalescePendingTaskScopeWake merged into the same queued run and returned without ever calling startNextQueuedRunForAgent, and the triggered patch then cleared monitorNextCheckAt — so the issue re-lapsed with the same undeliverable run. The recovery "recovered" the strand forever without moving it. The watchdog now, when the watched run is still queued: - runs an explicit dispatch pass for the run's agent instead of enqueueing a wake that can only coalesce; - clears the monitor if that (or anything else) got the run moving; - otherwise re-arms rather than consuming the timer, counting the attempt so the loop is bounded by maxAttempts and eventually surfaces via clearIssueMonitorAndRecover instead of polling a run that never moves. buildIssueMonitorDispatchRearmPatch takes an optional attemptCount so the tick-detected lapse path keeps restoring the attempt an undelivered wake never used, while this dispatch path consumes one. Test: "re-dispatches and re-arms when the watchdog fires on a run that is still queued" fails on the parent commit with "expected null not to be null" on monitorNextCheckAt — the exact strand — and passes here. Paired test covers the stand-down case. Co-Authored-By: Claude <noreply@anthropic.com>
0269c62 to
9016764
Compare
Thinking Path
Linked Issues or Issue Description
ddd1cd628)What Changed
cappedProviderCapacityRetryAtschedulesmin(advertisedResetAt, now + hop(attempt)), hop = 15m, 30m, 1h, 2h, 4h, 4h, … The advertised reset stays a hard upper bound, so a probe is only ever pulled earlier than fix(recovery): stop trusting adapter text as a capacity-reset horizon (BLO-18278 follow-up to #904) #930 scheduled it, never pushed later. Each re-probe advancesscheduledRetryAttempt, so a long park is an observable sequence of attempts rather than one opaque wait at attempt 0.resumeAt. Capping each hop means 24 attempts at the 4h max hop cover only 83.8h, which would have turned BLO-22844's 124.8h window into a hard exhaustion — strictly worse than the park this PR set out to fix.tickDueIssueMonitorsre-arms a monitor whose wake-carrying run has satqueuedpastISSUE_MONITOR_DISPATCH_LAPSE_MSinstead of leavingmonitorNextCheckAt: null.coalescePendingTaskScopeWakemerged into that same queued run and returned without ever callingstartNextQueuedRunForAgent, and the triggered patch then clearedmonitorNextCheckAt— re-lapsing forever without moving the run. The watchdog now runs an explicit dispatch pass for the run's agent, clears the monitor if the run got moving, and otherwise re-arms while counting the attempt, so the loop is bounded bymaxAttemptsand eventually surfaces viaclearIssueMonitorAndRecoverinstead of polling a run that never moves.PATCH /issues/:idonly, body containing nothing butexecutionPolicy, issuein_progress/in_reviewwith atriggeredmonitor and nullmonitorNextCheckAt, and anallow_manager_chaindecision. The write merges only the monitor into the report's existing policy, so stages/reviewPreset/authorizationPolicy cannot be dropped or escalated through this path.Verification
Run locally against an embedded Postgres:
issue-monitor-schedulerheartbeat-capacity-horizon-cap+heartbeat-provider-capacity-horizon+heartbeat-ccrotate-capacity-retry+issue-execution-policyissue-agent-mutation-ownership-routes+issue-execution-policy-routestsc --noEmit(server)Red-green evidence for the new regression test. With this revision's source changes reverted and the new tests kept, exactly one test fails, on the precise strand:
With the fix applied, 12/12 pass. The attempt-ceiling invariant is likewise asserted directly rather than vacuously —
heartbeat-capacity-horizon-capfails at the old ceiling with24 probes cover 83.8h, short of the 124.8h window seen on BLO-22844.Risks
queuedwith nostartedAt, runs at most once per watchdog fire, and is bounded bymaxAttempts;startNextQueuedRunForAgentis already fenced off on the API tier and no-ops under scheduling suppression.parseProviderCapacityResetHorizon→retryNotBefore→ thescheduleBoundedRetryForRunoverride) is not capped here. It is governed byRATE_LIMIT_HEARTBEAT_RETRY_MAX_ATTEMPTS = 12, and applying this curve without re-deriving that ceiling would reintroduce the exhaustion trap. Tracked as BLO-23525.Model Used
claude-opus-4-5), extended thinking, via Claude Code with tool use and code execution (agent-authored under the Paperclip CTO agent).Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template🤖 Generated with Claude Code