fix(control-ui): exclude disabled cron jobs from failed count (#95723)

Overview surfaces counted any job whose lastRunStatus was "error" as a
current failure, so an intentionally disabled job that previously failed
kept inflating the top-level "failed cron" badge and attention callout.

Add a shared isCronJobActiveFailure predicate that gates the error status
on enabled, matching the adjacent overdue filter, and use it in both the
overview card and the attention items list. Historical status stays
visible in detail views via resolveCronJobLastRunStatus.
This commit is contained in:
Wynne668
2026-06-23 01:13:19 +00:00
committed by GitHub
parent 760f86453e
commit 9fa14ff61a
4 changed files with 52 additions and 6 deletions
+2 -2
View File
@@ -64,7 +64,7 @@ import {
stopWorkboardLifecycleRefresh,
stopWorkboardPolling,
} from "./controllers/workboard.ts";
import { resolveCronJobLastRunStatus } from "./cron-status.ts";
import { isCronJobActiveFailure } from "./cron-status.ts";
import { syncCustomThemeStyleTag } from "./custom-theme.ts";
import { isMonitoredAuthProvider } from "./model-auth-helpers.ts";
import {
@@ -941,7 +941,7 @@ function buildAttentionItems(host: SettingsAppHost) {
}
const cronJobs = host.cronJobs ?? [];
const failedCron = cronJobs.filter((j) => resolveCronJobLastRunStatus(j) === "error");
const failedCron = cronJobs.filter(isCronJobActiveFailure);
if (failedCron.length > 0) {
items.push({
severity: "error",
+40
View File
@@ -0,0 +1,40 @@
// Control UI tests cover cron status derivation behavior.
import { describe, expect, it } from "vitest";
import { isCronJobActiveFailure, resolveCronJobLastRunStatus } from "./cron-status.ts";
import type { CronJob } from "./types.ts";
function job(overrides: Partial<CronJob> = {}): CronJob {
return {
id: "job",
name: "Job",
enabled: true,
createdAtMs: 0,
updatedAtMs: 0,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "test" },
...overrides,
};
}
describe("isCronJobActiveFailure", () => {
it("counts an enabled job whose last run errored", () => {
expect(isCronJobActiveFailure(job({ state: { lastRunStatus: "error" } }))).toBe(true);
});
it("ignores a disabled job that retains historical error state", () => {
const disabled = job({
enabled: false,
state: { lastRunStatus: "error", consecutiveErrors: 6, nextRunAtMs: undefined },
});
// Historical status is still preserved for detail views.
expect(resolveCronJobLastRunStatus(disabled)).toBe("error");
expect(isCronJobActiveFailure(disabled)).toBe(false);
});
it("does not count enabled jobs whose last run succeeded or is unknown", () => {
expect(isCronJobActiveFailure(job({ state: { lastRunStatus: "ok" } }))).toBe(false);
expect(isCronJobActiveFailure(job())).toBe(false);
});
});
+8
View File
@@ -6,3 +6,11 @@ export type CronJobLastRunStatus = CronRunStatus | "unknown";
export function resolveCronJobLastRunStatus(job: CronJob): CronJobLastRunStatus {
return job.state?.lastRunStatus ?? job.state?.lastStatus ?? "unknown";
}
// Overview "failed cron" surfaces track current actionability, so a failure only
// counts while the job is still enabled. Disabled jobs keep their historical
// `lastRunStatus: "error"` for detail views, but a retired job must not be
// reported as an active operational problem.
export function isCronJobActiveFailure(job: CronJob): boolean {
return job.enabled && resolveCronJobLastRunStatus(job) === "error";
}
+2 -4
View File
@@ -3,7 +3,7 @@ import { asDateTimestampMs } from "@openclaw/normalization-core/number-coercion"
import { html, nothing, type TemplateResult } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { t } from "../../i18n/index.ts";
import { resolveCronJobLastRunStatus } from "../cron-status.ts";
import { isCronJobActiveFailure } from "../cron-status.ts";
import { formatCost, formatTokens, formatRelativeTimestamp } from "../format.ts";
import { isMonitoredAuthProvider } from "../model-auth-helpers.ts";
import { formatNextRun } from "../presenter.ts";
@@ -134,9 +134,7 @@ export function renderOverviewCards(props: OverviewCardsProps) {
const cronEnabled = props.cronStatus?.enabled ?? null;
const cronNext = props.cronStatus?.nextWakeAtMs ?? null;
const cronJobCount = props.cronJobs.length;
const failedCronCount = props.cronJobs.filter(
(j) => resolveCronJobLastRunStatus(j) === "error",
).length;
const failedCronCount = props.cronJobs.filter(isCronJobActiveFailure).length;
const authLoading = props.modelAuthStatus === null;
const authProviders = props.modelAuthStatus?.providers ?? [];
const monitoredProviders = authProviders.filter(isMonitoredAuthProvider);