summaryrefslogtreecommitdiffhomepage
path: root/HANDOFF.md
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 11:44:27 +0900
committerAdam Malczewski <[email protected]>2026-06-01 11:44:27 +0900
commit0a5eea4c06371df756aea40f53bb6dbe71df664a (patch)
tree443e454e1edf1814f1a5c8e77507f63812739122 /HANDOFF.md
parent00922f6136ff0c6e047bb4a6165682f236971450 (diff)
parent03e58f69e77b7a27e235210158f3f8e499a817c3 (diff)
downloaddispatch-0a5eea4c06371df756aea40f53bb6dbe71df664a.tar.gz
dispatch-0a5eea4c06371df756aea40f53bb6dbe71df664a.zip
merge: dev into r1/claude-reset-fix
Brings in the n2/ntfy-notifications feature (ntfy.sh push notifications with per-event toggles, subagent-suppression flag, topic-only input, Settings UI, dispatcher + transport + config modules, 12+ new tests), the header declutter (theme picker + Debug panel moved into Settings / sidebar), the shared theme boot-apply module, and an a11y label for the remove-panel button. No code changes from this branch were touched by the merge — the overlap was purely textual. Conflict resolution: 1. HANDOFF.md (add/add conflict). Both branches independently put a single-purpose HANDOFF.md at the repo root for their respective in-flight feature, matching the existing convention (c351719 did the same for this branch; 29bdd00 did the same for ntfy). After this merge both features ship, so neither is in-flight anymore. Archive both into notes/: - notes/wake-schedule-handoff.md (this branch — git tracks as a rename from HANDOFF.md) - notes/ntfy-notifications-handoff.md (dev — recovered from MERGE_HEAD before deletion) The root HANDOFF.md is intentionally absent post-merge; the next in-flight branch will create its own. 2. packages/api/tests/routes.test.ts (auto-merged). dev appended ntfy stubs to the vi.mock('@dispatch/core', ...) factory; this branch appended a 'Wake schedule routes' describe block at the bottom. The two regions don't overlap and the textual auto-merge is correct (verified: 6 describe blocks, both mock-stub regions and the new describe present, no conflict markers). Verification on the merge commit: bun run test → 31 files, 495 / 495 passing (was 431 on the branch + 64 from dev) bun run check → biome clean, 156 files bun run --cwd packages/frontend typecheck → svelte-check 0 errors, 0 warnings dev can now fast-forward to this commit: git checkout dev && git merge --ff-only r1/claude-reset-fix
Diffstat (limited to 'HANDOFF.md')
-rw-r--r--HANDOFF.md442
1 files changed, 0 insertions, 442 deletions
diff --git a/HANDOFF.md b/HANDOFF.md
deleted file mode 100644
index 3ea711a..0000000
--- a/HANDOFF.md
+++ /dev/null
@@ -1,442 +0,0 @@
-# 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.