Skip to content

Commit bd87fdc

Browse files
committed
Add named guards for the turn-boundary vs reactor-shutdown split
inference.done and reactor.done read as near-synonyms at a call site but mean opposite things: inference.done fires once per turn, reactor.done fires once at shutdown. Three shipped defects came from code that needed a turn boundary but keyed off reactor.done instead. Route every such check through onTurnBoundary / onReactorShutdown in src/agent/reactor-events.ts so the mistake can't be reintroduced by a bare string comparison. Ten call sites converted, including a fifth misuse the original audit missed: run-sink.ts's sticky-error clear at line ~115 sat three lines below a legitimate reactor.done shutdown check at line ~107 and rode along as "already reviewed" on the strength of its neighbor. That shutdown check, plus renderer.ts, stream-event- map.ts, and turn-state.ts, remain untouched as genuine shutdown semantics. The run.json snapshot trigger in tui/runner.ts is also left alone: it is mid-rework on another branch to move off reactor.done, so touching it here would collide with that change.
1 parent 1e617b1 commit bd87fdc

12 files changed

Lines changed: 93 additions & 14 deletions

File tree

src/agent/compaction.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
import { compactionThresholdFor, contextTokensFromUsage } from "../provider/context-window.js";
99
import { COMPACTOR_KEEP_RECENT_TURNS, compactorNoOpFloor } from "../session/compactor.js";
1010
import { createContextEstimate, estimateOverheadTokens } from "./context-estimate.js";
11+
import { onTurnBoundary } from "./reactor-events.js";
1112

1213
const COMPACTOR_NAME = "pruning-compactor";
1314
// The exact turn count `createPruningCompactor` (session/compactor.ts) is
@@ -119,7 +120,7 @@ export function createCompactionGovernor(
119120
// compact when it (or the operator's next message) arrives.
120121
function noteIdleTurn(event: ReactorInboundEvent, actions: ReactorAction[]): void {
121122
if (!pending || idlePending || requestContinuation === undefined) return;
122-
if (event.type !== "inference.done") return;
123+
if (!onTurnBoundary(event)) return;
123124
const terminal =
124125
actions.some((a) => a.type === "reply" || a.type === "wait") &&
125126
!actions.some((a) => a.type === "infer" || a.type === "execute_tools");

src/agent/director.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from "../session/compactor.js";
1616
import type { WorkflowCoordinator } from "../workflows/coordinator.js";
1717
import { createCompactionGovernor, type CompactionGovernor } from "./compaction.js";
18+
import { onTurnBoundary } from "./reactor-events.js";
1819
import { type } from "arktype";
1920
import { applyManageTasks, hasActiveTasks, parseManageTasksArgs, type Task } from "./tasks.js";
2021
import { createCorbitsRetryPolicy } from "./retry-policy.js";
@@ -523,7 +524,7 @@ class ChatDirectorImpl extends DefaultDirector {
523524
this.pendingToolOnlyNudge = false;
524525
this.pausedForToolOnly = false;
525526
}
526-
if (event.type === "inference.done") this.inferenceRecoveries = 0;
527+
if (onTurnBoundary(event)) this.inferenceRecoveries = 0;
527528

528529
if (event.type === "message.received" && this.taskClassifier !== undefined) {
529530
const message = event.message;
@@ -564,7 +565,7 @@ class ChatDirectorImpl extends DefaultDirector {
564565
}
565566
}
566567

567-
if (event.type === "inference.done") {
568+
if (onTurnBoundary(event)) {
568569
this.turnCount++;
569570
const hasToolCalls = event.turn.content.some((b) => b.type === "tool_call");
570571
const hasText = event.turn.content.some(
@@ -679,7 +680,7 @@ class ChatDirectorImpl extends DefaultDirector {
679680
// prefers provider usage when present.
680681
const turns = state.turns ?? [];
681682
this.compaction.syncFromTurns(turns);
682-
if (event.type === "inference.done") {
683+
if (onTurnBoundary(event)) {
683684
this.compaction.noteInferenceDone(event, turns);
684685
}
685686

src/agent/reactor-events.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { onReactorShutdown, onTurnBoundary } from "./reactor-events.js";
3+
4+
describe("onTurnBoundary", () => {
5+
test("true only for inference.done", () => {
6+
expect(onTurnBoundary({ type: "inference.done" })).toBe(true);
7+
expect(onTurnBoundary({ type: "reactor.done" })).toBe(false);
8+
expect(onTurnBoundary({ type: "tool.done" })).toBe(false);
9+
});
10+
11+
// The property all three shipped defects violated: code that gated a
12+
// turn boundary on `reactor.done` only ever saw it once, at shutdown.
13+
// A multi-turn session must trip this guard once per turn.
14+
test("fires more than once across a multi-turn session", () => {
15+
const turnEvents = [
16+
{ type: "inference.start" },
17+
{ type: "inference.done" },
18+
{ type: "tool.done" },
19+
{ type: "inference.done" },
20+
{ type: "inference.done" },
21+
];
22+
23+
const boundaries = turnEvents.filter((event) => onTurnBoundary(event));
24+
25+
expect(boundaries.length).toBe(3);
26+
expect(boundaries.length).toBeGreaterThan(1);
27+
});
28+
});
29+
30+
describe("onReactorShutdown", () => {
31+
test("true only for reactor.done, and fires once per session", () => {
32+
const sessionEvents = [
33+
{ type: "inference.done" },
34+
{ type: "inference.done" },
35+
{ type: "inference.done" },
36+
{ type: "reactor.done" },
37+
];
38+
39+
expect(onReactorShutdown({ type: "reactor.done" })).toBe(true);
40+
expect(onReactorShutdown({ type: "inference.done" })).toBe(false);
41+
42+
const shutdowns = sessionEvents.filter((event) => onReactorShutdown(event));
43+
expect(shutdowns.length).toBe(1);
44+
});
45+
});

src/agent/reactor-events.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* `inference.done` and `reactor.done` read as near-synonyms at a call site
3+
* but mean opposite things: `inference.done` fires once per turn (the
4+
* boundary code that reacts "between turns" needs), while `reactor.done`
5+
* fires once, at reactor shutdown. Three shipped defects (queued messages
6+
* never dispatching, `run.json`'s `turnsUsed` freezing for a whole session,
7+
* and the shell not returning to idle between turns) all came from code
8+
* keying off `reactor.done` when it meant `inference.done`. These guards
9+
* make the two impossible to confuse: name the question, not the string.
10+
*
11+
* Generic over the event's own type so this narrows both `ReactorInboundEvent`
12+
* (`@intx/types/runtime`) and `ReactorEmittedEvent` (`@intx/inference`)
13+
* call sites without re-declaring the union here.
14+
*/
15+
16+
/** True when `event` is the turn boundary — fires once per turn, every turn. */
17+
export const onTurnBoundary = <E extends { type: string }>(
18+
event: E,
19+
): event is Extract<E, { type: "inference.done" }> => event.type === "inference.done";
20+
21+
/** True when `event` is reactor shutdown — fires once, at the end of the run. */
22+
export const onReactorShutdown = <E extends { type: string }>(
23+
event: E,
24+
): event is Extract<E, { type: "reactor.done" }> => event.type === "reactor.done";

src/perf/reactor-spans.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
*/
2121

2222
import type { ReactorEmittedEvent } from "@intx/inference";
23+
import { onTurnBoundary } from "../agent/reactor-events.js";
2324
import { end, start } from "./index.js";
2425
import {
2526
getActiveTurnId,
@@ -82,7 +83,7 @@ function emptyState(): ObserverState {
8283
}
8384

8485
function toolCallCount(event: ReactorEmittedEvent): number {
85-
if (event.type !== "inference.done") return 0;
86+
if (!onTurnBoundary(event)) return 0;
8687
const data = event.data as {
8788
turn?: { content?: ReadonlyArray<{ type: string }> };
8889
};
@@ -117,7 +118,7 @@ function modelTags(event: ReactorEmittedEvent): Record<string, unknown> | undefi
117118
}
118119
return undefined;
119120
}
120-
if (event.type === "inference.done") {
121+
if (onTurnBoundary(event)) {
121122
const data = event.data as {
122123
source?: { provider?: unknown; model?: unknown };
123124
usage?: { input?: unknown; output?: unknown };
@@ -222,7 +223,7 @@ export function createPerfReactorObserver(): PerfReactorObserver {
222223
return;
223224
}
224225

225-
if (type === "inference.done") {
226+
if (onTurnBoundary(event)) {
226227
const tags = modelTags(event);
227228
closeInferenceTree(tags);
228229
state.pendingTools = toolCallCount(event);

src/session/hooks.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
ToolCall,
1313
ToolResult,
1414
} from "@intx/types/runtime";
15+
import { onTurnBoundary } from "../agent/reactor-events.js";
1516

1617
import { COMMAND_NAME, SETTINGS_DIR_NAME } from "../branding.js";
1718

@@ -236,7 +237,7 @@ export function createTurnContextCollector(
236237
return;
237238
}
238239

239-
if (event.type === "inference.done") {
240+
if (onTurnBoundary(event)) {
240241
const toolCalls = event.data.turn.content
241242
.filter((block): block is Extract<typeof block, { type: "tool_call" }> => block.type === "tool_call")
242243
.map((block): ToolCall => ({

src/session/run-sink.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { EventEmitter } from "node:events";
22
import type { ReactorEmittedEvent } from "@intx/inference";
33
import type { TokenUsage } from "@intx/types/runtime";
44
import { createPerfReactorObserver } from "../perf/reactor-spans.js";
5+
import { onTurnBoundary } from "../agent/reactor-events.js";
56
import {
67
createTurnContextCollector,
78
type LifecycleHookManager,
@@ -112,7 +113,7 @@ export function createRunSink(args: RunSinkArgs): RunSink {
112113
// A completed inference turn supersedes a prior recoverable inference.error
113114
// (ChatDirector retries timeout/retryable/aborted). Leaving the sticky error
114115
// would mark a recovered successful send as failed.
115-
if (event.type === "inference.done") {
116+
if (onTurnBoundary(event)) {
116117
runError = undefined;
117118
}
118119
if (event.type === "reactor.error") {

src/session/stream-journal.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ReactorEmittedEvent } from "@intx/inference";
55
import { getLogger } from "@intx/log";
66

77
import { LOG_NAMESPACE_ROOT } from "../branding.js";
8+
import { onTurnBoundary } from "../agent/reactor-events.js";
89

910
/**
1011
* Partial-output capture for streaming inference cycles.
@@ -98,7 +99,7 @@ export function createCycleTextRecorder(
9899
if (typeof token === "string") cycleText = appendCycleText(cycleText, token);
99100
return;
100101
}
101-
if (event.type === "inference.done") {
102+
if (onTurnBoundary(event)) {
102103
cycleText = "";
103104
return;
104105
}

src/subagent/nudge-director.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
InferenceOptions,
1515
} from "@intx/types/runtime";
1616
import { createCompactionGovernor, type CompactionGovernor } from "../agent/compaction.js";
17+
import { onTurnBoundary } from "../agent/reactor-events.js";
1718
import {
1819
EMPTY_THRASH_STATE,
1920
nextThrashState,
@@ -137,7 +138,7 @@ export class SubAgentDirector extends DefaultDirector {
137138
// rewrites included). Arming still happens inside noteInferenceDone, which
138139
// prefers provider usage when present.
139140
this.compaction.syncFromTurns(state.turns);
140-
if (event.type === "inference.done") {
141+
if (onTurnBoundary(event)) {
141142
this.lastActivityAt = this.now();
142143
this.consecutiveStalls = 0;
143144
this.compaction.noteInferenceDone(event, state.turns);

src/subagent/stop-policy.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55

66
import type { ReactorEmittedEvent } from "@intx/inference";
7+
import { onTurnBoundary } from "../agent/reactor-events.js";
78
import {
89
evaluateThrashStop,
910
type ThrashConfig,
@@ -225,7 +226,7 @@ export function lastText(content: ReadonlyArray<{ type: string }>): string {
225226

226227
/** Best-effort partial assistant text from a stream event (inference.done). */
227228
export function partialTextFromEvent(event: ReactorEmittedEvent): string | null {
228-
if (event.type !== "inference.done") return null;
229+
if (!onTurnBoundary(event)) return null;
229230
// Stream events nest the turn under data (same shape as hooks/renderer).
230231
// Guard data.turn so a malformed event cannot throw in the stream sink.
231232
const turn = event.data?.turn;

0 commit comments

Comments
 (0)