import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { abortAndDrainEmbeddedPiRun } from "../agents/pi-embedded.js"; import { cleanupBrowserSessionsForLifecycleEnd } from "../browser-lifecycle-cleanup.js"; import type { CliDeps } from "../cli/deps.types.js"; import { getRuntimeConfig } from "../config/io.js"; import { canonicalizeMainSessionAlias, resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, } from "../config/sessions.js"; import { resolveStorePath } from "../config/sessions/paths.js"; import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js"; import { appendCronRunLog, resolveCronRunLogPath, resolveCronRunLogPruneOptions, } from "../cron/run-log.js"; import type { CronServiceContract } from "../cron/service-contract.js"; import { CronService } from "../cron/service.js"; import { resolveCronSessionTargetSessionKey } from "../cron/session-target.js"; import { resolveCronStorePath } from "../cron/store.js"; import type { CronJob } from "../cron/types.js"; import { formatErrorMessage } from "../infra/errors.js"; import { runHeartbeatOnce } from "../infra/heartbeat-runner.js"; import { requestHeartbeat } from "../infra/heartbeat-wake.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { getChildLogger } from "../logging.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import type { PluginHookCronChangedEvent, PluginHookGatewayCronJob, PluginHookGatewayCronService, PluginHookGatewayContext, } from "../plugins/hook-types.js"; import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; import { dispatchGatewayCronFinishedNotifications, sendGatewayCronFailureAlert, } from "./server-cron-notifications.js"; export type GatewayCronState = { cron: CronServiceContract; storePath: string; cronEnabled: boolean; }; /** Pick only the keys whose values are not `undefined` from an object. */ function pickDefined>( obj: T, keys: (keyof T)[], ): Partial> { const result: Partial> = {}; for (const k of keys) { if (obj[k] !== undefined) { (result as Record)[k as string] = obj[k]; } } return result; } function omitExplicitHeartbeatDestination( heartbeat: AgentDefaultsConfig["heartbeat"] | undefined, ): AgentDefaultsConfig["heartbeat"] | undefined { if (!heartbeat) { return undefined; } return { ...heartbeat, to: undefined, accountId: undefined, }; } function sanitizeCronHeartbeatOverride( heartbeat: AgentDefaultsConfig["heartbeat"] | undefined, ): AgentDefaultsConfig["heartbeat"] | undefined { return heartbeat?.target === "last" ? omitExplicitHeartbeatDestination(heartbeat) : heartbeat; } /** Map internal CronJob to the public plugin SDK shape. */ function toPluginCronJob(job: CronJob): PluginHookGatewayCronJob { return { id: job.id, agentId: job.agentId, name: job.name, description: job.description, enabled: job.enabled, schedule: job.schedule ? structuredClone(job.schedule) : undefined, sessionTarget: job.sessionTarget, wakeMode: job.wakeMode, payload: job.payload ? structuredClone(job.payload) : undefined, state: { nextRunAtMs: job.state.nextRunAtMs, runningAtMs: job.state.runningAtMs, lastRunAtMs: job.state.lastRunAtMs, lastRunStatus: job.state.lastRunStatus, lastError: job.state.lastError, lastDurationMs: job.state.lastDurationMs, lastDelivered: job.state.lastDelivered, lastDeliveryStatus: job.state.lastDeliveryStatus, lastDeliveryError: job.state.lastDeliveryError, lastFailureNotificationDelivered: job.state.lastFailureNotificationDelivered, lastFailureNotificationDeliveryStatus: job.state.lastFailureNotificationDeliveryStatus, lastFailureNotificationDeliveryError: job.state.lastFailureNotificationDeliveryError, }, createdAtMs: job.createdAtMs, updatedAtMs: job.updatedAtMs, }; } export function buildGatewayCronService(params: { cfg: OpenClawConfig; deps: CliDeps; broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void; }): GatewayCronState { const cronLogger = getChildLogger({ module: "cron" }); const storePath = resolveCronStorePath(params.cfg.cron?.store); const cronEnabled = process.env.OPENCLAW_SKIP_CRON !== "1" && params.cfg.cron?.enabled !== false; const findAgentEntry = (cfg: OpenClawConfig, agentId: string) => Array.isArray(cfg.agents?.list) ? cfg.agents.list.find( (entry) => entry && typeof entry.id === "string" && normalizeAgentId(entry.id) === agentId, ) : undefined; const hasConfiguredAgent = (cfg: OpenClawConfig, agentId: string) => Boolean(findAgentEntry(cfg, agentId)); const mergeRuntimeAgentConfig = (runtimeConfig: OpenClawConfig, requestedAgentId: string) => { if (hasConfiguredAgent(runtimeConfig, requestedAgentId)) { return runtimeConfig; } const fallbackAgentEntry = findAgentEntry(params.cfg, requestedAgentId); if (!fallbackAgentEntry) { return runtimeConfig; } const startupAgents = params.cfg.agents; const runtimeAgents = runtimeConfig.agents; return { ...runtimeConfig, agents: { ...startupAgents, ...runtimeAgents, defaults: { ...startupAgents?.defaults, ...runtimeAgents?.defaults, }, list: [...(runtimeAgents?.list ?? []), fallbackAgentEntry], }, }; }; const resolveCronAgent = (requested?: string | null) => { const runtimeConfig = getRuntimeConfig(); const normalized = typeof requested === "string" && requested.trim() ? normalizeAgentId(requested) : undefined; const effectiveConfig = normalized !== undefined ? mergeRuntimeAgentConfig(runtimeConfig, normalized) : runtimeConfig; const agentId = normalized !== undefined && hasConfiguredAgent(effectiveConfig, normalized) ? normalized : resolveDefaultAgentId(effectiveConfig); return { agentId, cfg: effectiveConfig }; }; const resolveCronSessionKey = (params: { runtimeConfig: OpenClawConfig; agentId: string; requestedSessionKey?: string | null; }) => { const requested = params.requestedSessionKey?.trim(); if (!requested) { return resolveAgentMainSessionKey({ cfg: params.runtimeConfig, agentId: params.agentId, }); } const candidate = toAgentStoreSessionKey({ agentId: params.agentId, requestKey: requested, mainKey: params.runtimeConfig.session?.mainKey, }); const canonical = canonicalizeMainSessionAlias({ cfg: params.runtimeConfig, agentId: params.agentId, sessionKey: candidate, }); if (canonical !== "global") { const sessionAgentId = resolveAgentIdFromSessionKey(canonical); if (normalizeAgentId(sessionAgentId) !== normalizeAgentId(params.agentId)) { return resolveAgentMainSessionKey({ cfg: params.runtimeConfig, agentId: params.agentId, }); } } return canonical; }; const resolveCronTarget = (opts?: { agentId?: string | null; sessionKey?: string | null; preserveUntargeted?: boolean; }) => { const requestedAgentId = typeof opts?.agentId === "string" && opts.agentId.trim() ? normalizeAgentId(opts.agentId) : undefined; const requestedSessionKey = typeof opts?.sessionKey === "string" && opts.sessionKey.trim() ? opts.sessionKey : undefined; if (opts?.preserveUntargeted && !requestedAgentId && !requestedSessionKey) { return { runtimeConfig: getRuntimeConfig(), agentId: undefined, sessionKey: undefined }; } // Derive from canonical agent-prefixed keys only. Relative keys intentionally // fall through to the configured default instead of hardcoding "main". const derivedAgentId = requestedSessionKey && parseAgentSessionKey(requestedSessionKey) ? resolveAgentIdFromSessionKey(requestedSessionKey) : undefined; const { agentId: resolvedAgentId, cfg: runtimeConfig } = resolveCronAgent( requestedAgentId ?? derivedAgentId, ); const agentId = resolvedAgentId || undefined; const sessionKey = agentId ? resolveCronSessionKey({ runtimeConfig, agentId, requestedSessionKey, }) : undefined; return { runtimeConfig, agentId, sessionKey }; }; const resolveCronHeartbeatOverride = (params: { runtimeConfig: OpenClawConfig; agentId?: string; heartbeat?: AgentDefaultsConfig["heartbeat"]; }) => { if (!params.heartbeat) { return undefined; } const agentEntry = params.agentId !== undefined ? findAgentEntry(params.runtimeConfig, params.agentId) : undefined; const agentHeartbeat = agentEntry && typeof agentEntry === "object" ? agentEntry.heartbeat : undefined; const baseHeartbeat = { ...params.runtimeConfig.agents?.defaults?.heartbeat, ...agentHeartbeat, }; const heartbeatOverride = { ...baseHeartbeat, ...params.heartbeat }; return sanitizeCronHeartbeatOverride(heartbeatOverride); }; const defaultAgentId = resolveDefaultAgentId(params.cfg); const runLogPrune = resolveCronRunLogPruneOptions(params.cfg.cron?.runLog); const resolveSessionStorePath = (agentId?: string) => resolveStorePath(params.cfg.session?.store, { agentId: agentId ?? defaultAgentId, }); const sessionStorePath = resolveSessionStorePath(defaultAgentId); const warnedLegacyWebhookJobs = new Set(); const runCronChangedHook = (evt: PluginHookCronChangedEvent) => { const hookRunner = getGlobalHookRunner(); if (!hookRunner?.hasHooks("cron_changed")) { return; } const hookCtx: PluginHookGatewayContext = { config: getRuntimeConfig(), getCron: () => cron as PluginHookGatewayCronService, }; void hookRunner.runCronChanged(evt, hookCtx).catch((err) => { cronLogger.warn( { err: formatErrorMessage(err), jobId: evt.jobId }, "cron_changed hook failed", ); }); }; const cron = new CronService({ storePath, cronEnabled, cronConfig: params.cfg.cron, defaultAgentId, resolveSessionStorePath, sessionStorePath, enqueueSystemEvent: (text, opts) => { const { sessionKey } = resolveCronTarget(opts); if (!sessionKey) { throw new Error("Cron system event target did not resolve a session key."); } enqueueSystemEvent(text, { sessionKey, contextKey: opts?.contextKey, forceSenderIsOwnerFalse: opts?.forceSenderIsOwnerFalse, trusted: opts?.forceSenderIsOwnerFalse !== true, }); }, requestHeartbeat: (opts) => { const { agentId, sessionKey } = resolveCronTarget({ ...opts, preserveUntargeted: true }); requestHeartbeat({ source: opts?.source ?? "cron", intent: opts?.intent ?? "event", reason: opts?.reason, agentId, sessionKey, heartbeat: sanitizeCronHeartbeatOverride(opts?.heartbeat), }); }, runHeartbeatOnce: async (opts) => { const { runtimeConfig, agentId, sessionKey } = resolveCronTarget({ ...opts, preserveUntargeted: true, }); return await runHeartbeatOnce({ cfg: runtimeConfig, source: opts?.source ?? "cron", intent: opts?.intent ?? "event", reason: opts?.reason, agentId, sessionKey, heartbeat: resolveCronHeartbeatOverride({ runtimeConfig, agentId, heartbeat: opts?.heartbeat, }), deps: { ...params.deps, runtime: defaultRuntime }, }); }, runIsolatedAgentJob: async ({ job, message, abortSignal, onExecutionStarted, onExecutionPhase, }) => { const { agentId, cfg: runtimeConfig } = resolveCronAgent(job.agentId); const sessionKey = resolveCronSessionTargetSessionKey(job.sessionTarget) ?? `cron:${job.id}`; try { return await runCronIsolatedAgentTurn({ cfg: runtimeConfig, deps: params.deps, job, message, abortSignal, onExecutionStarted, onExecutionPhase, agentId, sessionKey, lane: "cron", }); } finally { await cleanupBrowserSessionsForLifecycleEnd({ sessionKeys: [sessionKey], onWarn: (msg) => cronLogger.warn({ jobId: job.id }, msg), }); } }, cleanupTimedOutAgentRun: async ({ job, execution }) => { if (!execution?.sessionId) { return; } const result = await abortAndDrainEmbeddedPiRun({ sessionId: execution.sessionId, sessionKey: execution.sessionKey, settleMs: 15_000, forceClear: true, reason: "cron_timeout", }); cronLogger.warn( { jobId: job.id, sessionId: execution.sessionId, sessionKey: execution.sessionKey, aborted: result.aborted, drained: result.drained, forceCleared: result.forceCleared, }, "cron: cleaned up timed-out agent run", ); }, sendCronFailureAlert: async ({ job, text, channel, to, mode, accountId }) => await sendGatewayCronFailureAlert({ deps: params.deps, logger: cronLogger, resolveCronAgent, webhookToken: params.cfg.cron?.webhookToken, job, text, channel, to, mode, accountId, }), log: getChildLogger({ module: "cron", storePath }), onEvent: (evt) => { params.broadcast("cron", evt, { dropIfSlow: true }); // Build hook event from CronEvent. The job snapshot is carried on the // internal event so it's available even for "removed" actions where // getJob() would return undefined. `delivery` and `usage` are // intentionally omitted — they contain internal channel/token detail // that is not part of the public plugin SDK surface. // Resolve job snapshot from the event or live service so top-level // convenience fields (sessionTarget, agentId) are always populated // when the job is known. const jobSnapshot = evt.job ?? cron.getJob(evt.jobId); const pluginJob = jobSnapshot ? toPluginCronJob(jobSnapshot) : undefined; const hookEvt: PluginHookCronChangedEvent = { action: evt.action, jobId: evt.jobId, ...(pluginJob ? { job: pluginJob } : {}), // Top-level routing fields so plugins don't have to dig into job. sessionTarget: jobSnapshot?.sessionTarget, agentId: jobSnapshot?.agentId, ...pickDefined(evt, [ "runAtMs", "durationMs", "status", "error", "summary", "delivered", "deliveryStatus", "deliveryError", "sessionId", "sessionKey", "runId", "nextRunAtMs", "model", "provider", ]), }; runCronChangedHook(hookEvt); if (evt.action === "finished") { const job = evt.job ?? cron.getJob(evt.jobId); dispatchGatewayCronFinishedNotifications({ evt, job, deps: params.deps, logger: cronLogger, resolveCronAgent, webhookToken: params.cfg.cron?.webhookToken, legacyWebhook: params.cfg.cron?.webhook, globalFailureDestination: params.cfg.cron?.failureDestination, warnedLegacyWebhookJobs, }); const logPath = resolveCronRunLogPath({ storePath, jobId: evt.jobId, }); void appendCronRunLog( logPath, { ts: Date.now(), jobId: evt.jobId, action: "finished", status: evt.status, error: evt.error, summary: evt.summary, diagnostics: evt.diagnostics, delivered: evt.delivered, deliveryStatus: evt.deliveryStatus, deliveryError: evt.deliveryError, failureNotificationDelivery: evt.failureNotificationDelivery, delivery: evt.delivery, sessionId: evt.sessionId, sessionKey: evt.sessionKey, runId: evt.runId, runAtMs: evt.runAtMs, durationMs: evt.durationMs, nextRunAtMs: evt.nextRunAtMs, model: evt.model, provider: evt.provider, usage: evt.usage, }, runLogPrune, ).catch((err) => { cronLogger.warn({ err: String(err), logPath }, "cron: run log append failed"); }); } }, }); return { cron, storePath, cronEnabled }; }