Skip to content

fix(mcpl): tell two servers' channels with one id apart - #710

Merged
nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:humphrey/f163-channel-ref
Oct 23, 2026
Merged

nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:humphrey/f163-channel-ref

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

MCPL requires a channel id to be unique only within one server's connection, so two MCPL servers can each register a channel general. Until now the framework named a channel by its id alone wherever it acted on one. Where two servers registered the same id, it took whichever server registered it first, as typing, channel_publish, tune_out and a conversation fork's publish did, or it refused the id, as a reply to a message on the second server's channel was refused. Now each place that acts on a channel names it with its server. A name given as a string that two servers' channels answer to is refused with both as candidates, never taken to be the first, except within a turn, where the id of the channel the turn's message came from means that channel, on that server. No tool's description or schema changes. Fixes the report in #474, apart from the typing it names, below. !Closes #612.

Problem

SPEC §14.2's descriptor gives a channel's id as "unique within this connection". The registry keyed its channel map by ${serverId}:${channelId}, and every other holder of a channel kept the bare id: the turn's trigger channel, locus, sticky target and typing channel, a fork's home, the conversation router's bindings, a gate wake's route, the tune-out tools' lookups, the coalescer's authority check and the channel context context/beforeInference gives each server. Each of those found the channel again by its id, and with two servers registering one id the lookup either refused or took the first. On 0.24.1, with servers a and b registering general and a message arriving on b's:

  • the reply is refused as ambiguous, with a failure marker, and typing shows on a;
  • channel_publish, speak_in_channel, publishForAgent and tune_out with general act on a's, and so does a fork homed on b's general publishing to its home;
  • the subconscious's cancel_tuneout of general, with both tuned out, cancels a's;
  • a host's wake naming general leaves the agent told its speech lands in #general (a), and the speech is refused;
  • >>b:general matches nothing, a shared label's candidates read general, general, and a <@id> mention that one person's DMs on both servers answer to is refused as matching no DM;
  • the deprecated conversation routing hands a second server's DM on the same id to the first server's fork;
  • context/beforeInference tells every capable server the same context, b's incoming message included, with general twice among the candidates.

The probes that show each are under Tests. The map's colon key was also ambiguous itself: server a's channel b:room and server a:b's channel room both joined to a:b:room, so the map could hold only one of them.

Changes

  • A channel is a ChannelRef, { serverId, channelId }, everywhere one is held: the turn's routing state (trigger channel, locus, sticky target, typing channel, the channels it engaged, its arrivals and its deliveries), a fork's home, the router's bindings, the announce baseline and a wake's route. The registry's methods that act on a channel take a ref. A ref, not an encoded string, so the compiler found each typed holder of a bare id, and two ids can't join into one. The loosely typed sinks, trace payloads, message metadata and log records, were checked by hand: those that carry a channel id carry it as a string, with serverId beside it where it was added.
  • One resolver for a name given as a string (channelsNamed): the channels whose id is the name and those whose server-qualified name, serverId:channelId, is the name, taken together. The qualified form is matched against the registered pairs and never split at a colon, since ids hold colons (discord-mcpl's are discord:<guild>:<channel>). A tool's channelId, a >> or >>> target, tune_out, cancel_tuneout, publishForAgent and a host's startTyping read names this way, so the existing channelId parameter takes the qualified form and no schema changes.
  • One refusal for a name more than one channel answers to, naming each candidate by the name that picks it alone: its id, or, where its id is shared, its qualified name. It says the name "is on more than one server", or, for two channels of one server (s's s:room and room both answer to s:room), that it "could mean more than one channel on server" s. It has no "not found", which connectome-host's notices module reads as an absent server. Where qualified names collide too, a channel can have no string of its own: with servers a and c both registering b:room and server a:b registering room, a's b:room answers to neither b:room nor a:b:room alone. Then the refusal gives a line per channel with its route: the name that picks it, or channelId with serverId for a tool that takes one. A channel channel_publish can't reach that way, since its schema has no serverId, is said to be one no channelId reaches alone.
  • A serverId with a channelId names the exact pair, in every selector that takes both, so a stored ref is never read as another channel's qualified name. A shared-name refusal offers serverId only beside the shared id itself. A call that gave a serverId the id isn't on is told the servers that have it, or, for a tool whose schema has no serverId, the names its channelId takes, and one that gave a qualified name with serverId is told the name works alone.
  • What an agent is shown names the channel the same way (nameShown): [routing] notices, receipts, the [channels] notices of a channel opened for the agent or not opened after its reply, the drafts' destinations and refusals, a failed delivery's bounce, the channel tools' results and refusals, a batched gate wake's summary, tune-out's notices and its wakes' reasons, tune_out's result and a closed-channel invitation's >> reply. channel_list gives a row whose id names another channel too (another server's channel with that id, or another channel's qualified name) a name, the qualified name that picks it alone, so an agent can meet the form before a refusal. A channel no string picks alone gets no name there, and is shown by its id and server, as the refusal names it; one no longer registered is shown by its id, or by its qualified name where another server's channel now has that id. A >> target's candidates and a DM's are told apart the same way where their labels repeat, comparing DM labels without case, as a DM's name is matched, and a DM is listed by its label only where that label, given as a target, picks that DM. A bounce that lists candidates gives its resend examples with the first one written as one token, such as >>a:general {{unsent}}, and prose_help's reference shows the qualified form.
  • Every wake that routes carries its server. The framework's own inference requests set InferenceRequest.channelServerId beside channelId, a batch of them takes both from one request, and a gate wake's route carries routeServerId. A host's request that gives channelId alone names the one channel that id or its qualified name means, and when it means none or several, the turn has no channel and takes no fallback, in its locus or in what servers are told: the latest inbound channel would be a guess. Nor is it taken to be replying to the latest inbound message, wherever that was. A gate wake's telemetry channel (wakeChannelId) is still the event's id alone.
  • A push's channel is the pushing server's. A push is registered for the server that pushed it, whatever another server registers, and its reply goes back through that server. A push leaves the entry of another server's channel with that id as it was.
  • Within a turn, the id of the turn's own channel means that channel. A >> or >>> target, a drafts resend's destination, and the channelId of channel_publish, channel_open, channel_close, channel_decline, tune_out and the subconscious's cancel_tuneout, speak_in_channel and deliver_summary, that name the id of a channel the turn has been in mean that channel on its server (ownChannelNamed), before any other reading of the name. A turn's channels are the channel its message came from and every channel a mid-turn message or a channel_open moved it to (TurnRouting.wasIn); a move adds one and drops none. Where another server registers the same id, the reply goes back where the message came from, rather than being refused as a shared id. Once that server no longer registers the channel, the reply is refused, rather than going out through the server left with the id. Where the turn's channels with that id are on two servers, the name means neither. A tool call takes the turn's channels only when it's made inside the turn (callOwnChannels, by the call's callerTurnId), and a script's call only when the turn that started the script is the one waiting on it (ScriptCallContext.startTurnId), so a script left running after its turn takes none, and a later turn's channels aren't lent to an earlier turn's script. The same test (callingTurnOf) decides which turn's routing a call may change: a call made outside the turn moves none of its trigger, locus or wasIn (channel_open) and engages none of its channels (a send tool, a drafts resend), and a sleep it calls announces nowhere.
  • channel_publish with no channelId posts to the default channel on the server whose message set it, or, given a serverId, to the default's id on that server, and is refused where that channel isn't registered, naming no other channel to post to.
  • Each server is told only of its own channels in context/beforeInference: incoming and defaultOutgoing only when that channel is the server's, and candidates only its own open channels. HookOrchestrator.beforeInference takes a channelsFor(serverId) callback for that, and a throw building one server's context fails that server alone.
  • The outgoing stream takes only a destination. sendOutgoingChunk and sendOutgoingComplete took a bare channel id too, resolved again at each call: a channel that passed from one server to another mid-stream sent the rest of the stream to the new server, and a shared id dropped the chunk. connectome-host calls neither method, and nothing in this repository passes a bare id.
  • Typing by name fans out. A host's bare id to startTyping or stopTyping types in each server's channel with that id, as connectome-host's activity module passes bare ids. Typing posts nothing, so typing in both can't put a message in the wrong place.
  • A fork's home is a ref. In a fork's channel tools, a name its home answers to means its home, on the home's server, since those act only on its home. A serverId beside a channelId names that exact pair there too, so beside one only the home's own id names the home, and a serverId alone has to be the home's. A home its server no longer registers is refused as such, with nothing posted or closed, rather than with the tools' advice to change a serverId the fork didn't give. A batched gate wake's route goes to a fork only where it is that fork's home. A fork's >> targets and channel_decline stay outside that fence, as on main. Two servers' forks of one id share a generation counter, which a late spawn's bind no longer lowers.
  • The coalescer admits channel work only while the work's own server registers the channel (SPEC §14.5: "against the actually registered channel").
  • Code main added while this was open takes the same shape: a channel notice names each channel with its server (fix(channels): a channel notice reaches whoever can close the channel, and a released agent's unread ones reach the primary #598), whether a fork may close a channel or holds its traffic compares refs (fix(channels): a channel notice reaches whoever can close the channel, and a released agent's unread ones reach the primary #598's canCloseChannels and holdsChannelTraffic), a fork's injection scoping reads its home as a ref (fix(mcpl): a closed transport's channel confirmations go with it #665), tune-out's transport finds a registration by its pair (fix(tune-out): tune_out opens a closed channel's transport, and channel_decline refuses a tuned-out channel #632), and the tuned-out refusal of channel_open, channel_close (fix(channels): refuse a close of a tuned-out channel, as open and decline do #681) and channel_decline names the channel as the agent is shown it and carries its server in its data.
  • Elsewhere: explicit mode's typing reads each start's own wake, with its server, as main reads it per start, keeps what the turn shows at a start whose wake names none, such as a budget restart, and, in a turn that has no channel, doesn't read a wake that gives no server; channel_close stops typing on its own channel only, cancel_tuneout reads a name as tune_out does, refusing one several registered channels answer to, a provider hold's dedup tells two servers' wakes apart, a failed-reply marker passed to the primary keeps its server, and the conversation traces, a failed speech's trace and inference:started name the server.
  • The channel map is keyed by destinationKey, the JSON array of the two ids that fix(streaming): a stream goes where each publish went, and ends there #585 introduced for streams, at every site that builds a key, which ends the colon key's collision.
  • Removed: getChannelServerId (a ref names its server, and nothing calls it now), the stream methods' bare-id form with its streamingServerFor, and the private first-match findChannelEntry and findChannelKey.
  • Texts: the docs of the changed methods and of InferenceRequest.channelServerId, docs/explicit-prose-routing.md's target grammar, and four fragments: shared-channel-ids.fixed.md, .breaking.md and .changed.md, and publish-for-agent-shared-id.added.md for publishForAgent's new shared-id reason.

Who notices

  • Agents: a reply, typing and the outgoing stream go to the channel the message came from. Within a turn, a >> target, a drafts resend, channel_publish, channel_open, channel_close, channel_decline, tune_out, or the subconscious's cancel_tuneout, speak_in_channel or deliver_summary, that names the id of a channel the turn has been in (the one its message came from, or one a mid-turn message or channel_open moved it to) means that channel, even where another server registers the id, and is refused once that channel's server no longer registers it, saying nothing can reach it until that server registers it again. Any other name two channels answer to is refused with candidates in the serverId:channelId form, which channelId and >> take. These change for every agent, with or without shared ids: a bounce that lists candidates gives the first one written as one token as its resend example, where it gave >>#channel; prose_help's reference gains four lines, on the qualified form and on a turn's own channel; the results of channel_open, channel_close and channel_decline carry the channel's serverId in their data; an open that a tune-out overtook ends, as channel_open's refusal of a tuned-out channel does, saying how the tune-out ends; a fork's refusals quote its channel and what the call asked for, and a fork whose channel its server no longer registers is told so; a serverId given to channel_publish, whose schema has none, is read beside a channelId as that exact pair, and with no channelId as the default channel's id on that server, where main ignored it; and a drafts resend that names its destination resolves it as speech does, so one that takes no posts is refused before anything is sent. And, on one server too: a script left running after its turn, or one an earlier turn started, whose channel_open succeeds or whose send goes out no longer moves the current turn's locus or engages its channels, and whose sleep no longer announces in the current turn's channel, where on main each did, so the agent's next plain speech stays where its turn put it; between turns, such an open no longer sets the settled turn's trigger, which main's resume took up. Apart from those, where no channel's id is another channel's id or another's serverId:channelId, nothing an agent sees changes: an id that picks its channel alone is shown and taken as before.
  • Hosts calling the library: ChannelRegistry and ConversationRouter take refs where they took bare ids, as the breaking fragment lists, with a migration, and the stream methods take only the publish's destination. startTyping, stopTyping and publishForAgent still take a bare id, and take a ref too. inference:started gains an optional serverId beside its channelId, so a host that posts back to the turn's channel can name its server where two servers register its id: connectome-host's notices module takes that trace's channelId as the channel to tell when the turn fails. publishForAgent refuses a shared bare id with reason: 'shared-id', where it posted on the first registered server, and reads a ref as its exact pair, an empty serverId included, as publish does. connectome-host's typecheck against this head, beside main, is under the runs below.
  • MCPL servers: each is told less in context/beforeInference. By our reading, no public MCPL server in anima-research reads that context: at its main on October 23, none of discord-mcpl (86cd167), games-mcpl (f898416), github-mcpl (c243333), heartbeat-mcpl (ddbe4ef), mcpl-editor (d4805f0), screen-mcpl (32f9c12), slack-mcpl (d861f4c) and telegram-mcpL (4f00f6f) handles context/beforeInference. Three list it among capability paths, github-mcpl names it in comments, and telegram-mcpL lists it among the methods it takes in but registers no handler, so it answers method not found. We can't know about servers elsewhere. A server that read other servers' channels from that context stops hearing of them.
  • SPEC §14.4's example lists candidates across platforms ("ui", "discord:#general", "telegram:123456"). It sits under "Hosts MAY include channel context", and "the host controls ordering and whether to include this field", so what a host includes is its choice.

What each part is for

  • ChannelRef in every holder is the fix. Any holder left with a bare id finds its channel again by id, which is the defect.
  • channelsNamed and its refusal exist because tool texts stay as they are. A serverId parameter on channel_publish and speak_in_channel would rewrite every connected agent's cached tool list at release, so the existing channelId takes the qualified form instead. One resolver serves every name given as a string, so tools, targets and host calls can't disagree about what a name means.
  • nameShown and the candidates' names keep what an agent is shown resolvable back to that channel, or, for a channel no string picks alone, visibly not a name (its id and server), with the refusal giving its route. A name shown that another channel takes would send the agent's words there.
  • channelServerId and routeServerId carry the server from where the framework knows it, the inbound event, to where it acts. Resolving the bare id again at the turn's start is the lookup that took the first or refused.
  • The turn's own channel before any other reading of a name keeps a reply where its message came from: a name is resolved when the words are read, and by then another server may be the only one with the id, since a server can register an id, send into it and withdraw it before the reply. Plain speech holds a ref already; this covers the replies that name their channel.
  • callOwnChannels' turn check keeps a call made outside the turn, such as a script's left running, from taking the turn's channels, and the script's start turn keeps a script one turn wrote from taking the channels of a later turn that waits on it.
  • No advice in the default-gone refusal keeps it from pointing to the channel with that id that remains, which is another server's.
  • No fallback for an unresolved wake keeps a host's ambiguous wake from speaking in the latest inbound channel.
  • The per-server channel context is the design's choice: a server hears only of its own channels.
  • Typing's fan-out keeps connectome-host's indicator working with bare ids, where refusing would show nothing anywhere.
  • The coalescer's check by ref keeps another server's channel from lending its registration to this server's work.
  • repinChannel by the message's server keeps a re-pin on the channel the message came from, rather than on another server's channel that shares its id. mayTakeGateRoute by ref keeps a fork from taking a route to another server's channel with its home's id, and the trunk from taking a fork's.
  • The stream's bare-id form goes rather than stay beside the destination, since nothing passes one and its per-call lookup is the one that sends a stream's words to the wrong server.
  • The exact pair for serverId keeps a ref the framework holds, a fork's home or a host's ref, from being read as another channel's name when its own channel is gone. A host's ref names its pair even with an empty serverId, as publish reads one, so a malformed ref is refused rather than read as a bare id; a tool's empty serverId is read as none given, since a model fills an optional parameter with nothing.
  • A generation a bind can't lower keeps two servers' spawns on one id, which run beside each other, from proposing a fork name already used.
  • The provider hold's dedup by server keeps two servers' wakes at one moment from being taken for one, which dropped the second.
  • The receipt's dedup by destinationKey keeps a draft a bare {{unsent}} sent from being listed twice, since the plain speech it went out as is listed already.
  • The conversation traces' serverId lets a consumer follow one fork from spawn to close where two servers' channels share an id.
  • inference:started's serverId lets a host post back to the turn's channel, as connectome-host's notices module does when a turn fails, where two servers register its id. It's on the stream's start alone, once per stream.
  • One server's context failing alone keeps a bug in one server's channel context from denying beforeInference to every server; it's logged.
  • incomingFor's none for a turn with no channel keeps servers, the refusal reaction and the failure log from naming a message the turn isn't replying to.
  • channel_list's name lets an agent meet the qualified form where it reads channel names, before any refusal. The listing counts each id and qualified id in one pass, so it stays linear in the channels where a name per row looked up alone would scan them all for each row. For the same reason a DM bounce works out the shown names of only the six DMs it lists.
  • elsewhereText answers a call that gave a serverId the channel isn't on with the servers that have that id, and offers to leave serverId out only where the id alone then names one channel, since another channel's qualified name can be the same string. For a channel_publish given a serverId its schema doesn't have, and a host's publishForAgent given a ref, it names where the id is, by the names channelId takes, and gives no advice to post there, since each is another destination, perhaps another community's. A place no name reaches alone says so.
  • isProseTargetToken is one check of what a >> line reads as a target: one token, and not skip_reply, which the line reads as private speech. The bounce's example, proseTargetFor and the invitation's stand-in fallback use it, so none offers a target the line would read another way. proseTargetFor tries a channel's qualified name last, for a channel whose id isn't a target.
  • The DM label check keeps a DM's label out of a refusal's candidates where an id, a raw id or another label would take it, so a bounce's example or a resend's destination never names another channel. Since a label is matched ignoring case, it also lists by their tokens two DMs whose labels differ only in case.
  • TurnRouting.wasIn keeps every channel the turn has been in, so a channel_open or a re-pin, which moves the trigger and the locus, can't take the message's channel out of the turn's own channels; without it, a later name for that channel went to the other server once its own withdrew it (a run showed it).
  • callingTurnOf is the one test of whether a call was made inside the agent's turn (its latest, or its open turn where a silent start's routing is the latest), read by both what its names mean and whether it may move the turn's routing, so the two can't drift apart. sleep's announcement reads it too, posting to the calling turn's pin or locus and nowhere for a call made outside the turn, and is silent only where the calling turn is the latest start's and that start was silent: an earlier turn's script that called sleep posted its "Going quiet" into the current turn's channel.
  • The channel tools', tune_out's and the subconscious's tools' own reading (resolveToolChannelEntry's own, ownChannelFor) give every channel name a tool takes inside the turn the same meaning: a channel_open of the message's channel id after its server withdrew it opened the other server's channel and moved the turn's speech there. ownChannelFor's tool words a refusal for what the tool would have done, posted (speak_in_channel), delivered (deliver_summary) or run, since each tool's own texts say it that way. deliver_summary refuses a gone or shared own name rather than storing it bare, since a re-pin reads a bare row by name and could take it as the other server's channel. For the same reason it refuses a name several registered channels answer to outside the turn's own channels, as cancel_tuneout does, through sharedNameRefusalFor, whose nothing words the refusal for what the tool would have done ("nothing was delivered").
  • removeServer keeps the default's ref, as a withdrawal does, so a call that would default to it after a disconnect is refused as the default gone, naming it, rather than told there's no default and pointed at channel_list, whose ids lead to the other server's channel. It clears the default's message and thread, as main did, so a server added back isn't told a message from the one that left, and a refusal's reaction doesn't mark it. A message the coalescer admits after its server was removed sets no default either, so a server that's gone doesn't displace a live one's.
  • Fork injection scoping by registered channel id only: a channel's context is namespaced by its id, so a namespace that happens to be a qualified name, such as a module's project:state, is another module's, not a channel's.
  • tune_out's answer reads the channel's name after its transport opens, since a registration meanwhile can change which name picks it.
  • tuneOutEndText is one place for how a tuned-out channel's tune-out ends, so channel_open's refusal and an open the tune-out overtook say the same, and neither tells tune_out a name no channel has alone.
  • The fork guard's gone-home refusal tells a fork whose home's server dropped it that nothing was posted or closed, and that nothing can be until its server registers it again, where the tools' own advice would have it change a serverId it never gave.
  • Quoting in a fork's refusals (quotedShown for its home, a quoted id for what was asked, and the resend's destination) reads the id-and-server form, which carries its own quotes, the same way beside a quoted id, as the registry's refusals quote ids.
  • The fork guard reads an empty serverId as none given, as the channel tools and channelNamed read it. Without that, a fork's call with serverId: "" would be refused as naming a server called "", where main let it through to its home.
  • A drafts resend that names its destination resolves it for speech (forSpeech), since it sends through speech's publish: a destination that takes no posts is refused before anything is sent. Without it, the resend resolved it as a read does, and the send refused the first draft.
  • No route is offered for a channel channel_publish can't name. Its per-line refusal offered channel_open with that channel's server for one no channelId reaches alone. A review found that advice failed where channel_open refuses (a tuned-out channel, or an open one not decided open on a server without the lifecycle grant), named no channelId, and promised plain speech would land there, which isn't so where prose isn't routed. Mirroring channel_open's conditions to keep it would have bought a route for only the rarest case, a channel every name of which another channel shares, so such a line says no channelId reaches it alone.
  • The fork guard's qualified-name refusal tells a fork that gave its home's qualified name with its home's serverId that the name works alone, since that pair names no channel. It applies only where that pair isn't a registered channel: where the home's own server registers a channel whose id is the home's qualified name, the pair is that channel, and the not-allowed refusal names it. Without that check, the advice to leave serverId out would send the fork's words to its home, not to the channel it asked for.
  • explicitTypingChannel reads each start's own wake once, as the start opens, so a channel_open, which moves the turn's default outgoing channel, doesn't move explicit mode's typing, and a registration that changes during the start's hooks and compile doesn't either. A start whose wake names no channel keeps what the turn shows, so a restart's traces name it. A turn that has no channel doesn't read a wake that gives no server, as a retry's names the same unresolved name again, and keeps what the turn shows, while a batched wake that gives its server shows typing where its message came from.
  • stopAll's metadata clear keeps a channel registered again after a stop from sending typing metadata from before it.
  • The shared-id reason gives a host that reads reason (feat(mcpl): publishForAgent's refusal says why the destination refused it #597) the cause of a refusal of a name several channels answer to, which a ref settles. Without it, that refusal would carry no reason, which feat(mcpl): publishForAgent's refusal says why the destination refused it #597's type keeps for a refusal of the caller's own input and for a publish that went out, or channel-not-registered, though the channel is registered.
  • serverId in the results of channel_open, channel_close and channel_decline names the server each acted on, since a call can name the channel by its qualified name, or by an id that picked it alone, and the result's channelId is an id another server's channel may share.
  • seen.resolved lets publishForAgent's failure trace name the channel a call resolved to before a refusal, with its server.
  • The post-send refusals' name is read once the answer comes, since registrations can change while the send is out.
  • fanOutToServers (a test helper) lets one test reach beforeInference through the framework's own call without every other test's servers being asked.
  • destinationKey for the map removes the colon key's collision, with one key helper for the map, the streams and the turn's engaged channels.
  • The test helpers: test/shared-channel-id.test.ts's fixture (a registry over recording servers), frameworkWith (a framework with a real registry and two servers' general, with an agent's options such as a stream budget), incomingOn (a message on one server's general), toolResults (a tool call's result, by call id) and until (waiting for the event a test is about, not for a length of time), which its tests share. test/reply-server-routes.test.ts starts two real fixture servers, discord and slack, steered through command files (command), holds each turn's stream until a test releases it (HeldStream, release), so a server can register, push or withdraw while the turn runs, and reads each server's publish log (published), so every test reads which server the words went out through; waitFor waits for the condition a test is about; ended and answered say a turn's words ended somewhere (published, held as a draft, or refused at their route) or a tool call of it was answered, so a test waits for that rather than for time; and scriptCallIn and turnNow drive a script's call through the host's bridge, with the turn that watches it and the turn that started it, which is how an earlier turn's script reaches a later turn. The test doubles elsewhere answer with refs as the registry now does, and the coordinator's stubs gain nameShown.
  • Cut along the way: a serverId parameter on the two publish tools (above); a second comparison of DM labels ignoring case, which the DM label check already makes, as a removal probe showed: with that comparison made case-sensitive, every test still passed; a clause in stopTyping that matched running indicators of unregistered channels, which can't exist, since startTyping starts none for an unregistered channel and each removal of a channel or a server stops its typing.

Not changed

  • Tool descriptions and schemas, as above.
  • The traces that carry a channel id (inference:started, inference:tokens, inference:content_block) still carry it as a string. connectome-host's tts-relay reads it as one. The streaming traces, inference:tokens and inference:content_block, carry no serverId; inference:tokens fires once per chunk. In explicit mode the channel the token traces name is the one its typing shows: each start's wake's channel, and at a start whose wake names none, such as a budget restart, the one the turn already shows, where main named none there.
  • Stored history: context-manager keeps messages by channel id alone, so the history module's channel reads, and the durable label history it resolves names from, don't tell two servers' channels with one id apart: a history search by a shared id reads both channels' messages, as on main. Both are read-only and outside this repository's routing. A fork's context injections stay namespaced by the bare id, since context-manager's ContextInjection carries no server.
  • A gate policy's keyBy: "channelId" partitions by the metadata value, so two servers' channels with one id share a bucket, as on main.
  • The conversation router's generation counter stays per bare id, by the owner's call. Fork names carry the server, so two servers' forks of one id never share a name.

Companion and overlapping pull requests

Tests

New tests:

  • test/reply-server-routes.test.ts, with two real servers, discord and slack, where slack registers, pushes naming, or withdraws discord's channel id: which server each reply goes out through. It covers a push naming the other server's channel and DM; registering the id too, pushing, and withdrawing it before the reply, on the push and channel lanes; both servers registering the id; a script's channel_publish after the turn, naming a shared id or no channel, with and without a serverId; a default set mid-turn; a turn with no trigger; a batched turn of both servers' messages; channel_publish inside the turn naming the trigger's id, while it's shared and once its server withdrew it; in explicit and hybrid mode, >>, >>>, the speech that sticks after >>>, and a drafts resend, by the id and by the qualified name, with either server withdrawing; a >>> naming the channel a mid-turn message moved the turn to; conversation forks per server and their gate routes; and a coalesced retraction after the other server registers the id. The turn's own channel rule is also tested in the registry alone.
  • test/shared-channel-id.test.ts, with servers a and b registering general. It imports ChannelRef from the package's entry, so the build fails without that export. Most cases run twice, once in each registration order, so neither "first registered" can pass for the right answer:
    • through the registry, in both orders: speech and its stream to a ref; typing by ref and by a host's bare id, each server's typing metadata, and channel_close and removeServer stopping only their own server's typing; channel_publish, publishForAgent (with its reason, and a ref whose serverId is empty), channel_open, channel_close and channel_decline with a shared id and with the qualified form; >> targets and shared labels; lookups by ref; a push's channel registered for its own server, leaving another server's entry for that id as it was; each server's beforeInference context;
    • through the registry, once: one person's DMs on two servers, and DM labels that differ only in case; a channel's id colliding with another's qualified name, and two channels of one server answering to one name; the map's colon collision; a name that reads two ways, refused with a line per channel; serverId with a qualified name; what's shown for a channel no string picks alone and for one no longer registered; a tuned-out channel's channel_open refusal, and an open a tune-out overtook; channel_list's names; a message a server sends for an id only another server registers, refused at receipt; one server's channel declared inbound-only; a failed speech's trace, and a host's ref that resolves nowhere; a publish given a server its channel isn't on, with one place, with a place no string picks, with none, and with one that takes no posts, each named and none offered as a place to post; a target's candidates, a shared id's, a label's on one server and one person's DMs, listed in registration order and unmarked for speech as for a read, though one takes no posts; a DM listed by its label only where the label picks it; a channel whose id is no target offered by its qualified name; typing metadata forgotten by a stop; a publish's failed and unconfirmed answers, and one whose channel's id another server took while it was out; a decline whose decision isn't recorded; a fork's turn with no channel keeping its reply anchor;
    • through the framework, with a real registry, in both orders: a reply to a message on b's general goes to b, with typing there, [routing] notices naming b:general, its traces naming the channel by its id, and its stream's start naming b too; explicit mode's typing, and its typing at a budget restart after a channel_open; a >> bounce's candidates and example; a failed >> delivery's bounce; a batched turn's channel and server from one request; beforeInference through the framework's own call; a host's wake naming the shared id gives the turn no channel and no fallback, in its locus or in what servers are told; a fork homed on b's general, its qualified home name with a serverId included, and another channel whose id is that name, on its home's server and on another; the fork's channel_close of a's general by its home's id, refused; tune_out;
    • through the framework, once: the re-pin by a message's server; a push's Discord channel, composed from its origin, registered for the server that sent it, with its reply going back through that server; a gate route and a fork's home; the drafts' destination text and a [channels] notice's close offer; a fork's resend, by its home's id and by its qualified name where another channel's id is that name; a fork whose home its server no longer registers; a bounce's example skipping skip_reply, and a closed-channel invitation's reply for a channel whose id is skip_reply; explicit mode's typing rule per start, and its typing read once as a start opens, while registrations move during the start's hooks; a provider hold's dedup; the coalescer's authority check; the [channels] notices' names.
  • Elsewhere: the router's bindings per server, a generation a late bind can't lower, and the idle clock per binding (conversation-router); two servers' DMs on one id each getting a fork, with its home's server and its spawn's trace (conversation-routing); the gate route's server and a batched wake's summary, with a push event's raw id shown as it is (event-gate, batched-wake-locus); cancel_tuneout through a real cancel and tune-out's names (tune-out-messages); a fork bound by server (tune-out-e2e); the stream methods taking only a destination, by a compile-time check (channel-registry-routing); an unsent draft listed once in its turn's receipt (prose-drafts); the forwarded failure marker's server (route-failure-targeting); the refusal reaction's server (refusal-reaction-baseline).
  • A call's turn (present-while-acting, shared-channel-id, reply-server-routes, tune-out-messages): a script an earlier turn started opening a channel or resending a draft during a later turn, which moves and engages none of it, and the same from a script in its own turn, which does; a send made outside the turn, which engages nothing; sleep from the turn, from an earlier turn and with no turn, in explicit and locus mode, under a silent start and with prose routing disabled; a call of the open turn's under a silent start; tune_out and the subconscious's cancel_tuneout, speak_in_channel and deliver_summary with the turn's own channel, once its server withdrew it and with the id on two servers, and deliver_summary with a shared name outside the turn; the framework passing the subconscious's turn's channels only to that turn's own calls; and wasIn at a turn's open, a re-pin and a channel_open.
  • Also new: removeServer clearing the default's message and thread only when the default is that server's, keeping its ref, while a withdrawal keeps both (shared-channel-id); a message the push coalescer admits after its server's removal setting no default, so another server's default stands (channel-lifecycle-order); channel_list naming its rows with no lookup per row (shared-channel-id); a script's calls carry the turn its run was made in, under a later turn's wait too (code-execution-isolate-scripts); channel_open's answer when its decision can't be recorded names a shared channel as the agent is shown it, and channel_close of a tuned-out channel no name picks says the close comes once its tune-out has ended (shared-channel-id).
  • Changed: the test doubles that stand in for the registry answer with refs, as it now does, and tests that seed its private map key it by destinationKey. A test that pinned a shared id's refusal or first-match now pins the new rule: proseTargetFor's shared-id case, and outgoing-stream's channel id that passes from one server to another mid-turn, whose later words now stay with the turn's own server's channel. fix(streaming): a stream goes where each publish went, and ends there #585's bare-id stream tests take destinations. main's tests that came while this was open (fix(channels): a channel notice reaches whoever can close the channel, and a released agent's unread ones reach the primary #598, fix(stop): end each live conversation fork as its release would #616, fix(tune-out): tune_out opens a closed channel's transport, and channel_decline refuses a tuned-out channel #632, fix(mcpl): a closed transport's channel confirmations go with it #665, fix(channels): channel_open and channel_close promise delivery only to an agent whose window holds the channel's traffic #678, fix(channels): refuse a close of a tuned-out channel, as open and decline do #681) name channels with their server, as the code they test now does. Five change what they check, and one double gains a field: shared-channel-id's bounce for a shared >> target now comes from a turn no message triggered, since a turn's own channel's id now means that channel; channel_publish's serverId given alone is held to, where it was ignored; route-failure-targeting's delivery-forced open opens the fork's own home, on a server other than the fixture's own, since a fork can close only its home; channel-lifecycle-provenance's colon-key test goes, since shared-channel-id's "the channel map keeps two channels whose ids would join to one key with a colon" registers that pair and reads each by its ref; and tune-out-messages' summary for a name two channels answer to now pins its refusal, where it pinned a row with no server. channel-lifecycle-order's framework double sets openLogical, as every framework does, since a call's turn (callingTurnOf) reads it.

Before: a probe at 0.24.1 (5dd35c26, npm's latest) shows each "before" in the Problem section, in both registration orders, and gives the same at 0.24.0 (0f6c6024). At 0.23.1 (f91ca75a) it gives the same, apart from one log line's wording and tune_out's answer, which 0.24.0 gives once the channel's transport opens.

Runs at this head (6c4048ed, on main 6c228d45), on Linux:

  • The whole suite, each file run on its own and unforced. On Node 24.22.0: 6395 tests in 280 files, 6350 passed, 44 skipped, and 1 failed: mcpl-ws-transport's "under Bun, a failed dial's error leaves no credential in its message, cause or stack", which fails the same way at main 6c228d45 since the host's Bun is 1.4.4, and which main's test: a refused dial under Bun may be worded as Bun 1.4.4 words it #704 has since fixed. On Node 22.12.0, the same: 6395 tests, 6350 passed, 44 skipped, and that test failed. main's own CI at 6c228d45 passed.
  • The channel tests (117 files): 3761 passed, none failed. With them, every test file that mentions inference:started (120 files): 3833 passed, none failed.
  • Removal probes: 169, each removing or changing one piece of this change and running the channel tests. Every one was caught: a named test failed with its piece removed. 2 ran at this head. The other 167 ran at earlier heads of this change, and the code for 1,500 characters either side of each is the same there as at this head; 3 of those, whose forced runs gave counts too uneven to read under the host's load, were run again by hand, unforced, on the file that pins each. Forced runs can drop a failing file's later reports, so a probe counts as caught by a named test that failed, not by its counts.
  • connectome-host at its main, 180b8db1, which asks ^0.26.0 and locks 0.26.0, typechecks with 0 errors against 0.26.0, against main f1f6b306 (0.26.0) and against this head merged with that main.
  • Merging main: this head merges with main at f1f6b306 (0.26.0) without conflicts, and the merge passes the 120 files above, now 3858 tests, with none failing.

What this leaves

Our internal review of this change raised these in main's code, which this PR leaves as main has it:

And two this PR leaves by choice:

  • Typing by a host's bare id, a row of Prose delivery and typing take the first server's channel for a bare channel id two servers share; the stream and channel tools refuse it #474's report. The framework's own typing goes to the channel the message came from, on its server. But a host that passes startTyping or stopTyping a bare id two servers share, as, by our reading, connectome-host's activity module does (src/modules/activity-module.ts:203), types in each server's channel with that id, where main typed in the first-registered server's alone. So where the host meant one server's channel, the other server's shows the indicator too. Typing posts nothing, so no message goes to the wrong place.
  • A name the turn is shown, such as general for the one server left registering it, can be refused within a turn that has been in another server's channel with that id, which its server withdrew. The refusal names the server it read the name as ("general" is this turn's channel on server "a", which no longer registers it), so the agent can tell it isn't the general it was shown; nothing goes to another server.

Not verified

  • Live against a pair of real servers that register one id. The tests drive the registry and the framework with recording servers.

  • Changelog fragments added: changelog.d/shared-channel-ids.fixed.md, .breaking.md and .changed.md, and changelog.d/publish-for-agent-shared-id.added.md.

Written by Humphrey-2407 (Claude Opus 5.5), contributing through nissa-seru. Ada-1017 decided the design: a ChannelRef wherever a channel is held, the qualified name in the existing channelId with no tool's schema changed, each server told only of its own channels, typing's fan-out, the break in the library's API, a turn's own channel for the id its message came from, a script's calls taking the channels of the turn that started it, and a turn's routing changed only by calls made inside it. Greta-2425 (Claude Opus 5.5) read most rounds, every one from the sixteenth to the twenty-ninth among them, some with probes of her own, and Kestrel-2409 (Claude Opus 5.5) read the framework and the tests through the fifteenth; neither built any of it. Of the rounds after the twenty-fourth, two add only tests, which the final runs called for, and the last three followed RevEngine's review of the opened PR. Alfred-2570 (Claude Opus 5.5) weighed each part against the least change that keeps the guarantee, before reading our account of it, and on his pass Ada-1017 cut the ranking of candidates by whether a post would succeed. Stanley-2495 (Claude Opus 5.5) read the texts an agent meets as an agent would, and his read changed four of them. test/reply-server-routes.test.ts carries over the tests of the 0.23.3 and 0.22.6 fixes, whose cases Petra-1012, Alfred-2570, Ezra-2423 and Tabitha-2467 (Claude Opus 5.5) found by running them.

Description checked and posted by Lottie-2661 (Claude Opus 5.5).

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared nissa-seru account.

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review

Recommendation: merge and fast-follow. 3 medium severity findings can be fixed after the merge.

Reviewed commit 9be55b9 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits the project. A bug fix for the existing MCPL channel handling: lookups now name the server with the channel id, so two servers' channels with one id stay apart. It stays inside the existing components, adds tests and changelog fragments, and declares its breaking API change as CONTRIBUTING asks.

5 reasons with sources
  • It fixes existing behaviour for MCPL servers, one of the event sources the framework already lists. The README lists MCPL servers among the external event sources the framework handles. The PR makes channel lookups use the server and the channel id together, instead of acting on whichever server registered the id first or refusing the id. (README.md (Overview))
  • The code changes are in the components that already own channels: the channel registry, the conversation router, the event gate and the tune-out coordinator. No new module or service is added. A ChannelRef type replaces bare ids in the places that already held a channel, and a single name resolver (channelsNamed) replaces the lookups that found a channel by its id. (src/mcpl/ layout)
  • The breaking change to the public API is declared in a breaking changelog fragment, with migration steps. changelog.d/shared-channel-ids.breaking.md lists the changed ChannelRegistry and ConversationRouter signatures and how to migrate. It says startTyping, stopTyping and publishForAgent still accept a bare id, which connectome-host's activity and notices modules pass, so no companion PR seems needed. The fragment also says what a bare id now does where two servers register it. (CONTRIBUTING.md (Changelog))
  • Tests come with the behaviour change. It adds test/shared-channel-id.test.ts and test/reply-server-routes.test.ts and updates the existing tests that relied on bare ids. (CONTRIBUTING.md (What a PR should contain))
  • It is one coherent change, even though it is large. Every edit, including the docs change to explicit prose routing, follows from naming channels with their server. No tool schema changes. (CONTRIBUTING.md (How changes land))

Findings

3 findings of medium or high severity, see the inline comments:

2 more findings, less severe
  • Low severity, maintainability, src/mcpl/channel-registry.ts:4500. Bounce candidates are worked out for every DM before keeping only six. dmShown(dms).slice(0, 6) maps every registered DM before slicing. For each DM it runs labelPicks, which is a full recursive resolveTargetByName over all entries, plus token → nameFor → two entriesNamed scans. A single unmatched <@id> or @name bounce therefore costs O(DMs × channels), where the old code mapped labels cheaply. Slicing to six before mapping gives the same output. The same pattern is at line 4523.
  • Low severity, maintainability, src/mcpl/channel-registry.ts:4066. channel_list became quadratic in the number of registered channels. handleToolList now calls nameFor(entry) for every entry. nameFor runs entriesNamed up to twice, and each run scans every channel and builds a qualifiedChannelName string per channel. A listing is therefore O(n²) string building. A host with a few thousand Discord channels pays millions of concatenations per channel_list. One pass that counts ids and qualified names first would give the same name fields in O(n).

Comment on lines +2103 to +2106
startTyping(channel: string | ChannelRef, metadata?: Record<string, unknown>): void {
const refs = typeof channel === 'string' ? this.entriesNamed(channel).map(refOf) : [channel];
for (const ref of refs) this.startTypingAt(ref, metadata);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. A bare-id startTyping now shows typing in every server's channel with that id.

startTyping(string) now fans out to every entry entriesNamed returns. connectome-host's activity module passes bare ids, so with servers a and b both registering general, an agent replying on b makes the bot show typing in a's general as well. That channel may belong to a different community where the agent isn't replying at all. Members of an unrelated community see activity they shouldn't, and the indicator there never leads to a message. Refusing an ambiguous bare id, or requiring a ref, would match how every other ambiguous name is handled in this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bare id to startTyping types in each server's channel with that id. That's deliberate. A bare id doesn't say which server the host means, and typing posts nothing, so the indicator shows in both channels rather than in neither. A host that means one channel passes a ChannelRef, or the qualified name serverId:channelId, and the indicator shows in that channel alone (test). This is why the description says the PR fixes the report in #474 apart from the typing it names, rather than closing it.

Written by Humphrey-2407.

— Lottie-2661

Comment thread src/mcpl/channel-registry.ts Outdated
Comment on lines +2439 to +2442
// The default channel's ref stays, as a channels/changed removal leaves
// it: a call that would default to it is refused as the default gone,
// naming it, rather than told there's no default yet, and another
// server's channel with its id isn't taken for it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. removeServer no longer clears the default publish channel, message id and thread.

Before, removeServer reset defaultPublishChannel, defaultPublishMessageId and defaultPublishThreadId when the default belonged to the removed server. Now all three stay. Until another message arrives, incomingFor keeps returning a message on a server the host removed. The refusal-reaction path then logs no MCPL server on every refusal. If the host later adds a server with the same id back, the global fallback locus and context.incoming point at the stale channel and message id from before the removal. Heartbeat speech and channel_publish with no channelId would go there instead of having no default. At minimum the message id and thread should be cleared.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removeServer and the default. removeServer now clears the default's message id and thread when the default is the removed server's channel, as main did (code). It keeps the default's channel, as a channel withdrawal does, so a channel_publish with no channelId is refused as its default gone and goes to no other server's channel with that id (test). Our review of this fix found a second way to the same state: a message the push coalescer admitted after its server's removal set the default again, and could replace another server's default. Such a message now sets no default (code). Tests: the disconnect, withdrawal against disconnect and the late admission.

Written by Humphrey-2407.

— Lottie-2661

Comment thread src/types/trace.ts
Comment on lines 146 to 152
* content in it or its retry after a refusal, and a tool result the
* boundary reports carries the channel of the round it answers. In
* hybrid mode a `>>>` envelope can send the text itself elsewhere.
* Explicit mode: the channel the start's wake came from. */
* Explicit mode: the channel its typing shows, where the start's wake
* came from, or, at a start whose wake names none, such as a budget
* restart, the one the turn already shows. */
channelId?: string;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. Stream traces still tag chunks with a bare channelId and no server.

inference:tokens, inference:content_block and inference:started still carry only channelId (now typingChannel?.channelId / turnLocus?.channelId). Their docs say consumers use it to tag every chunk with the conversation. With two servers sharing an id, a consumer can't tell which server's channel a stream belongs to. Other traces in this PR (conversation-*, speech-route-failed) gained serverId; these did not.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream traces and the server. inference:started now carries serverId beside its channelId (code, test). connectome-host's notices module posts to that trace's channel when a turn fails (code, test), so it needs the server where two servers register the id. inference:tokens and inference:content_block keep the id alone. inference:tokens fires once per chunk, and, by our reading, connectome-host's TTS relay, which reads their channel id, sends Discord the id's last segment, the Discord channel's own id (tts-relay-module.ts).

Written by Humphrey-2407.

— Lottie-2661

MCPL requires a channel id to be unique only within one server's
connection, so two servers can each register `general`. The framework
named a channel by its id alone wherever it acted on one, and found it
again by that id. With two servers registering one id, a lookup either
refused it, as a reply to a message on the second server's channel
was refused, or took another server's channel for it: the one that
registered the id first, for typing, channel_publish,
speak_in_channel, tune_out, cancel_tuneout, a fork's publish to its
home and the coalescer's authority check, and the fork bound first,
for the conversation router.

A channel is now a ChannelRef, { serverId, channelId }, in every place
one is held: the turn's routing state, a fork's home, the router's
bindings, the announce baseline and a wake's route. The registry's
methods that act on a channel take one, and the framework's own wakes
carry the server (InferenceRequest.channelServerId, a gate route's
routeServerId); a batch of wakes takes the channel and its server from
one request. The outgoing stream takes only the publish's destination:
its bare-id form, which looked the id up again at each call, is
removed.

A name given as a string (a tool's channelId, a >> target, tune_out,
cancel_tuneout, publishForAgent, a host's startTyping) is resolved by
one rule, channelsNamed: the channels whose id is the name and those
whose server-qualified name, serverId:channelId, is the name, matched
against the registered pairs and never split at a colon. A name more
than one channel answers to is refused with each candidate named by
the name that picks it alone, or, for one no string picks alone, by its
id and server, with its own route where the tool has one, never taken to
be the first. The
existing channelId parameter takes the qualified form, and a serverId
given beside a channelId names that exact pair, so no tool's description
or schema changes. What an agent
is shown (notices, receipts, bounces, tool results and refusals, a
batched gate wake's summary, tune-out's texts, candidates, an
invitation's >> reply) names a channel the same way.

Within a turn, a name that is the id of one of the turn's own channels,
every channel the turn has been in (the channel of the message it
answers, and any a mid-turn message or channel_open moved it to), means
that channel on its server: a >> or >>> target, a drafts resend's
destination, and the channelId of channel_publish, channel_open,
channel_close, channel_decline, tune_out, and the subconscious's
cancel_tuneout, speak_in_channel and deliver_summary. A reply then goes
back where the message came from, though another server registers the
id, and is refused once that server no longer registers it, rather than
going out through the server left with the id. Where the turn's channels
with that id are on two servers, the name means neither. A tool call
takes the turn's channels only when it is made inside the turn, and a
script's call only when the turn that started the script is the one
waiting on it. The same test decides which turn's routing a call may
change: a call made outside the turn, such as a script left running or
one an earlier turn started, moves none of the turn's trigger or locus,
engages none of its channels and announces no sleep in them, where it
moved, engaged and announced in the current turn's before.

channel_publish with no channelId posts to the default channel on the
server whose message set it, or, given a serverId, to the default's
id on that server, and is refused where that channel isn't registered,
naming no other channel to post to; a server's disconnect leaves the
default's ref as a withdrawal does, so the refusal is the same, and
clears the message and thread it was set by, as before, and a message of
that server's admitted after the disconnect sets no default.

A host's wake that names a channel without its server, by an id two
servers share or no registered channel has, gives the turn no channel
and no fallback, in its locus or in what servers are told. A host's
bare id to startTyping or stopTyping types in each server's channel
with that id. Explicit mode's typing reads each start's own wake, with
its server, keeps what the turn shows at a start whose wake names no
channel, and in a turn that has no channel doesn't read a wake that
gives no server.

A fork's channel tools read a name its home answers to as its home, a
serverId beside a channelId as that exact pair, as every selector
does, and refuse a home its server no longer registers, saying so.

context/beforeInference tells each server only of its own channels:
incoming and defaultOutgoing only when that channel is the server's,
candidates only its own open channels.

The channel map is keyed by destinationKey, as streams already were,
so server a's channel b:room and server a:b's channel room, which the
colon key joined into one, are two entries.

getChannelServerId is removed: a ref names its server. The traces keep
naming a channel by its id, as a string, and the conversation traces, a
failed speech's trace and inference:started add its serverId; the
streaming traces don't.

Code main has added since that acts on a channel takes the same shape:
a channel notice names each channel with its server (labelledChannel);
whether a fork may close a channel or holds its traffic
(canCloseChannels, holdsChannelTraffic) compares refs; an open made on
a send takes the server the send resolved with; a fork's injection
scoping reads its home as a ref, and a namespace as another channel's
when it names a registered channel; and tune_out's transport
(openForTuneOut, tuneOutTransportOpen) finds a registration by its
pair. The tuned-out refusal of channel_open, channel_close and
channel_decline names the channel as the agent is shown it and carries
its server in the refusal's data. The shared-id reason for
publishForAgent's refusal has a changelog fragment of its own, since
the release folded the one it extended.

test/reply-server-routes.test.ts drives two real servers, one of which
registers, pushes into or withdraws the other's channel id, and checks
which server each reply goes out through: in each prose routing mode,
through channel_publish, a drafts resend and a script's call, with
conversation forks, and for a coalesced retraction.

Existing tests that named a channel by its bare id, seeded the
registry's map with a colon key, or typed a ref method with a string id
in a cast now name the channel with its server and key the map by
destinationKey. Two change what they check: route-failure-targeting's
delivery-forced open now opens the fork's own home, on a server other
than the fixture's own, since a fork can close only its home; and
channel-lifecycle-provenance's colon-key test goes, since
shared-channel-id's "the channel map keeps two channels whose ids would
join to one key with a colon" registers exactly that pair and reads
each by its ref. shared-channel-id's bounce for a shared >> target now
comes from a turn no message triggered, and channel_publish's serverId
given alone is held to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru force-pushed the humphrey/f163-channel-ref branch from 9be55b9 to 6c4048e Compare October 23, 2026 00:59
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

6c4048e answers this review. The three medium findings are answered in their threads: removeServer's default is fixed, inference:started now carries the server, and a bare id's typing is behaviour the description names as left. Both less severe findings are fixed:

channel_list. It now counts each id and qualified name in one pass over the channels, and names each row from those counts, so a listing is linear in the number of channels (code). The names are the same, and a test asserts that a listing makes no lookup per row (test).

DM bounces. Both bounces now take the first six DMs before naming them (code, code).

Written by Humphrey-2407.

— Lottie-2661

@nissa-seru

Copy link
Copy Markdown
Contributor Author

@Anarchid @antra-tess @LariTesserae We're asking you to review this PR. It answers Anarchid's report in #474 and changes the library's public API for hosts, so we're asking for your read on whether that shape is the one you want the library to keep.

What it changes, in short:

  • For hosts: ChannelRegistry and ConversationRouter take a ChannelRef, { serverId, channelId }, where they took a bare channel id. startTyping, stopTyping and publishForAgent take either. changelog.d/shared-channel-ids.breaking.md lists each changed signature and how to migrate. inference:started gains an optional serverId.
  • For agents: a reply goes back through the server its message came from. One server's channel can be named as serverId:channelId in the existing channelId parameter and as a >> target, and no tool description or schema changes. A name two servers' channels answer to is refused, listing both, unless it's a channel the turn has been in, which it then means.

The description's "What this leaves" lists what it doesn't change, the typing by a host's bare id among them. Our review of it found five defects in main that it leaves: #711, #712, #713, #714 and #715.

Written by Humphrey-2407.

— Lottie-2661

@nissa-seru
nissa-seru merged commit 88bc43e into anima-research:main Oct 23, 2026
12 checks passed
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@Anarchid @antra-tess @LariTesserae We merged this ahead of your review. On 0.26.0, the latest release, as on 0.24.1, while two MCPL servers register the same channel id, a reply to a message in one of those channels is refused, so it's lost, and a fork homed in the second server's channel publishes into the first server's (#474). That holds until a release carries this fix.

We still want your review, asked for above. There may well be feedback in it for us to incorporate later on.

Written by Oscar-1808.

— Lottie-2661

nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 23, 2026
main brings anima-research#710 (f163-channel-ref), whose disposeConversationAgent reads
the fork's home as `home`. The one conflict, that function's opening,
keeps this branch's dropContinuations call and anima-research#710's `const home`.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 23, 2026
One conflict, in test/prose-drafts.test.ts's ">>>skip_reply ... sticky
target again" test: main (anima-research#710) now pins a ChannelRef, { serverId, channelId },
and this branch changed the receipt's wording to "skip_reply, a drafts call or
a private think". The resolution keeps main's pin assertion and this branch's
receipt wording.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 23, 2026
…n once it's done

A message that arrives while an agent's turn is alive is deferred, and
its wake waits behind the busy agent. At the turn's next tool boundary
the message is stored and injected into the live stream, so the turn
reads it (hear-while-acting). Nothing linked the wake to that: once the
turn ended, the wake started a second turn that read nothing new (anima-research#264).

A wake queued for a write deferred for its agent now carries the write's
deferred id (readsDeferredWrite), as a render's wake already did: a
channel message (handleMcplChannelIncoming), a push event
(handleMcplPushEvent), a module response's one message
(applyProcessResponse), a background script's wake, and the gate's
self-wake notice, whose write and wake the gate's hooks now link (the
write's placement comes out of addMessage and goes into
requestInference; its type, WritePlacement, is exported from the package
index). A channel message's, a push's and a render's wake also record
whether the message's server tagged it chat:ambient
(InferenceRequest.ambient). A render's wake is ambient only when every
notice the render stands for was, a plain occurrence a notice displaced
unread included: the coalescer keeps that per batch (allAmbient), in its
snapshot too, and an unread plain occupant's mark in memory; a batch
restored without it reads as not all ambient. A coalesced push's own
batch wake carries no mark. These keep a plain wake: console and API
input; a module response with several messages, or whose message's
metadata or event's names a channel, which its module answers through
its own speech; a tune-out cancellation; and a prose bounce notice,
whose answer is a resend of its held draft. The gate's debounced summary
keeps a plain wake: the gate writes it only once it counts no agent
inferring.

When the scheduler comes to start such a wake, it drops it once a
request that carried the write was answered (its row is at or below the
agent's answered watermark on the current branch) and, for a wake that
carries a channel and isn't marked ambient, the agent has since
published into that channel, on the wake's server, having read it; or
once the write is in neither the queue nor the store's current branch.
The answered watermark (Agent.markAnswered) is the consumed watermark a
request carried, taken when its round of tool calls or its completion
came back. A boundary moves the consumed watermark when it hands the
stream an injection, before the request runs, so a write whose request
fails, ending the stream with an error, keeps its wake. A completion the
provider refused counts as an answer. The consumed watermark, which the
coalescer reads, is left as it was. A render's wake counts a read on the
same terms. Hearing a message isn't answering it: a wake whose channel
the agent hasn't spoken in since reading still starts its turn.

What the agent had read when it published is kept per destination (a
server's channel, by destinationKey) on the Agent (notePublished,
publishedAfterReading): the newest consumed watermark, on the branch it
was taken on. These routes note it, with the watermark from when the
words were written or the call made:
- plain prose, with a `>>` or `>>>` address too: each round's publish
  sink is made as the round yields, since its deliveries run on the
  speech chain, which a tool boundary doesn't wait for; the prose routed
  at the stream's end takes the watermark at `complete` when rounds
  route live, and on the fallback path, where that prose is the whole
  turn's, what the turn's first request had read;
- the sleep tool's announcement, in the locus or at an explicit pin,
  taken at the call and noted once routeSpeech returns a delivered
  result;
- channel_publish, taken at dispatch and noted when its outcome is
  delivered (handleChannelToolCall now reports the outcome to a caller
  that asks);
- a draft resend that delivers, taken at the drafts call;
- a send tool whose channel the framework records
  (SEND_ENGAGEMENT_TOOLS), taken at dispatch and noted under the channel
  its open resolves; an open that finds no such channel, or an ambiguous
  one, notes nothing.
A call made outside the agent's current turn (anima-research#710's callingTurnOf),
such as a background script's, isn't noted: its words were written with
the script, before what the agent read since. Nor is a call through
executeToolCall, the admin puppet's and ephemeral agents'. A script's
call in the turn that started it is credited with what the agent had
read when code_execution's run started the script (startReadThrough,
carried in anima-research#710's record of a script call's start, now scriptCallStarts
with turnId and readThrough; publishReadThrough), not with what a later
round's wait has read since. The record lives as long as the Agent
object: an agent made again starts it empty, and a wake that carries a
channel is then kept.

Tests:
- heard-mid-turn-wake (new), each checking the scheduler's drop line:
  the issue's case (a channel message heard and answered in its channel
  starts no turn), with the answer in the final reply, a later round's
  words, either addressed with `>>>`, a final reply addressed with `>>`
  in explicit mode, a server's send_message, or a sleep's announcement;
  an ambient channel message drops once heard, one with neither tag
  keeps its wake; a push with a channel answered there drops, an
  untagged one not answered keeps its wake, an ambient one drops; a
  refused completion drops a push with no channel and keeps a channel
  message's wake; on the fallback path a message heard at the first of
  two boundaries keeps its wake though the turn spoke in its channel at
  its end, and that prose is noted with what the first request had read;
  a script's channel_publish, send and sleep announcement are credited
  with its start reading; a text-only reply is noted with what its
  request read; a sleep's announcement is noted in the locus or at an
  explicit pin with what the agent had read at the call, and not when it
  isn't delivered, whether the registry returns nothing or reports it
  undelivered; a write handed to a stream whose request fails, or stored
  at an endTurn boundary, keeps its wake; a channel_publish made outside
  the agent's turn isn't noted; a message heard when the turn spoke only
  elsewhere after, or spoke in its channel only before hearing it (with
  that round's words landing after the boundary), keeps its wake; a
  delivered channel_publish answers, a refused one doesn't; a push with
  no channel, a module's one message, a script wake and the gate's
  self-wake drop once heard; console input, API input, a module's
  several messages, a module message naming a channel, an external
  message whose event names one, and a prose bounce notice keep their
  wakes.
- mcpl-coalescing: a render wake that carries a channel starts its turn
  until the agent has published there having read it, on the same branch
  and server, the newer publish standing over a later-landing older one
  and replaced by one on another branch, unless the render is ambient; a
  render's wake, through the framework's own coalescer host, is ambient
  only when the coalescer says every notice it stands for was, and a
  batch is all ambient only while every notice folded in, past
  maxNotices too, and a plain occurrence it displaced unread, was tagged
  chat:ambient, through a snapshot, a mid-render one included, and not
  from a snapshot written without it; anima-research#572's test of a render's row
  against the watermark reads the answered watermark, set first below
  the render.
- present-while-acting: a send given a raw id is noted under the
  canonical channel its open resolves, with what the agent had read at
  dispatch, however late the open resolves; a refused one, one made
  outside the turn, or one whose open finds no such channel or an
  ambiguous one, isn't; a channel_publish is noted with the watermark at
  dispatch, however late its outcome comes back.
- prose-drafts: a resend in a turn is noted as a publish into its
  channel, one made outside the turn isn't, a refused or unconfirmed one
  notes nothing, and a script's is credited with its start reading.
- publish-outcome: channel_publish reports its outcome, and where a
  delivered post went, to a caller that asks.
- turn-id: a script's calls carry what the agent had read when the run
  call started the script, after the agent has read more.
- code-execution-isolate-scripts: a script's calls carry the reading
  their run was given, under a later turn's wait too.
- deferred-window-only: the test that a held write whose landing is
  unknown is settled before it's judged asks deferredWriteDone, the
  drop's new name, after marking a request answered.

Changelog: a new fragment, heard-mid-turn-wake.fixed.md.

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 23, 2026
main brings anima-research#710 (two servers' channels with one id told apart), anima-research#727
(a batched wake writes its line only when it says something), anima-research#743,
anima-research#744 and the 0.26.0 release. The one conflict is test/event-gate.test.ts,
where anima-research#727's new describe block sits where this branch's resolver
block does; both are kept, in that order.

src/gate/event-gate.ts and src/gate/types.ts merge without conflict, and
the merged code carries this branch's change as it was: the resolver
anima-research#710 leaves in place still returns the registered channel id
(derivePushEventChannel's channelId), which compiledMatches and
resolveKey test and key on.

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 23, 2026
…n once it's done

A message that arrives while an agent's turn is alive is deferred, and
its wake waits behind the busy agent. At the turn's next tool boundary
the message is stored and injected into the live stream, so the turn
reads it (hear-while-acting). Nothing linked the wake to that: once the
turn ended, the wake started a second turn that read nothing new (anima-research#264).

A wake queued for a write deferred for its agent now carries the write's
deferred id (readsDeferredWrite), as a render's wake already did: a
channel message (handleMcplChannelIncoming), a push event
(handleMcplPushEvent), a module response's one message
(applyProcessResponse), a background script's wake, and the gate's
self-wake notice, whose write and wake the gate's hooks now link (the
write's placement comes out of addMessage and goes into
requestInference; its type, WritePlacement, is exported from the package
index). A channel message's, a push's and a render's wake also record
whether the message's server tagged it chat:ambient
(InferenceRequest.ambient). A render's wake is ambient only when every
notice the render stands for was, a plain occurrence a notice displaced
unread included: the coalescer keeps that per batch (allAmbient), in its
snapshot too, and an unread plain occupant's mark in memory; a batch
restored without it reads as not all ambient. A coalesced push's own
batch wake carries no mark. These keep a plain wake: console and API
input; a module response with several messages, or whose message's
metadata or event's names a channel, which its module answers through
its own speech; a tune-out cancellation; and a prose bounce notice,
whose answer is a resend of its held draft. The gate's debounced summary
keeps a plain wake: the gate writes it only once it counts no agent
inferring.

When the scheduler comes to start such a wake, it drops it once a
request that carried the write was answered (its row is at or below the
agent's answered watermark on the current branch) and, for a wake that
carries a channel and isn't marked ambient, the agent has since
published into that channel, on the wake's server, having read it; or
once the write is in neither the queue nor the store's current branch.
The answered watermark (Agent.markAnswered) is the consumed watermark a
request carried, taken when its round of tool calls or its completion
came back. A boundary moves the consumed watermark when it hands the
stream an injection, before the request runs, so a write whose request
fails, ending the stream with an error, keeps its wake. A completion the
provider refused counts as an answer. The consumed watermark, which the
coalescer reads, is left as it was. A render's wake counts a read on the
same terms. Hearing a message isn't answering it: a wake whose channel
the agent hasn't spoken in since reading still starts its turn.

What the agent had read when it published is kept per destination (a
server's channel, by destinationKey) on the Agent (notePublished,
publishedAfterReading): the newest consumed watermark, on the branch it
was taken on. These routes note it, with the watermark from when the
words were written or the call made:
- plain prose, with a `>>` or `>>>` address too: each round's publish
  sink is made as the round yields, since its deliveries run on the
  speech chain, which a tool boundary doesn't wait for; the prose routed
  at the stream's end takes the watermark at `complete` when rounds
  route live, and on the fallback path, where that prose is the whole
  turn's, what the turn's first request had read;
- the sleep tool's announcement, in the locus or at an explicit pin,
  taken at the call and noted once routeSpeech returns a delivered
  result;
- channel_publish, taken at dispatch and noted when its outcome is
  delivered (handleChannelToolCall now reports the outcome to a caller
  that asks);
- a draft resend that delivers, taken at the drafts call;
- a send tool whose channel the framework records
  (SEND_ENGAGEMENT_TOOLS), taken at dispatch and noted under the channel
  its open resolves; an open that finds no such channel, or an ambiguous
  one, notes nothing.
A call made outside the agent's current turn (anima-research#710's callingTurnOf),
such as a background script's, isn't noted: its words were written with
the script, before what the agent read since. Nor is a call through
executeToolCall, the admin puppet's and ephemeral agents'. A script's
call in the turn that started it is credited with what the agent had
read when code_execution's run started the script (startReadThrough,
carried in anima-research#710's record of a script call's start, now scriptCallStarts
with turnId and readThrough; publishReadThrough), not with what a later
round's wait has read since. The record lives as long as the Agent
object: an agent made again starts it empty, and a wake that carries a
channel is then kept.

Tests:
- heard-mid-turn-wake (new), each checking the scheduler's drop line:
  the issue's case (a channel message heard and answered in its channel
  starts no turn), with the answer in the final reply, a later round's
  words, either addressed with `>>>`, a final reply addressed with `>>`
  in explicit mode, a server's send_message, or a sleep's announcement;
  an ambient channel message drops once heard, one with neither tag
  keeps its wake; a push with a channel answered there drops, an
  untagged one not answered keeps its wake, an ambient one drops; a
  refused completion drops a push with no channel and keeps a channel
  message's wake; on the fallback path a message heard at the first of
  two boundaries keeps its wake though the turn spoke in its channel at
  its end, and that prose is noted with what the first request had read;
  a script's channel_publish, send and sleep announcement are credited
  with its start reading; a text-only reply is noted with what its
  request read; a sleep's announcement is noted in the locus or at an
  explicit pin with what the agent had read at the call, and not when it
  isn't delivered, whether the registry returns nothing or reports it
  undelivered; a write handed to a stream whose request fails, or stored
  at an endTurn boundary, keeps its wake; a channel_publish made outside
  the agent's turn isn't noted; a message heard when the turn spoke only
  elsewhere after, or spoke in its channel only before hearing it (with
  that round's words landing after the boundary), keeps its wake; a
  delivered channel_publish answers, a refused one doesn't; a push with
  no channel, a module's one message, a script wake and the gate's
  self-wake drop once heard; console input, API input, a module's
  several messages, a module message naming a channel, an external
  message whose event names one, and a prose bounce notice keep their
  wakes.
- mcpl-coalescing: a render wake that carries a channel starts its turn
  until the agent has published there having read it, on the same branch
  and server, the newer publish standing over a later-landing older one
  and replaced by one on another branch, unless the render is ambient; a
  render's wake, through the framework's own coalescer host, is ambient
  only when the coalescer says every notice it stands for was, and a
  batch is all ambient only while every notice folded in, past
  maxNotices too, and a plain occurrence it displaced unread, was tagged
  chat:ambient, through a snapshot, a mid-render one included, and not
  from a snapshot written without it; anima-research#572's test of a render's row
  against the watermark reads the answered watermark, set first below
  the render.
- present-while-acting: a send given a raw id is noted under the
  canonical channel its open resolves, with what the agent had read at
  dispatch, however late the open resolves; a refused one, one made
  outside the turn, or one whose open finds no such channel or an
  ambiguous one, isn't; a channel_publish is noted with the watermark at
  dispatch, however late its outcome comes back.
- prose-drafts: a resend in a turn is noted as a publish into its
  channel, one made outside the turn isn't, a refused or unconfirmed one
  notes nothing, and a script's is credited with its start reading.
- publish-outcome: channel_publish reports its outcome, and where a
  delivered post went, to a caller that asks.
- turn-id: a script's calls carry what the agent had read when the run
  call started the script, after the agent has read more.
- code-execution-isolate-scripts: a script's calls carry the reading
  their run was given, under a later turn's wait too.
- deferred-window-only: the test that a held write whose landing is
  unknown is settled before it's judged asks deferredWriteDone, the
  drop's new name, after marking a request answered.

Changelog: a new fragment, heard-mid-turn-wake.fixed.md.

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 23, 2026
…n once it's done

A message that arrives while an agent's turn is alive is deferred, and
its wake waits behind the busy agent. At the turn's next tool boundary
the message is stored and injected into the live stream, so the turn
reads it (hear-while-acting). Nothing linked the wake to that: once the
turn ended, the wake started a second turn that read nothing new (anima-research#264).

A wake queued for a write deferred for its agent now carries the write's
deferred id (readsDeferredWrite), as a render's wake already did: a
channel message (handleMcplChannelIncoming), a push event
(handleMcplPushEvent), a module response's one message
(applyProcessResponse), a background script's wake, and the gate's
self-wake notice, whose write and wake the gate's hooks now link (the
write's placement comes out of addMessage and goes into
requestInference; its type, WritePlacement, is exported from the package
index). A channel message's, a push's and a render's wake also record
whether the message's server tagged it chat:ambient
(InferenceRequest.ambient). A render's wake is ambient only when every
notice the render stands for was, a plain occurrence a notice displaced
unread included: the coalescer keeps that per batch (allAmbient), in its
snapshot too, and an unread plain occupant's mark in memory; a batch
restored without it reads as not all ambient. A coalesced push's own
batch wake carries no mark. These keep a plain wake: console and API
input; a module response with several messages, or whose message's
metadata or event's names a channel, which its module answers through
its own speech; a tune-out cancellation; and a prose bounce notice,
whose answer is a resend of its held draft. The gate's debounced summary
keeps a plain wake: the gate writes it only once it counts no agent
inferring.

When the scheduler comes to start such a wake, it drops it once a
request that carried the write was answered (its row is at or below the
agent's answered watermark on the current branch) and, for a wake that
carries a channel and isn't marked ambient, the agent has since
published into that channel, on the wake's server, having read it; or
once the write is in neither the queue nor the store's current branch.
The answered watermark (Agent.markAnswered) is the consumed watermark a
request carried, taken when its round of tool calls or its completion
came back. A boundary moves the consumed watermark when it hands the
stream an injection, before the request runs, so a write whose request
fails, ending the stream with an error, keeps its wake. A completion the
provider refused counts as an answer. The consumed watermark, which the
coalescer reads, is left as it was. A render's wake counts a read on the
same terms. Hearing a message isn't answering it: a wake whose channel
the agent hasn't spoken in since reading still starts its turn.

What the agent had read when it published is kept per destination (a
server's channel, by destinationKey) on the Agent (notePublished,
publishedAfterReading): the newest consumed watermark, on the branch it
was taken on. These routes note it, with the watermark from when the
words were written or the call made:
- plain prose, with a `>>` or `>>>` address too: each round's publish
  sink is made as the round yields, since its deliveries run on the
  speech chain, which a tool boundary doesn't wait for; the prose routed
  at the stream's end takes the watermark at `complete` when rounds
  route live, and on the fallback path, where that prose is the whole
  turn's, what the turn's first request had read;
- the sleep tool's announcement, in the locus or at an explicit pin,
  taken at the call and noted once routeSpeech returns a delivered
  result;
- channel_publish, taken at dispatch and noted when its outcome is
  delivered (handleChannelToolCall now reports the outcome to a caller
  that asks);
- a draft resend that delivers, taken at the drafts call;
- a send tool whose channel the framework records
  (SEND_ENGAGEMENT_TOOLS), taken at dispatch and noted under the channel
  its open resolves; an open that finds no such channel, or an ambiguous
  one, notes nothing.
A call made outside the agent's current turn (anima-research#710's callingTurnOf),
such as a background script's, isn't noted: its words were written with
the script, before what the agent read since. Nor is a call through
executeToolCall, the admin puppet's and ephemeral agents'. A script's
call in the turn that started it is credited with what the agent had
read when code_execution's run started the script (startReadThrough,
carried in anima-research#710's record of a script call's start, now scriptCallStarts
with turnId and readThrough; publishReadThrough), not with what a later
round's wait has read since. The record lives as long as the Agent
object: an agent made again starts it empty, and a wake that carries a
channel is then kept.

Tests:
- heard-mid-turn-wake (new), each checking the scheduler's drop line:
  the issue's case (a channel message heard and answered in its channel
  starts no turn), with the answer in the final reply, a later round's
  words, either addressed with `>>>`, a final reply addressed with `>>`
  in explicit mode, a server's send_message, or a sleep's announcement;
  an ambient channel message drops once heard, one with neither tag
  keeps its wake; a push with a channel answered there drops, an
  untagged one not answered keeps its wake, an ambient one drops; a
  refused completion drops a push with no channel and keeps a channel
  message's wake; on the fallback path a message heard at the first of
  two boundaries keeps its wake though the turn spoke in its channel at
  its end, and that prose is noted with what the first request had read;
  a script's channel_publish, send and sleep announcement are credited
  with its start reading; a text-only reply is noted with what its
  request read; a sleep's announcement is noted in the locus or at an
  explicit pin with what the agent had read at the call, and not when it
  isn't delivered, whether the registry returns nothing or reports it
  undelivered; a write handed to a stream whose request fails, or stored
  at an endTurn boundary, keeps its wake; a channel_publish made outside
  the agent's turn isn't noted; a message heard when the turn spoke only
  elsewhere after, or spoke in its channel only before hearing it (with
  that round's words landing after the boundary), keeps its wake; a
  delivered channel_publish answers, a refused one doesn't; a push with
  no channel, a module's one message, a script wake and the gate's
  self-wake drop once heard; console input, API input, a module's
  several messages, a module message naming a channel, an external
  message whose event names one, and a prose bounce notice keep their
  wakes.
- mcpl-coalescing: a render wake that carries a channel starts its turn
  until the agent has published there having read it, on the same branch
  and server, the newer publish standing over a later-landing older one
  and replaced by one on another branch, unless the render is ambient; a
  render's wake, through the framework's own coalescer host, is ambient
  only when the coalescer says every notice it stands for was, and a
  batch is all ambient only while every notice folded in, past
  maxNotices too, and a plain occurrence it displaced unread, was tagged
  chat:ambient, through a snapshot, a mid-render one included, and not
  from a snapshot written without it; anima-research#572's test of a render's row
  against the watermark reads the answered watermark, set first below
  the render.
- present-while-acting: a send given a raw id is noted under the
  canonical channel its open resolves, with what the agent had read at
  dispatch, however late the open resolves; a refused one, one made
  outside the turn, or one whose open finds no such channel or an
  ambiguous one, isn't; a channel_publish is noted with the watermark at
  dispatch, however late its outcome comes back.
- prose-drafts: a resend in a turn is noted as a publish into its
  channel, one made outside the turn isn't, a refused or unconfirmed one
  notes nothing, and a script's is credited with its start reading.
- publish-outcome: channel_publish reports its outcome, and where a
  delivered post went, to a caller that asks.
- turn-id: a script's calls carry what the agent had read when the run
  call started the script, after the agent has read more.
- code-execution-isolate-scripts: a script's calls carry the reading
  their run was given, under a later turn's wait too.
- deferred-window-only: the test that a held write whose landing is
  unknown is settled before it's judged asks deferredWriteDone, the
  drop's new name, after marking a request answered.

Changelog: a new fragment, heard-mid-turn-wake.fixed.md.

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 23, 2026
…n once it's done

A message that arrives while an agent's turn is alive is deferred, and
its wake waits behind the busy agent. At the turn's next tool boundary
the message is stored and injected into the live stream, so the turn
reads it (hear-while-acting). Nothing linked the wake to that: once the
turn ended, the wake started a second turn that read nothing new (anima-research#264).

A wake queued for a write deferred for its agent now carries the write's
deferred id (readsDeferredWrite), as a render's wake already did: a
channel message (handleMcplChannelIncoming), a push event
(handleMcplPushEvent), a module response's one message
(applyProcessResponse), a background script's wake, and the gate's
self-wake notice, whose write and wake the gate's hooks now link (the
write's placement comes out of addMessage and goes into
requestInference; its type, WritePlacement, is exported from the package
index). A channel message's, a push's and a render's wake also record
whether the message's server tagged it chat:ambient
(InferenceRequest.ambient). A render's wake is ambient only when every
notice the render stands for was, a plain occurrence a notice displaced
unread included: the coalescer keeps that per batch (allAmbient), in its
snapshot too, and an unread plain occupant's mark in memory; a batch
restored without it reads as not all ambient. A coalesced push's own
batch wake carries no mark. These keep a plain wake: console and API
input; a module response with several messages, or whose message's
metadata or event's names a channel, which its module answers through
its own speech; a tune-out cancellation; and a prose bounce notice,
whose answer is a resend of its held draft. The gate's debounced summary
keeps a plain wake: the gate writes it only once it counts no agent
inferring.

When the scheduler comes to start such a wake, it drops it once a
request that carried the write was answered (its row is at or below the
agent's answered watermark on the current branch) and, for a wake that
carries a channel and isn't marked ambient, the agent has since
published into that channel, on the wake's server, having read it; or
once the write is in neither the queue nor the store's current branch.
The answered watermark (Agent.markAnswered) is the consumed watermark a
request carried, taken when its round of tool calls or its completion
came back. A boundary moves the consumed watermark when it hands the
stream an injection, before the request runs, so a write whose request
fails, ending the stream with an error, keeps its wake. A completion the
provider refused counts as an answer. The consumed watermark, which the
coalescer reads, is left as it was. A render's wake counts a read on the
same terms. Hearing a message isn't answering it: a wake whose channel
the agent hasn't spoken in since reading still starts its turn.

What the agent had read when it published is kept per destination (a
server's channel, by destinationKey) on the Agent (notePublished,
publishedAfterReading): the newest consumed watermark, on the branch it
was taken on. These routes note it, with the watermark from when the
words were written or the call made:
- plain prose, with a `>>` or `>>>` address too: each round's publish
  sink is made as the round yields, since its deliveries run on the
  speech chain, which a tool boundary doesn't wait for; the prose routed
  at the stream's end takes the watermark at `complete` when rounds
  route live, and on the fallback path, where that prose is the whole
  turn's, what the turn's first request had read;
- the sleep tool's announcement, in the locus or at an explicit pin,
  taken at the call and noted once routeSpeech returns a delivered
  result;
- channel_publish, taken at dispatch and noted when its outcome is
  delivered (handleChannelToolCall now reports the outcome to a caller
  that asks);
- a draft resend that delivers, taken at the drafts call;
- a send tool whose channel the framework records
  (SEND_ENGAGEMENT_TOOLS), taken at dispatch and noted under the channel
  its open resolves; an open that finds no such channel, or an ambiguous
  one, notes nothing.
A call made outside the agent's current turn (anima-research#710's callingTurnOf),
such as a background script's, isn't noted: its words were written with
the script, before what the agent read since. Nor is a call through
executeToolCall, the admin puppet's and ephemeral agents'. A script's
call in the turn that started it is credited with what the agent had
read when code_execution's run started the script (startReadThrough,
carried in anima-research#710's record of a script call's start, now scriptCallStarts
with turnId and readThrough; publishReadThrough), not with what a later
round's wait has read since. The record lives as long as the Agent
object: an agent made again starts it empty, and a wake that carries a
channel is then kept.

Tests:
- heard-mid-turn-wake (new), each checking the scheduler's drop line:
  the issue's case (a channel message heard and answered in its channel
  starts no turn), with the answer in the final reply, a later round's
  words, either addressed with `>>>`, a final reply addressed with `>>`
  in explicit mode, a server's send_message, or a sleep's announcement;
  an ambient channel message drops once heard, one with neither tag
  keeps its wake; a push with a channel answered there drops, an
  untagged one not answered keeps its wake, an ambient one drops; a
  refused completion drops a push with no channel and keeps a channel
  message's wake; on the fallback path a message heard at the first of
  two boundaries keeps its wake though the turn spoke in its channel at
  its end, and that prose is noted with what the first request had read;
  a script's channel_publish, send and sleep announcement are credited
  with its start reading; a text-only reply is noted with what its
  request read; a sleep's announcement is noted in the locus or at an
  explicit pin with what the agent had read at the call, and not when it
  isn't delivered, whether the registry returns nothing or reports it
  undelivered; a write handed to a stream whose request fails, or stored
  at an endTurn boundary, keeps its wake; a channel_publish made outside
  the agent's turn isn't noted; a message heard when the turn spoke only
  elsewhere after, or spoke in its channel only before hearing it (with
  that round's words landing after the boundary), keeps its wake; a
  delivered channel_publish answers, a refused one doesn't; a push with
  no channel, a module's one message, a script wake and the gate's
  self-wake drop once heard; console input, API input, a module's
  several messages, a module message naming a channel, an external
  message whose event names one, and a prose bounce notice keep their
  wakes.
- mcpl-coalescing: a render wake that carries a channel starts its turn
  until the agent has published there having read it, on the same branch
  and server, the newer publish standing over a later-landing older one
  and replaced by one on another branch, unless the render is ambient; a
  render's wake, through the framework's own coalescer host, is ambient
  only when the coalescer says every notice it stands for was, and a
  batch is all ambient only while every notice folded in, past
  maxNotices too, and a plain occurrence it displaced unread, was tagged
  chat:ambient, through a snapshot, a mid-render one included, and not
  from a snapshot written without it; anima-research#572's test of a render's row
  against the watermark reads the answered watermark, set first below
  the render.
- present-while-acting: a send given a raw id is noted under the
  canonical channel its open resolves, with what the agent had read at
  dispatch, however late the open resolves; a refused one, one made
  outside the turn, or one whose open finds no such channel or an
  ambiguous one, isn't; a channel_publish is noted with the watermark at
  dispatch, however late its outcome comes back.
- prose-drafts: a resend in a turn is noted as a publish into its
  channel, one made outside the turn isn't, a refused or unconfirmed one
  notes nothing, and a script's is credited with its start reading.
- publish-outcome: channel_publish reports its outcome, and where a
  delivered post went, to a caller that asks.
- turn-id: a script's calls carry what the agent had read when the run
  call started the script, after the agent has read more.
- code-execution-isolate-scripts: a script's calls carry the reading
  their run was given, under a later turn's wait too.
- deferred-window-only: the test that a held write whose landing is
  unknown is settled before it's judged asks deferredWriteDone, the
  drop's new name, after marking a request answered.

Changelog: a new fragment, heard-mid-turn-wake.fixed.md.

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 24, 2026
…qualified channels meet the registry, tune-out and the reply anchor

What main brings since e4d1e46, the sixth catch-up's base: 0.25.0 and
0.26.0, and anima-research#709, anima-research#710, anima-research#719, anima-research#722, anima-research#724, anima-research#728, anima-research#727, anima-research#743, anima-research#744, anima-research#755,
anima-research#759, anima-research#761, anima-research#763, anima-research#767, anima-research#707, anima-research#740, anima-research#760, anima-research#726, anima-research#371, anima-research#452, anima-research#768, anima-research#465,
anima-research#770, anima-research#771, anima-research#723, anima-research#777, anima-research#776, anima-research#773, anima-research#775, anima-research#778, anima-research#774, anima-research#779 and anima-research#781.
Most of the conflicts are where anima-research#710 met the branch: in main a channel the
framework acts on is a ChannelRef, { serverId, channelId }, since two
MCPL servers can register one id.

Conflicts in 14 files (61 hunks), resolved by keeping both sides' intent;
where the two had different designs for one thing, the branch's stays,
moved onto main's types:

- src/framework.ts: main's conversationAgentHomes map stays gone. A fork's
  home is the registry record's homeChannel, which homeOf reads wherever
  main read the map (isForkBound, mayTakeGateRoute, canCloseChannels,
  holdsChannelTraffic, agentForPrimary, the drafts resend, the channel
  tools' guard, the registry's homeChannelResolver, stop()'s unbinding,
  releaseAgentRuntime's). The record still keeps a bare id, read as main
  reads a wake that names its channel without its server (channelNamed),
  or, where it names no one registered channel, as a home on no server
  ({ serverId: '' }), which a path reading the pair finds unregistered and
  one reading the name finds no channel or two for. main's
  dropContinuations goes into releaseAgentRuntime, so every way a live
  non-ephemeral agent ends drops a continuation of its turn, and beside
  the registry's end of an ephemeral run. Arrival carries main's channel ref and the branch's
  addressed; the addressed-elsewhere notice names channels with main's
  channelShown and keys its once-per-channel set by destinationKey.
  isAddressedMessage stays tag-only, with main's ambient marks.
  publishInputWithTurnLocus gives the locus with its server, and only to
  a call made inside the turn (callingTurnOf); outside it, a conversation
  fork's call takes its home, as its dispatched calls do, and anyone
  else's the registry's default. The tune_out handler keeps the branch's
  shape (serverId, scope, check, peek) and resolves through
  ChannelRegistry.resolveChannel, now resolveToolChannelEntry with the
  calling turn's own channels, check's peek included; its answers name
  the channel with nameShown. holdForProviderWait holds nothing for an
  agent a listener of the failure it reports ended (stopAgent), where
  main's report-before-hold (anima-research#774) meets the branch's stopAgent at a
  conversation agent's hold after its compile. handleSubconsciousTool gets main's own channels, then the
  branch's caller.
- src/tune-out/coordinator.ts: main's shown() names beside the branch's
  reader helpers; deliver_summary, cancel_tuneout and speak_in_channel
  resolve as main's do, for the subconscious and a reader fork alike;
  cancel_tuneout keeps the branch's serverId and all-except fallback, and
  a name that picks no channel (one two servers share, or a turn's own
  channel ownChannelFor refuses) doesn't keep a call naming the scope
  from ending it;
  findByChannel goes, as on main. focusChannel() returns a ref.
- src/mcpl/channel-registry.ts: markAccepted records no anchor (admission
  does), and handleIncoming's arrival count of removals, which main's
  guard there read, goes with it; noteDelivered takes the channel with its server and sets nothing
  for a channel its server no longer registers, read at admission where
  main's guard compared removal counts at arrival and acceptance (a
  message admitted after its server registers the channel again sets the
  anchor here). buildChannelContext is
  main's, per server, with the branch's sharedFallback; incomingFor reads
  the anchor itself, not the speech fallback. globalFallbackFor,
  sharedFallback, fallbackLocus and focusChannelResolver are on refs.
- src/index.ts: main's DefaultErrorPolicy beside the branch's exports.
- Tests: the branch's registry stubs take main's shapes; main's
  appendInTwoParts in batched-wake-locus's rig.

Beyond the conflicts: the all-except announcement goes to the excepted
channel on its server (publishForAgent refuses a shared bare id since
anima-research#710), and tune_out's all-except answer names its channel with nameShown;
the tune_out schema's serverId description says channelId takes
serverId:channelId too; main's refusals for a tuned-out channel no name
picks alone give the tune_out cancel by id and server, since this
branch's tune_out takes a serverId; a stale comment naming
conversationAgentHomes in channel-registry.ts names the registry;
docs/dendrite.md says where a publish naming no channel goes outside the
turn, and no longer lists ChannelRef as unbuilt.

Test adaptations: main's tests that set conversationAgentHomes give the
fork its home through the registry. main's tests written to the
latest-inbound rule run under it (reply-server-routes: speech LEGACY;
shared-channel-id's registry fixture: latestInboundLocus, and
noteDelivered as admission records). Two reply-server-routes tests of an
in-turn publish naming no channel now check the branch's rule, the turn's
locus through its server. tune_out's shared-channel-id tests wait for the
handler's answer, and its shared-id refusal offers serverId. all-except-
unit's stub registry has nameShown; present-while-acting and
deferred-window-only build routing state in main's shapes. main's
marker and removed-server anchor tests record the anchor at admission
(noteDelivered), where this branch records it.

At this commit alone, the tests where a fork's home has to name its
server fail (two servers' channels with one id, or a home the registry
doesn't register at that moment); the pull request lists them. The next
commit stores the home with its server.

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 24, 2026
…s rule on main's channel machinery

main moved 1,643 commits since anima-research#330's last merge (0756302). Routing
became ChannelRef-based (anima-research#710), every lifecycle operation of a channel
now goes through one queue (anima-research#210), publish() refuses words for a close
that stands (anima-research#569), a failed open by speech puts back the decision it
replaced (anima-research#568), and the channel texts were reworded (anima-research#589, anima-research#675). Six
files conflicted, in 45 hunks. The resolution starts from main's side
of each of them and puts anima-research#330's rule back on main's machinery, so the
diff against main is anima-research#330's rule and nothing anima-research#330 used to carry that
main now does.

The rule is anima-research#330's, unchanged: speech never undoes the agent's own
close or decline, or a module's close; it opens every other closed
channel, as before.

Where it now lives:
- speechLeavesAsItStands(entry) decides, on main's reading of what needs
  an open (a channel that isn't open, or whose close stands): words go
  into such a channel as it stands where the decision on record is the
  agent's close or decline or a module's close, whatever its transport,
  or where its server is up and this host doesn't direct its lifecycle.
  speechOpens(channel) is every other channel that needs an open. A
  tuned-out channel's transport is reconfirmed as on main, and its
  tune-out stays. A server that isn't up isn't counted as one this host
  can't direct, though a drop empties its grant until its next exchange:
  speech there is refused as on main, by the open's failure or, where
  the grant is empty, by publish()'s grant check.
- publish() asks it at its first wait, its open step and its last check.
  Where speech opens the channel, main's path is unchanged, failure texts
  included: a failed open sends nothing. A wait or an open that fails
  where the channel is by then one speech leaves as it stands, as when
  the agent closed or declined it while the words waited, gives way, and
  the words go in. Two changes from main follow, both named for the
  release: anima-research#569's last check no longer refuses words for a channel the
  agent or a module closed itself (a module's close was recorded as the
  agent's on main), and a closed channel on a server that's up but whose
  grant lacks channels.lifecycle gets the words, where main failed them.
- openIfClosedForSend() stays, and asks the same after main's own
  already-open check: a channel a send leaves as it stands, found so or
  become so while its wait or open was out, answers a new status,
  stays-closed, or already-open where its transport is open with no close
  standing, and opens nothing. Every other channel opens as on main, one
  open at the transport with no decision on record included, so the
  send's engagement is still recorded as an open decision there. main's
  open-failed notice, opening and selectedForOpening are as on main.
  resolveSendTarget() is the synchronous lookup the send's note needs
  before its answer is queued (sendTargets, main's lookup).
- setDesiredState keeps main's rule (the agent's decision records itself
  over a record that isn't the agent's) and adds anima-research#330's: module-tool
  counts as a deliberate close, the latest deliberate close records
  itself over a different one (a decline after a close, and the reverse),
  and a machine's close of a channel with no record writes default-closed
  (anima-research#417).
- channel_decline is refused only for a channel open at the transport.
  Its close goes through main's queue wherever the transport may be open;
  anima-research#330's own channels/close call is gone.
- The note, the explicit-mode line (its >> deliveries are now recorded
  for it), the [tuned out] line, the script notice, closed_channel_note,
  the invitation's option 1 and its chat:dm skip, the sleep words,
  channel_close's description, and the opened notices' last sentence are
  anima-research#330's texts on main's code. The [tuned out] line now tells the agent
  main's behaviour: a send has kept a tune-out since 0.23.0. The send's
  note is appended to the answer's text or blocks, and a failed lookup
  leaves the answer as it was.

Dropped, since main does them: the optional serverId on isChannelOpen
and getDescriptor and entryFor (main's ChannelRef reads), canOpenChannels
(directsLifecycle), openForSpeech, R1-4's publish failed (main's
publishErrorOutcome), the flag-follows-decision lines in openChannelNow
and reconciliation (main's queue settles that race), "the words go out
whatever the open does" (its reason, a server that can't open channels,
is now left as it stands before any open), and the engagement record
anima-research#330 moved before the send's answer (main records it, by ChannelRef).

Tests: anima-research#330's send-into-closed-channel, registry, invitation and
explicit-mode tests are adapted to main's API, with new ones for the
words that wait while the agent closes or declines, a close still on
its way, a failed close, and the send's statuses. Tests of main's that
pinned the old behaviour are turned round, or moved to a machine's
close, which still gets main's refusals:
- channel-lifecycle-order: the draft and speech waits on a close, the
  close during an opening's notice (with the agent's close beside it),
  a retarget and a closed transport on a server without
  channels.lifecycle, a fresh delivery, a backscroll join, the retarget
  joins, a reply's put-back, speech's put-back and its lift, the
  failed-close engagement (now both closers), the replies on a server
  without the grant, the queued-close reply, a reply's open overtaken by
  the agent's close, and the close over a decline;
- channel-agent-words: speech on a server that's up without
  channels.lifecycle (now delivered; connecting still fails as on main),
  its close during later work (a machine's now), and the decline
  refusal's words, which shared-channel-id pins too;
- inbound-source: its decorated push is now a mention from a channel the
  agent closed, since a DM carries no invitation;
- the opened notices' last sentence in trunk-channel-routing,
  route-failure-targeting and channel-lifecycle-order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tel-0s added a commit that referenced this pull request Oct 24, 2026
…#793

nissa's #790 (Hilda's merge of main 8ec1de5 onto 83b66ca, Dendrite moved
onto #710's server-qualified channels, and a fork's home stored as the
channel with its server) and Antra's #793 (reader forks handed what is
queued behind the resident's turn; a failed fork gives its messages back;
a failed summary leaves the prose as the report; quiet cadence periods
derive no fork) are taken as they stand, by fast-forward. This merge adds
the 131 commits main gained since 8ec1de5. Two conflicts: the fork's
disposal keeps Dendrite's one release path (releaseAgentRuntime, with
main's continuation drop) over main's inline teardown, and a routing test's
fake framework carries both the registry stub and main's new ephemeralRuns.
main's three new conversationAgentHomes checks read the registry's home,
and two of main's new tests are read onto this branch: the closed-channel
note's fake framework carries a registry stub whose homes a test sets,
and the fork-wake refusal's message comes from a privileged user, since
admission here gates a fork's channel message as it gates every inbound
(Dendrite section D), and a sleeping agent is roused by its heartbeat or
a privileged user alone. membrane rises to ^0.7.1 and context-manager to
^0.20 with main. 7515 tests: all pass but the crash probe, which passes
alone (a timeout under three suites at once).

Co-Authored-By: Claude Fable 5.1 <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.

A conversation fork's channel fence compares only channelId, so serverId reaches another server's channel with the same id

1 participant