fix(plugins): gate hook callbacks by call identity, not by tool name alone - #110470
deadczarvc wants to merge 1 commit into
Conversation
…alone Concurrent invocations of the same tool in one session collapsed into a single busy key (hook_name, id(cb)): the second invocation was reported as 'still running' and dropped. For pre_tool_call a drop is a fail-closed block, so the gate silenced itself on an ordinary, healthy callback. Measured on a busy profile: 3574 skip lines and 0 timeout lines in one hour — every skip was the 'while still running' branch, i.e. pure key collision, not slowness. The gate now keys on the call identity that is already in the payload (tool_call_id, else turn_id, else none — the last case behaves exactly as before). Suppression stays keyed coarsely on (hook_name, id(cb)): a hung callback is a fact about the callback, so its back-off must not be diluted per call. Refs NousResearch#98382. Independent of NousResearch#107894 (that one releases the slot on timeout; this one stops healthy concurrency from colliding).
Related: competing fixes for #98382 with different mechanisms — #98385 (per-invocation workers), #107894 (timeout slot release), #109441 (per-session single-flight), #103119 (issue author). This PR keys the busy gate by call identity ( |
|
Follow-up on the same latch, one layer deeper: the gate's key is still
Proposed: key by the callback's registration slot (the dispatch loop already walks the list) + diff --git a/hermes_cli/plugins_dispatch.py b/hermes_cli/plugins_dispatch.py
index aca55c5e..9423cf49 100644
--- a/hermes_cli/plugins_dispatch.py
+++ b/hermes_cli/plugins_dispatch.py
@@ -187,10 +187,10 @@ class PluginDispatchMixin:
timeout = _resolve_hook_callback_timeout()
use_timeout = _hook_uses_callback_timeout(hook_name, timeout)
fail_closed = hook_name in _HOOK_TIMEOUT_FAIL_CLOSED_HOOKS
- for cb in self._hooks.get(hook_name, []):
+ for slot, cb in enumerate(self._hooks.get(hook_name, [])):
try:
if use_timeout:
- ret = self._run_hook_callback_bounded(hook_name, cb, kwargs, timeout)
+ ret = self._run_hook_callback_bounded(hook_name, cb, kwargs, timeout, slot)
if ret is _HOOK_SKIPPED:
if fail_closed: # policy hook: fail closed with a block directive
results.append({"action": "block", "message": _PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE})
@@ -205,13 +205,19 @@ class PluginDispatchMixin:
return results
def _run_hook_callback_bounded(
- self, hook_name: str, cb: Callable, kwargs: Dict[str, Any], timeout: float
+ self, hook_name: str, cb: Callable, kwargs: Dict[str, Any], timeout: float,
+ slot: int = -1,
) -> Any:
"""Run one callback on a daemon worker with a wall-clock cap; ``_HOOK_SKIPPED`` when
suppressed, still running, timed out (worker abandoned, never joined), or the worker
could not be started. Exceptions propagate."""
callback_name = getattr(cb, "__name__", repr(cb))
- callback_key = (hook_name, id(cb))
+ # Key by the callback registration slot + name, never by id(cb): a CPython address is
+ # reused after the object is collected, so a fresh callback can inherit a dead one
+ # latch (suppression window / in-flight token) and be skipped for reasons that
+ # belonged to its predecessor. Slots are stable under append (plugins register by
+ # appending); the name disambiguates a slot shifted by a removal.
+ callback_key = (hook_name, slot, callback_name)
token = object()
with self._hook_timeout_lock:
suppressed_until = self._hook_timeout_suppressed_until.get(callback_key)
|
|
Correction to my own follow-up above, after re-reading the code. I overstated one part. The gate key is no longer Narrower proposal, consistent with the "suppression stays coarse" invariant: key The reuse concern itself is verified, not theoretical: |
1 similar comment
|
Thanks @deadczarvc — this landed on main via #111177 (rebase-merged, your commit cherry-picked with authorship intact; head |
Problem
_run_hook_callback_boundedkeyed its busy gate on(hook_name, id(cb))only. Two concurrent invocations of the same tool in the same session therefore collapsed into one key: the second invocation sawrunningand returned_HOOK_SKIPPED— as if a callback had timed out. Forpre_tool_calla skip is not observational, it is a fail-closed block, so the tool call itself never ran. The gate silenced itself on an ordinary, healthy callback.This is the concurrency half of #98382 ("Concurrent observer-hook invocations are dropped as if a callback had timed out"). It is independent of #107894, which releases the slot on timeout; this PR stops healthy concurrency from colliding in the first place.
Evidence (one busy profile, 2026-09-14)
3574skipped after previous timeout or while still runninglines and0timed out afterlines in one hour — every skip came from thewhile still runningbranch, i.e. pure key collision, not slowness.0.76 stotal for the wholepre_tool_callchain at idle,max 0.26 sunder 6 concurrent invocations, zero hooks above threshold.id(cb)cannot discriminate: for shell hooks the dispatcher holds one closure per (event, matcher, command) for the life of the process, soid(cb)is constant.Change
(hook_name, id(cb), <call identity>), where the call identity istool_call_id, elseturn_id, elseNone. All three are already in the payload the dispatcher receives — nothing new is plumbed, no new dependency.(hook_name, id(cb)): a hung callback is a fact about the callback, so a timeout back-off must not be diluted to a single call, or a wedged hook would be restarted by every new call.api_request_idis deliberately not used: one API request carries many tool calls, which would re-collapse the keys.Tests
tests/hermes_cli/test_plugins.py: two invariant tests, both proven to fail on the base revision.test_concurrent_same_tool_calls_with_distinct_ids_both_run— two concurrentpre_tool_callinvocations of the same tool with distincttool_call_idmust both run (base: the second is dropped).test_repeated_same_call_identity_still_deduplicated— negative control: a repeat of the same call identity is still deduplicated, so a hung worker is never restarted by a repeat of the very same call.Notes
id(cb)remains in the key as a callback discriminator. Replacing it with a registration handle would be strictly better and is a separate change; it is not required to fix this collision.