Merge origin/main into chore/nanoclaw-recursive-room

This commit is contained in:
Eyejoker
2026-03-20 03:48:29 +09:00
37 changed files with 4268 additions and 146 deletions

View File

@@ -20,6 +20,7 @@ import {
import { isPairedRoomJid } from '../db.js';
import { readEnvFile } from '../env.js';
import { logger } from '../logger.js';
import { formatOutbound } from '../router.js';
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions');
@@ -449,6 +450,7 @@ export class DiscordChannel implements Channel {
for (const [name, id] of Object.entries(mentionMap)) {
cleaned = cleaned.replace(new RegExp(`@${name}`, 'g'), `<@${id}>`);
}
cleaned = formatOutbound(cleaned);
// Discord has a 2000 character limit per message — split if needed
const MAX_LENGTH = 2000;
@@ -457,6 +459,11 @@ export class DiscordChannel implements Channel {
name: path.basename(f),
}));
if (!cleaned && files.length === 0) {
logger.debug({ jid }, 'Skipping empty Discord outbound message');
return;
}
if (cleaned.length <= MAX_LENGTH) {
await textChannel.send({
content: cleaned || undefined,
@@ -497,6 +504,7 @@ export class DiscordChannel implements Channel {
return groupType === this.agentTypeFilter;
}
async disconnect(): Promise<void> {
if (this.client) {
this.client.destroy();

53
src/claude-usage.test.ts Normal file
View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { parseClaudeUsagePanel } from './claude-usage.js';
describe('parseClaudeUsagePanel', () => {
it('parses session and weekly usage from Claude CLI panel output', () => {
const sample = `
Settings: Status Config Usage
Loading usage data...
Current session
████ 4% used
Resets in 8m (Asia/Seoul)
Current week (all models)
███████████████████████████████████████ 78% used
Resets Mar 17 at 10pm (Asia/Seoul)
Current week (Sonnet only)
███ 6% used
Resets Mar 17 at 11pm (Asia/Seoul)
Extra usage
Extra usage not enabled • /extra-usage to enable
`;
expect(parseClaudeUsagePanel(sample)).toEqual({
five_hour: {
utilization: 4,
resets_at: 'Resets in 8m (Asia/Seoul)',
},
seven_day: {
utilization: 78,
resets_at: 'Resets Mar 17 at 10pm (Asia/Seoul)',
},
});
});
it('converts percent left into used percent', () => {
const sample = `
Current session
60% left
Resets in 1h
`;
expect(parseClaudeUsagePanel(sample)).toEqual({
five_hour: {
utilization: 40,
resets_at: 'Resets in 1h',
},
});
});
});

49
src/dashboard.test.ts Normal file
View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { buildStatusContent, type DashboardOptions } from './dashboard.js';
function makeOptions(sessionId?: string): DashboardOptions {
const sessions: Record<string, string> = sessionId
? { 'group-folder': sessionId }
: {};
return {
assistantName: 'Test',
channels: [],
getSessions: () => sessions,
queue: {
getStatuses: () => [
{
jid: 'dc:123',
status: 'inactive',
elapsedMs: null,
pendingMessages: false,
pendingTasks: 0,
},
],
} as unknown as DashboardOptions['queue'],
registeredGroups: () => ({
'dc:123': {
name: 'clone-test',
folder: 'group-folder',
trigger: '@bot',
added_at: '2026-03-16T00:00:00.000Z',
},
}),
statusChannelId: 'status',
statusUpdateInterval: 60000,
usageUpdateInterval: 60000,
};
}
describe('buildStatusContent', () => {
it('shows cleared sessions as empty', () => {
const content = buildStatusContent(makeOptions());
expect(content).toContain('세션 없음');
});
it('shows a shortened session id when a session exists', () => {
const content = buildStatusContent(makeOptions('session-1234567890'));
expect(content).toContain('세션 34567890');
});
});

534
src/dashboard.ts Normal file
View File

@@ -0,0 +1,534 @@
import { ChildProcess, execSync, spawn } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
fetchClaudeUsageViaCli,
type ClaudeUsageData,
} from './claude-usage.js';
import { USAGE_DASHBOARD_ENABLED } from './config.js';
import { readEnvFile } from './env.js';
import { GroupQueue, GroupStatus } from './group-queue.js';
import { logger } from './logger.js';
import { Channel, ChannelMeta, RegisteredGroup } from './types.js';
export interface DashboardOptions {
assistantName: string;
channels: Channel[];
getSessions: () => Record<string, string>;
queue: GroupQueue;
registeredGroups: () => Record<string, RegisteredGroup>;
statusChannelId: string;
statusUpdateInterval: number;
usageUpdateInterval: number;
}
interface CodexRateLimit {
limitId?: string;
limitName: string | null;
primary: { usedPercent: number; resetsAt: string | number };
secondary: { usedPercent: number; resetsAt: string | number };
}
const STATUS_ICONS: Record<string, string> = {
processing: '🟡',
idle: '🟢',
waiting: '🔵',
inactive: '⚪',
};
const CHANNEL_META_REFRESH_MS = 300000;
let statusMessageId: string | null = null;
let usageMessageId: string | null = null;
let usageUpdateInProgress = false;
let channelMetaCache = new Map<string, ChannelMeta>();
let channelMetaLastRefresh = 0;
function findDiscordChannel(channels: Channel[]): Channel | undefined {
return channels.find((c) => c.name.startsWith('discord') && c.isConnected());
}
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 formatResetKST(value: string | number): string {
try {
const date =
typeof value === 'number' ? new Date(value * 1000) : new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString('ko-KR', {
timeZone: 'Asia/Seoul',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return String(value);
}
}
async function refreshChannelMeta(opts: DashboardOptions): Promise<void> {
const now = Date.now();
if (now - channelMetaLastRefresh < CHANNEL_META_REFRESH_MS) return;
const ch = opts.channels.find(
(c) => c.name.startsWith('discord') && c.isConnected() && c.getChannelMeta,
);
if (!ch?.getChannelMeta) return;
const jids = Object.keys(opts.registeredGroups()).filter((j) =>
j.startsWith('dc:'),
);
try {
channelMetaCache = await ch.getChannelMeta(jids);
channelMetaLastRefresh = now;
} catch (err) {
logger.debug({ err }, 'Failed to refresh channel metadata');
}
}
function getStatusLabel(status: GroupStatus): string {
if (status.status === 'processing') {
return `처리 중 (${formatElapsed(status.elapsedMs || 0)})`;
}
if (status.status === 'idle') return '대기 중';
if (status.status === 'waiting') {
return status.pendingTasks > 0
? `큐 대기 (태스크 ${status.pendingTasks}개)`
: '큐 대기 (메시지)';
}
return '비활성';
}
function getSessionLabel(sessionId: string | undefined): string {
if (!sessionId) return '세션 없음';
const shortId = sessionId.length > 8 ? sessionId.slice(-8) : sessionId;
return `세션 ${shortId}`;
}
/** @internal - exported for testing */
export function buildStatusContent(opts: DashboardOptions): string {
const registeredGroups = opts.registeredGroups();
const sessions = opts.getSessions();
const jids = Object.keys(registeredGroups);
const statuses = opts.queue.getStatuses(jids);
const entries = statuses
.map((status) => ({
status,
group: registeredGroups[status.jid],
meta: channelMetaCache.get(status.jid),
}))
.filter((entry) => entry.group);
const categoryMap = new Map<string, typeof entries>();
for (const entry of entries) {
const category = entry.meta?.category || '기타';
if (!categoryMap.has(category)) categoryMap.set(category, []);
categoryMap.get(category)!.push(entry);
}
const sortedCategories = [...categoryMap.entries()].sort((a, b) => {
const posA = a[1][0]?.meta?.categoryPosition ?? 999;
const posB = b[1][0]?.meta?.categoryPosition ?? 999;
return posA - posB;
});
const sections: string[] = [];
let totalActive = 0;
let totalIdle = 0;
let total = 0;
for (const [categoryName, categoryEntries] of sortedCategories) {
categoryEntries.sort(
(a, b) => (a.meta?.position ?? 999) - (b.meta?.position ?? 999),
);
const lines = categoryEntries.map((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}`;
});
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;
totalIdle += categoryEntries.filter(
(entry) => entry.status.status === 'idle',
).length;
total += categoryEntries.length;
}
const header = `**에이전트 상태** (${opts.assistantName}) — 활성 ${totalActive} | 대기 ${totalIdle} | 전체 ${total}`;
return `${header}\n\n${sections.join('\n\n')}\n\n_${new Date().toLocaleTimeString('ko-KR')}_`;
}
async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
const cliUsage = await fetchClaudeUsageViaCli();
if (cliUsage) return cliUsage;
try {
const envToken = readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN']);
let token =
process.env.CLAUDE_CODE_OAUTH_TOKEN ||
envToken.CLAUDE_CODE_OAUTH_TOKEN ||
'';
if (!token) {
const configDir =
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const credsPath = path.join(configDir, '.credentials.json');
if (!fs.existsSync(credsPath)) return null;
const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8'));
token = creds?.claudeAiOauth?.accessToken || '';
}
if (!token) return null;
const res = await fetch('https://api.anthropic.com/api/oauth/usage', {
headers: {
Authorization: `Bearer ${token}`,
'anthropic-beta': 'oauth-2025-04-20',
},
});
if (!res.ok) return null;
return (await res.json()) as ClaudeUsageData;
} catch {
return null;
}
}
async function fetchCodexUsage(): Promise<CodexRateLimit[] | null> {
const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex');
const codexBin = fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex';
return new Promise((resolve) => {
let done = false;
const finish = (value: CodexRateLimit[] | null) => {
if (done) return;
done = true;
clearTimeout(timer);
if (proc) {
try {
proc.kill();
} catch {
/* ignore */
}
}
resolve(value);
};
const timer = setTimeout(() => finish(null), 20000);
let proc: ChildProcess | null = null;
try {
proc = spawn(codexBin, ['app-server'], {
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...(process.env as Record<string, string>),
PATH: [
path.dirname(process.execPath),
path.join(os.homedir(), '.npm-global', 'bin'),
process.env.PATH || '',
].join(':'),
},
});
} catch {
resolve(null);
return;
}
if (!proc.stdout || !proc.stdin) {
finish(null);
return;
}
const stdout = proc.stdout;
const stdin = proc.stdin;
proc.on('error', () => finish(null));
proc.on('close', () => finish(null));
let buf = '';
stdout.on('data', (chunk: Buffer) => {
buf += chunk.toString();
const lines = buf.split('\n');
buf = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const msg = JSON.parse(line);
if (msg.id === 1) {
stdin.write(
JSON.stringify({
jsonrpc: '2.0',
id: 2,
method: 'account/rateLimits/read',
params: {},
}) + '\n',
);
} else if (msg.id === 2 && msg.result) {
const byId = msg.result.rateLimitsByLimitId;
if (byId && typeof byId === 'object') {
finish(Object.values(byId) as CodexRateLimit[]);
} else {
finish(null);
}
}
} catch {
/* non-JSON line */
}
}
});
stdin.write(
JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { clientInfo: { name: 'usage-monitor', version: '1.0' } },
}) + '\n',
);
});
}
async function buildUsageContent(): Promise<string> {
const lines: string[] = [];
const [claudeUsage, codexUsage] = await Promise.all([
fetchClaudeUsage(),
fetchCodexUsage(),
]);
const bar = (pct: number) => {
const filled = Math.round(pct / 10);
return '█'.repeat(filled) + '░'.repeat(10 - filled);
};
lines.push('📊 *사용량*');
type UsageRow = {
name: string;
h5pct: number;
h5reset: string;
d7pct: number;
d7reset: string;
};
const rows: UsageRow[] = [];
if (claudeUsage) {
const h5 = claudeUsage.five_hour;
const d7 = claudeUsage.seven_day;
rows.push({
name: 'Claude',
h5pct: h5
? h5.utilization > 1
? Math.round(h5.utilization)
: Math.round(h5.utilization * 100)
: -1,
h5reset: h5 ? formatResetKST(h5.resets_at) : '',
d7pct: d7
? d7.utilization > 1
? Math.round(d7.utilization)
: Math.round(d7.utilization * 100)
: -1,
d7reset: d7 ? formatResetKST(d7.resets_at) : '',
});
}
if (codexUsage && Array.isArray(codexUsage)) {
const relevant = codexUsage.filter(
(limit) =>
limit.primary.usedPercent > 0 || limit.secondary.usedPercent > 0,
);
const display = relevant.length > 0 ? relevant : codexUsage.slice(0, 1);
for (const limit of display) {
rows.push({
name: 'Codex',
h5pct: Math.round(limit.primary.usedPercent),
h5reset: formatResetKST(limit.primary.resetsAt),
d7pct: Math.round(limit.secondary.usedPercent),
d7reset: formatResetKST(limit.secondary.resetsAt),
});
}
}
if (rows.length > 0) {
lines.push('```');
lines.push(' 5-Hour 7-Day');
for (const row of rows) {
const h5 =
row.h5pct >= 0
? `${bar(row.h5pct)} ${String(row.h5pct).padStart(3)}%`
: ' — ';
const d7 =
row.d7pct >= 0
? `${bar(row.d7pct)} ${String(row.d7pct).padStart(3)}%`
: ' — ';
lines.push(`${row.name.padEnd(8)}${h5} ${d7}`);
}
lines.push('```');
} else {
lines.push('_조회 불가_');
}
lines.push('');
lines.push('🖥️ *서버*');
const loadAvg = os.loadavg();
const cpuCount = os.cpus().length;
const cpuPct = Math.round((loadAvg[1] / cpuCount) * 100);
const totalMem = os.totalmem();
const usedMem = totalMem - os.freemem();
const memPct = Math.round((usedMem / totalMem) * 100);
const memUsedGB = (usedMem / 1073741824).toFixed(1);
const memTotalGB = (totalMem / 1073741824).toFixed(1);
let diskPct = 0;
let diskUsedGB = '?';
let diskTotalGB = '?';
try {
const df = execSync('df -B1 / | tail -1', {
encoding: 'utf-8',
timeout: 5000,
}).trim();
const parts = df.split(/\s+/);
const diskUsed = parseInt(parts[2], 10);
const diskTotal = parseInt(parts[1], 10);
diskPct = Math.round((diskUsed / diskTotal) * 100);
diskUsedGB = (diskUsed / 1073741824).toFixed(0);
diskTotalGB = (diskTotal / 1073741824).toFixed(0);
} catch {
/* ignore */
}
lines.push('```');
lines.push(`${'CPU'.padEnd(8)}${bar(cpuPct)} ${String(cpuPct).padStart(3)}%`);
lines.push(
`${'Memory'.padEnd(8)}${bar(memPct)} ${String(memPct).padStart(3)}% ${memUsedGB}/${memTotalGB}GB`,
);
lines.push(
`${'Disk'.padEnd(8)}${bar(diskPct)} ${String(diskPct).padStart(3)}% ${diskUsedGB}/${diskTotalGB}GB`,
);
lines.push(`${'Uptime'.padEnd(8)}${formatElapsed(os.uptime() * 1000)}`);
lines.push('```');
return (
lines.join('\n') +
`\n_${new Date().toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
})}_`
);
}
export async function purgeDashboardChannel(
opts: Pick<DashboardOptions, 'channels' | 'statusChannelId'>,
): Promise<void> {
if (!opts.statusChannelId) return;
const statusJid = `dc:${opts.statusChannelId}`;
const ch = opts.channels.find(
(channel) =>
channel.name.startsWith('discord') &&
channel.isConnected() &&
channel.purgeChannel,
);
if (ch?.purgeChannel) {
await ch.purgeChannel(statusJid);
}
}
export async function startStatusDashboard(
opts: DashboardOptions,
): Promise<void> {
if (!opts.statusChannelId) return;
const statusJid = `dc:${opts.statusChannelId}`;
const updateStatus = async () => {
const ch = findDiscordChannel(opts.channels);
if (!ch) return;
try {
await refreshChannelMeta(opts);
const content = buildStatusContent(opts);
if (statusMessageId && ch.editMessage) {
await ch.editMessage(statusJid, statusMessageId, content);
} else if (ch.sendAndTrack) {
const id = await ch.sendAndTrack(statusJid, content);
if (id) statusMessageId = id;
}
} catch (err) {
logger.debug({ err }, 'Status dashboard update failed');
statusMessageId = null;
}
};
setInterval(updateStatus, opts.statusUpdateInterval);
await updateStatus();
logger.info({ channelId: opts.statusChannelId }, 'Status dashboard started');
}
export async function startUsageDashboard(
opts: DashboardOptions,
): Promise<void> {
if (!opts.statusChannelId) return;
if (!USAGE_DASHBOARD_ENABLED) return;
const statusJid = `dc:${opts.statusChannelId}`;
const updateUsage = async () => {
if (usageUpdateInProgress) return;
usageUpdateInProgress = true;
const ch = findDiscordChannel(opts.channels);
if (!ch) {
usageUpdateInProgress = false;
return;
}
try {
const content = await buildUsageContent();
if (usageMessageId && ch.editMessage) {
await ch.editMessage(statusJid, usageMessageId, content);
} else if (ch.sendAndTrack) {
const id = await ch.sendAndTrack(statusJid, content);
if (id) usageMessageId = id;
}
} catch (err) {
logger.debug({ err }, 'Usage dashboard update failed');
usageMessageId = null;
} finally {
usageUpdateInProgress = false;
}
};
setInterval(updateUsage, opts.usageUpdateInterval);
await updateUsage();
logger.info('Usage dashboard started');
}

View File

@@ -3,12 +3,18 @@ import { describe, it, expect, beforeEach } from 'vitest';
import {
_initTestDatabase,
createTask,
deleteSession,
deleteTask,
getAllChats,
getAllRegisteredGroups,
getDueTasks,
getRegisteredAgentTypesForJid,
getMessagesSince,
getNewMessages,
isPairedRoomJid,
getSession,
getTaskById,
setSession,
setRegisteredGroup,
storeChatMetadata,
storeMessage,
@@ -187,13 +193,12 @@ describe('getMessagesSince', () => {
'2024-01-01T00:00:02.000Z',
'Andy',
);
// Should exclude m1, m2 (before/at timestamp); m3 (bot) is now included
expect(msgs).toHaveLength(2);
expect(msgs[0].content).toBe('bot reply');
expect(msgs[1].content).toBe('third');
});
it('includes bot messages with is_bot_message flag', () => {
it('includes bot messages from other senders', () => {
const msgs = getMessagesSince(
'group@g.us',
'2024-01-01T00:00:00.000Z',
@@ -204,9 +209,8 @@ describe('getMessagesSince', () => {
expect(botMsgs[0].is_bot_message).toBe(true);
});
it('returns all messages including bot when sinceTimestamp is empty', () => {
it('returns all messages when sinceTimestamp is empty', () => {
const msgs = getMessagesSince('group@g.us', '', 'Andy');
// 3 user messages + 1 bot message
expect(msgs).toHaveLength(4);
});
@@ -277,7 +281,6 @@ describe('getNewMessages', () => {
'2024-01-01T00:00:00.000Z',
'Andy',
);
// Includes bot message, returns all 4 messages
expect(messages).toHaveLength(4);
expect(newTimestamp).toBe('2024-01-01T00:00:04.000Z');
});
@@ -288,7 +291,6 @@ describe('getNewMessages', () => {
'2024-01-01T00:00:02.000Z',
'Andy',
);
// bot reply (00:00:03) + g1 msg2 (00:00:04) after ts
expect(messages).toHaveLength(2);
expect(messages[0].content).toBe('bot reply');
expect(messages[1].content).toBe('g1 msg2');
@@ -301,6 +303,16 @@ describe('getNewMessages', () => {
});
});
describe('session accessors', () => {
it('deletes only the current service session for a group', () => {
setSession('group-a', 'session-123');
expect(getSession('group-a')).toBe('session-123');
deleteSession('group-a');
expect(getSession('group-a')).toBeUndefined();
});
});
// --- storeChatMetadata ---
describe('storeChatMetadata', () => {
@@ -392,6 +404,43 @@ describe('task CRUD', () => {
deleteTask('task-3');
expect(getTaskById('task-3')).toBeUndefined();
});
it('returns due tasks only for the requested agent type', () => {
const dueAt = new Date(Date.now() - 1_000).toISOString();
createTask({
id: 'task-claude',
group_folder: 'main',
chat_jid: 'group@g.us',
prompt: 'claude task',
schedule_type: 'once',
schedule_value: dueAt,
context_mode: 'isolated',
next_run: dueAt,
status: 'active',
created_at: '2024-01-01T00:00:00.000Z',
});
createTask({
id: 'task-codex',
group_folder: 'main',
chat_jid: 'group@g.us',
agent_type: 'codex',
prompt: 'codex task',
schedule_type: 'once',
schedule_value: dueAt,
context_mode: 'isolated',
next_run: dueAt,
status: 'active',
created_at: '2024-01-01T00:00:01.000Z',
});
expect(getDueTasks('claude-code').map((task) => task.id)).toEqual([
'task-claude',
]);
expect(getDueTasks('codex').map((task) => task.id)).toEqual([
'task-codex',
]);
});
});
// --- LIMIT behavior ---
@@ -510,3 +559,41 @@ describe('registered group isMain', () => {
expect(codexGroups['dc:shared']?.name).toBe('Shared Room Codex');
});
});
describe('paired room registration', () => {
it('detects when both Claude and Codex are registered on the same jid', () => {
setRegisteredGroup('dc:123', {
name: 'Paired Room Claude',
folder: 'paired-claude',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'claude-code',
});
setRegisteredGroup('dc:123', {
name: 'Paired Room Codex',
folder: 'paired-codex',
trigger: '@Codex',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'codex',
});
expect(getRegisteredAgentTypesForJid('dc:123').sort()).toEqual([
'claude-code',
'codex',
]);
expect(isPairedRoomJid('dc:123')).toBe(true);
});
it('does not mark solo rooms as paired', () => {
setRegisteredGroup('dc:solo', {
name: 'Solo Claude Room',
folder: 'solo-claude',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'claude-code',
});
expect(getRegisteredAgentTypesForJid('dc:solo')).toEqual(['claude-code']);
expect(isPairedRoomJid('dc:solo')).toBe(false);
});
});

119
src/db.ts
View File

@@ -48,6 +48,9 @@ function createSchema(database: Database.Database): void {
id TEXT PRIMARY KEY,
group_folder TEXT NOT NULL,
chat_jid TEXT NOT NULL,
agent_type TEXT,
status_message_id TEXT,
status_started_at TEXT,
prompt TEXT NOT NULL,
schedule_type TEXT NOT NULL,
schedule_value TEXT NOT NULL,
@@ -107,6 +110,47 @@ function createSchema(database: Database.Database): void {
/* column already exists */
}
try {
database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN agent_type TEXT`);
} catch {
/* column already exists */
}
try {
database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN status_message_id TEXT`);
} catch {
/* column already exists */
}
try {
database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN status_started_at TEXT`);
} catch {
/* column already exists */
}
database.exec(`
UPDATE scheduled_tasks
SET agent_type = COALESCE(
(
SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END
FROM registered_groups
WHERE jid = scheduled_tasks.chat_jid
AND folder = scheduled_tasks.group_folder
),
(
SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END
FROM registered_groups
WHERE jid = scheduled_tasks.chat_jid
),
(
SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END
FROM registered_groups
WHERE folder = scheduled_tasks.group_folder
)
)
WHERE agent_type IS NULL;
`);
// Add is_bot_message column if it doesn't exist (migration for existing DBs)
try {
database.exec(
@@ -476,17 +520,31 @@ export function getLastHumanMessageTimestamp(chatJid: string): string | null {
}
export function createTask(
task: Omit<ScheduledTask, 'last_run' | 'last_result'>,
task: Omit<
ScheduledTask,
| 'last_run'
| 'last_result'
| 'agent_type'
| 'status_message_id'
| 'status_started_at'
> & {
agent_type?: AgentType | null;
status_message_id?: string | null;
status_started_at?: string | null;
},
): void {
db.prepare(
`
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, agent_type, status_message_id, status_started_at, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
task.id,
task.group_folder,
task.chat_jid,
task.agent_type || SERVICE_AGENT_TYPE,
task.status_message_id || null,
task.status_started_at || null,
task.prompt,
task.schedule_type,
task.schedule_value,
@@ -503,7 +561,18 @@ export function getTaskById(id: string): ScheduledTask | undefined {
| undefined;
}
export function getTasksForGroup(groupFolder: string): ScheduledTask[] {
export function getTasksForGroup(
groupFolder: string,
agentType?: AgentType,
): ScheduledTask[] {
if (agentType) {
return db
.prepare(
'SELECT * FROM scheduled_tasks WHERE group_folder = ? AND agent_type = ? ORDER BY created_at DESC',
)
.all(groupFolder, agentType) as ScheduledTask[];
}
return db
.prepare(
'SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC',
@@ -511,7 +580,15 @@ export function getTasksForGroup(groupFolder: string): ScheduledTask[] {
.all(groupFolder) as ScheduledTask[];
}
export function getAllTasks(): ScheduledTask[] {
export function getAllTasks(agentType?: AgentType): ScheduledTask[] {
if (agentType) {
return db
.prepare(
'SELECT * FROM scheduled_tasks WHERE agent_type = ? ORDER BY created_at DESC',
)
.all(agentType) as ScheduledTask[];
}
return db
.prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC')
.all() as ScheduledTask[];
@@ -558,23 +635,49 @@ export function updateTask(
).run(...values);
}
export function updateTaskStatusTracking(
id: string,
updates: Partial<Pick<ScheduledTask, 'status_message_id' | 'status_started_at'>>,
): void {
const fields: string[] = [];
const values: unknown[] = [];
if (updates.status_message_id !== undefined) {
fields.push('status_message_id = ?');
values.push(updates.status_message_id);
}
if (updates.status_started_at !== undefined) {
fields.push('status_started_at = ?');
values.push(updates.status_started_at);
}
if (fields.length === 0) return;
values.push(id);
db.prepare(
`UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`,
).run(...values);
}
export function deleteTask(id: string): void {
// Delete child records first (FK constraint)
db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id);
db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id);
}
export function getDueTasks(): ScheduledTask[] {
export function getDueTasks(
agentType: AgentType = SERVICE_AGENT_TYPE,
): ScheduledTask[] {
const now = new Date().toISOString();
return db
.prepare(
`
SELECT * FROM scheduled_tasks
WHERE status = 'active' AND next_run IS NOT NULL AND next_run <= ?
WHERE status = 'active' AND agent_type = ? AND next_run IS NOT NULL AND next_run <= ?
ORDER BY next_run
`,
)
.all(now) as ScheduledTask[];
.all(agentType, now) as ScheduledTask[];
}
export function updateTaskAfterRun(

View File

@@ -4,7 +4,7 @@ import { GroupQueue } from './group-queue.js';
// Mock config to control concurrency limit
vi.mock('./config.js', () => ({
DATA_DIR: '/tmp/nanoclaw-test-data',
DATA_DIR: '/tmp/ejclaw-test-data',
MAX_CONCURRENT_AGENTS: 2,
}));
@@ -166,6 +166,29 @@ describe('GroupQueue', () => {
expect(callCount).toBe(3);
});
it('does not bypass retry backoff when new messages arrive', async () => {
let callCount = 0;
const processMessages = vi.fn(async () => {
callCount++;
return false;
});
queue.setProcessMessagesFn(processMessages);
queue.enqueueMessageCheck('group1@g.us');
await vi.advanceTimersByTimeAsync(10);
expect(callCount).toBe(1);
queue.enqueueMessageCheck('group1@g.us');
await vi.advanceTimersByTimeAsync(1000);
expect(callCount).toBe(1);
await vi.advanceTimersByTimeAsync(4000);
await vi.advanceTimersByTimeAsync(10);
expect(callCount).toBe(2);
});
// --- Shutdown prevents new enqueues ---
it('prevents new enqueues after shutdown', async () => {
@@ -413,6 +436,34 @@ describe('GroupQueue', () => {
await vi.advanceTimersByTimeAsync(10);
});
it('does not pipe follow-up messages to an agent after closeStdin', async () => {
let resolveProcess: () => void;
const processMessages = vi.fn(async () => {
await new Promise<void>((resolve) => {
resolveProcess = resolve;
});
return true;
});
queue.setProcessMessagesFn(processMessages);
queue.enqueueMessageCheck('group1@g.us', 'test-group');
await vi.advanceTimersByTimeAsync(10);
queue.registerProcess('group1@g.us', {} as any, 'agent-1', 'test-group');
queue.notifyIdle('group1@g.us');
queue.closeStdin('group1@g.us');
expect(queue.sendMessage('group1@g.us', 'hello after clear')).toBe(false);
queue.enqueueMessageCheck('group1@g.us', 'test-group');
resolveProcess!();
await vi.advanceTimersByTimeAsync(10);
expect(processMessages).toHaveBeenCalledTimes(2);
});
it('preempts when idle arrives with pending tasks', async () => {
const fs = await import('fs');
let resolveProcess: () => void;

View File

@@ -11,20 +11,29 @@ interface QueuedTask {
fn: () => Promise<void>;
}
export interface GroupRunContext {
runId: string;
reason: 'messages' | 'drain';
}
const MAX_RETRIES = 5;
const BASE_RETRY_MS = 5000;
interface GroupState {
active: boolean;
idleWaiting: boolean;
closingStdin: boolean;
isTaskProcess: boolean;
runningTaskId: string | null;
currentRunId: string | null;
pendingMessages: boolean;
pendingTasks: QueuedTask[];
process: ChildProcess | null;
processName: string | null;
groupFolder: string | null;
retryCount: number;
retryTimer: ReturnType<typeof setTimeout> | null;
retryScheduledAt: number | null;
startedAt: number | null;
}
@@ -40,8 +49,9 @@ export class GroupQueue {
private groups = new Map<string, GroupState>();
private activeCount = 0;
private waitingGroups: string[] = [];
private processMessagesFn: ((groupJid: string) => Promise<boolean>) | null =
null;
private processMessagesFn:
| ((groupJid: string, context: GroupRunContext) => Promise<boolean>)
| null = null;
private shuttingDown = false;
private getGroup(groupJid: string): GroupState {
@@ -50,14 +60,18 @@ export class GroupQueue {
state = {
active: false,
idleWaiting: false,
closingStdin: false,
isTaskProcess: false,
runningTaskId: null,
currentRunId: null,
pendingMessages: false,
pendingTasks: [],
process: null,
processName: null,
groupFolder: null,
retryCount: 0,
retryTimer: null,
retryScheduledAt: null,
startedAt: null,
};
this.groups.set(groupJid, state);
@@ -65,10 +79,16 @@ export class GroupQueue {
return state;
}
setProcessMessagesFn(fn: (groupJid: string) => Promise<boolean>): void {
setProcessMessagesFn(
fn: (groupJid: string, context: GroupRunContext) => Promise<boolean>,
): void {
this.processMessagesFn = fn;
}
private createRunId(): string {
return `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
enqueueMessageCheck(groupJid: string, groupFolder?: string): void {
if (this.shuttingDown) return;
@@ -85,6 +105,22 @@ export class GroupQueue {
return;
}
if (
state.retryScheduledAt !== null &&
Date.now() < state.retryScheduledAt
) {
state.pendingMessages = true;
logger.debug(
{
groupJid,
retryCount: state.retryCount,
retryScheduledAt: state.retryScheduledAt,
},
'Retry backoff active, message queued until retry window opens',
);
return;
}
if (this.activeCount >= MAX_CONCURRENT_AGENTS) {
state.pendingMessages = true;
if (!this.waitingGroups.includes(groupJid)) {
@@ -154,17 +190,38 @@ export class GroupQueue {
state.process = proc;
state.processName = processName;
if (groupFolder) state.groupFolder = groupFolder;
logger.info(
{
groupJid,
runId: state.currentRunId,
processName,
groupFolder: state.groupFolder,
isTaskProcess: state.isTaskProcess,
},
'Registered active process for group',
);
}
/**
* Mark the agent process as idle-waiting (finished work, waiting for IPC input).
* If tasks are pending, preempt the idle agent process immediately.
*/
notifyIdle(groupJid: string): void {
notifyIdle(groupJid: string, runId?: string): void {
const state = this.getGroup(groupJid);
state.idleWaiting = true;
logger.info(
{
groupJid,
runId: runId ?? state.currentRunId,
pendingTasks: state.pendingTasks.length,
},
'Agent entered idle wait state',
);
if (state.pendingTasks.length > 0) {
this.closeStdin(groupJid);
this.closeStdin(groupJid, {
runId: runId ?? state.currentRunId ?? undefined,
reason: 'pending-task-preemption',
});
}
}
@@ -174,8 +231,27 @@ export class GroupQueue {
*/
sendMessage(groupJid: string, text: string): boolean {
const state = this.getGroup(groupJid);
if (!state.active || !state.groupFolder || state.isTaskProcess)
if (!state.active || !state.groupFolder || state.isTaskProcess) {
logger.debug(
{
groupJid,
runId: state.currentRunId,
active: state.active,
closingStdin: state.closingStdin,
groupFolder: state.groupFolder,
isTaskProcess: state.isTaskProcess,
},
'Cannot pipe follow-up message to active agent',
);
return false;
}
if (state.closingStdin) {
logger.info(
{ groupJid, runId: state.currentRunId, groupFolder: state.groupFolder },
'Skipping follow-up IPC because active agent is closing',
);
return false;
}
state.idleWaiting = false; // Agent is about to receive work, no longer idle
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
@@ -186,8 +262,27 @@ export class GroupQueue {
const tempPath = `${filepath}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify({ type: 'message', text }));
fs.renameSync(tempPath, filepath);
logger.info(
{
groupJid,
runId: state.currentRunId,
groupFolder: state.groupFolder,
textLength: text.length,
filename,
},
'Queued follow-up message for active agent',
);
return true;
} catch {
} catch (err) {
logger.warn(
{
groupJid,
runId: state.currentRunId,
groupFolder: state.groupFolder,
err,
},
'Failed to queue follow-up message for active agent',
);
return false;
}
}
@@ -195,16 +290,39 @@ export class GroupQueue {
/**
* Signal the active agent process to wind down by writing a close sentinel.
*/
closeStdin(groupJid: string): void {
closeStdin(
groupJid: string,
metadata?: { runId?: string; reason?: string },
): void {
const state = this.getGroup(groupJid);
if (!state.active || !state.groupFolder) return;
state.closingStdin = true;
state.idleWaiting = false;
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
try {
fs.mkdirSync(inputDir, { recursive: true });
fs.writeFileSync(path.join(inputDir, '_close'), '');
} catch {
// ignore
logger.info(
{
groupJid,
runId: metadata?.runId ?? state.currentRunId,
groupFolder: state.groupFolder,
reason: metadata?.reason ?? 'unspecified',
},
'Signaled active agent to close stdin',
);
} catch (err) {
logger.warn(
{
groupJid,
runId: metadata?.runId ?? state.currentRunId,
groupFolder: state.groupFolder,
reason: metadata?.reason ?? 'unspecified',
err,
},
'Failed to signal active agent to close stdin',
);
}
}
@@ -213,36 +331,65 @@ export class GroupQueue {
reason: 'messages' | 'drain',
): Promise<void> {
const state = this.getGroup(groupJid);
const runId = this.createRunId();
state.active = true;
state.idleWaiting = false;
state.closingStdin = false;
state.isTaskProcess = false;
state.currentRunId = runId;
state.pendingMessages = false;
state.startedAt = Date.now();
this.activeCount++;
logger.debug(
{ groupJid, reason, activeCount: this.activeCount },
'Starting agent process for group',
logger.info(
{ groupJid, runId, reason, activeCount: this.activeCount },
'Starting group message run',
);
let outcome: 'success' | 'retry_scheduled' | 'error' = 'success';
try {
if (this.processMessagesFn) {
const success = await this.processMessagesFn(groupJid);
const success = await this.processMessagesFn(groupJid, {
runId,
reason,
});
if (success) {
state.retryCount = 0;
state.retryScheduledAt = null;
} else {
this.scheduleRetry(groupJid, state);
outcome = 'retry_scheduled';
this.scheduleRetry(groupJid, state, runId);
}
}
} catch (err) {
logger.error({ groupJid, err }, 'Error processing messages for group');
this.scheduleRetry(groupJid, state);
outcome = 'error';
logger.error(
{ groupJid, runId, err },
'Error processing messages for group',
);
this.scheduleRetry(groupJid, state, runId);
} finally {
const durationMs = state.startedAt ? Date.now() - state.startedAt : null;
logger.info(
{
groupJid,
runId,
reason,
outcome,
durationMs,
pendingMessages: state.pendingMessages,
pendingTasks: state.pendingTasks.length,
},
'Finished group message run',
);
state.active = false;
state.startedAt = null;
state.idleWaiting = false;
state.closingStdin = false;
state.process = null;
state.processName = null;
state.groupFolder = null;
state.currentRunId = null;
this.activeCount--;
this.drainGroup(groupJid);
}
@@ -252,6 +399,7 @@ export class GroupQueue {
const state = this.getGroup(groupJid);
state.active = true;
state.idleWaiting = false;
state.closingStdin = false;
state.isTaskProcess = true;
state.runningTaskId = task.id;
state.startedAt = Date.now();
@@ -271,6 +419,8 @@ export class GroupQueue {
state.isTaskProcess = false;
state.runningTaskId = null;
state.startedAt = null;
state.idleWaiting = false;
state.closingStdin = false;
state.process = null;
state.processName = null;
state.groupFolder = null;
@@ -279,11 +429,20 @@ export class GroupQueue {
}
}
private scheduleRetry(groupJid: string, state: GroupState): void {
private scheduleRetry(
groupJid: string,
state: GroupState,
runId?: string,
): void {
state.retryCount++;
if (state.retryCount > MAX_RETRIES) {
if (state.retryTimer) {
clearTimeout(state.retryTimer);
state.retryTimer = null;
}
state.retryScheduledAt = null;
logger.error(
{ groupJid, retryCount: state.retryCount },
{ groupJid, runId, retryCount: state.retryCount },
'Max retries exceeded, dropping messages (will retry on next incoming message)',
);
state.retryCount = 0;
@@ -291,11 +450,17 @@ export class GroupQueue {
}
const delayMs = BASE_RETRY_MS * Math.pow(2, state.retryCount - 1);
state.retryScheduledAt = Date.now() + delayMs;
logger.info(
{ groupJid, retryCount: state.retryCount, delayMs },
{ groupJid, runId, retryCount: state.retryCount, delayMs },
'Scheduling retry with backoff',
);
setTimeout(() => {
if (state.retryTimer) {
clearTimeout(state.retryTimer);
}
state.retryTimer = setTimeout(() => {
state.retryTimer = null;
state.retryScheduledAt = null;
if (!this.shuttingDown) {
this.enqueueMessageCheck(groupJid);
}
@@ -412,7 +577,7 @@ export class GroupQueue {
// via idle timeout or agent timeout.
// This prevents reconnection restarts from killing working agents.
const activeProcesses: string[] = [];
for (const [jid, state] of this.groups) {
for (const [, state] of this.groups) {
if (state.process && !state.process.killed && state.processName) {
activeProcesses.push(state.processName);
}

View File

@@ -1544,8 +1544,7 @@ async function main(): Promise<void> {
);
},
getAvailableGroups,
writeGroupsSnapshot: (gf, im, ag, rj) =>
writeGroupsSnapshot(gf, im, ag, rj),
writeGroupsSnapshot,
});
queue.setProcessMessagesFn(processGroupMessages);
recoverPendingMessages();

View File

@@ -107,6 +107,31 @@ describe('schedule_task authorization', () => {
expect(allTasks[0].group_folder).toBe('other-group');
});
it('stores the target group agent type on scheduled tasks', async () => {
groups['other@g.us'] = {
...OTHER_GROUP,
agentType: 'codex',
};
setRegisteredGroup('other@g.us', groups['other@g.us']);
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'codex owned task',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00',
targetJid: 'other@g.us',
},
'whatsapp_main',
true,
deps,
);
const allTasks = getAllTasks();
expect(allTasks).toHaveLength(1);
expect(allTasks[0].agent_type).toBe('codex');
});
it('non-main group cannot schedule for another group', async () => {
await processTaskIpc(
{

View File

@@ -3,7 +3,12 @@ import path from 'path';
import { CronExpressionParser } from 'cron-parser';
import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } from './config.js';
import {
DATA_DIR,
IPC_POLL_INTERVAL,
SERVICE_AGENT_TYPE,
TIMEZONE,
} from './config.js';
import { AvailableGroup } from './agent-runner.js';
import { createTask, deleteTask, getTaskById, updateTask } from './db.js';
import { isValidGroupFolder } from './group-folder.js';
@@ -20,7 +25,6 @@ export interface IpcDeps {
groupFolder: string,
isMain: boolean,
availableGroups: AvailableGroup[],
registeredJids: Set<string>,
) => void;
}
@@ -258,6 +262,7 @@ export async function processTaskIpc(
id: taskId,
group_folder: targetFolder,
chat_jid: targetJid,
agent_type: targetGroupEntry.agentType || SERVICE_AGENT_TYPE,
prompt: data.prompt,
schedule_type: scheduleType,
schedule_value: data.schedule_value,
@@ -267,7 +272,13 @@ export async function processTaskIpc(
created_at: new Date().toISOString(),
});
logger.info(
{ taskId, sourceGroup, targetFolder, contextMode },
{
taskId,
sourceGroup,
targetFolder,
contextMode,
agentType: targetGroupEntry.agentType || SERVICE_AGENT_TYPE,
},
'Task created via IPC',
);
}
@@ -401,12 +412,7 @@ export async function processTaskIpc(
await deps.syncGroups(true);
// Write updated snapshot immediately
const availableGroups = deps.getAvailableGroups();
deps.writeGroupsSnapshot(
sourceGroup,
true,
availableGroups,
new Set(Object.keys(registeredGroups)),
);
deps.writeGroupsSnapshot(sourceGroup, true, availableGroups);
} else {
logger.warn(
{ sourceGroup },

View File

@@ -1,7 +1,10 @@
import pino from 'pino';
const serviceName = (process.env.ASSISTANT_NAME || 'claude').toLowerCase();
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
name: serviceName,
transport: { target: 'pino-pretty', options: { colorize: true } },
});

707
src/message-runtime.test.ts Normal file
View File

@@ -0,0 +1,707 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./agent-runner.js', () => ({
runAgentProcess: vi.fn(),
writeGroupsSnapshot: vi.fn(),
writeTasksSnapshot: vi.fn(),
}));
vi.mock('./config.js', () => ({
isSessionCommandSenderAllowed: vi.fn(() => false),
}));
vi.mock('./db.js', () => ({
getAllChats: vi.fn(() => []),
getAllTasks: vi.fn(() => []),
getLastHumanMessageTimestamp: vi.fn(() => null),
getMessagesSince: vi.fn(),
getNewMessages: vi.fn(() => ({ messages: [], newTimestamp: '' })),
}));
vi.mock('./logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('./sender-allowlist.js', () => ({
isTriggerAllowed: vi.fn(() => true),
loadSenderAllowlist: vi.fn(() => ({})),
}));
vi.mock('./session-commands.js', () => ({
extractSessionCommand: vi.fn(() => null),
handleSessionCommand: vi.fn(async () => ({ handled: false })),
isSessionCommandAllowed: vi.fn(() => true),
isSessionCommandControlMessage: vi.fn(() => false),
}));
import * as agentRunner from './agent-runner.js';
import * as db from './db.js';
import { createMessageRuntime } from './message-runtime.js';
import type { Channel, RegisteredGroup } from './types.js';
function makeGroup(agentType: 'claude-code' | 'codex'): RegisteredGroup {
return {
name: 'Test Group',
folder: `test-${agentType}`,
trigger: '@Andy',
added_at: new Date().toISOString(),
requiresTrigger: false,
agentType,
};
}
function makeChannel(chatJid: string): Channel {
return {
name: 'discord',
connect: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
sendAndTrack: vi.fn().mockResolvedValue('progress-1'),
isConnected: vi.fn(() => true),
ownsJid: vi.fn((jid: string) => jid === chatJid),
disconnect: vi.fn().mockResolvedValue(undefined),
setTyping: vi.fn().mockResolvedValue(undefined),
editMessage: vi.fn().mockResolvedValue(undefined),
};
}
describe('createMessageRuntime', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('clears Claude sessions and closes stdin immediately on poisoned output', async () => {
const chatJid = 'group@test';
const group = makeGroup('claude-code');
const channel = makeChannel(chatJid);
const closeStdin = vi.fn();
const notifyIdle = vi.fn();
const persistSession = vi.fn();
const clearSession = vi.fn();
const saveState = vi.fn();
const lastAgentTimestamps: Record<string, string> = {};
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-18T09:00:00.000Z',
},
]);
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
result:
'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.',
newSessionId: 'session-123',
});
return {
status: 'success',
result: null,
newSessionId: 'session-123',
};
},
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin,
notifyIdle,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => lastAgentTimestamps,
saveState,
persistSession,
clearSession,
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-1',
reason: 'messages',
});
expect(result).toBe(true);
expect(persistSession).toHaveBeenCalledWith(group.folder, 'session-123');
expect(clearSession).toHaveBeenCalledWith(group.folder);
expect(closeStdin).toHaveBeenCalledWith(chatJid, {
runId: 'run-1',
reason: 'poisoned-session',
});
expect(notifyIdle).not.toHaveBeenCalled();
expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid,
'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.',
);
expect(lastAgentTimestamps[chatJid]).toBe('2026-03-18T09:00:00.000Z');
expect(saveState).toHaveBeenCalled();
});
it('does not apply the poisoned-session handling to Codex groups', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
const closeStdin = vi.fn();
const notifyIdle = vi.fn();
const clearSession = vi.fn();
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-18T09:00:00.000Z',
},
]);
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
result:
'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.',
newSessionId: 'session-456',
});
return {
status: 'success',
result: null,
newSessionId: 'session-456',
};
},
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin,
notifyIdle,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => ({}),
saveState: vi.fn(),
persistSession: vi.fn(),
clearSession,
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-2',
reason: 'messages',
});
expect(result).toBe(true);
expect(clearSession).not.toHaveBeenCalled();
expect(closeStdin).not.toHaveBeenCalled();
expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-2');
});
it('tracks Codex progress in one editable message and updates elapsed time every 10 seconds', async () => {
vi.useFakeTimers();
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
const notifyIdle = vi.fn();
const persistSession = vi.fn();
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-19T00:00:00.000Z',
},
]);
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'progress',
result: 'CI 상태 확인 중입니다.',
newSessionId: 'session-progress',
});
expect(notifyIdle).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(10_000);
await onOutput?.({
status: 'success',
result: null,
newSessionId: 'session-progress',
});
return {
status: 'success',
result: null,
newSessionId: 'session-progress',
};
},
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => ({}),
saveState: vi.fn(),
persistSession,
clearSession: vi.fn(),
});
try {
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-progress',
reason: 'messages',
});
expect(result).toBe(true);
expect(channel.sendAndTrack).toHaveBeenCalledWith(
chatJid,
'CI 상태 확인 중입니다.\n\n0초',
);
expect(channel.editMessage).toHaveBeenCalledWith(
chatJid,
'progress-1',
'CI 상태 확인 중입니다.\n\n10초',
);
expect(channel.sendMessage).not.toHaveBeenCalled();
expect(notifyIdle).toHaveBeenCalledTimes(1);
expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-progress');
expect(persistSession).toHaveBeenCalledWith(
group.folder,
'session-progress',
);
} finally {
vi.useRealTimers();
}
});
it('formats longer Codex progress durations with minutes and hours', async () => {
vi.useFakeTimers();
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-19T00:00:00.000Z',
},
]);
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'progress',
result: '오래 걸리는 작업입니다.',
newSessionId: 'session-long-progress',
});
await vi.advanceTimersByTimeAsync(70_000);
expect(channel.editMessage).toHaveBeenLastCalledWith(
chatJid,
'progress-1',
'오래 걸리는 작업입니다.\n\n1분 10초',
);
await vi.advanceTimersByTimeAsync(50_000);
expect(channel.editMessage).toHaveBeenLastCalledWith(
chatJid,
'progress-1',
'오래 걸리는 작업입니다.\n\n2분 0초',
);
await vi.advanceTimersByTimeAsync(3_480_000);
expect(channel.editMessage).toHaveBeenLastCalledWith(
chatJid,
'progress-1',
'오래 걸리는 작업입니다.\n\n1시간 0분 0초',
);
await vi.advanceTimersByTimeAsync(70_000);
await onOutput?.({
status: 'success',
result: null,
newSessionId: 'session-long-progress',
});
return {
status: 'success',
result: null,
newSessionId: 'session-long-progress',
};
},
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => ({}),
saveState: vi.fn(),
persistSession: vi.fn(),
clearSession: vi.fn(),
});
try {
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-long-progress',
reason: 'messages',
});
expect(result).toBe(true);
expect(channel.sendAndTrack).toHaveBeenCalledWith(
chatJid,
'오래 걸리는 작업입니다.\n\n0초',
);
expect(channel.editMessage).toHaveBeenLastCalledWith(
chatJid,
'progress-1',
'오래 걸리는 작업입니다.\n\n1시간 1분 10초',
);
} finally {
vi.useRealTimers();
}
});
it('keeps progress separate from the final Codex answer', async () => {
vi.useFakeTimers();
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
const notifyIdle = vi.fn();
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-19T00:00:00.000Z',
},
]);
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'progress',
result: '테스트를 돌리는 중입니다.',
newSessionId: 'session-final',
});
await vi.advanceTimersByTimeAsync(10_000);
await onOutput?.({
status: 'success',
phase: 'final',
result: '테스트가 끝났습니다.',
newSessionId: 'session-final',
});
return {
status: 'success',
result: null,
newSessionId: 'session-final',
};
},
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => ({}),
saveState: vi.fn(),
persistSession: vi.fn(),
clearSession: vi.fn(),
});
try {
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-final',
reason: 'messages',
});
expect(result).toBe(true);
expect(channel.sendAndTrack).toHaveBeenCalledWith(
chatJid,
'테스트를 돌리는 중입니다.\n\n0초',
);
expect(channel.editMessage).toHaveBeenCalledWith(
chatJid,
'progress-1',
'테스트를 돌리는 중입니다.\n\n10초',
);
expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid,
'테스트가 끝났습니다.',
);
expect(notifyIdle).toHaveBeenCalledTimes(1);
expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-final');
} finally {
vi.useRealTimers();
}
});
it('starts a fresh tracked progress message for each Codex turn in one runner session', async () => {
vi.useFakeTimers();
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-19T00:00:00.000Z',
},
]);
vi.mocked(channel.sendAndTrack!)
.mockResolvedValueOnce('progress-1')
.mockResolvedValueOnce('progress-2');
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'progress',
result: '첫 번째 진행상황입니다.',
newSessionId: 'session-multi-turn',
});
await vi.advanceTimersByTimeAsync(10_000);
await onOutput?.({
status: 'success',
phase: 'final',
result: '첫 번째 결과입니다.',
newSessionId: 'session-multi-turn',
});
await onOutput?.({
status: 'success',
phase: 'progress',
result: '두 번째 진행상황입니다.',
newSessionId: 'session-multi-turn',
});
await vi.advanceTimersByTimeAsync(10_000);
await onOutput?.({
status: 'success',
phase: 'final',
result: '두 번째 결과입니다.',
newSessionId: 'session-multi-turn',
});
return {
status: 'success',
result: null,
newSessionId: 'session-multi-turn',
};
},
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => ({}),
saveState: vi.fn(),
persistSession: vi.fn(),
clearSession: vi.fn(),
});
try {
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-multi-turn',
reason: 'messages',
});
expect(result).toBe(true);
expect(channel.sendAndTrack).toHaveBeenNthCalledWith(
1,
chatJid,
'첫 번째 진행상황입니다.\n\n0초',
);
expect(channel.sendAndTrack).toHaveBeenNthCalledWith(
2,
chatJid,
'두 번째 진행상황입니다.\n\n0초',
);
expect(channel.editMessage).toHaveBeenNthCalledWith(
1,
chatJid,
'progress-1',
'첫 번째 진행상황입니다.\n\n10초',
);
expect(channel.editMessage).toHaveBeenNthCalledWith(
2,
chatJid,
'progress-2',
'두 번째 진행상황입니다.\n\n10초',
);
expect(channel.sendMessage).toHaveBeenNthCalledWith(
1,
chatJid,
'첫 번째 결과입니다.',
);
expect(channel.sendMessage).toHaveBeenNthCalledWith(
2,
chatJid,
'두 번째 결과입니다.',
);
} finally {
vi.useRealTimers();
}
});
it('does not roll back when a streamed progress message was already posted before an error', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
const saveState = vi.fn();
const lastAgentTimestamps: Record<string, string> = {};
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: 'hello',
timestamp: '2026-03-19T00:00:00.000Z',
},
]);
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'progress',
result: '중간 진행상황입니다.',
newSessionId: 'session-error',
});
await onOutput?.({
status: 'error',
result: null,
newSessionId: 'session-error',
error: 'temporary failure',
});
return {
status: 'error',
result: null,
newSessionId: 'session-error',
error: 'temporary failure',
};
},
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => lastAgentTimestamps,
saveState,
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-progress-error',
reason: 'messages',
});
expect(result).toBe(true);
expect(channel.sendAndTrack).toHaveBeenCalledWith(
chatJid,
'중간 진행상황입니다.\n\n0초',
);
expect(lastAgentTimestamps[chatJid]).toBe('2026-03-19T00:00:00.000Z');
expect(saveState).toHaveBeenCalled();
});
});

728
src/message-runtime.ts Normal file
View File

@@ -0,0 +1,728 @@
import {
AgentOutput,
AvailableGroup,
runAgentProcess,
writeGroupsSnapshot,
writeTasksSnapshot,
} from './agent-runner.js';
import {
getAllChats,
getAllTasks,
getLastHumanMessageTimestamp,
getMessagesSince,
getNewMessages,
} from './db.js';
import { isSessionCommandSenderAllowed } from './config.js';
import { GroupQueue, GroupRunContext } from './group-queue.js';
import { findChannel, formatMessages, formatOutbound } from './router.js';
import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js';
import {
extractSessionCommand,
handleSessionCommand,
isSessionCommandAllowed,
isSessionCommandControlMessage,
} from './session-commands.js';
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
import { isTaskStatusControlMessage } from './task-scheduler.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js';
export interface MessageRuntimeDeps {
assistantName: string;
idleTimeout: number;
pollInterval: number;
timezone: string;
triggerPattern: RegExp;
channels: Channel[];
queue: GroupQueue;
getRegisteredGroups: () => Record<string, RegisteredGroup>;
getSessions: () => Record<string, string>;
getLastTimestamp: () => string;
setLastTimestamp: (timestamp: string) => void;
getLastAgentTimestamps: () => Record<string, string>;
saveState: () => void;
persistSession: (groupFolder: string, sessionId: 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): {
processGroupMessages: (
chatJid: string,
context: GroupRunContext,
) => Promise<boolean>;
recoverPendingMessages: () => void;
startMessageLoop: () => Promise<void>;
} {
let messageLoopRunning = false;
const filterOwnChannelMessages = (messages: NewMessage[]): NewMessage[] =>
messages.filter((msg) => {
const channel = findChannel(deps.channels, msg.chat_jid);
if (channel?.isOwnMessage?.(msg)) {
return false;
}
if (msg.is_bot_message && isSessionCommandControlMessage(msg.content)) {
return false;
}
if (msg.is_bot_message && isTaskStatusControlMessage(msg.content)) {
return false;
}
return true;
});
const getCurrentAvailableGroups = (): AvailableGroup[] =>
getAvailableGroups(deps.getRegisteredGroups());
const runAgent = async (
group: RegisteredGroup,
prompt: string,
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,
})),
);
writeGroupsSnapshot(group.folder, isMain, getCurrentAvailableGroups());
let resetSessionRequested = false;
const wrappedOnOutput = onOutput
? async (output: AgentOutput) => {
if (output.newSessionId) {
deps.persistSession(group.folder, output.newSessionId);
}
if (isClaudeCodeAgent && shouldResetSessionOnAgentFailure(output)) {
resetSessionRequested = true;
}
await onOutput(output);
}
: undefined;
try {
const output = await runAgentProcess(
group,
{
prompt,
sessionId,
groupFolder: group.folder,
chatJid,
runId,
isMain,
assistantName: deps.assistantName,
},
(proc, processName) =>
deps.queue.registerProcess(chatJid, proc, processName, group.folder),
wrappedOnOutput,
);
if (output.newSessionId) {
deps.persistSession(group.folder, output.newSessionId);
}
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') {
logger.error(
{ group: group.name, chatJid, runId, error: output.error },
'Agent process error',
);
return 'error';
}
return 'success';
} catch (err) {
logger.error({ group: group.name, chatJid, runId, err }, 'Agent error');
return 'error';
}
};
const processGroupMessages = async (
chatJid: string,
context: GroupRunContext,
): Promise<boolean> => {
const { runId, reason } = context;
const registeredGroups = deps.getRegisteredGroups();
const group = registeredGroups[chatJid];
if (!group) {
logger.warn(
{ chatJid, runId, reason },
'Registered group missing for queued run',
);
return true;
}
const channel = findChannel(deps.channels, chatJid);
if (!channel) {
logger.warn(
{ chatJid, runId, reason },
'No channel owns JID, skipping messages',
);
return true;
}
const isMainGroup = group.isMain === true;
const lastAgentTimestamps = deps.getLastAgentTimestamps();
const sinceTimestamp = lastAgentTimestamps[chatJid] || '';
const missedMessages = filterOwnChannelMessages(
getMessagesSince(chatJid, sinceTimestamp, deps.assistantName),
);
if (missedMessages.length === 0) {
logger.info(
{
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
reason,
},
'No pending messages for queued run',
);
return true;
}
logger.info(
{
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
reason,
messageCount: missedMessages.length,
sinceTimestamp,
},
'Loaded pending messages for queued run',
);
const cmdResult = await handleSessionCommand({
missedMessages,
isMainGroup,
groupName: group.name,
runId,
triggerPattern: deps.triggerPattern,
timezone: deps.timezone,
deps: {
sendMessage: (text) => channel.sendMessage(chatJid, text),
setTyping: (typing) =>
channel.setTyping?.(chatJid, typing) ?? Promise.resolve(),
runAgent: (prompt, onOutput) =>
runAgent(group, prompt, chatJid, runId, onOutput),
closeStdin: () =>
deps.queue.closeStdin(chatJid, {
runId,
reason: 'session-command',
}),
clearSession: () => deps.clearSession(group.folder),
advanceCursor: (timestamp) => {
lastAgentTimestamps[chatJid] = timestamp;
deps.saveState();
},
formatMessages,
isAdminSender: (msg) => isSessionCommandSenderAllowed(msg.sender),
canSenderInteract: (msg) => {
const hasTrigger = deps.triggerPattern.test(msg.content.trim());
const requiresTrigger =
!isMainGroup && group.requiresTrigger !== false;
return (
isMainGroup ||
!requiresTrigger ||
(hasTrigger &&
(msg.is_from_me ||
isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist())))
);
},
},
});
if (cmdResult.handled) return cmdResult.success;
if (!isMainGroup && group.requiresTrigger !== false) {
const allowlistCfg = loadSenderAllowlist();
const hasTrigger = missedMessages.some(
(msg) =>
deps.triggerPattern.test(msg.content.trim()) &&
(msg.is_from_me ||
isTriggerAllowed(chatJid, msg.sender, allowlistCfg)),
);
if (!hasTrigger) {
logger.info(
{ chatJid, group: group.name, groupFolder: group.folder, runId },
'Skipping queued run because no allowed trigger was found',
);
return true;
}
}
const prompt = formatMessages(missedMessages, deps.timezone);
const previousCursor = lastAgentTimestamps[chatJid] || '';
lastAgentTimestamps[chatJid] =
missedMessages[missedMessages.length - 1].timestamp;
deps.saveState();
logger.info(
{
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
messageCount: missedMessages.length,
},
'Dispatching queued messages to agent',
);
let idleTimer: ReturnType<typeof setTimeout> | null = null;
let latestProgressText: string | null = null;
let latestProgressRendered: string | null = null;
let progressMessageId: string | null = null;
let progressStartedAt: number | null = null;
let progressTicker: ReturnType<typeof setInterval> | null = null;
const resetIdleTimer = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
logger.info(
{ chatJid, group: group.name, groupFolder: group.folder, runId },
'Idle timeout reached, closing active agent stdin',
);
deps.queue.closeStdin(chatJid, {
runId,
reason: 'idle-timeout',
});
}, deps.idleTimeout);
};
let hadError = false;
let finalOutputSentToUser = false;
let progressOutputSentToUser = false;
let poisonedSessionDetected = false;
const isClaudeCodeAgent =
(group.agentType || 'claude-code') === 'claude-code';
const clearProgressTicker = () => {
if (progressTicker) {
clearInterval(progressTicker);
progressTicker = null;
}
};
const resetProgressState = () => {
clearProgressTicker();
latestProgressText = null;
latestProgressRendered = null;
progressMessageId = null;
progressStartedAt = null;
};
const renderProgressMessage = (text: string) => {
const elapsedSeconds =
progressStartedAt === null
? 0
: Math.floor((Date.now() - progressStartedAt) / 10_000) * 10;
const hours = Math.floor(elapsedSeconds / 3600);
const minutes = Math.floor((elapsedSeconds % 3600) / 60);
const seconds = elapsedSeconds % 60;
const elapsedLabel =
hours > 0
? `${hours}시간 ${minutes}${seconds}`
: minutes > 0
? `${minutes}${seconds}`
: `${seconds}`;
return `${text}\n\n${elapsedLabel}`;
};
const syncTrackedProgressMessage = async () => {
if (!progressMessageId || !channel.editMessage || !latestProgressText) {
return;
}
const rendered = renderProgressMessage(latestProgressText);
if (rendered === latestProgressRendered) {
return;
}
try {
await channel.editMessage(chatJid, progressMessageId, rendered);
latestProgressRendered = rendered;
} catch {
clearProgressTicker();
progressMessageId = null;
}
};
const ensureProgressTicker = () => {
if (!progressMessageId || !channel.editMessage || progressTicker) {
return;
}
progressTicker = setInterval(() => {
void syncTrackedProgressMessage();
}, 10_000);
};
const finalizeProgressMessage = async () => {
await syncTrackedProgressMessage();
resetProgressState();
};
const sendProgressMessage = async (text: string) => {
if (!text || text === latestProgressText) {
return;
}
if (progressStartedAt === null) {
progressStartedAt = Date.now();
}
latestProgressText = text;
const rendered = renderProgressMessage(text);
if (progressMessageId && channel.editMessage) {
await syncTrackedProgressMessage();
progressOutputSentToUser = true;
return;
}
if (channel.sendAndTrack) {
progressMessageId = await channel.sendAndTrack(chatJid, rendered);
if (progressMessageId) {
latestProgressRendered = rendered;
ensureProgressTicker();
progressOutputSentToUser = true;
return;
}
}
latestProgressRendered = rendered;
await channel.sendMessage(chatJid, rendered);
progressOutputSentToUser = true;
};
await channel.setTyping?.(chatJid, true);
const output = await runAgent(
group,
prompt,
chatJid,
runId,
async (result) => {
if (
isClaudeCodeAgent &&
shouldResetSessionOnAgentFailure(result) &&
!poisonedSessionDetected
) {
poisonedSessionDetected = true;
hadError = true;
deps.clearSession(group.folder);
deps.queue.closeStdin(chatJid, {
runId,
reason: 'poisoned-session',
});
logger.warn(
{ chatJid, group: group.name, groupFolder: group.folder, runId },
'Detected poisoned Claude session from streamed output, forcing close',
);
}
if (result.result) {
const raw =
typeof result.result === 'string'
? result.result
: JSON.stringify(result.result);
const text = formatOutbound(raw);
logger.info(
{
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
resultStatus: result.status,
},
`Agent output: ${raw.slice(0, 200)}`,
);
if (result.phase === 'progress') {
if (text) {
await sendProgressMessage(text);
}
return;
}
if (text) {
await finalizeProgressMessage();
await channel.sendMessage(chatJid, text);
finalOutputSentToUser = true;
}
} else {
await finalizeProgressMessage();
}
await channel.setTyping?.(chatJid, false);
if (!poisonedSessionDetected) {
resetIdleTimer();
}
if (result.status === 'success' && !poisonedSessionDetected) {
deps.queue.notifyIdle(chatJid, runId);
}
if (result.status === 'error') {
hadError = true;
}
},
);
await channel.setTyping?.(chatJid, false);
if (output === 'error') {
hadError = true;
}
clearProgressTicker();
if (idleTimer) clearTimeout(idleTimer);
if (hadError) {
if (finalOutputSentToUser || progressOutputSentToUser) {
logger.warn(
{ chatJid, group: group.name, groupFolder: group.folder, runId },
'Agent error after conversational output was sent, skipping cursor rollback to prevent duplicates',
);
return true;
}
lastAgentTimestamps[chatJid] = previousCursor;
deps.saveState();
logger.warn(
{ chatJid, group: group.name, groupFolder: group.folder, runId },
'Agent error, rolled back message cursor for retry',
);
return false;
}
logger.info(
{
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
outputSentToUser: finalOutputSentToUser,
progressOutputSentToUser,
},
'Queued run completed successfully',
);
return true;
};
const startMessageLoop = async (): Promise<void> => {
if (messageLoopRunning) {
logger.debug('Message loop already running, skipping duplicate start');
return;
}
messageLoopRunning = true;
logger.info(`EJClaw running (trigger: @${deps.assistantName})`);
while (true) {
try {
const registeredGroups = deps.getRegisteredGroups();
const jids = Object.keys(registeredGroups);
const { messages: rawMessages, newTimestamp } = getNewMessages(
jids,
deps.getLastTimestamp(),
deps.assistantName,
);
const messages = filterOwnChannelMessages(rawMessages);
if (rawMessages.length > 0) {
logger.info({ count: messages.length }, 'New messages');
deps.setLastTimestamp(newTimestamp);
deps.saveState();
if (messages.length === 0) {
continue;
}
const messagesByGroup = new Map<string, NewMessage[]>();
for (const msg of messages) {
const existing = messagesByGroup.get(msg.chat_jid);
if (existing) {
existing.push(msg);
} else {
messagesByGroup.set(msg.chat_jid, [msg]);
}
}
for (const [chatJid, groupMessages] of messagesByGroup) {
const group = registeredGroups[chatJid];
if (!group) continue;
const channel = findChannel(deps.channels, chatJid);
if (!channel) {
logger.warn(
{ chatJid },
'No channel owns JID, skipping messages',
);
continue;
}
const isMainGroup = group.isMain === true;
const allFromBots = groupMessages.every(
(msg) => msg.is_from_me || !!msg.is_bot_message,
);
if (allFromBots) {
const lastHuman = getLastHumanMessageTimestamp(chatJid);
if (
!lastHuman ||
Date.now() - new Date(lastHuman).getTime() > 12 * 60 * 60 * 1000
) {
logger.info(
{ chatJid, lastHuman },
'Bot-collaboration timeout: no human message within 12h, skipping',
);
continue;
}
}
const loopCmdMsg = groupMessages.find(
(msg) =>
extractSessionCommand(msg.content, deps.triggerPattern) !==
null,
);
if (loopCmdMsg) {
if (
isSessionCommandAllowed(
isMainGroup,
loopCmdMsg.is_from_me === true,
isSessionCommandSenderAllowed(loopCmdMsg.sender),
)
) {
deps.queue.closeStdin(chatJid, {
reason: 'session-command-detected',
});
}
deps.queue.enqueueMessageCheck(chatJid);
continue;
}
const needsTrigger =
!isMainGroup && group.requiresTrigger !== false;
if (needsTrigger) {
const allowlistCfg = loadSenderAllowlist();
const hasTrigger = groupMessages.some(
(msg) =>
deps.triggerPattern.test(msg.content.trim()) &&
(msg.is_from_me ||
isTriggerAllowed(chatJid, msg.sender, allowlistCfg)),
);
if (!hasTrigger) continue;
}
const lastAgentTimestamps = deps.getLastAgentTimestamps();
const allPending = filterOwnChannelMessages(
getMessagesSince(
chatJid,
lastAgentTimestamps[chatJid] || '',
deps.assistantName,
),
);
const messagesToSend =
allPending.length > 0 ? allPending : groupMessages;
const formatted = formatMessages(messagesToSend, deps.timezone);
if (deps.queue.sendMessage(chatJid, formatted)) {
logger.debug(
{ chatJid, count: messagesToSend.length },
'Piped messages to active agent',
);
lastAgentTimestamps[chatJid] =
messagesToSend[messagesToSend.length - 1].timestamp;
deps.saveState();
channel
.setTyping?.(chatJid, true)
?.catch((err) =>
logger.warn(
{ chatJid, err },
'Failed to set typing indicator',
),
);
} else {
deps.queue.enqueueMessageCheck(chatJid, group.folder);
}
}
}
} catch (err) {
logger.error({ err }, 'Error in message loop');
}
await new Promise((resolve) => setTimeout(resolve, deps.pollInterval));
}
};
const recoverPendingMessages = (): void => {
const registeredGroups = deps.getRegisteredGroups();
const lastAgentTimestamps = deps.getLastAgentTimestamps();
for (const [chatJid, group] of Object.entries(registeredGroups)) {
const sinceTimestamp = lastAgentTimestamps[chatJid] || '';
const pending = filterOwnChannelMessages(
getMessagesSince(chatJid, sinceTimestamp, deps.assistantName),
);
if (pending.length > 0) {
logger.info(
{ group: group.name, pendingCount: pending.length },
'Recovery: found unprocessed messages',
);
deps.queue.enqueueMessageCheck(chatJid, group.folder);
}
}
};
return {
processGroupMessages,
recoverPendingMessages,
startMessageLoop,
};
}

View File

@@ -0,0 +1,58 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
getPairedRoomPromptPath,
getPlatformPromptPath,
readPairedRoomPrompt,
readPlatformPrompt,
} from './platform-prompts.js';
describe('platform-prompts', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-prompts-'));
vi.spyOn(process, 'cwd').mockReturnValue(tempDir);
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('returns undefined when the prompt file is missing', () => {
expect(readPlatformPrompt('claude-code')).toBeUndefined();
});
it('reads and trims provider-specific prompt files', () => {
const promptsDir = path.join(tempDir, 'prompts');
fs.mkdirSync(promptsDir, { recursive: true });
fs.writeFileSync(
path.join(promptsDir, 'codex-platform.md'),
'\nCodex platform prompt\n',
);
expect(getPlatformPromptPath('codex')).toBe(
path.join(promptsDir, 'codex-platform.md'),
);
expect(readPlatformPrompt('codex')).toBe('Codex platform prompt');
});
it('reads and trims paired-room prompt files', () => {
const promptsDir = path.join(tempDir, 'prompts');
fs.mkdirSync(promptsDir, { recursive: true });
fs.writeFileSync(
path.join(promptsDir, 'claude-paired-room.md'),
'\nClaude paired prompt\n',
);
expect(getPairedRoomPromptPath('claude-code')).toBe(
path.join(promptsDir, 'claude-paired-room.md'),
);
expect(readPairedRoomPrompt('claude-code')).toBe('Claude paired prompt');
});
});

View File

@@ -34,16 +34,6 @@ export function formatOutbound(rawText: string): string {
return text;
}
export function routeOutbound(
channels: Channel[],
jid: string,
text: string,
): Promise<void> {
const channel = channels.find((c) => c.ownsJid(jid) && c.isConnected());
if (!channel) throw new Error(`No channel for JID: ${jid}`);
return channel.sendMessage(jid, text);
}
export function findChannel(
channels: Channel[],
jid: string,

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
import {
extractSessionCommand,
handleSessionCommand,
isSessionCommandControlMessage,
isSessionCommandAllowed,
} from './session-commands.js';
import type { NewMessage } from './types.js';
@@ -18,6 +19,10 @@ describe('extractSessionCommand', () => {
expect(extractSessionCommand('@Andy /compact', trigger)).toBe('/compact');
});
it('detects bare /clear', () => {
expect(extractSessionCommand('/clear', trigger)).toBe('/clear');
});
it('rejects /compact with extra text', () => {
expect(extractSessionCommand('/compact now please', trigger)).toBeNull();
});
@@ -43,19 +48,47 @@ describe('extractSessionCommand', () => {
describe('isSessionCommandAllowed', () => {
it('allows main group regardless of sender', () => {
expect(isSessionCommandAllowed(true, false)).toBe(true);
expect(isSessionCommandAllowed(true, false, false)).toBe(true);
});
it('allows trusted/admin sender (is_from_me) in non-main group', () => {
expect(isSessionCommandAllowed(false, true)).toBe(true);
expect(isSessionCommandAllowed(false, true, false)).toBe(true);
});
it('allows configured admin sender in non-main group', () => {
expect(isSessionCommandAllowed(false, false, true)).toBe(true);
});
it('denies untrusted sender in non-main group', () => {
expect(isSessionCommandAllowed(false, false)).toBe(false);
expect(isSessionCommandAllowed(false, false, false)).toBe(false);
});
it('allows trusted sender in main group', () => {
expect(isSessionCommandAllowed(true, true)).toBe(true);
expect(isSessionCommandAllowed(true, true, false)).toBe(true);
});
});
describe('isSessionCommandControlMessage', () => {
it('matches clear confirmation output', () => {
expect(
isSessionCommandControlMessage(
'Current session cleared. The next message will start a new conversation.',
),
).toBe(true);
});
it('matches admin denial output', () => {
expect(
isSessionCommandControlMessage('Session commands require admin access.'),
).toBe(true);
});
it('does not match regular bot conversation', () => {
expect(
isSessionCommandControlMessage(
'좋네요. 필요해지면 바로 말씀드리겠습니다.',
),
).toBe(false);
});
});
@@ -125,6 +158,26 @@ describe('handleSessionCommand', () => {
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
});
it('handles authorized /clear without invoking the agent', async () => {
const deps = makeDeps();
const result = await handleSessionCommand({
missedMessages: [makeMsg('/clear')],
isMainGroup: true,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.closeStdin).toHaveBeenCalledTimes(1);
expect(deps.clearSession).toHaveBeenCalledTimes(1);
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
expect(deps.runAgent).not.toHaveBeenCalled();
expect(deps.sendMessage).toHaveBeenCalledWith(
'Current session cleared. The next message will start a new conversation.',
);
});
it('sends denial to interactable sender in non-main group', async () => {
const deps = makeDeps();
const result = await handleSessionCommand({
@@ -205,6 +258,27 @@ describe('handleSessionCommand', () => {
);
});
it('allows configured admin sender in non-main group', async () => {
const deps = makeDeps({
isAdminSender: vi.fn().mockReturnValue(true),
});
const result = await handleSessionCommand({
missedMessages: [
makeMsg('/clear', { is_from_me: false, sender: 'discord-user-1' }),
],
isMainGroup: false,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.clearSession).toHaveBeenCalledTimes(1);
expect(deps.sendMessage).toHaveBeenCalledWith(
'Current session cleared. The next message will start a new conversation.',
);
});
it('reports failure when command-stage runAgent returns error without streamed status', async () => {
// runAgent resolves 'error' but callback never gets status: 'error'
const deps = makeDeps({

View File

@@ -3,6 +3,7 @@ import type { AgentOutput } from './agent-runner.js';
const SESSION_RESET_PATTERNS = [
/An image in the conversation exceeds the dimension limit for many-image requests \(2000px\)\./i,
/Start a new session with fewer images\./i,
/No conversation found with session ID/i,
];
function toText(value: string | object | null | undefined): string[] {

View File

@@ -4,6 +4,9 @@ import { _initTestDatabase, createTask, getTaskById } from './db.js';
import {
_resetSchedulerLoopForTests,
computeNextRun,
extractWatchCiTarget,
isWatchCiTask,
renderWatchCiStatusMessage,
startSchedulerLoop,
} from './task-scheduler.js';
@@ -52,12 +55,99 @@ describe('task scheduler', () => {
expect(task?.status).toBe('paused');
});
it('only enqueues tasks owned by the current service agent type', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({
id: 'task-claude',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
prompt: 'claude task',
schedule_type: 'once',
schedule_value: dueAt,
context_mode: 'isolated',
next_run: dueAt,
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
createTask({
id: 'task-codex',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'codex',
prompt: 'codex task',
schedule_type: 'once',
schedule_value: dueAt,
context_mode: 'isolated',
next_run: dueAt,
status: 'active',
created_at: '2026-02-22T00:00:01.000Z',
});
const enqueueTask = vi.fn();
startSchedulerLoop({
serviceAgentType: 'codex',
registeredGroups: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
trigger: '@Codex',
added_at: '2026-02-22T00:00:00.000Z',
agentType: 'codex',
},
}),
getSessions: () => ({}),
queue: { enqueueTask } as any,
onProcess: () => {},
sendMessage: async () => {},
});
await vi.advanceTimersByTimeAsync(10);
expect(enqueueTask).toHaveBeenCalledTimes(1);
expect(enqueueTask.mock.calls[0][1]).toBe('task-codex');
});
it('renders watcher heartbeat messages with target and timing', () => {
const prompt = `
[BACKGROUND CI WATCH]
Watch target:
GitHub Actions run 123456
Task ID:
task-123
Check instructions:
Check the run.
`.trim();
expect(isWatchCiTask({ prompt } as any)).toBe(true);
expect(extractWatchCiTarget(prompt)).toBe('GitHub Actions run 123456');
const rendered = renderWatchCiStatusMessage({
task: { prompt },
phase: 'waiting',
checkedAt: '2026-03-19T07:02:10.000Z',
nextRun: '2026-03-19T07:04:10.000Z',
});
expect(rendered).toContain('CI 감시 중: GitHub Actions run 123456');
expect(rendered).toContain('- 상태: 대기 중');
expect(rendered).toContain('- 마지막 확인:');
expect(rendered).toContain('- 다음 확인:');
expect(rendered).not.toContain('2분 10초');
});
it('computeNextRun anchors interval tasks to scheduled time to prevent drift', () => {
const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago
const task = {
id: 'drift-test',
group_folder: 'test',
chat_jid: 'test@g.us',
agent_type: 'claude-code' as const,
status_message_id: null,
status_started_at: null,
prompt: 'test',
schedule_type: 'interval' as const,
schedule_value: '60000', // 1 minute
@@ -82,6 +172,9 @@ describe('task scheduler', () => {
id: 'once-test',
group_folder: 'test',
chat_jid: 'test@g.us',
agent_type: 'claude-code' as const,
status_message_id: null,
status_started_at: null,
prompt: 'test',
schedule_type: 'once' as const,
schedule_value: '2026-01-01T00:00:00.000Z',
@@ -106,6 +199,9 @@ describe('task scheduler', () => {
id: 'skip-test',
group_folder: 'test',
chat_jid: 'test@g.us',
agent_type: 'claude-code' as const,
status_message_id: null,
status_started_at: null,
prompt: 'test',
schedule_type: 'interval' as const,
schedule_value: String(ms),

View File

@@ -2,7 +2,12 @@ import { ChildProcess } from 'child_process';
import { CronExpressionParser } from 'cron-parser';
import fs from 'fs';
import { ASSISTANT_NAME, SCHEDULER_POLL_INTERVAL, TIMEZONE } from './config.js';
import {
ASSISTANT_NAME,
SCHEDULER_POLL_INTERVAL,
SERVICE_AGENT_TYPE,
TIMEZONE,
} from './config.js';
import {
AgentOutput,
runAgentProcess,
@@ -13,13 +18,14 @@ import {
getDueTasks,
getTaskById,
logTaskRun,
updateTaskStatusTracking,
updateTask,
updateTaskAfterRun,
} from './db.js';
import { GroupQueue } from './group-queue.js';
import { resolveGroupFolderPath } from './group-folder.js';
import { logger } from './logger.js';
import { RegisteredGroup, ScheduledTask } from './types.js';
import { AgentType, RegisteredGroup, ScheduledTask } from './types.js';
/**
* Compute the next run time for a recurring task, anchored to the
@@ -63,6 +69,7 @@ export function computeNextRun(task: ScheduledTask): string | null {
}
export interface SchedulerDependencies {
serviceAgentType?: AgentType;
registeredGroups: () => Record<string, RegisteredGroup>;
getSessions: () => Record<string, string>;
queue: GroupQueue;
@@ -73,6 +80,70 @@ export interface SchedulerDependencies {
groupFolder: string,
) => void;
sendMessage: (jid: string, text: string) => Promise<void>;
sendTrackedMessage?: (jid: string, text: string) => Promise<string | null>;
editTrackedMessage?: (
jid: string,
messageId: string,
text: string,
) => 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));
}
export function renderWatchCiStatusMessage(args: {
task: Pick<ScheduledTask, 'prompt'>;
phase: WatcherStatusPhase;
checkedAt: string;
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.nextRun) {
lines.push(`- 다음 확인: ${formatTimeLabel(args.nextRun)}`);
}
return lines.join('\n');
}
async function runTask(
@@ -131,7 +202,9 @@ async function runTask(
// Update tasks snapshot for agent to read (filtered by group)
const isMain = group.isMain === true;
const tasks = getAllTasks();
const taskAgentType =
task.agent_type || deps.serviceAgentType || SERVICE_AGENT_TYPE;
const tasks = getAllTasks(taskAgentType);
writeTasksSnapshot(
task.group_folder,
isMain,
@@ -148,6 +221,8 @@ async function runTask(
let result: string | null = null;
let error: string | null = null;
let statusMessageId = task.status_message_id;
let statusStartedAt = task.status_started_at;
// For group context mode, use the group's current session
const sessions = deps.getSessions();
@@ -168,7 +243,62 @@ async function runTask(
}, TASK_CLOSE_DELAY_MS);
};
const shouldTrackStatus =
isWatchCiTask(task) &&
typeof deps.sendTrackedMessage === 'function' &&
typeof deps.editTrackedMessage === 'function';
const persistStatusTracking = () => {
const currentTask = getTaskById(task.id);
if (!currentTask) return;
updateTaskStatusTracking(task.id, {
status_message_id: statusMessageId,
status_started_at: statusStartedAt,
});
};
const updateWatcherStatus = async (
phase: WatcherStatusPhase,
nextRun?: string | null,
) => {
if (!shouldTrackStatus) {
return;
}
const checkedAt = new Date().toISOString();
if (!statusStartedAt) {
statusStartedAt = checkedAt;
}
const text = renderWatchCiStatusMessage({
task,
phase,
checkedAt,
nextRun,
});
const payload = `${TASK_STATUS_MESSAGE_PREFIX}${text}`;
if (statusMessageId) {
try {
await deps.editTrackedMessage!(task.chat_jid, statusMessageId, payload);
persistStatusTracking();
return;
} catch {
statusMessageId = null;
persistStatusTracking();
}
}
const messageId = await deps.sendTrackedMessage!(task.chat_jid, payload);
if (messageId) {
statusMessageId = messageId;
persistStatusTracking();
}
};
try {
await updateWatcherStatus('checking');
const output = await runAgentProcess(
group,
{
@@ -183,6 +313,9 @@ async function runTask(
(proc, processName) =>
deps.onProcess(task.chat_jid, proc, processName, task.group_folder),
async (streamedOutput: AgentOutput) => {
if (streamedOutput.phase === 'progress') {
return;
}
if (streamedOutput.result) {
result = streamedOutput.result;
// Forward result to user (sendMessage handles formatting)
@@ -209,7 +342,11 @@ async function runTask(
}
logger.info(
{ taskId: task.id, durationMs: Date.now() - startTime },
{
taskId: task.id,
agentType: taskAgentType,
durationMs: Date.now() - startTime,
},
'Task completed',
);
} catch (err) {
@@ -219,6 +356,22 @@ async function runTask(
}
const durationMs = Date.now() - startTime;
const currentTask = getTaskById(task.id);
const nextRun = currentTask ? computeNextRun(task) : null;
if (!currentTask) {
await updateWatcherStatus('completed');
logger.debug({ taskId: task.id }, 'Task deleted during execution, skipping persistence');
return;
}
if (error) {
await updateWatcherStatus('retrying', nextRun);
} else if (nextRun) {
await updateWatcherStatus('waiting', nextRun);
} else {
await updateWatcherStatus('completed');
}
logTaskRun({
task_id: task.id,
@@ -229,7 +382,6 @@ async function runTask(
error,
});
const nextRun = computeNextRun(task);
const resultSummary = error
? `Error: ${error}`
: result
@@ -250,7 +402,9 @@ export function startSchedulerLoop(deps: SchedulerDependencies): void {
const loop = async () => {
try {
const dueTasks = getDueTasks();
const dueTasks = getDueTasks(
deps.serviceAgentType || SERVICE_AGENT_TYPE,
);
if (dueTasks.length > 0) {
logger.info({ count: dueTasks.length }, 'Found due tasks');
}

View File

@@ -36,6 +36,9 @@ export interface ScheduledTask {
id: string;
group_folder: string;
chat_jid: string;
agent_type: AgentType | null;
status_message_id: string | null;
status_started_at: string | null;
prompt: string;
schedule_type: 'cron' | 'interval' | 'once';
schedule_value: string;