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
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:
@@ -1,3 +1,18 @@
|
||||
## v0.284.1 — the image clean-up also runs on the household's Remove button; one summary line per pass (2026-09-30)
|
||||
|
||||
**MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). No new strings. **v0.284.0 was never floored** — it
|
||||
ran only on scratch guest 9202, where this was found; the fleet goes from 0.283.1 to 0.284.1.
|
||||
|
||||
- **Found live on 9202:** v0.284.0 wired the remove half of decision 53 into `DeleteStack` only; the app page's Remove
|
||||
(`POST /api/stacks/<n>/remove`) runs `RemoveStack`, so a removed app's images stayed (the "seam built but never wired"
|
||||
class, a fifth instance). Now `RemoveStack` reads the app's image repositories before its `compose down` and runs the
|
||||
retention after (seam `retainAfterRemoveFn`). Its old comment "keep images for potential redeploy" is superseded by
|
||||
decision 53 (a redeploy pulls).
|
||||
- **One INFO line per retention pass, whatever it did** (`pass over N image(s) of [repos] — C candidate(s), D deleted`):
|
||||
v0.284.0 logged deletions only, so a pass that ran and kept everything was indistinguishable from one that never ran.
|
||||
- Tests `TestImageRetention_TheRemoveButtonRunsIt`, `TestImageRetention_ADoneUpdateRunsItWithThePrevious`; red-proofs
|
||||
(the call removed → each fails).
|
||||
|
||||
## v0.284.0 — a box deletes old app images (decision 53); an after_install app is held until its known login is replaced (R-741) (2026-09-30)
|
||||
|
||||
**MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). New strings: `app_info.install_hold_title`,
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
>
|
||||
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
|
||||
|
||||
Last updated: 2026-09-30 late evening (v0.284.0 — image retention, the install hold)
|
||||
Last updated: 2026-09-30 late evening (v0.284.0 + v0.284.1 — image retention, the install hold; v0.284.1 wires RemoveStack)
|
||||
|
||||
> **2026-09-30 late — v0.284.0.** Operator rulings: `09` §3 decision 53 (R-736 A: keep running + previous image per
|
||||
> service, delete older, never an image a container/compose/record names) → `stacks/image_retention.go`, with a
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# REPORT — v0.284.0 (2026-09-30 late evening)
|
||||
# REPORT — v0.284.0 + v0.284.1 (2026-09-30 late evening)
|
||||
|
||||
- **v0.284.1:** the remove half of decision 53 wired into `RemoveStack` (the household's Remove button — v0.284.0 wired only
|
||||
`DeleteStack`, found live on 9202); one summary line per retention pass. v0.284.0 was never floored (9202 only).
|
||||
|
||||
- **Image retention (`09` §3 decision 53, R-736):** after a done/undone guarded Update and at remove, an app's older
|
||||
images are deleted; the box-wide keep set (containers, installed composes, installed/previous records) is read at
|
||||
|
||||
@@ -367,7 +367,7 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
|
||||
if err := m.ScanStacks(); err != nil {
|
||||
m.logger.Printf("[WARN] Rescan after delete failed: %v", err)
|
||||
}
|
||||
go m.RetainImagesAfterRemove(name, removedRepos) // decision 53 (R-736): the removed app's images, if nothing keeps them
|
||||
retainAfterRemoveFn(m, name, removedRepos) // decision 53 (R-736): the removed app's images, if nothing keeps them
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
@@ -619,7 +619,12 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Run docker compose down --volumes (keep images for potential redeploy)
|
||||
// decision 53 (R-736, v0.284.1): the app's image repositories, read BEFORE the remove. The images are deleted after
|
||||
// it under the retention rule (the old "keep images for a redeploy" is superseded: a redeploy pulls). Found on 9202
|
||||
// 2026-09-30: v0.284.0 wired only DeleteStack, and the household's Remove button runs THIS function.
|
||||
removedRepos := appImageRepos(stackDir, LoadAppConfig(stackDir))
|
||||
|
||||
// Step 2: Run docker compose down --volumes
|
||||
env := m.stackEnv(stackDir)
|
||||
// R-489 (v0.242.0): the volumes are listed BEFORE and AFTER; the difference is what was removed.
|
||||
// Parsing compose's progress output reported `null` over volumes it did remove — measured five
|
||||
@@ -754,6 +759,7 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] Stack %s removed successfully (took %.1fs)", name, time.Since(start).Seconds())
|
||||
retainAfterRemoveFn(m, name, removedRepos) // decision 53 (R-736)
|
||||
|
||||
// Step 7: Update in-memory state and rescan
|
||||
m.mu.Lock()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user