v0.284.1 — the image clean-up runs on the household's Remove button too; one summary line per pass (decision 53, R-736)
gates / gates (push) Successful in 26s

Found live on 9202: v0.284.0 wired the remove half into DeleteStack only; the app page's Remove runs RemoveStack.
Now RemoveStack reads the app's image repositories before its compose down and runs the retention after. Every
retention pass logs one line (images seen, candidates, deleted), so a pass that kept everything is visible.
Tests TestImageRetention_TheRemoveButtonRunsIt / ADoneUpdateRunsItWithThePrevious, red-proofed. v0.284.0 was never
floored (scratch 9202 only).

MinAgent: 0.131.0 (unchanged).

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-30 22:54:15 +02:00
parent 5a3437669f
commit 00fddd3816
6 changed files with 98 additions and 4 deletions
@@ -242,3 +242,51 @@ func TestImageRetention_NoPassWhileAnUpdateRuns(t *testing.T) {
t.Fatalf("a pass ran while docs was updating: %v", f.rmi)
}
}
// The household's Remove button runs RemoveStack — the retention must run there (v0.284.0 wired only DeleteStack;
// found live on 9202 2026-09-30).
// COMPANION RED-PROOF: drop the retainAfterRemoveFn call in RemoveStack → "RemoveStack did not run the retention".
func TestImageRetention_TheRemoveButtonRunsIt(t *testing.T) {
drive := t.TempDir()
m, _, _ := newR442Manager(t, "app", ssdCompose, driveAppYAML(drive), drive)
var got string
var repos map[string]bool
prev := retainAfterRemoveFn
retainAfterRemoveFn = func(_ *Manager, name string, r map[string]bool) { got, repos = name, r }
defer func() { retainAfterRemoveFn = prev }()
if _, err := m.RemoveStack("app", false, nil); err != nil {
t.Fatalf("RemoveStack: %v", err)
}
if got != "app" || len(repos) == 0 {
t.Fatalf("RemoveStack did not run the retention with the app's repos (got %q, %v)", got, repos)
}
}
// A guarded Update that ends done runs the retention with what the app ran BEFORE (the previous image to keep).
// COMPANION RED-PROOF: drop the retainAfterUpdate call at the end of verifyAndConclude → "the done update did not run it".
func TestImageRetention_ADoneUpdateRunsItWithThePrevious(t *testing.T) {
m, dir, _, _, _ := ladderManager(t, true)
cfg := LoadAppConfig(dir)
cfg.InstalledImages = map[string]InstalledImage{"web": {Ref: ladderA, Digest: dA, At: "2026-09-20T00:00:00Z"}}
must(t, SaveAppConfig(dir, cfg, m.encKey, nil))
must(t, m.ScanStacks())
ch := make(chan map[string]InstalledImage, 1)
prev := retainAfterUpdateFn
retainAfterUpdateFn = func(_ *Manager, name string, p map[string]InstalledImage) { ch <- p }
defer func() { retainAfterUpdateFn = prev }()
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
t.Fatal(err)
}
st := waitUpdateDone(t, m, "nextcloud")
if st.UpdatePhase != UpdatePhaseDone {
t.Fatalf("the update ended %q (%s)", st.UpdatePhase, st.UpdateError)
}
select {
case p := <-ch:
if p["web"].Ref != ladderA {
t.Fatalf("the previous image handed on is %+v, want %s", p, ladderA)
}
default:
t.Fatal("the done update did not run the retention")
}
}