diff options
| author | Adam Malczewski <[email protected]> | 2026-06-01 09:54:30 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-01 09:54:30 +0900 |
| commit | ee8af7448a72fbd49710d8df24ecda4c23a58a8d (patch) | |
| tree | 5a3ee934b43c1314bb4827efe52771c557cf07c8 | |
| parent | 1c800eb35b48b28a82a64e8b2b3ce666323f2c42 (diff) | |
| download | dispatch-ee8af7448a72fbd49710d8df24ecda4c23a58a8d.tar.gz dispatch-ee8af7448a72fbd49710d8df24ecda4c23a58a8d.zip | |
docs: update HANDOFF for 4-slot probing + DB migration
| -rw-r--r-- | HANDOFF.md | 364 |
1 files changed, 204 insertions, 160 deletions
@@ -2,7 +2,7 @@ **Branch:** `r1/claude-reset-fix` (off `dev`) **Worktree:** `/home/tradam/projects/dispatch/r1-claude-reset-fix` -**Commits:** 3 atomic commits (see `git log r1/claude-reset-fix ^dev --oneline`). +**Commits:** 4 atomic commits (see `git log r1/claude-reset-fix ^dev --oneline`). --- @@ -10,66 +10,67 @@ The "Claude Wake Schedule" panel (`ClaudeReset.svelte` + `/models/wake-schedule*` routes + the in-memory backend scheduler) had several real bugs that would -silently lose wakes, drift over time, or behave erratically in the UI. This -branch fixes them with three logically separate commits. +silently lose wakes, drift over time, or behave erratically in the UI. It +also only probed once per marked hour, which is unreliable at rate-window +edges. + +This branch fixes the bugs *and* upgrades probing to 4× per hour +(`:00 / :15 / :30 / :45`), with same-tick coalescing so the upstream still +sees a single call. Schema changed destructively (per direction); migration +code drops the old `wake_schedule` table. ### What was broken #### Backend (`packages/api/src/routes/models.ts`) -1. **Missed wakes silently lost.** On boot, `loadScheduleFromDB` saw any - past `next_wake_at` and just rewrote it to the *next* occurrence using - server-local TZ — without firing the missed wake. So if the API was down - when a wake was due (e.g. overnight container restart), the user lost - that fire entirely. This directly contradicts the panel's whole purpose - (overnight task that needs a Claude session to refresh at HH:15). -2. **Server-TZ drift.** `nextOccurrenceAt15(hour)` used `new Date().setHours()`, - which is *server* local time. The client sends absolute Unix ms (user's - local wall-clock intent). On a UTC Docker host running for a PST user, - each reschedule re-anchored to the wrong TZ and slowly migrated the - fire time off the user's intended hour. +1. **Missed wakes silently lost.** `loadScheduleFromDB` saw any past + `next_wake_at`, rewrote it to the next occurrence using server-local TZ, + and never fired the missed wake. So if the API was down when a wake was + due (overnight container restart), the user lost it entirely. +2. **Server-TZ drift.** `nextOccurrenceAt15(hour)` used `new Date().setHours()` + — *server* local time. The client sends absolute Unix ms (user's local + wall-clock intent). On a UTC Docker host running for a PST user, each + reschedule re-anchored to the wrong TZ and slowly migrated the fire time. 3. **Retry storm.** Every failed wake pushed a new entry into a `pendingRetries[]` array, all converging at the same `+5min` instant. - Multiple consecutive failures could spawn N retries that all hit - simultaneously. -4. **Retry/fire race.** Within a single tick, a freshly fired wake AND a - due retry could both hit `wakeAllClaudeAccounts()` back-to-back. -5. **No status surface.** Nothing told the user whether a scheduled wake +4. **Retry/fire race within a tick.** A freshly fired wake AND a due retry + could both hit `wakeAllClaudeAccounts()` back-to-back. +5. **No status surface.** Nothing told the user whether scheduled wakes actually succeeded. +6. **Only one probe per hour.** A single fire at `:15` can land 14 min off + the actual rate-window reset moment. #### Frontend (`packages/frontend/src/lib/components/ClaudeReset.svelte`) -6. **`fadedHours` returned a function, not a Set.** The `$derived` had - shape `(): Set<number> => {...}` — `blockClass` then called - `fadedHours()` once per of the 24 buttons, rebuilding the Set 24× per - render and defeating Svelte's memoization entirely. -7. **`currentHour` was frozen.** `const currentHour = $derived(new Date().getHours())` - — `new Date()` is not a reactive read, so the value never updated. After - midnight or any hour boundary the "now" highlight stayed on the wrong - block until the page was reloaded. -8. **Out-of-order toggles.** Rapid double-clicks fired multiple requests; +7. **`fadedHours` returned a function, not a Set.** The `$derived` had shape + `(): Set<number> => {...}` — `blockClass` then called `fadedHours()` + once per of the 24 buttons, rebuilding the Set 24× per render. +8. **`currentHour` was frozen.** `const currentHour = $derived(new Date().getHours())` + — `new Date()` is not a reactive read; the value never updated. After + midnight (or any hour boundary) the "now" highlight stayed on the wrong + block until reload. +9. **Out-of-order toggles.** Rapid double-clicks fired multiple requests; the *last response* won, not the *last click* — so a slow add followed - by a fast remove could land in the wrong order, leaving the user with - an unexpected scheduled hour. -9. **No success/failure feedback.** No surface for whether the most-recent - wake actually worked. + by a fast remove could land in the wrong order. +10. **No success/failure feedback.** No surface for whether the most-recent + wake actually worked. ### What I changed -| # | Bug | Fix | +| # | Bug / Feature | Fix | |---|---|---| -| 1 | Missed wake silently lost | New `recoverScheduleEntry()` helper: if missed by ≤ 2h fire on next tick; either way roll forward by 24h-multiples. | -| 2 | Server-TZ drift | Removed server-local `nextOccurrenceAt15`; rescheduling now uses `nextDailyAfter(previous, now)` — adds 24h * N from the *client-supplied* original ms. | -| 3 | Retry storm | Replaced `pendingRetries: PendingRetry[]` with a single shared `pendingRetry: PendingRetry \| null` whose budget resets on subsequent failures. | +| 1 | Missed wake silently lost | New `recoverScheduleEntry()` helper: if missed by ≤ 2h fire on next tick; either way roll forward by 24h-multiple steps. | +| 2 | Server-TZ drift | Removed server-local `nextOccurrenceAt15`; rescheduling now uses `nextDailyAfter(previous, now)` — adds 24h × N from the *client-supplied* original ms. | +| 3 | Retry storm | Replaced `pendingRetries: []` with a single shared `pendingRetry: PendingRetry \| null` whose budget resets on subsequent failures. | | 4 | Retry/fire race | Retry processing skipped on any tick where a fresh wake fired. | -| 5 | No status surface | `GET /wake-schedule` now returns `{ schedule, resetOffsetHours, lastWake, pendingRetry }`. | -| 6 | `fadedHours` was a fn | Now `$derived.by(() => Set)`; passed as a value to `blockClass`. Window length is `resetOffsetHours - 1` (no longer hardcoded 4). | -| 7 | Frozen `currentHour` | Backed by `nowMs = $state(Date.now())`, bumped every 30s via `setInterval`, cleaned up in `onDestroy`. | -| 8 | Out-of-order toggles | Per-hour sequence counter (`inFlightSeq`) + `pendingHours: Set<number>` that disables in-flight buttons; stale responses dropped. | -| 9 | No feedback | New status row: "✓ Last wake N min ago" or "✗ Last wake N min ago — <error>"; pending retry row shows retries-left + next-attempt countdown. | +| 5 | No status surface | `GET /wake-schedule` now returns `{ schedule, resetOffsetHours, probeSlotMinutes, lastWake, pendingRetry }`. | +| 6 | One probe/hour | A marked hour expands to 4 slots (`:00 :15 :30 :45`), each its own row. Multiple due slots in the same tick coalesce into one upstream wake. | +| 7 | `fadedHours` was a fn | Now `$derived.by(() => Set)`; passed as a value to `blockClass`. Window length is `resetOffsetHours - 1` (no longer hardcoded 4). | +| 8 | Frozen `currentHour` | Backed by `nowMs = $state(Date.now())`, bumped every 30s via `setInterval`, cleaned up in `onDestroy`. | +| 9 | Out-of-order toggles | Per-hour sequence counter (`inFlightSeq`) + `pendingHours: Set<number>` that disables in-flight buttons; stale responses dropped. | +| 10 | No feedback | New status row: "✓ Last wake N min ago" or "✗ Last wake N min ago — <error>"; pending retry row shows retries-left + next-attempt countdown. | -Also: extracted `CLAUDE_RESET_OFFSET_HOURS = 5` into one place (was -hardcoded in 3 places: frontend `resetHour`, frontend `fadedHours` loop -length, conceptual semantics). Frontend now learns the value from the -server snapshot. +Also extracted `CLAUDE_RESET_OFFSET_HOURS = 5` and `PROBE_SLOT_MINUTES = [0,15,30,45]` +to a single source of truth in `packages/api/src/wake-scheduler.ts`; the +frontend learns both from the server snapshot. --- @@ -77,36 +78,62 @@ server snapshot. - **New:** `packages/api/src/wake-scheduler.ts` — pure helpers (`nextDailyAfter`, `recoverScheduleEntry`, `resetHourFor`, - `CLAUDE_RESET_OFFSET_HOURS`, `MISSED_WAKE_GRACE_MS`, `DAILY_INTERVAL_MS`). + `isProbeSlotMinute`, `CLAUDE_RESET_OFFSET_HOURS`, + `MISSED_WAKE_GRACE_MS`, `DAILY_INTERVAL_MS`, `PROBE_SLOT_MINUTES`, + `ProbeSlotMinute`). - **New:** `packages/api/tests/wake-scheduler.test.ts` — 12 unit tests for - the pure helpers (grace boundaries, multi-day skip, custom grace - windows, midnight wraparound). -- **Modified:** `packages/api/src/routes/models.ts` — rewrote the - `// ─── Wake scheduler` section (~165 LoC). All public route paths + the pure helpers (grace boundaries, multi-day skip, custom grace, + midnight wraparound). +- **Modified:** `packages/api/src/routes/models.ts` — full rewrite of the + wake-scheduler section (~280 LoC). Routes preserved (`POST /models/wake`, `POST /models/wake-schedule/toggle`, - `GET /models/wake-schedule`) preserved. -- **Modified:** `packages/api/tests/routes.test.ts` — +9 HTTP tests for - the wake-schedule routes. + `GET /models/wake-schedule`) but request/response payloads expanded. +- **Modified:** `packages/api/tests/routes.test.ts` — +12 HTTP tests for + the wake-schedule routes (was +9 in the prior commit; rewritten for + the 4-slot payload). +- **Modified:** `packages/core/src/db/index.ts` — `wake_schedule` schema + changed; destructive migration drops the old table if the + `slot_minute` column is missing. Nothing else touched. - **Modified:** `packages/frontend/src/lib/components/ClaudeReset.svelte` - — full rewrite of the script section; markup mostly unchanged (added - `disabled` to buttons + a status footer). + — full rewrite of the script section; markup updated for the marked-hour + summary + the status footer. --- ## Public surface changes -### API: `GET /models/wake-schedule` - -**Before:** -```json -{ "schedule": { "9": 1700000000000 } } +### Database schema + +```sql +-- BEFORE +CREATE TABLE wake_schedule ( + hour INTEGER PRIMARY KEY CHECK (hour BETWEEN 0 AND 23), + next_wake_at INTEGER NOT NULL +) + +-- AFTER +CREATE TABLE wake_schedule ( + hour INTEGER NOT NULL CHECK (hour BETWEEN 0 AND 23), + slot_minute INTEGER NOT NULL CHECK (slot_minute IN (0, 15, 30, 45)), + next_wake_at INTEGER NOT NULL, + PRIMARY KEY (hour, slot_minute) +) ``` -**After (additive — `schedule` key unchanged):** +Migration on boot: if `PRAGMA table_info(wake_schedule)` lacks a +`slot_minute` column, `DROP TABLE IF EXISTS wake_schedule` then `CREATE` +the new shape. **No other table is touched** (credentials, api_keys, +usage_cache, tabs, chunks, settings preserved). + +### API: `GET /models/wake-schedule` + ```json { - "schedule": { "9": 1700000000000 }, + "schedule": { + "9": { "0": 1700001500000, "15": 1700002400000, "30": 1700003300000, "45": 1700004200000 } + }, "resetOffsetHours": 5, + "probeSlotMinutes": [0, 15, 30, 45], "lastWake": { "firedAt": 1700000000000, "ok": true, @@ -115,67 +142,88 @@ server snapshot. "pendingRetry": { "retriesLeft": 5, "nextRetryAt": 1700000300000, - "reason": "scheduled wake at hour 9" + "reason": "scheduled probe(s) 9:15" } | null } ``` ### API: `POST /models/wake-schedule/toggle` -- Returns the same expanded snapshot shape as `GET`. -- Now rejects non-integer hours (`4.5` → 400). Range check (0–23) - unchanged. Past timestamps on add still rejected. -- Delete request shape unchanged (`{ hour }` with no `timestamp`). +**Add** (when hour is not yet marked): + +```json +{ + "hour": 9, + "timestamps": { "0": 1700001500000, "15": 1700002400000, "30": 1700003300000, "45": 1700004200000 } +} +``` + +All four slot keys are required; each value must be a future Unix ms. +Returns the same expanded snapshot as `GET`. + +**Remove** (when hour *is* marked): + +```json +{ "hour": 9 } +``` + +Same shape as before. Deletes all 4 slots for that hour atomically. + +Validation: hour must be an integer 0–23. Non-integer / out-of-range +hours → 400. Missing or non-object `timestamps` on add → 400. Missing, +non-finite, or past timestamp in any slot → 400. ### Component props -- `ClaudeReset.svelte` props unchanged: still `{ apiBase?: string }`. +`ClaudeReset.svelte` props unchanged: still `{ apiBase?: string }`. -### New exported types/helpers +### New exported helpers -- `packages/api/src/wake-scheduler.ts` exports `nextDailyAfter`, - `recoverScheduleEntry`, `resetHourFor`, `CLAUDE_RESET_OFFSET_HOURS`, - `DAILY_INTERVAL_MS`, `MISSED_WAKE_GRACE_MS`, `RecoveredEntry`. -- Not re-exported from `@dispatch/core` — they live in `@dispatch/api` - and are intentionally not part of the cross-package public surface. +`packages/api/src/wake-scheduler.ts` exports `nextDailyAfter`, +`recoverScheduleEntry`, `resetHourFor`, `isProbeSlotMinute`, +`CLAUDE_RESET_OFFSET_HOURS`, `DAILY_INTERVAL_MS`, +`MISSED_WAKE_GRACE_MS`, `PROBE_SLOT_MINUTES`, `ProbeSlotMinute`, +`RecoveredEntry`. Not re-exported from `@dispatch/core` — they live in +`@dispatch/api` and aren't intended cross-package surface. --- ## End-to-end traces -### Happy path: schedule wake at 9 AM +### Happy path: mark 9 AM 1. User clicks the "9" AM block. -2. Frontend computes `nextOccurrenceAt15(9)` in **user's local TZ** → - absolute ms. -3. `POST /models/wake-schedule/toggle` `{ hour: 9, timestamp }`. -4. Backend stores `wakeSchedule[9] = timestamp`, persists to SQLite, - restarts the tick loop. Returns full snapshot. -5. Frontend applies snapshot; button turns primary, +4 trailing blocks - fade. -6. Tick loop runs every 30s. When `Date.now() >= timestamp`, the entry's - `next_wake_at` is bumped via `nextDailyAfter` (preserves wall-clock - intent across 24h) and `wakeAllClaudeAccounts()` runs. -7. `lastWake` is updated; surfaced on next snapshot poll. +2. Frontend computes 4 timestamps in **user's local TZ** for the next + occurrence of `9:00`, `9:15`, `9:30`, `9:45`. +3. `POST /models/wake-schedule/toggle { hour: 9, timestamps: { "0":…, "15":…, "30":…, "45":… } }`. +4. Backend writes 4 rows to `wake_schedule`, returns snapshot. Button + turns primary, +4 trailing blocks fade. +5. Tick loop runs every 30s. At `9:00` the `0`-minute slot becomes due; + the tick advances its `next_wake_at` to tomorrow `9:00`, persists, + and fires *one* coalesced wake. Same dance at 9:15, 9:30, 9:45 — + each is a separate upstream call (different 15-min windows). +6. If two slots happen to come due in the *same* 30s tick (e.g. the + scheduler was paused), they coalesce into ONE upstream wake. ### Recovery: API was down when 9:15 fired -1. Server boots at 11:00. Reads `wake_schedule` row `{ hour: 9, next_wake_at: 9:15-today }`. -2. `recoverScheduleEntry(9:15, 11:00)` → `{ shouldFireNow: true, nextWakeAt: 9:15-tomorrow }` - (overdue by 1h45m, within 2h grace). -3. `loadScheduleFromDB` sets `wakeSchedule[9] = Date.now()`, persists. -4. First tick runs immediately, sees `ts <= now`, advances via - `nextDailyAfter`, fires the wake. `lastWake` shows ✓ on the panel. +1. Server boots at 11:00. Reads 4 rows for hour 9. The `9:00`, `9:15`, + `9:30`, `9:45` slots all have `next_wake_at` ≤ now, all overdue by + ≤ 2h → all `shouldFireNow: true`. +2. Each slot's `next_wake_at` is advanced to tomorrow's equivalent + wall-clock via `nextDailyAfter`. The boot-fire flag is set. +3. First tick runs immediately, sees `needsBootFire`, fires ONE coalesced + wake. `lastWake` shows ✓ on the panel. ### Recovery: API was down for two days -1. Server boots Wed at 14:00. Reads `next_wake_at = Mon 9:15`. -2. Overdue by > 2h → `shouldFireNow: false`. `nextWakeAt` jumps forward - by multiples of 24h to `Thu 9:15` (`nextDailyAfter` ceil-div, not loop). -3. Schedule is preserved; no spurious wake; entry resumes normally. +1. Server boots Wed at 14:00. Slots for Mon 9:00/15/30/45 are overdue by + > 48h → `shouldFireNow: false`. +2. Each `nextWakeAt` jumps forward by `nextDailyAfter` (ceil-div, single + step — not a 48-iteration loop) to Thu 9:00/15/30/45. +3. Schedule preserved; no spurious wake; entry resumes normally. ### Rapid double-click -1. User clicks "9" → request A (add, seq=1) in flight. -2. User clicks "9" again → request B (remove, seq=2) in flight; button - disabled in between (visual cursor: wait + opacity). -3. Response B arrives → applied (inFlightSeq[9] === 2 → match → apply). +1. User clicks "9" → request A (add, seq=1) in flight; button disabled. +2. User clicks "9" again → request B (remove, seq=2) in flight. +3. Response B arrives → `inFlightSeq[9] === 2` → applied. 4. Response A arrives later → `inFlightSeq[9] !== 1` → dropped. --- @@ -185,21 +233,21 @@ server snapshot. ### `bun run check` ``` $ biome check . -Checked 142 files in 148ms. No fixes applied. +Checked 142 files in 155ms. No fixes applied. ``` ### `bun run test` ``` Test Files 25 passed (25) - Tests 414 passed (414) - Start at 09:30:27 - Duration 2.83s + Tests 417 passed (417) + Start at 09:52:16 + Duration 2.80s ``` -(Was 393 tests before this branch; +21 = 12 helper unit tests + 9 HTTP -route tests for the wake schedule.) +(Was 393 tests at branch base; +24 net = 12 helper unit tests + 12 HTTP +route tests for the 4-slot wake schedule.) -### TypeScript strict checks (run individually — covered by Biome+svelte-check otherwise) +### TypeScript strict checks - `bun --bun tsc -p packages/api/tsconfig.json --noEmit` → exit 0 - `bun --bun tsc -p packages/core/tsconfig.json --noEmit` → exit 0 - `bun run --cwd packages/frontend typecheck` → svelte-check 0 errors, 0 warnings @@ -208,60 +256,56 @@ route tests for the wake schedule.) ## Assumptions / known gaps -These were not specified by the orchestrator; I made the call so the code -ships in a usable state. None should be controversial, but flag them if -the user wants different semantics. - -1. **TZ behavior:** the absolute `timestamp` from the toggle request is - the source of truth for *first* fire. On reschedule the entry advances - by exactly 24h * N from the previous `next_wake_at`. This preserves - the user's local wall-clock intent regardless of server TZ. **DST - transition days can drift the fire by ±1h**; it self-corrects the - next time the user toggles the hour. A more thorough fix would store - the hour + IANA TZ and recompute each cycle — punted because it - requires UI for TZ selection. +1. **TZ behavior:** absolute `timestamp` from the toggle request is the + source of truth for *first* fire. On reschedule the slot advances by + exactly 24h × N from its previous `next_wake_at`. Preserves the user's + local wall-clock intent regardless of server TZ. **DST transition days + can drift the fire by ±1h**; self-corrects when the user next toggles + the hour. A more thorough fix would store hour + IANA TZ and recompute + each cycle — punted; requires UI for TZ selection. 2. **Missed-wake grace = 2h.** Picked because Claude's typical session - window is ~5h — recovering within the first 40% of that window still - leaves the user real working time. Tunable in - `wake-scheduler.ts:MISSED_WAKE_GRACE_MS`. The 3rd argument of - `recoverScheduleEntry` accepts a custom value, exercised in tests. - -3. **"Reset" semantics:** I interpreted the "Reset at HH:00" label as a - *display hint* (wake + 5h ≈ when Claude's session window resets), not - a separate event. The scheduler only fires *wakes*; the "reset" label - helps the user reason about which 5h window each wake protects. - The `+5h` constant lives in `CLAUDE_RESET_OFFSET_HOURS` if it ever - needs changing. - -4. **Recurring vs one-shot:** the implementation is **recurring daily** - (matches the previous behavior). There is no UI for one-shot wakes. - -5. **`nowMs` ticker resolution = 30s** on the frontend. The - current-hour ring updates within at most 30s of the hour boundary, - which is plenty given hour granularity. Status "X min ago" labels + window is ~5h. Tunable in `wake-scheduler.ts:MISSED_WAKE_GRACE_MS`; + `recoverScheduleEntry` accepts a custom value (exercised in tests). + +3. **Same-tick coalescing.** Hitting `:00 :15 :30 :45` produces 4 wakes + per hour at steady state. Two slots due in the *same* 30s tick + coalesce into one upstream call — there's no value in 2 simultaneous + probes. The advancement-then-fire ordering means a slow upstream + call can't cause re-firing on the next tick. + +4. **"Reset" semantics:** I interpreted the "Reset by HH:00" label as a + display hint (wake + 5h ≈ when Claude's session window resets), not + a separate event. The scheduler only fires *wakes*. The `+5h` + constant lives in `CLAUDE_RESET_OFFSET_HOURS` if it ever needs + changing. + +5. **Recurring daily.** Matches prior behavior; no UI for one-shot + wakes. + +6. **`nowMs` ticker = 30s on the frontend.** Current-hour ring updates + within at most 30s of the hour boundary. Status "X min ago" labels refresh at the same cadence. -6. **Retry budget = 6 × 5min = 30min.** Unchanged from the original - implementation; just consolidated to a single shared slot. If both a - scheduled wake fails AND retries are exhausted, the next opportunity - is the next day's scheduled fire — no infinite retry. - -7. **Snapshot polling:** frontend only refreshes the snapshot on mount - and after toggles. The `lastWake` / `pendingRetry` rows are therefore - slightly stale between user actions. Adding a 30s poll would be a - one-liner if desired, but it'd also generate quiet background traffic - for a panel that's typically only opened intentionally — I left it - off. The displayed "N min ago" / "in N min" relative timestamps DO - refresh live (driven by the same `nowMs` ticker). - -8. **No backend test for `loadScheduleFromDB` recovery branch.** The - module-level scheduler state is initialized at import time, so - exercising the recovery branch from a Vitest module requires either - restructuring to inject the DB or spinning up a real SQLite. I - covered the pure logic via `recoverScheduleEntry` unit tests - instead, and verified the route surface end-to-end via HTTP tests. - If you want belt-and-braces coverage of the boot path, the right move - is to refactor `loadScheduleFromDB` to take a `db` parameter and - write a fixture-backed integration test — happy to do that in a - follow-up if asked. +7. **Retry budget = 6 × 5min = 30min.** Unchanged from before, just + consolidated to a single shared slot. + +8. **Snapshot polling:** frontend refreshes the snapshot on mount and + after toggles. `lastWake` / `pendingRetry` rows are therefore stale + between user actions; the displayed *relative* timestamps DO refresh + live (driven by the same `nowMs` ticker). Adding a 30s poll would be + a one-liner if desired — left off to avoid quiet background traffic + for a panel that's typically only opened intentionally. + +9. **Destructive migration.** Per direction: no back-compat. Any + existing rows in `wake_schedule` from before this branch are dropped + on first boot. Users will need to re-mark their hours. No other + tables are touched. + +10. **No backend test for `loadScheduleFromDB` recovery branch.** The + module-level scheduler state is initialized at import time; covering + the boot-path recovery from a Vitest module requires either DI for + the DB or spinning up real SQLite. I covered the pure logic via + `recoverScheduleEntry` unit tests and the route surface end-to-end + via HTTP tests. A follow-up could refactor `loadScheduleFromDB` to + take a `db` parameter and write a fixture-backed integration test. |
