v0.115.0: version-aware Supports (agent header channel) + DSM-validated NFS guidance — MinAgent: 0.81.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-11 15:03:41 +02:00
parent ac6adaaa44
commit b6842a4bc3
8 changed files with 394 additions and 72 deletions
+58 -8
View File
@@ -6,6 +6,8 @@ import (
"net/http"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// Agent-capability probing (the publish-train backstop). A controller release that depends on
@@ -53,13 +55,12 @@ type SupportProber interface {
NetVerifyStatus(ctx context.Context) (NetVerifyStatus, error)
}
// featureProbes maps each coupled feature to its route probe.
// featureProbes maps each coupled feature to its route probe (the FALLBACK path for agents that
// predate the v0.82.0 version header).
//
// CONVENTION (publish-train rules doc, felhom.eu/documentation/runbooks/publish-train-rules.md):
// every future coupled feature adds a row here plus a Supports gate call at its entry point, and
// declares MinAgent in its CHANGELOG header. When the agent someday reports an explicit version in
// its envelope, Supports should prefer that version comparison over route probing — that
// enhancement is roadmap, not built yet.
// every future coupled feature adds a row here AND a featureMinAgent row, plus a Supports gate
// call at its entry point, and declares MinAgent in its CHANGELOG header.
var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error{
FeatureNetstorageVerify: func(ctx context.Context, p SupportProber) error {
_, err := p.NetVerifyStatus(ctx)
@@ -67,6 +68,20 @@ var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error
},
}
// featureMinAgent maps each coupled feature to the MINIMUM agent version that carries its coupled
// semantics (the CHANGELOG `MinAgent:` header value). Used by Supports when the agent's version is
// KNOWN (the v0.82.0 X-Felhom-Agent-Version channel) — a direct comparison, no probe traffic. A
// feature missing here (or an unparseable table value) falls back to the probe.
var featureMinAgent = map[Feature]string{
FeatureNetstorageVerify: "0.81.0",
}
// AgentVersionReporter is optionally implemented by a SupportProber (*Client is one): it reports
// the last strictly-validated agent version seen on its traffic ("" = unknown → probe fallback).
type AgentVersionReporter interface {
AgentVersion() string
}
// supportTTL bounds how long a probe verdict (either polarity) is trusted. An agent updated
// mid-window flips within this — no invalidation plumbing by design.
const supportTTL = 5 * time.Minute
@@ -85,14 +100,24 @@ type SupportCache struct {
entries map[Feature]supportEntry
}
// Supports reports whether the agent behind p provides feature f, answering from the cache inside
// the TTL window and probing otherwise. The probe runs OUTSIDE the lock — concurrent misses may
// double-probe (harmless: the probe is one cheap GET).
// Supports reports whether the agent behind p provides feature f.
//
// Order (v0.115.0): (1) the agent's VERSION is known (the v0.82.0 header channel, strictly
// validated at capture) AND the feature has a MinAgent row → pure semver comparison, NO probe
// traffic, no cache involvement (the cache stays probe-only); (2) otherwise — pre-0.82 agent, no
// traffic yet, or a table gap — the v0.114.0 probe path, byte-identical (cache inside the TTL
// window, probe on miss). The probe runs OUTSIDE the lock — concurrent misses may double-probe
// (harmless: the probe is one cheap GET).
func (sc *SupportCache) Supports(ctx context.Context, p SupportProber, f Feature) SupportState {
probe, ok := featureProbes[f]
if !ok {
return SupportUnknown // unregistered feature — never refuse on a table gap
}
if vr, hasVer := p.(AgentVersionReporter); hasVer {
if state, decided := supportsByVersion(vr.AgentVersion(), f); decided {
return state
}
}
sc.mu.Lock()
nowFn := sc.now
if nowFn == nil {
@@ -123,6 +148,31 @@ func (c *Client) Supports(ctx context.Context, f Feature) SupportState {
return c.features.Supports(ctx, c, f)
}
// supportsByVersion decides a feature by version comparison alone. decided=false (unknown/garbage
// version, missing MinAgent row, unparseable table value) sends the caller to the probe fallback —
// a bad version string must never be trusted in EITHER direction.
func supportsByVersion(agentVer string, f Feature) (state SupportState, decided bool) {
if agentVer == "" {
return SupportUnknown, false
}
minStr, ok := featureMinAgent[f]
if !ok {
return SupportUnknown, false
}
av, err := util.ParseVersion(agentVer)
if err != nil {
return SupportUnknown, false // capture validates the shape, but stay defensive
}
mv, err := util.ParseVersion(minStr)
if err != nil {
return SupportUnknown, false // a broken table row falls back to probing, never refuses
}
if av.Compare(mv) >= 0 {
return SupportYes, true
}
return SupportNo, true
}
// classifySupportErr maps a probe outcome to a SupportState. ONLY a typed HTTP 404 means
// "unsupported" — every other error (transport, timeout, 401, 5xx, envelope problems) is Unknown,
// so a merely-down agent is never reported as outdated. Typed errors only; never string-match.