Adopt Home Assistant's time zone for the API household clock (task 6351b36d) #275

Merged
approver_agent merged 3 commits from task/time-zone-errors_6351b36d-0eef-4e7d-bf5a-96b754cbc883 into dev 2026-09-28 23:04:17 -04:00
Owner

Maestro task 6351b36d-0eef-4e7d-bf5a-96b754cbc883 — "Time zone errors"

Done condition: "Tasks will not show overdue and all items will be based on the local time zone. Currently, it seems all times are based on utc."

Root cause

Kids' panel cards → Home Assistant entities → coordinator → GET /api/v1/panel/snapshot. The API's _household_clock() returns settings.household_now(_local_now()). When the household timezone setting is empty (as it is in the deployed household), household_now hands back the host-local instant unchanged, and the deployed host runs UTC. snapshot.py then evaluates now.time() > due_moment against UTC, so a 10:00 quest shows overdue from 06:00 Eastern, and today_iso rolls over at UTC midnight. The only thing that ever filled in the zone automatically was the admin web app's browser auto-set, which runs only when an admin opens it.

Fix

  • The integration now sends hass.config.time_zone in an X-NestQuest-Timezone header on every panel request (api_client.py, coordinator.py).
  • The panel routes pass that header to the new core settings_store.adopt_reported_timezone(). While timezone_configured is false, the core saves the reported zone and leaves timezone_configured false, since the value is automatic rather than an admin choice. The check and the write happen under the settings lock.
  • Because the zone is saved, the admin plane also reads the household clock. Later changes to the zone in Home Assistant carry over too.
  • Scheduler re-planning (review finding TZ-001): the missed-sweep scheduler used to plan its next sweep once and then sleep on those settings. So if a zone was adopted after start-up, the old plan stayed in place and a UTC host swept at UTC midnight (20:00 in New York). Two changes fix this:
    • After adopting a zone, the panel route calls the new MissedSweepScheduler.settings_changed(settings). If the stored zone or rollover time differs from the scheduler's current plan, this wakes it so it plans again. It is an event, not a poll.
    • Before sweeping, the loop re-reads the settings. If they no longer match its plan, it plans again instead of sweeping.
  • Adoption serialised with the scheduler (review finding TZ-003, cf118cf): a new MissedSweepScheduler.settings_lock is held by the panel adoption across its write and its settings_changed call. The loop holds the same lock across (a) its plan read and (b) its pre-sweep re-read plus the sweep. So an adoption commits before a plan read (which sees it), after one (the wake compares against that exact plan), or after the sweep. It never lands between a read and what the loop does with it. The lock is never held across the wait, and the loop holds nothing else while acquiring it, so a panel request cannot deadlock; at most it waits for one settings read or one sweep. The guarantee is documented in the module docstring.
  • Wait cleanup (review finding TZ-004, cf118cf): _sleep_unless_woken now cancels and awaits both child tasks, and re-raises a failed sleep inside the loop's retry block. A failing sleep backs off and re-plans instead of sweeping, and nothing outlives stop().
  • An explicit admin choice, including an explicitly chosen empty zone, still wins.
  • An invalid reported zone is logged and ignored.
  • No schema change, no migration, and the one-clock-read-at-the-edge rule is unchanged.

Tests

  • TZ=UTC uv run pytest -q → 2168 passed, 1 skipped (head cf118cf)
  • Tests from the original change fail on base 07f2eec and pass here:
    • zone left unset, host clock on UTC:
      • 09:59:59 EDT is not overdue (checked through both the panel and the admin snapshots)
      • 23:30 EDT still reports today's today_iso
      • a 09:30 tap counts as on time
      • an automatically adopted zone follows a later change in Home Assistant
    • the header is sent, and both client factories pass hass.config.time_zone
  • Tests added for the review findings (each checked in a temporary worktree):
    • TZ-001 (both fail on 09d6a55, pass here):
      • the scheduler starts with no zone and plans for UTC midnight. A panel request then adopts New York while the scheduler sleeps, and it plans again for New York midnight: delays of 11 hours, then 15 hours.
      • the zone is saved without a wake-up. When the stale rollover arrives, the scheduler runs no sweep and plans again for New York midnight.
    • TZ-002 (fail on base 07f2eec, pass here):
      • the admin-precedence tests ("America/Los_Angeles" and "") first prove automatic adoption, then apply the admin choice and a conflicting reported zone.
      • the invalid-header test first adopts New York, then sends an invalid header. The adopted zone stays in place and 09:59:59 EDT is still not overdue.
      • new client test test_stream_events_reports_household_timezone: the server-sent events request sends both the Authorization and the X-NestQuest-Timezone headers.
    • TZ-003 / TZ-004 (all four fail on bcf387d, pass here; the new tests were run 15 times with no flakes):
      • test_scheduler_follows_successive_zone_changes: New York → UTC → New York, with a real panel adoption issued while the re-plan read is in flight. The scheduler ends up sleeping on New York midnight (delays 15h, 11h, 15h). On bcf387d it stays asleep on UTC.
      • test_adoption_after_presweep_check_cannot_precede_the_sweep: a panel adoption is issued right after the pre-sweep read. The sweep only ever runs with the zone it planned for still stored, then the scheduler re-plans for New York. On bcf387d the UTC sweep ran after New York had been stored.
      • test_failed_sleep_is_retried_not_swept: a sleep that raises backs off 60s and re-plans; nothing is swept. On bcf387d it swept.
      • test_stop_waits_for_the_sleep_to_finish_cancelling: after stop() the sleep task is done and its cancellation cleanup has run. On bcf387d it was still pending.

Deploy note

Both the API and the Home Assistant integration need upgrading. The fix takes effect on the integration's first panel request after the upgrade.

🤖 Generated with Claude Code

Maestro task `6351b36d-0eef-4e7d-bf5a-96b754cbc883` — "Time zone errors" **Done condition:** "Tasks will not show overdue and all items will be based on the local time zone. Currently, it seems all times are based on utc." ## Root cause Kids' panel cards → Home Assistant entities → coordinator → `GET /api/v1/panel/snapshot`. The API's `_household_clock()` returns `settings.household_now(_local_now())`. When the household `timezone` setting is empty (as it is in the deployed household), `household_now` hands back the host-local instant unchanged, and the deployed host runs UTC. `snapshot.py` then evaluates `now.time() > due_moment` against UTC, so a 10:00 quest shows overdue from 06:00 Eastern, and `today_iso` rolls over at UTC midnight. The only thing that ever filled in the zone automatically was the admin web app's browser auto-set, which runs only when an admin opens it. ## Fix - The integration now sends `hass.config.time_zone` in an `X-NestQuest-Timezone` header on every panel request (`api_client.py`, `coordinator.py`). - The panel routes pass that header to the new core `settings_store.adopt_reported_timezone()`. While `timezone_configured` is false, the core saves the reported zone and leaves `timezone_configured` false, since the value is automatic rather than an admin choice. The check and the write happen under the settings lock. - Because the zone is saved, the admin plane also reads the household clock. Later changes to the zone in Home Assistant carry over too. - **Scheduler re-planning (review finding TZ-001):** the missed-sweep scheduler used to plan its next sweep once and then sleep on those settings. So if a zone was adopted after start-up, the old plan stayed in place and a UTC host swept at UTC midnight (20:00 in New York). Two changes fix this: - After adopting a zone, the panel route calls the new `MissedSweepScheduler.settings_changed(settings)`. If the stored zone or rollover time differs from the scheduler's current plan, this wakes it so it plans again. It is an event, not a poll. - Before sweeping, the loop re-reads the settings. If they no longer match its plan, it plans again instead of sweeping. - **Adoption serialised with the scheduler (review finding TZ-003, `cf118cf`):** a new `MissedSweepScheduler.settings_lock` is held by the panel adoption across its write and its `settings_changed` call. The loop holds the same lock across (a) its plan read and (b) its pre-sweep re-read plus the sweep. So an adoption commits before a plan read (which sees it), after one (the wake compares against that exact plan), or after the sweep. It never lands between a read and what the loop does with it. The lock is never held across the wait, and the loop holds nothing else while acquiring it, so a panel request cannot deadlock; at most it waits for one settings read or one sweep. The guarantee is documented in the module docstring. - **Wait cleanup (review finding TZ-004, `cf118cf`):** `_sleep_unless_woken` now cancels *and awaits* both child tasks, and re-raises a failed sleep inside the loop's retry block. A failing sleep backs off and re-plans instead of sweeping, and nothing outlives `stop()`. - An explicit admin choice, including an explicitly chosen empty zone, still wins. - An invalid reported zone is logged and ignored. - No schema change, no migration, and the one-clock-read-at-the-edge rule is unchanged. ## Tests - `TZ=UTC uv run pytest -q` → **2168 passed, 1 skipped** (head `cf118cf`) - Tests from the original change fail on base `07f2eec` and pass here: - zone left unset, host clock on UTC: - 09:59:59 EDT is not overdue (checked through both the panel and the admin snapshots) - 23:30 EDT still reports today's `today_iso` - a 09:30 tap counts as on time - an automatically adopted zone follows a later change in Home Assistant - the header is sent, and both client factories pass `hass.config.time_zone` - Tests added for the review findings (each checked in a temporary worktree): - TZ-001 (both fail on `09d6a55`, pass here): - the scheduler starts with no zone and plans for UTC midnight. A panel request then adopts New York while the scheduler sleeps, and it plans again for New York midnight: delays of 11 hours, then 15 hours. - the zone is saved without a wake-up. When the stale rollover arrives, the scheduler runs no sweep and plans again for New York midnight. - TZ-002 (fail on base `07f2eec`, pass here): - the admin-precedence tests (`"America/Los_Angeles"` and `""`) first prove automatic adoption, then apply the admin choice and a conflicting reported zone. - the invalid-header test first adopts New York, then sends an invalid header. The adopted zone stays in place and 09:59:59 EDT is still not overdue. - new client test `test_stream_events_reports_household_timezone`: the server-sent events request sends both the Authorization and the `X-NestQuest-Timezone` headers. - TZ-003 / TZ-004 (all four fail on `bcf387d`, pass here; the new tests were run 15 times with no flakes): - `test_scheduler_follows_successive_zone_changes`: New York → UTC → New York, with a real panel adoption issued while the re-plan read is in flight. The scheduler ends up sleeping on New York midnight (delays 15h, 11h, 15h). On `bcf387d` it stays asleep on UTC. - `test_adoption_after_presweep_check_cannot_precede_the_sweep`: a panel adoption is issued right after the pre-sweep read. The sweep only ever runs with the zone it planned for still stored, then the scheduler re-plans for New York. On `bcf387d` the UTC sweep ran after New York had been stored. - `test_failed_sleep_is_retried_not_swept`: a sleep that raises backs off 60s and re-plans; nothing is swept. On `bcf387d` it swept. - `test_stop_waits_for_the_sleep_to_finish_cancelling`: after `stop()` the sleep task is done and its cancellation cleanup has run. On `bcf387d` it was still pending. ## Deploy note Both the API and the Home Assistant integration need upgrading. The fix takes effect on the integration's first panel request after the upgrade. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
overseer added 1 commit 2026-09-28 22:08:46 -04:00
The deployed API host runs UTC and the household `timezone` setting was
never set, so the panel snapshot read UTC: morning quests showed overdue
hours early and `today_iso` rolled over at UTC midnight.

The integration now reports `hass.config.time_zone` on every panel
request (`X-NestQuest-Timezone`).  While the admin has not chosen a zone
(`timezone_configured` is false) the API adopts and persists it, so the
panel, the admin plane, and the missed-sweep scheduler all read the
household clock out of the box.  An explicit admin choice — including an
explicitly chosen empty zone — still wins; an invalid reported zone is
logged and ignored.  No schema change.

Task: 6351b36d-0eef-4e7d-bf5a-96b754cbc883

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
overseer added 1 commit 2026-09-28 22:21:55 -04:00
The scheduler loaded settings once per cycle and slept on them until the
old rollover, so a zone adopted from Home Assistant after start-up left a
UTC host sweeping at UTC midnight (20:00 in New York), marking quests
missed hours early.

`MissedSweepScheduler.settings_changed` now wakes the sleeping loop when
the stored zone or rollover time differs from the one it planned with;
the panel's household clock calls it after adopting a reported zone.
Before sweeping, the loop re-reads the settings and re-plans instead if
they no longer match its plan.

Tests: the scheduler re-plans for New York midnight when a panel request
adopts the zone mid-sleep, and skips a stale sweep when the zone changed
without a wake-up.  The admin-precedence and invalid-header tests now
prove adoption first, and the SSE client test asserts the timezone header
rides beside the Authorization header.

Task: 6351b36d-0eef-4e7d-bf5a-96b754cbc883

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
overseer added 1 commit 2026-09-28 22:43:42 -04:00
The wake flag compared an adoption only against the plan the loop last
recorded, so New York -> UTC -> New York during the re-plan read left
the scheduler sleeping on UTC midnight; and an adoption committing
between the pre-sweep check and the sweep still let the stale sweep run.

`MissedSweepScheduler.settings_lock` now serialises the panel's adoption
(write plus `settings_changed`) with the loop's plan read and with its
pre-sweep re-read plus sweep.  It is never held across the wait, and the
loop holds nothing else while acquiring it, so a panel request is at
most delayed by one settings read or one sweep.

`_sleep_unless_woken` now cancels and awaits both child tasks, and
re-raises a failed sleep inside the loop's retry block, so the backoff
runs instead of a sweep and nothing outlives `stop()`.

Tests (all four fail on bcf387d): the successive-zone race, an adoption
issued during the pre-sweep check, a failing sleep, and stop() waiting
for the sleep's cancellation cleanup.

Task: 6351b36d-0eef-4e7d-bf5a-96b754cbc883

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
review_agent left a comment
Member

Independent Codex gpt-5.6-terra review completed for this exact commit.

Independent Codex gpt-5.6-terra review completed for this exact commit.
approver_agent merged commit 40d0e5d8f6 into dev 2026-09-28 23:04:17 -04:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Maniacs_Mansion/NestQuest#275