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
38 changes: 33 additions & 5 deletions apps/mcp-server/src/tui/events/hud-file-bridge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('HudFileBridge', () => {

it('emits MODE_CHANGED when currentMode changes in file', async () => {
writeHudState(hudFile, { currentMode: 'PLAN' });
bridge = new HudFileBridge(eventBus, hudFile, { debounceMs: 10 });
bridge = new HudFileBridge(eventBus, hudFile, { debounceMs: 10, pollIntervalMs: 50 });
bridge.start();

const modeChanges: Array<{ from: string | null; to: string }> = [];
Expand Down Expand Up @@ -61,7 +61,7 @@ describe('HudFileBridge', () => {

it('emits AGENT_ACTIVATED when activeAgent changes', async () => {
writeHudState(hudFile, { currentMode: 'PLAN', activeAgent: null });
bridge = new HudFileBridge(eventBus, hudFile, { debounceMs: 10 });
bridge = new HudFileBridge(eventBus, hudFile, { debounceMs: 10, pollIntervalMs: 50 });
bridge.start();

const activations: unknown[] = [];
Expand Down Expand Up @@ -107,13 +107,16 @@ describe('HudFileBridge', () => {
const modeChanges: Array<{ from: string | null; to: string }> = [];
eventBus.on(TUI_EVENTS.MODE_CHANGED, p => modeChanges.push(p));

// Simulate watcher error to trigger polling fallback
// Access internal watcher and emit error
// Simulate watcher error — polling is already running (hybrid mode)
const watcher = (bridge as unknown as { watcher: fs.FSWatcher | null }).watcher;
expect(watcher).not.toBeNull();
watcher!.emit('error', new Error('EPERM'));

// Write new state — polling should detect the change
// Watcher should be cleaned up after error
const bridgeInternal = bridge as unknown as { watcher: fs.FSWatcher | null };
expect(bridgeInternal.watcher).toBeNull();

// Write new state — polling safety net should detect the change
await sleep(100);
writeHudState(hudFile, { currentMode: 'ACT' });
await sleep(300);
Expand All @@ -122,6 +125,31 @@ describe('HudFileBridge', () => {
expect(modeChanges[modeChanges.length - 1]).toEqual({ from: 'PLAN', to: 'ACT' });
});

it('runs hybrid mode — both watch and poll active simultaneously', async () => {
writeHudState(hudFile, { currentMode: 'PLAN' });
bridge = new HudFileBridge(eventBus, hudFile, { debounceMs: 10, pollIntervalMs: 50 });
bridge.start();

const bridgeInternal = bridge as unknown as {
watcher: fs.FSWatcher | null;
pollInterval: ReturnType<typeof setInterval> | null;
};

// Both watcher and polling should be active (hybrid mode)
expect(bridgeInternal.watcher).not.toBeNull();
expect(bridgeInternal.pollInterval).not.toBeNull();

// Polling alone should detect changes even if fs.watch is silent
const modeChanges: Array<{ from: string | null; to: string }> = [];
eventBus.on(TUI_EVENTS.MODE_CHANGED, p => modeChanges.push(p));

writeHudState(hudFile, { currentMode: 'EVAL' });
await sleep(300);

expect(modeChanges.length).toBeGreaterThanOrEqual(1);
expect(modeChanges[modeChanges.length - 1]).toEqual({ from: 'PLAN', to: 'EVAL' });
});

it('handles malformed JSON gracefully', async () => {
writeHudState(hudFile, { currentMode: 'PLAN' });
bridge = new HudFileBridge(eventBus, hudFile, { debounceMs: 10 });
Expand Down
16 changes: 10 additions & 6 deletions apps/mcp-server/src/tui/events/hud-file-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const VALID_MODES = new Set(['PLAN', 'ACT', 'EVAL', 'AUTO']);
export interface HudFileBridgeOptions {
/** Debounce interval in ms (default: 150) */
debounceMs?: number;
/** Polling interval in ms when fs.watch fails (default: 1000) */
/** Polling interval in ms for hybrid safety-net polling (default: 200) */
pollIntervalMs?: number;
}

Expand All @@ -41,7 +41,7 @@ export class HudFileBridge {
this.eventBus = eventBus;
this.filePath = filePath;
this.debounceMs = options?.debounceMs ?? 150;
this.pollIntervalMs = options?.pollIntervalMs ?? 1000;
this.pollIntervalMs = options?.pollIntervalMs ?? 200;
}

start(): void {
Expand All @@ -61,11 +61,13 @@ export class HudFileBridge {
if (!filename || filename === basename) this.scheduleProcess();
});
this.watcher.on('error', () => {
// Directory deleted or inaccessible — fall back to polling
// Watcher failed — close it; polling is already running as safety net
this.watcher?.close();
this.watcher = null;
this.startPollingFallback();
process.stderr.write('[codingbuddy] fs.watch failed, polling safety net active\n');
});
// Hybrid mode: run polling alongside fs.watch as a silent safety net
this.startPollingFallback(true);
} catch {
// Directory doesn't exist yet — poll until it appears
this.pollUntilExists();
Expand Down Expand Up @@ -135,9 +137,11 @@ export class HudFileBridge {
}
}

private startPollingFallback(): void {
private startPollingFallback(silent: boolean = false): void {
if (this.stopped || this.pollInterval) return;
process.stderr.write('[codingbuddy] fs.watch failed, falling back to polling\n');
if (!silent) {
process.stderr.write('[codingbuddy] fs.watch failed, falling back to polling\n');
}
try {
const stat = fs.statSync(this.filePath);
this.lastMtimeMs = stat.mtimeMs;
Expand Down
Loading