fix(update): reuse the packaged desktop app when HEAD has not moved

build_update_products rebuilt, repackaged and reinstalled the desktop app on
every completion, including 'Already up to date', retried tails and takeover
re-entries, and could stop a running app to free the output. Reuse it when the
commit the packaged app ships (its baked install-stamp.json) is HEAD and its
freshness receipt is current. Freshness alone is not enough: the baked stamp is
an input only the build rewrites, so after a pull that touched no other desktop
input the receipt reads current while the app names the previous commit.
This commit is contained in:
kshitijk4poor
2026-10-02 11:46:01 +05:30
committed by kshitij
parent 266f11c5c5
commit 9f19f99166
2 changed files with 51 additions and 21 deletions

View File

@@ -125,6 +125,30 @@ def _desktop_build_needed(desktop_dir: Path, project_root: Path, *, source_mode:
return dist_dir is None or not source_product_current(project_root, "desktop", dist_dir)
def _packaged_desktop_current_for_head(desktop_dir: Path, project_root: Path) -> bool:
"""True when the packaged app was built from HEAD and none of its inputs changed since.
Freshness alone cannot answer this for an update: the baked ``install-stamp.json`` is a
desktop input that only the build itself rewrites, so after a pull that touched no other
desktop input the receipt still reads current while the app names the previous commit.
The commit the packaged app actually ships is the missing half; anything unreadable
means "build".
"""
dist_dir = _renderer_bundle_dir(desktop_dir, source_mode=False)
if dist_dir is None:
return False
try:
stamp = json.loads((dist_dir.parent.parent / "install-stamp.json").read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
return False
from hermes_cli.version_info import _run_git
commit = stamp.get("commit") if isinstance(stamp, dict) else None
if not isinstance(commit, str) or commit != _run_git(project_root, "rev-parse", "HEAD"):
return False
return not _desktop_build_needed(desktop_dir, project_root, source_mode=False)
def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]:
"""Return the current platform's unpacked Electron app executable."""
return _desktop_packaged_executable_in(desktop_dir / "release")

View File

@@ -115,9 +115,10 @@ def build_update_products(project_root: Path, *, desktop: bool) -> None:
publish_stage("Updating Node dependencies")
prepare_source_dependencies(project_root, workspaces, env=env, explicit=True)
# An update that changed no TUI/web input reuses the receipted output, as the
# launch path already does; recompiling it produces the same bytes. Desktop has
# no such skip: its baked install stamp carries the commit, so every update
# that moves HEAD changes a desktop input anyway.
# launch path already does; recompiling it produces the same bytes. Desktop
# additionally needs the packaged app to name HEAD (its baked stamp carries the
# commit), so it is reused only when HEAD did not move: "Already up to date",
# a retried tail, a takeover re-entry.
if "ui-tui" in frontends:
if source_product_current(project_root, "tui", project_root / "ui-tui/dist"):
print(" ✓ TUI is up to date")
@@ -131,28 +132,33 @@ def build_update_products(project_root: Path, *, desktop: bool) -> None:
publish_stage("Building the web UI")
build_source_web(project_root, env=env)
if desktop:
from hermes_cli.main_desktop import _refresh_installed_desktop_apps, build_prepared_desktop
from hermes_cli.main_desktop import (
_packaged_desktop_current_for_head, _refresh_installed_desktop_apps, build_prepared_desktop)
publish_stage("Building the desktop app")
# The desktop build mutates checkout-scoped node_modules and
# apps/desktop/release; serialize it against a concurrent manual
# `hermes desktop` (#93940). The update path waits rather than exits:
# the in-flight build it queues behind produces the same fresh tree
# this update needs.
from hermes_cli.desktop_build_lock import DesktopBuildLock
desktop_dir = project_root / "apps/desktop"
if _packaged_desktop_current_for_head(desktop_dir, project_root):
print(" ✓ Desktop app is up to date")
else:
publish_stage("Building the desktop app")
# The desktop build mutates checkout-scoped node_modules and
# apps/desktop/release; serialize it against a concurrent manual
# `hermes desktop` (#93940). The update path waits rather than exits:
# the in-flight build it queues behind produces the same fresh tree
# this update needs.
from hermes_cli.desktop_build_lock import DesktopBuildLock
build_lock = DesktopBuildLock(project_root)
build_lock.acquire(wait=True)
try:
build_prepared_desktop(
project_root / "apps/desktop", source_mode=False,
npm=shutil.which("npm", path=env["PATH"]), env=env, icons=project_root,
)
finally:
build_lock.release()
build_lock = DesktopBuildLock(project_root)
build_lock.acquire(wait=True)
try:
build_prepared_desktop(
desktop_dir, source_mode=False,
npm=shutil.which("npm", path=env["PATH"]), env=env, icons=project_root,
)
finally:
build_lock.release()
# A current release/ can still sit beside a stale installed copy (an earlier
# update rebuilt but never installed); healing must not wait for the next build.
_refresh_installed_desktop_apps(project_root / "apps/desktop")
_refresh_installed_desktop_apps(desktop_dir)
# A configured memory provider that no longer ships in core is installed from the
# catalog for every profile home sharing this venv (config, data and tool names
# unchanged). The update must finish even if the migration blows up.