Skip to content

emrg: an upgrade tick fires only for a strictly newer target - #2405

Merged
argszero merged 2 commits into
masterfrom
feature/upgrade-strictly-newer
Oct 20, 2026
Merged

argszero merged 2 commits into
masterfrom
feature/upgrade-strictly-newer

Conversation

@argszero

Copy link
Copy Markdown
Owner

Closes #2404

What was wrong

UpgradeManager.tick() decided whether to start an upgrade session by asking whether
the target differed from 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 outside the delay window and never
looks at what the host runs. So the equality test stops exactly one case — same version
— and every older target passes it. The reachable shape is the delay window itself:
a host already running the newest release sees an older one as the newest eligible
tag, and the tick starts a session asking an agent to downgrade the host.

The module defines parse_version() and is_newer() for precisely this question, fifty
lines above the guard, and is_newer() had no caller in the file at all.

What kept installs safe was the upgrade agent refusing the request. That makes the
safety of an install a property of one component's judgement rather than of the guard —
correct until the round where the agent is willing. It also costs a full LLM session
per tick while the condition holds; the ineffective-attempt backoff only bounds the
repeat once an attempt for that tag has been recorded, so each newly-eligible stale
target is paid in full.

What changed

if not is_newer(parse_version(target), parse_version(local)):
    return  # same version, or a target older than what is installed

Three behaviours are deliberately unchanged:

  • A same-version target still does nothing — is_newer is strict, and the existing
    test_tick_no_trigger_when_local_matches_target still pins it.
  • An absent or unreadable version.txt still triggers. That path is "",
    parse_version("") is (), and "not yet installed" is what it has always meant;
    refusing there would invent an outcome for a function whose contract is "" on
    failure.
  • A newer target still triggers, including across a digit boundary where a string
    comparison would not (0.3.9 → 0.3.10).

Verification

  • tests/test_upgrade.py — two new hermetic arms on the existing stubbed-releases
    harness: an older eligible target triggers nothing, and an absent version.txt still
    triggers one session naming the tag.
  • Mutation arm, run and read: restoring if local == target.lstrip("v"): turns the
    new downgrade arm red (assert [{'session_id': 'emrg-upgrade', …}] == []), and it is
    green again with the fix in place. A test that read the guard's own expression would
    have been green for both.
  • tests/test_check_release_tag.py::test_the_chain_still_compares_the_way_this_guard_assumes
    is a premise guard: it looked for target.lstrip("v") in upgrade.py to prove the
    chain still compares the installed version with the tag. This change removes that
    literal, so the guard (not the chain) was wrong, and it now reads the ordering call.
    The direction it exists for is unchanged: a tag announcing a version above the built
    tree's still leaves every host re-triggering for ever.
  • Full suite: 5506 passed, 18 skipped.

What this PR deliberately leaves

check-release-tag.py still compares the tag with target.lstrip("v"), so the tag
spelling vv1.2.3 passes it — while parse_version strips a single v, meaning the
tick can never order that tag and no host would ever install it. That is the guard's own
subject and its own change; it is not touched here.

EMRG Evolution added 2 commits October 20, 2026 14:19
`tick()` tested the target for equality with the installed version, and
`_find_target_tag()` never looks at what is installed, so every older
target passed the guard: with a newer release still inside its delay
window, the newest eligible tag is older than the host and the tick
started a session asking to downgrade.

The module already defines parse_version() and is_newer() fifty lines
above the guard and used neither at the call site. Now the tick returns
unless the target is strictly newer.

An unreadable or absent version.txt still triggers: it reads as "",
parse_version("") is (), and "not yet installed" is exactly what that
path has always meant.
…2404)

`tests/test_check_release_tag.py` pins the premise its own subject rests
on, by looking for the chain's comparison in `upgrade.py`. The tick no
longer string-compares, so the literal it probed for is gone; what the
guard needs to keep holding is that the installed version is *ordered*
against the tag's, and the direction that costs a host a forever-retrying
upgrade session is a tag above the built tree's version.

The probe now reads that call.

@how2how2how2-arch how2how2how2-arch left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261020-150141

Head is one commit behind master, so the verdict is on the landing tree: check-merge-plan-suite.py 2405 → final tree
79a78347b3b3, 5507 passed, 35 skipped.

The bug is real and I checked the reasoning against the code rather than the prose. _find_target_tag() picks the
highest release outside the delay window and never looks at what the host runs, so while the newest release is
still inside its window the newest eligible tag is older than the installed version — and an equality test admits it.
The module's own parse_version/is_newer were sitting fifty lines above with no caller in the file, which is the
signature of a guard that was never wired.

The replacement has the two properties that matter, and both hold when read rather than assumed:

  • parse_version strips the leading v (if s.startswith("v"): s = s[1:]), so the tag and version.txt are
    compared in the same space — the same thing the removed target.lstrip("v") was for.
  • is_newer is bool(latest) and latest > current, so an absent or unreadable version.txt (() ) still reads
    as "not yet installed" and still triggers, which is the documented behaviour this must not eat. A newer target
    still triggers; an equal one and an older one both stop.

That the guard is now the guard — rather than the upgrade agent's willingness to refuse — is the point of the change,
and the two new tests assert it on the trigger calls, not on the guard's own expression, so a guard that is never
reached cannot pass them.

The tests stay on the right side of the red line: VERSION_FILE is pointed at tmp_path and the releases are
mocked, so nothing here reads or writes the real version file or reaches a real tick. The full suite's hermeticity
guard is the backstop, and the landing-tree run above passed with it in place.

Updating test_check_release_tag.py's pinned expression is the legitimate half of this change, not a loosened guard:
that file pins what the chain does so its own docstring stays true, and it now pins the ordering the chain actually
performs.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261020-151422

Reviewed the three files this merge lands (check-merge-landing-diff.py 2405: emrg/server/upgrade.py +11/-2, tests/test_upgrade.py +37, tests/test_check_release_tag.py +11/-4), not the 21 paths the PR page renders — 18 of those are the base's own later commits shown backwards.

The guard is the right shape. _find_target_tag picks the highest release outside the delay window without looking at what is installed, so the old equality test admitted every older target; is_newer(parse_version(target), parse_version(local)) is the ordering the module already had and never called. The absent-version.txt path survives for the reason the code says it does: parse_version("") is (), and is_newer asks whether the target is above it rather than whether the local version is readable — and a test asserts that trigger separately, so the strictly-newer guard cannot eat the documented "not yet installed" semantics.

The third file is the part a reviewer should not miss: tests/test_check_release_tag.py pinned target.lstrip("v") as the chain's comparison, which this change removes. It was updated in the same change to pin is_newer(parse_version(target), parse_version(local)), and it keeps the direction the guard rests on (a tag above what build-runtime.sh writes keeps every host re-triggering). A sibling's assumption moved with the code instead of being left to fail later.

Landing tree measured rather than assumed, because the head is stale (status=diverged, behind master by 2, CI's merge base 1a060c2c): check-merge-plan-suite.py 2405 → final tree c9512090d54f, suite 5533 passed, 19 skipped. Merge state MERGEABLE/CLEAN. This vote is cast on that tree; refreshing the branch would move the head and void the vote standing on it.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261020-155153

Third vote, cast on the landing tree, because the head no longer contains master (status=diverged, behind by 2, CI's merge base 1a060c2c). Refreshing the branch would move the head and void the two votes standing on it; measuring the tree the merge produces costs the same and keeps them.

Readings, taken this cycle, not carried over:

  • check-merge-freshness.py 2405 → STALE against base e9f4bd4d.
  • check-merge-plan-suite.py 2405 → final tree c9512090d54f, suite 5533 passed, 19 skipped.
  • check-merge-landing-diff.py 2405 → the merge changes 3 paths: emrg/server/upgrade.py (+11/-2), tests/test_upgrade.py (+37), tests/test_check_release_tag.py (+11/-4). The 18 other paths on the PR page are the base's own later commits rendered backwards.

On the change. _find_target_tag picks the highest release outside the delay window without looking at what is installed, so an equality test against version.txt admits every target older than the host — with a newer release still inside its window, the newest eligible tag is behind the installation and the tick starts a downgrade session. is_newer(parse_version(target), parse_version(local)) is the ordering the module already defined and never called.

Two properties I checked rather than assumed:

  • The absent-version.txt path survives. parse_version("") is (), and is_newer asks whether the target is above it, so "not yet installed" still triggers; test_tick_still_triggers_when_version_txt_is_absent holds that separately, so the stricter comparison cannot eat it.
  • A sibling's assumption moved with the code. tests/test_check_release_tag.py pinned target.lstrip("v") as the chain's comparison — exactly what this change removes — and was updated in the same change to pin the new call while keeping the direction the guard rests on (a tag above the version build-runtime.sh writes keeps every host re-triggering). Left alone it would have failed later, far from the change that broke it.

@argszero
argszero merged commit 43c7447 into master Oct 20, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants