Skip to content

Commit 73be3eb

Browse files
puritysbclaude
andcommitted
fix(apme): route adapter-emitted task_boundary spans to closeTask
The collector's task_boundary span case handled only signal='clear' (splitRun) and silently dropped every other signal with a comment claiming they were "handled automatically elsewhere". That contract broke when the new adapter hooks landed: OpenClaw emits 'manual' on chat.aborted and 'idle_gap' on its idle-gap timer; OpenCode emits 'todo_complete' on its TodoWrite all-completed detection. None of those signals reach the Claude-Code-only auto-detection paths the comment relied on, so the active task stayed open until session_end — task_judge never fired and the upserted Timeline task_end row never received score/outcome. Fix routes signal in {todo_complete, manual, idle_gap} to `closeTask(sessionId, signal)`. session_end stays out of the route because closeRun fires that transition directly and a duplicate would re-emit onTaskClosed. Unknown signals log + drop (failure surfaces in test) rather than silently disappear. Added test coverage in apme-telemetry-envelope.test.ts for each of the three signals and for the unknown-signal drop. Existing /clear test preserved. Full suite: 1267/1267 (up from 1263, +4). TaskBoundarySignal type widened to include 'idle_gap' (already referenced unchecked in index.ts label switch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a09fc78 commit 73be3eb

2 files changed

Lines changed: 55 additions & 6 deletions

File tree

bridge/src/__tests__/apme-telemetry-envelope.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,46 @@ describe('ApmeCollector.ingestSpan dispatch', () => {
406406
expect(afterRunId).not.toBe(beforeRunId);
407407
});
408408

409+
it.each(['manual', 'idle_gap', 'todo_complete'] as const)(
410+
'task_boundary span with signal=%s closes the active task',
411+
(signal) => {
412+
// Seed a turn so a task is opened (closeTask drops empty tasks otherwise).
413+
for (const s of claudeHookToSpans(ctx(), 'UserPromptSubmit', { message: { content: 'work' } })) {
414+
collector.ingestSpan('S', s);
415+
}
416+
const taskId = collector.getActiveTaskId('S');
417+
expect(taskId).not.toBeNull();
418+
419+
let closedSignal: string | null = null;
420+
collector.onTaskClosed = ({ boundarySignal }) => { closedSignal = boundarySignal; };
421+
422+
collector.ingestSpan('S', {
423+
traceId: 'T', spanId: 'b', name: spanNameForKind('task_boundary'),
424+
kind: 'task_boundary', ts: Date.now(),
425+
attributes: { 'agentdeck.boundary_signal': signal },
426+
});
427+
428+
expect(collector.getActiveTaskId('S')).toBeNull();
429+
expect(closedSignal).toBe(signal);
430+
},
431+
);
432+
433+
it('task_boundary span with an unknown signal is dropped (no task close, no throw)', () => {
434+
for (const s of claudeHookToSpans(ctx(), 'UserPromptSubmit', { message: { content: 'work' } })) {
435+
collector.ingestSpan('S', s);
436+
}
437+
const taskBefore = collector.getActiveTaskId('S');
438+
expect(taskBefore).not.toBeNull();
439+
440+
collector.ingestSpan('S', {
441+
traceId: 'T', spanId: 'b', name: spanNameForKind('task_boundary'),
442+
kind: 'task_boundary', ts: Date.now(),
443+
attributes: { 'agentdeck.boundary_signal': 'made_up_value' },
444+
});
445+
446+
expect(collector.getActiveTaskId('S')).toBe(taskBefore);
447+
});
448+
409449
it('raw_step span inserts a steps row without lifecycle effects', () => {
410450
for (const s of claudeHookToSpans(ctx(), 'UserPromptSubmit', { message: { content: 'q' } })) {
411451
collector.ingestSpan('S', s);

bridge/src/apme/collector.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ interface ActiveTask {
5858
lastTurnIndex: number | null;
5959
}
6060

61-
export type TaskBoundarySignal = 'todo_complete' | 'clear' | 'session_end' | 'manual';
61+
export type TaskBoundarySignal = 'todo_complete' | 'clear' | 'session_end' | 'manual' | 'idle_gap';
6262

6363
/** Callback fired after a task is closed in DB. Used to enqueue task-level eval
6464
* without creating a direct dependency from collector → runner. */
@@ -505,11 +505,20 @@ export class ApmeCollector {
505505
this.splitRun(sessionId, (a['agentdeck.cwd'] as string | undefined));
506506
return;
507507
}
508-
// Other boundary signals (todo_complete, session_end) are handled
509-
// automatically: tool_result detects todo_complete, closeRun closes
510-
// the current task with session_end. Adapters that emit those
511-
// spans explicitly are no-ops here by design — preserving the
512-
// single-source-of-truth for those transitions.
508+
// Adapter-emitted boundaries (OpenClaw chat.aborted → 'manual',
509+
// OpenClaw idle-gap timer → 'idle_gap', OpenCode TodoWrite
510+
// all-completed → 'todo_complete') must close the active task.
511+
// Without this route those spans were silently swallowed and the
512+
// task only closed on session_end, breaking task_judge enqueue
513+
// and Timeline task_end emission.
514+
//
515+
// `session_end` is intentionally excluded: closeRun fires that
516+
// path itself, and a duplicate here would double-emit onTaskClosed.
517+
if (signal === 'todo_complete' || signal === 'manual' || signal === 'idle_gap') {
518+
this.closeTask(sessionId, signal);
519+
return;
520+
}
521+
debug('APME', `task_boundary span dropped: unknown signal=${signal ?? '<none>'}`);
513522
return;
514523
}
515524
case 'session_meta': {

0 commit comments

Comments
 (0)