mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 17:44:11 +00:00
🫱 fix: Keep Polling Through Queued Run Handoffs (#15509)
Queue a message during a run — queue, not steer — and the row is server-owned: `useQueueDrain` deliberately declines the boundary (`serverOwnsBoundary`) and the backend admits the successor itself. So the client has to hear about that run through the active-job list, exactly like a background-tool continuation. It cannot. Finishing a run empties the list — the client removes its own job optimistically the moment FINAL lands — and the poll is disabled on an empty list, so it goes quiet at precisely the moment the successor is admitted. With the tab already focused there is no focus refetch either, which is why this reproduces on desktop and not only on mobile. The announcement never arrives, so the re-arm never runs and the pane sits on the previous turn until a reload. Keep asking for a bounded window after the last known job ends. The window is module-scoped rather than per-observer because this is one shared query and a component mounting mid-handover should inherit the grace, not restart it. This is the other half of the previous commits: without the re-arm the announcement lands on a pane that still reads its finished submission as an attachment, and without this the announcement never lands at all. Neither half is observable alone.
This commit is contained in:
parent
519e64769a
commit
a20048ab2d
2 changed files with 93 additions and 4 deletions
|
|
@ -42,6 +42,10 @@ import {
|
|||
genTitleQueryKey,
|
||||
queueTitleGeneration,
|
||||
useActiveJobs,
|
||||
resetActiveJobsGrace,
|
||||
getActiveJobsRefetchInterval,
|
||||
ACTIVE_JOBS_POLL_MS,
|
||||
ACTIVE_JOBS_SUCCESSOR_GRACE_MS,
|
||||
} from '../queries';
|
||||
|
||||
describe('fetchStreamStatus generation protocol advertisement', () => {
|
||||
|
|
@ -84,14 +88,65 @@ describe('useActiveJobs focus behaviour', () => {
|
|||
expect(options.staleTime).toBe(5_000);
|
||||
});
|
||||
|
||||
it('polls only while something is listed', () => {
|
||||
it('polls while something is listed', () => {
|
||||
(useQuery as jest.Mock).mockClear();
|
||||
resetActiveJobsGrace();
|
||||
|
||||
useActiveJobs();
|
||||
|
||||
const options = (useQuery as jest.Mock).mock.calls[0][0];
|
||||
expect(options.refetchInterval({ activeJobIds: ['convo-1'] })).toBe(5_000);
|
||||
expect(options.refetchInterval({ activeJobIds: [] })).toBe(false);
|
||||
expect(options.refetchInterval({ activeJobIds: ['convo-1'] })).toBe(ACTIVE_JOBS_POLL_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useActiveJobs successor grace', () => {
|
||||
let now = 1_000_000;
|
||||
|
||||
beforeEach(() => {
|
||||
now = 1_000_000;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
resetActiveJobsGrace();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(Date.now as jest.Mock).mockRestore?.();
|
||||
resetActiveJobsGrace();
|
||||
});
|
||||
|
||||
it('does not poll for an empty list it has never seen fill', () => {
|
||||
expect(getActiveJobsRefetchInterval({ activeJobIds: [] })).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps asking across the handover to a server-started successor', () => {
|
||||
/**
|
||||
* The run ends, the client removes its own job optimistically, and the
|
||||
* backend admits the queued turn (or a background-tool continuation) it
|
||||
* owns. Going quiet on the empty list is what leaves the pane on the
|
||||
* previous turn until a reload.
|
||||
*/
|
||||
expect(getActiveJobsRefetchInterval({ activeJobIds: ['convo-1'] })).toBe(ACTIVE_JOBS_POLL_MS);
|
||||
|
||||
now += 1_000;
|
||||
expect(getActiveJobsRefetchInterval({ activeJobIds: [] })).toBe(ACTIVE_JOBS_POLL_MS);
|
||||
});
|
||||
|
||||
it('stops once the handover window has passed without a successor', () => {
|
||||
expect(getActiveJobsRefetchInterval({ activeJobIds: ['convo-1'] })).toBe(ACTIVE_JOBS_POLL_MS);
|
||||
|
||||
now += ACTIVE_JOBS_SUCCESSOR_GRACE_MS + 1;
|
||||
expect(getActiveJobsRefetchInterval({ activeJobIds: [] })).toBe(false);
|
||||
});
|
||||
|
||||
it('renews the window when the successor turns up', () => {
|
||||
getActiveJobsRefetchInterval({ activeJobIds: ['convo-1'] });
|
||||
now += 1_000;
|
||||
getActiveJobsRefetchInterval({ activeJobIds: [] });
|
||||
|
||||
now += 1_000;
|
||||
expect(getActiveJobsRefetchInterval({ activeJobIds: ['convo-1'] })).toBe(ACTIVE_JOBS_POLL_MS);
|
||||
|
||||
now += ACTIVE_JOBS_SUCCESSOR_GRACE_MS - 1;
|
||||
expect(getActiveJobsRefetchInterval({ activeJobIds: [] })).toBe(ACTIVE_JOBS_POLL_MS);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -219,6 +219,40 @@ export function useTitleGeneration(enabled = true) {
|
|||
* - Polls while jobs are active
|
||||
* - Shows generation indicators in conversation list
|
||||
*/
|
||||
export const ACTIVE_JOBS_POLL_MS = 5_000;
|
||||
/**
|
||||
* How long to keep asking after the last job this client knew about ended.
|
||||
*
|
||||
* A successor the server admits for itself — a queued turn handed to the
|
||||
* backend, a background-tool continuation — starts as part of terminalizing
|
||||
* the run before it. But finishing a run also empties this list, partly
|
||||
* because the client removes its own job optimistically the moment `FINAL`
|
||||
* lands. Stopping the poll on an empty list therefore goes quiet at exactly
|
||||
* the moment a successor appears, and with the tab already focused there is no
|
||||
* focus refetch to recover: the pane waits for a reload. Keep asking across
|
||||
* that handover instead.
|
||||
*/
|
||||
export const ACTIVE_JOBS_SUCCESSOR_GRACE_MS = 30_000;
|
||||
|
||||
/** Module-scoped rather than per-observer: this is one shared query, and a
|
||||
* component mounting mid-handover should inherit the grace, not restart it. */
|
||||
let lastListedActiveJobsAt = 0;
|
||||
|
||||
/** @internal Test seam — the grace window is wall-clock state. */
|
||||
export function resetActiveJobsGrace(): void {
|
||||
lastListedActiveJobsAt = 0;
|
||||
}
|
||||
|
||||
export function getActiveJobsRefetchInterval(data?: ActiveJobsResponse): number | false {
|
||||
if ((data?.activeJobIds?.length ?? 0) > 0) {
|
||||
lastListedActiveJobsAt = Date.now();
|
||||
return ACTIVE_JOBS_POLL_MS;
|
||||
}
|
||||
return Date.now() - lastListedActiveJobsAt < ACTIVE_JOBS_SUCCESSOR_GRACE_MS
|
||||
? ACTIVE_JOBS_POLL_MS
|
||||
: false;
|
||||
}
|
||||
|
||||
export function useActiveJobs(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: [QueryKeys.activeJobs],
|
||||
|
|
@ -231,7 +265,7 @@ export function useActiveJobs(enabled = true) {
|
|||
* invisible until some unrelated refetch, and returning to the tab is
|
||||
* exactly when a pane needs to know whether its history has moved. */
|
||||
refetchOnWindowFocus: 'always',
|
||||
refetchInterval: (data) => ((data?.activeJobIds?.length ?? 0) > 0 ? 5_000 : false),
|
||||
refetchInterval: getActiveJobsRefetchInterval,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue