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:
@@ -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
|
||||
|
||||
@@ -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)] }],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
64
runners/codex-runner/package-lock.json
generated
64
runners/codex-runner/package-lock.json
generated
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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