Skip to content

fix(mcpl): an open by speech or a reply that fails puts back the decision it replaced - #568

Merged
nissa-seru merged 7 commits into
anima-research:mainfrom
nissa-seru:fix/failed-open-puts-back
Oct 19, 2026
Merged

nissa-seru merged 7 commits into
anima-research:mainfrom
nissa-seru:fix/failed-open-puts-back

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

This PR makes a failed open by speech or a reply put back the channel decision it replaced, and tells the agent when a reply's open fails. Two smaller changes come with it: the agent's channel_open, channel_close and channel_decline are recorded as its decisions even when the channel is already in that state, and channels/close gets the deadline channels/open has when requestTimeoutMs is 0.

Problem

When speech opens a closed channel and the server fails the open, the agent is told "channel is closed and open failed", but the host keeps its decision to open the channel. A later open then happens without the notice that tells the agent a channel is open and how to close it: the agent's next speech there, or reconciliation when the server announces the channel again. So an agent told the channel is closed starts getting its traffic. A reply whose open fails keeps the same decision, and the agent isn't told at all: the failure reaches only the logs, which say "a later registration or delivery tries the open again".

Changes

  • The put-back. An open by speech or a reply (openChannelNow with opened-by-delivery or opened-by-reply) whose round-trip fails puts back the decision it replaced, with that decision's source, through setDesiredState. The lifecycle log records it, so a restart replays it. A channel with no decision on record, such as one registered from a push, goes back to closed by default (default-closed, the source that means nobody decided).
  • Only while the record is its own. A decision made during the round-trip stands, such as the agent closing the channel, or opening it with channel_open.
  • On the wire too. A failed request may have reached the server, as when its answer is lost to a timeout, and then the server has opened the channel. So the decision that stands after the put-back is carried out through the channel's lifecycle queue, as a decline's close is, unless other lifecycle work for the channel is queued: that work reads the restored decision and carries it out anyway. Work queued before the server's registrations were removed doesn't count, since it cancels when it would run. applyThroughQueue is shared with the close of a substituted channel.
  • A default put back follows the server's latest announcement. While the open is out, a server announcing the channel again as initially open finds the open's own record, so ensureInitialDesiredState's lift writes nothing. Where the decision put back is the default, nobody had decided, so after the put-back the lift runs against the channel's current descriptor and the subscription policy. It leaves every real decision alone, as it always has. If they select the channel for opening, the queued lifecycle work opens it, and the failure says why: speech's says "the channel is selected for opening again, as its server announces it open" (or "as the subscription policy admits it"), and a reply's notice says the same. A lift that can't be recorded leaves the default, closed, and the open's own failure is reported.
  • A deadline for channels/close. When requestTimeoutMs is 0, channels/open keeps a deadline of its own, since a delivery waits for it. A delivery waits for a channel's queued closes too, so channels/close now keeps the same deadline. The put-back's close follows an open that may have failed for the server's silence, and without the deadline it could hold every later delivery into the channel for good. requestTimeoutMs's doc now names channels/close beside the two requests it already excepted.
  • A put-back that can't be recorded makes the failure say so: the decision stays, no close is sent, and the channel stays selected for opening, the phrase channel_open's own failure uses, so reconciliation may open it later.
  • A server without channels.lifecycle, or one that's gone. On a server without channels.lifecycle, such an open fails before any request leaves (§14.1), and a reply's does when its server is gone. Either way it now records nothing, where it used to record a decision and then put it back.
  • The agent's decisions are recorded as the agent's. A decision that records the state already on record writes nothing, unless it's the agent's own and the record isn't. The agent's own are channel_open's and channel_close's (agent-tool) and channel_decline's (invitation-declined). The record then says the agent decided, which is all two other readers ask of it: handleToolClose's explicit-open guard and ensureInitialDesiredState's lift of a default.
    • So channel_open on a channel already open, and channel_close on one already closed, record the agent's decision, and so does channel_decline on a channel closed by default.
    • Either answer says so if the record can't be written.
    • The put-back's check that the record is still its own depends on this rule: the agent's channel_open is the only decision of the same state that can be made during the round-trip, and it gets a new record.
  • A reply whose open fails, where that open would have opened a channel not selected open, tells the agent that the reply went out and opening the channel failed, and, to an agent that can call channel_open, that channel_open tries again. A conversation fork's channel_open is refused outright, and an agent's allowedTools can leave it out, so those agents aren't pointed to it. The notice sits beside the "opened by reply" notice of the success path. Before, a reply's failed open reached only the logs and a trace. openIfClosedForSend's open-failed answer says whether its open was such an opening (opening), so the notice doesn't depend on the decision the failure leaves: a put-back that couldn't be recorded still brings it. Where the channel is selected for opening after the failure, the answer says so and why (selectedForOpening: 'unrecorded', 'server-bootstrap' or 'subscription-policy'), and the notice says it too, as speech's failure does, with what that means: where the put-back couldn't be recorded, in channel_open's own failure's words, a later delivery or reconnect tries again; where the server's announcement or the policy selected it again, the host tries again itself, so channel_open isn't named there. Either way channel_close withdraws that, named only where the agent can call it for that channel: a fork may close only its home channel. If the replying agent has ended and the channel is selected for opening, the primary is told by name in its place, if one is registered, as the "opened by reply" notice tells it of an open; where the channel stays closed, no one is. Where the reader can't close the channel, as a fork away from its home can't, or an agent whose allowedTools leave channel_close out, the primary is told by name too, if it's registered and can close it. Whether the host directs the server's lifecycle is read before the open, since a transport that drops while the open is out clears the server's grant before the failure arrives. There are four cases with no notice:
    • A channel selected open or tuned out. The open only failed to reconfirm its transport, and that decision stands.
    • A server without channels.lifecycle. That server opens and closes its own channels, and channel_open can't open one either. ChannelRegistry.directsLifecycle is public, so the framework can ask it.
    • An open that a decision made during its round-trip replaced, such as the agent closing the channel in the same turn as its reply, since the open-on-send doesn't wait. That decision stands, and the failure comes as a ChannelOpenOvertakenError.
    • A reply that waited on queued lifecycle work which left the channel closed. No open was sent, and the channel is as that work's decision left it.
  • channel_open's own decision still outlives a failed round-trip, and its failure still says the channel stays selected for opening.

Who it reaches

  • Residents whose speech or replies open closed channels on a server that sometimes fails an open. A failed open now leaves the channel as it was, except where the host can't record that, or where the server announced the channel again as initially open (or the policy came to admit it) while the open was out, and then the agent is told the channel is selected for opening, and why.
    • After speech's failed open, the next speech's open gives the notice.
    • Speech that waits on such an open, into a channel the agent closed, is now refused. Before, it was published into that channel.
    • A reply's failed open now leaves the channel closed, unless its server's latest announcement or the policy selects it for opening, and brings a notice either way. Before, a later registration or delivery tried the open again and opened the channel without one.
  • Servers the host directs, whose opens for speech or a reply fail. The server gets a close, since the request may have reached it, or an open, where it announced the channel again as initially open while the failed open was out. On main that channel opened with no notice to the agent.
  • A primary agent, when another agent's reply fails to open a channel that is then selected for opening. If that agent has ended, the primary is told by name. If it can't close the channel, the primary is told too, where the primary can close it.
  • Hosts with requestTimeoutMs set to 0. A channels/close the server doesn't answer now fails after the deadline channels/open already had, where it used to wait for good.
  • Servers without channels.lifecycle. Speech or a reply that would open a channel there no longer writes lifecycle records.
  • Modules that issue housekeeping closes. A channel the agent opened with channel_open now refuses such a close, even where speech, a reply, the server or a policy had opened it first. And a module's close with a source outside the machine-close vocabulary, on a channel already closed, is now refused for its source, where it used to answer "already closed": the source is checked before that answer, which now records the decision.
  • A channel the agent closed with channel_close, or declined, while it was closed by default. It now stays closed when its server registers it again as initially open.

What each part is for

  • The put-back in openChannelNow's failure path is the fix.
  • The check that the record is still the open's own keeps a decision made during the round-trip.
  • The queued close makes the closed decision hold on the server when a failed request reached it.
  • The close's deadline keeps the put-back's close, and any close the server never answers, from holding the channel's deliveries for good.
  • The failure that says the channel stays selected, and the reply notice's sentence saying so, keep what the agent is told true when the store can't take the put-back.
  • The lift after the put-back keeps a server's latest announcement, and the policy, from being lost to a failed open that was out when they arrived.
  • The failure and notice that say the channel is selected for opening again keep "Not open" from being followed by the channel's traffic with no word of why.
  • Telling the primary when the replying agent has ended, or can't close the channel, lets an agent that could close it hear that it may open, where the primary is that agent.
  • Reading whether the host directs the lifecycle before the open keeps a transport that drops during the open from silencing the notice of that failure, at the cost of one grant read on every send into a channel.
  • Not counting work queued before a removal keeps the decision the put-back leaves from waiting on work that cancels.
  • The failure trace of queued work names the channel's decision when the work fails, so a lifted open that fails isn't logged as a close.
  • The grant check, and the check for the server, keep the channels of a server without channels.lifecycle, or of one that's gone, from collecting a record and its put-back for every failed open.
  • The same-state rule and the two "already" answers make the record say whether the agent decided, which the guard, the lift and the put-back each read.
  • The reply notice, and opening, tell the agent what a failed open leaves it with: a channel that isn't open, and the way to try again.
  • Naming channel_close in the stays-selected sentence, only where the agent can call it for that channel, gives an agent told the channel may still open the way to stop that.

Merge notes

  • main: this PR merges cleanly with main 593f5e71.
  • fix(mcpl): speaking no longer reopens a channel the agent closed #330 (speaking no longer reopens a channel the agent closed) changes the same code. A trial merge of its head, c10d89eb, with this PR merged into main 593f5e71 conflicts in framework.ts (9 regions, against 8 with main alone), channel-registry.ts (11, against 9) and test/present-while-acting.test.ts (1, against none).
  • fix(streaming): a stream goes where each publish went, and ends there #585 (a stream goes where each publish went) adds a name to the same import line of framework.ts. A trial merge of its head, 06f16800, with this PR merged into main 593f5e71 conflicts in that one line, against none with main alone.

Tests

  • The suite, at this head on Linux with Node 24.22.0: typecheck clean, 4800 tests, 4773 pass, 0 fail, 27 skipped.
  • Against main's source (9c6964c9), with this head's tests, 40 of channel-lifecycle-order's 110 tests fail, and mcpl-request-outcome's test of the close's deadline under 0 times out. Four new tests pass there, since each pins what main already does and this change keeps:
    • the agent's close made while speech's open is out stands;
    • a housekeeping close of a channel closed by default records nothing;
    • channel_open on a server without channels.lifecycle still records the agent's decision;
    • channels/close keeps a configured requestTimeoutMs.
  • Each piece, taken out on its own, fails a test:
    • the put-back (18 tests fail);
    • the check that the record is still the open's own (3), and the reply path's reading of ChannelOpenOvertakenError (2);
    • putting back the default's source instead of the replaced decision's (3);
    • putting back after a failed channel_open too (3);
    • the default for a channel with no decision on record (1);
    • the queued work that carries out the decision standing after the put-back (5), carrying it out even while other lifecycle work is queued (4), and counting work queued before the server's registrations were removed as work that will carry it out (1);
    • the removal count the queued-work check reads kept for the oldest outstanding operation rather than the newest (1), or kept after the channel's queue empties (1);
    • the lift after the put-back (5), and a lift that can't be recorded escaping the put-back (1);
    • the failure not saying the channel is selected for opening again (5), a reply's answer not saying so (5), and a policy lift not counted as one (3);
    • the queued work's failure trace naming a close whatever it carried out (1);
    • an incidental open on a server without channels.lifecycle recording a decision (2), or on a server that's gone (1), and that check applied to channel_open too (1);
    • a put-back that can't be recorded only logged (2), a reply's answer not saying the channel stays selected (1), or the notice not saying so (4), and channel_close named in it to an agent without it (3) or to a fork away from its home (2);
    • the notice not saying the channel is selected for opening again (4), and naming channel_open where the host tries again itself (4);
    • the same-state rule: any same-state decision writing nothing (9), any of another source writing (2), the agent's writing again over its own record (2), and a decline not counted as the agent's (2);
    • channel_open's "already open" answer recording nothing (4), and channel_close's "already closed" answer recording nothing (2);
    • a reply's failed open not told to the agent (2), told for a failed reconfirm too (1), told where the host doesn't direct the lifecycle (1), or whether the host directs it read after the open's failure (1);
    • a reply's failed reconfirm of a tuned-out channel reported as an opening (2);
    • channel_open named to a fork (2), or to an agent whose allowedTools leave it out (2), an unnamed reply's advice judged for no agent rather than the primary (1), and its log naming 'primary' rather than the agent that reads it (1);
    • where the replying agent has ended and the channel is selected for opening: no one told (1), the notice sent with no primary to read it (1), worded as the reply's own agent's (1), or without naming the agent that ended (1);
    • the primary not told where the reader can't close a channel selected for opening (1), or told where it can't close it either (1);
    • channels/close without the deadline under 0, or always with it: one test times out each way.

Not verified

  • A resident against a real server. The tests drive the registry with a fixture server, and the framework through a bare-framework harness for the reply notice. I haven't run a resident against discord-mcpl or another server with this branch.
  • Node 20 and macOS. My runs are on Linux with Node 24.22.0. CI runs the others.

Review

Reviewed at the Rookery: nine internal reviews of the change and its fixes, and a colleague's read of every round, with probes on this branch and on main.


  • Changelog fragment added: changelog.d/failed-speech-open-puts-back.fixed.md

— Petra-1012 (Claude Opus 5.5), contributing through nissa-seru

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

🤖 Written with Claude Opus 5.5 through the Rookery's tooling.

…sion it replaced

openChannelNow records the decision to open before the round-trip, so the
intent outlives a failed subscribe and reconciliation retries it. That is
right for channel_open, the agent's own decision, whose failure says the
channel stays selected. For an open that speech or a reply made it isn't.
Speech fails with "channel is closed and open failed", but the decision to
open stays. The channel's next open, by the agent's next speech or by
reconciliation when the server announces the channel again, then opens it
with no notice, since openChannelNow reports `opened: false` for a channel
already decided open. An agent last told the channel is closed starts
getting its traffic, and was never told it's open or how to close it.

The put-back:
- An open by speech or a reply whose round-trip fails puts back the
  decision it replaced, with that decision's source (such as
  `default-closed`, nobody having decided), through setDesiredState, so the
  lifecycle log records it and a restart replays it. A channel with no
  decision on record, as one registered from a push, goes back to closed
  by default.
- It puts it back only while the record is still the one it wrote, so a
  decision made during the round-trip, such as the agent closing or opening
  the channel, stands. The failure then comes as a
  ChannelOpenOvertakenError, with the open's own message.
- A failed request may have reached the server, as when its answer is lost
  to a timeout. So the put-back's close is carried out through the queue,
  as a decline's is, unless other lifecycle work for the channel is queued,
  which carries it out anyway (closeThroughQueue, shared with the close of
  a substituted channel).
- If the put-back can't be recorded, the failure says the channel stays
  selected for opening.
- Where the open fails before any request leaves, on a server without
  channels.lifecycle (§14.1) or one that's gone, it writes nothing, where
  it used to write a decision only to put it back.
- channel_open's own decision still outlives a failed round-trip.

channels/close gets the deadline channels/open has when requestTimeoutMs is
0, and requestTimeoutMs's doc says so. A delivery waits for a channel's
queued lifecycle work, closes included, so a close never answered would
hold it for good, and the put-back's close follows an open that may have
failed for that very silence.

The agent's decisions are recorded as the agent's:
- A decision that records the state already on record writes nothing,
  unless it's the agent's own (agent-tool, or invitation-declined from
  channel_decline) and the record isn't. The record then says the agent
  decided, which is all handleToolClose's explicit-open guard and
  ensureInitialDesiredState's lift of a default ask of it.
- So channel_open's "already open" answer and channel_close's "already
  closed" answer record the agent's decision, and so does a decline over a
  default. If either answer can't record it, it says so.
- A channel the agent opened explicitly, over an open by speech, a reply,
  the server or a policy, now refuses a module's housekeeping close. A
  channel the agent closed or declined over a default isn't lifted open by
  a later registration.
- channel_close's provenance check now runs before its "already closed"
  answer, which records closeSource, so a module close with an unknown
  machine source on an already-closed channel is refused for its source.
- The put-back's identity check depends on this rule: the agent's
  channel_open is the only decision of the same state that can be made
  during the round-trip, and it gets a new record.

A reply's failed open is told to the agent:
- A reply whose open would have opened a channel not selected open, and
  failed, tells the agent, on a server whose lifecycle the host directs:
  the reply went out, opening the channel failed, and channel_open tries
  again. It sits beside the opened-by-reply notice of the success path.
  Before, the failure reached only the console and a trace.
- openIfClosedForSend's open-failed answer says whether the open was such
  an opening (`opening`). A failed reconfirm of a channel already selected
  open or tuned out isn't one, and neither is an open that a decision made
  during its round-trip replaced, such as the agent's close in the same
  turn as its reply. A put-back that couldn't be recorded still is.
- Without channels.lifecycle the server opens and closes its own channels
  and channel_open can't open one, so there is no notice there.
  ChannelRegistry.directsLifecycle is public for the framework to ask.
- It names channel_open as the way to try again only to an agent that
  can call it (canAgentUseTool, dispatch's own gate): a conversation fork's
  channel_open is refused outright, and an agent's allowedTools can leave
  it out. The notice goes to the agent whose reply it was, or to the
  primary when none is named, as the opened notice does.

Tests, in test/channel-lifecycle-order.test.ts:
- speech whose open fails leaves the decision as it was, and the next
  speech's open gives its notice;
- the server announcing the channel again afterwards opens nothing;
- the agent's close, and the agent's channel_open, made while speech's
  open is out stand when that open fails;
- a reply whose open fails puts back the agent's close;
- a channel registered from a push is put back as closed by default;
- the put-back survives a rebuild from the store;
- when the server announces the channel again while speech's open is out,
  the queued reconcile carries out the put-back with one close;
- speech waiting on a failed open into a channel the agent closed is
  refused;
- an open whose answer is lost after the server opened the channel is
  closed on the wire too, and the agent's channel_open after it waits
  behind that close in the channel's queue;
- speech or a reply on a server without channels.lifecycle, and a reply
  whose server is gone, writes no decision and queues no close;
- channel_open on a server without channels.lifecycle still records the
  agent's decision;
- the agent's channel_open over a channel speech or the server opened is
  recorded as the agent's, so a housekeeping close is refused;
- the agent's channel_close, and its decline, over a default are recorded,
  and a later registration's initiallyOpen doesn't open the channel;
- a housekeeping close over a default records nothing, the agent's
  channel_open twice writes one record, and its channel_close over its own
  decline writes nothing;
- a module close outside the machine-close vocabulary is refused on a
  channel already closed too, where it got "already closed";
- a put-back that can't be recorded, and either "already" answer that
  can't record the agent's decision, say what failed;
- a reply's failed open that would have opened the channel tells the agent
  (with the notice's system metadata), and a failed reconfirm, or one on a
  server without channels.lifecycle, tells it nothing;
- the notice names channel_open only to an agent that can call it, so
  not to a fork or to an agent whose allowedTools leave it out, and an
  agent that has ended is told nothing; a notice with no agent named, or
  an empty name, goes to the primary and names channel_open as the
  primary may call it;
- a reply's failed reconfirm of a channel selected open or tuned out isn't
  an opening; a reply that waits on a queued close leaving the channel
  closed sends no open; a reply's open that the agent's close replaced
  during its round-trip, or that failed after the agent's channel_open
  during it, isn't an opening that failed; one whose put-back can't be
  recorded is.
In test/mcpl-request-outcome.test.ts: channels/close keeps a deadline when
requestTimeoutMs is 0, and keeps a configured requestTimeoutMs otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[Medium impact] The changes affect channel lifecycle work for agents and connected servers.

The PR appears safe to merge, with a non-blocking gap in the reply notice when a rollback cannot be saved.

Findings

  1. P2 Reply hides the open decision ▶
Fix with agent prompt
### Issue 1
src/mcpl/channel-registry.ts:3195-3196
If saving the rollback fails, `openChannelNow` keeps the open decision and adds a warning that the channel stays selected for opening. This return drops that warning, so the reply notice says only “Not open” and suggests retrying. A later registration can then open the channel without another notice.

Return whether the channel remains selected and include that warning in the reply notice, as the speech failure already does.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Restores the previous channel decision after failed opens caused by speech or replies.

  • Failed speech and reply opens restore the channel's earlier choice.
  • Agent choices stay recorded even when a channel is already in that state.
  • A failed reply open now tells the agent what happened.
  • Channel closes now stop waiting when a server never answers.

Acknowledged by nissa-seru: an unsaved rollback intentionally keeps the open decision. The finding concerns the missing agent-facing warning, not that fallback.

Validation was by code inspection; tests were not run.

Reviews (1) · Last reviewed commit: "fix(mcpl): an open by speech or a reply ..." · Reviewed by Greptile

Comment on lines +3195 to 3196
return { status: 'open-failed', channelId: entry.descriptor.id, opening,
...(current ? { label: current.descriptor.label } : {}) };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Reply hides the open decision

If saving the rollback fails, openChannelNow keeps the open decision and adds a warning that the channel stays selected for opening. This return drops that warning, so the reply notice says only “Not open” and suggests retrying. A later registration can then open the channel without another notice.

Return whether the channel remains selected and include that warning in the reply notice, as the speech failure already does.

Knowledge Base Used: MCPL transport and server registration

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/channel-registry.ts
Line: 3195-3196

Comment:
**Reply hides the open decision**

If saving the rollback fails, `openChannelNow` keeps the open decision and adds a warning that the channel stays selected for opening. This return drops that warning, so the reply notice says only “Not open” and suggests retrying. A later registration can then open the channel without another notice.

Return whether the channel remains selected and include that warning in the reply notice, as the speech failure already does.

**Knowledge Base Used:** [MCPL transport and server registration](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/mcpl-transport-and-registration.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 079f1981. Where the put-back can't be recorded, openChannelNow now throws ChannelOpenPutBackUnrecordedError with the same message as before, so speech's failure text doesn't change. openIfClosedForSend's open-failed answer carries staysSelected: true for that error. The reply notice then says so, borrowing the words channel_open's own failure uses for the same state: "It stays selected for opening, as the host couldn't record undoing that: a later delivery or reconnect tries again, and channel_close withdraws that." It names channel_close only to an agent that can call it for that channel. In test/channel-lifecycle-order.test.ts, the test of a reply whose put-back can't be recorded expects staysSelected: true, and the notice tests cover the sentence for agents with and without channel_open and channel_close.

— Lottie-2661

Petra-1012 and others added 2 commits October 19, 2026 07:40
…stays selected

When the store refuses the put-back's record, openChannelNow keeps the open
decision, and speech's failure says the channel stays selected for opening.
The reply path dropped that: openIfClosedForSend answered `open-failed`
alone, so the reply notice said only that the channel wasn't open, though
a later registration may still open it.

- openChannelNow throws ChannelOpenPutBackUnrecordedError there, with the
  same message as before, so speech's failure text is unchanged.
- openIfClosedForSend's `open-failed` answer carries `staysSelected: true`
  for it.
- The reply notice then adds that the channel stays selected for opening,
  as the host couldn't record undoing that, and, in channel_open's own
  failure's words for a decision that outlives a failed open, that a later
  delivery or reconnect tries again and channel_close withdraws that.
  channel_close is named only where it would run: an agent whose
  allowedTools hold it, and a conversation fork only for its home channel,
  as dispatchChannelToolCall reads it.
- The notice's log line names the agent its advice was judged for,
  which is where addMessage delivers it, rather than 'primary'.
- The changelog's first bullet says that where the put-back can't be
  recorded, the decision stays and no close is sent.

Tests, in test/channel-lifecycle-order.test.ts: a reply's failed open
whose put-back can't be recorded answers `staysSelected: true`; the notice
for such an answer says the channel stays selected, names channel_close
to an agent that can call it, and leaves it out for an agent without it
and for a fork away from its home; and the notice with no agent named, or
an empty name, is judged for, and logged as read by, the agent addMessage
delivers it to: the primary, else the first registered agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The changelog says that where the host can't record putting a failed
open's decision back, the decision stays and no close is sent. The two
tests of that case, for speech and for a reply, asserted the failure's
words and the decision, and now also assert that no `channels/close`
goes out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@Anarchid @slimepriestess We're asking one of you to review this pull request before we merge it.

When speech or a reply opens a closed channel and the open fails, the host today keeps its decision to open it. The agent's next speech there, or the server announcing the channel again, then opens it without the notice that says it's open. With this pull request the failed open puts back the decision it replaced, and a reply whose open failed tells the agent.

Four things that work today change, as "Who it reaches" in the description says:

  • channel_open on a channel that speech, a reply, the server or a policy had opened now records the agent's decision, so a module's housekeeping close of it is refused, as it already is for a channel the agent opened from closed.
  • A channel the agent closed or declined while it was closed by default stays closed when its server registers it again as initially open.
  • A module's close with a source outside the machine-close vocabulary, on a channel already closed, is refused for its source rather than answered "already closed".
  • With requestTimeoutMs set to 0, channels/close keeps the deadline channels/open already has.

— Lottie-2661

@slimepriestess slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approve at d1df7b76, with one finding below that we'd like a look at.

An agent told "the channel is closed and the open failed" shouldn't then start receiving that channel's traffic. This fixes that at the decision, with the wire close and the agent's notice to match.

What we ran, on macOS with Node 20.20.2 (one of CI's legs; your runs were Linux with Node 24): npm install, npm run build, npm test. 4,794 tests, 4,767 pass, 0 fail, 27 skipped.

Each piece undone alone, rebuilt, and channel-lifecycle-order and mcpl-request-outcome re-run (112 tests, all green at the head):

  • The put-back's setDesiredState(replaced…) removed: 11 fail.
  • The check that the record is still the open's own, made always true (never overtaken): 3 fail.
  • The put-back's close through the queue removed: 2 fail.
  • openChannelNow's grant check for an incidental open removed: 2 fail.
  • setDesiredState's same-state rule back to main's (any same-state decision writes nothing): 9 fail.
  • channel_open's "already open" record removed: 4 fail. channel_close's "already closed" record removed: 2 fail.
  • staysSelected never set: 1 fails.
  • channels/close without its deadline under requestTimeoutMs: 0: the deadline test times out (cancelled).
  • The reply notice never sent (tell false): 2 fail. channel_open named to a fork: 1 fails.

Eleven of your pieces, each red on its own, at counts that match your list where you gave one (11, 9, 4, 2).

Probes that came back as described (registry against dist, with your test file's fixture server):

  • Two replies in flight on one closed channel, the first open failing. The first gets opening: true and the notice. The second gets opening: false and no second notice. Exactly one put-back close goes out (calls [close, open, close], the first being the registration's), so skipping closeThroughQueue while work is outstanding doesn't lose the close or send it twice.
  • The agent's channel_close, then speech engaging and failing, then a restart. The log replays closed/agent-tool, and a later initially-open registration leaves it closed.
  • A housekeeping close over an agent-closed record answers "already closed" and writes nothing.

Finding: an initially-open re-announcement during the round-trip is lost to the put-back. replaced is taken before the request (channel-registry.ts ~2905), and the put-back restores it as it was. Our sequence:

  1. A reply opens a closed channel with no decision on record, which writes opened-by-reply.
  2. While its channels/open is out, the server announces the channel again with initiallyOpen: true. ensureInitialDesiredState lifts only a default-closed or absent record, so it writes nothing.
  3. The open fails. The put-back restores default-closed, and the queued close goes out.

The record ends {closed, default-closed}, with calls [close, open, close]. The same re-announcement with no failed open in between gives {open, server-bootstrap}. Removing the server and re-adding it as initially open during the round-trip ends the same way. So the server's bootstrap preference is lost until its next registration.

We don't think this blocks: it fails toward closed, the agent is told "Not open" and how to try again, and on main the same race leaves the channel open with no notice, the bug this PR fixes. Your re-announce test ("…while the server announces the channel again…") uses initiallyOpen: false, so nothing pins this case either way. One shape that would cover it: when replaced.source is default-closed, run the lift against the channel's current descriptor after the put-back.

Two smaller notes:

  • The record-identity check catches the agent's own same-state decision during the round-trip (channel_open now always writes a fresh record over a non-agent one), but not a non-agent one, which writes nothing. That's why the case above isn't seen as overtaking. It's correct for everything the description names; we're noting the boundary.
  • If the replying agent has ended and the put-back couldn't be recorded (staysSelected), recordReplyOpenFailedNotice tells no one, and a later reconcile may open the channel. recordChannelAutoOpenNotice's rule is that no channel opens without someone who can close it being told. This is an edge on an edge (a store-write failure plus an ended agent), so we only mention it.

Read and found sound:

  • The put-back only ever restores a closed decision: wrote is set only when the open turned a closed channel open.
  • channel_close's "already closed" answer now comes after the source check, as "Who it reaches" says.
  • The notice is gated on opening and directsLifecycle. Its fork gating matches dispatchChannelToolCall, and its allowedTools gating reads canAgentUseTool, which agentToolPermissionFailure enforces.

Thank you for the "Who it reaches" section: it made the behaviour changes for modules reviewable on their own.

Petra-1012 and others added 3 commits October 19, 2026 14:43
… latest announcement

slimepriestess's review of d1df7b7 found that an initially-open
re-announcement during a failed incidental open is lost to the put-back:
the lift (ensureInitialDesiredState) finds the open's own record and
writes nothing, and the put-back then restores `default-closed`, so the
server's bootstrap preference waits for its next registration.

- After a put-back, the registry runs the lift against the channel's
  current descriptor. A default-closed record means nobody decided, so it
  follows the server's latest announcement and the subscription policy;
  every real decision is left alone, as the lift already does. A lift that
  can't be recorded leaves the default, closed, and the open's own failure
  is reported.
- Where the lift selects the channel for opening, the queued lifecycle work
  opens it (closeThroughQueue becomes applyThroughQueue, and its trace
  names the decision it carries out), and the failure says why:
  ChannelOpenSelectedAgainError for speech, and `selectedForOpening` on
  openIfClosedForSend's answer, which replaces `staysSelected` with the
  reason: 'unrecorded', 'server-bootstrap' or 'subscription-policy'.
- The reply notice says the channel is selected for opening again and why,
  and that channel_close withdraws that; channel_open isn't named there,
  since the host tries again itself.
- From the review's second note: where the replying agent has ended and
  the channel is selected for opening, the primary is told by name, as
  recordChannelAutoOpenNotice tells it of an open; where the channel stays
  closed, no one is told, as before.
- By the same rule, where the reply's reader can't close a channel
  selected for opening, as a fork away from its home can't, or an agent
  whose allowedTools leave channel_close out, the primary is told by name
  too, where it's registered, isn't the reader and can close it.

Tests: the reviewer's sequence for speech and a reply; the server removed
and added again as initially open; the subscription policy; an agent's
close put back and left alone; a lift that can't be recorded; and the
notice's new wording, routing and advice, including a reader who can't
close the channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and the reply notice reads the grant before the open

- The decision a failed open's put-back leaves is carried out through the
  queue unless other lifecycle work for the channel will carry it out. Work
  queued before the server's registrations were removed cancels when it
  would run, so it no longer counts: lifecycleQueuedRemovals keeps the
  removal count the newest outstanding operation was queued under. Before,
  a reconcile queued behind the open, a removal and the channel back from a
  push left neither the lifted open nor the put-back's close sent.
- The framework reads whether the host directs the server's lifecycle
  before the reply's open. On transport loss the connection clears its
  grant before it rejects the pending open, so read after the failure it
  said no, and no reader was told the open failed. The send-engagement
  test's stub registry gains directsLifecycle, which the path now reads on
  every send into a channel.
- Text: the reply notice's comments, the changelog and the description say
  when the primary is told (registered; for a reader who can't close, able
  to close it), rather than stating the rule as a guarantee.

Tests: the removal sequence, with and without a policy that lifts the
default; a lifted open that fails traced as an open; a grant dropped while
the open is out; and notice cases that pin the primary's own advice, a
reader who can close, a primary that can't open, and a primary named but
not registered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… holds after a removal or a lost grant; tests pin the queue's counts

From the review of 1cd1d52, and Cordelia-2434's read of this round:
- outstandingWorkCarriesOut's doc says it answers for now: a removal after
  it cancels that work too and takes the channel's entry, so the next
  registration carries the decision out, where the host directs the
  lifecycle then (reconcileChannels returns at its §14.1 check otherwise).
- The put-back's comment says an incidental open got there where the host
  directed the lifecycle when it opened, and that where the grant has lost
  channels.lifecycle since (a transport drop clears it), the queued work
  fails for it and the next registration carries the decision out, on the
  same condition.
- The queue-release test asserts lifecycleQueuedRemovals empties with the
  queue, and the re-registration test asserts the one open after the failed
  open, so the newest operation's count decides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru force-pushed the fix/failed-open-puts-back branch from c69cb65 to dc9a234 Compare October 19, 2026 18:47
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@slimepriestess Your finding and your note about the ended agent are both addressed in c55b32dc. Your note about the record-identity check needed nothing more once the finding was fixed. Two commits follow: 42ee1677 fixes two defects our own review of c55b32dc found, and dc9a2342 corrects two comments and adds two test assertions from the review of 42ee1677.

  • The initially-open re-announcement during the round-trip. Addressed in c55b32dc, in the shape you suggested.
    • The lift: after the put-back, the registry runs the lift against the channel's current descriptor. A default put back then follows the server's latest announcement and the subscription policy, not the ones from before the open. The lift leaves every real decision alone, as it already did.
    • What the agent is told: where the lift selects the channel for opening, the queued lifecycle work opens it, and the failure says why, since "Not open" followed by the channel's traffic is what this PR fixes. Speech's reason says "the channel is selected for opening again, as its server announces it open". The reply's notice says the same, and, to an agent that can call it, that channel_close withdraws that.
    • Tests:
      • your sequence, for speech and for a reply: {open, server-bootstrap}, with calls [close, open, open];
      • the server removed and added again as initially open;
      • the subscription policy admitting the channel while the open is out;
      • the agent's own close put back, which the lift leaves alone;
      • a lift that can't be recorded, which leaves the default closed.
  • The ended replying agent, with the channel selected for opening. Addressed in c55b32dc.
    • The ended agent: the primary is now told by name, if one is registered, as recordChannelAutoOpenNotice does, with its advice judged for the primary. Where the channel stays closed, no one is told, as before.
    • A reader who can't close the channel: by the same rule, where the reply's reader can't close it, the primary is told by name too, if it's registered and can close it. That covers a fork away from its home, and an agent whose allowedTools leave channel_close out.
  • The record-identity check's boundary. Nothing more was needed. The lift was the one decision that writes nothing over the open's own record, and it now runs after the put-back. The agent's close and its channel_open during the round-trip each write a record, so the check sees them, as the tests "the agent's close made while speech's open is out stands when that open fails" and its channel_open counterpart show.

What 42ee1677 adds:

  • Queued work that cancels no longer counts. The put-back skips queuing its own work when other lifecycle work for the channel will carry out its decision. Work queued before the server's registrations were removed cancels when it would run, so it no longer counts. Before, a reconcile queued behind the open, then a removal, then the channel back from a push, left neither the lifted open nor the put-back's close sent.
  • The grant is read before the open. The reply notice now reads whether the host directs the server's lifecycle before the open, not after its failure. On transport loss, the connection clears its grant before it rejects the pending open, so the notice had been skipped for exactly that failure.

Two names changed in these commits. openIfClosedForSend's staysSelected is now selectedForOpening, which carries the reason. closeThroughQueue is now applyThroughQueue, since it can carry out an open.

At dc9a2342, on Linux with Node 24.22.0: typecheck clean, 4800 tests, 0 fail. Each of the 50 pieces, undone on its own, fails a test, the 18 these commits add among them. The description is updated.

Written by Petra-1012.

— Lottie-2661

Joins framework.ts's import from ./mcpl/channel-registry.js, where main
added destinationKey and this branch added type SelectedForOpening.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
@nissa-seru
nissa-seru merged commit 6383562 into anima-research:main Oct 19, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…words

anima-research#568 merged first: a failed open by speech or a reply puts back the
decision it replaced. It added three error classes for that open's
failures, where this change carries every lifecycle failure as one
ChannelLifecycleError with a kind, so in this merge they become kinds:

- overtaken (was ChannelOpenOvertakenError): a decision made during the
  open's round-trip replaced the one it wrote. That decision stands and
  nothing is put back. The failure carries the open's own clause, with no
  step.
- put-back-unrecorded (was ChannelOpenPutBackUnrecordedError): putting
  back the replaced decision couldn't be recorded, so the channel stays
  selected for opening. Its words are the open's own clause and step,
  then "; the channel stays selected for opening, as this host couldn't
  record undoing that: <the store's words>".
- selected-again (was ChannelOpenSelectedAgainError): the default put
  back is lifted by the channel's current descriptor or the subscription
  policy. Its words are the open's own clause and step, then "; the
  channel is selected for opening again, as its server announces it
  open" or "as the subscription policy admits it". ChannelLifecycleError
  gains an optional `by` for it.

openIfClosedForSend reads the kinds where it read the classes, so its
answers (opening, selectedForOpening) are as anima-research#568 has them. The
round-trip's superseded open throws this change's superseded failure
inside anima-research#568's try, so it goes through the put-back's catch. anima-research#568's
refusal of an incidental open before anything is written keeps its check
(no server, or no channels.lifecycle), and lifecycleRefusal words it, as
every other lifecycle refusal here. SelectedForOpening keeps its export.
anima-research#568's tests of speech's reasons, the open-failed trace and channel_open's
refusal take this change's words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…e channel_open's old words

anima-research#568's pending fragment, and the comment on the reply's open-failed notice
in framework.ts, said the notice gives "a later delivery or reconnect tries
again" in channel_open's own failure's words, and the fragment said speech
tells the agent the channel is closed. After this branch's merge of main,
channel_open's failure says when reconciling applies the recorded open, and
speech's reason says the open for its words didn't complete, so both now
say only what the notice and the reason say.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…ds its sentence

Our internal review of the merge with anima-research#568 found these, each fixed:

- When an open by speech or a reply fails after a decision made during
  its round-trip replaced the one it wrote, its failure now says that
  decision stands. A superseded open's own clause already names the
  decision, so it's left as it is. The failure offers no step, since what
  helps turns on that decision.
- A server's own error words keep their final stop. The words a failure
  adds after them now start a sentence of their own (afterClause), where
  they followed the stop with a semicolon: that the channel stays, or is
  again, selected for opening, or that the decision made meanwhile stands.
- The put-back's two failures carry anima-research#568's words after the open's own
  clause, and no longer the open's step, which no test covered.
- The decline's refusal of a channel that's open now says "If you need it
  closed, call channel_close." It said "channel_close closes it.", which
  claimed more than the call does for a server that isn't up: there
  channel_close records the close.
- When a de-listing or this host's disconnect cuts short a decline's
  close, and the channel registers again meanwhile, the decline now says
  its registration went and came back. It said the channel isn't
  registered here any more.

Tests: the overtaken failure's reason and trace, an overtaken open whose
own clause names the later close once, a selected-again failure after a
server's words ending in a stop, the policy's selected-again clause in the
trace, the decline's refusal of an open channel, and the decline whose
registration came back during its close.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 24, 2026
…s rule on main's channel machinery

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants