Repository navigation
fix(conversations): a message during its channel's fork spawn waits for it - #430
Merged
nissa-seru merged 1 commit intoOct 18, 2026
Merged
Conversation
…or it
With conversation routing on, the first qualifying message on a channel
spawns its fork: createConversationAgent reserves the fork's name, opens
and seeds its context, and only then is the channel bound to it. The
event queue handles its messages one at a time, and so does the coalescer
(its deliveries and deferred batches' audience lookups run under one
lock), but the two run beside each other, and a spawn can wait: the
fork's seed compiles the template through afterAnnotations, which waits
while the template's withheld tool results are being saved to the
workspace. Meanwhile a second message for the same new channel was lost
one of two ways:
- it decided on the same spawn (a plain message and a coalesced one, or a
message and a deferred batch whose audience lookup spawns the fork), and
the second spawn of the name failed ("Conversation agent name ... is
already registered or reserved"; traced as mcpl:conversation-spawn-failed
on an incoming message's path, logged on a batch's);
- it wouldn't bind the channel on its own, such as an unmentioned message
where only a mention binds, so it decided `unbound` and was dropped as
unrouted, though a moment later it would have gone into the fork.
Either way it wasn't delivered to any agent: the trunk never holds channel
traffic.
- routeConversation decides a routing once no spawn is in flight for its
channel, and starts the spawn it decides on, in one synchronous step with
the check, so at most one spawn per channel is in flight. A routing that
finds its channel's fork being spawned waits for that spawn to settle,
then decides with the settled state: the fork once it is bound, or, if
the spawn failed, what a message arriving next would get, which proposes
the spawn again if the message qualifies. A failed spawn's own message is
lost as before; the failure isn't shared with the waiters.
- spawnConversationAgent creates the fork, binds the channel and persists
the counters, registered in conversationSpawns under the channel until it
settles. routeConversation registers a spawn only when none is in flight
for the channel, and only that spawn's settlement removes its entry, so
the entry removed is always its own; it is removed before anyone waiting
on it resumes, so a waiter deciding again doesn't find it.
- routeConversationIncoming and coalescedAudience, the two spawn sites, go
through them.
The waiting routing holds the path it runs on for as long as the spawn's
seed waits: on the event queue, every channel's messages; under the
coalescer's lock (a coalesced delivery, or a batch's wake from admit or
freeze), every subject. The recovery timer's and boot's wakes run outside
the lock and hold nothing else. That's bounded by the template's withheld-result deadline
(WITHHELD_SPILL_DEADLINE_MS, 30 s per batch), which the spawner's own path
already waited. By reading, nothing the spawn awaits waits on the event
queue or takes the coalescer's lock: the guard's spill
(spillWithheld -> writeToolResultFile -> the workspace module's
writeBinary, raced against the batch deadline), the fork's open and dry
compile and the template's dry compile (context-manager over the store),
and bind and persist (synchronous).
Two channel ids that forkAgentName sanitizes to one name still take one
fork name at the same generation, and the second's spawn fails as on main;
that's separate.
Tests: test/conversation-spawn-in-flight.test.ts holds the template's
aborted batch mid-annotation (its spill waits on a gate), so the first
routing's spawn waits in its seed:
- a coalesced message while a plain message's spawn waits: one spawn, no
failure, the fork holds both;
- an unmentioned message while a mention's spawn waits, bind 'mention':
nothing unrouted, the fork holds both;
- a plain message while a deferred batch's audience spawn waits: no
failure, the fork holds the plain message;
- a deferred batch whose audience is looked up while a plain message's
spawn waits: one spawn, and the batch wakes that fork, which renders it;
- a coalesced message waiting on a spawn that fails: the first failure is
traced and the waiter spawns the fork, holding its message;
- a plain message waiting on a batch's spawn that fails: it spawns the fork
itself, holding its message.
mcpl-rejection-log-lines' two spawn-failure tests run the spawn paths on a
stub framework, which now needs the framework's own routeConversation,
spawnConversationAgent and conversationSpawns (spawnStep()).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The PR appears safe to merge; no actionable issues were found. SummaryPrevents channel messages from being lost while their conversation fork is starting.
Reviews (1) · Last reviewed commit: "fix(conversations): a message during its..." · Reviewed by Greptile |
This was referenced Oct 17, 2026
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
and anima-research#430 main now holds anima-research#424 (F34), which this branch was stacked on, and anima-research#430 (F62), which shares one spawn step between the two spawn paths. Three files conflicted: - src/framework.ts: both spawn paths take anima-research#430's `await decision.spawned`. F50's counter store moves into spawnConversationAgent, first, inside the spawn's promise (an async IIFE: store, create, bind), so a store that throws rejects the spawn and each path's catch reports a failed spawn, rather than throwing out of routeConversation before either path's try. persistConversationRouterState has no caller left and goes. - src/tune-out/coordinator.ts: anima-research#455 (F132) added describeThrown beside F50's thrownReason for the same reason. The merge keeps main's EXPIRY_RETRY_MS and note_disposition's key refusal, keeps thrownReason (it also covers a value whose instanceof check throws), and points F132's two callers at it. - test/mcpl-rejection-log-lines.test.ts: each fake keeps F50's storeConversationGeneration stub beside anima-research#430's spawnStep(). test/tune-out-timers.test.ts merged cleanly, and its test of a value with no string form now expects thrownReason's text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
…search#429's text main moved through train G: anima-research#424, anima-research#430, anima-research#369, anima-research#427, anima-research#429 and anima-research#431. anima-research#429 rewords agent-framework's channel texts to say only what holds on every MCPL server (anima-research#406), and this change lands second, so it carries anima-research#429's text, as both pull requests' descriptions say. Ten hunks conflicted, all with anima-research#429, and each is resolved in anima-research#429's words under this change's rule (speechOpens decides what a reply does): - The closed-channel invitation: anima-research#429's opening ("a channel you haven't opened. Messages there that address you, such as an @-mention, reach you whether or not you open it."), its option 2 ("Open the channel") and its `#` handling (hashLabel). Option 1 keeps this change's two cases. Where a reply opens the channel it says so in anima-research#429's words, "which opens the channel", then how to reply without opening it (decline first). Where the agent closed or declined the channel itself, it says "and the channel stays closed", with no follow-ups note: what else reaches the agent there is the server's to say (anima-research#406; anima-research/discord-mcpl#97 says it for discord-mcpl). The no-target hint keeps this change's send-tool advice, in the same words. - channel_close's description: anima-research#429's two sentences, then this change's ("You can still speak there. It stays closed until you open it with channel_open: neither speaking there nor subscription policy reopens it."). - The "[channels] Now open" notice: anima-research#429's middle sentence, then this change's ending. - Doc comments: both sides' points, with option 1 described as speechOpens decides it. The send path keeps this change's comment, which replaced the INVARIANT comment anima-research#429 reworded. - trunk-channel-routing.test.ts: both sides' tests. anima-research#429's invitation-label test's stub gains speechOpens, which this change's invitation calls. Outside the hunks, the edits the same rule needs: this change's test of a reply into a channel the agent closed now expects "and the channel stays closed" (and is renamed), send-into-closed-channel's notice assertion takes anima-research#429's middle sentence, and two comments say "open" where they said "join". Train G's other merges touched framework.ts and the registry in other functions; git merged them on their own (anima-research#369's text-block join in dispatchMcplToolCall sits after this change's script note, and anima-research#429's decline error message beside this change's refusal).
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
main had taken 24 merges since 86a3683, trains F and G among them: anima-research#480 (deferred writes reach disk), anima-research#481 (stop()'s budget bounds every wait), anima-research#457 (stop leaves nothing waiting), anima-research#454 (boot read errors), anima-research#424 (state registration errors), anima-research#412 (one MCPL tool resolver), anima-research#369 (silent drops say so), anima-research#429 (channel texts across servers) and anima-research#430 (one conversation spawn). Three conflicts: - src/framework.ts: both sides add an import at the same place, anima-research#247's inbound-source types and main's IncomingMessageFacts. Both are kept. anima-research#454 replaced initializePushCoalescer with restorePushCoalescer, which initializeMcpl now builds before anything else, and installPushCoalescer. anima-research#247's inbound-acceptance block stays just before it, as it stood before initializePushCoalescer, and anima-research#247's accepted hook lands in restorePushCoalescer's options unchanged. - src/mcpl/push-coalescer.ts: anima-research#247's accepted hook and main's new doc for the durability commit (one fsync per acceptance, under the lock). Both are kept. - src/mcpl/push-handler.ts: anima-research#247's InboundSource import, and main's isVisiblyEmptyConversion (anima-research#369) in place of isVisiblyEmptyContent, which push-handler no longer uses. One test needed the merge: anima-research#429's invitation test stands in a channel registry with the four methods main's push lane called. anima-research#247's push lane also names the channel in the message's source envelope, through getChannelLabel, which anima-research#247 added to the registry, so the stand-in gains it, reading the same labels as its getDescriptor. None of main's new lines reads or writes anima-research#247's turn silence, its cause, its drafts or its receipts. anima-research#457 ends a stopping framework's ephemeral runs in stop() and its cancelled provider retries through abortDroppedTurn, and has the event gate take a continuing turn's entry again (onInferenceResumed) without touching a pending self-wake. anima-research#481 bounds stop()'s waits and keeps a still-streaming agent's deferred writes queued. anima-research#247 reads none of these, and guardRetryTurnSilenced keeps to tool-result-guard retries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 19, 2026
main 794bf97 into 0c3537c, merge base ca2f806 (1,334 commits). Ten regions in three files; git merged the rest on its own. src/api/server.ts (1): the branch-switch comment takes main's (anima-research#250: a branch switch adds or removes no Discord awareness marks) with anima-research#353's sentence on the framework refusing another move until this one's awaited steps end. test/discord-awareness-outbox.test.ts (1): main's side whole. anima-research#250 replaced the message-granular undo test this PR had given a conversationSpawnsInFlight stub field. src/framework.ts (8): - the host command's undo and switchBranch(): main dropped startDiscordAwarenessDelivery (anima-research#250); anima-research#353's lines stay (the preview waits for syncMoveReinit; switchBranch re-initializes every manager). - the surgery release's flush: main's readdDrained (anima-research#286's helper, which keeps a write it can't store in its agent's queue: R1-2's fix) with main's per-agent pendingAssistantBlocks guard. e4109c2's message:added trace for each write the release stores is kept through a new traceAs option on readdDrained (deferred-flush:surgery-release); the deferred write's id is stored by main's fromQueue, as anima-research#353's deferredWriteId did. - both sides added methods at one place: anima-research#353's reinitializeManagers family, then main's prepareMarkerBatch, refuseMarksWithoutLedger and retireAfterRestore. - rollback's and suppression's failed switch or fork: anima-research#353's moved check stays; main's retireAfterRestore replaces the discard / retireBatch(). - createConversationAgent: main's usedEphemeralAgentNames.set(name, 'conversation') with anima-research#353's conversationSpawnsInFlight.add(name). - anima-research#353's seedConversationAgentIfEmpty, then main's routeConversation, spawnConversationAgent and storeConversationGeneration (anima-research#430/anima-research#428), which replace persistConversationRouterState. Nothing calls the old name. Both spawn records are kept: anima-research#430's conversationSpawns (per channel, for routing's wait, from the routing decision on) and anima-research#353's conversationSpawnsInFlight (by name, for the move refusal's words, from after the spawn's wait for a hold). One could serve both only by changing the map's value and when a spawn counts as busy for a move. Typecheck clean on the merge (tsc 7.0.2).
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 19, 2026
main 794bf97 into 0c3537c, merge base ca2f806 (1,334 commits). Ten regions in three files; git merged the rest on its own. src/api/server.ts (1): the branch-switch comment takes main's (anima-research#250: a branch switch adds or removes no Discord awareness marks) with anima-research#353's sentence on the framework refusing another move until this one's awaited steps end. test/discord-awareness-outbox.test.ts (1): main's side whole. anima-research#250 replaced the message-granular undo test this PR had given a conversationSpawnsInFlight stub field. src/framework.ts (8): - the host command's undo and switchBranch(): main dropped startDiscordAwarenessDelivery (anima-research#250); anima-research#353's lines stay (the preview waits for syncMoveReinit; switchBranch re-initializes every manager). - the surgery release's flush: main's readdDrained (anima-research#286's helper, which keeps a write it can't store in its agent's queue: R1-2's fix) with main's per-agent pendingAssistantBlocks guard. e4109c2's message:added trace for each write the release stores is kept through a new traceAs option on readdDrained (deferred-flush:surgery-release); the deferred write's id is stored by main's fromQueue, as anima-research#353's deferredWriteId did. - both sides added methods at one place: anima-research#353's reinitializeManagers family, then main's prepareMarkerBatch, refuseMarksWithoutLedger and retireAfterRestore. - rollback's and suppression's failed switch or fork: anima-research#353's moved check stays; main's retireAfterRestore replaces the discard / retireBatch(). - createConversationAgent: main's usedEphemeralAgentNames.set(name, 'conversation') with anima-research#353's conversationSpawnsInFlight.add(name). - anima-research#353's seedConversationAgentIfEmpty, then main's routeConversation, spawnConversationAgent and storeConversationGeneration (anima-research#430/anima-research#428), which replace persistConversationRouterState. Nothing calls the old name. Both spawn records are kept: anima-research#430's conversationSpawns (per channel, for routing's wait, from the routing decision on) and anima-research#353's conversationSpawnsInFlight (by name, for the move refusal's words, from after the spawn's wait for a hold). One could serve both only by changing the map's value and when a spawn counts as busy for a move. Typecheck clean on the merge (tsc 7.0.2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A message that arrives on a channel while its conversation fork is being spawned now goes where it would have gone just after the spawn, instead of being lost.
What was wrong
With conversation routing on, the first qualifying message on a channel spawns that channel's fork:
createConversationAgentreserves the fork's name, opens and seeds its context, and only then is the channel bound to it. The event queue handles its messages one at a time. The coalescer delivers coalesced messages one at a time too, under one lock for all subjects, and a deferred batch's wake looks up the batch's audience, which can spawn the fork. But the queue and the coalescer run beside each other, and a spawn can wait: the fork's seed compiles the template only once the template's withheld tool results have been saved to the workspace (afterAnnotations). Meanwhile, a second message for the same new channel was lost in either of two ways:mcpl:conversation-spawn-failedon an incoming message's path, and logged on a batch's.unboundand was dropped as unrouted, though a moment later it would have gone into the fork.Either way the message wasn't delivered to any agent, since the trunk never holds channel traffic.
Changes
routeConversationmakes a routing's decision once no spawn is in flight for its channel, and starts the spawn it decides on.spawnConversationAgentcreates the fork, binds the channel to it and persists the router's counters. It keeps the spawn registered under the channel until it settles, and removes the entry before anyone waiting on it resumes.routeConversationIncoming) and a coalesced batch's audience lookup (coalescedAudience).What each part is for
The change is for one thing: a message that arrives on a channel while that channel's fork is being spawned isn't lost.
conversationSpawns, keyed by channel id, lets a routing find its channel's spawn in flight. Keying it by channel, not by fork name, makes every message on the channel wait, whether or not it would decide on the spawn. It also means no channel waits on another's spawn when two ids sanitize to one name.Checking for a spawn in flight, deciding and starting the spawn in one synchronous step is what keeps it to one spawn per channel. With an await between them, two routings could each start one.
Waiting and deciding again, rather than sharing the spawn's outcome, gives a waiter what it would have got arriving just after. That's the fork once it's bound. If the spawn failed, it's the waiter's own decision, which proposes the spawn again if its message qualifies. Sharing the failure would lose the waiter's message too.
Removing the entry in the spawn's first reaction means it's gone before anyone waiting resumes and decides again.
The tests:
The two stub tests in
mcpl-rejection-log-linesgain the spawn step they now call.The cost is the stall that "What callers will notice" describes. Cut: an identity check when removing the entry, since only its own spawn's settlement removes it. Not taken: delivering a waiting message once the spawn settles, without holding its path, since the coalescer needs a placement when it delivers.
What callers will notice
mcpl:conversation-spawn-failedormcpl:conversation-unroutedfor it, and no secondmcpl:conversation-spawned.WITHHELD_SPILL_DEADLINE_MS). Before, only the path that started the spawn waited that long, and the other path's colliding message was lost. Nothing the spawn awaits waits on the event queue or takes the coalescer's lock, so a waiting path can't hold up the spawn it waits for:Evidence
On Linux with Node 24.22.0, context-manager 0.14.1, membrane 0.5.87 and chronicle 0.4.0:
test/conversation-spawn-in-flight.test.tsholds the template's aborted batch mid-annotation, so the first routing's spawn waits in its seed:main'ssrcat14e25dc0, all six fail:routeConversationat this head, the same six tests fail the same ways.conversation-routing,conversation-router,mcpl-coalescing,mcpl-rejection-log-lines,tool-result-guardandtool-wrapper-prose-guard-integration.mainat24826254:--test-force-exit, as CI'snpm testruns: 2,392 of 2,392 pass in 153 files.history-modulefiles (18 of 28, 24 of 28 and 38 of 49), with no failure.main, not part of this PR:setImmediate, or a 0 to 20 ms timer, after the first found the fork already bound. Only two routings started in the same macrotask lost the second message.test/mcpl-rejection-log-lines.test.ts's two spawn-failure tests run the spawn paths on a stub framework. Their stubs gain the framework's own spawn step.Not covered
forkAgentNamesanitizes take one fork name at the same generation. The second channel's spawn then fails, and its message is lost, as onmain. That's a separate change.Open pull requests this meets
1d33acd1: its wait is the first statement ofcreateConversationAgent, so it runs inside the spawn this registers. A routing for that channel that arrives meanwhile waits on the spawn, and the path it runs on waits out the hold too. Nothing fix: every agent on the store works again after a rollback, suppression, branch switch, undo or redo #353's hold waits on uses the event queue or the coalescer's lock. Surgeries start outside both: from the API server, from an MCPL server'shost/command(such as an undo), or from a host. They refuse while a fork is being created. While holding the store, they await the maintenance pass, branch operations on the store and the context managers, the managers' re-initialization and, for a branch switch, the config mount. fix: every agent on the store works again after a rollback, suppression, branch switch, undo or redo #353 also adds a set of the forks being created, which a move treats as busy. Whichever of the two merges second can let one structure serve both.spawnConversationAgent, inside the spawn's promise, so that a failed store still reaches each site's handling of a failed spawn.Read by Gordon-2414 (Claude Opus 5.5), who read the change whole at
e999f4ffand approved it. He checked the runs at the head, red-first and with the wait removed, and his trial merge ontomainat24826254was clean. His approval carries to this head, which differs only in one sentence of the changelog fragment.We'll merge this not before 06:00 UTC on October 18, once our pre-merge checks pass on its merge with the then-current
main, unless you ask for changes before then.Written by Hilda-2681.