feat(updates): stable/experimental release channels
Add an update_channel setting (stable|experimental) that selects which git-tag
stream the self-updater tracks on the single linear master line:
stable -> 'v*' promoted, soaked releases (default; unchanged glob)
experimental -> 'exp-v*' every release batch (opt-in testers)
A channel is only a tag glob — no branches, no divergence — so every ff-only
guarantee (#2653/#2846/#3140) is preserved. Channel governs the WebUI repo only;
the Agent repo keeps its historical branch fall-through.
Correctness (advisor-reviewed, Codex + Fable):
- describe uses --match <glob> so a commit tagged both v* and exp-v* resolves
to the channel-correct tag
- stable never falls through to origin/master when HEAD contains the latest
stable tag (the firehose-suppression that makes channels work)
- apply_force_update refuses to reset --hard onto an ancestor ref (rewind guard)
- update cache + in-progress guard keyed by (channel, include_agent)
- channel display badge is a SEPARATE field; WEBUI_VERSION stays channel-neutral
(asset cache-busting / SW cache / stale-client skew all do exact-string equality)
Settings UX: channel dropdown with risk-forward copy + the ff-only asymmetry
helper ('switching back to Stable keeps your current version until Stable catches
up'), channel chip on the version badge, i18n keys, re-check on switch.
Tests: new tests/test_update_channels.py (12 real-git-fixture tests) + existing
update suite updated for the channel-aware signatures.
This commit is contained in:
1 parent
16272dd646
commit
2cf43527a3
8 files changed
+589
-80
No files matched your search
@@ -8183,6 +8183,7 @@ _SETTINGS_DEFAULTS = {
|
||||
"show_previous_messaging_sessions": False, # show older Telegram/Discord/etc. reset segments
|
||||
"sync_to_insights": False, # mirror WebUI token usage to state.db for /insights
|
||||
"check_for_updates": True, # check if webui/agent repos are behind upstream
|
||||
"update_channel": "stable", # stable | experimental — which release stream to track (stable = soaked/promoted; experimental = every batch)
|
||||
"ignore_agent_updates": False, # keep WebUI update notices but suppress Agent update checks
|
||||
"whats_new_summary_enabled": False, # show an LLM-written What's New summary before diff links
|
||||
"tts_enabled": False,
|
||||
@@ -8463,6 +8464,7 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {
|
||||
_SETTINGS_ENUM_VALUES = {
|
||||
"send_key": {"enter", "ctrl+enter", "shift+enter"},
|
||||
"sidebar_density": {"compact", "detailed"},
|
||||
"update_channel": {"stable", "experimental"},
|
||||
"font_size": {"small", "default", "large", "xlarge"},
|
||||
"auto_title_refresh_every": {"0", "5", "10", "20"},
|
||||
"default_message_mode": {"queue", "interrupt", "steer"},
|
||||
|
||||
+12
-1
@@ -11628,6 +11628,16 @@ def handle_get(handler, parsed) -> bool:
|
||||
settings["agent_version"] = AGENT_VERSION
|
||||
except Exception:
|
||||
pass
|
||||
# Channel-scoped display badge — SEPARATE from webui_version (which is
|
||||
# load-bearing for asset cache-busting / SW cache / skew detection and
|
||||
# must stay channel-neutral). update_channel_version is display-only.
|
||||
try:
|
||||
from api.updates import channel_version_badge, _read_update_channel
|
||||
channel = _read_update_channel()
|
||||
settings["update_channel"] = channel
|
||||
settings["update_channel_version"] = channel_version_badge(channel)
|
||||
except Exception:
|
||||
pass
|
||||
return j(handler, settings)
|
||||
|
||||
if parsed.path == "/api/transcribe/capability":
|
||||
@@ -13072,9 +13082,10 @@ def handle_post(handler, parsed) -> bool:
|
||||
return j(handler, {"disabled": True})
|
||||
include_agent_updates = not bool(settings.get("ignore_agent_updates"))
|
||||
force = bool(body.get("force", False))
|
||||
channel = settings.get("update_channel")
|
||||
from api.updates import check_for_updates
|
||||
|
||||
return j(handler, check_for_updates(force=force, include_agent=include_agent_updates))
|
||||
return j(handler, check_for_updates(force=force, include_agent=include_agent_updates, channel=channel))
|
||||
|
||||
if parsed.path == "/api/extensions/toggle":
|
||||
from api.extensions import ExtensionToggleError, set_extension_user_enabled
|
||||
|
||||
+273
-44
@@ -35,7 +35,7 @@ try:
|
||||
except ImportError:
|
||||
_AGENT_DIR = None
|
||||
|
||||
_update_cache = {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True}
|
||||
_update_cache = {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True, 'channel': 'stable'}
|
||||
_SUMMARY_CACHE_MAX = 16
|
||||
_summary_cache: OrderedDict = OrderedDict()
|
||||
_cache_lock = threading.Lock()
|
||||
@@ -648,17 +648,102 @@ def _detect_default_branch(path):
|
||||
return 'master'
|
||||
|
||||
|
||||
def _release_tags(path):
|
||||
"""Return release tags newest-first, using the repo's version-sort order."""
|
||||
out, ok = _run_git(['tag', '--list', 'v*', '--sort=-v:refname'], path)
|
||||
# ── Release channels ─────────────────────────────────────────────────────────
|
||||
# The self-updater tracks ONE of several release channels, selected in Settings
|
||||
# (``update_channel``). A channel is nothing more than *which glob of tags the
|
||||
# updater reads* on the single linear master line — no branches, no divergence,
|
||||
# so every hard-won ff-only guarantee (#2653/#2846/#3140) is preserved.
|
||||
#
|
||||
# stable -> 'v*' promoted, soaked releases (the default). Same glob
|
||||
# the updater has always used — every existing
|
||||
# v0.51.N tag matches, so legacy installs and the
|
||||
# full existing test suite keep working unchanged.
|
||||
# experimental -> 'exp-v*' every release batch, tagged for testers who opt in.
|
||||
#
|
||||
# ``exp-v*`` deliberately does NOT match ``v*`` (exp tags start with 'e', not
|
||||
# 'v'): the two channels never leak into each other's tag list, and a legacy
|
||||
# install running the historical 'v*' glob never matches an exp tag, so it
|
||||
# auto-lands on the stable stream with zero action.
|
||||
DEFAULT_UPDATE_CHANNEL = 'stable'
|
||||
_CHANNEL_TAG_GLOBS = {
|
||||
'stable': 'v*',
|
||||
'experimental': 'exp-v*',
|
||||
}
|
||||
|
||||
|
||||
def _normalize_channel(channel) -> str:
|
||||
"""Return a known channel name, defaulting to stable for anything unknown."""
|
||||
if isinstance(channel, str) and channel in _CHANNEL_TAG_GLOBS:
|
||||
return channel
|
||||
return DEFAULT_UPDATE_CHANNEL
|
||||
|
||||
|
||||
def _channel_tag_glob(channel) -> str:
|
||||
"""Return the ``git tag --list`` glob for the given channel."""
|
||||
return _CHANNEL_TAG_GLOBS[_normalize_channel(channel)]
|
||||
|
||||
|
||||
def _read_update_channel() -> str:
|
||||
"""Read the configured update channel from settings (stable fallback).
|
||||
|
||||
Read lazily at request time — never baked at import — so a channel switch in
|
||||
Settings takes effect on the next update check without a process restart.
|
||||
"""
|
||||
try:
|
||||
from api.config import load_settings
|
||||
return _normalize_channel(load_settings().get('update_channel'))
|
||||
except Exception:
|
||||
return DEFAULT_UPDATE_CHANNEL
|
||||
|
||||
|
||||
def channel_version_badge(channel=None) -> str:
|
||||
"""Return a channel-scoped version string for the Settings display badge ONLY.
|
||||
|
||||
This is DELIBERATELY separate from ``WEBUI_VERSION``. ``WEBUI_VERSION`` is
|
||||
load-bearing in exact-string-equality systems — asset cache-busting URLs, the
|
||||
service-worker CACHE_NAME, the models-cache stamp, and the stale-client skew
|
||||
banner — so it must stay channel-neutral and stable for the process lifetime.
|
||||
Making it channel-dependent would falsely trip "hard refresh" banners and
|
||||
spurious cache rebuilds on every channel flip. This helper is read at request
|
||||
time purely to render ``WebUI: v0.52.47 · Experimental`` in Settings.
|
||||
|
||||
Returns the channel-matched ``git describe`` (e.g. ``v0.52.47`` on stable,
|
||||
``exp-v0.52.51`` on experimental), or falls back to ``WEBUI_VERSION`` when no
|
||||
channel tag is reachable (fresh clone, Docker image without channel tags).
|
||||
"""
|
||||
if channel is None:
|
||||
channel = _read_update_channel()
|
||||
channel = _normalize_channel(channel)
|
||||
out, ok = _run_git(
|
||||
['describe', '--tags', '--always', '--match', _channel_tag_glob(channel)],
|
||||
REPO_ROOT,
|
||||
)
|
||||
if ok and out:
|
||||
return out + _dirty_suffix(REPO_ROOT)
|
||||
return WEBUI_VERSION
|
||||
|
||||
|
||||
def _release_tags(path, channel=DEFAULT_UPDATE_CHANNEL):
|
||||
"""Return the channel's release tags newest-first, in version-sort order."""
|
||||
glob = _channel_tag_glob(channel)
|
||||
out, ok = _run_git(['tag', '--list', glob, '--sort=-v:refname'], path)
|
||||
if not (ok and out):
|
||||
return []
|
||||
return [line.strip() for line in out.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _current_release_tag(path):
|
||||
"""Return the latest release tag reachable from HEAD, if one exists."""
|
||||
out, ok = _run_git(['describe', '--tags', '--abbrev=0'], path)
|
||||
def _current_release_tag(path, channel=DEFAULT_UPDATE_CHANNEL):
|
||||
"""Return the latest channel release tag reachable from HEAD, if one exists.
|
||||
|
||||
MUST filter by the channel glob (``--match``): a commit tagged BOTH
|
||||
``v0.52.0`` and ``exp-v0.52.0`` describes as ``exp-v0.52.0`` (git prefers the
|
||||
lexically-later tag), so an unfiltered ``describe`` would make stable-channel
|
||||
math resolve to the experimental tag and fall through to the branch firehose.
|
||||
"""
|
||||
out, ok = _run_git(
|
||||
['describe', '--tags', '--abbrev=0', '--match', _channel_tag_glob(channel)],
|
||||
path,
|
||||
)
|
||||
return out if ok and out else None
|
||||
|
||||
|
||||
@@ -671,18 +756,25 @@ def _release_gap(tags, current, latest):
|
||||
return 1
|
||||
|
||||
|
||||
def _head_is_past_latest_tag(path, current_tag):
|
||||
"""Return True when HEAD has moved past the latest reachable release tag.
|
||||
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.
|
||||
|
||||
`git describe --tags --always` returns the bare tag name (e.g. ``v2026.5.16``)
|
||||
when HEAD is exactly on the tag, and a ``v2026.5.16-608-g1d22b9c2`` suffix
|
||||
when HEAD has moved 608 commits past it. Used by both the update check and
|
||||
the update apply path so they agree on which ref to advance to — see #2653
|
||||
(check side) and #2846 (apply side).
|
||||
`git describe --tags --always --match <glob>` returns the bare tag name
|
||||
(e.g. ``v2026.5.16``) when HEAD is exactly on the tag, and a
|
||||
``v2026.5.16-608-g1d22b9c2`` suffix when HEAD has moved 608 commits past it.
|
||||
Used by both the update check and the update apply path so they agree on
|
||||
which ref to advance to — see #2653 (check side) and #2846 (apply side).
|
||||
|
||||
The ``--match`` filter is mandatory: without it, a HEAD sitting on a commit
|
||||
that carries the other channel's tag would describe against that tag and
|
||||
give a wrong past/at answer for THIS channel.
|
||||
"""
|
||||
if not current_tag:
|
||||
return False
|
||||
full_desc, ok = _run_git(['describe', '--tags', '--always'], path)
|
||||
full_desc, ok = _run_git(
|
||||
['describe', '--tags', '--always', '--match', _channel_tag_glob(channel)],
|
||||
path,
|
||||
)
|
||||
return bool(ok and full_desc and full_desc != current_tag)
|
||||
|
||||
|
||||
@@ -708,7 +800,7 @@ def _can_fast_forward_to(path, ref):
|
||||
return bool(ok)
|
||||
|
||||
|
||||
def _select_apply_compare_ref(path):
|
||||
def _select_apply_compare_ref(path, channel=DEFAULT_UPDATE_CHANNEL, target=None):
|
||||
"""Return the same remote ref family that the update check reports.
|
||||
|
||||
The update banner prefers published release tags when they exist. Applying
|
||||
@@ -722,11 +814,24 @@ def _select_apply_compare_ref(path):
|
||||
decision — otherwise we run `git pull --ff-only <latest-tag>` against a
|
||||
checkout that's already past the tag, no-op, restart, and the banner
|
||||
re-appears with the same N commits available. See #2846.
|
||||
|
||||
CHANNEL SEMANTICS (webui only): the stable/experimental channels govern the
|
||||
WebUI repo. For ``target == 'webui'`` on the ``stable`` channel, stable tags
|
||||
are a *promoted subset* of master, so a stable install whose HEAD already
|
||||
contains the latest stable tag but sits behind master's tip must NOT fall
|
||||
through to the branch comparison (that would advance it to ``origin/master``
|
||||
— the full experimental firehose, defeating the channel). We return ``None``
|
||||
so the caller reports "no update". Every other case — the experimental
|
||||
channel, and the AGENT repo (which is a separate project that legitimately
|
||||
tracks master past its tags) — keeps the historical branch fallthrough
|
||||
unchanged. This mirrors ``_check_repo_release``.
|
||||
"""
|
||||
tags = _release_tags(path)
|
||||
channel = _normalize_channel(channel)
|
||||
suppress_stable_fallthrough = (channel == 'stable' and target == 'webui')
|
||||
tags = _release_tags(path, channel)
|
||||
if tags:
|
||||
latest_tag = tags[0]
|
||||
current_tag = _current_release_tag(path)
|
||||
current_tag = _current_release_tag(path, channel)
|
||||
behind = _release_gap(tags, current_tag, latest_tag)
|
||||
# Mirror the check side exactly: fall through to the branch comparison
|
||||
# whenever the checkout has already moved past the release tag that the
|
||||
@@ -735,13 +840,18 @@ def _select_apply_compare_ref(path):
|
||||
# have behind > 0 after fetching a newer tag that HEAD already contains
|
||||
# (#3140). In both cases applying the tag would no-op, move backwards,
|
||||
# or fail fast-forward; branch comparison is the truthful update path.
|
||||
# Short-circuit `or` preserves the original minimal git-call pattern.
|
||||
if (
|
||||
behind == 0 and _head_is_past_latest_tag(path, current_tag)
|
||||
) or (
|
||||
behind > 0 and _head_contains_ref(path, latest_tag)
|
||||
) or (
|
||||
behind > 0 and not _can_fast_forward_to(path, latest_tag)
|
||||
(behind == 0 and _head_is_past_latest_tag(path, current_tag, channel))
|
||||
or (behind > 0 and _head_contains_ref(path, latest_tag))
|
||||
or (behind > 0 and not _can_fast_forward_to(path, latest_tag))
|
||||
):
|
||||
# WebUI stable: "HEAD past/contains the latest stable tag" means
|
||||
# up-to-date on the promoted subset — NOT a signal to advance to
|
||||
# master. Return None so the caller reports no update.
|
||||
if suppress_stable_fallthrough:
|
||||
return None
|
||||
# Experimental / agent: preserve the historical branch fallthrough.
|
||||
pass
|
||||
else:
|
||||
return latest_tag
|
||||
@@ -754,14 +864,39 @@ def _select_apply_compare_ref(path):
|
||||
return f'origin/{branch}'
|
||||
|
||||
|
||||
def _check_repo_release(path, name):
|
||||
"""Check if a git repo is behind its latest published release tag."""
|
||||
tags = _release_tags(path)
|
||||
def _channel_up_to_date_info(path, name, channel, current_tag):
|
||||
"""Return an 'up to date' payload for a channel that must NOT branch-compare.
|
||||
|
||||
Used by the stable channel: stable tags are a promoted subset of master, so
|
||||
when HEAD already contains the latest stable tag we report up-to-date
|
||||
(behind == 0) rather than falling through to the branch comparison, which
|
||||
would advance the user onto the experimental firehose.
|
||||
"""
|
||||
remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path)
|
||||
remote_url = _normalize_remote_url(remote_url)
|
||||
return {
|
||||
'name': name,
|
||||
'behind': 0,
|
||||
'current_sha': current_tag,
|
||||
'latest_sha': current_tag,
|
||||
'branch': current_tag,
|
||||
'repo_url': remote_url,
|
||||
'release_based': True,
|
||||
'current_version': current_tag,
|
||||
'latest_version': current_tag,
|
||||
'channel': channel,
|
||||
}
|
||||
|
||||
|
||||
def _check_repo_release(path, name, channel=DEFAULT_UPDATE_CHANNEL):
|
||||
"""Check if a git repo is behind its latest published channel release tag."""
|
||||
channel = _normalize_channel(channel)
|
||||
tags = _release_tags(path, channel)
|
||||
if not tags:
|
||||
return None
|
||||
|
||||
latest_tag = tags[0]
|
||||
current_tag = _current_release_tag(path)
|
||||
current_tag = _current_release_tag(path, channel)
|
||||
behind = _release_gap(tags, current_tag, latest_tag)
|
||||
|
||||
# If behind == 0 but HEAD has moved past the tag (e.g. the agent repo
|
||||
@@ -770,7 +905,16 @@ def _check_repo_release(path, name):
|
||||
# Fall through to _check_repo_branch so the real commit count is reported
|
||||
# instead. The same predicate is used by _select_apply_compare_ref so the
|
||||
# check and apply sides cannot drift again. See #2653 (check), #2846 (apply).
|
||||
if behind == 0 and _head_is_past_latest_tag(path, current_tag):
|
||||
#
|
||||
# CHANNEL (webui only): for the WebUI repo on stable, stable tags are a
|
||||
# promoted SUBSET of master, so "HEAD past the latest stable tag" means
|
||||
# up-to-date on the promoted subset, NOT a signal to branch-compare against
|
||||
# origin/master (the firehose). Report up-to-date. The AGENT repo and the
|
||||
# experimental channel keep the historical fall-through.
|
||||
suppress_stable_fallthrough = (channel == 'stable' and name == 'webui')
|
||||
if behind == 0 and _head_is_past_latest_tag(path, current_tag, channel):
|
||||
if suppress_stable_fallthrough:
|
||||
return _channel_up_to_date_info(path, name, channel, current_tag)
|
||||
return None
|
||||
|
||||
# Users tracking main can already contain the newest fetched release tag
|
||||
@@ -779,12 +923,16 @@ def _check_repo_release(path, name):
|
||||
# Fall through to the branch check so the banner compares against the
|
||||
# configured upstream instead of advertising a tag that cannot fast-forward.
|
||||
if behind > 0 and _head_contains_ref(path, latest_tag):
|
||||
if suppress_stable_fallthrough:
|
||||
return _channel_up_to_date_info(path, name, channel, current_tag)
|
||||
return None
|
||||
|
||||
# Patch releases can land on a side branch while day-to-day installs track
|
||||
# main past an older tag. A positive tag-name gap then advertises an update
|
||||
# that `git pull --ff-only <latest-tag>` cannot reach.
|
||||
if behind > 0 and not _can_fast_forward_to(path, latest_tag):
|
||||
if suppress_stable_fallthrough:
|
||||
return _channel_up_to_date_info(path, name, channel, current_tag)
|
||||
return None
|
||||
|
||||
remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path)
|
||||
@@ -802,6 +950,7 @@ def _check_repo_release(path, name):
|
||||
'release_based': True,
|
||||
'current_version': current_tag,
|
||||
'latest_version': latest_tag,
|
||||
'channel': channel,
|
||||
}
|
||||
|
||||
|
||||
@@ -876,7 +1025,7 @@ def _check_repo_branch(path, name, *, fetch=True):
|
||||
}
|
||||
|
||||
|
||||
def _check_repo(path, name):
|
||||
def _check_repo(path, name, channel=DEFAULT_UPDATE_CHANNEL):
|
||||
"""Check if a git repo is behind its latest release. Returns dict or None.
|
||||
|
||||
The returned dict (when not None) always carries a ``dirty: bool`` reflecting
|
||||
@@ -889,6 +1038,7 @@ def _check_repo(path, name):
|
||||
with ``no_git: True`` and ``behind: None`` so the frontend can distinguish
|
||||
"can't check" from "up to date" (issue #4356).
|
||||
"""
|
||||
channel = _normalize_channel(channel)
|
||||
if path is None or not (path / '.git').exists():
|
||||
return {
|
||||
'name': name,
|
||||
@@ -907,7 +1057,7 @@ def _check_repo(path, name):
|
||||
# See #2756.
|
||||
fetch_out, fetch_ok = _run_git(['fetch', 'origin', '--tags', '--force'], path, timeout=15)
|
||||
if not fetch_ok:
|
||||
release_info = _check_repo_release(path, name)
|
||||
release_info = _check_repo_release(path, name, channel)
|
||||
message = 'fetch failed'
|
||||
if fetch_out:
|
||||
message = f'{message}: {_sanitize_git_diagnostic(fetch_out)}'
|
||||
@@ -925,7 +1075,7 @@ def _check_repo(path, name):
|
||||
'dirty': _is_dirty(path),
|
||||
}
|
||||
|
||||
release_info = _check_repo_release(path, name)
|
||||
release_info = _check_repo_release(path, name, channel)
|
||||
if release_info is not None:
|
||||
release_info = dict(release_info)
|
||||
release_info['dirty'] = _is_dirty(path)
|
||||
@@ -935,6 +1085,7 @@ def _check_repo(path, name):
|
||||
if branch_info is not None:
|
||||
branch_info = dict(branch_info)
|
||||
branch_info['dirty'] = _is_dirty(path)
|
||||
branch_info['channel'] = channel
|
||||
return branch_info
|
||||
return None
|
||||
|
||||
@@ -959,11 +1110,20 @@ def _ignored_agent_update_info() -> dict:
|
||||
return {'name': 'agent', 'behind': 0, 'ignored': True}
|
||||
|
||||
|
||||
def cached_update_status(*, include_agent=True):
|
||||
def cached_update_status(*, include_agent=True, channel=None):
|
||||
"""Return cached update status without performing network or git mutations."""
|
||||
include_agent = bool(include_agent)
|
||||
if channel is None:
|
||||
channel = _read_update_channel()
|
||||
channel = _normalize_channel(channel)
|
||||
with _cache_lock:
|
||||
cached = dict(_update_cache)
|
||||
# If the cache was populated for a different channel, it is not a valid
|
||||
# answer for this channel — signal that so callers don't render stale
|
||||
# cross-channel data as authoritative.
|
||||
if cached.get('channel') != channel:
|
||||
cached['channel'] = channel
|
||||
cached['stale_channel'] = True
|
||||
if cached.get('include_agent') != include_agent:
|
||||
cached['include_agent'] = include_agent
|
||||
if not include_agent:
|
||||
@@ -972,31 +1132,43 @@ def cached_update_status(*, include_agent=True):
|
||||
return cached
|
||||
|
||||
|
||||
def check_for_updates(force=False, *, include_agent=True):
|
||||
def check_for_updates(force=False, *, include_agent=True, channel=None):
|
||||
"""Return cached update status for webui and agent repos."""
|
||||
global _check_in_progress
|
||||
include_agent = bool(include_agent)
|
||||
if channel is None:
|
||||
channel = _read_update_channel()
|
||||
channel = _normalize_channel(channel)
|
||||
with _cache_lock:
|
||||
# Cache is only valid when BOTH the channel AND include_agent match —
|
||||
# a channel switch must not serve the previous channel's answer, and an
|
||||
# in-progress check for the other channel must not short-circuit this one
|
||||
# with a stale cross-channel payload (Codex SILENT #5).
|
||||
cache_matches = (
|
||||
_update_cache.get('include_agent') == include_agent
|
||||
and _update_cache.get('channel') == channel
|
||||
)
|
||||
if (
|
||||
not force
|
||||
and _update_cache.get('include_agent') == include_agent
|
||||
and cache_matches
|
||||
and time.time() - _update_cache['checked_at'] < CACHE_TTL
|
||||
):
|
||||
return dict(_update_cache)
|
||||
if _check_in_progress:
|
||||
return dict(_update_cache) # another thread is already checking
|
||||
if _check_in_progress and cache_matches:
|
||||
return dict(_update_cache) # another thread is already checking this channel
|
||||
_check_in_progress = True
|
||||
|
||||
try:
|
||||
# Run checks outside the lock (network I/O)
|
||||
webui_info = _check_repo(REPO_ROOT, 'webui')
|
||||
agent_info = _check_repo(_AGENT_DIR, 'agent') if include_agent else _ignored_agent_update_info()
|
||||
webui_info = _check_repo(REPO_ROOT, 'webui', channel)
|
||||
agent_info = _check_repo(_AGENT_DIR, 'agent', channel) if include_agent else _ignored_agent_update_info()
|
||||
|
||||
with _cache_lock:
|
||||
_update_cache['webui'] = webui_info
|
||||
_update_cache['agent'] = agent_info
|
||||
_update_cache['checked_at'] = time.time()
|
||||
_update_cache['include_agent'] = include_agent
|
||||
_update_cache['channel'] = channel
|
||||
return dict(_update_cache)
|
||||
finally:
|
||||
_check_in_progress = False
|
||||
@@ -1482,7 +1654,7 @@ def _agent_gateway_restart_failure_message(target: str, restart_result: dict) ->
|
||||
)
|
||||
|
||||
|
||||
def apply_force_update(target: str) -> dict:
|
||||
def apply_force_update(target: str, channel=None) -> dict:
|
||||
"""Force-reset the target repo to the latest remote HEAD.
|
||||
|
||||
Unlike apply_update() which requires a clean working tree and refuses
|
||||
@@ -1493,7 +1665,17 @@ def apply_force_update(target: str) -> dict:
|
||||
Should only be called when apply_update() has already returned a
|
||||
response with ``conflict: True`` or ``diverged: True`` and the user
|
||||
has confirmed they want to discard local changes.
|
||||
|
||||
CHANNEL SAFETY (rewind guard): ``reset --hard`` is destructive. When the
|
||||
selected channel resolves to a ref that is an ANCESTOR of HEAD (i.e. the
|
||||
checkout is already ahead of the channel — e.g. an ex-experimental install
|
||||
switching back to stable), resetting to it would REWIND code and on-disk
|
||||
state. We refuse and return a clear message instead of silently downgrading.
|
||||
A deliberate rollback would be a separate, explicit feature.
|
||||
"""
|
||||
if channel is None:
|
||||
channel = _read_update_channel()
|
||||
channel = _normalize_channel(channel)
|
||||
blocker_snapshot = _restart_blocker_snapshot()
|
||||
if blocker_snapshot.get('restart_blocked'):
|
||||
return _restart_blocked_response(target, blocker_snapshot)
|
||||
@@ -1533,8 +1715,39 @@ def apply_force_update(target: str) -> dict:
|
||||
),
|
||||
}
|
||||
|
||||
compare_ref = _select_apply_compare_ref(path)
|
||||
compare_ref = _select_apply_compare_ref(path, channel, target)
|
||||
# Stable channel, already up to date on the promoted subset: nothing to
|
||||
# force to. Do NOT fall back to origin/master (firehose). See
|
||||
# _select_apply_compare_ref channel semantics.
|
||||
if compare_ref is None:
|
||||
return {
|
||||
'ok': True,
|
||||
'message': f'{target} is already up to date on the {channel} channel.',
|
||||
'target': target,
|
||||
'up_to_date': True,
|
||||
'channel': channel,
|
||||
}
|
||||
|
||||
# Rewind guard (Codex CORE #3): refuse to reset --hard onto a ref that
|
||||
# is an ANCESTOR of HEAD — that would downgrade the checkout. This is the
|
||||
# switch-back-to-stable-while-ahead case. A ref that is a descendant of
|
||||
# HEAD (normal update / opt-in to experimental) fast-forwards fine and is
|
||||
# allowed. Refs on a divergent line (neither ancestor nor descendant) are
|
||||
# the legitimate force-update case (conflict/diverged recovery) and are
|
||||
# also allowed — the guard fires ONLY on a pure-ancestor rewind.
|
||||
if _head_contains_ref(path, compare_ref) and not _can_fast_forward_to(path, compare_ref):
|
||||
return {
|
||||
'ok': False,
|
||||
'message': (
|
||||
f'{target} is already ahead of the {channel} channel '
|
||||
f'({compare_ref}); refusing to rewind the checkout. '
|
||||
'Switching to a slower channel keeps your current version '
|
||||
'until that channel catches up.'
|
||||
),
|
||||
'target': target,
|
||||
'channel': channel,
|
||||
'refused_rewind': True,
|
||||
}
|
||||
# Discard local modifications and untracked colliders before resetting.
|
||||
# Do not use -x: ignored build/cache artifacts should survive force update.
|
||||
_run_git(['checkout', '.'], path)
|
||||
@@ -1587,8 +1800,11 @@ def apply_force_update(target: str) -> dict:
|
||||
_apply_lock.release()
|
||||
|
||||
|
||||
def apply_update(target):
|
||||
def apply_update(target, channel=None):
|
||||
"""Stash, pull --ff-only, pop for the given target repo."""
|
||||
if channel is None:
|
||||
channel = _read_update_channel()
|
||||
channel = _normalize_channel(channel)
|
||||
blocker_snapshot = _restart_blocker_snapshot()
|
||||
if blocker_snapshot.get('restart_blocked'):
|
||||
return _restart_blocked_response(target, blocker_snapshot)
|
||||
@@ -1596,7 +1812,7 @@ def apply_update(target):
|
||||
if not _apply_lock.acquire(blocking=False):
|
||||
return {'ok': False, 'message': 'Update already in progress'}
|
||||
try:
|
||||
return _apply_update_inner(target)
|
||||
return _apply_update_inner(target, channel)
|
||||
finally:
|
||||
_apply_lock.release()
|
||||
|
||||
@@ -1636,8 +1852,9 @@ def _restore_stash_after_pull_failure(
|
||||
)
|
||||
|
||||
|
||||
def _apply_update_inner(target):
|
||||
def _apply_update_inner(target, channel=DEFAULT_UPDATE_CHANNEL):
|
||||
"""Inner implementation of apply_update, called under _apply_lock."""
|
||||
channel = _normalize_channel(channel)
|
||||
if target == 'webui':
|
||||
path = REPO_ROOT
|
||||
elif target == 'agent':
|
||||
@@ -1666,7 +1883,19 @@ def _apply_update_inner(target):
|
||||
),
|
||||
}
|
||||
|
||||
compare_ref = _select_apply_compare_ref(path)
|
||||
compare_ref = _select_apply_compare_ref(path, channel, target)
|
||||
# On the stable channel a None ref means HEAD already contains the latest
|
||||
# promoted stable tag (up-to-date on the promoted subset). Do NOT fall back
|
||||
# to origin/master — that would advance the user onto the experimental
|
||||
# firehose. Report success/no-op instead. See _select_apply_compare_ref.
|
||||
if compare_ref is None:
|
||||
return {
|
||||
'ok': True,
|
||||
'message': f'{target} is already up to date on the {channel} channel.',
|
||||
'target': target,
|
||||
'up_to_date': True,
|
||||
'channel': channel,
|
||||
}
|
||||
|
||||
# Check for dirty working tree (ignore untracked files — git stash
|
||||
# doesn't include them, so stashing on '??' alone leaves nothing to pop)
|
||||
|
||||
@@ -842,6 +842,10 @@ const LOCALES = {
|
||||
settings_label_previous_messaging_sessions: 'Show previous messaging sessions',
|
||||
settings_label_sync_insights: 'Sync to insights',
|
||||
settings_label_check_updates: 'Check for updates',
|
||||
settings_label_update_channel: 'Update channel',
|
||||
settings_update_channel_stable: 'Stable (recommended)',
|
||||
settings_update_channel_experimental: 'Experimental',
|
||||
settings_desc_update_channel: 'Stable gets soaked, proven releases. Experimental gets every release as it lands — faster, but may hit rough edges. Switching back to Stable keeps your current version until Stable catches up.',
|
||||
settings_label_ignore_agent_updates: 'Ignore Agent updates',
|
||||
settings_label_whats_new_summary: "Summarize What's New with AI",
|
||||
settings_label_bot_name: 'Default assistant name',
|
||||
|
||||
@@ -1398,6 +1398,14 @@
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_check_updates">Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsUpdateChannel" data-i18n="settings_label_update_channel">Update channel</label>
|
||||
<select id="settingsUpdateChannel" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
||||
<option value="stable" data-i18n="settings_update_channel_stable">Stable (recommended)</option>
|
||||
<option value="experimental" data-i18n="settings_update_channel_experimental">Experimental</option>
|
||||
</select>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_update_channel">Stable gets soaked, proven releases. Experimental gets every release as it lands — faster, but may hit rough edges. Switching back to Stable keeps your current version until Stable catches up.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsIgnoreAgentUpdates" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
|
||||
+47
-1
@@ -8543,6 +8543,8 @@ function _preferencesPayloadFromUi(){
|
||||
if(syncCb) payload.sync_to_insights=syncCb.checked;
|
||||
const updateCb=$('settingsCheckUpdates');
|
||||
if(updateCb) payload.check_for_updates=updateCb.checked;
|
||||
const updateChannelSel=$('settingsUpdateChannel');
|
||||
if(updateChannelSel) payload.update_channel=updateChannelSel.value;
|
||||
const ignoreAgentUpdatesCb=$('settingsIgnoreAgentUpdates');
|
||||
if(ignoreAgentUpdatesCb) payload.ignore_agent_updates=ignoreAgentUpdatesCb.checked;
|
||||
const whatsNewSummaryCb=$('settingsWhatsNewSummary');
|
||||
@@ -8735,9 +8737,21 @@ async function loadSettingsPanel(){
|
||||
checkWebUIVersionSkew(settings);
|
||||
// Populate the version badges from the server — keeps them in sync with git
|
||||
// tags automatically without any manual release step.
|
||||
//
|
||||
// The DISPLAY badge uses update_channel_version (a channel-scoped
|
||||
// `git describe --match`), which is SEPARATE from settings.webui_version.
|
||||
// webui_version is load-bearing for asset cache-busting / SW cache / stale-
|
||||
// client skew detection and must stay channel-neutral — never render it as
|
||||
// the channel badge. See api/updates.channel_version_badge().
|
||||
const webuiBadge = $('settings-webui-version-badge');
|
||||
if(webuiBadge){
|
||||
webuiBadge.textContent = `WebUI: ${settings.webui_version || 'not detected'}`;
|
||||
const chanVer = settings.update_channel_version || settings.webui_version || 'not detected';
|
||||
const chan = settings.update_channel==='experimental' ? 'experimental' : 'stable';
|
||||
// Only annotate the channel when on experimental — stable is the implicit
|
||||
// default and needs no extra chrome.
|
||||
webuiBadge.textContent = chan==='experimental'
|
||||
? `WebUI: ${chanVer} · Experimental`
|
||||
: `WebUI: ${chanVer}`;
|
||||
}
|
||||
const agentBadge = $('settings-agent-version-badge');
|
||||
if(agentBadge){
|
||||
@@ -9128,6 +9142,23 @@ async function loadSettingsPanel(){
|
||||
if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const updateCb=$('settingsCheckUpdates');
|
||||
if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const updateChannelSel=$('settingsUpdateChannel');
|
||||
if(updateChannelSel){
|
||||
updateChannelSel.value=settings.update_channel==='experimental'?'experimental':'stable';
|
||||
updateChannelSel.addEventListener('change',function(){
|
||||
// Persist the channel, then invalidate the cached update check and
|
||||
// re-check so the banner reflects the newly-selected channel. Changing
|
||||
// the channel changes WHAT is offered, never WHAT is installed — the
|
||||
// update banner still gates the actual apply behind "Update Now".
|
||||
_schedulePreferencesAutosave();
|
||||
if(typeof checkUpdatesNow==='function'){
|
||||
// Small delay so the autosave PUT lands before the forced re-check
|
||||
// reads the new channel server-side.
|
||||
setTimeout(function(){try{checkUpdatesNow();}catch(e){}},400);
|
||||
}
|
||||
if(typeof _syncUpdateChannelBadge==='function') _syncUpdateChannelBadge(updateChannelSel.value);
|
||||
},{once:false});
|
||||
}
|
||||
const ignoreAgentUpdatesCb=$('settingsIgnoreAgentUpdates');
|
||||
if(ignoreAgentUpdatesCb){ignoreAgentUpdatesCb.checked=!!settings.ignore_agent_updates;ignoreAgentUpdatesCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const whatsNewSummaryCb=$('settingsWhatsNewSummary');
|
||||
@@ -11703,6 +11734,20 @@ function _applySavedSettingsUi(saved, body, opts){
|
||||
if(typeof renderSessionList==='function') renderSessionList();
|
||||
}
|
||||
|
||||
// Instant client-side badge feedback when the update channel is toggled, before
|
||||
// the server round-trip that authoritatively re-renders the badge from
|
||||
// update_channel_version. Keeps the "· Experimental" suffix in sync immediately.
|
||||
function _syncUpdateChannelBadge(channel){
|
||||
try{
|
||||
const badge=$('settings-webui-version-badge');
|
||||
if(!badge) return;
|
||||
let base=badge.textContent||'';
|
||||
// Strip any existing " · Experimental" suffix, then re-append if needed.
|
||||
base=base.replace(/\s·\sExperimental\s*$/,'');
|
||||
badge.textContent = channel==='experimental' ? (base+' · Experimental') : base;
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
async function checkUpdatesNow(){
|
||||
const btn=$('btnCheckUpdatesNow');
|
||||
const label=$('checkUpdatesLabel');
|
||||
@@ -12268,6 +12313,7 @@ async function saveSettings(andClose){
|
||||
body.pinned_sessions_limit=pinnedSessionsLimit;
|
||||
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
|
||||
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
|
||||
body.update_channel=($('settingsUpdateChannel')||{}).value==='experimental'?'experimental':'stable';
|
||||
body.ignore_agent_updates=!!($('settingsIgnoreAgentUpdates')||{}).checked;
|
||||
body.whats_new_summary_enabled=!!($('settingsWhatsNewSummary')||{}).checked;
|
||||
body.sound_enabled=!!($('settingsSoundEnabled')||{}).checked;
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""End-to-end tests for update channels (stable vs experimental).
|
||||
|
||||
These use REAL git repositories in tmp_path (not mocked _run_git) so they
|
||||
exercise the actual git commands the channel logic depends on — the tag globs,
|
||||
`describe --match`, and the ancestor/descendant merge-base checks. This is the
|
||||
durable regression proof for the two-tag-channel design:
|
||||
|
||||
stable -> 'v*' promoted, soaked releases (default)
|
||||
experimental -> 'exp-v*' every batch (opt-in testers)
|
||||
|
||||
Both tag families live on the SAME linear master line; a channel is only a tag
|
||||
glob. The critical property under test: a STABLE user with an unpromoted
|
||||
`exp-v*` tag ahead of them reports up-to-date, NOT the master firehose.
|
||||
"""
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import api.updates as updates
|
||||
|
||||
|
||||
def _git(repo, *args):
|
||||
subprocess.run(
|
||||
['git', *args], cwd=str(repo), check=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def channel_repo(tmp_path):
|
||||
"""A linear repo: 5 batch commits, each tagged exp-v0.52.N; stable promoted
|
||||
at batch 2 (v0.52.2) and batch 5 (v0.52.5). HEAD ends on batch 5."""
|
||||
repo = tmp_path / 'repo'
|
||||
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')
|
||||
for i in range(1, 6):
|
||||
_git(repo, 'commit', '-q', '--allow-empty', '-m', f'batch{i}')
|
||||
_git(repo, 'tag', f'exp-v0.52.{i}')
|
||||
# Promote batch 2 and batch 5 to stable (same commit, same version number).
|
||||
_git(repo, 'tag', 'v0.52.2', 'HEAD~3')
|
||||
_git(repo, 'tag', 'v0.52.5', 'HEAD')
|
||||
return repo
|
||||
|
||||
|
||||
# ── Tag-glob separation ──────────────────────────────────────────────────────
|
||||
|
||||
def test_stable_glob_excludes_exp_tags(channel_repo):
|
||||
tags = updates._release_tags(channel_repo, 'stable')
|
||||
assert tags == ['v0.52.5', 'v0.52.2']
|
||||
assert all(not t.startswith('exp-') for t in tags)
|
||||
|
||||
|
||||
def test_experimental_glob_lists_only_exp_tags(channel_repo):
|
||||
tags = updates._release_tags(channel_repo, 'experimental')
|
||||
assert tags == [f'exp-v0.52.{i}' for i in (5, 4, 3, 2, 1)]
|
||||
|
||||
|
||||
def test_default_channel_is_stable(channel_repo):
|
||||
# No explicit channel == stable glob.
|
||||
assert updates._release_tags(channel_repo) == updates._release_tags(channel_repo, 'stable')
|
||||
|
||||
|
||||
def test_unknown_channel_falls_back_to_stable(channel_repo):
|
||||
assert updates._normalize_channel('bogus') == 'stable'
|
||||
assert updates._normalize_channel(None) == 'stable'
|
||||
assert updates._release_tags(channel_repo, 'bogus') == updates._release_tags(channel_repo, 'stable')
|
||||
|
||||
|
||||
# ── current_release_tag is channel-scoped (Codex CORE #1) ────────────────────
|
||||
|
||||
def test_current_release_tag_matches_channel(channel_repo):
|
||||
# HEAD is tagged BOTH v0.52.5 and exp-v0.52.5. describe must pick the
|
||||
# channel-matched tag, not whichever git prefers lexically.
|
||||
assert updates._current_release_tag(channel_repo, 'stable') == 'v0.52.5'
|
||||
assert updates._current_release_tag(channel_repo, 'experimental') == 'exp-v0.52.5'
|
||||
|
||||
|
||||
# ── The core property: stable never sees the firehose (Codex CORE #2) ────────
|
||||
|
||||
def test_stable_user_behind_a_promoted_tag_sees_the_update(channel_repo):
|
||||
"""Stable user sitting on v0.52.2 must be offered v0.52.5 (a real gap)."""
|
||||
_git(channel_repo, 'checkout', '-q', 'v0.52.2')
|
||||
info = updates._check_repo_release(channel_repo, 'webui', 'stable')
|
||||
assert info is not None
|
||||
assert info['behind'] == 1
|
||||
assert info['current_version'] == 'v0.52.2'
|
||||
assert info['latest_version'] == 'v0.52.5'
|
||||
assert updates._select_apply_compare_ref(channel_repo, 'stable', 'webui') == 'v0.52.5'
|
||||
|
||||
|
||||
def test_stable_user_on_latest_stable_with_unpromoted_exp_ahead_is_up_to_date(channel_repo):
|
||||
"""THE key guarantee: a stable webui user on v0.52.5 with an unpromoted
|
||||
exp-v0.52.6 ahead of them reports UP-TO-DATE, never the master firehose."""
|
||||
_git(channel_repo, 'commit', '-q', '--allow-empty', '-m', 'batch6')
|
||||
_git(channel_repo, 'tag', 'exp-v0.52.6') # experimental-only, NOT promoted
|
||||
_git(channel_repo, 'checkout', '-q', 'v0.52.5')
|
||||
|
||||
info = updates._check_repo_release(channel_repo, 'webui', 'stable')
|
||||
assert info is not None
|
||||
assert info['behind'] == 0, 'stable must be up-to-date, not offered the firehose'
|
||||
# Apply side agrees: it resolves to the current stable tag (a no-op ff),
|
||||
# NEVER to origin/master. Both "current tag" and None mean up-to-date; the
|
||||
# firehose ref (origin/*) must never appear.
|
||||
apply_ref = updates._select_apply_compare_ref(channel_repo, 'stable', 'webui')
|
||||
assert apply_ref in ('v0.52.5', None)
|
||||
assert apply_ref is None or not apply_ref.startswith('origin/')
|
||||
|
||||
|
||||
def test_stable_user_PAST_latest_stable_on_untagged_commit_is_up_to_date(channel_repo):
|
||||
"""A stable webui user whose HEAD moved PAST the latest stable tag onto an
|
||||
unpromoted (untagged-on-stable) commit must report up-to-date, NOT fall
|
||||
through to origin/master. This is the exact firehose-suppression path."""
|
||||
_git(channel_repo, 'commit', '-q', '--allow-empty', '-m', 'past-stable')
|
||||
_git(channel_repo, 'tag', 'exp-v0.52.6') # exp-only; no stable tag here
|
||||
# HEAD is now one commit PAST v0.52.5 with no stable tag on it.
|
||||
info = updates._check_repo_release(channel_repo, 'webui', 'stable')
|
||||
assert info is not None
|
||||
assert info['behind'] == 0, 'stable past-tag must be up-to-date, not firehose'
|
||||
assert updates._select_apply_compare_ref(channel_repo, 'stable', 'webui') is None
|
||||
|
||||
|
||||
def test_experimental_user_sees_unpromoted_exp_commit(channel_repo):
|
||||
"""Same HEAD (v0.52.5), experimental channel, sees exp-v0.52.6 as behind=1."""
|
||||
_git(channel_repo, 'commit', '-q', '--allow-empty', '-m', 'batch6')
|
||||
_git(channel_repo, 'tag', 'exp-v0.52.6')
|
||||
_git(channel_repo, 'checkout', '-q', 'v0.52.5')
|
||||
|
||||
info = updates._check_repo_release(channel_repo, 'webui', 'experimental')
|
||||
assert info is not None
|
||||
assert info['behind'] == 1
|
||||
assert info['current_version'] == 'exp-v0.52.5'
|
||||
assert info['latest_version'] == 'exp-v0.52.6'
|
||||
|
||||
|
||||
# ── Agent repo keeps historical fall-through (channel is webui-only) ─────────
|
||||
|
||||
def test_agent_repo_falls_through_to_branch_even_on_stable(channel_repo):
|
||||
"""The agent repo legitimately tracks master past its tags — the stable
|
||||
channel's fall-through suppression is webui-only, so the agent still
|
||||
branch-compares when HEAD is past the latest tag."""
|
||||
# Add commits past the latest stable tag WITHOUT promoting them, and set an
|
||||
# upstream so the branch path is reachable.
|
||||
_git(channel_repo, 'commit', '-q', '--allow-empty', '-m', 'past1')
|
||||
_git(channel_repo, 'commit', '-q', '--allow-empty', '-m', 'past2')
|
||||
# name='agent' → suppression OFF → past-tag HEAD returns None (fall through).
|
||||
result = updates._check_repo_release(channel_repo, 'agent', 'stable')
|
||||
assert result is None, 'agent must fall through to branch check when past its tag'
|
||||
|
||||
|
||||
# ── Force-update rewind guard (Codex CORE #3) ────────────────────────────────
|
||||
|
||||
def test_force_update_refuses_rewind_when_ref_is_ancestor(channel_repo, monkeypatch):
|
||||
"""The rewind guard: apply_force_update must refuse to reset --hard onto a
|
||||
ref that is a strict ANCESTOR of HEAD (a downgrade). HEAD is on v0.52.5;
|
||||
we force to a ref resolving to the older v0.52.2 (an ancestor)."""
|
||||
monkeypatch.setattr(updates, 'REPO_ROOT', channel_repo)
|
||||
monkeypatch.setattr(
|
||||
updates, '_restart_blocker_snapshot',
|
||||
lambda: {'restart_blocked': False, 'active_streams': 0, 'active_runs': 0},
|
||||
)
|
||||
# Force the compare ref to the older stable tag (a strict ancestor of HEAD).
|
||||
monkeypatch.setattr(
|
||||
updates, '_select_apply_compare_ref',
|
||||
lambda path, channel='stable', target=None: 'v0.52.2',
|
||||
)
|
||||
real_run_git = updates._run_git
|
||||
|
||||
def no_fetch(args, cwd, timeout=10):
|
||||
if args[:2] == ['fetch', 'origin']:
|
||||
return '', True
|
||||
return real_run_git(args, cwd, timeout=timeout)
|
||||
|
||||
monkeypatch.setattr(updates, '_run_git', no_fetch)
|
||||
result = updates.apply_force_update('webui', channel='stable')
|
||||
assert result.get('refused_rewind') is True, result
|
||||
assert result['ok'] is False
|
||||
# HEAD must not have moved (no rewind actually happened).
|
||||
head, ok = updates._run_git(['rev-parse', 'HEAD'], channel_repo)
|
||||
v525, _ = updates._run_git(['rev-parse', 'v0.52.5'], channel_repo)
|
||||
assert head == v525, 'force-update must not have rewound HEAD'
|
||||
|
||||
|
||||
# ── Cache is keyed by channel (Codex SILENT #5) ──────────────────────────────
|
||||
|
||||
def test_update_cache_scoped_by_channel(channel_repo, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_check_repo(path, name, channel='stable'):
|
||||
calls.append((name, channel))
|
||||
return {'name': name, 'behind': 0, 'channel': channel}
|
||||
|
||||
monkeypatch.setattr(updates, 'REPO_ROOT', channel_repo)
|
||||
monkeypatch.setattr(updates, '_AGENT_DIR', channel_repo)
|
||||
monkeypatch.setattr(updates, '_check_repo', fake_check_repo)
|
||||
monkeypatch.setitem(updates._update_cache, 'checked_at', 0)
|
||||
monkeypatch.setitem(updates._update_cache, 'channel', 'stable')
|
||||
|
||||
stable = updates.check_for_updates(force=True, include_agent=False, channel='stable')
|
||||
# A non-forced call on a DIFFERENT channel must not serve the stable cache.
|
||||
experimental = updates.check_for_updates(force=False, include_agent=False, channel='experimental')
|
||||
assert stable['channel'] == 'stable'
|
||||
assert experimental['channel'] == 'experimental'
|
||||
# Both channels triggered a real check (no stale cross-channel cache hit).
|
||||
assert ('webui', 'stable') in calls
|
||||
assert ('webui', 'experimental') in calls
|
||||
+34
-34
@@ -15,7 +15,7 @@ def _fake_git_for_release_fetch_failure(args, cwd, timeout=10):
|
||||
return 'would clobber existing tag v0.50.294', False
|
||||
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
|
||||
return 'v0.51.106\nv0.51.103', True
|
||||
if args == ['describe', '--tags', '--abbrev=0']:
|
||||
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
|
||||
return 'v0.51.103', True
|
||||
if args == ['merge-base', '--is-ancestor', 'v0.51.106', 'HEAD']:
|
||||
return '', False
|
||||
@@ -251,11 +251,11 @@ def test_check_for_updates_can_skip_agent_repo(tmp_path):
|
||||
|
||||
seen = []
|
||||
|
||||
def fake_check_repo(path, name):
|
||||
def fake_check_repo(path, name, channel='stable'):
|
||||
seen.append(name)
|
||||
return {'name': name, 'behind': 2 if name == 'webui' else 9}
|
||||
|
||||
cache_defaults = {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True}
|
||||
cache_defaults = {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True, 'channel': 'stable'}
|
||||
with patch.dict(updates._update_cache, cache_defaults, clear=True), \
|
||||
patch.object(updates, 'REPO_ROOT', webui_path), \
|
||||
patch.object(updates, '_AGENT_DIR', agent_path), \
|
||||
@@ -273,11 +273,11 @@ def test_update_cache_is_scoped_by_agent_inclusion(tmp_path):
|
||||
(tmp_path / '.git').mkdir()
|
||||
calls = []
|
||||
|
||||
def fake_check_repo(path, name):
|
||||
def fake_check_repo(path, name, channel='stable'):
|
||||
calls.append(name)
|
||||
return {'name': name, 'behind': len(calls)}
|
||||
|
||||
with patch.dict(updates._update_cache, {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True}, clear=True), \
|
||||
with patch.dict(updates._update_cache, {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True, 'channel': 'stable'}, clear=True), \
|
||||
patch.object(updates, 'REPO_ROOT', tmp_path), \
|
||||
patch.object(updates, '_AGENT_DIR', tmp_path), \
|
||||
patch.object(updates, '_check_repo', side_effect=fake_check_repo):
|
||||
@@ -484,9 +484,9 @@ def test_check_repo_recovers_from_remote_retag(tmp_path):
|
||||
return '', True
|
||||
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
|
||||
return 'v0.51.110\nv0.51.109', True
|
||||
if args == ['describe', '--tags', '--abbrev=0']:
|
||||
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
|
||||
return 'v0.51.110', True
|
||||
if args == ['describe', '--tags', '--always']:
|
||||
if args == ['describe', '--tags', '--always', '--match', 'v*']:
|
||||
return 'v0.51.110', True
|
||||
if args == ['remote', 'get-url', 'origin']:
|
||||
return 'https://github.com/nesquena/hermes-webui.git', True
|
||||
@@ -529,10 +529,10 @@ def test_check_repo_release_falls_through_when_head_is_past_tag(tmp_path):
|
||||
def fake_git(args, cwd, timeout=10):
|
||||
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
|
||||
return 'v2026.5.16', True
|
||||
if args == ['describe', '--tags', '--abbrev=0']:
|
||||
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
|
||||
return 'v2026.5.16', True
|
||||
# HEAD is 608 commits past the tag — describe includes a suffix.
|
||||
if args == ['describe', '--tags', '--always']:
|
||||
if args == ['describe', '--tags', '--always', '--match', 'v*']:
|
||||
return 'v2026.5.16-608-g1d22b9c2d', True
|
||||
raise AssertionError(f'unexpected git args: {args!r}')
|
||||
|
||||
@@ -552,10 +552,10 @@ def test_check_repo_release_not_affected_when_head_exactly_on_tag(tmp_path):
|
||||
def fake_git(args, cwd, timeout=10):
|
||||
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
|
||||
return 'v2026.5.16\nv2026.5.10', True
|
||||
if args == ['describe', '--tags', '--abbrev=0']:
|
||||
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
|
||||
return 'v2026.5.16', True
|
||||
# No -N-gSHA suffix: HEAD is exactly on the tag.
|
||||
if args == ['describe', '--tags', '--always']:
|
||||
if args == ['describe', '--tags', '--always', '--match', 'v*']:
|
||||
return 'v2026.5.16', True
|
||||
if args == ['remote', 'get-url', 'origin']:
|
||||
return 'https://github.com/nesquena/hermes-agent.git', True
|
||||
@@ -583,10 +583,10 @@ def test_check_repo_branch_check_runs_for_post_tag_commits(tmp_path):
|
||||
return '', True
|
||||
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
|
||||
return 'v2026.5.16', True
|
||||
if args == ['describe', '--tags', '--abbrev=0']:
|
||||
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
|
||||
return 'v2026.5.16', True
|
||||
# HEAD is 608 commits past the tag.
|
||||
if args == ['describe', '--tags', '--always']:
|
||||
if args == ['describe', '--tags', '--always', '--match', 'v*']:
|
||||
return 'v2026.5.16-608-g1d22b9c2d', True
|
||||
# Branch-check path follows: rev-parse upstream, default branch, rev-list.
|
||||
if args == ['rev-parse', '--abbrev-ref', '@{upstream}']:
|
||||
@@ -633,9 +633,9 @@ def test_select_apply_compare_ref_uses_tag_when_head_is_on_tag(tmp_path):
|
||||
def fake_git(args, cwd, timeout=10):
|
||||
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
|
||||
return 'v2026.5.16\nv2026.5.10', True
|
||||
if args == ['describe', '--tags', '--abbrev=0']:
|
||||
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
|
||||
return 'v2026.5.16', True
|
||||
if args == ['describe', '--tags', '--always']:
|
||||
if args == ['describe', '--tags', '--always', '--match', 'v*']:
|
||||
return 'v2026.5.16', True
|
||||
raise AssertionError(f'unexpected git args: {args!r}')
|
||||
|
||||
@@ -658,17 +658,17 @@ def test_select_apply_compare_ref_falls_through_when_head_is_past_tag(tmp_path):
|
||||
def fake_git(args, cwd, timeout=10):
|
||||
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
|
||||
return 'v2026.5.16', True
|
||||
if args == ['describe', '--tags', '--abbrev=0']:
|
||||
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
|
||||
# HEAD's nearest tag is v2026.5.16; HEAD is 608 commits past it.
|
||||
return 'v2026.5.16', True
|
||||
if args == ['describe', '--tags', '--always']:
|
||||
if args == ['describe', '--tags', '--always', '--match', 'v*']:
|
||||
return 'v2026.5.16-608-g1d22b9c2d', True
|
||||
if args == ['rev-parse', '--abbrev-ref', '@{upstream}']:
|
||||
return 'origin/main', True
|
||||
raise AssertionError(f'unexpected git args: {args!r}')
|
||||
|
||||
with patch.object(updates, '_run_git', side_effect=fake_git):
|
||||
ref = updates._select_apply_compare_ref(tmp_path)
|
||||
ref = updates._select_apply_compare_ref(tmp_path, 'stable', 'agent')
|
||||
|
||||
assert ref == 'origin/main', (
|
||||
'apply path must advance to the upstream branch when HEAD is past the '
|
||||
@@ -724,9 +724,9 @@ def test_check_and_apply_paths_agree_when_head_is_past_tag(tmp_path):
|
||||
def fake_git(args, cwd, timeout=10):
|
||||
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
|
||||
return 'v2026.5.16', True
|
||||
if args == ['describe', '--tags', '--abbrev=0']:
|
||||
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
|
||||
return 'v2026.5.16', True
|
||||
if args == ['describe', '--tags', '--always']:
|
||||
if args == ['describe', '--tags', '--always', '--match', 'v*']:
|
||||
return 'v2026.5.16-608-g1d22b9c2d', True
|
||||
if args == ['rev-parse', '--abbrev-ref', '@{upstream}']:
|
||||
return 'origin/main', True
|
||||
@@ -734,7 +734,7 @@ def test_check_and_apply_paths_agree_when_head_is_past_tag(tmp_path):
|
||||
|
||||
with patch.object(updates, '_run_git', side_effect=fake_git):
|
||||
check_result = updates._check_repo_release(tmp_path, 'agent')
|
||||
apply_ref = updates._select_apply_compare_ref(tmp_path)
|
||||
apply_ref = updates._select_apply_compare_ref(tmp_path, 'stable', 'agent')
|
||||
|
||||
# Check side falls through (release check returns None → branch check runs)
|
||||
assert check_result is None, (
|
||||
@@ -761,7 +761,7 @@ def test_check_repo_release_falls_through_when_head_contains_newer_tag(tmp_path)
|
||||
def fake_git(args, cwd, timeout=10):
|
||||
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
|
||||
return 'v2026.5.29.2\nv2026.5.29', True
|
||||
if args == ['describe', '--tags', '--abbrev=0']:
|
||||
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
|
||||
return 'v2026.5.29', True
|
||||
if args == ['merge-base', '--is-ancestor', 'v2026.5.29.2', 'HEAD']:
|
||||
return '', True
|
||||
@@ -783,7 +783,7 @@ def test_select_apply_compare_ref_falls_through_when_head_contains_newer_tag(tmp
|
||||
def fake_git(args, cwd, timeout=10):
|
||||
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
|
||||
return 'v2026.5.29.2\nv2026.5.29', True
|
||||
if args == ['describe', '--tags', '--abbrev=0']:
|
||||
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
|
||||
return 'v2026.5.29', True
|
||||
if args == ['merge-base', '--is-ancestor', 'v2026.5.29.2', 'HEAD']:
|
||||
return '', True
|
||||
@@ -792,7 +792,7 @@ def test_select_apply_compare_ref_falls_through_when_head_contains_newer_tag(tmp
|
||||
raise AssertionError(f'unexpected git args: {args!r}')
|
||||
|
||||
with patch.object(updates, '_run_git', side_effect=fake_git):
|
||||
ref = updates._select_apply_compare_ref(tmp_path)
|
||||
ref = updates._select_apply_compare_ref(tmp_path, 'stable', 'agent')
|
||||
|
||||
assert ref == 'origin/main', (
|
||||
'Update Now must not target a release tag that HEAD already contains; '
|
||||
@@ -819,10 +819,10 @@ def test_select_apply_compare_ref_case_d_older_tag_with_commits_and_newer_tag_ex
|
||||
def fake_git(args, cwd, timeout=10):
|
||||
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
|
||||
return 'v2026.5.16\nv2026.5.10', True
|
||||
if args == ['describe', '--tags', '--abbrev=0']:
|
||||
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
|
||||
# HEAD's nearest reachable tag (older one)
|
||||
return 'v2026.5.10', True
|
||||
if args == ['describe', '--tags', '--always']:
|
||||
if args == ['describe', '--tags', '--always', '--match', 'v*']:
|
||||
# HEAD has 3 commits past v2026.5.10, but it does not contain
|
||||
# the newer v2026.5.16 release tag.
|
||||
return 'v2026.5.10-3-gabcdef12', True
|
||||
@@ -857,9 +857,9 @@ def test_check_repo_release_falls_through_when_latest_tag_is_not_ff_reachable(tm
|
||||
def fake_git(args, cwd, timeout=10):
|
||||
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
|
||||
return 'v2026.5.29.2\nv2026.5.29', True
|
||||
if args == ['describe', '--tags', '--abbrev=0']:
|
||||
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
|
||||
return 'v2026.5.29', True
|
||||
if args == ['describe', '--tags', '--always']:
|
||||
if args == ['describe', '--tags', '--always', '--match', 'v*']:
|
||||
return 'v2026.5.29-265-g5921d6678', True
|
||||
if args == ['merge-base', '--is-ancestor', 'v2026.5.29.2', 'HEAD']:
|
||||
return '', False
|
||||
@@ -880,9 +880,9 @@ def test_select_apply_compare_ref_falls_through_when_latest_tag_is_not_ff_reacha
|
||||
def fake_git(args, cwd, timeout=10):
|
||||
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
|
||||
return 'v2026.5.29.2\nv2026.5.29', True
|
||||
if args == ['describe', '--tags', '--abbrev=0']:
|
||||
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
|
||||
return 'v2026.5.29', True
|
||||
if args == ['describe', '--tags', '--always']:
|
||||
if args == ['describe', '--tags', '--always', '--match', 'v*']:
|
||||
return 'v2026.5.29-265-g5921d6678', True
|
||||
if args == ['merge-base', '--is-ancestor', 'v2026.5.29.2', 'HEAD']:
|
||||
return '', False
|
||||
@@ -893,7 +893,7 @@ def test_select_apply_compare_ref_falls_through_when_latest_tag_is_not_ff_reacha
|
||||
raise AssertionError(f'unexpected git args: {args!r}')
|
||||
|
||||
with patch.object(updates, '_run_git', side_effect=fake_git):
|
||||
ref = updates._select_apply_compare_ref(tmp_path)
|
||||
ref = updates._select_apply_compare_ref(tmp_path, 'stable', 'agent')
|
||||
|
||||
assert ref == 'origin/main'
|
||||
|
||||
@@ -1141,7 +1141,7 @@ def test_apply_clear_lock_with_no_lock_runs_normal_update(tmp_path, monkeypatch)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
updates, '_select_apply_compare_ref',
|
||||
lambda path: 'origin/main'
|
||||
lambda path, channel='stable', target=None: 'origin/main'
|
||||
)
|
||||
result = updates.apply_clear_lock('webui')
|
||||
assert result['ok'] is True, result
|
||||
@@ -1258,7 +1258,7 @@ def test_apply_update_pull_lock_restores_stash(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(updates, 'REPO_ROOT', tmp_path)
|
||||
monkeypatch.setattr(
|
||||
updates, '_select_apply_compare_ref',
|
||||
lambda path: 'origin/main'
|
||||
lambda path, channel='stable', target=None: 'origin/main'
|
||||
)
|
||||
|
||||
result = updates._apply_update_inner('webui')
|
||||
@@ -1310,7 +1310,7 @@ def test_apply_update_pull_lock_no_stash_when_clean(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(updates, 'REPO_ROOT', tmp_path)
|
||||
monkeypatch.setattr(
|
||||
updates, '_select_apply_compare_ref',
|
||||
lambda path: 'origin/main'
|
||||
lambda path, channel='stable', target=None: 'origin/main'
|
||||
)
|
||||
|
||||
result = updates._apply_update_inner('webui')
|
||||
|
||||
Reference in new issue
Block a user