Skip to content

fix(scheduler): count a long turn as dispatched, bound fire retries (#337) - #340

Open
mabry1985 wants to merge 2 commits into
mainfrom
fix/scheduler-runaway-refire
Open

fix(scheduler): count a long turn as dispatched, bound fire retries (#337)#340
mabry1985 wants to merge 2 commits into
mainfrom
fix/scheduler-runaway-refire

Conversation

@mabry1985

@mabry1985 mabry1985 commented Jul 30, 2026

Copy link
Copy Markdown
Member

Fixes #337.

The bug

scheduler/local.py fired a job by POSTing to its own /a2a and awaiting the entire turn on httpx.AsyncClient(timeout=30). Any turn longer than 30s raised ReadTimeout_fire returned False_reschedule_or_delete never ran → next_fire stayed in the past and last_fire stayed null → the 1s poll loop re-claimed the same row every tick.

Because the server keeps running a turn after the client disconnects, each "failed" fire still cost a full agent turn. Live on the Deck: a */15 * * * * job fired every ~31s for ~18h unattended, leaving ~2,000 orphaned TASK_STATE_WORKING tasks.

The fix

  • ReadTimeout now means dispatched, not failed. The agent accepted the prompt and is working; that's the success path. Connect errors (agent not up) and HTTP error responses remain genuine failures.
  • Split timeouts — short connect timeout + a dispatch-confirmation read window, so a single long turn no longer blocks the poll loop for its full duration.
  • Bounded retry. Failures back off exponentially (30s → 15min ceiling), persisted to next_fire so a restart mid-backoff can't reset the job to firing every tick. After 5 consecutive failures the job stops retrying that slot: cron skips to its next natural slot, one-shots are dropped rather than retried forever.

The retry-on-transient-failure intent from the original code is preserved — a one-shot still survives a network blip, it just can't spin.

Tests

Four new tests, all four fail on main:

  • test_long_turn_fires_once_per_slot — the regression itself: five consecutive ticks against a slow-turn agent must produce exactly one fire, with next_fire advanced past now and last_fire recorded (so the operator surface stops lying).
  • test_long_turn_counts_as_dispatched — read timeout is INFO + success, not an error.
  • test_delivery_failure_backs_off_instead_of_hammering — a failed job is never left past-due, and successive delays grow.
  • test_retries_are_capped — cron rolls forward at the cap; one-shots are dropped.

Existing _fire tests are unchanged and still pass — True still means "delivered", the definition just now includes "accepted and still running".

50 passed across test_scheduler_local.py, test_schedule_tools.py, test_operator_api_routes.py; ruff check + format clean.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved scheduled job handling for long-running dispatches, preventing duplicate deliveries when a job is still processing.
    • Added exponential backoff for failed deliveries to reduce rapid, repeated retries.
    • Scheduler now skips persistently failing recurring jobs to the next scheduled occurrence.
    • One-time jobs that exceed the retry limit are removed instead of retried indefinitely.
    • Successful dispatches now advance schedules reliably, including when processing continues beyond the response timeout.

…337)

`_fire` awaited the entire agent turn on a 30s client timeout, so any turn
longer than that raised ReadTimeout and was scored a delivery failure. Since
`_reschedule_or_delete` only ran on success, `next_fire` never advanced and
`last_fire` stayed null — leaving the row permanently past-due for the 1s poll
loop to re-claim every tick. Live on the Deck a `*/15 * * * *` job fired every
~31s for ~18h, and because the server keeps running a turn after the client
disconnects, every "failed" fire still cost a full turn: ~2,000 orphaned tasks
and continuous unattended token burn.

- `_fire` now treats ReadTimeout as *dispatched* — the agent accepted the
  prompt and is working. Connect errors and HTTP error responses stay failures.
- Split the timeout: a short connect timeout plus a dispatch-confirmation read
  window, so the poll loop no longer blocks for a whole turn.
- Failures now back off exponentially (persisted to `next_fire`, so a restart
  mid-backoff can't reset to firing every tick) and give up after 5 attempts:
  cron skips to its next slot, one-shots are dropped.

Tests cover the regression directly: five consecutive ticks against a
slow-turn agent must produce exactly one fire, with `next_fire` advanced and
`last_fire` recorded. All four fail on main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@mabry1985, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 45 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 61b7c6d9-0389-4469-9fe1-59c7c70a47f4

📥 Commits

Reviewing files that changed from the base of the PR and between 37e45ef and ffcad88.

📒 Files selected for processing (3)
  • scheduler/interface.py
  • scheduler/local.py
  • tests/test_scheduler_local.py

Walkthrough

The LocalScheduler now treats read timeouts after dispatch as successful delivery, separates connection and dispatch timeouts, and applies exponential backoff with capped retries for genuine failures. Tests cover long turns, single-slot firing, increasing delays, and terminal cron or one-shot behavior.

Changes

Scheduler delivery semantics

Layer / File(s) Summary
Dispatch outcome and timeout contract
scheduler/local.py
_fire uses separate connect and dispatch timeouts, treats httpx.ReadTimeout as dispatched success, and retains connection or HTTP errors as failures.
Failure backoff and retry cap
scheduler/local.py
_tick tracks consecutive failures, persists exponential next_fire delays, advances capped cron jobs, and deletes capped one-shot jobs.
Dispatch and retry regression coverage
tests/test_scheduler_local.py
Tests verify long-turn dispatch handling, single firing per cron slot, increasing backoff, and capped retry outcomes.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SchedulerTick
  participant LocalScheduler
  participant AgentA2A
  participant SchedulerSQLite
  SchedulerTick->>LocalScheduler: Poll due job
  LocalScheduler->>AgentA2A: POST scheduled prompt
  alt Agent accepts and response exceeds read timeout
    AgentA2A-->>LocalScheduler: ReadTimeout after dispatch
    LocalScheduler->>SchedulerSQLite: Advance next_fire and record last_fire
  else Delivery failure
    AgentA2A-->>LocalScheduler: Connection or HTTP error
    LocalScheduler->>SchedulerSQLite: Persist exponential backoff
  end
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the scheduler dispatch and retry fix and is concise.
Linked Issues check ✅ Passed The changes match #337 by treating long turns as dispatched, advancing schedule state, and adding bounded retry backoff with cron/one-shot handling.
Out of Scope Changes check ✅ Passed The PR stays focused on scheduler firing semantics and regression tests, with no obvious unrelated changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/scheduler-runaway-refire

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/test_scheduler_local.py (1)

416-428: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate _RefusingClient test double — hoist to module scope.

Identical class defined inline in two tests, while _SlowClient above is already correctly shared at module level for its two consumers. Extracting one shared helper avoids drift between the copies as the connection-failure simulation evolves.

  • tests/test_scheduler_local.py#L416-L428: remove this inline _RefusingClient definition; import/reuse a single module-level version instead.
  • tests/test_scheduler_local.py#L460-L472: remove this duplicate _RefusingClient definition and reuse the same module-level class as above.
♻️ Proposed refactor
+class _RefusingClient:
+    """Agent isn't accepting connections (mirrors a genuine delivery failure)."""
+
+    def __init__(self, *a, **k):
+        pass
+
+    async def __aenter__(self):
+        return self
+
+    async def __aexit__(self, *a):
+        return False
+
+    async def post(self, *a, **k):
+        import httpx
+
+        raise httpx.ConnectError("Connection refused")
+
+
 `@pytest.mark.asyncio`
 async def test_delivery_failure_backs_off_instead_of_hammering(tmp_path, monkeypatch):
     """A genuine failure retries later — never at poll frequency."""
     import httpx

-    class _RefusingClient:
-        def __init__(self, *a, **k):
-            pass
-
-        async def __aenter__(self):
-            return self
-
-        async def __aexit__(self, *a):
-            return False
-
-        async def post(self, *a, **k):
-            raise httpx.ConnectError("Connection refused")
-
     monkeypatch.setattr(httpx, "AsyncClient", _RefusingClient)

Apply the same removal in test_retries_are_capped.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_scheduler_local.py` around lines 416 - 428, Hoist the duplicated
_RefusingClient test double to module scope alongside _SlowClient, then remove
both inline definitions in the affected locations
(tests/test_scheduler_local.py:416-428 and
tests/test_scheduler_local.py:460-472) so the tests reuse the shared class,
including test_retries_are_capped.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scheduler/local.py`:
- Around line 177-178: Persist the consecutive delivery-failure count alongside
next_fire in the scheduler’s durable state, updating both transactionally
whenever a delivery fails so restarts retain the retry budget. Clear the
persisted counter after accepted dispatch or terminal handling, and remove
reliance on the in-memory _fire_failures map. Add a regression test that
restarts between failed deliveries and verifies the five-attempt cap still
terminates the one-shot.

---

Nitpick comments:
In `@tests/test_scheduler_local.py`:
- Around line 416-428: Hoist the duplicated _RefusingClient test double to
module scope alongside _SlowClient, then remove both inline definitions in the
affected locations (tests/test_scheduler_local.py:416-428 and
tests/test_scheduler_local.py:460-472) so the tests reuse the shared class,
including test_retries_are_capped.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c3439c83-d754-472d-b614-a70bcb70c527

📥 Commits

Reviewing files that changed from the base of the PR and between 55e0566 and 37e45ef.

📒 Files selected for processing (2)
  • scheduler/local.py
  • tests/test_scheduler_local.py

Comment thread scheduler/local.py Outdated
CodeRabbit review on #340: `next_fire` survived a restart but the consecutive-
failure counter lived in memory, so a process restart between failed deliveries
reset the count — an unavailable one-shot could evade the five-attempt cap
indefinitely, since the cap only ever bounded a single uninterrupted run.

Move the counter to a `fire_attempts` column (lazy migration, same pattern as
`context_id`) and write it in the same UPDATE as `next_fire`, so the delay and
the budget it spends can never diverge. Cleared on accepted dispatch and when a
cron job gives up and skips ahead.

Two tests: the retry budget survives repeated restarts (a fresh scheduler
against the same jobs.db each round still drops the one-shot at the cap), and a
pre-existing jobs.db gains the column defaulting to 0. Verified to bite —
simulating the in-memory counter makes every restart log "attempt 1/5" and the
one-shot survives forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mabry1985

Copy link
Copy Markdown
Member Author

Fixed in ffcad88 — the finding was correct.

next_fire persisted but the counter didn't, so the five-attempt cap only ever bounded a single uninterrupted run: a restart between failed deliveries reset the count, and an unavailable one-shot could retry indefinitely.

The counter now lives in a fire_attempts column (lazy migration, mirroring the existing context_id one) and is written in the same UPDATE as next_fire, so the delay and the budget it spends can't diverge. It's cleared on accepted dispatch and when a cron job gives up and skips ahead.

Added the restart regression test you asked for — it hands the job to a fresh LocalScheduler against the same jobs.db each round and asserts the one-shot is still dropped at the cap. Verified it bites: simulating the old in-memory counter makes every round log attempt 1/5 and the one-shot survives forever.

Also covered the lazy migration (a pre-existing jobs.db gains the column defaulting to 0).

1739 passed; ruff + both spec gates green.

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.

Scheduler re-fires a job forever when a turn outlasts the 30s fire timeout (runaway token burn)

1 participant