← Back to Blog

One Python Daemon to Schedule All Your Scripts

September 20, 2026

The problem with one Task Scheduler entry per script

If you've automated more than three or four things on a Windows box — a daily report, a weekly backup, an hourly sync — you've probably felt Task Scheduler's ceiling. Each script needs its own task, its own trigger, its own "run whether user is logged in or not" checkbox to remember, and its own place to check when something silently stopped firing. There's no shared retry logic, no shared logging, and no single place to see "what's supposed to run today."

python-scripts-scheduler is a small alternative: one background daemon, MasterRunner.py, that reads a JSON config and runs everything else on its own schedule. You create exactly one Task Scheduler entry — for the daemon itself — and manage every other script's timing in a config file instead of the Windows GUI.

How it works

The daemon polls a config file every two minutes. For each script it checks whether it's "due," and if so, runs it as a subprocess and records the result. That's the whole design — no OS-level cron, no Windows service, just a while True loop with a sleep at the bottom.

pip install -r requirements.txt
copy scripts_config.example.json scripts_config.json
python MasterRunner.py

A config entry looks like this:

{
  "scripts": [
    {
      "path": "path/to/backup.py",
      "description": "Weekly Backup",
      "schedule": {
        "type": "weekly",
        "days": ["Monday", "Friday"],
        "time": "22:00"
      }
    }
  ]
}

Four schedule types are supported: daily, weekly (with a days list), date (one-off), and cron (via croniter, for anything the other three can't express — every 15 minutes, business hours on weekdays, and so on).

Catch-up runs are the actual selling point

The detail that makes this more than a toy wrapper around subprocess calls: because the daemon checks "is it past the scheduled time and has this not run today" rather than "is it exactly this time," a script scheduled for 11:00 that your PC missed — because it was asleep, or you logged in late — still runs the next time the daemon wakes up. Plain Task Scheduler misses these unless you separately enable "run task as soon as possible after a scheduled start is missed," and even then it doesn't track per-script retry state.

The state that makes this possible lives in runner_state.json, one entry per script path:

{
  "path/to/backup.py": {
    "last_run": "2026-09-19T22:00:04",
    "status": "success",
    "retry_count": 0
  }
}

is_script_due() in MasterRunner.py reads this before deciding whether to fire, so a restart of the daemon doesn't cause a script to double-run or forget it already ran today.

Retries, timeouts, and knowing when something broke

Each script gets configurable max_retries, retry_delay_minutes, and timeout_minutes — either per-script or as global defaults. If a script fails, the daemon flips its state to failed and retries it after the delay on a later poll, up to the retry limit. If a script hangs, run_script() kills it once timeout_minutes elapses rather than blocking the daemon's loop forever — the read-stdout thread is joined with a timeout, and the process is killed if that thread is still alive when the timeout hits.

reader_thread = threading.Thread(target=read_stdout, daemon=True)
reader_thread.start()
reader_thread.join(timeout=timeout_seconds)

if reader_thread.is_alive():
    timed_out = True
    logging.error(f"Timeout: {description} exceeded {timeout_minutes} minutes. Killing process.")
    process.kill()

On failure, it also calls winsound.Beep() — a genuinely useful touch if the daemon runs on a machine you're sitting at: you hear a failure instead of discovering it three days later in a log file.

Setting it up

Setup is one Task Scheduler entry, pointed at pythonw.exe (so no console window pops up) with MasterRunner.py as the argument, triggered "at log on." Everything else — what runs, when, how many retries — lives in scripts_config.json, which is gitignored so your personal schedule doesn't leak into version control if you fork the repo. Logs rotate automatically via RotatingFileHandler, capped at 2MB with three backups, so master_runner.log doesn't grow unbounded on a machine that's always on.

When this isn't the right tool

This is built for a single Windows machine you control, running as the logged-in user — it's not a distributed job scheduler, there's no web UI, and a two-minute polling interval means it's not meant for sub-minute precision. If you need any of that, you want something like Airflow or a proper cron daemon on Linux. For the common case — a handful of personal automation scripts on a Windows PC that need to "just run" without babysitting — a 300-line daemon and a JSON file cover it.