Skip to content

Make the reactor.done and inference.done distinction impossible to get wrong - #373

Merged
TheGreatAxios merged 3 commits into
mainfrom
cl-5598-make-the-reactordone-and-inferencedone-distinction
Aug 7, 2026
Merged

Make the reactor.done and inference.done distinction impossible to get wrong#373
TheGreatAxios merged 3 commits into
mainfrom
cl-5598-make-the-reactordone-and-inferencedone-distinction

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Three shipped defects (queued messages never dispatching, run.json's
turnsUsed freezing for a whole session, and the shell not returning
to idle between turns) all traced back to code that needed a turn
boundary but keyed off reactor.done — which fires once, at reactor
shutdown, not between turns. All three are already fixed; this is
prevention, not a live-bug fix.

  • Adds src/agent/reactor-events.ts: onTurnBoundary /
    onReactorShutdown, two generic type-guard predicates that narrow
    on inference.done / reactor.done respectively. Generic over the
    event's own type so they work against both ReactorInboundEvent
    (@intx/types/runtime) and ReactorEmittedEvent (@intx/inference)
    call sites.
  • Converts ten call sites that were genuinely asking "did a turn end"
    via a bare event.type === "inference.done" comparison — in
    agent/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, and
    tui-opentui/runtime-bridge.ts — to go through onTurnBoundary.
  • One of those ten is a fifth misuse site the original audit missed:
    session/run-sink.ts's sticky-error clear sat three lines below a
    legitimate reactor.done shutdown check and rode along as "already
    reviewed" on the strength of its neighbor. Converted.
  • Leaves four sites untouched as genuine shutdown semantics: the
    reactor.done check in session/run-sink.ts (clean-shutdown
    status), agent/renderer.ts (one-shot exec renderer),
    tui-opentui/stream-event-map.ts, and tui-opentui/turn-state.ts
    (idempotent idle re-assertions).
  • Updates docs/ARCHITECTURE.md's reactor events table to name the
    distinction, 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/inference packages, which is off-limits.
  • Adds src/agent/reactor-events.test.ts, including a test asserting
    the turn-boundary path fires more than once across a multi-turn
    session — the property all three defects violated.

The run.json snapshot trigger in src/tui/runner.ts is deliberately
left 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 — clean
  • bun run build — clean
  • bun run test — 4040 pass, 0 fail
  • bun test ./src ./tests ./evals --randomize --seed 42 — 4040 pass, 0 fail
  • Drove a real multi-turn session through createRunSink (the actual
    production module) with a mixed event stream (2x inference.start,
    3x inference.done, 1x tool.done, 1x reactor.done): confirmed
    onTurnBoundary fired exactly 3 times (once per turn) and
    onReactorShutdown fired exactly once, at the end.

@linear-code

linear-code Bot commented Aug 7, 2026

Copy link
Copy Markdown

CL-5598

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.
@TheGreatAxios
TheGreatAxios force-pushed the cl-5598-make-the-reactordone-and-inferencedone-distinction branch from 9f2fe24 to 920041c Compare August 7, 2026 15:20
@TheGreatAxios

Copy link
Copy Markdown
Collaborator Author

Review summary — two rounds, held open for human review

Ready for your call. Not merged.

What this prevents

Three shipped defects came from code needing a turn boundary keying off reactor.done, which fires once at reactor shutdown: queued messages never dispatching, run.json's turn count freezing for whole sessions, and the shell not returning to idle between turns. All three are already fixed; this stops a fourth.

The fifth site — the concrete answer to "did we get them all"

Review found one the first pass missed: src/session/run-sink.ts:115, a sticky-error clear that runs on every completed turn. It sits three lines below the legitimate reactor.done check at :107, so the file was marked already-handled and the second, distinct check rode along unexamined.

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 "inference.done" occurrence in src/ is a different construct — a type Extract, the two deliberate bare-runInference exclusions (goal-evaluator.ts, summarizer.ts, where reactor.done is never a sibling event), and exhaustive switch cases where a boolean guard does not apply.

The docs now state the limit honestly

The 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. docs/ARCHITECTURE.md now says plainly that this is a convention rather than an enforced constraint, and records why both enforcement routes are closed: no lint tooling is configured in the repo, and a type-level fix would require modifying vendored @intx/types / @intx/inference.

Documentation that overstates what was built is worse than documentation that states a limit.

Verified

  • All ten converted sites are literal 1:1 swaps with no logic reshaping — no gratuitous churn
  • The four legitimate shutdown sites are untouched and confirmed correct
  • src/tui/runner.ts deliberately left alone to avoid colliding with concurrent work on the same line
  • The generic signature does not weaken narrowing: the runtime check still runs, so an unrelated object returns true only if its type really is "inference.done"
  • Multi-turn property driven through the real integration harness: turn-boundary fired once per turn across a tool-call turn plus a text-reply turn; shutdown fired zero times during the live exchange, confirming the two are not siblings within a turn

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 vendor/.

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.
@TheGreatAxios
TheGreatAxios merged commit 6568833 into main Aug 7, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant