fix: recover issue monitors stuck triggered past timeoutAt with null nextCheckAt - #1326
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: 568196f
Critical Issues (0)
Important Issues (1)
- [native-codex]
server/src/services/heartbeat.ts:11089— the expired-monitor claim update only rechecksid,monitorNextCheckAt, expiry, and claim staleness; it omits the initial query'sassigneeUserId IS NULL, agent-assignee, andstatus IN (in_progress, in_review)guards. If an issue is reassigned to a user or becomes terminal after the select but before this update, the scheduler can still claim it andclearIssueMonitorAndRecovercan clear/recover a monitor outside the eligible state.- Keep the claim predicate consistent with the select and
tickDueIssueMonitorsclaim predicate, and add a concurrency regression test that changes assignee/status between selection and claim to verify no recovery occurs.
- Keep the claim predicate consistent with the select and
Suggestions (0)
Strengths
- The new sweep is narrowly scoped to
triggeredmonitors with nullnextCheckAtand an expired timeout. - It reuses the existing clear-and-recover pipeline and adds coverage for recovery, idempotency, and control states.
Recommended Action
- Address the Important issue before merge.
|
Addressed Ally's important finding in commit
PR: #1326 |
|
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: feacfa2
Prior Findings Dispositioned (1)
- prior:568196f important 1 — fixed —
server/src/services/heartbeat.ts:11094— the expired-monitor claim now rechecksassigneeUserId IS NULL, and lines 11095-11096 recheck agent assignment and eligible issue status before claiming; the added race tests atserver/src/__tests__/issue-monitor-scheduler.test.ts:225verify reassignment and terminal-status races do not recover.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The new sweep is narrowly scoped to triggered monitors with null
nextCheckAtand an expired timeout. - It reuses the existing clear-and-recover pipeline and adds coverage for recovery, idempotency, control states, and claim races.
Recommended Action
- No blocking issues found; this review is clean.
…eckAt tickDueIssueMonitors only claims work where monitorNextCheckAt is non-null. The moment a monitor fires, dispatchClaimedIssueMonitor sets monitorNextCheckAt to null optimistically, before the woken run has actually re-armed it. If that run never re-arms or clears the monitor (dies, gets reassigned, or the assignee never calls back), the monitor drops out of the sweep's WHERE clause permanently: exhaustedMonitorClearReason never re-runs, clearedAt/clearReason are never set, and the configured recoveryPolicy (typically wake_owner) never fires. Observed live on BLO-21020 and BLO-22798 (both since resolved by other means) and reproduced on three still-open issues (BLO-17091, BLO-17477, BLO-18396) — all `status: "triggered"`, `nextCheckAt: null`, timeoutAt long past, clearedAt still null. Add tickExpiredIssueMonitors, a second sweep that finds monitors stuck `triggered` with a null nextCheckAt and a passed timeoutAt, and routes them through the same clear-and-recover pipeline dispatchClaimedIssueMonitor already uses for exhaustion. Narrow the `monitor` parameter on the shared recovery helpers to the fields they actually read, since by this point executionPolicy.monitor has already been stripped and the only source of timeoutAt/maxAttempts/recoveryPolicy is the persisted executionState.monitor. Ref BLO-25865. Co-Authored-By: Paperclip <noreply@paperclip.ing>
feacfa2 to
60c1878
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: 60c1878
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The expired-monitor sweep is narrowly scoped to triggered monitors with a null next check and an expired timeout.
- The select and claim predicates both recheck assignee and issue status, addressing the prior claim-race finding.
- The added tests cover recovery, repeat-tick idempotency, normal-clear and not-yet-expired controls, and reassignment/status races.
Recommended Action
- No blocking issues found; this review is clean.
Thinking Path
Linked Issues or Issue Description
Refs BLO-25865 — https://paperclip.blockcast.net/BLO/issues/BLO-25865
What Changed
tickExpiredIssueMonitors(server/src/services/heartbeat.ts): a second sweep that claims monitors stuckstatus="triggered"withnextCheckAt IS NULL, a passedtimeoutAt, andclearedAt IS NULL, on issues stillin_progress/in_review, and routes them through the existingclearIssueMonitorAndRecover→performIssueMonitorRecoverypipeline sorecoveryPolicyfinally fires.monitorparameter on the shared recovery helpers (monitorRecoveryPolicy,monitorRecoveryDetails,performIssueMonitorRecovery,clearIssueMonitorAndRecover) to only the fields they actually read (serviceName/timeoutAt/maxAttempts/recoveryPolicy). By the time a monitor is stucktriggered,buildIssueMonitorTriggeredPatchhas already strippedexecutionPolicy.monitor, so the only surviving source of that metadata is the persistedexecutionState.monitor, which is shaped likeIssueExecutionMonitorStaterather thanIssueExecutionMonitorPolicy.server/src/services/issue-execution-policy.ts, plus the claim path inheartbeat.ts).server/src/__tests__/issue-monitor-scheduler.test.tscovering the new sweep and its controls.maxAttemptsdefault, or agent-health alert threshold is retuned by this PR.Verification
npx vitest run src/__tests__/issue-monitor-scheduler.test.ts— 18/18 pass at the rebased head. New cases: (1) a monitor stucktriggeredpasttimeoutAtendsclearedwithclearReason="timeout_exceeded"and emits exactly oneissue_monitor_recoverywake; (2) repeated ticks do not double-recover; (3) control — a monitor alreadyclearednormally is left untouched; (4) control — atriggeredmonitor whosetimeoutAthas not passed is left untouched.npx vitest run src/__tests__/issue-execution-policy.test.ts— 57/57 pass.npx tsc --noEmitinserver/— clean, zero errors (after building the@paperclipai/sharedand@paperclipai/plugin-sdkworkspace packages, whose staledistis the only source of errors in a cold worktree).monitor.status = 'triggered' AND monitor.nextCheckAt IS NULL AND monitor.timeoutAt < now() AND monitor.clearedAt IS NULLacross non-terminal issues should go from ≥1 (BLO-17091, BLO-17477, BLO-18396 were live instances at authoring time) to 0 after this deploys and the sweep ticks.Risks
status="triggered" AND nextCheckAt IS NULL AND timeoutAt < now AND clearedAt IS NULL) is disjoint from the existing sweep's (nextCheckAt IS NOT NULL), so it cannot touch any row the existing sweep already handles — it covers precisely the gap the existing sweep structurally cannot reach.monitorWakeRequestedAtstaleness guard), so it is safe under concurrent ticks, and reusesexhaustedMonitorClearReason/clearIssueMonitorAndRecover/performIssueMonitorRecoveryverbatim — no newclearReasonvalues and no new recovery-policy branches are introduced.wake_ownerrecoveries. Deploying this is the reconciliation step for the existing backlog of stuck rows, so expect a one-off burst of recovery wakes on the first few ticks as the accumulated backlog drains, ordered oldest-updatedAt-first and capped at 50 per tick. No DB migration is required.Model Used
Claude Opus 4.8 (
claude-opus-4-8), 1M context, extended thinking, with tool use and code execution — via Claude Code.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template