Make the reactor.done and inference.done distinction impossible to get wrong - #373
Conversation
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.
Names the failure mode in the events table, cites the three defects it caused, and points at the onTurnBoundary / onReactorShutdown guards. States plainly that this is a naming convention rather than an enforced constraint: no lint tooling is configured in this repo to add a restricted-syntax rule, and a type-level fix would require modifying the vendored @intx/types / @intx/inference packages, which is off-limits.
9f2fe24 to
920041c
Compare
Review summary — two rounds, held open for human reviewReady for your call. Not merged. What this preventsThree shipped defects came from code needing a turn boundary keying off The fifth site — the concrete answer to "did we get them all"Review found one the first pass missed: Worth remembering as a pattern: a file containing one correct use of an event is more likely to contain a mistaken one, not less. "Already reviewed" at file granularity is how this hid. Now converted. Independently verified that every remaining The docs now state the limit honestlyThe first draft claimed the guards could not be handed the wrong event. That described the guard's internal correctness, not enforcement — nothing stops a future author writing the string directly. Documentation that overstates what was built is worse than documentation that states a limit. Verified
Typecheck and build clean. 4040 pass / 0 fail. Commit hygiene clean — subjects and bodies within 72, no ticket references, no cross-PR references, nothing under |
The ARCHITECTURE.md block explaining inference.done vs reactor.done
carried a full rejected-alternatives essay on ESLint and Biome, which
reads as noise once someone actually configures a linter. Cut it to
the table row, one paragraph naming the distinction, and the note
that this is a convention rather than an enforced constraint.
The guard tests previously passed bare { type: string } literals,
which only proves the string comparison works. Drive real
ReactorInboundEvent and ReactorEmittedEvent members through
onTurnBoundary and onReactorShutdown instead, so a change that breaks
narrowing on either union is caught. Add an integration test that
exercises onTurnBoundary against a real reactor run's emitted events;
onReactorShutdown's shutdown path is not integration-testable because
agent.close() clears stream() consumers before the queued abort event
produces reactor.done, so app code can't observe it there either -
the unit test covers that guard's correctness instead.
Summary
Three shipped defects (queued messages never dispatching,
run.json'sturnsUsedfreezing for a whole session, and the shell not returningto idle between turns) all traced back to code that needed a turn
boundary but keyed off
reactor.done— which fires once, at reactorshutdown, not between turns. All three are already fixed; this is
prevention, not a live-bug fix.
src/agent/reactor-events.ts:onTurnBoundary/onReactorShutdown, two generic type-guard predicates that narrowon
inference.done/reactor.donerespectively. Generic over theevent's own type so they work against both
ReactorInboundEvent(
@intx/types/runtime) andReactorEmittedEvent(@intx/inference)call sites.
via a bare
event.type === "inference.done"comparison — inagent/director.ts,agent/compaction.ts,subagent/nudge-director.ts,subagent/stop-policy.ts,perf/reactor-spans.ts,session/stream-journal.ts,session/hooks.ts,session/run-sink.ts,tui-opentui/runner-host.ts, andtui-opentui/runtime-bridge.ts— to go throughonTurnBoundary.session/run-sink.ts's sticky-error clear sat three lines below alegitimate
reactor.doneshutdown check and rode along as "alreadyreviewed" on the strength of its neighbor. Converted.
reactor.donecheck insession/run-sink.ts(clean-shutdownstatus),
agent/renderer.ts(one-shot exec renderer),tui-opentui/stream-event-map.ts, andtui-opentui/turn-state.ts(idempotent idle re-assertions).
docs/ARCHITECTURE.md's reactor events table to name thedistinction, cite the three defects, and point at the new guards —
and states plainly that this is a naming convention, not an
enforced constraint: no lint tooling (ESLint/Biome) is configured
in this repo to add a restricted-syntax rule, and a type-level fix
would require modifying the vendored
@intx/types/@intx/inferencepackages, which is off-limits.src/agent/reactor-events.test.ts, including a test assertingthe turn-boundary path fires more than once across a multi-turn
session — the property all three defects violated.
The
run.jsonsnapshot trigger insrc/tui/runner.tsis deliberatelyleft alone: it's mid-rework on another open branch to move off
reactor.done, so touching it here would collide with that change.Closes CL-5598
Verification
bun run typecheck— cleanbun run build— cleanbun run test— 4040 pass, 0 failbun test ./src ./tests ./evals --randomize --seed 42— 4040 pass, 0 failcreateRunSink(the actualproduction module) with a mixed event stream (2x
inference.start,3x
inference.done, 1xtool.done, 1xreactor.done): confirmedonTurnBoundaryfired exactly 3 times (once per turn) andonReactorShutdownfired exactly once, at the end.