bgloop

package
v0.44.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 18, 2026 License: Apache-2.0 Imports: 8 Imported by: 0

Documentation

Overview

Package bgloop is fak's IN-KERNEL BACKGROUND-LOOP RUNTIME — the supervisor that keeps recurring work progressing while the kernel (`fak serve`) is up, and makes each loop observable.

Tier: foundation (1) — see internal/architest. It is stdlib-only and imports nothing internal, so it sits at the bottom of the layering DAG and any higher layer (the gateway, a demo command) may construct it. An upward import would fail the architest gate.

The gap it closes

fak frames itself as "loops all the way down" (docs/explainers/ engineering-is-building-loops.md): the tool-call, turn, session, fleet, and RSI loops. But every one of those is driven from OUTSIDE the running kernel — an OS scheduled task on a 10/15/30-minute cadence (the dispatch fleet), a one-shot `cmd/rsiloop` invocation, or an agent harness re-invoking itself. When `fak serve` is the process that is actually up, there was no first-class notion of a loop the KERNEL itself owns and keeps ticking. bgloop is that notion: a registered Loop runs in its own supervised goroutine on the serve lifecycle context, restarts on a panic or error with capped exponential backoff (a misbehaving loop never takes the kernel down), and exposes a point-in-time Snapshot of its progress.

How it complements loopmgr (the ledger) and looprecover (the worklist)

internal/loopmgr is the durable, hash-chained JSONL LEDGER of loop events, and its own doc is blunt that it "does not schedule, spawn, notify, or authorize anything by itself; those stay in the callers that produce events." bgloop is exactly that missing caller for in-kernel loops: it RUNS them. The two compose without coupling — bgloop stays dependency-free and exposes two seams a host wires to loopmgr at a higher tier:

  • WithObserver(func(Status)) is the PUSH seam: a host can fold each tick into the loopmgr ledger (an armed/heartbeat/end event) so an in-kernel loop shows up in `fak loop status` next to the externally-scheduled ones. Metrics, by contrast, use the PULL model — the gateway reads Snapshot() at /metrics scrape time — so no observer is needed just to expose Prometheus state.
  • WithAdmit(func(name) (ok, reason)) is the BACKPRESSURE seam: a host can gate each fire through loopmgr.Governor.Admit, giving an operator the existing pause/disable/cadence-floor knobs over a loop the kernel runs.

Pure, supervised, observable

The runtime is stdlib-only and deterministic to test: production code reads the real clock, and the time-dependent witnesses drive it under testing/synctest's virtual time. The three invariants it ships:

  • PROGRESS: an interval Loop keeps ticking while the kernel is up (Status.Ticks climbs, NextTickAt advances).
  • CONTAINMENT: a Tick that panics or errors is recovered and counted (Panics / Errors / Restarts), the loop backs off and resumes, and neither the supervisor nor any sibling loop is affected.
  • CLEAN SHUTDOWN: on context cancel (or Shutdown), every loop reaches Stopped and its goroutine is joined within the deadline; a Tick that ignores cancellation makes Shutdown return a timeout error rather than hang silently.

See AGENTS.md and internal/architest for the layering contract; the worked example is the `fak bgloop` verb (offline demo + live-server status) and the gateway wiring that registers the kernel's built-in heartbeat loop.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Loop

type Loop struct {
	// Name uniquely identifies the loop. It is the label in /metrics and the JSON
	// snapshot, so keep it stable and metric-safe (lowercase, no spaces). Required.
	Name string

	// Interval is the wait between the END of one tick and the START of the next.
	// A value <= 0 means CONTINUOUS: the next tick begins as soon as the last
	// returns, so such a Tick must pace itself (block until there is work) or it
	// will spin. Most kernel loops want a positive interval.
	Interval time.Duration

	// Tick is the work performed each iteration. It MUST honor ctx cancellation
	// (return promptly when ctx is Done) so the kernel can shut down cleanly. A
	// returned error is recorded and triggers backoff; a panic is recovered,
	// recorded, and also triggers backoff — neither ever crashes the kernel.
	// Required.
	Tick func(ctx context.Context) error
}

Loop is one unit of recurring background work the kernel supervises while it is up. It is pure configuration; the live runtime state lives in the supervisor's private loopState and is read out as a Status.

type Option

type Option func(*Supervisor)

Option configures a Supervisor at construction.

func WithAdmit

func WithAdmit(fn func(name string) (ok bool, reason string)) Option

WithAdmit installs an admission gate consulted before every fire. Returning false holds the fire (StatePaused) with the given reason and re-checks on the next interval — the BACKPRESSURE seam a host wires to loopmgr.Governor.Admit so an operator can pause, disable, or rate-floor a loop the kernel runs.

func WithBackoff

func WithBackoff(min, max time.Duration) Option

WithBackoff sets the failure backoff bounds (first retry waits min, doubling up to max). Non-positive or inverted bounds are ignored in favor of the defaults.

func WithClock added in v0.38.0

func WithClock(now func() time.Time) Option

WithClock installs the injectable clock seam — the `now func() time.Time` the dormancy clock and the rehydrate leaves also consume (epic #1178, #1192). Every instant the supervisor records — a loop's StartedAt, each tick's LastTickAt/duration, and the backoff / next-idle NextTickAt — is read through it, so a deterministic dormancy harness can fast-forward a loop's observable clock hours -> months with no real wait and no wall-clock read. Defaults to time.Now; a nil argument is ignored (the default stays in force) so production callers never lose their clock.

func WithObserver

func WithObserver(fn func(Status)) Option

WithObserver installs a callback invoked with a loop's Status after each completed tick (and each refused fire). It is the PUSH seam a host uses to fold in-kernel loop activity into an external surface such as the loopmgr ledger. It must not block; it runs on the loop's own goroutine. Metrics do not need it — read Snapshot at scrape time instead.

type State

type State string

State is the observable lifecycle phase of a supervised loop.

const (
	// StateIdle: the loop is waiting out its interval before the next tick.
	StateIdle State = "idle"
	// StateRunning: the loop is inside Tick right now.
	StateRunning State = "running"
	// StateBackoff: the last tick failed (error or panic); the loop is waiting out
	// the exponential backoff before retrying.
	StateBackoff State = "backoff"
	// StatePaused: the admit gate refused this fire (operator backpressure); the
	// loop is holding and will re-check on the next interval.
	StatePaused State = "paused"
	// StateStopped: the supervisor shut down or the lifecycle context was cancelled;
	// the loop's goroutine has exited.
	StateStopped State = "stopped"
)

type Status

type Status struct {
	Name       string    `json:"name"`
	State      State     `json:"state"`
	Interval   string    `json:"interval"`             // human-readable (e.g. "30s"); "continuous" when <= 0
	StartedAt  time.Time `json:"started_at,omitempty"` // when the loop's goroutine began
	Ticks      uint64    `json:"ticks"`                // ticks that returned nil
	Errors     uint64    `json:"errors"`               // ticks that returned a non-nil error
	Panics     uint64    `json:"panics"`               // ticks that panicked (recovered)
	Restarts   uint64    `json:"restarts"`             // backoff cycles entered (errors + panics)
	Pauses     uint64    `json:"pauses"`               // fires the admit gate refused
	LastTickAt time.Time `json:"last_tick_at,omitempty"`
	LastErrAt  time.Time `json:"last_err_at,omitempty"`
	LastErr    string    `json:"last_err,omitempty"`
	LastDurMS  float64   `json:"last_dur_ms,omitempty"` // wall time of the last completed tick
	NextTickAt time.Time `json:"next_tick_at,omitempty"`
}

Status is a point-in-time snapshot of one loop's progress — the observability surface a /metrics scrape, a /v1/fak/loops reader, or `fak bgloop status` renders. The JSON tags are the stable export schema; new fields are additive (omitempty).

type Supervisor

type Supervisor struct {
	// contains filtered or unexported fields
}

Supervisor runs a set of Loops on the kernel's lifecycle. Construct it with New, Register every loop BEFORE Start, then Start(ctx) with the serve lifecycle context. It spawns one goroutine per loop, supervises panics and errors with backoff, and exposes the live progress as a Snapshot. Shutdown cancels every loop and joins the goroutines within a deadline. All methods are safe for concurrent use.

func New

func New(opts ...Option) *Supervisor

New returns an empty Supervisor with default backoff bounds.

func (*Supervisor) Get

func (s *Supervisor) Get(name string) (Status, bool)

Get returns one loop's Status by name.

func (*Supervisor) Len

func (s *Supervisor) Len() int

Len is the number of registered loops.

func (*Supervisor) Register

func (s *Supervisor) Register(l Loop) error

Register adds a loop. It must be called before Start; a name must be non-empty and unique, and Tick must be non-nil. Returns an error otherwise (the loop is rejected, the supervisor is unchanged).

func (*Supervisor) Shutdown

func (s *Supervisor) Shutdown(ctx context.Context) error

Shutdown cancels every loop and waits for the goroutines to exit, up to ctx's deadline. It returns nil once all loops are joined, or a timeout error naming the loops still running if a Tick ignored cancellation past the deadline. Safe to call before Start (a no-op) and more than once.

func (*Supervisor) Snapshot

func (s *Supervisor) Snapshot() []Status

Snapshot returns the live Status of every loop, sorted by name. Safe to call any time (before Start it reports the registered loops as idle with zero counters).

func (*Supervisor) Start

func (s *Supervisor) Start(ctx context.Context)

Start launches every registered loop on a context derived from ctx, then returns immediately (it does not block). It is idempotent — a second call is a no-op. After Start, Register is refused. Cancelling ctx, or calling Shutdown, stops every loop.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL