mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-21 16:41:56 +00:00
refactor: dedupe cli config cron and install flows
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
25
src/cron/isolated-agent/job-fixtures.ts
Normal file
25
src/cron/isolated-agent/job-fixtures.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 }),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
24
src/cron/isolated-agent/run.suite-helpers.ts
Normal file
24
src/cron/isolated-agent/run.suite-helpers.ts
Normal 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;
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
18
src/cron/types-shared.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user