summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 09:53:12 +0900
committerAdam Malczewski <[email protected]>2026-06-01 09:53:12 +0900
commit1c800eb35b48b28a82a64e8b2b3ce666323f2c42 (patch)
treec26044a38f0099fbbe98680a02b73bc2417f1eb0 /packages/api/src
parentc351719ec81a1b87565abc0656756f4aa9e473d0 (diff)
downloaddispatch-1c800eb35b48b28a82a64e8b2b3ce666323f2c42.tar.gz
dispatch-1c800eb35b48b28a82a64e8b2b3ce666323f2c42.zip
feat(wake): probe 4 times per marked hour (:00 :15 :30 :45), coalesce same-tick fires
Marking an hour on the Claude Wake Schedule panel now schedules FOUR probes within that hour instead of one. Rate-window edges are unforgiving — a single probe at :15 can miss the actual reset moment by up to 14 minutes; hitting :00 / :15 / :30 / :45 puts us within ~7 minutes of any reset that happens during that hour. When multiple slots come due in the same 30s scheduler tick (or recover together at boot), they coalesce into a SINGLE upstream wake call — no point hitting Anthropic 4× in the same window. DB schema - wake_schedule is now (hour, slot_minute, next_wake_at) PK (hour, slot_minute). Destructive migration: detect old single-row-per-hour schema by absence of the slot_minute column and DROP TABLE. No other table is touched. Per user direction: no back-compat for old rows. API - POST /models/wake-schedule/toggle add: { hour, timestamps: { '0': ms, '15': ms, '30': ms, '45': ms } } — all 4 slots required, all must be future Unix ms. Delete shape unchanged ({ hour }). - GET /models/wake-schedule shape: schedule: { '9': { '0': ts, '15': ts, '30': ts, '45': ts }, ... } probeSlotMinutes: [0, 15, 30, 45] resetOffsetHours, lastWake, pendingRetry (unchanged from prior commit) Frontend - Computes 4 timestamps client-side (next occurrence of HH:MM in local TZ) and sends them in one request. - markedHours summary now says 'Probes :00 :15 :30 :45 → reset by ~Xh later'. - Same in-flight tracking / current-hour ring / status row as before. Tests - wake-scheduler.test.ts unchanged (pure helpers still correct; added PROBE_SLOT_MINUTES + isProbeSlotMinute exports). - routes.test.ts rewritten for the new payload shape: 12 wake-schedule tests covering snapshot shape, add/remove (full 4-slot round-trip), validation (range, integer, past-slot, missing slot, non-object, missing timestamps), independent multi-hour scheduling, and re-toggle replacement. 417 tests total (was 414).
Diffstat (limited to 'packages/api/src')
-rw-r--r--packages/api/src/routes/models.ts167
-rw-r--r--packages/api/src/wake-scheduler.ts41
2 files changed, 147 insertions, 61 deletions
diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts
index 4fc89eb..5f37a1f 100644
--- a/packages/api/src/routes/models.ts
+++ b/packages/api/src/routes/models.ts
@@ -23,7 +23,10 @@ import {
import { Hono } from "hono";
import {
CLAUDE_RESET_OFFSET_HOURS,
+ isProbeSlotMinute,
nextDailyAfter,
+ PROBE_SLOT_MINUTES,
+ type ProbeSlotMinute,
recoverScheduleEntry,
} from "../wake-scheduler.js";
@@ -609,8 +612,15 @@ 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 {
/** Remaining attempts. Starts at MAX_RETRIES (e.g. 6 → 30 min of retries). */
@@ -631,33 +641,43 @@ 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 countSlots(schedule: WakeSchedule): number {
+ let n = 0;
+ for (const slots of Object.values(schedule)) {
+ n += Object.keys(slots).length;
+ }
+ 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 = {};
const now = Date.now();
let needsPersist = false;
+ let anyShouldFire = false;
for (const row of rows) {
+ if (!isProbeSlotMinute(row.slot_minute)) continue; // defensive — schema CHECKs it
const recovered = recoverScheduleEntry(row.next_wake_at, now);
- schedule[row.hour] = recovered.nextWakeAt;
- if (recovered.nextWakeAt !== row.next_wake_at) {
- needsPersist = true;
- }
- if (recovered.shouldFireNow) {
- // Mark the entry as "due immediately" so the next tick fires it.
- // We accomplish this by setting next_wake_at = now; the tick will
- // see ts <= now, fire, and advance via nextDailyAfter().
- schedule[row.hour] = now;
- needsPersist = true;
- }
- }
- if (needsPersist) {
- persistSchedule(schedule);
+ 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 {};
@@ -670,16 +690,25 @@ function persistSchedule(scheduleToSave?: WakeSchedule): void {
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 });
+ 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,
+ });
+ }
}
} catch {
// Ignore DB errors — schedule still lives in-memory for this process.
}
}
+/** Set to true by loadScheduleFromDB when one or more slots need a boot fire. */
+let needsBootFire = false;
const wakeSchedule: WakeSchedule = loadScheduleFromDB();
/**
@@ -765,6 +794,27 @@ async function processPendingRetry(now: number): Promise<void> {
}
}
+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).
if (isTickRunning) return;
@@ -772,30 +822,35 @@ async function schedulerTick(): Promise<void> {
try {
const now = Date.now();
- // Snapshot hours so we don't iterate while mutating (toggle can race).
- const hours = Object.keys(wakeSchedule).map(Number);
- let anyFiredThisTick = false;
-
- for (const hour of hours) {
- const ts = wakeSchedule[hour];
- if (ts === undefined || ts > now) continue;
-
- // Advance the next fire to strictly > now (skips any missed days,
- // e.g. if the tick was paused for hours).
- wakeSchedule[hour] = nextDailyAfter(ts, now);
+ const due = collectDueSlots(now);
+
+ let firedThisTick = false;
+ if (due.length > 0 || needsBootFire) {
+ 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();
- anyFiredThisTick = true;
- await fireWake(`scheduled wake at hour ${hour}`);
+
+ const reasonParts = due.map((d) => `${d.hour}:${String(d.minute).padStart(2, "0")}`);
+ const reason =
+ reasonParts.length > 0 ? `scheduled probe(s) ${reasonParts.join(", ")}` : "boot recovery";
+ firedThisTick = true;
+ // COALESCED: one upstream call covers all slots due this tick.
+ await fireWake(reason);
}
// 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 (!anyFiredThisTick) {
+ if (!firedThisTick) {
await processPendingRetry(Date.now());
}
// Keep ticking while there's anything to monitor.
- if (Object.keys(wakeSchedule).length > 0 || pendingRetry !== null) {
+ if (countSlots(wakeSchedule) > 0 || pendingRetry !== null) {
(globalThis as Record<string, unknown>)[timerKey] = setTimeout(
schedulerTick,
TICK_INTERVAL_MS,
@@ -817,40 +872,56 @@ export function startWakeScheduler(): void {
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;
+ 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);
}
- // Integer-only; reject 4.7, NaN coercions, etc.
if (!Number.isInteger(hour)) {
return c.json({ error: "hour must be an integer 0-23" }, 400);
}
if (wakeSchedule[hour] !== undefined) {
- // Toggle off — remove the entry.
- delete wakeSchedule[hour];
+ // Toggle off — remove all 4 slots for this hour.
+ deleteHour(wakeSchedule, hour);
} else {
- // Toggle on — require a future absolute timestamp from the client. The
- // client is the source of truth for *local* wall-clock intent; the
- // server just stores the ms and rolls it forward by 24h cycles.
- const ts = body.timestamp;
- if (typeof ts !== "number" || !Number.isFinite(ts) || ts <= Date.now()) {
- return c.json({ error: "timestamp must be a future Unix ms value" }, 400);
+ // Toggle 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.
+ 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 now = Date.now();
+ const parsed: Partial<Record<ProbeSlotMinute, number>> = {};
+ for (const slot of PROBE_SLOT_MINUTES) {
+ const raw = (timestamps as Record<string, unknown>)[String(slot)];
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= now) {
+ return c.json({ error: `timestamps['${slot}'] must be a future Unix ms value` }, 400);
+ }
+ parsed[slot] = raw;
}
- wakeSchedule[hour] = ts;
+ wakeSchedule[hour] = parsed;
}
persistSchedule();
diff --git a/packages/api/src/wake-scheduler.ts b/packages/api/src/wake-scheduler.ts
index 7bd2fad..8953e9f 100644
--- a/packages/api/src/wake-scheduler.ts
+++ b/packages/api/src/wake-scheduler.ts
@@ -5,24 +5,30 @@
*
* Semantics — read this before editing:
*
- * 1. The user picks an hour (0-23) on the frontend. The frontend computes
- * the *first* fire timestamp in **its** local timezone (target HH:15
- * tomorrow, or today if still future) and sends absolute Unix ms to
- * the backend. That absolute ms is the source of truth.
+ * 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 successful (or attempted) fire we advance by exactly
+ * 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 (Docker is often UTC, the user is often not). DST can
- * drift the fire by ±1h on transition day; it self-corrects the next
- * time the user toggles the hour.
+ * 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 entry whose `next_wake_at` is in the
+ * 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 *now* (signal: `shouldFireNow = true`) and then jump
- * forward 24h at a time until the next slot is in the future. If
- * missed by more than the grace window we silently skip and jump
- * forward without firing. Either way the entry stays scheduled.
+ * 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. */
@@ -34,6 +40,10 @@ 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.
@@ -80,3 +90,8 @@ export function recoverScheduleEntry(
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;
+}