Skip to content

fix(cron): retry bot-chat delivery when the recipient is mid-turn - #107522

Open
sefsefsef10 wants to merge 5 commits into
NousResearch:mainfrom
sefsefsef10:fix/bot-chat-delivery-queue
Open

sefsefsef10 wants to merge 5 commits into
NousResearch:mainfrom
sefsefsef10:fix/bot-chat-delivery-queue

Conversation

@sefsefsef10

Copy link
Copy Markdown

The bug

A cron delivery to bot-chat:<profile> is dropped when the recipient's Bot Chat is mid-turn. The payload then exists nowhere: bot-chat is deliberately excluded from the durable delivery queue in _deliver_result (cron/scheduler_delivery.py:1673), so nothing retries it. The sender records delivery_outcome=failed, the recipient never learns anything was sent, and a scheduled finding is lost silently.

SESSION_NOT_OWNED is a typed capacity refusal — hermes_cli/active_sessions.py documents it as "Capacity = busy, come back later" — so giving up on it discards a payload the CLI explicitly invited us to retry.

Reproduced live (Windows, real CLI): two concurrent hermes chat --in ~ -c "Bot Chat" -Q sends against one profile; the second returns exit 1 with hermes-refusal-reason: SESSION_NOT_OWNED. Observed in the field with two bot routines 30 min apart whose recipient turn ran 15 min.

The fix

Retry only typed capacity refusals (SESSION_NOT_OWNED, MAX_CONCURRENT_SESSIONS) with exponential backoff. Genuine errors stay terminal and loud on the first attempt.

Four commits, each fixing a defect found by review rather than by writing the code:

  1. 11d6448 — retry capacity refusals instead of dropping them.
  2. b795c41 — classify by parsing the hermes-refusal-reason: marker, not substring-matching output. Also fixes classification running on output already truncated to its last 500 chars — the marker prints first, so a real refusal behind a verbose message was misread as a hard failure and the payload dropped. Classify on full output; truncate only what is displayed. SESSION_COORDINATION_UNAVAILABLE is explicitly excluded (ownership unprovable is not busy; retrying it is the fail-open hole).
  3. ea2d1f2 — make the retry wait yield to gateway shutdown. The backoff slept up to 210s uninterruptibly while stop drains cron for agent.cron_drain_timeout (default 30s), so a worker merely waiting was SIGKILLed mid-wait and left wedged. Now sleeps in ≤1s slices polling the existing _is_interrupted peek, keyed by fire token.
  4. 99fef31 — make the budget a real deadline. It was checked only before sleeping, so the next attempt still got a full per-attempt timeout regardless of time left: 1230s elapsed against a 900s budget. Each attempt's timeout is now clamped to remaining time, and a timeout caused by that clamp reports budget exhaustion rather than "the recipient hung" — different failures.

Config (config.yaml, not env vars)

key default meaning
cron.bot_chat_busy_retries 3 extra attempts on a capacity refusal; 0 restores previous drop-on-busy
cron.bot_chat_busy_backoff_seconds 30.0 doubles per attempt (30/60/120)
cron.bot_chat_busy_budget_seconds 900.0 overall wall-clock deadline; 0 disables

Measured live: a capacity refusal returns in ~3.4s (successful delivery ~9.8s), so the realistic ladder costs ~4 min. The budget exists for the pathological hang case only.

Not changed, and why

  • No idempotency key added. A capacity refusal is issued at lease acquisition, before any turn runs — verified live: a refused concurrent send wrote 0 rows to the recipient's state.db. TimeoutExpired exits through the outer handler and never reaches the retry loop. So retrying cannot duplicate a paid bot turn.
  • Scheduler is not blocked. tick(sync=False) submits to a ThreadPoolExecutor and releases .tick.lock without waiting; the sleep occupies one worker.

Tests

tests/cron/test_cron_bot_chat_delivery.py — 28 pass.

Each fix is pinned by a test that fails when reverted:

  • remove marker parsing / restore pre-truncation → 2 tests fail
  • remove the shutdown check → the shutdown test hangs until killed (exit 124), reproducing the wedged worker
  • remove the budget clamp → 2 tests fail

The budget test previously asserted only attempts < 4 and passed while elapsed time was 1230s against a 900s budget — a proxy, not the invariant. It now asserts elapsed <= budget and that a later attempt is clamped.

scripts/run_tests.sh tests/cron/ shows 11 failures on this Windows host that are identical on clean origin/main (pre-existing env issues, unrelated to this change).

A bot-chat delivery refused because the recipient's Bot Chat already has a
live owner was treated as a terminal failure and dropped. Bot-chat is
deliberately excluded from the durable delivery queue in _deliver_result
(a bot turn costs money and must never be replayed by a second writer), so
that drop is total: the payload exists nowhere afterwards. The sender records
delivery_outcome=failed, the recipient never learns anything was sent, and a
scheduled finding is lost silently.

SESSION_NOT_OWNED is a typed CAPACITY refusal -- active_sessions.py documents
it as "busy, come back later" -- so the correct response is to wait, not to
give up. Reproduced live: two concurrent `hermes chat -c "Bot Chat"` sends
against one profile return exit 1 / SESSION_NOT_OWNED for the second, which is
exactly what a bot routine firing during another bot's delivery turn produces.
Observed in the field with two routines 30 minutes apart whose recipient turn
ran 15 minutes.

Retry only the typed capacity refusals (SESSION_NOT_OWNED,
MAX_CONCURRENT_SESSIONS) with exponential backoff; genuine failures stay
terminal and loud on the first attempt, and an exhausted ladder still returns
the refusal rather than a phantom success. Defaults (3 retries; 30s doubling to
30/60/120s) are sized for an agent turn and live in config.yaml, not env vars;
bot_chat_busy_retries: 0 restores the previous drop-on-busy behaviour.

Tests: a busy recipient is retried and delivers; a genuine error is attempted
exactly once; a persistently busy recipient reports the refusal instead of
succeeding.
Two defects in the busy-retry gate, both found by adversarial review:

1. Substring matching meant ANY child output containing "SESSION_NOT_OWNED"
   was retried -- including a job whose own payload or traceback merely
   mentions the reason name. Parse the `hermes-refusal-reason:` marker line
   the CLI actually emits (hermes_cli/active_sessions.py) instead.

2. The output was truncated to its last 500 chars BEFORE classification. The
   refusal marker is printed FIRST, ahead of the CLI's long explanatory text,
   so a real refusal behind a verbose message was classified as a genuine
   failure and the payload dropped -- the precise bug the retry exists to
   prevent. Classify on the full output; truncate only what is displayed.

SESSION_COORDINATION_UNAVAILABLE is now explicitly excluded and pinned by a
test: it means ownership could not be PROVEN, and retrying an unprovable
state is the fail-open hole that lets two writers share one session.

Duplicate delivery was investigated and does not occur: a capacity refusal is
issued at lease acquisition, before any turn runs. Verified live -- a refused
concurrent send wrote 0 rows to the recipient's state.db. A timeout after
acceptance exits through `except subprocess.TimeoutExpired` and never reaches
the retry path.

Tests: 23 pass. Reverting either fix fails exactly the two tests that guard
it (mutation-verified).
Each retry attempt carries its own delivery timeout, so the retry ladder had
no overall ceiling: a recipient whose turns HANG rather than refuse could
stack 4x600s of timeout plus 210s of backoff -- ~44 minutes inside a single
delivery, holding a cron worker the whole time.

Measured live on the real CLI: a capacity refusal returns in ~3.4s (a
successful delivery ~9.8s), so the realistic ladder costs ~4 minutes and is
unaffected. The ceiling exists for the pathological case only.

cron.bot_chat_busy_budget_seconds (default 900) is checked BEFORE starting
another sleep, so it never truncates a delivery that is progressing -- it
only declines to sleep into time it cannot honour. 0 disables it.

Tests: 25 pass. Removing the guard fails exactly the budget test; a fast
ladder at the measured 3.4s latency still retries and delivers.
The busy-retry backoff slept up to 210s in one uninterruptible time.sleep.
Gateway stop marks in-flight cron executions interrupted and then drains for
agent.cron_drain_timeout (default 30s), so a worker merely WAITING to retry
was still sleeping when the drain gave up -- SIGKILLed mid-wait and left
wedged instead of cleanly interrupted. The retry that exists to save a
delivery could therefore cost a clean shutdown.

Sleep in <=1s slices and poll cron.scheduler._is_interrupted (the existing
non-destructive peek used elsewhere before deciding what to deliver), keyed by
the execution's fire token so a recurring job's later run is not poisoned.
Verified live: a 120s wait aborts in 0.00s once the flag is set, well inside
the 30s drain.

Two review-process notes, both self-inflicted and fixed here:
- I first called a helper that does not exist (_is_job_interrupted) behind a
  bare `except Exception: pass`, which would have swallowed the mistake
  forever and silently disabled the guard. The except is gone; the real name
  is _is_interrupted.
- Tests stubbing time.sleep as a no-op busy-spun against the real clock once
  the sleep became sliced. They now patch the _sleep_unless_interrupted seam,
  which is what the retry path actually calls.

Tests: 27 pass in ~6s. Removing the interrupt check makes the shutdown test
hang until the runner is killed (exit 124) -- the wedged-worker bug reproduced.
Independent review (different model family) returned BLOCK on ca44cc4 and
was correct. The budget was checked only before SLEEPING, so the next attempt
still started with a full per-attempt timeout regardless of time remaining.
Reproduced: two attempts at 600s each against a 900s budget = 1230s elapsed,
a 330s overshoot of the ceiling the config advertises.

The budget is now a deadline. Each attempt's timeout is clamped to the time
actually left, and a TimeoutExpired caused by that clamp is reported as budget
exhaustion rather than as the recipient hanging for the full configured
timeout -- the two are different failures and must not be conflated.

The more serious finding was the test, not the code: the previous budget test
asserted only `attempts < 4`, which passed while elapsed time was 1230s. It
tested a proxy instead of the invariant. It now asserts elapsed <= budget and
that a later attempt is clamped, and a second test pins the budget-vs-hang
error distinction. Reverting the clamp fails both.

Tests: 28 pass.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cron Cron scheduler and job management sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Sep 10, 2026
@alt-glitch

Copy link
Copy Markdown

This was generated by AI during triage.

Related: #102659 (open) also retries cron bot-chat delivery on live-owner refusals via the relay turn lock, and #100319 (open) queues delivery for CLI-only owners. This PR instead classifies the typed hermes-refusal-reason: marker and retries only capacity refusals (SESSION_NOT_OWNED / MAX_CONCURRENT_SESSIONS) with shutdown-aware backoff. Overlapping fixes for the same drop; reviewers should compare.

This branch has not been deployed

No deployments
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 P2 Medium — degraded but workaround exists sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants