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
@@ -0,0 +1,108 @@
// Package aftercrash runs the apps' after_unclean_boot blocks ONCE per controller start that followed an unclean
// host boot (R-1007, `09` §3 decision 236).
//
// The fact is crashboot's (the host crash guard, read through the agent): the same verdict that holds the app mails
// for CrashGrace. Unknown is a normal boot — a repair never runs on a guess. A normal boot never runs it. After a crash
// boot it waits CrashGrace from the controller's start (the apps come up slowly after a crash), then runs each app's
// block one after the other and reports each app's counts. It never stops an app; a failed run is reported, not
// retried. Pinned by aftercrash_test.go.
package aftercrash
import (
"log"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/crashboot"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// Verdict is crashboot.Grace's Decided (a seam for tests).
type Verdict func(now time.Time) (resolved, crash bool, bootAt time.Time)
// Runner does the one run. Every func field is required; Logger may be nil.
type Runner struct {
Start time.Time
Verdict Verdict
Apps func() []string
Run func(name string, bootAt time.Time) stacks.AfterUncleanBootResult
Report func(name string, bootAt time.Time, res stacks.AfterUncleanBootResult)
Logger *log.Logger
// Wait is how long after Start a crash-boot run waits (default crashboot.CrashGrace).
Wait time.Duration
mu sync.Mutex
done bool
}
// New builds the runner on the controller's crash-boot grace.
func New(g *crashboot.Grace, apps func() []string,
run func(string, time.Time) stacks.AfterUncleanBootResult,
report func(string, time.Time, stacks.AfterUncleanBootResult), logger *log.Logger) *Runner {
return &Runner{Start: g.Start(), Verdict: g.Decided, Apps: apps, Run: run, Report: report, Logger: logger}
}
// Done reports whether this start's decision has been taken (ran, or decided not to).
func (r *Runner) Done() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.done
}
// Tick is called periodically (the scheduler). It returns true once the run has happened or been ruled out.
func (r *Runner) Tick(now time.Time) bool {
r.mu.Lock()
if r.done {
r.mu.Unlock()
return true
}
resolved, crash, bootAt := r.Verdict(now)
if !resolved {
r.mu.Unlock()
return false
}
if !crash {
r.done = true
r.mu.Unlock()
r.logf("[INFO] [aftercrash] a normal (or unknown) boot — no app repair after an unclean boot (R-1007)")
return true
}
wait := r.Wait
if wait <= 0 {
wait = crashboot.CrashGrace
}
if now.Sub(r.Start) < wait {
r.mu.Unlock()
return false
}
r.done = true // set BEFORE the run: a slow or failing run is never started twice
r.mu.Unlock()
apps := r.Apps()
r.logf("[INFO] [aftercrash] the host's boot at %s followed an unclean stop — %d app(s) with an after_unclean_boot block (R-1007)",
bootAt.UTC().Format(time.RFC3339), len(apps))
for _, name := range apps {
res := r.Run(name, bootAt)
if res.Skipped != "" {
r.logf("[WARN] [aftercrash] %s: skipped — %s", name, res.Skipped)
} else {
r.logf("[INFO] [aftercrash] %s: found %d empty file(s), repaired %d, still broken %d, lost %d (checked=%t) in %s%s",
name, res.Found, res.Repaired, res.StillBroken, res.Lost, res.LostChecked, res.Duration.Round(time.Millisecond), problem(res.Problem))
}
r.Report(name, bootAt, res)
}
return true
}
func problem(p string) string {
if p == "" {
return ""
}
return " — problem: " + p
}
func (r *Runner) logf(format string, args ...any) {
if r.Logger != nil {
r.Logger.Printf(format, args...)
}
}