Tie keepalives to successful chat inference

1385bdd1ce3c8b3af2691f342db02a8735e56997

permissionBRICK <40219477+permissionBRICK@users.noreply.github.com>

6 files changed, +259 -57Showing whitespace changes
public/script.js+32 -2
@@ -5264,6 +5264,30 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
52645264 return Promise.resolve();
52655265 }
52665266
5267+ let activeProviderRequest = null;
5268+
5269+ async function beginProviderRequest() {
5270+ activeProviderRequest = {
5271+ id: uuidv4(),
5272+ type,
5273+ startedAt: Date.now(),
5274+ };
5275+ await eventSource.emit(event_types.GENERATION_REQUEST_STARTED, activeProviderRequest);
5276+ }
5277+
5278+ async function finishProviderRequest(success) {
5279+ if (!activeProviderRequest) {
5280+ return;
5281+ }
5282+ const request = activeProviderRequest;
5283+ activeProviderRequest = null;
5284+ await eventSource.emit(event_types.GENERATION_REQUEST_FINISHED, {
5285+ ...request,
5286+ success: !!success,
5287+ finishedAt: Date.now(),
5288+ });
5289+ }
5290+
52675291 /**
52685292 * Saves itemized prompt bits and calls streaming or non-streaming generation API.
52695293 * @returns {Promise<void|*|Awaited<*>|String|{fromStream}|string|undefined|Object>}
@@ -5333,6 +5357,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
53335357 streamingProcessor.firstMessageText = '';
53345358 }
53355359
5360+ await beginProviderRequest();
53365361 streamingProcessor.generator = await sendStreamingRequest(type, generate_data, { jsonSchema });
53375362
53385363 hideSwipeButtons();
@@ -5350,6 +5375,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
53505375
53515376 const isStreamFinished = streamingProcessor && !streamingProcessor.isStopped && streamingProcessor.isFinished;
53525377 const isStreamWithToolCalls = streamingProcessor && Array.isArray(streamingProcessor.toolCalls) && streamingProcessor.toolCalls.length;
5378+ await finishProviderRequest(isStreamFinished);
53535379 if (canPerformToolCalls && isStreamFinished && isStreamWithToolCalls) {
53545380 const lastMessage = chat[chat.length - 1];
53555381 const hasToolCalls = ToolManager.hasToolCalls(streamingProcessor.toolCalls);
@@ -5389,7 +5415,10 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
53895415 });
53905416 }
53915417 } else {
5392- return await sendGenerationRequest(type, generate_data, { jsonSchema });
5418+ await beginProviderRequest();
5419+ const data = await sendGenerationRequest(type, generate_data, { jsonSchema });
5420+ await finishProviderRequest(!data?.error);
5421+ return data;
53935422 }
53945423 }
53955424
@@ -5530,12 +5559,13 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
55305559 * @param {Error|object} exception Error or response JSON
55315560 * @throws {Error|object} Re-throws the exception
55325561 */
55335562 async function onError(exception) {
55345563 // if the response JSON was thrown (novel|textgenerationwebui|kobold), show the error message
55355564 if (typeof exception?.error?.message === 'string') {
55365565 toastr.error(exception.error.message, t`Text generation error`, { timeOut: 10000, extendedTimeOut: 20000 });
55375566 }
55385567
5568+ await finishProviderRequest(false);
55395569 unblockGeneration(type);
55405570 console.log(exception);
55415571 streamingProcessor = null;
public/scripts/events.js+2 -0
@@ -23,6 +23,8 @@ export const event_types = {
2323 GENERATION_STARTED: 'generation_started',
2424 GENERATION_STOPPED: 'generation_stopped',
2525 GENERATION_ENDED: 'generation_ended',
26+ GENERATION_REQUEST_STARTED: 'generation_request_started',
27+ GENERATION_REQUEST_FINISHED: 'generation_request_finished',
2628 SD_PROMPT_PROCESSING: 'sd_prompt_processing',
2729 EXTENSIONS_FIRST_LOAD: 'extensions_first_load',
2830 EXTENSION_SETTINGS_LOADED: 'extension_settings_loaded',
public/scripts/extensions/keepalive/index.js+89 -41
@@ -27,6 +27,7 @@ const KEEPALIVE_RESPONSE_LENGTH = 1;
2727const HEARTBEAT_INTERVAL_MS = 20000;
2828const RETRY_INTERVAL_MS = 5000;
2929const SETTINGS_REFRESH_DELAY_MS = 300;
30+const MAIN_CHAT_GENERATION_TYPES = new Set(['normal', 'regenerate', 'swipe', 'continue']);
3031
3132// Extension-prompt key used to inject the keepalive message as a USER message at depth 0.
3233const KEEPALIVE_INJECT_ID = 'keepalive_ping';
@@ -54,15 +55,16 @@ let generationRefreshTimer = null;
5455let retryTimer = null;
5556let preparingJob = false;
5657let lifecycleVersion = 0;
57-let foregroundGenerationActive = false;
5858let temporaryConnectionDepth = 0;
5959let refreshAfterTemporaryConnection = false;
60+const activeMainRequests = new Map();
61+let latestSuccessfulRequestStartedAt = 0;
6062// Whether keepalive is "armed" for the CURRENT chat. It stays dormant until the user sends a new
6163// message or starts another foreground generation in the open chat. Merely loading a chat does not
6264// prove that its prompt is cached server-side. Reset on page reload and whenever the chat changes.
6365let armed = false;
6466// Per-profile "time sinceof lastthe message",last keyedverified bysuccessful connectionmain-chat profileinference idrequest, (orkeyed NONE_KEY).by
65-// In-memory only: on reload every profile is treated as freshly active.
67+// connection profile id (or NONE_KEY). In-memory only; a reload starts disarmed.
6668const lastActivity = {};
6769// Last successful backend keepalive, keyed by connection profile. The backend returns this after a
6870// sleeping tab wakes so we can distinguish a still-warm prompt from an expired one.
@@ -147,7 +149,7 @@ async function stopBackendJob() {
147149 }
148150}
149151
150152async function sendBackendHeartbeat(activity = false) {
151153 if (temporaryConnectionDepth > 0 || !armed || !isKeepaliveEnabledForActive()) {
152154 return;
153155 }
@@ -165,7 +167,7 @@ async function sendBackendHeartbeat(activity = false) {
165167 const version = lifecycleVersion;
166168 const profileKey = getActiveProfileKey();
167169 try {
168170 const response = await postKeepaliveBackend('heartbeat', { activity });
169171 const state = await response.json().catch(() => ({}));
170172 if (version !== lifecycleVersion) {
171173 return;
@@ -184,20 +186,14 @@ async function sendBackendHeartbeat(activity = false) {
184186 }
185187}
186188
187-function markActivity() {
188- if (preparingJob || temporaryConnectionDepth > 0) {
189- return;
190- }
191- lastActivity[getActiveProfileKey()] = Date.now();
192- void sendBackendHeartbeat(true);
193-}
194-
195189/**
196190 * A new user message armsmay keepalivelead beforeto itsa foregroundmain-chat generationrequest, beginsbut does not prove the provider accepted it.
191+ * Arm the feature and refresh only the replay payload; the deadline remains unchanged until the
192+ * request-success event arrives.
197193 */
198194function onMessageSent() {
199195 armed = true;
200196 markActivityqueueJobRefresh();
201197}
202198
203199/**
@@ -206,6 +202,8 @@ function onMessageSent() {
206202 */
207203function onChatChanged() {
208204 armed = false;
205+ activeMainRequests.clear();
206+ latestSuccessfulRequestStartedAt = 0;
209207 void stopBackendJob();
210208}
211209
@@ -308,11 +306,15 @@ async function registerBackendJob() {
308306 return;
309307 }
310308 const profileKey = getActiveProfileKey();
309+ if (!getLastWarmTime(profileKey)) {
310+ return;
311+ }
311312 if (isWarmPromptExpired(profileKey)) {
312313 disarmExpiredJob();
313314 return;
314315 }
315316 const version = lifecycleVersion;
317+ const hadRegisteredJob = jobRegistered;
316318 try {
317319 if (isGenerating()) {
318320 throw new Error('Waiting for the active generation to finish');
@@ -343,7 +345,10 @@ async function registerBackendJob() {
343345 clearTimeout(retryTimer);
344346 jobRegistered = true;
345347 } catch (error) {
346- jobRegistered = false;
348+ // A failed payload refresh does not mean the existing backend job disappeared. Keeping
349+ // this state is especially important while a foreground request is starting, because its
350+ // lifecycle handler still needs to pause that existing job before provider dispatch.
351+ jobRegistered = hadRegisteredJob;
347352 console.debug('[Keepalive] Could not register backend job:', error);
348353 clearTimeout(retryTimer);
349354 retryTimer = setTimeout(queueJobRefresh, RETRY_INTERVAL_MS);
@@ -506,36 +511,81 @@ function setupListeners() {
506511 });
507512}
508513
509514async function onGenerationStartedpauseBackendJob(type, _options, dryRun) {
510- // Quiet/background generations (image prompts, summaries, helpers, etc.) use a different model
515+ if (!jobRegistered) {
511- // or context and do not refresh the foreground text-chat cache.
516+ return;
512- if (dryRun || type === 'quiet') {
517+ }
518+ try {
519+ const response = await postKeepaliveBackend('pause', {});
520+ if (response.status === 404) {
521+ jobRegistered = false;
522+ }
523+ } catch (error) {
524+ console.debug('[Keepalive] Could not pause backend job:', error);
525+ }
526+}
527+
528+async function resumeBackendJob(activityStartedAt = null) {
529+ if (!jobRegistered) {
530+ return;
531+ }
532+ const elapsedMs = Number.isFinite(activityStartedAt)
533+ ? Math.max(0, Date.now() - activityStartedAt)
534+ : null;
535+ const profileKey = getActiveProfileKey();
536+ try {
537+ const response = await postKeepaliveBackend('resume', {
538+ activityElapsedMs: elapsedMs,
539+ });
540+ const state = await response.json().catch(() => ({}));
541+ updateLastKeepalive(profileKey, state.lastRun);
542+ if (response.status === 404) {
543+ jobRegistered = false;
544+ }
545+ } catch (error) {
546+ console.debug('[Keepalive] Could not resume backend job:', error);
547+ }
548+}
549+
550+async function onGenerationRequestStarted(request) {
551+ if (temporaryConnectionDepth > 0 || !request?.id || !MAIN_CHAT_GENERATION_TYPES.has(request.type)) {
513552 return;
514553 }
515- // Regenerates, swipes, and continues send the current prompt to the provider without emitting
516- // MESSAGE_SENT. They are just as valid proof of a warm prompt as a newly typed message.
517- foregroundGenerationActive = true;
518554 armed = true;
519- markActivity();
555+ activeMainRequests.set(request.id, {
556+ profileKey: getActiveProfileKey(),
557+ startedAt: Number(request.startedAt) || Date.now(),
558+ });
559+ if (activeMainRequests.size === 1) {
560+ await pauseBackendJob();
561+ }
520562}
521563
522564async function onGenerationEndedonGenerationRequestFinished(request) {
523- if (!foregroundGenerationActive) {
565+ const activeRequest = activeMainRequests.get(request?.id);
566+ if (!activeRequest) {
524567 return;
525568 }
526- foregroundGenerationActive = false;
569+ activeMainRequests.delete(request.id);
527- markActivity();
570+
528- scheduleGenerationJobRefresh();
571+ if (request.success) {
572+ const startedAt = activeRequest.startedAt;
573+ lastActivity[activeRequest.profileKey] = Math.max(lastActivity[activeRequest.profileKey] || 0, startedAt);
574+ latestSuccessfulRequestStartedAt = Math.max(latestSuccessfulRequestStartedAt, startedAt);
575+ armed = true;
529576 }
530577
531-function onMessageReceived(_messageId, type) {
578+ if (activeMainRequests.size > 0) {
532- // Image-generation extensions add their results as chat messages. Those messages neither used
533- // nor refreshed the foreground text cache, so they must not move its keepalive deadline.
534- if (type === 'extension') {
535579 return;
536580 }
537- foregroundGenerationActive = false;
581+
538- markActivity();
582+ const successfulStartedAt = latestSuccessfulRequestStartedAt || null;
583+ latestSuccessfulRequestStartedAt = 0;
584+ await resumeBackendJob(successfulStartedAt);
585+
586+ // A failed request may still have changed local chat content, so refresh the replay payload,
587+ // but registerBackendJob() will retain the old deadline. A successful request supplies the
588+ // verified activity timestamp used for the new deadline.
539589 scheduleGenerationJobRefresh();
540590}
541591
@@ -580,17 +630,15 @@ async function init() {
580630 setupListeners();
581631 setupHeartbeatLoop();
582632
583633 // A new user message arms keepalive, beforebut generationonly starts.the Foregroundexact generationsprovider-request thatlifecycle dobelow is allowed
584634 // not sendto amove newits messagedeadline. (regenerateRegenerate/swipe/continue) arerequests armedarm byit onGenerationStartedwhen insteadtheir request starts.
585635 eventSource.on(event_types.MESSAGE_SENT, onMessageSent);
586636
587637 // Only actual text-provider generations reset the cache deadline. Local chat edits still
588638 // refresh the stored request so the server replays the latest prompt without pretending that
589639 // those edits refreshed the provider cache.
590640 eventSource.on(event_types.MESSAGE_RECEIVEDGENERATION_REQUEST_STARTED, onMessageReceivedonGenerationRequestStarted);
591641 eventSource.on(event_types.GENERATION_STARTEDGENERATION_REQUEST_FINISHED, onGenerationStartedonGenerationRequestFinished);
592- eventSource.on(event_types.GENERATION_ENDED, onGenerationEnded);
593- eventSource.on(event_types.GENERATION_STOPPED, () => { foregroundGenerationActive = false; });
594642 eventSource.on(event_types.MESSAGE_EDITED, onChatContentChanged);
595643 eventSource.on(event_types.MESSAGE_DELETED, onChatContentChanged);
596644 eventSource.on(event_types.MESSAGE_UPDATED, onChatContentChanged);
public/scripts/extensions/keepalive/manifest.json+1 -1
@@ -6,7 +6,7 @@
66 "js": "index.js",
77 "css": "",
88 "author": "SillyTavern",
99 "version": "1.1.56",
1010 "homePage": "https://github.com/SillyTavern/SillyTavern",
1111 "hooks": {
1212 "activate": "init"
src/endpoints/keepalive.js+84 -10
@@ -7,6 +7,7 @@ export const router = express.Router();
77export const FRONTEND_LEASE_MS = 5 * 60 * 1000;
88const MIN_INTERVAL_MS = 60 * 1000;
99const MAX_INTERVAL_MS = 24 * 60 * 60 * 1000;
10+const FAILED_REQUEST_RETRY_MS = 5 * 1000;
1011const EXPIRED_JOB_RETENTION_MS = MAX_INTERVAL_MS;
1112const MAX_EXPIRED_JOBS = 1000;
1213const MAX_JOB_ID_LENGTH = 128;
@@ -55,24 +56,38 @@ export class KeepaliveScheduler {
5556 const now = this.#now();
5657 this.#pruneExpiredJobs(now);
5758 this.#expiredJobs.delete(key);
59+ const existing = this.#jobs.get(key);
5860 const intervalMs = Math.min(MAX_INTERVAL_MS, Math.max(MIN_INTERVAL_MS, Number(job.intervalMs) || MIN_INTERVAL_MS));
5961 const requestedDelay = Number(job.delayMs);
6062 const delayMs = Number.isFinite(requestedDelay)
6163 ? Math.min(intervalMs, Math.max(0, requestedDelay))
6264 : intervalMs;
65+ const requestedNextRun = now + delayMs;
66+ if (existing) {
67+ // Re-registering updates the replay payload, but cannot claim that the provider cache
68+ // was refreshed. Mutate the existing object so an in-flight dispatch can still clear
69+ // its state, while preserving an earlier deadline and any foreground-request pause.
70+ Object.assign(existing, job, {
71+ intervalMs,
72+ lastSeen: now,
73+ nextRun: Math.min(existing.nextRun, requestedNextRun),
74+ });
75+ } else {
6376 this.#jobs.set(key, {
6477 ...job,
6578 intervalMs,
6679 lastSeen: now,
6780 nextRun: now + delayMsrequestedNextRun,
6881 inFlight: false,
82+ paused: false,
6983 lastRun: null,
7084 });
85+ }
7186 this.#schedule();
7287 return this.#jobs.get(key);
7388 }
7489
7590 heartbeat(key, activity = false, request = null) {
7691 const job = this.#jobs.get(key);
7792 if (!job) {
7893 return null;
@@ -84,9 +99,6 @@ export class KeepaliveScheduler {
8499 return null;
85100 }
86101 job.lastSeen = now;
87- if (activity) {
88- job.nextRun = now + job.intervalMs;
89- }
90102 if (request) {
91103 job.request = request;
92104 }
@@ -94,6 +106,33 @@ export class KeepaliveScheduler {
94106 return job;
95107 }
96108
109+ pause(key) {
110+ const job = this.#jobs.get(key);
111+ if (!job) {
112+ return null;
113+ }
114+ job.lastSeen = this.#now();
115+ job.paused = true;
116+ this.#schedule();
117+ return job;
118+ }
119+
120+ resume(key, activityElapsedMs = null) {
121+ const job = this.#jobs.get(key);
122+ if (!job) {
123+ return null;
124+ }
125+ const now = this.#now();
126+ job.lastSeen = now;
127+ job.paused = false;
128+ const elapsedMs = Number(activityElapsedMs);
129+ if (activityElapsedMs !== null && activityElapsedMs !== undefined && Number.isFinite(elapsedMs) && elapsedMs >= 0) {
130+ job.nextRun = now + Math.max(0, job.intervalMs - elapsedMs);
131+ }
132+ this.#schedule();
133+ return job;
134+ }
135+
97136 stop(key) {
98137 const removed = this.#jobs.delete(key);
99138 this.#expiredJobs.delete(key);
@@ -120,21 +159,22 @@ export class KeepaliveScheduler {
120159 if (job.nextRun > now) {
121160 continue;
122161 }
123-
162+ if (job.inFlight || job.paused) {
124- job.nextRun = advanceDeadline(job.nextRun, job.intervalMs, now);
125- if (job.inFlight) {
126163 continue;
127164 }
165+ const scheduledRun = job.nextRun;
128166 job.inFlight = true;
129167 pending.push(Promise.resolve(this.#dispatch(job)).then(() => {
130168 const completedAt = this.#now();
131169 job.lastRun = completedAt;
170+ job.nextRun = advanceDeadline(scheduledRun, job.intervalMs, completedAt);
132171 const expiredJob = this.#expiredJobs.get(key);
133172 if (expiredJob) {
134173 expiredJob.lastRun = completedAt;
135174 }
136175 }).catch(error => {
137176 console.debug(`[Keepalive] Backend request failed for ${job.id}:`, error);
177+ job.nextRun = this.#now() + FAILED_REQUEST_RETRY_MS;
138178 }).finally(() => {
139179 job.inFlight = false;
140180 this.#schedule();
@@ -176,7 +216,8 @@ export class KeepaliveScheduler {
176216
177217 let nextWake = Infinity;
178218 for (const job of this.#jobs.values()) {
179- nextWake = Math.min(nextWake, job.nextRun, job.lastSeen + FRONTEND_LEASE_MS);
219+ const nextRun = (job.inFlight || job.paused) ? Infinity : job.nextRun;
220+ nextWake = Math.min(nextWake, nextRun, job.lastSeen + FRONTEND_LEASE_MS);
180221 }
181222 const delay = Math.max(0, nextWake - this.#now());
182223 this.#timer = this.#setTimer(() => {
@@ -287,7 +328,7 @@ router.post('/heartbeat', (request, response) => {
287328 try {
288329 const id = getJobId(request);
289330 const key = getJobKey(request, id);
290331 const job = scheduler.heartbeat(key, !!request.body?.activity, getReplayRequest(request));
291332 if (!job) {
292333 const expiredJob = scheduler.getExpired(key);
293334 return response.status(404).send({
@@ -301,6 +342,39 @@ router.post('/heartbeat', (request, response) => {
301342 }
302343});
303344
345+router.post('/pause', (request, response) => {
346+ try {
347+ const id = getJobId(request);
348+ const job = scheduler.pause(getJobKey(request, id));
349+ if (!job) {
350+ return response.status(404).send({ expired: false });
351+ }
352+ console.info(`[Keepalive] Paused job ${id} while foreground inference is running`);
353+ return response.send({ ok: true, nextRun: job.nextRun, lastRun: job.lastRun, leaseMs: FRONTEND_LEASE_MS });
354+ } catch (error) {
355+ return response.status(400).send({ error: String(error.message || error) });
356+ }
357+});
358+
359+router.post('/resume', (request, response) => {
360+ try {
361+ const id = getJobId(request);
362+ const job = scheduler.resume(getJobKey(request, id), request.body?.activityElapsedMs);
363+ if (!job) {
364+ const expiredJob = scheduler.getExpired(getJobKey(request, id));
365+ return response.status(404).send({
366+ expired: !!expiredJob,
367+ lastRun: expiredJob?.lastRun ?? null,
368+ });
369+ }
370+ const reset = request.body?.activityElapsedMs !== null && request.body?.activityElapsedMs !== undefined;
371+ console.info(`[Keepalive] Resumed job ${id}; deadline ${reset ? 'reset by successful foreground inference' : 'preserved after unsuccessful foreground inference'}`);
372+ return response.send({ ok: true, nextRun: job.nextRun, lastRun: job.lastRun, leaseMs: FRONTEND_LEASE_MS });
373+ } catch (error) {
374+ return response.status(400).send({ error: String(error.message || error) });
375+ }
376+});
377+
304378router.post('/stop', (request, response) => {
305379 try {
306380 const id = getJobId(request);
tests/keepalive-scheduler.test.js+51 -3
@@ -63,14 +63,62 @@ describe('KeepaliveScheduler', () => {
6363 expect(scheduler.get('user:tab').lastRun).toBe(190000);
6464 });
6565
6666 test('activityre-registering resetsrefreshes thepayload nextwithout keepalivepostponing the deadline', () => {
67+ const { scheduler, setNow } = createScheduler();
68+ scheduler.register('user:tab', { id: 'tab', payload: { version: 1 }, intervalMs: 60000, delayMs: 60000 });
69+
70+ setNow(50000);
71+ scheduler.register('user:tab', { id: 'tab', payload: { version: 2 }, intervalMs: 60000, delayMs: 60000 });
72+
73+ expect(scheduler.get('user:tab').nextRun).toBe(60000);
74+ expect(scheduler.get('user:tab').payload).toEqual({ version: 2 });
75+ });
76+
77+ test('pauses a due keepalive while foreground inference is running', async () => {
78+ const { scheduler, dispatch, setNow } = createScheduler();
79+ scheduler.register('user:tab', { id: 'tab', intervalMs: 60000, delayMs: 60000 });
80+
81+ setNow(50000);
82+ scheduler.pause('user:tab');
83+ setNow(70000);
84+ await scheduler.tick();
85+ expect(dispatch).not.toHaveBeenCalled();
86+
87+ scheduler.resume('user:tab');
88+ await scheduler.tick();
89+ expect(dispatch).toHaveBeenCalledTimes(1);
90+ });
91+
92+ test('only a successful foreground request advances the deadline', () => {
6793 const { scheduler, setNow } = createScheduler();
6894 scheduler.register('user:tab', { id: 'tab', intervalMs: 60000, delayMs: 60000 });
6995
7096 setNow(50000);
7197 scheduler.heartbeatpause('user:tab', true);
98+ setNow(70000);
99+ scheduler.resume('user:tab', 30000);
100+
101+ expect(scheduler.get('user:tab').nextRun).toBe(100000);
102+ });
103+
104+ test('a failed keepalive retries without advancing a full interval', async () => {
105+ let now = 0;
106+ const dispatch = jest.fn().mockRejectedValue(new Error('HTTP 502'));
107+ const scheduler = new KeepaliveScheduler({
108+ now: () => now,
109+ dispatch,
110+ setTimer: () => ({ unref() { } }),
111+ clearTimer: () => { },
112+ });
113+ const debug = jest.spyOn(console, 'debug').mockImplementation(() => { });
114+ scheduler.register('user:tab', { id: 'tab', intervalMs: 60000, delayMs: 60000 });
115+
116+ now = 60000;
117+ await scheduler.tick();
72118
73- expect(scheduler.get('user:tab').nextRun).toBe(110000);
119+ expect(dispatch).toHaveBeenCalledTimes(1);
120+ expect(scheduler.get('user:tab').nextRun).toBe(65000);
121+ debug.mockRestore();
74122 });
75123
76124 test('removes a job after five minutes without a frontend heartbeat', async () => {