// 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, "" }