From 1f309ccca20aabbd0ee3fb8fbb3c8192124edd95 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 21 May 2026 15:44:00 +0900 Subject: fix: wake scheduler persistence/retry, credential filtering, usage cache and display names - Wake scheduler: fix Bun timer leak, make recurring daily, persist to disk, retry failed wakes every 5min for 30min, start at boot - Key usage: localStorage cache survives page refresh, spinner during all refreshes, show cached data immediately - Credential filtering: key-usage and wake only use configured credentials_file, exclude unconfigured accounts - Display: remove counter suffix from Claude labels, format opencode/copilot key names --- dispatch.toml | 2 +- packages/api/src/app.ts | 5 +- packages/api/src/routes/models.ts | 178 ++++++++++++++++++--- packages/core/src/credentials/claude.ts | 6 +- .../frontend/src/lib/components/KeyUsage.svelte | 116 ++++++++++---- .../src/lib/components/SidebarPanel.svelte | 15 +- 6 files changed, 260 insertions(+), 62 deletions(-) diff --git a/dispatch.toml b/dispatch.toml index 6fcf964..7e6fa5a 100644 --- a/dispatch.toml +++ b/dispatch.toml @@ -14,7 +14,7 @@ fallback = ["claude-pro", "claude-max", "opencode-1", "opencode-2", "copilot"] id = "claude-pro" provider = "anthropic" base_url = "https://api.anthropic.com/v1" -# Reads from ~/.claude/.credentials.json (default) +credentials_file = "/root/.claude/.credentials-1.json" [[keys]] id = "claude-max" diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 3591281..ce26aaa 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -4,7 +4,7 @@ import { AgentManager } from "./agent-manager.js"; import { PermissionManager } from "./permission-manager.js"; import { configRoutes } from "./routes/config.js"; import { skillsRoutes } from "./routes/skills.js"; -import { modelsRoutes } from "./routes/models.js"; +import { modelsRoutes, startWakeScheduler } from "./routes/models.js"; export const permissionManager = new PermissionManager(); export const agentManager = new AgentManager(permissionManager); @@ -60,3 +60,6 @@ app.post("/chat", async (c) => { app.route("/config", configRoutes); app.route("/skills", skillsRoutes); app.route("/models", modelsRoutes); + +// Start the wake scheduler on boot (restores persisted schedule) +startWakeScheduler(); diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts index f29d236..2f11268 100644 --- a/packages/api/src/routes/models.ts +++ b/packages/api/src/routes/models.ts @@ -1,3 +1,5 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import type { ModelRegistry, ModelResolver } from "@dispatch/core"; import { ANTHROPIC_MODELS_FALLBACK, @@ -259,11 +261,16 @@ modelsRoutes.get("/key-usage", async (c) => { try { if (provider === "anthropic") { - const accounts = discoverClaudeAccounts(); + const allAccounts = discoverClaudeAccounts(); + const credFile = key.definition.credentials_file; + // Only show the account matching this key's credentials_file + const accounts = credFile + ? allAccounts.filter((a) => a.source === credFile) + : allAccounts.slice(0, 1); // no credentials_file → default account only if (accounts.length === 0) { return c.json({ error: "no Claude accounts available" }, 502); } - // Fetch usage for all discovered accounts + // Fetch usage for matched accounts const accountResults = await Promise.all( accounts.map(async (acct) => { const report = await getAccountUsage(acct); @@ -341,7 +348,25 @@ modelsRoutes.get("/key-usage", async (c) => { async function wakeAllClaudeAccounts(): Promise< Array<{ label: string; ok: boolean; error?: string }> > { - const accounts = discoverClaudeAccounts(); + // Only wake accounts referenced by configured anthropic keys + const allAccounts = discoverClaudeAccounts(); + const registry = getRegistry(); + const configuredCredFiles = new Set(); + if (registry) { + for (const ks of registry.getKeys()) { + if (ks.definition.provider === "anthropic") { + if (ks.definition.credentials_file) { + configuredCredFiles.add(ks.definition.credentials_file); + } else if (allAccounts[0]) { + // Key without explicit credentials_file uses default account + configuredCredFiles.add(allAccounts[0].source); + } + } + } + } + const accounts = configuredCredFiles.size > 0 + ? allAccounts.filter((a) => configuredCredFiles.has(a.source)) + : allAccounts; if (accounts.length === 0) { return [{ label: "(none)", ok: false, error: "no Claude accounts available" }]; } @@ -389,37 +414,147 @@ modelsRoutes.post("/wake", async (c) => { // ─── Wake scheduler (runs on backend, survives frontend close) ─ -type WakeSchedule = Record; // hour → target timestamp (ms) +type WakeSchedule = Record; // hour → next wake timestamp (ms) -let wakeSchedule: WakeSchedule = {}; +interface PendingRetry { + retriesLeft: number; // starts at 6 (5 min × 6 = 30 min) + nextRetryAt: number; // timestamp for next retry attempt +} + +const SCHEDULE_FILE = join(process.cwd(), ".wake-schedule.json"); + +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); + } + return target.getTime(); +} + +function loadScheduleFromDisk(): WakeSchedule { + try { + if (existsSync(SCHEDULE_FILE)) { + const raw = readFileSync(SCHEDULE_FILE, "utf-8"); + const parsed = JSON.parse(raw) as Record; + const schedule: WakeSchedule = {}; + let needsPersist = false; + for (const [key, value] of Object.entries(parsed)) { + const hour = Number(key); + if (value > Date.now()) { + schedule[hour] = value; + } else { + // Timestamp has passed — recompute for next occurrence + schedule[hour] = nextOccurrenceAt15(hour); + needsPersist = true; + } + } + if (needsPersist) { + try { + writeFileSync(SCHEDULE_FILE, JSON.stringify(schedule), "utf-8"); + } catch { + // Ignore write errors + } + } + return schedule; + } + } catch { + // File doesn't exist or is corrupt — start fresh + } + return {}; +} + +function persistSchedule(): void { + try { + writeFileSync(SCHEDULE_FILE, JSON.stringify(wakeSchedule), "utf-8"); + } catch { + // Ignore write errors + } +} + +let wakeSchedule: WakeSchedule = loadScheduleFromDisk(); +let pendingRetries: PendingRetry[] = []; // HMR-safe: clear previous tick before starting a new one (globalThis as Record)._dispatchWakeTimer ??= undefined; const timerKey = "_dispatchWakeTimer"; +let isTickRunning = false; async function schedulerTick(): Promise { - 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) { - // Delete BEFORE wake to prevent duplicate triggers - delete wakeSchedule[hour]; - wakeAllClaudeAccounts().catch(() => {}); + // 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, + }); + } + } } - } - // Schedule next tick - if (Object.keys(wakeSchedule).length > 0) { - (globalThis as Record)[timerKey] = setTimeout(schedulerTick, 30_000); + // 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; + } + } + } + + // Schedule next tick while there's work to monitor + if (Object.keys(wakeSchedule).length > 0 || pendingRetries.length > 0) { + (globalThis as Record)[timerKey] = setTimeout(schedulerTick, 30_000); + } + } finally { + isTickRunning = false; } } export function startWakeScheduler(): void { - // Clear any previous interval (HMR-safe) + // Clear any previous timer (HMR-safe — works with Bun's Timer objects) const prev = (globalThis as Record)[timerKey]; - if (typeof prev === "number") clearTimeout(prev); + if (prev != null) clearTimeout(prev as ReturnType); schedulerTick(); } @@ -442,7 +577,8 @@ modelsRoutes.post("/wake-schedule/toggle", async (c) => { wakeSchedule[hour] = ts; } - // Restart the tick loop (handles empty → non-empty or vice versa) + // Persist and restart the tick loop + persistSchedule(); startWakeScheduler(); return c.json({ schedule: wakeSchedule }); diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts index e4cc1f9..6639afc 100644 --- a/packages/core/src/credentials/claude.ts +++ b/packages/core/src/credentials/claude.ts @@ -137,14 +137,10 @@ async function refreshViaOAuth(refreshToken: string): Promise(); for (const acct of accounts) { - const base = acct.credentials.subscriptionType + acct.label = acct.credentials.subscriptionType ? `Claude ${acct.credentials.subscriptionType.charAt(0).toUpperCase() + acct.credentials.subscriptionType.slice(1)}` : "Claude"; - const count = (counts.get(base) ?? 0) + 1; - counts.set(base, count); - acct.label = count > 1 ? `${base} ${count}` : base; } } diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte index 5e86f36..a2b735e 100644 --- a/packages/frontend/src/lib/components/KeyUsage.svelte +++ b/packages/frontend/src/lib/components/KeyUsage.svelte @@ -17,8 +17,45 @@ loading: boolean; } - // Module-level cache survives remounts during the same page session - const usageCache = new Map(); + // localStorage-backed cache: survives page refreshes + const CACHE_STORAGE_KEY = "dispatch-key-usage-cache"; + + function loadPersistedCache(): Map { + try { + const raw = localStorage.getItem(CACHE_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as Record; + return new Map(Object.entries(parsed)); + } + } catch { + // Ignore parse errors + } + return new Map(); + } + + function persistCache(cache: Map): void { + try { + const obj = Object.fromEntries(cache.entries()); + localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(obj)); + } catch { + // Ignore storage errors (e.g. quota exceeded) + } + } + + const usageCache = loadPersistedCache(); + + function buildEntries(keyList: KeyInfo[]): KeyUsageEntry[] { + return keyList.map((k) => { + const cached = usageCache.get(k.id); + return { + keyId: k.id, + provider: k.provider, + data: cached ?? null, + error: null, + loading: true, // always show spinner during refresh + }; + }); + } let entries = $state([]); @@ -36,6 +73,7 @@ } else { const fresh = await res.json() as KeyUsageData; usageCache.set(key.id, fresh); + persistCache(usageCache); updateEntry(key.id, { data: fresh, error: null, @@ -59,26 +97,23 @@ ); } + // Sync entries with keys reactively — runs before DOM update so + // cached data renders on first paint without a flash of empty state. + $effect.pre(() => { + entries = buildEntries(keys); + }); + + // Fetch data and set up 90s auto-refresh $effect(() => { - // Show cached data immediately if available, then refresh in background - entries = keys.map((k) => { - const cached = usageCache.get(k.id); - return { - keyId: k.id, - provider: k.provider, - data: cached ?? null, - error: null, - loading: true, - }; - }); + const currentKeys = keys; // Fire all fetches in parallel - for (const key of keys) { + for (const key of currentKeys) { fetchOne(key); } // Refresh every 90s const interval = setInterval(() => { - for (const key of keys) { + for (const key of currentKeys) { updateEntry(key.id, { loading: true }); fetchOne(key); } @@ -115,6 +150,9 @@ const claudeLoading = $derived( entries.some((e) => e.provider === "anthropic" && e.loading), ); + const claudeError = $derived( + entries.find((e) => e.provider === "anthropic" && e.error)?.error ?? null, + ); const nonClaudeEntries = $derived( entries.filter((e) => e.provider !== "anthropic"), @@ -160,6 +198,14 @@ }); } + function formatKeyId(id: string): string { + if (/^github-copilot/i.test(id)) return "Copilot"; + return id.split("-").map((part) => { + if (part.toLowerCase() === "opencode") return "OpenCode"; + return part.charAt(0).toUpperCase() + part.slice(1); + }).join(" "); + } + function hasBucketData(bucket: UsageBucket | undefined): boolean { return bucket !== undefined && bucket.utilization !== undefined; } @@ -171,7 +217,7 @@ {:else}
- {#if claudeAccounts.length > 0 || claudeLoading} + {#if claudeAccounts.length > 0 || claudeLoading || claudeError}
Claude @@ -181,12 +227,17 @@ {/if}
- {#if claudeAccounts.length === 0 && claudeLoading} -
- Loading... -
- {:else} - {#each claudeAccounts as acct, idx (acct.source)} + {#if claudeAccounts.length === 0 && claudeLoading} +
+ Loading... +
+ {:else if claudeAccounts.length === 0 && claudeError} + + {:else} + {#if claudeError} + + {/if} + {#each claudeAccounts as acct, idx (acct.source)} {#if idx > 0}
{/if} @@ -240,26 +291,26 @@ {#each nonClaudeEntries as entry (entry.keyId)}
- {entry.keyId} + {formatKeyId(entry.keyId)} {entry.provider} {#if entry.loading} {/if}
- {#if entry.loading} + {#if entry.loading && !entry.data}
Loading...
+ {:else} + {#if entry.error} + + {/if} + {#if !entry.data} +

No data.

- {:else if entry.error} - - - {:else if !entry.data} -

No data.

- - {:else if entry.data.provider === "opencode-go"} + {:else if entry.data.provider === "opencode-go"} {#if entry.data.unavailable}

Check console for usage.

{#if entry.data.consoleUrl} @@ -335,7 +386,8 @@ Resets: {formatDate(entry.data.resetAt)} {/if}
- {/if} + {/if} + {/if}
{/each}
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte index aebc74a..6edbdbf 100644 --- a/packages/frontend/src/lib/components/SidebarPanel.svelte +++ b/packages/frontend/src/lib/components/SidebarPanel.svelte @@ -50,11 +50,22 @@ function addPanel() { panels = [...panels, { id: nextId++, selected: "Select a view" }]; } + + function panelClass(selected: string): string { + const base = "bg-base-200 rounded-lg p-3 flex flex-col min-h-0"; + const fill = selected === "Key Usage" || selected === "Claude Reset" || selected === "Tasks"; + return fill ? base + " flex-1" : base; + } + + function contentClass(selected: string): string { + const fill = selected === "Key Usage" || selected === "Claude Reset" || selected === "Tasks"; + return fill ? "mt-2 flex-1 min-h-0" : "mt-2"; + }
{#each panels as panel, idx (panel.id)} -
+