Skip to content

Commit c3442c6

Browse files
committed
Abandon prior turn on every inference.start
Interrupt mid-inference or mid-tool must not nest the next call under a stale turn or leave orphan tool spans. Route all turn-close paths through one closeTurn helper (closeOpenTools + end turn) and cover abandon restart paths in tests.
1 parent 40f8bc3 commit c3442c6

2 files changed

Lines changed: 92 additions & 20 deletions

File tree

src/perf/reactor-spans.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,80 @@ describe("createPerfReactorObserver", () => {
175175
obs.observe(inferenceDone());
176176
expect(byName(completed(snapshot()), "turn")).toHaveLength(2);
177177
});
178+
179+
test("abandon mid-inference then new start closes prior turn with no orphans", () => {
180+
const obs = createPerfReactorObserver();
181+
182+
obs.observe(event("inference.start", { model: "test-model" }));
183+
obs.observe(event("inference.text.delta", { token: "partial", partial: { text: "partial" } }));
184+
// Interrupt: no inference.done / error — next start must abandon.
185+
obs.observe(event("inference.start", { model: "test-model" }));
186+
obs.observe(inferenceDone());
187+
188+
const spans = snapshot();
189+
expect(spans.every((s) => s.endNs !== undefined)).toBe(true);
190+
191+
const turns = byName(completed(spans), "turn");
192+
const inferences = byName(completed(spans), "inference");
193+
expect(turns).toHaveLength(2);
194+
expect(inferences).toHaveLength(2);
195+
expect(inferences[0]!.parentId).toBe(turns[0]!.id);
196+
expect(inferences[1]!.parentId).toBe(turns[1]!.id);
197+
// First turn abandoned before second opened — not nested.
198+
expect(turns[0]!.endNs! <= turns[1]!.startNs).toBe(true);
199+
});
200+
201+
test("abandon mid-tool then new start closes open tools and prior turn", () => {
202+
const obs = createPerfReactorObserver();
203+
204+
obs.observe(event("inference.start", { model: "test-model" }));
205+
obs.observe(
206+
inferenceDone([
207+
{ type: "tool_call", id: "call-1", name: "run_shell", arguments: {} },
208+
]),
209+
);
210+
obs.observe(event("tool.start", { call: { id: "call-1", name: "run_shell", arguments: {} } }));
211+
// Interrupt mid-tool: no tool.done — next inference.start must not nest.
212+
obs.observe(event("inference.start", { model: "next-model" }));
213+
obs.observe(inferenceDone());
214+
215+
const spans = snapshot();
216+
expect(spans.every((s) => s.endNs !== undefined)).toBe(true);
217+
218+
const turns = byName(completed(spans), "turn");
219+
const tools = byName(completed(spans), "tool");
220+
const inferences = byName(completed(spans), "inference");
221+
222+
expect(turns).toHaveLength(2);
223+
expect(tools).toHaveLength(1);
224+
expect(tools[0]!.parentId).toBe(turns[0]!.id);
225+
expect(inferences).toHaveLength(2);
226+
expect(inferences[0]!.parentId).toBe(turns[0]!.id);
227+
expect(inferences[1]!.parentId).toBe(turns[1]!.id);
228+
// Second inference must not nest under the abandoned turn.
229+
expect(inferences[1]!.parentId).not.toBe(turns[0]!.id);
230+
expect(turns[0]!.endNs! <= turns[1]!.startNs).toBe(true);
231+
});
232+
233+
test("inference.error mid-turn then new start leaves no open spans", () => {
234+
const obs = createPerfReactorObserver();
235+
236+
obs.observe(event("inference.start", { model: "test-model" }));
237+
obs.observe(event("inference.text.delta", { token: "x", partial: { text: "x" } }));
238+
obs.observe(event("inference.error", { error: { message: "timeout" } }));
239+
240+
// Error with no pending tools closes the turn.
241+
expect(snapshot().every((s) => s.endNs !== undefined)).toBe(true);
242+
243+
obs.observe(event("inference.start", { model: "retry-model" }));
244+
obs.observe(inferenceDone());
245+
246+
const spans = snapshot();
247+
expect(spans.every((s) => s.endNs !== undefined)).toBe(true);
248+
const turns = byName(completed(spans), "turn");
249+
expect(turns).toHaveLength(2);
250+
expect(byName(completed(spans), "inference")[1]!.parentId).toBe(turns[1]!.id);
251+
});
178252
});
179253

180254
describe("turn collector durationMs unchanged with perf observer", () => {

src/perf/reactor-spans.ts

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -136,14 +136,23 @@ export function createPerfReactorObserver(): PerfReactorObserver {
136136
state.openTools.clear();
137137
}
138138

139+
/**
140+
* Single exit for ending a turn: close orphan tool spans, then the turn.
141+
* Inference tree must already be closed (or will be via abandonTurn).
142+
*/
139143
function closeTurn(): void {
140-
closeInferenceTree();
141144
closeOpenTools();
142145
endIfOpen(state.turnId);
143146
state.turnId = null;
144147
state.pendingTools = 0;
145148
}
146149

150+
/** Abandon the whole open tree (inference + tools + turn). */
151+
function abandonTurn(): void {
152+
closeInferenceTree();
153+
closeTurn();
154+
}
155+
147156
function ensureTurn(): string {
148157
if (state.turnId === null) {
149158
state.turnId = start("turn");
@@ -163,17 +172,10 @@ export function createPerfReactorObserver(): PerfReactorObserver {
163172
const type = event.type;
164173

165174
if (type === "inference.start") {
166-
// Close a prior inference that never got done/error (defensive).
167-
if (state.inferenceId !== null) {
168-
closeInferenceTree();
169-
}
170-
// New model call after a fully finished turn: open a fresh top-level turn.
171-
// If tools are still pending, nest under the open turn (should not happen
172-
// under DefaultDirector, which re-infers only after all tool.done).
173-
if (state.turnId !== null && state.pendingTools === 0) {
174-
endIfOpen(state.turnId);
175-
state.turnId = null;
176-
}
175+
// Always abandon any prior turn before opening a new one. Interrupt mid-
176+
// inference or mid-tool must not nest the next call under a stale turn or
177+
// leave orphan tool spans in the process-wide open map.
178+
abandonTurn();
177179
const turnId = ensureTurn();
178180
const tags = modelTags(event);
179181
state.inferenceId = start("inference", {
@@ -195,8 +197,7 @@ export function createPerfReactorObserver(): PerfReactorObserver {
195197
closeInferenceTree(tags);
196198
state.pendingTools = toolCallCount(event);
197199
if (state.pendingTools === 0) {
198-
endIfOpen(state.turnId);
199-
state.turnId = null;
200+
closeTurn();
200201
}
201202
return;
202203
}
@@ -206,8 +207,7 @@ export function createPerfReactorObserver(): PerfReactorObserver {
206207
// Drop the turn if nothing is waiting on tools; otherwise keep it open
207208
// so in-flight tool spans can still close under it.
208209
if (state.pendingTools === 0) {
209-
endIfOpen(state.turnId);
210-
state.turnId = null;
210+
closeTurn();
211211
}
212212
return;
213213
}
@@ -246,16 +246,14 @@ export function createPerfReactorObserver(): PerfReactorObserver {
246246
state.pendingTools -= 1;
247247
}
248248
if (state.pendingTools === 0 && state.inferenceId === null && state.turnId !== null) {
249-
closeOpenTools();
250-
endIfOpen(state.turnId);
251-
state.turnId = null;
249+
closeTurn();
252250
}
253251
return;
254252
}
255253
}
256254

257255
function reset(): void {
258-
closeTurn();
256+
abandonTurn();
259257
state = emptyState();
260258
}
261259

0 commit comments

Comments
 (0)