diff options
| -rw-r--r-- | notes/claude-reset-review-2.md | 48 | ||||
| -rw-r--r-- | notes/claude-reset-review.md | 48 | ||||
| -rw-r--r-- | notes/ntfy-notifications-handoff.md (renamed from HANDOFF.md) | 0 | ||||
| -rw-r--r-- | notes/wake-schedule-handoff.md | 442 | ||||
| -rw-r--r-- | packages/api/src/routes/models.ts | 394 | ||||
| -rw-r--r-- | packages/api/src/wake-scheduler.ts | 97 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 304 | ||||
| -rw-r--r-- | packages/api/tests/wake-scheduler.test.ts | 98 | ||||
| -rw-r--r-- | packages/core/src/db/index.ts | 23 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ClaudeReset.svelte | 299 | ||||
| -rw-r--r-- | packages/frontend/src/lib/snapshot-sequencer.ts | 47 | ||||
| -rw-r--r-- | packages/frontend/tests/snapshot-sequencer.test.ts | 92 |
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); + }); +}); |
