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.
653 lines
16 KiB
Go
653 lines
16 KiB
Go
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)
|
|
}
|