Cron: add scheduler, wakeups, and run history

This commit is contained in:
Peter Steinberger
2025-12-13 02:34:11 +00:00
parent 572d17f46b
commit f9409cbe43
26 changed files with 3401 additions and 342 deletions

341
src/cron/isolated-agent.ts Normal file
View File

@@ -0,0 +1,341 @@
import crypto from "node:crypto";
import { chunkText } from "../auto-reply/chunk.js";
import { runCommandReply } from "../auto-reply/command-reply.js";
import {
applyTemplate,
type TemplateContext,
} from "../auto-reply/templating.js";
import { normalizeThinkLevel } from "../auto-reply/thinking.js";
import type { CliDeps } from "../cli/deps.js";
import type { ClawdisConfig } from "../config/config.js";
import {
DEFAULT_IDLE_MINUTES,
loadSessionStore,
resolveStorePath,
type SessionEntry,
saveSessionStore,
} from "../config/sessions.js";
import { enqueueCommandInLane } from "../process/command-queue.js";
import { normalizeE164 } from "../utils.js";
import type { CronJob } from "./types.js";
export type RunCronAgentTurnResult = {
status: "ok" | "error" | "skipped";
summary?: string;
};
function assertCommandReplyConfig(cfg: ClawdisConfig) {
const reply = cfg.inbound?.reply;
if (!reply || reply.mode !== "command" || !reply.command?.length) {
throw new Error(
"Configure inbound.reply.mode=command with reply.command before using cron agent jobs.",
);
}
return reply as NonNullable<
NonNullable<ClawdisConfig["inbound"]>["reply"]
> & {
mode: "command";
command: string[];
};
}
function pickSummaryFromOutput(text: string | undefined) {
const clean = (text ?? "").trim();
if (!clean) return undefined;
const oneLine = clean.replace(/\s+/g, " ");
return oneLine.length > 200 ? `${oneLine.slice(0, 200)}` : oneLine;
}
function resolveDeliveryTarget(
cfg: ClawdisConfig,
jobPayload: {
channel?: "last" | "whatsapp" | "telegram";
to?: string;
},
) {
const requestedChannel =
typeof jobPayload.channel === "string" ? jobPayload.channel : "last";
const explicitTo =
typeof jobPayload.to === "string" && jobPayload.to.trim()
? jobPayload.to.trim()
: undefined;
const sessionCfg = cfg.inbound?.reply?.session;
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
const storePath = resolveStorePath(sessionCfg?.store);
const store = loadSessionStore(storePath);
const main = store[mainKey];
const lastChannel =
main?.lastChannel && main.lastChannel !== "webchat"
? main.lastChannel
: undefined;
const lastTo = typeof main?.lastTo === "string" ? main.lastTo.trim() : "";
const channel = (() => {
if (requestedChannel === "whatsapp" || requestedChannel === "telegram") {
return requestedChannel;
}
return lastChannel ?? "whatsapp";
})();
const to = (() => {
if (explicitTo) return explicitTo;
return lastTo || undefined;
})();
const sanitizedWhatsappTo = (() => {
if (channel !== "whatsapp") return to;
const rawAllow = cfg.inbound?.allowFrom ?? [];
if (rawAllow.includes("*")) return to;
const allowFrom = rawAllow
.map((val) => normalizeE164(val))
.filter((val) => val.length > 1);
if (allowFrom.length === 0) return to;
if (!to) return allowFrom[0];
const normalized = normalizeE164(to);
if (allowFrom.includes(normalized)) return normalized;
return allowFrom[0];
})();
return {
channel,
to: channel === "whatsapp" ? sanitizedWhatsappTo : to,
};
}
function resolveCronSession(params: {
cfg: ClawdisConfig;
sessionKey: string;
nowMs: number;
}) {
const sessionCfg = params.cfg.inbound?.reply?.session;
const idleMinutes = Math.max(
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
1,
);
const idleMs = idleMinutes * 60_000;
const storePath = resolveStorePath(sessionCfg?.store);
const store = loadSessionStore(storePath);
const entry = store[params.sessionKey];
const fresh = entry && params.nowMs - entry.updatedAt <= idleMs;
const sessionId = fresh ? entry.sessionId : crypto.randomUUID();
const systemSent = fresh ? Boolean(entry.systemSent) : false;
const sessionEntry: SessionEntry = {
sessionId,
updatedAt: params.nowMs,
systemSent,
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
model: entry?.model,
contextTokens: entry?.contextTokens,
lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo,
syncing: entry?.syncing,
};
return { storePath, store, sessionEntry, systemSent, isNewSession: !fresh };
}
export async function runCronIsolatedAgentTurn(params: {
cfg: ClawdisConfig;
deps: CliDeps;
job: CronJob;
message: string;
sessionKey: string;
lane?: string;
}): Promise<RunCronAgentTurnResult> {
const replyCfg = assertCommandReplyConfig(params.cfg);
const now = Date.now();
const cronSession = resolveCronSession({
cfg: params.cfg,
sessionKey: params.sessionKey,
nowMs: now,
});
const sendSystemOnce = replyCfg.session?.sendSystemOnce === true;
const isFirstTurnInSession =
cronSession.isNewSession || !cronSession.systemSent;
const sessionIntro = replyCfg.session?.sessionIntro
? applyTemplate(replyCfg.session.sessionIntro, {
SessionId: cronSession.sessionEntry.sessionId,
})
: "";
const bodyPrefix = replyCfg.bodyPrefix
? applyTemplate(replyCfg.bodyPrefix, {
SessionId: cronSession.sessionEntry.sessionId,
})
: "";
const thinkOverride = normalizeThinkLevel(replyCfg.thinkingDefault);
const jobThink = normalizeThinkLevel(
(params.job.payload.kind === "agentTurn"
? params.job.payload.thinking
: undefined) ?? undefined,
);
const thinkLevel = jobThink ?? thinkOverride;
const timeoutSecondsRaw =
params.job.payload.kind === "agentTurn" && params.job.payload.timeoutSeconds
? params.job.payload.timeoutSeconds
: (replyCfg.timeoutSeconds ?? 600);
const timeoutSeconds = Math.max(Math.floor(timeoutSecondsRaw), 1);
const timeoutMs = timeoutSeconds * 1000;
const delivery =
params.job.payload.kind === "agentTurn" &&
params.job.payload.deliver === true;
const bestEffortDeliver =
params.job.payload.kind === "agentTurn" &&
params.job.payload.bestEffortDeliver === true;
const resolvedDelivery = resolveDeliveryTarget(params.cfg, {
channel:
params.job.payload.kind === "agentTurn"
? params.job.payload.channel
: "last",
to:
params.job.payload.kind === "agentTurn"
? params.job.payload.to
: undefined,
});
const base =
`[cron:${params.job.id}${params.job.name ? ` ${params.job.name}` : ""}] ${params.message}`.trim();
let commandBody = base;
if (!sendSystemOnce || isFirstTurnInSession) {
commandBody = bodyPrefix ? `${bodyPrefix}${commandBody}` : commandBody;
}
if (sessionIntro) {
commandBody = `${sessionIntro}\n\n${commandBody}`;
}
const templatingCtx: TemplateContext = {
Body: commandBody,
BodyStripped: commandBody,
SessionId: cronSession.sessionEntry.sessionId,
From: resolvedDelivery.to ?? "",
To: resolvedDelivery.to ?? "",
Surface: "Cron",
IsNewSession: cronSession.isNewSession ? "true" : "false",
};
// Persist systemSent before the run, mirroring the inbound auto-reply behavior.
if (sendSystemOnce && isFirstTurnInSession) {
cronSession.sessionEntry.systemSent = true;
cronSession.store[params.sessionKey] = cronSession.sessionEntry;
await saveSessionStore(cronSession.storePath, cronSession.store);
} else {
cronSession.store[params.sessionKey] = cronSession.sessionEntry;
await saveSessionStore(cronSession.storePath, cronSession.store);
}
const lane = params.lane?.trim() || "cron";
const runResult = await runCommandReply({
reply: { ...replyCfg, mode: "command" },
templatingCtx,
sendSystemOnce,
isNewSession: cronSession.isNewSession,
isFirstTurnInSession,
systemSent: cronSession.sessionEntry.systemSent ?? false,
timeoutMs,
timeoutSeconds,
thinkLevel,
enqueue: (task, opts) => enqueueCommandInLane(lane, task, opts),
runId: cronSession.sessionEntry.sessionId,
});
const payloads = runResult.payloads ?? [];
const firstText = payloads[0]?.text ?? "";
const summary = pickSummaryFromOutput(firstText);
if (delivery) {
if (resolvedDelivery.channel === "whatsapp") {
if (!resolvedDelivery.to) {
if (!bestEffortDeliver) {
return {
status: "error",
summary: "Cron delivery to WhatsApp requires a recipient.",
};
}
return {
status: "skipped",
summary: "Delivery skipped (no WhatsApp recipient).",
};
}
const to = normalizeE164(resolvedDelivery.to);
try {
for (const payload of payloads) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const primaryMedia = mediaList[0];
await params.deps.sendMessageWhatsApp(to, payload.text ?? "", {
verbose: false,
mediaUrl: primaryMedia,
});
for (const extra of mediaList.slice(1)) {
await params.deps.sendMessageWhatsApp(to, "", {
verbose: false,
mediaUrl: extra,
});
}
}
} catch (err) {
if (!bestEffortDeliver) throw err;
return {
status: "ok",
summary: summary
? `${summary} (delivery failed)`
: "completed (delivery failed)",
};
}
} else if (resolvedDelivery.channel === "telegram") {
if (!resolvedDelivery.to) {
if (!bestEffortDeliver) {
return {
status: "error",
summary: "Cron delivery to Telegram requires a chatId.",
};
}
return {
status: "skipped",
summary: "Delivery skipped (no Telegram chatId).",
};
}
const chatId = resolvedDelivery.to;
try {
for (const payload of payloads) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
if (mediaList.length === 0) {
for (const chunk of chunkText(payload.text ?? "", 4000)) {
await params.deps.sendMessageTelegram(chatId, chunk, {
verbose: false,
});
}
} else {
let first = true;
for (const url of mediaList) {
const caption = first ? (payload.text ?? "") : "";
first = false;
await params.deps.sendMessageTelegram(chatId, caption, {
verbose: false,
mediaUrl: url,
});
}
}
}
} catch (err) {
if (!bestEffortDeliver) throw err;
return {
status: "ok",
summary: summary
? `${summary} (delivery failed)`
: "completed (delivery failed)",
};
}
}
}
return { status: "ok", summary };
}

98
src/cron/run-log.test.ts Normal file
View File

@@ -0,0 +1,98 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
appendCronRunLog,
readCronRunLogEntries,
resolveCronRunLogPath,
} from "./run-log.js";
describe("cron run log", () => {
it("resolves a flat store path to cron.runs.jsonl", () => {
const storePath = path.join(os.tmpdir(), "cron.json");
const p = resolveCronRunLogPath({ storePath, jobId: "job-1" });
expect(p.endsWith(path.join(os.tmpdir(), "cron.runs.jsonl"))).toBe(true);
});
it("resolves jobs.json to per-job runs/<jobId>.jsonl", () => {
const storePath = path.join(os.tmpdir(), "cron", "jobs.json");
const p = resolveCronRunLogPath({ storePath, jobId: "job-1" });
expect(
p.endsWith(path.join(os.tmpdir(), "cron", "runs", "job-1.jsonl")),
).toBe(true);
});
it("appends JSONL and prunes by line count", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-cron-log-"));
const logPath = path.join(dir, "cron.runs.jsonl");
for (let i = 0; i < 10; i++) {
await appendCronRunLog(
logPath,
{
ts: 1000 + i,
jobId: "job-1",
action: "finished",
status: "ok",
durationMs: i,
},
{ maxBytes: 1, keepLines: 3 },
);
}
const raw = await fs.readFile(logPath, "utf-8");
const lines = raw
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
expect(lines.length).toBe(3);
const last = JSON.parse(lines[2] ?? "{}") as { ts?: number };
expect(last.ts).toBe(1009);
await fs.rm(dir, { recursive: true, force: true });
});
it("reads newest entries and filters by jobId", async () => {
const dir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdis-cron-log-read-"),
);
const logPath = path.join(dir, "cron.runs.jsonl");
await appendCronRunLog(logPath, {
ts: 1,
jobId: "a",
action: "finished",
status: "ok",
});
await appendCronRunLog(logPath, {
ts: 2,
jobId: "b",
action: "finished",
status: "error",
error: "nope",
});
await appendCronRunLog(logPath, {
ts: 3,
jobId: "a",
action: "finished",
status: "skipped",
});
const all = await readCronRunLogEntries(logPath, { limit: 10 });
expect(all.map((e) => e.jobId)).toEqual(["a", "b", "a"]);
const onlyA = await readCronRunLogEntries(logPath, {
limit: 10,
jobId: "a",
});
expect(onlyA.map((e) => e.ts)).toEqual([1, 3]);
const lastOne = await readCronRunLogEntries(logPath, { limit: 1 });
expect(lastOne.map((e) => e.ts)).toEqual([3]);
await fs.rm(dir, { recursive: true, force: true });
});
});

101
src/cron/run-log.ts Normal file
View File

@@ -0,0 +1,101 @@
import fs from "node:fs/promises";
import path from "node:path";
export type CronRunLogEntry = {
ts: number;
jobId: string;
action: "finished";
status?: "ok" | "error" | "skipped";
error?: string;
runAtMs?: number;
durationMs?: number;
nextRunAtMs?: number;
};
export function resolveCronRunLogPath(params: {
storePath: string;
jobId: string;
}) {
const storePath = path.resolve(params.storePath);
const dir = path.dirname(storePath);
const base = path.basename(storePath);
if (base === "jobs.json") {
return path.join(dir, "runs", `${params.jobId}.jsonl`);
}
const ext = path.extname(base);
const baseNoExt = ext ? base.slice(0, -ext.length) : base;
return path.join(dir, `${baseNoExt}.runs.jsonl`);
}
const writesByPath = new Map<string, Promise<void>>();
async function pruneIfNeeded(
filePath: string,
opts: { maxBytes: number; keepLines: number },
) {
const stat = await fs.stat(filePath).catch(() => null);
if (!stat || stat.size <= opts.maxBytes) return;
const raw = await fs.readFile(filePath, "utf-8").catch(() => "");
const lines = raw
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const kept = lines.slice(Math.max(0, lines.length - opts.keepLines));
const tmp = `${filePath}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
await fs.writeFile(tmp, `${kept.join("\n")}\n`, "utf-8");
await fs.rename(tmp, filePath);
}
export async function appendCronRunLog(
filePath: string,
entry: CronRunLogEntry,
opts?: { maxBytes?: number; keepLines?: number },
) {
const resolved = path.resolve(filePath);
const prev = writesByPath.get(resolved) ?? Promise.resolve();
const next = prev
.catch(() => undefined)
.then(async () => {
await fs.mkdir(path.dirname(resolved), { recursive: true });
await fs.appendFile(resolved, `${JSON.stringify(entry)}\n`, "utf-8");
await pruneIfNeeded(resolved, {
maxBytes: opts?.maxBytes ?? 2_000_000,
keepLines: opts?.keepLines ?? 2_000,
});
});
writesByPath.set(resolved, next);
await next;
}
export async function readCronRunLogEntries(
filePath: string,
opts?: { limit?: number; jobId?: string },
): Promise<CronRunLogEntry[]> {
const limit = Math.max(1, Math.min(5000, Math.floor(opts?.limit ?? 200)));
const jobId = opts?.jobId?.trim() || undefined;
const raw = await fs
.readFile(path.resolve(filePath), "utf-8")
.catch(() => "");
if (!raw.trim()) return [];
const parsed: CronRunLogEntry[] = [];
const lines = raw.split("\n");
for (let i = lines.length - 1; i >= 0 && parsed.length < limit; i--) {
const line = lines[i]?.trim();
if (!line) continue;
try {
const obj = JSON.parse(line) as Partial<CronRunLogEntry> | null;
if (!obj || typeof obj !== "object") continue;
if (obj.action !== "finished") continue;
if (typeof obj.jobId !== "string" || obj.jobId.trim().length === 0)
continue;
if (typeof obj.ts !== "number" || !Number.isFinite(obj.ts)) continue;
if (jobId && obj.jobId !== jobId) continue;
parsed.push(obj as CronRunLogEntry);
} catch {
// ignore invalid lines
}
}
return parsed.reverse();
}

26
src/cron/schedule.test.ts Normal file
View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { computeNextRunAtMs } from "./schedule.js";
describe("cron schedule", () => {
it("computes next run for cron expression with timezone", () => {
// Saturday, Dec 13 2025 00:00:00Z
const nowMs = Date.parse("2025-12-13T00:00:00.000Z");
const next = computeNextRunAtMs(
{ kind: "cron", expr: "0 9 * * 3", tz: "America/Los_Angeles" },
nowMs,
);
// Next Wednesday at 09:00 PST -> 17:00Z
expect(next).toBe(Date.parse("2025-12-17T17:00:00.000Z"));
});
it("computes next run for every schedule", () => {
const anchor = Date.parse("2025-12-13T00:00:00.000Z");
const now = anchor + 10_000;
const next = computeNextRunAtMs(
{ kind: "every", everyMs: 30_000, anchorMs: anchor },
now,
);
expect(next).toBe(anchor + 30_000);
});
});

29
src/cron/schedule.ts Normal file
View File

@@ -0,0 +1,29 @@
import { Cron } from "croner";
import type { CronSchedule } from "./types.js";
export function computeNextRunAtMs(
schedule: CronSchedule,
nowMs: number,
): number | undefined {
if (schedule.kind === "at") {
return schedule.atMs > nowMs ? schedule.atMs : undefined;
}
if (schedule.kind === "every") {
const everyMs = Math.max(1, Math.floor(schedule.everyMs));
const anchor = Math.max(0, Math.floor(schedule.anchorMs ?? nowMs));
if (nowMs <= anchor) return anchor;
const elapsed = nowMs - anchor;
const steps = Math.floor((elapsed + everyMs - 1) / everyMs);
return anchor + steps * everyMs;
}
const expr = schedule.expr.trim();
if (!expr) return undefined;
const cron = new Cron(expr, {
timezone: schedule.tz?.trim() || undefined,
catch: false,
});
const next = cron.nextRun(new Date(nowMs));
return next ? next.getTime() : undefined;
}

120
src/cron/service.test.ts Normal file
View File

@@ -0,0 +1,120 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CronService } from "./service.js";
const noopLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
async function makeStorePath() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-cron-"));
return {
storePath: path.join(dir, "cron.json"),
cleanup: async () => {
await fs.rm(dir, { recursive: true, force: true });
},
};
}
describe("CronService", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-12-13T00:00:00.000Z"));
noopLogger.debug.mockClear();
noopLogger.info.mockClear();
noopLogger.warn.mockClear();
noopLogger.error.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
it("runs a one-shot main job and disables it after success", async () => {
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestReplyHeartbeatNow = vi.fn();
const cron = new CronService({
storePath: store.storePath,
cronEnabled: true,
log: noopLogger,
enqueueSystemEvent,
requestReplyHeartbeatNow,
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
});
await cron.start();
const atMs = Date.parse("2025-12-13T00:00:02.000Z");
const job = await cron.add({
enabled: true,
schedule: { kind: "at", atMs },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "hello" },
});
expect(job.state.nextRunAtMs).toBe(atMs);
vi.setSystemTime(new Date("2025-12-13T00:00:02.000Z"));
await vi.runOnlyPendingTimersAsync();
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(requestReplyHeartbeatNow).toHaveBeenCalled();
await cron.list({ includeDisabled: true });
cron.stop();
await store.cleanup();
});
it("runs an isolated job and posts summary to main", async () => {
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestReplyHeartbeatNow = vi.fn();
const runIsolatedAgentJob = vi.fn(async () => ({
status: "ok" as const,
summary: "done",
}));
const cron = new CronService({
storePath: store.storePath,
cronEnabled: true,
log: noopLogger,
enqueueSystemEvent,
requestReplyHeartbeatNow,
runIsolatedAgentJob,
});
await cron.start();
const atMs = Date.parse("2025-12-13T00:00:01.000Z");
await cron.add({
enabled: true,
name: "weekly",
schedule: { kind: "at", atMs },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "do it", deliver: false },
isolation: { postToMain: true, postToMainPrefix: "Cron" },
});
vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z"));
await vi.runOnlyPendingTimersAsync();
await cron.list({ includeDisabled: true });
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
expect(enqueueSystemEvent).toHaveBeenCalledWith("Cron: done");
expect(requestReplyHeartbeatNow).toHaveBeenCalled();
cron.stop();
await store.cleanup();
});
});

431
src/cron/service.ts Normal file
View File

@@ -0,0 +1,431 @@
import crypto from "node:crypto";
import { computeNextRunAtMs } from "./schedule.js";
import { loadCronStore, saveCronStore } from "./store.js";
import type {
CronJob,
CronJobCreate,
CronJobPatch,
CronPayload,
CronStoreFile,
} from "./types.js";
export type CronEvent = {
jobId: string;
action: "added" | "updated" | "removed" | "started" | "finished";
runAtMs?: number;
durationMs?: number;
status?: "ok" | "error" | "skipped";
error?: string;
nextRunAtMs?: number;
};
type Logger = {
debug: (obj: unknown, msg?: string) => void;
info: (obj: unknown, msg?: string) => void;
warn: (obj: unknown, msg?: string) => void;
error: (obj: unknown, msg?: string) => void;
};
export type CronServiceDeps = {
nowMs?: () => number;
log: Logger;
storePath: string;
cronEnabled: boolean;
enqueueSystemEvent: (text: string) => void;
requestReplyHeartbeatNow: (opts?: { reason?: string }) => void;
runIsolatedAgentJob: (params: {
job: CronJob;
message: string;
}) => Promise<{ status: "ok" | "error" | "skipped"; summary?: string }>;
onEvent?: (evt: CronEvent) => void;
};
const STUCK_RUN_MS = 2 * 60 * 60 * 1000;
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function normalizePayloadToSystemText(payload: CronPayload) {
if (payload.kind === "systemEvent") return payload.text.trim();
return payload.message.trim();
}
export class CronService {
private readonly deps: Required<Omit<CronServiceDeps, "onEvent">> &
Pick<CronServiceDeps, "onEvent">;
private store: CronStoreFile | null = null;
private timer: NodeJS.Timeout | null = null;
private running = false;
private op: Promise<unknown> = Promise.resolve();
constructor(deps: CronServiceDeps) {
this.deps = {
...deps,
nowMs: deps.nowMs ?? (() => Date.now()),
onEvent: deps.onEvent,
};
}
async start() {
await this.locked(async () => {
if (!this.deps.cronEnabled) {
this.deps.log.info({ enabled: false }, "cron: disabled");
return;
}
await this.ensureLoaded();
this.recomputeNextRuns();
await this.persist();
this.armTimer();
this.deps.log.info(
{
enabled: true,
jobs: this.store?.jobs.length ?? 0,
nextWakeAtMs: this.nextWakeAtMs() ?? null,
},
"cron: started",
);
});
}
stop() {
if (this.timer) clearTimeout(this.timer);
this.timer = null;
}
async list(opts?: { includeDisabled?: boolean }) {
return await this.locked(async () => {
await this.ensureLoaded();
const includeDisabled = opts?.includeDisabled === true;
const jobs = (this.store?.jobs ?? []).filter(
(j) => includeDisabled || j.enabled,
);
return jobs.sort(
(a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0),
);
});
}
async add(input: CronJobCreate) {
return await this.locked(async () => {
await this.ensureLoaded();
const now = this.deps.nowMs();
const id = crypto.randomUUID();
const job: CronJob = {
id,
name: input.name?.trim() || undefined,
enabled: input.enabled !== false,
createdAtMs: now,
updatedAtMs: now,
schedule: input.schedule,
sessionTarget: input.sessionTarget,
wakeMode: input.wakeMode,
payload: input.payload,
isolation: input.isolation,
state: {
...input.state,
},
};
job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, now);
this.store?.jobs.push(job);
await this.persist();
this.armTimer();
this.emit({
jobId: id,
action: "added",
nextRunAtMs: job.state.nextRunAtMs,
});
return job;
});
}
async update(id: string, patch: CronJobPatch) {
return await this.locked(async () => {
await this.ensureLoaded();
const job = this.findJobOrThrow(id);
const now = this.deps.nowMs();
if (isNonEmptyString(patch.name)) job.name = patch.name.trim();
if (patch.name === null || patch.name === "") job.name = undefined;
if (typeof patch.enabled === "boolean") job.enabled = patch.enabled;
if (patch.schedule) job.schedule = patch.schedule;
if (patch.sessionTarget) job.sessionTarget = patch.sessionTarget;
if (patch.wakeMode) job.wakeMode = patch.wakeMode;
if (patch.payload) job.payload = patch.payload;
if (patch.isolation) job.isolation = patch.isolation;
if (patch.state) job.state = { ...job.state, ...patch.state };
job.updatedAtMs = now;
if (job.enabled) {
job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, now);
} else {
job.state.nextRunAtMs = undefined;
job.state.runningAtMs = undefined;
}
await this.persist();
this.armTimer();
this.emit({
jobId: id,
action: "updated",
nextRunAtMs: job.state.nextRunAtMs,
});
return job;
});
}
async remove(id: string) {
return await this.locked(async () => {
await this.ensureLoaded();
const before = this.store?.jobs.length ?? 0;
if (!this.store) return { ok: false, removed: false };
this.store.jobs = this.store.jobs.filter((j) => j.id !== id);
const removed = (this.store.jobs.length ?? 0) !== before;
await this.persist();
this.armTimer();
if (removed) this.emit({ jobId: id, action: "removed" });
return { ok: true, removed };
});
}
async run(id: string, mode?: "due" | "force") {
return await this.locked(async () => {
await this.ensureLoaded();
const job = this.findJobOrThrow(id);
const now = this.deps.nowMs();
const due =
mode === "force" ||
(job.enabled &&
typeof job.state.nextRunAtMs === "number" &&
now >= job.state.nextRunAtMs);
if (!due) return { ok: true, ran: false, reason: "not-due" as const };
await this.executeJob(job, now, { forced: mode === "force" });
await this.persist();
this.armTimer();
return { ok: true, ran: true };
});
}
wake(opts: { mode: "now" | "next-heartbeat"; text: string }) {
const text = opts.text.trim();
if (!text) return { ok: false };
this.deps.enqueueSystemEvent(text);
if (opts.mode === "now") {
this.deps.requestReplyHeartbeatNow({ reason: "wake" });
}
return { ok: true };
}
private async locked<T>(fn: () => Promise<T>): Promise<T> {
const next = this.op.then(fn, fn);
// Keep the chain alive even when the operation fails.
this.op = next.then(
() => undefined,
() => undefined,
);
return (await next) as T;
}
private async ensureLoaded() {
if (this.store) return;
const loaded = await loadCronStore(this.deps.storePath);
this.store = { version: 1, jobs: loaded.jobs ?? [] };
}
private async persist() {
if (!this.store) return;
await saveCronStore(this.deps.storePath, this.store);
}
private findJobOrThrow(id: string) {
const job = this.store?.jobs.find((j) => j.id === id);
if (!job) throw new Error(`unknown cron job id: ${id}`);
return job;
}
private computeJobNextRunAtMs(job: CronJob, nowMs: number) {
if (!job.enabled) return undefined;
if (job.schedule.kind === "at") {
// One-shot jobs stay due until they successfully finish.
if (job.state.lastStatus === "ok" && job.state.lastRunAtMs)
return undefined;
return job.schedule.atMs;
}
return computeNextRunAtMs(job.schedule, nowMs);
}
private recomputeNextRuns() {
if (!this.store) return;
const now = this.deps.nowMs();
for (const job of this.store.jobs) {
if (!job.state) job.state = {};
if (!job.enabled) {
job.state.nextRunAtMs = undefined;
job.state.runningAtMs = undefined;
continue;
}
const runningAt = job.state.runningAtMs;
if (typeof runningAt === "number" && now - runningAt > STUCK_RUN_MS) {
this.deps.log.warn(
{ jobId: job.id, runningAtMs: runningAt },
"cron: clearing stuck running marker",
);
job.state.runningAtMs = undefined;
}
job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, now);
}
}
private nextWakeAtMs() {
const jobs = this.store?.jobs ?? [];
const enabled = jobs.filter(
(j) => j.enabled && typeof j.state.nextRunAtMs === "number",
);
if (enabled.length === 0) return undefined;
return enabled.reduce(
(min, j) => Math.min(min, j.state.nextRunAtMs as number),
enabled[0].state.nextRunAtMs as number,
);
}
private armTimer() {
if (this.timer) clearTimeout(this.timer);
this.timer = null;
if (!this.deps.cronEnabled) return;
const nextAt = this.nextWakeAtMs();
if (!nextAt) return;
const delay = Math.max(nextAt - this.deps.nowMs(), 0);
this.timer = setTimeout(() => {
void this.onTimer().catch((err) => {
this.deps.log.error({ err: String(err) }, "cron: timer tick failed");
});
}, delay);
this.timer.unref?.();
}
private async onTimer() {
if (this.running) return;
this.running = true;
try {
await this.locked(async () => {
await this.ensureLoaded();
await this.runDueJobs();
await this.persist();
this.armTimer();
});
} finally {
this.running = false;
}
}
private async runDueJobs() {
if (!this.store) return;
const now = this.deps.nowMs();
const due = this.store.jobs.filter((j) => {
if (!j.enabled) return false;
if (typeof j.state.runningAtMs === "number") return false;
const next = j.state.nextRunAtMs;
return typeof next === "number" && now >= next;
});
for (const job of due) {
await this.executeJob(job, now, { forced: false });
}
}
private async executeJob(
job: CronJob,
nowMs: number,
opts: { forced: boolean },
) {
const startedAt = this.deps.nowMs();
job.state.runningAtMs = startedAt;
job.state.lastError = undefined;
this.emit({ jobId: job.id, action: "started", runAtMs: startedAt });
const finish = async (
status: "ok" | "error" | "skipped",
err?: string,
summary?: string,
) => {
const endedAt = this.deps.nowMs();
job.state.runningAtMs = undefined;
job.state.lastRunAtMs = startedAt;
job.state.lastStatus = status;
job.state.lastDurationMs = Math.max(0, endedAt - startedAt);
job.state.lastError = err;
if (job.schedule.kind === "at" && status === "ok") {
// One-shot job completed successfully; disable it.
job.enabled = false;
job.state.nextRunAtMs = undefined;
} else if (job.enabled) {
job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, endedAt);
} else {
job.state.nextRunAtMs = undefined;
}
this.emit({
jobId: job.id,
action: "finished",
status,
error: err,
runAtMs: startedAt,
durationMs: job.state.lastDurationMs,
nextRunAtMs: job.state.nextRunAtMs,
});
if (summary && job.isolation?.postToMain) {
const prefix = job.isolation.postToMainPrefix?.trim() || "Cron";
this.deps.enqueueSystemEvent(`${prefix}: ${summary}`);
if (job.wakeMode === "now") {
this.deps.requestReplyHeartbeatNow({ reason: `cron:${job.id}:post` });
}
}
};
try {
if (job.sessionTarget === "main") {
const text = normalizePayloadToSystemText(job.payload);
this.deps.enqueueSystemEvent(text);
if (job.wakeMode === "now") {
this.deps.requestReplyHeartbeatNow({ reason: `cron:${job.id}` });
}
await finish("ok");
return;
}
if (job.payload.kind !== "agentTurn") {
await finish("skipped", "isolated job requires payload.kind=agentTurn");
return;
}
const res = await this.deps.runIsolatedAgentJob({
job,
message: job.payload.message,
});
if (res.status === "ok") await finish("ok", undefined, res.summary);
else if (res.status === "skipped")
await finish("skipped", undefined, res.summary);
else await finish("error", res.summary ?? "cron job failed");
} catch (err) {
await finish("error", String(err));
} finally {
job.updatedAtMs = nowMs;
if (!opts.forced && job.enabled) {
// Keep nextRunAtMs in sync in case the schedule advanced during a long run.
job.state.nextRunAtMs = this.computeJobNextRunAtMs(
job,
this.deps.nowMs(),
);
}
}
}
private emit(evt: CronEvent) {
try {
this.deps.onEvent?.(evt);
} catch {
/* ignore */
}
}
}

52
src/cron/store.ts Normal file
View File

@@ -0,0 +1,52 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import JSON5 from "json5";
import { CONFIG_DIR } from "../utils.js";
import type { CronStoreFile } from "./types.js";
export const LEGACY_CRON_STORE_PATH = path.join(
CONFIG_DIR,
"cron",
"jobs.json",
);
export const DEFAULT_CRON_STORE_PATH = path.join(CONFIG_DIR, "cron.json");
export function resolveCronStorePath(storePath?: string) {
if (storePath?.trim()) {
const raw = storePath.trim();
if (raw.startsWith("~"))
return path.resolve(raw.replace("~", os.homedir()));
return path.resolve(raw);
}
if (fs.existsSync(LEGACY_CRON_STORE_PATH)) return LEGACY_CRON_STORE_PATH;
return DEFAULT_CRON_STORE_PATH;
}
export async function loadCronStore(storePath: string): Promise<CronStoreFile> {
try {
const raw = await fs.promises.readFile(storePath, "utf-8");
const parsed = JSON5.parse(raw) as Partial<CronStoreFile> | null;
const jobs = Array.isArray(parsed?.jobs) ? (parsed?.jobs as never[]) : [];
return {
version: 1,
jobs: jobs.filter(Boolean) as never as CronStoreFile["jobs"],
};
} catch {
return { version: 1, jobs: [] };
}
}
export async function saveCronStore(storePath: string, store: CronStoreFile) {
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
const tmp = `${storePath}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
const json = JSON.stringify(store, null, 2);
await fs.promises.writeFile(tmp, json, "utf-8");
await fs.promises.rename(tmp, storePath);
try {
await fs.promises.copyFile(storePath, `${storePath}.bak`);
} catch {
// best-effort
}
}

64
src/cron/types.ts Normal file
View File

@@ -0,0 +1,64 @@
export type CronSchedule =
| { kind: "at"; atMs: number }
| { kind: "every"; everyMs: number; anchorMs?: number }
| { kind: "cron"; expr: string; tz?: string };
export type CronSessionTarget = "main" | "isolated";
export type CronWakeMode = "next-heartbeat" | "now";
export type CronPayload =
| { kind: "systemEvent"; text: string }
| {
kind: "agentTurn";
message: string;
thinking?: string;
timeoutSeconds?: number;
deliver?: boolean;
channel?: "last" | "whatsapp" | "telegram";
to?: string;
bestEffortDeliver?: boolean;
};
export type CronIsolation = {
postToMain?: boolean;
postToMainPrefix?: string;
};
export type CronJobState = {
nextRunAtMs?: number;
runningAtMs?: number;
lastRunAtMs?: number;
lastStatus?: "ok" | "error" | "skipped";
lastError?: string;
lastDurationMs?: number;
};
export type CronJob = {
id: string;
name?: string;
enabled: boolean;
createdAtMs: number;
updatedAtMs: number;
schedule: CronSchedule;
sessionTarget: CronSessionTarget;
wakeMode: CronWakeMode;
payload: CronPayload;
isolation?: CronIsolation;
state: CronJobState;
};
export type CronStoreFile = {
version: 1;
jobs: CronJob[];
};
export type CronJobCreate = Omit<
CronJob,
"id" | "createdAtMs" | "updatedAtMs" | "state"
> & {
state?: Partial<CronJobState>;
};
export type CronJobPatch = Partial<
Omit<CronJob, "id" | "createdAtMs" | "state"> & { state: CronJobState }
>;