diff --git a/CHANGELOG.md b/CHANGELOG.md index e5bf5c92..9bd002fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,11 @@ after a later save of the running controller" — R-1343 reproduced), `TestR1343_TheBoxCommandFallsBackWhenNoControllerRuns`, `TestR1343_TheFallbackDropsAWaitingPutBackToo`, `TestHoldCtl_NotReachableFromOutside`, `TestHoldCtl_UnknownRequestIsRefused`, `TestHoldCtl_ANonSocketFileIsNotReplaced`. +- **R-1340, second producer (found live 2026-10-29 on rc.1, Tester 1):** the hold's `Why()` — the hub's „Held apps" line, + the box's `app_held` event and `--restore-holds` — still said „held after an off-site restore failed its database + replay" for a LOCAL restore of an app with no database. It is now built from the record (`RestoreKind`, `MixedCause`, + `FailedStep`, `DBReplay`); a record from before these fields gets the neutral „held after a restore stopped half-way". + Test `TestR1340_WhyNamesTheMixedCause` (13 assertions failed on the old sentence). - **R-1340 — the operator's „HELD STOPPED" mail names the real cause** (`cmd/controller/holdmail.go` `heldStoppedOperatorText`, built from the hold record). Hold writers now record `RestoreKind` (off-site / local), `MixedCause` (`definition_write` / `put_back` / `interrupted_put_back`), `FailedStep` (the put-back step that failed, diff --git a/controller/cmd/controller/r1286_hold_why_test.go b/controller/cmd/controller/r1286_hold_why_test.go index cd331ee5..1e87b0f9 100644 --- a/controller/cmd/controller/r1286_hold_why_test.go +++ b/controller/cmd/controller/r1286_hold_why_test.go @@ -20,7 +20,9 @@ func TestR1286_EveryHoldReasonSaysWhatItIs(t *testing.T) { }{ {settings.RestoreHold{Stack: "kimai", At: "2026-10-14T09:06:31Z", Reason: settings.HoldReasonUnhealthyStop, UnhealthyKind: "crash_loop"}, "stopped by the box for a crash loop"}, {settings.RestoreHold{Stack: "a", At: "t", Reason: settings.HoldReasonUpdateFailed}, "failed update"}, - {settings.RestoreHold{Stack: "b", At: "t", Reason: settings.HoldReasonRestoreMixed}, "off-site restore failed its database replay"}, + // R-1340 (2026-10-29): a record with no cause fields no longer claims „off-site … database replay" — that was the + // false story (a local restore of an app with no database read the same); it says what is known. + {settings.RestoreHold{Stack: "b", At: "t", Reason: settings.HoldReasonRestoreMixed}, "restore stopped half-way"}, {settings.RestoreHold{Stack: "c", At: "t", Reason: settings.HoldReasonPutBackPending}, "pre-restore copy is poured back"}, {settings.RestoreHold{Stack: "d", At: "t"}, "failed restore whose rollback also failed"}, } { diff --git a/controller/internal/settings/r1340_why_test.go b/controller/internal/settings/r1340_why_test.go new file mode 100644 index 00000000..5e18fd81 --- /dev/null +++ b/controller/internal/settings/r1340_why_test.go @@ -0,0 +1,41 @@ +package settings + +import ( + "strings" + "testing" +) + +// R-1340, second producer (found live 2026-10-29 on Tester 1): the hold's Why() — the hub's „Held apps" line and the +// box's app_held event — still said „held after an off-site restore failed its database replay" for a LOCAL restore of +// an app with no database whose put-back stop failed. It must name what the hold record carries. +// RED-PROOF: seen failing on the old fixed sentence. +func TestR1340_WhyNamesTheMixedCause(t *testing.T) { + for _, tc := range []struct { + name string + h RestoreHold + want, not []string + }{ + {"local put-back, no database", RestoreHold{Reason: HoldReasonRestoreMixed, At: "T", RestoreKind: RestoreKindLocal, MixedCause: MixedCausePutBack, FailedStep: "stopping the app before the put-back"}, + []string{"local restore", "putting the live data back failed", "stopping the app before the put-back"}, []string{"database", "off-site"}}, + {"off-site database replay", RestoreHold{Reason: HoldReasonRestoreMixed, At: "T", RestoreKind: RestoreKindOffsite, MixedCause: MixedCausePutBack, DBReplay: true}, + []string{"off-site restore", "database replay", "putting the live data back failed"}, nil}, + {"definition write", RestoreHold{Reason: HoldReasonRestoreMixed, At: "T", RestoreKind: RestoreKindOffsite, MixedCause: MixedCauseDefinitionWrite}, + []string{"definition"}, []string{"database"}}, + {"interrupted put-back", RestoreHold{Reason: HoldReasonRestoreMixed, At: "T", MixedCause: MixedCauseInterruptedPutBack}, + []string{"interrupted"}, []string{"database"}}, + {"legacy record", RestoreHold{Reason: HoldReasonRestoreMixed, At: "T"}, + []string{"stopped half-way", "clear the hold"}, []string{"database", "off-site"}}, + } { + got := tc.h.Why() + for _, w := range tc.want { + if !strings.Contains(got, w) { + t.Errorf("%s: %q lacks %q", tc.name, got, w) + } + } + for _, n := range tc.not { + if strings.Contains(got, n) { + t.Errorf("%s: %q says %q", tc.name, got, n) + } + } + } +} diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index a117b165..f0551536 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -2051,7 +2051,7 @@ func (h RestoreHold) Why() string { } return "stopped by the box for a " + strings.ReplaceAll(kind, "_", " ") + " (" + h.At + ") — the household's Start gives it one more try" case HoldReasonRestoreMixed: - return "held after an off-site restore failed its database replay (" + h.At + ") — clear the hold to start it" + return h.mixedWhy() + " (" + h.At + ") — clear the hold to start it" case HoldReasonPutBackPending: return "held until the pre-restore copy is poured back (" + h.At + ") — the put-back starts it" default: @@ -2059,6 +2059,36 @@ func (h RestoreHold) Why() string { } } +// mixedWhy (R-1340) names a restore_mixed hold from what its record carries — never „database" or „off-site" unless the +// record says so. A record written before these fields („legacy") gets the neutral sentence. Pinned by +// TestR1340_WhyNamesTheMixedCause. +func (h RestoreHold) mixedWhy() string { + kind := "a restore" + switch h.RestoreKind { + case RestoreKindOffsite: + kind = "an off-site restore" + case RestoreKindLocal: + kind = "a local restore" + } + switch h.MixedCause { + case MixedCauseDefinitionWrite: + return "held after " + kind + " could not write its definition and writing the live definition back failed too" + case MixedCauseInterruptedPutBack: + return "held after a restore was interrupted and its live data could not be put back at the next start" + case MixedCausePutBack: + what := "failed" + if h.DBReplay { + what = "failed its database replay" + } + s := "held after " + kind + " " + what + " and putting the live data back failed" + if h.FailedStep != "" { + s += " (" + h.FailedStep + ")" + } + return s + } + return "held after a restore stopped half-way" +} + // SetRestoreHold records a hold. Modelled on SetDisconnected: a condition, plus what it is holding. func (s *Settings) SetRestoreHold(h RestoreHold) error { s.mu.Lock()