feat(heartbeat): auto-retry ccrotate capacity defers via scheduled_retry (PEN-382) - #299
Conversation
…try (PEN-382) When the ccrotate capacity gate defers a wake on pool exhaustion, enqueueWakeup wrote a terminal status:"skipped" agentWakeupRequests row and dropped the wake — resumeAt was decorative and a human had to re-ping after the pool reset (BLO-4728 / PEN-382 live evidence 2026-06-05). Instead, persist the defer as a status="scheduled_retry" heartbeat run (scheduledRetryReason="ccrotate_capacity", scheduledRetryAt=resumeAt, errorFamily=rate_limit_exhausted + retryNotBefore=resumeAt). The existing promoteDueScheduledRetries sweep — driven by the server tick and startup recovery — claims it by (status, scheduledRetryAt) and promotes it when due, reusing the rate-limit family's bounded backoff/exhaust. No new status enum (status is a text column), no new sweep, no DB migration. The claim is keyed on (status, scheduledRetryAt), not idempotencyKey, so the resume cannot coalesce into its own row. A null resumeAt falls back to CCROTATE_CAPACITY_DEFAULT_RETRY_DELAY_MS so the row is never stranded with a null scheduledRetryAt the sweep would skip. Tests (embedded-postgres): defer creates the scheduled_retry run; null resumeAt falls back to a bounded delay; the row promotes when due. Follow-up (not in this commit): re-check the capacity gate at promotion time so a still-exhausted pool re-defers instead of dispatching a run that immediately 429s (today it self-corrects via the post-run rate-limit path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…2 follow-up) Re-check the capacity gate inside promoteScheduledRetryRun for scheduledRetryReason=ccrotate_capacity runs. If the pool is still exhausted at promotion time, re-defer with backoff (bump scheduledRetryAttempt, push scheduledRetryAt to the new resumeAt / fallback) instead of promoting a run that would dispatch and immediately 429. When the pool recovers, promote as before. Adds CCROTATE_CAPACITY_MAX_RETRY_ATTEMPTS (24) as a no-infinite-loop backstop: once the budget is exhausted the run is terminated (cancelled + finishedAt + warn event) and surfaces for operator attention instead of re-deferring forever. Tests: re-defers (stays scheduled_retry, attempt bumped, scheduledRetryAt pushed) when still exhausted; promotes when capacity returns; terminates at the cap. Guarded by reason so non-ccrotate scheduled retries are unaffected (heartbeat-retry-scheduling 15/15 still green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
b7599f9 to
bf98f3e
Compare
|
Split: the adapter-resolution guard (the "Process adapter missing command" / Ally adapter-down fix) was moved to its own PR off master — #302. This branch is now purely the PEN-382 scheduled_retry feature. |
|
@ally please review this PR. |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + gstack/codex (folded).
Solid, well-tested change. The core mechanism is correct: the resume claim is keyed on (status, scheduledRetryAt<=now) (not idempotencyKey), the optimistic-UPDATE guard is reused consistently, and the null resumeAt → bounded-delay fallback closes the stranded-row gap. CI Typecheck + General tests (server) + all four serialized server suites are green, so the new embedded-Postgres suite (all 5 cases) passes and the heartbeatRuns insert is schema-valid. One real concern below.
Important Issues (1)
- [code]
server/src/services/heartbeat.ts:11481— Capacity defers accumulate one promotablescheduled_retryrow per timer tick; on recovery they all promote into a burst of redundant runs, re-creating the capacity waste the gate exists to prevent.- The old deny path wrote a terminal, inert
skippedrow. The new path inserts ascheduled_retryheartbeatRunsrow with noidempotencyKeyand no dedup against an existing capacity row for the agent. The defer also does not bumplastHeartbeatAt, and neither the cooldown check (heartbeat.ts:11423, keyed onlastHeartbeatAt) nor the idle circuit-breaker (tickTimers, keyed onconsecutiveTimerIdleRunswhich only increments after a run executes) suppresses a deferred wake. So during a prolonged pool exhaustion,tickTimers(heartbeat.ts:12780) re-firesheartbeat_timerevery interval and each tick mints a fresh row (all withscheduledRetryAt ≈ resumeAt). - When capacity returns,
promoteDueScheduledRetriesclaims up to 50 due rows and the new re-check promotes each that the gate now allows → N queued runs for one agent. WithmaxConcurrentRuns:1they serialize rather than overload, but they still each dispatch a full heartbeat — exactly the "burning per-account cap on redundant requests" the long gate comment atheartbeat.ts:11437is protecting against, just shifted to recovery time (and able to re-exhaust the pool). - Recommendation: make the capacity defer reason-aware-idempotent for fungible wakes — before inserting, look for an existing
scheduled_retrycapacity row for this agent and update itsscheduledRetryAtto the newresumeAtinstead of inserting a second row (at minimum forheartbeat_timer; distinct issue-scoped wakes can legitimately each survive). This collapses a 30-tick exhaustion into one recovery run.
- The old deny path wrote a terminal, inert
Suggestions (3)
- [comments] PR body is stale and under-describes scope. The "Follow-up (not in this PR)" section says re-checking the capacity gate at promotion time is deferred, but this PR implements exactly that (
heartbeat.ts:6354, theccrotate_capacityre-check → re-defer-with-backoff / terminate-at-cap) and tests it (tests 4 & 5 atheartbeat-ccrotate-capacity-retry.test.ts:219and:253). The "Tests" section also lists only 3 of the 5 cases. Update the body so reviewers/history aren't misled about what landed. - [tests] No test exercises the multi-row accumulation path (several gated wakes → several
scheduled_retryrows → multiple promotions). That gap is why the amplification above isn't visible. A test asserting that repeatedheartbeat_timerdefers collapse to a single promotable row would guard the recommended fix. - [code]
e2eCI is red, but it's the browser/relay end-to-end suite with no touchpoint on server-sideheartbeat.tsscheduling (Typecheck + server + serialized suites all pass). Looks like the usual e2e flake — a re-run to get a clean board before merge would remove ambiguity.
Strengths
- Concurrency is handled correctly throughout: every state transition (
promote, re-defer, terminate) reuses the same(id, status="scheduled_retry", scheduledRetryAt<=now)guardedUPDATEwith.returning(), so concurrent sweeps can't double-promote or double-increment the attempt counter. - The
CCROTATE_CAPACITY_MAX_RETRY_ATTEMPTSbackstop bounds the re-defer loop per row and terminates tocancelledwith a clear operator-facing error + lifecycle event — no infinite loop. appendRunEventis correctly emitted only when the guarded update actually returned a row (if (exhausted)/if (rescheduled)), so no event fires on a no-op update.- Promoted rows carry the full
enrichedContextSnapshot(issueId/taskKey preserved), so a recovered run has richer context than the oldskippeddrop. SQL is fully parameterized via drizzle — no injection surface; no LLM trust-boundary in this diff.
Recommended Action
- Address the Important accumulation issue before merge (reason-aware dedup on the capacity defer), or consciously accept the recovery-burst trade-off and note it.
- Fix the stale PR body (the promotion-time re-check is in this PR).
- Re-run
e2eto confirm the failure is the unrelated flake.
…tate-scheduled-retry # Conflicts: # server/src/services/heartbeat.ts
What
Auto-retry agent wakes that the ccrotate capacity gate defers on pool exhaustion, by reusing paperclip's existing
scheduled_retrymachinery. Implements the design in penstock-paperclip-plugin#2 / PEN-382 (BLO-4728).Why
enqueueWakeup(heartbeat.ts) used to write a terminalstatus:"skipped"wake and drop it on capacity defer —resumeAtwas decorative, so a human had to@ally-re-ping after the pool reset (and a re-ping before reset just re-deferred). Live evidence 2026-06-05: Ally's review wake stranded atresumeAt 03:02Zwith no autonomous recovery.How
At the gate-deny site, instead of dropping, persist a
status:"scheduled_retry"heartbeat run:scheduledRetryReason = "ccrotate_capacity",scheduledRetryAt = resumeAterrorFamily = "rate_limit_exhausted"+retryNotBefore = resumeAtso the existing rate-limit bounded backoff/exhaust honorsresumeAtas the floorThe existing
promoteDueScheduledRetriessweep (server tick + startup recovery) claims it by(status, scheduledRetryAt <= now)and promotes it when due. No new status enum (statusis a text column), no new sweep, no DB migration.Two correctness properties:
(status, scheduledRetryAt), notidempotencyKey— so the retry cannot coalesce into its own deferred row (the crux flagged in eng review).nullresumeAtfalls back toCCROTATE_CAPACITY_DEFAULT_RETRY_DELAY_MS(5 min) so the row is never stranded with anullscheduledRetryAtthe sweep would skip.Tests
server/src/__tests__/heartbeat-ccrotate-capacity-retry.test.ts(embedded-postgres), all TDD'd red→green:scheduled_retryrun withscheduledRetryAt = resumeAt+ rate-limit family (and does not write a terminalskippeddrop)nullresumeAt falls back to a bounded future delay (not null)queuedwhen due via the existing sweeptsc --noEmitclean. No regression to the three adjacent gate tests (they all inject anallow:truegate; only the deny path changed).Follow-up (not in this PR)
Re-check the capacity gate at promotion time (a
ccrotate_capacitybranch inScheduledRetryGate) so a still-exhausted pool re-defers with backoff instead of dispatching a run that immediately 429s. Today it self-corrects via the post-run rate-limit path, so this is an optimization, not a correctness gap.🤖 Generated with Claude Code