fix(plugins): don't drop healthy concurrent fail-open hook invocations - #98385
chelsealong wants to merge 2 commits into
Conversation
invoke_hook conflated two different states under one `running` flag: a callback that actually exceeded plugins.hook_callback_timeout, and a healthy callback from an earlier invocation still executing within its timeout. Both were skipped identically, so a second concurrent event for a fail-open observer hook (e.g. post_tool_call) was silently dropped even though the first callback completed well within budget. Keep pre_tool_call failing closed on any overlap, and keep a confirmed timeout (tracked via _hook_timeout_suppressed_until) from spawning unbounded duplicate workers. But let other bounded hooks run a second, concurrent worker when the only reason to skip was that "something is still running" and it hasn't actually timed out.
ReviewThe fix correctly separates a confirmed timeout from a healthy overlap, and the mutation check is strong evidence for the original regression. One follow-up boundary remains: allowing every bounded fail-open hook to start another worker removes silent loss, but without a per-callback concurrency/queue limit it can turn a burst of events into unbounded worker growth when callback duration exceeds arrival rate. Please make the post-fix policy explicit: 1. Define a bounded maximum of concurrent workers or queued events per callback/hook, with an observable overflow result and counters. Regression matrixPlease cover: Direct evidence: PR #98385, including the |
Follow-up to NousResearch#98382/NousResearch#98385 review: removing the drop-on-overlap behavior for fail-open hooks made per-callback concurrency unbounded. Add plugins.hook_callback_max_concurrency (default 4) to cap in-flight workers per (hook, callback); invocations past the cap are dropped and tracked in a separate overflow counter so queue saturation is never conflated with a confirmed timeout. pre_tool_call is unaffected since it already fails closed on any overlap.
|
Addressed the concrete, boundable part of the follow-up: added Left out of scope for this PR: item 4 (drain-on-shutdown / cancellation reporting). Implementing a bounded drain would require joining the abandoned worker threads this file deliberately never joins, to avoid reintroducing the #6622 hang — that's a real conflict with an existing invariant here, not something I want to guess a resolution for without maintainer input, so I didn't add code for it. |
|
Thanks @chelsealong — the symptom this fixes (healthy concurrent invocations of the same fail-open hook being dropped by the per-callback running gate) is now resolved on main by #111177 ( |
Fixes #98382
Problem
PluginManager.invoke_hookused a singlerunningflag(
callback_key in self._hook_running_callbacks) to mean two differentthings: "this callback's worker exceeded
plugins.hook_callback_timeoutand is still running" and "a healthy invocation of this callback from a
moment ago hasn't returned yet." Both states hit the same skip branch:
For fail-open observer hooks (everything except
pre_tool_call), thismeant a second concurrent event was silently dropped even when the first
callback was well within its timeout — e.g. two
post_tool_callevents0.3s apart, callback duration ~0.6s, default timeout 30s: no timeout ever
occurred, yet the second invocation was skipped and logged as "skipped
after previous timeout or while still running." Reported as 376 dropped
callback events in two days on a profile with overlapping cron sessions,
with the affected callbacks being evidence/audit recorders — so this
silently loses telemetry.
Fix
Split the two states apart in
hermes_cli/plugins.py::PluginManager.invoke_hook:timed_out— a confirmed timeout (from_hook_timeout_suppressed_until,set only when a caller's
done.wait(timeout)actually elapsed) — alwaysskips, for every hook. This is what keeps a genuinely hung worker from
spawning unbounded duplicates.
runningalone (worker still executing, no confirmed timeout) — onlyskips for
pre_tool_call, which must keep failing closed on any overlap.For other bounded hooks, a second invocation now starts its own
concurrent worker instead of being dropped.
pre_tool_call's fail-closed behavior is unchanged (still covered bytest_pre_tool_call_timeout_fail_closed), and the existing hung-callbacksuppression test (
test_hung_callback_suppresses_repeat_fires) stillpasses because it exercises the confirmed-timeout path, not mere overlap.
Test
Added
test_concurrent_healthy_invocations_both_processedintests/hermes_cli/test_plugins.py: registers apost_tool_callcallbackthat sleeps 0.3s, invokes the hook from two threads 0.15s apart with a
5s timeout budget, and asserts both invocations actually ran and both
results came back.
Confirmed it fails without the fix (
git checkout HEAD~1 -- hermes_cli/plugins.py):With the fix, full suite passes via the mandated hermetic runner:
ruff check hermes_cli/plugins.py tests/hermes_cli/test_plugins.py— all checks passed.AI assistance disclosure
This change was authored by an AI coding agent (Claude), including the
diagnosis, fix, and regression test, with the test-failure-without-fix
verification shown above run by the same agent.