Skip to content

fix(mcpl): a channel known only from a push no longer counts as confirmed closed - #601

Merged
nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/push-channel-unconfirmed
Oct 19, 2026
Merged

nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/push-channel-unconfirmed

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

A channel this host knows only from a push now starts with its transport unconfirmed, as a registered one does. So channel_close and channel_decline send the server its close, or refuse as for a registered channel, where on main they sent nothing and took the channel as closed.

Problem

A channel this host knows only from a push event, whose server sends a push/event from it without registering it, gets its entry from ensureChannelRegistered. That entry was set with open: false directly. An entry made for a registration (channels/register, channels/changed) comes from registerEntry, 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 to ensureChannelRegistered, and the server may still hold a channel an earlier process opened.

Reproduced on main d8cc6909 (see Tests):

  • Where this host directs the channel's lifecycle (its server's grant includes channels.lifecycle):
    • channel_decline answered remained closed and sent the server nothing.
    • channel_close of a channel decided closed, as by a decision kept from an earlier process, answered already closed and sent nothing.
  • Where it doesn't, a close refused for the grant said 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

  • ensureChannelRegistered makes its entry through registerEntry, keeping its lazyRegistered metadata and its mcpl:channel-lazy-registered trace. A push-registered channel then starts unconfirmed, as a registered one does:
    • where this host directs the lifecycle, channel_decline in a channel that isn't open sends its close through the lifecycle queue, and its answer is still remained closed, and channel_close of a channel decided closed sends the close;
    • where it doesn't, channel_close gets the registered channel's refusal, whether or not a close is already recorded.
  • Its traffic shows it open, as on 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 entries ensureChannelRegistered made, 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, and handleChanged's retarget takes an entry out of it. An update that keeps the target leaves it in.
  • The refusal's account of when a recorded decision happens (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:

  • Where this host doesn't direct the lifecycle, channel_close of a push-registered channel decided closed, with no traffic, now gets the refusal, an error where it was answered already 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.
  • A server can now get a channels/close it 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 read closed. By our reading of handleChannelClose (src/server.ts at discord-mcpl's main 424b7ec), 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 in closeError, and the close would fail.

What each part is for, and what was left out

  • registerEntry in place of the hand-built entry. Every entry then starts with the same mark, set in one place.
  • The traffic condition. For a channel whose traffic has come, it keeps every reader of the open flag as on 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 called channel_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.
  • The set of push entries no update has given a target (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. registerEntry now returns its entry, so the push can add it. isDeclaredChannel, the label backfill and laterClause ask a different question, whether the server has declared the channel, and keep main's reading of it from the placeholder key a push's descriptor carries, now named pushPlaceholder. 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.
  • The comments say why a push's entry starts unconfirmed and why its traffic still shows it open. registerEntry's doc and ensureChannelRegistered's name both, and the comments on when a recorded decision happens add the push's placeholder. The handleIncoming comment 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.
  • The changelog fragment: CONTRIBUTING asks for one for a behaviour change.
  • The tests: one per reader of the mark, each with the server still holding the channel open in the fixture's model of its transport. They are a close after a decision kept across a restart through a store, a decline after traffic that, with nothing decided, leaves the channel not open, and a close refused for the grant, tried twice so that the second meets the decision the first recorded. A traffic test shows a tuned-out push channel open whether or not this host directs the lifecycle, refuses its decline and keeps the tune-out. A scoping test in conversation-fork-injection-scoping keeps such a channel's injections out of a conversation fork's context once its traffic has come; its installChannelRegistry helper 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 a channels/register over a push's entry or a channels/changed registration 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.
  • Left out: telling the push's own mark from later ones. The condition skips the mark whatever set it: the push's entry, a lifecycle request that failed, a receipt or failure from an older connection or target, or a server that answered for another id. A push-made entry has no target, and traffic on its current transport is the server delivering that id, so that's right for traffic on the current transport. The one interleaving where it goes wrong: a coalesced message waits through the coalescer's await while its transport drops, a lifecycle request fails on the drop and marks the channel, and the next transport is a fresh server that doesn't hold the channel. The stale message then shows the channel open. main does 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.
  • Left out: one rule for what traffic proves, with each reader of the open flag changed instead. The invitation and scoping would read the decision, and the decline would refuse a tuned-out channel. That changes several places in framework.ts and the registry, and leaves a push-only channel with no end to its wait for a receipt.

Tests

  • Probes on main d8cc6909, before the change, on Node 24.22.0, each case on a fresh channel, a push-registered one beside a registered one:
    • Where this host directs the lifecycle, a decline of the push-registered channel answered remained closed and sent the server nothing, and a close after it answered already closed and sent nothing.
    • On a server whose grant lacks channels.lifecycle, the push-registered channel's refused close said Channel "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, ….
  • At this head, test/channel-lifecycle-order.test.ts, test/conversation-fork-injection-scoping.test.ts and the five other test files that call ensureChannelRegistered or stand in for it pass on Node 24.22.0: 338 tests.
  • Red first. With main's src/mcpl/channel-registry.ts under this head's tests, the three tests of a channel nothing has shown open fail. The other four pass there, since main shows such a channel open from its traffic too, and has no exception for a registration to take.
  • Each piece, removed alone, builds and fails its tests:
    • the in-effect answer's reading of the mark: the refused close;
    • the traffic condition: the traffic test, the update test and the scoping test;
    • adding the push's entry to the set: the same three;
    • the retarget's removal of an entry from the set: the update test;
    • reading the placeholder key instead of the set: the update test and the registration test;
    • carrying the set's membership into a registration's new entry: the registration test;
    • the guard that traffic shows only a channel decided open or tuned out: the decline after traffic;
    • channel_close's already-closed shortcut's reading of the mark: the close after a kept decision, and the refused close's second attempt;
    • the decline's condition for sending its close: the decline;
    • laterClause's "if": the refused close.
  • The whole suite at this head on Node 24.22.0: 5,618 tests, 42 of them skipped, none failing.
  • CI at 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

  • A real restart against a live server. The tests and probes stand in for the server's transport with an in-memory model.

Out of scope


  • Changelog fragment added: 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-seru account.

Nigel-2654 and others added 4 commits October 19, 2026 19:00
… 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>
@nissa-seru
nissa-seru merged commit 9d1c65a into anima-research:main Oct 19, 2026
6 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant