Scheduling & State — Cron, Idempotency & Run Tracking
Scheduling without state is hope — idempotency is what makes Monday morning recoverable no matter what happened Sunday night.
Scheduling Is Infrastructure, Not Configuration
The blog-autopilot runs at 08:00 ET every Monday, Wednesday, and Friday. This is not a cron line someone runs occasionally. It is a persistent daemon configured in a launchd plist on the production server that survives reboots, does not require an active terminal session, and fires exactly at the specified intervals.
Understanding why launchd instead of a crontab entry, and why the specific plist fields matter, is the difference between a pipeline that runs reliably for years and one that silently stops working after the first production server restart.
launchd vs cron for Production Pipelines
crontab still works on macOS, but it is the wrong primitive for a production pipeline, for three concrete reasons. First, Apple has deprecated cron in favor of launchd — it is maintained for compatibility, not investment. Second, cron has no catch-up semantics: a job whose scheduled time passes while the machine is asleep is silently skipped, whereas a launchd StartCalendarInterval job fires as soon as the machine wakes. Third, macOS privacy controls (TCC, Full Disk Access) routinely block cron jobs from reading protected directories — and they fail silently when blocked. launchd is the correct scheduling primitive for persistent production server pipelines.
The Mon/Wed/Fri cadence in launchd uses StartCalendarInterval — a compact way to express multiple weekly schedules in a single plist. The Weekday values are 1=Monday, 3=Wednesday, 5=Friday — Monday=1 in both launchd and ISO 8601. The trap is Sunday: launchd accepts it as 0 or 7 (cron-style), whereas ISO 8601 uses 7 only, and Python's datetime.weekday() uses Monday=0 — a third convention you will meet in the health-check code in the playbook lesson. Mixing these up is a known confusion source.
The Complete launchd Plist Configuration
The EnvironmentVariables block is the most common source of pipeline failures after launchd migrations from Docker. Docker Compose inherits environment variables from the host shell and from .env files. launchd inherits nothing — not from your shell profile, not from .env files, not from Docker's environment. Every variable the pipeline needs must be explicitly listed in the plist.
Knox encountered this failure pattern during the production server native launchd migration (2026-05-24). Pipelines that worked in Docker stopped working under launchd because variables present in the Docker environment were not copied to the plist. The diagnosis took longer than it should because the failure was silent — launchd loaded the plist successfully, the process started, and then failed at the first API call with a KeyError on the missing variable.
The State Machine for Run Tracking
Every pipeline run passes through a defined state machine. Understanding this state machine is what makes run tracking — and idempotency — work:
The state transitions matter most at failure points. When a run transitions from not-run to running, the pipeline writes a run manifest file. The manifest contains: the topic_id being processed, the start timestamp, and the current stage. When the run completes (success or known failure), the manifest is closed.
An open manifest from a previous run is the signature of a crash. When the pipeline starts, its first action is to check for open manifests. An open manifest means the previous run crashed mid-execution. The pipeline identifies the affected intake entry, resets its status from processing to pending, closes the stale manifest, and then begins its normal execution. The crashed topic will be claimed and processed in the current run.
Idempotency: Making Every Stage Safely Repeatable
Idempotency is the property that running a stage multiple times with the same input produces the same output and does not create duplicate side effects. For a content pipeline, this means:
- Running SOURCE twice does not create two intake entries for the same topic
- Running DRAFT twice on the same intake entry produces the same article (or re-uses the existing draft)
- Running IMAGE twice for the same topic either re-uses the cached image or generates a new one (either is acceptable)
- Running PUBLISH twice for the same topic does not open two GitHub PRs
The key idempotency checks happen at the start of each stage:
Before SOURCE: Has this URL been seen before? (hash check against seen_urls set)
Before DRAFT: Does draft.md already exist for this topic_id? If yes, re-use it unless force_regenerate is set.
Before IMAGE: Does hero.png already exist for this topic_id? If yes, re-use it.
Before PUBLISH: Does a PR already exist with this topic_id in the branch name? If yes, mark the entry as done and skip.
These checks make scheduled recovery safe. Because production pipelines run with KeepAlive=false (they are scheduled jobs, not persistent services), launchd does not restart a crashed run — recovery happens at the next StartCalendarInterval slot. When that next run fires, it is an idempotent re-run: it finds the orphaned topic reset to pending, skips any already-completed stages, and picks up from where the crash occurred.
Build the guard that makes that next scheduled re-run harmless: check a run-tracking store for this run_id before doing any work, and record the run only after the work succeeds.
The Mon/Wed/Fri Cadence in Practice
The three-article weekly cadence is a deliberate constraint, not a technical limit. Knox could run daily. The constraint exists because:
-
Quality over quantity: Each article requires a non-trivial generation + review pass. Three high-quality articles per week consistently outperforms five mediocre ones.
-
Reader cadence: Subscribers to jeremyknox.ai have a predictable publication pattern. Monday, Wednesday, and Friday articles build a reading habit. Daily publication builds a skimming habit.
-
Operational overhead: Three runs per week produces three PRs per week. Knox merges them in under five minutes each. Daily publication would require daily PR review — the overhead becomes the pipeline.
The cadence is a product decision embedded in the scheduling configuration. Changing it requires both a plist update and a deliberate reconsideration of the operational implications.
The Critical env Var Trap
The single most common failure mode in launchd pipeline deployments is missing environment variables. The pattern is consistent: the pipeline works during development (where variables are set in shell profile or .env), is deployed to launchd, and fails immediately because launchd does not inherit any environment variables.
The fix is straightforward but must be applied to every variable: copy the entire contents of your .env file into the plist's EnvironmentVariables block. This is tedious but not optional. Every API key, every webhook URL, every feature flag that the pipeline reads via os.environ.get() must be in this block.
The lesson Knox learned: when a launchd pipeline fails immediately after deployment with a KeyError or None value on an environment variable, the diagnosis is always the same. Run launchctl list | grep <plist-name>, check the exit code, and look for missing environment variable errors in the log file. Then add the missing variable to the plist and reload.