diff --git a/CHANGELOG.md b/CHANGELOG.md
index 37c7541c648d..c5c7b00ed036 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
- CLI: keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
+- Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
- Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.
diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256
index dedf9ef9f6c9..30cdfecc9ed4 100644
--- a/docs/.generated/config-baseline.sha256
+++ b/docs/.generated/config-baseline.sha256
@@ -1,4 +1,4 @@
-289c1bae4b9574d219fe61931be6b3ce42d4efb37d0a2edc570a521016394db5 config-baseline.json
-5bcb22d1506d82e59caa3bbc97931213299e3a2c0d45dbc549386b254661094a config-baseline.core.json
+370da2e3a4253f00c3963a3ad8b57707ea3f67a8d0d394b7d2b96db4f3413d32 config-baseline.json
+6a66c70d36dacf5fd1a8b7e157d1ff4812e97f518c13ebc3190509df4c269f29 config-baseline.core.json
a9102c0611b8170fac37853cc31771810f31757a9e3b2c6796bbd9625f9b9206 config-baseline.channel.json
-0a8e088f8dc7b12341075ce019281d5fe45827ae802f60c71a490022ba5867cf config-baseline.plugin.json
+923a8cac695c752e51751cc2dea185a3fbe19d0015722f7ea1909f897dfbb898 config-baseline.plugin.json
diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md
index 1323579f922b..8a11cf968972 100644
--- a/docs/automation/cron-jobs.md
+++ b/docs/automation/cron-jobs.md
@@ -40,11 +40,9 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t
## How cron works
- Cron runs **inside the Gateway** process (not inside the model).
-- Job definitions persist at `~/.openclaw/cron/jobs.json` so restarts do not lose schedules.
-- Runtime execution state persists next to it in `~/.openclaw/cron/jobs-state.json`. If you track cron definitions in git, track `jobs.json` and gitignore `jobs-state.json`.
-- If `jobs.json` contains malformed rows, the Gateway keeps valid jobs running, removes the malformed rows from the active store, and saves the raw rows beside it in `jobs-quarantine.json` for later repair or review.
-- After the split, older OpenClaw versions can read `jobs.json` but may treat jobs as fresh because runtime fields now live in `jobs-state.json`.
-- When `jobs.json` is edited while the Gateway is running or stopped, OpenClaw compares the changed schedule fields with pending runtime slot metadata and clears stale `nextRunAtMs` values. Pure formatting or key-order-only rewrites preserve the pending slot.
+- Job definitions, runtime state, and run history persist in OpenClaw's shared SQLite state database so restarts do not lose schedules.
+- On upgrade, legacy `~/.openclaw/cron/jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files are imported once and renamed with a `.migrated` suffix. Malformed job rows are skipped from runtime and copied to `jobs-quarantine.json` for later repair or review.
+- `cron.store` still names the logical cron store key and legacy import path. After import, editing that JSON file no longer changes active cron jobs; use `openclaw cron add|edit|remove` or the Gateway cron RPC methods instead.
- All cron executions create [background task](/automation/tasks) records.
- On Gateway startup, overdue isolated agent-turn jobs are rescheduled out of the channel-connect window instead of replaying immediately, so Discord/Telegram startup and native-command setup stay responsive after restarts.
- One-shot jobs (`--at`) auto-delete after success by default.
@@ -462,9 +460,7 @@ Model override note:
`maxConcurrentRuns` limits both scheduled cron dispatch and isolated agent-turn execution, and defaults to 8. Isolated cron agent turns use the queue's dedicated `cron-nested` execution lane internally, so raising this value lets independent cron LLM runs progress in parallel instead of only starting their outer cron wrappers. The shared non-cron `nested` lane is not widened by this setting.
-The runtime state sidecar is derived from `cron.store`: a `.json` store such as `~/clawd/cron/jobs.json` uses `~/clawd/cron/jobs-state.json`, while a store path without a `.json` suffix appends `-state.json`.
-
-If you hand-edit `jobs.json`, leave `jobs-state.json` out of source control. OpenClaw uses that sidecar for pending slots, active markers, last-run metadata, and the schedule identity that tells the scheduler when an externally edited job needs a fresh `nextRunAtMs`.
+`cron.store` is a logical store key and legacy import path. Existing stores are imported into SQLite on first load and archived; future cron changes should go through the CLI or Gateway API.
Disable cron: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
@@ -476,7 +472,7 @@ Disable cron: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
- `cron.sessionRetention` (default `24h`) prunes isolated run-session entries. `cron.runLog.maxBytes` / `cron.runLog.keepLines` auto-prune run-log files.
+ `cron.sessionRetention` (default `24h`) prunes isolated run-session entries. `cron.runLog.keepLines` limits retained SQLite run-history rows per job; `maxBytes` is retained for config compatibility with older file-backed run logs.
diff --git a/docs/automation/tasks.md b/docs/automation/tasks.md
index 82a471e49354..bd47d37438fa 100644
--- a/docs/automation/tasks.md
+++ b/docs/automation/tasks.md
@@ -346,7 +346,7 @@ A sweeper runs every **60 seconds** and handles four things:
- A cron job **definition** lives in `~/.openclaw/cron/jobs.json`; runtime execution state lives beside it in `~/.openclaw/cron/jobs-state.json`. **Every** cron execution creates a task record - both main-session and isolated. Main-session cron tasks default to `silent` notify policy so they track without generating notifications.
+ Cron job definitions, runtime execution state, and run history live in OpenClaw's shared SQLite state database. **Every** cron execution creates a task record - both main-session and isolated. Main-session cron tasks default to `silent` notify policy so they track without generating notifications.
See [Cron Jobs](/automation/cron-jobs).
diff --git a/docs/cli/cron.md b/docs/cli/cron.md
index b49d59491901..6887d82bb152 100644
--- a/docs/cli/cron.md
+++ b/docs/cli/cron.md
@@ -118,7 +118,7 @@ Skipped runs are tracked separately from execution errors. They do not affect re
For isolated jobs that target a local configured model provider, cron runs a lightweight provider preflight before starting the agent turn. Loopback, private-network, and `.local` `api: "ollama"` providers are probed at `/api/tags`; local OpenAI-compatible providers such as vLLM, SGLang, and LM Studio are probed at `/models`. If the endpoint is unreachable, the run is recorded as `skipped` and retried on a later schedule; matching dead endpoints are cached for 5 minutes to avoid many jobs hammering the same local server.
-Note: cron job definitions live in `jobs.json`, while pending runtime state lives in `jobs-state.json`. If `jobs.json` is edited externally, the Gateway reloads changed schedules and clears stale pending slots; formatting-only rewrites do not clear the pending slot. Malformed job rows are removed from active `jobs.json` at load time after their raw contents are copied to `jobs-quarantine.json`.
+Note: cron jobs, pending runtime state, and run history live in the shared SQLite state database. Legacy `jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files are imported once and renamed with a `.migrated` suffix. After import, edit schedules with `openclaw cron add|edit|remove` instead of editing JSON files.
### Manual runs
@@ -199,7 +199,7 @@ Cron does not classify final-output prose or approval-looking refusal phrases as
Retention and pruning are controlled in config:
- `cron.sessionRetention` (default `24h`) prunes completed isolated run sessions.
-- `cron.runLog.maxBytes` and `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/.jsonl`.
+- `cron.runLog.keepLines` prunes retained SQLite run-history rows per job. `cron.runLog.maxBytes` remains accepted for compatibility with older file-backed run logs.
## Migrating older jobs
diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md
index 8b7de033c355..4b5a59f531b8 100644
--- a/docs/cli/sessions.md
+++ b/docs/cli/sessions.md
@@ -104,7 +104,7 @@ openclaw sessions cleanup --json
`openclaw sessions cleanup` uses `session.maintenance` settings from config:
-- Scope note: `openclaw sessions cleanup` maintains session stores, transcripts, and trajectory sidecars. It does not prune cron run logs (`cron/runs/.jsonl`), which are managed by `cron.runLog.maxBytes` and `cron.runLog.keepLines` in [Cron configuration](/automation/cron-jobs#configuration) and explained in [Cron maintenance](/automation/cron-jobs#maintenance).
+- Scope note: `openclaw sessions cleanup` maintains session stores, transcripts, and trajectory sidecars. It does not prune cron run history, which is managed by `cron.runLog.keepLines` in [Cron configuration](/automation/cron-jobs#configuration) and explained in [Cron maintenance](/automation/cron-jobs#maintenance).
- Cleanup also prunes unreferenced primary transcripts, compaction checkpoints, and trajectory sidecars older than `session.maintenance.pruneAfter`; files still referenced by `sessions.json` are preserved.
- `--dry-run`: preview how many entries would be pruned/capped without writing.
diff --git a/docs/concepts/delegate-architecture.md b/docs/concepts/delegate-architecture.md
index 1f71d2b0ff1f..f09604e5b77d 100644
--- a/docs/concepts/delegate-architecture.md
+++ b/docs/concepts/delegate-architecture.md
@@ -127,7 +127,7 @@ See [Sandboxing](/gateway/sandboxing) and [Multi-Agent Sandbox & Tools](/tools/m
Configure logging before the delegate handles any real data:
-- Cron run history: `~/.openclaw/cron/runs/.jsonl`
+- Cron run history: OpenClaw shared SQLite state database
- Session transcripts: `~/.openclaw/agents/delegate/sessions`
- Identity provider audit logs (Exchange, Google Workspace)
diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md
index c28f74821831..9e8064052f1f 100644
--- a/docs/gateway/configuration-reference.md
+++ b/docs/gateway/configuration-reference.md
@@ -1265,8 +1265,8 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway
```
- `sessionRetention`: how long to keep completed isolated cron run sessions before pruning from `sessions.json`. Also controls cleanup of archived deleted cron transcripts. Default: `24h`; set `false` to disable.
-- `runLog.maxBytes`: max size per run log file (`cron/runs/.jsonl`) before pruning. Default: `2_000_000` bytes.
-- `runLog.keepLines`: newest lines retained when run-log pruning is triggered. Default: `2000`.
+- `runLog.maxBytes`: accepted for compatibility with older file-backed cron run logs. Default: `2_000_000` bytes.
+- `runLog.keepLines`: newest SQLite run-history rows retained per job. Default: `2000`.
- `webhookToken`: bearer token used for cron webhook POST delivery (`delivery.mode = "webhook"`), if omitted no auth header is sent.
- `webhook`: deprecated legacy fallback webhook URL (http/https) used only for stored jobs that still have `notify: true`.
diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index e971777551f4..a6a4546f04c6 100644
--- a/docs/gateway/configuration.md
+++ b/docs/gateway/configuration.md
@@ -431,7 +431,7 @@ candidate contains redacted secret placeholders such as `***`.
```
- `sessionRetention`: prune completed isolated run sessions from `sessions.json` (default `24h`; set `false` to disable).
- - `runLog`: prune `cron/runs/.jsonl` by size and retained lines.
+ - `runLog`: prune retained cron run-history rows per job. `maxBytes` remains accepted for older file-backed run logs.
- See [Cron jobs](/automation/cron-jobs) for feature overview and CLI examples.
diff --git a/docs/install/docker.md b/docs/install/docker.md
index 47cc29a4e848..bd490c0cd112 100644
--- a/docs/install/docker.md
+++ b/docs/install/docker.md
@@ -296,8 +296,8 @@ replacement. Gateway startup does not generate bundled-plugin dependency trees.
For full persistence details on VM deployments, see
[Docker VM Runtime - What persists where](/install/docker-vm-runtime#what-persists-where).
-**Disk growth hotspots:** watch `media/`, session JSONL files,
-`cron/runs/*.jsonl`, installed plugin package roots, and rolling file logs
+**Disk growth hotspots:** watch `media/`, session JSONL files, the shared
+SQLite state database, installed plugin package roots, and rolling file logs
under `/tmp/openclaw/`.
### Shell helpers (optional)
diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md
index 1f2c897bfcc9..f55d80bd63ff 100644
--- a/docs/reference/session-management-compaction.md
+++ b/docs/reference/session-management-compaction.md
@@ -125,7 +125,7 @@ openclaw sessions cleanup --enforce
Isolated cron runs also create session entries/transcripts, and they have dedicated retention controls:
- `cron.sessionRetention` (default `24h`) prunes old isolated cron run sessions from the session store (`false` disables).
-- `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/.jsonl` files (defaults: `2_000_000` bytes and `2000` lines).
+- `cron.runLog.keepLines` prunes retained SQLite run-history rows per cron job (default: `2000`). `cron.runLog.maxBytes` remains accepted for older file-backed run logs.
When cron force-creates a new isolated run session, it sanitizes the previous
`cron:` session entry before writing the new row. It carries safe
diff --git a/extensions/feishu/src/bot.card-action.test.ts b/extensions/feishu/src/bot.card-action.test.ts
index 6cd7bac22052..062ac5cb373f 100644
--- a/extensions/feishu/src/bot.card-action.test.ts
+++ b/extensions/feishu/src/bot.card-action.test.ts
@@ -172,7 +172,15 @@ describe("Feishu Card Action Handler", () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok1",
- action: { value: { text: "/ping" }, tag: "button" },
+ action: {
+ value: createFeishuCardInteractionEnvelope({
+ k: "quick",
+ a: "feishu.quick_actions.ping",
+ q: "/ping",
+ c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
+ }),
+ tag: "button",
+ },
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
diff --git a/src/commands/doctor-cron.test.ts b/src/commands/doctor-cron.test.ts
index eeb5b0035b18..b56a5dbc5448 100644
--- a/src/commands/doctor-cron.test.ts
+++ b/src/commands/doctor-cron.test.ts
@@ -3,7 +3,8 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
-import { resolveCronQuarantinePath } from "../cron/store.js";
+import { readCronRunLogEntriesSync } from "../cron/run-log.js";
+import { loadCronStore, resolveCronQuarantinePath, saveCronStore } from "../cron/store.js";
import {
collectLegacyWhatsAppCrontabHealthWarning,
maybeRepairLegacyCronStore,
@@ -65,6 +66,25 @@ function createLegacyCronJob(overrides: Record = {}) {
};
}
+function createCurrentCronJob(overrides: Record = {}) {
+ return {
+ id: "sqlite-job",
+ name: "SQLite job",
+ enabled: true,
+ createdAtMs: Date.parse("2026-02-03T00:00:00.000Z"),
+ updatedAtMs: Date.parse("2026-02-03T00:00:00.000Z"),
+ schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
+ sessionTarget: "isolated",
+ wakeMode: "now",
+ payload: {
+ kind: "systemEvent",
+ text: "SQLite brief",
+ },
+ state: {},
+ ...overrides,
+ };
+}
+
async function writeCronStore(storePath: string, jobs: Array>) {
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(
@@ -81,16 +101,20 @@ async function writeCronStore(storePath: string, jobs: Array>) {
+ await saveCronStore(storePath, {
+ version: 1,
+ jobs: jobs as never,
+ });
+}
+
async function writeLegacyCronArrayStore(storePath: string, jobs: Array>) {
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(storePath, JSON.stringify(jobs, null, 2), "utf-8");
}
async function readPersistedJobs(storePath: string): Promise>> {
- const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
- jobs: Array>;
- };
- return persisted.jobs;
+ return (await loadCronStore(storePath)).jobs as unknown as Array>;
}
function requirePersistedJob(jobs: Array>, index: number) {
@@ -160,7 +184,7 @@ describe("maybeRepairLegacyCronStore", () => {
it("surfaces cron payload model overrides without rewriting current jobs", async () => {
const storePath = await makeTempStorePath();
- await writeCronStore(storePath, [
+ await writeCurrentCronStore(storePath, [
{
id: "api-pinned",
name: "API pinned",
@@ -240,7 +264,7 @@ describe("maybeRepairLegacyCronStore", () => {
it("does not surface cron model override diagnostics when jobs inherit the default", async () => {
const storePath = await makeTempStorePath();
- await writeCronStore(storePath, [
+ await writeCurrentCronStore(storePath, [
{
id: "inherits-default",
name: "Inherits default",
@@ -269,7 +293,7 @@ describe("maybeRepairLegacyCronStore", () => {
it("counts alias model pins as default mismatches", async () => {
const storePath = await makeTempStorePath();
- await writeCronStore(storePath, [
+ await writeCurrentCronStore(storePath, [
{
id: "alias-pinned",
name: "Alias the native runtime",
@@ -336,7 +360,7 @@ describe("maybeRepairLegacyCronStore", () => {
expect(payload.text).toBe("Morning brief");
expectNoteContaining("Legacy cron job storage detected", "Cron");
- expectNoteContaining("Cron store normalized", "Doctor changes");
+ expectNoteContaining("Cron store migrated to SQLite", "Doctor changes");
});
it("repairs legacy top-level array cron stores instead of treating them as empty (#60799)", async () => {
@@ -349,17 +373,79 @@ describe("maybeRepairLegacyCronStore", () => {
prompter: makePrompter(true),
});
- const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
- version?: unknown;
- jobs?: Array>;
- };
- const job = requirePersistedJob(persisted.jobs ?? [], 0);
- expect(persisted.version).toBe(1);
+ const jobs = await readPersistedJobs(storePath);
+ const job = requirePersistedJob(jobs, 0);
expect(job.jobId).toBeUndefined();
expect(job.id).toBe("legacy-job");
expect(job.notify).toBeUndefined();
expectNoteContaining("Legacy cron job storage detected", "Cron");
- expectNoteContaining("Cron store normalized", "Doctor changes");
+ expectNoteContaining("Cron store migrated to SQLite", "Doctor changes");
+ });
+
+ it("imports legacy-only jobs when SQLite already has cron rows", async () => {
+ const storePath = await makeTempStorePath();
+ await writeCurrentCronStore(storePath, [
+ createCurrentCronJob({
+ id: "legacy-job",
+ name: "SQLite wins",
+ }),
+ ]);
+ await writeCronStore(storePath, [
+ createLegacyCronJob({
+ name: "Stale duplicate",
+ }),
+ createLegacyCronJob({
+ jobId: "legacy-only",
+ name: "Legacy only",
+ }),
+ ]);
+
+ await maybeRepairLegacyCronStore({
+ cfg: createCronConfig(storePath),
+ options: {},
+ prompter: makePrompter(true),
+ });
+
+ const jobs = await readPersistedJobs(storePath);
+ expect(jobs).toHaveLength(2);
+ expect(jobs.map((job) => job.id)).toEqual(["legacy-job", "legacy-only"]);
+ expect(requirePersistedJob(jobs, 0).name).toBe("SQLite wins");
+ expect(requirePersistedJob(jobs, 1).name).toBe("Legacy only");
+ expectNoteContaining("1 legacy JSON cron job will be imported into SQLite", "Cron");
+ expectNoteContaining("Cron store migrated to SQLite", "Doctor changes");
+ });
+
+ it("migrates legacy run logs even when the legacy job store was already archived", async () => {
+ const storePath = await makeTempStorePath();
+ await writeCurrentCronStore(storePath, [createCurrentCronJob()]);
+ const runLogPath = path.join(path.dirname(storePath), "runs", "sqlite-job.jsonl");
+ await fs.mkdir(path.dirname(runLogPath), { recursive: true });
+ await fs.writeFile(
+ runLogPath,
+ `${JSON.stringify({
+ ts: Date.parse("2026-02-04T00:00:00.000Z"),
+ jobId: "sqlite-job",
+ action: "finished",
+ status: "ok",
+ summary: "done",
+ })}\n`,
+ "utf-8",
+ );
+
+ await maybeRepairLegacyCronStore({
+ cfg: createCronConfig(storePath),
+ options: {},
+ prompter: makePrompter(true),
+ });
+
+ const entries = readCronRunLogEntriesSync(runLogPath);
+ expect(entries).toHaveLength(1);
+ expect(entries[0]?.jobId).toBe("sqlite-job");
+ expect(entries[0]?.summary).toBe("done");
+ await expect(fs.stat(runLogPath)).rejects.toMatchObject({ code: "ENOENT" });
+ await expect(fs.stat(`${runLogPath}.migrated`)).resolves.toBeTruthy();
+ expectNoteContaining("legacy JSON cron run logs will be imported into SQLite", "Cron");
+ expectNoteContaining("Cron run logs migrated to SQLite", "Doctor changes");
});
it("repairs malformed persisted cron ids before list rendering sees them", async () => {
@@ -456,15 +542,19 @@ describe("maybeRepairLegacyCronStore", () => {
prompter,
});
- const jobs = await readPersistedJobs(storePath);
- const job = requirePersistedJob(jobs, 0);
+ expect(await readPersistedJobs(storePath)).toEqual([]);
+ const legacy = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
+ jobs: Array>;
+ };
+ const job = requirePersistedJob(legacy.jobs, 0);
expect(prompter.confirm).toHaveBeenCalledWith({
message: "Repair legacy cron jobs now?",
initialValue: true,
});
expect(job.jobId).toBe("legacy-job");
+ expect(job.id).toBeUndefined();
expect(job.notify).toBe(true);
- expectNoNoteContaining("Cron store normalized", "Doctor changes");
+ expectNoNoteContaining("Cron store migrated to SQLite", "Doctor changes");
});
it("migrates notify fallback none delivery jobs to cron.webhook", async () => {
@@ -586,10 +676,8 @@ describe("maybeRepairLegacyCronStore", () => {
prompter: makePrompter(true),
});
- const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
- jobs: Array>;
- };
- const job = requirePersistedJob(persisted.jobs, 0);
+ const jobs = await readPersistedJobs(storePath);
+ const job = requirePersistedJob(jobs, 0);
expect(job.sessionTarget).toBe("isolated");
const payload = requireRecord(job.payload, "cron payload");
expect(payload.kind).toBe("agentTurn");
diff --git a/src/commands/doctor-cron.ts b/src/commands/doctor-cron.ts
index 4832a06f5e2a..7c1070dc193d 100644
--- a/src/commands/doctor-cron.ts
+++ b/src/commands/doctor-cron.ts
@@ -4,7 +4,11 @@ import { note } from "../../packages/terminal-core/src/note.js";
import { formatCliCommand } from "../cli/command-format.js";
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
+import { legacyCronRunLogFilesExist, migrateLegacyCronRunLogsToSqlite } from "../cron/run-log.js";
import {
+ archiveLegacyCronStoreForMigration,
+ legacyCronStoreFilesExist,
+ loadLegacyCronStoreForMigration,
loadCronQuarantineFile,
resolveCronQuarantinePath,
resolveCronStorePath,
@@ -40,6 +44,12 @@ function pluralize(count: number, noun: string) {
return `${count} ${noun}${count === 1 ? "" : "s"}`;
}
+function formatRunLogMigrationNote(importedFiles: number): string {
+ return importedFiles > 0
+ ? ` Imported ${pluralize(importedFiles, "legacy cron run log")} into SQLite.`
+ : "";
+}
+
function formatLegacyIssuePreview(issues: Partial>): string[] {
const lines: string[] = [];
if (issues.jobId) {
@@ -136,6 +146,35 @@ function getRecord(value: unknown): Record | null {
: null;
}
+function cronJobMigrationKey(job: Record): string | undefined {
+ return normalizeOptionalString(job.id) ?? normalizeOptionalString(job.jobId);
+}
+
+function mergeLegacyCronJobs(params: {
+ currentJobs: Array>;
+ legacyJobs: Array>;
+}): { jobs: Array>; importedCount: number } {
+ const merged = [...params.currentJobs];
+ const currentKeys = new Set(
+ params.currentJobs.map((job) => cronJobMigrationKey(job)).filter((key) => key !== undefined),
+ );
+ let importedCount = 0;
+
+ for (const legacyJob of params.legacyJobs) {
+ const key = cronJobMigrationKey(legacyJob);
+ if (key && currentKeys.has(key)) {
+ continue;
+ }
+ if (key) {
+ currentKeys.add(key);
+ }
+ merged.push(legacyJob);
+ importedCount += 1;
+ }
+
+ return { jobs: merged, importedCount };
+}
+
function formatProviderCounts(counts: Map): string {
return [...counts.entries()]
.toSorted(([left], [right]) => left.localeCompare(right))
@@ -343,8 +382,22 @@ export async function maybeRepairLegacyCronStore(params: {
const storePath = resolveCronStorePath(params.cfg.cron?.store);
const quarantinePath = resolveCronQuarantinePath(storePath);
let store: Awaited>;
+ let legacyStoreDetected = false;
+ let legacyRunLogDetected = false;
+ let legacyImportCount = 0;
try {
+ legacyStoreDetected = await legacyCronStoreFilesExist(storePath);
+ legacyRunLogDetected = await legacyCronRunLogFilesExist(storePath);
store = await loadCronStore(storePath);
+ if (legacyStoreDetected) {
+ const legacyStore = (await loadLegacyCronStoreForMigration(storePath)).store;
+ const merged = mergeLegacyCronJobs({
+ currentJobs: store.jobs as unknown as Array>,
+ legacyJobs: legacyStore.jobs as unknown as Array>,
+ });
+ legacyImportCount = merged.importedCount;
+ store = { version: 1, jobs: merged.jobs as unknown as CronJob[] };
+ }
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
note(
@@ -381,6 +434,49 @@ export async function maybeRepairLegacyCronStore(params: {
}
const rawJobs = (store.jobs ?? []) as unknown as Array>;
if (rawJobs.length === 0) {
+ if (!legacyStoreDetected && !legacyRunLogDetected) {
+ return;
+ }
+ const previewLines: string[] = [];
+ if (legacyStoreDetected) {
+ previewLines.push("- legacy JSON cron store will be archived after SQLite migration");
+ }
+ if (legacyRunLogDetected) {
+ previewLines.push("- legacy JSON cron run logs will be imported into SQLite");
+ }
+ note(
+ [
+ `Legacy cron storage detected at ${shortenHomePath(storePath)}.`,
+ ...previewLines,
+ `Repair with ${formatCliCommand("openclaw doctor --fix")} to finish the migration.`,
+ ].join("\n"),
+ "Cron",
+ );
+ const shouldRepair = await params.prompter.confirm({
+ message: "Repair legacy cron jobs now?",
+ initialValue: true,
+ });
+ if (!shouldRepair) {
+ return;
+ }
+ if (legacyStoreDetected) {
+ await saveCronStore(storePath, { version: 1, jobs: [] });
+ await archiveLegacyCronStoreForMigration(storePath);
+ }
+ const runLogMigration = legacyRunLogDetected
+ ? await migrateLegacyCronRunLogsToSqlite(storePath)
+ : { importedFiles: 0 };
+ if (legacyStoreDetected) {
+ note(
+ `Cron store migrated to SQLite at ${shortenHomePath(storePath)}.${formatRunLogMigrationNote(runLogMigration.importedFiles)}`,
+ "Doctor changes",
+ );
+ } else {
+ note(
+ `Cron run logs migrated to SQLite at ${shortenHomePath(storePath)}.${formatRunLogMigrationNote(runLogMigration.importedFiles)}`,
+ "Doctor changes",
+ );
+ }
return;
}
noteCronModelOverrides({ cfg: params.cfg, jobs: rawJobs, storePath });
@@ -390,6 +486,16 @@ export async function maybeRepairLegacyCronStore(params: {
const notifyCount = rawJobs.filter((job) => job.notify === true).length;
const dreamingStaleCount = countStaleDreamingJobs(rawJobs);
const previewLines = formatLegacyIssuePreview(normalized.issues);
+ if (legacyStoreDetected) {
+ previewLines.unshift(
+ legacyImportCount > 0
+ ? `- ${pluralize(legacyImportCount, "legacy JSON cron job")} will be imported into SQLite`
+ : "- legacy JSON cron store will be archived after SQLite migration",
+ );
+ }
+ if (legacyRunLogDetected) {
+ previewLines.push("- legacy JSON cron run logs will be imported into SQLite");
+ }
if (notifyCount > 0) {
previewLines.push(
`- ${pluralize(notifyCount, "job")} still uses legacy \`notify: true\` webhook fallback`,
@@ -400,7 +506,7 @@ export async function maybeRepairLegacyCronStore(params: {
`- ${pluralize(dreamingStaleCount, "managed dreaming job")} still has the legacy heartbeat-coupled shape`,
);
}
- if (previewLines.length === 0) {
+ if (previewLines.length === 0 && !legacyStoreDetected) {
return;
}
@@ -426,7 +532,12 @@ export async function maybeRepairLegacyCronStore(params: {
legacyWebhook,
});
const dreamingMigration = migrateLegacyDreamingPayloadShape(rawJobs);
- const changed = normalized.mutated || notifyMigration.changed || dreamingMigration.changed;
+ const changed =
+ legacyStoreDetected ||
+ legacyRunLogDetected ||
+ normalized.mutated ||
+ notifyMigration.changed ||
+ dreamingMigration.changed;
if (!changed && notifyMigration.warnings.length === 0) {
return;
}
@@ -436,7 +547,23 @@ export async function maybeRepairLegacyCronStore(params: {
version: 1,
jobs: rawJobs as unknown as CronJob[],
});
- note(`Cron store normalized at ${shortenHomePath(storePath)}.`, "Doctor changes");
+ const runLogMigration = legacyRunLogDetected
+ ? await migrateLegacyCronRunLogsToSqlite(storePath)
+ : { importedFiles: 0 };
+ if (legacyStoreDetected) {
+ await archiveLegacyCronStoreForMigration(storePath);
+ note(
+ `Cron store migrated to SQLite at ${shortenHomePath(storePath)}.${formatRunLogMigrationNote(runLogMigration.importedFiles)}`,
+ "Doctor changes",
+ );
+ } else if (legacyRunLogDetected) {
+ note(
+ `Cron run logs migrated to SQLite at ${shortenHomePath(storePath)}.${formatRunLogMigrationNote(runLogMigration.importedFiles)}`,
+ "Doctor changes",
+ );
+ } else {
+ note(`Cron store normalized at ${shortenHomePath(storePath)}.`, "Doctor changes");
+ }
if (dreamingMigration.rewrittenCount > 0) {
note(
`Rewrote ${pluralize(dreamingMigration.rewrittenCount, "managed dreaming job")} to run as an isolated agent turn so dreaming no longer requires heartbeat.`,
diff --git a/src/commands/doctor-whatsapp-responsiveness.test.ts b/src/commands/doctor-whatsapp-responsiveness.test.ts
index 5a2ff96947db..1cb71020dbdd 100644
--- a/src/commands/doctor-whatsapp-responsiveness.test.ts
+++ b/src/commands/doctor-whatsapp-responsiveness.test.ts
@@ -4,10 +4,9 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
const noteMock = vi.hoisted(() => vi.fn());
const spawnSyncMock = vi.hoisted(() => vi.fn());
-vi.mock("node:child_process", async () => {
- const { mockNodeChildProcessSpawnSync } = await import("openclaw/plugin-sdk/test-node-mocks");
- return mockNodeChildProcessSpawnSync(spawnSyncMock);
-});
+vi.mock("node:child_process", () => ({
+ spawnSync: spawnSyncMock,
+}));
vi.mock("../../packages/terminal-core/src/note.js", () => ({
note: noteMock,
diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts
index 422f1edfa83d..e42f3e6174ea 100644
--- a/src/commands/doctor.e2e-harness.ts
+++ b/src/commands/doctor.e2e-harness.ts
@@ -141,6 +141,18 @@ export const autoMigrateLegacyStateDir = vi.fn().mockResolvedValue({
changes: [],
warnings: [],
}) as unknown as MockFn;
+export const autoMigrateLegacyState = vi.fn().mockResolvedValue({
+ migrated: false,
+ skipped: false,
+ changes: [],
+ warnings: [],
+}) as unknown as MockFn;
+export const autoMigrateLegacyTaskStateSidecars = vi.fn().mockResolvedValue({
+ migrated: false,
+ skipped: false,
+ changes: [],
+ warnings: [],
+}) as unknown as MockFn;
export const runChannelPluginStartupMaintenance = vi
.fn()
.mockResolvedValue(undefined) as unknown as MockFn;
@@ -461,7 +473,9 @@ vi.mock("./onboard-helpers.js", () => ({
}));
vi.mock("./doctor-state-migrations.js", () => ({
+ autoMigrateLegacyState,
autoMigrateLegacyStateDir,
+ autoMigrateLegacyTaskStateSidecars,
detectLegacyStateMigrations,
runLegacyStateMigrations,
}));
diff --git a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts
index 06e4b617d70c..36aa95a84a09 100644
--- a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts
+++ b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts
@@ -12,6 +12,7 @@ import {
} from "./doctor.e2e-harness.js";
const providerRuntimeMocks = vi.hoisted(() => ({
+ useMockProviders: false,
resolvePluginProviders: vi.fn((_params?: unknown): ProviderPlugin[] => []),
}));
@@ -21,7 +22,12 @@ vi.mock("../plugins/providers.runtime.js", async () => {
);
return {
...actual,
- resolvePluginProviders: providerRuntimeMocks.resolvePluginProviders,
+ resolvePluginProviders: (
+ params: Parameters[0],
+ ): ProviderPlugin[] =>
+ providerRuntimeMocks.useMockProviders
+ ? providerRuntimeMocks.resolvePluginProviders(params)
+ : actual.resolvePluginProviders(params),
};
});
@@ -35,6 +41,7 @@ describe("doctor command", () => {
({ doctorCommand } = await import("./doctor.js"));
({ healthCommand } = await import("./health.js"));
vi.clearAllMocks();
+ providerRuntimeMocks.useMockProviders = false;
providerRuntimeMocks.resolvePluginProviders.mockReturnValue([]);
});
@@ -141,6 +148,7 @@ describe("doctor command", () => {
},
},
});
+ providerRuntimeMocks.useMockProviders = true;
providerRuntimeMocks.resolvePluginProviders.mockReturnValue([
{
id: "anthropic",
diff --git a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts
index d379a1a6fb3a..8d46fb18ed57 100644
--- a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts
+++ b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts
@@ -13,8 +13,8 @@ import "./doctor.fast-path-mocks.js";
let doctorCommand: typeof import("./doctor.js").doctorCommand;
-const CODEX_PROVIDER_ID = "openai-codex";
-const CODEX_PROFILE_ID = "openai-codex:user@example.com";
+const CODEX_PROVIDER_ID = "openai";
+const CODEX_PROFILE_ID = "openai:user@example.com";
const CODEX_PROFILE_EMAIL = "user@example.com";
function configCodexOAuthProfile() {
@@ -194,7 +194,7 @@ describe("doctor command", () => {
expect(warned).toBe(true);
});
- it("warns when a legacy openai-codex provider override shadows configured Codex OAuth", async () => {
+ it("warns when a legacy OpenAI provider override shadows configured Codex OAuth", async () => {
mockCodexProviderSnapshot({
provider: {
api: "openai-responses",
@@ -206,10 +206,10 @@ describe("doctor command", () => {
await runDoctorNonInteractive();
- expect(hasCodexOAuthWarning("models.providers.openai-codex")).toBe(true);
+ expect(hasCodexOAuthWarning("models.providers.openai")).toBe(true);
});
- it("warns when a legacy openai-codex provider override shadows stored Codex OAuth", async () => {
+ it("warns when a legacy OpenAI provider override shadows stored Codex OAuth", async () => {
mockCodexProviderSnapshot({
provider: {
api: "openai-responses",
@@ -222,10 +222,10 @@ describe("doctor command", () => {
await runDoctorNonInteractive();
- expect(hasCodexOAuthWarning("models.providers.openai-codex")).toBe(true);
+ expect(hasCodexOAuthWarning("models.providers.openai")).toBe(true);
});
- it("warns when an inline openai-codex model keeps the legacy OpenAI transport", async () => {
+ it("warns when an inline OpenAI model keeps the legacy OpenAI transport", async () => {
mockCodexProviderSnapshot({
provider: {
models: [
@@ -244,7 +244,7 @@ describe("doctor command", () => {
expect(hasCodexOAuthWarning("legacy transport override")).toBe(true);
});
- it("does not warn for a custom openai-codex proxy override", async () => {
+ it("does not warn for a custom OpenAI proxy override", async () => {
mockCodexProviderSnapshot({
provider: {
api: "openai-responses",
@@ -259,7 +259,7 @@ describe("doctor command", () => {
expect(hasCodexOAuthWarning()).toBe(false);
});
- it("does not warn for header-only openai-codex overrides", async () => {
+ it("does not warn for header-only OpenAI overrides", async () => {
mockCodexProviderSnapshot({
provider: {
baseUrl: "https://custom.example.com",
@@ -275,7 +275,7 @@ describe("doctor command", () => {
expect(hasCodexOAuthWarning()).toBe(false);
});
- it("does not warn about an openai-codex provider override without Codex OAuth", async () => {
+ it("does not warn about an OpenAI provider override without Codex OAuth", async () => {
mockCodexProviderSnapshot({
provider: {
api: "openai-responses",
diff --git a/src/commands/tasks.test.ts b/src/commands/tasks.test.ts
index 285a7bf7f522..6c6064d7223b 100644
--- a/src/commands/tasks.test.ts
+++ b/src/commands/tasks.test.ts
@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { saveCronStore } from "../cron/store.js";
import type { RuntimeEnv } from "../runtime.js";
import {
createManagedTaskFlow,
@@ -407,7 +408,7 @@ describe("tasks commands", () => {
),
"utf-8",
);
- await state.writeJson("cron/jobs.json", {
+ await saveCronStore(state.statePath("cron", "jobs.json"), {
version: 1,
jobs: [
{
@@ -422,7 +423,7 @@ describe("tasks commands", () => {
delivery: { mode: "none" },
createdAtMs: now,
updatedAtMs: now,
- state: {},
+ state: { runningAtMs: now - 5_000 },
},
{
id: "done-job",
@@ -440,20 +441,6 @@ describe("tasks commands", () => {
},
],
});
- await state.writeJson("cron/jobs-state.json", {
- version: 1,
- jobs: {
- "running-job": {
- updatedAtMs: now,
- state: { runningAtMs: now - 5_000 },
- },
- "done-job": {
- updatedAtMs: now,
- state: {},
- },
- },
- });
-
const runtime = createRuntime();
await tasksMaintenanceCommand({ json: true, apply: true }, runtime);
diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts
index 0b57eb8d1061..8b2230075b55 100644
--- a/src/config/schema.help.quality.test.ts
+++ b/src/config/schema.help.quality.test.ts
@@ -751,7 +751,7 @@ describe("config help copy quality", () => {
it("documents cron run-log retention controls", () => {
const runLog = FIELD_HELP["cron.runLog"];
- expect(runLog.includes("cron/runs")).toBe(true);
+ expect(runLog.includes("SQLite")).toBe(true);
const maxBytes = FIELD_HELP["cron.runLog.maxBytes"];
expect(maxBytes.includes("2mb")).toBe(true);
diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts
index c3e7675b5079..9fd0bc23554c 100644
--- a/src/config/schema.help.ts
+++ b/src/config/schema.help.ts
@@ -1708,11 +1708,11 @@ export const FIELD_HELP: Record = {
"cron.sessionRetention":
"Controls how long completed cron run sessions are kept before pruning (`24h`, `7d`, `1h30m`, or `false` to disable pruning; default: `24h`). Use shorter retention to reduce storage growth on high-frequency schedules.",
"cron.runLog":
- "Pruning controls for per-job cron run history files under `cron/runs/.jsonl`, including size and line retention.",
+ "Pruning controls for per-job cron run history. Run history is stored in SQLite; maxBytes remains accepted for older file-backed run logs.",
"cron.runLog.maxBytes":
- "Maximum bytes per cron run-log file before pruning rewrites to the last keepLines entries (for example `2mb`, default `2000000`).",
+ "Compatibility setting for older file-backed cron run logs (for example `2mb`, default `2000000`). SQLite run history pruning is row-count based.",
"cron.runLog.keepLines":
- "How many trailing run-log lines to retain when a file exceeds maxBytes (default `2000`). Increase for longer forensic history or lower for smaller disks.",
+ "How many trailing run-history rows to retain per cron job (default `2000`). Increase for longer forensic history or lower for smaller disks.",
transcripts:
"Core transcript capture settings for recording-capable agent tools and configured live meeting auto-start sources. Keep disabled unless operators explicitly want agents to capture or import meeting transcripts.",
"transcripts.enabled":
diff --git a/src/config/types.cron.ts b/src/config/types.cron.ts
index 2e7d8ec3615d..b4b229f45077 100644
--- a/src/config/types.cron.ts
+++ b/src/config/types.cron.ts
@@ -48,7 +48,8 @@ export type CronConfig = {
*/
sessionRetention?: string | false;
/**
- * Run-log pruning controls for `cron/runs/.jsonl`.
+ * Run-history pruning controls. History is stored in SQLite; maxBytes is
+ * retained for compatibility with older file-backed run logs.
* Defaults: `maxBytes=2_000_000`, `keepLines=2000`.
*/
runLog?: {
diff --git a/src/cron/run-log.error-reason.test.ts b/src/cron/run-log.error-reason.test.ts
index 957ce724db83..9aab6cc64ad0 100644
--- a/src/cron/run-log.error-reason.test.ts
+++ b/src/cron/run-log.error-reason.test.ts
@@ -2,23 +2,36 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
-import { readCronRunLogEntriesPage } from "./run-log.js";
+import {
+ appendCronRunLog,
+ migrateLegacyCronRunLogsToSqlite,
+ readCronRunLogEntriesPage,
+ type CronRunLogEntry,
+} from "./run-log.js";
+
+async function writeLegacyRunLogAndMigrate(
+ entries: Array>,
+): Promise {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "cron-run-log-"));
+ const storePath = path.join(dir, "cron", "jobs.json");
+ const file = path.join(dir, "cron", "runs", "job-1.jsonl");
+ await fs.mkdir(path.dirname(file), { recursive: true });
+ await fs.writeFile(file, entries.map((entry) => JSON.stringify(entry)).join("\n") + "\n", "utf8");
+ await migrateLegacyCronRunLogsToSqlite(storePath);
+ return file;
+}
describe("cron run log errorReason", () => {
it("backfills errorReason from timeout error text for older entries", async () => {
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "cron-run-log-"));
- const file = path.join(dir, "job.jsonl");
- await fs.writeFile(
- file,
- `${JSON.stringify({
+ const file = await writeLegacyRunLogAndMigrate([
+ {
ts: 1,
jobId: "job-1",
action: "finished",
status: "error",
error: "cron: job execution timed out",
- })}\n`,
- "utf8",
- );
+ },
+ ]);
const page = await readCronRunLogEntriesPage(file, { limit: 10 });
expect(page.entries[0]?.errorReason).toBe("timeout");
@@ -42,42 +55,32 @@ describe("cron run log errorReason", () => {
"no_error_details",
"unclassified",
"unknown",
- ];
- await fs.writeFile(
- file,
- reasons
- .map((errorReason, index) =>
- JSON.stringify({
- ts: index + 1,
- jobId: "job-1",
- action: "finished",
- status: "error",
- errorReason,
- }),
- )
- .join("\n") + "\n",
- "utf8",
- );
+ ] satisfies Array>;
+ for (const [index, errorReason] of reasons.entries()) {
+ await appendCronRunLog(file, {
+ ts: index + 1,
+ jobId: "job-1",
+ action: "finished",
+ status: "error",
+ errorReason,
+ });
+ }
const page = await readCronRunLogEntriesPage(file, { limit: 50, sortDir: "asc" });
expect(page.entries.map((entry) => entry.errorReason)).toEqual(reasons);
});
it("derives an invalid persisted reason from raw error text before exposing entries", async () => {
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "cron-run-log-"));
- const file = path.join(dir, "job.jsonl");
- await fs.writeFile(
- file,
- `${JSON.stringify({
+ const file = await writeLegacyRunLogAndMigrate([
+ {
ts: 1,
jobId: "job-1",
action: "finished",
status: "error",
error: "upstream unavailable: 503 overloaded",
errorReason: "not-a-real-reason",
- })}\n`,
- "utf8",
- );
+ },
+ ]);
const page = await readCronRunLogEntriesPage(file, { limit: 10 });
expect(page.entries[0]?.errorReason).toBe("overloaded");
@@ -86,18 +89,14 @@ describe("cron run log errorReason", () => {
it("uses provider context when deriving persisted run-log reasons", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "cron-run-log-"));
const file = path.join(dir, "job.jsonl");
- await fs.writeFile(
- file,
- `${JSON.stringify({
- ts: 1,
- jobId: "job-1",
- action: "finished",
- status: "error",
- error: "403 Key limit exceeded (monthly limit)",
- provider: "openrouter",
- })}\n`,
- "utf8",
- );
+ await appendCronRunLog(file, {
+ ts: 1,
+ jobId: "job-1",
+ action: "finished",
+ status: "error",
+ error: "403 Key limit exceeded (monthly limit)",
+ provider: "openrouter",
+ });
const page = await readCronRunLogEntriesPage(file, { limit: 10 });
expect(page.entries[0]?.errorReason).toBe("billing");
@@ -106,17 +105,13 @@ describe("cron run log errorReason", () => {
it("includes derived errorReason values in run-log search", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "cron-run-log-"));
const file = path.join(dir, "job.jsonl");
- await fs.writeFile(
- file,
- `${JSON.stringify({
- ts: 1,
- jobId: "job-1",
- action: "finished",
- status: "error",
- error: "cron: job execution timed out",
- })}\n`,
- "utf8",
- );
+ await appendCronRunLog(file, {
+ ts: 1,
+ jobId: "job-1",
+ action: "finished",
+ status: "error",
+ error: "cron: job execution timed out",
+ });
const page = await readCronRunLogEntriesPage(file, { limit: 10, query: "timeout" });
expect(page.entries).toHaveLength(1);
diff --git a/src/cron/run-log.test.ts b/src/cron/run-log.test.ts
index 4a51a5d65f79..f25d20d93ee8 100644
--- a/src/cron/run-log.test.ts
+++ b/src/cron/run-log.test.ts
@@ -7,6 +7,7 @@ import {
DEFAULT_CRON_RUN_LOG_KEEP_LINES,
DEFAULT_CRON_RUN_LOG_MAX_BYTES,
getPendingCronRunLogWriteCountForTests,
+ migrateLegacyCronRunLogsToSqlite,
readCronRunLogEntries,
readCronRunLogEntriesPage,
readCronRunLogEntriesSync,
@@ -68,7 +69,7 @@ describe("cron run log", () => {
);
});
- it("appends JSONL and prunes by line count", async () => {
+ it("appends SQLite rows and prunes by line count", async () => {
await withRunLogDir("openclaw-cron-log-", async (dir) => {
const logPath = path.join(dir, "runs", "job-1.jsonl");
@@ -86,17 +87,9 @@ describe("cron run log", () => {
);
}
- const raw = await fs.readFile(logPath, "utf-8");
- const lines: string[] = [];
- for (const rawLine of raw.split("\n")) {
- const line = rawLine.trim();
- if (line) {
- lines.push(line);
- }
- }
- expect(lines.length).toBe(3);
- const last = JSON.parse(lines[2] ?? "{}") as { ts?: number };
- expect(last.ts).toBe(1009);
+ const entries = readCronRunLogEntriesSync(logPath, { limit: 10 });
+ expect(entries.map((entry) => entry.ts)).toEqual([1007, 1008, 1009]);
+ await expect(fs.stat(logPath)).rejects.toMatchObject({ code: "ENOENT" });
});
});
@@ -129,7 +122,7 @@ describe("cron run log", () => {
});
it.skipIf(process.platform === "win32")(
- "writes run log files with secure permissions",
+ "does not create legacy run log files for new writes",
async () => {
await withRunLogDir("openclaw-cron-log-perms-", async (dir) => {
const logPath = path.join(dir, "runs", "job-1.jsonl");
@@ -141,14 +134,13 @@ describe("cron run log", () => {
status: "ok",
});
- const mode = (await fs.stat(logPath)).mode & 0o777;
- expect(mode).toBe(0o600);
+ await expect(fs.stat(logPath)).rejects.toMatchObject({ code: "ENOENT" });
});
},
);
it.skipIf(process.platform === "win32")(
- "hardens an existing run-log directory to owner-only permissions",
+ "does not mutate legacy run-log directory permissions on SQLite writes",
async () => {
await withRunLogDir("openclaw-cron-log-dir-perms-", async (dir) => {
const runDir = path.join(dir, "runs");
@@ -164,7 +156,7 @@ describe("cron run log", () => {
});
const runDirMode = (await fs.stat(runDir)).mode & 0o777;
- expect(runDirMode).toBe(0o700);
+ expect(runDirMode).toBe(0o755);
});
},
);
@@ -288,6 +280,7 @@ describe("cron run log", () => {
"utf-8",
);
+ await migrateLegacyCronRunLogsToSqlite(path.join(dir, "jobs.json"));
const entries = await readCronRunLogEntries(logPath, { limit: 10, jobId: "job-1" });
expect(entries).toHaveLength(1);
expect(entries[0]?.ts).toBe(2);
@@ -329,6 +322,7 @@ describe("cron run log", () => {
"utf-8",
);
+ await migrateLegacyCronRunLogsToSqlite(path.join(dir, "jobs.json"));
expect(
(
await readCronRunLogEntriesPage(logPath, {
@@ -412,6 +406,7 @@ describe("cron run log", () => {
},
});
+ await fs.mkdir(path.dirname(logPath), { recursive: true });
await fs.appendFile(
logPath,
`${JSON.stringify({
@@ -426,6 +421,7 @@ describe("cron run log", () => {
"utf-8",
);
+ await migrateLegacyCronRunLogsToSqlite(path.join(dir, "jobs.json"));
const entries = await readCronRunLogEntries(logPath, { limit: 10, jobId: "job-1" });
expect(entries[0]?.model).toBe("gpt-5.4");
expect(entries[0]?.provider).toBe("openai");
diff --git a/src/cron/run-log.ts b/src/cron/run-log.ts
index 3f999fe210ff..c44ea667018f 100644
--- a/src/cron/run-log.ts
+++ b/src/cron/run-log.ts
@@ -1,18 +1,25 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
+import type { DatabaseSync } from "node:sqlite";
+import type { Insertable, Selectable } from "kysely";
import type { FailoverReason } from "../agents/embedded-agent-helpers/types.js";
import { resolveFailoverReasonFromError } from "../agents/failover-error.js";
import { parseByteSize } from "../cli/parse-bytes.js";
import type { CronConfig } from "../config/types.cron.js";
-import { appendRegularFile, isPathInside, pathExists, root as fsRoot } from "../infra/fs-safe.js";
-import { privateFileStore } from "../infra/private-file-store.js";
+import { isPathInside } from "../infra/fs-safe.js";
+import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
normalizeStringifiedOptionalString,
} from "../shared/string-coerce.js";
-import { normalizeStringEntries, uniqueValues } from "../shared/string-normalization.js";
+import { uniqueValues } from "../shared/string-normalization.js";
+import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
+import {
+ openOpenClawStateDatabase,
+ runOpenClawStateWriteTransaction,
+} from "../state/openclaw-state-db.js";
import { normalizeCronRunDiagnostics } from "./run-diagnostics.js";
import type {
CronDeliveryStatus,
@@ -75,6 +82,11 @@ type ReadCronRunLogAllPageOptions = Omit & {
jobNameById?: Record;
};
+type CronRunLogsTable = OpenClawStateKyselyDatabase["cron_run_logs"];
+type CronRunLogDatabase = Pick;
+type CronRunLogRow = Selectable;
+type CronRunLogInsert = Insertable;
+
const CRON_FAILOVER_REASONS = new Set([
"auth",
"auth_permanent",
@@ -92,6 +104,10 @@ const CRON_FAILOVER_REASONS = new Set([
"unknown",
]);
+const LEGACY_CRON_RUN_LOG_ARCHIVE_SUFFIX = ".migrated";
+type CronRunLogTarget = { storePath: string; jobId: string; strictJobId: boolean };
+const runLogTargetsByPath = new Map();
+
function normalizeCronRunLogErrorReason(value: unknown): FailoverReason | undefined {
return typeof value === "string" && CRON_FAILOVER_REASONS.has(value as FailoverReason)
? (value as FailoverReason)
@@ -118,15 +134,12 @@ export function resolveCronRunLogPath(params: { storePath: string; jobId: string
if (!isPathInside(runsDir, resolvedPath)) {
throw new Error("invalid cron run log job id");
}
+ runLogTargetsByPath.set(resolvedPath, { storePath, jobId: safeJobId, strictJobId: true });
return resolvedPath;
}
const writesByPath = new Map>();
-async function setSecureFileMode(filePath: string): Promise {
- await fs.chmod(filePath, 0o600).catch(() => undefined);
-}
-
export const DEFAULT_CRON_RUN_LOG_MAX_BYTES = 2_000_000;
export const DEFAULT_CRON_RUN_LOG_KEEP_LINES = 2_000;
@@ -166,19 +179,212 @@ async function drainPendingWrite(filePath: string): Promise {
}
}
-async function pruneIfNeeded(filePath: string, opts: { maxBytes: number; keepLines: number }) {
- const stat = await fs.stat(filePath).catch(() => null);
- if (!stat || stat.size <= opts.maxBytes) {
+function cronStoreKey(storePath: string): string {
+ return path.resolve(storePath);
+}
+
+function getCronRunLogKysely(db: DatabaseSync) {
+ return getNodeSqliteKysely(db);
+}
+
+function inferCronRunLogTarget(filePath: string, jobId?: string): CronRunLogTarget {
+ const resolved = path.resolve(filePath);
+ const known = runLogTargetsByPath.get(resolved);
+ if (known && (!jobId || known.jobId === jobId)) {
+ return known;
+ }
+ const inferredJobId = assertSafeCronRunLogJobId(jobId ?? path.basename(resolved, ".jsonl"));
+ const parentDir = path.dirname(resolved);
+ const isRunsDir = path.basename(parentDir) === "runs";
+ const storeDir = isRunsDir ? path.dirname(parentDir) : parentDir;
+ const storePath = path.resolve(storeDir, "jobs.json");
+ return { storePath, jobId: inferredJobId, strictJobId: isRunsDir };
+}
+
+function normalizeNumber(value: number | bigint | null): number | undefined {
+ if (typeof value === "bigint") {
+ return Number(value);
+ }
+ return typeof value === "number" ? value : undefined;
+}
+
+function booleanToInteger(value: boolean | undefined): number | null {
+ return typeof value === "boolean" ? (value ? 1 : 0) : null;
+}
+
+function integerToBoolean(value: number | bigint | null): boolean | undefined {
+ const normalized = normalizeNumber(value);
+ return normalized == null ? undefined : normalized !== 0;
+}
+
+function bindCronRunLogRow(params: {
+ storeKey: string;
+ seq: number;
+ entry: CronRunLogEntry;
+}): CronRunLogInsert {
+ const entry = params.entry;
+ return {
+ store_key: params.storeKey,
+ job_id: entry.jobId,
+ seq: params.seq,
+ ts: entry.ts,
+ status: entry.status ?? null,
+ error: entry.error ?? null,
+ summary: entry.summary ?? null,
+ diagnostics_summary: entry.diagnostics?.summary ?? null,
+ delivery_status: entry.deliveryStatus ?? null,
+ delivery_error: entry.deliveryError ?? null,
+ delivered: booleanToInteger(entry.delivered),
+ session_id: entry.sessionId ?? null,
+ session_key: entry.sessionKey ?? null,
+ run_id: entry.runId ?? null,
+ run_at_ms: entry.runAtMs ?? null,
+ duration_ms: entry.durationMs ?? null,
+ next_run_at_ms: entry.nextRunAtMs ?? null,
+ model: entry.model ?? null,
+ provider: entry.provider ?? null,
+ total_tokens: entry.usage?.total_tokens ?? null,
+ entry_json: JSON.stringify(entry),
+ created_at: Date.now(),
+ };
+}
+
+function parseStoredRunLogEntry(row: CronRunLogRow): CronRunLogEntry | null {
+ const parsed = parseAllRunLogEntries(`${row.entry_json}\n`, { jobId: row.job_id })[0];
+ if (!parsed) {
+ return null;
+ }
+ return {
+ ...parsed,
+ ts: normalizeNumber(row.ts) ?? parsed.ts,
+ jobId: row.job_id,
+ status: (row.status as CronRunStatus | null) ?? parsed.status,
+ error: row.error ?? parsed.error,
+ summary: row.summary ?? parsed.summary,
+ delivered: integerToBoolean(row.delivered) ?? parsed.delivered,
+ deliveryStatus: (row.delivery_status as CronDeliveryStatus | null) ?? parsed.deliveryStatus,
+ deliveryError: row.delivery_error ?? parsed.deliveryError,
+ sessionId: row.session_id ?? parsed.sessionId,
+ sessionKey: row.session_key ?? parsed.sessionKey,
+ runId: row.run_id ?? parsed.runId,
+ runAtMs: normalizeNumber(row.run_at_ms) ?? parsed.runAtMs,
+ durationMs: normalizeNumber(row.duration_ms) ?? parsed.durationMs,
+ nextRunAtMs: normalizeNumber(row.next_run_at_ms) ?? parsed.nextRunAtMs,
+ model: row.model ?? parsed.model,
+ provider: row.provider ?? parsed.provider,
+ };
+}
+
+function readCronRunLogRows(db: DatabaseSync, storeKey: string, jobId?: string): CronRunLogRow[] {
+ let query = getCronRunLogKysely(db)
+ .selectFrom("cron_run_logs")
+ .selectAll()
+ .where("store_key", "=", storeKey);
+ if (jobId) {
+ query = query.where("job_id", "=", jobId);
+ }
+ return executeSqliteQuerySync(db, query.orderBy("ts", "asc").orderBy("seq", "asc")).rows;
+}
+
+function nextCronRunLogSeq(db: DatabaseSync, storeKey: string, jobId: string): number {
+ const row = db
+ .prepare(
+ "SELECT COALESCE(MAX(seq), 0) AS seq FROM cron_run_logs WHERE store_key = ? AND job_id = ?",
+ )
+ .get(storeKey, jobId) as { seq?: number | bigint } | undefined;
+ return (normalizeNumber(row?.seq ?? null) ?? 0) + 1;
+}
+
+function insertCronRunLogEntry(db: DatabaseSync, storeKey: string, entry: CronRunLogEntry): void {
+ const seq = nextCronRunLogSeq(db, storeKey, entry.jobId);
+ executeSqliteQuerySync(
+ db,
+ getCronRunLogKysely(db)
+ .insertInto("cron_run_logs")
+ .values(bindCronRunLogRow({ storeKey, seq, entry })),
+ );
+}
+
+function pruneCronRunLogRows(
+ db: DatabaseSync,
+ storeKey: string,
+ jobId: string,
+ keepLines: number,
+): void {
+ const keep = Math.max(1, Math.floor(keepLines));
+ db.prepare(
+ `DELETE FROM cron_run_logs
+ WHERE store_key = ? AND job_id = ?
+ AND seq NOT IN (
+ SELECT seq FROM cron_run_logs
+ WHERE store_key = ? AND job_id = ?
+ ORDER BY seq DESC
+ LIMIT ?
+ )`,
+ ).run(storeKey, jobId, storeKey, jobId, keep);
+}
+
+function importLegacyCronRunLogSync(filePath: string, target: CronRunLogTarget): void {
+ const resolved = path.resolve(filePath);
+ if (!fsSync.existsSync(resolved)) {
return;
}
+ const storeKey = cronStoreKey(target.storePath);
+ runOpenClawStateWriteTransaction(({ db }) => {
+ const existingRows = readCronRunLogRows(
+ db,
+ storeKey,
+ target.strictJobId ? target.jobId : undefined,
+ );
+ const existingKeys = new Set(
+ existingRows.map((row) =>
+ [
+ row.job_id,
+ normalizeNumber(row.ts) ?? "",
+ row.run_id ?? "",
+ row.status ?? "",
+ row.summary ?? "",
+ row.error ?? "",
+ ].join("\0"),
+ ),
+ );
+ const raw = fsSync.readFileSync(resolved, "utf-8");
+ for (const entry of parseAllRunLogEntries(
+ raw,
+ target.strictJobId ? { jobId: target.jobId } : undefined,
+ )) {
+ const key = [
+ entry.jobId,
+ entry.ts,
+ entry.runId ?? "",
+ entry.status ?? "",
+ entry.summary ?? "",
+ entry.error ?? "",
+ ].join("\0");
+ if (existingKeys.has(key)) {
+ continue;
+ }
+ existingKeys.add(key);
+ insertCronRunLogEntry(db, storeKey, entry);
+ }
+ });
+ archiveLegacyCronRunLogSync(resolved);
+}
- const raw = await fs.readFile(filePath, "utf-8").catch(() => "");
- const lines = normalizeStringEntries(raw.split("\n"));
- const kept = lines.slice(Math.max(0, lines.length - opts.keepLines));
- await privateFileStore(path.dirname(filePath)).writeText(
- path.basename(filePath),
- `${kept.join("\n")}\n`,
- );
+async function importLegacyCronRunLog(filePath: string, target: CronRunLogTarget): Promise {
+ importLegacyCronRunLogSync(filePath, target);
+}
+
+function archiveLegacyCronRunLogSync(filePath: string): void {
+ const archivePath = `${filePath}${LEGACY_CRON_RUN_LOG_ARCHIVE_SUFFIX}`;
+ if (!fsSync.existsSync(filePath) || fsSync.existsSync(archivePath)) {
+ return;
+ }
+ try {
+ fsSync.renameSync(filePath, archivePath);
+ } catch {
+ // best-effort cleanup after durable SQLite import.
+ }
}
export async function appendCronRunLog(
@@ -191,18 +397,15 @@ export async function appendCronRunLog(
const next = prev
.catch(() => undefined)
.then(async () => {
- const runDir = path.dirname(resolved);
- await fs.mkdir(runDir, { recursive: true, mode: 0o700 });
- await fs.chmod(runDir, 0o700).catch(() => undefined);
- await appendRegularFile({
- filePath: resolved,
- content: `${JSON.stringify(entry)}\n`,
- rejectSymlinkParents: true,
- });
- await setSecureFileMode(resolved);
- await pruneIfNeeded(resolved, {
- maxBytes: opts?.maxBytes ?? DEFAULT_CRON_RUN_LOG_MAX_BYTES,
- keepLines: opts?.keepLines ?? DEFAULT_CRON_RUN_LOG_KEEP_LINES,
+ const target = inferCronRunLogTarget(resolved, entry.jobId);
+ runOpenClawStateWriteTransaction(({ db }) => {
+ insertCronRunLogEntry(db, cronStoreKey(target.storePath), entry);
+ pruneCronRunLogRows(
+ db,
+ cronStoreKey(target.storePath),
+ entry.jobId,
+ opts?.keepLines ?? DEFAULT_CRON_RUN_LOG_KEEP_LINES,
+ );
});
});
writesByPath.set(resolved, next);
@@ -236,16 +439,18 @@ export function readCronRunLogEntriesSync(
opts?: { limit?: number; jobId?: string },
): CronRunLogEntry[] {
const limit = Math.max(1, Math.min(5000, Math.floor(opts?.limit ?? 200)));
- let raw: string;
- try {
- raw = fsSync.readFileSync(path.resolve(filePath), "utf-8");
- } catch (error) {
- if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
- return [];
- }
- throw error;
- }
- return parseAllRunLogEntries(raw, { jobId: opts?.jobId }).slice(-limit);
+ const resolved = path.resolve(filePath);
+ const target = inferCronRunLogTarget(resolved);
+ const rows = readCronRunLogRows(
+ openOpenClawStateDatabase().db,
+ cronStoreKey(target.storePath),
+ target.strictJobId ? target.jobId : undefined,
+ );
+ return rows
+ .map(parseStoredRunLogEntry)
+ .filter((entry): entry is CronRunLogEntry => entry !== null)
+ .filter((entry) => !opts?.jobId || entry.jobId === opts.jobId)
+ .slice(-limit);
}
function normalizeRunStatusFilter(status?: string): CronRunLogStatusFilter {
@@ -465,12 +670,20 @@ export async function readCronRunLogEntriesPage(
): Promise {
await drainPendingWrite(filePath);
const limit = Math.max(1, Math.min(200, Math.floor(opts?.limit ?? 50)));
- const raw = await fs.readFile(path.resolve(filePath), "utf-8").catch(() => "");
+ const resolved = path.resolve(filePath);
+ const target = inferCronRunLogTarget(resolved);
const statuses = normalizeRunStatuses(opts);
const deliveryStatuses = normalizeDeliveryStatuses(opts);
const query = normalizeLowercaseStringOrEmpty(opts?.query);
const sortDir: CronRunLogSortDir = opts?.sortDir === "asc" ? "asc" : "desc";
- const all = parseAllRunLogEntries(raw, { jobId: opts?.jobId });
+ const all = readCronRunLogRows(
+ openOpenClawStateDatabase().db,
+ cronStoreKey(target.storePath),
+ target.strictJobId ? target.jobId : undefined,
+ )
+ .map(parseStoredRunLogEntry)
+ .filter((entry): entry is CronRunLogEntry => entry !== null)
+ .filter((entry) => !opts?.jobId || entry.jobId === opts.jobId);
const filtered = filterRunLogEntries(all, {
runId: opts?.runId,
statuses,
@@ -515,50 +728,9 @@ export async function readCronRunLogEntriesPageAll(
const deliveryStatuses = normalizeDeliveryStatuses(opts);
const query = normalizeLowercaseStringOrEmpty(opts.query);
const sortDir: CronRunLogSortDir = opts.sortDir === "asc" ? "asc" : "desc";
- const runsDir = path.resolve(path.dirname(path.resolve(opts.storePath)), "runs");
- if (!(await pathExists(runsDir))) {
- return {
- entries: [],
- total: 0,
- offset: 0,
- limit,
- hasMore: false,
- nextOffset: null,
- };
- }
- const runsRoot = await fsRoot(runsDir).catch(() => null);
- if (!runsRoot) {
- return {
- entries: [],
- total: 0,
- offset: 0,
- limit,
- hasMore: false,
- nextOffset: null,
- };
- }
- const files = await runsRoot.list(".", { withFileTypes: true }).catch(() => []);
- const jsonlFiles = files
- .filter((entry) => entry.isFile && entry.name.endsWith(".jsonl"))
- .map((entry) => entry.name);
- if (jsonlFiles.length === 0) {
- return {
- entries: [],
- total: 0,
- offset: 0,
- limit,
- hasMore: false,
- nextOffset: null,
- };
- }
- await Promise.all(jsonlFiles.map((fileName) => drainPendingWrite(path.join(runsDir, fileName))));
- const chunks = await Promise.all(
- jsonlFiles.map(async (fileName) => {
- const raw = await runsRoot.readText(fileName).catch(() => "");
- return parseAllRunLogEntries(raw);
- }),
- );
- const all = chunks.flat();
+ const all = readCronRunLogRows(openOpenClawStateDatabase().db, cronStoreKey(opts.storePath))
+ .map(parseStoredRunLogEntry)
+ .filter((entry): entry is CronRunLogEntry => entry !== null);
const filtered = filterRunLogEntries(all, {
runId: opts.runId,
statuses,
@@ -605,3 +777,32 @@ export async function readCronRunLogEntriesPageAll(
nextOffset: nextOffset < total ? nextOffset : null,
};
}
+
+export async function migrateLegacyCronRunLogsToSqlite(
+ storePath: string,
+): Promise<{ importedFiles: number }> {
+ const resolvedStorePath = path.resolve(storePath);
+ const runsDir = path.resolve(path.dirname(resolvedStorePath), "runs");
+ const files = await fs.readdir(runsDir, { withFileTypes: true }).catch(() => []);
+ const jsonlFiles = files.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"));
+
+ for (const file of jsonlFiles) {
+ const jobId = path.basename(file.name, ".jsonl");
+ const logPath = path.join(runsDir, file.name);
+ await drainPendingWrite(logPath);
+ await importLegacyCronRunLog(logPath, {
+ storePath: resolvedStorePath,
+ jobId,
+ strictJobId: true,
+ });
+ }
+
+ return { importedFiles: jsonlFiles.length };
+}
+
+export async function legacyCronRunLogFilesExist(storePath: string): Promise {
+ const resolvedStorePath = path.resolve(storePath);
+ const runsDir = path.resolve(path.dirname(resolvedStorePath), "runs");
+ const files = await fs.readdir(runsDir, { withFileTypes: true }).catch(() => []);
+ return files.some((entry) => entry.isFile() && entry.name.endsWith(".jsonl"));
+}
diff --git a/src/cron/service.armtimer-tight-loop.test.ts b/src/cron/service.armtimer-tight-loop.test.ts
index afd0a2ac7717..ee83064cf055 100644
--- a/src/cron/service.armtimer-tight-loop.test.ts
+++ b/src/cron/service.armtimer-tight-loop.test.ts
@@ -1,9 +1,8 @@
-import fs from "node:fs/promises";
-import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createNoopLogger, createCronStoreHarness } from "./service.test-harness.js";
import { createCronServiceState } from "./service/state.js";
import { armTimer, onTimer } from "./service/timer.js";
+import { saveCronStore } from "./store.js";
import type { CronJob } from "./types.js";
const noopLogger = createNoopLogger();
@@ -192,19 +191,10 @@ describe("CronService - armTimer tight loop prevention", () => {
const now = Date.parse("2026-02-28T12:32:00.000Z");
const pastDueMs = 17 * 60 * 1000;
- await fs.mkdir(path.dirname(store.storePath), { recursive: true });
- await fs.writeFile(
- store.storePath,
- JSON.stringify(
- {
- version: 1,
- jobs: [createStuckPastDueJob({ id: "monitor", nowMs: now, pastDueMs })],
- },
- null,
- 2,
- ),
- "utf-8",
- );
+ await saveCronStore(store.storePath, {
+ version: 1,
+ jobs: [createStuckPastDueJob({ id: "monitor", nowMs: now, pastDueMs })],
+ });
const state = createTimerState({
storePath: store.storePath,
diff --git a/src/cron/service.every-jobs-fire.test.ts b/src/cron/service.every-jobs-fire.test.ts
index ddff9102b322..c7f3c0d452ce 100644
--- a/src/cron/service.every-jobs-fire.test.ts
+++ b/src/cron/service.every-jobs-fire.test.ts
@@ -29,8 +29,9 @@ describe("CronService interval/cron jobs fire on time", () => {
firstDueAt: number;
}) => {
vi.setSystemTime(new Date(firstDueAt + 5));
+ const finishedRun = finished.waitForOk(jobId);
await vi.runOnlyPendingTimersAsync();
- await finished.waitForOk(jobId);
+ await finishedRun;
const jobs = await cron.list({ includeDisabled: true });
return jobs.find((current) => current.id === jobId);
};
diff --git a/src/cron/service.issue-16156-list-skips-cron.test.ts b/src/cron/service.issue-16156-list-skips-cron.test.ts
index 9b4694a1f3cd..75840c37abf0 100644
--- a/src/cron/service.issue-16156-list-skips-cron.test.ts
+++ b/src/cron/service.issue-16156-list-skips-cron.test.ts
@@ -1,20 +1,19 @@
-import fs from "node:fs/promises";
-import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { CronService } from "./service.js";
import {
createStartedCronServiceWithFinishedBarrier,
setupCronServiceSuite,
} from "./service.test-harness.js";
+import { saveCronStore } from "./store.js";
+import type { CronJob } from "./types.js";
const { logger: noopLogger, makeStorePath } = setupCronServiceSuite({
prefix: "openclaw-cron-16156-",
baseTimeIso: "2025-12-13T00:00:00.000Z",
});
-async function writeJobsStore(storePath: string, jobs: unknown[]) {
- await fs.mkdir(path.dirname(storePath), { recursive: true });
- await fs.writeFile(storePath, JSON.stringify({ version: 1, jobs }, null, 2), "utf-8");
+async function writeJobsStore(storePath: string, jobs: CronJob[]) {
+ await saveCronStore(storePath, { version: 1, jobs });
}
function createCronFromStorePath(storePath: string) {
@@ -75,9 +74,9 @@ describe("#16156: cron.list() must not silently advance past-due recurring jobs"
expect(jobBeforeTimer?.state.nextRunAtMs).toBe(firstDueAt);
// Now let the timer fire. The job should be found as due and execute.
+ const finishedRun = finished.waitForOk(job.id);
await vi.runOnlyPendingTimersAsync();
-
- await finished.waitForOk(job.id);
+ await finishedRun;
const jobs = await cron.list({ includeDisabled: true });
const updated = jobs.find((j) => j.id === job.id);
@@ -120,9 +119,9 @@ describe("#16156: cron.list() must not silently advance past-due recurring jobs"
await cron.status();
// Timer fires.
+ const finishedRun = finished.waitForOk(job.id);
await vi.runOnlyPendingTimersAsync();
-
- await finished.waitForOk(job.id);
+ await finishedRun;
const jobs = await cron.list({ includeDisabled: true });
const updated = jobs.find((j) => j.id === job.id);
diff --git a/src/cron/service.issue-35195-backup-timing.test.ts b/src/cron/service.issue-35195-backup-timing.test.ts
index a91706908a6e..aad434738612 100644
--- a/src/cron/service.issue-35195-backup-timing.test.ts
+++ b/src/cron/service.issue-35195-backup-timing.test.ts
@@ -1,33 +1,46 @@
import fs from "node:fs/promises";
-import path from "node:path";
import { describe, expect, it, vi } from "vitest";
-import { writeCronStoreSnapshot } from "./service.issue-regressions.test-helpers.js";
import { CronService } from "./service.js";
import { createCronStoreHarness, createNoopLogger } from "./service.test-harness.js";
+import { loadCronStore, saveCronStore } from "./store.js";
const noopLogger = createNoopLogger();
const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-issue-35195-" });
+async function pathExists(filePath: string): Promise {
+ try {
+ await fs.stat(filePath);
+ return true;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
+ return false;
+ }
+ throw error;
+ }
+}
+
describe("cron backup timing for edit", () => {
- it("keeps .bak as the pre-edit store even after later normalization persists", async () => {
+ it("updates SQLite cron jobs without creating a legacy migration archive", async () => {
const store = await makeStorePath();
const base = Date.now();
- await fs.mkdir(path.dirname(store.storePath), { recursive: true });
- await writeCronStoreSnapshot(store.storePath, [
- {
- id: "job-35195",
- name: "job-35195",
- enabled: true,
- createdAtMs: base,
- updatedAtMs: base,
- schedule: { kind: "every", everyMs: 60_000, anchorMs: base },
- sessionTarget: "main",
- wakeMode: "next-heartbeat",
- payload: { kind: "systemEvent", text: "hello" },
- state: {},
- },
- ]);
+ await saveCronStore(store.storePath, {
+ version: 1,
+ jobs: [
+ {
+ id: "job-35195",
+ name: "job-35195",
+ enabled: true,
+ createdAtMs: base,
+ updatedAtMs: base,
+ schedule: { kind: "every", everyMs: 60_000, anchorMs: base },
+ sessionTarget: "main",
+ wakeMode: "next-heartbeat",
+ payload: { kind: "systemEvent", text: "hello" },
+ state: {},
+ },
+ ],
+ });
const service = new CronService({
storePath: store.storePath,
@@ -38,44 +51,22 @@ describe("cron backup timing for edit", () => {
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
});
- await service.start();
+ try {
+ await service.start();
- const beforeEditRaw = await fs.readFile(store.storePath, "utf-8");
+ await service.update("job-35195", {
+ payload: { kind: "systemEvent", text: "edited" },
+ });
- await service.update("job-35195", {
- payload: { kind: "systemEvent", text: "edited" },
- });
-
- const backupRaw = await fs.readFile(`${store.storePath}.bak`, "utf-8");
- expect(JSON.parse(backupRaw)).toEqual(JSON.parse(beforeEditRaw));
-
- const diskAfterEdit = JSON.parse(await fs.readFile(store.storePath, "utf-8"));
- const normalizedJob = {
- ...diskAfterEdit.jobs[0],
- payload: {
- ...diskAfterEdit.jobs[0].payload,
- channel: "forum",
- },
- };
-
- await writeCronStoreSnapshot(store.storePath, [normalizedJob]);
-
- service.stop();
- const service2 = new CronService({
- storePath: store.storePath,
- cronEnabled: true,
- log: noopLogger,
- enqueueSystemEvent: vi.fn(),
- requestHeartbeat: vi.fn(),
- runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
- });
-
- await service2.start();
-
- const backupAfterNormalize = await fs.readFile(`${store.storePath}.bak`, "utf-8");
- expect(JSON.parse(backupAfterNormalize)).toEqual(JSON.parse(beforeEditRaw));
-
- service2.stop();
- await store.cleanup();
+ expect(await pathExists(`${store.storePath}.migrated`)).toBe(false);
+ const persistedAfterEdit = await loadCronStore(store.storePath);
+ expect(persistedAfterEdit.jobs[0]?.payload).toEqual({
+ kind: "systemEvent",
+ text: "edited",
+ });
+ } finally {
+ service.stop();
+ await store.cleanup();
+ }
});
});
diff --git a/src/cron/service.issue-66019-unresolved-next-run.test.ts b/src/cron/service.issue-66019-unresolved-next-run.test.ts
index 52d958d66df0..354434c54097 100644
--- a/src/cron/service.issue-66019-unresolved-next-run.test.ts
+++ b/src/cron/service.issue-66019-unresolved-next-run.test.ts
@@ -4,11 +4,11 @@ import {
createIsolatedRegressionJob,
noopLogger,
setupCronRegressionFixtures,
- writeCronJobs,
} from "../../test/helpers/cron/service-regression-fixtures.js";
import * as schedule from "./schedule.js";
import { createCronServiceState } from "./service/state.js";
import { onTimer } from "./service/timer.js";
+import { saveCronStore } from "./store.js";
const issue66019Fixtures = setupCronRegressionFixtures({ prefix: "cron-66019-" });
@@ -72,7 +72,7 @@ describe("#66019 unresolved next-run repro", () => {
id: "cron-66019-minimal-success",
scheduledAt,
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
const runIsolatedAgentJob = createDefaultIsolatedRunner();
const nextRunSpy = vi.spyOn(schedule, "computeNextRunAtMs").mockReturnValue(undefined);
@@ -107,7 +107,7 @@ describe("#66019 unresolved next-run repro", () => {
id: "cron-66019-minimal-error",
scheduledAt,
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
const runIsolatedAgentJob = vi.fn().mockResolvedValue({
status: "error",
@@ -145,7 +145,7 @@ describe("#66019 unresolved next-run repro", () => {
id: "cron-66019-error-backoff-floor",
scheduledAt,
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
const runIsolatedAgentJob = vi.fn().mockResolvedValue({
status: "error",
diff --git a/src/cron/service.issue-regressions.test.ts b/src/cron/service.issue-regressions.test.ts
index 577bef31e535..fcd5f2fc2b04 100644
--- a/src/cron/service.issue-regressions.test.ts
+++ b/src/cron/service.issue-regressions.test.ts
@@ -1,14 +1,11 @@
-import fs from "node:fs/promises";
import { describe, expect, it, vi } from "vitest";
import {
noopLogger,
setupCronIssueRegressionFixtures,
startCronForStore,
topOfHourOffsetMs,
- writeCronStoreSnapshot,
} from "./service.issue-regressions.test-helpers.js";
-import { CronService } from "./service.js";
-import { loadCronStore } from "./store.js";
+import { loadCronStore, saveCronStore } from "./store.js";
import type { CronJob, CronJobState } from "./types.js";
describe("Cron issue regressions", () => {
@@ -60,73 +57,35 @@ describe("Cron issue regressions", () => {
cron.stop();
});
- it("repairs isolated every jobs missing createdAtMs and sets nextWakeAtMs", async () => {
- const store = cronIssueRegressionFixtures.makeStorePath();
- await writeCronStoreSnapshot(store.storePath, [
- {
- id: "legacy-isolated",
- agentId: "feature-dev_planner",
- sessionKey: "agent:main:main",
- name: "legacy isolated",
- enabled: true,
- schedule: { kind: "every", everyMs: 300_000 },
- sessionTarget: "isolated",
- wakeMode: "now",
- payload: { kind: "agentTurn", message: "poll workflow queue" },
- state: {},
- },
- ]);
-
- const cron = new CronService({
- cronEnabled: true,
- storePath: store.storePath,
- log: noopLogger,
- enqueueSystemEvent: vi.fn(),
- requestHeartbeat: vi.fn(),
- runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok", summary: "ok" }),
- });
- await cron.start();
-
- const status = await cron.status();
- const jobs = await cron.list({ includeDisabled: true });
- const isolated = jobs.find((job) => job.id === "legacy-isolated");
- expect(Number.isFinite(isolated?.state.nextRunAtMs)).toBe(true);
- expect(Number.isFinite(status.nextWakeAtMs)).toBe(true);
-
- const persisted = await loadCronStore(store.storePath);
- const persistedIsolated = persisted.jobs.find((job) => job.id === "legacy-isolated");
- expect(typeof persistedIsolated?.state?.nextRunAtMs).toBe("number");
- expect(Number.isFinite(persistedIsolated?.state?.nextRunAtMs)).toBe(true);
-
- cron.stop();
- });
-
it("does not rewrite unchanged stores during startup", async () => {
const store = cronIssueRegressionFixtures.makeStorePath();
const scheduledAt = Date.parse("2026-02-06T11:00:00.000Z");
- await writeCronStoreSnapshot(store.storePath, [
- {
- id: "startup-stable",
- name: "startup stable",
- createdAtMs: scheduledAt - 60_000,
- updatedAtMs: scheduledAt - 60_000,
- enabled: true,
- schedule: { kind: "at", at: new Date(scheduledAt).toISOString() },
- sessionTarget: "main",
- wakeMode: "next-heartbeat",
- payload: { kind: "systemEvent", text: "stable" },
- state: { nextRunAtMs: scheduledAt },
- },
- ]);
- const before = await fs.readFile(store.storePath, "utf8");
+ await saveCronStore(store.storePath, {
+ version: 1,
+ jobs: [
+ {
+ id: "startup-stable",
+ name: "startup stable",
+ createdAtMs: scheduledAt - 60_000,
+ updatedAtMs: scheduledAt - 60_000,
+ enabled: true,
+ schedule: { kind: "at", at: new Date(scheduledAt).toISOString() },
+ sessionTarget: "main",
+ wakeMode: "next-heartbeat",
+ payload: { kind: "systemEvent", text: "stable" },
+ state: { nextRunAtMs: scheduledAt },
+ },
+ ],
+ });
+ const before = await loadCronStore(store.storePath);
const cron = await startCronForStore({
storePath: store.storePath,
cronEnabled: true,
});
- const after = await fs.readFile(store.storePath, "utf8");
+ const after = await loadCronStore(store.storePath);
- expect(after).toBe(before);
+ expect(after).toEqual(before);
cron.stop();
});
@@ -193,76 +152,6 @@ describe("Cron issue regressions", () => {
cron.stop();
});
- it("treats persisted jobs with missing enabled as enabled during update()", async () => {
- const store = cronIssueRegressionFixtures.makeStorePath();
- const now = Date.parse("2026-02-06T10:05:00.000Z");
- await writeCronStoreSnapshot(store.storePath, [
- {
- id: "missing-enabled-update",
- name: "legacy missing enabled",
- createdAtMs: now - 60_000,
- updatedAtMs: now - 60_000,
- schedule: { kind: "cron", expr: "0 */2 * * *", tz: "UTC" },
- sessionTarget: "main",
- wakeMode: "next-heartbeat",
- payload: { kind: "systemEvent", text: "legacy" },
- state: {},
- },
- ]);
-
- const cron = await startCronForStore({ storePath: store.storePath, cronEnabled: false });
-
- const listed = await cron.list();
- const listedJobIds = listed.map((job) => job.id);
- expect(listedJobIds).toContain("missing-enabled-update");
-
- const updated = await cron.update("missing-enabled-update", {
- schedule: { kind: "cron", expr: "0 */3 * * *", tz: "UTC" },
- });
-
- expect(updated.state.nextRunAtMs).toBeTypeOf("number");
- expect(updated.state.nextRunAtMs).toBeGreaterThan(now);
-
- cron.stop();
- });
-
- it("treats persisted due jobs with missing enabled as runnable", async () => {
- const store = cronIssueRegressionFixtures.makeStorePath();
- const now = Date.parse("2026-02-06T10:05:00.000Z");
- const dueAt = now - 30_000;
- await writeCronStoreSnapshot(store.storePath, [
- {
- id: "missing-enabled-due",
- name: "legacy due job",
- createdAtMs: dueAt - 60_000,
- updatedAtMs: dueAt,
- schedule: { kind: "at", at: new Date(dueAt).toISOString() },
- sessionTarget: "main",
- wakeMode: "now",
- payload: { kind: "systemEvent", text: "missing-enabled-due" },
- state: { nextRunAtMs: dueAt },
- },
- ]);
-
- const enqueueSystemEvent = vi.fn();
- const cron = await startCronForStore({
- storePath: store.storePath,
- cronEnabled: false,
- enqueueSystemEvent,
- });
-
- const result = await cron.run("missing-enabled-due", "due");
- expect(result).toEqual({ ok: true, ran: true });
- const enqueueCall = enqueueSystemEvent.mock.calls[0];
- if (!enqueueCall) {
- throw new Error("Expected due cron job to enqueue a system event");
- }
- expect(enqueueCall[0]).toBe("missing-enabled-due");
- expect(enqueueCall[1]?.agentId).toBeUndefined();
-
- cron.stop();
- });
-
it("rejects invalid cron schedule updates without mutating disabled jobs", async () => {
const store = cronIssueRegressionFixtures.makeStorePath();
const cron = await startCronForStore({ storePath: store.storePath, cronEnabled: false });
@@ -292,32 +181,6 @@ describe("Cron issue regressions", () => {
expect(storedJob.schedule.expr).toBe("0 * * * *");
expect(storedJob.schedule.tz).toBe("UTC");
- await writeCronStoreSnapshot(store.storePath, [
- {
- id: "invalid-disabled-job",
- name: "invalid disabled job",
- createdAtMs: Date.parse("2026-02-06T10:00:00.000Z"),
- updatedAtMs: Date.parse("2026-02-06T10:00:00.000Z"),
- enabled: false,
- schedule: { kind: "cron", expr: "* * * 13 *", tz: "UTC" },
- sessionTarget: "main",
- wakeMode: "next-heartbeat",
- payload: { kind: "systemEvent", text: "tick" },
- state: {},
- },
- ]);
-
- const invalidCron = await startCronForStore({ storePath: store.storePath, cronEnabled: false });
- await expect(invalidCron.update("invalid-disabled-job", { enabled: true })).rejects.toThrow(
- "CronPattern",
- );
-
- persisted = await loadCronStore(store.storePath);
- storedJob = persisted.jobs.find((job) => job.id === "invalid-disabled-job");
- expect(storedJob?.enabled).toBe(false);
- expect(storedJob?.state.nextRunAtMs).toBeUndefined();
-
- invalidCron.stop();
cron.stop();
});
@@ -326,13 +189,12 @@ describe("Cron issue regressions", () => {
const originalTarget = "https://t.me/obviyus";
const rewrittenTarget = "-10012345/6789";
const runIsolatedAgentJob = vi.fn(async (params: { job: { id: string } }) => {
- const raw = await fs.readFile(store.storePath, "utf-8");
- const persisted = JSON.parse(raw) as { version: number; jobs: CronJob[] };
+ const persisted = await loadCronStore(store.storePath);
const targetJob = persisted.jobs.find((job) => job.id === params.job.id);
if (targetJob?.delivery?.channel === "telegram") {
targetJob.delivery.to = rewrittenTarget;
}
- await fs.writeFile(store.storePath, JSON.stringify(persisted), "utf-8");
+ await saveCronStore(store.storePath, persisted);
return { status: "ok" as const, summary: "done", delivered: true };
});
@@ -402,7 +264,7 @@ describe("Cron issue regressions", () => {
];
for (const { id, state } of terminalStates) {
const job: CronJob = { id, ...baseJob, state };
- await fs.writeFile(store.storePath, JSON.stringify({ version: 1, jobs: [job] }), "utf-8");
+ await saveCronStore(store.storePath, { version: 1, jobs: [job] });
const enqueueSystemEvent = vi.fn();
const cron = await startCronForStore({
storePath: store.storePath,
diff --git a/src/cron/service.read-ops-nonblocking.test.ts b/src/cron/service.read-ops-nonblocking.test.ts
index 453de0c45eb7..384783f7116d 100644
--- a/src/cron/service.read-ops-nonblocking.test.ts
+++ b/src/cron/service.read-ops-nonblocking.test.ts
@@ -135,7 +135,7 @@ describe("CronService read ops while job is running", () => {
});
vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z"));
- await vi.runOnlyPendingTimersAsync();
+ await vi.advanceTimersByTimeAsync(1_000);
await isolatedRun.runStarted;
expect(isolatedRun.runIsolatedAgentJob).toHaveBeenCalledTimes(1);
diff --git a/src/cron/service.restart-catchup.test.ts b/src/cron/service.restart-catchup.test.ts
index 39058b08da06..bcb12bcabe32 100644
--- a/src/cron/service.restart-catchup.test.ts
+++ b/src/cron/service.restart-catchup.test.ts
@@ -1,11 +1,11 @@
-import fs from "node:fs/promises";
-import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { CronService } from "./service.js";
import { setupCronServiceSuite } from "./service.test-harness.js";
import type { CronEvent } from "./service/state.js";
import { createCronServiceState } from "./service/state.js";
import { runMissedJobs } from "./service/timer.js";
+import { saveCronStore } from "./store.js";
+import type { CronJob } from "./types.js";
const { logger: noopLogger, makeStorePath } = setupCronServiceSuite({
prefix: "openclaw-cron-",
@@ -13,9 +13,8 @@ const { logger: noopLogger, makeStorePath } = setupCronServiceSuite({
});
describe("CronService restart catch-up", () => {
- async function writeStoreJobs(storePath: string, jobs: unknown[]) {
- await fs.mkdir(path.dirname(storePath), { recursive: true });
- await fs.writeFile(storePath, JSON.stringify({ version: 1, jobs }, null, 2), "utf-8");
+ async function writeStoreJobs(storePath: string, jobs: CronJob[]) {
+ await saveCronStore(storePath, { version: 1, jobs });
}
function createRestartCronService(params: {
@@ -44,7 +43,7 @@ describe("CronService restart catch-up", () => {
});
}
- function createOverdueEveryJob(id: string, nextRunAtMs: number) {
+ function createOverdueEveryJob(id: string, nextRunAtMs: number): CronJob {
return {
id,
name: `job-${id}`,
@@ -59,7 +58,7 @@ describe("CronService restart catch-up", () => {
};
}
- function createOverdueCronJob(id: string, nextRunAtMs: number) {
+ function createOverdueCronJob(id: string, nextRunAtMs: number): CronJob {
return {
id,
name: `job-${id}`,
@@ -97,7 +96,7 @@ describe("CronService restart catch-up", () => {
}
async function withRestartedCron(
- jobs: unknown[],
+ jobs: CronJob[],
run: (params: {
cron: CronService;
enqueueSystemEvent: ReturnType;
diff --git a/src/cron/service.session-reaper-in-finally.test.ts b/src/cron/service.session-reaper-in-finally.test.ts
index 0ccdcbfa913e..4eb25378f03e 100644
--- a/src/cron/service.session-reaper-in-finally.test.ts
+++ b/src/cron/service.session-reaper-in-finally.test.ts
@@ -9,6 +9,7 @@ import {
import { createCronServiceState } from "./service/state.js";
import { onTimer } from "./service/timer.js";
import { resetReaperThrottle } from "./session-reaper.js";
+import { saveCronStore } from "./store.js";
import type { CronJob } from "./types.js";
const noopLogger = createNoopLogger();
@@ -50,16 +51,10 @@ describe("CronService - session reaper runs in finally block (#31946)", () => {
const store = await makeStorePath();
const now = Date.parse("2026-02-10T10:00:00.000Z");
- // Write a store with a due job that will trigger execution.
- await fs.mkdir(path.dirname(store.storePath), { recursive: true });
- await fs.writeFile(
- store.storePath,
- JSON.stringify({
- version: 1,
- jobs: [createDueIsolatedJob({ id: "failing-job", nowMs: now })],
- }),
- "utf-8",
- );
+ await saveCronStore(store.storePath, {
+ version: 1,
+ jobs: [createDueIsolatedJob({ id: "failing-job", nowMs: now })],
+ });
// Create a mock sessionStorePath to track if the reaper is called.
const sessionStorePath = path.join(path.dirname(store.storePath), "sessions", "sessions.json");
@@ -94,15 +89,10 @@ describe("CronService - session reaper runs in finally block (#31946)", () => {
const store = await makeStorePath();
const now = Date.parse("2026-02-10T10:00:00.000Z");
- await fs.mkdir(path.dirname(store.storePath), { recursive: true });
- await fs.writeFile(
- store.storePath,
- JSON.stringify({
- version: 1,
- jobs: [createDueIsolatedJob({ id: "ok-job", nowMs: now })],
- }),
- "utf-8",
- );
+ await saveCronStore(store.storePath, {
+ version: 1,
+ jobs: [createDueIsolatedJob({ id: "ok-job", nowMs: now })],
+ });
const resolvedPaths: string[] = [];
const state = createCronServiceState({
@@ -130,12 +120,13 @@ describe("CronService - session reaper runs in finally block (#31946)", () => {
});
});
- it("prunes expired cron-run sessions even when cron store load throws", async () => {
+ it("prunes expired cron-run sessions while ignoring malformed legacy cron files", async () => {
const store = await makeStorePath();
const now = Date.parse("2026-02-10T10:00:00.000Z");
const sessionStorePath = path.join(path.dirname(store.storePath), "sessions", "sessions.json");
- // Force onTimer's try-block to throw before normal execution flow.
+ // Runtime reads SQLite only; malformed legacy JSON is migrated by doctor,
+ // not imported or thrown from the timer path.
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(store.storePath, "{invalid-json", "utf-8");
@@ -164,7 +155,7 @@ describe("CronService - session reaper runs in finally block (#31946)", () => {
});
await withCronServiceStateForTest(state, async () => {
- await expect(onTimer(state)).rejects.toThrow("Failed to parse cron store");
+ await expect(onTimer(state)).resolves.toBeUndefined();
const updatedSessionStore = JSON.parse(
await fs.readFile(sessionStorePath, "utf-8"),
diff --git a/src/cron/service.test-harness.ts b/src/cron/service.test-harness.ts
index 96713646649a..ffcd771b75e5 100644
--- a/src/cron/service.test-harness.ts
+++ b/src/cron/service.test-harness.ts
@@ -6,6 +6,7 @@ import type { MockFn } from "../test-utils/vitest-mock-fn.js";
import type { CronEvent, CronServiceDeps } from "./service.js";
import { CronService } from "./service.js";
import { createCronServiceState, type CronServiceState } from "./service/state.js";
+import { saveCronStore } from "./store.js";
import type { CronJob } from "./types.js";
type NoopLogger = {
@@ -52,19 +53,10 @@ export function createCronStoreHarness(options?: { prefix?: string }) {
}
export async function writeCronStoreSnapshot(params: { storePath: string; jobs: CronJob[] }) {
- await fs.mkdir(path.dirname(params.storePath), { recursive: true });
- await fs.writeFile(
- params.storePath,
- JSON.stringify(
- {
- version: 1,
- jobs: params.jobs,
- },
- null,
- 2,
- ),
- "utf-8",
- );
+ await saveCronStore(params.storePath, {
+ version: 1,
+ jobs: params.jobs,
+ });
}
export function installCronTestHooks(options: {
@@ -242,7 +234,6 @@ export function createMockCronStateForJobs(params: {
running: false,
timer: null,
storeLoadedAtMs: nowMs,
- storeFileMtimeMs: null,
op: Promise.resolve(),
warnedDisabled: false,
warnedMissingSessionTargetJobIds: new Set(),
diff --git a/src/cron/service/ops.regression.test.ts b/src/cron/service/ops.regression.test.ts
index 89f37ab11089..6f021db8f752 100644
--- a/src/cron/service/ops.regression.test.ts
+++ b/src/cron/service/ops.regression.test.ts
@@ -1,14 +1,11 @@
-import fs from "node:fs/promises";
import { describe, expect, it, vi } from "vitest";
import {
createAbortAwareIsolatedRunner,
createDeferred,
createDueIsolatedJob,
createIsolatedRegressionJob,
- createRunningCronServiceState,
noopLogger,
setupCronRegressionFixtures,
- writeCronJobs,
} from "../../../test/helpers/cron/service-regression-fixtures.js";
import {
clearCommandLane,
@@ -16,6 +13,7 @@ import {
waitForActiveTasks,
} from "../../process/command-queue.js";
import { CommandLane } from "../../process/lanes.js";
+import { saveCronStore } from "../store.js";
import { enqueueRun, run, start } from "./ops.js";
import type { CronEvent } from "./state.js";
import { createCronServiceState } from "./state.js";
@@ -86,6 +84,10 @@ describe("cron service ops regressions", () => {
await expect(start(state)).resolves.toBeUndefined();
expect(state.store.jobs[0]?.state.nextRunAtMs).toBe(scheduledAt);
+ if (state.timer) {
+ clearTimeout(state.timer);
+ state.timer = null;
+ }
});
it("skips forced manual runs while a timer-triggered run is in progress", async () => {
@@ -99,7 +101,7 @@ describe("cron service ops regressions", () => {
payload: { kind: "agentTurn", message: "long task" },
state: { nextRunAtMs: dueAt },
});
- await writeCronJobs(store.storePath, [job]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [job] });
let resolveRun:
| ((value: { status: "ok" | "error" | "skipped"; summary?: string; error?: string }) => void)
@@ -158,7 +160,7 @@ describe("cron service ops regressions", () => {
payload: { kind: "agentTurn", message: "overlap" },
state: { nextRunAtMs: now },
});
- await writeCronJobs(store.storePath, [job]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [job] });
const runStarted = createDeferred();
const runFinished = createDeferred();
@@ -209,35 +211,38 @@ describe("cron service ops regressions", () => {
const dueNextRunAtMs = nowMs - 1_000;
const staleExecutedNextRunAtMs = nowMs - 2_000;
- await writeCronJobs(store.storePath, [
- createIsolatedRegressionJob({
- id: "manual-target",
- name: "manual target",
- scheduledAt: nowMs,
- schedule: { kind: "at", at: new Date(nowMs + 3_600_000).toISOString() },
- payload: { kind: "agentTurn", message: "manual target" },
- state: { nextRunAtMs: nowMs + 3_600_000 },
- }),
- createIsolatedRegressionJob({
- id: "unrelated-due",
- name: "unrelated due",
- scheduledAt: nowMs,
- schedule: { kind: "cron", expr: "*/5 * * * *", tz: "UTC" },
- payload: { kind: "agentTurn", message: "unrelated due" },
- state: { nextRunAtMs: dueNextRunAtMs },
- }),
- createIsolatedRegressionJob({
- id: "unrelated-stale-executed",
- name: "unrelated stale executed",
- scheduledAt: nowMs,
- schedule: { kind: "cron", expr: "*/5 * * * *", tz: "UTC" },
- payload: { kind: "agentTurn", message: "unrelated stale executed" },
- state: {
- nextRunAtMs: staleExecutedNextRunAtMs,
- lastRunAtMs: staleExecutedNextRunAtMs + 1,
- },
- }),
- ]);
+ await saveCronStore(store.storePath, {
+ version: 1,
+ jobs: [
+ createIsolatedRegressionJob({
+ id: "manual-target",
+ name: "manual target",
+ scheduledAt: nowMs,
+ schedule: { kind: "at", at: new Date(nowMs + 3_600_000).toISOString() },
+ payload: { kind: "agentTurn", message: "manual target" },
+ state: { nextRunAtMs: nowMs + 3_600_000 },
+ }),
+ createIsolatedRegressionJob({
+ id: "unrelated-due",
+ name: "unrelated due",
+ scheduledAt: nowMs,
+ schedule: { kind: "cron", expr: "*/5 * * * *", tz: "UTC" },
+ payload: { kind: "agentTurn", message: "unrelated due" },
+ state: { nextRunAtMs: dueNextRunAtMs },
+ }),
+ createIsolatedRegressionJob({
+ id: "unrelated-stale-executed",
+ name: "unrelated stale executed",
+ scheduledAt: nowMs,
+ schedule: { kind: "cron", expr: "*/5 * * * *", tz: "UTC" },
+ payload: { kind: "agentTurn", message: "unrelated stale executed" },
+ state: {
+ nextRunAtMs: staleExecutedNextRunAtMs,
+ lastRunAtMs: staleExecutedNextRunAtMs + 1,
+ },
+ }),
+ ],
+ });
const state = createCronServiceState({
cronEnabled: false,
@@ -271,7 +276,7 @@ describe("cron service ops regressions", () => {
payload: { kind: "agentTurn", message: "work", timeoutSeconds: FAST_TIMEOUT_SECONDS },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [job]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [job] });
const abortAwareRunner = createAbortAwareIsolatedRunner();
const state = createCronServiceState({
@@ -304,25 +309,28 @@ describe("cron service ops regressions", () => {
const now = Date.parse("2026-02-06T10:05:00.000Z");
const staleRunningAtMs = now - 2 * 60 * 60 * 1000 - 1;
- await writeCronJobs(store.storePath, [
- {
- id: "stale-running",
- name: "stale-running",
- enabled: true,
- createdAtMs: now - 3_600_000,
- updatedAtMs: now - 3_600_000,
- schedule: { kind: "at", at: new Date(now - 60_000).toISOString() },
- sessionTarget: "main",
- wakeMode: "now",
- payload: { kind: "systemEvent", text: "stale-running" },
- state: {
- runningAtMs: staleRunningAtMs,
- lastRunAtMs: now - 3_600_000,
- lastStatus: "ok",
- nextRunAtMs: now - 60_000,
+ await saveCronStore(store.storePath, {
+ version: 1,
+ jobs: [
+ {
+ id: "stale-running",
+ name: "stale-running",
+ enabled: true,
+ createdAtMs: now - 3_600_000,
+ updatedAtMs: now - 3_600_000,
+ schedule: { kind: "at", at: new Date(now - 60_000).toISOString() },
+ sessionTarget: "main",
+ wakeMode: "now",
+ payload: { kind: "systemEvent", text: "stale-running" },
+ state: {
+ runningAtMs: staleRunningAtMs,
+ lastRunAtMs: now - 3_600_000,
+ lastStatus: "ok",
+ nextRunAtMs: now - 60_000,
+ },
},
- },
- ]);
+ ],
+ });
const enqueueSystemEvent = vi.fn();
const state = createCronServiceState({
@@ -359,11 +367,7 @@ describe("cron service ops regressions", () => {
nowMs: dueAt,
nextRunAtMs: dueAt,
});
- await fs.writeFile(
- store.storePath,
- JSON.stringify({ version: 1, jobs: [first, second] }),
- "utf-8",
- );
+ await saveCronStore(store.storePath, { version: 1, jobs: [first, second] });
let now = dueAt;
let activeRuns = 0;
@@ -431,39 +435,4 @@ describe("cron service ops regressions", () => {
clearCommandLane(CommandLane.Cron);
});
-
- it("logs unexpected queued manual run background failures once", async () => {
- vi.useRealTimers();
- clearCommandLane(CommandLane.Cron);
- setCommandLaneConcurrency(CommandLane.Cron, 1);
-
- const dueAt = Date.parse("2026-02-06T10:05:03.000Z");
- const job = createDueIsolatedJob({ id: "queued-failure", nowMs: dueAt, nextRunAtMs: dueAt });
- const errorLogged = createDeferred();
- const log = {
- ...noopLogger,
- error: vi.fn<(payload: unknown, message?: string) => void>(() => {
- errorLogged.resolve();
- }),
- };
- const badStore = `${opsRegressionFixtures.makeStorePath().storePath}.dir`;
- await fs.mkdir(badStore, { recursive: true });
- const state = createRunningCronServiceState({
- storePath: badStore,
- log,
- nowMs: () => dueAt,
- jobs: [job],
- });
-
- const result = await enqueueRun(state, job.id, "force");
- expectQueuedRunAck(result);
-
- await errorLogged.promise;
- expect(log.error).toHaveBeenCalledTimes(1);
- expect(requireMockCall(log.error, 0, "logger error")[1]).toBe(
- "cron: queued manual run background execution failed",
- );
-
- clearCommandLane(CommandLane.Cron);
- });
});
diff --git a/src/cron/service/ops.test.ts b/src/cron/service/ops.test.ts
index 6c55a9685799..863f2e674ce3 100644
--- a/src/cron/service/ops.test.ts
+++ b/src/cron/service/ops.test.ts
@@ -163,7 +163,7 @@ function createMissedIsolatedJob(now: number): CronJob {
}
describe("cron service ops seam coverage", () => {
- it("preserves legacy top-level array jobs when adding a new job (#60799)", async () => {
+ it("keeps core add paths on SQLite and leaves legacy JSON for doctor migration", async () => {
const { storePath } = await makeStorePath();
const now = Date.parse("2026-05-20T08:00:00.000Z");
const legacyJobs: CronJob[] = [
@@ -208,12 +208,10 @@ describe("cron service ops seam coverage", () => {
}
const loaded = await loadCronStore(storePath);
- const raw = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
- jobs: Array>;
- };
- expect(loaded.jobs.map((job) => job.id)).toEqual(["legacy-alpha", "legacy-beta", newJob.id]);
- expect(raw.jobs.map((job) => job.id)).toEqual(["legacy-alpha", "legacy-beta", newJob.id]);
+ expect(loaded.jobs.map((job) => job.id)).toEqual([newJob.id]);
+ expect(await fs.stat(storePath)).toBeTruthy();
+ await expect(fs.stat(`${storePath}.migrated`)).rejects.toMatchObject({ code: "ENOENT" });
});
it("start marks interrupted running jobs failed, persists, and arms the timer", async () => {
@@ -284,51 +282,23 @@ describe("cron service ops seam coverage", () => {
const createdAtMs = now - 86_400_000;
const nextRunAtMs = Date.parse("2026-04-10T09:00:00.000Z");
const jobId = "future-sidecar-repair";
- const statePath = storePath.replace(/\.json$/, "-state.json");
-
- await fs.mkdir(path.dirname(storePath), { recursive: true });
- await fs.writeFile(
+ await writeCronStoreSnapshot({
storePath,
- JSON.stringify(
+ jobs: [
{
- version: 1,
- jobs: [
- {
- id: jobId,
- name: "future sidecar repair",
- enabled: true,
- createdAtMs,
- schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
- sessionTarget: "main",
- wakeMode: "next-heartbeat",
- payload: { kind: "systemEvent", text: "daily" },
- state: {},
- },
- ],
+ id: jobId,
+ name: "future sidecar repair",
+ enabled: true,
+ createdAtMs,
+ updatedAtMs: createdAtMs,
+ schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
+ sessionTarget: "main",
+ wakeMode: "next-heartbeat",
+ payload: { kind: "systemEvent", text: "daily" },
+ state: { nextRunAtMs },
},
- null,
- 2,
- ),
- "utf-8",
- );
- await fs.writeFile(
- statePath,
- JSON.stringify(
- {
- version: 1,
- jobs: {
- [jobId]: {
- state: { nextRunAtMs },
- },
- },
- },
- null,
- 2,
- ),
- "utf-8",
- );
- const configBefore = await fs.readFile(storePath, "utf-8");
-
+ ],
+ });
const state = createCronServiceState({
storePath,
cronEnabled: true,
@@ -342,14 +312,12 @@ describe("cron service ops seam coverage", () => {
try {
await start(state);
- const configAfter = await fs.readFile(storePath, "utf-8");
- const persistedState = JSON.parse(await fs.readFile(statePath, "utf-8")) as {
- jobs: Record;
- };
+ const persisted = await loadCronStore(storePath);
+ const job = persisted.jobs.find((entry) => entry.id === jobId);
- expect(configAfter).toBe(configBefore);
- expect(persistedState.jobs[jobId]?.updatedAtMs).toBe(createdAtMs);
- expect(persistedState.jobs[jobId]?.state?.nextRunAtMs).toBe(nextRunAtMs);
+ await expect(fs.stat(`${storePath}.migrated`)).rejects.toMatchObject({ code: "ENOENT" });
+ expect(job?.updatedAtMs).toBe(createdAtMs);
+ expect(job?.state?.nextRunAtMs).toBe(nextRunAtMs);
} finally {
stop(state);
}
diff --git a/src/cron/service/state.test.ts b/src/cron/service/state.test.ts
index fdc7c790c08b..00a6e2b25697 100644
--- a/src/cron/service/state.test.ts
+++ b/src/cron/service/state.test.ts
@@ -33,7 +33,6 @@ describe("cron service state seam coverage", () => {
expect(state.running).toBe(false);
expect(state.warnedDisabled).toBe(false);
expect(state.storeLoadedAtMs).toBeNull();
- expect(state.storeFileMtimeMs).toBeNull();
expect(state.deps.storePath).toBe("/tmp/cron/jobs.json");
expect(state.deps.cronEnabled).toBe(true);
diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts
index 7b1c8465a5f0..f3ca94597509 100644
--- a/src/cron/service/state.ts
+++ b/src/cron/service/state.ts
@@ -172,7 +172,6 @@ export type CronServiceState = {
pendingQuarantineConfigJobs: QuarantinedCronConfigJob[];
lastQuarantineFailureWarnKey: string | null;
storeLoadedAtMs: number | null;
- storeFileMtimeMs: number | null;
};
export function createCronServiceState(deps: CronServiceDeps): CronServiceState {
@@ -188,7 +187,6 @@ export function createCronServiceState(deps: CronServiceDeps): CronServiceState
pendingQuarantineConfigJobs: [],
lastQuarantineFailureWarnKey: null,
storeLoadedAtMs: null,
- storeFileMtimeMs: null,
};
}
diff --git a/src/cron/service/store.load-missing-session-target.test.ts b/src/cron/service/store.load-missing-session-target.test.ts
index c62415475f2b..a74a92dc69fa 100644
--- a/src/cron/service/store.load-missing-session-target.test.ts
+++ b/src/cron/service/store.load-missing-session-target.test.ts
@@ -1,270 +1,12 @@
-import fs from "node:fs/promises";
-import path from "node:path";
-import { describe, expect, it, vi } from "vitest";
-import { setupCronServiceSuite } from "../service.test-harness.js";
-import { resolveCronQuarantinePath } from "../store.js";
-import { assertSupportedJobSpec, findJobOrThrow } from "./jobs.js";
-import { createCronServiceState } from "./state.js";
-import { ensureLoaded } from "./store.js";
-
-const { logger, makeStorePath } = setupCronServiceSuite({
- prefix: "cron-service-store-missing-session-target-",
-});
-
-const STORE_TEST_NOW = Date.parse("2026-03-23T12:00:00.000Z");
-
-async function writeSingleJobStore(storePath: string, job: Record) {
- await writeJobStore(storePath, [job]);
-}
-
-async function writeJobStore(storePath: string, jobs: unknown[]) {
- await fs.mkdir(path.dirname(storePath), { recursive: true });
- await fs.writeFile(storePath, JSON.stringify({ version: 1, jobs }, null, 2), "utf8");
-}
-
-function createStoreTestState(storePath: string) {
- return createCronServiceState({
- storePath,
- cronEnabled: true,
- log: logger,
- nowMs: () => STORE_TEST_NOW,
- enqueueSystemEvent: vi.fn(),
- requestHeartbeat: vi.fn(),
- runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
- });
-}
+import { describe, expect, it } from "vitest";
+import { assertSupportedJobSpec } from "./jobs.js";
describe("cron service store load: missing sessionTarget", () => {
- it("hydrates flat legacy cron rows before recomputing next runs", async () => {
- const { storePath } = await makeStorePath();
-
- await writeSingleJobStore(storePath, {
- id: "legacy-flat-cron",
- name: "dbus-watchdog",
- kind: "cron",
- cron: "*/10 * * * *",
- tz: "UTC",
- session: "isolated",
- message: "watch dbus",
- tools: ["exec"],
- enabled: true,
- created_at: "2026-04-17T20:09:00Z",
- });
-
- const state = createStoreTestState(storePath);
- await ensureLoaded(state);
-
- const job = findJobOrThrow(state, "legacy-flat-cron");
- expect(job.schedule).toEqual({
- kind: "cron",
- expr: "*/10 * * * *",
- tz: "UTC",
- });
- expect(job.sessionTarget).toBe("isolated");
- expect(job.payload).toEqual({
- kind: "agentTurn",
- message: "watch dbus",
- toolsAllow: ["exec"],
- });
- expect(job.state.nextRunAtMs).toBeGreaterThan(STORE_TEST_NOW);
- expect(assertSupportedJobSpec(job)).toBeUndefined();
- });
-
- it('defaults missing sessionTarget to "main" for systemEvent payloads', async () => {
- const { storePath } = await makeStorePath();
-
- await writeSingleJobStore(storePath, {
- id: "missing-session-target-system-event",
- name: "missing session target system event",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- updatedAtMs: STORE_TEST_NOW - 60_000,
- schedule: { kind: "every", everyMs: 60_000 },
- wakeMode: "now",
- payload: { kind: "systemEvent", text: "tick" },
- state: {},
- });
-
- const state = createStoreTestState(storePath);
- await ensureLoaded(state);
-
- const job = findJobOrThrow(state, "missing-session-target-system-event");
- expect(job.sessionTarget).toBe("main");
- expect(assertSupportedJobSpec(job)).toBeUndefined();
- });
-
- it('defaults missing sessionTarget to "isolated" for agentTurn payloads', async () => {
- const { storePath } = await makeStorePath();
-
- await writeSingleJobStore(storePath, {
- id: "missing-session-target-agent-turn",
- name: "missing session target agent turn",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- updatedAtMs: STORE_TEST_NOW - 60_000,
- schedule: { kind: "every", everyMs: 60_000 },
- wakeMode: "now",
- payload: { kind: "agentTurn", message: "ping" },
- state: {},
- });
-
- const state = createStoreTestState(storePath);
- await ensureLoaded(state);
-
- const job = findJobOrThrow(state, "missing-session-target-agent-turn");
- expect(job.sessionTarget).toBe("isolated");
- expect(assertSupportedJobSpec(job)).toBeUndefined();
- });
-
it("assertSupportedJobSpec throws a clear error when sessionTarget is missing", () => {
const bogus = {
payload: { kind: "agentTurn" as const, message: "ping" },
} as unknown as Parameters[0];
+
expect(() => assertSupportedJobSpec(bogus)).toThrow(/missing sessionTarget/);
});
-
- it("quarantines malformed persisted schedule and payload shapes while sanitizing the store", async () => {
- const { storePath } = await makeStorePath();
-
- await writeJobStore(storePath, [
- {
- id: "valid-job",
- name: "valid job",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- updatedAtMs: STORE_TEST_NOW - 60_000,
- schedule: { kind: "every", everyMs: 60_000 },
- sessionTarget: "main",
- wakeMode: "now",
- payload: { kind: "systemEvent", text: "tick" },
- state: {},
- },
- {
- id: "bad-schedule",
- name: "bad schedule",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- updatedAtMs: STORE_TEST_NOW - 60_000,
- schedule: ["every", 60_000],
- sessionTarget: "main",
- wakeMode: "now",
- payload: { kind: "systemEvent", text: "tick" },
- state: {},
- },
- {
- id: "bad-payload",
- name: "bad payload",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- updatedAtMs: STORE_TEST_NOW - 60_000,
- schedule: { kind: "every", everyMs: 60_000 },
- sessionTarget: "main",
- wakeMode: "now",
- payload: ["systemEvent", "tick"],
- state: {},
- },
- {
- id: "bad-cron-expr",
- name: "bad cron expr",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- updatedAtMs: STORE_TEST_NOW - 60_000,
- schedule: { kind: "cron", expr: [] },
- sessionTarget: "main",
- wakeMode: "now",
- payload: { kind: "systemEvent", text: "tick" },
- state: {},
- },
- {
- id: "bad-system-event-text",
- name: "bad system event text",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- updatedAtMs: STORE_TEST_NOW - 60_000,
- schedule: { kind: "every", everyMs: 60_000 },
- sessionTarget: "main",
- wakeMode: "now",
- payload: { kind: "systemEvent", text: ["tick"] },
- state: {},
- },
- {
- id: "bad-agent-turn-message",
- name: "bad agent turn message",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- updatedAtMs: STORE_TEST_NOW - 60_000,
- schedule: { kind: "every", everyMs: 60_000 },
- sessionTarget: "isolated",
- wakeMode: "now",
- payload: { kind: "agentTurn", message: { text: "tick" } },
- state: {},
- },
- ]);
- const warnSpy = vi.spyOn(logger, "warn");
-
- const state = createStoreTestState(storePath);
- await ensureLoaded(state);
- await ensureLoaded(state, { forceReload: true });
-
- expect(state.store?.jobs.map((job) => job.id)).toEqual(["valid-job"]);
- expect(findJobOrThrow(state, "valid-job").state.nextRunAtMs).toBe(STORE_TEST_NOW);
- const sanitized = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
- jobs: Array>;
- };
- expect(sanitized.jobs.map((job) => job.id)).toEqual(["valid-job"]);
- const quarantine = JSON.parse(
- await fs.readFile(resolveCronQuarantinePath(storePath), "utf-8"),
- ) as { jobs: Array<{ job?: Record }> };
- expect(quarantine.jobs.map((entry) => entry.job?.id)).toEqual([
- "bad-schedule",
- "bad-payload",
- "bad-cron-expr",
- "bad-system-event-text",
- "bad-agent-turn-message",
- ]);
-
- const invalidShapeWarns = warnSpy.mock.calls.filter((call) => {
- const msg = typeof call[1] === "string" ? call[1] : "";
- return msg.includes("quarantined invalid persisted job");
- });
- expect(invalidShapeWarns).toHaveLength(5);
- expect(invalidShapeWarns.map((call) => (call[0] as { reason?: string }).reason)).toEqual([
- "missing-schedule",
- "missing-payload",
- "invalid-schedule",
- "invalid-payload",
- "invalid-payload",
- ]);
- warnSpy.mockRestore();
- });
-
- it("warns once per jobId across repeated forceReload cycles", async () => {
- const { storePath } = await makeStorePath();
-
- await writeSingleJobStore(storePath, {
- id: "log-dedupe-target",
- name: "log dedupe target",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- updatedAtMs: STORE_TEST_NOW - 60_000,
- schedule: { kind: "every", everyMs: 60_000 },
- wakeMode: "now",
- payload: { kind: "agentTurn", message: "ping" },
- state: {},
- });
-
- const warnSpy = vi.spyOn(logger, "warn");
- const state = createStoreTestState(storePath);
-
- await ensureLoaded(state);
- await ensureLoaded(state, { forceReload: true });
- await ensureLoaded(state, { forceReload: true });
-
- const missingSessionTargetWarns = warnSpy.mock.calls.filter((call) => {
- const msg = typeof call[1] === "string" ? call[1] : "";
- return msg.includes("missing sessionTarget");
- });
- expect(missingSessionTargetWarns).toHaveLength(1);
- warnSpy.mockRestore();
- });
});
diff --git a/src/cron/service/store.test.ts b/src/cron/service/store.test.ts
index e820c75fe7db..b16938f012e4 100644
--- a/src/cron/service/store.test.ts
+++ b/src/cron/service/store.test.ts
@@ -1,8 +1,7 @@
import fs from "node:fs/promises";
-import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { setupCronServiceSuite } from "../service.test-harness.js";
-import { resolveCronQuarantinePath, saveCronStore } from "../store.js";
+import { loadCronStore, saveCronStore } from "../store.js";
import type { CronJob } from "../types.js";
import { findJobOrThrow } from "./jobs.js";
import { createCronServiceState } from "./state.js";
@@ -19,19 +18,14 @@ async function writeSingleJobStore(storePath: string, job: Record {
+ await expect(fs.stat(targetPath)).rejects.toMatchObject({ code: "ENOENT" });
}
function createStoreTestState(storePath: string) {
@@ -110,10 +104,7 @@ describe("cron service store seam coverage", () => {
expect(job.delivery?.to).toBe("123");
expect(job?.state.nextRunAtMs).toBe(STORE_TEST_NOW);
- const persisted = JSON.parse(await fs.readFile(storePath, "utf8")) as {
- jobs: Array>;
- };
- const persistedJob = persisted.jobs[0];
+ const persistedJob = (await loadCronStore(storePath)).jobs[0];
const persistedPayload = persistedJob?.payload as
| { kind?: string; message?: string }
| undefined;
@@ -125,451 +116,12 @@ describe("cron service store seam coverage", () => {
expect(persistedDelivery?.mode).toBe("announce");
expect(persistedDelivery?.channel).toBe("telegram");
expect(persistedDelivery?.to).toBe("123");
-
- const firstMtime = state.storeFileMtimeMs;
- expect(typeof firstMtime).toBe("number");
+ await expectPathMissing(storePath);
await persist(state);
- expect(typeof state.storeFileMtimeMs).toBe("number");
- expect((state.storeFileMtimeMs ?? 0) >= (firstMtime ?? 0)).toBe(true);
});
- it("quarantines unsupported payload-kind rows and sanitizes active jobs.json", async () => {
- const { storePath } = await makeStorePath();
-
- await writeJobStore(storePath, [
- {
- id: "valid-job",
- name: "valid job",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- updatedAtMs: STORE_TEST_NOW - 60_000,
- schedule: { kind: "every", everyMs: 60_000 },
- sessionTarget: "main",
- wakeMode: "now",
- payload: { kind: "systemEvent", text: "tick" },
- state: {},
- },
- {
- id: "legacy-command",
- name: "legacy command",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- updatedAtMs: STORE_TEST_NOW - 60_000,
- schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
- sessionTarget: "main",
- wakeMode: "now",
- payload: { kind: "command", command: "echo daily" },
- state: { lastRunAtMs: STORE_TEST_NOW - 3_600_000 },
- },
- {
- id: "legacy-agentmessage",
- name: "legacy agentmessage",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
- sessionTarget: "isolated",
- wakeMode: "now",
- payload: { kind: "agentmessage", message: "summarize" },
- metadata: { preserve: { nested: true } },
- },
- ]);
-
- const state = createStoreTestState(storePath);
- await ensureLoaded(state, { skipRecompute: true });
-
- expect(state.store?.jobs.map((job) => job.id)).toEqual(["valid-job"]);
- expect(() => findJobOrThrow(state, "legacy-command")).toThrow(/unknown cron job id/);
- expect(() => findJobOrThrow(state, "legacy-agentmessage")).toThrow(/unknown cron job id/);
-
- const valid = findJobOrThrow(state, "valid-job");
- valid.name = "valid job renamed";
- await persist(state);
-
- const config = JSON.parse(await fs.readFile(storePath, "utf8")) as {
- jobs: Array>;
- };
- expect(config.jobs.map((job) => job.id)).toEqual(["valid-job"]);
- expect(config.jobs[0]?.name).toBe("valid job renamed");
-
- const quarantine = JSON.parse(
- await fs.readFile(resolveCronQuarantinePath(storePath), "utf8"),
- ) as { jobs: Array<{ reason?: string; job?: Record }> };
- expect(quarantine.jobs.map((entry) => entry.job?.id)).toEqual([
- "legacy-command",
- "legacy-agentmessage",
- ]);
- expect(quarantine.jobs[0]?.reason).toBe("invalid-payload");
- expect(quarantine.jobs[0]?.job).toMatchObject({
- id: "legacy-command",
- payload: { kind: "command", command: "echo daily" },
- state: { lastRunAtMs: STORE_TEST_NOW - 3_600_000 },
- });
- expect(quarantine.jobs[1]?.job).toMatchObject({
- id: "legacy-agentmessage",
- payload: { kind: "agentmessage", message: "summarize" },
- metadata: { preserve: { nested: true } },
- });
- expect(quarantine.jobs[1]?.job).not.toHaveProperty("state");
- expect(quarantine.jobs[1]?.job).not.toHaveProperty("updatedAtMs");
-
- const stateFile = JSON.parse(
- await fs.readFile(storePath.replace(/\.json$/, "-state.json"), "utf8"),
- ) as { jobs: Record };
- expect(Object.keys(stateFile.jobs)).toEqual(["valid-job"]);
-
- const invalidPayloadWarns = logger.warn.mock.calls.filter((call) => {
- const msg = typeof call[1] === "string" ? call[1] : "";
- return msg.includes("quarantined invalid persisted job");
- });
- expect(invalidPayloadWarns.map((call) => (call[0] as { jobId?: string }).jobId)).toEqual([
- "legacy-command",
- "legacy-agentmessage",
- ]);
- });
-
- it("quarantines malformed persisted rows and sanitizes active jobs.json", async () => {
- const { storePath } = await makeStorePath();
-
- await writeJobStore(storePath, [
- {
- id: "valid-job",
- name: "valid job",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- updatedAtMs: STORE_TEST_NOW - 60_000,
- schedule: { kind: "every", everyMs: 60_000 },
- sessionTarget: "main",
- wakeMode: "now",
- payload: { kind: "systemEvent", text: "tick" },
- state: {},
- },
- {
- id: "missing-schedule-job",
- name: "missing schedule job",
- enabled: true,
- payload: { kind: "systemEvent", text: "tick" },
- state: { lastRunAtMs: STORE_TEST_NOW - 3_600_000 },
- },
- {
- id: "missing-schedule-job",
- name: "missing schedule job",
- enabled: true,
- payload: { kind: "systemEvent", text: "tick" },
- state: { lastRunAtMs: STORE_TEST_NOW - 3_600_000 },
- },
- {
- id: "missing-system-text-job",
- name: "missing system text job",
- enabled: true,
- schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
- payload: { kind: "systemEvent" },
- metadata: { preserve: { nested: true } },
- },
- "bad-scalar-row",
- ]);
- await fs.writeFile(
- storePath.replace(/\.json$/, "-state.json"),
- JSON.stringify(
- {
- version: 1,
- jobs: {
- "missing-system-text-job": {
- updatedAtMs: STORE_TEST_NOW - 30_000,
- state: { lastStatus: "error", lastRunAtMs: STORE_TEST_NOW - 120_000 },
- },
- },
- },
- null,
- 2,
- ),
- "utf8",
- );
-
- const state = createStoreTestState(storePath);
- await ensureLoaded(state, { skipRecompute: true });
-
- expect(state.store?.jobs.map((job) => job.id)).toEqual(["valid-job"]);
- expect(() => findJobOrThrow(state, "missing-schedule-job")).toThrow(/unknown cron job id/);
- expect(() => findJobOrThrow(state, "missing-system-text-job")).toThrow(/unknown cron job id/);
-
- const valid = findJobOrThrow(state, "valid-job");
- valid.name = "valid job renamed";
- await persist(state);
-
- const config = JSON.parse(await fs.readFile(storePath, "utf8")) as {
- jobs: Array>;
- };
- expect(config.jobs.map((job) => job.id)).toEqual(["valid-job"]);
- expect(config.jobs[0]?.name).toBe("valid job renamed");
-
- const quarantine = JSON.parse(
- await fs.readFile(resolveCronQuarantinePath(storePath), "utf8"),
- ) as {
- jobs: Array<{
- reason?: string;
- job?: Record;
- raw?: unknown;
- sourceIndex?: number;
- state?: Record;
- updatedAtMs?: number;
- }>;
- };
- expect(quarantine.jobs.map((entry) => entry.job?.id ?? entry.raw)).toEqual([
- "missing-schedule-job",
- "missing-schedule-job",
- "missing-system-text-job",
- "bad-scalar-row",
- ]);
- expect(quarantine.jobs.map((entry) => entry.reason)).toEqual([
- "missing-schedule",
- "missing-schedule",
- "invalid-payload",
- "non-object-row",
- ]);
- expect(quarantine.jobs.map((entry) => entry.sourceIndex)).toEqual([1, 2, 3, 4]);
- expect(quarantine.jobs[0]?.job).toMatchObject({
- id: "missing-schedule-job",
- state: { lastRunAtMs: STORE_TEST_NOW - 3_600_000 },
- });
- expect(quarantine.jobs[2]?.job).toMatchObject({
- id: "missing-system-text-job",
- metadata: { preserve: { nested: true } },
- });
- expect(quarantine.jobs[2]?.state).toEqual({
- lastStatus: "error",
- lastRunAtMs: STORE_TEST_NOW - 120_000,
- });
- expect(quarantine.jobs[2]?.updatedAtMs).toBe(STORE_TEST_NOW - 30_000);
- expect(quarantine.jobs[2]?.job).not.toHaveProperty("state");
- expect(quarantine.jobs[2]?.job).not.toHaveProperty("updatedAtMs");
-
- const stateFile = JSON.parse(
- await fs.readFile(storePath.replace(/\.json$/, "-state.json"), "utf8"),
- ) as { jobs: Record };
- expect(Object.keys(stateFile.jobs)).toEqual(["valid-job"]);
-
- expect(logger.warn).toHaveBeenCalledWith(
- expect.objectContaining({ storePath, jobId: "missing-schedule-job", jobIndex: 1 }),
- expect.stringContaining("quarantined invalid persisted job"),
- );
- expect(logger.warn).toHaveBeenCalledWith(
- expect.objectContaining({ storePath, jobId: "missing-system-text-job", jobIndex: 3 }),
- expect.stringContaining("quarantined invalid persisted job"),
- );
- });
-
- it("quarantines legacy jobId rows with split runtime state before pruning state file", async () => {
- const { storePath } = await makeStorePath();
-
- await writeJobStore(storePath, [
- {
- id: "valid-job",
- name: "valid job",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- updatedAtMs: STORE_TEST_NOW - 60_000,
- schedule: { kind: "every", everyMs: 60_000 },
- sessionTarget: "main",
- wakeMode: "now",
- payload: { kind: "systemEvent", text: "tick" },
- state: {},
- },
- {
- jobId: "legacy-invalid-job",
- name: "legacy invalid job",
- enabled: true,
- schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
- payload: { kind: "systemEvent" },
- },
- ]);
- await fs.writeFile(
- storePath.replace(/\.json$/, "-state.json"),
- JSON.stringify(
- {
- version: 1,
- jobs: {
- "legacy-invalid-job": {
- updatedAtMs: STORE_TEST_NOW - 45_000,
- scheduleIdentity: "legacy-schedule-identity",
- state: { lastStatus: "error", lastRunAtMs: STORE_TEST_NOW - 90_000 },
- },
- },
- },
- null,
- 2,
- ),
- "utf8",
- );
-
- const state = createStoreTestState(storePath);
- await ensureLoaded(state, { skipRecompute: true });
-
- const quarantine = JSON.parse(
- await fs.readFile(resolveCronQuarantinePath(storePath), "utf8"),
- ) as {
- jobs: Array<{
- job?: Record;
- scheduleIdentity?: string;
- state?: Record;
- updatedAtMs?: number;
- }>;
- };
- expect(quarantine.jobs).toHaveLength(1);
- expect(quarantine.jobs[0]?.job).toMatchObject({ jobId: "legacy-invalid-job" });
- expect(quarantine.jobs[0]?.state).toEqual({
- lastStatus: "error",
- lastRunAtMs: STORE_TEST_NOW - 90_000,
- });
- expect(quarantine.jobs[0]?.updatedAtMs).toBe(STORE_TEST_NOW - 45_000);
- expect(quarantine.jobs[0]?.scheduleIdentity).toBe("legacy-schedule-identity");
-
- const stateFile = JSON.parse(
- await fs.readFile(storePath.replace(/\.json$/, "-state.json"), "utf8"),
- ) as { jobs: Record };
- expect(Object.keys(stateFile.jobs)).toEqual(["valid-job"]);
- });
-
- it("blocks later persists until malformed rows are copied to quarantine", async () => {
- const { storePath } = await makeStorePath();
- const quarantinePath = resolveCronQuarantinePath(storePath);
-
- await writeJobStore(storePath, [
- {
- id: "valid-job",
- name: "valid job",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- updatedAtMs: STORE_TEST_NOW - 60_000,
- schedule: { kind: "every", everyMs: 60_000 },
- sessionTarget: "main",
- wakeMode: "now",
- payload: { kind: "systemEvent", text: "tick" },
- state: {},
- },
- {
- id: "missing-schedule-job",
- name: "missing schedule job",
- enabled: true,
- payload: { kind: "systemEvent", text: "tick" },
- },
- ]);
- await fs.writeFile(quarantinePath, "{ not json", "utf8");
-
- const state = createStoreTestState(storePath);
- await ensureLoaded(state, { skipRecompute: true });
-
- const valid = findJobOrThrow(state, "valid-job");
- valid.name = "valid job renamed";
- await persist(state, { stateOnly: true });
- await ensureLoaded(state, { forceReload: true, skipRecompute: true });
- findJobOrThrow(state, "valid-job").name = "valid job renamed";
- await persist(state);
-
- const quarantineFailureWarns = logger.warn.mock.calls.filter((call) => {
- const msg = typeof call[1] === "string" ? call[1] : "";
- return msg.includes("failed to quarantine malformed persisted jobs");
- });
- expect(quarantineFailureWarns).toHaveLength(1);
-
- let config = JSON.parse(await fs.readFile(storePath, "utf8")) as {
- jobs: Array>;
- };
- expect(config.jobs.map((job) => job.id)).toEqual(["valid-job", "missing-schedule-job"]);
- expect(config.jobs[0]?.name).toBe("valid job");
-
- await fs.writeFile(quarantinePath, JSON.stringify({ version: 1, jobs: [] }), "utf8");
- await persist(state);
-
- config = JSON.parse(await fs.readFile(storePath, "utf8")) as {
- jobs: Array>;
- };
- expect(config.jobs.map((job) => job.id)).toEqual(["valid-job"]);
- expect(config.jobs[0]?.name).toBe("valid job renamed");
-
- const quarantine = JSON.parse(await fs.readFile(quarantinePath, "utf8")) as {
- jobs: Array<{ job?: Record }>;
- };
- expect(quarantine.jobs.map((entry) => entry.job?.id)).toEqual(["missing-schedule-job"]);
- });
-
- it("keeps canonical jobs when quarantined unsupported rows collide by id", async () => {
- const { storePath } = await makeStorePath();
-
- await writeJobStore(storePath, [
- {
- id: "trimmed-collision",
- name: "supported trimmed collision",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- updatedAtMs: STORE_TEST_NOW - 60_000,
- schedule: { kind: "every", everyMs: 60_000 },
- sessionTarget: "main",
- wakeMode: "now",
- payload: { kind: "systemEvent", text: "tick" },
- state: {},
- },
- {
- id: " trimmed-collision ",
- name: "stale unsupported padded id",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
- sessionTarget: "main",
- wakeMode: "now",
- payload: { kind: "command", command: "echo stale" },
- },
- {
- id: "legacy-jobid-collision",
- name: "supported legacy jobId collision",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- updatedAtMs: STORE_TEST_NOW - 60_000,
- schedule: { kind: "every", everyMs: 120_000 },
- sessionTarget: "main",
- wakeMode: "now",
- payload: { kind: "systemEvent", text: "tick legacy" },
- state: {},
- },
- {
- jobId: " legacy-jobid-collision ",
- name: "stale unsupported legacy jobId",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
- sessionTarget: "main",
- wakeMode: "now",
- payload: { kind: "agentmessage", message: "summarize stale" },
- },
- ]);
-
- const state = createStoreTestState(storePath);
- await ensureLoaded(state, { skipRecompute: true });
-
- expect(state.store?.jobs.map((job) => job.id)).toEqual([
- "trimmed-collision",
- "legacy-jobid-collision",
- ]);
-
- await persist(state);
-
- const config = JSON.parse(await fs.readFile(storePath, "utf8")) as {
- jobs: Array>;
- };
- expect(config.jobs.map((job) => job.id)).toEqual([
- "trimmed-collision",
- "legacy-jobid-collision",
- ]);
- expect(config.jobs.map((job) => job.name)).toEqual([
- "supported trimmed collision",
- "supported legacy jobId collision",
- ]);
- expect(config.jobs.some((job) => job.jobId === " legacy-jobid-collision ")).toBe(false);
- expect(config.jobs.some((job) => job.name === "stale unsupported padded id")).toBe(false);
- expect(config.jobs.some((job) => job.name === "stale unsupported legacy jobId")).toBe(false);
- });
-
- it("normalizes jobId-only jobs in memory so scheduler lookups resolve by stable id", async () => {
+ it("loads normalized jobId-only jobs from SQLite so scheduler lookups resolve by stable id", async () => {
const { storePath } = await makeStorePath();
await writeSingleJobStore(storePath, {
@@ -589,17 +141,10 @@ describe("cron service store seam coverage", () => {
await ensureLoaded(state);
- expectWarnedJob({ storePath, jobId: "repro-stable-id", message: "legacy jobId" });
-
const job = findJobOrThrow(state, "repro-stable-id");
expect(job.id).toBe("repro-stable-id");
expect((job as { jobId?: unknown }).jobId).toBeUndefined();
-
- const raw = JSON.parse(await fs.readFile(storePath, "utf8")) as {
- jobs: Array>;
- };
- expect(raw.jobs[0]?.jobId).toBe("repro-stable-id");
- expect(raw.jobs[0]?.id).toBeUndefined();
+ await expectPathMissing(`${storePath}.migrated`);
});
it("preserves disabled jobs when persisted booleans roundtrip through string values", async () => {
@@ -618,16 +163,13 @@ describe("cron service store seam coverage", () => {
state: {},
});
- const before = await fs.readFile(storePath, "utf8");
const state = createStoreTestState(storePath);
await ensureLoaded(state);
const job = findJobOrThrow(state, "disabled-string-job");
expect(job.enabled).toBe(false);
-
- const after = await fs.readFile(storePath, "utf8");
- expect(after).toBe(before);
+ await expectPathMissing(`${storePath}.migrated`);
});
it("loads persisted jobs with opaque custom session ids containing separators", async () => {
@@ -682,17 +224,15 @@ describe("cron service store seam coverage", () => {
await ensureLoaded(state, { skipRecompute: true });
expect(findJobOrThrow(state, "reload-cron-expr-job").state.nextRunAtMs).toBe(staleNextRunAtMs);
- await writeSingleJobStore(storePath, {
- id: "reload-cron-expr-job",
- name: "reload cron expr job",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- updatedAtMs: STORE_TEST_NOW - 30_000,
- schedule: { kind: "cron", expr: "30 6 * * 0,6", tz: "UTC" },
- sessionTarget: "main",
- wakeMode: "now",
- payload: { kind: "systemEvent", text: "tick" },
- state: {},
+ await saveCronStore(storePath, {
+ version: 1,
+ jobs: [
+ createReloadCronJob({
+ updatedAtMs: STORE_TEST_NOW - 30_000,
+ schedule: { kind: "cron", expr: "30 6 * * 0,6", tz: "UTC" },
+ state: {},
+ }),
+ ],
});
await ensureLoaded(state, { forceReload: true, skipRecompute: true });
@@ -718,17 +258,15 @@ describe("cron service store seam coverage", () => {
const state = createStoreTestState(storePath);
await ensureLoaded(state, { skipRecompute: true });
- await writeSingleJobStore(storePath, {
- id: "reload-cron-expr-job",
- name: "reload cron expr job",
- enabled: true,
- createdAtMs: STORE_TEST_NOW - 60_000,
- updatedAtMs: STORE_TEST_NOW - 30_000,
- schedule: { expr: "0 6 * * *", kind: "cron", tz: "UTC" },
- sessionTarget: "main",
- wakeMode: "now",
- payload: { kind: "systemEvent", text: "tick" },
- state: {},
+ await saveCronStore(storePath, {
+ version: 1,
+ jobs: [
+ createReloadCronJob({
+ updatedAtMs: STORE_TEST_NOW - 30_000,
+ schedule: { expr: "0 6 * * *", kind: "cron", tz: "UTC" },
+ state: { nextRunAtMs: dueNextRunAtMs },
+ }),
+ ],
});
await ensureLoaded(state, { forceReload: true, skipRecompute: true });
@@ -740,15 +278,6 @@ describe("cron service store seam coverage", () => {
const { storePath } = await makeStorePath();
const staleNextRunAtMs = STORE_TEST_NOW + 3_600_000;
- await writeSingleJobStore(storePath, {
- ...createReloadCronJob({
- state: { nextRunAtMs: staleNextRunAtMs },
- }),
- });
-
- const state = createStoreTestState(storePath);
- await ensureLoaded(state, { skipRecompute: true });
-
await writeSingleJobStore(storePath, {
...createReloadCronJob({
updatedAtMs: STORE_TEST_NOW,
@@ -757,40 +286,14 @@ describe("cron service store seam coverage", () => {
schedule: "0 17 * * *",
});
+ const state = createStoreTestState(storePath);
await expect(ensureLoaded(state, { forceReload: true, skipRecompute: true })).resolves.toBe(
undefined,
);
const job = findJobOrThrow(state, "reload-cron-expr-job");
expect(job.schedule).toBe("0 17 * * *");
- expect(job.state.nextRunAtMs).toBeUndefined();
- });
-
- it("warns once per malformed persisted row across repeated forceReload cycles", async () => {
- const { storePath } = await makeStorePath();
-
- await writeSingleJobStore(storePath, {
- id: "missing-cron-expr-job",
- name: "missing cron expr job",
- enabled: true,
- schedule: { kind: "cron" },
- payload: { kind: "systemEvent", text: "tick" },
- state: {},
- });
-
- const warnSpy = vi.spyOn(logger, "warn");
- const state = createStoreTestState(storePath);
-
- await ensureLoaded(state, { skipRecompute: true });
- await ensureLoaded(state, { forceReload: true, skipRecompute: true });
- await ensureLoaded(state, { forceReload: true, skipRecompute: true });
-
- const malformedWarns = warnSpy.mock.calls.filter((call) => {
- const msg = typeof call[1] === "string" ? call[1] : "";
- return msg.includes("quarantined invalid persisted job");
- });
- expect(malformedWarns).toHaveLength(1);
- warnSpy.mockRestore();
+ expect(job.state.nextRunAtMs).toBe(staleNextRunAtMs);
});
it("preserves nextRunAtMs after force reload when scheduling inputs are unchanged", async () => {
@@ -803,11 +306,14 @@ describe("cron service store seam coverage", () => {
const state = createStoreTestState(storePath);
await ensureLoaded(state, { skipRecompute: true });
- await writeSingleJobStore(storePath, {
- ...createReloadCronJob({
- updatedAtMs: STORE_TEST_NOW,
- state: { nextRunAtMs: originalNextRunAtMs + 60_000 },
- }),
+ await saveCronStore(storePath, {
+ version: 1,
+ jobs: [
+ createReloadCronJob({
+ updatedAtMs: STORE_TEST_NOW,
+ state: { nextRunAtMs: originalNextRunAtMs + 60_000 },
+ }),
+ ],
});
await ensureLoaded(state, { forceReload: true, skipRecompute: true });
@@ -830,12 +336,15 @@ describe("cron service store seam coverage", () => {
const state = createStoreTestState(storePath);
await ensureLoaded(state, { skipRecompute: true });
- await writeSingleJobStore(storePath, {
- ...createReloadCronJob({
- enabled: false,
- updatedAtMs: STORE_TEST_NOW,
- state: { nextRunAtMs: staleNextRunAtMs },
- }),
+ await saveCronStore(storePath, {
+ version: 1,
+ jobs: [
+ createReloadCronJob({
+ enabled: false,
+ updatedAtMs: STORE_TEST_NOW,
+ state: { nextRunAtMs: staleNextRunAtMs },
+ }),
+ ],
});
await ensureLoaded(state, { forceReload: true, skipRecompute: true });
@@ -858,13 +367,16 @@ describe("cron service store seam coverage", () => {
const state = createStoreTestState(storePath);
await ensureLoaded(state, { skipRecompute: true });
- await writeSingleJobStore(storePath, {
- ...createReloadCronJob({
- id: jobId,
- updatedAtMs: STORE_TEST_NOW,
- schedule: { kind: "every", everyMs: 60_000, anchorMs: STORE_TEST_NOW },
- state: { nextRunAtMs: staleNextRunAtMs },
- }),
+ await saveCronStore(storePath, {
+ version: 1,
+ jobs: [
+ createReloadCronJob({
+ id: jobId,
+ updatedAtMs: STORE_TEST_NOW,
+ schedule: { kind: "every", everyMs: 60_000, anchorMs: STORE_TEST_NOW },
+ state: { nextRunAtMs: staleNextRunAtMs },
+ }),
+ ],
});
await ensureLoaded(state, { forceReload: true, skipRecompute: true });
@@ -887,13 +399,16 @@ describe("cron service store seam coverage", () => {
const state = createStoreTestState(storePath);
await ensureLoaded(state, { skipRecompute: true });
- await writeSingleJobStore(storePath, {
- ...createReloadCronJob({
- id: jobId,
- updatedAtMs: STORE_TEST_NOW,
- schedule: { kind: "at", at: "2026-03-23T14:00:00.000Z" },
- state: { nextRunAtMs: staleNextRunAtMs },
- }),
+ await saveCronStore(storePath, {
+ version: 1,
+ jobs: [
+ createReloadCronJob({
+ id: jobId,
+ updatedAtMs: STORE_TEST_NOW,
+ schedule: { kind: "at", at: "2026-03-23T14:00:00.000Z" },
+ state: { nextRunAtMs: staleNextRunAtMs },
+ }),
+ ],
});
await ensureLoaded(state, { forceReload: true, skipRecompute: true });
diff --git a/src/cron/service/store.ts b/src/cron/service/store.ts
index a27ab454c99d..e13afc48c911 100644
--- a/src/cron/service/store.ts
+++ b/src/cron/service/store.ts
@@ -1,4 +1,3 @@
-import fs from "node:fs";
import { normalizeCronJobIdentityFields } from "../normalize-job-identity.js";
import { normalizeCronJobInput } from "../normalize.js";
import { getInvalidPersistedCronJobReason } from "../persisted-shape.js";
@@ -49,15 +48,6 @@ function warnInvalidPersistedCronJob(params: {
);
}
-async function getFileMtimeMs(path: string): Promise {
- try {
- const stats = await fs.promises.stat(path);
- return stats.mtimeMs;
- } catch {
- return null;
- }
-}
-
async function flushPendingQuarantine(
state: CronServiceState,
nowMs: number,
@@ -109,10 +99,6 @@ export async function ensureLoaded(
for (const job of state.store?.jobs ?? []) {
previousJobsById.set(job.id, job);
}
- // 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 loadCronStoreWithConfigJobs(state.deps.storePath);
const loadedJobs = (loaded.store.jobs ?? []) as unknown as CronJob[];
const jobs: CronJob[] = [];
@@ -208,7 +194,7 @@ export async function ensureLoaded(
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)",
+ "cron: job missing sessionTarget; defaulted in memory (run openclaw doctor --fix to persist canonical shape)",
);
}
}
@@ -219,7 +205,6 @@ export async function ensureLoaded(
jobs,
};
state.storeLoadedAtMs = state.deps.nowMs();
- state.storeFileMtimeMs = fileMtimeMs;
if (quarantinedConfigJobs.length > 0) {
state.pendingQuarantineConfigJobs = quarantinedConfigJobs;
@@ -227,14 +212,13 @@ export async function ensureLoaded(
if (quarantinePath) {
try {
await saveCronStore(state.deps.storePath, state.store);
- state.storeFileMtimeMs = await getFileMtimeMs(state.deps.storePath);
state.deps.log.warn(
{
storePath: state.deps.storePath,
quarantinePath,
quarantinedJobs: quarantinedConfigJobs.length,
},
- "cron: sanitized active jobs.json after quarantining malformed persisted jobs",
+ "cron: sanitized active cron store after quarantining malformed persisted jobs",
);
} catch (error) {
state.deps.log.warn(
@@ -284,6 +268,4 @@ export async function persist(
}
const saveOpts = flushedPendingQuarantine ? { skipBackup: opts?.skipBackup } : opts;
await saveCronStore(state.deps.storePath, state.store, saveOpts);
- // Update file mtime after save to prevent immediate reload
- state.storeFileMtimeMs = await getFileMtimeMs(state.deps.storePath);
}
diff --git a/src/cron/service/timer.regression.test.ts b/src/cron/service/timer.regression.test.ts
index 31bcca8f58f6..a74d4b671fd5 100644
--- a/src/cron/service/timer.regression.test.ts
+++ b/src/cron/service/timer.regression.test.ts
@@ -1,4 +1,3 @@
-import fs from "node:fs/promises";
import { describe, expect, it, vi } from "vitest";
import {
createAbortAwareIsolatedRunner,
@@ -9,10 +8,10 @@ import {
createRunningCronServiceState,
noopLogger,
setupCronRegressionFixtures,
- writeCronJobs,
} from "../../../test/helpers/cron/service-regression-fixtures.js";
import { HEARTBEAT_SKIP_LANES_BUSY, type HeartbeatRunResult } from "../../infra/heartbeat-wake.js";
import * as schedule from "../schedule.js";
+import { loadCronStore, saveCronStore } from "../store.js";
import type {
CronAgentExecutionPhase,
CronAgentExecutionPhaseUpdate,
@@ -80,7 +79,7 @@ describe("cron service timer regressions", () => {
});
state.store = { version: 1, jobs: [] };
- await fs.writeFile(store.storePath, JSON.stringify(state.store), "utf8");
+ await saveCronStore(store.storePath, state.store);
state.store.jobs.push({
id: "far-future",
@@ -146,7 +145,7 @@ describe("cron service timer regressions", () => {
state: { nextRunAtMs: scheduledAt },
});
cronJob.deleteAfterRun = params.deleteAfterRun;
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
let now = scheduledAt;
const runIsolatedAgentJob = vi
@@ -220,7 +219,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "remind me" },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
let now = scheduledAt;
const runIsolatedAgentJob = vi.fn().mockResolvedValue({
@@ -262,7 +261,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "remind me" },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
let now = scheduledAt;
const runIsolatedAgentJob = vi.fn().mockResolvedValue({
@@ -307,7 +306,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "remind me" },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
let now = scheduledAt;
const runIsolatedAgentJob = vi
@@ -355,7 +354,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "remind me" },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
let now = scheduledAt;
const runIsolatedAgentJob = vi
@@ -402,7 +401,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "remind me" },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
let now = scheduledAt;
const runIsolatedAgentJob = vi
@@ -451,7 +450,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "remind me" },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
let now = scheduledAt;
const state = createCronServiceState({
@@ -488,7 +487,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "closure report" },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
let now = scheduledAt;
const runIsolatedAgentJob = vi
@@ -543,7 +542,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "closure report" },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
let now = scheduledAt;
const runIsolatedAgentJob = vi.fn().mockResolvedValue({
@@ -623,7 +622,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "briefing" },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
let now = scheduledAt;
let fireCount = 0;
@@ -663,7 +662,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "pulse" },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
let now = scheduledAt;
const state = createCronServiceState({
@@ -698,7 +697,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "work", timeoutSeconds: 0 },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
let now = scheduledAt;
const deferredRun = createDeferred<{ status: "ok"; summary: string }>();
@@ -745,18 +744,33 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "work" },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
let now = scheduledAt;
const deferredRun = createDeferred<{ status: "ok"; summary: string }>();
- const runIsolatedAgentJob = vi.fn(async ({ abortSignal }: { abortSignal?: AbortSignal }) => {
- const result = await deferredRun.promise;
- if (abortSignal?.aborted) {
- return { status: "error" as const, error: String(abortSignal.reason) };
- }
- now += 5;
- return result;
- });
+ const runIsolatedAgentJob = vi.fn(
+ async ({
+ abortSignal,
+ onExecutionStarted,
+ onExecutionPhase,
+ }: {
+ abortSignal?: AbortSignal;
+ onExecutionStarted?: () => void;
+ onExecutionPhase?: (info: CronAgentExecutionPhaseUpdate) => void;
+ }) => {
+ onExecutionStarted?.();
+ onExecutionPhase?.({
+ jobId: "agentturn-default-safety-window",
+ phase: "attempt_dispatch",
+ });
+ const result = await deferredRun.promise;
+ if (abortSignal?.aborted) {
+ return { status: "error" as const, error: String(abortSignal.reason) };
+ }
+ now += 5;
+ return result;
+ },
+ );
const state = createCronServiceState({
cronEnabled: true,
storePath: store.storePath,
@@ -798,7 +812,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "work", timeoutSeconds: FAST_TIMEOUT_SECONDS },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
let now = scheduledAt;
const abortAwareRunner = createAbortAwareIsolatedRunner();
@@ -843,7 +857,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "work", timeoutSeconds: FAST_TIMEOUT_SECONDS },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
let now = scheduledAt;
const runnerEntered = createDeferred();
@@ -913,7 +927,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "work", timeoutSeconds: FAST_TIMEOUT_SECONDS },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
let now = scheduledAt;
const abortAwareRunner = createAbortAwareIsolatedRunner("late-summary");
@@ -960,7 +974,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "work", timeoutSeconds: FAST_TIMEOUT_SECONDS },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
let now = scheduledAt;
const abortAwareRunner = createAbortAwareIsolatedRunner();
@@ -1126,11 +1140,7 @@ describe("cron service timer regressions", () => {
const dueAt = Date.parse("2026-02-06T10:05:01.000Z");
const first = createDueIsolatedJob({ id: "batch-first", nowMs: dueAt, nextRunAtMs: dueAt });
const second = createDueIsolatedJob({ id: "batch-second", nowMs: dueAt, nextRunAtMs: dueAt });
- await fs.writeFile(
- store.storePath,
- JSON.stringify({ version: 1, jobs: [first, second] }),
- "utf-8",
- );
+ await saveCronStore(store.storePath, { version: 1, jobs: [first, second] });
let now = dueAt;
const events: CronEvent[] = [];
@@ -1177,11 +1187,7 @@ describe("cron service timer regressions", () => {
nowMs: dueAt,
nextRunAtMs: dueAt,
});
- await fs.writeFile(
- store.storePath,
- JSON.stringify({ version: 1, jobs: [first, second] }),
- "utf-8",
- );
+ await saveCronStore(store.storePath, { version: 1, jobs: [first, second] });
let now = dueAt;
let activeRuns = 0;
@@ -1248,7 +1254,7 @@ describe("cron service timer regressions", () => {
channel: "telegram",
to: "chat-123",
};
- await writeCronJobs(store.storePath, [selfRemovingJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [selfRemovingJob] });
const events: CronEvent[] = [];
const log = {
@@ -1267,7 +1273,11 @@ describe("cron service timer regressions", () => {
events.push(evt);
},
runIsolatedAgentJob: vi.fn(async (params: { job: { id: string } }) => {
- await fs.writeFile(store.storePath, JSON.stringify({ version: 1, jobs: [] }), "utf-8");
+ const persisted = await loadCronStore(store.storePath);
+ await saveCronStore(store.storePath, {
+ ...persisted,
+ jobs: persisted.jobs.filter((job) => job.id !== params.job.id),
+ });
return {
status: "ok" as const,
summary: `finished ${params.job.id}`,
@@ -1311,7 +1321,7 @@ describe("cron service timer regressions", () => {
nowMs: dueAt,
nextRunAtMs: dueAt,
});
- await writeCronJobs(store.storePath, [failedJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [failedJob] });
const events: CronEvent[] = [];
const log = {
@@ -1329,7 +1339,11 @@ describe("cron service timer regressions", () => {
events.push(evt);
},
runIsolatedAgentJob: vi.fn(async () => {
- await fs.writeFile(store.storePath, JSON.stringify({ version: 1, jobs: [] }), "utf-8");
+ const persisted = await loadCronStore(store.storePath);
+ await saveCronStore(store.storePath, {
+ ...persisted,
+ jobs: persisted.jobs.filter((job) => job.id !== failedJob.id),
+ });
return { status: "error" as const, error: "agent failed after removal" };
}),
});
@@ -1362,7 +1376,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "work", timeoutSeconds },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
vi.setSystemTime(scheduledAt);
let now = scheduledAt;
@@ -1445,7 +1459,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "work", timeoutSeconds: 1 },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
vi.setSystemTime(scheduledAt);
let now = scheduledAt;
@@ -1525,7 +1539,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "work", timeoutSeconds: 120 },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
vi.setSystemTime(scheduledAt);
let now = scheduledAt;
@@ -1586,7 +1600,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "work", timeoutSeconds: 1_200 },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
vi.setSystemTime(scheduledAt);
let now = scheduledAt;
@@ -1674,7 +1688,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "work", timeoutSeconds: 1_200 },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
vi.setSystemTime(scheduledAt);
let now = scheduledAt;
@@ -1782,7 +1796,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "work", timeoutSeconds: 1_200 },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
vi.setSystemTime(scheduledAt);
let now = scheduledAt;
@@ -1865,7 +1879,7 @@ describe("cron service timer regressions", () => {
payload: { kind: "agentTurn", message: "work", timeoutSeconds: 1_200 },
state: { nextRunAtMs: scheduledAt },
});
- await writeCronJobs(store.storePath, [cronJob]);
+ await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
vi.setSystemTime(scheduledAt);
let now = scheduledAt;
diff --git a/src/cron/service/timer.test.ts b/src/cron/service/timer.test.ts
index 266c35fb8200..147e8510c983 100644
--- a/src/cron/service/timer.test.ts
+++ b/src/cron/service/timer.test.ts
@@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { setupCronServiceSuite, writeCronStoreSnapshot } from "../../cron/service.test-harness.js";
import { createCronServiceState } from "../../cron/service/state.js";
import { executeJobCore, onTimer } from "../../cron/service/timer.js";
-import { loadCronStore, saveCronStore } from "../../cron/store.js";
+import { loadCronStore } from "../../cron/store.js";
import type { CronJob } from "../../cron/types.js";
import * as detachedTaskRuntime from "../../tasks/detached-task-runtime.js";
import { findTaskByRunId, resetTaskRegistryForTests } from "../../tasks/task-registry.js";
@@ -314,57 +314,4 @@ describe("cron service timer seam coverage", () => {
createTaskRecordSpy.mockRestore();
});
-
- it("reloads externally edited split-store schedules without firing stale slots", async () => {
- const { storePath } = await makeStorePath();
- const now = Date.parse("2026-03-23T06:00:00.000Z");
- const staleNextRunAtMs = now;
- const enqueueSystemEvent = vi.fn();
- const requestHeartbeat = vi.fn();
-
- await saveCronStore(storePath, {
- version: 1,
- jobs: [
- {
- id: "externally-edited-cron",
- name: "externally edited cron",
- enabled: true,
- createdAtMs: now - 60_000,
- updatedAtMs: now - 60_000,
- schedule: { kind: "cron", expr: "0 6 * * *", tz: "UTC" },
- sessionTarget: "main",
- wakeMode: "now",
- payload: { kind: "systemEvent", text: "stale schedule should not run" },
- state: { nextRunAtMs: staleNextRunAtMs },
- },
- ],
- });
-
- const config = JSON.parse(await fs.readFile(storePath, "utf8")) as {
- jobs: Array>;
- };
- config.jobs[0].schedule = { kind: "cron", expr: "0 7 * * *", tz: "UTC" };
- await fs.writeFile(storePath, JSON.stringify(config, null, 2), "utf8");
-
- const state = createCronServiceState({
- storePath,
- cronEnabled: true,
- log: logger,
- nowMs: () => now,
- enqueueSystemEvent,
- requestHeartbeat,
- runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
- });
-
- await onTimer(state);
-
- expect(enqueueSystemEvent).not.toHaveBeenCalled();
- expect(requestHeartbeat).not.toHaveBeenCalled();
-
- const persisted = await loadCronStore(storePath);
- const job = persisted.jobs[0];
- expect(job?.schedule).toEqual({ kind: "cron", expr: "0 7 * * *", tz: "UTC" });
- expect(job?.state.lastStatus).toBeUndefined();
- expect(job?.state.nextRunAtMs).toBe(Date.parse("2026-03-23T07:00:00.000Z"));
- });
});
diff --git a/src/cron/store.test.ts b/src/cron/store.test.ts
index afcbbc0fd8f8..e9d8a30edd89 100644
--- a/src/cron/store.test.ts
+++ b/src/cron/store.test.ts
@@ -1,12 +1,15 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
-import { setTimeout as scheduleNativeTimeout } from "node:timers";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js";
import {
+ archiveLegacyCronStoreForMigration,
+ loadLegacyCronStoreForMigration,
loadCronQuarantineFile,
loadCronStore,
loadCronStoreSync,
+ loadCronStoreWithConfigJobs,
resolveCronQuarantinePath,
resolveCronStorePath,
saveCronQuarantineFile,
@@ -56,23 +59,6 @@ function makeStore(jobId: string, enabled: boolean): CronStoreFile {
};
}
-async function captureRenameDestinations(action: () => Promise): Promise {
- const renamedDestinations: string[] = [];
- const origRename = fs.rename.bind(fs);
- const spy = vi.spyOn(fs, "rename").mockImplementation(async (src, dest) => {
- renamedDestinations.push(String(dest));
- return origRename(src, dest);
- });
-
- try {
- await action();
- } finally {
- spy.mockRestore();
- }
-
- return renamedDestinations;
-}
-
async function expectPathMissing(targetPath: string): Promise {
try {
await fs.stat(targetPath);
@@ -104,14 +90,16 @@ describe("cron store", () => {
expect(loaded).toEqual({ version: 1, jobs: [] });
});
- it("throws when store contains invalid JSON", async () => {
+ it("throws when doctor migration reads invalid legacy JSON", async () => {
const store = await makeStorePath();
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(store.storePath, "{ not json", "utf-8");
- await expect(loadCronStore(store.storePath)).rejects.toThrow(/Failed to parse cron store/i);
+ await expect(loadLegacyCronStoreForMigration(store.storePath)).rejects.toThrow(
+ /Failed to parse cron store/i,
+ );
});
- it("accepts JSON5 syntax when loading an existing cron store", async () => {
+ it("accepts JSON5 syntax when loading a legacy cron store for doctor migration", async () => {
const store = await makeStorePath();
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(
@@ -137,14 +125,14 @@ describe("cron store", () => {
"utf-8",
);
- const loaded = await loadCronStore(store.storePath);
+ const loaded = (await loadLegacyCronStoreForMigration(store.storePath)).store;
expect(loaded.version).toBe(1);
expect(loaded.jobs).toHaveLength(1);
expect(loaded.jobs[0]?.id).toBe("job-1");
expect(loaded.jobs[0]?.enabled).toBe(true);
});
- it("loads legacy top-level array stores instead of treating them as empty", async () => {
+ it("loads legacy top-level array stores for doctor migration", async () => {
const store = await makeStorePath();
const first = makeStore("legacy-array-1", true).jobs[0];
const second = makeStore("legacy-array-2", false).jobs[0];
@@ -155,7 +143,7 @@ describe("cron store", () => {
"utf-8",
);
- const loaded = await loadCronStore(store.storePath);
+ const loaded = (await loadLegacyCronStoreForMigration(store.storePath)).store;
expect(loaded.version).toBe(1);
expect(loaded.jobs.map((job) => job.id)).toEqual(["legacy-array-1", "legacy-array-2"]);
@@ -163,7 +151,7 @@ describe("cron store", () => {
expect(loaded.jobs[1]?.enabled).toBe(false);
});
- it("loads legacy top-level array stores synchronously", async () => {
+ it("does not load legacy top-level array stores synchronously from core", async () => {
const store = await makeStorePath();
const job = makeStore("legacy-array-sync", true).jobs[0];
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
@@ -171,12 +159,10 @@ describe("cron store", () => {
const loaded = loadCronStoreSync(store.storePath);
- expect(loaded.jobs).toHaveLength(1);
- expect(loaded.jobs[0]?.id).toBe("legacy-array-sync");
- expect(loaded.jobs[0]?.state).toStrictEqual(job.state);
+ expect(loaded.jobs).toHaveLength(0);
});
- it("preserves legacy top-level array jobs through a load-add-save round trip", async () => {
+ it("lets doctor import legacy top-level array jobs into SQLite and archive the source", async () => {
const store = await makeStorePath();
const legacy = makeStore("legacy-array-preserved", true).jobs[0];
legacy.state = { nextRunAtMs: legacy.createdAtMs + 60_000 };
@@ -184,27 +170,19 @@ describe("cron store", () => {
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(store.storePath, JSON.stringify([legacy], null, 2), "utf-8");
- const loaded = await loadCronStore(store.storePath);
+ const loaded = (await loadLegacyCronStoreForMigration(store.storePath)).store;
loaded.jobs.push(added);
await saveCronStore(store.storePath, loaded);
+ await archiveLegacyCronStoreForMigration(store.storePath);
- const config = JSON.parse(await fs.readFile(store.storePath, "utf-8")) as {
- version?: unknown;
- jobs?: Array>;
- };
- const stateFile = JSON.parse(
- await fs.readFile(store.storePath.replace(/\.json$/, "-state.json"), "utf-8"),
- ) as { jobs: Record }> };
-
- expect(config.version).toBe(1);
- expect(config.jobs?.map((job) => job.id)).toEqual(["legacy-array-preserved", "new-job"]);
- expect(config.jobs?.[0]?.state).toStrictEqual({});
- expect(stateFile.jobs["legacy-array-preserved"]?.state?.nextRunAtMs).toBe(
- legacy.createdAtMs + 60_000,
- );
+ const roundTrip = await loadCronStore(store.storePath);
+ expect(roundTrip.jobs.map((job) => job.id)).toEqual(["legacy-array-preserved", "new-job"]);
+ expect(roundTrip.jobs[0]?.state.nextRunAtMs).toBe(legacy.createdAtMs + 60_000);
+ await expectPathMissing(store.storePath);
+ expect(await fs.stat(`${store.storePath}.migrated`)).toBeTruthy();
});
- it("skips non-object persisted jobs instead of hydrating scalar rows", async () => {
+ it("skips non-object legacy persisted jobs during doctor migration", async () => {
const store = await makeStorePath();
const valid = makeStore("job-valid", true).jobs[0];
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
@@ -221,13 +199,71 @@ describe("cron store", () => {
"utf-8",
);
- const loaded = await loadCronStore(store.storePath);
+ const loaded = (await loadLegacyCronStoreForMigration(store.storePath)).store;
expect(loaded.jobs).toHaveLength(1);
expect(loaded.jobs[0]?.id).toBe("job-valid");
expect(loaded.jobs[0]?.state).toStrictEqual({});
});
+ it("loads malformed legacy stores for doctor without archiving first", async () => {
+ const store = await makeStorePath();
+ const valid = makeStore("job-valid-unarchived", true).jobs[0];
+ await fs.mkdir(path.dirname(store.storePath), { recursive: true });
+ await fs.writeFile(
+ store.storePath,
+ JSON.stringify(
+ {
+ version: 1,
+ jobs: [
+ valid,
+ {
+ id: "bad-schedule-unarchived",
+ name: "bad schedule",
+ enabled: true,
+ createdAtMs: valid.createdAtMs,
+ updatedAtMs: valid.updatedAtMs,
+ schedule: ["every", 60_000],
+ sessionTarget: "main",
+ wakeMode: "now",
+ payload: { kind: "systemEvent", text: "tick" },
+ state: {},
+ },
+ ],
+ },
+ null,
+ 2,
+ ),
+ "utf-8",
+ );
+
+ const loaded = await loadLegacyCronStoreForMigration(store.storePath);
+
+ expect(loaded.store.jobs.map((job) => job.id)).toEqual([
+ "job-valid-unarchived",
+ "bad-schedule-unarchived",
+ ]);
+ expect(await fs.stat(store.storePath)).toBeTruthy();
+ await expectPathMissing(`${store.storePath}.migrated`);
+ });
+
+ it("does not synchronously import legacy files from core reads", async () => {
+ const store = await makeStorePath();
+ const valid = makeStore("job-valid-sync-unarchived", true).jobs[0];
+ await fs.mkdir(path.dirname(store.storePath), { recursive: true });
+ await fs.writeFile(
+ store.storePath,
+ JSON.stringify({ version: 1, jobs: ["bad-row", valid] }, null, 2),
+ "utf-8",
+ );
+
+ const loaded = loadCronStoreSync(store.storePath);
+
+ expect(loaded.jobs.map((job) => job.id)).toEqual([]);
+ expect(await fs.stat(store.storePath)).toBeTruthy();
+ await expectPathMissing(`${store.storePath}.migrated`);
+ });
+
it("fails closed instead of overwriting unrecognized quarantine files", async () => {
const { storePath } = await makeStorePath();
const quarantinePath = resolveCronQuarantinePath(storePath);
@@ -279,7 +315,7 @@ describe("cron store", () => {
expect(loaded.jobs[0]?.updatedAtMs).toBeTypeOf("number");
});
- it("loads split cron state synchronously for legacy jobId rows", async () => {
+ it("loads split cron state for legacy jobId rows during doctor migration", async () => {
const { storePath } = await makeStorePath();
const statePath = storePath.replace(/\.json$/, "-state.json");
await fs.mkdir(path.dirname(storePath), { recursive: true });
@@ -321,13 +357,13 @@ describe("cron store", () => {
"utf-8",
);
- const loaded = loadCronStoreSync(storePath);
+ const loaded = (await loadLegacyCronStoreForMigration(storePath)).store;
expect(loaded.jobs[0]?.state).toEqual({ runningAtMs: 456 });
expect(loaded.jobs[0]?.updatedAtMs).toBe(123);
});
- it("compares split state identity for flat legacy cron rows", async () => {
+ it("compares split state identity for flat legacy cron rows during doctor migration", async () => {
const { storePath } = await makeStorePath();
const statePath = storePath.replace(/\.json$/, "-state.json");
await fs.mkdir(path.dirname(storePath), { recursive: true });
@@ -375,7 +411,7 @@ describe("cron store", () => {
"utf-8",
);
- const loaded = await loadCronStore(storePath);
+ const loaded = (await loadLegacyCronStoreForMigration(storePath)).store;
expect(loaded.jobs[0]?.state.nextRunAtMs).toBeUndefined();
});
@@ -390,7 +426,7 @@ describe("cron store", () => {
await expectPathMissing(`${store.storePath}.bak`);
});
- it("backs up previous content before replacing the store", async () => {
+ it("replaces cron jobs in SQLite without rewriting legacy files", async () => {
const store = await makeStorePath();
const first = makeStore("job-1", true);
const second = makeStore("job-2", false);
@@ -398,18 +434,13 @@ describe("cron store", () => {
await saveCronStore(store.storePath, first);
await saveCronStore(store.storePath, second);
- const currentRaw = await fs.readFile(store.storePath, "utf-8");
- const backupRaw = await fs.readFile(`${store.storePath}.bak`, "utf-8");
- const current = JSON.parse(currentRaw);
- const backup = JSON.parse(backupRaw);
- // jobs.json now contains config-only (state stripped to {}).
- expect(current.jobs[0].id).toBe("job-2");
- expect(current.jobs[0].state).toStrictEqual({});
- expect(backup.jobs[0].id).toBe("job-1");
- expect(backup.jobs[0].state).toStrictEqual({});
+ const loaded = await loadCronStore(store.storePath);
+ expect(loaded.jobs.map((job) => job.id)).toEqual(["job-2"]);
+ await expectPathMissing(store.storePath);
+ await expectPathMissing(`${store.storePath}.bak`);
});
- it("skips backup files for runtime-only state churn", async () => {
+ it("persists runtime-only state churn in SQLite", async () => {
const store = await makeStorePath();
const first = makeStore("job-1", true);
const second: CronStoreFile = {
@@ -428,70 +459,166 @@ describe("cron store", () => {
await saveCronStore(store.storePath, first);
await saveCronStore(store.storePath, second);
- // jobs.json should NOT be rewritten (only runtime changed).
- const configRaw = await fs.readFile(store.storePath, "utf-8");
- const config = JSON.parse(configRaw);
- expect(config.jobs[0].state).toStrictEqual({});
- expect(config.jobs[0]).not.toHaveProperty("updatedAtMs");
-
- // State file should contain runtime fields.
- const statePath = store.storePath.replace(/\.json$/, "-state.json");
- const stateRaw = await fs.readFile(statePath, "utf-8");
- const stateFile = JSON.parse(stateRaw);
- expect(stateFile.jobs[first.jobs[0].id].state.nextRunAtMs).toBe(
- first.jobs[0].createdAtMs + 60_000,
- );
- expect(typeof stateFile.jobs[first.jobs[0].id].scheduleIdentity).toBe("string");
-
+ const loaded = await loadCronStore(store.storePath);
+ expect(loaded.jobs[0]?.state.nextRunAtMs).toBe(first.jobs[0].createdAtMs + 60_000);
+ expect(loaded.jobs[0]?.state.lastRunAtMs).toBe(first.jobs[0].createdAtMs + 30_000);
+ await expectPathMissing(store.storePath);
+ await expectPathMissing(store.storePath.replace(/\.json$/, "-state.json"));
await expectPathMissing(`${store.storePath}.bak`);
});
- it("drops stale split runtime nextRunAtMs when schedule identity changes across restart", async () => {
+ it("updates runtime state without replacing concurrent cron config", async () => {
+ const store = await makeStorePath();
+ const stale = makeStore("job-state-only", true);
+ const current: CronStoreFile = {
+ version: 1,
+ jobs: [
+ {
+ ...stale.jobs[0],
+ name: "Job current",
+ updatedAtMs: stale.jobs[0].updatedAtMs + 1,
+ },
+ makeStore("job-added-concurrently", true).jobs[0],
+ ],
+ };
+ stale.jobs[0].state = { nextRunAtMs: stale.jobs[0].createdAtMs + 60_000 };
+ stale.jobs[0].updatedAtMs += 2;
+
+ await saveCronStore(store.storePath, makeStore("job-state-only", true));
+ await saveCronStore(store.storePath, current);
+ await saveCronStore(store.storePath, stale, { stateOnly: true });
+
+ const loaded = await loadCronStore(store.storePath);
+ expect(loaded.jobs.map((job) => job.id)).toEqual(["job-state-only", "job-added-concurrently"]);
+ expect(loaded.jobs[0]?.name).toBe("Job current");
+ expect(loaded.jobs[0]?.state.nextRunAtMs).toBe(stale.jobs[0].createdAtMs + 60_000);
+ });
+
+ it("round-trips agent-turn external content provenance through SQLite", async () => {
+ const store = await makeStorePath();
+ const payload = makeStore("hook-job", true);
+ payload.jobs[0].sessionTarget = "isolated";
+ payload.jobs[0].payload = {
+ kind: "agentTurn",
+ message: "Summarize hook payload",
+ externalContentSource: "webhook",
+ };
+
+ await saveCronStore(store.storePath, payload);
+
+ expect((await loadCronStore(store.storePath)).jobs[0]?.payload).toMatchObject({
+ kind: "agentTurn",
+ message: "Summarize hook payload",
+ externalContentSource: "webhook",
+ });
+ });
+
+ it("falls back to job_json payloads for early SQLite cron rows", async () => {
+ const { storePath } = await makeStorePath();
+ const storeKey = path.resolve(storePath);
+ const job = makeStore("early-sqlite-job", true).jobs[0];
+ job.sessionTarget = "isolated";
+ job.payload = {
+ kind: "agentTurn",
+ message: "Keep this prompt",
+ externalContentSource: "gmail",
+ };
+
+ runOpenClawStateWriteTransaction(({ db }) => {
+ db.prepare(
+ `INSERT INTO cron_jobs (
+ store_key, job_id, name, enabled, created_at_ms, schedule_kind,
+ session_target, wake_mode, payload_kind, payload_message, job_json, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ ).run(
+ storeKey,
+ job.id,
+ job.name,
+ 1,
+ job.createdAtMs,
+ "every",
+ "isolated",
+ "next-heartbeat",
+ "agentTurn",
+ null,
+ JSON.stringify(job),
+ job.updatedAtMs,
+ );
+ });
+
+ expect((await loadCronStore(storePath)).jobs[0]?.payload).toMatchObject({
+ kind: "agentTurn",
+ message: "Keep this prompt",
+ externalContentSource: "gmail",
+ });
+ });
+
+ it("drops stale split runtime nextRunAtMs when doctor imports edited legacy config", async () => {
const { storePath } = await makeStorePath();
const payload = makeStore("job-restart-drift", true);
const staleNextRunAtMs = payload.jobs[0].createdAtMs + 3_600_000;
- payload.jobs[0].schedule = { kind: "cron", expr: "0 6 * * *", tz: "UTC" };
- payload.jobs[0].state = { nextRunAtMs: staleNextRunAtMs };
+ payload.jobs[0].schedule = { kind: "cron", expr: "30 6 * * 0,6", tz: "UTC" };
+ await fs.mkdir(path.dirname(storePath), { recursive: true });
+ await fs.writeFile(storePath, JSON.stringify(payload, null, 2), "utf-8");
+ await fs.writeFile(
+ storePath.replace(/\.json$/, "-state.json"),
+ JSON.stringify({
+ version: 1,
+ jobs: {
+ [payload.jobs[0].id]: {
+ updatedAtMs: payload.jobs[0].updatedAtMs,
+ scheduleIdentity: JSON.stringify({
+ version: 1,
+ enabled: true,
+ schedule: { kind: "cron", expr: "0 6 * * *", tz: "UTC" },
+ }),
+ state: { nextRunAtMs: staleNextRunAtMs },
+ },
+ },
+ }),
+ "utf-8",
+ );
- await saveCronStore(storePath, payload);
-
- const config = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
- jobs: Array>;
- };
- config.jobs[0].schedule = { kind: "cron", expr: "30 6 * * 0,6", tz: "UTC" };
- await fs.writeFile(storePath, JSON.stringify(config, null, 2), "utf-8");
-
- const loaded = await loadCronStore(storePath);
+ const loaded = (await loadLegacyCronStoreForMigration(storePath)).store;
expect(loaded.jobs[0]?.schedule).toEqual({ kind: "cron", expr: "30 6 * * 0,6", tz: "UTC" });
expect(loaded.jobs[0]?.state.nextRunAtMs).toBeUndefined();
});
- it("drops stale split runtime nextRunAtMs in sync loads when schedule identity changes", async () => {
+ it("does not synchronously import stale split runtime nextRunAtMs from legacy files", async () => {
const { storePath } = await makeStorePath();
const payload = makeStore("job-sync-restart-drift", true);
const staleNextRunAtMs = payload.jobs[0].createdAtMs + 3_600_000;
- payload.jobs[0].schedule = { kind: "every", everyMs: 60_000, anchorMs: 1 };
- payload.jobs[0].state = { nextRunAtMs: staleNextRunAtMs };
-
- await saveCronStore(storePath, payload);
-
- const config = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
- jobs: Array>;
- };
- config.jobs[0].schedule = { kind: "every", everyMs: 60_000, anchorMs: 2 };
- await fs.writeFile(storePath, JSON.stringify(config, null, 2), "utf-8");
+ payload.jobs[0].schedule = { kind: "every", everyMs: 60_000, anchorMs: 2 };
+ await fs.mkdir(path.dirname(storePath), { recursive: true });
+ await fs.writeFile(storePath, JSON.stringify(payload, null, 2), "utf-8");
+ await fs.writeFile(
+ storePath.replace(/\.json$/, "-state.json"),
+ JSON.stringify({
+ version: 1,
+ jobs: {
+ [payload.jobs[0].id]: {
+ updatedAtMs: payload.jobs[0].updatedAtMs,
+ scheduleIdentity: JSON.stringify({
+ version: 1,
+ enabled: true,
+ schedule: { kind: "every", everyMs: 60_000, anchorMs: 1 },
+ }),
+ state: { nextRunAtMs: staleNextRunAtMs },
+ },
+ },
+ }),
+ "utf-8",
+ );
const loaded = loadCronStoreSync(storePath);
- expect(loaded.jobs[0]?.schedule).toEqual({ kind: "every", everyMs: 60_000, anchorMs: 2 });
- expect(loaded.jobs[0]?.state.nextRunAtMs).toBeUndefined();
+ expect(loaded.jobs).toEqual([]);
});
- it("keeps state separate for custom store paths without a json suffix", async () => {
+ it("keeps custom store paths separated by SQLite store key", async () => {
const store = await makeStorePath();
const storePath = store.storePath.replace(/\.json$/, "");
- const statePath = `${storePath}-state.json`;
const first = makeStore("job-1", true);
const second: CronStoreFile = {
...first,
@@ -508,70 +635,30 @@ describe("cron store", () => {
await saveCronStore(storePath, first);
await saveCronStore(storePath, second);
- const config = JSON.parse(await fs.readFile(storePath, "utf-8"));
- expect(Array.isArray(config.jobs)).toBe(true);
- expect(config.jobs[0].id).toBe("job-1");
- expect(config.jobs[0].state).toStrictEqual({});
-
- const stateFile = JSON.parse(await fs.readFile(statePath, "utf-8"));
- expect(stateFile.jobs["job-1"].state.nextRunAtMs).toBe(first.jobs[0].createdAtMs + 60_000);
-
const loaded = await loadCronStore(storePath);
expect(loaded.jobs[0]?.state.nextRunAtMs).toBe(first.jobs[0].createdAtMs + 60_000);
+ await expectPathMissing(storePath);
+ await expectPathMissing(`${storePath}-state.json`);
});
- it("recreates a missing state sidecar without rewriting unchanged config", async () => {
+ it("leaves legacy sidecars absent after idempotent saves", async () => {
const store = await makeStorePath();
- const statePath = store.storePath.replace(/\.json$/, "-state.json");
const payload = makeStore("job-1", true);
payload.jobs[0].state = { nextRunAtMs: payload.jobs[0].createdAtMs + 60_000 };
await saveCronStore(store.storePath, payload);
await loadCronStore(store.storePath);
- const configRawBefore = await fs.readFile(store.storePath, "utf-8");
- await fs.rm(statePath);
-
- const renamedDestinations = await captureRenameDestinations(() =>
- saveCronStore(store.storePath, payload),
- );
-
- const configRawAfter = await fs.readFile(store.storePath, "utf-8");
- const stateFile = JSON.parse(await fs.readFile(statePath, "utf-8"));
-
- expect(configRawAfter).toBe(configRawBefore);
- expect(renamedDestinations).toContain(statePath);
- expect(renamedDestinations).not.toContain(store.storePath);
- expect(stateFile.jobs["job-1"].state.nextRunAtMs).toBe(payload.jobs[0].createdAtMs + 60_000);
- });
-
- it("recreates a missing config file without rewriting unchanged state", async () => {
- const store = await makeStorePath();
- const statePath = store.storePath.replace(/\.json$/, "-state.json");
- const payload = makeStore("job-1", true);
- payload.jobs[0].state = { nextRunAtMs: payload.jobs[0].createdAtMs + 60_000 };
-
await saveCronStore(store.storePath, payload);
- await loadCronStore(store.storePath);
- const stateRawBefore = await fs.readFile(statePath, "utf-8");
- await fs.rm(store.storePath);
- const renamedDestinations = await captureRenameDestinations(() =>
- saveCronStore(store.storePath, payload),
+ await expectPathMissing(store.storePath);
+ await expectPathMissing(store.storePath.replace(/\.json$/, "-state.json"));
+ expect((await loadCronStore(store.storePath)).jobs[0]?.state.nextRunAtMs).toBe(
+ payload.jobs[0].createdAtMs + 60_000,
);
-
- const config = JSON.parse(await fs.readFile(store.storePath, "utf-8"));
- const stateRawAfter = await fs.readFile(statePath, "utf-8");
-
- expect(config.jobs[0].id).toBe("job-1");
- expect(config.jobs[0].state).toStrictEqual({});
- expect(stateRawAfter).toBe(stateRawBefore);
- expect(renamedDestinations).toContain(store.storePath);
- expect(renamedDestinations).not.toContain(statePath);
});
- it("migrates legacy inline state into the state sidecar", async () => {
+ it("lets doctor migrate legacy inline state into SQLite", async () => {
const store = await makeStorePath();
- const statePath = store.storePath.replace(/\.json$/, "-state.json");
const legacy = makeStore("job-1", true);
legacy.jobs[0].state = {
lastRunAtMs: legacy.jobs[0].createdAtMs + 30_000,
@@ -581,19 +668,18 @@ describe("cron store", () => {
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(store.storePath, JSON.stringify(legacy, null, 2), "utf-8");
- const loaded = await loadCronStore(store.storePath);
+ const loaded = (await loadLegacyCronStoreForMigration(store.storePath)).store;
await saveCronStore(store.storePath, loaded);
+ await archiveLegacyCronStoreForMigration(store.storePath);
- const config = JSON.parse(await fs.readFile(store.storePath, "utf-8"));
- const stateFile = JSON.parse(await fs.readFile(statePath, "utf-8"));
-
- expect(config.jobs[0]).not.toHaveProperty("updatedAtMs");
- expect(config.jobs[0].state).toStrictEqual({});
- expect(stateFile.jobs["job-1"].updatedAtMs).toBe(legacy.jobs[0].updatedAtMs);
- expect(stateFile.jobs["job-1"].state.nextRunAtMs).toBe(legacy.jobs[0].createdAtMs + 60_000);
+ const roundTrip = await loadCronStore(store.storePath);
+ expect(roundTrip.jobs[0]?.updatedAtMs).toBe(legacy.jobs[0].updatedAtMs);
+ expect(roundTrip.jobs[0]?.state.nextRunAtMs).toBe(legacy.jobs[0].createdAtMs + 60_000);
+ await expectPathMissing(store.storePath);
+ expect(await fs.stat(`${store.storePath}.migrated`)).toBeTruthy();
});
- it("ignores array-shaped state sidecars when migrating legacy inline state", async () => {
+ it("ignores array-shaped state sidecars when doctor migrates legacy inline state", async () => {
const store = await makeStorePath();
const statePath = store.storePath.replace(/\.json$/, "-state.json");
// Numeric-looking IDs catch accidental array indexing in invalid sidecars.
@@ -619,39 +705,51 @@ describe("cron store", () => {
await fs.writeFile(store.storePath, JSON.stringify(legacy, null, 2), "utf-8");
await fs.writeFile(statePath, JSON.stringify(staleSidecar, null, 2), "utf-8");
- const loaded = await loadCronStore(store.storePath);
+ const loaded = (await loadLegacyCronStoreForMigration(store.storePath)).store;
await saveCronStore(store.storePath, loaded);
-
- const stateFile = JSON.parse(await fs.readFile(statePath, "utf-8"));
+ await archiveLegacyCronStoreForMigration(store.storePath);
expect(loaded.jobs[0]?.updatedAtMs).toBe(legacy.jobs[0].updatedAtMs);
expect(loaded.jobs[0]?.state.nextRunAtMs).toBe(legacy.jobs[0].createdAtMs + 60_000);
- expect(Array.isArray(stateFile.jobs)).toBe(false);
- expect(stateFile.jobs["0"].updatedAtMs).toBe(legacy.jobs[0].updatedAtMs);
- expect(stateFile.jobs["0"].state.nextRunAtMs).toBe(legacy.jobs[0].createdAtMs + 60_000);
+ await expectPathMissing(statePath);
+ expect(await fs.stat(`${statePath}.migrated`)).toBeTruthy();
});
- it("treats a corrupt state sidecar as absent", async () => {
+ it("treats a corrupt state sidecar as absent during doctor migration", async () => {
const store = await makeStorePath();
const payload = makeStore("job-1", true);
payload.jobs[0].state = { nextRunAtMs: payload.jobs[0].createdAtMs + 60_000 };
const statePath = store.storePath.replace(/\.json$/, "-state.json");
- await saveCronStore(store.storePath, payload);
+ await fs.mkdir(path.dirname(store.storePath), { recursive: true });
+ await fs.writeFile(
+ store.storePath,
+ JSON.stringify(
+ {
+ version: 1,
+ jobs: payload.jobs.map((job) => ({ ...job, state: {}, updatedAtMs: undefined })),
+ },
+ null,
+ 2,
+ ),
+ "utf-8",
+ );
await fs.writeFile(statePath, "{ not json", "utf-8");
- const loaded = await loadCronStore(store.storePath);
+ const loaded = (await loadLegacyCronStoreForMigration(store.storePath)).store;
expect(loaded.jobs[0]?.updatedAtMs).toBe(payload.jobs[0].createdAtMs);
expect(loaded.jobs[0]?.state).toStrictEqual({});
});
- it("propagates unreadable state sidecar errors", async () => {
+ it("propagates unreadable state sidecar errors during doctor migration", async () => {
const store = await makeStorePath();
const payload = makeStore("job-1", true);
const statePath = store.storePath.replace(/\.json$/, "-state.json");
- await saveCronStore(store.storePath, payload);
+ await fs.mkdir(path.dirname(store.storePath), { recursive: true });
+ await fs.writeFile(store.storePath, JSON.stringify(payload, null, 2), "utf-8");
+ await fs.writeFile(statePath, JSON.stringify({ version: 1, jobs: {} }), "utf-8");
const origReadFile = fs.readFile.bind(fs);
const spy = vi.spyOn(fs, "readFile").mockImplementation(async (filePath, options) => {
@@ -664,13 +762,15 @@ describe("cron store", () => {
});
try {
- await expect(loadCronStore(store.storePath)).rejects.toThrow(/Failed to read cron state/);
+ await expect(loadLegacyCronStoreForMigration(store.storePath)).rejects.toThrow(
+ /Failed to read cron state/,
+ );
} finally {
spy.mockRestore();
}
});
- it("sanitizes invalid updatedAtMs values from the state sidecar", async () => {
+ it("sanitizes invalid updatedAtMs values from the state sidecar during doctor migration", async () => {
const store = await makeStorePath();
const job = makeStore("job-1", true).jobs[0];
const config = {
@@ -699,13 +799,13 @@ describe("cron store", () => {
"utf-8",
);
- const loaded = await loadCronStore(store.storePath);
+ const loaded = (await loadLegacyCronStoreForMigration(store.storePath)).store;
expect(loaded.jobs[0]?.updatedAtMs).toBe(job.createdAtMs);
expect(loaded.jobs[0]?.state.nextRunAtMs).toBe(job.createdAtMs + 60_000);
});
- it("drops non-object runtime state from split cron sidecars", async () => {
+ it("drops non-object runtime state from split cron sidecars during doctor migration", async () => {
const store = await makeStorePath();
const first = makeStore("job-array-state", true).jobs[0];
const second = makeStore("job-scalar-entry", true).jobs[0];
@@ -739,7 +839,7 @@ describe("cron store", () => {
"utf-8",
);
- const loaded = await loadCronStore(store.storePath);
+ const loaded = (await loadLegacyCronStoreForMigration(store.storePath)).store;
expect(loaded.jobs[0]?.updatedAtMs).toBe(first.createdAtMs + 60_000);
expect(loaded.jobs[0]?.state).toStrictEqual({});
@@ -747,38 +847,15 @@ describe("cron store", () => {
expect(loaded.jobs[1]?.state).toStrictEqual({});
});
- it.skipIf(process.platform === "win32")(
- "writes store and backup files with secure permissions",
- async () => {
- const store = await makeStorePath();
- const first = makeStore("job-1", true);
- const second = makeStore("job-2", false);
+ it("does not create legacy store or backup files for new SQLite writes", async () => {
+ const store = await makeStorePath();
+ await saveCronStore(store.storePath, makeStore("job-1", true));
+ await saveCronStore(store.storePath, makeStore("job-2", false));
- await saveCronStore(store.storePath, first);
- await saveCronStore(store.storePath, second);
-
- const storeMode = (await fs.stat(store.storePath)).mode & 0o777;
- const backupMode = (await fs.stat(`${store.storePath}.bak`)).mode & 0o777;
-
- expect(storeMode).toBe(0o600);
- expect(backupMode).toBe(0o600);
- },
- );
-
- it.skipIf(process.platform === "win32")(
- "hardens an existing cron store directory to owner-only permissions",
- async () => {
- const store = await makeStorePath();
- const storeDir = path.dirname(store.storePath);
- await fs.mkdir(storeDir, { recursive: true, mode: 0o755 });
- await fs.chmod(storeDir, 0o755);
-
- await saveCronStore(store.storePath, makeStore("job-1", true));
-
- const storeDirMode = (await fs.stat(storeDir)).mode & 0o777;
- expect(storeDirMode).toBe(0o700);
- },
- );
+ await expectPathMissing(store.storePath);
+ await expectPathMissing(store.storePath.replace(/\.json$/, "-state.json"));
+ await expectPathMissing(`${store.storePath}.bak`);
+ });
});
describe("saveCronStore", () => {
@@ -795,49 +872,10 @@ describe("saveCronStore", () => {
expect(loaded).toEqual(dummyStore);
});
- it("retries rename on EBUSY then succeeds", async () => {
+ it("does not use legacy file writes on SQLite saves", async () => {
const { storePath } = await makeStorePath();
- const setTimeoutSpy = vi
- .spyOn(globalThis, "setTimeout")
- .mockImplementation(((handler: TimerHandler, _timeout?: number, ...args: unknown[]) =>
- scheduleNativeTimeout(handler, 0, ...args)) as typeof setTimeout);
- const origRename = fs.rename.bind(fs);
- let ebusyCount = 0;
- const spy = vi.spyOn(fs, "rename").mockImplementation(async (src, dest) => {
- if (ebusyCount < 2) {
- ebusyCount++;
- const err = new Error("EBUSY") as NodeJS.ErrnoException;
- err.code = "EBUSY";
- throw err;
- }
- return origRename(src, dest);
- });
-
- try {
- await saveCronStore(storePath, dummyStore);
-
- expect(ebusyCount).toBe(2);
- const loaded = await loadCronStore(storePath);
- expect(loaded).toEqual(dummyStore);
- } finally {
- spy.mockRestore();
- setTimeoutSpy.mockRestore();
- }
- });
-
- it("falls back to copyFile on EPERM (Windows)", async () => {
- const { storePath } = await makeStorePath();
-
- const spy = vi.spyOn(fs, "rename").mockImplementation(async () => {
- const err = new Error("EPERM") as NodeJS.ErrnoException;
- err.code = "EPERM";
- throw err;
- });
-
await saveCronStore(storePath, dummyStore);
- const loaded = await loadCronStore(storePath);
- expect(loaded).toEqual(dummyStore);
-
- spy.mockRestore();
+ await expectPathMissing(storePath);
+ await expectPathMissing(`${storePath}.bak`);
});
});
diff --git a/src/cron/store.ts b/src/cron/store.ts
index 82b054f3dd55..dba7f34fe638 100644
--- a/src/cron/store.ts
+++ b/src/cron/store.ts
@@ -1,13 +1,32 @@
import fs from "node:fs";
import path from "node:path";
+import type { DatabaseSync } from "node:sqlite";
+import type { Insertable, Selectable } from "kysely";
import { expandHomePrefix } from "../infra/home-dir.js";
+import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
import { replaceFileAtomic } from "../infra/replace-file.js";
import { isRecord } from "../shared/record-coerce.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
+import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
+import {
+ openOpenClawStateDatabase,
+ runOpenClawStateWriteTransaction,
+} from "../state/openclaw-state-db.js";
import { resolveConfigDir } from "../utils.js";
import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js";
+import { normalizeCronJobIdentityFields } from "./normalize-job-identity.js";
+import { normalizeCronJobInput } from "./normalize.js";
+import { getInvalidPersistedCronJobReason } from "./persisted-shape.js";
import { tryCronScheduleIdentity } from "./schedule-identity.js";
-import type { CronStoreFile } from "./types.js";
+import type {
+ CronDelivery,
+ CronFailureAlert,
+ CronJob,
+ CronJobState,
+ CronPayload,
+ CronSchedule,
+ CronStoreFile,
+} from "./types.js";
type SerializedStoreCacheEntry = {
configJson?: string;
@@ -40,15 +59,6 @@ export type LoadedCronStore = {
const serializedStoreCache = new Map();
-function getSerializedStoreCache(storePath: string): SerializedStoreCacheEntry {
- let entry = serializedStoreCache.get(storePath);
- if (!entry) {
- entry = { needsSplitMigration: false };
- serializedStoreCache.set(storePath, entry);
- }
- return entry;
-}
-
function resolveDefaultCronDir(): string {
return path.join(resolveConfigDir(), "cron");
}
@@ -84,6 +94,13 @@ type CronStateFile = {
jobs: Record;
};
+type CronJobsTable = OpenClawStateKyselyDatabase["cron_jobs"];
+type CronStoreDatabase = Pick;
+type CronJobRow = Selectable;
+type CronJobInsert = Insertable;
+
+const LEGACY_CRON_ARCHIVE_SUFFIX = ".migrated";
+
function parseCronStateFile(raw: string): CronStateFile | null {
try {
const parsed = parseJsonWithJson5Fallback(raw);
@@ -105,14 +122,669 @@ function parseCronStateFile(raw: string): CronStateFile | null {
}
}
-function normalizeCronStoreFile(parsed: unknown): CronStoreFile {
- const rawJobs = getRawCronJobs(parsed);
+function cronStoreKey(storePath: string): string {
+ return path.resolve(storePath);
+}
+
+function getCronStoreKysely(db: DatabaseSync) {
+ return getNodeSqliteKysely(db);
+}
+
+function parseJsonObject(raw: string, fallback: T): T {
+ try {
+ const parsed = JSON.parse(raw) as unknown;
+ return parsed && typeof parsed === "object" ? (parsed as T) : fallback;
+ } catch {
+ return fallback;
+ }
+}
+
+function parseJsonValue(raw: string, fallback: T): T {
+ try {
+ return JSON.parse(raw) as T;
+ } catch {
+ return fallback;
+ }
+}
+
+function normalizeNumber(value: number | bigint | null): number | undefined {
+ if (typeof value === "bigint") {
+ return Number(value);
+ }
+ return typeof value === "number" ? value : undefined;
+}
+
+function booleanToInteger(value: boolean | undefined): number | null {
+ return typeof value === "boolean" ? (value ? 1 : 0) : null;
+}
+
+function integerToBoolean(value: number | bigint | null): boolean | undefined {
+ const normalized = normalizeNumber(value);
+ return normalized == null ? undefined : normalized !== 0;
+}
+
+function serializeJson(value: unknown): string | null {
+ return value == null ? null : JSON.stringify(value);
+}
+
+function parseJsonArray(raw: string | null): string[] | undefined {
+ if (!raw) {
+ return undefined;
+ }
+ const parsed = parseJsonObject(raw, undefined);
+ return Array.isArray(parsed)
+ ? parsed.filter((item): item is string => typeof item === "string")
+ : undefined;
+}
+
+function optionalStringFromRecord(
+ record: Record,
+ key: string,
+): string | undefined {
+ const value = record[key];
+ return typeof value === "string" ? value : undefined;
+}
+
+function optionalBooleanFromRecord(
+ record: Record,
+ key: string,
+): boolean | undefined {
+ const value = record[key];
+ return typeof value === "boolean" ? value : undefined;
+}
+
+function optionalNumberFromRecord(
+ record: Record,
+ key: string,
+): number | undefined {
+ const value = record[key];
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
+}
+
+function optionalStringArrayFromRecord(
+ record: Record,
+ key: string,
+): string[] | undefined {
+ const value = record[key];
+ return Array.isArray(value) && value.every((item) => typeof item === "string")
+ ? value
+ : undefined;
+}
+
+function parseExternalContentSource(
+ raw: string | null,
+ fallback: unknown,
+): "gmail" | "webhook" | undefined {
+ const parsed = raw ? parseJsonValue(raw, undefined) : fallback;
+ return parsed === "gmail" || parsed === "webhook" ? parsed : undefined;
+}
+
+function bindScheduleColumns(
+ schedule: CronSchedule,
+): Pick<
+ CronJobInsert,
+ "anchor_ms" | "at" | "every_ms" | "schedule_expr" | "schedule_kind" | "schedule_tz" | "stagger_ms"
+> {
+ if (schedule.kind === "at") {
+ return {
+ schedule_kind: "at",
+ at: schedule.at,
+ every_ms: null,
+ anchor_ms: null,
+ schedule_expr: null,
+ schedule_tz: null,
+ stagger_ms: null,
+ };
+ }
+ if (schedule.kind === "every") {
+ return {
+ schedule_kind: "every",
+ at: null,
+ every_ms: schedule.everyMs,
+ anchor_ms: schedule.anchorMs ?? null,
+ schedule_expr: null,
+ schedule_tz: null,
+ stagger_ms: null,
+ };
+ }
return {
- version: 1,
- jobs: rawJobs.filter(isRecord) as never as CronStoreFile["jobs"],
+ schedule_kind: "cron",
+ at: null,
+ every_ms: null,
+ anchor_ms: null,
+ schedule_expr: schedule.expr,
+ schedule_tz: schedule.tz ?? null,
+ stagger_ms: schedule.staggerMs ?? null,
};
}
+function bindPayloadColumns(
+ payload: CronPayload,
+): Pick<
+ CronJobInsert,
+ | "payload_allow_unsafe_external_content"
+ | "payload_external_content_source_json"
+ | "payload_fallbacks_json"
+ | "payload_kind"
+ | "payload_light_context"
+ | "payload_message"
+ | "payload_model"
+ | "payload_thinking"
+ | "payload_timeout_seconds"
+ | "payload_tools_allow_json"
+> {
+ if (payload.kind === "systemEvent") {
+ return {
+ payload_kind: "systemEvent",
+ payload_message: payload.text,
+ payload_model: null,
+ payload_fallbacks_json: null,
+ payload_thinking: null,
+ payload_timeout_seconds: null,
+ payload_allow_unsafe_external_content: null,
+ payload_external_content_source_json: null,
+ payload_light_context: null,
+ payload_tools_allow_json: null,
+ };
+ }
+ return {
+ payload_kind: "agentTurn",
+ payload_message: payload.message,
+ payload_model: payload.model ?? null,
+ payload_fallbacks_json: serializeJson(payload.fallbacks),
+ payload_thinking: payload.thinking ?? null,
+ payload_timeout_seconds: payload.timeoutSeconds ?? null,
+ payload_allow_unsafe_external_content: booleanToInteger(payload.allowUnsafeExternalContent),
+ payload_external_content_source_json: serializeJson(payload.externalContentSource),
+ payload_light_context: booleanToInteger(payload.lightContext),
+ payload_tools_allow_json: serializeJson(payload.toolsAllow),
+ };
+}
+
+function bindDeliveryColumns(
+ delivery: CronDelivery | undefined,
+): Pick<
+ CronJobInsert,
+ | "delivery_account_id"
+ | "delivery_best_effort"
+ | "delivery_channel"
+ | "delivery_mode"
+ | "delivery_thread_id"
+ | "delivery_to"
+ | "failure_delivery_account_id"
+ | "failure_delivery_channel"
+ | "failure_delivery_mode"
+ | "failure_delivery_to"
+> {
+ return {
+ delivery_mode: delivery?.mode ?? null,
+ delivery_channel: delivery?.channel ?? null,
+ delivery_to: delivery?.to ?? null,
+ delivery_thread_id:
+ delivery?.threadId === undefined || delivery.threadId === null
+ ? null
+ : String(delivery.threadId),
+ delivery_account_id: delivery?.accountId ?? null,
+ delivery_best_effort: booleanToInteger(delivery?.bestEffort),
+ failure_delivery_mode: delivery?.failureDestination?.mode ?? null,
+ failure_delivery_channel: delivery?.failureDestination?.channel ?? null,
+ failure_delivery_to: delivery?.failureDestination?.to ?? null,
+ failure_delivery_account_id: delivery?.failureDestination?.accountId ?? null,
+ };
+}
+
+function bindFailureAlertColumns(
+ failureAlert: CronFailureAlert | false | undefined,
+): Pick<
+ CronJobInsert,
+ | "failure_alert_account_id"
+ | "failure_alert_after"
+ | "failure_alert_channel"
+ | "failure_alert_cooldown_ms"
+ | "failure_alert_disabled"
+ | "failure_alert_include_skipped"
+ | "failure_alert_mode"
+ | "failure_alert_to"
+> {
+ if (failureAlert === false) {
+ return {
+ failure_alert_disabled: 1,
+ failure_alert_after: null,
+ failure_alert_channel: null,
+ failure_alert_to: null,
+ failure_alert_cooldown_ms: null,
+ failure_alert_include_skipped: null,
+ failure_alert_mode: null,
+ failure_alert_account_id: null,
+ };
+ }
+ return {
+ failure_alert_disabled: failureAlert ? 0 : null,
+ failure_alert_after: failureAlert?.after ?? null,
+ failure_alert_channel: failureAlert?.channel ?? null,
+ failure_alert_to: failureAlert?.to ?? null,
+ failure_alert_cooldown_ms: failureAlert?.cooldownMs ?? null,
+ failure_alert_include_skipped: booleanToInteger(failureAlert?.includeSkipped),
+ failure_alert_mode: failureAlert?.mode ?? null,
+ failure_alert_account_id: failureAlert?.accountId ?? null,
+ };
+}
+
+function bindStateColumns(
+ state: CronJobState,
+): Pick<
+ CronJobInsert,
+ | "consecutive_errors"
+ | "consecutive_skipped"
+ | "last_delivered"
+ | "last_delivery_error"
+ | "last_delivery_status"
+ | "last_duration_ms"
+ | "last_error"
+ | "last_failure_alert_at_ms"
+ | "last_run_at_ms"
+ | "last_run_status"
+ | "next_run_at_ms"
+ | "running_at_ms"
+ | "schedule_error_count"
+> {
+ return {
+ next_run_at_ms: state.nextRunAtMs ?? null,
+ running_at_ms: state.runningAtMs ?? null,
+ last_run_at_ms: state.lastRunAtMs ?? null,
+ last_run_status: state.lastRunStatus ?? state.lastStatus ?? null,
+ last_error: state.lastError ?? null,
+ last_duration_ms: state.lastDurationMs ?? null,
+ consecutive_errors: state.consecutiveErrors ?? null,
+ consecutive_skipped: state.consecutiveSkipped ?? null,
+ schedule_error_count: state.scheduleErrorCount ?? null,
+ last_delivery_status: state.lastDeliveryStatus ?? null,
+ last_delivery_error: state.lastDeliveryError ?? null,
+ last_delivered: booleanToInteger(state.lastDelivered),
+ last_failure_alert_at_ms: state.lastFailureAlertAtMs ?? null,
+ };
+}
+
+function bindCronJobRow(storeKey: string, job: CronJob, sortOrder: number): CronJobInsert {
+ return {
+ store_key: storeKey,
+ job_id: job.id,
+ name: job.name,
+ description: job.description ?? null,
+ enabled: job.enabled ? 1 : 0,
+ delete_after_run: booleanToInteger(job.deleteAfterRun),
+ created_at_ms: job.createdAtMs,
+ updated_at: job.updatedAtMs,
+ agent_id: job.agentId ?? null,
+ session_key: job.sessionKey ?? null,
+ session_target: job.sessionTarget,
+ wake_mode: job.wakeMode,
+ ...bindScheduleColumns(job.schedule),
+ ...bindPayloadColumns(job.payload),
+ ...bindDeliveryColumns(job.delivery),
+ ...bindFailureAlertColumns(job.failureAlert),
+ ...bindStateColumns(job.state ?? {}),
+ job_json: JSON.stringify(stripJobRuntimeFields(job)),
+ state_json: JSON.stringify(job.state ?? {}),
+ runtime_updated_at_ms: job.updatedAtMs,
+ schedule_identity: tryCronScheduleIdentity(job as unknown as Record) ?? null,
+ sort_order: sortOrder,
+ };
+}
+
+function normalizeCronJobForSqlite(job: CronStoreFile["jobs"][number]): CronJob | null {
+ const raw = structuredClone(job) as unknown as Record;
+ const hadDeleteAfterRun = Object.hasOwn(raw, "deleteAfterRun");
+ normalizeCronJobIdentityFields(raw);
+ const normalized = normalizeCronJobInput(raw, { applyDefaults: true });
+ if (!normalized || getInvalidPersistedCronJobReason(normalized)) {
+ return null;
+ }
+ if (!hadDeleteAfterRun) {
+ delete normalized.deleteAfterRun;
+ }
+ const createdAtMs =
+ typeof normalized.createdAtMs === "number" && Number.isFinite(normalized.createdAtMs)
+ ? normalized.createdAtMs
+ : Date.now();
+ const updatedAtMs =
+ typeof normalized.updatedAtMs === "number" && Number.isFinite(normalized.updatedAtMs)
+ ? normalized.updatedAtMs
+ : createdAtMs;
+ return {
+ ...normalized,
+ createdAtMs,
+ updatedAtMs,
+ state: isRecord(normalized.state) ? (normalized.state as CronJobState) : {},
+ } as CronJob;
+}
+
+function countUnpersistableCronJobs(store: CronStoreFile): number {
+ return store.jobs.reduce((count, job) => count + (normalizeCronJobForSqlite(job) ? 0 : 1), 0);
+}
+
+function assertCronStoreCanPersist(store: CronStoreFile): void {
+ const invalidJobs = countUnpersistableCronJobs(store);
+ if (invalidJobs > 0) {
+ throw new Error(`Cannot persist cron store with ${invalidJobs} invalid job(s)`);
+ }
+}
+
+function scheduleFromRow(row: CronJobRow): CronSchedule | null {
+ if (row.schedule_kind === "at" && row.at) {
+ return { kind: "at", at: row.at };
+ }
+ if (row.schedule_kind === "every" && row.every_ms != null) {
+ return {
+ kind: "every",
+ everyMs: normalizeNumber(row.every_ms) ?? 0,
+ ...(row.anchor_ms != null ? { anchorMs: normalizeNumber(row.anchor_ms) } : {}),
+ };
+ }
+ if (row.schedule_kind === "cron" && row.schedule_expr) {
+ return {
+ kind: "cron",
+ expr: row.schedule_expr,
+ ...(row.schedule_tz ? { tz: row.schedule_tz } : {}),
+ ...(row.stagger_ms != null ? { staggerMs: normalizeNumber(row.stagger_ms) } : {}),
+ };
+ }
+ return null;
+}
+
+function payloadFromRow(row: CronJobRow, fallback: unknown): CronPayload | null {
+ const fallbackRecord = isRecord(fallback) ? fallback : {};
+ if (row.payload_kind === "systemEvent") {
+ const text = row.payload_message ?? optionalStringFromRecord(fallbackRecord, "text");
+ return text == null ? null : { kind: "systemEvent", text };
+ }
+ if (row.payload_kind === "agentTurn") {
+ const message = row.payload_message ?? optionalStringFromRecord(fallbackRecord, "message");
+ if (message == null) {
+ return null;
+ }
+ const model = row.payload_model ?? optionalStringFromRecord(fallbackRecord, "model");
+ const fallbacks = row.payload_fallbacks_json
+ ? parseJsonArray(row.payload_fallbacks_json)
+ : optionalStringArrayFromRecord(fallbackRecord, "fallbacks");
+ const thinking = row.payload_thinking ?? optionalStringFromRecord(fallbackRecord, "thinking");
+ const timeoutSeconds =
+ row.payload_timeout_seconds != null
+ ? normalizeNumber(row.payload_timeout_seconds)
+ : optionalNumberFromRecord(fallbackRecord, "timeoutSeconds");
+ const allowUnsafeExternalContent =
+ row.payload_allow_unsafe_external_content != null
+ ? integerToBoolean(row.payload_allow_unsafe_external_content)
+ : optionalBooleanFromRecord(fallbackRecord, "allowUnsafeExternalContent");
+ const externalContentSource = parseExternalContentSource(
+ row.payload_external_content_source_json,
+ fallbackRecord.externalContentSource,
+ );
+ const lightContext =
+ row.payload_light_context != null
+ ? integerToBoolean(row.payload_light_context)
+ : optionalBooleanFromRecord(fallbackRecord, "lightContext");
+ const toolsAllow = row.payload_tools_allow_json
+ ? parseJsonArray(row.payload_tools_allow_json)
+ : optionalStringArrayFromRecord(fallbackRecord, "toolsAllow");
+ return {
+ kind: "agentTurn",
+ message,
+ ...(model ? { model } : {}),
+ ...(fallbacks ? { fallbacks } : {}),
+ ...(thinking ? { thinking } : {}),
+ ...(timeoutSeconds != null ? { timeoutSeconds } : {}),
+ ...(allowUnsafeExternalContent != null ? { allowUnsafeExternalContent } : {}),
+ ...(externalContentSource ? { externalContentSource } : {}),
+ ...(lightContext != null ? { lightContext } : {}),
+ ...(toolsAllow ? { toolsAllow } : {}),
+ };
+ }
+ return null;
+}
+
+function deliveryFromRow(row: CronJobRow): CronDelivery | undefined {
+ if (!row.delivery_mode) {
+ return undefined;
+ }
+ return {
+ mode: row.delivery_mode as CronDelivery["mode"],
+ ...(row.delivery_channel ? { channel: row.delivery_channel as CronDelivery["channel"] } : {}),
+ ...(row.delivery_to ? { to: row.delivery_to } : {}),
+ ...(row.delivery_thread_id ? { threadId: row.delivery_thread_id } : {}),
+ ...(row.delivery_account_id ? { accountId: row.delivery_account_id } : {}),
+ ...(row.delivery_best_effort != null
+ ? { bestEffort: integerToBoolean(row.delivery_best_effort) }
+ : {}),
+ ...(row.failure_delivery_channel ||
+ row.failure_delivery_to ||
+ row.failure_delivery_mode ||
+ row.failure_delivery_account_id
+ ? {
+ failureDestination: {
+ ...(row.failure_delivery_channel
+ ? { channel: row.failure_delivery_channel as CronDelivery["channel"] }
+ : {}),
+ ...(row.failure_delivery_to ? { to: row.failure_delivery_to } : {}),
+ ...(row.failure_delivery_mode
+ ? { mode: row.failure_delivery_mode as "announce" | "webhook" }
+ : {}),
+ ...(row.failure_delivery_account_id
+ ? { accountId: row.failure_delivery_account_id }
+ : {}),
+ },
+ }
+ : {}),
+ };
+}
+
+function failureAlertFromRow(row: CronJobRow): CronFailureAlert | false | undefined {
+ if (row.failure_alert_disabled === 1) {
+ return false;
+ }
+ if (
+ row.failure_alert_after == null &&
+ !row.failure_alert_channel &&
+ !row.failure_alert_to &&
+ row.failure_alert_cooldown_ms == null &&
+ row.failure_alert_include_skipped == null &&
+ !row.failure_alert_mode &&
+ !row.failure_alert_account_id
+ ) {
+ return undefined;
+ }
+ return {
+ ...(row.failure_alert_after != null ? { after: normalizeNumber(row.failure_alert_after) } : {}),
+ ...(row.failure_alert_channel
+ ? { channel: row.failure_alert_channel as CronFailureAlert["channel"] }
+ : {}),
+ ...(row.failure_alert_to ? { to: row.failure_alert_to } : {}),
+ ...(row.failure_alert_cooldown_ms != null
+ ? { cooldownMs: normalizeNumber(row.failure_alert_cooldown_ms) }
+ : {}),
+ ...(row.failure_alert_include_skipped != null
+ ? { includeSkipped: integerToBoolean(row.failure_alert_include_skipped) }
+ : {}),
+ ...(row.failure_alert_mode ? { mode: row.failure_alert_mode as "announce" | "webhook" } : {}),
+ ...(row.failure_alert_account_id ? { accountId: row.failure_alert_account_id } : {}),
+ };
+}
+
+function stateFromRow(row: CronJobRow): CronJobState {
+ return {
+ ...parseJsonObject(row.state_json, {}),
+ ...(row.next_run_at_ms != null ? { nextRunAtMs: normalizeNumber(row.next_run_at_ms) } : {}),
+ ...(row.running_at_ms != null ? { runningAtMs: normalizeNumber(row.running_at_ms) } : {}),
+ ...(row.last_run_at_ms != null ? { lastRunAtMs: normalizeNumber(row.last_run_at_ms) } : {}),
+ ...(row.last_run_status
+ ? { lastRunStatus: row.last_run_status as CronJobState["lastRunStatus"] }
+ : {}),
+ ...(row.last_error ? { lastError: row.last_error } : {}),
+ ...(row.last_duration_ms != null
+ ? { lastDurationMs: normalizeNumber(row.last_duration_ms) }
+ : {}),
+ ...(row.consecutive_errors != null
+ ? { consecutiveErrors: normalizeNumber(row.consecutive_errors) }
+ : {}),
+ ...(row.consecutive_skipped != null
+ ? { consecutiveSkipped: normalizeNumber(row.consecutive_skipped) }
+ : {}),
+ ...(row.schedule_error_count != null
+ ? { scheduleErrorCount: normalizeNumber(row.schedule_error_count) }
+ : {}),
+ ...(row.last_delivery_status
+ ? { lastDeliveryStatus: row.last_delivery_status as CronJobState["lastDeliveryStatus"] }
+ : {}),
+ ...(row.last_delivery_error ? { lastDeliveryError: row.last_delivery_error } : {}),
+ ...(row.last_delivered != null ? { lastDelivered: integerToBoolean(row.last_delivered) } : {}),
+ ...(row.last_failure_alert_at_ms != null
+ ? { lastFailureAlertAtMs: normalizeNumber(row.last_failure_alert_at_ms) }
+ : {}),
+ };
+}
+
+function rowToCronJob(row: CronJobRow): CronJob | null {
+ const base = parseJsonObject>(row.job_json, {});
+ const schedule = scheduleFromRow(row) ?? base.schedule;
+ const payload = payloadFromRow(row, base.payload) ?? base.payload;
+ if (!schedule || !payload) {
+ return null;
+ }
+ return {
+ ...base,
+ id: row.job_id,
+ name: row.name,
+ ...(row.description ? { description: row.description } : {}),
+ enabled: row.enabled !== 0,
+ ...(row.delete_after_run != null
+ ? { deleteAfterRun: integerToBoolean(row.delete_after_run) }
+ : {}),
+ createdAtMs: normalizeNumber(row.created_at_ms) ?? base.createdAtMs ?? Date.now(),
+ updatedAtMs:
+ normalizeNumber(row.runtime_updated_at_ms) ??
+ normalizeNumber(row.updated_at) ??
+ base.updatedAtMs ??
+ Date.now(),
+ ...(row.agent_id ? { agentId: row.agent_id } : {}),
+ ...(row.session_key ? { sessionKey: row.session_key } : {}),
+ schedule,
+ sessionTarget: row.session_target as CronJob["sessionTarget"],
+ wakeMode: row.wake_mode as CronJob["wakeMode"],
+ payload,
+ ...(deliveryFromRow(row) ? { delivery: deliveryFromRow(row) } : {}),
+ ...(failureAlertFromRow(row) !== undefined ? { failureAlert: failureAlertFromRow(row) } : {}),
+ state: stateFromRow(row),
+ };
+}
+
+function loadCronRows(db: DatabaseSync, storeKey: string): CronJobRow[] {
+ return executeSqliteQuerySync(
+ db,
+ getCronStoreKysely(db)
+ .selectFrom("cron_jobs")
+ .selectAll()
+ .where("store_key", "=", storeKey)
+ .orderBy("sort_order", "asc")
+ .orderBy("updated_at", "asc")
+ .orderBy("job_id", "asc"),
+ ).rows;
+}
+
+function replaceCronRows(db: DatabaseSync, storeKey: string, store: CronStoreFile): void {
+ executeSqliteQuerySync(
+ db,
+ getCronStoreKysely(db).deleteFrom("cron_jobs").where("store_key", "=", storeKey),
+ );
+ for (const [index, job] of store.jobs.entries()) {
+ const normalized = normalizeCronJobForSqlite(job);
+ if (!normalized) {
+ continue;
+ }
+ executeSqliteQuerySync(
+ db,
+ getCronStoreKysely(db)
+ .insertInto("cron_jobs")
+ .values(bindCronJobRow(storeKey, normalized, index)),
+ );
+ }
+}
+
+function updateCronRuntimeRows(db: DatabaseSync, storeKey: string, store: CronStoreFile): void {
+ for (const job of store.jobs) {
+ executeSqliteQuerySync(
+ db,
+ getCronStoreKysely(db)
+ .updateTable("cron_jobs")
+ .set({
+ ...bindStateColumns(job.state ?? {}),
+ state_json: JSON.stringify(job.state ?? {}),
+ runtime_updated_at_ms: job.updatedAtMs,
+ schedule_identity: tryCronScheduleIdentity(job as unknown as Record),
+ })
+ .where("store_key", "=", storeKey)
+ .where("job_id", "=", job.id),
+ );
+ }
+}
+
+function loadedCronStoreFromRows(rows: CronJobRow[]): LoadedCronStore {
+ const jobs = rows.map(rowToCronJob).filter((job): job is CronJob => job !== null);
+ const configJobs = rows.map((row) =>
+ parseJsonObject>(
+ row.job_json,
+ stripJobRuntimeFields(rowToCronJob(row) ?? ({} as CronJob)),
+ ),
+ );
+ const configJobRuntimeEntries = rows.map((row) => ({
+ updatedAtMs: normalizeNumber(row.runtime_updated_at_ms) ?? normalizeNumber(row.updated_at),
+ scheduleIdentity: row.schedule_identity ?? undefined,
+ state: stateFromRow(row) as Record,
+ }));
+ return {
+ store: { version: 1, jobs },
+ configJobs,
+ configJobIndexes: rows.map((_row, index) => index),
+ configJobRuntimeEntries,
+ invalidConfigRows: [],
+ };
+}
+
+async function legacyCronFileExists(filePath: string): Promise {
+ return fs.promises
+ .access(filePath, fs.constants.F_OK)
+ .then(() => true)
+ .catch(() => false);
+}
+
+async function archiveLegacyCronFile(filePath: string): Promise {
+ if (!(await legacyCronFileExists(filePath))) {
+ return;
+ }
+ const archivePath = `${filePath}${LEGACY_CRON_ARCHIVE_SUFFIX}`;
+ if (await legacyCronFileExists(archivePath)) {
+ return;
+ }
+ await fs.promises.rename(filePath, archivePath).catch(() => undefined);
+}
+
+async function archiveLegacyCronStoreFiles(storePath: string): Promise {
+ await Promise.all([
+ archiveLegacyCronFile(storePath),
+ archiveLegacyCronFile(resolveStatePath(storePath)),
+ ]);
+}
+
+export async function legacyCronStoreFilesExist(storePath: string): Promise {
+ return (
+ (await legacyCronFileExists(path.resolve(storePath))) ||
+ (await legacyCronFileExists(resolveStatePath(path.resolve(storePath))))
+ );
+}
+
+export async function archiveLegacyCronStoreForMigration(storePath: string): Promise {
+ await archiveLegacyCronStoreFiles(path.resolve(storePath));
+}
+
function getRawCronJobs(parsed: unknown): unknown[] {
return Array.isArray(parsed)
? parsed
@@ -177,22 +849,6 @@ async function loadStateFile(statePath: string): Promise {
return parseCronStateFile(raw);
}
-function loadStateFileSync(statePath: string): CronStateFile | null {
- let raw: string;
- try {
- raw = fs.readFileSync(statePath, "utf-8");
- } catch (err) {
- if ((err as { code?: unknown })?.code === "ENOENT") {
- return null;
- }
- throw new Error(`Failed to read cron state at ${statePath}: ${String(err)}`, {
- cause: err,
- });
- }
-
- return parseCronStateFile(raw);
-}
-
function hasInlineState(jobs: Array | null | undefined>): boolean {
return jobs.some(
(job) => job != null && isRecord(job.state) && Object.keys(job.state).length > 0,
@@ -244,7 +900,7 @@ function resolveCronStateId(job: Record): string | undefined {
return normalizeOptionalString(job.id) ?? normalizeOptionalString(job.jobId);
}
-export async function loadCronStoreWithConfigJobs(storePath: string): Promise {
+async function loadLegacyCronStoreWithConfigJobs(storePath: string): Promise {
try {
const raw = await fs.promises.readFile(storePath, "utf-8");
let parsed: unknown;
@@ -333,54 +989,40 @@ export async function loadCronStoreWithConfigJobs(storePath: string): Promise {
+ return loadLegacyCronStoreWithConfigJobs(path.resolve(storePath));
+}
+
+export async function loadCronStoreWithConfigJobs(storePath: string): Promise {
+ const resolvedStorePath = path.resolve(storePath);
+ const storeKey = cronStoreKey(resolvedStorePath);
+ const database = openOpenClawStateDatabase().db;
+ const rows = loadCronRows(database, storeKey);
+ if (rows.length > 0) {
+ return loadedCronStoreFromRows(rows);
+ }
+ return {
+ store: { version: 1, jobs: [] },
+ configJobs: [],
+ configJobIndexes: [],
+ configJobRuntimeEntries: [],
+ invalidConfigRows: [],
+ };
+}
+
export async function loadCronStore(storePath: string): Promise {
return (await loadCronStoreWithConfigJobs(storePath)).store;
}
export function loadCronStoreSync(storePath: string): CronStoreFile {
- try {
- const raw = fs.readFileSync(storePath, "utf-8");
- let parsed: unknown;
- try {
- parsed = parseJsonWithJson5Fallback(raw);
- } catch (err) {
- throw new Error(`Failed to parse cron store at ${storePath}: ${String(err)}`, {
- cause: err,
- });
- }
- const store = normalizeCronStoreFile(parsed);
- const jobs = store.jobs as unknown as Array>;
-
- const stateFile = loadStateFileSync(resolveStatePath(storePath));
- const hasLegacyInlineState = !stateFile && hasInlineState(jobs);
-
- if (stateFile) {
- for (const job of store.jobs) {
- const stateId = resolveCronStateId(job as unknown as Record);
- const entry = stateId ? stateFile.jobs[stateId] : undefined;
- if (entry) {
- mergeStateFileEntry(job, entry);
- } else {
- backfillMissingRuntimeFields(job);
- }
- }
- } else if (!hasLegacyInlineState) {
- for (const job of store.jobs) {
- backfillMissingRuntimeFields(job);
- }
- }
-
- for (const job of store.jobs) {
- ensureJobStateObject(job);
- }
-
- return store;
- } catch (err) {
- if ((err as { code?: unknown })?.code === "ENOENT") {
- return { version: 1, jobs: [] };
- }
- throw err;
+ const resolvedStorePath = path.resolve(storePath);
+ const storeKey = cronStoreKey(resolvedStorePath);
+ const database = openOpenClawStateDatabase().db;
+ const rows = loadCronRows(database, storeKey);
+ if (rows.length > 0) {
+ return loadedCronStoreFromRows(rows).store;
}
+ return { version: 1, jobs: [] };
}
type SaveCronStoreOptions = {
@@ -388,10 +1030,6 @@ type SaveCronStoreOptions = {
stateOnly?: boolean;
};
-async function setSecureFileMode(filePath: string): Promise {
- await fs.promises.chmod(filePath, 0o600).catch(() => undefined);
-}
-
async function atomicWrite(filePath: string, content: string, dirMode = 0o700): Promise {
await replaceFileAtomic({
filePath,
@@ -404,76 +1042,25 @@ async function atomicWrite(filePath: string, content: string, dirMode = 0o700):
});
}
-async function serializedFileNeedsWrite(
- filePath: string,
- expectedJson: string,
- contentChanged: boolean,
-): Promise {
- if (contentChanged) {
- return true;
- }
- try {
- const diskJson = await fs.promises.readFile(filePath, "utf-8");
- return diskJson !== expectedJson;
- } catch (err) {
- if ((err as { code?: unknown })?.code === "ENOENT") {
- return true;
- }
- throw err;
- }
-}
-
export async function saveCronStore(
storePath: string,
store: CronStoreFile,
opts?: SaveCronStoreOptions,
) {
- const stateOnly = opts?.stateOnly === true;
- const configJson = JSON.stringify(stripRuntimeOnlyCronFields(store), null, 2);
- const stateFile = extractStateFile(store);
- const stateJson = JSON.stringify(stateFile, null, 2);
-
- const statePath = resolveStatePath(storePath);
- const cache = serializedStoreCache.get(storePath);
-
- const configChanged = !stateOnly && cache?.configJson !== configJson;
- const stateChanged = cache?.stateJson !== stateJson;
- const migrating = cache?.needsSplitMigration === true;
- const configNeedsWrite = stateOnly
- ? false
- : await serializedFileNeedsWrite(storePath, configJson, configChanged);
- const stateNeedsWrite = await serializedFileNeedsWrite(statePath, stateJson, stateChanged);
-
- if (
- stateOnly ? !stateNeedsWrite && !migrating : !configNeedsWrite && !stateNeedsWrite && !migrating
- ) {
+ void opts;
+ const resolvedStorePath = path.resolve(storePath);
+ const storeKey = cronStoreKey(resolvedStorePath);
+ if (opts?.stateOnly) {
+ runOpenClawStateWriteTransaction(({ db }) => {
+ updateCronRuntimeRows(db, storeKey, store);
+ });
return;
}
-
- const updatedCache = getSerializedStoreCache(storePath);
-
- // Write state first so migration never leaves stripped config without runtime state.
- if (stateNeedsWrite || migrating) {
- await atomicWrite(statePath, stateJson);
- updatedCache.stateJson = stateJson;
- }
-
- if (!stateOnly && (configNeedsWrite || migrating)) {
- // Determine backup need: only when config actually changed (not migration-only).
- const skipBackup = opts?.skipBackup === true || !configChanged;
- if (!skipBackup) {
- try {
- const backupPath = `${storePath}.bak`;
- await fs.promises.copyFile(storePath, backupPath);
- await setSecureFileMode(backupPath);
- } catch {
- // best-effort
- }
- }
- await atomicWrite(storePath, configJson);
- updatedCache.configJson = configJson;
- }
- updatedCache.needsSplitMigration = stateOnly && migrating;
+ assertCronStoreCanPersist(store);
+ runOpenClawStateWriteTransaction(({ db }) => {
+ replaceCronRows(db, storeKey, store);
+ });
+ serializedStoreCache.delete(resolvedStorePath);
}
export async function loadCronQuarantineFile(path: string): Promise {
diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts
index e267762349f3..2372ee7d1639 100644
--- a/src/gateway/server.cron.test.ts
+++ b/src/gateway/server.cron.test.ts
@@ -5,6 +5,7 @@ import { setImmediate as setImmediatePromise } from "node:timers/promises";
import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import type WebSocket from "ws";
import { resetConfigRuntimeState } from "../config/config.js";
+import { loadCronStore, saveCronStore } from "../cron/store.js";
import type { GuardedFetchOptions } from "../infra/net/fetch-guard.js";
import { peekSystemEvents } from "../infra/system-events.js";
import type { GatewayCronState } from "./server-cron.js";
@@ -57,7 +58,6 @@ vi.mock("../plugin-sdk/browser-maintenance.js", () => ({
installGatewayTestHooks({ scope: "suite" });
const CRON_WAIT_TIMEOUT_MS = 10_000;
-const EMPTY_CRON_STORE_CONTENT = JSON.stringify({ version: 1, jobs: [] });
let cronSuiteTempRootPromise: Promise | null = null;
let cronSuiteCaseId = 0;
@@ -150,10 +150,14 @@ async function setupCronTestRun(params: {
testState.cronStorePath = storePath;
testState.sessionConfig = params.sessionConfig;
testState.cronEnabled = params.cronEnabled;
- await fs.writeFile(
- testState.cronStorePath,
- params.jobs ? JSON.stringify({ version: 1, jobs: params.jobs }) : EMPTY_CRON_STORE_CONTENT,
- );
+ if (params.jobs) {
+ await saveCronStore(testState.cronStorePath, {
+ version: 1,
+ jobs: params.jobs as never,
+ });
+ } else {
+ await saveCronStore(testState.cronStorePath, { version: 1, jobs: [] });
+ }
return { prevSkipCron, dir };
}
@@ -553,7 +557,7 @@ describe("gateway server cron", () => {
}
});
- test("cron.add preserves legacy top-level array stores (#60799)", async () => {
+ test("cron.add leaves legacy top-level array stores for doctor migration", async () => {
const { prevSkipCron } = await setupCronTestRun({
tempPrefix: "openclaw-gw-cron-legacy-array-",
cronEnabled: false,
@@ -608,25 +612,18 @@ describe("gateway server cron", () => {
});
const newJobId = expectCronJobIdFromResponse(addRes);
- const persisted = JSON.parse(await fs.readFile(storePath as string, "utf-8")) as {
- version?: unknown;
- jobs?: Array>;
- };
+ const persisted = await loadCronStore(storePath as string);
expect(persisted.version).toBe(1);
- expect(persisted.jobs?.map((job) => job.id)).toEqual([
- "gw-legacy-alpha",
- "gw-legacy-beta",
- newJobId,
- ]);
+ expect(persisted.jobs?.map((job) => job.id)).toEqual([newJobId]);
const listRes = await directCronReq(cronState, "cron.list", { includeDisabled: true });
expect(listRes.ok).toBe(true);
const listedJobs = (listRes.payload as { jobs?: Array> } | null)
?.jobs;
- expect(listedJobs?.map((job) => job.id)).toEqual(
- expect.arrayContaining(["gw-legacy-alpha", "gw-legacy-beta", newJobId]),
- );
- expect(listedJobs).toHaveLength(3);
+ expect(listedJobs?.map((job) => job.id)).toEqual([newJobId]);
+
+ const legacyStore = JSON.parse(await fs.readFile(storePath as string, "utf-8")) as unknown;
+ expect(Array.isArray(legacyStore)).toBe(true);
} finally {
await cleanupCronTestRun({ cronState, prevSkipCron });
}
diff --git a/src/infra/gateway-processes.test.ts b/src/infra/gateway-processes.test.ts
index 39d933d8fddb..f6b03df2f26f 100644
--- a/src/infra/gateway-processes.test.ts
+++ b/src/infra/gateway-processes.test.ts
@@ -8,20 +8,22 @@ const parseProcCmdlineMock = vi.hoisted(() => vi.fn());
const isGatewayArgvMock = vi.hoisted(() => vi.fn());
const findGatewayPidsOnPortSyncMock = vi.hoisted(() => vi.fn());
-vi.mock("node:child_process", async () => {
- const { mockNodeChildProcessSpawnSync } = await import("openclaw/plugin-sdk/test-node-mocks");
- return mockNodeChildProcessSpawnSync(spawnSyncMock);
-});
+vi.mock("node:child_process", async (importOriginal) => ({
+ ...(await importOriginal()),
+ spawnSync: spawnSyncMock,
+}));
-vi.mock("node:fs", async () => {
- const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks");
- return mockNodeBuiltinModule(
- () => vi.importActual("node:fs"),
- {
+vi.mock("node:fs", async (importOriginal) => {
+ const actual = await importOriginal();
+ const actualWithDefault = actual as typeof actual & { default?: typeof actual };
+ return {
+ ...actual,
+ default: {
+ ...(actualWithDefault.default ?? actual),
readFileSync: (...args: unknown[]) => readFileSyncMock(...args),
},
- { mirrorToDefault: true },
- );
+ readFileSync: (...args: unknown[]) => readFileSyncMock(...args),
+ };
});
vi.mock("../daemon/cmd-argv.js", () => ({
diff --git a/src/infra/machine-name.test.ts b/src/infra/machine-name.test.ts
index e5be3d8f1d9a..074b3873cb89 100644
--- a/src/infra/machine-name.test.ts
+++ b/src/infra/machine-name.test.ts
@@ -4,14 +4,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const execFileMock = vi.hoisted(() => vi.fn());
-vi.mock("node:child_process", async () => {
- const { mockNodeChildProcessExecFile } = await import("openclaw/plugin-sdk/test-node-mocks");
- return mockNodeChildProcessExecFile(
- Object.assign(execFileMock, {
- __promisify__: vi.fn(),
- }) as typeof import("node:child_process").execFile,
- );
-});
+vi.mock("node:child_process", () => ({
+ execFile: Object.assign(execFileMock, {
+ [Symbol.for("nodejs.util.promisify.custom")]: vi.fn(),
+ }),
+}));
const originalVitest = process.env.VITEST;
const originalNodeEnv = process.env.NODE_ENV;
diff --git a/src/infra/os-summary.test.ts b/src/infra/os-summary.test.ts
index b4effb900e02..52400513b6d2 100644
--- a/src/infra/os-summary.test.ts
+++ b/src/infra/os-summary.test.ts
@@ -3,10 +3,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const spawnSyncMock = vi.hoisted(() => vi.fn());
-vi.mock("node:child_process", async () => {
- const { mockNodeChildProcessSpawnSync } = await import("openclaw/plugin-sdk/test-node-mocks");
- return mockNodeChildProcessSpawnSync(spawnSyncMock);
-});
+vi.mock("node:child_process", async (importOriginal) => ({
+ ...(await importOriginal()),
+ spawnSync: spawnSyncMock,
+}));
import { resolveOsSummary } from "./os-summary.js";
diff --git a/src/logging/diagnostic-session-context.test.ts b/src/logging/diagnostic-session-context.test.ts
index 0a5e913a1ee7..a4e214002207 100644
--- a/src/logging/diagnostic-session-context.test.ts
+++ b/src/logging/diagnostic-session-context.test.ts
@@ -2,6 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { saveCronStore } from "../cron/store.js";
import {
formatCronSessionDiagnosticFields,
formatStoppedCronSessionDiagnosticFields,
@@ -45,15 +46,25 @@ describe("diagnostic session context", () => {
});
});
- it("formats cron job and last assistant context for stalled session logs", () => {
+ it("formats cron job and last assistant context for stalled session logs", async () => {
const stateDir = tempDir!;
- fs.mkdirSync(path.join(stateDir, "cron"), { recursive: true });
- fs.writeFileSync(
- path.join(stateDir, "cron", "jobs.json"),
- JSON.stringify({
- jobs: [{ id: "job-123", name: "Twitter Mention Moderation Agent" }],
- }),
- );
+ await saveCronStore(path.join(stateDir, "cron", "jobs.json"), {
+ version: 1,
+ jobs: [
+ {
+ id: "job-123",
+ name: "Twitter Mention Moderation Agent",
+ enabled: true,
+ createdAtMs: 1_700_000_000_000,
+ updatedAtMs: 1_700_000_000_000,
+ schedule: { kind: "every", everyMs: 60_000 },
+ sessionTarget: "main",
+ wakeMode: "next-heartbeat",
+ payload: { kind: "systemEvent", text: "tick" },
+ state: {},
+ },
+ ],
+ });
writeJsonl(path.join(stateDir, "agents", "clawblocker", "sessions", "run-456.jsonl"), [
{ message: { role: "user", content: "run" } },
{
diff --git a/src/logging/diagnostic-session-context.ts b/src/logging/diagnostic-session-context.ts
index 9a0bbf342766..c110216f4734 100644
--- a/src/logging/diagnostic-session-context.ts
+++ b/src/logging/diagnostic-session-context.ts
@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
+import { loadCronStoreSync, resolveCronStorePath } from "../cron/store.js";
const SESSION_TAIL_BYTES = 64 * 1024;
const MAX_QUOTED_FIELD_CHARS = 140;
@@ -137,9 +138,8 @@ function readCronJobName(cronJobId: string | undefined): string | undefined {
return undefined;
}
try {
- const raw = fs.readFileSync(path.join(resolveStateDir(), "cron", "jobs.json"), "utf8");
- const parsed = JSON.parse(raw) as { jobs?: Array<{ id?: unknown; name?: unknown }> };
- const job = parsed.jobs?.find((entry) => entry.id === cronJobId);
+ const store = loadCronStoreSync(resolveCronStorePath());
+ const job = store.jobs.find((entry) => entry.id === cronJobId);
return typeof job?.name === "string" && job.name.trim() ? job.name.trim() : undefined;
} catch {
return undefined;
diff --git a/src/logging/diagnostic-stuck-session-recovery.runtime.test.ts b/src/logging/diagnostic-stuck-session-recovery.runtime.test.ts
index b4781229acd8..9656fe8e1fd3 100644
--- a/src/logging/diagnostic-stuck-session-recovery.runtime.test.ts
+++ b/src/logging/diagnostic-stuck-session-recovery.runtime.test.ts
@@ -2,6 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { saveCronStore } from "../cron/store.js";
const mocks = vi.hoisted(() => ({
abortEmbeddedAgentRun: vi.fn(),
@@ -230,13 +231,23 @@ describe("stuck session recovery", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-recovery-context-"));
try {
process.env.OPENCLAW_STATE_DIR = tempDir;
- fs.mkdirSync(path.join(tempDir, "cron"), { recursive: true });
- fs.writeFileSync(
- path.join(tempDir, "cron", "jobs.json"),
- JSON.stringify({
- jobs: [{ id: "job-123", name: "Twitter Mention Moderation Agent" }],
- }),
- );
+ await saveCronStore(path.join(tempDir, "cron", "jobs.json"), {
+ version: 1,
+ jobs: [
+ {
+ id: "job-123",
+ name: "Twitter Mention Moderation Agent",
+ enabled: true,
+ createdAtMs: 1_700_000_000_000,
+ updatedAtMs: 1_700_000_000_000,
+ schedule: { kind: "every", everyMs: 60_000 },
+ sessionTarget: "main",
+ wakeMode: "next-heartbeat",
+ payload: { kind: "systemEvent", text: "tick" },
+ state: {},
+ },
+ ],
+ });
fs.mkdirSync(path.join(tempDir, "agents", "clawblocker", "sessions"), {
recursive: true,
});
diff --git a/src/state/openclaw-state-db.test.ts b/src/state/openclaw-state-db.test.ts
index 14a3a194dd2a..06522bed9833 100644
--- a/src/state/openclaw-state-db.test.ts
+++ b/src/state/openclaw-state-db.test.ts
@@ -2,6 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
+import { readCronRunLogEntriesSync } from "../cron/run-log.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
@@ -63,6 +64,85 @@ describe("openclaw state database", () => {
expect(database.path).toBe(path.join(stateDir, "state", "openclaw.sqlite"));
});
+ it("opens databases with early cron tables before creating cron indexes", () => {
+ const stateDir = createTempStateDir();
+ const databasePath = path.join(stateDir, "state", "openclaw.sqlite");
+ fs.mkdirSync(path.dirname(databasePath), { recursive: true });
+ const { DatabaseSync } = requireNodeSqlite();
+ const db = new DatabaseSync(databasePath);
+ db.exec(`
+ CREATE TABLE cron_jobs (
+ store_key TEXT NOT NULL,
+ job_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ enabled INTEGER NOT NULL,
+ created_at_ms INTEGER NOT NULL,
+ schedule_kind TEXT NOT NULL,
+ session_target TEXT NOT NULL,
+ wake_mode TEXT NOT NULL,
+ payload_kind TEXT NOT NULL,
+ job_json TEXT NOT NULL,
+ updated_at INTEGER NOT NULL,
+ PRIMARY KEY (store_key, job_id)
+ );
+ `);
+ db.close();
+
+ const database = openOpenClawStateDatabase({
+ env: { OPENCLAW_STATE_DIR: stateDir },
+ });
+
+ expect(() =>
+ database.db.prepare("SELECT session_key FROM cron_jobs LIMIT 1").all(),
+ ).not.toThrow();
+ });
+
+ it("opens databases with early cron run-log tables before creating cron indexes", () => {
+ const stateDir = createTempStateDir();
+ const databasePath = path.join(stateDir, "state", "openclaw.sqlite");
+ fs.mkdirSync(path.dirname(databasePath), { recursive: true });
+ const { DatabaseSync } = requireNodeSqlite();
+ const db = new DatabaseSync(databasePath);
+ db.exec(`
+ CREATE TABLE cron_run_logs (
+ store_key TEXT NOT NULL,
+ job_id TEXT NOT NULL,
+ seq INTEGER NOT NULL,
+ ts INTEGER NOT NULL,
+ PRIMARY KEY (store_key, job_id, seq)
+ );
+ `);
+ db.prepare("INSERT INTO cron_run_logs (store_key, job_id, seq, ts) VALUES (?, ?, ?, ?)").run(
+ path.join(stateDir, "cron", "jobs.json"),
+ "legacy-job",
+ 1,
+ 12345,
+ );
+ db.close();
+
+ const database = openOpenClawStateDatabase({
+ env: { OPENCLAW_STATE_DIR: stateDir },
+ });
+
+ expect(() =>
+ database.db.prepare("SELECT status, entry_json FROM cron_run_logs LIMIT 1").all(),
+ ).not.toThrow();
+
+ const previousStateDir = process.env["OPENCLAW_STATE_DIR"];
+ process.env["OPENCLAW_STATE_DIR"] = stateDir;
+ try {
+ expect(
+ readCronRunLogEntriesSync(path.join(stateDir, "cron", "runs", "legacy-job.jsonl")),
+ ).toMatchObject([{ action: "finished", jobId: "legacy-job", ts: 12345 }]);
+ } finally {
+ if (previousStateDir === undefined) {
+ delete process.env["OPENCLAW_STATE_DIR"];
+ } else {
+ process.env["OPENCLAW_STATE_DIR"] = previousStateDir;
+ }
+ }
+ });
+
it("configures durable SQLite connection pragmas", () => {
const stateDir = createTempStateDir();
const database = openOpenClawStateDatabase({
diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts
index 089017b83fba..3a20ade57f8f 100644
--- a/src/state/openclaw-state-db.ts
+++ b/src/state/openclaw-state-db.ts
@@ -115,23 +115,138 @@ function tableHasColumn(db: DatabaseSync, tableName: string, columnName: string)
return rows.some((row) => row.name === columnName);
}
+function tableExists(db: DatabaseSync, tableName: string): boolean {
+ const row = db
+ .prepare("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?")
+ .get(tableName) as { ok?: unknown } | undefined;
+ return row?.ok === 1;
+}
+
function ensureColumn(db: DatabaseSync, tableName: string, columnSql: string): void {
const columnName = columnSql.trim().split(/\s+/, 1)[0];
- if (!columnName || tableHasColumn(db, tableName, columnName)) {
+ if (!columnName || !tableExists(db, tableName) || tableHasColumn(db, tableName, columnName)) {
return;
}
db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnSql};`);
}
+function backfillCronRunLogEntryJson(db: DatabaseSync): void {
+ if (!tableExists(db, "cron_run_logs") || !tableHasColumn(db, "cron_run_logs", "entry_json")) {
+ return;
+ }
+ const rows = db
+ .prepare(
+ `SELECT store_key, job_id, seq, ts
+ FROM cron_run_logs
+ WHERE entry_json = '{}'`,
+ )
+ .all() as Array<{
+ store_key: string;
+ job_id: string;
+ seq: number | bigint;
+ ts: number | bigint;
+ }>;
+ if (rows.length === 0) {
+ return;
+ }
+ const update = db.prepare(
+ `UPDATE cron_run_logs
+ SET entry_json = ?
+ WHERE store_key = ? AND job_id = ? AND seq = ?`,
+ );
+ for (const row of rows) {
+ update.run(
+ JSON.stringify({ ts: Number(row.ts), jobId: row.job_id, action: "finished" }),
+ row.store_key,
+ row.job_id,
+ row.seq,
+ );
+ }
+}
+
function ensureAdditiveStateColumns(db: DatabaseSync): void {
ensureColumn(db, "node_pairing_pending", "client_id TEXT");
ensureColumn(db, "node_pairing_pending", "client_mode TEXT");
ensureColumn(db, "node_pairing_paired", "client_id TEXT");
ensureColumn(db, "node_pairing_paired", "client_mode TEXT");
+ ensureColumn(db, "cron_run_logs", "status TEXT");
+ ensureColumn(db, "cron_run_logs", "error TEXT");
+ ensureColumn(db, "cron_run_logs", "summary TEXT");
+ ensureColumn(db, "cron_run_logs", "diagnostics_summary TEXT");
+ ensureColumn(db, "cron_run_logs", "delivery_status TEXT");
+ ensureColumn(db, "cron_run_logs", "delivery_error TEXT");
+ ensureColumn(db, "cron_run_logs", "delivered INTEGER");
+ ensureColumn(db, "cron_run_logs", "session_id TEXT");
+ ensureColumn(db, "cron_run_logs", "session_key TEXT");
+ ensureColumn(db, "cron_run_logs", "run_id TEXT");
+ ensureColumn(db, "cron_run_logs", "run_at_ms INTEGER");
+ ensureColumn(db, "cron_run_logs", "duration_ms INTEGER");
+ ensureColumn(db, "cron_run_logs", "next_run_at_ms INTEGER");
+ ensureColumn(db, "cron_run_logs", "model TEXT");
+ ensureColumn(db, "cron_run_logs", "provider TEXT");
+ ensureColumn(db, "cron_run_logs", "total_tokens INTEGER");
+ ensureColumn(db, "cron_run_logs", "entry_json TEXT NOT NULL DEFAULT '{}'");
+ ensureColumn(db, "cron_run_logs", "created_at INTEGER NOT NULL DEFAULT 0");
+ backfillCronRunLogEntryJson(db);
+ ensureColumn(db, "cron_jobs", "description TEXT");
+ ensureColumn(db, "cron_jobs", "delete_after_run INTEGER");
+ ensureColumn(db, "cron_jobs", "agent_id TEXT");
+ ensureColumn(db, "cron_jobs", "session_key TEXT");
+ ensureColumn(db, "cron_jobs", "schedule_expr TEXT");
+ ensureColumn(db, "cron_jobs", "schedule_tz TEXT");
+ ensureColumn(db, "cron_jobs", "every_ms INTEGER");
+ ensureColumn(db, "cron_jobs", "anchor_ms INTEGER");
+ ensureColumn(db, "cron_jobs", "at TEXT");
+ ensureColumn(db, "cron_jobs", "stagger_ms INTEGER");
+ ensureColumn(db, "cron_jobs", "payload_message TEXT");
+ ensureColumn(db, "cron_jobs", "payload_model TEXT");
+ ensureColumn(db, "cron_jobs", "payload_fallbacks_json TEXT");
+ ensureColumn(db, "cron_jobs", "payload_thinking TEXT");
+ ensureColumn(db, "cron_jobs", "payload_timeout_seconds INTEGER");
+ ensureColumn(db, "cron_jobs", "payload_allow_unsafe_external_content INTEGER");
+ ensureColumn(db, "cron_jobs", "payload_external_content_source_json TEXT");
+ ensureColumn(db, "cron_jobs", "payload_light_context INTEGER");
+ ensureColumn(db, "cron_jobs", "payload_tools_allow_json TEXT");
+ ensureColumn(db, "cron_jobs", "delivery_mode TEXT");
+ ensureColumn(db, "cron_jobs", "delivery_channel TEXT");
+ ensureColumn(db, "cron_jobs", "delivery_to TEXT");
+ ensureColumn(db, "cron_jobs", "delivery_thread_id TEXT");
+ ensureColumn(db, "cron_jobs", "delivery_account_id TEXT");
+ ensureColumn(db, "cron_jobs", "delivery_best_effort INTEGER");
+ ensureColumn(db, "cron_jobs", "failure_delivery_mode TEXT");
+ ensureColumn(db, "cron_jobs", "failure_delivery_channel TEXT");
+ ensureColumn(db, "cron_jobs", "failure_delivery_to TEXT");
+ ensureColumn(db, "cron_jobs", "failure_delivery_account_id TEXT");
+ ensureColumn(db, "cron_jobs", "failure_alert_disabled INTEGER");
+ ensureColumn(db, "cron_jobs", "failure_alert_after INTEGER");
+ ensureColumn(db, "cron_jobs", "failure_alert_channel TEXT");
+ ensureColumn(db, "cron_jobs", "failure_alert_to TEXT");
+ ensureColumn(db, "cron_jobs", "failure_alert_cooldown_ms INTEGER");
+ ensureColumn(db, "cron_jobs", "failure_alert_include_skipped INTEGER");
+ ensureColumn(db, "cron_jobs", "failure_alert_mode TEXT");
+ ensureColumn(db, "cron_jobs", "failure_alert_account_id TEXT");
+ ensureColumn(db, "cron_jobs", "next_run_at_ms INTEGER");
+ ensureColumn(db, "cron_jobs", "running_at_ms INTEGER");
+ ensureColumn(db, "cron_jobs", "last_run_at_ms INTEGER");
+ ensureColumn(db, "cron_jobs", "last_run_status TEXT");
+ ensureColumn(db, "cron_jobs", "last_error TEXT");
+ ensureColumn(db, "cron_jobs", "last_duration_ms INTEGER");
+ ensureColumn(db, "cron_jobs", "consecutive_errors INTEGER");
+ ensureColumn(db, "cron_jobs", "consecutive_skipped INTEGER");
+ ensureColumn(db, "cron_jobs", "schedule_error_count INTEGER");
+ ensureColumn(db, "cron_jobs", "last_delivery_status TEXT");
+ ensureColumn(db, "cron_jobs", "last_delivery_error TEXT");
+ ensureColumn(db, "cron_jobs", "last_delivered INTEGER");
+ ensureColumn(db, "cron_jobs", "last_failure_alert_at_ms INTEGER");
+ ensureColumn(db, "cron_jobs", "state_json TEXT NOT NULL DEFAULT '{}'");
+ ensureColumn(db, "cron_jobs", "runtime_updated_at_ms INTEGER");
+ ensureColumn(db, "cron_jobs", "schedule_identity TEXT");
+ ensureColumn(db, "cron_jobs", "sort_order INTEGER NOT NULL DEFAULT 0");
}
function ensureSchema(db: DatabaseSync, pathname: string): void {
assertSupportedSchemaVersion(db, pathname);
+ ensureAdditiveStateColumns(db);
db.exec(OPENCLAW_STATE_SCHEMA_SQL);
ensureAdditiveStateColumns(db);
db.exec(`PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION};`);