feat: integrate Memento MCP for shared memory across agents
- Add MEMENTO_MCP_SSE_URL/ACCESS_KEY/REMOTE_PATH to readEnvFile - Merge .env vars into cleanEnv for runner process inheritance - Claude Code runner: add memento-mcp via mcp-remote in mcpServers - Codex runner: inject memento-mcp section into config.toml - Various improvements: CI watch, task scheduler, DB, IPC auth
This commit is contained in:
@@ -287,9 +287,7 @@ describe('agent-runner timeout behavior', () => {
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
'/tmp/ejclaw-test-data/sessions/eyejokerdb-4/.codex/config.toml',
|
||||
expect.stringContaining(
|
||||
'NANOCLAW_CHAT_JID = "dc:1481348008183595170"',
|
||||
),
|
||||
expect.stringContaining('NANOCLAW_CHAT_JID = "dc:1481348008183595170"'),
|
||||
);
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
'/tmp/ejclaw-test-data/sessions/eyejokerdb-4/.codex/config.toml',
|
||||
|
||||
@@ -175,10 +175,17 @@ function prepareGroupEnvironment(
|
||||
'CODEX_OPENAI_API_KEY',
|
||||
'CODEX_MODEL',
|
||||
'CODEX_EFFORT',
|
||||
'MEMENTO_MCP_SSE_URL',
|
||||
'MEMENTO_ACCESS_KEY',
|
||||
'MEMENTO_MCP_REMOTE_PATH',
|
||||
]);
|
||||
|
||||
// Build a clean env without Claude Code nesting detection variables
|
||||
const cleanEnv = { ...(process.env as Record<string, string>) };
|
||||
// Merge .env file values (readEnvFile only reads file, doesn't set process.env)
|
||||
for (const [k, v] of Object.entries(envVars)) {
|
||||
if (v && !cleanEnv[k]) cleanEnv[k] = v;
|
||||
}
|
||||
delete cleanEnv.CLAUDECODE;
|
||||
delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
|
||||
|
||||
@@ -327,7 +334,28 @@ NANOCLAW_CHAT_JID = ${JSON.stringify(chatJid)}
|
||||
NANOCLAW_GROUP_FOLDER = ${JSON.stringify(group.folder)}
|
||||
NANOCLAW_IS_MAIN = ${JSON.stringify(isMain ? '1' : '0')}
|
||||
`;
|
||||
toml = toml.trimEnd() + '\n' + mcpSection;
|
||||
// Inject memento-mcp if MEMENTO_MCP_SSE_URL is set
|
||||
const mementoSseUrl =
|
||||
envVars.MEMENTO_MCP_SSE_URL || process.env.MEMENTO_MCP_SSE_URL;
|
||||
const mementoAccessKey =
|
||||
envVars.MEMENTO_ACCESS_KEY || process.env.MEMENTO_ACCESS_KEY || '';
|
||||
const mementoRemotePath =
|
||||
envVars.MEMENTO_MCP_REMOTE_PATH ||
|
||||
process.env.MEMENTO_MCP_REMOTE_PATH ||
|
||||
'mcp-remote';
|
||||
toml = toml.replace(
|
||||
/\n?\[mcp_servers\.memento-mcp\][\s\S]*?(?=\n\[|$)/,
|
||||
'',
|
||||
);
|
||||
const mementoSection = mementoSseUrl
|
||||
? `
|
||||
[mcp_servers.memento-mcp]
|
||||
command = ${JSON.stringify(mementoRemotePath)}
|
||||
args = [${JSON.stringify(mementoSseUrl)}, "--header", ${JSON.stringify(`Authorization:Bearer ${mementoAccessKey}`)}]
|
||||
`
|
||||
: '';
|
||||
|
||||
toml = toml.trimEnd() + '\n' + mcpSection + mementoSection;
|
||||
fs.writeFileSync(configTomlPath, toml);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
deleteTask,
|
||||
getAllChats,
|
||||
getAllRegisteredGroups,
|
||||
getDueTasks,
|
||||
getRegisteredAgentTypesForJid,
|
||||
getMessagesSince,
|
||||
getNewMessages,
|
||||
@@ -402,6 +403,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 ---
|
||||
|
||||
117
src/db.ts
117
src/db.ts
@@ -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(
|
||||
@@ -420,17 +464,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,
|
||||
@@ -447,7 +505,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',
|
||||
@@ -455,7 +524,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[];
|
||||
@@ -502,23 +579,47 @@ 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(
|
||||
|
||||
23
src/index.ts
23
src/index.ts
@@ -218,6 +218,7 @@ async function main(): Promise<void> {
|
||||
|
||||
// Start subsystems (independently of connection handler)
|
||||
startSchedulerLoop({
|
||||
serviceAgentType: SERVICE_AGENT_TYPE,
|
||||
registeredGroups: () => registeredGroups,
|
||||
getSessions: () => sessions,
|
||||
queue,
|
||||
@@ -232,6 +233,28 @@ async function main(): Promise<void> {
|
||||
const text = formatOutbound(rawText);
|
||||
if (text) await channel.sendMessage(jid, text);
|
||||
},
|
||||
sendTrackedMessage: async (jid, rawText) => {
|
||||
const channel = findChannel(channels, jid);
|
||||
if (!channel?.sendAndTrack) {
|
||||
return null;
|
||||
}
|
||||
const text = formatOutbound(rawText);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return channel.sendAndTrack(jid, text);
|
||||
},
|
||||
editTrackedMessage: async (jid, messageId, rawText) => {
|
||||
const channel = findChannel(channels, jid);
|
||||
if (!channel?.editMessage) {
|
||||
throw new Error(`Channel does not support message edits: ${jid}`);
|
||||
}
|
||||
const text = formatOutbound(rawText);
|
||||
if (!text) {
|
||||
throw new Error(`Cannot edit tracked message to empty text: ${jid}`);
|
||||
}
|
||||
await channel.editMessage(jid, messageId, text);
|
||||
},
|
||||
});
|
||||
startIpcWatcher({
|
||||
sendMessage: (jid, text) => {
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
16
src/ipc.ts
16
src/ipc.ts
@@ -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';
|
||||
@@ -257,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,
|
||||
@@ -266,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',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
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';
|
||||
|
||||
@@ -79,6 +80,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
if (msg.is_bot_message && isSessionCommandControlMessage(msg.content)) {
|
||||
return false;
|
||||
}
|
||||
if (msg.is_bot_message && isTaskStatusControlMessage(msg.content)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -98,7 +102,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
const sessions = deps.getSessions();
|
||||
const sessionId = sessions[group.folder];
|
||||
|
||||
const tasks = getAllTasks();
|
||||
const tasks = getAllTasks(group.agentType || 'claude-code');
|
||||
writeTasksSnapshot(
|
||||
group.folder,
|
||||
isMain,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
@@ -212,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) {
|
||||
@@ -222,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,
|
||||
@@ -232,7 +382,6 @@ async function runTask(
|
||||
error,
|
||||
});
|
||||
|
||||
const nextRun = computeNextRun(task);
|
||||
const resultSummary = error
|
||||
? `Error: ${error}`
|
||||
: result
|
||||
@@ -253,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');
|
||||
}
|
||||
|
||||
@@ -43,6 +43,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;
|
||||
|
||||
Reference in New Issue
Block a user