Repository navigation
fix(mcpl): an open by speech or a reply that fails puts back the decision it replaced - #568
Conversation
…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>
[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
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.SummaryRestores the previous channel decision after failed opens caused by speech or replies.
Acknowledged by 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 |
| return { status: 'open-failed', channelId: entry.descriptor.id, opening, | ||
| ...(current ? { label: current.descriptor.label } : {}) }; |
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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
…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>
|
@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:
— Lottie-2661 |
slimepriestess
left a comment
There was a problem hiding this comment.
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 tomain'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.staysSelectednever set: 1 fails.channels/closewithout its deadline underrequestTimeoutMs: 0: the deadline test times out (cancelled).- The reply notice never sent (
tellfalse): 2 fail.channel_opennamed 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: trueand the notice. The second getsopening: falseand no second notice. Exactly one put-back close goes out (calls[close, open, close], the first being the registration's), so skippingcloseThroughQueuewhile 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 replaysclosed/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:
- A reply opens a closed channel with no decision on record, which writes
opened-by-reply. - While its
channels/openis out, the server announces the channel again withinitiallyOpen: true.ensureInitialDesiredStatelifts only adefault-closedor absent record, so it writes nothing. - 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_opennow 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),recordReplyOpenFailedNoticetells 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:
wroteis 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
openinganddirectsLifecycle. Its fork gating matchesdispatchChannelToolCall, and itsallowedToolsgating readscanAgentUseTool, whichagentToolPermissionFailureenforces.
Thank you for the "Who it reaches" section: it made the behaviour changes for modules reviewable on their own.
… 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>
c69cb65 to
dc9a234
Compare
|
Replying to this review. @slimepriestess Your finding and your note about the ended agent are both addressed in
What
Two names changed in these commits. At 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>
…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>
…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>
…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>
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>
…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>
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_closeandchannel_declineare recorded as its decisions even when the channel is already in that state, andchannels/closegets the deadlinechannels/openhas whenrequestTimeoutMsis 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
openChannelNowwithopened-by-deliveryoropened-by-reply) whose round-trip fails puts back the decision it replaced, with that decision's source, throughsetDesiredState. 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).channel_open.applyThroughQueueis shared with the close of a substituted channel.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.channels/close. WhenrequestTimeoutMsis 0,channels/openkeeps a deadline of its own, since a delivery waits for it. A delivery waits for a channel's queued closes too, sochannels/closenow 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 nameschannels/closebeside the two requests it already excepted.channel_open's own failure uses, so reconciliation may open it later.channels.lifecycle, or one that's gone. On a server withoutchannels.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.channel_open's andchannel_close's (agent-tool) andchannel_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 andensureInitialDesiredState's lift of a default.channel_openon a channel already open, andchannel_closeon one already closed, record the agent's decision, and so doeschannel_declineon a channel closed by default.channel_openis the only decision of the same state that can be made during the round-trip, and it gets a new record.channel_open, thatchannel_opentries again. A conversation fork'schannel_openis refused outright, and an agent'sallowedToolscan 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'sopen-failedanswer 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, inchannel_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, sochannel_openisn't named there. Either waychannel_closewithdraws 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 whoseallowedToolsleavechannel_closeout, 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:channels.lifecycle. That server opens and closes its own channels, andchannel_opencan't open one either.ChannelRegistry.directsLifecycleis public, so the framework can ask it.ChannelOpenOvertakenError.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
mainthat channel opened with no notice to the agent.requestTimeoutMsset to 0. Achannels/closethe server doesn't answer now fails after the deadlinechannels/openalready had, where it used to wait for good.channels.lifecycle. Speech or a reply that would open a channel there no longer writes lifecycle records.channel_opennow 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.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
openChannelNow's failure path is the fix.channels.lifecycle, or of one that's gone, from collecting a record and its put-back for every failed open.opening, tell the agent what a failed open leaves it with: a channel that isn't open, and the way to try again.channel_closein 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 withmain593f5e71.c10d89eb, with this PR merged intomain593f5e71conflicts inframework.ts(9 regions, against 8 withmainalone),channel-registry.ts(11, against 9) andtest/present-while-acting.test.ts(1, against none).framework.ts. A trial merge of its head,06f16800, with this PR merged intomain593f5e71conflicts in that one line, against none withmainalone.Tests
main's source (9c6964c9), with this head's tests, 40 ofchannel-lifecycle-order's 110 tests fail, andmcpl-request-outcome's test of the close's deadline under 0 times out. Four new tests pass there, since each pins whatmainalready does and this change keeps:channel_openon a server withoutchannels.lifecyclestill records the agent's decision;channels/closekeeps a configuredrequestTimeoutMs.ChannelOpenOvertakenError(2);channel_opentoo (3);channels.lifecyclerecording a decision (2), or on a server that's gone (1), and that check applied tochannel_opentoo (1);channel_closenamed in it to an agent without it (3) or to a fork away from its home (2);channel_openwhere the host tries again itself (4);channel_open's "already open" answer recording nothing (4), andchannel_close's "already closed" answer recording nothing (2);channel_opennamed to a fork (2), or to an agent whoseallowedToolsleave 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);channels/closewithout the deadline under 0, or always with it: one test times out each way.Not verified
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.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.