Repository navigation
fix(mcpl): tell two servers' channels with one id apart - #710
Conversation
There was a problem hiding this comment.
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
ChannelReftype 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
ChannelRegistryandConversationRoutersignatures and how to migrate. It saysstartTyping,stopTypingandpublishForAgentstill 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:
- Medium severity. A bare-id startTyping now shows typing in every server's channel with that id.
- Medium severity. removeServer no longer clears the default publish channel, message id and thread.
- Medium severity. Stream traces still tag chunks with a bare channelId and no server.
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 runslabelPicks, which is a full recursiveresolveTargetByNameover all entries, plustoken→nameFor→ twoentriesNamedscans. A single unmatched<@id>or@namebounce 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.handleToolListnow callsnameFor(entry)for every entry.nameForrunsentriesNamedup to twice, and each run scans every channel and builds aqualifiedChannelNamestring per channel. A listing is therefore O(n²) string building. A host with a few thousand Discord channels pays millions of concatenations perchannel_list. One pass that counts ids and qualified names first would give the samenamefields in O(n).
| 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); | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
| // 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
| * 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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
9be55b9 to
6c4048e
Compare
|
Replying to this review.
DM bounces. Both bounces now take the first six DMs before naming them (code, code). Written by Humphrey-2407. — Lottie-2661 |
|
@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:
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 Written by Humphrey-2407. — Lottie-2661 |
|
@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 |
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`.
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.
…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>
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>
…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>
…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>
…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>
…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>
…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>
…#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>
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_outand 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
idas "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 contextcontext/beforeInferencegives 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 serversaandbregisteringgeneraland a message arriving onb's:a;channel_publish,speak_in_channel,publishForAgentandtune_outwithgeneralact ona's, and so does a fork homed onb'sgeneralpublishing to its home;cancel_tuneoutofgeneral, with both tuned out, cancelsa's;generalleaves the agent told its speech lands in#general (a), and the speech is refused;>>b:generalmatches nothing, a shared label's candidates readgeneral, general, and a<@id>mention that one person's DMs on both servers answer to is refused as matching no DM;context/beforeInferencetells every capable server the same context,b's incoming message included, withgeneraltwice among the candidates.The probes that show each are under Tests. The map's colon key was also ambiguous itself: server
a's channelb:roomand servera:b's channelroomboth joined toa:b:room, so the map could hold only one of them.Changes
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, withserverIdbeside it where it was added.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 arediscord:<guild>:<channel>). A tool'schannelId, a>>or>>>target,tune_out,cancel_tuneout,publishForAgentand a host'sstartTypingread names this way, so the existingchannelIdparameter takes the qualified form and no schema changes.s'ss:roomandroomboth answer tos: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 serversaandcboth registeringb:roomand servera:bregisteringroom,a'sb:roomanswers to neitherb:roomnora:b:roomalone. Then the refusal gives a line per channel with its route: the name that picks it, orchannelIdwithserverIdfor a tool that takes one. A channelchannel_publishcan't reach that way, since its schema has noserverId, is said to be one nochannelIdreaches alone.serverIdwith achannelIdnames 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 offersserverIdonly beside the shared id itself. A call that gave aserverIdthe id isn't on is told the servers that have it, or, for a tool whose schema has noserverId, the names itschannelIdtakes, and one that gave a qualified name withserverIdis told the name works alone.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_listgives a row whose id names another channel too (another server's channel with that id, or another channel's qualified name) aname, the qualified name that picks it alone, so an agent can meet the form before a refusal. A channel no string picks alone gets nonamethere, 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}}, andprose_help's reference shows the qualified form.InferenceRequest.channelServerIdbesidechannelId, a batch of them takes both from one request, and a gate wake's route carriesrouteServerId. A host's request that giveschannelIdalone 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.>>or>>>target, a drafts resend's destination, and thechannelIdofchannel_publish,channel_open,channel_close,channel_decline,tune_outand the subconscious'scancel_tuneout,speak_in_channelanddeliver_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 achannel_openmoved 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'scallerTurnId), 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 orwasIn(channel_open) and engages none of its channels (a send tool, a drafts resend), and asleepit calls announces nowhere.channel_publishwith nochannelIdposts to the default channel on the server whose message set it, or, given aserverId, to the default's id on that server, and is refused where that channel isn't registered, naming no other channel to post to.context/beforeInference:incominganddefaultOutgoingonly when that channel is the server's, andcandidatesonly its own open channels.HookOrchestrator.beforeInferencetakes achannelsFor(serverId)callback for that, and a throw building one server's context fails that server alone.sendOutgoingChunkandsendOutgoingCompletetook 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.startTypingorstopTypingtypes 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.serverIdbeside achannelIdnames that exact pair there too, so beside one only the home's own id names the home, and aserverIdalone 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 aserverIdthe 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 andchannel_declinestay outside that fence, as onmain. Two servers' forks of one id share a generation counter, which a late spawn's bind no longer lowers.mainadded 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'scanCloseChannelsandholdsChannelTraffic), 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 ofchannel_open,channel_close(fix(channels): refuse a close of a tuned-out channel, as open and decline do #681) andchannel_declinenames the channel as the agent is shown it and carries its server in its data.mainreads 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_closestops typing on its own channel only,cancel_tuneoutreads a name astune_outdoes, 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 andinference:startedname the server.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.getChannelServerId(a ref names its server, and nothing calls it now), the stream methods' bare-id form with itsstreamingServerFor, and the private first-matchfindChannelEntryandfindChannelKey.InferenceRequest.channelServerId,docs/explicit-prose-routing.md's target grammar, and four fragments:shared-channel-ids.fixed.md,.breaking.mdand.changed.md, andpublish-for-agent-shared-id.added.mdforpublishForAgent's newshared-idreason.Who notices
>>target, a drafts resend,channel_publish,channel_open,channel_close,channel_decline,tune_out, or the subconscious'scancel_tuneout,speak_in_channelordeliver_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 orchannel_openmoved 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 theserverId:channelIdform, whichchannelIdand>>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 ofchannel_open,channel_closeandchannel_declinecarry the channel'sserverIdin theirdata; an open that a tune-out overtook ends, aschannel_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; aserverIdgiven tochannel_publish, whose schema has none, is read beside achannelIdas that exact pair, and with nochannelIdas the default channel's id on that server, wheremainignored 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, whosechannel_opensucceeds or whose send goes out no longer moves the current turn's locus or engages its channels, and whosesleepno longer announces in the current turn's channel, where onmaineach 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, whichmain's resume took up. Apart from those, where no channel's id is another channel's id or another'sserverId:channelId, nothing an agent sees changes: an id that picks its channel alone is shown and taken as before.ChannelRegistryandConversationRoutertake 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,stopTypingandpublishForAgentstill take a bare id, and take a ref too.inference:startedgains an optionalserverIdbeside itschannelId, 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'schannelIdas the channel to tell when the turn fails.publishForAgentrefuses a shared bare id withreason: 'shared-id', where it posted on the first registered server, and reads a ref as its exact pair, an emptyserverIdincluded, aspublishdoes. connectome-host's typecheck against this head, besidemain, is under the runs below.context/beforeInference. By our reading, no public MCPL server in anima-research reads that context: at itsmainon 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) handlescontext/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."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
ChannelRefin every holder is the fix. Any holder left with a bare id finds its channel again by id, which is the defect.channelsNamedand its refusal exist because tool texts stay as they are. AserverIdparameter onchannel_publishandspeak_in_channelwould rewrite every connected agent's cached tool list at release, so the existingchannelIdtakes 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.nameShownand 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.channelServerIdandrouteServerIdcarry 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.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.repinChannelby 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.mayTakeGateRouteby 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.serverIdkeeps 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 emptyserverId, aspublishreads one, so a malformed ref is refused rather than read as a bare id; a tool's emptyserverIdis read as none given, since a model fills an optional parameter with nothing.destinationKeykeeps a draft a bare{{unsent}}sent from being listed twice, since the plain speech it went out as is listed already.serverIdlets a consumer follow one fork from spawn to close where two servers' channels share an id.inference:started'sserverIdlets 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.beforeInferenceto 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'snamelets 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.elsewhereTextanswers a call that gave aserverIdthe channel isn't on with the servers that have that id, and offers to leaveserverIdout only where the id alone then names one channel, since another channel's qualified name can be the same string. For achannel_publishgiven aserverIdits schema doesn't have, and a host'spublishForAgentgiven a ref, it names where the id is, by the nameschannelIdtakes, and gives no advice to post there, since each is another destination, perhaps another community's. A place no name reaches alone says so.isProseTargetTokenis one check of what a>>line reads as a target: one token, and notskip_reply, which the line reads as private speech. The bounce's example,proseTargetForand the invitation's stand-in fallback use it, so none offers a target the line would read another way.proseTargetFortries a channel's qualified name last, for a channel whose id isn't a target.TurnRouting.wasInkeeps every channel the turn has been in, so achannel_openor 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).callingTurnOfis 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 calledsleepposted its "Going quiet" into the current turn's channel.tune_out's and the subconscious's tools' own reading (resolveToolChannelEntry'sown,ownChannelFor) give every channel name a tool takes inside the turn the same meaning: achannel_openof the message's channel id after its server withdrew it opened the other server's channel and moved the turn's speech there.ownChannelFor'stoolwords 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_summaryrefuses 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, ascancel_tuneoutdoes, throughsharedNameRefusalFor, whosenothingwords the refusal for what the tool would have done ("nothing was delivered").removeServerkeeps 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 atchannel_list, whose ids lead to the other server's channel. It clears the default's message and thread, asmaindid, 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.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.tuneOutEndTextis one place for how a tuned-out channel's tune-out ends, sochannel_open's refusal and an open the tune-out overtook say the same, and neither tellstune_outa name no channel has alone.serverIdit never gave.quotedShownfor 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.serverIdas none given, as the channel tools andchannelNamedread it. Without that, a fork's call withserverId: ""would be refused as naming a server called"", wheremainlet it through to its home.forSpeech), since it sends through speech'spublish: 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.channel_publishcan't name. Its per-line refusal offeredchannel_openwith that channel's server for one nochannelIdreaches alone. A review found that advice failed wherechannel_openrefuses (a tuned-out channel, or an open one not decided open on a server without the lifecycle grant), named nochannelId, and promised plain speech would land there, which isn't so where prose isn't routed. Mirroringchannel_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 nochannelIdreaches it alone.serverIdthat 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 leaveserverIdout would send the fork's words to its home, not to the channel it asked for.explicitTypingChannelreads each start's own wake once, as the start opens, so achannel_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.shared-idreason gives a host that readsreason(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, orchannel-not-registered, though the channel is registered.serverIdin the results ofchannel_open,channel_closeandchannel_declinenames 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'schannelIdis an id another server's channel may share.seen.resolvedletspublishForAgent's failure trace name the channel a call resolved to before a refusal, with its server.fanOutToServers(a test helper) lets one test reachbeforeInferencethrough the framework's own call without every other test's servers being asked.destinationKeyfor the map removes the colon key's collision, with one key helper for the map, the streams and the turn's engaged channels.test/shared-channel-id.test.ts'sfixture(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'sgeneral),toolResults(a tool call's result, by call id) anduntil(waiting for the event a test is about, not for a length of time), which its tests share.test/reply-server-routes.test.tsstarts two real fixture servers,discordandslack, 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;waitForwaits for the condition a test is about;endedandansweredsay 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; andscriptCallInandturnNowdrive 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 gainnameShown.serverIdparameter 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 instopTypingthat matched running indicators of unregistered channels, which can't exist, sincestartTypingstarts none for an unregistered channel and each removal of a channel or a server stops its typing.Not changed
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:tokensandinference:content_block, carry noserverId;inference:tokensfires 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, wheremainnamed none there.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'sContextInjectioncarries no server.keyBy: "channelId"partitions by the metadata value, so two servers' channels with one id share a bucket, as onmain.Companion and overlapping pull requests
reasontopublishForAgent's failure, for hosts to read instead of the refusal's words. This PR adds itsshared-idvalue, for a name more than one registered channel answers to, which a ref tells apart, as a server-qualified name does where it names one channel alone, and lists it inpublish-for-agent-shared-id.added.md, since feat(mcpl): publishForAgent's refusal says why the destination refused it #597's fragment was folded into 0.24.0's notes. A ref whose server doesn't register its id getschannel-not-registered.channelIdalone), which this PR closes: the fence compares the pair, so a fork can't close or post to another server's channel with its home's id; fix(channels): a channel notice reaches whoever can close the channel, and a released agent's unread ones reach the primary #598'scanCloseChannels, the other half A conversation fork's channel fence compares only channelId, so serverId reaches another server's channel with the same id #612 names, compares refs; and the notices name a shared id with its server.src/mcpl/channel-registry.ts,src/framework.ts, and for two of themsrc/mcpl/push-coalescer.tsand the tune-out coordinator).Tests
New tests:
test/reply-server-routes.test.ts, with two real servers,discordandslack, whereslackregisters, 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'schannel_publishafter the turn, naming a shared id or no channel, with and without aserverId; a default set mid-turn; a turn with no trigger; a batched turn of both servers' messages;channel_publishinside 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 serversaandbregisteringgeneral. It importsChannelReffrom 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:channel_closeandremoveServerstopping only their own server's typing;channel_publish,publishForAgent(with itsreason, and a ref whoseserverIdis empty),channel_open,channel_closeandchannel_declinewith 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'sbeforeInferencecontext;serverIdwith a qualified name; what's shown for a channel no string picks alone and for one no longer registered; a tuned-out channel'schannel_openrefusal, 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;b'sgeneralgoes tob, with typing there,[routing]notices namingb:general, its traces naming the channel by its id, and its stream's start namingbtoo; explicit mode's typing, and its typing at a budget restart after achannel_open; a>>bounce's candidates and example; a failed>>delivery's bounce; a batched turn's channel and server from one request;beforeInferencethrough 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 onb'sgeneral, its qualified home name with aserverIdincluded, and another channel whose id is that name, on its home's server and on another; the fork'schannel_closeofa'sgeneralby its home's id, refused;tune_out;[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 skippingskip_reply, and a closed-channel invitation's reply for a channel whose id isskip_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.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_tuneoutthrough 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).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;sleepfrom 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_outand the subconscious'scancel_tuneout,speak_in_channelanddeliver_summarywith the turn's own channel, once its server withdrew it and with the id on two servers, anddeliver_summarywith a shared name outside the turn; the framework passing the subconscious's turn's channels only to that turn's own calls; andwasInat a turn's open, a re-pin and achannel_open.removeServerclearing 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_listnaming 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, andchannel_closeof a tuned-out channel no name picks says the close comes once its tune-out has ended (shared-channel-id).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'sserverIdgiven 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 setsopenLogical, as every framework does, since a call's turn (callingTurnOf) reads it.Before: a probe at 0.24.1 (
5dd35c26, npm'slatest) 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 andtune_out's answer, which 0.24.0 gives once the channel's transport opens.Runs at this head (
6c4048ed, onmain6c228d45), on Linux: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 atmain6c228d45since the host's Bun is 1.4.4, and whichmain'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 at6c228d45passed.inference:started(120 files): 3833 passed, none failed.main,180b8db1, which asks^0.26.0and locks 0.26.0, typechecks with 0 errors against 0.26.0, againstmainf1f6b306(0.26.0) and against this head merged with thatmain.main: this head merges withmainatf1f6b306(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 asmainhas it:>>prefix, which only explicit prose routing reads #711: the closed-channel invitation tells an agent to reply with a>>prefix in every routing mode, and only explicit routing reads one.>>>envelope still decides where it goes #713: in hybrid routing,channel_open's result says plain speech now lands in the opened channel, while an earlier>>>envelope still decides where it goes.channel_declinepasses its fence, so a fork can decline another channel for the whole host and post its acknowledgment there #714: a conversation fork'schannel_declineof a channel that isn't its home passes the fork's fence, records a decline for the whole host, and can post its acknowledgment there.>>>envelope pinned it #715: in hybrid routing, thesleeptool's announcement, which is the agent's ownmessagewhere it gives one, goes to the turn's locus, while its plain speech goes where its last>>>envelope pinned it.And two this PR leaves by choice:
startTypingorstopTypinga 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, wheremaintyped 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.generalfor 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 thegeneralit was shown; nothing goes to another server.Not verified
changelog.d/shared-channel-ids.fixed.md,.breaking.mdand.changed.md, andchangelog.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
ChannelRefwherever a channel is held, the qualified name in the existingchannelIdwith 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.tscarries 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-seruaccount.