refactor: dedupe gateway config and infra flows

This commit is contained in:
Peter Steinberger
2026-03-03 00:14:50 +00:00
parent fd3ca8a34c
commit 6a42d09129
40 changed files with 1438 additions and 1444 deletions

View File

@@ -1,4 +1,4 @@
import { vi } from "vitest";
import { expect, vi } from "vitest";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import type { CliDeps } from "../cli/deps.js";
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
@@ -30,6 +30,20 @@ export function mockAgentPayloads(
});
}
export function expectDirectTelegramDelivery(
deps: CliDeps,
params: { chatId: string; text: string; messageThreadId?: number },
) {
expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
params.chatId,
params.text,
expect.objectContaining(
params.messageThreadId === undefined ? {} : { messageThreadId: params.messageThreadId },
),
);
}
export async function runTelegramAnnounceTurn(params: {
home: string;
storePath: string;

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
import {
createCliDeps,
expectDirectTelegramDelivery,
mockAgentPayloads,
runTelegramAnnounceTurn,
} from "./isolated-agent.delivery.test-helpers.js";
@@ -30,14 +31,11 @@ describe("runCronIsolatedAgentTurn forum topic delivery", () => {
expect(res.status).toBe("ok");
expect(res.delivered).toBe(true);
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"123",
"forum message",
expect.objectContaining({
messageThreadId: 42,
}),
);
expectDirectTelegramDelivery(deps, {
chatId: "123",
text: "forum message",
messageThreadId: 42,
});
vi.clearAllMocks();
mockAgentPayloads([{ text: "plain message" }]);

View File

@@ -6,6 +6,7 @@ import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
import type { CliDeps } from "../cli/deps.js";
import {
createCliDeps,
expectDirectTelegramDelivery,
mockAgentPayloads,
runTelegramAnnounceTurn,
} from "./isolated-agent.delivery.test-helpers.js";
@@ -262,14 +263,11 @@ describe("runCronIsolatedAgentTurn", () => {
expect(res.status).toBe("ok");
expect(res.delivered).toBe(true);
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"123",
"Final weather summary",
expect.objectContaining({
messageThreadId: 42,
}),
);
expectDirectTelegramDelivery(deps, {
chatId: "123",
text: "Final weather summary",
messageThreadId: 42,
});
});
});

View File

@@ -2,7 +2,7 @@ import "./isolated-agent.mocks.js";
import fs from "node:fs/promises";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import { withTempHome as withTempHomeHelper } 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";
@@ -11,7 +11,7 @@ import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
import type { CronJob } from "./types.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(fn, { prefix: "openclaw-cron-submodel-" });
return withTempHomeHelper(fn, { prefix: "openclaw-cron-submodel-" });
}
async function writeSessionStore(home: string) {

View File

@@ -20,32 +20,74 @@ function expectNormalizedAtSchedule(scheduleInput: Record<string, unknown>) {
expect(schedule.at).toBe(new Date(Date.parse("2026-01-12T18:00:00Z")).toISOString());
}
function expectAnnounceDeliveryTarget(
delivery: Record<string, unknown>,
params: { channel: string; to: string },
): void {
expect(delivery.mode).toBe("announce");
expect(delivery.channel).toBe(params.channel);
expect(delivery.to).toBe(params.to);
}
function expectPayloadDeliveryHintsCleared(payload: Record<string, unknown>): void {
expect(payload.channel).toBeUndefined();
expect(payload.deliver).toBeUndefined();
}
function normalizeIsolatedAgentTurnCreateJob(params: {
name: string;
payload?: Record<string, unknown>;
delivery?: Record<string, unknown>;
}): Record<string, unknown> {
return normalizeCronJobCreate({
name: params.name,
enabled: true,
schedule: { kind: "cron", expr: "* * * * *" },
sessionTarget: "isolated",
wakeMode: "now",
payload: {
kind: "agentTurn",
message: "hi",
...params.payload,
},
...(params.delivery ? { delivery: params.delivery } : {}),
}) as unknown as Record<string, unknown>;
}
function normalizeMainSystemEventCreateJob(params: {
name: string;
schedule: Record<string, unknown>;
}): Record<string, unknown> {
return normalizeCronJobCreate({
name: params.name,
enabled: true,
schedule: params.schedule,
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: {
kind: "systemEvent",
text: "tick",
},
}) as unknown as Record<string, unknown>;
}
describe("normalizeCronJobCreate", () => {
it("maps legacy payload.provider to payload.channel and strips provider", () => {
const normalized = normalizeCronJobCreate({
const normalized = normalizeIsolatedAgentTurnCreateJob({
name: "legacy",
enabled: true,
schedule: { kind: "cron", expr: "* * * * *" },
sessionTarget: "isolated",
wakeMode: "now",
payload: {
kind: "agentTurn",
message: "hi",
deliver: true,
provider: " TeLeGrAm ",
to: "7200373102",
},
}) as unknown as Record<string, unknown>;
});
const payload = normalized.payload as Record<string, unknown>;
expect(payload.channel).toBeUndefined();
expect(payload.deliver).toBeUndefined();
expectPayloadDeliveryHintsCleared(payload);
expect("provider" in payload).toBe(false);
const delivery = normalized.delivery as Record<string, unknown>;
expect(delivery.mode).toBe("announce");
expect(delivery.channel).toBe("telegram");
expect(delivery.to).toBe("7200373102");
expectAnnounceDeliveryTarget(delivery, { channel: "telegram", to: "7200373102" });
});
it("trims agentId and drops null", () => {
@@ -105,29 +147,20 @@ describe("normalizeCronJobCreate", () => {
});
it("canonicalizes payload.channel casing", () => {
const normalized = normalizeCronJobCreate({
const normalized = normalizeIsolatedAgentTurnCreateJob({
name: "legacy provider",
enabled: true,
schedule: { kind: "cron", expr: "* * * * *" },
sessionTarget: "isolated",
wakeMode: "now",
payload: {
kind: "agentTurn",
message: "hi",
deliver: true,
channel: "Telegram",
to: "7200373102",
},
}) as unknown as Record<string, unknown>;
});
const payload = normalized.payload as Record<string, unknown>;
expect(payload.channel).toBeUndefined();
expect(payload.deliver).toBeUndefined();
expectPayloadDeliveryHintsCleared(payload);
const delivery = normalized.delivery as Record<string, unknown>;
expect(delivery.mode).toBe("announce");
expect(delivery.channel).toBe("telegram");
expect(delivery.to).toBe("7200373102");
expectAnnounceDeliveryTarget(delivery, { channel: "telegram", to: "7200373102" });
});
it("coerces ISO schedule.at to normalized ISO (UTC)", () => {
@@ -139,17 +172,10 @@ describe("normalizeCronJobCreate", () => {
});
it("migrates legacy schedule.cron into schedule.expr", () => {
const normalized = normalizeCronJobCreate({
const normalized = normalizeMainSystemEventCreateJob({
name: "legacy-cron-field",
enabled: true,
schedule: { kind: "cron", cron: "*/10 * * * *", tz: "UTC" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: {
kind: "systemEvent",
text: "tick",
},
}) as unknown as Record<string, unknown>;
});
const schedule = normalized.schedule as Record<string, unknown>;
expect(schedule.kind).toBe("cron");
@@ -158,34 +184,20 @@ describe("normalizeCronJobCreate", () => {
});
it("defaults cron stagger for recurring top-of-hour schedules", () => {
const normalized = normalizeCronJobCreate({
const normalized = normalizeMainSystemEventCreateJob({
name: "hourly",
enabled: true,
schedule: { kind: "cron", expr: "0 * * * *", tz: "UTC" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: {
kind: "systemEvent",
text: "tick",
},
}) as unknown as Record<string, unknown>;
});
const schedule = normalized.schedule as Record<string, unknown>;
expect(schedule.staggerMs).toBe(DEFAULT_TOP_OF_HOUR_STAGGER_MS);
});
it("preserves explicit exact cron schedule", () => {
const normalized = normalizeCronJobCreate({
const normalized = normalizeMainSystemEventCreateJob({
name: "exact",
enabled: true,
schedule: { kind: "cron", expr: "0 * * * *", tz: "UTC", staggerMs: 0 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: {
kind: "systemEvent",
text: "tick",
},
}) as unknown as Record<string, unknown>;
});
const schedule = normalized.schedule as Record<string, unknown>;
expect(schedule.staggerMs).toBe(0);
@@ -208,69 +220,43 @@ describe("normalizeCronJobCreate", () => {
});
it("normalizes delivery mode and channel", () => {
const normalized = normalizeCronJobCreate({
const normalized = normalizeIsolatedAgentTurnCreateJob({
name: "delivery",
enabled: true,
schedule: { kind: "cron", expr: "* * * * *" },
sessionTarget: "isolated",
wakeMode: "now",
payload: {
kind: "agentTurn",
message: "hi",
},
delivery: {
mode: " ANNOUNCE ",
channel: " TeLeGrAm ",
to: " 7200373102 ",
},
}) as unknown as Record<string, unknown>;
});
const delivery = normalized.delivery as Record<string, unknown>;
expect(delivery.mode).toBe("announce");
expect(delivery.channel).toBe("telegram");
expect(delivery.to).toBe("7200373102");
expectAnnounceDeliveryTarget(delivery, { channel: "telegram", to: "7200373102" });
});
it("normalizes delivery accountId and strips blanks", () => {
const normalized = normalizeCronJobCreate({
const normalized = normalizeIsolatedAgentTurnCreateJob({
name: "delivery account",
enabled: true,
schedule: { kind: "cron", expr: "* * * * *" },
sessionTarget: "isolated",
wakeMode: "now",
payload: {
kind: "agentTurn",
message: "hi",
},
delivery: {
mode: "announce",
channel: "telegram",
to: "-1003816714067",
accountId: " coordinator ",
},
}) as unknown as Record<string, unknown>;
});
const delivery = normalized.delivery as Record<string, unknown>;
expect(delivery.accountId).toBe("coordinator");
});
it("strips empty accountId from delivery", () => {
const normalized = normalizeCronJobCreate({
const normalized = normalizeIsolatedAgentTurnCreateJob({
name: "empty account",
enabled: true,
schedule: { kind: "cron", expr: "* * * * *" },
sessionTarget: "isolated",
wakeMode: "now",
payload: {
kind: "agentTurn",
message: "hi",
},
delivery: {
mode: "announce",
channel: "telegram",
accountId: " ",
},
}) as unknown as Record<string, unknown>;
});
const delivery = normalized.delivery as Record<string, unknown>;
expect("accountId" in delivery).toBe(false);
@@ -296,15 +282,9 @@ describe("normalizeCronJobCreate", () => {
});
it("defaults isolated agentTurn delivery to announce", () => {
const normalized = normalizeCronJobCreate({
const normalized = normalizeIsolatedAgentTurnCreateJob({
name: "default-announce",
enabled: true,
schedule: { kind: "cron", expr: "* * * * *" },
payload: {
kind: "agentTurn",
message: "hi",
},
}) as unknown as Record<string, unknown>;
});
const delivery = normalized.delivery as Record<string, unknown>;
expect(delivery.mode).toBe("announce");
@@ -326,9 +306,7 @@ describe("normalizeCronJobCreate", () => {
}) as unknown as Record<string, unknown>;
const delivery = normalized.delivery as Record<string, unknown>;
expect(delivery.mode).toBe("announce");
expect(delivery.channel).toBe("telegram");
expect(delivery.to).toBe("7200373102");
expectAnnounceDeliveryTarget(delivery, { channel: "telegram", to: "7200373102" });
expect(delivery.bestEffort).toBe(true);
});

View File

@@ -4,6 +4,13 @@ import type { CronServiceState } from "./service/state.js";
import { DEFAULT_TOP_OF_HOUR_STAGGER_MS } from "./stagger.js";
import type { CronJob, CronJobPatch } from "./types.js";
function expectCronStaggerMs(job: CronJob, expected: number): void {
expect(job.schedule.kind).toBe("cron");
if (job.schedule.kind === "cron") {
expect(job.schedule.staggerMs).toBe(expected);
}
}
describe("applyJobPatch", () => {
const createIsolatedAgentTurnJob = (
id: string,
@@ -481,10 +488,7 @@ describe("cron stagger defaults", () => {
payload: { kind: "systemEvent", text: "tick" },
});
expect(job.schedule.kind).toBe("cron");
if (job.schedule.kind === "cron") {
expect(job.schedule.staggerMs).toBe(DEFAULT_TOP_OF_HOUR_STAGGER_MS);
}
expectCronStaggerMs(job, DEFAULT_TOP_OF_HOUR_STAGGER_MS);
});
it("keeps exact schedules when staggerMs is explicitly 0", () => {
@@ -500,10 +504,7 @@ describe("cron stagger defaults", () => {
payload: { kind: "systemEvent", text: "tick" },
});
expect(job.schedule.kind).toBe("cron");
if (job.schedule.kind === "cron") {
expect(job.schedule.staggerMs).toBe(0);
}
expectCronStaggerMs(job, 0);
});
it("preserves existing stagger when editing cron expression without stagger", () => {

View File

@@ -333,6 +333,20 @@ async function runIsolatedAnnounceJobAndWait(params: {
return job;
}
async function runIsolatedAnnounceScenario(params: {
cron: CronService;
events: ReturnType<typeof createCronEventHarness>;
name: string;
status?: "ok" | "error";
}) {
await runIsolatedAnnounceJobAndWait({
cron: params.cron,
events: params.events,
name: params.name,
status: params.status ?? "ok",
});
}
async function addWakeModeNowMainSystemEventJob(
cron: CronService,
options?: { name?: string; agentId?: string; sessionKey?: string },
@@ -349,6 +363,82 @@ async function addWakeModeNowMainSystemEventJob(
});
}
async function addMainOneShotHelloJob(
cron: CronService,
params: { atMs: number; name: string; deleteAfterRun?: boolean },
) {
return cron.add({
name: params.name,
enabled: true,
...(params.deleteAfterRun === undefined ? {} : { deleteAfterRun: params.deleteAfterRun }),
schedule: { kind: "at", at: new Date(params.atMs).toISOString() },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "hello" },
});
}
function expectMainSystemEventPosted(enqueueSystemEvent: unknown, text: string) {
expect(enqueueSystemEvent).toHaveBeenCalledWith(
text,
expect.objectContaining({ agentId: undefined }),
);
}
async function stopCronAndCleanup(cron: CronService, store: { cleanup: () => Promise<void> }) {
cron.stop();
await store.cleanup();
}
function createStartedCronService(
storePath: string,
runIsolatedAgentJob?: CronServiceDeps["runIsolatedAgentJob"],
) {
return new CronService({
storePath,
cronEnabled: true,
log: noopLogger,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: runIsolatedAgentJob ?? vi.fn(async () => ({ status: "ok" as const })),
});
}
async function createMainOneShotJobHarness(params: { name: string; deleteAfterRun?: boolean }) {
const harness = await createMainOneShotHarness();
const atMs = Date.parse("2025-12-13T00:00:02.000Z");
const job = await addMainOneShotHelloJob(harness.cron, {
atMs,
name: params.name,
deleteAfterRun: params.deleteAfterRun,
});
return { ...harness, atMs, job };
}
async function loadLegacyDeliveryMigrationByPayload(params: {
id: string;
payload: { provider?: string; channel?: string };
}) {
const rawJob = createLegacyDeliveryMigrationJob(params);
return loadLegacyDeliveryMigration(rawJob);
}
async function expectNoMainSummaryForIsolatedRun(params: {
runIsolatedAgentJob: CronServiceDeps["runIsolatedAgentJob"];
name: string;
}) {
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } =
await createIsolatedAnnounceHarness(params.runIsolatedAgentJob);
await runIsolatedAnnounceScenario({
cron,
events,
name: params.name,
});
expect(enqueueSystemEvent).not.toHaveBeenCalled();
expect(requestHeartbeatNow).not.toHaveBeenCalled();
await stopCronAndCleanup(cron, store);
}
function createLegacyDeliveryMigrationJob(options: {
id: string;
payload: { provider?: string; channel?: string };
@@ -378,14 +468,7 @@ async function loadLegacyDeliveryMigration(rawJob: Record<string, unknown>) {
const store = await makeStorePath();
writeStoreFile(store.storePath, { version: 1, jobs: [rawJob] });
const cron = new CronService({
storePath: store.storePath,
cronEnabled: true,
log: noopLogger,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
});
const cron = createStartedCronService(store.storePath);
await cron.start();
const jobs = await cron.list({ includeDisabled: true });
const job = jobs.find((j) => j.id === rawJob.id);
@@ -394,18 +477,11 @@ async function loadLegacyDeliveryMigration(rawJob: Record<string, unknown>) {
describe("CronService", () => {
it("runs a one-shot main job and disables it after success when requested", async () => {
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } =
await createMainOneShotHarness();
const atMs = Date.parse("2025-12-13T00:00:02.000Z");
const job = await cron.add({
name: "one-shot hello",
enabled: true,
deleteAfterRun: false,
schedule: { kind: "at", at: new Date(atMs).toISOString() },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "hello" },
});
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events, atMs, job } =
await createMainOneShotJobHarness({
name: "one-shot hello",
deleteAfterRun: false,
});
expect(job.state.nextRunAtMs).toBe(atMs);
@@ -416,29 +492,18 @@ describe("CronService", () => {
const jobs = await cron.list({ includeDisabled: true });
const updated = jobs.find((j) => j.id === job.id);
expect(updated?.enabled).toBe(false);
expect(enqueueSystemEvent).toHaveBeenCalledWith(
"hello",
expect.objectContaining({ agentId: undefined }),
);
expectMainSystemEventPosted(enqueueSystemEvent, "hello");
expect(requestHeartbeatNow).toHaveBeenCalled();
await cron.list({ includeDisabled: true });
cron.stop();
await store.cleanup();
await stopCronAndCleanup(cron, store);
});
it("runs a one-shot job and deletes it after success by default", async () => {
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } =
await createMainOneShotHarness();
const atMs = Date.parse("2025-12-13T00:00:02.000Z");
const job = await cron.add({
name: "one-shot delete",
enabled: true,
schedule: { kind: "at", at: new Date(atMs).toISOString() },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "hello" },
});
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events, job } =
await createMainOneShotJobHarness({
name: "one-shot delete",
});
vi.setSystemTime(new Date("2025-12-13T00:00:02.000Z"));
await vi.runOnlyPendingTimersAsync();
@@ -446,14 +511,10 @@ describe("CronService", () => {
const jobs = await cron.list({ includeDisabled: true });
expect(jobs.find((j) => j.id === job.id)).toBeUndefined();
expect(enqueueSystemEvent).toHaveBeenCalledWith(
"hello",
expect.objectContaining({ agentId: undefined }),
);
expectMainSystemEventPosted(enqueueSystemEvent, "hello");
expect(requestHeartbeatNow).toHaveBeenCalled();
cron.stop();
await store.cleanup();
await stopCronAndCleanup(cron, store);
});
it("wakeMode now waits for heartbeat completion when available", async () => {
@@ -491,10 +552,7 @@ describe("CronService", () => {
expect(runHeartbeatOnce).toHaveBeenCalledTimes(1);
expect(requestHeartbeatNow).not.toHaveBeenCalled();
expect(enqueueSystemEvent).toHaveBeenCalledWith(
"hello",
expect.objectContaining({ agentId: undefined }),
);
expectMainSystemEventPosted(enqueueSystemEvent, "hello");
expect(job.state.runningAtMs).toBeTypeOf("number");
if (typeof resolveHeartbeat === "function") {
@@ -505,8 +563,7 @@ describe("CronService", () => {
expect(job.state.lastStatus).toBe("ok");
expect(job.state.lastDurationMs).toBeGreaterThan(0);
cron.stop();
await store.cleanup();
await stopCronAndCleanup(cron, store);
});
it("rejects sessionTarget main for non-default agents at creation time", async () => {
@@ -525,8 +582,7 @@ describe("CronService", () => {
}),
).rejects.toThrow('cron: sessionTarget "main" is only valid for the default agent');
cron.stop();
await store.cleanup();
await stopCronAndCleanup(cron, store);
});
it("wakeMode now falls back to queued heartbeat when main lane stays busy", async () => {
@@ -567,23 +623,18 @@ describe("CronService", () => {
expect(job.state.lastError).toBeUndefined();
await cron.list({ includeDisabled: true });
cron.stop();
await store.cleanup();
await stopCronAndCleanup(cron, store);
});
it("runs an isolated job and posts summary to main", async () => {
const runIsolatedAgentJob = vi.fn(async () => ({ status: "ok" as const, summary: "done" }));
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } =
await createIsolatedAnnounceHarness(runIsolatedAgentJob);
await runIsolatedAnnounceJobAndWait({ cron, events, name: "weekly", status: "ok" });
await runIsolatedAnnounceScenario({ cron, events, name: "weekly" });
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
expect(enqueueSystemEvent).toHaveBeenCalledWith(
"Cron: done",
expect.objectContaining({ agentId: undefined }),
);
expectMainSystemEventPosted(enqueueSystemEvent, "Cron: done");
expect(requestHeartbeatNow).toHaveBeenCalled();
cron.stop();
await store.cleanup();
await stopCronAndCleanup(cron, store);
});
it("does not post isolated summary to main when run already delivered output", async () => {
@@ -592,19 +643,11 @@ describe("CronService", () => {
summary: "done",
delivered: true,
}));
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } =
await createIsolatedAnnounceHarness(runIsolatedAgentJob);
await runIsolatedAnnounceJobAndWait({
cron,
events,
await expectNoMainSummaryForIsolatedRun({
runIsolatedAgentJob,
name: "weekly delivered",
status: "ok",
});
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
expect(enqueueSystemEvent).not.toHaveBeenCalled();
expect(requestHeartbeatNow).not.toHaveBeenCalled();
cron.stop();
await store.cleanup();
});
it("does not post isolated summary to main when announce delivery was attempted", async () => {
@@ -614,27 +657,18 @@ describe("CronService", () => {
delivered: false,
deliveryAttempted: true,
}));
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } =
await createIsolatedAnnounceHarness(runIsolatedAgentJob);
await runIsolatedAnnounceJobAndWait({
cron,
events,
await expectNoMainSummaryForIsolatedRun({
runIsolatedAgentJob,
name: "weekly attempted",
status: "ok",
});
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
expect(enqueueSystemEvent).not.toHaveBeenCalled();
expect(requestHeartbeatNow).not.toHaveBeenCalled();
cron.stop();
await store.cleanup();
});
it("migrates legacy payload.provider to payload.channel on load", async () => {
const rawJob = createLegacyDeliveryMigrationJob({
const { store, cron, job } = await loadLegacyDeliveryMigrationByPayload({
id: "legacy-1",
payload: { provider: " TeLeGrAm " },
});
const { store, cron, job } = await loadLegacyDeliveryMigration(rawJob);
// Legacy delivery fields are migrated to the top-level delivery object
const delivery = job?.delivery as unknown as Record<string, unknown>;
expect(delivery?.channel).toBe("telegram");
@@ -642,22 +676,19 @@ describe("CronService", () => {
expect("provider" in payload).toBe(false);
expect("channel" in payload).toBe(false);
cron.stop();
await store.cleanup();
await stopCronAndCleanup(cron, store);
});
it("canonicalizes payload.channel casing on load", async () => {
const rawJob = createLegacyDeliveryMigrationJob({
const { store, cron, job } = await loadLegacyDeliveryMigrationByPayload({
id: "legacy-2",
payload: { channel: "Telegram" },
});
const { store, cron, job } = await loadLegacyDeliveryMigration(rawJob);
// Legacy delivery fields are migrated to the top-level delivery object
const delivery = job?.delivery as unknown as Record<string, unknown>;
expect(delivery?.channel).toBe("telegram");
cron.stop();
await store.cleanup();
await stopCronAndCleanup(cron, store);
});
it("posts last output to main even when isolated job errors", async () => {
@@ -675,13 +706,9 @@ describe("CronService", () => {
status: "error",
});
expect(enqueueSystemEvent).toHaveBeenCalledWith(
"Cron (error): last output",
expect.objectContaining({ agentId: undefined }),
);
expectMainSystemEventPosted(enqueueSystemEvent, "Cron (error): last output");
expect(requestHeartbeatNow).toHaveBeenCalled();
cron.stop();
await store.cleanup();
await stopCronAndCleanup(cron, store);
});
it("does not post fallback main summary for isolated delivery-target errors", async () => {
@@ -702,24 +729,19 @@ describe("CronService", () => {
expect(enqueueSystemEvent).not.toHaveBeenCalled();
expect(requestHeartbeatNow).not.toHaveBeenCalled();
cron.stop();
await store.cleanup();
await stopCronAndCleanup(cron, store);
});
it("rejects unsupported session/payload combinations", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const cron = new CronService({
storePath: store.storePath,
cronEnabled: true,
log: noopLogger,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn(async (_params: { job: unknown; message: string }) => ({
status: "ok",
const cron = createStartedCronService(
store.storePath,
vi.fn(async (_params: { job: unknown; message: string }) => ({
status: "ok" as const,
})) as unknown as CronServiceDeps["runIsolatedAgentJob"],
});
);
await cron.start();

View File

@@ -32,44 +32,61 @@ async function listJobById(cron: CronService, jobId: string) {
return jobs.find((entry) => entry.id === jobId);
}
async function startCronWithStoredJobs(jobs: Array<Record<string, unknown>>) {
const store = await makeStorePath();
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(
store.storePath,
JSON.stringify(
{
version: 1,
jobs,
},
null,
2,
),
"utf-8",
);
const cron = await createStartedCron(store.storePath).start();
return { store, cron };
}
async function stopCronAndCleanup(cron: CronService, store: { cleanup: () => Promise<void> }) {
cron.stop();
await store.cleanup();
}
function createLegacyIsolatedAgentTurnJob(
overrides: Record<string, unknown>,
): Record<string, unknown> {
return {
enabled: true,
createdAtMs: Date.parse("2026-02-01T12:00:00.000Z"),
updatedAtMs: Date.parse("2026-02-05T12:00:00.000Z"),
schedule: { kind: "cron", expr: "0 23 * * *", tz: "UTC" },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "legacy payload fields" },
...overrides,
};
}
describe("CronService store migrations", () => {
it("migrates legacy top-level agentTurn fields and initializes missing state", async () => {
const store = await makeStorePath();
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(
store.storePath,
JSON.stringify(
{
version: 1,
jobs: [
{
id: "legacy-agentturn-job",
name: "legacy agentturn",
enabled: true,
createdAtMs: Date.parse("2026-02-01T12:00:00.000Z"),
updatedAtMs: Date.parse("2026-02-05T12:00:00.000Z"),
schedule: { kind: "cron", expr: "0 23 * * *", tz: "UTC" },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
model: "openrouter/deepseek/deepseek-r1",
thinking: "high",
timeoutSeconds: 120,
allowUnsafeExternalContent: true,
deliver: true,
channel: "telegram",
to: "12345",
bestEffortDeliver: true,
payload: { kind: "agentTurn", message: "legacy payload fields" },
},
],
},
null,
2,
),
"utf-8",
);
const cron = await createStartedCron(store.storePath).start();
const { store, cron } = await startCronWithStoredJobs([
createLegacyIsolatedAgentTurnJob({
id: "legacy-agentturn-job",
name: "legacy agentturn",
model: "openrouter/deepseek/deepseek-r1",
thinking: "high",
timeoutSeconds: 120,
allowUnsafeExternalContent: true,
deliver: true,
channel: "telegram",
to: "12345",
bestEffortDeliver: true,
}),
]);
const status = await cron.status();
expect(status.enabled).toBe(true);
@@ -106,40 +123,17 @@ describe("CronService store migrations", () => {
expect(persistedJob?.to).toBeUndefined();
expect(persistedJob?.bestEffortDeliver).toBeUndefined();
cron.stop();
await store.cleanup();
await stopCronAndCleanup(cron, store);
});
it("preserves legacy timeoutSeconds=0 during top-level agentTurn field migration", async () => {
const store = await makeStorePath();
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(
store.storePath,
JSON.stringify(
{
version: 1,
jobs: [
{
id: "legacy-agentturn-no-timeout",
name: "legacy no-timeout",
enabled: true,
createdAtMs: Date.parse("2026-02-01T12:00:00.000Z"),
updatedAtMs: Date.parse("2026-02-05T12:00:00.000Z"),
schedule: { kind: "cron", expr: "0 23 * * *", tz: "UTC" },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
timeoutSeconds: 0,
payload: { kind: "agentTurn", message: "legacy payload fields" },
},
],
},
null,
2,
),
"utf-8",
);
const cron = await createStartedCron(store.storePath).start();
const { store, cron } = await startCronWithStoredJobs([
createLegacyIsolatedAgentTurnJob({
id: "legacy-agentturn-no-timeout",
name: "legacy no-timeout",
timeoutSeconds: 0,
}),
]);
const job = await listJobById(cron, "legacy-agentturn-no-timeout");
expect(job).toBeDefined();
@@ -148,38 +142,22 @@ describe("CronService store migrations", () => {
expect(job.payload.timeoutSeconds).toBe(0);
}
cron.stop();
await store.cleanup();
await stopCronAndCleanup(cron, store);
});
it("migrates legacy cron fields (jobId + schedule.cron) and defaults wakeMode", async () => {
const store = await makeStorePath();
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(
store.storePath,
JSON.stringify(
{
version: 1,
jobs: [
{
jobId: "legacy-cron-field-job",
name: "legacy cron field",
enabled: true,
createdAtMs: Date.parse("2026-02-01T12:00:00.000Z"),
updatedAtMs: Date.parse("2026-02-05T12:00:00.000Z"),
schedule: { kind: "cron", cron: "*/5 * * * *", tz: "UTC" },
payload: { kind: "systemEvent", text: "tick" },
state: {},
},
],
},
null,
2,
),
"utf-8",
);
const cron = await createStartedCron(store.storePath).start();
const { store, cron } = await startCronWithStoredJobs([
{
jobId: "legacy-cron-field-job",
name: "legacy cron field",
enabled: true,
createdAtMs: Date.parse("2026-02-01T12:00:00.000Z"),
updatedAtMs: Date.parse("2026-02-05T12:00:00.000Z"),
schedule: { kind: "cron", cron: "*/5 * * * *", tz: "UTC" },
payload: { kind: "systemEvent", text: "tick" },
state: {},
},
]);
const job = await listJobById(cron, "legacy-cron-field-job");
expect(job).toBeDefined();
expect(job?.wakeMode).toBe("now");
@@ -202,7 +180,6 @@ describe("CronService store migrations", () => {
expect(persistedSchedule?.cron).toBeUndefined();
expect(persistedSchedule?.expr).toBe("*/5 * * * *");
cron.stop();
await store.cleanup();
await stopCronAndCleanup(cron, store);
});
});