Skip to content

feat(heartbeat): auto-retry ccrotate capacity defers via scheduled_retry (PEN-382) - #299

Merged
kkroo merged 3 commits into
masterfrom
omar/pen382-ccrotate-scheduled-retry
Jun 5, 2026
Merged

feat(heartbeat): auto-retry ccrotate capacity defers via scheduled_retry (PEN-382)#299
kkroo merged 3 commits into
masterfrom
omar/pen382-ccrotate-scheduled-retry

Conversation

@kkroo

@kkroo kkroo commented Jun 5, 2026

Copy link
Copy Markdown

What

Auto-retry agent wakes that the ccrotate capacity gate defers on pool exhaustion, by reusing paperclip's existing scheduled_retry machinery. Implements the design in penstock-paperclip-plugin#2 / PEN-382 (BLO-4728).

Why

enqueueWakeup (heartbeat.ts) used to write a terminal status:"skipped" wake and drop it on capacity defer — resumeAt was 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 at resumeAt 03:02Z with no autonomous recovery.

How

At the gate-deny site, instead of dropping, persist a status:"scheduled_retry" heartbeat run:

  • scheduledRetryReason = "ccrotate_capacity", scheduledRetryAt = resumeAt
  • errorFamily = "rate_limit_exhausted" + retryNotBefore = resumeAt so the existing rate-limit bounded backoff/exhaust honors resumeAt as the floor

The existing promoteDueScheduledRetries sweep (server tick + startup recovery) claims it by (status, scheduledRetryAt <= now) and promotes it when due. No new status enum (status is a text column), no new sweep, no DB migration.

Two correctness properties:

  • The resume claim is keyed on (status, scheduledRetryAt), not idempotencyKey — so the retry cannot coalesce into its own deferred row (the crux flagged in eng review).
  • A null resumeAt falls back to CCROTATE_CAPACITY_DEFAULT_RETRY_DELAY_MS (5 min) so the row is never stranded with a null scheduledRetryAt the sweep would skip.

Tests

server/src/__tests__/heartbeat-ccrotate-capacity-retry.test.ts (embedded-postgres), all TDD'd red→green:

  1. capacity defer creates a scheduled_retry run with scheduledRetryAt = resumeAt + rate-limit family (and does not write a terminal skipped drop)
  2. null resumeAt falls back to a bounded future delay (not null)
  3. the row promotes to queued when due via the existing sweep

tsc --noEmit clean. No regression to the three adjacent gate tests (they all inject an allow:true gate; only the deny path changed).

Follow-up (not in this PR)

Re-check the capacity gate at promotion time (a ccrotate_capacity branch in ScheduledRetryGate) 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

kkroo and others added 2 commits June 5, 2026 03:05
…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>
@kkroo
kkroo force-pushed the omar/pen382-ccrotate-scheduled-retry branch from b7599f9 to bf98f3e Compare June 5, 2026 05:22
@kkroo

kkroo commented Jun 5, 2026

Copy link
Copy Markdown
Author

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.

@kkroo

kkroo commented Jun 5, 2026

Copy link
Copy Markdown
Author

@ally please review this PR.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:11481Capacity defers accumulate one promotable scheduled_retry row 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 skipped row. The new path inserts a scheduled_retry heartbeatRuns row with no idempotencyKey and no dedup against an existing capacity row for the agent. The defer also does not bump lastHeartbeatAt, and neither the cooldown check (heartbeat.ts:11423, keyed on lastHeartbeatAt) nor the idle circuit-breaker (tickTimers, keyed on consecutiveTimerIdleRuns which only increments after a run executes) suppresses a deferred wake. So during a prolonged pool exhaustion, tickTimers (heartbeat.ts:12780) re-fires heartbeat_timer every interval and each tick mints a fresh row (all with scheduledRetryAt ≈ resumeAt).
    • When capacity returns, promoteDueScheduledRetries claims up to 50 due rows and the new re-check promotes each that the gate now allows → N queued runs for one agent. With maxConcurrentRuns:1 they 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 at heartbeat.ts:11437 is 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_retry capacity row for this agent and update its scheduledRetryAt to the new resumeAt instead of inserting a second row (at minimum for heartbeat_timer; distinct issue-scoped wakes can legitimately each survive). This collapses a 30-tick exhaustion into one recovery run.

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, the ccrotate_capacity re-check → re-defer-with-backoff / terminate-at-cap) and tests it (tests 4 & 5 at heartbeat-ccrotate-capacity-retry.test.ts:219 and :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_retry rows → multiple promotions). That gap is why the amplification above isn't visible. A test asserting that repeated heartbeat_timer defers collapse to a single promotable row would guard the recommended fix.
  • [code] e2e CI is red, but it's the browser/relay end-to-end suite with no touchpoint on server-side heartbeat.ts scheduling (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) guarded UPDATE with .returning(), so concurrent sweeps can't double-promote or double-increment the attempt counter.
  • The CCROTATE_CAPACITY_MAX_RETRY_ATTEMPTS backstop bounds the re-defer loop per row and terminates to cancelled with a clear operator-facing error + lifecycle event — no infinite loop.
  • appendRunEvent is 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 old skipped drop. SQL is fully parameterized via drizzle — no injection surface; no LLM trust-boundary in this diff.

Recommended Action

  1. 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.
  2. Fix the stale PR body (the promotion-time re-check is in this PR).
  3. Re-run e2e to confirm the failure is the unrelated flake.

…tate-scheduled-retry

# Conflicts:
#	server/src/services/heartbeat.ts
@kkroo
kkroo marked this pull request as ready for review June 5, 2026 17:26
@kkroo
kkroo merged commit c3d6b31 into master Jun 5, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant