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
@@ -23,6 +23,7 @@ import (
"strings"
"sync"
"gitea.dooplex.hu/admin/felhom-controller/internal/aftercrash"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/api"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
@@ -963,6 +964,32 @@ func main() {
}
}
bootGrace := crashboot.New(startTime, crashProbe, logger)
// R-1007 (`09` §3 decision 236): ONCE per start that followed an unclean host boot — the same verdict, after the same
// CrashGrace — each app with an after_unclean_boot block rebuilds the files the power cut left empty and its own
// lost-file check is read. A normal or unknown boot never runs it. The operator gets counts only
// (app_repaired_after_crash); the household's note lives on the app page. Pinned by internal/aftercrash.
aubRunner := aftercrash.New(bootGrace, stackMgr.AfterUncleanBootApps, stackMgr.RunAfterUncleanBoot,
func(name string, bootAt time.Time, res stacks.AfterUncleanBootResult) {
disp := name
if st, ok := stackMgr.GetStack(name); ok && st.Meta.DisplayName != "" {
disp = st.Meta.DisplayName
}
d := notify.AppRepairDetails{App: disp, StackName: name, Found: res.Found, Repaired: res.Repaired,
StillBroken: res.StillBroken, Lost: res.Lost, LostChecked: res.LostChecked,
DurationS: int64(res.Duration.Round(time.Second) / time.Second), Skipped: res.Skipped, Problem: res.Problem}
if !bootAt.IsZero() {
d.BootAt = bootAt.UTC().Format(time.RFC3339)
}
notifier.NotifyAppRepairedAfterCrash(d)
}, logger)
var aubBusy sync.Mutex
sched.Every("after-unclean-boot", 30*time.Second, func(ctx context.Context) error {
if aubRunner.Done() || !aubBusy.TryLock() {
return nil
}
go func() { defer aubBusy.Unlock(); aubRunner.Tick(time.Now()) }() // a run can take minutes; never block the scheduler
return nil
})
sched.Every("deadapp-check", 30*time.Second, func(ctx context.Context) error {
if bootGrace.Within(time.Now()) {
return nil // still inside the startup settle window (90 s, or ~15 min after a crash boot)