Skip to content

fix(cli): bypass _invalidate() throttle for approval panel render (#41098)#41166

Closed
Elshayib wants to merge 1 commit into
NousResearch:mainfrom
Elshayib:fix/approval-invalidate-bypass-41098
Closed

fix(cli): bypass _invalidate() throttle for approval panel render (#41098)#41166
Elshayib wants to merge 1 commit into
NousResearch:mainfrom
Elshayib:fix/approval-invalidate-bypass-41098

Conversation

@Elshayib

@Elshayib Elshayib commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Summary

The dangerous-command approval prompt never renders in classic CLI mode. The callback goes through the 250ms _invalidate() throttle, which silently drops the redraw when any other UI event triggered an invalidation within the previous 250ms. The approval panel never appears, and after 60s the command is silently denied -- making approvals.mode: manual non-functional.

Root Cause

_approval_callback() called self._invalidate() for the initial render. The throttle (min_interval=0.25) meant that if a spinner frame, output flush, or completion event had triggered an invalidation within 250ms prior, the approval's invalidation was silently dropped. The ConditionalContainer wrapping the approval widget never re-evaluated, so the panel never appeared.

The retry loop (every 5s) had the same problem -- not a throttle issue, but the user had no visual indicator between the initial drop and the 5s retry.

Fix

  1. Bypass throttle in _approval_callback: Call app.invalidate() directly instead of self._invalidate(), matching the precedent set by _force_full_redraw() (line ~3511).
  2. Bypass throttle in retry loop: Same direct app.invalidate() call for the 5s retry.
  3. Terminal bell: Emit BEL character when the approval panel first appears, giving an audible signal that approval is pending.

Tests

9 new tests in tests/cli/test_approval_invalidate_bypass.py:

  • Bypass on initial render
  • Bypass in retry loop
  • Timeout bypass
  • Graceful handling when _app is None
  • Graceful handling when app.invalidate() raises
  • Terminal bell emission
  • State cleanup after response

All 9 pass. 11 existing approval UI tests + 203 approval tool tests: 0 regressions.

…usResearch#41098)

The approval callback's initial render was going through the 250ms
_invalidate() throttle, which silently dropped the redraw when any
other UI event (spinner, output flush) had triggered an invalidation
within the previous 250ms. This made the approval panel never appear,
causing commands to be silently denied after 60s timeout.

Fix: call app.invalidate() directly in _approval_callback (matching the
precedent set by _force_full_redraw), and add a terminal bell to alert
the user that approval is pending.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard labels Jun 7, 2026
@liuhao1024

Copy link
Copy Markdown
Contributor

✅ Verified — Approval panel invalidate throttle bypass

Reviewed the diff for the CLI approval modal rendering issue (#41098).

  • Root cause confirmed: _invalidate() has a 250ms throttle to prevent terminal flicker. When another UI event triggered an invalidation within the previous 250ms, the approval panel's _invalidate() call is silently dropped — the panel never renders, and the command is denied after 60s with no user interaction.
  • Fix correctness: Directly calling self._app.invalidate() bypasses the throttle, ensuring the approval modal renders immediately. The hasattr/try/except guard handles the case where _app is not yet initialized (e.g., during early startup).
  • Consistency: All three call sites (initial render, countdown refresh, timeout) are consistently updated to use the same bypass pattern.
  • Test coverage: The test suite covers entry bypass, countdown refresh bypass, and timeout flow. Uses a stub CLI object with mock _app.invalidate() to verify direct calls.

The fix is correct and the audible bell (\a) is a nice touch for headless/low-visibility terminals. No issues found.

kshitijk4poor added a commit to kshitijk4poor/hermes-agent that referenced this pull request Jun 7, 2026
…not via the throttle (NousResearch#41098)

In classic CLI mode the dangerous-command approval prompt (and the clarify,
sudo, and secret-capture prompts) could fail to render: the user saw
'⏱ Timeout — denying command' after 60s without ever seeing the panel,
making approvals.mode: manual unusable.

Root cause. These prompts run their wait loop on the agent/background thread:
they set modal state that a ConditionalContainer's filter reads, then call
self._invalidate() to repaint so the panel appears. _invalidate() is a
THROTTLED wrapper built for high-frequency background repaints (spinner frames,
streaming) — it (a) returns early while a SIGWINCH resize-recovery is pending,
and (b) otherwise only repaints if 250ms elapsed since the last paint. Under
either condition the modal's entry paint is silently dropped, the
ConditionalContainer never re-evaluates, and the prompt times out unseen.

The throttle never belonged on these paths. Originally the callbacks painted
with a direct self._app.invalidate() and worked; a throttle PR blanket-replaced
every invalidate (including these rare, one-shot, user-blocking modal paints)
with the throttled _invalidate(); a later commit removed an idle 1Hz repaint
that had been masking dropped modal paints, surfacing the bug. Notably the
modal KEY-BINDING handlers (↑/↓/Enter) already paint with a direct
event.app.invalidate(), never the throttle — the background-thread callbacks
were the inconsistent ones.

Fix. Add a small _paint_now() helper that paints directly (guarded for a
missing _app, exception-safe) and route the four modal paths' entry, response,
countdown, and teardown paints through it — matching the key-handler idiom.
This covers approval, clarify, sudo, and the secret-capture teardown
(_submit_secret_response, which previously used the throttled _invalidate() so
its panel could linger after submit). _invalidate() is left untouched and its
docstring now states it is for high-frequency background repaints only;
modal/interactive paints must use _paint_now()/_app.invalidate() directly. This
also fixes the resize-recovery edge case for free (a direct paint never
consults the resize guard) without a throttle-bypass flag that could be
cargo-culted onto hot paths. Countdown refresh cadence tightened 5s->1s so the
timer stays visible while waiting, and a copy-pasted duplicate countdown block
in _clarify_callback is removed.

Tests: TestModalPaintNow drives all three wait-loop callbacks on a background
thread with BOTH gates active (_resize_recovery_pending=True + a recent
_last_invalidate in the throttle window) and asserts the panel paints on entry
AND repaints on teardown; plus a secret-teardown test, a direct
_paint_now-vs-_invalidate gate test, and a no-_app safety test. Each modal test
fails if its paint is reverted to _invalidate(). 17 in-file tests pass; full
tests/cli suite green (900).

Diagnosis credit: the throttle-drop root cause was identified by @sanidhyasin
in NousResearch#41116; @islam666 independently reached the same direct-invalidate approach
in NousResearch#41166; original report NousResearch#41098 by @jodonnel.
@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Thanks @islam666 — you independently arrived at the same direct-app.invalidate() approach, which is exactly the right fix and validated the architecture. Superseding with #41155, which generalizes it: it routes all four background-thread modal prompts (approval + clarify + sudo + secret-capture teardown) through one small _paint_now() helper instead of inlining the guarded bypass per-site, leaves the _invalidate throttle untouched (docstring now documents the two-idiom rule), and omits the audible-bell side effect so the bell stays a separate UX decision. You're credited in the #41155 body and commit for independently reaching the direct-invalidate approach. 🙏

@Elshayib Elshayib deleted the fix/approval-invalidate-bypass-41098 branch June 7, 2026 19:49
a249169329-cpu pushed a commit to a249169329-cpu/hermes-agent that referenced this pull request Jun 9, 2026
…not via the throttle (NousResearch#41098)

In classic CLI mode the dangerous-command approval prompt (and the clarify,
sudo, and secret-capture prompts) could fail to render: the user saw
'⏱ Timeout — denying command' after 60s without ever seeing the panel,
making approvals.mode: manual unusable.

Root cause. These prompts run their wait loop on the agent/background thread:
they set modal state that a ConditionalContainer's filter reads, then call
self._invalidate() to repaint so the panel appears. _invalidate() is a
THROTTLED wrapper built for high-frequency background repaints (spinner frames,
streaming) — it (a) returns early while a SIGWINCH resize-recovery is pending,
and (b) otherwise only repaints if 250ms elapsed since the last paint. Under
either condition the modal's entry paint is silently dropped, the
ConditionalContainer never re-evaluates, and the prompt times out unseen.

The throttle never belonged on these paths. Originally the callbacks painted
with a direct self._app.invalidate() and worked; a throttle PR blanket-replaced
every invalidate (including these rare, one-shot, user-blocking modal paints)
with the throttled _invalidate(); a later commit removed an idle 1Hz repaint
that had been masking dropped modal paints, surfacing the bug. Notably the
modal KEY-BINDING handlers (↑/↓/Enter) already paint with a direct
event.app.invalidate(), never the throttle — the background-thread callbacks
were the inconsistent ones.

Fix. Add a small _paint_now() helper that paints directly (guarded for a
missing _app, exception-safe) and route the four modal paths' entry, response,
countdown, and teardown paints through it — matching the key-handler idiom.
This covers approval, clarify, sudo, and the secret-capture teardown
(_submit_secret_response, which previously used the throttled _invalidate() so
its panel could linger after submit). _invalidate() is left untouched and its
docstring now states it is for high-frequency background repaints only;
modal/interactive paints must use _paint_now()/_app.invalidate() directly. This
also fixes the resize-recovery edge case for free (a direct paint never
consults the resize guard) without a throttle-bypass flag that could be
cargo-culted onto hot paths. Countdown refresh cadence tightened 5s->1s so the
timer stays visible while waiting, and a copy-pasted duplicate countdown block
in _clarify_callback is removed.

Tests: TestModalPaintNow drives all three wait-loop callbacks on a background
thread with BOTH gates active (_resize_recovery_pending=True + a recent
_last_invalidate in the throttle window) and asserts the panel paints on entry
AND repaints on teardown; plus a secret-teardown test, a direct
_paint_now-vs-_invalidate gate test, and a no-_app safety test. Each modal test
fails if its paint is reverted to _invalidate(). 17 in-file tests pass; full
tests/cli suite green (900).

Diagnosis credit: the throttle-drop root cause was identified by @sanidhyasin
in NousResearch#41116; @islam666 independently reached the same direct-invalidate approach
in NousResearch#41166; original report NousResearch#41098 by @jodonnel.
changman pushed a commit to changman/hermes-agent that referenced this pull request Jun 10, 2026
…not via the throttle (NousResearch#41098)

In classic CLI mode the dangerous-command approval prompt (and the clarify,
sudo, and secret-capture prompts) could fail to render: the user saw
'⏱ Timeout — denying command' after 60s without ever seeing the panel,
making approvals.mode: manual unusable.

Root cause. These prompts run their wait loop on the agent/background thread:
they set modal state that a ConditionalContainer's filter reads, then call
self._invalidate() to repaint so the panel appears. _invalidate() is a
THROTTLED wrapper built for high-frequency background repaints (spinner frames,
streaming) — it (a) returns early while a SIGWINCH resize-recovery is pending,
and (b) otherwise only repaints if 250ms elapsed since the last paint. Under
either condition the modal's entry paint is silently dropped, the
ConditionalContainer never re-evaluates, and the prompt times out unseen.

The throttle never belonged on these paths. Originally the callbacks painted
with a direct self._app.invalidate() and worked; a throttle PR blanket-replaced
every invalidate (including these rare, one-shot, user-blocking modal paints)
with the throttled _invalidate(); a later commit removed an idle 1Hz repaint
that had been masking dropped modal paints, surfacing the bug. Notably the
modal KEY-BINDING handlers (↑/↓/Enter) already paint with a direct
event.app.invalidate(), never the throttle — the background-thread callbacks
were the inconsistent ones.

Fix. Add a small _paint_now() helper that paints directly (guarded for a
missing _app, exception-safe) and route the four modal paths' entry, response,
countdown, and teardown paints through it — matching the key-handler idiom.
This covers approval, clarify, sudo, and the secret-capture teardown
(_submit_secret_response, which previously used the throttled _invalidate() so
its panel could linger after submit). _invalidate() is left untouched and its
docstring now states it is for high-frequency background repaints only;
modal/interactive paints must use _paint_now()/_app.invalidate() directly. This
also fixes the resize-recovery edge case for free (a direct paint never
consults the resize guard) without a throttle-bypass flag that could be
cargo-culted onto hot paths. Countdown refresh cadence tightened 5s->1s so the
timer stays visible while waiting, and a copy-pasted duplicate countdown block
in _clarify_callback is removed.

Tests: TestModalPaintNow drives all three wait-loop callbacks on a background
thread with BOTH gates active (_resize_recovery_pending=True + a recent
_last_invalidate in the throttle window) and asserts the panel paints on entry
AND repaints on teardown; plus a secret-teardown test, a direct
_paint_now-vs-_invalidate gate test, and a no-_app safety test. Each modal test
fails if its paint is reverted to _invalidate(). 17 in-file tests pass; full
tests/cli suite green (900).

Diagnosis credit: the throttle-drop root cause was identified by @sanidhyasin
in NousResearch#41116; @islam666 independently reached the same direct-invalidate approach
in NousResearch#41166; original report NousResearch#41098 by @jodonnel.
alt-glitch pushed a commit that referenced this pull request Jun 14, 2026
…not via the throttle (#41098)

In classic CLI mode the dangerous-command approval prompt (and the clarify,
sudo, and secret-capture prompts) could fail to render: the user saw
'⏱ Timeout — denying command' after 60s without ever seeing the panel,
making approvals.mode: manual unusable.

Root cause. These prompts run their wait loop on the agent/background thread:
they set modal state that a ConditionalContainer's filter reads, then call
self._invalidate() to repaint so the panel appears. _invalidate() is a
THROTTLED wrapper built for high-frequency background repaints (spinner frames,
streaming) — it (a) returns early while a SIGWINCH resize-recovery is pending,
and (b) otherwise only repaints if 250ms elapsed since the last paint. Under
either condition the modal's entry paint is silently dropped, the
ConditionalContainer never re-evaluates, and the prompt times out unseen.

The throttle never belonged on these paths. Originally the callbacks painted
with a direct self._app.invalidate() and worked; a throttle PR blanket-replaced
every invalidate (including these rare, one-shot, user-blocking modal paints)
with the throttled _invalidate(); a later commit removed an idle 1Hz repaint
that had been masking dropped modal paints, surfacing the bug. Notably the
modal KEY-BINDING handlers (↑/↓/Enter) already paint with a direct
event.app.invalidate(), never the throttle — the background-thread callbacks
were the inconsistent ones.

Fix. Add a small _paint_now() helper that paints directly (guarded for a
missing _app, exception-safe) and route the four modal paths' entry, response,
countdown, and teardown paints through it — matching the key-handler idiom.
This covers approval, clarify, sudo, and the secret-capture teardown
(_submit_secret_response, which previously used the throttled _invalidate() so
its panel could linger after submit). _invalidate() is left untouched and its
docstring now states it is for high-frequency background repaints only;
modal/interactive paints must use _paint_now()/_app.invalidate() directly. This
also fixes the resize-recovery edge case for free (a direct paint never
consults the resize guard) without a throttle-bypass flag that could be
cargo-culted onto hot paths. Countdown refresh cadence tightened 5s->1s so the
timer stays visible while waiting, and a copy-pasted duplicate countdown block
in _clarify_callback is removed.

Tests: TestModalPaintNow drives all three wait-loop callbacks on a background
thread with BOTH gates active (_resize_recovery_pending=True + a recent
_last_invalidate in the throttle window) and asserts the panel paints on entry
AND repaints on teardown; plus a secret-teardown test, a direct
_paint_now-vs-_invalidate gate test, and a no-_app safety test. Each modal test
fails if its paint is reverted to _invalidate(). 17 in-file tests pass; full
tests/cli suite green (900).

Diagnosis credit: the throttle-drop root cause was identified by @sanidhyasin
in #41116; @islam666 independently reached the same direct-invalidate approach
in #41166; original report #41098 by @jodonnel.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants