Add self-stopping CI watch tasks
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
62
runners/agent-runner/src/watch-ci.ts
Normal file
62
runners/agent-runner/src/watch-ci.ts
Normal 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();
|
||||
}
|
||||
40
runners/agent-runner/test/watch-ci.test.ts
Normal file
40
runners/agent-runner/test/watch-ci.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user