now_due_slot() returned None on almost every tick and I had no idea for three days. The launchd plist was firing correctly. The generation code was fine. The queue had candidates. Nothing posted.
The root cause: StartCalendarInterval fires at exact clock times, and at some point my config.SLOTS drifted away from those exact times. The cron style trigger was asking "is it 9:00am?" and the slot config said "post at 9:05am." Miss by five minutes, return None, exit 0, log nothing, move on. Three days of that.
The fix was to stop being clever. Switch to StartInterval 600 (a polling loop every 10 minutes) and let the slot logic itself decide whether to fire. Each slot gets a slot_key and posts at most once per local day. Idempotency lives in the application, not in the scheduler. The scheduler just wakes up, checks, and usually does nothing.
This is a lesson I have learned before in different forms: never put business logic in your cron spec. The plist (or cron entry, or GitHub Actions schedule) should know one thing: when to wake up. The application should know everything else: whether to run, what to skip, whether this tick is the right tick.,
On the same branch, I added content feeders to break a different kind of silence: the original post queue running dry.
The previous setup pulled topic candidates only from Obsidian session logs. Sessions are grounded, they reflect real work, but they are finite and slow to accumulate. When I had a quiet week, the queue went flat.
Two new sources now feed it.
sources/github_trending.py pulls the AI and dev trending repos daily using stdlib urllib and html.parser. No API key, no rate limit headache. Repos become signal candidates in the queue.
sources/niche_pulse.py aggregates recurring themes across the creator pool I track. If five of the people I follow are talking about the same thing, that is a signal worth reacting to.
Both feed into blended_candidates(), which interleaves grounded candidates (Obsidian sessions, usable for first person work claims) and ungrounded signals (trending topics, usable for reaction posts). The generator picks the right prompt based on the grounded flag on the Candidate. Work claim prompt for sessions, reaction prompt for signals. Clean separation, no conditional spaghetti in the generator itself.
A POSTS_DAILY_FLOOR backstop handles the residual case where no slot fires and the queue still sits idle. variance.floor_catchup checks pace against the floor, picks a candidate if behind, and posts with a small capped jitter so it does not fire again on the very next tick. It has its own quiet hours check, so it will not fire at 2am to catch up on a slow day.
One more fix buried in the same commit: , dry run was popping the queue. There was a stale state.save call that ran even in preview mode, which violated the documented peek never pop contract. A dry run should read state and render output. It should never mutate. The call is gone.,
What I would do differently
Skip StartCalendarInterval entirely from day one. Polling loops with application side idempotency are strictly simpler and more debuggable. The appeal of cron style scheduling is that it "just knows" when to fire. In practice, it creates a tight coupling between your scheduler config and your application config that drifts silently and leaves you staring at logs for three days.
Add an explicit queue depth alert earlier. Three days of silence is a long time to notice a zero queue. A single log line on every tick reporting queue depth would have surfaced the problem the first morning it happened.
Ship the feeders earlier too. A content engine that relies on a single source of candidates is one quiet week away from going flat. Blend your sources, gate on quality, let the floor catch the rest.
Top comments (0)