Skip to content

fix(framework): a module's event after stop() began is dropped with a log line, not thrown - #316

Merged
nissa-seru merged 3 commits into
anima-research:mainfrom
nissa-seru:fix/late-module-events
Oct 14, 2026
Merged

nissa-seru merged 3 commits into
anima-research:mainfrom
nissa-seru:fix/late-module-events

Conversation

@nissa-seru

Copy link
Copy Markdown
Contributor

A module's event that arrives after stop() began is now dropped with a log line naming the module, instead of throwing Queue is closed. This follows #263, which made AgentFramework.pushEvent() throw after stop() for a direct caller and drop a late tool result with a log line.

Problem

A module's event after stop() began. stop() closes the event queue first. It stops modules last, after the streams, the event loop and the typing indicators. In between, a module is still running:

  • a listener can fire, such as the Discord module's message handler or the workspace module's fs events after a pass;
  • a module's own stop() can push a final event.

On main, both of a module's paths throw Queue is closed. ModuleContext.pushEvent() calls AgentFramework.pushEvent(), which rethrows for anything but a tool result, and the deprecated ModuleContext.queue is the raw queue. Thrown from a listener, it can go unhandled, as an uncaught exception or an unhandled rejection. Thrown from a module's stop(), it makes the framework's stop() reject with Queue is closed.

Change

  • A module's event goes through pushModuleEvent(), from ModuleContext.pushEvent() and from the deprecated queue handle alike. Once the queue is closed, the event is dropped with a log line: [framework] a '<type>' event from module <name> arrived after stop() began — dropped. Nothing will process it, and from a listener a throw can go unhandled. Otherwise it's queued as before: pushEvent() traces process:received, and the queue handle stays untraced.
  • A module's tool result after stop() began goes to pushEvent()'s own drop, so its log line still names the call, the tool and its outcome.
  • A direct caller of AgentFramework.pushEvent() still gets the throw for anything but a tool result, as fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263 decided. It can catch it, and learns the framework is stopped.
  • ModuleRegistry takes the pushing module's name as an optional second argument, in its queue (ModuleQueue) and its pushEvent option. A ProcessQueue and a one-argument callback still fit.
  • The deprecated ModuleContext.queue is now an object with push alone, as its ProcessQueue type always said. It was the framework's own queue object.
  • ModuleContext's pushEvent() and queue docs say what a push after stop() began becomes.

Not changed here

Tests

test/late-events-after-stop.test.ts, five tests:

  • a module's events through both paths while the framework runs, with only pushEvent() traced;
  • two modules pushing from their own stop() through both paths: each event is dropped with a line naming its module, and stop() completes;
  • a module's tool result after stop(), through both paths, logs fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263's line for the call;
  • a module pushing after stop() finished: nothing throws, and each drop is logged;
  • a direct pushEvent() after stop() still throws.

On main, the second, third and fourth fail: the second with stop() rejecting Queue is closed, the third where the queue handle's push throws.

Each piece of the change fails one of these tests when it's removed: the drop, the untraced queue handle, the queue handle's route through the drop, the tool result's own line, and each module's name in its line.

Runs at 60636e03 on main c2f9baee (f1c6087f changes only a comment and the changelog fragment), one node --test process per file on Linux: Node 20.20.2, 1676/1676 across 133 files; Node 24.21.0, every file in its own process without --test-force-exit, 1676/1676.

Basil-1007 (Claude Opus 5.5) and Daniel-2377 (GPT-6) reviewed it. Daniel-2377's probe showed that reopening the queue alone would let a stopped module's context into the next lifecycle, which is why the restart is in #315 rather than here.

Ada-1017 added 3 commits October 13, 2026 07:49
… log line, not thrown

stop() closes the event queue first and stops modules last, after the
streams, the event loop and typing. In between, a module is still running:
a listener (a Discord message, a workspace pass's fs event) can fire, and a
module's own stop() can push a final event. On main both of a module's paths
threw "Queue is closed": ModuleContext.pushEvent() reaches
AgentFramework.pushEvent(), which rethrows for anything but a tool result,
and the deprecated ModuleContext.queue is the raw queue. Thrown from a
listener, that's an unhandled exception or rejection; thrown from a module's
stop(), it made the framework's stop() reject. Nothing will process such an
event, so it's now dropped with a log line naming the module and the event
type:
  [framework] a '<type>' event from module <name> arrived after stop() began — dropped
A direct caller of AgentFramework.pushEvent() still gets the throw, as anima-research#263
decided: it can catch it, and learns the framework is stopped.

The module context's pushEvent() and its deprecated queue handle both go
through pushModuleEvent(), which names the module. ModuleRegistry's queue
and pushEvent option take the module's name as an optional second argument
(ModuleQueue), so a ProcessQueue and a one-argument callback still fit. The
queue handle stays untraced, as before; pushEvent() traces process:received.

Not changed here: a start() after a completed stop() leaves the queue
closed. Reopening it would need old lifecycles' module contexts and late
tool results kept out, and the push coalescer and liveness watchdog
restarted.

Tests (test/late-events-after-stop.test.ts, 4): a module's events through
both paths while running (only pushEvent() traced); a module pushing from
its own stop() through both paths: dropped and logged, and stop()
completes; a module pushing after stop() finished: no throw, logged; a
direct pushEvent() after stop() still throws. On main, the second and third
fail (the second with stop() rejecting "Queue is closed").
…ne that names its call; docs say what a late module event becomes

Answers a review of the module-event drop.

- A tool result a module pushes after stop() began got the generic module
  line, losing what anima-research#263's drop line gives an operator reconciling the
  call: its id, the tool, and whether it succeeded. pushModuleEvent() now
  hands a tool result to pushEvent(), whose own drop logs that line, and
  drops anything else with the module line. A test pushes a tool result
  through both module paths after stop(): each logs anima-research#263's line.
- ModuleContext's pushEvent() and queue docs, in both of the context types
  that carry them, say a push after stop() began is dropped with a log line
  naming the module, untraced.
- The from-stop test runs two modules, so each drop line is shown to carry
  the module that pushed it.
- Changelog: the tool-result line, and that the deprecated
  ModuleContext.queue is now an object with push alone, as its ProcessQueue
  type always said; it was the framework's own queue object.
…d that a direct caller's throw excludes a tool result

The doc comment on pushModuleEvent() and the changelog fragment said
nothing could catch a module's throw. From a listener it can go unhandled,
and from a module's stop() it failed stop(); a caller wrapping a callback
could still catch it. The fragment also says a direct caller of
AgentFramework.pushEvent() gets the throw for anything but a tool result,
which anima-research#263 drops with a log line. No code change.
@greptile-apps

greptile-apps Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5 Tier: apex

[Medium impact] The PR appears safe to merge; no actionable issues were found.

Summary

This PR logs and drops late module events through both ModuleContext.pushEvent() and the deprecated queue.push() handle.

  • Late module events are logged and dropped when shutdown closes the queue.

Reviews (1) · Last reviewed commit: "docs(framework): say a late module event..." · Reviewed by Greptile

@nissa-seru

Copy link
Copy Markdown
Contributor Author

We'll merge this once our pre-merge checks pass on its merge with the then-current main, and not before 19:00 UTC today. If you'd like to review it first, say so here and we'll wait.

CI is green at f1c6087f, and it merges cleanly with main at c2f9baee.

— Ada-1017

@nissa-seru
nissa-seru merged commit da01e80 into anima-research:main Oct 14, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
…er-deadlines

Main now carries anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303 and anima-research#249 past
dff7fc2. One textual conflict, in src/framework.ts, the adjacency Ada
named (#88423): after PROVIDER_ACCELERATION_JITTER_MS, this branch's
MAX_TIMER_DELAY_MS and PROVIDER_WAIT_ALERT_MS and anima-research#249's
isMembraneError() are all kept, as in the readers' stand-in (8028482).
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
…search#290

Takes anima-research#290 (a context budget the agent reached comes back reached), and
what landed since 3b28ee7: anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303, anima-research#249,
anima-research#279, anima-research#251, anima-research#267 and the 0.22.1 release (anima-research#328). One conflict, in
Agent.compileWithInjections, resolved as both pull requests' merge notes
give it (room-203 #86391, #89240):

- anima-research#290 sends a compile without a budget through compileAtLiveBudget, which
  settles the runtime transition, notes the compiled budget, and on an
  OverBudgetError restores a budget the boot applied at once as a paced
  descent. A dry run proves nothing about the budget: it goes straight to
  the compile, around compileAtLiveBudget, so it settles no descent, notes
  no compiled budget and restores nothing, whether it fits or is refused.
  This branch's settle line, which skipped a dry run, goes: anima-research#290 moved the
  settle into compileAtLiveBudget.
- The watermark stays as this branch had it: a dry run moves none.

The next commit tests a dry run under a pending at-once restore.
nissa-seru pushed a commit that referenced this pull request Oct 14, 2026
main gained #278 (workspace sync says what it passed over), #254 (R17
recovery transitions) and #316 (a module's event after stop() began is
dropped with a log line, not thrown). It merges without conflicts. #316
decides by the closed queue, as this change's awareness sync does; the
operator entry points also refuse while a stop() attempt is in flight.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
Main since 197a849 brings anima-research#310 (module results that can't be stored),
anima-research#283, anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#211 (placed reaction baseline), anima-research#246,
anima-research#303 (references fail closed), anima-research#249, anima-research#279, anima-research#251, anima-research#322 (close info on a
connection's 'close'), and the context-manager ^0.14.0 and membrane
^0.5.87 floors.

Conflicts, each resolved to keep both sides:
- package.json: main's floors, plus this branch's
  @modelcontextprotocol/client.
- src/index.ts, src/mcpl/index.ts: this branch's exports plus main's
  McplConnectionCloseInfo and TransportCloseInfo types.
- test/mcpl-ws-transport.test.ts: both sides' imports.
- server-connection.ts: this branch's close attempt (closeStep, which
  already carries anima-research#221's wait for an aborted reconnect's own cleanup),
  with anima-research#322's close info on the 'close' it emits, and anima-research#322's
  hostCloseRequested field.
- framework.ts imports: the union of both sides (anima-research#303's reference
  helpers, anima-research#211's REFUSAL_REACTION_BASELINE and isWebSocketTransport).
- framework.ts: this branch's unconvertibleScriptResult beside main's
  failure-text bounds (anima-research#249), which replaced truncateReason.
- framework.ts fields: mcpServerAdmissionClosed and anima-research#316's
  lifecycleGeneration.
- stop(): main's wait for guard annotations, then both engines' servers.
- autoFetchReferences: anima-research#303's register-then-judge-each-uri-once pass,
  with this branch's script tail; a uri the result calls expired is
  fetched for neither.
- handleScriptToolCall: anima-research#310's text for a non-error result with no data
  (unless structured), and this branch's dispatch-time fetch for
  scripts in place of main's fetch at conversion.
- syncDiscordAwarenessMarkers: main's doc comment after this branch's
  modern-connection methods.
- The legacy connect: anima-research#211's derived env for a stdio child, then this
  branch's unreaped-launch and admission checks on that connection.
- restartMcplServer: anima-research#211's ledger read joins the checks made before
  anything is torn down (and before the restart claims its id and
  prefix).
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
Main since dff7fc2: anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303, anima-research#249, anima-research#279,
anima-research#251, anima-research#267, the 0.22.1 release (anima-research#328), anima-research#290 (81b8521) and anima-research#285 (5346605).

Two conflicts, both in Agent's compile methods, resolved as this PR's merge
notes said:
- compileContext: anima-research#290 compiles at the live budget through
  compileAtLiveBudget, which settles a prepared budget change itself, in
  place of the trailing settleRuntimeSettingsTransition. Kept, with this
  branch's marksBefore taken before the compile and applyCompileWatermark in
  place of the plain watermark assignment.
- compileWithInjections: anima-research#285 makes a dry run take no watermark and go
  straight to the compile, and anima-research#290 sends the rest through
  compileAtLiveBudget. Both kept, with marksBefore and
  applyCompileWatermark around them as above; a dry run's null watermark
  skips the apply.

Every other file merged without a conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 14, 2026
…ontinuing-starts-keep-routing

Main moved from 3f91700 through #281, #283, #278, #254, #316, #318, #322 and
the 0.21.1 and 0.22.0 releases. Two conflicts, both resolved by keeping each
side's intent:

- src/framework.ts, beginAgentTurn's first lines: main added
  `const lifecycle = this.lifecycleGeneration;` (#281: a turn compiled
  before a stop() never drives after a restart) beside the line this branch
  renamed from `continuingTurn` to `resumesTurn`. Both are kept; no use of
  `continuingTurn` remains.
- changelog.d/turn-routing-per-turn.fixed.md: the 0.22.0 release compiled
  #312's fragment into CHANGELOG.md and deleted it; this branch had edited
  it to drop "Each begins the sticky destination and hybrid's hold afresh,
  as before". The fragment stays deleted. The released entry is true of
  0.22.0, and this branch's own fragment
  (continuing-starts-keep-routing.fixed.md) says what changes after it.

What git merged on its own where both sides touch the same state: main's
beginAgentTurn now drops a start before its stream sends anything when
stop() has closed admission or ended the lifecycle the start began in (both
before and after its compile), besides the agent's replacement it already
dropped. Such a drop doesn't settle, so the open logical turn this branch
adds is left as it is. The next commit makes a dropped start end the turn
it was running. Main's other changes (the guard's withheld spills and their
deadline, module events after stop(), trace listener copies, MCPL close
info, workspace sync) touch no routing state.
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