Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 57 additions & 41 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,11 @@ import {
joinAdeAgentSkillRoots,
splitAdeAgentSkillRoots,
} from "../../desktop/src/shared/agentSkillRoots";
import { createUsageTrackingService } from "../../desktop/src/main/services/usage/usageTrackingService";
import {
attachSharedUsageTrackingScope,
createUsageTrackingService,
type UsageTrackingHost,
} from "../../desktop/src/main/services/usage/usageTrackingService";
import { createBudgetCapService } from "../../desktop/src/main/services/usage/budgetCapService";
import {
createProductAnalyticsService,
Expand Down Expand Up @@ -342,7 +346,7 @@ export type AdeRuntime = {
linearIngressService?: ReturnType<typeof createLinearIngressService> | null;
cursorCloudIngressService?: ReturnType<typeof createCursorCloudIngressService> | null;
feedbackReporterService?: ReturnType<typeof createFeedbackReporterService> | null;
usageTrackingService?: ReturnType<typeof createUsageTrackingService> | null;
usageTrackingService?: UsageTrackingHost | null;
productAnalyticsService?: ProductAnalyticsService | null;
usageProductAnalyticsExporter?: UsageProductAnalyticsExporter | null;
storageInsightsService?: ReturnType<typeof createStorageInsightsService> | null;
Expand Down Expand Up @@ -1962,47 +1966,59 @@ export async function createAdeRuntime(args: {

let lastDailyAnalyticsDay: string | null = null;
let dailyAnalyticsInFlight: Promise<void> | null = null;
let usageTrackingService: ReturnType<typeof createUsageTrackingService>;
usageTrackingService = createUsageTrackingService({
logger,
db,
pollIntervalMs: 120_000,
onUpdate: (snapshot) => {
pushEvent("runtime", { type: "usage", snapshot });
if (!productAnalyticsService.getStatus().effective || dailyAnalyticsInFlight) return;
const target = completedDailyUsageAnalyticsTarget();
if (!target || lastDailyAnalyticsDay === target.day) return;
const current = Promise.resolve()
.then(async () => {
// Report the last completed local day. Capturing the in-progress
// "today" bucket on the first poll systematically missed providers,
// models, and actions used later in the day.
const stats = await usageTrackingService.getAdeUsageStats({
preset: "today",
until: target.occurredAt,
scope: "project",
});
captureDailyUsageAnalytics({
analytics: productAnalyticsService,
stats,
projectId,
reportDay: target.day,
occurredAt: target.occurredAt,
});
lastDailyAnalyticsDay = target.day;
})
.catch((error) => {
logger.debug("product_analytics.daily_summary_failed", {
errorKind: error instanceof Error ? error.name : "unknown",
let usageTrackingService: ReturnType<typeof attachSharedUsageTrackingScope>;
// Provider quota belongs to the machine, so this daemon polls it once and
// every project scope attaches to that one poller. Per-project inputs (the
// database ADE's own stats and account rollups live in, the repository
// GitHub activity is read from) ride on the scope, so project-scoped
// answers stay per project while the quota meter cannot drift between
// windows.
usageTrackingService = attachSharedUsageTrackingScope(
resolveMachineAdeLayout().adeDir,
() => createUsageTrackingService({ logger, pollIntervalMs: 120_000 }),
{
key: `${projectId}:${projectRoot}`,
db,
projectRoot,
logger,
onUpdate: (snapshot) => {
pushEvent("runtime", { type: "usage", snapshot });
if (!productAnalyticsService.getStatus().effective || dailyAnalyticsInFlight) return;
const target = completedDailyUsageAnalyticsTarget();
if (!target || lastDailyAnalyticsDay === target.day) return;
const current = Promise.resolve()
.then(async () => {
// Report the last completed local day. Capturing the in-progress
// "today" bucket on the first poll systematically missed providers,
// models, and actions used later in the day.
const stats = await usageTrackingService.getAdeUsageStats({
preset: "today",
until: target.occurredAt,
scope: "project",
});
captureDailyUsageAnalytics({
analytics: productAnalyticsService,
stats,
projectId,
reportDay: target.day,
occurredAt: target.occurredAt,
});
lastDailyAnalyticsDay = target.day;
})
.catch((error) => {
logger.debug("product_analytics.daily_summary_failed", {
errorKind: error instanceof Error ? error.name : "unknown",
});
})
.finally(() => {
if (dailyAnalyticsInFlight === current) dailyAnalyticsInFlight = null;
});
})
.finally(() => {
if (dailyAnalyticsInFlight === current) dailyAnalyticsInFlight = null;
});
dailyAnalyticsInFlight = current;
dailyAnalyticsInFlight = current;
},
},
projectRoot,
});
);
// Detaches this project. The shared poller keeps running for the scopes
// that are still open and shuts down only with the last one.
teardown.push(() => usageTrackingService.dispose());
const storageInsightsService = createStorageInsightsService({
projectRoot,
Expand Down
4 changes: 2 additions & 2 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ import type { createOrchestrationService } from "../../../../desktop/src/main/se
import type { createPrService } from "../../../../desktop/src/main/services/prs/prService";
import type { createPrSummaryService } from "../../../../desktop/src/main/services/prs/prSummaryService";
import type { createPtyService } from "../../../../desktop/src/main/services/pty/ptyService";
import type { createUsageTrackingService } from "../../../../desktop/src/main/services/usage/usageTrackingService";
import type { UsageTrackingHost } from "../../../../desktop/src/main/services/usage/usageTrackingService";
import type { ProductAnalyticsService } from "../../../../desktop/src/main/services/analytics/productAnalyticsService";
import { parseProductAnalyticsCapture } from "../../../../desktop/src/shared/types/productAnalytics";
import { deleteTerminalSessionWithRuntimeCleanup } from "../../../../desktop/src/main/services/sessions/deleteTerminalSession";
Expand Down Expand Up @@ -339,7 +339,7 @@ type SyncRemoteCommandServiceArgs = {
* production callers (bootstrap, syncHostService) always pass it.
*/
db?: AdeDb;
usageTrackingService?: ReturnType<typeof createUsageTrackingService> | null;
usageTrackingService?: UsageTrackingHost | null;
productAnalyticsService?: ProductAnalyticsService | null;
projectRoot?: string;
laneService: ReturnType<typeof createLaneService>;
Expand Down
4 changes: 2 additions & 2 deletions apps/ade-cli/src/services/sync/syncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ import type { PushPublisherService } from "../push/pushPublisherService";
import { acquireSyncHostSingleton, type SyncHostSingletonLease } from "./syncHostSingleton";
import type { SharedSyncListener } from "./sharedSyncListener";
import type { ModelPickerStore } from "../modelPickerStore";
import type { createUsageTrackingService } from "../../../../desktop/src/main/services/usage/usageTrackingService";
import type { UsageTrackingHost } from "../../../../desktop/src/main/services/usage/usageTrackingService";
import type { ProductAnalyticsService } from "../../../../desktop/src/main/services/analytics/productAnalyticsService";
import type { AccountAuthService } from "../account/accountAuthService";
import {
Expand All @@ -102,7 +102,7 @@ import {

type SyncServiceArgs = {
db: AdeDb;
usageTrackingService?: ReturnType<typeof createUsageTrackingService> | null;
usageTrackingService?: UsageTrackingHost | null;
productAnalyticsService?: ProductAnalyticsService | null;
logger: Logger;
getAccountDirectoryHealth?: () => SyncAccountDirectoryHealth;
Expand Down
119 changes: 119 additions & 0 deletions apps/ade-cli/src/sharedUsageTracking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* The brain hosts several project scopes in one process and each of them used
* to build its own usage tracker: two 120s poll timers on different phases, two
* `lastSnapshot`s, two demand leases. Two ADE windows on one machine then
* showed two different Claude/Codex meters, and one of them was always behind.
*
* `bootstrap.ts` now attaches every scope to one machine-level tracker. These
* tests hold that wiring — the same call shape `createAdeRuntime` uses, without
* booting two full runtimes for it.
*/
import { afterEach, describe, expect, it, vi } from "vitest";

import type { UsageSnapshot } from "../../desktop/src/shared/types/usage";
import {
attachSharedUsageTrackingScope,
clearSharedUsageTrackingServicesForTesting,
createUsageTrackingService,
peekSharedUsageTrackingService,
} from "../../desktop/src/main/services/usage/usageTrackingService";

const logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};

function fastDependencies() {
return {
pollClaudeUsage: vi.fn(async () => ({ windows: [] as never[], extraUsage: null, errors: [] as never[] })),
pollCodexUsage: vi.fn(async () => ({ windows: [] as never[], errors: [] as never[] })),
scanClaudeLogs: vi.fn(async () => [] as never[]),
scanCodexLogs: vi.fn(async () => [] as never[]),
scanCursorLogs: vi.fn(async () => [] as never[]),
scanCursorAgentLogs: vi.fn(async () => [] as never[]),
scanOpenClawLogs: vi.fn(async () => [] as never[]),
scanOpenCodeLogs: vi.fn(async () => [] as never[]),
scanDroidLogs: vi.fn(async () => [] as never[]),
scanCopilotLogs: vi.fn(async () => [] as never[]),
scanGeminiLogs: vi.fn(async () => [] as never[]),
};
}

type RuntimeEvent = { type: string; snapshot: UsageSnapshot };

/** Mirrors bootstrap: one scope per project, pushing into that scope's buffer. */
function attachScope(
adeDir: string,
make: () => ReturnType<typeof createUsageTrackingService>,
project: { projectId: string; projectRoot: string },
) {
const events: RuntimeEvent[] = [];
const scope = attachSharedUsageTrackingScope(adeDir, make, {
key: `${project.projectId}:${project.projectRoot}`,
projectRoot: project.projectRoot,
logger,
onUpdate: (snapshot) => events.push({ type: "usage", snapshot }),
});
return { scope, events };
}

describe("shared usage tracking across project scopes", () => {
afterEach(() => {
clearSharedUsageTrackingServicesForTesting();
});

it("gives two project scopes in one process one tracker and one snapshot", async () => {
const adeDir = "/tmp/ade-shared-usage-one";
const dependencies = fastDependencies();
const make = vi.fn(() => createUsageTrackingService({ logger, dependencies }));

const first = attachScope(adeDir, make, { projectId: "p1", projectRoot: "/repo-one" });
const second = attachScope(adeDir, make, { projectId: "p2", projectRoot: "/repo-two" });

expect(make).toHaveBeenCalledTimes(1);
expect(peekSharedUsageTrackingService(adeDir)).toBeDefined();

await first.scope.poll();

expect(first.events).toHaveLength(1);
expect(second.events).toHaveLength(1);
const a = first.events[0]!.snapshot;
const b = second.events[0]!.snapshot;
expect(a.revision?.producerId).toBeTruthy();
expect(b.revision?.producerId).toBe(a.revision?.producerId);
expect(b.revision?.seq).toBe(a.revision?.seq);
expect(b.lastPolledAt).toBe(a.lastPolledAt);
// The provider was polled once for the machine, not once per project.
expect(dependencies.pollClaudeUsage).toHaveBeenCalledTimes(1);

first.scope.dispose();
second.scope.dispose();
});

it("keeps delivering to the remaining scope when one project closes", async () => {
const adeDir = "/tmp/ade-shared-usage-close";
const dependencies = fastDependencies();
const make = vi.fn(() => createUsageTrackingService({ logger, dependencies }));

const first = attachScope(adeDir, make, { projectId: "p1", projectRoot: "/repo-one" });
const second = attachScope(adeDir, make, { projectId: "p2", projectRoot: "/repo-two" });

first.scope.start();
second.scope.start();
await first.scope.poll();
const beforeClose = second.events.length;

first.scope.dispose();
await second.scope.poll();

expect(second.events.length).toBeGreaterThan(beforeClose);
expect(first.events).toHaveLength(beforeClose);
expect(second.events.at(-1)!.snapshot.revision!.producerId)
.toBe(second.events[0]!.snapshot.revision!.producerId);
// The last scope takes the tracker with it.
second.scope.dispose();
expect(peekSharedUsageTrackingService(adeDir)).toBeUndefined();
});
});
Loading
Loading