Skip to content

Commit fd18074

Browse files
authored
Fix mobile settings and live provider usage (#854)
* Fix mobile settings and live provider usage * ship: iteration 1 — harden usage refresh state
1 parent 2cc83ee commit fd18074

21 files changed

Lines changed: 744 additions & 109 deletions

apps/desktop/src/main/main.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4316,15 +4316,6 @@ app.whenReady().then(async () => {
43164316
const runtimeProject = await localRuntimePool.ensureProject(projectRoot);
43174317
const db = await openKvDb(adePaths.dbPath, logger);
43184318
const shellContext = createDormantProjectContext(projectRoot, { enableUsageTracking: false });
4319-
const usageTrackingService = createUsageTrackingService({
4320-
logger,
4321-
db,
4322-
pollIntervalMs: 120_000,
4323-
onUpdate: (snapshot) => {
4324-
emitProjectEvent(projectRoot, IPC.usageEvent, snapshot);
4325-
},
4326-
projectRoot,
4327-
});
43284319
const storageInsightsService = createStorageInsightsService({
43294320
projectRoot,
43304321
adeHome: machineAdeLayout.adeDir,
@@ -4335,7 +4326,6 @@ app.whenReady().then(async () => {
43354326
// because activity cannot be known here.
43364327
isPathActive: () => true,
43374328
});
4338-
usageTrackingService.start();
43394329
const diskPressureMonitor = createDiskPressureMonitor({
43404330
roots: [projectRoot, machineAdeLayout.adeDir],
43414331
logger,
@@ -4357,7 +4347,10 @@ app.whenReady().then(async () => {
43574347
adeCliService: shellContext.adeCliService,
43584348
builtInBrowserService,
43594349
diskPressureMonitor,
4360-
usageTrackingService,
4350+
// The machine brain owns quota polling and streams its snapshots through
4351+
// the runtime event pump. A second renderer-side tracker races those
4352+
// events and can make the compact header disagree with the open panel.
4353+
usageTrackingService: null,
43614354
storageInsightsService,
43624355
};
43634356
};

apps/desktop/src/main/services/usage/usageProviderStrategies.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ export type UsageProviderPollContext = {
1313
reason: UsageRefreshReason;
1414
};
1515

16-
export type UsageProviderPollResult = {
16+
export type FreshUsageProviderPollResult = {
17+
disposition?: "fresh";
1718
windows: UsageWindow[];
1819
/** Provider-level Codex spend control state, not tied to an individual quota window. */
1920
spendControlReached?: boolean;
@@ -26,6 +27,24 @@ export type UsageProviderPollResult = {
2627
providerMessages?: UsageProviderMessage[];
2728
};
2829

30+
type PreserveUsageProviderPollResult = {
31+
/** The non-interactive caller could not authoritatively check this provider. */
32+
disposition: "preserve_previous";
33+
windows: [];
34+
errors: [];
35+
source?: UsageProviderSource;
36+
spendControlReached?: never;
37+
errorKind?: never;
38+
retryAfterMs?: never;
39+
extraUsage?: never;
40+
dailyUsage7d?: never;
41+
providerMessages?: never;
42+
};
43+
44+
export type UsageProviderPollResult =
45+
| FreshUsageProviderPollResult
46+
| PreserveUsageProviderPollResult;
47+
2948
/**
3049
* Boundary between the quota scheduler and provider-specific auth/fallback
3150
* behavior. Historical ledger scanners intentionally do not implement this

apps/desktop/src/main/services/usage/usageTrackingService.test.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3776,6 +3776,197 @@ describe("usage reliability: non-destructive merge", () => {
37763776
service.dispose();
37773777
});
37783778

3779+
it("keeps a valid Claude status when a mobile refresh cannot read interactive credentials", async () => {
3780+
const logger = createLogger();
3781+
const weeklyMs = 3 * 24 * 60 * 60 * 1000;
3782+
const claudeWindow = {
3783+
provider: "claude" as const,
3784+
windowType: "weekly" as const,
3785+
percentUsed: 37,
3786+
resetsAt: futureIso(weeklyMs),
3787+
resetsInMs: weeklyMs,
3788+
};
3789+
const claudeExtraUsage = {
3790+
provider: "claude" as const,
3791+
isEnabled: true,
3792+
usedCreditsUsd: 9,
3793+
monthlyLimitUsd: 100,
3794+
utilization: 9,
3795+
currency: "usd",
3796+
};
3797+
const pollClaudeUsage = vi
3798+
.fn()
3799+
.mockResolvedValueOnce({
3800+
windows: [claudeWindow],
3801+
errors: [],
3802+
source: "oauth" as const,
3803+
extraUsage: claudeExtraUsage,
3804+
})
3805+
.mockResolvedValueOnce({
3806+
disposition: "preserve_previous" as const,
3807+
windows: [],
3808+
errors: [],
3809+
source: "oauth" as const,
3810+
});
3811+
const service = createUsageTrackingService({
3812+
logger,
3813+
dependencies: {
3814+
pollClaudeUsage,
3815+
pollCodexUsage: vi.fn(async () => ({ windows: [], errors: [] })),
3816+
},
3817+
});
3818+
3819+
const first = await service.poll({ reason: "user" });
3820+
const refreshed = await service.poll({ reason: "remote" });
3821+
3822+
expect(first.providerStatus?.claude?.state).toBe("ok");
3823+
expect(refreshed.providerStatus?.claude).toEqual(first.providerStatus?.claude);
3824+
expect(refreshed.windows.filter((window) => window.provider === "claude")).toHaveLength(1);
3825+
expect(refreshed.windows.find((window) => window.provider === "claude")).toMatchObject(claudeWindow);
3826+
expect(refreshed.extraUsage).toEqual([claudeExtraUsage]);
3827+
expect(refreshed.errors).toEqual([]);
3828+
3829+
service.dispose();
3830+
});
3831+
3832+
it("downgrades a preserved provider after its final carried window expires", async () => {
3833+
vi.useFakeTimers();
3834+
vi.setSystemTime(new Date("2026-07-17T12:00:00.000Z"));
3835+
const pollClaudeUsage = vi
3836+
.fn()
3837+
.mockResolvedValueOnce({
3838+
windows: [{
3839+
provider: "claude" as const,
3840+
windowType: "weekly" as const,
3841+
percentUsed: 37,
3842+
resetsAt: "2026-07-17T12:01:00.000Z",
3843+
resetsInMs: 60_000,
3844+
}],
3845+
errors: [],
3846+
source: "oauth" as const,
3847+
})
3848+
.mockResolvedValueOnce({
3849+
disposition: "preserve_previous" as const,
3850+
windows: [],
3851+
errors: [],
3852+
source: "oauth" as const,
3853+
});
3854+
const service = createUsageTrackingService({
3855+
logger: createLogger(),
3856+
dependencies: {
3857+
pollClaudeUsage,
3858+
pollCodexUsage: vi.fn(async () => ({ windows: [], errors: [] })),
3859+
},
3860+
});
3861+
3862+
try {
3863+
const first = await service.poll({ reason: "user" });
3864+
expect(first.providerStatus?.claude?.state).toBe("ok");
3865+
3866+
vi.advanceTimersByTime(60_000);
3867+
const expired = await service.poll({ reason: "remote" });
3868+
3869+
expect(expired.windows.filter((window) => window.provider === "claude")).toEqual([]);
3870+
expect(expired.providerStatus?.claude).toMatchObject({
3871+
state: "stale",
3872+
lastSuccessAt: first.lastPolledAt,
3873+
});
3874+
expect(expired.providerStatus?.claude?.message).toMatch(/expired/i);
3875+
} finally {
3876+
service.dispose();
3877+
vi.useRealTimers();
3878+
}
3879+
});
3880+
3881+
it("clears a sticky Claude auth error when a remote refresh cannot verify credentials", async () => {
3882+
const logger = createLogger();
3883+
const weeklyMs = 3 * 24 * 60 * 60 * 1000;
3884+
const claudeWindow = {
3885+
provider: "claude" as const,
3886+
windowType: "weekly" as const,
3887+
percentUsed: 37,
3888+
resetsAt: futureIso(weeklyMs),
3889+
resetsInMs: weeklyMs,
3890+
};
3891+
const pollClaudeUsage = vi
3892+
.fn()
3893+
.mockResolvedValueOnce({ windows: [claudeWindow], errors: [], source: "oauth" as const })
3894+
.mockResolvedValueOnce({
3895+
windows: [],
3896+
errors: ["claude: no non-interactive credentials found"],
3897+
errorKind: "auth" as const,
3898+
source: "oauth" as const,
3899+
})
3900+
.mockResolvedValueOnce({
3901+
disposition: "preserve_previous" as const,
3902+
windows: [],
3903+
errors: [],
3904+
source: "oauth" as const,
3905+
});
3906+
const service = createUsageTrackingService({
3907+
logger,
3908+
dependencies: {
3909+
pollClaudeUsage,
3910+
pollCodexUsage: vi.fn(async () => ({ windows: [], errors: [] })),
3911+
},
3912+
});
3913+
3914+
await service.poll({ reason: "user" });
3915+
const staleError = await service.poll({ reason: "remote" });
3916+
const refreshed = await service.poll({ reason: "remote" });
3917+
3918+
expect(staleError.providerStatus?.claude?.state).toBe("unauthed");
3919+
expect(refreshed.providerStatus?.claude).toMatchObject({
3920+
state: "stale",
3921+
lastSuccessAt: expect.any(String),
3922+
});
3923+
expect(refreshed.providerStatus?.claude?.message).toBeUndefined();
3924+
expect(refreshed.windows.filter((window) => window.provider === "claude")).toHaveLength(1);
3925+
expect(refreshed.errors).toEqual([]);
3926+
3927+
service.dispose();
3928+
});
3929+
3930+
it("preserves a genuine Claude auth error while automatic polling is backed off", async () => {
3931+
vi.useFakeTimers();
3932+
vi.setSystemTime(new Date("2026-07-17T12:00:00.000Z"));
3933+
const logger = createLogger();
3934+
const pollClaudeUsage = vi
3935+
.fn()
3936+
.mockResolvedValueOnce({
3937+
windows: [],
3938+
errors: ["claude: API returned 401"],
3939+
errorKind: "auth" as const,
3940+
source: "oauth" as const,
3941+
})
3942+
.mockResolvedValueOnce({
3943+
disposition: "preserve_previous" as const,
3944+
windows: [],
3945+
errors: [],
3946+
source: "oauth" as const,
3947+
});
3948+
const service = createUsageTrackingService({
3949+
logger,
3950+
dependencies: {
3951+
pollClaudeUsage,
3952+
pollCodexUsage: vi.fn(async () => ({ windows: [], errors: [] })),
3953+
},
3954+
});
3955+
3956+
const failed = await service.poll({ reason: "user" });
3957+
const backedOff = await service.poll({ reason: "automatic" });
3958+
vi.advanceTimersByTime(61_000);
3959+
const nonInteractive = await service.poll({ reason: "automatic" });
3960+
3961+
expect(failed.providerStatus?.claude?.state).toBe("unauthed");
3962+
expect(backedOff.providerStatus?.claude).toEqual(failed.providerStatus?.claude);
3963+
expect(nonInteractive.providerStatus?.claude).toEqual(failed.providerStatus?.claude);
3964+
expect(pollClaudeUsage).toHaveBeenCalledTimes(2);
3965+
3966+
service.dispose();
3967+
vi.useRealTimers();
3968+
});
3969+
37793970
it("attaches per-window pacing for both the 5-hour and weekly windows", async () => {
37803971
const logger = createLogger();
37813972
const fiveHourMs = 2 * 60 * 60 * 1000; // 60% of the 5h window elapsed

0 commit comments

Comments
 (0)