Merge pull request #315 from getagentseal/fix/menubar-version-prefix
Some checks failed
CI / semgrep (push) Has been cancelled

Harden menubar refresh recovery
This commit is contained in:
Resham Joshi 2026-05-13 20:33:42 -07:00 committed by GitHub
commit 2ca92a97cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 356 additions and 82 deletions

View file

@ -9,6 +9,8 @@
set -euo pipefail
VERSION="${1:-dev}"
ASSET_VERSION="${VERSION#mac-}"
BUNDLE_VERSION="${ASSET_VERSION#v}"
BUNDLE_NAME="CodeBurnMenubar.app"
BUNDLE_ID="org.agentseal.codeburn-menubar"
EXECUTABLE_NAME="CodeBurnMenubar"
@ -66,9 +68,9 @@ cat > "${BUNDLE}/Contents/Info.plist" <<PLIST
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION}</string>
<string>${BUNDLE_VERSION}</string>
<key>CFBundleVersion</key>
<string>${VERSION}</string>
<string>${BUNDLE_VERSION}</string>
<key>LSMinimumSystemVersion</key>
<string>${MIN_MACOS}</string>
<key>LSUIElement</key>
@ -93,7 +95,7 @@ echo "▸ Ad-hoc signing..."
codesign --force --sign - --timestamp=none --deep "${BUNDLE}" 2>/dev/null || true
codesign --verify --deep --strict "${BUNDLE}" 2>/dev/null || echo " (signature verify skipped)"
ZIP_NAME="CodeBurnMenubar-${VERSION}.zip"
ZIP_NAME="CodeBurnMenubar-${ASSET_VERSION}.zip"
ZIP_PATH="${DIST_DIR}/${ZIP_NAME}"
echo "▸ Packaging ${ZIP_NAME}..."
(cd "${DIST_DIR}" && COPYFILE_DISABLE=1 /usr/bin/ditto -c -k --norsrc --keepParent "${BUNDLE_NAME}" "${ZIP_NAME}")

View file

@ -2,6 +2,7 @@ import Foundation
import Observation
private let cacheTTLSeconds: TimeInterval = 30
private let interactiveRefreshResetSeconds: TimeInterval = 120
struct CachedPayload {
let payload: MenubarPayload
@ -51,6 +52,7 @@ final class AppStore {
private var cache: [PayloadCacheKey: CachedPayload] = [:]
private var cacheDate: String = ""
private var switchTask: Task<Void, Never>?
private var payloadRefreshGeneration: UInt64 = 0
/// Tracks the last successful fetch timestamp per key for stuck-loading
/// diagnostics. NOT used for cache-freshness logic `CachedPayload.fetchedAt`
/// is authoritative there. This map persists across cache wipes (day
@ -87,6 +89,44 @@ final class AppStore {
cache[currentKey] != nil
}
var hasStaleLoading: Bool {
let now = Date()
return loadingStartedAtByKey.values.contains {
now.timeIntervalSince($0) > loadingWatchdogSeconds
}
}
var hasStaleInteractivePayload: Bool {
staleInteractivePayloadAgeSeconds != nil
}
var shouldResetInteractiveRefreshPipeline: Bool {
hasStaleLoading || hasStaleInteractivePayload
}
var staleInteractivePayloadAgeSeconds: Int? {
let keys = Set([
currentKey,
PayloadCacheKey(period: .today, provider: .all),
PayloadCacheKey(period: selectedPeriod, provider: .all),
])
let staleAges = keys.compactMap { key -> TimeInterval? in
guard let cached = cache[key] else { return nil }
let age = Date().timeIntervalSince(cached.fetchedAt)
return age > interactiveRefreshResetSeconds ? age : nil
}
return staleAges.max().map(Int.init)
}
var needsInteractivePayloadRefresh: Bool {
let todayKey = PayloadCacheKey(period: .today, provider: .all)
let periodAllKey = PayloadCacheKey(period: selectedPeriod, provider: .all)
return cache[currentKey]?.isFresh != true ||
cache[todayKey]?.isFresh != true ||
cache[periodAllKey]?.isFresh != true ||
hasStaleLoading
}
/// True if any cached payload reports at least one provider. Used to keep the
/// AgentTabStrip visible across period/provider switches even when the current
/// key's payload is briefly empty (e.g. immediately after a `switchTo` and
@ -95,6 +135,12 @@ final class AppStore {
cache.values.contains { !$0.payload.current.providers.isEmpty }
}
#if DEBUG
func setCachedPayloadForTesting(_ payload: MenubarPayload, period: Period, provider: ProviderFilter, fetchedAt: Date) {
cache[PayloadCacheKey(period: period, provider: provider)] = CachedPayload(payload: payload, fetchedAt: fetchedAt)
}
#endif
var findingsCount: Int {
payload.optimize.findingCount
}
@ -135,6 +181,7 @@ final class AppStore {
private var inFlightKeys: Set<PayloadCacheKey> = []
func resetLoadingState() {
payloadRefreshGeneration &+= 1
loadingCountsByKey.removeAll()
loadingStartedAtByKey.removeAll()
inFlightKeys.removeAll()
@ -161,6 +208,7 @@ final class AppStore {
}
guard !staleEntries.isEmpty else { return false }
payloadRefreshGeneration &+= 1
for (key, started) in staleEntries {
NSLog("CodeBurn: loading stuck for %ds on %@/%@ — auto-clearing",
Int(now.timeIntervalSince(started)), key.period.rawValue, key.provider.rawValue)
@ -196,8 +244,15 @@ final class AppStore {
formatter.dateFormat = "yyyy-MM-dd"
let today = formatter.string(from: Date())
if cacheDate != today {
payloadRefreshGeneration &+= 1
cache.removeAll()
loadingCountsByKey.removeAll()
loadingStartedAtByKey.removeAll()
inFlightKeys.removeAll()
attemptedKeys.removeAll()
lastErrorByKey.removeAll()
cacheDate = today
NSLog("CodeBurn: reset menubar payload cache for new day %@", today)
}
}
@ -209,6 +264,7 @@ final class AppStore {
invalidateStaleDayCache()
let key = currentKey
let cacheDateAtStart = cacheDate
let generationAtStart = payloadRefreshGeneration
if !force, cache[key]?.isFresh == true { return }
if !force, inFlightKeys.contains(key) { return }
inFlightKeys.insert(key)
@ -237,6 +293,10 @@ final class AppStore {
}
do {
let fresh = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: includeOptimize)
if generationAtStart != payloadRefreshGeneration {
NSLog("CodeBurn: dropping fetch result for \(key.period.rawValue)/\(key.provider.rawValue) — refresh pipeline reset mid-fetch")
return
}
if Task.isCancelled {
// Distinguish cancellation (user switched tabs mid-fetch) from
// the silent-no-result path. Without this log, a cancelled
@ -263,6 +323,7 @@ final class AppStore {
do {
let fallback = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: false)
guard !Task.isCancelled else { return }
if generationAtStart != payloadRefreshGeneration { return }
if cacheDate != cacheDateAtStart { return }
cache[key] = CachedPayload(payload: fallback, fetchedAt: Date())
lastSuccessByKey[key] = Date()
@ -288,8 +349,13 @@ final class AppStore {
func refreshQuietly(period: Period) async {
invalidateStaleDayCache()
let cacheDateAtStart = cacheDate
let generationAtStart = payloadRefreshGeneration
do {
let fresh = try await DataClient.fetch(period: period, provider: .all, includeOptimize: false)
if generationAtStart != payloadRefreshGeneration {
NSLog("CodeBurn: dropping quiet fetch result for \(period.rawValue) — refresh pipeline reset mid-fetch")
return
}
// Same day-rollover guard as refresh(): drop yesterday's payload if
// the calendar rolled over during the fetch.
if cacheDate != cacheDateAtStart { return }

View file

@ -0,0 +1,43 @@
import Foundation
enum AppVersion {
static var bundleShortVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
}
static var bundleBuildVersion: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
}
static var normalizedBundleShortVersion: String {
normalize(bundleShortVersion)
}
static var normalizedBundleBuildVersion: String {
normalize(bundleBuildVersion)
}
static var displayBundleShortVersion: String {
display(bundleShortVersion)
}
static func normalize(_ version: String) -> String {
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.lowercased().hasPrefix("mac-v") {
return String(trimmed.dropFirst(5))
}
if trimmed.lowercased().hasPrefix("v") {
return String(trimmed.dropFirst())
}
return trimmed
}
static func display(_ version: String) -> String {
let normalized = normalize(version)
guard !normalized.isEmpty else { return "v?" }
if normalized == "?" || normalized == "dev" || normalized == "dev-preview" || normalized == "" {
return normalized
}
return "v\(normalized)"
}
}

View file

@ -6,6 +6,8 @@ private let refreshIntervalSeconds: UInt64 = 30
private let nanosPerSecond: UInt64 = 1_000_000_000
private let refreshIntervalNanos: UInt64 = refreshIntervalSeconds * nanosPerSecond
private let forceRefreshWatchdogSeconds: TimeInterval = 90
private let refreshLoopWatchdogSeconds: TimeInterval = 90
private let refreshRateLimitSeconds: TimeInterval = 5
private let interactiveQuotaRefreshFloorSeconds: TimeInterval = 30
private let statusItemWidth: CGFloat = NSStatusItem.variableLength
private let popoverWidth: CGFloat = 360
@ -42,6 +44,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private var forceRefreshGeneration: UInt64 = 0
private var manualRefreshTask: Task<Void, Never>?
private var manualRefreshGeneration: UInt64 = 0
private var refreshLoopHeartbeatAt: Date = .distantPast
func applicationWillFinishLaunching(_ notification: Notification) {
// Set accessory policy before the app's focus chain forms. On macOS Tahoe
@ -94,30 +97,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.forceRefreshTask?.cancel()
self?.forceRefreshTask = nil
self?.forceRefreshStartedAt = nil
self?.forceRefreshGeneration &+= 1
self?.manualRefreshTask?.cancel()
self?.manualRefreshTask = nil
self?.manualRefreshGeneration &+= 1
self?.store.resetLoadingState()
self?.refreshLoopTask?.cancel()
self?.refreshLoopTask = nil
self?.prepareRefreshPipelineForSleep()
}
}
// didWakeNotification + screensDidWakeNotification can both fire on
// the same wake. forceRefresh has a 5-second rate-limit gate so the
// duplicate is squashed there. Restart the refresh loop too, since
// we cancelled it on willSleep.
// the same wake. forceRefreshTask squashes overlap; both notifications
// still bypass the short manual-click rate limit so a just-before-sleep
// refresh cannot block wake recovery.
NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didWakeNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.recoverRefreshPipelineAfterInterruption(resetLoading: true)
self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "wake")
}
}
@ -127,7 +121,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.recoverRefreshPipelineAfterInterruption(resetLoading: true)
self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "screen wake")
}
}
}
@ -139,21 +133,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.recoverRefreshPipelineAfterInterruption(resetLoading: false)
self?.recoverRefreshPipelineAfterInterruption(resetLoading: false, reason: "launch agent")
}
}
}
private func recoverRefreshPipelineAfterInterruption(resetLoading: Bool) {
private func prepareRefreshPipelineForSleep() {
forceRefreshTask?.cancel()
forceRefreshTask = nil
forceRefreshStartedAt = nil
forceRefreshGeneration &+= 1
manualRefreshTask?.cancel()
manualRefreshTask = nil
manualRefreshGeneration &+= 1
store.resetLoadingState()
refreshLoopTask?.cancel()
refreshLoopTask = nil
refreshLoopHeartbeatAt = .distantPast
lastRefreshTime = .distantPast
}
private func recoverRefreshPipelineAfterInterruption(resetLoading: Bool, clearCache: Bool = false, reason: String) {
if resetLoading {
store.resetLoadingState()
forceRefreshTask?.cancel()
forceRefreshTask = nil
forceRefreshStartedAt = nil
forceRefreshGeneration &+= 1
manualRefreshTask?.cancel()
manualRefreshTask = nil
manualRefreshGeneration &+= 1
store.resetRefreshState(clearCache: clearCache)
} else {
_ = store.clearStaleLoadingIfNeeded()
}
if refreshLoopTask == nil {
let now = Date()
let loopAge = now.timeIntervalSince(refreshLoopHeartbeatAt)
if refreshLoopTask == nil || loopAge > refreshLoopWatchdogSeconds {
if refreshLoopTask != nil {
NSLog("CodeBurn: refresh loop stale for %ds after %@ — restarting", Int(loopAge), reason)
}
refreshLoopTask?.cancel()
refreshLoopTask = nil
startRefreshLoop()
}
forceRefresh()
forceRefresh(bypassRateLimit: true, forceQuota: true)
}
private func installLaunchAgentIfNeeded() {
@ -250,11 +273,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
return false
}
private func forceRefresh() {
private func forceRefresh(bypassRateLimit: Bool = false, forceQuota: Bool = false) {
let now = Date()
_ = clearStaleForceRefreshIfNeeded(now: now)
guard forceRefreshTask == nil else { return }
guard now.timeIntervalSince(lastRefreshTime) > 5 else { return }
if !bypassRateLimit {
guard now.timeIntervalSince(lastRefreshTime) > refreshRateLimitSeconds else { return }
}
lastRefreshTime = now
forceRefreshStartedAt = now
forceRefreshGeneration &+= 1
@ -262,9 +287,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
forceRefreshTask = Task {
async let main: Void = store.refresh(includeOptimize: false, force: true, showLoading: true)
async let today: Void = store.refreshQuietly(period: .today)
async let quotas: Bool = refreshLiveQuotaProgressIfDue()
_ = await (main, today, quotas)
async let quotas: Bool = refreshLiveQuotaProgressIfDue(force: forceQuota)
if store.selectedPeriod != .today || store.selectedProvider != .all {
await store.refreshQuietly(period: .today)
}
_ = await main
refreshStatusButton()
await MainActor.run { [weak self] in
guard let self, self.forceRefreshGeneration == generation else { return }
@ -272,6 +299,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
self.forceRefreshStartedAt = nil
self.lastRefreshTime = Date()
}
_ = await quotas
}
}
@ -344,39 +372,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
}
}
private func refreshPayloadForPopoverOpen() {
guard store.needsInteractivePayloadRefresh else { return }
let shouldResetPipeline = store.shouldResetInteractiveRefreshPipeline
if shouldResetPipeline, let age = store.staleInteractivePayloadAgeSeconds {
NSLog("CodeBurn: popover opened with %ds stale payload cache - resetting refresh pipeline", age)
}
recoverRefreshPipelineAfterInterruption(
resetLoading: shouldResetPipeline,
reason: "popover open"
)
}
private func startRefreshLoop() {
refreshLoopTask?.cancel()
refreshLoopHeartbeatAt = Date()
forceRefresh(bypassRateLimit: true, forceQuota: true)
refreshLoopTask = Task { [weak self] in
// Provider refreshes only run when the user has explicitly connected.
// Each refresh is a no-op until its corresponding bootstrap flag is set.
if let self {
await self.refreshLiveQuotaProgressIfDue(force: true)
}
while !Task.isCancelled {
guard let self else { return }
self.refreshLoopHeartbeatAt = Date()
let clearedStaleForceRefresh = self.clearStaleForceRefreshIfNeeded()
let clearedStaleLoading = self.store.clearStaleLoadingIfNeeded()
// Skip the loop's tick if a wake / manual / distributed-
// notification refresh just ran. Without this gate, every
// wake produced two refreshes (forceRefresh from the wake
// observer plus the loop's natural tick).
let sinceLast = Date().timeIntervalSince(self.lastRefreshTime)
if self.forceRefreshTask == nil && (clearedStaleForceRefresh || clearedStaleLoading || sinceLast >= 5) {
if self.store.selectedPeriod != .today || self.store.selectedProvider != .all {
async let quiet: Void = self.store.refreshQuietly(period: .today)
async let main: Void = self.store.refresh(includeOptimize: false, force: true)
_ = await (quiet, main)
} else {
await self.store.refresh(includeOptimize: false, force: true)
}
self.lastRefreshTime = Date()
self.refreshStatusButton()
if clearedStaleForceRefresh || clearedStaleLoading || sinceLast >= TimeInterval(refreshIntervalSeconds) {
self.forceRefresh(bypassRateLimit: true)
}
// Cadence-driven live-quota refresh, anchored on LAST SUCCESS
// (not last attempt) so an intermittent failure doesn't reset
// the timer. Each provider has its own anchor so a Codex 429
// doesn't delay a due Claude refresh.
await self.refreshLiveQuotaProgressIfDue()
try? await Task.sleep(nanoseconds: refreshIntervalNanos)
}
}
@ -617,6 +638,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
window.collectionBehavior.insert(.canJoinAllSpaces)
window.makeKeyAndOrderFront(nil)
}
refreshPayloadForPopoverOpen()
refreshLiveQuotaProgressForPopoverOpen()
}
}
@ -705,10 +727,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
alert.icon = codeburnAlertIcon()
if updateChecker.updateAvailable, let latest = updateChecker.latestVersion {
alert.messageText = "Update Available"
alert.informativeText = "v\(latest) is available (you have v\(updateChecker.currentVersion)). Run:\n\ncodeburn menubar --force"
alert.informativeText = "\(AppVersion.display(latest)) is available (you have \(AppVersion.display(updateChecker.currentVersion))). Run:\n\ncodeburn menubar --force"
} else {
alert.messageText = "Up to Date"
alert.informativeText = "You're on the latest version (v\(updateChecker.currentVersion))."
alert.informativeText = "You're on the latest version (\(AppVersion.display(updateChecker.currentVersion)))."
}
alert.alertStyle = .informational
alert.addButton(withTitle: "OK")

View file

@ -16,14 +16,14 @@ final class UpdateChecker {
var updateAvailable: Bool {
guard let latest = latestVersion else { return false }
let current = currentVersion
let normalizedLatest = latest.hasPrefix("v") ? String(latest.dropFirst()) : latest
let normalizedCurrent = current.hasPrefix("v") ? String(current.dropFirst()) : current
let normalizedLatest = AppVersion.normalize(latest)
let normalizedCurrent = AppVersion.normalize(current)
guard !normalizedCurrent.isEmpty && normalizedCurrent != "dev" else { return false }
return normalizedLatest.compare(normalizedCurrent, options: .numeric) == .orderedDescending
}
var currentVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
AppVersion.normalizedBundleShortVersion
}
func checkIfNeeded() async {

View file

@ -567,7 +567,7 @@ struct FooterBar: View {
Spacer()
Text("v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?")")
Text(AppVersion.displayBundleShortVersion)
.font(.system(size: 10, weight: .regular, design: .monospaced))
.foregroundStyle(.tertiary)

View file

@ -337,10 +337,8 @@ private struct CodexConnectionRow: View {
// MARK: - About
private struct AboutSettingsTab: View {
private let appVersion: String =
(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? ""
private let buildVersion: String =
(Bundle.main.infoDictionary?["CFBundleVersion"] as? String) ?? ""
private let appVersion: String = AppVersion.normalizedBundleShortVersion
private let buildVersion: String = AppVersion.normalizedBundleBuildVersion
var body: some View {
VStack(spacing: 14) {

View file

@ -0,0 +1,63 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
private func menubarPayload(cost: Double) -> MenubarPayload {
MenubarPayload(
generated: "test",
current: CurrentBlock(
label: "Today",
cost: cost,
calls: 1,
sessions: 1,
oneShotRate: nil,
inputTokens: 1,
outputTokens: 1,
cacheHitPercent: 0,
topActivities: [],
topModels: [],
providers: ["claude": cost]
),
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
history: HistoryBlock(daily: [])
)
}
@Suite("AppStore refresh recovery")
@MainActor
struct AppStoreRefreshRecoveryTests {
@Test("stale visible payload triggers hard recovery without clearing cache")
func stalePayloadTriggersHardRecoveryWithoutClearingCache() {
let store = AppStore()
store.setCachedPayloadForTesting(
menubarPayload(cost: 92.33),
period: .today,
provider: .all,
fetchedAt: Date().addingTimeInterval(-180)
)
#expect(store.todayPayload?.current.cost == 92.33)
#expect(store.needsInteractivePayloadRefresh)
#expect(store.hasStaleInteractivePayload)
#expect(store.shouldResetInteractiveRefreshPipeline)
store.resetRefreshState(clearCache: false)
#expect(store.todayPayload?.current.cost == 92.33)
}
@Test("fresh visible payload does not trigger hard recovery")
func freshPayloadDoesNotTriggerHardRecovery() {
let store = AppStore()
store.setCachedPayloadForTesting(
menubarPayload(cost: 164.06),
period: .today,
provider: .all,
fetchedAt: Date()
)
#expect(!store.needsInteractivePayloadRefresh)
#expect(!store.hasStaleInteractivePayload)
#expect(!store.shouldResetInteractiveRefreshPipeline)
}
}

View file

@ -0,0 +1,19 @@
import Testing
@testable import CodeBurnMenubar
@Suite("AppVersion")
struct AppVersionTests {
@Test("display avoids duplicate v prefix")
func displayAvoidsDuplicatePrefix() {
#expect(AppVersion.display("0.9.8") == "v0.9.8")
#expect(AppVersion.display("v0.9.8") == "v0.9.8")
#expect(AppVersion.display("mac-v0.9.8") == "v0.9.8")
}
@Test("bundle metadata stores unprefixed semver")
func normalizeBundleVersion() {
#expect(AppVersion.normalize("v0.9.8") == "0.9.8")
#expect(AppVersion.normalize("mac-v0.9.8") == "0.9.8")
#expect(AppVersion.normalize("dev") == "dev")
}
}

View file

@ -12,7 +12,7 @@
],
"scripts": {
"bundle-litellm": "node scripts/bundle-litellm.mjs",
"build": "node scripts/bundle-litellm.mjs && tsup && node -e \"require('fs').copyFileSync('src/cli.ts','dist/cli.js')\"",
"build": "node scripts/bundle-litellm.mjs && tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); fs.chmodSync('dist/cli.js',0o755)\"",
"dev": "tsx src/cli.ts",
"test": "vitest",
"prepublishOnly": "npm run build"

View file

@ -5,6 +5,7 @@ import { calculateCost, getShortModelName } from './models.js'
import { discoverAllSessions, getProvider } from './providers/index.js'
import { flushCodexCache } from './codex-cache.js'
import { flushAntigravityCache } from './providers/antigravity.js'
import { isSqliteBusyError } from './sqlite.js'
import type { ParsedProviderCall } from './providers/types.js'
import type {
AssistantMessageContent,
@ -565,6 +566,19 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn {
}
}
const warnedProviderReadFailures = new Set<string>()
function warnProviderReadFailureOnce(providerName: string, err: unknown): void {
const key = `${providerName}:sqlite-busy`
if (warnedProviderReadFailures.has(key)) return
warnedProviderReadFailures.add(key)
if (isSqliteBusyError(err)) {
process.stderr.write(
`codeburn: skipped ${providerName} data because its SQLite database is temporarily locked; will retry on the next refresh.\n`
)
}
}
async function parseProviderSources(
providerName: string,
sources: Array<{ path: string; project: string }>,
@ -589,25 +603,33 @@ async function parseProviderSources(
seenKeys,
)
for await (const call of parser.parse()) {
if (dateRange) {
if (!call.timestamp) continue
const ts = new Date(call.timestamp)
if (ts < dateRange.start || ts > dateRange.end) continue
}
try {
for await (const call of parser.parse()) {
if (dateRange) {
if (!call.timestamp) continue
const ts = new Date(call.timestamp)
if (ts < dateRange.start || ts > dateRange.end) continue
}
const turn = providerCallToTurn(call)
const classified = classifyTurn(turn)
const project = call.project ?? source.project
const key = `${providerName}:${call.sessionId}:${project}`
const turn = providerCallToTurn(call)
const classified = classifyTurn(turn)
const project = call.project ?? source.project
const key = `${providerName}:${call.sessionId}:${project}`
const existing = sessionMap.get(key)
if (existing) {
existing.turns.push(classified)
if (!existing.projectPath && call.projectPath) existing.projectPath = call.projectPath
} else {
sessionMap.set(key, { project, projectPath: call.projectPath, turns: [classified] })
const existing = sessionMap.get(key)
if (existing) {
existing.turns.push(classified)
if (!existing.projectPath && call.projectPath) existing.projectPath = call.projectPath
} else {
sessionMap.set(key, { project, projectPath: call.projectPath, turns: [classified] })
}
}
} catch (err) {
if (isSqliteBusyError(err)) {
warnProviderReadFailureOnce(providerName, err)
continue
}
throw err
}
}
} finally {

View file

@ -4,7 +4,7 @@ import { homedir } from 'os'
import { calculateCost, getShortModelName } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js'
import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, isSqliteBusyError, type SqliteDatabase } from '../sqlite.js'
import type {
Provider,
SessionSource,
@ -126,7 +126,8 @@ function validateSchemaDetailed(db: SqliteDatabase): SchemaCheckResult {
for (const table of required) {
try {
db.query<{ cnt: number }>(`SELECT COUNT(*) as cnt FROM ${table} LIMIT 1`)
} catch {
} catch (err) {
if (isSqliteBusyError(err)) throw err
missing.push(table)
}
}

View file

@ -16,6 +16,7 @@ export type SqliteDatabase = {
type DatabaseSyncCtor = new (path: string, options?: { readOnly?: boolean }) => {
prepare(sql: string): { all(...params: unknown[]): Row[] }
exec?(sql: string): void
close(): void
}
@ -97,12 +98,35 @@ export function getSqliteLoadError(): string {
return loadError ?? 'SQLite driver not available'
}
export function isSqliteBusyError(err: unknown): boolean {
const e = err as { code?: unknown; errcode?: unknown; errstr?: unknown; message?: unknown } | null
const code = typeof e?.code === 'string' ? e.code : ''
const errcode = typeof e?.errcode === 'number' ? e.errcode : null
const message = [
typeof e?.message === 'string' ? e.message : '',
typeof e?.errstr === 'string' ? e.errstr : '',
].join(' ')
return (
errcode === 5 ||
errcode === 6 ||
code === 'SQLITE_BUSY' ||
code === 'SQLITE_LOCKED' ||
/\bSQLITE_(BUSY|LOCKED)\b|database (?:is |table is )?locked/i.test(message)
)
}
export function openDatabase(path: string): SqliteDatabase {
if (!loadDriver() || DatabaseSync === null) {
throw new Error(getSqliteLoadError())
}
const db = new DatabaseSync(path, { readOnly: true })
try {
db.exec?.('PRAGMA busy_timeout = 1000')
} catch {
// Best effort. Some Node sqlite builds may not expose exec on DatabaseSync.
}
return {
query<T extends Row = Row>(sql: string, params: unknown[] = []): T[] {

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { blobToText } from '../src/sqlite.js'
import { blobToText, isSqliteBusyError } from '../src/sqlite.js'
describe('blobToText', () => {
it('returns empty string for null', () => {
@ -37,3 +37,17 @@ describe('blobToText', () => {
expect(blobToText(new Uint8Array(0))).toBe('')
})
})
describe('isSqliteBusyError', () => {
it('detects node:sqlite busy errors by errcode', () => {
expect(isSqliteBusyError({ code: 'ERR_SQLITE_ERROR', errcode: 5, errstr: 'database is locked' })).toBe(true)
})
it('detects sqlite locked messages', () => {
expect(isSqliteBusyError(new Error('SQLITE_LOCKED: database table is locked'))).toBe(true)
})
it('ignores unrelated sqlite errors', () => {
expect(isSqliteBusyError(new Error('no such table: session'))).toBe(false)
})
})