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
|
- Prefer reading the current workspace before making assumptions
|
||||||
- Modify only what is needed for the task
|
- Modify only what is needed for the task
|
||||||
- Verify changes when you can instead of claiming they should work
|
- 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',
|
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: {
|
hooks: {
|
||||||
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import path from 'path';
|
|||||||
import { CronExpressionParser } from 'cron-parser';
|
import { CronExpressionParser } from 'cron-parser';
|
||||||
import {
|
import {
|
||||||
buildCiWatchPrompt,
|
buildCiWatchPrompt,
|
||||||
|
DEFAULT_WATCH_CI_CONTEXT_MODE,
|
||||||
normalizeWatchCiIntervalSeconds,
|
normalizeWatchCiIntervalSeconds,
|
||||||
} from './watch-ci.js';
|
} from './watch-ci.js';
|
||||||
|
|
||||||
@@ -174,8 +175,8 @@ server.tool(
|
|||||||
.describe('How often to poll in seconds. Default 60, minimum 30.'),
|
.describe('How often to poll in seconds. Default 60, minimum 30.'),
|
||||||
context_mode: z
|
context_mode: z
|
||||||
.enum(['group', 'isolated'])
|
.enum(['group', 'isolated'])
|
||||||
.default('group')
|
.default(DEFAULT_WATCH_CI_CONTEXT_MODE)
|
||||||
.describe('group=runs with chat history and memory, isolated=fresh session (include all context in check_instructions)'),
|
.describe('group=runs with chat history and memory, isolated=fresh session (include all context in check_instructions). Default: isolated.'),
|
||||||
target_group_jid: z
|
target_group_jid: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -211,7 +212,7 @@ server.tool(
|
|||||||
prompt,
|
prompt,
|
||||||
schedule_type: 'interval' as const,
|
schedule_type: 'interval' as const,
|
||||||
schedule_value: String(pollSeconds * 1000),
|
schedule_value: String(pollSeconds * 1000),
|
||||||
context_mode: args.context_mode || 'group',
|
context_mode: args.context_mode || DEFAULT_WATCH_CI_CONTEXT_MODE,
|
||||||
targetJid,
|
targetJid,
|
||||||
createdBy: groupFolder,
|
createdBy: groupFolder,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export const DEFAULT_WATCH_CI_INTERVAL_SECONDS = 60;
|
export const DEFAULT_WATCH_CI_INTERVAL_SECONDS = 60;
|
||||||
export const MIN_WATCH_CI_INTERVAL_SECONDS = 30;
|
export const MIN_WATCH_CI_INTERVAL_SECONDS = 30;
|
||||||
export const MAX_WATCH_CI_INTERVAL_SECONDS = 3600;
|
export const MAX_WATCH_CI_INTERVAL_SECONDS = 3600;
|
||||||
|
export const DEFAULT_WATCH_CI_CONTEXT_MODE = 'isolated';
|
||||||
|
|
||||||
export interface BuildCiWatchPromptArgs {
|
export interface BuildCiWatchPromptArgs {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
@@ -49,12 +50,21 @@ Check instructions:
|
|||||||
${checkInstructions}
|
${checkInstructions}
|
||||||
|
|
||||||
Rules:
|
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.
|
- 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 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:
|
- 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\`.
|
1. Send exactly one concise completion message with \`send_message\`.
|
||||||
2. Include the final status and only the most important details.
|
2. Format it as a short multiline summary when possible, not one long paragraph.
|
||||||
3. Call \`cancel_task\` with task_id "${taskId}" so this watcher stops itself.
|
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.
|
- 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.
|
- 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.
|
- Do not claim continued monitoring after you cancel the task.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildCiWatchPrompt,
|
buildCiWatchPrompt,
|
||||||
|
DEFAULT_WATCH_CI_CONTEXT_MODE,
|
||||||
normalizeWatchCiIntervalSeconds,
|
normalizeWatchCiIntervalSeconds,
|
||||||
} from '../src/watch-ci.js';
|
} from '../src/watch-ci.js';
|
||||||
|
|
||||||
@@ -18,6 +19,15 @@ describe('watch-ci helpers', () => {
|
|||||||
expect(prompt).toContain('cancel_task');
|
expect(prompt).toContain('cancel_task');
|
||||||
expect(prompt).toContain('send_message');
|
expect(prompt).toContain('send_message');
|
||||||
expect(prompt).toContain('gh pr checks 42');
|
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', () => {
|
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",
|
"name": "ejclaw-codex-runner",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openai/codex-sdk": "^0.114.0"
|
"@openai/codex-sdk": "^0.115.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@openai/codex": {
|
"node_modules/@openai/codex": {
|
||||||
"version": "0.114.0",
|
"version": "0.115.0",
|
||||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0.tgz",
|
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0.tgz",
|
||||||
"integrity": "sha512-HMo8LRR6CtfKkaa28xvFK6eOarmBFTDfsrS9GJtEoaspGGemFok494CpafDspiTZaHZZGHfSUe5SWTUxYq7OaA==",
|
"integrity": "sha512-uu689DHUzvuPcb39hJ+7fqy++TAvY32w9VggDpcz3HS0Sx0WadWoAPPcMK547P2T6AqfMsAtA8kspkR3tqErOg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"codex": "bin/codex.js"
|
"codex": "bin/codex.js"
|
||||||
@@ -27,19 +27,19 @@
|
|||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.114.0-darwin-arm64",
|
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.115.0-darwin-arm64",
|
||||||
"@openai/codex-darwin-x64": "npm:@openai/codex@0.114.0-darwin-x64",
|
"@openai/codex-darwin-x64": "npm:@openai/codex@0.115.0-darwin-x64",
|
||||||
"@openai/codex-linux-arm64": "npm:@openai/codex@0.114.0-linux-arm64",
|
"@openai/codex-linux-arm64": "npm:@openai/codex@0.115.0-linux-arm64",
|
||||||
"@openai/codex-linux-x64": "npm:@openai/codex@0.114.0-linux-x64",
|
"@openai/codex-linux-x64": "npm:@openai/codex@0.115.0-linux-x64",
|
||||||
"@openai/codex-win32-arm64": "npm:@openai/codex@0.114.0-win32-arm64",
|
"@openai/codex-win32-arm64": "npm:@openai/codex@0.115.0-win32-arm64",
|
||||||
"@openai/codex-win32-x64": "npm:@openai/codex@0.114.0-win32-x64"
|
"@openai/codex-win32-x64": "npm:@openai/codex@0.115.0-win32-x64"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@openai/codex-darwin-arm64": {
|
"node_modules/@openai/codex-darwin-arm64": {
|
||||||
"name": "@openai/codex",
|
"name": "@openai/codex",
|
||||||
"version": "0.114.0-darwin-arm64",
|
"version": "0.115.0-darwin-arm64",
|
||||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-darwin-arm64.tgz",
|
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-darwin-arm64.tgz",
|
||||||
"integrity": "sha512-c9dlgo9O+66alr3s3G36fKE3KaO2+xrLZ/QcU3oujDruV86pqgVSEK2njHi+WIyyOa6m2AyWxdaHLEbKxPCNRg==",
|
"integrity": "sha512-wTWV+YlDTL0y0mL+FMmbzhilm+dPkbIZTe/lH3LwBzcEMhgSGLsPdZLfAiMND4wFE21HS7H0pjMogNQEMLQmqg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -54,9 +54,9 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@openai/codex-darwin-x64": {
|
"node_modules/@openai/codex-darwin-x64": {
|
||||||
"name": "@openai/codex",
|
"name": "@openai/codex",
|
||||||
"version": "0.114.0-darwin-x64",
|
"version": "0.115.0-darwin-x64",
|
||||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-darwin-x64.tgz",
|
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-darwin-x64.tgz",
|
||||||
"integrity": "sha512-oPAPeZSaJZ1AQneFPSJu44uqbZr63Bxut9FOTWZwuSqYx4YEees7i0HJmJcqQc0XnCbYtHJdqo/i07Uv374NLw==",
|
"integrity": "sha512-EPzgymU4CFp83qjv29wXFwhWib3zC8g6SLTJGh2OpcJiOYyLY0bO53FB+QchL1jC9gm1uLyD5j5F3ddI0AbjIg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -71,9 +71,9 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@openai/codex-linux-arm64": {
|
"node_modules/@openai/codex-linux-arm64": {
|
||||||
"name": "@openai/codex",
|
"name": "@openai/codex",
|
||||||
"version": "0.114.0-linux-arm64",
|
"version": "0.115.0-linux-arm64",
|
||||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-linux-arm64.tgz",
|
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-linux-arm64.tgz",
|
||||||
"integrity": "sha512-oUFZGZgMiEqmo85C+dfhckpVApH25alLWfwBgfKr2IRgdx/tovy8vN101f6kj01E6nDDe4LfJdNSZGtZht4fRA==",
|
"integrity": "sha512-40+SCyI+LvVx/iX30qH7dTQzWt9MZxDaK2E6YRB4hoYej5UALrhkFNzweHa5uvqvhmqGZCuagZOYB8285eGCgw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -88,9 +88,9 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@openai/codex-linux-x64": {
|
"node_modules/@openai/codex-linux-x64": {
|
||||||
"name": "@openai/codex",
|
"name": "@openai/codex",
|
||||||
"version": "0.114.0-linux-x64",
|
"version": "0.115.0-linux-x64",
|
||||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-linux-x64.tgz",
|
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-linux-x64.tgz",
|
||||||
"integrity": "sha512-MF6RhxfBoccccd5CYII2C7TL2TvYzombBQhanDZfQAajr6n7IuoazCmoHDlrFHS4AcSw9bYA4MRlrwEjbEjgsw==",
|
"integrity": "sha512-+6eRd2p4KMrhQvuF7XpjYIdCA2Ok2LbhOq+ywZdLpHbmwQ/Yynd0gJ/Q90xCeo2vloCwl9WsbsiVVSahG5FyWg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -104,12 +104,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@openai/codex-sdk": {
|
"node_modules/@openai/codex-sdk": {
|
||||||
"version": "0.114.0",
|
"version": "0.115.0",
|
||||||
"resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.114.0.tgz",
|
"resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.115.0.tgz",
|
||||||
"integrity": "sha512-28tV+pvoQhhoRADBCtlg24fR6LDTyeUA6C65NBTvYFnDoQVPJcHcAwRrbuWmUMcXdLrXwY2bmiMxyfXlUWQzgw==",
|
"integrity": "sha512-BPoPhim0uUm3rzugY7JFaFJ+rPG/wMRhvKNFii//Dp3kTb+gFy3jrip7ijXqhswsnqXM3nwTiv7kYHt1+TMUPg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openai/codex": "0.114.0"
|
"@openai/codex": "0.115.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -117,9 +117,9 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@openai/codex-win32-arm64": {
|
"node_modules/@openai/codex-win32-arm64": {
|
||||||
"name": "@openai/codex",
|
"name": "@openai/codex",
|
||||||
"version": "0.114.0-win32-arm64",
|
"version": "0.115.0-win32-arm64",
|
||||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-win32-arm64.tgz",
|
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-win32-arm64.tgz",
|
||||||
"integrity": "sha512-CnRMHopj3en9aqQ2UaDW7EgpEGkxHdZVLLRq2cOy5D0HyuzF6Qb6595ADoFVJHEmPeN5Iz/KUbiGs5GLDjUwOA==",
|
"integrity": "sha512-yaYhQ0kPL9Kithmrr1vh5A4c+gqAMhMZeA/htTtkpWW8RQkmwgcYYpX/Ky2cEzu0w51pvxQQy63LgHftc+pg1Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -134,9 +134,9 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@openai/codex-win32-x64": {
|
"node_modules/@openai/codex-win32-x64": {
|
||||||
"name": "@openai/codex",
|
"name": "@openai/codex",
|
||||||
"version": "0.114.0-win32-x64",
|
"version": "0.115.0-win32-x64",
|
||||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.114.0-win32-x64.tgz",
|
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-win32-x64.tgz",
|
||||||
"integrity": "sha512-ztRsH5Z+gPVZ5ZInx6HzXsTFFkuEdjk3Ay0UGVOhRRCWvevXQi/SL4eU8hwysCYrs8K/WRq3mo4c95w9zQ2wLw==",
|
"integrity": "sha512-wtYf2HJCB+p+Uj7o1W08fRgZMzKCVGRQ4YdSfLRNfF4wwPqX5XsK9blsv7brukn5J9lclYxagiR6qGvbfHbD7w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -287,9 +287,7 @@ describe('agent-runner timeout behavior', () => {
|
|||||||
|
|
||||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||||
'/tmp/ejclaw-test-data/sessions/eyejokerdb-4/.codex/config.toml',
|
'/tmp/ejclaw-test-data/sessions/eyejokerdb-4/.codex/config.toml',
|
||||||
expect.stringContaining(
|
expect.stringContaining('NANOCLAW_CHAT_JID = "dc:1481348008183595170"'),
|
||||||
'NANOCLAW_CHAT_JID = "dc:1481348008183595170"',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||||
'/tmp/ejclaw-test-data/sessions/eyejokerdb-4/.codex/config.toml',
|
'/tmp/ejclaw-test-data/sessions/eyejokerdb-4/.codex/config.toml',
|
||||||
|
|||||||
@@ -175,10 +175,17 @@ function prepareGroupEnvironment(
|
|||||||
'CODEX_OPENAI_API_KEY',
|
'CODEX_OPENAI_API_KEY',
|
||||||
'CODEX_MODEL',
|
'CODEX_MODEL',
|
||||||
'CODEX_EFFORT',
|
'CODEX_EFFORT',
|
||||||
|
'MEMENTO_MCP_SSE_URL',
|
||||||
|
'MEMENTO_ACCESS_KEY',
|
||||||
|
'MEMENTO_MCP_REMOTE_PATH',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Build a clean env without Claude Code nesting detection variables
|
// Build a clean env without Claude Code nesting detection variables
|
||||||
const cleanEnv = { ...(process.env as Record<string, string>) };
|
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.CLAUDECODE;
|
||||||
delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
|
delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
|
||||||
|
|
||||||
@@ -327,7 +334,28 @@ NANOCLAW_CHAT_JID = ${JSON.stringify(chatJid)}
|
|||||||
NANOCLAW_GROUP_FOLDER = ${JSON.stringify(group.folder)}
|
NANOCLAW_GROUP_FOLDER = ${JSON.stringify(group.folder)}
|
||||||
NANOCLAW_IS_MAIN = ${JSON.stringify(isMain ? '1' : '0')}
|
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);
|
fs.writeFileSync(configTomlPath, toml);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
deleteTask,
|
deleteTask,
|
||||||
getAllChats,
|
getAllChats,
|
||||||
getAllRegisteredGroups,
|
getAllRegisteredGroups,
|
||||||
|
getDueTasks,
|
||||||
getRegisteredAgentTypesForJid,
|
getRegisteredAgentTypesForJid,
|
||||||
getMessagesSince,
|
getMessagesSince,
|
||||||
getNewMessages,
|
getNewMessages,
|
||||||
@@ -402,6 +403,43 @@ describe('task CRUD', () => {
|
|||||||
deleteTask('task-3');
|
deleteTask('task-3');
|
||||||
expect(getTaskById('task-3')).toBeUndefined();
|
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 ---
|
// --- LIMIT behavior ---
|
||||||
|
|||||||
117
src/db.ts
117
src/db.ts
@@ -48,6 +48,9 @@ function createSchema(database: Database.Database): void {
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
group_folder TEXT NOT NULL,
|
group_folder TEXT NOT NULL,
|
||||||
chat_jid TEXT NOT NULL,
|
chat_jid TEXT NOT NULL,
|
||||||
|
agent_type TEXT,
|
||||||
|
status_message_id TEXT,
|
||||||
|
status_started_at TEXT,
|
||||||
prompt TEXT NOT NULL,
|
prompt TEXT NOT NULL,
|
||||||
schedule_type TEXT NOT NULL,
|
schedule_type TEXT NOT NULL,
|
||||||
schedule_value TEXT NOT NULL,
|
schedule_value TEXT NOT NULL,
|
||||||
@@ -107,6 +110,47 @@ function createSchema(database: Database.Database): void {
|
|||||||
/* column already exists */
|
/* 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)
|
// Add is_bot_message column if it doesn't exist (migration for existing DBs)
|
||||||
try {
|
try {
|
||||||
database.exec(
|
database.exec(
|
||||||
@@ -420,17 +464,31 @@ export function getLastHumanMessageTimestamp(chatJid: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createTask(
|
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 {
|
): void {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at)
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
).run(
|
).run(
|
||||||
task.id,
|
task.id,
|
||||||
task.group_folder,
|
task.group_folder,
|
||||||
task.chat_jid,
|
task.chat_jid,
|
||||||
|
task.agent_type || SERVICE_AGENT_TYPE,
|
||||||
|
task.status_message_id || null,
|
||||||
|
task.status_started_at || null,
|
||||||
task.prompt,
|
task.prompt,
|
||||||
task.schedule_type,
|
task.schedule_type,
|
||||||
task.schedule_value,
|
task.schedule_value,
|
||||||
@@ -447,7 +505,18 @@ export function getTaskById(id: string): ScheduledTask | undefined {
|
|||||||
| 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
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC',
|
'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[];
|
.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
|
return db
|
||||||
.prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC')
|
.prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC')
|
||||||
.all() as ScheduledTask[];
|
.all() as ScheduledTask[];
|
||||||
@@ -502,23 +579,47 @@ export function updateTask(
|
|||||||
).run(...values);
|
).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 {
|
export function deleteTask(id: string): void {
|
||||||
// Delete child records first (FK constraint)
|
// Delete child records first (FK constraint)
|
||||||
db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id);
|
db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id);
|
||||||
db.prepare('DELETE FROM scheduled_tasks WHERE 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();
|
const now = new Date().toISOString();
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
SELECT * FROM scheduled_tasks
|
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
|
ORDER BY next_run
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.all(now) as ScheduledTask[];
|
.all(agentType, now) as ScheduledTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateTaskAfterRun(
|
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)
|
// Start subsystems (independently of connection handler)
|
||||||
startSchedulerLoop({
|
startSchedulerLoop({
|
||||||
|
serviceAgentType: SERVICE_AGENT_TYPE,
|
||||||
registeredGroups: () => registeredGroups,
|
registeredGroups: () => registeredGroups,
|
||||||
getSessions: () => sessions,
|
getSessions: () => sessions,
|
||||||
queue,
|
queue,
|
||||||
@@ -232,6 +233,28 @@ async function main(): Promise<void> {
|
|||||||
const text = formatOutbound(rawText);
|
const text = formatOutbound(rawText);
|
||||||
if (text) await channel.sendMessage(jid, text);
|
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({
|
startIpcWatcher({
|
||||||
sendMessage: (jid, text) => {
|
sendMessage: (jid, text) => {
|
||||||
|
|||||||
@@ -107,6 +107,31 @@ describe('schedule_task authorization', () => {
|
|||||||
expect(allTasks[0].group_folder).toBe('other-group');
|
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 () => {
|
it('non-main group cannot schedule for another group', async () => {
|
||||||
await processTaskIpc(
|
await processTaskIpc(
|
||||||
{
|
{
|
||||||
|
|||||||
16
src/ipc.ts
16
src/ipc.ts
@@ -3,7 +3,12 @@ import path from 'path';
|
|||||||
|
|
||||||
import { CronExpressionParser } from 'cron-parser';
|
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 { AvailableGroup } from './agent-runner.js';
|
||||||
import { createTask, deleteTask, getTaskById, updateTask } from './db.js';
|
import { createTask, deleteTask, getTaskById, updateTask } from './db.js';
|
||||||
import { isValidGroupFolder } from './group-folder.js';
|
import { isValidGroupFolder } from './group-folder.js';
|
||||||
@@ -257,6 +262,7 @@ export async function processTaskIpc(
|
|||||||
id: taskId,
|
id: taskId,
|
||||||
group_folder: targetFolder,
|
group_folder: targetFolder,
|
||||||
chat_jid: targetJid,
|
chat_jid: targetJid,
|
||||||
|
agent_type: targetGroupEntry.agentType || SERVICE_AGENT_TYPE,
|
||||||
prompt: data.prompt,
|
prompt: data.prompt,
|
||||||
schedule_type: scheduleType,
|
schedule_type: scheduleType,
|
||||||
schedule_value: data.schedule_value,
|
schedule_value: data.schedule_value,
|
||||||
@@ -266,7 +272,13 @@ export async function processTaskIpc(
|
|||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
logger.info(
|
logger.info(
|
||||||
{ taskId, sourceGroup, targetFolder, contextMode },
|
{
|
||||||
|
taskId,
|
||||||
|
sourceGroup,
|
||||||
|
targetFolder,
|
||||||
|
contextMode,
|
||||||
|
agentType: targetGroupEntry.agentType || SERVICE_AGENT_TYPE,
|
||||||
|
},
|
||||||
'Task created via IPC',
|
'Task created via IPC',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
isSessionCommandControlMessage,
|
isSessionCommandControlMessage,
|
||||||
} from './session-commands.js';
|
} from './session-commands.js';
|
||||||
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
|
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
|
||||||
|
import { isTaskStatusControlMessage } from './task-scheduler.js';
|
||||||
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
@@ -79,6 +80,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
if (msg.is_bot_message && isSessionCommandControlMessage(msg.content)) {
|
if (msg.is_bot_message && isSessionCommandControlMessage(msg.content)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (msg.is_bot_message && isTaskStatusControlMessage(msg.content)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,7 +102,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
const sessions = deps.getSessions();
|
const sessions = deps.getSessions();
|
||||||
const sessionId = sessions[group.folder];
|
const sessionId = sessions[group.folder];
|
||||||
|
|
||||||
const tasks = getAllTasks();
|
const tasks = getAllTasks(group.agentType || 'claude-code');
|
||||||
writeTasksSnapshot(
|
writeTasksSnapshot(
|
||||||
group.folder,
|
group.folder,
|
||||||
isMain,
|
isMain,
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { _initTestDatabase, createTask, getTaskById } from './db.js';
|
|||||||
import {
|
import {
|
||||||
_resetSchedulerLoopForTests,
|
_resetSchedulerLoopForTests,
|
||||||
computeNextRun,
|
computeNextRun,
|
||||||
|
extractWatchCiTarget,
|
||||||
|
isWatchCiTask,
|
||||||
|
renderWatchCiStatusMessage,
|
||||||
startSchedulerLoop,
|
startSchedulerLoop,
|
||||||
} from './task-scheduler.js';
|
} from './task-scheduler.js';
|
||||||
|
|
||||||
@@ -52,12 +55,99 @@ describe('task scheduler', () => {
|
|||||||
expect(task?.status).toBe('paused');
|
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', () => {
|
it('computeNextRun anchors interval tasks to scheduled time to prevent drift', () => {
|
||||||
const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago
|
const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago
|
||||||
const task = {
|
const task = {
|
||||||
id: 'drift-test',
|
id: 'drift-test',
|
||||||
group_folder: 'test',
|
group_folder: 'test',
|
||||||
chat_jid: 'test@g.us',
|
chat_jid: 'test@g.us',
|
||||||
|
agent_type: 'claude-code' as const,
|
||||||
|
status_message_id: null,
|
||||||
|
status_started_at: null,
|
||||||
prompt: 'test',
|
prompt: 'test',
|
||||||
schedule_type: 'interval' as const,
|
schedule_type: 'interval' as const,
|
||||||
schedule_value: '60000', // 1 minute
|
schedule_value: '60000', // 1 minute
|
||||||
@@ -82,6 +172,9 @@ describe('task scheduler', () => {
|
|||||||
id: 'once-test',
|
id: 'once-test',
|
||||||
group_folder: 'test',
|
group_folder: 'test',
|
||||||
chat_jid: 'test@g.us',
|
chat_jid: 'test@g.us',
|
||||||
|
agent_type: 'claude-code' as const,
|
||||||
|
status_message_id: null,
|
||||||
|
status_started_at: null,
|
||||||
prompt: 'test',
|
prompt: 'test',
|
||||||
schedule_type: 'once' as const,
|
schedule_type: 'once' as const,
|
||||||
schedule_value: '2026-01-01T00:00:00.000Z',
|
schedule_value: '2026-01-01T00:00:00.000Z',
|
||||||
@@ -106,6 +199,9 @@ describe('task scheduler', () => {
|
|||||||
id: 'skip-test',
|
id: 'skip-test',
|
||||||
group_folder: 'test',
|
group_folder: 'test',
|
||||||
chat_jid: 'test@g.us',
|
chat_jid: 'test@g.us',
|
||||||
|
agent_type: 'claude-code' as const,
|
||||||
|
status_message_id: null,
|
||||||
|
status_started_at: null,
|
||||||
prompt: 'test',
|
prompt: 'test',
|
||||||
schedule_type: 'interval' as const,
|
schedule_type: 'interval' as const,
|
||||||
schedule_value: String(ms),
|
schedule_value: String(ms),
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { ChildProcess } from 'child_process';
|
|||||||
import { CronExpressionParser } from 'cron-parser';
|
import { CronExpressionParser } from 'cron-parser';
|
||||||
import fs from 'fs';
|
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 {
|
import {
|
||||||
AgentOutput,
|
AgentOutput,
|
||||||
runAgentProcess,
|
runAgentProcess,
|
||||||
@@ -13,13 +18,14 @@ import {
|
|||||||
getDueTasks,
|
getDueTasks,
|
||||||
getTaskById,
|
getTaskById,
|
||||||
logTaskRun,
|
logTaskRun,
|
||||||
|
updateTaskStatusTracking,
|
||||||
updateTask,
|
updateTask,
|
||||||
updateTaskAfterRun,
|
updateTaskAfterRun,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { GroupQueue } from './group-queue.js';
|
import { GroupQueue } from './group-queue.js';
|
||||||
import { resolveGroupFolderPath } from './group-folder.js';
|
import { resolveGroupFolderPath } from './group-folder.js';
|
||||||
import { logger } from './logger.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
|
* 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 {
|
export interface SchedulerDependencies {
|
||||||
|
serviceAgentType?: AgentType;
|
||||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||||
getSessions: () => Record<string, string>;
|
getSessions: () => Record<string, string>;
|
||||||
queue: GroupQueue;
|
queue: GroupQueue;
|
||||||
@@ -73,6 +80,70 @@ export interface SchedulerDependencies {
|
|||||||
groupFolder: string,
|
groupFolder: string,
|
||||||
) => void;
|
) => void;
|
||||||
sendMessage: (jid: string, text: string) => Promise<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(
|
async function runTask(
|
||||||
@@ -131,7 +202,9 @@ async function runTask(
|
|||||||
|
|
||||||
// Update tasks snapshot for agent to read (filtered by group)
|
// Update tasks snapshot for agent to read (filtered by group)
|
||||||
const isMain = group.isMain === true;
|
const isMain = group.isMain === true;
|
||||||
const tasks = getAllTasks();
|
const taskAgentType =
|
||||||
|
task.agent_type || deps.serviceAgentType || SERVICE_AGENT_TYPE;
|
||||||
|
const tasks = getAllTasks(taskAgentType);
|
||||||
writeTasksSnapshot(
|
writeTasksSnapshot(
|
||||||
task.group_folder,
|
task.group_folder,
|
||||||
isMain,
|
isMain,
|
||||||
@@ -148,6 +221,8 @@ async function runTask(
|
|||||||
|
|
||||||
let result: string | null = null;
|
let result: string | null = null;
|
||||||
let error: 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
|
// For group context mode, use the group's current session
|
||||||
const sessions = deps.getSessions();
|
const sessions = deps.getSessions();
|
||||||
@@ -168,7 +243,62 @@ async function runTask(
|
|||||||
}, TASK_CLOSE_DELAY_MS);
|
}, 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 {
|
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(
|
const output = await runAgentProcess(
|
||||||
group,
|
group,
|
||||||
{
|
{
|
||||||
@@ -212,7 +342,11 @@ async function runTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ taskId: task.id, durationMs: Date.now() - startTime },
|
{
|
||||||
|
taskId: task.id,
|
||||||
|
agentType: taskAgentType,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
},
|
||||||
'Task completed',
|
'Task completed',
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -222,6 +356,22 @@ async function runTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const durationMs = Date.now() - startTime;
|
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({
|
logTaskRun({
|
||||||
task_id: task.id,
|
task_id: task.id,
|
||||||
@@ -232,7 +382,6 @@ async function runTask(
|
|||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextRun = computeNextRun(task);
|
|
||||||
const resultSummary = error
|
const resultSummary = error
|
||||||
? `Error: ${error}`
|
? `Error: ${error}`
|
||||||
: result
|
: result
|
||||||
@@ -253,7 +402,9 @@ export function startSchedulerLoop(deps: SchedulerDependencies): void {
|
|||||||
|
|
||||||
const loop = async () => {
|
const loop = async () => {
|
||||||
try {
|
try {
|
||||||
const dueTasks = getDueTasks();
|
const dueTasks = getDueTasks(
|
||||||
|
deps.serviceAgentType || SERVICE_AGENT_TYPE,
|
||||||
|
);
|
||||||
if (dueTasks.length > 0) {
|
if (dueTasks.length > 0) {
|
||||||
logger.info({ count: dueTasks.length }, 'Found due tasks');
|
logger.info({ count: dueTasks.length }, 'Found due tasks');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ export interface ScheduledTask {
|
|||||||
id: string;
|
id: string;
|
||||||
group_folder: string;
|
group_folder: string;
|
||||||
chat_jid: string;
|
chat_jid: string;
|
||||||
|
agent_type: AgentType | null;
|
||||||
|
status_message_id: string | null;
|
||||||
|
status_started_at: string | null;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
schedule_type: 'cron' | 'interval' | 'once';
|
schedule_type: 'cron' | 'interval' | 'once';
|
||||||
schedule_value: string;
|
schedule_value: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user