diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dff2411..723ae5d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ ### Fixed +- **The Experimental update channel now shows the real installed version instead of "unknown" / a bare SHA on a stable-tagged install.** When an install pinned to a stable `v*` tag opts into the Experimental channel, no `exp-v*` tag sits behind HEAD, so the channel-scoped version lookup returned nothing and the UI rendered "unknown" (and a bogus ahead-count of 1). The check now recovers the real installed version and the true number of Experimental releases ahead. The installed-version fallback is scoped to the WebUI repo (never injected into the Agent repo's payload), and the compare-link ref is always a git-verified tag (or omitted) rather than a possibly-dirty version string. Thanks @nesquena-hermes. (#5864, #5862) + - **The activity worklog no longer freezes the browser on reasoning-heavy history (#4633 frontend half).** A settled turn's activity worklog (the reasoning + tool steps behind the "Processed Xm Ys" chip) can carry 80+ rows; in compact-worklog mode (the default) every collapsed group still eagerly built all its row DOM, so a long history ballooned the DOM and a later synchronous layout could tip the tab into a multi-GB freeze. Collapsed settled worklogs now defer their row DOM until first expand. Expanded rows still get full post-processing (syntax highlighting, copy buttons, Mermaid, KaTeX, structured trees) and their open/scroll state is preserved across transcript rebuilds — matching (and, for nested-card scroll, improving on) the eager path. Thanks @nesquena-hermes. (#5860, #5839, #4633) - **Server memory growth on reasoning-heavy sessions is fixed (#4633 backend root cause).** Reasoning-heavy sessions accumulate 250–480 KB of `anchor_activity_scenes` that were serialized *before* the `messages` array in the session sidecar, so the cheap 64 KB metadata-prefix read always overflowed and fell back to a full parse on every read — driving memory growth. The scene data is now serialized *after* `messages`, so the metadata prefix stays small (≈2 KB) and modern reads need no full parse. Existing session files in the old layout still load correctly (one authoritative parse, then cached), and round-trips preserve scene content exactly. Thanks @nesquena-hermes. (#5858, #5854, #4633) diff --git a/api/updates.py b/api/updates.py index 048925c5..b5698b5d 100644 --- a/api/updates.py +++ b/api/updates.py @@ -716,8 +716,17 @@ def channel_version_badge(channel=None) -> str: if channel is None: channel = _read_update_channel() channel = _normalize_channel(channel) + # NOTE: no ``--always`` here (deliberately different from _detect_webui_version). + # The current version is channel-INDEPENDENT — it's just what's installed. The + # channel only picks which tag family we compare AGAINST for updates. On a + # stable-tagged install (e.g. HEAD == v0.52.0) that opts into Experimental, no + # ``exp-v*`` tag is reachable BEHIND HEAD (the exp tags sit ahead on master), so + # ``--always`` would fall through to a bare SHA and render "WebUI: d4e80b45 · + # Experimental" instead of the real installed version. Falling back to the + # channel-neutral WEBUI_VERSION keeps the badge showing "v0.52.0 · Experimental". + # (#5862) out, ok = _run_git( - ['describe', '--tags', '--always', '--match', _channel_tag_glob(channel)], + ['describe', '--tags', '--match', _channel_tag_glob(channel)], REPO_ROOT, ) if ok and out: @@ -758,6 +767,26 @@ def _release_gap(tags, current, latest): return 1 +def _count_channel_tags_ahead(path, channel=DEFAULT_UPDATE_CHANNEL): + """Count channel release tags strictly ahead of HEAD (fast-forwardable). + + Used only when NO channel tag is reachable behind HEAD — the channel-scoped + ``describe`` returned None — e.g. a stable ``v0.52.0`` install opting into + Experimental (all ``exp-v*`` tags sit ahead on master). ``_release_gap`` can't + position HEAD in the tag list then and returns a bogus 1. ``git tag --contains + HEAD`` lists tags whose history includes HEAD, i.e. tags that are ahead of (or + on) HEAD; since HEAD carries no channel tag in this path, that count is exactly + the number of channel releases the install can fast-forward to. (#5862) + """ + out, ok = _run_git( + ['tag', '--list', _channel_tag_glob(channel), '--contains', 'HEAD'], + path, + ) + if not (ok and out): + return 0 + return sum(1 for line in out.splitlines() if line.strip()) + + def _head_is_past_latest_tag(path, current_tag, channel=DEFAULT_UPDATE_CHANNEL): """Return True when HEAD has moved past the latest reachable channel tag. @@ -901,6 +930,42 @@ def _check_repo_release(path, name, channel=DEFAULT_UPDATE_CHANNEL): current_tag = _current_release_tag(path, channel) behind = _release_gap(tags, current_tag, latest_tag) + # When NO channel tag is reachable behind HEAD, _current_release_tag returns + # None (channel-scoped `describe --abbrev=0` fatals with "No tags can describe"). + # This is the normal state of a stable-tagged install (HEAD == v0.52.0) opting + # into Experimental: every exp-v* tag sits AHEAD on master. _release_gap can't + # position None in the tag list and returns a bogus 1, and the display fields + # would carry current_version=None (rendered as "unknown"). Recover the real + # ahead-count and show the channel-neutral installed version as the current + # version — the channel only chooses the comparison tag family, not what's + # installed. (#5862) + current_version_display = current_tag + # A git-verified ref for the compare link (defaults to the resolved channel + # tag; may be refined below in the no-channel-tag-behind-HEAD fallback). + current_sha_ref = current_tag + if current_tag is None: + ahead = _count_channel_tags_ahead(path, channel) + if ahead > 0: + behind = ahead + # Scope the installed-version fallback to the WebUI repo only. + # _check_repo_release() is shared with the Agent repo, and WEBUI_VERSION + # (e.g. v0.52.0) is not a valid ref/tag in the Agent repository — injecting + # it there would display the WebUI version as the Agent's installed version + # and produce a broken Agent compare link. (#5864) + if name == "webui": + current_version_display = WEBUI_VERSION + # For the compare link, derive a git-VERIFIED installed tag rather than + # reusing WEBUI_VERSION (which can be `vX.Y.Z-dirty-`, `-N-g`, + # a bare SHA, or `unknown` — none guaranteed refs). Prefer the exact tag + # on HEAD across ALL release families (channel-neutral), so a stable- + # pinned Experimental install still gets a resolvable /compare/... + # link; fall back to None (no link) when HEAD is not exactly on a tag. (#5864) + exact_tag, ok = _run_git( + ['describe', '--tags', '--exact-match', 'HEAD'], path + ) + exact_tag = (exact_tag or '').strip() + current_sha_ref = exact_tag if ok and exact_tag else None + # If behind == 0 but HEAD has moved past the tag (e.g. the agent repo # keeps committing to master between tagged releases), the release check # would report "Up to date" even though hundreds of commits are missing. @@ -944,13 +1009,18 @@ def _check_repo_release(path, name, channel=DEFAULT_UPDATE_CHANNEL): 'name': name, 'behind': behind, # GitHub compare URLs accept tag names, and tag-to-tag links are the - # clearest "what changed in this release?" view for operators. - 'current_sha': current_tag, + # clearest "what changed in this release?" view for operators. Use a + # git-VERIFIED ref for the compare link: the resolved channel tag when + # one is reachable behind HEAD, else None. WEBUI_VERSION is NOT safe here + # — it can be `v0.52.0-dirty-`, `v0.52.0-N-g`, a bare SHA, or + # `unknown`, none of which are guaranteed refs, so reusing it would emit + # a broken /compare link (ui.js) and lose update-summary commit subjects. (#5864) + 'current_sha': current_sha_ref, 'latest_sha': latest_tag, 'branch': latest_tag, 'repo_url': remote_url, 'release_based': True, - 'current_version': current_tag, + 'current_version': current_version_display, 'latest_version': latest_tag, 'channel': channel, } diff --git a/tests/test_update_channels.py b/tests/test_update_channels.py index 1af8f594..b5eb6c3c 100644 --- a/tests/test_update_channels.py +++ b/tests/test_update_channels.py @@ -285,3 +285,117 @@ def test_clear_lock_retry_preserves_experimental_channel(tmp_path, monkeypatch): ) assert result['ok'] is True assert result['lock_recovery']['action'] == 'no-lock-found' + + +# ── Stable-tagged install opting into Experimental (#5862) ─────────────────── +# +# b3nw's report: a plain stable install pinned on v0.52.0 that flips to the +# Experimental channel. Unlike `channel_repo` (which tags stable and exp on the +# SAME commits), here the stable tag PREDATES every exp-v* tag — so from HEAD == +# v0.52.0 there is NO exp-v* tag reachable behind HEAD. Both channel-scoped git +# lookups then degrade: the chip `describe --always` leaks a bare SHA, and the +# banner `describe --abbrev=0` fatals -> current_version=None -> "unknown". + +@pytest.fixture +def stable_pinned_repo(tmp_path): + """v0.52.0 tagged first (stable only), then 3 exp-v* batches AHEAD on master. + + HEAD is checked out ON v0.52.0, so no exp-v* tag is reachable behind HEAD. + """ + repo = tmp_path / 'stable_pinned' + repo.mkdir() + _git(repo, 'init', '-q') + _git(repo, 'config', 'user.email', 't@t.co') + _git(repo, 'config', 'user.name', 'Test') + _git(repo, 'remote', 'add', 'origin', 'https://github.com/nesquena/hermes-webui.git') + _git(repo, 'commit', '-q', '--allow-empty', '-m', 'v0.52.0 release') + _git(repo, 'tag', 'v0.52.0') # stable tag, NO exp tag on this commit + for i in range(1, 4): + _git(repo, 'commit', '-q', '--allow-empty', '-m', f'exp batch {i}') + _git(repo, 'tag', f'exp-v0.52.{i}') + _git(repo, 'checkout', '-q', 'v0.52.0') # pin HEAD on the stable release + return repo + + +def test_stable_pinned_current_release_tag_is_none_on_experimental(stable_pinned_repo): + """Precondition: the channel-scoped current-tag lookup genuinely returns None + (no exp-v* reachable behind a v0.52.0 HEAD). This is the trigger for #5862.""" + assert updates._current_release_tag(stable_pinned_repo, 'experimental') is None + # Stable still resolves the real tag. + assert updates._current_release_tag(stable_pinned_repo, 'stable') == 'v0.52.0' + + +def test_stable_pinned_experimental_reports_installed_version_not_unknown(stable_pinned_repo, monkeypatch): + """#5862: current_version must be the neutral installed tag (v0.52.0), NOT + None (which the UI renders as 'unknown'), and the count must be the real + number of exp releases ahead (3), NOT the bogus _release_gap fallback of 1.""" + monkeypatch.setattr(updates, 'WEBUI_VERSION', 'v0.52.0') + info = updates._check_repo_release(stable_pinned_repo, 'webui', 'experimental') + assert info is not None + assert info['current_version'] == 'v0.52.0', 'must not be None/"unknown"' + assert info['current_sha'] == 'v0.52.0' + assert info['latest_version'] == 'exp-v0.52.3' + assert info['behind'] == 3, 'all 3 exp releases are ahead, not a bogus 1' + + +def test_stable_pinned_stable_channel_still_up_to_date(stable_pinned_repo, monkeypatch): + """Sanity: the SAME install on the stable channel is up-to-date on v0.52.0 + (the exp-v* tags ahead must not be offered as stable updates).""" + monkeypatch.setattr(updates, 'WEBUI_VERSION', 'v0.52.0') + info = updates._check_repo_release(stable_pinned_repo, 'webui', 'stable') + assert info is not None + assert info['behind'] == 0 + assert info['current_version'] == 'v0.52.0' + + +def test_channel_version_badge_no_bare_sha_on_experimental(stable_pinned_repo, monkeypatch): + """#5862 chip: channel_version_badge must fall back to the neutral installed + version, never a bare git SHA, when no channel tag is reachable.""" + monkeypatch.setattr(updates, 'REPO_ROOT', stable_pinned_repo) + monkeypatch.setattr(updates, 'WEBUI_VERSION', 'v0.52.0') + badge = updates.channel_version_badge('experimental') + assert badge == 'v0.52.0', f'expected installed version, got bare SHA-ish {badge!r}' + # Stable channel resolves its own reachable tag directly. + assert updates.channel_version_badge('stable') == 'v0.52.0' + + +def test_count_channel_tags_ahead(stable_pinned_repo): + """The ahead-count helper: 3 exp-v* tags sit ahead of a v0.52.0 HEAD. + + This helper is only ever CALLED when no channel tag is reachable on/behind + HEAD (current_tag is None) — exactly the experimental case here, where no + exp-v* tag sits on the v0.52.0 commit, so `--contains HEAD` counts precisely + the 3 tags strictly ahead. + """ + _git(stable_pinned_repo, 'checkout', '-q', 'v0.52.0') + assert updates._count_channel_tags_ahead(stable_pinned_repo, 'experimental') == 3 + + +def test_stable_pinned_experimental_agent_repo_does_not_inject_webui_version(stable_pinned_repo, monkeypatch): + """#5864: the installed-version fallback is WebUI-only. _check_repo_release is + shared with the Agent repo, where WEBUI_VERSION (v0.52.0) is not a valid ref — + it must NOT be injected as the Agent's current_version/current_sha (that would + show the WebUI version as the Agent's and emit a broken Agent compare link).""" + monkeypatch.setattr(updates, 'WEBUI_VERSION', 'v0.52.0') + info = updates._check_repo_release(stable_pinned_repo, 'agent', 'experimental') + assert info is not None + # Agent must NOT carry the WebUI version. + assert info['current_version'] != 'v0.52.0' + assert info['current_sha'] != 'v0.52.0' + + +def test_stable_pinned_experimental_current_sha_is_verified_ref_only(stable_pinned_repo, monkeypatch): + """#5864: current_sha must be a git-VERIFIED ref, never a raw WEBUI_VERSION + that can be dirty/`-N-g`/bare-SHA/`unknown`. When HEAD is exactly on a + tag, current_sha resolves to that tag; when WEBUI_VERSION is a non-ref dirty + string, the compare ref falls back to None (no broken /compare link) while the + displayed version still shows the real installed string.""" + # HEAD is exactly on v0.52.0 → verified tag resolves for the compare link. + monkeypatch.setattr(updates, 'WEBUI_VERSION', 'v0.52.0-dirty-deadbeef') + info = updates._check_repo_release(stable_pinned_repo, 'webui', 'experimental') + assert info is not None + # current_version shows the real (dirty) installed string... + assert info['current_version'] == 'v0.52.0-dirty-deadbeef' + # ...but current_sha is the git-verified exact tag, NOT the dirty display string. + assert info['current_sha'] == 'v0.52.0' + assert 'dirty' not in (info['current_sha'] or '')