Merge pull request #5790 from nesquena/feat/update-channels

Release channels: stable / experimental self-updater
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-07-08 17:12:50 -07:00
commit 4fc637d8d0
13 files changed
+794 -93

No files matched your search

+2
View File
@@ -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"},
+32 -3
View File
@@ -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,18 @@ 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))
# Allow the client to pass the channel explicitly in the POST body. This
# avoids a race on channel switch: the Settings dropdown re-checks
# immediately, but its autosave PUT (debounced) may not have landed
# server-side yet, so reading the saved setting here could answer for the
# OLD channel. An explicit body channel (validated against the enum) wins;
# otherwise fall back to the saved setting. (Fable UX gate.)
channel = body.get("channel") if isinstance(body, dict) else None
if channel not in ("stable", "experimental"):
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
@@ -15107,17 +15126,27 @@ def handle_post(handler, parsed) -> bool:
target = body.get("target", "")
if target not in ("webui", "agent"):
return bad(handler, 'target must be "webui" or "agent"')
# Honor an explicit validated body channel (the client sends the channel
# the banner was offering) so a channel switch whose debounced autosave
# hasn't landed can't make apply read the OLD saved channel (Codex gate).
# Fall back to the saved setting when absent/invalid.
_apply_channel = body.get("channel") if isinstance(body, dict) else None
if _apply_channel not in ("stable", "experimental"):
_apply_channel = None
from api.updates import apply_update
return j(handler, apply_update(target))
return j(handler, apply_update(target, _apply_channel))
if parsed.path == "/api/updates/force":
target = body.get("target", "")
if target not in ("webui", "agent"):
return bad(handler, 'target must be "webui" or "agent"')
_force_channel = body.get("channel") if isinstance(body, dict) else None
if _force_channel not in ("stable", "experimental"):
_force_channel = None
from api.updates import apply_force_update
return j(handler, apply_force_update(target))
return j(handler, apply_force_update(target, _force_channel))
if parsed.path == "/api/updates/clear_lock":
# Manual-instruction recovery for the .git/index.lock case. The
+287 -46
View File
@@ -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()
@@ -327,10 +327,12 @@ def apply_clear_lock(target: str) -> dict:
if not inv['well_known_lock_present']:
# Lock is gone. Run the normal non-destructive update flow and
# annotate the response with what we found for the user's
# records.
# records. Pass the configured channel through — otherwise an
# experimental-channel WebUI lock-recovery retry silently falls back
# to stable (Codex gate: _apply_update_inner defaults to stable).
with _cache_lock:
_update_cache['checked_at'] = 0
retry_result = _apply_update_inner(target)
retry_result = _apply_update_inner(target, _read_update_channel())
retry_result = dict(retry_result)
retry_result['lock_recovery'] = {
'action': 'no-lock-found',
@@ -648,17 +650,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 +758,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 +802,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 +816,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 +842,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 +866,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 +907,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 +925,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 +952,7 @@ def _check_repo_release(path, name):
'release_based': True,
'current_version': current_tag,
'latest_version': latest_tag,
'channel': channel,
}
@@ -876,7 +1027,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 +1040,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 +1059,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 +1077,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 +1087,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 +1112,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 +1134,48 @@ 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)
# The update channel is a WebUI-only concept. The Agent is a separate
# project that tags plain v* and legitimately tracks master past its
# tags; it must ALWAYS use the default channel regardless of the user's
# WebUI channel selection. (Codex gate: passing 'experimental' here made
# the Agent ignore its v* tags and fall back to origin/master.)
agent_info = _check_repo(_AGENT_DIR, 'agent', DEFAULT_UPDATE_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 +1661,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 +1672,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)
@@ -1505,6 +1694,8 @@ def apply_force_update(target: str) -> dict:
path = REPO_ROOT
elif target == 'agent':
path = _AGENT_DIR
# Channel is WebUI-only — the Agent always uses the default channel.
channel = DEFAULT_UPDATE_CHANNEL
else:
return {'ok': False, 'message': f'Unknown target: {target}'}
@@ -1533,8 +1724,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 +1809,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 +1821,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,12 +1861,16 @@ 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':
path = _AGENT_DIR
# Channel is WebUI-only — the Agent always uses the default channel
# regardless of the user's WebUI selection (see check_for_updates).
channel = DEFAULT_UPDATE_CHANNEL
else:
return {'ok': False, 'message': f'Unknown target: {target}'}
@@ -1666,7 +1895,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)
+60
View File
@@ -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 soak-tested, 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. Applies to WebUI updates only.',
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',
@@ -2537,6 +2541,10 @@ const LOCALES = {
settings_label_previous_messaging_sessions: 'Mostra sessioni di messaggistica precedenti',
settings_label_sync_insights: 'Sincronizza con insights',
settings_label_check_updates: 'Verifica aggiornamenti',
settings_label_update_channel: 'Canale di aggiornamento',
settings_update_channel_stable: 'Stabile (consigliato)',
settings_update_channel_experimental: 'Sperimentale',
settings_desc_update_channel: 'Stabile riceve release consolidate e testate. Sperimentale riceve ogni release appena esce: più veloce, ma può avere imperfezioni. Tornando a Stabile mantieni la versione attuale finché Stabile non la raggiunge. Si applica solo agli aggiornamenti della WebUI.',
settings_label_ignore_agent_updates: 'Ignore Agent updates',
settings_label_whats_new_summary: "Summarize What's New with AI",
settings_label_bot_name: 'Nome assistente predefinito',
@@ -4223,6 +4231,10 @@ const LOCALES = {
settings_label_previous_messaging_sessions: '以前のメッセージングセッションを表示',
settings_label_sync_insights: 'インサイトに同期',
settings_label_check_updates: 'アップデートを確認',
settings_label_update_channel: 'アップデートチャンネル',
settings_update_channel_stable: '安定版(推奨)',
settings_update_channel_experimental: '実験版',
settings_desc_update_channel: '安定版は熟成済みの実績あるリリースを受け取ります。実験版はリリースを出た直後に受け取ります。速いですが不具合が出ることがあります。安定版に戻しても、安定版が追いつくまで現在のバージョンを維持します。 WebUI の更新にのみ適用されます。',
settings_label_ignore_agent_updates: 'Agentのアップデートを無視',
settings_label_whats_new_summary: "What's New をAIで要約",
settings_label_bot_name: 'デフォルトのアシスタント名',
@@ -5617,6 +5629,10 @@ const LOCALES = {
settings_label_previous_messaging_sessions: 'Показывать предыдущие сеансы обмена сообщениями',
settings_label_sync_insights: 'Синхронизировать с Insights',
settings_label_check_updates: 'Проверять обновления',
settings_label_update_channel: 'Канал обновлений',
settings_update_channel_stable: 'Стабильный (рекомендуется)',
settings_update_channel_experimental: 'Экспериментальный',
settings_desc_update_channel: 'Стабильный канал получает проверенные, обкатанные релизы. Экспериментальный получает каждый релиз сразу — быстрее, но возможны шероховатости. При возврате на Стабильный ваша версия сохраняется, пока Стабильный не догонит её. Применяется только к обновлениям WebUI.',
settings_label_ignore_agent_updates: 'Ignore Agent updates',
settings_label_whats_new_summary: "Summarize What's New with AI",
settings_label_bot_name: 'Имя помощника по умолчанию',
@@ -7230,6 +7246,10 @@ const LOCALES = {
settings_label_previous_messaging_sessions: 'Mostrar sesiones de mensajería anteriores',
settings_label_sync_insights: 'Sincronizar con insights',
settings_label_check_updates: 'Buscar actualizaciones',
settings_label_update_channel: 'Canal de actualizaciones',
settings_update_channel_stable: 'Estable (recomendado)',
settings_update_channel_experimental: 'Experimental',
settings_desc_update_channel: 'Estable recibe versiones probadas y maduradas. Experimental recibe cada versión en cuanto sale: más rápido, pero puede tener asperezas. Al volver a Estable conservas tu versión actual hasta que Estable la alcance. Se aplica solo a las actualizaciones de la WebUI.',
settings_label_ignore_agent_updates: 'Ignore Agent updates',
settings_label_whats_new_summary: "Summarize What's New with AI",
settings_label_bot_name: 'Nombre predeterminado del asistente',
@@ -8831,6 +8851,10 @@ const LOCALES = {
settings_label_previous_messaging_sessions: 'Vorherige Messaging-Sitzungen anzeigen',
settings_label_sync_insights: 'Mit Insights synchronisieren',
settings_label_check_updates: 'Nach Updates suchen',
settings_label_update_channel: 'Update-Kanal',
settings_update_channel_stable: 'Stabil (empfohlen)',
settings_update_channel_experimental: 'Experimentell',
settings_desc_update_channel: 'Stabil erhält ausgereifte, bewährte Releases. Experimentell erhält jedes Release sofort — schneller, aber mit möglichen Ecken und Kanten. Beim Zurückwechseln zu Stabil behältst du deine aktuelle Version, bis Stabil aufholt. Gilt nur für WebUI-Updates.',
settings_label_ignore_agent_updates: 'Ignore Agent updates',
settings_label_whats_new_summary: "Summarize What's New with AI",
settings_label_bot_name: 'Standard-Assistentenname',
@@ -10497,6 +10521,10 @@ const LOCALES = {
settings_label_previous_messaging_sessions: '显示以前的消息会话',
settings_label_sync_insights: '同步到 insights',
settings_label_check_updates: '检查更新',
settings_label_update_channel: '更新通道',
settings_update_channel_stable: '稳定版(推荐)',
settings_update_channel_experimental: '实验版',
settings_desc_update_channel: '稳定版提供经过充分验证的成熟版本。实验版会在每个版本发布时立即提供——更快,但可能有粗糙之处。切回稳定版会保留当前版本,直到稳定版赶上。 仅适用于 WebUI 更新。',
settings_label_ignore_agent_updates: '忽略智能体更新',
settings_label_whats_new_summary: "用AI总结新功能",
settings_label_bot_name: '默认助手名称',
@@ -12404,6 +12432,10 @@ const LOCALES = {
settings_label_previous_messaging_sessions: '顯示以前的訊息對話',
settings_label_sync_insights: '同步至 insights',
settings_label_check_updates: '檢查更新',
settings_label_update_channel: '更新通道',
settings_update_channel_stable: '穩定版(建議)',
settings_update_channel_experimental: '實驗版',
settings_desc_update_channel: '穩定版提供經過充分驗證的成熟版本。實驗版會在每個版本發布時立即提供——更快,但可能有粗糙之處。切回穩定版會保留目前版本,直到穩定版趕上。 僅適用於 WebUI 更新。',
settings_label_ignore_agent_updates: '忽略 Agent 更新',
settings_label_whats_new_summary: '使用 AI 摘要新版內容',
settings_label_bot_name: '助理名稱',
@@ -13985,6 +14017,10 @@ const LOCALES = {
settings_label_previous_messaging_sessions: 'Mostrar sessões de mensagens anteriores',
settings_label_sync_insights: 'Sincronizar para insights',
settings_label_check_updates: 'Verificar atualizações',
settings_label_update_channel: 'Canal de atualização',
settings_update_channel_stable: 'Estável (recomendado)',
settings_update_channel_experimental: 'Experimental',
settings_desc_update_channel: 'Estável recebe versões testadas e amadurecidas. Experimental recebe cada versão assim que sai — mais rápido, mas pode ter arestas. Ao voltar para Estável você mantém sua versão atual até que o Estável a alcance. Aplica-se apenas a atualizações da WebUI.',
settings_label_ignore_agent_updates: 'Ignore Agent updates',
settings_label_whats_new_summary: "Summarize What's New with AI",
settings_label_bot_name: 'Nome padrão do assistente',
@@ -15570,6 +15606,10 @@ const LOCALES = {
settings_label_previous_messaging_sessions: '이전 메시징 세션 표시',
settings_label_sync_insights: 'Insights에 동기화',
settings_label_check_updates: '업데이트 확인',
settings_label_update_channel: '업데이트 채널',
settings_update_channel_stable: '안정 버전(권장)',
settings_update_channel_experimental: '실험 버전',
settings_desc_update_channel: '안정 채널은 충분히 검증된 릴리스를 받습니다. 실험 채널은 릴리스가 나오는 즉시 받습니다 — 더 빠르지만 거친 부분이 있을 수 있습니다. 안정 채널로 되돌려도 안정 채널이 따라잡을 때까지 현재 버전이 유지됩니다. WebUI 업데이트에만 적용됩니다.',
settings_label_ignore_agent_updates: 'Ignore Agent updates',
settings_label_whats_new_summary: "Summarize What's New with AI",
settings_label_bot_name: '기본 Assistant 이름',
@@ -17268,6 +17308,10 @@ const LOCALES = {
settings_label_previous_messaging_sessions: 'Afficher les sessions de messagerie précédentes',
settings_label_sync_insights: 'Synchroniser avec les analyses',
settings_label_check_updates: 'Vérifier les mises à jour',
settings_label_update_channel: 'Canal de mise à jour',
settings_update_channel_stable: 'Stable (recommandé)',
settings_update_channel_experimental: 'Expérimental',
settings_desc_update_channel: 'Stable reçoit des versions éprouvées et matures. Expérimental reçoit chaque version dès sa sortie — plus rapide, mais avec de possibles aspérités. En revenant sur Stable, vous conservez votre version actuelle jusqu\'à ce que Stable la rattrape. Ne s\'applique qu\'aux mises à jour de la WebUI.',
settings_label_ignore_agent_updates: 'Ignorer les mises à jour de l\'agent',
settings_label_whats_new_summary: "Résumer les nouveautés avec l'IA",
settings_label_bot_name: 'Nom de l\'assistant par défaut',
@@ -19058,6 +19102,10 @@ const LOCALES = {
settings_label_bot_name: 'Výchozí název asistenta',
settings_label_chat_activity_display_mode: 'Zobrazení aktivity',
settings_label_check_updates: 'Kontrolovat aktualizace',
settings_label_update_channel: 'Kanál aktualizací',
settings_update_channel_stable: 'Stabilní (doporučeno)',
settings_update_channel_experimental: 'Experimentální',
settings_desc_update_channel: 'Stabilní kanál dostává ověřená, prověřená vydání. Experimentální dostává každé vydání hned — rychlejší, ale může mít nedodělky. Při přepnutí zpět na Stabilní si ponecháte svou současnou verzi, dokud ji Stabilní nedožene. Platí pouze pro aktualizace WebUI.',
settings_label_claude_code_sessions: 'Zobrazit Claude Code relace',
settings_label_composer_controls: 'Ovládací prvky zápatí chatu',
settings_label_composer_situational_controls: 'Situační ovládací prvky zápatí',
@@ -20567,6 +20615,10 @@ const LOCALES = {
settings_label_previous_messaging_sessions: 'Önceki mesajlaşma oturumlarını göster',
settings_label_sync_insights: 'Analizlerle senkronize edin',
settings_label_check_updates: 'Güncellemeleri kontrol edin',
settings_label_update_channel: 'Güncelleme kanalı',
settings_update_channel_stable: 'Kararlı (önerilir)',
settings_update_channel_experimental: 'Deneysel',
settings_desc_update_channel: 'Kararlı kanal olgunlaşmış, kanıtlanmış sürümleri alır. Deneysel kanal her sürümü çıktığı anda alır — daha hızlı ama pürüzler olabilir. Kararlı\'ya geri dönmek, Kararlı yetişene kadar mevcut sürümünüzü korur. Yalnızca WebUI güncellemeleri için geçerlidir.',
settings_label_whats_new_summary: "Summarize What's New with AI",
settings_label_bot_name: 'Varsayılan asistanın adı',
settings_label_password: 'Erişim Şifresi',
@@ -22267,6 +22319,10 @@ const LOCALES = {
settings_label_previous_messaging_sessions: 'Pokaż poprzednie sesje komunikatorów',
settings_label_sync_insights: 'Synchronizuj z Insights',
settings_label_check_updates: 'Sprawdzaj aktualizacje',
settings_label_update_channel: 'Kanał aktualizacji',
settings_update_channel_stable: 'Stabilny (zalecany)',
settings_update_channel_experimental: 'Eksperymentalny',
settings_desc_update_channel: 'Kanał stabilny otrzymuje sprawdzone, dojrzałe wydania. Eksperymentalny otrzymuje każde wydanie od razu — szybciej, ale mogą wystąpić niedoróbki. Powrót na Stabilny zachowuje bieżącą wersję, dopóki Stabilny jej nie dogoni. Dotyczy tylko aktualizacji WebUI.',
settings_label_ignore_agent_updates: 'Ignoruj aktualizacje agenta',
settings_label_whats_new_summary: 'Podsumuj Co Nowego za pomocą AI',
settings_label_bot_name: 'Domyślna nazwa asystenta',
@@ -23795,6 +23851,10 @@ const LOCALES = {
settings_label_previous_messaging_sessions: 'Hiện các phiên nhắn tin trước đó',
settings_label_sync_insights: 'Đồng bộ sang insights',
settings_label_check_updates: 'Kiểm tra cập nhật',
settings_label_update_channel: 'Kênh cập nhật',
settings_update_channel_stable: 'Ổn định (khuyến nghị)',
settings_update_channel_experimental: 'Thử nghiệm',
settings_desc_update_channel: 'Kênh Ổn định nhận các bản phát hành đã được kiểm chứng kỹ. Kênh Thử nghiệm nhận mọi bản phát hành ngay khi có — nhanh hơn nhưng có thể còn lỗi. Khi chuyển về Ổn định, bạn giữ phiên bản hiện tại cho đến khi Ổn định bắt kịp. Chỉ áp dụng cho các bản cập nhật WebUI.',
settings_label_ignore_agent_updates: 'Bỏ qua cập nhật Agent',
settings_label_whats_new_summary: "Tóm tắt What's New bằng AI",
settings_label_bot_name: 'Tên assistant mặc định',
+8
View File
@@ -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 soak-tested, 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. Applies to WebUI updates only.</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)">
+57 -3
View File
@@ -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,24 @@ 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'){
// Pass the just-selected channel EXPLICITLY so the re-check cannot race
// the debounced autosave PUT and answer for the previous channel.
const _picked=updateChannelSel.value;
setTimeout(function(){try{checkUpdatesNow(_picked);}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,7 +11735,21 @@ function _applySavedSettingsUi(saved, body, opts){
if(typeof renderSessionList==='function') renderSessionList();
}
async function checkUpdatesNow(){
// 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(channelOverride){
const btn=$('btnCheckUpdatesNow');
const label=$('checkUpdatesLabel');
const spinner=$('checkUpdatesSpinner');
@@ -11714,8 +11760,15 @@ async function checkUpdatesNow(){
if(spinner) spinner.style.display='';
if(label) label.textContent=t('settings_checking');
if(status) status.textContent='';
try {
const data=await api('/api/updates/check',{method:'POST',body:JSON.stringify({force:true}),timeoutMs:60000});
// Pass the channel explicitly when the caller has one (e.g. the dropdown
// just switched) so the check cannot race the debounced settings autosave
// and answer for the previous channel. Omit otherwise → server uses the
// saved setting. (Fable UX gate.)
const _checkBody={force:true};
if(channelOverride==='stable'||channelOverride==='experimental') _checkBody.channel=channelOverride;
const data=await api('/api/updates/check',{method:'POST',body:JSON.stringify(_checkBody),timeoutMs:60000});
if(data.disabled){
if(status){status.textContent=t('settings_updates_disabled');status.style.color='var(--muted)';}
} else {
@@ -12268,6 +12321,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;
+1 -1
View File
@@ -5119,7 +5119,7 @@ main.main > #mainPlugin{display:none;}
.settings-section-head{flex-direction:column;align-items:flex-start;gap:8px;}
.settings-section-title{font-size:16px;}
#checkUpdatesBlock{flex-wrap:wrap;row-gap:6px;width:100%;}
.settings-version-badge{white-space:nowrap;}
.settings-version-badge{white-space:nowrap;max-width:100%;overflow:hidden;text-overflow:ellipsis;display:block;}
.hermes-action-grid{grid-template-columns:1fr;}
#mainSettings .settings-field{padding:14px;}
#mainSettings .chat-activity-mode-toggle{grid-template-columns:1fr;}
+10 -2
View File
@@ -9325,7 +9325,15 @@ async function applyUpdates(){
const stashConflictMessages=[];
const baselineServerIdentity = await _readHealthServerIdentity();
for(const target of targets){
const res=await api('/api/updates/apply',{method:'POST',body:JSON.stringify({target}),timeoutMs:120000});
// Send the channel the CHECK reported for this target (what was actually
// offered in the banner), not a fresh settings read — otherwise a channel
// switch whose debounced autosave hasn't landed yet races apply, which
// would then read the OLD saved channel (Codex gate). webui carries the
// channel; agent is channel-neutral server-side so omitting it is fine.
const _applyBody={target};
const _ch=window._updateData?.[target]?.channel;
if(_ch==='stable'||_ch==='experimental') _applyBody.channel=_ch;
const res=await api('/api/updates/apply',{method:'POST',body:JSON.stringify(_applyBody),timeoutMs:120000});
if(!res.ok){
_showUpdateError(target,res);
resetApplyButton(0);
@@ -9527,7 +9535,7 @@ async function forceUpdate(btn){
if(errEl){errEl.style.display='none';}
try{
const baselineServerIdentity = await _readHealthServerIdentity();
const res=await api('/api/updates/force',{method:'POST',body:JSON.stringify({target}),timeoutMs:120000});
const res=await api('/api/updates/force',{method:'POST',body:JSON.stringify((()=>{const b={target};const _ch=window._updateData?.[target]?.channel;if(_ch==='stable'||_ch==='experimental')b.channel=_ch;return b;})()),timeoutMs:120000});
if(!res.ok){
if(errEl){errEl.textContent='Force update failed: '+(res.message||'unknown error');errEl.style.display='block';}
btn.disabled=false;btn.textContent='Force update';
+7 -3
View File
@@ -211,10 +211,14 @@ def test_update_flows_keep_explicit_longer_timeouts():
"""Legitimately long update flows should not inherit the generic 30s guard."""
src = _source(UI_JS)
panels = _source(PANELS_JS)
assert "api('/api/updates/check',{method:'POST',body:JSON.stringify({force:true}),timeoutMs:60000})" in panels
# /api/updates/check builds its body in a _checkBody var (to optionally add
# an explicit channel), but must still carry the 60s timeout override.
assert "api('/api/updates/check',{method:'POST',body:JSON.stringify(_checkBody),timeoutMs:60000})" in panels
assert "api('/api/updates/summary',{method:'POST',body:JSON.stringify({updates:scopedUpdates,target:target||null}),timeoutMs:60000})" in src
assert "api('/api/updates/apply',{method:'POST',body:JSON.stringify({target}),timeoutMs:120000})" in src
assert "api('/api/updates/force',{method:'POST',body:JSON.stringify({target}),timeoutMs:120000})" in src
# apply/force now build their body inline to optionally carry the offered
# channel (Codex debounce-race fix), but MUST still carry the 120s override.
assert "api('/api/updates/apply',{method:'POST',body:JSON.stringify(_applyBody),timeoutMs:120000})" in src
assert "api('/api/updates/force',{method:'POST',body:JSON.stringify((()=>{const b={target};const _ch=window._updateData?.[target]?.channel;if(_ch==='stable'||_ch==='experimental')b.channel=_ch;return b;})()),timeoutMs:120000})" in src
def test_session_message_loads_keep_explicit_longer_timeouts():
+1 -1
View File
@@ -118,7 +118,7 @@ def test_post_update_check_performs_forced_fetch(monkeypatch):
monkeypatch.setattr(routes, "load_settings", lambda: {"check_for_updates": True})
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
def fake_check(*, force=False, include_agent=True):
def fake_check(*, force=False, include_agent=True, channel="stable"):
calls.append((force, include_agent))
return {"checked_at": 456, "webui": None, "agent": None}
+287
View File
@@ -0,0 +1,287 @@
"""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
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'
def test_agent_resolution_identical_under_both_webui_channels(tmp_path, monkeypatch):
"""Codex gate: the update CHANNEL is WebUI-only. An Agent repo that tags only
plain v* must resolve release/apply IDENTICALLY whether the user's WebUI
channel is 'stable' or 'experimental' — the webui channel must never leak
into the agent check (which would make the agent ignore its v* tags and fall
back to origin/master)."""
agent = tmp_path / 'agent'
agent.mkdir()
_git(agent, 'init', '-q')
_git(agent, 'config', 'user.email', 't@t.co')
_git(agent, 'config', 'user.name', 'Test')
_git(agent, 'remote', 'add', 'origin', 'https://github.com/nesquena/hermes-agent.git')
_git(agent, 'commit', '-q', '--allow-empty', '-m', 'a1')
_git(agent, 'tag', 'v1.0.0')
_git(agent, 'commit', '-q', '--allow-empty', '-m', 'a2')
_git(agent, 'tag', 'v1.0.3') # agent has ONLY plain v* tags, no exp-v*
_git(agent, 'checkout', '-q', 'v1.0.0') # behind by one release
# check_for_updates threads the user's WebUI channel; the agent leg must
# ignore it. Compare agent payloads under stable vs experimental WebUI.
monkeypatch.setattr(updates, 'REPO_ROOT', tmp_path / 'nonexistent-webui')
monkeypatch.setattr(updates, '_AGENT_DIR', agent)
def fresh_cache():
monkeypatch.setitem(updates._update_cache, 'checked_at', 0)
fresh_cache()
stable = updates.check_for_updates(force=True, include_agent=True, channel='stable')['agent']
fresh_cache()
experimental = updates.check_for_updates(force=True, include_agent=True, channel='experimental')['agent']
# Agent must resolve its v1.0.3 release identically on both — never None /
# origin/master (which is what leaking 'experimental' into the agent caused).
assert stable.get('latest_version') == 'v1.0.3'
assert experimental.get('latest_version') == 'v1.0.3'
assert stable.get('behind') == experimental.get('behind') == 1
# Apply-ref selection for the agent is channel-independent because the apply
# wrappers force DEFAULT_UPDATE_CHANNEL for target=='agent'. Verified at the
# raw layer with the default (stable) channel — the agent's v* tag resolves.
assert updates._select_apply_compare_ref(agent, 'stable', 'agent') == 'v1.0.3'
# ── 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
# ── Lock-recovery preserves the channel (Codex round-2 SILENT) ───────────────
def test_clear_lock_retry_preserves_experimental_channel(tmp_path, monkeypatch):
"""apply_clear_lock re-runs the normal update once the lock is gone. It must
pass the configured channel through — otherwise an experimental WebUI
lock-recovery retry silently falls back to stable (_apply_update_inner
defaults to stable)."""
(tmp_path / '.git').mkdir()
monkeypatch.setattr(updates, 'REPO_ROOT', tmp_path)
monkeypatch.setattr(
updates, '_restart_blocker_snapshot',
lambda: {'restart_blocked': False, 'active_streams': 0, 'active_runs': 0},
)
# No lock present → clear-lock takes the "re-run normal update" branch.
monkeypatch.setattr(
updates, '_inventory_locks',
lambda path: {'well_known_lock_present': False,
'well_known_lock_path': str(tmp_path / '.git/index.lock'),
'other_locks': []},
)
# User's configured channel is experimental.
monkeypatch.setattr(updates, '_read_update_channel', lambda: 'experimental')
seen = {}
def fake_inner(target, channel='stable'):
seen['channel'] = channel
return {'ok': True, 'target': target, 'channel': channel}
monkeypatch.setattr(updates, '_apply_update_inner', fake_inner)
result = updates.apply_clear_lock('webui')
assert seen.get('channel') == 'experimental', (
'clear-lock retry must preserve the experimental channel, not default to stable'
)
assert result['ok'] is True
assert result['lock_recovery']['action'] == 'no-lock-found'
+8
View File
@@ -56,6 +56,10 @@ def test_apply_force_update_removes_untracked_files_before_reset(tmp_path):
return '', True
if args == ['clean', '-fd']:
return '', True
if args[:2] == ['merge-base', '--is-ancestor']:
# rewind guard probe: origin/master is NOT an ancestor of HEAD
# (it's the update target ahead of HEAD) -> allow the reset.
return '', False
if args == ['reset', '--hard', 'origin/master']:
return '', True
raise AssertionError(f'unexpected git args: {args!r}')
@@ -95,6 +99,10 @@ def test_apply_force_update_proceeds_when_clean_fails(tmp_path):
return '', True
if args == ['clean', '-fd']:
return 'warning: failed to remove nul: Invalid argument', False
if args[:2] == ['merge-base', '--is-ancestor']:
# rewind guard probe: origin/master is NOT an ancestor of HEAD
# (it's the update target ahead of HEAD) -> allow the reset.
return '', False
if args == ['reset', '--hard', 'origin/master']:
return '', True
raise AssertionError(f'unexpected git args: {args!r}')
+34 -34
View File
@@ -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')