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"} )