Files
EJClaw/src/task-scheduler.test.ts
Codex 80ddb1aa0d fix(scheduler): forward only final agent output to chat
Scheduled tasks only suppressed phase 'progress', so intermediate
preambles (e.g. "I'll run the watchdog checks.") leaked to the chat
each run while the actual <internal>-wrapped result was correctly
stripped. The interactive path treats intermediate/tool-activity as
silent (toVisiblePhase); align the scheduler to forward only the final
message, with error outputs still falling through to rotation/error
handling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-13 14:37:42 +09:00

2013 lines
58 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
runAgentProcessMock,
writeTasksSnapshotMock,
loggerDebugMock,
checkGitHubActionsRunMock,
} = vi.hoisted(() => ({
runAgentProcessMock: vi.fn(async () => ({
status: 'success' as const,
result: 'done',
})),
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('./agent-error-detection.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('./agent-error-detection.js')>();
return {
...actual,
classifyRotationTrigger: vi.fn((error?: string | null) => {
const lower = (error || '').toLowerCase();
if (
lower.includes('does not have access to claude') ||
(lower.includes('failed to authenticate') &&
lower.includes('403') &&
lower.includes('terminated'))
) {
return { shouldRetry: true, reason: 'org-access-denied' };
}
if (
lower.includes('429') ||
lower.includes('rate limit') ||
lower.includes('hit your limit')
) {
return { shouldRetry: true, reason: '429' };
}
return { shouldRetry: false, reason: '' };
}),
};
});
vi.mock('./token-rotation.js', () => ({
rotateToken: vi.fn(() => false),
getTokenCount: vi.fn(() => 1),
getCurrentTokenIndex: vi.fn(() => 0),
markTokenHealthy: vi.fn(),
}));
vi.mock('./token-refresh.js', () => ({
forceRefreshToken: vi.fn(async () => null),
}));
vi.mock('./codex-token-rotation.js', () => ({
detectCodexRotationTrigger: vi.fn((error?: string | null) => {
const lower = (error || '').toLowerCase();
if (
lower.includes('429') ||
lower.includes('rate limit') ||
lower.includes('oauth token has expired') ||
lower.includes('authentication_error') ||
lower.includes('failed to authenticate') ||
lower.includes('401')
) {
return { shouldRotate: true, reason: 'auth-expired' };
}
return { shouldRotate: false, reason: '' };
}),
rotateCodexToken: vi.fn(() => false),
getCodexAccountCount: vi.fn(() => 1),
markCodexTokenHealthy: vi.fn(),
}));
vi.mock('./agent-runner.js', async () => {
const actual =
await vi.importActual<typeof import('./agent-runner.js')>(
'./agent-runner.js',
);
return {
...actual,
runAgentProcess: runAgentProcessMock,
writeTasksSnapshot: writeTasksSnapshotMock,
};
});
vi.mock('./logger.js', () => {
const mockLogger = {
debug: loggerDebugMock,
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: (_bindings?: Record<string, unknown>) => mockLogger,
};
return {
logger: mockLogger,
createScopedLogger: (_bindings?: Record<string, unknown>) => mockLogger,
};
});
vi.mock('./github-ci.js', () => ({
checkGitHubActionsRun: checkGitHubActionsRunMock,
computeGitHubWatcherDelayMs: vi.fn(
(task: { schedule_value: string; created_at: string }, nowMs: number) => {
const baseDelayMs = Number.parseInt(task.schedule_value, 10);
const normalizedBaseDelayMs =
Number.isFinite(baseDelayMs) && baseDelayMs > 0 ? baseDelayMs : 15_000;
const createdAtMs = new Date(task.created_at).getTime();
const elapsedMs = Number.isFinite(createdAtMs)
? Math.max(0, nowMs - createdAtMs)
: 0;
if (elapsedMs >= 60 * 60 * 1000) {
return Math.max(normalizedBaseDelayMs, 60_000);
}
if (elapsedMs >= 10 * 60 * 1000) {
return Math.max(normalizedBaseDelayMs, 30_000);
}
return normalizedBaseDelayMs;
},
),
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('./service-routing.js', () => ({
hasReviewerLease: vi.fn(() => false),
}));
import {
_initTestDatabase,
createPairedTask,
createTask,
getTaskById,
} from './db.js';
import * as codexTokenRotation from './codex-token-rotation.js';
import { TIMEZONE } from './config.js';
import * as serviceRouting from './service-routing.js';
import * as tokenRefresh from './token-refresh.js';
import { createTaskStatusTracker } from './task-status-tracker.js';
import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js';
import * as tokenRotation from './token-rotation.js';
import {
_resetSchedulerLoopForTests,
computeNextRun,
extractWatchCiTarget,
isWatchCiTask,
nudgeSchedulerLoop,
renderWatchCiStatusMessage,
runSchedulerTickOnce,
startSchedulerLoop,
} from './task-scheduler.js';
function formatExpectedTimeLabel(timestampIso: string): string {
return new Intl.DateTimeFormat('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: TIMEZONE,
})
.format(new Date(timestampIso))
.replace(/:/g, '시 ')
.replace(/시 (\d{2})$/, '분 $1초');
}
describe('task scheduler', () => {
beforeEach(() => {
_initTestDatabase();
_resetSchedulerLoopForTests();
runAgentProcessMock.mockClear();
writeTasksSnapshotMock.mockClear();
loggerDebugMock.mockClear();
checkGitHubActionsRunMock.mockClear();
checkGitHubActionsRunMock.mockResolvedValue({
terminal: false,
resultSummary: 'GitHub Actions run 123 is in_progress',
});
// No fallback provider setup needed
vi.mocked(tokenRotation.getTokenCount).mockReturnValue(1);
vi.mocked(tokenRotation.getCurrentTokenIndex).mockReturnValue(0);
vi.mocked(tokenRotation.markTokenHealthy).mockClear();
vi.mocked(tokenRotation.rotateToken).mockClear();
vi.mocked(tokenRotation.rotateToken).mockReturnValue(false);
vi.mocked(tokenRefresh.forceRefreshToken).mockReset();
vi.mocked(tokenRefresh.forceRefreshToken).mockResolvedValue(null);
vi.mocked(codexTokenRotation.rotateCodexToken).mockClear();
vi.mocked(codexTokenRotation.rotateCodexToken).mockReturnValue(false);
vi.mocked(codexTokenRotation.getCodexAccountCount).mockReturnValue(1);
vi.mocked(codexTokenRotation.markCodexTokenHealthy).mockClear();
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(false);
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('pauses due tasks with invalid group folders to prevent retry churn', async () => {
createTask({
id: 'task-invalid-folder',
group_folder: '../../outside',
chat_jid: 'bad@g.us',
prompt: 'run',
schedule_type: 'once',
schedule_value: '2026-02-22T00:00:00.000Z',
context_mode: 'isolated',
next_run: new Date(Date.now() - 60_000).toISOString(),
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
const enqueueTask = vi.fn(
(_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
void fn();
},
);
startSchedulerLoop({
roomBindings: () => ({}),
getSessions: () => ({}),
queue: { enqueueTask } as any,
onProcess: () => {},
sendMessage: async () => {},
});
await vi.advanceTimersByTimeAsync(10);
const task = getTaskById('task-invalid-folder');
expect(task?.status).toBe('paused');
});
it('enqueues due tasks across agent types in unified service mode', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({
id: 'task-claude',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'claude-code',
prompt: 'claude task',
schedule_type: 'once',
schedule_value: dueAt,
context_mode: 'isolated',
next_run: dueAt,
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
createTask({
id: 'task-codex',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'codex',
prompt: 'codex task',
schedule_type: 'once',
schedule_value: dueAt,
context_mode: 'isolated',
next_run: dueAt,
status: 'active',
created_at: '2026-02-22T00:00:01.000Z',
});
const enqueueTask = vi.fn();
startSchedulerLoop({
serviceAgentType: 'codex',
roomBindings: () => ({
'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(enqueueTask).toHaveBeenCalledTimes(2);
expect(
enqueueTask.mock.calls
.map(([queueJid, taskId]) => `${queueJid}|${taskId}`)
.sort(),
).toEqual(
[
'shared@g.us::task:task-claude|task-claude',
'shared@g.us::task:task-codex|task-codex',
].sort(),
);
});
it('requeues an orphaned review_ready paired task once its CI watcher is gone', async () => {
createPairedTask({
id: 'paired-review-ready-orphan',
chat_jid: 'review@g.us',
group_folder: 'review-group',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
owner_agent_type: 'codex',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: null,
title: null,
source_ref: 'HEAD',
plan_notes: null,
review_requested_at: null,
round_trip_count: 1,
status: 'review_ready',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
owner_failure_count: 0,
created_at: '2026-02-22T00:00:00.000Z',
updated_at: '2026-02-22T00:00:01.000Z',
});
const enqueueMessageCheck = vi.fn();
const deps = {
serviceAgentType: 'codex' as const,
roomBindings: () => ({}),
getSessions: () => ({}),
queue: { enqueueTask: vi.fn(), enqueueMessageCheck } as any,
onProcess: () => {},
sendMessage: async () => {},
};
await runSchedulerTickOnce(deps);
expect(enqueueMessageCheck).toHaveBeenCalledTimes(1);
expect(enqueueMessageCheck).toHaveBeenCalledWith(
'review@g.us',
expect.stringContaining('review-group'),
);
await runSchedulerTickOnce(deps);
expect(enqueueMessageCheck).toHaveBeenCalledTimes(1);
});
it('can execute one scheduler tick without starting the timer loop', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({
id: 'task-single-tick',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'codex',
prompt: 'single tick task',
schedule_type: 'once',
schedule_value: dueAt,
context_mode: 'isolated',
next_run: dueAt,
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
const enqueueTask = vi.fn();
await runSchedulerTickOnce({
serviceAgentType: 'codex',
roomBindings: () => ({
'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 () => {},
});
expect(enqueueTask).toHaveBeenCalledTimes(1);
expect(enqueueTask.mock.calls[0][0]).toBe(
'shared@g.us::task:task-single-tick',
);
expect(enqueueTask.mock.calls[0][1]).toBe('task-single-tick');
});
it('marks one-off tasks completed after a successful scheduler run', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({
id: 'task-once-success-finalize',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'codex',
prompt: 'finalize once task',
schedule_type: 'once',
schedule_value: dueAt,
context_mode: 'isolated',
next_run: dueAt,
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
let execution: Promise<void> | null = null;
const enqueueTask = vi.fn(
(_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
execution = fn();
return execution;
},
);
await runSchedulerTickOnce({
serviceAgentType: 'codex',
roomBindings: () => ({
'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 execution;
const task = getTaskById('task-once-success-finalize');
expect(task?.status).toBe('completed');
expect(task?.next_run).toBeNull();
expect(task?.last_result).toBe('done');
});
it('keeps interval tasks active and reschedules them after an execution error', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({
id: 'task-interval-error-retry',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'codex',
prompt: 'retry interval task',
schedule_type: 'interval',
schedule_value: '60000',
context_mode: 'isolated',
next_run: dueAt,
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
(runAgentProcessMock as any).mockResolvedValueOnce({
status: 'error',
error: 'scheduler boom',
});
let execution: Promise<void> | null = null;
const enqueueTask = vi.fn(
(_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
execution = fn();
return execution;
},
);
await runSchedulerTickOnce({
serviceAgentType: 'codex',
roomBindings: () => ({
'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 execution;
const task = getTaskById('task-interval-error-retry');
expect(task?.status).toBe('active');
expect(task?.last_result).toBe('Error: scheduler boom');
expect(task?.next_run).not.toBe(dueAt);
expect(task?.next_run).not.toBeNull();
});
it('keeps group-context tasks on the chat queue key', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({
id: 'task-group-context',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'codex',
prompt: 'group context task',
schedule_type: 'once',
schedule_value: dueAt,
context_mode: 'group',
next_run: dueAt,
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
const enqueueTask = vi.fn();
startSchedulerLoop({
serviceAgentType: 'codex',
roomBindings: () => ({
'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(enqueueTask).toHaveBeenCalledTimes(1);
expect(enqueueTask.mock.calls[0][0]).toBe('shared@g.us');
expect(enqueueTask.mock.calls[0][1]).toBe('task-group-context');
});
it('forwards only the final message, not intermediate preambles, for scheduled tasks', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({
id: 'task-intermediate-preamble',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'claude-code',
prompt: 'watchdog task',
schedule_type: 'once',
schedule_value: dueAt,
context_mode: 'isolated',
next_run: dueAt,
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
(runAgentProcessMock as any).mockImplementationOnce(
async (
_group: unknown,
_input: unknown,
_onProcess: unknown,
onOutput?: (output: Record<string, unknown>) => Promise<void>,
) => {
// Preamble the model emits before running checks — must not leak.
await onOutput?.({
status: 'success',
phase: 'intermediate',
result: "I'll run the watchdog checks.",
});
// Tool activity — also silent.
await onOutput?.({
status: 'success',
phase: 'tool-activity',
result: 'running df -m',
});
// The actual final answer — this is the only thing that should send.
await onOutput?.({
status: 'success',
phase: 'final',
result: 'Disk at 80%, please clean up.',
});
return { status: 'success', result: null };
},
);
const enqueueTask = vi.fn(
(_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
void fn();
},
);
const sendMessage = vi.fn(async () => {});
startSchedulerLoop({
serviceAgentType: 'claude-code',
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
trigger: '@Claude',
added_at: '2026-02-22T00:00:00.000Z',
agentType: 'claude-code',
},
}),
getSessions: () => ({}),
queue: { enqueueTask } as any,
onProcess: () => {},
sendMessage,
});
await vi.advanceTimersByTimeAsync(10);
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
'shared@g.us',
'Disk at 80%, please clean up.',
);
});
it('keeps watch_ci tasks on a dedicated queue even in group context', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({
id: 'task-watch-group',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'codex',
prompt: `
[BACKGROUND CI WATCH]
Watch target:
GitHub Actions run 123456
Check instructions:
Check the run.
`.trim(),
schedule_type: 'interval',
schedule_value: '60000',
context_mode: 'group',
next_run: dueAt,
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
const enqueueTask = vi.fn();
startSchedulerLoop({
serviceAgentType: 'codex',
roomBindings: () => ({
'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(enqueueTask).toHaveBeenCalledTimes(1);
expect(enqueueTask.mock.calls[0][0]).toBe(
'shared@g.us::task:task-watch-group',
);
expect(enqueueTask.mock.calls[0][1]).toBe('task-watch-group');
});
it('suppresses Claude usage banners for scheduled tasks and retries with a rotated account', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({
id: 'task-usage-banner',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'claude-code',
prompt: 'claude task',
schedule_type: 'once',
schedule_value: dueAt,
context_mode: 'isolated',
next_run: dueAt,
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
vi.mocked(tokenRotation.getTokenCount).mockReturnValue(2);
vi.mocked(tokenRotation.rotateToken).mockReturnValueOnce(true);
(runAgentProcessMock as any)
.mockImplementationOnce(
async (
_group: unknown,
_input: unknown,
_onProcess: unknown,
onOutput?: (output: Record<string, unknown>) => Promise<void>,
) => {
await onOutput?.({
status: 'success',
phase: 'intermediate',
result: "You're out of extra usage · resets 4am (Asia/Seoul)",
});
await onOutput?.({
status: 'success',
result: "You're out of extra usage · resets 4am (Asia/Seoul)",
});
return {
status: 'success',
result: null,
};
},
)
.mockImplementationOnce(
async (
_group: unknown,
_input: unknown,
_onProcess: unknown,
onOutput?: (output: Record<string, unknown>) => Promise<void>,
) => {
await onOutput?.({
status: 'success',
result: 'rotated scheduled task response',
});
return {
status: 'success',
result: null,
};
},
);
const enqueueTask = vi.fn(
(_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
void fn();
},
);
const sendMessage = vi.fn(async () => {});
startSchedulerLoop({
serviceAgentType: 'claude-code',
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
trigger: '@Claude',
added_at: '2026-02-22T00:00:00.000Z',
agentType: 'claude-code',
},
}),
getSessions: () => ({}),
queue: { enqueueTask } as any,
onProcess: () => {},
sendMessage,
});
await vi.advanceTimersByTimeAsync(10);
expect(runAgentProcessMock).toHaveBeenCalledTimes(2);
expect(tokenRotation.rotateToken).toHaveBeenCalledTimes(1);
expect(tokenRotation.markTokenHealthy).toHaveBeenCalledTimes(1);
// No fallback cooldown to check
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
'shared@g.us',
'rotated scheduled task response',
);
});
it('suppresses Claude OAuth expiry banners for scheduled tasks and retries with a rotated account', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({
id: 'task-auth-expired-banner',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'claude-code',
prompt: 'claude task',
schedule_type: 'once',
schedule_value: dueAt,
context_mode: 'isolated',
next_run: dueAt,
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
vi.mocked(tokenRotation.getTokenCount).mockReturnValue(2);
vi.mocked(tokenRotation.rotateToken).mockReturnValueOnce(true);
(runAgentProcessMock as any)
.mockImplementationOnce(
async (
_group: unknown,
_input: unknown,
_onProcess: unknown,
onOutput?: (output: Record<string, unknown>) => Promise<void>,
) => {
await onOutput?.({
status: 'success',
phase: 'intermediate',
result:
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}',
});
await onOutput?.({
status: 'success',
result:
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}',
});
return {
status: 'success',
result: null,
};
},
)
.mockImplementationOnce(
async (
_group: unknown,
_input: unknown,
_onProcess: unknown,
onOutput?: (output: Record<string, unknown>) => Promise<void>,
) => {
await onOutput?.({
status: 'success',
result: 'rotated scheduled task auth response',
});
return {
status: 'success',
result: null,
};
},
);
const enqueueTask = vi.fn(
(_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
void fn();
},
);
const sendMessage = vi.fn(async () => {});
startSchedulerLoop({
serviceAgentType: 'claude-code',
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
trigger: '@Claude',
added_at: '2026-02-22T00:00:00.000Z',
agentType: 'claude-code',
},
}),
getSessions: () => ({}),
queue: { enqueueTask } as any,
onProcess: () => {},
sendMessage,
});
await vi.advanceTimersByTimeAsync(10);
expect(runAgentProcessMock).toHaveBeenCalledTimes(2);
expect(tokenRotation.rotateToken).toHaveBeenCalledTimes(1);
expect(tokenRotation.markTokenHealthy).toHaveBeenCalledTimes(1);
// No fallback cooldown to check
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
'shared@g.us',
'rotated scheduled task auth response',
);
});
it('suppresses Claude 502 HTML for scheduled tasks and falls back without forwarding it', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({
id: 'task-claude-502-fallback',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'claude-code',
prompt: 'claude task',
schedule_type: 'once',
schedule_value: dueAt,
context_mode: 'isolated',
next_run: dueAt,
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
(runAgentProcessMock as any).mockImplementationOnce(
async (
_group: unknown,
_input: unknown,
_onProcess: unknown,
onOutput?: (output: Record<string, unknown>) => Promise<void>,
) => {
await onOutput?.({
status: 'success',
phase: 'intermediate',
result:
'API Error: 502 <html><head><title>502 Bad Gateway</title></head><body><center><h1>502 Bad Gateway</h1></center><hr><center>cloudflare</center></body></html>',
});
await onOutput?.({
status: 'success',
result:
'API Error: 502 <html><head><title>502 Bad Gateway</title></head><body><center><h1>502 Bad Gateway</h1></center><hr><center>cloudflare</center></body></html>',
});
return {
status: 'success',
result: null,
};
},
);
const enqueueTask = vi.fn(
(_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
void fn();
},
);
const sendMessage = vi.fn(async () => {});
startSchedulerLoop({
serviceAgentType: 'claude-code',
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
trigger: '@Claude',
added_at: '2026-02-22T00:00:00.000Z',
agentType: 'claude-code',
},
}),
getSessions: () => ({}),
queue: { enqueueTask } as any,
onProcess: () => {},
sendMessage,
});
await vi.advanceTimersByTimeAsync(10);
expect(runAgentProcessMock).toHaveBeenCalledTimes(1);
expect(tokenRotation.rotateToken).not.toHaveBeenCalled();
// No fallback — 502 results in error without retrying on another provider
});
it('suppresses Claude org access denied banners for scheduled tasks and retries with a rotated account', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({
id: 'task-org-access-denied-banner',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'claude-code',
prompt: 'claude task',
schedule_type: 'once',
schedule_value: dueAt,
context_mode: 'isolated',
next_run: dueAt,
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
vi.mocked(tokenRotation.getTokenCount).mockReturnValue(2);
vi.mocked(tokenRotation.rotateToken).mockReturnValueOnce(true);
(runAgentProcessMock as any)
.mockImplementationOnce(
async (
_group: unknown,
_input: unknown,
_onProcess: unknown,
onOutput?: (output: Record<string, unknown>) => Promise<void>,
) => {
await onOutput?.({
status: 'success',
phase: 'intermediate',
result:
'Your organization does not have access to Claude. Please login again or contact your administrator.',
});
await onOutput?.({
status: 'success',
result:
'Your organization does not have access to Claude. Please login again or contact your administrator.',
});
return {
status: 'success',
result: null,
};
},
)
.mockImplementationOnce(
async (
_group: unknown,
_input: unknown,
_onProcess: unknown,
onOutput?: (output: Record<string, unknown>) => Promise<void>,
) => {
await onOutput?.({
status: 'success',
result: 'rotated scheduled task org-access response',
});
return {
status: 'success',
result: null,
};
},
);
const enqueueTask = vi.fn(
(_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
void fn();
},
);
const sendMessage = vi.fn(async () => {});
startSchedulerLoop({
serviceAgentType: 'claude-code',
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
trigger: '@Claude',
added_at: '2026-02-22T00:00:00.000Z',
agentType: 'claude-code',
},
}),
getSessions: () => ({}),
queue: { enqueueTask } as any,
onProcess: () => {},
sendMessage,
});
await vi.advanceTimersByTimeAsync(10);
expect(runAgentProcessMock).toHaveBeenCalledTimes(2);
expect(tokenRotation.rotateToken).toHaveBeenCalledTimes(1);
expect(tokenRotation.markTokenHealthy).toHaveBeenCalledTimes(1);
// No fallback cooldown to check
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
'shared@g.us',
'rotated scheduled task org-access response',
);
});
it('retries Codex scheduled tasks with a rotated account on streamed auth expiry', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({
id: 'task-codex-auth-expired',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'codex',
prompt: 'codex task',
schedule_type: 'once',
schedule_value: dueAt,
context_mode: 'isolated',
next_run: dueAt,
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
vi.mocked(codexTokenRotation.getCodexAccountCount).mockReturnValue(2);
vi.mocked(codexTokenRotation.rotateCodexToken).mockReturnValueOnce(true);
(runAgentProcessMock as any)
.mockImplementationOnce(
async (
_group: unknown,
_input: unknown,
_onProcess: unknown,
onOutput?: (output: Record<string, unknown>) => Promise<void>,
) => {
await onOutput?.({
status: 'error',
error:
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"OAuth token has expired. Please obtain a new token or refresh your existing token."}}',
result: null,
});
return {
status: 'error',
error:
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"OAuth token has expired. Please obtain a new token or refresh your existing token."}}',
result: null,
};
},
)
.mockImplementationOnce(
async (
_group: unknown,
_input: unknown,
_onProcess: unknown,
onOutput?: (output: Record<string, unknown>) => Promise<void>,
) => {
await onOutput?.({
status: 'success',
result: 'rotated codex scheduled task response',
});
return {
status: 'success',
result: null,
};
},
);
const enqueueTask = vi.fn(
(_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
void fn();
},
);
const sendMessage = vi.fn(async () => {});
startSchedulerLoop({
serviceAgentType: 'codex',
roomBindings: () => ({
'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(runAgentProcessMock).toHaveBeenCalledTimes(2);
expect(codexTokenRotation.rotateCodexToken).toHaveBeenCalledTimes(1);
expect(codexTokenRotation.markCodexTokenHealthy).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
'shared@g.us',
'rotated codex scheduled task response',
);
});
it('picks up newly due tasks immediately when nudged', async () => {
const enqueueTask = vi.fn();
startSchedulerLoop({
serviceAgentType: 'codex',
roomBindings: () => ({
'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(enqueueTask).not.toHaveBeenCalled();
createTask({
id: 'task-watch-immediate',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'codex',
prompt: `
[BACKGROUND CI WATCH]
Watch target:
GitHub Actions run 654321
Check instructions:
Check the run.
`.trim(),
schedule_type: 'interval',
schedule_value: '60000',
context_mode: 'isolated',
next_run: new Date(Date.now() - 1000).toISOString(),
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
nudgeSchedulerLoop();
await vi.advanceTimersByTimeAsync(10);
expect(enqueueTask).toHaveBeenCalledTimes(1);
expect(enqueueTask).toHaveBeenCalledWith(
'shared@g.us::task:task-watch-immediate',
'task-watch-immediate',
expect.any(Function),
);
});
it('uses dedicated IPC but shared session state for group-context CI watchers', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({
id: 'task-watch-runtime',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'codex',
prompt: `
[BACKGROUND CI WATCH]
Watch target:
GitHub Actions run 123456
Check instructions:
Check the run.
`.trim(),
schedule_type: 'interval',
schedule_value: '60000',
context_mode: 'group',
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',
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
trigger: '@Codex',
added_at: '2026-02-22T00:00:00.000Z',
agentType: 'codex',
},
}),
getSessions: () => ({ 'shared-group': 'session-123' }),
queue: { enqueueTask } as any,
onProcess: () => {},
sendMessage: async () => {},
});
await vi.advanceTimersByTimeAsync(10);
expect(runAgentProcessMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
runtimeTaskId: 'task-watch-runtime',
useTaskScopedSession: false,
sessionId: 'session-123',
}),
expect.any(Function),
expect.any(Function),
undefined,
);
expect(writeTasksSnapshotMock).toHaveBeenCalledWith(
'shared-group',
false,
expect.any(Array),
'task-watch-runtime',
);
});
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
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',
roomBindings: () => ({
'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);
expect(task?.ci_metadata).toContain('"poll_count":1');
expect(task?.ci_metadata).toContain('"consecutive_errors":0');
});
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
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',
roomBindings: () => ({
'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('backs off long-running GitHub watchers based on elapsed time', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({
id: 'task-github-backoff',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'codex',
ci_provider: 'github',
ci_metadata: JSON.stringify({
repo: 'owner/repo',
run_id: 222222,
poll_count: 9,
}),
prompt: `
[BACKGROUND CI WATCH]
Watch target:
GitHub Actions run 222222
Check instructions:
Managed by host-driven watcher.
`.trim(),
schedule_type: 'interval',
schedule_value: '15000',
context_mode: 'isolated',
next_run: dueAt,
status: 'active',
created_at: new Date(Date.now() - 11 * 60_000).toISOString(),
});
const enqueueTask = vi.fn(
async (_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
await fn();
},
);
startSchedulerLoop({
serviceAgentType: 'codex',
roomBindings: () => ({
'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);
const task = getTaskById('task-github-backoff');
expect(task).toBeDefined();
expect(
new Date(task!.next_run!).getTime() - Date.now(),
).toBeGreaterThanOrEqual(29_000);
expect(task?.ci_metadata).toContain('"poll_count":10');
});
it('pauses GitHub watchers after repeated gh api failures', async () => {
checkGitHubActionsRunMock.mockRejectedValueOnce(
new Error('gh api failed: rate limit'),
);
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({
id: 'task-github-pause',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'codex',
ci_provider: 'github',
ci_metadata: JSON.stringify({
repo: 'owner/repo',
run_id: 333333,
poll_count: 4,
consecutive_errors: 4,
}),
prompt: `
[BACKGROUND CI WATCH]
Watch target:
GitHub Actions run 333333
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',
roomBindings: () => ({
'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);
const task = getTaskById('task-github-pause');
expect(task?.status).toBe('paused');
expect(task?.ci_metadata).toContain('"consecutive_errors":5');
expect(sendMessage).toHaveBeenCalledWith(
'shared@g.us',
expect.stringContaining('gh api 연속 5회 실패'),
);
expect(runAgentProcessMock).not.toHaveBeenCalled();
});
it('deletes active tasks that exceed max duration before they run', async () => {
const enqueueTask = vi.fn();
createTask({
id: 'task-watch-expired',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'codex',
max_duration_ms: 60_000,
prompt: `
[BACKGROUND CI WATCH]
Watch target:
GitHub Actions run 999999
Check instructions:
Check the run.
`.trim(),
schedule_type: 'interval',
schedule_value: '60000',
context_mode: 'group',
next_run: new Date(Date.now() + 60_000).toISOString(),
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
startSchedulerLoop({
serviceAgentType: 'codex',
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
trigger: '@Codex',
added_at: '2026-02-22T00:00:00.000Z',
agentType: 'codex',
},
}),
getSessions: () => ({ 'shared-group': 'session-123' }),
queue: { enqueueTask } as any,
onProcess: () => {},
sendMessage: async () => {},
});
await vi.advanceTimersByTimeAsync(10);
expect(getTaskById('task-watch-expired')).toBeUndefined();
expect(enqueueTask).not.toHaveBeenCalled();
expect(runAgentProcessMock).not.toHaveBeenCalled();
});
it('deletes expired tasks during a direct scheduler tick before enqueueing', async () => {
const enqueueTask = vi.fn();
createTask({
id: 'task-watch-expired-direct',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'codex',
max_duration_ms: 60_000,
prompt: 'expired direct tick task',
schedule_type: 'interval',
schedule_value: '60000',
context_mode: 'isolated',
next_run: new Date(Date.now() - 60_000).toISOString(),
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
await runSchedulerTickOnce({
serviceAgentType: 'codex',
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
trigger: '@Codex',
added_at: '2026-02-22T00:00:00.000Z',
agentType: 'codex',
},
}),
getSessions: () => ({ 'shared-group': 'session-123' }),
queue: { enqueueTask } as any,
onProcess: () => {},
sendMessage: async () => {},
});
expect(getTaskById('task-watch-expired-direct')).toBeUndefined();
expect(enqueueTask).not.toHaveBeenCalled();
});
it('isolates both IPC and session state for isolated tasks', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({
id: 'task-isolated-runtime',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'claude-code',
prompt: 'run isolated task',
schedule_type: 'once',
schedule_value: dueAt,
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: 'claude-code',
roomBindings: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
trigger: '@Claude',
added_at: '2026-02-22T00:00:00.000Z',
agentType: 'claude-code',
},
}),
getSessions: () => ({ 'shared-group': 'session-123' }),
queue: { enqueueTask } as any,
onProcess: () => {},
sendMessage: async () => {},
});
await vi.advanceTimersByTimeAsync(10);
expect(runAgentProcessMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
runtimeTaskId: 'task-isolated-runtime',
useTaskScopedSession: true,
sessionId: undefined,
}),
expect.any(Function),
expect.any(Function),
undefined,
);
expect(writeTasksSnapshotMock).toHaveBeenCalledWith(
'shared-group',
false,
expect.any(Array),
'task-isolated-runtime',
);
});
it('renders watcher heartbeat messages with target and timing', () => {
const prompt = `
[BACKGROUND CI WATCH]
Watch target:
GitHub Actions run 123456
Check instructions:
Check the run.
`.trim();
expect(isWatchCiTask({ prompt } as any)).toBe(true);
expect(extractWatchCiTarget(prompt)).toBe('GitHub Actions run 123456');
const rendered = renderWatchCiStatusMessage({
task: {
prompt,
schedule_type: 'interval',
schedule_value: '60000',
} as any,
phase: 'waiting',
checkedAt: '2026-03-19T07:02:10.000Z',
statusStartedAt: '2026-03-19T07:00:00.000Z',
nextRun: '2026-03-19T07:04:10.000Z',
});
const expectedCheckedLabel = formatExpectedTimeLabel(
'2026-03-19T07:02:10.000Z',
);
const expectedNextLabel = formatExpectedTimeLabel(
'2026-03-19T07:04:10.000Z',
);
expect(rendered).toContain('CI 감시 중: GitHub Actions run 123456');
expect(rendered).toContain('- 상태: 대기 중');
expect(rendered).toContain(`- 마지막 확인: ${expectedCheckedLabel}`);
expect(rendered).toContain('- 경과 시간: 2분 10초');
expect(rendered).toContain('- 확인 간격: 1분');
expect(rendered).toContain(`- 다음 확인: ${expectedNextLabel}`);
expect(rendered).not.toContain('16:02:10');
expect(rendered).not.toContain('16:04:10');
});
it('omits watcher elapsed time when tracking has not started yet', () => {
const prompt = `
[BACKGROUND CI WATCH]
Watch target:
GitHub Actions run 123456
Check instructions:
Check the run.
`.trim();
const rendered = renderWatchCiStatusMessage({
task: {
prompt,
schedule_type: 'interval',
schedule_value: '60000',
} as any,
phase: 'checking',
checkedAt: '2026-03-19T07:02:10.000Z',
statusStartedAt: null,
});
expect(rendered).not.toContain('- 경과 시간:');
});
it('edits the existing watcher status message with refreshed elapsed time', async () => {
vi.setSystemTime(new Date('2026-03-19T07:00:00.000Z'));
createTask({
id: 'task-watch-status',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'codex',
prompt: `
[BACKGROUND CI WATCH]
Watch target:
GitHub Actions run 123456
Check instructions:
Check the run.
`.trim(),
schedule_type: 'interval',
schedule_value: '60000',
context_mode: 'isolated',
next_run: new Date(Date.now() - 60_000).toISOString(),
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
const sendTrackedMessage = vi.fn(async () => 'msg-123');
const editTrackedMessage = vi.fn(async () => {});
const tracker = createTaskStatusTracker(getTaskById('task-watch-status')!, {
sendTrackedMessage,
editTrackedMessage,
});
await tracker.update('checking');
const firstState = getTaskById('task-watch-status');
expect(sendTrackedMessage).toHaveBeenCalledWith(
'shared@g.us',
expect.stringContaining(`${TASK_STATUS_MESSAGE_PREFIX}CI 감시 중:`),
);
expect(firstState?.status_message_id).toBe('msg-123');
expect(firstState?.status_started_at).toBe('2026-03-19T07:00:00.000Z');
vi.setSystemTime(new Date('2026-03-19T07:02:10.000Z'));
await tracker.update('waiting', '2026-03-19T07:04:10.000Z');
const expectedNextLabel = formatExpectedTimeLabel(
'2026-03-19T07:04:10.000Z',
);
expect(editTrackedMessage).toHaveBeenCalledWith(
'shared@g.us',
'msg-123',
expect.stringContaining('- 경과 시간: 2분 10초'),
);
expect(editTrackedMessage).toHaveBeenCalledWith(
'shared@g.us',
'msg-123',
expect.stringContaining(`- 다음 확인: ${expectedNextLabel}`),
);
const secondState = getTaskById('task-watch-status');
expect(secondState?.status_message_id).toBe('msg-123');
expect(secondState?.status_started_at).toBe('2026-03-19T07:00:00.000Z');
});
it('logs and falls back to sending a new watcher status message when edit fails', async () => {
vi.setSystemTime(new Date('2026-03-19T07:00:00.000Z'));
createTask({
id: 'task-watch-status-edit-fail',
group_folder: 'test-group',
chat_jid: 'shared@g.us',
agent_type: 'claude-code',
prompt: `
[BACKGROUND CI WATCH]
Watch target:
PR #77 checks
Check instructions:
Check the run.
`.trim(),
schedule_type: 'interval',
schedule_value: '60000',
context_mode: 'isolated',
next_run: new Date(Date.now() - 60_000).toISOString(),
status: 'active',
status_message_id: 'msg-old',
status_started_at: '2026-03-19T07:00:00.000Z',
created_at: '2026-03-19T07:00:00.000Z',
});
const sendTrackedMessage = vi.fn(async () => 'msg-new');
const editTrackedMessage = vi.fn(async () => {
throw new Error('discord edit failed');
});
const tracker = createTaskStatusTracker(
getTaskById('task-watch-status-edit-fail')!,
{
sendTrackedMessage,
editTrackedMessage,
},
);
await tracker.update('waiting', '2026-03-19T07:04:10.000Z');
expect(loggerDebugMock).toHaveBeenCalledWith(
expect.objectContaining({
taskId: 'task-watch-status-edit-fail',
chatJid: 'shared@g.us',
statusMessageId: 'msg-old',
phase: 'waiting',
}),
'Failed to edit watcher status message, falling back to send',
);
expect(sendTrackedMessage).toHaveBeenCalledWith(
'shared@g.us',
expect.stringContaining(`${TASK_STATUS_MESSAGE_PREFIX}CI 감시 중:`),
);
const updatedTask = getTaskById('task-watch-status-edit-fail');
expect(updatedTask?.status_message_id).toBe('msg-new');
expect(updatedTask?.status_started_at).toBe('2026-03-19T07:00:00.000Z');
});
it('edits the existing watcher status message when the watcher completes', async () => {
vi.setSystemTime(new Date('2026-03-19T07:05:00.000Z'));
createTask({
id: 'task-watch-status-completed',
group_folder: 'test-group',
chat_jid: 'shared@g.us',
agent_type: 'codex',
prompt: `
[BACKGROUND CI WATCH]
Watch target:
PR #77 checks
Check instructions:
Check the run.
`.trim(),
schedule_type: 'interval',
schedule_value: '60000',
context_mode: 'isolated',
next_run: new Date(Date.now() - 60_000).toISOString(),
status: 'active',
status_message_id: 'msg-old',
status_started_at: '2026-03-19T07:00:00.000Z',
created_at: '2026-03-19T07:00:00.000Z',
});
const sendTrackedMessage = vi.fn(async () => 'msg-terminal');
const editTrackedMessage = vi.fn(async () => {});
const tracker = createTaskStatusTracker(
getTaskById('task-watch-status-completed')!,
{
sendTrackedMessage,
editTrackedMessage,
},
);
await tracker.update('completed');
expect(editTrackedMessage).toHaveBeenCalledWith(
'shared@g.us',
'msg-old',
expect.stringContaining(`${TASK_STATUS_MESSAGE_PREFIX}CI 감시 종료:`),
);
expect(editTrackedMessage).toHaveBeenCalledWith(
'shared@g.us',
'msg-old',
expect.stringContaining('- 상태: 완료'),
);
expect(sendTrackedMessage).not.toHaveBeenCalled();
const updatedTask = getTaskById('task-watch-status-completed');
expect(updatedTask?.status_message_id).toBe('msg-old');
expect(updatedTask?.status_started_at).toBe('2026-03-19T07:00:00.000Z');
});
it('computeNextRun anchors interval tasks to scheduled time to prevent drift', () => {
const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago
const task = {
id: 'drift-test',
group_folder: 'test',
chat_jid: 'test@g.us',
agent_type: 'claude-code' as const,
status_message_id: null,
status_started_at: null,
prompt: 'test',
schedule_type: 'interval' as const,
schedule_value: '60000', // 1 minute
context_mode: 'isolated' as const,
next_run: scheduledTime,
last_run: null,
last_result: null,
status: 'active' as const,
created_at: '2026-01-01T00:00:00.000Z',
};
const nextRun = computeNextRun(task);
expect(nextRun).not.toBeNull();
// Should be anchored to scheduledTime + 60s, NOT Date.now() + 60s
const expected = new Date(scheduledTime).getTime() + 60000;
expect(new Date(nextRun!).getTime()).toBe(expected);
});
it('computeNextRun returns null for once-tasks', () => {
const task = {
id: 'once-test',
group_folder: 'test',
chat_jid: 'test@g.us',
agent_type: 'claude-code' as const,
status_message_id: null,
status_started_at: null,
prompt: 'test',
schedule_type: 'once' as const,
schedule_value: '2026-01-01T00:00:00.000Z',
context_mode: 'isolated' as const,
next_run: new Date(Date.now() - 1000).toISOString(),
last_run: null,
last_result: null,
status: 'active' as const,
created_at: '2026-01-01T00:00:00.000Z',
};
expect(computeNextRun(task)).toBeNull();
});
it('computeNextRun skips missed intervals without infinite loop', () => {
// Task was due 10 intervals ago (missed)
const ms = 60000;
const missedBy = ms * 10;
const scheduledTime = new Date(Date.now() - missedBy).toISOString();
const task = {
id: 'skip-test',
group_folder: 'test',
chat_jid: 'test@g.us',
agent_type: 'claude-code' as const,
status_message_id: null,
status_started_at: null,
prompt: 'test',
schedule_type: 'interval' as const,
schedule_value: String(ms),
context_mode: 'isolated' as const,
next_run: scheduledTime,
last_run: null,
last_result: null,
status: 'active' as const,
created_at: '2026-01-01T00:00:00.000Z',
};
const nextRun = computeNextRun(task);
expect(nextRun).not.toBeNull();
// Must be in the future
expect(new Date(nextRun!).getTime()).toBeGreaterThan(Date.now());
// Must be aligned to the original schedule grid
const offset =
(new Date(nextRun!).getTime() - new Date(scheduledTime).getTime()) % ms;
expect(offset).toBe(0);
});
});