diff --git a/CHANGELOG.md b/CHANGELOG.md index 734ab7e6b57..43af7e32227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) - 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: 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. - 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. diff --git a/src/cron/service.issue-regressions.test.ts b/src/cron/service.issue-regressions.test.ts index 87e98d69b95..bbd64f4c728 100644 --- a/src/cron/service.issue-regressions.test.ts +++ b/src/cron/service.issue-regressions.test.ts @@ -2,7 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import * as schedule from "./schedule.js"; import { CronService } from "./service.js"; +import { computeJobNextRunAtMs } from "./service/jobs.js"; import { createCronServiceState, type CronEvent } from "./service/state.js"; import { onTimer } from "./service/timer.js"; import type { CronJob, CronJobState } from "./types.js"; @@ -593,6 +595,87 @@ describe("Cron issue regressions", () => { 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 () => { const store = await makeStorePath(); const dueAt = Date.parse("2026-02-06T10:05:01.000Z");