Appearance
Cron inventory
All module crons register per workspace via scheduleModuleCrons(ctx) inside initWorkspace — the ONE path (the last hand-wired module exception, the monthly report, was removed 2026-07-22 with the parked PDF). Every job goes through createWorkspaceGatedCron, THE chokepoint, which at fire time checks: global kill-switch (system_flags.crons_paused) → workspace active (live + account active) → the automation gate (automationGate, workspace/readiness.js: setup complete AND agent.paused not set). Since 2026-08-16 that is the ONLY place readiness and the pause switch are decided — makeCronGuard no longer takes an isReady or reads a per-module paused bit (that key was in no schema and unsettable), no module scheduler re-checks workspaceReadyById in its body, and the schedule-time readiness gate in initWorkspace is gone (it used the settings-only half and depended on a reloadWorkspace that not every setup path called; fire-time re-reads settings, so a mapped collection or a verified Search Console takes effect on the next fire). Active/archived stays a schedule-time gate too, because status flips call reloadWorkspace.
Operator Run-now writes the same ledger (2026-08-18): the autopilot's five operator triggers (agentSignalsSweep, agentDailyResearch, agentBootstrapResearch, agentOutcomeChecker, agentWeeklyOverview) run through makeCronGuard under the SAME job names the crons use (board/jobs.js: SWEEP_JOB … OVERVIEW_JOB, read back by signals.js#lastSignalRuns for the board's run line). Before, a run a person forced while the schedule was skipped left no job_runs row and no run line — two days of forced runs, empty ledger. job_runs itself was empty since it shipped only because no cron fired between the deploy and the switch being noticed.
The kill switch is read from the database by every surface (2026-08-18): system_flags.crons_paused is the truth; the scheduler refreshes its per-process cache every 10 s at fire time (isCronPaused), and the internal dashboard's GET /api/internal/system now calls readKillSwitchState() (fresh read) instead of returning the cache — a web replica that never schedules sat on the boot default and showed RUNNING for two days (16–18 Aug) while the flag was true and no cron fired fleet-wide. Operator Run-now bypasses the switch by design, which is why forced runs still worked.
Two lanes on that chokepoint (2026-08-16, Leon): ctx.cron.schedule(…) is the automation lane described above. ctx.cron.measure(…) is the measure lane — kill-switch → active, and nothing else — for the two jobs that make ZERO paid vendor/LLM calls and whose data is time-bound (cannot be re-fetched later): the pixel daily rollup (a DB compaction of sessions we ingest anyway; 72h rewrite window) and the GSC daily sync (Google's free APIs; ~10 trailing days self-heal). They keep running for a half-onboarded or paused workspace, so the customer's search + traffic history is already there when they finish setup or resume — instead of a gap and backfill machinery to close it. The lane is a property of the job's COST, fixed at registration, never a per-workspace choice: scripts/test-module-crons.mjs pins exactly which modules may use measure (orbit-pixel ×1, gsc ×1) and test-autopilot-safety.mjs asserts neither module imports a paid client. Anything that spends or acts (backlinks, health, AI visibility, site refresh — Haiku enrichment —, url canonicalizer, every agent lane) rides schedule. Pause stays the cost lever; the moment it leaked vendor spend it would stop meaning anything.
Every guarded run writes a job_runs row — and since runs-and-actions PR B (2026-09-02) it OPENS at fire time: a 'running' row (null finished_at) written by startJobRun, closed by finishJobRun with the job's own return value stored as result; the scheduler boot closes strays as 'interrupted' (a deploy mid-run used to leave NO row and read as "never fired"). Fail-open on a database without the job_runs_running_status migration: the guard falls back to the legacy completion-only insert (2026-08-15). (A workspace the chokepoint skips as not-ready or paused writes no row — the fire never reaches the guard; the state is readable on the workspace itself.) Hooked in makeCronGuard itself rather than per module, because that is the one seam all nine module schedulers already pass through; a per-module version would be half-adopted within a quarter, and a half-covered run table is worse than none. The write is a single fire-and-forget insert (core/services/job-runs.js) that is never awaited and cannot throw, so a cron can never fail because its bookkeeping did. Why it exists: on 2026-08-08/09/10 the GSC sync partially failed and wrote snapshots for 1–3 workspaces instead of the usual 9–11; the database showed that as a decline, indistinguishable from a quiet period, and nothing recorded that the job had broken. The product doctrine is absent, never silently zero — this makes it true of our own pipelines. Not batched, deliberately: roughly one row per module per workspace per day is under a hundred a day fleet-wide, so a queue would add failure modes to save nothing (and core/ may not import the observability batcher under the ring rule). Schedules are staggered per workspace. Master env switch: ENABLE_CRON. A module whose schedule() throws is isolated — the others still register, and the aggregate failure alerts the admin (see Architecture); scripts/test-module-crons.mjs asserts the registration contract in npm run verify.
System-level exceptions (fleet-scoped, registered in scheduler/index.js, not per-workspace):
The weekly fleet data sweep (scheduler/data-sweep.js, Mon 05:20 UTC — after every workspace's 02:20-local nightly jobs across the fleet's timezones). It spawns the canonical scripts/check-data.mjs as a child process and emails the admin the failing check lines on any FAIL. Deliberately not on the per-workspace path and immune to the kill-switch: half its checks are fleet-scoped (the crons_paused switch itself, fleet geo cardinality, SQL parity), and an alarm wired through the breaker it monitors can never report that breaker left on — the 2026-08-07→10 pause was caught by a human running the script by hand, which is what this cron retires. Read-only by construction, so firing while the fleet is paused bills nothing and writes nothing.
The daily spend alarm (scheduler/spend-alarm.js, 08:00 UTC, judging the previous complete UTC day — whole, so a morning is never compared against full days). Mails the admin when yesterday's fleet spend is unlike any day in the trailing fortnight, when more than 5% of paid spend had no workspace attached, or when the ledger went silent while the fleet was previously spending (a dead ledger disables the caps, which read the same rows). Like the data sweep it is fleet-scoped, kill-switch-immune, and read-only. Its baseline is the trailing max, not the median: the research schedule is deliberately bimodal (two full days a week at ~$25 fleet-wide, three light days at ~$6), and a median baseline would fire on every full-research day and be muted within a week. This is the layer the caps and the check-spend-guard gate cannot provide — money burnt correctly, through the guards (a retry storm, a loop, a fleet-wide config slip) never trips either of them.
The nightly engagement rollup (scheduler/engagement-rollup.js, 04:40 UTC) compacts raw in-app usage events (app_events) into app_engagement_daily for the internal dashboard's Engagement page, then purges raw rows older than 90 days. Unlike the sweep it respects the kill-switch (it's a compaction, not an alarm), and it recomputes the last 3 days each run, so it is idempotent and self-catches missed nights. Failure is console.error only — nothing product-facing reads this data. Design: plans/engagement-tracking-2026-08-10.md.
The automation gate (v3 → one place, 2026-08-16): every per-workspace cron below is gated at fire time by automationGate (workspace/readiness.js) in the scheduler chokepoint. A workspace that is not set up (essentials — CMS credentials, languages, brand identity, ideal client — plus a publish-mapped collection and a verified Search Console) or whose agent is paused (agent.paused, the app's "Pause agent" toggle / the internal dashboard's level off) runs none of them — no research, no sweep, no syncs, no audits, no rollups — and the skip log names why. Paused is the workspace's ONE off-switch; there is no per-module pause. Operator Run-now bypasses the gate on purpose (an explicit operator action), EXCEPT the agent lanes, whose readyGate holds the same automationGate on both paths because they are the model spend.
| Cron id (operator label) | Module | Cadence | What it does |
|---|---|---|---|
gscSync — "GSC sync (daily snapshot)" | gsc | settings.cronGscSync, default 0 5 * * * | Measure lane (runs while paused / half-onboarded; a workspace with no gscSiteUrl yet logs "nothing to measure" and returns). GSC → blog_seo_snapshots + site_page_snapshots for every published article/page; index inspection; key-tier re-rank. Access-class phase errors (403/404 — the property was never granted to our service account) log a warning and do NOT alert: unfinished setup is the workspace's to fix, and the sweep asks for it as a gsc:access-denied signal. Everything else still throws into the admin alert |
siteRefresh — "Site catalog refresh" | pages | 0 3 * * * (nightly 03:00; weekly until 2026-08-18) | Discovery + key selection + Haiku enrichment + URL inspection (runSiteRefresh). Nightly because people work on their sites daily and the run is our own fetches + Haiku only for key pages whose body hash CHANGED (unchanged pages reuse type/summary/embedding) — cents a night |
backlinksSync — "Backlinks sync (profile refresh)" | backlinks | 0 6 * * 1 (Mon 06:00) | Referring-domain snapshot + per-page link counts (the operator label says "Orbit backlink profile" — branding contract, docs/spine/core.md) |
marketSync — "Market sync (competitive field refresh)" — PAUSED 2026-08-23, MARKET_SYNC_ENABLED=true resumes it | market | 0 7 * * 1 (Mon 07:00 — after backlinks, so the authority read is fresh) | Competitor discovery + ranked-keyword field (own domain + ≤5 tracked rivals, prev_* shifted) + one bulk authority call + rival site atlas (tracked + watch) + weekly snapshots (SoV, demand index) — see Market |
healthAudit — "Health audit (full site)" | health | 0 5 * * 2 (Tue 05:00) | Site checks + OnPage crawl + GSC fusion + CWV → score/verdicts/issues |
aiVisibilitySync — "AI visibility scan" | ai-visibility | 0 7 * * 2 (Tue 07:00) | Tracked prompts × (ChatGPT + Gemini + Google AIO + AI Mode) → results + weekly snapshot; category intel (Mentions DB) refreshed when ≥28d old. Runs for every workspace (the enabled gate was removed 2026-08-14) |
agentSignalsSweep / agentDailyResearch / agentBootstrapResearch / agentOutcomeChecker / agentWeeklyOverview / agentPageLiveness | agent-autopilot | 05:00 / 05:30 Tuesday and Friday (one cadence since 2026-08-16 — the sweep and the research run surface and verify together; the absence grace is 4 days = two absent readings) · Tue 06:15 · 07:00 daily · Mon 07:30 · 04:15 daily (page liveness, board/page-liveness.js, 2026-08-19: after the 03:00 site catalog pass every open card's linked pages are re-verified — catalog first, one probe otherwise — moved pages rewrite, removed pages drop, a card whose pages are all gone leaves the board via closeItem closer page_removed, reopenable; health + ghost-pages exempt; no AI, no vendor calls) | The autopilot morning (see the AI-agent guide): detectors → synthesis → (start-phase bootstrap research) → outcome check → the Monday Signals overview email (weekly-overview.js; skips on an empty board). The drafting lane is retired (S9). Gated on the automation gate on BOTH the cron and the operator path (readyGate → automationGate) |
statistics-daily-rollup — "Statistics: rebuild daily rollup" | orbit-pixel | 20 2 * * * (02:20 workspace-local) | Measure lane (runs while paused / half-onboarded). Recomputes pixel_daily from pixel_sessions for the last 72h. Runs in the workspace's own timezone (ctx.timezone) — a fleet-wide UTC job would rebuild a Brussels day two hours after it ended and a Los Angeles one before it had. The window is 72h, not 1 day, because engagement heartbeats, late conversions and rDNS reclassification keep landing after midnight — and not 48h because the window is measured from the 02:20 cron, so sessions started before that time aged out one run early (2 settled days caught 2 pageviews short, 2026-08-09); the rollup is idempotent, so re-running is the repair path when it drifts |
collectionsCanonicalizeUrls — "Canonicalize article URLs" | collections | 10 4 * * * (nightly 04:10; weekly until 2026-08-18) | Reconciles blog_articles.url against the live sitemap (2026-08-02: publish-time URLs silently age through path migrations/slug renames — 13 stale on Luniq). Fills nulls by locale+slug match, follows the site's own redirects for the rest (paced, ≤40/run), adopts only sitemap-listed non-listing finals; a redirect that lands on a hub/listing page is reported as "article removed on-site", never stored |
Gone (2026-07-27 → cut 2026-08-16): the monthly PDF report has no cron, no app surface, and since 2026-08-16 no generator either — modules/reports/ was deleted with the corpus-canonical-split (Leon), and the internal dashboard's MANUAL_TRIGGERS list is empty. A rebuilt report on the outcome loop brings its own cron and its own notification type; git holds the old 4-page layout.
The chat has no cron. The old scheduled planner stays removed — chat is user-driven only; the autopilot lanes above are the agent's scheduled surface. initAgent() sweeps stranded skill_jobs at boot + every 5 min (a setInterval, not a cron); a deploy never reaches that sweep since 2026-07-27 — SIGTERM releases the jobs this process is running first (see the AI-agent guide). The daily cost cap ($15 default, core/spend) gates spend on every paid call, not just per chat turn.
Operator Run-now surface: each module's operatorCrons descriptor (gscSync, siteRefresh, backlinksSync, marketSync, healthAudit, aiVisibilitySync, statistics-daily-rollup, collectionsCanonicalizeUrls, plus the five agent-autopilot triggers above) is collected by getOperatorCrons() for the internal dashboard's manual-trigger dropdown. Since 2026-08-23 a descriptor also declares job (the job_runs name its guard records), cron (the base expression) or cronSetting (the settings key holding it — GSC), and lane: 'measure' for the two measure-lane jobs; get_automation_status joins these to latestJobRuns so the agent can report every job's schedule + last run. The market descriptor carries none yet and is not listed there.