Files
llmgw/internal/agent/provision.go
T
Ilker ManapandClaude Opus 5.5 62657f4c2a
Release / build (push) Successful in 4m11s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 4m57s
Release v0.10.4: CPU threads, tracing, and the rest of the review
CPU replicas start on half the machine's CPUs, not all of them: two busy
llama.cpp servers each spinning on all 8 vCPUs fell to 0.8 tok/s apiece,
where at 4 threads they keep 5.5. llamacpp.threads sets it per node.

Inference requests can be traced over OTLP/HTTP (tracing.endpoint), with
admission, schedule and upstream spans and traceparent sent to the engine.

Security: onboarding no longer puts the cluster root in the system trust
store and removes it where it was; the mirror's mutual-TLS API can be
limited to named certificates (--api-client); init.log no longer keeps the
administrator's password; a warning when auth is off on a public listen;
the recorded actor is sanitised and sudo-aware; Ray's dashboard is off, and
the remaining engine gaps are documented.

reconciler.go and agent.go are split by topic, the status mapping of each
failure is tested, and the plan's checklist is brought up to date.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 04:00:35 +02:00

198 lines
5.6 KiB
Go

package agent
import (
"context"
"fmt"
"log/slog"
"maps"
"slices"
"strings"
"time"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
)
// engines is what this node can run: what the agent installed and saw run,
// and what the configuration names and answers. Nothing merely found on the
// machine — a llama-server on PATH is somebody's, not necessarily one that
// works, and placing on it is how a replica fails on a node nobody watches.
func (a *Agent) engines(ctx context.Context) map[string]string {
a.engineOnce.Do(func() {
vers := a.reg.ProbeConfigured(ctx)
a.mu.Lock()
a.configuredVers, a.lastEngineProbe = vers, time.Now()
a.mu.Unlock()
if len(vers) > 0 {
a.log.Info("engines named in the configuration",
"engines", engines.ProbedNames(vers))
}
})
a.mu.Lock()
defer a.mu.Unlock()
vers := a.engineReportLocked()
// Whatever registration sends is what the control plane now knows, so a
// heartbeat repeats it only once it differs.
a.enginesSent = enginesFingerprint(vers)
return vers
}
// engineReportLocked merges the two sources. Installed wins: it is the one
// that was seen to run. Called with mu held.
func (a *Agent) engineReportLocked() map[string]string {
out := maps.Clone(a.configuredVers)
if out == nil {
out = map[string]string{}
}
maps.Copy(out, a.installedVers)
return out
}
// provisionEvery is how often installed engines are checked again. A library
// removed by an unrelated upgrade is found here rather than by the next
// replica to start.
const provisionEvery = 10 * time.Minute
// setProvision takes the control plane's list of wanted engines.
func (a *Agent) setProvision(p *llmgwv1.Provision) {
wants := []provision.Want{}
for _, e := range p.GetEngines() {
wants = append(wants, provision.Want{Engine: e.GetEngine(), Version: e.GetVersion()})
}
a.mu.Lock()
a.provWants = wants
a.mu.Unlock()
select {
case a.provKick <- struct{}{}:
default:
}
}
// provisionLoop installs and re-checks the wanted engines, one pass at a time.
func (a *Agent) provisionLoop(ctx context.Context) {
// After a failure the next pass comes sooner, and backs off: a lock held
// by a new machine's own updates clears in a minute or two, while ten
// minutes' wait left a node idle for no reason.
next := provisionEvery
retry := time.Minute
for {
select {
case <-ctx.Done():
return
case <-a.provKick:
case <-time.After(next):
}
a.mu.Lock()
wants := slices.Clone(a.provWants)
managed := a.provWants != nil
a.mu.Unlock()
if !managed {
continue
}
statuses := a.prov.Converge(ctx, wants)
next = provisionEvery
for _, st := range statuses {
if st.State == provision.Failed {
next, retry = retry, min(retry*2, provisionEvery)
break
}
}
if next == provisionEvery {
retry = time.Minute
}
installed := map[string]string{}
keep := map[string]bool{}
for _, st := range statuses {
keep[st.Engine] = true
if st.State == provision.Ready {
a.reg.UseInstalled(st.Engine, st.Binary)
installed[st.Engine] = st.Version
}
}
a.mu.Lock()
if enginesFingerprint(installed) != enginesFingerprint(a.installedVers) {
a.log.Info("engines installed and running", "engines", engines.ProbedNames(installed))
}
a.installedVers = installed
// An engine no longer asked for is no longer reported on.
for e := range a.provStatus {
if !keep[e] {
delete(a.provStatus, e)
}
}
a.mu.Unlock()
}
}
// provisionStatus records a change in one engine's installation.
func (a *Agent) provisionStatus(st provision.Status) {
a.mu.Lock()
prev, had := a.provStatus[st.Engine]
a.provStatus[st.Engine] = st
a.mu.Unlock()
if !had || prev.State != st.State || prev.Detail != st.Detail {
lvl := slog.LevelInfo
if st.State == provision.Failed || st.State == provision.Unsupported {
lvl = slog.LevelWarn
}
a.log.Log(context.Background(), lvl, "engine installation",
"engine", st.Engine, "version", st.Version, "variant", st.Variant,
"state", st.State, "detail", st.Detail)
}
}
func provisionFingerprint(m map[string]provision.Status) string {
var b strings.Builder
for _, k := range slices.Sorted(maps.Keys(m)) {
st := m[k]
fmt.Fprintf(&b, "%s|%s|%s|%s|%s\n", st.Engine, st.Version, st.Variant, st.State, st.Detail)
}
return b.String()
}
// engineReprobe is how often the installed engines are looked for again.
//
// Minutes, not seconds: an engine is installed by somebody at a keyboard, so a
// minute's delay in noticing is invisible, while SGLang's probe starts a
// Python interpreter that imports it.
const engineReprobe = time.Minute
// reprobeEngines looks for installed engines again when it is time, off the
// heartbeat goroutine so a slow probe never delays a heartbeat.
func (a *Agent) reprobeEngines() {
a.mu.Lock()
due := !a.probingEngines && !a.lastEngineProbe.IsZero() &&
time.Since(a.lastEngineProbe) >= engineReprobe
if due {
a.probingEngines = true
}
a.mu.Unlock()
if !due {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
vers := a.reg.ProbeConfigured(ctx)
a.mu.Lock()
changed := enginesFingerprint(vers) != enginesFingerprint(a.configuredVers)
a.configuredVers, a.lastEngineProbe, a.probingEngines = vers, time.Now(), false
a.mu.Unlock()
if changed {
a.log.Info("engines named in the configuration changed",
"engines", engines.ProbedNames(vers))
}
}()
}
func enginesFingerprint(vers map[string]string) string {
var b strings.Builder
for _, k := range slices.Sorted(maps.Keys(vers)) {
fmt.Fprintf(&b, "%s=%s\n", k, vers[k])
}
return b.String()
}