fix: keep CI watcher completion actionable

This commit is contained in:
Eyejoker
2026-05-29 04:00:28 +09:00
committed by GitHub
parent 6b51315eb1
commit 96b800e812
5 changed files with 118 additions and 30 deletions

View File

@@ -64,7 +64,10 @@ describe('GitHub watcher delivery identity', () => {
const task = getTaskById('task-github-codex-paired-complete'); const task = getTaskById('task-github-codex-paired-complete');
expect(task).toBeDefined(); 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); await runGithubCiTask(task!, deps);

View File

@@ -44,6 +44,8 @@ import type { ScheduledTask } from './types.js';
function buildSchedulerDeps(overrides: { function buildSchedulerDeps(overrides: {
sendMessage?: SchedulerDependencies['sendMessage']; sendMessage?: SchedulerDependencies['sendMessage'];
sendTrackedMessage?: SchedulerDependencies['sendTrackedMessage'];
editTrackedMessage?: SchedulerDependencies['editTrackedMessage'];
enqueueMessageCheck?: (chatJid: string, ipcDir?: string) => void; enqueueMessageCheck?: (chatJid: string, ipcDir?: string) => void;
}): SchedulerDependencies { }): SchedulerDependencies {
return { return {
@@ -55,6 +57,8 @@ function buildSchedulerDeps(overrides: {
} as any, } as any,
onProcess: () => {}, onProcess: () => {},
sendMessage: overrides.sendMessage ?? vi.fn(async () => {}), sendMessage: overrides.sendMessage ?? vi.fn(async () => {}),
sendTrackedMessage: overrides.sendTrackedMessage,
editTrackedMessage: overrides.editTrackedMessage,
}; };
} }
@@ -90,6 +94,8 @@ function createReviewReadyPairedTask(now: string): void {
function createGithubWatcherTask(overrides: { function createGithubWatcherTask(overrides: {
id?: string; id?: string;
roomRole?: ScheduledTask['room_role']; roomRole?: ScheduledTask['room_role'];
statusMessageId?: string | null;
statusStartedAt?: string | null;
now: string; now: string;
}): ScheduledTask { }): ScheduledTask {
const taskId = overrides.id ?? 'task-github-owner-complete'; const taskId = overrides.id ?? 'task-github-owner-complete';
@@ -118,6 +124,8 @@ Managed by host-driven watcher.
context_mode: 'isolated', context_mode: 'isolated',
next_run: overrides.now, next_run: overrides.now,
status: 'active', status: 'active',
status_message_id: overrides.statusMessageId,
status_started_at: overrides.statusStartedAt,
created_at: overrides.now, created_at: overrides.now,
}); });
return getTaskById(taskId)!; return getTaskById(taskId)!;
@@ -164,4 +172,100 @@ describe('GitHub CI watcher scheduler', () => {
}); });
expect(getTaskById(task.id)).toBeUndefined(); 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();
});
}); });

View File

@@ -2,7 +2,6 @@ import { getErrorMessage } from './utils.js';
import { import {
deleteTask, deleteTask,
getLatestOpenPairedTaskForChat,
getTaskById, getTaskById,
logTaskRun, logTaskRun,
storeChatMetadata, storeChatMetadata,
@@ -34,11 +33,6 @@ function enqueueOwnerAfterTerminalCiWatcher(args: {
return; return;
} }
const pairedTask = getLatestOpenPairedTaskForChat(args.task.chat_jid);
if (!pairedTask) {
return;
}
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const content = [ const content = [
'[CI watcher completed]', '[CI watcher completed]',
@@ -46,7 +40,7 @@ function enqueueOwnerAfterTerminalCiWatcher(args: {
].join('\n'); ].join('\n');
storeChatMetadata(args.task.chat_jid, timestamp, undefined, 'discord', true); storeChatMetadata(args.task.chat_jid, timestamp, undefined, 'discord', true);
storeMessage({ storeMessage({
id: `watch-ci-completed:${args.task.id}:${Date.now()}`, id: `watch-ci-completed:${args.task.id}`,
chat_jid: args.task.chat_jid, chat_jid: args.task.chat_jid,
sender: 'ci-watcher', sender: 'ci-watcher',
sender_name: 'CI watcher', sender_name: 'CI watcher',
@@ -65,8 +59,6 @@ function enqueueOwnerAfterTerminalCiWatcher(args: {
taskId: args.task.id, taskId: args.task.id,
chatJid: args.task.chat_jid, chatJid: args.task.chat_jid,
groupFolder: args.task.group_folder, groupFolder: args.task.group_folder,
pairedTaskId: pairedTask.id,
pairedTaskStatus: pairedTask.status,
}, },
'Queued owner follow-up after terminal CI watcher completion', 'Queued owner follow-up after terminal CI watcher completion',
); );
@@ -107,7 +99,7 @@ export async function runGithubCiTask(
if (check.terminal) { if (check.terminal) {
await statusTracker.update('completed'); await statusTracker.update('completed');
if (check.completionMessage) { if (!statusTracker.enabled && check.completionMessage) {
await sendScheduledMessage( await sendScheduledMessage(
deps, deps,
task.chat_jid, task.chat_jid,

View File

@@ -1741,7 +1741,7 @@ Check the run.
expect(updatedTask?.status_started_at).toBe('2026-03-19T07:00:00.000Z'); 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')); vi.setSystemTime(new Date('2026-03-19T07:05:00.000Z'));
createTask({ createTask({
id: 'task-watch-status-completed', id: 'task-watch-status-completed',
@@ -1780,18 +1780,20 @@ Check the run.
await tracker.update('completed'); await tracker.update('completed');
expect(editTrackedMessage).not.toHaveBeenCalled(); expect(editTrackedMessage).toHaveBeenCalledWith(
expect(sendTrackedMessage).toHaveBeenCalledWith(
'shared@g.us', 'shared@g.us',
'msg-old',
expect.stringContaining(`${TASK_STATUS_MESSAGE_PREFIX}CI 감시 종료:`), expect.stringContaining(`${TASK_STATUS_MESSAGE_PREFIX}CI 감시 종료:`),
); );
expect(sendTrackedMessage).toHaveBeenCalledWith( expect(editTrackedMessage).toHaveBeenCalledWith(
'shared@g.us', 'shared@g.us',
'msg-old',
expect.stringContaining('- 상태: 완료'), expect.stringContaining('- 상태: 완료'),
); );
expect(sendTrackedMessage).not.toHaveBeenCalled();
const updatedTask = getTaskById('task-watch-status-completed'); 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'); expect(updatedTask?.status_started_at).toBe('2026-03-19T07:00:00.000Z');
}); });

View File

@@ -58,8 +58,7 @@ export function createTaskStatusTracker(
nextRun, nextRun,
})}`; })}`;
const sendFreshTerminalStatus = phase === 'completed'; if (statusMessageId) {
if (statusMessageId && !sendFreshTerminalStatus) {
try { try {
await transport.editTrackedMessage!( await transport.editTrackedMessage!(
task.chat_jid, task.chat_jid,
@@ -82,18 +81,6 @@ export function createTaskStatusTracker(
statusMessageId = null; statusMessageId = null;
persist(); 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!( const nextMessageId = await transport.sendTrackedMessage!(