diff --git a/src/task-scheduler-github-delivery.test.ts b/src/task-scheduler-github-delivery.test.ts index 231eb08..04c631a 100644 --- a/src/task-scheduler-github-delivery.test.ts +++ b/src/task-scheduler-github-delivery.test.ts @@ -64,7 +64,10 @@ describe('GitHub watcher delivery identity', () => { const task = getTaskById('task-github-codex-paired-complete'); expect(task).toBeDefined(); - const deps = { sendMessage: vi.fn(async () => {}) } as any; + const deps = { + sendMessage: vi.fn(async () => {}), + queue: { enqueueMessageCheck: vi.fn() }, + } as any; await runGithubCiTask(task!, deps); diff --git a/src/task-scheduler-github.test.ts b/src/task-scheduler-github.test.ts index 6a35355..87be0ae 100644 --- a/src/task-scheduler-github.test.ts +++ b/src/task-scheduler-github.test.ts @@ -44,6 +44,8 @@ import type { ScheduledTask } from './types.js'; function buildSchedulerDeps(overrides: { sendMessage?: SchedulerDependencies['sendMessage']; + sendTrackedMessage?: SchedulerDependencies['sendTrackedMessage']; + editTrackedMessage?: SchedulerDependencies['editTrackedMessage']; enqueueMessageCheck?: (chatJid: string, ipcDir?: string) => void; }): SchedulerDependencies { return { @@ -55,6 +57,8 @@ function buildSchedulerDeps(overrides: { } as any, onProcess: () => {}, sendMessage: overrides.sendMessage ?? vi.fn(async () => {}), + sendTrackedMessage: overrides.sendTrackedMessage, + editTrackedMessage: overrides.editTrackedMessage, }; } @@ -90,6 +94,8 @@ function createReviewReadyPairedTask(now: string): void { function createGithubWatcherTask(overrides: { id?: string; roomRole?: ScheduledTask['room_role']; + statusMessageId?: string | null; + statusStartedAt?: string | null; now: string; }): ScheduledTask { const taskId = overrides.id ?? 'task-github-owner-complete'; @@ -118,6 +124,8 @@ Managed by host-driven watcher. context_mode: 'isolated', next_run: overrides.now, status: 'active', + status_message_id: overrides.statusMessageId, + status_started_at: overrides.statusStartedAt, created_at: overrides.now, }); return getTaskById(taskId)!; @@ -164,4 +172,100 @@ describe('GitHub CI watcher scheduler', () => { }); expect(getTaskById(task.id)).toBeUndefined(); }); + + it('queues an owner turn even when the paired task already closed before CI completion', async () => { + checkGitHubActionsRunMock.mockResolvedValueOnce({ + terminal: true, + resultSummary: '성공: owner/repo run 777777', + completionMessage: 'CI 완료: GitHub Actions run 777777\n판정: 성공', + }); + const now = '2026-02-22T00:00:00.000Z'; + const task = createGithubWatcherTask({ now, roomRole: 'owner' }); + const enqueueMessageCheck = vi.fn(); + + await runGithubCiTask(task, buildSchedulerDeps({ enqueueMessageCheck })); + + expect(enqueueMessageCheck).toHaveBeenCalledWith( + 'shared@g.us', + expect.stringContaining('shared-group'), + ); + expect(getRecentChatMessages('shared@g.us', 5).at(-1)).toMatchObject({ + sender: 'ci-watcher', + sender_name: 'CI watcher', + is_bot_message: false, + message_source_kind: 'trusted_external_bot', + content: + '[CI watcher completed]\nCI 완료: GitHub Actions run 777777\n판정: 성공', + }); + expect(getTaskById(task.id)).toBeUndefined(); + }); + + it('does not queue owner wake-up for non-owner CI watchers', async () => { + checkGitHubActionsRunMock.mockResolvedValueOnce({ + terminal: true, + resultSummary: '성공: owner/repo run 777777', + completionMessage: 'CI 완료: GitHub Actions run 777777\n판정: 성공', + }); + const now = '2026-02-22T00:00:00.000Z'; + createReviewReadyPairedTask(now); + const task = createGithubWatcherTask({ now, roomRole: 'reviewer' }); + const enqueueMessageCheck = vi.fn(); + + await runGithubCiTask(task, buildSchedulerDeps({ enqueueMessageCheck })); + + expect(enqueueMessageCheck).not.toHaveBeenCalled(); + expect(getRecentChatMessages('shared@g.us', 5)).toEqual([]); + expect(getTaskById(task.id)).toBeUndefined(); + }); + + it('edits the tracked watcher status and still wakes owner without a duplicate bot completion message', async () => { + checkGitHubActionsRunMock.mockResolvedValueOnce({ + terminal: true, + resultSummary: '성공: owner/repo run 777777', + completionMessage: 'CI 완료: GitHub Actions run 777777\n판정: 성공', + }); + const now = '2026-02-22T00:00:00.000Z'; + createReviewReadyPairedTask(now); + const task = createGithubWatcherTask({ + now, + roomRole: 'owner', + statusMessageId: 'status-msg-1', + statusStartedAt: now, + }); + const sendMessage = vi.fn(async () => {}); + const sendTrackedMessage = vi.fn(async () => 'status-msg-new'); + const editTrackedMessage = vi.fn(async () => {}); + const enqueueMessageCheck = vi.fn(); + + await runGithubCiTask( + task, + buildSchedulerDeps({ + sendMessage, + sendTrackedMessage, + editTrackedMessage, + enqueueMessageCheck, + }), + ); + + expect(editTrackedMessage).toHaveBeenCalledWith( + 'shared@g.us', + 'status-msg-1', + expect.stringContaining('CI 감시 종료: GitHub Actions run 777777'), + ); + expect(sendTrackedMessage).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + expect(enqueueMessageCheck).toHaveBeenCalledWith( + 'shared@g.us', + expect.stringContaining('shared-group'), + ); + expect(getRecentChatMessages('shared@g.us', 5).at(-1)).toMatchObject({ + sender: 'ci-watcher', + sender_name: 'CI watcher', + is_bot_message: false, + message_source_kind: 'trusted_external_bot', + content: + '[CI watcher completed]\nCI 완료: GitHub Actions run 777777\n판정: 성공', + }); + expect(getTaskById(task.id)).toBeUndefined(); + }); }); diff --git a/src/task-scheduler-github.ts b/src/task-scheduler-github.ts index 145e874..61f27bd 100644 --- a/src/task-scheduler-github.ts +++ b/src/task-scheduler-github.ts @@ -2,7 +2,6 @@ import { getErrorMessage } from './utils.js'; import { deleteTask, - getLatestOpenPairedTaskForChat, getTaskById, logTaskRun, storeChatMetadata, @@ -34,11 +33,6 @@ function enqueueOwnerAfterTerminalCiWatcher(args: { return; } - const pairedTask = getLatestOpenPairedTaskForChat(args.task.chat_jid); - if (!pairedTask) { - return; - } - const timestamp = new Date().toISOString(); const content = [ '[CI watcher completed]', @@ -46,7 +40,7 @@ function enqueueOwnerAfterTerminalCiWatcher(args: { ].join('\n'); storeChatMetadata(args.task.chat_jid, timestamp, undefined, 'discord', true); storeMessage({ - id: `watch-ci-completed:${args.task.id}:${Date.now()}`, + id: `watch-ci-completed:${args.task.id}`, chat_jid: args.task.chat_jid, sender: 'ci-watcher', sender_name: 'CI watcher', @@ -65,8 +59,6 @@ function enqueueOwnerAfterTerminalCiWatcher(args: { taskId: args.task.id, chatJid: args.task.chat_jid, groupFolder: args.task.group_folder, - pairedTaskId: pairedTask.id, - pairedTaskStatus: pairedTask.status, }, 'Queued owner follow-up after terminal CI watcher completion', ); @@ -107,7 +99,7 @@ export async function runGithubCiTask( if (check.terminal) { await statusTracker.update('completed'); - if (check.completionMessage) { + if (!statusTracker.enabled && check.completionMessage) { await sendScheduledMessage( deps, task.chat_jid, diff --git a/src/task-scheduler.test.ts b/src/task-scheduler.test.ts index 98936b2..298b727 100644 --- a/src/task-scheduler.test.ts +++ b/src/task-scheduler.test.ts @@ -1741,7 +1741,7 @@ Check the run. expect(updatedTask?.status_started_at).toBe('2026-03-19T07:00:00.000Z'); }); - it('sends a fresh watcher status message when the watcher completes', async () => { + it('edits the existing watcher status message when the watcher completes', async () => { vi.setSystemTime(new Date('2026-03-19T07:05:00.000Z')); createTask({ id: 'task-watch-status-completed', @@ -1780,18 +1780,20 @@ Check the run. await tracker.update('completed'); - expect(editTrackedMessage).not.toHaveBeenCalled(); - expect(sendTrackedMessage).toHaveBeenCalledWith( + expect(editTrackedMessage).toHaveBeenCalledWith( 'shared@g.us', + 'msg-old', expect.stringContaining(`${TASK_STATUS_MESSAGE_PREFIX}CI 감시 종료:`), ); - expect(sendTrackedMessage).toHaveBeenCalledWith( + expect(editTrackedMessage).toHaveBeenCalledWith( 'shared@g.us', + 'msg-old', expect.stringContaining('- 상태: 완료'), ); + expect(sendTrackedMessage).not.toHaveBeenCalled(); const updatedTask = getTaskById('task-watch-status-completed'); - expect(updatedTask?.status_message_id).toBe('msg-terminal'); + expect(updatedTask?.status_message_id).toBe('msg-old'); expect(updatedTask?.status_started_at).toBe('2026-03-19T07:00:00.000Z'); }); diff --git a/src/task-status-tracker.ts b/src/task-status-tracker.ts index 7331f07..e0dbfe3 100644 --- a/src/task-status-tracker.ts +++ b/src/task-status-tracker.ts @@ -58,8 +58,7 @@ export function createTaskStatusTracker( nextRun, })}`; - const sendFreshTerminalStatus = phase === 'completed'; - if (statusMessageId && !sendFreshTerminalStatus) { + if (statusMessageId) { try { await transport.editTrackedMessage!( task.chat_jid, @@ -82,18 +81,6 @@ export function createTaskStatusTracker( statusMessageId = null; persist(); } - } else if (statusMessageId && sendFreshTerminalStatus) { - logger.debug( - { - taskId: task.id, - chatJid: task.chat_jid, - statusMessageId, - phase, - }, - 'Sending terminal watcher status as a new message instead of editing the existing status message', - ); - statusMessageId = null; - persist(); } const nextMessageId = await transport.sendTrackedMessage!(