@@ -815,6 +815,29 @@ function loadRunIntoState(num) {
815815 state . activeDbRunId = _runMeta ?. dbRunId != null ? Number ( _runMeta . dbRunId ) : null ;
816816 try { setActiveDbRunId ( state . activeDbRunId ) ; }
817817 catch ( e ) { console . error ( '[runs] setActiveDbRunId on load failed:' , e . message ) ; }
818+ // Route-isolation: a loaded run is a read-only historical snapshot. Reset the
819+ // run-mode context so a subsequent Retest (which clears loadedReadonly) can't
820+ // inherit a stale mode from the PRIOR live run — e.g. loading a P2P run after a
821+ // TEST RUN would otherwise leave state.testRun=true / runMode='test' and the
822+ // Retest would hijack into TEST_RUN_SKIP rows (the hijack CLAUDE.md warns of).
823+ // Derive the loaded run's mode from its SQLite row when known; default to
824+ // 'p2p' and NEVER leave 'test'.
825+ let _loadedMode = 'p2p' ;
826+ if ( state . activeDbRunId != null ) {
827+ try {
828+ const _runRow = getRun ( state . activeDbRunId ) ;
829+ if ( _runRow && _runRow . mode && _runRow . mode !== 'test' ) _loadedMode = _runRow . mode ;
830+ } catch ( e ) { console . error ( '[runs] mode lookup on load failed:' , e . message ) ; }
831+ }
832+ state . testRun = false ;
833+ state . runMode = _loadedMode ;
834+ state . runPlanId = null ;
835+ state . runSubscriptionId = null ;
836+ state . runGranter = null ;
837+ state . activeBatchId = 0 ;
838+ state . resumeHeadAddr = null ;
839+ state . currentNode = null ;
840+ state . continuousLoop = false ;
818841 return data ;
819842}
820843
@@ -900,6 +923,15 @@ function clearActiveRunView() {
900923 state . spentUdvpn = 0 ;
901924 state . estimatedTotalCost = '0 P2P' ;
902925 state . auditLogPath = null ;
926+ // Charts + transient run pointers also belong to the now-deleted run: without
927+ // clearing these the dashboard's "Last 10 baseline" / "Last 10 node speeds"
928+ // and currentNode keep rendering the DELETED run's data, and a stale
929+ // activeBatchId/resumeHeadAddr could mis-target a later resume.
930+ state . baselineHistory = [ ] ;
931+ state . nodeSpeedHistory = [ ] ;
932+ state . currentNode = null ;
933+ state . resumeHeadAddr = null ;
934+ state . activeBatchId = 0 ;
903935 logBuffer . length = 0 ;
904936 try { flushStateSnapshot ( ) ; } catch ( e ) { console . error ( '[deleteRun] snapshot flush failed:' , e . message ) ; }
905937}
@@ -2576,11 +2608,25 @@ app.post('/api/test-sub-plan', adminOnly, async (req, res) => {
25762608} ) ;
25772609
25782610app . post ( '/api/clear' , adminOnly , ( req , res ) => {
2611+ // Guard: clearing mid-run corrupts state — the pipeline keeps pushing rows
2612+ // into the same results array we'd be emptying. Refuse while a run is live.
2613+ if ( isPipelineBusy ( ) ) return res . status ( 409 ) . json ( { error : 'RUN_ACTIVE' , message : 'Stop the run before clearing.' } ) ;
25792614 getResults ( ) . length = 0 ;
25802615 state . testedNodes = state . failedNodes = state . skippedNodes = state . passed15 = state . passed10 = state . passedBaseline = 0 ;
25812616 state . retryCount = 0 ;
25822617 state . baselineHistory = [ ] ;
25832618 state . nodeSpeedHistory = [ ] ;
2619+ // /api/clear wipes the DISPLAYED rows but keeps the active-run identity
2620+ // (activeRunNumber / activeDbRunId / pipeline run dir stay attached — use
2621+ // clearActiveRunView()/deleteRun to drop the identity). The counters above
2622+ // leave spend/total/currentNode stale, so the header would still show the old
2623+ // Net Spend / Total. Reset those transient fields too for a consistent wipe.
2624+ state . spentUdvpn = 0 ;
2625+ state . estimatedTotalCost = '0 P2P' ;
2626+ state . totalNodes = 0 ;
2627+ state . currentNode = null ;
2628+ state . resumeHeadAddr = null ;
2629+ state . activeBatchId = 0 ;
25842630 saveResults ( ) ;
25852631 broadcastStateFresh ( ) ;
25862632 res . json ( { ok : true } ) ;
@@ -3141,10 +3187,15 @@ app.listen(PORT, LISTEN_HOST, async () => {
31413187 // ─── Periodic balance refresh (runs even when idle) ────────────────────────
31423188 if ( MNEMONIC ) {
31433189 setInterval ( async ( ) => {
3144- // Skip refresh while the pipeline is doing its OWN balance refresh
3145- // (actively testing, or parked in the insufficient-funds poll loop). This
3146- // is deliberately NOT isPipelineBusy() — during paused_internet/'paused'
3147- // the pipeline isn't touching balance, so the periodic refresh is fine.
3190+ // Whether this refresher may zero spentUdvpn. It must NOT while a run is
3191+ // in-progress OR resumable: the tester is an on-chain spend oracle, and a
3192+ // paused_internet/'paused' or 'stopped'-but-resumable run still has
3193+ // cumulative spend that a later Resume must report. Zeroing it here makes
3194+ // the resumed run under-report. Only when truly idle ('idle'/'done') is
3195+ // the live chain balance the sole truth and spentUdvpn safe to reset.
3196+ const spendLocked = isPipelineBusy ( ) || state . status === 'stopped' ;
3197+ // Skip entirely while the pipeline does its OWN balance refresh (actively
3198+ // testing, or parked in the insufficient-funds poll loop).
31483199 if ( state . status === 'running' || state . status === 'paused_balance' ) return ;
31493200 try {
31503201 const w = await DirectSecp256k1HdWallet . fromMnemonic ( MNEMONIC , { prefix : 'sent' } ) ;
@@ -3155,11 +3206,13 @@ app.listen(PORT, LISTEN_HOST, async () => {
31553206 tmpClient . disconnect ( ) ;
31563207 if ( fresh !== state . balanceUdvpn ) {
31573208 state . balanceUdvpn = fresh ;
3158- state . spentUdvpn = 0 ;
3209+ // Only reset the spend estimate when truly idle — never on a paused or
3210+ // stopped-but-resumable run (see spendLocked above).
3211+ if ( ! spendLocked ) state . spentUdvpn = 0 ;
31593212 state . balance = `${ ( fresh / 1_000_000 ) . toFixed ( 4 ) } P2P` ;
31603213 broadcast ( 'state' , { state } ) ;
31613214 }
3162- } catch { /* non-critical */ }
3215+ } catch ( e ) { console . error ( '[balance-refresh] periodic refresh failed:' , e . message ) ; }
31633216 } , 2 * 60_000 ) ; // Every 2 minutes
31643217 }
31653218} ) ;
0 commit comments