emrg: a scheduled task's frequency cannot vary by the time of day #2486
Description
Part: 1/2 — the scheduler's side of this. Part 2/2 (the GUI task form being able to display and edit the windows, whose field rows, i18n and its own interval minimum are a separate block) is not in this part: until it lands, a window is set by hand in ~/.emrg/tasks.yml.
Part 2/2 deliberately has no issue of its own yet. This project's rule is that an issue and the PR that finishes it are opened together, because an open issue no open PR declares is a link the reading reports as unfinished; so 2/2 opens its issue at the moment a cycle takes it on. What it will have to satisfy is stated here rather than left to that cycle's memory: the task form must display and edit the windows in the same shape the record carries, and the 60 its own validation hard-codes must become the one minimum every reader reads.
What is observed
A scheduled task carries exactly one frequency: interval, in seconds, and it is the only knob the scheduler has.
validate_task_record(emrg/server/scheduler.py) acceptsintervaland refuses anything belowMIN_INTERVAL; the daemon's_validate_task_fieldsis a second write path over the same field._heartbeat_interval()answersmax(interval, min(interval * 8, 8 * 3600)), and_connect_backoff()builds its exponential backoff onself.interval._resume_wait_timeout()folds a persisted slot back into a wait when a task resumes.list_tasks()reports the interval, the TUI prints(every {interval}s), and the GUI task form editsintervalwith its own minimum of60.
So a task whose work is only interesting while the host is working, and whose cost is wasted outside those hours, must pick one number for both: reading every 15 minutes during the working day also reads every 15 minutes all night and all weekend, and reading every 3 hours to save that cost makes the task useless while the host is at work.
What is expected
An optional top-level schedule field on a task record, in local time:
- name: feishu-inbox
interval: 10800 # the frequency outside every window
schedule: # optional; without it, today's behaviour is unchanged
- days: mon-fri # optional = every day
from: "09:00"
to: "18:00"
interval: 900 # the frequency inside this windowdays: case-insensitive, whitespace-tolerant;mon,mon-fri,mon,fri, and a reversed range such asfri-mon(expanded around the ring). Weekdays only — no public holidays, no make-up workdays.from/to:HH:MM, and24:00is allowed;tomust be later thanfrom, and a window crossing midnight is written as two windows.- Two windows of the same day must not overlap — an overlap is two answers to one question.
Requirements
scheduleis optional and purely additive: a task without it must behave exactly as it does today, including the wait it computes.- Exactly one answer at any instant: inside a window, that window's
interval; outside every window and between windows, the top-levelinterval. - One producer.
_connect_backoff()and_heartbeat_interval()take the currently effective interval as their base, computed by one function; neither re-derives the rule and no second copy of the algorithm is added. - A wait must be cut to the next window boundary. A 3-hour weekend wait that begins at 06:59 must wake at 09:00 rather than sleep through the whole window; waking at a boundary recomputes instead of spinning.
- Local time only:
datetime.now(), no timezone or DST dependency, and the documentation says so. - Validation:
HH:MMshape, parsabledays, no overlap within a day, and a window interval not below the minimum. That minimum has more than one home today (the record validator, the daemon's field validator, and the GUI form's own literal) — measure how many, then leave one constant that every reader reads, and add no fourth. - Observability:
list_tasks()reports the currently effective interval and the next window boundary, and a record with no live handler reports the same keys, so a client never has to know whether a task has been started to render its cadence; the TUI's(every Ns)shows the value in force rather than the declared one. (Displaying and editing the windows in the GUI task form is Part 2/2.) - No fighting the existing coverage layers: the task's own slowdown (×8, capped at 8 hours) and the connection backoff both take the effective interval as their base and neither bypasses the other; a manual
trigger()is unchanged. - A restart does not reset the rhythm: a slot folded back by
_resume_wait_timeout()is re-clipped under the new rule, so a daemon restart inside a window does not drop the task to the out-of-window frequency. - One source of truth for the field: the loader, the daemon's create/update path and the GUI form all read and write the same shape.
Acceptance
Every item is an assertion, not a happy path:
- Both window edges: 17:59 takes the 15-minute frequency, 18:00 takes the 3-hour one.
- Boundary cutting on its own: a task in a weekend band at 06:59 must wake at 09:00 — not at 09:59, and not at 06:59 plus the weekend interval.
- A task with no
schedulewaits exactly as long as it does today, measured on the sametasks.yml. - Four rejected inputs, each with a readable error: an unparsable
days,toearlier thanfrom, a window interval below the minimum, and two overlapping windows of one day. - Mutation: delete the boundary cutting and its test reddens; delete the
daystest and its test reddens.
Rejected alternative
A cron expression ("*/15 9-17 * * 1-5" plus "0 */3 * * *", with interval demoted to a minimum-interval gate). The format is standard, but cron names fixed instants while this scheduler waits "the interval after the previous run finished", so a reader must hold both concepts at once; cron's hour range is also closed, so finishing at 18:00 means writing 9-17, a trap that needs its own explanation. Readability was preferred, which is why the window field above is the shape to build.
Activity
argszero commentedon Oct 22, 2026
Handled by #2487
emrg: the GUI task form can show and edit schedule windows (#2491)