Files
hermes-agent/tests/agent/test_future_timeout_worker_death.py
kshitijk4poor 68f7b90eeb fix: return a raced-successful compression from _await_worker_within_budget
The `future.done()` guard added for dead workers (#117261 / #63892)
unconditionally logged `future.exception()` and returned `(False, None)`.
If the worker finished SUCCESSFULLY in the window between
`result(timeout=)` expiring and the `done()` check, that discarded a
completed compression and sent the caller down the stall/fallback path —
`future.cancel()` becomes a no-op on a settled future and the fallback
route costs a second LLM call. Siblings `_await_in_flight_commit` and
tool_executor's `_poll_sequential_future` already re-read the result on
a settled future; do the same here: `exception() is None` → return
`(True, result)`, otherwise take the stall path as before.

The compression-seam test grows a settled-successful case through the
same helper (a Future subclass whose first timed `result()` still raises
TimeoutError, modelling the race); it fails on the previous guard and
passes with this one.
2026-09-21 21:34:44 +05:30

112 lines
5.1 KiB
Python

"""A worker that DIES raising a timeout-class error must not be mistaken for a live one.
On Python 3.11+ ``concurrent.futures.TimeoutError`` IS the builtin ``TimeoutError``, so a poll
loop's ``except concurrent.futures.TimeoutError`` cannot tell "wait slice expired" (worker alive)
from "worker raised TimeoutError" (worker dead). The aux client raises bare ``TimeoutError`` on
a stalled summary stream, so without a ``future.done()`` guard the host re-waited on a settled
future at ~2k iterations/sec until the idle budget burned (or forever, for the ceiling-less
commit wait). #117261 / #63892.
Budgets here are tiny so the red-on-base run FAILS fast instead of hanging. The commit-wait
loop has no ceiling, so on base it never returns; the runner's per-file timeout
(``scripts/run_tests.sh``, ``HERMES_TEST_FILE_TIMEOUT``) is what bounds that hang —
pytest-timeout is not a project dependency, so a ``pytest.mark.timeout`` marker would be inert.
"""
import concurrent.futures
import time
from types import SimpleNamespace
import pytest
from agent.conversation_compression import (
CompressionCommitFence,
_await_in_flight_commit,
_await_worker_within_budget,
_join_cancelled_worker,
)
from agent.tool_executor import _poll_sequential_future
def _settled_dead_future(pool, exc):
future = pool.submit(lambda: (_ for _ in ()).throw(exc))
concurrent.futures.wait([future], timeout=5)
assert future.done()
return future
def test_compression_wait_returns_promptly_when_worker_dies():
"""_await_worker_within_budget takes the stall path at once; the teardown join reports the
dead worker as exited (so its lease is released) rather than as an orphan."""
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = _settled_dead_future(pool, TimeoutError("aux stream stalled"))
fence = CompressionCommitFence()
fence.touch_progress()
started = time.monotonic()
settled, result = _await_worker_within_budget(
future, fence, idle=1.0, ceiling=2.0, wait_started=started
)
elapsed = time.monotonic() - started
# Pre-fix this hot-spun for the whole idle window (1.0s here) before returning.
assert elapsed < 0.5, f"host waited {elapsed:.2f}s on an already-dead worker"
assert (settled, result) == (False, None)
assert _join_cancelled_worker(future, 0.5) is True
# Success race: the worker finishes in the window between ``result(timeout=)`` expiring and
# the ``done()`` check. The guard must hand back the completed compression, not discard it
# onto the stall/fallback path (which would cost a second LLM call).
raced = _RacedSuccessFuture()
raced.set_result("summary")
fence = CompressionCommitFence()
fence.touch_progress()
started = time.monotonic()
assert _await_worker_within_budget(raced, fence, idle=1.0, ceiling=2.0, wait_started=started) == (
True,
"summary",
)
class _RacedSuccessFuture(concurrent.futures.Future):
"""Already-settled future whose FIRST timed ``result()`` still raises TimeoutError, modelling
a worker that completed just after the wait slice expired."""
def __init__(self):
super().__init__()
self._timed_out_once = False
def result(self, timeout=None):
if timeout is not None and not self._timed_out_once:
self._timed_out_once = True
raise concurrent.futures.TimeoutError()
return super().result(timeout)
def test_in_flight_commit_surfaces_worker_timeout_instead_of_looping():
"""_await_in_flight_commit has no ceiling on this path — pre-fix a dead worker spun forever."""
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = _settled_dead_future(pool, TimeoutError("commit stream stalled"))
started = time.monotonic()
with pytest.raises(TimeoutError, match="commit stream stalled"):
_await_in_flight_commit(future, ceiling=1.0, wait_started=started, on_commit_overrun=None)
elapsed = time.monotonic() - started
assert elapsed < 0.5, f"commit wait hung {elapsed:.2f}s on a dead worker"
def test_sequential_tool_poll_surfaces_worker_timeout_instead_of_looping():
"""_poll_sequential_future re-waits on the future after each slice; without the done() guard
a worker that died with TimeoutError kept it spinning until the deadline. With a 2s deadline
on base this returns ("timeout", None) after 2s — post-fix the worker's error propagates at
once."""
agent = SimpleNamespace(_interrupt_requested=False, _touch_activity=lambda msg: None)
gate = SimpleNamespace(excluded_seconds=lambda: 0.0)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = _settled_dead_future(pool, TimeoutError("tool stream stalled"))
started = time.monotonic()
with pytest.raises(TimeoutError, match="tool stream stalled"):
_poll_sequential_future(agent, future, "some_tool", deadline=started + 2.0, started=started, authorization_gate=gate)
elapsed = time.monotonic() - started
assert elapsed < 0.5, f"sequential poll hung {elapsed:.2f}s on a dead worker"