Automation that runs every day without being watched: idempotency, scheduling guardrails, and a calendar read

Photo by Ed Hardie on Unsplash

A scheduled job nobody watches is trusted right up until it is not. My daily automation reads my calendar and sends one walk-window message. It hid two failures behind a green run: it could fire twice for a day, when two triggers named the same morning’s work, and it could deliver a line the data did not carry. Both read as a normal run. The discipline an unwatched pipeline rests on is its own story.

TL;DR

Four structural guards, not a tighter schedule, keep this run honest: a calendar read over a Proton Calendar ICS share, fixed launchd calendar slots instead of a relative interval, a per-machine idempotency sentinel, and a deterministic rendering contract that keeps the advice grounded in the data. Five records are partial: I claim the design and its status, not a measured rate.

The real question was not which schedule

Framed as tooling, the answer is a better schedule expression. That framing hides the real question: can an unwatched run be trusted not to double-fire, drift, or surprise? A duplicate is two triggers naming the same morning’s work; a catch-up interval drifts onto the wrong day. The fix is structural: a calendar source that degrades independently, a sentinel that makes a same-day re-run safe, a schedule that cannot misfire at midnight, and a rendering contract that keeps output deterministic and grounded.

The five decisions, and what each rejected

  • Fixed calendar slots, not a relative interval. The catch-up job uses a StartCalendarInterval array, one slot per hour, never StartInterval: 3600, which fires at midnight; the scheduled job uses one fixed daily slot with no RunAtLoad, both on macOS.
  • A per-machine sentinel, not a lock file and a database state machine. A run exits if today’s dog-walk-{YYYYMMDD}-*.md marker exists; the lock file plus state machine lost because the pipeline is otherwise stateless.
  • A JSON-only contract with deterministic rendering, not free-form output. The model returns structured JSON; code renders every visible artifact. Free-form prose lost: it cannot be diffed or audited. The rendering architecture is one I reuse, and the guard it feeds is the deterministic guard in code.
  • The Proton “Limited view” share link, not the full-view link. It exposes only busy/free timing, not title, description, location, or attendees; the full-view link would expose details.
  • A deterministic pass over the model’s own conflict judgement. Selected slots are re-checked against the real events in code; the model’s calendar_conflict field is never trusted downstream.

What the pipeline actually does

The calendar read is its own script, proton_calendar_check.py. It reads the official Proton “Share with anyone, Limited view” ICS link, which exposes availability only. Events arrive under a fixed neutral summary and calendar name, parsed with icalendar and recurring-ical-events for recurring-event expansion (RRULE), times computed against Europe/Paris. The share URL is a bearer secret: never logged or printed. Moving a household’s mail, docs, and calendar out of the US cloud is what required the read.

Rendering is two-stage: the model returns JSON, format_notify_message() renders every unit and layout detail, and a validation step drops an advice line the forecast does not carry.

One detail to state plainly: the fixed launchd slots are the scheduling decision, but the production trigger is a plain cron job at 06:00 CEST on my virtual private server (VPS). The Mac launchd job is suspended, re-enabled only for local debug.

What broke

The calendar guard exists because of a recorded failure. The model sometimes ignored explicitly listed blocked hours. It could pick a slot starting exactly at a meeting boundary or inside a booked hour, though the prompt listed each blocked hour. The fix was not a better prompt: the deterministic pass re-checks every selected slot against the real events and overwrites the model’s calendar_conflict field with the actual overlap, logging a warning when it corrects.

The outcome, and its status

The run is daily and unwatched: the calendar read is in the pipeline, a same-day re-run is safe per machine, the schedule cannot misfire at midnight, the message renders deterministically, and the advice is checked against the data. Application-level operation, not autonomy: the human stays in the chain.

Five records are partial, so I claim the design and its status, not a measured property: no uptime, no throughput, no delivery rate.

The frame holds the backups I test before trusting and the private messaging channel my automations report through. Self-managed hosting is where it sits: four sites on one server, moved by a blue-green migration, with the sites’ own scheduler moved onto a system crontab. Keeping a person accountable for what ships is the pattern under all of it. This belongs to the resilience frame it sits in. The operator behind this stack is on the about page. If this is the shape of your problem, let’s talk.