diff --git a/ui/src/components/sidebar-attention-items.ts b/ui/src/components/sidebar-attention-items.ts index 20617925446a..0fdd3d49a35b 100644 --- a/ui/src/components/sidebar-attention-items.ts +++ b/ui/src/components/sidebar-attention-items.ts @@ -5,7 +5,7 @@ import type { CronJob, ModelAuthStatusResult } from "../api/types.ts"; import type { NavigationRouteId } from "../app-navigation.ts"; import type { ExecApprovalRequest } from "../app/exec-approval.ts"; import { t } from "../i18n/index.ts"; -import { isCronJobActiveFailure } from "../lib/cron-status.ts"; +import { isCronJobActiveFailure, isCronJobRunning } from "../lib/cron-status.ts"; import { clampText } from "../lib/format.ts"; import { isMonitoredAuthProvider } from "../lib/model-auth.ts"; import type { IconName } from "./icons.ts"; @@ -89,6 +89,7 @@ export function buildSidebarAttentionItems(params: { const overdueCron = params.cronJobs.filter( (job) => job.enabled && + !isCronJobRunning(job) && job.state?.nextRunAtMs != null && params.now - job.state.nextRunAtMs > CRON_OVERDUE_GRACE_MS, ); diff --git a/ui/src/components/sidebar-attention.test.ts b/ui/src/components/sidebar-attention.test.ts index d257994e8974..e1cfc8834e86 100644 --- a/ui/src/components/sidebar-attention.test.ts +++ b/ui/src/components/sidebar-attention.test.ts @@ -153,6 +153,22 @@ describe("cron attention details", () => { expect(overdue?.detail).toBe("Nightly backup\nunnamed-id"); }); + + it("does not flag an actively running job as overdue", () => { + // The gateway leaves nextRunAtMs past-due during execution; runningAtMs is + // the recorded fact that a run is in flight (agentTurn runs may take up to + // an hour, far beyond the 5-minute overdue grace). + const running = cronJob("running-id"); + running.state = { lastRunStatus: "ok", nextRunAtMs: 1, runningAtMs: 2 }; + const stalled = cronJob("stalled-id"); + stalled.state = { lastRunStatus: "ok", nextRunAtMs: 2 }; + + const overdue = cronItems([running, stalled], 300_003).find( + (item) => item.kind === "cronOverdue", + ); + + expect(overdue?.detail).toBe("stalled-id"); + }); }); describe("pending approval attention", () => { diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 8ebc6b0fb63f..9524c4855802 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -5839,6 +5839,7 @@ export const en: TranslationMap = { runStatusError: "Error", runStatusSkipped: "Skipped", runStatusUnknown: "Unknown", + runStatusRunning: "Running", deliveryDelivered: "Delivered", deliveryNotDelivered: "Not delivered", deliveryUnknown: "Unknown", diff --git a/ui/src/lib/cron-status.ts b/ui/src/lib/cron-status.ts index c5b857af74e9..9d7db298aaf3 100644 --- a/ui/src/lib/cron-status.ts +++ b/ui/src/lib/cron-status.ts @@ -7,6 +7,14 @@ export function resolveCronJobLastRunStatus(job: CronJob): CronJobLastRunStatus return job.state?.lastRunStatus ?? job.state?.lastStatus ?? "unknown"; } +// The gateway intentionally leaves nextRunAtMs past-due while a run executes +// (it only advances on the outcome), so "overdue" surfaces must not flag a +// job that is actively running. runningAtMs is the recorded fact for that. +export function isCronJobRunning(job: CronJob): boolean { + const runningAtMs = job.state?.runningAtMs; + return typeof runningAtMs === "number" && Number.isFinite(runningAtMs); +} + // "Failed cron" surfaces (cron page, sidebar attention chips) track current // actionability, so a failure only counts while the job is still enabled. // Disabled jobs keep their historical `lastRunStatus: "error"` for detail diff --git a/ui/src/pages/cron/view-running-state.test.ts b/ui/src/pages/cron/view-running-state.test.ts new file mode 100644 index 000000000000..467fea2a735d --- /dev/null +++ b/ui/src/pages/cron/view-running-state.test.ts @@ -0,0 +1,17 @@ +// Running-state rendering for the Automations (cron) table. +// Lives beside view.test.ts, which sits at the max-lines cap. +import { expect, it } from "vitest"; +import { + createCronViewJob as createJob, + renderCronView as renderView, +} from "./view.test-support.ts"; + +it("shows Running instead of a past-due next-run time while a run executes", () => { + const running = createJob("job-running", { + state: { nextRunAtMs: Date.now() - 600_000, runningAtMs: Date.now() - 60_000 }, + }); + const container = renderView({ jobs: [running] }); + const row = container.querySelector(".cron-table__row"); + expect(row?.querySelector(".cron-table__running")?.textContent).toBe("Running"); + expect(row?.textContent).not.toContain("ago"); +}); diff --git a/ui/src/pages/cron/view.ts b/ui/src/pages/cron/view.ts index 3d0a8862c14b..61738ede3530 100644 --- a/ui/src/pages/cron/view.ts +++ b/ui/src/pages/cron/view.ts @@ -36,7 +36,11 @@ import { renderSettingsToggleRow, } from "../../components/settings-ui.ts"; import { t } from "../../i18n/index.ts"; -import { isCronJobActiveFailure, resolveCronJobLastRunStatus } from "../../lib/cron-status.ts"; +import { + isCronJobActiveFailure, + isCronJobRunning, + resolveCronJobLastRunStatus, +} from "../../lib/cron-status.ts"; import { parseCronEveryMs } from "../../lib/cron/decimal.ts"; import type { CronFieldErrors, @@ -755,7 +759,11 @@ function renderJobRow(job: CronJob, props: CronProps) { ${formatCronSchedule(job)} - ${hasNextRun ? formatRelativeTimestamp(nextRunAtMs) : t("common.na")} + ${isCronJobRunning(job) + ? html`${t("cron.runs.runStatusRunning")}` + : hasNextRun + ? formatRelativeTimestamp(nextRunAtMs) + : t("common.na")} ${renderLastRunCell(job)}