fix(rollback): announce the post-update probation commit on stderr (#5232) - #5240
fix(rollback): announce the post-update probation commit on stderr (#5232)#5240sanity wants to merge 5 commits into
Conversation
Rule Review: fix(rollback) probation announcement — no blocking issuesRules checked: git-workflow.md, code-style.md, testing.md The diff is a focused WarningsNone. Info
Rule review against |
…ents Review of #5240 found the guard one-sided. Three fixes, all about assertions that could not fail. **The committed test's "no crash was recorded" was unfalsifiable.** It is a negative assertion on a subprocess's stderr, and the preceding assertion had already established the marker was gone — so nothing could record a crash. It would equally have passed had the post-stop command never reached the probation branch at all: a misspelled `FREENET_POST_STOP_EXIT_CODE`, a `HOME` that did not relocate the state dir, an unparseable fixture, or a reworded needle. `probation_survives_a_stop_inside_the_commit_window` is the positive control: the same fixture, env var and needle must produce `1/3`, and the marker must persist `crash_count = 1`. It also covers the half of the contract nothing tested — a stop INSIDE the window keeps rollback armed — which is the half that produced the user-visible rollback in #5232. ~15s, and no network call, because the crash branch exits before `freenet update` probes GitHub. **The ClearedStale announcement had no coverage at all**, despite the PR arguing that branch is the more dangerous one. Rather than spend another 60s node boot on it, the wording moves into `commit_announcement`, a pure function over `CommitOutcome`, and a unit test asserts both branches plus the silent `Nothing` case. That also gives the e2e's duplicated needle a partner that fails in microseconds instead of after the 60s window. **The commit poll raced the announcement.** `commit_probation_at` deletes the marker before `commit_probation` prints, so polling on the file could observe "gone" microseconds before the line was written, then read stderr exactly once. It now polls for the announcement itself, which is the artifact under guard anyway. Relatedly, `commit_probation` documents why the print must stay AFTER the state transition: `eprintln!` panics on EPIPE, and a panic in front of the removal would leave a healthy node's marker armed — an observability line turning into a rollback bug. Also from review: - Corrected three comments that described things that are not true: the other stderr prints live in `commands::update` and there are three, not two in this module; the commit task's handle is retained and aborted, not dropped; and `arm_probation` claimed a fixture re-read that does not exist (the cheap test is the fixture's canary, and now says so). - Qualified the tracing-vs-journal claim, which holds under the shipped systemd deployment but not for the fallback tracer (no log dir, or `FREENET_LOG_TO_STDERR`), which does write to stdout. - Documented that `run_disabled_idle` never commits, and why that is deliberate rather than an oversight: a process parked by `freenet service disable` never joined the network, so it has not demonstrated the health probation tests for, and committing there would disarm rollback on evidence the mechanism does not have. The 1h TTL retires the marker instead. - The commit deadline now starts when the node's WS API answers rather than at spawn, so a slow cold start cannot be reported as "the marker is still armed". - Failure messages carry the node's exit status and stderr. A port collision or a rejected flag reports itself there, often before `tracing` is initialised, so the previous diagnostic printed the one file guaranteed to be empty. - A stale `target/debug/freenet` predating a version bump would take the ClearedStale branch and fail as if rollback were broken; a version precondition now names the real cause. - `--log-dir` points at the state dir, matching production on Linux, where the log pruner and the probation marker share a directory. - Shutdown wait is bounded, so a shutdown hang fails with output instead of an opaque harness kill. - nextest override: 300s period and `retries = 0`. Retrying brick-safety equipment would turn "the commit sometimes does not happen" back into green. Refs #5232 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHwV1j9kGJEa5D6CxAyb6T
Review: three independent lenses, all findings addressedRan three blind reviewers on FixedAssertions that could not fail. All three lenses independently flagged that "no crash was recorded" was unfalsifiable: it is a negative on a subprocess's stderr, and the preceding assertion had already established the marker was gone. It would equally have passed with a misspelled
A race between the commit poll and the announcement. Three comments that described things that are not true. The other stderr prints are in An overstated claim. "tracing goes to the log files" holds under the shipped systemd deployment but not for the fallback tracer (no log dir resolvable, or
Diagnostics and robustness. The commit deadline now starts when the WS API answers rather than at spawn, so a slow cold start cannot be reported as "the marker is still armed". Failure messages carry the node's exit status and stderr — a port collision or a rejected flag reports itself there, often before nextest override: 300s period, Re-mutated after the reworkThe suite changed substantially, so the mutation testing was redone. The two tests now fail on disjoint mutations, which is the property worth having — neither is redundant, and the positive control genuinely can fail:
Plus the two from the first round, both still fatal: deleting the Not taken
[AI-assisted - Claude] |
…ents Review of #5240 found the guard one-sided. Three fixes, all about assertions that could not fail. **The committed test's "no crash was recorded" was unfalsifiable.** It is a negative assertion on a subprocess's stderr, and the preceding assertion had already established the marker was gone — so nothing could record a crash. It would equally have passed had the post-stop command never reached the probation branch at all: a misspelled `FREENET_POST_STOP_EXIT_CODE`, a `HOME` that did not relocate the state dir, an unparseable fixture, or a reworded needle. `probation_survives_a_stop_inside_the_commit_window` is the positive control: the same fixture, env var and needle must produce `1/3`, and the marker must persist `crash_count = 1`. It also covers the half of the contract nothing tested — a stop INSIDE the window keeps rollback armed — which is the half that produced the user-visible rollback in #5232. ~15s, and no network call, because the crash branch exits before `freenet update` probes GitHub. **The ClearedStale announcement had no coverage at all**, despite the PR arguing that branch is the more dangerous one. Rather than spend another 60s node boot on it, the wording moves into `commit_announcement`, a pure function over `CommitOutcome`, and a unit test asserts both branches plus the silent `Nothing` case. That also gives the e2e's duplicated needle a partner that fails in microseconds instead of after the 60s window. **The commit poll raced the announcement.** `commit_probation_at` deletes the marker before `commit_probation` prints, so polling on the file could observe "gone" microseconds before the line was written, then read stderr exactly once. It now polls for the announcement itself, which is the artifact under guard anyway. Relatedly, `commit_probation` documents why the print must stay AFTER the state transition: `eprintln!` panics on EPIPE, and a panic in front of the removal would leave a healthy node's marker armed — an observability line turning into a rollback bug. Also from review: - Corrected three comments that described things that are not true: the other stderr prints live in `commands::update` and there are three, not two in this module; the commit task's handle is retained and aborted, not dropped; and `arm_probation` claimed a fixture re-read that does not exist (the cheap test is the fixture's canary, and now says so). - Qualified the tracing-vs-journal claim, which holds under the shipped systemd deployment but not for the fallback tracer (no log dir, or `FREENET_LOG_TO_STDERR`), which does write to stdout. - Documented that `run_disabled_idle` never commits, and why that is deliberate rather than an oversight: a process parked by `freenet service disable` never joined the network, so it has not demonstrated the health probation tests for, and committing there would disarm rollback on evidence the mechanism does not have. The 1h TTL retires the marker instead. - The commit deadline now starts when the node's WS API answers rather than at spawn, so a slow cold start cannot be reported as "the marker is still armed". - Failure messages carry the node's exit status and stderr. A port collision or a rejected flag reports itself there, often before `tracing` is initialised, so the previous diagnostic printed the one file guaranteed to be empty. - A stale `target/debug/freenet` predating a version bump would take the ClearedStale branch and fail as if rollback were broken; a version precondition now names the real cause. - `--log-dir` points at the state dir, matching production on Linux, where the log pruner and the probation marker share a directory. - Shutdown wait is bounded, so a shutdown hang fails with output instead of an opaque harness kill. - nextest override: 300s period and `retries = 0`. Retrying brick-safety equipment would turn "the commit sometimes does not happen" back into green. Refs #5232 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHwV1j9kGJEa5D6CxAyb6T
9aa196a to
c6dcd34
Compare
Rebased onto 0.2.123, re-reviewed, re-verifiedForce-pushed The conflict was in #5230 changes the ground this test stood on, so that got the real scrutiny. A graceful shutdown now exits 0, and The assertions were already right. Three pieces of prose were not, and
Also checked: #5230 does not touch The 0.2.123 bump lines up: Re-verified locally on the rebased head: [AI-assisted - Claude] |
#5232 reported that post-update probation is never committed, so every later clean stop counts as a crash and eventually rolls a node back off the release it just installed. Investigating it, the commit turned out to work: the node clears the marker at exactly 60s on every healthy boot. What we did not have was any test covering the seam the issue suspected, and no way to see the commit in the journal, which is why the mechanism looked dead from the outside. The commit is not a property of `commit_probation_at` — that function is already unit-tested and correct. It is a property of its only caller, a fire-and-forget `GlobalExecutor::spawn` in `run_network_node_with_signals` whose handle is dropped and which is aborted at shutdown. A unit test on `commit_probation_at` passes happily while that task never runs, or commits a version string that does not match the marker the installer wrote. So this test spawns the real `freenet network` binary with its own HOME, plants an armed probation marker for the binary's exact version, and asserts the two observable halves of the issue: the marker is gone once the node has been up past the commit window, and the post-stop `freenet update` that follows records no crash. It asserts the commit LOG LINE as well as the marker's absence, because `commit_probation_at` removes the marker down both of its branches — a version-matched Committed and a ClearedStale for some other version's marker. Checking only the file would still pass if the version comparison regressed and every marker were dropped as stale, which would disable rollback entirely: a worse bug than the one being guarded. Runtime is ~64s, spent waiting out the real 60s window, which is a hard-coded constant in the binary under test. Refs #5232 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHwV1j9kGJEa5D6CxAyb6T
…5232) #5232 reported that post-update probation is never committed, so every later clean stop counts as a crash and eventually rolls a node back off the release it just installed. The commit was in fact working — it fires at exactly 60s on every healthy boot — but there was no way to see that from where the report was written, and the evidence read as a dead mechanism. The node's `tracing` output goes to the rolling log files under the log dir. systemd captures only stdout/stderr. Of the three decisions this module makes, two are printed on stderr by the installer (the crash count and the rollback itself) and reach the journal; the commit that disarms them went only to `tracing`. So `journalctl -u freenet` showed strikes accumulating against a probationary version and never once showed a commit, which is exactly how it was read. The ClearedStale branch was worse: `debug!`, which release builds compile out entirely. That branch discards a marker belonging to a different version WITHOUT committing anything, so rollback protection for that version silently disappears — and it is indistinguishable from a healthy commit by the state directory alone, since both just delete the marker. It is also the branch a version-comparison regression would take, the #5104 `v`-prefix class. It is now a warning, on stderr. Volume is bounded: at most one line per node start, and only when a probation marker exists. Testing - `probation_is_committed_after_a_healthy_uptime_window` asserts the announcement on the node's STDERR, standing in for the journal, so a build that goes silent again fails. Reverting this commit's eprintln fails the test; asserting the log file instead would not have. - Mutation-tested against both ways the guarded behaviour can break: deleting the `commit_probation` call fails it, and a `v`-prefix mismatch (marker removed as stale, never committed) also fails it — the second is the one a file-existence check alone would have missed. Refs #5232 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHwV1j9kGJEa5D6CxAyb6T
…ents Review of #5240 found the guard one-sided. Three fixes, all about assertions that could not fail. **The committed test's "no crash was recorded" was unfalsifiable.** It is a negative assertion on a subprocess's stderr, and the preceding assertion had already established the marker was gone — so nothing could record a crash. It would equally have passed had the post-stop command never reached the probation branch at all: a misspelled `FREENET_POST_STOP_EXIT_CODE`, a `HOME` that did not relocate the state dir, an unparseable fixture, or a reworded needle. `probation_survives_a_stop_inside_the_commit_window` is the positive control: the same fixture, env var and needle must produce `1/3`, and the marker must persist `crash_count = 1`. It also covers the half of the contract nothing tested — a stop INSIDE the window keeps rollback armed — which is the half that produced the user-visible rollback in #5232. ~15s, and no network call, because the crash branch exits before `freenet update` probes GitHub. **The ClearedStale announcement had no coverage at all**, despite the PR arguing that branch is the more dangerous one. Rather than spend another 60s node boot on it, the wording moves into `commit_announcement`, a pure function over `CommitOutcome`, and a unit test asserts both branches plus the silent `Nothing` case. That also gives the e2e's duplicated needle a partner that fails in microseconds instead of after the 60s window. **The commit poll raced the announcement.** `commit_probation_at` deletes the marker before `commit_probation` prints, so polling on the file could observe "gone" microseconds before the line was written, then read stderr exactly once. It now polls for the announcement itself, which is the artifact under guard anyway. Relatedly, `commit_probation` documents why the print must stay AFTER the state transition: `eprintln!` panics on EPIPE, and a panic in front of the removal would leave a healthy node's marker armed — an observability line turning into a rollback bug. Also from review: - Corrected three comments that described things that are not true: the other stderr prints live in `commands::update` and there are three, not two in this module; the commit task's handle is retained and aborted, not dropped; and `arm_probation` claimed a fixture re-read that does not exist (the cheap test is the fixture's canary, and now says so). - Qualified the tracing-vs-journal claim, which holds under the shipped systemd deployment but not for the fallback tracer (no log dir, or `FREENET_LOG_TO_STDERR`), which does write to stdout. - Documented that `run_disabled_idle` never commits, and why that is deliberate rather than an oversight: a process parked by `freenet service disable` never joined the network, so it has not demonstrated the health probation tests for, and committing there would disarm rollback on evidence the mechanism does not have. The 1h TTL retires the marker instead. - The commit deadline now starts when the node's WS API answers rather than at spawn, so a slow cold start cannot be reported as "the marker is still armed". - Failure messages carry the node's exit status and stderr. A port collision or a rejected flag reports itself there, often before `tracing` is initialised, so the previous diagnostic printed the one file guaranteed to be empty. - A stale `target/debug/freenet` predating a version bump would take the ClearedStale branch and fail as if rollback were broken; a version precondition now names the real cause. - `--log-dir` points at the state dir, matching production on Linux, where the log pruner and the probation marker share a directory. - Shutdown wait is bounded, so a shutdown hang fails with output instead of an opaque harness kill. - nextest override: 300s period and `retries = 0`. Retrying brick-safety equipment would turn "the commit sometimes does not happen" back into green. Refs #5232 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHwV1j9kGJEa5D6CxAyb6T
Rebasing onto 0.2.123 brought #5230 in, which makes a graceful shutdown exit 0 instead of 1. Three claims in this file were written against the old behaviour and now say the opposite of what the code does. Review of the rebase caught them; the assertions were already correct, only the prose was wrong. - `post_stop_update` said 1 is "what a graceful shutdown exits with today" and hedged about #5227's fix landing. It has landed. The forwarded "1" is now justified by the reason that actually applies: `classify_stop` short-circuits "0" to NotCrash BEFORE the marker is ever read, so forwarding the real status would mean neither test touched the probation logic at all, and the in-window test would lose its ability to fail. - The module doc credited the user-visible rollback to "three fast restarts". Post-#5230 ordinary restarts no longer accumulate strikes, and the PR body already says #5227 was the cause — the two now agree. - "the stop IS counted" merged two separable claims. The test asserts both, and says so: the marker retention is demonstrated by a REAL graceful stop, the `1/3` counting by a forwarded crash status. Also reworded two assertion messages that would have sent someone debugging a failure toward the exit-code classifier rather than the commit path. Refs #5232 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHwV1j9kGJEa5D6CxAyb6T
The stderr announcement this PR adds could lie. `remove_probation_at` discarded the `remove_file` result, so `commit_probation` printed "committed, auto-rollback disarmed" whether or not the marker was actually gone. A failed unlink leaves the marker armed and inside its 1h TTL, so an ordinary crash in the next hour can still roll the node back off a version it was just told had passed — and the commit timer fires once per process, so nothing retries. That would have reintroduced, at the moment of fixing it, exactly the misleading-journal problem this PR exists to remove. The trigger is a read-only or full state directory, which is the same fleet condition behind #5244. `remove_probation_at` now returns `io::Result` ("already absent" counts as success, since the post-condition is "no marker", not "I deleted something"), and `commit_probation_at` reports a new `ClearFailed` outcome rather than a false `Committed`. The announcement for it says the marker survived, names the cause, and tells the operator where to look. The other removal sites keep dropping the result, now explicitly and with the reason bound in the name: the stale-version and TTL branches are not announced and re-attempt the removal on the next stop, and the post-rollback one self-heals because the restored binary's next stop sees a version mismatch and takes the stale branch. Surfacing those would need an output channel this process does not have — #5244. Also made the two announcements textually disjoint. The failure line originally began "post-update probation passed, but ...", which CONTAINS the substring the end-to-end test greps for to conclude the node committed — so a failed clear would have passed that guard while rollback was still armed. The e2e now keys on "committed, auto-rollback disarmed", which only the success branch can produce, and a unit test pins the two apart in both directions. Testing - `a_failed_clear_is_never_announced_as_a_pass` — the ClearFailed line says STILL ARMED, names the cause, never says "disarmed", and does not contain the e2e's success needle. - `an_unremovable_marker_reports_clear_failed_not_committed` — drives `commit_probation_at` against a marker in a read-only directory and asserts the outcome is not `Committed`; skips when running as root, which bypasses the directory permission check and would make the test vacuous rather than failing. - `removing_an_absent_marker_succeeds` — an idempotent re-commit must not raise a spurious ClearFailed. Refs #5232, #5244 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHwV1j9kGJEa5D6CxAyb6T
c6dcd34 to
8cd407b
Compare
Rebased, plus: the new message could lie, and now cannotForce-pushed The problem with the message this PR adds
That would have reintroduced the misleading-journal problem at the exact moment of fixing it. The trigger is a read-only or full state directory, which is the same fleet condition behind #5244.
The other removal sites still drop the result, but now explicitly and with the reason bound in the name: the stale-version and TTL branches are not announced and re-attempt the removal on the next stop, and the post-rollback one self-heals because the restored binary's next stop sees a version mismatch and takes the stale branch. Surfacing those properly needs an output channel this process does not have — that is #5244. A false-green this nearly shippedThe The e2e now keys on Tests
34 Related: #5244, filed for the structural cause — the [AI-assisted - Claude] |
Problem
#5232 reported that post-update probation is never committed — so a node stays probationary indefinitely after an update, every later clean stop is scored as a crash, and the third one rolls the node back off the release it just installed and pins that release as known-bad. A real user hit exactly that outcome on 0.2.122.
commit_probationwas never broken. It fires at exactly 60s on every healthy boot, and this PR changes no rollback decision, threshold, or state transition. What was broken is that you could not see it from where the report was written.Under the shipped systemd deployment the node's
tracingoutput goes to the rolling log files under the log dir (the console layer is added only when stdout is a terminal, which it is not under a service manager), while the unit recordsStandardOutput=journal/StandardError=journal. The three other decisions in this flow — the crash count, the rollback, and rollback-unavailable — are alleprintln!incommands::update, so they reach the journal. The commit that disarms them did not.journalctl -u freenettherefore showed strikes accumulating against a probationary version and never once showed a commit.Evidence that the commit does work, from the same node the issue was filed against, in
~/.local/state/freenet/freenet.*.logand absent from that host's journal:Four commits, one per version, each exactly 60s after that boot's start banner. Confirmed independently by running the real binary with an armed marker in a scratch
HOME: the marker is removed at ~65s.The issue's other two observations resolve the same way. The
1/3seen twice was two different nodes: that host's boot banners show23:15:18Z version="0.2.120"and23:24:54Z version="0.2.121", so the node that stopped was 0.2.120 while theExecStopPostbinary matching the marker was 0.2.121 — a hand-driven test bench with the binary swapped underneath, not a counter that resets. And the marker andbuild_info::VERSIONstrings match exactly (0.2.121both sides), so thev-prefix concern does not apply here.Solution
Announce both marker-clearing outcomes on stderr, alongside the decisions they cancel. One line per node start at most, and only when a probation marker exists. The wording lives in
commit_announcement, a pure function overCommitOutcome, so both branches are unit-testable —commit_probationitself reads the process-global$HOMEand cannot be driven from a test.Scope note, stated plainly because it goes beyond the reported symptom: the
ClearedStalebranch is also escalated, fromdebug!(compiled out of release builds byrelease_max_level_info, so it was completely silent in the field) towarn!plus the stderr line. That branch discards a marker belonging to a different version without committing anything, so rollback protection for that version disappears. It is indistinguishable from a healthy commit by the state directory alone — both just delete the marker — and it is the branch a version-comparison regression would take, the #5104v-prefix class.One case is deliberately left alone and now documented rather than changed: a node parked by
freenet service disable(run_disabled_idle) returns before the commit timer is spawned and keeps its marker until the 1h TTL retires it. Committing there would disarm rollback on evidence the mechanism does not have — that process never joined the network, so it has not demonstrated the health probation is testing for.Testing
crates/core/tests/post_update_probation_commit.rsspawns the realfreenet networkbinary with its ownHOMEand covers both halves of the contract:probation_is_committed_after_a_healthy_uptime_windowfreenet updaterecords no crashprobation_survives_a_stop_inside_the_commit_window1/3, andcrash_countis persistedThree deliberate choices, each load-bearing:
commit_probation_at, which is already unit-tested and correct. It is a property of its only caller — aGlobalExecutor::spawninrun_network_node_with_signalsthat nothing awaits or monitors. A unit test oncommit_probation_atpasses happily while that task never runs.commands::rollbackalso lives in the bin crate behind a$HOME-derived state dir, so no in-process harness can reach it.RUST_LOG=error, which filters thetracing::info!out entirely.)FREENET_POST_STOP_EXIT_CODE, aHOMEthat did not relocate the state dir, an unparseable fixture, or a reworded needle. The in-window test forces the same fixture, env var and needle to produce a crash line. It is also the half that produced the user-visible rollback in Post-update probation commit is invisible in the journal, and has no regression test #5232 (three fast restarts), and it makes no network call — the crash branch exits beforefreenet updateprobes GitHub.Mutation-tested; each of these fails the suite:
commit_probationcall (the failure #5232 hypothesised)v-prefix version mismatch — marker removed as stale, never committedeprintln(tracing-only, pre-fix state)The second is the one that matters for test design: the marker is removed, so a file-existence check alone would have passed it while rollback protection was silently gone.
rollback.rs::tests::both_marker_clearing_outcomes_are_announcedpins both announcement strings (and the silentNothingcase) in microseconds, so a reword fails there rather than after the 60s window.nextest override: 300s period,
retries = 0. Retrying brick-safety equipment would turn "the commit sometimes does not happen" — the exact bug class #5232 was filed about — back into a green run.A worked example of the failure mode this area keeps producing
Worth reading even if you skim the rest, because it happened inside the fix.
commit_probationannounces "committed, auto-rollback disarmed".remove_probation_atdiscarded itsremove_fileresult, so that line printed whether or not the marker was actually gone — and a failed unlink leaves rollback armed inside its 1h TTL. The fix for a misleading journal was itself capable of misleading the journal.Fixing that introduced a second instance. The new
ClearFailedmessage began "post-update probation passed, but the marker could NOT be removed…" — which contains the exact substring the end-to-end test greps for to conclude the node committed. A failed clear would have passed the guard while rollback was still armed: the same false-green, one level up, inside the test written to prevent it.Both are now closed:
commit_probation_atreportsClearFailedinstead of a falseCommitted, the e2e keys on"committed, auto-rollback disarmed"which only the success branch can produce, and a unit test pins the two disjoint in both directions.The pattern to watch for: a success signal and a failure signal that share a substring, or a message emitted before the state change it describes. Both produce a green check over a broken invariant.
Review
Three independent lenses (skeptical, testing, code-first). No blocking findings; every should-fix is addressed in the second commit, including several assertions that could not fail, three comments that described things that were not true, a poll that raced the announcement, and the missing
ClearedStalecoverage.Not in scope
systemctl stopis counted as a crash and can trigger auto-rollback #5227 (a graceful shutdown exits 1, so it is scored as a crash) is the real cause of the user-visible rollback and is fixed separately in fix(node): exit 0 on a requested graceful shutdown #5230. Rolling back needs three crashes recorded while the marker is alive, and the marker dies 60s into the first healthy boot — so it takes three stops each inside a 60s window, which is a fast restart loop, which is what that user'sStartLimitBursttrip says they had.Startup update check: failed to parse latest version 'v0.2.121', so update detection is dead there and a node already rolled back onto one cannot auto-update out of it. Separate issue, separately owned. (The known-bad pin is not the trap: it is exact-version, so a newer release is never blocked by it.)Refs #5232
[AI-assisted - Claude]