diff --git a/prompts/codex-platform.md b/prompts/codex-platform.md index 0ecc1ed..18ec385 100644 --- a/prompts/codex-platform.md +++ b/prompts/codex-platform.md @@ -15,7 +15,7 @@ Your output is sent directly to the Discord group. - Keep answers concise unless more detail is genuinely needed - Give conclusions and concrete next steps, not hidden reasoning - Use code blocks for commands or code when helpful -- Do not claim you will keep watching, monitor later, report back later, or continue tracking unless you actually scheduled an EJClaw task with `schedule_task` +- Do not claim you will keep watching, monitor later, report back later, or continue tracking unless you actually scheduled an EJClaw task with `watch_ci` or `schedule_task` - If no task was scheduled, do not imply that background tracking is active. If future follow-up is needed, tell the user to ping you again or explicitly ask for scheduling - When you do schedule background follow-up, mention that it was scheduled. Include the task ID only when it is useful for later reference @@ -24,4 +24,4 @@ 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 `schedule_task` over leaving the chat session idle +- For CI/status/watch requests that require future follow-up, prefer `watch_ci` over leaving the chat session idle diff --git a/runners/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts index c7438b0..b43f142 100644 --- a/runners/agent-runner/src/ipc-mcp-stdio.ts +++ b/runners/agent-runner/src/ipc-mcp-stdio.ts @@ -10,6 +10,10 @@ import { z } from 'zod'; import fs from 'fs'; import path from 'path'; import { CronExpressionParser } from 'cron-parser'; +import { + buildCiWatchPrompt, + normalizeWatchCiIntervalSeconds, +} from './watch-ci.js'; const IPC_DIR = process.env.EJCLAW_IPC_DIR || process.env.NANOCLAW_IPC_DIR || '/workspace/ipc'; @@ -155,6 +159,77 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): }, ); +server.tool( + 'watch_ci', + 'Schedule a background CI watcher that checks until a run or check reaches a terminal state, then sends one message and cancels itself.', + { + target: z.string().describe('What to watch, for example "PR #123 checks" or "GitHub Actions run 987654321".'), + check_instructions: z.string().describe('Exact steps or commands to check status and what details matter when it finishes.'), + poll_interval_seconds: z + .number() + .int() + .min(30) + .max(3600) + .default(60) + .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)'), + target_group_jid: z + .string() + .optional() + .describe('(Main group only) JID of the group to schedule the watcher for. Defaults to the current group.'), + }, + async (args) => { + let pollSeconds: number; + try { + pollSeconds = normalizeWatchCiIntervalSeconds(args.poll_interval_seconds); + } catch (error) { + return { + content: [ + { + type: 'text' as const, + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + + const targetJid = isMain && args.target_group_jid ? args.target_group_jid : chatJid; + const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const prompt = buildCiWatchPrompt({ + taskId, + target: args.target, + checkInstructions: args.check_instructions, + }); + + const data = { + type: 'schedule_task', + taskId, + prompt, + schedule_type: 'interval' as const, + schedule_value: String(pollSeconds * 1000), + context_mode: args.context_mode || 'group', + targetJid, + createdBy: groupFolder, + timestamp: new Date().toISOString(), + }; + + writeIpcFile(TASKS_DIR, data); + + return { + content: [ + { + type: 'text' as const, + text: `CI watcher scheduled: ${taskId} (${pollSeconds}s)`, + }, + ], + }; + }, +); + server.tool( 'list_tasks', "List all scheduled tasks. From main: shows all tasks. From other groups: shows only that group's tasks.", diff --git a/runners/agent-runner/src/watch-ci.ts b/runners/agent-runner/src/watch-ci.ts new file mode 100644 index 0000000..6eccb8b --- /dev/null +++ b/runners/agent-runner/src/watch-ci.ts @@ -0,0 +1,62 @@ +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 interface BuildCiWatchPromptArgs { + taskId: string; + target: string; + checkInstructions: string; +} + +export function normalizeWatchCiIntervalSeconds(seconds?: number): number { + if (seconds === undefined) { + return DEFAULT_WATCH_CI_INTERVAL_SECONDS; + } + + if (!Number.isInteger(seconds)) { + throw new Error('poll_interval_seconds must be an integer.'); + } + + if ( + seconds < MIN_WATCH_CI_INTERVAL_SECONDS || + seconds > MAX_WATCH_CI_INTERVAL_SECONDS + ) { + throw new Error( + `poll_interval_seconds must be between ${MIN_WATCH_CI_INTERVAL_SECONDS} and ${MAX_WATCH_CI_INTERVAL_SECONDS}.`, + ); + } + + return seconds; +} + +export function buildCiWatchPrompt({ + taskId, + target, + checkInstructions, +}: BuildCiWatchPromptArgs): string { + return ` +[BACKGROUND CI WATCH] + +You are running as an EJClaw background CI watcher. + +Watch target: +${target} + +Task ID: +${taskId} + +Check instructions: +${checkInstructions} + +Rules: +- 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. +- If you hit a transient problem such as a rate limit, network issue, or temporary auth failure, send no visible message and leave the task active for the next retry. +- Prefer no normal final response. Use \`send_message\` for the completion message, and keep any non-user-facing notes inside \`\` tags if needed. +- Do not claim continued monitoring after you cancel the task. +`.trim(); +} diff --git a/runners/agent-runner/test/watch-ci.test.ts b/runners/agent-runner/test/watch-ci.test.ts new file mode 100644 index 0000000..b895730 --- /dev/null +++ b/runners/agent-runner/test/watch-ci.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildCiWatchPrompt, + normalizeWatchCiIntervalSeconds, +} from '../src/watch-ci.js'; + +describe('watch-ci helpers', () => { + it('builds a self-cancelling CI watch prompt', () => { + const prompt = buildCiWatchPrompt({ + taskId: 'task-123', + target: 'PR #42 checks', + checkInstructions: 'Use gh pr checks 42 and summarize only terminal results.', + }); + + expect(prompt).toContain('PR #42 checks'); + expect(prompt).toContain('task-123'); + expect(prompt).toContain('cancel_task'); + expect(prompt).toContain('send_message'); + expect(prompt).toContain('gh pr checks 42'); + }); + + it('normalizes valid poll intervals', () => { + expect(normalizeWatchCiIntervalSeconds()).toBe(60); + expect(normalizeWatchCiIntervalSeconds(30)).toBe(30); + expect(normalizeWatchCiIntervalSeconds(600)).toBe(600); + }); + + it('rejects invalid poll intervals', () => { + expect(() => normalizeWatchCiIntervalSeconds(29)).toThrow( + /between 30 and 3600/i, + ); + expect(() => normalizeWatchCiIntervalSeconds(3601)).toThrow( + /between 30 and 3600/i, + ); + expect(() => normalizeWatchCiIntervalSeconds(30.5)).toThrow( + /integer/i, + ); + }); +});