DEV Community

Cover image for What should a scheduled job do when the laptop was asleep?
Hanako H.
Hanako H.

Posted on

What should a scheduled job do when the laptop was asleep?

A cron expression says when a job runs and nothing else. On a server that is enough, because the machine is always on and a run rarely outlasts the gap to the next one. On a laptop neither is true. The lid is closed at 09:00, the app that owns the schedule isn't running, and an AI agent run can take several minutes. Two questions then need answers that 0 9 * * 1-5 can't give: what happens to a firing the machine slept through, and what happens when a firing comes due while the previous run is still going.

The osFoundry desktop app answers both for its scheduled agent loops, and its Rust source (local_agent_tasks.rs) names Temporal's Schedules as the model: an overlap policy (skip or queue) and a catch-up window (an hour, 12 hours, or however late). Below is the same design in about 170 lines of standard-library Python on SQLite, run in real time on a Mac, including a laptop "sleep" made with SIGSTOP, and then the time-zone arithmetic that decides whether "09:00" is your 09:00.

What macOS does already

The system's own schedulers both answer the sleep question, in opposite ways, and neither lets you choose. The launchd.plist man page on this Mac spells it out: cron skips invocations while the computer is asleep, while launchd starts a StartCalendarInterval job the next time the computer wakes, and if several intervals passed during sleep they are coalesced into one event.

So cron drops the morning report if the lid was closed at 09:00, and launchd delivers it at 18:00 when you open the laptop in the evening. For a report that is supposed to describe this morning, both are wrong. What you want is in between: run it if I open the laptop at 10:30, not if I open it tomorrow.

One row per upcoming firing

The whole scheduler is one table. A schedule is never stored as "the cron expression" alone. It is stored as a row for its next firing, with the time already computed and the two policies copied onto the row.

SCHEMA = """CREATE TABLE IF NOT EXISTS tasks(
  id TEXT PRIMARY KEY, session TEXT, kind TEXT, cron TEXT, run_at TEXT,
  status TEXT DEFAULT 'pending', overlap TEXT, catchup INTEGER,
  attempts INTEGER DEFAULT 0, note TEXT, payload TEXT,
  created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), started_at TEXT, updated_at TEXT)"""

def sql_time(t):
    # One format for every timestamp in the table: SQLite's own, UTC.
    return t.strftime("%Y-%m-%d %H:%M:%S")
Enter fullscreen mode Exit fullscreen mode

next_fire is a plain five-field cron evaluator in UTC that steps minute by minute from the next whole minute, the same approach as the app's next_cron_fire in cron.rs, including the classic rule that when both day-of-month and day-of-week are restricted, either one matching is enough. It is in the full script; nothing about it is unusual.

Two things about this layout do most of the work later. While the laptop sleeps, at most one firing per schedule exists, so there is nothing to pile up. And because the next row is only written after a run ends, a long run can't stack copies of itself:

def finish_and_rearm(c, session, task):
    tid, kind, cron, overlap, catchup, payload = task
    c.execute("UPDATE tasks SET status='completed', updated_at=datetime('now') WHERE id=?", (tid,))
    if kind == "scheduled":
        nxt = next_fire(cron)           # from NOW, i.e. after the run finished
        if nxt:
            arm(c, session, cron, nxt, overlap, catchup, payload=json.loads(payload))
Enter fullscreen mode Exit fullscreen mode

The policies are copied onto the new row. The app's source has a comment on exactly this: re-arming inserts a new row, so a default there would silently reset the user's choice after the first firing.

The timestamp format matters more than it looks. SQLite has no date type, and run_at <= datetime('now') is a string comparison. If one writer stores 2026-11-05T09:00:00+00:00 and datetime('now') returns 2026-11-05 09:05:00, the eleventh character decides: T sorts after a space, so the row is "not due yet" for the rest of that UTC day.

$ sqlite3 :memory: "select '2026-11-05T09:00:00+00:00' <= '2026-11-05 09:05:00',
                           datetime('2026-11-05T09:00:00+00:00') <= '2026-11-05 09:05:00'"
0|1
Enter fullscreen mode Exit fullscreen mode

Pick one format for every writer, or wrap the column in datetime() everywhere you compare it.

Retire first, then claim

Every poller pass (15 seconds here, the same as the app's default interval) does two things per session, in this order. First it ends the due firings that must not run:

def retire_unrunnable(c, session):
    busy = c.execute("SELECT EXISTS(SELECT 1 FROM tasks WHERE session=? AND status='running')",
                     (session,)).fetchone()[0]
    due = c.execute("""SELECT id, cron, overlap, catchup, payload,
                              CAST((julianday('now') - julianday(run_at)) * 86400 AS INTEGER)
                         FROM tasks WHERE session=? AND status='pending' AND kind='scheduled'
                          AND run_at <= datetime('now')""", (session,)).fetchall()
    for tid, cron, overlap, catchup, payload, late in due:
        too_late = catchup is not None and late > catchup
        overlapped = busy and overlap == "skip"
        if not (too_late or overlapped):
            continue
        note = (f"skipped: due {late}s ago, past the catch-up window" if too_late
                else "skipped: the previous run was still going")
        c.execute("UPDATE tasks SET status='completed', note=?, updated_at=datetime('now') "
                  "WHERE id=? AND status='pending'", (note, tid))
        nxt = next_fire(cron)            # the recurrence survives a skipped firing
        if nxt:
            arm(c, session, cron, nxt, overlap, catchup, payload=json.loads(payload))
Enter fullscreen mode Exit fullscreen mode

A retired firing is marked completed with a reason, not deleted and not cancelled. The app does the same and explains why: "cancelled" reads as "a person stopped this", and the reason string is what the schedule's status line shows. The re-arm inside the loop is the line that is easy to forget. Without it, skipping one firing ends the schedule for good.

Then it claims at most one due task, and never while another task of the same session is running. That NOT EXISTS is the "queue" half of the overlap policy; the "skip" half already happened above.

row = c.execute("""UPDATE tasks SET status='running', attempts=attempts+1, ...
                    WHERE id=(SELECT id FROM tasks WHERE session=? AND status='pending'
                                AND NOT EXISTS(SELECT 1 FROM tasks r WHERE r.session=? AND r.status='running')
                                AND (run_at IS NULL OR run_at <= datetime('now'))
                              ORDER BY created_at LIMIT 1)
                RETURNING id, kind, cron, overlap, catchup, payload""", (session, session)).fetchone()
Enter fullscreen mode Exit fullscreen mode

Doing the claim as one conditional UPDATE ... RETURNING lets SQLite serialise two pollers for you. Only one of them gets the row.

Five runs in real time

run.sh starts five scenarios at once, each its own process and database, all on the schedule * * * * * (every minute, so that 8 minutes of wall clock show the behaviour that a daily schedule would take days to show). The "agent" is a time.sleep.

A run longer than the interval. Each run takes 150 seconds with overlap set to skip:

Due Started Ended
04:33:00 04:33:09 04:35:39
04:36:00 04:36:09 04:38:39
04:39:00 04:39:09 (still running at the end)

Three runs in 8 minutes, never two at once, and no "skipped" rows at all. The firings at 04:34, 04:35, 04:37 and 04:38 were never created, because the next firing is computed when a run ends. The 9-second delay on every start is the poller's phase: a firing waits for the next 15-second pass.

Overlap with another trigger. That leaves overlap to come from somewhere else, such as a person pressing Run in the same session. Here, the scheduled runs take 20 seconds, and a manual run of 130 seconds starts at 04:33:39 and ends at 04:35:49:

Firing due Skip it Wait, then run
04:34 skipped: the previous run was still going ran at 04:35:54, 114 s late
04:35 skipped: the previous run was still going never created
04:36 ran at 04:36:09 never created
04:37 ran at 04:37:09 ran at 04:37:09

Over the same 8 minutes, skip produced 6 scheduled runs and 2 recorded skips, and queue produced 6 scheduled runs, one of them nearly two minutes late. Queue doesn't make up every missed minute. It holds the one pending firing and runs it as soon as the slot frees up, then the next one is computed from that finish time. For a report that says "what changed since the last run", a late run is fine. For a job tied to a time, such as "send the 09:00 digest", the skip is the honest outcome.

Sleep. Each run takes 5 seconds. The poller process was stopped with kill -STOP at 04:34:19 and continued with kill -CONT at 04:38:19, 240 seconds later. The wall clock keeps going while a process is stopped, which is what a sleeping laptop looks like from the scheduler's point of view. Four firings (04:35 to 04:38) fell inside the gap.

Catch-up window What happened after waking Next run
however late the 04:35 firing ran once at 04:38:19 04:39:04
60 seconds 04:35 retired: "skipped: due 199s ago, past the catch-up window" 04:39:04

Neither run four catch-up runs. "However late" behaves like launchd: one coalesced run on wake. A finite window turns it into "skip it if it's stale", which is the behaviour cron can't express. With the app's "Up to 12 hours late" and a weekday 09:00 schedule, opening the laptop at 10:30 runs that morning's job, and opening it at 08:00 the next morning runs nothing late; the stale firing is retired and the schedule continues at 09:00.

The watermark ignores skipped firings

A recurring report should say what changed since the previous report. The app doesn't rely on the agent remembering: it appends the time of the last run that actually ran to that firing's goal. The query has to exclude retired firings, because they are completed too.

def last_real_run(c, session):
    r = c.execute("""SELECT updated_at FROM tasks WHERE session=? AND status='completed'
                      AND attempts > 0 AND (note IS NULL OR note NOT LIKE 'skipped:%')
                    ORDER BY updated_at DESC LIMIT 1""", (session,)).fetchone()
    return r and r[0]

def goal_for(c, session, goal):
    when = last_real_run(c, session)
    if not when:
        return goal                     # first run: nothing extra
    return (f"{goal}\n\nThis job last completed a run at {when} UTC. Report only what "
            f"changed since then, and say so if nothing did.")
Enter fullscreen mode Exit fullscreen mode

On the 60-second-window database, right after waking, this returns 04:34:14, the end of the last real run. Without the two extra conditions it returns 04:38:19, the moment the stale firing was retired, and the next report would silently cover four minutes less than it claims. On a daily schedule that is a whole missed day of competitor changes. The app's comment on the same query puts it as moving the watermark "past work nobody did".

Telling the model the time, instead of telling it "only report new things", matters too. An agent with no way to check what is new will invent the distinction.

Whose 09:00?

The app evaluates cron in UTC, and so does this script. tz.py takes the first 20 weekday firings from 19 October 2026 and shows them on four local clocks. That window crosses both the European (25 October) and US (1 November) clock changes.

Zone 0 9 * * 1-5 lands at Cron for 09:00 local Right for
Asia/Tokyo 18:00 0 0 * * 1-5 20 of 20
Europe/London 10:00, then 09:00 0 8 * * 1-5 5 of 20, wrong from Mon 26 Oct
America/New_York 05:00, then 04:00 0 13 * * 1-5 10 of 20, wrong from Mon 2 Nov
Australia/Sydney 20:00 0 22 * * 0-4 20 of 20

Two things stand out. Sydney's 09:00 on Monday is 22:00 UTC on Sunday, so the weekday field has to move as well (0-4, Sunday to Thursday), or the job runs Tuesday to Saturday. And no fixed UTC expression is right all year in a zone with daylight saving time. The script's helper computes the right expression for the offset in force on a given day, which then stays right until the next clock change:

def local_cron_for_9am(zone, on):
    local = datetime(on.year, on.month, on.day, 9, tzinfo=ZoneInfo(zone))
    u = local.astimezone(timezone.utc)
    shift = (u.date() - local.date()).days          # -1 when 09:00 local is still yesterday in UTC
    days = "1-5" if shift == 0 else "0-4" if shift == -1 else "2-6"
    return f"{u.minute} {u.hour} * * {days}"
Enter fullscreen mode Exit fullscreen mode

The app's schedule dialog deals with the same problem by listing the next three firings in the reader's own time zone under the expression, so "09:00" can be checked against a clock before anything is saved. That list is the cheapest safeguard in this whole design, and it is worth copying into any tool that accepts a cron string.

Running it

python3 deskcron.py out/sleep-60.db sleep-60      # one scenario, 8 minutes
./run.sh                                          # all five in parallel, then prints each table
python3 tz.py                                     # the time-zone table, instant
Enter fullscreen mode Exit fullscreen mode

The osFoundry desktop app uses this design for its scheduled agents: Trigger → Add a schedule on a loop, with Skip it or Wait, then run, and a catch-up window of an hour, 12 hours or however late. It is free from the osFoundry download page.

Top comments (0)