mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-20 09:31:54 -06:00
4e9ae9fbff
* feat(cron): system-owned heartbeat monitor jobs replace the interval scheduler
- new internal cron payload kind {kind:"heartbeat"}: execution pokes
requestHeartbeat({source:"interval"}); reported in the protocol job
schema, not accepted from client create/patch
- gateway converges one declaration-keyed monitor job per heartbeat-enabled
agent (schedule every+deterministic phase anchor) at startup and on
config reload; removes monitors for unconfigured agents
- heartbeat runner loses its interval setTimeout machinery; nextDueMs
stays as the cooldown gate, event wakes unchanged
* test(cron): heartbeat monitor regressions; docs for cron-owned cadence
- converge/prune/failure-containment tests for heartbeat monitor jobs
- heartbeat payload run fires an interval wake, no system event
- scheduler tests converted from timer self-fire to wake-queue pokes;
timer-mechanics-only tests deleted with the timer
- persisted-shape accepts the heartbeat payload kind
- docs: heartbeat cadence ownership + system payload kind
* fix(cron): heartbeat monitor review round 1
- targeted cron-monitor interval ticks use the full per-agent path so
due-commitment sessions still deliver
- cron-disabled gateways keep a local fallback interval timer (shipped
cron.enabled=false contract; removed when heartbeat config folds into
cron in #110950)
- heartbeat job reconciliations serialize with latest-wins epochs and a
bounded 30s retry after a failed convergence pass
* fix(cron): chain clamped fallback heartbeat timers past the setTimeout cap
* fix(cron): heartbeat monitor review round 3
- targeted monitor redirect skips wakes carrying heartbeat overrides and
surfaces the per-agent terminal skip reason instead of not-due
- cron-disabled fallback timer re-arms with a 1s floor after each firing
so a dropped wake cannot end the chain
- heartbeat payloads are system-owned at the service boundary: add requires
the gateway opt-in, patches to the kind are rejected
* fix(cron): heartbeat monitor review round 4 — full ownership enforcement
- prune only jobs proven to be monitors (prefix AND heartbeat payload)
- existing monitors reject every update patch; declarative upserts on the
monitor key require the gateway opt-in even with a different payload
* fix(cron): complete heartbeat monitor ownership boundary
- converge scopes declarative matching to real monitors so a colliding
user job with the same key is never adopted or overwritten
- monitor removal requires the gateway systemOwned opt-in; ad-hoc
API/CLI deletion is rejected, reconciliation cleanup still prunes
* docs(cron): record intentional enrollment-snapshot semantics for monitor ticks
* fix(cron): repair heartbeat monitor CI gates
101 lines
2.9 KiB
TypeScript
101 lines
2.9 KiB
TypeScript
import type { CronJob, GatewaySessionRow } from "../api/types.ts";
|
|
// Control UI module implements presenter behavior.
|
|
import { t } from "../i18n/index.ts";
|
|
import { resolveCronJobLastRunStatus } from "../lib/cron-status.ts";
|
|
import {
|
|
formatDateMs,
|
|
formatRelativeTimestamp,
|
|
formatDurationHuman,
|
|
formatMs,
|
|
formatUnknownText,
|
|
} from "../lib/format.ts";
|
|
|
|
export function formatNextRun(ms?: number | null) {
|
|
if (!ms) {
|
|
return t("common.na");
|
|
}
|
|
const weekday = formatDateMs(ms, { weekday: "short" });
|
|
if (weekday === t("common.na")) {
|
|
return weekday;
|
|
}
|
|
return `${weekday}, ${formatMs(ms)} (${formatRelativeTimestamp(ms)})`;
|
|
}
|
|
|
|
export function formatSessionTokens(row: GatewaySessionRow) {
|
|
if (row.totalTokens == null) {
|
|
return t("common.na");
|
|
}
|
|
const total = row.totalTokens ?? 0;
|
|
const ctx = row.contextTokens ?? 0;
|
|
return ctx ? `${total} / ${ctx}` : String(total);
|
|
}
|
|
|
|
export function formatEventPayload(payload: unknown): string {
|
|
if (payload == null) {
|
|
return "";
|
|
}
|
|
try {
|
|
return JSON.stringify(payload, null, 2);
|
|
} catch {
|
|
return formatUnknownText(payload);
|
|
}
|
|
}
|
|
|
|
export function formatCronState(job: CronJob) {
|
|
const state = job.state ?? {};
|
|
const next = state.nextRunAtMs ? formatMs(state.nextRunAtMs) : t("common.na");
|
|
const last = state.lastRunAtMs ? formatMs(state.lastRunAtMs) : t("common.na");
|
|
const status = resolveCronJobLastRunStatus(job);
|
|
return `${status} · next ${next} · last ${last}`;
|
|
}
|
|
|
|
export function formatCronSchedule(job: CronJob) {
|
|
const s = job.schedule;
|
|
if (s.kind === "at") {
|
|
const atMs = Date.parse(s.at);
|
|
return Number.isFinite(atMs) ? `At ${formatMs(atMs)}` : `At ${s.at}`;
|
|
}
|
|
if (s.kind === "every") {
|
|
return `Every ${formatDurationHuman(s.everyMs)}`;
|
|
}
|
|
if (s.kind === "on-exit") {
|
|
// on-exit jobs carry a watched command (+ optional cwd), not a cron expr;
|
|
// without this branch they fall through and render "Cron undefined".
|
|
return `On exit: ${s.command}${s.cwd ? ` (cwd: ${s.cwd})` : ""}`;
|
|
}
|
|
if (s.kind === "stream") {
|
|
return `Stream: ${s.command.join(" ")}${s.cwd ? ` (cwd: ${s.cwd})` : ""}`;
|
|
}
|
|
return `Cron ${s.expr}${s.tz ? ` (${s.tz})` : ""}`;
|
|
}
|
|
|
|
export function formatCronPayload(job: CronJob) {
|
|
const p = job.payload;
|
|
if (p.kind === "systemEvent") {
|
|
return `System: ${p.text}`;
|
|
}
|
|
if (p.kind === "command") {
|
|
return `Command: ${p.argv.join(" ")}`;
|
|
}
|
|
if (p.kind === "script") {
|
|
return `Script: ${p.script}`;
|
|
}
|
|
if (p.kind === "heartbeat") {
|
|
return "Heartbeat monitor";
|
|
}
|
|
const base = `Agent: ${p.message}`;
|
|
const delivery = job.delivery;
|
|
if (delivery && delivery.mode !== "none") {
|
|
const target =
|
|
delivery.mode === "webhook"
|
|
? delivery.to
|
|
? ` (${delivery.to})`
|
|
: ""
|
|
: delivery.channel || delivery.to
|
|
? ` (${delivery.channel ?? "last"}${delivery.to ? ` -> ${delivery.to}` : ""})`
|
|
: "";
|
|
return `${base} · ${delivery.mode}${target}`;
|
|
}
|
|
return base;
|
|
}
|