diff --git a/runners/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts index 2618dbc..af69f29 100644 --- a/runners/agent-runner/src/ipc-mcp-stdio.ts +++ b/runners/agent-runner/src/ipc-mcp-stdio.ts @@ -169,21 +169,53 @@ server.tool( { target: z .string() + .optional() .describe( 'What to watch, for example "PR #123 checks" or "GitHub Actions run 987654321".', ), check_instructions: z .string() + .optional() .describe( 'Exact steps or commands to check status and what details matter when it finishes.', ), + ci_provider: z + .enum(['github']) + .optional() + .describe( + 'Optional structured CI provider selector. When set to "github", the watcher can use a host-driven fast path.', + ), + ci_repo: z + .string() + .optional() + .describe( + 'Optional GitHub repository in "owner/repo" format for host-driven GitHub watchers.', + ), + ci_run_id: z + .number() + .int() + .positive() + .optional() + .describe( + 'Optional GitHub Actions run ID for host-driven GitHub watchers.', + ), + ci_pr_number: z + .number() + .int() + .positive() + .optional() + .describe( + 'Optional PR number reserved for future GitHub PR-check watchers.', + ), poll_interval_seconds: z .number() .int() - .min(30) + .min(10) .max(3600) - .default(60) - .describe('How often to poll in seconds. Default 60, minimum 30.'), + .optional() + .describe( + 'How often to poll in seconds. Defaults to 60 for generic watchers and 15 for GitHub host-driven watchers. Generic watchers require 30+, GitHub host-driven watchers allow 10+.', + ), context_mode: z .enum(['group', 'isolated']) .default(DEFAULT_WATCH_CI_CONTEXT_MODE) @@ -198,9 +230,59 @@ server.tool( ), }, async (args) => { + const isGitHubWatcher = args.ci_provider === 'github'; + const target = + args.target || + (isGitHubWatcher && args.ci_run_id + ? `GitHub Actions run ${args.ci_run_id}` + : undefined); + const checkInstructions = + args.check_instructions || + (isGitHubWatcher + ? 'This watcher is handled by the host-driven GitHub Actions path. Do not rely on the prompt for execution.' + : undefined); + + if (!target) { + return { + content: [ + { + type: 'text' as const, + text: 'target is required unless structured GitHub watcher fields are provided.', + }, + ], + isError: true, + }; + } + + if (!checkInstructions) { + return { + content: [ + { + type: 'text' as const, + text: 'check_instructions is required for generic CI watchers.', + }, + ], + isError: true, + }; + } + + if (isGitHubWatcher && (!args.ci_repo || !args.ci_run_id)) { + return { + content: [ + { + type: 'text' as const, + text: 'GitHub host-driven watchers require both ci_repo and ci_run_id.', + }, + ], + isError: true, + }; + } + let pollSeconds: number; try { - pollSeconds = normalizeWatchCiIntervalSeconds(args.poll_interval_seconds); + pollSeconds = normalizeWatchCiIntervalSeconds(args.poll_interval_seconds, { + ciProvider: args.ci_provider, + }); } catch (error) { return { content: [ @@ -218,8 +300,8 @@ server.tool( const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const prompt = buildCiWatchPrompt({ taskId, - target: args.target, - checkInstructions: args.check_instructions, + target, + checkInstructions, }); const data = { @@ -229,6 +311,13 @@ server.tool( schedule_type: 'interval' as const, schedule_value: String(pollSeconds * 1000), context_mode: args.context_mode || DEFAULT_WATCH_CI_CONTEXT_MODE, + ci_provider: args.ci_provider, + ci_metadata: isGitHubWatcher + ? JSON.stringify({ + repo: args.ci_repo, + run_id: args.ci_run_id, + }) + : undefined, targetJid, createdBy: groupFolder, timestamp: new Date().toISOString(), diff --git a/runners/agent-runner/src/watch-ci.ts b/runners/agent-runner/src/watch-ci.ts index b0ce26b..4784093 100644 --- a/runners/agent-runner/src/watch-ci.ts +++ b/runners/agent-runner/src/watch-ci.ts @@ -1,17 +1,34 @@ export const DEFAULT_WATCH_CI_INTERVAL_SECONDS = 60; export const MIN_WATCH_CI_INTERVAL_SECONDS = 30; +export const DEFAULT_GITHUB_WATCH_CI_INTERVAL_SECONDS = 15; +export const MIN_GITHUB_WATCH_CI_INTERVAL_SECONDS = 10; export const MAX_WATCH_CI_INTERVAL_SECONDS = 3600; export const DEFAULT_WATCH_CI_CONTEXT_MODE = 'isolated'; +export interface NormalizeWatchCiIntervalOptions { + ciProvider?: 'github'; +} + export interface BuildCiWatchPromptArgs { taskId: string; target: string; checkInstructions: string; } -export function normalizeWatchCiIntervalSeconds(seconds?: number): number { +export function normalizeWatchCiIntervalSeconds( + seconds?: number, + options?: NormalizeWatchCiIntervalOptions, +): number { + const isGitHub = options?.ciProvider === 'github'; + const defaultSeconds = isGitHub + ? DEFAULT_GITHUB_WATCH_CI_INTERVAL_SECONDS + : DEFAULT_WATCH_CI_INTERVAL_SECONDS; + const minSeconds = isGitHub + ? MIN_GITHUB_WATCH_CI_INTERVAL_SECONDS + : MIN_WATCH_CI_INTERVAL_SECONDS; + if (seconds === undefined) { - return DEFAULT_WATCH_CI_INTERVAL_SECONDS; + return defaultSeconds; } if (!Number.isInteger(seconds)) { @@ -19,11 +36,11 @@ export function normalizeWatchCiIntervalSeconds(seconds?: number): number { } if ( - seconds < MIN_WATCH_CI_INTERVAL_SECONDS || + seconds < minSeconds || seconds > MAX_WATCH_CI_INTERVAL_SECONDS ) { throw new Error( - `poll_interval_seconds must be between ${MIN_WATCH_CI_INTERVAL_SECONDS} and ${MAX_WATCH_CI_INTERVAL_SECONDS}.`, + `poll_interval_seconds must be between ${minSeconds} and ${MAX_WATCH_CI_INTERVAL_SECONDS}.`, ); } diff --git a/runners/agent-runner/test/watch-ci.test.ts b/runners/agent-runner/test/watch-ci.test.ts index f838526..ddd1bdd 100644 --- a/runners/agent-runner/test/watch-ci.test.ts +++ b/runners/agent-runner/test/watch-ci.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { buildCiWatchPrompt, DEFAULT_WATCH_CI_CONTEXT_MODE, + DEFAULT_GITHUB_WATCH_CI_INTERVAL_SECONDS, normalizeWatchCiIntervalSeconds, } from '../src/watch-ci.js'; @@ -35,6 +36,12 @@ describe('watch-ci helpers', () => { expect(normalizeWatchCiIntervalSeconds()).toBe(60); expect(normalizeWatchCiIntervalSeconds(30)).toBe(30); expect(normalizeWatchCiIntervalSeconds(600)).toBe(600); + expect( + normalizeWatchCiIntervalSeconds(undefined, { ciProvider: 'github' }), + ).toBe(DEFAULT_GITHUB_WATCH_CI_INTERVAL_SECONDS); + expect(normalizeWatchCiIntervalSeconds(10, { ciProvider: 'github' })).toBe( + 10, + ); }); it('rejects invalid poll intervals', () => { @@ -45,5 +52,8 @@ describe('watch-ci helpers', () => { /between 30 and 3600/i, ); expect(() => normalizeWatchCiIntervalSeconds(30.5)).toThrow(/integer/i); + expect(() => + normalizeWatchCiIntervalSeconds(9, { ciProvider: 'github' }), + ).toThrow(/between 10 and 3600/i); }); }); diff --git a/src/db.test.ts b/src/db.test.ts index b8dc4d4..4e27038 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -400,6 +400,36 @@ describe('task CRUD', () => { expect(getTaskById('task-2')!.status).toBe('paused'); }); + it('stores and updates GitHub CI task metadata', () => { + createTask({ + id: 'task-github', + group_folder: 'main', + chat_jid: 'group@g.us', + ci_provider: 'github', + ci_metadata: JSON.stringify({ repo: 'owner/repo', run_id: 123456 }), + prompt: 'github watcher', + schedule_type: 'interval', + schedule_value: '15000', + context_mode: 'isolated', + next_run: '2024-06-01T00:00:00.000Z', + status: 'active', + created_at: '2024-01-01T00:00:00.000Z', + }); + + expect(getTaskById('task-github')?.ci_provider).toBe('github'); + expect(getTaskById('task-github')?.ci_metadata).toContain('owner/repo'); + + updateTask('task-github', { + ci_metadata: JSON.stringify({ + repo: 'owner/repo', + run_id: 123456, + poll_count: 2, + }), + }); + + expect(getTaskById('task-github')?.ci_metadata).toContain('"poll_count":2'); + }); + it('deletes a task and its run logs', () => { createTask({ id: 'task-3', diff --git a/src/db.ts b/src/db.ts index ca10965..8ba9535 100644 --- a/src/db.ts +++ b/src/db.ts @@ -137,6 +137,8 @@ function createSchema(database: Database.Database): void { group_folder TEXT NOT NULL, chat_jid TEXT NOT NULL, agent_type TEXT, + ci_provider TEXT, + ci_metadata TEXT, max_duration_ms INTEGER, status_message_id TEXT, status_started_at TEXT, @@ -205,6 +207,18 @@ function createSchema(database: Database.Database): void { /* column already exists */ } + try { + database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN ci_provider TEXT`); + } catch { + /* column already exists */ + } + + try { + database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN ci_metadata TEXT`); + } catch { + /* column already exists */ + } + try { database.exec( `ALTER TABLE scheduled_tasks ADD COLUMN max_duration_ms INTEGER`, @@ -877,11 +891,15 @@ export function createTask( | 'last_run' | 'last_result' | 'agent_type' + | 'ci_provider' + | 'ci_metadata' | 'max_duration_ms' | 'status_message_id' | 'status_started_at' > & { agent_type?: AgentType | null; + ci_provider?: ScheduledTask['ci_provider']; + ci_metadata?: string | null; max_duration_ms?: number | null; status_message_id?: string | null; status_started_at?: string | null; @@ -889,14 +907,16 @@ export function createTask( ): void { db.prepare( ` - INSERT INTO scheduled_tasks (id, group_folder, chat_jid, agent_type, max_duration_ms, status_message_id, status_started_at, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO scheduled_tasks (id, group_folder, chat_jid, agent_type, ci_provider, ci_metadata, max_duration_ms, status_message_id, status_started_at, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ).run( task.id, task.group_folder, task.chat_jid, task.agent_type || SERVICE_AGENT_TYPE, + task.ci_provider ?? null, + task.ci_metadata ?? null, task.max_duration_ms ?? null, task.status_message_id || null, task.status_started_at || null, @@ -960,6 +980,7 @@ export function updateTask( | 'next_run' | 'status' | 'suspended_until' + | 'ci_metadata' > >, ): void { @@ -990,6 +1011,10 @@ export function updateTask( fields.push('suspended_until = ?'); values.push(updates.suspended_until); } + if (updates.ci_metadata !== undefined) { + fields.push('ci_metadata = ?'); + values.push(updates.ci_metadata); + } if (fields.length === 0) return; diff --git a/src/github-ci.test.ts b/src/github-ci.test.ts new file mode 100644 index 0000000..a4a4bd0 --- /dev/null +++ b/src/github-ci.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { execFileMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), +})); + +vi.mock('child_process', () => ({ + execFile: execFileMock, +})); + +import { + checkGitHubActionsRun, + parseGitHubCiMetadata, + serializeGitHubCiMetadata, +} from './github-ci.js'; + +describe('github-ci helpers', () => { + afterEach(() => { + execFileMock.mockReset(); + }); + + it('round-trips GitHub CI metadata', () => { + const raw = serializeGitHubCiMetadata({ + repo: 'owner/repo', + run_id: 123456, + poll_count: 1, + }); + + expect(parseGitHubCiMetadata(raw)).toEqual({ + repo: 'owner/repo', + run_id: 123456, + poll_count: 1, + consecutive_errors: undefined, + }); + expect(parseGitHubCiMetadata('{"repo":"","run_id":0}')).toBeNull(); + }); + + it('returns non-terminal status for in-progress runs', async () => { + execFileMock.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + cb( + null, + JSON.stringify({ + status: 'in_progress', + conclusion: null, + }), + '', + ); + }, + ); + + await expect( + checkGitHubActionsRun({ + prompt: ` +[BACKGROUND CI WATCH] + +Watch target: +GitHub Actions run 123456 + +Task ID: +task-github + +Check instructions: +Managed by host-driven watcher. + `.trim(), + ci_metadata: JSON.stringify({ + repo: 'owner/repo', + run_id: 123456, + }), + }), + ).resolves.toEqual({ + terminal: false, + resultSummary: 'GitHub Actions run 123456 is in_progress', + }); + }); + + it('renders a concise completion message for terminal runs', async () => { + execFileMock + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + cb( + null, + JSON.stringify({ + status: 'completed', + conclusion: 'failure', + name: 'CI', + html_url: 'https://github.com/owner/repo/actions/runs/654321', + head_branch: 'main', + }), + '', + ); + }, + ) + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + cb( + null, + JSON.stringify({ + jobs: [ + { name: 'build', conclusion: 'success' }, + { name: 'test', conclusion: 'failure' }, + ], + }), + '', + ); + }, + ); + + const result = await checkGitHubActionsRun({ + prompt: ` +[BACKGROUND CI WATCH] + +Watch target: +GitHub Actions run 654321 + +Task ID: +task-github + +Check instructions: +Managed by host-driven watcher. + `.trim(), + ci_metadata: JSON.stringify({ + repo: 'owner/repo', + run_id: 654321, + }), + }); + + expect(result.terminal).toBe(true); + expect(result.resultSummary).toContain('실패'); + expect(result.completionMessage).toContain('CI 완료: GitHub Actions run 654321'); + expect(result.completionMessage).toContain('판정: 실패'); + expect(result.completionMessage).toContain('- 실패 job: test'); + }); +}); diff --git a/src/github-ci.ts b/src/github-ci.ts new file mode 100644 index 0000000..8c0aa98 --- /dev/null +++ b/src/github-ci.ts @@ -0,0 +1,199 @@ +import { execFile } from 'child_process'; + +import { extractWatchCiTarget } from './task-watch-status.js'; +import type { ScheduledTask } from './types.js'; + +export interface GitHubCiMetadata { + repo: string; + run_id: number; + poll_count?: number; + consecutive_errors?: number; +} + +interface GitHubActionsRunResponse { + status?: string | null; + conclusion?: string | null; + name?: string | null; + display_title?: string | null; + html_url?: string | null; + head_branch?: string | null; + head_sha?: string | null; + event?: string | null; +} + +interface GitHubActionsJobsResponse { + jobs?: Array<{ + name?: string | null; + conclusion?: string | null; + }>; +} + +export interface GitHubRunCheckResult { + terminal: boolean; + resultSummary: string; + completionMessage?: string; +} + +function execGhApi(args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + 'gh', + ['api', ...args], + { + maxBuffer: 10 * 1024 * 1024, + env: process.env, + }, + (error, stdout, stderr) => { + if (error) { + const details = stderr?.trim() || stdout?.trim() || error.message; + reject(new Error(`gh api failed: ${details}`)); + return; + } + resolve(stdout); + }, + ); + }); +} + +export function parseGitHubCiMetadata( + raw: string | null | undefined, +): GitHubCiMetadata | null { + if (!raw) return null; + + let parsed: Partial; + try { + parsed = JSON.parse(raw) as Partial; + } catch { + return null; + } + if (typeof parsed.repo !== 'string' || parsed.repo.trim() === '') { + return null; + } + + const runId = Number(parsed.run_id); + if (!Number.isInteger(runId) || runId <= 0) { + return null; + } + + return { + repo: parsed.repo, + run_id: runId, + poll_count: + Number.isInteger(parsed.poll_count) && parsed.poll_count! >= 0 + ? parsed.poll_count + : undefined, + consecutive_errors: + Number.isInteger(parsed.consecutive_errors) && + parsed.consecutive_errors! >= 0 + ? parsed.consecutive_errors + : undefined, + }; +} + +export function serializeGitHubCiMetadata( + metadata: GitHubCiMetadata, +): string { + return JSON.stringify(metadata); +} + +function formatConclusionLabel(conclusion: string | null | undefined): string { + switch (conclusion) { + case 'success': + return '성공'; + case 'failure': + return '실패'; + case 'cancelled': + return '취소됨'; + case 'timed_out': + return '시간 초과'; + case 'action_required': + return '조치 필요'; + case 'neutral': + return '중립'; + case 'skipped': + return '건너뜀'; + case 'stale': + return '오래됨'; + default: + return conclusion || '완료'; + } +} + +async function fetchFailedJobs( + metadata: GitHubCiMetadata, +): Promise { + const stdout = await execGhApi([ + `repos/${metadata.repo}/actions/runs/${metadata.run_id}/jobs?per_page=100`, + ]); + const parsed = JSON.parse(stdout) as GitHubActionsJobsResponse; + return (parsed.jobs || []) + .filter( + (job) => + job.conclusion && + ['failure', 'cancelled', 'timed_out', 'startup_failure'].includes( + job.conclusion, + ), + ) + .map((job) => job.name?.trim()) + .filter((name): name is string => Boolean(name)) + .slice(0, 3); +} + +export async function checkGitHubActionsRun( + task: Pick, +): Promise { + const metadata = parseGitHubCiMetadata(task.ci_metadata); + if (!metadata) { + throw new Error('Task is missing valid GitHub CI metadata'); + } + + const stdout = await execGhApi([ + `repos/${metadata.repo}/actions/runs/${metadata.run_id}`, + ]); + const run = JSON.parse(stdout) as GitHubActionsRunResponse; + const status = run.status || 'unknown'; + + if (status !== 'completed') { + return { + terminal: false, + resultSummary: `GitHub Actions run ${metadata.run_id} is ${status}`, + }; + } + + let failedJobs: string[] = []; + try { + failedJobs = await fetchFailedJobs(metadata); + } catch { + failedJobs = []; + } + + const target = + extractWatchCiTarget(task.prompt) || + `GitHub Actions run ${metadata.run_id}`; + const conclusionLabel = formatConclusionLabel(run.conclusion); + + const lines = [ + `CI 완료: ${target}`, + `판정: ${conclusionLabel}`, + `- 저장소: ${metadata.repo}`, + ]; + + if (run.name) { + lines.push(`- 워크플로: ${run.name}`); + } + if (run.head_branch) { + lines.push(`- 브랜치: ${run.head_branch}`); + } + if (failedJobs.length > 0) { + lines.push(`- 실패 job: ${failedJobs.join(', ')}`); + } + if (run.html_url) { + lines.push(`- 링크: ${run.html_url}`); + } + + return { + terminal: true, + resultSummary: `${conclusionLabel}: ${metadata.repo} run ${metadata.run_id}`, + completionMessage: lines.join('\n'), + }; +} diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index 3434593..79c6cf3 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -588,6 +588,43 @@ Check the run. expect(deps.nudgeScheduler).toHaveBeenCalledTimes(1); }); + it('persists structured GitHub watch metadata for host-driven watchers', async () => { + await processTaskIpc( + { + type: 'schedule_task', + prompt: ` +[BACKGROUND CI WATCH] + +Watch target: +GitHub Actions run 654321 + +Task ID: +task-watch-github + +Check instructions: +Managed by host-driven watcher. + `.trim(), + schedule_type: 'interval', + schedule_value: '15000', + ci_provider: 'github', + ci_metadata: JSON.stringify({ + repo: 'owner/repo', + run_id: 654321, + }), + targetJid: 'other@g.us', + }, + 'whatsapp_main', + true, + deps, + ); + + const tasks = getAllTasks(); + expect(tasks).toHaveLength(1); + expect(tasks[0].ci_provider).toBe('github'); + expect(tasks[0].ci_metadata).toContain('owner/repo'); + expect(tasks[0].max_duration_ms).toBe(DEFAULT_WATCH_CI_MAX_DURATION_MS); + }); + it('does not assign a max duration to regular scheduled tasks', async () => { await processTaskIpc( { diff --git a/src/ipc.ts b/src/ipc.ts index d797f49..12aa508 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -286,6 +286,8 @@ export async function processTaskIpc( schedule_type?: string; schedule_value?: string; context_mode?: string; + ci_provider?: 'github'; + ci_metadata?: string; groupFolder?: string; chatJid?: string; targetJid?: string; @@ -404,6 +406,8 @@ export async function processTaskIpc( group_folder: targetFolder, chat_jid: resolvedTargetJid, agent_type: targetGroupEntry.agentType || SERVICE_AGENT_TYPE, + ci_provider: data.ci_provider ?? null, + ci_metadata: data.ci_metadata ?? null, max_duration_ms: isWatchCiTask({ prompt: data.prompt }) ? DEFAULT_WATCH_CI_MAX_DURATION_MS : null, diff --git a/src/task-scheduler.test.ts b/src/task-scheduler.test.ts index 130a869..f6eba46 100644 --- a/src/task-scheduler.test.ts +++ b/src/task-scheduler.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { runAgentProcessMock, writeTasksSnapshotMock, loggerDebugMock } = +const { + runAgentProcessMock, + writeTasksSnapshotMock, + loggerDebugMock, + checkGitHubActionsRunMock, +} = vi.hoisted(() => ({ runAgentProcessMock: vi.fn(async () => ({ status: 'success' as const, @@ -8,6 +13,16 @@ const { runAgentProcessMock, writeTasksSnapshotMock, loggerDebugMock } = })), writeTasksSnapshotMock: vi.fn(), loggerDebugMock: vi.fn(), + checkGitHubActionsRunMock: vi.fn( + async (): Promise<{ + terminal: boolean; + resultSummary: string; + completionMessage?: string; + }> => ({ + terminal: false, + resultSummary: 'GitHub Actions run 123 is in_progress', + }), + ), })); vi.mock('./provider-fallback.js', () => ({ @@ -90,6 +105,10 @@ vi.mock('./logger.js', () => ({ }, })); +vi.mock('./github-ci.js', () => ({ + checkGitHubActionsRun: checkGitHubActionsRunMock, +})); + import { _initTestDatabase, createTask, getTaskById } from './db.js'; import * as providerFallback from './provider-fallback.js'; import * as codexTokenRotation from './codex-token-rotation.js'; @@ -113,6 +132,11 @@ describe('task scheduler', () => { runAgentProcessMock.mockClear(); writeTasksSnapshotMock.mockClear(); loggerDebugMock.mockClear(); + checkGitHubActionsRunMock.mockClear(); + checkGitHubActionsRunMock.mockResolvedValue({ + terminal: false, + resultSummary: 'GitHub Actions run 123 is in_progress', + }); vi.mocked(providerFallback.markPrimaryCooldown).mockClear(); vi.mocked(providerFallback.getActiveProvider).mockResolvedValue('claude'); vi.mocked(providerFallback.isFallbackEnabled).mockReturnValue(true); @@ -838,6 +862,148 @@ Check the run. ); }); + it('uses the host-driven GitHub watcher path without spawning an agent', async () => { + const dueAt = new Date(Date.now() - 60_000).toISOString(); + createTask({ + id: 'task-github-running', + group_folder: 'shared-group', + chat_jid: 'shared@g.us', + agent_type: 'codex', + ci_provider: 'github', + ci_metadata: JSON.stringify({ + repo: 'owner/repo', + run_id: 123456, + }), + prompt: ` +[BACKGROUND CI WATCH] + +Watch target: +GitHub Actions run 123456 + +Task ID: +task-github-running + +Check instructions: +Managed by host-driven watcher. + `.trim(), + schedule_type: 'interval', + schedule_value: '15000', + context_mode: 'isolated', + next_run: dueAt, + status: 'active', + created_at: '2026-02-22T00:00:00.000Z', + }); + + const enqueueTask = vi.fn( + async (_groupJid: string, _taskId: string, fn: () => Promise) => { + await fn(); + }, + ); + + startSchedulerLoop({ + serviceAgentType: 'codex', + registeredGroups: () => ({ + 'shared@g.us': { + name: 'Shared', + folder: 'shared-group', + trigger: '@Codex', + added_at: '2026-02-22T00:00:00.000Z', + agentType: 'codex', + }, + }), + getSessions: () => ({}), + queue: { enqueueTask } as any, + onProcess: () => {}, + sendMessage: async () => {}, + }); + + await vi.advanceTimersByTimeAsync(10); + + expect(checkGitHubActionsRunMock).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'task-github-running', + ci_provider: 'github', + }), + ); + expect(runAgentProcessMock).not.toHaveBeenCalled(); + + const task = getTaskById('task-github-running'); + expect(task).toBeDefined(); + expect(task?.next_run).not.toBe(dueAt); + }); + + it('sends a final message and deletes terminal GitHub watcher tasks', async () => { + checkGitHubActionsRunMock.mockResolvedValueOnce({ + terminal: true, + resultSummary: '성공: owner/repo run 654321', + completionMessage: 'CI 완료: GitHub Actions run 654321\n판정: 성공', + }); + + const dueAt = new Date(Date.now() - 60_000).toISOString(); + createTask({ + id: 'task-github-complete', + group_folder: 'shared-group', + chat_jid: 'shared@g.us', + agent_type: 'codex', + ci_provider: 'github', + ci_metadata: JSON.stringify({ + repo: 'owner/repo', + run_id: 654321, + }), + prompt: ` +[BACKGROUND CI WATCH] + +Watch target: +GitHub Actions run 654321 + +Task ID: +task-github-complete + +Check instructions: +Managed by host-driven watcher. + `.trim(), + schedule_type: 'interval', + schedule_value: '15000', + context_mode: 'isolated', + next_run: dueAt, + status: 'active', + created_at: '2026-02-22T00:00:00.000Z', + }); + + const sendMessage = vi.fn(async () => {}); + const enqueueTask = vi.fn( + async (_groupJid: string, _taskId: string, fn: () => Promise) => { + await fn(); + }, + ); + + startSchedulerLoop({ + serviceAgentType: 'codex', + registeredGroups: () => ({ + 'shared@g.us': { + name: 'Shared', + folder: 'shared-group', + trigger: '@Codex', + added_at: '2026-02-22T00:00:00.000Z', + agentType: 'codex', + }, + }), + getSessions: () => ({}), + queue: { enqueueTask } as any, + onProcess: () => {}, + sendMessage, + }); + + await vi.advanceTimersByTimeAsync(10); + + expect(sendMessage).toHaveBeenCalledWith( + 'shared@g.us', + 'CI 완료: GitHub Actions run 654321\n판정: 성공', + ); + expect(runAgentProcessMock).not.toHaveBeenCalled(); + expect(getTaskById('task-github-complete')).toBeUndefined(); + }); + it('deletes active tasks that exceed max duration before they run', async () => { const enqueueTask = vi.fn(); createTask({ diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index 872df38..fa51dfb 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -71,9 +71,11 @@ import { import { getTaskQueueJid, getTaskRuntimeTaskId, + isGitHubCiTask, shouldUseTaskScopedSession, } from './task-watch-status.js'; import { AgentType, RegisteredGroup, ScheduledTask } from './types.js'; +import { checkGitHubActionsRun } from './github-ci.js'; export { extractWatchCiTarget, getTaskQueueJid, @@ -779,6 +781,94 @@ async function runTask( updateTaskAfterRun(task.id, nextRun, resultSummary); } +async function runGithubCiTask( + task: ScheduledTask, + deps: SchedulerDependencies, +): Promise { + const startTime = Date.now(); + let result: string | null = null; + let error: string | null = null; + let completedAndDeleted = false; + const statusTracker = createTaskStatusTracker(task, { + sendTrackedMessage: deps.sendTrackedMessage, + editTrackedMessage: deps.editTrackedMessage, + }); + + try { + await statusTracker.update('checking'); + + const check = await checkGitHubActionsRun(task); + result = check.resultSummary; + + if (check.terminal) { + await statusTracker.update('completed'); + if (check.completionMessage) { + await deps.sendMessage(task.chat_jid, check.completionMessage); + } + deleteTask(task.id); + completedAndDeleted = true; + logger.info( + { + taskId: task.id, + groupFolder: task.group_folder, + durationMs: Date.now() - startTime, + }, + 'GitHub CI watcher completed and deleted', + ); + } else { + logger.info( + { + taskId: task.id, + groupFolder: task.group_folder, + result, + }, + 'GitHub CI watcher checked non-terminal run', + ); + } + } catch (err) { + error = getErrorMessage(err); + logger.error({ taskId: task.id, error }, 'GitHub CI watcher failed'); + } + + const durationMs = Date.now() - startTime; + const currentTask = getTaskById(task.id); + const nextRun = currentTask ? computeNextRun(task) : null; + + if (!currentTask) { + if (!completedAndDeleted) { + await statusTracker.update('completed'); + } + logger.debug( + { taskId: task.id }, + 'GitHub CI watcher deleted during execution, skipping persistence', + ); + return; + } + + if (error) { + await statusTracker.update('retrying', nextRun); + } else if (nextRun) { + await statusTracker.update('waiting', nextRun); + } else { + await statusTracker.update('completed'); + } + + logTaskRun({ + task_id: task.id, + run_at: new Date().toISOString(), + duration_ms: durationMs, + status: error ? 'error' : 'success', + result, + error, + }); + + updateTaskAfterRun( + task.id, + nextRun, + error ? `Error: ${error}` : result || 'Completed', + ); +} + let schedulerRunning = false; let schedulerTimer: ReturnType | null = null; let schedulerLoopFn: (() => Promise) | null = null; @@ -864,7 +954,10 @@ export function startSchedulerLoop(deps: SchedulerDependencies): void { deps.queue.enqueueTask( getTaskQueueJid(currentTask), currentTask.id, - () => runTask(currentTask, deps), + () => + isGitHubCiTask(currentTask) + ? runGithubCiTask(currentTask, deps) + : runTask(currentTask, deps), ); } } catch (err) { diff --git a/src/task-watch-status.ts b/src/task-watch-status.ts index edac995..aa277af 100644 --- a/src/task-watch-status.ts +++ b/src/task-watch-status.ts @@ -16,6 +16,12 @@ export function isWatchCiTask(task: Pick): boolean { return task.prompt.startsWith(WATCH_CI_PREFIX); } +export function isGitHubCiTask( + task: Pick, +): boolean { + return task.ci_provider === 'github'; +} + export function isTaskStatusControlMessage(content: string): boolean { return content.startsWith(TASK_STATUS_MESSAGE_PREFIX); } diff --git a/src/types.ts b/src/types.ts index 9f37e0e..7ba8997 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,6 +72,8 @@ export interface ScheduledTask { group_folder: string; chat_jid: string; agent_type: AgentType | null; + ci_provider?: 'github' | null; + ci_metadata?: string | null; max_duration_ms?: number | null; status_message_id: string | null; status_started_at: string | null;