controller v0.268.0: the undo finds volumes by definition; a held app names only a whole copy; one press = one tested step (R-658, R-659, R-660, R-651; 09 §6.4 part 5)
gates / gates (push) Successful in 27s

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-24 08:15:27 +02:00
parent 80e6ad8c47
commit 206b0357d1
36 changed files with 2201 additions and 40 deletions
+129
View File
@@ -608,3 +608,132 @@ func (m *Manager) clearUpdateHoldAfterRestore(stackName string) {
}
m.logger.Printf("[INFO] [backup] %s: restore completed — the update hold (set %s) is CLEARED", stackName, h.At)
}
// UpdateHeldStacks is the set of apps held stopped after a failed update (R-660, v0.268.0) — the
// FOURTH way the product stops an app on purpose, and until v0.268.0 the one `classifyRunStates` did
// not know: each hold's `app_update_held` was followed ~11 s later by an `app_start_failed` for the
// same app (chaos rounds 8 and 11, 2026-09-23 night). A RESTORE hold (R-379) is not in the set: it
// has no event of its own, so the app-down alarm stays its only voice. Nil-safe.
func (m *Manager) UpdateHeldStacks() map[string]bool {
if m == nil || m.settings == nil {
return nil
}
var out map[string]bool
for _, h := range m.settings.ListRestoreHolds() {
if h.Reason != settings.HoldReasonUpdateFailed {
continue
}
if out == nil {
out = map[string]bool{}
}
out[h.Stack] = true
}
return out
}
// ── R-659 (v0.268.0): the hold names only a copy that can bring the app back WHOLE ────────────────
//
// MEASURED 2026-09-24 00:00 on 9202 (chaos round 11): nextcloud's update and its undo both failed; the
// hold named „saját meghajtó" (the precondition copy — decision 8 lets an update lean on any tier);
// the household pressed exactly that restore and was REFUSED, because the unit holds no copy of the
// app's files on the drive (R-538). The box had no other copy, so nothing on any page brought the app
// back. Operator ruling 2026-09-24 (`09` §3 decision 25, option A): the hold names only a copy that
// brings the app back whole; with none, it says so, says support is informed, and support is told.
//
// THE TRUTH TABLE, read from the restores' OWN refusals (measured from source, v0.267.0), not from
// what each tier stores:
//
// app own unit (1) second drive (2) off-site (3)
// no declared drive files whole (unit restore) whole („Teljes visszaállítás") whole (full restore)
// declared drive files NOT — refused (R-538) NOT — its unit restore is refused whole („Teljes
// (DeclaredDriveFileLegs) by the same guard; its file restore visszaállítás (fájlok
// only ADDS missing files, no database + adatbázis)")
//
// So the question is asked of the SAME predicate the refusal uses (DeclaredDriveFileLegs), and a test
// pins that the two cannot drift (TestR659_TruthTableAgreesWithTheRestoresRefusal). Tier 2 holds a
// file app's files AND its unit, but no single action brings the app back whole from it — R-661.
// WholeOnTier reports whether a copy on `tier` can bring this app back WHOLE through the restore the
// Mentések page offers for that tier.
func (m *Manager) WholeOnTier(stackName string, tier int) bool {
switch tier {
case UpdateTierOffsite:
return true
case UpdateTierLocal, UpdateTierSecondDrive:
return !m.HasDriveFileLegs(stackName)
}
return false
}
// HoldCopies walks EVERY tier (not only until the first acceptable one, as the update does) and
// returns the newest copy that brings the app back whole, whether there is one, and every copy seen.
func (m *Manager) HoldCopies(ctx context.Context, stackName string) (UpdateTierPoint, bool, []UpdateTierPoint) {
var seen []UpdateTierPoint
var best UpdateTierPoint
found := false
for _, tier := range []int{UpdateTierSecondDrive, UpdateTierLocal, UpdateTierOffsite} {
p, ok := m.updateTierPoint(ctx, stackName, tier)
if !ok {
continue
}
seen = append(seen, p)
if m.WholeOnTier(stackName, tier) && (!found || p.At.After(best.At)) {
best, found = p, true
}
}
return best, found, seen
}
// HoldAfterFailedUpdateWhole records the update hold naming the newest WHOLE copy, or — with none —
// a hold that names nothing and says support is informed (NoWholeCopy). The copies seen are recorded
// either way. Returns whether no whole copy exists.
func (m *Manager) HoldAfterFailedUpdateWhole(ctx context.Context, stackName string, at time.Time, undoState string) (bool, error) {
best, found, seen := m.HoldCopies(ctx, stackName)
var seenS []string
for _, p := range seen {
seenS = append(seenS, fmt.Sprintf("tier %d at %s", p.Tier, p.At.UTC().Format(time.RFC3339)))
}
if !found {
if m == nil || m.settings == nil {
return true, 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,
UndoState: undoState, NoWholeCopy: true, CopiesSeen: seenS}
if err := m.settings.SetRestoreHold(h); err != nil {
return true, fmt.Errorf("persisting the update hold for %s: %w", stackName, err)
}
m.logger.Printf("[ERROR] [backup] %s is HELD STOPPED after a failed update and NO copy on this box brings it back whole (seen: %v; drive files declared: %v; undo: %q) — support must act (R-659)",
stackName, seenS, m.HasDriveFileLegs(stackName), undoState)
return true, nil
}
if err := m.HoldAfterFailedUpdateHolding(stackName, at, best.At, best.Tier, m.UpdateCopyHolds(stackName, best.Tier), undoState); err != nil {
return false, err
}
if h, ok := m.settings.GetRestoreHold(stackName); ok {
h.CopiesSeen = seenS
_ = m.settings.SetRestoreHold(h)
}
return false, nil
}
// HoldNoWholeCopy reports whether the app's hold names no copy (R-659) — the page then offers no
// restore button for it.
func (m *Manager) HoldNoWholeCopy(stackName string) bool {
if m == nil || m.settings == nil {
return false
}
h, ok := m.settings.GetRestoreHold(stackName)
return ok && h.Reason == settings.HoldReasonUpdateFailed && h.NoWholeCopy
}
// UpdateHold returns the stored update hold, for the operator event (R-659).
func (m *Manager) UpdateHold(stackName string) (settings.RestoreHold, bool) {
if m == nil || m.settings == nil {
return settings.RestoreHold{}, false
}
h, ok := m.settings.GetRestoreHold(stackName)
if !ok || h.Reason != settings.HoldReasonUpdateFailed {
return settings.RestoreHold{}, false
}
return h, true
}