fix(turns): clear progress ticker on abnormal turn exit to prevent zombie progress messages

turnController.finish() is the only path that clears the 5s progress ticker,
but it lives inside the caller's try block in message-runtime-turns.ts. When
runAgent throws / is aborted / is killed (e.g. a user "중단"), control jumps to
finally and finish() is skipped, leaving the ticker editing the Discord message
forever — an orphaned "stuck at 0s" zombie progress message with no backing
task/attempt.

Add an idempotent MessageTurnController.dispose() that tears down the progress
ticker + idle timer, and always call it from the caller's finally so timers are
cleared on every exit path. Adds a regression test.
This commit is contained in:
Codex
2026-07-26 18:07:17 +09:00
parent 58811b2700
commit 142929ba39
3 changed files with 81 additions and 0 deletions

View File

@@ -285,6 +285,12 @@ export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn {
visiblePhase, visiblePhase,
}; };
} finally { } finally {
// Always tear down the controller's background timers. finish() clears
// them on the normal path, but it lives in the try above — if runAgent
// throws / is aborted / is killed, finish() is skipped and the 5s
// progress ticker would otherwise keep editing the Discord message
// forever (an orphaned "stuck at 0s" zombie). dispose() is idempotent.
turnController.dispose();
turnController.cancelPendingTypingDelay(); turnController.cancelPendingTypingDelay();
logger.debug( logger.debug(
{ {

View File

@@ -162,6 +162,61 @@ describe('MessageTurnController outbound audit logging', () => {
); );
}); });
it('dispose() stops the progress ticker so an aborted turn leaves no zombie edits', async () => {
vi.useFakeTimers();
try {
const channel = makeChannel();
const controller = new MessageTurnController({
chatJid: 'dc:test-room',
group: makeGroup(),
runId: 'run-zombie-1',
channel,
idleTimeout: 1_000,
failureFinalText: '실패',
isClaudeCodeAgent: true,
clearSession: vi.fn(),
requestClose: vi.fn(),
deliverFinalText: vi.fn().mockResolvedValue(true),
deliveryRole: 'reviewer',
deliveryServiceId: 'codex-review',
pairedTurnIdentity: makeTurnIdentity(),
});
await controller.start();
// Subagent tool-activity creates the tracked progress message and starts
// the 5s ticker.
await controller.handleOutput({
status: 'success',
phase: 'tool-activity',
agentId: 'sub-1',
result: '작업 중',
} as any);
// Flush the async progress-message creation (.then → progressMessageId).
await vi.advanceTimersByTimeAsync(0);
// Ticker is live: advancing past the 5s interval edits the message.
await vi.advanceTimersByTimeAsync(5_000);
expect(vi.mocked(channel.editMessage)).toHaveBeenCalled();
// Abnormal exit: finish() is NEVER called (agent threw / turn aborted).
// The caller's finally must call dispose().
controller.dispose();
const editsAfterDispose = vi.mocked(channel.editMessage!).mock.calls.length;
await vi.advanceTimersByTimeAsync(30_000);
// No further edits — the orphaned "stuck at 0s" zombie ticker is gone.
expect(vi.mocked(channel.editMessage!).mock.calls.length).toBe(
editsAfterDispose,
);
// Idempotent: calling dispose() again is a no-op and does not throw.
expect(() => controller.dispose()).not.toThrow();
} finally {
vi.useRealTimers();
}
});
it('logs fallback progress audit when tracked message creation fails', async () => { it('logs fallback progress audit when tracked message creation fails', async () => {
const channel = makeChannel(); const channel = makeChannel();
const deliverFinalText = vi.fn().mockResolvedValue(true); const deliverFinalText = vi.fn().mockResolvedValue(true);

View File

@@ -479,6 +479,26 @@ export class MessageTurnController {
this.progressTicker = null; this.progressTicker = null;
} }
/**
* Idempotent teardown of background timers (progress ticker + idle timer).
*
* `finish()` already tears these down on the normal completion path, but it
* lives inside the caller's `try` block. When the agent run throws / is
* aborted / is killed, control jumps straight to the caller's `finally` and
* `finish()` is skipped — leaving the 5s progress ticker editing the Discord
* message forever (an orphaned "stuck at 0s" zombie progress message).
*
* Callers MUST invoke this from a `finally` so timers are cleared on every
* exit path. Safe to call more than once and safe to call after finish().
*/
dispose(): void {
this.clearProgressTicker();
if (this.idleTimer) {
clearTimeout(this.idleTimer);
this.idleTimer = null;
}
}
private resetProgressState(): void { private resetProgressState(): void {
this.clearProgressTicker(); this.clearProgressTicker();
this.pendingProgressText = null; this.pendingProgressText = null;