v0.284.0 — a box deletes old app images (decision 53, R-736); an after_install app is held until its known login is replaced (R-741)
gates / gates (push) Successful in 27s

Image retention: after a done/undone guarded Update and at remove, an app's images older than its running
and previous one are deleted — never an image any container, installed compose or installed/previous record
names (box-wide keep set read at delete time); exact id, never forced or pruned; paused while any update runs;
a one-time sweep of catalog app images at the first start. Install hold: an after_install app is installed
behind the setup gate's door and opens when after_install succeeds or the household says it changed the login.
Tests TestImageRetention_* and TestInstallHold_* with red-proofs; parity fixture for the held card.

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:29:33 +02:00
parent d48da6c7f3
commit 5a3437669f
23 changed files with 1724 additions and 23 deletions
+16 -6
View File
@@ -757,6 +757,13 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
dir := filepath.Dir(st.ComposePath)
g := m.guards()
entry := updateJournalEntry{StartedAt: start}
// decision 53: what the app ran before this press — kept as the previous image if the update ends done.
if st.AppConfig != nil && len(st.AppConfig.InstalledImages) > 0 {
entry.BeforeImages = make(map[string]InstalledImage, len(st.AppConfig.InstalledImages))
for k, v := range st.AppConfig.InstalledImages {
entry.BeforeImages[k] = v
}
}
fail := func(key, detail string, args ...interface{}) {
m.logger.Printf("[ERROR] [stacks] update %s FAILED in phase %s after %s — nothing was moved: %s", name, entry.Phase, m.now().Sub(start).Round(time.Millisecond), detail)
m.clearJournal(name)
@@ -1031,6 +1038,7 @@ func (m *Manager) verifyAndConclude(ctx context.Context, name, dir string, env [
}
m.finishUpdate(name, UpdatePhaseDone, "")
m.logger.Printf("[INFO] [stacks] update %s: DONE in %s", name, m.now().Sub(start).Round(time.Second))
m.retainAfterUpdate(name, entry.BeforeImages) // decision 53 (R-736)
}
// holdLogTailLines is how much of each service's log the hold keeps. 400 lines is enough to hold a
@@ -1259,12 +1267,14 @@ func (m *Manager) waitUpdateHealthyMeta(ctx context.Context, name string, timeou
// ── the journal ──────────────────────────────────────────────────────────────────────────────────
type updateJournalEntry struct {
Phase string `json:"phase"`
StartedAt time.Time `json:"started_at"`
PrevPin map[string]string `json:"prev_pin,omitempty"`
PrevCompose string `json:"prev_compose,omitempty"`
PrevApplied string `json:"prev_applied,omitempty"`
ProvenCopyAt string `json:"proven_copy_at,omitempty"`
Phase string `json:"phase"`
StartedAt time.Time `json:"started_at"`
PrevPin map[string]string `json:"prev_pin,omitempty"`
// BeforeImages (decision 53): installed_images at the press — the previous image kept if the update ends done.
BeforeImages map[string]InstalledImage `json:"before_images,omitempty"`
PrevCompose string `json:"prev_compose,omitempty"`
PrevApplied string `json:"prev_applied,omitempty"`
ProvenCopyAt string `json:"proven_copy_at,omitempty"`
// ProvenTier (R-475) — which tier ProvenCopyAt belongs to, so a resumed update that fails names
// the right copy. 0 in a journal written by v0.238.1 or older.
ProvenTier int `json:"proven_tier,omitempty"`