Cron: add scheduler, wakeups, and run history
This commit is contained in:
@@ -115,7 +115,9 @@ describe("heartbeat helpers", () => {
|
||||
describe("resolveHeartbeatRecipients", () => {
|
||||
it("returns the sole session recipient", async () => {
|
||||
const now = Date.now();
|
||||
const store = await makeSessionStore({ "+1000": { updatedAt: now } });
|
||||
const store = await makeSessionStore({
|
||||
main: { updatedAt: now, lastChannel: "whatsapp", lastTo: "+1000" },
|
||||
});
|
||||
const cfg: ClawdisConfig = {
|
||||
inbound: {
|
||||
allowFrom: ["+1999"],
|
||||
@@ -131,8 +133,8 @@ describe("resolveHeartbeatRecipients", () => {
|
||||
it("surfaces ambiguity when multiple sessions exist", async () => {
|
||||
const now = Date.now();
|
||||
const store = await makeSessionStore({
|
||||
"+1000": { updatedAt: now },
|
||||
"+2000": { updatedAt: now - 10 },
|
||||
main: { updatedAt: now, lastChannel: "whatsapp", lastTo: "+1000" },
|
||||
alt: { updatedAt: now - 10, lastChannel: "whatsapp", lastTo: "+2000" },
|
||||
});
|
||||
const cfg: ClawdisConfig = {
|
||||
inbound: {
|
||||
@@ -162,7 +164,9 @@ describe("resolveHeartbeatRecipients", () => {
|
||||
|
||||
it("merges sessions and allowFrom when --all is set", async () => {
|
||||
const now = Date.now();
|
||||
const store = await makeSessionStore({ "+1000": { updatedAt: now } });
|
||||
const store = await makeSessionStore({
|
||||
main: { updatedAt: now, lastChannel: "whatsapp", lastTo: "+1000" },
|
||||
});
|
||||
const cfg: ClawdisConfig = {
|
||||
inbound: {
|
||||
allowFrom: ["+1999"],
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
resolveReconnectPolicy,
|
||||
sleepWithAbort,
|
||||
} from "./reconnect.js";
|
||||
import type { ReplyHeartbeatWakeResult } from "./reply-heartbeat-wake.js";
|
||||
import { setReplyHeartbeatWakeHandler } from "./reply-heartbeat-wake.js";
|
||||
import { formatError, getWebAuthAgeMs, readWebSelfId } from "./session.js";
|
||||
|
||||
const WEB_TEXT_LIMIT = 4000;
|
||||
@@ -379,21 +381,24 @@ export async function runWebHeartbeatOnce(opts: {
|
||||
}
|
||||
|
||||
function getFallbackRecipient(cfg: ReturnType<typeof loadConfig>) {
|
||||
const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store);
|
||||
const sessionCfg = cfg.inbound?.reply?.session;
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
const candidates = Object.entries(store).filter(([key]) => key !== "global");
|
||||
if (candidates.length === 0) {
|
||||
const allowFrom =
|
||||
Array.isArray(cfg.inbound?.allowFrom) && cfg.inbound.allowFrom.length > 0
|
||||
? cfg.inbound.allowFrom.filter((v) => v !== "*")
|
||||
: [];
|
||||
if (allowFrom.length === 0) return null;
|
||||
return allowFrom[0] ? normalizeE164(allowFrom[0]) : null;
|
||||
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
|
||||
const main = store[mainKey];
|
||||
const lastTo = typeof main?.lastTo === "string" ? main.lastTo.trim() : "";
|
||||
const lastChannel = main?.lastChannel;
|
||||
|
||||
if (lastChannel === "whatsapp" && lastTo) {
|
||||
return normalizeE164(lastTo);
|
||||
}
|
||||
const mostRecent = candidates.sort(
|
||||
(a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0),
|
||||
)[0];
|
||||
return mostRecent ? normalizeE164(mostRecent[0]) : null;
|
||||
|
||||
const allowFrom =
|
||||
Array.isArray(cfg.inbound?.allowFrom) && cfg.inbound.allowFrom.length > 0
|
||||
? cfg.inbound.allowFrom.filter((v) => v !== "*")
|
||||
: [];
|
||||
if (allowFrom.length === 0) return null;
|
||||
return allowFrom[0] ? normalizeE164(allowFrom[0]) : null;
|
||||
}
|
||||
|
||||
function getSessionRecipients(cfg: ReturnType<typeof loadConfig>) {
|
||||
@@ -402,14 +407,30 @@ function getSessionRecipients(cfg: ReturnType<typeof loadConfig>) {
|
||||
if (scope === "global") return [];
|
||||
const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
return Object.entries(store)
|
||||
const isGroupKey = (key: string) =>
|
||||
key.startsWith("group:") || key.includes("@g.us");
|
||||
const isCronKey = (key: string) => key.startsWith("cron:");
|
||||
|
||||
const recipients = Object.entries(store)
|
||||
.filter(([key]) => key !== "global" && key !== "unknown")
|
||||
.map(([key, entry]) => ({
|
||||
to: normalizeE164(key),
|
||||
.filter(([key]) => !isGroupKey(key) && !isCronKey(key))
|
||||
.map(([_, entry]) => ({
|
||||
to:
|
||||
entry?.lastChannel === "whatsapp" && entry?.lastTo
|
||||
? normalizeE164(entry.lastTo)
|
||||
: "",
|
||||
updatedAt: entry?.updatedAt ?? 0,
|
||||
}))
|
||||
.filter(({ to }) => Boolean(to))
|
||||
.filter(({ to }) => to.length > 1)
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
// Dedupe while preserving recency ordering.
|
||||
const seen = new Set<string>();
|
||||
return recipients.filter((r) => {
|
||||
if (seen.has(r.to)) return false;
|
||||
seen.add(r.to);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveHeartbeatRecipients(
|
||||
@@ -1055,6 +1076,7 @@ export async function monitorWebProvider(
|
||||
|
||||
const closeListener = async () => {
|
||||
setActiveWebListener(null);
|
||||
setReplyHeartbeatWakeHandler(null);
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
if (replyHeartbeatTimer) clearInterval(replyHeartbeatTimer);
|
||||
if (watchdogTimer) clearInterval(watchdogTimer);
|
||||
@@ -1126,8 +1148,11 @@ export async function monitorWebProvider(
|
||||
}, WATCHDOG_CHECK_MS);
|
||||
}
|
||||
|
||||
const runReplyHeartbeat = async () => {
|
||||
if (!heartbeatsEnabled) return;
|
||||
const runReplyHeartbeat = async (): Promise<ReplyHeartbeatWakeResult> => {
|
||||
const started = Date.now();
|
||||
if (!heartbeatsEnabled) {
|
||||
return { status: "skipped", reason: "disabled" };
|
||||
}
|
||||
const queued = getQueueSize();
|
||||
if (queued > 0) {
|
||||
heartbeatLogger.info(
|
||||
@@ -1135,16 +1160,18 @@ export async function monitorWebProvider(
|
||||
"reply heartbeat skipped",
|
||||
);
|
||||
console.log(success("heartbeat: skipped (requests in flight)"));
|
||||
return;
|
||||
return { status: "skipped", reason: "requests-in-flight" };
|
||||
}
|
||||
if (!replyHeartbeatMinutes) {
|
||||
return { status: "skipped", reason: "disabled" };
|
||||
}
|
||||
if (!replyHeartbeatMinutes) return;
|
||||
if (lastInboundMsg?.chatType === "group") {
|
||||
heartbeatLogger.info(
|
||||
{ connectionId, reason: "last-inbound-group" },
|
||||
"reply heartbeat skipped",
|
||||
);
|
||||
console.log(success("heartbeat: skipped (group chat)"));
|
||||
return;
|
||||
return { status: "skipped", reason: "group-chat" };
|
||||
}
|
||||
const tickStart = Date.now();
|
||||
if (!lastInboundMsg) {
|
||||
@@ -1159,7 +1186,7 @@ export async function monitorWebProvider(
|
||||
"reply heartbeat skipped",
|
||||
);
|
||||
console.log(success("heartbeat: skipped (no recent inbound)"));
|
||||
return;
|
||||
return { status: "skipped", reason: "no-recent-inbound" };
|
||||
}
|
||||
const snapshot = getSessionSnapshot(cfg, fallbackTo, true);
|
||||
if (!snapshot.entry) {
|
||||
@@ -1168,7 +1195,7 @@ export async function monitorWebProvider(
|
||||
"reply heartbeat skipped",
|
||||
);
|
||||
console.log(success("heartbeat: skipped (no session to resume)"));
|
||||
return;
|
||||
return { status: "skipped", reason: "no-session-for-fallback" };
|
||||
}
|
||||
if (isVerbose()) {
|
||||
heartbeatLogger.info(
|
||||
@@ -1199,7 +1226,7 @@ export async function monitorWebProvider(
|
||||
},
|
||||
"reply heartbeat sent (fallback session)",
|
||||
);
|
||||
return;
|
||||
return { status: "ran", durationMs: Date.now() - started };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1252,7 +1279,7 @@ export async function monitorWebProvider(
|
||||
"reply heartbeat skipped",
|
||||
);
|
||||
console.log(success("heartbeat: ok (empty reply)"));
|
||||
return;
|
||||
return { status: "ran", durationMs: Date.now() - started };
|
||||
}
|
||||
|
||||
const stripped = stripHeartbeatToken(replyPayload.text);
|
||||
@@ -1270,7 +1297,7 @@ export async function monitorWebProvider(
|
||||
"reply heartbeat skipped",
|
||||
);
|
||||
console.log(success("heartbeat: ok (HEARTBEAT_OK)"));
|
||||
return;
|
||||
return { status: "ran", durationMs: Date.now() - started };
|
||||
}
|
||||
|
||||
// Apply response prefix if configured (same as regular messages)
|
||||
@@ -1310,6 +1337,7 @@ export async function monitorWebProvider(
|
||||
},
|
||||
"reply heartbeat sent",
|
||||
);
|
||||
return { status: "ran", durationMs: Date.now() - started };
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - tickStart;
|
||||
heartbeatLogger.warn(
|
||||
@@ -1323,9 +1351,12 @@ export async function monitorWebProvider(
|
||||
console.log(
|
||||
danger(`heartbeat: failed (${formatDuration(durationMs)})`),
|
||||
);
|
||||
return { status: "failed", reason: String(err) };
|
||||
}
|
||||
};
|
||||
|
||||
setReplyHeartbeatWakeHandler(async () => runReplyHeartbeat());
|
||||
|
||||
if (replyHeartbeatMinutes && !replyHeartbeatTimer) {
|
||||
const intervalMs = replyHeartbeatMinutes * 60_000;
|
||||
replyHeartbeatTimer = setInterval(() => {
|
||||
|
||||
77
src/web/reply-heartbeat-wake.ts
Normal file
77
src/web/reply-heartbeat-wake.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export type ReplyHeartbeatWakeResult =
|
||||
| { status: "ran"; durationMs: number }
|
||||
| { status: "skipped"; reason: string }
|
||||
| { status: "failed"; reason: string };
|
||||
|
||||
export type ReplyHeartbeatWakeHandler = (opts: {
|
||||
reason?: string;
|
||||
}) => Promise<ReplyHeartbeatWakeResult>;
|
||||
|
||||
let handler: ReplyHeartbeatWakeHandler | null = null;
|
||||
let pendingReason: string | null = null;
|
||||
let scheduled = false;
|
||||
let running = false;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
const DEFAULT_COALESCE_MS = 250;
|
||||
const DEFAULT_RETRY_MS = 1_000;
|
||||
|
||||
function schedule(coalesceMs: number) {
|
||||
if (timer) return;
|
||||
timer = setTimeout(async () => {
|
||||
timer = null;
|
||||
scheduled = false;
|
||||
const active = handler;
|
||||
if (!active) return;
|
||||
if (running) {
|
||||
scheduled = true;
|
||||
schedule(coalesceMs);
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = pendingReason;
|
||||
pendingReason = null;
|
||||
running = true;
|
||||
try {
|
||||
const res = await active({ reason: reason ?? undefined });
|
||||
if (res.status === "skipped" && res.reason === "requests-in-flight") {
|
||||
// The main lane is busy; retry soon.
|
||||
pendingReason = reason ?? "retry";
|
||||
schedule(DEFAULT_RETRY_MS);
|
||||
}
|
||||
} catch (err) {
|
||||
pendingReason = reason ?? "retry";
|
||||
schedule(DEFAULT_RETRY_MS);
|
||||
throw err;
|
||||
} finally {
|
||||
running = false;
|
||||
if (pendingReason || scheduled) schedule(coalesceMs);
|
||||
}
|
||||
}, coalesceMs);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
export function setReplyHeartbeatWakeHandler(
|
||||
next: ReplyHeartbeatWakeHandler | null,
|
||||
) {
|
||||
handler = next;
|
||||
if (handler && pendingReason) {
|
||||
schedule(DEFAULT_COALESCE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export function requestReplyHeartbeatNow(opts?: {
|
||||
reason?: string;
|
||||
coalesceMs?: number;
|
||||
}) {
|
||||
pendingReason = opts?.reason ?? pendingReason ?? "requested";
|
||||
schedule(opts?.coalesceMs ?? DEFAULT_COALESCE_MS);
|
||||
}
|
||||
|
||||
export function hasReplyHeartbeatWakeHandler() {
|
||||
return handler !== null;
|
||||
}
|
||||
|
||||
export function hasPendingReplyHeartbeatWake() {
|
||||
return pendingReason !== null || Boolean(timer) || scheduled;
|
||||
}
|
||||
Reference in New Issue
Block a user