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',
|
'SERVICE_AGENT_TYPE',
|
||||||
'SESSION_COMMAND_ALLOWED_SENDERS',
|
'SESSION_COMMAND_ALLOWED_SENDERS',
|
||||||
'SESSION_COMMAND_USER_IDS',
|
'SESSION_COMMAND_USER_IDS',
|
||||||
|
'STATUS_SHOW_ROOMS',
|
||||||
'USAGE_DASHBOARD',
|
'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_CHANNEL_ID = process.env.STATUS_CHANNEL_ID || '';
|
||||||
export const STATUS_UPDATE_INTERVAL = 10000; // 10s
|
export const STATUS_UPDATE_INTERVAL = 10000; // 10s
|
||||||
export const USAGE_UPDATE_INTERVAL = 300000; // 5 minutes
|
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 =
|
export const USAGE_DASHBOARD_ENABLED =
|
||||||
(process.env.USAGE_DASHBOARD || envConfig.USAGE_DASHBOARD) === 'true';
|
(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 {
|
function makeOptions(sessionId?: string): DashboardOptions {
|
||||||
const sessions: Record<string, string> = sessionId
|
const sessions: Record<string, string> = sessionId
|
||||||
@@ -37,13 +37,33 @@ function makeOptions(sessionId?: string): DashboardOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('buildStatusContent', () => {
|
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());
|
const content = buildStatusContent(makeOptions());
|
||||||
expect(content).toContain('세션 없음');
|
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'));
|
const content = buildStatusContent(makeOptions('session-1234567890'));
|
||||||
expect(content).toContain('세션 34567890');
|
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,
|
fetchClaudeUsageViaCli,
|
||||||
type ClaudeUsageData,
|
type ClaudeUsageData,
|
||||||
} from './claude-usage.js';
|
} 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 { readEnvFile } from './env.js';
|
||||||
import { GroupQueue, GroupStatus } from './group-queue.js';
|
import { GroupQueue, GroupStatus } from './group-queue.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
@@ -103,15 +109,7 @@ async function refreshChannelMeta(opts: DashboardOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getStatusLabel(status: GroupStatus): string {
|
function getStatusLabel(status: GroupStatus): string {
|
||||||
if (status.status === 'processing') {
|
return formatDashboardStatusLabel(status);
|
||||||
return `처리 중 (${formatElapsed(status.elapsedMs || 0)})`;
|
|
||||||
}
|
|
||||||
if (status.status === 'waiting') {
|
|
||||||
return status.pendingTasks > 0
|
|
||||||
? `큐 대기 (태스크 ${status.pendingTasks}개)`
|
|
||||||
: '큐 대기 (메시지)';
|
|
||||||
}
|
|
||||||
return '비활성';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSessionLabel(sessionId: string | undefined): string {
|
function getSessionLabel(sessionId: string | undefined): string {
|
||||||
@@ -122,6 +120,8 @@ function getSessionLabel(sessionId: string | undefined): string {
|
|||||||
|
|
||||||
/** @internal - exported for testing */
|
/** @internal - exported for testing */
|
||||||
export function buildStatusContent(opts: DashboardOptions): string {
|
export function buildStatusContent(opts: DashboardOptions): string {
|
||||||
|
if (!STATUS_SHOW_ROOMS) return '';
|
||||||
|
|
||||||
const registeredGroups = opts.registeredGroups();
|
const registeredGroups = opts.registeredGroups();
|
||||||
const sessions = opts.getSessions();
|
const sessions = opts.getSessions();
|
||||||
const jids = Object.keys(registeredGroups);
|
const jids = Object.keys(registeredGroups);
|
||||||
@@ -148,7 +148,7 @@ export function buildStatusContent(opts: DashboardOptions): string {
|
|||||||
return posA - posB;
|
return posA - posB;
|
||||||
});
|
});
|
||||||
|
|
||||||
const sections: string[] = [];
|
const roomLines: DashboardRoomLine[] = [];
|
||||||
let totalActive = 0;
|
let totalActive = 0;
|
||||||
let totalWaiting = 0;
|
let totalWaiting = 0;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
@@ -158,20 +158,19 @@ export function buildStatusContent(opts: DashboardOptions): string {
|
|||||||
(a, b) => (a.meta?.position ?? 999) - (b.meta?.position ?? 999),
|
(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 icon = STATUS_ICONS[entry.status.status] || '⚪';
|
||||||
const label = getStatusLabel(entry.status);
|
const label = getStatusLabel(entry.status);
|
||||||
const sessionLabel = getSessionLabel(sessions[entry.group.folder]);
|
const sessionLabel = getSessionLabel(sessions[entry.group.folder]);
|
||||||
const name = entry.meta?.name ? `#${entry.meta.name}` : entry.group.name;
|
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(
|
totalActive += categoryEntries.filter(
|
||||||
(entry) => entry.status.status === 'processing',
|
(entry) => entry.status.status === 'processing',
|
||||||
).length;
|
).length;
|
||||||
@@ -182,7 +181,15 @@ export function buildStatusContent(opts: DashboardOptions): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const header = `**에이전트 상태** (${opts.assistantName}) — 활성 ${totalActive} | 큐대기 ${totalWaiting} | 전체 ${total}`;
|
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> {
|
async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
|
||||||
@@ -473,6 +480,10 @@ export async function startStatusDashboard(
|
|||||||
try {
|
try {
|
||||||
await refreshChannelMeta(opts);
|
await refreshChannelMeta(opts);
|
||||||
const content = buildStatusContent(opts);
|
const content = buildStatusContent(opts);
|
||||||
|
if (!content) {
|
||||||
|
statusMessageId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (statusMessageId && ch.editMessage) {
|
if (statusMessageId && ch.editMessage) {
|
||||||
await ch.editMessage(statusJid, statusMessageId, content);
|
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 { 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';
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
interface QueuedTask {
|
interface QueuedTask {
|
||||||
@@ -213,14 +215,8 @@ export class GroupQueue {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
|
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(inputDir, { recursive: true });
|
const filename = queueFollowUpMessage(state.groupFolder, text);
|
||||||
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);
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
groupJid,
|
groupJid,
|
||||||
@@ -257,10 +253,8 @@ export class GroupQueue {
|
|||||||
if (!state.active || !state.groupFolder) return;
|
if (!state.active || !state.groupFolder) return;
|
||||||
state.closingStdin = true;
|
state.closingStdin = true;
|
||||||
|
|
||||||
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
|
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(inputDir, { recursive: true });
|
writeCloseSentinel(state.groupFolder);
|
||||||
fs.writeFileSync(path.join(inputDir, '_close'), '');
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
groupJid,
|
groupJid,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
composeDashboardContent,
|
||||||
editFormattedTrackedChannelMessage,
|
editFormattedTrackedChannelMessage,
|
||||||
sendFormattedChannelMessage,
|
sendFormattedChannelMessage,
|
||||||
sendFormattedTrackedChannelMessage,
|
sendFormattedTrackedChannelMessage,
|
||||||
@@ -81,3 +82,21 @@ describe('index scheduler messaging helpers', () => {
|
|||||||
expect(sendMessage).not.toHaveBeenCalled();
|
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,
|
SERVICE_AGENT_TYPE,
|
||||||
isSessionCommandSenderAllowed,
|
isSessionCommandSenderAllowed,
|
||||||
STATUS_CHANNEL_ID,
|
STATUS_CHANNEL_ID,
|
||||||
|
STATUS_SHOW_ROOMS,
|
||||||
STATUS_UPDATE_INTERVAL,
|
STATUS_UPDATE_INTERVAL,
|
||||||
TIMEZONE,
|
TIMEZONE,
|
||||||
TRIGGER_PATTERN,
|
TRIGGER_PATTERN,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
getRegisteredChannelNames,
|
getRegisteredChannelNames,
|
||||||
} from './channels/registry.js';
|
} from './channels/registry.js';
|
||||||
import { writeGroupsSnapshot } from './agent-runner.js';
|
import { writeGroupsSnapshot } from './agent-runner.js';
|
||||||
|
import { listAvailableGroups } from './available-groups.js';
|
||||||
import {
|
import {
|
||||||
getAllChats,
|
getAllChats,
|
||||||
getAllRegisteredGroups,
|
getAllRegisteredGroups,
|
||||||
@@ -41,6 +43,12 @@ import {
|
|||||||
storeChatMetadata,
|
storeChatMetadata,
|
||||||
storeMessage,
|
storeMessage,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
|
import {
|
||||||
|
composeDashboardContent,
|
||||||
|
formatElapsed,
|
||||||
|
getStatusLabel as formatDashboardStatusLabel,
|
||||||
|
renderCategorizedRoomSections,
|
||||||
|
} from './dashboard-render.js';
|
||||||
import { GroupQueue } from './group-queue.js';
|
import { GroupQueue } from './group-queue.js';
|
||||||
import { readEnvFile } from './env.js';
|
import { readEnvFile } from './env.js';
|
||||||
import { resolveGroupFolderPath } from './group-folder.js';
|
import { resolveGroupFolderPath } from './group-folder.js';
|
||||||
@@ -69,9 +77,11 @@ import {
|
|||||||
import { startSchedulerLoop } from './task-scheduler.js';
|
import { startSchedulerLoop } from './task-scheduler.js';
|
||||||
import { Channel, ChannelMeta, NewMessage, RegisteredGroup } from './types.js';
|
import { Channel, ChannelMeta, NewMessage, RegisteredGroup } from './types.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
import { normalizeStoredSeqCursor } from './message-cursor.js';
|
||||||
|
|
||||||
// Re-export for backwards compatibility during refactor
|
// Re-export for backwards compatibility during refactor
|
||||||
export { escapeXml, formatMessages } from './router.js';
|
export { escapeXml, formatMessages } from './router.js';
|
||||||
|
export { composeDashboardContent } from './dashboard-render.js';
|
||||||
|
|
||||||
export async function sendFormattedChannelMessage(
|
export async function sendFormattedChannelMessage(
|
||||||
channels: Channel[],
|
channels: Channel[],
|
||||||
@@ -148,15 +158,6 @@ const runtime = createMessageRuntime({
|
|||||||
clearSession,
|
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 {
|
function loadState(): void {
|
||||||
lastTimestamp = normalizeStoredSeqCursor(
|
lastTimestamp = normalizeStoredSeqCursor(
|
||||||
getRouterState('last_seq') || getRouterState('last_timestamp'),
|
getRouterState('last_seq') || getRouterState('last_timestamp'),
|
||||||
@@ -229,17 +230,7 @@ function registerGroup(jid: string, group: RegisteredGroup): void {
|
|||||||
* Returns groups ordered by most recent activity.
|
* Returns groups ordered by most recent activity.
|
||||||
*/
|
*/
|
||||||
export function getAvailableGroups(): import('./agent-runner.js').AvailableGroup[] {
|
export function getAvailableGroups(): import('./agent-runner.js').AvailableGroup[] {
|
||||||
const chats = getAllChats();
|
return listAvailableGroups(registeredGroups);
|
||||||
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),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal - exported for testing */
|
/** @internal - exported for testing */
|
||||||
@@ -251,22 +242,6 @@ export function _setRegisteredGroups(
|
|||||||
|
|
||||||
// ── Status & Usage Dashboards ───────────────────────────────────
|
// ── 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 {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)}GB`;
|
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)}GB`;
|
||||||
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(0)}MB`;
|
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(0)}MB`;
|
||||||
@@ -360,13 +335,11 @@ function getStatusLabel(s: {
|
|||||||
pendingMessages: boolean;
|
pendingMessages: boolean;
|
||||||
pendingTasks: number;
|
pendingTasks: number;
|
||||||
}): string {
|
}): string {
|
||||||
if (s.status === 'processing')
|
return formatDashboardStatusLabel({
|
||||||
return `처리 중 (${formatElapsed(s.elapsedMs || 0)})`;
|
status: s.status,
|
||||||
if (s.status === 'waiting')
|
elapsedMs: s.elapsedMs,
|
||||||
return s.pendingTasks > 0
|
pendingTasks: s.pendingTasks,
|
||||||
? `큐 대기 (태스크 ${s.pendingTasks}개)`
|
});
|
||||||
: '큐 대기 (메시지)';
|
|
||||||
return '비활성';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAgentDisplayName(agentType: 'claude-code' | 'codex'): string {
|
function getAgentDisplayName(agentType: 'claude-code' | 'codex'): string {
|
||||||
@@ -434,6 +407,8 @@ function writeLocalStatusSnapshot(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildStatusContent(): string {
|
function buildStatusContent(): string {
|
||||||
|
if (!STATUS_SHOW_ROOMS) return '';
|
||||||
|
|
||||||
const snapshots = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS);
|
const snapshots = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS);
|
||||||
const chatNameByJid = new Map(
|
const chatNameByJid = new Map(
|
||||||
getAllChats().map((chat) => [chat.jid, chat.name]),
|
getAllChats().map((chat) => [chat.jid, chat.name]),
|
||||||
@@ -504,11 +479,9 @@ function buildStatusContent(): string {
|
|||||||
return posA - posB;
|
return posA - posB;
|
||||||
});
|
});
|
||||||
|
|
||||||
const sections: string[] = [];
|
const roomLines = [];
|
||||||
for (const [catName, rooms] of sortedCategories) {
|
for (const [catName, rooms] of sortedCategories) {
|
||||||
rooms.sort((a, b) => (a.meta?.position ?? 999) - (b.meta?.position ?? 999));
|
rooms.sort((a, b) => (a.meta?.position ?? 999) - (b.meta?.position ?? 999));
|
||||||
|
|
||||||
const roomLines: string[] = [];
|
|
||||||
for (const room of rooms) {
|
for (const room of rooms) {
|
||||||
// Sort agents: claude-code first, codex second
|
// Sort agents: claude-code first, codex second
|
||||||
room.agents.sort((a, b) =>
|
room.agents.sort((a, b) =>
|
||||||
@@ -525,18 +498,21 @@ function buildStatusContent(): string {
|
|||||||
const tag = getAgentDisplayName(a.agentType);
|
const tag = getAgentDisplayName(a.agentType);
|
||||||
return `${tag} ${icon} ${label}`;
|
return `${tag} ${icon} ${label}`;
|
||||||
});
|
});
|
||||||
roomLines.push(` **${room.name}** — ${agentParts.join(' | ')}`);
|
roomLines.push({
|
||||||
}
|
category: catName,
|
||||||
|
categoryPosition: room.meta?.categoryPosition ?? 999,
|
||||||
if (channelMetaCache.size > 0 && catName !== '기타') {
|
position: room.meta?.position ?? 999,
|
||||||
sections.push(`📁 **${catName}**\n${roomLines.join('\n')}`);
|
line: ` **${room.name}** — ${agentParts.join(' | ')}`,
|
||||||
} else {
|
});
|
||||||
sections.push(roomLines.join('\n'));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const header = `**📊 에이전트 상태** — 활성 ${totalActive} / ${totalRooms}`;
|
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 ──────────────────────────────────────────
|
// ── API Usage Fetchers ──────────────────────────────────────────
|
||||||
@@ -907,17 +883,14 @@ async function refreshUsageCache(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildUnifiedDashboard(): string {
|
function buildUnifiedDashboard(): string {
|
||||||
const status = buildStatusContent();
|
const parts: string[] = [];
|
||||||
const parts = [status];
|
if (STATUS_SHOW_ROOMS) {
|
||||||
|
parts.push(buildStatusContent());
|
||||||
|
}
|
||||||
if (cachedUsageContent) {
|
if (cachedUsageContent) {
|
||||||
parts.push(cachedUsageContent);
|
parts.push(cachedUsageContent);
|
||||||
}
|
}
|
||||||
|
return composeDashboardContent(parts);
|
||||||
parts.push(
|
|
||||||
`_${new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}_`,
|
|
||||||
);
|
|
||||||
return parts.join('\n\n');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startStatusDashboard(): Promise<void> {
|
async function startStatusDashboard(): Promise<void> {
|
||||||
@@ -944,6 +917,10 @@ async function startStatusDashboard(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await refreshChannelMeta();
|
await refreshChannelMeta();
|
||||||
const content = buildUnifiedDashboard();
|
const content = buildUnifiedDashboard();
|
||||||
|
if (!content) {
|
||||||
|
statusMessageId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (statusMessageId && ch.editMessage) {
|
if (statusMessageId && ch.editMessage) {
|
||||||
await ch.editMessage(statusJid, statusMessageId, content);
|
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 {
|
import {
|
||||||
getLatestMessageSeqAtOrBefore,
|
|
||||||
getLastHumanMessageTimestamp,
|
getLastHumanMessageTimestamp,
|
||||||
isPairedRoomJid,
|
isPairedRoomJid,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { filterProcessableMessages } from './bot-message-filter.js';
|
import { filterProcessableMessages } from './bot-message-filter.js';
|
||||||
|
import { normalizeStoredSeqCursor } from './message-cursor.js';
|
||||||
import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js';
|
import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js';
|
||||||
import {
|
import {
|
||||||
type Channel,
|
type Channel,
|
||||||
@@ -13,15 +13,6 @@ import {
|
|||||||
|
|
||||||
const BOT_COLLABORATION_WINDOW_MS = 12 * 60 * 60 * 1000;
|
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(
|
export function advanceLastAgentCursor(
|
||||||
lastAgentTimestamps: Record<string, string>,
|
lastAgentTimestamps: Record<string, string>,
|
||||||
saveState: () => void,
|
saveState: () => void,
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
|
import { AgentOutput } from './agent-runner.js';
|
||||||
import {
|
import {
|
||||||
AgentOutput,
|
|
||||||
AvailableGroup,
|
|
||||||
runAgentProcess,
|
|
||||||
writeGroupsSnapshot,
|
|
||||||
writeTasksSnapshot,
|
|
||||||
} from './agent-runner.js';
|
|
||||||
import {
|
|
||||||
getAllChats,
|
|
||||||
getAllTasks,
|
|
||||||
getMessagesSinceSeq,
|
getMessagesSinceSeq,
|
||||||
getNewMessagesBySeq,
|
getNewMessagesBySeq,
|
||||||
getOpenWorkItem,
|
getOpenWorkItem,
|
||||||
@@ -16,17 +8,8 @@ import {
|
|||||||
markWorkItemDeliveryRetry,
|
markWorkItemDeliveryRetry,
|
||||||
type WorkItem,
|
type WorkItem,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { DATA_DIR, isSessionCommandSenderAllowed } from './config.js';
|
import { isSessionCommandSenderAllowed } from './config.js';
|
||||||
import { GroupQueue, GroupRunContext } from './group-queue.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 { findChannel, formatMessages } from './router.js';
|
||||||
import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js';
|
import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js';
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +20,7 @@ import {
|
|||||||
hasAllowedTrigger,
|
hasAllowedTrigger,
|
||||||
shouldSkipBotOnlyCollaboration,
|
shouldSkipBotOnlyCollaboration,
|
||||||
} from './message-runtime-rules.js';
|
} from './message-runtime-rules.js';
|
||||||
|
import { runAgentForGroup } from './message-agent-executor.js';
|
||||||
import { MessageTurnController } from './message-turn-controller.js';
|
import { MessageTurnController } from './message-turn-controller.js';
|
||||||
import {
|
import {
|
||||||
extractSessionCommand,
|
extractSessionCommand,
|
||||||
@@ -44,10 +28,8 @@ import {
|
|||||||
isSessionCommandAllowed,
|
isSessionCommandAllowed,
|
||||||
isSessionCommandControlMessage,
|
isSessionCommandControlMessage,
|
||||||
} from './session-commands.js';
|
} from './session-commands.js';
|
||||||
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
|
|
||||||
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
export interface MessageRuntimeDeps {
|
export interface MessageRuntimeDeps {
|
||||||
assistantName: string;
|
assistantName: string;
|
||||||
@@ -67,22 +49,6 @@ export interface MessageRuntimeDeps {
|
|||||||
clearSession: (groupFolder: string) => void;
|
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): {
|
export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||||
processGroupMessages: (
|
processGroupMessages: (
|
||||||
chatJid: string,
|
chatJid: string,
|
||||||
@@ -97,9 +63,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
deps.idleTimeout,
|
deps.idleTimeout,
|
||||||
);
|
);
|
||||||
|
|
||||||
const getCurrentAvailableGroups = (): AvailableGroup[] =>
|
|
||||||
getAvailableGroups(deps.getRegisteredGroups());
|
|
||||||
|
|
||||||
const deliverOpenWorkItem = async (
|
const deliverOpenWorkItem = async (
|
||||||
channel: Channel,
|
channel: Channel,
|
||||||
item: WorkItem,
|
item: WorkItem,
|
||||||
@@ -176,345 +139,25 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
chatJid: string,
|
chatJid: string,
|
||||||
runId: string,
|
runId: string,
|
||||||
onOutput?: (output: AgentOutput) => Promise<void>,
|
onOutput?: (output: AgentOutput) => Promise<void>,
|
||||||
): Promise<'success' | 'error'> => {
|
): Promise<'success' | 'error'> =>
|
||||||
const isMain = group.isMain === true;
|
runAgentForGroup(
|
||||||
const isClaudeCodeAgent =
|
{
|
||||||
(group.agentType || 'claude-code') === 'claude-code';
|
assistantName: deps.assistantName,
|
||||||
const sessions = deps.getSessions();
|
queue: deps.queue,
|
||||||
const sessionId = sessions[group.folder];
|
getRegisteredGroups: deps.getRegisteredGroups,
|
||||||
|
getSessions: deps.getSessions,
|
||||||
const tasks = getAllTasks(group.agentType || 'claude-code');
|
persistSession: deps.persistSession,
|
||||||
writeTasksSnapshot(
|
clearSession: deps.clearSession,
|
||||||
group.folder,
|
},
|
||||||
isMain,
|
{
|
||||||
tasks.map((task) => ({
|
group,
|
||||||
id: task.id,
|
prompt,
|
||||||
groupFolder: task.group_folder,
|
chatJid,
|
||||||
prompt: task.prompt,
|
runId,
|
||||||
schedule_type: task.schedule_type,
|
onOutput,
|
||||||
schedule_value: task.schedule_value,
|
},
|
||||||
status: task.status,
|
|
||||||
next_run: task.next_run,
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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 (
|
const processGroupMessages = async (
|
||||||
chatJid: string,
|
chatJid: string,
|
||||||
context: GroupRunContext,
|
context: GroupRunContext,
|
||||||
|
|||||||
@@ -25,7 +25,21 @@ import {
|
|||||||
import { GroupQueue } from './group-queue.js';
|
import { GroupQueue } from './group-queue.js';
|
||||||
import { resolveGroupFolderPath } from './group-folder.js';
|
import { resolveGroupFolderPath } from './group-folder.js';
|
||||||
import { logger } from './logger.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';
|
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
|
* Compute the next run time for a recurring task, anchored to the
|
||||||
@@ -88,128 +102,6 @@ export interface SchedulerDependencies {
|
|||||||
) => Promise<void>;
|
) => 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(
|
async function runTask(
|
||||||
task: ScheduledTask,
|
task: ScheduledTask,
|
||||||
deps: SchedulerDependencies,
|
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