mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-29 18:12:52 +00:00
323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
hasHeartbeatWakeHandler,
|
|
hasPendingHeartbeatWake,
|
|
requestHeartbeatNow,
|
|
resetHeartbeatWakeStateForTests,
|
|
setHeartbeatWakeHandler,
|
|
} from "./heartbeat-wake.js";
|
|
|
|
describe("heartbeat-wake", () => {
|
|
function setRetryOnceHeartbeatHandler() {
|
|
const handler = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ status: "skipped", reason: "requests-in-flight" })
|
|
.mockResolvedValueOnce({ status: "ran", durationMs: 1 });
|
|
setHeartbeatWakeHandler(handler);
|
|
return handler;
|
|
}
|
|
|
|
async function expectRetryAfterDefaultDelay(params: {
|
|
handler: ReturnType<typeof vi.fn>;
|
|
initialReason: string;
|
|
expectedRetryReason: string;
|
|
}) {
|
|
setHeartbeatWakeHandler(
|
|
params.handler as unknown as Parameters<typeof setHeartbeatWakeHandler>[0],
|
|
);
|
|
requestHeartbeatNow({ reason: params.initialReason, coalesceMs: 0 });
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(params.handler).toHaveBeenCalledTimes(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(params.handler).toHaveBeenCalledTimes(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(params.handler).toHaveBeenCalledTimes(2);
|
|
expect(params.handler.mock.calls[1]?.[0]).toEqual({ reason: params.expectedRetryReason });
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetHeartbeatWakeStateForTests();
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetHeartbeatWakeStateForTests();
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("coalesces multiple wake requests into one run", async () => {
|
|
vi.useFakeTimers();
|
|
const handler = vi.fn().mockResolvedValue({ status: "skipped", reason: "disabled" });
|
|
setHeartbeatWakeHandler(handler);
|
|
|
|
requestHeartbeatNow({ reason: "interval", coalesceMs: 200 });
|
|
requestHeartbeatNow({ reason: "exec-event", coalesceMs: 200 });
|
|
requestHeartbeatNow({ reason: "retry", coalesceMs: 200 });
|
|
|
|
expect(hasPendingHeartbeatWake()).toBe(true);
|
|
|
|
await vi.advanceTimersByTimeAsync(199);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(handler).toHaveBeenCalledWith({ reason: "exec-event" });
|
|
expect(hasPendingHeartbeatWake()).toBe(false);
|
|
});
|
|
|
|
it("retries requests-in-flight after the default retry delay", async () => {
|
|
vi.useFakeTimers();
|
|
const handler = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ status: "skipped", reason: "requests-in-flight" })
|
|
.mockResolvedValueOnce({ status: "ran", durationMs: 1 });
|
|
await expectRetryAfterDefaultDelay({
|
|
handler,
|
|
initialReason: "interval",
|
|
expectedRetryReason: "interval",
|
|
});
|
|
});
|
|
|
|
it("keeps retry cooldown even when a sooner request arrives", async () => {
|
|
vi.useFakeTimers();
|
|
const handler = setRetryOnceHeartbeatHandler();
|
|
|
|
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
|
|
// Retry is now waiting for 1000ms. This should not preempt cooldown.
|
|
requestHeartbeatNow({ reason: "hook:wake", coalesceMs: 0 });
|
|
await vi.advanceTimersByTimeAsync(998);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(handler).toHaveBeenCalledTimes(2);
|
|
expect(handler.mock.calls[1]?.[0]).toEqual({ reason: "hook:wake" });
|
|
});
|
|
|
|
it("retries thrown handler errors after the default retry delay", async () => {
|
|
vi.useFakeTimers();
|
|
const handler = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error("boom"))
|
|
.mockResolvedValueOnce({ status: "skipped", reason: "disabled" });
|
|
await expectRetryAfterDefaultDelay({
|
|
handler,
|
|
initialReason: "exec-event",
|
|
expectedRetryReason: "exec-event",
|
|
});
|
|
});
|
|
|
|
it("stale disposer does not clear a newer handler", async () => {
|
|
vi.useFakeTimers();
|
|
const handlerA = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
|
|
const handlerB = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
|
|
|
|
// Runner A registers its handler
|
|
const disposeA = setHeartbeatWakeHandler(handlerA);
|
|
|
|
// Runner B registers its handler (replaces A)
|
|
const disposeB = setHeartbeatWakeHandler(handlerB);
|
|
|
|
// Runner A's stale cleanup runs — should NOT clear handlerB
|
|
disposeA();
|
|
expect(hasHeartbeatWakeHandler()).toBe(true);
|
|
|
|
// handlerB should still work
|
|
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(handlerB).toHaveBeenCalledTimes(1);
|
|
expect(handlerA).not.toHaveBeenCalled();
|
|
|
|
// Runner B's dispose should work
|
|
disposeB();
|
|
expect(hasHeartbeatWakeHandler()).toBe(false);
|
|
});
|
|
|
|
it("preempts existing timer when a sooner schedule is requested", async () => {
|
|
vi.useFakeTimers();
|
|
const handler = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
|
|
setHeartbeatWakeHandler(handler);
|
|
|
|
// Schedule for 5 seconds from now
|
|
requestHeartbeatNow({ reason: "slow", coalesceMs: 5000 });
|
|
|
|
// Schedule for 100ms from now — should preempt the 5s timer
|
|
requestHeartbeatNow({ reason: "fast", coalesceMs: 100 });
|
|
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
// The reason should be "fast" since it was set last
|
|
expect(handler).toHaveBeenCalledWith({ reason: "fast" });
|
|
});
|
|
|
|
it("keeps existing timer when later schedule is requested", async () => {
|
|
vi.useFakeTimers();
|
|
const handler = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
|
|
setHeartbeatWakeHandler(handler);
|
|
|
|
// Schedule for 100ms from now
|
|
requestHeartbeatNow({ reason: "fast", coalesceMs: 100 });
|
|
|
|
// Schedule for 5 seconds from now — should NOT preempt
|
|
requestHeartbeatNow({ reason: "slow", coalesceMs: 5000 });
|
|
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not downgrade a higher-priority pending reason", async () => {
|
|
vi.useFakeTimers();
|
|
const handler = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
|
|
setHeartbeatWakeHandler(handler);
|
|
|
|
requestHeartbeatNow({ reason: "exec-event", coalesceMs: 100 });
|
|
requestHeartbeatNow({ reason: "retry", coalesceMs: 100 });
|
|
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(handler).toHaveBeenCalledWith({ reason: "exec-event" });
|
|
});
|
|
|
|
it("resets running/scheduled flags when new handler is registered", async () => {
|
|
vi.useFakeTimers();
|
|
|
|
// Simulate a handler that's mid-execution when SIGUSR1 fires.
|
|
// We do this by having the handler hang forever (never resolve).
|
|
let resolveHang: () => void;
|
|
const hangPromise = new Promise<void>((r) => {
|
|
resolveHang = r;
|
|
});
|
|
const handlerA = vi
|
|
.fn()
|
|
.mockReturnValue(hangPromise.then(() => ({ status: "ran" as const, durationMs: 1 })));
|
|
setHeartbeatWakeHandler(handlerA);
|
|
|
|
// Trigger the handler — it starts running but never finishes
|
|
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(handlerA).toHaveBeenCalledTimes(1);
|
|
|
|
// Now simulate SIGUSR1: register a new handler while handlerA is still running.
|
|
// Without the fix, `running` would stay true and handlerB would never fire.
|
|
const handlerB = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
|
|
setHeartbeatWakeHandler(handlerB);
|
|
|
|
// handlerB should be able to fire (running was reset)
|
|
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(handlerB).toHaveBeenCalledTimes(1);
|
|
|
|
// Clean up the hanging promise
|
|
resolveHang!();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
it("clears stale retry cooldown when a new handler is registered", async () => {
|
|
vi.useFakeTimers();
|
|
const handlerA = vi.fn().mockResolvedValue({ status: "skipped", reason: "requests-in-flight" });
|
|
setHeartbeatWakeHandler(handlerA);
|
|
|
|
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(handlerA).toHaveBeenCalledTimes(1);
|
|
|
|
// Simulate SIGUSR1 startup with a fresh wake handler.
|
|
const handlerB = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
|
|
setHeartbeatWakeHandler(handlerB);
|
|
|
|
requestHeartbeatNow({ reason: "manual", coalesceMs: 0 });
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(handlerB).toHaveBeenCalledTimes(1);
|
|
expect(handlerB).toHaveBeenCalledWith({ reason: "manual" });
|
|
});
|
|
|
|
it("drains pending wake once a handler is registered", async () => {
|
|
vi.useFakeTimers();
|
|
|
|
requestHeartbeatNow({ reason: "manual", coalesceMs: 0 });
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(hasPendingHeartbeatWake()).toBe(true);
|
|
|
|
const handler = vi.fn().mockResolvedValue({ status: "skipped", reason: "disabled" });
|
|
setHeartbeatWakeHandler(handler);
|
|
|
|
await vi.advanceTimersByTimeAsync(249);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(handler).toHaveBeenCalledWith({ reason: "manual" });
|
|
expect(hasPendingHeartbeatWake()).toBe(false);
|
|
});
|
|
|
|
it("forwards wake target fields and preserves them across retries", async () => {
|
|
vi.useFakeTimers();
|
|
const handler = setRetryOnceHeartbeatHandler();
|
|
|
|
requestHeartbeatNow({
|
|
reason: "cron:job-1",
|
|
agentId: "ops",
|
|
sessionKey: "agent:ops:discord:channel:alerts",
|
|
coalesceMs: 0,
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(handler.mock.calls[0]?.[0]).toEqual({
|
|
reason: "cron:job-1",
|
|
agentId: "ops",
|
|
sessionKey: "agent:ops:discord:channel:alerts",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(handler).toHaveBeenCalledTimes(2);
|
|
expect(handler.mock.calls[1]?.[0]).toEqual({
|
|
reason: "cron:job-1",
|
|
agentId: "ops",
|
|
sessionKey: "agent:ops:discord:channel:alerts",
|
|
});
|
|
});
|
|
|
|
it("executes distinct targeted wakes queued in the same coalescing window", async () => {
|
|
vi.useFakeTimers();
|
|
const handler = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
|
|
setHeartbeatWakeHandler(handler);
|
|
|
|
requestHeartbeatNow({
|
|
reason: "cron:job-a",
|
|
agentId: "ops",
|
|
sessionKey: "agent:ops:discord:channel:alerts",
|
|
coalesceMs: 100,
|
|
});
|
|
requestHeartbeatNow({
|
|
reason: "cron:job-b",
|
|
agentId: "main",
|
|
sessionKey: "agent:main:telegram:group:-1001",
|
|
coalesceMs: 100,
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
|
|
expect(handler).toHaveBeenCalledTimes(2);
|
|
expect(handler.mock.calls.map((call) => call[0])).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
reason: "cron:job-a",
|
|
agentId: "ops",
|
|
sessionKey: "agent:ops:discord:channel:alerts",
|
|
},
|
|
{
|
|
reason: "cron:job-b",
|
|
agentId: "main",
|
|
sessionKey: "agent:main:telegram:group:-1001",
|
|
},
|
|
]),
|
|
);
|
|
});
|
|
});
|