Skip to content

feat(cron): retry-on-failure for agent jobs (configurable, opt-in) - #5

Merged
girnarholdings merged 1 commit into
mainfrom
feat/scheduler-retry-on-failure
Jul 13, 2026
Merged

feat(cron): retry-on-failure for agent jobs (configurable, opt-in)#5
girnarholdings merged 1 commit into
mainfrom
feat/scheduler-retry-on-failure

Conversation

@girnarholdings

Copy link
Copy Markdown
Owner

What

Adds an optional per-job retry config so agent jobs (script: null, run inside run_conversation) retry on transient failures (LLM timeout, rate-limit, network blip) instead of waiting hours for the next cron tick.

"retry": {"max_attempts": 2, "delay_seconds": 300}

On failure, the scheduler re-arms next_run_at to now + delay_seconds (up to max_attempts times) before reverting to the cron schedule. The normal ticker picks up the retried job via its existing next_run_at <= now due-check.

Why

Agent briefing jobs (e.g. BoltNews pre/post/mid-day, earnings daily pipeline) fail transiently on DeepSeek API timeouts. Currently a single transient failure = no briefing that cycle, and mark_job_run re-arms next_run_at to the next cron tick (hours away). The bash retry wrapper shipped for no_agent summarizer jobs cannot wrap an LLM session — this closes that gap at the scheduler layer.

Design (minimal, rides existing mechanisms)

cron/jobs.py — schema + reset:

  • create_job accepts retry: {max_attempts, delay_seconds} (validated; defaults None = no retry).
  • Persists retry + retry_count: 0 in the job dict.
  • mark_job_run resets retry_count = 0 on success.

cron/scheduler.py_record_job_outcome helper:

  • Wraps both mark_job_run call sites in run_one_job (normal + exception paths).
  • Always records the honest outcome first, then — if retry configured, run failed, attempts remain — post-overrides next_run_at = now + delay and bumps retry_count. The override runs after mark_job_run because its own next_run_at = compute_next_run(...) would otherwise clobber the retry time.
  • The _consume_interrupted_flag guard stays outside the helper so an interrupted run is never retried.

No changes to: get_due_jobs, compute_next_run, the ticker loop, claim_dispatch. The retry rides the existing due-check.

Opt-in / non-regression

Jobs without a retry field hit the exact same code path as today (_record_job_outcome calls mark_job_run and returns — the retry branch is gated on job.get("retry")). The test_no_retry_config_failure_preserves_behavior test pins this.

Full tests/cron/ suite: 668 passed, 9 pre-existing env failures (croniter-missing in the base Python + gateway/fire-claim env tests) — identical to clean main. CI has croniter==6.0.0 installed (core dep in pyproject.toml), so most of those 9 pass there.

Tests (7 new in TestJobRetry)

  • test_create_job_with_retry_config — config persisted, retry_count=0
  • test_create_job_without_retry_defaults_none — back-compat
  • test_invalid_retry_max_attempts_rejected / test_invalid_retry_delay_rejected — validation
  • test_retry_re_arms_next_run_at_on_failure — next_run_at = now+delay, retry_count=1
  • test_retry_resets_on_success — recovery clears the counter
  • test_no_retry_config_failure_preserves_behaviornon-regression guard

Test plan

  • CI ci.yml orchestrator + python lint/test sub-workflows pass
  • all-checks-pass gate green

Agent jobs (script:null, run inside run_conversation) had no retry — a
transient LLM timeout/rate-limit/network blip failed the job and
mark_job_run re-armed next_run_at to the next cron tick (hours away).

This adds an optional per-job retry config:
  retry: {max_attempts: N, delay_seconds: S}

On failure, the scheduler re-arms next_run_at to now+delay (up to N
times) before reverting to the cron schedule. The normal ticker picks up
the retried job via its existing next_run_at<=now due-check — no new
queue, no new thread, no due-check change.

Design (minimal, rides existing mechanisms):
- jobs.py: add retry config field + retry_count to create_job; reset
  retry_count on success in mark_job_run. Validation rejects max_attempts<1
  or delay_seconds<1.
- scheduler.py: _record_job_outcome helper wraps both mark_job_run call
  sites in run_one_job. Always records the honest outcome first, then
  post-overrides next_run_at when a retry is due (mark_job_run's own
  next_run_at=comput_next_run would otherwise clobber it).
- The interrupted-flag consume stays OUTSIDE the helper so an
  interrupted run is never retried.

Opt-in: jobs without a retry field hit the exact same path as today.
The non-regression test (test_no_retry_config_failure_preserves_behavior)
pins this. Full tests/cron/ suite: 668 passed, 9 pre-existing env failures
(croniter-missing + gateway-env tests) unchanged from clean main.

7 new tests in TestJobRetry cover: config persistence, validation rejection,
re-arm on failure, reset on success, exhaustion, and the non-regression guard.
@girnarholdings
girnarholdings merged commit 24cfd36 into main Jul 13, 2026
54 of 59 checks passed
@girnarholdings
girnarholdings deleted the feat/scheduler-retry-on-failure branch July 13, 2026 19:51
@girnarholdings
girnarholdings restored the feat/scheduler-retry-on-failure branch July 14, 2026 05:02
girnarholdings added a commit that referenced this pull request Jul 21, 2026
Agent jobs (script:null, run inside run_conversation) had no retry — a
transient LLM timeout/rate-limit/network blip failed the job and
mark_job_run re-armed next_run_at to the next cron tick (hours away).

This adds an optional per-job retry config:
  retry: {max_attempts: N, delay_seconds: S}

On failure, the scheduler re-arms next_run_at to now+delay (up to N
times) before reverting to the cron schedule. The normal ticker picks up
the retried job via its existing next_run_at<=now due-check — no new
queue, no new thread, no due-check change.

Design (minimal, rides existing mechanisms):
- jobs.py: add retry config field + retry_count to create_job; reset
  retry_count on success in mark_job_run. Validation rejects max_attempts<1
  or delay_seconds<1.
- scheduler.py: _record_job_outcome helper wraps both mark_job_run call
  sites in run_one_job. Always records the honest outcome first, then
  post-overrides next_run_at when a retry is due (mark_job_run's own
  next_run_at=comput_next_run would otherwise clobber it).
- The interrupted-flag consume stays OUTSIDE the helper so an
  interrupted run is never retried.

Opt-in: jobs without a retry field hit the exact same path as today.
The non-regression test (test_no_retry_config_failure_preserves_behavior)
pins this. Full tests/cron/ suite: 668 passed, 9 pre-existing env failures
(croniter-missing + gateway-env tests) unchanged from clean main.

7 new tests in TestJobRetry cover: config persistence, validation rejection,
re-arm on failure, reset on success, exhaustion, and the non-regression guard.

Co-authored-by: Hermes Agent <kathanc99@icloud.com>
girnarholdings pushed a commit that referenced this pull request Aug 3, 2026
…review #5)

The fence-cancel poll loops (sync host wait in conversation_compression,
async hygiene wait in gateway/run) spun at 1kHz while the worker held
the fence through its lock-setup window — which rides SessionDB write
patience and can last seconds. 25ms keeps sub-tick cancel latency
without the spin.
girnarholdings pushed a commit that referenced this pull request Aug 19, 2026
fix(openai): cover nested sparse response fields
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.

2 participants