diff --git a/src/message-runtime-turns.ts b/src/message-runtime-turns.ts index 78d9d1b..e496f3d 100644 --- a/src/message-runtime-turns.ts +++ b/src/message-runtime-turns.ts @@ -285,6 +285,12 @@ export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn { visiblePhase, }; } 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(); logger.debug( { diff --git a/src/message-turn-controller.test.ts b/src/message-turn-controller.test.ts index bd2181e..c1d5f3f 100644 --- a/src/message-turn-controller.test.ts +++ b/src/message-turn-controller.test.ts @@ -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 () => { const channel = makeChannel(); const deliverFinalText = vi.fn().mockResolvedValue(true); diff --git a/src/message-turn-controller.ts b/src/message-turn-controller.ts index d0a2a23..4c0e29a 100644 --- a/src/message-turn-controller.ts +++ b/src/message-turn-controller.ts @@ -479,6 +479,26 @@ export class MessageTurnController { 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 { this.clearProgressTicker(); this.pendingProgressText = null;