Skip to content

feat(cron): per-job max_turns and wall-clock timeout caps for cron jobs - #45809

Open
fabiosiqueira wants to merge 1 commit into
NousResearch:mainfrom
fabiosiqueira:pr/cron-job-caps
Open

feat(cron): per-job max_turns and wall-clock timeout caps for cron jobs#45809
fabiosiqueira wants to merge 1 commit into
NousResearch:mainfrom
fabiosiqueira:pr/cron-job-caps

Conversation

@fabiosiqueira

@fabiosiqueira fabiosiqueira commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds optional per-job max_turns and wall-clock timeout ceilings to cron jobs.

Today a cron session has no per-job ceiling: max_turns is global-only (config.yaml agent.max_turns) and the only runtime guard — the HERMES_CRON_TIMEOUT inactivity watcher — never fires during retry storms, because every retry touches the activity tracker ("API error recovery"). An unattended agentic cron job stuck retrying can therefore keep calling the API for hours with no ceiling. These caps give each job an independent, opt-in budget.

Related Issue

Implements #63267 (feat: per-job max_turns for cron jobs).

Also relevant to #82813 / #82815: the turn ceiling here now resolves through hermes_cli.config.resolve_turn_limit() at both the per-job and the global level, so max_turns: none / unlimited / an explicit 0 are honored in the cron path too rather than being swallowed by an or chain.

Type of Change

  • ✨ New feature (non-breaking change that adds functionality)
  • ♻️ Robustness (retry-storm / runaway-session guard)

Changes Made

  • tools/cronjob_tools.py + cron/jobs.py: optional max_turns (int) and timeout (seconds) per job in the schema/tool and create_job / update_job; 0 clears the cap on update; bool/invalid values are rejected (fall back to global config rather than capping at a bogus value).
  • cron/scheduler.py: _resolve_job_max_iterations (precedence: per-job > agent.max_turns > max_turns, each normalized by resolve_turn_limit(), so an absent global stays unlimited and the per-job knob never introduces a cron-only default) and _resolve_job_wall_clock_limit; the inactivity watcher gains a wall-clock branch that interrupts the run when total elapsed exceeds the job cap, regardless of activity (so a retry loop that keeps the inactivity watcher alive is still bounded).
  • Opt-in: behaviour is unchanged when both fields are unset.
  • tests/cron/test_cron_job_caps.py: coverage.

How to Test

  1. Create a cron job with max_turns: 5 and timeout: 30.
  2. Verify a long / stuck agentic run is interrupted at the wall-clock cap regardless of activity, and turns are capped at the per-job value.
  3. Run: pytest tests/cron/test_cron_job_caps.py -q → 26 passed.

Checklist

  • Conventional Commits
  • PR contains only changes related to this feature
  • pytest tests/cron/test_cron_job_caps.py -q passes (26 passed)
  • Added tests
  • Tool schema descriptions updated for the new fields

@alt-glitch alt-glitch added type/feature New feature or request comp/cron Cron scheduler and job management P3 Low — cosmetic, nice to have labels Jun 13, 2026
@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 14, 2026
@fabiosiqueira

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (was conflicting; now mergeable, single commit).

Two notes on the rebase, and one on overlap with other open PRs.

Rebase

The conflict was in cron/scheduler.py, in the run-watchdog block. Upstream had
added the one-shot run_claim heartbeat path there; this PR had added the
wall-clock cap. Resolved as a union rather than a side-pick: the unlimited branch
now requires both limits to be unset (_cron_inactivity_limit is None and _wall_clock_limit is None) and keeps the upstream one-shot polling/heartbeat
behaviour, while the watcher loop keeps _heartbeat_run_claim_if_due() and checks
the wall-clock cap before inactivity. No behaviour dropped from either side.

Also: #72176 raised the default turn ceiling 90 → 500 while this PR sat. The
_resolve_job_max_iterations fallback and its tests were still pinned to 90, which
would have silently reverted that default for every cron job. Both now resolve to
500, so the per-job cap is purely additive over current main.

Verified: tests/cron/test_cron_job_caps.py 23 passed; full tests/cron clean apart
from 8 pre-existing failures that reproduce identically on unmodified upstream/main
(missing croniter in the local env), plus py_compile on the touched modules.

Overlap

For maintainer triage — several open PRs cover parts of this, and I only spotted
them properly now:

The part of this PR that isn't covered elsewhere is the wall-clock cap and its
rationale: the existing inactivity watchdog can't stop a retry storm, because every
retry attempt touches the activity tracker, so the job never looks idle. That's the
regression test ActiveFakeAgent covers here.

If you'd rather land the turn-cap half from #33323, I'm happy to close this and
re-open the wall-clock cap alone on top of it — say the word and I'll do the split
rather than have four PRs competing over the same knob.

— 🤖 Claude Opus 5

fabiosiqueira added a commit to fabiosiqueira/hermes-engine that referenced this pull request Aug 17, 2026
…seline de teste, lixo de origem

Lições dos rebases de NousResearch#27724 e NousResearch#45809: --ours num teste superseded pode
orfanar helper usado pelo commit seguinte; falha local só conta contra
worktree de upstream/main puro; PR parado carrega import morto desde a
origem. Consolidado em 8 entradas (cap da skill).
fabiosiqueira added a commit to fabiosiqueira/hermes-engine that referenced this pull request Aug 17, 2026
Routine /fork-sync catch-up. Clean merge-tree dry run (0 conflicts,
8 files auto-merged: .gitignore, agent/agent_init.py, agent/curator.py,
cron/scheduler.py, run_agent.py, tools/skill_manager_tool.py,
tools/skill_usage.py, tools/skills_tool.py). Open upstream PRs NousResearch#74875,
NousResearch#80382, NousResearch#78819, NousResearch#45809, NousResearch#27724 re-verified mergeable clean against the
new tip; carry b1e8eb1 (skip_memory_provider) has no upstream route
and stays local per prior audit.
Per-job `max_turns` and `timeout` (wall-clock) caps for cron jobs, with the
global `agent.max_turns` as fallback. The wall-clock cap complements the
inactivity watchdog: a retry storm touches the activity tracker on every
attempt, so an agent stuck retrying never trips inactivity but does trip
wall-clock.

Both the per-job and the global level normalize through
`hermes_cli.config.resolve_turn_limit()`, the single turn-limit
normalization point, so `agent.max_turns: none` / `unlimited` / an explicit
`0` keep meaning "no ceiling" and an absent global stays unlimited — the
per-job knob never introduces a cron-only default of its own. A per-job
value that is not a real cap (non-int, <= 0, `none`) falls through to the
global config instead of capping the job at a bogus value or silently
uncapping it.
@fabiosiqueira

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (was conflicting again after the turn-limit refactor). Single commit, mergeable.

The conflict was in cron/scheduler.py, where main had replaced the or-chain that read agent.max_turns with resolve_turn_limit(). Rather than keeping this PR's own chain, _resolve_job_max_iterations now normalizes both levels through resolve_turn_limit():

  • per-job max_turns wins when it resolves to a real ceiling;
  • anything that isn't one (non-int, <= 0, none, unlimited) falls through to the global config instead of capping the job at a bogus value or silently uncapping it;
  • an absent global stays TURN_LIMIT_UNLIMITED, so the per-job knob no longer introduces a cron-only default of its own (this PR previously hardcoded one).

That keeps agent.max_turns: none working in the cron path too — same class of bug as #82813.

This implements #63267; I've linked it in the description (the PR predated that issue).

Verification: pytest tests/cron/test_cron_job_caps.py -q → 26 passed. Full pytest tests/cron/ → 8 failures, identical by name to a clean main checkout at the same commit (test_blueprint_catalog, test_inflight_stale_guard, test_ticker_stall_60703) — pre-existing, not from this branch.

— 🤖 Claude Opus 5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cron Cron scheduler and job management P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants