Skip to content

Commit 35af1b9

Browse files
committed
split error handling
1 parent 6e28b5f commit 35af1b9

8 files changed

Lines changed: 206 additions & 63 deletions

File tree

Sources/AgentTally/App/AppDelegate.swift

Lines changed: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -95,39 +95,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
9595

9696
refreshTask?.cancel()
9797
refreshTask = Task {
98-
do {
99-
let usageDataScan = await Task.detached(priority: .utility) {
100-
UsageDataScanner.currentScan()
101-
}.value
102-
103-
let agentsToRefresh = UsageRefreshController.agentsNeedingRefresh(
104-
pricingMode: request.pricingMode,
105-
currentUsageDataScan: usageDataScan,
106-
cachedUsageDataFingerprints: lastUsageDataFingerprints,
107-
cachedAgentData: lastSuccessfulAgentData
108-
)
109-
110-
if !agentsToRefresh.isEmpty {
98+
defer {
99+
refreshTask = nil
100+
}
101+
102+
let usageDataScan = await Task.detached(priority: .utility) {
103+
UsageDataScanner.currentScan()
104+
}.value
105+
106+
let agentsToRefresh = UsageRefreshController.agentsNeedingRefresh(
107+
pricingMode: request.pricingMode,
108+
currentUsageDataScan: usageDataScan,
109+
cachedUsageDataFingerprints: lastUsageDataFingerprints,
110+
cachedAgentData: lastSuccessfulAgentData,
111+
lastErrorByAgent: state.lastErrorByAgent
112+
)
113+
114+
var nextErrorByAgent = state.lastErrorByAgent
115+
for agent in agentsToRefresh {
116+
do {
111117
let snapshot = try await UsageFetcher.fetchUsage(
112118
offline: request.pricingMode == .offline,
113-
agents: agentsToRefresh
119+
agents: [agent]
114120
)
115121
cache(snapshot: snapshot, usageDataScan: usageDataScan)
122+
nextErrorByAgent.removeValue(forKey: agent)
123+
} catch {
124+
guard !Task.isCancelled else {
125+
return
126+
}
127+
nextErrorByAgent[agent] = error.localizedDescription
128+
NSLog(
129+
"agenttally %@ refresh failed: %@",
130+
agent.displayName,
131+
error.localizedDescription
132+
)
116133
}
117-
118-
applyRefreshSuccess(
119-
cachedSnapshot(),
120-
pricingMode: request.pricingMode,
121-
lastUsageDetectedAtByAgent: usageDataScan.lastUsageDetectedAtByAgent
122-
)
123-
} catch {
124-
guard !Task.isCancelled else {
125-
return
126-
}
127-
applyRefreshFailure(error)
128134
}
129135

130-
refreshTask = nil
136+
applyRefreshSuccess(
137+
cachedSnapshot(),
138+
pricingMode: request.pricingMode,
139+
lastUsageDetectedAtByAgent: usageDataScan.lastUsageDetectedAtByAgent,
140+
lastErrorByAgent: nextErrorByAgent
141+
)
131142
}
132143
}
133144

@@ -155,12 +166,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
155166
private func applyRefreshSuccess(
156167
_ snapshot: UsageSnapshot,
157168
pricingMode: PricingRefreshMode,
158-
lastUsageDetectedAtByAgent: [AgentKind: Date]
169+
lastUsageDetectedAtByAgent: [AgentKind: Date],
170+
lastErrorByAgent: [AgentKind: String]
159171
) {
160172
state = UsageRefreshController.applySuccess(
161173
snapshot: snapshot,
162174
pricingMode: pricingMode,
163175
lastUsageDetectedAtByAgent: lastUsageDetectedAtByAgent,
176+
lastErrorByAgent: lastErrorByAgent,
164177
to: state
165178
)
166179
renderTitle()
@@ -170,9 +183,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
170183
private func applyRefreshFailure(_ error: Error) {
171184
state = UsageRefreshController.applyFailure(error: error, to: state)
172185
renderTitle()
173-
if let lastError = state.lastError, !lastError.isEmpty {
174-
NSLog("agenttally refresh failed: %@", lastError)
175-
}
186+
NSLog("agenttally refresh failed: %@", error.localizedDescription)
176187
refreshMenuIfNeeded()
177188
}
178189

Sources/AgentTally/Domain/AppState.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,21 @@ public struct AppState {
1111
public var businessDays = 0
1212
public var lastRefreshAt: Date?
1313
public var lastOnlinePricingRefreshAt: Date?
14-
public var lastError: String?
14+
public var lastErrorByAgent: [AgentKind: String] = [:]
1515

1616
public init(
1717
isRefreshing: Bool = false,
1818
agentSpendings: [AgentSpending] = [],
1919
businessDays: Int = 0,
2020
lastRefreshAt: Date? = nil,
2121
lastOnlinePricingRefreshAt: Date? = nil,
22-
lastError: String? = nil
22+
lastErrorByAgent: [AgentKind: String] = [:]
2323
) {
2424
self.isRefreshing = isRefreshing
2525
self.agentSpendings = agentSpendings
2626
self.businessDays = businessDays
2727
self.lastRefreshAt = lastRefreshAt
2828
self.lastOnlinePricingRefreshAt = lastOnlinePricingRefreshAt
29-
self.lastError = lastError
29+
self.lastErrorByAgent = lastErrorByAgent
3030
}
3131
}

Sources/AgentTally/Presentation/MenuRowsBuilder.swift

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,17 @@ public enum MenuRowsBuilder {
4141

4242
if state.lastRefreshAt != nil {
4343
var isFirst = true
44+
var renderedAgents = Set<AgentKind>()
4445
for spending in state.agentSpendings {
4546
if !isFirst { rows.append(.separator) }
4647
isFirst = false
4748

48-
if spending.isInstalled, state.lastError == nil {
49+
let agent = AgentKind(displayName: spending.name)
50+
if let agent {
51+
renderedAgents.insert(agent)
52+
}
53+
54+
if spending.isInstalled {
4955
rows.append(.section("\(spending.name) spending"))
5056
rows.append(
5157
.disabled("Today: $\(StatusPresenter.displayDollarAmount(for: spending.todayCost))"))
@@ -65,10 +71,21 @@ public enum MenuRowsBuilder {
6571
} else if !spending.isInstalled {
6672
rows.append(.disabled("\(spending.name): not installed"))
6773
}
74+
75+
if let agent, let error = state.lastErrorByAgent[agent], !error.isEmpty {
76+
rows.append(.disabled("Error: \(error)"))
77+
}
6878
}
6979

70-
if let lastError = state.lastError, !lastError.isEmpty {
71-
rows.append(.disabled("Error: \(lastError)"))
80+
for agent in AgentKind.allCases where !renderedAgents.contains(agent) {
81+
guard let error = state.lastErrorByAgent[agent], !error.isEmpty else {
82+
continue
83+
}
84+
85+
if !isFirst { rows.append(.separator) }
86+
isFirst = false
87+
rows.append(.section("\(agent.displayName) spending"))
88+
rows.append(.disabled("Error: \(error)"))
7289
}
7390
}
7491

Sources/AgentTally/Presentation/StatusPresenter.swift

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,10 @@ public enum StatusPresenter {
1313
}
1414

1515
public static func shouldShowWarningSymbol(for state: AppState) -> Bool {
16-
state.lastError != nil
16+
!state.lastErrorByAgent.isEmpty
1717
}
1818

1919
public static func title(for state: AppState, now: Date = Date()) -> String {
20-
if shouldShowWarningSymbol(for: state) {
21-
return "ERR \(placeholderAgent.abbreviation)"
22-
}
23-
2420
if shouldShowLoadingTitle(lastRefreshAt: state.lastRefreshAt, now: now) {
2521
return loadingTitle(for: state)
2622
}
@@ -34,9 +30,24 @@ public enum StatusPresenter {
3430
}
3531
}
3632

33+
if !state.lastErrorByAgent.isEmpty {
34+
return errorTitle(for: state)
35+
}
36+
3737
return loadingTitle(for: state)
3838
}
3939

40+
private static func errorTitle(for state: AppState) -> String {
41+
let abbreviations = AgentKind.allCases
42+
.filter { state.lastErrorByAgent[$0] != nil }
43+
.map(\.abbreviation)
44+
guard !abbreviations.isEmpty else {
45+
return "ERR \(placeholderAgent.abbreviation)"
46+
}
47+
48+
return "ERR \(abbreviations.joined(separator: " "))"
49+
}
50+
4051
private static func loadingTitle(for state: AppState) -> String {
4152
let abbreviations = state.agentSpendings
4253
.filter { $0.isInstalled }

Sources/AgentTally/Usage/UsageRefreshController.swift

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ enum UsageRefreshController {
3333
snapshot: UsageSnapshot,
3434
pricingMode: PricingRefreshMode,
3535
lastUsageDetectedAtByAgent: [AgentKind: Date] = [:],
36+
lastErrorByAgent: [AgentKind: String] = [:],
3637
to state: AppState,
3738
now: Date = Date()
3839
) -> AppState {
@@ -52,22 +53,25 @@ enum UsageRefreshController {
5253
lastUsageDetectedAt: agent.flatMap { lastUsageDetectedAtByAgent[$0] }
5354
)
5455
}
55-
if pricingMode == .online {
56+
if pricingMode == .online && lastErrorByAgent.isEmpty {
5657
nextState.lastOnlinePricingRefreshAt = now
5758
}
58-
nextState.lastError = nil
59+
nextState.lastErrorByAgent = lastErrorByAgent
5960
return nextState
6061
}
6162

6263
static func applyFailure(
6364
error: Error,
65+
affectedAgents: [AgentKind] = AgentKind.allCases,
6466
to state: AppState,
6567
now: Date = Date()
6668
) -> AppState {
6769
var nextState = state
6870
nextState.isRefreshing = false
6971
nextState.lastRefreshAt = now
70-
nextState.lastError = error.localizedDescription
72+
for agent in affectedAgents {
73+
nextState.lastErrorByAgent[agent] = error.localizedDescription
74+
}
7175
return nextState
7276
}
7377

@@ -95,7 +99,8 @@ enum UsageRefreshController {
9599
pricingMode: PricingRefreshMode,
96100
currentUsageDataScan: UsageDataScan,
97101
cachedUsageDataFingerprints: [AgentKind: UsageDataFingerprint],
98-
cachedAgentData: [AgentKind: AgentRawData]
102+
cachedAgentData: [AgentKind: AgentRawData],
103+
lastErrorByAgent: [AgentKind: String] = [:]
99104
) -> [AgentKind] {
100105
if pricingMode == .online {
101106
return AgentKind.allCases
@@ -108,6 +113,7 @@ enum UsageRefreshController {
108113

109114
return cachedAgentData[agent] == nil
110115
|| cachedUsageDataFingerprints[agent] != currentFingerprint
116+
|| lastErrorByAgent[agent] != nil
111117
}
112118
}
113119
}

Tests/MenuRowsHarness.swift

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func testMenuRowsBuilder() throws {
2727
agentSpendings: [claudeSpending, codexSpending],
2828
businessDays: 4,
2929
lastRefreshAt: Date(timeIntervalSinceReferenceDate: 1_000),
30-
lastError: nil
30+
lastErrorByAgent: [:]
3131
),
3232
startAtLogin: .make(status: .enabled),
3333
appVersion: "0.1",
@@ -130,7 +130,7 @@ func testMenuRowsBuilder() throws {
130130
agentSpendings: [claudeSpending, codexSpending],
131131
businessDays: 4,
132132
lastRefreshAt: Date(timeIntervalSinceReferenceDate: 1_000),
133-
lastError: nil
133+
lastErrorByAgent: [:]
134134
),
135135
startAtLogin: .make(status: .enabled)
136136
)
@@ -145,17 +145,40 @@ func testMenuRowsBuilder() throws {
145145
agentSpendings: [claudeSpending, codexSpending],
146146
businessDays: 4,
147147
lastRefreshAt: Date(timeIntervalSinceReferenceDate: 1_000),
148-
lastError: "helper timed out"
148+
lastErrorByAgent: [.codex: "helper timed out"]
149149
),
150150
startAtLogin: .make(status: .enabled)
151151
)
152152
try expect(
153153
errorRows.contains(.disabled("Error: helper timed out")),
154-
"refresh failures should surface the localized error message in the menu"
154+
"refresh failures should surface the affected agent error in the menu"
155155
)
156156
try expect(
157-
!errorRows.contains(.disabled("Today: $49")),
158-
"error state should hide stale summary rows"
157+
errorRows.contains(.disabled("Today: $49")),
158+
"one agent error should not hide another agent's cached summary rows"
159+
)
160+
try expect(
161+
errorRows.contains(.disabled("Today: $10")),
162+
"an errored agent should still show cached summary rows when available"
163+
)
164+
165+
let firstRefreshErrorRows = MenuRowsBuilder.rows(
166+
for: AppState(
167+
isRefreshing: false,
168+
agentSpendings: [],
169+
businessDays: 0,
170+
lastRefreshAt: Date(timeIntervalSinceReferenceDate: 1_000),
171+
lastErrorByAgent: [.codex: "helper timed out"]
172+
),
173+
startAtLogin: .make(status: .enabled)
174+
)
175+
try expect(
176+
firstRefreshErrorRows.contains(.section("Codex spending")),
177+
"first-refresh agent errors should create a section for the affected agent"
178+
)
179+
try expect(
180+
firstRefreshErrorRows.contains(.disabled("Error: helper timed out")),
181+
"first-refresh agent errors should still be visible without cached spending"
159182
)
160183

161184
// No agent data yet (before first refresh)

0 commit comments

Comments
 (0)