From 41afcb91cf812cd71d1220454c8786251b0e0898 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Fri, 20 Mar 2026 00:12:43 +0900 Subject: [PATCH] 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 --- prompts/codex-platform.md | 3 +- runners/agent-runner/src/index.ts | 12 ++ runners/agent-runner/src/ipc-mcp-stdio.ts | 7 +- runners/agent-runner/src/watch-ci.ts | 14 +- runners/agent-runner/test/watch-ci.test.ts | 10 ++ runners/codex-runner/package-lock.json | 64 ++++---- src/agent-runner.test.ts | 4 +- src/agent-runner.ts | 30 +++- src/db.test.ts | 38 +++++ src/db.ts | 117 ++++++++++++++- src/index.ts | 23 +++ src/ipc-auth.test.ts | 25 ++++ src/ipc.ts | 16 +- src/message-runtime.ts | 6 +- src/task-scheduler.test.ts | 96 ++++++++++++ src/task-scheduler.ts | 163 ++++++++++++++++++++- src/types.ts | 3 + 17 files changed, 572 insertions(+), 59 deletions(-) diff --git a/prompts/codex-platform.md b/prompts/codex-platform.md index 18ec385..527695e 100644 --- a/prompts/codex-platform.md +++ b/prompts/codex-platform.md @@ -24,4 +24,5 @@ Your output is sent directly to the Discord group. - Prefer reading the current workspace before making assumptions - Modify only what is needed for the task - Verify changes when you can instead of claiming they should work -- For CI/status/watch requests that require future follow-up, prefer `watch_ci` over leaving the chat session idle +- For CI/status/watch requests that require future follow-up, schedule `watch_ci` +- Use generic `schedule_task` for reminders or other non-CI recurring work diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index c9f99dd..dd09684 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -531,6 +531,18 @@ async function runQuery( NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0', }, }, + ...(process.env.MEMENTO_MCP_SSE_URL + ? { + 'memento-mcp': { + command: process.env.MEMENTO_MCP_REMOTE_PATH || 'mcp-remote', + args: [ + process.env.MEMENTO_MCP_SSE_URL, + '--header', + `Authorization:Bearer ${process.env.MEMENTO_ACCESS_KEY || ''}`, + ], + }, + } + : {}), }, hooks: { PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }], diff --git a/runners/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts index b43f142..69f471e 100644 --- a/runners/agent-runner/src/ipc-mcp-stdio.ts +++ b/runners/agent-runner/src/ipc-mcp-stdio.ts @@ -12,6 +12,7 @@ import path from 'path'; import { CronExpressionParser } from 'cron-parser'; import { buildCiWatchPrompt, + DEFAULT_WATCH_CI_CONTEXT_MODE, normalizeWatchCiIntervalSeconds, } from './watch-ci.js'; @@ -174,8 +175,8 @@ server.tool( .describe('How often to poll in seconds. Default 60, minimum 30.'), context_mode: z .enum(['group', 'isolated']) - .default('group') - .describe('group=runs with chat history and memory, isolated=fresh session (include all context in check_instructions)'), + .default(DEFAULT_WATCH_CI_CONTEXT_MODE) + .describe('group=runs with chat history and memory, isolated=fresh session (include all context in check_instructions). Default: isolated.'), target_group_jid: z .string() .optional() @@ -211,7 +212,7 @@ server.tool( prompt, schedule_type: 'interval' as const, schedule_value: String(pollSeconds * 1000), - context_mode: args.context_mode || 'group', + context_mode: args.context_mode || DEFAULT_WATCH_CI_CONTEXT_MODE, targetJid, createdBy: groupFolder, timestamp: new Date().toISOString(), diff --git a/runners/agent-runner/src/watch-ci.ts b/runners/agent-runner/src/watch-ci.ts index 6eccb8b..b0ce26b 100644 --- a/runners/agent-runner/src/watch-ci.ts +++ b/runners/agent-runner/src/watch-ci.ts @@ -1,6 +1,7 @@ export const DEFAULT_WATCH_CI_INTERVAL_SECONDS = 60; export const MIN_WATCH_CI_INTERVAL_SECONDS = 30; export const MAX_WATCH_CI_INTERVAL_SECONDS = 3600; +export const DEFAULT_WATCH_CI_CONTEXT_MODE = 'isolated'; export interface BuildCiWatchPromptArgs { taskId: string; @@ -49,12 +50,21 @@ Check instructions: ${checkInstructions} Rules: +- Use the watch target and check instructions in this prompt as the source of truth for what to inspect. - On each run, check whether the target is still queued, pending, running, in progress, or otherwise non-terminal. - If it is still not finished, send no visible message and end this run quietly. - If it reached a terminal state such as success, failure, cancelled, timed out, neutral, skipped, or action required: 1. Send exactly one concise completion message with \`send_message\`. - 2. Include the final status and only the most important details. - 3. Call \`cancel_task\` with task_id "${taskId}" so this watcher stops itself. + 2. Format it as a short multiline summary when possible, not one long paragraph. + 3. Preferred shape: + - First line: \`CI 완료: \` + - Second line: \`판정: \` + - Then 2-4 flat bullet points with only the most important metrics, errors, or comparisons. + - Optional final line: \`다음: \` if a concrete follow-up is needed. + 4. Adapt the content to the specific CI. Do not invent fixed fields when they do not fit. + 5. Avoid tables unless they are clearly the shortest readable format. + 6. Keep the message compact and easy for other agents to parse. + 7. Call \`cancel_task\` with task_id "${taskId}" so this watcher stops itself. - If you hit a transient problem such as a rate limit, network issue, or temporary auth failure, send no visible message and leave the task active for the next retry. - Prefer no normal final response. Use \`send_message\` for the completion message, and keep any non-user-facing notes inside \`\` tags if needed. - Do not claim continued monitoring after you cancel the task. diff --git a/runners/agent-runner/test/watch-ci.test.ts b/runners/agent-runner/test/watch-ci.test.ts index b895730..ef49ef7 100644 --- a/runners/agent-runner/test/watch-ci.test.ts +++ b/runners/agent-runner/test/watch-ci.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { buildCiWatchPrompt, + DEFAULT_WATCH_CI_CONTEXT_MODE, normalizeWatchCiIntervalSeconds, } from '../src/watch-ci.js'; @@ -18,6 +19,15 @@ describe('watch-ci helpers', () => { expect(prompt).toContain('cancel_task'); expect(prompt).toContain('send_message'); expect(prompt).toContain('gh pr checks 42'); + expect(prompt).toContain('CI 완료: '); + expect(prompt).toContain('판정: '); + expect(prompt).toContain( + 'Use the watch target and check instructions in this prompt as the source of truth', + ); + }); + + it('defaults CI watchers to isolated context', () => { + expect(DEFAULT_WATCH_CI_CONTEXT_MODE).toBe('isolated'); }); it('normalizes valid poll intervals', () => { diff --git a/runners/codex-runner/package-lock.json b/runners/codex-runner/package-lock.json index 956a0a0..9851d0a 100644 --- a/runners/codex-runner/package-lock.json +++ b/runners/codex-runner/package-lock.json @@ -8,7 +8,7 @@ "name": "ejclaw-codex-runner", "version": "1.0.0", "dependencies": { - "@openai/codex-sdk": "^0.114.0" + "@openai/codex-sdk": "^0.115.0" }, "devDependencies": { "@types/node": "^22.10.7", @@ -16,9 +16,9 @@ } }, "node_modules/@openai/codex": { - "version": "0.114.0", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0.tgz", - "integrity": "sha512-HMo8LRR6CtfKkaa28xvFK6eOarmBFTDfsrS9GJtEoaspGGemFok494CpafDspiTZaHZZGHfSUe5SWTUxYq7OaA==", + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0.tgz", + "integrity": "sha512-uu689DHUzvuPcb39hJ+7fqy++TAvY32w9VggDpcz3HS0Sx0WadWoAPPcMK547P2T6AqfMsAtA8kspkR3tqErOg==", "license": "Apache-2.0", "bin": { "codex": "bin/codex.js" @@ -27,19 +27,19 @@ "node": ">=16" }, "optionalDependencies": { - "@openai/codex-darwin-arm64": "npm:@openai/codex@0.114.0-darwin-arm64", - "@openai/codex-darwin-x64": "npm:@openai/codex@0.114.0-darwin-x64", - "@openai/codex-linux-arm64": "npm:@openai/codex@0.114.0-linux-arm64", - "@openai/codex-linux-x64": "npm:@openai/codex@0.114.0-linux-x64", - "@openai/codex-win32-arm64": "npm:@openai/codex@0.114.0-win32-arm64", - "@openai/codex-win32-x64": "npm:@openai/codex@0.114.0-win32-x64" + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.115.0-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.115.0-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.115.0-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.115.0-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.115.0-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.115.0-win32-x64" } }, "node_modules/@openai/codex-darwin-arm64": { "name": "@openai/codex", - "version": "0.114.0-darwin-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-darwin-arm64.tgz", - "integrity": "sha512-c9dlgo9O+66alr3s3G36fKE3KaO2+xrLZ/QcU3oujDruV86pqgVSEK2njHi+WIyyOa6m2AyWxdaHLEbKxPCNRg==", + "version": "0.115.0-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-darwin-arm64.tgz", + "integrity": "sha512-wTWV+YlDTL0y0mL+FMmbzhilm+dPkbIZTe/lH3LwBzcEMhgSGLsPdZLfAiMND4wFE21HS7H0pjMogNQEMLQmqg==", "cpu": [ "arm64" ], @@ -54,9 +54,9 @@ }, "node_modules/@openai/codex-darwin-x64": { "name": "@openai/codex", - "version": "0.114.0-darwin-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-darwin-x64.tgz", - "integrity": "sha512-oPAPeZSaJZ1AQneFPSJu44uqbZr63Bxut9FOTWZwuSqYx4YEees7i0HJmJcqQc0XnCbYtHJdqo/i07Uv374NLw==", + "version": "0.115.0-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-darwin-x64.tgz", + "integrity": "sha512-EPzgymU4CFp83qjv29wXFwhWib3zC8g6SLTJGh2OpcJiOYyLY0bO53FB+QchL1jC9gm1uLyD5j5F3ddI0AbjIg==", "cpu": [ "x64" ], @@ -71,9 +71,9 @@ }, "node_modules/@openai/codex-linux-arm64": { "name": "@openai/codex", - "version": "0.114.0-linux-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-linux-arm64.tgz", - "integrity": "sha512-oUFZGZgMiEqmo85C+dfhckpVApH25alLWfwBgfKr2IRgdx/tovy8vN101f6kj01E6nDDe4LfJdNSZGtZht4fRA==", + "version": "0.115.0-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-linux-arm64.tgz", + "integrity": "sha512-40+SCyI+LvVx/iX30qH7dTQzWt9MZxDaK2E6YRB4hoYej5UALrhkFNzweHa5uvqvhmqGZCuagZOYB8285eGCgw==", "cpu": [ "arm64" ], @@ -88,9 +88,9 @@ }, "node_modules/@openai/codex-linux-x64": { "name": "@openai/codex", - "version": "0.114.0-linux-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-linux-x64.tgz", - "integrity": "sha512-MF6RhxfBoccccd5CYII2C7TL2TvYzombBQhanDZfQAajr6n7IuoazCmoHDlrFHS4AcSw9bYA4MRlrwEjbEjgsw==", + "version": "0.115.0-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-linux-x64.tgz", + "integrity": "sha512-+6eRd2p4KMrhQvuF7XpjYIdCA2Ok2LbhOq+ywZdLpHbmwQ/Yynd0gJ/Q90xCeo2vloCwl9WsbsiVVSahG5FyWg==", "cpu": [ "x64" ], @@ -104,12 +104,12 @@ } }, "node_modules/@openai/codex-sdk": { - "version": "0.114.0", - "resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.114.0.tgz", - "integrity": "sha512-28tV+pvoQhhoRADBCtlg24fR6LDTyeUA6C65NBTvYFnDoQVPJcHcAwRrbuWmUMcXdLrXwY2bmiMxyfXlUWQzgw==", + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.115.0.tgz", + "integrity": "sha512-BPoPhim0uUm3rzugY7JFaFJ+rPG/wMRhvKNFii//Dp3kTb+gFy3jrip7ijXqhswsnqXM3nwTiv7kYHt1+TMUPg==", "license": "Apache-2.0", "dependencies": { - "@openai/codex": "0.114.0" + "@openai/codex": "0.115.0" }, "engines": { "node": ">=18" @@ -117,9 +117,9 @@ }, "node_modules/@openai/codex-win32-arm64": { "name": "@openai/codex", - "version": "0.114.0-win32-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-win32-arm64.tgz", - "integrity": "sha512-CnRMHopj3en9aqQ2UaDW7EgpEGkxHdZVLLRq2cOy5D0HyuzF6Qb6595ADoFVJHEmPeN5Iz/KUbiGs5GLDjUwOA==", + "version": "0.115.0-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-win32-arm64.tgz", + "integrity": "sha512-yaYhQ0kPL9Kithmrr1vh5A4c+gqAMhMZeA/htTtkpWW8RQkmwgcYYpX/Ky2cEzu0w51pvxQQy63LgHftc+pg1Q==", "cpu": [ "arm64" ], @@ -134,9 +134,9 @@ }, "node_modules/@openai/codex-win32-x64": { "name": "@openai/codex", - "version": "0.114.0-win32-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-win32-x64.tgz", - "integrity": "sha512-ztRsH5Z+gPVZ5ZInx6HzXsTFFkuEdjk3Ay0UGVOhRRCWvevXQi/SL4eU8hwysCYrs8K/WRq3mo4c95w9zQ2wLw==", + "version": "0.115.0-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-win32-x64.tgz", + "integrity": "sha512-wtYf2HJCB+p+Uj7o1W08fRgZMzKCVGRQ4YdSfLRNfF4wwPqX5XsK9blsv7brukn5J9lclYxagiR6qGvbfHbD7w==", "cpu": [ "x64" ], diff --git a/src/agent-runner.test.ts b/src/agent-runner.test.ts index 25fad00..5bfd070 100644 --- a/src/agent-runner.test.ts +++ b/src/agent-runner.test.ts @@ -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', diff --git a/src/agent-runner.ts b/src/agent-runner.ts index f7e08a2..17fdc5f 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -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) }; + // 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); } diff --git a/src/db.test.ts b/src/db.test.ts index 56c7ab4..f64f8b0 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -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 --- diff --git a/src/db.ts b/src/db.ts index ec8c2fd..cc599fb 100644 --- a/src/db.ts +++ b/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, + 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>, +): 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( diff --git a/src/index.ts b/src/index.ts index b2e6585..f5182f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -218,6 +218,7 @@ async function main(): Promise { // Start subsystems (independently of connection handler) startSchedulerLoop({ + serviceAgentType: SERVICE_AGENT_TYPE, registeredGroups: () => registeredGroups, getSessions: () => sessions, queue, @@ -232,6 +233,28 @@ async function main(): Promise { 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) => { diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index 1aa681e..18ea6b7 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -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( { diff --git a/src/ipc.ts b/src/ipc.ts index 72a31c2..a434581 100644 --- a/src/ipc.ts +++ b/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', ); } diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 96dcae2..253c06f 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -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, diff --git a/src/task-scheduler.test.ts b/src/task-scheduler.test.ts index 2032b51..31dc01f 100644 --- a/src/task-scheduler.test.ts +++ b/src/task-scheduler.test.ts @@ -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), diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index 2be69a8..d7519d7 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -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; getSessions: () => Record; queue: GroupQueue; @@ -73,6 +80,70 @@ export interface SchedulerDependencies { groupFolder: string, ) => void; sendMessage: (jid: string, text: string) => Promise; + sendTrackedMessage?: (jid: string, text: string) => Promise; + editTrackedMessage?: ( + jid: string, + messageId: string, + text: string, + ) => Promise; +} + +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): 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; + 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'); } diff --git a/src/types.ts b/src/types.ts index 60d2fd2..3d382c0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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;