diff --git a/cli.py b/cli.py index a43659c718..a5fe488d59 100644 --- a/cli.py +++ b/cli.py @@ -2837,7 +2837,7 @@ class HermesCLI(CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMix getattr(self, "_write_terminal_breadcrumb", lambda: None)() self._history_file = _hermes_home / ".hermes_history" - self._last_invalidate: float = 0.0 # throttles UI repaints + self._last_invalidate: float | None = None # throttles UI repaints (None = never; monotonic epoch is arbitrary) self._init_ui_state() def _init_session_store(self): diff --git a/hermes_cli/cli_terminal_mixin.py b/hermes_cli/cli_terminal_mixin.py index dcfaa60182..8fe300e5e0 100644 --- a/hermes_cli/cli_terminal_mixin.py +++ b/hermes_cli/cli_terminal_mixin.py @@ -90,7 +90,11 @@ class CLITerminalMixin: if getattr(self, "_terminal_io_broken", False) or getattr(self, "_resize_recovery_pending", False): return now = time.monotonic() - if hasattr(self, "_app") and self._app and (now - getattr(self, "_last_invalidate", 0.0)) >= min_interval: + # None sentinel, not 0.0 — monotonic's epoch is arbitrary (boot on Linux), so on + # a fresh VM ``now`` can be < min_interval and a 0.0 sentinel would swallow the + # first repaint (same class as _schedule_focus_regain_redraw below). + last = getattr(self, "_last_invalidate", None) + if hasattr(self, "_app") and self._app and (last is None or now - last >= min_interval): self._last_invalidate = now self._app_invalidate(self._app, "invalidate", swallow=False) @@ -138,7 +142,13 @@ class CLITerminalMixin: stacks on stale content (#60920, #25337); terminals without it never emit ``CSI I``. """ now = time.monotonic() - if now - getattr(self, "_last_focus_regain_redraw", 0.0) < min_interval: + # Sentinel is None, not 0.0: time.monotonic() counts from an arbitrary epoch + # (boot on Linux). On a fresh VM (CI runners, containers) ``now`` can be smaller + # than ``min_interval``, and ``now - 0.0 < min_interval`` would suppress the + # FIRST redraw ever requested (CI run 32494557030: uptime < 60s made the + # min_interval=60 rate-limit test fail both attempts). + last = getattr(self, "_last_focus_regain_redraw", None) + if last is not None and now - last < min_interval: return self._last_focus_regain_redraw = now self._force_full_redraw() diff --git a/tests/hermes_cli/test_cli_force_redraw.py b/tests/hermes_cli/test_cli_force_redraw.py index 81d1312f5f..849e82bc25 100644 --- a/tests/hermes_cli/test_cli_force_redraw.py +++ b/tests/hermes_cli/test_cli_force_redraw.py @@ -426,3 +426,21 @@ class TestFocusRegainRedraw: bare_cli._schedule_focus_regain_redraw(min_interval=0.0) assert calls == ["redraw", "redraw"] + + def test_first_redraw_fires_even_with_small_monotonic_clock( + self, bare_cli, monkeypatch + ): + """The first-ever redraw must fire regardless of monotonic's epoch. + + time.monotonic() starts from an arbitrary point (boot on Linux); on a + fresh CI VM it can be smaller than min_interval. The old 0.0 sentinel + turned that into ``now - 0.0 < min_interval`` and silently swallowed + the FIRST redraw (CI run 32494557030 — both retry attempts failed). + """ + calls = [] + bare_cli._force_full_redraw = lambda: calls.append("redraw") + monkeypatch.setattr(cli_mod.time, "monotonic", lambda: 3.0) + + bare_cli._schedule_focus_regain_redraw(min_interval=60.0) + + assert calls == ["redraw"]