Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 8208cf8

Browse files
authored
Quiet routine pricing warnings + menubar recovery from stuck-loading (#266)
* Quiet routine pricing warnings + menubar recovery from stuck-loading CLI: - Default `codeburn` invocation no longer prints "no pricing data for model" warnings on every run. Greeting a fresh user with three lines of stderr before the dashboard even draws looked like the tool was broken on first launch. The warning now requires --verbose, and the suppressed pricing miss still results in $0 cost (correct for unmapped models). - Local-model heuristic skips the warning entirely for Ollama tags (`qwen3.6:35b-a3b-bf16`), GGUF/quantized fingerprints, and similar names that will never have public pricing. The "update codeburn" hint was actively misleading there. - When the warning does fire (with --verbose), it points users at `codeburn model-alias <model> <known-model>` as the actual escape hatch alongside the package update suggestion. Menubar: - Replace perpetual "Loading…" spinner with a FetchErrorOverlay when the per-key fetch fails and the cache is empty. User sees the error and a Retry button instead of an infinite hang. - Add diagnostic breadcrumbs (NSLog, invisible to normal users — Console.app / `log stream --process CodeBurnMenubar` only) for the four states that produce a stuck loading overlay: - subprocess timeout after 45s - fetch result dropped due to Task cancellation (rapid tab switch) - fetch result dropped due to mid-fetch calendar rollover - retry attempt where the last successful fetch is >2 min stale - Track lastSuccessByKey separately from cache freshness so the staleness diagnostic survives day-rollover cache wipes. * Stop flashing the compare-view loading screen on background refresh When the 30s CLI tick updated `projects` while the user was reading the model comparison results, the projects-watching effect always fired setLoadTrigger, which flipped phase to 'loading' and re-ran the slow scanSelfCorrections walk over every provider's session directory. The user lost their scroll position and saw a loading flash mid-read. Recompute the comparison rows in place when: - the user is already on the results phase, AND - both picked models still exist in the new aggregate. Skip the corrections rescan on these in-place refreshes — corrections drift slowly enough that holding the previous value until the user re-enters compare is acceptable, and the rescan is the slow part of the load. Initial selection and post-selection load still run the full pipeline.
1 parent eafc8eb commit 8208cf8

5 files changed

Lines changed: 165 additions & 24 deletions

File tree

mac/Sources/CodeBurnMenubar/AppStore.swift

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ final class AppStore {
4646
private var cache: [PayloadCacheKey: CachedPayload] = [:]
4747
private var cacheDate: String = ""
4848
private var switchTask: Task<Void, Never>?
49+
/// Tracks the last successful fetch timestamp per key for stuck-loading
50+
/// diagnostics. NOT used for cache-freshness logic — `CachedPayload.fetchedAt`
51+
/// is authoritative there. This map persists across cache wipes (day
52+
/// rollover, etc.) so we can distinguish "fresh install, never fetched"
53+
/// from "cache was wiped 10 minutes ago and we still haven't refilled".
54+
private var lastSuccessByKey: [PayloadCacheKey: Date] = [:]
55+
56+
private func staleSecondsForKey(_ key: PayloadCacheKey) -> TimeInterval {
57+
guard let last = lastSuccessByKey[key] else { return .infinity }
58+
return Date().timeIntervalSince(last)
59+
}
4960

5061
private var currentKey: PayloadCacheKey {
5162
PayloadCacheKey(period: selectedPeriod, provider: selectedProvider)
@@ -148,19 +159,41 @@ final class AppStore {
148159
if didShowLoading {
149160
loadingCount += 1
150161
}
162+
// Diagnostic anchor: if this key has been empty for a long time (the
163+
// popover would currently be showing "Loading..."), log how stale the
164+
// miss is so the next time a user reports a stuck-loading bug we have
165+
// a concrete data point — "no successful fetch for (today, claude)
166+
// in 14 minutes" beats squinting at unified-log noise. We deliberately
167+
// skip the first-attempt case (no prior success ever, finite check
168+
// below filters .infinity) — that's just the cold path, not a bug.
169+
let staleSeconds = staleSecondsForKey(key)
170+
if staleSeconds.isFinite, staleSeconds > 120 {
171+
NSLog("CodeBurn: refresh attempt for stale key \(key.period.rawValue)/\(key.provider.rawValue) — last success was \(Int(staleSeconds))s ago")
172+
}
151173
defer {
152174
inFlightKeys.remove(key)
153175
if didShowLoading { loadingCount = max(loadingCount - 1, 0) }
154176
}
155177
do {
156178
let fresh = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: includeOptimize)
157-
guard !Task.isCancelled else { return }
179+
if Task.isCancelled {
180+
// Distinguish cancellation (user switched tabs mid-fetch) from
181+
// the silent-no-result path. Without this log, a cancelled
182+
// fetch leaves cache empty + lastError nil and the user sees
183+
// perpetual loading with nothing in the diagnostics.
184+
NSLog("CodeBurn: fetch for \(key.period.rawValue)/\(key.provider.rawValue) cancelled before result was applied")
185+
return
186+
}
158187
// Day-rollover race guard: if the calendar date changed during the
159188
// fetch, this payload was computed against yesterday's date and
160189
// would pollute today's freshly-cleared cache. Drop it; the next
161190
// tick will refetch with today's data.
162-
if cacheDate != cacheDateAtStart { return }
191+
if cacheDate != cacheDateAtStart {
192+
NSLog("CodeBurn: dropping fetch result for \(key.period.rawValue)/\(key.provider.rawValue) — calendar rolled mid-fetch")
193+
return
194+
}
163195
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
196+
lastSuccessByKey[key] = Date()
164197
lastError = nil
165198
} catch {
166199
if Task.isCancelled { return }
@@ -171,6 +204,7 @@ final class AppStore {
171204
guard !Task.isCancelled else { return }
172205
if cacheDate != cacheDateAtStart { return }
173206
cache[key] = CachedPayload(payload: fallback, fetchedAt: Date())
207+
lastSuccessByKey[key] = Date()
174208
lastError = nil
175209
return
176210
} catch {

mac/Sources/CodeBurnMenubar/Data/DataClient.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,16 @@ struct DataClient {
6262
}
6363

6464
// Wall-clock timeout: if the CLI hangs (parser stuck, disk stall), kill it.
65+
// Log when this fires so a recurring stuck-popover state has an actual
66+
// diagnostic — historically users saw "Loading..." forever with no signal
67+
// about what failed; the only way to debug was to read process state at
68+
// the wrong time. The log line names the subcommand so we can correlate
69+
// with a specific period/provider combination.
6570
let timeoutTask = Task.detached(priority: .utility) {
6671
try? await Task.sleep(nanoseconds: spawnTimeoutSeconds * 1_000_000_000)
6772
if process.isRunning {
73+
NSLog("CodeBurn: CLI subprocess timed out after %llus for %@ — terminating",
74+
spawnTimeoutSeconds, subcommand.joined(separator: " "))
6875
process.terminate()
6976
}
7077
}

mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,21 @@ struct MenuBarContent: View {
4343

4444
// Overlay fires only on cold cache for the current key. This
4545
// avoids the 1-frame `$0.00` flash on first-time period/provider
46-
// switches (the body would otherwise render the empty payload
47-
// for the runloop tick before the overlay slides in). With the
48-
// cache no longer being wiped on every wake/manual-refresh,
49-
// hasCachedData==false now means "we have never fetched this
50-
// key before in this session", which is the right time to
51-
// cover the popover.
46+
// switches. When the fetch fails (CLI subprocess timeout, parse
47+
// error, etc.), surface a retry card instead of leaving the
48+
// user stuck on a perpetual "Loading..." spinner.
5249
if !store.hasCachedData {
53-
BurnLoadingOverlay(periodLabel: store.selectedPeriod.rawValue)
50+
if let err = store.lastError, !store.isLoading {
51+
FetchErrorOverlay(
52+
error: err,
53+
periodLabel: store.selectedPeriod.rawValue,
54+
retry: { Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } }
55+
)
5456
.transition(.opacity)
57+
} else {
58+
BurnLoadingOverlay(periodLabel: store.selectedPeriod.rawValue)
59+
.transition(.opacity)
60+
}
5561
}
5662
}
5763
.frame(height: 520)
@@ -126,6 +132,49 @@ private struct EmptyProviderState: View {
126132
}
127133
}
128134

135+
/// Shown when a fetch failed and the cache is still empty for this key. The
136+
/// user previously sat on the "Loading…" spinner forever — the popover had
137+
/// no path to recover beyond the next 30s tick (which would just re-fail).
138+
/// Now they see what broke and can retry directly.
139+
private struct FetchErrorOverlay: View {
140+
let error: String
141+
let periodLabel: String
142+
let retry: () -> Void
143+
144+
var body: some View {
145+
ZStack {
146+
Rectangle().fill(.ultraThinMaterial)
147+
VStack(spacing: 12) {
148+
Image(systemName: "exclamationmark.triangle.fill")
149+
.font(.system(size: 28))
150+
.foregroundStyle(Theme.brandAccent)
151+
Text("Couldn't load \(periodLabel)")
152+
.font(.system(size: 12.5, weight: .semibold))
153+
.foregroundStyle(.primary)
154+
Text(displayError)
155+
.font(.system(size: 10.5))
156+
.foregroundStyle(.secondary)
157+
.multilineTextAlignment(.center)
158+
.frame(maxWidth: 280)
159+
.lineLimit(3)
160+
Button("Retry", action: retry)
161+
.buttonStyle(.borderedProminent)
162+
.tint(Theme.brandAccent)
163+
.controlSize(.small)
164+
}
165+
.padding(.horizontal, 20)
166+
}
167+
}
168+
169+
/// Strip the leading subprocess noise that creeps into NSError descriptions
170+
/// so the visible message is the actual cause, not the framework wrapper.
171+
private var displayError: String {
172+
let trimmed = error.trimmingCharacters(in: .whitespacesAndNewlines)
173+
if trimmed.count <= 240 { return trimmed }
174+
return String(trimmed.prefix(240)) + ""
175+
}
176+
}
177+
129178
/// Translucent overlay that blurs whatever's behind it (the previous tab/period content)
130179
/// and centers an animated burning flame -- the brand mark filling up bottom-to-top in
131180
/// yellow→orange→red, looping.

src/compare.tsx

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -331,16 +331,40 @@ export function CompareView({ projects, onBack }: CompareViewProps) {
331331
const newModels = aggregateModelStats(projects)
332332
setModels(newModels)
333333

334-
if (pickedNames) {
335-
const hasA = newModels.some(m => m.model === pickedNames[0])
336-
const hasB = newModels.some(m => m.model === pickedNames[1])
337-
if (hasA && hasB) {
338-
setLoadTrigger(t => t + 1)
339-
} else {
340-
setPickedNames(null)
341-
setPhase('select')
342-
}
334+
if (!pickedNames) return
335+
const hasA = newModels.some(m => m.model === pickedNames[0])
336+
const hasB = newModels.some(m => m.model === pickedNames[1])
337+
if (!hasA || !hasB) {
338+
setPickedNames(null)
339+
setPhase('select')
340+
return
341+
}
342+
343+
// When the periodic CLI refresh updates `projects` while the user is
344+
// reading the results page, recompute the comparison rows IN PLACE rather
345+
// than flipping to a loading screen. Previously every 30s tick bounced the
346+
// user to a loading flash and reset their scroll position; the slow part
347+
// (scanSelfCorrections, which walks every provider's session dir) is
348+
// skipped on these refreshes — corrections drift slowly enough that
349+
// staying with the existing values until the user re-enters compare from
350+
// scratch is fine.
351+
if (phase === 'results') {
352+
const a = newModels.find(m => m.model === pickedNames[0])
353+
const b = newModels.find(m => m.model === pickedNames[1])
354+
if (!a || !b) return
355+
const aCopy = { ...a, selfCorrections: selectedA?.selfCorrections ?? 0 }
356+
const bCopy = { ...b, selfCorrections: selectedB?.selfCorrections ?? 0 }
357+
setSelectedA(aCopy)
358+
setSelectedB(bCopy)
359+
setRows(computeComparison(aCopy, bCopy))
360+
setCategories(computeCategoryComparison(projects, a.model, b.model))
361+
setStyle(computeWorkingStyle(projects, a.model, b.model))
362+
return
343363
}
364+
365+
// Initial load (or returning from select after picking) — full pipeline,
366+
// including scanSelfCorrections.
367+
setLoadTrigger(t => t + 1)
344368
}, [projects])
345369

346370
useEffect(() => {

src/models.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,36 @@ export function getModelCosts(model: string): ModelCosts | null {
235235
// session that used it, hiding real spend until the user noticed.
236236
const warnedUnknownModels = new Set<string>()
237237

238+
/// Heuristic for "this looks like a local model that will never be in LiteLLM's
239+
/// pricing JSON". We suppress the unknown-model warning for these because the
240+
/// "update codeburn" advice can't help — local Ollama models, llama.cpp tags,
241+
/// LM Studio loads, etc. are billed locally and don't have public pricing.
242+
/// Users still get $0 in cost reports for them (correct — local inference is
243+
/// effectively free); the warning was just noise.
244+
function looksLikeLocalModel(name: string): boolean {
245+
// Ollama and LM Studio tags include `:tag` (e.g. qwen3.6:35b-a3b-bf16).
246+
if (name.includes(':') && !name.startsWith('http')) return true
247+
// GGUF / quantized fingerprints commonly seen in local inference.
248+
if (/[-_](q[2-8](_[a-z0-9]+)?|bf16|fp16|gguf|f16|f32)$/i.test(name)) return true
249+
return false
250+
}
251+
252+
function shouldWarnAboutUnknownModel(name: string): boolean {
253+
if (!name || name === '<synthetic>') return false
254+
if (warnedUnknownModels.has(name)) return false
255+
// Suppress for local/quantized models — the "update codeburn" hint is
256+
// actively misleading there. Users who need cost visibility for local
257+
// inference can still set an alias via `codeburn model-alias`.
258+
if (looksLikeLocalModel(name)) return false
259+
// The warning fired on every CLI invocation (including the default
260+
// dashboard) which made first launches look broken — three "no pricing
261+
// data" lines greet a user before the dashboard even draws. Now opt-in
262+
// via --verbose. The unknown model still costs $0 in reports; users who
263+
// suspect missing models run `codeburn --verbose` to see the list.
264+
if (process.env['CODEBURN_VERBOSE'] !== '1') return false
265+
return true
266+
}
267+
238268
export function calculateCost(
239269
model: string,
240270
inputTokens: number,
@@ -246,19 +276,16 @@ export function calculateCost(
246276
): number {
247277
const costs = getModelCosts(model)
248278
if (!costs) {
249-
// Skip the synthetic placeholder and the auto-router pseudo-models that
250-
// intentionally have no direct pricing entry; calculateCost callers
251-
// resolve those through aliasing first, so an unknown here is genuinely
252-
// an unmapped real model.
253-
if (model && model !== '<synthetic>' && !warnedUnknownModels.has(model)) {
279+
if (shouldWarnAboutUnknownModel(model)) {
254280
warnedUnknownModels.add(model)
255281
// Strip control characters and cap length: model names come from JSONL
256282
// payloads written by external tools, so a hostile or corrupt file
257283
// could embed terminal escape sequences here.
258284
const safeName = model.replace(/[\x00-\x1F\x7F-\x9F]/g, '?').slice(0, 200)
285+
const aliasHint = `Map it with: codeburn model-alias "${safeName}" <known-model>`
259286
process.stderr.write(
260287
`codeburn: no pricing data for model "${safeName}" — costs for this model will show $0. ` +
261-
`Update with: npx codeburn@latest, or report at https://github.com/getagentseal/codeburn/issues.\n`
288+
`${aliasHint}, or update with: npx codeburn@latest.\n`
262289
)
263290
}
264291
return 0

0 commit comments

Comments
 (0)