summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--notes/claude-reset-review-2.md48
-rw-r--r--notes/claude-reset-review.md48
-rw-r--r--notes/ntfy-notifications-handoff.md (renamed from HANDOFF.md)0
-rw-r--r--notes/wake-schedule-handoff.md442
-rw-r--r--packages/api/src/routes/models.ts394
-rw-r--r--packages/api/src/wake-scheduler.ts97
-rw-r--r--packages/api/tests/routes.test.ts304
-rw-r--r--packages/api/tests/wake-scheduler.test.ts98
-rw-r--r--packages/core/src/db/index.ts23
-rw-r--r--packages/frontend/src/lib/components/ClaudeReset.svelte299
-rw-r--r--packages/frontend/src/lib/snapshot-sequencer.ts47
-rw-r--r--packages/frontend/tests/snapshot-sequencer.test.ts92
12 files changed, 1723 insertions, 169 deletions
diff --git a/notes/claude-reset-review-2.md b/notes/claude-reset-review-2.md
new file mode 100644
index 0000000..31e5789
--- /dev/null
+++ b/notes/claude-reset-review-2.md
@@ -0,0 +1,48 @@
+# Review: Dispatch — Claude Wake Schedule (r1/claude-reset-fix)
+
+## Executive Summary
+
+**HOLD.** While the backend restructuring for the 4-probe coalescing is elegant and the database migrations are sound, the attempt to fix the frontend snapshot race condition is fundamentally flawed. The new `SnapshotSequencer` utility drops server responses based on client-side sequencing, but fails to account for network reordering on the *upstream* path. Under rapid user interaction, this will permanently desync the UI from the server. This desync is severely compounded by a design flaw in the toggle endpoint itself, which ignores explicit client intent.
+
+## Findings
+
+### 1. `SnapshotSequencer` fails to prevent UI desync under concurrent POSTs (Critical)
+**Location:** `packages/frontend/src/lib/snapshot-sequencer.ts` and `packages/frontend/src/lib/components/ClaudeReset.svelte`
+**Issue:** The `SnapshotSequencer` assumes that a later client request will always contain a strictly fresher view of the server's global state. However, concurrent `fetch` calls can overtake each other on the network. If a user clicks 9 AM (Request A, seq 1) and immediately clicks 10 AM (Request B, seq 2):
+1. Network jitter causes Request B to be received and processed by the server *first*.
+2. The server toggles 10 AM and returns the global state `{10}`.
+3. The server then receives Request A, toggles 9 AM, and returns `{9, 10}`.
+4. Regardless of which response arrives at the client first, the sequencer dictates that seq 2 (the `{10}` snapshot) is the "winner" and seq 1 (the `{9, 10}` snapshot) is dropped.
+**Impact:** The server state is `{9, 10}` but the UI is permanently stuck at `{10}`.
+**Recommended Fix:** The simplest fix is to prevent concurrent mutations entirely. Replace the per-hour `pendingHours` lock with a global `isMutating` boolean that disables all toggle buttons while *any* POST request is in flight. The `SnapshotSequencer` can remain to protect against the `loadFromServer()` vs. user click race, but mutating requests must be sequentially ordered by the client.
+
+### 2. Toggle endpoint ignores client intent, compounding desync (High)
+**Location:** `packages/api/src/routes/models.ts` (~line 924)
+**Issue:** The `POST /wake-schedule/toggle` endpoint blindly determines whether to turn an hour ON or OFF based on its *own* local state (`if (wakeSchedule[hour] !== undefined) ...`). It does not validate the client's intent. If the UI suffers the desync described in Bug 1 (the UI thinks 9 AM is OFF, but the server knows it is ON), the user will naturally click 9 AM to turn it on. The client sends `{ hour: 9, timestamps: {...} }` (clearly intending to turn it ON). The server, seeing `wakeSchedule[9]` is already defined, ignores the timestamps and toggles the hour OFF.
+**Impact:** The user's clicks feel broken. They try to turn an hour on, but the server invisibly turns it off, keeping the UI looking broken.
+**Recommended Fix:** Make the toggle endpoint idempotent. Require the client to send an explicit intent (e.g., `{ action: 'on', timestamps }` or `{ action: 'off' }`). If the client says "on" but it's already on, simply update the timestamps (or no-op) and return the current snapshot without deleting the hour.
+
+### 3. Retry storm re-wakes successful accounts (Low)
+**Location:** `packages/api/src/routes/models.ts` (`wakeAllClaudeAccounts` and `processPendingRetry`)
+**Issue:** If a user has multiple Anthropic accounts and only *one* of them fails a wake probe (e.g., due to expired credentials), `recordWake` returns `false`. This triggers the scheduler's background retry loop for 30 minutes. Every 5 minutes, `processPendingRetry` calls `wakeAllClaudeAccounts()`, which will re-probe *all* configured accounts, including the ones that already succeeded.
+**Recommended Fix:** Likely acceptable as a known trade-off since the Anthropic probe payload is tiny (16 tokens) and cheap, but it's worth noting as a minor waste of resources. To fix, you would need to track per-account success state in the `pendingRetry` object.
+
+## Verification of prior-review fixes
+
+1. **Clock skew / latency rejects valid toggles (High):** **FIXED.** The server now correctly accepts finite past timestamps and relies on the tick loop to advance them safely.
+2. **Race condition in global snapshot (High):** **NOT FIXED.** The author added `SnapshotSequencer`, which handles out-of-order *responses* correctly, but fails catastrophically when the server processes the *requests* out-of-order (see Finding 1).
+3. **Missing transaction boundary (Medium):** **FIXED.** The `persistSchedule` function now wraps the DELETE and INSERT loop in a synchronous `db.transaction()`.
+4. **Redundant concurrency logic (Nit):** **FIXED.** The unreachable `inFlightSeq` was removed, leaving only `pendingHours`.
+5. **Masked boot recovery reason (Nit):** **FIXED.** The text `" (boot recovery)"` is successfully appended to the reason string.
+
+## Things the author got right
+
+* **Data-Loss Protection:** The `db.transaction()` wrapper safely prevents partial persistence wipes.
+* **Schema Boundary:** The SQLite destructive migration is perfectly executed. Using `PRAGMA table_info` to detect the old schema and dropping *only* the `wake_schedule` table correctly preserves user API keys, settings, and chunks.
+* **Math Purity:** Extracting `nextDailyAfter` and `recoverScheduleEntry` away from the Hono app allows them to be rigorously unit-tested, and those test cases cover the edge cases thoroughly.
+* **Tick Coalescing:** The transition from 1 probe to 4 probes was handled intelligently. Coalescing all due slots into a single API tick prevents wasteful bursts of HTTP requests on boot or when gracefully recovering missed schedules.
+
+## Open design questions / deferred items
+
+* **DST Transition Drift:** The scheduler continues to use `DAILY_INTERVAL_MS` (24h) absolute additions. This means that a scheduled 9:00 AM wake will drift to 10:00 AM or 8:00 AM when the user's local daylight saving time transitions, correcting only when the user manually toggles the UI.
+* **No Snapshot Polling:** As flagged in the previous review, the frontend still has no mechanism (polling or SSE) to react to background state changes. If a retry loop successfully completes in the background, the UI will continue to say "Retrying..." until the user refreshes the page or clicks another button.
diff --git a/notes/claude-reset-review.md b/notes/claude-reset-review.md
new file mode 100644
index 0000000..f38ce39
--- /dev/null
+++ b/notes/claude-reset-review.md
@@ -0,0 +1,48 @@
+# Review: Dispatch — Claude Wake Schedule (r1/claude-reset-fix)
+
+## Executive Summary
+
+The overall architecture of this rewrite is surprisingly elegant, especially the separation of pure scheduling logic (`wake-scheduler.ts`) and the coalescing tick loop. The DB schema migration is handled correctly per the requirements, and the Svelte 5 implementation correctly avoids common reactivity pitfalls.
+
+However, **I recommend a HOLD** on shipping this branch as-is. There are two high-severity issues: one that will cause legitimate user toggles to be rejected by the server (due to network latency/clock skew interacting with a strict time validation), and another race condition where the global UI state can be overwritten by stale data if the user interacts quickly. There is also a data-loss risk in the SQLite persistence logic due to a missing transaction boundary.
+
+## Findings
+
+### 1. Clock skew / latency rejects valid toggles (High)
+**Location:** `packages/api/src/routes/models.ts` (~line 918)
+**Issue:** When toggling an hour on, the client generates four timestamps for the slots using `nextOccurrenceAt`, which bases its math on the client's `Date.now()`. When the server receives the request, it validates each timestamp strictly: `if (... raw <= now) return 400;`.
+If a user toggles an hour that is imminent (e.g., they click 9:00 at 8:59:59.999), or if their local clock is just a few milliseconds behind the server, the generated `9:00:00` timestamp can be slightly in the past by the time the server evaluates `Date.now()`. The server will reject the entire request with a 400 Bad Request, breaking the UI toggle.
+**Fix:** The backend scheduler's `recoverScheduleEntry` is already perfectly equipped to handle past timestamps by firing them immediately and rolling them forward. Remove the `raw <= now` validation on the POST route, or change it to allow a generous grace period (e.g., `raw <= now - MISSED_WAKE_GRACE_MS`).
+
+### 2. Race condition in global snapshot application (High)
+**Location:** `packages/frontend/src/lib/components/ClaudeReset.svelte` (`postToggle` and `loadFromServer`)
+**Issue:** The frontend replaces its entire `schedule` state whenever it receives a response from the server (`applySnapshot`). While the author added a per-hour `inFlightSeq` lock to prevent rapid clicks *on the same hour* from causing issues, this does not protect against toggles across *different* hours.
+If a user quickly clicks "9 AM" and then "10 AM", two concurrent POST requests are fired. If the network delivers the response for 9 AM *after* the response for 10 AM, the older global snapshot (which only knows about 9 AM) will blindly overwrite the UI state, causing the 10 AM mark to disappear visually. The exact same race condition exists between the initial `$effect` `loadFromServer()` call and a rapid user toggle.
+**Fix:** Use a single global `snapshotSeq` counter for all `/models/wake-schedule` responses, or implement selective merging of the returned `schedule` data into the local state rather than replacing the whole object.
+
+### 3. Missing transaction boundary causes data loss (Medium)
+**Location:** `packages/api/src/routes/models.ts` (`persistSchedule` function)
+**Issue:** The `persistSchedule` function performs a `db.run("DELETE FROM wake_schedule")` followed by a loop of `insert.run(...)`. If any insertion fails (e.g., due to disk space, bad data, or arbitrary SQLite errors), the `catch` block simply ignores the error. However, the `DELETE` has already been committed, completely wiping out the user's persistent wake schedule upon the next boot.
+**Fix:** Wrap the `DELETE` and `INSERT` loop inside a single `db.transaction(...)` so that any failure safely rolls back the deletion.
+
+### 4. Redundant concurrency logic (Nit)
+**Location:** `packages/frontend/src/lib/components/ClaudeReset.svelte` (`toggleHour` vs `postToggle`)
+**Issue:** The author notes in `HANDOFF.md` that `inFlightSeq` handles rapid out-of-order double clicks. However, they also implemented a strict UI lock: `if (pendingHours.has(hour)) return;` at the very start of `toggleHour`. This makes it impossible to dispatch a second request for the same hour until the first completes. Consequently, `inFlightSeq` is completely unreachable dead code.
+**Fix:** Remove `inFlightSeq` entirely and rely on the `pendingHours` lock, or remove the `pendingHours` early-return if optimistic UI interaction is preferred.
+
+### 5. Masked boot recovery reason (Nit)
+**Location:** `packages/api/src/routes/models.ts` (`schedulerTick`)
+**Issue:** If `needsBootFire` is true and `due.length > 0`, the `reason` string for the retry tracker correctly joins the due slot names but drops the fact that a boot recovery also occurred.
+**Fix:** Append `" (plus boot recovery)"` to the `reason` string if `needsBootFire` is true.
+
+## Design / Architectural Concerns
+
+* **No Snapshot Polling:** As noted in the handoff, the frontend explicitly does not poll for backend state changes. This means that if a retry loop runs in the background and eventually succeeds, the UI will permanently say "Retrying..." or display a stale error state until the user interacts with the panel again or refreshes the page. A slow background poll (e.g., every 60s) or SSE feed is recommended.
+* **DST Transition Drift:** Adding `24 * 60 * 60 * 1000` (`DAILY_INTERVAL_MS`) to an absolute Unix timestamp ignores Daylight Saving Time transitions. A scheduled 9:00 AM wake will drift to 10:00 AM or 8:00 AM when the user's local clock changes. The author documented this limitation, but it's a regression in usability vs a true calendar-aware cron.
+
+## Things the author got right
+
+* **Pure Logic Extraction:** Extracting the math for `nextDailyAfter` and `recoverScheduleEntry` into pure functions with 100% test coverage makes the most critical parts of this feature verifiable without SQLite/Hono overhead.
+* **Tick Coalescing:** Using the `isTickRunning` lock and advancing all due slots *before* awaiting the upstream `fireWake()` call correctly avoids re-entrancy bugs and retry storms.
+* **Svelte 5 Reactivity:** The usage of Svelte 5 `$derived.by` for `fadedHours` and `$derived` for `currentHour` is idiomatic and correctly avoids the stale reactivity bugs that plagued the previous implementation.
+* **Destructive Migration:** Handling the schema change by checking `PRAGMA table_info` for the missing column and dropping the table is clean and respects the boundary constraints. \ No newline at end of file
diff --git a/HANDOFF.md b/notes/ntfy-notifications-handoff.md
index fde84c8..fde84c8 100644
--- a/HANDOFF.md
+++ b/notes/ntfy-notifications-handoff.md
diff --git a/notes/wake-schedule-handoff.md b/notes/wake-schedule-handoff.md
new file mode 100644
index 0000000..3ea711a
--- /dev/null
+++ b/notes/wake-schedule-handoff.md
@@ -0,0 +1,442 @@
+# Claude Reset / Wake Schedule — Fix Handoff
+
+**Branch:** `r1/claude-reset-fix` (off `dev`)
+**Worktree:** `/home/tradam/projects/dispatch/r1-claude-reset-fix`
+**Commits:** 4 atomic commits (see `git log r1/claude-reset-fix ^dev --oneline`).
+
+---
+
+## Summary
+
+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. 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.** `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.
+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`)
+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.
+10. **No success/failure feedback.** No surface for whether the most-recent
+ wake actually worked.
+
+### What I changed
+
+| # | 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-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, 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` 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.
+
+---
+
+## Files changed
+
+- **New:** `packages/api/src/wake-scheduler.ts` — pure helpers
+ (`nextDailyAfter`, `recoverScheduleEntry`, `resetHourFor`,
+ `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,
+ 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`) 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 updated for the marked-hour
+ summary + the status footer.
+
+---
+
+## Public surface changes
+
+### 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)
+)
+```
+
+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": { "0": 1700001500000, "15": 1700002400000, "30": 1700003300000, "45": 1700004200000 }
+ },
+ "resetOffsetHours": 5,
+ "probeSlotMinutes": [0, 15, 30, 45],
+ "lastWake": {
+ "firedAt": 1700000000000,
+ "ok": true,
+ "results": [{ "label": "personal", "ok": true }]
+ } | null,
+ "pendingRetry": {
+ "retriesLeft": 5,
+ "nextRetryAt": 1700000300000,
+ "reason": "scheduled probe(s) 9:15"
+ } | null
+}
+```
+
+### API: `POST /models/wake-schedule/toggle`
+
+**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 }`.
+
+### New exported helpers
+
+`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: mark 9 AM
+1. User clicks the "9" AM block.
+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 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. 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; 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.
+
+---
+
+## Verification
+
+### `bun run check`
+```
+$ biome check .
+Checked 142 files in 155ms. No fixes applied.
+```
+
+### `bun run test`
+```
+Test Files 25 passed (25)
+ Tests 417 passed (417)
+ Start at 09:52:16
+ Duration 2.80s
+```
+
+(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
+- `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
+
+---
+
+## Assumptions / known gaps
+
+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. 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.
+
+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.
+
+---
+
+## Review followup (Gemini review pass — `notes/claude-reset-review.md`)
+
+A Gemini code-review pass after the initial 4-slot work surfaced 3 real
+bugs (2 High, 1 Medium) and 2 nits. All are now fixed on this branch.
+
+| # | Sev | Where | Symptom | Fix |
+|---|---|---|---|---|
+| 1 | High | `models.ts` POST toggle | `raw <= now` rejected legitimate toggles whenever client clock skew or request latency made an imminent slot land in the past → 400 → UI toggle silently fails | Dropped the `<= now` check; kept `Number.isFinite`. The scheduler's `recoverScheduleEntry` already fires within `MISSED_WAKE_GRACE_MS` and rolls forward. |
+| 2 | High | `ClaudeReset.svelte` | Per-hour `inFlightSeq` couldn't stop an older snapshot from clobbering a newer one when the two requests covered *different* hours (or initial-load racing a click). `applySnapshot` replaces the whole `schedule` → newest click vanishes. | Replaced per-hour counter with a single global `SnapshotSequencer` (`src/lib/snapshot-sequencer.ts`) used by `loadFromServer` AND `postToggle`. Older `accept(seq)` calls return false and are dropped. |
+| 3 | Med | `models.ts` `persistSchedule` | `DELETE` + N `INSERT`s with no transaction; an insert failure left the table empty (DELETE already committed) → schedule silently wiped on next boot, error swallowed. | Wrapped both in `db.transaction(...)`. On failure the DELETE rolls back and the previously persisted snapshot stays intact. |
+| 4 | Nit | `ClaudeReset.svelte` | `inFlightSeq` was effectively dead code for user clicks (the `pendingHours.has(hour)` early-return blocks them) but still mattered for initial-load vs first-click. | Subsumed by the new global `SnapshotSequencer` (cleaner than two parallel mechanisms). |
+| 5 | Nit | `models.ts` `schedulerTick` | Boot-recovery `reason` was masked whenever boot recovery + due slots coincided in the same tick. | Capture `bootFireRequested` before clearing the flag and append `" (boot recovery)"` to the reason. |
+
+### Files added/changed in the followup
+
+- **New:** `packages/frontend/src/lib/snapshot-sequencer.ts` — 47 LoC, the
+ reusable "most-recent request wins" race guard. Pure class, no Svelte
+ deps; usable from any component that fans out snapshot-style HTTP calls.
+- **New:** `packages/frontend/tests/snapshot-sequencer.test.ts` — 8 unit
+ tests covering the core race, the initial-load-vs-click race, monotonic
+ ordering, equal-seq idempotency, and the watermark inspector.
+- **New:** `notes/claude-reset-review.md` — the original review (kept for
+ audit trail).
+- **Modified:** `packages/api/src/routes/models.ts` — fixes #1, #3, #5
+ (~30 LoC delta).
+- **Modified:** `packages/frontend/src/lib/components/ClaudeReset.svelte`
+ — fix #2 / nit #4 (~20 LoC delta).
+- **Modified:** `packages/api/tests/routes.test.ts` — replaced the
+ "POST toggle rejects past timestamp" test with two new tests:
+ - "POST toggle ACCEPTS a slightly-past timestamp (clock skew / latency)"
+ regression-guards finding #1.
+ - "POST toggle rejects NaN / Infinity / non-number slot values" guards
+ that we still reject *malformed* inputs.
+ - Plus "snapshot remains consistent across toggle round-trips" guards
+ finding #3 (the transactional persist path).
+
+### Verification (after followup)
+
+```
+$ bun run check
+Checked 144 files in 167ms. No fixes applied.
+
+$ bun run test
+Test Files 26 passed (26)
+ Tests 427 passed (427)
+
+$ bun run --cwd packages/frontend typecheck
+svelte-check found 0 errors and 0 warnings
+```
+
+### Still deferred (not addressed in followup)
+
+These were noted in the review as design pushback rather than bugs and
+remain as documented in §"Assumptions / known gaps" above:
+
+- **Snapshot polling.** UI may show stale "Retrying…" forever if a retry
+ eventually succeeds in the background without user interaction. Adding
+ a slow 60s poll is still a one-liner; left off to avoid quiet background
+ traffic. (Documented in gap #8.)
+- **DST drift.** Adding `24h` to an absolute Unix ts ignores DST
+ transitions; documented in gap #1.
+
+---
+
+## Review followup — Round 2 (Gemini review pass — `notes/claude-reset-review-2.md`)
+
+After the round-1 fixes shipped, a second Gemini review pass surfaced one
+**Critical** and one **High** finding which together exposed a real desync
+hazard: the round-1 SnapshotSequencer only protected against RESPONSE
+reordering, but the toggle endpoint was vulnerable to REQUEST reordering,
+and the toggle endpoint itself ignored client intent — combining into
+"clicks feel inverted" when the UI got desynced.
+
+Both are now fixed on this branch.
+
+| # | Sev | Where | Symptom | Fix |
+|---|---|---|---|---|
+| R2-1 | Critical | `ClaudeReset.svelte` + `snapshot-sequencer.ts` | If two concurrent toggle POSTs reorder on the WIRE (B reaches server first), the server's truer post-A snapshot carries the OLDER client seq → SnapshotSequencer discards it as stale → UI permanently desyncs from server. Round-1's per-hour-counter fix was replaced with a global sequencer but BOTH had this blind spot. | Replaced per-hour `pendingHours: Set<number>` with a single global `pendingHour: number \| null` mutation lock. While any POST is in flight, ALL toggle buttons are disabled — mutations are now serialized on the client, so the server never sees two concurrent toggle requests. Sequencer retained for the GET-on-mount vs first-click race (which the global lock doesn't cover). |
+| R2-2 | High | `models.ts` POST `/wake-schedule/toggle` | Server decided add-vs-remove from its own in-memory state instead of an explicit request field. Any UI desync (from R2-1 or any future cause) → user clicks to turn on an hour the UI shows off, server sees it on, deletes it → "click inverted" UX, recoverable only by reload. | Toggle endpoint now requires explicit `action: 'on' \| 'off'`. Idempotent: `'off'` on already-off is a no-op success; `'on'` on already-on REPLACES timestamps (so a recovering UI can re-assert wall-clock intent without a delete-then-add round trip). Missing/invalid action → 400. |
+| R2-3 | Low (deferred) | `models.ts` `wakeAllClaudeAccounts` / `processPendingRetry` | If 1 of N accounts fails a probe, the 6 × 5min retry loop re-probes ALL accounts (including the ones that already succeeded). Wastes bandwidth, but the probe payload is tiny (~16 tok) and the constant 30-min budget caps the blast radius. | **Not fixed** — explicit deliberate trade-off; per-account success tracking inside `PendingRetry` would meaningfully complicate the retry path for marginal savings. Noted in §"Assumptions / known gaps". |
+
+### Files changed in round 2
+
+- **Modified:** `packages/api/src/routes/models.ts` — toggle endpoint
+ rewritten to require explicit `action` (`+27 / -6` LoC, idempotency rules
+ documented inline).
+- **Modified:** `packages/api/tests/routes.test.ts` — `toggle()` helper
+ auto-derives `action` from `timestamps` presence so the existing 12 tests
+ stayed terse; one test (`POST toggle rejects missing timestamps on add`)
+ was renamed to `rejects action='on' with missing timestamps` and now
+ passes `action` explicitly. **+4 new contract tests** (29 / 29 routes
+ tests pass):
+ - `POST toggle requires explicit action: 'on' | 'off'` (rejects missing
+ action, rejects non-`'on'/'off'` strings/numbers/`null`).
+ - `POST toggle action='off' is idempotent on an already-off hour`.
+ - `POST toggle action='on' on an already-on hour REPLACES timestamps`
+ (the recovery-from-desync scenario).
+ - `POST toggle action='off' ignores timestamps payload`.
+- **Modified:** `packages/frontend/src/lib/components/ClaudeReset.svelte`
+ — `pendingHours: Set<number>` → `pendingHour: number | null`; all 4 row
+ buttons gated by the global lock; `toggleHour` derives `action` from
+ local state; `postToggle` sends it on the wire. Per-hour
+ `cursor-wait` class is preserved for the in-flight hour as a UX cue.
+- **New:** `notes/claude-reset-review-2.md` — the round-2 review (kept
+ for audit trail, parallel to `notes/claude-reset-review.md`).
+
+### Verification (after round-2 followup)
+
+```
+$ bun run check
+Checked 144 files in 161ms. No fixes applied.
+
+$ bun run test
+Test Files 26 passed (26)
+ Tests 431 passed (431)
+
+$ bun run --cwd packages/frontend typecheck
+svelte-check found 0 errors and 0 warnings
+```
+
+(`+4` tests vs round 1: the four explicit-action contract tests.)
+
+### What's NOT addressed in round 2
+
+- **DST drift** — unchanged design trade-off (gap #1).
+- **No snapshot polling** — unchanged design trade-off (gap #8).
+- **R2-3 (retry storm re-probes succeeded accounts)** — deliberate trade-
+ off, see table above.
diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts
index 1a54abb..03c079a 100644
--- a/packages/api/src/routes/models.ts
+++ b/packages/api/src/routes/models.ts
@@ -21,6 +21,14 @@ import {
validateAccountCredentials,
} from "@dispatch/core";
import { Hono } from "hono";
+import {
+ CLAUDE_RESET_OFFSET_HOURS,
+ isProbeSlotMinute,
+ nextDailyAfter,
+ PROBE_SLOT_MINUTES,
+ type ProbeSlotMinute,
+ recoverScheduleEntry,
+} from "../wake-scheduler.js";
let getRegistry: () => ModelRegistry | null = () => null;
let getAccounts: () => ClaudeAccount[] = () => [];
@@ -604,44 +612,72 @@ modelsRoutes.post("/wake", async (c) => {
});
// ─── Wake scheduler (runs on backend, survives frontend close) ─
+//
+// A "marked hour" expands to 4 probe slots inside that hour: :00, :15, :30,
+// :45. Each slot is its own (hour, slot_minute) row in `wake_schedule` with
+// its own `next_wake_at`. When multiple slots come due in the same tick we
+// coalesce into a single upstream wake — no point hitting Anthropic 4× in
+// the same 30-second window.
-type WakeSchedule = Record<number, number>; // hour → next wake timestamp (ms)
+/** Schedule: hour (0-23) → slot minute (0/15/30/45) → next fire ms. */
+type WakeSchedule = Record<number, Partial<Record<ProbeSlotMinute, number>>>;
interface PendingRetry {
- retriesLeft: number; // starts at 6 (5 min × 6 = 30 min)
- nextRetryAt: number; // timestamp for next retry attempt
+ /** Remaining attempts. Starts at MAX_RETRIES (e.g. 6 → 30 min of retries). */
+ retriesLeft: number;
+ /** Absolute timestamp (ms) of the next retry attempt. */
+ nextRetryAt: number;
+ /** Why we entered retry mode — surfaced on /wake-schedule. */
+ reason: string;
+}
+
+interface LastWake {
+ firedAt: number;
+ ok: boolean;
+ results: Array<{ label: string; ok: boolean; error?: string }>;
+}
+
+const MAX_RETRIES = 6;
+const RETRY_INTERVAL_MS = 5 * 60 * 1000;
+const TICK_INTERVAL_MS = 30_000;
+
+function setSlot(schedule: WakeSchedule, hour: number, minute: ProbeSlotMinute, ts: number): void {
+ const hourEntry = schedule[hour] ?? {};
+ hourEntry[minute] = ts;
+ schedule[hour] = hourEntry;
+}
+
+function deleteHour(schedule: WakeSchedule, hour: number): void {
+ delete schedule[hour];
}
-function nextOccurrenceAt15(hour: number): number {
- const now = new Date();
- const target = new Date(now);
- target.setHours(hour, 15, 0, 0);
- if (target.getTime() <= Date.now()) {
- target.setDate(target.getDate() + 1);
+function countSlots(schedule: WakeSchedule): number {
+ let n = 0;
+ for (const slots of Object.values(schedule)) {
+ n += Object.keys(slots).length;
}
- return target.getTime();
+ return n;
}
function loadScheduleFromDB(): WakeSchedule {
try {
const db = getDatabase();
- const rows = db.query("SELECT hour, next_wake_at FROM wake_schedule").all() as Array<{
- hour: number;
- next_wake_at: number;
- }>;
+ const rows = db
+ .query("SELECT hour, slot_minute, next_wake_at FROM wake_schedule")
+ .all() as Array<{ hour: number; slot_minute: number; next_wake_at: number }>;
const schedule: WakeSchedule = {};
- let needsUpdate = false;
+ const now = Date.now();
+ let needsPersist = false;
+ let anyShouldFire = false;
for (const row of rows) {
- if (row.next_wake_at > Date.now()) {
- schedule[row.hour] = row.next_wake_at;
- } else {
- schedule[row.hour] = nextOccurrenceAt15(row.hour);
- needsUpdate = true;
- }
- }
- if (needsUpdate) {
- persistSchedule(schedule);
+ if (!isProbeSlotMinute(row.slot_minute)) continue; // defensive — schema CHECKs it
+ const recovered = recoverScheduleEntry(row.next_wake_at, now);
+ setSlot(schedule, row.hour, row.slot_minute, recovered.nextWakeAt);
+ if (recovered.nextWakeAt !== row.next_wake_at) needsPersist = true;
+ if (recovered.shouldFireNow) anyShouldFire = true;
}
+ if (needsPersist) persistSchedule(schedule);
+ if (anyShouldFire) needsBootFire = true;
return schedule;
} catch {
return {};
@@ -652,90 +688,186 @@ function persistSchedule(scheduleToSave?: WakeSchedule): void {
try {
const db = getDatabase();
const data = scheduleToSave ?? wakeSchedule;
- db.run("DELETE FROM wake_schedule");
const insert = db.query(
- "INSERT INTO wake_schedule (hour, next_wake_at) VALUES ($hour, $nextWakeAt)",
+ "INSERT INTO wake_schedule (hour, slot_minute, next_wake_at) VALUES ($hour, $slot, $nextWakeAt)",
);
- for (const [hour, nextWakeAt] of Object.entries(data)) {
- insert.run({ $hour: Number(hour), $nextWakeAt: nextWakeAt });
- }
+ // One atomic transaction: DELETE + every INSERT either all commit or all
+ // roll back. Without this, an INSERT failure (disk full, bad row, etc.)
+ // would leave the table empty — silently wiping the user's schedule on
+ // next boot since the DELETE has already committed.
+ const writeAll = db.transaction(() => {
+ db.run("DELETE FROM wake_schedule");
+ for (const [hour, slots] of Object.entries(data)) {
+ for (const [slotMinute, nextWakeAt] of Object.entries(slots)) {
+ if (nextWakeAt === undefined) continue;
+ insert.run({
+ $hour: Number(hour),
+ $slot: Number(slotMinute),
+ $nextWakeAt: nextWakeAt,
+ });
+ }
+ }
+ });
+ writeAll();
} catch {
- // Ignore DB errors
+ // Ignore DB errors — schedule still lives in-memory for this process,
+ // and the previously persisted snapshot stays intact thanks to the
+ // transaction rollback above.
}
}
+/** Set to true by loadScheduleFromDB when one or more slots need a boot fire. */
+let needsBootFire = false;
const wakeSchedule: WakeSchedule = loadScheduleFromDB();
-const pendingRetries: PendingRetry[] = [];
-// HMR-safe: clear previous tick before starting a new one
-(globalThis as Record<string, unknown>)._dispatchWakeTimer ??= undefined;
+/**
+ * A single shared retry slot. We deliberately do NOT queue one retry per
+ * failed wake — multiple back-to-back failures (e.g. the network is down for
+ * five minutes) used to spawn retries that all converged on the same instant
+ * and hammered the upstream. One in-flight retry covers all accounts.
+ */
+let pendingRetry: PendingRetry | null = null;
+let lastWake: LastWake | null = null;
+
+// HMR-safe: track the scheduler timer on globalThis so re-imports during dev
+// don't leave orphaned timers running.
const timerKey = "_dispatchWakeTimer";
+(globalThis as Record<string, unknown>)[timerKey] ??= undefined;
let isTickRunning = false;
+function recordWake(results: Array<{ label: string; ok: boolean; error?: string }>): boolean {
+ const ok = results.length > 0 && results.every((r) => r.ok);
+ lastWake = { firedAt: Date.now(), ok, results };
+ return ok;
+}
+
+function scheduleRetry(reason: string): void {
+ if (pendingRetry) {
+ // Already retrying — reset the budget so the next failure window covers
+ // the new incident too, but don't compound timers.
+ pendingRetry.retriesLeft = MAX_RETRIES;
+ pendingRetry.nextRetryAt = Date.now() + RETRY_INTERVAL_MS;
+ pendingRetry.reason = reason;
+ return;
+ }
+ pendingRetry = {
+ retriesLeft: MAX_RETRIES,
+ nextRetryAt: Date.now() + RETRY_INTERVAL_MS,
+ reason,
+ };
+}
+
+async function fireWake(reason: string): Promise<void> {
+ try {
+ const results = await wakeAllClaudeAccounts();
+ const ok = recordWake(results);
+ if (!ok) scheduleRetry(reason);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ lastWake = {
+ firedAt: Date.now(),
+ ok: false,
+ results: [{ label: "(scheduler)", ok: false, error: message }],
+ };
+ scheduleRetry(reason);
+ }
+}
+
+async function processPendingRetry(now: number): Promise<void> {
+ // Capture into a local so TS narrowing survives across awaits, and so a
+ // racing toggle that clears `pendingRetry` mid-flight can't NPE us.
+ const retry = pendingRetry;
+ if (!retry || retry.nextRetryAt > now) return;
+ try {
+ const results = await wakeAllClaudeAccounts();
+ const ok = recordWake(results);
+ if (ok || retry.retriesLeft <= 1) {
+ pendingRetry = null;
+ } else {
+ retry.retriesLeft -= 1;
+ retry.nextRetryAt = Date.now() + RETRY_INTERVAL_MS;
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ lastWake = {
+ firedAt: Date.now(),
+ ok: false,
+ results: [{ label: "(retry)", ok: false, error: message }],
+ };
+ if (retry.retriesLeft <= 1) {
+ pendingRetry = null;
+ } else {
+ retry.retriesLeft -= 1;
+ retry.nextRetryAt = Date.now() + RETRY_INTERVAL_MS;
+ }
+ }
+}
+
+interface DueSlot {
+ hour: number;
+ minute: ProbeSlotMinute;
+ ts: number;
+}
+
+/** Collect every slot whose next_wake_at is at or before `now`. */
+function collectDueSlots(now: number): DueSlot[] {
+ const due: DueSlot[] = [];
+ for (const [hourStr, slots] of Object.entries(wakeSchedule)) {
+ const hour = Number(hourStr);
+ for (const [slotStr, ts] of Object.entries(slots)) {
+ if (ts === undefined) continue;
+ const slotMinute = Number(slotStr);
+ if (!isProbeSlotMinute(slotMinute)) continue;
+ if (ts <= now) due.push({ hour, minute: slotMinute, ts });
+ }
+ }
+ return due;
+}
+
async function schedulerTick(): Promise<void> {
- // Prevent concurrent tick execution (e.g. toggle called mid-tick)
+ // Prevent concurrent tick execution (e.g. toggle called mid-tick).
if (isTickRunning) return;
isTickRunning = true;
try {
const now = Date.now();
- const hours = Object.keys(wakeSchedule).map(Number);
-
- for (const hour of hours) {
- const ts = wakeSchedule[hour];
- if (ts !== undefined && ts <= now) {
- // Reschedule for next day (recurring daily)
- wakeSchedule[hour] = nextOccurrenceAt15(hour);
- persistSchedule();
-
- // Wake accounts and track failures for retry
- try {
- const results = await wakeAllClaudeAccounts();
- const anyFailed = results.some((r) => !r.ok);
- if (anyFailed) {
- pendingRetries.push({
- retriesLeft: 6,
- nextRetryAt: now + 5 * 60 * 1000,
- });
- }
- } catch {
- // Total failure — schedule retry
- pendingRetries.push({
- retriesLeft: 6,
- nextRetryAt: now + 5 * 60 * 1000,
- });
- }
+ const due = collectDueSlots(now);
+
+ let firedThisTick = false;
+ const bootFireRequested = needsBootFire;
+ if (due.length > 0 || bootFireRequested) {
+ needsBootFire = false;
+ // Advance every due slot before firing — so a slow upstream call
+ // can't cause us to re-fire the same slot on the next tick.
+ for (const slot of due) {
+ const next = nextDailyAfter(slot.ts, now);
+ setSlot(wakeSchedule, slot.hour, slot.minute, next);
}
+ persistSchedule();
+
+ const reasonParts = due.map((d) => `${d.hour}:${String(d.minute).padStart(2, "0")}`);
+ const fromBoot = bootFireRequested ? " (boot recovery)" : "";
+ const reason =
+ reasonParts.length > 0
+ ? `scheduled probe(s) ${reasonParts.join(", ")}${fromBoot}`
+ : "boot recovery";
+ firedThisTick = true;
+ // COALESCED: one upstream call covers all slots due this tick.
+ await fireWake(reason);
}
- // Process pending retries (iterate backwards for safe splicing)
- for (let i = pendingRetries.length - 1; i >= 0; i--) {
- const retry = pendingRetries[i];
- if (!retry || retry.nextRetryAt > now) continue;
-
- try {
- const results = await wakeAllClaudeAccounts();
- const anyFailed = results.some((r) => !r.ok);
- if (!anyFailed || retry.retriesLeft <= 1) {
- // All succeeded or out of retries — remove
- pendingRetries.splice(i, 1);
- } else {
- retry.retriesLeft--;
- retry.nextRetryAt = now + 5 * 60 * 1000;
- }
- } catch {
- if (retry.retriesLeft <= 1) {
- pendingRetries.splice(i, 1);
- } else {
- retry.retriesLeft--;
- retry.nextRetryAt = now + 5 * 60 * 1000;
- }
- }
+ // Only attempt a retry on ticks that didn't *just* fire — otherwise we'd
+ // race the retry against a fresh attempt within the same loop iteration.
+ if (!firedThisTick) {
+ await processPendingRetry(Date.now());
}
- // Schedule next tick while there's work to monitor
- if (Object.keys(wakeSchedule).length > 0 || pendingRetries.length > 0) {
- (globalThis as Record<string, unknown>)[timerKey] = setTimeout(schedulerTick, 30_000);
+ // Keep ticking while there's anything to monitor.
+ if (countSlots(wakeSchedule) > 0 || pendingRetry !== null) {
+ (globalThis as Record<string, unknown>)[timerKey] = setTimeout(
+ schedulerTick,
+ TICK_INTERVAL_MS,
+ );
}
} finally {
isTickRunning = false;
@@ -743,38 +875,100 @@ async function schedulerTick(): Promise<void> {
}
export function startWakeScheduler(): void {
- // Clear any previous timer (HMR-safe — works with Bun's Timer objects)
+ // Clear any previous timer (HMR-safe — works with Bun's Timer objects).
const prev = (globalThis as Record<string, unknown>)[timerKey];
if (prev != null) clearTimeout(prev as ReturnType<typeof setTimeout>);
- schedulerTick();
+ // Fire-and-forget; the tick re-arms itself.
+ void schedulerTick();
+}
+
+function scheduleSnapshot(): {
+ schedule: WakeSchedule;
+ resetOffsetHours: number;
+ probeSlotMinutes: readonly number[];
+ lastWake: LastWake | null;
+ pendingRetry: PendingRetry | null;
+} {
+ return {
+ schedule: wakeSchedule,
+ resetOffsetHours: CLAUDE_RESET_OFFSET_HOURS,
+ probeSlotMinutes: PROBE_SLOT_MINUTES,
+ lastWake,
+ pendingRetry,
+ };
}
modelsRoutes.post("/wake-schedule/toggle", async (c) => {
- const body = await c.req.json<{ hour?: number; timestamp?: number }>();
+ const body = await c.req.json<{
+ hour?: unknown;
+ action?: unknown;
+ timestamps?: unknown;
+ }>();
const hour = body.hour;
if (typeof hour !== "number" || !Number.isFinite(hour) || hour < 0 || hour > 23) {
return c.json({ error: "hour must be a number 0-23" }, 400);
}
+ if (!Number.isInteger(hour)) {
+ return c.json({ error: "hour must be an integer 0-23" }, 400);
+ }
- if (wakeSchedule[hour] !== undefined) {
- // Delete
- delete wakeSchedule[hour];
+ // The action is the CLIENT'S DECLARED INTENT. Previously the server
+ // derived add-vs-remove from its own in-memory state, which meant a UI
+ // that had become stale (e.g. due to a snapshot race) would have its
+ // clicks silently inverted: user clicks to turn ON an hour the UI shows
+ // as OFF, server sees it as already-ON, deletes it. Requiring an explicit
+ // action makes the request idempotent and self-describing — a stale UI's
+ // click is now either a redundant no-op (action matches server state) or
+ // a recoverable replace (action="on" against an already-on hour just
+ // refreshes its timestamps to the new values).
+ const action = body.action;
+ if (action !== "on" && action !== "off") {
+ return c.json({ error: "action must be 'on' or 'off'" }, 400);
+ }
+
+ if (action === "off") {
+ // Idempotent: removing an already-removed hour is a no-op success.
+ if (wakeSchedule[hour] !== undefined) {
+ deleteHour(wakeSchedule, hour);
+ }
} else {
- // Add — require a future timestamp
- const ts = body.timestamp;
- if (typeof ts !== "number" || ts <= Date.now()) {
- return c.json({ error: "timestamp must be a future Unix ms value" }, 400);
+ // action === "on" — require a `timestamps` object with one absolute
+ // Unix ms per probe slot (0, 15, 30, 45). The client is the source
+ // of truth for the *local* wall-clock intent of each probe.
+ // Idempotent: turning ON an already-on hour replaces its timestamps
+ // (so a UI recovering from a desync can re-assert the correct wall-
+ // clock intent without first deleting).
+ const timestamps = body.timestamps;
+ if (timestamps === null || typeof timestamps !== "object") {
+ return c.json(
+ { error: "timestamps must be an object { '0': ms, '15': ms, '30': ms, '45': ms }" },
+ 400,
+ );
+ }
+ const parsed: Partial<Record<ProbeSlotMinute, number>> = {};
+ for (const slot of PROBE_SLOT_MINUTES) {
+ const raw = (timestamps as Record<string, unknown>)[String(slot)];
+ // Accept any finite Unix-ms number. We deliberately do NOT reject
+ // past timestamps: client-server clock skew + request latency mean
+ // a freshly-computed `nextOccurrenceAt(HH:MM)` for an imminent slot
+ // can land "in the past" by the time the server validates it. The
+ // scheduler tick handles past entries correctly via
+ // `recoverScheduleEntry` — fires within MISSED_WAKE_GRACE_MS, then
+ // advances by 24h * N to the next future occurrence.
+ if (typeof raw !== "number" || !Number.isFinite(raw)) {
+ return c.json({ error: `timestamps['${slot}'] must be a finite Unix ms value` }, 400);
+ }
+ parsed[slot] = raw;
}
- wakeSchedule[hour] = ts;
+ wakeSchedule[hour] = parsed;
}
- // Persist and restart the tick loop
persistSchedule();
startWakeScheduler();
- return c.json({ schedule: wakeSchedule });
+ return c.json(scheduleSnapshot());
});
modelsRoutes.get("/wake-schedule", (c) => {
- return c.json({ schedule: wakeSchedule });
+ return c.json(scheduleSnapshot());
});
diff --git a/packages/api/src/wake-scheduler.ts b/packages/api/src/wake-scheduler.ts
new file mode 100644
index 0000000..8953e9f
--- /dev/null
+++ b/packages/api/src/wake-scheduler.ts
@@ -0,0 +1,97 @@
+/**
+ * Pure helpers for the Claude wake scheduler. Kept side-effect-free so the
+ * recovery & rescheduling logic can be unit-tested without spinning up the
+ * Hono app or touching SQLite.
+ *
+ * Semantics — read this before editing:
+ *
+ * 1. The user marks an hour (0-23) on the frontend. Marking the hour
+ * schedules FOUR probes inside that hour, one per quarter-hour slot
+ * (:00, :15, :30, :45). Each slot is its own persisted row keyed by
+ * (hour, slot_minute). The frontend computes the *first* fire ms for
+ * each slot in **its** local timezone and sends them; that absolute
+ * ms is the source of truth.
+ *
+ * 2. After each fire (successful or not) we advance the slot by exactly
+ * 24h from the previous `next_wake_at`. This preserves the user's
+ * original local wall-clock intent regardless of the *server*'s
+ * timezone. DST can drift the fire by ±1h on transition day; it
+ * self-corrects the next time the user toggles the hour.
+ *
+ * 3. On server boot, any persisted slot whose `next_wake_at` is in the
+ * past is "recovered": if it was missed by ≤ MISSED_WAKE_GRACE_MS we
+ * fire it on the next tick (signal: `shouldFireNow = true`) and
+ * advance to the next future occurrence. If missed by more than the
+ * grace window we silently skip and advance. Either way the slot
+ * stays scheduled.
+ *
+ * 4. Multiple slots that come due in the same tick (or recover at
+ * boot) coalesce into a SINGLE upstream wake call. Probing four
+ * times in 15 minutes is fine; probing four times within the same
+ * 30s tick is wasteful and pointless.
+ */
+
+/** How long after a missed fire we still consider it worth running. */
+export const MISSED_WAKE_GRACE_MS = 2 * 60 * 60 * 1000; // 2 hours
+
+/** Day length used when advancing recurring wakes. */
+export const DAILY_INTERVAL_MS = 24 * 60 * 60 * 1000;
+
+/** Fixed offset (hours) from a wake to the "Claude session reset" display. */
+export const CLAUDE_RESET_OFFSET_HOURS = 5;
+
+/** Minute offsets inside a marked hour where a probe fires. */
+export const PROBE_SLOT_MINUTES = [0, 15, 30, 45] as const;
+export type ProbeSlotMinute = (typeof PROBE_SLOT_MINUTES)[number];
+
+/**
+ * Advance `previous` by 24-hour increments until strictly after `now`.
+ * Pure: only does math on the given numbers.
+ */
+export function nextDailyAfter(previous: number, now: number): number {
+ if (previous > now) return previous;
+ const deltaMs = now - previous;
+ // Ceiling division so the result is strictly > now.
+ const stepsAhead = Math.floor(deltaMs / DAILY_INTERVAL_MS) + 1;
+ return previous + stepsAhead * DAILY_INTERVAL_MS;
+}
+
+export interface RecoveredEntry {
+ /** New `next_wake_at` to persist (always strictly in the future). */
+ nextWakeAt: number;
+ /** True if the caller should fire a wake *right now* before scheduling. */
+ shouldFireNow: boolean;
+}
+
+/**
+ * Compute the post-boot state for a single persisted schedule entry.
+ *
+ * - Entry still in the future → keep as-is, no fire.
+ * - Missed by ≤ grace window → fire now, then advance to next day.
+ * - Missed by > grace window → skip the fire, advance to next day.
+ */
+export function recoverScheduleEntry(
+ storedNextWakeAt: number,
+ now: number,
+ graceMs: number = MISSED_WAKE_GRACE_MS,
+): RecoveredEntry {
+ if (storedNextWakeAt > now) {
+ return { nextWakeAt: storedNextWakeAt, shouldFireNow: false };
+ }
+ const overdueBy = now - storedNextWakeAt;
+ const shouldFireNow = overdueBy <= graceMs;
+ return {
+ nextWakeAt: nextDailyAfter(storedNextWakeAt, now),
+ shouldFireNow,
+ };
+}
+
+/** Display hour (0-23) for the "reset" label paired with a wake hour. */
+export function resetHourFor(wakeHour: number): number {
+ return (wakeHour + CLAUDE_RESET_OFFSET_HOURS) % 24;
+}
+
+/** Type guard: is this number a valid probe slot minute? */
+export function isProbeSlotMinute(n: unknown): n is ProbeSlotMinute {
+ return n === 0 || n === 15 || n === 30 || n === 45;
+}
diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts
index 5606754..c768cee 100644
--- a/packages/api/tests/routes.test.ts
+++ b/packages/api/tests/routes.test.ts
@@ -447,3 +447,307 @@ describe("POST /chat/stop", () => {
expect(res.status).toBe(400);
});
});
+describe("Wake schedule routes", () => {
+ async function getSchedule() {
+ const res = await app.request("/models/wake-schedule");
+ expect(res.status).toBe(200);
+ return (await res.json()) as {
+ schedule: Record<string, Record<string, number>>;
+ resetOffsetHours: number;
+ probeSlotMinutes: number[];
+ lastWake: unknown;
+ pendingRetry: unknown;
+ };
+ }
+
+ /**
+ * Auto-derives `action` from `timestamps` presence:
+ * - body has `timestamps` → action = "on"
+ * - body has no `timestamps` → action = "off"
+ * Tests can override by passing `action` explicitly. This keeps the
+ * intent-vs-state contract enforced (every request carries an explicit
+ * action) while keeping the existing test bodies short.
+ */
+ async function toggle(body: Record<string, unknown>) {
+ const withAction: Record<string, unknown> =
+ "action" in body ? body : { ...body, action: body.timestamps !== undefined ? "on" : "off" };
+ return app.request("/models/wake-schedule/toggle", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(withAction),
+ });
+ }
+
+ /** Build a `timestamps` payload with all 4 probe slots set to absolute ms. */
+ function buildTimestamps(base: number): Record<string, number> {
+ return { "0": base, "15": base + 60_000, "30": base + 120_000, "45": base + 180_000 };
+ }
+
+ it("GET returns the full snapshot shape with probeSlotMinutes, resetOffsetHours, lastWake, pendingRetry", async () => {
+ const snap = await getSchedule();
+ expect(snap.schedule).toBeDefined();
+ expect(Number.isInteger(snap.resetOffsetHours)).toBe(true);
+ expect(snap.resetOffsetHours).toBeGreaterThan(0);
+ expect(snap.probeSlotMinutes).toEqual([0, 15, 30, 45]);
+ expect(snap.lastWake).toBeNull();
+ expect(snap.pendingRetry).toBeNull();
+ });
+
+ it("POST toggle adds an hour as 4 probe slots and removes them all together", async () => {
+ const base = Date.now() + 60 * 60 * 1000; // 1 h ahead
+ const timestamps = buildTimestamps(base);
+
+ const addRes = await toggle({ hour: 9, timestamps });
+ expect(addRes.status).toBe(200);
+ const addBody = (await addRes.json()) as {
+ schedule: Record<string, Record<string, number>>;
+ };
+ expect(addBody.schedule["9"]).toEqual({
+ "0": base,
+ "15": base + 60_000,
+ "30": base + 120_000,
+ "45": base + 180_000,
+ });
+
+ const removeRes = await toggle({ hour: 9 });
+ expect(removeRes.status).toBe(200);
+ const removeBody = (await removeRes.json()) as {
+ schedule: Record<string, Record<string, number>>;
+ };
+ expect(removeBody.schedule["9"]).toBeUndefined();
+ });
+
+ it("POST toggle rejects out-of-range hour", async () => {
+ const res = await toggle({
+ hour: 24,
+ timestamps: buildTimestamps(Date.now() + 60_000),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("POST toggle rejects negative hour", async () => {
+ const res = await toggle({
+ hour: -1,
+ timestamps: buildTimestamps(Date.now() + 60_000),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("POST toggle rejects non-integer hour", async () => {
+ const res = await toggle({
+ hour: 4.5,
+ timestamps: buildTimestamps(Date.now() + 60_000),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("POST toggle ACCEPTS a slightly-past timestamp (clock skew / latency)", async () => {
+ // Regression guard for Gemini-review finding #1: the old code rejected
+ // any slot timestamp <= server now, which broke legitimate toggles when
+ // network latency made an imminent slot land "in the past". The HTTP
+ // layer must accept it; the scheduler then either fires it immediately
+ // (if within MISSED_WAKE_GRACE_MS) or rolls it forward by 24h × N. By
+ // the time the response returns, the scheduler tick has already run
+ // synchronously up to its first await — so the snapshot reflects the
+ // post-advance ts (strictly > now), not the original past ts.
+ await toggle({ hour: 22 }); // ensure clean
+ const now = Date.now();
+ const timestamps: Record<string, number> = {
+ "0": now - 5_000, // 5s in the past — well within any plausible skew
+ "15": now + 60_000,
+ "30": now + 120_000,
+ "45": now + 180_000,
+ };
+ const res = await toggle({ hour: 22, timestamps });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ schedule: Record<string, Record<string, number>>;
+ };
+ const slot0 = body.schedule["22"]?.["0"];
+ expect(typeof slot0).toBe("number");
+ expect((slot0 ?? 0) > now).toBe(true); // slot :00 was advanced by the tick
+ expect(body.schedule["22"]?.["15"]).toBe(now + 60_000); // future slot kept
+ await toggle({ hour: 22 }); // cleanup
+ });
+
+ it("POST toggle rejects NaN / Infinity / non-number slot values", async () => {
+ // Use a dedicated hour and ALWAYS clean it up, even on assertion failure,
+ // so we don't leak state into the next iteration (which would otherwise
+ // interpret the next toggle as a DELETE and return 200).
+ const hour = 23;
+ await toggle({ hour }); // ensure clean
+ for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, "x", null]) {
+ const now = Date.now();
+ const timestamps: Record<string, unknown> = {
+ "0": now + 60_000,
+ "15": bad,
+ "30": now + 180_000,
+ "45": now + 240_000,
+ };
+ const res = await toggle({ hour, timestamps });
+ try {
+ expect(res.status, `bad value: ${String(bad)}`).toBe(400);
+ } finally {
+ await toggle({ hour }); // ensure clean for next iteration
+ }
+ }
+ });
+
+ it("POST toggle rejects action='on' with missing timestamps", async () => {
+ // Under the explicit-action contract, the helper would otherwise
+ // auto-derive { action: 'off' } for a body with no timestamps. Pass
+ // action explicitly so we exercise the on-without-timestamps reject.
+ const res = await toggle({ hour: 8, action: "on" });
+ expect(res.status).toBe(400);
+ });
+
+ it("POST toggle rejects missing slot in timestamps object", async () => {
+ const res = await toggle({
+ hour: 8,
+ timestamps: { "0": Date.now() + 60_000, "15": Date.now() + 120_000 },
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("POST toggle rejects non-object timestamps", async () => {
+ const res = await toggle({ hour: 8, timestamps: 12345 });
+ expect(res.status).toBe(400);
+ });
+
+ it("POST toggle: a delete does NOT require timestamps", async () => {
+ const base = Date.now() + 60 * 60 * 1000;
+ const addRes = await toggle({ hour: 11, timestamps: buildTimestamps(base) });
+ expect(addRes.status).toBe(200);
+ const delRes = await toggle({ hour: 11 });
+ expect(delRes.status).toBe(200);
+ const body = (await delRes.json()) as { schedule: Record<string, unknown> };
+ expect(body.schedule["11"]).toBeUndefined();
+ });
+
+ it("snapshot reflects multiple marked hours independently with all 4 slots each", async () => {
+ const base1 = Date.now() + 2 * 60 * 60 * 1000;
+ const base2 = base1 + 60 * 60 * 1000;
+ await toggle({ hour: 14, timestamps: buildTimestamps(base1) });
+ await toggle({ hour: 19, timestamps: buildTimestamps(base2) });
+ const snap = await getSchedule();
+ expect(Object.keys(snap.schedule["14"] ?? {}).sort()).toEqual(["0", "15", "30", "45"]);
+ expect(Object.keys(snap.schedule["19"] ?? {}).sort()).toEqual(["0", "15", "30", "45"]);
+ expect(snap.schedule["14"]?.["0"]).toBe(base1);
+ expect(snap.schedule["19"]?.["0"]).toBe(base2);
+ // Cleanup so later tests start clean.
+ await toggle({ hour: 14 });
+ await toggle({ hour: 19 });
+ });
+
+ it("re-toggling the same hour replaces all 4 slot timestamps", async () => {
+ const base1 = Date.now() + 60 * 60 * 1000;
+ const base2 = base1 + 30 * 60 * 1000;
+ await toggle({ hour: 5, timestamps: buildTimestamps(base1) });
+ await toggle({ hour: 5 }); // remove
+ const addRes = await toggle({ hour: 5, timestamps: buildTimestamps(base2) });
+ const body = (await addRes.json()) as {
+ schedule: Record<string, Record<string, number>>;
+ };
+ expect(body.schedule["5"]?.["0"]).toBe(base2);
+ expect(body.schedule["5"]?.["45"]).toBe(base2 + 180_000);
+ await toggle({ hour: 5 });
+ });
+
+ it("snapshot remains consistent across toggle round-trips (persistSchedule atomicity)", async () => {
+ // Regression guard for Gemini-review finding #3: persistSchedule
+ // originally did DELETE + N INSERTs without a transaction. A mid-loop
+ // failure would commit the DELETE and lose the schedule. We can't
+ // directly induce a SQLite mid-INSERT failure from here without
+ // monkey-patching getDatabase, but we CAN assert that the steady-state
+ // round-trip never drops rows — and a transactional impl must agree
+ // with itself across GET/POST cycles.
+ const base = Date.now() + 60 * 60 * 1000;
+ await toggle({ hour: 1, timestamps: buildTimestamps(base) });
+ await toggle({ hour: 2, timestamps: buildTimestamps(base + 60_000) });
+ await toggle({ hour: 3, timestamps: buildTimestamps(base + 120_000) });
+ const snap = await getSchedule();
+ for (const h of ["1", "2", "3"]) {
+ expect(Object.keys(snap.schedule[h] ?? {}).sort()).toEqual(["0", "15", "30", "45"]);
+ }
+ // Remove one; the others must be untouched.
+ await toggle({ hour: 2 });
+ const snap2 = await getSchedule();
+ expect(snap2.schedule["1"]).toBeDefined();
+ expect(snap2.schedule["2"]).toBeUndefined();
+ expect(snap2.schedule["3"]).toBeDefined();
+ // Cleanup.
+ await toggle({ hour: 1 });
+ await toggle({ hour: 3 });
+ });
+
+ it("POST toggle requires explicit action: 'on' | 'off' (Gemini round-2 #2)", async () => {
+ // The server must reject a request that omits `action`. This is the
+ // contract that closes the desync-causes-inverted-clicks failure mode:
+ // the server is no longer allowed to guess the user's intent from its
+ // own (possibly stale-relative-to-UI) in-memory state.
+ const now = Date.now();
+ const raw = await app.request("/models/wake-schedule/toggle", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ hour: 6, timestamps: buildTimestamps(now + 60_000) }),
+ });
+ expect(raw.status).toBe(400);
+
+ for (const bad of ["toggle", "ON", "OFF", "", true, 1, null]) {
+ const res = await app.request("/models/wake-schedule/toggle", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ hour: 6, action: bad, timestamps: buildTimestamps(now + 60_000) }),
+ });
+ expect(res.status, `bad action: ${JSON.stringify(bad)}`).toBe(400);
+ }
+ });
+
+ it("POST toggle action='off' is idempotent on an already-off hour (no error)", async () => {
+ // Stale UI scenario: the UI thinks hour 4 is on and clicks to turn it
+ // off, but server state had already deleted it. Must succeed quietly,
+ // not 400 or change anything else.
+ const before = await getSchedule();
+ expect(before.schedule["4"]).toBeUndefined();
+ const res = await toggle({ hour: 4 }); // helper auto-derives action='off'
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { schedule: Record<string, unknown> };
+ expect(body.schedule["4"]).toBeUndefined();
+ });
+
+ it("POST toggle action='on' on an already-on hour REPLACES timestamps (recovery from desync)", async () => {
+ // Stale UI scenario: the UI thinks hour 12 is off and clicks to turn
+ // it on, but server state had it already on (from a snapshot the UI
+ // missed). Old behavior would have INVERTED the click (turning it
+ // off); new behavior replaces the timestamps with the user's freshly
+ // computed wall-clock intent and keeps the hour on.
+ const base1 = Date.now() + 60 * 60 * 1000;
+ const base2 = base1 + 7 * 60 * 60 * 1000;
+ const addRes = await toggle({ hour: 12, timestamps: buildTimestamps(base1) });
+ expect(addRes.status).toBe(200);
+ const reAddRes = await toggle({ hour: 12, timestamps: buildTimestamps(base2) });
+ expect(reAddRes.status).toBe(200);
+ const body = (await reAddRes.json()) as {
+ schedule: Record<string, Record<string, number>>;
+ };
+ // Hour still present (NOT inverted to off), AND timestamps refreshed.
+ expect(body.schedule["12"]?.["0"]).toBe(base2);
+ expect(body.schedule["12"]?.["45"]).toBe(base2 + 180_000);
+ await toggle({ hour: 12 }); // cleanup
+ });
+
+ it("POST toggle action='off' ignores timestamps payload (off doesn't need them)", async () => {
+ // Accepting extra fields on an off request is fine; we only fail if
+ // an action='on' lacks timestamps.
+ const base = Date.now() + 60 * 60 * 1000;
+ await toggle({ hour: 13, timestamps: buildTimestamps(base) });
+ const res = await app.request("/models/wake-schedule/toggle", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ hour: 13, action: "off", timestamps: buildTimestamps(base) }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { schedule: Record<string, unknown> };
+ expect(body.schedule["13"]).toBeUndefined();
+ });
+});
diff --git a/packages/api/tests/wake-scheduler.test.ts b/packages/api/tests/wake-scheduler.test.ts
new file mode 100644
index 0000000..0e5731c
--- /dev/null
+++ b/packages/api/tests/wake-scheduler.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it } from "vitest";
+import {
+ CLAUDE_RESET_OFFSET_HOURS,
+ DAILY_INTERVAL_MS,
+ MISSED_WAKE_GRACE_MS,
+ nextDailyAfter,
+ recoverScheduleEntry,
+ resetHourFor,
+} from "../src/wake-scheduler.js";
+
+const HOUR = 60 * 60 * 1000;
+
+describe("nextDailyAfter", () => {
+ it("returns the input when it is already strictly in the future", () => {
+ const now = 1_700_000_000_000;
+ const future = now + 60_000;
+ expect(nextDailyAfter(future, now)).toBe(future);
+ });
+
+ it("advances by exactly 24h when the input is 1ms in the past", () => {
+ const now = 1_700_000_000_000;
+ const previous = now - 1;
+ expect(nextDailyAfter(previous, now)).toBe(previous + DAILY_INTERVAL_MS);
+ });
+
+ it("skips multiple missed days in a single step when far in the past", () => {
+ const now = 1_700_000_000_000;
+ const previous = now - 3 * DAILY_INTERVAL_MS - HOUR; // 3d 1h ago
+ const next = nextDailyAfter(previous, now);
+ expect(next).toBeGreaterThan(now);
+ // Should be the next 24h-multiple boundary, not just +24h.
+ expect((next - previous) % DAILY_INTERVAL_MS).toBe(0);
+ expect(next - previous).toBe(4 * DAILY_INTERVAL_MS);
+ });
+
+ it("returns previous + 1 day exactly when previous == now", () => {
+ const now = 1_700_000_000_000;
+ expect(nextDailyAfter(now, now)).toBe(now + DAILY_INTERVAL_MS);
+ });
+});
+
+describe("recoverScheduleEntry", () => {
+ const now = 1_700_000_000_000;
+
+ it("leaves a future entry unchanged and does not fire", () => {
+ const stored = now + 5 * HOUR;
+ expect(recoverScheduleEntry(stored, now)).toEqual({
+ nextWakeAt: stored,
+ shouldFireNow: false,
+ });
+ });
+
+ it("fires now for an entry missed by less than the grace window", () => {
+ const stored = now - HOUR; // 1h ago, within 2h grace
+ const recovered = recoverScheduleEntry(stored, now);
+ expect(recovered.shouldFireNow).toBe(true);
+ expect(recovered.nextWakeAt).toBeGreaterThan(now);
+ });
+
+ it("fires now for an entry missed by exactly the grace window", () => {
+ const stored = now - MISSED_WAKE_GRACE_MS;
+ expect(recoverScheduleEntry(stored, now).shouldFireNow).toBe(true);
+ });
+
+ it("does NOT fire for an entry missed by more than the grace window", () => {
+ const stored = now - MISSED_WAKE_GRACE_MS - 1;
+ const recovered = recoverScheduleEntry(stored, now);
+ expect(recovered.shouldFireNow).toBe(false);
+ expect(recovered.nextWakeAt).toBeGreaterThan(now);
+ });
+
+ it("always returns a future nextWakeAt for past entries (regardless of grace)", () => {
+ for (const ageDays of [0.1, 0.5, 1, 2, 7, 30]) {
+ const stored = now - ageDays * DAILY_INTERVAL_MS;
+ const { nextWakeAt } = recoverScheduleEntry(stored, now);
+ expect(nextWakeAt, `age=${ageDays}d`).toBeGreaterThan(now);
+ }
+ });
+
+ it("respects a custom grace window", () => {
+ const stored = now - 10 * 60_000; // 10 min ago
+ expect(recoverScheduleEntry(stored, now, 5 * 60_000).shouldFireNow).toBe(false);
+ expect(recoverScheduleEntry(stored, now, 15 * 60_000).shouldFireNow).toBe(true);
+ });
+});
+
+describe("resetHourFor / CLAUDE_RESET_OFFSET_HOURS", () => {
+ it("adds the offset modulo 24", () => {
+ expect(resetHourFor(0)).toBe(CLAUDE_RESET_OFFSET_HOURS);
+ expect(resetHourFor(20)).toBe((20 + CLAUDE_RESET_OFFSET_HOURS) % 24);
+ expect(resetHourFor(23)).toBe((23 + CLAUDE_RESET_OFFSET_HOURS) % 24);
+ });
+
+ it("wraps cleanly across midnight", () => {
+ // Wake at 22:15, +5h = 03:00
+ expect(resetHourFor(22)).toBe(3);
+ });
+});
diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts
index 29448bc..93ec1f9 100644
--- a/packages/core/src/db/index.ts
+++ b/packages/core/src/db/index.ts
@@ -54,9 +54,28 @@ export function getDatabase(): Database {
updated_at INTEGER NOT NULL
)`);
+ // Wake schedule: 4 rows per marked hour (one per :00 / :15 / :30 / :45 probe
+ // slot). The PK is (hour, slot_minute). Destructive migration off the legacy
+ // single-row-per-hour schema: detect by absence of the `slot_minute` column
+ // and drop the old table. Other tables (credentials, api_keys, usage_cache,
+ // settings, tabs, chunks) are NOT touched.
+ const legacyWakeSchema = (() => {
+ try {
+ const cols = _db.query("PRAGMA table_info(wake_schedule)").all() as Array<{ name: string }>;
+ if (cols.length === 0) return false; // table doesn't exist yet
+ return !cols.some((c) => c.name === "slot_minute");
+ } catch {
+ return false;
+ }
+ })();
+ if (legacyWakeSchema) {
+ _db.run("DROP TABLE IF EXISTS wake_schedule");
+ }
_db.run(`CREATE TABLE IF NOT EXISTS wake_schedule (
- hour INTEGER PRIMARY KEY CHECK (hour BETWEEN 0 AND 23),
- next_wake_at INTEGER NOT NULL
+ 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)
)`);
_db.run(`CREATE TABLE IF NOT EXISTS usage_cache (
diff --git a/packages/frontend/src/lib/components/ClaudeReset.svelte b/packages/frontend/src/lib/components/ClaudeReset.svelte
index baddb73..eea8744 100644
--- a/packages/frontend/src/lib/components/ClaudeReset.svelte
+++ b/packages/frontend/src/lib/components/ClaudeReset.svelte
@@ -1,82 +1,204 @@
<script lang="ts">
+import { onDestroy } from "svelte";
+import { SnapshotSequencer } from "../snapshot-sequencer.js";
+
const { apiBase = "" }: { apiBase?: string } = $props();
-// Map of hour (0-23) → scheduled wake timestamp (ms)
-let schedule = $state<Record<number, number>>({});
+/** Fixed offset (hours) from a wake to the "Claude session reset" display.
+ * Mirrors the backend constant — kept in sync via the GET response. */
+const DEFAULT_RESET_OFFSET_HOURS = 5;
+const DEFAULT_PROBE_SLOT_MINUTES = [0, 15, 30, 45] as const;
+
+type ProbeMinute = number; // 0 | 15 | 30 | 45 in practice
+type HourSlots = Record<ProbeMinute, number>; // slot minute → next fire ms
+
+interface WakeResult {
+ label: string;
+ ok: boolean;
+ error?: string;
+}
+
+interface LastWake {
+ firedAt: number;
+ ok: boolean;
+ results: WakeResult[];
+}
+
+interface PendingRetry {
+ retriesLeft: number;
+ nextRetryAt: number;
+ reason: string;
+}
+
+interface ScheduleSnapshot {
+ /** hour (0-23) → { slotMinute → next fire ms }. */
+ schedule: Record<string, Record<string, number>>;
+ resetOffsetHours?: number;
+ probeSlotMinutes?: number[];
+ lastWake?: LastWake | null;
+ pendingRetry?: PendingRetry | null;
+}
+
+// Marked hours: hour → { slotMinute → next fire ms }. Empty inner record = not marked.
+let schedule = $state<Record<number, HourSlots>>({});
+let resetOffsetHours = $state<number>(DEFAULT_RESET_OFFSET_HOURS);
+let probeSlotMinutes = $state<readonly number[]>(DEFAULT_PROBE_SLOT_MINUTES);
+let lastWake = $state<LastWake | null>(null);
+let pendingRetry = $state<PendingRetry | null>(null);
+
+/**
+ * Global mutation lock: the hour whose toggle POST is currently in flight,
+ * or null if none. Disables ALL toggle buttons (not just this hour's) while
+ * any mutation is pending.
+ *
+ * Why global, not per-hour: snapshot responses can be reordered on the wire,
+ * but worse, requests themselves can be reordered. If two POSTs are in
+ * flight and the SERVER processes them out of order, the snapshot the
+ * SnapshotSequencer picks as "winner" (highest client-send seq) may not be
+ * the snapshot reflecting the truest server state — the UI desyncs from
+ * the server permanently. Serializing mutations on the client eliminates
+ * the reorder window entirely. (The sequencer is still useful for the
+ * GET-on-mount vs first-click race.)
+ */
+let pendingHour = $state<number | null>(null);
+
+/**
+ * Single global sequencer for ALL /models/wake-schedule responses (initial
+ * GET + every toggle POST). Each response is dropped if a newer request has
+ * already won. This protects against three races:
+ * 1. Two toggles on different hours land out of order — older snapshot
+ * blindly overwrites the newer one, and the most-recent click vanishes
+ * from the UI.
+ * 2. The initial loadFromServer is still in flight when the user clicks.
+ * 3. Any future fan-out (e.g. polling) racing a user action.
+ * A per-hour counter was insufficient because applySnapshot replaces the
+ * whole `schedule` object, not just one hour's slot. See snapshot-sequencer.ts.
+ */
+const sequencer = new SnapshotSequencer();
+
+/** Live "now" used for the current-hour ring + relative timestamps. */
+let nowMs = $state<number>(Date.now());
+
+const nowTimer = setInterval(() => {
+ nowMs = Date.now();
+}, 30_000);
+
+onDestroy(() => {
+ clearInterval(nowTimer);
+});
function formatHour(h: number): string {
const display = h % 12;
return display === 0 ? "12" : String(display);
}
-function nextOccurrenceAt15(hour: number): number {
- const now = new Date();
- const target = new Date(now);
- target.setHours(hour, 15, 0, 0);
+/**
+ * Compute the next occurrence of HH:MM in the user's local timezone.
+ * Today if still future, else tomorrow.
+ */
+function nextOccurrenceAt(hour: number, minute: number): number {
+ const target = new Date();
+ target.setHours(hour, minute, 0, 0);
if (target.getTime() <= Date.now()) {
target.setDate(target.getDate() + 1);
}
return target.getTime();
}
-async function loadFromServer(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/models/wake-schedule`);
- if (!res.ok) return;
- const data = (await res.json()) as { schedule: Record<string, number> };
- const parsed: Record<number, number> = {};
- for (const [k, v] of Object.entries(data.schedule)) {
- parsed[Number(k)] = v;
+function applySnapshot(data: ScheduleSnapshot): void {
+ const parsed: Record<number, HourSlots> = {};
+ for (const [hourStr, slots] of Object.entries(data.schedule ?? {})) {
+ const inner: HourSlots = {};
+ for (const [slotStr, ts] of Object.entries(slots ?? {})) {
+ inner[Number(slotStr)] = ts;
}
- schedule = parsed;
- } catch {
- // Network error — leave schedule empty
- }
-}
-
-async function parseScheduleResponse(res: Response): Promise<void> {
- const data = (await res.json()) as { schedule: Record<string, number> };
- const parsed: Record<number, number> = {};
- for (const [k, v] of Object.entries(data.schedule)) {
- parsed[Number(k)] = v;
+ parsed[Number(hourStr)] = inner;
}
schedule = parsed;
+ if (typeof data.resetOffsetHours === "number") {
+ resetOffsetHours = data.resetOffsetHours;
+ }
+ if (Array.isArray(data.probeSlotMinutes) && data.probeSlotMinutes.length > 0) {
+ probeSlotMinutes = data.probeSlotMinutes;
+ }
+ lastWake = data.lastWake ?? null;
+ pendingRetry = data.pendingRetry ?? null;
}
-async function toggleOnServer(hour: number, ts: number): Promise<void> {
+async function loadFromServer(): Promise<void> {
+ const mySeq = sequencer.begin();
try {
- const res = await fetch(`${apiBase}/models/wake-schedule/toggle`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ hour, timestamp: ts }),
- });
+ const res = await fetch(`${apiBase}/models/wake-schedule`);
if (!res.ok) return;
- await parseScheduleResponse(res);
+ const data = (await res.json()) as ScheduleSnapshot;
+ if (!sequencer.accept(mySeq)) return; // a newer response already won
+ applySnapshot(data);
} catch {
- // Network error — keep local state
+ // Network error — leave existing state
}
}
-async function removeFromServer(hour: number): Promise<void> {
+function setPending(hour: number | null): void {
+ pendingHour = hour;
+}
+
+async function postToggle(
+ hour: number,
+ action: "on" | "off",
+ timestamps?: Record<number, number>,
+): Promise<void> {
+ const mySeq = sequencer.begin();
+ setPending(hour);
+
try {
+ const body: {
+ hour: number;
+ action: "on" | "off";
+ timestamps?: Record<string, number>;
+ } = { hour, action };
+ if (timestamps) {
+ const stringKeyed: Record<string, number> = {};
+ for (const [k, v] of Object.entries(timestamps)) stringKeyed[k] = v;
+ body.timestamps = stringKeyed;
+ }
const res = await fetch(`${apiBase}/models/wake-schedule/toggle`, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ hour }),
+ body: JSON.stringify(body),
});
if (!res.ok) return;
- await parseScheduleResponse(res);
+ const data = (await res.json()) as ScheduleSnapshot;
+ // Drop stale snapshots — only the most-recent request wins for ALL
+ // shared state (schedule, lastWake, pendingRetry). Even with the
+ // global mutation lock, this still catches the GET-on-mount vs
+ // first-click race.
+ if (!sequencer.accept(mySeq)) return;
+ applySnapshot(data);
} catch {
- // Network error — keep local state
+ // Network error — leave local state alone; user can re-toggle.
+ } finally {
+ setPending(null);
}
}
function toggleHour(hour: number): void {
+ // Global lock: any pending mutation on ANY hour blocks new clicks. This
+ // serializes POSTs on the wire so the server never has to choose between
+ // two concurrent requests — eliminating the request-reorder failure mode
+ // where the sequencer's "highest client seq wins" rule would discard the
+ // snapshot reflecting the true server state.
+ if (pendingHour !== null) return;
if (schedule[hour] !== undefined) {
- void removeFromServer(hour);
+ // User intent: turn this hour OFF.
+ void postToggle(hour, "off");
} else {
- const ts = nextOccurrenceAt15(hour);
- void toggleOnServer(hour, ts);
+ // User intent: turn this hour ON — compute first occurrence of HH:MM
+ // for each probe slot, in the user's local timezone.
+ const timestamps: Record<number, number> = {};
+ for (const minute of probeSlotMinutes) {
+ timestamps[minute] = nextOccurrenceAt(hour, minute);
+ }
+ void postToggle(hour, "on", timestamps);
}
}
@@ -84,11 +206,16 @@ $effect(() => {
void loadFromServer();
});
-// Compute "faded" hours: the 4 hours after each scheduled block
-const fadedHours = $derived((): Set<number> => {
+/**
+ * Faded hours: the `resetOffsetHours - 1` hours immediately after each
+ * marked hour (the "active session window"). Excludes hours that are
+ * themselves marked.
+ */
+const fadedHours = $derived.by((): Set<number> => {
const result = new Set<number>();
+ const window = Math.max(0, resetOffsetHours - 1);
for (const h of Object.keys(schedule).map(Number)) {
- for (let i = 1; i <= 4; i++) {
+ for (let i = 1; i <= window; i++) {
const faded = (h + i) % 24;
if (schedule[faded] === undefined) {
result.add(faded);
@@ -98,17 +225,26 @@ const fadedHours = $derived((): Set<number> => {
return result;
});
-const currentHour = $derived(new Date().getHours());
+const currentHour = $derived(new Date(nowMs).getHours());
-function blockClass(hour: number): string {
- const isScheduled = schedule[hour] !== undefined;
+function blockClass(hour: number, faded: Set<number>): string {
+ const isMarked = schedule[hour] !== undefined;
const isCurrent = hour === currentHour;
- const isFaded = fadedHours().has(hour);
+ const isFaded = faded.has(hour);
+ // Only the hour whose request is in flight shows the "wait" cursor;
+ // other buttons are merely disabled (via the template `disabled={...}`).
+ const isPending = pendingHour === hour;
let base =
- "flex items-center justify-center rounded cursor-pointer select-none text-[10px] font-mono transition-colors";
+ "flex items-center justify-center rounded select-none text-[10px] font-mono transition-colors";
+
+ if (isPending) {
+ base += " opacity-60 cursor-wait";
+ } else {
+ base += " cursor-pointer";
+ }
- if (isScheduled) {
+ if (isMarked) {
base += " bg-primary text-primary-content";
} else if (isFaded) {
base += " bg-primary/25 text-base-content";
@@ -117,11 +253,7 @@ function blockClass(hour: number): string {
}
if (isCurrent) {
- if (isScheduled) {
- base += " ring-2 ring-accent ring-offset-1 ring-offset-base-200";
- } else {
- base += " ring-2 ring-accent ring-offset-1 ring-offset-base-200";
- }
+ base += " ring-2 ring-accent ring-offset-1 ring-offset-base-200";
}
return base;
@@ -134,15 +266,35 @@ function formatAmPm(hour24: number): string {
}
function resetHour(wakeHour: number): number {
- return (wakeHour + 5) % 24;
+ return (wakeHour + resetOffsetHours) % 24;
}
-const scheduledHours = $derived(
+function formatRelative(ts: number, now: number): string {
+ const diff = now - ts;
+ if (diff < 0) {
+ const ahead = -diff;
+ if (ahead < 60_000) return "in <1 min";
+ if (ahead < 3600_000) return `in ${Math.round(ahead / 60_000)} min`;
+ return `in ${Math.round(ahead / 3600_000)} h`;
+ }
+ if (diff < 60_000) return "just now";
+ if (diff < 3600_000) return `${Math.round(diff / 60_000)} min ago`;
+ if (diff < 86_400_000) return `${Math.round(diff / 3600_000)} h ago`;
+ return `${Math.round(diff / 86_400_000)} d ago`;
+}
+
+const markedHours = $derived(
Object.keys(schedule)
.map(Number)
- .sort((a, b) => (schedule[a] ?? 0) - (schedule[b] ?? 0)),
+ .sort((a, b) => a - b),
);
+function probeLabel(minute: number): string {
+ return `:${String(minute).padStart(2, "0")}`;
+}
+
+const probeLabels = $derived(probeSlotMinutes.map(probeLabel).join(" "));
+
const amRow1 = Array.from({ length: 6 }, (_, i) => i); // 0–5
const amRow2 = Array.from({ length: 6 }, (_, i) => i + 6); // 6–11
const pmRow1 = Array.from({ length: 6 }, (_, i) => i + 12); // 12–17
@@ -158,14 +310,14 @@ const pmRow2 = Array.from({ length: 6 }, (_, i) => i + 18); // 18–23
<div class="flex flex-col gap-0.5">
<div class="flex gap-0.5">
{#each amRow1 as hour}
- <button type="button" class="{blockClass(hour)} w-[22px] h-[24px]" onclick={() => toggleHour(hour)} title="{formatHour(hour)}:15 AM">
+ <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHour !== null} onclick={() => toggleHour(hour)} title="{formatHour(hour)} AM — probes at {probeLabels}">
{formatHour(hour)}
</button>
{/each}
</div>
<div class="flex gap-0.5">
{#each amRow2 as hour}
- <button type="button" class="{blockClass(hour)} w-[22px] h-[24px]" onclick={() => toggleHour(hour)} title="{formatHour(hour)}:15 AM">
+ <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHour !== null} onclick={() => toggleHour(hour)} title="{formatHour(hour)} AM — probes at {probeLabels}">
{formatHour(hour)}
</button>
{/each}
@@ -179,14 +331,14 @@ const pmRow2 = Array.from({ length: 6 }, (_, i) => i + 18); // 18–23
<div class="flex flex-col gap-0.5">
<div class="flex gap-0.5">
{#each pmRow1 as hour}
- <button type="button" class="{blockClass(hour)} w-[22px] h-[24px]" onclick={() => toggleHour(hour)} title="{formatHour(hour)}:15 PM">
+ <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHour !== null} onclick={() => toggleHour(hour)} title="{formatHour(hour)} PM — probes at {probeLabels}">
{formatHour(hour)}
</button>
{/each}
</div>
<div class="flex gap-0.5">
{#each pmRow2 as hour}
- <button type="button" class="{blockClass(hour)} w-[22px] h-[24px]" onclick={() => toggleHour(hour)} title="{formatHour(hour)}:15 PM">
+ <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHour !== null} onclick={() => toggleHour(hour)} title="{formatHour(hour)} PM — probes at {probeLabels}">
{formatHour(hour)}
</button>
{/each}
@@ -194,17 +346,30 @@ const pmRow2 = Array.from({ length: 6 }, (_, i) => i + 18); // 18–23
</div>
</div>
- <!-- Scheduled summary -->
- {#if scheduledHours.length > 0}
+ <!-- Marked hours summary -->
+ {#if markedHours.length > 0}
<div class="flex flex-col gap-0.5 mt-1">
- {#each scheduledHours as hour}
+ {#each markedHours as hour}
<div class="flex items-center gap-1.5 text-xs text-base-content/70">
- <span class="badge badge-xs badge-primary">{formatHour(hour)}:15</span>
- <span>Reset at {formatAmPm(resetHour(hour))}</span>
+ <span class="badge badge-xs badge-primary whitespace-nowrap shrink-0">{formatHour(hour)} {hour < 12 ? "AM" : "PM"}</span>
+ <span>Probes {probeLabels} → reset by {formatAmPm(resetHour(hour))}</span>
</div>
{/each}
</div>
{:else}
- <p class="text-xs text-base-content/40 italic">No wake times scheduled. Click a block to schedule.</p>
+ <p class="text-xs text-base-content/40 italic">No wake hours marked. Click a block to probe at {probeLabels} that hour.</p>
+ {/if}
+
+ <!-- Status: last wake / pending retry -->
+ {#if lastWake}
+ <div class="flex items-center gap-1.5 text-xs mt-1" class:text-success={lastWake.ok} class:text-error={!lastWake.ok}>
+ <span class="font-semibold">{lastWake.ok ? "✓" : "✗"}</span>
+ <span>Last wake {formatRelative(lastWake.firedAt, nowMs)}{lastWake.ok ? "" : ` — ${lastWake.results.find((r) => !r.ok)?.error ?? "failed"}`}</span>
+ </div>
+ {/if}
+ {#if pendingRetry}
+ <div class="text-xs text-warning">
+ Retrying ({pendingRetry.retriesLeft} left, next {formatRelative(pendingRetry.nextRetryAt, nowMs)})
+ </div>
{/if}
</div>
diff --git a/packages/frontend/src/lib/snapshot-sequencer.ts b/packages/frontend/src/lib/snapshot-sequencer.ts
new file mode 100644
index 0000000..fccc9ef
--- /dev/null
+++ b/packages/frontend/src/lib/snapshot-sequencer.ts
@@ -0,0 +1,47 @@
+/**
+ * Tiny race guard for "the most-recent request wins" semantics.
+ *
+ * When a frontend component fans out multiple HTTP calls that each return a
+ * full snapshot of shared state — and applying an older snapshot would clobber
+ * a newer one — wrap each call with `seq = sequencer.begin()` before send and
+ * `sequencer.accept(seq)` before applying the response. Older sequences are
+ * rejected.
+ *
+ * Why: the Claude Wake Schedule's POST /toggle and GET /wake-schedule both
+ * return the *whole* schedule. If a user toggles hour 9 (request A) and then
+ * hour 10 (request B), and B's response arrives before A's, the older A
+ * response — which doesn't know about hour 10 yet — would otherwise overwrite
+ * hour 10 right out of the UI. A per-hour counter is NOT enough because the
+ * race spans different hours (and also covers the initial-load vs first-click
+ * race).
+ *
+ * `>=` on accept is intentional: if seq equals the latest applied seq, the
+ * response is a redundant arrival of the most-recent winner — accepting it
+ * (idempotently) is fine. The discriminator is *strictly less than*.
+ */
+export class SnapshotSequencer {
+ private nextSeq = 0;
+ private latestApplied = 0;
+
+ /** Tag a new request. Call before sending; pass the returned seq to accept(). */
+ begin(): number {
+ this.nextSeq += 1;
+ return this.nextSeq;
+ }
+
+ /**
+ * Decide whether to apply a response. Returns true if this seq is the
+ * newest seen so far (and updates the watermark); false if a newer
+ * response has already won.
+ */
+ accept(seq: number): boolean {
+ if (seq < this.latestApplied) return false;
+ this.latestApplied = seq;
+ return true;
+ }
+
+ /** Inspect (for tests / debugging). */
+ get state(): { nextSeq: number; latestApplied: number } {
+ return { nextSeq: this.nextSeq, latestApplied: this.latestApplied };
+ }
+}
diff --git a/packages/frontend/tests/snapshot-sequencer.test.ts b/packages/frontend/tests/snapshot-sequencer.test.ts
new file mode 100644
index 0000000..f2c5b8e
--- /dev/null
+++ b/packages/frontend/tests/snapshot-sequencer.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from "vitest";
+import { SnapshotSequencer } from "../src/lib/snapshot-sequencer.js";
+
+describe("SnapshotSequencer", () => {
+ it("accepts the first response unconditionally", () => {
+ const s = new SnapshotSequencer();
+ const seq = s.begin();
+ expect(s.accept(seq)).toBe(true);
+ });
+
+ it("accepts responses in send order", () => {
+ const s = new SnapshotSequencer();
+ const a = s.begin();
+ const b = s.begin();
+ const c = s.begin();
+ expect(s.accept(a)).toBe(true);
+ expect(s.accept(b)).toBe(true);
+ expect(s.accept(c)).toBe(true);
+ });
+
+ it("rejects an older response that arrives AFTER a newer one (the core race)", () => {
+ // Sequence: user clicks hour 9 (A), then hour 10 (B). B arrives first.
+ const s = new SnapshotSequencer();
+ const a = s.begin(); // toggle hour 9
+ const b = s.begin(); // toggle hour 10
+
+ // B arrives first — applied.
+ expect(s.accept(b)).toBe(true);
+
+ // A arrives later — must be dropped, else the snapshot from B (which
+ // knows about both 9 and 10) gets overwritten by A's stale snapshot
+ // (which only knows about 9), and hour 10 vanishes from the UI.
+ expect(s.accept(a)).toBe(false);
+ });
+
+ it("rejects ALL straggler responses once a newer one wins", () => {
+ const s = new SnapshotSequencer();
+ const a = s.begin();
+ const b = s.begin();
+ const c = s.begin();
+ expect(s.accept(c)).toBe(true);
+ expect(s.accept(b)).toBe(false);
+ expect(s.accept(a)).toBe(false);
+ });
+
+ it("handles the initial-load vs first-click race", () => {
+ // On mount: $effect fires loadFromServer (seq=1).
+ // Before it lands, user clicks a hour (seq=2).
+ const s = new SnapshotSequencer();
+ const initial = s.begin();
+ const click = s.begin();
+
+ // Click response arrives first — applied.
+ expect(s.accept(click)).toBe(true);
+ // Initial load straggles in — must be dropped (it pre-dates the click).
+ expect(s.accept(initial)).toBe(false);
+ });
+
+ it("treats an equal seq as accept (idempotent re-arrival of the winner)", () => {
+ const s = new SnapshotSequencer();
+ const a = s.begin();
+ expect(s.accept(a)).toBe(true);
+ // Defensive: same seq accepted again (shouldn't happen in practice
+ // but the semantics must be 'no-op accept', not 'reject').
+ expect(s.accept(a)).toBe(true);
+ });
+
+ it("seq numbers are monotonic and unique across begin() calls", () => {
+ const s = new SnapshotSequencer();
+ const seen = new Set<number>();
+ let prev = 0;
+ for (let i = 0; i < 100; i++) {
+ const seq = s.begin();
+ expect(seq).toBeGreaterThan(prev);
+ expect(seen.has(seq)).toBe(false);
+ seen.add(seq);
+ prev = seq;
+ }
+ });
+
+ it("state inspector reflects last-applied watermark", () => {
+ const s = new SnapshotSequencer();
+ expect(s.state).toEqual({ nextSeq: 0, latestApplied: 0 });
+ const a = s.begin();
+ const b = s.begin();
+ s.accept(b);
+ expect(s.state).toEqual({ nextSeq: 2, latestApplied: b });
+ // A is too old now — accept() returns false and watermark doesn't move back.
+ expect(s.accept(a)).toBe(false);
+ expect(s.state.latestApplied).toBe(b);
+ });
+});