v0.263.0: a failed update puts the app back by itself (09 decision 15, R-637)
gates / gates (push) Successful in 26s

The guarded update gains a folder copy of the app's named volumes, taken
after the pull where the app stops anyway (decision 19, chosen by the
2026-09-23 bake-off). On a failed health check the box undoes: every copy
validated by its finished-marker first, volumes refilled, definition and pin
from the job's own pre-update copies, the old version checked with the OLD
.felhom.yml probe. It holds only if the undo fails, and the hold sentence
says so and what state the data is in. Bind-mounted folders are never
touched.

- R-637 built; R-638/R-640/R-641 do not arise with a folder copy; R-639
  (pre-update copies incl. .felhom.yml kept until the undo is over).
- journal phases copying/undoing with power-cut recovery.
- app.yaml last_update_undone + one line on the app page (hu/en).
- R-642: start/restart never answer "completed".
- Removal deletes kept undo copies.

MinAgent unchanged (0.131.0). Nine red-proofs in REPORT.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-23 11:12:49 +02:00
parent b9deec1907
commit 8fc2b4a1a9
26 changed files with 1326 additions and 69 deletions
@@ -340,18 +340,7 @@ func (m *Manager) RestoreHoldFor(stack string) (bool, string) {
// Slice 4: one storage, two reasons. An update hold names the copy it can be restored from; a
// restore hold names nothing, because the restore it refers to already consumed the copy.
if h.Reason == settings.HoldReasonUpdateFailed {
copyDate := m.note("note.reconstitute.copy_latest")
if h.CopyDate != "" {
copyDate = fmtHoldTime(h.CopyDate)
}
// R-475: name the tier when the hold recorded one; an older hold keeps its own sentence.
if label := UpdateTierLabel(h.CopyTier); label != "" && h.CopyDate != "" {
if h.CopyHolds != "" { // R-479: name what the copy holds
return true, fmt.Sprintf(UpdateHoldFmt, stack, fmtHoldTime(h.At), label, copyDate, h.CopyHolds)
}
return true, fmt.Sprintf(UpdateHoldTierFmt, stack, fmtHoldTime(h.At), label, copyDate)
}
return true, fmt.Sprintf(UpdateHoldLegacyFmt, stack, fmtHoldTime(h.At), copyDate)
return true, m.undoHoldPrefix(h.UndoState) + m.updateHoldSentence(stack, h)
}
when := h.At
if t, err := time.Parse(time.RFC3339, h.At); err == nil {
@@ -360,6 +349,36 @@ func (m *Manager) RestoreHoldFor(stack string) (bool, string) {
return true, m.note("note.reconstitute.held", stack, when)
}
// undoHoldPrefix (v0.263.0) opens the hold sentence when the box already TRIED to undo the update and
// that failed too: what was tried, then what state the data is in. "" when no undo was attempted, so
// every hold written before v0.263.0 reads exactly as it did.
func (m *Manager) undoHoldPrefix(state string) string {
switch state {
case "untouched", "half", "not_started":
return m.note("hold.update.undo_failed") + " " + m.note("hold.update.undo_state."+state) + " "
case "":
return ""
}
m.logger.Printf("[WARN] [backup] unknown undo state %q on a hold — rendering the plain prefix", state)
return m.note("hold.update.undo_failed") + " "
}
// updateHoldSentence is the update hold's own sentence (slice 4, R-475, R-479), unchanged.
func (m *Manager) updateHoldSentence(stack string, h settings.RestoreHold) string {
copyDate := m.note("note.reconstitute.copy_latest")
if h.CopyDate != "" {
copyDate = fmtHoldTime(h.CopyDate)
}
// R-475: name the tier when the hold recorded one; an older hold keeps its own sentence.
if label := UpdateTierLabel(h.CopyTier); label != "" && h.CopyDate != "" {
if h.CopyHolds != "" { // R-479: name what the copy holds
return fmt.Sprintf(UpdateHoldFmt, stack, fmtHoldTime(h.At), label, copyDate, h.CopyHolds)
}
return fmt.Sprintf(UpdateHoldTierFmt, stack, fmtHoldTime(h.At), label, copyDate)
}
return fmt.Sprintf(UpdateHoldLegacyFmt, stack, fmtHoldTime(h.At), copyDate)
}
// holdAppAfterFailedRollback records the R-379/R-380 hold and makes sure nothing restarts the app
// behind our back.
//
@@ -74,7 +74,7 @@ func TestR479_HoldSentenceNamesWhatTheCopyHolds(t *testing.T) {
if h := m.UpdateCopyHolds("app", UpdateTierOffsite); !strings.Contains(h, "a fájlokat") || strings.Contains(h, "csak") {
t.Errorf("off-site holds the files too, got %q", h)
}
if err := m.HoldAfterFailedUpdateHolding("app", at, copyAt, UpdateTierLocal, holds); err != nil {
if err := m.HoldAfterFailedUpdateHolding("app", at, copyAt, UpdateTierLocal, holds, ""); err != nil {
t.Fatal(err)
}
_, why := m.RestoreHoldFor("app")
@@ -0,0 +1,58 @@
package backup
import (
"fmt"
"io"
"log"
"strings"
"testing"
"time"
)
// v0.263.0 — a hold after a FAILED UNDO opens with what was tried and what state the data is in, in the
// box's language; a hold with no undo attempted reads exactly as before (every pre-v0.263.0 hold).
//
// COMPANION RED-PROOF (REPORT.md): make RestoreHoldFor ignore h.UndoState (drop undoHoldPrefix) — the
// three undo cases then read the plain sentence and this test fails on the prefix.
func TestUndo_HoldSentenceSaysTheUndoWasTriedAndTheDataState(t *testing.T) {
at := time.Date(2026, 9, 23, 8, 0, 0, 0, time.UTC)
copyAt := time.Date(2026, 9, 23, 1, 30, 0, 0, time.UTC)
m := r479Manager(t, false)
m.logger = log.New(io.Discard, "", 0)
plain := fmt.Sprintf(UpdateHoldTierFmt, "app", "2026-09-23 10:00", "második meghajtó", "2026-09-23 03:30")
if err := m.HoldAfterFailedUpdateHolding("app", at, copyAt, UpdateTierSecondDrive, "", ""); err != nil {
t.Fatal(err)
}
if _, why := m.RestoreHoldFor("app"); why != plain {
t.Errorf("no undo attempted: the sentence must be unchanged, got %q", why)
}
for state, clause := range map[string]string{
"untouched": "Az adatok az új változat által hagyott állapotban vannak.",
"half": "Az adatok visszamásolása félbeszakadt",
"not_started": "Az adatok a frissítés előtti állapotba kerültek vissza, de az előző változat nem indult el.",
} {
if err := m.HoldAfterFailedUpdateHolding("app", at, copyAt, UpdateTierSecondDrive, "", state); err != nil {
t.Fatal(err)
}
_, why := m.RestoreHoldFor("app")
if !strings.HasPrefix(why, "A frissítés nem sikerült, és az automatikus visszaállítás sem. "+clause) {
t.Errorf("%s: the hold must open with the undo prefix and its state, got %q", state, why)
}
if !strings.HasSuffix(why, plain) {
t.Errorf("%s: the hold must still name the copy to restore from, got %q", state, why)
}
}
if err := m.settings.SetLanguage("en"); err != nil {
t.Fatal(err)
}
_, why := m.RestoreHoldFor("app")
if !strings.HasPrefix(why, "The update did not succeed, and the automatic undo did not either. The data is back as it was before the update") {
t.Errorf("an English box gets the English prefix, got %q", why)
}
if strings.Contains(why, "automatikus visszaállítás") {
t.Errorf("the Hungarian prefix must be GONE on an English box, got %q", why)
}
}
+11 -6
View File
@@ -505,20 +505,25 @@ func fmtHoldTime(rfc3339 string) string {
// that the next restart button will quietly start again. The caller logs it at ERROR and keeps the
// failure on the page.
func (m *Manager) HoldAfterFailedUpdate(stackName string, at time.Time, copyDate time.Time, copyTier int) error {
return m.HoldAfterFailedUpdateHolding(stackName, at, copyDate, copyTier, "")
return m.HoldAfterFailedUpdateHolding(stackName, at, copyDate, copyTier, "", "")
}
// HoldAfterFailedUpdateHolding is HoldAfterFailedUpdate with the R-479 phrase for what the copy holds;
// "" records none (the tier-only sentence). The adapter in main.go computes the phrase with
// UpdateCopyHolds at hold time.
func (m *Manager) HoldAfterFailedUpdateHolding(stackName string, at time.Time, copyDate time.Time, copyTier int, copyHolds string) error {
//
// undoState (v0.263.0) is what a FAILED undo left the data as (stacks.UndoState*), "" when no undo was
// attempted. RestoreHoldFor puts it in front of the sentence, so the household reads that the box
// already tried to put the app back, and in what state that left the data.
func (m *Manager) HoldAfterFailedUpdateHolding(stackName string, at time.Time, copyDate time.Time, copyTier int, copyHolds, undoState string) error {
if m == nil || m.settings == nil {
return fmt.Errorf("no settings wired — the update hold for %s cannot be persisted", stackName)
}
h := settings.RestoreHold{
Stack: stackName,
At: at.UTC().Format(time.RFC3339),
Reason: settings.HoldReasonUpdateFailed,
Stack: stackName,
At: at.UTC().Format(time.RFC3339),
Reason: settings.HoldReasonUpdateFailed,
UndoState: undoState,
}
if !copyDate.IsZero() {
h.CopyDate = copyDate.UTC().Format(time.RFC3339)
@@ -528,7 +533,7 @@ func (m *Manager) HoldAfterFailedUpdateHolding(stackName string, at time.Time, c
if err := m.settings.SetRestoreHold(h); err != nil {
return fmt.Errorf("persisting the update hold for %s: %w", stackName, err)
}
m.logger.Printf("[WARN] [backup] %s is HELD STOPPED after a failed update (restore point: tier %d %q, %s; holds: %q)", stackName, h.CopyTier, UpdateTierLabel(h.CopyTier), h.CopyDate, h.CopyHolds)
m.logger.Printf("[WARN] [backup] %s is HELD STOPPED after a failed update (restore point: tier %d %q, %s; holds: %q; undo: %q)", stackName, h.CopyTier, UpdateTierLabel(h.CopyTier), h.CopyDate, h.CopyHolds, h.UndoState)
return nil
}