HERMES-GIELIE/api/updates.py

1522 lines
62 KiB
Python
Raw Normal View History

"""
Hermes Web UI -- Self-update checker.
2026-05-12 14:52:12 +00:00
Checks if the webui and hermes-agent git repos are behind their latest
release tags. Results are cached server-side (30-min TTL) so git fetch runs
at most twice per hour regardless of client count.
Skips repos that are not git checkouts (e.g. Docker baked images where
.git does not exist).
"""
2026-05-13 21:48:01 +00:00
import hashlib
import json
import logging
import os
2026-05-13 21:48:01 +00:00
import re
import shutil
import subprocess
import threading
import time
import urllib.error
import urllib.request
from collections import OrderedDict
from pathlib import Path
from urllib.parse import urlparse
from api.config import REPO_ROOT, STREAMS, STREAMS_LOCK
logger = logging.getLogger(__name__)
# Lazy -- may be None if agent not found
try:
from api.config import _AGENT_DIR
except ImportError:
_AGENT_DIR = None
2026-05-24 14:20:31 +00:00
_update_cache = {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True}
_SUMMARY_CACHE_MAX = 16
_summary_cache: OrderedDict = OrderedDict()
_cache_lock = threading.Lock()
_check_in_progress = False
_apply_lock = threading.Lock() # prevents concurrent stash/pull/pop on same repo
CACHE_TTL = 1800 # 30 minutes
_GIT_DIAGNOSTIC_MAX_CHARS = 300
_CREDENTIAL_IN_URL_RE = re.compile(r"([a-zA-Z][a-zA-Z0-9+.-]*://)([^/@\s'\"]+)@")
_GITHUB_TOKEN_RE = re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b")
_QUERY_SECRET_RE = re.compile(r"([?&](?:access_token|token|password|auth|key)=)[^&\s'\"]+", re.IGNORECASE)
def _sanitize_git_diagnostic(output: str, *, limit: int = _GIT_DIAGNOSTIC_MAX_CHARS) -> str:
"""Return a user-facing git diagnostic with credentials removed.
Git can echo remote URLs in failure output. Keep the actionable error text,
but strip URL userinfo, common GitHub token shapes, and secret-looking query
parameter values before any message reaches the update-check API/UI.
"""
if not output:
return ""
sanitized = _CREDENTIAL_IN_URL_RE.sub(r"\1<redacted>@", str(output))
sanitized = _GITHUB_TOKEN_RE.sub("<redacted>", sanitized)
sanitized = _QUERY_SECRET_RE.sub(r"\1<redacted>", sanitized)
sanitized = sanitized.strip()
if len(sanitized) > limit:
sanitized = sanitized[:limit].rstrip() + "…"
return sanitized
def _restart_blocker_snapshot() -> dict:
"""Return active chat work that should block a self-restart."""
with STREAMS_LOCK:
stream_ids = [str(k) for k in STREAMS.keys()]
run_ids: list[str] = []
try:
from api import config as _config
active_runs = getattr(_config, 'ACTIVE_RUNS', {})
active_runs_lock = getattr(_config, 'ACTIVE_RUNS_LOCK', None)
if active_runs_lock is not None:
with active_runs_lock:
run_ids = [str(k) for k in active_runs.keys()]
else:
run_ids = [str(k) for k in active_runs.keys()]
except Exception:
run_ids = []
return {
'active_streams': len(stream_ids),
'active_runs': len(run_ids),
'blocking_stream_ids': stream_ids[:10],
'blocking_run_ids': run_ids[:10],
'restart_blocked': bool(stream_ids or run_ids),
}
def _active_stream_count() -> int:
"""Return the current in-memory chat stream count.
Kept for compatibility with older tests/helpers; restart safety should use
``_restart_blocker_snapshot()`` so detached worker runs also block updates.
"""
return int(_restart_blocker_snapshot().get('active_streams') or 0)
def _restart_blocked_response(target: str, blocker_snapshot: dict | int) -> dict:
if isinstance(blocker_snapshot, int):
blocker_snapshot = {
'active_streams': blocker_snapshot,
'active_runs': 0,
'blocking_stream_ids': [],
'blocking_run_ids': [],
'restart_blocked': bool(blocker_snapshot),
}
active_streams = int(blocker_snapshot.get('active_streams') or 0)
active_runs = int(blocker_snapshot.get('active_runs') or 0)
parts = []
if active_streams:
parts.append(f"{active_streams} active chat stream{'s' if active_streams != 1 else ''}")
if active_runs:
parts.append(f"{active_runs} active agent run{'s' if active_runs != 1 else ''}")
detail = ' and '.join(parts) or 'active chat work'
return {
'ok': False,
'message': (
f'Cannot update {target} while {detail} is running. '
'Wait for the response to finish, then retry the update.'
),
'target': target,
'restart_blocked': True,
'active_streams': active_streams,
'active_runs': active_runs,
'blocking_stream_ids': blocker_snapshot.get('blocking_stream_ids') or [],
'blocking_run_ids': blocker_snapshot.get('blocking_run_ids') or [],
}
def _wait_until_restart_safe(poll_seconds: float = 2.0, max_wait_seconds: float = 300.0) -> dict:
"""Wait for active work to finish before self-reexec.
Bounded by ``max_wait_seconds`` so a long-running (or stuck/orphaned) agent
run can't soft-jam the self-update indefinitely. If the deadline is reached
while work is still in flight, the snapshot is returned with
``wait_timed_out=True`` so the caller can proceed with the re-exec anyway
(preserving the pre-#3105 "execv preempts in-flight work" fallback) rather
than holding ``_apply_lock`` for the run's full lifetime.
"""
snapshot = _restart_blocker_snapshot()
deadline = time.monotonic() + max(0.0, max_wait_seconds)
while snapshot.get('restart_blocked'):
if time.monotonic() >= deadline:
logger.warning(
"restart-safety wait exceeded %.0fs with work still in flight (%s); "
"proceeding with re-exec anyway",
max_wait_seconds, snapshot,
)
snapshot = dict(snapshot)
snapshot['wait_timed_out'] = True
return snapshot
time.sleep(max(0.1, poll_seconds))
snapshot = _restart_blocker_snapshot()
return snapshot
def _run_git(args, cwd, timeout=10):
"""Run a git command and return (useful output, ok).
On failure, returns stderr (or stdout as fallback) so callers can
surface actionable git error messages instead of empty strings.
"""
try:
r = subprocess.run(
['git'] + args, cwd=str(cwd), capture_output=True,
text=True, timeout=timeout,
encoding='utf-8', errors='replace',
)
# On non-UTF-8 locales (e.g. Chinese Windows GBK), a binary git
# output that fails to decode used to leave r.stdout = None and crash
# the whole import with AttributeError. Guard against None defensively.
stdout = (r.stdout or '').strip()
stderr = (r.stderr or '').strip()
if r.returncode == 0:
return stdout, True
return stderr or stdout or f"git exited with status {r.returncode}", False
except subprocess.TimeoutExpired as exc:
detail = (getattr(exc, 'stderr', None) or getattr(exc, 'stdout', None) or '').strip()
return detail or f"git {' '.join(args)} timed out after {timeout}s", False
except FileNotFoundError:
return 'git executable not found', False
except OSError as exc:
return f'git failed to start: {exc}', False
2026-05-12 14:52:12 +00:00
def _dirty_suffix(path: Path, timeout=1) -> str:
"""Return a best-effort ``-dirty`` suffix without blocking version display."""
out, ok = _run_git(['diff-index', '--quiet', 'HEAD', '--'], path, timeout=timeout)
if ok:
return ""
# diff-index --quiet exits 1 with no stdout/stderr to *signal* a dirty tree
# (not an error). _run_git() substitutes a synthetic "git exited with
# status N" diagnostic when both streams are empty, which makes the naive
# `if not out` guard always false on dirty trees — silently dropping the
# suffix and defeating dev-build cache busting (static/foo.js?v=… stays
# identical to the last-committed version). Treat the synthetic shape as
# the dirty signal; real errors (timeouts, missing git) carry a different
# diagnostic and correctly suppress the suffix.
if not out or out.startswith('git exited with status '):
2026-05-30 09:25:33 +00:00
diff, diff_ok = _run_git(['diff', '--binary', 'HEAD', '--'], path, timeout=timeout)
if diff_ok and diff:
digest = hashlib.sha1(diff.encode('utf-8', errors='replace')).hexdigest()[:8]
return f"-dirty-{digest}"
return "-dirty"
return ""
2026-05-12 14:52:12 +00:00
def _describe_git_version(path: Path, *, timeout=5, dirty_timeout=1) -> str | None:
"""Return a fast git version string for a checkout, if available."""
out, ok = _run_git(['describe', '--tags', '--always'], path, timeout=timeout)
if not (ok and out):
return None
return out + _dirty_suffix(path, timeout=dirty_timeout)
fix: dynamic version badge — read from git tag, never hardcoded (#790) * fix: dynamic version badge — read from git tag, never hardcoded The settings panel showed v0.50.87 and the HTTP Server: header said HermesWebUI/0.50.38 — both hardcoded strings that drift further behind with every release because there was no mechanism to keep them in sync. Changes: - api/updates.py: add _run_git() (moved before _detect_webui_version), _detect_webui_version(), and WEBUI_VERSION module constant resolved once at import time via 'git describe --tags --always --dirty'. Fallback chain: git → api/_version.py → 'unknown'. - api/routes.py: inject webui_version into GET /api/settings response so the frontend can read it without a separate API call. - static/panels.js: loadSettingsPanel() populates .settings-version-badge from settings.webui_version — one line after the existing api() call. - static/index.html: replace stale hardcoded 'v0.50.87' with '—' placeholder; JS overwrites it as soon as the settings panel opens. - server.py: replace hardcoded 'HermesWebUI/0.50.38' server_version with 'HermesWebUI/' + WEBUI_VERSION.lstrip('v') — stays in sync automatically. - Dockerfile: add ARG HERMES_VERSION=unknown and write api/_version.py so Docker images (where .git is excluded) still show the correct tag. - .github/workflows/release.yml: pass build-args: HERMES_VERSION=${{ github.ref_name }} to the Docker build step on tag pushes. - .gitignore: exclude api/_version.py (generated by Docker/CI, never committed). No manual 'update the version badge' step is required going forward. Tagging is sufficient — the badge and HTTP header update automatically. Tests: 18 new tests in tests/test_version_badge.py covering the full resolution chain, /api/settings injection, HTML placeholder, JS wiring, and server.py import. 1596 tests pass total. * fix: address review feedback on PR #790 - api/updates.py: replace exec() with regex parse for api/_version.py (no supply-chain risk from build artifact; exec unnecessary for one assignment) - api/updates.py: cap git describe timeout at 3s (was 10s — import-time stall on NFS/.git would block server startup unnecessarily) - server.py: lstrip('v') → removeprefix('v') (lstrip strips chars not prefix) - server.py: emit bare 'HermesWebUI' when version is 'unknown' rather than 'HermesWebUI/unknown' (log aggregators expect semver-ish suffix or none) - CHANGELOG.md: add v0.50.124 entry for this user-visible change - tests: rename exec-error test to reflect regex behaviour; add tests for removeprefix usage and unknown-version header guard (1598 tests total) --------- Co-authored-by: nesquena-hermes <hermes@nesquena.com>
2026-04-21 03:36:53 +00:00
def _detect_webui_version() -> str:
"""Detect the running WebUI version from git or a baked-in fallback file.
Resolution order:
1. ``git describe --tags --always --dirty`` — works in any git checkout.
Returns the exact tag on tagged commits (e.g. ``v0.50.124``), a
post-tag descriptor between releases (e.g. ``v0.50.124-1-ge91325d``),
or a bare SHA when no tags exist (shallow clones, fresh forks).
2. ``api/_version.py`` — a fallback written by the Docker / CI release
workflow when ``.git`` is not present in the image. Expected to define
``__version__ = 'vX.Y.Z'``.
3. ``'unknown'`` — last resort; displayed as-is in the settings badge.
"""
# Timeout capped at 3s: git describe on a healthy local repo is <50ms;
# a 10s stall on import (NFS-mounted .git, broken git binary) is unacceptable.
2026-05-12 14:52:12 +00:00
out = _describe_git_version(REPO_ROOT)
if out:
fix: dynamic version badge — read from git tag, never hardcoded (#790) * fix: dynamic version badge — read from git tag, never hardcoded The settings panel showed v0.50.87 and the HTTP Server: header said HermesWebUI/0.50.38 — both hardcoded strings that drift further behind with every release because there was no mechanism to keep them in sync. Changes: - api/updates.py: add _run_git() (moved before _detect_webui_version), _detect_webui_version(), and WEBUI_VERSION module constant resolved once at import time via 'git describe --tags --always --dirty'. Fallback chain: git → api/_version.py → 'unknown'. - api/routes.py: inject webui_version into GET /api/settings response so the frontend can read it without a separate API call. - static/panels.js: loadSettingsPanel() populates .settings-version-badge from settings.webui_version — one line after the existing api() call. - static/index.html: replace stale hardcoded 'v0.50.87' with '—' placeholder; JS overwrites it as soon as the settings panel opens. - server.py: replace hardcoded 'HermesWebUI/0.50.38' server_version with 'HermesWebUI/' + WEBUI_VERSION.lstrip('v') — stays in sync automatically. - Dockerfile: add ARG HERMES_VERSION=unknown and write api/_version.py so Docker images (where .git is excluded) still show the correct tag. - .github/workflows/release.yml: pass build-args: HERMES_VERSION=${{ github.ref_name }} to the Docker build step on tag pushes. - .gitignore: exclude api/_version.py (generated by Docker/CI, never committed). No manual 'update the version badge' step is required going forward. Tagging is sufficient — the badge and HTTP header update automatically. Tests: 18 new tests in tests/test_version_badge.py covering the full resolution chain, /api/settings injection, HTML placeholder, JS wiring, and server.py import. 1596 tests pass total. * fix: address review feedback on PR #790 - api/updates.py: replace exec() with regex parse for api/_version.py (no supply-chain risk from build artifact; exec unnecessary for one assignment) - api/updates.py: cap git describe timeout at 3s (was 10s — import-time stall on NFS/.git would block server startup unnecessarily) - server.py: lstrip('v') → removeprefix('v') (lstrip strips chars not prefix) - server.py: emit bare 'HermesWebUI' when version is 'unknown' rather than 'HermesWebUI/unknown' (log aggregators expect semver-ish suffix or none) - CHANGELOG.md: add v0.50.124 entry for this user-visible change - tests: rename exec-error test to reflect regex behaviour; add tests for removeprefix usage and unknown-version header guard (1598 tests total) --------- Co-authored-by: nesquena-hermes <hermes@nesquena.com>
2026-04-21 03:36:53 +00:00
return out
# Docker / baked-image fallback: api/_version.py written by CI at build time.
# Parse with regex rather than exec() — the file holds exactly one assignment
# and regex is sufficient; exec() on a build artifact is an unnecessary surface.
version_file = REPO_ROOT / 'api' / '_version.py'
if version_file.exists():
try:
import re as _re
m = _re.search(
r"""__version__\s*=\s*['"]([^'"]+)['"]""",
version_file.read_text(encoding='utf-8'),
)
if m:
return m.group(1)
except Exception:
pass
return 'unknown'
def _read_agent_source_version(agent_dir: Path) -> str | None:
"""Read Hermes Agent's package version from a copied source tree."""
init_file = agent_dir / 'hermes_cli' / '__init__.py'
try:
text = init_file.read_text(encoding='utf-8')
except (OSError, UnicodeDecodeError):
return None
m = re.search(r"""__version__\s*=\s*['"]([^'"]+)['"]""", text)
if m and m.group(1).strip():
return m.group(1).strip()
return None
def _gateway_health_base_url() -> str:
"""Return the configured/default Hermes Agent gateway base URL."""
raw = (
os.environ.get('GATEWAY_HEALTH_URL')
or os.environ.get('HERMES_GATEWAY_HEALTH_URL')
or 'http://hermes-agent:8642'
).strip()
if raw.endswith('/health/detailed'):
raw = raw[: -len('/health/detailed')]
elif raw.endswith('/health'):
raw = raw[: -len('/health')]
return raw.rstrip('/')
def _version_from_gateway_health_payload(payload: object) -> str | None:
"""Extract a version string from a Hermes Agent gateway health payload."""
if not isinstance(payload, dict):
return None
for key in ('version', 'agent_version', 'hermes_version'):
value = payload.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
nested = payload.get('agent')
if isinstance(nested, dict):
value = nested.get('version')
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _detect_agent_version_from_gateway_health(timeout: float = 0.75) -> str | None:
"""Best-effort cross-container gateway API fallback for Agent version."""
base = _gateway_health_base_url()
if not base:
return None
parsed = urlparse(base)
if parsed.scheme not in ('http', 'https') or not parsed.netloc:
return None
for path in ('/health', '/health/detailed'):
try:
with urllib.request.urlopen(f'{base}{path}', timeout=timeout) as resp:
payload = json.loads(resp.read().decode('utf-8'))
except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError, UnicodeDecodeError):
continue
version = _version_from_gateway_health_payload(payload)
if version:
return version
return None
2026-05-04 15:46:44 +00:00
def _detect_agent_version() -> str:
"""Detect the running Hermes Agent version for UI display."""
agent_dir = Path(_AGENT_DIR) if _AGENT_DIR is not None else None
2026-05-04 15:46:44 +00:00
if agent_dir is not None:
version_file = agent_dir / "VERSION"
try:
if version_file.exists():
text = version_file.read_text(encoding='utf-8').strip()
if text:
return text
except Exception:
pass
# Fallback: infer from git describe when the checkout exists but no VERSION
# file is available (common in source checkouts and developer environments).
if agent_dir.exists():
# Symmetric with _detect_webui_version() above — `--dirty` flags a
# locally-modified checkout so operators can see when their agent has
# uncommitted changes vs a clean tag. Per Opus advisor on stage-293.
out = _describe_git_version(agent_dir)
if out:
return out
# Docker two-container deployments often mount a copied agent source
# tree without .git metadata or a VERSION file. The package version
# still lives in hermes_cli/__init__.py, so prefer that before giving
# up or relying on a live gateway probe.
source_version = _read_agent_source_version(agent_dir)
if source_version:
return source_version
gateway_version = _detect_agent_version_from_gateway_health()
if gateway_version:
return gateway_version
2026-05-04 15:46:44 +00:00
return 'not detected'
fix: dynamic version badge — read from git tag, never hardcoded (#790) * fix: dynamic version badge — read from git tag, never hardcoded The settings panel showed v0.50.87 and the HTTP Server: header said HermesWebUI/0.50.38 — both hardcoded strings that drift further behind with every release because there was no mechanism to keep them in sync. Changes: - api/updates.py: add _run_git() (moved before _detect_webui_version), _detect_webui_version(), and WEBUI_VERSION module constant resolved once at import time via 'git describe --tags --always --dirty'. Fallback chain: git → api/_version.py → 'unknown'. - api/routes.py: inject webui_version into GET /api/settings response so the frontend can read it without a separate API call. - static/panels.js: loadSettingsPanel() populates .settings-version-badge from settings.webui_version — one line after the existing api() call. - static/index.html: replace stale hardcoded 'v0.50.87' with '—' placeholder; JS overwrites it as soon as the settings panel opens. - server.py: replace hardcoded 'HermesWebUI/0.50.38' server_version with 'HermesWebUI/' + WEBUI_VERSION.lstrip('v') — stays in sync automatically. - Dockerfile: add ARG HERMES_VERSION=unknown and write api/_version.py so Docker images (where .git is excluded) still show the correct tag. - .github/workflows/release.yml: pass build-args: HERMES_VERSION=${{ github.ref_name }} to the Docker build step on tag pushes. - .gitignore: exclude api/_version.py (generated by Docker/CI, never committed). No manual 'update the version badge' step is required going forward. Tagging is sufficient — the badge and HTTP header update automatically. Tests: 18 new tests in tests/test_version_badge.py covering the full resolution chain, /api/settings injection, HTML placeholder, JS wiring, and server.py import. 1596 tests pass total. * fix: address review feedback on PR #790 - api/updates.py: replace exec() with regex parse for api/_version.py (no supply-chain risk from build artifact; exec unnecessary for one assignment) - api/updates.py: cap git describe timeout at 3s (was 10s — import-time stall on NFS/.git would block server startup unnecessarily) - server.py: lstrip('v') → removeprefix('v') (lstrip strips chars not prefix) - server.py: emit bare 'HermesWebUI' when version is 'unknown' rather than 'HermesWebUI/unknown' (log aggregators expect semver-ish suffix or none) - CHANGELOG.md: add v0.50.124 entry for this user-visible change - tests: rename exec-error test to reflect regex behaviour; add tests for removeprefix usage and unknown-version header guard (1598 tests total) --------- Co-authored-by: nesquena-hermes <hermes@nesquena.com>
2026-04-21 03:36:53 +00:00
# Resolved once at import time — tags cannot change without a process restart.
WEBUI_VERSION: str = _detect_webui_version()
2026-05-04 15:46:44 +00:00
AGENT_VERSION: str = _detect_agent_version()
fix: dynamic version badge — read from git tag, never hardcoded (#790) * fix: dynamic version badge — read from git tag, never hardcoded The settings panel showed v0.50.87 and the HTTP Server: header said HermesWebUI/0.50.38 — both hardcoded strings that drift further behind with every release because there was no mechanism to keep them in sync. Changes: - api/updates.py: add _run_git() (moved before _detect_webui_version), _detect_webui_version(), and WEBUI_VERSION module constant resolved once at import time via 'git describe --tags --always --dirty'. Fallback chain: git → api/_version.py → 'unknown'. - api/routes.py: inject webui_version into GET /api/settings response so the frontend can read it without a separate API call. - static/panels.js: loadSettingsPanel() populates .settings-version-badge from settings.webui_version — one line after the existing api() call. - static/index.html: replace stale hardcoded 'v0.50.87' with '—' placeholder; JS overwrites it as soon as the settings panel opens. - server.py: replace hardcoded 'HermesWebUI/0.50.38' server_version with 'HermesWebUI/' + WEBUI_VERSION.lstrip('v') — stays in sync automatically. - Dockerfile: add ARG HERMES_VERSION=unknown and write api/_version.py so Docker images (where .git is excluded) still show the correct tag. - .github/workflows/release.yml: pass build-args: HERMES_VERSION=${{ github.ref_name }} to the Docker build step on tag pushes. - .gitignore: exclude api/_version.py (generated by Docker/CI, never committed). No manual 'update the version badge' step is required going forward. Tagging is sufficient — the badge and HTTP header update automatically. Tests: 18 new tests in tests/test_version_badge.py covering the full resolution chain, /api/settings injection, HTML placeholder, JS wiring, and server.py import. 1596 tests pass total. * fix: address review feedback on PR #790 - api/updates.py: replace exec() with regex parse for api/_version.py (no supply-chain risk from build artifact; exec unnecessary for one assignment) - api/updates.py: cap git describe timeout at 3s (was 10s — import-time stall on NFS/.git would block server startup unnecessarily) - server.py: lstrip('v') → removeprefix('v') (lstrip strips chars not prefix) - server.py: emit bare 'HermesWebUI' when version is 'unknown' rather than 'HermesWebUI/unknown' (log aggregators expect semver-ish suffix or none) - CHANGELOG.md: add v0.50.124 entry for this user-visible change - tests: rename exec-error test to reflect regex behaviour; add tests for removeprefix usage and unknown-version header guard (1598 tests total) --------- Co-authored-by: nesquena-hermes <hermes@nesquena.com>
2026-04-21 03:36:53 +00:00
def _normalize_remote_url(remote_url):
"""Return the browser-facing repository URL for update compare links.
Git remotes may be HTTPS or SSH and may include a literal ``.git`` suffix.
Strip only that literal suffix — never use ``str.rstrip('.git')`` because it
treats the argument as a character set and can truncate ``hermes-webui`` to
``hermes-webu``.
"""
if not remote_url:
return remote_url
remote_url = remote_url.strip()
if remote_url.startswith('git@'):
remote_url = remote_url.replace(':', '/', 1).replace('git@', 'https://', 1)
remote_url = remote_url.rstrip('/')
if remote_url.endswith('.git'):
remote_url = remote_url[:-4]
return remote_url.rstrip('/')
def _build_compare_url(repo_url, current_sha, latest_sha):
"""Return a safe browser compare URL, or None when any piece is missing."""
if not (repo_url and current_sha and latest_sha):
return None
parsed = urlparse(repo_url)
if parsed.scheme not in ('http', 'https') or not parsed.netloc:
return None
return f"{repo_url}/compare/{current_sha}...{latest_sha}"
def _split_remote_ref(ref):
"""Split 'origin/branch-name' into ('origin', 'branch-name').
Returns (None, ref) if ref contains no slash.
"""
if '/' not in ref:
return None, ref
remote, branch = ref.split('/', 1)
return remote, branch
def _detect_default_branch(path):
"""Detect the remote default branch (master or main)."""
out, ok = _run_git(['symbolic-ref', 'refs/remotes/origin/HEAD'], path)
if ok and out:
# refs/remotes/origin/master -> master
return out.split('/')[-1]
# Fallback: try master, then main
for branch in ('master', 'main'):
_, ok = _run_git(['rev-parse', '--verify', f'origin/{branch}'], path)
if ok:
return branch
return 'master'
2026-05-12 14:52:12 +00:00
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)
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)
return out if ok and out else None
def _release_gap(tags, current, latest):
"""Count release tags between current and latest in a newest-first list."""
if not latest or current == latest:
return 0
if current in tags:
return tags.index(current)
return 1
fix(updates): apply path must follow check-side fall-through past the latest tag Fixes #2846. After PR #2758 (the #2653 fix) the update check correctly falls through to the branch comparison when HEAD has moved past the latest `v*` tag — so the banner reports the real commit count against `origin/<branch>`. But `_select_apply_compare_ref` was never updated to mirror that decision: as long as any `v*` tag exists, it returns `tags[0]`, even when HEAD is far past it. Result for everyone running hermes-agent past `v2026.5.16` (i.e. anyone on agent master between tagged releases): 1. Banner: `Agent (origin/main): 254 updates available` ← correct 2. User clicks Update Now 3. `_select_apply_compare_ref` picks `v2026.5.16` because tags exist 4. `git pull --ff-only origin v2026.5.16` — no-op (HEAD is already past it) 5. `_schedule_restart()` fires anyway, server bounces 6. Next check still reports 254 behind — banner reappears unchanged `apply_force_update` had the same bug, except worse: `git reset --hard v2026.5.16` would have actively rewound the user's checkout 254 commits. The root cause is the same bug class as #2653 — two parallel paths (`_check_repo_release` and `_select_apply_compare_ref`) that should make the same decision but didn't. Pre-fix, the "is HEAD past the latest tag?" predicate lived inline inside `_check_repo_release` only. Fix --- Extract `_head_is_past_latest_tag(path, current_tag)` and have both paths consult it. When HEAD is past the latest tag: - check path: release check returns None → branch check runs (#2653, unchanged behaviour, just refactored) - apply path: falls through to upstream / `origin/<branch>`, never the stale tag (#2846, new behaviour) Tests ----- - `test_select_apply_compare_ref_uses_tag_when_head_is_on_tag` — unchanged behaviour pinned: HEAD exactly on tag → advance to tag. - `test_select_apply_compare_ref_falls_through_when_head_is_past_tag` — the #2846 repro: HEAD = v2026.5.16 + 608 commits → advance to `origin/main`, not the tag. - `test_select_apply_compare_ref_no_tags_uses_upstream` — unchanged. - `test_select_apply_compare_ref_no_tags_no_upstream_uses_default_branch` — unchanged. - `test_check_and_apply_paths_agree_when_head_is_past_tag` — symmetry test, ensures the two paths can't drift apart again. All 21 tests in `tests/test_updates.py` pass locally (16 existing + 5 new). Refs #2846, #2653.
2026-05-24 16:54:49 +00:00
def _head_is_past_latest_tag(path, current_tag):
"""Return True when HEAD has moved past the latest reachable release 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).
"""
if not current_tag:
return False
full_desc, ok = _run_git(['describe', '--tags', '--always'], path)
return bool(ok and full_desc and full_desc != current_tag)
def _head_contains_ref(path, ref):
"""Return True when ``ref`` is an ancestor of HEAD.
Release-channel checks are tag-name based, but users tracking ``main`` can
be on a commit that already contains the newest published tag. In that case
a positive tag gap is not an available update; applying the tag would move
backwards or fail fast-forward. Use the commit graph to detect that state.
"""
if not ref:
return False
_, ok = _run_git(['merge-base', '--is-ancestor', ref, 'HEAD'], path)
return bool(ok)
def _can_fast_forward_to(path, ref):
"""Return True when ``ref`` is a descendant of HEAD (``git pull --ff-only`` can reach it)."""
if not ref:
return False
_, ok = _run_git(['merge-base', '--is-ancestor', 'HEAD', ref], path)
return bool(ok)
def _select_apply_compare_ref(path):
"""Return the same remote ref family that the update check reports.
The update banner prefers published release tags when they exist. Applying
an update must therefore advance to the latest release tag too; otherwise a
checkout on a local/fork tracking branch can report release updates, pull a
different branch that is already current, restart, and still remain behind.
fix(updates): apply path must follow check-side fall-through past the latest tag Fixes #2846. After PR #2758 (the #2653 fix) the update check correctly falls through to the branch comparison when HEAD has moved past the latest `v*` tag — so the banner reports the real commit count against `origin/<branch>`. But `_select_apply_compare_ref` was never updated to mirror that decision: as long as any `v*` tag exists, it returns `tags[0]`, even when HEAD is far past it. Result for everyone running hermes-agent past `v2026.5.16` (i.e. anyone on agent master between tagged releases): 1. Banner: `Agent (origin/main): 254 updates available` ← correct 2. User clicks Update Now 3. `_select_apply_compare_ref` picks `v2026.5.16` because tags exist 4. `git pull --ff-only origin v2026.5.16` — no-op (HEAD is already past it) 5. `_schedule_restart()` fires anyway, server bounces 6. Next check still reports 254 behind — banner reappears unchanged `apply_force_update` had the same bug, except worse: `git reset --hard v2026.5.16` would have actively rewound the user's checkout 254 commits. The root cause is the same bug class as #2653 — two parallel paths (`_check_repo_release` and `_select_apply_compare_ref`) that should make the same decision but didn't. Pre-fix, the "is HEAD past the latest tag?" predicate lived inline inside `_check_repo_release` only. Fix --- Extract `_head_is_past_latest_tag(path, current_tag)` and have both paths consult it. When HEAD is past the latest tag: - check path: release check returns None → branch check runs (#2653, unchanged behaviour, just refactored) - apply path: falls through to upstream / `origin/<branch>`, never the stale tag (#2846, new behaviour) Tests ----- - `test_select_apply_compare_ref_uses_tag_when_head_is_on_tag` — unchanged behaviour pinned: HEAD exactly on tag → advance to tag. - `test_select_apply_compare_ref_falls_through_when_head_is_past_tag` — the #2846 repro: HEAD = v2026.5.16 + 608 commits → advance to `origin/main`, not the tag. - `test_select_apply_compare_ref_no_tags_uses_upstream` — unchanged. - `test_select_apply_compare_ref_no_tags_no_upstream_uses_default_branch` — unchanged. - `test_check_and_apply_paths_agree_when_head_is_past_tag` — symmetry test, ensures the two paths can't drift apart again. All 21 tests in `tests/test_updates.py` pass locally (16 existing + 5 new). Refs #2846, #2653.
2026-05-24 16:54:49 +00:00
When HEAD is past the latest tag (the agent repo's day-to-day state between
tagged releases), the check side falls through to the branch comparison via
`_check_repo_release` returning None. The apply side must mirror that
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.
"""
tags = _release_tags(path)
if tags:
fix(updates): apply path must follow check-side fall-through past the latest tag Fixes #2846. After PR #2758 (the #2653 fix) the update check correctly falls through to the branch comparison when HEAD has moved past the latest `v*` tag — so the banner reports the real commit count against `origin/<branch>`. But `_select_apply_compare_ref` was never updated to mirror that decision: as long as any `v*` tag exists, it returns `tags[0]`, even when HEAD is far past it. Result for everyone running hermes-agent past `v2026.5.16` (i.e. anyone on agent master between tagged releases): 1. Banner: `Agent (origin/main): 254 updates available` ← correct 2. User clicks Update Now 3. `_select_apply_compare_ref` picks `v2026.5.16` because tags exist 4. `git pull --ff-only origin v2026.5.16` — no-op (HEAD is already past it) 5. `_schedule_restart()` fires anyway, server bounces 6. Next check still reports 254 behind — banner reappears unchanged `apply_force_update` had the same bug, except worse: `git reset --hard v2026.5.16` would have actively rewound the user's checkout 254 commits. The root cause is the same bug class as #2653 — two parallel paths (`_check_repo_release` and `_select_apply_compare_ref`) that should make the same decision but didn't. Pre-fix, the "is HEAD past the latest tag?" predicate lived inline inside `_check_repo_release` only. Fix --- Extract `_head_is_past_latest_tag(path, current_tag)` and have both paths consult it. When HEAD is past the latest tag: - check path: release check returns None → branch check runs (#2653, unchanged behaviour, just refactored) - apply path: falls through to upstream / `origin/<branch>`, never the stale tag (#2846, new behaviour) Tests ----- - `test_select_apply_compare_ref_uses_tag_when_head_is_on_tag` — unchanged behaviour pinned: HEAD exactly on tag → advance to tag. - `test_select_apply_compare_ref_falls_through_when_head_is_past_tag` — the #2846 repro: HEAD = v2026.5.16 + 608 commits → advance to `origin/main`, not the tag. - `test_select_apply_compare_ref_no_tags_uses_upstream` — unchanged. - `test_select_apply_compare_ref_no_tags_no_upstream_uses_default_branch` — unchanged. - `test_check_and_apply_paths_agree_when_head_is_past_tag` — symmetry test, ensures the two paths can't drift apart again. All 21 tests in `tests/test_updates.py` pass locally (16 existing + 5 new). Refs #2846, #2653.
2026-05-24 16:54:49 +00:00
latest_tag = tags[0]
Stage 403: Opus pre-release fixes (1 MUST-FIX + 3 SHOULD-FIX) MUST-FIX: - tests/test_2735_open_in_vscode.py: bump expected open_in_vscode locale counter from 10 to 11 (Turkish locale added in #2772). The bump fell out of an in-rebase test edit but never got committed; tagging without this would have shipped a failing test in the release commit. SHOULD-FIX inline: - api/updates.py: case-D drift in _select_apply_compare_ref. The original #2855 fix used latest_tag in the past-tag predicate; the check side uses current_tag (HEAD's nearest reachable tag) plus a 'behind == 0' gate. They drift when HEAD is on an OLDER release tag with commits on top AND a NEWER tag exists ('case D'): check correctly suggests advancing to the newer tag, but apply fell through to origin/<branch>. Mirror the check-side predicate exactly. Adds regression test test_select_apply_compare_ref_case_d_older_tag_with_commits_and_newer_tag_exists. - static/messages.js: post-await race guard in _restoreSettledSession. stream_end without preceding 'done' enters the settlement path, awaits /api/session, then sets _streamFinalized=true. If a late 'done' event arrives during that await, it sees _streamFinalized still false and double-runs the finalize. The guard returns early when done won the race, avoiding double renderMessages() + double notification. - server.py: CORS preflight Access-Control-Allow-Methods now includes PUT. #2776 wired PUT into the router for /api/mcp/servers/{name} but didn't update the OPTIONS response. Same-origin only in practice, but cosmetic completeness for CORS-aware deployments. Opus advisor verdict: all 5 risk areas reviewed, 1 MUST-FIX + 3 SHOULD-FIX all addressed inline. Net: +69/-9, no new architecture, no behavior risk.
2026-05-24 17:42:06 +00:00
current_tag = _current_release_tag(path)
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
# banner would otherwise advertise. The common case is behind == 0 and
# HEAD is past its nearest tag, but main-tracking checkouts can also
# 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.
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)
):
pass
else:
fix(updates): apply path must follow check-side fall-through past the latest tag Fixes #2846. After PR #2758 (the #2653 fix) the update check correctly falls through to the branch comparison when HEAD has moved past the latest `v*` tag — so the banner reports the real commit count against `origin/<branch>`. But `_select_apply_compare_ref` was never updated to mirror that decision: as long as any `v*` tag exists, it returns `tags[0]`, even when HEAD is far past it. Result for everyone running hermes-agent past `v2026.5.16` (i.e. anyone on agent master between tagged releases): 1. Banner: `Agent (origin/main): 254 updates available` ← correct 2. User clicks Update Now 3. `_select_apply_compare_ref` picks `v2026.5.16` because tags exist 4. `git pull --ff-only origin v2026.5.16` — no-op (HEAD is already past it) 5. `_schedule_restart()` fires anyway, server bounces 6. Next check still reports 254 behind — banner reappears unchanged `apply_force_update` had the same bug, except worse: `git reset --hard v2026.5.16` would have actively rewound the user's checkout 254 commits. The root cause is the same bug class as #2653 — two parallel paths (`_check_repo_release` and `_select_apply_compare_ref`) that should make the same decision but didn't. Pre-fix, the "is HEAD past the latest tag?" predicate lived inline inside `_check_repo_release` only. Fix --- Extract `_head_is_past_latest_tag(path, current_tag)` and have both paths consult it. When HEAD is past the latest tag: - check path: release check returns None → branch check runs (#2653, unchanged behaviour, just refactored) - apply path: falls through to upstream / `origin/<branch>`, never the stale tag (#2846, new behaviour) Tests ----- - `test_select_apply_compare_ref_uses_tag_when_head_is_on_tag` — unchanged behaviour pinned: HEAD exactly on tag → advance to tag. - `test_select_apply_compare_ref_falls_through_when_head_is_past_tag` — the #2846 repro: HEAD = v2026.5.16 + 608 commits → advance to `origin/main`, not the tag. - `test_select_apply_compare_ref_no_tags_uses_upstream` — unchanged. - `test_select_apply_compare_ref_no_tags_no_upstream_uses_default_branch` — unchanged. - `test_check_and_apply_paths_agree_when_head_is_past_tag` — symmetry test, ensures the two paths can't drift apart again. All 21 tests in `tests/test_updates.py` pass locally (16 existing + 5 new). Refs #2846, #2653.
2026-05-24 16:54:49 +00:00
return latest_tag
upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path)
if ok and upstream:
return upstream
branch = _detect_default_branch(path)
return f'origin/{branch}'
2026-05-12 14:52:12 +00:00
def _check_repo_release(path, name):
"""Check if a git repo is behind its latest published release tag."""
tags = _release_tags(path)
if not tags:
return None
2026-05-12 14:52:12 +00:00
latest_tag = tags[0]
current_tag = _current_release_tag(path)
behind = _release_gap(tags, current_tag, latest_tag)
# If behind == 0 but HEAD has moved past the tag (e.g. the agent repo
# keeps committing to master between tagged releases), the release check
# would report "Up to date" even though hundreds of commits are missing.
fix(updates): apply path must follow check-side fall-through past the latest tag Fixes #2846. After PR #2758 (the #2653 fix) the update check correctly falls through to the branch comparison when HEAD has moved past the latest `v*` tag — so the banner reports the real commit count against `origin/<branch>`. But `_select_apply_compare_ref` was never updated to mirror that decision: as long as any `v*` tag exists, it returns `tags[0]`, even when HEAD is far past it. Result for everyone running hermes-agent past `v2026.5.16` (i.e. anyone on agent master between tagged releases): 1. Banner: `Agent (origin/main): 254 updates available` ← correct 2. User clicks Update Now 3. `_select_apply_compare_ref` picks `v2026.5.16` because tags exist 4. `git pull --ff-only origin v2026.5.16` — no-op (HEAD is already past it) 5. `_schedule_restart()` fires anyway, server bounces 6. Next check still reports 254 behind — banner reappears unchanged `apply_force_update` had the same bug, except worse: `git reset --hard v2026.5.16` would have actively rewound the user's checkout 254 commits. The root cause is the same bug class as #2653 — two parallel paths (`_check_repo_release` and `_select_apply_compare_ref`) that should make the same decision but didn't. Pre-fix, the "is HEAD past the latest tag?" predicate lived inline inside `_check_repo_release` only. Fix --- Extract `_head_is_past_latest_tag(path, current_tag)` and have both paths consult it. When HEAD is past the latest tag: - check path: release check returns None → branch check runs (#2653, unchanged behaviour, just refactored) - apply path: falls through to upstream / `origin/<branch>`, never the stale tag (#2846, new behaviour) Tests ----- - `test_select_apply_compare_ref_uses_tag_when_head_is_on_tag` — unchanged behaviour pinned: HEAD exactly on tag → advance to tag. - `test_select_apply_compare_ref_falls_through_when_head_is_past_tag` — the #2846 repro: HEAD = v2026.5.16 + 608 commits → advance to `origin/main`, not the tag. - `test_select_apply_compare_ref_no_tags_uses_upstream` — unchanged. - `test_select_apply_compare_ref_no_tags_no_upstream_uses_default_branch` — unchanged. - `test_check_and_apply_paths_agree_when_head_is_past_tag` — symmetry test, ensures the two paths can't drift apart again. All 21 tests in `tests/test_updates.py` pass locally (16 existing + 5 new). Refs #2846, #2653.
2026-05-24 16:54:49 +00:00
# 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):
return None
# Users tracking main can already contain the newest fetched release tag
# while their nearest reachable tag is older. A positive tag gap then means
# only "there is a newer tag name", not "HEAD is behind that tag" (#3140).
# 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):
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):
return None
2026-05-12 14:52:12 +00:00
remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path)
remote_url = _normalize_remote_url(remote_url)
return {
'name': name,
'behind': behind,
# GitHub compare URLs accept tag names, and tag-to-tag links are the
# clearest "what changed in this release?" view for operators.
'current_sha': current_tag,
'latest_sha': latest_tag,
'branch': latest_tag,
'repo_url': remote_url,
'release_based': True,
'current_version': current_tag,
'latest_version': latest_tag,
}
def _check_repo_branch(path, name, *, fetch=True):
"""Fallback: check if a git repo is behind its upstream branch."""
# Fetch latest from origin (network call, cached by TTL)
2026-05-12 14:52:12 +00:00
if fetch:
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
if not fetch_ok:
return {'name': name, 'behind': 0, 'error': 'fetch failed'}
# Use the current branch's upstream tracking branch, not the repo default.
# This avoids false "N updates behind" alerts when the user is on a feature
# branch and master/main has moved forward with unrelated commits.
# If no upstream is set (brand-new local branch), fall back to the default branch.
upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path)
if ok and upstream:
# upstream is like "origin/feat/foo" — use it directly in rev-list
compare_ref = upstream
else:
branch = _detect_default_branch(path)
compare_ref = f'origin/{branch}'
# Count commits behind
out, ok = _run_git(['rev-list', '--count', f'HEAD..{compare_ref}'], path)
behind = int(out) if ok and out.isdigit() else 0
fix(updates): use merge-base for compare URL so 'What's new?' link resolves Closes #1579. api/updates.py was building the GitHub compare URL from local HEAD short SHA: repoUrl + '/compare/' + curSha + '...' + newSha where curSha = `git rev-parse --short HEAD` Whenever local HEAD diverges from upstream — unpushed work, dirty stage branches, forks, in-flight rebases, release-time merge commits whose SHA only lives in the maintainer's local history — the compare URL points at a SHA github.com has never seen and returns the standard 404 page. Reporter (@ai-ag2026) observed: https://github.com/nesquena/hermes-webui/compare/c660c7f...86cb22e → 404 because c660c7f was an unpushed local commit. The right base is `git merge-base HEAD <compare_ref>` — the most recent commit local and upstream share. Since `git fetch` succeeded just before, the merge-base is guaranteed to exist on the upstream GitHub repo. Behavior matrix: Pure-behind clone (no local commits): merge-base == HEAD; URL unchanged. Behind + local-only commits (#1579): merge-base != HEAD; URL points at public ancestor instead of local HEAD. merge-base failure (shallow clone): current_sha=None; JS link guard suppresses link rather than emitting a known-broken URL. Also hardens static/ui.js: reset the link's href and display:none on every banner render, so a stale link from a prior render can't survive a re-render where the new payload has current_sha=null. Tests: - test_current_sha_is_merge_base_not_local_HEAD — reporter's scenario - test_current_sha_equals_HEAD_when_no_local_commits — backward compat - test_current_sha_falls_back_to_None_when_merge_base_fails — defensive - test_whats_new_link_resets_display_and_href_on_every_render - test_whats_new_link_suppressed_when_curSha_falsy - test_reporter_url_shape_no_longer_produces_invalid_compare_url 4094 → 4100 passing. 0 regressions.
2026-05-04 05:08:00 +00:00
# Get short SHAs for display.
#
# latest_sha = upstream tip (compare_ref). Always exists on github.com
# because it is literally the commit `git fetch` just pulled.
#
# current_sha is trickier. The intuitive choice — local HEAD — breaks
# the "What's new?" compare URL whenever HEAD is not a public commit:
# unpushed work, dirty stage branches, forks, in-flight rebases, or
# release-time merge commits whose SHA only lives in the maintainer's
# checkout. We saw exactly this in #1579: a banner reporting "17 updates"
# linked to /compare/<localHEAD>...<upstream> and 404'd because <localHEAD>
# was never pushed to the canonical repo.
#
# The right base is the merge-base between HEAD and the upstream ref —
# that's the most recent commit both sides agree on, and (because
# `git fetch` succeeded above) it is guaranteed to be present upstream.
# If a user is 17 commits behind with no local-only commits, merge-base
# equals local HEAD and the URL is identical to what we shipped before;
# if they ARE ahead with local-only commits, the URL still resolves to
# the public history they share with upstream. If merge-base fails for
# any reason (e.g. shallow clone where the bases diverge before the
# cutoff), fall back to None so the JS link guard suppresses the link
# rather than emitting a known-broken URL.
mb_full, mb_ok = _run_git(['merge-base', 'HEAD', compare_ref], path)
if mb_ok and mb_full:
short, ok = _run_git(['rev-parse', '--short', mb_full], path)
current = short if (ok and short) else None
else:
current = None
latest, _ = _run_git(['rev-parse', '--short', compare_ref], path)
# Get repo URL for "What's new?" link
remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path)
remote_url = _normalize_remote_url(remote_url)
return {
'name': name,
'behind': behind,
'current_sha': current,
'latest_sha': latest,
'branch': compare_ref,
'repo_url': remote_url,
'compare_url': _build_compare_url(remote_url, current, latest),
}
2026-05-12 14:52:12 +00:00
def _check_repo(path, name):
"""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
the working-tree state vs HEAD. A dirty install at-or-past the latest release
tag used to silently report "Up to date" with no remediation affordance, so
the Settings panel reads this flag to offer ``apply_force_update`` (issue
#4085).
When ``.git`` is absent (Docker images, pip installs), returns a minimal dict
with ``no_git: True`` and ``behind: None`` so the frontend can distinguish
"can't check" from "up to date" (issue #4356).
"""
2026-05-12 14:52:12 +00:00
if path is None or not (path / '.git').exists():
return {
'name': name,
'behind': None,
'no_git': True,
}
2026-05-12 14:52:12 +00:00
# Fetch tags first so update prompts track published releases, not every
# development commit that lands on master/main after the latest release.
#
# --force is required because the WebUI is a release-tracking consumer:
# it never pushes tags, so it should always defer to whatever the remote
# says a release tag points to. Without --force, a remote re-tag (e.g.
# after a squash-merge that re-points a release tag at a new SHA) jams
# the update path indefinitely with "would clobber existing tag" errors.
# See #2756.
fetch_out, fetch_ok = _run_git(['fetch', 'origin', '--tags', '--force'], path, timeout=15)
2026-05-12 14:52:12 +00:00
if not fetch_ok:
release_info = _check_repo_release(path, name)
message = 'fetch failed'
if fetch_out:
message = f'{message}: {_sanitize_git_diagnostic(fetch_out)}'
if release_info is not None:
release_info = dict(release_info)
release_info['error'] = message
release_info['stale_check'] = True
release_info['dirty'] = _is_dirty(path)
return release_info
return {
'name': name,
'behind': None,
'error': message,
'stale_check': True,
'dirty': _is_dirty(path),
}
2026-05-12 14:52:12 +00:00
release_info = _check_repo_release(path, name)
if release_info is not None:
release_info = dict(release_info)
release_info['dirty'] = _is_dirty(path)
2026-05-12 14:52:12 +00:00
return release_info
branch_info = _check_repo_branch(path, name, fetch=False)
if branch_info is not None:
branch_info = dict(branch_info)
branch_info['dirty'] = _is_dirty(path)
return branch_info
return None
def _is_dirty(path: Path, timeout: int = 1) -> bool:
"""Return True when the working tree has uncommitted changes vs HEAD.
Same primitive as ``_dirty_suffix`` (issue #4085): ``git diff-index
--quiet HEAD --`` exits 0 on a clean tree and 1 on a dirty tree (not an
error). Real errors (timeout, missing git, fatal) are conservatively
reported as clean so a transient probe failure never produces a false-
positive "local changes" alert.
"""
out, ok = _run_git(['diff-index', '--quiet', 'HEAD', '--'], path, timeout=timeout)
if ok:
return False
return not out or out.startswith('git exited with status ')
2026-05-12 14:52:12 +00:00
2026-05-24 14:20:31 +00:00
def _ignored_agent_update_info() -> dict:
"""Return a stable update-check payload for intentionally ignored Agent updates."""
return {'name': 'agent', 'behind': 0, 'ignored': True}
Release v0.51.307 — Release JW (stage-a3 — onboarding spoof fix + update-check CSRF, #3758 partial) (#3764) * fix(security): ignore spoofable forwarded IPs in onboarding gate + make update-check CSRF-safe (#3758, partial) Ships the two unambiguous slices of #3758's security review. The two slices with breakage risk for existing installs — the Docker-default public-bind-requires-auth gate and removing /tmp from the /api/media allowed roots — are held for separate review/decision. Onboarding forwarded-IP spoof hardening (+ release-gate CORE fix): - The unauthenticated first-run onboarding local-network gate now IGNORES X-Forwarded-For / X-Real-IP by default (a direct client can spoof them to a private/loopback address to bypass the gate), trusting them only when HERMES_WEBUI_TRUST_FORWARDED_FOR=1 is set behind a trusted proxy (rightmost proxy-appended hop). - Release-gate (Codex) CORE catch + refinement: when forwarded headers are present but untrusted, the header is ignored and locality is judged by the raw socket — but a PRIVATE/LAN raw socket (a separate proxy box that could forward an arbitrary public client) is no longer treated as local; only a LOOPBACK raw socket is (genuine same-host; a remote attacker can't forge a 127.0.0.1 TCP source). This closes the new fail-open the initial refactor introduced (public client behind a LAN proxy read as local) while preserving genuine same-host onboarding. LAN-proxy operators must set HERMES_WEBUI_TRUST_FORWARDED_FOR=1. Regression tests lock the full matrix (spoof-block, LAN-proxy-deny, loopback-allow, trusted-proxy-rightmost-hop, direct-public-deny). - Three duplicated inline gate blocks unified into _onboarding_gate_allows / _onboarding_request_is_local; ONBOARDING_OPEN normalized to canonical truthy values via _truthy_env. Update-check CSRF hardening: - GET /api/updates/check is cache-only (cached_update_status(): no network/git mutation); forced refresh moves to POST /api/updates/check {force:true}; both frontend call sites updated and the test_api_timeout contract assertion updated. - cached_update_status() preserves cached agent info when include_agent re-enabled. Docker log masking: ENV_OBFUSCATE_PART also masks PASSWORD/SECRET/CREDENTIAL/COOKIE/SESSION. Held for separate review (NOT in this PR): public-bind-requires-auth startup gate (server.py + Dockerfile default) and the /api/media /tmp-root removal. Co-authored-by: fantasticsquirrel <[email protected]> * docs(changelog): stamp v0.51.307 — Release JW (stage-a3 #3758 partial) --------- Co-authored-by: nesquena-hermes <[email protected]>
2026-06-07 02:50:56 +00:00
def cached_update_status(*, include_agent=True):
"""Return cached update status without performing network or git mutations."""
include_agent = bool(include_agent)
with _cache_lock:
cached = dict(_update_cache)
if cached.get('include_agent') != include_agent:
cached['include_agent'] = include_agent
if not include_agent:
cached['agent'] = _ignored_agent_update_info()
cached['cached'] = True
return cached
2026-05-24 14:20:31 +00:00
def check_for_updates(force=False, *, include_agent=True):
"""Return cached update status for webui and agent repos."""
global _check_in_progress
2026-05-24 14:20:31 +00:00
include_agent = bool(include_agent)
with _cache_lock:
2026-05-24 14:20:31 +00:00
if (
not force
and _update_cache.get('include_agent') == include_agent
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
_check_in_progress = True
try:
# Run checks outside the lock (network I/O)
webui_info = _check_repo(REPO_ROOT, 'webui')
2026-05-24 14:20:31 +00:00
agent_info = _check_repo(_AGENT_DIR, 'agent') 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()
2026-05-24 14:20:31 +00:00
_update_cache['include_agent'] = include_agent
return dict(_update_cache)
finally:
_check_in_progress = False
2026-05-13 21:22:26 +00:00
def _repo_path_for_update_target(target: str):
if target == 'webui':
return REPO_ROOT
if target == 'agent':
return _AGENT_DIR
return None
def _commit_subjects_for_update(info: dict, *, limit: int = 24) -> list[str]:
"""Return commit subjects for an update range, if the local git refs exist."""
2026-05-14 05:17:56 +00:00
subjects, _truncated = _commit_subjects_for_update_with_limit(info, limit=limit)
return subjects
def _commit_subjects_for_update_with_limit(info: dict, *, limit: int = 24) -> tuple[list[str], bool]:
"""Return recent commit subjects plus whether the local list was capped."""
2026-05-13 21:22:26 +00:00
if not isinstance(info, dict):
2026-05-14 05:17:56 +00:00
return [], False
2026-05-13 21:22:26 +00:00
target = info.get('name')
if target not in ('webui', 'agent'):
target = 'webui' if info.get('repo_url', '').endswith('hermes-webui') else target
path = _repo_path_for_update_target(target)
if path is None or not (Path(path) / '.git').exists():
2026-05-14 05:17:56 +00:00
return [], False
2026-05-13 21:22:26 +00:00
current = str(info.get('current_sha') or '').strip()
latest = str(info.get('latest_sha') or '').strip()
if not (current and latest):
2026-05-14 05:17:56 +00:00
return [], False
probe_limit = max(1, int(limit)) + 1
out, ok = _run_git(['log', '--format=%s', f'{current}..{latest}', f'-n{probe_limit}'], path, timeout=5)
2026-05-13 21:22:26 +00:00
if not ok or not out:
2026-05-14 05:17:56 +00:00
return [], False
subjects = [line.strip() for line in out.splitlines() if line.strip()]
truncated = len(subjects) > limit
return subjects[:limit], truncated
2026-05-13 21:22:26 +00:00
2026-05-13 21:48:01 +00:00
def _summary_cache_key(updates: dict, details: list[dict]) -> str:
"""Stable key for the exact update range being summarized."""
payload = []
for item in details:
payload.append({
'name': item.get('name'),
'behind': item.get('behind'),
'current_sha': item.get('current_sha'),
'latest_sha': item.get('latest_sha'),
'compare_url': item.get('compare_url'),
})
blob = json.dumps(payload, sort_keys=True, separators=(',', ':'))
return hashlib.sha256(blob.encode('utf-8')).hexdigest()
def _clean_summary_bullet(line: str) -> str:
line = re.sub(r'^\s*(?:[-*•]+|\d+[.)])\s*', '', str(line or '')).strip()
line = re.sub(r'\s+', ' ', line)
if not line:
return ''
if line[-1] not in '.!?':
line += '.'
return line[:240]
def _split_summary_category(line: str) -> tuple[str | None, str]:
raw = str(line or '').strip()
match = re.match(r'^\s*(?:[-*•]+|\d+[.)])?\s*(notice|what you(?:ll|\'ll| will) notice|user(?:s)? will notice|worth knowing|worth|note)\s*:\s*(.+)$', raw, re.I)
if not match:
return None, raw
label = match.group(1).lower()
category = 'worth' if label in {'worth knowing', 'worth', 'note'} else 'notice'
return category, match.group(2)
def _unique_summary_bullets(items: list[str]) -> list[str]:
seen = set()
bullets = []
for item in items:
cleaned = _clean_summary_bullet(item)
key = cleaned.lower()
if cleaned and key not in seen:
bullets.append(cleaned)
seen.add(key)
return bullets
2026-05-13 21:48:01 +00:00
def _summary_bullets_from_text(text: str, *, fallback_items: list[str]) -> list[str]:
raw = str(text or '').strip()
candidates = []
for line in raw.splitlines():
_category, body = _split_summary_category(line)
cleaned = _clean_summary_bullet(body)
2026-05-13 21:48:01 +00:00
if cleaned:
candidates.append(cleaned)
if len(candidates) <= 1 and raw:
candidates = [_clean_summary_bullet(part) for part in re.split(r'(?<=[.!?])\s+', raw)]
candidates = [item for item in candidates if item]
if not candidates:
candidates = [_clean_summary_bullet(item) for item in fallback_items]
bullets = _unique_summary_bullets(candidates)
2026-05-13 21:48:01 +00:00
return bullets or ['Updates are available.']
def _categorized_summary_bullets_from_text(text: str) -> tuple[list[str], list[str]]:
notice_items: list[str] = []
worth_items: list[str] = []
for line in str(text or '').splitlines():
category, body = _split_summary_category(line)
if category == 'notice':
notice_items.append(body)
elif category == 'worth':
worth_items.append(body)
2026-05-15 01:16:39 +00:00
elif re.match(r'^\s*(?:[-*•]+|\d+[.)])?\s*[A-Za-z][A-Za-z ]{1,32}\s*:', str(line or '')):
notice_items.append(body)
return _unique_summary_bullets(notice_items), _unique_summary_bullets(worth_items)
2026-05-13 21:48:01 +00:00
def _fallback_update_bullets(details: list[dict]) -> list[str]:
bullets = []
2026-05-13 21:22:26 +00:00
for item in details:
label = item.get('label') or item.get('name') or 'Hermes'
behind = item.get('behind') or 0
commits = item.get('commits') or []
if commits:
2026-05-13 21:48:01 +00:00
highlights = '; '.join(commits[:3])
2026-05-14 05:17:56 +00:00
qualifier = 'recent updates' if item.get('commits_truncated') else 'updates'
bullets.append(f"{label} has {behind} update(s), including {qualifier}: {highlights}.")
2026-05-13 21:22:26 +00:00
else:
2026-05-13 21:48:01 +00:00
bullets.append(f"{label} has {behind} update(s) available.")
return bullets or ['Updates are available.']
2026-05-13 22:00:55 +00:00
def _worth_knowing_bullets(details: list[dict]) -> list[str]:
2026-05-14 05:17:56 +00:00
items = []
truncated = [item for item in details if item.get('commits_truncated') and item.get('commits_limit')]
for item in truncated[:2]:
label = item.get('label') or item.get('name') or 'Hermes'
behind = item.get('behind') or 0
limit = item.get('commits_limit') or len(item.get('commits') or [])
items.append(
f"{label} has {behind} updates; this summary uses the latest {limit} commit subjects, with the full comparison still available in the diff link."
)
if items:
return items
2026-05-13 22:00:55 +00:00
targets = [
f"{item.get('label') or item.get('name') or 'Hermes'} ({item.get('behind') or 0} update{'s' if (item.get('behind') or 0) != 1 else ''})"
for item in details
if item.get('behind')
]
if len(targets) > 1:
return ['This summary combines updates from ' + ' and '.join(targets) + '.']
return []
2026-05-13 22:00:55 +00:00
2026-05-13 21:48:01 +00:00
def _format_update_summary_sections(summary_text: str, details: list[dict]) -> tuple[list[dict], str]:
notice_items, worth_items = _categorized_summary_bullets_from_text(summary_text)
if not notice_items:
notice_items = _summary_bullets_from_text(summary_text, fallback_items=_fallback_update_bullets(details))
notice_keys = {item.lower() for item in notice_items}
worth_items = [item for item in worth_items if item.lower() not in notice_keys]
worth_items.extend(
item for item in _worth_knowing_bullets(details)
if item.lower() not in notice_keys and item.lower() not in {existing.lower() for existing in worth_items}
)
2026-05-13 21:48:01 +00:00
sections = [
{
'title': "What you'll notice",
2026-05-13 22:00:55 +00:00
'items': notice_items,
2026-05-13 21:48:01 +00:00
},
]
if worth_items:
sections.append(
{
'title': 'Worth knowing',
'items': worth_items,
}
)
2026-05-13 21:48:01 +00:00
lines = []
for section in sections:
lines.append(section['title'])
lines.extend(f"- {item}" for item in section['items'])
lines.append('')
return sections, '\n'.join(lines).strip()
def _fallback_update_summary(updates: dict, details: list[dict]) -> str:
_sections, summary = _format_update_summary_sections('', details)
return summary
2026-05-13 21:22:26 +00:00
def _update_summary_prompt(details: list[dict]) -> tuple[str, str]:
system = (
"You write human-readable release summaries for Hermes users. "
"Focus on what the user will notice in the product. Keep it simple, specific, and short. "
"avoid technical jargon, implementation details, SHA names, branch names, and file paths unless necessary. "
2026-05-13 21:48:01 +00:00
"Return only bullets. Do not include headings, markdown tables, intro paragraphs, or closing notes."
2026-05-13 21:22:26 +00:00
)
user_lines = [
"Summarize these available updates as concise bullets.",
"Prefix each bullet with `Notice:` for user-visible behavior changes or `Worth knowing:` for useful context.",
"Put user-visible Notice bullets first and include every meaningful user-facing change from the available commit subjects.",
"Use Worth knowing only for helpful context that is not a duplicate of a Notice bullet.",
2026-05-13 21:22:26 +00:00
"Use everyday language and explain visible behavior changes, not code mechanics.",
"Return only prefixed bullets; the WebUI will add the fixed section headings separately.",
2026-05-13 21:22:26 +00:00
"",
]
for item in details:
user_lines.append(f"{item['label']}: {item['behind']} commit(s) behind")
commits = item.get('commits') or []
if commits:
2026-05-14 05:17:56 +00:00
if item.get('commits_truncated'):
user_lines.append(
f"- Showing latest {len(commits)} of {item['behind']} commit subjects; summarize trends, not every commit."
)
2026-05-13 21:22:26 +00:00
user_lines.extend(f"- {subject}" for subject in commits)
else:
user_lines.append("- No local commit subjects available; summarize only the update count.")
user_lines.append("")
return system, '\n'.join(user_lines)
2026-05-13 22:15:52 +00:00
def summarize_update_payload(updates: dict, llm_callback=None, *, target: str | None = None, use_cache: bool = True) -> dict:
2026-05-13 21:22:26 +00:00
"""Build a human-readable What's New summary and keep regular diff comparison links.
``llm_callback`` receives ``(system_prompt, user_prompt)`` and returns text.
The caller may wire that to AIAgent; this module keeps a deterministic
fallback so the banner remains useful when no LLM provider is configured.
2026-05-13 21:48:01 +00:00
Summaries are cached per exact update range so refreshes do not generate
slightly different wording for the same available updates.
2026-05-13 21:22:26 +00:00
"""
if not isinstance(updates, dict):
updates = {}
2026-05-13 22:15:52 +00:00
requested_target = target if target in ('webui', 'agent') else None
2026-05-13 21:22:26 +00:00
details = []
for key, label in (('webui', 'WebUI'), ('agent', 'Agent')):
2026-05-13 22:15:52 +00:00
if requested_target and key != requested_target:
continue
2026-05-13 21:22:26 +00:00
info = updates.get(key)
if not isinstance(info, dict) or int(info.get('behind') or 0) <= 0:
continue
2026-05-14 05:17:56 +00:00
commit_limit = 24
commits, commits_truncated = _commit_subjects_for_update_with_limit({'name': key, **info}, limit=commit_limit)
behind = int(info.get('behind') or 0)
2026-05-13 21:22:26 +00:00
item = {
'name': key,
'label': label,
2026-05-14 05:17:56 +00:00
'behind': behind,
2026-05-13 21:48:01 +00:00
'current_sha': info.get('current_sha'),
'latest_sha': info.get('latest_sha'),
2026-05-13 21:22:26 +00:00
'compare_url': info.get('compare_url'),
2026-05-14 05:17:56 +00:00
'commits': commits,
'commits_limit': commit_limit,
'commits_truncated': bool(commits_truncated or (commits and behind > len(commits))),
2026-05-13 21:22:26 +00:00
}
details.append(item)
2026-05-13 21:48:01 +00:00
cache_key = _summary_cache_key(updates, details)
if use_cache:
with _cache_lock:
cached = _summary_cache.get(cache_key)
if cached:
_summary_cache.move_to_end(cache_key)
2026-05-13 21:48:01 +00:00
if cached:
result = dict(cached)
result['cached'] = True
return result
2026-05-13 21:22:26 +00:00
generated_by = 'fallback'
2026-05-13 21:48:01 +00:00
candidate = ''
2026-05-13 21:22:26 +00:00
if details and callable(llm_callback):
system, prompt = _update_summary_prompt(details)
try:
candidate = (llm_callback(system, prompt) or '').strip()
if candidate:
generated_by = 'llm'
except Exception:
2026-05-13 21:48:01 +00:00
candidate = ''
sections, summary = _format_update_summary_sections(candidate, details)
result = {
2026-05-13 21:22:26 +00:00
'ok': True,
'summary': summary,
2026-05-13 21:48:01 +00:00
'summary_sections': sections,
2026-05-13 21:22:26 +00:00
'generated_by': generated_by,
2026-05-13 21:48:01 +00:00
'cached': False,
'cache_key': cache_key,
2026-05-13 22:15:52 +00:00
'target': requested_target,
2026-05-13 21:22:26 +00:00
'targets': details,
}
2026-05-13 21:48:01 +00:00
if use_cache:
with _cache_lock:
if len(_summary_cache) >= _SUMMARY_CACHE_MAX and cache_key not in _summary_cache:
_summary_cache.popitem(last=False)
2026-05-13 21:48:01 +00:00
_summary_cache[cache_key] = dict(result)
return result
2026-05-13 21:22:26 +00:00
# ── Self-update application ───────────────────────────────────────────────────
def _purge_agent_pycache(repo_dir: Path) -> None:
"""Delete all __pycache__ dirs under *repo_dir* so the next import
recompiles from source, avoiding stale-bytecode errors after git pull.
``os.execv()`` replaces the process image but does not touch the
on-disk bytecode cache. When a ``git pull`` writes new ``.py`` files
whose mtime lands within the same second as the pre-existing ``.pyc``
files, CPython may trust the stale cache and serve an old class
definition. The mismatch between cached class symbols and newly-imported
supporting modules causes ``AttributeError`` (e.g. a method added in
the same update is missing from the cached ``AIAgent`` class).
This is safe to call right before ``os.execv()`` because the current
process is about to be replaced — losing the bytecode cache is harmless
and forces a clean recompilation on the next startup.
"""
if repo_dir is None or not repo_dir.exists():
return
try:
for pycache in repo_dir.rglob("__pycache__"):
try:
shutil.rmtree(pycache, ignore_errors=True)
except OSError:
pass
except Exception:
pass
fix: update banner — conflict recovery path + server self-restart after update (#816) * fix: update banner conflict recovery + server self-restart after update (#813 #814) * fix(update): restart must wait for in-flight update + reset force button on retry Two defects in the update banner flow found during review of PR #816: 1. Two-target race (webui + agent sequential) The client posts targets sequentially: webui succeeds and schedules a restart timer (2 s delay); client then posts agent; server begins agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull, killing the agent update and closing the client connection. User sees "Update failed (agent): Failed to fetch" even though webui did update, and the agent repo is in an unknown partial state. Fix: _schedule_restart() now blocks on _apply_lock before calling os.execv. If a second update is in flight when the timer fires, the restart thread waits until it completes. If nothing is in flight the lock acquire is instant, so no-op updates still restart immediately. 2. Stale force-update button across retries _showUpdateError sets btnForceUpdate to display:inline-block when res.conflict / res.diverged. Nothing resets it on the next retry, so a subsequent non-conflict error (e.g. network) leaves the stale force button visible pointing at the previous target. Fix: applyUpdates() now hides the force button and clears its data-target at the start of each attempt. Tests: - test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a helper thread, verifies execv is delayed until the lock is released. - test_schedule_restart_still_fires_when_no_update_in_flight: sanity check that the common path still works with no contention. - test_apply_updates_resets_force_button_at_start: regression guard that the reset appears before the update loop begins. Full suite: 1683 passed, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(update): hold _apply_lock through execv + fix banner error layout Two fixes from Opus review: 1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern acquired _apply_lock, released it, then called os.execv — leaving a brief window where a new update could start between release and execv. Fixed by moving os.execv inside the 'with _apply_lock:' block so the process is replaced while still holding the lock; no new update can acquire it. 2. Banner CSS layout (static/index.html): #updateError was a direct flex child of .update-banner (display:flex row), so long error messages sat inline between #updateMsg and the buttons instead of below the message. Wrapped #updateMsg + #updateError in a flex-column container so errors stack vertically under the status line. * docs: add v0.50.134 CHANGELOG entry --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
def _schedule_restart(delay: float = 2.0) -> None:
"""Re-exec this process after *delay* seconds.
Called after a successful update so that the freshly-pulled code is
loaded on the next request, rather than running with a mix of old and
new Python modules in sys.modules.
os.execv() replaces the current process image with a fresh interpreter
running the same argv — sessions are preserved on disk, the HTTP port
is reclaimed within the delay window, and the client's own
``setTimeout(() => location.reload(), 2500)`` lands after the restart.
Coordinates with ``_apply_lock``: when the user updates both webui
and agent, the client POSTs them sequentially. Without coordination
the restart timer scheduled by the first update's success would fire
while the second update's git-pull is still running, killing it mid-
stream and leaving the second repo in an unknown partial state.
Blocking on ``_apply_lock`` before ``os.execv`` means a pending
second update always completes before the restart happens.
"""
import os
import sys
def _do():
import time
time.sleep(delay)
# Hold _apply_lock through os.execv so no new update can start between
# the lock-release and the process replacement. Any in-flight update
# finishes first (since it holds the lock), and then the process is
# replaced while still holding the lock — meaning no new update can
# sneak in during the brief TOCTOU window that existed with the
# original acquire-release-execv sequence.
# Threads die when execv replaces the process image, so the lock is
# released atomically by the kernel.
with _apply_lock:
_wait_until_restart_safe()
# Purge bytecode caches so the new process imports from
# current source. Without this, Python may serve stale .pyc
# files whose mtime matches the just-pulled .py files,
# causing AttributeError when new methods are missing from
# cached class definitions.
if _AGENT_DIR is not None:
_purge_agent_pycache(Path(_AGENT_DIR))
_purge_agent_pycache(REPO_ROOT)
fix: update banner — conflict recovery path + server self-restart after update (#816) * fix: update banner conflict recovery + server self-restart after update (#813 #814) * fix(update): restart must wait for in-flight update + reset force button on retry Two defects in the update banner flow found during review of PR #816: 1. Two-target race (webui + agent sequential) The client posts targets sequentially: webui succeeds and schedules a restart timer (2 s delay); client then posts agent; server begins agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull, killing the agent update and closing the client connection. User sees "Update failed (agent): Failed to fetch" even though webui did update, and the agent repo is in an unknown partial state. Fix: _schedule_restart() now blocks on _apply_lock before calling os.execv. If a second update is in flight when the timer fires, the restart thread waits until it completes. If nothing is in flight the lock acquire is instant, so no-op updates still restart immediately. 2. Stale force-update button across retries _showUpdateError sets btnForceUpdate to display:inline-block when res.conflict / res.diverged. Nothing resets it on the next retry, so a subsequent non-conflict error (e.g. network) leaves the stale force button visible pointing at the previous target. Fix: applyUpdates() now hides the force button and clears its data-target at the start of each attempt. Tests: - test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a helper thread, verifies execv is delayed until the lock is released. - test_schedule_restart_still_fires_when_no_update_in_flight: sanity check that the common path still works with no contention. - test_apply_updates_resets_force_button_at_start: regression guard that the reset appears before the update loop begins. Full suite: 1683 passed, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(update): hold _apply_lock through execv + fix banner error layout Two fixes from Opus review: 1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern acquired _apply_lock, released it, then called os.execv — leaving a brief window where a new update could start between release and execv. Fixed by moving os.execv inside the 'with _apply_lock:' block so the process is replaced while still holding the lock; no new update can acquire it. 2. Banner CSS layout (static/index.html): #updateError was a direct flex child of .update-banner (display:flex row), so long error messages sat inline between #updateMsg and the buttons instead of below the message. Wrapped #updateMsg + #updateError in a flex-column container so errors stack vertically under the status line. * docs: add v0.50.134 CHANGELOG entry --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
try:
# Re-exec into the just-pulled image.
#
# sys.argv[0]'s meaning depends on how the server was launched:
#
# * Source checkout (`python server.py` via bootstrap.py /
# ctl.sh / start.sh): sys.argv[0] is the SCRIPT path
# (e.g. "/root/hermes-webui/server.py"), sys.executable is
# the interpreter. CPython treats argv[1] as the script to
# run, so we must pass [sys.executable] + sys.argv.
#
# * Frozen/packaged build (PyInstaller, embedded zipapp,
# etc.): sys.argv[0] == sys.executable == <binary>. Passing
# [sys.executable] + sys.argv would re-insert the binary as
# argv[1] — the kernel launches it, the interpreter treats
# the binary itself as the "script" to run, and execv
# effectively becomes a recursive no-op that never reaches
# bind(), leaving the WebUI stuck "offline" after every
# self-update. Pass argv as-is instead.
#
# Distinguish the two cases with sys.frozen (set by
# PyInstaller / zipapp / similar). For source checkouts the
# `[sys.executable] + sys.argv` form is the canonical CPython
# re-exec idiom (same shape Flask/Django reloaders use) and
# is the correct path.
#
# IMPORTANT: On Windows, os.execv() does NOT replace the
# current process — it spawns a new process while the old
# one keeps running. This causes "address already in use"
# because the old process still holds the port. On Windows
# we use subprocess.Popen() + os._exit() instead.
if sys.platform == 'win32':
import subprocess
if getattr(sys, "frozen", False):
args = sys.argv
else:
args = [sys.executable] + sys.argv
# Start new process detached, redirect all stdio to
# avoid broken-pipe errors when the parent exits.
subprocess.Popen(
args,
cwd=os.getcwd(),
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
close_fds=True,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# Exit immediately — the port is released as soon as
# this process dies, allowing the new process to bind.
os._exit(0)
else:
if getattr(sys, "frozen", False):
os.execv(sys.executable, sys.argv)
else:
os.execv(sys.executable, [sys.executable] + sys.argv)
fix: update banner — conflict recovery path + server self-restart after update (#816) * fix: update banner conflict recovery + server self-restart after update (#813 #814) * fix(update): restart must wait for in-flight update + reset force button on retry Two defects in the update banner flow found during review of PR #816: 1. Two-target race (webui + agent sequential) The client posts targets sequentially: webui succeeds and schedules a restart timer (2 s delay); client then posts agent; server begins agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull, killing the agent update and closing the client connection. User sees "Update failed (agent): Failed to fetch" even though webui did update, and the agent repo is in an unknown partial state. Fix: _schedule_restart() now blocks on _apply_lock before calling os.execv. If a second update is in flight when the timer fires, the restart thread waits until it completes. If nothing is in flight the lock acquire is instant, so no-op updates still restart immediately. 2. Stale force-update button across retries _showUpdateError sets btnForceUpdate to display:inline-block when res.conflict / res.diverged. Nothing resets it on the next retry, so a subsequent non-conflict error (e.g. network) leaves the stale force button visible pointing at the previous target. Fix: applyUpdates() now hides the force button and clears its data-target at the start of each attempt. Tests: - test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a helper thread, verifies execv is delayed until the lock is released. - test_schedule_restart_still_fires_when_no_update_in_flight: sanity check that the common path still works with no contention. - test_apply_updates_resets_force_button_at_start: regression guard that the reset appears before the update loop begins. Full suite: 1683 passed, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(update): hold _apply_lock through execv + fix banner error layout Two fixes from Opus review: 1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern acquired _apply_lock, released it, then called os.execv — leaving a brief window where a new update could start between release and execv. Fixed by moving os.execv inside the 'with _apply_lock:' block so the process is replaced while still holding the lock; no new update can acquire it. 2. Banner CSS layout (static/index.html): #updateError was a direct flex child of .update-banner (display:flex row), so long error messages sat inline between #updateMsg and the buttons instead of below the message. Wrapped #updateMsg + #updateError in a flex-column container so errors stack vertically under the status line. * docs: add v0.50.134 CHANGELOG entry --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
except Exception:
# Last-resort: if execv fails for any reason, just exit so the
# process supervisor (start.sh / Docker) restarts us.
fix: update banner — conflict recovery path + server self-restart after update (#816) * fix: update banner conflict recovery + server self-restart after update (#813 #814) * fix(update): restart must wait for in-flight update + reset force button on retry Two defects in the update banner flow found during review of PR #816: 1. Two-target race (webui + agent sequential) The client posts targets sequentially: webui succeeds and schedules a restart timer (2 s delay); client then posts agent; server begins agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull, killing the agent update and closing the client connection. User sees "Update failed (agent): Failed to fetch" even though webui did update, and the agent repo is in an unknown partial state. Fix: _schedule_restart() now blocks on _apply_lock before calling os.execv. If a second update is in flight when the timer fires, the restart thread waits until it completes. If nothing is in flight the lock acquire is instant, so no-op updates still restart immediately. 2. Stale force-update button across retries _showUpdateError sets btnForceUpdate to display:inline-block when res.conflict / res.diverged. Nothing resets it on the next retry, so a subsequent non-conflict error (e.g. network) leaves the stale force button visible pointing at the previous target. Fix: applyUpdates() now hides the force button and clears its data-target at the start of each attempt. Tests: - test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a helper thread, verifies execv is delayed until the lock is released. - test_schedule_restart_still_fires_when_no_update_in_flight: sanity check that the common path still works with no contention. - test_apply_updates_resets_force_button_at_start: regression guard that the reset appears before the update loop begins. Full suite: 1683 passed, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(update): hold _apply_lock through execv + fix banner error layout Two fixes from Opus review: 1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern acquired _apply_lock, released it, then called os.execv — leaving a brief window where a new update could start between release and execv. Fixed by moving os.execv inside the 'with _apply_lock:' block so the process is replaced while still holding the lock; no new update can acquire it. 2. Banner CSS layout (static/index.html): #updateError was a direct flex child of .update-banner (display:flex row), so long error messages sat inline between #updateMsg and the buttons instead of below the message. Wrapped #updateMsg + #updateError in a flex-column container so errors stack vertically under the status line. * docs: add v0.50.134 CHANGELOG entry --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
os._exit(0)
threading.Thread(target=_do, daemon=True).start()
def apply_force_update(target: str) -> dict:
"""Force-reset the target repo to the latest remote HEAD.
Unlike apply_update() which requires a clean working tree and refuses
merge conflicts, this discards all local modifications (checkout .) and
resets to origin/<branch> — equivalent to what the diverged/conflict
error messages ask the user to run manually.
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.
"""
blocker_snapshot = _restart_blocker_snapshot()
if blocker_snapshot.get('restart_blocked'):
return _restart_blocked_response(target, blocker_snapshot)
fix: update banner — conflict recovery path + server self-restart after update (#816) * fix: update banner conflict recovery + server self-restart after update (#813 #814) * fix(update): restart must wait for in-flight update + reset force button on retry Two defects in the update banner flow found during review of PR #816: 1. Two-target race (webui + agent sequential) The client posts targets sequentially: webui succeeds and schedules a restart timer (2 s delay); client then posts agent; server begins agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull, killing the agent update and closing the client connection. User sees "Update failed (agent): Failed to fetch" even though webui did update, and the agent repo is in an unknown partial state. Fix: _schedule_restart() now blocks on _apply_lock before calling os.execv. If a second update is in flight when the timer fires, the restart thread waits until it completes. If nothing is in flight the lock acquire is instant, so no-op updates still restart immediately. 2. Stale force-update button across retries _showUpdateError sets btnForceUpdate to display:inline-block when res.conflict / res.diverged. Nothing resets it on the next retry, so a subsequent non-conflict error (e.g. network) leaves the stale force button visible pointing at the previous target. Fix: applyUpdates() now hides the force button and clears its data-target at the start of each attempt. Tests: - test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a helper thread, verifies execv is delayed until the lock is released. - test_schedule_restart_still_fires_when_no_update_in_flight: sanity check that the common path still works with no contention. - test_apply_updates_resets_force_button_at_start: regression guard that the reset appears before the update loop begins. Full suite: 1683 passed, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(update): hold _apply_lock through execv + fix banner error layout Two fixes from Opus review: 1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern acquired _apply_lock, released it, then called os.execv — leaving a brief window where a new update could start between release and execv. Fixed by moving os.execv inside the 'with _apply_lock:' block so the process is replaced while still holding the lock; no new update can acquire it. 2. Banner CSS layout (static/index.html): #updateError was a direct flex child of .update-banner (display:flex row), so long error messages sat inline between #updateMsg and the buttons instead of below the message. Wrapped #updateMsg + #updateError in a flex-column container so errors stack vertically under the status line. * docs: add v0.50.134 CHANGELOG entry --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
if not _apply_lock.acquire(blocking=False):
return {'ok': False, 'message': 'Update already in progress'}
try:
if target == 'webui':
path = REPO_ROOT
elif target == 'agent':
path = _AGENT_DIR
else:
return {'ok': False, 'message': f'Unknown target: {target}'}
if path is None or not (path / '.git').exists():
return {'ok': False, 'message': 'Not a git repository'}
# --force so a remote re-tag (e.g. squash-merge that re-points an
# existing release tag) doesn't jam the apply path with "would clobber
# existing tag". See #2756.
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet', '--tags', '--force'], path, timeout=15)
fix: update banner — conflict recovery path + server self-restart after update (#816) * fix: update banner conflict recovery + server self-restart after update (#813 #814) * fix(update): restart must wait for in-flight update + reset force button on retry Two defects in the update banner flow found during review of PR #816: 1. Two-target race (webui + agent sequential) The client posts targets sequentially: webui succeeds and schedules a restart timer (2 s delay); client then posts agent; server begins agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull, killing the agent update and closing the client connection. User sees "Update failed (agent): Failed to fetch" even though webui did update, and the agent repo is in an unknown partial state. Fix: _schedule_restart() now blocks on _apply_lock before calling os.execv. If a second update is in flight when the timer fires, the restart thread waits until it completes. If nothing is in flight the lock acquire is instant, so no-op updates still restart immediately. 2. Stale force-update button across retries _showUpdateError sets btnForceUpdate to display:inline-block when res.conflict / res.diverged. Nothing resets it on the next retry, so a subsequent non-conflict error (e.g. network) leaves the stale force button visible pointing at the previous target. Fix: applyUpdates() now hides the force button and clears its data-target at the start of each attempt. Tests: - test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a helper thread, verifies execv is delayed until the lock is released. - test_schedule_restart_still_fires_when_no_update_in_flight: sanity check that the common path still works with no contention. - test_apply_updates_resets_force_button_at_start: regression guard that the reset appears before the update loop begins. Full suite: 1683 passed, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(update): hold _apply_lock through execv + fix banner error layout Two fixes from Opus review: 1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern acquired _apply_lock, released it, then called os.execv — leaving a brief window where a new update could start between release and execv. Fixed by moving os.execv inside the 'with _apply_lock:' block so the process is replaced while still holding the lock; no new update can acquire it. 2. Banner CSS layout (static/index.html): #updateError was a direct flex child of .update-banner (display:flex row), so long error messages sat inline between #updateMsg and the buttons instead of below the message. Wrapped #updateMsg + #updateError in a flex-column container so errors stack vertically under the status line. * docs: add v0.50.134 CHANGELOG entry --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
if not fetch_ok:
return {
'ok': False,
'message': 'Could not reach the remote repository. Check your connection.',
}
compare_ref = _select_apply_compare_ref(path)
fix: update banner — conflict recovery path + server self-restart after update (#816) * fix: update banner conflict recovery + server self-restart after update (#813 #814) * fix(update): restart must wait for in-flight update + reset force button on retry Two defects in the update banner flow found during review of PR #816: 1. Two-target race (webui + agent sequential) The client posts targets sequentially: webui succeeds and schedules a restart timer (2 s delay); client then posts agent; server begins agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull, killing the agent update and closing the client connection. User sees "Update failed (agent): Failed to fetch" even though webui did update, and the agent repo is in an unknown partial state. Fix: _schedule_restart() now blocks on _apply_lock before calling os.execv. If a second update is in flight when the timer fires, the restart thread waits until it completes. If nothing is in flight the lock acquire is instant, so no-op updates still restart immediately. 2. Stale force-update button across retries _showUpdateError sets btnForceUpdate to display:inline-block when res.conflict / res.diverged. Nothing resets it on the next retry, so a subsequent non-conflict error (e.g. network) leaves the stale force button visible pointing at the previous target. Fix: applyUpdates() now hides the force button and clears its data-target at the start of each attempt. Tests: - test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a helper thread, verifies execv is delayed until the lock is released. - test_schedule_restart_still_fires_when_no_update_in_flight: sanity check that the common path still works with no contention. - test_apply_updates_resets_force_button_at_start: regression guard that the reset appears before the update loop begins. Full suite: 1683 passed, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(update): hold _apply_lock through execv + fix banner error layout Two fixes from Opus review: 1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern acquired _apply_lock, released it, then called os.execv — leaving a brief window where a new update could start between release and execv. Fixed by moving os.execv inside the 'with _apply_lock:' block so the process is replaced while still holding the lock; no new update can acquire it. 2. Banner CSS layout (static/index.html): #updateError was a direct flex child of .update-banner (display:flex row), so long error messages sat inline between #updateMsg and the buttons instead of below the message. Wrapped #updateMsg + #updateError in a flex-column container so errors stack vertically under the status line. * docs: add v0.50.134 CHANGELOG entry --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
# Discard local modifications and untracked colliders before resetting.
# Do not use -x: ignored build/cache artifacts should survive force update.
fix: update banner — conflict recovery path + server self-restart after update (#816) * fix: update banner conflict recovery + server self-restart after update (#813 #814) * fix(update): restart must wait for in-flight update + reset force button on retry Two defects in the update banner flow found during review of PR #816: 1. Two-target race (webui + agent sequential) The client posts targets sequentially: webui succeeds and schedules a restart timer (2 s delay); client then posts agent; server begins agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull, killing the agent update and closing the client connection. User sees "Update failed (agent): Failed to fetch" even though webui did update, and the agent repo is in an unknown partial state. Fix: _schedule_restart() now blocks on _apply_lock before calling os.execv. If a second update is in flight when the timer fires, the restart thread waits until it completes. If nothing is in flight the lock acquire is instant, so no-op updates still restart immediately. 2. Stale force-update button across retries _showUpdateError sets btnForceUpdate to display:inline-block when res.conflict / res.diverged. Nothing resets it on the next retry, so a subsequent non-conflict error (e.g. network) leaves the stale force button visible pointing at the previous target. Fix: applyUpdates() now hides the force button and clears its data-target at the start of each attempt. Tests: - test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a helper thread, verifies execv is delayed until the lock is released. - test_schedule_restart_still_fires_when_no_update_in_flight: sanity check that the common path still works with no contention. - test_apply_updates_resets_force_button_at_start: regression guard that the reset appears before the update loop begins. Full suite: 1683 passed, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(update): hold _apply_lock through execv + fix banner error layout Two fixes from Opus review: 1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern acquired _apply_lock, released it, then called os.execv — leaving a brief window where a new update could start between release and execv. Fixed by moving os.execv inside the 'with _apply_lock:' block so the process is replaced while still holding the lock; no new update can acquire it. 2. Banner CSS layout (static/index.html): #updateError was a direct flex child of .update-banner (display:flex row), so long error messages sat inline between #updateMsg and the buttons instead of below the message. Wrapped #updateMsg + #updateError in a flex-column container so errors stack vertically under the status line. * docs: add v0.50.134 CHANGELOG entry --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
_run_git(['checkout', '.'], path)
_, clean_ok = _run_git(['clean', '-fd'], path)
if not clean_ok:
return {
'ok': False,
'message': 'Failed to remove untracked files before force reset',
}
fix: update banner — conflict recovery path + server self-restart after update (#816) * fix: update banner conflict recovery + server self-restart after update (#813 #814) * fix(update): restart must wait for in-flight update + reset force button on retry Two defects in the update banner flow found during review of PR #816: 1. Two-target race (webui + agent sequential) The client posts targets sequentially: webui succeeds and schedules a restart timer (2 s delay); client then posts agent; server begins agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull, killing the agent update and closing the client connection. User sees "Update failed (agent): Failed to fetch" even though webui did update, and the agent repo is in an unknown partial state. Fix: _schedule_restart() now blocks on _apply_lock before calling os.execv. If a second update is in flight when the timer fires, the restart thread waits until it completes. If nothing is in flight the lock acquire is instant, so no-op updates still restart immediately. 2. Stale force-update button across retries _showUpdateError sets btnForceUpdate to display:inline-block when res.conflict / res.diverged. Nothing resets it on the next retry, so a subsequent non-conflict error (e.g. network) leaves the stale force button visible pointing at the previous target. Fix: applyUpdates() now hides the force button and clears its data-target at the start of each attempt. Tests: - test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a helper thread, verifies execv is delayed until the lock is released. - test_schedule_restart_still_fires_when_no_update_in_flight: sanity check that the common path still works with no contention. - test_apply_updates_resets_force_button_at_start: regression guard that the reset appears before the update loop begins. Full suite: 1683 passed, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(update): hold _apply_lock through execv + fix banner error layout Two fixes from Opus review: 1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern acquired _apply_lock, released it, then called os.execv — leaving a brief window where a new update could start between release and execv. Fixed by moving os.execv inside the 'with _apply_lock:' block so the process is replaced while still holding the lock; no new update can acquire it. 2. Banner CSS layout (static/index.html): #updateError was a direct flex child of .update-banner (display:flex row), so long error messages sat inline between #updateMsg and the buttons instead of below the message. Wrapped #updateMsg + #updateError in a flex-column container so errors stack vertically under the status line. * docs: add v0.50.134 CHANGELOG entry --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
_, ok = _run_git(['reset', '--hard', compare_ref], path)
if not ok:
return {'ok': False, 'message': f'Force reset to {compare_ref} failed'}
with _cache_lock:
_update_cache['checked_at'] = 0
_schedule_restart()
return {
'ok': True,
'message': f'{target} force-updated to {compare_ref}',
'target': target,
'restart_scheduled': True,
}
finally:
_apply_lock.release()
def apply_update(target):
"""Stash, pull --ff-only, pop for the given target repo."""
blocker_snapshot = _restart_blocker_snapshot()
if blocker_snapshot.get('restart_blocked'):
return _restart_blocked_response(target, blocker_snapshot)
if not _apply_lock.acquire(blocking=False):
return {'ok': False, 'message': 'Update already in progress'}
try:
return _apply_update_inner(target)
finally:
_apply_lock.release()
def _apply_update_inner(target):
"""Inner implementation of apply_update, called under _apply_lock."""
if target == 'webui':
path = REPO_ROOT
elif target == 'agent':
path = _AGENT_DIR
else:
return {'ok': False, 'message': f'Unknown target: {target}'}
if path is None or not (path / '.git').exists():
return {'ok': False, 'message': 'Not a git repository'}
v0.44.0: approval fix, login CSP, update diagnostics, Lucide icons * fix: approval pending check broken by stale has_pending import (#228) api/routes.py imported has_pending/pop_pending from tools.approval, but the agent module renamed has_pending to has_blocking_approval (checks gateway queue, not _pending dict) and removed pop_pending. The import fell through to fallback lambdas that always returned False, making GET /api/approval/pending always return {pending:null} even after a successful inject_test. Fix: check _pending directly under _lock — same dict submit_pending writes to. Stale imports removed. Before: 554 pass, 1 fail | After: 555 pass, 0 fail * fix: move login JS into external file, remove inline handlers (#226) Login page used inline onsubmit/onkeydown handlers and an inline <script> block — all blocked by strict script-src CSP, causing silent login failure. Fix: extract doLogin() and Enter key listener into static/login.js (served from /static/, already a public path). Form uses id='login-form' and data-* attributes for i18n strings instead of injected JS literals. Also guards res.json() parse with try/catch so non-JSON error bodies (e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'. Fixes #222. * fix: improve update error messages when pull fails (#227) _apply_update_inner() ran git pull --ff-only and returned only raw stderr on failure, making all failure modes indistinguishable. Fix: explicit git fetch before pull; if fetch fails, returns human-readable network error. Diverged history and missing upstream tracking branch each get distinct messages with exact recovery commands. Generic fallback truncates to 300 chars and shows sentinel when git produces no output. Also adds tests/test_update_checker.py with 13 tests covering all 4 new diagnostic code paths (0 tests existed before). Fixes #223. * fix: stabilize 30s terminal approval prompt visibility (#225) Adds minimum 30-second visibility guard for the approval card using _approvalVisibleSince, _approvalHideTimer, and a signature fingerprint to deduplicate repeated poll ticks. Fix: respondApproval() and all stream-end paths (done/cancel/apperror/ error/start-error) now call hideApprovalCard(true) so the card hides immediately when the user responds or the session ends. The 30s guard only applies to mid-session poll ticks where the approval is still live but briefly absent. Adds 11 structural tests covering the new timer variables, force parameter, force-on-respond, force-on-stream-end, and poll-loop no-force behavior. * feat: replace emoji icons with self-hosted Lucide SVG icons (#221) Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled in static/icons.js (no CDN dependency). Adds li(name) function returning inline SVG geometry from a hardcoded whitelist — unknown keys return '' so dynamic server-supplied names never inject arbitrary SVG. Changes: - static/icons.js: new file with 21 icon paths + li() renderer - static/index.html: all nav/action buttons now use li() icons - static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons - static/messages.js: cancelStream button uses SVG square stop icon - .gitignore: adds node_modules/ entry Verified: all 35 onclick= functions exist in JS, all 21 li() calls reference defined icons, applyBotName() selectors intact, version label present, no removed IDs referenced by JS. * docs: v0.44.0 release notes, bump version, update test counts --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 17:02:28 +00:00
# Fetch before attempting pull, so the remote ref is current.
# --force so a remote re-tag doesn't block the update path (see #2756).
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet', '--tags', '--force'], path, timeout=15)
v0.44.0: approval fix, login CSP, update diagnostics, Lucide icons * fix: approval pending check broken by stale has_pending import (#228) api/routes.py imported has_pending/pop_pending from tools.approval, but the agent module renamed has_pending to has_blocking_approval (checks gateway queue, not _pending dict) and removed pop_pending. The import fell through to fallback lambdas that always returned False, making GET /api/approval/pending always return {pending:null} even after a successful inject_test. Fix: check _pending directly under _lock — same dict submit_pending writes to. Stale imports removed. Before: 554 pass, 1 fail | After: 555 pass, 0 fail * fix: move login JS into external file, remove inline handlers (#226) Login page used inline onsubmit/onkeydown handlers and an inline <script> block — all blocked by strict script-src CSP, causing silent login failure. Fix: extract doLogin() and Enter key listener into static/login.js (served from /static/, already a public path). Form uses id='login-form' and data-* attributes for i18n strings instead of injected JS literals. Also guards res.json() parse with try/catch so non-JSON error bodies (e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'. Fixes #222. * fix: improve update error messages when pull fails (#227) _apply_update_inner() ran git pull --ff-only and returned only raw stderr on failure, making all failure modes indistinguishable. Fix: explicit git fetch before pull; if fetch fails, returns human-readable network error. Diverged history and missing upstream tracking branch each get distinct messages with exact recovery commands. Generic fallback truncates to 300 chars and shows sentinel when git produces no output. Also adds tests/test_update_checker.py with 13 tests covering all 4 new diagnostic code paths (0 tests existed before). Fixes #223. * fix: stabilize 30s terminal approval prompt visibility (#225) Adds minimum 30-second visibility guard for the approval card using _approvalVisibleSince, _approvalHideTimer, and a signature fingerprint to deduplicate repeated poll ticks. Fix: respondApproval() and all stream-end paths (done/cancel/apperror/ error/start-error) now call hideApprovalCard(true) so the card hides immediately when the user responds or the session ends. The 30s guard only applies to mid-session poll ticks where the approval is still live but briefly absent. Adds 11 structural tests covering the new timer variables, force parameter, force-on-respond, force-on-stream-end, and poll-loop no-force behavior. * feat: replace emoji icons with self-hosted Lucide SVG icons (#221) Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled in static/icons.js (no CDN dependency). Adds li(name) function returning inline SVG geometry from a hardcoded whitelist — unknown keys return '' so dynamic server-supplied names never inject arbitrary SVG. Changes: - static/icons.js: new file with 21 icon paths + li() renderer - static/index.html: all nav/action buttons now use li() icons - static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons - static/messages.js: cancelStream button uses SVG square stop icon - .gitignore: adds node_modules/ entry Verified: all 35 onclick= functions exist in JS, all 21 li() calls reference defined icons, applyBotName() selectors intact, version label present, no removed IDs referenced by JS. * docs: v0.44.0 release notes, bump version, update test counts --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 17:02:28 +00:00
if not fetch_ok:
return {
'ok': False,
'message': (
'Could not reach the remote repository. '
'Check your internet connection and try again.'
),
}
compare_ref = _select_apply_compare_ref(path)
# Check for dirty working tree (ignore untracked files — git stash
# doesn't include them, so stashing on '??' alone leaves nothing to pop)
status_out, status_ok = _run_git(
['status', '--porcelain', '--untracked-files=no'], path
)
if not status_ok:
return {'ok': False, 'message': f'Failed to inspect repo status: {status_out[:200]}'}
# Fail early on unresolved merge conflicts
if any(line[:2] in {'DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'}
for line in status_out.splitlines()):
fix: update banner — conflict recovery path + server self-restart after update (#816) * fix: update banner conflict recovery + server self-restart after update (#813 #814) * fix(update): restart must wait for in-flight update + reset force button on retry Two defects in the update banner flow found during review of PR #816: 1. Two-target race (webui + agent sequential) The client posts targets sequentially: webui succeeds and schedules a restart timer (2 s delay); client then posts agent; server begins agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull, killing the agent update and closing the client connection. User sees "Update failed (agent): Failed to fetch" even though webui did update, and the agent repo is in an unknown partial state. Fix: _schedule_restart() now blocks on _apply_lock before calling os.execv. If a second update is in flight when the timer fires, the restart thread waits until it completes. If nothing is in flight the lock acquire is instant, so no-op updates still restart immediately. 2. Stale force-update button across retries _showUpdateError sets btnForceUpdate to display:inline-block when res.conflict / res.diverged. Nothing resets it on the next retry, so a subsequent non-conflict error (e.g. network) leaves the stale force button visible pointing at the previous target. Fix: applyUpdates() now hides the force button and clears its data-target at the start of each attempt. Tests: - test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a helper thread, verifies execv is delayed until the lock is released. - test_schedule_restart_still_fires_when_no_update_in_flight: sanity check that the common path still works with no contention. - test_apply_updates_resets_force_button_at_start: regression guard that the reset appears before the update loop begins. Full suite: 1683 passed, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(update): hold _apply_lock through execv + fix banner error layout Two fixes from Opus review: 1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern acquired _apply_lock, released it, then called os.execv — leaving a brief window where a new update could start between release and execv. Fixed by moving os.execv inside the 'with _apply_lock:' block so the process is replaced while still holding the lock; no new update can acquire it. 2. Banner CSS layout (static/index.html): #updateError was a direct flex child of .update-banner (display:flex row), so long error messages sat inline between #updateMsg and the buttons instead of below the message. Wrapped #updateMsg + #updateError in a flex-column container so errors stack vertically under the status line. * docs: add v0.50.134 CHANGELOG entry --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
return {
'ok': False,
'message': (
f'The local {target} repo has unresolved merge conflicts. '
'To reset to the latest remote version run: '
'git -C ' + str(path) + ' checkout . && '
'git -C ' + str(path) + ' pull --ff-only'
),
'conflict': True,
}
stashed = False
if status_out:
_, ok = _run_git(['stash', 'push', '-m', 'hermes-update-autostash'], path)
if not ok:
return {'ok': False, 'message': 'Failed to stash local changes'}
stashed = True
# Pull with ff-only (no merge commits).
# Split tracking refs like 'origin/main' into separate remote + branch
# arguments — git treats 'origin/main' as a repository name otherwise.
remote, branch = _split_remote_ref(compare_ref)
pull_args = ['pull', '--ff-only']
if remote:
pull_args.extend([remote, branch])
else:
pull_args.extend(['origin', compare_ref])
pull_out, pull_ok = _run_git(pull_args, path, timeout=30)
if not pull_ok:
pull_lower = pull_out.lower()
detail = pull_out.strip()[:300] if pull_out.strip() else '(no output from git)'
untracked_collision = (
'untracked working tree files would be overwritten' in pull_lower
)
diverged_failure = (
'not possible to fast-forward' in pull_lower or 'diverged' in pull_lower
)
restored_stash = False
stash_drop_failed = False
if stashed:
_, apply_ok = _run_git(['stash', 'apply'], path)
if apply_ok:
_, drop_ok = _run_git(['stash', 'drop'], path)
restored_stash = True
stash_drop_failed = not drop_ok
else:
_, reset_ok = _run_git(['reset', '--hard', 'HEAD'], path)
if not reset_ok:
response = {
'ok': False,
'message': (
'Pull failed, and failed to clean up a stash-apply '
'conflict while restoring local changes. Manual '
'intervention needed: run git -C ' + str(path) + ' '
'reset --hard HEAD to remove conflict markers. Your '
'changes remain in the git stash. Pull error: '
+ detail
),
'stash_conflict': True,
}
if diverged_failure:
response['diverged'] = True
return response
response = {
'ok': False,
'message': (
f'Pull failed, and your local {target} modifications '
'conflicted while restoring from stash. The index and '
'tracked files were restored to HEAD, and your changes '
'remain in the git stash. To inspect: git -C ' + str(path) + ' stash show -p. '
'To re-apply: git -C ' + str(path) + ' stash apply, then '
'resolve conflicts. Pull error: ' + detail
),
'stash_conflict': True,
}
if diverged_failure:
response['diverged'] = True
return response
restored_note_parts = []
if restored_stash:
restored_note_parts.append(
f'Local {target} modifications were restored to the working '
'tree; save or stash them before running destructive recovery '
'commands.'
)
if stash_drop_failed:
restored_note_parts.append(
'The temporary stash entry may still be present because '
'git stash drop failed.'
)
restored_note = ' '.join(restored_note_parts)
v0.44.0: approval fix, login CSP, update diagnostics, Lucide icons * fix: approval pending check broken by stale has_pending import (#228) api/routes.py imported has_pending/pop_pending from tools.approval, but the agent module renamed has_pending to has_blocking_approval (checks gateway queue, not _pending dict) and removed pop_pending. The import fell through to fallback lambdas that always returned False, making GET /api/approval/pending always return {pending:null} even after a successful inject_test. Fix: check _pending directly under _lock — same dict submit_pending writes to. Stale imports removed. Before: 554 pass, 1 fail | After: 555 pass, 0 fail * fix: move login JS into external file, remove inline handlers (#226) Login page used inline onsubmit/onkeydown handlers and an inline <script> block — all blocked by strict script-src CSP, causing silent login failure. Fix: extract doLogin() and Enter key listener into static/login.js (served from /static/, already a public path). Form uses id='login-form' and data-* attributes for i18n strings instead of injected JS literals. Also guards res.json() parse with try/catch so non-JSON error bodies (e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'. Fixes #222. * fix: improve update error messages when pull fails (#227) _apply_update_inner() ran git pull --ff-only and returned only raw stderr on failure, making all failure modes indistinguishable. Fix: explicit git fetch before pull; if fetch fails, returns human-readable network error. Diverged history and missing upstream tracking branch each get distinct messages with exact recovery commands. Generic fallback truncates to 300 chars and shows sentinel when git produces no output. Also adds tests/test_update_checker.py with 13 tests covering all 4 new diagnostic code paths (0 tests existed before). Fixes #223. * fix: stabilize 30s terminal approval prompt visibility (#225) Adds minimum 30-second visibility guard for the approval card using _approvalVisibleSince, _approvalHideTimer, and a signature fingerprint to deduplicate repeated poll ticks. Fix: respondApproval() and all stream-end paths (done/cancel/apperror/ error/start-error) now call hideApprovalCard(true) so the card hides immediately when the user responds or the session ends. The 30s guard only applies to mid-session poll ticks where the approval is still live but briefly absent. Adds 11 structural tests covering the new timer variables, force parameter, force-on-respond, force-on-stream-end, and poll-loop no-force behavior. * feat: replace emoji icons with self-hosted Lucide SVG icons (#221) Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled in static/icons.js (no CDN dependency). Adds li(name) function returning inline SVG geometry from a hardcoded whitelist — unknown keys return '' so dynamic server-supplied names never inject arbitrary SVG. Changes: - static/icons.js: new file with 21 icon paths + li() renderer - static/index.html: all nav/action buttons now use li() icons - static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons - static/messages.js: cancelStream button uses SVG square stop icon - .gitignore: adds node_modules/ entry Verified: all 35 onclick= functions exist in JS, all 21 li() calls reference defined icons, applyBotName() selectors intact, version label present, no removed IDs referenced by JS. * docs: v0.44.0 release notes, bump version, update test counts --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 17:02:28 +00:00
# Diagnose the most common failure modes and surface actionable messages.
if diverged_failure:
message_parts = [
f'The local {target} repo has commits that are not on the remote '
'branch, so a fast-forward update is not possible.'
]
if restored_note:
message_parts.append(restored_note)
message_parts.append(
'Run: git -C ' + str(path) + ' fetch origin && '
'git -C ' + str(path) + ' reset --hard ' + compare_ref
)
v0.44.0: approval fix, login CSP, update diagnostics, Lucide icons * fix: approval pending check broken by stale has_pending import (#228) api/routes.py imported has_pending/pop_pending from tools.approval, but the agent module renamed has_pending to has_blocking_approval (checks gateway queue, not _pending dict) and removed pop_pending. The import fell through to fallback lambdas that always returned False, making GET /api/approval/pending always return {pending:null} even after a successful inject_test. Fix: check _pending directly under _lock — same dict submit_pending writes to. Stale imports removed. Before: 554 pass, 1 fail | After: 555 pass, 0 fail * fix: move login JS into external file, remove inline handlers (#226) Login page used inline onsubmit/onkeydown handlers and an inline <script> block — all blocked by strict script-src CSP, causing silent login failure. Fix: extract doLogin() and Enter key listener into static/login.js (served from /static/, already a public path). Form uses id='login-form' and data-* attributes for i18n strings instead of injected JS literals. Also guards res.json() parse with try/catch so non-JSON error bodies (e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'. Fixes #222. * fix: improve update error messages when pull fails (#227) _apply_update_inner() ran git pull --ff-only and returned only raw stderr on failure, making all failure modes indistinguishable. Fix: explicit git fetch before pull; if fetch fails, returns human-readable network error. Diverged history and missing upstream tracking branch each get distinct messages with exact recovery commands. Generic fallback truncates to 300 chars and shows sentinel when git produces no output. Also adds tests/test_update_checker.py with 13 tests covering all 4 new diagnostic code paths (0 tests existed before). Fixes #223. * fix: stabilize 30s terminal approval prompt visibility (#225) Adds minimum 30-second visibility guard for the approval card using _approvalVisibleSince, _approvalHideTimer, and a signature fingerprint to deduplicate repeated poll ticks. Fix: respondApproval() and all stream-end paths (done/cancel/apperror/ error/start-error) now call hideApprovalCard(true) so the card hides immediately when the user responds or the session ends. The 30s guard only applies to mid-session poll ticks where the approval is still live but briefly absent. Adds 11 structural tests covering the new timer variables, force parameter, force-on-respond, force-on-stream-end, and poll-loop no-force behavior. * feat: replace emoji icons with self-hosted Lucide SVG icons (#221) Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled in static/icons.js (no CDN dependency). Adds li(name) function returning inline SVG geometry from a hardcoded whitelist — unknown keys return '' so dynamic server-supplied names never inject arbitrary SVG. Changes: - static/icons.js: new file with 21 icon paths + li() renderer - static/index.html: all nav/action buttons now use li() icons - static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons - static/messages.js: cancelStream button uses SVG square stop icon - .gitignore: adds node_modules/ entry Verified: all 35 onclick= functions exist in JS, all 21 li() calls reference defined icons, applyBotName() selectors intact, version label present, no removed IDs referenced by JS. * docs: v0.44.0 release notes, bump version, update test counts --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 17:02:28 +00:00
return {
'ok': False,
'message': ' '.join(message_parts),
v0.44.0: approval fix, login CSP, update diagnostics, Lucide icons * fix: approval pending check broken by stale has_pending import (#228) api/routes.py imported has_pending/pop_pending from tools.approval, but the agent module renamed has_pending to has_blocking_approval (checks gateway queue, not _pending dict) and removed pop_pending. The import fell through to fallback lambdas that always returned False, making GET /api/approval/pending always return {pending:null} even after a successful inject_test. Fix: check _pending directly under _lock — same dict submit_pending writes to. Stale imports removed. Before: 554 pass, 1 fail | After: 555 pass, 0 fail * fix: move login JS into external file, remove inline handlers (#226) Login page used inline onsubmit/onkeydown handlers and an inline <script> block — all blocked by strict script-src CSP, causing silent login failure. Fix: extract doLogin() and Enter key listener into static/login.js (served from /static/, already a public path). Form uses id='login-form' and data-* attributes for i18n strings instead of injected JS literals. Also guards res.json() parse with try/catch so non-JSON error bodies (e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'. Fixes #222. * fix: improve update error messages when pull fails (#227) _apply_update_inner() ran git pull --ff-only and returned only raw stderr on failure, making all failure modes indistinguishable. Fix: explicit git fetch before pull; if fetch fails, returns human-readable network error. Diverged history and missing upstream tracking branch each get distinct messages with exact recovery commands. Generic fallback truncates to 300 chars and shows sentinel when git produces no output. Also adds tests/test_update_checker.py with 13 tests covering all 4 new diagnostic code paths (0 tests existed before). Fixes #223. * fix: stabilize 30s terminal approval prompt visibility (#225) Adds minimum 30-second visibility guard for the approval card using _approvalVisibleSince, _approvalHideTimer, and a signature fingerprint to deduplicate repeated poll ticks. Fix: respondApproval() and all stream-end paths (done/cancel/apperror/ error/start-error) now call hideApprovalCard(true) so the card hides immediately when the user responds or the session ends. The 30s guard only applies to mid-session poll ticks where the approval is still live but briefly absent. Adds 11 structural tests covering the new timer variables, force parameter, force-on-respond, force-on-stream-end, and poll-loop no-force behavior. * feat: replace emoji icons with self-hosted Lucide SVG icons (#221) Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled in static/icons.js (no CDN dependency). Adds li(name) function returning inline SVG geometry from a hardcoded whitelist — unknown keys return '' so dynamic server-supplied names never inject arbitrary SVG. Changes: - static/icons.js: new file with 21 icon paths + li() renderer - static/index.html: all nav/action buttons now use li() icons - static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons - static/messages.js: cancelStream button uses SVG square stop icon - .gitignore: adds node_modules/ entry Verified: all 35 onclick= functions exist in JS, all 21 li() calls reference defined icons, applyBotName() selectors intact, version label present, no removed IDs referenced by JS. * docs: v0.44.0 release notes, bump version, update test counts --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 17:02:28 +00:00
'diverged': True,
}
if 'does not track' in pull_lower or 'no tracking information' in pull_lower:
message_parts = [
f'The local {target} branch has no upstream tracking branch configured.'
]
if restored_note:
message_parts.append(restored_note)
message_parts.append(
'Run: git -C ' + str(path) + ' branch --set-upstream-to=' + compare_ref
)
v0.44.0: approval fix, login CSP, update diagnostics, Lucide icons * fix: approval pending check broken by stale has_pending import (#228) api/routes.py imported has_pending/pop_pending from tools.approval, but the agent module renamed has_pending to has_blocking_approval (checks gateway queue, not _pending dict) and removed pop_pending. The import fell through to fallback lambdas that always returned False, making GET /api/approval/pending always return {pending:null} even after a successful inject_test. Fix: check _pending directly under _lock — same dict submit_pending writes to. Stale imports removed. Before: 554 pass, 1 fail | After: 555 pass, 0 fail * fix: move login JS into external file, remove inline handlers (#226) Login page used inline onsubmit/onkeydown handlers and an inline <script> block — all blocked by strict script-src CSP, causing silent login failure. Fix: extract doLogin() and Enter key listener into static/login.js (served from /static/, already a public path). Form uses id='login-form' and data-* attributes for i18n strings instead of injected JS literals. Also guards res.json() parse with try/catch so non-JSON error bodies (e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'. Fixes #222. * fix: improve update error messages when pull fails (#227) _apply_update_inner() ran git pull --ff-only and returned only raw stderr on failure, making all failure modes indistinguishable. Fix: explicit git fetch before pull; if fetch fails, returns human-readable network error. Diverged history and missing upstream tracking branch each get distinct messages with exact recovery commands. Generic fallback truncates to 300 chars and shows sentinel when git produces no output. Also adds tests/test_update_checker.py with 13 tests covering all 4 new diagnostic code paths (0 tests existed before). Fixes #223. * fix: stabilize 30s terminal approval prompt visibility (#225) Adds minimum 30-second visibility guard for the approval card using _approvalVisibleSince, _approvalHideTimer, and a signature fingerprint to deduplicate repeated poll ticks. Fix: respondApproval() and all stream-end paths (done/cancel/apperror/ error/start-error) now call hideApprovalCard(true) so the card hides immediately when the user responds or the session ends. The 30s guard only applies to mid-session poll ticks where the approval is still live but briefly absent. Adds 11 structural tests covering the new timer variables, force parameter, force-on-respond, force-on-stream-end, and poll-loop no-force behavior. * feat: replace emoji icons with self-hosted Lucide SVG icons (#221) Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled in static/icons.js (no CDN dependency). Adds li(name) function returning inline SVG geometry from a hardcoded whitelist — unknown keys return '' so dynamic server-supplied names never inject arbitrary SVG. Changes: - static/icons.js: new file with 21 icon paths + li() renderer - static/index.html: all nav/action buttons now use li() icons - static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons - static/messages.js: cancelStream button uses SVG square stop icon - .gitignore: adds node_modules/ entry Verified: all 35 onclick= functions exist in JS, all 21 li() calls reference defined icons, applyBotName() selectors intact, version label present, no removed IDs referenced by JS. * docs: v0.44.0 release notes, bump version, update test counts --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 17:02:28 +00:00
return {
'ok': False,
'message': ' '.join(message_parts),
v0.44.0: approval fix, login CSP, update diagnostics, Lucide icons * fix: approval pending check broken by stale has_pending import (#228) api/routes.py imported has_pending/pop_pending from tools.approval, but the agent module renamed has_pending to has_blocking_approval (checks gateway queue, not _pending dict) and removed pop_pending. The import fell through to fallback lambdas that always returned False, making GET /api/approval/pending always return {pending:null} even after a successful inject_test. Fix: check _pending directly under _lock — same dict submit_pending writes to. Stale imports removed. Before: 554 pass, 1 fail | After: 555 pass, 0 fail * fix: move login JS into external file, remove inline handlers (#226) Login page used inline onsubmit/onkeydown handlers and an inline <script> block — all blocked by strict script-src CSP, causing silent login failure. Fix: extract doLogin() and Enter key listener into static/login.js (served from /static/, already a public path). Form uses id='login-form' and data-* attributes for i18n strings instead of injected JS literals. Also guards res.json() parse with try/catch so non-JSON error bodies (e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'. Fixes #222. * fix: improve update error messages when pull fails (#227) _apply_update_inner() ran git pull --ff-only and returned only raw stderr on failure, making all failure modes indistinguishable. Fix: explicit git fetch before pull; if fetch fails, returns human-readable network error. Diverged history and missing upstream tracking branch each get distinct messages with exact recovery commands. Generic fallback truncates to 300 chars and shows sentinel when git produces no output. Also adds tests/test_update_checker.py with 13 tests covering all 4 new diagnostic code paths (0 tests existed before). Fixes #223. * fix: stabilize 30s terminal approval prompt visibility (#225) Adds minimum 30-second visibility guard for the approval card using _approvalVisibleSince, _approvalHideTimer, and a signature fingerprint to deduplicate repeated poll ticks. Fix: respondApproval() and all stream-end paths (done/cancel/apperror/ error/start-error) now call hideApprovalCard(true) so the card hides immediately when the user responds or the session ends. The 30s guard only applies to mid-session poll ticks where the approval is still live but briefly absent. Adds 11 structural tests covering the new timer variables, force parameter, force-on-respond, force-on-stream-end, and poll-loop no-force behavior. * feat: replace emoji icons with self-hosted Lucide SVG icons (#221) Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled in static/icons.js (no CDN dependency). Adds li(name) function returning inline SVG geometry from a hardcoded whitelist — unknown keys return '' so dynamic server-supplied names never inject arbitrary SVG. Changes: - static/icons.js: new file with 21 icon paths + li() renderer - static/index.html: all nav/action buttons now use li() icons - static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons - static/messages.js: cancelStream button uses SVG square stop icon - .gitignore: adds node_modules/ entry Verified: all 35 onclick= functions exist in JS, all 21 li() calls reference defined icons, applyBotName() selectors intact, version label present, no removed IDs referenced by JS. * docs: v0.44.0 release notes, bump version, update test counts --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 17:02:28 +00:00
}
# Generic fallback — include the raw git output for debugging.
message_parts = [f'Pull failed: {detail}']
if restored_note:
message_parts.append(restored_note)
response = {'ok': False, 'message': ' '.join(message_parts)}
if untracked_collision:
response['conflict'] = True
return response
# Re-apply stash if we stashed.
stash_drop_failed = False
if stashed:
_, apply_ok = _run_git(['stash', 'apply'], path)
if apply_ok:
_, drop_ok = _run_git(['stash', 'drop'], path)
stash_drop_failed = not drop_ok
else:
_, reset_ok = _run_git(['reset', '--hard', 'HEAD'], path)
Release v0.51.260 — Release IB (stage-r8) (#3614) ## Release v0.51.260 — Release IB (stage-r8) Un-held safety fixes (author resolved my earlier hold findings; re-reviewed fresh) + a clean fix batch. 6 PRs. ### Fixed | Issue/PR | Author | Fix | |----------|--------|-----| | #3535 (#3538) | @rodboev | **Self-update recovers from a stash-pop conflict without data loss.** Was a BRICK bug (`git reset --merge` + `git stash drop` discarded local mods while reporting success). Now keeps the stash, returns `ok:false` + "preserved in `stash@{0}`", no restart on conflict. *(was held — fix verified)* | | #1909 s3 (#3562) | @rodboev | **Auth `Secure` cookie no longer locks out plain-HTTP LAN/Tailscale users.** Secure now keys only on real TLS evidence (env / TLS socket / opt-in `TRUST_FORWARDED_PROTO`); non-loopback plain-HTTP is no longer force-Secure. SameSite back to `Lax`. *(was held — fix verified)* | | #2785 (#3559) | @franksong2702 | Clearer cron/gateway diagnostics for single-container Docker (gateway configured, no daemon → jobs silently don't fire). | | #3555 | @lambyangzhao | Long TTS responses chunked at sentence boundaries (works around the browser's ~32K silent-truncation). | | #3340 (#3342) | @rly09 | Persistent-state toast when a turn has saved memory / created-updated a skill. | | #3533 | @franksong2702 | `/reload-mcp` marked `cli_only` so the WebUI doesn't dispatch it as an LLM prompt. | ### Gate - Full pytest suite: **7681 passed, 0 failed** - ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN - Codex (regression): **SAFE TO SHIP** — confirmed the stash-conflict path never drops the stash / never restarts on conflict, auth Secure handles LAN-HTTP correctly with no header-forgery hole, `/reload-mcp` allowlisted, state-toast has a real backend writer + active-session guard, diagnostics leak no paths, TTS chunking preserves order. Co-authored-by: rodboev <rodboev@users.noreply.github.com> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> Co-authored-by: lambyangzhao <lambyangzhao@users.noreply.github.com> Co-authored-by: rly09 <rly09@users.noreply.github.com>
2026-06-04 22:21:41 +00:00
if not reset_ok:
return {
'ok': False,
'message': (
'Updated successfully, but failed to clean up a '
'stash-apply conflict. Manual intervention needed: '
'run git -C ' + str(path) + ' reset --hard HEAD to '
'remove conflict markers. Your changes remain in the '
'git stash.'
Release v0.51.260 — Release IB (stage-r8) (#3614) ## Release v0.51.260 — Release IB (stage-r8) Un-held safety fixes (author resolved my earlier hold findings; re-reviewed fresh) + a clean fix batch. 6 PRs. ### Fixed | Issue/PR | Author | Fix | |----------|--------|-----| | #3535 (#3538) | @rodboev | **Self-update recovers from a stash-pop conflict without data loss.** Was a BRICK bug (`git reset --merge` + `git stash drop` discarded local mods while reporting success). Now keeps the stash, returns `ok:false` + "preserved in `stash@{0}`", no restart on conflict. *(was held — fix verified)* | | #1909 s3 (#3562) | @rodboev | **Auth `Secure` cookie no longer locks out plain-HTTP LAN/Tailscale users.** Secure now keys only on real TLS evidence (env / TLS socket / opt-in `TRUST_FORWARDED_PROTO`); non-loopback plain-HTTP is no longer force-Secure. SameSite back to `Lax`. *(was held — fix verified)* | | #2785 (#3559) | @franksong2702 | Clearer cron/gateway diagnostics for single-container Docker (gateway configured, no daemon → jobs silently don't fire). | | #3555 | @lambyangzhao | Long TTS responses chunked at sentence boundaries (works around the browser's ~32K silent-truncation). | | #3340 (#3342) | @rly09 | Persistent-state toast when a turn has saved memory / created-updated a skill. | | #3533 | @franksong2702 | `/reload-mcp` marked `cli_only` so the WebUI doesn't dispatch it as an LLM prompt. | ### Gate - Full pytest suite: **7681 passed, 0 failed** - ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN - Codex (regression): **SAFE TO SHIP** — confirmed the stash-conflict path never drops the stash / never restarts on conflict, auth Secure handles LAN-HTTP correctly with no header-forgery hole, `/reload-mcp` allowlisted, state-toast has a real backend writer + active-session guard, diagnostics leak no paths, TTS chunking preserves order. Co-authored-by: rodboev <rodboev@users.noreply.github.com> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> Co-authored-by: lambyangzhao <lambyangzhao@users.noreply.github.com> Co-authored-by: rly09 <rly09@users.noreply.github.com>
2026-06-04 22:21:41 +00:00
),
'stash_conflict': True,
}
with _cache_lock:
_update_cache['checked_at'] = 0
_schedule_restart()
return {
'ok': True,
Release v0.51.260 — Release IB (stage-r8) (#3614) ## Release v0.51.260 — Release IB (stage-r8) Un-held safety fixes (author resolved my earlier hold findings; re-reviewed fresh) + a clean fix batch. 6 PRs. ### Fixed | Issue/PR | Author | Fix | |----------|--------|-----| | #3535 (#3538) | @rodboev | **Self-update recovers from a stash-pop conflict without data loss.** Was a BRICK bug (`git reset --merge` + `git stash drop` discarded local mods while reporting success). Now keeps the stash, returns `ok:false` + "preserved in `stash@{0}`", no restart on conflict. *(was held — fix verified)* | | #1909 s3 (#3562) | @rodboev | **Auth `Secure` cookie no longer locks out plain-HTTP LAN/Tailscale users.** Secure now keys only on real TLS evidence (env / TLS socket / opt-in `TRUST_FORWARDED_PROTO`); non-loopback plain-HTTP is no longer force-Secure. SameSite back to `Lax`. *(was held — fix verified)* | | #2785 (#3559) | @franksong2702 | Clearer cron/gateway diagnostics for single-container Docker (gateway configured, no daemon → jobs silently don't fire). | | #3555 | @lambyangzhao | Long TTS responses chunked at sentence boundaries (works around the browser's ~32K silent-truncation). | | #3340 (#3342) | @rly09 | Persistent-state toast when a turn has saved memory / created-updated a skill. | | #3533 | @franksong2702 | `/reload-mcp` marked `cli_only` so the WebUI doesn't dispatch it as an LLM prompt. | ### Gate - Full pytest suite: **7681 passed, 0 failed** - ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN - Codex (regression): **SAFE TO SHIP** — confirmed the stash-conflict path never drops the stash / never restarts on conflict, auth Secure handles LAN-HTTP correctly with no header-forgery hole, `/reload-mcp` allowlisted, state-toast has a real backend writer + active-session guard, diagnostics leak no paths, TTS chunking preserves order. Co-authored-by: rodboev <rodboev@users.noreply.github.com> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> Co-authored-by: lambyangzhao <lambyangzhao@users.noreply.github.com> Co-authored-by: rly09 <rly09@users.noreply.github.com>
2026-06-04 22:21:41 +00:00
'message': (
f'{target} updated to the latest version. Your local '
'modifications conflicted with upstream changes and were '
'set aside in a git stash. To inspect: '
'git -C ' + str(path) + ' stash show -p. To re-apply: '
'git -C ' + str(path) + ' stash apply, then resolve '
'conflicts. Drop the stash after you are satisfied.'
Release v0.51.260 — Release IB (stage-r8) (#3614) ## Release v0.51.260 — Release IB (stage-r8) Un-held safety fixes (author resolved my earlier hold findings; re-reviewed fresh) + a clean fix batch. 6 PRs. ### Fixed | Issue/PR | Author | Fix | |----------|--------|-----| | #3535 (#3538) | @rodboev | **Self-update recovers from a stash-pop conflict without data loss.** Was a BRICK bug (`git reset --merge` + `git stash drop` discarded local mods while reporting success). Now keeps the stash, returns `ok:false` + "preserved in `stash@{0}`", no restart on conflict. *(was held — fix verified)* | | #1909 s3 (#3562) | @rodboev | **Auth `Secure` cookie no longer locks out plain-HTTP LAN/Tailscale users.** Secure now keys only on real TLS evidence (env / TLS socket / opt-in `TRUST_FORWARDED_PROTO`); non-loopback plain-HTTP is no longer force-Secure. SameSite back to `Lax`. *(was held — fix verified)* | | #2785 (#3559) | @franksong2702 | Clearer cron/gateway diagnostics for single-container Docker (gateway configured, no daemon → jobs silently don't fire). | | #3555 | @lambyangzhao | Long TTS responses chunked at sentence boundaries (works around the browser's ~32K silent-truncation). | | #3340 (#3342) | @rly09 | Persistent-state toast when a turn has saved memory / created-updated a skill. | | #3533 | @franksong2702 | `/reload-mcp` marked `cli_only` so the WebUI doesn't dispatch it as an LLM prompt. | ### Gate - Full pytest suite: **7681 passed, 0 failed** - ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN - Codex (regression): **SAFE TO SHIP** — confirmed the stash-conflict path never drops the stash / never restarts on conflict, auth Secure handles LAN-HTTP correctly with no header-forgery hole, `/reload-mcp` allowlisted, state-toast has a real backend writer + active-session guard, diagnostics leak no paths, TTS chunking preserves order. Co-authored-by: rodboev <rodboev@users.noreply.github.com> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> Co-authored-by: lambyangzhao <lambyangzhao@users.noreply.github.com> Co-authored-by: rly09 <rly09@users.noreply.github.com>
2026-06-04 22:21:41 +00:00
),
'target': target,
'restart_scheduled': True,
'stash_conflict': True,
}
# Invalidate cache
with _cache_lock:
_update_cache['checked_at'] = 0
fix: update banner — conflict recovery path + server self-restart after update (#816) * fix: update banner conflict recovery + server self-restart after update (#813 #814) * fix(update): restart must wait for in-flight update + reset force button on retry Two defects in the update banner flow found during review of PR #816: 1. Two-target race (webui + agent sequential) The client posts targets sequentially: webui succeeds and schedules a restart timer (2 s delay); client then posts agent; server begins agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull, killing the agent update and closing the client connection. User sees "Update failed (agent): Failed to fetch" even though webui did update, and the agent repo is in an unknown partial state. Fix: _schedule_restart() now blocks on _apply_lock before calling os.execv. If a second update is in flight when the timer fires, the restart thread waits until it completes. If nothing is in flight the lock acquire is instant, so no-op updates still restart immediately. 2. Stale force-update button across retries _showUpdateError sets btnForceUpdate to display:inline-block when res.conflict / res.diverged. Nothing resets it on the next retry, so a subsequent non-conflict error (e.g. network) leaves the stale force button visible pointing at the previous target. Fix: applyUpdates() now hides the force button and clears its data-target at the start of each attempt. Tests: - test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a helper thread, verifies execv is delayed until the lock is released. - test_schedule_restart_still_fires_when_no_update_in_flight: sanity check that the common path still works with no contention. - test_apply_updates_resets_force_button_at_start: regression guard that the reset appears before the update loop begins. Full suite: 1683 passed, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(update): hold _apply_lock through execv + fix banner error layout Two fixes from Opus review: 1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern acquired _apply_lock, released it, then called os.execv — leaving a brief window where a new update could start between release and execv. Fixed by moving os.execv inside the 'with _apply_lock:' block so the process is replaced while still holding the lock; no new update can acquire it. 2. Banner CSS layout (static/index.html): #updateError was a direct flex child of .update-banner (display:flex row), so long error messages sat inline between #updateMsg and the buttons instead of below the message. Wrapped #updateMsg + #updateError in a flex-column container so errors stack vertically under the status line. * docs: add v0.50.134 CHANGELOG entry --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
# Schedule a self-restart so the updated code is loaded fresh. A plain
# git pull leaves stale Python modules in sys.modules — agent imports that
# reference new symbols (functions, classes) added in the update will fail
# on the next request with AttributeError / ImportError. os.execv() re-
# execs the same interpreter with the same argv, picking up the new code
# cleanly without requiring the user to restart manually.
#
# The 2 s delay gives the HTTP response time to flush to the client before
# the process replaces itself. The client already does
# setTimeout(() => location.reload(), 1500) on success, so the page reload
# and the restart land at roughly the same time.
_schedule_restart()
message = f'{target} updated successfully'
if stash_drop_failed:
message += (
'. Local modifications were restored, but the temporary stash '
'entry may still be present because git stash drop failed.'
)
fix: update banner — conflict recovery path + server self-restart after update (#816) * fix: update banner conflict recovery + server self-restart after update (#813 #814) * fix(update): restart must wait for in-flight update + reset force button on retry Two defects in the update banner flow found during review of PR #816: 1. Two-target race (webui + agent sequential) The client posts targets sequentially: webui succeeds and schedules a restart timer (2 s delay); client then posts agent; server begins agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull, killing the agent update and closing the client connection. User sees "Update failed (agent): Failed to fetch" even though webui did update, and the agent repo is in an unknown partial state. Fix: _schedule_restart() now blocks on _apply_lock before calling os.execv. If a second update is in flight when the timer fires, the restart thread waits until it completes. If nothing is in flight the lock acquire is instant, so no-op updates still restart immediately. 2. Stale force-update button across retries _showUpdateError sets btnForceUpdate to display:inline-block when res.conflict / res.diverged. Nothing resets it on the next retry, so a subsequent non-conflict error (e.g. network) leaves the stale force button visible pointing at the previous target. Fix: applyUpdates() now hides the force button and clears its data-target at the start of each attempt. Tests: - test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a helper thread, verifies execv is delayed until the lock is released. - test_schedule_restart_still_fires_when_no_update_in_flight: sanity check that the common path still works with no contention. - test_apply_updates_resets_force_button_at_start: regression guard that the reset appears before the update loop begins. Full suite: 1683 passed, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(update): hold _apply_lock through execv + fix banner error layout Two fixes from Opus review: 1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern acquired _apply_lock, released it, then called os.execv — leaving a brief window where a new update could start between release and execv. Fixed by moving os.execv inside the 'with _apply_lock:' block so the process is replaced while still holding the lock; no new update can acquire it. 2. Banner CSS layout (static/index.html): #updateError was a direct flex child of .update-banner (display:flex row), so long error messages sat inline between #updateMsg and the buttons instead of below the message. Wrapped #updateMsg + #updateError in a flex-column container so errors stack vertically under the status line. * docs: add v0.50.134 CHANGELOG entry --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
return {
'ok': True,
'message': message,
fix: update banner — conflict recovery path + server self-restart after update (#816) * fix: update banner conflict recovery + server self-restart after update (#813 #814) * fix(update): restart must wait for in-flight update + reset force button on retry Two defects in the update banner flow found during review of PR #816: 1. Two-target race (webui + agent sequential) The client posts targets sequentially: webui succeeds and schedules a restart timer (2 s delay); client then posts agent; server begins agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull, killing the agent update and closing the client connection. User sees "Update failed (agent): Failed to fetch" even though webui did update, and the agent repo is in an unknown partial state. Fix: _schedule_restart() now blocks on _apply_lock before calling os.execv. If a second update is in flight when the timer fires, the restart thread waits until it completes. If nothing is in flight the lock acquire is instant, so no-op updates still restart immediately. 2. Stale force-update button across retries _showUpdateError sets btnForceUpdate to display:inline-block when res.conflict / res.diverged. Nothing resets it on the next retry, so a subsequent non-conflict error (e.g. network) leaves the stale force button visible pointing at the previous target. Fix: applyUpdates() now hides the force button and clears its data-target at the start of each attempt. Tests: - test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a helper thread, verifies execv is delayed until the lock is released. - test_schedule_restart_still_fires_when_no_update_in_flight: sanity check that the common path still works with no contention. - test_apply_updates_resets_force_button_at_start: regression guard that the reset appears before the update loop begins. Full suite: 1683 passed, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(update): hold _apply_lock through execv + fix banner error layout Two fixes from Opus review: 1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern acquired _apply_lock, released it, then called os.execv — leaving a brief window where a new update could start between release and execv. Fixed by moving os.execv inside the 'with _apply_lock:' block so the process is replaced while still holding the lock; no new update can acquire it. 2. Banner CSS layout (static/index.html): #updateError was a direct flex child of .update-banner (display:flex row), so long error messages sat inline between #updateMsg and the buttons instead of below the message. Wrapped #updateMsg + #updateError in a flex-column container so errors stack vertically under the status line. * docs: add v0.50.134 CHANGELOG entry --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
'target': target,
'restart_scheduled': True,
}