agent v0.27.0: slice 10 P3 — self-heal watchdog reconcile + 4-state intent model

IntentStore (durable-id-keyed: new/enrolled/ejected/decommissioned, OnAbsent
replug rule). Watchdog re-mounts only enrolled drives (out-of-band unmount heals;
ejected/decommissioned/new left alone) + exp-backoff flapping guard (alert@4,
cap@8). guest-attach records enrolled; eject records ejected. Non-hollow tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 17:49:25 +02:00
parent bc4f2b9168
commit 237b85f420
8 changed files with 590 additions and 35 deletions
+122
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net"
"path/filepath"
"sync"
"testing"
"time"
@@ -358,3 +359,124 @@ func TestHostLiveness_NetworkDial(t *testing.T) {
t.Error("network target without endpoint must not be flagged down")
}
}
// ---- P3 self-heal reconcile (intent-gated) -----------------------------------------------
// newReconcileWD builds a watchdog with one mount-backed, durable-id'd target ("usb"), a fake
// remounter, an intent store, and the OnAbsent hook wired to it. Synchronous dispatch + manual clock.
func newReconcileWD(intent *IntentStore) (*Watchdog, *mapLiveness, *fakeRemounter, *time.Time) {
known := &staticKnown{targets: []KnownTarget{{
Name: "usb", DurableID: "uuid:U", MountBacked: true,
MountPath: "/mnt/usb", BackingDevice: "/dev/sdb1", UUID: "U",
}}}
live := &mapLiveness{present: map[string]bool{}, device: map[string]bool{}}
rem := &fakeRemounter{}
clock := time.Unix(1_700_000_000, 0).UTC()
var reader IntentReader
if intent != nil {
reader = intent
}
w := NewWatchdog(WatchdogOptions{
Targets: known, Liveness: live, Remounter: rem, Intent: reader,
OnAbsent: func(id string) {
if intent != nil {
_ = intent.OnAbsent(id)
}
},
Interval: time.Second, Debounce: 30 * time.Second, Logger: quietLogger(),
})
w.now = func() time.Time { return clock }
w.spawn = func(f func()) { f() }
return w, live, rem, &clock
}
// colleague's out-of-band unmount (intent=enrolled, device present, not mounted) → reconciled.
func TestWatchdog_Reconcile_EnrolledColleagueUnmount(t *testing.T) {
intent, _ := OpenIntentStore(filepath.Join(t.TempDir(), "i.json"))
_ = intent.SetEnrolled("uuid:U")
w, live, rem, _ := newReconcileWD(intent)
ctx := context.Background()
live.set("usb", true)
w.tick(ctx) // baseline present
live.set("usb", false)
live.setDevice("usb", true) // unmounted, device still there (out-of-band unmount)
w.tick(ctx)
if rem.count() != 1 {
t.Fatalf("enrolled drive out-of-band-unmounted should be reconciled: remounts=%d", rem.count())
}
}
// ejected / new / decommissioned → NEVER reconciled (only enrolled is).
func TestWatchdog_Reconcile_RespectsIntent(t *testing.T) {
for _, tc := range []struct {
name string
setup func(*IntentStore)
}{
{"ejected", func(s *IntentStore) { _ = s.SetEjected("uuid:U") }},
{"new", func(s *IntentStore) {}}, // no record
{"decommissioned", func(s *IntentStore) { _ = s.SetDecommissioned("uuid:U") }},
} {
t.Run(tc.name, func(t *testing.T) {
intent, _ := OpenIntentStore(filepath.Join(t.TempDir(), "i.json"))
tc.setup(intent)
w, live, rem, _ := newReconcileWD(intent)
ctx := context.Background()
live.set("usb", true)
w.tick(ctx)
live.set("usb", false)
live.setDevice("usb", true)
w.tick(ctx)
if rem.count() != 0 {
t.Fatalf("%s drive must NOT be reconciled: remounts=%d", tc.name, rem.count())
}
})
}
}
// ejected → physically absent → (OnAbsent clears to enrolled) → replug → reconciled (replug rule).
func TestWatchdog_Reconcile_EjectedAbsentReplugAutoMounts(t *testing.T) {
intent, _ := OpenIntentStore(filepath.Join(t.TempDir(), "i.json"))
_ = intent.SetEjected("uuid:U")
w, live, rem, _ := newReconcileWD(intent)
ctx := context.Background()
live.set("usb", true)
w.tick(ctx) // baseline present
// physically pull: not present AND device gone
live.set("usb", false)
live.setDevice("usb", false)
w.tick(ctx) // present→absent, device gone → OnAbsent clears ejected→enrolled
if intent.Get("uuid:U") != IntentEnrolled {
t.Fatalf("ejected→absent should clear to enrolled, got %q", intent.Get("uuid:U"))
}
// replug: device back, not yet mounted
live.setDevice("usb", true)
w.tick(ctx)
if rem.count() != 1 {
t.Fatalf("replugged drive (now enrolled) should auto-mount: remounts=%d", rem.count())
}
}
// flapping: a re-mount that never sticks backs off and STOPS after MaxRetries (no infinite loop).
func TestWatchdog_Reconcile_FlappingBacksOffAndCaps(t *testing.T) {
intent, _ := OpenIntentStore(filepath.Join(t.TempDir(), "i.json"))
_ = intent.SetEnrolled("uuid:U")
w, live, rem, clock := newReconcileWD(intent)
ctx := context.Background()
live.set("usb", true)
w.tick(ctx) // baseline present
// Drive is stuck: device present but the re-mount never makes it present.
live.set("usb", false)
live.setDevice("usb", true)
for i := 0; i < 20; i++ {
w.tick(ctx)
*clock = clock.Add(2 * time.Hour) // always exceed the (growing) backoff window
}
if rem.count() != flappingMaxRetries {
t.Fatalf("flapping re-mount should cap at %d, got %d (infinite loop?)", flappingMaxRetries, rem.count())
}
// further ticks add no more re-mounts (stays capped)
w.tick(ctx)
if rem.count() != flappingMaxRetries {
t.Fatalf("capped flapping must not resume: got %d", rem.count())
}
}