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