Skip to content

fix(heartbeat): accept spaced interval forms in /heartbeat - #80185

Open
0xGr1mm wants to merge 1 commit into
NousResearch:mainfrom
0xGr1mm:fix/heartbeat-interval-prefix
Open

fix(heartbeat): accept spaced interval forms in /heartbeat#80185
0xGr1mm wants to merge 1 commit into
NousResearch:mainfrom
0xGr1mm:fix/heartbeat-interval-prefix

Conversation

@0xGr1mm

@0xGr1mm 0xGr1mm commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

/heartbeat rejects every interval that spells the unit as a separate word — every 90 minutes, every 2 hours, every 30 min, every 1 day. The user gets the usage banner instead of a heartbeat, on both the CLI and the gateway.

The parser is not the problem. parse_interval handles those forms and tests/hermes_cli/test_heartbeat.py already asserts it (("every 2 hours", 7200), ("90 minutes", 5400)). The two command handlers never give it the whole interval:

tokens = arg.split(None, 2)                        # ["every", "90", "minutes Check CI"]
if tokens and tokens[0].lower() == "every" and len(tokens) >= 2:
    interval = parse_interval(f"every {tokens[1]}")   # parse_interval("every 90") -> None
    prompt = tokens[2] if len(tokens) > 2 else ""     # "minutes Check CI"

Splitting on whitespace assumes the interval is exactly one token. When it is two, the unit is stranded at the head of the prompt, parse_interval sees a bare number, and interval comes back None.

The fix splits the interval off using the interval grammar instead of using whitespace. split_interval_prefix() matches the same units anchored at the start of the string and returns (seconds, prompt), so value and unit stay together however they were written.

One detail worth calling out: the unit is terminated with \b rather than $. On every 90 minutes … the alternation tries m first, finds no word boundary before inutes, and backtracks until minutes matches whole — so a short unit can never swallow a long one.

parse_interval now delegates to the new helper (whole string must be the interval, so a non-empty remainder is still None). That leaves one grammar in the module instead of two regexes that can drift apart, and keeps parse_interval's existing signalling: None for "not an interval", -1 for "below MIN_INTERVAL_SECONDS".

Related Issue

No existing issue. Searched open and merged PRs and issues before starting, per CONTRIBUTING's search-first section:

gh search prs --repo NousResearch/hermes-agent "heartbeat interval"      # only connection-heartbeat PRs (WeCom/QQ/Discord)
gh search prs --repo NousResearch/hermes-agent "parse_interval"          # 0 results
gh search prs --repo NousResearch/hermes-agent "heartbeat every minutes" # 0 results
gh search issues --repo NousResearch/hermes-agent heartbeat in:title     # nothing on interval parsing

Regression from #79681, which shipped /heartbeat yesterday.

Type of Change

Bug fix (non-breaking).

Changes Made

  • hermes_cli/heartbeat.py — added split_interval_prefix(text) -> (Optional[int], str); replaced _INTERVAL_RE with the start-anchored _INTERVAL_PREFIX_RE; parse_interval now delegates to the helper; exported the helper in __all__.
  • hermes_cli/cli_commands_mixin.py_handle_heartbeat_command uses split_interval_prefix(arg) in place of the split(None, 2) block.
  • gateway/slash_commands.py — same substitution in the gateway _handle_heartbeat_command.
  • tests/hermes_cli/test_heartbeat.py — 4 new tests (30 cases) covering the spaced forms, the short-unit-prefix trap, the below-floor path, prompt whitespace preservation, and agreement between split_interval_prefix and parse_interval.
  • website/docs/user-guide/features/heartbeat.md — the interval column now shows the spelled-out forms.

How to Test

Before and after, on the same inputs:

/heartbeat … before after
every 10m Check CI 600s, prompt Check CI unchanged
10m Check CI 600s, prompt Check CI unchanged
every 90 minutes Check CI usage error 5400s, prompt Check CI
every 2 hours Check CI usage error 7200s, prompt Check CI
every 30 min ping usage error 1800s, prompt ping
90 minutes Check CI usage error 5400s, prompt Check CI
every 1 day run the backup usage error 86400s, prompt run the backup
every 30s Check CI "interval too small" unchanged
banana check CI usage error unchanged
  1. pytest tests/hermes_cli/test_heartbeat.py -q — 44 passed.
  2. pytest tests/hermes_cli/test_heartbeat.py tests/hermes_cli/test_commands.py tests/agent/test_refine_focus.py -q — 99 passed.
  3. Manually in the CLI: /heartbeat every 2 hours Check the deploy now prints ♥ Heartbeat set (every 2h): Check the deploy; /heartbeat status confirms the interval and the next fire time.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(heartbeat): …)
  • I searched for existing PRs to make sure this isn't a duplicate (queries above)
  • My PR contains only changes related to this fix
  • I've run the affected suites and all tests pass
  • I've added tests for my changes
  • I've tested on my platform: macOS 15 (Darwin 25.5), Python 3.11

Documentation & Housekeeping

  • I've updated relevant documentation — heartbeat.md interval column, plus docstrings on both functions
  • cli-config.yaml.example — N/A, no config keys touched
  • CONTRIBUTING.md / AGENTS.md — N/A, no architecture or workflow change
  • Cross-platform impact — N/A, pure string parsing, no paths or platform APIs
  • Tool descriptions/schemas — N/A, /heartbeat is a slash command, not a tool

Notes for the reviewer

parse_interval stays exported and its contract is unchanged; only its implementation moved onto the shared grammar. test_split_interval_prefix_agrees_with_parse_interval pins that equivalence so the two entry points cannot diverge later.

Separately, while reading this feature I noticed the gateway heartbeat poller captures session_id at _register_heartbeat_watch time, so after a compression session rotation it polls the archived id, sees the migrated state as cleared, and drops the watch — the heartbeat stops silently. The CLI path rebinds its manager on session_id change and is unaffected. That is a separate defect with a separate fix, so it is deliberately not in this PR; happy to open a follow-up.

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Aug 6, 2026
`parse_interval` accepts `every 90 minutes` and `every 2 hours`, and its
tests assert that. Both command handlers, however, split the argument with
`split(None, 2)` and pass only the *first* token after `every` to it:

    tokens = arg.split(None, 2)          # ["every", "90", "minutes Check CI"]
    interval = parse_interval(f"every {tokens[1]}")   # parse_interval("every 90") -> None

The unit lands at the head of the prompt instead, `interval` comes back
None, and the command is rejected with the usage banner. Every form that
spells the unit as a separate word is unusable on both the CLI and the
gateway:

    /heartbeat every 90 minutes Check CI    -> usage error
    /heartbeat every 2 hours Check CI       -> usage error
    /heartbeat every 30 min ping            -> usage error
    /heartbeat every 1 day run the backup   -> usage error

Split the interval off with the interval grammar itself rather than with
whitespace. `split_interval_prefix()` matches the same units anchored at
the start of the string and returns `(seconds, prompt)`, so the value and
unit stay together however they are written. The unit is terminated with
`\b` instead of `$`, which lets the alternation backtrack from `m` to
`minutes` rather than matching the short prefix.

`parse_interval` now delegates to it (whole string must be the interval,
so a non-empty remainder is still None) — one grammar, no second regex to
drift. Its signalling is unchanged: None for "not an interval", -1 for
"below MIN_INTERVAL_SECONDS".

No behavior change for the compact forms (`10m`, `every 2h`), for the
below-floor path, or for input that is not an interval at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@0xGr1mm
0xGr1mm force-pushed the fix/heartbeat-interval-prefix branch from 492572e to 6db1d67 Compare August 9, 2026 08:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants