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
+105
View File
@@ -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, ""
}
+121
View File
@@ -0,0 +1,121 @@
package fasttick
import (
"io"
"log/slog"
"testing"
"time"
)
func quiet() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
// flagSource is a fake Source whose convergence the test flips at will.
type flagSource struct {
unconverged bool
reason string
}
func (f *flagSource) Unconverged() (bool, string) { return f.unconverged, f.reason }
// drain reports how many pulses are queued (channel is cap-1 in production; tests may use larger).
func drain(ch chan struct{}) int {
n := 0
for {
select {
case <-ch:
n++
default:
return n
}
}
}
// D1 — any source unconverged → one pulse per tick (drained between ticks).
func TestFastTick_UnconvergedPulses(t *testing.T) {
out := make(chan struct{}, 1)
src := &flagSource{unconverged: true, reason: "test drift"}
l := New(out, time.Hour, quiet(), src)
for i := 0; i < 3; i++ {
if !l.step() {
t.Fatalf("tick %d: step reported converged, want unconverged", i)
}
if got := drain(out); got != 1 {
t.Fatalf("tick %d: pulses = %d, want 1", i, got)
}
}
}
// D2 — ALL sources converged → zero pulses across N ticks (the emergent disarm).
func TestFastTick_ConvergedSilent(t *testing.T) {
out := make(chan struct{}, 4)
l := New(out, time.Hour, quiet(), &flagSource{unconverged: false}, &flagSource{unconverged: false})
for i := 0; i < 5; i++ {
if l.step() {
t.Fatalf("tick %d: step reported unconverged with all sources converged", i)
}
}
if got := drain(out); got != 0 {
t.Fatalf("pulses on a fully-converged box = %d, want 0", got)
}
}
// D3 (the ruled red-proof) — flip unconverged→converged mid-run → pulses STOP from the next tick.
func TestFastTick_ConvergenceDisarms(t *testing.T) {
out := make(chan struct{}, 8)
src := &flagSource{unconverged: true, reason: "drift"}
l := New(out, time.Hour, quiet(), src)
// Two unconverged ticks pulse.
l.step()
l.step()
if got := drain(out); got != 2 {
t.Fatalf("pre-convergence pulses = %d, want 2", got)
}
// Converge.
src.unconverged = false
// Every subsequent tick is silent — the cadence returns to normal.
for i := 0; i < 4; i++ {
if l.step() {
t.Fatalf("post-convergence tick %d still unconverged", i)
}
}
if got := drain(out); got != 0 {
t.Fatalf("pulses fired after convergence = %d, want 0 (disarm failed)", got)
}
}
// D4 — the out channel is already full (a poke just landed): the non-blocking send drops, no block,
// no goroutine leak, no queue growth.
func TestFastTick_ChannelFullDrops(t *testing.T) {
out := make(chan struct{}, 1)
out <- struct{}{} // pre-fill: an out-of-band report is already pending
l := New(out, time.Hour, quiet(), &flagSource{unconverged: true, reason: "drift"})
done := make(chan bool, 1)
go func() {
l.step() // must NOT block on the full channel
l.step()
done <- true
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("step blocked on a full channel (non-blocking send violated)")
}
if len(out) != 1 {
t.Fatalf("channel depth = %d, want 1 (coalesced, no queue growth)", len(out))
}
}
// Priority ordering: the first unconverged source supplies the reason.
func TestFastTick_FirstReasonWins(t *testing.T) {
out := make(chan struct{}, 1)
l := New(out, time.Hour, quiet(),
&flagSource{unconverged: false},
&flagSource{unconverged: true, reason: "second"},
&flagSource{unconverged: true, reason: "third"})
if u, r := l.evaluate(); !u || r != "second" {
t.Fatalf("evaluate = (%v, %q), want (true, second)", u, r)
}
}