Repository navigation
emrg: an upgrade tick fires for a target that is not newer than the installed version #2404
Description
UpgradeManager.tick() (emrg/server/upgrade.py) decides whether to start an upgrade
session by testing the target for equality with the installed version:
local = self._read_local_version()
if local == target.lstrip("v"):
return # already at target — nothing to do
...
await self._trigger(target)_find_target_tag() returns the highest release among those outside the delay window,
with no reference to what is installed. So the comparison only stops the two cases
"same version" — every target that is older than the installed version passes the
guard and starts a session that asks an agent to install a downgrade.
What was observed
Measured on master 2c55a36a, emrg/server/upgrade.py:337 (the guard) and :414
(_find_target_tag). The module defines parse_version() (:88) and is_newer()
(:111) for exactly this question, and is_newer() is called nowhere in that file —
its only uses in the tree are tests/test_upgrade.py's own unit test of the helper.
The reachable case is not hypothetical: the delay window is what makes it real. When a
newer release is younger than delay_minutes it is excluded, so a host already running
that newer version sees an older release as the newest eligible tag, and the tick
starts a downgrade session. It has been observed on a host: installed 0.2.58, tick
offered v0.2.54, the session refused, the install was left untouched.
The cost when the agent refuses is an LLM session every tick — _is_waiting() and the
ineffective-attempt backoff only bound the repeat once an attempt has been recorded for
that tag, so the first attempt at each stale target is paid in full, forever, once per
release that arrives out of order.
The safety of the install currently rests entirely on the upgrade agent refusing. A
guard in the caller is a bug of the "the other component will save us" kind: it is
correct until the one round where the agent is willing.
What the fix must satisfy
tick()starts no session unless the target is strictly newer than the installed
version, using this module's ownparse_version()/is_newer()rather than a new
comparison.- The documented semantics of an unreadable or absent
version.txtdo not change: that
path is"", reads as "not yet installed", and must still trigger (_read_local_version
keeps""deliberately — refusing there would invent an outcome for a function whose
contract is """on failure"). - A same-version target still does nothing, as today.
- A target that is a prerelease or carries a suffix orders by
parse_version's tuple,
which is what the rest of the module already uses.
Test requirement
The arms must be hermetic — the stubbed releases client and a temporary VERSION_FILE
that tests/test_upgrade.py already uses for the delay-filter and same-version arms
(tests/conftest.py::_guard_upgrade_hermeticity fails a test that reaches the real
chain). Three arms: an older target triggers nothing, an absent version.txt still
triggers, and a newer target still triggers.
Activity
argszero commentedon Oct 20, 2026
Handled by #2405
emrg: an upgrade tick fires only for a strictly newer target (#2405)