Skip to content

fix(conversations): a message during its channel's fork spawn waits for it - #430

Merged
nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:fix/conversation-spawn-once
Oct 18, 2026
Merged

nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:fix/conversation-spawn-once

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

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: 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. 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:

  • It decided on the same spawn. This happens with a plain message and a coalesced one, or with a message and a deferred batch whose audience lookup spawns the fork. The second spawn of the fork's name failed with "Conversation agent name … is already registered or reserved". It was traced as mcpl:conversation-spawn-failed on an incoming message's path, and logged on a batch's.
  • It wouldn't bind the channel on its own. An example is an unmentioned message where only a mention binds. It decided unbound and 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

  • routeConversation makes a routing's decision once no spawn is in flight for its channel, and starts the spawn it decides on.
    • Checking for a spawn in flight, deciding and starting the spawn happen in one synchronous step, 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. Once the fork is bound, the decision is the fork. If the spawn failed, it decides as a message arriving next would, which proposes the spawn again if the message qualifies.
  • spawnConversationAgent creates 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.
  • Both spawn sites go through them: an incoming channel message's routing (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:

    • one for each way the two paths can meet: a coalesced message during a plain message's spawn, a plain message during a batch's spawn, and a batch during a plain message's spawn;
    • the unmentioned message;
    • a failed spawn on each path.

    The two stub tests in mcpl-rejection-log-lines gain 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

  • A message during a spawn: a message on a channel whose fork is being spawned waits for the spawn, then goes into the fork. That's whether it would have decided on the spawn or not, on either path. Before, it was lost. There's no mcpl:conversation-spawn-failed or mcpl:conversation-unrouted for it, and no second mcpl:conversation-spawned.
  • A spawn that fails: its own message is lost, as before. The first routing that was waiting on it then decides again, and proposes the spawn itself if its message qualifies. By reading, a routing that arrives after that waits on the new spawn.
  • The wait holds its path. By reading, while a routing waits, the path it runs on waits with it. On the event queue, that's every channel's messages. A coalesced delivery, or a batch's wake under the coalescer's lock, holds the coalescer for every subject. The wait lasts as long as the spawn's seed waits, and the template's withheld results have 30 seconds per batch to be saved (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:
    • the template's annotations, which save withheld results through the workspace module, raced against that deadline;
    • the fork's open and dry compile, and the template's dry compile;
    • binding and persisting the counters.

Evidence

On Linux with Node 24.22.0, context-manager 0.14.1, membrane 0.5.87 and chronicle 0.4.0:

  • The new tests: test/conversation-spawn-in-flight.test.ts holds the template's aborted batch mid-annotation, so the first routing's spawn waits in its seed:
    • A coalesced message while a plain message's spawn waits: one spawn, no spawn failure, and the fork holds both.
    • An unmentioned message while a mention's spawn waits, where only a mention binds: nothing is dropped as unrouted, and the fork holds both.
    • A plain message while a deferred batch's audience lookup spawns the fork: no spawn failure, and 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 at its turn.
    • A coalesced message waiting on a spawn that fails: the first message's failure is traced, and the waiting routing spawns the fork, which holds its message.
    • A plain message waiting on a batch's spawn that fails: it spawns the fork itself, and the fork holds its message.
  • Red-first: with the new tests on main's src at 14e25dc0, all six fail:
    • Four tests (the coalesced message, the plain message during a batch's spawn, and both failed-spawn tests): the waiting routing's own spawn fails with "Conversation agent name "conversation-chat-g1" is already registered or reserved".
    • The unmentioned message: it's dropped as unrouted.
    • The batch: its audience lookup's spawn fails the same way, which the coalescer logs, and the fork doesn't render the batch.
  • Removal: with the wait taken out of routeConversation at this head, the same six tests fail the same ways.
  • The touched files pass at this head: conversation-routing, conversation-router, mcpl-coalescing, mcpl-rejection-log-lines, tool-result-guard and tool-wrapper-prose-guard-integration.
  • The whole suite, each test file in its own process, at this head merged with main at 24826254:
    • Node 24 with --test-force-exit, as CI's npm test runs: 2,392 of 2,392 pass in 153 files.
    • Node 24 without the flag: 2,417 of 2,417.
    • Node 20.20.2 with the flag: 2,417 of 2,417.
    • Node 24's run with the flag counts 25 fewer tests, all in three history-module files (18 of 28, 24 of 28 and 38 of 49), with no failure.
  • Probes on main, not part of this PR:
    • Two plain messages for one new channel, or two coalesced ones, are handled one after another, and the fork holds both.
    • With no template annotation to wait on, a second routing started one 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.
  • The stub tests: 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

  • Two channels with one fork name: by reading, two channel ids that differ only in what forkAgentName sanitizes take one fork name at the same generation. The second channel's spawn then fails, and its message is lost, as on main. That's a separate change.

Open pull requests this meets

Read by Gordon-2414 (Claude Opus 5.5), who read the change whole at e999f4ff and approved it. He checked the runs at the head, red-first and with the wait removed, and his trial merge onto main at 24826254 was 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.

…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>
@greptile-apps

greptile-apps Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

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

Summary

Prevents channel messages from being lost while their conversation fork is starting.

  • Messages arriving during a fork's creation wait and then reach it.

Reviews (1) · Last reviewed commit: "fix(conversations): a message during its..." · Reviewed by Greptile

@nissa-seru
nissa-seru merged commit d6dbdfe into anima-research:main Oct 18, 2026
7 checks passed
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>
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