refactor: dedupe cli config cron and install flows

This commit is contained in:
Peter Steinberger
2026-03-02 19:48:38 +00:00
parent 9d30159fcd
commit b1c30f0ba9
80 changed files with 1379 additions and 2027 deletions

View File

@@ -3,8 +3,14 @@ import fs from "node:fs/promises";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { createCliDeps } from "./isolated-agent.delivery.test-helpers.js";
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
import { makeCfg, makeJob, withTempCronHome } from "./isolated-agent.test-harness.js";
import {
makeCfg,
makeJob,
withTempCronHome,
writeSessionStore,
} from "./isolated-agent.test-harness.js";
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
describe("runCronIsolatedAgentTurn auth profile propagation (#20624)", () => {
@@ -14,26 +20,7 @@ describe("runCronIsolatedAgentTurn auth profile propagation (#20624)", () => {
it("passes authProfileId to runEmbeddedPiAgent when auth profiles exist", async () => {
await withTempCronHome(async (home) => {
// 1. Write session store
const sessionsDir = path.join(home, ".openclaw", "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const storePath = path.join(sessionsDir, "sessions.json");
await fs.writeFile(
storePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "main-session",
updatedAt: Date.now(),
lastProvider: "webchat",
lastTo: "",
},
},
null,
2,
),
"utf-8",
);
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
// 2. Write auth-profiles.json in the agent directory
// resolveAgentDir returns <stateDir>/agents/main/agent
@@ -79,14 +66,7 @@ describe("runCronIsolatedAgentTurn auth profile propagation (#20624)", () => {
const res = await runCronIsolatedAgentTurn({
cfg,
deps: {
sendMessageSlack: vi.fn(),
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
},
deps: createCliDeps(),
job: makeJob({ kind: "agentTurn", message: "check status", deliver: false }),
message: "check status",
sessionKey: "cron:job-1",

View File

@@ -1,8 +1,6 @@
import "./isolated-agent.mocks.js";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
import type { CliDeps } from "../cli/deps.js";
@@ -10,56 +8,8 @@ import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
import { makeCfg, makeJob, writeSessionStore } from "./isolated-agent.test-harness.js";
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
let tempRoot = "";
let tempHomeId = 0;
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
if (!tempRoot) {
throw new Error("temp root not initialized");
}
const home = path.join(tempRoot, `case-${tempHomeId++}`);
await fs.mkdir(path.join(home, ".openclaw", "agents", "main", "sessions"), {
recursive: true,
});
const snapshot = {
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
HOMEDRIVE: process.env.HOMEDRIVE,
HOMEPATH: process.env.HOMEPATH,
OPENCLAW_HOME: process.env.OPENCLAW_HOME,
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
};
process.env.HOME = home;
process.env.USERPROFILE = home;
delete process.env.OPENCLAW_HOME;
process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw");
if (process.platform === "win32") {
const driveMatch = home.match(/^([A-Za-z]:)(.*)$/);
if (driveMatch) {
process.env.HOMEDRIVE = driveMatch[1];
process.env.HOMEPATH = driveMatch[2] || "\\";
}
}
try {
return await fn(home);
} finally {
const restoreKey = (key: keyof typeof snapshot) => {
const value = snapshot[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
};
restoreKey("HOME");
restoreKey("USERPROFILE");
restoreKey("HOMEDRIVE");
restoreKey("HOMEPATH");
restoreKey("OPENCLAW_HOME");
restoreKey("OPENCLAW_STATE_DIR");
}
return withTempHomeBase(fn, { prefix: "openclaw-cron-heartbeat-suite-" });
}
async function createTelegramDeliveryFixture(home: string): Promise<{
@@ -120,17 +70,6 @@ async function runTelegramAnnounceTurn(params: {
}
describe("runCronIsolatedAgentTurn", () => {
beforeAll(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-heartbeat-suite-"));
});
afterAll(async () => {
if (!tempRoot) {
return;
}
await fs.rm(tempRoot, { recursive: true, force: true });
});
beforeEach(() => {
setupIsolatedAgentTurnMocks({ fast: true });
});

View File

@@ -1,4 +1,8 @@
import { vi } from "vitest";
import {
makeIsolatedAgentJobFixture,
makeIsolatedAgentParamsFixture,
} from "./isolated-agent/job-fixtures.js";
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
@@ -22,28 +26,5 @@ vi.mock("../agents/subagent-announce.js", () => ({
runSubagentAnnounceFlow: vi.fn(),
}));
type LooseRecord = Record<string, unknown>;
export function makeIsolatedAgentJob(overrides?: LooseRecord) {
return {
id: "test-job",
name: "Test Job",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
sessionTarget: "isolated",
payload: { kind: "agentTurn", message: "test" },
...overrides,
} as never;
}
export function makeIsolatedAgentParams(overrides?: LooseRecord) {
const jobOverrides =
overrides && "job" in overrides ? (overrides.job as LooseRecord | undefined) : undefined;
return {
cfg: {},
deps: {} as never,
job: makeIsolatedAgentJob(jobOverrides),
message: "test",
sessionKey: "cron:test",
...overrides,
};
}
export const makeIsolatedAgentJob = makeIsolatedAgentJobFixture;
export const makeIsolatedAgentParams = makeIsolatedAgentParamsFixture;

View File

@@ -1,8 +1,7 @@
import "./isolated-agent.mocks.js";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
import type { CliDeps } from "../cli/deps.js";
import {
@@ -14,56 +13,8 @@ import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
import { makeCfg, makeJob, writeSessionStore } from "./isolated-agent.test-harness.js";
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
let tempRoot = "";
let tempHomeId = 0;
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
if (!tempRoot) {
throw new Error("temp root not initialized");
}
const home = path.join(tempRoot, `case-${tempHomeId++}`);
await fs.mkdir(path.join(home, ".openclaw", "agents", "main", "sessions"), {
recursive: true,
});
const snapshot = {
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
HOMEDRIVE: process.env.HOMEDRIVE,
HOMEPATH: process.env.HOMEPATH,
OPENCLAW_HOME: process.env.OPENCLAW_HOME,
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
};
process.env.HOME = home;
process.env.USERPROFILE = home;
delete process.env.OPENCLAW_HOME;
process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw");
if (process.platform === "win32") {
const driveMatch = home.match(/^([A-Za-z]:)(.*)$/);
if (driveMatch) {
process.env.HOMEDRIVE = driveMatch[1];
process.env.HOMEPATH = driveMatch[2] || "\\";
}
}
try {
return await fn(home);
} finally {
const restoreKey = (key: keyof typeof snapshot) => {
const value = snapshot[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
};
restoreKey("HOME");
restoreKey("USERPROFILE");
restoreKey("HOMEDRIVE");
restoreKey("HOMEPATH");
restoreKey("OPENCLAW_HOME");
restoreKey("OPENCLAW_STATE_DIR");
}
return withTempHomeBase(fn, { prefix: "openclaw-cron-delivery-suite-" });
}
async function runExplicitTelegramAnnounceTurn(params: {
@@ -216,17 +167,6 @@ async function assertExplicitTelegramTargetAnnounce(params: {
}
describe("runCronIsolatedAgentTurn", () => {
beforeAll(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-delivery-suite-"));
});
afterAll(async () => {
if (!tempRoot) {
return;
}
await fs.rm(tempRoot, { recursive: true, force: true });
});
beforeEach(() => {
setupIsolatedAgentTurnMocks();
});

View File

@@ -1,8 +1,8 @@
import "./isolated-agent.mocks.js";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import type { CliDeps } from "../cli/deps.js";
@@ -15,56 +15,8 @@ import {
} from "./isolated-agent.test-harness.js";
import type { CronJob } from "./types.js";
let tempRoot = "";
let tempHomeId = 0;
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
if (!tempRoot) {
throw new Error("temp root not initialized");
}
const home = path.join(tempRoot, `case-${tempHomeId++}`);
await fs.mkdir(path.join(home, ".openclaw", "agents", "main", "sessions"), {
recursive: true,
});
const snapshot = {
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
HOMEDRIVE: process.env.HOMEDRIVE,
HOMEPATH: process.env.HOMEPATH,
OPENCLAW_HOME: process.env.OPENCLAW_HOME,
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
};
process.env.HOME = home;
process.env.USERPROFILE = home;
delete process.env.OPENCLAW_HOME;
process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw");
if (process.platform === "win32") {
const driveMatch = home.match(/^([A-Za-z]:)(.*)$/);
if (driveMatch) {
process.env.HOMEDRIVE = driveMatch[1];
process.env.HOMEPATH = driveMatch[2] || "\\";
}
}
try {
return await fn(home);
} finally {
const restoreKey = (key: keyof typeof snapshot) => {
const value = snapshot[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
};
restoreKey("HOME");
restoreKey("USERPROFILE");
restoreKey("HOMEDRIVE");
restoreKey("HOMEPATH");
restoreKey("OPENCLAW_HOME");
restoreKey("OPENCLAW_STATE_DIR");
}
return withTempHomeBase(fn, { prefix: "openclaw-cron-turn-suite-" });
}
function makeDeps(): CliDeps {
@@ -201,17 +153,6 @@ async function runTurnWithStoredModelOverride(
}
describe("runCronIsolatedAgentTurn", () => {
beforeAll(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-turn-suite-"));
});
afterAll(async () => {
if (!tempRoot) {
return;
}
await fs.rm(tempRoot, { recursive: true, force: true });
});
beforeEach(() => {
vi.mocked(runEmbeddedPiAgent).mockClear();
vi.mocked(loadModelCatalog).mockResolvedValue([]);

View File

@@ -0,0 +1,25 @@
type LooseRecord = Record<string, unknown>;
export function makeIsolatedAgentJobFixture(overrides?: LooseRecord) {
return {
id: "test-job",
name: "Test Job",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
sessionTarget: "isolated",
payload: { kind: "agentTurn", message: "test" },
...overrides,
} as never;
}
export function makeIsolatedAgentParamsFixture(overrides?: LooseRecord) {
const jobOverrides =
overrides && "job" in overrides ? (overrides.job as LooseRecord | undefined) : undefined;
return {
cfg: {},
deps: {} as never,
job: makeIsolatedAgentJobFixture(jobOverrides),
message: "test",
sessionKey: "cron:test",
...overrides,
};
}

View File

@@ -1,53 +1,21 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import {
makeIsolatedAgentTurnJob,
makeIsolatedAgentTurnParams,
setupRunCronIsolatedAgentTurnSuite,
} from "./run.suite-helpers.js";
import {
clearFastTestEnv,
loadRunCronIsolatedAgentTurn,
makeCronSession,
resolveAgentModelFallbacksOverrideMock,
resolveCronSessionMock,
resetRunCronIsolatedAgentTurnHarness,
restoreFastTestEnv,
runWithModelFallbackMock,
} from "./run.test-harness.js";
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
function makePayloadJob(overrides?: Record<string, unknown>) {
return {
id: "test-job",
name: "Test Job",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
sessionTarget: "isolated",
payload: { kind: "agentTurn", message: "test" },
...overrides,
} as never;
}
function makePayloadParams(overrides?: Record<string, unknown>) {
return {
cfg: {},
deps: {} as never,
job: makePayloadJob(overrides?.job as Record<string, unknown> | undefined),
message: "test",
sessionKey: "cron:test",
...overrides,
};
}
// ---------- tests ----------
describe("runCronIsolatedAgentTurn — payload.fallbacks", () => {
let previousFastTestEnv: string | undefined;
beforeEach(() => {
previousFastTestEnv = clearFastTestEnv();
resetRunCronIsolatedAgentTurnHarness();
resolveCronSessionMock.mockReturnValue(makeCronSession());
});
afterEach(() => {
restoreFastTestEnv(previousFastTestEnv);
});
setupRunCronIsolatedAgentTurnSuite();
it.each([
{
@@ -77,8 +45,8 @@ describe("runCronIsolatedAgentTurn — payload.fallbacks", () => {
}
const result = await runCronIsolatedAgentTurn(
makePayloadParams({
job: makePayloadJob({ payload }),
makeIsolatedAgentTurnParams({
job: makeIsolatedAgentTurnJob({ payload }),
}),
);

View File

@@ -1,62 +1,34 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import {
makeIsolatedAgentTurnJob,
makeIsolatedAgentTurnParams,
setupRunCronIsolatedAgentTurnSuite,
} from "./run.suite-helpers.js";
import {
buildWorkspaceSkillSnapshotMock,
clearFastTestEnv,
getCliSessionIdMock,
isCliProviderMock,
loadRunCronIsolatedAgentTurn,
logWarnMock,
makeCronSession,
resolveAgentConfigMock,
resolveAgentSkillsFilterMock,
resolveAllowedModelRefMock,
resolveCronSessionMock,
resetRunCronIsolatedAgentTurnHarness,
restoreFastTestEnv,
runCliAgentMock,
runWithModelFallbackMock,
} from "./run.test-harness.js";
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
function makeSkillJob(overrides?: Record<string, unknown>) {
return {
id: "test-job",
name: "Test Job",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
sessionTarget: "isolated",
payload: { kind: "agentTurn", message: "test" },
...overrides,
} as never;
}
function makeSkillParams(overrides?: Record<string, unknown>) {
return {
cfg: {},
deps: {} as never,
job: makeSkillJob(overrides?.job as Record<string, unknown> | undefined),
message: "test",
sessionKey: "cron:test",
...overrides,
};
}
const makeSkillJob = makeIsolatedAgentTurnJob;
const makeSkillParams = makeIsolatedAgentTurnParams;
// ---------- tests ----------
describe("runCronIsolatedAgentTurn — skill filter", () => {
let previousFastTestEnv: string | undefined;
beforeEach(() => {
previousFastTestEnv = clearFastTestEnv();
resetRunCronIsolatedAgentTurnHarness();
resolveCronSessionMock.mockReturnValue(makeCronSession());
});
afterEach(() => {
restoreFastTestEnv(previousFastTestEnv);
});
setupRunCronIsolatedAgentTurnSuite();
async function runSkillFilterCase(overrides?: Record<string, unknown>) {
const result = await runCronIsolatedAgentTurn(makeSkillParams(overrides));
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams(overrides));
expect(result.status).toBe("ok");
return result;
}

View File

@@ -0,0 +1,24 @@
import { afterEach, beforeEach } from "vitest";
import { makeIsolatedAgentJobFixture, makeIsolatedAgentParamsFixture } from "./job-fixtures.js";
import {
clearFastTestEnv,
makeCronSession,
resolveCronSessionMock,
resetRunCronIsolatedAgentTurnHarness,
restoreFastTestEnv,
} from "./run.test-harness.js";
export function setupRunCronIsolatedAgentTurnSuite() {
let previousFastTestEnv: string | undefined;
beforeEach(() => {
previousFastTestEnv = clearFastTestEnv();
resetRunCronIsolatedAgentTurnHarness();
resolveCronSessionMock.mockReturnValue(makeCronSession());
});
afterEach(() => {
restoreFastTestEnv(previousFastTestEnv);
});
}
export const makeIsolatedAgentTurnJob = makeIsolatedAgentJobFixture;
export const makeIsolatedAgentTurnParams = makeIsolatedAgentParamsFixture;

View File

@@ -39,6 +39,30 @@ function createStuckPastDueJob(params: { id: string; nowMs: number; pastDueMs: n
}
describe("CronService - armTimer tight loop prevention", () => {
function extractTimeoutDelays(timeoutSpy: ReturnType<typeof vi.spyOn>) {
const calls = timeoutSpy.mock.calls as Array<[unknown, unknown, ...unknown[]]>;
return calls
.map(([, delay]: [unknown, unknown, ...unknown[]]) => delay)
.filter((d: unknown): d is number => typeof d === "number");
}
function createTimerState(params: {
storePath: string;
now: number;
runIsolatedAgentJob?: () => Promise<{ status: "ok" }>;
}) {
return createCronServiceState({
storePath: params.storePath,
cronEnabled: true,
log: noopLogger,
nowMs: () => params.now,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob:
params.runIsolatedAgentJob ?? vi.fn().mockResolvedValue({ status: "ok" }),
});
}
beforeEach(() => {
noopLogger.debug.mockClear();
noopLogger.info.mockClear();
@@ -55,14 +79,9 @@ describe("CronService - armTimer tight loop prevention", () => {
const now = Date.parse("2026-02-28T12:32:00.000Z");
const pastDueMs = 17 * 60 * 1000; // 17 minutes past due
const state = createCronServiceState({
const state = createTimerState({
storePath: "/tmp/test-cron/jobs.json",
cronEnabled: true,
log: noopLogger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok" }),
now,
});
state.store = {
version: 1,
@@ -72,9 +91,7 @@ describe("CronService - armTimer tight loop prevention", () => {
armTimer(state);
expect(state.timer).not.toBeNull();
const delays = timeoutSpy.mock.calls
.map(([, delay]) => delay)
.filter((d): d is number => typeof d === "number");
const delays = extractTimeoutDelays(timeoutSpy);
// Before the fix, delay would be 0 (tight loop).
// After the fix, delay must be >= MIN_REFIRE_GAP_MS (2000 ms).
@@ -90,14 +107,9 @@ describe("CronService - armTimer tight loop prevention", () => {
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
const now = Date.parse("2026-02-28T12:32:00.000Z");
const state = createCronServiceState({
const state = createTimerState({
storePath: "/tmp/test-cron/jobs.json",
cronEnabled: true,
log: noopLogger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok" }),
now,
});
state.store = {
version: 1,
@@ -121,9 +133,7 @@ describe("CronService - armTimer tight loop prevention", () => {
armTimer(state);
const delays = timeoutSpy.mock.calls
.map(([, delay]) => delay)
.filter((d): d is number => typeof d === "number");
const delays = extractTimeoutDelays(timeoutSpy);
// The natural delay (10 s) should be used, not the floor.
expect(delays).toContain(10_000);
@@ -151,14 +161,9 @@ describe("CronService - armTimer tight loop prevention", () => {
"utf-8",
);
const state = createCronServiceState({
const state = createTimerState({
storePath: store.storePath,
cronEnabled: true,
log: noopLogger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok" }),
now,
});
// Simulate the onTimer path: it will find no runnable jobs (blocked by
@@ -170,9 +175,7 @@ describe("CronService - armTimer tight loop prevention", () => {
// The re-armed timer must NOT use delay=0. It should use at least
// MIN_REFIRE_GAP_MS to prevent the hot-loop.
const allDelays = timeoutSpy.mock.calls
.map(([, delay]) => delay)
.filter((d): d is number => typeof d === "number");
const allDelays = extractTimeoutDelays(timeoutSpy);
// The last setTimeout call is from the finally→armTimer path.
const lastDelay = allDelays[allDelays.length - 1];

View File

@@ -1,5 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import type { HeartbeatRunResult } from "../infra/heartbeat-wake.js";
import { CronService } from "./service.js";
import { setupCronServiceSuite, writeCronStoreSnapshot } from "./service.test-harness.js";
import type { CronJob } from "./types.js";
@@ -8,59 +7,75 @@ const { logger, makeStorePath } = setupCronServiceSuite({
prefix: "cron-main-heartbeat-target",
});
describe("cron main job passes heartbeat target=last", () => {
it("should pass heartbeat.target=last to runHeartbeatOnce for wakeMode=now main jobs", async () => {
const { storePath } = await makeStorePath();
const now = Date.now();
type RunHeartbeatOnce = NonNullable<
ConstructorParameters<typeof CronService>[0]["runHeartbeatOnce"]
>;
const job: CronJob = {
id: "test-main-delivery",
name: "test-main-delivery",
describe("cron main job passes heartbeat target=last", () => {
function createMainCronJob(params: {
now: number;
id: string;
wakeMode: CronJob["wakeMode"];
}): CronJob {
return {
id: params.id,
name: params.id,
enabled: true,
createdAtMs: now - 10_000,
updatedAtMs: now - 10_000,
createdAtMs: params.now - 10_000,
updatedAtMs: params.now - 10_000,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "now",
wakeMode: params.wakeMode,
payload: { kind: "systemEvent", text: "Check in" },
state: { nextRunAtMs: now - 1 },
state: { nextRunAtMs: params.now - 1 },
};
}
await writeCronStoreSnapshot({ storePath, jobs: [job] });
function createCronWithSpies(params: { storePath: string; runHeartbeatOnce: RunHeartbeatOnce }) {
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
const runHeartbeatOnce = vi.fn<
(opts?: {
reason?: string;
agentId?: string;
sessionKey?: string;
heartbeat?: { target?: string };
}) => Promise<HeartbeatRunResult>
>(async () => ({
status: "ran" as const,
durationMs: 50,
}));
const cron = new CronService({
storePath,
storePath: params.storePath,
cronEnabled: true,
log: logger,
enqueueSystemEvent,
requestHeartbeatNow,
runHeartbeatOnce,
runHeartbeatOnce: params.runHeartbeatOnce,
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
});
return { cron, requestHeartbeatNow };
}
async function runSingleTick(cron: CronService) {
await cron.start();
// Wait for the timer to fire
await vi.advanceTimersByTimeAsync(2_000);
// Give the async run a chance to complete
await vi.advanceTimersByTimeAsync(1_000);
cron.stop();
}
it("should pass heartbeat.target=last to runHeartbeatOnce for wakeMode=now main jobs", async () => {
const { storePath } = await makeStorePath();
const now = Date.now();
const job = createMainCronJob({
now,
id: "test-main-delivery",
wakeMode: "now",
});
await writeCronStoreSnapshot({ storePath, jobs: [job] });
const runHeartbeatOnce = vi.fn<RunHeartbeatOnce>(async () => ({
status: "ran" as const,
durationMs: 50,
}));
const { cron } = createCronWithSpies({
storePath,
runHeartbeatOnce,
});
await runSingleTick(cron);
// runHeartbeatOnce should have been called
expect(runHeartbeatOnce).toHaveBeenCalled();
@@ -77,42 +92,25 @@ describe("cron main job passes heartbeat target=last", () => {
const { storePath } = await makeStorePath();
const now = Date.now();
const job: CronJob = {
const job = createMainCronJob({
now,
id: "test-next-heartbeat",
name: "test-next-heartbeat",
enabled: true,
createdAtMs: now - 10_000,
updatedAtMs: now - 10_000,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "Check in" },
state: { nextRunAtMs: now - 1 },
};
});
await writeCronStoreSnapshot({ storePath, jobs: [job] });
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
const runHeartbeatOnce = vi.fn(async () => ({
const runHeartbeatOnce = vi.fn<RunHeartbeatOnce>(async () => ({
status: "ran" as const,
durationMs: 50,
}));
const cron = new CronService({
const { cron, requestHeartbeatNow } = createCronWithSpies({
storePath,
cronEnabled: true,
log: logger,
enqueueSystemEvent,
requestHeartbeatNow,
runHeartbeatOnce,
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
});
await cron.start();
await vi.advanceTimersByTimeAsync(2_000);
await vi.advanceTimersByTimeAsync(1_000);
cron.stop();
await runSingleTick(cron);
// wakeMode=next-heartbeat uses requestHeartbeatNow, not runHeartbeatOnce
expect(requestHeartbeatNow).toHaveBeenCalled();

View File

@@ -1,32 +1,11 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createCronStoreHarness } from "./service.test-harness.js";
import { loadCronStore, resolveCronStorePath, saveCronStore } from "./store.js";
import type { CronStoreFile } from "./types.js";
let fixtureRoot = "";
let fixtureCount = 0;
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-store-"));
});
afterAll(async () => {
if (!fixtureRoot) {
return;
}
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
async function makeStorePath() {
const dir = path.join(fixtureRoot, `case-${fixtureCount++}`);
await fs.mkdir(dir, { recursive: true });
return {
dir,
storePath: path.join(dir, "jobs.json"),
};
}
const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-store-" });
function makeStore(jobId: string, enabled: boolean): CronStoreFile {
const now = Date.now();
@@ -72,6 +51,7 @@ describe("cron store", () => {
it("throws when store contains invalid JSON", async () => {
const store = await makeStorePath();
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(store.storePath, "{ not json", "utf-8");
await expect(loadCronStore(store.storePath)).rejects.toThrow(/Failed to parse cron store/i);
});

18
src/cron/types-shared.ts Normal file
View File

@@ -0,0 +1,18 @@
export type CronJobBase<TSchedule, TSessionTarget, TWakeMode, TPayload, TDelivery, TFailureAlert> =
{
id: string;
agentId?: string;
sessionKey?: string;
name: string;
description?: string;
enabled: boolean;
deleteAfterRun?: boolean;
createdAtMs: number;
updatedAtMs: number;
schedule: TSchedule;
sessionTarget: TSessionTarget;
wakeMode: TWakeMode;
payload: TPayload;
delivery?: TDelivery;
failureAlert?: TFailureAlert;
};

View File

@@ -1,4 +1,5 @@
import type { ChannelId } from "../channels/plugins/types.js";
import type { CronJobBase } from "./types-shared.js";
export type CronSchedule =
| { kind: "at"; at: string }
@@ -138,23 +139,14 @@ export type CronJobState = {
lastDelivered?: boolean;
};
export type CronJob = {
id: string;
agentId?: string;
/** Origin session namespace for reminder delivery and wake routing. */
sessionKey?: string;
name: string;
description?: string;
enabled: boolean;
deleteAfterRun?: boolean;
createdAtMs: number;
updatedAtMs: number;
schedule: CronSchedule;
sessionTarget: CronSessionTarget;
wakeMode: CronWakeMode;
payload: CronPayload;
delivery?: CronDelivery;
failureAlert?: CronFailureAlert | false;
export type CronJob = CronJobBase<
CronSchedule,
CronSessionTarget,
CronWakeMode,
CronPayload,
CronDelivery,
CronFailureAlert | false
> & {
state: CronJobState;
};