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
@@ -212,6 +212,13 @@ func (m *Manager) deleteUnkeptImages(why string, repos map[string]bool, except s
}
sort.Strings(ids)
var deleted []string
candidates := 0
defer func() {
// ONE line per pass, whatever it did (v0.284.1): an absent deletion line must never be read as "it ran and
// found nothing" — this line is the positive observable that the pass ran (R-96 rule 3).
m.logger.Printf("[INFO] [stacks] image retention (%s): pass over %d image(s) of %v — %d candidate(s), %d deleted, the rest kept",
why, len(ids), sortedKeys(repos), candidates, len(deleted))
}()
for _, id := range ids {
group := byID[id]
if keep[id] {
@@ -227,6 +234,7 @@ func (m *Manager) deleteUnkeptImages(why string, repos map[string]bool, except s
if !inRepos {
continue
}
candidates++
if len(uniqueRepos(group)) > 1 {
m.logger.Printf("[INFO] [stacks] image retention (%s): %s carries several repositories' names %v — left alone", why, shortID(id), names)
continue
@@ -291,6 +299,11 @@ func (m *Manager) retainAfterUpdate(name string, previous map[string]InstalledIm
retainAfterUpdateFn(m, name, previous)
}
// retainAfterRemoveFn runs the retention after a remove (a seam, so a test can see the remove path calls it).
var retainAfterRemoveFn = func(m *Manager, name string, repos map[string]bool) {
go m.RetainImagesAfterRemove(name, repos)
}
// RetainImagesAfterRemove deletes a removed app's images (its repos, read BEFORE the remove) that nothing else keeps.
func (m *Manager) RetainImagesAfterRemove(name string, repos map[string]bool) {
if len(repos) == 0 {
@@ -351,3 +364,12 @@ func (m *Manager) RunImageRetentionOnce() []string {
_ = os.WriteFile(m.imageRetentionMarker(), []byte(fmt.Sprintf("deleted %d\n%s\n", len(deleted), strings.Join(deleted, "\n"))), 0o644)
return deleted
}
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}