Files
openclaw/src/gateway/server-cron.ts
Kaspre 072fa9b174 fix(wake): handle relative + agent-prefixed session keys consistently in cron adapter
Address review findings from successive codex rounds:

1. next-heartbeat + sessionKey now fires a targeted immediate wake.
   The regularly-scheduled heartbeat fires for the agent's main session,
   not the supplied sessionKey, so an event queued for a non-main session
   would sit stranded indefinitely; an "event"-intent wake is also
   deferred as not-due by the heartbeat runner and not retried, so
   neither path delivers without an explicit immediate wake.

2. resolveCronWakeTarget now always runs through resolveCronAgent, both
   for agent-prefixed session keys (so non-default agents are honored)
   and relative keys (so the configured default agent is used instead
   of the hardcoded "main" returned by resolveAgentIdFromSessionKey).
   Mirrors the matching fix in the enqueueSystemEvent adapter so wake
   and enqueue resolve to the same target.

3. Generated Swift `WakeParams` models now expose the new optional
   `sessionkey` field (codingKey "sessionKey") in both the macOS and
   shared OpenClawKit copies. Locally regenerated from agent.ts via
   protocol:gen + protocol:gen:swift would have produced this; the
   environment couldn't run the generators (fs-safe transitive
   typecheck errors), so the diff was applied by hand to match what
   pnpm protocol:check would output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:24:30 +01:00

498 lines
18 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,
},
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 resolveCronWakeTarget = (opts?: { agentId?: string; sessionKey?: string | null }) => {
const requestedAgentId =
typeof opts?.agentId === "string" && opts.agentId.trim()
? normalizeAgentId(opts.agentId)
: undefined;
// Derive agentId from sessionKey only when the key is agent-prefixed
// (`agent:<id>:...`). For relative session keys like `discord:channel:ops`,
// `resolveAgentIdFromSessionKey` returns the literal `DEFAULT_AGENT_ID`
// ("main") regardless of cfg, which would route relative keys to the
// hardcoded "main" agent even on non-main-default deployments. Passing
// `undefined` lets `resolveCronAgent` fall back to the configured default
// agent so wake and enqueue resolve to the same target.
const parsedSessionKeyAgentId =
opts?.sessionKey && parseAgentSessionKey(opts.sessionKey)
? normalizeAgentId(resolveAgentIdFromSessionKey(opts.sessionKey))
: undefined;
const requestedOrDerived = requestedAgentId ?? parsedSessionKeyAgentId;
// Always run `resolveCronAgent`, including when no agent is requested —
// for relative session keys (e.g. `discord:channel:ops`) we want the
// configured default agent's session, which `resolveCronAgent(undefined)`
// returns. Leaving agentId undefined here would strand the wake on the
// resolveCronSessionKey branch below.
const { agentId: resolvedAgentId, cfg: runtimeConfig } = resolveCronAgent(requestedOrDerived);
const agentId = resolvedAgentId || undefined;
const sessionKey =
opts?.sessionKey && agentId
? resolveCronSessionKey({
runtimeConfig,
agentId,
requestedSessionKey: opts.sessionKey,
})
: 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) => {
// When the caller passes only a sessionKey (e.g. `system event --session-key`),
// derive agentId from that key so a non-default agent's session is not silently
// rejected as foreign by resolveCronSessionKey and rerouted to the default agent.
// Only derive from agent-prefixed keys — for relative keys, let resolveCronAgent
// fall back to the configured default so we don't hardcode "main" on multi-agent
// deployments where main exists but isn't the default. (Mirrors the same fix in
// resolveCronWakeTarget above.)
const derivedAgentId =
opts?.agentId ??
(opts?.sessionKey && parseAgentSessionKey(opts.sessionKey)
? resolveAgentIdFromSessionKey(opts.sessionKey)
: undefined);
const { agentId, cfg: runtimeConfig } = resolveCronAgent(derivedAgentId);
const sessionKey = resolveCronSessionKey({
runtimeConfig,
agentId,
requestedSessionKey: opts?.sessionKey,
});
enqueueSystemEvent(text, {
sessionKey,
contextKey: opts?.contextKey,
trusted: opts?.trusted,
});
},
requestHeartbeat: (opts) => {
const { agentId, sessionKey } = resolveCronWakeTarget(opts);
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 } = resolveCronWakeTarget(opts);
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,
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 };
}