import fs from "node:fs"; import { normalizeCronJobIdentityFields } from "../normalize-job-identity.js"; import { normalizeCronJobInput } from "../normalize.js"; import { isInvalidCronSessionTargetIdError } from "../session-target.js"; import { loadCronStore, saveCronStore } from "../store.js"; import type { CronJob } from "../types.js"; import { recomputeNextRuns } from "./jobs.js"; import type { CronServiceState } from "./state.js";
export async function ensureLoaded(
state: CronServiceState,
opts?: {
forceReload?: boolean; /** Skip recomputing nextRunAtMs after load so the caller can run due
* jobs against the persisted values first (see onTimer). */
skipRecompute?: boolean;
},
) { // Fast path: store is already in memory. Other callers (add, list, run, …) // trust the in-memory copy to avoid a stat syscall on every operation. if (state.store && !opts?.forceReload) { return;
} // Force reload always re-reads the file to avoid missing cross-service // edits on filesystems with coarse mtime resolution.
const fileMtimeMs = await getFileMtimeMs(state.deps.storePath); const loaded = await loadCronStore(state.deps.storePath); const jobs = (loaded.jobs ?? []) as unknown as CronJob[]; for (const [index, job] of jobs.entries()) { const raw = job as unknown as Record<string, unknown>; const { legacyJobIdIssue } = normalizeCronJobIdentityFields(raw);
let normalized: Record<string, unknown> | null; try {
normalized = normalizeCronJobInput(raw);
} catch (error) { if (!isInvalidCronSessionTargetIdError(error)) { throw error;
}
normalized = null;
state.deps.log.warn(
{ storePath: state.deps.storePath, jobId: typeof raw.id === "string" ? raw.id : undefined }, "cron: job has invalid persisted sessionTarget; run openclaw doctor --fix to repair",
);
} const hydrated =
normalized && typeof normalized === "object" ? (normalized as unknown as CronJob) : job;
jobs[index] = hydrated; if (legacyJobIdIssue) { const resolvedId = typeof hydrated.id === "string" ? hydrated.id : undefined;
state.deps.log.warn(
{ storePath: state.deps.storePath, jobId: resolvedId }, "cron: job used legacy jobId field; normalized id in memory (run openclaw doctor --fix to persist canonical shape)",
);
} // Persisted legacy jobs may predate the required `enabled` field. // Keep runtime behavior backward-compatible without rewriting the store. if (typeof hydrated.enabled !== "boolean") {
hydrated.enabled = true;
} // Same shape: persisted jobs missing `sessionTarget` crash downstream // on any code path that dereferences `.startsWith` (e.g. // `runIsolatedAgentJob` in `src/gateway/server-cron.ts`). Mirror the // defaulter applied at create time: systemEvent payloads -> "main", // agentTurn -> "isolated". Use `Object.hasOwn` rather than `in` so a // poisoned prototype cannot feed a crafted `kind` into the defaulter. if (typeof hydrated.sessionTarget !== "string") { const payload = hydrated.payload as unknown; const payloadKind =
payload && typeof payload === "object" &&
!Array.isArray(payload) &&
Object.hasOwn(payload, "kind")
? (payload as { kind?: unknown }).kind
: undefined;
let defaulted: "main" | "isolated" | undefined; if (payloadKind === "systemEvent") {
defaulted = "main";
} elseif (payloadKind === "agentTurn") {
defaulted = "isolated";
} if (defaulted) {
hydrated.sessionTarget = defaulted; // `ensureLoaded` is called with `forceReload: true` on every tick; // warn once per jobId per process to avoid log spam on repeated // loads of the same still-broken store file. const jobId = typeof hydrated.id === "string" ? hydrated.id : undefined; const dedupeKey = jobId ?? "<unknown>"; if (!state.warnedMissingSessionTargetJobIds.has(dedupeKey)) {
state.warnedMissingSessionTargetJobIds.add(dedupeKey);
state.deps.log.warn(
{ storePath: state.deps.storePath, jobId, defaulted }, "cron: job missing sessionTarget; defaulted in memory (edit jobs.json to persist canonical shape)",
);
}
}
}
}
state.store = {
version: 1,
jobs,
};
state.storeLoadedAtMs = state.deps.nowMs();
state.storeFileMtimeMs = fileMtimeMs;
if (!opts?.skipRecompute) {
recomputeNextRuns(state);
}
}
export function warnIfDisabled(state: CronServiceState, action: string) { if (state.deps.cronEnabled) { return;
} if (state.warnedDisabled) { return;
}
state.warnedDisabled = true;
state.deps.log.warn(
{ enabled: false, action, storePath: state.deps.storePath }, "cron: scheduler disabled; jobs will not run automatically",
);
}
export async function persist(state: CronServiceState, opts?: { skipBackup?: boolean }) { if (!state.store) { return;
}
await saveCronStore(state.deps.storePath, state.store, opts); // Update file mtime after save to prevent immediate reload
state.storeFileMtimeMs = await getFileMtimeMs(state.deps.storePath);
}
Messung V0.5 in Prozent
¤ Dauer der Verarbeitung: 0.13 Sekunden
(vorverarbeitet am 2026-06-10)
¤
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.