feat: add host-driven GitHub Actions CI watcher (Level 3)

Replace LLM-per-tick polling with direct `gh api` calls for GitHub
Actions watchers. New `ci_provider` discriminator column routes tasks
to either the host-driven path (zero LLM tokens) or the existing
generic LLM path. GitHub watchers poll at 15s intervals (min 10s)
via `checkGitHubActionsRun()` in the scheduler, with completion
messages sent directly to chat on terminal state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eyejoker
2026-03-25 22:31:42 +09:00
parent 07c1437055
commit 33fcf9c788
13 changed files with 841 additions and 14 deletions

View File

@@ -169,21 +169,53 @@ server.tool(
{ {
target: z target: z
.string() .string()
.optional()
.describe( .describe(
'What to watch, for example "PR #123 checks" or "GitHub Actions run 987654321".', 'What to watch, for example "PR #123 checks" or "GitHub Actions run 987654321".',
), ),
check_instructions: z check_instructions: z
.string() .string()
.optional()
.describe( .describe(
'Exact steps or commands to check status and what details matter when it finishes.', '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 poll_interval_seconds: z
.number() .number()
.int() .int()
.min(30) .min(10)
.max(3600) .max(3600)
.default(60) .optional()
.describe('How often to poll in seconds. Default 60, minimum 30.'), .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 context_mode: z
.enum(['group', 'isolated']) .enum(['group', 'isolated'])
.default(DEFAULT_WATCH_CI_CONTEXT_MODE) .default(DEFAULT_WATCH_CI_CONTEXT_MODE)
@@ -198,9 +230,59 @@ server.tool(
), ),
}, },
async (args) => { 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; let pollSeconds: number;
try { try {
pollSeconds = normalizeWatchCiIntervalSeconds(args.poll_interval_seconds); pollSeconds = normalizeWatchCiIntervalSeconds(args.poll_interval_seconds, {
ciProvider: args.ci_provider,
});
} catch (error) { } catch (error) {
return { return {
content: [ content: [
@@ -218,8 +300,8 @@ server.tool(
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const prompt = buildCiWatchPrompt({ const prompt = buildCiWatchPrompt({
taskId, taskId,
target: args.target, target,
checkInstructions: args.check_instructions, checkInstructions,
}); });
const data = { const data = {
@@ -229,6 +311,13 @@ server.tool(
schedule_type: 'interval' as const, schedule_type: 'interval' as const,
schedule_value: String(pollSeconds * 1000), schedule_value: String(pollSeconds * 1000),
context_mode: args.context_mode || DEFAULT_WATCH_CI_CONTEXT_MODE, 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, targetJid,
createdBy: groupFolder, createdBy: groupFolder,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),

View File

@@ -1,17 +1,34 @@
export const DEFAULT_WATCH_CI_INTERVAL_SECONDS = 60; export const DEFAULT_WATCH_CI_INTERVAL_SECONDS = 60;
export const MIN_WATCH_CI_INTERVAL_SECONDS = 30; 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 MAX_WATCH_CI_INTERVAL_SECONDS = 3600;
export const DEFAULT_WATCH_CI_CONTEXT_MODE = 'isolated'; export const DEFAULT_WATCH_CI_CONTEXT_MODE = 'isolated';
export interface NormalizeWatchCiIntervalOptions {
ciProvider?: 'github';
}
export interface BuildCiWatchPromptArgs { export interface BuildCiWatchPromptArgs {
taskId: string; taskId: string;
target: string; target: string;
checkInstructions: 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) { if (seconds === undefined) {
return DEFAULT_WATCH_CI_INTERVAL_SECONDS; return defaultSeconds;
} }
if (!Number.isInteger(seconds)) { if (!Number.isInteger(seconds)) {
@@ -19,11 +36,11 @@ export function normalizeWatchCiIntervalSeconds(seconds?: number): number {
} }
if ( if (
seconds < MIN_WATCH_CI_INTERVAL_SECONDS || seconds < minSeconds ||
seconds > MAX_WATCH_CI_INTERVAL_SECONDS seconds > MAX_WATCH_CI_INTERVAL_SECONDS
) { ) {
throw new Error( 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}.`,
); );
} }

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import { import {
buildCiWatchPrompt, buildCiWatchPrompt,
DEFAULT_WATCH_CI_CONTEXT_MODE, DEFAULT_WATCH_CI_CONTEXT_MODE,
DEFAULT_GITHUB_WATCH_CI_INTERVAL_SECONDS,
normalizeWatchCiIntervalSeconds, normalizeWatchCiIntervalSeconds,
} from '../src/watch-ci.js'; } from '../src/watch-ci.js';
@@ -35,6 +36,12 @@ describe('watch-ci helpers', () => {
expect(normalizeWatchCiIntervalSeconds()).toBe(60); expect(normalizeWatchCiIntervalSeconds()).toBe(60);
expect(normalizeWatchCiIntervalSeconds(30)).toBe(30); expect(normalizeWatchCiIntervalSeconds(30)).toBe(30);
expect(normalizeWatchCiIntervalSeconds(600)).toBe(600); 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', () => { it('rejects invalid poll intervals', () => {
@@ -45,5 +52,8 @@ describe('watch-ci helpers', () => {
/between 30 and 3600/i, /between 30 and 3600/i,
); );
expect(() => normalizeWatchCiIntervalSeconds(30.5)).toThrow(/integer/i); expect(() => normalizeWatchCiIntervalSeconds(30.5)).toThrow(/integer/i);
expect(() =>
normalizeWatchCiIntervalSeconds(9, { ciProvider: 'github' }),
).toThrow(/between 10 and 3600/i);
}); });
}); });

View File

@@ -400,6 +400,36 @@ describe('task CRUD', () => {
expect(getTaskById('task-2')!.status).toBe('paused'); 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', () => { it('deletes a task and its run logs', () => {
createTask({ createTask({
id: 'task-3', id: 'task-3',

View File

@@ -137,6 +137,8 @@ function createSchema(database: Database.Database): void {
group_folder TEXT NOT NULL, group_folder TEXT NOT NULL,
chat_jid TEXT NOT NULL, chat_jid TEXT NOT NULL,
agent_type TEXT, agent_type TEXT,
ci_provider TEXT,
ci_metadata TEXT,
max_duration_ms INTEGER, max_duration_ms INTEGER,
status_message_id TEXT, status_message_id TEXT,
status_started_at TEXT, status_started_at TEXT,
@@ -205,6 +207,18 @@ function createSchema(database: Database.Database): void {
/* column already exists */ /* 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 { try {
database.exec( database.exec(
`ALTER TABLE scheduled_tasks ADD COLUMN max_duration_ms INTEGER`, `ALTER TABLE scheduled_tasks ADD COLUMN max_duration_ms INTEGER`,
@@ -877,11 +891,15 @@ export function createTask(
| 'last_run' | 'last_run'
| 'last_result' | 'last_result'
| 'agent_type' | 'agent_type'
| 'ci_provider'
| 'ci_metadata'
| 'max_duration_ms' | 'max_duration_ms'
| 'status_message_id' | 'status_message_id'
| 'status_started_at' | 'status_started_at'
> & { > & {
agent_type?: AgentType | null; agent_type?: AgentType | null;
ci_provider?: ScheduledTask['ci_provider'];
ci_metadata?: string | null;
max_duration_ms?: number | null; max_duration_ms?: number | null;
status_message_id?: string | null; status_message_id?: string | null;
status_started_at?: string | null; status_started_at?: string | null;
@@ -889,14 +907,16 @@ export function createTask(
): void { ): void {
db.prepare( 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) 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
).run( ).run(
task.id, task.id,
task.group_folder, task.group_folder,
task.chat_jid, task.chat_jid,
task.agent_type || SERVICE_AGENT_TYPE, task.agent_type || SERVICE_AGENT_TYPE,
task.ci_provider ?? null,
task.ci_metadata ?? null,
task.max_duration_ms ?? null, task.max_duration_ms ?? null,
task.status_message_id || null, task.status_message_id || null,
task.status_started_at || null, task.status_started_at || null,
@@ -960,6 +980,7 @@ export function updateTask(
| 'next_run' | 'next_run'
| 'status' | 'status'
| 'suspended_until' | 'suspended_until'
| 'ci_metadata'
> >
>, >,
): void { ): void {
@@ -990,6 +1011,10 @@ export function updateTask(
fields.push('suspended_until = ?'); fields.push('suspended_until = ?');
values.push(updates.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; if (fields.length === 0) return;

149
src/github-ci.test.ts Normal file
View File

@@ -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');
});
});

199
src/github-ci.ts Normal file
View File

@@ -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<string> {
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<GitHubCiMetadata>;
try {
parsed = JSON.parse(raw) as Partial<GitHubCiMetadata>;
} 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<string[]> {
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<ScheduledTask, 'prompt' | 'ci_metadata'>,
): Promise<GitHubRunCheckResult> {
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'),
};
}

View File

@@ -588,6 +588,43 @@ Check the run.
expect(deps.nudgeScheduler).toHaveBeenCalledTimes(1); 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 () => { it('does not assign a max duration to regular scheduled tasks', async () => {
await processTaskIpc( await processTaskIpc(
{ {

View File

@@ -286,6 +286,8 @@ export async function processTaskIpc(
schedule_type?: string; schedule_type?: string;
schedule_value?: string; schedule_value?: string;
context_mode?: string; context_mode?: string;
ci_provider?: 'github';
ci_metadata?: string;
groupFolder?: string; groupFolder?: string;
chatJid?: string; chatJid?: string;
targetJid?: string; targetJid?: string;
@@ -404,6 +406,8 @@ export async function processTaskIpc(
group_folder: targetFolder, group_folder: targetFolder,
chat_jid: resolvedTargetJid, chat_jid: resolvedTargetJid,
agent_type: targetGroupEntry.agentType || SERVICE_AGENT_TYPE, 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 }) max_duration_ms: isWatchCiTask({ prompt: data.prompt })
? DEFAULT_WATCH_CI_MAX_DURATION_MS ? DEFAULT_WATCH_CI_MAX_DURATION_MS
: null, : null,

View File

@@ -1,6 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { runAgentProcessMock, writeTasksSnapshotMock, loggerDebugMock } = const {
runAgentProcessMock,
writeTasksSnapshotMock,
loggerDebugMock,
checkGitHubActionsRunMock,
} =
vi.hoisted(() => ({ vi.hoisted(() => ({
runAgentProcessMock: vi.fn(async () => ({ runAgentProcessMock: vi.fn(async () => ({
status: 'success' as const, status: 'success' as const,
@@ -8,6 +13,16 @@ const { runAgentProcessMock, writeTasksSnapshotMock, loggerDebugMock } =
})), })),
writeTasksSnapshotMock: vi.fn(), writeTasksSnapshotMock: vi.fn(),
loggerDebugMock: 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', () => ({ 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 { _initTestDatabase, createTask, getTaskById } from './db.js';
import * as providerFallback from './provider-fallback.js'; import * as providerFallback from './provider-fallback.js';
import * as codexTokenRotation from './codex-token-rotation.js'; import * as codexTokenRotation from './codex-token-rotation.js';
@@ -113,6 +132,11 @@ describe('task scheduler', () => {
runAgentProcessMock.mockClear(); runAgentProcessMock.mockClear();
writeTasksSnapshotMock.mockClear(); writeTasksSnapshotMock.mockClear();
loggerDebugMock.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.markPrimaryCooldown).mockClear();
vi.mocked(providerFallback.getActiveProvider).mockResolvedValue('claude'); vi.mocked(providerFallback.getActiveProvider).mockResolvedValue('claude');
vi.mocked(providerFallback.isFallbackEnabled).mockReturnValue(true); 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<void>) => {
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<void>) => {
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 () => { it('deletes active tasks that exceed max duration before they run', async () => {
const enqueueTask = vi.fn(); const enqueueTask = vi.fn();
createTask({ createTask({

View File

@@ -71,9 +71,11 @@ import {
import { import {
getTaskQueueJid, getTaskQueueJid,
getTaskRuntimeTaskId, getTaskRuntimeTaskId,
isGitHubCiTask,
shouldUseTaskScopedSession, shouldUseTaskScopedSession,
} from './task-watch-status.js'; } from './task-watch-status.js';
import { AgentType, RegisteredGroup, ScheduledTask } from './types.js'; import { AgentType, RegisteredGroup, ScheduledTask } from './types.js';
import { checkGitHubActionsRun } from './github-ci.js';
export { export {
extractWatchCiTarget, extractWatchCiTarget,
getTaskQueueJid, getTaskQueueJid,
@@ -779,6 +781,94 @@ async function runTask(
updateTaskAfterRun(task.id, nextRun, resultSummary); updateTaskAfterRun(task.id, nextRun, resultSummary);
} }
async function runGithubCiTask(
task: ScheduledTask,
deps: SchedulerDependencies,
): Promise<void> {
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 schedulerRunning = false;
let schedulerTimer: ReturnType<typeof setTimeout> | null = null; let schedulerTimer: ReturnType<typeof setTimeout> | null = null;
let schedulerLoopFn: (() => Promise<void>) | null = null; let schedulerLoopFn: (() => Promise<void>) | null = null;
@@ -864,7 +954,10 @@ export function startSchedulerLoop(deps: SchedulerDependencies): void {
deps.queue.enqueueTask( deps.queue.enqueueTask(
getTaskQueueJid(currentTask), getTaskQueueJid(currentTask),
currentTask.id, currentTask.id,
() => runTask(currentTask, deps), () =>
isGitHubCiTask(currentTask)
? runGithubCiTask(currentTask, deps)
: runTask(currentTask, deps),
); );
} }
} catch (err) { } catch (err) {

View File

@@ -16,6 +16,12 @@ export function isWatchCiTask(task: Pick<ScheduledTask, 'prompt'>): boolean {
return task.prompt.startsWith(WATCH_CI_PREFIX); return task.prompt.startsWith(WATCH_CI_PREFIX);
} }
export function isGitHubCiTask(
task: Pick<ScheduledTask, 'ci_provider'>,
): boolean {
return task.ci_provider === 'github';
}
export function isTaskStatusControlMessage(content: string): boolean { export function isTaskStatusControlMessage(content: string): boolean {
return content.startsWith(TASK_STATUS_MESSAGE_PREFIX); return content.startsWith(TASK_STATUS_MESSAGE_PREFIX);
} }

View File

@@ -72,6 +72,8 @@ export interface ScheduledTask {
group_folder: string; group_folder: string;
chat_jid: string; chat_jid: string;
agent_type: AgentType | null; agent_type: AgentType | null;
ci_provider?: 'github' | null;
ci_metadata?: string | null;
max_duration_ms?: number | null; max_duration_ms?: number | null;
status_message_id: string | null; status_message_id: string | null;
status_started_at: string | null; status_started_at: string | null;