From 6fcb04e9056a2c9d54d6c0e4568f93de17d2c629 Mon Sep 17 00:00:00 2001 From: Moltar Date: Thu, 2 Apr 2026 16:04:26 -0400 Subject: [PATCH] fix(scheduler): trigger jobs by id/jobId and avoid heartbeat not-scheduled badge --- api/src/services/cronJobsService.js | 43 +++++++++++++++++++++++++++-- web/src/components/CronJobList.jsx | 8 +++--- web/src/pages/CronJobs.jsx | 15 ++++++++-- 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/api/src/services/cronJobsService.js b/api/src/services/cronJobsService.js index 1ae719d..b419232 100644 --- a/api/src/services/cronJobsService.js +++ b/api/src/services/cronJobsService.js @@ -11,6 +11,19 @@ function isUuid(str) { return typeof str === 'string' && UUID_RE.test(str); } +function resolveJobEntry(jobs, identifier) { + if (!jobs || !identifier) return null; + if (jobs[identifier]) return { key: identifier, job: jobs[identifier] }; + + for (const [key, job] of Object.entries(jobs)) { + if (job?.jobId === identifier || job?.id === identifier) { + return { key, job }; + } + } + + return null; +} + /** * Compute the next run timestamp (ms) for a cron job based on its schedule. * Returns null if the schedule cannot be parsed. @@ -940,17 +953,41 @@ async function triggerCronJob(jobId) { }); } + // Try Gateway cron.run via WebSocket RPC before file fallback + try { + const { gatewayWsRpc } = require('./openclawGatewayClient'); + const result = await gatewayWsRpc('cron.run', { jobId }); + if (result) { + logger.info('Cron job triggered via Gateway WS RPC', { jobId }); + const job = result.job || result; + return fromOfficialFormat({ + ...job, + jobId: job.jobId || jobId, + source: 'gateway', + }); + } + } catch (wsErr) { + if (wsErr.code === 'SERVICE_NOT_CONFIGURED' || wsErr.code === 'SERVICE_UNAVAILABLE') { + throw wsErr; + } + logger.warn('Gateway cron.run WS RPC failed, falling back to file trigger', { + error: wsErr.message, + code: wsErr.code, + }); + } + // Fallback: set nextRunAtMs to near-immediate so the Gateway fires it const jobs = await readCronJobs(); - if (!jobs[jobId]) { + const resolved = resolveJobEntry(jobs, jobId); + if (!resolved) { const err = new Error(`Cron job not found: ${jobId}`); err.status = 404; err.code = 'NOT_FOUND'; throw err; } - const job = jobs[jobId]; + const { key: mapKey, job } = resolved; if (job.enabled === false) { const err = new Error('Cannot trigger a disabled cron job. Enable it first.'); @@ -962,7 +999,7 @@ async function triggerCronJob(jobId) { // Set nextRunAtMs to 3 seconds from now — the Gateway picks it up on // its next 60 s timer tick and fires the job. job.state = { ...(job.state || {}), nextRunAtMs: Date.now() + 3000 }; - jobs[jobId] = job; + jobs[mapKey] = job; await writeCronJobs(jobs); logger.info('Cron job trigger requested via file fallback (nextRunAtMs set to now)', { diff --git a/web/src/components/CronJobList.jsx b/web/src/components/CronJobList.jsx index 9eca511..3e9e527 100644 --- a/web/src/components/CronJobList.jsx +++ b/web/src/components/CronJobList.jsx @@ -104,7 +104,7 @@ function formatModel(model) { return modelPart; } -function getStatusBadge(status, enabled, nextRunAt, lastRunAt) { +function getStatusBadge(status, enabled, nextRunAt, lastRunAt, isHeartbeat = false) { if (enabled === false) { return { classes: 'bg-dark-600/10 text-dark-400 border-dark-600/20', @@ -114,7 +114,7 @@ function getStatusBadge(status, enabled, nextRunAt, lastRunAt) { } // Check if enabled but has never been scheduled (no next or last run) - if (enabled !== false && !nextRunAt && !lastRunAt) { + if (enabled !== false && !nextRunAt && !lastRunAt && !isHeartbeat) { return { classes: 'bg-yellow-600/10 text-yellow-500 border-yellow-500/20', icon: ExclamationTriangleIcon, @@ -151,9 +151,9 @@ function getStatusBadge(status, enabled, nextRunAt, lastRunAt) { } function CronJobRow({ job }) { - const badge = getStatusBadge(job.status, job.enabled, job.nextRunAt, job.lastRunAt); - const BadgeIcon = badge.icon; const isHeartbeat = job.source === 'config' || job.payload?.kind === 'heartbeat'; + const badge = getStatusBadge(job.status, job.enabled, job.nextRunAt, job.lastRunAt, isHeartbeat); + const BadgeIcon = badge.icon; // Extract prompt from payload const prompt = job.payload?.message || job.payload?.text || job.prompt || null; diff --git a/web/src/pages/CronJobs.jsx b/web/src/pages/CronJobs.jsx index 1ae5926..e76a9dc 100644 --- a/web/src/pages/CronJobs.jsx +++ b/web/src/pages/CronJobs.jsx @@ -222,6 +222,7 @@ function getStatusBadge( lastRunAtMs, cronExpr = null, cronTz = null, + isHeartbeat = false, ) { if (enabled === false) { return { @@ -230,7 +231,7 @@ function getStatusBadge( label: 'disabled', }; } - if (enabled !== false && !nextRunAtMs && !lastRunAtMs) { + if (enabled !== false && !nextRunAtMs && !lastRunAtMs && !isHeartbeat) { return { classes: 'bg-yellow-600/10 text-yellow-500 border-yellow-500/20', icon: ExclamationTriangleIcon, @@ -297,9 +298,17 @@ function CronJobRow({ const cronExpr = schedule.kind === 'cron' ? schedule.expr : null; const cronTz = schedule.tz || null; - const badge = getStatusBadge(lastStatus, job.enabled, nextRunAtMs, lastRunAtMs, cronExpr, cronTz); - const BadgeIcon = badge.icon; const isHeartbeat = job.source === 'config' || job.payload?.kind === 'heartbeat'; + const badge = getStatusBadge( + lastStatus, + job.enabled, + nextRunAtMs, + lastRunAtMs, + cronExpr, + cronTz, + isHeartbeat, + ); + const BadgeIcon = badge.icon; const prompt = job.payload?.message || job.payload?.text || job.payload?.prompt || job.prompt || null;