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
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { getAllRules, clearAllRules, clearCache } from '../permissions/trust-sto
import type { AddTrustRule } from '../daemon/ipc-contract.js';
import type { HandlerContext } from '../daemon/handlers.js';
import type { ServerMessage } from '../daemon/ipc-contract.js';
import { DebouncerMap } from '../util/debounce.js';

function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
const sent: ServerMessage[] = [];
Expand All @@ -73,7 +74,7 @@ function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
cuObservationParseSequence: new Map(),
socketSandboxOverride: new Map(),
sharedRequestTimestamps: [],
debounceTimers: new Map(),
debounceTimers: new DebouncerMap({ defaultDelayMs: 200 }),
suppressConfigReload: false,
setSuppressConfigReload: () => {},
updateConfigFingerprint: () => {},
Expand Down
3 changes: 2 additions & 1 deletion assistant/src/__tests__/handlers-cu-observation-blob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ mock.module('../util/logger.js', () => ({
import { handleMessage, type HandlerContext } from '../daemon/handlers.js';
import type { CuObservation, IpcBlobRef, ServerMessage } from '../daemon/ipc-contract.js';
import { ComputerUseSession } from '../daemon/computer-use-session.js';
import { DebouncerMap } from '../util/debounce.js';

/** Write a blob file to the test blob directory and return the IpcBlobRef. */
function writeBlobFile(content: Buffer, kind: IpcBlobRef['kind'], encoding: IpcBlobRef['encoding']): IpcBlobRef {
Expand Down Expand Up @@ -86,7 +87,7 @@ function createTestContext(sessionId: string): {
cuObservationParseSequence: new Map(),
socketSandboxOverride: new Map(),
sharedRequestTimestamps: [],
debounceTimers: new Map(),
debounceTimers: new DebouncerMap({ defaultDelayMs: 200 }),
suppressConfigReload: false,
setSuppressConfigReload: () => {},
updateConfigFingerprint: () => {},
Expand Down
3 changes: 2 additions & 1 deletion assistant/src/__tests__/handlers-ipc-blob-probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ mock.module('../util/logger.js', () => ({

import { handleMessage, type HandlerContext } from '../daemon/handlers.js';
import type { IpcBlobProbe, ServerMessage } from '../daemon/ipc-contract.js';
import { DebouncerMap } from '../util/debounce.js';

/** Write a probe file to the test blob directory. */
function writeProbeFile(probeId: string, content: Buffer): string {
Expand All @@ -63,7 +64,7 @@ function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
cuObservationParseSequence: new Map(),
socketSandboxOverride: new Map(),
sharedRequestTimestamps: [],
debounceTimers: new Map(),
debounceTimers: new DebouncerMap({ defaultDelayMs: 200 }),
suppressConfigReload: false,
setSuppressConfigReload: () => {},
updateConfigFingerprint: () => {},
Expand Down
3 changes: 2 additions & 1 deletion assistant/src/__tests__/handlers-slack-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import type {
ShareToSlackRequest,
ServerMessage,
} from '../daemon/ipc-contract.js';
import { DebouncerMap } from '../util/debounce.js';

function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
const sent: ServerMessage[] = [];
Expand All @@ -93,7 +94,7 @@ function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
cuObservationParseSequence: new Map(),
socketSandboxOverride: new Map(),
sharedRequestTimestamps: [],
debounceTimers: new Map(),
debounceTimers: new DebouncerMap({ defaultDelayMs: 200 }),
suppressConfigReload: false,
setSuppressConfigReload: () => {},
updateConfigFingerprint: () => {},
Expand Down
3 changes: 2 additions & 1 deletion assistant/src/__tests__/handlers-telegram-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ import type {
TelegramConfigRequest,
ServerMessage,
} from '../daemon/ipc-contract.js';
import { DebouncerMap } from '../util/debounce.js';

function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
const sent: ServerMessage[] = [];
Expand All @@ -131,7 +132,7 @@ function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
cuObservationParseSequence: new Map(),
socketSandboxOverride: new Map(),
sharedRequestTimestamps: [],
debounceTimers: new Map(),
debounceTimers: new DebouncerMap({ defaultDelayMs: 200 }),
suppressConfigReload: false,
setSuppressConfigReload: () => {},
updateConfigFingerprint: () => {},
Expand Down
3 changes: 2 additions & 1 deletion assistant/src/__tests__/handlers-twitter-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ import type {
TwitterIntegrationConfigRequest,
ServerMessage,
} from '../daemon/ipc-contract.js';
import { DebouncerMap } from '../util/debounce.js';

function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
const sent: ServerMessage[] = [];
Expand All @@ -126,7 +127,7 @@ function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
cuObservationParseSequence: new Map(),
socketSandboxOverride: new Map(),
sharedRequestTimestamps: [],
debounceTimers: new Map(),
debounceTimers: new DebouncerMap({ defaultDelayMs: 200 }),
suppressConfigReload: false,
setSuppressConfigReload: () => {},
updateConfigFingerprint: () => {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { handleToolPermissionSimulate } from '../daemon/handlers/config.js';
import { addRule, clearAllRules, clearCache } from '../permissions/trust-store.js';
import type { ToolPermissionSimulateRequest, ToolPermissionSimulateResponse, ServerMessage } from '../daemon/ipc-contract.js';
import type { HandlerContext } from '../daemon/handlers.js';
import { DebouncerMap } from '../util/debounce.js';

function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
const sent: ServerMessage[] = [];
Expand All @@ -71,7 +72,7 @@ function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
cuObservationParseSequence: new Map(),
socketSandboxOverride: new Map(),
sharedRequestTimestamps: [],
debounceTimers: new Map(),
debounceTimers: new DebouncerMap({ defaultDelayMs: 200 }),
suppressConfigReload: false,
setSuppressConfigReload: () => {},
updateConfigFingerprint: () => {},
Expand Down
3 changes: 2 additions & 1 deletion assistant/src/__tests__/twitter-auth-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ import type {
TwitterAuthStatusRequest,
ServerMessage,
} from '../daemon/ipc-contract.js';
import { DebouncerMap } from '../util/debounce.js';

// Mock global fetch for Twitter /2/users/me
const _originalFetch = globalThis.fetch;
Expand All @@ -162,7 +163,7 @@ function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
cuObservationParseSequence: new Map(),
socketSandboxOverride: new Map(),
sharedRequestTimestamps: [],
debounceTimers: new Map(),
debounceTimers: new DebouncerMap({ defaultDelayMs: 200 }),
suppressConfigReload: false,
setSuppressConfigReload: () => {},
updateConfigFingerprint: () => {},
Expand Down
15 changes: 3 additions & 12 deletions assistant/src/daemon/handlers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,7 @@ export function handleModelSet(
ctx.setSuppressConfigReload(wasSuppressed);
throw err;
}
const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
if (existingSuppressTimer) clearTimeout(existingSuppressTimer);
const resetTimer = setTimeout(() => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
ctx.debounceTimers.set('__suppress_reset__', resetTimer);
ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);

// Re-initialize provider with the new model so LLM calls use it
const config = getConfig();
Expand Down Expand Up @@ -195,10 +192,7 @@ export function handleImageGenModelSet(
ctx.setSuppressConfigReload(wasSuppressed);
throw err;
}
const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
if (existingSuppressTimer) clearTimeout(existingSuppressTimer);
const resetTimer = setTimeout(() => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
ctx.debounceTimers.set('__suppress_reset__', resetTimer);
ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);

ctx.updateConfigFingerprint();
log.info({ model: msg.model }, 'Image generation model updated');
Expand Down Expand Up @@ -547,10 +541,7 @@ export function handleIngressConfig(
ctx.setSuppressConfigReload(wasSuppressed);
throw err;
}
const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
if (existingSuppressTimer) clearTimeout(existingSuppressTimer);
const resetTimer = setTimeout(() => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
ctx.debounceTimers.set('__suppress_reset__', resetTimer);
ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);

// Propagate to the gateway's process environment so it picks up the
// new URL when it is restarted. For the local-deployment path the
Expand Down
3 changes: 2 additions & 1 deletion assistant/src/daemon/handlers/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { estimateBase64Bytes } from '../assistant-attachments.js';
import type { ClientMessage, CuSessionCreate, ServerMessage, SessionTransportMetadata } from '../ipc-protocol.js';
import type { SecretPromptResult } from '../../permissions/secret-prompter.js';
import { getConfig } from '../../config/loader.js';
import type { DebouncerMap } from '../../util/debounce.js';

const log = getLogger('handlers');

Expand Down Expand Up @@ -115,7 +116,7 @@ export interface HandlerContext {
cuObservationParseSequence: Map<string, number>;
socketSandboxOverride: Map<net.Socket, boolean>;
sharedRequestTimestamps: number[];
debounceTimers: Map<string, ReturnType<typeof setTimeout>>;
debounceTimers: DebouncerMap;
suppressConfigReload: boolean;
setSuppressConfigReload(value: boolean): void;
updateConfigFingerprint(): void;
Expand Down
25 changes: 5 additions & 20 deletions assistant/src/daemon/handlers/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,7 @@ export function handleSkillsEnable(
}
invalidateConfigCache();

const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
if (existingSuppressTimer) clearTimeout(existingSuppressTimer);
const resetTimer = setTimeout(() => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
ctx.debounceTimers.set('__suppress_reset__', resetTimer);
ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);

ctx.updateConfigFingerprint();

Expand Down Expand Up @@ -108,10 +105,7 @@ export function handleSkillsDisable(
}
invalidateConfigCache();

const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
if (existingSuppressTimer) clearTimeout(existingSuppressTimer);
const resetTimer = setTimeout(() => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
ctx.debounceTimers.set('__suppress_reset__', resetTimer);
ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);

ctx.updateConfigFingerprint();

Expand Down Expand Up @@ -165,10 +159,7 @@ export function handleSkillsConfigure(
}
invalidateConfigCache();

const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
if (existingSuppressTimer) clearTimeout(existingSuppressTimer);
const resetTimer = setTimeout(() => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
ctx.debounceTimers.set('__suppress_reset__', resetTimer);
ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);

ctx.updateConfigFingerprint();

Expand Down Expand Up @@ -226,10 +217,7 @@ export async function handleSkillsInstall(
throw err;
}
invalidateConfigCache();
const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
if (existingSuppressTimer) clearTimeout(existingSuppressTimer);
const resetTimer = setTimeout(() => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
ctx.debounceTimers.set('__suppress_reset__', resetTimer);
ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
ctx.updateConfigFingerprint();
} catch (err) {
log.warn({ err, skillId }, 'Failed to auto-enable installed skill');
Expand Down Expand Up @@ -321,10 +309,7 @@ export async function handleSkillsUninstall(
}
invalidateConfigCache();

const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
if (existingSuppressTimer) clearTimeout(existingSuppressTimer);
const resetTimer = setTimeout(() => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
ctx.debounceTimers.set('__suppress_reset__', resetTimer);
ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);

ctx.updateConfigFingerprint();
}
Expand Down
53 changes: 11 additions & 42 deletions assistant/src/daemon/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { tryRouteCallMessage } from '../calls/call-bridge.js';
import { resolveSlash } from './session-slash.js';
import { createUserMessage, createAssistantMessage } from '../agent/message-types.js';
import { registerDaemonCallbacks } from '../work-items/work-item-runner.js';
import { DebouncerMap } from '../util/debounce.js';

const log = getLogger('server');

Expand Down Expand Up @@ -77,8 +78,11 @@ export class DaemonServer {
private socketPath: string;
private httpPort: number | undefined;
private watchers: FSWatcher[] = [];
private debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
private static readonly MAX_DEBOUNCE_ENTRIES = 1000;
private debounceTimers = new DebouncerMap({
defaultDelayMs: 200,
maxEntries: 1000,
protectedKeyPrefix: '__',
});
private suppressConfigReload = false;
private lastConfigFingerprint = '';
private lastConfigRefreshTime = 0;
Expand Down Expand Up @@ -373,16 +377,10 @@ export class DaemonServer {
if (!filename) return;
const file = String(filename);
if (!handlers[file]) return;
const key = `file:${file}`;
const existing = this.debounceTimers.get(key);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
this.debounceTimers.delete(key);
this.debounceTimers.schedule(`file:${file}`, () => {
log.info({ file }, 'File changed, reloading');
handlers[file]();
}, 200);
this.debounceTimers.set(key, timer);
this.enforceDebounceLimit();
});
});
this.watchers.push(watcher);
log.info({ dir }, `Watching ${label}`);
Expand Down Expand Up @@ -478,51 +476,22 @@ export class DaemonServer {
}

private stopFileWatchers(): void {
for (const timer of this.debounceTimers.values()) {
clearTimeout(timer);
}
this.debounceTimers.clear();
this.debounceTimers.cancelAll();
for (const watcher of this.watchers) {
watcher.close();
}
this.watchers = [];
}

/**
* Evict the oldest file-watcher debounce entries when the map exceeds the safety cap.
* Map iteration order follows insertion order, so the first keys are oldest.
* Protects system timers (keys starting with '__') from eviction, so critical
* timers like '__suppress_reset__' are never cleared during bursts of file events.
*/
private enforceDebounceLimit(): void {
if (this.debounceTimers.size <= DaemonServer.MAX_DEBOUNCE_ENTRIES) return;
const excess = this.debounceTimers.size - DaemonServer.MAX_DEBOUNCE_ENTRIES;
let removed = 0;
for (const [key, timer] of this.debounceTimers) {
if (removed >= excess) break;
// Skip system timers (those with keys starting with '__')
if (key.startsWith('__')) continue;
clearTimeout(timer);
this.debounceTimers.delete(key);
removed++;
}
}

private startSkillsWatchers(evictSessions: () => void): void {
const skillsDir = getWorkspaceSkillsDir();
if (!existsSync(skillsDir)) return;

const scheduleSkillsReload = (file: string): void => {
const key = `skills:${file}`;
const existing = this.debounceTimers.get(key);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
this.debounceTimers.delete(key);
this.debounceTimers.schedule(`skills:${file}`, () => {
log.info({ file }, 'Skill file changed, reloading');
evictSessions();
}, 200);
this.debounceTimers.set(key, timer);
this.enforceDebounceLimit();
});
};

try {
Expand Down
14 changes: 5 additions & 9 deletions assistant/src/hooks/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { discoverHooks } from './discovery.js';
import { runHookScript } from './runner.js';
import { getLogger, isDebug } from '../util/logger.js';
import { getHooksDir } from '../util/platform.js';
import { Debouncer } from '../util/debounce.js';
import type { DiscoveredHook, HookEventName, HookEventData, HookTriggerResult } from './types.js';

const log = getLogger('hooks-manager');
Expand All @@ -11,7 +12,7 @@ export class HookManager {
private hooks: DiscoveredHook[] = [];
private eventIndex = new Map<HookEventName, DiscoveredHook[]>();
private watcher: FSWatcher | null = null;
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private readonly debouncer = new Debouncer(500);

initialize(): void {
this.hooks = discoverHooks();
Expand Down Expand Up @@ -82,12 +83,10 @@ export class HookManager {

try {
this.watcher = watch(hooksDir, { recursive: true }, (_eventType, filename) => {
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
this.debouncer.schedule(() => {
log.info({ filename: String(filename ?? '') }, 'Hooks directory changed, reloading');
this.reload();
}, 500);
});
});
log.info({ dir: hooksDir }, 'Watching hooks directory for changes');
} catch (err) {
Expand All @@ -96,10 +95,7 @@ export class HookManager {
}

stopWatching(): void {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
this.debouncer.cancel();
if (this.watcher) {
this.watcher.close();
this.watcher = null;
Expand Down
Loading
Loading