diff --git a/src/task-scheduler-github.test.ts b/src/task-scheduler-github.test.ts new file mode 100644 index 0000000..6a35355 --- /dev/null +++ b/src/task-scheduler-github.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { checkGitHubActionsRunMock } = vi.hoisted(() => ({ + checkGitHubActionsRunMock: vi.fn(), +})); + +vi.mock('./github-ci.js', () => ({ + checkGitHubActionsRun: checkGitHubActionsRunMock, + computeGitHubWatcherDelayMs: vi.fn(() => 15_000), + MAX_GITHUB_CONSECUTIVE_ERRORS: 5, + parseGitHubCiMetadata: vi.fn((raw: string | null | undefined) => { + if (!raw) return null; + return JSON.parse(raw); + }), + serializeGitHubCiMetadata: vi.fn((metadata: unknown) => + JSON.stringify(metadata), + ), +})); + +vi.mock('./logger.js', () => { + const mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: (_bindings?: Record) => mockLogger, + }; + return { + logger: mockLogger, + createScopedLogger: (_bindings?: Record) => mockLogger, + }; +}); + +import { + _initTestDatabase, + createPairedTask, + createTask, + getRecentChatMessages, + getTaskById, +} from './db.js'; +import { runGithubCiTask } from './task-scheduler-github.js'; +import type { SchedulerDependencies } from './task-scheduler-types.js'; +import type { ScheduledTask } from './types.js'; + +function buildSchedulerDeps(overrides: { + sendMessage?: SchedulerDependencies['sendMessage']; + enqueueMessageCheck?: (chatJid: string, ipcDir?: string) => void; +}): SchedulerDependencies { + return { + serviceAgentType: 'codex', + roomBindings: () => ({}), + getSessions: () => ({}), + queue: { + enqueueMessageCheck: overrides.enqueueMessageCheck ?? vi.fn(), + } as any, + onProcess: () => {}, + sendMessage: overrides.sendMessage ?? vi.fn(async () => {}), + }; +} + +function createReviewReadyPairedTask(now: string): void { + createPairedTask({ + id: 'paired-review-ready', + chat_jid: 'shared@g.us', + group_folder: 'shared-group', + owner_service_id: 'codex-main', + reviewer_service_id: 'claude-review', + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: 'codex', + title: null, + source_ref: null, + plan_notes: null, + review_requested_at: null, + round_trip_count: 1, + owner_failure_count: 0, + owner_step_done_streak: 0, + finalize_step_done_count: 0, + task_done_then_user_reopen_count: 0, + empty_step_done_streak: 0, + status: 'review_ready', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: now, + updated_at: now, + }); +} + +function createGithubWatcherTask(overrides: { + id?: string; + roomRole?: ScheduledTask['room_role']; + now: string; +}): ScheduledTask { + const taskId = overrides.id ?? 'task-github-owner-complete'; + createTask({ + id: taskId, + group_folder: 'shared-group', + chat_jid: 'shared@g.us', + agent_type: 'codex', + room_role: overrides.roomRole, + ci_provider: 'github', + ci_metadata: JSON.stringify({ + repo: 'owner/repo', + run_id: 777777, + }), + prompt: ` +[BACKGROUND CI WATCH] + +Watch target: +GitHub Actions run 777777 + +Check instructions: +Managed by host-driven watcher. + `.trim(), + schedule_type: 'interval', + schedule_value: '15000', + context_mode: 'isolated', + next_run: overrides.now, + status: 'active', + created_at: overrides.now, + }); + return getTaskById(taskId)!; +} + +describe('GitHub CI watcher scheduler', () => { + beforeEach(() => { + _initTestDatabase(); + checkGitHubActionsRunMock.mockReset(); + }); + + it('queues an owner turn after an owner CI watcher completes in a paired room', 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' }); + const sendMessage = vi.fn(async () => {}); + const enqueueMessageCheck = vi.fn(); + + await runGithubCiTask( + task, + buildSchedulerDeps({ sendMessage, enqueueMessageCheck }), + ); + + expect(sendMessage).toHaveBeenCalledWith( + 'shared@g.us', + 'CI 완료: GitHub Actions run 777777\n판정: 성공', + ); + 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 095ea7b..145e874 100644 --- a/src/task-scheduler-github.ts +++ b/src/task-scheduler-github.ts @@ -2,11 +2,15 @@ import { getErrorMessage } from './utils.js'; import { deleteTask, + getLatestOpenPairedTaskForChat, getTaskById, logTaskRun, + storeChatMetadata, + storeMessage, updateTask, updateTaskAfterRun, } from './db.js'; +import { resolveGroupIpcPath } from './group-folder.js'; import { logger } from './logger.js'; import { createTaskStatusTracker } from './task-status-tracker.js'; import { extractWatchCiTarget } from './task-watch-status.js'; @@ -21,6 +25,53 @@ import { sendScheduledMessage } from './task-scheduler-runtime.js'; import type { SchedulerDependencies } from './task-scheduler-types.js'; import type { ScheduledTask } from './types.js'; +function enqueueOwnerAfterTerminalCiWatcher(args: { + task: ScheduledTask; + deps: SchedulerDependencies; + completionText: string | null | undefined; +}): void { + if (args.task.room_role !== 'owner') { + return; + } + + const pairedTask = getLatestOpenPairedTaskForChat(args.task.chat_jid); + if (!pairedTask) { + return; + } + + const timestamp = new Date().toISOString(); + const content = [ + '[CI watcher completed]', + args.completionText?.trim() || 'CI watcher completed.', + ].join('\n'); + storeChatMetadata(args.task.chat_jid, timestamp, undefined, 'discord', true); + storeMessage({ + id: `watch-ci-completed:${args.task.id}:${Date.now()}`, + chat_jid: args.task.chat_jid, + sender: 'ci-watcher', + sender_name: 'CI watcher', + content, + timestamp, + is_from_me: false, + is_bot_message: false, + message_source_kind: 'trusted_external_bot', + }); + args.deps.queue.enqueueMessageCheck( + args.task.chat_jid, + resolveGroupIpcPath(args.task.group_folder), + ); + logger.info( + { + 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', + ); +} + export async function runGithubCiTask( task: ScheduledTask, deps: SchedulerDependencies, @@ -66,6 +117,11 @@ export async function runGithubCiTask( } deleteTask(task.id); completedAndDeleted = true; + enqueueOwnerAfterTerminalCiWatcher({ + task, + deps, + completionText: check.completionMessage ?? result, + }); logger.info( { taskId: task.id,