feat(telemetry): complete bb-buff.1.3 - Backend Liveness Refactor

STORY:
The session backend needed to aggregate agent health from a live
telemetry stream rather than static bead metadata. This refactor
makes liveness signals real-time and accurate.

COLLABORATION:
We extended the ActivityEvent model with a native 'heartbeat' kind,
updated extendActivityLease() to emit through the activity bus, and
refactored getAgentLivenessMap() to prioritize heartbeat activity
history over stale bead metadata.

DELIVERABLES:
- ActivityEvent extended with 'heartbeat' kind
- extendActivityLease() emits heartbeats through activity bus
- getAgentLivenessMap() prefers telemetry over static metadata
- Registry APIs support projectRoot injection for testing
- Tests verify preference logic via TDD

VERIFICATION:
- 93/93 tests PASSING
- Heartbeat override verified in isolated temp projects

CLOSES: bb-buff.1.3
BLOCKS: bb-buff.3.2, bb-buff.3.3, bb-buff.2.1
This commit is contained in:
zenchantlive 2026-02-15 21:14:05 -08:00
parent 0016b57e37
commit 4ee550c333
36 changed files with 1380 additions and 541 deletions

View file

@ -86,7 +86,7 @@ export async function GET(request: Request): Promise<Response> {
}
if (nextVersion !== lastTouchedVersion) {
lastTouchedVersion = nextVersion;
write(toSseFrame(issuesEventBus.emit(projectRoot, lastTouchedPath, 'changed')));
write(toSseFrame(issuesEventBus.emit(projectRoot, lastTouchedPath, 'telemetry')));
}
};

View file

@ -14,7 +14,7 @@ export async function GET(request: Request): Promise<Response> {
const issues = await readIssuesFromDisk({ projectRoot, preferBd: true });
const activity = activityEventBus.getHistory(projectRoot);
const communication = await getCommunicationSummary();
const livenessMap = await getAgentLivenessMap();
const livenessMap = await getAgentLivenessMap(projectRoot, activity);
const incursions = await calculateIncursions();
const agentsResult = await listAgents({});

View file

@ -1,8 +1,10 @@
import { SessionsPage } from '../../components/sessions/sessions-page';
import type { SwarmGroup } from '../../components/sessions/sessions-header';
import { readIssuesForScope } from '../../lib/aggregate-read';
import { resolveProjectScope } from '../../lib/project-scope';
import { listProjects } from '../../lib/registry';
import { listAgents } from '../../lib/agent-registry';
import { getSwarmMembers } from '../../lib/swarm-molecules';
export const dynamic = 'force-dynamic';
@ -32,6 +34,35 @@ export default async function Page({ searchParams }: PageProps) {
preferBd: true,
});
const epics = issues.filter(i => i.issue_type === 'epic');
const epicsWithSwarm = epics.filter(
i => (i.labels || []).some(l => l.startsWith('swarm:'))
);
const swarmGroups: SwarmGroup[] = [];
const assignedAgentIds = new Set<string>();
for (const epic of epicsWithSwarm) {
const swarmLabel = epic.labels?.find(l => l.startsWith('swarm:'));
if (!swarmLabel) continue;
const swarmId = swarmLabel.replace('swarm:', '');
const memberIds = await getSwarmMembers({ swarmId }, { projectRoot: scope.selected.root });
const members = agents.filter(a => memberIds.includes(a.agent_id));
members.forEach(a => assignedAgentIds.add(a.agent_id));
if (members.length > 0) {
swarmGroups.push({
swarmId,
swarmLabel: epic.id,
members,
});
}
}
const unassignedAgents = agents.filter(a => !assignedAgentIds.has(a.agent_id));
return (
<SessionsPage
issues={issues}
@ -40,6 +71,8 @@ export default async function Page({ searchParams }: PageProps) {
projectScopeKey={scope.selected.key}
projectScopeOptions={scope.options}
projectScopeMode={scope.mode}
swarmGroups={swarmGroups}
unassignedAgents={unassignedAgents}
/>
);
}