feat(dashboard): 60s base refresh with event-driven immediate updates

Raise the status dashboard base refresh from 10s to 60s, and refresh
immediately (debounced 1.5s) on events between ticks:
- a real chat message arrives in a registered room (index.ts onMessage)
- an agent starts/finishes a run, i.e. activity moves between rooms
  (GroupQueue.setOnActivityChange fired on activeCount changes)

requestImmediateStatusUpdate() drives updateStatus out-of-band via a coalescing
trigger. The existing re-entrancy guard means the base periodic refresh does not
run while an edit-retry is in flight; a refresh requested during that window is
remembered and runs once afterward. Adds createCoalescingTrigger with tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-08-21 12:53:39 +09:00
parent 9108174380
commit 8600213bcc
5 changed files with 122 additions and 4 deletions

View File

@@ -111,12 +111,28 @@ let channelMetaLastRefresh = 0;
let dashboardUpdateLogged = false;
/** Guards against overlapping status updates while a slow edit-retry runs. */
let statusUpdateRunning = false;
/** A refresh was requested while one was already running; run once more after. */
let statusUpdatePending = false;
/**
* On a failed status-message edit, retry the edit this many extra times at
* STATUS_EDIT_RETRY_DELAY_MS spacing before falling back to a fresh message.
*/
const STATUS_EDIT_MAX_RETRIES = 2;
const STATUS_EDIT_RETRY_DELAY_MS = 15_000;
/** Coalesce bursts of event-driven refresh requests into one update. */
const IMMEDIATE_UPDATE_DEBOUNCE_MS = 1_500;
/** Set by the renderer's startUnifiedDashboard so external events can nudge it. */
let immediateUpdateTrigger: (() => void) | null = null;
/**
* Request an out-of-band status refresh (e.g. a new chat message arrived or an
* agent's activity changed) so the dashboard updates immediately instead of
* waiting for the next periodic tick. No-op until the renderer dashboard starts;
* bursts are debounced.
*/
export function requestImmediateStatusUpdate(): void {
immediateUpdateTrigger?.();
}
/** Codex service only: cached usage rows written into the status snapshot. */
let cachedCodexUsageRows: UsageRow[] = [];
/** Codex service only: ISO timestamp of last successful usage fetch. */
@@ -801,6 +817,29 @@ export async function editStatusMessageWithRetry(args: {
return false;
}
/**
* Returns a trigger that runs `run` at most once per `delayMs` window: the
* first call schedules a run after `delayMs`, and further calls within that
* window are folded into the same pending run. Timer fns are injectable for
* tests.
*/
export function createCoalescingTrigger(
run: () => void,
delayMs: number,
timer: {
set: (cb: () => void, ms: number) => ReturnType<typeof setTimeout>;
} = { set: (cb, ms) => setTimeout(cb, ms) },
): () => void {
let scheduled: ReturnType<typeof setTimeout> | null = null;
return () => {
if (scheduled) return;
scheduled = timer.set(() => {
scheduled = null;
run();
}, delayMs);
};
}
export async function startUnifiedDashboard(
opts: UnifiedDashboardOptions,
): Promise<void> {
@@ -836,8 +875,13 @@ export async function startUnifiedDashboard(
writeLocalStatusSnapshot(opts);
if (!isRenderer) return;
// A failed edit now retries with delays, so a single updateStatus can run
// for tens of seconds. Skip overlapping interval ticks to avoid double posts.
if (statusUpdateRunning) return;
// for tens of seconds. Skip overlapping ticks (this is also why the base
// periodic refresh does not fire mid-retry); remember the request so it runs
// once the in-flight update finishes.
if (statusUpdateRunning) {
statusUpdatePending = true;
return;
}
statusUpdateRunning = true;
try {
@@ -926,9 +970,22 @@ export async function startUnifiedDashboard(
statusMessageId = null;
} finally {
statusUpdateRunning = false;
if (statusUpdatePending) {
statusUpdatePending = false;
immediateUpdateTrigger?.();
}
}
};
if (isRenderer) {
// Event-driven refresh: external callers (new chat message, agent activity
// change) call requestImmediateStatusUpdate(); bursts are coalesced.
immediateUpdateTrigger = createCoalescingTrigger(
() => void updateStatus(),
IMMEDIATE_UPDATE_DEBOUNCE_MS,
);
}
setInterval(updateStatus, opts.statusUpdateInterval);
setInterval(() => {
if (!isRenderer || !statusMessageId) return;