fix: deduplicate CI watchers and remove task ID from watcher prompts

Prevent duplicate CI completion notifications in paired rooms by checking
for existing active watchers with the same channel+provider+metadata before
creating a new one. Remove Task ID from watcher prompt construction to
avoid router secret redaction (task- IDs contain sk- pattern), passing
EJCLAW_RUNTIME_TASK_ID via env var instead and making cancel_task
auto-resolve when task_id is omitted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eyejoker
2026-03-26 20:57:59 +09:00
parent b6dc500daf
commit 7b63ba859d
12 changed files with 74 additions and 71 deletions

View File

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

View File

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

View File

@@ -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');

View File

@@ -70,6 +70,7 @@ function buildBaseRunnerEnv(args: {
groupSessionsDir: string;
agentType: AgentType;
envVars: Record<string, string>;
runtimeTaskId?: string;
}): Record<string, string> {
const cleanEnv = { ...(process.env as Record<string, string>) };
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') {

View File

@@ -469,9 +469,6 @@ describe('task CRUD', () => {
Watch target:
cleanup
Task ID:
task-cleanup
Check instructions:
Check the run.
`.trim(),

View File

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

View File

@@ -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(),

View File

@@ -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(),

View File

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

View File

@@ -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(),

View File

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

View File

@@ -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',
},
{