v0.278.0: a hold left by an earlier install no longer holds the new one (R-704)
gates / gates (push) Successful in 26s

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-28 15:34:57 +02:00
parent 1ee6dbe184
commit 7cba0bfca7
9 changed files with 232 additions and 6 deletions
@@ -0,0 +1,38 @@
package settings
import (
"io"
"log"
"path/filepath"
"testing"
)
// R-704 (v0.278.0) — ClearUpdateHold clears the holds that belong to an INSTALL (update, crash-loop stop) and
// never a restore hold (R-379), which stays operator-cleared.
// COMPANION RED-PROOF: the pre-fix predicate (`h.Reason != HoldReasonUpdateFailed`) → the crash-loop case fails.
func TestR704_ClearUpdateHoldClearsTheInstallsHoldsOnly(t *testing.T) {
s, err := Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
for _, c := range []struct {
reason string
want bool
}{{HoldReasonUpdateFailed, true}, {HoldReasonUnhealthyStop, true}, {HoldReasonRestoreFailed, false}} {
if err := s.SetRestoreHold(RestoreHold{Stack: "app", At: "2026-09-13T19:56:32Z", Reason: c.reason}); err != nil {
t.Fatal(err)
}
cleared, err := s.ClearUpdateHold("app")
if err != nil {
t.Fatal(err)
}
_, still := s.GetRestoreHold("app")
if cleared != c.want || still == c.want {
t.Fatalf("reason %q: cleared=%v still-held=%v, want cleared=%v", c.reason, cleared, still, c.want)
}
_, _ = s.ClearRestoreHold("app")
}
if cleared, _ := s.ClearUpdateHold("none"); cleared {
t.Fatal("clearing an app with no hold reported a clear")
}
}
+8 -5
View File
@@ -1917,19 +1917,22 @@ func (s *Settings) ClearRestoreHold(stack string) (bool, error) {
return true, s.save()
}
// ClearUpdateHold (R-491, v0.242.0) removes an app's hold ONLY when it is an update hold. An R-379
// restore hold stays: that one means a database was left in an unknown state and is cleared by the
// operator (`-clear-restore-hold`), never by a removal. Returns whether an update hold was cleared.
// ClearUpdateHold (R-491, v0.242.0) removes an app's hold when it belongs to the app's INSTALL: an update
// hold, and since v0.278.0 (R-704) the box's crash-loop/OOM stop (HoldReasonUnhealthyStop) — both name an
// install that a removal ends, and the name is all a later install shares with it. An R-379 restore hold
// stays: that one means a database was left in an unknown state and is cleared by the operator
// (`-clear-restore-hold`), never by a removal or an install. Returns whether a hold was cleared.
// Pinned by TestR704_ClearUpdateHoldClearsTheInstallsHoldsOnly.
func (s *Settings) ClearUpdateHold(stack string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
h, ok := s.RestoreHolds[stack]
if !ok || h.Reason != HoldReasonUpdateFailed {
if !ok || (h.Reason != HoldReasonUpdateFailed && h.Reason != HoldReasonUnhealthyStop) {
return false, nil
}
delete(s.RestoreHolds, stack)
if s.log != nil {
s.log.Printf("[INFO] [settings] update hold CLEARED for %s (the app was removed)", stack)
s.log.Printf("[INFO] [settings] %s hold CLEARED for %s (set %s; its install is gone)", h.Reason, stack, h.At)
}
return true, s.save()
}