diff --git a/runners/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts index af69f29..9cd57f6 100644 --- a/runners/agent-runner/src/ipc-mcp-stdio.ts +++ b/runners/agent-runner/src/ipc-mcp-stdio.ts @@ -27,6 +27,7 @@ const chatJid = process.env.EJCLAW_CHAT_JID!; const groupFolder = process.env.EJCLAW_GROUP_FOLDER!; const isMain = process.env.EJCLAW_IS_MAIN === '1'; const agentType = process.env.EJCLAW_AGENT_TYPE || 'claude-code'; +const runtimeTaskId = process.env.EJCLAW_RUNTIME_TASK_ID; const allowGenericScheduling = agentType !== 'codex'; function writeIpcFile(dir: string, data: object): string { @@ -299,7 +300,6 @@ server.tool( 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, checkInstructions, }); @@ -329,7 +329,7 @@ server.tool( content: [ { type: 'text' as const, - text: `CI watcher scheduled: ${taskId} (${pollSeconds}s)`, + text: `CI watcher scheduled for ${target} (${pollSeconds}s interval)`, }, ], }; @@ -414,12 +414,20 @@ server.tool( server.tool( 'cancel_task', - 'Cancel and delete a scheduled task.', - { task_id: z.string().describe('The task ID to cancel') }, - async (args: { task_id: string }) => { + 'Cancel and delete a scheduled task. If no task_id is provided, cancels the current task (for background watchers).', + { task_id: z.string().optional().describe('The task ID to cancel. Omit to cancel the current task.') }, + async (args: { task_id?: string }) => { + const resolvedId = args.task_id || runtimeTaskId; + if (!resolvedId) { + return { + content: [{ type: 'text' as const, text: 'No task_id provided and no current task context available.' }], + isError: true, + }; + } + const data = { type: 'cancel_task', - taskId: args.task_id, + taskId: resolvedId, groupFolder, isMain, timestamp: new Date().toISOString(), @@ -427,7 +435,7 @@ server.tool( writeIpcFile(TASKS_DIR, data); - return { content: [{ type: 'text' as const, text: `Task ${args.task_id} cancellation requested.` }] }; + return { content: [{ type: 'text' as const, text: `Task cancellation requested.` }] }; }, ); diff --git a/runners/agent-runner/src/watch-ci.ts b/runners/agent-runner/src/watch-ci.ts index 4784093..38b1781 100644 --- a/runners/agent-runner/src/watch-ci.ts +++ b/runners/agent-runner/src/watch-ci.ts @@ -10,7 +10,6 @@ export interface NormalizeWatchCiIntervalOptions { } export interface BuildCiWatchPromptArgs { - taskId: string; target: string; checkInstructions: string; } @@ -48,7 +47,6 @@ export function normalizeWatchCiIntervalSeconds( } export function buildCiWatchPrompt({ - taskId, target, checkInstructions, }: BuildCiWatchPromptArgs): string { @@ -60,9 +58,6 @@ You are running as an EJClaw background CI watcher. Watch target: ${target} -Task ID: -${taskId} - Check instructions: ${checkInstructions} @@ -81,7 +76,7 @@ Rules: 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. + 7. Call \`cancel_task\` (no arguments needed) 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. diff --git a/runners/agent-runner/test/watch-ci.test.ts b/runners/agent-runner/test/watch-ci.test.ts index ddd1bdd..3dcc024 100644 --- a/runners/agent-runner/test/watch-ci.test.ts +++ b/runners/agent-runner/test/watch-ci.test.ts @@ -10,14 +10,13 @@ import { 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).not.toContain('Task ID:'); expect(prompt).toContain('cancel_task'); expect(prompt).toContain('send_message'); expect(prompt).toContain('gh pr checks 42'); diff --git a/src/agent-runner-environment.ts b/src/agent-runner-environment.ts index c261233..efd19d5 100644 --- a/src/agent-runner-environment.ts +++ b/src/agent-runner-environment.ts @@ -70,6 +70,7 @@ function buildBaseRunnerEnv(args: { groupSessionsDir: string; agentType: AgentType; envVars: Record; + runtimeTaskId?: string; }): Record { const cleanEnv = { ...(process.env as Record) }; for (const [key, value] of Object.entries(args.envVars)) { @@ -103,6 +104,9 @@ function buildBaseRunnerEnv(args: { EJCLAW_IS_MAIN: args.isMain ? '1' : '0', EJCLAW_AGENT_TYPE: args.agentType, CLAUDE_CONFIG_DIR: args.groupSessionsDir, + ...(args.runtimeTaskId + ? { EJCLAW_RUNTIME_TASK_ID: args.runtimeTaskId } + : {}), }; } @@ -422,6 +426,7 @@ export function prepareGroupEnvironment( groupSessionsDir, agentType, envVars, + runtimeTaskId, }); if (agentType === 'codex') { diff --git a/src/db.test.ts b/src/db.test.ts index 4e27038..fd8764f 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -469,9 +469,6 @@ describe('task CRUD', () => { Watch target: cleanup -Task ID: -task-cleanup - Check instructions: Check the run. `.trim(), diff --git a/src/db.ts b/src/db.ts index 8ba9535..2ffcd21 100644 --- a/src/db.ts +++ b/src/db.ts @@ -936,6 +936,25 @@ export function getTaskById(id: string): ScheduledTask | undefined { | undefined; } +/** + * Find an existing active/paused CI watcher for the same channel + provider + metadata. + * Used to prevent duplicate watchers when both agents register for the same CI run. + */ +export function findDuplicateCiWatcher( + chatJid: string, + ciProvider: string, + ciMetadata: string, +): ScheduledTask | undefined { + return db + .prepare( + `SELECT * FROM scheduled_tasks + WHERE chat_jid = ? AND ci_provider = ? AND ci_metadata = ? + AND status IN ('active', 'paused') + LIMIT 1`, + ) + .get(chatJid, ciProvider, ciMetadata) as ScheduledTask | undefined; +} + export function getTasksForGroup( groupFolder: string, agentType?: AgentType, diff --git a/src/github-ci.test.ts b/src/github-ci.test.ts index 22b2e39..abb0139 100644 --- a/src/github-ci.test.ts +++ b/src/github-ci.test.ts @@ -62,9 +62,6 @@ describe('github-ci helpers', () => { Watch target: GitHub Actions run 123456 -Task ID: -task-github - Check instructions: Managed by host-driven watcher. `.trim(), @@ -128,9 +125,6 @@ Managed by host-driven watcher. Watch target: GitHub Actions run 654321 -Task ID: -task-github - Check instructions: Managed by host-driven watcher. `.trim(), diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index 79c6cf3..c6ec8ec 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -563,9 +563,6 @@ describe('schedule_task schedule types', () => { Watch target: GitHub Actions run 123456 -Task ID: -task-watch-ci - Check instructions: Check the run. `.trim(), @@ -598,9 +595,6 @@ Check the run. Watch target: GitHub Actions run 654321 -Task ID: -task-watch-github - Check instructions: Managed by host-driven watcher. `.trim(), diff --git a/src/ipc.ts b/src/ipc.ts index 12aa508..8043a69 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -11,7 +11,13 @@ import { } from './config.js'; import { readJsonFile } from './utils.js'; import { AvailableGroup } from './agent-runner.js'; -import { createTask, deleteTask, getTaskById, updateTask } from './db.js'; +import { + createTask, + deleteTask, + findDuplicateCiWatcher, + getTaskById, + updateTask, +} from './db.js'; import { isValidGroupFolder } from './group-folder.js'; import { logger } from './logger.js'; import { @@ -394,6 +400,28 @@ export async function processTaskIpc( nextRun = date.toISOString(); } + // Deduplicate CI watchers: if another agent already watches the same + // channel + provider + run, skip creation to avoid duplicate notifications. + if (data.ci_provider && data.ci_metadata) { + const existing = findDuplicateCiWatcher( + resolvedTargetJid, + data.ci_provider, + data.ci_metadata as string, + ); + if (existing) { + logger.info( + { + existingTaskId: existing.id, + existingAgentType: existing.agent_type, + ciProvider: data.ci_provider, + sourceGroup, + }, + 'Duplicate CI watcher skipped — another agent already watches this run', + ); + break; + } + } + const taskId = data.taskId || `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; diff --git a/src/task-scheduler.test.ts b/src/task-scheduler.test.ts index 763b50a..ab8473b 100644 --- a/src/task-scheduler.test.ts +++ b/src/task-scheduler.test.ts @@ -326,9 +326,6 @@ describe('task scheduler', () => { Watch target: GitHub Actions run 123456 -Task ID: -task-watch-group - Check instructions: Check the run. `.trim(), @@ -890,9 +887,6 @@ Check the run. Watch target: GitHub Actions run 654321 -Task ID: -task-watch-immediate - Check instructions: Check the run. `.trim(), @@ -928,9 +922,6 @@ Check the run. Watch target: GitHub Actions run 123456 -Task ID: -task-watch-runtime - Check instructions: Check the run. `.trim(), @@ -1004,9 +995,6 @@ Check the run. Watch target: GitHub Actions run 123456 -Task ID: -task-github-running - Check instructions: Managed by host-driven watcher. `.trim(), @@ -1082,9 +1070,6 @@ Managed by host-driven watcher. Watch target: GitHub Actions run 654321 -Task ID: -task-github-complete - Check instructions: Managed by host-driven watcher. `.trim(), @@ -1149,9 +1134,6 @@ Managed by host-driven watcher. Watch target: GitHub Actions run 222222 -Task ID: -task-github-backoff - Check instructions: Managed by host-driven watcher. `.trim(), @@ -1220,9 +1202,6 @@ Managed by host-driven watcher. Watch target: GitHub Actions run 333333 -Task ID: -task-github-pause - Check instructions: Managed by host-driven watcher. `.trim(), @@ -1284,9 +1263,6 @@ Managed by host-driven watcher. Watch target: GitHub Actions run 999999 -Task ID: -task-watch-expired - Check instructions: Check the run. `.trim(), @@ -1389,9 +1365,6 @@ Check the run. Watch target: GitHub Actions run 123456 -Task ID: -task-123 - Check instructions: Check the run. `.trim(); @@ -1428,9 +1401,6 @@ Check the run. Watch target: GitHub Actions run 123456 -Task ID: -task-123 - Check instructions: Check the run. `.trim(); @@ -1463,9 +1433,6 @@ Check the run. Watch target: GitHub Actions run 123456 -Task ID: -task-watch-status - Check instructions: Check the run. `.trim(), @@ -1527,9 +1494,6 @@ Check the run. Watch target: PR #77 checks -Task ID: -task-watch-status-edit-fail - Check instructions: Check the run. `.trim(), diff --git a/src/task-watch-status.ts b/src/task-watch-status.ts index aa277af..5e2c323 100644 --- a/src/task-watch-status.ts +++ b/src/task-watch-status.ts @@ -27,7 +27,7 @@ export function isTaskStatusControlMessage(content: string): boolean { } export function extractWatchCiTarget(prompt: string): string | null { - const match = prompt.match(/Watch target:\n([\s\S]*?)\n\nTask ID:/); + const match = prompt.match(/Watch target:\n([\s\S]*?)\n\nCheck instructions:/); return match?.[1]?.trim() || null; } diff --git a/src/unified-dashboard.test.ts b/src/unified-dashboard.test.ts index bb1cd66..761fa27 100644 --- a/src/unified-dashboard.test.ts +++ b/src/unified-dashboard.test.ts @@ -10,17 +10,17 @@ describe('summarizeWatcherTasks', () => { const summary = summarizeWatcherTasks([ { prompt: - '[BACKGROUND CI WATCH]\n\nWatch target:\nGitHub Actions run 1\n\nTask ID:\na', + '[BACKGROUND CI WATCH]\n\nWatch target:\nGitHub Actions run 1\n\nCheck instructions:\na', status: 'active', }, { prompt: - '[BACKGROUND CI WATCH]\n\nWatch target:\nGitHub Actions run 2\n\nTask ID:\nb', + '[BACKGROUND CI WATCH]\n\nWatch target:\nGitHub Actions run 2\n\nCheck instructions:\nb', status: 'paused', }, { prompt: - '[BACKGROUND CI WATCH]\n\nWatch target:\nGitHub Actions run 3\n\nTask ID:\nc', + '[BACKGROUND CI WATCH]\n\nWatch target:\nGitHub Actions run 3\n\nCheck instructions:\nc', status: 'completed', }, {