Repository navigation
emrg: a scheduled task's frequency can vary by the time of day - #2487
Conversation
argszero
left a comment
There was a problem hiding this comment.
✅ 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
left a comment
There was a problem hiding this comment.
✅ 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 2487over base67e9cfe7: final tree9c306ba1— 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 newtests/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 thatdiff(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_taskscarrieseffective_intervalandnext_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_boundarybefore_next_run_atis written, and the tests that pin it drive a realTaskHandler.run()over a realtasks.ymlrather 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 amon-fri 09:00-18:00band 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_scheduleis 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
schedulekey 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
left a comment
There was a problem hiding this comment.
✅ 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_boundarybefore_next_run_atis 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 realTaskHandler.run()over a realtasks.yml, withdatetime.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), andvalidate_scheduleis 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.
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>
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 withdays(an optional weekday set),from/to(HH:MM,24:00allowed) andinterval. Inside a window that window's interval answers; outside every window and between windows the record's ownintervaldoes — 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: 10800and 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 inDEVELOPMENT.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 passesschedulethrough untouched, andtask_updatewrites 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_INTERVALalready had one constant and two readers; no third home was added.Reported, not re-derived.
status()and_idle_status()both carryeffective_interval,scheduleandnext_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 tointervalfor 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"anduv run python -m emrg --helpboth 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-monwrapping, disjoint days not overlapping), both write paths and the reported rows.run()loop, over a realtasks.yml, differing only in what they state: a record with noschedulekey at all waits itsinterval(~3600s), and a record carrying themon-fri 09:00-18:00 / 900band — with the clock frozen inside it — waits the window's ~900s. Deleting the cut inrun()reddens the first pair's sibling (3599.99s where a 1s boundary was injected); making_effective_intervalanswer the declared interval again reddens the second (3599.99s where 900 was due) along with the heartbeat/backoff test.test_run_without_a_boundary_waits_the_whole_intervalread 899.99s ontestand ontest-windows. Its docstring claimed a record with no windows, but the helper only overrodeintervaland leftschedulein place: the CI runner executes at 10:54 UTC on a Thursday, insidemon-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 underTZ=UTC(899.9899…), fixed by giving the record noschedulekey (absent, not a stored null) and adding the dual arm that proves the field is read at all.TZ=UTC(the moment that reddened CI),TZ=Pacific/Midway(UTC−11, band shut) andTZ=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 ownnow()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
intervalagainst its own literal60, the third home of that minimum; both go with Part 2/2.