fix(realtime): unify authority via shared SSE subscription and watcher-v3

We resolved a major project fragmentation issue today. The Graph page was technically divergent from the Kanban board, causing P0 'stale data' bugs. We realized that 'Polling' is the enemy of truth in a multi-agent system.

Triumphs:
- Refactored the core SSE transport into a shared useBeadsSubscription hook. Now Kanban, Graph, and Sessions all obey the same lifecycle: Event -> Authority Fetch -> Reconcile.
- Upgraded the Chokidar watcher to monitor the global .beadboard/agent/messages directory, ensuring agent communication arrives instantly in the social feed.
- Forced a watcher version bump to 3 to solve the ghost-listener problem where old watchers were blocking file access during HMR.

Raw Honest Moment:
We spent significant time debugging why 'closed' issues were missing from the UI, only to find we were victims of our own CLI defaults (--limit 50). The fix was simple but humiliating: we just needed to ask for the truth (--all --limit 0).
This commit is contained in:
zenchantlive 2026-02-14 00:20:20 -08:00
parent ab051952bd
commit 28abfe3ce2
6 changed files with 438 additions and 24 deletions

View file

@ -1,12 +1,21 @@
import chokidar, { type FSWatcher } from 'chokidar';
import path from 'node:path';
import os from 'node:os';
import { ProjectEventCoalescer } from './coalescer';
import { windowsPathKey } from './pathing';
import { issuesEventBus, type IssuesChangeKind, type IssuesEventBus } from './realtime';
import { resolveIssuesJsonlPathCandidates } from './read-issues';
import { issuesEventBus, activityEventBus, type IssuesChangeKind, type IssuesEventBus, type ActivityEventBus } from './realtime';
import { readIssuesFromDisk, resolveIssuesJsonlPathCandidates } from './read-issues';
import { diffSnapshots } from './snapshot-differ';
import type { BeadIssueWithProject } from './types';
type FileEventName = 'add' | 'change' | 'unlink';
function getGlobalAgentMessagesPath(): string {
const userProfile = process.env.USERPROFILE?.trim() || os.homedir();
return path.join(userProfile, '.beadboard', 'agent', 'messages');
}
interface WatchRegistration {
projectRoot: string;
watcher: FSWatcher;
@ -15,12 +24,16 @@ interface WatchRegistration {
export interface WatchManagerOptions {
debounceMs?: number;
eventBus?: IssuesEventBus;
activityBus?: ActivityEventBus;
}
export class IssuesWatchManager {
private readonly registrations = new Map<string, WatchRegistration>();
private readonly snapshots = new Map<string, BeadIssueWithProject[]>();
private readonly eventBus: IssuesEventBus;
private readonly activityBus: ActivityEventBus;
private readonly coalescer: ProjectEventCoalescer<{
changedPath?: string;
@ -30,18 +43,69 @@ export class IssuesWatchManager {
constructor(options: WatchManagerOptions = {}) {
const debounceMs = options.debounceMs ?? 150;
this.eventBus = options.eventBus ?? issuesEventBus;
this.coalescer = new ProjectEventCoalescer(debounceMs, ({ projectRoot, payload }) => {
this.activityBus = options.activityBus ?? activityEventBus;
this.coalescer = new ProjectEventCoalescer(debounceMs, async ({ projectRoot, payload }) => {
console.log(`[Watcher] Processing event for ${projectRoot}: ${payload.kind} (${payload.changedPath})`);
// 1. Emit basic file change event
this.eventBus.emit(projectRoot, payload.changedPath, payload.kind);
// 2. Perform snapshot diffing if issues.jsonl changed
const changedPath = payload.changedPath || '';
const isIssuesJsonl = changedPath.endsWith('issues.jsonl') || changedPath.endsWith('issues.jsonl.new');
const isGlobalMessages = changedPath.includes('.beadboard') && changedPath.includes('messages');
if (isIssuesJsonl) {
console.log(`[Watcher] Issues changed. Syncing activity for ${projectRoot}...`);
await this.syncActivity(projectRoot);
} else if (isGlobalMessages) {
console.log(`[Watcher] Global agent messages changed. Triggering refresh for ${projectRoot}.`);
// No need to syncActivity (diff issues) if only messages changed,
// the 'issues' event emitted above will trigger client refresh.
}
});
}
startWatch(projectRoot: string): void {
private async syncActivity(projectRoot: string): Promise<void> {
const projectKey = windowsPathKey(projectRoot);
const previous = this.snapshots.get(projectKey) ?? null;
try {
const current = await readIssuesFromDisk({ projectRoot });
const events = diffSnapshots(previous, current);
this.snapshots.set(projectKey, current);
events.forEach(event => {
this.activityBus.emit(event);
});
} catch (error) {
console.error(`[Watcher] Failed to sync activity for ${projectRoot}:`, error);
}
}
async startWatch(projectRoot: string): Promise<void> {
const projectKey = windowsPathKey(projectRoot);
if (this.registrations.has(projectKey)) {
return;
}
// Pre-populate snapshot to avoid "all created" burst on first change
try {
const initial = await readIssuesFromDisk({ projectRoot });
this.snapshots.set(projectKey, initial);
} catch {
// Ignore initial read failure, will retry on first change
}
const watchedPaths = resolveIssuesJsonlPathCandidates(projectRoot);
watchedPaths.push(path.join(projectRoot, '.beads', 'beads.db'));
watchedPaths.push(path.join(projectRoot, '.beads', 'beads.db-wal'));
watchedPaths.push(path.join(projectRoot, '.beads', 'last-touched'));
// Add global agent messages to enable cross-project communication real-time updates
watchedPaths.push(getGlobalAgentMessagesPath());
const watcher = chokidar.watch(watchedPaths, {
ignoreInitial: true,
awaitWriteFinish: {
@ -101,13 +165,23 @@ export class IssuesWatchManager {
}
}
const WATCHER_VERSION = 3; // Bump this to force re-creation on HMR
const globalRegistry = globalThis as typeof globalThis & {
__beadboardWatchManager?: IssuesWatchManager;
__beadboardWatcherVersion?: number;
};
export function getIssuesWatchManager(): IssuesWatchManager {
if (!globalRegistry.__beadboardWatchManager) {
if (!globalRegistry.__beadboardWatchManager || globalRegistry.__beadboardWatcherVersion !== WATCHER_VERSION) {
if (globalRegistry.__beadboardWatchManager) {
console.log('[Watcher] Stopping stale watcher instance...');
// Best effort stop of old instance
void globalRegistry.__beadboardWatchManager.stopAll();
}
console.log(`[Watcher] Initializing new manager (v${WATCHER_VERSION})...`);
globalRegistry.__beadboardWatchManager = new IssuesWatchManager();
globalRegistry.__beadboardWatcherVersion = WATCHER_VERSION;
}
return globalRegistry.__beadboardWatchManager;