Repository navigation
emrg: an upgrade tick fires only for a strictly newer target - #2405
Conversation
`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
left a comment
There was a problem hiding this comment.
✅ 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_versionstrips the leadingv(if s.startswith("v"): s = s[1:]), so the tag andversion.txtare
compared in the same space — the same thing the removedtarget.lstrip("v")was for.is_newerisbool(latest) and latest > current, so an absent or unreadableversion.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
left a comment
There was a problem hiding this comment.
✅ 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
left a comment
There was a problem hiding this comment.
✅ 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 basee9f4bd4d.check-merge-plan-suite.py 2405→ final treec9512090d54f, 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.txtpath survives.parse_version("")is(), andis_newerasks whether the target is above it, so "not yet installed" still triggers;test_tick_still_triggers_when_version_txt_is_absentholds that separately, so the stricter comparison cannot eat it. - A sibling's assumption moved with the code.
tests/test_check_release_tag.pypinnedtarget.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 versionbuild-runtime.shwrites keeps every host re-triggering). Left alone it would have failed later, far from the change that broke it.
Closes #2404
What was wrong
UpgradeManager.tick()decided whether to start an upgrade session by asking whetherthe target differed from the installed version:
_find_target_tag()returns the highest release outside the delay window and neverlooks 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()andis_newer()for precisely this question, fiftylines 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
Three behaviours are deliberately unchanged:
is_neweris strict, and the existingtest_tick_no_trigger_when_local_matches_targetstill pins it.version.txtstill 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
""onfailure.
comparison would not (
0.3.9→0.3.10).Verification
tests/test_upgrade.py— two new hermetic arms on the existing stubbed-releasesharness: an older eligible target triggers nothing, and an absent
version.txtstilltriggers one session naming the tag.
if local == target.lstrip("v"):turns thenew downgrade arm red (
assert [{'session_id': 'emrg-upgrade', …}] == []), and it isgreen 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_assumesis a premise guard: it looked for
target.lstrip("v")inupgrade.pyto prove thechain 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.
What this PR deliberately leaves
check-release-tag.pystill compares the tag withtarget.lstrip("v"), so the tagspelling
vv1.2.3passes it — whileparse_versionstrips a singlev, meaning thetick 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.