



























1+// Verifies the configured retry.backoffMs floor for a recurring job survives a
2+// real cron service run and is persisted to the SQLite-backed store, not just
3+// computed in memory by applyJobResult.
4+import path from "node:path";
5+import { describe, expect, it, vi } from "vitest";
6+import { resetTaskRegistryForTests } from "../../tasks/task-registry.js";
7+import { withEnvAsync } from "../../test-utils/env.js";
8+import { setupCronServiceSuite, writeCronStoreSnapshot } from "../service.test-harness.js";
9+import { loadCronStore } from "../store.js";
10+import type { CronJob } from "../types.js";
11+import { run } from "./ops.js";
12+import { createCronServiceState } from "./state.js";
13+14+const { logger, makeStorePath } = setupCronServiceSuite({
15+prefix: "cron-backoff-config-readback",
16+});
17+18+function createDueRecurringJob(now: number): CronJob {
19+return {
20+id: "recurring-backoff-readback",
21+name: "recurring backoff readback",
22+enabled: true,
23+createdAtMs: now - 60_000,
24+updatedAtMs: now - 60_000,
25+schedule: { kind: "every", everyMs: 1_000, anchorMs: now - 60_000 },
26+sessionTarget: "isolated",
27+wakeMode: "next-heartbeat",
28+payload: { kind: "agentTurn", message: "ping" },
29+sessionKey: "agent:main:main",
30+state: { nextRunAtMs: now - 1 },
31+};
32+}
33+34+describe("recurring error backoff floor persistence", () => {
35+it("persists the configured retry.backoffMs floor across a real run and SQLite readback", async () => {
36+const now = Date.parse("2026-03-02T12:00:00.000Z");
37+const { storePath } = await makeStorePath();
38+const stateRoot = path.dirname(path.dirname(storePath));
39+const job = createDueRecurringJob(now);
40+41+let persistedJob: CronJob | undefined;
42+resetTaskRegistryForTests();
43+try {
44+await withEnvAsync({ OPENCLAW_STATE_DIR: stateRoot }, async () => {
45+await writeCronStoreSnapshot({ storePath, jobs: [job] });
46+47+const state = createCronServiceState({
48+ storePath,
49+cronEnabled: true,
50+log: logger,
51+nowMs: () => now,
52+enqueueSystemEvent: vi.fn(),
53+requestHeartbeat: vi.fn(),
54+// Permanent (non-retryable) error -> recurring safety-net backoff
55+// floor, the branch that must honor the configured backoffMs.
56+runIsolatedAgentJob: vi.fn(async () => {
57+throw new Error("permanent: bad request");
58+}),
59+cronConfig: { retry: { backoffMs: [300_000] } },
60+});
61+62+// mode "due" (not "force") keeps preserveSchedule false, so the error
63+// path computes the safety-net backoff floor rather than preserving the
64+// recurring anchor.
65+await expect(run(state, job.id, "due")).resolves.toEqual({ ok: true, ran: true });
66+67+const persisted = (await loadCronStore(storePath)) as { jobs: CronJob[] };
68+persistedJob = persisted.jobs.find((entry) => entry.id === job.id);
69+});
70+} finally {
71+resetTaskRegistryForTests();
72+}
73+74+// The floor read back from the SQLite-backed store must be endedAt(=now) +
75+// the configured backoffMs[0], not the hardcoded 30000 default.
76+expect(persistedJob?.state.nextRunAtMs).toBe(now + 300_000);
77+expect(persistedJob?.state.lastStatus).toBe("error");
78+expect(persistedJob?.state.consecutiveErrors).toBe(1);
79+});
80+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。