Skip to content
Closed
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
171 changes: 171 additions & 0 deletions src/services/observer/ObserverBudgetTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* ObserverBudgetTracker
*
* Addresses Bug #1938: Observer background sessions burn excessive tokens with no budget cap.
*
* Provides:
* 1. Daily token budget tracking (resets at midnight)
* 2. Throttling between observer runs (configurable minimum interval)
* 3. Budget check before processing each observation
*
* All state is in-memory (resets on worker restart, which is acceptable since
* it means a restart gives a fresh daily budget).
*/

import { logger } from '../../utils/logger.js';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js';

export class ObserverBudgetTracker {
private static instance: ObserverBudgetTracker | null = null;

/** Total tokens consumed today */
private tokensConsumedToday: number = 0;

/** Date string (YYYY-MM-DD) for the current budget period */
private currentBudgetDay: string;

/** Timestamp of the last observation processing */
private lastObservationTimestamp: number = 0;

/** Number of observations skipped due to budget exhaustion (for logging) */
private skippedDueToBudget: number = 0;

/** Number of observations skipped due to throttling (for logging) */
private skippedDueToThrottle: number = 0;

private constructor() {
this.currentBudgetDay = this.getTodayString();
}

static getInstance(): ObserverBudgetTracker {
if (!ObserverBudgetTracker.instance) {
ObserverBudgetTracker.instance = new ObserverBudgetTracker();
}
return ObserverBudgetTracker.instance;
}

/**
* Reset the singleton (useful for testing).
*/
static resetInstance(): void {
ObserverBudgetTracker.instance = null;
}

/**
* Check whether an observation should be processed, enforcing both
* the daily token budget and the throttle interval.
*
* Returns true if the observation is allowed, false if it should be skipped.
*/
canProcessObservation(): boolean {
this.maybeResetDailyBudget();

const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const maxTokensPerDay = parseInt(settings.CLAUDE_MEM_OBSERVER_MAX_TOKENS_PER_DAY, 10) || 100_000;
const throttleMs = parseInt(settings.CLAUDE_MEM_OBSERVER_THROTTLE_MS, 10) || 5000;
Comment on lines +64 to +66
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 || 0 fallback makes it impossible to disable the budget cap

parseInt(settings.CLAUDE_MEM_OBSERVER_MAX_TOKENS_PER_DAY, 10) || 100_000 treats 0 as falsy and falls back to 100 000. A user who sets the value to "0" (to effectively disable the cap) will silently get the default limit instead. The same issue applies to the throttleMs line. Consider using an explicit NaN guard:

Suggested change
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const maxTokensPerDay = parseInt(settings.CLAUDE_MEM_OBSERVER_MAX_TOKENS_PER_DAY, 10) || 100_000;
const throttleMs = parseInt(settings.CLAUDE_MEM_OBSERVER_THROTTLE_MS, 10) || 5000;
const parsed = parseInt(settings.CLAUDE_MEM_OBSERVER_MAX_TOKENS_PER_DAY, 10);
const maxTokensPerDay = !isNaN(parsed) && parsed > 0 ? parsed : 100_000;
const parsedThrottle = parseInt(settings.CLAUDE_MEM_OBSERVER_THROTTLE_MS, 10);
const throttleMs = !isNaN(parsedThrottle) && parsedThrottle >= 0 ? parsedThrottle : 5000;

Fix in Claude Code

Comment on lines +61 to +66
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Settings file read on every canProcessObservation() call

SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH) performs a synchronous disk read each time this method is invoked (potentially on every incoming observation). The same pattern is repeated in getBudgetStatus() and recordTokensUsed() (via maybeResetDailyBudget()). For high-frequency hook calls this could add measurable I/O overhead. A short-lived in-memory cache (e.g., re-read settings at most once per minute) would avoid repeated disk hits without sacrificing configurability.

Fix in Claude Code


// Check throttle
const now = Date.now();
const timeSinceLastObservation = now - this.lastObservationTimestamp;
if (this.lastObservationTimestamp > 0 && timeSinceLastObservation < throttleMs) {
this.skippedDueToThrottle++;
if (this.skippedDueToThrottle % 50 === 1) {
logger.debug('OBSERVER', 'Observation throttled', {
timeSinceLastMs: timeSinceLastObservation,
throttleMs,
totalSkippedThrottle: this.skippedDueToThrottle,
});
}
return false;
}

// Check budget
if (this.tokensConsumedToday >= maxTokensPerDay) {
this.skippedDueToBudget++;
if (this.skippedDueToBudget === 1 || this.skippedDueToBudget % 100 === 0) {
logger.warn('OBSERVER', 'Daily token budget exceeded, skipping observation', {
tokensConsumedToday: this.tokensConsumedToday,
maxTokensPerDay,
skippedCount: this.skippedDueToBudget,
budgetDay: this.currentBudgetDay,
});
}
return false;
}

return true;
}

/**
* Record that an observation was processed and how many tokens it consumed.
* Call this after the observation has been successfully processed.
*/
recordTokensUsed(tokenCount: number): void {
this.maybeResetDailyBudget();
this.tokensConsumedToday += tokenCount;
this.lastObservationTimestamp = Date.now();

logger.debug('OBSERVER', 'Token usage recorded', {
tokensUsed: tokenCount,
tokensConsumedToday: this.tokensConsumedToday,
budgetDay: this.currentBudgetDay,
});
Comment on lines +104 to +113
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 recordTokensUsed extends the throttle window unexpectedly

recordTokensUsed() sets this.lastObservationTimestamp = Date.now() at processing-completion time. canProcessObservation() uses that same field for its throttle check. This means every time a long-running AI call finishes, the throttle window restarts — in effect adding a throttleMs quiet period after each processing result, on top of the queue-time quiet period set by markObservationProcessed(). For a 30-second AI call with throttleMs = 5000, observations could be blocked for an extra 5 s after every completion even when the queue is drained and ready for new work.

If the intent is purely "don't flood the queue", only markObservationProcessed() (called at queue time) should update lastObservationTimestamp. Consider removing the timestamp assignment from recordTokensUsed().

Suggested change
recordTokensUsed(tokenCount: number): void {
this.maybeResetDailyBudget();
this.tokensConsumedToday += tokenCount;
this.lastObservationTimestamp = Date.now();
logger.debug('OBSERVER', 'Token usage recorded', {
tokensUsed: tokenCount,
tokensConsumedToday: this.tokensConsumedToday,
budgetDay: this.currentBudgetDay,
});
recordTokensUsed(tokenCount: number): void {
this.maybeResetDailyBudget();
this.tokensConsumedToday += tokenCount;
logger.debug('OBSERVER', 'Token usage recorded', {
tokensUsed: tokenCount,
tokensConsumedToday: this.tokensConsumedToday,
budgetDay: this.currentBudgetDay,
});
}

Fix in Claude Code

}

/**
* Mark that an observation was processed (updates the throttle timestamp)
* even when no token count is available yet (e.g. for queuing).
*/
markObservationProcessed(): void {
this.lastObservationTimestamp = Date.now();
}

/**
* Get current budget status for health/status endpoints.
*/
getBudgetStatus(): {
tokensConsumedToday: number;
maxTokensPerDay: number;
budgetDay: string;
skippedDueToBudget: number;
skippedDueToThrottle: number;
budgetExhausted: boolean;
} {
this.maybeResetDailyBudget();
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const maxTokensPerDay = parseInt(settings.CLAUDE_MEM_OBSERVER_MAX_TOKENS_PER_DAY, 10) || 100_000;

return {
tokensConsumedToday: this.tokensConsumedToday,
maxTokensPerDay,
budgetDay: this.currentBudgetDay,
skippedDueToBudget: this.skippedDueToBudget,
skippedDueToThrottle: this.skippedDueToThrottle,
budgetExhausted: this.tokensConsumedToday >= maxTokensPerDay,
};
}

/**
* Reset daily budget if the day has changed (midnight rollover).
*/
private maybeResetDailyBudget(): void {
const today = this.getTodayString();
if (today !== this.currentBudgetDay) {
logger.info('OBSERVER', 'Daily token budget reset', {
previousDay: this.currentBudgetDay,
previousTokens: this.tokensConsumedToday,
previousSkippedBudget: this.skippedDueToBudget,
previousSkippedThrottle: this.skippedDueToThrottle,
});
this.currentBudgetDay = today;
this.tokensConsumedToday = 0;
this.skippedDueToBudget = 0;
this.skippedDueToThrottle = 0;
}
}

private getTodayString(): string {
return new Date().toISOString().slice(0, 10);
}
Comment on lines +168 to +170
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Budget day uses UTC date, not local date

toISOString().slice(0, 10) returns the date in UTC. For users in non-UTC timezones the daily budget resets at their UTC midnight offset — for example, US Eastern users see a reset at 8 pm local time. Consider using local date parts instead:

Suggested change
private getTodayString(): string {
return new Date().toISOString().slice(0, 10);
}
private getTodayString(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}

Fix in Claude Code

}
2 changes: 2 additions & 0 deletions src/services/server/Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { errorHandler, notFoundHandler } from './ErrorHandler.js';
import { getSupervisor } from '../../supervisor/index.js';
import { isPidAlive } from '../../supervisor/process-registry.js';
import { ENV_PREFIXES, ENV_EXACT_MATCHES } from '../../supervisor/env-sanitizer.js';
import { ObserverBudgetTracker } from '../observer/ObserverBudgetTracker.js';

// Build-time injected version constant (set by esbuild define)
declare const __DEFAULT_PACKAGE_VERSION__: string;
Expand Down Expand Up @@ -175,6 +176,7 @@ export class Server {
initialized: this.options.getInitializationComplete(),
mcpReady: this.options.getMcpReady(),
ai: this.options.getAiStatus(),
observerBudget: ObserverBudgetTracker.getInstance().getBudgetStatus(),
});
});

Expand Down
Loading
Loading