Files
openclaw/src/gateway/server-cron.ts
2026-05-15 21:51:25 +01:00

495 lines
17 KiB
TypeScript

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<T extends Record<string, unknown>>(
obj: T,
keys: (keyof T)[],
): Partial<Pick<T, (typeof keys)[number]>> {
const result: Partial<Pick<T, (typeof keys)[number]>> = {};
for (const k of keys) {
if (obj[k] !== undefined) {
(result as Record<string, unknown>)[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<string>();
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 };
}