diff --git a/client/src/components/cos/JobCard.jsx b/client/src/components/cos/JobCard.jsx
index 6f6b2652bf..f7cbeb598e 100644
--- a/client/src/components/cos/JobCard.jsx
+++ b/client/src/components/cos/JobCard.jsx
@@ -3,7 +3,7 @@ import { Play, Trash2, ChevronDown, ChevronUp, Clock, ToggleLeft, ToggleRight, E
import toast from '../ui/Toast';
import * as api from '../../services/api';
import { timeAgo, timeUntil, formatDateNumeric } from '../../utils/formatters';
-import { DEFAULT_CRON, describeCron, describeRecurrence, parseCronToRecurrence, buildCronFromRecurrence, JOB_INTERVAL_OPTIONS as INTERVAL_OPTIONS } from '../../utils/cronHelpers';
+import { DEFAULT_CRON, describeCron, describeRecurrence, parseCronToRecurrence, buildCronFromRecurrence, JOB_INTERVAL_OPTIONS as INTERVAL_OPTIONS, ON_DEMAND_INTERVAL } from '../../utils/cronHelpers';
import CronSchedulePicker from '../CronSchedulePicker';
import AgentJobProviderFields, { hasRunnableAgentProvider } from './AgentJobProviderFields';
import { AGENT_OPTIONS, agentOptionButtonClass } from './constants';
@@ -46,6 +46,12 @@ export const JOB_TYPE_OPTIONS = [
// AI runner.
export const isAgentJobType = (type) => type !== 'shell' && type !== 'script';
+// Interval-mode job with the no-recurrence cadence: never due, never scheduled,
+// runnable only from the Run-now button. Cron mode overrides it — a job switched
+// to Cron keeps whatever cadence it last had. Mirrors isOnDemandJob on the server.
+export const isOnDemandSchedule = (job) =>
+ !job?.cronExpression && !job?.cronSchedule && job?.interval === ON_DEMAND_INTERVAL;
+
const BRIEFING_CONFIG_OPTIONS = [
{ key: 'dailyJoke', label: 'Daily Joke', desc: 'Include a short joke to start the day' },
{ key: 'dailyQuote', label: 'Daily Quote', desc: 'Include an inspirational quote related to focus areas' },
@@ -127,25 +133,35 @@ export function ScheduleFields({ data, onChange, timezone }) {
/>
) : (
-
@@ -156,6 +172,8 @@ function formatNextDue(job) {
// Cron jobs: show human-readable schedule (server computes exact next fire time)
if (job.cronSchedule) return describeRecurrence(job.cronSchedule);
if (job.cronExpression) return describeCron(job.cronExpression);
+ // No recurrence — rendering lastRun + a null intervalMs would print 'Invalid Date'.
+ if (isOnDemandSchedule(job)) return 'On demand';
const { lastRun, intervalMs, scheduledTime } = job;
if (!lastRun) return scheduledTime ? `at ${scheduledTime}` : 'Immediately';
@@ -325,7 +343,7 @@ export default function JobCard({
onUpdate();
};
- const isDue = job.enabled && (job.cronSchedule
+ const isDue = job.enabled && !isOnDemandSchedule(job) && (job.cronSchedule
? (!job.lastRun || Boolean(job.nextRunAt && Date.now() >= new Date(job.nextRunAt).getTime()))
: (!job.lastRun || (Date.now() - new Date(job.lastRun).getTime() >= job.intervalMs)));
diff --git a/client/src/components/cos/JobCard.test.jsx b/client/src/components/cos/JobCard.test.jsx
index 8ec9ebf457..8cf158775f 100644
--- a/client/src/components/cos/JobCard.test.jsx
+++ b/client/src/components/cos/JobCard.test.jsx
@@ -69,3 +69,57 @@ describe('JobCard machine output', () => {
expect(pre.className).toContain('break-all');
});
});
+
+// #6375 — the on-demand cadence. The card must not render an arithmetic-derived
+// date for a job with no interval, and the edit form must not offer a
+// time-of-day the job can never honor.
+describe('JobCard on-demand cadence', () => {
+ const ON_DEMAND_JOB = {
+ id: 'job-manual',
+ name: 'Manual only',
+ description: '',
+ type: 'agent',
+ category: 'custom',
+ interval: 'on-demand',
+ intervalMs: null,
+ priority: 'MEDIUM',
+ autonomyLevel: 'manager',
+ enabled: true,
+ lastRun: '2026-01-01T00:00:00.000Z',
+ runCount: 3
+ };
+
+ it('renders "On demand" as the next run instead of a date derived from a null interval', () => {
+ renderCard(ON_DEMAND_JOB);
+
+ expect(screen.getByText('Next: On demand')).toBeTruthy();
+ expect(screen.queryByText(/Invalid Date/)).toBeNull();
+ expect(screen.queryByText(/NaN/)).toBeNull();
+ });
+
+ it('never shows the Due badge, however long ago the job last ran', () => {
+ renderCard({ ...ON_DEMAND_JOB, lastRun: null });
+
+ // A never-run recurring job is due immediately; an on-demand one never is.
+ expect(screen.queryByText('Due')).toBeNull();
+ });
+
+ it('labels the cadence from the shared option list', () => {
+ renderCard(ON_DEMAND_JOB);
+ expect(screen.getByText('On Demand')).toBeTruthy();
+ });
+
+ it('offers the cadence and hides the time input once it is selected', () => {
+ renderCard({ ...ON_DEMAND_JOB, interval: 'daily', intervalMs: 86400000, scheduledTime: '09:00' });
+ fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
+
+ const select = screen.getByLabelText('Interval');
+ expect(screen.getByLabelText('Run at a specific time (leave empty for any time)')).toBeTruthy();
+
+ fireEvent.change(select, { target: { value: 'on-demand' } });
+
+ expect(select.value).toBe('on-demand');
+ expect(screen.queryByLabelText('Run at a specific time (leave empty for any time)')).toBeNull();
+ expect(screen.getByText('Runs only when you press Run now.')).toBeTruthy();
+ });
+});
diff --git a/client/src/utils/README.md b/client/src/utils/README.md
index 712679479a..219f40b89f 100644
--- a/client/src/utils/README.md
+++ b/client/src/utils/README.md
@@ -23,7 +23,7 @@ grep -i "what you want to do" client/src/utils/README.md
| Module | Purpose |
|---|---|
| `formatters` | Date/time/duration/byte/word formatters (`clamp`, `formatBytes`, `formatDownloadGb` (decimal-GB model download size, "~29 GB"), `formatCompactCount`, `formatCompactCountOrDash` (same, but an ABSENT count renders "—" rather than "0"), `timeAgo`, `formatAgeDays` (age in whole DAYS — "412 days ago" — where `timeAgo` would collapse to "1y ago"; model-download lists), `localDateKey` (browser-local `YYYY-MM-DD`), `shiftISODate` (DST-safe calendar-day shifts), `formatTimecode`, `formatDurationMs`, `formatDateShort`, `formatContextTokens` (suffix-less context length, "4K"), `throughputLabel` (a measured model's speed as one label — tok/s where the runtime reported token counts, `~` prefixed when frame-counted, else chars/s; never both), `parseTimeoutMs`, `formatCooldown`, `recommendedRamGb`, `nameFromImageFilename`, `formatUsd` — one USD renderer (`signed` puts the minus outside the `$`; `trimWhole` drops `.00` on a typed round price) — `formatWeight` / `formatPercent` — round unit-converted floats (`170.35000000000002` → `170.4 lbs`) so raw binary precision never reaches a tile — `middleTruncate` — clip a long string from the MIDDLE so its distinguishing tail survives, where CSS `line-clamp`/`text-overflow` always eats the end — …) plus timeout-input bounds and `getAppName`. Do not re-define formatters inside components. **Never write `new Date(x).toLocaleDateString()` inline** — pick the helper for the shape you want: `formatDateNumeric` ("3/5/2026", compact cells), `formatDateShort` ("Mar 5, 2026"), `formatDate` ("March 5, 2026"), `formatDateFull` ("Saturday, March 5, 2026"), `formatWeekdayDate` ("Monday, Mar 5", `{ weekday, year }`), `formatMonthDay` ("Mar 5"), `formatMonthYear` ("March 2026"), `formatWeekdayShort` ("Mon"), `formatWeekdayTime` ("Mon, 7:00 AM"), `formatTimeOfDay` ("1:30 PM"), `formatTimeOfDaySeconds` ("1:30:45 PM", log/queue rows), `formatClockTime` ("02:30:45 PM"; `{ seconds, hour12, timeZone }`), `formatDateTime`. `formatHourOfDay` renders a bare hour-of-day number (0-23) as a 12-hour label in one of four styles (`long` "3 PM", `compact` "3PM", `tiny` "3p", `lower` "3pm") — the canonical home for what five components each hand-rolled, so the same 3pm no longer renders four ways across screens. Date display helpers anchor a bare `YYYY-MM-DD` at LOCAL midnight (a naive `new Date('2026-03-05')` is UTC midnight and renders as the previous day west of Greenwich) and take a fallback instead of rendering the literal "Invalid Date". |
-| `cronHelpers` | Cron preset list, friendly cron parsing/building, anchored recurrence parsing/building/description, `isCronExpression` detection, `describeCron` human-readable rendering, `cronFromIntervalMs` numeric-interval → expression conversion (mirrors the server helper), and `JOB_INTERVAL_OPTIONS` — the interval-mode cadences for autonomous jobs, mirroring the server's `INTERVAL_OPTIONS`. Import it rather than re-declaring the list in a scheduling component. |
+| `cronHelpers` | Cron preset list, friendly cron parsing/building, anchored recurrence parsing/building/description, `isCronExpression` detection, `describeCron` human-readable rendering, `cronFromIntervalMs` numeric-interval → expression conversion (mirrors the server helper), and `JOB_INTERVAL_OPTIONS` / `ON_DEMAND_INTERVAL` — the interval-mode cadences for autonomous jobs, mirroring the server's `INTERVAL_OPTIONS` (`on-demand` = stays enabled and manually runnable, but arms no timer). Import it rather than re-declaring the list in a scheduling component. |
| `markdownText` | `markdownToPlainText(md)` — flatten markdown source to one plain-text string for a clamped preview: strips heading/list/blockquote/fence markers, unwraps emphasis, inline code and links, images → `[alt]`, collapses blank-line runs. Use whenever a card previews arbitrary agent-authored markdown — `line-clamp-N` does not clamp a subtree of block elements, and foreign `##` headings would otherwise join the page's heading outline. Underscore emphasis is word-boundary gated and `__…__` needs interior whitespace, so stack frames and user-agent strings (`10_15_7`, `__init__`) are never silently rewritten. `dropsMarkupWhenFlattened(md)` — did the flatten lose actual markup, as opposed to only normalizing whitespace? Gates a "Show more" disclosure so a short-but-lossy body stays reachable without putting a toggle on every body that merely lost a trailing newline. |
| `timeWindow` | Time-of-day window math (`isInTimeWindow`, `timeStringToMinutes`) and morning-layout auto-switch helpers (`pickActiveLayoutId`, `recordManualLayoutPick`). |
| `timezone` | Timezone day-key helpers (`dayKeyInTimezone`, `todayKeyInTimezone`) — browser mirror of the server's `todayInTimezone`, so date-scoped POST surfaces derive "today" in the user's configured timezone and agree with the server (#2681). |
diff --git a/client/src/utils/cronHelpers.js b/client/src/utils/cronHelpers.js
index 1acee43a01..b69106a63c 100644
--- a/client/src/utils/cronHelpers.js
+++ b/client/src/utils/cronHelpers.js
@@ -279,11 +279,15 @@ export function describeCron(expr) {
}
// Interval-mode cadences for autonomous jobs — the client mirror of
-// `INTERVAL_OPTIONS` in `server/services/autonomousJobs/constants.js`. Values
+// `INTERVAL_OPTIONS` in `server/lib/autonomousJobIntervals.js`. Values
// must stay in lockstep with `resolveIntervalMs` there, since the server
// recomputes `intervalMs` from whichever value a picker submits. Lives here
// rather than in a component so a second job-scheduling surface doesn't fork
-// its own copy of the list.
+// its own copy of the list. `on-demand` is the no-recurrence cadence: the job
+// stays enabled and manually runnable, but no timer is ever armed for it.
+// `server/lib/autonomousJobIntervals.mirror.test.js` fails when the two lists drift.
+export const ON_DEMAND_INTERVAL = 'on-demand';
+
export const JOB_INTERVAL_OPTIONS = [
{ value: 'hourly', label: 'Every Hour' },
{ value: 'every-2-hours', label: 'Every 2 Hours' },
@@ -292,5 +296,6 @@ export const JOB_INTERVAL_OPTIONS = [
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'biweekly', label: 'Every 2 Weeks' },
- { value: 'monthly', label: 'Monthly' }
+ { value: 'monthly', label: 'Monthly' },
+ { value: ON_DEMAND_INTERVAL, label: 'On Demand' }
];
diff --git a/server/lib/README.md b/server/lib/README.md
index 522c70be0e..a93554f8c8 100644
--- a/server/lib/README.md
+++ b/server/lib/README.md
@@ -401,6 +401,7 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and
|---|---|
| `appIdentity.js` | The baseline managed-app id (`PORTOS_APP_ID`) — importable without pulling in the `services/apps.js` graph (pm2, scheduler, settings) behind it. Re-exported by `apps.js`. |
| `appResolver.js` | Fuzzy-match a spoken/typed phrase to a managed app (`{ id, name }`). Tiered exact → prefix → substring, used by voice tools that target a specific app. |
+| `autonomousJobIntervals.js` | Autonomous-job cadence vocabulary (pure). `INTERVAL_OPTIONS` (picker rows, `ms: null` for a cadence with no recurrence), `ON_DEMAND_INTERVAL`, `JOB_INTERVAL_VALUES` (every legal stored cadence, incl. `custom`), `resolveIntervalMs(interval, customMs)` → ms or `null` (the no-interval sentinel — never falls through to `DAY`), and `isOnDemandJob(job)` for the three due/next-fire implementations. Lives here so `cosValidation.js` can validate the cadence without a lib → services import; `services/autonomousJobs/constants.js` re-exports it. Separate vocabulary from the scheduled-CoS-task `INTERVAL_TYPES`. |
| `capabilityMap.js` | Pure row builders for Setup & Capabilities, including strict network/provider first-run readiness and optional-integration health rollups; fed by `routes/capabilities.js`. |
| `chiptuneRender.js` | Deterministic chiptune score → mono PCM → 16-bit WAV buffer (pure Node, no audio deps); `renderScoreToPcm` / `pcmToWavBuffer` / `renderScoreToWav`. |
| `chiptuneScore.js` | Chiptune score contract (#2911): `chiptuneScoreSchema` (Zod) + `sanitizeChiptuneScore`, pitch math (`pitchToMidi`/`midiToFreq`), and `buildScoreEvents` — the pattern/order → absolute-time flatten mirrored by `client/src/lib/chiptunePlayback.js`. |
diff --git a/server/lib/autonomousJobIntervals.js b/server/lib/autonomousJobIntervals.js
new file mode 100644
index 0000000000..b772bc17c3
--- /dev/null
+++ b/server/lib/autonomousJobIntervals.js
@@ -0,0 +1,94 @@
+/**
+ * Autonomous-job cadence vocabulary (pure).
+ *
+ * The interval-mode vocabulary for CoS autonomous jobs — the picker rows, the
+ * cadence → milliseconds resolver, and the "this job has no clock" predicate.
+ * Lives in lib rather than `services/autonomousJobs/constants.js` so the Zod
+ * boundary (`lib/cosValidation.js`) can validate against the same list without
+ * a lib → services import; the services constants module re-exports it, so
+ * existing deep imports keep working.
+ *
+ * NOTE: this is a SEPARATE vocabulary from the scheduled-CoS-task
+ * `INTERVAL_TYPES = { ON_DEMAND, CRON }` in `services/taskScheduleConstants.js`.
+ * Nothing converts between the two.
+ */
+
+// Time units are declared locally rather than imported from `fileUtils.js`.
+// This module is pulled in by `cosValidation.js` — i.e. by every route suite —
+// and dozens of those suites mock fileUtils with an exhaustive factory, so an
+// import here would make a missing `DAY`/`HOUR` on their mock a hard failure.
+// `autonomousJobIntervals.test.js` asserts these still equal fileUtils'.
+const HOUR = 60 * 60 * 1000;
+const DAY = 24 * HOUR;
+
+export const WEEK = 7 * DAY;
+
+/**
+ * Cadence for a job that never fires on a clock — it runs only when a human (or
+ * another feature) triggers it via `POST /api/cos/jobs/:id/trigger`. Distinct
+ * from `enabled: false`, which also makes a job un-runnable.
+ */
+export const ON_DEMAND_INTERVAL = 'on-demand';
+
+/**
+ * Available interval options for UI pickers. `ms: null` marks a cadence with no
+ * recurrence. Mirrored (value/label only) by `JOB_INTERVAL_OPTIONS` in
+ * `client/src/utils/cronHelpers.js`; the mirror describe in
+ * `autonomousJobIntervals.test.js` fails when the two drift.
+ */
+export const INTERVAL_OPTIONS = [
+ { value: 'hourly', label: 'Every Hour', ms: HOUR },
+ { value: 'every-2-hours', label: 'Every 2 Hours', ms: 2 * HOUR },
+ { value: 'every-4-hours', label: 'Every 4 Hours', ms: 4 * HOUR },
+ { value: 'every-8-hours', label: 'Every 8 Hours', ms: 8 * HOUR },
+ { value: 'daily', label: 'Daily', ms: DAY },
+ { value: 'weekly', label: 'Weekly', ms: WEEK },
+ { value: 'biweekly', label: 'Every 2 Weeks', ms: 2 * WEEK },
+ { value: 'monthly', label: 'Monthly', ms: 30 * DAY },
+ { value: ON_DEMAND_INTERVAL, label: 'On Demand', ms: null }
+];
+
+/**
+ * Every cadence string a persisted job may carry. `custom` is deliberately not
+ * an INTERVAL_OPTIONS row (it has no fixed duration to label — the caller
+ * supplies `intervalMs`), but it is a legal stored value.
+ */
+export const JOB_INTERVAL_VALUES = [...INTERVAL_OPTIONS.map(o => o.value), 'custom'];
+
+const INTERVAL_MS_BY_VALUE = new Map(INTERVAL_OPTIONS.map(o => [o.value, o.ms]));
+
+/**
+ * Resolve a cadence string to milliseconds.
+ *
+ * Returns `null` — an explicit no-interval sentinel, never `DAY` and never
+ * `NaN` — for the on-demand cadence and for any value outside the vocabulary.
+ * The old `default: return DAY` fall-through silently rescheduled a typo'd
+ * cadence as a daily job; every caller now handles the sentinel instead.
+ *
+ * @param {string} interval
+ * @param {number} [customMs] duration for the `custom` cadence
+ * @returns {number|null} milliseconds, or null when the job has no recurrence
+ */
+export function resolveIntervalMs(interval, customMs) {
+ if (interval === 'custom') return customMs || DAY;
+ if (INTERVAL_MS_BY_VALUE.has(interval)) return INTERVAL_MS_BY_VALUE.get(interval);
+ if (interval !== ON_DEMAND_INTERVAL) {
+ console.warn(`⚠️ Unknown autonomous-job cadence '${interval}' — treating it as on-demand (no schedule)`);
+ }
+ return null;
+}
+
+/**
+ * True when a job has nothing to fire on — the on-demand cadence, or a stored
+ * job whose cadence resolved to the no-interval sentinel. Cron-mode jobs are
+ * never on-demand: their schedule lives in `cronExpression`/`cronSchedule`, and
+ * a job switched to cron mode keeps whatever `interval` it last had.
+ *
+ * @param {Object} job
+ * @returns {boolean}
+ */
+export function isOnDemandJob(job) {
+ if (job?.cronExpression || job?.cronSchedule) return false;
+ if (job?.interval === ON_DEMAND_INTERVAL) return true;
+ return !Number.isFinite(job?.intervalMs);
+}
diff --git a/server/lib/autonomousJobIntervals.test.js b/server/lib/autonomousJobIntervals.test.js
new file mode 100644
index 0000000000..a4217461ac
--- /dev/null
+++ b/server/lib/autonomousJobIntervals.test.js
@@ -0,0 +1,110 @@
+/**
+ * Autonomous-job cadence vocabulary (#6375).
+ *
+ * Two contracts that a higher-level test can't pin cheaply:
+ * - `resolveIntervalMs` must return an explicit no-interval sentinel rather
+ * than falling through to `DAY`. The fall-through was silent, so only a
+ * direct assertion catches its return.
+ * - the client mirror in `client/src/utils/cronHelpers.js` is hand-maintained.
+ * A row added on one side only would leave a cadence the server accepts
+ * invisible in the picker (or a picker row the Zod enum rejects), so the
+ * mirror is compared by reading the client source rather than importing it
+ * (a server test that imports a client module breaks CI dependency-wise).
+ */
+
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { extractDeclaration } from './mirrorParity.js';
+
+// Declared here rather than imported from fileUtils: this suite's whole point is
+// that autonomousJobIntervals.js stays import-free, and pulling fileUtils in
+// through the test would put its closure back into the suite's import budget.
+const HOUR = 60 * 60 * 1000;
+const DAY = 24 * HOUR;
+import {
+ INTERVAL_OPTIONS,
+ JOB_INTERVAL_VALUES,
+ ON_DEMAND_INTERVAL,
+ isOnDemandJob,
+ resolveIntervalMs
+} from './autonomousJobIntervals.js';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const CLIENT_PATH = resolve(HERE, '../../client/src/utils/cronHelpers.js');
+
+describe('resolveIntervalMs', () => {
+ it('returns the no-interval sentinel for the on-demand cadence — not DAY, not NaN', () => {
+ const resolved = resolveIntervalMs(ON_DEMAND_INTERVAL);
+ expect(resolved).toBeNull();
+ expect(resolved).not.toBe(DAY);
+ expect(Number.isNaN(resolved)).toBe(false);
+ });
+
+ it('no longer falls through to DAY for a cadence outside the vocabulary', () => {
+ // The `default: return DAY` this replaced turned a typo'd cadence into a
+ // daily job that nobody asked for.
+ expect(resolveIntervalMs('dailyy')).toBeNull();
+ expect(resolveIntervalMs(undefined)).toBeNull();
+ });
+
+ it('resolves every recurring option to its declared duration', () => {
+ for (const opt of INTERVAL_OPTIONS) {
+ expect(resolveIntervalMs(opt.value), opt.value).toBe(opt.ms);
+ }
+ });
+
+ it('custom uses the caller-supplied duration and falls back to a day', () => {
+ expect(resolveIntervalMs('custom', 90_000)).toBe(90_000);
+ expect(resolveIntervalMs('custom')).toBe(DAY);
+ });
+
+ it('JOB_INTERVAL_VALUES covers every option plus custom', () => {
+ expect(JOB_INTERVAL_VALUES).toEqual([...INTERVAL_OPTIONS.map(o => o.value), 'custom']);
+ });
+});
+
+describe('isOnDemandJob', () => {
+ it('is true for the cadence and for a job left with no resolvable interval', () => {
+ expect(isOnDemandJob({ interval: ON_DEMAND_INTERVAL, intervalMs: null })).toBe(true);
+ expect(isOnDemandJob({ interval: 'daily', intervalMs: null })).toBe(true);
+ });
+
+ it('is false for a recurring job', () => {
+ expect(isOnDemandJob({ interval: 'daily', intervalMs: DAY })).toBe(false);
+ });
+
+ it('is false for a cron-mode job that kept a stale on-demand cadence', () => {
+ // Switching a job to Cron does not rewrite `interval`, so the cron fields
+ // have to win or the job would silently stop firing.
+ expect(isOnDemandJob({ interval: ON_DEMAND_INTERVAL, intervalMs: null, cronExpression: '0 4 * * *' })).toBe(false);
+ expect(isOnDemandJob({ interval: ON_DEMAND_INTERVAL, intervalMs: null, cronSchedule: { kind: 'DAILY' } })).toBe(false);
+ });
+});
+
+describe('client JOB_INTERVAL_OPTIONS mirror', () => {
+ const clientSrc = readFileSync(CLIENT_PATH, 'utf8');
+ const clientDecl = extractDeclaration(clientSrc, 'JOB_INTERVAL_OPTIONS');
+
+ // Parses `{ value: X, label: 'Y' }` rows — X is a quoted literal for most
+ // rows and the ON_DEMAND_INTERVAL identifier for the on-demand one.
+ const ROW_RE = /\{\s*value:\s*(?:'([^']+)'|([A-Z_][A-Z0-9_]*))\s*,\s*label:\s*'([^']+)'\s*\}/g;
+ const clientRows = [...(clientDecl ?? '').matchAll(ROW_RE)].map(m => ({
+ value: m[1] ?? (m[2] === 'ON_DEMAND_INTERVAL' ? ON_DEMAND_INTERVAL : m[2]),
+ label: m[3]
+ }));
+
+ it('finds the client declaration and parses every row', () => {
+ expect(clientDecl, 'client cronHelpers.js is missing JOB_INTERVAL_OPTIONS').not.toBeNull();
+ expect(clientRows.length).toBe(INTERVAL_OPTIONS.length);
+ });
+
+ it('carries the same values and labels, in the same order, as the server list', () => {
+ expect(clientRows).toEqual(INTERVAL_OPTIONS.map(({ value, label }) => ({ value, label })));
+ });
+
+ it('declares the on-demand cadence with the same string the server validates', () => {
+ expect(clientSrc).toContain(`export const ON_DEMAND_INTERVAL = '${ON_DEMAND_INTERVAL}';`);
+ });
+});
diff --git a/server/lib/cosValidation.js b/server/lib/cosValidation.js
index fd679ce2a7..cd8fea2f51 100644
--- a/server/lib/cosValidation.js
+++ b/server/lib/cosValidation.js
@@ -19,6 +19,7 @@ import { PUBLIC_REVIEW_EXECUTION_PROFILES } from './agentExecutionProfiles.js';
import { ORCHESTRATION_MODES, ORCHESTRATION_ROLES } from './orchestrationProfile.js';
import { AGENT_RUN_EVENT_KINDS, RUN_EVENT_READ_LIMITS } from './agentRunEvents.js';
import { recurrenceRuleSchema } from './recurrenceValidation.js';
+import { JOB_INTERVAL_VALUES } from './autonomousJobIntervals.js';
import { TASK_DATA_INPUT_DEFINITIONS, TASK_DATA_INPUT_IDS } from './taskDataInputCatalog.js';
import {
EFFORT_SELECTABLE_REVIEWERS,
@@ -409,7 +410,13 @@ export const createCosJobSchema = z.object({
description: z.string().optional(),
category: z.string().optional(),
type: z.enum(['agent', 'shell', 'script']).optional(),
- interval: z.string().optional(),
+ // The autonomous-job cadence vocabulary. Previously a bare z.string(), so a
+ // typo'd cadence reached disk and silently rescheduled the job daily via
+ // resolveIntervalMs's old `default: DAY` fall-through. Note this is NOT the
+ // scheduled-CoS-task INTERVAL_TYPES vocabulary — nothing converts between them.
+ interval: z.enum(JOB_INTERVAL_VALUES).optional(),
+ // Only meaningful for the `custom` cadence; never required or back-filled for
+ // the on-demand one, which resolves to a null intervalMs by design.
intervalMs: z.number().positive().int().optional(),
// Null actively clears a pinned time/cron mode on update. The jobs UI has
// always emitted null for the inactive mode; accepting it here lets updateJob
diff --git a/server/lib/cosValidation.test.js b/server/lib/cosValidation.test.js
index abb9f5d904..fac461157c 100644
--- a/server/lib/cosValidation.test.js
+++ b/server/lib/cosValidation.test.js
@@ -8,6 +8,7 @@ import {
taskTemplateSettingsSchema,
} from './cosValidation.js';
import { EFFORT_LEVELS } from './providerModels.js';
+import { JOB_INTERVAL_VALUES, ON_DEMAND_INTERVAL } from './autonomousJobIntervals.js';
describe('cosValidation effort field', () => {
it('accepts every EFFORT_LEVELS value on create and rejects unknown values', () => {
@@ -280,3 +281,28 @@ describe('createCosTaskSchema isInvestigation (#6043)', () => {
expect(parsed).not.toHaveProperty('investigationFingerprint');
});
});
+
+describe('cosValidation job cadence (#6375)', () => {
+ it('accepts every legal cadence on create', () => {
+ for (const interval of JOB_INTERVAL_VALUES) {
+ expect(createCosJobSchema.safeParse({ name: 'j', interval }).success, interval).toBe(true);
+ }
+ });
+
+ it('rejects a cadence outside the vocabulary on create and update', () => {
+ // Before this enum, `interval` was a bare z.string(): a typo reached disk
+ // and resolveIntervalMs's `default: DAY` silently rescheduled it daily.
+ expect(createCosJobSchema.safeParse({ name: 'j', interval: 'dailyy' }).success).toBe(false);
+ expect(updateCosJobSchema.safeParse({ interval: 'dailyy' }).success).toBe(false);
+ });
+
+ it('accepts the on-demand cadence without an intervalMs', () => {
+ const parsed = createCosJobSchema.parse({ name: 'j', interval: ON_DEMAND_INTERVAL });
+ expect(parsed.interval).toBe(ON_DEMAND_INTERVAL);
+ expect(parsed.intervalMs).toBeUndefined();
+ });
+
+ it('still leaves the cadence optional so an unrelated PATCH does not have to send one', () => {
+ expect(updateCosJobSchema.safeParse({ enabled: false }).success).toBe(true);
+ });
+});
diff --git a/server/lib/importScoping.test.js b/server/lib/importScoping.test.js
index 90126a632f..122450712e 100644
--- a/server/lib/importScoping.test.js
+++ b/server/lib/importScoping.test.js
@@ -192,6 +192,12 @@ describe('deferred imports stay deferred (#6156)', () => {
// #6368 adds `lib/callerModePolicy.js`, another zero-dependency leaf reached by
// the routing boundary and the lib barrel (~92 instantiations). Same tolerated
// shape; it fits inside the allowance above.
+//
+// #6375 adds `lib/autonomousJobIntervals.js` — the autonomous-job cadence
+// vocabulary `cosValidation.js` validates against, so ~194 suites reach it. It is
+// a zero-import leaf (its time units are declared locally precisely so it drags
+// nothing); the alternative is re-declaring the cadence list at the Zod boundary,
+// which is the drift that issue exists to close. Fits inside the allowance above.
const MAX_STATIC_INSTANTIATIONS = 91400;
const SKIP_DIRS = new Set(['node_modules', 'coverage', 'dist', 'data']);
diff --git a/server/lib/index.js b/server/lib/index.js
index 2b7e6a486b..a38e9792ba 100644
--- a/server/lib/index.js
+++ b/server/lib/index.js
@@ -364,6 +364,7 @@ export * from './songCraftRef.js';
// === Domain utilities ===
export * from './appIdentity.js';
export * from './appResolver.js';
+export * from './autonomousJobIntervals.js';
export * from './capabilityMap.js';
export * from './chiptuneRender.js';
export * from './chiptuneScore.js';
diff --git a/server/services/autonomousJobs.test.js b/server/services/autonomousJobs.test.js
index f81cf1ef68..546496e2c9 100644
--- a/server/services/autonomousJobs.test.js
+++ b/server/services/autonomousJobs.test.js
@@ -53,6 +53,8 @@ vi.mock('./autobiography.js', () => ({
}))
// Import after mocks
+import { computeNextJobRun } from './autonomousJobs/scheduler.js'
+import { ON_DEMAND_INTERVAL, resolveIntervalMs } from '../lib/autonomousJobIntervals.js'
import {
agentDataCleanup,
getAllJobs,
@@ -318,6 +320,79 @@ describe('autonomousJobs', () => {
})
})
+ // #6375 — the on-demand cadence. Its whole contract is negative (never fires
+ // on a clock) and it is enforced in three separate places, so each is pinned.
+ describe('on-demand cadence (#6375)', () => {
+ const onDemandJob = (overrides = {}) => ({
+ ...mockJobsData.jobs[0],
+ id: 'job-on-demand',
+ interval: ON_DEMAND_INTERVAL,
+ intervalMs: null,
+ enabled: true,
+ ...overrides
+ })
+
+ it('resolveIntervalMs returns the no-interval sentinel, not DAY and not NaN', () => {
+ const resolved = resolveIntervalMs(ON_DEMAND_INTERVAL)
+ expect(resolved).toBeNull()
+ expect(resolved).not.toBe(24 * 60 * 60 * 1000)
+ expect(Number.isNaN(resolved)).toBe(false)
+ })
+
+ it('does not fall through to DAY for a cadence outside the vocabulary', () => {
+ // The old `default: return DAY` silently rescheduled a typo'd cadence daily.
+ expect(resolveIntervalMs('dailyy')).toBeNull()
+ })
+
+ it('is never due — not when never run, and not after a week of elapsed time', async () => {
+ // loadJobs merges the shipped default jobs in, so assert on this job only.
+ readJSONFile.mockResolvedValue({ ...mockJobsData, jobs: [onDemandJob()] })
+ expect((await getDueJobs()).find(j => j.id === 'job-on-demand')).toBeUndefined()
+
+ const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()
+ readJSONFile.mockResolvedValue({ ...mockJobsData, jobs: [onDemandJob({ lastRun: weekAgo })] })
+ expect((await getDueJobs()).find(j => j.id === 'job-on-demand')).toBeUndefined()
+ })
+
+ it('computeNextJobRun returns null rather than an Invalid Date', () => {
+ const next = computeNextJobRun(onDemandJob({ lastRun: '2025-01-01T00:00:00.000Z' }), 'UTC')
+ expect(next).toBeNull()
+ // The regression this closes: lastRun + null === lastRun, a finite past
+ // timestamp that reads as permanently overdue.
+ expect(next).not.toBe(Date.parse('2025-01-01T00:00:00.000Z'))
+ })
+
+ it('createJob stores the sentinel and drops a pinned time-of-day', async () => {
+ const job = await createJob({
+ name: 'Manual only',
+ interval: ON_DEMAND_INTERVAL,
+ scheduledTime: '09:00',
+ promptTemplate: 'Only when asked'
+ })
+
+ expect(job.interval).toBe(ON_DEMAND_INTERVAL)
+ expect(job.intervalMs).toBeNull()
+ expect(job.scheduledTime).toBeNull()
+ })
+
+ it('updateJob switching an existing recurring job to on-demand clears its interval', async () => {
+ const updated = await updateJob('job-test-1', { interval: ON_DEMAND_INTERVAL })
+
+ expect(updated.interval).toBe(ON_DEMAND_INTERVAL)
+ expect(updated.intervalMs).toBeNull()
+ })
+
+ it('every recurring cadence still round-trips to a finite interval', async () => {
+ for (const opt of INTERVAL_OPTIONS.filter(o => o.value !== ON_DEMAND_INTERVAL)) {
+ const job = await createJob({ name: opt.value, interval: opt.value, promptTemplate: 'x' })
+ expect(job.interval, opt.value).toBe(opt.value)
+ expect(job.intervalMs, opt.value).toBe(opt.ms)
+ }
+ const custom = await createJob({ name: 'custom', interval: 'custom', intervalMs: 90_000, promptTemplate: 'x' })
+ expect(custom.intervalMs).toBe(90_000)
+ })
+ })
+
describe('INTERVAL_OPTIONS', () => {
it('has expected options', () => {
expect(INTERVAL_OPTIONS).toContainEqual({
diff --git a/server/services/autonomousJobs/constants.js b/server/services/autonomousJobs/constants.js
index 41ea85d345..97162d8f3a 100644
--- a/server/services/autonomousJobs/constants.js
+++ b/server/services/autonomousJobs/constants.js
@@ -18,45 +18,22 @@ export const JOBS_SKILLS_DIR = PATHS.promptSkillsJobs
// Serializes writes to autonomous-jobs.json so two write paths can't clobber each other.
export const withLock = createMutex()
-export const WEEK = 7 * DAY
+// Cadence vocabulary + resolver live in lib so lib/cosValidation.js can validate
+// against the same list without importing a service. Re-exported here because
+// the submodules (and autonomousJobs.js) have always imported them from this
+// leaf module.
+export {
+ WEEK,
+ ON_DEMAND_INTERVAL,
+ INTERVAL_OPTIONS,
+ JOB_INTERVAL_VALUES,
+ resolveIntervalMs,
+ isOnDemandJob
+} from '../../lib/autonomousJobIntervals.js'
// Re-export the time units used across submodules so callers import from one place.
export { DAY, HOUR }
-/**
- * Resolve interval string to milliseconds. Pure (depends only on the time
- * units above), so it lives in this leaf module — keeping it out of scheduler.js
- * avoids the store→scheduler and crud→scheduler import cycles.
- */
-export function resolveIntervalMs(interval, customMs) {
- switch (interval) {
- case 'hourly': return HOUR
- case 'every-2-hours': return 2 * HOUR
- case 'every-4-hours': return 4 * HOUR
- case 'every-8-hours': return 8 * HOUR
- case 'daily': return DAY
- case 'weekly': return WEEK
- case 'biweekly': return 2 * WEEK
- case 'monthly': return 30 * DAY
- case 'custom': return customMs || DAY
- default: return DAY
- }
-}
-
-/**
- * Available interval options for UI
- */
-export const INTERVAL_OPTIONS = [
- { value: 'hourly', label: 'Every Hour', ms: HOUR },
- { value: 'every-2-hours', label: 'Every 2 Hours', ms: 2 * HOUR },
- { value: 'every-4-hours', label: 'Every 4 Hours', ms: 4 * HOUR },
- { value: 'every-8-hours', label: 'Every 8 Hours', ms: 8 * HOUR },
- { value: 'daily', label: 'Daily', ms: DAY },
- { value: 'weekly', label: 'Weekly', ms: WEEK },
- { value: 'biweekly', label: 'Every 2 Weeks', ms: 2 * WEEK },
- { value: 'monthly', label: 'Monthly', ms: 30 * DAY }
-]
-
// Fields that are code contracts — always overwrite on restart so runtime
// stays consistent with the shipped implementation.
export const JOB_STRUCTURAL_FIELDS = ['type', 'scriptHandler', 'appId']
diff --git a/server/services/autonomousJobs/crud.js b/server/services/autonomousJobs/crud.js
index eaf0283103..6ca8846ad5 100644
--- a/server/services/autonomousJobs/crud.js
+++ b/server/services/autonomousJobs/crud.js
@@ -69,6 +69,12 @@ async function createJob(jobData) {
const jobType = jobData.type || 'agent'
+ // resolveIntervalMs returns null — the no-interval sentinel — for the
+ // on-demand cadence, so the schedulers arm nothing and a time-of-day has no
+ // recurrence to align to.
+ const interval = jobData.interval || 'weekly'
+ const intervalMs = resolveIntervalMs(interval, jobData.intervalMs)
+
// Strip agent-specific triggerAction values from shell jobs
const agentOnlyActions = ['spawn-agent', 'create-task']
const triggerAction = (jobType === 'shell' && agentOnlyActions.includes(jobData.triggerAction))
@@ -91,9 +97,9 @@ async function createJob(jobData) {
cronExpression: jobData.cronExpression || null,
cronSchedule: jobData.cronSchedule || null,
type: jobType,
- interval: jobData.interval || 'weekly',
- intervalMs: resolveIntervalMs(jobData.interval || 'weekly', jobData.intervalMs),
- scheduledTime: jobData.scheduledTime || null,
+ interval,
+ intervalMs,
+ scheduledTime: intervalMs == null ? null : (jobData.scheduledTime || null),
weekdaysOnly: jobData.weekdaysOnly || false,
enabled: jobData.enabled !== undefined ? jobData.enabled : false,
priority: jobData.priority || 'MEDIUM',
@@ -167,6 +173,9 @@ async function updateJob(jobId, updates) {
// Recalculate intervalMs if interval changed
if (updates.interval) {
job.intervalMs = resolveIntervalMs(updates.interval, updates.intervalMs)
+ // Switching to a no-recurrence cadence (on-demand) leaves no schedule for
+ // a pinned time-of-day to align to.
+ if (job.intervalMs == null) job.scheduledTime = null
}
// Validate shell jobs have a valid command after all fields are applied
diff --git a/server/services/autonomousJobs/scheduler.js b/server/services/autonomousJobs/scheduler.js
index 29e6dcf0e4..4b5de3663f 100644
--- a/server/services/autonomousJobs/scheduler.js
+++ b/server/services/autonomousJobs/scheduler.js
@@ -9,7 +9,7 @@
import { getLocalParts, nextLocalTime } from '../../lib/timezone.js'
import { getUserTimezone } from '../userTimezone.js'
import { parseCronToNextRun, parseRecurrenceToNextRun } from '../eventScheduler.js'
-import { DAY } from './constants.js'
+import { DAY, isOnDemandJob } from './constants.js'
import { loadJobs } from './store.js'
// Reads enabled jobs straight from the store. Scheduler intentionally does NOT
@@ -56,6 +56,11 @@ async function getDueJobs() {
continue
}
+ // On-demand jobs have no clock — they run only via POST /jobs/:id/trigger.
+ // Without this guard the interval comparison below reads a null intervalMs
+ // as 0 and reports the job due on every sweep.
+ if (isOnDemandJob(job)) continue
+
// Interval-mode jobs
const lastRun = job.lastRun ? new Date(job.lastRun).getTime() : 0
const timeSinceLastRun = now - lastRun
@@ -114,6 +119,10 @@ export function computeNextJobRun(job, timezone) {
return next?.getTime() ?? null
}
+ // No recurrence to project forward from — the caller renders 'On demand'
+ // rather than an arithmetic-on-null date.
+ if (isOnDemandJob(job)) return null
+
const lastRun = job.lastRun ? new Date(job.lastRun).getTime() : 0
let nextDue = lastRun + job.intervalMs
diff --git a/server/services/autonomousJobs/store.js b/server/services/autonomousJobs/store.js
index 7fbeee72d7..a141f86559 100644
--- a/server/services/autonomousJobs/store.js
+++ b/server/services/autonomousJobs/store.js
@@ -13,7 +13,7 @@ import { join } from 'path'
import { existsSync } from 'fs'
import { ensureDir, PATHS, readJSONFile, atomicWrite, tryReadFile } from '../../lib/fileUtils.js'
import { validateCommand } from '../../lib/commandSecurity.js'
-import { DATA_DIR, JOBS_FILE, JOBS_SKILLS_DIR, resolveIntervalMs } from './constants.js'
+import { DATA_DIR, JOBS_FILE, JOBS_SKILLS_DIR, JOB_INTERVAL_VALUES, resolveIntervalMs } from './constants.js'
import { createDefaultJobsData, mergeWithDefaults } from './defaults.js'
let initPromise = null
@@ -133,7 +133,7 @@ async function migrateScriptsState(jobsData) {
const existingIds = new Set(jobsData.jobs.map(j => j.id))
// Map legacy schedule values to valid interval values
- const VALID_INTERVALS = new Set(['hourly', 'every-2-hours', 'every-4-hours', 'every-8-hours', 'daily', 'weekly', 'biweekly', 'monthly', 'custom'])
+ const VALID_INTERVALS = new Set(JOB_INTERVAL_VALUES)
const LEGACY_SCHEDULE_MAP = {
'every-5-min': 'hourly',
'every-10-min': 'hourly',
@@ -146,7 +146,9 @@ async function migrateScriptsState(jobsData) {
'twice-daily': 'daily'
}
const mapLegacySchedule = (schedule, scriptName) => {
- if (!schedule || schedule === 'on-demand' || schedule === 'startup') return 'daily'
+ // 'startup' has no cadence equivalent; 'on-demand' now maps to the real
+ // on-demand cadence via VALID_INTERVALS below instead of a disabled daily job.
+ if (!schedule || schedule === 'startup') return 'daily'
if (VALID_INTERVALS.has(schedule)) return schedule
if (LEGACY_SCHEDULE_MAP[schedule]) {
console.log(`📦 Mapped legacy schedule '${schedule}' for '${scriptName}' to '${LEGACY_SCHEDULE_MAP[schedule]}'`)
@@ -167,7 +169,10 @@ async function migrateScriptsState(jobsData) {
if (script.cronExpression) {
console.warn(`⚠️ Legacy cron expression '${script.cronExpression}' for script '${script.name}' not supported by job scheduler, using interval '${mappedInterval}' instead`)
}
- const isOnDemandOrStartup = script.schedule === 'on-demand' || script.schedule === 'startup'
+ // A startup script has no cadence to carry over, so it lands disabled. An
+ // on-demand one keeps its own enabled flag — the on-demand cadence already
+ // guarantees no timer, and staying enabled keeps it manually runnable.
+ const isStartupScript = script.schedule === 'startup'
// Validate command against allowlist — disable jobs with invalid commands
let commandValid = true
@@ -188,7 +193,7 @@ async function migrateScriptsState(jobsData) {
command: commandValid ? script.command : null,
interval: mappedInterval,
intervalMs: resolveIntervalMs(mappedInterval),
- enabled: commandValid ? (isOnDemandOrStartup ? false : (script.enabled || false)) : false,
+ enabled: commandValid ? (isStartupScript ? false : (script.enabled || false)) : false,
priority: script.triggerPriority || 'MEDIUM',
triggerAction: 'log-only',
lastRun: script.lastRun || null,
diff --git a/server/services/cosJobScheduler.js b/server/services/cosJobScheduler.js
index a334f8f0f4..5ed8475f64 100644
--- a/server/services/cosJobScheduler.js
+++ b/server/services/cosJobScheduler.js
@@ -27,6 +27,7 @@ import { getUserTimezone } from './userTimezone.js';
import { formatDuration } from '../lib/fileUtils.js';
import { loadState, isDaemonRunning, canQueueImprovementTasks } from './cosState.js';
import { getDomainMode } from '../lib/domainAutonomy.js';
+import { isOnDemandJob } from '../lib/autonomousJobIntervals.js';
import { remainingActionBudget } from '../lib/domainBudgets.js';
import { getDomainBudgetStatus, recordDomainUsage } from './domainUsage.js';
import { cosEvents, emitLog } from './cosEvents.js';
@@ -45,9 +46,11 @@ import {
* 1. Cron mode: job.cronExpression defines the full schedule
* 2. Interval mode: job.intervalMs + optional job.scheduledTime (HH:MM in user timezone)
*
+ * 3. On-demand: no cadence at all — returns null so no timer is armed.
+ *
* @param {Object} job - The job object
* @param {string} timezone - IANA timezone string for interpreting scheduledTime/cron
- * @returns {number} Timestamp (ms) of next fire time
+ * @returns {number|null} Timestamp (ms) of next fire time, or null when the job never fires on a clock
*/
function computeNextJobFireTime(job, timezone) {
// Convert scheduledTime (HH:MM) + interval to a cron expression so parseCronToNextRun
@@ -65,6 +68,12 @@ function computeNextJobFireTime(job, timezone) {
}
return next.getTime();
}
+ // On-demand jobs never fire on a clock. Checked after the explicit cron
+ // fields (a job switched to cron mode keeps whatever cadence it last had) but
+ // before the synthesized daily/weekday cron below, which a weekdaysOnly
+ // on-demand job would otherwise fall into.
+ if (!cronExpr && isOnDemandJob(job)) return null;
+
const isDailyCronCandidate = job.interval === 'daily' || job.weekdaysOnly;
if (!cronExpr && job.scheduledTime && isDailyCronCandidate) {
const match = String(job.scheduledTime).match(/^([01]\d|2[0-3]):([0-5]\d)$/);
@@ -125,6 +134,12 @@ export async function registerSingleJobSchedule(jobId) {
const timezone = await getUserTimezone();
const nextFire = computeNextJobFireTime(job, timezone);
+ // An on-demand job stays enabled (so POST /jobs/:id/trigger still runs it)
+ // but arms no timer. Cancel any timer left over from a previous cadence.
+ if (nextFire == null) {
+ cancelEvent(`job:${jobId}`);
+ return;
+ }
const delayMs = Math.max(nextFire - Date.now(), 1000);
scheduleEvent({
diff --git a/server/services/cosJobScheduler.test.js b/server/services/cosJobScheduler.test.js
index b821bcd215..4ef0498d3b 100644
--- a/server/services/cosJobScheduler.test.js
+++ b/server/services/cosJobScheduler.test.js
@@ -234,4 +234,63 @@ describe('registerSingleJobSchedule', () => {
expect(cancelEvent).toHaveBeenCalledWith('job:job-disabled');
expect(scheduleEvent).not.toHaveBeenCalled();
});
+
+ // #6375 — this is the scheduler that actually arms the timer. An on-demand
+ // job stays enabled (so POST /jobs/:id/trigger still runs it) but must never
+ // be given a fire time; before the cadence existed, a null intervalMs reached
+ // `lastRun + intervalMs` and produced a bogus delay instead.
+ it('arms no timer for an enabled on-demand job, and cancels any stale one', async () => {
+ scheduleEvent.mockClear();
+ cancelEvent.mockClear();
+ getJob.mockResolvedValueOnce({
+ id: 'job-on-demand',
+ name: 'Manual only',
+ enabled: true,
+ interval: 'on-demand',
+ intervalMs: null,
+ });
+
+ await registerSingleJobSchedule('job-on-demand');
+
+ expect(scheduleEvent).not.toHaveBeenCalled();
+ expect(cancelEvent).toHaveBeenCalledWith('job:job-on-demand');
+ });
+
+ it('a weekdaysOnly on-demand job with a stale scheduledTime still arms no timer', async () => {
+ // The synthesized daily/weekday cron path keys on weekdaysOnly, so it would
+ // otherwise capture an on-demand job that carried those fields over.
+ scheduleEvent.mockClear();
+ cancelEvent.mockClear();
+ getJob.mockResolvedValueOnce({
+ id: 'job-on-demand-weekday',
+ name: 'Manual only',
+ enabled: true,
+ interval: 'on-demand',
+ intervalMs: null,
+ weekdaysOnly: true,
+ scheduledTime: '04:30',
+ });
+
+ await registerSingleJobSchedule('job-on-demand-weekday');
+
+ expect(scheduleEvent).not.toHaveBeenCalled();
+ });
+
+ it('still arms a timer for a recurring job', async () => {
+ scheduleEvent.mockClear();
+ getJob.mockResolvedValueOnce({
+ id: 'job-daily',
+ name: 'Daily',
+ enabled: true,
+ interval: 'daily',
+ intervalMs: 86_400_000,
+ });
+
+ await registerSingleJobSchedule('job-daily');
+
+ expect(scheduleEvent).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'job:job-daily', type: 'once' })
+ );
+ expect(scheduleEvent.mock.calls[0][0].delayMs).toBeGreaterThan(0);
+ });
});