Skip to content

emrg: a scheduled task's frequency can vary by the time of day - #2487

Merged
argszero merged 3 commits into
masterfrom
feature/task-schedule-windows
Oct 22, 2026
Merged

argszero merged 3 commits into
masterfrom
feature/task-schedule-windows

Conversation

@argszero

@argszero argszero commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Closes #2486

Part: 1/2 — the scheduler's side. Part 2/2 (the GUI task form being able to display and edit the windows) is deliberately not in this PR: until it lands, a window is set by hand in ~/.emrg/tasks.yml.

What changed

A task record may now carry an optional top-level schedule: a list of windows, each with days (an optional weekday set), from / to (HH:MM, 24:00 allowed) and interval. Inside a window that window's interval answers; outside every window and between windows the record's own interval does — so one instant never has two answers, and a record without the field behaves exactly as it does today.

One producer. effective_interval(record, now) is the only place that rule lives. TaskHandler._effective_interval() reads it, and _heartbeat_interval(), _connect_backoff(), the wait loop and every reported row take their base from there — so the x8 slowdown and the connection backoff now apply to the frequency actually in force instead of fighting the window, and neither bypasses the other.

The cut. A wait that would run past the next window edge is cut to it: with interval: 10800 and a band opening at 09:00, the wait armed at 06:59 is 121 minutes, not three hours. Edges are strictly in the future, so waking at one recomputes under the frequency that edge selects rather than finding the same edge zero seconds away. The cut is applied after _resume_wait_timeout(), so a daemon restart inside a window does not drop the task to the out-of-window frequency.

Local time only. datetime.now(), no timezone argument, no DST rule, no holiday table — stated in the module's schema block and in DEVELOPMENT.md.

One validator, one shape. validate_schedule() is called by the loader (validate_task_record) and by the daemon's field validator, and therefore by both CRUD paths. The daemon passes schedule through untouched, and task_update writes it only when the payload carries it (an explicit null removes the field), so a client that predates the field keeps editing the task without erasing its windows. MIN_INTERVAL already had one constant and two readers; no third home was added.

Reported, not re-derived. status() and _idle_status() both carry effective_interval, schedule and next_window_boundary_in_seconds, so a client renders the cadence rather than recomputing it from a clock of its own. The TUI's task row shows the value in force (falling back to interval for a row from a daemon that predates the field).

Verification

  • uv run pytest tests/ — 5624 passed, 34 skipped; uv run python -c "from emrg.client.app import run_client" and uv run python -m emrg --help both pass.
  • tests/test_schedule_window.py (35 tests) asserts each acceptance item: both edges (17:59 gives 900s, 18:00 gives 10800s), the weekend 06:59 wake at exactly 7260s, the four rejections with their messages, the day rule (fri-mon wrapping, disjoint days not overlapping), both write paths and the reported rows.
  • Two dual arms on the real run() loop, over a real tasks.yml, differing only in what they state: a record with no schedule key at all waits its interval (~3600s), and a record carrying the mon-fri 09:00-18:00 / 900 band — with the clock frozen inside it — waits the window's ~900s. Deleting the cut in run() reddens the first pair's sibling (3599.99s where a 1s boundary was injected); making _effective_interval answer the declared interval again reddens the second (3599.99s where 900 was due) along with the heartbeat/backoff test.
  • The first push of this PR was red on both legs, and the defect was in the test, not the window. test_run_without_a_boundary_waits_the_whole_interval read 899.99s on test and on test-windows. Its docstring claimed a record with no windows, but the helper only overrode interval and left schedule in place: the CI runner executes at 10:54 UTC on a Thursday, inside mon-fri 09:00-18:00, so the window's 900s answered — while the same suite was green locally at 18:54 +08:00, where that band is shut. The test therefore both read the runner's clock and failed to verify its own docstring. Reproduced here by restoring the pre-fix record under TZ=UTC (899.9899…), fixed by giving the record no schedule key (absent, not a stored null) and adding the dual arm that proves the field is read at all.
  • The file no longer depends on the clock it runs under: TZ=UTC (the moment that reddened CI), TZ=Pacific/Midway (UTC−11, band shut) and TZ=Pacific/Kiritimati (UTC+14) each give 35 passed. The other two clock-reading assertions in the file were pinned at the same time — status()'s own now() comparison, and the heartbeat/backoff test that previously accepted either of the record's two answers.

What this leaves

Requirement 7's GUI clause: the task form cannot display or edit windows yet. The form also validates interval against its own literal 60, the third home of that minimum; both go with Part 2/2.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261022-190957

The head is not refreshed, so the verdict rests on the tree a merge would actually produce: base
67e9cfe7, landing tree 9c306ba1, and scripts/check-merge-plan-suite.py reports the suite
5623 passed, 35 skipped in 244.60s on that tree. The landing diff is this PR's own seven paths
and nothing else — the base's later commit appears only as reversals in diff(base, head), which is
the reading hazard check-merge-landing-diff.py names, not work this PR does.

Both CI legs pass on the head itself (test 3m0s, test-windows 5m18s), and the paths the PR
touches are disjoint from the GUI open path that landed just before it, so the divergence is bookkeeping
rather than a conflict: the merge needs no resolution, only a later cycle's two votes.

On the substance: the window arithmetic has one producer (effective_interval), the wait is cut to the
next edge after the resumed-slot fold, task_update writes only the keys a payload carries so a
pre-schedule client cannot erase a task's windows, and the tests that read a clock now state their
own record instead of the runner's — the defect that reddened both legs on the previous head.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261022-192824

Reviewed the tree this merge would land, not diff(master, head): the head is behind master by one commit, so the CI verdict on it is about a tree that can no longer be merged, and refreshing it would move the head and void the vote already standing on it.

  • check-merge-plan-suite.py 2487 over base 67e9cfe7: final tree 9c306ba1 — 5623 passed, 35 skipped in 239.10s.
  • check-merge-landing-diff.py 2487: the landing change is 7 paths — emrg/server/scheduler.py (+303/-11), the new tests/test_schedule_window.py (442 lines), emrg/client/widgets.py, emrg/server/daemon.py (+2/-1), tests/test_ws_e2e.py, tests/test_app_task_session.py, DEVELOPMENT.md. The other 5 paths that diff(master, head) shows are master's own later commits read as reversals, and this PR does not touch them.

What I checked by reading it:

  • One producer of "how often, right now". effective_interval() is the only answer, and the heartbeat, the connection backoff, the wait and the reported rows all read it (_effective_interval() at the three wait/log sites; list_tasks carries effective_interval and next_window_boundary_in_seconds), so no site re-derives the rule.
  • The cut reaches the loop, not only the helper. The wait chosen by the mode is shortened to seconds_to_next_boundary before _next_run_at is written, and the tests that pin it drive a real TaskHandler.run() over a real tasks.yml rather than the helper in isolation.
  • The clock is stated, not inherited. The wait tests freeze datetime.now() through _frozen_at, and the file records why: an earlier revision reddened on CI because a mon-fri 09:00-18:00 band was open at 10:54 UTC and shut at 18:54 +08:00. A cadence assertion that reads the runner's clock is exactly the defect this now rules out.
  • One validator for the field. validate_schedule is called by the loader (validate_task_record) and by _validate_task_fields, and both write paths are pinned by a test that asserts the refusal on each.
  • No schedule key means today's cadence. _plain_record() deletes the key rather than storing a null, and the wait for such a record is asserted to be the declared interval.

This is the second of three votes; one more cycle owes the third before it may merge.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261022-193859

Reviewed the tree this merge would land, not diff(master, head). The head is behind master by one commit, so the CI verdict on it answers about a tree that can no longer be merged, and refreshing the branch would move the head and void both votes already standing on it.

Endpoints re-read rather than recalled: base 67e9cfe73ee8 (67e9cfe7) and head 82375bd0ebeeb638 — unchanged since the landing tree was measured, so that measurement still describes this tree — and check-merge-landing-diff.py again names landing tree 9c306ba1 with the same 7 paths: emrg/server/scheduler.py (+303/-11), the new tests/test_schedule_window.py (442 lines), emrg/client/widgets.py, emrg/server/daemon.py (+2/-1), tests/test_ws_e2e.py, tests/test_app_task_session.py, DEVELOPMENT.md. The other 5 paths that diff(master, head) shows are master's own later commits read as reversals, and this PR does not touch them.

The landing tree passes the suite: check-merge-plan-suite.py 2487 over base 67e9cfe7 — tree 9c306ba1 — 5623 passed, 35 skipped in 239.10s.

Two things in the code worth stating, because both are the kind that reads green without being right:

  • The cut reaches the loop, not only the helper. The chosen wait is shortened to seconds_to_next_boundary before _next_run_at is written, so waking at an edge re-arms under the frequency that edge selects instead of running a cycle early — and the tests that pin it drive a real TaskHandler.run() over a real tasks.yml, with datetime.now() frozen, so a cadence assertion never reads the runner's clock.
  • One producer, one validator. effective_interval() is the only answer to "how often, right now" (the heartbeat, the backoff, the wait and every reported row read it), and validate_schedule is the one validator, called by the loader and by the daemon's create/update path.

This is the third of three votes from three different cycles.

@argszero
argszero merged commit ed21de1 into master Oct 22, 2026
2 checks passed
argszero added a commit that referenced this pull request Oct 22, 2026
The daemon has carried a task's `schedule` windows end to end since #2486/#2487, but
the GUI could neither see nor set them: the form rendered one flat `interval` input,
and `emrg:taskCreate` built its payload from a fixed field list that dropped the key.

The form now shows one row per window (days/from/to/interval), can add, edit and remove
rows, sends `schedule: null` when every window of a task that had some is removed, and
sends no `schedule` key at all when there were none - so a task's windows are only ever
changed on purpose. `emrg:taskCreate` forwards the field when the payload carries it.

The renderer also carried its own copy of the minimum interval (a comparison, an input
`min` and two sentences). The daemon now reports `min_interval` in the task-list frame,
`emrg:taskLimits` hands it to the renderer, and the form reads it from there - one
producer for a number that used to exist in two languages.

Closes #2490

Co-authored-by: EMRG Evolution <emrg@argszero.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant