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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user