Skip to content

Pulse cron scheduler and launchd LaunchAgents don't catch up missed nightly jobs (machine off/asleep at scheduled time) #1513

Description

@xmasyx

Bug

Pulse's cron scheduler (PULSE/lib.ts, isDue()) — and the standalone Install*.ts launchd LaunchAgent templates in TOOLS/ — do not catch up missed calendar-scheduled jobs after the machine has been off or asleep through the scheduled time. On any laptop that isn't left powered on/charging overnight, every nightly/weekly job silently stops running, with no error and no log entry indicating a miss.

Root cause 1 — isDue() requires a live exact-minute match, not "most recent occurrence since last run"

// PULSE/lib.ts
export function isDue(schedule: string, now: Date, lastRun?: number): boolean {
  if (!matchesCron(schedule, now)) return false
  if (lastRun === undefined) return true
  // Don't run more than once per minute
  return Math.floor(now.getTime() / 60_000) > Math.floor(lastRun / 60_000)
}

matchesCron(schedule, now) only returns true when the current wall-clock minute matches the cron fields. There is no comparison against "when was this job last supposed to fire, and did we miss it." Consequences for any [[job]] in PULSE.toml with a calendar-style schedule (e.g. 0 3 * * *, 0 4 * * 0, 0 23 * * *):

  • If Pulse (and the machine) is not running at the exact HH:MM the schedule specifies, isDue() returns false for that tick.
  • It stays false for every subsequent tick until wall-clock time happens to land on that exact minute again — which, on a machine that's off overnight, may never happen if the user's wake time doesn't line up with the job's minute.
  • Nothing surfaces this: no warning, no "N days since last successful run," no degraded-health signal. Documentation/Pulse/PulseSystem.md states "jobs that were overdue during the downtime will run on the first tick" — that's only true for high-frequency schedules (*/5 * * * * etc.) where the next match is minutes away. For once-daily/weekly jobs it's false, and the documentation is misleading about the actual guarantee.

Root cause 2 — inconsistent RunAtLoad across the independent LaunchAgent templates

Outside Pulse's own heartbeat, several background jobs are installed as separate launchd LaunchAgents via TOOLS/Install*.ts + TOOLS/com.lifeos.*.plist.template, each with its own StartCalendarInterval. RunAtLoad (fire once when the agent loads, i.e. at every login/boot — the closest thing launchd offers to "catch up if we missed the scheduled time") is set inconsistently:

Template Schedule RunAtLoad
com.lifeos.usage-aggregator.plist.template 03:30 daily true
com.lifeos.blogdiscovery.plist.template 04:00 daily false
com.lifeos.commitmentsweep.plist.template 07:00 daily false
com.lifeos.codexupdate.plist.template 04:00 daily absent (comment: "No RunAtLoad — avoids reinstalling the codex global on every login")

Only usage-aggregator got the fix. blogdiscovery and commitmentsweep have no stated reason to omit it — they look like the same gap, just not patched yet.

Suggested fix

1. Make isDue() catch-up-aware (fixes every current and future PULSE.toml job in one place, no per-job config):

// PULSE/lib.ts
export function isDue(schedule: string, now: Date, lastRun?: number): boolean {
  const lastScheduled = lastScheduledOccurrence(schedule, now) // most recent past minute matching the cron fields
  if (lastScheduled === null) return false
  if (lastRun === undefined) return true
  // Due if the most recent scheduled firing happened after the last successful run
  // (covers both "it's due right now" and "we missed it while the machine was off/asleep").
  return lastScheduled > lastRun
}

function lastScheduledOccurrence(schedule: string, now: Date, lookbackMinutes = 7 * 24 * 60): Date | null {
  const cursor = new Date(now)
  cursor.setSeconds(0, 0)
  for (let i = 0; i <= lookbackMinutes; i++) {
    if (matchesCron(schedule, cursor)) return cursor
    cursor.setMinutes(cursor.getMinutes() - 1)
  }
  return null // no match in the lookback window — malformed schedule, or a field combination that can't occur (e.g. Feb 30)
}

The existing "don't run more than once per minute" behavior is preserved as a side effect of comparing against lastRun directly rather than floor-dividing by 60s — a job that already ran this minute has lastRun >= lastScheduled for that same occurrence, so it won't double-fire. Recommend capping lookbackMinutes (a week is generous; even a day covers "laptop was off overnight") so a badly-formed cron expression that never matches doesn't scan forever.

2. Add RunAtLoad: true to com.lifeos.blogdiscovery.plist.template and com.lifeos.commitmentsweep.plist.template, matching the fix already shipped for usage-aggregator. For com.lifeos.codexupdate.plist.template, which intentionally opts out of RunAtLoad to avoid reinstalling the codex global on every login, the safer fix is an idempotency check inside CodexUpdate.ts itself (skip if already up to date) so RunAtLoad can be safely turned on there too — happy to send that as a follow-up if useful.

Environment

  • macOS, Pulse installed via the standard ~/.claude LifeOS install path.
  • Reproduced by reading PULSE/lib.ts isDue()/matchesCron() and the four com.lifeos.*.plist.template files with StartCalendarInterval, and cross-checking against PULSE/state/state.json job lastRun timestamps and Documentation/Pulse/PulseSystem.md's stated overdue-catch-up guarantee.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions