Files
openclaw/ui/src/lib/presenter.ts
T
Peter Steinberger 4e9ae9fbff feat(cron): system-owned heartbeat monitor jobs replace the dedicated interval scheduler (#112585)
* 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
2026-07-22 14:03:29 -07:00

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;
}