fix: throttle progress edits via 5s ticker, prevent Discord rate limit

- Remove per-tool-activity sync calls (was flooding Discord with edits)
- Ticker-only sync at 5s intervals for time + tool activity updates
- Summary updates heading directly when progress message exists
- Prevent duplicate progress message creation with progressCreating flag
This commit is contained in:
Eyejoker
2026-03-23 19:13:51 +09:00
parent 5112af6741
commit a8a1c9b45a
3 changed files with 43 additions and 32 deletions

View File

@@ -558,7 +558,7 @@ async function runQuery(
if (summary) { if (summary) {
writeOutput({ writeOutput({
status: 'success', status: 'success',
phase: 'tool-activity', phase: 'progress',
result: `📋 ${summary}`, result: `📋 ${summary}`,
newSessionId, newSessionId,
}); });

View File

@@ -1451,14 +1451,15 @@ describe('createMessageRuntime', () => {
result: '커밋은 정상 들어갔고 pre-commit도 통과했습니다.', result: '커밋은 정상 들어갔고 pre-commit도 통과했습니다.',
newSessionId: 'session-progress-only', newSessionId: 'session-progress-only',
}); });
await vi.advanceTimersByTimeAsync(10_000); // Third progress: updates tracked message heading directly
// Third progress: flushes second (edits tracked message with new text)
await onOutput?.({ await onOutput?.({
status: 'success', status: 'success',
phase: 'progress', phase: 'progress',
result: '테스트도 통과했습니다.', result: '테스트도 통과했습니다.',
newSessionId: 'session-progress-only', newSessionId: 'session-progress-only',
}); });
// Advance timer so the ticker fires and syncs the tracked message
await vi.advanceTimersByTimeAsync(5_000);
await onOutput?.({ await onOutput?.({
status: 'success', status: 'success',
result: null, result: null,
@@ -1506,17 +1507,17 @@ describe('createMessageRuntime', () => {
chatJid, chatJid,
P('검증 중입니다.\n\n0초'), P('검증 중입니다.\n\n0초'),
); );
// Second progress flushed when third arrives — edits tracked message // Ticker fires after advanceTimersByTime — edits tracked message with latest heading
expect(channel.editMessage).toHaveBeenCalledWith( expect(channel.editMessage).toHaveBeenCalledWith(
chatJid, chatJid,
'progress-1', 'progress-1',
P('커밋은 정상 들어갔고 pre-commit도 통과했습니다.\n\n10초'), P('테스트도 통과했습니다.\n\n5초'),
); );
// finish() promotes the last flushed progress text to a final message // finish() promotes the last flushed progress text to a final message
expect(channel.sendMessage).toHaveBeenCalledTimes(1); expect(channel.sendMessage).toHaveBeenCalledTimes(1);
expect(channel.sendMessage).toHaveBeenCalledWith( expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid, chatJid,
'커밋은 정상 들어갔고 pre-commit도 통과했습니다.', '검증 중입니다.',
); );
} finally { } finally {
vi.useRealTimers(); vi.useRealTimers();
@@ -1561,14 +1562,15 @@ describe('createMessageRuntime', () => {
result: '아직 진행 중.', result: '아직 진행 중.',
newSessionId: 'session-progress-recreate', newSessionId: 'session-progress-recreate',
}); });
await vi.advanceTimersByTimeAsync(10_000); // Third progress: updates heading directly (edit fails once on ticker, then succeeds on retry)
// Third progress: flushes second (edit fails once, then succeeds on retry in syncTrackedProgressMessage)
await onOutput?.({ await onOutput?.({
status: 'success', status: 'success',
phase: 'progress', phase: 'progress',
result: '거의 완료.', result: '거의 완료.',
newSessionId: 'session-progress-recreate', newSessionId: 'session-progress-recreate',
}); });
// Advance timer so the ticker fires and syncs (first edit fails, second succeeds)
await vi.advanceTimersByTimeAsync(5_000);
await onOutput?.({ await onOutput?.({
status: 'success', status: 'success',
result: null, result: null,
@@ -1627,7 +1629,7 @@ describe('createMessageRuntime', () => {
expect(channel.sendMessage).toHaveBeenCalledTimes(1); expect(channel.sendMessage).toHaveBeenCalledTimes(1);
expect(channel.sendMessage).toHaveBeenCalledWith( expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid, chatJid,
'아직 진행 중.', '진행 중입니다.',
); );
} finally { } finally {
vi.useRealTimers(); vi.useRealTimers();

View File

@@ -29,6 +29,7 @@ export class MessageTurnController {
private previousProgressText: string | null = null; private previousProgressText: string | null = null;
private pendingProgressText: string | null = null; private pendingProgressText: string | null = null;
private toolActivities: string[] = []; private toolActivities: string[] = [];
private progressCreating = false;
private latestProgressRendered: string | null = null; private latestProgressRendered: string | null = null;
private progressMessageId: string | null = null; private progressMessageId: string | null = null;
private progressStartedAt: number | null = null; private progressStartedAt: number | null = null;
@@ -105,9 +106,18 @@ export class MessageTurnController {
} }
if (result.phase === 'tool-activity') { if (result.phase === 'tool-activity') {
// Flush pending progress so the message exists for tool activity sub-lines // Ensure a progress message exists for tool activity sub-lines
if (this.pendingProgressText && !this.progressMessageId) { if (!this.progressMessageId && !this.progressCreating) {
void this.sendProgressMessage(this.pendingProgressText); this.progressCreating = true;
const heading = this.pendingProgressText || '작업 중...';
this.sendProgressMessage(heading).then(() => {
this.progressCreating = false;
this.ensureProgressTicker();
// Replay any queued tool activities now that the message exists
if (this.toolActivities.length > 0 && this.progressMessageId) {
void this.syncTrackedProgressMessage();
}
});
this.pendingProgressText = null; this.pendingProgressText = null;
} }
if (text) { if (text) {
@@ -121,7 +131,14 @@ export class MessageTurnController {
if (result.phase === 'progress') { if (result.phase === 'progress') {
if (text) { if (text) {
this.bufferProgress(text); if (this.progressMessageId) {
// Progress message already visible — update heading directly
this.latestProgressText = text;
this.toolActivities = [];
void this.syncTrackedProgressMessage();
} else {
this.bufferProgress(text);
}
} }
if (!this.poisonedSessionDetected) { if (!this.poisonedSessionDetected) {
this.resetIdleTimer(); this.resetIdleTimer();
@@ -223,7 +240,7 @@ export class MessageTurnController {
const elapsedSeconds = const elapsedSeconds =
this.progressStartedAt === null this.progressStartedAt === null
? 0 ? 0
: Math.floor((Date.now() - this.progressStartedAt) / 10_000) * 10; : Math.floor((Date.now() - this.progressStartedAt) / 5_000) * 5;
const hours = Math.floor(elapsedSeconds / 3600); const hours = Math.floor(elapsedSeconds / 3600);
const minutes = Math.floor((elapsedSeconds % 3600) / 60); const minutes = Math.floor((elapsedSeconds % 3600) / 60);
const seconds = elapsedSeconds % 60; const seconds = elapsedSeconds % 60;
@@ -241,9 +258,7 @@ export class MessageTurnController {
const isLast = i === this.toolActivities.length - 1; const isLast = i === this.toolActivities.length - 1;
const connector = isLast ? '└' : '├'; const connector = isLast ? '└' : '├';
const isSummary = a.startsWith('📋'); const isSummary = a.startsWith('📋');
return isSummary return isSummary ? `${connector} ${a}` : `${connector} ${a}`;
? `${connector} ${a}`
: `${connector} ${a}`;
}) })
.join('\n') .join('\n')
: ''; : '';
@@ -267,6 +282,7 @@ export class MessageTurnController {
private resetProgressState(): void { private resetProgressState(): void {
this.clearProgressTicker(); this.clearProgressTicker();
this.pendingProgressText = null; this.pendingProgressText = null;
this.progressCreating = false;
this.toolActivities = []; this.toolActivities = [];
this.latestProgressText = null; this.latestProgressText = null;
this.previousProgressText = null; this.previousProgressText = null;
@@ -299,11 +315,9 @@ export class MessageTurnController {
if (this.toolActivities.length > MAX_ACTIVITIES) { if (this.toolActivities.length > MAX_ACTIVITIES) {
this.toolActivities = this.toolActivities.slice(-MAX_ACTIVITIES); this.toolActivities = this.toolActivities.slice(-MAX_ACTIVITIES);
} }
// Update the displayed progress message with tool activity sub-lines // Don't sync here — let the ticker handle periodic updates
if (this.latestProgressText && this.progressMessageId) { // to avoid flooding Discord with edits.
void this.syncTrackedProgressMessage(); this.ensureProgressTicker();
this.ensureProgressTicker();
}
} }
/** /**
@@ -327,9 +341,6 @@ export class MessageTurnController {
} }
const rendered = this.renderProgressMessage(this.latestProgressText); const rendered = this.renderProgressMessage(this.latestProgressText);
if (rendered === this.latestProgressRendered) {
return;
}
try { try {
await this.options.channel.editMessage( await this.options.channel.editMessage(
@@ -361,17 +372,15 @@ export class MessageTurnController {
} }
private ensureProgressTicker(): void { private ensureProgressTicker(): void {
if ( if (this.progressTicker || !this.options.channel.editMessage) {
!this.progressMessageId ||
!this.options.channel.editMessage ||
this.progressTicker
) {
return; return;
} }
this.progressTicker = setInterval(() => { this.progressTicker = setInterval(() => {
void this.syncTrackedProgressMessage(); if (this.progressMessageId && this.latestProgressText && !this.progressCreating) {
}, 10_000); void this.syncTrackedProgressMessage();
}
}, 5_000);
} }
private async finalizeProgressMessage(): Promise<void> { private async finalizeProgressMessage(): Promise<void> {