From 1059b406a8708d3211256ed9e639cda96b2ab953 Mon Sep 17 00:00:00 2001 From: sline Date: Thu, 5 Mar 2026 11:46:27 +0800 Subject: [PATCH] fix: cron backup should preserve pre-edit snapshot (#35195) (#35234) * fix(cron): avoid overwriting .bak during normalization Fixes openclaw/openclaw#35195 * test(cron): preserve pre-edit bak snapshot in normalization path --------- Co-authored-by: 0xsline Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + .../service.issue-35195-backup-timing.test.ts | 81 +++++++++++++++++++ src/cron/service/store.ts | 6 +- src/cron/store.ts | 12 ++- 4 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 src/cron/service.issue-35195-backup-timing.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4736be5d8..cf9e30958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Agents/tool-result truncation: preserve important tail diagnostics by using head+tail truncation for oversized tool results while keeping configurable truncation options. (#20076) thanks @jlwestsr. - Telegram/topic agent routing: support per-topic `agentId` overrides in forum groups and DM topics so topics can route to dedicated agents with isolated sessions. (#33647; based on #31513) Thanks @kesor and @Sid-Qin. - Slack/DM typing feedback: add `channels.slack.typingReaction` so Socket Mode DMs can show reaction-based processing status even when Slack native assistant typing is unavailable. (#19816) Thanks @dalefrieswthat. +- Cron/job snapshot persistence: skip backup during normalization persistence in `ensureLoaded` so `jobs.json.bak` keeps the pre-edit snapshot for recovery, while preserving backup creation on explicit user-driven writes. (#35234) Thanks @0xsline. ### Fixes diff --git a/src/cron/service.issue-35195-backup-timing.test.ts b/src/cron/service.issue-35195-backup-timing.test.ts new file mode 100644 index 000000000..c8e965f1f --- /dev/null +++ b/src/cron/service.issue-35195-backup-timing.test.ts @@ -0,0 +1,81 @@ +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"; + +const noopLogger = createNoopLogger(); +const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-issue-35195-" }); + +describe("cron backup timing for edit", () => { + it("keeps .bak as the pre-edit store even after later normalization persists", 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: {}, + }, + ]); + + const service = new CronService({ + storePath: store.storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent: vi.fn(), + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })), + }); + + await service.start(); + + const beforeEditRaw = await fs.readFile(store.storePath, "utf-8"); + + 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: "telegram", + }, + }; + + await writeCronStoreSnapshot(store.storePath, [normalizedJob]); + + service.stop(); + const service2 = new CronService({ + storePath: store.storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent: vi.fn(), + requestHeartbeatNow: 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(); + }); +}); diff --git a/src/cron/service/store.ts b/src/cron/service/store.ts index 693c18141..dca0bde2e 100644 --- a/src/cron/service/store.ts +++ b/src/cron/service/store.ts @@ -543,7 +543,7 @@ export async function ensureLoaded( } if (mutated) { - await persist(state); + await persist(state, { skipBackup: true }); } } @@ -561,11 +561,11 @@ export function warnIfDisabled(state: CronServiceState, action: string) { ); } -export async function persist(state: CronServiceState) { +export async function persist(state: CronServiceState, opts?: { skipBackup?: boolean }) { if (!state.store) { return; } - await saveCronStore(state.deps.storePath, state.store); + await saveCronStore(state.deps.storePath, state.store, opts); // Update file mtime after save to prevent immediate reload state.storeFileMtimeMs = await getFileMtimeMs(state.deps.storePath); } diff --git a/src/cron/store.ts b/src/cron/store.ts index 6f0e3e409..70fd978aa 100644 --- a/src/cron/store.ts +++ b/src/cron/store.ts @@ -52,7 +52,15 @@ export async function loadCronStore(storePath: string): Promise { } } -export async function saveCronStore(storePath: string, store: CronStoreFile) { +type SaveCronStoreOptions = { + skipBackup?: boolean; +}; + +export async function saveCronStore( + storePath: string, + store: CronStoreFile, + opts?: SaveCronStoreOptions, +) { await fs.promises.mkdir(path.dirname(storePath), { recursive: true }); const json = JSON.stringify(store, null, 2); const cached = serializedStoreCache.get(storePath); @@ -76,7 +84,7 @@ export async function saveCronStore(storePath: string, store: CronStoreFile) { } const tmp = `${storePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`; await fs.promises.writeFile(tmp, json, "utf-8"); - if (previous !== null) { + if (previous !== null && !opts?.skipBackup) { try { await fs.promises.copyFile(storePath, `${storePath}.bak`); } catch {