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
|
|
@ -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')));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,19 +9,39 @@ interface SessionFeedCardProps {
|
|||
onSelect: (id: string) => void;
|
||||
isHighlighted?: boolean;
|
||||
incursion?: Incursion;
|
||||
highlightSource?: 'task' | 'agent';
|
||||
}
|
||||
|
||||
export function SessionFeedCard({ card, onSelect, isHighlighted, incursion }: SessionFeedCardProps) {
|
||||
export function SessionFeedCard({ card, onSelect, isHighlighted, incursion, highlightSource }: SessionFeedCardProps) {
|
||||
return (
|
||||
<motion.article
|
||||
layout
|
||||
onClick={() => onSelect(card.id)}
|
||||
className={`relative w-full cursor-pointer rounded-[1.25rem] border p-[1rem] text-left transition-all duration-200 ${
|
||||
isHighlighted
|
||||
? 'border-sky-500 bg-sky-500/10 ring-1 ring-sky-500/50 scale-[1.02] shadow-[0_0_20px_rgba(56,189,248,0.15)]'
|
||||
? highlightSource === 'agent'
|
||||
? 'border-emerald-500/50 bg-emerald-500/10 ring-1 ring-emerald-500/30 scale-[1.01] shadow-[0_0_16px_rgba(16,185,129,0.15)]'
|
||||
: 'border-sky-500 bg-sky-500/10 ring-1 ring-sky-500/50 scale-[1.02] shadow-[0_0_20px_rgba(56,189,248,0.15)]'
|
||||
: `${statusBorder(card.status)} ${statusGradient(card.status)} hover:bg-white/[0.04]`
|
||||
} ${sessionStateGlow(card.sessionState)} ${incursion ? 'ring-1 ring-rose-500/30' : ''}`}
|
||||
>
|
||||
{/* Critical state badges */}
|
||||
{card.sessionState === 'stuck' && (
|
||||
<div className="absolute -top-2 right-4 z-10">
|
||||
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.55rem] font-black uppercase tracking-[0.1em] bg-red-500/80 text-white border border-red-400 shadow-lg animate-pulse">
|
||||
<span aria-hidden="true">⚠</span>
|
||||
STUCK
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{card.sessionState === 'dead' && (
|
||||
<div className="absolute -top-2 right-4 z-10">
|
||||
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.55rem] font-black uppercase tracking-[0.1em] bg-zinc-700/80 text-zinc-300 border border-zinc-600 shadow-lg">
|
||||
<span aria-hidden="true">✕</span>
|
||||
OFFLINE
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{incursion && (
|
||||
<div className="absolute -top-2 right-4 z-10">
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.55rem] font-black uppercase tracking-[0.1em] border shadow-lg animate-pulse ${
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ interface SessionTaskFeedProps {
|
|||
selectedEpicId: string | null;
|
||||
onSelectTask: (id: string) => void;
|
||||
highlightTaskId?: string | null;
|
||||
highlightingAgentId?: string | null;
|
||||
}
|
||||
|
||||
export function IncursionTicker({ incursions }: { incursions: Incursion[] }) {
|
||||
|
|
@ -35,7 +36,7 @@ export function IncursionTicker({ incursions }: { incursions: Incursion[] }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function SessionTaskFeed({ feed, incursions = [], selectedEpicId, onSelectTask, highlightTaskId }: SessionTaskFeedProps) {
|
||||
export function SessionTaskFeed({ feed, incursions = [], selectedEpicId, onSelectTask, highlightTaskId, highlightingAgentId }: SessionTaskFeedProps) {
|
||||
const filteredFeed = useMemo(() => {
|
||||
if (!selectedEpicId) return feed;
|
||||
return feed.filter(b => b.epic.id === selectedEpicId);
|
||||
|
|
@ -88,13 +89,15 @@ export function SessionTaskFeed({ feed, incursions = [], selectedEpicId, onSelec
|
|||
task.owner && inc.agents.includes(task.owner)
|
||||
);
|
||||
|
||||
const isAgentMission = highlightingAgentId ? task.owner === highlightingAgentId : false;
|
||||
return (
|
||||
<SessionFeedCard
|
||||
key={task.id}
|
||||
card={task}
|
||||
onSelect={onSelectTask}
|
||||
isHighlighted={highlightTaskId === task.id}
|
||||
isHighlighted={highlightTaskId === task.id || isAgentMission}
|
||||
incursion={taskIncursion}
|
||||
highlightSource={isAgentMission ? 'agent' : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { AgentRecord, AgentLiveness } from '../../lib/agent-registry';
|
||||
import { ProjectScopeControls } from '../shared/project-scope-controls';
|
||||
import type { ProjectScopeOption } from '../../lib/project-scope';
|
||||
import { AgentStation } from './agent-station';
|
||||
import { getSwarmHealth } from './sessions-header-logic';
|
||||
|
||||
export interface SwarmGroup {
|
||||
swarmId: string;
|
||||
swarmLabel: string;
|
||||
members: AgentRecord[];
|
||||
}
|
||||
|
||||
interface SessionsHeaderProps {
|
||||
agents: AgentRecord[];
|
||||
|
|
@ -18,6 +25,9 @@ interface SessionsHeaderProps {
|
|||
completed: number;
|
||||
};
|
||||
livenessMap?: Record<string, string>;
|
||||
swarmGroups?: SwarmGroup[];
|
||||
unassignedAgents?: AgentRecord[];
|
||||
missionCounts?: Record<string, number>;
|
||||
}
|
||||
|
||||
export function SessionsHeader({
|
||||
|
|
@ -29,6 +39,9 @@ export function SessionsHeader({
|
|||
projectScopeOptions,
|
||||
stats,
|
||||
livenessMap = {},
|
||||
swarmGroups = [],
|
||||
unassignedAgents = [],
|
||||
missionCounts = {},
|
||||
}: SessionsHeaderProps) {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 flex flex-col border-b border-white/5 bg-[#0b0c10]/60 backdrop-blur-3xl shadow-2xl">
|
||||
|
|
@ -40,15 +53,68 @@ export function SessionsHeader({
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar py-1">
|
||||
{agents.map((agent) => (
|
||||
<AgentStation
|
||||
key={agent.agent_id}
|
||||
agent={agent}
|
||||
isSelected={activeAgentId === agent.agent_id}
|
||||
onSelect={onSelectAgent}
|
||||
liveness={(livenessMap[agent.agent_id] as AgentLiveness) || 'active'}
|
||||
/>
|
||||
))}
|
||||
{swarmGroups.length > 0 || unassignedAgents.length > 0 ? (
|
||||
<div className="flex items-center gap-4">
|
||||
{swarmGroups.map((group) => {
|
||||
const health = getSwarmHealth(group.members, livenessMap);
|
||||
return (
|
||||
<div key={group.swarmId} className="swarm-container flex items-center gap-2">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="ui-text text-[0.55rem] font-black uppercase tracking-[0.15em] text-sky-400/50 whitespace-nowrap">
|
||||
{group.swarmLabel}
|
||||
</span>
|
||||
<span className={`ui-text text-[0.45rem] font-bold uppercase tracking-wider ${health.color} flex items-center gap-0.5`}>
|
||||
<span className="text-xs">●</span>
|
||||
{health.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{group.members.map((agent) => (
|
||||
<AgentStation
|
||||
key={agent.agent_id}
|
||||
agent={agent}
|
||||
isSelected={activeAgentId === agent.agent_id}
|
||||
onSelect={onSelectAgent}
|
||||
liveness={(livenessMap[agent.agent_id] as AgentLiveness) || 'active'}
|
||||
missionCount={missionCounts[agent.agent_id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{unassignedAgents.length > 0 && (
|
||||
<div className="swarm-container flex items-center gap-2">
|
||||
<span className="ui-text text-[0.55rem] font-black uppercase tracking-[0.15em] text-zinc-500/40 whitespace-nowrap">
|
||||
No Swarm
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{unassignedAgents.map((agent) => (
|
||||
<AgentStation
|
||||
key={agent.agent_id}
|
||||
agent={agent}
|
||||
isSelected={activeAgentId === agent.agent_id}
|
||||
onSelect={onSelectAgent}
|
||||
liveness={(livenessMap[agent.agent_id] as AgentLiveness) || 'active'}
|
||||
missionCount={missionCounts[agent.agent_id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
agents.map((agent) => (
|
||||
<AgentStation
|
||||
key={agent.agent_id}
|
||||
agent={agent}
|
||||
isSelected={activeAgentId === agent.agent_id}
|
||||
onSelect={onSelectAgent}
|
||||
liveness={(livenessMap[agent.agent_id] as AgentLiveness) || 'active'}
|
||||
missionCount={missionCounts[agent.agent_id]}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -78,95 +144,6 @@ export function SessionsHeader({
|
|||
);
|
||||
}
|
||||
|
||||
function useTimeAgo(isoTimestamp: string) {
|
||||
const [timeAgo, setTimeAgo] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const seconds = Math.floor((new Date().getTime() - new Date(isoTimestamp).getTime()) / 1000);
|
||||
if (seconds < 60) setTimeAgo(`${seconds}s`);
|
||||
else if (seconds < 3600) setTimeAgo(`${Math.floor(seconds / 60)}m`);
|
||||
else setTimeAgo(`${Math.floor(seconds / 3600)}h`);
|
||||
};
|
||||
update();
|
||||
const interval = setInterval(update, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isoTimestamp]);
|
||||
|
||||
return timeAgo;
|
||||
}
|
||||
|
||||
function AgentStation({
|
||||
agent,
|
||||
isSelected,
|
||||
onSelect,
|
||||
liveness
|
||||
}: {
|
||||
agent: AgentRecord,
|
||||
isSelected: boolean,
|
||||
onSelect: (id: string | null) => void,
|
||||
liveness: AgentLiveness
|
||||
}) {
|
||||
const timeAgo = useTimeAgo(agent.last_seen_at);
|
||||
|
||||
const statusStyles = {
|
||||
active: {
|
||||
dot: 'bg-emerald-500 animate-pulse shadow-[0_0_10px_rgba(16,185,129,0.8)]',
|
||||
label: 'On Mission',
|
||||
color: 'text-emerald-400/60'
|
||||
},
|
||||
stale: {
|
||||
dot: 'bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.5)]',
|
||||
label: 'Lease Expiring',
|
||||
color: 'text-amber-400/60'
|
||||
},
|
||||
evicted: {
|
||||
dot: 'bg-rose-500/50 shadow-none',
|
||||
label: 'Disconnected',
|
||||
color: 'text-rose-400/40'
|
||||
},
|
||||
idle: {
|
||||
dot: 'bg-zinc-700 shadow-none',
|
||||
label: 'Idle',
|
||||
color: 'text-zinc-500/30'
|
||||
}
|
||||
}[liveness];
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(isSelected ? null : agent.agent_id)}
|
||||
className={`flex-none group flex w-[10rem] items-center gap-2 rounded-lg border px-2 py-1.5 transition-all duration-300 ${
|
||||
isSelected
|
||||
? 'border-sky-500/50 bg-sky-500/10 shadow-[0_0_10px_rgba(14,165,233,0.1)]'
|
||||
: 'border-white/5 bg-white/[0.01] hover:bg-white/5'
|
||||
} ${liveness === 'idle' ? 'opacity-40 grayscale-[0.5]' : ''}`}
|
||||
>
|
||||
<div className="relative flex-none">
|
||||
<div className={`h-7 w-7 rounded-md bg-gradient-to-br from-zinc-700 to-zinc-900 flex items-center justify-center border border-white/10 shadow-inner transition-transform duration-300 ${isSelected ? 'scale-90' : 'group-hover:scale-105'}`}>
|
||||
<span className="ui-text text-[0.6rem] font-black text-zinc-400">
|
||||
{agent.agent_id.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border-2 border-[#0b0c10] ${statusStyles.dot}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start min-w-0 text-left">
|
||||
<div className="flex items-center gap-1 w-full justify-between pr-1">
|
||||
<span className={`ui-text text-[0.65rem] font-black truncate transition-colors ${isSelected ? 'text-sky-300' : 'text-text-body'}`}>
|
||||
{agent.agent_id}
|
||||
</span>
|
||||
<span className="system-data text-[0.5rem] font-bold text-text-muted/40">
|
||||
{timeAgo}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`system-data text-[0.5rem] font-bold uppercase tracking-tighter ${statusStyles.color}`}>
|
||||
{statusStyles.label}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatPill({ label, value, color }: { label: string, value: number, color: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-white/5 bg-white/5 px-1.5 py-0.5">
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ import type { AgentRecord } from '../../lib/agent-registry';
|
|||
import { EpicChipStrip } from '../shared/epic-chip-strip';
|
||||
import { SessionTaskFeed } from './session-task-feed';
|
||||
import { ConversationDrawer } from './conversation-drawer';
|
||||
import { SessionsHeader } from './sessions-header';
|
||||
import { SessionsHeader, type SwarmGroup } from './sessions-header';
|
||||
import { getMissionsByAgent } from '../../lib/agent-sessions';
|
||||
|
||||
interface SessionsPageProps {
|
||||
issues: BeadIssue[];
|
||||
|
|
@ -19,6 +20,8 @@ interface SessionsPageProps {
|
|||
projectScopeKey: string;
|
||||
projectScopeOptions: ProjectScopeOption[];
|
||||
projectScopeMode: 'single' | 'aggregate';
|
||||
swarmGroups?: SwarmGroup[];
|
||||
unassignedAgents?: AgentRecord[];
|
||||
}
|
||||
|
||||
export function SessionsPage({
|
||||
|
|
@ -28,10 +31,21 @@ export function SessionsPage({
|
|||
projectScopeKey,
|
||||
projectScopeOptions,
|
||||
projectScopeMode,
|
||||
swarmGroups = [],
|
||||
unassignedAgents = [],
|
||||
}: SessionsPageProps) {
|
||||
// 2. Session-specific feed
|
||||
const { feed, incursions, livenessMap, loading, refresh: refreshFeed, stats } = useSessionFeed(projectRoot);
|
||||
|
||||
// Compute mission counts for agent header badges
|
||||
const missionCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
const missionsByAgent = getMissionsByAgent(feed);
|
||||
for (const [agentId, missions] of Object.entries(missionsByAgent)) {
|
||||
counts[agentId] = missions.length;
|
||||
}
|
||||
return counts;
|
||||
}, [feed]);
|
||||
|
||||
const {
|
||||
selectedAgentId,
|
||||
selectedTaskId,
|
||||
|
|
@ -43,11 +57,11 @@ export function SessionsPage({
|
|||
const [selectedEpicId, setSelectedEpicId] = useState<string | null>(null);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
// 1. Basic subscription for SSE invalidation
|
||||
const { refresh: refreshIssues, issues: localIssues } = useBeadsSubscription(initialIssues, projectRoot, {
|
||||
onUpdate: () => {
|
||||
console.log('[Sessions] SSE update detected. Scheduling silent refresh...');
|
||||
// Small delay to ensure backend files are flushed
|
||||
onUpdate: (kind) => {
|
||||
if (kind === 'telemetry') return;
|
||||
|
||||
console.log(`[Sessions] ${kind} update detected. Scheduling silent refresh...`);
|
||||
setTimeout(() => {
|
||||
void refreshFeed({ silent: true });
|
||||
setRefreshTrigger(prev => prev + 1);
|
||||
|
|
@ -74,6 +88,9 @@ export function SessionsPage({
|
|||
projectScopeOptions={projectScopeOptions}
|
||||
stats={stats}
|
||||
livenessMap={livenessMap}
|
||||
swarmGroups={swarmGroups}
|
||||
unassignedAgents={unassignedAgents}
|
||||
missionCounts={missionCounts}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
|
|
@ -102,6 +119,7 @@ export function SessionsPage({
|
|||
selectedEpicId={selectedEpicId}
|
||||
onSelectTask={setSelectedTaskId}
|
||||
highlightTaskId={selectedTaskId}
|
||||
highlightingAgentId={selectedAgentId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ export function sessionStateGlow(state: string): string {
|
|||
switch (state) {
|
||||
case 'active': return 'shadow-[0_0_12px_rgba(74,222,128,0.3)] border-emerald-500/30';
|
||||
case 'needs_input': return 'shadow-[0_0_12px_rgba(248,113,113,0.3)] border-rose-500/30';
|
||||
case 'stuck': return 'ring-2 ring-red-500 animate-pulse shadow-[0_0_16px_rgba(239,68,68,0.5)]';
|
||||
case 'dead': return 'opacity-40 grayscale';
|
||||
case 'evicted': return 'opacity-60 grayscale-[0.5]';
|
||||
case 'stale': return 'opacity-60 grayscale-[0.5]';
|
||||
case 'completed': return 'opacity-80';
|
||||
default: return '';
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ async function fetchIssues(projectRoot: string): Promise<BeadIssue[]> {
|
|||
export function useBeadsSubscription(
|
||||
initialIssues: BeadIssue[],
|
||||
projectRoot: string,
|
||||
options: { onUpdate?: () => void } = {}
|
||||
options: { onUpdate?: (kind: 'issues' | 'telemetry' | 'activity') => void } = {}
|
||||
): UseBeadsSubscriptionResult {
|
||||
const [issues, setIssues] = useState<BeadIssue[]>(initialIssues);
|
||||
const refreshInFlightRef = useRef(false);
|
||||
|
|
@ -54,7 +54,7 @@ export function useBeadsSubscription(
|
|||
try {
|
||||
const reconciled = await fetchIssues(projectRoot);
|
||||
setIssues(reconciled);
|
||||
onUpdate?.();
|
||||
onUpdate?.('issues');
|
||||
} catch (error) {
|
||||
if (!options.silent) {
|
||||
console.error('[BeadsSubscription] Refresh failed:', error);
|
||||
|
|
@ -77,18 +77,36 @@ export function useBeadsSubscription(
|
|||
};
|
||||
|
||||
const onIssues = (event: MessageEvent) => {
|
||||
console.log('🚨 SSE RECEIVED:', event.data);
|
||||
onUpdate?.();
|
||||
console.log('🚨 SSE ISSUES RECEIVED:', event.data);
|
||||
onUpdate?.('issues');
|
||||
void refresh({ silent: true });
|
||||
};
|
||||
|
||||
const onTelemetry = (event: MessageEvent) => {
|
||||
console.log('📡 SSE TELEMETRY RECEIVED (Silent):', event.data);
|
||||
// We don't trigger a full refresh or parent update for heartbeats
|
||||
// This prevents the page from flickering/clearing state while typing.
|
||||
onUpdate?.('telemetry');
|
||||
};
|
||||
|
||||
const onActivity = (event: MessageEvent) => {
|
||||
console.log('📝 SSE ACTIVITY RECEIVED:', event.data);
|
||||
onUpdate?.('activity');
|
||||
};
|
||||
|
||||
source.addEventListener('issues', onIssues as EventListener);
|
||||
source.addEventListener('telemetry', onTelemetry as EventListener);
|
||||
source.addEventListener('activity', onActivity as EventListener);
|
||||
|
||||
return () => {
|
||||
console.log('[SSE] Closing connection');
|
||||
source.removeEventListener('issues', onIssues as EventListener);
|
||||
source.removeEventListener('telemetry', onTelemetry as EventListener);
|
||||
source.removeEventListener('activity', onActivity as EventListener);
|
||||
source.close();
|
||||
};
|
||||
// onUpdate is intentionally excluded from deps to avoid re-subscribing on parent re-renders
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectRoot, refresh]);
|
||||
|
||||
return { issues, refresh, updateLocal };
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ export type ActivityEventKind =
|
|||
| 'comment_added'
|
||||
| 'due_date_changed'
|
||||
| 'estimate_changed'
|
||||
| 'field_changed';
|
||||
| 'field_changed'
|
||||
| 'heartbeat';
|
||||
|
||||
/**
|
||||
* Represents a discrete change or action derived from bead snapshots or interactions.
|
||||
|
|
|
|||
|
|
@ -188,6 +188,21 @@ async function resolveRegisteredAgent(agentId: string): Promise<AgentRecord | nu
|
|||
return result.ok ? result.data : null;
|
||||
}
|
||||
|
||||
async function resolveRecipients(to: string, from: string): Promise<string[]> {
|
||||
if (to === 'broadcast') {
|
||||
const agents = (await listAgents({})).data ?? [];
|
||||
return agents.map((a) => a.agent_id).filter((id) => id !== from);
|
||||
}
|
||||
|
||||
if (to.startsWith('role:')) {
|
||||
const role = to.slice(5);
|
||||
const agents = (await listAgents({ role })).data ?? [];
|
||||
return agents.map((a) => a.agent_id).filter((id) => id !== from);
|
||||
}
|
||||
|
||||
return [to];
|
||||
}
|
||||
|
||||
export async function sendAgentMessage(
|
||||
input: SendAgentMessageInput,
|
||||
deps: Partial<SendAgentMessageDeps> = {},
|
||||
|
|
@ -210,7 +225,9 @@ export async function sendAgentMessage(
|
|||
return invalid(command, 'UNKNOWN_RECIPIENT', 'Recipient agent is required.');
|
||||
}
|
||||
|
||||
if (to !== 'broadcast' && !(await resolveRegisteredAgent(to))) {
|
||||
const isRoleOrBroadcast = to === 'broadcast' || to.startsWith('role:');
|
||||
|
||||
if (!isRoleOrBroadcast && !(await resolveRegisteredAgent(to))) {
|
||||
return invalid(command, 'UNKNOWN_RECIPIENT', 'Recipient agent is not registered.');
|
||||
}
|
||||
|
||||
|
|
@ -229,12 +246,17 @@ export async function sendAgentMessage(
|
|||
try {
|
||||
const now = deps.now ? deps.now() : new Date().toISOString();
|
||||
const generateId = deps.idGenerator ?? (() => defaultMessageId(now));
|
||||
const recipientIds =
|
||||
to === 'broadcast'
|
||||
? ((await listAgents({})).data ?? []).map((agent) => agent.agent_id).filter((agentId) => agentId !== from)
|
||||
: [to];
|
||||
const recipientIds = await resolveRecipients(to, from);
|
||||
|
||||
if (recipientIds.length === 0) {
|
||||
if (to.startsWith('role:')) {
|
||||
const role = to.slice(5);
|
||||
const allWithRole = (await listAgents({ role })).data ?? [];
|
||||
if (allWithRole.length === 0) {
|
||||
return invalid(command, 'UNKNOWN_RECIPIENT', `no agents found with role '${role}'.`);
|
||||
}
|
||||
return invalid(command, 'UNKNOWN_RECIPIENT', 'all recipients were excluded (sender).');
|
||||
}
|
||||
return invalid(command, 'UNKNOWN_RECIPIENT', 'No recipients available for broadcast.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { runBdCommand } from './bridge';
|
||||
import { activityEventBus } from './realtime';
|
||||
|
||||
const AGENT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
export type AgentCommandName = 'agent register' | 'agent list' | 'agent show' | 'agent activity-lease';
|
||||
export type AgentCommandName = 'agent register' | 'agent list' | 'agent show' | 'agent activity-lease' | 'agent state';
|
||||
|
||||
export type AgentZfcState = 'idle' | 'spawning' | 'running' | 'working' | 'stuck' | 'done' | 'stopped' | 'dead';
|
||||
|
||||
export interface AgentCommandError {
|
||||
code: string;
|
||||
|
|
@ -24,8 +27,10 @@ export interface AgentRecord {
|
|||
role: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
last_seen_at: string; // Used as the base for the Activity Lease
|
||||
last_seen_at: string;
|
||||
version: number;
|
||||
rig?: string;
|
||||
role_type?: string;
|
||||
}
|
||||
|
||||
export interface RegisterAgentInput {
|
||||
|
|
@ -33,10 +38,12 @@ export interface RegisterAgentInput {
|
|||
display?: string;
|
||||
role: string;
|
||||
forceUpdate?: boolean;
|
||||
rig?: string;
|
||||
}
|
||||
|
||||
export interface RegisterAgentDeps {
|
||||
now: () => string;
|
||||
projectRoot: string;
|
||||
}
|
||||
|
||||
export interface ListAgentsInput {
|
||||
|
|
@ -52,26 +59,83 @@ export interface ActivityLeaseInput {
|
|||
agent: string;
|
||||
}
|
||||
|
||||
function userProfileRoot(): string {
|
||||
return process.env.USERPROFILE?.trim() || os.homedir();
|
||||
/**
|
||||
* Normalizes agent name to bead ID with prefix.
|
||||
* e.g. "silver-castle" -> "bb-silver-castle"
|
||||
*/
|
||||
function toBeadId(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.startsWith('bb-')) return trimmed;
|
||||
return `bb-${trimmed}`;
|
||||
}
|
||||
|
||||
export function agentRegistryRoot(): string {
|
||||
return path.join(userProfileRoot(), '.beadboard', 'agent');
|
||||
/**
|
||||
* Strips prefix from bead ID for display/internal logic.
|
||||
* e.g. "bb-silver-castle" -> "silver-castle"
|
||||
*/
|
||||
function fromBeadId(id: string): string {
|
||||
if (id.startsWith('bb-')) return id.slice(3);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function agentsDirectoryPath(): string {
|
||||
return path.join(agentRegistryRoot(), 'agents');
|
||||
/**
|
||||
* Robustly extracts the first JSON block from a potentially noisy string.
|
||||
* Handles cases where 'bd' outputs warnings or daemon logs before the JSON.
|
||||
*/
|
||||
function extractJson(text: string): any {
|
||||
const start = text.indexOf('{');
|
||||
const end = text.lastIndexOf('}');
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error('No JSON block found in output');
|
||||
}
|
||||
const jsonPart = text.slice(start, end + 1);
|
||||
return JSON.parse(jsonPart);
|
||||
}
|
||||
|
||||
export function agentFilePath(agentId: string): string {
|
||||
return path.join(agentsDirectoryPath(), `${agentId}.json`);
|
||||
/**
|
||||
* Robustly extracts the first JSON array from a potentially noisy string.
|
||||
*/
|
||||
function extractJsonArray(text: string): any[] {
|
||||
const start = text.indexOf('[');
|
||||
const end = text.lastIndexOf(']');
|
||||
if (start === -1 || end === -1) {
|
||||
// Check if it's a single object instead
|
||||
try {
|
||||
const single = extractJson(text);
|
||||
return [single];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const jsonPart = text.slice(start, end + 1);
|
||||
return JSON.parse(jsonPart);
|
||||
}
|
||||
|
||||
function trimOrEmpty(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to fetch and parse agent details robustly.
|
||||
*/
|
||||
async function callBdAgentShow(beadId: string, projectRoot: string): Promise<AgentRecord | null> {
|
||||
const showResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['show', beadId, '--json'],
|
||||
});
|
||||
|
||||
if (!showResult.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const bdAgent = extractJson(showResult.stdout);
|
||||
return mapBdAgentToRecord(bdAgent);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(command: AgentCommandName, code: string, message: string): AgentCommandResponse<never> {
|
||||
return {
|
||||
ok: false,
|
||||
|
|
@ -112,50 +176,36 @@ function validateRole(value: string): AgentCommandError | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
async function readAgent(agentId: string): Promise<AgentRecord | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(agentFilePath(agentId), 'utf8');
|
||||
const parsed = JSON.parse(raw) as AgentRecord;
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
function mapBdAgentToRecord(bdAgent: any): AgentRecord {
|
||||
// Extract role from labels if role_type is not set
|
||||
let role = bdAgent.role_type || 'agent';
|
||||
if (role === 'agent' && Array.isArray(bdAgent.labels)) {
|
||||
const roleLabel = bdAgent.labels.find((l: string) => l.startsWith('role:'));
|
||||
if (roleLabel) {
|
||||
role = roleLabel.split(':')[1];
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAgent(record: AgentRecord): Promise<void> {
|
||||
const filePath = agentFilePath(record.agent_id);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
async function loadAllAgents(): Promise<AgentRecord[]> {
|
||||
try {
|
||||
const entries = await fs.readdir(agentsDirectoryPath(), { withFileTypes: true });
|
||||
const files = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.json'));
|
||||
|
||||
const agents: AgentRecord[] = [];
|
||||
for (const file of files) {
|
||||
const filePath = path.join(agentsDirectoryPath(), file.name);
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
agents.push(JSON.parse(raw) as AgentRecord);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
let rig = bdAgent.rig;
|
||||
if (!rig && Array.isArray(bdAgent.labels)) {
|
||||
const rigLabel = bdAgent.labels.find((l: string) => l.startsWith('rig:'));
|
||||
if (rigLabel) {
|
||||
rig = rigLabel.split(':')[1];
|
||||
}
|
||||
|
||||
return agents.sort((left, right) => left.agent_id.localeCompare(right.agent_id));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const record: AgentRecord = {
|
||||
agent_id: fromBeadId(bdAgent.id),
|
||||
display_name: bdAgent.title?.replace(/^Agent: /, '') || fromBeadId(bdAgent.id),
|
||||
role,
|
||||
status: bdAgent.agent_state || 'idle',
|
||||
created_at: bdAgent.created_at || bdAgent.last_activity || new Date().toISOString(),
|
||||
last_seen_at: bdAgent.last_activity || new Date().toISOString(),
|
||||
version: 1,
|
||||
rig,
|
||||
role_type: bdAgent.role_type,
|
||||
};
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function registerAgent(
|
||||
|
|
@ -163,11 +213,12 @@ export async function registerAgent(
|
|||
deps: Partial<RegisterAgentDeps> = {},
|
||||
): Promise<AgentCommandResponse<AgentRecord>> {
|
||||
const command: AgentCommandName = 'agent register';
|
||||
const agentId = trimOrEmpty(input.name);
|
||||
const name = trimOrEmpty(input.name);
|
||||
const role = trimOrEmpty(input.role);
|
||||
const display = trimOrEmpty(input.display) || agentId;
|
||||
const display = trimOrEmpty(input.display) || name;
|
||||
const projectRoot = deps.projectRoot || process.cwd();
|
||||
|
||||
const agentIdError = validateAgentId(agentId);
|
||||
const agentIdError = validateAgentId(name);
|
||||
if (agentIdError) {
|
||||
return invalid(command, agentIdError.code, agentIdError.message);
|
||||
}
|
||||
|
|
@ -178,86 +229,179 @@ export async function registerAgent(
|
|||
}
|
||||
|
||||
try {
|
||||
const existing = await readAgent(agentId);
|
||||
const now = deps.now ? deps.now() : new Date().toISOString();
|
||||
const beadId = toBeadId(name);
|
||||
|
||||
// 1. Check if agent exists
|
||||
const showResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['agent', 'show', beadId, '--json'],
|
||||
});
|
||||
|
||||
if (existing && !input.forceUpdate) {
|
||||
if (showResult.success && !input.forceUpdate) {
|
||||
return invalid(command, 'DUPLICATE_AGENT_ID', 'Agent is already registered. Use --force-update to change display/role.');
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
const updated: AgentRecord = {
|
||||
...existing,
|
||||
display_name: display || existing.display_name,
|
||||
role,
|
||||
last_seen_at: now,
|
||||
version: existing.version + 1,
|
||||
};
|
||||
await writeAgent(updated);
|
||||
return success(command, updated);
|
||||
// 2. Set state (auto-creates if missing)
|
||||
const stateResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['agent', 'state', beadId, 'idle', '--json'],
|
||||
});
|
||||
|
||||
if (!stateResult.success) {
|
||||
return invalid(command, 'INTERNAL_ERROR', `Failed to set agent state: ${stateResult.error}`);
|
||||
}
|
||||
|
||||
const created: AgentRecord = {
|
||||
agent_id: agentId,
|
||||
display_name: display,
|
||||
role,
|
||||
status: 'idle',
|
||||
created_at: now,
|
||||
last_seen_at: now,
|
||||
version: 1,
|
||||
};
|
||||
// 3. Update title, role, and rig via labels
|
||||
const labels = ['gt:agent'];
|
||||
if (role) {
|
||||
labels.push(`role:${role}`);
|
||||
}
|
||||
if (input.rig) {
|
||||
labels.push(`rig:${input.rig}`);
|
||||
}
|
||||
|
||||
await writeAgent(created);
|
||||
return success(command, created);
|
||||
const updateArgs = ['update', beadId, '--title', `Agent: ${display}`, '--add-label', labels.join(',')];
|
||||
|
||||
const updateResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: [...updateArgs, '--json'],
|
||||
});
|
||||
|
||||
if (!updateResult.success) {
|
||||
console.error('Update failed:', updateResult.error, updateResult.stdout, updateResult.stderr);
|
||||
return invalid(command, 'INTERNAL_ERROR', `Failed to update agent details: ${updateResult.error}`);
|
||||
}
|
||||
|
||||
// 4. Force flush to ensure issues.jsonl is updated (critical for tests and sync)
|
||||
const flushResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['admin', 'flush'],
|
||||
});
|
||||
if (!flushResult.success) {
|
||||
console.error('Flush failed:', flushResult.error, flushResult.stdout, flushResult.stderr);
|
||||
}
|
||||
|
||||
// 5. Return the new record
|
||||
const record = await callBdAgentShow(beadId, projectRoot);
|
||||
if (!record) {
|
||||
return invalid(command, 'INTERNAL_ERROR', 'Failed to retrieve final agent state.');
|
||||
}
|
||||
|
||||
return success(command, record);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to register agent.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAgents(input: ListAgentsInput): Promise<AgentCommandResponse<AgentRecord[]>> {
|
||||
export async function listAgents(
|
||||
input: ListAgentsInput,
|
||||
deps: Partial<RegisterAgentDeps> = {},
|
||||
): Promise<AgentCommandResponse<AgentRecord[]>> {
|
||||
const command: AgentCommandName = 'agent list';
|
||||
const role = trimOrEmpty(input.role);
|
||||
const status = trimOrEmpty(input.status);
|
||||
const projectRoot = deps.projectRoot || process.cwd();
|
||||
|
||||
try {
|
||||
const agents = await loadAllAgents();
|
||||
const filtered = agents.filter((agent) => {
|
||||
if (role && agent.role !== role) {
|
||||
return false;
|
||||
}
|
||||
if (status && agent.status !== status) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
const listResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['list', '--label', 'gt:agent', '--json'],
|
||||
});
|
||||
|
||||
return success(command, filtered);
|
||||
if (!listResult.success) {
|
||||
return invalid(command, 'INTERNAL_ERROR', `Failed to list agents from bd: ${listResult.error}`);
|
||||
}
|
||||
|
||||
const rawList = extractJsonArray(listResult.stdout);
|
||||
if (rawList.length === 0) {
|
||||
return success(command, []);
|
||||
}
|
||||
|
||||
const agents: AgentRecord[] = [];
|
||||
for (const item of rawList) {
|
||||
// Get detailed agent state for each bead found using show
|
||||
const record = await callBdAgentShow(item.id, projectRoot);
|
||||
if (record) {
|
||||
if (role && record.role !== role) continue;
|
||||
if (status && record.status !== status) continue;
|
||||
|
||||
agents.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
return success(command, agents.sort((a, b) => a.agent_id.localeCompare(b.agent_id)));
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to list agents.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function showAgent(input: ShowAgentInput): Promise<AgentCommandResponse<AgentRecord>> {
|
||||
export async function showAgent(
|
||||
input: ShowAgentInput,
|
||||
deps: Partial<RegisterAgentDeps> = {},
|
||||
): Promise<AgentCommandResponse<AgentRecord>> {
|
||||
const command: AgentCommandName = 'agent show';
|
||||
const agentId = trimOrEmpty(input.agent);
|
||||
const name = trimOrEmpty(input.agent);
|
||||
const projectRoot = deps.projectRoot || process.cwd();
|
||||
|
||||
const agentIdError = validateAgentId(agentId);
|
||||
const agentIdError = validateAgentId(name);
|
||||
if (agentIdError) {
|
||||
return invalid(command, agentIdError.code, agentIdError.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const agent = await readAgent(agentId);
|
||||
if (!agent) {
|
||||
const beadId = toBeadId(name);
|
||||
const record = await callBdAgentShow(beadId, projectRoot);
|
||||
|
||||
if (!record) {
|
||||
return invalid(command, 'AGENT_NOT_FOUND', 'Agent is not registered.');
|
||||
}
|
||||
|
||||
return success(command, agent);
|
||||
return success(command, record);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to load agent.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the ZFC state of an agent bead.
|
||||
*/
|
||||
export async function setAgentState(
|
||||
input: { agent: string; state: AgentZfcState },
|
||||
deps: Partial<RegisterAgentDeps> = {},
|
||||
): Promise<AgentCommandResponse<AgentRecord>> {
|
||||
const command: AgentCommandName = 'agent state';
|
||||
const name = trimOrEmpty(input.agent);
|
||||
const state = input.state;
|
||||
const projectRoot = deps.projectRoot || process.cwd();
|
||||
|
||||
const agentIdError = validateAgentId(name);
|
||||
if (agentIdError) {
|
||||
return invalid(command, agentIdError.code, agentIdError.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const beadId = toBeadId(name);
|
||||
|
||||
const stateResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['agent', 'state', beadId, state, '--json'],
|
||||
});
|
||||
|
||||
if (!stateResult.success) {
|
||||
return invalid(command, 'AGENT_NOT_FOUND', 'Agent is not registered.');
|
||||
}
|
||||
|
||||
const record = await callBdAgentShow(beadId, projectRoot);
|
||||
if (!record) {
|
||||
return invalid(command, 'INTERNAL_ERROR', 'Failed to retrieve agent state after update.');
|
||||
}
|
||||
|
||||
return success(command, record);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to set agent state.');
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentLiveness = 'active' | 'stale' | 'evicted' | 'idle';
|
||||
|
||||
/**
|
||||
|
|
@ -285,36 +429,59 @@ export function deriveLiveness(lastSeenAt: string, now: Date = new Date(), stale
|
|||
}
|
||||
|
||||
/**
|
||||
* Extends the activity lease (last_seen_at timestamp) for a registered agent.
|
||||
* Equivalent to a "parking permit" extension based on real work.
|
||||
* Extends the activity lease for a registered agent by emitting a native bd wisp.
|
||||
* This provides silent observability WITHOUT persistent git churn.
|
||||
*/
|
||||
export async function extendActivityLease(
|
||||
input: ActivityLeaseInput,
|
||||
deps: Partial<RegisterAgentDeps> = {},
|
||||
): Promise<AgentCommandResponse<AgentRecord>> {
|
||||
): Promise<AgentCommandResponse<AgentRecord | null>> {
|
||||
const command: AgentCommandName = 'agent activity-lease';
|
||||
const agentId = trimOrEmpty(input.agent);
|
||||
const name = trimOrEmpty(input.agent);
|
||||
const projectRoot = deps.projectRoot || process.cwd();
|
||||
|
||||
const agentIdError = validateAgentId(agentId);
|
||||
const agentIdError = validateAgentId(name);
|
||||
if (agentIdError) {
|
||||
return invalid(command, agentIdError.code, agentIdError.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await readAgent(agentId);
|
||||
if (!existing) {
|
||||
return invalid(command, 'AGENT_NOT_FOUND', 'Agent is not registered.');
|
||||
const beadId = toBeadId(name);
|
||||
|
||||
// We create an ephemeral wisp of type 'heartbeat' tied to the agent bead.
|
||||
// This refreshes the 'last_activity' in the bd system without mutating issues.jsonl.
|
||||
const wispResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: [
|
||||
'create',
|
||||
`pulse:${name}:${Date.now()}`,
|
||||
'--type', 'event',
|
||||
'--wisp-type', 'heartbeat',
|
||||
'--ephemeral',
|
||||
'--event-actor', beadId,
|
||||
'--json'
|
||||
],
|
||||
});
|
||||
|
||||
if (!wispResult.success) {
|
||||
return invalid(command, 'INTERNAL_ERROR', `Failed to emit heartbeat wisp: ${wispResult.error}`);
|
||||
}
|
||||
|
||||
const now = deps.now ? deps.now() : new Date().toISOString();
|
||||
const updated: AgentRecord = {
|
||||
...existing,
|
||||
last_seen_at: now,
|
||||
version: existing.version + 1,
|
||||
};
|
||||
// Emit heartbeat to activity bus for real-time aggregation
|
||||
activityEventBus.emit({
|
||||
id: randomUUID(),
|
||||
kind: 'heartbeat',
|
||||
beadId: beadId,
|
||||
beadTitle: `Agent: ${name}`,
|
||||
projectId: projectRoot,
|
||||
projectName: path.basename(projectRoot),
|
||||
timestamp: new Date().toISOString(),
|
||||
actor: name,
|
||||
payload: { message: 'running' }
|
||||
});
|
||||
|
||||
await writeAgent(updated);
|
||||
return success(command, updated);
|
||||
// We return ok: true. The actual lease state will be aggregated from wisps.
|
||||
return success(command, null);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to extend activity lease.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -407,7 +407,8 @@ export async function releaseAgentReservation(
|
|||
try {
|
||||
const now = deps.now ? deps.now() : new Date().toISOString();
|
||||
const reservations = await readActiveReservations();
|
||||
const existing = reservations.find((reservation) => reservation.scope === scope);
|
||||
const normalizedScope = normalizePath(scope);
|
||||
const existing = reservations.find((reservation) => reservation.scope === normalizedScope);
|
||||
|
||||
if (!existing || isExpired(existing, now)) {
|
||||
if (existing && isExpired(existing, now)) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { execFile as nodeExecFile } from 'node:child_process';
|
||||
import { exec as nodeExec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import path from 'node:path';
|
||||
|
||||
import { BdExecutableNotFoundError, resolveBdExecutable } from './bd-path';
|
||||
|
||||
const execFileAsync = promisify(nodeExecFile);
|
||||
const execAsync = promisify(nodeExec);
|
||||
|
||||
export type BdFailureClassification = 'not_found' | 'timeout' | 'non_zero_exit' | 'bad_args' | 'unknown';
|
||||
|
||||
|
|
@ -27,60 +28,51 @@ export interface RunBdCommandResult {
|
|||
error: string | null;
|
||||
}
|
||||
|
||||
type ExecFileOptions = {
|
||||
cwd: string;
|
||||
timeout: number;
|
||||
windowsHide: boolean;
|
||||
env: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
type ExecFileLike = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: ExecFileOptions,
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
|
||||
interface RunBdCommandDeps {
|
||||
resolveBdExecutable: typeof resolveBdExecutable;
|
||||
execFile: ExecFileLike;
|
||||
exec: (command: string, options: { cwd: string; timeout: number; env: NodeJS.ProcessEnv }) => Promise<{ stdout: string; stderr: string }>;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
function normalizeOutput(text: unknown): string {
|
||||
if (typeof text !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof text !== 'string') return '';
|
||||
return text.replaceAll('\r\n', '\n').trim();
|
||||
}
|
||||
|
||||
function toErrorMessage(value: unknown): string {
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
}
|
||||
if (value instanceof Error) return value.message;
|
||||
return String(value ?? 'Unknown error');
|
||||
}
|
||||
|
||||
function classifyFailure(error: NodeJS.ErrnoException & { stderr?: string; killed?: boolean; signal?: string }): BdFailureClassification {
|
||||
if (error.code === 'ENOENT') {
|
||||
return 'not_found';
|
||||
}
|
||||
|
||||
if (error.code === 'ETIMEDOUT' || error.killed || error.signal === 'SIGTERM') {
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
if (error.code === 'ENOENT') return 'not_found';
|
||||
if (error.code === 'ETIMEDOUT' || error.killed || error.signal === 'SIGTERM') return 'timeout';
|
||||
const stderr = normalizeOutput(error.stderr);
|
||||
if (typeof error.code === 'number') {
|
||||
if (/(unknown|invalid|required|usage)/i.test(stderr)) {
|
||||
return 'bad_args';
|
||||
}
|
||||
if (/(unknown|invalid|required|usage)/i.test(stderr)) return 'bad_args';
|
||||
return 'non_zero_exit';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function buildShellCommand(executable: string, args: string[]): string {
|
||||
// Normalize to forward slashes for Windows shell compatibility
|
||||
const normalizedExe = executable.split(path.sep).join('/');
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// Windows: quote the executable path, leave simple args unquoted
|
||||
const quotedExe = `"${normalizedExe}"`;
|
||||
const quotedArgs = args.map(a => {
|
||||
if (/[\s&|<>()^"]/.test(a)) return `"${a.replace(/"/g, '""')}"`;
|
||||
return a;
|
||||
});
|
||||
return [quotedExe, ...quotedArgs].join(' ');
|
||||
} else {
|
||||
const escapeArg = (a: string) => `'${a.replace(/'/g, "'\''")}'`;
|
||||
return [normalizedExe, ...args.map(escapeArg)].join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
export async function runBdCommand(
|
||||
options: RunBdCommandOptions,
|
||||
injectedDeps?: Partial<RunBdCommandDeps>,
|
||||
|
|
@ -89,14 +81,17 @@ export async function runBdCommand(
|
|||
const timeoutMs = options.timeoutMs ?? 30_000;
|
||||
const cwd = options.projectRoot;
|
||||
const args = [...options.args];
|
||||
if (process.env.BD_NO_DAEMON === 'true') {
|
||||
args.unshift('--no-daemon');
|
||||
}
|
||||
|
||||
const deps: RunBdCommandDeps = {
|
||||
resolveBdExecutable: injectedDeps?.resolveBdExecutable ?? resolveBdExecutable,
|
||||
execFile: injectedDeps?.execFile ?? execFileAsync,
|
||||
exec: injectedDeps?.exec ?? execAsync,
|
||||
env: injectedDeps?.env ?? process.env,
|
||||
};
|
||||
|
||||
let command = options.explicitBdPath ?? 'bd.exe';
|
||||
let command = options.explicitBdPath ?? 'bd';
|
||||
|
||||
try {
|
||||
const resolved = await deps.resolveBdExecutable({
|
||||
|
|
@ -105,10 +100,11 @@ export async function runBdCommand(
|
|||
});
|
||||
command = resolved.executable;
|
||||
|
||||
const { stdout, stderr } = await deps.execFile(command, args, {
|
||||
const shellCommand = buildShellCommand(command, args);
|
||||
|
||||
const { stdout, stderr } = await deps.exec(shellCommand, {
|
||||
cwd,
|
||||
timeout: timeoutMs,
|
||||
windowsHide: true,
|
||||
env: deps.env,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { BeadDependency, BeadIssue, ParseableBeadIssue } from './types';
|
|||
|
||||
export interface ParseIssuesOptions {
|
||||
includeTombstones?: boolean;
|
||||
skipAgentFilter?: boolean;
|
||||
}
|
||||
|
||||
function normalizeDependencies(value: unknown): BeadDependency[] {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export interface ReadIssuesOptions {
|
|||
projectSource?: ProjectSource;
|
||||
projectAddedAt?: string | null;
|
||||
preferBd?: boolean;
|
||||
skipAgentFilter?: boolean;
|
||||
}
|
||||
|
||||
export function resolveIssuesJsonlPathCandidates(projectRoot: string = process.cwd()): string[] {
|
||||
|
|
@ -108,8 +109,8 @@ async function readIssuesViaBd(options: ReadIssuesOptions, project: ReturnType<t
|
|||
.filter((issue) => {
|
||||
// Exclude tombstones
|
||||
if (issue.status === 'tombstone' && !options.includeTombstones) return false;
|
||||
// Exclude agent identities from mission lists
|
||||
if (issue.labels.includes('gt:agent')) return false;
|
||||
// Exclude agent identities from mission lists unless skipping filter (for watcher/diffing)
|
||||
if (issue.labels.includes('gt:agent') && !options.skipAgentFilter) return false;
|
||||
return true;
|
||||
})
|
||||
.map((issue) => ({
|
||||
|
|
@ -141,6 +142,7 @@ export async function readIssuesFromDisk(options: ReadIssuesOptions = {}): Promi
|
|||
const jsonl = await readTextFileWithRetry(issuesPath);
|
||||
return parseIssuesJsonl(jsonl, {
|
||||
includeTombstones: options.includeTombstones ?? false,
|
||||
skipAgentFilter: options.skipAgentFilter ?? false,
|
||||
}).map((issue) => ({
|
||||
...issue,
|
||||
project,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { canonicalizeWindowsPath, windowsPathKey } from './pathing';
|
||||
import type { ActivityEvent } from './activity';
|
||||
|
||||
export type IssuesChangeKind = 'changed' | 'renamed';
|
||||
export type IssuesChangeKind = 'changed' | 'renamed' | 'telemetry';
|
||||
|
||||
export interface IssuesChangedEvent {
|
||||
id: number;
|
||||
|
|
@ -184,7 +184,8 @@ if (!globalRegistry.__beadboardActivityEventBus) {
|
|||
}
|
||||
|
||||
export function toSseFrame(event: IssuesChangedEvent): string {
|
||||
return `id: ${event.id}\nevent: issues\ndata: ${JSON.stringify(event)}\n\n`;
|
||||
const eventName = event.kind === 'telemetry' ? 'telemetry' : 'issues';
|
||||
return `id: ${event.id}\nevent: ${eventName}\ndata: ${JSON.stringify(event)}\n\n`;
|
||||
}
|
||||
|
||||
export function toActivitySseFrame(event: ActivityDispatchedEvent): string {
|
||||
|
|
|
|||
|
|
@ -48,14 +48,20 @@ export class IssuesWatchManager {
|
|||
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
|
||||
// If it's just last-touched or a DB file change, we treat it as telemetry
|
||||
const changedPath = payload.changedPath || '';
|
||||
const isIssuesJsonl = changedPath.endsWith('issues.jsonl') || changedPath.endsWith('issues.jsonl.new');
|
||||
const isLastTouched = changedPath.includes('last-touched');
|
||||
const isDbPulse = changedPath.includes('beads.db');
|
||||
|
||||
const kind = (isLastTouched || isDbPulse) && !isIssuesJsonl ? 'telemetry' : payload.kind;
|
||||
this.eventBus.emit(projectRoot, payload.changedPath, kind);
|
||||
|
||||
// 2. Perform snapshot diffing if issues.jsonl changed
|
||||
const isBeadsDb = changedPath.includes('beads.db') || isLastTouched;
|
||||
const isGlobalMessages = changedPath.includes('.beadboard') && changedPath.includes('messages');
|
||||
|
||||
if (isIssuesJsonl) {
|
||||
if (isIssuesJsonl || isBeadsDb) {
|
||||
console.log(`[Watcher] Issues changed. Syncing activity for ${projectRoot}...`);
|
||||
await this.syncActivity(projectRoot);
|
||||
} else if (isGlobalMessages) {
|
||||
|
|
@ -71,7 +77,7 @@ export class IssuesWatchManager {
|
|||
const previous = this.snapshots.get(projectKey) ?? null;
|
||||
|
||||
try {
|
||||
const current = await readIssuesFromDisk({ projectRoot });
|
||||
const current = await readIssuesFromDisk({ projectRoot, preferBd: true, skipAgentFilter: true });
|
||||
const events = diffSnapshots(previous, current);
|
||||
|
||||
this.snapshots.set(projectKey, current);
|
||||
|
|
@ -92,7 +98,7 @@ export class IssuesWatchManager {
|
|||
|
||||
// Pre-populate snapshot to avoid "all created" burst on first change
|
||||
try {
|
||||
const initial = await readIssuesFromDisk({ projectRoot });
|
||||
const initial = await readIssuesFromDisk({ projectRoot, preferBd: true, skipAgentFilter: true });
|
||||
this.snapshots.set(projectKey, initial);
|
||||
} catch {
|
||||
// Ignore initial read failure, will retry on first change
|
||||
|
|
@ -165,7 +171,7 @@ export class IssuesWatchManager {
|
|||
}
|
||||
}
|
||||
|
||||
const WATCHER_VERSION = 3; // Bump this to force re-creation on HMR
|
||||
const WATCHER_VERSION = 4; // Bump this to force re-creation on HMR (v4: fix beads.db telemetry classification)
|
||||
|
||||
const globalRegistry = globalThis as typeof globalThis & {
|
||||
__beadboardWatchManager?: IssuesWatchManager;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue