diff --git a/CHANGELOG.md b/CHANGELOG.md index 818004b2..3b6d0911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- Show an update notice when a newer Pythinker version becomes available mid-session. + ## 0.61.0 (2026-07-22) - Fixed reasoning summaries exposing Markdown delimiters and duplicate terminal rows after refocus, and redesigned agent progress (single `Agent` calls and parallel `RunAgents` fan-outs) as a compact, payload-free agent activity tree. diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 01054805..4fb6c877 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -69,6 +69,8 @@ from pythinker_code.ui.shell.slash import SKILL_COMMAND_PREFIX, shell_mode_registry from pythinker_code.ui.shell.slash import registry as shell_slash_registry from pythinker_code.ui.shell.update import ( + AUTO_UPDATE_CHECK_ATTEMPT_TIMEOUT_SECONDS, + AUTO_UPDATE_CHECK_INTERVAL_SECONDS, MANAGED_CHANNEL_MARKER, UpdateIntent, UpdateResult, @@ -142,6 +144,33 @@ class _PromptEvent: """Explicit skill/flow prefixes that should remain visible in transcript.""" +async def _periodic_update_check(check: Callable[[], Awaitable[None]]) -> None: + """Run an update check immediately and periodically until cancelled. + + The check bodies re-consult the shared on-disk throttle, so this loop can + never poll GitHub faster than the throttle allows across concurrent shells. + + Each attempt is bounded by a generous watchdog timeout so a single hung + check (a non-network stall the inner per-socket timeouts cannot catch) can + never block every future retry for the session; on timeout the loop logs it + distinctly and continues to the next interval. + """ + while True: + try: + async with asyncio.timeout(AUTO_UPDATE_CHECK_ATTEMPT_TIMEOUT_SECONDS): + await check() + except asyncio.CancelledError: + raise + except TimeoutError: + logger.warning( + "Periodic update check timed out after %ss; retrying next interval", + AUTO_UPDATE_CHECK_ATTEMPT_TIMEOUT_SECONDS, + ) + except Exception: + logger.exception("Periodic update check failed:") + await asyncio.sleep(AUTO_UPDATE_CHECK_INTERVAL_SECONDS) + + def _background_idle_reminder(active_running: int) -> str: """Build the system-reminder injected when background tasks finish while idle. @@ -597,6 +626,8 @@ def __init__( # short TTL so the hot toolbar render path does not stat the update # cache on every repaint (mirrors the footer's git-branch TTL). self._update_notice_cache: tuple[float, str | None] = (0.0, None) + self._update_toast_shown_version: str | None = None + self._update_toasts_shown: set[str] = set() self._running_input_handler: Callable[[UserInput], None] | None = None self._running_interrupt_handler: Callable[[], None] | None = None self._active_approval_sink: Any | None = None @@ -2135,12 +2166,18 @@ def _pop_next_pending_approval_request(self) -> ApprovalRequest | None: return None async def _auto_update(self) -> None: - # Background-refresh the cached latest version (throttled); never blocks startup. + # A target already cached at startup is covered by the welcome-banner chip. + before = welcome_update_target() await refresh_update_cache_if_due() - # The persistent under-input line renders the cached update hint; refresh - # it when the cache changes instead of duplicating the text as a toast. - if pending_update_notice(): - self._refresh_update_notice_line() + # pending_update_notice() has applied dismissal/skip suppression. + notice = pending_update_notice() + if not notice: + return + self._refresh_update_notice_line() + target = welcome_update_target() + if target and target != before and target != self._update_toast_shown_version: + self._update_toast_shown_version = target + self._update_toast(notice, style="fg:ansibrightyellow bold") async def _silent_auto_update(self) -> None: """Download and stage a newer release in the background at startup. @@ -2246,6 +2283,12 @@ def _managed_channel_notice(self) -> str | None: return format_managed_channel_notice(current_version, latest) def _update_toast(self, notice: str, *, style: str) -> None: + # The periodic check loop re-surfaces update outcomes every interval; + # dedupe by exact notice text so each is toasted once per session + # (a new version produces new text and toasts again). + if notice in self._update_toasts_shown: + return + self._update_toasts_shown.add(notice) toast(notice, topic="update", duration=30.0, immediate=True, style=style) if self._prompt_session is not None: self._prompt_session.invalidate() @@ -2292,7 +2335,7 @@ def _compute_update_notice(self) -> str | None: return text def _schedule_startup_update_task(self) -> None: - """Pick the startup update behavior and schedule it (non-blocking). + """Pick the update behavior and schedule its session-lifetime loop. - env kill-switch set → nothing (cache filters already suppress the notice, matching today's hard-disable behavior). @@ -2309,16 +2352,22 @@ def _schedule_startup_update_task(self) -> None: logger.info("Auto-update disabled by PYTHINKER_CLI_NO_AUTO_UPDATE environment variable") return if not isinstance(self.soul, PythinkerSoul): - self._start_background_task(self._auto_update()) + self._start_periodic_update_check(self._auto_update) return mode = resolve_auto_update_mode(self.soul.runtime.config) if mode is AutoUpdateMode.OFF: logger.info("Startup update task disabled by auto_update policy 'off'") return if mode is AutoUpdateMode.NOTIFY: - self._start_background_task(self._auto_update()) + self._start_periodic_update_check(self._auto_update) return - self._start_background_task(self._silent_auto_update()) + self._start_periodic_update_check(self._silent_auto_update) + + def _start_periodic_update_check(self, check: Callable[[], Awaitable[None]]) -> None: + task_coro = _periodic_update_check(check) + # Retain the concrete check name in task diagnostics and existing observers. + task_coro.__name__ = check.__name__ + self._start_background_task(task_coro) def _start_background_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]: task = asyncio.create_task(coro) diff --git a/src/pythinker_code/ui/shell/update.py b/src/pythinker_code/ui/shell/update.py index c708f00a..fcd68195 100644 --- a/src/pythinker_code/ui/shell/update.py +++ b/src/pythinker_code/ui/shell/update.py @@ -75,6 +75,15 @@ # that is a wide margin. A throttle miss fails safe: a transient error returns # FAILED, which skips the mark and retries next launch. AUTO_UPDATE_CHECK_INTERVAL_SECONDS = 30 * 60 +# Backstop watchdog for a single periodic check attempt. The check's own +# per-socket timeouts already abort network stalls (and the streamed installer +# download is deliberately not capped by a total timeout so a slow link still +# completes — see `_maybe_run_native_update`), so this only prevents a +# non-network hang (stuck subprocess, trickle-forever stream) from killing the +# session-lifetime loop. Set to 2× the interval so a genuinely slow silent +# download still finishes before the watchdog fires; worst-case dead-loop +# recovery is one timeout plus one interval. +AUTO_UPDATE_CHECK_ATTEMPT_TIMEOUT_SECONDS = 2 * AUTO_UPDATE_CHECK_INTERVAL_SECONDS PROMPT_UPDATE_REFRESH_TIMEOUT_SECONDS = 2.0 WINDOWS_UPDATE_STAGING_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 UPGRADE_COMMAND_TIMEOUT_SECONDS = 30 * 60 diff --git a/tests/ui_and_conv/test_mid_session_update_notice.py b/tests/ui_and_conv/test_mid_session_update_notice.py new file mode 100644 index 00000000..9274a80a --- /dev/null +++ b/tests/ui_and_conv/test_mid_session_update_notice.py @@ -0,0 +1,233 @@ +"""Mid-session update discovery: one-time toast + periodic re-check loop.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from types import SimpleNamespace + +import pytest +from pythinker_core.tooling.empty import EmptyToolset + +import pythinker_code.ui.shell as shell_module +from pythinker_code.soul.agent import Agent, Runtime +from pythinker_code.soul.context import Context +from pythinker_code.soul.pythinkersoul import PythinkerSoul + + +def _make_shell(runtime: Runtime, tmp_path: Path) -> shell_module.Shell: + agent = Agent( + name="Test Agent", + system_prompt="Test system prompt.", + toolset=EmptyToolset(), + runtime=runtime, + ) + soul = PythinkerSoul(agent, context=Context(file_backend=tmp_path / "history.jsonl")) + return shell_module.Shell(soul) + + +@pytest.fixture +def _toasts(monkeypatch): + captured: list[tuple[str, dict]] = [] + monkeypatch.setattr(shell_module, "toast", lambda msg, **kw: captured.append((msg, kw))) + return captured + + +def _mock_update_check(monkeypatch, targets: list[str | None], notice: str | None) -> None: + async def refresh() -> None: + return None + + target_values = iter(targets) + monkeypatch.setattr(shell_module, "refresh_update_cache_if_due", refresh) + monkeypatch.setattr(shell_module, "welcome_update_target", lambda: next(target_values)) + monkeypatch.setattr(shell_module, "pending_update_notice", lambda: notice) + + +@pytest.mark.asyncio +async def test_newly_discovered_update_toasts_once( + runtime: Runtime, tmp_path: Path, monkeypatch, _toasts +): + shell = _make_shell(runtime, tmp_path) + shell._prompt_session = SimpleNamespace(invalidate=lambda: None) # type: ignore[assignment] + _mock_update_check(monkeypatch, [None, "9.9.9"], "Update available: 9.9.9") + + await shell._auto_update() + + assert len(_toasts) == 1 + assert "9.9.9" in _toasts[0][0] + assert shell._update_toast_shown_version == "9.9.9" + + +@pytest.mark.asyncio +async def test_update_cached_before_refresh_does_not_toast( + runtime: Runtime, tmp_path: Path, monkeypatch, _toasts +): + shell = _make_shell(runtime, tmp_path) + _mock_update_check(monkeypatch, ["9.9.9", "9.9.9"], "Update available: 9.9.9") + + await shell._auto_update() + + assert _toasts == [] + + +@pytest.mark.asyncio +async def test_repeat_discovery_of_same_version_toasts_only_once( + runtime: Runtime, tmp_path: Path, monkeypatch, _toasts +): + shell = _make_shell(runtime, tmp_path) + _mock_update_check( + monkeypatch, + [None, "9.9.9", None, "9.9.9"], + "Update available: 9.9.9", + ) + + await shell._auto_update() + await shell._auto_update() + + assert len(_toasts) == 1 + + +@pytest.mark.asyncio +async def test_suppressed_notice_does_not_toast( + runtime: Runtime, tmp_path: Path, monkeypatch, _toasts +): + shell = _make_shell(runtime, tmp_path) + _mock_update_check(monkeypatch, [None, "9.9.9"], None) + + await shell._auto_update() + + assert _toasts == [] + assert shell._update_toast_shown_version is None + + +def test_update_toast_dedupes_repeated_notices(runtime: Runtime, tmp_path: Path, _toasts): + """The periodic loop re-surfaces outcomes; each notice text toasts once.""" + shell = _make_shell(runtime, tmp_path) + + shell._update_toast("Update available: 9.9.9", style="bold") + shell._update_toast("Update available: 9.9.9", style="bold") + shell._update_toast("Update available: 10.0.0", style="bold") + + assert [msg for msg, _ in _toasts] == [ + "Update available: 9.9.9", + "Update available: 10.0.0", + ] + + +@pytest.mark.asyncio +async def test_periodic_update_check_runs_immediately_then_at_interval(monkeypatch): + checks: list[str] = [] + sleeps: list[float] = [] + + async def check() -> None: + checks.append("check") + + async def sleep(delay: float) -> None: + sleeps.append(delay) + if len(sleeps) == 2: + raise asyncio.CancelledError + + monkeypatch.setattr(shell_module.asyncio, "sleep", sleep) + + with pytest.raises(asyncio.CancelledError): + await shell_module._periodic_update_check(check) + + assert checks == ["check", "check"] + assert sleeps == [ + shell_module.AUTO_UPDATE_CHECK_INTERVAL_SECONDS, + shell_module.AUTO_UPDATE_CHECK_INTERVAL_SECONDS, + ] + + +@pytest.mark.asyncio +async def test_periodic_update_check_recovers_after_failing_check(monkeypatch): + checks: list[str] = [] + sleeps: list[float] = [] + + async def check() -> None: + checks.append("check") + if len(checks) == 1: + raise RuntimeError("transient check failure") + + async def sleep(delay: float) -> None: + sleeps.append(delay) + if len(sleeps) == 2: + raise asyncio.CancelledError + + monkeypatch.setattr(shell_module.asyncio, "sleep", sleep) + + with pytest.raises(asyncio.CancelledError): + await shell_module._periodic_update_check(check) + + # The first failure is swallowed (logged) and the loop keeps re-checking. + assert checks == ["check", "check"] + assert sleeps == [ + shell_module.AUTO_UPDATE_CHECK_INTERVAL_SECONDS, + shell_module.AUTO_UPDATE_CHECK_INTERVAL_SECONDS, + ] + + +@pytest.mark.asyncio +async def test_periodic_update_check_recovers_after_timed_out_check(monkeypatch): + checks: list[str] = [] + sleeps: list[float] = [] + + async def check() -> None: + checks.append("check") + if len(checks) == 1: + await asyncio.Event().wait() # hang until the watchdog fires + + async def sleep(delay: float) -> None: + sleeps.append(delay) + if len(sleeps) == 2: + raise asyncio.CancelledError + + # asyncio.timeout drives off the event loop clock, not asyncio.sleep, so a + # tiny real timeout fires while the patched sleep only records intervals. + monkeypatch.setattr(shell_module.asyncio, "sleep", sleep) + monkeypatch.setattr(shell_module, "AUTO_UPDATE_CHECK_ATTEMPT_TIMEOUT_SECONDS", 0.01) + + with pytest.raises(asyncio.CancelledError): + await shell_module._periodic_update_check(check) + + # The hung first attempt is bounded by the watchdog; the loop logs the + # timeout and continues to the next interval instead of dying. + assert checks == ["check", "check"] + assert sleeps == [ + shell_module.AUTO_UPDATE_CHECK_INTERVAL_SECONDS, + shell_module.AUTO_UPDATE_CHECK_INTERVAL_SECONDS, + ] + + +def test_startup_scheduler_uses_periodic_loop_in_all_scheduling_branches( + runtime: Runtime, tmp_path: Path, monkeypatch +): + from pythinker_code.config import AutoUpdateMode + + shell = _make_shell(runtime, tmp_path) + scheduled_names: list[str] = [] + + def capture(coro): + # The scheduler wraps each check in the periodic loop but deliberately + # retains the concrete check's __name__ for task diagnostics/observers + # (see `_start_periodic_update_check`). Assert on that documented name + # at the scheduling seam instead of mocking the private loop wiring; + # fully removing the seam would require exercising real update I/O. + scheduled_names.append(coro.__name__) + coro.close() + return None + + monkeypatch.delenv("PYTHINKER_CLI_NO_AUTO_UPDATE", raising=False) + monkeypatch.setattr(shell, "_start_background_task", capture) + + with monkeypatch.context() as context: + context.setattr(shell_module, "PythinkerSoul", type("OtherSoul", (), {})) + shell._schedule_startup_update_task() + monkeypatch.setattr(shell_module, "resolve_auto_update_mode", lambda cfg: AutoUpdateMode.NOTIFY) + shell._schedule_startup_update_task() + monkeypatch.setattr( + shell_module, "resolve_auto_update_mode", lambda cfg: AutoUpdateMode.DOWNLOAD + ) + shell._schedule_startup_update_task() + + assert scheduled_names == ["_auto_update", "_auto_update", "_silent_auto_update"]