The scheduler console — 79 jobs, and what each one stops if it stops

How to read Admin → System → Scheduled Tasks — the heartbeat banner, the job registry with its sticky refusals, and the live log — and why a green dashboard is not evidence that scheduled work is happening.

11 min readUpdated 6 August 2026cron, scheduler, monitoring, withdrawals, admin

A stopped scheduler is the quietest outage this platform can have. The site keeps serving, every page loads, the system health screen stays green — and no withdrawal is processed, no price is written, no investment settles, no temporary block expires. There is nothing in the ordinary admin panel that says so, because the only evidence of a scheduler is work that silently stops.

The console at Admin → System → Scheduled Tasks (/admin/system/cron) exists for exactly that. It is shaped around "what is not working" first and "what is working" second.

Processes and ports covers the cron process itself. Scheduled jobs reference lists all 79 jobs with their cadence. This page is how to read the screen.

Three signals, deliberately not merged

The page keeps three independent things apart, and any one of them can be down while the other two look fine. There is no single green tick, on purpose.

Signal Answers Source
The heartbeat banner, at the top Is anything in this deployment registering jobs? GET /api/admin/system/cron/scheduler — a Redis key rewritten every 15s
The job registry, on the left For each job: when it last ran, whether it succeeded, whether it is refusing GET /api/admin/system/cron, hydrated from a Redis snapshot
The live log, on the right What the scheduler is saying right now A WebSocket on /api/admin/system/cron, relayed from the cron process

The job list is polled over HTTP every 20 seconds and the heartbeat every 15, underneath the socket. The socket only carries changes, so a page opened between two ticks of a six-hourly job would otherwise show nothing about it; and on a split deployment the browser's socket is attached to the web process while the state is written by the cron one, so a dropped relay would leave a page that looks live and is frozen. Polling underneath means the worst case is 20 seconds stale, not forever.

The heartbeat banner

The backend app runs CRON_MODE=off and registers no jobs at all. If the cron app has stopped, nothing scheduled runs anywhere — and the job list below the banner will keep showing all 79 jobs with plausible "last run" times for up to 24 hours, because the run bookkeeping is hydrated from a Redis snapshot that outlives its writer. A job list cannot report the absence of the thing that would have updated it. The banner can.

The scheduling process rewrites one Redis key (cron:scheduler) every 15 seconds, with a 90-second TTL. The banner reports one of six states.

Banner Means Do
running One process is beating. A single quiet line naming it, because a warning strip that is always there is one nobody reads on the day it matters. Nothing.
missing No beat in the last 90 seconds. Nothing scheduled is happening. pm2 list, then pnpm start, then pm2 logs cron.
stale A beat exists but is older than 60 seconds. The message says how many seconds ago and which instance wrote it. Same as missing — the scheduler stopped reporting.
duplicate Two processes are registering jobs. Worse than none. Below.
unknown The heartbeat could not be read because Redis could not be read. This is a cache problem, not necessarily a scheduler one. Check System Health first.
unavailable This backend has no scheduler route — an older dist. Update.

unknown is deliberately a warning and never destructive: "I cannot tell" and "nothing is scheduling" are different answers, and conflating them would put a red NO SCHEDULER banner on a healthy deployment every time Redis blinked.

BullMQ hands a repeatable job to whichever worker takes it, and the single-flight guard is per-process — it coordinates nothing across processes. Two processes registering jobs means every scheduled job runs twice over the same rows, withdrawals and settlement included.

The banner reports duplicate ahead of staleness, because it is the more urgent of the two. Each beat also checks whether the previous value came from a live second process: on the same host it tests the PID, so a routine restart does not denounce its own predecessor.

The fix is pnpm start — it runs reconcile-scheduler.js, which deletes any PM2 app whose scheduling role disagrees with the config about to start — and then stopping anything started outside PM2. A stray node backend/dist/index.js, or a second host pointed at one database, is the usual cause.

The banner also names this process (mode and PID), because "the scheduler is elsewhere" is only meaningful relative to who is answering the request.

The five tiles

Tile Counts
Jobs registered The size of the registry on this install, with a health bar and the number of categories
Not working Jobs that are refused or degraded — scheduled, and not doing their work
Failed last run Jobs whose last run threw
Running now Jobs mid-tick
Last activity How long ago any job completed

Health excludes both refused and failed jobs, so a refusing scheduler cannot read 100%. Last activity is the cheapest read on the whole page: on a healthy install the 15-second and 5-second jobs make this "a few seconds ago" permanently. A figure in minutes means something is wrong before you have read anything else.

The job list

Each row carries the job name, its category, and four figures under a sticky column legend:

Column Is
Every The registered period, rendered in whole units (15s, 30m, 6h, 1d)
Last run Relative time. "Never" here on a job that should tick every 15 seconds is the finding.
Duration How long the last run took
Next run The later of the scheduler's declared nextScheduledRun and lastRun + period

Above the list: a text search, a category filter (the addon buckets plus Normal), and five tabs — All, Needs attention, Running, Completed, Idle. The Needs attention chip turns red when it carries a number; it gathers refused, degraded and failed together, and it is the reason to be on this page.

Clicking a row opens the detail panel. Clicking the row's Run now button fires the job by hand.

Why the list shows run state at all

Run bookkeeping — lastRun, status, successRate, lastExecutions, the sticky refusal — exists only in the process that actually schedules. Two deployment shapes serve this admin route from somewhere else, and a freshly-built registry has lastRun: null, status: "idle" hard-coded on every entry:

  • The split (CRON_MODE=off on the web tier). This is the shape where the console is the only window onto a scheduler in another process, and a refusal recorded there could not otherwise reach this route at all.
  • The threaded entry point (pnpm start:thread), where a worker thread also serves the admin routes — an operator polling the page saw two thirds of the responses claim every job had never run.

getCronJobsWithRunState() fixes both by hydrating those fields from a snapshot the scheduling process publishes to Redis after every completed run. If Redis cannot be read, the route reports the registry as it stands rather than inventing a state.

The REFUSED state

A job can decline to run in this process and still return normally. That is the whole point: throwing would be recorded as a failure, and a refusal is not one. But a normal return used to be stamped completed — so on this very table, a job that had not executed a single line of its work in a week was byte-identical to one succeeding every 30 seconds.

backend/src/cron/refusal.ts is the one sanctioned way for a job to decline, and it is never quiet. Every refusal lands in four places:

  1. Sticky refusal state on the job row. The only one of the four that is still there tomorrow, and the only one that stops a refused job being reported as completed. It records since (when this refusal started, not when it was last seen) so the row can say "this has been skipped for six hours".
  2. logger.error, for the process log and whatever ships it.
  3. The live log on this page, so a refusal is visible on the screen that lists the job as scheduled.
  4. An URGENT in-app and email alert to every Admin and Super Admin, linking back to /admin/system/cron. The only channel that reaches a human who is not already looking.

The state is written on every refused tick. The three announcements are rate-limited to one per job per fifteen minutes, because the jobs that refuse tick every 5 to 60 seconds and an alert arriving 1,440 times a day is one nobody reads. A message is an event; a state is a fact, and the fact has to hold between announcements.

When NEXT_PUBLIC_DEMO_STATUS=true, the alert in (4) goes to Super Admins only. Demo mode hands the Admin role to every public signup, so "every Admin" is the whole visitor list — one refused job would otherwise mail every account on the site, every fifteen minutes, for as long as the refusal stands.

The other three channels are unchanged, so an admin who can open this page still sees the refusal in full. On a normal install the alert reaches Admins and Super Admins as described.

Nothing here expires on a timer. A refusal is cleared only by a run that did not refuse, and a refused job never returns to idleidle means "scheduled, waiting its turn", which is precisely the impression a refused job must not give. successRate counts only completed runs, so it drops rather than sitting at 100%.

What a refusal notice tells you

Field Is
job The registry name — the key this page is indexed by
reason Why this process must not run it
impact What is consequently not happening, in operator terms
fix The concrete change: an extension to enable, a setting to move

degraded is not refused

One variant means "running, but achieving nothing" rather than "not running". The handler executed, no scheduled tick was dropped — but the work has nowhere to land. The AI market maker engine is the case: with ecosystem disabled nothing in the deployment boots a matcher, so its bots have no market to quote, and saying "not running" would be both stronger and untrue.

Degraded rows count toward Not working and toward Needs attention, and are painted in warning colour rather than destructive. The three gates that can produce a refusal or a degradation, and what you change to clear each, are in Scheduled jobs reference.

Why refusals replaced exit code 78

Until the cron split became the default, an addon whose job could not run on a dedicated scheduler made the whole cron process print a banner and process.exit(78) — and 78 is in stop_exit_codes, so PM2 stopped the app rather than restart-looping it. That was defensible while the split was opt-in and an operator was necessarily watching.

As the default it inverts. The web process runs CRON_MODE=off, so a cron process that refuses to boot leaves the deployment with no scheduler at all — no withdrawals, no price updates, no settlement, nothing in the admin panel to say so — for an operator who never asked for the split and never read a banner that scrolled past at boot. One addon's limitation would take out all 79 jobs.

So the cron process now always starts, always runs every job that is safe, and the specific jobs that are not refuse themselves per run — loudly. What still exits 78 is deliberately narrow and unchanged: an unreachable Redis, and the native-module and Node-ABI preflight. Neither is addon-specific and neither is survivable; the web process fails on both identically, so exiting loses no scheduling the deployment still had.

The live log

The right-hand pane has two tabs, Live log and Activity.

The log is severity-coloured and severity-labelled, because cron traffic is overwhelmingly routine and the two lines that matter — a refusal and a failure — both arrive as error and both have to be findable while the stream is moving. There is a per-job filter (roughly 65 to 79 jobs share one socket, several ticking every five seconds) and autoscroll that pauses when you scroll up, with a resume button that says how many lines arrived meanwhile.

Two things about the stream that are not obvious:

  • Opening the page is what starts it. The browser sends SUBSCRIBE on open, which puts it in the relay's audience set. Ordinary log lines are only built and published when somebody is in that set — the scheduler holds no sockets of its own, so it asks the web process whether anyone is looking before it serialises anything. With no dashboard open there is nothing to send, and roughly 500 call sites would otherwise fire into an empty room.
  • Refusal lines skip that gate. Presence is heard over the bus and is therefore a slightly stale fact — a cron process that has just booted may believe nobody is watching for up to a heartbeat, and "this job is not running" is the one message that must not fall into that window. They are rate-limited to one per job per fifteen minutes, so always publishing them costs nothing.

Anything that must survive an unwatched run is written to logger at the same call site, so pm2 logs cron loses nothing when the page is closed.

Run now, and when it refuses

Run now calls POST /api/admin/system/cron/trigger with the job name. The button explains itself before you press it: the scheduler endpoint returns a canTrigger flag that mirrors the two refusals inside the manager, and the button is disabled with a tooltip when it is false.

canTrigger is false in two cases:

  • This process is not the main thread. Under pnpm start:thread, worker threads serve this admin route too and their job map is permanently empty, so the single-flight guard would be a no-op and the handler would run beside the main thread's scheduled tick against the same rows. Retrying may land you on the right thread.
  • Cron is delegated (CRON_MODE=off on the process answering you — the default production shape). Every web process is the wrong one, so this refusal is terminal. The 503 says to wait for the next scheduled run, and does not offer to trigger it on the cron process: that process registers no application routes at all, so there is no admin endpoint there to call, on a port nothing should connect to.

It used to be, while parts of the platform genuinely did not work under the split. None of that is true now. Collapsing back to one process gives up the isolation that exists to stop cron work stalling the site — a mark-compact pause near the heap limit was measured at 1.9 seconds with the site answering nothing — in exchange for the convenience of one manual job run.

Run now shares its single-flight guard with the scheduler, so it returns 409 rather than running concurrently when a scheduled tick is already in flight. On a successful trigger the page re-reads the job list immediately, so the row settles without waiting for the next poll.

Which jobs appear on your install

The registry is built per install. 17 jobs are registered unconditionally; the other 62 are grouped into 17 extension buckets and only pushed into the registry when that extension is enabled. An install with no addons shows 17 rows, and that is correct.

Gating is re-evaluated every 60 seconds against the extension table directly, so toggling an extension on Admin → System → Extensions takes effect without a restart. The scheduler logs each change:

Extension staking enabled — scheduled cron processStakingPositions
Extension nft disabled — stopped cron settleAuctions

A disabled extension's jobs are torn down properly — the repeatable is removed from BullMQ, the fallback interval is cleared, and any job with a teardown hook (the background deposit scanner and the BTC scanner both arm their own internal loops) is asked to stop. Deregistering the supervisor tick alone would not have stopped those.

Permissions

Key Grants
access.cron The page at /admin/system/cron
view.cron The job list, the scheduler endpoint, and the live-log socket
manage.cron The Run now button

A role with access.cron and no view.cron gets the page frame and no data. Super Admin short-circuits all three.

A five-minute check

  1. Read the banner. Anything other than a quiet running line is the whole answer — stop here and fix that.

  2. Read the Last activity tile. Seconds is healthy. Minutes means the scheduler has stopped or is wedged, even if the banner still beats.

  3. Open the Needs attention tab. Zero is the expected state. Anything in it carries its own reason, impact and fix on the row.

  4. Confirm the money jobs by name. processPendingWithdrawals (30m), reconcileSpotWithdrawals (5m), processSpotPendingDeposits (15m) and, if you run the ecosystem, ecosystemWithdrawRecon (5m) and verifyPendingEcoDeposits (60s). A Last run older than roughly twice the period is the finding.

  5. If the page itself is unreachable, go to the shell:

    pm2 list                 # is `cron` online?
    pm2 logs cron --lines 100