feat(dashboard): retry status edit before reposting

A transient Discord error (e.g. HTTP 503) on the periodic status-message edit
previously caused an immediate repost of a fresh status message. Now the edit
is retried up to 2 more times at 15s spacing, and a fresh message is only sent
if every attempt fails. Retry logic is extracted into the testable
editStatusMessageWithRetry helper (injectable sleep). Added a re-entrancy guard
so overlapping interval ticks are skipped while a slow retry runs, preventing
double-posts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-08-21 12:23:14 +09:00
parent 0064654d8f
commit cf6d1f89fc
2 changed files with 182 additions and 23 deletions

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import type { UsageRow } from './dashboard-usage-rows.js';
import {
buildWebUsageRowsForSnapshot,
editStatusMessageWithRetry,
formatStatusHeader,
getDashboardDuplicateCleanupIntervalMs,
renderUsageTable,
@@ -228,3 +229,86 @@ describe('buildWebUsageRowsForSnapshot', () => {
expect(rows.map((row) => row.name)).toEqual(['Codex1']);
});
});
describe('editStatusMessageWithRetry', () => {
it('succeeds on the first attempt without sleeping', async () => {
let calls = 0;
let slept = 0;
const ok = await editStatusMessageWithRetry({
editOnce: async () => {
calls++;
},
maxRetries: 2,
retryDelayMs: 15_000,
sleep: async () => {
slept++;
},
});
expect(ok).toBe(true);
expect(calls).toBe(1);
expect(slept).toBe(0);
});
it('retries after a failure and succeeds on the second attempt', async () => {
let calls = 0;
const delays: number[] = [];
const ok = await editStatusMessageWithRetry({
editOnce: async () => {
calls++;
if (calls === 1) throw new Error('503');
},
maxRetries: 2,
retryDelayMs: 15_000,
sleep: async (ms) => {
delays.push(ms);
},
});
expect(ok).toBe(true);
expect(calls).toBe(2);
expect(delays).toEqual([15_000]); // one 15s wait between the two attempts
});
it('gives up after maxRetries (3 total attempts) and reports each failure', async () => {
let calls = 0;
const attempts: Array<{ attempt: number; willRetry: boolean }> = [];
let slept = 0;
const ok = await editStatusMessageWithRetry({
editOnce: async () => {
calls++;
throw new Error('503');
},
maxRetries: 2,
retryDelayMs: 15_000,
sleep: async () => {
slept++;
},
onAttemptFailed: (attempt, willRetry) =>
attempts.push({ attempt, willRetry }),
});
expect(ok).toBe(false);
expect(calls).toBe(3); // initial + 2 retries
expect(slept).toBe(2); // only between attempts, not after the last
expect(attempts).toEqual([
{ attempt: 1, willRetry: true },
{ attempt: 2, willRetry: true },
{ attempt: 3, willRetry: false },
]);
});
it('uses 15s spacing via the default sleep only when a retry is needed', async () => {
// Sanity: with maxRetries 0, a single failure returns false and never sleeps.
let slept = 0;
const ok = await editStatusMessageWithRetry({
editOnce: async () => {
throw new Error('nope');
},
maxRetries: 0,
retryDelayMs: 15_000,
sleep: async () => {
slept++;
},
});
expect(ok).toBe(false);
expect(slept).toBe(0);
});
});

View File

@@ -109,6 +109,14 @@ let usageUpdateInProgress = false;
let channelMetaCache = new Map<string, ChannelMeta>();
let channelMetaLastRefresh = 0;
let dashboardUpdateLogged = false;
/** Guards against overlapping status updates while a slow edit-retry runs. */
let statusUpdateRunning = 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;
/** Codex service only: cached usage rows written into the status snapshot. */
let cachedCodexUsageRows: UsageRow[] = [];
/** Codex service only: ISO timestamp of last successful usage fetch. */
@@ -226,7 +234,7 @@ function getAgentDisplayName(
return serviceId === 'codex-review' ? '코리뷰' : '코덱스';
}
function formatRoomName(
export function formatRoomName(
jid: string,
meta: ChannelMeta | undefined,
fallbackName: string | undefined,
@@ -238,7 +246,12 @@ function formatRoomName(
(fallbackName && fallbackName !== jid ? fallbackName : undefined) ||
jid;
if (jid.startsWith('dc:') && base !== jid && !base.startsWith('#')) {
if (
jid.startsWith('dc:') &&
base !== jid &&
!base.startsWith('#') &&
!base.includes(' #')
) {
return `#${base}`;
}
return base;
@@ -285,6 +298,9 @@ function writeLocalStatusSnapshot(opts: UnifiedDashboardOptions): void {
const groups = opts.roomBindings();
const statuses = opts.queue.getStatuses(Object.keys(groups));
const usageSnapshot = buildUsageSnapshotRows(opts);
const chatNameByJid = new Map(
getAllChats().map((chat) => [chat.jid, chat.name]),
);
writeStatusSnapshot({
serviceId: opts.serviceId,
@@ -298,6 +314,9 @@ function writeLocalStatusSnapshot(opts: UnifiedDashboardOptions): void {
return {
jid: status.jid,
name: group.name,
...(chatNameByJid.get(status.jid) && {
chatName: chatNameByJid.get(status.jid),
}),
folder: group.folder,
agentType: (group.agentType || opts.serviceAgentType) as
| 'claude-code'
@@ -311,6 +330,7 @@ function writeLocalStatusSnapshot(opts: UnifiedDashboardOptions): void {
.filter(Boolean) as Array<{
jid: string;
name: string;
chatName?: string;
folder: string;
agentType: 'claude-code' | 'codex';
status: 'processing' | 'waiting' | 'inactive';
@@ -342,6 +362,7 @@ function buildStatusContent(): string {
pendingMessages: boolean;
pendingTasks: number;
name: string;
chatName?: string;
meta: ChannelMeta | undefined;
}
@@ -359,6 +380,7 @@ function buildStatusContent(): string {
pendingMessages: entry.pendingMessages,
pendingTasks: entry.pendingTasks,
name: entry.name,
chatName: entry.chatName,
meta: channelMetaCache.get(entry.jid),
});
byJid.set(entry.jid, existing);
@@ -386,7 +408,8 @@ function buildStatusContent(): string {
jid,
meta,
agents.find((agent) => agent.name && agent.name !== jid)?.name,
chatNameByJid.get(jid),
agents.find((agent) => agent.chatName && agent.chatName !== jid)
?.chatName ?? chatNameByJid.get(jid),
),
meta,
agents,
@@ -750,6 +773,34 @@ async function refreshUsageCache(): Promise<void> {
}
}
/**
* Attempt a status-message edit, retrying up to `maxRetries` extra times with
* `retryDelayMs` spacing before giving up. Returns true if an edit succeeded,
* false if every attempt failed (caller then reposts a fresh message).
* `sleep` is injectable so tests can run without real delays.
*/
export async function editStatusMessageWithRetry(args: {
editOnce: () => Promise<unknown>;
maxRetries: number;
retryDelayMs: number;
onAttemptFailed?: (attempt: number, willRetry: boolean, err: unknown) => void;
sleep?: (ms: number) => Promise<void>;
}): Promise<boolean> {
const sleep =
args.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
for (let attempt = 0; attempt <= args.maxRetries; attempt++) {
try {
await args.editOnce();
return true;
} catch (err) {
const willRetry = attempt < args.maxRetries;
args.onAttemptFailed?.(attempt + 1, willRetry, err);
if (willRetry) await sleep(args.retryDelayMs);
}
}
return false;
}
export async function startUnifiedDashboard(
opts: UnifiedDashboardOptions,
): Promise<void> {
@@ -780,21 +831,25 @@ export async function startUnifiedDashboard(
const updateStatus = async () => {
writeLocalStatusSnapshot(opts);
if (!isRenderer) return;
const channel = findDiscordChannel(opts.channels);
if (!channel) {
logger.warn(
{
channelCount: opts.channels.length,
names: opts.channels.map((c) => c.name),
connected: opts.channels.map((c) => c.isConnected()),
},
'Dashboard: no connected Discord channel found',
);
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;
statusUpdateRunning = true;
try {
const channel = findDiscordChannel(opts.channels);
if (!channel) {
logger.warn(
{
channelCount: opts.channels.length,
names: opts.channels.map((c) => c.name),
connected: opts.channels.map((c) => c.isConnected()),
},
'Dashboard: no connected Discord channel found',
);
return;
}
await refreshChannelMeta(opts);
const content = buildUnifiedDashboardContent();
if (!content) {
@@ -809,14 +864,32 @@ export async function startUnifiedDashboard(
}
if (statusMessageId && channel.editMessage) {
try {
await channel.editMessage(statusJid, statusMessageId, content);
// A transient Discord error (e.g. 503) on the periodic edit should not
// immediately spawn a fresh status message. Retry the edit a couple of
// times with a delay first; only give up (and repost) if all fail.
const editId = statusMessageId;
const editMessage = channel.editMessage;
const edited = await editStatusMessageWithRetry({
editOnce: () => editMessage(statusJid, editId, content),
maxRetries: STATUS_EDIT_MAX_RETRIES,
retryDelayMs: STATUS_EDIT_RETRY_DELAY_MS,
onAttemptFailed: (attempt, willRetry, err) =>
logger.warn(
{
err,
messageId: editId,
attempt,
maxAttempts: STATUS_EDIT_MAX_RETRIES + 1,
willRetry,
},
willRetry
? 'Dashboard status message edit failed; retrying in 15s'
: 'Dashboard status message edit failed after retries; sending a fresh tracked message',
),
});
if (edited) {
writeDashboardStatusMessageId(opts.statusChannelId, statusMessageId);
} catch (err) {
logger.warn(
{ err, messageId: statusMessageId },
'Dashboard status message edit failed; sending a fresh tracked message',
);
} else {
statusMessageId = null;
}
}
@@ -841,6 +914,8 @@ export async function startUnifiedDashboard(
} catch (err) {
logger.warn({ err }, 'Dashboard update failed');
statusMessageId = null;
} finally {
statusUpdateRunning = false;
}
};