Skip to content

fix(gate): deliver what the gate held for a turn only if nothing took it back - #943

Open
nissa-seru wants to merge 1 commit into
anima-research:mainfrom
nissa-seru:gideon2613/gate-buffer-recheck
Open

nissa-seru wants to merge 1 commit into
anima-research:mainfrom
nissa-seru:gideon2613/gate-buffer-recheck

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Why. The events the gate holds during an agent's turn are meant to go out together once the turn has ended. When another turn starts, a quiesce begins or the gate is disposed between that turn's end and their delivery, they still go out: into an agent's next turn, into a quiesced context, or from a disposed gate.

This makes EventGate deliver the events it held for an agent's turn only if, when the delivery runs, no agent's turn has started and no quiesce is on, and deliver nothing that a dispose() in between emptied. On main, the delivery runs in a microtask after the turn ends, with no second check, so a turn starting, a quiesce or a dispose() in between doesn't stop it: a host's held events can reach an agent whose next turn has started, or a quiesced context.

A turn that starts, or a quiesce, between a turn's end and its delivery

  • base (main): the held events are delivered anyway, into the agent whose next turn has started, or into the quiesced context.
  • head: they stay held, in arrival order and under the backlog's cap, and go out once that turn ends or the quiesce lifts.

A dispose() between a turn's end and its delivery

  • base (main): the delivery runs anyway, writing its [Gate: …] line and requesting inference.
  • head: it delivers nothing, since dispose() empties the backlog.

Events held through two turns that both end before either delivery runs, with a third started

  • base (main): both deliveries go into the third turn, as two [Gate: …] lines.
  • head: they go out once, when the third turn ends, as one [Gate: …] line and one inference request.

@antra-tess, we're letting you know because it changes when what the gate held for an agent's turn reaches the agent.

Its tests drive the gate directly, as a host can. Whether the framework itself starts a turn, quiesces or disposes the gate in that window wasn't traced or run.

  • Changelog fragment added: changelog.d/gate-held-batch-recheck.fixed.md.
Credits

@theaspirational found the bug, in #937, and wrote the tests the three interruption tests are adapted from; the commit credits them with a Co-authored-by. Gideon-2613 made the change, Ansel-2428 read it word for word, and an internal review read it; the commit holds the fix for what that review found. Beth-2709 wrote this description's details and ran the checks in Tests, and Daniel-2612 wrote the summary above.

Details for agents

Head bf1b83a855, on 71193a9fef.

Problem

At 71193a9fef, flushInferenceBufferIfClear checks the quiesce and the agents' turns, takes the held events off the backlog, and queues their delivery in a microtask, which delivers them with no second check (src/gate/event-gate.ts:4251–:4270 at 71193a9fef). So a turn starting (onInferenceStarted), a quiesce (setQuiesced(true)) or dispose() after a turn's end, before that microtask runs, doesn't stop the delivery. The gate writes its [Gate: …] line and requests inference anyway: into an agent whose next turn has started, into a quiesced context, or from a disposed gate. On November 7, main at 0c570d8f2d still takes the backlog before queuing the microtask and delivers with no second check.

0.31.0's published setQuiesced doc says "While set, debounce firings buffer instead of writing gate messages into context" (dist/src/gate/event-gate.d.ts:686–:687 in the package). EventGate is exported from the package root (src/index.ts:75), and these methods are public, so a host that drives its own gate reaches that window.

Changes

  • src/gate/event-gate.ts (flushInferenceBufferIfClear, :4251–:4271):
    • The queued delivery returns if a quiesce is on or an agent's turn has started by the time it runs (:4258), and only then takes the backlog (:4259).
    • Otherwise the events stay in the backlog where they were held. That keeps them in arrival order and under its cap of 100, with the oldest dropped and counted (bufferForInference, :4303–:4319). They're delivered by the flush that the turn's end or the quiesce's lifting queues.
    • dispose() already empties the backlog (:4752), and a delivery that finds nothing delivers nothing (:3802). So when a dispose() comes between a delivery's queuing and its running, the delivery sends nothing.
  • What a host meets, compared with 0.31.0:
    • held events no longer reach an agent whose next turn has started or a quiesced context, and a dispose() between a turn's end and that turn's delivery now stops the delivery;
    • events held during two turns that both end, with a third started, before either turn's delivery runs, go out when the third turn ends, as one [Gate: …] line and one inference request, where 0.31.0 wrote two lines into the third turn;
    • the same, by reading, compared with 0.32.0, published October 31, which still takes the backlog before queuing the delivery (flushInferenceBufferIfClear, dist/src/gate/event-gate.js:3734–3742 in the package).
  • test/event-gate.test.ts, four tests in "inference buffering":
    • one for each of a turn start, a quiesce and a dispose between a turn's end and the delivery (:879–:908);
    • one where two turns end before the delivery runs and a third has started (:910–:939). The backlog holds 100 events in arrival order with one dropped and counted, and when that turn ends they go out once.
  • changelog.d/gate-held-batch-recheck.fixed.md.

Tests

This ran at a clean checkout of this head, bf1b83a855, on Linux x86_64 with Node 24.22.2 and npm 11.19.1, after npm install and npm run build.

  • The installed packages: @animalabs/chronicle 0.5.1, @animalabs/context-manager 0.24.2, @animalabs/mcpl-core 0.4.0, @animalabs/membrane 0.8.0, @babel/parser 7.29.12, @modelcontextprotocol/client and @modelcontextprotocol/server 2.4.0, @types/node 22.20.5, @types/ws 8.18.2, chokidar 4.0.3, discord.js 14.27.0, tsx 4.23.15, typescript 7.0.2 and ws 8.22.0.
  • At this head: test/event-gate.test.ts passes 68 of 68.
  • Red first: with src/gate/event-gate.ts as at 71193a9fef and this head's tests, the four new tests fail and the other 64 pass. Each failure is a delivery the test says shouldn't have happened yet: one gate message where none was expected for the turn, quiesce and dispose cases, and two for the two-turn case.
  • Each half of the new check:
    • without the quiesce check, only the quiesce test fails;
    • without the turn check, the turn test and the two-turn test fail.

Not verified

  • Whether the framework itself starts a turn, quiesces or disposes the gate between a turn's onInferenceEnded and the next microtask: not traced or run. The tests drive the gate directly, as a host can.
  • What becomes of events a host passes to a gate after dispose(): not traced or run, and this change doesn't touch it. dispose() sets disposed (:4747), and the lines that read it are :3918, :4090, :4136, :4364 and :4398: none of them in evaluate() (:3200), in the delivery's microtask (:4257–:4269) or in deliverEvents (:3785).
  • The rest of the suite in the house's own runs, which ran test/event-gate.test.ts alone.

… it back

When an agent's turn ends, the gate delivers what it held for the turn in
a microtask, to avoid re-entrancy, but took it off the backlog when the
delivery was queued. A turn starting, a quiesce or a dispose in between
still let it through: into an agent inferring again, into a context the
host quiesced the gate to keep gate messages out of, or from a disposed
gate. EventGate and those calls are public, so a host reaches that window
whatever the framework does.

The delivery now takes the backlog when it runs, and only if no turn or
quiesce is on by then. Otherwise everything stays held where it is: in
arrival order, under the backlog's cap, and delivered once when the turn
ends or the quiesce lifts. A dispose empties the backlog, so what it held
then never goes out.

theaspirational found this in anima-research#937 and wrote the tests these are adapted
from.

Co-authored-by: Vasyl Dovhanchyn <48733736+theaspirational@users.noreply.github.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

/review

@anima-review

anima-review Bot commented Nov 3, 2026

Copy link
Copy Markdown

Automatic review of bf1b83a85516.

No findings.

@nissa-seru
nissa-seru requested review from Anarchid and slimepriestess and removed request for slimepriestess November 8, 2026 09:27
@anima-review

anima-review Bot commented Nov 11, 2026

Copy link
Copy Markdown

Automatic context view of bf1b83a85516 merged onto 36b6dfe9de39.

What a context view runs and what its checks judge: CHECKS.md.

The records of the 80 runs don't differ between the base and this head.

In the files profiled hosts loaded, 2 changed source lines that map to code ran in no profiled drive: src/gate/event-gate.ts lines 4656–4657.

Usage: the figures of the agents' requests, subagents' included, in the 80 runs are the same in the base and this head: 206,047 tokens sent a request, 72.4% of them read from the cache, 138,567,998 written to it, 4,043,512 output, 24 thinking blocks dropped, USD 0.5446 at list price a priced request and 123 requests with no list price. The figures are the stand-in's model of each provider's published caching and token rules, not a measurement; the stand-in answers the same every time, so a difference between the arms comes from the change. A list price is what each arm's host prices those figures at with its own table (connectome-host's priceCall), where it gives one, so it rests on the same model, with the stand-in's token estimate in place of the provider's own count, cache hits as the script's pacing gives them, and scripted replies.

Upgraded runs beside their fresh twins
On the base requests tokens sent a request read from the cache written to it output thinking dropped last request dropped calibration
H1b/continuation/from-0.9.0 15 159,911 58.3% 857,419 20,328 24 0 1.28, confirmed after a reset
H1b/continuation/fresh 15 155,647 63.2% 715,495 20,328 0 0 1.29, confirmed
H1b/continuation/from-0.20.0 15 155,632 63.2% 715,480 20,328 0 0 1.29, confirmed
H1b/continuation/from-0.23.0 15 155,647 63.2% 715,495 20,328 0 0 1.29, confirmed
On the head requests tokens sent a request read from the cache written to it output thinking dropped last request dropped calibration
H1b/continuation/from-0.9.0 15 159,911 58.3% 857,419 20,328 24 0 1.28, confirmed after a reset
H1b/continuation/fresh 15 155,647 63.2% 715,495 20,328 0 0 1.29, confirmed
H1b/continuation/from-0.20.0 15 155,632 63.2% 715,480 20,328 0 0 1.29, confirmed
H1b/continuation/from-0.23.0 15 155,647 63.2% 715,495 20,328 0 0 1.29, confirmed

A run that writes its own history first, as a fresh twin does, is shown without that history's requests, so its figures are its workload's alone, as the upgraded runs' are.

Held the same in base and head: connectome-host 00d56c540b27, chronicle 0.6.0, context-manager 0.27.0 (the view chose it for both arms, since connectome-host's lockfile holds 0.25.3, which doesn't meet ^0.27.0 from the base), membrane 0.8.1.

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