Repository navigation
fix(tune-out): tune_out opens a closed channel's transport, and channel_decline refuses a tuned-out channel - #632
Conversation
…when it can't A tuned-out channel's traffic must keep arriving for the subconscious (TuneOutParams), yet tune_out only recorded the tune-out and answered success. Nothing in its path opened a closed transport: reconciling did, at the server's next registration of the channel or a retarget. So a tune-out of a channel the agent had closed diverted nothing until then, while the tool said it had, and so did one recorded while a decline's acknowledgment was out, since the decline then sends nothing for a decision it no longer holds. - ChannelRegistry.openForTuneOut makes the transport match a recorded tune-out. A transport that's open with no lifecycle work pending needs nothing. Where outstanding work will carry the decision out, it waits for that work rather than queue a second open; otherwise the channel's lifecycle queue opens it, since applyDesiredChannelState reads a tune-out as open. It writes no decision, so the tune-out stands whatever becomes of the open. - tune_out's answer waits for it. Where the transport doesn't end open, the answer is still a success, since the tune-out is recorded, and its openError says why none of the channel's messages reach the subconscious yet and when they will: on a server whose channels this host may not open, only as that server sends them; while the server isn't up, or after a failed open or a registration that went, when the server next registers the channel; and where a later decision replaced the tune-out while its open was out, that the decision stands. - outstandingWorkCarriesOut's doc says what it answers where a push re-makes an entry that a removal took while an RPC for the old entry was out. - The tune-out fixture server keeps its channel's lifecycle: it records opens and closes, and sends nothing for a closed channel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…transport A decline of a tuned-out channel whose transport wasn't open passed the decline's check, which looked only at an open or decided-open channel. It recorded closed over the tune-out and answered "remained closed", which ended the tune-out without its cancel, so the backlog the tune-out had diverted never reached the agent. A tuned-out channel's transport isn't open where tune_out's open was refused or failed, while that open is out, after a retarget or a dropped connection, or for a channel made from a push before its first traffic. channel_decline now refuses a channel decided tuned out, before its other checks, in the words channel_open's refusal of a tuned-out channel uses, with the same data: tune_out's cancel ends a tune-out and delivers its backlog. A tuned-out channel that's open was refused already, as open; it now gets the same refusal, which names the call that helps. Tests: a decline of a tuned-out channel is refused, open or not, and changes nothing; through the framework, a decline of a tuned-out channel whose transport isn't open is refused and the diverted backlog reaches the agent at the cancel; and a tune-out made while a decline's acknowledgment is out opens the channel once, by the tune-out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s one Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… from other work's errors From our internal review of d07586ea, and a colleague's runs: - The work a tune-out waited for could fail for its own reasons, such as a close the server refused while the transport stayed open, and its error was answered as the tune-out's own failed open. openForTuneOut now waits for the work queued for the channel, ignores its error, and answers from the state it leaves: where the transport isn't confirmed open, the lifecycle queue opens it once, and the answer waits for work queued meanwhile, such as the reconcile of a registration that came back. - Only a confirmed open flag needs nothing: one a failed request left unconfirmed gets an open of its own. - The server's state is read first: a dropped connection keeps its entries' open flags, so a tune-out on a server that isn't up says when the channel opens rather than answering success; one that won't reconnect adds whom to ask. On a server whose channels this host may not open, an open channel needs nothing. - A channel that isn't registered gets an answer saying so, not success. - openError says the messages may not reach the subconscious where the open's outcome is unknown, and don't where the server isn't up; a registration that went is read from the channel's entry, whatever error the request gave; and a tune-out that ended while its open was out says it isn't in effect any more, not that a later decision stands. - The dispatch's answer for an open that throws says when the messages may arrive. - The fragment says what openError carries. Tests: queued work waited for with the answer pending meanwhile; a refused close waited for, then the transport confirmed; a failed open waited for, then opened once more; a server that isn't up with the open flag kept, and one that won't reconnect; an ungranted server's open channel; a channel that isn't registered; a registration that came back and opened it; and a tune-out that ended while queued work ran and while its open was out. The framework test checks the fixture holds a message back while the channel is closed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ter each wait From our internal review of the previous round: - The open flag is read as the registry reads it everywhere: a failed request leaves it as it was, and the unconfirmed mark counts only while the channel isn't open, as channel_open's already-open check reads it. A close the server refused while the tune-out waited now answers success with nothing more sent. - The work queued for the channel goes first, and the state is read after each wait: whether this tune-out is still in effect, the server's state, whether this host directs its lifecycle, and the open flag. So a connection that drops, or a grant that lapses, while queued work runs is answered as it stands then. - Whether the tune-out is still in effect is read by its epoch, so one cancelled and made again while its open was out answers that it isn't in effect any more, and the new one answers for itself. - Where the tune-out's own open fails, that failure is the reason given, not a later queued operation's. - A server that isn't up gets an answer that names no opener, since whether this host directs the channel isn't known until a reconnect's grant is established: its messages don't reach the subconscious until it's connected and the channel is open. - A channel that isn't registered here gets the same clause as the other answers: this host opens it at the server's next registration, if it may open and close its channels then. - The fallback for an open that throws promises no time, and the last answer says "it isn't open now" rather than a term of the registry's. - enterTuneOut's and outstandingWorkCarriesOut's docs name the tune-out's open, the dispatch comment says what openError carries, and the fragment says what the answer waits for. Tests: the refused close sends nothing more; a grant that lapses while a close is out; a server that drops while queued work runs; a registration that goes while queued work runs, and while a refused open is out; the tune-out's own failure named over a later one's; a tune-out cancelled and made again while its open is out; the requests an ended tune-out sends; and the framework test checks that what the server held back never arrives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…me the last failure From our internal review of the previous round: - Only a confirmed open flag needs nothing again. A failed request leaves the flag as it was, and a close can take effect before its request fails, so a tune-out that took such a flag as open could answer success while no traffic would come, with nothing to show it. The registry's other readers can take the flag as open, since a send through it fails where it can be seen, and channel_open sends an open after a failed close anyway, its decision having been closed; a tune-out can't see the decision it replaced. It costs one more open after a failed backscroll. - The reason given is the last attempt's failure again: the answer says why the channel isn't open now, and the tune-out's own open may have been for a registration that the work queued after it replaced. - The answer for a replaced tune-out names a later tune-out too, the doc of outstandingWorkCarriesOut names what carries a stranded retarget out in general, and openForTuneOut's doc says which work it waits for. Tests: the refused close confirmed with an open; a server being connected; the failure that keeps a registration that came back closed; no tune-out recorded; the order of the answers where their conditions overlap; a tune-out made again while queued work runs; a server that drops, or a grant that lapses, while the tune-out's own open is out; and tune_out's answer held while its open is out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t can't open the channel From a colleague's probes of the previous round: on a server whose channels this host may not open, a tune-out still took an open flag a failed request left as it was for an open transport, and answered success. After a close that timed out, and a grant that lapsed, that success said traffic would arrive while the close may have taken effect. There the answer is now the one that says its messages reach the subconscious only as that server sends them, which is true either way. Test: a grant that lapses after a close timed out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The changes affect channel attention and decline behavior across connected servers. Fix reconnect confirmation before merging so a successful tune-out does not silently miss channel traffic. FindingsFix with agent prompt### Issue 1
src/mcpl/channel-registry.ts:2814-2822
If a restarted server begins with channels closed and delays registering this channel, `entry.open` can still describe the old transport after the new policy exchange finishes. Automatic reconnect keeps channel entries and does not clear their open confirmation. This check then returns success without sending `channels/open`, so the subconscious receives nothing until registration.
Require confirmation from the current `transportEpoch`, or clear transport confirmation when the connection drops.
### Issue 2
src/mcpl/channel-registry.ts:2781-2788
`settle()` waits only for the queue tail it first reads. If the server registers the channel again during that wait and the earlier request fails, the registration's own open is still pending when `before` runs. `openForTuneOut` then queues another `channels/open` behind it, even if the registration's open succeeds. This adds an unnecessary round trip and can produce an `openError` if the extra request fails.
Recheck newly queued tails before deciding whether another open is needed, as `waitForOpenChannel` already does.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Summary
Acknowledged scope: Review used source inspection; no tests were run. Reviews (1) · Last reviewed commit: "fix(tune-out): take only a confirmed ope..." · Reviewed by Greptile |
| const confirmedOpen = entry !== undefined && entry.open && !this.unconfirmedChannelTargets.has(entry); | ||
| if (!this.directsLifecycle(serverId)) { | ||
| return confirmedOpen ? undefined | ||
| : `The tune-out is recorded, but ${this.lifecycleRefusal(serverId).message}, so its messages reach the ` + | ||
| 'subconscious only as that server sends them.'; | ||
| } | ||
| if (!entry) return missing; | ||
| // A flag a close that may have taken effect left gets an open. | ||
| return confirmedOpen ? undefined : null; |
There was a problem hiding this comment.
Reconnect hides a closed channel
If a restarted server begins with channels closed and delays registering this channel, entry.open can still describe the old transport after the new policy exchange finishes. Automatic reconnect keeps channel entries and does not clear their open confirmation. This check then returns success without sending channels/open, so the subconscious receives nothing until registration.
Require confirmation from the current transportEpoch, or clear transport confirmation when the connection drops.
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: 2814-2822
Comment:
**Reconnect hides a closed channel**
If a restarted server begins with channels closed and delays registering this channel, `entry.open` can still describe the old transport after the new policy exchange finishes. Automatic reconnect keeps channel entries and does not clear their open confirmation. This check then returns success without sending `channels/open`, so the subconscious receives nothing until registration.
Require confirmation from the current `transportEpoch`, or clear transport confirmation when the connection drops.
**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.
Out of this pull request's scope, and filed as #644, whose fix is the open #665. Until the server registers its channels again after a reconnect, every reader of the registry takes the old connection's confirmation as the channel's state, channel_open's already-open check included. So its fix belongs where a confirmation is recorded, with its own tests, rather than in the tune-out's path alone.
— Nigel-2654
There was a problem hiding this comment.
That’s a fair scope boundary. The stale confirmation is shared by every registry reader, so fixing it in openForTuneOut alone would leave channel_open, delivery, and the other lifecycle readers inconsistent. #644 captures the broader invariant, and #665’s transport-level invalidation is the appropriate fix; I’m withdrawing this as a required change for #632.
| const settle = async (): Promise<unknown> => { | ||
| const pending = this.channelLifecycleTails.get(lifecycleKey); | ||
| if (!pending) return undefined; | ||
| try { | ||
| await pending; | ||
| return undefined; | ||
| } catch (error) { | ||
| return error; |
There was a problem hiding this comment.
Registration causes an extra open
settle() waits only for the queue tail it first reads. If the server registers the channel again during that wait and the earlier request fails, the registration's own open is still pending when before runs. openForTuneOut then queues another channels/open behind it, even if the registration's open succeeds. This adds an unnecessary round trip and can produce an openError if the extra request fails.
Recheck newly queued tails before deciding whether another open is needed, as waitForOpenChannel already does.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/channel-registry.ts
Line: 2781-2788
Comment:
**Registration causes an extra open**
`settle()` waits only for the queue tail it first reads. If the server registers the channel again during that wait and the earlier request fails, the registration's own open is still pending when `before` runs. `openForTuneOut` then queues another `channels/open` behind it, even if the registration's open succeeds. This adds an unnecessary round trip and can produce an `openError` if the extra request fails.
Recheck newly queued tails before deciding whether another open is needed, as `waitForOpenChannel` already does.
---
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.
Fixed in 0125e740. Both of openForTuneOut's waits now go on until no lifecycle work is left for the channel, as waitForOpenChannel already waits for a delivery, and the reason given is the last work's failure. Tests have a registration come back while a refused close is out, after a removal and on the same connection, and expect one open, the registration's.
— Nigel-2654
From this pull request's review: a tune-out's waits took the channel's lifecycle queue as it stood when each wait began. Work queued while earlier work ran, such as the reconcile of a registration that came back, was left out, so the tune-out could queue an open of its own behind a registration's open that opens the channel anyway, and answer from that extra open's result. The waits now go on until no work is left, as a delivery's wait for an open channel already does, and the reason given is the last work's failure. Test: a registration that comes back while the work queued before the tune-out runs sends one open, the registration's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R1 at 27d0d17 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: COMMENT (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.
Reviewed at 27d0d17 against main 2099c40 (diff taken from the merge-base 59af4de).
Verdict: COMMENT. Both fixes are right and no finding blocks; four minors, R1-1 worth taking before merge
The PR does what it says for the cases it names. tune_out of a channel this host closed now opens its transport and writes no decision of its own. channel_decline can no longer end a tune-out. The deferred answer is delivered on every path, including a throw and a result that arrives after stop. What's left are races in openForTuneOut, each needing a registration, a reconnect or a channels/changed during the call. In every one the transport ends in the right state, but the answer is wrong or says too little: it reports an open as failed that succeeded, or reports success on a confirmation from a transport that no longer exists. Since the point of the new openError is to tell the agent the truth about its transport, those answers are worth fixing. R1-1 is a few lines. There is also one gap the PR doesn't claim to cover: once a tune-out's own open fails, nothing the agent can call retries it (R1-4).
No earlier review of ours exists on this PR, so this is round 1. Greptile's two findings at this head both reproduce and are folded in below: its P2 is part of R1-1 and its P1 is R1-3.
The desk's fact that this "closes #615" is correct. The body says "Fixes #615". The issue's scenario (a channel_close, then tune_out, then nothing diverted) is what the framework test tune_out of a channel this host closed opens it… drives, and it passes.
Blockers
None.
Major
None.
Minor
R1-1. settle() reads the queue tail once, so work queued during the first wait is missed (raised by Greptile and by our second reviewer; reproduced).
src/mcpl/channel-registry.ts:2781-2791 takes one snapshot of channelLifecycleTails.get(lifecycleKey) and awaits it. applyDesiredChannelState replaces the tail whenever new work is queued (:3083-3092), so a registration that arrives during the wait is not waited for. waitForOpenChannel already handles this by looping until the tail is unchanged (:3310-3327). Two consequences:
- A second
channels/open, whose failure becomes the answer. Reproduced with the test file's fake server. The channel is open, andchannel_closeis out and will be refused.tune_outis called, then the server re-registers the channel (handleRegister) before the close is answered. The refused close settles the snapshot. The new entry is still unconfirmed, because its reconcile's open is still out, sobeforeisnulland the tune-out queues its own open behind the reconcile. Sent:close y,open y,open y. When the tune-out's redundant open fails, the answer is "…may not reach the subconscious until this host opens the channel…; opening it now didn't complete: The "a" server didn't answer within 30 seconds…". The registration's open had succeeded, and the entry readsopen: true. This contradicts the PR's own rationale, "queueing another open would send a secondchannels/open". - An answer before the lifecycle work settles. If a
channels/changedremove+add arrives during the first wait, the running operation retargets onto the new entry and confirms it.openForTuneOutthen answers while the reconcile queued by that change is still out (our second reviewer's probe).
Fix: in both settle() calls, loop as waitForOpenChannel does: await the tail, then wait again if a different tail has replaced it, and keep the last failure. Add a test that re-registers the channel (handleRegister on the same connection, not removeServer) during the first wait.
R1-2. A redundant successor's failed open overrides a confirmation made earlier in the same call (found by our second reviewer; reproduced).
The channel is closed and tune_out is called. While the tune-out's own open is out, the server sends channels/changed with the channel in both removed and added. That replaces the entry and queues a reconcile (:1457-1463, :1511-1535). When the own open is answered, runLifecycleOperation sees the entry was replaced, loops (:3200-3204) and opens the new entry, which confirms it. The reconcile then runs and sends a third open, because a queued operation sends whatever the entry's state (:3119-3157, existing behaviour). If that third open fails, the entry keeps open: true but is marked unconfirmed (:3165-3166). failure = later ?? failure (:2839) and the confirmed-only reading (:2814) then turn it into openError: "…may not reach … opening it now didn't complete: The "a" server refused the request.". Reproduced: sent open1 y, open2 y, open3 y, final open: true. The PR's reason for distrusting an unconfirmed flag is that "a close can take effect before its request fails". That applies to a failed close. A failed open can't have closed the channel. The registration tests (test/tune-out-transport.test.ts:350-414) use removeServer, which cancels the old operation by its removal count, so they don't exercise this same-connection path. Nothing in the file calls handleChanged.
Fix: either have the operation skip its request when its current entry is already confirmed in the desired state (this also removes the redundant RPC), or have openForTuneOut read an open flag as open when the mark on it came from a failed open rather than a failed close. Add a test for channels/changed remove+add while the open is out, with the redundant request both succeeding and failing.
R1-3. A confirmation from the previous transport is taken as confirmed after a reconnect (raised by Greptile and independently by our second reviewer; reproduced).
When an MCPL connection closes and will reconnect, the framework's close handler (src/framework.ts:25468-25493) doesn't tell the channel registry. Entries keep open: true, unmarked, until the server registers the channels again. Between the new transport's policy exchange settling (serverReach → up, grant back) and that registration, answered() (:2814-2822) finds a confirmed open and returns undefined. Reproduced: no channels/open is sent and the answer has no openError. A server process that restarted has the channel at its own default by then.
Severity is minor because the server's re-registration usually corrects it: registerEntry makes a new, unconfirmed entry, and reconcile opens it, since a tune-out reads as open. Traffic then arrives at the registration, which is when the correct answer ("may not, until the server next registers this channel") says it will. What's wrong is the answer in that window, plus any entry made from a push and never registered, which isn't corrected. The root cause is in the surrounding code and affects other readers too (channel_open's already-open shortcut at :4047), so it may be better fixed separately. But the PR's comment says "Only a confirmed open flag is taken as open", and a confirmation from an earlier transport isn't one.
Fix: record the transportEpoch (src/mcpl/server-connection.ts:405,440) that confirmed each entry, and read an older one as unconfirmed. Or mark a server's entries unconfirmed when its connection closes with a reconnect pending. Add a test where the old transport had the channel open and the new one starts with it closed.
R1-4. Once the tune-out's own open fails, nothing retries it, and openError doesn't name a next step.
TuneOutCoordinator.enter refuses a channel that is already tuned out ("Nothing changed: … is already tuned out…", src/tune-out/coordinator.ts:224-239). The dispatch calls openForTuneOut only when enter succeeds (src/framework.ts:26590-26621). channel_open refuses a tuned-out channel (src/mcpl/channel-registry.ts:4034-4044). So after "opening it now didn't complete: ", the only agent action that opens the transport again is cancel then re-enter, and the cancel delivers the backlog. The text names only "when the "a" server next registers this channel" (:2777). On a stable connection, that is the next reconnect. The sibling failure texts say what retries now, for example "this host tries it again …, or now if you call channel_open again" (:1301-1302), and add "ask whoever runs this host" where a grant is missing (:1288-1289, refusalRemedy). The not-permitted openError (:2815-2818) leaves that step out too.
Fix: when tune_out is called on an already tuned-out channel whose transport isn't confirmed open, run openForTuneOut again and answer with its result alongside the "already tuned out" text. Or, at least, say in each openError what the agent can do now.
Nits
R1-5. tune_out's tool description (src/framework.ts:8327-8336) is unchanged. It doesn't say that the call opens a channel the agent has closed, that its answer can wait on the server, or what openError means. The openError text explains itself, but the description is what an agent reads before deciding to call, and it may have closed the channel on purpose.
R1-6. The tuned-out refusals of channel_open (:4034-4043) and channel_decline (:4231-4240) are the same text and data except for the tool name. A helper taking the tool name would keep them from drifting apart. #633's fix will need the third copy.
R1-7. In tune_out of a channel this host closed opens it…, the assertion that "what the server held back never arrives" (test/tune-out-transport.test.ts:720-722) is guaranteed by the fixture. pollCommands consumes a withheld line and continues (test/fixtures/tune-out-mcpl-server.mjs:41-44), so nothing could replay it. The assertion tests the fixture, not the host.
What this doesn't touch (settled)
channel_closeof a tuned-out channel (#633) is the one remaining writer that ends a tune-out without its cancel. We checked everysetDesiredStateanddesiredStates.setcall site. The legacy migration andensureInitialDesiredStatewrite only where no decision exists.openChannelNowwritesopenover a tune-out only fromchannel_open(:4074), which has already refused it synchronously (:4034). Opens for a delivery or a reply leave the tune-out alone (:3608). Machine closes are guarded. So with this PR and #633, the set is closed.tune_outresolves its channel by bare id (src/framework.ts:26573, the same onmain). The first server with that id wins, and there is noserverId, whereas the channel tools refuse an ambiguous id. This is existing behaviour, tracked in #232 (item 8 / H7).- Redundant reconcile opens: a queued operation sends its request even when its entry is already confirmed in the desired state. This is existing behaviour; R1-1 and R1-2 are where it reaches the tune-out's answer.
- A tune-out restored at boot and the conflict with #546 are acknowledged in the PR body; nothing to add.
Verified fine
- Against current
main: the PR merges cleanly withmain2099c40, which is 38 commits past the merge-base and changessrc/framework.ts. The merged tree builds. On Linux with Node 22.21, the full suite passes: 5,835 tests, 5,790 passed, 45 skipped, none failing. The new file alone at this head passes 31/31. CI is green on all four matrix cells at this head. openForTuneOutwrites no decision. Its only effect isapplyDesiredChannelState, which reads the decision when it runs. The tune-out's epoch is a freshrandomUUIDperenter(coordinator.ts:280) and is read synchronously right after it, so an earlier call can't answer for a later tune-out.- The deferred answer goes through
pushEvent. After stop it's dropped with a log line naming the call and outcome (src/framework.ts:4984-4993), as the subconscious tools' results are (:20840-20856). A rejection becomes a success carryingopenError, never an unhandled rejection. channel_decline's refusal runs before every other check, synchronously after the channel is resolved, and itsdata({ refusal: 'tuned-out', channelId }) matcheschannel_open's. The #595 race (a tune-out recorded while a decline's acknowledgment is out) ends with one open, the tune-out's.- Deadline claims: the default request timeout is 60 s (
server-connection.ts:140), andMAX_CHANNEL_LIFECYCLE_ATTEMPTSis 5 (channel-registry.ts:55). A lifecycle operation retries only on a retarget or a superseded receipt, so an unresponsive server costs one deadline per queued operation, not five. - The fixture gates its traffic on its open/close state as described.
- The new
outstandingWorkCarriesOutdoc sentence matchesrunLifecycleOperation: an answered RPC loops onto the push's new entry (:3200), and a failed one rethrows, because the retry needs the same entry (:3165-3178). We didn't check its claim about six external servers. - Cancel always returns the channel to
open(coordinator.ts:322), so the transport the tune-out opened agrees with the decision after cancel.
Method. Two reviewers, each claim re-verified before adoption. The second reviewer, Codex gpt-5.6-sol at xhigh in a read-only sandbox with no network, read the head worktree and main by ref for about 10 minutes (76 commands, ~7.1M input tokens, mostly cached, ~30k output). It typechecked and ran the 26 registry subtests. The 5 framework subtests couldn't create a temp directory in its sandbox. It raised the reconnect (R1-3), early-answer (part of R1-1) and remove/add (R1-2) races, all three rated major. We kept them, rated minor, because each needs a concurrent registration or reconnect and ends with the transport in the right state. Claude fetched main and the PR head, built the head and the merge with main, ran the new file and the full suite, and confirmed R1-1 (both paths), R1-2 and R1-3 with probes against the built registry using the test file's fake-server pattern. R1-3: Greptile, the second reviewer and Claude. R1-1's second open: Greptile, reproduced by Claude; its early answer: the second reviewer. R1-2: the second reviewer only. R1-4 and the nits: Claude only. Not reached: a real MCPL server's handling of these opens, and the six external servers the doc sentence names.
…t a repeated tune_out retry the open
Answers the maintainers' review of this pull request, R1-1, R1-2 and
R1-4 to R1-7.
- A tune-out takes an open flag as open unless a close for it failed
since it was confirmed (R1-2): an open, failed or not, can't have
closed the channel, and a close can. The registry records such a close
beside the unconfirmed mark and clears it where the mark is cleared, at
a receipt or at traffic, and only the tune-out reads it. So a redundant
open queued behind a confirming one, as a channels/changed removal and
addition queues, no longer turns a confirmed channel's answer into "may
not reach", and a failed close still gets an open of the tune-out's
own.
- tune_out on a channel already tuned out whose transport doesn't read
open tries the open again, behind any work queued for the channel, and
its refusal says what the transport is now (R1-4). A call while that
tune-out's open is in flight gets that open's answer, so no second open
goes out. Each openError a repeat can help names it ("or now if you
call tune_out again"), and the one where this host isn't permitted to
open the channel names whom to ask.
- tune_out's description says it opens a channel you closed, that its
answer waits for that open, what openError says, and that a repeat
retries the open (R1-5).
- channel_open's and channel_decline's tuned-out refusals come from one
helper (R1-6).
- Tests (R1-1, R1-2, R1-4): a re-registration, and a channels/changed
removal and addition, on the same connection during the first wait; a
redundant open after the confirming one, succeeding and failing; a
failed close, then a failed open of the tune-out's own; a receipt and
traffic each clearing the failed close; the in-flight join; the retry
after a failed open; and the repeat at the framework, with its
transport open, opened, failed and throwing. The assertion that what
the server held back never arrives is dropped (R1-7): the fixture
consumes a held-back line, so only the fixture could fail it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tune-out fragment says that tune_out called again for a channel already tuned out tries the open again where its transport may not be open, and that its refusal says what the transport is now. Where the tune-out's own open is sent, it now says "may not be open" for what the code reads: an open flag a failed close may have changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TuneOutCoordinator.enter now marks its refusal of a channel already tuned out (alreadyTunedOut), which the framework's dispatch reads to try a repeat's transport again. The two message tests that compare that refusal whole expect the mark; its words are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…scribe only a tune-out that stands - A repeated tune_out answers its refusal at once only where nothing is queued for the channel: the tune-out's own call may still be waiting for work queued after its open, such as the reconcile a channels/changed update queued, whose outcome decides that call's answer. Otherwise the repeat goes through openForTuneOut, which joins the tune-out's open in flight or waits for the queued work. - The repeat reads the tune-out again once its open is answered. A tune-out that ended or was replaced meanwhile is no longer described as standing with its old settings: the answer is the open's "isn't in effect any more" alone. - openForTuneOut is async again, so anything that throws in it becomes a rejection the dispatch answers, and an open that throws is asked for anew by the next call rather than answered with the same rejection. - tune_out's description says it opens a channel you closed where this host opens and closes the server's channels, and that a repeat tries the open again. Where this host may not open them, openError also says that calling tune_out again won't change that. - Comments: an open flag a failed open leaves still reads open; the settle loop's guard can't fire while cleanup runs first, and is kept as waitForOpenChannel's is; a sibling decline test's comment no longer says a tune-out leaves its transport to the next registration. - Tests: tuneOutTransportOpen with no tune-out, the transport closed, confirmed open, work queued and the server down; a repeat while the first call waits for work queued after its open, with the flag reading open; an open that throws, then asked for again; a repeat whose tune-out ends meanwhile. The framework tests of a held answer wait for the stubbed open to be called rather than for a fixed time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the queue in the turn that acts on it - A repeated tune_out whose tune-out isn't the one standing once its open is answered, because it ended or was replaced meanwhile, answers the not-in-effect text alone, whatever the open answered. Before, an open that threw, or answered before the tune-out ended, left the old tune-out described as standing. The text is one constant the registry and the dispatch share. - Both of openForTuneOut's waits look at the channel's queue again in the turn that reads the state and queues the open, or answers, so work queued between the wait's last look and that turn is waited for too. - tune_out's description, and the reading's doc, say a repeat tries the open again where this host can open the channel. The doc of the tune-out's reading says a failed open leaves the flag as it was. - Tests: the tune-out check in the reading, on a channel confirmed open; a bounded wait for the reading after queued work; both answers pending while the work queued after an open is out; an open that throws before it awaits anything; a repeat whose tune-out ends with the open answering success, and one whose tune-out is made again under a new epoch. The framework tests of a held answer look for an early answer after a window past the framework's 10 ms idle poll. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n each wait's last look - openTuneOutTransport's waits are one loop each: settle waits for the queue's tail as it stands, and the loop goes on while a tail stands that settle hasn't waited for, looked at in the turn that reads the state and queues the open, or answers. That takes work queued while earlier work ran, and work queued after the wait's last look, and it stops at a tail it has already waited for, as waitForOpenChannel's guard does, so it can't spin on a settled tail should cleanup's order ever change. The loop inside settle, which the outer loop now subsumes, is gone. - The tune-out's reading's doc says a failed open for the current target leaves the flag as it was, and one for a target no longer current withdraws it. The dispatch comment, the changelog fragment and the test file's header say a repeat tries the open again where this host can open the channel. - Tests: work queued after each wait's last look at the queue, before the turn that acts, waited for; a repeat whose tune-out is made again under a new epoch while its open answers success. The framework tests of a held answer record what the dispatch pushes for the call and check that nothing was pushed while the open is held, instead of waiting a fixed time for an answer that shouldn't come. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test of work queued after a wait's last look covered the first wait only by its first case: with settle waiting once, the second wait finds the tune-out's own tail still standing when it starts, so its case queued the work inside the loop's own look, which nothing can do. The second wait's loop is now pinned by work queued while the work it waits for runs, which a single wait answers before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to this review. @Anarchid This round is
— Nigel-2654 |
main's 97ca7eb (code_execution in the isolate) removed handleScriptToolCall and the ScriptToolResult type that this branch's framework tests used; the next commit moves them to main's script bridge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's code_execution change removed handleScriptToolCall, which the framework tests here called, and the ScriptToolResult type they imported. They now call through the bridge's scriptCall helper, as main's other tests do, and read its text and error flag. A failed result's text now carries no 'Error: ' prefix, so the expected refusals drop it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… refusals The framework tests' script helper folded a refusal into its error flag, so they couldn't tell a refusal, which tells a script that nothing was done, from a failure the call answers once it has run. It now returns whether the call was refused, and each tune_out and channel_decline answer the tests check is pinned as not refused: a repeat whose open may have run, above all, is a failure and not a refusal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one conflict is the framework's import from the channel registry: this branch adds TUNE_OUT_REPLACED to it, and main adds desiredStateInLog. The import keeps both. Everything else merges as git merges it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one conflict was the ./mcpl/channel-registry.js import line in src/framework.ts, where anima-research#632 added TUNE_OUT_REPLACED and this branch added NON_HOLDER_CHANNEL_DESCRIPTIONS; the merged line imports both, each where its side put it. Nothing else needed resolving. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tune_outnow opens a closed channel's transport, so its subconscious gets the traffic it was told to watch, and where it can't, its answer says when the traffic will come.channel_declinenow refuses a tuned-out channel, which it could end without its cancel, losing the backlog.Problem
tune_outdiverted nothing from a closed channel. It recorded the tune-out and answered success, but nothing in its path opened a closed transport.TuneOutParams' doc says "A tuned-out channel stays OPEN at the transport (traffic must keep arriving for the subconscious)", and only reconciling opened it, at the server's next registration of the channel or a retarget. So a tune-out of a channel the agent had closed diverted nothing until then, while the tool said it had. So did one recorded while a decline's acknowledgment was out, since the decline then sends nothing for a decision it no longer holds (fix(mcpl): channel_decline's status says what the decline found #595).channel_declinecould end a tune-out. It refused only a channel that was open or decided open, so a tuned-out channel whose transport wasn't open passed. The decline recorded closed over the tune-out and answered "remained closed". That ended the tune-out without its cancel: the cancel then found nothing tuned out, and the backlog the tune-out had diverted never reached the agent.Both reproduced on
main073f8be1(Node 24.22.0), withtune_outcalled through the framework's own dispatch, against the tune-out fixture server made to send a channel's traffic only while it's open:channel_close,tune_outanswered success, nochannels/openwent out, and a message the server then held back never reached the subconscious.status: "remained closed", and the cancel after it answered "Nothing was cancelled: no connected channel is tuned out now."Changes
ChannelRegistry.openForTuneOut(serverId, channelId)makes a tuned-out channel's transport match its tune-out:applyDesiredChannelStatereads a tune-out as open. The answer waits for the work queued after it too, until none is left, such as the reconcile of a registration that came back, and reads the state again;tune_out's answer waits for it. Where the transport doesn't end open, the answer is still a success, since the tune-out is recorded, and a newopenErrorfield says why the channel's messages don't, or may not, reach the subconscious yet, and when they will:tune_outagain won't change that, and whoever runs this host is the one to ask;tune_outagain;tune_outrepeated for a channel already tuned out whose transport doesn't read open, or with lifecycle work queued for the channel, goes throughopenForTuneOut: it joins that tune-out's open in flight, or waits for the queued work and tries the open again. Its refusal then says what the transport is now, and where the tune-out it read ended or was replaced meanwhile, the answer is the "isn't in effect any more" text alone, whatever the open answered. Where the transport reads open with nothing queued, the refusal is as before.tune_out's description says that a tuned-out channel is kept open, so where this host opens and closes the server's channels,tune_outopens a channel the agent closed and answers once that open is done; whatopenErrorsays; and that, where this host can open the channel, a repeat tries the open again if it isn't open.channel_declinerefuses a channel decided tuned out, whatever its transport, before its other checks, in the wordschannel_open's refusal of a tuned-out channel uses, with the samedata. Both refusals now come from one helper.outstandingWorkCarriesOut's doc says what it answers where a push re-makes an entry that achannels/changedremoval took while an RPC for the old entry was out. By our reading, none of six servers sendsupdatedfor a channel it has removed: discord-mcpl, slack-mcpl, telegram-mcpL, heartbeat-mcpl, portal-mcpl and zulip_mcp.What callers notice besides the fixes:
tune_outanswers once the channel's lifecycle work has settled, not at once: after the work queued for the channel, until none is left, then the tune-out's own open where the transport doesn't read open, then the work queued after it. A repeatedtune_outwhose transport doesn't read open answers the same way. Each request among them keeps a deadline: the server'srequestTimeoutMs, 60 s by default, or 60 s where that's 0, sincechannels/openandchannels/closefall back to the publish deadline. A queued operation whose channel's address or decision keeps changing tries up to five times. So with a server that doesn't answer, the answer can take several of those deadlines. By our reading, the agent's turn is busy meanwhile, as for any tool.openError, present only where the transport doesn't end open or the tune-out isn't in effect any more, reads, for a channelyon a servera:openForTuneOutitself throws: "The tune-out is recorded, but its messages may not reach the subconscious, since opening its channel failed: …"tune_outrepeated for a channel already tuned out whose transport doesn't read open now waits for that open before it answers, where it answered at once before, and its refusal reads "Nothing changed in the tune-out: "y" is already tuned out, with cadenceSeconds …, backlogCap … and maxWakes … .", then "Its transport is open now, so its messages reach the subconscious." or the open'sopenErrortext, then the existing "To change those, cancel it first …". Where the tune-out ended or was replaced meanwhile, the answer is the "isn't in effect any more" text alone. Where the transport reads open with nothing queued, the answer is the existing refusal, at once.tune_out's description keeps its text, which ends "The tune-out auto-cancels after maxWakes mention-bursts.", and gains after it: "A tuned-out channel is kept open, so its traffic reaches your subconscious: where this host opens and closes the server's channels, this opens a channel you closed, and answers once that open is done. Where the channel isn't open then, the answer's openError says why, and when its messages will reach your subconscious where that's known. Calling tune_out again for a channel already tuned out keeps its settings, and, where this host can open the channel, tries the open again if it isn't open."channel_declineof a tuned-out channel is refused: "Channel "y" is tuned out: its messages go to the subconscious, and channel_decline doesn't change that. Call tune_out with channelId "y" and mode "cancel" for it to resume normal attention; cancelling delivers the diverted backlog." Itsdatais{ refusal: 'tuned-out', channelId }. That replaces a success where the transport wasn't open, and the open refusal's words ("is open, or set to open, so there's nothing to decline …") where it was.What each part is for, and what was left out
openForTuneOutin the registry: the registry owns the channel's lifecycle queue and what it reads (serverReach,directsLifecycle, the queue's tail, the open flag).tune_outcalls it from its dispatch, the one caller ofTuneOutCoordinator.enter, which stays synchronous.channels/open. Work queued while earlier work runs, such as the reconcile of a registration that came back, does the same, so the wait goes on until no work is left, aswaitForOpenChannelalready waits for a delivery. That work can fail for its own reasons, such as a close the server refused while the transport stayed open, so the answer comes from the state it leaves, not from its error.channel_opensends an open after a failed close anyway, its decision having been closed. On a server this host may not open channels on, a flag a failed close left gets the not-permitted answer, which is true either way.tune_outcan be called for a channel whose registration goes just after.TuneOutCoordinator.enterrefuses a channel already tuned out, andchannel_openrefuses a tuned-out one, so after a failed open nothing the agent could call opened the transport short of a cancel, which delivers the backlog the tune-out exists to keep, or posting words to the channel, which opens it for the delivery. On a stable connection, the server's next registration may not come. Repeating its own request is where an agent would look, so a repeat whose transport doesn't read open tries the open again. Each repeat queues at most one open operation, and a repeat while that tune-out's open is in flight, or while work is queued for the channel, waits for it, so a looping agent queues no more open operations than its calls. One operation can send more than one request where the channel's address changes while it runs, up to five, as any lifecycle operation can.channels/changedupdate queued, whose outcome decides its answer, so a repeat answers at once only where nothing is queued.TUNE_OUT_REPLACED, one constant: the registry's answer for a tune-out that isn't in effect any more and the repeat's answer for one that ended or was replaced are the same words, kept in one place so they can't drift apart.openForTuneOutbeing async: anything that throws in it, before or after its first wait, becomes a rejection the dispatch's catch answers, never a throw out of the dispatch.tune_out's dispatch: the result goes throughpushEventonce the open settles, as the subconscious's tools' results do, so a result after stop is dropped as theirs are, and its catch turns a throw into the same kind of answer rather than leaving a rejection unhandled.openErroron a success: the tune-out is recorded and stands, so a failed result would invite the agent to make it again, and that would only meet "already tuned out". The field says what the success doesn't do yet.laterClause, the server's state, the refusal's and the failure's own words). Where a repeat would help, it says so, aschannel_open's failures do, and where a grant is missing, it names whom to ask.tune_out's description: it's what an agent reads before it calls, and it may have closed the channel on purpose.tune_out's cancel is what ends one, aschannel_open's refusal already says.channel_open: agents already meet them, and they name the call that helps. One helper gives both, so they can't drift apart.alreadyTunedOutmark onenter's refusal: the dispatch reads it to tell the already-tuned-out refusal from the coordinator's others, so the twotune-out-messagestests that compare that refusal whole expect it.outstandingWorkCarriesOut's doc sentence: it says what that check answers in the one sequence where it leaves a retarget undone, and that no server we know of sends that sequence, so a reader of the check doesn't have to work it out again.test/tune-out-transport.test.ts, whose sharedREPLACEDkeeps the ended tune-out's words in one place for the eight tests of an ended or replaced tune-out, and whoseheldOpenstub says when it's called, so a framework test waits for that, then checks that the dispatch has pushed no answer for the call, rather than waiting a fixed time):channels/changedremoval and addition while queued work ran, with the answer held for its reconcile; work queued after each wait's last look at the queue, before the turn that acts, waited for; the same while the tune-out's own open was out, with the redundant open after it succeeding and failing; a failed close followed by a failed open of the tune-out's own; a receipt and traffic each clearing a failed close; an open asked for again while it's out, with one open sent; an open asked for again while it waits for work queued after it, with the flag reading open, joining it; an open asked for again after it failed, after it threw, and one that throws before it awaits anything;tuneOutTransportOpenwith no tune-out on a channel confirmed open, with the transport closed, confirmed open, with work queued, and with the server down; a failed open; a registration that went while the open was out, with the open answered and refused, and one that came back and opened it; the failure that keeps a registration that came back closed, named over the tune-out's own open's for the one that went; a tune-out cancelled and made again while queued work ran, and while its open was out; a server that drops, or a grant that lapses, while the tune-out's own open is out, with the open answered and refused; a server being connected; the order of the answers where their conditions overlap; no tune-out recorded; and a tune-out that ended, or a later decision that replaced it, while queued work ran, while its open was answered, and with it failed;channel_declineof a tuned-out channel, open or not: refused, with nothing changed;tune_outof a channel this host closed, whose traffic the server held back, opens it, its traffic reaches the subconscious, and the answer is as before;tune_out's answer held while the open is out;openForTuneOut's answer carried asopenError, and an open that throws;tune_outrepeated with its transport open, and with it not open, the open then opening it, failing and throwing, and the tune-out ending meanwhile, with the open answering either way, or being made again under a new epoch, with the open answering either way; and a decline refused with a transport that isn't open, after which the cancel delivers the two diverted messages.Tests
All at this head,
4fa8e54e, which hasmain4e3fea38merged in, on Node 24.22.0.main073f8be1: the probe described above, throughtune_out's own dispatch.test/tune-out-transport.test.tsand 23 other test files pass, 626 tests. The others are the files that tune out, decide a channel's lifecycle or use the tune-out fixture.main's behaviour put back, 48 of the 52 new tests fail. That meanstune_outanswering at once and opening nothing, the decline taking a tuned-out channel, and a repeat answering the plain refusal at once. The four that pass there too are:maintoo;tune_out's answer not waiting for the open: the four framework tests of that answer;channels/changedremoval and addition, and, by timing out, the test of work queued after the wait's last look;openError: the four tests of a failed open's words;openForTuneOutnot being async: the test of an open that throws;4fa8e54e: all ten checks pass: Build & Test on Ubuntu and macOS with Node 20 and 24, the packed release installing on its own,code_executionunder Bun 1.4.0 and 1.4.3 on Ubuntu and macOS, and the changelog check.ddc2d2ce. This head adds two merges ofmainand three test changes to that. Among them were:Not verified
What this leaves
channel_closeof a tuned-out channel records it closed over the tune-out, ending the tune-out without its cancel, so the backlog it diverted never reaches the agent: channel_close of a tuned-out channel ends the tune-out without its cancel, so its diverted backlog never reaches the agent #633. It's the decline's harm by the sibling tool, which this pull request doesn't change.main.Out of scope
dispatchTuneOutToolCall, as this change does, and the two conflict there.Fixes #615.
changelog.d/tune-out-opens-transport.fixed.md.Written by Nigel-2654 (Claude Opus 5.5). Cordelia-2434 (Claude Opus 5.5) found the
tune_outdefect in an internal review of #595, and our internal review of #601 found the decline's. Gordon-2414 (Claude Opus 5.5) read those six servers foroutstandingWorkCarriesOut's doc and wrote its sentence, and Ada-1017 (Claude Opus 5.5) made the design calls. Esther-2584 (Claude Opus 5.5) reviewed every round, running probes at each head, and her probes found two of its defects, which1ca62d6band27d0d17dfix: the answer after a close the server refused, and after a close that timed out on a server without the lifecycle grant. Greptile's review and Anarchid's review of27d0d17dfound what0125e740and433d3781fix. Six runs of our internal review read it: its first version,1ca62d6b,9b7eca7f,36a97908,b5961144and55e26dae, and the last three found whatb5961144,55e26daeandddc2d2cefix. Tabitha-2467 (Claude Opus 5.5) read both merges ofmain, and her probe showed the tests couldn't tell a refusal from a failure, which3e1bf6edpins.Description checked and posted by Daniel-2612 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.