v0.311.0: after a power cut, an app repairs the files the cut left empty; the household sees lost photos (R-1007)
gates / gates (push) Successful in 52s

New template block after_unclean_boot (decision 236): once per controller start that followed an
UNCLEAN host boot (crashboot's own verdict, after the 15-minute crash grace; a normal or unknown boot
never runs it), each app with the block rebuilds ONLY its zero-byte files (scan: regular files, no
symlinks, its own filesystem; ids appended to the app's own command in batches; one retry, then
reported) and its own lost-file check is read. The household sees lost files (name, date taken) on
the app page until dismissed; the operator gets app_repaired_after_crash with counts only. Skips,
with the reason, an unproven image, an unknown key, an app not running. MinAgent 0.155.0 unchanged.
Tests red-proofed (14 guards). One new parity fixture (the new card only).

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
2026-10-14 16:47:13 +02:00
parent acbbf4b305
commit 8d862e4e69
24 changed files with 1865 additions and 3 deletions
+27
View File
@@ -310,6 +310,10 @@ type Manager struct {
updateUndoHealthFn func(ctx context.Context, name string, timeout time.Duration, meta *Metadata) (bool, string)
// afterLoadFn is RunAfterLoad's exec seam (kept.go); nil = compose exec with the app's env.
afterLoadFn func(dir string, args ...string) (string, error)
// aubSleepFn is RunAfterUncleanBoot's wait seam (tests); nil = time.Sleep.
aubSleepFn func(time.Duration)
// aubRunningFn is its "is the app up" seam (tests); nil = RefreshStatus + StateRunning.
aubRunningFn func(name string) bool
// keptUnitFn names the removed app's recovery unit on a drive (backup.RemovedAppUnitFor, wired in
// main.go) so a start-fresh moves it in with the files it belongs to. nil = files only.
keptUnitFn func(app, drive string) string
@@ -1150,6 +1154,11 @@ func deepCopyStack(s *Stack) Stack {
acCopy.LockedFields = make([]string, len(s.AppConfig.LockedFields))
copy(acCopy.LockedFields, s.AppConfig.LockedFields)
}
if r := s.AppConfig.AfterUncleanBoot; r != nil { // R-1007: the household's lost list is a slice
rc := *r
rc.Lost = append([]LostFile(nil), r.Lost...)
acCopy.AfterUncleanBoot = &rc
}
cp.AppConfig = &acCopy
}
@@ -1242,6 +1251,24 @@ func deepCopyStack(s *Stack) Stack {
as.Args, as.Command, as.OpenCommand = cloneStrs(as.Args), cloneStrs(as.Command), cloneStrs(as.OpenCommand)
cp.Meta.AfterSetup = &as
}
if s.Meta.AfterUncleanBoot != nil { // R-1007
ab := *s.Meta.AfterUncleanBoot
ab.Images = cloneStrs(ab.Images)
ab.Repair.Command = cloneStrs(ab.Repair.Command)
if ab.ReportLost != nil {
rl := *ab.ReportLost
rl.Command = cloneStrs(rl.Command)
ab.ReportLost = &rl
}
if ab.Extra != nil {
ex := make(map[string]any, len(ab.Extra))
for k, v := range ab.Extra {
ex[k] = v
}
ab.Extra = ex
}
cp.Meta.AfterUncleanBoot = &ab
}
if s.Meta.SetupDoneProbe != nil {
sp := *s.Meta.SetupDoneProbe
cp.Meta.SetupDoneProbe = &sp