Add self-stopping CI watch tasks

This commit is contained in:
Eyejoker
2026-03-19 14:14:39 +09:00
parent aa063ccea4
commit 193119945a
4 changed files with 179 additions and 2 deletions

View File

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

View File

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

View File

@@ -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 \`<internal>\` tags if needed.
- Do not claim continued monitoring after you cancel the task.
`.trim();
}

View File

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