Skip to content

emrg: an upgrade tick fires for a target that is not newer than the installed version #2404

Closed

Description

@argszero

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

  1. tick() starts no session unless the target is strictly newer than the installed
    version, using this module's own parse_version() / is_newer() rather than a new
    comparison.
  2. The documented semantics of an unreadable or absent version.txt do 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").
  3. A same-version target still does nothing, as today.
  4. 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

  1. argszero commented on Oct 20, 2026

    @argszero
    OwnerAuthor

    Handled by #2405

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions