perf: pace the refetch and back the tail poll off a quiet pane

Both of the dashboard's periodic reads hit endpoints that are far more
expensive than their cadence assumed, and the cost lands on the SERVER's
event loop, so it is paid by every browser client too.

`GET /api/sessions/unified` is ~550ms against 11 live sessions: it scans
every Claude transcript plus the lifecycle log, uncached, and republishes
the search index. `scheduleRefresh()` was a 250ms trailing debounce with no
floor, and a queued refresh re-ran the instant the previous one returned
(by recursing, which also chained one pending promise per iteration), so a
stream of events paced the refetches at the endpoint's own latency: with
`session:updated` broadcast per session per 500ms while anything is
working, the scans ran back to back. `resyncDelayMs()` now keeps ambient
refetches 3s apart, measured start-to-start. The user's own actions call
`refresh()` directly and are unaffected, so what this paces is only
"notice what changed elsewhere".

`GET /api/sessions/:id/terminal` is ~80-100ms: two `execSync` tmux calls,
then the whole byte buffer normalized before the tail is taken. It was
polled every second for as long as a live row was selected. It now backs
off 1s, 2s, 4s, 5s while consecutive reads change nothing, and resets to 1s
on any change, when the selection moves, when this dashboard sends input or
answers a dialog, and on return from an attach. A pane that is printing is
still read every second; a pane at its composer is not.

The poll also kept running in three places it had nothing to draw for: the
whole time the user was attached in tmux (an attach can last hours), and
behind the message overlays that an async action opens (answered, killed,
started), which are not keystroke-driven and so never reached the
`afterInput()` path that stops it. `setInterval` becomes a chained
`setTimeout`, since the delay now varies.

Measured against the live server, same idle row selected, 25s window:
22 tail reads before, 5 after. With a working pane selected it stays at 22,
which is the intended cadence for a pane whose output you are watching.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-17 16:58:37 +02:00
parent 0a8067170d
commit c9b3d518e2
3 changed files with 193 additions and 18 deletions
+25
View File
@@ -20,7 +20,9 @@ import {
helpKeysFor,
isSelfSession,
planAttach,
previewIntervalMs,
previewNoteFor,
resyncDelayMs,
sameFrame,
samePreview,
shouldAnimate,
@@ -259,6 +261,29 @@ describe('the preview policy', () => {
});
});
describe('the refetch and tail-read cadence', () => {
it('debounces a burst but paces a stream, measured from the last start', () => {
// Nothing has been refetched yet: pay the debounce and nothing more.
expect(resyncDelayMs(10_000, 0, 250, 3_000)).toBe(250);
// A refetch that started 2.9s ago: wait out the rest of the floor.
expect(resyncDelayMs(10_000, 9_900, 250, 3_000)).toBe(2_900);
// Past the floor: back to the debounce, never below it.
expect(resyncDelayMs(10_000, 6_000, 250, 3_000)).toBe(250);
expect(resyncDelayMs(10_000, 1_000, 250, 3_000)).toBe(250);
});
it('reads a printing pane every second and a quiet one every five', () => {
expect(previewIntervalMs(0, 1_000, 5_000)).toBe(1_000);
expect(previewIntervalMs(1, 1_000, 5_000)).toBe(2_000);
expect(previewIntervalMs(2, 1_000, 5_000)).toBe(4_000);
// The ceiling holds however long the pane stays quiet, and a silly counter
// cannot overflow the doubling into Infinity.
expect(previewIntervalMs(3, 1_000, 5_000)).toBe(5_000);
expect(previewIntervalMs(50, 1_000, 5_000)).toBe(5_000);
expect(previewIntervalMs(-5, 1_000, 5_000)).toBe(1_000);
});
});
describe('the repaint test', () => {
const key = { revision: 3, cols: 100, rows: 30, tick: 0 };
+23
View File
@@ -60,6 +60,12 @@ const terminals = new Map<string, string>();
* ago. A row dated by the wrong one of those reads `10m` instead of `1m`.
*/
let liveState: Array<Record<string, unknown>> = [];
/**
* Tail reads the preview pane has asked for. On the real server that route runs
* two synchronous tmux calls and normalizes the whole byte buffer, so how often
* a quiet pane is re-read is a property worth pinning.
*/
let terminalReads = 0;
/** Everything the TUI posted, so a test can assert on the exact body. */
const answered: Array<{ id: string; body: Record<string, unknown> }> = [];
const inputs: Array<{ sessionId: string; body: Record<string, unknown> }> = [];
@@ -281,6 +287,7 @@ beforeAll(async () => {
const previewFor = sessionRoute(url, 'terminal');
if (previewFor) {
terminalReads++;
return sendJson(res, { success: true, data: { terminalBuffer: terminals.get(previewFor) ?? '' } });
}
@@ -492,6 +499,22 @@ describe('codeman tui (under a pty)', () => {
await waitFor(() => rowFor(output, 'w2-beta').startsWith('>'), 'the up arrow to move the cursor');
});
it('keeps re-reading the tail, but backs off while the pane stays quiet', async () => {
await selectRow('w2-beta');
// Let the selection's own immediate read land, then measure a window in
// which nothing writes to the pane.
await new Promise((done) => setTimeout(done, 400));
const before = terminalReads;
await new Promise((done) => setTimeout(done, 6_000));
const reads = terminalReads - before;
// Still following: a chain that forgot to re-arm would freeze the pane at
// whatever it last showed, which no frame assertion would notice.
expect(reads).toBeGreaterThan(0);
// A fixed one-second poll would be six. The ladder (1s, 2s, 4s, then the
// 5s ceiling) cannot exceed four in this window.
expect(reads).toBeLessThanOrEqual(4);
}, 20_000);
it('picks up a session announced over SSE', async () => {
sessions = [
...sessions,