Skip to content

Commit f8275f8

Browse files
committed
fix(daemon): anchor ACP tool-first turns at the tool's start
Three findings from the fifth review round. ACP accumulates `tool_call` frames and emits the canonical `tool_use` only once the call reaches terminal status, carrying `startedAt` set to the first frame's arrival. The universal `send()` path stamped the anchor with `Date.now()` when that delayed event arrived, so a tool-only ACP turn put its anchor at tool COMPLETION -- measuring the whole tool loop as runtime init. That is precisely the case this PR exists to fix, still fully present for the ACP family. The stream-event markers now carry the producer's `startedAt` (daemon clock, same domain as our own marks) and the tracer stamps that instead of arrival time. Tool ids are reused across a retry: sequential ids like `call_0` restart in the retry's fresh session, and `openTools` is keyed by id alone. A killed attempt's still-open `call_0` therefore merged with the retry's genuinely new `call_0`, pairing attempt one's opening with attempt two's close and reporting a 19s tool that never ran -- inflating `tool_duration_ms` as well as phase occupancy. A still-open entry observed before the current attempt began is now replaced rather than merged. The Langfuse `model-active` diagnostic used different boundaries from the metric it exists to be compared against: it preferred `firstModelEventAt` over the earliest mark, and ended at `finalizeStartAt` rather than run end. On a text-first stream those differ, so the same run reported two windows. It now mirrors the canonical rules; the shared "earliest finite mark" logic is a named helper.
1 parent a98522a commit f8275f8

7 files changed

Lines changed: 176 additions & 7 deletions

apps/daemon/src/langfuse-trace.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -998,6 +998,19 @@ function buildArtifactWriteDiagnostics(
998998
};
999999
}
10001000

1001+
// Earliest of the supplied marks, ignoring absent and non-finite values. Mirrors
1002+
// how `summarizeRunTimingAnalytics` picks its phase anchor: the model cannot
1003+
// have started responding after it emitted its first token, so a later mark is
1004+
// always a producer bug rather than a later start.
1005+
function earliestFiniteTimestamp(
1006+
...values: Array<number | undefined>
1007+
): number | undefined {
1008+
const finite = values.filter(
1009+
(value): value is number => typeof value === 'number' && Number.isFinite(value),
1010+
);
1011+
return finite.length > 0 ? Math.min(...finite) : undefined;
1012+
}
1013+
10011014
function buildSemanticPhaseDiagnostics(ctx: ReportContext): Record<string, unknown> {
10021015
const marks = ctx.run.timingMarks ?? {};
10031016
const measured: Record<string, unknown> = {};
@@ -1026,7 +1039,16 @@ function buildSemanticPhaseDiagnostics(ctx: ReportContext): Record<string, unkno
10261039
// on the first model event of any kind. Kept as a diagnostics entry rather
10271040
// than a second span: this map rides along in existing metadata, while a new
10281041
// span would add an observation row per run.
1029-
addMeasured('model-active', marks.firstModelEventAt ?? marks.firstTokenAt, marks.finalizeStartAt ?? ctx.run.endedAt);
1042+
//
1043+
// Boundaries mirror `model_active_duration_ms` in run-analytics-observability
1044+
// exactly -- earliest of the two marks, through run end -- because the whole
1045+
// point of this entry is to be compared against that number. Deliberately
1046+
// unlike its neighbours above, which end at `finalizeStartAt`.
1047+
addMeasured(
1048+
'model-active',
1049+
earliestFiniteTimestamp(marks.firstModelEventAt, marks.firstTokenAt),
1050+
ctx.run.endedAt,
1051+
);
10301052
addMeasured('artifact-write', marks.firstArtifactWriteAt, marks.finalizeStartAt ?? ctx.run.endedAt);
10311053
addMeasured('finalize', marks.finalizeStartAt, ctx.run.endedAt);
10321054
return {

apps/daemon/src/run-analytics-observability.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,11 @@ export function summarizeRunTimingAnalytics(args: {
970970
}): RunTimingAnalytics {
971971
const telemetry = args.telemetry ?? {};
972972
const runEndAt = args.runUpdatedAt;
973+
const scanStartAt = telemetry.startChatRunStartedAt ?? telemetry.startRequestedAt;
974+
// When the current attempt began. Needed inside the scan so a tool id reused
975+
// by a retry is not merged with the corpse of the same id from a dead
976+
// attempt.
977+
const attemptStartAt = telemetry.attemptStartedAt ?? scanStartAt;
973978
let toolCallCount = 0;
974979
let toolDurationMs = 0;
975980
const toolIntervals: Array<{ start: number; end: number }> = [];
@@ -1032,7 +1037,19 @@ export function summarizeRunTimingAnalytics(args: {
10321037
: undefined;
10331038
const toolStartedAt = payloadStartedAt ?? ts;
10341039
// First tool_use timestamp wins for duration pairing.
1035-
if (!openTools.has(data.id)) {
1040+
const priorObservedAt = openToolObservedAt.get(data.id);
1041+
// Sequential tool ids (`call_0`, `call_1`, ...) restart in a retry's
1042+
// fresh session, so a still-open entry from a killed attempt can collide
1043+
// with a genuinely new call. Keeping the old start would pair attempt
1044+
// one's opening with attempt two's close and report a tool that never
1045+
// ran. Replace, rather than merge, when the open entry predates this
1046+
// attempt and the new one does not.
1047+
const reusesDeadAttemptId =
1048+
priorObservedAt !== undefined &&
1049+
attemptStartAt !== undefined &&
1050+
priorObservedAt < attemptStartAt &&
1051+
ts >= attemptStartAt;
1052+
if (!openTools.has(data.id) || reusesDeadAttemptId) {
10361053
openTools.set(data.id, toolStartedAt);
10371054
openToolObservedAt.set(data.id, ts);
10381055
} else if (payloadStartedAt !== undefined) {
@@ -1100,10 +1117,6 @@ export function summarizeRunTimingAnalytics(args: {
11001117
].filter((value): value is number => value !== undefined && Number.isFinite(value));
11011118
const phaseAnchorAt =
11021119
phaseAnchorCandidates.length > 0 ? Math.min(...phaseAnchorCandidates) : undefined;
1103-
// When the current attempt began. Distinct from `phaseAnchorAt`: the anchor
1104-
// is when the model started responding, this is when the attempt started
1105-
// running, and on a tool-first run the tools come between them.
1106-
const attemptStartAt = telemetry.attemptStartedAt ?? startAt;
11071120
// A run can end while a tool is still outstanding (crash, cancel, timeout),
11081121
// leaving a tool_use with no tool_result. That span still occupied the clock,
11091122
// so close it at run end for phase purposes.

apps/daemon/src/run-lifecycle-tracer.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ export interface RunWithLifecycleTelemetry {
4444

4545
export interface RunLifecycleStreamEventMarkers {
4646
firstModelEventType?: TrackingFirstModelEventType;
47+
// When the producer says this event's work actually began, on the daemon's
48+
// clock. Present when a payload carries `startedAt`, which ACP does: it
49+
// accumulates tool_call frames and emits the canonical `tool_use` only once
50+
// the call is terminal, so the arrival time of that event is the tool's END.
51+
// Stamping the anchor from arrival would measure a tool-only ACP turn as
52+
// runtime init, which is the case this whole boundary change exists for.
53+
firstModelEventAt?: number;
4754
firstVisibleOutput: boolean;
4855
firstArtifactWrite: boolean;
4956
}
@@ -68,8 +75,19 @@ export function runLifecycleMarkersForStreamEvent(
6875
type === 'text_delta' || type === 'thinking_delta' || type === 'tool_use'
6976
? type
7077
: undefined;
78+
const startedAt =
79+
data && typeof data === 'object' && 'startedAt' in data
80+
? (data as { startedAt?: unknown }).startedAt
81+
: undefined;
82+
const firstModelEventAt =
83+
typeof startedAt === 'number' && Number.isFinite(startedAt)
84+
? startedAt
85+
: undefined;
7186
return {
7287
...(firstModelEventType ? { firstModelEventType } : {}),
88+
...(firstModelEventType && firstModelEventAt !== undefined
89+
? { firstModelEventAt }
90+
: {}),
7391
firstVisibleOutput:
7492
type === 'text_delta' ||
7593
type === 'thinking_delta' ||

apps/daemon/src/server.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10688,7 +10688,13 @@ export async function startServer({
1068810688
const send = (event, data) => {
1068910689
const lifecycleMarkers = runLifecycleMarkersForStreamEvent(event, data);
1069010690
if (lifecycleMarkers.firstModelEventType) {
10691-
lifecycle.markFirstModelEvent(lifecycleMarkers.firstModelEventType);
10691+
// `firstModelEventAt` is present when the producer told us when the
10692+
// work began. ACP emits `tool_use` at terminal status, so without this
10693+
// the anchor lands at tool completion.
10694+
lifecycle.markFirstModelEvent(
10695+
lifecycleMarkers.firstModelEventType,
10696+
lifecycleMarkers.firstModelEventAt,
10697+
);
1069210698
}
1069310699
if (lifecycleMarkers.firstVisibleOutput) {
1069410700
lifecycle.mark('first_visible_output');

apps/daemon/tests/langfuse-trace.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1358,6 +1358,43 @@ describe('buildTracePayload', () => {
13581358
expect(metadata.total_duration_ms).toBe(100);
13591359
});
13601360

1361+
it('measures model-active on the same boundaries as the analytics metric', () => {
1362+
const batch = buildTracePayload(
1363+
makeCtx({
1364+
run: {
1365+
runId: 'run-model-active',
1366+
status: 'succeeded',
1367+
startedAt: 1_700_000_000_000,
1368+
endedAt: 1_700_000_010_000,
1369+
timingMarks: {
1370+
startChatRunStartedAt: 1_700_000_000_100,
1371+
processSpawnedAt: 1_700_000_000_300,
1372+
stdinWriteEndAt: 1_700_000_000_500,
1373+
// Text-first: the server stamps first-token before the send() path
1374+
// records the model-event mark, so the two are not equal and the
1375+
// earlier one is the true start of the response.
1376+
firstTokenAt: 1_700_000_001_000,
1377+
firstModelEventAt: 1_700_000_001_200,
1378+
finalizeStartAt: 1_700_000_009_000,
1379+
},
1380+
},
1381+
}),
1382+
);
1383+
1384+
const generation = bodyOf(batch, 'generation-create', 'llm');
1385+
const measured =
1386+
generation.metadata.performance_diagnostics.semantic_phases.measured;
1387+
1388+
// `model_active_duration_ms` takes the earliest of the two marks and runs
1389+
// to run end. This entry exists to be compared against that number, so a
1390+
// different window here makes PostHog and Langfuse disagree on the same
1391+
// run.
1392+
expect(measured['model-active']).toMatchObject({
1393+
duration_ms: 9_000,
1394+
status: 'measured',
1395+
});
1396+
});
1397+
13611398
it('adds duration spans for run timing marks', () => {
13621399
const promptTelemetry = buildPromptStackTelemetry({
13631400
composedPrompt:

apps/daemon/tests/run-analytics-observability.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1946,3 +1946,36 @@ describe('summarizeRunTimingAnalytics outstanding tools without a model-event ma
19461946
expect(result.bottleneck_phase).toBe('tool_execution');
19471947
});
19481948
});
1949+
1950+
describe('summarizeRunTimingAnalytics tool id reuse across a retry', () => {
1951+
it('does not pair a dead attempt\'s open tool with a same-id retry tool', () => {
1952+
const result = summarizeRunTimingAnalytics({
1953+
runCreatedAt: 1_000,
1954+
runUpdatedAt: 25_000,
1955+
analyticsCapturedAt: 25_050,
1956+
telemetry: {
1957+
startRequestedAt: 1_100,
1958+
attemptStartedAt: 20_000,
1959+
attemptIndex: 2,
1960+
stdinWriteEndAt: 20_200,
1961+
firstModelEventAt: 20_500,
1962+
firstModelEventType: 'text_delta' as const,
1963+
firstTokenAt: 20_500,
1964+
},
1965+
events: [
1966+
// Attempt 1's child was killed mid-tool, leaving `call_0` open.
1967+
{ id: 1, event: 'agent', timestamp: 3_000, data: { type: 'tool_use', id: 'call_0', name: 'Bash' } },
1968+
// Sequential ids restart at `call_0` in the retry's fresh session, so
1969+
// the id collides with the corpse above.
1970+
{ id: 2, event: 'agent', timestamp: 21_500, data: { type: 'tool_use', id: 'call_0', name: 'Read' } },
1971+
{ id: 3, event: 'agent', timestamp: 22_000, data: { type: 'tool_result', toolUseId: 'call_0' } },
1972+
],
1973+
});
1974+
1975+
// The retry tool ran 21.5s -> 22.0s. Keeping the stale start pairs
1976+
// attempt 1's 3s opening with attempt 2's 22s close and reports a 19s
1977+
// tool that never existed.
1978+
expect(result.tool_duration_ms).toBe(500);
1979+
expect(result.model_active_duration_ms).toBe(4_500);
1980+
});
1981+
});

apps/daemon/tests/run-lifecycle-tracer.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,43 @@ describe('runLifecycleMarkersForStreamEvent artifact events', () => {
7878
).toBe('thinking_delta');
7979
});
8080
});
81+
82+
describe('runLifecycleMarkersForStreamEvent producer-supplied start', () => {
83+
it('carries a tool_use startedAt so the anchor is not stamped at completion', () => {
84+
// ACP accumulates tool_call frames and emits the canonical tool_use only
85+
// once the call reaches a terminal status, with `startedAt` set to the
86+
// first frame's arrival (daemon clock). Stamping the anchor when that
87+
// delayed event arrives puts it at tool COMPLETION, so a tool-only ACP
88+
// turn measures its whole tool loop as runtime init.
89+
const markers = runLifecycleMarkersForStreamEvent('agent', {
90+
type: 'tool_use',
91+
id: 't1',
92+
name: 'Bash',
93+
startedAt: 1_700_000_000_000,
94+
});
95+
96+
expect(markers.firstModelEventType).toBe('tool_use');
97+
expect(markers.firstModelEventAt).toBe(1_700_000_000_000);
98+
});
99+
100+
it('leaves the timestamp to the caller when the payload carries no start', () => {
101+
const markers = runLifecycleMarkersForStreamEvent('agent', {
102+
type: 'text_delta',
103+
text: 'hi',
104+
});
105+
106+
expect(markers.firstModelEventType).toBe('text_delta');
107+
expect(markers.firstModelEventAt).toBeUndefined();
108+
});
109+
110+
it('ignores a start that is not a finite number', () => {
111+
for (const startedAt of [Number.NaN, Number.POSITIVE_INFINITY, '123', null]) {
112+
const markers = runLifecycleMarkersForStreamEvent('agent', {
113+
type: 'tool_use',
114+
id: 't1',
115+
startedAt,
116+
});
117+
expect(markers.firstModelEventAt).toBeUndefined();
118+
}
119+
});
120+
});

0 commit comments

Comments
 (0)