1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
|
# 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.
|