Pause a specific folder or repo's runs between certain times — recurring daily, restricted to certain weekdays or a date range, in a timezone of your choice — and resume automatically after. A paused job is deferred, never dropped: it waits in the queue and runs once the window ends.
This is distinct from the global pause (pi-dispatch pause / /dispatch p), which stops the whole
queue with no schedule. Scoped pause windows are per-scope and timed, and the two compose — a scope can be
inside a pause window while the rest of the queue keeps draining.
Point PI_PAUSE_WINDOWS_FILE at a JSON file. For the worker, unset means the feature is off: nothing is
defaulted, and no windows are read.
# .env
PI_PAUSE_WINDOWS_FILE=/absolute/path/to/pause-windows.json{
"windows": [
{ "scope": "acme/web", "from": "22:00", "to": "06:00", "tz": "Europe/Amsterdam" }
]
}The worker validates the file at boot (a malformed file refuses startup, fail-loud) and live-reloads
it on change — an edit takes effect on the next job without a restart, and a bad edit keeps the last-good
windows. Two files in this repo to know apart: pi-dispatch init scaffolds an empty
./pause-windows.json ({ "windows": [] }) in the deployment folder, and pause-windows.example.json at
the repo root is the populated example to copy windows out of.
Three parts of the system decide on this path independently, and only one of them treats unset as off:
| Who | What it uses when PI_PAUSE_WINDOWS_FILE is unset |
|---|---|
| The worker, the only thing that actually defers a job | nothing: the feature is off, no windows are loaded |
pi-dispatch init |
scaffolds ./pause-windows.json and leaves the variable commented out in .env |
The /dispatch panel (and the dispatch_pause_* tools) |
defaults to ./pause-windows.json in the panel's own cwd, so it works from a deployment folder with no env wiring |
Each is defensible alone. Together they compose into one silent trap: run init, then manage quiet hours
through the panel, and you are editing a file the worker never reads. The panel answers
pause window added (live), the JSON on disk is correct, and nothing ever pauses.
So set the variable, to an absolute path, in the worker's own environment, and make sure the panel
resolves the same file (export it there too, or let /dispatch setup write a deployment pointer:
PI_PAUSE_WINDOWS_FILE is on the pointer's env allowlist precisely so a panel started anywhere can find the
worker's files). pi-dispatch doctor warns on this exact mismatch, a pause-windows.json in its cwd
while the variable is unset, and says the worker ignores it so scoped pauses are off. It warns rather than
fails, and offers no --fix, because only you know which path was meant.
| Field | Required | Meaning |
|---|---|---|
scope |
yes | What the window applies to: the job's repo path on any forge (GitHub, GitLab, Forgejo, Azure DevOps), the folder host path for a local/cron job, or "*" for all scopes. Matched exactly, so mind the segment count: GitHub and Forgejo are owner/name, but a GitLab project is group/subgroup/project and Azure DevOps is org/project/repo. The rule is just local → folder, anything else → repo, so a forge added later is scoped automatically. |
from |
yes | Pause start, "HH:MM" 24-hour. |
to |
yes | Resume time, "HH:MM" 24-hour. If from > to the window is overnight (spans midnight). from == to is rejected — a 24h pause isn't expressible; remove the trigger instead. |
tz |
no (default UTC) |
IANA timezone, e.g. "Europe/Amsterdam", "America/New_York". from/to are that zone's wall clock, DST-correct. |
days |
no (default: every day) | Weekday allow-list, e.g. ["mon","tue","wed","thu","fri"]. Gates the day the window starts — an overnight window that starts on an allowed day still runs into the next morning. |
dateFrom |
no | Inclusive "YYYY-MM-DD": the window applies only on/after this start date. |
dateTo |
no | Inclusive "YYYY-MM-DD": only on/before this start date. |
Daytime freeze (same-day window) — pause a repo 09:00–17:00 UTC:
{ "scope": "acme/web", "from": "09:00", "to": "17:00" }Overnight quiet hours — pause a folder every night 22:00 → 06:00 in Amsterdam time (from > to = overnight):
{ "scope": "/srv/site", "from": "22:00", "to": "06:00", "tz": "Europe/Amsterdam" }Weeknights only — the overnight window, but only when it starts on a weekday:
{ "scope": "acme/web", "from": "22:00", "to": "06:00", "tz": "Europe/Amsterdam",
"days": ["mon","tue","wed","thu","fri"] }A change-freeze between dates: acme/api frozen across a release window. Note the shape, 00:00 to
23:59 is a same-day window matched as [from, to), so it leaves the final minute (23:59) open, and
from == to is rejected by design, which means a true 24-hour window is not expressible in one entry. Either
accept that minute or lay down two adjacent windows:
{ "windows": [
{ "scope": "acme/api", "from": "00:00", "to": "12:00", "dateFrom": "2026-08-10", "dateTo": "2026-08-14" },
{ "scope": "acme/api", "from": "12:00", "to": "00:00", "dateFrom": "2026-08-10", "dateTo": "2026-08-14" }
] }Everything, on weekends — pause all scopes on Saturday/Sunday nights:
{ "scope": "*", "from": "20:00", "to": "08:00", "days": ["sat","sun"] }Multiple windows — they're independent; a job is paused if it falls inside any matching window, and held until the latest one ends:
{ "windows": [
{ "scope": "acme/web", "from": "22:00", "to": "06:00", "tz": "Europe/Amsterdam" },
{ "scope": "/srv/site", "from": "09:00", "to": "17:00" }
] }- Deferred, not dropped. When a job is picked up and its scope is inside an active window, the worker moves
it to the queue's delayed set until the window ends (via BullMQ's
moveToDelayed), then it runs automatically. It keeps its identity (so GitHub delivery-GUID dedup still holds) and survives a worker restart. - Zero cost while paused. The check runs before the budget reservation, so a deferred job reserves no spend slot and counts nothing against your daily/weekly/monthly caps — a deferral is not a job start.
- Timezone-correct. Wall-clock times are resolved in the window's
tzusing the runtime's built-in timezone data (DST-correct), with no extra dependency.
Three equivalent ways, all writing the same validated file and taking effect live, provided the panel and
the worker resolve PI_PAUSE_WINDOWS_FILE to the same path (see Enable it above):
- Edit the file. Change
pause-windows.json; the worker hot-reloads it (keeps the last-good set on a bad edit). - In the panel. Open
/dispatch, pressw→ add, edit, or delete a window through operator dialogs. Editing re-prompts each field with its current value, so a blank answer keeps it and you only re-type what changes. The PAUSE WINDOWS section shows each window as●paused (with a resume countdown) or○open. - From an agent, human-gated. The model tools
dispatch_pause_add/dispatch_pause_edit/dispatch_pause_delete(and the read-onlydispatch_pauses) let an agent manage windows — edit is a partial change (pass only the fields to alter; the rest keep their value) — but each write pops an operator confirmation the model can't answer, and is refused when no operator is present.
- A window edited or removed while a job is already delayed doesn't re-time that job — it wakes at its original window-end and the gate re-checks then. (You can also promote delayed jobs manually via the queue.)
from == tois rejected on purpose. To pause a scope indefinitely, remove its trigger rather than express a 24-hour window.
The internal specs: REQ-SCOPED-PAUSE-WINDOWS (requirements),
DES-SCOPED-PAUSE-VIA-MOVE-TO-DELAYED (design),
INT-PAUSE-WINDOWS-FILE-CONTRACT (interfaces).