* 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 <sline@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
81
src/cron/service.issue-35195-backup-timing.test.ts
Normal file
81
src/cron/service.issue-35195-backup-timing.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,15 @@ export async function loadCronStore(storePath: string): Promise<CronStoreFile> {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user