@@ -11,6 +11,8 @@ import { lineageSvg, loopSvg } from './diagram.js';
1111import { explainerFor , SCAN_EXPLAINERS } from './explainers.js' ;
1212import { deriveFit , firstSentence , verdictCopyText } from './verdict.js' ;
1313import { pingRunner } from './runner.js' ;
14+ import { deepDiveLifecycleAction } from './deep-dive-lifecycle.js' ;
15+ import { lensActivitySignature } from './background-activity.js' ;
1416import { emptyLens , runOf } from './lens-runs.js' ;
1517import { spine , flow , ranked , matrix2x2 , optionMatrix } from './layouts.js' ;
1618import { guideFor } from './lens-guide.js' ;
@@ -98,15 +100,17 @@ const sessionKey = params.get('key');
98100
99101let keepalivePort = null ;
100102let keepaliveTimer = null ;
101- function startScanKeepalive ( ) {
103+ let keepaliveRefs = 0 ;
104+
105+ function openKeepalivePort ( ) {
102106 if ( ! sessionKey || keepalivePort ) return ;
103107 try {
104108 keepalivePort = chrome . runtime . connect ( { name : 'repolens-scan-keepalive' } ) ;
105109 const ping = ( ) => {
106110 try {
107111 keepalivePort ?. postMessage ( { type : 'PING' , sessionKey, at : Date . now ( ) } ) ;
108112 } catch {
109- /* the worker may be restarting; the next scan /reload reconnects */
113+ /* the worker may be restarting; the next tick /reload reconnects */
110114 }
111115 } ;
112116 ping ( ) ;
@@ -115,13 +119,17 @@ function startScanKeepalive() {
115119 keepalivePort = null ;
116120 if ( keepaliveTimer ) clearInterval ( keepaliveTimer ) ;
117121 keepaliveTimer = null ;
122+ // Work still in flight (refs > 0) means the worker cycled mid-run — reopen so the
123+ // pings keep resetting its idle timer. A deliberate release sets refs to 0 first,
124+ // so this no-ops on intentional teardown.
125+ if ( keepaliveRefs > 0 ) openKeepalivePort ( ) ;
118126 } ) ;
119127 } catch {
120128 /* keepalive is best-effort; polling still works without it */
121129 }
122130}
123131
124- function stopScanKeepalive ( ) {
132+ function closeKeepalivePort ( ) {
125133 if ( keepaliveTimer ) clearInterval ( keepaliveTimer ) ;
126134 keepaliveTimer = null ;
127135 try {
@@ -132,6 +140,21 @@ function stopScanKeepalive() {
132140 keepalivePort = null ;
133141}
134142
143+ // Reference-counted keepalive. Every long background flow (initial scan, Deep Dive, and
144+ // any on-demand lens) keeps the MV3 service worker warm for its own duration; the port only
145+ // closes once the last holder releases it. Without this, a worker suspended mid-flow leaves
146+ // the session entry frozen forever — the exact failure Deep Dive hit on the lineage stage.
147+ function acquireKeepalive ( ) {
148+ keepaliveRefs += 1 ;
149+ if ( keepaliveRefs === 1 ) openKeepalivePort ( ) ;
150+ }
151+
152+ function releaseKeepalive ( ) {
153+ if ( keepaliveRefs === 0 ) return ;
154+ keepaliveRefs -= 1 ;
155+ if ( keepaliveRefs === 0 ) closeKeepalivePort ( ) ;
156+ }
157+
135158const loading = document . getElementById ( 'loading-state' ) ;
136159const errorState = document . getElementById ( 'error-state' ) ;
137160const errorMsg = document . getElementById ( 'error-msg' ) ;
@@ -587,13 +610,13 @@ async function init() {
587610 errorState . style . display = 'flex' ;
588611 return ;
589612 }
590- startScanKeepalive ( ) ;
613+ acquireKeepalive ( ) ;
591614 let data ;
592615 try {
593616 data = await waitForData ( ) ;
594617 } catch ( err ) {
595618 loading . style . display = 'none' ;
596- stopScanKeepalive ( ) ;
619+ releaseKeepalive ( ) ;
597620 try {
598621 const stored = await chrome . storage . session . get ( sessionKey ) ;
599622 const stale = stored [ sessionKey ] ;
@@ -620,7 +643,7 @@ async function init() {
620643 return ;
621644 }
622645 loading . style . display = 'none' ;
623- stopScanKeepalive ( ) ;
646+ releaseKeepalive ( ) ;
624647
625648 if ( data . error ) {
626649 errorMsg . textContent = data . error ;
@@ -718,6 +741,7 @@ async function init() {
718741 . catch ( ( ) => { } ) ;
719742 renderThemeSwitcher ( ) ;
720743 initHeaderActions ( ) ;
744+ manageKeepalive ( data ) ; // re-warm the worker if a lens was already running when the tab loaded
721745 renderDeepDive ( data ) ;
722746 renderFrameworkLens ( data , SYSTEMS_CFG ) ;
723747 renderFrameworkLens ( data , IDEATE_CFG ) ;
@@ -1509,7 +1533,106 @@ function renderUnderstandCheck(host, questions, repoId) {
15091533 drawCard ( ) ;
15101534}
15111535
1536+ // ── Deep Dive keepalive + stall watchdog ──
1537+ // ── MV3 keepalive for ALL on-demand lenses ──
1538+ // Every lens that runs AI work after the initial scan (Deep Dive, Systems/Ideate/Prioritize,
1539+ // Synergies, Combinator, Versus, SKTPG, Docs, Maintenance, Fits-Stack, Ask) needs the worker
1540+ // kept warm or it can be suspended mid-run and freeze. Driven off the session entry's
1541+ // activity signature so one place covers them all; the hold is bounded so a flow that never
1542+ // settles can't pin the worker (and drain) indefinitely.
1543+ const LENS_STALL_MS = 300_000 ; // give up keeping warm after 5 min with no progress at all
1544+ let lensKeepaliveHeld = false ;
1545+ let lensSig = '' ;
1546+ let lensStallTimer = null ;
1547+
1548+ function releaseLensKeepalive ( ) {
1549+ if ( lensStallTimer ) {
1550+ clearTimeout ( lensStallTimer ) ;
1551+ lensStallTimer = null ;
1552+ }
1553+ if ( lensKeepaliveHeld ) {
1554+ releaseKeepalive ( ) ;
1555+ lensKeepaliveHeld = false ;
1556+ }
1557+ }
1558+
1559+ function manageKeepalive ( entry ) {
1560+ const sig = lensActivitySignature ( entry ) ;
1561+ if ( sig === lensSig ) return ; // background-work state unchanged → nothing to do
1562+ lensSig = sig ;
1563+ const inFlight = sig !== '' ;
1564+
1565+ if ( inFlight && ! lensKeepaliveHeld ) {
1566+ acquireKeepalive ( ) ;
1567+ lensKeepaliveHeld = true ;
1568+ } else if ( ! inFlight && lensKeepaliveHeld ) {
1569+ releaseKeepalive ( ) ;
1570+ lensKeepaliveHeld = false ;
1571+ }
1572+
1573+ // Progress (a signature change) re-arms the hold; prolonged silence releases it.
1574+ if ( lensStallTimer ) {
1575+ clearTimeout ( lensStallTimer ) ;
1576+ lensStallTimer = null ;
1577+ }
1578+ if ( inFlight ) lensStallTimer = setTimeout ( releaseLensKeepalive , LENS_STALL_MS ) ;
1579+ }
1580+
1581+ // ── Deep Dive stall watchdog ──
1582+ // The heaviest flow gets a tighter watchdog with a graceful recovery: if a stage makes no
1583+ // progress within DD_STALL_MS, persist an error so the tab shows "Deep Dive failed → Try
1584+ // again" (and the keepalive driver then releases) instead of an endless spinner. Keepalive
1585+ // itself is handled by manageKeepalive above; this only owns the watchdog + recovery.
1586+ const DD_STALL_MS = 180_000 ; // longest plausible single stage (runner ~90s, model + retries) ×2
1587+ let ddLastStatus = null ;
1588+ let ddWatchdog = null ;
1589+
1590+ function clearDdWatchdog ( ) {
1591+ if ( ddWatchdog ) {
1592+ clearTimeout ( ddWatchdog ) ;
1593+ ddWatchdog = null ;
1594+ }
1595+ }
1596+
1597+ async function onDeepDiveStalled ( ) {
1598+ ddWatchdog = null ;
1599+ ddLastStatus = 'error' ; // settle locally so stray re-renders don't re-arm the watchdog
1600+ const msg =
1601+ 'Deep Dive stopped responding — the background worker was likely suspended mid-run. ' +
1602+ 'Try again; if it repeats, reload RepoLens from chrome://extensions first.' ;
1603+ try {
1604+ // Persist the error so renderDeepDive shows its normal failed/Try-again state, the result
1605+ // survives a reload, and manageKeepalive sees the run settle and releases the worker.
1606+ const cur = ( await chrome . storage . session . get ( sessionKey ) ) [ sessionKey ] || { } ;
1607+ await chrome . storage . session . set ( {
1608+ [ sessionKey ] : { ...cur , deepDive : { ...( cur . deepDive || { } ) , status : 'error' , error : msg } } ,
1609+ } ) ;
1610+ } catch {
1611+ // Storage unavailable — fall back to a local render so the user is never stuck.
1612+ const host = document . getElementById ( 't10' ) ;
1613+ if ( host ) {
1614+ host . innerHTML = `<div class="dd-cta"><h3>Deep Dive stopped responding</h3><p>${ esc ( msg ) } </p><button class="dd-run" id="dd-run">Try again</button></div>` ;
1615+ document . getElementById ( 'dd-run' ) ?. addEventListener ( 'click' , ( ) => {
1616+ if ( lastData ) startDeepDive ( lastData ) ;
1617+ } ) ;
1618+ }
1619+ }
1620+ }
1621+
1622+ function manageDeepDiveWatchdog ( d ) {
1623+ const status = d ?. deepDive ?. status ?? null ;
1624+ const action = deepDiveLifecycleAction ( ddLastStatus , status ) ;
1625+ if ( ! action . changed ) return ;
1626+ ddLastStatus = status ;
1627+ if ( action . clearWatchdog ) clearDdWatchdog ( ) ;
1628+ if ( action . resetWatchdog ) {
1629+ clearDdWatchdog ( ) ;
1630+ ddWatchdog = setTimeout ( onDeepDiveStalled , DD_STALL_MS ) ;
1631+ }
1632+ }
1633+
15121634function renderDeepDive ( d ) {
1635+ manageDeepDiveWatchdog ( d ) ;
15131636 const host = document . getElementById ( 't10' ) ;
15141637 if ( ! host ) return ;
15151638 const dd = d . deepDive ;
@@ -2580,6 +2703,7 @@ chrome.storage.onChanged.addListener((changes, area) => {
25802703 const nv = changes [ sessionKey ] . newValue ;
25812704 const ov = changes [ sessionKey ] . oldValue || { } ;
25822705 lastData = nv ;
2706+ manageKeepalive ( nv ) ; // keep the MV3 worker warm while any lens runs in the background
25832707 renderDeepDive ( nv ) ;
25842708 renderFrameworkLens ( nv , SYSTEMS_CFG ) ;
25852709 renderFrameworkLens ( nv , IDEATE_CFG ) ;
0 commit comments