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:
Eyejoker
2026-03-20 00:12:43 +09:00
parent e7200a1ed8
commit 41afcb91cf
17 changed files with 572 additions and 59 deletions

View File

@@ -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

View File

@@ -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)] }],

View File

@@ -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(),

View File

@@ -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 완료: <target>\`
- Second line: \`판정: <one-line conclusion>\`
- Then 2-4 flat bullet points with only the most important metrics, errors, or comparisons.
- Optional final line: \`다음: <next action>\` 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 \`<internal>\` tags if needed.
- Do not claim continued monitoring after you cancel the task.

View File

@@ -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 완료: <target>');
expect(prompt).toContain('판정: <one-line conclusion>');
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', () => {

View File

@@ -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"
],

View File

@@ -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',

View File

@@ -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);
}

View File

@@ -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
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(
@@ -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(

View File

@@ -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) => {

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';
@@ -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',
);
}

View File

@@ -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,

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,
{
@@ -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');
}

View File

@@ -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;