From 1f4cd861af22a4cb604ec293cf89a1a7f5112816 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sun, 12 Apr 2026 14:18:22 +0900 Subject: [PATCH] runtime: suppress stale owner progress flush --- src/message-turn-controller.test.ts | 48 +++++++++++++++++++++++++++++ src/message-turn-controller.ts | 18 +++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/message-turn-controller.test.ts b/src/message-turn-controller.test.ts index aa1e8ab..2e84d40 100644 --- a/src/message-turn-controller.test.ts +++ b/src/message-turn-controller.test.ts @@ -472,6 +472,54 @@ describe('MessageTurnController outbound audit logging', () => { ); }); + it('does not flush buffered progress when final delivery is disallowed for a stale owner turn', async () => { + const channel = makeChannel(); + const deliverFinalText = vi.fn().mockResolvedValue(true); + const controller = new MessageTurnController({ + chatJid: 'dc:test-room', + group: makeGroup(), + runId: 'run-stale-owner-final-with-buffered-progress', + channel, + idleTimeout: 1_000, + failureFinalText: '실패', + isClaudeCodeAgent: true, + clearSession: vi.fn(), + requestClose: vi.fn(), + deliverFinalText, + canDeliverFinalText: () => false, + deliveryRole: 'owner', + pairedTurnIdentity: { + turnId: 'paired-task:2026-04-10T00:00:00.000Z:finalize-owner-turn', + taskId: 'paired-task', + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + intentKind: 'finalize-owner-turn', + role: 'owner', + }, + }); + + await controller.start(); + await controller.handleOutput({ + status: 'success', + phase: 'progress', + result: '버퍼된 진행 상황', + output: { visibility: 'public', text: '버퍼된 진행 상황' }, + } as any); + + await controller.handleOutput({ + status: 'success', + phase: 'final', + result: 'DONE 최종 답변', + output: { visibility: 'public', text: 'DONE 최종 답변' }, + } as any); + + const finishResult = await controller.finish('success'); + + expect(finishResult.deliverySucceeded).toBe(true); + expect(channel.sendAndTrack).not.toHaveBeenCalled(); + expect(channel.sendMessage).not.toHaveBeenCalled(); + expect(deliverFinalText).not.toHaveBeenCalled(); + }); + it('replaces the tracked progress message when finish() publishes a failure final', async () => { const channel = { ...makeChannel(), diff --git a/src/message-turn-controller.ts b/src/message-turn-controller.ts index 6cf6fcf..42e8277 100644 --- a/src/message-turn-controller.ts +++ b/src/message-turn-controller.ts @@ -517,6 +517,24 @@ export class MessageTurnController { * pending text differs from the final text. */ private async flushPendingProgress(finalText: string): Promise { + if ( + this.options.canDeliverFinalText && + !this.options.canDeliverFinalText() + ) { + this.log.info( + { + runId: this.options.runId, + deliveryRole: this.options.deliveryRole ?? null, + turnId: this.options.pairedTurnIdentity?.turnId ?? null, + pendingLength: this.pendingProgressText?.length ?? 0, + finalLength: finalText.length, + }, + 'Skipped flushing pending progress because this run no longer owns the active paired turn attempt', + ); + this.pendingProgressText = null; + return; + } + if ( this.pendingProgressText && (this.options.pairedTurnIdentity?.role === 'reviewer' ||