Schedules and background jobs#

Durable workflows can survive a restart, but something has to start them. A schedule is that trigger: a file under src/schedules/, discovered the way routes are, run at-least-once, and leased through the same storage adapter everything else in the harness uses.

import {
  defineSchedule,
  defineScheduleConfig,
  createScheduler,
  isValidCron,
  nextOccurrence,
} from '@janux/agent';

The convention#

src/schedules/
  _config.ts              ← which store backs the scheduler (optional)
  nightly-digest.ts       ← the schedule "nightly-digest"
  billing/invoice-sweep.ts ← the schedule "billing/invoice-sweep"

Every .ts/.js file default-exports defineSchedule(...) and is named by its path, so a rename is a rename and nothing else has to agree. Files starting with _ are shared code, never schedules — which is what makes _config.ts and a _helpers.ts beside your schedules unremarkable.

import { defineSchedule } from '@janux/agent';
import { provisioning, provisioningRunner } from '../server/workflow';
import { storage } from './_config';

const runner = provisioningRunner(storage);

export default defineSchedule({
  cron: '*/5 * * * *',
  async run({ state, remember }) {
    const pending = (state as { runId?: string } | undefined)?.runId;

    if (pending) {
      await runner.resume(provisioning, pending, 'starter');
      await remember({});

      return;
    }
    const started = await runner.start(provisioning, { requestedBy: 'provision-sweep' });

    await remember({ runId: started.runId });
  },
});

cron is validated at definition time, so a typo is a boot failure rather than a job that silently never runs. Five fields (minute hour day-of-month month day-of-week) plus @hourly, @daily/@midnight, @weekly, @monthly and @yearly; *, 5, 1-5, */15, 10-40/10, lists, and names like MON-FRI or JAN all work, in the runtime's local timezone. isValidCron(expr) and nextOccurrence(expr, after) are the same parser, exported for your own checks. Valid means will actually fire: 0 0 30 2 * parses perfectly and names a date the calendar never has, so it is rejected rather than accepted into a schedule that could only ever throw.

The run bag#

Field What it is
name The schedule's path-derived name
dueAt The instant this occurrence was due — not when it actually started
state Whatever the previous run remembered. undefined on the first run
remember(state) Persists handler memory immediately; it survives crashes and restarts

remember is what turns at-least-once into something you can build on. A schedule that starts a durable run and remembers its runId resumes that run on the next occurrence instead of starting a second one — including after the process that started it was killed.

Choosing the store#

src/schedules/_config.ts picks the backend. Without it the scheduler keeps its state in memory, which is right for dev and wrong for anything with two instances.

import { defineScheduleConfig } from '@janux/agent';
import { durableStorage } from '../server/harness';

export const storage = await durableStorage();

export default defineScheduleConfig({ storage, tickMs: 30_000, leaseMs: 60_000 });

tickMs is how often this instance looks for due work; leaseMs is how long a claim stays exclusive before another instance may take it. Any store implementing syncSchedules / claimDueSchedules / settleSchedule / saveScheduleState works — createPgStorage implements them, and so does createMemoryStorage.

The guarantees#

At-least-once, deduplicated by lease. A claim is an atomic lease on the store: two instances ticking on the same second cannot claim the same occurrence, and an instance that dies holding one has its claim reopen when the lease expires — so a crash mid-run means a re-run, never a lost run. Your handler is therefore expected to be safe to re-enter: remember() exists so it can be.

Some consequences worth stating outright:

  • The next occurrence is computed from when a run finished, not when it started, so a run longer than its interval never settles onto an already-past time and re-fires immediately.
  • A brand-new schedule is seeded from now, so it waits for its first real occurrence instead of firing the moment it is deployed.
  • Changing a schedule's cron reseeds its clock; leaving it alone preserves the pending occurrence across restarts.
  • A schedule deleted from disk is pruned from the store on the next boot.
  • A handler that throws is recorded (lastStatus, lastError) and its clock still advances — one bad night does not wedge the schedule forever.

How it fires — and where it doesn't#

This is the part a deployment cannot paper over: serverless has no persistent process, so nothing can hold a tick loop. Each adapter declares what it can do through its schedules capability, and janux build says so out loud.

Capability What happens Who declares it
'process' The server ticks in-process, on tickMs janux dev, janux start, @janux/node
'http' The platform's cron POSTs /_janux/schedules/tick @janux/vercel
false Schedules cannot run at all; the build reports it as an unsupported feature any target with neither

Under the http trigger the endpoint is the trigger, so it is secret-gated: set JANUX_CRON_SECRET and have the platform send it as Authorization: Bearer <secret>. An unset secret answers 503 schedule_trigger_unconfigured rather than ticking for whoever asks. GET and POST both tick, because platform schedulers differ on which they send — Vercel Cron uses GET.

On Vercel there is nothing else to wire: add a crons entry and set CRON_SECRET, the name Vercel already sends its bearer under and which Janux accepts when JANUX_CRON_SECRET is unset.

{ "crons": [{ "path": "/_janux/schedules/tick", "schedule": "*/5 * * * *" }] }

The platform's cron decides when to look; your cron expressions still decide what is actually due, so a one-minute platform tick and a @daily schedule do the right thing between them.

Running one yourself#

createScheduler is the mount the framework builds for you — reach for it directly only in a custom server or a test.

import { createMemoryStorage, createScheduler, defineSchedule } from '@janux/agent';

const scheduler = createScheduler({
  storage: createMemoryStorage(),
  schedules: { sweep: defineSchedule({ cron: '@hourly', run: () => {} }) },
});

await scheduler.tick(); // claims and runs everything due right now → the names that ran
scheduler.start();      // the in-process loop
scheduler.stop();

Related: Durable workflows · Agent memory & storage · Adapters