Recurring tasks — heartbeats + the system-health dashboard
Every cron, polling loop, scheduled retry, or other repeating background task on this platform reports a heartbeat to a single system_heartbeats table on every fire. The maintenance dashboard at /dashboard/admin/system-health reads those heartbeats and flags any task that's stale, errored, or never fired.
This is the contract: if a task runs on a cadence, it must record a heartbeat. Otherwise it's invisible to ops and silently dying is the most likely failure mode.
Why this matters
The Node process can be alive while the cron loop has died — an unhandled exception inside setInterval, an await import() that started failing after a deploy, or a Promise chain that swallowed its own error. From outside, the box looks healthy: HTTP /health returns 200, memory is stable. But work isn't getting done.
Heartbeats catch this without external monitoring infrastructure. The dashboard reads last_run_at, compares to expected_interval_seconds, and flags > 1.5× as yellow, > 3× as red. A task that hasn't fired in 45 minutes when it should fire every 15 = red, regardless of whether the rest of the platform looks fine.
The contract
For any task that fires on a cadence:
- Call
recordHeartbeat()(or wrap inwithHeartbeat()) on every fire — both success AND error paths. - Use a stable, unique
name— snake_case, namespaced if helpful (e.g.cafe_contest_tick,welcome_drip_tick,season_auto_render_scan). - Pass
expectedIntervalSeconds— what cadence are you firing on? The dashboard staleness check needs this. - Surface useful counters in
metadata— they appear under "Show metadata" in the dashboard for click-through inspection (e.g.{ transitioned: 2, emailed: 5, errors: 0 }). - Add a friendly label to
CRON_LABELSinapps/backend/src/services/system-health.tsso the dashboard renders "Cafe contest cron (15m)" instead ofcafe_contest_tick.
That's it. The dashboard picks up the new task automatically once it fires once.
Two patterns
Pattern A — manual control (use when the task has nuanced statuses)
import { recordHeartbeat } from './services/system-heartbeats.js'
const INTERVAL_MS = 15 * 60 * 1000
setInterval(async () => {
let result: any = null
let errMsg: string | null = null
try {
result = await runTheTaskLogic()
} catch (e: any) {
errMsg = e?.message || String(e)
console.warn('[my-task] failed:', errMsg)
}
await recordHeartbeat({
name: 'my_task_tick',
status: errMsg
? 'error'
: (result.errors?.length ? 'partial' : 'ok'),
error: errMsg || result?.errors?.[0]?.message || null,
expectedIntervalSeconds: INTERVAL_MS / 1000,
metadata: result
? { processed: result.count, errors: result.errors?.length || 0 }
: undefined,
})
}, INTERVAL_MS)
The 'partial' status is for tasks that completed but had per-item errors (e.g. emailed 48 of 50 users with 2 Resend hiccups). Yellow on the dashboard.
Pattern B — withHeartbeat() wrapper (use when the task is a simple ok-or-throw)
import { withHeartbeat } from './services/system-heartbeats.js'
setInterval(async () => {
try {
await withHeartbeat(
{ name: 'my_task_tick', expectedIntervalSeconds: 900 },
async () => {
const result = await runTheTaskLogic()
return { processed: result.count } // surfaces as metadata
},
)
} catch {
// withHeartbeat already recorded the error and re-threw; outer
// catch is just to keep setInterval running.
}
}, 15 * 60 * 1000)
Use this for the simpler case — it can't forget the error path.
Adding new check types
Beyond cron heartbeats, apps/backend/src/services/system-health.ts supports three other check categories. To add a new one:
External service probe
Add to checkExternalServices(). Conventions:
- Yellow when env vars are unset for a feature-flagged service (e.g. Bluesky without creds — that's "off as expected", not broken).
- Green when probe succeeds, red when it fails with creds set.
- 5-second timeout via
probeUrl()so a hanging service can't stall the dashboard. - Use the cheapest auth'd endpoint the service exposes — Stripe
/v1/balance, OpenAI/v1/models, Resend/v1/domains, etc. Don't burn quota.
Data-state check
Add to checkDataState(). Conventions:
- Read-only queries. No writes ever from the dashboard.
- Time-aware: "this week's contest exists?" makes sense on Monday 10am, may be expected-missing on Sunday 11pm. Use
unknown(gray) for "not enough info to judge" rather than red. - Single targeted query, not a sweep. The dashboard runs every check on every page load; broad scans don't belong here.
Config-presence panel
Add to REQUIRED_ENV in checkConfigPresence(). Each entry is { name, label, severity } where severity is what the missing var produces (red for unrecoverable, yellow for "feature is off"). Lighter than a probe — just env-presence.
When NOT to use this
- One-shot jobs (a migration script, a backfill you ran once). Heartbeats are for repeating work.
- Per-request work (an HTTP route's handler logic). That's monitored at the request level, not by heartbeat staleness.
- Sub-second loops (e.g. a polling loop inside a single request). Heartbeats are for tasks where "didn't fire for an hour" is meaningful.
Existing tasks (as of 2026-05-08)
| Task name | Cadence | Source |
|---|---|---|
cafe_contest_tick | 15 min | apps/backend/src/index.ts → services/cafe-contests.ts runCafeContestTick() |
cafe_battle_tick | 2 min | apps/backend/src/index.ts → services/cafe-battles.ts runCafeBattleTick() — fans out to: tryPairFromQueue (queue → battle), closeOverdueBattles (24h hard expiry), runBotSubmissionFallback (catches dropped setTimeout bot writes after 15 min), runForfeitureCheck (4h no-show walkover), maybeStartDemoBattle (bot-vs-bot when a spectator is recently active), and runTournamentTick (bracket auto-advance). |
welcome_drip_tick | 15 min | apps/backend/src/index.ts → services/welcome-drip.ts runWelcomeDripTick() |
ops_weekly_report | 6 hrs | apps/backend/src/index.ts → services/ops-weekly-report.ts maybeGenerateWeeklyReportNow() — gates on Sunday ≥ 9am UTC + idempotency, so most fires are no-ops. |
season_auto_render_scan | 4 hrs | apps/backend/src/index.ts → services/season-auto-render.ts runAutoRenderScan() |
cafe_random_prompt_pool_tick | 15 min | apps/backend/src/index.ts → services/cafe-random-prompts.ts runRandomPromptPoolTick() — keeps cafe_random_prompts topped up to 10 ready rows with DALL-E images. Generates up to 3 per tick to cap cost; no-op when ready ≥ 10. |
short_stories_backfill_tick | 6 hrs | apps/backend/src/index.ts → services/short-stories.ts runShortStoriesBackfillTick() — fires the /sj-anderson mini-tournament (5 prompts × 3 personas + judge + ElevenLabs narration) at most weekly. Gates: cafe_random_prompts.ready ≥ 5 unused AND latest short_stories.published_at ≥ 7 days old. Most fires are no-ops; the heartbeat metadata records the gate decision so the system-health dashboard surfaces "skipped: pool not ready" vs "skipped: latest is 3.2d old". |
company_expenses_recurring_tick | 24 hrs | apps/backend/src/index.ts → services/company-expenses.ts materializeRecurringExpenses() — walks active company_recurring_expenses definitions (Vercel/Railway/ElevenLabs plan, LLC renewal, etc.) and materializes a dated company_expenses ledger row for each elapsed period boundary. Idempotent via the (recurring_expense_id, expense_date) unique index, so a double-run is a no-op; 60-period safety bound per definition per pass. Powers /dashboard/admin/expenses. |
cold_read_capture_tick | 24 hrs (~28d cadence per question) | apps/backend/src/index.ts → services/cold-reads.ts runColdReadCaptureTick() — for each fixed prompt in COLD_READ_PROMPTS (positioning + TAM), captures a web-grounded "how an outside AI describes graphene.fm" read via the OpenAI Responses web_search tool, but only when that question's latest read is >28 days old (daily tick + freshness gate = effectively monthly, restart-resilient). Stores with status='captured'; an admin publishes before it shows on the public /about/cold-reads timeline. Heartbeat metadata records captured/skipped per question. |
coaching_payouts_tick | 24 hrs | apps/backend/src/index.ts → services/coaching-payouts.ts payoutPendingCoachingEarnings() — settles pending DreamPro Coaching channel earnings (coach share + partner override, recorded on each invoice.payment_succeeded) into real Stripe Connect transfers. REAL MONEY, doubly gated: the coaching_payouts cron toggle AND env COACHING_PAYOUTS_ENABLED (the payout fn is a no-op when unset). Idempotent per earning (status guard); payees without an onboarded/enabled Connect account are skipped + left pending. |
signup_canary | 24 hrs | apps/backend/src/index.ts → services/signup-canary.ts runSignupCanary() — per brand domain (hivejournal/graphene/writecafe/lovio/dreampro): GETs https://<host>/auth/signup (asserts 200 + no off-host redirect) and admin-creates a tagged canary user → verifies the profiles trigger fired → deletes it (try/finally, never leaks). Can't run the real signup flow (Turnstile + email confirmation are intentionally un-automatable), so this probes the front door + the account/DB plumbing. Server-side create → no signup_completed event → no PostHog pollution. Heartbeat metadata records per-brand pass/fail. |
sdk_release_watch_tick | 24 hrs | apps/backend/src/index.ts → services/sdk-release-watch.ts runSdkReleaseWatchTick() — polls a fixed TRACKED_SDKS list of AR/XR hardware SDK sources (Android XR Maven, Meta Spatial SDK npm + Wearables toolkit page, Snap Spectacles + Brilliant Labs GitHub releases) and resolves each to a single value (version / release tag / page hash). First sighting = baseline row (no alert); a changed value = a new row in sdk_release_alerts (migration 463) and a service_errors warning so it surfaces in /errors. Per-source failures are isolated + counted (a stale URL shows as an errored check, self-correcting). Surfaces on /dashboard/admin/sdk-watch + the /sdk-releases Claude skill. The point: know when the open Android XR glasses SDK ships. See docs/product/AR_GLASSES_LIVING_VOICE.md. |
In addition to true cron heartbeats, system_heartbeats is also used by some services as a key-value persistence mechanism for ephemeral state that needs to survive process restarts:
| Key | Purpose |
|---|---|
cafe_battle_spectator_active | Last ping from /write-cafe/battles/watch. Demo-bot cron only spins up when this was within 30 min. |
cafe_battle_demo_started | Last bot-vs-bot demo creation timestamp. 1h cooldown between demos. |
These rows write last_status: 'ok' purely to satisfy the table's check constraint — the dashboard's cron-staleness check ignores them because they aren't in the CRON_LABELS map. Keeping them in the same table avoids a separate system_state migration.
When you add a new recurring task, add a row to the cron table above so the next contributor knows what's already wired.