fix(heartbeat): accept spaced interval forms in /heartbeat - #80185
Open
0xGr1mm wants to merge 1 commit into
Open
fix(heartbeat): accept spaced interval forms in /heartbeat#801850xGr1mm wants to merge 1 commit into
0xGr1mm wants to merge 1 commit into
Conversation
12 tasks
`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
force-pushed
the
fix/heartbeat-interval-prefix
branch
from
August 9, 2026 08:56
492572e to
6db1d67
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
/heartbeatrejects 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_intervalhandles those forms andtests/hermes_cli/test_heartbeat.pyalready asserts it (("every 2 hours", 7200),("90 minutes", 5400)). The two command handlers never give it the whole interval: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_intervalsees a bare number, andintervalcomes backNone.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
\brather than$. Onevery 90 minutes …the alternation triesmfirst, finds no word boundary beforeinutes, and backtracks untilminutesmatches whole — so a short unit can never swallow a long one.parse_intervalnow delegates to the new helper (whole string must be the interval, so a non-empty remainder is stillNone). That leaves one grammar in the module instead of two regexes that can drift apart, and keepsparse_interval's existing signalling:Nonefor "not an interval",-1for "belowMIN_INTERVAL_SECONDS".Related Issue
No existing issue. Searched open and merged PRs and issues before starting, per CONTRIBUTING's search-first section:
Regression from #79681, which shipped
/heartbeatyesterday.Type of Change
Bug fix (non-breaking).
Changes Made
hermes_cli/heartbeat.py— addedsplit_interval_prefix(text) -> (Optional[int], str); replaced_INTERVAL_REwith the start-anchored_INTERVAL_PREFIX_RE;parse_intervalnow delegates to the helper; exported the helper in__all__.hermes_cli/cli_commands_mixin.py—_handle_heartbeat_commandusessplit_interval_prefix(arg)in place of thesplit(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 betweensplit_interval_prefixandparse_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 …every 10m Check CI600s, promptCheck CI10m Check CI600s, promptCheck CIevery 90 minutes Check CI5400s, promptCheck CIevery 2 hours Check CI7200s, promptCheck CIevery 30 min ping1800s, promptping90 minutes Check CI5400s, promptCheck CIevery 1 day run the backup86400s, promptrun the backupevery 30s Check CIbanana check CIpytest tests/hermes_cli/test_heartbeat.py -q— 44 passed.pytest tests/hermes_cli/test_heartbeat.py tests/hermes_cli/test_commands.py tests/agent/test_refine_focus.py -q— 99 passed./heartbeat every 2 hours Check the deploynow prints♥ Heartbeat set (every 2h): Check the deploy;/heartbeat statusconfirms the interval and the next fire time.Checklist
Code
fix(heartbeat): …)Documentation & Housekeeping
heartbeat.mdinterval column, plus docstrings on both functionscli-config.yaml.example— N/A, no config keys touchedCONTRIBUTING.md/AGENTS.md— N/A, no architecture or workflow change/heartbeatis a slash command, not a toolNotes for the reviewer
parse_intervalstays exported and its contract is unchanged; only its implementation moved onto the shared grammar.test_split_interval_prefix_agrees_with_parse_intervalpins that equivalence so the two entry points cannot diverge later.Separately, while reading this feature I noticed the gateway heartbeat poller captures
session_idat_register_heartbeat_watchtime, 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 onsession_idchange 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.