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

@ -7,7 +7,7 @@ export async function GET(request: Request): Promise<Response> {
const projectRoot = url.searchParams.get('projectRoot') ?? process.cwd();
try {
const issues = await readIssuesFromDisk({ projectRoot });
const issues = await readIssuesFromDisk({ projectRoot, preferBd: true });
return NextResponse.json({ ok: true, issues });
} catch (error) {
return NextResponse.json(

View file

@ -1,26 +1,30 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { canonicalizeWindowsPath } from '../../../lib/pathing';
import { issuesEventBus, SSE_CONNECTED_FRAME, SSE_HEARTBEAT_FRAME, toSseFrame } from '../../../lib/realtime';
import { issuesEventBus, activityEventBus, SSE_CONNECTED_FRAME, SSE_HEARTBEAT_FRAME, toSseFrame, toActivitySseFrame } from '../../../lib/realtime';
import { getIssuesWatchManager } from '../../../lib/watcher';
const encoder = new TextEncoder();
const HEARTBEAT_MS = 15_000;
const LAST_TOUCHED_POLL_MS = 1_000;
async function readLastTouchedVersion(filePath: string): Promise<number | null> {
try {
const stat = await fs.stat(filePath);
return stat.mtimeMs;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
return null;
}
}
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const projectRootSearchParam = url.searchParams.get('projectRoot');
if (!projectRootSearchParam) {
return Response.json(
{
ok: false,
error: {
classification: 'bad_args',
message: 'The `projectRoot` query parameter is required.',
},
},
{ status: 400 },
);
}
const projectRoot = canonicalizeWindowsPath(projectRootSearchParam);
const projectRoot = canonicalizeWindowsPath(projectRootSearchParam || process.cwd());
try {
getIssuesWatchManager().startWatch(projectRoot);
@ -51,16 +55,45 @@ export async function GET(request: Request): Promise<Response> {
write(SSE_CONNECTED_FRAME);
const unsubscribe = issuesEventBus.subscribe(
const unsubscribeIssues = issuesEventBus.subscribe(
(event) => {
write(toSseFrame(event));
},
{ projectRoot },
);
const unsubscribeActivity = activityEventBus.subscribe(
(event) => {
write(toActivitySseFrame(event));
},
{ projectRoot },
);
const heartbeat = setInterval(() => {
write(SSE_HEARTBEAT_FRAME);
}, HEARTBEAT_MS);
const lastTouchedPath = path.join(projectRoot, '.beads', 'last-touched');
let lastTouchedVersion: number | null = null;
const pollLastTouched = async () => {
const nextVersion = await readLastTouchedVersion(lastTouchedPath);
if (nextVersion === null) {
return;
}
if (lastTouchedVersion === null) {
lastTouchedVersion = nextVersion;
return;
}
if (nextVersion !== lastTouchedVersion) {
lastTouchedVersion = nextVersion;
write(toSseFrame(issuesEventBus.emit(projectRoot, lastTouchedPath, 'changed')));
}
};
const touchedPoll = setInterval(() => {
void pollLastTouched();
}, LAST_TOUCHED_POLL_MS);
void pollLastTouched();
const close = () => {
if (closed) {
@ -69,7 +102,9 @@ export async function GET(request: Request): Promise<Response> {
closed = true;
clearInterval(heartbeat);
unsubscribe();
clearInterval(touchedPoll);
unsubscribeIssues();
unsubscribeActivity();
request.signal.removeEventListener('abort', close);
try {
controller.close();
@ -96,4 +131,4 @@ export async function GET(request: Request): Promise<Response> {
Connection: 'keep-alive',
},
});
}
}