From 47527598992ef2857873d6084c53094ad6d2778d Mon Sep 17 00:00:00 2001 From: Mateusz Choma Date: Fri, 30 Oct 2026 21:33:05 +0100 Subject: [PATCH 1/4] feat(server): configurable usage history retention Provider transcript cleanup can leave the usage cache as the only long-term record, but environments cannot retain it beyond the fixed 92-day policy. Add an environment-owned usageHistoryRetentionDays setting with the unchanged 92-day default, positive day counts, and unlimited retention. Preserve history on settings decode failure, prevent obsolete scans from pruning under a newer policy, and clamp cutoffs to epoch zero. Backfill widened Cursor horizons on read and prune its cache at merge. Keep existing client views unchanged. Cover retained history, restart, concurrent policies and provider cutoffs with focused tests, and document the server retention benefit and current view limits. Model: gpt-6-astra via Codex CLI, orchestrated by Claude Code. --- apps/server/src/serverSettings.test.ts | 11 +- apps/server/src/serverSettings.ts | 6 +- apps/server/src/usage/UsageService.test.ts | 261 +++++++++++++++--- apps/server/src/usage/UsageService.ts | 28 +- docs/user/usage.md | 2 + packages/contracts/src/settings.ts | 11 + .../src/server/CursorUsageAccounts.ts | 2 +- .../src/server/accountCache.test.ts | 29 +- .../src/server/accountCache.ts | 9 +- 9 files changed, 315 insertions(+), 44 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index ef8b4d6afae79..d13ca4e37badf 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1957,7 +1957,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("leaves an unreadable settings.json untouched instead of folding over it", () => + it.effect.each([ + { label: "malformed JSON", broken: '{"defaultAutoPull": tru' }, + { label: "invalid retention", broken: '{"usageHistoryRetentionDays": 0}' }, + { + label: "invalid unrelated setting", + broken: '{"defaultAutoPull": "true", "usageHistoryRetentionDays": "unlimited"}', + }, + ])("leaves settings.json with $label untouched and disables destructive defaults", ({ broken }) => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; @@ -1972,12 +1979,12 @@ it.layer(NodeServices.layer)("server settings", (it) => { ${"2026-08-25T00:00:00.000Z"}, ${"2026-08-25T00:00:00.000Z"} ) `; - const broken = '{"defaultAutoPull": tru'; yield* fileSystem.writeFileString(serverConfig.settingsPath, broken); const settings = yield* serverSettings.getSettings; assert.isFalse(settings.projectSettingsFolded); assert.isFalse(settings.continueThreadsAfterServerUpdate); + assert.equal(settings.usageHistoryRetentionDays, "unlimited"); assert.deepEqual(settings.projectSettingsOverrides, {}); assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), broken); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 67a1c3c3414f3..fedb48815c3af 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -717,7 +717,11 @@ const make = Effect.gen(function* () { if (decoded._tag === "Failure" || legacySettings._tag === "Failure") { const failure = decoded._tag === "Failure" ? decoded : legacySettings; // The file may hold an opt-out we can no longer read; never resume threads on a guess. - settings = { ...DEFAULT_SERVER_SETTINGS, continueThreadsAfterServerUpdate: false }; + settings = { + ...DEFAULT_SERVER_SETTINGS, + continueThreadsAfterServerUpdate: false, + usageHistoryRetentionDays: "unlimited", + }; if (failure._tag === "Failure") { parseFailure = Option.some(Cause.squash(failure.cause)); yield* Effect.logWarning("failed to parse settings.json, using defaults", { diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 9600f0a45adfb..cfa9c01c3009a 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -23,6 +23,7 @@ import { } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -30,6 +31,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Context from "effect/Context"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Scheduler from "effect/Scheduler"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; @@ -1405,40 +1407,57 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("restores the Cursor account cache after a restart", () => - Effect.gen(function* () { - const { settings, home } = yield* setup; - yield* writeCursorLogin(home); - yield* TestClock.setTime(CURSOR_NOW); - const before = makeFakeCursor(); - before.state.events = [ - { timestampMs: CURSOR_NOW - 30 * HOUR_MS, outputTokens: 5 }, - { timestampMs: CURSOR_NOW - 3 * HOUR_MS, outputTokens: 7 }, - ]; - yield* Effect.gen(function* () { - const first = yield* makeWithCursor(before.read); - const original = yield* first.readSummary({ ...WINDOW, awaitRefresh: true }); - yield* first.awaitPersisted; + it.live.each([ + { label: "default", retention: undefined, cutoff: CURSOR_NOW - 92 * 24 * HOUR_MS }, + { label: "365 days", retention: 365, cutoff: CURSOR_NOW - 365 * 24 * HOUR_MS }, + { label: "unlimited", retention: "unlimited" as const, cutoff: 0 }, + { label: "36500 days", retention: 36500, cutoff: 0 }, + ])( + "propagates the $label cutoff and restores the Cursor account cache after a restart", + ({ retention, cutoff }) => + Effect.gen(function* () { + const { settings, home } = yield* setup; + yield* writeCursorLogin(home); + yield* TestClock.setTime(CURSOR_NOW); + const before = makeFakeCursor(); + before.state.events = [ + { timestampMs: CURSOR_NOW - 30 * HOUR_MS, outputTokens: 5 }, + { timestampMs: CURSOR_NOW - 3 * HOUR_MS, outputTokens: 7 }, + ]; + yield* Effect.gen(function* () { + const first = yield* makeWithCursor(before.read); + const original = yield* first.readSummary({ ...WINDOW, awaitRefresh: true }); + assert.deepStrictEqual(before.state.calls, [{ sinceMs: cutoff, untilMs: CURSOR_NOW }]); + yield* first.awaitPersisted; - const after = makeFakeCursor(); - after.state.events = before.state.events; - const restarted = yield* makeWithCursor(after.read); - const restored = yield* restarted.readSummary(WINDOW); - assert.isUndefined(cursorSource(restored)?.refreshing); - assert.deepStrictEqual(restored.buckets, original.buckets); - assert.deepStrictEqual(cursorSource(restored), cursorSource(original)); - assert.strictEqual(after.state.calls.length, 0); - - yield* TestClock.adjust(Duration.minutes(2)); - const refreshed = yield* restarted.readSummary({ ...WINDOW, awaitRefresh: true }); - assert.deepStrictEqual(refreshed.buckets, original.buckets); - assert.deepStrictEqual(after.state.calls, [ - { sinceMs: CURSOR_NOW - HOUR_MS, untilMs: CURSOR_NOW + 2 * 60 * 1000 }, - ]); - }).pipe( - Effect.provide(layerService({ prefix: "usage-service-cursor-restart", home, settings })), - ); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + const after = makeFakeCursor(); + after.state.events = before.state.events; + const restarted = yield* makeWithCursor(after.read); + const restored = yield* restarted.readSummary(WINDOW); + assert.isUndefined(cursorSource(restored)?.refreshing); + assert.deepStrictEqual(restored.buckets, original.buckets); + assert.deepStrictEqual(cursorSource(restored), cursorSource(original)); + assert.strictEqual(after.state.calls.length, 0); + + yield* TestClock.adjust(Duration.minutes(2)); + const refreshed = yield* restarted.readSummary({ ...WINDOW, awaitRefresh: true }); + assert.deepStrictEqual(refreshed.buckets, original.buckets); + assert.deepStrictEqual(after.state.calls, [ + { sinceMs: CURSOR_NOW - HOUR_MS, untilMs: CURSOR_NOW + 2 * 60 * 1000 }, + ]); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-cursor-restart", + home, + settings: { + ...settings, + ...(retention === undefined ? {} : { usageHistoryRetentionDays: retention }), + }, + }), + ), + ); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); it.live("does not read the macOS Cursor Keychain before account usage is enabled", () => @@ -2495,6 +2514,182 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live.each([ + { label: "the default 92 days", retention: undefined, expected: 1 }, + { label: "365 days", retention: 365, expected: 7 }, + { label: "unlimited", retention: "unlimited" as const, expected: 15 }, + ])( + "retains deleted transcripts according to $label across restart and settings changes", + ({ retention, expected }) => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const now = Date.parse("2026-08-02T12:00:00Z"); + yield* TestClock.setTime(now); + const window = { ...WINDOW, sinceDay: UsageDay.make("2024-01-01") }; + const files = [92, 93, 180, 730].map((days, index) => ({ + path: `${transcript}-${index}.jsonl`, + timestamp: now - days * 86400000, + content: claudeLine(index, 2 ** index).replace( + "2026-08-01T10:00:00Z", + DateTime.formatIso(DateTime.makeUnsafe(now - days * 86400000)), + ), + })); + yield* Effect.promise(async () => { + for (const file of files) { + await NodeFSP.writeFile(file.path, file.content); + await NodeFSP.utimes(file.path, file.timestamp / 1000, file.timestamp / 1000); + } + }); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const first = yield* service.readSummary(window); + assert.strictEqual(totalOutputTokens(first), 15); + yield* service.awaitPersisted; + yield* Effect.promise(() => Promise.all(files.map((file) => NodeFSP.rm(file.path)))); + const restarted = yield* UsageService.make; + const saved = yield* restarted.readSummary(window); + assert.strictEqual(totalOutputTokens(saved), expected); + assert.deepStrictEqual(saved.sources[0]?.fingerprint, first.sources[0]?.fingerprint); + const narrow = yield* restarted.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(narrow), 0); + assert.strictEqual(narrow.sources[0]?.distinctSessions, 0); + + const config = yield* ServerSettings.ServerSettingsService; + yield* config.updateSettings({ usageHistoryRetentionDays: 1 }); + assert.strictEqual(totalOutputTokens(yield* restarted.readSummary(window)), 0); + yield* restarted.awaitPersisted; + yield* config.updateSettings({ usageHistoryRetentionDays: "unlimited" }); + const afterPruning = yield* UsageService.make; + assert.strictEqual(totalOutputTokens(yield* afterPruning.readSummary(window)), 0); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-retention", + home, + settings: { + ...settings, + ...(retention === undefined ? {} : { usageHistoryRetentionDays: retention }), + }, + }), + ), + ); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + + it.live("keeps retained history when an obsolete scan finishes after retention widens", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* TestClock.setTime(CURSOR_NOW); + const timestamp = CURSOR_NOW - 180 * 24 * HOUR_MS; + yield* Effect.promise(async () => { + await NodeFSP.writeFile( + transcript, + claudeLine(1, 11).replace( + "2026-08-01T10:00:00Z", + DateTime.formatIso(DateTime.makeUnsafe(timestamp)), + ), + ); + await NodeFSP.utimes(transcript, timestamp / 1000, timestamp / 1000); + }); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const updateReadStarted = yield* Deferred.make(); + const releaseUpdateRead = yield* Deferred.make(); + const guardReadStarted = yield* Deferred.make(); + const releaseGuardRead = yield* Deferred.make(); + const snapshotStarted = yield* Deferred.make(); + const secrets = new Map(); + let gating = false; + let updateReleased = false; + let reads = 0; + const secretLayer = Layer.mock(ServerSecretStore.ServerSecretStore)({ + get: (name) => + Effect.gen(function* () { + if (gating) { + const index = ++reads; + if (index === 1) { + yield* Deferred.succeed(updateReadStarted, undefined); + yield* Deferred.await(releaseUpdateRead); + } + if (index === 2 && !updateReleased) { + yield* Deferred.succeed(guardReadStarted, undefined); + yield* Deferred.await(releaseGuardRead); + } + } + const value = secrets.get(name); + return value === undefined ? Option.none() : Option.some(value); + }), + set: (name, value) => Effect.sync(() => void secrets.set(name, value)), + remove: (name) => Effect.sync(() => void secrets.delete(name)), + }); + let scans = 0; + yield* Effect.gen(function* () { + const config = yield* ServerSettings.ServerSettingsService; + yield* config.updateSettings({ + ...settings, + providerInstances: { + ...settings.providerInstances, + [ProviderInstanceId.make("secretFixture")]: { + driver: ProviderDriverKind.make("antigravity"), + environment: [ + { name: "OPENROUTER_API_KEY", value: "synthetic-secret", sensitive: true }, + ], + }, + }, + }); + const service = yield* UsageService.make.pipe( + Effect.provideService(ServerSettings.ServerSettingsService, { + ...config, + withSettingsSnapshot: (use) => + Effect.andThen( + Deferred.succeed(snapshotStarted, undefined), + config.withSettingsSnapshot(use), + ), + }), + Effect.provideService(CursorUsageAccounts.CursorUsageAccounts, { + scan: () => + Effect.gen(function* () { + if (++scans === 1) { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + } + return []; + }), + awaitPersisted: Effect.void, + }), + ); + const window = { ...WINDOW, sinceDay: UsageDay.make("2024-01-01") }; + const oldScan = yield* service.readSummary(window).pipe(Effect.forkChild); + yield* Deferred.await(started); + gating = true; + const updating = yield* config + .updateSettings({ usageHistoryRetentionDays: "unlimited" }) + .pipe(Effect.forkChild); + yield* Deferred.await(updateReadStarted); + yield* Deferred.succeed(release, undefined); + yield* Effect.raceFirst(Deferred.await(guardReadStarted), Deferred.await(snapshotStarted)); + updateReleased = true; + yield* Deferred.succeed(releaseUpdateRead, undefined); + yield* Fiber.join(updating); + assert.strictEqual(totalOutputTokens(yield* service.readSummary(window)), 11); + yield* service.awaitPersisted; + yield* Effect.promise(() => NodeFSP.rm(transcript)); + yield* Deferred.succeed(releaseGuardRead, undefined); + yield* Fiber.join(oldScan); + yield* service.awaitPersisted; + const restarted = yield* UsageService.make; + assert.strictEqual(totalOutputTokens(yield* restarted.readSummary(window)), 11); + }).pipe( + Effect.provide( + ServerSettings.layer.pipe( + Layer.provide(secretLayer), + Layer.provideMerge(layerService({ prefix: "usage-retention-race", home, settings })), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + it.live("credits the same copy of a duplicate after its transcripts are deleted", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index c1f619eaae177..cb0b8024897ef 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -69,8 +69,6 @@ const RATES_REFRESH_FLOOR_MS = 60 * 1000; const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; -const CACHE_RETENTION_DAYS = 92; - const TRANSCRIPT_READ_CONCURRENCY = 4; const transcriptReaders = BUILT_IN_USAGE_DRIVERS.flatMap((driver) => @@ -704,7 +702,10 @@ export const make = Effect.gen(function* () { const windowStartMs = (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; - const retentionCutoffMs = startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000; + const retentionCutoffMs = + settings.usageHistoryRetentionDays === "unlimited" + ? 0 + : Math.max(0, startedAtMs - settings.usageHistoryRetentionDays * 24 * 60 * 60 * 1000); const [, scannedDirs] = yield* Effect.all( [ @@ -867,8 +868,25 @@ export const make = Effect.gen(function* () { }); } - const pruned = pruneScanCache(fileCache, retentionCutoffMs); - if (pruned > 0) cacheDirty = true; + yield* settingsService + .withSettingsSnapshot((currentSettings) => + Effect.sync(() => { + if (settings.usageHistoryRetentionDays === currentSettings.usageHistoryRetentionDays) { + const pruned = pruneScanCache(fileCache, retentionCutoffMs); + if (pruned > 0) cacheDirty = true; + } + }), + ) + .pipe( + Effect.catchCause( + (cause) => + new UsageReadError({ + reason: "scanFailed", + detail: "Server settings could not be read.", + cause: Cause.squash(cause), + }), + ), + ); const aggregated = aggregator.finish(); const readAt = yield* DateTime.now; diff --git a/docs/user/usage.md b/docs/user/usage.md index 447924a072eff..e4802584507c4 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -18,6 +18,8 @@ Select a model under **Breakdown** to see its trend, cache hit rate, and cost pe Totals depend on the history available on each server. Grok turns without a saved completed-turn record are missing from the totals. +The server keeps scanned usage history for 92 days by default. Claude Code deletes its own transcripts after 30 days by default, so for older Claude usage this history is the only record left. To keep it longer, set `usageHistoryRetentionDays` in the environment's `/userdata/settings.json` to a positive number of days such as `365`, or to `"unlimited"`. Usage views currently show up to 90 days; a longer retention keeps the history on the server for later without changing what the app displays. Lowering the value prunes older history on the next refresh, and raising it again does not bring pruned history back unless the provider still has it. If `settings.json` cannot be read, history is kept as if retention were unlimited until the file is valid again. Longer retention uses more disk space and memory. + OpenCode reads its SQLite database and older JSON history. Antigravity reads local conversation databases, including T3-managed profiles. Set `OPENCODE_DATA_DIR` or `ANTIGRAVITY_DATA_DIR` on the server to read a different data directory; comma-separated paths read multiple directories. diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index fff25149b2e3d..049579ba19ade 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1178,6 +1178,11 @@ export const StorageCleanupSettings = Schema.Struct({ }); export type StorageCleanupSettings = typeof StorageCleanupSettings.Type; +const UsageHistoryRetentionDays = Schema.Union([ + Schema.Int.check(Schema.isGreaterThan(0)), + Schema.Literal("unlimited"), +]); + export const ServerSettings = Schema.Struct({ // Null means unmigrated; empty is authoritative and must not resurrect cleared preferences. favorites: Schema.NullOr(ModelFavorites).pipe(Schema.withDecodingDefault(Effect.succeed(null))), @@ -1354,6 +1359,10 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(false)), ), + usageHistoryRetentionDays: UsageHistoryRetentionDays.pipe( + Schema.withDecodingDefault(Effect.succeed(92)), + ), + usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), @@ -1671,6 +1680,8 @@ export const ServerSettingsPatch = Schema.Struct({ ), cursorKeychainUsageEnabled: Schema.optionalKey(Schema.Boolean), + usageHistoryRetentionDays: Schema.optionalKey(UsageHistoryRetentionDays), + usagePriceOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)), ), diff --git a/packages/provider-cursor/src/server/CursorUsageAccounts.ts b/packages/provider-cursor/src/server/CursorUsageAccounts.ts index 8f6b08670ea7d..f439824e79be1 100644 --- a/packages/provider-cursor/src/server/CursorUsageAccounts.ts +++ b/packages/provider-cursor/src/server/CursorUsageAccounts.ts @@ -199,7 +199,7 @@ const make = Effect.gen(function* () { let refreshing = false; if ( (recentFailure === undefined || nowMs - recentFailure.atMs >= CURSOR_ACCOUNT_TTL_MS) && - !isCursorCacheFresh(caches.get(credentialKey), nowMs) + !isCursorCacheFresh(caches.get(credentialKey), nowMs, retentionStartMs) ) { const refresh = yield* startRefresh(credential, credentialKey, retentionStartMs); if (awaitRefresh) yield* Deferred.await(refresh); diff --git a/packages/provider-cursor/src/server/accountCache.test.ts b/packages/provider-cursor/src/server/accountCache.test.ts index 55b6ba5df3c66..39b94700bc6ce 100644 --- a/packages/provider-cursor/src/server/accountCache.test.ts +++ b/packages/provider-cursor/src/server/accountCache.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; -import { cursorFetchRange, mergeCursorFetch } from "./accountCache.ts"; +import { cursorFetchRange, isCursorCacheFresh, mergeCursorFetch } from "./accountCache.ts"; import type { UsageRecord } from "@t3tools/provider-core/server/usage"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -31,6 +31,20 @@ describe("cursorAccountCache", () => { fetchedAtMs: 10 * DAY_MS, records: [record(5 * DAY_MS)], }; + assert.isTrue(isCursorCacheFresh(cache, 10 * DAY_MS, 0)); + const narrow = { ...cache, sinceMs: DAY_MS }; + assert.isFalse(isCursorCacheFresh(narrow, 10 * DAY_MS, 0)); + const widenedRange = cursorFetchRange(narrow, 0, 10 * DAY_MS); + assert.deepStrictEqual(widenedRange, { + sinceMs: 0, + untilMs: 10 * DAY_MS, + }); + const widened = mergeCursorFetch(narrow, "a", widenedRange, cache.records, 10 * DAY_MS, 0); + assert.isTrue(isCursorCacheFresh(widened.cache, 10 * DAY_MS, 0)); + assert.deepStrictEqual(cursorFetchRange(widened.cache, 0, 11 * DAY_MS), { + sinceMs: 10 * DAY_MS - 60 * 60 * 1000, + untilMs: 11 * DAY_MS, + }); const nowMs = 100 * DAY_MS; const range = cursorFetchRange(cache, 10 * DAY_MS, nowMs); assert.deepStrictEqual(range, { sinceMs: 10 * DAY_MS, untilMs: nowMs }); @@ -65,6 +79,19 @@ describe("cursorAccountCache", () => { assert.isFalse( mergeCursorFetch(cache, "a", range, [record(10 * DAY_MS)], 11 * DAY_MS, 0).changed, ); + assert.isTrue( + mergeCursorFetch(cache, "a", range, [record(10 * DAY_MS)], 11 * DAY_MS, 2 * DAY_MS).changed, + ); + assert.isTrue( + mergeCursorFetch( + { ...cache, sinceMs: DAY_MS, records: [] }, + "a", + { sinceMs: 0, untilMs: 11 * DAY_MS }, + [], + 11 * DAY_MS, + 0, + ).changed, + ); assert.isTrue( mergeCursorFetch( cache, diff --git a/packages/provider-cursor/src/server/accountCache.ts b/packages/provider-cursor/src/server/accountCache.ts index bcbb1ca402b17..8e90538d97ebb 100644 --- a/packages/provider-cursor/src/server/accountCache.ts +++ b/packages/provider-cursor/src/server/accountCache.ts @@ -25,8 +25,13 @@ interface CursorFetchRange { export function isCursorCacheFresh( cache: CursorAccountCache | undefined, nowMs: number, + retentionStartMs: number, ): cache is CursorAccountCache { - return cache !== undefined && nowMs - cache.fetchedAtMs < CURSOR_ACCOUNT_TTL_MS; + return ( + cache !== undefined && + cache.sinceMs <= retentionStartMs && + nowMs - cache.fetchedAtMs < CURSOR_ACCOUNT_TTL_MS + ); } export function cursorFetchRange( @@ -56,6 +61,8 @@ export function mergeCursorFetch( const replacedKeys = new Set(previous.filter(inRange).map((record) => record.dedupeKey)); const changed = cache === undefined || + cache.sinceMs > retentionStartMs || + previous.some((record) => record.timestampMs < retentionStartMs) || replacedKeys.size !== added.length || added.some((record) => !replacedKeys.has(record.dedupeKey)); return { From 2c632cb960f3708e76e44f7eb56d0fe6d274ed8e Mon Sep 17 00:00:00 2001 From: Mateusz Choma Date: Sat, 31 Oct 2026 11:59:51 +0100 Subject: [PATCH 2/4] test(server): use a neutral secret fixture in the retention race test --- apps/server/src/usage/UsageService.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index cfa9c01c3009a..2587e36b457a8 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -2631,9 +2631,7 @@ describe("UsageService", () => { ...settings.providerInstances, [ProviderInstanceId.make("secretFixture")]: { driver: ProviderDriverKind.make("antigravity"), - environment: [ - { name: "OPENROUTER_API_KEY", value: "synthetic-secret", sensitive: true }, - ], + environment: [{ name: "API_TOKEN", value: "fixture-token", sensitive: true }], }, }, }); From 4705330a59379c0e538702746ea48dc067fdda88 Mon Sep 17 00:00:00 2001 From: Mateusz Choma Date: Sat, 31 Oct 2026 13:01:41 +0100 Subject: [PATCH 3/4] fix(server): skip usage cache pruning when settings cannot be read --- apps/server/src/usage/UsageService.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index cb0b8024897ef..e52996f9ebab0 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -878,13 +878,8 @@ export const make = Effect.gen(function* () { }), ) .pipe( - Effect.catchCause( - (cause) => - new UsageReadError({ - reason: "scanFailed", - detail: "Server settings could not be read.", - cause: Cause.squash(cause), - }), + Effect.catchCause((cause) => + Effect.logWarning("failed to read settings before pruning the usage cache", { cause }), ), ); From 77dc88b3415989de5ca525011c10cd92a379f1eb Mon Sep 17 00:00:00 2001 From: Mateusz Choma Date: Sat, 31 Oct 2026 13:06:27 +0100 Subject: [PATCH 4/4] fix(cursor): recheck cache coverage after a shared usage refresh --- apps/server/src/usage/UsageService.test.ts | 61 +++++++++++++++++++ .../src/server/CursorUsageAccounts.ts | 12 +++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 2587e36b457a8..6b5a75222eae5 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -235,11 +235,13 @@ function makeFakeCursor() { events: [] as { readonly timestampMs: number; readonly outputTokens: number }[], error: null as string | null, gate: undefined as Deferred.Deferred | undefined, + started: undefined as Deferred.Deferred | undefined, calls: [] as { readonly sinceMs: number; readonly untilMs: number }[], }; const read = (_credential: unknown, sinceMs: number, untilMs: number) => Effect.gen(function* () { state.calls.push({ sinceMs, untilMs }); + if (state.started !== undefined) yield* Deferred.succeed(state.started, undefined); if (state.gate !== undefined) yield* Deferred.await(state.gate); const { accountKey, error } = state; if (error !== null) return { accountKey, records: [], missing: false, error }; @@ -1460,6 +1462,65 @@ describe("UsageService", () => { }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); + it.live("widens Cursor history after awaiting a shared narrower refresh", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + yield* TestClock.setTime(CURSOR_NOW); + const cursor = makeFakeCursor(); + cursor.state.gate = yield* Deferred.make(); + cursor.state.started = yield* Deferred.make(); + cursor.state.events = [ + { timestampMs: CURSOR_NOW - 120 * 24 * HOUR_MS, outputTokens: 5 }, + { timestampMs: CURSOR_NOW - 3 * HOUR_MS, outputTokens: 7 }, + ]; + const context = yield* Layer.build( + layerCursorUsageAccounts( + Layer.succeed(CursorAccountReader.CursorAccountReader, { read: cursor.read }), + ).pipe( + Layer.provide( + layerService({ + prefix: "usage-service-cursor-shared-refresh", + home, + settings, + platform: "darwin", + }), + ), + ), + ); + const accounts = Context.get(context, CursorUsageAccounts.CursorUsageAccounts); + const input = { + keychainUsageEnabled: true, + windowStartMs: 0, + retentionCutoffMs: CURSOR_NOW - 92 * 24 * HOUR_MS, + awaitRefresh: false, + }; + const narrow = yield* accounts.scan(input); + assert.isTrue(narrow[0]?.refreshing); + yield* Deferred.await(cursor.state.started); + const wider = yield* accounts + .scan({ ...input, retentionCutoffMs: 0, awaitRefresh: true }) + .pipe(Effect.forkChild({ startImmediately: true })); + assert.strictEqual(cursor.state.calls.length, 1); + yield* Deferred.succeed(cursor.state.gate, undefined); + const sources = yield* Fiber.join(wider); + assert.deepStrictEqual(cursor.state.calls, [ + { sinceMs: input.retentionCutoffMs, untilMs: CURSOR_NOW }, + { sinceMs: 0, untilMs: CURSOR_NOW }, + ]); + assert.strictEqual(sources[0]?.status, "ok"); + assert.deepStrictEqual( + sources[0]?.files?.flatMap((file) => + file.records.map(({ timestampMs, totals }) => ({ + timestampMs, + outputTokens: totals.outputTokens, + })), + ), + cursor.state.events, + ); + yield* accounts.awaitPersisted; + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + it.live("does not read the macOS Cursor Keychain before account usage is enabled", () => Effect.gen(function* () { const { settings, home } = yield* setup; diff --git a/packages/provider-cursor/src/server/CursorUsageAccounts.ts b/packages/provider-cursor/src/server/CursorUsageAccounts.ts index f439824e79be1..eca1b06f31605 100644 --- a/packages/provider-cursor/src/server/CursorUsageAccounts.ts +++ b/packages/provider-cursor/src/server/CursorUsageAccounts.ts @@ -202,8 +202,16 @@ const make = Effect.gen(function* () { !isCursorCacheFresh(caches.get(credentialKey), nowMs, retentionStartMs) ) { const refresh = yield* startRefresh(credential, credentialKey, retentionStartMs); - if (awaitRefresh) yield* Deferred.await(refresh); - else refreshing = true; + if (awaitRefresh) { + yield* Deferred.await(refresh); + if ( + !failures.has(credentialKey) && + !isCursorCacheFresh(caches.get(credentialKey), nowMs, retentionStartMs) + ) { + const retry = yield* startRefresh(credential, credentialKey, retentionStartMs); + yield* Deferred.await(retry); + } + } else refreshing = true; } const cache = caches.get(credentialKey);