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
+55
View File
@@ -70,6 +70,14 @@ type GuestAttacher interface {
RebootGuest(ctx context.Context, vmid int) error
}
// IntentRecorder persists drive enroll/eject INTENT (slice 10 P3 self-heal), keyed by durable-id, so
// the watchdog reconciles only enrolled drives and respects an official eject. Satisfied by
// *storage.IntentStore. Optional — when nil, the local API records no intent (self-heal is ungated).
type IntentRecorder interface {
SetEnrolled(durableID string) error
SetEjected(durableID string) error
}
// ---- handlers ---------------------------------------------------------------------------
// DiskInfo is one host drive with its data-bearing flag (for the UI).
@@ -205,6 +213,10 @@ func (s *Server) handleDiskEject(w http.ResponseWriter, r *http.Request, vmid in
return
}
dependents := s.dependentGuests(r.Context(), req.Where)
// Record the EJECT intent BEFORE unmounting (the target still resolves to its durable-id) so the
// self-heal watchdog leaves it alone — an OFFICIAL eject is the only thing that sets this (P3); an
// out-of-band unmount records nothing and is healed.
s.recordIntent(r.Context(), req.Where, "ejected")
if err := s.disks.Unmount(r.Context(), req.Where); err != nil {
s.logger.Error("local-api: disk eject", "vmid", vmid, "where", req.Where, "err", err)
writeErr(w, http.StatusBadRequest, "eject failed: "+err.Error())
@@ -266,6 +278,8 @@ func (s *Server) handleDiskGuestAttach(w http.ResponseWriter, r *http.Request, v
writeErr(w, http.StatusBadGateway, "guest-attach failed: "+err.Error())
return
}
// Record the drive as ENROLLED so the self-heal watchdog will reconcile it (P3).
s.recordIntent(r.Context(), where, "enrolled")
writeOK(w, map[string]any{"vmid": vmid, "attached": where, "slot": slot})
}
@@ -485,6 +499,47 @@ func (s *Server) handleDiskFormat(w http.ResponseWriter, r *http.Request, vmid i
"device is system/backup-protected — format requires an operator signature ("+dec.Reason+")")
}
// durableIDForMount resolves the durable-id of the storage mounted at `where` (from the agent's own
// storage view) — the key the intent store records enroll/eject against. "" if not resolvable.
func (s *Server) durableIDForMount(ctx context.Context, where string) string {
targets, err := s.storage.Observe(ctx)
if err != nil {
return ""
}
for _, t := range targets {
if t.MountPath == where {
return t.DurableID
}
}
return ""
}
// recordIntent records enroll/eject intent for the drive at `where`, best-effort (a nil store, an
// unresolved durable-id, or a write error is logged, never fatal — intent is a self-heal aid, not a
// gate on the user's action). `action` is "enrolled" or "ejected".
func (s *Server) recordIntent(ctx context.Context, where, action string) {
if s.intent == nil {
return
}
id := s.durableIDForMount(ctx, where)
if id == "" {
s.logger.Warn("local-api: intent not recorded — durable-id unresolved", "where", where, "action", action)
return
}
var err error
switch action {
case "enrolled":
err = s.intent.SetEnrolled(id)
case "ejected":
err = s.intent.SetEjected(id)
}
if err != nil {
s.logger.Warn("local-api: intent record failed", "where", where, "action", action, "durable_id", id, "err", err)
return
}
s.logger.Info("local-api: drive intent recorded", "where", where, "action", action, "durable_id", id)
}
// hostReader returns the injected root-free host topology reader, or the production default. The seam
// keeps the role classification (SystemDisks) testable without touching the real /proc /dev /sys.
func (s *Server) hostReader() storage.HostReader {
+5
View File
@@ -84,6 +84,9 @@ type Options struct {
// GuestAttach binds an enrolled user-data drive's felhom-data namespace into the guest (slice 10
// P2, Model A). OPTIONAL — when nil, POST /disks/guest-attach reports "not configured".
GuestAttach GuestAttacher
// Intent records drive enroll/eject intent for the self-heal watchdog (slice 10 P3). OPTIONAL —
// when nil, no intent is recorded (self-heal runs ungated).
Intent IntentRecorder
// HostReader is the root-free host topology reader used to classify a device/mount's protection
// ROLE (it backs SystemDisks for the eject role-gate + the /disks role hints). OPTIONAL — when nil
// it defaults to the production *storage.ProcHostReader. Injectable so the role-gate is testable.
@@ -139,6 +142,7 @@ type Server struct {
diskGate StorageGate // slice 8C (optional)
guestList GuestLister // slice 8C (optional)
guestAttach GuestAttacher // slice 10 P2 (optional)
intent IntentRecorder // slice 10 P3 (optional)
host storage.HostReader // role classification source (optional; defaults to ProcHostReader)
hostMetrics HostMetricsProvider // slice 9 (optional)
@@ -180,6 +184,7 @@ func NewServer(o Options) (*Server, error) {
diskGate: o.DiskGate,
guestList: o.Guests2,
guestAttach: o.GuestAttach,
intent: o.Intent,
host: o.HostReader,
hostMetrics: o.HostMetrics,
hostID: o.HostID,