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

@ -4,7 +4,7 @@ import { listAgents, deriveLiveness } from './agent-registry';
import { inboxAgentMessages, type AgentMessage } from './agent-mail';
import { statusAgentReservations, classifyOverlap } from './agent-reservations';
export type AgentSessionState = 'active' | 'reviewing' | 'deciding' | 'needs_input' | 'completed' | 'stale' | 'evicted' | 'idle';
export type AgentSessionState = 'active' | 'reviewing' | 'deciding' | 'needs_input' | 'completed' | 'stale' | 'evicted' | 'idle' | 'stuck' | 'dead';
export interface SessionTaskCard {
id: string;
@ -38,14 +38,102 @@ export interface CommunicationSummary {
// 15 minutes default stale threshold
const STALE_THRESHOLD_MS = 15 * 60 * 1000;
export async function getAgentLivenessMap(): Promise<Record<string, string>> {
const agentsResult = await listAgents({});
/**
* Derives the session state for a task based on task status, liveness, and ZFC state.
* Priority: completed > stuck > dead > needs_input > evicted > stale > active > deciding
*/
export function deriveSessionState(
task: BeadIssue,
lastEvent: ActivityEvent | null,
pendingRequired: boolean,
ownerLiveness?: string,
ownerZfcState?: string
): AgentSessionState {
if (task.status === 'closed') return 'completed';
if (ownerZfcState === 'stuck') return 'stuck';
if (ownerZfcState === 'dead') return 'dead';
if (task.status === 'blocked' || pendingRequired) return 'needs_input';
if (ownerLiveness === 'evicted') return 'evicted';
if (ownerLiveness === 'stale') return 'stale';
const lastActiveTime = lastEvent ? new Date(lastEvent.timestamp).getTime() : new Date(task.updated_at).getTime();
if (Date.now() - lastActiveTime > STALE_THRESHOLD_MS) return 'stale';
if (task.status === 'in_progress') return 'active';
return 'deciding';
}
/**
* Returns all active (non-closed) tasks owned by a specific agent.
* Used for mission pathing: drawing visual links between working agents and their tasks.
*/
export function getAgentActiveMissions(
feed: EpicBucket[],
agentId: string
): SessionTaskCard[] {
return feed
.flatMap(bucket => bucket.tasks)
.filter(task => task.owner === agentId && task.status !== 'closed');
}
/**
* Returns count of active missions for an agent.
* Used for visual indicators in the sessions header.
*/
export function getActiveMissionCount(feed: EpicBucket[], agentId: string): number {
return getAgentActiveMissions(feed, agentId).length;
}
/**
* Groups all active missions by agent ID.
* Used for efficient batch rendering of mission paths.
*/
export function getMissionsByAgent(feed: EpicBucket[]): Record<string, SessionTaskCard[]> {
const missions: Record<string, SessionTaskCard[]> = {};
for (const bucket of feed) {
for (const task of bucket.tasks) {
if (task.owner && task.status !== 'closed') {
if (!missions[task.owner]) {
missions[task.owner] = [];
}
missions[task.owner].push(task);
}
}
}
return missions;
}
export async function getAgentLivenessMap(
projectRoot: string = process.cwd(),
activityHistory: ActivityEvent[] = []
): Promise<Record<string, string>> {
const agentsResult = await listAgents({}, { projectRoot });
const agents = agentsResult.data ?? [];
const map: Record<string, string> = {};
const now = new Date();
// Group activity by actor to find latest heartbeat
const latestHeartbeatByAgent = new Map<string, string>();
activityHistory
.filter(e => e.kind === 'heartbeat')
.forEach(e => {
const current = latestHeartbeatByAgent.get(e.actor || '');
if (!current || new Date(e.timestamp) > new Date(current)) {
latestHeartbeatByAgent.set(e.actor || '', e.timestamp);
}
});
for (const agent of agents) {
map[agent.agent_id] = deriveLiveness(agent.last_seen_at, now);
const telemetryLastSeen = latestHeartbeatByAgent.get(agent.agent_id);
const metadataLastSeen = agent.last_seen_at;
// Use most recent signal
let effectiveLastSeen = metadataLastSeen;
if (telemetryLastSeen && new Date(telemetryLastSeen) > new Date(metadataLastSeen)) {
effectiveLastSeen = telemetryLastSeen;
}
map[agent.agent_id] = deriveLiveness(effectiveLastSeen, now);
}
return map;