mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 20:38:27 +00:00
fix(cron): add spin-loop regression coverage
This commit is contained in:
@@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Agents/Sessions: align session lock watchdog hold windows with run and compaction timeout budgets (plus grace), preventing valid long-running turns from being force-unlocked mid-run while still recovering hung lock owners. (#18060)
|
- Agents/Sessions: align session lock watchdog hold windows with run and compaction timeout budgets (plus grace), preventing valid long-running turns from being force-unlocked mid-run while still recovering hung lock owners. (#18060)
|
||||||
- Cron/Heartbeat: canonicalize session-scoped reminder `sessionKey` routing and preserve explicit flat `sessionKey` cron tool inputs, preventing enqueue/wake namespace drift for session-targeted reminders. (#18637) Thanks @vignesh07.
|
- Cron/Heartbeat: canonicalize session-scoped reminder `sessionKey` routing and preserve explicit flat `sessionKey` cron tool inputs, preventing enqueue/wake namespace drift for session-targeted reminders. (#18637) Thanks @vignesh07.
|
||||||
- Cron/Webhooks: reuse existing session IDs for webhook/cron runs when the session key is stable and still fresh, preserving conversation history. (#18031) Thanks @Operative-001.
|
- Cron/Webhooks: reuse existing session IDs for webhook/cron runs when the session key is stable and still fresh, preserving conversation history. (#18031) Thanks @Operative-001.
|
||||||
|
- Cron: prevent spin loops when cron jobs complete within the scheduled second by advancing the next run and enforcing a minimum refire gap. (#18073) Thanks @widingmarcus-cyber.
|
||||||
- OpenClawKit/iOS ChatUI: accept canonical session-key completion events for local pending runs and preserve message IDs across history refreshes, preventing stuck "thinking" state and message flicker after gateway replies. (#18165) Thanks @mbelinky.
|
- OpenClawKit/iOS ChatUI: accept canonical session-key completion events for local pending runs and preserve message IDs across history refreshes, preventing stuck "thinking" state and message flicker after gateway replies. (#18165) Thanks @mbelinky.
|
||||||
- iOS/Onboarding: add QR-first onboarding wizard with setup-code deep link support, pairing/auth issue guidance, and device-pair QR generation improvements for Telegram/Web/TUI fallback flows. (#18162) Thanks @mbelinky and @Marvae.
|
- iOS/Onboarding: add QR-first onboarding wizard with setup-code deep link support, pairing/auth issue guidance, and device-pair QR generation improvements for Telegram/Web/TUI fallback flows. (#18162) Thanks @mbelinky and @Marvae.
|
||||||
- iOS/Gateway: stabilize connect/discovery state handling, add onboarding reset recovery in Settings, and fix iOS gateway-controller coverage for command-surface and last-connection persistence behavior. (#18164) Thanks @mbelinky.
|
- iOS/Gateway: stabilize connect/discovery state handling, add onboarding reset recovery in Settings, and fix iOS gateway-controller coverage for command-surface and last-connection persistence behavior. (#18164) Thanks @mbelinky.
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import * as schedule from "./schedule.js";
|
||||||
import { CronService } from "./service.js";
|
import { CronService } from "./service.js";
|
||||||
|
import { computeJobNextRunAtMs } from "./service/jobs.js";
|
||||||
import { createCronServiceState, type CronEvent } from "./service/state.js";
|
import { createCronServiceState, type CronEvent } from "./service/state.js";
|
||||||
import { onTimer } from "./service/timer.js";
|
import { onTimer } from "./service/timer.js";
|
||||||
import type { CronJob, CronJobState } from "./types.js";
|
import type { CronJob, CronJobState } from "./types.js";
|
||||||
@@ -593,6 +595,87 @@ describe("Cron issue regressions", () => {
|
|||||||
expect(fireCount).toBe(1);
|
expect(fireCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("enforces a minimum refire gap for second-granularity cron schedules (#17821)", async () => {
|
||||||
|
const store = await makeStorePath();
|
||||||
|
const scheduledAt = Date.parse("2026-02-15T13:00:00.000Z");
|
||||||
|
|
||||||
|
const cronJob: CronJob = {
|
||||||
|
id: "spin-gap-17821",
|
||||||
|
name: "second-granularity",
|
||||||
|
enabled: true,
|
||||||
|
createdAtMs: scheduledAt - 86_400_000,
|
||||||
|
updatedAtMs: scheduledAt - 86_400_000,
|
||||||
|
schedule: { kind: "cron", expr: "* * * * * *", tz: "UTC" },
|
||||||
|
sessionTarget: "isolated",
|
||||||
|
wakeMode: "next-heartbeat",
|
||||||
|
payload: { kind: "agentTurn", message: "pulse" },
|
||||||
|
delivery: { mode: "announce" },
|
||||||
|
state: { nextRunAtMs: scheduledAt },
|
||||||
|
};
|
||||||
|
await fs.writeFile(
|
||||||
|
store.storePath,
|
||||||
|
JSON.stringify({ version: 1, jobs: [cronJob] }, null, 2),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
let now = scheduledAt;
|
||||||
|
const state = createCronServiceState({
|
||||||
|
cronEnabled: true,
|
||||||
|
storePath: store.storePath,
|
||||||
|
log: noopLogger,
|
||||||
|
nowMs: () => now,
|
||||||
|
enqueueSystemEvent: vi.fn(),
|
||||||
|
requestHeartbeatNow: vi.fn(),
|
||||||
|
runIsolatedAgentJob: vi.fn(async () => {
|
||||||
|
now += 100;
|
||||||
|
return { status: "ok" as const, summary: "done" };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await onTimer(state);
|
||||||
|
|
||||||
|
const job = state.store?.jobs.find((j) => j.id === "spin-gap-17821");
|
||||||
|
expect(job).toBeDefined();
|
||||||
|
const endedAt = now;
|
||||||
|
const minNext = endedAt + 2_000;
|
||||||
|
expect(job!.state.nextRunAtMs).toBeDefined();
|
||||||
|
expect(job!.state.nextRunAtMs).toBeGreaterThanOrEqual(minNext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries cron schedule computation from the next second when the first attempt returns undefined (#17821)", () => {
|
||||||
|
const scheduledAt = Date.parse("2026-02-15T13:00:00.000Z");
|
||||||
|
const cronJob: CronJob = {
|
||||||
|
id: "retry-next-second-17821",
|
||||||
|
name: "retry",
|
||||||
|
enabled: true,
|
||||||
|
createdAtMs: scheduledAt - 86_400_000,
|
||||||
|
updatedAtMs: scheduledAt - 86_400_000,
|
||||||
|
schedule: { kind: "cron", expr: "0 13 * * *", tz: "UTC" },
|
||||||
|
sessionTarget: "isolated",
|
||||||
|
wakeMode: "next-heartbeat",
|
||||||
|
payload: { kind: "agentTurn", message: "briefing" },
|
||||||
|
delivery: { mode: "announce" },
|
||||||
|
state: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const original = schedule.computeNextRunAtMs;
|
||||||
|
const spy = vi.spyOn(schedule, "computeNextRunAtMs");
|
||||||
|
try {
|
||||||
|
spy
|
||||||
|
.mockImplementationOnce(() => undefined)
|
||||||
|
.mockImplementation((sched, nowMs) => original(sched, nowMs));
|
||||||
|
|
||||||
|
const expected = original(cronJob.schedule, scheduledAt + 1_000);
|
||||||
|
expect(expected).toBeDefined();
|
||||||
|
|
||||||
|
const next = computeJobNextRunAtMs(cronJob, scheduledAt);
|
||||||
|
expect(next).toBe(expected);
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
|
} finally {
|
||||||
|
spy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("records per-job start time and duration for batched due jobs", async () => {
|
it("records per-job start time and duration for batched due jobs", async () => {
|
||||||
const store = await makeStorePath();
|
const store = await makeStorePath();
|
||||||
const dueAt = Date.parse("2026-02-06T10:05:01.000Z");
|
const dueAt = Date.parse("2026-02-06T10:05:01.000Z");
|
||||||
|
|||||||
Reference in New Issue
Block a user