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
.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(),

View File

@@ -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}.`,
);
}

View File

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

View File

@@ -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',

View File

@@ -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;

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);
});
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(
{

View File

@@ -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,

View File

@@ -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<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 () => {
const enqueueTask = vi.fn();
createTask({

View File

@@ -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<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 schedulerTimer: ReturnType<typeof setTimeout> | null = null;
let schedulerLoopFn: (() => Promise<void>) | 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) {

View File

@@ -16,6 +16,12 @@ export function isWatchCiTask(task: Pick<ScheduledTask, 'prompt'>): boolean {
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 {
return content.startsWith(TASK_STATUS_MESSAGE_PREFIX);
}

View File

@@ -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;