Repository navigation
fix(mcpl): a channel known only from a push no longer counts as confirmed closed - #601
Merged
nissa-seru merged 4 commits intoOct 19, 2026
Merged
Conversation
… unknown ensureChannelRegistered set a push-registered channel's entry with open: false directly, without the unconfirmed mark registerEntry gives a registration's entry. So every reader of the mark took such a channel as confirmed closed. After a restart, a push for a channel no registration has comes here, and the server may still hold a channel an earlier process opened. Where this host directs the lifecycle, channel_decline answered "remained closed" and sent nothing, and channel_close of a channel decided closed answered "already closed" and sent nothing. Where it doesn't, a close refused for the grant said "Channel ... is closed". The entry is now made through registerEntry, keeping its lazyRegistered metadata and its mcpl:channel-lazy-registered trace, so a push-registered channel starts unconfirmed, as a registered one does. Each reader of the mark now takes its transport as unknown: - channel_decline sends its close through the lifecycle queue; - channel_close of a channel decided closed sends the close; - a close refused for the grant says it didn't go through, as for a registered channel; - where this host directs the lifecycle, traffic no longer shows a channel decided open or tuned out as open; a receipt for this host's own request does, as for a registered channel. That keeps one rule for what traffic proves, with no branch for push-registered entries. registerEntry's doc now names the push's entry and the traffic that shows a channel open where the host doesn't direct the lifecycle. test/channel-lifecycle-order.test.ts gains one test per reader, each with the server still holding the channel open: a close after a decision kept from before a restart, a decline, a close refused for the grant, and traffic in a tuned-out channel, where the host directs the lifecycle and where it doesn't. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Our internal review of 150b6c3 found that keeping a push-registered channel unconfirmed through its traffic, where this host directs the lifecycle, reached every reader of its open flag. With a conversation router, scopeInjectionsForAgent let its injections into other conversations' agents. The closed-channel invitation went on every addressed message, saying the agent hadn't opened it. channel_decline of a tuned-out push channel ended the tune-out. And nothing ended that window for a channel only pushes name. This part is reversed. The directs branch waits for a receipt because, after a retarget, traffic can still come from the old subscription. An entry made from a push has no target that could have been retargeted, so for that same reason its traffic shows the transport open for a channel decided open or tuned out, and the mark goes. madeFromPush names the check, which isDeclaredChannel and the label backfill now use too. An entry stops being push-made when a registration replaces it or a channels/changed update replaces its descriptor. laterClause takes the entry: for a push-made channel, which may never be registered, a recorded decision happens "if" the server registers it, not "when" it next does. The comment that called the push's entry CLOSED now says it isn't open, and registerEntry's doc names the traffic rule. Tests: the traffic test now expects open whether or not this host directs the lifecycle, and a refused decline that leaves the tune-out. The refused close is tried twice, the second time with its decision recorded. conversation-fork-injection-scoping gains a push-made channel whose traffic scopes it out of a fork's context, which fails without the condition. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Our internal review of b077da9 found two holes in how the traffic condition knew an entry was made from a push. It read the descriptor's lazyRegistered metadata, which a server's own registration or update supplies, so a registered channel whose descriptor carried that key skipped the receipt rule. And an update that kept a push entry's target replaced its descriptor without the key, ending the exception while the creation mark stayed and nothing reconciled the channel. The host now keeps which entries the condition covers, untargetedPushEntries, a WeakSet of the entries ensureChannelRegistered made. registerEntry returns its entry so the push can add it. A registration makes a new entry, which isn't in the set, and handleChanged's retarget, beside invalidateTransportConfirmation, takes an entry out. An update that keeps the target leaves it in, since no earlier target exists that its traffic could come from. isDeclaredChannel, the label backfill and laterClause ask whether the server has declared the channel, so they keep main's reading of the push's placeholder metadata, now named pushPlaceholder. Comments: the traffic comment states the exception inside the receipt rule; registerEntry's and ensureChannelRegistered's docs name the decided open or tuned out condition; the notes on when a recorded decision happens add the push's placeholder; a stale handleIncoming paragraph about registering an unknown channel from its first message, which the code below it refuses, is gone. Tests: a registration whose descriptor carries lazyRegistered: true still waits for a receipt; an update that keeps a push entry's target leaves its traffic showing it open, and one that gives it a target waits for a receipt; the decline test first delivers traffic with nothing decided and checks the channel stays not open. The fragment's decline line now says "that isn't open". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… registration over a push From our internal review of 87751b6, text and one test: - pushPlaceholder's doc no longer says the key marks a descriptor no server declaration replaced, since a server's own descriptor can carry it. It says the key is how main reads whether the server has declared the channel, and that the traffic rule reads untargetedPushEntries instead. - The changelog fragment and the test file's header now say a push's traffic shows the channel open where this host directs the lifecycle only once it's decided open or tuned out. - The registration test now covers a channels/register over a push's entry beside a channels/changed registration carrying the placeholder key: each waits for a receipt before its traffic shows it open, since a registration makes a new entry outside the set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 20, 2026
Closed
nissa-seru
pushed a commit
to rufalupa666-netizen/agent-framework
that referenced
this pull request
Oct 20, 2026
Main has moved since the last merge (037aa79), by 26 pull requests, among them anima-research#589 (the channel tools and speech say what happened), anima-research#597 (publishForAgent's refusal says why), anima-research#595 (channel_decline's status), anima-research#601, anima-research#568 and anima-research#594 (channel opens and retargets), anima-research#495, anima-research#596 and anima-research#599 (code_execution's channel and how a script's ending reads), anima-research#617 (a sleeping agent wakes for the host's heartbeat server) and anima-research#616 (stop() ends each live conversation fork). Two files conflict: - src/mcpl/channel-registry.ts: routeSpeech's no-locus branch. Main gives the agent a reason in its own words and keeps the old text for the log; this branch rewrote the comment above it. Both are kept. - test/event-gate.test.ts: both sides append a describe block after the quiesce tests. Both blocks are kept, this branch's first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A channel this host knows only from a push now starts with its transport unconfirmed, as a registered one does. So
channel_closeandchannel_declinesend the server its close, or refuse as for a registered channel, where onmainthey sent nothing and took the channel as closed.Problem
A channel this host knows only from a push event, whose server sends a
push/eventfrom it without registering it, gets its entry fromensureChannelRegistered. That entry was set withopen: falsedirectly. An entry made for a registration (channels/register,channels/changed) comes fromregisterEntry, which also marks its transport unconfirmed until something confirms it. So the readers that answer for a closed channel took a push-registered channel as confirmed closed. After a restart, a push for a channel no registration has comes toensureChannelRegistered, and the server may still hold a channel an earlier process opened.Reproduced on
maind8cc6909(see Tests):channels.lifecycle):channel_declineansweredremained closedand sent the server nothing.channel_closeof a channel decided closed, as by a decision kept from an earlier process, answeredalready closedand sent nothing.Channel "x" is closed. This host isn't permitted to open or close the "test" server's channels, so it sent the server nothing.A registered channel's says the close didn't go through and stays recorded.Changes
ensureChannelRegisteredmakes its entry throughregisterEntry, keeping itslazyRegisteredmetadata and itsmcpl:channel-lazy-registeredtrace. A push-registered channel then starts unconfirmed, as a registered one does:channel_declinein a channel that isn't open sends its close through the lifecycle queue, and its answer is stillremained closed, andchannel_closeof a channel decided closed sends the close;channel_closegets the registered channel's refusal, whether or not a close is already recorded.main. Where this host directs the lifecycle, traffic shows a registered channel open only once a receipt has confirmed its target, since after a retarget, traffic can still come from the old subscription. An entry made from a push has no earlier target its traffic could come from, until an update gives it one. So for that same reason, its traffic shows the transport open for a channel decided open or tuned out, and the mark goes. The host keeps which entries those are, as a set of the entriesensureChannelRegisteredmade, rather than reading the descriptor's metadata, which a server's own registration supplies. A registration makes a new entry, which isn't in the set, andhandleChanged's retarget takes an entry out of it. An update that keeps the target leaves it in.laterClause) now takes the entry. A channel made from a push may never be registered, so for one, the decision happens "if the server registers this channel at a time this host may open and close its channels", not "when the server next registers this channel".What callers notice besides the fix:
channel_closeof a push-registered channel decided closed, with no traffic, now gets the refusal, an error where it was answeredalready closed. That's the registered channel's answer in the same state: this host can't close the channel, and nothing has shown it's closed.channels/closeit didn't need, for a push-registered channel it holds closed. The registry takes any answer that isn't an error as the receipt, and doesn't readclosed. By our reading ofhandleChannelClose(src/server.tsat discord-mcpl'smain424b7ec), discord-mcpl answers a close of a channel it doesn't hold with{ closed: false }, not an error. Against a server that answered such a close with an error, the decline would report the failure incloseError, and the close would fail.What each part is for, and what was left out
registerEntryin place of the hand-built entry. Every entry then starts with the same mark, set in one place.main: the closed-channel invitation, the channel context, injection scoping under a conversation router, and the decline's refusal of an open channel. Without it, a push-only channel decided open or tuned out would stay not open until the agent spoke in it or calledchannel_open, since nothing else sends a request for it. Meanwhile those readers would answer as for a closed channel: a scoped conversation's agent would take in that channel's injections (the scoping test), and a decline would end its tune-out (the traffic test). By our reading, the invitation would also say the agent hadn't opened the channel.untargetedPushEntries): the host's own record of which entries the traffic condition covers, so a server's descriptor can't claim it, and an update that keeps the target doesn't end it.registerEntrynow returns its entry, so the push can add it.isDeclaredChannel, the label backfill andlaterClauseask a different question, whether the server has declared the channel, and keepmain's reading of it from the placeholder key a push's descriptor carries, now namedpushPlaceholder. A server's own descriptor doesn't normally carry that key but can, which is why the traffic condition reads the set instead.laterClause's "if": the refusal now reaches push-registered channels, which may never be registered.registerEntry's doc andensureChannelRegistered's name both, and the comments on when a recorded decision happens add the push's placeholder. ThehandleIncomingcomment that called the push's entry closed now says it isn't open, and a paragraph there that described registering an unknown channel from its first message, which the code below it refuses, is gone.conversation-fork-injection-scopingkeeps such a channel's injections out of a conversation fork's context once its traffic has come; itsinstallChannelRegistryhelper now takes the servers its registry reaches, so one can direct the lifecycle, and returns the registry. Two more pin the set: a registration, whether achannels/registerover a push's entry or achannels/changedregistration whose descriptor carries the placeholder key, still waits for a receipt; and an update that keeps a push entry's target leaves its traffic showing it open, while one that gives it a target waits for a receipt.maindoes the same when no request failed, since its push-made entry carries no mark. Telling the marks apart would take a second piece of state for that one window.framework.tsand the registry, and leaves a push-only channel with no end to its wait for a receipt.Tests
maind8cc6909, before the change, on Node 24.22.0, each case on a fresh channel, a push-registered one beside a registered one:remained closedand sent the server nothing, and a close after it answeredalready closedand sent nothing.channels.lifecycle, the push-registered channel's refused close saidChannel "dm-n" is closed. …, so it sent the server nothing., and the registered channel's said…, so this close of channel "reg-n" didn't go through, ….test/channel-lifecycle-order.test.ts,test/conversation-fork-injection-scoping.test.tsand the five other test files that callensureChannelRegisteredor stand in for it pass on Node 24.22.0: 338 tests.main'ssrc/mcpl/channel-registry.tsunder this head's tests, the three tests of a channel nothing has shown open fail. The other four pass there, sincemainshows such a channel open from its traffic too, and has no exception for a registration to take.channel_close's already-closed shortcut's reading of the mark: the close after a kept decision, and the refused close's second attempt;laterClause's "if": the refused close.ac256af4: Build & Test passes on ubuntu-latest and macos-latest, each with Node 20 and 24, beside the packed-release install check (https://github.com/anima-research/agent-framework/actions/runs/39338909844) and the changelog check (https://github.com/anima-research/agent-framework/actions/runs/39338909815).Not verified
Out of scope
ensureChannelRegisteredreturns early when it finds the id on any server. That belongs with Prose delivery and typing take the first server's channel for a bare channel id two servers share; the stream and channel tools refuse it #474's qualified channel ids.changelog.d/push-registered-channel-unconfirmed.fixed.md.Written by Nigel-2654 (Claude Opus 5.5). Cordelia-2434 (Claude Opus 5.5) found the defect in an internal review of #589, Ada-1017 (Claude Opus 5.5) made the design calls, and Desmond-2579 (Claude Opus 5.5) reviewed it at this head. Three rounds of our internal review read it, the last before the commit that changed only comments, the fragment and one test.
Description checked and posted by Daniel-2612 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.