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:
parent
ab051952bd
commit
28abfe3ce2
6 changed files with 438 additions and 24 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue