refactor: consolidate runtime helpers
This commit is contained in:
19
src/available-groups.ts
Normal file
19
src/available-groups.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { AvailableGroup } from './agent-runner.js';
|
||||
import { getAllChats } from './db.js';
|
||||
import type { RegisteredGroup } from './types.js';
|
||||
|
||||
export function listAvailableGroups(
|
||||
registeredGroups: Record<string, RegisteredGroup>,
|
||||
): AvailableGroup[] {
|
||||
const chats = getAllChats();
|
||||
const registeredJids = new Set(Object.keys(registeredGroups));
|
||||
|
||||
return chats
|
||||
.filter((chat) => chat.jid !== '__group_sync__' && chat.is_group)
|
||||
.map((chat) => ({
|
||||
jid: chat.jid,
|
||||
name: chat.name,
|
||||
lastActivity: chat.last_message_time,
|
||||
isRegistered: registeredJids.has(chat.jid),
|
||||
}));
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const envConfig = readEnvFile([
|
||||
'SERVICE_AGENT_TYPE',
|
||||
'SESSION_COMMAND_ALLOWED_SENDERS',
|
||||
'SESSION_COMMAND_USER_IDS',
|
||||
'STATUS_SHOW_ROOMS',
|
||||
'USAGE_DASHBOARD',
|
||||
]);
|
||||
|
||||
@@ -82,6 +83,9 @@ export const TRIGGER_PATTERN = new RegExp(
|
||||
export const STATUS_CHANNEL_ID = process.env.STATUS_CHANNEL_ID || '';
|
||||
export const STATUS_UPDATE_INTERVAL = 10000; // 10s
|
||||
export const USAGE_UPDATE_INTERVAL = 300000; // 5 minutes
|
||||
export const STATUS_SHOW_ROOMS =
|
||||
(process.env.STATUS_SHOW_ROOMS || envConfig.STATUS_SHOW_ROOMS || 'true') !==
|
||||
'false';
|
||||
export const USAGE_DASHBOARD_ENABLED =
|
||||
(process.env.USAGE_DASHBOARD || envConfig.USAGE_DASHBOARD) === 'true';
|
||||
|
||||
|
||||
90
src/dashboard-render.ts
Normal file
90
src/dashboard-render.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { STATUS_SHOW_ROOMS } from './config.js';
|
||||
import type { GroupStatus } from './group-queue.js';
|
||||
|
||||
export function formatElapsed(ms: number): string {
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
if (s < 3600) {
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = s % 60;
|
||||
return `${m}m${rem.toString().padStart(2, '0')}s`;
|
||||
}
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (h < 24) return `${h}h${m.toString().padStart(2, '0')}m`;
|
||||
const d = Math.floor(h / 24);
|
||||
const remH = h % 24;
|
||||
return `${d}d${remH}h`;
|
||||
}
|
||||
|
||||
export function getStatusLabel(status: {
|
||||
status: GroupStatus['status'];
|
||||
elapsedMs: number | null;
|
||||
pendingTasks: number;
|
||||
}): string {
|
||||
if (status.status === 'processing') {
|
||||
return `처리 중 (${formatElapsed(status.elapsedMs || 0)})`;
|
||||
}
|
||||
if (status.status === 'waiting') {
|
||||
return status.pendingTasks > 0
|
||||
? `큐 대기 (태스크 ${status.pendingTasks}개)`
|
||||
: '큐 대기 (메시지)';
|
||||
}
|
||||
return '비활성';
|
||||
}
|
||||
|
||||
export interface DashboardRoomLine {
|
||||
category: string;
|
||||
categoryPosition: number;
|
||||
position: number;
|
||||
line: string;
|
||||
}
|
||||
|
||||
export function renderCategorizedRoomSections(args: {
|
||||
lines: DashboardRoomLine[];
|
||||
showCategoryHeaders: boolean;
|
||||
}): string {
|
||||
const { lines, showCategoryHeaders } = args;
|
||||
const categoryMap = new Map<string, DashboardRoomLine[]>();
|
||||
|
||||
for (const line of lines) {
|
||||
if (!categoryMap.has(line.category)) {
|
||||
categoryMap.set(line.category, []);
|
||||
}
|
||||
categoryMap.get(line.category)!.push(line);
|
||||
}
|
||||
|
||||
const sortedCategories = [...categoryMap.entries()].sort(
|
||||
([, a], [, b]) =>
|
||||
(a[0]?.categoryPosition ?? Number.MAX_SAFE_INTEGER) -
|
||||
(b[0]?.categoryPosition ?? Number.MAX_SAFE_INTEGER),
|
||||
);
|
||||
|
||||
return sortedCategories
|
||||
.map(([category, entries]) => {
|
||||
entries.sort((a, b) => a.position - b.position);
|
||||
const content = entries.map((entry) => entry.line).join('\n');
|
||||
if (!showCategoryHeaders || category === '기타') {
|
||||
return content;
|
||||
}
|
||||
return `📁 **${category}**\n${content}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function composeDashboardContent(
|
||||
sections: string[],
|
||||
now = new Date(),
|
||||
): string {
|
||||
const parts = sections.map((section) => section.trim()).filter(Boolean);
|
||||
if (parts.length === 0) return '';
|
||||
|
||||
parts.push(
|
||||
`_${now.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}_`,
|
||||
);
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
export function shouldShowRoomStatus(): boolean {
|
||||
return STATUS_SHOW_ROOMS;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildStatusContent, type DashboardOptions } from './dashboard.js';
|
||||
import type { DashboardOptions } from './dashboard.js';
|
||||
|
||||
function makeOptions(sessionId?: string): DashboardOptions {
|
||||
const sessions: Record<string, string> = sessionId
|
||||
@@ -37,13 +37,33 @@ function makeOptions(sessionId?: string): DashboardOptions {
|
||||
}
|
||||
|
||||
describe('buildStatusContent', () => {
|
||||
it('shows cleared sessions as empty', () => {
|
||||
async function loadBuildStatusContent(
|
||||
statusShowRooms = 'true',
|
||||
): Promise<(opts: DashboardOptions) => string> {
|
||||
vi.resetModules();
|
||||
process.env.STATUS_SHOW_ROOMS = statusShowRooms;
|
||||
const mod = await import('./dashboard.js');
|
||||
return mod.buildStatusContent;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.STATUS_SHOW_ROOMS;
|
||||
});
|
||||
|
||||
it('shows cleared sessions as empty', async () => {
|
||||
const buildStatusContent = await loadBuildStatusContent();
|
||||
const content = buildStatusContent(makeOptions());
|
||||
expect(content).toContain('세션 없음');
|
||||
});
|
||||
|
||||
it('shows a shortened session id when a session exists', () => {
|
||||
it('shows a shortened session id when a session exists', async () => {
|
||||
const buildStatusContent = await loadBuildStatusContent();
|
||||
const content = buildStatusContent(makeOptions('session-1234567890'));
|
||||
expect(content).toContain('세션 34567890');
|
||||
});
|
||||
|
||||
it('returns an empty string when room status display is disabled', async () => {
|
||||
const buildStatusContent = await loadBuildStatusContent('false');
|
||||
expect(buildStatusContent(makeOptions())).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,13 @@ import {
|
||||
fetchClaudeUsageViaCli,
|
||||
type ClaudeUsageData,
|
||||
} from './claude-usage.js';
|
||||
import { USAGE_DASHBOARD_ENABLED } from './config.js';
|
||||
import { STATUS_SHOW_ROOMS, USAGE_DASHBOARD_ENABLED } from './config.js';
|
||||
import {
|
||||
composeDashboardContent,
|
||||
type DashboardRoomLine,
|
||||
getStatusLabel as formatDashboardStatusLabel,
|
||||
renderCategorizedRoomSections,
|
||||
} from './dashboard-render.js';
|
||||
import { readEnvFile } from './env.js';
|
||||
import { GroupQueue, GroupStatus } from './group-queue.js';
|
||||
import { logger } from './logger.js';
|
||||
@@ -103,15 +109,7 @@ async function refreshChannelMeta(opts: DashboardOptions): Promise<void> {
|
||||
}
|
||||
|
||||
function getStatusLabel(status: GroupStatus): string {
|
||||
if (status.status === 'processing') {
|
||||
return `처리 중 (${formatElapsed(status.elapsedMs || 0)})`;
|
||||
}
|
||||
if (status.status === 'waiting') {
|
||||
return status.pendingTasks > 0
|
||||
? `큐 대기 (태스크 ${status.pendingTasks}개)`
|
||||
: '큐 대기 (메시지)';
|
||||
}
|
||||
return '비활성';
|
||||
return formatDashboardStatusLabel(status);
|
||||
}
|
||||
|
||||
function getSessionLabel(sessionId: string | undefined): string {
|
||||
@@ -122,6 +120,8 @@ function getSessionLabel(sessionId: string | undefined): string {
|
||||
|
||||
/** @internal - exported for testing */
|
||||
export function buildStatusContent(opts: DashboardOptions): string {
|
||||
if (!STATUS_SHOW_ROOMS) return '';
|
||||
|
||||
const registeredGroups = opts.registeredGroups();
|
||||
const sessions = opts.getSessions();
|
||||
const jids = Object.keys(registeredGroups);
|
||||
@@ -148,7 +148,7 @@ export function buildStatusContent(opts: DashboardOptions): string {
|
||||
return posA - posB;
|
||||
});
|
||||
|
||||
const sections: string[] = [];
|
||||
const roomLines: DashboardRoomLine[] = [];
|
||||
let totalActive = 0;
|
||||
let totalWaiting = 0;
|
||||
let total = 0;
|
||||
@@ -158,20 +158,19 @@ export function buildStatusContent(opts: DashboardOptions): string {
|
||||
(a, b) => (a.meta?.position ?? 999) - (b.meta?.position ?? 999),
|
||||
);
|
||||
|
||||
const lines = categoryEntries.map((entry) => {
|
||||
categoryEntries.forEach((entry) => {
|
||||
const icon = STATUS_ICONS[entry.status.status] || '⚪';
|
||||
const label = getStatusLabel(entry.status);
|
||||
const sessionLabel = getSessionLabel(sessions[entry.group.folder]);
|
||||
const name = entry.meta?.name ? `#${entry.meta.name}` : entry.group.name;
|
||||
return ` ${icon} **${name}** — ${label} · ${sessionLabel}`;
|
||||
roomLines.push({
|
||||
category: categoryName,
|
||||
categoryPosition: entry.meta?.categoryPosition ?? 999,
|
||||
position: entry.meta?.position ?? 999,
|
||||
line: ` ${icon} **${name}** — ${label} · ${sessionLabel}`,
|
||||
});
|
||||
});
|
||||
|
||||
if (channelMetaCache.size > 0 && categoryName !== '기타') {
|
||||
sections.push(`📁 **${categoryName}**\n${lines.join('\n')}`);
|
||||
} else {
|
||||
sections.push(lines.join('\n'));
|
||||
}
|
||||
|
||||
totalActive += categoryEntries.filter(
|
||||
(entry) => entry.status.status === 'processing',
|
||||
).length;
|
||||
@@ -182,7 +181,15 @@ export function buildStatusContent(opts: DashboardOptions): string {
|
||||
}
|
||||
|
||||
const header = `**에이전트 상태** (${opts.assistantName}) — 활성 ${totalActive} | 큐대기 ${totalWaiting} | 전체 ${total}`;
|
||||
return `${header}\n\n${sections.join('\n\n')}\n\n_${new Date().toLocaleTimeString('ko-KR')}_`;
|
||||
return composeDashboardContent(
|
||||
[
|
||||
`${header}\n\n${renderCategorizedRoomSections({
|
||||
lines: roomLines,
|
||||
showCategoryHeaders: channelMetaCache.size > 0,
|
||||
})}`,
|
||||
],
|
||||
new Date(),
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
|
||||
@@ -473,6 +480,10 @@ export async function startStatusDashboard(
|
||||
try {
|
||||
await refreshChannelMeta(opts);
|
||||
const content = buildStatusContent(opts);
|
||||
if (!content) {
|
||||
statusMessageId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusMessageId && ch.editMessage) {
|
||||
await ch.editMessage(statusJid, statusMessageId, content);
|
||||
|
||||
21
src/group-queue-ipc.ts
Normal file
21
src/group-queue-ipc.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from './config.js';
|
||||
|
||||
export function queueFollowUpMessage(groupFolder: string, text: string): string {
|
||||
const inputDir = path.join(DATA_DIR, 'ipc', groupFolder, 'input');
|
||||
fs.mkdirSync(inputDir, { recursive: true });
|
||||
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}.json`;
|
||||
const filepath = path.join(inputDir, filename);
|
||||
const tempPath = `${filepath}.tmp`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify({ type: 'message', text }));
|
||||
fs.renameSync(tempPath, filepath);
|
||||
return filename;
|
||||
}
|
||||
|
||||
export function writeCloseSentinel(groupFolder: string): void {
|
||||
const inputDir = path.join(DATA_DIR, 'ipc', groupFolder, 'input');
|
||||
fs.mkdirSync(inputDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(inputDir, '_close'), '');
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ChildProcess } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR, MAX_CONCURRENT_AGENTS } from './config.js';
|
||||
import { MAX_CONCURRENT_AGENTS } from './config.js';
|
||||
import {
|
||||
queueFollowUpMessage,
|
||||
writeCloseSentinel,
|
||||
} from './group-queue-ipc.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
interface QueuedTask {
|
||||
@@ -213,14 +215,8 @@ export class GroupQueue {
|
||||
return false;
|
||||
}
|
||||
|
||||
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
|
||||
try {
|
||||
fs.mkdirSync(inputDir, { recursive: true });
|
||||
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}.json`;
|
||||
const filepath = path.join(inputDir, filename);
|
||||
const tempPath = `${filepath}.tmp`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify({ type: 'message', text }));
|
||||
fs.renameSync(tempPath, filepath);
|
||||
const filename = queueFollowUpMessage(state.groupFolder, text);
|
||||
logger.info(
|
||||
{
|
||||
groupJid,
|
||||
@@ -257,10 +253,8 @@ export class GroupQueue {
|
||||
if (!state.active || !state.groupFolder) return;
|
||||
state.closingStdin = true;
|
||||
|
||||
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
|
||||
try {
|
||||
fs.mkdirSync(inputDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(inputDir, '_close'), '');
|
||||
writeCloseSentinel(state.groupFolder);
|
||||
logger.info(
|
||||
{
|
||||
groupJid,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
composeDashboardContent,
|
||||
editFormattedTrackedChannelMessage,
|
||||
sendFormattedChannelMessage,
|
||||
sendFormattedTrackedChannelMessage,
|
||||
@@ -81,3 +82,21 @@ describe('index scheduler messaging helpers', () => {
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('composeDashboardContent', () => {
|
||||
it('returns an empty string when all dashboard sections are disabled', () => {
|
||||
expect(
|
||||
composeDashboardContent([], new Date('2026-03-23T04:00:00+09:00')),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('keeps non-status sections when room status is hidden', () => {
|
||||
const content = composeDashboardContent(
|
||||
['**사용량**\nCodex OK'],
|
||||
new Date('2026-03-23T04:00:00+09:00'),
|
||||
);
|
||||
|
||||
expect(content).toContain('**사용량**');
|
||||
expect(content).not.toContain('**📊 에이전트 상태**');
|
||||
});
|
||||
});
|
||||
|
||||
101
src/index.ts
101
src/index.ts
@@ -11,6 +11,7 @@ import {
|
||||
SERVICE_AGENT_TYPE,
|
||||
isSessionCommandSenderAllowed,
|
||||
STATUS_CHANNEL_ID,
|
||||
STATUS_SHOW_ROOMS,
|
||||
STATUS_UPDATE_INTERVAL,
|
||||
TIMEZONE,
|
||||
TRIGGER_PATTERN,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
getRegisteredChannelNames,
|
||||
} from './channels/registry.js';
|
||||
import { writeGroupsSnapshot } from './agent-runner.js';
|
||||
import { listAvailableGroups } from './available-groups.js';
|
||||
import {
|
||||
getAllChats,
|
||||
getAllRegisteredGroups,
|
||||
@@ -41,6 +43,12 @@ import {
|
||||
storeChatMetadata,
|
||||
storeMessage,
|
||||
} from './db.js';
|
||||
import {
|
||||
composeDashboardContent,
|
||||
formatElapsed,
|
||||
getStatusLabel as formatDashboardStatusLabel,
|
||||
renderCategorizedRoomSections,
|
||||
} from './dashboard-render.js';
|
||||
import { GroupQueue } from './group-queue.js';
|
||||
import { readEnvFile } from './env.js';
|
||||
import { resolveGroupFolderPath } from './group-folder.js';
|
||||
@@ -69,9 +77,11 @@ import {
|
||||
import { startSchedulerLoop } from './task-scheduler.js';
|
||||
import { Channel, ChannelMeta, NewMessage, RegisteredGroup } from './types.js';
|
||||
import { logger } from './logger.js';
|
||||
import { normalizeStoredSeqCursor } from './message-cursor.js';
|
||||
|
||||
// Re-export for backwards compatibility during refactor
|
||||
export { escapeXml, formatMessages } from './router.js';
|
||||
export { composeDashboardContent } from './dashboard-render.js';
|
||||
|
||||
export async function sendFormattedChannelMessage(
|
||||
channels: Channel[],
|
||||
@@ -148,15 +158,6 @@ const runtime = createMessageRuntime({
|
||||
clearSession,
|
||||
});
|
||||
|
||||
function normalizeStoredSeqCursor(
|
||||
cursor: string | undefined,
|
||||
chatJid?: string,
|
||||
): string {
|
||||
if (!cursor) return '0';
|
||||
if (/^\d+$/.test(cursor.trim())) return cursor.trim();
|
||||
return String(getLatestMessageSeqAtOrBefore(cursor, chatJid));
|
||||
}
|
||||
|
||||
function loadState(): void {
|
||||
lastTimestamp = normalizeStoredSeqCursor(
|
||||
getRouterState('last_seq') || getRouterState('last_timestamp'),
|
||||
@@ -229,17 +230,7 @@ function registerGroup(jid: string, group: RegisteredGroup): void {
|
||||
* Returns groups ordered by most recent activity.
|
||||
*/
|
||||
export function getAvailableGroups(): import('./agent-runner.js').AvailableGroup[] {
|
||||
const chats = getAllChats();
|
||||
const registeredJids = new Set(Object.keys(registeredGroups));
|
||||
|
||||
return chats
|
||||
.filter((c) => c.jid !== '__group_sync__' && c.is_group)
|
||||
.map((c) => ({
|
||||
jid: c.jid,
|
||||
name: c.name,
|
||||
lastActivity: c.last_message_time,
|
||||
isRegistered: registeredJids.has(c.jid),
|
||||
}));
|
||||
return listAvailableGroups(registeredGroups);
|
||||
}
|
||||
|
||||
/** @internal - exported for testing */
|
||||
@@ -251,22 +242,6 @@ export function _setRegisteredGroups(
|
||||
|
||||
// ── Status & Usage Dashboards ───────────────────────────────────
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
if (s < 3600) {
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = s % 60;
|
||||
return `${m}m${rem.toString().padStart(2, '0')}s`;
|
||||
}
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (h < 24) return `${h}h${m.toString().padStart(2, '0')}m`;
|
||||
const d = Math.floor(h / 24);
|
||||
const remH = h % 24;
|
||||
return `${d}d${remH}h`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)}GB`;
|
||||
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(0)}MB`;
|
||||
@@ -360,13 +335,11 @@ function getStatusLabel(s: {
|
||||
pendingMessages: boolean;
|
||||
pendingTasks: number;
|
||||
}): string {
|
||||
if (s.status === 'processing')
|
||||
return `처리 중 (${formatElapsed(s.elapsedMs || 0)})`;
|
||||
if (s.status === 'waiting')
|
||||
return s.pendingTasks > 0
|
||||
? `큐 대기 (태스크 ${s.pendingTasks}개)`
|
||||
: '큐 대기 (메시지)';
|
||||
return '비활성';
|
||||
return formatDashboardStatusLabel({
|
||||
status: s.status,
|
||||
elapsedMs: s.elapsedMs,
|
||||
pendingTasks: s.pendingTasks,
|
||||
});
|
||||
}
|
||||
|
||||
function getAgentDisplayName(agentType: 'claude-code' | 'codex'): string {
|
||||
@@ -434,6 +407,8 @@ function writeLocalStatusSnapshot(): void {
|
||||
}
|
||||
|
||||
function buildStatusContent(): string {
|
||||
if (!STATUS_SHOW_ROOMS) return '';
|
||||
|
||||
const snapshots = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS);
|
||||
const chatNameByJid = new Map(
|
||||
getAllChats().map((chat) => [chat.jid, chat.name]),
|
||||
@@ -504,11 +479,9 @@ function buildStatusContent(): string {
|
||||
return posA - posB;
|
||||
});
|
||||
|
||||
const sections: string[] = [];
|
||||
const roomLines = [];
|
||||
for (const [catName, rooms] of sortedCategories) {
|
||||
rooms.sort((a, b) => (a.meta?.position ?? 999) - (b.meta?.position ?? 999));
|
||||
|
||||
const roomLines: string[] = [];
|
||||
for (const room of rooms) {
|
||||
// Sort agents: claude-code first, codex second
|
||||
room.agents.sort((a, b) =>
|
||||
@@ -525,18 +498,21 @@ function buildStatusContent(): string {
|
||||
const tag = getAgentDisplayName(a.agentType);
|
||||
return `${tag} ${icon} ${label}`;
|
||||
});
|
||||
roomLines.push(` **${room.name}** — ${agentParts.join(' | ')}`);
|
||||
}
|
||||
|
||||
if (channelMetaCache.size > 0 && catName !== '기타') {
|
||||
sections.push(`📁 **${catName}**\n${roomLines.join('\n')}`);
|
||||
} else {
|
||||
sections.push(roomLines.join('\n'));
|
||||
roomLines.push({
|
||||
category: catName,
|
||||
categoryPosition: room.meta?.categoryPosition ?? 999,
|
||||
position: room.meta?.position ?? 999,
|
||||
line: ` **${room.name}** — ${agentParts.join(' | ')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const header = `**📊 에이전트 상태** — 활성 ${totalActive} / ${totalRooms}`;
|
||||
return `${header}\n\n${sections.join('\n\n')}`;
|
||||
const sections = renderCategorizedRoomSections({
|
||||
lines: roomLines,
|
||||
showCategoryHeaders: channelMetaCache.size > 0,
|
||||
});
|
||||
return `${header}\n\n${sections}`;
|
||||
}
|
||||
|
||||
// ── API Usage Fetchers ──────────────────────────────────────────
|
||||
@@ -907,17 +883,14 @@ async function refreshUsageCache(): Promise<void> {
|
||||
}
|
||||
|
||||
function buildUnifiedDashboard(): string {
|
||||
const status = buildStatusContent();
|
||||
const parts = [status];
|
||||
|
||||
const parts: string[] = [];
|
||||
if (STATUS_SHOW_ROOMS) {
|
||||
parts.push(buildStatusContent());
|
||||
}
|
||||
if (cachedUsageContent) {
|
||||
parts.push(cachedUsageContent);
|
||||
}
|
||||
|
||||
parts.push(
|
||||
`_${new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}_`,
|
||||
);
|
||||
return parts.join('\n\n');
|
||||
return composeDashboardContent(parts);
|
||||
}
|
||||
|
||||
async function startStatusDashboard(): Promise<void> {
|
||||
@@ -944,6 +917,10 @@ async function startStatusDashboard(): Promise<void> {
|
||||
try {
|
||||
await refreshChannelMeta();
|
||||
const content = buildUnifiedDashboard();
|
||||
if (!content) {
|
||||
statusMessageId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusMessageId && ch.editMessage) {
|
||||
await ch.editMessage(statusJid, statusMessageId, content);
|
||||
|
||||
381
src/message-agent-executor.ts
Normal file
381
src/message-agent-executor.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
AgentOutput,
|
||||
runAgentProcess,
|
||||
writeGroupsSnapshot,
|
||||
writeTasksSnapshot,
|
||||
} from './agent-runner.js';
|
||||
import { listAvailableGroups } from './available-groups.js';
|
||||
import { DATA_DIR } from './config.js';
|
||||
import { getAllTasks } from './db.js';
|
||||
import { GroupQueue } from './group-queue.js';
|
||||
import { logger } from './logger.js';
|
||||
import {
|
||||
detectFallbackTrigger,
|
||||
getActiveProvider,
|
||||
getFallbackEnvOverrides,
|
||||
getFallbackProviderName,
|
||||
hasGroupProviderOverride,
|
||||
isFallbackEnabled,
|
||||
markPrimaryCooldown,
|
||||
} from './provider-fallback.js';
|
||||
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
|
||||
import type { RegisteredGroup } from './types.js';
|
||||
|
||||
export interface MessageAgentExecutorDeps {
|
||||
assistantName: string;
|
||||
queue: Pick<GroupQueue, 'registerProcess'>;
|
||||
getRegisteredGroups: () => Record<string, RegisteredGroup>;
|
||||
getSessions: () => Record<string, string>;
|
||||
persistSession: (groupFolder: string, sessionId: string) => void;
|
||||
clearSession: (groupFolder: string) => void;
|
||||
}
|
||||
|
||||
export async function runAgentForGroup(
|
||||
deps: MessageAgentExecutorDeps,
|
||||
args: {
|
||||
group: RegisteredGroup;
|
||||
prompt: string;
|
||||
chatJid: string;
|
||||
runId: string;
|
||||
onOutput?: (output: AgentOutput) => Promise<void>;
|
||||
},
|
||||
): Promise<'success' | 'error'> {
|
||||
const { group, prompt, chatJid, runId, onOutput } = args;
|
||||
const isMain = group.isMain === true;
|
||||
const isClaudeCodeAgent =
|
||||
(group.agentType || 'claude-code') === 'claude-code';
|
||||
const sessions = deps.getSessions();
|
||||
const sessionId = sessions[group.folder];
|
||||
|
||||
const tasks = getAllTasks(group.agentType || 'claude-code');
|
||||
writeTasksSnapshot(
|
||||
group.folder,
|
||||
isMain,
|
||||
tasks.map((task) => ({
|
||||
id: task.id,
|
||||
groupFolder: task.group_folder,
|
||||
prompt: task.prompt,
|
||||
schedule_type: task.schedule_type,
|
||||
schedule_value: task.schedule_value,
|
||||
status: task.status,
|
||||
next_run: task.next_run,
|
||||
})),
|
||||
);
|
||||
|
||||
writeGroupsSnapshot(
|
||||
group.folder,
|
||||
isMain,
|
||||
listAvailableGroups(deps.getRegisteredGroups()),
|
||||
);
|
||||
|
||||
let resetSessionRequested = false;
|
||||
|
||||
const settingsPath = path.join(
|
||||
DATA_DIR,
|
||||
'sessions',
|
||||
group.folder,
|
||||
'.claude',
|
||||
'settings.json',
|
||||
);
|
||||
const groupHasOverride = hasGroupProviderOverride(settingsPath);
|
||||
const canFallback =
|
||||
isClaudeCodeAgent && isFallbackEnabled() && !groupHasOverride;
|
||||
|
||||
const agentInput = {
|
||||
prompt,
|
||||
sessionId,
|
||||
groupFolder: group.folder,
|
||||
chatJid,
|
||||
runId,
|
||||
isMain,
|
||||
assistantName: deps.assistantName,
|
||||
};
|
||||
|
||||
const runAttempt = async (
|
||||
provider: string,
|
||||
): Promise<{
|
||||
output?: AgentOutput;
|
||||
error?: unknown;
|
||||
sawOutput: boolean;
|
||||
sawSuccessNullResultWithoutOutput: boolean;
|
||||
streamedTriggerReason?: {
|
||||
reason: string;
|
||||
retryAfterMs?: number;
|
||||
};
|
||||
}> => {
|
||||
const persistSessionIds = provider === 'claude';
|
||||
let sawOutput = false;
|
||||
let sawSuccessNullResultWithoutOutput = false;
|
||||
let streamedTriggerReason:
|
||||
| {
|
||||
reason: string;
|
||||
retryAfterMs?: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const wrappedOnOutput = onOutput
|
||||
? async (output: AgentOutput) => {
|
||||
if (persistSessionIds && output.newSessionId) {
|
||||
deps.persistSession(group.folder, output.newSessionId);
|
||||
}
|
||||
if (
|
||||
persistSessionIds &&
|
||||
isClaudeCodeAgent &&
|
||||
shouldResetSessionOnAgentFailure(output)
|
||||
) {
|
||||
resetSessionRequested = true;
|
||||
}
|
||||
if (output.result !== null && output.result !== undefined) {
|
||||
sawOutput = true;
|
||||
} else if (
|
||||
provider === 'claude' &&
|
||||
output.status === 'success' &&
|
||||
!sawOutput
|
||||
) {
|
||||
sawSuccessNullResultWithoutOutput = true;
|
||||
}
|
||||
if (
|
||||
provider === 'claude' &&
|
||||
output.status === 'error' &&
|
||||
!sawOutput &&
|
||||
!streamedTriggerReason
|
||||
) {
|
||||
const trigger = detectFallbackTrigger(output.error);
|
||||
if (trigger.shouldFallback) {
|
||||
streamedTriggerReason = {
|
||||
reason: trigger.reason,
|
||||
retryAfterMs: trigger.retryAfterMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
await onOutput(output);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (provider !== 'claude') {
|
||||
logger.info(
|
||||
{
|
||||
chatJid,
|
||||
group: group.name,
|
||||
groupFolder: group.folder,
|
||||
runId,
|
||||
provider,
|
||||
},
|
||||
`Claude provider in cooldown, routing request to ${provider}`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
chatJid,
|
||||
group: group.name,
|
||||
groupFolder: group.folder,
|
||||
runId,
|
||||
provider,
|
||||
canFallback,
|
||||
groupHasOverride,
|
||||
},
|
||||
`Using provider: ${provider}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const output = await runAgentProcess(
|
||||
group,
|
||||
{
|
||||
...agentInput,
|
||||
sessionId: persistSessionIds ? sessionId : undefined,
|
||||
},
|
||||
(proc, processName) =>
|
||||
deps.queue.registerProcess(chatJid, proc, processName, group.folder),
|
||||
wrappedOnOutput,
|
||||
provider === 'claude' ? undefined : getFallbackEnvOverrides(),
|
||||
);
|
||||
|
||||
if (persistSessionIds && output.newSessionId) {
|
||||
deps.persistSession(group.folder, output.newSessionId);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
chatJid,
|
||||
group: group.name,
|
||||
groupFolder: group.folder,
|
||||
runId,
|
||||
provider,
|
||||
status: output.status,
|
||||
sawOutput,
|
||||
},
|
||||
`Provider response completed (provider: ${provider})`,
|
||||
);
|
||||
|
||||
return {
|
||||
output,
|
||||
sawOutput,
|
||||
sawSuccessNullResultWithoutOutput,
|
||||
streamedTriggerReason,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error,
|
||||
sawOutput,
|
||||
sawSuccessNullResultWithoutOutput,
|
||||
streamedTriggerReason,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const runFallbackAttempt = async (
|
||||
reason: string,
|
||||
retryAfterMs?: number,
|
||||
): Promise<'success' | 'error'> => {
|
||||
const fallbackName = getFallbackProviderName();
|
||||
markPrimaryCooldown(reason, retryAfterMs);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
chatJid,
|
||||
group: group.name,
|
||||
groupFolder: group.folder,
|
||||
runId,
|
||||
reason,
|
||||
retryAfterMs,
|
||||
fallbackProvider: fallbackName,
|
||||
},
|
||||
`Falling back to provider: ${fallbackName} (reason: ${reason})`,
|
||||
);
|
||||
|
||||
const fallbackAttempt = await runAttempt(fallbackName);
|
||||
if (fallbackAttempt.error) {
|
||||
logger.error(
|
||||
{
|
||||
chatJid,
|
||||
group: group.name,
|
||||
groupFolder: group.folder,
|
||||
runId,
|
||||
provider: fallbackName,
|
||||
err: fallbackAttempt.error,
|
||||
},
|
||||
'Fallback provider also threw',
|
||||
);
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if (fallbackAttempt.output?.status === 'error') {
|
||||
logger.error(
|
||||
{
|
||||
chatJid,
|
||||
group: group.name,
|
||||
groupFolder: group.folder,
|
||||
runId,
|
||||
provider: fallbackName,
|
||||
error: fallbackAttempt.output.error,
|
||||
},
|
||||
`Fallback provider (${fallbackName}) also failed`,
|
||||
);
|
||||
return 'error';
|
||||
}
|
||||
|
||||
return 'success';
|
||||
};
|
||||
|
||||
const provider = canFallback ? getActiveProvider() : 'claude';
|
||||
const primaryAttempt = await runAttempt(provider);
|
||||
|
||||
if (primaryAttempt.error) {
|
||||
if (canFallback && provider === 'claude' && !primaryAttempt.sawOutput) {
|
||||
const errMsg =
|
||||
primaryAttempt.error instanceof Error
|
||||
? primaryAttempt.error.message
|
||||
: String(primaryAttempt.error);
|
||||
const trigger = primaryAttempt.streamedTriggerReason
|
||||
? {
|
||||
shouldFallback: true,
|
||||
reason: primaryAttempt.streamedTriggerReason.reason,
|
||||
retryAfterMs: primaryAttempt.streamedTriggerReason.retryAfterMs,
|
||||
}
|
||||
: detectFallbackTrigger(errMsg);
|
||||
if (trigger.shouldFallback) {
|
||||
return runFallbackAttempt(trigger.reason, trigger.retryAfterMs);
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{
|
||||
chatJid,
|
||||
group: group.name,
|
||||
groupFolder: group.folder,
|
||||
runId,
|
||||
provider,
|
||||
err: primaryAttempt.error,
|
||||
},
|
||||
'Agent error',
|
||||
);
|
||||
return 'error';
|
||||
}
|
||||
|
||||
const output = primaryAttempt.output;
|
||||
if (!output) {
|
||||
logger.error(
|
||||
{
|
||||
chatJid,
|
||||
group: group.name,
|
||||
groupFolder: group.folder,
|
||||
runId,
|
||||
provider,
|
||||
},
|
||||
'Agent produced no output object',
|
||||
);
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if (
|
||||
canFallback &&
|
||||
provider === 'claude' &&
|
||||
!primaryAttempt.sawOutput &&
|
||||
primaryAttempt.sawSuccessNullResultWithoutOutput
|
||||
) {
|
||||
return runFallbackAttempt('success-null-result');
|
||||
}
|
||||
|
||||
if (
|
||||
isClaudeCodeAgent &&
|
||||
(resetSessionRequested || shouldResetSessionOnAgentFailure(output))
|
||||
) {
|
||||
deps.clearSession(group.folder);
|
||||
logger.warn(
|
||||
{ group: group.name, chatJid, runId },
|
||||
'Cleared poisoned agent session after unrecoverable error',
|
||||
);
|
||||
}
|
||||
|
||||
if (output.status === 'error') {
|
||||
if (canFallback && provider === 'claude' && !primaryAttempt.sawOutput) {
|
||||
const trigger = primaryAttempt.streamedTriggerReason
|
||||
? {
|
||||
shouldFallback: true,
|
||||
reason: primaryAttempt.streamedTriggerReason.reason,
|
||||
retryAfterMs: primaryAttempt.streamedTriggerReason.retryAfterMs,
|
||||
}
|
||||
: detectFallbackTrigger(output.error);
|
||||
if (trigger.shouldFallback) {
|
||||
return runFallbackAttempt(trigger.reason, trigger.retryAfterMs);
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{
|
||||
group: group.name,
|
||||
chatJid,
|
||||
runId,
|
||||
provider,
|
||||
error: output.error,
|
||||
},
|
||||
'Agent process error',
|
||||
);
|
||||
return 'error';
|
||||
}
|
||||
|
||||
return 'success';
|
||||
}
|
||||
10
src/message-cursor.ts
Normal file
10
src/message-cursor.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { getLatestMessageSeqAtOrBefore } from './db.js';
|
||||
|
||||
export function normalizeStoredSeqCursor(
|
||||
cursor: string | undefined,
|
||||
chatJid?: string,
|
||||
): string {
|
||||
if (!cursor) return '0';
|
||||
if (/^\d+$/.test(cursor.trim())) return cursor.trim();
|
||||
return String(getLatestMessageSeqAtOrBefore(cursor, chatJid));
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
getLatestMessageSeqAtOrBefore,
|
||||
getLastHumanMessageTimestamp,
|
||||
isPairedRoomJid,
|
||||
} from './db.js';
|
||||
import { filterProcessableMessages } from './bot-message-filter.js';
|
||||
import { normalizeStoredSeqCursor } from './message-cursor.js';
|
||||
import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js';
|
||||
import {
|
||||
type Channel,
|
||||
@@ -13,15 +13,6 @@ import {
|
||||
|
||||
const BOT_COLLABORATION_WINDOW_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
export function normalizeStoredSeqCursor(
|
||||
cursor: string | undefined,
|
||||
chatJid?: string,
|
||||
): string {
|
||||
if (!cursor) return '0';
|
||||
if (/^\d+$/.test(cursor.trim())) return cursor.trim();
|
||||
return String(getLatestMessageSeqAtOrBefore(cursor, chatJid));
|
||||
}
|
||||
|
||||
export function advanceLastAgentCursor(
|
||||
lastAgentTimestamps: Record<string, string>,
|
||||
saveState: () => void,
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { AgentOutput } from './agent-runner.js';
|
||||
import {
|
||||
AgentOutput,
|
||||
AvailableGroup,
|
||||
runAgentProcess,
|
||||
writeGroupsSnapshot,
|
||||
writeTasksSnapshot,
|
||||
} from './agent-runner.js';
|
||||
import {
|
||||
getAllChats,
|
||||
getAllTasks,
|
||||
getMessagesSinceSeq,
|
||||
getNewMessagesBySeq,
|
||||
getOpenWorkItem,
|
||||
@@ -16,17 +8,8 @@ import {
|
||||
markWorkItemDeliveryRetry,
|
||||
type WorkItem,
|
||||
} from './db.js';
|
||||
import { DATA_DIR, isSessionCommandSenderAllowed } from './config.js';
|
||||
import { isSessionCommandSenderAllowed } from './config.js';
|
||||
import { GroupQueue, GroupRunContext } from './group-queue.js';
|
||||
import {
|
||||
detectFallbackTrigger,
|
||||
getActiveProvider,
|
||||
getFallbackEnvOverrides,
|
||||
getFallbackProviderName,
|
||||
hasGroupProviderOverride,
|
||||
isFallbackEnabled,
|
||||
markPrimaryCooldown,
|
||||
} from './provider-fallback.js';
|
||||
import { findChannel, formatMessages } from './router.js';
|
||||
import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js';
|
||||
import {
|
||||
@@ -37,6 +20,7 @@ import {
|
||||
hasAllowedTrigger,
|
||||
shouldSkipBotOnlyCollaboration,
|
||||
} from './message-runtime-rules.js';
|
||||
import { runAgentForGroup } from './message-agent-executor.js';
|
||||
import { MessageTurnController } from './message-turn-controller.js';
|
||||
import {
|
||||
extractSessionCommand,
|
||||
@@ -44,10 +28,8 @@ import {
|
||||
isSessionCommandAllowed,
|
||||
isSessionCommandControlMessage,
|
||||
} from './session-commands.js';
|
||||
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
|
||||
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||
import { logger } from './logger.js';
|
||||
import path from 'path';
|
||||
|
||||
export interface MessageRuntimeDeps {
|
||||
assistantName: string;
|
||||
@@ -67,22 +49,6 @@ export interface MessageRuntimeDeps {
|
||||
clearSession: (groupFolder: string) => void;
|
||||
}
|
||||
|
||||
export function getAvailableGroups(
|
||||
registeredGroups: Record<string, RegisteredGroup>,
|
||||
): AvailableGroup[] {
|
||||
const chats = getAllChats();
|
||||
const registeredJids = new Set(Object.keys(registeredGroups));
|
||||
|
||||
return chats
|
||||
.filter((chat) => chat.jid !== '__group_sync__' && chat.is_group)
|
||||
.map((chat) => ({
|
||||
jid: chat.jid,
|
||||
name: chat.name,
|
||||
lastActivity: chat.last_message_time,
|
||||
isRegistered: registeredJids.has(chat.jid),
|
||||
}));
|
||||
}
|
||||
|
||||
export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
processGroupMessages: (
|
||||
chatJid: string,
|
||||
@@ -97,9 +63,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
deps.idleTimeout,
|
||||
);
|
||||
|
||||
const getCurrentAvailableGroups = (): AvailableGroup[] =>
|
||||
getAvailableGroups(deps.getRegisteredGroups());
|
||||
|
||||
const deliverOpenWorkItem = async (
|
||||
channel: Channel,
|
||||
item: WorkItem,
|
||||
@@ -176,345 +139,25 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
chatJid: string,
|
||||
runId: string,
|
||||
onOutput?: (output: AgentOutput) => Promise<void>,
|
||||
): Promise<'success' | 'error'> => {
|
||||
const isMain = group.isMain === true;
|
||||
const isClaudeCodeAgent =
|
||||
(group.agentType || 'claude-code') === 'claude-code';
|
||||
const sessions = deps.getSessions();
|
||||
const sessionId = sessions[group.folder];
|
||||
|
||||
const tasks = getAllTasks(group.agentType || 'claude-code');
|
||||
writeTasksSnapshot(
|
||||
group.folder,
|
||||
isMain,
|
||||
tasks.map((task) => ({
|
||||
id: task.id,
|
||||
groupFolder: task.group_folder,
|
||||
prompt: task.prompt,
|
||||
schedule_type: task.schedule_type,
|
||||
schedule_value: task.schedule_value,
|
||||
status: task.status,
|
||||
next_run: task.next_run,
|
||||
})),
|
||||
): Promise<'success' | 'error'> =>
|
||||
runAgentForGroup(
|
||||
{
|
||||
assistantName: deps.assistantName,
|
||||
queue: deps.queue,
|
||||
getRegisteredGroups: deps.getRegisteredGroups,
|
||||
getSessions: deps.getSessions,
|
||||
persistSession: deps.persistSession,
|
||||
clearSession: deps.clearSession,
|
||||
},
|
||||
{
|
||||
group,
|
||||
prompt,
|
||||
chatJid,
|
||||
runId,
|
||||
onOutput,
|
||||
},
|
||||
);
|
||||
|
||||
writeGroupsSnapshot(group.folder, isMain, getCurrentAvailableGroups());
|
||||
|
||||
let resetSessionRequested = false;
|
||||
|
||||
const settingsPath = path.join(
|
||||
DATA_DIR,
|
||||
'sessions',
|
||||
group.folder,
|
||||
'.claude',
|
||||
'settings.json',
|
||||
);
|
||||
const groupHasOverride = hasGroupProviderOverride(settingsPath);
|
||||
const canFallback =
|
||||
isClaudeCodeAgent && isFallbackEnabled() && !groupHasOverride;
|
||||
|
||||
const agentInput = {
|
||||
prompt,
|
||||
sessionId,
|
||||
groupFolder: group.folder,
|
||||
chatJid,
|
||||
runId,
|
||||
isMain,
|
||||
assistantName: deps.assistantName,
|
||||
};
|
||||
|
||||
const runAttempt = async (
|
||||
provider: string,
|
||||
): Promise<{
|
||||
output?: AgentOutput;
|
||||
error?: unknown;
|
||||
sawOutput: boolean;
|
||||
sawSuccessNullResultWithoutOutput: boolean;
|
||||
streamedTriggerReason?: {
|
||||
reason: string;
|
||||
retryAfterMs?: number;
|
||||
};
|
||||
}> => {
|
||||
const persistSessionIds = provider === 'claude';
|
||||
let sawOutput = false;
|
||||
let sawSuccessNullResultWithoutOutput = false;
|
||||
let streamedTriggerReason:
|
||||
| {
|
||||
reason: string;
|
||||
retryAfterMs?: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const wrappedOnOutput = onOutput
|
||||
? async (output: AgentOutput) => {
|
||||
if (persistSessionIds && output.newSessionId) {
|
||||
deps.persistSession(group.folder, output.newSessionId);
|
||||
}
|
||||
if (
|
||||
persistSessionIds &&
|
||||
isClaudeCodeAgent &&
|
||||
shouldResetSessionOnAgentFailure(output)
|
||||
) {
|
||||
resetSessionRequested = true;
|
||||
}
|
||||
if (output.result !== null && output.result !== undefined) {
|
||||
sawOutput = true;
|
||||
} else if (
|
||||
provider === 'claude' &&
|
||||
output.status === 'success' &&
|
||||
!sawOutput
|
||||
) {
|
||||
sawSuccessNullResultWithoutOutput = true;
|
||||
}
|
||||
if (
|
||||
provider === 'claude' &&
|
||||
output.status === 'error' &&
|
||||
!sawOutput &&
|
||||
!streamedTriggerReason
|
||||
) {
|
||||
const trigger = detectFallbackTrigger(output.error);
|
||||
if (trigger.shouldFallback) {
|
||||
streamedTriggerReason = {
|
||||
reason: trigger.reason,
|
||||
retryAfterMs: trigger.retryAfterMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
await onOutput(output);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (provider !== 'claude') {
|
||||
logger.info(
|
||||
{
|
||||
chatJid,
|
||||
group: group.name,
|
||||
groupFolder: group.folder,
|
||||
runId,
|
||||
provider,
|
||||
},
|
||||
`Claude provider in cooldown, routing request to ${provider}`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
chatJid,
|
||||
group: group.name,
|
||||
groupFolder: group.folder,
|
||||
runId,
|
||||
provider,
|
||||
canFallback,
|
||||
groupHasOverride,
|
||||
},
|
||||
`Using provider: ${provider}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const output = await runAgentProcess(
|
||||
group,
|
||||
{
|
||||
...agentInput,
|
||||
sessionId: persistSessionIds ? sessionId : undefined,
|
||||
},
|
||||
(proc, processName) =>
|
||||
deps.queue.registerProcess(
|
||||
chatJid,
|
||||
proc,
|
||||
processName,
|
||||
group.folder,
|
||||
),
|
||||
wrappedOnOutput,
|
||||
provider === 'claude' ? undefined : getFallbackEnvOverrides(),
|
||||
);
|
||||
|
||||
if (persistSessionIds && output.newSessionId) {
|
||||
deps.persistSession(group.folder, output.newSessionId);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
chatJid,
|
||||
group: group.name,
|
||||
groupFolder: group.folder,
|
||||
runId,
|
||||
provider,
|
||||
status: output.status,
|
||||
sawOutput,
|
||||
},
|
||||
`Provider response completed (provider: ${provider})`,
|
||||
);
|
||||
|
||||
return {
|
||||
output,
|
||||
sawOutput,
|
||||
sawSuccessNullResultWithoutOutput,
|
||||
streamedTriggerReason,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error,
|
||||
sawOutput,
|
||||
sawSuccessNullResultWithoutOutput,
|
||||
streamedTriggerReason,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const runFallbackAttempt = async (
|
||||
reason: string,
|
||||
retryAfterMs?: number,
|
||||
): Promise<'success' | 'error'> => {
|
||||
const fallbackName = getFallbackProviderName();
|
||||
markPrimaryCooldown(reason, retryAfterMs);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
chatJid,
|
||||
group: group.name,
|
||||
groupFolder: group.folder,
|
||||
runId,
|
||||
reason,
|
||||
retryAfterMs,
|
||||
fallbackProvider: fallbackName,
|
||||
},
|
||||
`Falling back to provider: ${fallbackName} (reason: ${reason})`,
|
||||
);
|
||||
|
||||
const fallbackAttempt = await runAttempt(fallbackName);
|
||||
if (fallbackAttempt.error) {
|
||||
logger.error(
|
||||
{
|
||||
chatJid,
|
||||
group: group.name,
|
||||
groupFolder: group.folder,
|
||||
runId,
|
||||
provider: fallbackName,
|
||||
err: fallbackAttempt.error,
|
||||
},
|
||||
'Fallback provider also threw',
|
||||
);
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if (fallbackAttempt.output?.status === 'error') {
|
||||
logger.error(
|
||||
{
|
||||
chatJid,
|
||||
group: group.name,
|
||||
groupFolder: group.folder,
|
||||
runId,
|
||||
provider: fallbackName,
|
||||
error: fallbackAttempt.output.error,
|
||||
},
|
||||
`Fallback provider (${fallbackName}) also failed`,
|
||||
);
|
||||
return 'error';
|
||||
}
|
||||
|
||||
return 'success';
|
||||
};
|
||||
|
||||
const provider = canFallback ? getActiveProvider() : 'claude';
|
||||
const primaryAttempt = await runAttempt(provider);
|
||||
|
||||
if (primaryAttempt.error) {
|
||||
if (canFallback && provider === 'claude' && !primaryAttempt.sawOutput) {
|
||||
const errMsg =
|
||||
primaryAttempt.error instanceof Error
|
||||
? primaryAttempt.error.message
|
||||
: String(primaryAttempt.error);
|
||||
const trigger = primaryAttempt.streamedTriggerReason
|
||||
? {
|
||||
shouldFallback: true,
|
||||
reason: primaryAttempt.streamedTriggerReason.reason,
|
||||
retryAfterMs: primaryAttempt.streamedTriggerReason.retryAfterMs,
|
||||
}
|
||||
: detectFallbackTrigger(errMsg);
|
||||
if (trigger.shouldFallback) {
|
||||
return runFallbackAttempt(trigger.reason, trigger.retryAfterMs);
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{
|
||||
chatJid,
|
||||
group: group.name,
|
||||
groupFolder: group.folder,
|
||||
runId,
|
||||
provider,
|
||||
err: primaryAttempt.error,
|
||||
},
|
||||
'Agent error',
|
||||
);
|
||||
return 'error';
|
||||
}
|
||||
|
||||
const output = primaryAttempt.output;
|
||||
if (!output) {
|
||||
logger.error(
|
||||
{
|
||||
chatJid,
|
||||
group: group.name,
|
||||
groupFolder: group.folder,
|
||||
runId,
|
||||
provider,
|
||||
},
|
||||
'Agent produced no output object',
|
||||
);
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if (
|
||||
canFallback &&
|
||||
provider === 'claude' &&
|
||||
!primaryAttempt.sawOutput &&
|
||||
primaryAttempt.sawSuccessNullResultWithoutOutput
|
||||
) {
|
||||
return runFallbackAttempt('success-null-result');
|
||||
}
|
||||
|
||||
if (
|
||||
isClaudeCodeAgent &&
|
||||
(resetSessionRequested || shouldResetSessionOnAgentFailure(output))
|
||||
) {
|
||||
deps.clearSession(group.folder);
|
||||
logger.warn(
|
||||
{ group: group.name, chatJid, runId },
|
||||
'Cleared poisoned agent session after unrecoverable error',
|
||||
);
|
||||
}
|
||||
|
||||
if (output.status === 'error') {
|
||||
if (canFallback && provider === 'claude' && !primaryAttempt.sawOutput) {
|
||||
const trigger = primaryAttempt.streamedTriggerReason
|
||||
? {
|
||||
shouldFallback: true,
|
||||
reason: primaryAttempt.streamedTriggerReason.reason,
|
||||
retryAfterMs: primaryAttempt.streamedTriggerReason.retryAfterMs,
|
||||
}
|
||||
: detectFallbackTrigger(output.error);
|
||||
if (trigger.shouldFallback) {
|
||||
return runFallbackAttempt(trigger.reason, trigger.retryAfterMs);
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{
|
||||
group: group.name,
|
||||
chatJid,
|
||||
runId,
|
||||
provider,
|
||||
error: output.error,
|
||||
},
|
||||
'Agent process error',
|
||||
);
|
||||
return 'error';
|
||||
}
|
||||
|
||||
return 'success';
|
||||
};
|
||||
|
||||
const processGroupMessages = async (
|
||||
chatJid: string,
|
||||
context: GroupRunContext,
|
||||
|
||||
@@ -25,7 +25,21 @@ import {
|
||||
import { GroupQueue } from './group-queue.js';
|
||||
import { resolveGroupFolderPath } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import {
|
||||
getTaskQueueJid,
|
||||
isWatchCiTask,
|
||||
renderWatchCiStatusMessage,
|
||||
TASK_STATUS_MESSAGE_PREFIX,
|
||||
type WatcherStatusPhase,
|
||||
} from './task-watch-status.js';
|
||||
import { AgentType, RegisteredGroup, ScheduledTask } from './types.js';
|
||||
export {
|
||||
extractWatchCiTarget,
|
||||
getTaskQueueJid,
|
||||
isTaskStatusControlMessage,
|
||||
isWatchCiTask,
|
||||
renderWatchCiStatusMessage,
|
||||
} from './task-watch-status.js';
|
||||
|
||||
/**
|
||||
* Compute the next run time for a recurring task, anchored to the
|
||||
@@ -88,128 +102,6 @@ export interface SchedulerDependencies {
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
type WatcherStatusPhase = 'checking' | 'waiting' | 'retrying' | 'completed';
|
||||
|
||||
const WATCH_CI_PREFIX = '[BACKGROUND CI WATCH]';
|
||||
const TASK_STATUS_MESSAGE_PREFIX = '\u2063\u2063\u2063';
|
||||
|
||||
export function isWatchCiTask(task: Pick<ScheduledTask, 'prompt'>): boolean {
|
||||
return task.prompt.startsWith(WATCH_CI_PREFIX);
|
||||
}
|
||||
|
||||
export function isTaskStatusControlMessage(content: string): boolean {
|
||||
return content.startsWith(TASK_STATUS_MESSAGE_PREFIX);
|
||||
}
|
||||
|
||||
export function extractWatchCiTarget(prompt: string): string | null {
|
||||
const match = prompt.match(/Watch target:\n([\s\S]*?)\n\nTask ID:/);
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function formatTimeLabel(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초');
|
||||
}
|
||||
|
||||
function formatWatchIntervalLabel(
|
||||
task: Pick<ScheduledTask, 'schedule_type' | 'schedule_value'>,
|
||||
): string | null {
|
||||
if (task.schedule_type !== 'interval') return null;
|
||||
const ms = parseInt(task.schedule_value, 10);
|
||||
if (!Number.isFinite(ms) || ms <= 0) return null;
|
||||
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
if (totalSeconds < 60) return `${totalSeconds}초`;
|
||||
|
||||
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||
if (totalMinutes < 60) {
|
||||
const seconds = totalSeconds % 60;
|
||||
return seconds > 0 ? `${totalMinutes}분 ${seconds}초` : `${totalMinutes}분`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return minutes > 0 ? `${hours}시간 ${minutes}분` : `${hours}시간`;
|
||||
}
|
||||
|
||||
function formatElapsedLabel(
|
||||
startedAtIso: string,
|
||||
checkedAtIso: string,
|
||||
): string {
|
||||
const elapsedMs = Math.max(
|
||||
0,
|
||||
new Date(checkedAtIso).getTime() - new Date(startedAtIso).getTime(),
|
||||
);
|
||||
const totalSeconds = Math.floor(elapsedMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (hours > 0) parts.push(`${hours}시간`);
|
||||
if (minutes > 0) parts.push(`${minutes}분`);
|
||||
parts.push(`${seconds}초`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
export function renderWatchCiStatusMessage(args: {
|
||||
task: Pick<ScheduledTask, 'prompt' | 'schedule_type' | 'schedule_value'>;
|
||||
phase: WatcherStatusPhase;
|
||||
checkedAt: string;
|
||||
statusStartedAt?: string | null;
|
||||
nextRun?: string | null;
|
||||
}): string {
|
||||
const target = extractWatchCiTarget(args.task.prompt) || 'CI watcher';
|
||||
const title =
|
||||
args.phase === 'completed'
|
||||
? `CI 감시 종료: ${target}`
|
||||
: `CI 감시 중: ${target}`;
|
||||
const statusLabel =
|
||||
args.phase === 'checking'
|
||||
? '확인 중'
|
||||
: args.phase === 'retrying'
|
||||
? '재시도 대기'
|
||||
: args.phase === 'completed'
|
||||
? '완료'
|
||||
: '대기 중';
|
||||
|
||||
const lines = [
|
||||
title,
|
||||
`- 상태: ${statusLabel}`,
|
||||
`- 마지막 확인: ${formatTimeLabel(args.checkedAt)}`,
|
||||
];
|
||||
if (args.statusStartedAt) {
|
||||
lines.push(
|
||||
`- 경과 시간: ${formatElapsedLabel(args.statusStartedAt, args.checkedAt)}`,
|
||||
);
|
||||
}
|
||||
const intervalLabel = formatWatchIntervalLabel(args.task);
|
||||
if (intervalLabel) {
|
||||
lines.push(`- 확인 간격: ${intervalLabel}`);
|
||||
}
|
||||
|
||||
if (args.nextRun) {
|
||||
lines.push(`- 다음 확인: ${formatTimeLabel(args.nextRun)}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function getTaskQueueJid(
|
||||
task: Pick<ScheduledTask, 'chat_jid' | 'context_mode' | 'id' | 'prompt'>,
|
||||
): string {
|
||||
return task.context_mode === 'isolated' || isWatchCiTask(task)
|
||||
? `${task.chat_jid}::task:${task.id}`
|
||||
: task.chat_jid;
|
||||
}
|
||||
|
||||
async function runTask(
|
||||
task: ScheduledTask,
|
||||
deps: SchedulerDependencies,
|
||||
|
||||
128
src/task-watch-status.ts
Normal file
128
src/task-watch-status.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { TIMEZONE } from './config.js';
|
||||
import type { ScheduledTask } from './types.js';
|
||||
|
||||
export type WatcherStatusPhase =
|
||||
| 'checking'
|
||||
| 'waiting'
|
||||
| 'retrying'
|
||||
| 'completed';
|
||||
|
||||
export const WATCH_CI_PREFIX = '[BACKGROUND CI WATCH]';
|
||||
export const TASK_STATUS_MESSAGE_PREFIX = '\u2063\u2063\u2063';
|
||||
|
||||
export function isWatchCiTask(task: Pick<ScheduledTask, 'prompt'>): boolean {
|
||||
return task.prompt.startsWith(WATCH_CI_PREFIX);
|
||||
}
|
||||
|
||||
export function isTaskStatusControlMessage(content: string): boolean {
|
||||
return content.startsWith(TASK_STATUS_MESSAGE_PREFIX);
|
||||
}
|
||||
|
||||
export function extractWatchCiTarget(prompt: string): string | null {
|
||||
const match = prompt.match(/Watch target:\n([\s\S]*?)\n\nTask ID:/);
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function formatTimeLabel(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초');
|
||||
}
|
||||
|
||||
function formatWatchIntervalLabel(
|
||||
task: Pick<ScheduledTask, 'schedule_type' | 'schedule_value'>,
|
||||
): string | null {
|
||||
if (task.schedule_type !== 'interval') return null;
|
||||
const ms = parseInt(task.schedule_value, 10);
|
||||
if (!Number.isFinite(ms) || ms <= 0) return null;
|
||||
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
if (totalSeconds < 60) return `${totalSeconds}초`;
|
||||
|
||||
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||
if (totalMinutes < 60) {
|
||||
const seconds = totalSeconds % 60;
|
||||
return seconds > 0 ? `${totalMinutes}분 ${seconds}초` : `${totalMinutes}분`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return minutes > 0 ? `${hours}시간 ${minutes}분` : `${hours}시간`;
|
||||
}
|
||||
|
||||
function formatElapsedLabel(
|
||||
startedAtIso: string,
|
||||
checkedAtIso: string,
|
||||
): string {
|
||||
const elapsedMs = Math.max(
|
||||
0,
|
||||
new Date(checkedAtIso).getTime() - new Date(startedAtIso).getTime(),
|
||||
);
|
||||
const totalSeconds = Math.floor(elapsedMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (hours > 0) parts.push(`${hours}시간`);
|
||||
if (minutes > 0) parts.push(`${minutes}분`);
|
||||
parts.push(`${seconds}초`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
export function renderWatchCiStatusMessage(args: {
|
||||
task: Pick<ScheduledTask, 'prompt' | 'schedule_type' | 'schedule_value'>;
|
||||
phase: WatcherStatusPhase;
|
||||
checkedAt: string;
|
||||
statusStartedAt?: string | null;
|
||||
nextRun?: string | null;
|
||||
}): string {
|
||||
const target = extractWatchCiTarget(args.task.prompt) || 'CI watcher';
|
||||
const title =
|
||||
args.phase === 'completed'
|
||||
? `CI 감시 종료: ${target}`
|
||||
: `CI 감시 중: ${target}`;
|
||||
const statusLabel =
|
||||
args.phase === 'checking'
|
||||
? '확인 중'
|
||||
: args.phase === 'retrying'
|
||||
? '재시도 대기'
|
||||
: args.phase === 'completed'
|
||||
? '완료'
|
||||
: '대기 중';
|
||||
|
||||
const lines = [
|
||||
title,
|
||||
`- 상태: ${statusLabel}`,
|
||||
`- 마지막 확인: ${formatTimeLabel(args.checkedAt)}`,
|
||||
];
|
||||
if (args.statusStartedAt) {
|
||||
lines.push(
|
||||
`- 경과 시간: ${formatElapsedLabel(args.statusStartedAt, args.checkedAt)}`,
|
||||
);
|
||||
}
|
||||
const intervalLabel = formatWatchIntervalLabel(args.task);
|
||||
if (intervalLabel) {
|
||||
lines.push(`- 확인 간격: ${intervalLabel}`);
|
||||
}
|
||||
|
||||
if (args.nextRun) {
|
||||
lines.push(`- 다음 확인: ${formatTimeLabel(args.nextRun)}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function getTaskQueueJid(
|
||||
task: Pick<ScheduledTask, 'chat_jid' | 'context_mode' | 'id' | 'prompt'>,
|
||||
): string {
|
||||
return task.context_mode === 'isolated' || isWatchCiTask(task)
|
||||
? `${task.chat_jid}::task:${task.id}`
|
||||
: task.chat_jid;
|
||||
}
|
||||
Reference in New Issue
Block a user