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:
parent
0016b57e37
commit
4ee550c333
36 changed files with 1380 additions and 541 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue