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:
@@ -0,0 +1,105 @@
|
||||
// Package fasttick is the agent-plane immediacy SECONDARY (v0.90.0, R-28). While ANY desired-state
|
||||
// item is still unapplied — most importantly the pre-tunnel WG-registration window where a hub poke
|
||||
// is undeliverable by construction — it pulses the hub control loop's out-of-band report trigger on
|
||||
// a fast (30 s) cadence, and self-disarms EMERGENTLY the instant everything converges. It is the
|
||||
// state-based complement to the poke: the poke handles hub→box changes once the tunnel exists; the
|
||||
// fast-tick handles the window before that (and any lingering unapplied drift) from the box side.
|
||||
//
|
||||
// By ruling it is STATE-BASED, not a fixed burst and not a timer: there is nothing to journal
|
||||
// (stateless across restarts) and nothing to leak. A perma-unconverged box fast-ticks at ~2 small
|
||||
// reports/min, bounded and visible; the LOUD pbsdr states (consumed_failed/verify_failed) are
|
||||
// deliberately EXCLUDED from the sources so a stuck-loud box does not hammer (§8).
|
||||
//
|
||||
// It pulses the SAME cap-1 channel the storage watchdog and the poke listener use, so a pulse
|
||||
// coalesces with a poke/watchdog nudge for free — no extra debounce here.
|
||||
package fasttick
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultInterval is the ruled fast cadence while unconverged.
|
||||
const DefaultInterval = 30 * time.Second
|
||||
|
||||
// Source reports whether one subsystem still has unapplied desired-state. Implementations MUST be a
|
||||
// cheap, CACHED read — no exec, no network per call (the fast-tick calls every source each tick).
|
||||
type Source interface {
|
||||
Unconverged() (unconverged bool, reason string)
|
||||
}
|
||||
|
||||
// SourceFunc adapts a plain func to a Source (main.go closes over each subsystem).
|
||||
type SourceFunc func() (bool, string)
|
||||
|
||||
// Unconverged implements Source.
|
||||
func (f SourceFunc) Unconverged() (bool, string) { return f() }
|
||||
|
||||
// Loop evaluates the sources on a ticker and pulses the out-of-band channel while any is unconverged.
|
||||
type Loop struct {
|
||||
sources []Source
|
||||
out chan<- struct{}
|
||||
interval time.Duration
|
||||
logger *slog.Logger
|
||||
armed bool // for armed↔disarmed transition logging (avoids 30 s reason spam)
|
||||
}
|
||||
|
||||
// New builds a fast-tick loop. out is the hub loop's out-of-band trigger channel (cap-1). A
|
||||
// non-positive interval falls back to DefaultInterval.
|
||||
func New(out chan<- struct{}, interval time.Duration, logger *slog.Logger, sources ...Source) *Loop {
|
||||
if interval <= 0 {
|
||||
interval = DefaultInterval
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Loop{sources: sources, out: out, interval: interval, logger: logger}
|
||||
}
|
||||
|
||||
// Run evaluates the sources every interval until ctx is cancelled. Stateless — nothing to recover.
|
||||
func (l *Loop) Run(ctx context.Context) error {
|
||||
l.logger.Info("fast-tick armed: "+l.interval.String()+" out-of-band cadence while desired-state is unapplied", "interval", l.interval)
|
||||
ticker := time.NewTicker(l.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
l.logger.Info("fast-tick: shutting down", "reason", ctx.Err())
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
l.step()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// step evaluates the sources once. If any is unconverged it pulses the channel (non-blocking: a full
|
||||
// channel means an out-of-band report is already pending, so the pulse coalesces harmlessly) and, on
|
||||
// the disarmed→armed edge, logs the reason. When all converge it logs the armed→disarmed edge once.
|
||||
// Returns whether this tick found the box unconverged (test hook). No per-tick logging when steady.
|
||||
func (l *Loop) step() bool {
|
||||
unconverged, reason := l.evaluate()
|
||||
if unconverged {
|
||||
select {
|
||||
case l.out <- struct{}{}:
|
||||
default: // an out-of-band report is already queued — coalesce, never block
|
||||
}
|
||||
if !l.armed {
|
||||
l.logger.Info("fast-tick: desired-state unapplied — pulsing out-of-band reports", "reason", reason, "cadence", l.interval)
|
||||
l.armed = true
|
||||
}
|
||||
} else if l.armed {
|
||||
l.logger.Info("fast-tick: desired-state converged — back to the normal cadence")
|
||||
l.armed = false
|
||||
}
|
||||
return unconverged
|
||||
}
|
||||
|
||||
// evaluate returns the first unconverged source's reason (order = priority for the log line).
|
||||
func (l *Loop) evaluate() (bool, string) {
|
||||
for _, s := range l.sources {
|
||||
if u, reason := s.Unconverged(); u {
|
||||
return true, reason
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
Reference in New Issue
Block a user