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,4 +1,5 @@
import { canonicalizeWindowsPath, windowsPathKey } from './pathing';
import type { ActivityEvent } from './activity';
export type IssuesChangeKind = 'changed' | 'renamed';
@ -10,11 +11,21 @@ export interface IssuesChangedEvent {
at: string;
}
export interface ActivityDispatchedEvent {
id: number;
event: ActivityEvent;
}
interface Subscriber {
projectKey?: string;
listener: (event: IssuesChangedEvent) => void;
}
interface ActivitySubscriber {
projectKey?: string;
listener: (event: ActivityDispatchedEvent) => void;
}
export interface SubscribeOptions {
projectRoot?: string;
}
@ -27,6 +38,7 @@ export class IssuesEventBus {
private nextSubscriberId = 1;
emit(projectRoot: string, changedPath?: string, kind: IssuesChangeKind = 'changed'): IssuesChangedEvent {
console.log(`[IssuesBus] Emitting event: ${kind} for ${projectRoot} (${changedPath})`);
const canonicalProjectRoot = canonicalizeWindowsPath(projectRoot);
const projectKey = windowsPathKey(canonicalProjectRoot);
const event: IssuesChangedEvent = {
@ -73,11 +85,111 @@ export class IssuesEventBus {
}
}
export const issuesEventBus = new IssuesEventBus();
import { loadActivityHistory, saveActivityHistory } from './activity-persistence';
export class ActivityEventBus {
private nextEventId = 1;
private readonly subscribers = new Map<number, ActivitySubscriber>();
private readonly history: ActivityEvent[] = [];
private readonly MAX_HISTORY = 100;
private initialized = false;
private nextSubscriberId = 1;
constructor() {
this.init();
}
private async init() {
const history = await loadActivityHistory();
this.history.push(...history);
this.initialized = true;
}
emit(activity: ActivityEvent): ActivityDispatchedEvent {
const projectKey = windowsPathKey(activity.projectId);
const event: ActivityDispatchedEvent = {
id: this.nextEventId,
event: activity,
};
this.nextEventId += 1;
// Buffer history
this.history.unshift(activity);
if (this.history.length > this.MAX_HISTORY) {
this.history.pop();
}
// Persist async
void saveActivityHistory(this.history);
for (const subscriber of this.subscribers.values()) {
if (!subscriber.projectKey || subscriber.projectKey === projectKey) {
subscriber.listener(event);
}
}
return event;
}
getHistory(projectRoot?: string): ActivityEvent[] {
if (!projectRoot) {
return [...this.history];
}
const key = windowsPathKey(canonicalizeWindowsPath(projectRoot));
return this.history.filter(e => windowsPathKey(e.projectId) === key);
}
subscribe(listener: (event: ActivityDispatchedEvent) => void, options: SubscribeOptions = {}): () => void {
const id = this.nextSubscriberId;
this.nextSubscriberId += 1;
const projectKey = options.projectRoot ? windowsPathKey(canonicalizeWindowsPath(options.projectRoot)) : undefined;
this.subscribers.set(id, {
listener,
projectKey,
});
return () => {
this.subscribers.delete(id);
};
}
getSubscriberCount(): number {
return this.subscribers.size;
}
resetForTests(): void {
this.subscribers.clear();
this.history.length = 0;
this.nextSubscriberId = 1;
this.nextEventId = 1;
}
}
const globalRegistry = globalThis as typeof globalThis & {
__beadboardIssuesEventBus?: IssuesEventBus;
__beadboardActivityEventBus?: ActivityEventBus;
};
export const issuesEventBus = globalRegistry.__beadboardIssuesEventBus ?? new IssuesEventBus();
if (!globalRegistry.__beadboardIssuesEventBus) {
globalRegistry.__beadboardIssuesEventBus = issuesEventBus;
}
export const activityEventBus = globalRegistry.__beadboardActivityEventBus ?? new ActivityEventBus();
if (!globalRegistry.__beadboardActivityEventBus) {
globalRegistry.__beadboardActivityEventBus = activityEventBus;
}
export function toSseFrame(event: IssuesChangedEvent): string {
return `id: ${event.id}\nevent: issues\ndata: ${JSON.stringify(event)}\n\n`;
}
export function toActivitySseFrame(event: ActivityDispatchedEvent): string {
return `id: ${event.id}\nevent: activity\ndata: ${JSON.stringify(event.event)}\n\n`;
}
export const SSE_HEARTBEAT_FRAME = ': heartbeat\n\n';
export const SSE_CONNECTED_FRAME = ': connected\n\n';