fix: prevent heartbeat scheduler silent death from wake handler race (#15108)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: fd7165b935
Co-authored-by: joeykrug <5925937+joeykrug@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Joseph Krug
2026-02-12 23:30:21 -04:00
committed by GitHub
parent ec44e262be
commit 40aff672c1
5 changed files with 282 additions and 22 deletions

View File

@@ -87,6 +87,57 @@ describe("startHeartbeatRunner", () => {
runner.stop();
});
it("cleanup is idempotent and does not clear a newer runner's handler", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
const runSpy1 = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
const runSpy2 = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
const cfg = {
agents: { defaults: { heartbeat: { every: "30m" } } },
} as OpenClawConfig;
// Start runner A
const runnerA = startHeartbeatRunner({ cfg, runOnce: runSpy1 });
// Start runner B (simulates lifecycle reload)
const runnerB = startHeartbeatRunner({ cfg, runOnce: runSpy2 });
// Stop runner A (stale cleanup) — should NOT kill runner B's handler
runnerA.stop();
// Runner B should still fire
await vi.advanceTimersByTimeAsync(30 * 60_000 + 1_000);
expect(runSpy2).toHaveBeenCalledTimes(1);
expect(runSpy1).not.toHaveBeenCalled();
// Double-stop should be safe (idempotent)
runnerA.stop();
runnerB.stop();
});
it("run() returns skipped when runner is stopped", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
const runner = startHeartbeatRunner({
cfg: {
agents: { defaults: { heartbeat: { every: "30m" } } },
} as OpenClawConfig,
runOnce: runSpy,
});
runner.stop();
// After stopping, no heartbeats should fire
await vi.advanceTimersByTimeAsync(60 * 60_000);
expect(runSpy).not.toHaveBeenCalled();
});
it("reschedules timer when runOnce returns requests-in-flight", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));