Skip to content

fix(update): roll back when the new release cannot run its post-switch phase - #586

Open
defangdevs wants to merge 1 commit into
masterfrom
fix/update-phase-two-rollback
Open

fix(update): roll back when the new release cannot run its post-switch phase#586
defangdevs wants to merge 1 commit into
masterfrom
fix/update-phase-two-rollback

Conversation

@defangdevs

@defangdevs defangdevs commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Symptom

An update that switches the profile and then cannot run the new release's
agentbox leaves the box on that release, with nothing rolled back. The
box is then running software that cannot run its own updater, and the only
way out is to hand-drive nix profile rollback — twice, because the
remove-then-install pair means one rollback lands on the empty intermediate
generation rather than on the previous release.

Why the existing rollback misses it

cmd_update ends by handing over to the agentbox it just installed:

child = [str(profile / "bin" / "agentbox"), "update", "--post-switch", ...]
return subprocess.run(child).returncode      # <- the hole

post_switch does roll back its own failures. But it is the new release's
code
, and a release can fail before one line of it runs:

  • argparse exits 2 on an argument this release no longer takes — and it
    exits before calling the handler that would have rolled back. Renaming or
    dropping --post-switch or --from-generation fails exactly here, which is
    the change someone working on the updater itself is most likely to make.
  • an import fails, or the interpreter is missing.
  • the file is not there at all — FileNotFoundError is neither ConfigError
    nor UpdateError, so it escaped main() as a traceback, still with the
    profile switched.

The build succeeding does not rule these out: the flake8 gate catches a syntax
error, not a renamed flag or a runtime import.

The fix

Exit status cannot distinguish "phase two rolled back" from "phase two never
ran" — post_switch returns 1, argparse returns 2, and neither is reserved.
So ask the profile instead: if it is still ahead of the generation phase
one recorded, no rollback happened and phase one owns it.

That question is safe in both directions. rollback_to() stops the moment the
profile is at or behind its target, so phase two's own rollback is never
repeated and the profile is never walked past the release the box was
running.

When phase one does recover, it rolls back, re-applies with the rolled-back
release as a fresh process (the same version-skew reason phase one hands over
at all), and restarts units — mirroring what post_switch's own handler does.
When the rollback itself fails there is nothing safe left to run, so it says
so loudly and re-applies nothing rather than running a release over a profile
that is still switched.

OSError from the exec is folded in as exit 127.

What does not change

  • A clean handover is untouched: rc == 0 returns 0 without asking anything.
  • A phase-two failure that already rolled back returns the same status it did
    before, and now provably does no second rollback.
  • A profile that is not a generation symlink attempts nothing, which is the
    same case that already keeps --from-generation off the child's argv.

Test plan

  • tests/test_agentbox.py +8 assertions in two new classes.
    HandoverFailureTest covers the recovery itself: the profile still
    ahead (rolls back, re-applies, restarts), the profile already back
    (does nothing), a failed rollback (says so, re-applies nothing), no
    generation number, and --no-restart-sessions carried through.
    HandoverExitStatusTest covers the wiring that was the actual bug — a
    rejected argument and an unstartable child both reach the recovery, a
    clean handover does not.
  • Negative control: against master's bin/agentbox these fail 7/8
    (1 failure, 6 errors). The one that passes both ways is
    test_a_clean_handover_never_recovers, which is the unchanged-behaviour
    pin.
  • Full python3 tests/test_agentbox.py green, so
    tests/native/expected/ does not move — this changes update logic, not
    rendering.
  • All 31 native aarch64-linux flake checks build clean.
  • x86_64-linux is covered without a local eval. 31 of its 40 checks are
    the set shared with aarch64-linux, built natively above. Of the 9
    x86-only ones, 8 (connect, memory-protection, sessions,
    sessions-web, settings-page, vm-closure, web-surface,
    webhook) are enumerated in ci.yml and pass here. The 9th,
    testscript-fits, cannot be evaluated on an aarch64 host and fails
    identically on unmodified master — see the comment below.

Not in this PR

The follow-up Lio and I scoped: an explicit agentbox rollback verb and a
deadman timer that reverts unless disarmed. This PR only closes the case where
the box cannot roll itself back; those cover the case where the update
succeeds and the result is wrong anyway.

Co-Authored-By: Claude Opus 5 [email protected]

…h phase

An update switches the profile, then hands over to the agentbox it just
installed to render the host configuration and restart onto it. That child
rolls back its own failures — but it IS the new release's code, and a
release can fail before one line of it runs: argparse exits 2 on an
argument this release no longer takes, an import fails, the interpreter or
the file is not there.

Phase one returned that exit status and stopped. The profile stayed
switched, so the box was left running a release that cannot run its own
updater, with no way back short of hand-driving `nix profile rollback`
twice (the remove-then-install pair means one rollback lands on the empty
intermediate generation).

Exit status cannot tell "phase two rolled back" from "phase two never ran"
— post_switch returns 1, argparse returns 2, neither is reserved. So ask
the profile instead: if it is still ahead of where phase one found it, no
rollback happened and phase one owns it. Asking is safe in both directions
because rollback_to() stops the moment the profile is at or behind its
target, so phase two's own rollback is never repeated and the profile is
never walked past the release the box was running.

An OSError from the exec is folded in as exit 127: FileNotFoundError is
neither ConfigError nor UpdateError, so it escaped main() as a traceback
with the profile left switched.

Co-Authored-By: Claude Opus 5 <[email protected]>
@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 6ce7017b-2fa1-43fe-b056-1c1f2148ea11

📥 Commits

Reviewing files that changed from the base of the PR and between ef4f0dd and 91a9f56.

📒 Files selected for processing (2)
  • bin/agentbox
  • tests/test_agentbox.py

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

Self-update handover now recovers from child launch and execution failures. Recovery checks the active generation, rolls back and reapplies the previous release when needed, restarts services, and reports the original or recovery failure status. Regression tests cover these paths.

Changes

Self-update handover recovery

Layer / File(s) Summary
Handover failure recovery
bin/agentbox
The handover catches launch errors and nonzero child exits. recover_handover checks the active generation, performs rollback and re-application when required, restarts services, and preserves failure status when recovery is unavailable or unnecessary.
Recovery behavior validation
tests/test_agentbox.py
Tests cover rollback decisions, missing generations, rollback failures, restart settings, child exit status propagation, and unstartable child processes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 91a9f

The update flow now recovers failed handovers while avoiding duplicate rollback and preserving restart settings. Covered failure and success paths show no remaining merge-blocking risk.

Sequence Diagram(s)

sequenceDiagram
  participant UpdateHandover
  participant NewAgentbox
  participant recover_handover
  participant ProfileAndServices
  UpdateHandover->>NewAgentbox: Launch post-switch handover
  NewAgentbox-->>UpdateHandover: Return exit status or OSError
  UpdateHandover->>recover_handover: Report failure
  recover_handover->>ProfileAndServices: Inspect active generation
  ProfileAndServices-->>recover_handover: Return generation state
  recover_handover->>ProfileAndServices: Roll back, reapply release, restart services
Loading

Suggested reviewers: lionello

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 39.13% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 23 functions across 1 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: rolling back when the new release cannot execute its post-switch phase.
Description check ✅ Passed The description directly explains the failure scenario, recovery design, affected execution errors, behavior guarantees, and test coverage for the changeset.
Full details: Docstring Coverage

Explanation

Docstring coverage is 39.13% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 23 functions across 1 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/update-phase-two-rollback

Comment @coderabbitai help to get the list of available commands.

@defangdevs

Copy link
Copy Markdown
Owner Author

Local verification note, promised in the unchecked box in the description.

testscript-fits cannot be evaluated on this aarch64 box, on this branch or
on master.
Both fail identically:

error: attribute 'testScript' missing
  at flake.nix:1814:46
    (_: t: builtins.stringLength t.driver.drvAttrs.testScript) vmTests;

Control, run against unmodified master so the rev is visible in the trace:

nix eval --raw "git+file:///…?rev=ef4f0dd24257ec876db59de45ca6dff19c2ef91d#checks.x86_64-linux.testscript-fits.drvPath"
-> exit 1, same "attribute 'testScript' missing" at flake.nix:1814

An aarch64 host cannot evaluate the x86 VM driver, so the attribute the check
measures is not there to measure. Nothing in this PR touches it — the diff is
bin/agentbox and tests/test_agentbox.py; testscript-fits reads
tests/*.nix.

Worth flagging separately: testscript-fits has no step in ci.yml
either. So it is a check that CI never runs and that an aarch64 contributor
cannot run, which means in practice nothing exercises it at all. That is the
same coverage gap #558 noted from the other side (twelve native checks
enumerated in the flake with no CI step). Not this PR's to fix, but it should
not keep hiding.

What is verified:

  • All 31 native aarch64-linux checks build clean.
  • Full python3 tests/test_agentbox.py green, so tests/native/expected/
    does not move.
  • Negative control on the new tests: against master's bin/agentbox they fail
    7 of 8 (1 failure, 6 errors). The one that passes both ways is
    test_a_clean_handover_never_recovers, which is the unchanged-behaviour pin
    and is supposed to.
  • CI here: Validate module & VM (x86, 12m33s) and Validate AWS template
    both pass, which is the real x86 coverage this box cannot provide locally.

A local .drvPath eval of the remaining x86_64-linux checks is re-running; I
will follow up only if it turns up something CI did not.

@defangdevs

Copy link
Copy Markdown
Owner Author

Closing the loop on the local x86_64-linux eval: I am abandoning it as
redundant
, not reporting it as green.

It was killed twice with no output (evaluating the x86 VM driver from an
aarch64 host is expensive). Rather than retry, I checked what it could still
have told me, and the answer is nothing:

  • x86_64-linux has 40 checks; 31 are the same set as
    aarch64-linux, and those were built natively — all green.
  • The 9 x86-only ones are connect, memory-protection, sessions,
    sessions-web, settings-page, vm-closure, web-surface, webhook and
    testscript-fits.
  • The first 8 are all enumerated in ci.yml, and Validate module & VM
    passed here in 12m33s — real execution on x86, which is strictly stronger
    than a .drvPath eval.
  • testscript-fits is the one nothing covers, and it fails identically on
    unmodified master, as shown above.

So every x86_64-linux check except testscript-fits has either been built
natively or actually run by CI on this PR. The eval would only have
re-confirmed derivations that CI has since realised for real.

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

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

1 participant