Skip to content

emrg: a scheduled task's frequency cannot vary by the time of day #2486

Closed

Description

@argszero

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) accepts interval and refuses anything below MIN_INTERVAL; the daemon's _validate_task_fields is a second write path over the same field.
  • _heartbeat_interval() answers max(interval, min(interval * 8, 8 * 3600)), and _connect_backoff() builds its exponential backoff on self.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 edits interval with its own minimum of 60.

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 window
  • days: case-insensitive, whitespace-tolerant; mon, mon-fri, mon,fri, and a reversed range such as fri-mon (expanded around the ring). Weekdays only — no public holidays, no make-up workdays.
  • from / to: HH:MM, and 24:00 is allowed; to must be later than from, 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

  1. schedule is optional and purely additive: a task without it must behave exactly as it does today, including the wait it computes.
  2. Exactly one answer at any instant: inside a window, that window's interval; outside every window and between windows, the top-level interval.
  3. 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.
  4. 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.
  5. Local time only: datetime.now(), no timezone or DST dependency, and the documentation says so.
  6. Validation: HH:MM shape, parsable days, 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.
  7. 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.)
  8. 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.
  9. 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.
  10. 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 schedule waits exactly as long as it does today, measured on the same tasks.yml.
  • Four rejected inputs, each with a readable error: an unparsable days, to earlier than from, a window interval below the minimum, and two overlapping windows of one day.
  • Mutation: delete the boundary cutting and its test reddens; delete the days test 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

  1. argszero commented on Oct 22, 2026

    @argszero
    OwnerAuthor

    Handled by #2487

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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