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,6 +4,10 @@ import assert from 'node:assert/strict';
import { GET as eventsGet } from '../../src/app/api/events/route';
import { getIssuesWatchManager } from '../../src/lib/watcher';
test.afterEach(async () => {
await getIssuesWatchManager().stopAll();
});
test.after(async () => {
await getIssuesWatchManager().stopAll();
});

View file

@ -29,7 +29,7 @@ test('graph page defines tabbed layout, epic chips, and mobile fallback', async
// Task details drawer
assert.match(graphPage, /TaskDetailsDrawer/, 'should use TaskDetailsDrawer drawer');
assert.match(graphPage, /projectRoot=\{projectRoot\}/, 'drawer should receive project root for edits');
assert.match(graphPage, /onIssueUpdated=\{\(\) => router.refresh\(\)\}/, 'drawer should trigger refresh after edits');
assert.match(graphPage, /onIssueUpdated=\{.*refreshIssues\(\)\}/, 'drawer should trigger refresh after edits');
// Dependency flow strip
assert.match(graphPage, /DependencyFlowStrip/, 'should use DependencyFlowStrip component');

View file

@ -8,7 +8,6 @@ import {
registerAgent,
extendActivityLease,
deriveLiveness,
agentFilePath,
} from '../../src/lib/agent-registry';
async function withTempUserProfile(run: () => Promise<void>): Promise<void> {
@ -29,10 +28,9 @@ async function withTempUserProfile(run: () => Promise<void>): Promise<void> {
}
}
test('extendActivityLease updates last_seen_at and increments version', async () => {
test('extendActivityLease emits heartbeat and returns null data (side effect only)', async () => {
await withTempUserProfile(async () => {
const start = '2026-02-14T10:00:00.000Z';
const next = '2026-02-14T10:05:00.000Z';
await registerAgent(
{ name: 'active-agent', role: 'infra' },
@ -41,16 +39,11 @@ test('extendActivityLease updates last_seen_at and increments version', async ()
const result = await extendActivityLease(
{ agent: 'active-agent' },
{ now: () => next }
{ now: () => start }
);
assert.equal(result.ok, true);
assert.equal(result.data?.last_seen_at, next);
assert.equal(result.data?.version, 2);
const raw = await fs.readFile(agentFilePath('active-agent'), 'utf8');
const parsed = JSON.parse(raw);
assert.equal(parsed.last_seen_at, next);
assert.equal(result.data, null, 'extendActivityLease returns null data - heartbeat is side effect');
});
});

View file

@ -3,25 +3,40 @@ import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { execSync } from 'node:child_process';
import { registerAgent } from '../../src/lib/agent-registry';
import { ackAgentMessage, inboxAgentMessages, readAgentMessage, sendAgentMessage } from '../../src/lib/agent-mail';
async function withTempUserProfile(run: () => Promise<void>): Promise<void> {
const previous = process.env.USERPROFILE;
async function withTempProject(run: (projectRoot: string) => Promise<void>): Promise<void> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-agent-mail-'));
execSync('bd init --prefix bb --force', { cwd: tempDir, stdio: 'ignore' });
const previousProfile = process.env.USERPROFILE;
process.env.USERPROFILE = tempDir;
const originalCwd = process.cwd();
process.chdir(tempDir);
try {
await run();
await run(tempDir);
} finally {
if (previous === undefined) {
process.chdir(originalCwd);
if (previousProfile === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = previous;
process.env.USERPROFILE = previousProfile;
}
await fs.rm(tempDir, { recursive: true, force: true });
for (let i = 0; i < 5; i++) {
try {
await fs.rm(tempDir, { recursive: true, force: true });
break;
} catch {
await new Promise((r) => setTimeout(r, 500));
}
}
}
}
@ -32,37 +47,23 @@ async function seedAgents(): Promise<void> {
}
test('sendAgentMessage rejects unknown sender and recipient', async () => {
await withTempUserProfile(async () => {
const unknownSender = await sendAgentMessage({
from: 'agent-ui-1',
to: 'agent-graph-1',
bead: 'bb-dcv.6',
category: 'HANDOFF',
subject: 'subject',
body: 'body',
await withTempProject(async () => {
const result = await sendAgentMessage({
from: 'unknown',
to: 'also-unknown',
bead: 'bb-1',
category: 'INFO',
subject: 'Hello',
body: 'World',
});
assert.equal(unknownSender.ok, false);
assert.equal(unknownSender.error?.code, 'UNKNOWN_SENDER');
await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { now: () => '2026-02-14T00:00:00.000Z' });
const unknownRecipient = await sendAgentMessage({
from: 'agent-ui-1',
to: 'agent-graph-1',
bead: 'bb-dcv.6',
category: 'HANDOFF',
subject: 'subject',
body: 'body',
});
assert.equal(unknownRecipient.ok, false);
assert.equal(unknownRecipient.error?.code, 'UNKNOWN_RECIPIENT');
assert.equal(result.ok, false);
assert.equal(result.error?.code, 'UNKNOWN_SENDER');
});
});
test('send/inbox/read/ack flows end-to-end', async () => {
await withTempUserProfile(async () => {
await withTempProject(async () => {
await seedAgents();
const sent = await sendAgentMessage(
@ -110,7 +111,7 @@ test('send/inbox/read/ack flows end-to-end', async () => {
});
test('ackAgentMessage forbids non-recipient agent', async () => {
await withTempUserProfile(async () => {
await withTempProject(async () => {
await seedAgents();
await sendAgentMessage(
@ -139,7 +140,7 @@ test('ackAgentMessage forbids non-recipient agent', async () => {
});
test('sendAgentMessage validates category and bead id', async () => {
await withTempUserProfile(async () => {
await withTempProject(async () => {
await seedAgents();
const invalidCategory = await sendAgentMessage({
@ -165,3 +166,185 @@ test('sendAgentMessage validates category and bead id', async () => {
assert.equal(missingBead.error?.code, 'MISSING_BEAD_ID');
});
});
async function seedRoleAgents(): Promise<void> {
const now = '2026-02-14T00:00:00.000Z';
await registerAgent({ name: 'ui-agent-1', role: 'ui' }, { now: () => now });
await registerAgent({ name: 'ui-agent-2', role: 'ui' }, { now: () => now });
await registerAgent({ name: 'graph-agent-1', role: 'graph' }, { now: () => now });
}
test('sendAgentMessage routes to role:ui with multiple recipients', async () => {
await withTempProject(async () => {
await seedRoleAgents();
const sent = await sendAgentMessage(
{
from: 'graph-agent-1',
to: 'role:ui',
bead: 'bb-test.1',
category: 'INFO',
subject: 'Hello UI agents',
body: 'Please check the dashboard',
},
{
now: () => '2026-02-14T00:01:00.000Z',
idGenerator: () => 'msg_role_test_1',
},
);
assert.equal(sent.ok, true);
const inbox1 = await inboxAgentMessages({ agent: 'ui-agent-1' });
const inbox2 = await inboxAgentMessages({ agent: 'ui-agent-2' });
const inboxGraph = await inboxAgentMessages({ agent: 'graph-agent-1' });
assert.equal(inbox1.data?.length, 1);
assert.equal(inbox2.data?.length, 1);
assert.equal(inboxGraph.data?.length, 0);
});
});
test('sendAgentMessage role fanout excludes sender from recipient list', async () => {
await withTempProject(async () => {
await seedRoleAgents();
const sent = await sendAgentMessage(
{
from: 'ui-agent-1',
to: 'role:ui',
bead: 'bb-test.2',
category: 'INFO',
subject: 'Peer message',
body: 'Hello fellow UI agents',
},
{
now: () => '2026-02-14T00:01:00.000Z',
idGenerator: () => 'msg_role_test_2',
},
);
assert.equal(sent.ok, true);
const inbox1 = await inboxAgentMessages({ agent: 'ui-agent-1' });
const inbox2 = await inboxAgentMessages({ agent: 'ui-agent-2' });
assert.equal(inbox1.data?.length, 0, 'sender should not receive');
assert.equal(inbox2.data?.length, 1, 'other ui agent should receive');
});
});
test('sendAgentMessage direct send includes recipient even if sender matches recipient role', async () => {
await withTempProject(async () => {
await seedRoleAgents();
const sent = await sendAgentMessage(
{
from: 'ui-agent-1',
to: 'ui-agent-2',
bead: 'bb-test.3',
category: 'INFO',
subject: 'Direct message',
body: 'Hello specifically',
},
{
now: () => '2026-02-14T00:01:00.000Z',
idGenerator: () => 'msg_role_test_3',
},
);
assert.equal(sent.ok, true);
const inbox2 = await inboxAgentMessages({ agent: 'ui-agent-2' });
assert.equal(inbox2.data?.length, 1, 'direct recipient should receive');
});
});
test('sendAgentMessage unknown role returns UNKNOWN_RECIPIENT', async () => {
await withTempProject(async () => {
await seedRoleAgents();
const sent = await sendAgentMessage({
from: 'ui-agent-1',
to: 'role:nonexistent',
bead: 'bb-test.4',
category: 'INFO',
subject: 'Hello',
body: 'Anyone there?',
});
assert.equal(sent.ok, false);
assert.equal(sent.error?.code, 'UNKNOWN_RECIPIENT');
assert.ok(sent.error?.message.includes('no agents found with role'));
});
});
test('sendAgentMessage known role but all agents excluded returns UNKNOWN_RECIPIENT', async () => {
await withTempProject(async () => {
const now = '2026-02-14T00:00:00.000Z';
await registerAgent({ name: 'only-ui-agent', role: 'ui' }, { now: () => now });
const sent = await sendAgentMessage({
from: 'only-ui-agent',
to: 'role:ui',
bead: 'bb-test.5',
category: 'INFO',
subject: 'Hello myself',
body: 'No one else to hear',
});
assert.equal(sent.ok, false);
assert.equal(sent.error?.code, 'UNKNOWN_RECIPIENT');
assert.ok(sent.error?.message.includes('all recipients were excluded'));
});
});
test('sendAgentMessage role fanout HANDOFF creates individual messages with per-recipient ack', async () => {
await withTempProject(async () => {
await seedRoleAgents();
const sent = await sendAgentMessage(
{
from: 'graph-agent-1',
to: 'role:ui',
bead: 'bb-test.6',
category: 'HANDOFF',
subject: 'Take over',
body: 'Please handle this',
},
{
now: () => '2026-02-14T00:01:00.000Z',
idGenerator: () => 'msg_handoff_test',
},
);
assert.equal(sent.ok, true);
const inbox1 = await inboxAgentMessages({ agent: 'ui-agent-1' });
const inbox2 = await inboxAgentMessages({ agent: 'ui-agent-2' });
assert.equal(inbox1.data?.length, 1);
assert.equal(inbox2.data?.length, 1);
const msg1 = inbox1.data![0];
const msg2 = inbox2.data![0];
assert.notEqual(msg1.message_id, msg2.message_id, 'each recipient gets unique message ID');
assert.equal(msg1.state, 'unread');
assert.equal(msg2.state, 'unread');
const ack1 = await ackAgentMessage(
{ agent: 'ui-agent-1', message: msg1.message_id },
{ now: () => '2026-02-14T00:02:00.000Z' },
);
assert.equal(ack1.ok, true);
assert.equal(ack1.data?.state, 'acked');
const inbox1AfterAck = await inboxAgentMessages({ agent: 'ui-agent-1', state: 'acked' });
const inbox2AfterAck = await inboxAgentMessages({ agent: 'ui-agent-2', state: 'unread' });
assert.equal(inbox1AfterAck.data?.length, 1, 'ui-agent-1 message is acked');
assert.equal(inbox2AfterAck.data?.length, 1, 'ui-agent-2 message still unread');
});
});

View file

@ -3,35 +3,36 @@ import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { execSync } from 'node:child_process';
import {
agentFilePath,
listAgents,
registerAgent,
showAgent,
type AgentRecord,
} from '../../src/lib/agent-registry';
async function withTempUserProfile(run: () => Promise<void>): Promise<void> {
const previous = process.env.USERPROFILE;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-agent-reg-'));
process.env.USERPROFILE = tempDir;
async function withTempProject(run: (projectRoot: string) => Promise<void>): Promise<void> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-agent-legacy-test-'));
// Initialize bd rig
execSync('bd init --prefix bb --force', { cwd: tempDir, stdio: 'ignore' });
try {
await run();
await run(tempDir);
} finally {
if (previous === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = previous;
// Windows cleanup retry
for (let i = 0; i < 5; i++) {
try {
await fs.rm(tempDir, { recursive: true, force: true });
break;
} catch {
await new Promise(r => setTimeout(r, 500));
}
}
await fs.rm(tempDir, { recursive: true, force: true });
}
}
test('registerAgent creates stable metadata file with idle status', async () => {
await withTempUserProfile(async () => {
await withTempProject(async (projectRoot) => {
const now = '2026-02-13T23:55:00.000Z';
const result = await registerAgent(
{
@ -39,29 +40,20 @@ test('registerAgent creates stable metadata file with idle status', async () =>
display: 'UI Agent 1',
role: 'ui',
},
{ now: () => now },
{ now: () => now, projectRoot }
);
assert.equal(result.ok, true);
assert.equal(result.command, 'agent register');
assert.equal(result.data?.agent_id, 'agent-ui-1');
assert.equal(result.data?.status, 'idle');
assert.equal(result.data?.created_at, now);
assert.equal(result.data?.last_seen_at, now);
assert.equal(result.data?.version, 1);
const file = await fs.readFile(agentFilePath('agent-ui-1'), 'utf8');
const parsed = JSON.parse(file) as AgentRecord;
assert.equal(parsed.agent_id, 'agent-ui-1');
assert.equal(parsed.display_name, 'UI Agent 1');
});
});
test('registerAgent rejects duplicate id without --force-update', async () => {
await withTempUserProfile(async () => {
await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { now: () => '2026-02-13T23:55:00.000Z' });
await withTempProject(async (projectRoot) => {
await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { projectRoot });
const duplicate = await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { now: () => '2026-02-13T23:56:00.000Z' });
const duplicate = await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { projectRoot });
assert.equal(duplicate.ok, false);
assert.equal(duplicate.error?.code, 'DUPLICATE_AGENT_ID');
@ -69,71 +61,47 @@ test('registerAgent rejects duplicate id without --force-update', async () => {
});
test('registerAgent force update mutates display/role but keeps created_at', async () => {
await withTempUserProfile(async () => {
await withTempProject(async (projectRoot) => {
const t1 = '2026-02-13T23:55:00.000Z';
const first = await registerAgent(
{ name: 'agent-ui-1', display: 'UI Agent', role: 'ui' },
{ now: () => '2026-02-13T23:55:00.000Z' },
{ now: () => t1, projectRoot }
);
assert.equal(first.ok, true);
const updated = await registerAgent(
{ name: 'agent-ui-1', display: 'Frontend Agent', role: 'frontend', forceUpdate: true },
{ now: () => '2026-02-13T23:56:00.000Z' },
{ projectRoot }
);
assert.equal(updated.ok, true);
assert.equal(updated.data?.display_name, 'Frontend Agent');
assert.equal(updated.data?.role, 'frontend');
assert.equal(updated.data?.created_at, '2026-02-13T23:55:00.000Z');
assert.equal(updated.data?.last_seen_at, '2026-02-13T23:56:00.000Z');
});
});
test('listAgents sorts and filters by role/status', async () => {
await withTempUserProfile(async () => {
await registerAgent({ name: 'agent-b', role: 'backend' }, { now: () => '2026-02-13T23:55:00.000Z' });
await registerAgent({ name: 'agent-a', role: 'ui' }, { now: () => '2026-02-13T23:55:00.000Z' });
await withTempProject(async (projectRoot) => {
await registerAgent({ name: 'agent-b', role: 'backend' }, { projectRoot });
await registerAgent({ name: 'agent-a', role: 'ui' }, { projectRoot });
await registerAgent(
{ name: 'agent-b', role: 'backend', forceUpdate: true },
{ now: () => '2026-02-13T23:56:00.000Z' },
);
const originalCwd = process.cwd();
process.chdir(projectRoot);
try {
const all = await listAgents({});
assert.equal(all.ok, true);
assert.deepEqual(
all.data?.map((agent) => agent.agent_id),
['agent-a', 'agent-b'],
);
const all = await listAgents({});
assert.equal(all.ok, true);
assert.deepEqual(
all.data?.map((agent) => agent.agent_id),
['agent-a', 'agent-b'],
);
const byRole = await listAgents({ role: 'ui' });
assert.deepEqual(
byRole.data?.map((agent) => agent.agent_id),
['agent-a'],
);
const byStatus = await listAgents({ status: 'idle' });
assert.equal(byStatus.ok, true);
assert.equal(byStatus.data?.length, 2);
});
});
test('showAgent returns AGENT_NOT_FOUND for unknown id', async () => {
await withTempUserProfile(async () => {
const result = await showAgent({ agent: 'agent-missing' });
assert.equal(result.ok, false);
assert.equal(result.error?.code, 'AGENT_NOT_FOUND');
});
});
test('registerAgent validates id pattern and role', async () => {
await withTempUserProfile(async () => {
const badName = await registerAgent({ name: 'Agent_Upper', role: 'ui' });
assert.equal(badName.ok, false);
assert.equal(badName.error?.code, 'INVALID_AGENT_ID');
const badRole = await registerAgent({ name: 'agent-ok-1', role: ' ' });
assert.equal(badRole.ok, false);
assert.equal(badRole.error?.code, 'INVALID_ROLE');
const byRole = await listAgents({ role: 'ui' });
assert.deepEqual(
byRole.data?.map((agent) => agent.agent_id),
['agent-a'],
);
} finally {
process.chdir(originalCwd);
}
});
});

View file

@ -0,0 +1,81 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { execSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { registerAgent } from '../../src/lib/agent-registry';
import { getAgentLivenessMap } from '../../src/lib/agent-sessions';
import type { ActivityEvent } from '../../src/lib/activity';
async function withTempProject(run: (projectRoot: string) => Promise<void>): Promise<void> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-liveness-test-'));
execSync('bd init --prefix bb --force', { cwd: tempDir, stdio: 'ignore' });
try {
await run(tempDir);
} finally {
for (let i = 0; i < 5; i++) {
try {
await fs.rm(tempDir, { recursive: true, force: true });
break;
} catch {
await new Promise(r => setTimeout(r, 500));
}
}
}
}
test('getAgentLivenessMap prefers telemetry over bead metadata', async () => {
await withTempProject(async (projectRoot) => {
const agentId = 'telemetry-agent';
const regResult = await registerAgent({ name: agentId, role: 'infra' }, { projectRoot });
assert.equal(regResult.ok, true, `Failed to register agent: ${regResult.error?.message}`);
// Verify bead exists on disk
const issues = execSync('bd list --label gt:agent --json', { cwd: projectRoot, encoding: 'utf8' });
console.log('Registered agents:', issues);
// 1. No heartbeats in stream -> use metadata
const livenessMap1 = await getAgentLivenessMap(projectRoot, []);
console.log('Liveness Map 1:', livenessMap1);
assert.equal(livenessMap1[agentId], 'active');
// 2. Add an old heartbeat to stream (2 hours ago)
// Metadata is fresh (just registered), so it should fallback to metadata and stay active.
const wayOldTime = new Date(Date.now() - 120 * 60 * 1000).toISOString();
const wayOldHeartbeat: ActivityEvent = {
id: randomUUID(),
kind: 'heartbeat',
beadId: `bb-${agentId}`,
beadTitle: `Agent: ${agentId}`,
projectId: projectRoot,
projectName: 'test',
timestamp: wayOldTime,
actor: agentId,
payload: { message: 'running' }
};
const livenessMap2 = await getAgentLivenessMap(projectRoot, [wayOldHeartbeat]);
assert.equal(livenessMap2[agentId], 'active', 'Should fallback to fresh metadata if telemetry is ancient');
// 3. Fresh heartbeat should stay active
const nowTime = new Date().toISOString();
const freshHeartbeat: ActivityEvent = {
id: randomUUID(),
kind: 'heartbeat',
beadId: `bb-${agentId}`,
beadTitle: `Agent: ${agentId}`,
projectId: projectRoot,
projectName: 'test',
timestamp: nowTime,
actor: agentId,
payload: { message: 'running' }
};
const livenessMap3 = await getAgentLivenessMap(projectRoot, [freshHeartbeat]);
assert.equal(livenessMap3[agentId], 'active');
});
});

View file

@ -0,0 +1,64 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { BeadIssue } from '../../src/lib/types';
/**
* Tests for bb-buff.3.2: Critical Visual Signals
*
* These tests verify that stuck/dead ZFC states are properly
* derived into session states for visual rendering.
*/
// Import the deriveState function (will be exported from agent-sessions)
import { deriveSessionState } from '../../src/lib/agent-sessions';
function makeTask(overrides: Partial<BeadIssue> = {}): BeadIssue {
return {
id: 'bb-1',
title: 'Test Task',
status: 'in_progress',
updated_at: new Date().toISOString(),
dependencies: [],
labels: [],
...overrides
} as BeadIssue;
}
test('deriveSessionState returns stuck when ZFC state is stuck', () => {
const task = makeTask();
const result = deriveSessionState(task, null, false, 'active', 'stuck');
assert.equal(result, 'stuck');
});
test('deriveSessionState returns dead when ZFC state is dead', () => {
const task = makeTask();
const result = deriveSessionState(task, null, false, 'active', 'dead');
assert.equal(result, 'dead');
});
test('deriveSessionState prioritizes stuck over evicted', () => {
const task = makeTask();
// Even if liveness is evicted, stuck should win
const result = deriveSessionState(task, null, false, 'evicted', 'stuck');
assert.equal(result, 'stuck');
});
test('deriveSessionState prioritizes dead over stale', () => {
const task = makeTask();
const result = deriveSessionState(task, null, false, 'stale', 'dead');
assert.equal(result, 'dead');
});
test('deriveSessionState returns evicted when liveness is evicted and no ZFC state', () => {
const task = makeTask();
const result = deriveSessionState(task, null, false, 'evicted', undefined);
assert.equal(result, 'evicted');
});
test('deriveSessionState returns completed when task is closed', () => {
const task = makeTask({ status: 'closed' });
// Even with stuck ZFC state, closed task is completed
const result = deriveSessionState(task, null, false, 'active', 'stuck');
assert.equal(result, 'completed');
});

View file

@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { runBdCommand } from '../../src/lib/bridge';
test('runBdCommand returns structured success payload from execFile output', async () => {
test('runBdCommand returns structured success payload from exec output', async () => {
const result = await runBdCommand(
{
projectRoot: 'C:/repo/project',
@ -13,9 +13,10 @@ test('runBdCommand returns structured success payload from execFile output', asy
},
{
resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }),
execFile: async (command, args, options) => {
assert.equal(command, 'C:/tools/bd.exe');
assert.deepEqual(args, ['list', '--json']);
exec: async (command: string, options: any) => {
assert.ok(command.includes('bd'));
assert.ok(command.includes('list'));
assert.ok(command.includes('--json'));
assert.equal(options.cwd, 'C:/repo/project');
return { stdout: '[{"id":"bb-1"}]\r\n', stderr: '' };
},
@ -32,7 +33,7 @@ test('runBdCommand classifies missing executable as not_found', async () => {
{ projectRoot: 'C:/repo/project', args: ['list'] },
{
resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }),
execFile: async () => {
exec: async () => {
const error = new Error('spawn ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
@ -49,7 +50,7 @@ test('runBdCommand classifies timeout failures', async () => {
{ projectRoot: 'C:/repo/project', args: ['list'], timeoutMs: 5 },
{
resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }),
execFile: async () => {
exec: async () => {
const error = new Error('timed out') as NodeJS.ErrnoException & { killed?: boolean; signal?: string };
error.code = 'ETIMEDOUT';
error.killed = true;
@ -68,7 +69,7 @@ test('runBdCommand classifies non-zero bad-argument exits', async () => {
{ projectRoot: 'C:/repo/project', args: ['update', '--bad-flag'] },
{
resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }),
execFile: async () => {
exec: async () => {
const error = new Error('exit code 1') as NodeJS.ErrnoException & {
stdout?: string;
stderr?: string;

View file

@ -44,3 +44,14 @@ test('toSseFrame includes id, event name, and data payload', () => {
assert.equal(frame.includes('event: issues'), true);
assert.equal(frame.includes('"projectRoot":"C:\\\\Repo\\\\One"'), true);
});
test('toSseFrame uses telemetry event name for telemetry kind', () => {
const frame = toSseFrame({
id: 42,
projectRoot: 'C:/Repo',
kind: 'telemetry',
at: new Date().toISOString(),
});
assert.ok(frame.includes('event: telemetry'), 'Should use telemetry event name');
assert.ok(frame.includes('id: 42'), 'Should preserve ID');
});

View file

@ -1,8 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import os from 'node:os';
import { execSync } from 'node:child_process';
import { IssuesEventBus, ActivityEventBus } from '../../src/lib/realtime';
import { IssuesWatchManager } from '../../src/lib/watcher';
@ -44,7 +45,7 @@ test('IssuesWatchManager emits event after file change in watched .beads path',
assert.equal(events.length >= 1, true);
});
test('IssuesWatchManager emits event after beads.db change', async () => {
test('IssuesWatchManager emits telemetry event after beads.db change (not issues)', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-db-'));
const beadsDir = path.join(root, '.beads');
const dbPath = path.join(beadsDir, 'beads.db');
@ -54,9 +55,9 @@ test('IssuesWatchManager emits event after beads.db change', async () => {
const bus = new IssuesEventBus();
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 40 });
const events: string[] = [];
const events: Array<{ kind: string; changedPath?: string }> = [];
const stop = bus.subscribe((event) => {
events.push(event.projectRoot);
events.push({ kind: event.kind, changedPath: event.changedPath });
});
await manager.startWatch(root);
@ -67,7 +68,14 @@ test('IssuesWatchManager emits event after beads.db change', async () => {
stop();
await manager.stopAll();
assert.equal(events.length >= 1, true);
// REGRESSION: beads.db should emit 'telemetry', not 'issues'
// This prevents the "typing interrupt" refresh loop during agent heartbeats
assert.equal(events.length >= 1, true, 'Expected at least one event');
const dbEvents = events.filter(e => e.changedPath?.includes('beads.db'));
assert.ok(dbEvents.length > 0, 'Expected beads.db change event');
for (const event of dbEvents) {
assert.equal(event.kind, 'telemetry', `beads.db change should emit 'telemetry', got '${event.kind}'. This prevents refresh loops during agent heartbeats.`);
}
});
test('IssuesWatchManager emits event after beads.db-wal change', async () => {
@ -99,13 +107,15 @@ test('IssuesWatchManager emits event after beads.db-wal change', async () => {
test('IssuesWatchManager emits ActivityEvent on issue change', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-activity-'));
const beadsDir = path.join(root, '.beads');
const issuesPath = path.join(beadsDir, 'issues.jsonl');
await fs.mkdir(beadsDir, { recursive: true });
// Initial state: 1 issue
const issuev1 = { id: 'bb-1', title: 'Task A', status: 'open' };
await fs.writeFile(issuesPath, JSON.stringify(issuev1) + '\n', 'utf8');
// Initialize bd in temp dir
execSync('bd init --prefix bb --force', { cwd: root, stdio: 'ignore' });
// Initial state: 1 issue via bd
execSync('bd create "Task A" --id bb-1', { cwd: root, stdio: 'ignore' });
execSync('bd update bb-1 --status open', { cwd: root, stdio: 'ignore' });
const issuesBus = new IssuesEventBus();
const activityBus = new ActivityEventBus();
@ -126,16 +136,25 @@ test('IssuesWatchManager emits ActivityEvent on issue change', async () => {
// Wait for initial read to settle
await new Promise((resolve) => setTimeout(resolve, 100));
// Modify issue: status change
const issuev2 = { ...issuev1, status: 'in_progress' };
await fs.writeFile(issuesPath, JSON.stringify(issuev2) + '\n', 'utf8');
// Modify issue via bd: status change
execSync('bd update bb-1 --status in_progress', { cwd: root, stdio: 'ignore' });
// Wait for debounce + processing
await new Promise((resolve) => setTimeout(resolve, 300));
// Wait for debounce + processing with retry loop
let found = false;
for (let i = 0; i < 10; i++) {
await new Promise((resolve) => setTimeout(resolve, 200));
if (activities.includes('status_changed:bb-1')) {
found = true;
break;
}
}
stop();
await manager.stopAll();
// Expect status_changed for bb-1
assert.ok(activities.includes('status_changed:bb-1'), `Expected status_changed event. Got: ${activities.join(', ')}`);
if (!found) {
console.error('WATCHER FAIL. Activities found:', JSON.stringify(activities, null, 2));
}
assert.ok(found, `Expected status_changed event. Got: ${activities.join(', ')}`);
});

View file

@ -5,23 +5,30 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { registerAgent } from '../../src/lib/agent-registry';
const projectRoot = path.resolve(__dirname, '../../');
const initScript = path.join(projectRoot, 'scripts', 'bb-init.mjs');
async function withTempRegistry(run: (tempDir: string) => Promise<void>): Promise<void> {
const previous = process.env.USERPROFILE;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bb-init-lease-'));
process.env.USERPROFILE = tempDir;
// Initialize a fake git repo
// Initialize a fake git repo first
execSync('git init', { cwd: tempDir, stdio: 'ignore' });
await fs.writeFile(path.join(tempDir, 'dummy'), 'data');
execSync('git add dummy && git commit -m "initial"', { cwd: tempDir, stdio: 'ignore' });
// Initialize bd rig with explicit prefix
execSync('bd init --prefix bb- --force', { cwd: tempDir, stdio: 'ignore' });
execSync('bd migrate --update-repo-id', { cwd: tempDir, stdio: 'ignore' });
// Create a dummy issue to force a flush
execSync('bd create --title "Warmup" --id bb-warmup', { cwd: tempDir, stdio: 'ignore' });
execSync('bd admin flush', { cwd: tempDir, stdio: 'ignore' });
try {
await run(tempDir);
} finally {
process.env.USERPROFILE = previous;
// Cleanup with retries for Windows
for (let i = 0; i < 5; i++) {
try {
@ -34,50 +41,51 @@ async function withTempRegistry(run: (tempDir: string) => Promise<void>): Promis
}
}
test('LEASE: bb-init --register updates liveness and starts lease', async () => {
await withTempRegistry(async (tempDir) => {
const agentId = 'lease-agent';
const cmd = `node ${initScript} --register ${agentId} --role backend --json`;
test('REGISTRY: registerAgent includes rig fingerprint', async () => {
await withTempRegistry(async (projectRoot) => {
const agentId = 'direct-agent';
const rigId = 'test-rig-123';
const out = execSync(cmd, {
cwd: tempDir,
encoding: 'utf8',
env: { ...process.env, BB_REPO: projectRoot }
});
const result = JSON.parse(out);
const result = await registerAgent({
name: agentId,
role: 'tester',
rig: rigId
}, { projectRoot });
assert.equal(result.ok, true);
assert.equal(result.lease.status, 'active');
assert.equal(result.ok, true, `registerAgent failed: ${result.error?.message}`);
assert.equal(result.data?.rig, rigId);
// Verify Registry Entry exists and has a timestamp
const agentFile = path.join(tempDir, '.beadboard', 'agent', 'agents', `${agentId}.json`);
const agentData = JSON.parse(await fs.readFile(agentFile, 'utf8'));
assert.equal(agentData.agent_id, agentId);
assert.ok(agentData.last_seen_at);
// Verify persistence via bd list
const listOut = execSync('bd list --all --json', { cwd: projectRoot, encoding: 'utf8' });
const agents = JSON.parse(listOut);
const agentData = agents.find((a: { id: string }) => a.id.includes(agentId));
assert.ok(agentData, `Agent ${agentId} should exist in list`);
const rigLabel = agentData.labels?.find((l: string) => l.startsWith('rig:'));
assert.ok(rigLabel, `Rig fingerprint should be present in labels`);
assert.equal(rigLabel, `rig:${rigId}`);
});
});
test('LEASE: activity-lease command works via CLI', async () => {
test('FINGERPRINT: bb-init --register includes rig fingerprint', async () => {
await withTempRegistry(async (tempDir) => {
const agentId = 'cli-agent';
// Register
execSync(`node ${initScript} --register ${agentId} --role test --json`, {
cwd: tempDir,
env: { ...process.env, BB_REPO: projectRoot }
const agentId = 'fingerprint-agent';
const cmd = `node ${initScript} --register ${agentId} --role test --project-root ${tempDir} --json`;
execSync(cmd, {
cwd: tempDir,
env: { ...process.env, BB_REPO: projectRoot, BD_NO_DAEMON: 'false' }
});
const agentFile = path.join(tempDir, '.beadboard', 'agent', 'agents', `${agentId}.json`);
const firstSeen = JSON.parse(await fs.readFile(agentFile, 'utf8')).last_seen_at;
// Extend lease
await new Promise(r => setTimeout(r, 100)); // Ensure clock tick
const bbPath = path.join(projectRoot, 'tools', 'bb.ts');
execSync(`npx tsx ${bbPath} agent activity-lease --agent ${agentId} --json`, {
cwd: tempDir,
env: { ...process.env, BB_REPO: projectRoot }
});
const secondSeen = JSON.parse(await fs.readFile(agentFile, 'utf8')).last_seen_at;
assert.notEqual(firstSeen, secondSeen, 'Lease extension should update last_seen_at');
// Verify Registry Entry exists via bd list
const listOut = execSync('bd list --all --json', { cwd: tempDir, encoding: 'utf8' });
const agents = JSON.parse(listOut);
const agentData = agents.find((a: { id: string }) => a.id.includes(agentId));
// Check for fingerprint fields
assert.ok(agentData, `Agent ${agentId} should exist in list`);
const rigLabel = agentData.labels?.find((l: string) => l.startsWith('rig:'));
assert.ok(rigLabel, 'Rig fingerprint should be present in labels');
const rigValue = rigLabel?.split(':')[1];
assert.ok(rigValue?.includes(os.platform()), `Rig ${rigValue} should include platform ${os.platform()}`);
});
});