feat(bf-3jyx): fire beadsCompleted on bead.released/release_success

Modern NEEDLE emits bead.released with reason=release_success instead of
legacy bead.completed. Update store.ts and workerAnalytics.ts to handle
this event: increment beadsCompleted, set status to idle, and clear
activeBead/activeFiles/activeDirectories.

Also fixes workerAnalytics trackBeadEvent to use explicit isReleasedSuccess
check instead of broad msg.includes('success') which could false-positive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-15 16:57:28 -04:00
parent cffeb26e79
commit c36ce6da37
7 changed files with 88 additions and 3 deletions

View file

@ -0,0 +1,16 @@
{
"bead_id": "bf-3jyx",
"agent": "claude-sonnet",
"provider": "anthropic",
"model": "claude-sonnet-4-6",
"exit_code": 1,
"outcome": "failure",
"duration_ms": 179856,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-15T20:54:16.080059559Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

View file

@ -0,0 +1 @@
Error: Reached max turns (30)

View file

@ -200,6 +200,37 @@ describe('InMemoryEventStore', () => {
expect(worker?.beadsCompleted).toBe(2);
});
it('should set status to idle on bead.released with release_success', () => {
store.add(createEvent({ worker: 'w-test', msg: 'bead.released', reason: 'release_success', bead: 'bd-1' }));
const worker = store.getWorker('w-test');
expect(worker?.status).toBe('idle');
});
it('should increment beadsCompleted on bead.released with release_success', () => {
store.add(createEvent({ worker: 'w-test', msg: 'bead.released', reason: 'release_success', bead: 'bd-1' }));
store.add(createEvent({ worker: 'w-test', msg: 'bead.released', reason: 'release_success', bead: 'bd-2' }));
const worker = store.getWorker('w-test');
expect(worker?.beadsCompleted).toBe(2);
});
it('should clear activeBead and activeFiles on bead.released with release_success', () => {
store.add(createEvent({ worker: 'w-test', msg: 'bead.claimed', bead: 'bd-1' }));
store.add(createEvent({ worker: 'w-test', msg: 'bead.released', reason: 'release_success', bead: 'bd-1' }));
const worker = store.getWorker('w-test');
expect(worker?.activeBead).toBeUndefined();
expect(worker?.activeFiles).toEqual([]);
});
it('should NOT increment beadsCompleted on bead.released without release_success', () => {
store.add(createEvent({ worker: 'w-test', msg: 'bead.released', reason: 'release_retry', bead: 'bd-1' }));
const worker = store.getWorker('w-test');
expect(worker?.beadsCompleted).toBe(0);
});
it('should track firstSeen timestamp', () => {
const earlyTs = 1000;
const lateTs = 5000;

View file

@ -484,7 +484,8 @@ export class InMemoryEventStore implements EventStore {
// Find the completion event for this bead
const completionEvent = this.events.find(e =>
e.bead === beadId &&
(e.msg === 'bead.completed' || e.msg === 'bead.failed')
(e.msg === 'bead.completed' || e.msg === 'bead.failed' ||
(e.msg === 'bead.released' && e['reason'] === 'release_success'))
);
if (completionEvent) {
@ -686,6 +687,14 @@ export class InMemoryEventStore implements EventStore {
worker.activeDirectories = [];
worker.activeBead = undefined;
}
} else if (needleEvent === 'bead.released' && event['reason'] === 'release_success') {
worker.status = 'idle';
if (event.bead) {
worker.beadsCompleted++;
}
worker.activeFiles = [];
worker.activeDirectories = [];
worker.activeBead = undefined;
} else if (
needleEvent === 'worker.started' ||
needleEvent === 'bead.claimed' ||
@ -746,7 +755,8 @@ export class InMemoryEventStore implements EventStore {
// Check for task completion — match on NEEDLE event type exactly
const msg = event.msg || '';
if (msg === 'bead.completed' || msg === 'bead.failed') {
const isReleasedSuccess = msg === 'bead.released' && event['reason'] === 'release_success';
if (msg === 'bead.completed' || msg === 'bead.failed' || isReleasedSuccess) {
const startTime = this.taskStartTimes.get(beadId);
if (startTime) {
const durationMs = event.ts - startTime;

View file

@ -144,6 +144,32 @@ describe('WorkerAnalytics', () => {
});
});
describe('bead.released tracking', () => {
it('should count bead.released with release_success as a completion', () => {
const events: LogEvent[] = [
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'bead.released', reason: 'release_success', bead: 'bd-1' },
{ ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'bead.released', reason: 'release_success', bead: 'bd-2' },
];
events.forEach(e => analytics.processEvent(e));
const metrics = analytics.getWorkerMetrics('w-1');
expect(metrics?.beadsCompleted).toBe(2);
});
it('should NOT count bead.released with other reasons as a completion', () => {
const events: LogEvent[] = [
{ ts: baseTime, worker: 'w-1', level: 'info', msg: 'bead.released', reason: 'release_retry', bead: 'bd-1' },
{ ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'bead.released', bead: 'bd-2' },
];
events.forEach(e => analytics.processEvent(e));
const metrics = analytics.getWorkerMetrics('w-1');
expect(metrics?.beadsCompleted).toBe(0);
});
});
describe('Error Tracking', () => {
it('should track error count', () => {
const events: LogEvent[] = [

View file

@ -695,11 +695,12 @@ export class WorkerAnalytics implements WorkerAnalyticsStore {
// Detect bead completion
const msg = event.msg?.toLowerCase() || '';
const isReleasedSuccess = event.msg === 'bead.released' && event['reason'] === 'release_success';
if (
msg.includes('completed') ||
msg.includes('finished') ||
msg.includes('done') ||
msg.includes('success')
isReleasedSuccess
) {
const startTime = worker.beadStartTimes.get(beadId);
if (startTime) {