v0.90.0 — guest RAM resize (R-24) + fast-tick-until-convergence (R-28)

MinAgent coupling: felhom-controller v0.143.0 gates its guest-memory-resize UI on
this agent (FeatureGuestMemoryResize, MinAgent 0.90.0).

R-24 guest RAM resize (internal/localapi/guestmemory.go): self-scoped GET/POST
/guest/memory. Agent enforces every bound FRESH per request (min 2048, max
host_total-2048, shrink floor max(2048, usage+512)); applies via PVE SetConfig —
live cgroup apply, no reboot (Phase-0 proven on the nested demo box). Verify-after-apply
re-reads maxmem before claiming success. New narrow MemoryOps seam (GuestAPI untouched);
Options.Memory nil -> 503. Memory only.

R-28 fast-tick (internal/fasttick): while any desired-state item is unapplied -
including the pre-tunnel window a hub poke can't reach - pulse the shared out-of-band
trigger every 30s, self-disarm on convergence. Four cached sources (desired-gen==0,
reconcile Planned-Pending>0, pbsdr waiting_secret only, wgtunnel desired-not-operational);
LOUD pbsdr states + pending_signature excluded. Seams: reconcile.Engine.LastResult() +
wgtunnel.Manager.TunnelConvergence() (cached, no per-tick exec).

Guests-0/0: hypothesis REFUTED live (9201 IS a pool member; 0/0 was the pre-provision
window; PoolAddVMID re-assert already covers restore-over-existing). No code change; the
fast-tick mitigates the window.

Tests + red-proofs (i floor guard, ii max guard, iii always-pulse) all restored green.
This commit is contained in:
2026-07-17 19:09:40 +02:00
parent 9127f547f9
commit ac112c956e
10 changed files with 958 additions and 1 deletions
+36
View File
@@ -108,6 +108,28 @@ type Manager struct {
lastResolvedIP netip.Addr // last A-record we rendered into the conf; invalid → resolve on next apply
resolveFailLogged bool // DNS-failure log throttle (reset on the next success)
staleSameIPLogged bool // "stale but IP unchanged" log throttle (endpoint-down, not re-IP)
// conv is the CACHED tunnel-convergence snapshot (v0.90.0, R-28 fast-tick source), refreshed at
// the end of every Apply (the tunnel's own cadence) so the fast-tick reads it WITHOUT execing
// `wg`/`systemctl` per tick. wgBlockDesired = a wireguard block for THIS key is desired;
// operational = registered (marker) AND the unit is active. desired && !operational is exactly
// the poke-undeliverable window R-28 exists to close.
conv convSnapshot
}
// convSnapshot is the cheap, cached read the fast-tick consumes.
type convSnapshot struct {
wgBlockDesired bool
operational bool
}
// TunnelConvergence returns the cached (desired, operational) snapshot without any exec (fast-tick
// source). Zero value (false, false) before the first Apply = "nothing desired yet" = converged for
// this source (the never-fetched case is covered by the desired-generation source instead).
func (m *Manager) TunnelConvergence() (desired, operational bool) {
m.mu.Lock()
defer m.mu.Unlock()
return m.conv.wgBlockDesired, m.conv.operational
}
// NewManager builds a Manager. stateDir is the agent state dir (default /var/lib/felhom-agent —
@@ -307,6 +329,20 @@ func (m *Manager) Apply(ctx context.Context, fetched bool, block *hub.WireWiregu
m.mu.Lock()
defer m.mu.Unlock()
// Refresh the cached fast-tick convergence snapshot at the end of every Apply (this cadence),
// so the fast-tick never execs. Runs under the lock, before the Unlock defer (LIFO), covering
// every return path. desired = a wg block for this key is wanted; operational = registered + active.
defer func() {
desired := fetched && block != nil
operational := false
if desired {
if mk := m.loadMarker(); mk != nil {
operational = m.isActive(ctx)
}
}
m.conv = convSnapshot{wgBlockDesired: desired, operational: operational}
}()
keyExists := false
if _, err := os.Stat(KeyFilePath(m.stateDir)); err == nil {
keyExists = true