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
+49 -4
View File
@@ -622,6 +622,17 @@ func main() {
if backupMgr != nil && ev.CopyTier > 0 {
d.CopyHolds = backupMgr.UpdateCopyHoldsKey(ev.App, ev.CopyTier) // R-647: the key, never the Hungarian phrase
}
var hold settings.RestoreHold
haveHold := false
if ev.HoldRecorded && backupMgr != nil {
hold, haveHold = backupMgr.UpdateHold(ev.App)
}
if haveHold { // R-659: the details name the copy the SENTENCE names, not the precondition's
d.CopyTier, d.CopyDate, d.CopyHolds = hold.CopyTier, hold.CopyDate, ""
if hold.CopyTier > 0 {
d.CopyHolds = backupMgr.UpdateCopyHoldsKey(ev.App, hold.CopyTier)
}
}
notifier.NotifyAppUpdateHeld(d, func(lang string) string {
if ev.HoldRecorded && backupMgr != nil {
if held, why := backupMgr.RestoreHoldForLang(ev.App, lang); held {
@@ -630,6 +641,12 @@ func main() {
}
return util.Text(lang, "update.error.hold_unsaved")
})
// R-659 (v0.268.0; operator ruling 2026-09-24, option A): no copy on this box brings the app
// back whole — the household was just told support is informed; this is that information.
if haveHold && hold.NoWholeCopy {
notifier.NotifyAppHoldNoWholeCopy(notify.AppHoldNoWholeCopyDetails{App: ev.App, StackName: ev.App,
From: ev.From, To: ev.To, At: ev.At.UTC().Format(time.RFC3339), CopiesSeen: hold.CopiesSeen, UndoState: hold.UndoState})
}
}
})
@@ -843,7 +860,7 @@ func main() {
if time.Since(startTime) < deadAppBootGrace {
return nil // still inside the startup settle window
}
dead, states := scanDeployedAppRunStates(stackMgr, quiesceLoop, appStopGuard)
dead, states := scanDeployedAppRunStates(stackMgr, quiesceLoop, appStopGuard, backupMgr)
alertMgr.SetDeadAppAlerts(dead)
notifier.NotifyAppStartFailures(states)
// R-514: a worker OOM-killed inside a running container leaves the app „Fut". Surface it.
@@ -2353,7 +2370,7 @@ func recordLateRecovery(logger *log.Logger, started time.Time, res bootrecon.Res
// state-based dashboard banner) and EVERY deployed app's run state (for the notifier's one-event-per-
// transition tracking). Deploying apps are skipped (mid-deploy is not a fault). Pure over GetStacks()
// — the derivation itself lives in classifyRunStates so it is testable without a live Manager.
func scanDeployedAppRunStates(mgr *stacks.Manager, q *quiesce.Loop, g *backup.AppStopGuard) ([]web.DeadApp, []notify.AppRunState) {
func scanDeployedAppRunStates(mgr *stacks.Manager, q *quiesce.Loop, g *backup.AppStopGuard, held updateHeldLister) ([]web.DeadApp, []notify.AppRunState) {
// R-97b: a stack THIS controller stopped for a backup is not a fault. q may be nil (unprovisioned
// guest) — SuppressedStacks is nil-safe and returns nothing, i.e. suppress nothing.
//
@@ -2366,9 +2383,26 @@ func scanDeployedAppRunStates(mgr *stacks.Manager, q *quiesce.Loop, g *backup.Ap
// for health, and it ends in healthy or HELD. Counting it as dead mid-update would be R-330's false
// alarm one mechanism over.
suppressed := unionSuppressed(unionSuppressed(q.SuppressedStacks(), g.SuppressedStacks()), mgr.UpdatingStacks())
// R-660 (v0.268.0): a FOURTH — an app HELD after a failed update is stopped by the product and has
// its own event (`app_update_held`). Without this each hold was followed by an `app_start_failed`
// for the same app. Pinned by TestR660_UpdateHeldAppIsNotReportedDown + the wiring test beside it.
suppressed = unionSuppressed(suppressed, updateHeldSet(held))
return classifyRunStates(mgr.GetStacks(), suppressed, q.FailedRestarts(), time.Now())
}
// updateHeldLister is the backup manager's UpdateHeldStacks, as a seam for the test.
type updateHeldLister interface {
UpdateHeldStacks() map[string]bool
}
// updateHeldSet is nil-safe over a nil interface (a box with backup disabled has no holds).
func updateHeldSet(l updateHeldLister) map[string]bool {
if l == nil {
return nil
}
return l.UpdateHeldStacks()
}
// unionSuppressed merges the suppression sets of the two mechanisms that stop apps on purpose.
// Returns nil when both are empty so the common case allocates nothing.
func unionSuppressed(a, b map[string]bool) map[string]bool {
@@ -3545,6 +3579,17 @@ func (a *updateGuardsAdapter) HoldAfterFailedUpdate(name string, at time.Time, r
if a.b == nil {
return fmt.Errorf("backup is not enabled on this box — the hold cannot be recorded")
}
// R-479: the sentence names what the chosen copy holds, decided by the app's data layout NOW.
return a.b.HoldAfterFailedUpdateHolding(name, at, rp.ProvenAt, rp.Tier, a.b.UpdateCopyHolds(name, rp.Tier), undoState)
// R-659 (v0.268.0): the hold names the newest copy that brings the app back WHOLE — not the
// precondition copy `rp`, which may be one the restore refuses — and none when there is none.
// R-479 stands inside it: the sentence still says what that copy holds.
_, err := a.b.HoldAfterFailedUpdateWhole(context.Background(), name, at, undoState)
return err
}
// HoldNoWholeCopy is the page's half of R-659: a hold naming no copy gets no restore button.
func (a *updateGuardsAdapter) HoldNoWholeCopy(name string) bool {
if a.b == nil {
return false
}
return a.b.HoldNoWholeCopy(name)
}